看似稳健的删缓存策略,在高并发场景下真的安全吗?

侯乾 Lv4

背景

近期在重构公司的核心交易链路查询服务。与普通的资讯类业务不同,该服务涉及到用户资产维度的查询,对数据的一致性极其敏感。

目前的系统面临着经典的“读多写少”场景,但核心约束在于:

  1. 数据零容忍:用户充值或交易后,必须立刻看到余额变化,不允许出现“充值成功但余额未变”的客诉。
  2. 高并发洪峰:在活动期间,QPS 会瞬间飙升,数据库无法独立承担所有流量,必须依赖 Redis。
  3. 解耦诉求:业务代码已经不堪重负,充斥着大量的缓存维护逻辑,急需剥离。

基于上述背景,我们审视了业界通用的 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
  1. 业务层:只操作 MySQL,提交事务。无任何缓存代码侵入
  2. 传输层:Canal 捕获 Binlog,发送至 RocketMQ(Partition Ordered,保证同一主键有序)。
  3. 消费层:消费者收到消息后,执行“删除缓存”操作。
  4. 兜底层:利用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RocketMQMessageListener(topic = "DB_BINLOG_TOPIC", consumerGroup = "CACHE_SYNC_GROUP")
public class CacheUpdateConsumer implements RocketMQListener<BinlogMessage> {

@Resource
private StringRedisTemplate redisTemplate;
@Resource
private RocketMQTemplate mqTemplate;

@Override
public void onMessage(BinlogMessage message) {
String cacheKey = generateKey(message.getTable(), message.getId());

try {
// 1. 立即执行第一次删除 (防止写后读脏数据)
redisTemplate.delete(cacheKey);
log.info("立即删除缓存成功: {}", cacheKey);

// 2. 判断是否是"延迟重试"消息
if (!message.isRetry()) {
// 如果是首次 Binlog 消息,发送一条延迟 500ms 的二删消息
// 解决:读写并发导致的脏数据回填 (Race Condition)
BinlogMessage retryMsg = message.clone();
retryMsg.setRetry(true);
mqTemplate.syncSend("DB_BINLOG_TOPIC",
MessageBuilder.withPayload(retryMsg).build(),
2000, 3); // 延迟级别 3 对应约 5-10s,或使用精确延迟
log.info("投递延迟二删任务完成: {}", cacheKey);
}

} catch (Exception e) {
// 3. ================== 容错兜底 ==================
// 抛出异常,利用 MQ 自身的 ACK 机制触发重试
// 保证原子性失败场景下的最终一致性
log.error("缓存删除失败,等待 MQ 自动重试...", e);
throw new RuntimeException("Cache sync failed");
}
}
}

四、 复杂性与收益的权衡

这套方案上线后,效果如何?

  • 稳定性:彻底解决了业务代码中 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.
Comments