jktantan头像

【混合缓存】五种同步通道与它们的失效模式:为什么 Pub/Sub 不可靠反而是对的(五)

混合缓存的同步层有五个实现:Redis Pub/Sub、RocketMQ、Kafka、RabbitMQ、NATS。选型时大家比较的是「延迟、吞吐、运维成本」,但真正决定线上表现的是三种失效形态——丢、重、乱。这篇文章把五种通道拆到这三种形态上,并论证一件事:因为同步消息传的是「失效」而不是「数据」,重和乱几乎无害,只有丢要兜底。


一、痛点:三种「以为可靠」的错觉

1.1 错觉一:以为 MQ 的持久化等于「同步不会丢」

早期我把同步通道从 Redis 换成了 RocketMQ,理由是「Redis Pub/Sub 不持久化,会丢消息」。

换完之后确实没再出现「订阅者重启期间漏消息」的问题,但线上仍然有新节点读到旧值的情况。查下来是这样一条链:

  1. 节点 C 启动时,消费者还没连上 Broker;
  2. 这期间节点 A 写了一条缓存,消息被 Broker 收下;
  3. 节点 C 的消费者连上之后从最新的偏移开始消费(auto.offset.reset=latest),跳过了那条消息;
  4. 节点 C 的本地缓存里是更早的旧值,一直用到 TTL 到期。

「消息持久化了」和「这个节点会收到」是两件事。 Broker 里有消息,不代表你的消费者会读到它——取决于消费位点从哪里开始。

1.2 错觉二:以为「至少投递一次」等于「处理一次」

另一个教训来自重复投递。

某个节点处理同步消息时抛了异常,框架把它退回队列重投。重投之后又抛——因为失败的原因(某个 key 的对象反序列化失败)是稳定复现的。于是这条消息在队列里来回滚了几十次,消费线程被占满,后续所有同步消息都堵在它后面。

「至少一次」的另一面是「可能无限次」。 没有死信队列、没有重试上限的通道,一条毒消息就能把整个同步层按住。

1.3 错觉三:以为五个通道只是「换一个实现类」

我最初真的以为只要改一行 sync-type 就能换通道。实际上五个实现的差异远大于「API 不同」:

差异点后果
广播靠什么实现独立 group / 独立队列 / 独立 durable,每种通道做法不同
有没有 ACK无 ACK 的通道「处理失败」和「处理成功」无法区分
消费位点从哪开始新节点补不补历史消息,直接决定「重启后的一致性」
失败后怎么办重投、丢弃、还是根本不重试

这些差异不会体现在接口签名上,全部藏在实现里。切换通道前不看这三件事,等于在不知道兜底逻辑的情况下换掉安全网。

1.4 我最初的错误做法

错误一:把同步当成「数据复制」来设计兜底。

一开始我担心「消息丢了一条,数据就少了一份」。其实同步消息从不携带值,只携带 cacheName + key + 操作类型;接收方收到之后只做一件事——把本地那个 key 删掉。

错误二:为「消息乱序」设计序列号。

我认真考虑过在消息里加自增序号,接收端丢弃旧序号的消息。实现到一半发现没必要:因为同步动作是「删除本地副本」而不是「写入新值」,乱序执行的结果和顺序执行完全一样——删两次和删一次等价。

错误三:认为 Redis 默认选项是「够用就行」的选择。

默认 sync-type: REDIS 看起来像「没想清楚就用最简单的」。实际上它是对的:这套系统的同步层只需要低延迟的「失效通知」,不需要可靠投递。真正必须可靠的场合,兜底不是通道,而是时间上界。


二、问题拆解:丢、重、乱,以及为什么只关心丢

2.1 同步消息到底在传什么

先看消息模型。五个字段,没有一个携带业务值:

字段作用
cacheName路由到哪个缓存实例(全限定名,含前缀)
key要失效的键;CLEAR 时为 null
operationTypePUT / EVICT / CLEAR
timestamp生成时间,仅用于排查
sourceNode发送方节点 ID,用于回环过滤

