事業者情報台帳サービスにイベントパブリッシングを導入した話

はじめに

メルペイバックエンドエンジニアの @r_yamaoka です。 Merpay Advent Calendar 2021 の19日目の記事をお届けします。

私は普段メルペイ加盟店さまの管理を担うマイクロサービス群の開発に従事しており、この記事では加盟店さまの事業者情報を記録する台帳サービスが抱えていた技術的負債とその返済に関する取り組み、実装において注意を払った点について紹介したいと思います。

背景

 私のチームでは加盟店さまに纏わるマイクロサービスを開発していますが、その中に「事業者情報台帳サービス」があります。これはその名の通り加盟店さまの事業者情報を管理するためのサービスで、例えば事業者名やその他内部処理で使う各種ステータスフラグ等を記録しています。

 この台帳サービスは決済に不可欠な情報を保持しているためメルペイのサービスローンチ時から存在している歴史あるサービスですが、諸般の事情によりいくつかの負債を敢えて受け入れてきました。その中の一つとして他マイクロサービスからデータベースを直接参照されているというものがあります。

 これは様々な書籍で紹介されている典型的アンチパターンであり、本来であれば許容されるべきではありません。しかしベストなアーキテクチャーよりもリリーススケジュールを優先せざるを得なかった、要件が読み取りのみで比較的リスクが低かった、他サービスが別の基盤で稼働しており連携方法が限定されていた、等々の理由により受け入れるという意思決定が行われました。

課題

 上記の通り台帳サービスはデータベースを直接参照されることになりましたが、具体的な連携の実装としては定期的に他サービス側からポーリングしUpdatedAtの値の変化をトリガーとして処理が行われるため、下記のような弊害が発生していました。

  • ポーリングとポーリングの間で同じレコードに対し複数回更新が行われると、最後の更新しか伝えられない
  • 運用上の理由によりやむを得ずDMLでレコードを直接更新をする際、UpdatedAtの更新を忘れるとデータ連携が漏れる
  • データマイグレーション等で大量更新をかける場合、UpdatedAtの値を分散させないと他サービス側が高負荷に陥る

 これらの他にもこのベストとは言えない連携方式に起因した弊害が複数発生しており、徐々に痛みが大きくなってきたため本格的な解消に向けた動きが始まりました。

解消方法の検討

 ここではマイクロサービス間のデータ連携で一般に採用されるPubSub方式を採用することにしました。台帳サービス側から「事業者情報が新規登録された」「事業者情報が更新された」等といったドメインのイベントとその内容をメッセージングサービスを通して配信することで非同期・疎結合にデータを連携できます。

 メルペイのシステムは基本的にはGCP上に構築されており、Cloud Pub/Subが今回の要件に合致するためこれを利用します。 またこういったドメインイベントをトリガーに処理を行いたい業務は他にも複数あり、将来的に様々なシステム連携に活用できる見込みもあります。

連携対象データの抽出方法

 今まではUpdatedAtで抽出するというシンプルな方法でしたが、今回の改修に伴いどのように連携対象を抽出するか考える必要があり、以下2つについてそれぞれpros/consを検討しました。

  • 案1. 事業者の作成・更新系APIの実装を修正しイベントを作成する
  • 案2. テーブルをワーカーからポーリングし変化のあったレコードを検出しイベントを作成する

 案1についてはAPIを叩かれた回数分必ずイベントを生成することができ、短時間に頻繁な更新があっても欠損は起きません。一方で今後APIのメンテナスを行う場合、イベントの生成処理に対する気配りが常に必要になるというデメリットがあります。またデータマイグレーション等でDMLによる更新を行った場合、イベントの生成も併せて手作業で行う必要があります。

 案2は既存と似た仕組みであり、UpdatedAtの更新さえ忘れなければデータ更新とイベント生成が紐付くため、APIのメンテナンスやマイグレーション時の配慮が不要というメリットがあります。しかしポーリング間隔中に複数回更新があった場合に最後の更新しか連携できないという欠点が残ったままとなります。

 なお改修工数を概算で見積ってみたところ、どちらも然程違わないと見込まれたため判断には特に影響を与えていません。

イベントの状態管理

 作成されたドメインイベントをPub/SubのTopicに配信する方法についても下記2案について検討を行いました。

  • 案A. イベント作成時点で同期的に送信する
  • 案B. イベントを一旦データベースに書き込み永続化する。状態のカラムを持たせ別途ワーカー等から未送信のイベントを送信する

 案Aはイベント抽出の都度Topicへの送信を行うというシンプルな方法です。実装が非常に簡単になるというメリットはありますが、ネットワークやPub/Sub側に障害が発生して配信に失敗するとリトライができず欠損が発生します。

 案Bは配信内容を一度データベースへ書き込み別途ワーカーから配信処理を行うため実装は少々複雑になりますが、イベント抽出と永続化を同一トランザクション内で行え、また配信状態の管理ができるため偶発的な障害が発生した場合でも確実な配信を実現できます。

