去年三月,我们有人连续一周早上 4 点 50 到客户仓库。不是去搬菜,是去看开单。生鲜的节奏跟别的电商不一样:5 点整小程序开放,6 点半截单,7 点前分拣装车,晚半小时这一车就赶不上早市。
第一版:下单扣库存
逻辑最直白,提交订单时 UPDATE stock SET num = num - qty。上线第三天出问题:有人把菜加进购物车不付款,库存已经被扣了,别人看着是售罄。到 6 点半截单,系统里剩一堆「被占着但没卖掉」的斤数,仓库那边按系统数备货,白备了。
老板说得挺直接:这菜放不到下午。
第二版:预占 15 分钟
改成创建订单时写一条预占记录,库存表里加 frozen 字段,可售量等于 num 减 frozen。15 分钟不支付,定时任务每分钟扫一次把预占释放掉。这一版对了大半,但碰上一次:同一秒两笔单抢最后 3 斤,两边都读到可售 3,都觉得自己够,结果超卖 2 斤。
原因是先查后写,中间没有锁。两个人同时看,都看见 3。
第三版:把判断交给数据库
最后没上队列,也没上分布式锁,就用了一条条件更新:
UPDATE goods SET num = num - 1 WHERE id = 10086 AND num >= 1
后端 PHP 拿 PDO 的 rowCount(),返回 0 就说明没抢到,直接回「刚卖完」。一条语句里完成判断和扣减,MySQL 的行锁保证不会两个都成功。预占记录还在,但只有抢到了才写。
那次改完,我们又拿 ab 并发压了 50 次,看有没有负数。没有。
那 15 分钟是怎么定的
预占时长不是拍的。客户翻了半个月的订单记录,从创建到支付的中位时间是 3 分多钟,最长的一笔 11 分钟,是有人去仓库门口接电话。留 15 分钟,够覆盖绝大多数,又不至于让菜在系统里压太久。
每分钟扫一次的定时任务,我们特意写成「只释放未支付且未取消」的。第一版漏了「未取消」,结果有人主动取消订单,预占又被释放任务重复处理了一次,日志里多了一行,没造成错,但看着别扭,加了状态判断才干净。
超卖那 2 斤后来怎么算的
当天仓库照实发了货,多出来的 2 斤是老板自己贴的,没让客户补钱。复盘会上我们提要不要加个「超卖告警」,老板摆摆手,说一年碰不上两回,真碰上了他宁可自己认,别为这个把下单流程弄复杂。
这话我们记下来了。后来做别的电商项目,遇到「要不要为极小概率事件加一层校验」,常拿来问一句:真出了,是系统兜还是人兜?有些场景,人兜更便宜。
那台旧电脑
仓库录单那台机器后来换过一次,2018 年我们去看的时候键盘上 F1 到 F12 的标签还是磨没的。新机器贴上去了,操作员大姐说不用看,手记得。站着看的那 40 多张单里,她没抬过一次头找鼠标。
早市那套逻辑后来又改过几版,加过冷链时段、加过品类汇总,但 rowCount 那行一直没动,注释写着:先抢到再写预占。那年仓库门口的雨棚是后来才搭的,之前下雨只能站檐下,我们蹲点那几天没赶上几场雨,倒是赶上了两回秤坏了。
早上那点事
截单前 10 分钟,仓库会在后台点「导出分拣单」,一张按品类汇总的 Excel,A4 两三页。这个导出我们特意做成了按预占释放后的最终数算,不按可售量,怕中途有人取消。
分拣大姐把纸单夹在板子上,推着车走。系统到 6 点半自动关下单入口,前端按钮置灰,后端也判一次时间,两道都在。
有天 5 点 40 下小雨,仓库门口地上湿了一片,我们几个人站在檐下看手机上的订单数往上跳。没什么好升华的,就是觉得那行 rowCount 没白写。

