【AI全职下属】AI 下属翻车现场:AI Agent 写出 `@Transactional`,为什么仍然超卖?
Spring Boot 高并发秒杀实战:AI Agent 写出 @Transactional,为什么仍然超卖?
系列导读:本系列用一个可运行的 Spring Boot 秒杀 Demo,拆解“当 AI 成为我的全职下属”之后,研发工作流应该如何重建。五期内容从一次超卖事故开始,依次讨论并发正确性、上下文裁剪、防作弊 CI、Agent 友好架构和 Human-in-the-loop 上线门禁。主线不是证明 AI 能不能写代码,而是回答一个更实际的问题:当 Agent 已经能高吞吐地产出代码时,工程师如何用边界、验证和审批机制,把它变成可靠的执行者。
文章目录
1. 问题现场:事务写了,库存还是卖穿了
你把“判断库存、扣减库存、创建订单”交给 AI Agent,它返回了一段看上去很规范的 @Transactional 代码。单测通过,接口能跑,日志也没有异常。
但一压测,500 个请求争抢 100 件库存,数据库里出现了 500 条成功订单。更糟的是库存字段没有变成负数,单看库存还以为系统正常。
这个错误很适合拿来做 AI Agent 工程治理的第一课:代码能编译、事务能提交,不等于业务约束在并发下成立。
本文用配套 Spring Boot Demo 复现这个错误,再解释原因、事务原理和修复办法。但第一期真正想强调的不是“秒杀该怎么写”,而是一个更基础的工作习惯:给 AI 下发任务时,不要只描述功能,要把任务写成可验证的验收条件。
如果 Issue 只写“实现一个高并发秒杀接口,注意并发安全”,Agent 很容易交出一段看起来正确的 @Transactional 代码。它完成了“像一个秒杀接口”的表面目标,却没有被要求证明“订单数不能超过库存”这个业务不变量。
所以在开始写代码前,先把目标约束说清楚:
- 库存不能小于 0。
- 成功订单数不能超过初始库存。
- 库存扣减与订单创建必须在同一事务中完成。
这三句话不是补充说明,而是验收条件。后面的代码、压测和修复,都围绕它们展开。
2. 原因:先查再改留下了并发窗口
下面的代码没有语法错误,也加了事务:
@Transactional
public SeckillResult execute(Long userId, Long productId) {
SeckillProduct product = productMapper.selectById(productId);
if (product == null || product.getStock() <= 0) {
return SeckillResult.soldOut("unsafe");
}
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
SeckillOrder order = orderCreator.create(userId, productId, "unsafe");
return SeckillResult.success(order.getId(), "unsafe");
}
单线程下,它的执行过程完全正确。问题出现在两个线程同时读到相同库存时:
线程 A:读取 stock = 100
线程 B:读取 stock = 100
线程 A:写入 stock = 99
线程 B:写入 stock = 99
两笔订单已经创建,库存却只减少 1。这叫丢失更新。因此超卖不一定表现为负库存;它也可能表现为“订单数远大于售出库存,但库存值仍然看起来正常”。
动态演示:为什么 unsafe 会继续写入订单
下面这张动图把两种实现放在同一个并发场景下对比:

- 左侧
unsafe:多个请求并发读到同一个旧库存,后续请求即使已经超过真实库存,也仍然进入“写入订单”区。 - 右侧
atomic:请求必须通过UPDATE ... WHERE stock > 0,库存归零后数据库返回affectedRows=0,服务端不再创建订单。
如果博客平台支持外链页面,也可以打开完整交互动画:

3. 在 Demo 中复现这个错误
启动项目:
cd demo\AutoEnterprise-Seckill
mvn.cmd spring-boot:run
命令要求当前终端中的 java -version 已指向 JDK 17 或更高版本,不在项目中记录任何本机 JDK 安装路径。
执行压测:
python pipeline\run_stress_test.py `
--base-url $env:SECKILL_BASE_URL `
--mode unsafe `
--concurrency 100 `
--requests 500 `
--stock 100
本机一次实测结果:
{
"mode": "unsafe",
"requests": 500,
"concurrency": 100,
"initial_stock": 100,
"success_responses": 500,
"database_orders": 500,
"remaining_stock": 49,
"oversold": true
}
500 个请求全部拿到“秒杀成功”,数据库出现 500 条订单,但初始库存只有 100。更危险的是剩余库存仍为正数,单看库存字段甚至不容易发现事故。
吞吐量受机器、数据库连接池和后台进程影响。判断正确性的关键不是 QPS,而是
订单数 <= 初始库存这一业务不变量。
3.1 如何判断实验复现成功
unsafe模式的database_orders大于 100,说明已复现超卖。remaining_stock不一定为负数;丢失更新时它可能仍为正数。- 如果本机并发较低未复现,可增加
--requests,但不要直接把结果当作生产容量数据。
4. 原理:@Transactional 解决不了所有并发不变量
事务没有失效。每个线程内部的“更新库存 + 插入订单”仍然一起提交或回滚。真正的问题是多个事务都基于旧库存做出了合法但互相冲突的决定。
需要区分两个概念:
- 事务原子性:一个事务中的操作要么全部成功,要么全部失败。
- 并发业务正确性:多个事务同时执行后,系统仍满足库存不超卖等业务不变量。
前者不能自动推导出后者。AI Agent 生成 @Transactional 只能说明它知道要包事务,不说明它已经把库存判断做成了并发安全的不变量。
5. 解决办法:把判断和扣减合并成一条 SQL
MyBatis-Plus Mapper 中增加条件更新:
@Update("""
UPDATE seckill_product
SET stock = stock - 1, version = version + 1
WHERE id = #{productId} AND stock > 0
""")
int deductStockIfAvailable(@Param("productId") Long productId);
Service 只在影响行数为 1 时创建订单:
@Transactional
public SeckillResult execute(Long userId, Long productId) {
if (productMapper.deductStockIfAvailable(productId) != 1) {
return SeckillResult.soldOut("atomic");
}
SeckillOrder order = orderCreator.create(userId, productId, "atomic");
return SeckillResult.success(order.getId(), "atomic");
}
这条 SQL 将“库存大于 0 的判断”和“库存减 1”交给数据库原子执行。多个线程竞争时,最多只有 100 次更新成功。
同样参数再次压测:
{
"mode": "atomic",
"requests": 500,
"concurrency": 100,
"initial_stock": 100,
"success_responses": 100,
"database_orders": 100,
"remaining_stock": 0,
"oversold": false
}
6. 给 AI 下发任务时要写成验收条件
这次超卖事故表面上是并发问题,本质上也是任务表达问题。
很多人给 Agent 下任务时会这样写:
帮我实现一个秒杀接口,要求并发安全,不要超卖。
这句话对人类开发者可能勉强够用,因为人会主动追问边界、补测试、确认数据口径。但 Agent 更像一个高吞吐执行者:你没有明确写进任务的约束,它未必会自动变成测试、SQL 条件或审查清单。
更好的写法是把需求拆成“输入、动作、禁止事项、验收条件”:
任务:实现商品秒杀接口。
输入条件:
1. 商品初始库存为 100。
2. 使用 100 个并发线程发起 500 个秒杀请求。
3. 每个请求使用不同 userId,同一个 productId。
必须满足:
1. 成功响应数量不能超过 100。
2. 订单表新增记录数不能超过 100。
3. 最终库存不能小于 0。
4. 当订单表新增记录数为 100 时,最终库存必须为 0。
5. 库存扣减成功后才能创建订单。
禁止事项:
1. 禁止通过降低并发度让测试变绿。
2. 禁止跳过、删除或弱化测试断言。
3. 禁止只依赖接口返回值判断正确性,必须检查数据库订单数和库存。
验收方式:
1. 提供并发压测脚本或测试用例。
2. 输出 success_responses、database_orders、remaining_stock、oversold 四个指标。
3. 当 oversold=false 且 database_orders<=initial_stock 时,才算通过。
这个模板的关键,是把“不要超卖”翻译成机器可以判断的指标:
| 业务语言 | 验收指标 |
|---|---|
| 不要超卖 | database_orders <= initial_stock |
| 库存不能卖成负数 | remaining_stock >= 0 |
| 成功下单要有真实订单 | success_responses == database_orders 或解释差异来源 |
| 库存扣减和订单创建一致 | 扣减失败时不得创建订单 |
| 并发安全 | 在指定并发度和请求数下重复验证不变量 |
这一步会直接改变 Agent 的实现倾向。
如果任务只写“加事务”,它很可能围绕 @Transactional 做文章;如果任务写成“500 个请求抢 100 件库存,订单数不能超过 100”,它才会更容易走向条件更新、唯一约束、幂等键、锁或补偿机制这些真正服务于不变量的方案。
因此第一期的核心经验可以写成一句话:
不要把“实现什么功能”当作任务终点,要把“满足什么验收条件”写成任务本体。
AI 擅长实现目标,但目标必须被写成测试可以判断的条件。否则它可能完成了代码,却没有完成业务。
实验环境
| 项目 | 版本或参数 |
|---|---|
| JDK | 17.0.15 |
| Spring Boot | 3.5.15 |
| MyBatis-Plus | 3.5.15 |
| 数据库 | H2 内存库,MySQL 兼容模式 |
| 初始库存 | 100 |
| 请求数 | 500 |
| 并发线程 | 100 |
小结
这次事故给出了专栏的第一条原则:
给 AI 下发任务时,先写验收条件,再看代码实现;不要审查它写得像不像人,要审查它是否守住了业务不变量。
下一期将解决另一个常见问题:项目越来越大后,为什么把整个仓库塞进上下文,反而会让 Agent 更容易改错文件。
适用边界
条件更新适合单商品库存这一类简单不变量。涉及多仓、预占库存、超时释放、消息最终一致性时,还需要库存流水、幂等键和补偿机制;本文 Demo 不代表完整电商生产方案。
测试 Demo 仓库
配套 Demo 仓库地址:https://github.com/quan020406/xiaozhan-blog-column-demos
下一篇:小z疯狂码字ing…
码字成功:【AI全职下属】将Agent 的“记忆问题”变成可控制、可审计、可验证的工程上下文管理问题
感谢阅读,记得点赞、关注、收藏,欢迎各位评论区交流!!!
更多推荐

所有评论(0)