接收端的处理逻辑(MultiLevelCache#handle)很短:

switch (message.getOperationType()) {
    case PUT -> {
        if (key != null) {
            localCache.evict(key);
        }
    }
    case EVICT -> {
        if (key != null) {
            localCache.evict(key);
        }
    }
    case CLEAR -> localCache.clear();
}

注意 PUT 分支也是 evict。别人的写入在我这里变成「删掉我的本地副本」,下次读的时候我会回远程取新的。这是整个设计的核心:同步传播的是「你不知道了」,不是「你应该知道什么」。

2.2 三种失效形态的危害不对称

形态具体表现对本系统的影响需要处理吗
丢某条消息没有被任何节点收到本地副本陈旧直到 TTL 到期✅ 必须兜底
重同一条消息被处理两次以上第二次 evict 一个已经不存在的 key❌ 幂等,无害
乱B 的消息先到、A 的后到两次 evict 顺序无关❌ 幂等,无害

这张表是本文最重要的一张。它解释了两个此前看起来奇怪的设计选择:

  1. 为什么消息里没有版本号:不需要,因为动作是幂等的删除,不是有状态的覆盖;
  2. 为什么可以接受「至少一次」:重复无害,所以不必追求「恰好一次」——而「恰好一次」在分布式消息里本来就要靠额外成本换。

于是问题简化成:五种通道各自的「丢」发生在什么时候,兜底靠什么。

2.3 丢的四种来源

来源触发条件影响范围
发布时无人订阅接收方没连上 / 监听器注册失败那一段时间内所有节点
消费位点跳过消费者从 latest 开始新启动或长期离线的节点
处理失败后丢弃反序列化失败、重试超限单条消息
发布方自己失败网络、序列化异常单条消息

这四种在五种通道上的分布完全不同,下一节逐一对。


下面这张图把「丢、重、乱」三种形态与最终结论串起来:

丢

重

乱

同步消息:cacheName + key + 操作类型

失效形态

本地副本陈旧
直到 TTL 到期

重复 evict
幂等,无害

乱序 evict
幂等,无害

必须兜底
forceExpiryTime

可接受至少一次

问题收敛为:
各通道何时丢、靠什么兜

三、实现:五种通道,五种丢法

3.1 整体结构

REDIS

ROCKETMQ

KAFKA

RABBITMQ

NATS

是

否

节点 A
put/evict

publishSyncMessage
sourceNode = A

sync-type

Pub/Sub
不持久化

Topic
独立 group

Topic
每节点 group

Fanout
每节点队列

JetStream
每节点 durable

节点 B / C 消费

sourceNode
是本节点?

丢弃(回环)

evict 本地副本

看图要点:广播语义由各自的「每节点一份订阅」实现;回环过滤在消费端统一做;五种通道的分叉点在传输层,汇聚点之后的逻辑完全相同。

3.2 Redis Pub/Sub:不持久化是特性,不是缺陷

long receivers = topic.publish(messageBytes);
if (receivers == 0) {
    log.warn("发布缓存同步消息但无订阅者接收: nodeId={}, channel={}, message={}",
            nodeId, channel, message);
}

RTopic.publish 返回收到消息的订阅者数量。这个返回值在这里被用来做一件事:当它是 0 时打一条 warning。

它能发现什么:本进程的监听器没注册上、Redis 连接异常、channel 配错。
它不能发现什么:其他节点的订阅者离线。因为 receivers 只统计当前连接着的订阅者——正好是「收不到的人不会出现在这个数字里」。

Redis Pub/Sub 的失效形态很纯粹:

场景结果
订阅者在线立即收到,延迟最低(同机房亚毫秒级)
订阅者离线消息直接消失,不排队、不补投
订阅者处理失败消息已经被 Redis 扔掉,无处重试
Broker(Redis)重启所有订阅关系重建,期间的消息全丢

这看起来是最差的可靠性,但它的代价换来了两件事:延迟最低、不需要额外的中间件(Redis 本来就在)。而丢的后果被 forceExpiryTime 兜住了——最多陈旧 N 秒。

所以「Pub/Sub 不可靠」不是缺陷,是这套设计的匹配点:同步层负责「尽快通知」,时间上界负责「一定会通知」。两者分工,不需要同步层保证可靠。

3.3 Kafka:每节点一个 group,但位点会跳过

Kafka 实现的首要问题是广播:同一个 group 里多个消费者会分区分摊,不是广播。解法是每个节点用独立的 group:

this.groupId = groupIdPrefix + "-" + nodeId;

nodeId 是 NodeIdHolder 的 ULID,进程启动时生成一次。于是每个节点是一个独立消费者组,都能收到全量消息——这就是「广播」在 Kafka 上的实现方式:N 个 group,N 份全量消费。

代价也在这里:N 个节点 = N 份存储读取,Broker 的出口带宽随节点数线性增长。

真正危险的是消费端配置:

props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);

