はじめに
限定ドロップ系のECサイトが発売開始と同時にアクセスが殺到して、売り越し(在庫数を超えて売ってしまうこと)や二重予約、サーバーダウンを起こしているのを何度か見たことがあります。
よく見ると、これらは別々の不具合ではなく、実は同じ1つの問題です。
「同時に大量のアクセスが来たときに、1つの数字(在庫数)を正しく数え続けられるか」
という問題です。
100個しかない在庫を101個売ってしまえば、それは技術的な誤差ではなく、お客さんに対する嘘になります。
これがキューイングシステムで後から取り繕う話ではなく、データベースの設計だけで最初から起きないようにできないか気になったので、実際にDROPZEROという限定ドロップECを作ってみました。
バックエンドはAmazon Aurora DSQL、フロントエンドはNext.js(v0で土台を作成)+Vercelです。
なぜAmazon Aurora DSQLなのか
単一リージョンのデータベースなら、在庫の数字をトランザクションで守るのはそれほど難しくありません。
難しいのは、これをリージョンをまたいで、しかも正しさを犠牲にせずにやることです。
分散システムの世界では「強整合」「低レイテンシ」「マルチリージョン書き込み」の3つは同時に成立しないというのがよくある前提で、たいてい2つしか選べません。
Amazon Aurora DSQLは、この前提そのものを崩してくれるサービスでした。
サーバーレスの分散SQLデータベースで、
マルチリージョンのアクティブ・アクティブ構成でありながら強整合
という特徴を持っています。
2つのリージョンエンドポイントがありながら、論理的には1つのデータベースとして振る舞い、コミットは同期的な合意(クォーラム)を取ります。
つまり、東京にいる買い手とソウルにいる買い手が同時に最後の1個を取り合っても、両者は同じ在庫の状態を見て判定されます。
どちらか一方だけが成功し、在庫がマイナスになることはありません。
この特性がDROPZERO全体の土台になっています。
簡単に説明すると
限定100個のプリンをオンラインで予約する場面を想像してください。
「予約する」ボタンを押した瞬間、システムは冷蔵庫を覗いてプリンがまだ残っているか確認し、残っていればそのまま1個抜き取ります。
ここで大事なのは、「確認する」と「抜き取る」を必ずセットで、同じ瞬間に行うという約束です。
もし2人が同時にボタンを押したらどうなるでしょうか。
片方が冷蔵庫を覗いている間、もう片方は少しだけ待たされます。
先に覗いた人が最後の1個を抜き取ったら、後から覗いた人には「もう空っぽです」と正直に伝わります。
誰も、本当は無いプリンを「あるつもり」で持ち帰ってしまうことはありません。
これがAurora DSQLの楽観的同時実行制御(OCC)という仕組みの正体で、DROPZEROが世界中から同時にアクセスされても売り越さない理由です。
構成
- Amazon Aurora DSQL: マルチリージョンクラスター。東京(ap-northeast-1)とソウル(ap-northeast-2)がアクティブなペア、大阪(ap-northeast-3)がwitness(第三者証人)リージョン
- AWS IAM: フロントエンドがDSQLの認証トークンを取得するために引き受けるロール
- Vercel OIDC federation: フロントエンドがOIDCトークンを提示してIAMロールを引き受け、そこからDSQLの認証情報を得る。アプリ側に長期的な秘密情報を一切持たない
- Next.js on Vercel: ドロップ画面と購入/在庫APIルート
import { AuroraDSQLPool } from "@aws/aurora-dsql-node-postgres-connector";
import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider";
const pool = new AuroraDSQLPool({
host: process.env.PGHOST!,
region: process.env.AWS_REGION!,
user: "admin",
database: "postgres",
port: 5432,
customCredentialsProvider: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN!, // Vercelが注入する
clientConfig: { region: process.env.AWS_REGION! },
}),
});
最初にDSQLを触ったとき、「認証がおかしい」で1晩費やしたのですが、原因は無関係な古い認証情報を握っていたことでした。
Vercelの管理されたOIDC連携にトークンのライフサイクルを丸ごと任せて、DSQLトークンを自前で作ろうとしないのがコツのようです。
DSQL専用のデータモデル
Aurora DSQLはPostgreSQL互換ですが、素のPostgreSQLではありません。
今回スキーマ設計に効いてきた制約は3つです。
- 外部キーが使えないので、整合性の担保はアプリ側で行う
- シーケンス/SERIAL型が使えないので、主キーはUUID(
gen_random_uuid()をサーバー側デフォルトとして使用)にする - 1トランザクションにつきDDL文は1つまでなので、マイグレーションは1文ずつ実行する
スキーマはあえて小さくしています。
products(id uuid PK, name, drop_name)-
inventory(id uuid PK, product_id, stock)← ドロップ全体がこの1行にかかっている orders(id uuid PK, product_id, user_ref, status, region)
世界中の買い手全員が、同じstockという1つのセルを取り合っているだけ、というのがこのアプリの本質です。
売り越しを防ぐ核心部分
購入処理は「在庫確認」と「減算」を1つのトランザクションにまとめています。
面白いのは2つの購入が衝突したときの挙動です。
Aurora DSQLは楽観的同時実行制御(OCC)を採用していて、衝突はコミットのタイミングで検出され、負けた方はSQLSTATE 40001(またはDSQL固有のOC000/OC001)で弾かれます。なのでアプリ側でリトライする必要があります。
const OCC = new Set(["40001", "OC000", "OC001"]);
async function purchase(productId, userRef, region, maxAttempts = 40) {
for (let attempt = 1; ; attempt++) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const upd = await client.query(
"UPDATE inventory SET stock = stock - 1 WHERE product_id = $1 AND stock > 0",
[productId],
);
if (upd.rowCount === 0) {
await client.query("ROLLBACK");
return "sold_out";
}
await client.query(
"INSERT INTO orders (product_id, user_ref, status, region) VALUES ($1,$2,'confirmed',$3)",
[productId, userRef, region],
);
await client.query("COMMIT"); // 衝突はここで顕在化する
return "confirmed";
} catch (e) {
await client.query("ROLLBACK").catch(() => {});
if (OCC.has(e.code) && attempt < maxAttempts) {
await backoffWithJitter(attempt); // 指数バックオフ+ジッタ
continue; // リトライすると最新の在庫を読み直す
}
throw e;
} finally {
client.release();
}
}
}
ここのポイントは、リトライすると最新のスナップショットを読み直すことです。
在庫が0になった時点でWHERE stock > 0にはどの行もマッチしなくなるので、その購入は素直に「売り切れ」を返します。
衝突がエラーではなく、正しい答えに変わるという設計です。
マルチリージョンの強整合を証明する
東京(ap-northeast-1)とソウル(ap-northeast-2)をアクティブなペアにして、大阪(ap-northeast-3)をwitnessリージョンにしたマルチリージョンクラスターを作りました。
作る前に1つハマった話があります。witnessは米国リージョンでないといけないと思い込んでいて(ネット上でもよく見かける説明です)、実際にus-east-1とus-west-2を指定したところ、両方とも拒否されました。
witnessはペアと同じリージョンセットに属している必要があり、APACのクラスターなら大阪が正解でした。
aws dsql create-cluster --region ap-northeast-1 \
--multi-region-properties '{"witnessRegion":"ap-northeast-3"}'
# この後ソウル側のクラスターを作って、2つのARNを紐づける
伝聞ではなく
「実際に返ってきたAPIのエラーを信じるべきだった」
という教訓でした。
実際の検証は、両方のリージョンエンドポイントに同時に書き込む形で行いました。
在庫を1にして、東京とソウルからそれぞれ1件ずつ購入を投げると、
片方だけが成功し、もう片方は売り切れになり、両方のエンドポイントが同じ最終在庫を返すこと
を確認できました。アプリ側で何か特別な調整をすることなく、リージョンをまたいだ強整合が実現されています。
実際に負荷をかけてみる
k6を使って、50VUsで両方のリージョンエンドポイントに向けて
在庫100個、3000件の同時購入
をぶつけてみました。
| リトライ上限 | 成功(confirmed) | 売り切れ(sold_out) | エラー | 最終在庫 |
|---|---|---|---|---|
| 8回 | 100 | 2,810 | 90 | 0 |
| 40回 | 100 | 2,900 | 0 | 0 |
わかったことは2つあります。
1つ目は、どちらの条件でも
売り越しは一度も起きていないこと
です。confirmedはちょうど100件、最終在庫もちょうど0で、2回とも一致していました。
1回目の90件の「エラー」は、売り越しではなく、衝突が解消しきれずに諦めた購入です(在庫は減っていません)。
2つ目は、同じ1行を奪い合う状況では、それなりのリトライ回数が要る
ということです。
50VUsが1つの在庫行に殺到すると相当数のOCC衝突が発生し、リトライ上限を引き上げたことで、その90件のエラーがきれいな「売り切れ」に変わりました。
何回実行しても同じ結果になり、途中で失敗してもやり直せるというトランザクションは、ここではオプションではなく設計そのものだと感じました。
最初から世界向けに作る
世界中どこからアクセスしても違和感がないように、UIは
8言語(英語・日本語・中国語・韓国語・スペイン語・フランス語・ポルトガル語・アラビア語)
に対応させました。
アラビア語は完全な**右から左(RTL)**のレイアウトで、画面全体が反転します(数字やIDだけは左から右のまま固定しています)。
わかったこと
作りながら得た教訓をいくつか書いておきます。
管理された認証の経路に乗るのが一番安全でした。
Vercel OIDC→IAM→DSQLトークンという流れに任せておけば、アプリ内に秘密情報を持たずに済みますし、トークンの配管を自分で書く必要もありません。
DSQLの方言は素直に受け入れた方がよさそうです。
外部キー無し、シーケンス無し、1トランザクション1DDL、分離レベルはRepeatable Read固定。これに逆らって設計するのではなく、これを前提にスキーマを組んだ方が早いと感じました。
OCCのリトライはエラー処理ではなく機能そのものだと考えるようになりました。
「売り越さない」という約束を実際に成立させているのは、このリトライの部分です。
マルチリージョンのwitnessは、必ず同じリージョンセットの中から選ぶ必要があります。
「米国リージョンでないといけない」というよくある説明を鵜呑みにせず、実際に返ってきたAPIのエラーを信じるべきでした。
おわりに
Aurora DSQLがあれば、リージョンをまたいでも1つの強整合な真実を持つことができます。そこまで揃ってしまえば、「売り越さない」というのは、小さくて注意深いトランザクションを1つ書くだけの話になります。
買い手が1人でも100万人でも、東京でもサンパウロでも同じです。
この内容で、2026年8月22日にシンガポールで開催される
AWS Community Day Singapore 2026
に登壇します。発表タイトルは
「Distributed Transactions Under Fire: Building a Zero-Oversell Flash Sale Platform with Amazon Aurora DSQL」
です。
当日はこの記事よりもう少し踏み込んで、OCCリトライの挙動やマルチリージョンでの障害時の振る舞いについても話す予定です。
シンガポールにいらっしゃる方がいれば、ぜひ会場で声をかけてください。
最後まで読んでいただきありがとうございました。



