大部分消息进行服务端存储,是为了便于查看历史消息或者用于暂存离线消息。
一个支持用户点对点聊天的消息收发架构主要包括三部分:消息存储、消息未读和消息收发通道。
一、消息存储
假设收发双方的历史消息都是相互独立的,即一方发送消息后删除了消息,另一方仍可获取到这条消息,则消息的存储需要用到两张表:消息内容表(图中的 user_message 表)和消息索引表(图中的 user_message_history 表)。前者主要存储消息ID、消息内容、消息类型、消息创建时间等,后者可以理解为历史聊天记录,记录了收发双方的用户ID,通过消息ID和前者关联。
一般 IM 系统还需要一个最近联系人列表(图中的 user_message_contacter 表),使互动双方能快速查找需要聊天的对象,联系人列表还会携带两人最近一条聊天消息用于展示,该表与消息索引表的区别在于:消息索引表存储收发双方的历史消息记录,联系人表主要用于查询某个用户最近的所有联系人。
假设张三给李四发送了一条消息,会先向消息内容表插入一条数据(假设是图中 user_message 表 ID 为 1001 的记录),然后向消息索引表插入两条数据,一条是用户ID为张三的记录(假设是图中 user_message_history 表 ID 为 30923 的记录),一条是用户ID为李四的记录(假设是图中 user_message_history 表 ID 为 30922 的记录),两条记录的消息ID都是 1001。
同时会分别更新张三的最近联系人和李四的最近联系人,前者是查找联系人表中是否有用户ID为张三USERID且互动人ID为李四USERID的记录,如果没有,则插入一条新的联系人记录,最新消息ID就是 1001;反之,如果张三和李四之前已经有过聊天记录,就更新最新消息ID即可。同样的办法更新李四的最近联系人。
如果想列出张三与李四的对话消息记录,可以使用如下 SQL 语句查询:
select * from user_message_history where user_id = 张三USERID or contacter_id = 张三USERID;
二、消息未读
如果一方发送消息,而接收方不在线或限制通知栏提醒权限,则需要有未读提醒来作为补救措施。具体实现是设置一个未读消息总数和针对某个接收方会话的消息未读数。
当张三给李四发送消息,IM 服务端接收到消息后,给李四的总未读数加 1,给李四和张三的会话未读数加 1;
李四查看这条消息后会执行未读数变更,将李四的总未读数减 1,将李四和张三的会话未读数减 1。
一般,需要支持“消息多终端漫游”的功能,未读数存储在 IM 服务端,反之选择本地存储即可。
三、消息收发通道
- 发送通道
客户端 和 IM 服务端之间维持一个 TCP 长连接,IM 服务端提供发送消息的 API;
当客户端有消息发送时,会以私有协议封装这条消息,然后调用 API 把消息发给 IM 服务端。
- 接收通道
IM 服务端的网关服务和接收消息的客户端之间维持一个长连接(TCP长连接 或 Websocket长连接),借助 TCP 能同时接收与发送数据的能力,把消息从 IM 服务端推送给接收方。若接收方不在线(无网络或未打开APP),可借助第三方操作系统系别的辅助通道、各种设备的厂商通道,将消息通过通知栏的方式推送给接收方。
四、思考题
1、消息存储中,内容表和索引表如果需要分库处理,应该按什么字段来哈希?内容表和索引表可以合并成一个表吗?
答:如果对内容表进行分库分表处理,应该按消息ID(主键ID)来哈希,有利于定位某一条具体的消息;如果对索引表进行分库分表处理,应该按用户ID来哈希,这样可以使与该用户互动的所有联系人都落在一张表上。
索引表与内容表可以合成一张表,优点是能减少拉取历史消息时的数据库IO,缺点是消息内容冗余存储,浪费了空间。
2、能从索引表里获取到最近联系人所需要的信息,为什么还要单独设置联系人表呢?
如果从索引表中获取一个用户的所有联系人的最后一条消息记录(消息内容和时间),SQL语句中会有分组后取top 1的操作,性能不理想;另外当前用户与单个联系人之间的未读数需要维护,用联系人表的一个字段来存储,比用索引表方便许多。