代表的な面接トピック

マルチチャネル通知システムの設計

システム設計難しい
Offer.cc 編集チーム公開日 更新日

質問

モバイルプッシュ、SMS、メール、アプリ内メッセージに対応するマルチチャネル通知システムを設計してください。OTPなどのトランザクション通知を低レイテンシでプロバイダに送信し、大規模なマーケティングキャンペーンを処理するとともに、ユーザー設定、重複処理、プロバイダクォータ、順不同のコールバック、リカバリを適切に管理する必要があります。

問題と適用シナリオ

認証、注文、ソーシャル、マーケティングの各サービスで利用される共通通知プラットフォームを設計します。モバイルプッシュ、SMS、メール、アプリ内メッセージをサポートします。トランザクションメッセージにはOTPや決済結果が含まれ、一括(バルク)メッセージにはイベントのリマインダーやマーケティング配信が含まれます。呼び出し元は即時送信または予約送信を選択できます。ユーザーはチャネルおよび通知タイプごとにオプトアウトが可能であり、マーケティングメッセージは各ユーザーのタイムゾーンのサイレント時間帯(Quiet Hours)を尊重する必要があります。

面接における前提条件は以下のとおりです:

  • システムは1日あたり10億件のチャネル配信タスクを生成します。SMSとメールの双方で送信される1件のビジネス通知は、2件のタスクとしてカウントされます。
  • 平均スループットは毎秒 1,000,000,000 / 86,400 ≈ 11,600 タスクです。キャンペーン時のピークは平均の20倍、すなわち毎秒約232,000タスクと見積もります。
  • 永続的なAPI受け付けの可用性目標は99.99%、p99の受け付けレイテンシは200ミリ秒未満とします。
  • OTPの場合、受け付けからチャネルプロバイダへの送信までのp99時間は2秒未満とします。マーケティング処理は15分間にわたって平準化される場合があります。どちらの目標も、デバイスが実際にメッセージを表示するタイミングを保証するものではありません。
  • 1つの配信タスクの永続エンベロープが平均1 KBの場合、インデックス、レプリカ、配信確認(レシート)、圧縮を考慮する前の論理書き込みは、1日あたり約1 TB、30日間で約30 TBになります。

これらの数値はパーティショニング、バックログ、分離の判断基準となるものであり、プロバイダの性能を主張するものではありません。テンプレート作成、オーディエンス選定アルゴリズム、請求処理、A/Bテストはスコープ外です。この問題は、シニアバックエンド、プラットフォーム、およびシステムデザインの役割を対象としています。その中核となる課題は、異なるセマンティクスを持つ外部チャネル全体で、優先度、復旧可能性、および信頼性の高い可観測性を維持することです。

面接官が評価しているポイント

第1のシグナルは、候補者が「送信成功」をどのように定義しているかです。APIの202は、プラットフォームが処理を永続的に受け付けたことを意味します。プロバイダの成功レスポンスは通常、プロバイダがリクエストを受け付けたことを意味します。デバイスの受信、OSの表示、ユーザーの開封はそれ以降の状態です。FirebaseのSendsメトリクスはメッセージがキューイングされたかAPNsに渡されたことを意味する場合があり、一方でAPNsの成功レスポンスはリクエスト自体を記述します。これらすべてを単一の delivered に集約してしまうと、運用メトリクスとインシデント対応の双方が損なわれます。

第2のシグナルは、優先度がリソースの分離によって担保されているかです。優先度フィールドを持つ共有キューであっても、5,000万人のユーザーに対するキャンペーンによって、そのディスク、コンシューマ接続、プロバイダクォータが占有される可能性があります。優れた設計では、トランザクション用キューとバルク用キュー、コンシューマ、保護されたチャネルバジェットを分離しつつ、バルクトラフィックがアイドル容量を借用できるようにします。OTPトラフィックにも上限が必要であり、「重要(critical)」というラベルがあらゆる保護策を回避してはなりません。

