跳到主要内容

flashsale — 秒杀

定位:活动管理(时间窗口 / 独立库存 / 秒杀价 / 限购)、上架预热 Redis、限流、类型化缓存原子预扣、持久预扣生命周期、MQ 异步落单、可重试回补与逐预扣事实对账。

实现:internal/flashsale/

数据模型

flashsale_activities

字段类型说明
idBIGINT UNSIGNED PK活动 ID
sku_idBIGINT UNSIGNED FK绑定 SKU(RESTRICT)
titleVARCHAR(128)活动标题
priceBIGINT UNSIGNED秒杀价(分)
stockINT UNSIGNED活动独立库存(与 skus.stock 互不干扰;落单扣活动库存)
per_user_limitINT UNSIGNED每人限购(默认 1)
statusVARCHAR(16)off_sale(下架)/ on_sale(上架);进行中由时间窗口动态判定status=on_sale && start_at <= now <= end_at),status 仅用于手动下架/紧急停止
start_at / end_atDATETIME(3)时间窗口

派生状态(读取时计算,不落库):用户视角 not_started / in_progress;admin 视角另含 off_sale(手动下架)/ ended(窗口已结束)。

flashsale_pre_deductions

每次抢购按 (user_id, activity_id, client_request_id) 幂等创建稳定 id,该 ID 同时是购买槽位。表内固化 SKU、成交价、数量、槽位和订单号;状态流为 preparing → pending_publish → pending_order → ordered → pending_rollback → rolled_back。该表同时承担本地 saga 与 MQ outbox 事实源。

Redis 约定与原子能力

key 约定(活动进行中均为 Redis 预扣事实源):

key说明
flashsale:stock:{id}活动库存(上架预热;TTL = 结束时间 + 1h 自清理)
flashsale:count:{id}:{user}用户抢购计数(原子预扣 INCR)
flashsale:idem:{id}:{user}:{purchase_slot}槽位所有权键(TTL 30min;不同合法购买互不覆盖)
flashsale:reservation:{pre_deduction_id}与库存/计数同一 Lua 写入的持久预扣标记;回退先写 rolled_back tombstone,MySQL 终态提交后再清理,重试可区分“已回补”与“标记异常缺失”
flashsale:pause:{id}进行中库存编辑的短暂预扣栅栏;先封口、再锁活动行重算差额,避免编辑与消费者/新预扣竞态
flashsale:rl:{user}按用户限流计数(固定窗口 INCR+TTL)

业务 service 只调用类型化缓存能力;Lua 文本与整数返回码协议封装在 internal/platform/cache Redis 适配器内:

能力语义
WarmFlashSaleStock预热:key 不存在写入;已存在仅当配置库存更低才覆盖(进行中只减不增
PauseFlashSaleStockDurably / DecreaseFlashSaleStockDurably进行中库存编辑先暂停新预扣并读取可售量,锁行校验后事务提交新库存并临时下架,再按锁内差额扣 Redis;不得低于已接受预扣,AOF 未确认则保持下架
PreDeductFlashSaleDurably原子预扣后在同一专用连接执行 WAITAOF;同一 marker 重放会重写当前状态快照后再次确认 AOF
EnsureFlashSaleReservationDurably发布/重投前验证 marker;若整次 Lua 结果丢失,则按 MySQL 持久事实原子重建库存、用户计数、幂等键与 marker,并等待 AOF fsync
EnsureOrderedFlashSaleReservationDurablyordered 事实按 MySQL 活动库存校准 Redis,并重建用户计数/幂等键/marker;AOF 确认后才允许清理 marker
AcquireIdempotency幂等键抢占(内部 SETNX + EX)
RestoreFlashSale仅 reservation token 匹配时回补并写 tombstone;重复执行不多回补,标记缺失不假报成功,且 compare-delete 不会删除新请求幂等键

接口

HTTP(handler/flashsale_handler.go)

admin(Bearer + admin):

方法路径说明
POST / PUT/api/admin/flashsales(/:id)创建 / 编辑活动;进行中仅标题和库存减少可改,SKU/价格/时间/限购锁定;Redis 同步失败自动下架
GET/api/admin/flashsales活动列表(全状态 + 派生状态 + SKU/商品摘要)
POST/api/admin/flashsales/:id/publish上架(先预热库存、后写状态——预热失败保持下架)
POST/api/admin/flashsales/:id/unpublish下架(写状态后清除预热库存)

用户(Bearer):

方法路径说明
GET/api/flashsales秒杀页列表(仅进行中/即将开始;携带 server_time 供前端对齐倒计时;剩余库存读 Redis 预扣余量,缺失降级配置库存)
POST/api/flashsales/:id/purchase请求体 {client_request_id};成功返回 202 排队中 + pre_deduction_id + 可选 order_no;同请求 ID 重试返回原生命周期,新请求 ID 在限购内分配新槽位
GET/api/flashsales/purchases/:id查询本人预扣生命周期(owner 校验;他人记录返回 404)

跨模块端口

应用编排(flashsale → order 单向依赖):消费者通过 flashsale TxRunner 开启事务,调用 order.CreateSeckillInTx 后由活动仓储条件扣减库存;用户主动取消与超时取消都通过 order.SeckillCancellation / CancelSeckill 和活动仓储完成事务内取消、MySQL 回补及持久回退事实,提交后恢复 Redis;对账调用 order.CountValidSeckill。order handler 只依赖调用方声明的取消端口,由组合根注入 flashsale 实现,因此 order 不反向导入 flashsale。

关键流程

抢购全流程(DESIGN.md 秒杀时序)

POST /api/flashsales/:id/purchase
[1] 全局令牌桶限流(中间件,429)→ 按用户 Redis 固定窗口计数(flashsale:rl:{user})
[2] MySQL 按 client_request_id 创建 preparing 事实,固化 SKU/价格/数量,pre_deduction_id 作为购买槽位
[3] PreDeductFlashSale 类型化原子预扣:
· 成功 → 库存/计数/reservation marker 同时提交,状态转 pending_publish
· 业务拒绝(抢光/限购/窗口外/下架)→ 释放幂等键(允许窗口内重试)
· 基础设施失败 → 保留幂等键(防瞬时故障下重复预扣)
[4] 持久化雪花订单号 → 发布 MQ;确认成功转 pending_order
订单号生成/发布失败由启动恢复与每分钟任务继续;重投前验证或重建 Redis reservation;启动恢复任一失败时抢购门禁保持关闭(503),全部恢复后开放;仅 pending_publish 10 次仍失败转回退,pending_order 不因补发失败误回退
[5] 返回 202,前端按 pre_deduction_id 轮询 ordered / rolled_back

异步落单消费者(flashsale.order.create)

消息 {pre_deduction_id, order_no, user_id, activity_id, sku_id, price, quantity, purchase_slot}
→ 与持久预扣快照逐字段校验(活动编辑不能改写已接受成交语义)
→ 查默认地址(user.GetDefaultAddress,固化为地址快照;无地址 → 永久失败进死信)
→ flashsale 编排开启事务:order.CreateSeckillInTx 建秒杀订单(10min 超时)+ 订单项
→ 活动仓储条件扣活动库存
· 重复键(order_no 主键 / user_activity_key=user:activity:slot)→ 同槽幂等成功(不重复扣减库存)
· 活动库存不足 → 永久失败(死信)
· DB 瞬时故障 → 重投(Nack requeue,at-least-once)
· 永久失败 → 持久化 pending_rollback 后进 DLQ;死信消费者确认回退意图

对账(cron)

任务频率语义
flashsale-recovery启动时 + 每分钟扫描 preparing/pending_publish/pending_order/pending_rollback,继续发布、重投或幂等回退;同时修复 ordered marker。两部分全部成功才开放抢购门禁

pre-R04 durable 主队列或 DLQ 消息没有 pre_deduction_id 时,会先按 order_no 创建/复用 legacy 生命周期,再进入同一落单和回退状态机。 | flashsale-reconcile-active | 每小时 | 输出具体 pre_deduction_id/user_id/order_no/status,并保留聚合库存差异作为最终不变量告警 | | flashsale-reconcile-ended | 每分钟 | 无未决预扣事实时才以 MySQL 对齐 Redis,避免随后逐笔回退造成多回补 |

权威源:docs/DESIGN.md 秒杀时序 / 秒杀活动 / 定时任务、迁移 000008_flashsale_activities / 000018_flashsale_purchase_slots。对账细节亦见运营域