news 2026/10/13 3:46:15

高并发抽奖系统实战:iris+xorm+Redis 防超卖与原子扣减

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发抽奖系统实战:iris+xorm+Redis 防超卖与原子扣减

简介:这是一套基于 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 ping

go 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 并发的用例当验收门槛,不跑通不上线。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 3:46:15

Flutter汇率库curo鸿蒙化适配实战:精度与平台通道全解析

最近在做一批 Flutter 三方库的鸿蒙化适配&#xff0c;手头这个叫 curo 的库&#xff0c;算是我踩坑最深的之一。curo 在 Flutter 社区里不算最热的那一档&#xff0c;但功能指向非常明确&#xff1a;多币种汇率获取、货币换算、多币种资产按基准货币折算&#xff0c;以及一套自…

作者头像 李华
网站建设 2026/10/13 3:45:44

英文版Linux安装全程指南:从镜像到中文输入法

想装 Linux&#xff0c;但是一看官网下载的镜像默认是英文界面就犯怵&#xff1f;这大概是 Linux 新手咨询里出现频率最高的开场白了。我当年第一次装的时候也有这个顾虑&#xff0c;甚至为了规避英文&#xff0c;专门去找那种号称“中文版”的第三方改版镜像。结果装完才发现&…

作者头像 李华
网站建设 2026/10/13 3:45:30

LLaMA-Factory 微调实战:从 GitHub 下载到跑通第一条训练命令

简介&#xff1a;本资源为从 GitHub 下载的 LLama-factory 完整源码包&#xff0c;面向大模型微调初学者与工程实践者&#xff0c;可用于本地搭建 LLaMA 系列模型的训练、微调与推理环境。压缩包共 408 个文件&#xff0c;约 231.59MB&#xff0c;以 146 个 Python 源码、108 个…

作者头像 李华
网站建设 2026/10/13 3:45:30

博客小程序+SSM从部署到二次开发:毕设源码核心逻辑与避坑指南

市面上关于“博客小程序 SSM”的代码和文档其实不少&#xff0c;但大多数要么只给源码不讲思路&#xff0c;要么文档写得像产品说明书&#xff0c;真正能让人照着做完、做完还能讲清楚原理的资料并不多见。我手上这套“weixin162博客小程序ssm(文档源码)_kaic”就是典型的毕设…

作者头像 李华
网站建设 2026/10/13 3:44:46

LeetCode两数相加:链表模拟竖式加法与进位处理全解析

先把结论放在前头&#xff1a;这道「两数相加」在 LeetCode 热题 100 里的出现频率非常高&#xff0c;但很多人第一反应是“把链表转成整数直接加”&#xff0c;写完一提交才发现溢出。它考的其实不是大数运算本身&#xff0c;而是你愿不愿意接受“链表的每一个节点只存一位数”…

作者头像 李华