高并发和支付业务中,怎么保证缓存一致性

3 阅读14分钟

高并发和支付业务中,怎么保证缓存一致性

做高并发系统时,缓存几乎是绕不开的。只要流量一上来,很多服务第一反应就是:

  • 加 Redis
  • 把热点数据放缓存
  • 尽量少打数据库

这个思路本身没问题,但一到支付场景,问题就变了。

因为支付业务和普通业务最大的区别在于:

有些数据可以短暂不一致,但有些数据绝对不能因为缓存出错而影响资金结果。

比如商品详情、营销配置、渠道列表,这类数据缓存不一致,最多影响展示或者少量请求结果;但如果订单状态、支付状态、账户余额、幂等结果处理不好,后果就可能是:

  • 重复扣款
  • 支付成功但订单没更新
  • 用户已支付却看到“未支付”
  • 余额被覆盖
  • 退款状态错乱

所以支付系统谈“缓存一致性”,核心不是背几个模式,而是先分清楚:

哪些数据适合缓存,哪些数据不能把缓存当真相源。

这篇文章就从真实支付系统实践角度,讲清楚高并发和支付业务里怎么设计缓存一致性。

一、先说结论:支付系统里,不要追求“所有数据强一致缓存”

很多人一上来就问:

如何让 Redis 和 MySQL 绝对一致?

这个问题本身就不太对。

在高并发系统里,缓存和数据库之间天然存在时间差。你很难在工程上用低成本做到“绝对同时一致”。真正可落地的做法通常是:

  • 非核心数据,接受短暂不一致
  • 核心资金数据,不把缓存当最终依据
  • 状态类数据,通过删缓存、重试、补偿、消息驱动来尽量收敛一致性
  • 账务类数据,以数据库、账本、流水表作为最终真相

所以支付场景的真实答案不是:

用一个万能方案解决缓存一致性

而是:

按数据重要性做分层设计。

二、先分数据类型:不是所有支付数据都应该同样处理

真实系统里,常见会碰到这几类数据:

1. 读多写少的配置类数据

比如:

  • 支付渠道配置
  • 商户费率配置
  • 风控开关
  • 限流配置
  • 营销活动配置

这类数据特点是:

  • 更新不频繁
  • 读取频繁
  • 短暂不一致通常可以接受

这类数据最适合上缓存,而且一致性要求相对低。

2. 状态类数据

比如:

  • 订单状态
  • 支付状态
  • 退款状态
  • 回调处理状态

这类数据也经常放缓存,但要非常小心。因为状态流转是有方向的,一旦缓存里读到旧状态,可能会触发错误分支逻辑。

3. 幂等和防重数据

比如:

  • 支付请求幂等键
  • 回调去重记录
  • 防重复提交 token

这类数据通常适合放 Redis,因为访问频率高,而且天然适合用原子命令控制并发。

4. 资金类核心数据

比如:

  • 账户余额
  • 可提现金额
  • 冻结金额
  • 总账、分账流水

这类数据最敏感。真实系统里,一般不会只依赖缓存判断最终资金状态。缓存可以加速查询,但最终记账和核算必须以数据库或账务流水为准。

三、最常见的错误做法:先更新数据库,再更新缓存

很多系统早期都会这么写:

  1. 更新 MySQL
  2. 更新 Redis

看起来很自然,实际上问题很多。

例如:

  1. 数据库更新成功
  2. Redis 更新失败
  3. 后续请求继续读到旧缓存

或者反过来:

  1. Redis 先更新成功
  2. 数据库事务失败回滚
  3. 缓存里出现了一个并不存在的新状态

支付系统里,这种写法风险很高,因为你相当于在维护两个“状态副本”,但又没有真正的分布式事务把它们绑定住。

所以在线上更推荐的策略通常不是“更新缓存”,而是:

更新数据库后删除缓存,让缓存下次按最新数据回源重建。

四、主流实践:Cache Aside 模式

高并发系统里最常见、也最实用的模式就是 Cache Aside

它的基本流程是:

读流程

  1. 先查缓存
  2. 缓存命中直接返回
  3. 缓存未命中再查数据库
  4. 查到后回写缓存

写流程

  1. 先更新数据库
  2. 再删除缓存

为什么不是“更新缓存”,而是“删除缓存”?

因为删除操作更简单,也更安全。

你不需要保证:

  • Redis 中的数据结构和数据库映射完全一致
  • 多字段更新时缓存同步无遗漏
  • 并发覆盖时不会写回旧值

你只需要保证:

  • 数据库先成功
  • 缓存被删除

后面的读请求自然会把最新数据重新加载进来。

这是支付和订单系统里最常见的基础方案。

五、但只靠“更新 DB 后删缓存”还不够

很多文章讲到这里就结束了,但真实线上系统没这么简单。

因为在高并发下,会出现这样一个经典竞态:

  1. 请求 A 读取缓存,未命中
  2. 请求 A 去查数据库,拿到旧值
  3. 请求 B 更新数据库成功,并删除缓存
  4. 请求 A 把刚才查到的旧值重新写回缓存