第3のシグナルは、配信保証に関する正確な言葉遣いです。標準的なキューはタスクを複数回配信する可能性があるため、ワーカーは安定した delivery_id のもとで冪等に処理を行う必要があります。プロバイダがリクエストを受け付けた後にネットワークタイムアウトが発生した場合、内部の重複排除によってその外部の副作用を取り消すことはできません。プロバイダ側の冪等送信APIがない場合、プラットフォームは重複の確率を低減させ、不確定な結果を記録し、メッセージのリスクに基づいてリトライまたはフェイルオーバーを選択できますが、エンドツーエンドのExactly-Once(厳密に1回)配信を約束することはできません。

第4のシグナルは、完結した障害ループ(クローズド・障害ループ)です。恒久的な障害はリトライを停止し、妥当な場合は無効なエンドポイントを無効化します。HTTP 429、5xxレスポンス、およびネットワーク障害にはジッター付きバックオフを使用します。期限切れのタスクは終了します。デッドレターの再生では元の delivery_id を維持し、有効期限とオプトアウト状態を再確認します。レシートは重複したり順不同で到着したりする可能性があるため、システムは生イベントを保存してから、チャネル固有の遷移を経て現在の状態を導出します。

回答前に明確にすべき質問

  • ビジネスにとって「配信完了(delivered)」とは何を意味するか? オフラインのデバイス、通信キャリア、ユーザー権限はプラットフォームの外部にあるため、制御可能なOTPの目標はプロバイダの受け付け時点で終了すべきです。ビジネスが「既読」を要求する場合は、サポートされているチャネルのレシートまたはクライアントイベントが必要となり、その対象範囲を明らかにする必要があります。
  • 通知タイプと優先度は誰が割り当てるか? サーバが、タイプを優先度、許可されたチャネル、テンプレート、オプトアウトルールにマッピングする管理されたタイプカタログを保持します。呼び出し元が任意に critical=true を送信できるようにすると、すべてのチームが緊急レーンを奪い合うことになります。
  • サイレント時間帯やオプトアウトをバイパスできるメッセージはどれか? その例外が承認されたトランザクションタイプのみがバイパスできます。マーケティングの設定はチャネルのキューイング直前に確認されるため、予約後にオプトアウトしたユーザーは除外されます。
  • チャネル間のフォールバックは許可されるか? 失敗したSMSをプッシュ通知で代替することは、コスト、到達性、ユーザーの期待を変化させます。通知タイプごとにフォールバックグラフを設定し、明示的な拒否と不明な結果を区別します。不明な結果の後にフェイルオーバーすると、ユーザーに2回連絡してしまう可能性があります。
  • どのような順序性が要求されるか? 1人のユーザーに対するパスワードリセットメッセージには順序性が必要な場合がありますが、通常のソーシャルアラートには通常グローバルな順序性は不要です。システム全体の直列化を避けるため、(user_id, notification_type) などのキーについてのみ順序を保持します。
  • 保持期間とプライバシーの境界はどこか? メッセージ本文は機密性が高い場合があります。キューにはテンプレートのバージョンと最小限のパラメータのみを保持し、特定のフィールドを暗号化し、保持期間の上限を適用し、OTP、完全な電話番号、メール本文は決してログに出力しません。

30秒の回答フレームワーク

「私は、永続的な受け付け、プロバイダの受け付け、デバイスへの配信、ユーザーの既読を個別の状態として分離します。Ingressは通知とアウトボックスをアトミックに書き込み、202を返します。受信者のファンアウトは非同期であり、設定、サイレント時間帯、有効期限、重複排除に関するポリシーはディスパッチ直前にチェックされます。各チャネルにはトランザクション用、通常用、バルク用のキューとコンシューマが個別に用意され、プロバイダのキャパシティはトランザクション用に保護され、アイドル容量はバルク処理に貸し出されます。ワーカーは安定した delivery_id のもとでAt-Least-Once(少なくとも1回)のタスクを処理し、恒久的障害、レート制限、一時的障害を分類し、一時的なケースにはジッター付き指数バックオフを使用します。プロバイダのコールバックはイベントとして追記され、チャネルステートマシンを介して集約されるため、順不同のsentイベントによってdeliveredの状態が後退することはありません。OTPと同時にマーケティングの急増、重複タスク、順序の入れ替わったコールバックをテストし、2秒のSLOとバックログのリカバリを実証します。」

ステップバイステップの詳細設計

