【書き起こし】Payment distributed transaction case study – Rui Gao【Merpay Tech Fest 2021】

Merpay Tech Fest 2021は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知れるお祭りで、2021年7月26日(月)からの5日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。

この記事は、「Payment distributed(分散)transaction case study」の書き起こしです。

Rui Gao氏:皆さん、こんにちは。本日は「Payment distributed(分散)transaction case study」というテーマで発表させていただきます。

まず、簡単に自己紹介させてください。私は、メルペイ・ペイメントプラットフォームチームのエンジニアリングマネージャーのガオ・ルイです。

約7年前に来日して、新卒のときにヤフージャパンでビッグデータ基盤の開発を経験して、そのあとお金のデザインというベンチャーの会社で、資産運用のロボアドバイザーの開発を経験して、約3年前にメルペイにジョインしました。メルペイにジョインしてからずっと、ペイメントプラットフォームチームという分散トランザクションをメインに担当しているチームで活動しています。

Agenda

早速ですが、きょうのセッションのアジェンダを紹介します。おもに4つに分かれています。最初は、今回のケースの背景を説明しようと思います。

Background

今回のケースは、メルペイのスマート払いの、精算トランザクションのリペイメントというトランザクションのケースのスタディになります。

メルペイのスマート払いは2019年4月にリリースして、去年の7月ごろに定額払いも登場しています。リペイメントというトランザクションは、お客さんがスマート払いを使って決済したあと、翌月の精算のときに使うトランザクションになっています。

このリペイメントのトランザクションに関わるマイクロサービスは、おもに4つあります。まずは、支払いスマートペイの支払いを管理しているスマートペイサービス、真ん中のトランザクションを管理しているペイメントサービス、そして依存しているのが右側の債権を管理しているデータサービスと、お客さんの残高を管理しているバランスサービスです。

この4つのマイクロサービス以外に、メッセージキューも登場しています。ここはエッジケースの対応の仕組みになっていますので、のちほど細かく説明します。きょうの話のメインは、このペイメントサービスの話になります。

まず、このトランザクションを管理するために、この3つのサービスにそれぞれAPIを用意しています。メルペイのマイクロサービスは、ほとんどコマンド式ではなく、リリース式のAPIデザインになっています。TCCという仕組みも使っていますので、1つのリソースに対してトライを使うクリエイトAPIを用意しています。コンファーム用のキャプチャーAPIもあります。このトライのときに成功か失敗が発生し得るので、このあとトライが成功してもキャンセルしたい場合は、VoidもしくはキャンセルのAPIも用意しています。

トランザクションの詳細に入る前に、プリサポートの前提条件の説明しようかなと思います。まず、グローバルトランザクション管理をするには、BASEというフレームワークがあって、基本的に可用性を重視しています。途中の状態で一時的にステータスがずれたり、データ不整合があったりした場合、それを許しますが、最終的にデータの一貫性を担保するようになっています。

このBASEを実現するためには、先ほど紹介したTCCというAPIの設計を活用していて、APIごとに冪等(べきとう)性を担保することも前提条件になっています。こちらの理論の詳細は、同じチームの@foghostさんがメルカリテックブログで発表している記事の中にも詳細を掲載しています。

Transaction Status Management

ここまでは背景の説明で、これからはトランザクションのステータスマネジメントの部分に入ります。

ペイメントサービス内部では、1つのトランザクションに対して、実は3つのステータスを管理しています。一番上がステータスというフィールドで、ビジネスロジックを管理しているステータスフィールドになっています。ステータス以外のリソースステータスと、フェーズもしくはロールバックフェーズというのは、ペイメントサービス内のステータスマシンをメインに使っているステータスフィールドになっています。アップストリームのサービスから見ると、1つのトランザクションのステータスは上のステータスだけを見ればOKで、下の2つはペイメントサービス内部の情報になっています。

ペイメントサービスのシステムの構造を説明しようと思います。

まず、紫の部分がアップストリームサービス、上位サービスになります。上位サービスからペイメントサービスにリクエストが来たときは、まずはリソースハンドラーでリクエスト処理をします。そのときにペイメントサービスのデータベースに、リソースステータスというフィールドにwork in progressという状態を記録します。

