Case Study
本地生活电商
面向本地生活场景的电商平台:秒杀、套餐库存、下单支付与订单履约的全链路服务端设计。
原子扣减
Redis + Lua 库存预扣
一人一单
用户维度幂等去重
异步落库
BullMQ 削峰写入
背景
本地生活电商的典型压力不在日常流量,而在限时套餐秒杀:库存在几分钟内被抢空,瞬时并发是平峰的数十倍,而库存、订单与支付三者必须保持一致。系统还要同时服务 Web 与小程序端,并保证超时未支付订单能自动关闭、库存能正确回滚。
我的角色
作为全栈开发,我负责平台主流程的服务端设计与实现,并配合 uni-app 客户端完成下单链路联调:
- 基于 MidwayJS 3 的分层结构(Controller / Service / Queue / Processor)实现商品与套餐、库存、下单支付、订单管理接口。
- 使用 PostgreSQL + TypeORM 建模,统一通过
@midwayjs/validate做参数校验,@midwayjs/swagger生成接口文档。 - 设计秒杀链路的库存一致性与幂等方案,并补齐订单关闭与库存回补。
关键设计
1. 库存判断与扣减放进同一个 Lua 脚本
如果先 GET 库存再 DECR,两个请求之间就会出现超卖窗口。我们把「判断余量 → 扣减 → 记录用户已购」写进一段 Lua,由 Redis 单线程原子执行:
-- KEYS[1] = stock:{skuId}
-- KEYS[2] = bought:{activityId}
-- ARGV[1] = userId, ARGV[2] = quantity
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock < tonumber(ARGV[2]) then
return -1 -- 库存不足
end
if redis.call('HSETNX', KEYS[2], ARGV[1], '1') == 0 then
return -2 -- 重复下单
end
redis.call('DECRBY', KEYS[1], ARGV[2])
return stock - tonumber(ARGV[2])
返回码区分「售罄」与「重复下单」,前端可以给出不同提示,而不是统一报「活动太火爆」。
2. 用 BullMQ 把瞬时并发转成平稳写入
预扣减成功后不直接写库,而是投递一个带唯一 jobId 的任务后立即返回。后台 Worker 以固定并发消费,把脉冲式流量摊平成可控的写入速率:
jobId使用订单号,天然幂等,重复投递不会产生重复订单。- 延迟队列负责超时关单:到时未支付则关闭订单并通过 Lua 回补库存。
- 写库失败进入重试与死信队列,人工可观测、可补偿。
3. 一致性边界写清楚
| 环节 | 一致性要求 | 手段 |
|---|---|---|
| 预扣减 | 强一致、不可超卖 | Redis 单 Key + Lua 原子脚本 |
| 订单落库 | 最终一致 | BullMQ 幂等消费 + 唯一索引 |
| 超时关单 | 最终一致 | 延迟队列 + 库存回补脚本 |
| 支付回调 | 幂等 | 支付流水号唯一约束 + 状态机校验 |
订单状态机只允许单向流转(待支付 → 已支付 / 已关闭),任何非法跃迁在 Service 层直接拒绝,避免回调乱序把已支付订单改回关闭。
结果
- 秒杀主链路在预扣减阶段完成绝大多数拒绝,数据库只承担成功订单的写入。
- 库存与订单在压测和演练中未出现超卖;重复提交被
HSETNX与唯一索引双重拦截。 - 客户端只需处理三种明确结果:成功、售罄、已参与,交互文案不再含糊。
复盘
秒杀的本质不是「扛住流量」,而是把并发收敛到一个可串行的点上。Redis + Lua 承担了判定,队列承担了削峰,数据库只做最终事实的记录。三者职责分清之后,链路的每个环节都可以独立压测与回滚,这比任何单点优化都更重要。