Ingress APIは、SMSやプッシュのプロバイダを同期的に呼び出しません。呼び出し元を認証し、管理された通知タイプを検証し、テンプレートのバージョンを固定し、最小限のパラメータを検証した上で、1つのデータベーストランザクション内で通知とアウトボックスレコードを書き込みます:

~~~text POST /v1/notifications { request_id, notification_type, recipients | audience_id, template_version, template_params, schedule_at, expire_at }

202 Accepted { notification_id, accepted_at } ~~~

request_id は呼び出し元のリトライを重複排除し、notification_id は1件のビジネス通知を識別し、delivery_id は1つのユーザー・チャネル配信を識別します。1件の通知が多数のユーザーやチャネルにファンアウトされる可能性があるため、これらを1つの識別子にまとめることはできません。アウトボックスリレーは、Ingressトランザクションがコミットされた後にのみパブリッシュを行い、データベースのコミットとメッセージのパブリッシュのギャップを埋めます。5,000万人の受信者を対象とするキャンペーンの場合、Ingressはオーディエンススナップショットの参照とカーソルを保存し、1つのリクエストやトランザクション内で5,000万行を作成することはありません。

ファンアウトサービスはスナップショットをパーティションごとに読み込み、小さな計画バッチを作成します。送信時刻が近づくと、ポリシーサービスが通知タイプのルールと現在のユーザー設定をロードし、オプトアウト、サイレント時間帯、チャネルの可用性、頻度ポリシー、有効期限、フォールバック順序を評価します。サイレント時間帯はユーザーのIANAタイムゾーンを使用し、夏時間の移行に対応します。タイムゾーンが不明な場合、プロダクトはサーバ時間を黙って使用するのではなく、明示的なデフォルト値を使用します。フィルタリングされたタスクは SUPPRESSED と機械可読な理由を受け取り、サポートが送信されなかった理由を説明できるようにします。

中核となるデータは3つのレコードに分割できます:

~~~text Notification( notificationid, tenantid, notification_type, templateversion, scheduleat, expire_at )

Delivery( deliveryid, notificationid, user_id, channel, trafficclass, state, provider, providermessage_id, attemptcount, nextattemptat, stateversion )

DeliveryEvent( deliveryid, providereventid, eventtype, providertime, receivedat, rawpayloadref ) ~~~

Delivery はマテリアライズドされた現在のビューであり、DeliveryEvent はプロバイダのレシート事実を保持します。生のコンテンツと個人データは管理されたストレージに保存され、イベントテーブルには参照のみが保持されます。プロバイダが provider_event_id を提供する場合は一意性を強制します。それ以外の場合は、正規化したレシートフィールドをハッシュ化して重複排除を行います。イベントを書き込む前にWebhookの署名を検証し、未知のフィールドを互換性を保ちながら保存します。プロバイダの新しいフィールドによって有効なコールバックが破壊されてはなりません。

最低限、以下の状態を区別します:

~~~text ACCEPTED -> PLANNED -> QUEUED -> SENDING -> PROVIDER_ACCEPTED | v DELIVERED -> READ

Terminal side states: SUPPRESSED, EXPIRED, FAILED_PERMANENT ~~~

この図はビジネス上のステージを表しており、コールバックの到着順序を保証するものではありません。プロバイダは sent の前に delivered を配信する場合があり、重複したWebhookも一般的です。ハンドラは冪等なイベントを追記し、チャネル、現在の状態、新しいイベントをキーとする遷移テーブルを使用してプロジェクションを更新します。遅れて届いた sent によって、すでに DELIVERED にあるSMSが後退することはありません。開封確認を持つチャネルは DELIVERED から READ に進むことができます。マッピングされていないイベントは生のテーブルに残り、推測された状態を強制するのではなくアラートを発報します。

送信データプレーンは、sms.transactionalsms.bulkpush.transactional のように、チャネルおよびトラフィッククラスごとにキューを分離します。各グループは独立したバックログ滞留時間メトリクス、コンシューマ並行数、リトライキューを持ちます。SMSプロバイダとの契約で毎秒10,000リクエストが許可されていると仮定します。スケジューラはトランザクション用に毎秒3,000リクエストを保護し、バルクトラフィックが未使用のキャパシティを借用できるようにし、トランザクションが増加した際にはそれを回収します。これらは設定例であり、実際の値はプロバイダの契約や負荷テストに基づいて決定されます。テナントごとの公平なスケジューリングにより、1つのキャンペーンがバルクのバジェットを消費し尽くすのを防ぎ、エージングにより通常の処理が恒久的に枯渇(Starvation)するのを防ぎます。

