尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis+Lua+Gin高并发秒杀系统实战:解决库存超卖的关键方案

Redis+Lua+Gin高并发秒杀系统实战:解决库存超卖的关键方案 简介这份资源是一套基于Golang生态的完整高并发秒杀系统实现面向需要掌握Redis缓存、Lua脚本原子操作与Gin框架的中高级Go开发者解决抢购场景下库存扣减一致性和接口高吞吐问题。压缩包共58个文件大小约4.63MB以25个Go源文件为核心另有Dockerfile、docker-compose.yml、JMeter压测脚本、CSV并发测试数据和项目说明文档等便于本地部署与性能验证。目前已有41人学习下载。代码结构在SecKill-System-master下按api、data、engine、model等模块组织包含Gin路由与JWT鉴权、Lua脚本实现库存预减、Redis与MySQL数据同步、JMeter压力测试用例等核心内容同时提供config-dev/prod等环境配置和Docker编排可快速拉起依赖并模拟多用户抢券流程。该实现充分利用Redis内存读写速度降低数据库压力借助Lua脚本保证扣减库存的原子性再以Gin简化HTTP接口开发三者结合为同类型高并发场景提供了可复用的工程化范式对想借鉴秒杀设计方案或完成课设项目的读者具有直接参考价值。1. 秒杀系统为什么绕不开 RedisLuaGin先看库存扣减这一锤子买卖做高并发秒杀系统最怕的不是流量大而是库存扣减在并发下算错账。10 万用户抢 100 件商品如果扣库存靠数据库UPDATE加行锁数据库连接一被打满整个服务就雪崩如果靠应用层SELECT再UPDATE超卖几乎是必然。我见过不少团队用 Go Gin 写接口最后在 Redis 上用 Lua 脚本把「查库存、校验、扣减、记录」合成一个原子操作才真正把超卖这件事堵死。这套方案里Gin 负责接流量Redis 负责扛并发Lua 脚本负责保证库存操作不被打断。本文就把这套「RedisLuaGin 的秒杀系统」从数据模型到压测验证完整讲一遍适合正在做高并发接口、准备 Golang 后端面试或想接手秒杀类需求的开发者。2. 从订单模型到库存流水秒杀系统的数据设计与选型理由2.1 秒杀场景的数据表设计库存、订单、用户三张表的约束秒杀系统的数据模型核心是三张表sku商品库存、seckill_order秒杀订单、user用户。我一般会再加一张stock_log库存流水用来对账。设计时最重要的约束是「一个用户对一个活动只能下单一单」这个约束如果只靠应用层判断并发下照样会被绕过所以必须落到数据库唯一索引上。CREATE TABLE sku ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL COMMENT 商品ID, stock int NOT NULL COMMENT 剩余库存, version int NOT NULL COMMENT 乐观锁版本号, start_time datetime NOT NULL, end_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_product_time (product_id, start_time) ) ENGINEInnoDB COMMENT 秒杀商品库存;秒杀订单表要把用户 ID 和活动 ID 做联合唯一索引这是防重复下单的兜底防线。注意秒杀订单不要和普通订单混在一张表里秒杀订单量大、生命周期短分表后方便按活动归档。CREATE TABLE seckill_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单号, user_id bigint NOT NULL, sku_id bigint NOT NULL, activity_id bigint NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_activity (user_id, activity_id), KEY idx_sku (sku_id) ) ENGINEInnoDB COMMENT 秒杀订单表;为什么把唯一索引直接建在订单表上因为秒杀场景的查重操作频率极高如果每次下单前都先SELECT COUNT再决定是否插入两个并发请求可能同时查不到记录然后同时插入——数据库唯一索引能在最后一步把后到的那条直接拒绝。这是比 Redis 分布式锁更靠得住的防线Redis 锁只能保证应用层互斥唯一索引保证数据层绝对不重复。2.2 为什么库存扣减不能走后端事务Redis 原子操作与 Lua 的登场很多新手第一反应是用 MySQL 事务扣库存BEGIN; SELECT stock FROM sku WHERE id1 FOR UPDATE; UPDATE sku SET stockstock-1; COMMIT;。这写法在低并发下没问题但秒杀场景每秒几万请求进来FOR UPDATE行锁会让所有请求排队等同一行锁数据库连接池瞬间耗尽后面全是connection timeout。所以高并发秒杀系统的库存扣减几乎不会直接打 MySQL而是把库存预加载到 Redis用 Redis 的原子操作来扣。Redis 单线程执行命令DECR、DECRBY本身就是原子的不会出现两个请求同时读到同一个库存值的情况。但秒杀的规则比单纯的减一复杂得多要先判断库存是否大于 0要校验用户是否已买过要把扣减结果记录下来。如果这些步骤分开执行比如先GET库存再DECR中间任何一步被其他请求插入都会超卖。Redis 的 Lua 脚本正好把多个操作打包成一段原子脚本Redis 保证脚本执行期间不会被其他命令插队相当于把「检查库存、扣除库存、标记用户」这几步锁在一个事务里。这套方案里Gin 只在请求入口做参数校验和身份识别业务核心全部下沉到 Lua 脚本。Gin 本身是高并发 Web 框架里非常轻量的一层它不解决库存问题但它的连接管理和中间件机制能帮我们挡住大量无效请求。选 Gin 而不是 go-zero 或 beego主要看团队熟悉程度go-zero 自带服务治理和缓存治理组件适合完整微服务beego 带全套 MVC 和 ORM适合业务耦合紧密的工程。秒杀系统往往只是整个交易系统的一个子模块用 Gin 这种轻量框架更容易单独部署、按需扩容也更容易在面试时把每一行代码讲清楚。3. 用 Lua 脚本把「查库存、扣库存、限购」合成一步脚本写法与参数拆解3.1 秒杀 Lua 脚本的完整实现KEYS 与 ARGV 的约定写秒杀 Lua 脚本第一步是约定参数。Redis 执行EVAL时KEYS数组传 Redis 键名ARGV数组传业务参数。我一般把库存键、已买用户集合键、限购数量键放在KEYS里把用户 ID、商品 ID、当前时间戳、每人限购数量放在ARGV里。这样才能让 Redis Cluster 模式下用哈希标签把相关键放在同一个分片。-- keys[1] 库存键: seckill:stock:{skuId} -- keys[2] 已购买用户集合键: seckill:users:{activityId} -- argv[1] 用户ID -- argv[2] 每人限购数量 -- argv[3] 当前时间戳 -- argv[4] 活动ID local stock tonumber(redis.call(GET, KEYS[1])) if not stock then return -1 -- 库存不存在 end if stock 0 then return 0 -- 已售罄 end local user_key KEYS[2] .. : .. ARGV[4] local bought tonumber(redis.call(HGET, user_key, ARGV[1]) or 0) if bought tonumber(ARGV[2]) then return -2 -- 超出限购 end -- 扣减库存并记录用户购买数 redis.call(DECRBY, KEYS[1], 1) redis.call(HINCRBY, user_key, ARGV[1], 1) -- 记录过期时间, 防止集合无限膨胀 redis.call(EXPIRE, user_key, 86400*30) return 1 -- 秒杀成功这段脚本的要点在于HGET判断用户已购数量时HINCRBY和DECRBY在同一个脚本里执行Redis 在执行整个脚本期间不会让其他命令插进来所以不会出现「A 请求扣了库存B 请求又扣了一次」的情况。return -1 / 0 / -2 / 1四类返回值分别对应库存不存在、售罄、超限购、成功业务方拿到返回值后决定要不要写订单。参数上要注意DECRBY的步长必须和后续订单写入数量一致。如果一次秒杀允许用户购买多件DECRBY的步长要改成ARGV[2]对应的购买数量同时限购判断要用bought buyCount limit判断。我一般把每人限购数量和本次购买数量拆成两个参数避免把限购值直接当步长。3.2 调用 Lua 脚本的 Go 代码用 go-redis 的 EvalSha 避免重复传脚本Go 侧调用 Lua 脚本常见做法是启动时用redis.NewScript(script)注册脚本go-redis会自动把脚本转成EVALSHA减少网络传输脚本体的开销。脚本内容可以用一个独立的.lua文件维护用go:embed直接编译进二进制部署时不需要额外带文件。//go:embed lua/seckill.lua var seckillScript string func Seckill(ctx context.Context, rdb *redis.Client, skuID, userID string, activityID string, limit int) (int, error) { script : redis.NewScript(seckillScript) stockKey : fmt.Sprintf(seckill:stock:%s, skuID) userKey : fmt.Sprintf(seckill:users:%s, activityID) // 注意: KEYS[1] 和 KEYS[2] 必须用哈希标签 {activityID} 保证集群分片一致 stockKey fmt.Sprintf(seckill:stock:{%s}, skuID) userKey fmt.Sprintf(seckill:users:{%s}, activityID) result, err : script.Run(ctx, rdb, []string{stockKey, userKey}, userID, limit, time.Now().Unix(), activityID).Int() if err ! nil { return -3, err } return result, nil }这段代码里最容易踩坑的是script.Run的返回值类型。Lua 脚本返回的整数在 Redis 里是整数类型但 go-redis 的Run返回interface{}直接调.Int()只有在返回确实是整数时才正确。如果脚本里不小心返回了字符串.Int()会解析失败必须先ToString()再strconv.Atoi。参数校验要在调用 Redis 之前做。用户 ID、商品 ID 必须非空活动时间要在有效期内这些校验放在 Gin 中间件里避免把恶意请求打进 Lua 脚本。Lua 脚本本身不做正则校验它只关心库存和限购业务合法性由上层负责。4. Gin 接口层与防重放、限流把无效请求挡在 Redis 之外4.1 Gin 秒杀接口的最小实现从中间件到 handler 的分层Gin 处理秒杀请求我习惯分成三层路由层做 IP 限流和身份解析服务层做业务参数校验数据层调 Lua 脚本。路由层的核心是防止非登录用户直接打秒杀接口用 JWT 中间件解析出用户 ID把用户 ID 注入gin.Context。func SeckillHandler(svc *SeckillService) gin.HandlerFunc { return func(c *gin.Context) { // 从 JWT 中间件中取用户 userID, exists : c.Get(userID) if !exists { c.JSON(401, gin.H{code: 401, msg: 未登录}) c.Abort() return } var req SeckillReq if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{code: 400, msg: 参数错误}) return } // 校验活动时间 if req.SkuID || req.ActivityID { c.JSON(400, gin.H{code: 400, msg: 缺少商品或活动ID}) return } // 调用服务 result, err : svc.Seckill(c.Request.Context(), req.SkuID, userID.(string), req.ActivityID, req.BuyCount) if err ! nil { c.JSON(500, gin.H{code: 500, msg: 系统繁忙}) return } switch result { case 1: c.JSON(200, gin.H{code: 0, msg: 秒杀成功, data: gin.H{orderNo: generateOrderNo(userID.(string), req.SkuID)}}) case 0: c.JSON(200, gin.H{code: 1, msg: 已售罄}) case -2: c.JSON(200, gin.H{code: 2, msg: 超出限购数量}) default: c.JSON(200, gin.H{code: 3, msg: 秒杀失败}) } } }注意c.ShouldBindJSON这里有个细节秒杀请求的高频参数其实只有SkuID、ActivityID、BuyCount不要用一个大结构体去接收无关字段。ShouldBindJSON在反复触发时会有 JSON 反序列化开销而每秒几十万请求下这个开销会被放大。更极端的做法是请求只传路径参数GET /seckill/{activityId}/{skuId}配合 GET 请求让 CDN 层可以缓存部分静态参数但秒杀接口本身不适合 GET会被恶意预取。我做过一次压测POST JSON 和 GET 路径参数在纯 Go 下性能差异不大真正的瓶颈是 Redis 连接数所以这里不必过度优化 JSON 解析。4.2 用 Redis 分布式锁和令牌桶给接口降压不是所有请求都要进 Lua秒杀接口最怕的不是并发高而是同一用户的重复请求和脚本刷量。用户手抖点十下十次请求全进 Lua 脚本每个请求都会执行一次HGET和DECRBY用户的限购虽然能拦住后九次但库存已经被无效请求多打了几次。常见做法是在 Gin 中间件里加一层简单的 Redis 分布式锁对同一用户和同一活动做互斥。func UserRateLimit(rdb *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { userID : c.GetString(userID) activityID : c.Param(activityId) if userID || activityID { c.Next() return } key : fmt.Sprintf(seckill:req:%s:%s, userID, activityID) // 5秒内只放行1次 ok, err : rdb.SetNX(ctx, key, 1, 5*time.Second).Result() if err ! nil { c.JSON(500, gin.H{code: 500, msg: 系统繁忙}) c.Abort() return } if !ok { c.JSON(200, gin.H{code: 4, msg: 操作太频繁}) c.Abort() return } c.Next() } }这里的SetNX就是 Redis 分布式锁最常见的实现但要注意锁的粒度。粒度过粗比如全接口一个锁会串行化所有请求秒杀就变成了排队粒度过细每次请求都SetNX且 key 不同又起不到防重放作用。我建议按userID activityID做粒度5 秒过期时间。这个时间要大于单次秒杀完成时间否则用户第二次点击时上一个请求还在执行锁就已经过期了。更精细的方案是用滑动窗口计数但秒杀场景下 5 秒 1 次足够挡掉手工连点真正的脚本刷量要靠网关层的 IP 限流应用层不用做太复杂的令牌桶算法。Gin 本身没有内置限流组件常见做法是用golang.org/x/time/rate的令牌桶或者用 Redis 的INCREXPIRE做一个计数器限流。注意秒杀系统要限制的是「每个用户的请求速率」和「总入口速率」二者要分开。总入口速率可以用一个 Redis key 计数每 100ms 一个窗口超过阈值直接返回 503。这个策略的值要压测后调整不要把总速率设置成 Redis 单实例的极限要给 Lua 脚本执行留出余量。5. 避坑手册秒杀系统最常见的 5 个翻车现场与排查路径5.1 坑一Redis 连接池被打满command timed out刷屏现象压测到每秒 1 万请求时日志里大量redis: command timed outRedis 监控面板上连接数飙升CPU 反而很低。原因go-redis 默认连接池大小是10 * runtime.NumCPU()每个请求从池子里借连接执行 Lua 脚本脚本执行完才还回去。当并发超过连接池上限时所有请求都在等连接等待超时默认 3 秒于是大量超时。Redis 是单线程连接数再多也不能提升吞吐反而增加线程切换开销。解决把连接池上限调低让请求排队而不是创建新连接用 pipeline 或批量接口合并操作。更关键的是在 Gin 里对秒杀接口做并发度限制用信号量控制同时进入 Lua 脚本的请求数剩下的快速失败。压测时手表测出业务能接受的最大吞吐然后把这个数打到信号量上。不要盲目调大连接池连接池 500 和 2000 对 Redis 吞吐影响不大但 2000 个连接堆积会拖垮 Redis 的事件循环。5.2 坑二Lua 脚本返回值为redis.NilGo 侧却收到 0现象秒杀明明成功但 Go 侧拿到的返回值是 0业务判定为售罄用户看到「已售罄」实际却扣了库存。原因redis.Call在脚本中执行GET一个不存在的 key 时返回falsetonumber(false)在某些解析下得到 0 而不是报错。更隐蔽的是script.Run的返回结果在 go-redis 中是string类型直接.Int()时如果是负数部分版本会解析失败。解决在 Lua 脚本里对GET结果做显式判断local stock redis.call(GET, KEYS[1]); if not stock then return -1; end。Go 侧不要依赖.Int()的隐式转换先.String()再用strconv.Atoi并对err ! nil做明确分支。另外每次发版修改 Lua 脚本后要重启服务重新SCRIPT LOAD否则EVALSHA可能报NOSCRIPT错误。go-redis 的NewScript会在运行时自动检测脚本是否存在但如果是跨多实例部署每个实例第一次调用时都要加载。5.3 坑三Redis 缓存穿透导致的活动库存被清空现象秒杀刚开始Redis 里的库存 key 就变成了 0但数据库里还有库存用户纷纷显示已售罄。原因这种一般是数据库中的库存初始值和 Redis 预加载时的值不一致。比如活动配置在管理后台改了库存但预热脚本没有重新同步或者预热时用SETNX如果 key 已存在则不会更新旧值。解决预热脚本不要用SETNX直接用SET覆盖并在活动开始前做一个校验比对 Redis 里的库存和数据库sku.stock是否一致。预热代码要放在发布流程里而不是靠运维手工执行。更完整的方案是给库存 key 设置活动结束时间的 TTL活动结束后让 Redis 自动删除避免下个活动还在用旧 key。每次活动重新生成一个activityId就能天然隔离不会串数据。5.4 坑四唯一索引兜底后数据库成了新的瓶颈现象Redis 扣库存成功但订单写入 MySQL 时因为唯一索引冲突大量报错数据库负载飙升。原因所有秒杀成功的请求几乎同时去写订单表即使每个用户只能下单一单同一时刻的插入量也很大MySQL 的插入性能和唯一索引检查成为瓶颈。解决不要真的把所有订单都同步插入数据库。常见做法是把秒杀成功的订单先写入 Redis 的list或消息队列如 Kafka、RabbitMQ由消费线程异步批量插入数据库。Redis 里seckill_ok队列用LPUSH下单接口直接返回成功真正的订单创建在后台慢慢写。这样数据库的写入压力被削峰。注意异步写库时要保证消息不丢消费端要做幂等唯一索引就是幂等保障。5.5 坑五订单超时未支付库存不释放现象用户抢到库存但一直不支付系统没有任何机制回收库存导致商品实际售罄但有一堆僵尸订单占着库存。原因秒杀订单和普通订单不同通常要求 15 分钟或 30 分钟内支付超时要自动取消并回补库存。如果只做了扣减没做回补库存只会减少不会增加。解决用延迟消息或者定时扫描订单表把超过支付时限的订单状态改成已取消然后回补 Redis 库存。回补操作也要注意并发安全不能SET回一个固定值而要用INCR或者DECRBY的逆操作。更稳妥的是让 Lua 脚本支持「取消秒杀」的路径在回补时重新走一遍脚本逻辑防止回补被并发请求覆盖。这个逻辑我一般叫做「库存补偿」它和扣减一样必须原子化不能拆成两行 Redis 命令。6. 进阶技巧用压测数据说话并把秒杀接口做成可观测的秒杀系统上线前一定要做三件事压测、日志、降级开关。压测工具我常用wrk或go-wrk压测目标不是看 Redis 能扛多少而是看整个请求链路在什么并发下开始失败。以我经验一个标准的 Gin Redis 秒杀接口在 8 核机器上Redis 响应时间 0.2ms 时单实例能扛到 8000 到 12000 QPS再往上就需要加 Redis 分片或升级实例规格。压测时要记录三个指标Redis 每秒执行命令数、Gin 的 P99 延迟、MySQL 的插入速率。这三个指标互相制约任何一个先到瓶颈都会拖垮整体。常见压测命令wrk -t8 -c256 -d30s -s post.lua http://127.0.0.1:8080/seckillpost.lua里要随机生成用户 ID 和商品 ID避免所有请求都打在同一个 key 上否则压的是 Redis 单 key 热点和真实场景不符。真实场景中用户 ID 是正态分布的热点商品确实只有一个但商品 key 的访问频率本身就会成为热点这是压测时要单独测试的。我还习惯在秒杀接口中埋一个埋点每次 Lua 脚本执行后用go-gin的c.Set(seckill_result, result)然后在中间件里把耗时、结果、Redis 地址记录到日志。秒杀系统的排查速度决定了故障恢复时间没有日志的秒杀系统出了问题是没法定位的。我曾在生产上靠日志里的 Redis 平均耗时找出了连接池配置问题那次经历让我意识到可观测性和业务逻辑一样重要。最后说一个压箱底的习惯秒杀系统的 Redis key 里必须带activityId或日期版本。不要用一个固定的seckill:stockkey每次活动用seckill:stock:{activityId}:{skuId}。这样不仅方便清理也方便做活动数据对比。我在多次活动中发现总有人在活动结束后忘记删预热 key导致下一期活动刚开始Redis 里就残留着上一期的库存值预热脚本又没覆盖用户直接买到已过期活动的库存。这个坑比上面所有坑都难查。希望这个实战拆解能帮你在做秒杀系统时少走弯路欢迎按这套路径去复现和压测数据不会骗人。本文还有配套的精品资源点击获取
返回列表