マイクロサービス間でのデータ同期方法

榎本悠介氏(以下、榎本):実際にデータ同期をどうやるか、マイクロサービス間でどうやってデータを同期させるかという話に移ります。前提として、プライベートAPIを叩いてデータを同期するのは悪手だと思っています。

申請された情報を渡して請求書レコードを作らせる、申請情報を入れる時に、(スライドを指の)バクラク請求書部分がAPIになっていると同期処理になってしまうので、もともとやりたかった、ただ申請をしたかっただけの人にとっては遅くなったり、他の障害に引きずられて本来の動作ができなくなったりする。

申請をしたかっただけなのに、なぜか請求書側の事情で止まってしまうなど、トランザクションの境界が分かれているので、こっちが失敗した時にリトライの制御が大変です。結論としては、非同期処理をするのがいいと思っています。queueか何かをかますなどして、エンキューして(スライドの)バクラク請求書までの部分でデキューして、ダメだったらリトライして、結合度を下げるのがいいと思います。

エンキューするパターンにもいろいろあります。3つのうち、1つ目を僕はPush型と呼んでいますが、queueの中に申請の実体を丸ごと入れてUPSERTするものです。シンプルで当たり前という感じですが、必要なデータを丸ごとエンキューしてUPSERTすることをPush型と呼んでいます。そうすると(スライドを指して)上から下への依存が発生します。

次にPull型。エンキューする内容を変えます。申請IDだけをエンキューして「申請の丸っとしたペイロードは聞きに来い」という感じで、APIを1回かまして内容をUPSERTする。IDだけエンキューして、実体は一度取りに来させる。

(これの)何がうれしいかというと、強い依存関係が上から下ではなく下から上に変わるんです。もちろん一見循環参照しているんですが、IDだけをエンキューしているので許してもらって、強い依存関係を下から上に変えることができます。

最後に仲介型です。これが一番複雑なのですが。新しいコンポーネントを作ってしまい、これにIDだけを渡して、先ほどと同じようにIDを元に実体を取って来て、その実体をUPSERTしたいリクエストに投げる。これをかますことで、プロダクト間での依存関係はあまり生まれない。強い依存関係はコンポーネント(Data Sync)を元にしているので、2つのプロダクト間にはあまり生まれない方法があり得ると思います。

まとめると、依存関係がどう生まれるかや、コンポーネントの数や複雑さが違うと思っています。どれがいいかはかなりドメインに依存すると思っており、将来ありそうな要件をベースに考えていきました。

開発を進める中で出てきた追加要件

細かくなりますが、バクラク請求書側には、申請する時にマスターデータを入れたいという話が出てきました。会計上の部門です。それを申請の中に入れて表示することで、どの部門で使われた費用かがわかる。管理会計上どの部門が作った費用かがわかるうれしさがあります。

やり方はシンプルで、申請を表示したい時、申請には部門IDがひもづいているので、それを元に部門の実体を取得して表示する。今回のプロダクトは申請から請求書という依存が発生しやすいと理解しました。

そのため結論として、発生しやすい申請から請求書の依存に対応しやすいPush型、一番シンプルなものにしました。まるっとペイロードを入れるパターンです。ここまでがひととおりりの循環参照を避ける旅でした。

補足しますが、データの置き場所はとても大事です。先ほど、マスターデータはバクラク請求書が持っていると話しましたが、そもそもマスターデータを共通管理の場所(一番下のレイヤー)に置いておけば、依存は発生しないはずです。

そのため、要件によっては各プロダクトから参照されやすいマスターデータを共通基盤に逃がしてやる手もあります。どこから更新されるか、どこから参照したくなるかなど、あらゆるユースケースを想像しないと誰の持ち物かが決まらないので、事前に決めるにはメチャクチャなドメイン知識とエスパー力がいる。

設計レベルでも、事前にドメインエキスパートと相談してやっていました。実際に、ミスしたと思って後からマスターデータをマイクロサービス間で引っ越ししたこともあります。

