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 有 150 个 200,回复 OK 的金额 150,000 日元,预算实际减少 17,000 日元,960 ms。atomic、locked、retry 都是 100 个 200 和 50 个 409,减少 100,000 日元,分别 487 ms、700 ms、955 ms
向 100,000 日元的预算,同时发了 150 个“扣 1,000 日元”。读出、减去、写回的 naive 对 150 件全都回答 OK,预算却只少了 17,000 日元(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

在业务系统里,同一份申请、同一笔预算,常常有好几个人同时在动。一个人测试时好好的,两个人一起用,数字就对不上了,而且不报错。
这一讲用 MyBatis 在 PostgreSQL 上实测:画面上的覆盖、对预算同时发 150 个“扣款”、死锁、过长的事务,并比较四种解决办法:在 SQL 里减、给行上锁、版本号、重试。

一

后保存的人赢

新井我加了 @Transactional,同时来请求也没问题吧。

古井@Transactional 保证的是“要么全做,要么全不做”。“和别人同时”是另一回事。

新井另一回事……

古井今天我们两个人改同一份申请,再让 150 个人同时扣同一笔预算试试。

全 15 讲路线图(当前:第 9 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 画面上的覆盖 ── 两人都是 200,先保存的人的修改却消失了(实测)
  • 同时 150 件 ── 150 件全回答 OK,预算只少了 14,000〜18,000 日元(实测)
  • 四种解决办法 ── 在 SQL 里减、for update、版本号、隔离级别+重试
  • 死锁 ── 两条反向调拨,其中一条 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

数据库里新增了部门预算表 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 日元出租车费消失了 两人都是 200。没有错误也没有日志。直到月底会计看合计时才发现 从打开到保存要几分钟。这期间用不了数据库的锁(事务)

古井的画面上还留着打开时的 24,160 日元,于是原样送了回去。新井的出租车费消失了,但两人都是 200,没有错误,也没有日志。这就是“后保存的人赢”(lost update)。

从打开到保存,可能要好几分钟。这期间不能一直开着数据库事务,所以这里靠数据库的锁防不住。第六章用版本号解决。

三

同时 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,同时发 150 个“扣 1,000 日元”。正确的话,应该有 100 件通过、余额 0 日元,剩下 50 件得到“预算不足”(409)。为了比较,第四章、第五章的解决办法也一起列出来。

# 向 100,000 日元的预算,同时发 150 个“扣 1,000 日元”(各 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 日元。钱的进出,两个方向都对不上。

① 里面发生了什么

① 的内部:两个事务 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,000 日元。

古井一个人就不会和谁重叠。这类问题,在测试里是睡着的。

四

在 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 日元。只有 1 条 SQL,时间反而比 ① 还短一些(311〜378 ms)。

看到“读出余额、在 Java 里算、再写回”,先想想“能不能在 SQL 里做”。set 列 = 列 - #{值} 这种写法,库存、余额、次数等增减数量的场合都能直接用。

五

给行上锁

有时确认条件装不进一个 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 保存,数据库为 24,160。edit checked:古井的保存得到 409 ほかの人が先に変更しました,重新打开为 25,660 version=1,再保存为 出張-001(大阪) 25,660 version=2
没有版本号时,后保存的古井抹掉了新井的修改(上)。有版本号时,古井收到 409,重新打开再保存,两人的修改都留了下来(下)(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 带版本号的 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 日元,再加上目的地保存,两人的修改都保留了下来。

这种方法不上锁,而是“在写的时候发现”,所以也叫乐观锁。上一章的 for update 先上锁,叫悲观锁。跨画面的编辑用乐观锁,一个事务里的计算用 ② 或 ③,分开使用。

新井收到 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 就以“被同时修改,无法继续”(SQLState 40001)拒绝 update。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
# 重试次数(100 个 200 的合计/单件最多)
  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跨画面的编辑。画面要把版本号送回来
八

死锁

开始用锁,就会遇到另一个问题。我们写了预算调拨(从 dev 调 10,000 日元到 sales),并和反方向的调拨同时运行。为了看得清楚,减完之后隔 0.2 秒再加。

终端画面。上方是数据库服务器日志: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 之间,同时执行两条方向相反的调拨。上锁顺序不一致时,数据库发现死锁并取消了其中一条;统一按名字顺序后,两条都通过了(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 预算调拨(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
# 当时的数据库日志
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)发现环,取消其中一个 解决:统一上锁顺序。按名字排序后两条都是 200 实测:顺序混乱 → 一条 500(1.2 秒),按名字 → 0.2・0.4 秒 200 被取消的一方也是 ConcurrencyFailureException,可以作为重试对象

① 锁住 dev 的行、等 sales;② 锁住 sales 的行、等 dev。双方都在等对方结束,永远走不下去。PostgreSQL 等了 1 秒(deadlock_timeout 的默认值)后去找这个环,取消了其中一条。被取消的一方 500,另一方用了 1.2 秒得到 200。预算只按一条调拨移动了。

解决办法是上锁的顺序永远一致。这一讲改成先按名字顺序用 for update 锁住两行,两条都成了 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 秒后打开一份无关申请)
  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 秒 不要拿着锁和数据库连接去等慢的东西 连接池 10 条。第 11 件起,连无关的读取都要等连接

在事务里等,会拿着预算行的锁等 1 秒,12 件只能一件一件排队,最后一件用了 12.0 秒。而且,连只是打开一份与预算无关的申请的读取,也被迫等了 2.73 秒。因为持锁的 1 件和等锁的 9 件各自占着一条数据库连接,把连接池(10 条)占满了。提交后再等,12 件都是 1.0 秒,无关的读取不到 0.01 秒。

邮件、外部 API、写文件等可能很慢的工作,要放到事务外面。Spring 也可以用 @TransactionalEventListener 让它“在提交之后”运行。

POINT

并发更新不是“偶尔发生的罕见事故”,而是从用户变成两个人的那一刻起,每天都在发生。看到读出、计算、写回,就想想能否在 SQL 里算、要不要锁、要不要版本号。还有,持锁的时间越短越好。

十

在自己的电脑上验证

想试的事写法
查看当前隔离级别show default_transaction_isolation;
用两个 psql 手动制造覆盖begin; select … ; update … ; commit;(两个窗口交替)
看谁在等谁select pid, pg_blocking_pids(pid), query from pg_stat_activity;
发现死锁前的等待时间show deadlock_timeout;
找出“读出再写回”的代码grep -rn 'set .* = #{' src/main/resources

打开两个 psql 窗口,两边都 begin; 后 select 同一行,再分别 update,就能看到第二个 update 停住了。在第一个窗口 commit; 的瞬间,第二个开始动。亲眼看过这个“停住、再动”,日志的意思就读得懂了。

结 — 一页总结与下一讲

  1. @Transactional 是“要么全部,要么为零”,不保证与同时到来的人的顺序。
  2. 读出、计算、写回:150 件全 OK,预算只少 18,000 日元,而且不报错。
  3. 数量的增减在 SQL 里做,确认条件也放进 where。
  4. 读了再决定,用 for update;跨画面,用版本号返回 409。
  5. REPEATABLE READ 会拒绝。被拒就从头重来。
  6. 锁要总按同一顺序拿,并且拿得短。

新井原来“同时”这么可怕。

古井下一讲是审批。申请会按“草稿 → 申请中 → 已批准 → 已支付”前进。如果有人能悄悄改掉已批准的申请,会怎样?我们来讲状态迁移。

参考

记录是笔者于 2026 年 10 月 31 日在云端 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 取得的。申请服务器与数据库服务器是在一台机器里建的不同网络空间(network namespace),往返约 0.04 ms。并发实验各做 3 次,结果每次运行都会变化(截图是另外的一次)。数据库连接池是 Spring Boot 默认的 10 条。文中的数字和输出,都是从当时命令输出里读到的实际值。登场人物均为虚构。

古法后端入门 第9讲 — 事务与并发更新 / 2026.10

← 返回文章列表