即时通讯系统的关键设计

3 阅读55分钟

一、即时通讯解决的问题

即时通讯(IM,Instant Messaging)要解决的核心问题是:

让一组不可靠、可离线、数量可变的读取端,各自与服务端权威就发生了哪些事、以什么顺序达成一致。

其中:

  1. 「发生了哪些事」是「集合问题」:端上的同步结果无遗漏、无重复,并能依据协议校验完整性;

  2. 「以什么顺序」是「序问题」:在约定的定序范围内,各端对已确认消息的相对顺序与服务端一致。

此外:

  1. 「权威」是指各端以服务端最新确认的结果为准;

    例如各端按服务端未读数校准,服务端也可根据消息和已读状态重新计算该数值;

  2. 「集合」与「序」独立,且需分别保证;

    例如,漏收消息属于集合不完整;两端消息顺序相反属于顺序不一致;

  3. 消息、会话、成员关系和已读位置等是「基础事实」;

    未读数、会话排序等是根据这些事实计算的「派生结果」,即事实和规则完整则能重新计算。

二、消息的发送与投递

普通消息的发送与投递

以下以文本消息先受理、再异步处理的流程为例:

  1. 同步链路(请求受理阶段、发送端发起):

    1. 发送端:生成幂等键并随消息保存;先在本地展示消息,再携带该标识发起请求;

    2. 服务端:校验请求,分配消息编号(seq),写入持久化队列,返回发送确认(ACK),表示请求已受理;

    3. 发送端:收到 ACK,标记为已受理;

  2. 异步链路(落库与投递阶段、服务端处理):

    1. 服务端:消费队列中的消息并落库,向接收端投递;同时将处理后的消息同步给发送者的设备;

    服务端回显(remote echo)就是消息经服务端处理后,再发回发送者的客户端,确认消息的正式结果。

    1. 接收端:若在线,则接收推送;若离线,则上线后拉取。保存消息后按编号排序,并按协议补齐遗漏的更新;

    2. 发送端:收到服务端回显,确认发送成功。

普通消息的发送与投递

这里的 ACK 只表示请求已受理,不代表消息已落库或对方已收到。服务端回显到达后,发起设备按幂等键关联原本地消息,补齐服务端消息 ID(msgId)和 seq。

富媒体消息的发送与投递

富媒体消息的附件主体单独上传和下载;消息服务传递文件引用、必要元数据及可选的小图预览。

用户点击发送后,客户端保存消息及本地文件路径,立即展示本地预览和“发送中”状态,随后:

  1. 上传附件:上传成功则获取文件引用;上传失败则将原消息标为发送失败,保留本地文件及路径,重试时继续更新原记录;支持秒传时,经权限校验后可复用已有文件引用;

  2. 发送消息:将文件引用、类型、尺寸、必要的预览信息等提交给消息服务,根据发送结果更新原消息;

  3. 接收展示:接收端先展示消息携带的预览;没有预览时显示占位,再按需下载缩略图或原文件。

富媒体消息的发送与投递

消息体除了原图引用,还可能包含一个极小的「占位缩略图」,这里有两种做法:

  1. 只带引用:接收端先显示通用占位,再请求缩略图,会影响图片首次预览;

  2. 内联占位图:将低分辨率小图或紧凑的预览编码放入消息,接收端可立即展示模糊内容。

缩略图可由发送端生成,也可在上传后由能读取附件的服务端生成并返回;端到端加密时,由发送端生成。

富媒体的成本优化也分两种:

  1. 存储优化:按内容指纹识别重复文件,在权限允许时复用同一份内容,减少重复存储;

  2. 流量优化:结合尺寸分级、按需下载和缓存复用,减少不必要的传输;用户查看大图时再下载更高分辨率文件;在权限允许前提下同一文件可共用缓存,减少重复回源。

实际应用

文件传输:Telegram 先分片上传文件,再发送携带文件引用的消息;其官方文档建议使用独立连接传输大文件,减少对消息更新和其他请求的干扰。

占位预览:Telegram 将极低分辨率的 JPEG 缩略图去除固定头尾后,内嵌于媒体对象,接收端补齐后即可解码展示,无需等待图片下载,Telegram 称为 Stripped Thumbnail;BlurHash 和 ThumbHash 将图片的整体颜色与明暗分布编码为少量数据,由接收端重建模糊占位图。

三、消息链与同步游标

消息 ID 与消息编号

幂等键用于发送去重,客户端也可用它关联响应和回显;msgId 与 seq 分别承担标识和定序:

  1. msgId 标识消息:用于存储、引用和关联更新,同一条消息编辑或撤回后仍通过原 ID 定位;

  2. seq 表示顺序:会话内按 seq 排序,编号分配后保持不变。

标识和定序通常分别实现,也存在协议用一个字段兼任两者。

同一会话可能同时收到多个写入请求,需要协调 seq 的分配,避免重复编号,常见做法包括:

  1. 按会话加锁:用分布式锁协调不同机器、不同进程的写入;取得锁后,进程先读取已保存的最大编号,再分配更大的编号并完成写入,最后释放锁;

  2. 按会话串行定序:将同一会话的写入请求交给当前负责的进程,依次分配编号并写入;不同会话可由不同进程并行处理。

两种做法都需保证编号在重启后不重复、不回退。

会话链与收件箱

一般消息的正文与指向它的引用分开保存。

正文按 msgId 存储,链(timeline)按 seq 排列消息引用,每条引用主要记录 seq 和 msgId。

会话链汇集一个会话的消息引用;收件箱汇集一个用户在多个会话中的消息引用,因此也称用户链或混链。

会话链和收件箱各自编号,聊天页按会话编号排序,收件箱编号用于跨会话同步。

读取时,先按链上的编号范围取得 msgId,再批量读取正文。两者区别如下:

会话链(单链)收件箱(用户链、混链)
分区维度会话,每个会话一条用户,每个用户一条
写入方式单份追加写扩散,每个成员各一份
读取用途按会话读取消息跨会话同步更新
保存内容与恢复保存会话消息引用与顺序;
恢复依赖副本、备份等保留记录
保存消息引用;
所需事实和规则完整时可重建
生命周期按产品历史保留策略保存可设置较短的增量保留窗口
存储介质大容量、低成本高性能

收件箱的优势是「统一跨会话增量同步」。客户端保存一个收件箱游标,一次拉取多个会话的更新;重新联网后,携带上次保存的游标继续补拉。逐成员写入引用,换取更简单的客户端同步。

