写代码十年的老油条,翻过不少车,但最近一次翻车让我窝火到不行——不是因为它报错,而是因为它全程没报错,逻辑看起来全对,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(id=user_id).first() product = db.session.query(Product).filter_by(id=product_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_id=user_id, product_id=product_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能把代码写得很好看,但把系统想得很难看。那个为“系统整体正确性”兜底的人,永远且只能是你我这些坐在屏幕对面、要在出事后半夜爬起来处理数据的开发者。