拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高并发秒杀系统库存扣减一致性实战:Redis+Lua+Gin

高并发秒杀系统库存扣减一致性实战:Redis+Lua+Gin

简介:这是一份基于Redis+Lua+Gin实现的Golang高并发秒杀系统完整项目源码,适合有一定Go基础的后端开发者在学习高性能Web开发、库存防超卖、接口压测与微服务部署等场景时参考。资源共58个文件,压缩包约4.63MB,包含25个Go源文件(如路由、模型、Redis/MySQL服务、JWT中间件、消息队列与并发测试)、YAML多环境配置与Dockerfile(便于快速部署)、CSV测试数据、JMeter压测脚本、CSV建模脚本以及多张PNG演示说明图,整体按engine、api、model、conf等模块组织,便于阅读与二次开发。已有41人浏览学习,对秒杀场景学习者具有一定参考价值。项目中Lua脚本负责原子化扣减库存,Redis承担热点数据缓存,Gin负责高并发HTTP请求处理,清晰展示了从用户注册、领取优惠券到抢购下单的完整核心链路;使用者可直接运行模拟并发,观察数据库与缓存一致性,也可调整配置用于本地实验或生产预研。

1. 高并发秒杀系统实现:难点不在接口,在库存扣减的一致性

秒杀系统一上架,流量会瞬间集中在同一个商品上,库存从几百被抢到零往往只需要几秒。这时候最怕的不是接口慢,而是库存扣减不一致:两个请求同时读到还剩 1 件,各自都扣了一次,实际卖出 2 件。基于 Golang + Redis + Lua + Gin 的高并发秒杀系统实现,核心思路就是把这个读写过程收进 Redis 的 Lua 脚本里,让“判断库存、扣减、记购买人”变成原子操作,再由 Gin 对外提供轻量 HTTP 接口。

对已经会用 Go 写业务、但没真正处理过高并发写入的开发者,这套方案是很好的落地样例。它能让你在一台普通机器上模拟出秒杀场景,观察超卖是怎么出现的,又怎么靠 Lua 脚本消除。准备面试时遇到的秒杀八股文,也基本都能在这套代码里找到对应实现。

我日常做这类活动,第一版往往不是先调框架,而是先定好 Redis 里的数据结构和脚本边界。因为秒杀的性能上限,几乎完全取决于 Redis 与 Lua 脚本怎么配合,Gin 反而是这里面最不容易出问题的部分。

2. Redis + Lua + Gin 的选型逻辑:为什么这套组合能扛住瞬时流量

2.1 Redis 负责快速读写,为什么不用数据库直接扛

秒杀场景里,数据和流量最集中的是同一个商品 ID。这意味着几乎所有请求都打到一行库存记录上,热点非常明确。MySQL 处理这类写操作要经过 Buffer Pool、行锁、索引、Redo Log 多道环节,同一行上的并发 UPDATE 会互相锁等待,连接一旦堆积,QPS 立刻从几千掉到几百,甚至把整个库拖垮。Redis 把数据放在内存,读写是微秒级;单线程命令队列天然把所有并发操作变成顺序执行,反而在这种高冲突场景下表现更稳定。

所以这套实现里 Redis 承担的不是传统意义的“缓存加速”,而是直接接管了“扣减库存”这个核心写路径。数据库只在秒杀结束之后异步接收中奖订单,承担纯插入逻辑,不再碰库存这一列。

我一般会用一个简单表格来定组件边界:

组件读写延迟并发瓶颈在秒杀中承担的角色
MySQL毫秒级行锁与连接池订单持久化,异步写入
Redis微秒级单线程 CPU、连接数库存预检、扣减、用户去重
Gin微秒级服务器文件描述符参数校验、路由分发、响应
Nginx微秒级配置与连接模型接入层限流、静态拦截

选 Redis 的另一个原因还在于它天然支持 TTL、SET 这类原子命令,和 Lua 配合可以做到一次网络开销完成全部库存操作。数据库做不到这一点,哪怕用事务也仍然要多次往返和行锁等待。