游标(同步水位)记录一条同步链路的推进位置(不表示用户已读位置)。若仅靠游标保证更新不遗漏,就不能越过尚未补齐的空洞;若缺失部分另有可靠的记录和补拉机制,则可以按协议继续推进同步进度。

会话链与收件箱

客户端在三种场景下与服务端同步:

  1. 首次安装(重装):本地没有消息和游标;若服务端保留历史,端可先获取会话和最近消息,保存配套的同步游标,再按需分页加载更早的历史。初始化期间,未包含在加载结果中的变化,仍须能从该游标补到;

  2. 离线后上线:客户端携带已保存的游标拉取增量;处理并保存本页更新后,按服务端返回的游标继续拉取;

  3. 在线时收到推送:客户端校验并保存更新;发现空洞时记录缺失范围并补拉,游标按协议推进。

会话链与收件箱

实际应用

「实时接收与历史加载分离」Slack 公开的桌面端优化方案先获取各会话的未读和提及计数,通过 WebSocket 接收新消息;会话历史在进入会话时获取,较早消息随用户翻阅分页加载。实时接收不必等待所有会话历史加载完成。Matrix 协议的统一同步接口一次返回多个会话的更新,并提供下一次增量同步使用的游标;各会话另有历史分页位置,用于按会话读取更早的消息。

写扩散与读扩散

扩散(fan-out)指一条消息写入后,使会话内每个成员都能读到它的过程。这个过程分两种:

  1. 写扩散(fan-out on write):服务端落库后,把消息引用逐个追加到每个成员的收件箱;客户端按收件箱游标批量读取跨会话增量;

  2. 读扩散(fan-out on read):消息写入共享会话链,不为每个成员追加消息引用;客户端按会话维护进度,读取所需会话的增量。服务端可通过更新通知告知哪些会话有新消息;客户端也可同步会话列表中的最新消息编号,与本地进度比较后,再拉取对应会话的增量。

写扩散读扩散
消息引用写入会话链一份,成员收件箱各一份共享会话链追加一份引用
读取一个游标,跨会话批量取增量按会话取增量,需定位有更新的会话
会话列表排序从收件箱增量更新会话活动信息同步会话活动信息;逐人维护列表仍有写入成本
适合场景单聊、小群超大群、聊天室

写扩散与读扩散

读扩散也可维护用户级的会话变更链,用一个游标同步“哪些会话有更新”,再按会话拉取消息。这也是一种混链,但记录的是会话变化,可将同一会话的多次更新合并,而非逐条保存消息引用,仍有用户级的维护成本。若不维护这类变更链,客户端上线后需批量查询各会话的最新进度,与本地比较后补拉;在线时可通过通知及时发现更新。

实际应用

「超大群按会话同步」Telegram 的私聊和普通群共用账号级的消息更新进度,超级群和频道则各有独立的更新进度(pts)。客户端发现某个超级群有更新缺口,或收到需要补拉的通知时,携带该群标识和本地进度,通过差异接口补齐该群的更新。按会话补拉也能与实时推送并存,并不意味着大群只拉不推。

同步进度与空洞检测

「空洞检查」用于确认同步范围是否完整,不能仅凭编号间隔认定消息丢失。

不同编号方案的检查方式不同:

  1. 连续编号:检查收到的编号能否衔接;无法衔接可能只是暂时乱序,先暂存再按协议等待或补拉缺失更新。

  2. 非连续编号:编号允许跳号,不能凭间隔判断遗漏;分页拉取时,处理并保存本页更新后,按服务端返回的游标继续。

同步进度与空洞检测

需区分批量更新与暂时乱序:

  1. 批量更新:一条更新覆盖多个事件编号;例如收到的更新覆盖 101~103,处理并保存整批后即可推进游标;

  2. 暂时乱序:多条更新未按编号顺序到达;例如只收到 103 对应的更新,101、102 尚未到达,需按协议等待或补拉,不能认为 101~103 已覆盖。

同步进度与空洞检测

实际应用

「用变化数量判断是否漏更新」Telegram 为新增、编辑、删除等消息变化编号。更新中会说明这次结束于哪个编号(pts,表示更新进度,不是消息 ID)、包含几项变化(pts_count)。

假设本地已处理到 100,收到的结束编号是 103:

  1. 包含 3 项变化:正好覆盖 101~103,处理并保存后,游标可推进到 103;

  2. 只包含 1 项变化:仅覆盖 103,101~102 仍缺失。客户端先暂存这条更新,等待或补拉缺失部分。

因此,不能只看结束编号,还要看这次更新能否衔接本地进度。

「编号递增但不连续」微信公开的序列号设计为每个用户分配独立的递增版本号,允许跳号,例如 100 后直接分配 110。客户端同步时上报已保存的最大版本号,服务端计算并返回其后的增量,无需把未分配的 101~109 当作缺失消息补拉。

「号段预留与重启跳号」TiDB 的官方示例将号段缓存设为 100,首次插入使用编号 1;重启后,下一次插入从 101 开始,未使用的 2~100 不再分配。这些编号是未使用的合法跳号,不能据此认定消息遗漏。

推送与拉取的配合

服务端有新消息时,可以直接推送消息内容,也可以只推送更新通知,再由客户端携带游标拉取内容。

若最后一次推送丢失且没有后续消息,客户端无法仅凭编号发现遗漏。客户端可低频主动拉取,检查是否还有未同步的更新。

客户端可从拉取节奏和并发数量两方面控制请求压力:

  1. 调节拉取节奏:让请求频率随响应速度调整,响应快时及时拉取,响应慢时合并通知、批量补拉,减少密集请求的开销;例如,同一条链一次只发起一个拉取请求。期间的新通知先合并,待结果保存后,再从最新游标继续拉取。下表以每秒新增 100 条消息为例,假设响应后立即继续拉取:

    一次拉取的往返时延拉取频率每批消息数
    0.2 秒每秒 5 次约 20 条
    2 秒(×10)每 2 秒 1 次(降至 1/10)约 200 条(×10)
  2. 控制拉取并发:多个会话同时需要补拉时,客户端统一限制同时进行的拉取任务数,必要时再设单会话上限;超出上限的任务排队,已有任务完成后再调度后续任务,减少对带宽、服务端和本地处理资源的集中占用。

推送与拉取的配合

实际应用

「待推送更新过多时,通知后补拉」Telegram 平时通过长连接推送消息等更新;待下发更新过多时,可改发“需要补拉”的提示,分别用于普通更新和频道、超级群更新。客户端收到后,携带本地保存的同步进度拉取缺失更新,必要时分批完成。提示负责触发补拉,消息内容由拉取返回。

