はじめに
Hackweek という毎年開催される社内ハッカソンが8/24~26の3日間で行われました。
社内ハッカソンでは、ビジネス職、エンジニア問わず案を持ってきて案に興味がある人で取り組むというようなイベントです。
その中で「 Saga パターンを用いたマイクロサービスの検証」というアイデアがあり、そのアイデアに興味がある5名で今回の検証を行いました。その中で得た知見や所感をこの記事では書いています。
なぜやろうとしたのか
印刷ECであるラクスルは、raksul.com の他に dm.raksul.com や novelty.raksul.com など他にも色々なサービスが展開されています。これらのサービスは各サービスごとにDBを持っており、一部 raksul.com に依存している機能はありますがそれぞれが独立したサービスとなっています。いわゆるマイクロサービスのようなアーキテクチャを取っています。
このようなアーキテクチャを取っていて課題に挙がることの1つとして複数サービスにまたがる処理のトランザクション管理がありました。1つのサービスであれば、同じ DB で処理を行っているため DB のトランザクションを使えばデータの整合性を保つことが出来ます。
Rails であれば以下のように1つのトランザクションで行いたい処理をブロックで囲むだけで実装できます。
ApplicationRecord.transaction do Order.create!(...) # 注文情報のレコード作成 Payment.ceate!(...) # 支払情報のレコード作成 SupplyOrder.create!(...) # 印刷発注用のレコード作成 end
このように実装することでどれか1つでも処理が失敗すると DB のロールバックが走りデータの不整合が起きないように出来ます。
しかし複数サービスにまたがるとそれぞれのサービスが DB を持っているため DB のトランザクションを使うことが出来ません。
Order テーブルを自身のサービスで持っていて、 Payment と SupplyOrder を外部のサービスに対して CreateAPI を叩こうとするときの擬似コードです。
Order.create(...) # 注文情報のレコード作成 PaymentApi.create(...) # 支払情報を作成するためのAPIコール SupplyApi.create(...) # 印刷発注を行うためのAPIコール
DB のトランザクションを使うことができないと、複数の DB をまとめて更新するようなアクションを行ったときに処理の途中で失敗してしまった場合データの不整合が発生してしまいます。DB のトランザクションが使えないため簡単にロールバックができません。データの不整合を直すために起こり得るケースを網羅してロールバック処理を行う方法があると思いますが DB のロールバックほど簡単には実装できず複雑な実装になってしまいます。
どのようなアーキテクチャがあるか
複数サービスをまたぐトランザクション管理を行うアーキテクチャとしてはいくつかあります。
今回実装する前に検討したアーキテクチャとしては TCCパターン と Sagaパターン です。
どちらのアーキテクチャも更新直後にデータの整合性を担保するものではありません。 更新から一定時間経過後にはデータの整合性を担保するという結果整合性の考え方を反映させたアーキテクチャになっています。
TCCパターン
Try / Confirm / Cancel の3つのアクションによって整合性を取るアーキテクチャです。
以下の流れで整合性を取ります。
- 最初に各サービスに対して Try を行い、与信枠や在庫などサービスが持つリソースを予約する
- すべてのサービスに対して予約が成功した場合
- 各サービスに対してConfirmを行い、リソースを正式に確保する
- いずれかのサービスで予約に失敗した場合
- 予約をしたサービスに対してCancelを行う
成功した場合のシーケンス図
Supply ServiceへのTryが失敗する場合のシーケンス図
Sagaパターン
補償トランザクションを使って整合性を取るアーキテクチャです。
補償トランザクションとは処理をすでに行ったサービスに対して処理を取り消す処理です。Sagaパターンの中にもコレオグラフィとオーケストレーションという2つの方法が存在します。
- コレオグラフィ
各サービスが受け取ったイベントを理解しそのイベントに応じて処理を行います。イベントの全体の流れはどのサービスも把握しておらず、各サービスは受け取ったイベントに対してどのような処理を行うことだけしか知らずその先のサービスでどういったことが起きるかは関知しません。
- オーケストレーション
イベント全体の流れを把握しているオーケストレーターというものが存在します。オーケストレーターはイベントを受け取ったら次のイベント行うために次のサービスを呼び出すための処理を行います。
アーキテクチャの選定
今回はSagaパターンのオーケストレーションを用いて検証を行いました。当初はTCCパターンでやってみようという話だったのですが、実装を行う前に色々調査を進めていたところTCCパターンは考慮しだすとかなり大変そうだということが分かりました。
TCCパターンでは失敗した時にCancelを行う必要があるのですが、このCancelのリクエストが何らかの理由で失敗してしまうということを考慮しないといけません。他にも通信エラーを考慮すべきケースがいくつかあり、こういったことからTCCパターンを完全に実装しきるのは難しいだろうという判断になりました。
そこでSagaパターンを使おうということになったのですが、コレオグラフィはイベントドリブンアーキテクチャへのパラダイムシフトが必要になり既存システムへの導入の難易度が高そうだという判断を行いました。消去法ではありますが、一番容易だと判断したSagaパターンのオーケストレーションを検証で用いようという判断になりました。
今回実装したサンプル
今回架空のECサイトを構築し検証を行いました。「このECサイトではシールとタオルを販売しており、タオルを買うユーザは一緒にシールも買うことができる。」というユースケースを設定し検証を行いました。
今回実装したECサイトの全体を表したのが以下の図です。
このECサイトには注文を受け付ける POST /orders のエンドポイントがあり、そのエンドポイントを担っているのがOrder Serviceです。 そこから決済情報を担うPayment Service、タオルの発注を担うNovelty Printing Service、シールの発注を担うPrinting Serviceのそれぞれにリクエスト行いユーザが注文できるかどうかを判断します。
また今回の検証では3日間という時間の制約があったこともあり、認証の機能は挟まずログイン済みのものとして各サービスがやりとりを行っています。
APIで実装した場合
まず Bad Pattern として API で実装した場合について説明します。
モデル図
注文は Order と OrderItem の2つのテーブルで構成しています。
Order は注文自体を表し、OrderItemは注文に含まれる商品を表しています。例えば、1回の注文でタオルやシールをそれぞれ買うと Order に対して 2つのOrderItems(タオル・シール)が紐付きます。
シーケンス図
上記のサンプルECサイトを単純にAPIコールした場合の正常系のシーケンス図です。
注文に関する流れは以下の通りです。
- ユーザが注文を行うときに Order Service に対してリクエストを行う
- Order Service は自身のDBに注文情報を作成する
- Order Service は Printing Service に OrderItem(シール) をパラメータとしてリクエストを行う
- 3が成功したのを確認すると Order Service は Novelty Printing Service に OrderItem(タオル) をパラメータとしてリクエストを行う
- 4が成功したのを確認すると Order Service は Payment Service に決済を行うためリクエストを行う
- 5が成功したことを確認すると Order Service はユーザに対して注文が作成されたことを伝える
このように正常系をみるとシンプルに見えますが、実際には各サービス間の通信はネットワークエラーなどエラーが発生することを考慮しなければなりません。 もし Payment Service でエラーが発生してしまうと、Novelty Printing Service、Printing Service をそれぞれの注文をキャンセルしなければなりません。異常系を考えるとかなり複雑になることが分かります。
Sagaパターン
前述の架空のECサイトをSagaパターンで実装するための設計について述べます。
モデル図
Sagaパターンでは、Order、OrderItemテーブルに加え注文ごとのSagaの状態を管理するSagaテーブルが必要になります。今回のユースケースでは注文の作成だけであるためCreateOrderSagaテーブルを追加します。
ステートマシン
今回実装する Sagaパターン はオーケストレーションによる方法を用いています。 現在のステートとイベントの発火によって次のステートが決まるため、実装する際にはステートマシンを使うのが良いだろうという判断をしました。
以下の図は今回発生するイベント、アクションをステートマシンとして表現した図です。
右側の白い四角で囲っている部分が正常系のステートです。左側の白い四角で囲っている部分が異常系のステートです。 initialized は初期状態を表しており、 order_approved のステートが注文完了を表し order_rejected は注文がキャンセルされたことを表しています。
正常系の場合のイベントとステートの遷移について以下の流れなります。
- 初期状態から start イベントが発火すると、タオルとシールの両方の商品が発注可能かを訪ねているステートである creating_supply_orders のステートに遷移する
- タオルとシールが発注可能状態になると create_payment イベントが発火され creating_payment のステートに遷移する
- 決済に問題がなければ、approve_supply_orders イベントが発火され approving_supply_orders のステートに遷移する
- 各商品の承認がすべて完了すると approve_order イベントが発火され approving_order のステートに遷移する
- 注文の承認が完了すると、success イベントが発火され order_approved のステートに遷移する
異常系へ遷移する場合は reject_supply_orders イベントを発火し、 rejecting_supply_orders ステートに遷移します。その後、 rejecting_order、order_rejected とステートが遷移し注文キャンセルとなります。
Sagaパターンの設計
Sagaパターンを実装するにあたり、トランザクションと補償トランザクションを設計する必要があります。今回は以下のテーブルのように設計を行いました。
step | service | Transaction | Compensation Transaction |
---|---|---|---|
1 | order service | createOrder() | rejectOrder() |
2 | printing service | createSupplyOrder() | rejectSupplyOrder() |
3 | novelty printing service | createSupplyOrder() | rejectSupplyOrder() |
4 | payment service | createPayment() | — |
5 | novelty printing service | approveSupplyOrder() | — |
6 | printing service | approveSupplyOrder() | — |
7 | order service | approveOrder() | — |
上記で設計したステップを図で示したものが以下の図です。
メッセージのやりとりを行って各サービスと連携する必要があるためメッセージブローカーである Kafka を用いています。 メッセージのやり取り行うトピックは4つあります。各サービスからのreplyを受け取る create_order_saga_reply_topic があります。 その他の printing_service_req_topic、 novelty_printing_service_req_topic、payment_service_req_topic の3つは各サービスへのリクエストを行うトピックとなっています。 今回はこれら3つのトピックを通して発注を行う(createSupplyOrder)、決済を行う(createPayment)などのメッセージを送信しています。
上記の図で表したものを実際に開発しました。開発を行う中でつまずいたことや実際の運用を行う際、課題になりそうなところなど感じたことを次の章で振り返りとしてまとめています。
振り返り
実際にサンプルECでオーケストレーションを用いたSagaパターンを実装して、感じたことをチームで振り返って出てきた内容について一部抜粋して書きます。
- オーケストレーターが難しかった、オーケストレータのコードを追うのが少し辛かった
オーケストレーターでは注文に関する様々なメッセージを受け取るため、自身の関心のある全てのメッセージに対してアクションを書く必要がありました。事前に状態遷移図を書いたりすることで整理を行ってから取り組んだのは良かったと思います。ちゃんとリファクタリングを行わないとコードが複雑になってしまい追うのが大変になってしまいそうだという感想でした。
- デバッグの難しさ
各サービスがメッセージのやりとりを行うため失敗したときに気づけるような仕組みやメッセージが一連の流れで追えるような仕組みが必要そうだという話をしました。
他にも以下のようなことがチーム内では上がっていました。
- Sagaパターンで動いていない既存システムに対してどうやって導入するか考える必要がある
- 実運用では複数のサービスで実装するには規約の整備が必要そう。専任のチームが必要かも
- ローカル環境での結合テストの難しさ
おわりに
本番で運用するために検証すべきことはさすがに3日間では終わりませんでしたが、実際に手を動かしてみると本や知識だけでは分からない色々な課題が見えてきました。
今回の検証しきれなかったこととして、同一メッセージを何度処理してもデータの不整合が起きないようにする冪等性の担保や一定時間経過してもイベントが進まない場合にどういった処理を行うのか、他にもリトライやデッドロック、メッセージの処理順序による整合性が挙げられます。 実運用で今回のパターンを実装するにはまだまだ時間をかける必要がありそうです。