不能直接用 channel 做抢券队列,因其无持久化、不支持多实例共享、易丢消息且无法保证幂等;应采用 Redis+Lua 原子扣减初筛,Kafka 做二级队列,并在 DB 层用 version 乐观锁防重复。

为什么不能直接用 channel 做抢券队列
channel 在单机 goroutine 协作时很轻量,但微服务场景下它立刻失效:进程重启就丢数据、多实例间无法共享、没持久化和重试机制。你看到的 panic: send on closed channel 或者抢券成功却查不到记录,八成是 channel 被意外关闭或消费者没及时收走消息。
真实压测中,当 QPS 超过 500,纯内存 channel 就开始丢消息——不是 Go 实现有问题,而是它根本不是为跨进程、高可靠队列设计的。
- 别把
chan *CouponEvent当作生产级队列,它连基本的幂等消费都做不到 - 若用
buffered channel加大缓冲,只是把问题从“丢消息”推迟到“OOM” - 多实例部署时,每个服务自己维护一套 channel,等于把库存拆成 N 份,超卖风险翻倍
Redis + Lua 原子扣减是唯一靠谱的初筛层
所有请求必须先过 Redis 原子校验,否则后面再强的队列也拦不住超卖。关键不是“用不用 Redis”,而是怎么用——必须用 Lua 脚本把「查库存」「扣库存」「写入待处理队列」三步锁死在一个原子操作里。
常见错误是先 GET 再 DECR,中间插入了其他请求,导致库存判断失准。正确脚本类似:
if redis.call("EXISTS", KEYS[1]) == 0 then
return 0
end
local stock = tonumber(redis.call("GET", KEYS[1]))
if stock
-
KEYS[1]是库存 key(如coupon:1001:stock),KEYS[2]是待处理队列 key(如queue:coupon:1001) - 返回
1表示抢券成功并已入队;-1表示库存不足;0表示优惠券未启用 - 别用
INCRBY反向扣减,负数库存难排查,且无法区分“扣完”和“从未初始化”
用 Kafka 替代 Redis List 做二级队列
Redis List 虽能存消息,但缺乏 offset 管理、无副本容错、消费失败后难追溯。Kafka 不是“更高级的选择”,而是当并发超 2000 QPS、要求消息不丢、需按业务分组重试时的刚性需求。
关键配置要调实:
- 生产者设
acks=all和retries=MAX_INT,避免网络抖动丢消息 - topic 分区数至少等于消费者实例数,否则存在消费瓶颈
- 消费者用
Enable.auto.commit=false,手动在 DB 写入成功后再CommitOffsets,不然会丢“已发券但未落库”的单 - 别把所有券塞进同一个 topic,按
coupon_id % 16分 topic 或 partition,避免热点分区
DB 写入层必须带 version 字段做乐观锁
即使前面层层拦截,DB 层仍可能因网络重试、消费者重复拉取导致同一条消息被处理两次。靠主键冲突或唯一索引只能拦住“完全重复”,拦不住“用户 A 抢两张同一张券”这种逻辑重复。
每张优惠券表必须有 version 字段,更新时强制校验:
UPDATE coupon_user SET status = 1, version = version + 1 WHERE coupon_id = ? AND user_id = ? AND version = ?
- 如果
RowsAffected == 0,说明已被其他请求抢先写入,当前消息可丢弃或转入死信队列 - 别用
TIMESTAMP或UUID代替version,它们无法保证更新顺序和并发安全 - MySQL 的
INSERT ... ON DUPLICATE KEY UPDATE看似简洁,但无法区分“首次插入”和“重复更新”,对核销、发放日志等场景不可靠
真正难的不是堆技术组件,而是每个环节都得默认按“消息可能重复、网络可能超时、DB 可能回滚”来设计。漏掉任意一环,流量越大,资损越快。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











