Redis 主从下的库存一致性:我推翻了"付款前查库存"这个方案

Redis 主从下的库存一致性:我推翻了"付款前查库存"这个方案

主题:Redis 加固与分布式数据一致性

先说结论

Redis 主从是异步复制 ------ 主库挂掉的瞬间会丢最后几笔写。代码层无法 100% 消灭 这个本质,能做的是让丢失无害化

为了做到这一点,我推翻了自己第一版方案。核心是三个认知:

  1. 闸门 / 账本模型 :秒杀成交资格在 Redis 预扣时就已经确定,DB 只是记账
  2. "查剩余库存"是不可能正确实现的 ------ 它无法区分"本单货已被扣掉(正常)"和"本单货没扣上(异常)"
  3. 对账必须分层 ,而且只能以账本为唯一权威基准

下面按"加固 → 踩坑 → 一致性设计"三层讲。


第一层:先把 Redis 本身加固好(四连坑)

现状(四连坑)

编号 问题 实测
无密码 requirepass 空;11 个服务无 SPRING_DATA_REDIS_PASSWORD
无 AOF appendonly no,仅 RDB(save 3600 1 300 100 60 10000)→ 最坏可丢约 1 小时数据 (低写入时;高写入时几分钟);秒杀购买标记丢失 → 可能重复秒杀
无内存上限 maxmemory 0 + noeviction → 写满拒绝写入 = 功能停摆
无自定义 conf redis-serverCONFIG 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 必须带密码 :加了 requirepassredis-cli ping 返回 NOAUTH → healthcheck 误判 unhealthy

⚠️ 真正踩到的坑:RDB 切 AOF 时数据全丢了

现象 :compose 重建后 DBSIZE=0(原 21 个键消失),数据卷里 dump.rdb 从 8157 字节变 89 字节(空库)。

根因链(三层)

  1. Redis 7 的启动逻辑appendonly yes 但 AOF 目录不存在时,不会回退加载已有的 RDB ,直接以空库启动并创建空 AOF ------ 日志里没有 DB loaded from disk 就是信号
  2. 我的"备份"无效redis-cli SAVE 把数据写回了同一个数据卷dump.rdb,并未复制到卷外 → 后续空库关闭时把它覆盖了
  3. 正确做法我写过但没执行 :重建前应先在线 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 个从库;且不要配在"可能被提升为主库"的从库上

本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。

相关推荐
ikoala1 小时前
DeepSeek 官方仓库惊现 DeepSeek Harness 桌面端!
前端·javascript·后端
用户408527444141 小时前
一个个人开发者,怎么啃下 12 种工控协议?
后端
Bs_MoneyMagnet2 小时前
基于springboot+vue的旅游景区点评系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·旅游
cpp_learner2 小时前
Qt 5.14.2 x86_64 静态编译 —— 从零搭建完整手册
后端
企业数字化笔记4 小时前
固定资产历史数据怎么导入系统?Excel模板、字段映射和数据校验
android·java·数据库·后端
西瓜太郎12344 小时前
request_id 如何串起请求、路由、计费和日志
后端·python
by组态4 小时前
Ricon组态系统通信配置指南
前端·后端·物联网
sbjdhjd4 小时前
ThinkPHP 5.0.10 缓存写入型 RCE 复盘:从手动搭建、换行绕过到源码单步验证 | 05
java·后端·安全·spring·网络安全·数据挖掘·php
LEE4 小时前
Agent 的控制权,为什么正在回到模型手里?
前端·后端