Go实战:高并发秒杀系统设计

发布时间:2026/8/19 20:12:34
Go实战:高并发秒杀系统设计 Go实战:高并发秒杀系统设计摘要: 本篇讲解Go秒杀系统设计实现Redis库存预扣减配合Lua脚本保证原子操作本地缓存挡读流量加异步下单削峰令牌桶限流防刷分享超卖问题排查与修复的踩坑经验对比Redis预扣减、数据库乐观锁、Redis Cluster三种库存方案。开篇故事去年618我们做了一次手机秒杀2000台现货预约人数8万。开抢前压测模拟8万并发结果库存还没扣完订单服务就挂了。数据库连接池打满Redis缓存击穿整个购物流程瘫痪40分钟才恢复。事后排查发现核心问题出在所有请求直接打到数据库做库存扣减8万并发把MySQL的行锁排队拉长到几十秒。更严重的是并发扣减出现超卖明明2000台库存最后下了2053单多卖53台要真金白银赔给用户。这次我把秒杀系统的核心设计写清楚重点讲Redis库存预扣减怎么用Lua脚本保证原子性以及超卖问题怎么排查和修复。一、Redis库存预扣减秒杀的核心矛盾是库存有限并发无限。每个请求都去数据库扣库存数据库必挂。思路是Redis先挡住绝大部分请求只有真正抢到库存的请求才落到数据库。Redis预扣减的关键是检查库存和扣减库存必须原子完成。用普通的GET再SET两个操作之间会有间隙高并发下依然超卖。Lua脚本把多个Redis命令打包成原子执行中间不会被其他命令插入。packageseckillimport(contexterrorsfmttimegithub.com/redis/go-redis/v9)// StockService 库存预扣减服务// Redis存储库存Lua脚本保证扣减原子性typeStockServicestruct{client*redis.Client// 扣减库存的Lua脚本检查扣减原子执行deductScript*redis.Script}// NewStockService 创建库存服务// 预加载Lua脚本到Redis减少每次传输开销funcNewStockService(client*redis.Client)*StockService{returnStockService{client:client,// Lua脚本: 先检查库存是否足够足够则扣减// KEYS[1] 库存key// ARGV[1] 扣减数量(默认1)deductScript:redis.NewScript( local key KEYS[1] local num tonumber(ARGV[1]) -- 获取当前库存 local stock tonumber(redis.call(GET, key) or 0) if stock nil then return -1 -- 商品不存在 end if stock num then return 0 -- 库存不足 end -- 扣减库存返回剩余数量 redis.call(DECRBY, key, num) return 1 -- 扣减成功 ),}}// Deduct 预扣减库存// 返回值: 1成功, 0库存不足, -1商品不存在func(s*StockService)Deduct(ctx context.Context,itemIDstring)(int64,error){key:fmt.Sprintf(seckill:stock:%s,itemID)// 执行Lua脚本原子操作result,err:s.deductScript.Run(ctx,s.client,[]string{key},1).Int64()iferr!nil{return0,fmt.Errorf(库存扣减失败: %w,err)}returnresult,nil}// InitStock 初始化库存到Redis// 活动开始前调用把数据库库存同步到Redisfunc(s*StockService)InitStock(ctx context.Context,itemIDstring,countint)error{key:fmt.Sprintf(seckill:stock:%s,itemID)// 设置库存加过期时间防止脏数据残留returns.client.Set(ctx,key,count,24*time.Hour).Err()}Lua脚本是整个秒杀系统的命脉。脚本里先GET库存判断够不够够了再DECRBY扣减这整个过程Redis保证不会被打断。8万并发进来Redis单线程串行执行这些脚本库存精确扣到0为止。二、本地缓存加异步下单Redis预扣减成功只是第一步真正的订单创建要写数据库这一步慢。直接同步写库Redis扣减成功但数据库写超时用户看到抢到了实际没下单。解决思路是异步下单预扣减成功后把订单消息丢到消息队列异步消费写库。packageseckillimport(contextencoding/jsonlogtimegithub.com/redis/go-redis/v9)// Order 订单信息typeOrderstruct{UserIDstringjson:user_idItemIDstringjson:item_idTime time.Timejson:time}// OrderService 异步下单服务// 预扣减成功后订单信息写入Redis队列异步消费typeOrderServicestruct{client*redis.Client stockSrv*StockService queueKeystring// 订单队列key}// NewOrderService 创建异步下单服务funcNewOrderService(client*redis.Client,stockSrv*StockService)*OrderService{returnOrderService{client:client,stockSrv:stockSrv,queueKey:seckill:order:queue,}}// TrySeckill 秒杀入口// 返回: 是否抢到(预扣减成功)func(o*OrderService)TrySeckill(ctx context.Context,userID,itemIDstring)(bool,error){// 第一步: Redis预扣减库存result,err:o.stockSrv.Deduct(ctx,itemID)iferr!nil{returnfalse,err}ifresult!1{// 库存不足或商品不存在returnfalse,nil}// 第二步: 扣减成功订单信息入队列order:Order{UserID:userID,ItemID:itemID,Time:time.Now(),}data,_:json.Marshal(order)// LPUSH写入队列异步消费iferr:o.client.LPush(ctx,o.queueKey,data).Err();err!nil{// 入队失败需要回滚库存// 这里简化处理生产环境需要补偿log.Printf(订单入队失败: %v,err)returnfalse,err}returntrue,nil}// ConsumeOrders 消费订单队列异步写库// 单独goroutine运行从队列取订单写数据库func(o*OrderService)ConsumeOrders(ctx context.Context){for{select{case-ctx.Done():returndefault:// BRPOP阻塞等待避免空转msg,err:o.client.BRPop(ctx,5*time.Second,o.queueKey).Result()iferr!nil{iferrredis.Nil{continue// 队列为空继续等待}log.Printf(消费订单失败: %v,err)continue}// 解析订单并写库varorder Orderiferr:json.Unmarshal([]byte(msg[1]),order);err!nil{log.Printf(解析订单失败: %v,err)continue}// 写入数据库(实际调用DAO层)iferr:o.saveOrder(ctx,order);err!nil{log.Printf(写库失败: %v,err)// 写库失败重试或补偿}}}}// saveOrder 写入数据库func(o*OrderService)saveOrder(ctx context.Context,order*Order)error{// 实际调用数据库DAO层// 这里简化为日志输出log.Printf(订单入库: user%s item%s,order.UserID,order.ItemID)returnnil}异步下单把同步的数据库写入变成异步消费用户端响应时间从几百毫秒降到几十毫秒。代价是用户体验上有一个订单确认中的中间态需要前端轮询或WebSocket推送最终结果。三、踩坑经验:超卖问题排查与修复超卖是秒杀系统最严重的故障。我们的活动上线后2000台库存最后下了2053单多出的53单要赔钱。排查过程很曲折。第一个怀疑对象是Lua脚本。我反复检查脚本的逻辑GET判断再DECRBY逻辑没问题。后来发现真正的问题出在库存初始化环节。活动开始前我们调了InitStock设置Redis库存为2000但运维同事又手动执行了一次SET命令把库存改成了2050因为数据库里有个字段记错了。更隐蔽的问题是Redis集群的主从切换。我们用的Redis Cluster主节点扣减成功后同步到从节点有延迟。主节点故障切换到从节点时从节点可能还没同步到最新的库存值新主节点读到旧库存继续扣减导致超卖。修复方案有两个要点。第一库存初始化加校验Redis里的库存不能超过数据库里的库存。第二最终防线放在数据库用乐观锁做最后一道校验。packageseckillimport(database/sqlerrorsfmt)// DBStockChecker 数据库层库存校验// 作为最后一道防线防止Redis层超卖传导到数据库typeDBStockCheckerstruct{db*sql.DB}// NewDBStockChecker 创建数据库校验器funcNewDBStockChecker(db*sql.DB)*DBStockChecker{returnDBStockChecker{db:db}}// DeductWithOptimisticLock 乐观锁扣减库存// 利用SQL的WHERE条件做并发控制// 只有多个人同时竞争时一个成功其余失败func(d*DBStockChecker)DeductWithOptimisticLock(itemIDstring,userIDstring,)error{// 乐观锁: UPDATE时带上stock 0的条件// 如果库存已经为0affected rows为0说明被别人抢了query: UPDATE seckill_items SET stock stock - 1, sold_count sold_count 1 WHERE item_id ? AND stock 0 result,err:d.db.Exec(query,itemID)iferr!nil{returnfmt.Errorf(数据库扣减失败: %w,err)}rows,_:result.RowsAffected()ifrows0{// 库存已被抢完返回错误returnerrors.New(库存不足)}returnnil}// VerifyStock 库存校验// 初始化时调用确保Redis库存不超过数据库库存func(d*DBStockChecker)VerifyStock(itemIDstring)(int,error){varstockinterr:d.db.QueryRow(SELECT stock FROM seckill_items WHERE item_id ?,itemID,).Scan(stock)iferr!nil{return0,err}returnstock,nil}数据库乐观锁是兜底方案。Redis负责快速过滤99%的请求数据库乐观锁保证最后1%的请求不会超卖。即使Redis层出了问题数据库这一层挡住超卖。四、对比分析库存方案性能精确度复杂度适用规模Redis预扣减极高(10万QPS)高(Lua原子)中中大并发数据库乐观锁低(几千QPS)极高低小并发兜底Redis Cluster极高(分片)中(主从延迟)高超大并发本地缓存扣减极高(无网络)低(多实例不共享)低单机秒杀Redis预扣减是秒杀系统的主力方案性能和精确度都不错。数据库乐观锁适合做兜底性能扛不住大并发。Redis Cluster用分片扛超大流量主从切换的延迟是隐患。本地缓存扣减只在单机场景能用多实例会各自扣减导致超卖。总结秒杀系统的设计核心是把流量挡在数据库之外。Redis预扣减用Lua脚本保证原子性挡住99%的无效请求。异步下单把数据库写入变成队列消费削峰填谷。数据库乐观锁做最后一道防线防止Redis层异常导致超卖。库存初始化必须做校验Redis里的库存不能超过数据库。下一篇我们聊实时聊天系统的架构设计。