☰
Go语言高并发抽奖系统实战:iris+xorm+MySQL+Redis防超卖
2026/10/8 3:47:32 网站建设 项目流程

简介:这是一套基于 Go 语言的企业级抽奖系统源码,采用 iris 框架搭配 xorm、MySQL 与 Redis 构建,适合具备一定 Go 后端基础、希望深入理解高并发抽奖业务与后台管理的开发者学习参考。系统围绕六张核心数据表展开,涵盖黑名单、虚拟券、奖品、获奖记录、用户及每日抽奖情况,完整支撑抽奖次数限制、奖品库存与中奖概率调整、中奖记录查询及作弊标记等业务逻辑。资源包共 103 个文件,约 1.28MB,其中 54 个 go 源文件承载核心业务与数据访问逻辑,12 个 html 与 9 个 js、4 个 css 构成后台管理界面,另有 13 张 png 图片及 sql 建表脚本、rdb 持久化文件等辅助内容。目前已有 230 人学习下载。读者可借此梳理抽奖系统的分层结构、数据库表设计与 Redis 缓存应用,理解奖品发放周期、虚拟券状态流转及后台管理模块的实现思路,为二次开发或同类项目搭建提供可复用的参考。

1. 从一张抽奖表说起:iris + xorm + mysql + redis 到底怎么搭出企业级抽奖系统

电商大促晚上八点,运营在后台点下「开始抽奖」,三万人同时涌进来。如果每次抽奖都直接SELECT ... FOR UPDATE锁库存行,数据库连接池几分钟就被打满,前端开始转圈,运营在群里问「是不是挂了」。这个场景就是 lottery_system 要解决的核心问题:把「高并发写」和「库存一致性」拆开,用 iris 做 HTTP 层、xorm 做 ORM、mysql 存持久化数据、redis 扛住瞬时流量。iris 是 Go 语言里性能靠前的 Web 框架,路由和中间件写起来直接;xorm 是 Go 的 ORM,支持自动建表、乐观锁、缓存;mysql 负责订单和奖品库存的最终落库;redis 负责原子扣减、分布式锁和热点数据缓存。适合谁看:已经会写 Go 基础语法、想做一个能扛住几千 QPS 的抽奖后端、或者正在被「超卖」和「重复中奖」折磨的工程师。下面按「先跑通最小闭环,再补并发和一致性,最后处理缓存和排查」的顺序展开,每一步都给可复现的命令和代码。

2. 环境搭建与最小可运行闭环:把 iris + xorm + mysql + redis 串起来

2.1 依赖安装与项目骨架

先确认本机有 Go 1.20 以上、mysql 8.0、redis 7.x。mysql 安装时注意字符集用utf8mb4,否则奖品名称里的 emoji 会报错。redis 在 Windows 上可以用 docker 跑,命令如下:

# 启动 redis,映射 6379,开启 AOF 持久化 docker run -d --name lottery-redis \ -p 6379:6379 \ -v /data/redis:/data \ redis:7-alpine redis-server --appendonly yes # 启动 mysql,设置 root 密码和默认字符集 docker run -d --name lottery-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=lottery \ mysql:8.0 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

逻辑说明:redis 用 AOF 是为了重启后扣减记录不丢;mysql 指定utf8mb4是因为抽奖系统里奖品描述常带特殊符号。参数上,-v /data/redis:/data把容器数据挂到宿主机,避免容器删除后数据丢失。如果本机 3306 被占用,把-p 3306:3306改成-p 3307:3306,后面连接串同步改端口。

项目骨架按 iris 官方推荐分层:

lottery_system/ ├── main.go ├── config/ │ └── config.yaml ├── models/ │ └── prize.go ├── services/ │ └── lottery.go ├── controllers/ │ └── lottery.go └── dao/ └── engine.go

2.2 用 xorm 建表和初始化引擎

奖品表和抽奖记录表是核心。xorm 支持通过 struct tag 自动同步表结构,省去手写 DDL。