四、消息投递的可靠性

幂等键与发送去重

幂等键(idempotency key)是端在创建消息时生成、随发送请求一起发出的唯一标识(通常是 UUID),与消息一起写入本地库,服务端用它识别同一条消息的重复请求。它必须由端生成,在超时、断线、ACK 丢失后的每次重发都携带同一个幂等键。服务端以「发送者 + 幂等键」为键记录首次处理的结果;重复请求命中时,直接返回已有结果,避免重复创建消息。端按同一个幂等键找到本地占位消息完成处理。

服务端处理队列事件时,完成当前任务或将未完成任务可靠写入后续队列后,再确认消费成功。重试需继续完成剩余步骤,避免重复写入收件箱引用或重复增加未读数。

幂等键与发送去重

实际应用

「Telegram:复用原发送结果」Telegram 用客户端生成的 random_id 识别发送请求。同一账号向同一目标会话重试时,若该标识对应的请求已完成,服务端返回原消息;若仍在处理,则返回重复请求错误。官方协议明确,已使用的 random_id 不随时间失效。

「Discord:限定时间内去重」Discord 的 Create Message 在携带 nonce 并开启 enforce_nonce 时,检查同一发送者最近几分钟是否使用过该标识;命中则返回已有消息,不再创建。超过这段时间,即使复用原 nonce,也不能保证不会重复创建消息。

发送状态、确认与回执

发送端根据服务端确认的处理阶段更新状态:

状态推进报文状态含义
发送中无端已持久化,幂等键已生成
已受理ACK已进入持久化队列,尚未确认落库
发送成功服务端回显已落库、已定序、已开始扩散
已送达送达回执接收端设备已收到
已读已读回执接收端已按产品规则将该消息标为已读

其中:

  1. 确认发送结果:ACK 表示已受理;服务端回显确认消息已落库、定序并开始扩散后,才标为发送成功。这是发送结果,不表示接收端已收到或已读。回显丢失后,客户端可用原幂等键查询或重试;

  2. 超时与重试:客户端保存尚未确认结果的发送记录,超时后先查询发送结果;需要重试时,仍使用原幂等键。达到重试上限可暂停并提示重试,但不能据此认定服务端失败;后续确认成功时,仍更新原记录;

  3. 送达回执由接收端发起:接收端收到消息后向服务端确认,服务端把确认作为一条状态变化转给发送端。群里若逐成员同步回执,流量和存储会随成员数增长,可按实际需求选择聚合计数、批量回执或按需查询。

发送状态、确认与回执

实际应用

WhatsApp 在私聊中用单灰勾表示发送成功,双灰勾表示对方手机或任一关联设备已收到,双蓝勾表示已读。Telegram 在私聊中用单勾表示消息已到达云端,双勾表示已读,不单独展示“已送达设备”。

WhatsApp 三种消息状态

Telegram 官方私聊已读状态演示

消息空洞的补齐

消息不完整时分以下四种情况:

  1. 推送丢失或乱序:发现缺失更新后,可立即补拉,也可先短暂等待迟到的推送,再补拉仍缺失的部分;有时序依赖的更新要等前序结果就绪;是否先处理已到达的更新,需遵循协议;

  2. 合法跳号或不可见消息:编号作废(或未使用)或消息被可见性规则过滤,都可能使部分编号没有可见正文;服务端应返回占位记录,或确认哪些范围已完整覆盖;客户端据此标记为已校验;

  3. 尚未加载的历史消息:首次安装可以先取最近 20 条,更早的消息等用户向前翻阅时再分页加载;这些尚未加载的历史,不妨碍从已建立的游标继续同步新变化;

  4. 增量记录过期:服务端无法再按旧游标返回完整增量时,需要重新建立同步进度;仍保留且有权访问的历史按需加载,增量过期不代表历史已删除。

消息空洞的补齐

补拉时,还需要记录范围、控制重试、处理游标失效:

  1. 记录已校验区间:用已校验区间表记录哪些范围已取得完整结果,或已由服务端确认没有可见消息;相邻区间合并保存,请求失败或返回结果不足以确认范围完整时仍保留空洞;

  2. 合并请求并限制重试:将相邻空洞合并补拉,避免对同一范围重复请求;失败后逐步延长重试间隔,达到次数上限时保留空洞,等上线、进入会话等时机再试;

  3. 按服务端结果重建同步进度:补拉失败不等于增量过期;只有服务端明确告知旧游标失效,才重新获取当前状态和新游标。

消息空洞的补齐

实际应用

「增量过期后的同步恢复」Telegram 的增量记录只保留一定范围。客户端的旧同步进度(pts)超出该范围时,服务端会返回“无法补齐旧增量”的结果。客户端重新取得最新同步状态,继续接收新更新;旧历史中的缺口等用户翻到时,再通过消息查询接口补取。

「连接恢复与事件重放」Discord 网关允许客户端携带上次连接的会话 ID 和最后收到的事件序号,请求恢复连接。恢复成功后,网关按序补发遗漏事件,最后下发 RESUMED 表示补发完成;若收到 Invalid Session,则按其中的标志决定重试恢复,或重新建连并发送 Identify 建立新会话。

五、状态同步与信令

持久状态的同步

会话不只有用户消息。撤回、置顶、成员变更、群资料修改都是状态变化,同样要在多端之间达成一致。

这类状态可通过两种方式同步:

  1. 命令消息:将撤回、删除等变化作为命令下发,端据此更新本地记录;可复用消息的定序、推拉和补齐流程;

  2. 状态查询:服务端保存当前状态及版本,端携带本地版本查询变化;无法补齐时,重新获取当前完整状态。

两种方式可以结合,例如先通知群成员有变化,再由客户端查询具体变化。

无论采用哪种方式,都需要:

  1. 确定目标与范围:记录变更作用于哪条消息、哪个会话或哪个用户,只同步给有权接收的设备;

  2. 处理重复与乱序:按目标 ID 关联更新,并按版本或协议规定的顺序应用,避免重复处理或旧结果覆盖新状态。局部更新需分别合并,不能因表态版本较新,就丢弃尚未应用的正文编辑;

  3. 支持离线恢复:通过变更记录补齐,或在增量过期后获取当前状态,不能只依赖一次通知。

下图以命令消息同步撤回、仅对我删除为例:

持久状态的同步

命令消息可复用会话链或用户收件箱,也可通过独立的状态同步序列补齐。

实际应用