リソースハンドラーの処理が終わったら、リクエストをリソースリクエストキューに入れます。リクエストがインキューしたあと、リソースハンドラーがアップストリームサービスにレスポンスを返すことも可能になっています。要は、ペイメントサービスのAPIは、基本的に、シンクロナイズの呼び方とアンシンクロナイズの呼び方を両方サポートしています。

リソースリクエストキューの中のリクエストを実際に処理するのは、ワーカーレイヤーの各ワーカーが担当しています。このワーカーの役割としては、まず、ダウンストリームサービスにリクエストを流します。ダウンストリームサービスからレスポンスが来たとき、このレスポンスの結果を見てデータベースの更新も行っています。

複数のステータスフェーズがあるとき、この1と2の部分を何回も繰り返すケースが多いです。外部サービスのクールが終わったら3番の処理を行います。3番の処理は、リソースステータスと、フェーズと、ステータスの3つを全部更新するようになっています。

見ると分かると思いますが、リソースステータスというフィールドがリクエストを受け取った時点から、実際の処理が全部終わるまでずっとwork in progressのまま残しています。データ復旧のときは、このフィールドがキーポイントになっています。3番の処理が終わると、ペイメントサービスのデータベースの処理が全部終わります。そのあと4番のところは、メッセージキューにリソースの処理が終わったという通知をステータスも含めてメッセージをpublishします。ここはエッジケースの対応のときに重要なポイントになります。のちほど詳細を紹介します。

今回のリペイメントのステータスを見てみると、initのステータスからCREATED、PARTIAL_PAID、もしくはPAIDという3つの正常なステータスに遷移することができます。そこはお客さんが選んだ支払い手段によって分岐が発生しています。そこは全部成功のケースです。もし失敗したら、initフェーズからFAILEDフェーズにも遷移できますので、initフェーズから4つのステータスに遷移することができます。今回は、この中の1つのinitステータスからFAIEDステータスへの遷移の詳細を紹介しようかなと思います。

initフェーズからFALEDフェーズに遷移するためには、この3つの操作が必要になります。まず、精算しようとしている債権を仮押さえる操作が必要です。そのあと、精算する支払い手段としてバランスの残高を抑える操作が必要になります。最後は自動復旧のためにメッセージキューにNotificationをPublishすることも必要になります。

このフェーズの遷移は、左側はエラーがなく全部成功したときのフェーズ遷移グラフになります。initからdebtを押さえて、そのあとbalanceを押さえて、そのあと通知を送ります。ただ、このマイクロサービスのコンテキストで、マイクロサービス間の通信や、依存しているマイクロサービスのサービスダウンなどの事故も発生し得るので、そのときはトランザクションの一貫性を担保するためにロールバックの操作も必要になります。

例えば、debtを押さえる操作は成功したけれども、残高を押さえるときにエラーが発生したら、右側の赤いフェーズ遷移が必要になります。そのときは、残高を押さえる取り消し操作をロールバックする必要があります。そのあと、押さえた債権をアンフリーズするロールバック操作もあります。それが終わったらロールバックが完了という遷移になります。

成功したケースと失敗したケースのデータベースのフィールド更新を紹介しようかなと思います。まず一番上です。リクエストが来たときは、まず、リソースステをwork in progressに更新します。そのあとフェーズとステータスのところが、init_phaseとinit_statusのまま残っています。具体的な操作を始めたときのリソースステータスは、ずっとwork in progressのまま残っています。更新するのはフェーズのフィールドと、すべてのフェーズ遷移が終わったときにステータスの更新も行います。最後に、Published Resource Notificationの操作が終わったら、初めてリソースステータスをwork in progressのステータスからwaitという 、次の操作を待っている安定している状態のバリューに更新します。これが成功したケースです。

もし失敗したら、initからFAILEDに遷移するトランザクションも紹介しようかなと思います。

このケースでは、債権を押さえる操作は成功したけれども、ユーザーの残高を押さえる操作でエラー発生したとき、リソースステータスはwork in progressのまま残ります。フェーズの遷移が止まると、代わりにロールバック遷移が走るようになります。すべてのロールバック処理が終わると、リソースステータスはFailedに更新します。フェーズとリソースもそれぞれrollback_completedと、failed_statusに更新します。最後に、上位サービスにエラーを返すようになっています。