// models/prize.go package models import "time" type Prize struct { Id int64 `xorm:"pk autoincr 'id'" json:"id"` Name string `xorm:"varchar(128) notnull 'name'" json:"name"` Stock int `xorm:"int notnull default 0 'stock'" json:"stock"` Weight int `xorm:"int notnull default 1 'weight'" json:"weight"` CreatedAt time.Time `xorm:"created 'created_at'" json:"created_at"` UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updated_at"` } type LotteryRecord struct { Id int64 `xorm:"pk autoincr 'id'" json:"id"` UserId int64 `xorm:"index notnull 'user_id'" json:"user_id"` PrizeId int64 `xorm:"notnull 'prize_id'" json:"prize_id"` CreatedAt time.Time `xorm:"created 'created_at'" json:"created_at"` }
// dao/engine.go package dao import ( "github.com/go-xorm/xorm" _ "github.com/go-sql-driver/mysql" "lottery_system/models" ) var Engine *xorm.Engine func InitDB(dsn string) error { var err error Engine, err = xorm.NewEngine("mysql", dsn) if err != nil { return err } Engine.SetMaxOpenConns(200) // 最大连接数,按 mysql max_connections 的 70% 设 Engine.SetMaxIdleConns(50) // 空闲连接,避免频繁建连 Engine.ShowSQL(true) // 开发期打开,上线关掉 return Engine.Sync2(new(models.Prize), new(models.LotteryRecord)) }

逻辑说明:Sync2会自动建表或加字段,但不会删字段,生产环境建议关掉自动同步,改用 SQL 迁移。SetMaxOpenConns设 200 是因为 mysql 默认max_connections是 151,直接设 200 会报too many connections,需要先在 mysql 里SET GLOBAL max_connections = 500;。ShowSQL上线必须关,否则日志量会拖慢磁盘。

2.3 iris 路由与抽奖接口的最小实现

// main.go package main import ( "github.com/kataras/iris/v12" "lottery_system/dao" "lottery_system/services" ) func main() { app := iris.New() if err := dao.InitDB("root:root123@tcp(127.0.0.1:3306)/lottery?charset=utf8mb4&parseTime=true"); err != nil { panic(err) } app.Post("/api/lottery/draw", func(ctx iris.Context) { userId := ctx.URLParamInt64Default("user_id", 0) if userId == 0 { ctx.JSON(iris.Map{"code": 400, "msg": "user_id required"}) return } prize, err := services.Draw(userId) if err != nil { ctx.JSON(iris.Map{"code": 500, "msg": err.Error()}) return } ctx.JSON(iris.Map{"code": 0, "prize": prize}) }) app.Listen(":8080") }

逻辑说明:iris 的ctx.URLParamInt64Default直接取 query 参数并给默认值,省去手动转换。抽奖逻辑放在 service 层,controller 只做参数校验和响应封装。参数上,user_id必传,否则无法防重。启动后curl -X POST "http://127.0.0.1:8080/api/lottery/draw?user_id=1001"应该返回奖品或「未中奖」。

3. 并发扣减与防重:redis 原子操作 + mysql 乐观锁怎么配合

3.1 为什么不能直接用 mysql 扣库存

假设奖品库存 100,三万人同时抽。如果 service 里写UPDATE prize SET stock = stock - 1 WHERE id = ? AND stock > 0,mysql 行锁会让请求排队,InnoDB 的锁等待超时默认 50 秒,大量请求会卡在Lock wait timeout exceeded。更糟的是,如果先SELECT stock再UPDATE,两个请求都读到 1,都扣成功,库存变成 -1,这就是超卖。常见做法是:redis 里放库存计数,用DECR原子扣减,扣到 0 直接返回「已抽完」,只有 redis 扣减成功的请求才去 mysql 落订单。这样 mysql 的压力从「三万人」降到「一百人」。

3.2 redis 预扣库存与分布式锁

