看似稳健的删缓存策略,在高并发场景下真的安全吗?
背景
近期在重构公司的核心交易链路查询服务。与普通的资讯类业务不同,该服务涉及到用户资产维度的查询,对数据的一致性极其敏感。
目前的系统面临着经典的“读多写少”场景,但核心约束在于:
- 数据零容忍:用户充值或交易后,必须立刻看到余额变化,不允许出现“充值成功但余额未变”的客诉。
- 高并发洪峰:在活动期间,QPS 会瞬间飙升,数据库无法独立承担所有流量,必须依赖 Redis。
- 解耦诉求:业务代码已经不堪重负,充斥着大量的缓存维护逻辑,急需剥离。
基于上述背景,我们审视了业界通用的 Cache Aside(先写 DB,再删缓存)模式,发现它在极端并发和网络抖动下,依然存在数据不一致的“暗礁”。因此,我们放弃了简单的同步删除方案,选择了一条更稳健的路径:基于 Binlog 订阅 + MQ 异步编排的最终一致性架构。
一、 核心矛盾与一致性分析
首先我们来剖析一下,为什么最简单的“写 DB + 删缓存”在我们的场景下不够用。
我们作为一个“资产查询”服务,必须确保缓存中的数据是数据库的真实投影。这个问题看起来也就是两行代码的事,其实暗藏杀机。
为什么“先写 DB,再删缓存”依然不安全?
这是经典的 Cache Aside 模式。它规避了双写冲突,但在我们的高并发压测中,依然出现了**脏数据回填(Stale Data Inconsistency)**的问题。
我们将这个经典的“回填”场景还原为时序图,问题便一目了然:
sequenceDiagram
participant ReadThread as 读线程
participant WriteThread as 写线程
participant Redis
participant DB
ReadThread->>Redis: 1. 读缓存 (未命中)
Redis-->>ReadThread: null
ReadThread->>DB: 2. 读 DB (旧值: 100)
Note right of ReadThread: 此时网络拥堵或GC暂停<br/>读线程持有旧值 100 尚未写入
WriteThread->>DB: 3. 写 DB (新值: 200)
WriteThread->>Redis: 4. 删缓存
Note right of WriteThread: 缓存已清除,DB为新值
ReadThread->>Redis: 5. 写入旧值 (100)
Note right of Redis: 灾难发生:<br/>缓存被旧值覆盖,<br/>由于没有过期时间,<br/>脏数据将长期存在
后果:缓存中永久驻留了旧数据,直到下一次过期。这对于刚完成交易的用户来说,看到余额没变是严重的事故。
为什么必须解决“部分失败”?
数据库和 Redis 是异构系统,不支持强一致事务。如果 DB 更新成功,但代码执行到 redis.del() 时服务宕机或网络超时,缓存将不再受控。
结论:我们需要一个能够自动重试、保证“必达”的机制,而不是依赖应用进程的内存状态。
二、 备选方案推演:从妥协到治本
为了解决上述问题,我们评估了三种方案,试图找到性能与一致性的最佳平衡点。
方案 A:延迟双删 (Delayed Double Delete)
这是面试中常被提及的方案。逻辑是:写 DB -> 删缓存 -> sleep(N) -> 再删缓存。
- 弊端:这个
sleep(N)是基于经验的“玄学”。N 该设多少?设小了防不住长耗时查询,设大了影响吞吐量。这本质上是在用降低吞吐量来掩盖架构缺陷,不够优雅。
方案 B:分布式读写锁
在读写操作上加 Redisson 的 ReadWriteLock。
- 弊端:将并行化强制变为串行化,虽然保证了强一致,但 Redis 的高并发优势荡然无存。对于 C 端高频接口,这是不可接受的性能退坡。
方案 C:基于 Binlog 的异步可靠架构
这是我们最终选定的方案。核心思想是关注点分离:业务代码只管 ACID 事务内的 DB 操作,缓存的维护交给独立的基础设施。
三、 最终架构:Canal + MQ 异步编排
我们引入了 Canal 伪装成 MySQL Slave,结合 RocketMQ 的重试机制,设计了一套完全解耦的缓存更新链路。
3.1 架构核心逻辑(事件驱动)
我们将缓存维护逻辑从业务代码中剥离,转变为数据变更事件的消费者。整体的数据流向如下:
flowchart LR
subgraph BusinessLayer [业务层]
App[业务应用] -->|Commit Tx| MySQL[(MySQL Master)]
end
subgraph Infrastructure [基础设施层]
MySQL -->|Binlog| Canal[Canal Server]
Canal -->|解析变更| MQ{RocketMQ}
end
subgraph ConsumerLayer [消费层]
MQ -->|Push| Consumer[Cache Consumer]
Consumer -->|Del| Redis[(Redis Cluster)]
Consumer -.->|延迟二删| MQ
end
style BusinessLayer fill:#f9f,stroke:#333,stroke-width:2px
style ConsumerLayer fill:#bbf,stroke:#333,stroke-width:2px
- 业务层:只操作 MySQL,提交事务。无任何缓存代码侵入。
- 传输层:Canal 捕获 Binlog,发送至 RocketMQ(Partition Ordered,保证同一主键有序)。
- 消费层:消费者收到消息后,执行“删除缓存”操作。
- 兜底层:利用 MQ 的 ACK 机制,如果删除失败,返回RECONSUME_LATER,自动重试,直到成功。
3.2 解决“回填”问题的杀手锏:延迟消息
针对上文提到的“脏数据回填”问题,我们在 MQ 消费端做了一个巧妙的设计:
当收到 Binlog 变更消息时,消费者不仅立即执行一次删除,还会发送一条 delayTime = 500ms 的延迟消息给自己。
这意味着,即使有读线程在极端情况下回填了旧数据,500ms 后的那次“回马枪”删除,也会彻底清除脏数据。这相当于架构层面的自动延迟双删。
逻辑流程如下:
flowchart TD
Start(收到 Binlog 消息) --> IsRetry{是否为<br/>重试/延迟消息?}
IsRetry -- No (首次通知) --> Del1[立即删除缓存]
Del1 --> SendDelay[发送延迟 500ms 消息<br/>Key = OriginalMsg]
SendDelay --> End((结束))
IsRetry -- Yes (延迟二删) --> Del2[再次删除缓存]
Del2 --> End
Del1 -.->|异常| Error[抛出异常<br/>触发 MQ 自动重试]
Del2 -.->|异常| Error
style Start fill:#cfc,stroke:#333
style IsRetry fill:#ff9,stroke:#333
style Del1 fill:#f96,stroke:#333
style Del2 fill:#f96,stroke:#333
3.3 核心伪代码实现
看看代码是如何落实这个逻辑的。我们在 CacheUpdateConsumer 中编排了重试与延迟的逻辑。
1 |
|
四、 复杂性与收益的权衡
这套方案上线后,效果如何?
- 稳定性:彻底解决了业务代码中
try-catch删缓存的丑陋逻辑,业务开发不再需要关心缓存一致性,只需专注业务逻辑。 - 一致性:通过 MQ 的 ACK 重试 + 延迟二删,我们在 99.99% 的场景下保证了最终一致性。即使 Redis 短暂宕机,服务恢复后 MQ 堆积的消息会迅速追平数据。
- 性能:业务接口响应时间(RT)平均下降了 15ms,因为省去了与 Redis 建立连接和通信的同步开销。
小结
回到最初的问题:看起来简单的缓存删除,在高并发场景下真的简单吗?
如果是为了“能用”,简单的 Cache Aside 也就够了。
但如果是为了“可靠”,为了在金融级查询场景下守护数据的准确性,我们需要将复杂性从业务逻辑下沉到架构设计中。
通过引入 Canal + MQ,我们虽然增加了基础设施的维护成本,但换来了系统的高内聚与高可用。在架构设计中,我们实际上是在用架构的复杂度去置换业务的稳定性。这,或许就是架构师存在的意义。
- Title: 看似稳健的删缓存策略,在高并发场景下真的安全吗?
- Author: 侯乾
- Created at : 2022-12-09 12:19:21
- Updated at : 2022-12-09 12:19:21
- Link: http://houqian.github.io/2022/12/09/看似稳健的删缓存策略,在高并发场景下真的安全吗?/
- License: This work is licensed under CC BY-NC-SA 4.0.