一番難しい状況というのは、エラー、もしくはFailure Handlingのところです。エラーは、おもにビジネスロジックに関わるエラーです。例えば債権の残高が不足、もしくはメルペイが残高が不足の場合です。その場合は当然、精算ができないのでエラーになります。先ほどのロールバックのフェーズに入ると、途中で失敗したトランザクションがちゃんとロールバックできるようになっています。ビジネスロジック以外の異常状況のFailureを整理しています。おもにネットワークのIssue、もしくは依存しているマイクロサービスにシステムインシデントが発生したときです。そのときによく見ているのは、Aborted、もしくはTimeout、もしくはService Unavailableのエラーになります。次に、Failure Handlingを紹介します。

Failure Handling and Recovery

Failure Handlingと自動復旧の話です。

まず、ペイメントサービスの内部では、この3つのレイヤーで自動復旧を行っています。まず、一時的なnetwork failureです。このときはペイメントサービス内部のworker layerでリトライの仕組みを入れて、millisecondもしくはsecondの単位で自動復旧を行っています。一時的な network failure とは違って、サービスのトラフィックが重くなったとき、一定期間続いていたシステムスパイクが発生したとき、秒間範囲で自動復旧ができないとき、このときはペイメントサービス内部のresource request queue layerでリトライします。workerがリクエストを処理しようとしてもずっとエラーが発生していて、一定期間リトライしたら諦めます。このとき、リクエストをもう1回resource request queueに戻します。それからバックオフを入れて、1分から1時間以内に新しいワーカーがバックオフを取ったリクエストをもう1回処理します。これが2つ目のレイヤーの自動復旧です。スパイクよりもっと長い続くインシデントレベルの障害が発生したときは、リカバリーバッチを1時間ごとに1回走らせています。そのバッチは、直近3日間のwork in progressのリソースに集中して、もう1回リソースキューに入れて処理するようになっています。

このフレームワークの構造を取ると、ペイメントサービスにはこのステータスマシンを使って、workerレイヤーとresource queueレイヤー、かつ、Cron Jobレイヤーの3つのレイヤーで自動復旧を行っています。メッセージキューのところがエッジケースの対策になっています。このエッジケースを紹介しようかなと思います。

initフェーズからpaidフェーズに遷移するとき、すべての処理が終わったとき、要は、ペイメントサービスのデータベース更新が終わったときに、ネットワークの問題が発生したり、もしくはアップストリームサービスのサービスダウンなどが発生したら、ペイメントサービスの視点から見るとトランザクションは全部無事に処理が終わったのですが、上位レイヤーの視点から見るとエラーが開始しているので失敗したという整理になっています。そうすると、上位レイヤーのサービスと、ペイメントサービスの間にデータ不整合が発生します。ステータスマシンの仕組みの場合、クローンバッチを使っても検知できません。なぜかというと、リソースステータスが既にwork in progressではなく、安定のステータスになっていますので復旧は不可能になっています。

このエッジケースを対応するために、このメッセージキューを使っています。

リペイメントのケースのときは、スマートペイのサービスからペイメントのCreat Repayment APIをたたいて、タイムアウトエラーが発生したらリペイメントサービスの復旧の仕組みで、自動復旧できた、もしくは処理が終わったけれどもレスポンスとしてはエラーが発生したと。そのときは、先ほど話したデータ不整合が発生します。ペイメントの通知を送る機能が追加されたおかげで、メッセージキューの中にリソースがcreatedという通知があります。メッセージキューからスマートペイのハンドラーにメッセージをプッシュするようになっています。スマートペイのハンドラーがこのメッセージを見るんですけど、ローカルDBだとこのトランザクションが存在しない、もしくは既に失敗したというステータスになっていることを確認したら、ペイメントサービスのVoid Repayment APIをたたきます。このときにペイメントが押さえた債権と、押さえた残高をアンフリーズするという取り消しの操作を行います。その操作が終わったら、スマートペイサービスとペイメントサービスの間に、この取引のトランザクションが確実に2つのサービスとも失敗したという認識を合わせることができます。ここまでがエッジケースのハンドリングです。