「已读状态补齐」Telegram 通过已读更新下发“读到哪条消息”和“还剩多少条未读”。例如,手机读到消息 100 后,电脑收到对应更新,就能同步已读位置和未读数。这类更新也携带同步编号;电脑离线或漏收时,可通过增量同步补齐。因此,需要同步的不只是新消息,也包括已读等状态变化。

瞬态信号与丢弃策略

正在输入、在线状态这类信号描述当前情况,可通过独立接口上报、实时通知下发,通常不作为聊天历史保存。服务端可短期维护当前状态;客户端在输入提示过期后自动清除,通过后续查询或通知刷新在线状态,无需重放过期通知。

瞬态信号与丢弃策略

实际应用

「瞬态提示单独处理」Slack 将正在输入归为不写入数据库的瞬态事件,经网关和频道服务器转发;在线状态通知则只发送当前屏幕涉及的部分用户。两者分别减少持久化开销和通知范围。

已读回执与已读位置

用户读到某条消息后,客户端向服务端上报已读位置,用于更新未读数并同步本人其他设备。允许对外展示时,服务端也可通过已读回执将这一位置通知消息发送者,供其判断哪些消息已读。

已读回执与已读位置

若产品将某个位置及之前的消息都视为已读,只需记录每位用户在会话中的已读位置。例如,某成员的已读位置为 50,就可将其可见范围内编号不超过 50 的消息视为已读。若允许跳过中间消息、只标记个别消息已读,则还需记录这些消息的已读状态。已读变化可实时推送,客户端也可在进入会话时拉取最新结果,校准本地显示。

实际应用

「本人已读与对外回执分开维护」Matrix 分别维护公开和私有的已读位置。用户可以更新自己的未读数,而不向对方公开最新阅读进度。例如,自己已读到第 50 条,对方看到的回执仍停在第 40 条。

未读计数与角标

未读数统计当前用户尚未阅读、且按业务规则需要计数的消息。

以下两种方式都记录已读位置,并将该位置及之前的消息视为已读:

  1. 直接维护未读数:服务端为每个用户在会话中保存已读位置和未读数。新消息到达时,按规则增加未读数;用户阅读后,客户端上报读到哪条消息,服务端推进已读位置,只扣除本次新读掉的应计数消息,再同步结果;例如,已读位置为 100,101~103 共 3 条未读。用户上报读到 102,服务端处理前又收到 104,未读数先增为 4;处理这次已读上报时,只扣除 101、102,剩余 103、104 两条未读。重复上报同一位置不会再次扣减;

    未读计数与角标

  2. 累计计数差值:维护累计计数和已读计数。前者记录该用户在会话中累计产生的应计数消息数,后者记录截至已读位置的累计值。未读数 = 累计计数 − 已读计数。例如,初始已读位置为 100,累计计数为 23、已读计数为 20,未读数为 3。用户上报读到 102,服务端处理前又收到 104,累计计数先增为 24;处理已读上报时,取得位置 102 对应的累计值 22,将已读计数更新为 22,剩余未读数为 24 − 22 = 2,对应消息 103、104。

    未读计数与角标

直接维护未读数,需要准确记录每次增减;差值方案需要能取得已读位置对应的累计值,取得后即可相减,无须逐条统计本次读了多少条。两者都需处理重复上报与并发到达的新消息,并统一删除、撤回是否扣减的规则。服务端可计算后直接返回未读数,也可下发两个累计值供客户端相减。

实际应用

「本地更新与服务端校准」Telegram 服务端同时下发已读位置和未读数,已读更新也包含剩余未读数。以 Desktop 客户端为例,应计数的新消息到达时增加未读数;确认一条未读消息被删除时,未读数减 1。阅读后更新已读位置和计数,无法准确计算时,再请求服务端会话数据校准。

六、群聊设计与扩散成本

不同规模群聊的方案选择

选择群聊方案主要看成员数、消息频率和可靠性要求。

可从以下三个方面选择:

  1. 引用写入:写扩散为成员预写消息引用,便于按收件箱游标统一同步;读扩散不逐成员写引用,降低大群的写入成本,但需要发现有更新的会话并分别同步;

  2. 在线分发:可以直接推送消息,也可以先通知再拉取;频道广播按订阅关系分发;

  3. 可靠性:聊天消息通常需要重试和断线补齐。

依据实际写入与读取负载,成员较少、逐成员写入成本可控,且希望跨会话统一补齐时,可采用写扩散;成员多、消息频繁,尤其多数成员只偶尔进入会话时,可采用读扩散,减少预写引用,但需配套更新通知、会话发现和分别同步。

不同规模群聊的方案选择

实际应用

「Telegram」客户端统一补拉账号更新时,可收到“某个频道或超级群需要补拉”的提示,再携带该会话的同步进度,单独拉取它的具体更新。群消息也支持实时推送。

成员关系与历史可见性

服务端可维护成员区间表,记录用户每次加入和退出会话的位置。本文约定每段为 [join, leave):join 是开始计为成员的会话链编号,leave 是不再计为成员的编号。因此包含 join、不包含 leave;leave 为空表示尚未退出,多次进出形成多段区间。

读取历史时,服务端查询用户的成员区间,结合入群前历史是否可见、退群后是否保留访问权限等策略,确定可读取的消息范围,再分页返回。

成员关系与历史可见性

实际应用

「按消息产生时的成员状态判断」Matrix 的开源服务端 Synapse 保存成员加入、退出的状态变化。群采用“仅在群期间可见”策略时,服务端根据消息产生时的成员状态筛选历史中的普通消息:用户当时在群里的消息可见,不在群里的消息不可见。

「下发历史可见边界」Telegram 支持对新成员隐藏入群前的历史,服务端通过 available_min_id 告知客户端隐藏范围。例如,值为 100 时,编号不大于 100 的消息不可访问。边界变化后,服务端下发更新,客户端据此清理本地隐藏范围内的消息。用户入群时已被隐藏的历史,不会因后来开放历史或退群重加而自动恢复。

「为成员指定历史起点」Teams 在成员记录中保存可见历史的起始时间。添加成员时可以指定该时间,例如用户 9 月 10 日加入,却允许查看 9 月 1 日起的消息,历史起点可以早于加入时间。

消息存储与投递的成本

消息存储与投递的主要成本可以分为五类:

  1. 正文存储:保存消息内容,空间随保留消息量和平均正文大小增长;

  2. 索引存储:保存会话链、收件箱等消息引用,空间随引用数量增长;

  3. 写入处理:写正文、追加引用和维护索引,负载取决于消息速率及每条消息触发的写入次数;

  4. 读取处理:拉取更新、补齐消息和翻阅历史产生查询开销,取决于请求量、读取的数据量及缓存命中情况;

  5. 消息下行:在线推送、消息补拉和历史加载产生传输流量,取决于实际下发次数和传输包大小。

