Old-School Backend / 2026 ・ No.09

いにしえのバックエンド入門

第9回 トランザクションと同時更新 ── あとから保存した人が勝つ

公開

端末の画面。BudgetMapper.xml の setRemaining は update budget set remaining = #{remaining} where dept = #{dept}、subtract は update budget set remaining = remaining - #{amount} where dept = #{dept} and remaining >= #{amount}。python3 show9.py race の結果は、naive が 200 を 150 件、OK にした額 150,000 円、予算から減った額 17,000 円、960 ms。atomic、locked、retry はどれも 200 が 100 件と 409 が 50 件、減った額 100,000 円で、487 ms、700 ms、955 ms
予算 100,000 円に、1,000 円の「引いて」を 150 件同時に送りました。読んで、引いて、書く naive は 150 件とも OK と答えたのに、予算は 17,000 円しか減っていません(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

業務システムでは、同じ申請や同じ予算を、何人もが同時に触ります。1人で試すと正しく動くのに、2人になると数字が合わなくなる。しかもエラーは出ません。
今回は MyBatis で、画面での上書き、予算への 150 件同時の「引いて」、デッドロック、長すぎるトランザクションを PostgreSQL で実測し、SQL の中で引く・行に鍵をかける・版番号・やり直しの4つの直し方を比べます。

一

あとから保存した人が勝つ

新井@Transactional を付けてあるので、同時に来ても大丈夫ですよね。

古井@Transactional が守るのは「全部やるか、全部やらないか」だ。「ほかの人と同時に」は、別の話だよ。

新井別の話……。

古井今日は2人で同じ申請を直して、150 人で同じ予算を引いてみよう。

全15回の路線図(いまここ:第9回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • 画面の上書き ── 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

画面で起きる上書き(実測)

① 開く ② 開く ③ 保存 ④ 保存 新井 古井 DB 開く 24,160 保存 25,660 開く 24,160 保存(大阪) 24,160 24,160 出張-001 24,160 出張-001 25,660 出張-001 24,160 出張-001(大阪) 古井さんは、開いたときに見えた 24,160 を送り返した。タクシー代 1,500 円が消えた 2人とも 200。エラーもログも出ない。気づくのは、月末に経理が合計を見たとき 開いてから保存までは数分。そのあいだ DB の鍵(トランザクション)は使えない

古井さんの画面には、開いたときの 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 円でした。お金の出入りが、どちらの向きにも合っていません。

① の中で起きていること

① の中身:2つのトランザクション A・B が、同じ予算の行を読んで書く A B select 50,000 を見る update → 49,000 commit select 50,000 を見る update 待つ → 49,000 DB 50,000 50,000 49,000* 49,000 2件引いたのに、減ったのは 1,000 円(* は A の書きかけ) ② SQL の中で引く:B は待ったあと、最新の 49,000 から引く → 48,000 ③ for update:B の select が A を待ち、49,000 を読む → 48,000 ④ REPEATABLE READ:B の update が断られる(40001)→ やり直す update は行の鍵を待つが、待ったあとに書くのは「B が計算した値」 READ COMMITTED(PostgreSQL の既定)では、B の select は A の書きかけを見ない

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 &gt;= #{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 つ進めます。

端末の画面。ClaimEditMapper.xml の updateIfSameVersion は update expense set item = #{item}, amount = #{amount}, version = version + 1 where id = #{id} and version = #{version}。show9.py edit naive では、新井さんが 25,660 で保存したあと古井さんが 出張-001(大阪) と 24,160 で保存し、DB は 24,160。edit checked では、古井さんの保存が 409 ほかの人が先に変更しました、開き直すと 25,660 version=1、保存すると 出張-001(大阪) 25,660 version=2
版番号がないと、あとから保存した古井さんが新井さんの変更を消しました(上)。版番号があると古井さんは 409 を受け取り、開き直して保存すると、2人の変更が両方残りました(下)(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 版番号つきの 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 READ79〜103 件が 500単独では使わない。断られたらやり直す前提
⑤ ④+やり直し正しい・578〜626 ms読む行が多く、鍵をかけにくい。回数の上限と、待ち時間のばらし
版番号先に変えられたら 409画面をまたぐ編集。画面が版番号を送り返すこと
八

デッドロック

鍵を使い始めると、もう1つの問題に出会います。予算の付け替え(dev から sales へ 10,000 円)を書き、逆向きの付け替えと同時に動かしました。わかりやすいように、引いたあと 0.2 秒置いてから足します。

端末の画面。上は DB サーバーのログで、ERROR: deadlock detected、DETAIL: Process 11422 waits for ShareLock on transaction 17717; blocked by process 11430.、CONTEXT: while updating tuple (0,27) in relation budget、STATEMENT: update budget set remaining = remaining + $1 where dept = $2。下の show9.py dead は、ordered=false で dev→sales が 200 で 1444 ms、sales→dev が 500 で 1589 ms、ordered=true では 242 ms と 450 ms でどちらも 200
dev と sales の間で、逆向きの付け替えを同時に2本。鍵をかける順番がばらばらだと、DB がデッドロックを見つけて片方を取り消しました。名前の順にそろえると、2本とも通りました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 予算の付け替え(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 付け替え ② budget: dev budget: sales 鍵を持つ 待つ 鍵を持つ 待つ ① は dev を引いて sales を待ち、② は sales を引いて dev を待つ。輪になる PostgreSQL は 1 秒(deadlock_timeout)待って輪を見つけ、片方を取り消す 直し方:鍵をかける順番をそろえる。名前の順で 2 本とも 200 実測:順番ばらばら → 片方 500(1.2 秒)、名前の順 → 0.2・0.4 秒で 200 取り消された側も ConcurrencyFailureException。やり直しの対象にできる

① は 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

通知を待つ場所で、待ち時間がこう変わる(実測)

トランザクションの中で通知を待つ 12 件(点線は鍵待ち) 関係ない読み取り 2.73 s 12.0 s コミットしてから通知を待つ 12 件 関係ない読み取り < 0.01 s 1.0 s 0 2 4 6 8 10 12 秒 鍵と DB の接続を持ったまま、遅いものを待たない 接続の池は 10 本。11 件目からは、関係ない読み取りまで接続を待った

中で待つと、予算の行の鍵を持ったまま 1 秒待つので、12 件は1件ずつ順番になり、最後の1件は 12.0 秒かかりました。しかも、予算と関係ない申請を開くだけの読み取りまで 2.73 秒待たされています。鍵を持つ1件と鍵を待つ9件が、DB への接続を1本ずつ持ったまま並び、接続の池(10 本)が埋まったからです。コミットしてから待つと、12 件とも 1.0 秒、関係ない読み取りは 0.01 秒未満でした。

メール、外部の API、ファイルの書き出しなど、遅いかもしれない仕事はトランザクションの外へ出します。Spring では @TransactionalEventListener で「コミットのあとで」動かす方法もあります。

POINT

同時更新は「たまに起きる珍しい事故」ではなく、人が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つ目が動き出します。この「止まって、動く」を自分の目で見ておくと、ログの意味が読めるようになります。

結 — 一枚まとめと、次回

  1. @Transactional は「全部か、ゼロか」。同時に来た人との順番は守らない。
  2. 読んで計算して書くと、150 件とも OK で、予算は 18,000 円しか減らない。エラーは出ない。
  3. 数の増減は SQL の中で。確認も where に入れる。
  4. 読んでから決めるなら for update。画面をまたぐなら 版番号で 409。
  5. REPEATABLE READ は断る。断られたら最初からやり直す。
  6. 鍵はいつも同じ順番で、短く持つ。

新井同時って、こんなに怖かったんですね。

古井次回は承認だ。申請は「下書き → 申請中 → 承認 → 支払済み」と進む。承認済みの申請を、だれかがこっそり直せたらどうなるか。状態遷移の話をしよう。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