キューを分離すると、優先度フィールドを持つ単一のキューよりも多くのトピック、接続、運用設定が必要になりますが、ディスクのバックログとコンシューマリソースを分離できます。小規模で単一チャネルの場合は、共有キューと重み付け公平スケジューリング(Weighted Fair Scheduling)の組み合わせの方がシンプルな場合があります。トランザクションのSLOとバルクの期限が桁違いに異なる場合、物理的な分離の方が証明しやすくなります。どちらの設計でも、単一のチャネルスケジューラがプロバイダの実際のクォータを強制します。個々のコンシューマがそれぞれフルクォータを専有していると見なしてはなりません。

チャネルワーカーはAt-Least-Onceタスクを受信し、delivery_id のもとで試行(attempt)をアトミックに獲得します。配信がすでに終端状態にある場合、キュータスクをACK(確認応答)します。そうでない場合は expire_at を確認し、テンプレートをレンダリングし、プロバイダを呼び出し、レスポンスを保存します。重複したキュータスクが2つ目の配信レコードを作成することはできません。外部のタイムアウトには、依然として3つの可能性が存在します。プロバイダがリクエストを受信しなかった、受信したがそのレスポンスが失われた、または処理結果が不明である、のいずれかです。プロバイダの冪等キーが存在する場合はそれを再利用します。その機能がない場合は試行を UNKNOWN とマークし、プロバイダへの問い合わせやレシートを通じて照合(リコンサイル)します。重要な通知は、ビジネスが重複のリスクを明示的に許容した後にリトライできますが、マーケティングは通常、照合または期限切れを待つことができます。

エラーの分類によって次のアクションが決まります:

  • 無効なパラメータ、無効なテンプレート、および確認された無効なデバイストークンは恒久的なエラーです。FAILED_PERMANENT に移行し、証拠に裏付けられている場合にのみエンドポイントを無効化します。APNsの410は、デバイストークンがそのトピックに対してアクティブでなくなったことを意味します。
  • HTTP 429、プロバイダの5xxレスポンス、および接続障害は、通常リトライ可能です。指数バックオフ、フルジッター、最大試行回数、および全体の期限を使用します。リトライもチャネルのキャパシティ制御を通過するため、リカバリによって2次的なスパイクが発生することはありません。
  • 認証やアカウント設定の失敗はプラットフォームのインシデントです。影響を受けるプロバイダチャネルを一時停止してアラートを出します。すべてのメッセージをリトライすると障害が増幅されます。
  • expire_at に達したOTPや古いリマインダーは EXPIRED になります。デッドレターの再生によって元の有効期限を延長したり、重複排除を回避するために新しい delivery_id を割り当てたりすることはできません。

プロバイダの受け付けは、デバイスへの配信を証明するものではありません。APNsは各POSTに対してステータスと apns-id を返します。成功はリクエストレベルの成功を証明します。FirebaseもキューイングやAPNsへのハンドオフをSendsとしてカウントします。SMSプロバイダは、その後に queuedsentdeliveredundeliveredread の状態を提供する可能性があり、そのWebhookは順不同になることがあります。そのため、パブリックAPIは階層化された状態と status_reason を返します。レポートは、受け付け率、プロバイダ受け付け率、観測可能な配信率、および観測可能な開封率を個別に計算します。チャネルが状態を公開していない場合は、成功や失敗としてカウントするのではなく、不明(unknown)として報告します。

スケールのために、通知テーブルと配信テーブルを notification_id または時間でパーティショニングし、チャネル、クラス、パーティションキーごとにワークキューをスケールさせます。ユーザーごとの順序付けが必要な処理は安定したユーザーパーティションキーを使用し、順序のないバルクタスクはより均等にハッシュ化できます。30 TB、30日分の論理エンベロープ全体を高価なオンラインインデックスに保持する必要はありません。最新の配信状態をオンラインに維持し、古いイベントを圧縮してアーカイブし、コンプライアンスとサポートに必要なインデックスのみを保持します。キャパシティプランニングでは、レプリカ、インデックス、レシートの増幅、リトライの書き込みを別途加算します。