此外,系统通知和媒体文件分发还需单独估算推送请求量与文件传输量。

群规模增大时,要分别关注引用数量、数据库读写量和接收设备的下行流量。压缩正文能减少字节数,但不能减少逐成员写入引用的次数。

实际应用

「消息分区与热点读取」Discord 在 2023 年公开的设计按“频道 ID + 固定时间段(时间桶)”划分消息存储分区,避免一个频道的全部历史集中在同一分区。但大频道发布公告后,大量用户仍可能同时读取同一条消息,形成热点。为此,Discord 在数据库前增加数据服务层,按频道 ID 将请求路由到同一服务实例;首个请求发起查询,查询完成前到达的相同请求共同等待,再共用这次查询的结果,减少重复访问数据库。

群消息的投递优化

群消息的投递成本取决于接收范围、事件频率、处理次数和传输量。

优化时先减少不必要的投递,再降低处理与传输开销,同时隔离大群带来的排队压力:

  1. 按需订阅:在线状态、输入中提示等事件,只推送给正在查看相关内容且已订阅的用户,减少不必要的下发;

    群消息的投递优化

  2. 合并状态更新:只关心最新值的状态,短时间内多次变化时,可合并后推送最终结果。群名等持久状态仍需支持版本校验和补拉;在线状态、输入中提示等瞬态事件可按负载降频或丢弃;

    群消息的投递优化

  3. 按服务器分组投递:将接收者按连接所在的服务器分组,每台服务器只接收一份消息,再逐连接下发。这样减少了服务器之间的重复传输,但最终仍需向每个接收端发送一份;

    群消息的投递优化

  4. 下行批量发送与压缩:服务器可将同一连接上的多条待发消息合并发送,减少网络写操作和协议开销;也可使用流式压缩,利用连续消息中的重复内容降低传输量,由接收端解压;

    群消息的投递优化

  5. 队列隔离与限流:将大群与单聊、小群的投递任务分开排队,并按下游处理能力限制大群的处理速率,避免大量扩散拖慢其他会话。需可靠送达的消息仍保留重试或补拉能力。

    群消息的投递优化

除了优化消息投递,还可以减少引用写入的开销,并通过专用索引降低查询成本:

  1. 降低引用写入开销:批量写入可减少写入请求和重复处理开销,但引用数量不变。低优先级机器人的消息也可只写会话链,省去逐成员引用;客户端改由会话更新通知触发补拉,或在进入会话时拉取这些消息;

    群消息的投递优化

  2. 按需建立索引:为「提及我的消息」等查询建立索引,用额外的索引存储和维护开销,减少对会话的扫描。

    群消息的投递优化

实际应用

「按服务器分组投递」Slack 的频道服务器先向订阅该频道的连接服务器(网关)各发送一份消息,再由网关逐连接下发,减少服务器之间的重复传输。

「按节点聚合投递」Discord 的 Manifold 库将接收消息的会话进程按所在节点分组,先向每个目标节点发送一次,再由节点内的工作进程分发,减少跨节点发送次数和网络流量。

「提及索引与已读标记」Zulip 在用户与消息的关联表(UserMessage)中分别记录「是否被提及」和「是否已读」,并为被提及的记录建立专用数据库索引,加速查询「提及我的消息」。它复用已有的关联表,无需另建一张提及表。用户阅读后,服务端更新已读标志,保留关联记录和提及标志;这条消息仍可在提及历史中查到,但不再属于未读提及。

七、消息更新与会话管理

已发送消息的更新

编辑、撤回、删除、表态(点赞)、卡片更新和 AI 流式回复,会改变已发送消息的内容、状态或可见性。

编辑消息

消息 m5 从「周五 10 点开会」改成「周五 11 点开会」后,客户端仍在原气泡中展示新正文。

区别主要在于服务端如何保存这次修改:

  1. 维护当前内容:更新 m5 的正文和版本,再将编辑结果同步给客户端;客户端按消息 ID 更新本地原记录。

  2. 追加编辑事件:保留 m5 的原始内容,新增一条有独立 ID 的编辑事件 e8,记录目标消息 m5 和新正文。e8 参与同步,但不单独展示成聊天气泡;客户端据此更新 m5 的展示。追加编辑事件时,也可以同时维护最新正文,便于直接读取。

服务端校验编辑权限和时限,客户端按版本或约定顺序合并编辑结果,避免旧内容覆盖新内容。

编辑消息

实际应用

「追加编辑事件」Matrix 协议 保留原消息的内容,每次编辑另存一条有独立 ID 的编辑事件,记录原消息 ID 和新正文。客户端据此更新原气泡,编辑事件不单独展示为一条消息。

「接收并应用编辑结果」Slack 的 message_changed 通知携带修改后的消息,客户端按频道 ID(channel)和原消息标识(message.ts)更新原记录。Telegram 的 updateEditMessage 同样携带编辑后的消息,并将编辑计入更新进度;客户端发现空洞时,再拉取差异补齐。

撤回与删除

「撤回」服务端校验权限和时限,保存撤回状态并通知相关用户;各端按原消息 ID 移除正文,可在原位置显示撤回提示。断线期间的变化在重新联网后同步。若撤回通知先到,客户端可先按目标消息 ID 保存撤回标记,在原消息到达时合并,避免旧正文重新出现。

「仅对我删除」按用户和消息 ID 保存个人删除记录,同步到该用户各端;自己的聊天页、历史和搜索不再展示这条消息,其他用户不受影响。

这类变化可通过命令消息或通知后拉取等方式同步,客户端按目标消息 ID 更新本地记录。

撤回与删除

表态(点赞)

服务端保存「谁对哪条消息添加了什么表情」的关系,按消息和表情汇总。客户端缓存表态摘要,包括各表情的总数、自己是否已表态,以及展示用户的 ID。摘要可随消息保存,也可分表保存并在加载一页消息时批量读取,组装到内存中的消息对象,界面直接使用这些对象。完整名单展开时再分页获取。

有人添加或取消表态后,服务端下发变化,客户端按会话 ID 和消息 ID 更新消息对象、保存本地结果,并通知界面刷新对应消息的数量和名单。重新进入或断线恢复时,批量拉取可见消息的最新摘要;也可用定时拉取控制推送成本。

表态(点赞)

实际应用

