ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Golang的秒杀系统:Redis+Lua如何实现库存原子扣减?

基于Golang的秒杀系统:Redis+Lua如何实现库存原子扣减? 简介秒杀抢券是典型的高并发业务场景这份面向Go后端开发者的完整代码示例以Gin作为Web框架、Redis承担缓存与计数、Lua脚本保障库存扣减原子性兼顾性能与开发效率。压缩包共58个文件、约4.63MB包含25个Go源码、4个CSV测试数据、多份YAML/XML配置、Dockerfile、JMeter压测脚本、说明文档和13张图片覆盖开发、配置、测试与部署环节CSV数据可导入数据库模拟业务JMeter脚本用于并发压测。当前已有41人学习/浏览。项目按SecKill-System组织区分engine、middleware、model、service等模块提供注册、抢券等并发测试用例可直接试运行Redis与MySQL服务封装、JWT鉴权中间件、Docker编排和多环境配置样例有助于快速启动和压测并理解Web层、缓存层与持久层的协作方式。对想理解秒杀业务、学习RedisLua与Gin集成方式的开发者有直接参考价值。1. 基于 Golang 的秒杀系统为什么我拆完这份源码后把 Redis 事务忘得一干二净凌晨 0 点的秒杀一台 4C8G 的云主机一个 10000 库存的商品压测跑了 20 分钟库存直接变成负数——这是我亲眼在线上见过的事故。也是我第一次认真拆这份「Golang Redis Lua Gin 高并发秒杀系统」源码包的原因。它要解决的核心就一件事在请求峰值的几秒钟里把库存扣减做得又快又不出错。整份源码按「限流 → 判重 → 扣库存 → 异步下单」四条链路组织覆盖了秒杀从请求进来到订单落库的完整闭环。适合两类人一类是接了个秒杀需求但没想清楚库存该放 MySQL 还是 Redis 的后端工程师另一类是刷 redis 面试题刷到「超卖、缓存穿透」但没见过完整实现的人。看完这套代码你会意识到一个反直觉的结论Redis 自带的 MULTI/EXEC 事务在秒杀场景里远不如一段几十行的 Lua 脚本好用。2. 整体架构与选型三层防护、库存缓存和这套实现的代码结构我先直接说结论秒杀系统的技术选型九成的决定都发生在「库存放哪」这个问题上。库存放 MySQL逻辑最简单但扛不住峰值库存放 Redis逻辑稍微绕一点但能抗压。这套实现选的是后者并且用 Lua 把并发竞态压到了最小。下面先看整体链路再逐个方案对比最后说源码包应该怎么读。2.1 秒杀请求的三层防护限流、判重、扣减秒杀请求的流量特征是一瞬间几万个请求但真正能进入业务逻辑的只该有一小部分剩下全是无效流量。第一层防护就是接口限流。常见做法是令牌桶限流Gin 中间件里用 Redis 的 INCR EXPIRE 做一个固定窗口计数每个用户每秒最多 1 个请求每个商品每秒最多 200 个请求超出直接返回「排队中」。固定窗口没有令牌桶平滑但实现成本低而且限流在这套架构里只是第一道闸不需要特别精准。第二层是用户判重。一人一单是秒杀的硬规则判重必须和库存扣减放在同一个原子操作里完成否则两个请求带着同一个 user_id 同时穿过检查数据库里就会留下重复订单。这套实现里为每场秒杀维护了一个 SETkey 形如bought:{goodsId}成员就是已购买的 user_id用 SISMEMBER 检查、SADD 写入。第三层才是库存扣减。库存数量用 Redis 的 String 类型存单个 key 的值就是剩余量由 Lua 脚本统一完成「读库存 → 判断是否大于 0 → DECR」。扣减成功之后不立刻写订单而是把订单任务丢进一个 channel 缓冲队列由后台 worker 去写 MySQL。这里先记住一个结论Redis 只负责「承诺」库存MySQL 负责「兑现」订单。三层缺一不可。我见过只做库存扣减不做限流的压测一开 MySQL 直接 CPU 拉满也见过只做限流不判重的同一个用户把库存刷了几十单。这三个环节的代码都不复杂但漏掉任何一层线上都会以很狼狈的方式翻车。2.2 数据库行锁、Redis 事务、分布式锁、Lua四个方案怎么选把常见的库存扣减方案放在一张表里对比选型理由一眼就能看清方案会不会超卖峰值吞吐实现复杂度主要坑点MySQL 行锁不会低低行锁串行连接一打满就瘫痪Redis MULTI/EXEC 事务会中中事务内不能根据上一步结果做分支分布式锁 二次校验不会中高锁超时、误删锁、锁竞争拖垮性能Redis Lua 脚本不会高中脚本需要预热和错误处理MySQL 行锁方案很多人首选因为UPDATE goods SET stock stock - 1 WHERE stock 0单条语句确实原子不会超卖。但秒杀这种全部请求命中同一行的场景行锁要排队连接池被瞬并发打满只是时间问题。我压过单行更新1000 并发下平均延迟能直接飙升到秒级。Redis MULTI/EXEC 事务方案是最典型的误区。MULTI 只是把一串命令按顺序打包执行之间不会被其它命令打断但它不支持「先读、再判断、再写」这种条件逻辑。事务开始前要 WATCH冲突就失败不冲突就原样执行根本没有 if 分支。库存检查这种三步逻辑用 MULTI 写出来就是白纸一张——先 GET 再 DECR两个请求同时 GET 到剩余 1照样扣成负数。分布式锁方案是另一个常见选择SETNX 拿到锁之后读库存判断再扣减。逻辑上对但锁超时怎么办、锁没释放会不会拖垮系统、每次请求抢锁本身又成了一次热点访问。锁竞争到一定程度性能和 MySQL 行锁也差不了太多。Lua 脚本方案是四个方案里我最推荐的把「读库存 → 判断大于 0 → DECR → 标记用户」整个包进一个脚本Redis 服务端单线程执行整个脚本执行期间不可能有别的命令插进来。没有锁、没有事务包装、没有行锁排队一个脚本从进到出就是原子操作。Redis 里常被问的两大数据类型在这里也恰好对上了库存用 String已购用户集合用 SET。选 Redis 做库存载体不只是性能问题更是数据结构的天然契合。2.3 这套源码的代码结构先读哪几个文件这类秒杀系统源码拆开之后核心文件并不算多难点是阅读顺序。按主流程顺一遍建立脑子里这条链连接初始化 → 路由注册 → 接口处理 → 脚本执行 → 异步落库。文件常见命名作用建议阅读顺序main.go初始化 Redis 连接、创建 Lua 脚本对象、启动 Gin1router/router.go路由注册挂载限流、鉴权等中间件2handler/seckill.goHTTP 层参数解析与结果包装3service/stock.go核心业务执行 Lua 脚本处理返回码4lua/seckill.lua库存扣减与判重的原子脚本5main.go 主要看三件事Redis client 是不是全局单例、连接池参数有没有设置、Lua 脚本是启动时加载还是每次请求现编。router 看中间件挂载顺序——限流必须在业务 handler 之前。handler 和 service 是分层是否干净的试金石如果 handler 里直接拼 Redis key说明这套实现的分层没想清楚。最值得静下心读的是 lua/seckill.lua整份源码最关键的一段逻辑全在里面。不要跳着看要把每个边界条件都过一遍比如库存 key 不存在时返回什么、用户判重失败时返回什么。第 3 章我把这段脚本完整拆开讲。3. 核心扣减逻辑用 RedisLua 写出不会超卖的原子操作这一章是整个源码的命门。库存扣减不是简单的 DECR它要同时满足三个条件不够卖时不扣、超卖这种事不发生、同一用户只能扣一次。三个条件放在同一个脚本里才能从根上消除竞态。3.1 为什么分布式锁解决不了秒杀Lua 却能先看一段最常见的分布式锁写法很多项目的秒杀代码就是这么写的// 不推荐抢锁 → 读库存 → 判断 → 扣减 lockKey : fmt.Sprintf(lock:goods:%s, goodsId) ok, _ : rdb.SetNX(ctx, lockKey, uid, 5*time.Second).Result() if !ok { return -3 // 排队失败 } defer rdb.Del(ctx, lockKey) stock, _ : rdb.Get(ctx, stockKey).Int() if stock 0 { return -1 } rdb.Decr(ctx, stockKey)这写法看着对线上会在三处翻车第一锁超时 5 秒业务没处理完锁自动没了后面新请求带着新锁进来两笔扣减同时发生第二延迟执行Del时锁可能已经过期被别人重新持有你把别人的锁删了第三抢锁失败的请求直接返回吞吐量被锁竞争拖垮压测数据很难看。Lua 方案把这三类问题全部删掉只剩一个脚本调用。Redis 官方保证脚本执行期间其它客户端的任何命令都不会插入整个脚本天然是一个原子操作。这也是为什么在 Redis 高并发场景里Lua 是处理「读-判-写」这类复合逻辑的标准解法——比 WATCH/MULTI 更灵活比分布式锁更轻。3.2 可抄走的 Lua 脚本校验、扣减、去重三合一这份实现的核心脚本逻辑非常紧凑值得逐行读-- 秒杀核心脚本 lua/seckill.lua -- 返回值1 成功-1 库存不足-2 重复下单 -- KEYS[1] 库存 keyKEYS[2] 已购用户集合 key local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return -1 end local already redis.call(SISMEMBER, KEYS[2], ARGV[1]) if already 1 then return -2 end redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[1]) redis.call(EXPIRE, KEYS[2], tonumber(ARGV[2])) return 1逻辑说明第一步读取库存并转成数字库存不存在或小于等于 0 直接返回 -1这一步堵住了超卖第二步用 SISMEMBER 检查当前用户是否已经在集合里在就直接返回 -2这一步实现了用户判重第三步才执行 DECR 扣减同时 SADD 把用户加入已购集合并给集合设置过期时间防内存堆积。参数说明KEYS[1]是库存 key形如stock:1001用商品 ID 区分每场活动KEYS[2]是已购集合 key形如bought:1001ARGV[1]是用户 ID来源是鉴权中间件解析出的 uid不是前端传什么就信什么ARGV[2]是集合过期时间秒杀场景建议 86400 秒活动结束后自动回收内存。这里有一个容易被忽略的边界行为如果库存 key 不存在tonumber(false)得到 nil脚本会直接返回 -1也就是「已抢光」。这意味着活动开始前必须把初始库存 SET 进去否则活动一开始就是抢光状态。我一般会在 main.go 里加一个 initStocks 方法用 pipeline 批量写入避免循环逐个 SET。3.3 在 Gin 里集成go-redis 的 NewScript 与返回码处理Go 侧代码用 go-redis 的成熟封装脚本对象只需要创建一次package main import ( context github.com/gin-gonic/gin github.com/go-redis/redis/v8 ) var rdb *redis.Client // 秒杀脚本整个进程只创建一次 var seckillScript redis.NewScript( local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return -1 end local already redis.call(SISMEMBER, KEYS[2], ARGV[1]) if already 1 then return -2 end redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[1]) redis.call(EXPIRE, KEYS[2], tonumber(ARGV[2])) return 1 ) // killStock 执行扣减返回 1 成功 / -1 无库存 / -2 重复参与 func killStock(ctx context.Context, goodsId, uid string) (int, error) { keys : []string{stock: goodsId, bought: goodsId} vals : []interface{}{uid, 86400} res, err : seckillScript.Run(ctx, rdb, keys, vals...).Int() if err ! nil { return 0, err } return res, nil }逻辑说明redis.NewScript内部会自动帮你 SCRIPT LOAD 把脚本缓存到 Redis拿到 SHA1 之后用 EVALSHA 执行如果 Redis 重启导致脚本缓存丢失客户端收到 NOSCRIPT 错误会自动回退成 EVAL。这一步直接解决了「脚本没预热就报 NOSCRIPT」的经典故障比手写 SHA1 管理省心得多。参数说明keys 和 vals 分开传是 Redis Lua 通信规范KEYS 之外的参数一律走 ARGV。有些人习惯把 key 也塞进 ARGV单机模式下能跑通但一旦切 Redis Clusterkey 的 hash slot 计算就会出问题所以这个规范从第一天就该守好。注意Redis 执行 Lua 脚本期间其它请求都在排队等待脚本里不要写大循环或批量查大量 key执行时间越短越好。秒杀这种场景脚本逻辑控制在 10 行以内是底线。错误处理是秒杀最容易含糊的部分。killStock返回 err 时handler 要统一回 503「系统繁忙」绝对不要返回成功。因为网络超时场景下你无法确定脚本到底执行了没有好在这套脚本对同一用户天然幂等——即使客户端重试第二次进来会被 SISMEMBER 挡住返回 -2不会造成超卖只是用户体验上可能看到「已参与」。这个设计是有意为之的。4. Gin 接入层与异步下单接口校验、队列落库与压测参数Lua 脚本解决的是原子扣减Gin 层解决的是「请求怎么进来、结果怎么返回、订单怎么落库」。这一章把 HTTP 层到存储层的完整链路串起来。4.1 路由注册、鉴权中间件与秒杀 handlerGin 侧的路由和 handler 写法比较常规但中间件顺序值得讲一下func main() { r : gin.Default() // 秒杀路由组限流在前鉴权在后 seckillGroup : r.Group(/api/v1) seckillGroup.Use(RateLimit(200)) seckillGroup.Use(AuthRequired()) { seckillGroup.POST(/seckill/:goodsId, SeckillHandler) } r.Run(:8080) } func SeckillHandler(c *gin.Context) { goodsId : c.Param(goodsId) uid : c.GetString(uid) result, err : killStock(c.Request.Context(), goodsId, uid) if err ! nil { // 网络超时等不确定状态不返回成功 c.JSON(503, gin.H{code: 503, msg: 系统繁忙请重试}) return } switch result { case 1: // 扣减成功订单异步落库 SubmitOrder(OrderTask{GoodsId: goodsId, UserId: uid}) 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: 您已经参与过}) } }逻辑说明中间件顺序是有讲究的限流放在鉴权之前这样未登录的海量请求直接在最外层被拦掉根本到不了业务逻辑。uid由 AuthRequired 中间件从 token 解析后写进 Gin 的 Contexthandler 里只通过c.GetString(uid)拿不要再查一次用户表。参数说明:goodsId是路由参数handler 里用c.Param(goodsId)获取。商品 ID 必须做白名单校验因为第 5 章会提到非法的商品 ID 会让缓存穿透打到数据库。返回码设计成三档0 表示成功-1 表示抢光-2 表示重复参与前端拿到 -1 和 -2 不需要再发请求直接展示对应文案即可。4.2 异步下单channel 缓冲队列把 MySQL 从热路径里摘出去扣减成功只代表库存被预订了订单还没产生。如果每个秒杀请求都同步写 MySQL数据库瞬间就会成为瓶颈。这套实现的做法是把订单任务丢进 channel由后台 worker 消费type OrderTask struct { GoodsId string UserId string Seq string // 订单号由雪花算法生成 } var orderQueue make(chan OrderTask, 1024) func init() { for i : 0; i 8; i { go orderWorker(orderQueue) } } func orderWorker(q -chan OrderTask) { for task : range q { err : createOrder(task) if err ! nil { if strings.Contains(err.Error(), Duplicate entry) { // 同一请求被重复投递订单已存在不需要回补 continue } // 数据库临时故障订单没写进去把预扣的库存还回去 rdb.Incr(context.Background(), stock:task.GoodsId) continue } } }逻辑说明channel 缓冲区大小是 1024超过这个值 producer 会阻塞这是一层天然背压比无界队列安全。worker 数量 8 个每个 worker 串行写库MySQL 的写入压力被限制在可控范围内。createOrder失败时的处理是关键如果是唯一索引冲突说明订单已经存在直接跳过如果是数据库故障要把预扣的库存用 INCR 还回去保证用户失败后库存不丢失。参数说明channel 方案不持久化服务重启会丢未落库的订单——小规模秒杀可以接受生产环境我会把 channel 换成 RabbitMQ 或 Kafka但「扣减和落库解耦」这个思路是通用的。本条还有一个容易被忽视的点订单表必须加UNIQUE KEY uk_user_goods (user_id, goods_id)。这个唯一索引是数据库层面的最后兜底Redis 判重被绕过时它还能挡住重复订单。4.3 连接池与超时参数压测前必须调对的几个数字秒杀手滑第一件事是调参数不是改代码。这套实现里 Redis 客户端的初始化参数对压测结果影响极大rdb redis.NewClient(redis.Options{ Addr: 127.0.0.1:6379, PoolSize: 200, // 连接池上限 MinIdleConns: 50, // 预热连接数 DialTimeout: 1 * time.Second, ReadTimeout: 1 * time.Second, WriteTimeout: 1 * time.Second, MaxRetries: 0, // 秒杀场景不要自动重试 })参数说明PoolSize200 是单机压测的保守值经验上取预估并发的 1.5 到 2 倍再往上提升意义不大单机 Redis 处理能力有限要更高吞吐就上 Redis Cluster 做水平扩展。MinIdleConns设 50 是为了压测开始时连接已经就绪避免几百个请求同时触发建连的冷启动尖刺。MaxRetries设 0 是秒杀场景特有的选择。go-redis 默认在网络错误时自动重试但秒杀接口对不确定状态的原则是「不重试、返回繁忙、让用户自己决定是否再点」。万一第一次 DECR 已经执行成功只是响应超时自动重试虽然会被用户的 SISMEMBER 挡住不会超卖但会造成无谓的 Redis 压力而且响应时间翻倍。注意ReadTimeout 别设成 3 秒 5 秒这种宽值。秒杀接口要求快速释放连接超时太长会让连接池在 Redis 故障时被全部占住错误率从这时候开始就压不下来了。这套实现本身不依赖 Redis 特殊版本Lua 脚本能力从 Redis 2.6 起就有本地用 Docker 或 Windows 版 Redis 都能直接跑。5. 避坑与常见问题五个秒杀线上的真实排查记录这一章是这几年做秒杀活动攒下来的血泪经验每一条都是线上真实出现过、有明确复现路径的问题。按「现象 → 原因 → 解决」记录方便你踩坑时对照排查。5.1 压测后库存变成负数数据库订单比初始库存还多现象压测跑完Redis 里库存 key 的值是 -1878数据库订单数量远超初始库存。原因这是最经典的超卖路径。业务代码先 GET 库存判断大于 0再 DECR 扣减判断和扣减是两条独立的命令。两个并发请求同时 GET 到剩余 1都认为可以卖然后各自 DECR库存被扣两次。问题本质是「读-判-写」不是一个原子操作。解决把 GET 判断和 DECR 扣减以及用户判重全部写进同一个 Lua 脚本里也就是第 3 章那份脚本。Redis 执行脚本期间不会插入任何其它命令竞态从根上被消除。从那以后我把所有「先读后写」的 Redis 操作都改成了脚本实现。5.2 同一个用户在同一场秒杀里抢到两单现象活动结束后导订单表发现 (user_id, goods_id) 组合出现了两条记录。原因判重逻辑写在业务代码里而不是 Lua 脚本里。业务代码先把已购列表查出来做 Contains 判断再调扣减这两步之间用户的第二次请求又进来了两次都通过检查。数据库层没有唯一索引重复记录直接落库。解决判重写进 Lua 脚本的 SISMEMBER 分支这一步已经把并发窗口关死同时给订单表加UNIQUE KEY uk_user_goods (user_id, goods_id)做兜底。即使 Redis 判重被绕过数据库也会拒绝插入订单 worker 收到 Duplicate entry 后直接跳过不会多扣库存。5.3 一个不存在的商品 ID 把 MySQL 打爆缓存穿透现象压测脚本里商品 ID 拼错了一个比如 1001 写成了 1010结果 Redis 命中率直接掉到 0所有请求都穿透到 MySQL数据库连接数瞬间打满。原因这是标准的缓存穿透。商品信息虽然预热到了 Redis但那个写错的 ID 在 Redis 里没有对应 key每个请求都去 MySQL 查一次。秒杀场景的请求量是平时的几十倍穿透效果被放大得极猛。解决三个措施一起上。第一活动开始前把所有合法商品 ID 预热进 Redishandler 里对不存在的 ID 直接返回「参数错误」这一步就能挡掉 90% 的穿透流量第二对未命中的 key 设置空值缓存TTL 30 到 60 秒第三要更稳就加布隆过滤器。秒杀这种封闭场次第一招其实就够用了。5.4 压测报 redis: connection pool exhausted现象压测进行到第 10 秒日志开始大量刷redis: connection pool exhausted接口错误率直线上升。原因两个典型原因。一个是 Redis Client 不是全局单例每个请求都 New 了一个连接连接根本复用不了另一个是 PoolSize 设得太小压测并发超过连接数上限新请求只能排队等连接释放。解决把 Client 提升为全局变量main.go 里只初始化一次按压测并发调整 PoolSize经验值是并发数的 1.5 到 2 倍配合 MinIdleConns 预热连接。同时把 ReadTimeout 压到 1 秒防止慢请求占着连接不放。排查这类问题最快的办法是先看连接池监控指标而不是先怀疑代码逻辑。5.5 Lua 脚本报 NOSCRIPT 错误现象本地用 Redis Desktop Manager 或 redis-cli 手动执行脚本一切正常压测一开始就报NOSCRIPT No matching script. Please check EVALSHA。原因代码里直接调 EVALSHA但 Redis 服务端没有对应脚本的缓存。Redis 重启之后 SCRIPT LOAD 缓存的脚本全部丢失如果客户端只有 EVALSHA 没有回退逻辑脚本就执行不了。解决用 go-redis 的redis.NewScript它内部做了 SCRIPT LOAD EVALSHA收到 NOSCRIPT 会自动回退 EVAL如果是裸 Redis 命令操作就在服务启动时统一做一遍 SCRIPT LOAD 预热脚本并捕获 NOSCRIPT 错误改用 EVAL 重新执行。这个坑在本地环境几乎不会出现因为本地 Redis 没重启过上线前务必测试一下 Redis 重启后的行为。6. 上线前的验证用 wrk 压测和库存对账把系统跑通6.1 压测与对账一组命令验证系统不多卖压测前先写一个 wrk 的请求脚本# post.lua wrk.method POST wrk.headers[Content-Type] application/json wrk.body {userId: 12345}然后启动压测8 个线程 200 个连接跑 30 秒wrk -t8 -c200 -d30s -s post.lua http://127.0.0.1:8080/api/v1/seckill/1001压测结束后重点关注两类结果错误率Errors 列和 p99 延迟。Redis 正常时秒杀接口 p99 应该稳定在 10ms 以内如果错误率不为 0先查连接池参数而不是改代码。压测通过只证明性能达标还要验证数据一致性。对账只需要三条命令# Redis 剩余库存 redis-cli get stock:1001 # 订单表实际落库数 mysql -e select count(*) from orders where goods_id1001 # 检查有没有重复订单 mysql -e select goods_id,user_id from orders group by goods_id,user_id having count(*) 1对账公式是剩余库存加上落库订单数等于初始库存。前两组命令相加对不上说明扣减或落库有一个环节出错第三条命令有任何输出说明判重被绕过。这两步做完才敢说系统是真的能上线的。6.2 上线前强制检查的清单每次上线秒杀前我都固定走一遍这套检查五分钟搞定Lua 脚本在启动时已完成 SCRIPT LOAD 预热并测试过 Redis 重启后能自动回退 EVALRedis 里所有商品的库存 key 已 SET 好key 命名和代码完全一致订单表 (user_id, goods_id) 唯一索引已建立MaxRetries 0ReadTimeout 1s连接池参数已按压测配额调好回补库存逻辑已接好数据库故障时预扣库存能还回去这套检查现在已经是肌肉记忆因为真在半夜上线时翻过一次车脚本没预热Redis 重启后秒杀直接全断用户看到的状态全是「系统繁忙」。从那以后我每次上线秒杀前都强制走一遍脚本预热、库存对账和唯一索引检查出事故后熬一夜的代价比上线前五分钟的检查沉重太多。希望帮到你。本文还有配套的精品资源点击获取
返回列表