決定的な受け付けテストは、「バルクの急増がOTPを遅延させてはならない」という点です。毎秒約232,000件のキャンペーンタスクを持続させ、そこにトランザクションのトラフィックを追加します。受け付けからプロバイダ送信までのOTPのp99が2秒未満を維持し、バルクのバックログが15分のウィンドウ内で解消されることを検証します。429および5xxのレスポンスを注入し、バックオフが同期してリトライの波にならないことを確認します。同じキュータスクを複数回配信し、元の delivery_id が再利用されることを確認します。delivered -> sent -> delivered の順序でコールバックを送信し、状態が後退しないことを確認します。また、送信直前のオプトアウト、夏時間の移行、期限切れのデッドレター、プロバイダの不明な結果もカバーします。これらの障害パスの結果によって、「高信頼性」がテスト可能になります。

質の高い模範回答

「私は4つの個別の結果を定義します。永続的なプラットフォーム受け付け、プロバイダの受け付け、観測可能なデバイスへの配信、そしてユーザーの既読です。APIは最初のもののみを約束します。通知とアウトボックスを書き込み、202を返します。ファンアウトは非同期であり、最新のオプトアウト、サイレント時間帯、チャネル可用性、有効期限のルールは、安定した delivery_id を割り当てる前の送信直前に評価されます。

1日あたり10億件のチャネルタスクは平均で毎秒約11,600件であり、20倍のキャンペーンピークは毎秒約232,000件になります。OTPは2秒以内にプロバイダに到達する必要がある一方、マーケティングは15分間に平準化できます。したがって、チャネル別、およびトランザクション用、通常用、バルク用のクラス別にキューとコンシューマを分離し、プロバイダのキャパシティをトランザクション用に保護し、バルクにはアイドル容量のみを借用させます。各テナントにも公平なシェアが与えられます。

ワーカーはAt-Least-Onceの消費を前提とします。重複したキュー処理は同じ delivery_id を獲得します。恒久的なエラーは停止し、429、5xx、およびネットワーク障害は全体の期限で制限されたジッター付きバックオフを使用します。プロバイダのタイムアウトは外部の副作用を生み出している可能性があります。利用可能な場合はプロバイダの冪等キーを再利用し、そうでない場合はエンドツーエンドのExactly-Onceを主張する代わりにUNKNOWNとマークして照合します。

レシートはチャネル遷移テーブルを介して集約されるイミュータブルなイベントであるため、遅れて届いたsentイベントがdeliveredを上書きすることはありません。ロールアウトテストでは、マーケティングのピークとOTPトラフィックを結合し、重複処理、429レスポンス、プロバイダのタイムアウト、順不同のコールバック、直前のオプトアウトを注入します。主要なメトリクスは、クラスごとのキュー滞留時間、プロバイダ送信レイテンシ、障害クラス、UNKNOWNカウント、および試行された状態の後退です。」

よくある間違い

  • APIが202を返したときにdeliveredと記録する → 永続的な受け付けのみが完了している状態です → ACCEPTED、PROVIDER_ACCEPTED、DELIVERED、READを分離してください。
  • すべての処理を1つの優先度キューに入れる → バルクのバックログが依然としてディスク、コンシューマ、プロバイダクォータを共有してしまいます → 制御された借用を伴う形で、チャネルおよびトラフィッククラスごとにリソースを分離してください。
  • 重要トラフィックにあらゆる制限をバイパスさせる → 異常なOTPトラフィックがプラットフォームとプロバイダを過負荷にする可能性があります → トランザクション用トラフィックに独自の上限、アラート、テナントの公平性を設けてください。
  • キューがAt-Least-OnceであるためユーザーがExactly-Onceで受信すると主張する → レスポンスが失われただけでプロバイダの呼び出しは成功している場合があります → 内部処理を delivery_id ごとに冪等にし、不明な外部結果を照合し、重複リスクを開示してください。
  • すべてのエラーを即座にリトライする → 恒久的なエラーはキャパシティを浪費し、429/5xxの失敗はリトライストームを引き起こします → 恒久的、一時的、スロットリング、プラットフォーム設定の各障害を分類し、一時的なケースにはジッター付きバックオフを適用してください。
  • 現在の状態を直接上書きする → 順不同のコールバックによってdeliveredがsentに戻ってしまう可能性があります → まず冪等なイベントを永続化し、次にチャネル遷移テーブルを使用してプロジェクションを更新してください。
  • 予約時にユーザー設定を固定する → その後のオプトアウト後もマーケティングが届いてしまいます → チャネルのキューイング直前に設定とコンプライアンスルールを再確認してください。
  • デッドレターの再生時に新しいIDを割り当てる → 重複排除が回避され、古い通知が送信される可能性があります → 元の delivery_id を再利用し、オプトアウトと有効期限を再確認してください。
  • 単純化のためにグローバルな順序付けを使用する → 無関係なユーザー同士がブロックし合い、単一パーティションがスループットの上限になってしまいます → ユーザーの通知タイプが実際に要求するローカルな順序のみを保持してください。