三行配置决定了三种丢法:

配置丢法说明
auto.offset.reset=latest跳过历史新 group 没有已提交位点时,从头开始 = 从最新开始,之前的所有消息都不消费
enable.auto.commit=true先提交后处理处理抛异常也会在下次 poll 时提交位点,那条消息永久丢失
max.poll.records=100批量失败一批里一条失败,其余照常处理,失败那条不重试

latest 与「每节点独立 group」叠加,产生了一个反直觉的后果:节点重启后 nodeId 变了(ULID 每次进程启动重新生成),group 名随之变化,Broker 把它当成一个全新的消费者组——而新组的位点策略是 latest。

所以 Kafka 通道在节点重启时的行为是:离线期间的所有同步消息都不会被补投。持久化没帮上忙,因为消费位点是从「现在」开始的。

这个组合不是 bug——它和 Redis 的定位其实一样(只保证在线期间的投递),但它披着「消息持久化」的外衣,容易让人误以为更可靠。

把「每节点独立 group」与「latest 位点」叠加后的时序画出来,就能看清为什么持久化没帮上忙:

"节点 C(重启)""Broker""节点 A""节点 C(重启)""Broker""节点 A""进程重启,nodeId 变化group 名随之变化""本地副本陈旧,直到 TTL 到期""发布同步消息 m1""以新 group 订阅""从 latest 开始,m1 被跳过"

3.4 RocketMQ:会重投,但重投不等于重新处理成功

RocketMQ 用同样的思路做广播:每节点独立 consumerGroup。消费端的语义比 Kafka 明确:

return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
// 或
return ConsumeConcurrentlyStatus.RECONSUME_LATER;

RECONSUME_LATER 让 Broker 稍后重投。这带来两个后果:

后果说明
重投可能重复处理成功但返回失败,或 ACK 丢失,都会导致重复消费
重投没有上限稳定复现的毒消息会反复重投,直到达到 Broker 的最大重试次数

ConsumeConcurrently 这个模式名也说明了并发消费、不保证顺序。

但回到 2.2 那张表:重复和乱序对「删除本地副本」这个动作无害。真正需要担心的是重投期间的延迟——一条消息要等重试间隔过去才会再次尝试,这期间该节点的本地副本是陈旧的。

3.5 RabbitMQ:Fanout 广播,失败处理要自己定

RabbitMQ 是唯一一个「原生广播」的通道:Fanout Exchange 把消息复制到所有绑定队列,每个节点声明自己的队列:

channel.exchangeDeclare(exchange, BuiltinExchangeType.FANOUT, durable, false, null);
queueName = queuePrefix + "-" + sanitizeQueueName(nodeId);
channel.queueDeclare(queueName, durable, false, false, null);
channel.queueBind(queueName, exchange, "");
channel.basicQos(1);

basicQos(1) 表示一次只预取一条——对同步消息这种小体量、低延迟的场景合理(处理完再取下一条,避免一个节点堆积后批量处理)。

手动 ACK,失败的处理分两种:

if (msg == null) {
    log.warn("反序列化缓存同步消息为空");
    channel.basicNack(envelope.getDeliveryTag(), false, false);   // requeue=false → 丢弃
    return;
}
// ...
} catch (Exception e) {
    channel.basicNack(envelope.getDeliveryTag(), false, true);    // requeue=true → 重投
}

这两行是整套实现里最有判断力的地方:

失败类型requeue理由
反序列化失败false(丢弃)字节流已经坏了,重投一万次也解不出来
处理异常true(重投)可能是瞬时故障(如本地缓存被关闭),值得重试

「不可重试的错误不重投」这条判断,比任何 QoS 配置都重要。把毒消息留在队列里,代价是整个队列的吞吐。

不过 requeue=true 在 RabbitMQ 上有个已知行为:消息会被立即重新入队,如果没有延迟队列配套,稳定复现的异常会导致热循环重投——消息几乎零间隔地反复回到消费者手上,CPU 和日志都会被刷爆。

3.6 NATS JetStream:durable 名字里藏着一个坑

NATS 的实现看起来最「正确」:JetStream 持久化、每条消息 ACK/NAK、每节点独立 durable consumer。

String durableName = consumerPrefix + "-" + sanitizeConsumerName(nodeId);
PushSubscribeOptions pso = PushSubscribeOptions.builder()
        .stream(streamName)
        .durable(durableName)
        .configuration(ConsumerConfiguration.builder()
                .deliverPolicy(DeliverPolicy.New)
                .build())
        .build();

durable 的语义是「这个消费者的位点由服务端记住,断开重连后继续」。看起来能解决 Kafka 的「重启就跳过」问题。

但 durable 名字里带了 nodeId,而 nodeId 是进程启动时生成的 ULID。所以:

  1. 进程重启 → nodeId 变化 → durable 名字变化;
  2. 服务端把它当成一个全新的 durable,没有已提交位点;
  3. DeliverPolicy.New 生效 → 从「现在」开始,之前的消息一笔勾销。

结果和 Kafka 完全一样:离线期间的消息不补投。 durable 的断点续传能力,被一个每次都会变的消费者名字抵消掉了。

这个坑特别值得记:「我用了 durable」和「我得到了续传」之间,隔着消费者标识的稳定性。任何把「实例 ID」放进消费者名的设计,都自动放弃了续传。

3.7 回环过滤:所有通道共用一段

五个实现里都有一段相同的判断:

if (NodeIdHolder.isCurrentNode(message.getSourceNode())) {
    return;   // 自节点回环,正常路径:直接吞掉不处理
}

sourceNode 在发布端写入,消费端与本节点 ID 比对。这段逻辑不依赖通道,所以它被放在消费回调的最前面。

Redis 实现里还有一段值得看的注释——它纠正了一个容易犯的错:

// Redisson 侧信息(注意:RTopic.MessageListener 收到的是 CharSequence 频道 + payload,
// Redisson 4.x 的 Message<byte[]> 没有 getRedissonClientId() 之类的方法——
// 这里 channelFromRedis 是发布端/订阅端配置的 channel 名,跟 sourceNode 不是一回事。
// 想区分"是不是本节点发出去的"仍要靠反序列化后的 message.sourceNode 与 NodeIdHolder 比对。)

为什么「频道名」不能用来判断回环:频道名是配置项,同一个应用的所有节点用的是同一个频道。它标识的是「哪个缓存主题」,不是「谁发的」。

另一个细节:addListener 返回的 listenerId 小于 0 时打 error 日志。这是一个「注册失败」的显式信号——如果监听器没注册上,本节点从此收不到任何同步消息,而发布端看不出任何异常(别人的 receivers 里根本没有你)。

3.8 热切换:为什么旧发布器要延迟销毁

同步配置本身支持热更新(reloadSyncPublisherIfChanged),流程是「先建新 → 校验 → 原子切换 → 延迟销毁旧」:

// Step 1:创建并初始化新发布器(失败则不切换)
newPublisher = syncPublisherFactory.create(newSyncConfig);
// Step 2:原子替换(旧发布器不再接收新的 publish 调用)
CacheSyncPublisher oldPublisher = this.syncPublisher;
this.syncPublisher = newPublisher;
// Step 5:旧发布器延迟销毁(消费端继续接收窗口期未完成的消息)
Duration delay = newSyncConfig.getOldPublisherDestroyDelay();