「表态摘要与名单」Telegram 的消息可附带表态摘要,提供各表情数量、自己的选择和部分最近表态者,供客户端在消息下展示。群聊允许查看名单时,再通过分页接口获取具体表态者,无需随每条消息带回完整名单。

「随消息保存表态摘要」以 Telegram Android 客户端为例,表态摘要保存在消息对象的 reactions 字段中,随消息一起存入本地消息表。表态变化时,客户端读取原消息、更新摘要,再写回同一条消息记录,并通知界面刷新对应消息。

「表态更新」Telegram 的更新通知携带会话 ID、消息 ID 和新表态摘要,客户端据此更新对应消息。消息作者按通知设置接收推送;对当前可见、由他人发送且已带表态信息的消息,官方要求每 15~30 秒查询一次,并支持在同一会话内批量查询多条消息。

卡片更新与 AI 流式回复

卡片内容可以随消息保存,也可以单独保存并由消息引用。订单、红包等业务状态由对应服务维护;即使卡片仍显示「可领取」,服务端也要重新校验库存、权限和重复领取。

AI 回复可以用有序分段组织文本、工具结果、引用和文件。生成过程中,服务端合并下发增量,客户端持续更新同一条回复;生成结束后保存完整结果,避免每个词元都写一次消息表。

卡片更新与 AI 流式回复

实际应用

「卡片更新」Slack 应用通过会话 ID(channel)和消息标识(ts)定位自己发送的卡片,调用更新接口替换卡片内容。例如,审批结束后,可将原卡片的「待审批」改为「已通过」,同时移除审批按钮,直接在原消息上展示处理结果。

「AI 流式回复」Slack 应用先创建一条流式回复并取得消息标识,再随着生成进度分批追加内容。生成结束时,调用结束接口,可一并追加最后一段内容。追加和结束都使用原会话 ID 与消息标识,最终回复保留在同一条消息中。

会话创建与个人历史管理

创建会话、在列表中展示会话、清空历史,需要分别处理。以甲乙单聊为例:

  1. 创建会话:客户端可在用户发起聊天时请求服务端创建,也可等到首次发消息时创建。服务端可将双方用户 ID 按固定顺序组成唯一键,确保双方同时发起或请求重试时,复用同一会话;

  2. 个人列表与设置:服务端会话表按会话 ID 保存类型等共享信息,用户会话表按「用户 ID + 会话 ID」保存置顶、免打扰等个人设置。已读进度可独立存储。客户端结合这些数据、本地草稿和消息摘要展示会话列表;

  3. 隐藏会话:甲在客户端隐藏会话后,服务端保存甲的隐藏标记,乙不受影响。若新消息到来后需要恢复展示,可记录隐藏时的消息编号,仅让更大编号的新消息触发恢复,避免补拉旧消息使会话重新出现;

  4. 清空历史:甲在客户端清空历史后,服务端保存甲的个人清空边界,并据此过滤甲后续查询的历史;甲的客户端同步清理本地记录。

会话创建与个人历史管理

实际应用

「会话定位」Telegram 在当前登录账号下,以对方用户 ID 定位普通私聊。例如,甲的用户 ID 为 100,乙为 200,则甲用 200 定位这段私聊,乙用 100;向服务端拉取历史时也按对方定位,客户端无需另行获取双方共用的会话 ID。创建会话时,TDLib 加载或建立会话对象,必要时从服务端补齐状态。

「个人设置与同步」Telegram 将置顶、免打扰和草稿保存在服务端,客户端修改设置后,服务端通过置顶更新、通知设置更新等同步到该用户的其他设备,各端更新本地记录和界面。新设备拉取会话列表时,可直接取得这些状态的当前值,无需重放历次设置操作。

「归档会话」Telegram 在服务端用列表归属字段 folder_id 区分主列表(0)与归档列表(1)。客户端归档时,提交目标会话并将其归属改为 1;服务端返回新的归属,并通过列表归属更新同步到同一用户的其他设备,各端据此将会话移入归档列表。手动取消归档时,将归属改回 0 即可。新设备可按列表归属分别拉取会话,恢复主列表与归档列表。

「清空历史」以普通私聊为例,客户端请求服务端清空历史时,分别指定清空到哪条消息、是否同时删除对方的历史。TDLib 收到「历史已清空」的服务消息后,会以该消息 ID 更新本地清空边界,移除该编号及之前的本地消息;后续加载时也过滤边界内的旧消息,避免重新显示。是否移除会话列表入口由独立参数控制,因此可以清空历史并保留会话入口。

消息自见与会话拉黑

消息自见是指服务端接受并保存消息,但仅向发送方展示。它可用于不向发送者明示的反垃圾、审核限制;拉黑若要求对方不可感知,也可以采用这种方式。

自见消息仍需保存到服务端,供发送方在换设备或清除本地缓存后恢复。

服务端按可见性控制推送、历史拉取和检索。投递与读取需要分别处理:

  1. 投递时减少引用写入:采用写扩散且可见性已确定时,可跳过不可见用户,不写其收件箱引用。编号是否连续,仍由该收件箱的编号协议决定;

  2. 读取时过滤不可见正文:无论是否写入收件箱引用,历史拉取和检索等入口都要校验可见性。若客户端需要检查某段范围是否完整,服务端可返回无正文的占位记录,或明确告知该范围已检查、没有可见正文;客户端据此记录已覆盖范围,避免反复补拉。

消息自见与会话拉黑

同步审核在返回业务 ACK 前完成,可先确定可见性再投递。异步审核在消息被接受后完成,判定后更新可见性,并过滤后续投递和拉取;若接收端已收到正文,还需通知其隐藏或移除已有内容。这只能限制后续展示,不能保证消息此前未被看到。

实际应用

「仅发送者可见」Stream Chat 的 shadow ban允许用户继续发送消息,但只向本人展示;限制可作用于整个应用或指定会话。其早期 Android SDK 接入方案在消息中携带 shadowed(自见)标记,由客户端处理隐藏;当前更新说明已明确由服务端限制消息仅作者可见。

「拉黑关系与消息屏蔽」Stream 客户端调用 blockUser,由服务端保存拉黑关系;以其 Android SDK 为例,端上可从当前用户信息中读取已拉黑的用户 ID 列表,据此显示拉黑或解除入口。对于双方单聊,服务端向拉黑方隐藏会话,停止发送消息事件和通知,新消息不计入其未读;被拉黑方仍可发送消息。解除拉黑后,期间的消息也会重新可见。

八、多端协同与客户端 SDK

账号与设备管理