2.2 Lua 脚本的原子性:库存扣减如何做到不超卖

库存扣减本质上是“先读库存、再判断剩余、最后写回”三步操作。如果拆成三次 Redis 命令执行,两个并发请求可能同时读到剩余 1 件,随后各自执行扣减,最后库存变成 0,但卖出记录是 2 条。这就是最典型的超卖成因。

Redis 的 Lua 脚本在执行期间是原子的,脚本内所有命令不会和外部命令交错。因为 Redis 服务端是单线程事件循环,整段脚本从第一行到最后一行连续执行完毕,外部来的其他命令只能排队等脚本结束。把“读库存、判断、扣减、写已购集合”放进同一段脚本,就能从机制上消除超卖。

用命令片段可以直观看出非原子写法的问题:

# 连接A与连接B同时读到 stock=1 GET seckill:product:p1001 # 返回 1 GET seckill:product:p1001 # 返回 1 DECR seckill:product:p1001 # A 把库存扣到 0 DECR seckill:product:p1001 # B 再把 0 扣成 -1,超卖

同样是 Redis,换成 Lua 脚本后,连接A和连接B只能按顺序执行。脚本内先取 stock,如果当前是 1,则第一个请求扣成 0;第二个请求执行到判断库存时发现已经是 0,直接返回“已售罄”。整个过程没有中间态。

还有一个实际工程细节我一般会强调:Lua 脚本在两次 Redis 连接之间是共用一套逻辑的,生产环境用SCRIPT LOAD先加载脚本拿到 SHA,请求时用EVALSHA只传 40 字节的摘要,避免每个请求都推送整段脚本。这一点在高并发下能明显降低网络包体和解析开销。

2.3 Gin 在其中的定位:路由、中间件与并发模型的取舍

Gin 的路由结构是压缩字典树,支持参数路由、通配符和中间件链,性能在 Go 的 HTTP 框架里是第一梯队。但它适合秒杀接口的真正原因不是“框架最快”,而是它给并发控制留够了空间:每个请求一个 goroutine,处理完自动回收,内存分配可控,代码结构上可以很自然地把鉴权、限流、参数校验拆成中间件。

写秒杀接口时常见的误区是在 handler 里把业务全写完。我一般会把它拆成三层:接入层中间件只做用户识别和限流;handler 只做参数校验和调用核心函数;真正扣库存的操作收敛到 Lua 脚本这一个入口。这样后续加规则、改策略都只动一个地方。

还要注意不要误以为瓶颈会出现在 Gin 本身。Gin 单实例轻松扛上万 QPS,秒杀系统真正需要盯的是 Redis 连接数和下游持久化。因此写 handler 时不要每次请求都redis.NewClient,必须用连接池复用连接。Go 侧还有一点要注意:不要在 handler 里随意起 goroutine 做异步任务,秒杀场景下大量 goroutine 一旦失控,会直接放大下游 DB 的压力,响应反而更慢。

2.4 一次秒杀的完整请求链路

把上面几层串起来,一次秒杀请求的路径是固定的:请求从客户端发出,先进入 Nginx 或网关做 IP 维度限流,再到 Gin 中间件里做登录态校验和基础参数检查,然后直接调用 Redis Lua 脚本完成库存判断、扣减和用户去重,成功的结果通过异步队列落到 MySQL,前端只拿到一个状态码。

链路节点职责失败时怎么处理
客户端发起点击重试按钮置灰,防重复提交
Nginx/网关按用户或 IP 限流直接返回 503,不进入后端
Gin 接口校验参数、提取用户 ID参数错误返回 400
Redis + Lua原子扣库存、去重返回 -1、0、-2 等状态码
异步落库将成功订单写入 MySQL失败进重试队列,不影响用户响应

这套链路里唯一真正“动库存”的动作只出现在 Lua 脚本内,其他层都不碰库存字段。这样设计的直接好处是:数据库不会因为并发 UPDATE 被打崩,接口也不会因为数据库锁等待而变慢,库存一致性问题被压缩到了一个可控的脚本里。