では、実際にお客さんのボイスです。お客さんといっても、メルペイ内部のクレジットデザインチームの@tk8さんのボイスです。メッセージキューを使うと、APIのユーザーとしてスマートペイサービスの長期開発をケアするタイムポイントは増えるのですが、エッジケースも含めて処理のシンプル化もでき、開発プロセスが独立しているので並列で開発が行えるようになっていますので、総合的に見ると開発は楽になっています。自動復旧の仕組みがあるので、障害が発生したときに手動で運用する必要がなく、全部を自動復旧することができるので運用コストを削減することができています。この仕組みは、最初つくるときにいろいろ理解しなければならないポイントはあるのですが、いったん慣れてしまえば、新しい機能開発のときにやりやすくなり、開発工数の削減にもつながっています。

ちなみに、@tk8(タクヤ)さんは、メルペイ全社MVPを受賞しています。おめでとうございます。

ここまでがリペイメントのケースの紹介になります。リペイメントのトランザクションをきちんと管理していたのですが、会社の事業展開とともにトランザクションの管理の仕組みも進化しないといけません。

一つ紹介したい進化のポイントは、ドコモと連携するときのユースケースになります。去年の6月ごろ、メルカリでdポイントが使えるようになりました。

このとき、メルカリ内部の決済のエスクローというトランザクションの中にドコモポイントが使えるようにしないといけなくなりました。

リペイメントの場合だと依存してるのが内部のサービスが多いので、dポイントを使うためには外部のシステムと通信しないといけないんですね。このときFailureが発生したら、データの修正に結構時間がかかります。最速で翌日にデータが修正できるようになります。データ復旧の間、もしお客さんの残高がずっと仮押さえの状態になってしまったら悪いユーザー体験となってしまうので、それを対応するためにステータスマシンの仕組みを進化させました。

具体的には、メルペイ内部の残高を押さえることは成功したけれども、dポイントを消費するときにエラー発生した場合、先に押さえたメルペイのバランスをauthの操作を取り消してロールバックします。ロールバックをしたあと、dポイントの消費を取り消します。ただ、この取り消しに時間がかかるので、エスクローというリソースはずっとwork in progressのまま残します。ここはお客さんにあまり影響がありません。なぜかというと、メルペイの残高はもう既にアンフリーズしていて使えるようになっています。最終的にドコモとのデータ不整合を解除したら、ペイメントサービスのエスクロートランザクションのロールバックが成功できるようになっています。これも自動復旧になっています。

シーケンスを見ると、一番下のエラー部分では、ロールバックしようとしてもデータ不整合を解除していないのでwork in progressのままになっています。このデータ不整合を解除したら、ペイメントサービスのトランザクションは自動的にロールバック成功という形になります。

リペイメントとエスクローはそれぞれ特徴があるので、対応方法もちょっと違います。リペイメントの場合は、優先的にリトライしてトランジションが前にプッシュします。エスクローの場合は、優先的にロールバックを行ってお客さんの残高をアンフリーズするようにします。ユースケースごとにデータ不整合の解除とステータスマシンの仕組みは進化しています。

Summary

最後にまとめです。リペイメントサービスは、ステータスマシンと、クローンジョブと、メッセージキューを使って分散トランザクションを管理しています。

管理の目標は可用性を重視して、一時的にデータ不整合があっても最終的に一貫性を担保するようになっています。

ペイメントの分散トランザクション管理の仕組みは、実は、会社のビジネスの発展とともに進化しています。

会社のサービスをローンチしてから、いろんな機能をどんどん出しています。それとともにペイメントサービスのトランザクションの仕組みもどんどん進化しています。

これからメルコインという子会社をつくって、ブロックチェーンや暗号資産とNFTの事業に参加することになっています。これもペイメントプラットフォームとして新しいチャレンジになります。新しいトランザクションを管理する仕組みをケース・バイ・ケースで進化していくことになると思います。

ペイメントプラットフォームチームの、おもにトランザクションを管理しているペイメントサブチームには、今、開発者が6名しかいません。事業展開のために技術の仕組みも進化しないといけないので、エンジニアを絶賛募集中です。

詳細は、メルペイの採用ページで確認してください。特にペイメントプラットフォームに興味がある方は、ぜひ、私のLinkedInアカウントにつなげて気軽にオンラインチャットでも、カジュアル面談でもお気軽にどうぞ。

最後です。会社の「信用を創造して、なめらかな社会を創る」というミッションに共感している方、かつ、会社の「Go Bold」「All for One」「Be a Pro」のバリューと価値観に共感している方は、ぜひお気軽に声をかけてください。仲間になれたらすごくうれしいです。

ご清聴ありがとうございました。