03.这就是我第一次写的抢红包代码(以及它为什么必炸)
上一节我把那套“你脑子里一定会冒出来的直觉流程”摊开了。现在就按那套直觉来写第一版,不要装成熟,也别急着上 Redis、锁、事务这些词。你把自己放回学生状态,或者放回你刚工作那两年,正常人写出来的第一版,大概就是这个味道。我先把话说在前面:下面这段不是示范代码,是事故现场代码。它能跑,而且在你自己点接口的时候跑得很顺,但它一定会炸。你要做的不是马上把它改“正确”,你要先搞懂它为什么会炸。第一版我通常会把逻辑都写在 Service 里。你看着像不像你会写的那版。if (remainCount <= 0) return fail;if (alreadyGrabbed(userId)) return fail;amount = calculateAmount();decreaseRemain(amount);saveGrabRecord();return success;
这段代码你要是放在单线程里看,几乎挑不出毛病。红包不存在拦住了,抢完了拦住了,重复抢也拦住了。金额算完,库存扣掉,记录写进去,返回成功。你本地调试,或者你一个人拿 Postman 连点十次,基本就会觉得:这不挺好吗?真正的坑就藏在“读→判断→写”这条链路里,而且是藏在你最信任的那句 if 里。比如这句:remainCount <= 0 就失败。它本身没错,错的是它默认了一个前提:你判断完的那一刻,到你真正扣减库存的那一刻,中间不会有人插队。现实里这个前提不存在。这里我想你把它当成一个通用的风险提示:凡是你在代码里“先读一个状态,再基于这个状态做决定,然后再写回去”的地方,默认都是并发雷区。不是因为你写得差,而是因为这种写法天生会出现时间窗口。你以后看任何线上事故,很多都是这个形态:读到了 A,以为下一秒还是 A,于是就按 A 去写;结果写回去的时候,世界已经变成 B 了。我给你一个最小的并发场景,就两个人,不需要什么高并发。A 和 B 几乎同时点了抢红包。A 进来,查到 remainCount = 1,通过了判断。B 也进来,查到 remainCount = 1,也通过了判断。接下来两个人都会去扣减库存。你就会看到你之前怎么都复现不了的现象出现了:明明只剩一份,结果两个人都抢成功;或者红包被扣成负数;或者记录里出现两条“最后一份”。你这时候别急着怀疑自己写错了。很多人翻车之后第一反应是把 if 写得更复杂一点,再加一堆判断,再加一些“看起来更严谨”的条件。你会发现没用。因为问题压根不在条件写得够不够严,而在于你根本没控制住“判断之后到扣减之前那段空档”。还有一个更现实的点:这段代码其实还同时在保护三件事,但你在第一版里是混着写的。第一,红包有没有剩。第二,一个人能不能抢两次。第三,扣减库存和写记录是不是一回事。你把这三件事全塞在一个方法里,单线程看没问题,并发一来就开始打架。你想修,也不知道该从哪修,最后很容易走上“哪里炸我就哪里补”的路。我建议你在这一篇先学会一个拆分思路:把问题分成“规则”和“事实”。规则是你写在 if 里的那些判断,事实是你最终改掉了哪些状态。并发下真正要守住的是事实,不是规则。规则你写得再漂亮,事实没守住,系统照样错。我带班时最喜欢的就是让大家先写出这段必炸版,因为它太真实了。真实到你后面再谈锁、谈 Redis、谈乐观锁,学生不会觉得那是玄学,而是会觉得:我确实需要一个东西,把我刚才那个空档给补上。下一篇我不急着把你拉去背概念。我先把一个问题讲清楚:这段逻辑里,到底哪些步骤必须绑在一起,必须做到“要么都发生,要么都不发生”。你把这个边界划清楚了,后面你用什么技术手段去实现,才有意义。别急着改代码。先把炸点看明白。你以后工作里写的很多“看起来没问题但线上会炸”的代码,本质上都和这段第一版是同一类毛病。