Day 4:从 SQL-only 到 Redis + Lua 的抽奖优化实战
本文记录今天将抽奖核心从“纯数据库事务”升级为“Redis + Lua 原子扣减 + 异步落库”的过程、优化点、踩坑与结果。
目标与背景
- 目标:在保证功能正确与可观测的前提下,提高单机并发与整体吞吐,缓解数据库热点与锁竞争。
- 背景:V1 方案所有请求走 PostgreSQL 事务,存在连接/锁瓶颈;V2 引入 Redis 作为热点写入层与限流去重层。
架构与实现
- docker 多阶段构建:
- builder(rust:1)编译产物;runtime(debian-slim)只运行三进制:
fast-lottery-engine/migrate/db_prepare。 - entrypoint 顺序:migrate → db_prepare → 启动服务。
- builder(rust:1)编译产物;runtime(debian-slim)只运行三进制:
- 环境配置:本机
.env与容器.docker.env分离;compose 使用env_file: .docker.env,端口映射 app 18080、PG 15432、Redis 16379。 - 抽奖路径(新):
- 读取“启用奖品”的权重信息(内存缓存,定时刷新)。
- 本地加权随机选中候选奖品。
- 调用 Redis Lua 原子脚本:
lottery:cooldown:{uid}冷却键(限频)。lottery:stock:{prize_id}库存键,DECR。lottery:sold:{prize_id}增量键,INCR(用于回写 DB)。
- 结果异步写入
lottery_records,并更新users.last_lottery_at。
- 数据同步:
- 初始化:
db_prepare从 DB 读取各奖品remaining_count,写入lottery:stock:*,并清理lottery:sold:*。 - 回写:后台任务每 5s 执行一次
GETDEL lottery:sold:*,将增量安全扣减回 PostgreSQL(避免竞态)。
- 初始化:


性能优化点
- 全局 Redis 连接管理器:使用
OnceCell复用连接,避免每请求建连导致的 QPS 下降。 - 内存奖品缓存:每 800ms 刷新一次启用奖品与权重,抽奖不再每次查询 DB。
- 异步落库:抽奖记录与用户最近时间通过
tokio::spawn后台写入,减少请求临界路径阻塞。 - 统计输出改为毫秒,便于研判尾延迟。
基准结果(本机环境)
- 纯数据库(参考历史):约 4k QPS。
- 初版 Redis(未复用连接、无缓存):约 2.0–2.6k QPS。
- 最终方案(连接复用 + 内存缓存 + 异步落库):
- ops=10000, conc=256, time≈296ms, QPS≈33,780
- 延迟(ms):avg≈6.36, p50≈5.23, p95≈11.57, p99≈33.24
说明:数据受本机硬件、网络与运行时状态影响,仅供相对比较。
踩过的坑与修复
- 容器启动 cargo not found:使用 rust 基础镜像运行源码会因 PATH/环境不同导致找不到 cargo;改为多阶段构建,仅运行编译产物。
- compose 健康检查库名不一致:
fast_lottery→lottery,否则pg_isready失败。 - Redis Lua 调用需要
&mut连接:invoke_async需可变连接;初版误传&ConnectionManager导致编译错误。 - API 变更:
get_tokio_connection_manager已废弃,改用get_connection_manager。 - OnceCell 使用:
get_or_try_init需要|| async { ... }的闭包写法。 - 测试环境变量:
TEST_PG_URL未加载导致 nextest 失败;在各测试用例顶部显式dotenvy::dotenv()。 db_prepare重复/过期代码:清理重复的 Redis 种入逻辑与漏掉的分号。- 基准单位:输出从
us改为ms,更易阅读。
一致性与限制
- 进程内缓存存在一致性窗口(<1s),仅用于权重读取,不承载库存;库存仍以 Redis 为准。
- 增量回写采用
GETDEL,避免与扣减并发写入冲突;应用异常退出时,增量键仍保留,重启后继续回写。 - 当前仍为单实例 Redis 部署示例,未引入分布式分片与有序事件流,适合演示与单机/小规模压测。
技术决策与问答摘录
- 问:宿主机开发端口如何安排?EXPOSE 还是 ports?
- 决策:宿主机访问必须
ports映射;我们将 app/PG/Redis 改为非常用端口(18080/15432/16379)以避免冲突。
- 决策:宿主机访问必须
- 问:本地与容器环境变量如何隔离?
- 决策:采用
.env(本机)与.docker.env(容器)分离,compose 使用env_file: .docker.env;.docker.env忽略提交,并提供.docker.env.example。
- 决策:采用
- 问:app 容器应否编译源码?
- 决策:否。采用多阶段构建,仅运行已编译二进制,启动更快、镜像更小且避免 cargo/路径问题。
- 问:Redis 如何初始化库存?
- 决策:在
db_prepare中读取 DB 的remaining_count,写入lottery:stock:{prize_id},并清理lottery:sold:{prize_id};entrypoint 保证顺序:migrate → prepare → serve。
- 决策:在
- 问:扣减后如何同步回 DB?为什么不直接用
lottery:stock:{prize_id}覆盖 DB?- 决策:使用增量键
lottery:sold:{prize_id}+ 定时GETDEL回写,避免“读 stock 与并发 DECR 冲突”的竞态覆盖;绝对量覆盖需暂停写或引入版本/CAS,复杂且风险大。
- 决策:使用增量键
- 问:频控在哪里做?
- 决策:在 Lua 中设置/校验
lottery:cooldown:{uid},中奖与否都设置,避免重试风暴。
- 决策:在 Lua 中设置/校验
- 问:为什么加内存缓存?一致性如何?
- 决策:缓存仅存权重与可用列表,降低 DB 压力;缓存刷新周期 800ms,库存仍以 Redis 为准。若需更强一致性,后续改为“权重表存 Redis + 版本热更新”。
- 问:测试为何失败?
- 决策:nextest 默认不加载
.env,在各测试中显式dotenvy::dotenv();并要求设置TEST_PG_URL。
- 决策:nextest 默认不加载
- 问:基准延迟单位可读性?
- 决策:输出从 us 改为 ms,便于观察 p95/p99。
为什么更快
- 热路径不落库:库存扣减与频控都在 Redis 用 Lua 原子完成,避免 DB 事务锁与连接瓶颈。
- 连接与读优化:复用全局 Redis 连接;奖品权重用内存快照,避免每次 draw 查询 DB。
- 写入解耦:抽奖记录与用户时间异步写库,库存回写走增量批处理(GETDEL),显著缩短请求临界路径。
- 结果:从 ~4k QPS 提升到 ~33.8k QPS,尾延迟(p99)稳定在几十毫秒级。
后续演进方向(暂不实施)
- 全量权重/别名表存 Redis,Lua 内完成“抽签+扣减+限频”,应用节点无本地缓存。
- 配置热更新:版本键 + Pub/Sub(或 Keyspace 通知)广播,各节点热重载。
- 结果落库改为事件流(Redis Stream/Kafka),消费者落表与对账,支持重放。
- Redis Cluster 分片(按活动或 prize_id),进一步提升水平扩展能力。
最终压测结果:QPS≈33.8k,avg≈6.36ms,p99≈33.24ms;后续若需要,可以继续按“全 Redis 原子化 + 流式落库”的方向演进以追求更高吞吐与更强一致性。