服务端按账号管理设备会话,并明确哪些设备可同时在线、哪些新登录会使旧会话失效。需要使旧会话失效时,服务端使其凭证失效,拒绝后续请求,并通知旧端、关闭旧连接。即使通知未送达,旧端在下次请求或重新鉴权时也会收到失效结果,停止自动重试,等待用户重新登录。允许同时在线的设备越多,在线投递和状态同步的工作量越大。

客户端按账号隔离本地数据。退出或切换账号时,停止旧账号的同步任务,清理对应的内存缓存和界面;旧请求随后返回的结果,也不能写入或刷新当前账号的数据。

账号与设备管理

前台连接与后台通知

前台长连接用于实时同步,系统推送用于后台提醒,两者不能互相替代。

应用进入后台后,可能被系统暂停,长连接也可能失效;连接记录仍在,不代表应用此刻能处理消息。

前台连接与后台通知

其中:

  1. 前台同步:应用运行且连接可用时,通过长连接收发消息,并按同步协议保存消息、更新游标;

  2. 后台提醒:应用无法及时接收消息时,服务端可通过 APNs、FCM 或厂商推送发送通知。通知能否展示受系统权限和通知设置影响;通知已展示,不代表消息已同步到应用本地;

  3. 恢复补齐:应用回到前台或重新联网后,按本地游标拉取遗漏消息及已读等状态,再更新聊天页和会话列表。无论是否收到过通知,都要执行恢复同步。

应用恢复后,根据最新消息、已读位置和计数状态校准未读与桌面角标。通知是否展示与消息是否同步分别处理。

系统通知可直接携带展示内容,也可仅提示应用拉取;后者依赖应用获准运行和网络可用,提醒可能延迟。

实际应用

「静默通知触发后台更新」Apple 的静默通知不弹提醒,而是让应用获得短暂的后台运行时间,从服务端拉取新内容。但系统不保证送达,也可能延迟或限制投递,因此不能仅靠静默通知保证消息及时同步。

「通知展示与应用处理分开」FCM 的部分高优先级通知可由 Google Play services 代为展示,无需启动应用。因此,用户看到通知时,应用可能尚未运行,本地聊天记录也未必更新;应用启动后,仍需通过消息同步流程补齐数据。

换机恢复与多设备同步

历史记录主要有服务端保存和客户端保存两种保存方案。

服务端保存历史:聊天记录保存在服务端,各设备按需下载并缓存。

  1. 换机与重装:登录后,先从服务端恢复会话列表和近期消息;

    翻看更早的聊天记录时,再按页加载;只能恢复服务端仍保留、且当前账号有权查看的记录;

  2. 多设备同步:每台设备用自己的游标记录已经同步到哪里;

    手机已同步到 6、平板只到 3 时,平板仍需补齐 4~6;

  3. 长期离线:如果服务端提示旧游标已失效,就重新获取会话列表、近期消息和新的游标;

    对已有的本地记录,也要核对哪些消息已撤回、已删除或不再有权查看,避免重新显示这些内容。

换机恢复与多设备同步

客户端保存历史:聊天记录主要保存在设备上,服务端负责转发新消息,并为离线设备短期暂存。

  1. 换机与重装:需要从旧设备迁移聊天记录,或从已有备份恢复;

    旧设备和备份都不可用时,重新登录也无法找回旧记录;

  2. 多设备同步:服务端为每台已关联设备分别暂存消息,各设备独立接收和确认;

    手机保存并确认后,服务端只清理手机对应的暂存记录;电脑对应的记录继续保留,等电脑上线后拉取;

  3. 长期离线:服务端暂存的消息有保留期限,过期后无法再补发;

    能否找回这段记录,要看其他设备或备份是否仍有这些消息,以及产品是否支持从中恢复。

换机恢复与多设备同步

实际应用

「本地历史与换机迁移」微信的聊天记录保存在设备上,换设备登录不会自动恢复旧记录。用户可以从旧手机迁移全部或部分聊天记录,也可以先备份到电脑,再恢复到手机。恢复时,备份记录与手机已有记录合并,不覆盖已有记录。

「设备投递与历史迁移」Signal 为每台设备分别暂存消息。手机确认收到后,服务端只清理手机对应的记录,电脑尚未接收的消息仍在保留期内暂存。为新关联的电脑或平板恢复旧记录时,手机将聊天记录打包、加密,再通过服务端传给新设备。换机或重装还可以使用其支持的加密备份或设备迁移。

SDK 的消息发送流程

SDK 将一次发送关联到同一条本地记录:先保存并显示,再提交服务端,后续确认和回显继续更新这条记录。

SDK 的消息发送流程

  1. 保存并展示:接收发送请求,生成本地标识和幂等键,将正文与发送状态写入本地库;

    保存成功后更新内存、通知界面显示「发送中」,再向服务端提交;

  2. 更新原记录:收到 ACK 标为已受理;回显或查询确认成功后,按幂等键更新原记录并补齐 msgId、消息编号;

    已确认成功时,迟到的 ACK 或超时回调不能使状态回退。每次保存成功后,再更新内存并通知界面;

  3. 排序与恢复:待发消息先按本地发送顺序展示,收到服务端编号后再按编号排序;

    本地标识保持不变,仍更新原来的消息记录。

实际应用

「临时消息与发送结果关联」Telegram TDLib 先为待发消息分配临时消息 ID,供应用提前展示;发送成功后,通过 updateMessageSendSucceeded 返回旧临时 ID 和最终消息。应用据此定位原气泡、替换临时 ID 并更新内容与状态,避免把最终消息再次插成一条新气泡。

SDK 的消息接收流程

长连接下推、同步拉取和历史回填得到的消息及状态,都交给 SDK 按统一流程处理。

处理消息前检查所属账号,旧账号的请求结果不得写入当前账号的数据。

更新可按账号串行执行,历史回填分批处理,避免长时间阻塞实时消息。

SDK 的消息接收流程

  1. 去重与合并:按 msgId 识别已有消息,新消息插入,编辑、撤回等按对应版本更新;

  2. 事务落库:消息、相关状态和同步进度一起保存,失败时不推进游标;若协议允许先推进游标、再补齐空洞,应将游标和缺失的消息编号范围一起写入本地数据库,重启后继续补拉这些消息;

  3. 更新界面:事务提交成功后更新内存,再合并通知界面,只刷新变化的会话和消息,避免每写一条就刷新一次。

启动时,客户端可先展示本地已有的会话和最近消息,同时联网同步;收到新消息、编辑或撤回等更新后,再统一合并、保存并刷新界面。

实际应用

