消息链路必须按跳数比
第二轮。从本地 id 到对端可见,只比较 hop 顺序与失败边,不再摊功能表。
第一轮把「有没有 seq / 有没有 MQ / 支不支持大群」摊成功能表,比出来的是架构标签,不是失败边。同一条私聊从本地 id 到对端可见,只有 hop 顺序是可证伪的:H5 persist 或入队、H6 seq 出生、H7 sender-ack 谁先谁后,决定超时重试是同一条消息还是第二条,也决定杀进程后对端补不补得回来。出厂关发送(H4 硬拒、H5–H8 永不发生)和 MQ 打开后的 persist 路径不是同一条流水线的两个开关,不能压成一列「发送」。
同品类只取 OpenIM、野火、Tinode、JuggleIM、Turms 与 layababa。Rocket.Chat 与 HuLa 只作参照。下文 hop 核自各仓本地树与当时工作树;layababa 相对 origin/dev 的漂移本轮未用 git show 复核。
一、为什么必须按跳数比
功能表会把三件不同的事收成一个勾:发送成功、已持久化、对端已看见。它们在所有被核过的树上都不是同一个 RPC 结果。
可比较的只有同一条 hop 链:H1 铸本地身份;H2 客户端落库或 outbox;H3 上线;H4 persist 前硬拒(无行、无 seq);H5 服务端 persist 或入队;H6 seq 出生;H7 回给发送方的 ack;H8 对端通知或推送。超时、崩溃、重连、换设备、群扇出,都是这条链在失败边上的同一组 hop。第一轮漏掉的正是 H6 钉在哪一跳。OpenIM 的回执没有 Seq、野火的 PUBACK 没有每人序号、Tinode 的控制帧带着 params.seq——三者都「有消息 id」,hop 完全不同。
还有一条必须拆开的边:H4 拒发。layababa 出厂走 refuseWithoutNewChainOwner 抛限流;野火超频、Turms 请求过频、Tinode 503、JuggleIM 连接超限都是同一跳:身份未铸、行未写。把它和 MQ01 先可见再落库、MQ06 先落库再出箱画进同一条发送路径,后续所有崩溃对比都会错位。
二、一条私聊发出去:从本地 id 到对端可见
OpenIM(ack 在 seq 之前)
- H1 铸
ClientMsgID,本地 Seq=0。 - H2 插入本地发送中。同号且非失败则重复错误。
- H3 WebSocket 请求响应,等待十秒。
- H4 入站限流默认关;打开则耗尽、无消息。
- H5 每次新的服务器消息号,入队。
- H7 回执只有服务器号、客户端号、时间,没有 Seq;SDK 标成功,本地 Seq 仍为 0。
- H6 之后:传输进程分配序号、写缓存、再落库、再入推送队。
- H8 推全文(含 Seq)。发送方靠同步回填 Seq。
缺跳:发送回执上没有 seq;服务端不对客户端号做唯一约束。
野火(ack 在 seq 之前,收件箱序号另铸)
- H1 社区协议无客户端本地号。
- H2 官方客户端不在本树。
- H3 MQTT QoS1。
- H4 漏桶拒绝则 PUBACK 带超频,不生成号。
- H5 雪花号进 Hazelcast;SQL 只是调度。
- H7 PUBACK 带回 mid 与时间。
- H6 之后给每个接收方铸个人序号。
- H8 QoS0 通知带水位,对端再拉。
缺跳:ack 不含 seq;无 clientMessageId。
Tinode(先 persist 出 seq,再 ack)
- H1 发布帧的 id 只是请求相关,空 id 仍落库,只是不回 202。
- H3 发布。
- H4 广播满则 503,无保存。
- H5–H6 取 lastID+1,先改主题再插消息(非一事务)。
- H7 控制帧 202,含义是已保存,带着 seq。
- H8 已挂接推数据帧;未挂接推出席提示再拉取。
缺跳:同号检查仍是待办;发布 id 不幂等。
JuggleIM(seq 在 ack 前铸,persist 异步)
- H1 可选客户端号,空则服务端生成。
- H3 发布需确认。
- H4 限流则空号、seq=0。静默拦截器也回成功且 seq=0、不落库,ack 形状与真发送相同。
- H6 锁内递增会话序号。
- H7 确认带消息号与序号。含义是序号已分配且发件箱、历史、分发已入队,不是收件箱已提交。
- H8 首条或单端推全文,否则通知再同步。
缺跳:ack 不等于 persist;去重只是十分钟进程缓存。
Turms(打开持久化则插入后 ack;默认无会话 seq)
- H1 请求号只做远程调用相关。
- H4 网关令牌桶拒则无远程调用。
- H5 插入雪花号。打开序号则先自增再插入。
- H6 默认不发生;打开后序号也不上推送体。
- H7 成功通知;持久化打开则带消息号。
- H8 返回后再异步通知相关用户。
缺跳:无请求号幂等;推送体无序号。
layababa 出厂关发送(H4 终结)
- H1 乐观行加发送出箱,客户端号已固定。
- H2 同号持久出箱。
- H3 长连接或 HTTP,从不并行。
- H4 热私聊不接受 → 拒发 → 限流。无预备、无库、无队列、无序号。
- H5–H8 整段缺跳。客户端按限流每五秒重试同一号。
layababa 打开 MQ01 私聊
- H1–H3 同出厂。
- H6 先于 ack:Redis 自增并预备;同摘要走已有,回收同一序号。
- 发布确认;超时或否认则保持预备,禁止新序号。
- 标可见并等耐久门槛;日志写明库仍可能待写。
- H7 回执带着序号。库由消费者在 ack 之后写成已持久。
- H8 等到可见或已持久且摘要匹配才推全文。
缺跳:H7 不等于库耐久;确认未知被做成 HTTP 200;已声明不可重试的未知结果码,发送路径零抛出。
Rocket.Chat 参照:随机 id 即主键,插入后接口成功;事后钩子在返回之后。无 H6。HuLa 参照:本地临时号,接口回写;服务端 hop 不在树内。
发送 ack 只允许这一张表:
| 路径 | H7 实际承诺 | H6 是否已发生 | 耐久行是否已提交 |
|---|---|---|---|
| OpenIM | 入队 | 否 | 否 |
| 野火 | 接受 mid 与时间 | 否(收件箱序号更晚) | 缓存已放,SQL 未保证 |
| Tinode 持久化开 | 202 等于保存成功 | 是 | 是(两步,非一事务) |
| JuggleIM | 序号加已入队 | 是 | 否 |
| Turms 持久化开 | 插入完成 | 默认否 | 是 |
| layababa MQ01 | Redis 已可见 | 是 | 库可能还没有 |
| layababa MQ06 群 | 库加出箱保证 | 是 | 是 |
| layababa 出厂关 | 限流 | 永不 | 永不 |
没有任何一家把「对端设备已投递」放进这条发送 ack。
三、超时之后:同号重放还是第二条消息
问的不是支不支持重试,是 H3 丢失之后 H1 是否复用,以及会不会再走一遍 H6。
- OpenIM:十秒超时。立刻读本地状态,看不到之后的回填。宿主用同一客户端号再发:服务端新服务器号加新序号。第一次若已入队,对端两条序号。
- 野火:QoS1 重传同一报文。接收侧无去重,每次新 mid、新收件箱序号。
- Tinode:服务端不对发布计时。再发同一发布 id → 第二次保存 → 新序号。唯一约束是主题加序号,不是客户端号。丢 202 与从未入队的 503 对客户端同形。
- Turms:六十秒删请求映射,官方库不重发。再发送 = 新请求号加新雪花。打开序号会再自增。
- JuggleIM:同客户端号且缓存命中 → 旧序号加成功。进程重启、换节点或号为空 → 新序号;表上无唯一约束。
- layababa:出箱永远重放同一客户端号。MQ01 确认未知保持预备。MQ06 同指纹回收同一序号;改内容复用号则冲突,分类为终态。接受后结算失败保持在途。软超时只发界面事件,不改出箱。
同品类里,超时后仍能撞回「已出生的那条序号」的,只有 layababa 打开 MQ 之后(持久出箱加预备或指纹)和 JuggleIM 的单进程缓存。OpenIM、野火、Turms、Tinode 都会在 H6 另铸身份。这是 hop 事实,不是排名。
四、在线对端怎么拿到
H8 不是推送功能,是 H7 之后接收工作何时出生、序号在不在那一包上、套接字写失败滚不滚 persist。
- OpenIM:ack 只是入队。分配序号后才开始接收。线上推全文。写失败走厂商离线推,历史不回滚。SDK 仅当连续才吃推送,否则按区间拉。
- 野火:收件箱序号先写入,再发 QoS0 信号。写失败落厂商推,不丢那条序号。对端必须再拉。缺键直接跳过,无墓碑。
- Tinode:已挂接推全文;未挂接推提示再拉。发送队列满则摘会话,库不动。
- JuggleIM:收件箱写入后,首条或单端全文,多端强制通知。写失败只打印,不关连接、不重试。
- Turms:推创建请求加消息号,无序号。写失败标离线,不重发正文。
- layababa:等 Redis 可见或已持久;私聊与普通群一律推全文。超级群仅当该用户每个活跃设备都声明了信号能力才推水位再同步,否则回退全文。套接字写之前插入每设备待确认;写成功仍不是已确认,要对端本地耐久后再回执。业务写失败队列仍确认,重试靠投递工人;空会话从未建账本,只能同步。缺口检测只认新序号大于已知最大加一,且跳变时先把已知最大抬到新序号,内部洞此后看不见。
缺跳(layababa):私聊没有通知再拉;空会话写失败没有账本可重试。所有被核过的引擎:套接字写失败都不回滚已 persist 的行。
五、杀掉进程、换设备、多端同时在线
重连不是再拉一次,是一条时序:掉线 → 再鉴权会不会推漏消息 → 发现水位 → 拉 → 换设备分叉 → 多端分叉 → 这条路径上会丢什么。拉到本地游标等于服务端最大,是「一拉」之后必须写明的 hop;没有它,「新设备能追上」不可证伪。各家连接都不自动倾倒未读正文。
- OpenIM:连接后客户端取全部会话最大序号,再按区间拉。空库且未安装当重装。无设备邮箱;发送方同步把同一正文加序号推其他端。
- 野火:连接过程不推消息。客户端从上一序号拉。漫游默认只补三百秒;远程历史关则空列表。
- Tinode:元数据里的序号只是水位提示。按 since 拉一页。丢 202 只要保存已提交就能靠 since 找回。无会话枚举。
- JuggleIM:时间游标,夹在离线保存窗口。大群关离线则从未写入收件箱,这条路径补不回。
- Turms:无服务端会话游标;按投递时间查。关闭持久化的窗口永久消失。
- layababa:刷新列表后,重连协调器串行至多五十个会话,每会话一次单发同步。重连批量是否继续翻页恒为假。群且信号超前走群同步。新设备登录推送只发事件,不同步。空设备与重连同一条单发;完整翻页只在打开会话。鉴权后重放同一客户端号——这是发送出箱,不是历史补拉。发送方不在群可见投递的收件人集合里。
出厂关发送的重连:同一客户端号再撞限流,同步什么也没有。这是卡住的出箱,不是漏消息补拉。打开 MQ01 在可见之后、落库之前崩溃:发送方已有序号;扇出可能已发生;历史可能没有;补洞会跳过热链路占用的号。单发同步不能假定补上这个洞。
六、一条群消息的分支
群发送的 H1–H7 与私聊同链,分叉在 H8。出厂拒发仍然是另一条发送:无 persist、无扇出。
发送侧禁言发生在序号之前。layababa 非成员、个人禁言(含主和管理员)、全员禁言且非特权,都会在铸号前拒。OpenIM 超级群跳过成员校验。接收方禁言不是这一跳。
正文只存一份:同品类都是一行。复制若发生,发生在线上。layababa 群可见路径是序号、保存与出箱保证同事务,然后才返回——ack 带序号,扇出未开始。OpenIM 回执无序号。野火确认无序号,之后 N 条收件箱指向同一 mid。
收件人集合:layababa 消费时可见成员减发送方,不读发送快照。普通档推全文;超级群仅当该用户全部活跃设备具备信号能力才信号,否则回退全文。OpenIM 超级群批量推仍是全文。野火统一信号。JuggleIM 拷贝下行全文,多端改通知。
没有一家因接收方禁言而跳过 persist。大群丢掉:layababa 真分叉,一百零一人粘性升级不降级。JuggleIM 最富丢弃树:延迟队列过旧丢、大群关离线不写收件箱、再往上关推送——发送方此时已经拿到成功加序号。
七、崩溃发生在 ack 前 / 后、push 前 / 后
序号一旦分配,合法结局只有三种:复用同一会话与序号、另铸第二条、永久空洞。撤回和已读必须改原序号上的旗标或游标,不得占新号。H4 拒发没有序号,不能拿来和「先可见再落库」比丢不丢。
ack 前、序号已占:Turms 打开序号时自增后插入前崩溃是永久空洞。Tinode 主题序号已加、内存未加,下一条可能补洞或撞唯一。JuggleIM 缓存丢了就再铸。OpenIM 再消费再分配。layababa MQ01 确认未知保持预备,禁新序号。野火此时还没有个人序号,重试新 mid。
persist 后、发送方没看到 ack:Tinode 与 Turms 已有行;客户端超时再发是第二条身份。layababa MQ01 已在可见时 ack——发送方握着一条历史可能没有、补洞会跳过的序号。MQ06 的 ack 在 persist 与出箱保证之后,发送前崩溃由工人同世代重试,世代不匹配变死信。OpenIM、野火、JuggleIM 的 ack 本就是入队或接受,这一跳已经是未建模的未知。
ack 后、push 前:对端靠拉。没有人回滚序号。撤回:layababa 原地标撤回并抬版本,推送带原序号;已读是已读序号的单调更新。OpenIM 在同一聊天序号上墓碑。Tinode 删除藏正文、删除号加一,最后数据号不变。野火把 mid 改类型后再写入收件箱——同一 mid 新的个人序号,是这条维度上的反模式。
八、layababa 出厂关发送 vs 开 MQ:必须分开写
路径 A · 出厂关
私聊热链路不接受、群可见发布者不接受,都走拒发,抛的是限流。之后没有任何服务端身份。客户端把限流映射为五秒退避,不以尝试次数结束意图。重连后再派同一客户端号,再吃同一个码。同步为空。
这不是节流。这是新链路未覆盖、旧流已退役的硬关。用限流发出去,接入方无法区分真限流与链路关闭。
路径 B · 私聊 MQ01
预备占序号 → 发布确认 → 未知则保持预备 → 标可见 → 返回时库可能仍待写。成功回可见;确认未知回 HTTP 200 服务端确认中。客户端再包装成内部错误,分类当可重试的五类。
崩溃边:预备后、可见前 → 同号不得新序号。可见后、落库前 → 发送方已 ack,库可能没有,补洞跳过热占用。
路径 B · 群 MQ06
命中幂等则回收。否则保存(取号加落库)与出箱保证同事务,此时才 ack。到期处理之后才发可见信封。消费时成员集减发送方。普通档全文;超级群信号或回退全文。发送方其他设备不在这个在线集合里。
两条路径唯一允许共用的客户端 hop 是 H1–H3。H4 之后必须分叉写。把 A 的无限五秒重试抄到 B 的确认中上,会把「链路已关」和「结果未知、应对账」搅成同一种内部错误。
九、可偷的跳与追赶顺序
只偷 hop,不偷产品定位。下列是函数级、可证伪的。
现在就该改,因为本仓 hop 自相矛盾。第一,H4 不要再用限流表示链路关闭,应抛专用码,客户端对其终态或停泵。参照拒发与节流分码。第二,未知结果走已有的不可重试码。不要把确认未知写成 HTTP 200;长连接与四版处理必须接住。客户端不要把确认中包装成内部错误再抖动重试。第三,重连必须翻页。批量是否继续翻页恒为假,加上新设备登录只弹提示,新设备与长离线未打开的会话停在一页。可偷 OpenIM 的取最大序号再按会话拉区间,或 Tinode 拉到计数控制帧。最低限度:列表服务端最大远大于已同步时,打开前也要继续翻页。
第二批:MQ01 的 H7 与 H5 对齐,要么像 MQ06 与 Tinode 那样耐久提交后再回可见,要么补洞与同步必须能看见热占用的序号。缺口检测跳变时不要先抬已知最大。私聊 H8 可在多端与未打开会话上复用超级群信号,先写收件箱再通知,写失败落推送、序号已在。
第三批不要偷错。JuggleIM 同号直接成功加旧序号值得要,但必须做成持久唯一;layababa 群指纹已经更强,缺的是 Redis 与库的同一套唯一键。可偷首条全文、多端改通知来限制扇出;不要偷成功加零序号的静默拦截,也不要偷过期扇出直接丢。已读与撤回与聊天序号分家,不要学野火撤回后再写入收件箱。不要偷 OpenIM 的入队 ack:超时同客户端号必双序号。layababa 私聊已经把序号绑在预备上,退回去是退步。
追赶顺序只按谁会在真实失败边上双写或假成功排:先改 H4 码与 H7 未知(否则出厂关和确认未知在客户端是同一种重试),再补重连翻页(否则新设备合同不可验收),再决定 MQ01 是把 ack 挪到 persist 之后还是让同步看见热序号。通知再拉和大群信号是带宽优化,排在合同之后。