2023-01-11月間数百万通のメール送信サービスをIPウォームアップしつつ切り替えたら到達率が向上した話バックエンドはじめにこんにちは。コミューンでスクラムマスターをしているまつむらと申します。今回はコミューンで私が取り組んだ技術課題のなかから「メール送信システムのリプレース」について記載させていただこうと思います。キャッシュ有効期限のコントロール結果やらかしまとめ背景コミューンは、BtoBおよびBtoC向けにコミュニティを作成できるプロダクト「commmune」を提供しています。アカウントを変更することになりました。当時のメールの送信数は月間数百万通ほどあったため、IPウォームアップ(次節で簡単に説明)を行う必要がありました。本記事のテーマとは逸れるため、ごくごく簡単な説明に留めさせていただきます。メールサービス(Gmailなど)が施している迷惑メール対策の一つに「メール送信者のIPアドレスの評価(IPレピュテーション)」があります。迷惑メール対策の一つに「送信元のIPアドレスの信頼性」があります。一度もメールを送ったことがないIPアドレスの信頼性は、良くも悪くゼロです。この状態で大量件数のメールを送信すると、謎IPアドレスからのメールバラマキと判断され、スパム扱いされるリスクが高くなります。お客様の受信箱にメールが届かなくなってしまいます。具体的には、1日ごとに以下のように送信数を増やしていきます。移行計画のための準備IPウォームアップを効果的に行うために、commmuneで運営されているコミュニティにおいて以下の分析を行いました。IPウォームアップの序盤において、開封率やクリック率の高い人に対してメールを送ることができれば、より効果的にIPウォームアップが可能なのではないか?と考えました。調査の結果、BtoB向けのコミュニティは開封率が高く、BtoC向けのコミュニティでは開封率が低い傾向がありました。同じコミュニティにおいても、メールの種別によって開封率に差があることがわかりました。ドメイン乖離度合IPアドレスの評価はメールサービスごとに決まります。Gmailの評価を高めることができてもYahoo!メールの評価はゼロのままです。そのため、コミュニティに所属するユーザーのメールアドレスのドメインの構成比率に注目しました。コミューンのサービス全体の構成比率と、コミュニティごとの構成比率がどの程度乖離しているかを指標としました。調査の結果、全体のドメイン比率(調査当時)は以下のようでした。BtoC向けコミュニティだと乖離が小さく、またBtoB向けコミュニティだと企業ドメインが多く乖離が大きい傾向でした。上記指標についてどんなに優秀なコミュニティだとしても、メール送信数が少ないとIPウォームアップが不十分になります。そのため、ある程度メールを送信していることも考慮することにしました。調査の結果、BtoC向けの参加者が多いコミュニティがメール送信数が多い傾向にありました。上記3つのパラメータからコミュニティごとの優先度を決定し、IPウォームアップのスケジュールを作成しました。前節で作成した移行計画を実現するため、おおまかな機能要件を以下の通りとしました。コミュニティのIDおよびメールの種別から、新アカウントから送信する確率(0以上1以下)を返すコミュニティIDは「特定のコミュニティIDを指定」と「全コミュニティ」があるメール種別は「投稿通知メール」「ダイジェストメール」「全通知メール」「全メール」がある確率情報はデータベースに保存されるメール送信件数が多いため、DBのデータをキャッシュする必要があるメール種別は以下の通りです。その他のメール通知メールとは、例えばコミュニティ内で自分がメンションされたり自分の投稿が「いいね」された際などに送られてくるメールダイジェストメールとは、コミュニティで起こったことのサマリーについて週に1度送られてくるメールその他のメールとは、ユーザーがメールアドレスを登録した際に有効なメールアドレスかどうかを検証するための認証メールなどを指します。ソースコードこれに基づき以下のような実装を行いました。実際に使用したソースコードからは一部改変しています。コミュニティIDやメール種別をキーとしたMapで保持することができない。期間を通して同時に有効になる条件はせいぜい数十である。ゆえに有効なレコードを全件保持しておく。データベースにアクセスするもの。初期値(サーバ起動直後)は、キャッシュ有効期限を現在時刻にしておく。することで初回呼び出し時にはキャッシュが利用されない。スコアの最も高いレコードに書かれている確率値を採用する。対象となるコミュニティID。最終的には全コミュニティ・全メールが新しいアカウントから送信されます。それを表現するため、条件レコードは「全コミュニティ」「全メール」という条件が含まれることになります。コミュニティIDは「特定のコミュニティIDを指定」と「全コミュニティ」があるメール種別は「投稿通知メール」「ダイジェストメール」「全通知メール」「全メール」があるそのため、DBから直接条件レコードを引いてこようとすると条件文が非常に煩雑になります。キーとして持つ必要があり、キャッシュ量が多くなります。これを解決するために、逆に条件レコードは先にすべてDBから取得しておき、プログラム側で絞り込みを行う戦略を取りました。同時に有効になる条件レコードはせいぜい数十件であったため、キャッシュ量も少なくて済みます。どこのコミュニティ・どのメール種別であっても、初回1回のみのアクセスで済むためDBアクセス数も減らすことができます。なるため、複雑なSQLを書く必要もなくなりました。厳密に言うとロック等をしていないため、キャッシュが無効な状態で同時にリクエストが合った場合、複数回のDBアクセスになる可能性はあります。今回のケースで問題になることはありません。乱数を使うと再現性が乏しくなるため、テストが困難になりがちです。そのため、関数を外部から注入する戦略を取りました。(そこまで厳密なランダム性を求められていないため)単体テストのためのインスタンス化においては以下のような関数を注入し、乱数を制御しました。回目の乱数を制御することができ、期待値を完全にコントロールすることができました。キャッシュ有効期限のコントロール今回の要件は、ある時刻で挙動を切り替えたいがキャッシュを利用したいというものでした。そのため、キャッシュの有効期限の設定値がいかに長かろうと、条件レコードの有効期限が来たら強制的にキャッシュを無効化する仕組みを作成しました。副次的に、動作確認時はキャッシュを短くしたい、実稼働時は長くしてもいい、ということを解決することができました。キャッシュの有効期限を長い設定にしていても、動作確認中は有効期限を数分刻みにしたレコードを入れておくことで、数分で設定を切り替えることができ、キャッシュが切れるまで待つ(あるいはサーバを再起動する)ことなくたくさんの条件を変えながらの動作確認が可能となりました。上記切り替えのための実装により、IPウォームアップしながら少しずつメール送信を新しいアカウントに切り替えていくことができました!また到達率を向上させることにも成功しました!やらかしキャッシュ方針もうまくいき、単体テストもしっかり書くことができたまではよかったのですが、ひとつやらかしがありました。問題はダイジェストメールで発生しました。ダイジェストメールは性質上100通まとめてバッチ的にメール送信を行っているため以下のような実装を行いました。oldAccountClient.send(oldAccountMails)メールを分割させたところまでは良かったのですが、分割前のメール配列を送信クライアントに渡してしまいました。そのため新旧両方からのメールが送信されてしまい、同じメールが2通届いてしまうという障害を発生させてしまいました。単体テストや型エラーでは検知できない部分から障害が発生してしまうことを痛感する結果になってしまいました。実環境での動作確認は非常に大切であり、ダイジェストメールは性質上動作確認が困難だったため、不十分な確認しかできていなかったことが反省点です。まとめメールアカウントの切り替えにあたり、確率的に分散させる実装を試してい見てうまくいきました。乱数をモックする戦略により、単体テストを書くこともできました。それを利用するところでバグを仕込んでしまったというところは反省点となりました。(1)引用をストックしましたストック一覧を見る閉じる引用するにはまずログインしてくださいログイン閉じる引用をストックできませんでした。再度お試しください閉じる限定公開記事のため引用できません。