02アーキテクチャ5 min

同じ並行性のバグが、別々の三つのプロジェクトで出てきた

負荷のかかったカウンタは加算を取りこぼし、減算するカウンタは同じものを二度請求しかねません。カウンタが金額なら、それはあとで直せばいい細部ではありません。

著者: Salva Castells

カバー画像: kisnikee, CC0.
この記事の内容

同じ API キーに対して百件のリクエストを同時に投げます。利用回数のカウンタは八十七を示します。ログにエラーはなく、失敗したリクエストもなく、誰も苦情を言いません。ただ十三回分の加算が消えています。

この不具合は三回出ました。コードを一行も共有していないプロジェクトで、いつも同じ理由です。カウンタが、書き込むプロセスはひとつしかないという前提で書かれていたからです。

パターン: 読む、足す、書くの三段階

これを生むコードは、決して危なそうには見えません。文字起こし API では、キーの認証が二つのことをしていました。失効していないかの確認と、useCount と lastUsedAt の更新です。行を読み、メモリ上で一を足し、結果を書く。

一度にひとりのユーザーなら動きます。同じキーに対してバッチのワーカーが叩くと動きません。読みと書きの間に別のリクエストが入り、同じ値を読み、同じ結果を書きます。二回の利用で加算は一回。k6 で仮想ユーザー 10、50、100 で測ったところ、損失は 8 から 14 パーセントでした。

修正は一文に収まります。

UPDATE api_key
SET use_count = use_count + 1,
    last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;

大事なのは、分かりやすい use_count + 1 だけではありません。revoked_at IS NULL が同じ UPDATE の中にあることです。以前は失効の確認と加算が別々の往復だったので、検証した直後に誰かがキーを失効させても利用が数えられてしまう窓がありました。フィルタを中に入れれば、行が条件を満たして加算されるか、何も返らず 401 を投げるかのどちらかです。扱うべき中間状態はありません。

変更後、並行性テストを九回(三段階 × 三回)走らせて、損失ゼロでした。

逆のケース: 同じものを二度支払う

二つ目は逆でした。意味検索では、検索ごとにクォータを一単位消費し、かかったコストを一行に記録します。埋め込みのモデル、トークン数、リランクのモデル、入力と出力のトークン数です。

重複排除がありました。同じ検索を繰り返すと、予約は duplicate を返し、元の行を再利用し、クォータを再請求しませんでした。

問題は、重複排除された検索が実際には実行されていたことです。埋め込みをもう一度呼び、リランクをもう一度呼び、プロバイダはもう一度課金します。そのコストには行き場がありませんでした。元の行にはすでに自分のコストが入っていて、上書きすれば一回目の支出が消えてしまいます。だからコストは消えていました。管理画面は、検索の実際より安い金額を表示していたわけです。

同じテーブルに別の状態の行をもう一行入れたくなります。悪い考えで、具体的な理由が二つあります。

  • クォータの問い合わせはすべて、フィルタなしで行を数える。 そこに余分な行があれば、誰も消費していないクォータを請求することになります。
  • 重複の検索も状態でフィルタしない。 そのハッシュを持つ最新の行を取ります。新しい行が次の重複排除の起点になってしまいます。クォータの一単位ではなかった行が、です。

解決策は別テーブル unbilled_search_spend でした。同じ利用量のカラムと、重複を解決した予約への参照を持ちます。この参照は監査用で ON DELETE set null が付いており、クォータの参照ではありません。コストはレポートには出ますが、価格を決めるのに使う「課金された検索あたりのコスト」には入りません。

三つ目: 別々に読まれる二つの残高

三つ目は画像生成の SaaS でした。サブスクリプションのクレジットの上に日次クレジットを足したところ、日次クレジットしか持っていない人の生成が動かなくなりました。エンドポイントは profile.subCredits < estimatedCost を確認して 402 を返し、dailyCredits を完全に無視していました。クレジットは存在し、サイドバーにも描画されているのに、サーバーは足りないと言うのです。

これは並行性のバグではなく、カウンタを二つに割って片方を忘れたバグです。しかし根は同じです。残高が数値であることをやめ、コード中に散らばった演算の結果、しかも各所で手作業で行われる演算の結果になったのです。

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

この行は、確認のところに一回、クライアントに返す残高を計算するところに一回、計二回現れます。足し算を忘れないようにすべき場所が二か所。三種類目のクレジットができた日には、三か所になります。

そしてバグではなく、書かれていない方針だったもの

四つ目は毛色が違いますが、同じ系統に属します。invoice.paid の webhook が、更新のたびに残高を踏み潰していました。

balanceCents: pricing.balanceCents, // 以前の残高を踏み潰していた

前月の未消費分は、更新日にすべて蒸発していました。残高 1499 セントのプランのユーザー四十人ほどで、失われる額の中央値は 310 セント、90 パーセンタイルは 890 セントに達しました。場合によってはプランの 60 パーセント近くが捨てられていたことになります。

興味深いのは、これを誰も決めていなかったことです。方針ではなく、加算ではなく代入を書いたことの帰結でした。いまは三か月分を上限に繰り越します。

const nextBalance = Math.min(
  current.balanceCents + pricing.balanceCents,
  pricing.balanceCents * 3
);

この上限は飾りではありません。なければ、たまにしか使わないアカウントが十二か月分の残高をため込み、それを一気に使います。上流で AI プロバイダに従量で払っているときに、いちばん避けたい形です。

ルール

金額を表すカウンタはすべて、初日から三つのことを守って書きます。ひとつ。加算とその条件はひとつの文に収める。SET x = x + 1 WHERE ... であって、read-modify-write ではない。ふたつ。重複排除があるなら、クォータを請求しなくても実コストの落ちる先を用意する。みっつ。更新時に余った分をどうするかの方針を、コメントでもいいのでどこかに書いておく。書かなければ、代入演算子が代わりに書いてしまいます。

三つとも、最初にやれば時間はかかりません。あとからやると、どれも障害ひとつ分のコストがかかります。