本文主要围绕个人在设计IM中核心业务(信息收发)中的存储&缓存层的构建时的一些思考。苦逼大学生,才疏学浅,请多指教!
事不宜迟,首先非常简单暴力的概括一下信息收发中表拆分的业务需求,无非就是:
- 三关系表:user_to_user \ user_to_device \ user_to_session
- 单消息表(每条消息所具有的信息):msg(userId, sessionId, msgId, seqId, content, type, is_delete)
- 单消息状态表:msg_state(userId, msgId, type)
注意:消息状态并非是消息本身的属性。此处状态是针对每个用户而言。譬如说A信息可能B用户已读,但是C用户未读。此时在消息状态表中存储的状态就是不一样的
综上表,我们设计出了一个应对IM中信息收发业务的表架构雏形。但是在规模较大的情况下完全不可行的,需引入缓存及其他优化措施,此处主要解释缓存相关设计。
注:下方设备与客户端同义
在用户下线之后,原下发消息会不断挤压。等待用户再次登录的时候拉取。此处使用redis中的zset进行存储,以sessionID为key。不同的会话内,以消息的序列号(seqID)为score,msgID为value。存储离线消息信息。
当用户该设备再次上线时,从redis中对应sessionID拉取对应的msgID的list,将其返回。
在允许多设备登录的情况下,某条信息可能已经下发到A设备中并且被用户已读了。但是由于B设备未登录,B设备对此消息的状态是未知的,会默认拉取离线队列,并且再次将其标为未读。造成了消息状态不一致的问题。为解决这一问题,只需要在取出msgID-list时,查询消息状态表即可。
但是此举会大大降低拉取的效率。也许可以在缓存层再设计消息状态的分布式缓存?但这种做法同样需要维护消息状态缓存的一致性。
一种好的方法是利用旁路缓存思想。在消息的状态发生变更的时候,直接将缓存删除。而当消息状态查询请求到来时,先查缓存,miss则查DB且回写缓存,hit则返回缓存。这一策略牺牲了一定的查询效率,但是尽最大努力保证DB层和cache层消息状态的一致性。
同时,随时间增大,zset中的缓存数据并不会收敛,会一直积压离线消息。此操作本身会对存储空间造成一定的压力。可对其进行定期清理,或实现多设备间消息同步,但也同时会对客户端上压力。所以需根据IM的具体业务场景,商讨其中的存储实现细节。
在上述表关系中,用户 & 会话 & 设备之间的映射关系是一大重点。映射关系决定了信息的下发对象,是IM运行的根本。并且他的修改请求频率较少(用户加入群聊,新设备登录,添加好友的频率 对比 发送消息的频率)。
但是同时,对这类映射关系的读需求很大。每当一条信息下发,服务器需要根据sessionID找到含有的userID,再根据userID找到各用户的deviceID。在这每一步的查询中若miss造成了缓存击穿,影响非常严重。
综上,可以得出此板块读多写少。于是我们可以全盘维护一个映射关系的缓存。同时基于flink等大数据组件,实现数据的流式更新。但是这也会对存储空间造成很大的压力。当然,可以改变架构使用图数据库存储,或改为session绑定,但需同时修改接入层的架构。
上方的设计理论上对消息下发进行大量的优化,但只是针对于一般情况下的运行作分析,仍存在很多问题。下方分为极端情况 & 异常状况进行分析:
先回顾一下信息下发的真实流程:假设有
k
个群聊,每个群聊中有n
名用户,每个用户具有m
台设备。那么在所有设备在线的情况下,一条上行消息对应发送(n * m) - 1
条下行消息(除去发送方)。k
个群聊单条消息对应k * ((n * m) - 1)
条下行消息,这一数量级是非常恐怖的。
(渣图如下)
userId -> sessionId -> deviceId
的路线。成为性能瓶颈。…
假设缓存引擎清一色为redis
…
至此,初步讨论了设计IM时的典型问题,之后将对现有架构作进一步完善,并改善现有问题。