简介面向高并发秒杀场景的完整 Go 工程选用 Redis 做缓存、Lua 脚本保证库存扣减原子性、Gin 提供 HTTP 接口适合有 Web 基础、想深入理解缓存与接口性能优化的后端开发者。压缩包共 58 个文件以 25 个 Go 源码为主体另含 4 个 CSV 测试数据、3 份 YAML/yml 环境配置、Dockerfile 部署文件、JMeter 压测脚本与 13 张架构示意图整体约 4.63MB模块按 model、api、middleware、conf 等目录划分方便对照学习。目前已有 41 人学习适合作为秒杀系统从零落地、复习高并发设计的参考项目。通过源码可以完整看到 Redis 与 MySQL 双存储、JWT 鉴权、消息队列、并发测试的组合链路配合初始化脚本和 README 能快速还原用户注册、商家发券、用户抢券、库存扣减等核心流程对自行扩展限流、防超卖和分布式部署也有直接借鉴价值。此外目录中的 png 示意图与 csv 测试用例也能帮助理解整体数据流和接口调用方式。1. 高并发秒杀系统为什么盯着 RedisLuaGin 这个组合秒杀系统真正的难点不在把 QPS 打高而在让一万个并发请求里只有一百个能扣到库存。用 Golang 写并发接口不难难的是「检查库存」和「扣减库存」这两步不能被并发撕开。这个资源包就是一套基于 Gin Redis Lua 的秒杀实现核心库存扣减逻辑全部收敛在一条 Lua 脚本里由 Redis 单线程保证原子性接口层只做参数校验和结果透传。适合正在做商城/Golang 后台开发、准备秒杀相关面试、或者想自己搭一个高并发项目练手的人。下面按项目结构、Redis 数据设计、Lua 脚本、Gin 接入、避坑排查、压测验收的顺序把整份资源拆开讲透。2. 项目结构与 Redis 数据设计先把三个 key 的角色定清楚秒杀链路涉及库存、用户已购标记、订单三份数据数据结构定不对后面的 Lua 脚本写得再漂亮也会翻车。这一章先看包里的工程骨架再聊 Redis 数据结构和预热。2.1 从 zip 包看工程骨架Gin 项目应该怎么组织打开下载到的 zip 包常见做法是按 cmd / internal / scripts 分层这样 main.go 很薄业务逻辑全部收在 internal 里Lua 脚本独立放在 scripts 目录方便用 redis-cli 单独调试。大致目录如下seckill/ ├── cmd/server/main.go ├── internal/handler/seckill.go ├── internal/service/order.go ├── internal/cache/redis.go ├── scripts/seckill.lua ├── config/config.yaml └── go.modmain.go 里初始化 Redis 连接、挂载 Gin 路由然后启动 HTTP 服务。拿到代码后先补齐依赖再跑起来go mod tidy go run ./cmd/serverconfig.yaml 里一般只需要配 Redis 地址、密码、DB 号和监听端口server: addr: :8080 redis: addr: 127.0.0.1:6379 password: db: 0这里要强调一点秒杀对 Redis 的依赖远比 MySQL 重本地开发建议直接用 Docker 起一个 Redis 6 实例避免 Windows 原生安装版本过老导致 Lua 相关命令行为不一致。2.2 Redis 数据结构先定库存字符串、已购集合、订单队列库存用 String 类型因为 DECRBY 本身就是原子操作天然适合扣减已购用户用 SetSISMEMBER 判断是 O(1)能挡住重复下单订单用 List塞给异步消费者落库秒杀接口不碰 MySQL。三个 key 各司其职Key 示例类型作用关键命令stock:1001String剩余库存GET / DECRBYbought:1001Set已购用户 ID 集合SISMEMBER / SADDorder:listList订单流水异步落库LPUSH / BRPOP活动开始前必须预热库存这一步漏了压测一开始 Lua 脚本就会整体报错。常见做法是直接写进 README 或启动脚本redis-cli set stock:1001 100 redis-cli del bought:1001注意预热后要顺手清掉已购集合否则上一场活动的用户数据会污染下一场。我一般会把预热的 set 和 del 写在一个 shell 脚本里活动上线前跑一遍而不是手动敲命令。3. Lua 扣减脚本一条 eval 把检查与扣减合二为一Redis 执行 Lua 脚本时整个脚本是在单线程里跑完的期间不会有其他命令插入。这意味着「查重 → 判存 → 扣减 → 记账」四步天然原子不需要分布式锁也不需要事务。这是本资源最核心的设计值得单独拆一章。3.1 一条 Lua 脚本管住「查重、判存、扣减、记账」scripts/seckill.lua 的核心逻辑大致如下local stock_key KEYS[1] local bought_key KEYS[2] local user_id ARGV[1] local quantity tonumber(ARGV[2]) -- 1. 判断用户是否已经买过 if redis.call(SISMEMBER, bought_key, user_id) 1 then return -1 end -- 2. 判断库存是否足够GET 可能返回 nil必须补默认值 local stock tonumber(redis.call(GET, stock_key) or 0) if stock quantity then return -2 end -- 3. 扣减库存 redis.call(DECRBY, stock_key, quantity) -- 4. 记录已购用户 redis.call(SADD, bought_key, user_id) return 1逻辑说明第一步用 SISMEMBER 挡重复下单第二步用 tonumber 把 Redis 返回的字符串转成数字再比较第三步 DECRBY 扣库存第四步 SADD 标记用户。四步在同一个 Lua 脚本里Redis 执行期间不会被并发请求打断所以不会出现两个请求同时读到库存 1、然后都扣成功的情况。参数说明KEYS[1] 是库存 keyKEYS[2] 是已购集合 keyARGV[1] 是用户 IDARGV[2] 是购买数量。这里有个关键约束——用户 ID 必须走 ARGV 而不是拼进脚本字符串。原因有两个一是脚本内容一旦带变量Redis 的 EVALSHA 缓存就失效每次都要重新传输脚本二是在 Redis Cluster 下只有 KEYS 参与哈希槽计算ARGV 只是透传参数。如果要把库存和已购集合放到同一槽位key 上要带同一个 hash tag比如 stock:{1001} 和 bought:{1001}。很多人单机调试没问题一上集群就报跨槽错误就是没注意这一点。返回值约定也要定死1 成功-1 重复购买-2 库存不足。这个约定在 Gin 层直接映射成 JSON 响应也方便压测时按返回码对账。千万别顺手返回 0 表示成功后面统计时会和 Redis 的 nil、false 混在一起排查起来非常痛苦。3.2 用 redis-cli 把脚本调通再进代码我一般会先在命令行把脚本调通再写 Go 代码省得在 Gin 层和 Lua 之间来回试错。用 redis-cli 的 --eval 参数直接跑redis-cli set stock:1001 100 redis-cli del bought:1001 redis-cli --eval scripts/seckill.lua stock:1001 bought:1001 , u_10001 1注意 --eval 的语法逗号前是 KEYS逗号后是 ARGV逗号两侧必须都有空格。第一次执行返回 1再执行一次返回 -1说明 SISMEMBER 的重复下单拦截已经生效。把库存改成 0 再换一个用户跑返回 -2。这里有一个血泪经验Lua 脚本里一旦有运行时错误Redis 返回的是错误堆栈而不是约定好的错误码Gin 层拿到后直接 500。常见做法是在脚本里包一层 pcall把异常转成自定义返回码local ok, res pcall(function() if redis.call(SISMEMBER, KEYS[2], ARGV[1]) 1 then return -1 end local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock tonumber(ARGV[2]) then return -2 end redis.call(DECRBY, KEYS[1], ARGV[2]) redis.call(SADD, KEYS[2], ARGV[1]) return 1 end) if not ok then return -99 end return res注意这种写法里 pcall 只能包函数调用我上面的示例是示意实际把整个逻辑包进 function 里再调用即可。这样脚本内部再出问题返回的是 -99而不是把堆栈抛给上游。排查时看到 -99 就知道脚本本身写错了而不是业务状态问题。4. Gin 接入与下单接口从参数校验到削峰落库Lua 脚本调通之后Gin 层就变得很薄。这一章写接口怎么接、脚本怎么调、订单怎么异步落库以及为什么这里不需要再套一层分布式锁。4.1 handler 只做三件事绑参、取用户、跑脚本秒杀接口的 handler 不需要碰库存逻辑只需要做参数绑定、从上下文取用户 ID、执行 Lua 脚本、把返回码映射成响应。用 go-redis 的 redis.NewScript 预加载脚本package handler import ( github.com/gin-gonic/gin github.com/redis/go-redis/v9 ) var seckillScript redis.NewScript( local stock_key KEYS[1] local bought_key KEYS[2] local user_id ARGV[1] local quantity tonumber(ARGV[2]) if redis.call(SISMEMBER, bought_key, user_id) 1 then return -1 end local stock tonumber(redis.call(GET, stock_key) or 0) if stock quantity then return -2 end redis.call(DECRBY, stock_key, quantity) redis.call(SADD, bought_key, user_id) return 1 ) func SeckillHandler(c *gin.Context) { userID : c.GetString(userID) var req struct { GoodsID string json:goods_id binding:required Quantity int json:quantity binding:min1 } if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{code: 400, msg: 参数错误}) return } rdb : c.MustGet(redis).(*redis.Client) res, err : seckillScript.Run(c, rdb, []string{stock: req.GoodsID, bought: req.GoodsID}, userID, req.Quantity, ).Int() if err ! nil { c.JSON(500, gin.H{code: 500, msg: 内部错误}) return } switch res { case 1: c.JSON(200, gin.H{code: 0, msg: 下单成功}) case -1: c.JSON(200, gin.H{code: -1, msg: 重复下单}) case -2: c.JSON(200, gin.H{code: -2, msg: 已售罄}) } }逻辑说明userID 是从登录中间件写入的真实项目里一般是 JWT 解析后塞进 contextShouldBindJSON 负责参数校验seckillScript.Run 内部会先用 EVALSHA 尝试执行缓存脚本脚本不存在时自动回退 EVAL所以脚本内容必须固定这也是为什么用户 ID 必须走 ARGV 而不是拼接进脚本字符串。返回码 -1 和 -2 都可以算作「业务正常拒绝」HTTP 状态码用 200方便前端统一处理。这里有个常见误区有同学觉得既然并发高接口层必须套一层 Redis 分布式锁。实际上 Lua 脚本已经是原子操作再套 SetNX 锁反而引入新问题——锁过期后第二个请求拿到锁但第一个请求的 Lua 还没执行完比如网络抖动两个请求同时进扣减路径反而破坏了原子性。Lua 脚本本身就不需要外部锁保护。4.2 削峰与异步落库订单交给 List 消费接口不碰 MySQL秒杀接口最怕同步写数据库一万个请求打到 MySQL 直接打满。常见做法是 Lua 脚本扣完库存后顺手把订单信息 LPUSH 进一个 ListMySQL 落库交给后台消费者慢慢写local order cjson.encode({ user_id ARGV[1], goods_id string.sub(KEYS[1], 7), quantity tonumber(ARGV[2]) }) redis.call(LPUSH, KEYS[3], order)对应的 Go 消费循环for { item, err : rdb.BRPop(ctx, time.Second, order:list).Result() if err ! nil { continue } // item 是 [key, value] 数组value 是 cjson 编码的订单 var order Order json.Unmarshal([]byte(item[1]), order) // 写入 MySQL失败可以记录后重试 db.Create(order) }逻辑说明BRPop 是阻塞式弹出List 里没数据时休眠等待而不是空转轮询消费者可以只起两三个 goroutine把 MySQL 的写入压力控在可接受范围。cjson 是 Redis 内置的 JSON 编码库不用额外引入。注意 KEYS[3] 也要参与脚本调用Kes 列表变成三个库存 key、已购 key、订单 List key。接口本身还需要防重试风暴。前端拿到 -2售罄后不要自动重试否则大量请求会持续打到 Redis虽然 Lua 挡住了扣减但带宽和连接数还是会被打满。我一般会在响应头里带 Retry-After让前端至少退避三秒。这条在压测时尤其重要因为用 wrk 做压力测试时没有退避逻辑Redis 会收到远超业务真实量的请求。5. 避坑排查秒杀系统里五个翻车现场秒杀系统的坑大多不在代码逻辑而在脚本边界和压测方法。这一章全是从实际项目里踩出来的按「现象 → 原因 → 解决」写清楚。5.1 压测一开始就报错GET 返回 nil 导致 Lua 直接抛异常现象压测刚开始接口大量返回 500日志里能看到 panic而不是预期的「已售罄」。原因库存 key 没有预热或者 key 名拼错了。Redis 里 GET 一个不存在的 key 返回 nilLua 里 tonumber(nil) 直接报错整个脚本中断。解决库存预热脚本里先 set代码里再用tonumber(redis.call(GET, key) or 0)兜底。or 0 这个写法要形成肌肉记忆因为 Redis 的 nil 在 Lua 里是 falsetonumber(false) 必炸。从那以后我写脚本凡是 GET 之后接 tonumber 的第一反应就是or 0。5.2 压测发现超卖订单数比库存多现象压测结束后统计订单表发现成交数大于预热库存比如库存 100订单 120。原因扣减逻辑没有收进 Lua而是用「先 GET 判断、再 DECRBY」两步完成。两个请求同时 GET 到库存 1都认为还有货先后 DECRBY结果就是超卖。还有人用 MULTI/EXEC 事务包住这两步但 Redis 事务期间读到的值不会在 EXEC 时重新校验同样会超卖。解决把「判断库存不足 → DECRBY」整段收进 Lua 脚本Redis 单线程执行期间没有其他命令能插进来。如果担心 Redis 版本差异至少要在 CI 里跑一遍并发测试专门验证超卖这条。5.3 同一个用户下了两单重复下单拦截失效现象用户 ID 相同压测日志里显示成功两次订单表出现两条相同用户的记录。原因SISMEMBER 判断和 SADD 写入被拆到了两个请求里。Go 代码里先执行 SISMEMBER 发现没买过然后另一个请求也执行 SISMEMBER 发现没买过两个请求都往下走都 SADD 成功。解决SISMEMBER 和 SADD 必须在同一个 Lua 脚本里执行。顺序上先 SISMEMBER 再扣库存再 SADD确保「判断」和「标记」中间没有并发窗口。这也是为什么这个资源把查重逻辑放在脚本最前面而不是拆成两个 Redis 命令。5.4 Redis 内存暴涨EVALSHA 缓存被参数刷爆现象活动跑了十分钟Redis 内存和 used_memory 持续上涨INFO 里 eval 调用次数异常但业务 QPS 并没有明显增长。原因有人图省事把用户 ID 直接拼进 Lua 脚本字符串再去 eval比如return SISMEMBER bought:1001 .. userID。每来一个新用户就生成一段新脚本Redis 的脚本缓存全部失效永远走 EVAL 全量传输内存被脚本堆积吃光。解决脚本内容必须是静态的所有变量走 KEYS 和 ARGV。Redis 7 之前脚本缓存无法主动清理只能等内存淘汰或重启实例非常被动。排查脚本缓存问题时用redis-cli script exists sha1看缓存是否命中用redis-cli info commandstats看 eval 和 evalsha 的调用占比能快速判断是脚本拼接问题还是客户端库版本问题。5.5 压测结果全是 -1单用户压测看不出并发效果现象wrk 压测十分钟接口返回全是「重复下单」成功的请求只有一条性能报告完全失真。原因压测脚本没有生成动态用户 ID所有请求都带同一个用户标识。Redis 里 SISMEMBER 很快返回 1后续逻辑全被挡掉根本测不到库存扣减和 DECRBY 的真实开销。解决压测脚本必须模拟多用户。用 wrk 的 post.lua 生成随机用户 IDwrk.method POST wrk.headers[Content-Type] application/json function request() local uid string.format(u_%d, math.random(100000, 999999)) wrk.headers[Authorization] Bearer .. uid return wrk.format(nil, /seckill, nil, {\goods_id\:\1001\,\quantity\:1}) end这样每个请求都是独立用户SISMEMBER 基本都不会命中才能真正压到库存扣减这条主链路。注意 lua 脚本语言里的 math.random 在不同系统中表现有差异压测前先跑一次确认用户 ID 不重复。6. 压测与验收三种返回码加起来必须等于库存秒杀系统上线前验证方法不是看接口响应快不快而是看成功数、重复数、售罄数三个数字和库存能不能对上账。先预热再压测redis-cli set stock:1001 100 redis-cli del bought:1001 wrk -t8 -c1000 -d30s --latency -s post.lua http://127.0.0.1:8080/seckill压测结束后统计订单表里 goods_id 为 1001 的记录数它必须等于 100也就是预热库存。然后把接口返回的 code 按0 / -1 / -2分组统计code 为 0 的数量加上 -1 和 -2 的数量必须等于总请求数。如果三者对不上要么是 Lua 脚本里有分支没走统一返回要么是 handler 丢了业务错误码。这个对账脚本我一般用 go test 写进项目里每次压测后自动跑func TestSeckillConcurrent(t *testing.T) { rdb.FlushDB(ctx) rdb.Set(ctx, stock:1001, 100, 0) var wg sync.WaitGroup var success int32 var repeat int32 var soldOut int32 for i : 0; i 5000; i { wg.Add(1) go func(i int) { defer wg.Done() userID : fmt.Sprintf(u_%d, i) res, _ : seckillScript.Run(ctx, rdb, []string{stock:1001, bought:1001}, userID, 1, ).Int() switch res { case 1: atomic.AddInt32(success, 1) case -1: atomic.AddInt32(repeat, 1) case -2: atomic.AddInt32(soldOut, 1) } }(i) } wg.Wait() if success ! 100 { t.Fatalf(success %d, want 100, success) } }这段测试模拟 5000 个并发用户抢 100 个库存校验成功数精确等于库存。再往深走一步的验证手段是单步调试 Lua 脚本。redis-cli 的 --ldb 参数可以进 Lua 调试器配合 --eval 在活动上线前逐行走一遍脚本逻辑确认分支覆盖完整。我习惯用这个方式检查脚本里有没有忘写的 return比如库存不足分支里直接跳到了结尾返回了 nilGin 层会把它当成 redis.Nil 错误处理成 500——这类问题只有逐行跑才能看出来。从那以后我每次上一场秒杀都强制走一遍「预热库存 → CLUA 调脚本 → wrk 压测 → go test 对账」这个流程省下的全是上线后的血泪。希望帮到你。本文还有配套的精品资源点击获取