高并发和支付业务中,怎么保证缓存一致性
做高并发系统时,缓存几乎是绕不开的。只要流量一上来,很多服务第一反应就是:
- 加 Redis
- 把热点数据放缓存
- 尽量少打数据库
这个思路本身没问题,但一到支付场景,问题就变了。
因为支付业务和普通业务最大的区别在于:
有些数据可以短暂不一致,但有些数据绝对不能因为缓存出错而影响资金结果。
比如商品详情、营销配置、渠道列表,这类数据缓存不一致,最多影响展示或者少量请求结果;但如果订单状态、支付状态、账户余额、幂等结果处理不好,后果就可能是:
- 重复扣款
- 支付成功但订单没更新
- 用户已支付却看到“未支付”
- 余额被覆盖
- 退款状态错乱
所以支付系统谈“缓存一致性”,核心不是背几个模式,而是先分清楚:
哪些数据适合缓存,哪些数据不能把缓存当真相源。
这篇文章就从真实支付系统实践角度,讲清楚高并发和支付业务里怎么设计缓存一致性。
一、先说结论:支付系统里,不要追求“所有数据强一致缓存”
很多人一上来就问:
如何让 Redis 和 MySQL 绝对一致?
这个问题本身就不太对。
在高并发系统里,缓存和数据库之间天然存在时间差。你很难在工程上用低成本做到“绝对同时一致”。真正可落地的做法通常是:
- 对非核心数据,接受短暂不一致
- 对核心资金数据,不把缓存当最终依据
- 对状态类数据,通过删缓存、重试、补偿、消息驱动来尽量收敛一致性
- 对账务类数据,以数据库、账本、流水表作为最终真相
所以支付场景的真实答案不是:
用一个万能方案解决缓存一致性
而是:
按数据重要性做分层设计。
二、先分数据类型:不是所有支付数据都应该同样处理
真实系统里,常见会碰到这几类数据:
1. 读多写少的配置类数据
比如:
- 支付渠道配置
- 商户费率配置
- 风控开关
- 限流配置
- 营销活动配置
这类数据特点是:
- 更新不频繁
- 读取频繁
- 短暂不一致通常可以接受
这类数据最适合上缓存,而且一致性要求相对低。
2. 状态类数据
比如:
- 订单状态
- 支付状态
- 退款状态
- 回调处理状态
这类数据也经常放缓存,但要非常小心。因为状态流转是有方向的,一旦缓存里读到旧状态,可能会触发错误分支逻辑。
3. 幂等和防重数据
比如:
- 支付请求幂等键
- 回调去重记录
- 防重复提交 token
这类数据通常适合放 Redis,因为访问频率高,而且天然适合用原子命令控制并发。
4. 资金类核心数据
比如:
- 账户余额
- 可提现金额
- 冻结金额
- 总账、分账流水
这类数据最敏感。真实系统里,一般不会只依赖缓存判断最终资金状态。缓存可以加速查询,但最终记账和核算必须以数据库或账务流水为准。
三、最常见的错误做法:先更新数据库,再更新缓存
很多系统早期都会这么写:
- 更新 MySQL
- 更新 Redis
看起来很自然,实际上问题很多。
例如:
- 数据库更新成功
- Redis 更新失败
- 后续请求继续读到旧缓存
或者反过来:
- Redis 先更新成功
- 数据库事务失败回滚
- 缓存里出现了一个并不存在的新状态
支付系统里,这种写法风险很高,因为你相当于在维护两个“状态副本”,但又没有真正的分布式事务把它们绑定住。
所以在线上更推荐的策略通常不是“更新缓存”,而是:
更新数据库后删除缓存,让缓存下次按最新数据回源重建。
四、主流实践:Cache Aside 模式
高并发系统里最常见、也最实用的模式就是 Cache Aside。
它的基本流程是:
读流程
- 先查缓存
- 缓存命中直接返回
- 缓存未命中再查数据库
- 查到后回写缓存
写流程
- 先更新数据库
- 再删除缓存
为什么不是“更新缓存”,而是“删除缓存”?
因为删除操作更简单,也更安全。
你不需要保证:
- Redis 中的数据结构和数据库映射完全一致
- 多字段更新时缓存同步无遗漏
- 并发覆盖时不会写回旧值
你只需要保证:
- 数据库先成功
- 缓存被删除
后面的读请求自然会把最新数据重新加载进来。
这是支付和订单系统里最常见的基础方案。
五、但只靠“更新 DB 后删缓存”还不够
很多文章讲到这里就结束了,但真实线上系统没这么简单。
因为在高并发下,会出现这样一个经典竞态:
- 请求 A 读取缓存,未命中
- 请求 A 去查数据库,拿到旧值
- 请求 B 更新数据库成功,并删除缓存
- 请求 A 把刚才查到的旧值重新写回缓存
结果就是:
- 数据库是新值
- 缓存又被旧值污染了
这就是很多人说的“删缓存还是会不一致”的核心原因。
所以真实系统往往会在基础方案上继续加保险。
六、延迟双删:一个常见但不能滥用的补偿方案
所谓延迟双删,就是:
- 先删一次缓存
- 更新数据库
- 过一小段时间后再删一次缓存
或者更常见的变种:
- 更新数据库
- 立刻删缓存
- 延迟几百毫秒到几秒,再删一次缓存
这个思路的目标是:
尽量清掉并发读请求可能回填进去的旧缓存。
它有用,但要明确边界:
- 它只能降低脏缓存存活概率
- 它不是严格一致性方案
- 删除延迟时间不好拍脑袋定
- 高峰期、长耗时 SQL、网络抖动下仍可能失效
所以延迟双删适合:
- 对一致性有要求,但允许极短暂脏读的状态类数据
- 订单详情、支付结果展示、查询类接口
但不适合拿来当资金安全方案的核心保障。
七、支付核心链路里,更稳的做法是“数据库为准,缓存做加速”
支付系统里有一个很重要的原则:
缓存可以服务查询,但不能替代账务正确性。
例如支付结果查询:
- 用户在支付完成后频繁刷新订单页
- 前端不断查“是否支付成功”
- 这时可以用缓存抗高并发
但真正决定“订单是否已支付”的依据,应该还是数据库里的支付记录、订单状态表、渠道回执和账务流水,而不是某个 Redis key。
再比如账户余额:
- 缓存里的余额可以做展示
- 但扣减时不能只改 Redis 然后异步落库
真实支付系统里,余额扣减通常要在数据库事务或账务流水里完成,再通过缓存提升查询性能。
因为只要你把缓存当成资金真相源,一旦缓存丢失、过期、覆盖、回滚失败,就会造成严重资金问题。
八、支付状态、订单状态,真实系统一般怎么做
支付业务里最常见的状态链路包括:
- 用户发起支付
- 本地创建支付单
- 调用三方支付渠道
- 渠道异步回调
- 系统更新支付状态
- 系统更新订单状态
- 通知下游发货或履约
这条链路里,缓存最常见的用法不是“替代状态存储”,而是:
- 缓存订单查询结果
- 缓存支付结果查询结果
- 缓存用户侧轮询页面需要的数据
而真正的状态更新路径一般是:
- 渠道回调落库
- 本地事务更新支付单和订单状态
- 删除相关缓存
- 发 MQ 通知下游系统
- 下游系统各自更新并删除自己的缓存
也就是说,支付状态一致性的中心还是数据库事务和消息链路,不是 Redis 本身。
九、消息驱动补偿,是支付系统里非常关键的一层
如果你只依赖“写库后删缓存”,线上还是容易出问题:
- Redis 短暂不可用
- 删除缓存失败
- 应用刚写完数据库就宕机
- 下游系统没及时更新
所以真实系统里,往往会引入 MQ 做异步补偿。
典型做法是:
- 本地事务成功提交
- 发送订单/支付状态变更消息
- 消费者根据事件删除缓存或刷新缓存
- 失败则重试
这套机制的好处是:
- 主流程更轻
- 删除缓存失败可以重试
- 多个下游系统能统一感知状态变更
- 更容易把“最终一致性”做完整
例如:
- 支付成功后,订单服务更新状态
- 发一条
PAY_SUCCESS消息 - 库存服务、营销服务、履约服务、用户中心分别消费
- 各自更新本地数据并清理缓存
这比你在一个同步接口里串联所有事情要稳得多。
十、如果要求更高,可以考虑订阅 Binlog 做缓存修正
一些对一致性要求更高、链路更复杂的系统,会订阅 MySQL Binlog 来驱动缓存刷新或缓存删除。
这种模式的思路是:
- 业务写库只关注数据库事务成功
- 数据变更由 Binlog 统一输出
- 后台程序订阅 Binlog
- 根据表和主键删除或更新对应缓存
它的优点是:
- 把“数据库变更”作为单一事实来源
- 降低业务代码里到处手动删缓存的遗漏概率
- 比纯手工补偿更体系化
但缺点也明显:
- 架构复杂度更高
- 表结构和缓存映射要维护
- 延迟和异常处理要额外设计
所以这更适合:
- 订单量大
- 服务很多
- 缓存键规则相对清晰
- 已经有成熟数据平台能力的团队
对中小型支付系统来说,通常先把“写库后删缓存 + MQ 补偿 + 幂等重试”做好,收益更大。
十一、幂等性和防重,比缓存一致性更关键
支付场景里有一个很现实的问题:
很多线上事故,表面看像缓存不一致,实际上根因是没有做好幂等。
例如:
- 用户重复点击支付
- 渠道重复回调
- 消息重复投递
- 服务超时重试
如果没有幂等保护,就可能出现:
- 多次处理同一支付成功事件
- 多次更新订单
- 多次发货
- 多次加积分
这时候即使缓存完全一致,也没用。
所以真实支付系统一定要把以下几类数据设计好:
- 幂等键
- 去重记录
- 回调处理状态
- 消息消费去重表
- 状态机校验
这些数据很多时候本身就会放在 Redis 或数据库里,作用不是“加速查询”,而是“防止错误重复处理”。
十二、账户余额这种数据,怎么做才安全
这是支付场景里最容易被问到的一类问题。
很多人会下意识说:
把余额放 Redis,扣减时用 Lua 保证原子性。
这在秒杀库存场景可以讨论,但在支付账务场景里要非常谨慎。
因为余额不是普通计数器,它涉及:
- 资金准确性
- 流水可追溯
- 对账
- 审计
- 退款和逆向操作
所以更稳妥的做法通常是:
- 以数据库账户表和账务流水表为准
- 扣减时走事务或账务记账逻辑
- 缓存只做余额展示加速
- 更新成功后删除余额缓存
- 必要时通过消息异步刷新用户资产视图
也就是说:
余额可以缓存,但不能只靠缓存维护。
这句话在支付系统里非常重要。
十三、缓存穿透、击穿、雪崩,在支付场景里也要处理
高并发支付系统除了“一致性”,还有缓存本身的稳定性问题。
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 怎么一致”,这个问题太窄了。
真实支付系统里更应该问的是:
在高并发、重试、回调重复、服务异常的情况下,怎么保证最终业务状态和资金结果正确。
这才是支付场景里缓存一致性的真正答案。