不能直接用gorm写秒杀逻辑,因其默认事务和select...for update会引发mysql行锁争用,导致高并发下锁等待加剧、响应延迟飙升,且结构体绑定与验证开销大;必须绕过数据库直连,用redis lua脚本原子执行“读-判-减”,并分层解耦业务逻辑。

秒杀接口在 Gin 中不是加个 @Transactional 或塞个 sync.Mutex 就能扛住的——它必须绕过数据库直连、压平并发路径、把校验和扣减拆到不同层级,否则一开抢就 500 或超卖。
为什么不能直接用 GORM 写秒杀逻辑
GORM 默认开启事务,每次 SELECT ... FOR UPDATE 都会持有行锁;高并发下大量请求卡在 MySQL 的锁等待队列里,innodb_row_lock_time_avg 暴涨,响应延迟从几毫秒拉到几秒甚至超时。更糟的是,GORM 的结构体绑定 + 验证 + 事务开启本身就有可观开销,QPS 上不去。
- 避免在 HTTP handler 里做
db.Where("stock > ?", 0).First(&item)这类查询 —— 它不带锁,但查完再UPDATE就是经典 AB-BA 超卖 - 别依赖 GORM 的
UpdateColumn做原子扣减:它不保证先检查后更新的语义,需手写UPDATE items SET stock = stock - 1 WHERE id = ? AND stock >= 1 - 如果非要用 GORM,至少关掉日志(
db.Debug().Disabled)并复用*gorm.DB实例,避免每次新建 session
Redis 预减库存必须用 EVAL 原子脚本
单纯用 GET + DECR 是错的:两个请求几乎同时 GET 到剩余 1,都执行 DECR,结果变成 -1 —— 超卖已发生。必须用 Lua 脚本把“读-判-减”三步锁死在 Redis 单线程内。
正确脚本示例:
if redis.call("GET", KEYS[1]) == false then
return -1
end
local stock = tonumber(redis.call("GET", KEYS[1]))
if stock
- 调用时传入商品 ID 作为
KEYS[1],返回值为-1(未初始化)、0(已售罄)、正数(扣减后剩余量) - 不要用
INCRBY反向操作,避免浮点误差或溢出;库存初始值必须用SET显式设好,别依赖GET返回 nil 就当 0 - 脚本执行失败(如 OOM)时 Redis 会直接报错,Gin handler 需捕获
redis.Error并降级走本地限流
接口分层:Gin handler 只做轻量路由与参数解析
一个秒杀请求在 Gin 层该做的事只有三件:解析用户 ID(从 JWT header)、校验签名(防刷)、转发到预减库存服务。所有业务逻辑(比如查用户是否已参与、生成订单、发 MQ)必须剥离出去,否则 handler 一卡,整个 HTTP worker pool 就堵死。
- 用
c.MustGet("user_id").(int64)取解析后的用户 ID,别在 handler 里重复解析 JWT - 禁用 Gin 的默认
Recovery中间件,改用自定义 panic 捕获:记录错误但立刻c.AbortWithStatusJSON(429, map[string]string{"msg": "too many requests"}),防止 panic 波及其他请求 - 路由注册用
r.POST("/seckill/:item_id", seckillHandler),别用正则或通配符,Gin 的 Radix 树对静态路径匹配最快
容易被忽略的冷启动与缓存穿透问题
活动开始前 1 秒,Redis 里根本没这个 item:1001:stock key —— 大量请求穿透到 MySQL 查库存,瞬间打挂 DB。这不是理论风险,是每场大促必踩的坑。
- 活动创建时,必须提前用
SET item:1001:stock 1000 EX 3600初始化库存 key,且设置合理过期时间(比如活动结束+1小时) - 加一层布隆过滤器(Bloom Filter)拦截非法
item_id请求,避免恶意请求直接打穿 Redis - 本地缓存(如
bigcache)只存“已售罄”状态,不存实时库存 —— 否则多实例间无法同步,反而增加不一致风险











