集群MQTT血泪史:最蠢的就是不管状态场景,统一用一种一致性算法


每个分布式系统都有不同类型的状态。

分布式系统最大的坑,就是把所有状态当同一类人对待。我们从集群MQTT代理的内存爆炸里悟出一个死理:语义决定保障,保障决定机制,机制塑造架构。把状态按“如果两个节点意见不合会怎样”分成五类,每一类配一套最低保证,再选最弱的机制去兑现。结果不是调优,是删掉了整层多余的复制。状态导向一致性、共识算法、确定性哈希、AP路由、Gossip协议——这些词不是拿来装的,是拿来填空的。



你问错问题已经很久了  

先做个思想实验。你家里有三个冰箱,一个冻冰淇淋,一个放剩菜,一个存疫苗。三个冰箱都用同一个温度行吗?肯定不行。冰淇淋要-18℃,剩菜4℃,疫苗2-8℃。没人会傻到统一设一个温度。

但到了分布式系统里,程序员集体变傻了。一堆服务器组成集群,里面住着各种“状态”:谁在线、谁离线、消息往哪转发、消息存没存、谁活着谁死了。然后架构师一上来就问:“咱们集群用强一致性还是最终一致性?”——这就好比问“咱们家冰箱设几度?”然后把所有冰箱拧到同一个数。

我们早期就是这么干的。结果呢?每次有新状态加进来,默认继承同一个一致性保证。不管它是需要立刻仲裁的“连接所有权”,还是可以随便算的“离线归属”,全被塞进同一个框架里。内存爆了怪调优,连接抖了怪网络,从来没想过是问题本身问歪了。

真正该问的是:这条具体状态,如果两个节点暂时看法不一样,后果有多严重? 严重程度就是它的“语义”。语义定了,最低保障就定了,保障定了,用什么机制就几乎不用动脑子了。这个链条,我们花了三次OOM才彻底刻进脑子里。

一次OOM撕开了“统一一致性”的遮羞布  

说个真事。集群里三个Pod,内存上限512MB。两个Pod被内核的OOM杀手干掉了。一开始查负载均衡,发现连接数最多那个是连接数最少那个的130倍,但内存只差90MB。连接数翻130倍,内存几乎没变,说明内存里的东西跟连接数关系不大。

查代码才发现:每个Pod一启动,就把数据库里所有客户端的会话状态全部加载到内存。不管这个客户端将来会不会连到它,先全塞进来。为什么这么设计?因为设计者默认了一个前提:任何Pod都可能接到任何客户端的重连请求,所以每个Pod都得准备好所有人的状态。

这个前提听起来“以防万一”,但它的语义是什么?它的语义是“每个节点都对所有客户端负责”。但这个语义是错的。一个节点真正需要负责的,只是那些通过某种规则算出来“属于我”的客户端。其他客户端的离线状态,你提前加载了就是白占内存。

我们给这种“不区分状态、统一套用同一种准备策略”的习惯起了个名字:统一一致性。它不是算法,是一种默认倾向——用同一种保证覆盖所有状态,省事,但代价是每个节点都复制了全集群的数据。节点越多,复制成本越高,直到内存撑爆。

而真正解决问题的起点,是把所有状态列出来,逐行问:这个状态,到底需要什么? 这就是语义分析的开始。

语义 → 保障 → 机制:三列拆掉一切  
下面这张表,不是总结,是整个架构的“宪法”。它必须读成三列,不是两列。因为第三列是第二列逼出来的,第二列是第一列逼出来的。顺序不能反。

| 状态(语义) | 最低保障(因为语义) | 机制(兑现保障) |
|------------|-------------------|----------------|
| 实时连接所有权 | 单一权威主人,立刻决定 | 小规模协调器(共识算法) |
| 离线客户端所有权 | 确定性,可独立计算,无需仲裁 | 确定性哈希(每个节点自己算) |
| 消息路由 | 可用即可,短暂陈旧能接受 | AP型路由(容忍旧数据) |
| 持久消息状态 | 绝对不丢,比可用性更重要 | 持久共享存储(同步落盘) |
| 集群成员 | 最终一致,临时误判无大碍 | Gossip协议(闲聊式传播) |

一行一行拆开看。

第一行,实时连接。一个设备连上来了,它到底挂在哪个节点上?如果两个节点都以为自己是主人,消息就会重复推,或者互相踢下线。如果两个节点都以为对方是主人,那这个连接就变成幽灵,没人管。所以它的语义是“必须唯一且即时”。这个语义决定了最低保障是“单一权威主人,且裁决不能等”。这个保障决定了机制——必须有一个能快速达成共识的小圈子,也就是协调器。协调器只干这一件事,不掺和别的。

第二行,离线状态。设备断网了,它的订阅信息、未取消息还留着。如果两个节点对“谁存着这份离线数据”有分歧,没关系,设备还没连上来,等它连上时再算。连到谁就算谁的。所以它的语义是“可以等,不需要实时争抢”。保障就成了“确定性计算”——任意节点用同一套哈希算法都能算出同一个归属。机制就是哈希环,没有协商,没有投票,各自算各自的。

第三行,路由表。消息来了,按主题找目标节点。如果路由信息稍微旧一点,比如把消息发到了一个已经不存在的节点,没关系,重试或等下一次路由刷新就行。语义是“容忍秒级陈旧”。保障就降级为“可用性优先,一致性靠边”。机制就是AP风格的路由缓存,本地过期了就从别处拉,不阻塞转发。

