全部项目

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 承担了判定,队列承担了削峰,数据库只做最终事实的记录。三者职责分清之后,链路的每个环节都可以独立压测与回滚,这比任何单点优化都更重要。