Redis 主从下的库存一致性:我推翻了"付款前查库存"这个方案
主题:Redis 加固与分布式数据一致性
先说结论
Redis 主从是异步复制 ------ 主库挂掉的瞬间会丢最后几笔写。代码层无法 100% 消灭 这个本质,能做的是让丢失无害化。
为了做到这一点,我推翻了自己第一版方案。核心是三个认知:
- 闸门 / 账本模型 :秒杀成交资格在 Redis 预扣时就已经确定,DB 只是记账
- "查剩余库存"是不可能正确实现的 ------ 它无法区分"本单货已被扣掉(正常)"和"本单货没扣上(异常)"
- 对账必须分层 ,而且只能以账本为唯一权威基准
下面按"加固 → 踩坑 → 一致性设计"三层讲。
第一层:先把 Redis 本身加固好(四连坑)
现状(四连坑)
| 编号 | 问题 | 实测 |
|---|---|---|
| ① | 无密码 | requirepass 空;11 个服务无 SPRING_DATA_REDIS_PASSWORD |
| ② | 无 AOF | appendonly no,仅 RDB(save 3600 1 300 100 60 10000)→ 最坏可丢约 1 小时数据 (低写入时;高写入时几分钟);秒杀购买标记丢失 → 可能重复秒杀 |
| ③ | 无内存上限 | maxmemory 0 + noeviction → 写满拒绝写入 = 功能停摆 |
| ④ | 无自定义 conf | 裸 redis-server,CONFIG SET 重启即失 |
conf 设计(每行都能讲为什么)
conf
bind 0.0.0.0 # 容器内监听(Docker 网络隔离,非裸奔)
requirepass <密码> # 认证
maxmemory 256mb # 内存硬顶
maxmemory-policy volatile-lru # ★ 只淘汰带 TTL 的键 → 保护永久购买标记
appendonly yes # AOF
appendfsync everysec # 性能/安全平衡(秒杀标准做法)
dir /data
⭐ volatile-lru 而不是 allkeys-lru ------ 这是最值得讲的一个细节:
volatile-lru:只淘汰带 TTL 的键(缓存类)→seckill:reseckill:*这类永久购买标记无 TTL,永不被淘汰 ✅allkeys-lru:全部键可淘汰 → 可能误删购买标记 → 用户重复秒杀 ❌noeviction(当时的现状):写满直接拒绝写入 → 功能停摆而非降级 ❌
两个易漏的点:
- 挂载不加
:ro:哨兵 运行时会自动 REWRITE 自己的 conf(记录角色 / 主库地址等;Redis 服务端本身只在显式CONFIG REWRITE时写盘),只读挂载会导致故障切换失败/脑裂 - healthcheck 必须带密码 :加了
requirepass后redis-cli ping返回NOAUTH→ healthcheck 误判 unhealthy
⚠️ 真正踩到的坑:RDB 切 AOF 时数据全丢了
现象 :compose 重建后 DBSIZE=0(原 21 个键消失),数据卷里 dump.rdb 从 8157 字节变 89 字节(空库)。
根因链(三层):
- Redis 7 的启动逻辑 :
appendonly yes但 AOF 目录不存在时,不会回退加载已有的 RDB ,直接以空库启动并创建空 AOF ------ 日志里没有DB loaded from disk就是信号 - 我的"备份"无效 :
redis-cli SAVE把数据写回了同一个数据卷 的dump.rdb,并未复制到卷外 → 后续空库关闭时把它覆盖了 - 正确做法我写过但没执行 :重建前应先在线
CONFIG SET appendonly yes热开 AOF
正确迁移姿势(教训固化):
bash
# 数据还在内存时先热开 AOF(零停机,Redis 自动做首次 rewrite)
redis-cli CONFIG SET appendonly yes
# 确认 appendonlydir/ 生成后,再以 appendonly yes 配置重启容器
# 备份必须出卷:docker cp <redis容器>:/data/dump.rdb ./backup/
影响与自愈 :丢的是缓存数据(库存/随机码/购买标记/token 黑名单),非业务数据;DB 侧(success 唯一键 + 条件扣减)兜底不超卖;库存由定时任务每分钟自动重建。演示项目可接受,商业化前必须按正确姿势迁移。
第二层:主从切换的数据一致性
问题本质
markdown
秒杀链路:Redis 预扣(DECR 闸门,最多放行 N 单)
↓
MQ 异步 → DB 条件扣减(seckill_stock >= qty,逐单)
主从异步复制的意思是:主库在确认写入后立刻返回,从库稍后才收到 → 主库挂的那一刻,最后几笔写可能有去无回。
⭐ 我推翻了自己的第一版方案
曾计划 :"付款前查 DB 剩余库存" ------ 推演后发现语义上不成立:
| 缺陷 | 说明 |
|---|---|
| 缺陷 1(误拦已成交) | 库存一致时(Redis=DB=N),放行 N 单 MQ 全部扣成功,最后一件成交后 DB=0 是正常结果 ------ 其用户付款时查"剩余库存"会被误拦 。查剩余库存无法区分"本单货已被 MQ 扣掉(正常)"与"本单货没扣上(异常)" |
| 缺陷 2(防不了真问题) | 要防的"Redis 多放导致超卖",DB 条件扣减已兜底(rows==0 即不成交);查剩余库存既不拦"该拦的",又误拦"不该拦的" |
结论 :秒杀成交资格在 Redis 预扣时已确定 ,DB 只是记账(闸门/账本模型 )。→ 改为查"本单是否已成交"。
治本前置:用 order_type 区分秒杀单 / 普通单
原来的 bug 是:支付/取消时写/清购买标记的方法无条件执行 → 普通订单也被误当秒杀单写标记(污染限购)。
修复:订单表 加 order_type 列(Flyway 迁移,历史数据默认 0=普通):
- 秒杀入口置
orderType=1 - 普通入口强制
orderType=0(防前端伪造秒杀单) - 购买标记的写/清仅对秒杀单执行
方案Y:支付前校验"本单成交状态"
java
// 秒杀端新增对外服务
public interface IForOrderSeckillRecordService {
boolean isSeckillSuccessRecorded(String orderSn); // 查 success 表 order_sn 是否有记录
}
// 订单端支付前校验
if (isSeckillOrder(order)) {
validateSeckillRecordBeforePay(order); // 未落库则拦截支付,Dubbo 异常保守放行
}
为什么查"本单"而非"剩余库存":
- 成交单必有 success 记录 → 不误拦已成交的最后一件(解决缺陷 1)
- 未落库单(Redis 放行但 DB 扣减失败
rows==0)→ 拦截支付(解决缺陷 2)
P1 对账:分层修正漂移
为什么分层 (社区主流做法,不是我拍的):对账不能只放凌晨 (白天漂移持续伤用户),也不能只在高峰期(全量比对有并发干扰)。
| 层 | 调度 | 范围 | 阈值 |
|---------------|----------|---------------------|------|--------|----------------|--------|----------------------------|
| A 运行期轻量纠偏 | 每 5 分钟 | 只处理已预热的 sku | `\ | diff\ | ≥2 直接修;\ | diff\ | =1` 连续 3 次同向才修(容错瞬时差) |
| B 凌晨全量校准 | 每天 03:30 | 全量所有 sku + 补建缺失 key | `\ | diff\ | ≥1` 即修(低峰无并发) |
核心逻辑 :以 DB 为唯一权威基准修正 Redis:
redis > db(扣少了)→ 拉回 DB(防超卖)redis < db(多放行)→ 回补到 DB(防有货不能买)
诚实声明一个权衡 :运行期回补在 MQ 积压时,可能短暂让 Redis 大于真实可成交数 ------ 但最终仍会被 DB 的
seckill_stock >= qty拦下(rows==0不成交),不会真超卖,只是从"拦在 Redis"退化为"拦在 DB"(DB 条件扣减仍是最终防线)。
第三层:配置层的兜底 ------ min-replicas-to-write
让"没有健康从库确认"的主库拒绝写 ,把静默丢数据 变成明确报错:
conf
min-replicas-to-write 1
min-replicas-max-lag 10
这不是我自己想出来的加固 ------ 而是 Redis/Sentinel 官方对"哨兵架构固有缺陷"的标准补丁(官方文档明确描述了"主库失联后 10 秒内变不可用"的场景)。
⚠️ 但它在"只有 1 个从库"时有致命局限
| 事实 | 后果 |
|---|---|
| 该参数对被提升为主库的从库同样生效 | 故障转移后新主库手里没有从库 → 拒绝所有写(老主库不恢复就一直不可写) |
| 只有 1 个从库 | 任何一台机器故障都会让写入停摆 |
它的设计前提是至少 2 个从库 (挂一个还剩一个)。我只有 1 个,评估过三种方案后选择接受局限 + 保留"演练开关":
bash
# 停从库做演练/维护前
CONFIG SET min-replicas-to-write 0
# 恢复后设回
CONFIG SET min-replicas-to-write 1
⚠️ 另一个坑:CONFIG REWRITE 在单文件 bind mount 上必然失败(Resource busy,因为它是"写临时文件 + rename")→ 只能手工追加到 conf。
踩坑清单(可直接抄)
| 坑 | 结论 |
|---|---|
| 单文件 bind mount 不能被 rename | Sentinel/Redis 改写配置必然失败 → 哨兵必须挂目录 |
| 挂载文件的属主必须是容器内运行用户 | Redis 容器内是 uid 999,宿主机属主 + chmod 600 → 读不了(用 docker root 容器 chown,无需宿主机 sudo) |
| Redis 7 的 AOF 首启语义 | appendonly yes 但 AOF 目录不存在 → 不回退加载 RDB,直接空库启动 |
| 备份必须"出卷" | redis-cli SAVE 写回同一数据卷 = 等于没备份 |
min-replicas-to-write 的前提 |
至少 2 个从库;且不要配在"可能被提升为主库"的从库上 |
本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。