概要
RESTful APIでリソースのロックが長期間になりそうなときRDBMSのACIDなトランザクションを複数組み合わせてロック期間を短くしつつトランザクション処理っぽくする方法。
RDBMSの1つのトランザクションだけで処理するのは難しい
例として、商品を購入し外部のクレジットカード決済APIで決済する処理を考えてみる。
テーブルはorders(id)
, order_details(id, order_id, product_id)
, products(id, price, stock)
, payments(id, order_id, card, amount)
があるとして、/orders
に商品IDの配列とクレジットカード情報をPOST
すればその商品を購入できるとしよう。
POST https://api.example.com/orders HTTP/1.1
{
"products": [5, 10, 15],
"card": ...
}
HTTP/1.1 201 Created
Location: https://api.example.com/orders/3456
{ ... }
処理フロー
- トランザクション開始
-
products
をSELECTしてstockがあるか確認する- ここで商品を確保する(
products
の該当レコードのロック)が必要となる
- ここで商品を確保する(
-
orders
にINSERT -
order_details
にリクエストの商品配列の件数分INSERT(order_id=ordersと紐付け, product_id=リクエストから) - 外部APIに連携して決済する(total_priceを計算して、cardを渡す)
- 失敗すれば ロールバック
- 決済が成功すれば
products
をUPDATE(stockを減らす) -
payments
にINSERT(order_id=ordersと紐付け, amount= total_price, card=リクエストから) - コミット
処理全体がトランザクション内で実行されるため外部APIで失敗した場合にロールバックすればなんの問題もないように思われる。
しかし、実際はproducts
の該当レコードをロックしてしまっているため同時に同じ商品を購入できるのがひとりだけになってしまう。
決済にはそれなりに時間がかかるので人気商品を買うのに決済待ちの長蛇の列ができてしまうことになる。
こういったリソースを確保しながら他の時間のかかる処理をする場合、リソースの確保と時間のかかる処理を分割して実行しないといけない。
また、クライアントから直接外部APIに連携する場合は、はじめから分割して実行する必要がある。
しかし、分割して実行すると状態を管理する必要がある。ステートレスなRESTful APIでどのようにして実現するのか?
トランザクションリソースを使った設計
やりたいこと
- トランザクションの開始がある。
- トランザクション内かどうかを判定できること。
- トランザクションはコミットかロールバックかで終了される
- コミットした場合はトランザクション内の全ての処理が実行された状態となり
- ロールバックした場合はトランザクション内の全ての処理が実行されなかった状態となる
- トランザクション内で整合性条件を満たさない場合は必ずロールバックする
できなくてもいいこと
- 非同期に実行するためトランザクションの途中の状態が外から丸見えなのは仕方がない。
- 分離性についてはあきらめよう
やりかた
- トランザクションリソースという低レイヤーのリソースをでっち上げる
- 本質的なリソースの代わりにトランザクションリソースに対するリクエストに置き換える
- ロールバックは自前でロールバック前の処理を打ち消すような処理を行う
具体例
1. トランザクション開始
例えば、order_transactions
をトランザクションを表現するリソースとすると、
POST https://api.example.com/order_transactions HTTP/1.1
でトランザクションを開始する。
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123
{ ... }
処理
-
order_transactions
にINSERT
2. 商品購入
PUT https://api.example.com/order_transactions/123/purchase HTTP/1.1
{
"products": [5, 10, 15]
}
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123/purchase
{
"order": {
"id": 3456,
"products": [5, 10, 15]
},
"total_price": 12800
}
購入が複数回ある場合は末尾に連番をつけて(/purchases/1
)トランザクション内の処理の順序を表してもいい。順序がどうでもいい場合はuuidでもいい。とにかく、サーバは状態を管理しないのでクライアントで管理しないといけない。クライアント側で採番してサーバに知らせないとサーバ側で再送の判定ができず二重に購入してしまいかねない。
支払合計金額を計算してレスポンスに含める。一種の請求書となる。
なお、PUT
ではなくPOST
を使用する場合は、
POST https://api.example.com/order_transactions/123 HTTP/1.1
{
"purchase": {
"products": [5, 10, 15]
}
}
となる。しかし、このような冪等性が必要な処理にはPOST
の代わりにPUT
を使ったほうが混乱がなくていいと思う。
処理
-
products
をSELECTしてstockがあるか確認する- ここで商品を確保する(
products
の該当レコードのロック)が必要となる
- ここで商品を確保する(
-
orders
にINSERT -
order_details
にリクエストの商品配列の件数分INSERT(order_id=ordersと紐付け, product_id=リクエストから) -
products
をUPDATE(stockを減らす) -
order_transactions
をUPDATE(order_id=ordersと紐付け) - コミットする(DBのACIDトランザクション)
ここで、1.で在庫がない場合は、ロールバックする
ロールバック
トランザクションリソースを削除する。サーバは後続処理を実行できなくなる。
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
HTTP/1.1 204 No Content
-
order_transactions
をDELETE
3. 決済
PUT https://api.example.com/order_transactions/123/purchase/payment HTTP/1.1
{
"total_price": 12800,
"card": ...
}
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123/purchase/payment
{ ... }
処理
- 外部APIに連携して決済する(リクエストのtotal_priceとcardを渡す)
-
payments
にINSERT(order_id=ordersと紐付け, amount=リクエストのtotal_price, card=リクエストから) - コミットする(DBのACIDトランザクション)
ここで、1.で決済に失敗した場合は、ロールバックする
ロールバック
まず、購入した商品を棚に戻す。
order_cancellations
という取消イベントを表すテーブルを作る。order_cancellations
にorder_id
があるorders
は取り消されていると判断する。
PUT https://api.example.com/order_transactions/123/cancellation HTTP/1.1
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123/cancellation
{ ... }
-
order_cancellations
にINSERT(order_id=ordersと紐付け) -
order_details
のproduct_id
をみてproducts
のstock
を増やす - コミットする(DBのACIDトランザクション)
つぎに、トランザクションリソースを削除する。
サーバは後続処理を実行できなくなる。
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
HTTP/1.1 204 No Content
-
order_transactions
をDELETE
4. コミット
トランザクションリソースを削除する。
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
HTTP/1.1 204 No Content
-
order_transactions
をDELETE
課題
- 以上の方法でやりたいことは実現できている。
- ロールバックがビジネスロジックに依存するため汎用的なトランザクションリソースを作るのは難しそうだ。(やってできないことはないと思う)
- トランザクションリソースという場違いなリソースがでてきてしまう一方で本質的な
orders
などのリソースがただのパラメータになってしまったため、無理矢理感は否めない
いろいろPUT
したけど、GET
するとどうなる?
-
/order_transactions
をGET
するとトランザクション途中の処理一覧が取得できる。 -
/order_transactions/123
をGET
するとトランザクション(id=123)の状態が取得できる。 -
/order_transactions/123/purchase
をGET
するとトランザクション(id=123)の購入商品リストと金額が取得できる。 -
/order_transactions/123/purchase/payment
をGET
するとトランザクション(id=123)の決済情報が取得できる。 -
/order_transactions/123/cancellation
をGET
するとトランザクション(id=123)の取消有無が取得できる。 - いずれもトランザクションが完走出来ていない場合に途中から再開するときの拠り所とすることができる
途中経過はどのようにみえるか
- 商品購入後は
/orders/3456
をGET
することができる。order_transactions
にorder_id=3456
があるため、トランザクション処理中と判断することができる- 当然、子要素
/orders/3456/details
もGET
できる
- 当然、子要素
- 商品購入後は
/products/5
のstock
は減っているようにみえる- 本来、これは見えてはいけない
- 決済成功後は
/orders/3456/payment
もGET
できる - 決済失敗後でロールバック前は商品購入直後と同じようにみえる。つまり、在庫は減ったままにみえる。
- 決済失敗後でロールバック後は商品の在庫は戻ってみえる
- 決済失敗後でロールバック後は
/orders/3456
はGET
できない