一括処理と並列化によってバッチ処理のメール送信を100倍高速にした話

1. はじめに

みなさん、こんにちは! Wallet Stationのバックエンドチームの荒川です。

2023年4月にインフキュリオンに新卒入社して、無事上期を終えることが出来ました。

上期は主にバッチに関する以下のタスクに取り組み、結果としてバッチのメール送信処理を100倍程度高速化することが出来ました。

具体的には、メールの一括送信リクエストと並列化を行うことで、3万件規模で9分かかっていたメール送信処理を5秒まで短縮することができました。

上期に取り組んだタスク:

  1. 残高管理のマイクロサービスをモジュラーモノリスへ移行したことで修正が必要になったバッチのリファクタリング
  2. 上記のバッチの性能試験/改善

そこで、本記事では「バッチのメール送信処理を高速化する際に立てた改善方針・改善フロー・手法」について紹介したいと思います。

2. 背景

Wallet Stationとは、自社プロダクトに決済機能を埋め込むことができるプラットフォームであり、日本コカ・コーラ株式会社が提供するアプリ「Coke ON」や株式会社ツルハホールディングスが提供する「HAPPAY」などに導入されています。

infcurion.com

infcurion.com

ありがたいことに、Wallet Stationを導入してくださる企業様は年々増えているのですが、一方で求められる性能目標も上がってきました。

例に漏れず私が携わっているバッチ処理についても、以前よりも高い性能目標が求められるようになりました。

私が携わっているバッチ処理は「〇〇Payの管理者がCSVファイルで予約したバリュー/ポイントを、〇〇Payの利用者に一括で付与するバッチ」であり、主に付与処理とメール送信処理の2つの処理で構成されています。

上期はこのバッチの性能試験/改善に取り組むことになったのですが、抜本的なアーキテクチャの変更をして高速化できるほど、今回の性能改善/試験に割ける時間は長くありませんでした。

3. 性能改善の方針

そこで今回は必要十分な性能改善をスムーズに実施するために3点の方針を立てました。

性能改善の方針:

  1. 保守性(可読性/変更容易性/テスタビリティ)が下がる高速化は必要以上にしない
  2. 「推測するな計測せよ」を徹底する
  3. 大規模データ作成のための自動化スクリプトを作成して、気軽に改善/計測しやすくする

3.1. 保守性が下がる高速化は必要以上にしない

1点目は、「保守性(可読性/変更容易性/テスタビリティ)が下がる高速化」は必要以上にしないことです。

高速化の中には、改善前のコードに比べて複雑な実装になってしまう割にそこまで高速化に寄与しないものがあります。

この類の高速化を不必要にしてしまうと保守にかかるコストが上がってしまう割にコードは速くないというコスパが悪い状態に陥ることがあると思います。

そのため、今回の性能改善では性能目標を必要十分な水準で満たしており、なおかつ保守性が下がらないコスパの良いチューニングを優先的にすることにしました。

3.2.「推測するな計測せよ」を徹底する

2点目は、「推測するな計測せよ」を徹底することであり、計測値を根拠に重要度の高い改善ポイントを洗い出して、効率よく性能改善に取り組めるように心がけました。

3.3. 大規模データ作成のための自動化スクリプトを作成して、気軽に改善/計測しやすくする

3点目は、「大規模データ作成のための自動化スクリプトを作成する」ことであり、ローカルで改善~計測のサイクルを高速に回して性能改善を効率よく進めようと心がけました。

また今回作成したスクリプトについてはチーム内で共有して、今後性能改善を行う人が気軽に性能改善/計測に取り組めるようにしました。

以上の3点の方針を立てた上で、今回の性能改善は以下のフローに従って実施しました

性能改善のフロー:

  1. バッチの処理を意味のあるまとまりでTasklet化することで処理時間を計測しやすくする (その際単体テストを拡充しておく)
  2. ローカルで中~大規模データを作成するスクリプトを作成(sql, sql文生成スクリプト)
  3. ローカルでバッチを実行 / ボトルネックの特定
  4. 改善策の立案 ~ 実装 ~ ローカルでの計測
  5. dev環境でより正確に計測