第四行,持久消息。这是已经存下来的、还没被设备取走的消息。丢了就是永久丢,用户投诉电话能打爆。语义是“绝对持久,比什么都重要”。保障就是“强耐久性,不丢一条”。机制只能用同步落盘的共享存储,哪怕写入慢一点,也必须确认落盘才返回成功。

第五行,成员关系。节点挂了,其他节点可能过几秒才知道。这段时间里可能把请求发给死节点,但请求会失败重试,最终总会纠正。语义是“临时误判可以接受,只要最终一致”。保障就是“最终一致性”。机制就是Gossip,节点之间没事聊聊“你还活着吗”,消息量小,实现简单。

注意,每一行的机制都彼此独立。协调器不存消息,哈希环不管路由,Gossip不问持久。每个机制只服务于自己那行状态的保障。把这张表当成三列来读,你就不会问“协调器能不能顺便把路由也管了”——因为路由的保障根本不需要共识,你硬塞给它反而拖慢全局。

机制一旦独立,架构自动变干净  

以前我们总想把一个组件复用给多个功能,觉得省事。协调器已经跑起来了,顺手让它存一下路由表吧;存储已经有了,顺便把离线状态也塞进去吧。结果每个组件都负重前行,边界模糊,出问题不知道怪谁。

但按三列逻辑走,每个机制只兑现一行保障。协调器只接第一行的活,绝不让消息流量经过它。因为共识算法的延迟是毫秒级的,一旦让它承载大流量,整个系统的吞吐量就被拖进共识的泥潭。哈希环只算归属,不存储任何数据,算完就扔。路由缓存自己过期自己刷新,不依赖外部仲裁。存储只管落盘,不参与任何仲裁。Gossip纯聊天,不碰业务数据。

这种“各管一摊”的架构,不是设计出来的,是被每一行的语义逼出来的。逼出来的东西比设计出来的更结实,因为每个组件都有非做不可的理由,而不是“顺便干点别的”。

我们后来连滚动升级都变得无感。因为协调器小,升级时停几秒不影响消息流;哈希环随时可重算;路由缓存秒级自愈;存储有持久层兜底;Gossip天生容忍节点变更。每个组件独立升级,风险相互隔离。

最难的不是选机制,是承认“这里不需要机制”  

很多人看完表会问:你们最后选了哪种共识算法?Raft还是Paxos?说实话,这个问题是我们整个改造里最不重要的。协调器那行,随便找个成熟的Raft库就能干活。真正难的是其他四行——你要反复说服自己:这里真的不需要共识。

比如离线状态那行,你本能地会觉得“归属问题也得仲裁一下吧,万一两个节点同时算呢?”但仔细一想,只要哈希算法确定,两个节点算出来的结果一模一样,根本不存在“同时算但不同”的情况。不需要商量。不需要投票。别把共识当万金油。

再比如路由表,你可能会想“路由信息旧了会不会导致丢消息?”但消息有重试,路由会刷新,丢不了。为了一个秒级自愈的问题,去搭建一套强一致的路由同步,纯属杀鸡用牛刀。

最难的技术决策,往往是决定“什么不做”。而我们习惯性地选择“先做了再说,以防万一”。这个习惯就是统一一致性的温床。

一旦你开始逐行追问“如果这里不做任何机制,最坏会怎样”,你会发现很多状态根本没你想的那么娇贵。娇贵的只有那一两行,给它们最重的保证,其他行给最轻的。这样整个系统的资源开销跟集群规模解耦,只跟真正需要仲裁的那一小块数据挂钩。

我们拔掉的不是bug,是一个默认假设  

改完之后的对比很讽刺。

改之前,每个节点启动先吃掉270-360MB内存,还没服务一个客户端。改之后,启动时只加载它哈希算出来的那部分离线状态,通常不到50MB。内存溢出再也没有发生过。

但数字不是重点。重点是我们移除了那个让“全量加载”看起来合理的默认假设:任何节点都该准备好服务任何客户端。 这个假设被“每行状态各自定归属”的逻辑替代了。

以前我们花大量精力调优内存、调负载均衡、调OOM阈值,全是治标。真正治本是承认:你之前给状态配的保障太高了,高到它不配。你给所有状态都上了“全量复制”的保证,但其中四行根本不需要。

调优是改参数,重构是删代码,而改假设是改掉你面对问题时的第一反应。那个OOM再也没有出现过,不是因为我们把代码写得更精妙了,而是因为我们再也不把“全量加载”当成唯一的答案。

分布式系统里,没有“唯一正确答案”。只有“对这个状态,此刻最不傻的保证”。你把每一行状态的语义读懂了,保障自然浮现,机制自然跟上,架构自然长成它该有的样子。而我们花了好几年,才学会不再跟问题本身较劲,而是跟“默认假设”较劲。



总结  

四个字:逐行定策。

语义问“出多大事”,保障定“最少做啥”,机制选“最弱够用”。

剩下的就是让每个组件只干一件正事。最难的不是设计架构,是承认大多数状态压根不配你那么紧张。

别再问用哪种一致性了,先给状态排个队:语义定保障,保障定机制  。



原文期刊 / 发表日期:Keel Blog / 2026年8月6日  
原文标题:状态导向一致性:我们为何不再寻求唯一正确答案——基尔  
作者单位背景:Keel MQTT Gateway 开发团队