2.2 预约下单:并发占位与锁

场景:十八个客人同时抢一个时段

档期表建好之后,真正的考验来了:并发。还记得林姐说的国庆事故吗?十七个客人撞在同一时段。小程序上线后的第一个周末,我们就亲眼见到了数字版的同一场事故:周六上午十点准时放出的新名额,四秒钟内涌进一百三十个请求,其中六个客人成功提交了同一时段的最后一个位置。库里的容量明明只剩一位,六个订单却都写进了成功。

这一切发生在系统上线第 9 天,如果不是健壮的日志,我们可能还蒙在鼓里以为一切顺利。发现问题的过程也颇具戏剧性:一位客人在群里抱怨约到了同一时段,另外两位也跳出来附和,林姐截图发过来的时候我们正在吃火锅。放下筷子连夜排查,凌晨两点从 Nginx 日志里数出了五个几乎同毫秒的请求。

问题的根源不是代码写得差,而是经典的检查后执行间隙:接口先查剩余容量、再插入预约、最后更新计数,三步之间隔了几十毫秒,这几十毫秒足够让六个请求都看到剩一位。对于美甲店这种小体量系统,我们必须用最简单可靠的方案把这段间隙堵上,而不是引入一整套分布式锁组件。

事后林姐问了一个特别直击灵魂的问题:那六个人最后怎么办?答案是系统按时间戳留了最先下单的那位,其余五位接到提示后被客服手动改约。但客人心里清楚,这种人工兜底不能成为常态,客人每多等一分钟,信任就掉一分。当晚我们就把并发占位列为最高优先级,火锅都没吃踏实。凭借截图和日志,我们当晚就定位了原因并加班修复,那顿火锅后来被团队戏称为史上最贵的一餐,因为代价是两个通宵。

方案:三种锁的对比与取舍

团队讨论了三种方案,下表是我们当时的对比。轻量应用服务器加云数据库 MySQL 的架构里,引入 Redis 等于多一个需要维护的组件,直接被否决:

方案 原理 优点 缺点 取舍
乐观锁 带版本号更新 无等待,实现简单 高并发时重试多 最终选中
悲观行锁 先锁行再操作 强一致 事务拉长易超时 备选兜底
分布式锁 外部协调器统一 跨服务通用 多一个依赖 否决
消息队列 异步串行化削峰 削峰能力最强 架构复杂度翻倍 否决

消息队列那行是林姐的一位技术朋友建议的,被我们礼貌地挡回去了:日订单量两百单的系统上队列,就像给自行车装翅膀,能飞但没必要。

最终我们采用了乐观锁为主、唯一索引兜底的组合拳。核心思路:把检查和扣减合并成一条 SQL,让数据库自己保证原子性;同时利用唯一索引防止同一客人对同一时段重复下单。对于美甲这个体量,MySQL 一行 UPDATE 的锁粒度完全够用,没必要上更重的方案。选型的原则始终是:能不新增组件就不新增,每多一个组件就多一份半夜爬起来的可能。

下单接口的处理分三步,任何一步失败都立刻回滚:

步骤 操作 失败处理
0 前置 本人是否已有未完成预约 拦截囤位
1 校验 时段是否可约、是否打烊后 返回前端明确提示
2 占位 原子更新扣减容量 失败即提示已被抢光
3 落单 插入预约单并设锁位过期 异常时归还容量
4 支付 拉起微信支付,回调改状态 超时定时任务释放

三步之外还有第 0 步:校验客人本人。一个客人最多持有一个未完成预约,挡住囤位的羊毛党,也拦住那些想一次性占三个时段再挑的纠结型客人。五个步骤串起来就是完整下单流程,每一步都有明确的失败出口。

配置/代码:原子扣减是核心

占位的那条 SQL 是整个系统的命门,它用的是带条件的原子更新:

UPDATE t_slot
SET booked = booked + 1
WHERE slot_id = 42
  AND booked < capacity;

影响行数为 1 说明抢位成功,为 0 说明慢了一步。Go 代码里根据 RowsAffected 直接判定,没有任何中间态:

res, err := db.Exec(ctx, updateSQL, slotID)
if n, _ := res.RowsAffected(); n == 0 {
    return ErrSoldOut // 手慢了,返回已被抢光
}