3. 从零跑通秒杀核心:Lua 脚本与 Gin 接口实现

3.1 商品库存的 Redis 数据结构设计

秒杀商品的库存数据,我会用一个 Hash 存所有商品维度信息,而不是散落多个 String key。原因是秒杀接口响应时往往要返回标题、价格、剩余库存等字段,Hash 可以一次HMGET全部取回,请求阶段省掉多次网络往返。

结构上我习惯这样设计:

type Product struct { ID string `json:"id"` Stock int `json:"stock"` Title string `json:"title"` Price float64 `json:"price"` }

预热时把这个结构体写进 Redis:

func preloadProduct(ctx context.Context, rdb *redis.Client, p Product) error { key := "seckill:product:" + p.ID _, err := rdb.HSet(ctx, key, map[string]interface{}{ "stock": p.Stock, "saled": 0, "title": p.Title, "price": p.Price, }).Result() if err != nil { return err } // 活动结束后 key 自动过期,避免残留大 key return rdb.Expire(ctx, key, time.Hour).Err() }

这里HSet一次性写入多个 field,Hash 里stock是剩余库存,saled是已售数量。Expire设置一小时过期时间,实际生产里一般按活动时长加 10 分钟缓冲,防止活动结束后 Redis 里残留无用数据占用内存。

需要注意:预热阶段写入的stock只是初始值,后续任何地方都不要用DECR或HINCRBY单独修改它。所有库存变化必须走同一段 Lua 脚本,否则一旦出现旁路扣减,一致性就无法保证。

3.2 核心 Lua 脚本:原子扣减与用户去重

这是整个秒杀系统的核心,我把它单独放进项目里的seckill.lua文件,方便测试和复用。脚本接收两个 key:商品 Hash 的 key 和用户已购集合的 key,再接收用户 ID 和购买数量两个参数。

-- KEYS[1]: 商品 Hash key,例如 seckill:product:p1001 -- KEYS[2]: 已购用户集合 key,例如 seckill:users:p1001 -- ARGV[1]: 用户 ID -- ARGV[2]: 购买数量,秒杀场景默认 1 local stock = redis.call('HGET', KEYS[1], 'stock') if not stock then return -1 -- 商品不存在或未预热 end local left = tonumber(stock) if left < tonumber(ARGV[2]) then return 0 -- 库存不足,已售罄 end local bought = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if bought == 1 then return -2 -- 该用户已抢购过,防重复 end redis.call('HINCRBY', KEYS[1], 'stock', -tonumber(ARGV[2])) redis.call('HINCRBY', KEYS[1], 'saled', tonumber(ARGV[2])) redis.call('SADD', KEYS[2], ARGV[1]) return 1 -- 抢购成功,库存已扣

这段脚本把“查库存、判库存、查重复、扣库存、记已购”合并进一次原子执行。HINCRBY对stock是减操作,对saled是加操作,两者在同一次脚本里完成,无论并发多高都不会出现只扣 stock 不记 saled 的中间态。

返回值设计成四种状态码,分别对应成功、售罄、商品不存在、重复购买。这样 handler 拿到结果后可以直接映射到 HTTP 响应,不用再回去查一次 Redis。脚本里用SISMEMBER判断用户是否已经抢过,再用SADD写入去重集合;这两步也在同一个原子边界内,不会出现两个请求同时通过检查再各自下单的情况。

脚本本身不长,但要注意一点:尽量不在脚本里使用时间函数或随机数。具体原因放在避坑章节详细说。

3.3 Gin 接口处理器:参数校验、脚本调用与响应

启动阶段先把 Lua 脚本注册到 Redis,拿到 SHA 摘要:

var seckillScriptSHA string func initScript(rdb *redis.Client) error { sha, err := rdb.ScriptLoad(context.Background(), seckillLuaScript).Result() if err != nil { return err } seckillScriptSHA = sha return nil }

ScriptLoad只加载一次,进程启动时执行。之后所有请求都通过EvalSha调用,传入的是 40 字节的 SHA,而不是整段脚本。这样既省带宽,又避免每次请求重复解析 Lua 代码。

核心 handler 写法如下:

func seckillHandler(rdb *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { uid := c.GetHeader("X-User-ID") pid := c.Param("pid") if uid == "" || pid == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) return } ctx := c.Request.Context() val, err := rdb.EvalSha(ctx, seckillScriptSHA, []string{ "seckill:product:" + pid, "seckill:users:" + pid, }, uid, 1).Int() if err != nil { // 脚本丢失时回退到 Eval 重新加载,生产环境也要做一次兜底 c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务繁忙"}) return } switch val { case 1: c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "抢购成功"}) case 0: c.JSON(http.StatusOK, gin.H{"code": 1, "msg": "已售罄"}) case -1: c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "商品不存在或未开始"}) case -2: c.JSON(http.StatusOK, gin.H{"code": 2, "msg": "请勿重复购买"}) } } }

EvalSha的返回值对应 Lua 脚本里定义的四个状态码。handler 里没有再去查数据库、没有额外扣减操作,所有业务逻辑都收敛在 Lua 脚本这一个入口。用户 ID 从 Header 读取,常见做法是登录态由网关或 middleware 解析后注入,不建议依赖前端传入。

main.go 里把 Redis 客户端和 Gin 路由组装起来:

func main() { rdb := redis.NewClient(&redis.Options{ Addr: "127.0.0.1:6379", PoolSize: 50, MinIdleConns: 20, }) if err := initScript(rdb); err != nil { panic(err) } r := gin.New() r.GET("/seckill/:pid", seckillHandler(rdb)) r.Run(":8080") }

PoolSize决定 Redis 连接池最大连接数,MinIdleConns让进程启动时就维持一定数量的空闲连接,避免流量进来时一边建连接一边处理。这两个参数直接决定接口能打多高的 QPS,我一般会在压测里来回调几轮。

启动后用一条 curl 就能验证基本流程:

curl -H "X-User-ID: 1001" http://127.0.0.1:8080/seckill/p1001

第一次请求如果库存初始化了 100 件,会返回抢购成功;第二次用同一个用户 ID 再请求,会被 Lua 脚本里的SISMEMBER拦截,返回“请勿重复购买”。这一步能快速确认脚本和路由都正常工作。

注意:生产环境不要把明文用户 ID 放在 Header 里,通常由 API 网关从登录态解析后注入,这里只做演示。

4. 压测与参数调优:把 QPS 从几百提到几千

4.1 用 wrk 打基线:先看数据,再调参数

秒杀系统上线前,压测是必须做的一步。我常用 wrk 配合一个简单 Lua 脚本,从不同用户维度打流量。直接压力测试接口本身没法模拟真实秒杀场景,因为所有请求如果带同一个用户 ID,第一次成功之后就会被去重逻辑拦住,后面测到的全是“重复购买”响应。

-- bench.lua -- 用法:wrk -t8 -c200 -d30s -s bench.lua http://127.0.0.1:8080/seckill/p1001 function request() wrk.headers["X-User-ID"] = tostring(math.random(1, 999999)) return wrk.format(nil) end

这里用随机用户 ID 模拟不同用户同时抢购,保证大多数请求都能走到库存扣减逻辑,而不是被去重集合拦截。wrk 的thread数、connection数、duration分别代表并发线程、并发连接数、压测时长。一组常见起步参数是 8 线程、200 连接、30 秒。

压测结果里重点看 QPS、Avg Latency 和 p99。如果 QPS 只有几百、p99 超过 500ms,先别急着怀疑 Redis 性能,回头看自己的连接池配置和 Redis 是否开启了 AOF 刷盘。

我一般会先跑一组基线,记录数据后只改一个变量再跑一组,对比前后差异。这比一次改五六个参数再全量压测要更容易定位问题。调优时目标是让 QPS 曲线平稳、错误率低于 0.1%,而不是一味把连接数往上加。

4.2 Redis 侧参数怎么调:四个必看的配置项

秒杀场景下 Redis 的瓶颈通常出现在连接数、命令执行效率和持久化策略上。我压测时必查以下四个配置项:

配置项默认表现建议值调整理由
maxclients10000按压测峰值 x 1.5连接池打满时报max number of clients reached
tcp-backlog511511+,并同步调大系统 somaxconn避免 accept 队列堆积导致握手延迟
appendfsynceveryseceverysec兼顾刷盘安全与写入性能
maxmemory-policynoevictionnoeviction 或 volatile-lru秒杀 key 不能被 LRU 淘汰

maxclients不调整时,压测到 1 万左右连接就会出现连接拒绝,误以为是 Redis 崩了。tcp-backlog和内核参数net.core.somaxconn要配套调整,否则 TCP 连接会在 accept 队列堆积,压测表现为延迟忽高忽低。

appendfsync如果设成always,每次写命令都刷盘,QPS 会直接砍半以上;never又太危险,进程挂掉丢数据。秒杀场景里everysec是折中方案:最多丢 1 秒数据,但性能损失可控。另外可以用redis-cli --latency观察本机到 Redis 的及时延迟,一般应低于 1ms,如果偏高,往往是网络配置或服务器负载问题。

4.3 Gin 侧参数怎么调:别在框架层拖后腿

Gin 侧最常见的性能杀手是 debug 模式。Gin 默认的开发模式会打印每个请求的访问日志,压测时大量日志写入会占用 CPU 和磁盘 IO。正式压测前先确认运行在 release 模式:

gin.SetMode(gin.ReleaseMode)

第二个要调的是 Redis 连接池。连接池不是越大越好。我压测时从PoolSize=20调到50,QPS 有明显提升;再调到100,QPS 反而下降,原因是连接建立和释放的开销超过了复用收益。经验做法是先用MinIdleConns预热一批连接,再根据压测曲线逐步增加PoolSize。

Go 运行时本身多数情况下不用特别调优,GOMAXPROCS默认用满服务器核心即可。但如果压测时 CPU 占用率已经很高,响应时间仍在涨,就要看是否日志打印、JSON 序列化占了太多资源。接口响应体尽量用固定 struct 而不是每次重新组装gin.H,减少反射和 GC 压力。

压测期间用 Redis Desktop Manager 这类可视化工具观察内存和 key 分布也很有用。重点关注活动结束之后seckill:product:*和seckill:users:*是否正常过期,避免留下大 key 占用内存,影响后续其他业务。

5. 避坑清单:秒杀系统最容易翻车的 5 个坑

5.1 超卖:库存显示负数,订单超卖

现象:压测结束后 Redis 里 stock 变成负数,或者数据库订单数大于活动库存数。

原因:库存判断和扣减被拆成了多条 Redis 命令执行,两个请求同时读到剩余 1 件,各自认为自己抢到了,再分别执行扣减,导致库存被扣成 -1。

解决:所有库存操作只准出现在同一个 Lua 脚本里。脚本第一行取库存,接着判断,然后扣减,任何外部代码都不允许直接DECR或HINCRBY操作stock字段。压测后要习惯性检查HGET seckill:product:p1001 stock,确认结果不会是负数。

5.2 用 Redis 事务替代 Lua,导致大量重试

现象:秒杀接口使用了WATCH+MULTI/EXEC来处理库存扣减,压测时大量事务执行失败,报EXECABORT,接口成功率直线下降。

原因:WATCH机制是乐观锁,适合读多写少的场景。秒杀是高冲突写入,两个请求同时盯住同一行库存,必然有一个事务在提交时发现 key 被修改而回滚,重试又放大冲突,形成恶性循环。

解决:直接用 Lua 脚本替代云态锁事务。Lua 脚本在服务端串行执行,不存在失败重试的问题,代码也更简洁。

5.3 Lua 脚本里用了时间函数或随机数,数据不一致

现象:Redis 主从切换后,某些副本上的数据与主库不一致,甚至 Redis 7 环境直接报错拒绝执行脚本。

原因:Redis 复制模式下,主库执行 Lua 脚本后会把效果发送给从库。如果脚本里用了os.time()、math.random()这类非确定性函数,从库重放时无法得到和主库一样的结果。

解决:把随机数、时间戳等所有非确定参数放在ARGV里,由 Go 侧生成后传入脚本。脚本内部只做纯计算和 Redis 操作,保证同一套输入在任何环境重放结果一致。

5.4 库存没预热,接口直接回源打垮数据库

现象:秒杀刚开始,Redis 里没有商品 key,Lua 脚本返回 -1;handler 按“商品不存在”处理,反而触发了查询数据库回源,一瞬间数据库连接被打满。

原因:预热流程缺失,或者接口里把所有异常情况都处理成了“回源查一次”。秒杀场景最忌讳回源,热点数据必须在秒杀开始前全部放到 Redis。

解决:启动或开售前强制预热商品数据,并设置一个“已预热”标记。接口先检查标记,没有标记直接返回“未开始”,绝不回源查 DB。预热失败时宁可让活动延迟开启,也不要让流量打到数据库。

5.5 压测机就是 Redis 所在机器,数据好看但不真实

现象:在本机压测 QPS 轻松上万,部署到测试环境后只有原来的三分之一,延迟也涨了不少。

原因:本机回环网络延迟极低,连接数上限也可能只针对单机。压测请求没有经过真实网卡、交换机,结果不具有参考价值。

解决:压测机和 Redis 服务器分开部署,压测前检查两端文件描述符上限和 Redismaxclients。压测时观察redis-cli INFO里的connected_clients,确认是否逼近连接上限。

6. 进阶验证:从接口正确性到全链路闭环

6.1 正确性验证:并发抢购与幂等自检

秒杀系统调完参数不代表就完事了,我每次改完代码都要跑一遍并发正确性验证。思路很简单:准备一个初始库存为 100 的商品,用 1000 个不同用户 ID 并发抢购,最后断言成功返回的数量不超过 100。这段验证逻辑可以直接写成 Go 测试脚本。

var success int32 var wg sync.WaitGroup for i := 1; i <= 1000; i++ { wg.Add(1) go func(id int) { defer wg.Done() req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/seckill/p1001", nil) req.Header.Set("X-User-ID", fmt.Sprintf("user-%d", id)) resp, err := http.DefaultClient.Do(req) if err != nil { return } defer resp.Body.Close() var body struct { Code int `json:"code"` } json.NewDecoder(resp.Body).Decode(&body) if body.Code == 0 { atomic.AddInt32(&success, 1) } }(i) } wg.Wait() fmt.Println("success:", success)

跑完这组验证,如果success大于初始库存 100,说明超卖;等于 100,说明库存扣减正确;小于 100,则要回头看是预热不足还是用户 ID 生成有重复。

幂等验证也要单独跑:同一个 userID 连续发送 100 个请求,成功次数必须是 1,其余都返回“请勿重复购买”。这一步能确认用户去重和库存扣减在同一个原子边界里。

6.2 降级验证:Redis 挂了接口不能拖垮数据库

最后再验证降级路径:手动停掉 Redis,请求秒杀接口,预期是快速返回 500 或“服务繁忙”,而不是卡住等超时,更不能让 handler 去访问数据库。恢复 Redis 后不需要重启服务,因为EvalSha在 next请求时重新加载即可。

我第一次把秒杀系统放到测试环境时,漏了用户去重逻辑,压测刚结束就发现同一账号连中两次。当时还以为是随机数撞了,后来才知道问题出在去重和扣库存不在同一个原子操作里。后来把SISMEMBER和HINCRBY收进同一个 Lua 脚本,这个坑才算填平。像秒杀这种流量模型,很多问题只有并发打上去才看得见,光背八股文容易忽略黑匣子里的真实行为。希望这篇踩坑记录能帮你在搭第一套秒杀系统时少走几步弯路,也记得把验证脚本留在仓库里,每次改代码都拿出来跑一遍。

本文还有配套的精品资源,点击获取

返回列表