「按变化更新界面」Telegram TDLib 分别通知应用新增消息、正文修改和会话排序变化。应用按通知顺序更新本地展示数据:新增消息加入列表,正文修改更新原消息,会话排序变化则调整会话列表中的位置。

新旧客户端的消息兼容

新旧版本的客户端会同时使用,消息兼容主要考虑两点:

  1. 旧版无法展示新消息:例如新版发送一种交互卡片,旧版无法展示,就显示“请升级查看”等提示;消息仍应有可见的气泡,并能正常标记已读,避免只增加未读数,却找不到对应消息;

  2. 先支持接收,再开放发送:服务端和客户端先做好新格式的识别与处理,再逐步开放新功能的发送入口;对尚未升级的客户端,继续提供约定的提示或简化展示。

新旧客户端的消息兼容

九、异常恢复与系统演进

连接异常时的请求处理

长连接不可用,而 HTTP 接口仍可访问时,SDK 可改用 HTTP 发送请求。无论使用哪种通道,发送结果都应更新同一条本地消息。请求超时后,消息可能已被服务端处理。SDK 可通过后续回显或查询结果确认发送状态;需要重试时,沿用原幂等键,按前文的去重规则处理。

连接异常时的请求处理

实际应用

「长连接中断后保留基础功能」Slack 的桌面及受支持浏览器客户端,在长连接无法建立、但 HTTP 接口仍可用时,仍能发送消息、浏览频道,并手动加载新消息。

网络恢复后的消息补齐

网络恢复后,SDK 根据已保存的游标补拉遗漏的消息、编辑、撤回等变化,并与实时收到的更新合并到本地。HTTP 接口可用时,就可以开始补拉。游标失效时,SDK 先获取并保存服务端返回的会话状态、近期消息及配套的新游标,再继续增量同步。

网络恢复后的消息补齐

网络恢复和回到前台可能接连触发拉取,SDK 应合并这些触发,复用正在进行的拉取,并控制失败重试的频率。

过载降级与逐步恢复

服务处理不过来时,请求排队变长;大量超时重试又会增加压力。此时需要限制请求量,或降低部分功能的更新频率,优先保障消息保存与后续同步。不同数据可以采取不同的降级方式:

数据类型可选降级动作恢复方式
输入中、在线状态降频、合并或丢弃过期事件收到新状态后更新,不补发过期提示
系统通知与角标折叠通知、延迟推送或减少重复提醒App 联网后同步消息和未读数,不逐条补发过期通知
已读、撤回、成员变化状态照常保存,推送可延迟或改为拉取补齐最新状态;成员权限仍按最新结果校验
需要可靠保存的消息先可靠保存,再排队投递或由客户端补拉无法可靠保存时返回错误;
已确认发送成功的消息继续完成投递

减少推送后,应同时控制客户端的拉取和重试频率。待队列积压与响应延迟持续下降,再逐步提高处理速度和允许的请求量,避免恢复时再次过载。

实际应用

「逐步放开启动请求」在Slack 2022 年 2 月的事故中,缓存命中率下降,更多请求转而查询数据库,造成过载。团队先限制客户端启动时拉取频道列表等初始数据的请求,让已进入应用的用户继续使用。第一次恢复时放进太多启动请求,数据库再次过载;于是重新收紧限制,再分几轮小幅增加允许通过的启动请求量,观察数据库负载后继续放开。

收件箱重建与同步恢复

服务端收件箱损坏或丢失后,优先从备份或日志恢复原引用和编号;无法恢复时,根据会话消息、历史成员关系和个人删除记录重建。重建前先记录后续变化,切换前补齐,并让后续更新持续写入新收件箱。

消息 ID 和会话内编号不变,收件箱编号可能改变。不能延续原收件箱编号时,服务端用版本号区分新旧收件箱,使旧游标失效。客户端保存会话状态、近期消息和配套的新游标后,继续增量同步;更早历史按需加载。

系统迁移与回退

更换消息或收件箱的存储时,可按以下步骤迁移:

  1. 记录新增与修改:旧存储继续提供服务,先记录此后的新增与修改。

  2. 复制并同步数据:复制历史,同时同步期间的变化;按消息 ID 和版本合并,保存复制进度。

  3. 核对后切换:历史与期间变化都补齐后,核对消息 ID、正文和顺序;先切换少量读取请求,稳定后再切换其余读写。

  4. 保留回退:切换后继续将变化同步到旧存储,出问题时可切回;确认新存储稳定后,再停用旧存储。

实际应用

「双写、复制历史与比对」Discord 从 Cassandra 迁移到 ScyllaDB 时,先把新消息同时写入两套存储,再复制历史消息;迁移器用 SQLite 记录进度,中断后可继续。随后抽取部分请求同时读取新旧存储并比较结果,通过验证后,将新存储作为主要消息存储。

十、核心指标

以下给出本文采用的统计口径,实际系统需统一检查范围和计时起止点。

消息发送

发送成功率 = 规定时间内确认发送成功的消息数 ÷ 发起发送的消息数。
发送确认耗时 = 发送端确认成功的时刻 − 用户发起发送的时刻。

同一消息的自动重试只算一次,失败和超时未确认仍计入分母。

按本文协议,ACK 只表示已受理,收到回显或查询确认成功才结束计时;富媒体耗时包含附件上传。

消息到达

在线端到端耗时 = 接收端保存消息的时刻 − 发送端发起发送的时刻。

在线消息与离线补齐分开统计,跨设备计时须校准两端时间。计时到接收端保存消息,不包含用户阅读或附件下载。

消息到达

数据完整性

消息完整率 = 消息检查通过次数 ÷ 消息检查总次数。
会话完整率 = 会话检查通过次数 ÷ 会话检查总次数。

每轮检查按约定范围核对本地记录,发现缺失就算未通过。未要求加载的历史、合法清理和无权查看的记录不算缺失。补拉前、补拉后分别统计,观察缺失是否修复。

数据完整性

命令消息还要检查是否执行成功。例如撤回通知已收到,但本地正文仍未隐藏,不能仅凭“已收到”判定处理完整。

媒体加载

图片加载成功率 = 下载并解码成功的次数 ÷ 需要下载的图片次数。
图片加载耗时 = 图片下载并解码完成的时刻 − 触发下载的时刻。
视频播放成功率 = 首帧回调次数 ÷ (首帧回调次数 + 播放错误次数)。
视频首帧耗时 = 首帧回调时刻 − 触发播放的时刻。

消息已到达,不代表附件已成功加载。图片从触发下载计时,视频从触发播放计时,与消息到达耗时分开统计。