結論

 これらについてチームで議論を重ねた結果「案1」と「案B」を採用することにしました。具体的な処理の流れとしては以下のようになります。

  1. 事業者の作成・更新系APIが叩かれる
  2. APIのトランザクション内でイベント管理用テーブルにも書き込みを行う
  3. 別プロセスとして稼働している配信ワーカーがイベント管理用テーブルをポーリング
  4. 未配信のイベントがあればPub/Subへ配信
  5. 他サービスがPub/Subからイベントを取得し処理を行う

 この仕組みは Transactional outbox と呼ばれ様々な書籍やWebサイトで紹介されている基本的なマイクロサービスの実装パターンとなります。 https://microservices.io/patterns/data/transactional-outbox.html

 今後イベントを他の用途へ発展させることを考えた場合、欠損が発生し得る仕組みは避けるべきであり、改修漏れのリスクはレビューやQAで発見しやすくカバー可能、改修工数も許容できる範囲であると判断したためこの方式を採用することとしました。

メルペイでの実装はこちらで詳細をご紹介しています。
https://engineering.mercari.com/blog/entry/20211221-transactional-outbox/

配信ワーカーの実装における考慮点

イベントテーブルに更新内容も保存する

 単純に考えると、下記例のようにイベントテーブルには発生日時と事業者IDのみを保存しておけば良さそうに思えますが、この場合正確に配信できるのは「更新があった」という事実のみです。イベント発生時点での値を保存していないため、購読側からは配信処理が行われた時点のデータしか取得できません。例えば同じ事業者にほぼ同時に更新処理が発生した場合、先に書き込んだ内容が失われる可能性があります(失われない可能性もあります)。

CREATE TABLE Corporates (
  ID STRING(MAX),
  Name STRING(MAX),
  …
) PRIMARY KEY (ID);

CREATE TABLE CorporateEvents (
  ID STRING(MAX),
  CorporateID STRING(MAX)
) PRIMARY KEY (ID);

 今回のケースでは全ての変更を追えるようにすることが理想であったため、イベント作成時に値をイベントテーブルのカラムに保存しました。こうすることにより全ての変更を確実に追跡できるイベント配信となります。より簡便な方法としてJSON型やBYTES型等で1つのカラムに全ての事業者情報を保存することも考えられますが、今回のユースケースでは厳格なスキーマ管理を行いたかったので、実装の煩雑さと引き換えにカラムへの保存としました。

CREATE TABLE CorporateEvents (
  ID STRING(MAX),
  CorporateID STRING(MAX),
  CorporateName STRING(MAX),
  …
) PRIMARY KEY (ID);

イベントの状態管理

 配信ワーカーの実装においてイベントは「未配信」「配信済み」「配信失敗」の3種類を定義しました。ワーカーを多重起動しスケーラビリティを持たせることを考えると重複配信を避けるために「配信中」という状態も欲しくなりますが、現状そこまでの性能が必要無いことに加え

  1. ワーカーが「未配信」のイベントを取得する
  2. 「未配信」を「配信中」に変更する
  3. 突然のプロセスクラッシュ!
  4. ワーカー再起動後も「配信中」に変更されたイベントは配信対象とならずそのままスタックする

というようなケースに対応した実装が必要になるためYAGNIの精神に則りシンプルに3種類の状態としました。

 「配信中」が無い場合何らかの理由でワーカーが多重起動するとメッセージの重複配送が発生しますが、そもそもCloud Pub/Subというサービス自体が at-least-once (少なくとも1回は配送することを保証するが重複配送されることもある) という仕様であり、購読側で冪等な実装をすることが前提であるため特に問題は無いと判断しました。

終わりに

 今回の対応では技術負債の返済と今後の機能拡張に向けてTransactional outboxパターンを活用したイベント配信の仕組みを整備しました。まだ移行は限定的であり当初目標のデータベース直接参照も廃止しきれてはいませんが、これからの歩みで徐々にあるべき姿へと近付けるようになったと思います。

 我々のチームで開発しているサービスは他にも複数の負債を抱えておりイベントで解消できそうなものもそうでないものもあります。今後も引き続きそれらの返済と機能開発のバランスを取りつつ前に進んでいかねばなりません。メルペイでは今回紹介したような取り組みに興味のある方、ミッション・バリューに共感できるバックエンドエンジニアを募集しています。一緒に働ける仲間をお待ちしております。

ソフトウェアエンジニア (Backend) [Merpay]

参考文献

Sam Newman, 佐藤直生, 木下哲也, マイクロサービスアーキテクチャ, O’Reilly Japan, Inc. 2016

Chris Richardson, 長尾高弘, 橂澤広亨, マイクロサービスパターン 実践的システムデザインのためのコード解説, 株式会社インプレス, 2020