这条 SQL 还有一个隐藏好处:它天然避免了幻读场景下的多次扣减,因为 booked 的自增发生在 WHERE 条件成立之后,数据库行锁帮我们排好了队。高峰时段这个语句的等待时间在压测里只有几毫秒,完全在可接受范围内。那条 UPDATE 后来被团队称为整个项目最值钱的一行 SQL,所有新人入职都会被拉着读一遍。

预约单写入时带唯一索引防重复,客人在小程序里狂点提交也只会成功一次:

ALTER TABLE t_booking
ADD UNIQUE KEY uk_slot_openid (slot_id, openid, status);

加索引之前先用线上数据跑一遍影响范围:三万条预约单里冲突的只有两对,都是同一个客人手滑重复提交。确认无副作用后才在维护窗口加上。

锁位过期机制在创建预约单时一并写入 lock_expire,如果 15 分钟内没有支付完成,定时任务就把这条单标记为超时释放,并归还容量:

// 每分钟扫描过期的锁位
rows := db.Query(`SELECT book_id, slot_id FROM t_booking
                   WHERE status=0 AND lock_expire<NOW()`)
for each row {
    db.Exec(`UPDATE t_slot SET booked=booked-1 WHERE slot_id=?`, slotID)
    db.Exec(`UPDATE t_booking SET status=4 WHERE book_id=?`, bookID)
}

释放的那两条 UPDATE 在同一个事务里执行,任何一条失败就整体回滚。释放历史的提取记录全部写入 CLS,事后复查多少单被释放、释放得对不对,一目了然,详见 监控告警。

这套机制上线后再没出现过超卖,国庆高峰也是稳的。唯一索引在退订场景下需要注意:客人取消后 status 变为 3,同一时段可以再次下单,所以索引里必须带上 status 字段才有意义。

我们把锁位超时时间定为 15 分钟,是踩着真实数据调出来的:最初设的 5 分钟,结果支付慢的客人总是被释放后重下;后来改成 30 分钟,热门时段被占着不付的人又太多。15 分钟是支付成功率与时段周转之间的平衡点,上线三个月没再调过。这个数字我们也写进了运营手册,换人维护时照着抄即可,不必再从头踩一遍。

踩坑:五个锁相关的坑

第一个坑是踩在最痛的乐观锁重试上。抢位失败后我们最初写了自动重试三次,结果高峰期疯狂重试反而放大了数据库压力,接口响应时间直接翻倍。后来直接改为不重试,失败就告诉客人手慢了,客人重选远比系统兜底有意义。

第二个坑是归还容量时漏判。支付回调里如果走了取消分支,只改了预约单状态忘了减 booked,那个时段就一直虚占一位,后面客人约不上前台也不知道为什么。解法是把扣减和归还统一封装成一个函数,所有状态迁移只走这一个入口。

第三个坑是前端重复提交。客人在弱网环境会狂点提交按钮,即使后端有唯一索引兜底,数据库也会被同一个请求打几百次。小程序端加按钮置灰两秒,请求加客户端幂等键,双保险之后这类调用量降了九成。最直观的感受是数据库的慢查询日志清净了,高峰期再也没有同一 openid 的连击。

第四个坑是支付回调可能来两次。微信支付在极端情况下会重发回调,我们的回调处理里加了 state 判定:只有状态是待支付时才推进,重复回调直接返回成功不做事,幂等从此与业务逻辑绑定。

第五个坑是库存归还与订单状态的一致性。极端情况下归还容量的 UPDATE 成功了、订单状态更新却失败了,时段就虚占了一位。后来把两步放进同一个事务,要么都成要么都不成。这个坑看似初级,但它是在某天晚上对账时发现时段占用数比实际预约多两位才暴露出来的。

小结

并发占位的核心就两条:原子扣减把检查和执行合并,唯一索引把重复请求挡在数据库外。小项目不需要分布式锁,一行带条件的 UPDATE 加一个唯一索引,足够撑住节假日洪峰,前提是你真的把每一步的状态出口都想清楚了。锁住的不仅是容量,更是客人对系统的信任。这套方案我们后来在另一个秒杀小项目里也复用过,思路完全一致,只是换了表名。放在更大的业务里,可能确实需要分布式锁和队列,但判断标准永远是当前量级,而不是技术先进性。我们给后来者留了一句话在文档里:先用最土的方法解决,等问题真的来了再升级,不要为了简历好看绑架业务。下一节聊客人到店之后,怎么用扫码把这个预约单核销掉。

延伸阅读