在庫UPDATEがつらいのでやめた。Aurora DSQLとAuroraでECスパイクトラフィックを殴ってみた
ECサイトのスパイクトラフィックでつらいのは、だいたい在庫だ。
注文が一気に入ると、同じ商品の在庫行に対して UPDATE inventory SET quantity = quantity - 1 が殺到する。アプリケーションはシンプルでも、DBは急に苦しそうな顔をする。
Amazon Aurora DSQL が気になっていたので、よくあるECの注文APIを雑に切り出し、Aurora PostgreSQL と Aurora DSQL を同条件でぶつけてみた。
結論だけ先に書く。
- 同一行UPDATEは、DBの種類以前に設計で負ける
- 楽観ロックは高並列で普通に死ぬ
- Append-Only にすると景色が変わる
- 1000並列では Aurora DSQL がちゃんと強かった
この記事でやること
- API Gateway + Lambda + DB だけの最小構成で注文APIを作る
- 在庫管理方式を3パターン試す
- 悲観ロック
- 楽観ロック
- Append-Only
- Aurora PostgreSQL と Aurora DSQL を比較する
- Distributed Load Testing on AWS ではなく、まず同等条件のHTTPベンチマークで基礎性能を見る
検証した構成
C4モデル - System Context
C4モデル - Container
処理フロー
テーブル設計
最初はこの5テーブルで始めた。
- 商品テーブル
products - 在庫テーブル
inventory - 顧客テーブル
customers - 注文テーブル
orders - 注文詳細テーブル
order_items
CREATE TABLE products (
product_id UUID PRIMARY KEY,
product_name VARCHAR(255) NOT NULL
);
CREATE TABLE inventory (
product_id UUID PRIMARY KEY,
quantity INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE customers (
customer_id UUID PRIMARY KEY
);
CREATE TABLE orders (
order_id UUID PRIMARY KEY,
ordered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
customer_id UUID NOT NULL
);
CREATE TABLE order_items (
order_item_id UUID PRIMARY KEY,
order_id UUID NOT NULL,
product_id UUID NOT NULL,
cancel_flag BOOLEAN NOT NULL DEFAULT FALSE
);
UUIDは全部 UUID v7 をアプリ側で生成した。DB側の自動生成に寄せるより、Aurora と DSQL の両方で同じ条件を作りやすかったからだ。v7 は先頭 48 bit に Unix ミリ秒タイムスタンプを持つので生成順にソートでき、B-Tree インデックスとの相性が良い(RFC 9562 で標準化済み)。
APIの仕様
エンドポイントは3つだけ。
-
POST /orders— 注文作成 -
GET /products— 商品一覧 -
GET /inventory/{id}— 在庫確認
注文時の本題はここ。
POST /orders
この1リクエストの中で、
- 注文レコードを作る
- 注文詳細を作る
- 在庫判定または在庫減算を行う
をまとめて実行する。
試した3パターン
1. 悲観ロック型
いちばんよく見るやつ。
UPDATE inventory
SET quantity = quantity - 1, updated_at = NOW()
WHERE product_id = :product_id
AND quantity > 0;
シンプルだが、同じ商品に注文が集中すると同一行UPDATE競合が起きる。
2. 楽観ロック型
UPDATE前に updated_at を読む。
SELECT quantity, updated_at
FROM inventory
WHERE product_id = :product_id;
UPDATE inventory
SET quantity = quantity - 1, updated_at = NOW()
WHERE product_id = :product_id
AND quantity > 0
AND updated_at = :updated_at;
一見賢そうだが、高並列だと SELECT -> UPDATE の間に他トランザクションが先に更新し、競合祭りになる。
3. Append-Only型
在庫テーブルを減算しない。
SELECT quantity FROM inventory WHERE product_id = :product_id;
SELECT COUNT(*)
FROM order_items
WHERE product_id = :product_id
AND cancel_flag = FALSE;
つまり、
在庫あり = 初期在庫数 > キャンセルされていない注文詳細数
という見方に変えた。
UPDATEが消える。注文APIはほぼ INSERT only になる。
この時点で「勝ち筋これでは?」という気配が濃い。
ベンチマーク条件
- 商品3件
- 顧客100人
- API Gateway + Lambda 経由
- Python Lambda
- Aurora PostgreSQL Serverless v2
- Aurora DSQL
- 比較条件を揃えるため、後半は両方とも us-east-1 に配置
Aurora PostgreSQL の結果
悲観ロック
| 並列数 | リクエスト数 | RPS | P50 | P95 | P99 | 成功率 |
|---|---|---|---|---|---|---|
| 100 | 1000 | 55.5 | 1685ms | 2395ms | 2819ms | 100% |
楽観ロック
| 並列数 | リクエスト数 | RPS | P50 | P95 | P99 | 成功率 |
|---|---|---|---|---|---|---|
| 100 | 1000 | 41.2 | 1679ms | 4017ms | 11825ms | 34.6% |
Append-Only
| 並列数 | リクエスト数 | RPS | P50 | P95 | P99 | 成功率 |
|---|---|---|---|---|---|---|
| 100 | 1000 | 57.9 | 1640ms | 2191ms | 2499ms | 100% |
指標の読み方
- RPS(Requests Per Second)― 1秒あたりの処理リクエスト数。スループットの指標。
- P50 / P95 / P99(パーセンタイル)― 全リクエストを速い順に並べたとき、50% / 95% / 99% 目にあたるレスポンスタイム。P50 は「ふつうの体感」、P95・P99 は「たまに遅いやつがどれくらい遅いか」を示す。P99 が跳ねていたら、テールレイテンシに問題がある兆候。
見えたこと
- 楽観ロックは高並列で普通に厳しい
- 悲観ロックより Append-Only の方が少し速く、しかも安定する
- 同一行UPDATEを消しただけでかなりマシになる
同リージョンで Aurora と DSQL を比較する
リージョン差があるとフェアじゃないので、Aurora も us-east-1 に作り直して比較した。
Append-Only / 100並列
| 指標 | Aurora (us-east-1) | DSQL (us-east-1) |
|---|---|---|
| RPS | 56.2 | 56.0 |
| P50 | 1486ms | 860ms |
| P95 | 2623ms | 6078ms |
| P99 | 4807ms | 11871ms |
| 成功率 | 100% | 100% |
この段階だと、
- スループットはほぼ同じ
- P50は DSQL がかなり速い
- でも P95/P99 は Aurora の方が安定
という結果になった。
ここでは「DSQL速いじゃん」で終わらない。高並列にするともっと差が出る。
200並列・500並列・1000並列でどうなるか
200並列 / Append-Only
| 指標 | Aurora | DSQL |
|---|---|---|
| RPS | 49.9 | 60.5 |
| P50 | 2180ms | 962ms |
| 成功 | 1405 | 2000 |
| 在庫切れ | 595 | 0 |
ここで DSQL がかなり強くなった。
1000並列 / 10000リクエスト / 在庫8000
| 指標 | Aurora | DSQL |
|---|---|---|
| RPS | 75.3 | 75.3 |
| P50 | 3398ms | 3694ms |
| P95 | 63076ms | 42022ms |
| P99 | 120850ms | 65130ms |
| 成功 | 9797 | 10000 |
| エラー | 203 | 0 |
| DB時間 P50 | 1161ms | 229ms |
ここで一気に差が出た。
DB時間の中央値が 1161ms vs 229ms。DSQLが約5倍速い。
Aurora はタイムアウトが出た。DSQLは出なかった。
Q. RPS変わってねーじゃん?
A. 「DB を5倍速くしても、パイプラインの他の部分が3000ms以上食っているので、全体のRPSは変わらない」という、典型的な アムダールの法則が出てる。DB時間が全体の34%→6%に縮んでも、残り66%→94%の部分が律速になるので、全体スループットの改善幅は限定的になる。
つまり何が言えるのか
1. まず設計を直せ
DBを変える前に、在庫減算を疑った方がいい。
- 悲観ロック → 競合で遅い
- 楽観ロック → 高並列でさらに厳しい
- Append-Only → UPDATEが消えて、ようやく戦える
一番効いたのは DB の変更ではなく、データの持ち方を変えたことだった。
2. DSQL は「普通に速い」ではなく「高並列で崩れにくい」
100並列くらいだと Aurora でも十分戦える。
でも 200並列を超えたあたりから、DSQL の分散性が見えてくる。
1000並列では、Aurora がタイムアウトを出し始めたのに対して、DSQL は全部返し切った。
この差は大きい。
3. DSQL は魔法ではない
P50は良くても、P95/P99は荒れる場面がある。低並列では Aurora の方が安定するケースもあった。
つまり、
- 常時安定した OLTP を少人数で処理するなら Aurora で十分
- スパイクが激しく、同時書き込みが突発的に増えるなら DSQL が効く
という整理がしっくりくる。
どんなシステムで向いていそうか
Aurora DSQL が向いていそうなのは、例えばこういうやつ。
- チケット争奪
- 先着販売
- ECの限定商品販売
- 同一タイミングに注文が集中するキャンペーン
- ゲーム内イベントで同一商品に購入が集中するケース
逆に、通常の業務システムでトラフィックが平準化されているなら、Aurora の方が素直で扱いやすい。
今回の結論
在庫UPDATEがつらいなら、まずやるべきは DSQL の導入ではなく 在庫の持ち方を疑うこと だった。
そのうえで、
- 同一行UPDATEをやめる
- INSERT中心の Append-Only に寄せる
- その設計で高並列を受ける
ここまで持っていくと、Aurora DSQL はちゃんと強い。
設計を変える前の DSQL は過大評価しやすい。設計を変えた後の DSQL はかなり頼もしい。
この順番を間違えないのが大事だった。
続編はこちら。