为什么旧的要留一段时间:旧发布器的消费端可能还有 in-flight 消息没处理完(RabbitMQ 的手动 ACK、NATS 的 ack 都可能在路上)。立即销毁等于主动丢掉这批消息。

这里有一处注释与实现不一致值得记下:KafkaCacheSync 的类注释写「热切换时旧实例保留 2 小时」,MultiLevelCacheManager 的方法注释也写「旧发布器 2 小时后完全销毁」,但配置项 oldPublisherDestroyDelay 的默认值是 60 秒。

以配置为准:默认窗口是 60 秒。注释里的「2 小时」大概是早期版本的设定,改配置时没同步改注释。这类偏差在排查「为什么切换后还有旧消息进来」时会造成误判。


热切换的完整流程如下,注意「延迟销毁」这一步是刻意保留的窗口:

否

是

检测到同步配置变更

创建并初始化新发布器

初始化成功?

保持旧发布器
记录错误日志

原子替换 syncPublisher

旧发布器延迟销毁
默认 60 秒

窗口期内消费端继续处理
in-flight 消息

窗口结束,旧发布器销毁

四、权衡、边界与坑

4.1 通道对照表(本文核心产出)

通道广播实现持久化消费位点丢的时机兜底手段
Redis Pub/Sub同频道所有订阅者❌ 无无位点概念订阅者离线 / 处理失败forceExpiryTime
RocketMQ每节点独立 group✅Broker 管理重试超限 / 位点过期重试 + forceExpiryTime
Kafka每节点独立 group✅latest + 自动提交新 group 跳过历史 / 自动提交先于处理forceExpiryTime
RabbitMQFanout + 每节点队列✅(durable 可配)手动 ACK + QoS 1反序列化失败丢弃 / 队列未持久化ACK + forceExpiryTime
NATS JetStream每节点 durable✅(File 存储)durable + DeliverPolicy.New节点重启后 durable 名变化 → 跳过历史forceExpiryTime

最后一行(兜底手段)是关键:五种通道的兜底全部包含 forceExpiryTime。这不是巧合——它说明同步通道在这套设计里从来不是一致性保证的来源,只是一条「尽快」的路径。

4.2 选型判据

你的约束选择理由
已有 Redis,节点数 < 20,能接受 N 秒陈旧REDIS零额外组件,延迟最低
已有 Kafka,节点数多,不想再引入 MQKAFKA高吞吐,N 份消费的成本可接受
需要可靠投递 + 原生广播RABBITMQFanout 语义最直接,手动 ACK 可控
已用 RocketMQ,需要事务消息ROCKETMQ生态一致
云原生、节点数 100+NATS轻量,JetStream 可靠广播

注意这张表里没有任何一行是因为「一致性」而选的。选通道看的是运维成本和延迟量级,一致性由时间上界统一兜底——这是本文想说清的分工。

4.3 踩过的四个坑

坑 1:以为换了 MQ 就不会丢消息。

  • 现象:从 Redis 换到 Kafka 后,仍有节点读到旧值。
  • 定位:节点重启后 nodeId 变化 → group 名变化 → 新 group 从 latest 开始,离线期间的消息全部跳过。
  • 修法:要么接受(靠 TTL 兜底),要么把消费者标识做稳定(不能用每次启动都变的 ULID),并把位点策略改成 earliest。

坑 2:RabbitMQ 上一条毒消息把队列打满。

  • 现象:某个节点日志刷屏,同步消息处理延迟飙升。
  • 定位:basicNack(requeue=true) 是即时重投,稳定复现的异常形成热循环。
  • 修法:区分「可重试」与「不可重试」,反序列化失败必须 requeue=false;再配合重试上限或死信队列。

坑 3:以为 durable consumer 就能续传。

  • 现象:NATS 节点重启后,离线期间的变更没同步到。
  • 定位:durableName = consumerPrefix + "-" + nodeId,nodeId 每次启动重新生成;加上 DeliverPolicy.New,等于每次重启都是新消费者。
  • 修法:用稳定的消费者名(如 hostname + 固定序号,或由配置显式指定),并评估是否要改成 DeliverPolicy.All。

