Old-School Backend / 2026 ・ No.09
古法后端入门
第9讲 事务与并发更新 ── 后保存的人赢
发布
在业务系统里,同一份申请、同一笔预算,常常有好几个人同时在动。一个人测试时好好的,两个人一起用,数字就对不上了,而且不报错。
这一讲用 MyBatis 在 PostgreSQL 上实测:画面上的覆盖、对预算同时发 150 个“扣款”、死锁、过长的事务,并比较四种解决办法:在 SQL 里减、给行上锁、版本号、重试。
后保存的人赢
新井我加了 @Transactional,同时来请求也没问题吧。
古井@Transactional 保证的是“要么全做,要么全不做”。“和别人同时”是另一回事。
新井另一回事……
古井今天我们两个人改同一份申请,再让 150 个人同时扣同一笔预算试试。
全 15 讲路线图(当前:第 9 讲)
- 画面上的覆盖 ── 两人都是 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
画面上发生的覆盖(实测)
古井的画面上还留着打开时的 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 日元。钱的进出,两个方向都对不上。
① 里面发生了什么
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 >= #{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。
# 带版本号的 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 READ | 79〜103 件 500 | 不单独使用。以被拒就重试为前提 |
| ⑤ ④+重试 | 正确・578〜626 ms | 要读的行多、不好上锁。次数上限,错开等待时间 |
| 版本号 | 被先改就 409 | 跨画面的编辑。画面要把版本号送回来 |
死锁
开始用锁,就会遇到另一个问题。我们写了预算调拨(从 dev 调 10,000 日元到 sales),并和反方向的调拨同时运行。为了看得清楚,减完之后隔 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
# 当时的数据库日志
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。预算只按一条调拨移动了。
解决办法是上锁的顺序永远一致。这一讲改成先按名字顺序用 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
等通知的位置不同,等待时间就这样变化(实测)
在事务里等,会拿着预算行的锁等 1 秒,12 件只能一件一件排队,最后一件用了 12.0 秒。而且,连只是打开一份与预算无关的申请的读取,也被迫等了 2.73 秒。因为持锁的 1 件和等锁的 9 件各自占着一条数据库连接,把连接池(10 条)占满了。提交后再等,12 件都是 1.0 秒,无关的读取不到 0.01 秒。
邮件、外部 API、写文件等可能很慢的工作,要放到事务外面。Spring 也可以用 @TransactionalEventListener 让它“在提交之后”运行。
并发更新不是“偶尔发生的罕见事故”,而是从用户变成两个人的那一刻起,每天都在发生。看到读出、计算、写回,就想想能否在 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; 的瞬间,第二个开始动。亲眼看过这个“停住、再动”,日志的意思就读得懂了。
结 — 一页总结与下一讲
- @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
记录是笔者于 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