结果就是:

  • 数据库是新值
  • 缓存又被旧值污染了

这就是很多人说的“删缓存还是会不一致”的核心原因。

所以真实系统往往会在基础方案上继续加保险。

六、延迟双删:一个常见但不能滥用的补偿方案

所谓延迟双删,就是:

  1. 先删一次缓存
  2. 更新数据库
  3. 过一小段时间后再删一次缓存

或者更常见的变种:

  1. 更新数据库
  2. 立刻删缓存
  3. 延迟几百毫秒到几秒,再删一次缓存

这个思路的目标是:

尽量清掉并发读请求可能回填进去的旧缓存。

它有用,但要明确边界:

  • 它只能降低脏缓存存活概率
  • 它不是严格一致性方案
  • 删除延迟时间不好拍脑袋定
  • 高峰期、长耗时 SQL、网络抖动下仍可能失效

所以延迟双删适合:

  • 对一致性有要求,但允许极短暂脏读的状态类数据
  • 订单详情、支付结果展示、查询类接口

但不适合拿来当资金安全方案的核心保障。

七、支付核心链路里,更稳的做法是“数据库为准,缓存做加速”

支付系统里有一个很重要的原则:

缓存可以服务查询,但不能替代账务正确性。

例如支付结果查询:

  • 用户在支付完成后频繁刷新订单页
  • 前端不断查“是否支付成功”
  • 这时可以用缓存抗高并发

但真正决定“订单是否已支付”的依据,应该还是数据库里的支付记录、订单状态表、渠道回执和账务流水,而不是某个 Redis key。

再比如账户余额:

  • 缓存里的余额可以做展示
  • 但扣减时不能只改 Redis 然后异步落库

真实支付系统里,余额扣减通常要在数据库事务或账务流水里完成,再通过缓存提升查询性能。

因为只要你把缓存当成资金真相源,一旦缓存丢失、过期、覆盖、回滚失败,就会造成严重资金问题。

八、支付状态、订单状态,真实系统一般怎么做

支付业务里最常见的状态链路包括:

  • 用户发起支付
  • 本地创建支付单
  • 调用三方支付渠道
  • 渠道异步回调
  • 系统更新支付状态
  • 系统更新订单状态
  • 通知下游发货或履约

这条链路里,缓存最常见的用法不是“替代状态存储”,而是:

  • 缓存订单查询结果
  • 缓存支付结果查询结果
  • 缓存用户侧轮询页面需要的数据

而真正的状态更新路径一般是:

  1. 渠道回调落库
  2. 本地事务更新支付单和订单状态
  3. 删除相关缓存
  4. 发 MQ 通知下游系统
  5. 下游系统各自更新并删除自己的缓存

也就是说,支付状态一致性的中心还是数据库事务和消息链路,不是 Redis 本身。

九、消息驱动补偿,是支付系统里非常关键的一层

如果你只依赖“写库后删缓存”,线上还是容易出问题:

  • Redis 短暂不可用
  • 删除缓存失败
  • 应用刚写完数据库就宕机
  • 下游系统没及时更新

所以真实系统里,往往会引入 MQ 做异步补偿。

典型做法是:

  1. 本地事务成功提交
  2. 发送订单/支付状态变更消息
  3. 消费者根据事件删除缓存或刷新缓存
  4. 失败则重试

这套机制的好处是:

  • 主流程更轻
  • 删除缓存失败可以重试
  • 多个下游系统能统一感知状态变更
  • 更容易把“最终一致性”做完整

例如:

  • 支付成功后,订单服务更新状态
  • 发一条 PAY_SUCCESS 消息
  • 库存服务、营销服务、履约服务、用户中心分别消费
  • 各自更新本地数据并清理缓存

这比你在一个同步接口里串联所有事情要稳得多。

十、如果要求更高,可以考虑订阅 Binlog 做缓存修正

一些对一致性要求更高、链路更复杂的系统,会订阅 MySQL Binlog 来驱动缓存刷新或缓存删除。

这种模式的思路是:

  • 业务写库只关注数据库事务成功
  • 数据变更由 Binlog 统一输出
  • 后台程序订阅 Binlog
  • 根据表和主键删除或更新对应缓存

它的优点是:

  • 把“数据库变更”作为单一事实来源
  • 降低业务代码里到处手动删缓存的遗漏概率
  • 比纯手工补偿更体系化

但缺点也明显:

  • 架构复杂度更高
  • 表结构和缓存映射要维护
  • 延迟和异常处理要额外设计

所以这更适合:

  • 订单量大
  • 服务很多
  • 缓存键规则相对清晰
  • 已经有成熟数据平台能力的团队

对中小型支付系统来说,通常先把“写库后删缓存 + MQ 补偿 + 幂等重试”做好,收益更大。

十一、幂等性和防重,比缓存一致性更关键

支付场景里有一个很现实的问题:

很多线上事故,表面看像缓存不一致,实际上根因是没有做好幂等。

