可靠性对照 · 源码阅读 | 音视频专篇
会话序、投递闭环与出厂路径
layababa-im-sdk 与 Rocket.Chat、野火、OpenIM、Turms、JuggleIM、Tinode、HuLa 在消息可靠性上的架构差异
一、问题与边界
即时通讯产品对外只说一句话:发出去的消息,对方迟早能看见,而且看见的是同一条。这句话在实现上会碎成许多互不相让的合同——发送方收到的成功回执代表持久化、代表入队、还是只代表进程内缓存;对端离线归来是按会话序号把空洞补齐,还是按时间窗碰运气;群聊是给每个成员各写一份收件箱,还是只存一份正文、上线再扇出。把这些合同混在一张「功能清单」里比较,得到的只能是营销结论。
本文对照八个系统。其中五个与 layababa-im-sdk 同属「可嵌入的 C 端即时通讯引擎」:OpenIM、野火社区版服务端、Tinode 服务端、JuggleIM 社区版、Turms。Rocket.Chat 是 Slack 一类的团队协作套件,HuLa 是 Tauri 桌面客户端。后两者只用来标明品类边界,不参与引擎排名。Tinode、野火、JuggleIM、Turms 的官方移动端或 Web SDK 多数不在本次克隆范围内,凡涉及客户端重试、本地库与补洞循环的判断,只对 OpenIM(同时阅读了 open-im-server 与 openim-sdk-core)和 layababa Flutter 树作对源陈述。
阅读方式是打开本地树里的发送、同步、扇出与确认路径,而不是仓库首页的星数或白皮书。layababa 一侧以当时工作树为准,该树相对 origin/dev 落后一百二十个提交;文中所有关于它的判断,都不能冒充「当日开发主干的最终态」。没有做到达率测量、多副本杀进程或弱网回放。凡是无法从源码直接看出的数字,文中不写。
二、八个系统各自在写什么
OpenIM 把即时通讯拆成一组 Go 微服务:网关持有 WebSocket,消息 RPC 负责校验与入队,transfer 从 Kafka 取消息、在 Redis 上分配会话序号、写入热缓存,再异步落 Mongo、交给推送服务。群聊采用读扩散——会话里只有一份正文,推送时枚举成员。这是目前开源界里,产品形态与 layababa 最接近的一套:服务端加多端 SDK,会话序号,推拉结合。
野火社区版是单进程 Java。客户端不走业务 HTTP 发消息,而是连上从 Moquette 分叉出来的 MQTT broker,以 QoS1 发布一条短主题,服务端在 PUBACK 里带回服务器生成的消息号。在线通知另走 QoS0 的 Notify,接收方再用个人序号拉取正文。群是写扩散:为每个未退出成员插入收件箱。集群、超级群、送达与已读回执被划进专业版,本树看不到实现。
Tinode 把聊天做成主题。一个 Topic 对应一个独立的事件循环,私聊、群、频道、个人元数据都是主题。会话挂上主题之后收 {data};离线方靠 {get} 按序号把历史拉回来。落库成功后才在控制帧里回 seq。服务端是 GPL,官方客户端在另外的 Apache 仓库里。联邦写在 README 里,树内没有对端服务器协议。
JuggleIM 社区版同样是单进程。连接管理、导航、业务 HTTP 和 WebSocket 分端口,内部用自研的 actor 信封在同一运行时里转。私聊与群都写入一份历史,再按成员做分发;大群用阈值把扇出推迟、关掉离线收件箱或关掉厂商推送。去重依赖进程内 LRU。所谓集群路由属于专业版,开源树里 GetAllNodes 的长度恒为 1。
Turms 把网关与业务拆成两个 JVM 角色。客户端到网关是带长度前缀的 Protobuf,可走 TCP 或 WebSocket。业务在 Mongo 里插入一条消息——读扩散,按投递日期分片——再根据 Redis 里的在线会话,把通知 RPC 到对应网关。会话级序号默认关闭。官方多语言 SDK 被有意做成协议驱动,重连、本地库和路由都不做,留给宿主。
layababa-im-sdk 是 Flutter 核心与 UIKit、加上一套八单元 JVM 服务。会话序号由 findAndModify 分配。发送面在退役 Redis Stream 之后分成两条可选链路:MQ01 用 Redis 先把私聊标成可见,再异步落库;MQ06 先把群消息写入 Mongo,再经 Rabbit 投递。客户端线上通道是 V4 Protobuf 信封;会话补拉仍有一部分走名为 V3 的 HTTP。设备送达与发送成功被拆成两本账。
Rocket.Chat 的写入路径是 Meteor 单体里的 Mongo insertOne。房间是一份文档,未读打在订阅记录上,实时面是 DDP。没有会话序号,也没有按序号补洞。它解决的是团队工作区,不是把一套 SDK 嵌进宿主 App。HuLa 则是桌面端自己处理重连与本地存储,重试函数在阅读时仍是占位,服务端不属于这个项目。
| 系统 | 品类 | 发送回执的含义 | 离线补拉 | 群的存储与扇出 |
|---|---|---|---|---|
| OpenIM | 可嵌入引擎 | Kafka 入队成功,响应不含序号 | 取最大序号后按区间拉 | 一份正文,推送时枚举成员 |
| 野火社区版 | 可嵌入引擎 | Hazelcast 写入;数据库异步 | 按用户序号拉取 | 为每个成员写收件箱 |
| Tinode | 引擎(客户端另仓) | 落库成功后回 seq | 客户端按 seq 与删除游标自拉 | 主题内扇出,默认成员上限 256 |
| JuggleIM 社区版 | 引擎(客户端另仓) | 确认早于异步收件箱 | 按时间窗同步,默认约十分钟 | 一份历史,大群用阈值降级 |
| Turms | 引擎(薄协议 SDK) | Mongo 插入后回消息号 | 无同步协议,按时间或编号查询 | 读扩散,超大群策略尚未落地 |
| layababa | 可嵌入引擎 | 设计上可见或持久后成功;出厂无发送属主则拒绝 | 多层补洞,合同尚未统一 | 百人内全量推,超出后只推水位 |
| Rocket.Chat | 团队协作 | Mongo 插入 | 按时间增量,无序号 | 房间一份文档 |
| HuLa | 桌面客户端 | 客户端协议确认 | 客户端拉列表 | 无独立引擎 |
三、发送成功究竟承诺了什么
可靠性的第一道裂缝出现在发送回执上。Tinode 在主题循环里为下一条消息取 lastID+1,调用存储保存,成功之后才递增内存中的序号,并在控制帧里把这个序号还给发送方。Turms 在默认打开持久化时,先用 Snowflake 生成编号、插入 Mongo,再把编号放进 RPC 响应。这两条路径允许我们说:发送方看见成功,对端稍后按编号去查,库里应当有这一行。
OpenIM 不这样。群聊与单聊的发送函数在消息进入 Kafka 之后就返回,响应里只有服务器消息号、客户端消息号和发送时间,没有会话序号。序号是 transfer 稍后用 Redis 分配的。若缓存写入失败,日志记一笔,Kafka 的完成回调仍然提交偏移——序号可能已被消耗,正文却不在。拉取缺号时,服务端会合成一条「已删除」。客户端超时后把本地状态标成失败,再次发送会得到第二条序号,因为服务端并不把客户端消息号当作唯一约束。
野火的 PUBACK 发生在 Hazelcast 写入之后。指向 MySQL 的持久化被丢进调度器异步执行。进程在确认之后、JDBC 提交之前崩溃,已确认的消息可以消失。JuggleIM 把历史与发件箱做成单向投递,分发函数在收件箱写入失败时仍然继续通知;重复过滤是一份一万条、十分钟过期的进程缓存,表上的消息号不是唯一索引。重启或换机之后,重试就是重复。
layababa 在合同上站在「先有身份、再承认成功」这一边。Mongo 上存在发送方与租户维度的客户端消息号唯一索引,指纹不一致会冲突而不是覆盖。MQ01 在确认失败时保持预备态,不为同一次发送铸造第二条身份。MQ06 在会话锁内先保存再保证投递发件箱。客户端发送发件箱按会话先进先出,若服务端已经接受而本地结算失败,会用同一个客户端消息号再投,而不是把这次发送标成失败。会话序号的空洞由定时修复打上墓碑,并且拒绝烧掉热私聊链路仍占用的号。
这些机制在源码里都读得到。它们此刻却不是出厂路径。hot-private 与 mq06-group-visible 在应用配置和 Compose 里默认关闭。没有发送属主时,持久化层抛出的是限流,而不是「发送链路未被拥有」。接入方会按限流去重试,看不见真正的原因。Tinode 与 Turms 没有这个问题:它们的先持久再确认,就是默认可走的那条路。
因此不能写「一旦打开那两面旗,layababa 就是稳定性上的第一名」。打开旗只是让合同开始工作。崩溃发生在 Redis 已可见、Mongo 尚未提交时会怎样,多副本下序号篱笆会不会双重分配,迁移文档里仍标记为未验证的那几条链路会不会在真实流量里裂开——这些都还没有实验。合同完整,不等于闭环已被证明。
四、对端如何把缺的补回来
发送回执只解决了半边。另一半是接收方在断线、换设备、进程被杀之后,如何知道自己缺了什么。
OpenIM 把这件事做成一条可以从 SDK 对源的直线。本地为每个会话记住已同步的最大序号。连接建立、从后台唤醒或手动同步时,先取服务端最大序号,再按区间拉取,分页大小写在常量里。实时推送若与本地游标不连续,就回头拉中间的洞。历史浏览还会补块与块之间的空隙。成员被移出群后,个人可见的最大序号被截断,新加入的成员看不到加入之前的号。这一套的代价是:服务端把缺号当成删除,客户端必须信任「空洞即已删」,而不是「空洞尚在修复」。
野火用的是每个人自己的单调序号。重连时连接确认带上收件箱头部,客户端从该处向上取。模型正确,但社区版默认只缓存有限的收件箱,漫游与远程历史默认关闭,跨设备能补到多远,取决于运营有没有打开那些开关。
Tinode 把游标交给客户端。主题元数据里有最后一条数据序号和删除序号,订阅时可以附带一次拉取。服务端不负责发现空洞,一页有上限,更大的缺口要客户端自己翻页。两条存储语句——更新主题序号、插入消息行——没有包在同一事务里,崩溃可以留下「序号已占用、行不存在」的洞,下一次插入会撞上唯一约束。
JuggleIM 按时间同步。应用信息里离线消息保存时间先被写成一天,随后被默认值十分钟覆盖。游标是时间,分发时还会改写收件箱时间以维持单用户单调,于是时间不再等于会话序号。Turms 走得更远:没有消息同步协议。离线历史就是同一张消息表上的条件查询。官方 JavaScript 驱动不保存任何本地状态,宿主必须自己发明「上次看到哪里」。
layababa 在这一面上写得最多。冷启动、过大缺口、在线前向缺口、已加载窗口内的修复、历史接口返回的跳号区间、群水位信号之后的分页拉取,各自有编排。多,并不等于已经收束。带跳号区间的同步响应与只带安全游标的会话同步,是两份合同。重连是否走线上的批量同步,由一个默认关闭的开关决定。V4 通道里仍调用着 V3 名字的接口。对接入方而言,缺的不是「有没有补拉」,而是「任何一种回来的方式,是否都保证同一套序号语义」。
五、群不是私聊乘以 N
野火把群当成许多人的私聊:枚举成员,为每个人写入收件箱并通知。实现直接,规模上限也直接。项目自己的说明把复杂度写成平方级,社区建议把人数压在五百以下。专业版超级群不在开源树里,本文不予讨论。
OpenIM、Turms、Tinode、JuggleIM 选择读扩散。库里一份正文,在线成员靠推送得知,离线成员靠补拉或厂商推送。OpenIM 在推送层对每个网关调用批量接口,网关错误被吞掉时,一次失败看起来像「这些人不在线」。超大群类型会跳过成员与禁言校验。Turms 把「特别大的群要丢弃还是部分发送」留在待办。Tinode 用单独的频道角色把只读读者从默认二百五十六人的群里摘出去,靠推送主题扩散,不再给每个人保存一份订阅写者。
JuggleIM 把大群的降级写成默认生效的阀门:人数越过第一道阈值就进入延迟队列,过旧的扇出丢弃;越过更大的阈值,离线成员不再写入收件箱;再往上关闭厂商推送。这不是类型名称,是同一条发送管道里的数字。它因此成为「出厂就能对大群说不」的参照,尽管丢弃发生在发送方已经确认之后。
layababa 没有在人数上封顶,而是在第一百零一个成员加入时,于同一次成员变更里把投递档位单向写成超级群。人数回落也不降级。普通档仍推全文,超级群只推水位,客户端自己拉正文。离线摘要与有界的「只推与我相关」存在于推送策略里,但推送开关默认关闭。没有打开群可见链路时,发群会被拒绝,不会退回到旧的同步扇出。成员集合在每次投递交接时从会话成员字段重新取,不是一份可分页、带版本的成员索引。档位切换是这条链路上最有意识的设计;成员物化方式则是规模上来之后会先裂开的地方。
六、通道、弱网与未知结果
Tinode 把传输当成封闭协议:WebSocket、长轮询与 gRPC 三种入口,订阅帧可以附带拉取,发送队列满员时拆掉卡住的会话,主题队列满员时对发布返回不可用。OpenIM 的客户端长连接只有 WebSocket,发送是同一条连接上的请求响应,心跳与帧大小写死在网关常量里,传输总线是 Kafka。野火在本树中只初始化明文 TCP,WebSocket 与 SSL 的处理函数并未挂上。JuggleIM 用导航服务发现连接地址,数据面是自研帧。Turms 的热路径上没有第三方消息队列,网关与业务之间是自研 RPC。
layababa 的 Flutter 通道拒绝用字符串类型逃逸 V4 信封,发送按会话排队。跨节点开关默认关闭,握手、发送与同步仍有从网关跳到核心服务的 HTTP。旧的异步扇出总线已经删掉,剩下的投递队列在请求里同步处理。作为嵌入式 SDK,强类型信封和会话内排队是加分;作为多副本运行时,缺少一条默认开启的异步总线是缺口。
弱网把「成功回执」逼成哲学问题。一次超时之后,客户端不知道服务端是否已经接受。OpenIM、野火、Tinode、JuggleIM、Turms 都没有在这条路径上发出「结果未知、禁止当作失败再发」的信号。它们或者把本地标失败再发——于是双份——或者把重试完全交给宿主。layababa 在错误码枚举和对外契约里为这种情况留了名字,并标明宿主应当对账而不是重试。生产发送路径上没有抛出这个码。私聊确认未知时,接口返回的是成功态之下的「服务端确认中」。客户端分类器并未按请求指纹走对账,未识别的错误会落到终态或当作传输层重试。
名字已经起了,路径还没有接上。这比完全没有这个概念更危险:阅读契约的人会以为运行时已经在保护他们。
七、layababa 的位置
把同品类五个开源引擎放在一起,layababa 不是功能最少的那个,也不是出厂最稳的那个。它在合同层上往前走了几步别人没有走完的路:发送身份可以按客户端消息号恢复,而不能被同号不同内容悄悄替换;发送成功与某台设备送达是两本账;群在过线之后换线路,而不是继续写扩散或直接丢离线收件箱;序号空洞会被显式关闭,而不是在拉取时假装成删除。客户端发件箱在「服务端已接受、本地尚未结算」时选择重放同一条,而不是把一次成功改写成失败。这些都是可以从文件里指出来的优点。
它输在默认能跑的那一圈。OpenIM 克隆下来,沿着入队、推送、按序号补拉、厂商推送兜底,可以走出一条不完整但闭合的路径。Tinode 打开服务,发布一条消息,得到的序号对应库里的一行。layababa 在默认配置下拒绝用户私聊和群聊,并且用限流来表达这件事。推送摘要默认关闭。跨节点默认关闭。补拉仍分裂成两套响应。若干投递链路在内部迁移文档里仍是「源码已实现、行为未验证」。对要在产品里嵌入即时通讯的人来说,第一天碰到的不是「合同比 OpenIM 更严」,而是「为什么发不出去,错误码还叫限流」。
许可证上,OpenIM 的官方 SDK 是 AGPL,服务端是 Apache,嵌入官方客户端有传染风险。野火与 JuggleIM 把集群和部分回执放进专业版。Tinode 服务端是 GPL。Turms 是少见的、核心与协议 SDK 都在 Apache 下的引擎,代价是官方客户端故意不做可靠层。layababa 走自有商业仓,没有把可靠性开关卖成专业版,但也没有把默认路径做成开箱即用。这是另一种开放核:功能写在树里,运行靠旗。
八、应当追赶的方向
第一件事不是继续增加档位和篱笆,而是让「能发、能补、超时能对账」成为一条无需猜测的出厂路径。发送面未被拥有时,应当使用单独的、不可重试的错误,而不是限流。文档与 Compose 必须指出哪一组开关构成可靠性最小集。在此之前,一切关于到达率的讨论都没有运行时对象。
第二件事是把未知结果从枚举变成发送路径上的行为。确认尚未完成、超时尚未判明时,客户端必须拿着原来的请求指纹去问,而不是换号重发。服务端已经为「不铸第二身份」写过预备态,客户端分类器却还没有把这条路走完。弱网下的双发,是现在就能在真实用户里出现的问题,不需要等到百万并发。
第三件事是把补拉收成一种序号语义。无论冷启动、重连、群水位还是历史翻页,都应当以会话最大序号和闭区间为准。跳号区间与安全游标可以并存于实现,不能并存于两套对外合同。重连批量同步不应当躲在默认关闭的开关后面。通道已经叫 V4,补拉却仍在 V3 的名字下,会让下一轮阅读的人以为迁移还没有开始。
这三件事做完之前,谈「全面领先」没有意义。做完之后,仍有一串应当排队、却不必今晚开工的工作:为最小集做崩溃注入,证明可见与持久两种成功在杀进程后仍能对上;把群成员从会话字段里的整包物化改成带版本的索引;重新给出一条默认的异步扇出,而不必恢复已经删掉的旧总线;为离线推送写一句实话——若摘要关闭,离线就只靠客户端补拉,不要让文档继续描写一套不运行的摘要。
不必向 Rocket.Chat 看齐房间模型,也不必向 HuLa 看齐桌面重试。不必抄 OpenIM 把确认等同于入队,不必抄野火为每个群成员各写一份正文,不必抄 JuggleIM 用十分钟时间窗冒充漫游,不必抄 Turms 把同步完全推给宿主。应当抄的是 OpenIM 那条默认能走通的补拉直线,Tinode 那种无条件的「先有行、再有序号」,以及 JuggleIM 把大群降级写成默认数字而不是类型名称的态度。
layababa 已经拥有别人尚未写完的合同。它现在缺的不是新想法,是让这些合同在第一次启动时就开始约束真实的发送。
方法。第二轮工作流对八个项目并行阅读本地树,再按稳定性、到达率、离线补拉、群扇出、通道与场景鲁棒性横切,并由独立核验反对过满表述。成文由人工根据核验后的材料重写。OpenIM 对照路径包括 send.go、online_history_msg_handler.go、msg_sync.go;野火包括 SendMessageHandler 与异步 DatabaseStore;Tinode 包括 topic.go 的保存与确认顺序;JuggleIM 包括 LRU 去重与空的确认超时;Turms 包括默认关闭的序号与按时间查询;layababa 包括发送属主拒绝、幂等索引、发件箱重放与两套同步响应。本地树位于本机 oss-im-refs,不随 layababa-im-sdk 提交。
限制。未测量投递率。未做多副本与崩溃注入。layababa 结论落后于 origin/dev 一百二十个提交。Tinode、野火、JuggleIM、Turms 的官方客户端大多未克隆,其客户端质量不得由服务端反推。