フォローアップの質問と回答

フォローアップ1:5,000万人のユーザーが現地時間の午前9:00にキャンペーンを希望しています。何を変更しますか?

ファンアウト時に、IANAタイムゾーンと現地日付ごとにタイムバケットを構築し、対象ウィンドウに先立ってレート制御されたバッチを生成します。正時にすべてのタスクを一斉に解放するのではなく、各ターゲットウィンドウ内でジッターを追加します。夏時間の変更時に存在しない現地時間や重複する現地時間に対するプロダクトの動作を定義します(例:次の有効な瞬間に移動し、1日1回を超えて送信しないなど)。デバイスの表示は依然としてプラットフォームの制御外にあるため、「午前9:00」はプロバイダへの送信ウィンドウを意味するべきです。

フォローアップ2:プロバイダは成功を返しましたが、配信確認(レシート)を一度も送信しませんでした。状態はどうなりますか?

PROVIDER_ACCEPTED を維持し、DELIVERED に昇格させないでください。チャネルの観測ウィンドウの後、システムは運用向けに DELIVERY_UNKNOWN を公開する場合がありますが、不明を失敗としてカウントしてはなりません。プロバイダが問い合わせAPIや集約レポートを提供している場合は、非同期に照合し、そのデータの遅延と対象範囲を開示します。

フォローアップ3:delivered のコールバックが sent より先に到着した場合はどうなりますか?

双方のイベントを冪等に追記します。プロジェクションは PROVIDER_ACCEPTED または SENDING から DELIVERED に進むことができます。後から届いた sent は、状態を低下させることなく監査履歴を充実させます。同じプロバイダが後から文書化された取り消しやエラーを発行した場合は、グローバルな整数から順序を推測するのではなく、そのプロバイダ固有の遷移を明示的にモデル化します。

フォローアップ4:2つのSMSプロバイダ間で自動フェイルオーバーできますか?

明示的な拒否、接続前の障害、またはプロバイダ全体のサーキットブレーカーの後は、フェイルオーバーが妥当です。送信後のタイムアウトはプライマリがすでに配信したことを意味する可能性があるため、即時のフェイルオーバーは重複SMSのリスクを高めます。OTPは、コストとセキュリティの責任者がそのリスクを明示的に許容した場合にフェイルオーバーできますが、マーケティングは通常、問い合わせ、レシート、または有効期限を待ちます。この判断はグローバルではなく通知タイプごとに設定します。

フォローアップ5:優先度の分離が機能していることをどのように証明しますか?

持続的なバルクバックログ、高負荷な特定テナント(ホットテナント)、プロバイダからの繰り返される429レスポンスという3つの負荷を組み合わせたクローズドな負荷テストを使用します。受け入れ条件として、OTPのプロバイダ送信p99が2秒未満であること、トランザクション用コンシューマでバルク処理が行われていないこと、プロバイダの合計レートが設定内であること、およびバルクのリカバリが15分の期限内であることが求められます。その後、トランザクションコンシューマの1つのパーティションを障害停止させ、残りのインスタンスが保護されたキャパシティを引き継ぐことを確認します。平均スループットだけでは分離を証明できません。

公開情報ソース

関連する質問

関連面接ツール

システム設計の回答には「回答する」を使用

まず要件を明確にし、スケール、アーキテクチャ、コンポーネント選定、トレードオフの順に進めます。

ツールを見る