LoginSignup
79
80

More than 5 years have passed since last update.

RESTful API設計におけるトランザクション

Last updated at Posted at 2015-09-15

概要

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すればその商品を購入できるとしよう。

request
POST https://api.example.com/orders HTTP/1.1

{
    "products": [5, 10, 15],
    "card": ...
}
response
HTTP/1.1 201 Created
Location: https://api.example.com/orders/3456

{ ... }

処理フロー

  1. トランザクション開始
  2. productsをSELECTしてstockがあるか確認する
    • ここで商品を確保する(productsの該当レコードのロック)が必要となる
  3. ordersにINSERT
  4. order_detailsにリクエストの商品配列の件数分INSERT(order_id=ordersと紐付け, product_id=リクエストから)
  5. 外部APIに連携して決済する(total_priceを計算して、cardを渡す)
    • 失敗すれば ロールバック
  6. 決済が成功すればproductsをUPDATE(stockを減らす)
  7. paymentsにINSERT(order_id=ordersと紐付け, amount= total_price, card=リクエストから)
  8. コミット

処理全体がトランザクション内で実行されるため外部APIで失敗した場合にロールバックすればなんの問題もないように思われる。
しかし、実際はproductsの該当レコードをロックしてしまっているため同時に同じ商品を購入できるのがひとりだけになってしまう。
決済にはそれなりに時間がかかるので人気商品を買うのに決済待ちの長蛇の列ができてしまうことになる。
こういったリソースを確保しながら他の時間のかかる処理をする場合、リソースの確保と時間のかかる処理を分割して実行しないといけない。

また、クライアントから直接外部APIに連携する場合は、はじめから分割して実行する必要がある。

しかし、分割して実行すると状態を管理する必要がある。ステートレスなRESTful APIでどのようにして実現するのか?

トランザクションリソースを使った設計

やりたいこと

  • トランザクションの開始がある。
    • トランザクション内かどうかを判定できること。
  • トランザクションはコミットかロールバックかで終了される
    • コミットした場合はトランザクション内の全ての処理が実行された状態となり
    • ロールバックした場合はトランザクション内の全ての処理が実行されなかった状態となる
  • トランザクション内で整合性条件を満たさない場合は必ずロールバックする

できなくてもいいこと

  • 非同期に実行するためトランザクションの途中の状態が外から丸見えなのは仕方がない。
    • 分離性についてはあきらめよう

やりかた

  • トランザクションリソースという低レイヤーのリソースをでっち上げる
  • 本質的なリソースの代わりにトランザクションリソースに対するリクエストに置き換える
  • ロールバックは自前でロールバック前の処理を打ち消すような処理を行う

具体例

1. トランザクション開始

例えば、order_transactionsをトランザクションを表現するリソースとすると、

request
POST https://api.example.com/order_transactions HTTP/1.1

でトランザクションを開始する。

response
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123

{ ... }

処理

  1. order_transactionsにINSERT

2. 商品購入

request
PUT https://api.example.com/order_transactions/123/purchase HTTP/1.1

{
    "products": [5, 10, 15]
}
response
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を使用する場合は、

request
POST https://api.example.com/order_transactions/123 HTTP/1.1

{
    "purchase": {
        "products": [5, 10, 15]
    }
}

となる。しかし、このような冪等性が必要な処理にはPOSTの代わりにPUTを使ったほうが混乱がなくていいと思う。

処理

  1. productsをSELECTしてstockがあるか確認する
    • ここで商品を確保する(productsの該当レコードのロック)が必要となる
  2. ordersにINSERT
  3. order_detailsにリクエストの商品配列の件数分INSERT(order_id=ordersと紐付け, product_id=リクエストから)
  4. productsをUPDATE(stockを減らす)
  5. order_transactionsをUPDATE(order_id=ordersと紐付け)
  6. コミットする(DBのACIDトランザクション)

ここで、1.で在庫がない場合は、ロールバックする

ロールバック

トランザクションリソースを削除する。サーバは後続処理を実行できなくなる。

request
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
response
HTTP/1.1 204 No Content
  1. order_transactionsをDELETE

3. 決済

request
PUT https://api.example.com/order_transactions/123/purchase/payment HTTP/1.1

{
    "total_price": 12800,
    "card": ...
}
response
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123/purchase/payment

{ ... }

処理

  1. 外部APIに連携して決済する(リクエストのtotal_priceとcardを渡す)
  2. paymentsにINSERT(order_id=ordersと紐付け, amount=リクエストのtotal_price, card=リクエストから)
  3. コミットする(DBのACIDトランザクション)

ここで、1.で決済に失敗した場合は、ロールバックする

ロールバック

まず、購入した商品を棚に戻す。

order_cancellationsという取消イベントを表すテーブルを作る。order_cancellationsorder_idがあるordersは取り消されていると判断する。

request
PUT https://api.example.com/order_transactions/123/cancellation HTTP/1.1
response
HTTP/1.1 201 Created
Location: https://api.example.com/order_transactions/123/cancellation

{ ... }

  1. order_cancellationsにINSERT(order_id=ordersと紐付け)
  2. order_detailsproduct_idをみてproductsstockを増やす
  3. コミットする(DBのACIDトランザクション)
つぎに、トランザクションリソースを削除する。

サーバは後続処理を実行できなくなる。

request
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
response
HTTP/1.1 204 No Content
  1. order_transactionsをDELETE

4. コミット

トランザクションリソースを削除する。

request
DELETE https://api.example.com/order_transactions/123 HTTP/1.1
response
HTTP/1.1 204 No Content
  1. order_transactionsをDELETE

課題

  • 以上の方法でやりたいことは実現できている。
  • ロールバックがビジネスロジックに依存するため汎用的なトランザクションリソースを作るのは難しそうだ。(やってできないことはないと思う)
  • トランザクションリソースという場違いなリソースがでてきてしまう一方で本質的なordersなどのリソースがただのパラメータになってしまったため、無理矢理感は否めない

いろいろPUTしたけど、GETするとどうなる?

  • /order_transactionsGETするとトランザクション途中の処理一覧が取得できる。
  • /order_transactions/123GETするとトランザクション(id=123)の状態が取得できる。
  • /order_transactions/123/purchaseGETするとトランザクション(id=123)の購入商品リストと金額が取得できる。
  • /order_transactions/123/purchase/paymentGETするとトランザクション(id=123)の決済情報が取得できる。
  • /order_transactions/123/cancellationGETするとトランザクション(id=123)の取消有無が取得できる。
  • いずれもトランザクションが完走出来ていない場合に途中から再開するときの拠り所とすることができる

途中経過はどのようにみえるか

  • 商品購入後は/orders/3456GETすることができる。order_transactionsorder_id=3456があるため、トランザクション処理中と判断することができる
    • 当然、子要素/orders/3456/detailsGETできる
  • 商品購入後は/products/5stockは減っているようにみえる
    • 本来、これは見えてはいけない
  • 決済成功後は/orders/3456/paymentGETできる
  • 決済失敗後でロールバック前は商品購入直後と同じようにみえる。つまり、在庫は減ったままにみえる。
  • 決済失敗後でロールバック後は商品の在庫は戻ってみえる
  • 決済失敗後でロールバック後は/orders/3456GETできない
79
80
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
79
80