さらに補足ですが、不整合が起こるリスクがあるので、リトライやデータ同期のジョブを監視するのは当たり前ですが、最終的にデータの不整合がないか監視するものを立てて、何かあったらアラートを飛ばす仕組みを入れています。ここまでが循環参照を避ける話でした。

レプリケーション

次に、レプリケーションについて話します。またデータ同期の話ですが、新しい話として、他のリレーショナルデータベースとジョインしないと無理なクエリが出てきました。この時点で「ドメイン境界とは何ぞや」と思う方がいるかもしれませんが、落ち着いてください。

先ほどの例はデータ同期していましたが、データ同期だけではなく、請求書レコードを作る。申請を元にレコードを作るというフックした処理があったのがポイントです。今回はただのデータ同期、レプリケーションだけでよくてフックなどがないので、他にもデータ同期パターンがあると思いました。

まず、先ほど紹介したPush・Pull・仲介はそのまま使えます。Writeして実体をエンキューしてまた同じものを書くというものには、そのまま使えます。

新しいパターンとしてあり得ると思ったのは、バッチが定期実行して更新があったレコード一覧をくれて、更新があったもの、UPSERTするものが定期的に回っているやり方です。定期的にPullをする感じです。差分があったレコードだけをもらってUPSERTする。

クライアントサイドは最終更新時刻を持っておいて、「それ以降に更新されたレコード一覧をくれ!」というイメージです。

メリットとして、フックした処理ではなく定期実行されているので、取りこぼしが起きづらいと思っています。安心してデータの整合性を保ちやすい気がします。デメリットは、バッチなのでリアルタイム性がイベント起因ではなく低いこと。

ほかに、一括で上の人がデータ更新しているとめちゃくちゃ更新量が多くてやばい。また、レコード一覧をくれという仕組みなので物理削除したレコードをもらいにくい。削除されている一覧を別でもらう必要があるので、ひと手間必要です。

脳みそ筋肉な感じですが、DBをまたいでレプリケーションするパターンも考えました。例えば、AWS DMS(Database Migration Service)というおもしろいものがあります。ホストをまたいだ特定テーブルのレプリケーションが可能で、オンプレからクラウドに移行する時によく使われるそうですが、1回限りでなく継続的なレプリケーションが可能です。

実際に素振りしてみましたが、問題なくホストをまたいで特定のテーブルをレプリケーションできました。ALTER文もシンクされて気分が上がりました。一方、ローカルでこの仕組みを動かすのはけっこう無理ゲーだとか、マイクロサービス間でデータ同期を使っている同様のユースケースが見つからなくてかなり不安だったので、結論としては定期Pull型です。

定期的に更新分をもらってUPSERTする。デメリットもいくつかありましたが、今回のユースケースでは許せたのでそれを採用しました。ユースケースによって許せるデメリットや依存関係が違うので、一口にデータ同期といっても銀の弾丸はない気がします。

アーキテクチャの再考、モジュラモノリスの挑戦

ここまでを元に、再びアーキテクチャを見ていきます。現在進行形で第3のプロダクトと何らかの連携がしたいというニーズが増えています。バクラク請求書から第3のプロダクトのバクラク電子帳簿保存、バクラク申請からバクラク電子帳簿保存あるいはその逆の連携がしたいという声がチラホラ出てきています。

その度に設計をメチャクチャ考える必要があり、「いったい何と戦っているんだろう」という気持ちになります。夢だったと思っている人も多いと思いますが、そもそもドメイン境界を間違えていないか、モノリスじゃだめなのかと思っていたので、実際に発表してやってみました。

「もうだめだ!」となって、1、2週間集中してこれに取り組みました。「モノリスにするんだ」「マイクロサービスは夢だったんだ」と。ただ、さすがにせっかく分かれているDBまで統合するのは(よくない)と思ったので、ある意味モジュラモノリスというか、DBも内部のパッケージも分けて、ただ1つのAPIにするプロジェクトを始めました。