例如:

  • 用户重复点击支付
  • 渠道重复回调
  • 消息重复投递
  • 服务超时重试

如果没有幂等保护,就可能出现:

  • 多次处理同一支付成功事件
  • 多次更新订单
  • 多次发货
  • 多次加积分

这时候即使缓存完全一致,也没用。

所以真实支付系统一定要把以下几类数据设计好:

  • 幂等键
  • 去重记录
  • 回调处理状态
  • 消息消费去重表
  • 状态机校验

这些数据很多时候本身就会放在 Redis 或数据库里,作用不是“加速查询”,而是“防止错误重复处理”。

十二、账户余额这种数据,怎么做才安全

这是支付场景里最容易被问到的一类问题。

很多人会下意识说:

把余额放 Redis,扣减时用 Lua 保证原子性。

这在秒杀库存场景可以讨论,但在支付账务场景里要非常谨慎。

因为余额不是普通计数器,它涉及:

  • 资金准确性
  • 流水可追溯
  • 对账
  • 审计
  • 退款和逆向操作

所以更稳妥的做法通常是:

  1. 以数据库账户表和账务流水表为准
  2. 扣减时走事务或账务记账逻辑
  3. 缓存只做余额展示加速
  4. 更新成功后删除余额缓存
  5. 必要时通过消息异步刷新用户资产视图

也就是说:

余额可以缓存,但不能只靠缓存维护。

这句话在支付系统里非常重要。

十三、缓存穿透、击穿、雪崩,在支付场景里也要处理

高并发支付系统除了“一致性”,还有缓存本身的稳定性问题。

1. 缓存穿透

比如有人不断查不存在的订单号、支付单号。

应对方法:

  • 空值缓存
  • 布隆过滤器
  • 参数合法性校验

2. 缓存击穿

比如某个热点商户配置、热点订单查询 key 过期瞬间,大量请求直接打到数据库。

应对方法:

  • 热点 key 永不过期或逻辑过期
  • 单飞机制
  • 分布式锁保护重建

3. 缓存雪崩

比如大批 key 同时过期,数据库被打满。

应对方法:

  • TTL 加随机值
  • 多级缓存
  • 限流降级
  • 核心接口兜底数据

这些问题虽然不完全等于缓存一致性,但在真实线上环境里,它们经常和一致性问题一起出现。

十四、线上推荐方案:支付系统里一套更靠谱的缓存一致性设计

如果让我给一个更接近真实项目的推荐方案,我会这样设计:

1. 配置类数据

  • 用 Redis 缓存
  • 采用 Cache Aside
  • 更新 DB 后删缓存
  • 配合短 TTL 或版本号

2. 订单状态和支付状态

  • 数据库状态表为准
  • 查询接口读缓存
  • 状态变更后删缓存
  • 发送 MQ 做异步补偿和下游同步
  • 消费者全链路幂等

3. 幂等和防重信息

  • 放 Redis 或数据库
  • 利用 SETNX、Lua、唯一索引控制并发
  • 给回调、补单、消息消费都加去重

4. 账户余额和账务数据

  • 以数据库和流水表为准
  • 不把 Redis 当唯一扣减依据
  • 缓存只服务读
  • 更新后删缓存
  • 定期对账与修正

5. 失败补偿

  • 删缓存失败写入重试队列
  • 消息投递失败可重试
  • 定时任务扫描异常订单和异常状态
  • 必要时根据流水重建缓存

这套方案的关键不是“多先进”,而是足够稳,足够符合支付业务特点。

十五、几个真实会踩的坑

1. 以为延迟双删就能解决所有问题

不能。它只是降低脏缓存概率,不是严格一致性方案。

2. 以为 Redis 原子操作就等于资金安全

也不能。Redis 原子性只解决并发更新问题,不解决账务可追溯、事务落库、审计对账这些问题。

3. 以为支付结果可以只读缓存

风险很高。缓存适合抗查询流量,不适合作为最终支付成功依据。

4. 只做删缓存,不做补偿重试

线上总会遇到网络抖动、服务重启、Redis 故障。没有补偿,最终一致性就是一句空话。

5. 只谈缓存,不谈幂等和状态机

支付业务里真正容易出事的,很多时候不是“缓存有一点旧”,而是“重复处理了一次成功事件”。 高并发和支付业务中的缓存一致性,本质上不是一个单点技术问题,而是一个系统设计问题。

真正靠谱的思路是:

  • 先分数据层次
  • 再决定哪些能缓存、哪些不能信缓存
  • Cache Aside 解决大部分读写一致性问题
  • 用消息补偿、幂等、防重、状态机保证支付链路正确
  • 用数据库和账务流水守住资金底线

如果只问“Redis 和 MySQL 怎么一致”,这个问题太窄了。

真实支付系统里更应该问的是:

在高并发、重试、回调重复、服务异常的情况下,怎么保证最终业务状态和资金结果正确。

这才是支付场景里缓存一致性的真正答案。