この記事は、 Mercari Bold Challenge Monthの16日目の記事です。
こんにちは。株式会社メルペイのPayment Platform teamに所属している @ogataka50 です。
メルペイではマイクロサービスアーキテクチャで決済システムを開発、運用をしています。
メルペイはNFC決済・QR/バーコード決済・ネット決済を提供しています。それらに加えて、メルカリ内決済のコンビニ/ATM払い、キャリア決済、クレジットカード決済等も提供しています。
メルペイの決済システムはメルカリの決済システムをマイクロサービスとして切り出されたのが始まりになっています。
メルペイはアプリとしては2019年2月13日にサービスリリースしましたが、システム的にはその少し前からメルカリ内の決済機能をマイクロサービス化するため先にリリースされていました。
今回はメルペイリリース前に行われた、決済システムマイクロサービス化、特にそれに伴うデータ移行の話を書こうと思います。
なぜデータ移行が必要だったのか
メルペイの開発当初の話になります。その時はまだNFC、QR/バーコード決済などの決済手段は具体的になっていませんでしたが「メルカリ内の決済機能、ポイント・売上金をOnline、Offline問わず様々なところで使えるようにする」というイメージは明確にありました。
しかしその時のメルカリはモノリスなサービスであり、ポイント・売上金のデータもメルカリ内のDBで管理されていました。そのため既存のアーキテクチャのまま追加実装をしていくとメルカリ、メルペイが相互に依存する状態になってしまいます。
仮にメルカリに障害が起きるとメルペイにも影響を受けてしまいます(逆も然りです)。またポイント、売上金はメルカリ初期からの機能であり、様々な機能と密結合しており、さらにその上に機能追加していくとメンテナンス性・開発効率の低下が容易に想像できる状況でした。
これらの背景により、メルカリの決済システムのマイクロサービス化 & データ移行が必須であるということとなり、移行プロジェクトがスタートしました。
データ移行計画の検討
移行前の状況整理
まず最初に始めたのは移行が必要なデータ、修正が必要なコードの規模感の調査から始めました。ここではデータ移行が必要だった「ポイント」を例としてみます。ポイントに関してデータ移行が必要なテーブル、それぞれのレコード数は下記でした。(簡略化しています)
- pointの加算、減算を記録している
point_histories(仮)
約9億 records - pointの所持情報を管理する
point_balances(仮)
約4億 records
またその時点でのコードベースでポイント関連のテーブルを参照、更新している箇所は必ずしも共通化されておらず、決済・キャンペーンなどの様々な機能と密結合している箇所が存在しました。そのため単にAPIの向き先を変更するなどではデータソースの切り替えはできない状況でした。
移行戦略の検討
大枠の状況は把握できましたので、次に移行戦略の検討を行いました。主に検討が必要だったものは下記でした。
- 移行データの選定
- データ移行とデータソース切り替えプラン
移行データの選定
まず初めに行ったのは移行データの選定です。移行したいテーブルの目処は付いていますが、既存のテーブル構成ですべてのレコードを移行する必要はありません。
ポイントに関して言うとpoint_hisrories
にはいくつかメルカリ由来の外部キーが設定されていました。下記のようなイメージです。
CREATE TABLE IF NOT EXISTS point_histories ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, amount INT NOT NULL, expire_at DATE DEFAULT NULL, transaction_id INT UNSIGNED DEFAULT NULL, item_id VARCHAR(255) DEFAULT NULL, created DATETIME NOT NULL ) ENGINE=INNODB DEFAULT CHARSET=UTF8;
上記のtransaction_id, item_idはメルカリ内の transaction_id, item_idはそれぞれ取引、商品のidを指しています。メルペイではメルカリ以外の様々な加盟店での利用を想定しているため、これらメルカリ由来のデータは不要となります。
また過去に消費したポイントのレコードはポイント履歴表示のためだけに使われるので必ずしも移行は必要ありませんでした。
このような精査を移行が必要な各テーブルで行い移行すべきデータの選定を行いました。
データ移行とデータソース切り替えプラン
次に行ったのはデータ移行とデータソース切り替えプランの検討でした。
データ移行には大きく下記の2つの方法があると思います。
- サービスをメンテナンスに入れ、その間にデータ移行を行う。データ移行後のデータソースを参照するようにコード修正する
- 移行期間を設け、サービスを稼働させたまま徐々にデータ移行を行う。移行済みフラグなどで移行前後判定しデータソースを切り分けるようにコード修正する
1の場合はサービスをメンテナンスに入れることで、データ移行処理中のユーザアクションによるデータ競合などを考慮する必要はなくなります。また実際のデータ処理もdump&restore等で行うことも可能です。
2の場合はサービスが稼働しているため、データ競合を考慮する必要があります。移行期間中は移行済み・未済が同居するため、それらを考慮してデータソースを切り替えるようなコードに修正が必要なため複雑度が上がります。
これらはどちらが良い悪いはなく、何を選択するかはケース・バイ・ケースだと思います。
今回の私達の判断基準としては、
- 可能な限りサービスメンテナンスは避けたい
- データ移行量が多いので相応の移行時間がかかる
- コード修正範囲も大きくビッグバン的に一度にデータ移行&切り替えを行うと、不具合が起きた時に影響範囲が大きくなりコントロールが難しい
上記の理由から、複雑度はあがるものの、安全性かつGo Boldな2の方法で移行処理を進めることとしました。
実際のコード修正
データソースを切り替えるためリファクタリング
ここまで来るとコード修正の方針はある程度見えてきました。
既存のコードでは様々なクラスからポイントを参照・更新していました。それらを共通化するためリファクタリングを行いました。
その後データ移行のステータスを確認してデータソースを切り分けるような処理を追加しました。
JOINを使っているSQLの修正
移行対象のテーブルに対してJOINを使ったSQLを発行している箇所もあったので、JOINを使わないように修正を行いました。
SELECT * FROM point_histories AS ph LEFT JOIN transactions AS t ON sh.transaction_id = t.id;
もちろんデータ移行後はjoinできないので、下記のようにjoinを使わないように修正を行いました。
<?php $point_histories = $point_service->get_histories(); $transaction_ids = array_map(function (PointHistory $v) { return $v->getTransactionId(); }, $point_histories); $transactions = Transaction::find($transaction_ids); foreach ($point_histories as $history) { $history->setTransaction($transactions[$history->getTransactionId()]); }
データ不整合への対策
既存のコードでは下記のように1つのdb transactionで一連のデータ更新をするものが多くありました。
モノリスで共通のDBを使っている場合は処理中に例外が起きてもすべてがrollbackされるため、データの整合性を取ることができます。
<?php try { $db->begin(); $foo->doSomething(); // ポイント付与処理 // データ移行済み: merpay microservicesをcall // データ移行未済: 直接DB Update $point_service->provide(); $bar->doSomething(); $db->commit(); } catch (AwesomeException $e) { $db->rollback(); }
しかしデータ移行を行うとなるとそうはいかなくなります。
データ移行済みの場合、$point_service->provide()
はmerpay microservicesをcallしてポイント付与処理を行います。
仮に後続の関数 $bar->doSomething();
で例外が起きた場合、データ不整合が起こります。
例外が起こったので、$foo->doSomething();
, $bar->doSomething();
はrollbackされますが、$point_service->provide();
で成功したポイント付与処理はmerpay microservicesで管理されているため、DB rollbackの対象ではありません。結果として、ポイント付与処理のみが成功してしまうという状況が起きます。
また$point_service->provide()
で例外が起きたとしても、ポイント付与が失敗しているとは限りません。マイクロサービスにはリクエストが到達して、正常に処理が完了したが、ネットワーク的な問題でtimeoutなどの例外が起きた場合も同じく、ポイント付与処理のみが成功してしまうという状況になります。
これらの対策としては主に下記の対応を行っていました。
- idempotency key(冪等性キー)を用いてRetry可能にする
- マイクロサービス側から処理完了結果通知を受け取り、データの整合性をチェックし、不整合がある場合は解消する
- 定時実行のバッチでデータ不整合が起きていないかチェック、不整合がある場合は解消する
冪等性についてはマイクロサービスにおける決済トランザクション管理 の冪等性セクションで詳しく記載されているため、こちらをご参照ください
合わせてマイクロサービス側からはなんらかの処理が完了した際にはその旨をGoogle Cloud Pub/Subを用いてpublishするようにしています。呼び側のサービスはそれをsubscribeし、データの不整合が起きていないかチェックし、不整合が起きている場合はRetryまたは取り消しをすることでデータの不整合の解消を行います。
取引関連など重要な処理では上記に加えて定時実行のバッチでも不整合が起きていないかチェックし、不整合がある場合は同様の不整合解消処理をするようにしています。
データ不整合に関しては今後もマイクロサービスが増えていくのでまだまだ課題が残る箇所になります。
大まかには上記のようにコード修正を行いました。メルカリリリース当初から稼働している機能のため
- 単純に物量も多い
- 明確な仕様書がなく、実装者はすでに不在という箇所も存在した
地味な作業ですが多くのリソースが割かれました。
おわりに
決済システムのマイクロサービス化に伴うデータ移行について簡単ですが、行ったことをご紹介しました。
データ移行というとサービス上は変化はなく、お客様に気づかれることもないが、ひとたび事故が起きると即時クリティカルというあまり好ましいタスクではないかもしれません。
しかしそれらの対応する過程の中で、普段では気にしないような所までコードを見る必要があり、サービスのドメイン知識を得る機会になります。
またマイクロサービス化によって起こり得るデータ不整合対策の経験は今後の設計時に役立つものだと思います。
加えてそうそう出会う機会がないレアなタスクでもあると思います。
幸か不幸かメルカリ・メルペイにはまだまだマイクロサービス化、データ移行したいものがたくさんあります。
Backend Engineer募集しておりますので、ご興味のある方は是非ご応募ください。
明日の記事は、@kitasukeによる「エキスパートチームによるSwiftコミュニティへの取り組み」です。お楽しみに!