抢购接口不能直接用Gin POST路由硬扛,因为其同步阻塞特性导致并发请求同时读取相同库存值后扣减,引发超卖;必须通过Redis Lua脚本原子执行“查库存→判断→扣减”,再异步落库保证最终一致性。

抢购接口为什么不能直接用 Gin 的 POST 路由硬扛
因为 Gin 默认路由是同步阻塞的,每个请求进来的 goroutine 都会执行完整业务逻辑(查库存、扣减、写订单),一旦库存校验和 DB 更新没加锁或没做隔离,就会超卖。哪怕 QPS 上千,只要并发请求同时读到“还有 10 件”,就可能一起扣成 -5。
真实场景里,你看到的 500 或 “库存不足” 误报,往往不是因为没货,而是多个请求在 DB 层踩了彼此的脚。
- 别依赖 MySQL 的
UPDATE ... WHERE stock > 0做兜底——它只防写入冲突,不防读-改-写竞争 -
gin.Context里的c.BindJSON和c.JSON没问题,但中间所有 DB 操作必须有原子性保障 - Redis 的
INCRBY+GET组合不是原子的,得用EVAL或Lua脚本封装扣减逻辑
用 Redis Lua 脚本实现原子扣库存
这是最轻量、最可控的方案:把“读库存 → 判断是否足够 → 扣减”三步压进一个 EVAL 原子操作里,避免网络往返和中间状态被篡改。
示例脚本(保存为 decr_stock.lua):
if redis.call("GET", KEYS[1]) >= tonumber(ARGV[1]) then
return redis.call("DECRBY", KEYS[1], ARGV[1])
else
return -1
end
Go 中调用:
script := redis.NewScript(1, luaScriptContent)result, err := script.Run(ctx, rdb, []string{"stock:1001"}, "1").Int64()if err != nil || result == -1 { /* 库存不足 */ }
- KEYS[1] 是商品 ID 对应的 Redis key,比如
stock:1001 - ARGV[1] 是本次要扣的数量(通常为 1)
- 返回 -1 表示扣减失败,不是错误,不要 panic
- 脚本里用
redis.call("GET", ...)而不是redis.call("GETRANGE", ...)—— 后者不支持数值比较
为什么要在 Redis 扣完再写 MySQL 订单
因为 Redis 快、原子、可水平扩展;MySQL 慢、有事务开销、容易成为瓶颈。抢购本质是“先筛后落库”,Redis 是筛子,MySQL 是归档。
- Redis 扣成功 → 立即返回“抢购成功”,用户感知快
- 异步协程(或消息队列)去写 MySQL 订单表,失败要重试+告警,但不影响前端响应
- 别在 Gin handler 里同步写 MySQL —— 一个慢查询卡住整个 goroutine,QPS 直接掉一半
- 注意 Redis 和 MySQL 数据最终一致性:加个定时任务比对 Redis 库存和 DB 销量,差值过大就告警人工介入
限流和排队怎么加才不拖慢正常请求
硬限流(比如 golang.org/x/time/rate)会直接拒绝请求,体验差;全量排队(比如 channel 缓冲)又容易积压导致超时。更实用的是“令牌桶 + 短队列 + 快速失败”组合。
- 用
rate.NewLimiter(100, 200)控制每秒最多 100 个请求进入处理流程,burst 设为 200 防突发 - 真正扣库存前,先尝试获取一个
semaphore(比如基于golang.org/x/sync/semaphore),数量设为 500 —— 超过就快速返回“系统繁忙” - 不要用全局 mutex 或 sync.Pool 做库存锁,它只适合单机,横向扩容后失效
- 前端配合做按钮置灰 + 倒计时,减少无效请求打进来
高并发抢购不是堆机器就能解决的事,关键路径上每一行代码都要问:它会不会成为瓶颈?有没有竞态?失败了用户能不能感知清楚?
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











