私はledger-serviceを構築しています。これはJava 21 / Spring Boot 3.5 / PostgreSQLを使用した二重簿記型の電子ウォレット帳簿です。はRender上で稼働しています。早い段階で、同じ口座に対して50回の送金を発行するストレステストを書きました。 をすぐに実行し、本が決して損なわないことを主張します。赤くなった——そして赤くなった方法が、このプロジェクトを構築してきた中で学んだ最も役立つことはです.
この投稿では、お金の台帳がバランスを保持する理由 キャッシュを整ったプロセスを説明します。 ではありません。キャッシュが招く読み書き書き換えの競合状態、それをどのように検知したか、修正(楽観的ロック+制限再試行)、楽観的ロックを選択したことを正当化するベンチマーク、および idempotency が再試行とどのように組み合わさる必要があるか(ネットワークの不安定さがダブルスペンドを防ぐ)について.
設定:帳簿、それがキャッシュを持つ理由
真実の源は貸借対照式、追記のみテーブル (ADR-0005)。すべての金銭操作は少なくとも1つのバランスを記録します。DEBIT/CREDITペアでΣ DEBIT == Σ CREDIT、そしてledger_entries挿入のみ — いいえUPDATE、いいえDELETE. 誤りは投稿して修正されます修正エントリーより、歴史を編集することなく。これがStripe、Modern Treasury、Formanceがすべて使用するモデルであり、信頼できる監査トレースを提供するものです。
しかし「口座Xの残高は何ですか?」はそうであってはなりませんSUMすべてのアカウントが過去に持ったエントリーよりも。だから私はキャッシュ:accounts.balance具体化されていますΣその口座のエントリ、更新済みは同じトランザクション内でエントリ自体と同じです(ADR-0006)。エントリは真実です;残高は派生読み取りキャッシュであり、O(1)で保持されます
そのキャッシュが正に並行性が問題を引き起こす場所です
レース
二つのリクエストが同じ口座を同じ時に借方にします
R1: read balance $500 (enough) R2: read balance $500 (enough)
R1: commit −$300 → $200 R2: commit −$400 → −$200 ← overdraft / lost update
両方は$500を読み、両方は十分であると決定し、新しいバランスの自分の考えを返信します。一方は静かに破壊し、他方を:ロストアップデート、そして、下の帳簿エントリと一致しないバランスが残ります。
この罠は、データベースがこれを止めてくれると仮定することです。それはそうではありません。PostgreSQLのデフォルトの隔離レベル、READ COMMITTED、あなたが読まないことを保証するだけです未約束データ — それは同時に同じ行を読み書きする2つのトランザクションに関して何もしない。読み書き変更書き込みレースはそれを通り抜ける。
検出:ストレステスト
テストでバグが表面化したものです。一つの口座に資金を付け、次に解雇N = 50 は同時にそれから転送され、その後帳簿を確認します:
AtomicInteger successes = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < N; i++) {
pool.submit(() -> {
start.await(); // line them all up...
int code = post("/transfers", from, to, AMOUNT); // ...then fire at once
if (code == 201) successes.incrementAndGet();
});
}
start.countDown();
// after all complete:
assertThat(balanceCache(from)).isEqualTo(ledgerBalance(from)); // cache == Σ entries
assertThat(balanceCache(from)).isGreaterThanOrEqualTo(0); // no overdraft
assertThat(balanceCache(to)).isEqualTo((long) ok * AMOUNT); // exact accounting
断片的な主張は意図的に タイミングに依存しない — それらは どんな場合にも当てはまります は成功と失敗を分けるのではなく、キャッシュを帳簿の真実と比較するのではなく、固定された期待される数と比較するためです。それがテストを安定した回帰ガードにするのです。
私は実験によってバグを確認しました:@Version 列を削除すると、~85% のキャッシュ更新が失われました とこれらの断言が赤くなった — キャッシュされたバランスは、エントリの合計から遠ざかった。キャッシュと真実が一致しなかった、これはお金のシステムにおいては全てのゲームのことである。
修正、第1部:楽観的ロック
accounts にはすでに version BIGINT 列があった、なぜなら Account は集約/ロックの境界であるから (ADR-0010)。JPA の @Version としてマッピングすることで、すべてのバランス書き込みが比較と設定に変換される。
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
同時に二つのライターがバージョン7をロードします。最初にコミットする方はそれを8に設定します。二番目のUPDATE ... WHERE version = 7は今やゼロ行に一致し、Hibernateはコミット時にOptimisticLockingFailureExceptionを引き起こします。失われた更新は今や不可能です:静かに上書きする代わりに、敗者は言ったそれが負けた。
主要のプロパティ:これは検知、ブロックしない読者は決してロックを待ちません。レジスタンス(ledger)では、バランス/履歴の読み込みが書き込みをはるかに上回るため、それは非常に重要です。
修正、第2部:制限再試行
単独での検知だけでは不十分です。それに@Version が設定されているが再試行は行わない、ストレステストはデータの破損を停止したが、大きなデータ転送の一部が今や 失敗 してコンフリクトが発生する——正しいが、非常に悪い経験である。そのため、敗者は再試行する必要がある。
再試行のヘルパーは 外部 @Transactional に位置しており、その配置が全てのポイントである:
public <T> T execute(Supplier<T> operation) {
for (int attempt = 1; ; attempt++) {
try {
return operation.get(); // a FRESH transaction each attempt
} catch (OptimisticLockingFailureException e) {
if (attempt >= maxAttempts) throw new ConcurrencyConflictException();
sleep(backoffWithFullJitter(attempt)); // 25–200 ms, capped
}
}
}
各試行は新しい取引であり、現在のバージョンで行を再読み込みします — 失敗した取引内で再試行しても古いバージョンに対して再失敗します。デフォルト:5回の試行、指数的バックオフと完全なジッター(雷鳴の群れが別の衝突に再同期しないように)、枯渇時にクリーンな409 Conflict —決して500.
中程度の負荷で証明可能に終了するための小さな論理的な要素があります:k番目のコミット者は、異なる以前のコミット者にしか負けられず、したがって最大限にkの試行が行われます。4つの並行書き込みと5試行の予算で、すべての4つが決定的に成功します—不安定なテストはありません。本物の熱心なアカウント(試行予算よりも多くの並行書き込みがある)は409として現れ、これは隠れた汚染ではなく正直なバックプレッシャーです.
楽観的対悲観的:測定された選択
明らかな代替案は悲観的ロックです—SELECT ... FOR UPDATE触れる前にその行をロックするので、ライター#2はただ待つ。リトライはなし、考えるのが簡単だ。ではなぜ楽観的か?
気持ち悪いと感じたので、ベンチマークを書きましたTransferConcurrencyBenchmark) を運行している同一転送論理両方の戦略において、50人の並行書きエージェント、実際のPostgreSQL1台に対して:
| シナリオ | 楽観的 + 再試行 | 悲観的FOR UPDATE
|
|---|---|---|
| 低コンテンション (50個の独立した口座ペア) | 34 ms · 50/50成功 · 0 再試行の無駄 | 31 ms · 50/50成功 |
| 高コンテンション (50回の転送→1 熱バイト) | 731ミリ秒 · 50/50成功 · 185リトライ無駄 | 358ミリ秒 · 50/50成功 |
数字を読む:
- 低い競合は一般的で、引き分けです (34対31ミリ秒) — しかし楽観的にはゼロリトライを無駄にし、そして重要なことに読み取りをブロックしません読み取り負荷の高い帳簿にとって、それが決定要因です。
- 単一の高温の行で、悲観的は~2倍高速です(358 vs 731 ms)であり何も無駄にせず、楽観的は衝突とバックオフで185回の追加試行(≈4.7倍の作業量)を燃費する。しかし悲観的はここで正確に「勝つ」シリアライズおよびブロッキング読み取り— 避けようとしているもの — それは実際には解決するホットアカウントなら、ただキューに入れるだけです。
それでは、結論は楽観的+再試行であり、ベンチマークの価値は「楽観的が速い」とは決してありません(競合状態ではありません)—それはそれら185度の失敗した再試行は、閾値を定量化する本当に熱心なアカウント(考える:各トップアップが共有SYSTEM_FUNDING 行)には実際のエスカレーションが必要です:非同期キューイングやサブアカウントシャーディング、全体のシステムを悲観的ロックに切り替えるのではなく。
idempotency で組み合わせる
もう一つの方法として、リトライが実際に 悪化させます もし注意しなければならない。サーバーがコミットした後に接続が切断されるクライアントは、HTTPリクエスト全体を再試行する——そして今や転送を二重に投稿するリスクがある。コンフリクト時再試行とネットワークの小さな問題時再試行は異なる問題であり、片方の修正が他方を壊さないようにしなければならない.
したがって、両方の金銭エンドポイントには Idempotency-Key ヘッダー(ADR-0012、Stripeのパターン)が必要である。それを並行処理安全にする仕組みは権利要求-初:
INSERT INTO idempotency_keys (key, status) VALUES (?, 'PENDING')
ON CONFLICT (key) DO NOTHING; -- committed immediately, before business logic
その原子挿入がシリアライズポイントです。権利要求で勝った方が操作を実行します。同じキーでの並行リクエストはコミットされたPENDING行を見て409 (飛行中) の代わりに再度実行するのではなく。完了したキーは格納されたレスポンスを再生し、異なる本文で再利用されたキーは 422 (クライアント契約違反、意図的に 409 コンフィリクトコードとは区別される) となります。
この理由は、前回のリトライとこの構成がスムーズに組み合わされるためです:楽観的ロックのリトライは 後ろに位置しています。 その鍵は既に要求されている。それらの内部の試行は既に要求されている idempotency 鍵の下で行われるため、クライアントからは完全に見えず、二重投稿を決して生成することはできない。コンフリクト再試行とリクエスト idempotency スタックで代わりに戦う.
次に手を伸ばすべきもの
キャッシュは速いが、ずれることができる(バグ、部分的な失敗)。だからスケジュールされた調整 はすべてのバランスを不変のエントリから再計算し、不一致があるとアラートを表示します—それは決して自動修正しません;オペレーターが修正エントリを投稿します。追記専用の台帳は、真実が常に回復可能であることを意味します。
そして、185回の再試行が露呈させた熱心アカウントの上限は次の実際のスケール問題です:一つの行が本当に競合している場合、答えは非同期投稿やそのアカウントのシャーディングです。再試行率があなたが限界を越えたときを知らせる信号となります。
本質的な点:お金のシステムにおいて、キャッシュが帳簿と異意を持っている は失敗が重要であり、デフォルト分離データベースはそれを作成するのを止めることはありません。@Version の比較設定は失われた更新を不可能にし、制限付きリトライとジッターで通常の負荷下ではそれが見えなくし、ベンチマークは支払っているコストと上限がどこかを教えてくれます。排他性は各レイヤーでのリトライが重複支出にならないように保証します。
コード: github.com/xidoke/ledger-service — その 並列処理モデルのドキュメントとADR(Architecture Decision Records)の0005、0006、0011、0012はさらに詳しい内容を提供しています。ライブデモ: レジスターサービス-bjzr.onrender.com (無料インスタンス — 初回リクエストコールドスタート~50秒)。