とにかく循環参照の問題から解放されたかった。解放されてデータ同期をやる必要がなくなるとか、ロジックが共有できるとか、プライベートAPIを内部で持つ必要がないとか、いろいろなメリットを考えてやりましたが、お疲れ様でした。無理でした。撤退しました。393ファイルの4万5,000行が撤退しました。

モジュラモノリスを試したけれど、循環参照問題からは解放されなかったんです。僕がきちんとモジュラモノリスをきちんと理解しているか怪しいので、今後も理解する努力をしていこうと思います。

モジュラモノリス間で循環参照してしまう問題があり、それを避けるためには似たようなことをコード内で考える必要がありました。どちらのパッケージがどちらの持ち物か。例えば申請の情報を請求書がembedする、structがembedするものがあると常に依存が発生するので、今までの複雑さをコードの間の複雑さに押し込めた感じがして辛い。結局、循環参照を避けなければいけないと。

ほかに、DBを分ける選択をした時点でデータの不整合リスクが残っているとか、組織をスケールしたいという思いはどこにいったのかとか、コードを書くのが単純に楽しくなくなったとか、いろいろありました。

今思えば、真に目指すべきはモノリスだったのか。DBも完全に統一して循環参照を知らない、パッケージ依存も知らないくらいにフラットに置くような、完全なモノリス化だったらあり得たと思いますが、今享受しているメリットを全部捨てることになるし、完全に内部で循環させるのは、戻ることのできない選択だと理解しています。ゼロベースでやるならともかく、今からやるのは違うと思っています。

マイクロサービスで受けているメリットは何か?

あらためて今の構成のメリットは何か、マイクロサービスで受けているメリットは何かに立ち返ると、開発速度がメチャクチャ上がっていると感じます。基本的には各位が自立して動いたことで、実際に開発速度が上がり、運用もできている。今も現在進行形で、特に問題なく新しいプロダクトを開発しようとしている。

もちろんデータ連携も大変ですが、そもそも単体で成立しているプロダクトを忘れてはいけないと思っています。ほかにも、今回は紹介していませんがOCR(Optical Character Reader)の基盤や認証基盤など、いろいろな機構がファンクションベースでマイクロサービスになっていて、これらは間違いなく分けてよかったと思っています。

単体で成立しているプロダクトなので、一部データ連携があるかもしれないし、モノリスにするのは暴論かもしれない。今後も新しいプロダクトを展開していきたい、その可能性があるから全部モノリスにするのもなんだかなと思うので、全部モノリスに変えるのは違うと思います。チームが新しいプロダクトや新しい開発手法を試せているのもいい(こと)です。

複数のプロダクトを掛け合わせたSaaSはドメイン境界の設定が難しい

最後にまとめます。複数のプロダクトを掛け合わせて業務フローを滑らかにするSaaSは、ドメイン境界の設定がめちゃくちゃ難しいと感じました。ただプロダクトが違うから分ければいいというわけではない。適切な切り方を知るには事前に深いドメイン知識が必要で、あとからわかることも多い。

お客さまの声を聞いてから理解を深めることも多いです。その中で、マイクロサービス間の連携や、どこのデータを誰の持ち物にするかはドメイン知識のスキルが問われるし、ユースケースに依存すると感じました。もちろん、割り切って「こういうのは無理だから最初からモノリスにしようぜ」ということも十分あると思っています。

ただ、先輩のSaaSたちがあとから切り出すのに苦労するのも、速度やスケールするのか悩むのも事実で、簡単ではない。モジュラモノリスっぽいものも一度試しましたが、依存関係と戦うことになるので、銀の弾丸とは思いませんでした。

最後に、(弊社では)顧客と向き合って最高の体験と最適な設計を両立できるエンジニアを求めています。ご清聴ありがとうございました。