TT 作为国内 TOP3 的社交应用,IM 是非常核心的功能,下边我来介绍一下 TT 的 IM 是如何保证消息时序的。
什么是消息时序?
消息的时序代表的是发送方的意见表述和接收方的语义逻辑理解。如果时序一致性不能保证,可能会导致聊天语义不连贯,容易出现曲解和误会。
比如,你给一个小姐姐发送了1、2、3、4、5几句话,小姐姐收到的却是4、5、2、3、1。这个小姐姐一定觉得你是个脑残,直接拉黑了。
对于单聊的场景,时序一致性需要保证接收方的接收顺序和发送方的发出顺序一致;
对于群聊的场景,时序一致性保证的是群里所有接收人看到的消息展现顺序都一样。
如何保证消息时序的一致性?
在讨论这个问题之前,我们先要知道为什么消息时序一致性不容易保证:因为对于后端服务来说,是不同机器、并发处理用户每个发消息请求的。也就是说用户发送过来的消息,被成功处理的先后顺序是不确定的,处理一条消息的内部逻辑非常复杂,举几个最常见的:消息过模型判断是否违规、判断双方用户状态、更新各种未读数等等。如果我们按照服务器处理一条消息成功后的时间将消息推送给对方,那么很有可能对方的接收顺序并不是之前的发送顺序。
这种情况下就需要给每一条消息提前分配好一个确定的发送时间点,也可以不是时间,只要是一个可比较大小的值就行,要满足后发送的消息一定比先发送的消息值要大。
我们称这个值为「时序基准」,多条消息之间可以根据一个共同的「时序基准」可以来进行比较。
接下来的问题就转变为了如何找到一个合适的「时序基准」。
获取「时序基准」的几种方式
客户端生成
客户端在发送消息时连同消息再携带一个本地的时间戳或者本地维护的一个序号给到 IM 服务端,IM 服务端再把这个时间戳或者序号和消息一起发送给消息接收方,消息接收方根据这个时间戳或者序号来进行消息的排序。
使用客户端时间或序号可能会有以下几个问题:
- 客户端时钟存在较大不稳定因素,用户可以随时调整时钟导致序号回退等问题。
- 客户端本地序号如果重装应用会导致序号清零,也会导致序号回退的问题。
- 类似「群聊」和 「多点登录」这种多客户端场景,存在:物理世界中的同一时间点,不同客户端同时发消息给同一个接收方。
第3点不太容易理解,用一个例子解释一下:比如同一个群里,多个用户同时发言,多客户端间由于存在时钟不同步的问题,并不能保证客户端带上来的时间是准确的,可能存在群里的用户 A 先发言,B 后发言,但由于用户 A 的手机时钟比用户 B 的慢了半分钟,如果以这个时间作为「时序基准」来进行排序,可能反而导致用户 A 的发言被认为是晚于用户 B 的。
IM服务器生成
客户端把消息提交给 IM 服务器后,IM 服务器依据自身服务器的时钟生成一个时间戳,再把消息推送给接收方时携带这个时间戳,接收方依据这个时间戳来进行消息的排序。
在实际环境中,IM 服务都是集群化部署,集群化部署也就是许多服务器同时提供服务。
虽然多台服务器通过 NTP 时间同步服务,能降低服务集群机器间的时钟差异到毫秒级别,但仍然还是存在一定的时钟误差,而且 IM 服务器规模相对比较大,时钟的统一性维护上也比较有挑战,整体时钟很难保持极低误差,因此一般也不能用 IM 服务器的本地时钟来作为消息的「时序基准」。
全局序号生成器
如果有一个全局递增的序号生成器,就能避免多服务器时钟不同步的问题了。IM 服务端就能通过这个序号生成器发出的序号,来作为消息排序的「时序基准」。
这种「全局序号生成器」可以通过多种方式来实现,常见的比如 Redis 的原子自增命令 incr,DB 自带的自增 id,或者类似 Twitter 的 snowflake 算法、「时间相关」的分布式序号生成服务等。
TT 在用的发号器
TT 没有搭建独立的全局序号生成服务,而是利用 PostgreSQL 强大的 function 能力来实现的。TT 本身也在结构化存储上大规模使用了PostgreSQL,基建相对来说是比较完善。
我们自己在 PostgreSQL 内实现了发号器函数,可以根据自己的ID、对方 ID、当前时间、shard 等条件生成集群间毫秒级唯一、保证递增但不保证连续的ID。
性能
我们 IM 使用的PostgreSQL集群分了8192个逻辑shard,每个shard每毫秒可生成1024个序号,理论上整个集群每秒最多了生成 (1024 * 1000 * 8192
)=83亿个序号,性能上完完全全是够用的。
可用性
PostgreSQL 有自身的高可用架构,另外我们还用了 PostgreSQL 强大的逻辑 shard 能力,两个用户间的消息ID通过哈希规则,固定选择其中一个的shard 来生成,即使某个shard真的出了故障也只会影响8192 / 2 =
4096分之1的用户。
两个用户间的一致性
考虑一个问题,如果不同的数据库实例的时间不一致,两个用户间的聊天顺序是否会有影响?答案是没有影响。
两个用户之间的消息ID始终通过一个固定的实例生成的。具体shard选取规则为:
1 | (uid + other_uid) % shard_num |
通过以上规则,可以保证无论是用户A发给用户B的,还是用户B发给用户A的消息,都可以路由到同一个shard上。
这相当于两个用户间的消息ID是基于同一个单机的发号器来生成的,不会由于不同机器时间不一致而造成消息顺序错乱的问题。
群消息
群聊消息的序号是以群的唯一 ID 计算哈希后,找到对应数据库 shard 来生成的。也就是说,多个用户在同一个群内的发言也是通过同一个发号器来生成序号,同一个群内的消息时序可以得到保证。
我们的精度也相对更高。据我所知,微博和微信的消息只能做到秒间有序,而我们可以做到毫秒间有序(然并卵)。