Old-School Backend / 2026 ・ No.09
いにしえのバックエンド入門
第9回 トランザクションと同時更新 ── あとから保存した人が勝つ
公開
業務システムでは、同じ申請や同じ予算を、何人もが同時に触ります。1人で試すと正しく動くのに、2人になると数字が合わなくなる。しかもエラーは出ません。
今回は MyBatis で、画面での上書き、予算への 150 件同時の「引いて」、デッドロック、長すぎるトランザクションを PostgreSQL で実測し、SQL の中で引く・行に鍵をかける・版番号・やり直しの4つの直し方を比べます。
あとから保存した人が勝つ
新井@Transactional を付けてあるので、同時に来ても大丈夫ですよね。
古井@Transactional が守るのは「全部やるか、全部やらないか」だ。「ほかの人と同時に」は、別の話だよ。
新井別の話……。
古井今日は2人で同じ申請を直して、150 人で同じ予算を引いてみよう。
全15回の路線図(いまここ:第9回)
- 画面の上書き ── 2人とも 200 なのに、先に保存した人の変更が消えた(実測)
- 150 件同時 ── 150 件とも OK と答え、予算は 14,000〜18,000 円しか減らない(実測)
- 4つの直し方 ── SQL の中で引く、for update、版番号、分離レベル+やり直し
- デッドロック ── 逆向きの付け替え2本で、片方が 1.2 秒後に 500(実測)
- 長いトランザクション ── 中で 1 秒待つだけで、12 件目は 12 秒(実測)
前回の比べを受けて、この回から倉庫番は MyBatis にしました。アプリから JPA を外し(MyBatis 3.5.19、mybatis-spring-boot 4.1.0)、これまでの窓口は JdbcClient のまま、新しい窓口を MyBatis で書いています。
# 道具立て(第8回の JPA+MyBatis から、JPA を外した) 第8回: jar 55,029,971 バイト、ライブラリ 75 第9回: jar 26,299,833 バイト、ライブラリ 52、起動 3.945 s / 4.119 s / 4.058 s
DB には、部署の予算の表 budget(dev と sales に 100,000 円ずつ)を足し、申請の表 expense に版番号の列 version を足しました。
画面で起きる上書き
まずは、いちばんよくある形です。新井さんと古井さんが、同じ出張の申請(EXP-2026-5001、24,160 円)を編集画面で開きました。新井さんは帰りのタクシー代 1,500 円を足して保存。古井さんは品目に行き先「(大阪)」を足して保存しました。窓口は、届いた品目と金額でそのまま update します。
# 新井さんと古井さんが、同じ申請を開いて直す(版番号なし) 新井 開く 200 item=出張-001 amount=24,160 version=0 古井 開く 200 item=出張-001 amount=24,160 version=0 新井 保存 200 item=出張-001 amount=25,660 version=0 古井 保存 200 item=出張-001(大阪) amount=24,160 version=0 DB あとで見る 200 item=出張-001(大阪) amount=24,160 version=0
画面で起きる上書き(実測)
古井さんの画面には、開いたときの 24,160 円が残っていて、それがそのまま送り返されました。新井さんのタクシー代は消えましたが、2人とも 200 で、エラーもログもありません。これが「あとから保存した人が勝つ」(lost update)です。
開いてから保存までは、数分かかることもあります。そのあいだ DB のトランザクションを開けておくことはできないので、ここは DB の鍵では防げません。第六章の版番号で直します。
150 件同時の「引いて」
次は、もっと短い時間の話です。申請を出すたびに、部署の予算から金額を引く担当者を書きました。残りを読み、足りるか確かめ、引いた値を書く。@Transactional も付いています。
# ① 読んで、引いて、書く(BudgetService.java と BudgetMapper.xml)
@Transactional // ① 読んで、引いて、書く
public void spendNaive(String dept, BigDecimal amount) {
Budget b = budgets.find(dept);
if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
budgets.setRemaining(dept, b.remaining().subtract(amount));
}
<select id="find" resultType="example.expenses.v9.Budget">
select dept, remaining, version from budget where dept = #{dept}
</select>
<update id="setRemaining">
update budget set remaining = #{remaining} where dept = #{dept}
</update>
予算 100,000 円の dev に、1,000 円の「引いて」を 150 件同時に送りました。正しければ、100 件が通って残りは 0 円、残りの 50 件は「予算が足りません」(409)になるはずです。比べのため、第四章・第五章の直し方も並べます。
# 予算 100,000 円に、1,000 円の「引いて」を 150 件同時に(各3回)
やり方 200 409 500 OK にした額 予算から減った額 時間
naive 150 0 0 150,000 円 14,000 円 606 ms
150 0 0 150,000 円 18,000 円 557 ms
150 0 0 150,000 円 18,000 円 441 ms
atomic 100 50 0 100,000 円 100,000 円 378 ms
100 50 0 100,000 円 100,000 円 323 ms
100 50 0 100,000 円 100,000 円 311 ms
locked 100 50 0 100,000 円 100,000 円 405 ms
100 50 0 100,000 円 100,000 円 380 ms
100 50 0 100,000 円 100,000 円 363 ms
① は 150 件とも OK と答えました。OK にした額は 150,000 円で、予算を 50,000 円超えています。それなのに、予算から減ったのは 14,000〜18,000 円でした。お金の出入りが、どちらの向きにも合っていません。
① の中で起きていること
PostgreSQL の既定の分離レベル READ COMMITTED では、select は「その時点でコミット済みの値」を読みます。A と B が同時に 50,000 円を読み、それぞれ 49,000 円を計算します。B の update は A の鍵が外れるまで待ちますが、待ったあとに書くのは B が計算した 49,000 円です。@Transactional は「途中で失敗したら全部取り消す」ための道具で、ほかのトランザクションとの順番までは決めてくれません。
新井1人で試したときは、ちゃんと 1,000 円ずつ減ってました。
古井1人なら、だれとも重ならないからね。この手の不具合は、テストでは眠っている。
SQL の中で引く
いちばん簡単な直し方は、Java で計算せず、SQL の中で引くことです。足りるかどうかの確認も、同じ update の where に入れます。
# ② SQL の中で引く
<update id="subtract">
update budget set remaining = remaining - #{amount}
where dept = #{dept} and remaining >= #{amount}
</update>
@Transactional // ② SQL の中で引く
public void spendAtomic(String dept, BigDecimal amount) {
if (budgets.subtract(dept, amount) == 0) throw new BudgetShortException(dept);
}
B の update は A を待ったあと、where をもう一度確かめ、最新の値(49,000 円)から引きます。足りなければ0行の更新になるので、それを「予算が足りません」にします。結果は 100 件が 200、50 件が 409、予算はちょうど 0 円。SQL は1本で、時間は ① より短いくらいでした(311〜378 ms)。
「残りを読んで Java で計算して書く」を見つけたら、まず「SQL の中でできないか」を考えます。set 列 = 列 - #{値} の形は、在庫・残高・回数など、数を増やしたり減らしたりする場面でそのまま使えます。
行に鍵をかける
確認が1つの where に収まらないこともあります。たとえば「部署の規程を読み、申請者の役職を読み、それから決める」ような場合です。そのときは、読む段階で行に鍵をかけます。select … for update です。
# ③ 行に鍵をかけてから読む(for update)
<select id="findForUpdate" resultType="example.expenses.v9.Budget">
select dept, remaining, version from budget where dept = #{dept} for update
</select>
@Transactional // ③ 行に鍵をかけてから読む
public void spendLocked(String dept, BigDecimal amount) {
Budget b = budgets.findForUpdate(dept);
if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
budgets.setRemaining(dept, b.remaining().subtract(amount));
}
A が for update で読んだ行は、A がコミットするまで、B の for update が待たされます。B は A のあとで最新の 49,000 円を読むので、計算が正しくなります。結果は ② と同じで、100 件と 50 件、0 円(363〜405 ms)でした。
鍵は、トランザクションが終わるまで外れません。鍵を持ったまま遅い仕事をすると、ほかの人が並んで待つことになります(第九章)。
版番号で気づく
第二章の画面の上書きに戻ります。画面を開いている数分間、鍵はかけられません。そこで、行に版番号(version)を持たせます。画面を開いたときの版番号を一緒に送り返し、update の where で「まだ同じ版か」を確かめます。書けたら版番号を 1 つ進めます。
# 版番号つきの update(ClaimEditMapper.xml と ClaimEditService.java)
<update id="updateIfSameVersion">
update expense set item = #{item}, amount = #{amount}, version = version + 1
where id = #{id} and version = #{version}
</update>
@Transactional // 画面を開いたときと同じ版のときだけ書く
public ClaimEdit updateChecked(String id, EditRequest req) {
if (claims.updateIfSameVersion(id, req.item(), req.amount(), req.version()) == 0) {
find(id); // ない → 404
throw new StaleClaimException(id); // ある → だれかが先に変えた
}
return claims.find(id);
}
# 係が 409 に変える(ApiErrors.java に足した分)
@ExceptionHandler(StaleClaimException.class) // 開いたあとで、だれかが先に変えた → 409
ProblemDetail stale(StaleClaimException e) {
return problem(HttpStatus.CONFLICT, "stale", "ほかの人が先に変更しました", "受付番号 " + e.id() + " を開き直して、もう一度変更してください");
}
# 同じ手順を、版番号つきで
新井 開く 200 item=出張-001 amount=24,160 version=0
古井 開く 200 item=出張-001 amount=24,160 version=0
新井 保存 200 item=出張-001 amount=25,660 version=1
古井 保存 409 ほかの人が先に変更しました
古井 開き直す 200 item=出張-001 amount=25,660 version=1
古井 保存 200 item=出張-001(大阪) amount=25,660 version=2
DB あとで見る 200 item=出張-001(大阪) amount=25,660 version=2
新井さんの保存で版が 0 から 1 に進み、版 0 のまま送った古井さんの update は0行になりました。窓口は、それを 409 と「開き直して、もう一度」という返事に変えます。古井さんが開き直すと、タクシー代を含む 25,660 円が見え、そこに行き先を足して保存すると、2人の変更が両方残りました。
この方法は、鍵をかけずに「書くときに気づく」ので、楽観ロックとも呼ばれます。前の章の for update は、先に鍵をかけるので悲観ロックです。画面をまたぐ編集には楽観ロック、1つのトランザクションの中の計算には ② か ③、と使い分けます。
新井409 をもらった人は、ちょっとかわいそうですね。
古井黙って消されるよりずっといい。画面は「ほかの人が先に変更しました」と出して、最新を見せればいい。
分離レベルとやり直し
コードは ① のまま、トランザクションの分離レベルだけを REPEATABLE READ に上げてみました。
# ④ ①のまま、分離レベルを REPEATABLE READ に
@Transactional(isolation = Isolation.REPEATABLE_READ) // ④ ①のまま、分離レベルだけ上げる
public void spendRepeatable(String dept, BigDecimal amount) {
Budget b = budgets.find(dept);
if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
budgets.setRemaining(dept, b.remaining().subtract(amount));
}
やり方 200 409 500 OK にした額 予算から減った額 時間
repeatable 71 0 79 71,000 円 71,000 円 557 ms
47 0 103 47,000 円 47,000 円 510 ms
48 0 102 48,000 円 48,000 円 493 ms
# 500 の中身(アプリのログ、150 件中 100 件で同じ)
org.springframework.dao.CannotSerializeTransactionException:
org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update
予算を超えて OK にすることはなくなりました。そのかわり、150 件中 79〜103 件が 500 です。REPEATABLE READ では、トランザクションの最初に見た値のまま進みます。ほかの人がその行を先に変えていると、PostgreSQL は update を「同時に変えられたので、続けられない」(SQLState 40001)と断ります。PostgreSQL の文書は、この返事を受けたらトランザクションを最初からやり直すように書いています。
# ⑤ 断られたら、最初からやり直す(RetryingSpender.java)
public int spend(String dept, BigDecimal amount) {
for (int attempt = 1; ; attempt++) {
try {
service.spendRepeatable(dept, amount); // 毎回、新しいトランザクション
return attempt;
} catch (ConcurrencyFailureException e) {
if (attempt == 10) throw e;
log.debug("やり直し {} 回目: {}", attempt, e.getClass().getSimpleName());
BudgetService.pause(ThreadLocalRandom.current().nextLong(5, 20) * attempt); // 少し待ってから(待ち時間をばらす)
}
}
}
やり方 200 409 500 OK にした額 予算から減った額 時間
retry 100 50 0 100,000 円 100,000 円 626 ms
100 50 0 100,000 円 100,000 円 578 ms
100 50 0 100,000 円 100,000 円 584 ms
# やり直しの回数(200 の 100 件ぶんの合計/1件の最多)
232 / 5 203 / 5 244 / 6
断られたら少し待って、最初からやり直す係を足すと、100 件と 50 件、予算 0 円になりました。成功した 100 件で、合わせて 203〜244 回の試行、多いものは 6 回目で通っています。やり直しは毎回新しいトランザクションで行う必要があるので、@Transactional のメソッドの外側に書きます。Spring Framework 7 には、同じことをする @Retryable も入りました(既定は 3 回まで、1 秒おき)。
| 直し方 | この回の結果 | 向いている場面と注意 |
|---|---|---|
| ② SQL の中で引く | 正しい・311〜378 ms | 数を増やす・減らす。確認が where に収まること |
| ③ for update | 正しい・363〜405 ms | 読んでから Java で決める。鍵を持つ時間を短く(第九章) |
| ④ REPEATABLE READ | 79〜103 件が 500 | 単独では使わない。断られたらやり直す前提 |
| ⑤ ④+やり直し | 正しい・578〜626 ms | 読む行が多く、鍵をかけにくい。回数の上限と、待ち時間のばらし |
| 版番号 | 先に変えられたら 409 | 画面をまたぐ編集。画面が版番号を送り返すこと |
デッドロック
鍵を使い始めると、もう1つの問題に出会います。予算の付け替え(dev から sales へ 10,000 円)を書き、逆向きの付け替えと同時に動かしました。わかりやすいように、引いたあと 0.2 秒置いてから足します。
# 予算の付け替え(BudgetService.java)
@Transactional // 予算の付け替え:from から引いて、to に足す
public void transfer(String from, String to, BigDecimal amount, long pauseMs, boolean ordered) {
if (ordered) { // 鍵は、いつも名前の順にかける
budgets.findForUpdate(from.compareTo(to) < 0 ? from : to);
budgets.findForUpdate(from.compareTo(to) < 0 ? to : from);
}
if (budgets.subtract(from, amount) == 0) throw new BudgetShortException(from);
pause(pauseMs);
budgets.add(to, amount);
}
# dev→sales と sales→dev を同時に(10,000 円、引いたあと 0.2 秒置いて足す)
ordered=false
dev→sales 500 1220 ms 処理できませんでした ERR-688287aa
sales→dev 200 1218 ms OK
budget: dev 110,000, sales 90,000
ordered=true
dev→sales 200 413 ms OK
sales→dev 200 209 ms OK
budget: dev 100,000, sales 100,000
# そのときの DB のログ
09:08:01.035 JST [2605] ERROR: deadlock detected
09:08:01.035 JST [2605] DETAIL: Process 2605 waits for ShareLock on transaction 13552; blocked by process 2592.
09:08:01.035 JST [2605] CONTEXT: while updating tuple (0,10) in relation "budget"
09:08:01.035 JST [2605] STATEMENT: update budget set remaining = remaining + $1 where dept = $2
デッドロック:鍵を逆の順番で取りあう
① は dev の行に鍵をかけて sales を待ち、② は sales の行に鍵をかけて dev を待ちます。どちらも相手が終わるのを待つので、永遠に進みません。PostgreSQL は 1 秒(deadlock_timeout の既定値)待ってから輪を探し、片方を取り消しました。取り消された側は 500、もう片方は 1.2 秒かかって 200。予算は付け替え1本分だけ動きました。
直し方は、鍵をかける順番をいつも同じにすることです。この回では、最初に2つの行を名前の順に for update で押さえるようにしたところ、2本とも 200(0.2 秒と 0.4 秒)になりました。取り消された側の例外(DeadlockLoserDataAccessException)は、第七章と同じ ConcurrencyFailureException の仲間なので、やり直しの対象にもできます。
トランザクションは短く
最後に、鍵を持つ時間の話です。予算を引いたあとに「通知メールを送る」(ここでは 1 秒待つだけ)処理を足し、トランザクションの中で待つ版と、コミットしてから待つ版を比べました。同じ予算に 12 件同時、さらに 0.3 秒後に、関係ない申請を開く読み取りを1件送ります。
# 通知(1 秒)を、トランザクションの中で待つか、コミットのあとで待つか
@Transactional // 引いたあと、トランザクションの中で通知を待つ
public void spendThenWaitInside(String dept, BigDecimal amount, long notifyMs) {
spendAtomic(dept, amount);
pause(notifyMs); // 通知メールを送ったつもり
}
case "notify-after" -> {
budgets.spendAtomic(dept, amount); // コミットしてから
BudgetService.pause(notifyMs); // 通知を待つ
}
# 12 件同時に、それぞれの返事までの秒数(+0.3 秒後に、関係ない申請を開く1件)
notify-inside 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 11.0 12.0
関係ない申請を開く: 2.73 s
notify-after 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
関係ない申請を開く: 0.00 s
通知を待つ場所で、待ち時間がこう変わる(実測)
中で待つと、予算の行の鍵を持ったまま 1 秒待つので、12 件は1件ずつ順番になり、最後の1件は 12.0 秒かかりました。しかも、予算と関係ない申請を開くだけの読み取りまで 2.73 秒待たされています。鍵を持つ1件と鍵を待つ9件が、DB への接続を1本ずつ持ったまま並び、接続の池(10 本)が埋まったからです。コミットしてから待つと、12 件とも 1.0 秒、関係ない読み取りは 0.01 秒未満でした。
メール、外部の API、ファイルの書き出しなど、遅いかもしれない仕事はトランザクションの外へ出します。Spring では @TransactionalEventListener で「コミットのあとで」動かす方法もあります。
同時更新は「たまに起きる珍しい事故」ではなく、人が2人になった瞬間から毎日起きます。読んで計算して書くを見つけたら、SQL の中で計算できないか、鍵が要るか、版番号が要るかを考えます。そして、鍵を持つ時間はできるだけ短く。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| いまの分離レベルを見る | show default_transaction_isolation; |
| 2人ぶんの psql で、上書きを手で起こす | begin; select … ; update … ; commit;(2つの窓で交互に) |
| だれがだれを待っているか見る | select pid, pg_blocking_pids(pid), query from pg_stat_activity; |
| デッドロックを見つけるまでの時間 | show deadlock_timeout; |
| 読んで書くコードを探す | grep -rn 'set .* = #{' src/main/resources |
psql の窓を2つ開き、両方で begin; してから同じ行を select、それぞれ update すると、2つ目の update が止まるのが見えます。1つ目で commit; した瞬間に、2つ目が動き出します。この「止まって、動く」を自分の目で見ておくと、ログの意味が読めるようになります。
結 — 一枚まとめと、次回
- @Transactional は「全部か、ゼロか」。同時に来た人との順番は守らない。
- 読んで計算して書くと、150 件とも OK で、予算は 18,000 円しか減らない。エラーは出ない。
- 数の増減は SQL の中で。確認も where に入れる。
- 読んでから決めるなら for update。画面をまたぐなら 版番号で 409。
- REPEATABLE READ は断る。断られたら最初からやり直す。
- 鍵はいつも同じ順番で、短く持つ。
新井同時って、こんなに怖かったんですね。
古井次回は承認だ。申請は「下書き → 申請中 → 承認 → 支払済み」と進む。承認済みの申請を、だれかがこっそり直せたらどうなるか。状態遷移の話をしよう。
参考
- PostgreSQL 16 — Transaction Isolation
- PostgreSQL 16 — Explicit Locking(Row-Level Locks, Deadlocks)
- PostgreSQL 16 — Lock Management(deadlock_timeout)
- Spring Framework — Transaction Management
- Spring Framework — Resilience Features(@Retryable)
- MyBatis 3 — Mapper XML Files
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Spring Framework 7.0.10、MyBatis 3.5.19、mybatis-spring-boot 4.1.0)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月31日に取ったものです。申請サーバーと DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、往復は 0.04 ms ほどです。同時の実験は各3回で、結果は動かすたびに変わります(写真は別の1回分)。DB への接続の池は Spring Boot の既定の 10 本です。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第9回 — トランザクションと同時更新 / 2026.10