简介:这是一套基于 Go 语言、采用 iris 框架搭配 xorm、MySQL 与 Redis 构建的企业级抽奖系统源码,面向具备一定 Go 后端基础、希望深入理解高并发抽奖业务与后台管理的开发者。系统围绕黑名单、虚拟券、奖品、获奖记录、用户及每日抽奖情况共六张数据表展开,覆盖中奖记录管理与奖品管理两大后台模块,支持按用户或奖品维度检索中奖信息、标记作弊、调整库存与中奖概率等操作。资源包共 103 个文件,以 54 个 go 源码为核心,辅以 12 个 html 页面、9 个 js 与 4 个 css 前端资源,另有 png 图片、sql 建表脚本及 rdb 持久化文件,整体约 1.28MB,结构紧凑。目前已有 230 人学习。借助完整的分层代码与数据库设计,读者可快速理清抽奖流程、概率控制与状态位管理思路,并在此基础上二次开发或用于课程设计参考。
1. 抽奖系统从“能跑”到“能扛”:这套 iris + xorm + MySQL + Redis 组合到底解决了什么
电商大促晚上八点,运营在后台点下“开始抽奖”,瞬时涌进来的请求能把一台单机服务打到 CPU 飙满、MySQL 连接池直接爆掉,最后用户看到的是转圈和 500。很多团队第一次做抽奖,代码逻辑其实不复杂——查库存、扣库存、发奖、记流水,但一旦并发上来,超卖、重复中奖、奖品发多了这些事全冒出来。这套lottery_system用 iris 做 Web 层、xorm 做 ORM、MySQL 存持久数据、Redis 扛热点和原子操作,本质上是把“抽奖”这个看似简单的业务,拆成了高并发下能站得住的一套工程结构。它适合谁?适合已经会写 Go 基础接口、但没真正处理过秒杀级并发扣减的开发者,也适合想找一个完整分层项目来对照自己代码的熟手。下面我按“它怎么组织、怎么跑起来、坑在哪、怎么验证”一路拆下去。
2. 分层结构与选型逻辑:为什么是 iris + xorm + Redis 这套组合
2.1 四层职责划分与请求流转路径
这套系统的目录结构通常按controller / service / model / dao或者api / logic / data来切。iris 负责路由注册、参数绑定、中间件(限流、鉴权、日志),它本身性能不错,路由基于 radix tree,比标准库net/http写起来省事,又不像某些重框架那样把业务绑死。xorm 作为 ORM,把 MySQL 表映射成 Go struct,支持自动建表、事务、乐观锁版本字段,写 CRUD 比手搓database/sql快很多。Redis 在这里不是缓存装饰,而是核心:奖品库存扣减、用户抽奖次数限制、防重放 token 都靠它的原子命令。
一次抽奖请求的典型路径是:iris 路由收到POST /api/lottery/draw→ 中间件校验登录态和签名 → service 层先查 Redis 里该活动的库存和用户剩余次数 → 用 Lua 脚本原子扣减 → 扣成功再写 MySQL 流水和发奖记录 → 返回中奖结果。注意顺序:先 Redis 后 MySQL,因为 Redis 扛得住瞬时高并发,MySQL 只承接最终落库,压力被削峰。
2.2 为什么库存扣减必须放 Redis 而不是直接打 MySQL
MySQL 的行锁在几百并发下就开始排队,UPDATE prize SET stock = stock - 1 WHERE id = ? AND stock > 0虽然能防超卖,但每次都要走磁盘事务,QPS 上不去。Redis 单机十万级 QPS,用DECR或 Lua 脚本判断并扣减,能把绝大多数无效请求挡在数据库之前。常见做法是活动开始前把 MySQL 里的库存预热到 Redis,抽奖时只认 Redis 的数,异步或定时把最终结果同步回 MySQL。
# 活动开始前预热库存:把 MySQL 的 prize.stock 写入 Redis # key 命名建议带活动 ID,避免多活动串数据 redis-cli SET lottery:stock:act_1001 500 redis-cli SET lottery:user_limit:act_1001:uid_9527 3上面两条命令分别设置活动总库存和某用户的剩余抽奖次数。key 里带act_1001是活动维度隔离,uid_9527是用户维度隔离。参数怎么改:库存值要和 MySQL 里该活动实际可发数量一致,用户次数按运营规则设。失败时先看redis-cli GET返回是不是 nil,nil 说明预热脚本没跑或 key 过期了。
2.3 xorm 映射与表结构约定
xorm 的 struct tag 决定了字段映射,xorm:"'stock' int"这种写法要留意引号,MySQL 里stock不是保留字但养成加引号习惯能少踩坑。下面是一个奖品表的典型定义:
type Prize struct { Id int64 `xorm:"pk autoincr 'id'"` ActId int64 `xorm:"index 'act_id'"` Name string `xorm:"varchar(64) 'name'"` Stock int `xorm:"int 'stock'"` Version int `xorm:"version 'version'"` // 乐观锁版本字段 Created time.Time `xorm:"created 'created'"` }pk autoincr声明主键自增,index给活动 ID 建索引,因为查询几乎都带活动维度。version字段是 xorm 的乐观锁支持,更新时会自动带上版本比对,防止并发覆盖。逻辑说明:定义好 struct 后用engine.Sync2(new(Prize))可以自动建表,但生产环境我一般手动写 DDL,避免 ORM 改错字段类型。参数上varchar(64)对奖品名够用,如果要做多语言得放宽到 128。
3. 把项目跑起来:环境、配置与核心接口实现
3.1 依赖安装与配置文件落地
先把 Go 环境和依赖拉齐。项目用 go modules 管理,go.mod里会有 iris、xorm、go-redis 这几个核心包。
# 初始化并拉取依赖(版本以项目 go.mod 为准,不要盲目升级) go mod tidy # 编译 go build -o lottery_system main.go # 运行前确认 MySQL 和 Redis 可达 mysql -h 127.0.0.1 -P 3306 -u root -p -e "CREATE DATABASE IF NOT EXISTS lottery DEFAULT CHARSET utf8mb4;" redis-cli pinggo mod tidy会根据 import 自动补全依赖,比手动go get稳。建库时指定utf8mb4,否则奖品名里的特殊字符会乱码。redis-cli ping返回 PONG 才说明 Redis 通了。配置文件一般是config.yaml或conf/app.ini,里面写 MySQL 的 DSN、Redis 地址端口、服务监听端口。DSN 形如root:pass@tcp(127.0.0.1:3306)/lottery?charset=utf8mb4&parseTime=true,parseTime=true必须加,否则 xorm 读时间字段会报错。
3.2 抽奖核心逻辑:Lua 脚本保证原子性
抽奖最关键的一步是“判断库存 + 扣减 + 记录用户次数”必须原子完成,否则并发下会超卖。用 Redis Lua 脚本把这几步打包:
-- draw.lua:原子扣减库存并记录用户抽奖次数 -- KEYS[1] = 库存 key, KEYS[2] = 用户次数 key -- ARGV[1] = 本次扣减数量(通常1), ARGV[2] = 用户次数上限 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil or stock < tonumber(ARGV[1]) then return -1 -- 库存不足 end local used = tonumber(redis.call('GET', KEYS[2]) or "0") if used >= tonumber(ARGV[2]) then return -2 -- 超过个人抽奖次数 end redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('INCR', KEYS[2]) return stock - tonumber(ARGV[1]) -- 返回剩余库存逻辑说明:脚本先读库存,nil 或不足返回 -1;再读用户已用次数,达到上限返回 -2;都通过才扣库存、加次数,返回剩余值。Redis 执行 Lua 是单线程原子的,中间不会被其他请求插入,这就杜绝了“两个请求同时读到库存 1 都扣成功”的超卖。参数上ARGV[1]一般传 1,如果做十连抽就传 10;ARGV[2]是运营配的个人上限。Go 侧用redis.NewScript加载并Run调用,注意每次调用要传对 KEYS 和 ARGV 的顺序。
3.3 中奖结果落库与事务处理
Redis 扣减成功后,要把中奖记录写进 MySQL。这一步用 xorm 的 session 事务:
sess := engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } record := &LotteryRecord{ActId: actId, UserId: uid, PrizeId: prizeId, Status: 1} if _, err := sess.Insert(record); err != nil { sess.Rollback() return err } // 同步扣减 MySQL 库存,带乐观锁 affected, err := sess.Exec("UPDATE prize SET stock = stock - 1 WHERE id = ? AND stock > 0", prizeId) if err != nil || affected == 0 { sess.Rollback() return errors.New("db stock deduct failed") } return sess.Commit()逻辑说明:先开事务,插入中奖流水,再更新 MySQL 库存,任一步失败就回滚。affected == 0说明 MySQL 侧库存已经不够,虽然 Redis 扣成功了,但这里要回滚并考虑补偿(把 Redis 库存加回去)。参数上Status: 1表示待发放,后续发奖流程再改成 2。注意这里 MySQL 库存是“最终一致”的兜底,正常流量下 Redis 已经挡住了,走到这一步的请求量不大。
4. 避坑与排查:并发扣减、连接池和缓存一致性
4.1 现象:压测时出现超卖,奖品发多了
原因:库存判断和扣减分成了两条 Redis 命令,比如先GET再DECR,两条命令之间被其他请求插入,两个请求都读到库存 1,都执行了 DECR,库存变成 -1。解决:必须用 Lua 脚本或DECR的返回值判断,把判断和扣减合成一个原子操作。我一般直接看DECR返回后是否小于 0,小于 0 再INCR补回来并返回失败,但更稳的还是 Lua。
4.2 现象:服务跑一段时间后报 “too many connections”
原因:xorm 的 engine 默认连接池没设上限,或者每次请求都NewSession却没Close,连接泄漏。解决:初始化时设置engine.SetMaxOpenConns(100)和SetMaxIdleConns(20),并且每个 session 用defer sess.Close()。Redis 客户端同理,go-redis的PoolSize要按并发量调,默认 10 在高并发下会成瓶颈。
4.3 现象:Redis 库存和 MySQL 库存对不上
原因:Redis 扣了但 MySQL 写失败回滚了,或者 MySQL 扣了但 Redis 没同步。解决:加对账任务,定时比对两边差值,以 MySQL 为准修正 Redis;或者用消息队列把扣减事件异步落库,保证最终一致。常见做法是记录每次 Redis 扣减的流水,后台 job 扫描未落库的记录补写。
4.4 现象:活动结束后 Redis key 没清理,下次活动读到旧库存
原因:key 命名没带活动 ID,或者活动结束没删 key。解决:key 强制带act_id,活动结束时DEL对应 key,或者设 TTL 让它在活动结束后自动过期。我一般给库存 key 设一个比活动结束时间稍长的 TTL 作为后悔药。
4.5 现象:Lua 脚本报 “NOSCRIPT No matching script”
原因:用了EVALSHA但脚本没先加载,或者 Redis 重启后脚本缓存丢了。解决:用redis.NewScript封装,它内部会先尝试EVALSHA,失败自动回退EVAL,不用手动管。如果自己手写EVALSHA,要在服务启动时先SCRIPT LOAD。
5. 进阶验证:用压测和日志确认系统真的扛得住
跑通不代表扛得住,得验证。我一般用wrk或hey对抽奖接口打一轮,观察 Redis 库存是否精确扣到 0、MySQL 流水条数是否和成功次数一致。
# 用 hey 压测抽奖接口,200 并发,总共 5000 请求 hey -n 5000 -c 200 -m POST \ -H "Content-Type: application/json" \ -d '{"act_id":1001,"user_id":9527}' \ http://127.0.0.1:8080/api/lottery/draw压测后做三件事核对:第一,redis-cli GET lottery:stock:act_1001看剩余库存是否等于初始值减去成功次数;第二,SELECT COUNT(*) FROM lottery_record WHERE act_id=1001看流水条数;第三,看服务日志里有没有db stock deduct failed这类回滚记录。如果 Redis 剩余和 MySQL 流水对不上,说明补偿逻辑没生效。
再补一个边界验证:把库存设成 1,用 100 并发去打,正确结果是只有 1 个请求返回中奖,其余全部返回库存不足,且 Redis 库存为 0、MySQL 流水只有 1 条。这个用例能一次性暴露超卖和重复中奖两个问题。
从那以后我每次接抽奖类需求,都会先把“库存扣减必须原子”这条写进设计评审,再拿这个 1 库存 100 并发的用例当验收门槛,不跑通不上线。希望帮到你。
本文还有配套的精品资源,点击获取