ロックなしで、正確に在庫を引き当てる — Valkey + Lua のアトミック在庫エンジン
賞味期限・ロット・安全在庫を考慮した引当を、ロックを使わずに正確に。単一スレッド実行のLuaでどう整合を守るか。
SOURCE OF TRUTH在庫台帳をインメモリに置く
通販基幹エンジンで最も競合が激しいのが在庫の引当だ。同一SKUに対して複数の注文が同時に到達し、それぞれが「まだ在庫はあるか」「引き当てられるか」を問い合わせる。RDBのトランザクションと行ロックだけで捌こうとすると、ホット行への待ち行列が伸び、ロック競合がスループットの天井になる。
そこで採用したのが、Valkey(Redis互換)をリアルタイム在庫台帳の正として扱う構成だ。RDBは永続化の受け皿に徹し、引当の可否判定と数量更新はすべてインメモリ側で完結させる。あるSKUに最初のアクセスが来たとき、RDBから現在の在庫状態を遅延ロードし、SET NX で一度だけキーを作成する。以降はValkey上のドキュメントを更新し続け、RDBへは非同期に反映する。
- 初回のみRDBを参照するため、コールドスタート後は読み書きがインメモリで完結する。
SET NXにより、同時初回アクセスが競合しても台帳の作成は必ず一度きり。- RDBは監査・復旧・レポートの基盤として残し、在庫の権威はValkeyへ移す。
SINGLE THREADなぜロックが要らないのか
整合性の要は、Luaスクリプトが Valkey 上でシングルスレッドで逐次実行される点にある。ひとつのスクリプトが走っている間、他のコマンドは割り込めない。つまり「在庫を読む → 判定する → 数量を動かす → 書き戻す」という一連の操作が、外から見て不可分(アトミック)に見える。アプリ側で分散ロックを取る必要も、楽観ロックのリトライループを書く必要もない。
これは強力だが、代償もある。スクリプト実行中はサーバ全体が専有されるため、Luaが重いとレイテンシに直結する。だから引当ロジックは「1キーのドキュメントをGETし、メモリ内で計算し、SETする」以上の複雑さを持ち込まない。ループは在庫のロット数に対して線形に抑え、外部呼び出しやブロッキング処理はスクリプトの外へ追い出す。
LOT BUCKETSロットと在庫区分バケツ
在庫は単純なカウンタではなく、ロットの配列として持つ。各ロットは賞味期限・良品/不良の別を属性に持ち、内部に在庫区分ごとのバケツを抱える。バケツはたとえば「販売可能」「引当済み」「隠し在庫」といった区分だ。引当とは、このバケツ間で数量を移動させる操作にほかならない。出荷引当なら、あるロットの「販売可能」から同じロットの「引当済み」へ必要数を移す。
総量は常に保存されるため、引当・取消・出荷確定のいずれも「どのバケツからどのバケツへ、いくつ動かすか」で表現できる。これにより在庫の履歴と現在値が矛盾しにくくなる。
FEFO / LIFO実在庫はFEFO、計画在庫はLIFO
複数ロットにまたがって必要数を充当する順序も、引当の正しさを左右する。すでに手元にある実在庫は FEFO(賞味期限が近い順)で消費し、期限切れロスを減らす。一方、まだ入荷していない入荷予定の計画在庫は LIFO で探索する。alloc は「まず実在庫をFEFOで充当し、それでも不足するなら計画在庫をLIFOで補う」という順序で必要数を切り出す。
引当の際は対象日の期間チェックを行い、その日に有効なロットだけを候補とする。さらに出荷引当時のみ安全在庫チェックを挟む。良品の販売可能数量を累積し、安全在庫 + 引当数の閾値を割り込むなら ERR_SAFETY_STOCK を返して引当を拒否する。予約や仮引当と、実際に在庫を減らす出荷引当とで、守るべき制約が違うためだ。
COMMIT / ROLLBACK永続化とロールバック
Lua内での数量移動が成功したら、その結果を SQS へ流し、RDBへの反映を非同期に行う。オンラインの引当パスはインメモリ更新で完了し、永続化は後追いで整合させる。逆に、期間チェックや安全在庫チェックで弾かれた場合、あるいはメモリ操作が失敗した場合は、バケツの移動をロールバックして台帳を元の状態へ戻す。
下は在庫ドキュメントを1キーに保持し、Lua内でGET→数量移動→SETする疑似コードだ。
-- KEYS[1] = 在庫台帳キー(SKU単位) -- ARGV[1] = 引当数, ARGV[2] = 対象日, ARGV[3] = 安全在庫, ARGV[4] = 出荷フラグ local doc = redis.call("GET", KEYS[1]) if not doc then return redis.error_reply("ERR_NOT_LOADED") end local inv = cjson.decode(doc) local need = tonumber(ARGV[1]) local sellable = 0 -- 良品の販売可能を累積(安全在庫の判定用) for _, lot in ipairs(inv.lots) do if lot.good and in_period(lot, ARGV[2]) then sellable = sellable + lot.buckets.sellable end end -- 出荷引当時のみ安全在庫チェック if ARGV[4] == "1" and sellable < tonumber(ARGV[3]) + need then return redis.error_reply("ERR_SAFETY_STOCK") end -- 実在庫FEFO → 不足なら計画在庫LIFO で充当(バケツ間で移動) local remain = move_fefo_then_lifo(inv, ARGV[2], need) if remain > 0 then return redis.error_reply("ERR_SHORTAGE") end redis.call("SET", KEYS[1], cjson.encode(inv)) return "OK" -- 呼び出し側が SQS で RDB 反映を通知
設計のトレードオフは明快だ。シングルスレッド実行と引き換えにロック管理の複雑さを消せる一方、在庫の権威がインメモリに移るため、Valkeyの可用性と永続化の遅延がそのまま在庫の信頼性に効いてくる。だからこそ、スクリプトを軽く保ち、非同期永続化とロールバックの経路を丁寧に設計することが前提になる。
在庫台帳をValkeyの正とし、シングルスレッドのLuaで「読む→動かす→書く」をアトミックに閉じれば、ロックなしで正確な引当が実現できる。整合はバケツ間移動とFEFO/LIFOの充当順序で守り、永続化は非同期に後追いさせる。