4. 発見した課題

先述のフローに従ってローカルでバッチを実行したところ、全体の実行時間のうち多くの時間がメール送信処理に費やしていることがわかりました。

実験1(改善前)
ユーザ数 10,000人
付与件数 30,000件
実行時間(付与処理) 約3分
実行時間(メール送信処理) 約9分
実行時間(総計) 約12分

さらに、この結果をもとにメール送信処理のコードを静的に解析したところ以下の課題点を発見しました。

発見した課題:

  1. メール処理の中でメールサービスへのAPIリクエストをしている箇所に多くの時間を使っていた
  2. メール処理に関わるレコードの挿入/更新が1件づつ行われていた
  3. メール本文の作成、メール送信リクエストが全て直列で繰り返し処理されていた

5. 改善手法

以上の課題点に関して、以下の通り一括送信と処理の並列化を実施しました。

改善手法:

  1. メール送信を1件づつ送信リクエストしていたのを1000件単位で一括で送信リクエストするように変更
  2. RDBへの挿入/更新は1件づつではなくbulk insert/updateする
  3. Streamで処理している箇所をParallelStreamで並列化

まず1点目の一括送信に関しては、SendGrid APIPersonalizationに複数の宛先とメール本文をそれぞれ指定した上で、1000件単位で送信リクエストをするようにしました。

2点目に関しては、RDBへの挿入/更新をまとめてbulk insert/updateするようにしました。

簡単なことですが、JOOQの実験によると1万件のデータをfor文でinsertした場合とbulk insertした場合では1000倍ほど速度に差が出るとのことですので、忘れずにやっておきたいですね。

3点目については、集合に対してStreamで処理している箇所をParallelStreamに置き換えて並列化しました。

3点目に関してはおまけ程度ですが、ほぼ実装が変わらないので手をつけておきました。

6. 改善結果

先述の改善を行った結果、メール送信処理にかかる時間は3万件規模では9分から5秒へ、30万件規模でも31秒で処理できるようになりました。

実験1(改善前) 実験2(改善後) 実験3(改善後)
ユーザ数 10,000人 10,000人 100,000人
付与件数 30,000件 30,000件 300,000件
実行時間(付与処理) 約3分 約2.25分 約1.16時間
実行時間(メール送信処理) 約9分 約5秒 約31秒
実行時間(総計) 約12分 約3分 約1.1時間

ローカルでの計測にはなりますが、結果として30~100万件規模だと見込みで数時間かかる処理が数十秒~1分程度で終わるようになり、バッチ用のインスタンスの稼働時間の減少に伴うインフラコストの減少が期待できるようになりました。

なお今回は実施しませんでしたが、他には以下のような高速化の手法があります。

やらなかったこと:

  1. 全てをプロシージャで処理すること(今後もできれば避けたい)
  2. メール送信リクエストを非同期にして並列化する(今後の展望)
  3. メールサービスを変更する(今後の展望)

2, 3点目の手法についてはさらなるコストダウンが望めそうなので今後取り組んでいきたいことになります。

一方で1点目の全てをSQLのプロシージャで処理することでDBの接続/切断のオーバーヘッドを減らす手法については、以下のようなデメリットがあり保守性が下がる割に大きな高速化が望めるわけでもないと判断して今後も避けられればと思っています。

1点目の手法のデメリット:

  1. ドメインルールがSQLに流出する
  2. 処理が肥大化することでモック化が困難になる
  3. バッチ処理のジョブレベルの統合テストのテストケースの数が増加する

7. まとめ

今回は時間が無い中でもメール送信を高速化できるTIPSについてご紹介しました。

既にユーザ数が大規模であるサービスでは当たり前のように行われていることかもしれませんが、ユーザ数が急拡大したサービスでは意外に見落とされがちなポイントだと思いますので、ぜひ取り組んでみてほしいと思います。

また、今後は「メール通数が増えてきたことで現在のメールサービスの料金が割高になっている懸念」がありますので、そちらの解決のために別のメールサービスの検証等に取り組んでいきたいと思います。

以上

参考