AI写代码的并发安全关:7个线上bug死于同一个“读—算—写”结构(附行锁/乐观锁规则文件)

摘要:本文深入剖析了 AI 生成代码中常见的并发安全问题,指出 7 个线上 bug 均源于相同的“读—算—写”结构。文章分析了 AI 产生此类问题的根本原因(训练语料局限和人工审查疏忽),并提供了完整的解决方案:通过决策树区分不同并发场景(无竞争、低并发、高并发),给出行锁和乐观锁的具体代码实现,最后介绍了如何将规则集成到 AI 审查 Skill 中实现自动化防护。

上一篇我把团队的 AI 审查 Skill 开源了——五关清单,41 条规则,仓库地址在文末。有朋友问:41 条规则会一条条讲吗?

讲。把最硬的几关一篇一篇拆开,今天从并发关开始。它在五关里条数不是最多,但"战果"最大:那 11 个线上 bug 里,7 个死在它手里。

之前系列第二篇里给过一份人审清单,但当时只给了审法,没给规则全文和代码。而且有个数字没说透:那 7 个并发 bug,其实是同一个 bug。场景各不相同,代码结构一模一样。这篇把审法翻译成 AI 能自动执行的规则文件,代码补齐。

同 6 行代码,炸了我们 7 次

把 7 个并发 bug 的代码叠在一起看,全是这个结构:

@Transactional
public void deductPoints(Long userId, Integer amount) {
User user = userMapper.selectById(userId); // 读
if (user.getPoints() < amount) { // 算
throw new BusinessException("积分不足");
}
user.setPoints(user.getPoints() - amount);
userMapper.updateById(user); // 写
}

读一次,算一下,写回去。就这三步。

单线程下,这段代码完美——逻辑通顺,命名规范,测试全绿。我们本地跑了三天,没出过一次错。

上线之后,两个请求同时读到 `points = 100`,各扣 10 分,都写回了 `90`。实际应该是 80。MySQL 默认 RR 隔离级别下,普通 SELECT 和 UPDATE 之间有一个窗口,并发一起来,这个窗口就是抽奖。

7 个 bug 全死在这个窗口上。积分、库存、计数类字段,换着花样死。

为什么 AI 每次都写出这个结构

复盘到第 3 个的时候,我就不骂 AI 了,因为我弄明白了一件事:这不是失手,这是它的标准答案。

它训练语料里的教科书示例、博客教程、高票回答,绝大多数"扣减积分"的代码就长这样——教科书在单线程语境里教书,这段代码在单线程语境里是对的。

它不知道你的接口 QPS 上千,不知道这个字段每天被并发读写多少次。你不告诉它,它就给你教科书答案。

还有一个原因在人这边。AI 30 秒写完,我就下意识地 30 秒审完——逻辑一眼就通,通到我把 review 从"推演一遍并发路径"退化成了"看一眼通不通"。7 个 bug 里有好几个,我 review 的时候看了两遍没看出来。不是逻辑复杂,是逻辑太顺了

这两个原因凑在一起,结论只有一个:**规则必须写成文件,让 AI 每次动笔前先读。靠 prompt 会忘,靠人肉 review 会漏,靠文件不会。**

决策树:三句话分完所有场景

并发关规则文件的核心是一棵决策树。碰到"读—算—写"结构,先问数据归属:

1. 不同用户操作不同数据(各扣各的分)→ 没有数据竞争,不需要锁,但要防重复提交——同一个请求不能因为用户双击或网络重试被执行两次。

2. 同一份数据、并发量低 → `SELECT FOR UPDATE` 行锁,简单可靠。

3. 同一份数据、并发量高(秒杀、抢券)→ Redis DECR / Lua 原子预扣,异步落库。这种场景上行锁会把自己拖死。

外加一条兜底:并发量不确定的时候,先上行锁,加监控看重试率,再决定要不要换方案。不确定时不许裸奔。

顺带修正一个旧口径:之前的系列里我写过"不确定就先上乐观锁"。五个月跑下来,这条改了——乐观锁在冲突率上来之后会引发重试风暴,比行锁更难收拾。现在规则库的默认是行锁。规则是活的,以仓库最新版为准。

正确写法,直接抄

姿势一:行锁(低并发首选)

@Select("SELECT * FROM account WHERE id = #{id} FOR UPDATE")
Account lockAndSelect(@Param("id") Long id);
@Transactional
public void deductPoints(Long userId, Integer amount) {
Account account = accountMapper.lockAndSelect(userId);
if (account.getPoints() < amount) {
throw new BusinessException("积分不足");
}
accountMapper.deductPoints(userId, amount);
}

注意:`FOR UPDATE` 必须在 `@Transactional` 方法内调用,否则锁不会持有到事务结束——锁提前释放,等于没锁。这种细节是踩出来的,不是看文档看出来的。

姿势二:乐观锁(冲突少的场景)

@Update("UPDATE account SET points = points - #{amount}, version = version + 1 " +
"WHERE id = #{id} AND version = #{version}")
int deductWithVersion(@Param("id") Long id,
@Param("amount") Integer amount,
@Param("version") Integer version);
@Transactional
public void deductPoints(Long userId, Integer amount) {
for (int i = 0; i < 3; i++) {
Account account = accountMapper.selectById(userId);
if (account.getPoints() < amount) {
throw new BusinessException("积分不足");
}
int affected = accountMapper.deductWithVersion(
userId, amount, account.getVersion());
if (affected == 1) return;
}
throw new BusinessException("扣减冲突,请重试");
}

三个要点:`UPDATE` 语句本身完成"判断 + 扣减"两步,原子执行;`version` 字段做 CAS;失败重试三次,不行就抛业务异常让上游重试。

怎么选:并发量低、冲突少 → 乐观锁,没有锁开销;并发量不确定 → 先行锁加监控;高并发秒杀 → Redis 原子预扣。

这条规则在 Skill 里怎么生效

规则文件 `references/concurrency.md` 的结构:适用条件(代码里出现"读—算—写"就触发)→ 决策树禁止写法正反例

装上之后的效果:AI 生成代码时碰到积分扣减这类场景,第一稿就直接给出带锁的版本,并在注释里标明守住了哪几关;review 已有代码时,看到裸的 SELECT + UPDATE 会直接拦下,附上修改后的代码。

团队版里并发关攒到了 9 条:读—算—写主规则、防重复提交、缓存并发、批量场景的豁免条款……每条后面标着来源——谁加的、踩了什么坑、什么时候加的。

还有一条元规则,我认为比所有具体规则都重要:AI 不确定项目的外部调用范围或并发量级时,必须向人提问,禁止猜测。 方案选错可以改,猜错了没人知道。

最后

仓库地址:[github.com/wangheng19901021/skills](https://github.com/wangheng19901021/skills)。并发关规则全文在 `references/concurrency.md`,正反例在 `examples/select-for-update.md`,复制进你项目的 `.claude/skills/` 就能用。

文中说的 11 个 bug 完整复盘和五关清单速查表,都收进了《AI 代码审查避坑手册

AI 写代码的并发安全关:7 个线上 bug 死于同一个“读—算—写”结构(附行锁/乐观锁规则文件)

关:事务里调 RPC,AI 每次都踩——afterCommit,一条规则的事。

—— 硅基书斋主理人,十年 Java 后端

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