状态感知
状态感知分为两大类,一类是用户在线状态感知
,另一类是业务数据变化感知
(如收到新消息、群成员发送变化)。
由于状态感知的具体实现与具体的产品需求有着密切关系,因此需要您能够把握住以下两点:
- 判断产品需求是否合理。通常不合理的需求,诸如:一个群内可以有10000名用户,当一名用户发送消息时,要保证这条消息能100%地传送给其他9999名用户,并且用户能够拉取几年前的聊天信息。
- 分清主次需求,尽可能在质量属性之间取得平衡。IM服务的实现细节繁多,是否真的有必要为了兼容极端情况,而设计大量的兜底策略(如消息会话级自增ID),既大幅度地增加了开发成本与故障点,也让服务端总体的吞吐量下降。
用户在线状态感知
简而言之,Turms通过心跳包来检测用户TCP连接的健康状态并以此判断用户是否“在线”。另外,如果您不关心底层实现,您仅需阅读:客户端API——会话的生命周期。
具体原理(拓展知识)
背景
从网络传输层来看,TCP只是一个虚拟的连接,需要通过双向的消息传递与消息确认来模拟物理连接,因此如果客户端与服务端之间的连接实际上断开了,但在没有完成四次挥手(即没有完成指定的消息传递与确认)的情况下,TCP仍然判定该连接属于保持状态(如果此时试图从该TCP连接中读取数据,则会抛出带有类似于“An existing connection was forcibly closed by the remote host”消息的异常)。因此对于基于TCP协议开发的上层即时通讯应用而言,如果我们不做额外的工作,服务端就只能错误认为“该用户处于在线状态”。
TCP没完成四次挥手的常见原因
- 客户端:客户端应用被强制关闭
- 服务端:负载持续过高无法响应;服务器直接宕机,导致服务端应用被强制关闭
- 链路中间路由:意外中断(如:移动接入网NAT超时)
应对异常断开连接的方案
为了保证服务端能感知到“用户下线”了的状态,Turms客户端会在上一个任意类型请求(如发送消息请求)的定长时间间隔后(暂不支持根据网络状况配置智能心跳),向服务端发送心跳包来维护其“在线状态”。服务端在收到客户端发来的心跳包或者其他业务请求后,都会在Redis服务端处刷新客户端的在线状态,以此来保活。
业务数据变化感知
为了让用户能感知业务数据的变化(增删改),Turms支持推模式(服务端主动通知)、拉模式(客户端主动拉取机制。支持按Timeline拉取)以及推拉结合模式,以在实时性与资源消耗之间取得平衡,并让开发者能够自行调整实时性与资源消耗之间的权重。
感知方式
方式一:推模式(服务端主动通知)
推模式指的是:当某个业务模型发生变化时(由于增删改操作),服务端将主动通知相关在线用户该事件的发生。而当客户端收到通知时,Turms客户端会触发NotificationService
中的onNotification
回调函数。该函数的参数为TurmsRequest
对象,表明触发该事件的请求。
通知相关行为可以根据:im.turms.server.common.infra.property.env.service.business.NotificationProperties
类进行配置。每一种通知类型都可以单独配置,并且所有通知相关配置均可在集群运行时进行动态更新。
示例
以im.turms.server.common.infra.property.env.service.business.NotificationProperties#notifyMembersAfterGroupUpdated
这个属性为例。该属性用于控制“当群组信息发生变化时,是否通知群组成员”。这里的群组信息指的是:群组名称、群组类型、群组禁言时间等这样具有全局性的群组信息。
如果您将该属性值设置为true,则当群组信息发生变化时,群组成员的客户端都将收到触发该变化的通知。否则,群组成员客户端不会收到任何通知。
评价
通知机制可以保证通知能实时地传递给相关用户,但其缺点就在于它很容易导致无意义的资源消耗(以具体业务场景为准)。比如用户A已经加入了100个群组,但该用户平时只查看其中3个群组的信息。这种场景下,如果100个群组的所有状态变化都开启了通知机制,则不管是服务端还是客户端都需要浪费大量资源去处理这些无意义的通知(因为该用户从来不看这些通知)。
为了解决该类问题,以及满足其他常见需求(如:要求当时离线的用户在上线时也能检测到业务模型是否发生变化;要求在线用户在通知被关闭的情况下也能感知业务模型的变化),Turms还提供了拉模式(客户端主动拉取)让用户来感知业务模型的变化。
方式二:拉模式(客户端主动拉取。支持按Timeline拉取)
为了弥补上述提到的推模式的不足,Turms还提供了拉模式。
大概实现
Turms的每个业务模型都带有一个版本信息,这个版本信息记录了该业务模型最后一次更新的时间。当客户端向服务端请求资源时,可以携带客户端最后一次更新该业务模型的时间(也可以不带),Turms服务端会对这个版本信息与当前业务模型的版本信息进行比对,如果客户端发来的版本信息早于当前业务模型的版本信息,则Turms服务端会返回最新的业务模型数据,否则抛出状态码NO_CONTENT
,在客户端处则会收到空数据。
常见拉取时机(同步时机)
- 当您的应用被切换到前台时
- 会话重新连接上时
- 根据具体业务而定(看下文示例)
示例
继续以上述的案例为例。假设我们希望群组成员之间能够实时感知其他群组成员资料信息的变化。那如果我们采用通知机制,假设每个群除了用户A还有其他100名在线用户,则用户A的资料信息变化,需要向其他10000(100群*100人/群)名群组成员发送通知,这在实际运用中是绝对不可取的。
在实际运用中,通常会在特定时机(比如在用户打开某名用户的个人信息UI界面时,或者打开和某人的聊天窗口时),才让客户端主动请求服务端该用户的信息。同时,通过版本对比,减少无意义的资源浪费。
这种时刻注意实时性与资源消耗的设计要牢记在心中,以免设计出不切实际的应用场景。
客户端对用户行为感知的实时性与服务端延迟
以拉黑用户的相关实现为例,Turms默认对用户关系进行1分钟的缓存,以避免频发查询数据库,这是合理的行为。如果此时用户A“拉黑”了用户B,那么可能会出现:虽然用户A拉黑了用户B,但在有缓存的这段时间里,用户B仍然有可能可以给用户A发送消息(因为Turms服务端是分布式集群,关系缓存与接收拉黑请求操作的服务端不一定是同一个服务端)。这种行为对Turms服务端是可以接受的,而不是Bug。
其合理且理想的参考解决方案是:在客户端的业务层面上(业务逻辑由您控制,而非由Turms客户端控制),就算Turms服务端发送给Turms客户端消息,您的客户端也应该根据您产品自身的业务逻辑,再做一次是否已拉黑用户判断,如果是,则隐藏不显示。
消息感知
读扩散与写扩散
Turms的架构是基于读扩散消息模型而设计的。下表对读扩散与写扩散各自的优劣势进行了比较,供读者参考:
读扩散 | 写扩散 | |
---|---|---|
含义 | 1. 每名用户跟与其聊天的其他用户或群都有一个独立会话(也叫做信箱或Timeline)。 2. 当用户发送消息时,无论私聊还是群聊,数据库都只需存储一份消息记录。 3. 当用户查询消息时,客户端需要向服务端发送一个请求来拉取指定会话ID列表的消息;或者(重点)先通过一个请求不指定会话ID列表,来拉取所有私聊会话的消息,再通过一个请求指定群聊会话ID列表来拉取群聊消息 | 1. 每名用户有且仅有一个信箱。 2. 当用户发送消息时,需要把这条消息写到该会话内的所有成员信箱中,即若群聊中有其他成员100人,则需要把这消息写100次。 3. 当用户查询消息时,客户端无需指定会话ID列表,只需要向服务端发送一个请求读取自己信箱中的消息即可 |
优势场景 | 用户会话(私聊会话与群聊会话)相对少,群人数多的场景。 注意:如果应用只有私聊会话,没有群聊会话,那么在Turms服务端的实现下,读扩散与写扩散的优劣势场景其实并没什么太大区别,因为两个消息模型都只要求用户发消息时,数据库写一次消息;用户读消息时,数据库根据索引查一次表(Turms采用的是 消息发送时间 + 收信人ID 复合索引,具体见消息集合设计) | 为了避免太多的消息拷贝,因此写扩散相对更适合群聊多,但群成员少的场景 |
劣势场景 | 因为客户端需要指定群聊会话的ID列表,因此读扩散的劣势场景是:群聊会话数多,且用户频繁读消息。 提醒:Turms服务端是通过一条MongoDB客户端请求,并基于索引来完成上述的查询操作的,因此性能其实也很高效。只是相对于写扩散而言,该场景对于读扩散是劣势场景 | 因为群成员越多,消息被拷贝的次数越多,因此写扩散的劣势场景是:单个群的成员数多,且群成员频繁发消息 |
技术实现 | 1. 可以通过MongoDB的分片副本架构,对读请求进行负载均衡 2. 所有的读请求都是基于索引实现的,性能高效 | 1. 写操作难以进行负载均衡 2. 更新消息、撤回消息等IM功能的实现成本巨大,需要考虑分布式一致性问题与消息风暴 |
消息可靠性 | 如产品对消息可靠性有较高的要求,即保证消息不丢,保证消息内容一致,那么读扩散对应的实现简单得多,因为数据库只需存储一条消息,用户都只需读取这一条消息 | 因为要保证消息写入到每位群成员的信箱中,因此需要引入弱分布式一致性事务(或强分布式一致性事务),否则消息可能丢失,但分布式一致性事务会导致吞吐量低下 |
总评 | 1. 读扩散适用的产品极广,对于写扩散实现成本巨大的特性,基于读扩散实现,通常都只需要客户端自定义好查询条件,并通过向Turms服务端发送一条查询语句来即可实现(如群组新成员消息分享、多端消息同步),服务端不需要改一行代码,且这些查询任务都是基于索引完成的。 2. 读扩散在劣势场景下依然能够依靠索引保证较高的效率 | 由于写扩散需要写入大量的消息,如有任何更新操作(撤回/更新)还需要使用分布式事务,并且IM功能特性(如群组新成员消息分享、多端同步)的实现很复杂。 综上,写扩散的业务拓展性极差,其使用场景基本限定在:应用基本都是私聊,没有群聊,且业务功能简单,但对于只有私聊的应用,如上所述,读扩散或写扩散的性能表现都差不多。 如果您团队的产品经理要求添加业务功能,您的开发团队很快就会体会到只支持写扩散对IM系统是多么致命的设计。读扩散可以很高效且容易实现的功能,但对于写扩散而言,这就成了低效且高难度实现地功能了 |
再次特别强调:除非您非常明确您的产品的用例就如上述简单且局限(私聊会话数多少无所谓,但群聊会话数多且群成员少),且未来业务需求也基本不变,否则用了写扩散消息模型基本就意味着您的产品终有一天需要重构回读扩散模型,或同时支持读写两套模型。当然,写扩散也可以作为“技术负债”长期保留。
提醒:
- 从写扩散实现改成读扩散实现几乎意味着要把整个项目的设计与实现都从头重现一遍。也因为消息模型对IM架构的影响是如此巨大,我们在谈Turms的架构时,第一句永远是
Turms的架构是基于读扩散消息模型而设计的
。 - 在Turms服务端的实现中,“撤回消息”也是一条消息,即一种特殊的系统消息。
消息接收、消息更新与消息撤回
Turms基于上述的“推模式”与“拉模式”实现客户端的消息接收、更新与撤回。其中:
结合上述的
常见拉取时机
与下文的关于消息的可达性、有序性与重复性
,Turms是可以实现100%消息必达、消息一致性排序与去重消息更新与撤回的通知本质上也是一条消息,即一条特别的系统消息。Turms服务端在接收到用户发出的消息更新或撤销请求后,会先判断该功能是否启动、用户是否有权限、是否在一定时间区间内等等判断,如果验证通过,则会(下文以撤回消息的流程为例,更新消息同理):
Turms服务端先对存储在数据库的目标原消息记录做修改,给它标记上“消息被撤回”的时间戳。
然后再生成一条“撤回消息”的系统消息(注意它是
message
,不是通知notification
),并插入到消息集合中。最后再将上述的“撤回消息”的系统消息,发送给对应的在线用户,以告知这些客户端:之前某些消息已被撤回了。
开发者需要在客户端接收到这系统消息后,自行做对应业务层上的处理(Turms客户端除了解析哪些消息被撤回外,自身不会做其他任何的逻辑处理),比如在本地物理删除该消息,或只是将其隐藏,或是将被撤回的消息替换成类似“该消息已在XX时间被撤回”等等。
补充:如上所述,目前Turms服务端处理撤回消息时,会发送给对应的在线客户端一个“撤回消息”的系统消息,以保证在线的客户端能迅速撤回本地已接收到的消息,但之后还会添加配置项,以支持不想让Turms服务端主动发该系统消息的应用。
如果用户已经下线了,而没有接收到这个“撤回消息”的系统消息,那么在用户下次登陆时,由于它仍需要去主动拉取离线时收到的消息,所以在拉取的过程中也会顺带把上面插入的“撤回消息”的系统消息拉取下来,开发者在检测到这类系统消息时,再做具体的业务层处理即可。
提醒:开发者可以通过客户端侧所提供的消息服务中的
addMessageListener
接口,来判断接收到的消息是否为“撤回消息”的系统消息,以turms-client-js客户端为例:jsturmsClient.messageService.addMessageListener((message, addition) => { if (addition.recalledMessageIds.length) { // is a system message to recall messages } else { // not } });
另外:
- 关于Turms服务端删除消息的流程,Turms服务端目前只是对消息做对应的软删除或硬删除,并不会执行任何“撤回消息”相关的逻辑。我们之后会给Turms添加对应的配置项,以支持希望删除消息时,也执行撤回消息的应用。
- 目前Turms服务端对“更新消息”并没有提供如“撤回消息”那样完整的支持,这部分的优化会在近期完成。
关于消息的可达性、有序性与重复性
架构设计永远是平衡的艺术,盲目承诺消息100%必达只是一种销售的说辞。好比大部分互联网应用在分布式事务的技术实现上,只会采用性能更好的弱分布式事务,而非虽然更可靠但性能低下的强分布式事务。是否需要实现100%的消息必达还是需要根据业务场景而定。如在直播聊天室场景,不仅不要求消息必达,甚至还会要求服务端要能根据负载情况与消息优先级,主动丢弃用户消息,或者只将消息发送给一部分用户。
直播场景也可能不强制要求消息有序性,而是要求“怎样消息吞吐量大,怎样设计。尽量保证消息的有序性,但不提供额外辅助资源进行支持”。一些设计IM应用也可以“为了取得高吞吐量与高可达性间的平衡,对免费群采用非消息必达机制,对VIP群采用消息必达机制”。实际应用的需求永远是五花八门的。
因此再次强调:做功能设计时,要分清主次需求,尽可能在质量属性之间取得平衡。切忌脱离业务场景,闭门造车。
总结
由于下文各种消息特性的具体实现对比相对复杂,该总结部分为您快速归纳最终方案。
在大原则上Turms在设计时遵循能客户端自己实现的,Turms服务端就不实现,以实现最大的吞吐量也灵活业务实现。如果特性必须由服务端实现,且对吞吐量影响不大,则默认开启,否则默认关闭
,具体而言:
可达性
方案一:如果您希望实现几乎100%的消息必达,您可以开启
turms.service.message.sequence-id
下的use-sequence-id-for-group-conversation
与use-sequence-id-for-private-conversation
(默认配置下,均关闭),该机制会在每次生成消息记录时,向Redis请求一个会话级别的自增sequence ID
,并将这个ID赋给当前消息记录上,客户端可以通过这个ID的自增性与消息发送时间判断消息是否丢失(需要判断消息发送时间是因为:如果Redis宕机,序列号数据丢失,序列ID会从头开始计算,而当客户端检测到序列号变小时,则可以再根据消息发送时间判断哪条是最新的消息)。注意:
sequence ID
跟message ID
没有任何关系。方案二(默认实现):如果您不要求消息必须100%必达,则关闭上述配置,从而获得更大的消息推送吞吐量。
有序性
顺序最终一致性
- 方案一:借助上述提到的自增
sequence ID
“顺便”实现消息的有序性 - 方案二:(默认实现)使用服务端时间保证消息顺序。提醒:不仅仅是消息需要使用系统时间,Turms服务端各个功能模块也重度使用系统时间,如基于Snowflake算法生成的ID、日志的时间戳与基于时间戳的限流防刷机制。
- 方案一:借助上述提到的自增
接收顺序一致性:部分IM系统会通过延迟发送消息或客户端延迟展示消息,来尽可能避免“客户端先接收到在后发送的消息、再接收到在前发送的消息”,导致消息UI需要重排。但Turms暂未计划提供相关支持
因果一致性:客户端发送消息时,可以携带
preMessageId
字段,用于指示在消息发送客户端UI上显示的上一条消息ID是什么。该记录对Turms自身没任何实际作用,但其他客户端可参考该值做上层的消息UI展示,以实现客户端之间消息逻辑的因果一致。注意:
preMessageId
跟“消息可达性”的实现没有任何关系,它仅仅用于您产品进行消息UI排序
重复性。Turms服务端在这方面只是提供全局ID唯一的消息记录,消息的去重工作需要开发人员自行在客户端实现:如果您的应用需要实现100%的消息去重,则需要考虑落盘存储已接收的消息ID。如果您的应用只需要保证一个应用的生命周期内消息去重,那就只需要在内存中存储已接收的消息ID,每当服务端推送来新消息只需判断该ID的消息是否已处理过即可。
提醒:通常只需要存储本地最近时间(如最近1天)的消息ID即可,没有必要进行全量存储
另外,下文会把一个业界常见但却通常非常失败的设计方案,即采用需要服务端参与的消息确认机制
方案作为反面案例进行讲解。它用最高的成本实现了最差的“可达性”与“重复性”的效果,并且性能与拓展性也都极差。(TODO:尚未更新该部分文档)
消息确认机制(Acknowledge)
值得注意的是:
- Turms的消息确认机制并不需要Turms服务端的参与
- 消息确认机制与业务层面“消息已读”功能是完全独立的,二者没有关联关系。
需要服务端参与的Ack机制 | 不需要服务端参与的Ack机制 | |
---|---|---|
介绍 | 部分即时通讯架构设计中,会要求客户端在接受到消息后,间隔一定时间(如5秒、10秒等),向服务端发送消息确认请求(而不是一接受到消息就确认。一是为了提高确认处理效率,二是减少因网络延迟问题丢消息的概率)。 服务端记录每个会话最新的确认时间,以实现用户在对所有会话进行消息拉取时(如用户上线时),可以通过一个简单的请求去拉取确认时间至今的所有消息。 | 客户端本地存储每个会话的最后确认时间,客户端如果想获得任意其所属的会话消息,则向服务端发送对应的会话ID与确认时间,服务端会返回确认时间至今的所有消息。 |
优点 | 1. 客户端实现简单,无需在本地存储会话信息 | 1. 客户端可以自定义消息拉取范围。业务适用面更广,可以很轻松支持多端消息同步功能 2. 服务端不需要先查一次所有会话的确认时间,再根据Ack时间拉取消息,性能更优 3. 不需要客户端定时发送确认请求给服务端,能够完全省去大量确认操作带来的性能开销 |
缺点 | 1. 服务端需要先查一次所有会话的确认时间,再根据确认时间拉取消息,性能相对差 2. 对于受到的每一条消息,客户端都需要向服务端发送确认请求,然后服务端更新对应的消息状态,性能低下 | 1. 客户端发请求时,需要携带所有欲请求消息的会话ID与其对应的确认时间,请求体相对较大(但也对应了上述②的优点) 2. 需要开发者自行实现客户端本地数据库(如:Realm数据库。Turms未来可能会以拓展形式,帮助开发者实现本地存储功能) |
关于消息的可达性
架构设计永远是平衡的艺术,盲目承诺消息100%必达只是一种销售的说辞。好比大部分互联网应用在分布式事务的技术实现上,只会采用性能更好的弱分布式事务,而非虽然更可靠但性能低下的强分布式事务。是否需要实现100%的消息必达还是根据业务场景而定(如在直播聊天室场景,不仅不要求消息必达,甚至还会要求服务端能主动根据负载情况,抛弃用户消息)。
实现消息100%必达的方案也比较简单,可以通过Redis实现一个会话级别的自增ID生成服务器,保证消息ID在一个会话内递增。客户端能通过ID的递增性自行判断是否有消息丢失,如果发现消息丢失,则发请求向服务端拿取指定消息即可。
Turms会同时支持上述的会话级消息自增ID实现来保证消息100%必达(TODO),同时也提供基于Snowflake算法的全局自增ID实现来提供最佳的吞吐量(代价就是消息不能保证100%必达)。
关于未读消息数的实现
业务需求
- 作为应用桌面角标(Badge Number)时,显示未读消息总数(iOS必须服务端计算总数)。需要支持离线更新,或不需要支持离线更新
- 作为应用内的会话角标时,显示各个会话的未读消息数
方案
不支持离线消息推送时携带未读消息数(默认实现) | 支持离线消息推送时携带未读消息数(TODO) | |
---|---|---|
实现 | 客户端在接收消息与拉消息时,自行发送请求让服务端实时计算“未读消息数”。 在这个方案中,Turms服务端其实并没有 未读消息数 这个概念,服务端只是根据客户端请求去计算某个消息发送时间区间内的消息数 | 使用Redis,支持离线消息推送时携带未读消息数:携带会话未读消息数与总消息未读数;只携带总未读消息数 大致实现是:服务端接收到消息时,将对应的收信人在Redis的未读消息数记录加1,总数也加1 用户读取消息时,或用户或群组被删除时,则在Redis记录中做相反的减操作 (注意:总未读消息数必须由服务端计算) |
优点 | 1. 实现简单且可以灵活地支持各种业务需求,无需专门引入Redis服务端 2. 发送消息时,无需向Redis发送请求去计算消息未读数,写吞吐量更高 | 1. 支持离线消息推送时携带未读消息数 2. 读取未读消息时,不需要实时计算,读吞吐量更高 |
缺点 | 1. 不支持离线消息推送时携带未读消息数 2. 客户端读取未读消息数时,需要实时计算,读吞吐量更低(补充:有索引支持) | 1. 需要引入Redis服务端,增加运维成本与难度 2. 服务端每次接收到新消息,都需要Redis发送请求去计算消息未读数,写吞吐量更低 |
与未读消息的关系 | 未读消息 与未读消息数 都是以端为维度,由客户端自行通过上述的客户端向服务发送本地消息最后确认时间,来获取这个时间点之后的“未读”消息与“未读”消息数。因此不同端得到的 未读消息 与未读消息数 可能是不一致的 | 未读消息 仍是以端为维度,但未读消息数 则以用户为维度。如果消息A在桌面端被“读”了,那手机端仍可以认为其“未读”,但推送给该用户所有客户端的未读消息数都统一减了1因此不同端得到的 未读消息 可能是不一致的,但未读消息数 是一致的 |
补充 | 如上文所述,该方案其实也能“强行”支持离线消息推送时携带未读消息数。 但因为这方案并不是为频繁读取未读消息数而设计的,因此如果每次推送消息时,都让服务端自行实时计算未读消息数,其性能明显是不可取的,因此实践上是不支持的 | 上述方案各有优劣,具体用哪个方案,取决于具体应用的业务需求。不需要支持离线消息推送时携带未读消息数,则采用左侧的方案,需要支持则采用右侧的方案。 如果客户在这两个方案基础上,还有额外需求,则需要自行做二次开发 TODO:该实现将在近期支持 |
具体实现
TODO
关于离线推送的实现
对于在线用户,开发者可以通过notification属性来配置是否让服务端主动推送消息给在线用户(默认为true)。对于离线用户,离线推送的实现通常需要借助手机运营商提供的推送SDK,通过其通道进行离线推送。
但由于Turms本身不接入任何运营商,也没计划接入,因此您需要通过NotificationHandler
插件来实现自定义的离线推送逻辑。该Handler提供一个handle函数,并接受消息信息、在线用户ID、离线用户ID与可选的未读消息数这四个参数,您可以自行通过该函数调用厂商提供的推送SDK,来实现离线推送逻辑。
消息批量拉取
TODO:暂不支持。由于消息拉取是由客户端自行控制的,因此该功能可以很容易地高效且灵活实现,我们会在正式发布之前提供支持。
特大群
特大群的实现其实并不难,只是它的业务需求与场景跟一般社交应用的很不一样,所以要有一套专门的策略来支持特大群。
策略(TODO)
- 消息按照优先级发送
- 智能限制消息峰值,主动根据服务端状况与消息优先级丢消息
- 分桶(分小群)发消息
- 通常不需要消息漫游功能