ARTICLE DETAIL

资讯详情

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

Go语言构建企业级高并发抽奖系统:Iris+Redis+Lua防超卖实战

Go语言构建企业级高并发抽奖系统:Iris+Redis+Lua防超卖实战 简介一套基于 Go 语言开发的企业级抽奖系统选用 iris xorm mysql redis 技术栈面向需要快速搭建高并发抽奖平台的后端开发者也可用作毕业设计、课程设计或实际项目的二次开发基座。压缩包共 103 个文件整体约 1.28MB其中 54 个 Go 源文件承载核心业务逻辑与数据访问层配合 12 个 HTML、9 个 JS、4 个 CSS 构成后台管理页面另有 SQL 初始化脚本和少量 rdb 快照便于在本地环境一键启动和调试。项目围绕黑名单表、虚拟券表、奖品表、获奖表、用户表、每日抽奖情况表这 6 张业务表展开实现了奖品库存与中奖概率动态调整、中奖记录按用户或奖品筛选、后台删除或标记作弊等完整闭环同时覆盖用户每日抽奖次数限制、黑名单到期时间等真实运营规则并能通过 redis 支撑高频访问场景。已有 229 人浏览学习适合希望学习 Go 服务端分层设计、xorm 操作 mysql、redis 缓存应用及抽奖业务建模的开发者直接获得一套可运行的实战参考资源。 做抽奖系统这几年我接手过不少线上出过事的项目超卖、重复中奖、活动期间Redis被热点打挂问题五花八门但根子大多出在技术选型混乱和并发控制不到位这两件事上。这次用Go从零做一个企业级抽奖系统lottery_system技术栈是iris xorm MySQL Redis整套方案经过了压测验证也补齐了生产环境的细节。这个项目解决的核心问题很明确运营活动这类突发流量下抽奖请求怎么扛住、奖品库存怎么不超卖、中奖记录怎么不丢、用户疯狂点击怎么拦住。如果你有Go基础或者正打算做类似的活动系统这套设计可以直接拿来改里面每一步为什么这么走我会讲清楚。踩过的坑也会一并整理出来帮你绕开。1. 系统整体设计与技术选型思路1.1 为什么用iris而不是gin现在Go社区里gin的认知度最高这个我承认但抽奖系统这类业务有个特点路由层级多、接口都围绕活动和奖品展开天然契合MVC模式。iris在这点上是加分项它内置了完整的MVC支持用iris.mvc.New(app.Party(/lottery))就能挂载一个控制器代码组织方式和我以前写Java Spring MVC的体验非常接近团队成员接手时几乎零学习成本。另一个原因是iris的路由性能。抽奖接口是典型的读多写少、高频访问iris底层的路由基于Radix Tree实现压测数据在Go Web框架里排在第一梯队实测对性能提升有直接帮助。除此之外iris的中间件生态也够用CORS、gzip压缩、Recover恢复这些都有现成的实现。有一点需要提醒项目初期如果只追求“能跑”用gin确实上手更快但抽奖系统后面一定会加数据监控、中间件编排、接口版本这类需求iris在这里的扩展性和内置能力会让你的迭代舒服很多。1.2 xorm相比gorm的核心优势选xorm不是因为它比gorm好而是它在特定场景下更合适。这个项目的表结构跟运营后台关联比较深奖品表、活动表在开发前就已经被DBA设计好了。xorm有个非常实用的工具——Reverse命令可以直接从MySQL表结构反向生成带tag的struct省去了手工敲一堆字段标签的时间。xorm的软删除也做得比较自然结构体里定义一个deleted int64字段删除操作会自动转成UPDATE ... SET deletedid查询时自动带上deleted0条件。抽奖系统的活动、奖品这类数据不能物理删除这个特性非常贴合业务。它在原生SQL上的支持也足够像统计中奖率、报表查询这类复杂SQL不需要拼Raw.SQL()方法里直接写原生语句再缓存结果集使用体验很顺畅。唯一要注意的是xorm的Where方法在某些情况下会忽略空字符串条件如果你要查的值恰好可能是空字符串记得用And(column ?, value)来绕开。1.3 MySQL和Redis的分工边界这个系统里MySQL是唯一的事实数据源所有的活动配置、奖品信息、中奖记录最终都存在MySQL上。Redis承担的角色有四个热点数据缓存、库存预热、参与次数计数、分布式锁。有些设计会把Redis也当成存储层来用中奖记录直接写Redis然后异步落库。我不建议这么做原因很简单Redis的持久化机制无论是RDB还是AOF都存在数据丢失的窗口期。抽奖系统一旦出现“用户抽中但后台没记录”的投诉解释成本非常高。用MySQL做主存储Redis做加速和原子操作服务重启或Redis宕机都不影响最终数据的一致性。2. 数据模型设计与缓存规划2.1 核心表结构与字段说明抽奖系统的表结构不需要太复杂核心就四张表活动表、奖品表、抽奖记录表、用户表。设计时要把“活动和奖品的配置信息”和“抽奖过程的动态数据”分清楚避免一张表承载太多业务。表名核心字段说明activitiesid, title, status, start_time, end_time, daily_limit, total_limit活动状态0未开始、1进行中、2已结束prizesid, activity_id, name, level, total_count, remaining_count, weightweight是权重用于概率抽奖lottery_recordsid, user_id, activity_id, prize_id, result, unique_nounique_no为业务流水号唯一索引防重usersid, username, phone, status用户基础信息有一个细节值得多讲lottery_records表里的unique_no字段。这个字段是每次抽奖请求生成的唯一流水号比如时间戳 userId 活动ID 4位随机数然后在这个字段上建唯一索引。当同一个用户因为网络重试连发了两次抽奖请求第二次插入会因为唯一索引冲突而失败这是拦截重复提交最底层的兜底方案。2.2 索引设计与查询优化抽奖记录表是写入量最大的表活动高峰期每秒可能写入几百上千条。索引设计上有两个原则一是查询的字段尽量全部放进索引避免回表二是索引数量不能贪多否则影响插入性能。我在实际项目中只建了三个索引主键索引、unique_no唯一索引、(activity_id, user_id, created_at)联合索引。联合索引主要用来支撑两个高频查询一个是查用户在某活动下的抽奖记录列表另一个是统计某活动的中奖记录。要特别留意的是不要把DATE(created_at)这种函数用在索引字段上作为查询条件MySQL的函数索引只有在5.7及以上才支持而且规划不好容易让索引失效。更好的做法是对日期查询用created_at ? AND created_at ?这种范围条件。2.3 Redis数据结构和预热策略Redis在抽奖系统中的数据结构规划我用一张表来列清楚。数据类型Key示例用途Stringactivity:{id}:status活动状态1表示进行中Stringprize:{id}:stock奖品预热后的剩余库存Hashuser:{id}:activity:{aid}:count用户参与次数用HINCRBYStringlock:lottery:{aid}抽奖分布式锁库存预热是活动开始前必须做的一步。写个定时任务在活动开始前1分钟扫描数据库中所有remaining_count大于0的奖品批量加载到Redis。这样抽奖接口只跟Redis交互不会把数据库打爆。活动结束后再把Redis中剩余的库存异步回写数据库。这里有个实际教训一定要在预热前确保活动状态已切换到1否则Redis里存了库存但用户无法访问等用户开始抽时库存又已经过期等于白预热。3. 核心流程与关键代码实现3.1 抽奖前置检查活动状态和用户次数限制抽奖接口接到请求后不能直接抽要先做三道前置检查活动是否存在且状态为进行中、用户是否在有效期内、用户参与次数是否已达上限。我用Redis来快速校验活动状态。活动上下线时写状态到Redis抽奖时读一次Redis就能判断不用每次打到MySQL。func CheckPreconditions(ctx context.Context, rdb *redis.Client, db *xorm.Engine, activityID, userID int64) error { // 1. 活动状态检查 status, err : rdb.Get(ctx, fmt.Sprintf(activity:%d:status, activityID)).Int() if err redis.Nil { return errors.New(活动不存在或未开始) } if err ! nil { return err } if status ! 1 { return errors.New(活动不在进行中) } // 2. 用户参与次数检查使用HINCRBY避免并发计数不准 countKey : fmt.Sprintf(user:%d:activity:%d:count, userID, activityID) count, err : rdb.HIncrBy(ctx, countKey, day, 1).Result() if err ! nil { return err } if count 1 { // 第一次加次数时设置过期时间为当天结束防止计数器脏数据 ttl : timeUtil.UntilEndOfDay() rdb.Expire(ctx, countKey, ttl) } dailyLimit : 3 // 从活动配置读取 if count int64(dailyLimit) { return errors.New(今日抽奖次数已用完) } return nil }这段逻辑里有几个点值得抠一抠。用HIncrBy而不是Incr是因为Hash字段天然支持“这一天抽了几次”这种维度后续还可以扩展同一天内按小时统计。设置Expire的时机很关键只在第一次加次数时设置过期时间如果每次抽奖都重置过期时间用户一直抽就一直不清理数据会有脏数据。3.2 权重随机算法与中奖判定中奖判定不能简单地用rand.Intn(100) probability因为不同奖品的概率可能不一样而且奖品池可能是动态变化的。我用的是权重随机算法每个奖品配置一个weight权重越大中奖概率越高。type PrizeData struct { PrizeID int64 json:prize_id Name string json:name Weight int json:weight } func RandomPrizeByWeight(prizes []PrizeData) (int64, bool) { totalWeight : 0 for _, p : range prizes { totalWeight p.Weight } if totalWeight 0 { return 0, false } randNum : rand.Intn(totalWeight) 1 // 生成[1, totalWeight]区间的整数 for _, p : range prizes { randNum - p.Weight if randNum 0 { return p.PrizeID, true } } return 0, false }这个算法的时间复杂度是O(n)在奖品数量不超过几十个的场景里性能没有问题。有一点要注意如果奖品池中存在权重为0的奖品意味着这个奖品永远抽不中这种数据应该在建活动的时候就过滤掉不要带到随机算法里。3.3 用Lua脚本扣减库存彻底杜绝超卖库存扣减是抽奖系统最容易出事故的地方我先说结论一定要用Lua脚本实现“检查库存并扣减”这两个动作的原子性。Redis是单线程模型同一个Lua脚本里的命令会按顺序原子执行不会插入其他指令这从根本上解决了并发场景下的竞态问题。-- KEYS[1]: 奖品库存key -- 返回值: -1表示库存不足, 0表示扣减后的剩余库存 local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock 0 then return -1 end return redis.call(DECR, KEYS[1])把这段脚本保存为decr_stock.lua在Go中用go-redis的NewScript加载使用。var decrStockScript redis.NewScript( local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock 0 then return -1 end return redis.call(DECR, KEYS[1]) ) func DecrStock(ctx context.Context, rdb *redis.Client, prizeID int64) (int64, error) { key : fmt.Sprintf(prize:%d:stock, prizeID) result, err : decrStockScript.Run(ctx, rdb, []string{key}).Int64() return result, err }之前我看过很多团队用DECR命令直接扣扣完再检查返回值是否小于0。这个方案的漏洞在于当并发量非常高时可能在检查返回值之前它已经变成了负数这时你就不知道到底是哪个请求把库存扣成了负数补偿起来很麻烦。用Lua脚本把检查动作放在同一段原子逻辑里永远不会有负数库存出现。还有一个小建议调试Lua脚本时可以用redis-cli --eval decr_stock.lua prize:1直接在命令行验证逻辑比每次都在Go代码里跑方便得多。3.4 事务落库与幂等设计Redis扣减库存成功之后接下来要把抽奖记录写入MySQL。这一步必须用事务把“插入抽奖记录”和“更新奖品剩余库存”放在同一个事务里执行保证要么全部成功要么全部回滚。func InsertLotteryRecord(db *xorm.Engine, record *LotteryRecord, prize *Prize) error { session : db.NewSession() defer session.Close() if err : session.Begin(); err ! nil { return err } defer func() { _ session.Rollback() }() // 1. 插入抽奖记录 if _, err : session.Insert(record); err ! nil { return err } // 2. 更新奖品库存用乐观锁防止并发覆盖 if prize ! nil { affected, err : session.Where(id ? AND remaining_count 0, prize.ID). Update(Prize{RemainingCount: prize.RemainingCount - 1}) if err ! nil { return err } if affected 0 { return errors.New(奖品库存已不足) } } return session.Commit() }这里的defer session.Rollback()在Commit()成功后再调用是没有副作用的xorm会静默处理我习惯这么写能防止漏rollback导致的连接泄漏。幂等设计的关键是前面提到的unique_no字段。当网络超时导致用户重试时第二次请求会生成新的unique_no插入数据库此时因为唯一索引冲突插入会失败。这时候事务会直接回滚用户拿到的反馈是“请勿重复提交”而不是“系统繁忙”。这就把因为重试导致的重复中奖问题彻底堵住了。4. 并发与高可用保障方案4.1 超卖问题三种方案对比超卖问题可以从三个层面来解决我按可靠性排一下。方案原理优点缺点数据库乐观锁UPDATE ... SET remaining_count remaining_count - 1 WHERE remaining_count 0数据强一致高并发下热点行锁性能差Redis DECR直接扣减Redis库存性能高实现简单扣成负数难补偿Lua原子脚本检查扣减合并执行性能高且可靠需要额外维护脚本上线前测试的时候我们曾经用数据库乐观锁的方案压测1000个并发请求打过来MySQL的CPU直接飙到100%响应时间变得很不可控。后来全部改成Lua脚本后同样压测环境下Redis端的耗时稳定在1ms以内。方案选择上最终用的就是Lua脚本这也是当前企业级抽奖系统的主流做法。有一点要说明Redis扣减成功不代表这个用户百分百中奖因为中奖记录落库还可能失败。所以我在代码里加了一步补偿机制如果落库失败就调用Lua脚本把Redis库存加回去。这套最终一致性方案在正常情况下都能自愈极端情况依靠后面讲的对账任务兜底。4.2 分布式锁的注意事项抽奖系统里其实不一定需要分布式锁因为Lua脚本已经保证了库存扣减的原子性。但在一些更高层级的操作上比如活动配置变更、奖品库存批量修改这种同时只能有一个实例执行的操作分布式锁还是必要的。用Redis实现分布式锁时有两个坑非常容易踩。第一个坑是加锁和设置过期时间必须用一条命令也就是SET key value NX PX 30000不能拆成SETNX和EXPIRE两条命令否则加了锁还没来得及设过期时间就宕机锁永远无法释放。func AcquireLock(ctx context.Context, rdb *redis.Client, lockKey string, token string, ttl time.Duration) (bool, error) { ok, err : rdb.SetNX(ctx, lockKey, token, ttl).Result() if err ! nil { return false, err } return ok, nil } func ReleaseLock(ctx context.Context, rdb *redis.Client, lockKey string, token string) error { script : redis.NewScript( if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) end return 0 ) return script.Run(ctx, rdb, []string{lockKey}, token).Err() }第二个坑是释放锁的时候必须校验value是不是自己之前设置的那个。用上面这段Lua脚本来释放锁判断GET的返回值是否等于token相等才删除。不然的话如果锁过期了某个新请求正好拿到了同一把锁旧请求去释放锁会误删新请求的锁后续事务就乱套了。4.3 缓存穿透、击穿、雪崩的处理抽奖系统的缓存主要集中在活动状态和奖品库存上这类数据是热点中的热点。上线后要重点防三件事穿透、击穿、雪崩。缓存穿透指的是请求查一个不存在的key比如活动ID传一个根本不存在的值这时Redis查不到请求会直接打到MySQL。解法很简单在活动状态查询时如果Redis中不存在该key就返回一个空值或兜底状态同时设置一个较短的过期时间比如60秒。我实际做的时候对活动状态还会加一层校验活动ID必须存在于数据库中的活动表里。缓存击穿是热点key在瞬间过期大量请求同时从MySQL加载数据。解决方案是互斥锁获取到锁的请求负责重新加载缓存其他请求短暂等待。抽奖活动中活动状态key设计成本来就是为了避免频繁过期所以一般不会出现击穿问题但奖品库存这种高频变化的key要特别留意。缓存雪崩是大量key同时过期导致的。在抽奖系统里活动状态和用户计数key都会设置过期时间如果某个时间点同时过期数据库压力瞬间拉满。我的解决办法是对过期时间加入随机偏移比如基础过期时间加上0到300秒的随机数把过期时间打散。5. 典型问题与排障实录5.1 Redis连接池耗尽的排查上线后第一次大规模活动压测Redis的响应突然变慢查看日志发现大量pool exhausted的报错。问题出在go-redis默认连接池大小上默认值只有10 * GOMAXPROCS在并发压力大时完全不够用。解决办法是在初始化Redis客户端时显式配置连接池参数rdb : redis.NewClient(redis.Options{ Addr: 127.0.0.1:6379, DialTimeout: 5 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, PoolSize: 100, MinIdleConns: 20, })把PoolSize调大到100后再压测就稳定了。注意MinIdleConns建议保留20-30个空闲连接避免流量来临时反复建连。5.2 xorm连接池配置与慢SQLxorm的数据库连接池也不能忽略我刚开始没配结果活动期间连接一直新建MySQL端报Too many connections。在初始化xorm引擎时加了这几行配置engine, err : xorm.NewEngine(mysql, dsn) engine.SetMaxIdleConns(10) engine.SetMaxOpenConns(100) engine.SetConnMaxLifetime(time.Hour)这里的关键是SetMaxOpenConns要跟MySQL的max_connections配置匹配。以前有次我把MaxOpenConns设成500数据库一下就承载不住了。一般控制在100以内MySQL单机同时连接数别超500这是经验值。慢SQL的排查也有个技巧在xorm里开启SQL日志开发环境设置engine.ShowSQL(true)生产环境可以通过日志分析平台收集执行时间超过200ms的SQL。以前发现过一个典型慢查询就是联合索引(activity_id, user_id, created_at)顺序搞反了用户高并发查询时全表扫描调整索引后性能立刻提升。5.3 中奖记录和Redis库存不一致正常情况下Redis扣减和MySQL落库是最终一致的。但极端情况比如数据库抖动MySQL事务失败Redis库存已经扣了这时就会不一致。我的解法是每天凌晨跑一个对账任务扫描所有活动的奖品用MySQL的remaining_count和Redis中的stock作对比不一致时以MySQL为准同时把Redis库存修正。这个对账任务用协程并发查速度很快整个活动期间几万条奖品记录几分钟就能扫完。跑了一段时间后我发现绝大多数不一致都发生在数据库连接超时的场景下所以后来我在落库代码里加了重试机制连续失败3次才真正放弃对账任务的告警量明显减少了。5.4 压测数据与性能调优参考最后说下压测结果给关注性能的同学一个参考。测试环境是4核8G的虚拟机Redis单节点MySQL单实例用wrk压测抽奖接口最终稳定在2000并发下QPS达到8000左右P99响应时间约50ms库存扣减和落库流程没有出现超卖或丢单。如果这个量级还不满足下一步的方向是Redis集群分摊热点以及把抽奖链路拆出来单独部署用消息队列承接落库请求做异步处理。不过对于大多数业务场景上面的架构已经足够稳了。我在实际开发中发现抽奖系统这类峰值流量型的应用最大的风险往往不在一行代码逻辑而在并发控制手段是否齐备。Lua脚本扣库存、唯一索引防重、合理连接池配置、缓存预热这四件事做扎实系统基本不会出大问题。这次lottery_system的完整实现就是一个可以直接参考的落地方案。本文还有配套的精品资源点击获取
返回列表