直接用channel会崩,因无缓冲时消费者稍慢即阻塞生产者,有缓冲则高并发易撑爆内存;可靠方案需“限流+落地+异步消费”三层设计,核心依赖Redis实现可重入、可撤销的抢购队列。

抢购请求进来时,为什么直接用 channel 会崩
因为 channel 默认是无缓冲的,一旦消费者处理稍慢,生产者就会阻塞;而有缓冲的 channel 在高并发下容易撑爆内存——比如 10 万并发请求全塞进 make(chan int, 1000),剩下的 99000 个 goroutine 会立刻 panic 或超时失败。
真正能扛住瞬时流量的队列,得靠「限流 + 落地 + 异步消费」三层设计:
- 接入层用
semaphore(信号量)或rate.Limiter控制入口并发数,拒绝超出容量的请求 - 核心队列用带 TTL 的 Redis List(如
LPUSH + BRPOP)或本地带驱逐策略的 ring buffer,避免内存无限增长 - 消费端用固定数量 worker goroutine 拉取任务,处理失败时写回重试队列或丢弃,不阻塞主流程
用 Redis 实现可重入、可撤销的抢购队列
单纯用 LPUSH/BRPOP 无法支持“用户取消下单”或“库存回滚”,必须把订单 ID 和状态绑定存储。推荐结构:HSET queue:order:{id} status pending user_id 123 sku_id 456 ts 1717023456,再用 ZADD queue:pending_ts 1717023456 {id} 做时间排序。
关键点在于原子性操作:
立即学习“go语言免费学习笔记(深入)”;
- 抢购成功后,用
EVAL脚本同时校验库存、扣减、写入订单哈希、更新有序集合——避免 Lua 脚本外的竞态 - 用户取消时,用
HGET queue:order:{id} status判断是否仍为pending,再HSET改为canceled并从ZSET中ZREM - 消费 worker 定期
ZRANGEBYSCORE queue:pending_ts -inf (now-300)扫描超时订单做清理
本地内存队列只适合低延迟、强一致性场景
如果你的抢购逻辑必须严格顺序执行(比如限量 100 件,且要按请求时间精确排序),又不能容忍 Redis 网络延迟,那可以用带 CAS 的 ring buffer + atomic 计数器,但必须接受以下代价:
- 单机容量受限,
ring buffer大小设为 8192 就已占 ~1MB 内存,扩容只能靠水平分片(如按sku_id % N分桶) - 节点宕机即丢失未消费数据,需配合 WAL 日志或双写 Redis 保证可靠性
-
sync.Pool缓存 task struct 能减少 GC,但要注意:pool.Get() 返回的对象不保证清零,必须显式重置字段
示例初始化:var taskPool = sync.Pool{New: func() interface{} { return &OrderTask{} }},使用前务必 t.skuID = 0; t.userID = 0 等手动归零。
为什么不要在 HTTP handler 里直接调用 DB 扣库存
一个 http.HandlerFunc 里直接跑 UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0,看似简单,实际在 5000+ QPS 下会迅速打满数据库连接池,触发 MySQL 的 Lock wait timeout exceeded 错误。
正确路径是解耦:
- HTTP handler 只做参数校验、限流、写入队列(Redis 或 Kafka),返回
202 Accepted+ 请求 ID - 独立服务从队列消费,批量聚合相同
sku_id的请求(比如 100 个扣同一商品),再用一条UPDATE ... WHERE stock >= #{sum}扣减 - 扣减成功后发 MQ 通知下游(支付、发货),失败则发告警并记录
failed_order_log表供人工对账
这个模式下,DB 压力和请求峰值彻底脱钩,真正决定吞吐的是队列吞吐能力,而不是数据库的 UPDATE 性能。
最常被忽略的是超时控制:worker 从 Redis BRPOP 必须设 timeout(比如 5 秒),否则网络抖动时所有 goroutine 都卡死在阻塞调用上,整个消费系统就僵住了。


