// services/lottery.go package services import ( "errors" "github.com/go-redis/redis/v8" "lottery_system/dao" "lottery_system/models" "context" ) var Rdb *redis.Client var ctx = context.Background() func InitRedis(addr string) { Rdb = redis.NewClient(&redis.Options{ Addr: addr, Password: "", DB: 0, PoolSize: 100, // 连接池大小,按 QPS 的 1/10 设 }) } // 预扣库存,返回 true 表示扣减成功 func preDecrStock(prizeId int64) (bool, error) { key := "lottery:stock:" + strconv.FormatInt(prizeId, 10) // 用 Lua 保证「判断存在 + 扣减」原子 script := redis.NewScript(` if redis.call("EXISTS", KEYS[1]) == 0 then return -1 end local stock = tonumber(redis.call("GET", KEYS[1])) if stock <= 0 then return 0 end redis.call("DECR", KEYS[1]) return 1 `) res, err := script.Run(ctx, Rdb, []string{key}).Int() if err != nil { return false, err } if res == -1 { return false, errors.New("stock not initialized") } if res == 0 { return false, errors.New("stock empty") } return true, nil }

逻辑说明:Lua 脚本在 redis 里单线程执行,EXISTS和DECR之间不会被其他请求插入,避免「判断有库存但扣减时已被抢完」的竞态。参数上,PoolSize设 100 是因为 redis 单连接吞吐有限,连接池太小会导致redis: connection pool timeout。库存 key 用lottery:stock:{prizeId},初始化时用SET lottery:stock:1 100。

3.3 mysql 乐观锁落订单与防重

redis 扣减成功后,还要防同一用户重复中奖。用 mysql 唯一索引 + 乐观锁:

func Draw(userId int64) (*models.Prize, error) { // 1. 选奖品,这里简化成固定奖品 id=1 prizeId := int64(1) ok, err := preDecrStock(prizeId) if !ok { return nil, err } // 2. 查奖品详情 prize := new(models.Prize) has, err := dao.Engine.ID(prizeId).Get(prize) if err != nil || !has { return nil, errors.New("prize not found") } // 3. 乐观锁扣 mysql 库存 affected, err := dao.Engine.Exec( "UPDATE prize SET stock = stock - 1, updated_at = NOW() WHERE id = ? AND stock > 0", prizeId, ) if err != nil { return nil, err } rows, _ := affected.RowsAffected() if rows == 0 { // mysql 扣减失败,回滚 redis Rdb.Incr(ctx, "lottery:stock:"+strconv.FormatInt(prizeId, 10)) return nil, errors.New("mysql stock empty") } // 4. 写抽奖记录,user_id + prize_id 唯一索引防重 _, err = dao.Engine.Exec( "INSERT INTO lottery_record (user_id, prize_id, created_at) VALUES (?, ?, NOW())", userId, prizeId, ) if err != nil { // 重复插入,回滚库存 dao.Engine.Exec("UPDATE prize SET stock = stock + 1 WHERE id = ?", prizeId) Rdb.Incr(ctx, "lottery:stock:"+strconv.FormatInt(prizeId, 10)) return nil, errors.New("already drawn") } return prize, nil }

逻辑说明:第 3 步的stock > 0是乐观锁条件,RowsAffected为 0 说明 mysql 库存已空,必须把 redis 预扣的库存加回去,否则 redis 和 mysql 会不一致。第 4 步依赖lottery_record表上(user_id, prize_id)的唯一索引,重复插入会报错,捕获后同样回滚。参数上,唯一索引建表时加:ALTER TABLE lottery_record ADD UNIQUE KEY uk_user_prize (user_id, prize_id);。注意回滚操作本身也可能失败,生产环境要把失败的回滚写进补偿队列,不能只打日志。

4. 避坑与排查:抽奖系统上线后最容易翻车的 5 个点

4.1 现象:redis 扣减成功但 mysql 没订单,库存对不上

原因:redis 预扣后,mysql 扣减或插入记录失败,代码里回滚逻辑没执行或执行失败。解决:每次 redis 扣减前,先在 mysql 写一条「预扣流水」,状态为 pending;mysql 落订单成功后更新为 success;定时任务扫 pending 超过 10 秒的记录,把 redis 库存加回。这样即使进程崩溃,也能靠流水补偿。

4.2 现象:同一用户抽到两次奖

原因:只靠 redis 防重,但 redis 没做用户维度的 set,或者 mysql 唯一索引没建。解决:redis 里用SADD lottery:users:{prizeId} {userId},返回 0 表示已抽过;同时 mysql 唯一索引兜底。注意SADD和DECR不在同一个 Lua 里会有竞态,要合并成一个脚本。

4.3 现象:redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException

原因:连接池太小或 redis 单线程被大 key 阻塞。解决:检查PoolSize是否小于并发数;用redis-cli --bigkeys找大 key;抽奖系统的库存 key 都是小 key,但抽奖记录如果全塞 redis 会变大,记录只存 mysql。参数上,PoolSize设成预估 QPS 的 1/5 到 1/10,ReadTimeout设 500ms 以内。

4.4 现象:mysql 报Lock wait timeout exceeded

原因:抽奖高峰期还有别的业务在锁 prize 表,或者事务没提交。解决:抽奖的 mysql 操作单独用短事务,UPDATE完立即提交;不要在事务里做 HTTP 调用或 redis 操作。参数上,innodb_lock_wait_timeout默认 50 秒,可以调到 5 秒快速失败,避免请求堆积。

4.5 现象:xorm 的Sync2把生产表字段改了

原因:开发期开了Sync2,上线没关,struct 改字段后自动加列,可能锁表。解决:生产环境用 SQL 迁移工具,Sync2只在本地和测试环境用。xorm 引擎初始化时加Engine.ShowSQL(false)和Engine.Logger().SetLevel("warn"),减少日志开销。

5. 压测验证与库存对账:怎么确认这套方案真的扛得住

5.1 用 wrk 做最小压测

# 安装 wrk 后,对抽奖接口压 30 秒,12 个线程,400 个连接 wrk -t12 -c400 -d30s -s post.lua http://127.0.0.1:8080/api/lottery/draw

post.lua内容:

wrk.method = "POST" wrk.body = "" wrk.headers["Content-Type"] = "application/json" -- 每个请求带不同 user_id,避免被防重拦截 request = function() local uid = math.random(1, 1000000) return wrk.format("POST", "/api/lottery/draw?user_id=" .. uid) end

逻辑说明:-t12是 12 个线程,-c400是 400 个并发连接,-d30s压 30 秒。request函数每次生成随机 user_id,模拟真实用户。压测时观察三个指标:iris 的 QPS、redis 的instantaneous_ops_per_sec、mysql 的Threads_running。如果 QPS 上不去但 CPU 不高,多半是 redis 连接池或 mysql 连接池满了。

5.2 库存对账脚本

压测结束后,redis 库存和 mysql 库存必须一致。写一个对账脚本:

func CheckStock(prizeId int64) (int, int, error) { redisStock, err := Rdb.Get(ctx, "lottery:stock:"+strconv.FormatInt(prizeId, 10)).Int() if err != nil { return 0, 0, err } var mysqlStock int _, err = dao.Engine.SQL("SELECT stock FROM prize WHERE id = ?", prizeId).Get(&mysqlStock) if err != nil { return 0, 0, err } return redisStock, mysqlStock, nil }

逻辑说明:正常情况下两者应该相等。如果 redis 比 mysql 大,说明有预扣没落库,需要补偿;如果 redis 比 mysql 小,说明有回滚没执行,需要人工介入。参数上,对账脚本建议每 5 分钟跑一次,结果写入监控。

5.3 我踩过的一个坑

早期版本我把 redis 库存初始化和 mysql 库存同步放在一个定时任务里,结果定时任务执行时把正在扣减的库存覆盖了,导致超卖。后来改成:redis 库存只在系统启动时从 mysql 加载一次,之后所有变更都走 Lua 脚本,定时任务只做对账告警,不直接改 redis。这个习惯我一直保留:任何「同步」操作都要先确认不会覆盖正在进行的写操作。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询