坑 4:以为热切换后旧通道立即失效。

  • 现象:切换通道后,日志里还能看到旧通道的消息被处理。
  • 定位:旧发布器延迟销毁(默认 60 秒),这期间消费端仍在工作。这是设计如此——为了不丢 in-flight 消息。
  • 修法:不用改,但要理解这段窗口内两条通道并存。切换后的短暂重复消费是无害的(幂等删除)。

4.4 同步层防不住什么

风险能否防住说明
在线节点收不到失效通知✅ 基本能通道正常时延迟很低
离线节点错过变更❌五种通道都会跳过离线期间的消息
毒消息阻塞整个通道⚠️ 部分取决于通道的失败策略与重试上限
跨应用误消费⚠️靠 cacheName(含前缀)过滤,配错前缀会串
一致性窗口❌同步只让窗口更小,上界永远由 TTL 决定
Redis 整体不可用时的同步❌REDIS 通道跟随 Redis 一起失效

第一行和第五行要分开看:同步能把「通常情况下的不一致窗口」压到毫秒级,但它不改变「最坏情况」——最坏情况永远由 forceExpiryTime 决定。这就是为什么专题里 S-1 先讲时间上界、S-4 才讲通道:通道是优化项,上界是保底项。


五、可以直接抄走的清单

  1. 先明确同步消息传的是「失效」还是「数据」。传失效时重复与乱序无害,可以放弃「恰好一次」。
  2. 在消息里带 sourceNode,消费端第一件事就是过滤回环,不要用频道名判断。
  3. RTopic.publish 的返回值是当前在线订阅者数,为 0 时打 warning——它能发现注册失败,但发现不了别人离线。
  4. 用 MQ 做广播时,每个节点必须有独立的 group / 队列 / durable,否则会变成集群内分摊消费。
  5. 消费者名字里不要放每次启动都变的 ID,否则 durable、位点续传全部失效。
  6. Kafka 的 auto.offset.reset 默认 latest,意味着新节点不补历史消息;要补就改 earliest,但要评估重放量。
  7. enable.auto.commit=true 是「先提交后处理」,处理失败也会前进——需要严格语义时改手动提交。
  8. 手动 ACK 的通道必须区分可重试与不可重试:反序列化失败直接丢弃,不要 requeue=true。
  9. 别用 requeue=true 做重试,除非有延迟队列或重试上限,否则会热循环。
  10. 热切换同步通道时,旧发布器要延迟销毁,给 in-flight 消息留处理窗口;默认 60 秒,别看注释里的「2 小时」。
  11. 所有通道的兜底都是同一个 forceExpiryTime。不要指望换通道能把「最坏陈旧时长」变小。
  12. LOCAL 模式不发布同步消息;REMOTE 模式收到消息直接返回——这两条是省开销的,不要「统一处理」掉。

附:一条消息的一生

阶段可能出的问题后果兜底
序列化Kryo 未注册类发布端抛异常显式注册 CacheSyncMessage
发布无订阅者 / 网络失败这条变更没传出去TTL
传输通道不持久化消息消失TTL
消费入口反序列化失败丢弃或重投丢弃(requeue=false)
回环过滤sourceNode 缺失自节点重复处理无害(幂等)
执行本地缓存已关闭抛异常 → 重投重投 + TTL

最后一行值得单独说:消费阶段抛异常时,最坏结果不是「数据不一致」,而是「消息堆积」。因为真正的一致性上界由 TTL 保证,堆积只影响延迟。这也是为什么可以接受「不来也行」的通道设计——系统的正确性不依赖消息一定到达。

把「一条消息的一生」按阶段串成流程图,可以一眼看出每个环节的兜底落在哪里:

是

否

反序列化失败

无订阅者/网络失败

通道不持久化

本地缓存已关闭

序列化
Kryo 注册

发布
publishSyncMessage

传输
通道投递

消费入口
反序列化

sourceNode
是本节点?

丢弃(回环)

执行 evict 本地副本

丢弃 requeue=false

TTL 兜底

重投 + TTL

一致性上界由 forceExpiryTime 保证

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/jkhyf/article/details/170378726

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--