はじめに
はじめまして。Kyashでサーバサイドエンジニアを担当しているhirobeです。
Kyash Advent Calendar 2021の12/5担当分です。
Kyashでは、約30ほどのマイクロサービスが動いてます。
マイクロサービスは難しいです。
私が入社して2年半ほどの間、マイクロサービスの複雑さに苦しめられ、あがいてきた実経験をもとに、マイクロサービスにひそむ難しさを紹介したいと思います。
ここでは、ケースとして、弊社の機能のひとつである登録カードからのリンクを実装する上で発生する問題を紹介したいと思います。もちろん弊社サービスを使ったことない人でもわかるように説明をしますのでご安心ください。
なお、最初に注意書きしておくと、本ブログではあくまで「マイクロサービスにひそむ複雑さとその対応法」を説明するためのわかりやすさを優先して説明していきます。事実とは異なるケースがあります。
事前知識
「登録カードからのリンク」とは、「自動チャージ」と呼ばれたりする弊社サービスの機能で、ユーザの大多数が利用している機能です。以下、「自動チャージ」に統一します。
Kyashは「Kyash Visaカード」と呼ばれるプリペイドカード(バーチャルカードを含む)を発行しており、そのカードを利用して、通常の決済を行うことができます。その入金手段は様々で他のクレジットカードや銀行口座、コンビニ、ATM等から入金できます。
事前に「自動チャージ」設定をすることで、決済する際に紐付けているクレジットカードから不足分を自動で引き落とすことができます。この機能を「自動チャージ」と呼びます。
例えば、残高が1,000円あり、Kyash Visaカードで10,000円の買い物をした場合、通常は決済が失敗します。が、事前に他のクレジットカードを紐付けて「自動チャージ」設定することで登録カードから不足分の9,000円が決済処理中に即時入金され、成功した場合は決済成功となります。
鋭い人は、「外部との通信が発生しそうだし、なんか遅そうだな」と思うかもしれませんが、その通りです。「自動チャージ」が発生する決済と発生しない決済では「自動チャージ」が発生する決済のほうが遅いです。
このブログで必要な弊社サービスの事前知識は以上です。
専門用語として「オーソリ」のみ使わせてください。「オーソリ」とは決済電文のことです。決済に必要な情報がつまっているリクエスト情報のことで、ここでは、決済を担当しているサービスに届くJSONリクエストのようなものと理解して構いません。レスポンスとしてOKを返せば決済成功とみなされ、無事買い物が成立することになります。
問題
さて、この自動チャージは実装においてどのような問題が発生しうるのでしょうか。以下のようなことが起こりえます。
- 1円残高時に、3,000円、2,000円のオーソリがこの順でほぼ同時(数ms~数10ms)に走るとする1
- 不足額を2,999円、1,999円と判断し両者ともにチャージは成功する
- 前者の決済成功後の残高は1,999円のため2,000円の決済は残高不足で失敗する
この場合、ユーザからみると、「1,999円のクレカチャージされ、2,000円の決済は失敗」という経験をします。クレカチャージされた1,999円分は当然残高に残っているのでユーザは損をしているわけではないですが、不必要にチャージされることになり、良いユーザ体験とはいえません。
図を見たほうがわかりやすいでしょう。2
「決済」「チャージ」「残高」はそれぞれ独立しているマイクロサービスです。
「外部」は「チャージ」マイクロサービスからよばれる外部のAPIで1~5sほどで平均的にレスポンスされ、遅い時で10秒ほどでレスポンスされることが期待できるとします。
「残高サービスのDB」は「残高」マイクロサービスのみから参照可能なデータベースです。
実は、もともと残高が1円以上あるというのがポイントです!残高が0円であれば、3,000円チャージ、2,000円チャージが行われ、成功します。3
Kyashの自動チャージ利用者はその機能の特性上、残高が0円のユーザが大半なので、「残高が1円以上ある」✕ 「オーソリが同時にくる」の2つの稀な事象が偶然重ならない限りこの問題はおきません。
このような稀な事象でも、決済というドメインでは問い合わせにつながるし、考慮しなけばいけないところが、決済サービスのツライところでもあり、楽しいところでもあると思います。4
「マイクロサービス特有の問題ではなく、モノリスでもこの問題はツラくないか?」と思うかもしれません。最大10秒ほどの残高のlockを許容できるのであれば、「一番最初に残高テーブルを参照するときに行ロックをとる」だけで解決可能です。マイクロサービスではこれができません。
また、マイクロサービスは各サービスに専用DBをもつのが基本ですが、そうではなく仮に、共有DBを参照していたとします。5その場合も、「一番最初に残高テーブルを参照するときに行ロックをとる」ができません。なぜなら各マイクロサービスが残高を更新するためです。例えば、決済サービスがlockをとっているとチャージサービスは残高を更新できなくなってしまいます。
キャンセルAPIでキャンセルしよう
かっこいい言葉で「補正トランザクション」といったりします。マイクロサービスの文脈で、一貫性を保つために巻き戻したい処理のロールバックシーケンスのことを指しますが、実際にはただのキャンセルAPIです。
今回の場合、「2,000円のオーソリ失敗」後に、決済サービスからチャージサービスにキャンセルを叩き、チャージサービスから外部のキャンセルAPIをたたくことになります。これによりユーザの1,999円チャージはKyashのDB上にも外部の記録上にも存在しないことになります。
この方式のメリットは、チャージ成功後に決済サービスで何らかの理由により決済を失敗させたい場合にも対応できることです。図でいうと、青線が存在しないで赤線のみだとしても、「チャージ成功後に何らかの処理で失敗」 or 「レスポンスが遅すぎたから決済を失敗させよう」となった場合にもこのキャンセルAPIが利用できます。というより、実情としては順序が逆で、キャンセルAPIはもともと存在してしかるべきで、「ほぼ同時にオーソリ来た場合」にも対応できるよねという方が正しいでしょう。
この方式のデメリットは2つあります。1つめは、同時に来たオーソリの一部が失敗することです。
2つめは、外部サービスがキャンセルAPIを用意していない場合に採用することができないことです。
キャンセルしたい一連の処理の中にキャンセルを提供していないサービス(外部、社内問わず)が存在する場合はこの方式をとることができません。それが外部に存在した場合は諦める以外方法はありません。その場合どうするかは場合によりけりですが、キューベースで非同期にretryして成功側に倒せないか試みて、それでもだめなら手動対応するしかありません。
幸運にも、クレカ決済のドメインでは「キャンセル可能であること」が必須です。お店でクレカ決済してキャンセルできなかったことはないですよね。
一方、Kyashでも銀行チャージで利用している、銀行口座振替のドメインではキャンセル不可能だったりします。
最後に、キャンセルAPIの実装上の注意点を紹介します。実はキャンセルAPIの実装は難しいです。
ここで紹介するのは、バグに遭遇した時に感じた、「こう実装しといてくれればよいのに。。」とか、問題のある外部のAPI仕様を見た時に感じた「これ、API設計ミスってないか」みたいな個別具体的な経験からの知見ですので、どこかに体系的にまとまってたら逆に教えてほしいです。
キャンセルさせたい元APIでは冪等IDを呼び出し元で採番してリクエストしよう
「チャージAPI」「チャージキャンセルAPI」があったときに、「チャージAPI」のリクエストにはクライアント側が発行した冪等IDをもたせるようにすべきです。
レスポンスに冪等IDが含まれていて、これを元にキャンセルしてくださいという仕様をみたことがありますが、クライアント側でタイムアウト判定したいときや単純にレスポンスをうけとれなかったときに対処できません。
キャンセルさせたい処理はserialに呼ぼう
ある対象をキャンセルさせたいとなったとき、キャンセルの特性上、短いintervalで同じ対象にキャンセルAPIを呼びがちです。キャンセルさせようと判断ポイントが複数あったり、もしくは短いintervalでキャンセルAPIをretryすることが多いでしょう。
短いintervalでよばれたAPIを正しく実装するのは地味に難易度が高く、特に外部のAPIである場合にはコントロールできない6ので、どこかしらのマイクロサービスの呼び出し元でserialに呼んであげたほうが無難だと思います。実現するのは単純にキャンセルしたい取引レコードの行ロックを取得するなどで簡単に対応できるはずです。
キャンセルAPIを呼ばれたタイミングでは元処理が進行中である可能性を考慮しよう
チャージ側の処理で、一つのDBトランザクションで実行されている場合、問題が起こります。例えば、決済サービスが冪等ID「xxx」にてチャージリクエストし、呼び出して5秒後にキャンセルした場合を想定します。チャージサービス側では処理がつまり、8秒処理実行に要したとすると、キャンセルAPIが叩かれた際に元取引の冪等ID「xxx」なんてものはDBに存在しないぞ!?となってしまいます。
期待される処理完了時間の上限(例えば15秒など)までクライアント側がキャンセルAPIを呼ぶといった対応が必要になります。
チャージサービス側で、呼び出されてすぐに「取引状態管理テーブル」のようなものに記録してcommitしておけばこのようなことにはなりません。
Kyashがエッセンスを参考にしているDDDでは1ユースケース1トランザクションが好ましいと言われますが、トランザクションが大きい場合、完了していない可能性のある処理の状態取得が難しくなり、また細かい単位でトランザクションを作る方が整合性の面でも扱いやすいことが多かったりします。
ちなみに、メルペイさんのマイクロサービスにおける決済トランザクション管理という良いエントリーにも以下のような同じような話が書いてあり、やっぱりそうだよなぁという気持ちにはなります。
決済処理を受付時に内部トランザクションデータとIDを必ず一つのフェーズとして確定してから処理する
キャンセルAPIで対応できないケース
実は3件以上のオーソリが同時に来るとキャンセルAPIでも対応できないケースがあります。
以下はその一例です。
- 1円残高時に、3,000円、2,000円、1,000円のオーソリがこの順でほぼ同時(数ms~数10ms)に走るとする。
- 不足額を2,999円、1,999円、999円と判断しどれもチャージは成功し、残高は一時 5998(1 + 2,999 + 1,999 + 999)円になる
- 残高減算して、残高2,998円になり、3,000円決済成功
- 残高減算して、残高998円になり、2,000円決済成功
- 残高減算しようとするが、1,000円に足りず、1,000円の決済は失敗
- 999円のチャージキャンセルしようとするが、残高は998円なので失敗
難しく、騙された気分になるかもしれません。
ポイントは、残高が1円以上存在した状態で3件オーソリが同時にきたときに、「1件決済成功、2件決済失敗」となればよいのですが、たまたまチャージ分が早く残高加算されるがために「2件決済成功、1件決済失敗」になりうるというところです。
また、上記ではわかりやすさを優先し、2で全チャージ分が加算されるようにしましたが、例えば、途中で3,000円の残高減算が割り込んだ場合も同様の問題が発生します。
もちろん、他のケースでも、理論上は起こりえます。例えば以下のような単純なケースでも起こりえますが、今のところKyashでは観測されてはおりません。
- 0円残高時に、3,000円のオーソリがくる
- 不足額を3,000円と判断しチャージ成功し、残高3,000円
- 3,000円の減算する前に2,000円のオーソリが来る
- 2,000円のオーソリは残高のみで決済成立し、残高1,000円
- 残高不足で3,000円の決済が失敗
- 3,000円のチャージキャンセルしようとするが、残高は1,000円なので失敗
なお、ここであげた、チャージキャンセルの失敗のケースの場合、単純に実装すると、外部APIに対してのチャージキャンセルは成功し、残高減算に失敗しているので、Kyash側が損をすることになってしまいます。7
同一ユーザからのリクエストはserialに行われるようにする
前の章のような問題も考慮するならば、キャンセルAPIの作り込みに加えて、決済サービスでオーソリを受け取った時に、「ユーザ残高の論理的なグローバルロックをとる」しか解はないと思います。8
図で示すと以下のようになります。
あとは、これをどうやって実現するか。
RDBを利用した場合
すぐに思いつくのはRDBを利用したロックでしょう。
対応法として思い浮かぶのは以下の2パターンです。
- user_idのみから構成されるuser_account_lockテーブルを事前に作成する
- 1000万(数字は適当)レコードほど事前にinsertしておく
- lockを獲得したいときは対象ユーザのuser_idの行ロックを獲得する
- lockを開放したいときは、transactionを適当に終わらせればよい
- user_idのみから構成されるuser_account_lockテーブルを空にて作成する
- user_idにprimary_keyをつけておく
- lockを獲得したいときは、対象ユーザのuser_idをinsertする
- lockを開放したいときは、transactionをrollbackする
1、2ともに一長一短でしょう。いずれにしろ、以下の問題からRDBの利用はやめました。
- lockのためだけのトランザクションをアプリケーションロジックとは別で管理する必要がある
- lockの獲得試行時間、lockの獲得済み時間いずれも、タイムアウト時間を設定できない
etcdを利用した場合
etcdやzookeeperならグローバルロックを実現できるでしょうが、明らかにToo muchだと思います。
仮に検討するにしても、調べる限りはAWS等でマネージドサービスは提供されていないようで、このためだけに採用するのはありえないと思います。
Redisを利用した場合
Kyashではこちらを採用しています。
Redisであれば、スループット/レイテンシの懸念点は基本的にないですし、key/valueをロックに利用し、適切に期限を設定しておけば、最悪システムに問題がおきたとしても、ロックを取り続けてしまうといった事態をさけることができます。
次章にて、具体的な実現方法を述べます。
Redisでロック
達成したいことはユーザごとにtimeoutを設定したグローバルロックを実現することです。
ユーザを特定するIDをユーザIDとします。決済はどんなに遅くても10秒で終了することが期待され、lockのタイムアウトも10秒とします。
ユーザID123のユーザの決済処理にロックを取る時に、「keyにuser_id_123
があるかを確認し、なければvalueにフラグとして1
を、タイムアウト10秒で設定、あればリトライする」ことが考えられます。
が、アトミックにread/writeすべきなので、「NX」を使い、
SET user_id_123 適当なランダム値 NX PX 10000
のようにすべきです。同様にアンロック時は、アトミックな実行が可能であるLuaを利用し、
if redis.call("get",user_id_123) == SETしたランダム値 then
return redis.call("del",user_id_123)
else
return 0
end
のように書きます。突然でてきた「ランダム値」は何かというと、
- クライアントAがロックを取得
- クライアントAが何らかの理由により処理遅延(GCとかなんでもいい)し、許可されているロック時間を超えているのに気付かずアンロック
- ロックがタイムアウトした後、ロックを取得していたクライアントBのロックがアンロックされてしまった
といったことが起きないように「自分がかけたロックのみアンロック」するために利用します。
これで解決かというと、厳密にはそうではなく、Redisのレプリケーションが非同期であるため、
- クライアントAがロックを取得
- レプリに書き込まれる前にマスターがクラッシュ
- フェイルオーバーし、レプリがマスターになる
- クライアントBが同じロックを取得
となり、ロック対象をA/B両方同時に保持してしまう可能性があります。
上記が許容できない場合を想定し、Redisチームは、お互いに完全に独立したN台のmasterノードを利用した分散ロックアルゴリズムRedlockを提唱し、コミュニティに広め、推し進めています。
ただ、その分散ロックアルゴリズムは強く支持されているかといわれるとそうでもないようです。分散システムは本当に難しい世界で、本題とも離れてしまうのでここでは詳しく書きませんが、興味ある人は、基本となるブログや、「データ指向アプリケーションデザイン」の著者のRedlockへの批判やさらにその返答を読むとよいでしょう。読み物として非常に面白いですが、コメント欄はすごい量になっていて、読む気がしません笑。
分散システムの専門ではないのでRedlockの正当性を正確には理解できておりませんが、自分の意見は「たしかにPaxos/Raftに比べると信頼性は低いのかもしれないけど、Redisを利用するほとんどのケースでは、そこまでの保証は不要。そもそもCluster構成さえ不要では?」です。9
少なくとも、今回のユースケースではRedlockの耐障害性でも問題ありません。
Redlockはさまざまな言語でライブラリとして実装されてます。KyashではGoを利用しているので、
を使いました。
念のため、実装を確認したところ、本家のRedlockアルゴリズムと比べるとアルゴリズムに問題がある部分があったため、ついでに一部、修正しています。10
https://github.com/go-redsync/redsync/pull/71
https://github.com/go-redsync/redsync/issues/72
https://github.com/go-redsync/redsync/pull/80
最後にサンプルを載せて終わりにしたいと思います。
// 呼び出し元は決済処理開始前にcallする
// unlockFuncがnilの場合はロック取得失敗、non-nilの場合は残高処理後にunlockFuncをcallすること
func walletLockForPayment(ctx context.Context, userID uint64) (unlockFunc func()) {
..
// poolはredisのコネクションとする
sync := redsync.New(rs.NewPool(pool))
mutex := sync.NewMutex(
keyOfUserID(userID),
// lock待ちの場合のretry数
redsync.WithTries(40),
// lockの期限
redsync.WithExpiry(time.Second*10),
)
if err := mutex.LockContext(ctx); err != nil {
return nil
}
return func() {
ok, err := mutex.UnlockContext(ctx)
..
}
}
おわりに
ここまで書いて思いますが、読者はこんな細かい話に興味あるんだろうか笑。
詳細はわからなくても、マイクロサービスの難しさと日々向かい合っているんだなぁと実感いただければ幸いです。決済のドメインは稀にしか発生しない一貫性の欠如も許容されないため、より難しいなぁと思います。
これをツライと思うか、楽しいと思うのかは捉え方しだいです。自分はというと、質問されたタイミングで回答が変わるでしょう笑。
楽しそうと思った方はもしご興味ありましたら、ぜひ以下のリンクをご覧ください!
-
同一ユーザに同時に決済が飛ぶことなんてありえるのか?と思うかもしれませんが、稀ですが、実際にあります。対面決済ではさすがに見たことないですが、EC決済では起こりえて、カート内の商品をグループに分けオーソリを送っているようです。 ↩
-
補足ですが、図中にある「外部にチャージ依頼」は厳密には正しくなく、「外部にオーソリを投げる」が正しいです。つまり、ここではKyash側が加盟店となるわけです。また、「User」も本来は「加盟店」の方が適切です。 ↩
-
厳密にいうと、1,000円のオーソリのためのチャージ(赤線)が成功し、残高を1,000円に更新した後減算するまえに、2,000円のオーソリ(青線)がきて必要なチャージ額は2,000-1,000=1,000円だなと判断し、最終的に2,000円のオーソリは失敗となる可能性はゼロではないです。 ↩
-
そもそも決済などの厳密さが求められるドメイン以外ではわざわざユーザが問い合わせせずに、エンジニアが問題のあるケース自体に気づけないことが多そうです。 ↩
-
Kyashでもレガシーな部分は共有DBを参照していたりはします。 ↩
-
実際にバグに遭遇して修正してもらったことがあります。 ↩
-
一般に、「キャンセルのキャンセルAPI」は用意されてません。なので事前に外部のキャンセルAPIを叩く前に残高確認をしたほうがいいでしょう。それでも、外部のキャンセルAPIが最大10秒ほどかかることが想定される場合、他の残高変動が間に挟まれて、残高が足りないという事態が発生しえます。許容できないのであれば、先に残高減算してしまって、キャンセルAPIの実行に失敗したら残高をもどしてあげればいいでしょう。その場合、一時的に不必要な残高変動を参照できてしまいます。それすらも許容できないのであれば、いよいよTwo Phase Commitを採用するしかないでしょう。 ↩
-
「マイナス残高という概念をもちだして残高-1円でキャンセル成功とする」「非同期で請求を行うマイクロサービスに1円の請求情報を登録し、残高0円でキャンセル成功とする」などの方法もあります。ここでは、残高が足りない場合はキャンセル失敗とする前提とします。 ↩
-
一方、Redisノードの障害時に「残高計算がおかしくなり、ユーザが損失を被り、しかもそれを検知できない」が起こりえるといったケースでは、仮に障害が5年に1回の頻度だとしても許容できないでしょう。 ↩
-
実装を読むと、実は他にも問題がありそうだが直せてない。例えば、「During step 2, when setting the lock in each instance, the client uses a timeout which is small compared to the total lock auto-release time in order to acquire it. 」の実装がされていないように見受けられる。この記事書きながらちゃんとやらないとなと思っていたので頑張って修正しました。 https://github.com/go-redsync/redsync/pull/80 ↩