AI写代码竟致库存超卖?并发控制与事务一致性的教训
写代码十年的老油条翻过不少车但最近一次翻车让我窝火到不行——不是因为它报错而是因为它全程没报错逻辑看起来全对REVIEW也没拦下结果上线后直接把库存扣穿了。事情发生在我负责的一个积分商城项目上。用户攒积分可以兑换商品运营隔三差五搞一波抢兑活动。需求本身非常普通用户积分够、商品库存够就扣积分减库存创建一个兑换订单。我图省事把需求丢给AI写AI很快就吐出一段“标准得不能再标准”的代码。单测是AI顺手一起生成的跑了三条用例全过;代码评审同事扫了一圈只提了返回码统一的风格建议;我自己本地跑了一遍正常兑换、积分不足、库存不足三种路径返回值都符合预期。结果上线后第二周运营搞了一波限量抢兑200件库存的商品后台显示剩余-37。订单创建了237单仓库压根发不出来。这两天排查下来我最大的感悟不是“AI不行”而是AI代码最大的问题恰恰出现在“几乎完美”的时刻因为它太像人写的好代码了反而让人放弃了质疑。这篇复盘我把整个过程、背后的技术原因以及我现在防翻车的几个策略全部写清楚给正在用AI写代码的朋友一个参考。1. 真实事故还原一段“看起来全对”的代码如何上线后就崩1.1 需求简单到我觉得不需要动脑直接丢给了AI我们这个积分商城核心兑换逻辑就一个接口用户登录传用户ID和商品ID系统判断两件事——用户积分是否大于等于商品兑换价商品库存是否大于0。都满足就扣掉用户积分、扣掉一个库存、创建一条兑换订单、提交事务返回成功。这个流程在我脑子里不是需求是常识。每个做过后端业务的人都知道这是个标准CRUD甚至都不需要开设计会。当时我手头有几个功能同时在忙想着“这种模板代码让AI写最快”就直接用一句话描述需求“写一个积分兑换接口用户积分足够并且商品库存大于0时扣减积分和库存并创建订单。”就是这么一句提示词后面引出的是整整两天的线上事故。1.2 AI的产出确实漂亮得让人挑不出毛病AI返回的核心代码长这样。def exchange(user_id, product_id): user db.session.query(User).filter_by(iduser_id).first() product db.session.query(Product).filter_by(idproduct_id).first() if not user or not product: return {code: 40001, msg: 用户或商品不存在} if user.points product.price: return {code: 40002, msg: 积分不足} if product.stock 0: return {code: 40003, msg: 库存不足} product.stock - 1 user.points - product.price db.session.add(Order(user_iduser_id, product_idproduct_id)) db.session.commit() return {code: 0, msg: 兑换成功}平心而论这段代码放在“单用户一次兑换”的场景下写得相当完整。查用户、查商品、用户不存在、商品不存在、库存不足、积分不足都覆盖了。扣减的顺序也是先查后改提交前追加订单记录事务也用了。我当时甚至觉得让AI写比我自己手写还要规整。我还顺手让AI生成了pytest测试文件三条用例分别是正常兑换、积分不足、库存不足跑出来全绿。同事看到这段代码也只给了两点非业务性的建议一个说返回码数字风格希望统一另一个说函数命名用动词开头更舒服。没有任何人提出并发相关的疑问——包括我自己。1.3 上线翻车库存从200直接变到-37上线后第一周风平浪静。第二周运营策划了一场“积分限时抢兑”标价商品放出200件库存。活动前我特意看了一眼监控面板接口QPS当时还在个位数心想这点量根本不算压力。结果活动开始的第一分钟QPS冲到几十监控警报瞬间响起来库存数量变成了负数。我第一反应是脏数据或者人工改库出错赶紧打开数据库看记录发现事情远比想象的严重商品库存从200一路减到-37兑换订单表里对应创建了237条记录而且还有几十个请求一直排在后面。当时先把功能下线回滚了版本然后把线上日志拉出来逐条分析。问题清晰得让人羞愧同一毫秒内至少几十个请求同时读到“该商品剩余1件库存”然后每个人都通过了检查各自执行了库存减一和订单插入。数据库的最终结果就是库存被扣到了负数。这是教科书里的TOCTOU竞态条件检查库存和扣减库存两个步骤之间没有任何原子性保护。我那个“product.stock 0”的判断在并发场景下等于没有——两个请求都可以同时看到库存为1然后同时觉得“自己还有权限扣”。紧急修复用的是原子条件更新核心思路是让数据库来决定“还有没有库存”而不是靠应用层先读再判断。UPDATE product SET stock stock - 1 WHERE id %s AND stock 0先执行这条UPDATE再看影响行数如果返回0说明库存已经被其他请求抢走直接回滚整个事务不创建订单。如果返回1说明这次扣减是有效的才继续插入订单记录并提交。经过这个修复超卖才真正止住。复盘到这儿我开始真正思考那个让我不舒服的问题这段代码到底为什么“看起来没问题”只是因为我看代码的时候太自信吗还是AI生成代码这件事本身就藏着某些系统性的盲区”2. 深度拆解为什么AI生成的代码总是“看起来没问题”2.1 盲区一AI在补全代码模式不是在理解业务逻辑一个很反直觉的事实是AI生成代码本质是在做海量代码样本上的模式续写。它见过的公开代码片段里库存、积分、订单这类业务有着无比相似的写法所以它很容易生成一段“在所有公开代码里出现概率最高”的兑换逻辑。这个逻辑对单一请求是成立的因为主流示例代码本身就是这么写的。但它不会像有经验的人那样主动做因果推理。人在写这个接口的时候脑子里会不自觉地跑一遍问题“万一两个人同时兑换最后一单”“万一用户重复点提交按钮”“万一扣积分成功了但插订单失败”AI不会自己把这些场景跑一遍因为提示词里没有这些信息。它就沿着“查库—判断—扣减—插入—提交”这条最常规的路径一路写下去直到输出结束。我后来总结了一个类比让AI写代码就像把SOP手册交给一个特别熟练的实习生。手册里写了的流程他执行得又快又准手册里没写的角落他不会主动多想。高并发、幂等、事务回滚这些都是SOP里没写的东西。2.2 盲区二AI对边界条件的想象力只会按“最省力”的方式展开我复盘时把AI生成的原版代码和后来团队手写的修复版做了逐行diff发现不止缺锁。原版代码还缺了很多“一上线就可能出事的细节”没有幂等控制。同一个用户如果连点两次兑换按钮会生成两笔订单。没有考虑用户积分在被扣减前可能已经被其他并发操作消耗。没有考虑“订单表插入失败时积分和库存扣减要不要回滚”这件事的完整事务边界。没有打印任何业务日志出问题后连“哪个用户在哪个时刻成功扣减”都看不到。这些空白不是模型能力不行而是模型在估算“最可能生成的代码”时默认把分支覆盖收敛到了最小集。主流程必写常见异常分支也会写但幂等、重试、超时、并发、事务一致性、日志可观测性这些生产级要求如果提示词里没有很多模型默认就不会展开。它会写一个“单用户视角下看起来逻辑完整”的函数而不是“生产环境下系统正确”的服务。生产环境真正要命的恰恰是这些非主路径促销流量突增、网络闪断导致重复请求、数据库连接池耗尽、某个第三方接口慢到拖垮线程、缓存失效后突发回源。你把这些出事的可能性列出来再回头读AI生成的那段代码会发现它在这些路径上基本是一片空白。2.3 盲区三AI只看得见提示词这个“小世界”看不见你的整个系统第三个盲区更隐蔽。AI写代码的时候眼睛只能看到你喂给它的那一段上下文看不到你们项目的架构看不到服务的部署方式看不到数据库是主从还是单机看不到上游调用方有没有超时重试看不到你们网关设置的超时上限也看不到团队对日志和监控的要求。于是AI会自动默认一个“绝对理想的环境”单机部署、内存无限、数据库永远零延迟、网络永远不抖、请求永远不并发。在这个默认宇宙里先查一次库再写库没问题;两个UPDATE先后执行没问题;从连接池拿连接永远秒回没问题。但这些假设落到真实系统里每一条都可能被击穿。这也是为什么AI代码做代码评审时特别容易“顺利通过”评审你我的注意力也被带进同一个默认宇宙看代码的时候默认“现在这个环境是正常的”。人与人之间评审还可能有分歧和质疑面对一段“结构流畅、逻辑标准、测试全绿”的AI代码大脑会不自觉地放松警惕。3. 复盘事故背后的三个误区这些判断让我麻痹到放它上线3.1 误区一代码“能跑”就默认代码“跑对了”本地跑一遍正常兑换返回成功积分不足返回业务码库存不足也返回业务码。三种路径的输出都符合预期我当时就直接得出“功能完成”的结论。可“能跑”跟“跑对了”完全不是一回事。本地这种单进程、无并发的环境不可能暴露“两个请求同时读到库存1”这种竞态。验证“能跑”只证明了输入输出符合预期证明不了逻辑在多请求同时执行时还成立。以后遇到任何涉及读改写状态的功能本地通了之后都应该再用并发工具模拟同时来几十个请求看数据最终是否一致。3.2 误区二单测过了是保障其实很多时候是在给你壮胆这次最尴尬的事情是那组pytest单测也是AI顺带生成的。被测代码是AI写的测试也是AI写的等于让AI自己证明自己写得对。问题在于如果AI对业务需求的理解本身就有盲区那它的测试也会基于同样的盲区构造结果就形成了一个“自证正确”的死循环。当时AI生成的库存测试长这样先设置商品库存为1请求一次兑换返回成功再查数据库确认库存为0。这个用例看似合理但它完全没模拟“两个请求同时进来抢这最后一件”。后来我把有约束力的测试改成并发模拟开10个线程同时抢最后1个库存断言最终创建成功的订单只有1张库存为0。这条用例一跑原版AI代码立刻红了。写测试的正确姿势应该先从业务契约出发而不是从代码实现出发。业务契约是“任何时候都不允许超卖”那测试就要设计一个能证明“超卖必然发生”的场景。基于代码去补测试很容易变成拿测试给代码洗地。3.3 误区三代码评审过了就以为团队已经为正确性集体背书评审同事没挑出问题不是他不够认真而是评审的注意力天然容易放在代码结构、命名规范、风格一致性这些一眼就能看到的东西上。对业务时序和系统边界的审视通常需要带着特定问题去读代码比如“两个请求同时进来这段代码是否安全”“这个操作失败后事务会怎样收尾”“重复调用会不会产生脏数据”。有AI以后这个问题更严重。AI生成的代码太流畅太工整看起来就像某个资深工程师写的读者大概率会下意识少问几个“为什么”。经历过这次事故我给团队评审流程加了一个硬性环节不管是人写的还是AI写的代码合入前作者必须先回答三个问题——这段代码在什么极端情况下会出问题出了问题系统能不能感知和回滚涉及关键状态变更的地方有没有事务或幂等保护答不上来哪怕代码再顺眼也不许合入。4. 防翻车策略把AI从“答案生成器”调教成“受控实习生”只讲道理没意思下面四条策略都是我那次重构之后一直在用的算是在现有工作流里验证过有效。4.1 提示词里不只要写业务步骤还要写清楚并发、一致性和失败路径我最初给AI的提示词只描述了几步操作等于告诉它“做一套快乐路径就行”。后来我重新写了一个版本把生产环境的约束信息一起喂进去实现积分兑换接口必须满足 1. 库存扣减与订单创建必须在同一个数据库事务内完成 2. 扣减库存只能使用 WHERE 条件限定的原子 UPDATE禁止先查后改 3. 库存不足时返回明确业务错误码 4. 同一用户重复提交请求时要具备幂等控制不能产生两张重复订单 5. 任意一步失败时事务整体回滚。这个提示词不需要你会Hack完全是把“上线后可能会出什么问题”翻译成约束清单。你会发现当这些约束进入提示词后AI输出质量会明显上台阶它会主动用条件UPDATE会加上幂等键会在库存不足分支返回业务码而不是简单抛一个500。4.2 拿到代码别急着跑先逼AI把每个失败路径给我解释一遍现在我有个固定习惯AI生成代码后先不写测试也不做评审直接追问几个问题——数据库连接断开会怎样并发请求同时进来会怎样上游超时重试会怎样同一批数据被两个事务同时修改会怎样然后要求AI给出每个问题的处理方案。这个追问过程不只是为了得到一个“回答”更重要的是逼我自己把系统的失败路径梳理一遍。AI可能在某些边界问题下给不出完美答案但那个“把它没有考虑到的方向提出来”的动作会逼我作为开发者提前补上漏洞。AI负责写代码边界假设这些系统性问题得靠人来把关。4.3 先写契约测试再让AI实现不要反着顺序来我前面强调过“AI生成代码再生成测试”是危险的现在我的习惯是反过来的先由我或项目里有经验的人把业务契约测试写好再把这些测试作为约束交给AI让它去实现满足这些测试的代码。比如我会先写好下面这类测试def test_concurrent_exchange_only_one_order_succeeds(): # 准备商品库存为1用户积分为100 # 并发10个线程同时请求兑换 # 断言最终成功订单数 1商品库存 0 ...测试前置代码后置AI在实现时就会被测试引导到正确的方向。反过来如果AI代码先行测试后补那测试基本会沦为代码的“辩护律师”很难发现真正的业务隐患。个人开发者也能用这个套路。把一个核心业务约束写成测试失败就红、通过就绿AI改代码时自己看着颜色就能迭代。这个小流程的成本很低但收益非常大。4.4 评审时把一半时间花在“系统叙事”上而不是只盯代码细节传统的代码评审容易变成逐行找茬命名、缩进、返回值风格、函数长度。这些当然重要但都算“低风险关注点”。真正的高风险关注点是这段代码在系统里将要经历的真实过程。我现在建议团队在评审新代码时先让代码作者把功能讲成一个故事某个时刻一个用户发起请求系统按什么顺序去读哪些数据做了哪些判断在哪个节点修改数据在哪个节点提交如果出错了怎么回滚。然后继着往下追问如果同时有100个人做同样的事这个故事还成立吗如果同一个人连按十次按钮呢这种“系统叙事”式评审会把所有参与者的注意力从文本层面拉到系统行为层面。如果当时评审时有任何人问一句“这个接口在抢兑场景下会不会有两个请求同时通过判断”这段代码根本不会上线。5. 写在最后翻过这次车我总结出的几条经验如果只挑一条给正在用AI写代码的朋友我会说别怕AI写出“看起来差点意思”的代码怕的是写出“看起来特别对”的代码。“看起来特别对”意味着你会省掉本该进行的边界推演和并发测试而这恰恰是事故最大的入口。我现在对AI编程的态度没有变依然把它当提升效率的重要工具但使用方式变了。它帮我搭项目骨架、生成调用链示例、探索不熟悉的库、写注释和文档这些场景它效率确实高。但凡是涉及“状态变更”和“数据一致性”的核心逻辑我都要按“系统级正确”的标准重新过一遍把并发、幂等、事务、失败回滚、日志可观测性全部当成必答题而不是选答题。最近我又接了一个类似的兑换需求这次没有让AI直接开写先花半小时写了并发测试、幂等测试和库存不为负的契约测试然后才把需求和测试一起发给AI。实现代码一次通过上线后也没再爆类似事故。说到底AI能把代码写得很好看但把系统想得很难看。那个为“系统整体正确性”兜底的人永远且只能是你我这些坐在屏幕对面、要在出事后半夜爬起来处理数据的开发者。