Redis 支撑即时通讯源码在线状态与路由转发的实现思路

📅 2026/7/2 10:56:50
Redis 支撑即时通讯源码在线状态与路由转发的实现思路
IM 即时通讯的系统技术复杂度并不来自“有多少聊天功能”而来自实时通信链路本身。用户看到的是一条消息从输入框发出服务端真正处理的是连接鉴权、协议解析、消息编号、幂等判断、消息落库、在线路由、跨节点转发、ACK 确认、离线同步、多端状态刷新等一整条链路。如果系统同时覆盖 Android、iOS、H5、PC并且后端基于SpringBoot WebSocket Socket HTTP Redis MySQL 5.7设计那么架构重点就不能只停留在接口层而要围绕“实时消息如何可靠流转”来建模。本文从工程实现角度分析一个 IM 即时通讯系统在消息链路、连接状态、存储索引、Redis 路由、多端一致性和集群部署中的技术设计思路。从一次连接开始而不是从一个接口开始普通业务系统通常是“请求来了接口处理返回结果”。IM 系统不同客户端和服务端之间需要长期保持连接。一个客户端进入聊天系统后第一步不是发送消息而是建立实时通道。这个通道可能来自 App 的 Socket 自定义协议也可能来自 H5 或 PC 的 WebSocket。连接建立后服务端需要完成几件事验证 token 是否有效。识别当前用户 ID。识别当前设备类型。绑定当前连接所在节点。记录连接最后活跃时间。启动心跳检测。这一步完成后服务端才知道“某个用户的某个端当前连接在哪台 IM 节点上”。可以把这个过程理解成一张动态路由表userId10001 android - im-node-01 / channel-a81 h5 - im-node-02 / channel-h19 pc - im-node-03 / channel-p77这个结构比简单的userId - channelId更适合多端在线。否则用户在 PC 登录后可能覆盖 App 的连接导致移动端收不到消息或状态不同步。Redis 在 IM 系统里不是普通缓存在很多业务系统里Redis 只是缓存热点数据。但在 IM 系统中Redis 更像实时状态中心。它保存的不是“查库结果副本”而是系统当前运行状态例如谁在线、在哪个节点、哪个会话有多少未读、哪个临时 token 还有效、哪个红包领取流程正在并发处理。在线路由可以这样保存Service public class ImConnectionRegistry { private final StringRedisTemplate redisTemplate; public ImConnectionRegistry(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public void online(Long userId, String device, String nodeId, String channelId) { String key im:conn: userId; String value nodeId | channelId; redisTemplate.opsForHash().put(key, device, value); redisTemplate.opsForHash().put(key, device :activeAt, String.valueOf(System.currentTimeMillis())); redisTemplate.expire(key, Duration.ofMinutes(10)); } public MapObject, Object routes(Long userId) { return redisTemplate.opsForHash().entries(im:conn: userId); } public void heartbeat(Long userId) { redisTemplate.expire(im:conn: userId, Duration.ofMinutes(10)); } }这个结构的关键点不是代码本身而是数据维度。它不是只记录用户在线而是记录用户在哪些设备在线、每个设备在哪个节点、对应哪个连接。后续消息投递、多端同步、跨节点转发都依赖这张路由表。一条消息真正经历了哪些阶段一条消息从客户端发出后不应该直接写成“保存并推送”。更稳妥的方式是把它拆成状态流转。客户端本地生成 clientMsgId ↓ 进入本地发送队列 ↓ 通过 WebSocket / Socket 发送 ↓ 服务端解析协议 ↓ 校验用户与会话状态 ↓ 判断 clientMsgId 是否重复 ↓ 生成 serverMsgId 和 msgSeq ↓ 写入消息存储 ↓ 返回 ACK 给发送端 ↓ 根据在线路由投递到接收端 ↓ 接收端确认或等待后续同步这里面有三个 ID 很重要。clientMsgId用于解决客户端弱网重发。客户端网络不稳定时同一条消息可能被发送多次服务端需要根据它做幂等判断。serverMsgId是服务端消息唯一 ID。消息撤回、引用回复、收藏、合并转发、历史查询都会用到它。msgSeq是会话内递增序号。它用于解决消息排序、分页查询、多端同步和断点续拉问题。如果只依赖时间戳排序在并发发送、跨节点投递或客户端本地临时消息存在时容易出现顺序不稳定。消息协议不要围绕页面设计聊天页面上看到的是文本、图片、文件、表情、红包、名片、语音、视频等不同形态但服务端不应该为每种形态设计一套协议。更合理的协议是命令 消息体 扩展字段。{ cmd: MSG_SEND, clientMsgId: c_10001_202607010001, serverMsgId: null, conversationId: c2c_10001_20001, fromUserId: 10001, targetId: 20001, scene: single, bodyType: text, body: { text: 这是一条即时消息 }, extra: { quoteMsgId: null, atUserIds: [], forwardId: null }, sendAt: 1782864000000 }这个结构里cmd描述操作bodyType描述内容类型extra描述消息之间的关系。例如消息撤回不是一种正文内容而是一种命令。已读回执不是聊天气泡而是状态事件。音视频邀请也不是普通文本而是信令消息。这样设计后消息类型增加时协议主结构不需要频繁变化。服务端只需要根据cmd和bodyType分发到不同处理器。写入消息表前先确定查询方式IM 消息表的设计不能只看“要存哪些字段”还要先看“未来怎么查”。聊天窗口向上翻页需要按会话查询历史消息。客户端断线重连需要按上次同步位置补消息。引用回复需要按消息 ID 查原消息摘要。消息撤回需要按消息 ID 更新状态。合并转发需要按一组消息 ID 查询上下文。因此消息存储至少要支持三种查询路径conversationId msgSeq 用于会话分页 serverMsgId 用于单条消息定位 senderId clientMsgId 用于发送幂等表结构可以围绕这些路径设计CREATE TABLE im_message_store ( id BIGINT PRIMARY KEY AUTO_INCREMENT, server_msg_id VARCHAR(64) NOT NULL, client_msg_id VARCHAR(64) DEFAULT NULL, conversation_id VARCHAR(64) NOT NULL, sender_id BIGINT NOT NULL, msg_seq BIGINT NOT NULL, scene VARCHAR(20) NOT NULL, body_type VARCHAR(30) NOT NULL, body JSON NOT NULL, msg_status TINYINT DEFAULT 0, send_at BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_server_msg_id (server_msg_id), UNIQUE KEY uk_conversation_seq (conversation_id, msg_seq), KEY idx_sender_client (sender_id, client_msg_id), KEY idx_conversation_time (conversation_id, send_at) );uk_conversation_seq可以保证同一个会话内消息序号不重复。idx_sender_client可以防止客户端重发造成重复消息。msg_status用于表达正常、已撤回、已删除、异常等状态。撤回消息时通常不物理删除而是更新状态并推送撤回事件。当消息量变大后还需要考虑冷热数据拆分。近期消息用于高频查询历史消息可以进入归档表或独立存储避免单表持续膨胀影响分页性能。ACK 不是提示而是可靠性边界很多聊天系统的问题都出在 ACK 设计不清楚。客户端点击发送后可以先把消息展示在本地但此时只能算“发送中”。只有服务端完成协议解析、幂等判断、消息编号和落库后返回 ACK客户端才能把状态改成“已发送”。如果服务端没有返回 ACK客户端需要决定是重试、标记失败还是等待网络恢复。这也是clientMsgId必须存在的原因。重试时服务端看到相同clientMsgId应该返回同一条消息的处理结果而不是重新生成一条新消息。ACK 的意义不是“告诉用户发出去了”而是划定客户端状态和服务端状态的分界线。群聊高并发的核心不是消息内容而是扩散策略群聊最容易出性能问题的地方不是消息表存一条内容而是这条消息要影响多少用户。一个群有 500 个成员时一条消息可能带来500 个成员的未读状态变化。若干在线成员的实时推送。若干离线成员的后续同步。被 成员的特殊提醒。多端在线用户的状态刷新。如果每发一条群消息都给每个成员复制一份完整消息写入量会快速放大。更常见的方式是消息正文只存一份。会话内生成递增msgSeq。每个成员维护自己的lastReadSeq。未读数可以由读取位置计算也可以在 Redis 中做高频计数。在线成员实时推送离线成员通过同步机制补齐。这种设计可以把“消息内容存储”和“成员读取状态”拆开避免群成员数量直接放大消息表写入压力。已读未读本质是读取位置不是布尔值在 IM 系统中已读未读不能简单设计成read true / false。对于单聊用户只需要知道对方读到哪里。对于群聊每个成员都可能读到不同位置。因此已读状态更适合用读取位置表达conversationId group_90001 userId 10001 lastReadSeq 3688 lastReadAt 1782867600000当会话最大消息序号是 3700而用户最后已读序号是 3688就可以计算出未读区间。这种方式也更适合多端同步。用户在 PC 端打开会话后服务端更新lastReadSeq然后向 Android、iOS、H5 推送已读事件各端再更新本地未读数。如果未读数只在客户端本地维护换设备、断线重连、清理缓存后很容易出现不一致。多端同步不要伪装成普通消息Android、iOS、H5、PC 同时在线时长连接里传输的不只有聊天消息还有大量状态事件。例如PC 端已读会影响移动端未读数。App 端撤回消息会影响 H5 和 PC 的消息展示。用户修改资料会影响会话列表展示。群信息变更会影响所有在线成员的群资料缓存。这些数据不应该都伪装成普通聊天气泡而应该以事件方式处理。可以把长连接下发数据分成两类聊天消息需要进入消息列表 状态事件只更新本地状态或页面缓存例如MSG_REVOKE是状态事件客户端收到后查找本地对应消息并更新为已撤回。READ_RECEIPT也是状态事件客户端收到后更新未读数或消息已读状态。GROUP_PROFILE_UPDATE不进入聊天记录只刷新群资料缓存。这样客户端处理逻辑会更清晰也能减少大量无意义的系统气泡。文件和音视频都不应该压在消息通道里实时消息通道适合传输轻量数据不适合传输大文件和音视频媒体流。文件发送更合理的方式是先走 HTTP 上传拿到文件元数据后再发送一条文件类型消息。消息里保存文件 ID、文件名、大小、类型、下载地址等信息。语音通话、视频通话、多人音视频会议也类似。IM 负责发送信令例如邀请、接听、拒绝、挂断、加入会议、离开会议。真正的媒体流应该交给 RTC 通道处理。这种拆分有两个好处。第一长连接不会被大文件或媒体流阻塞。第二文件存储、下载鉴权、音视频房间、媒体传输都可以独立扩展不会和聊天消息强耦合。跨节点投递要依赖路由而不是广播单机部署时用户连接都在当前进程内服务端可以直接找到连接并推送。集群部署后情况会变成用户 A 连接在 im-node-01 用户 B 连接在 im-node-03 用户 C 连接在 im-node-02用户 A 给用户 B 发消息时im-node-01不能盲目广播给所有节点而应该先查 Redis 路由确认用户 B 在im-node-03然后把消息转发到目标节点。跨节点转发可以通过内部 RPC、消息队列或 Redis Pub/Sub 实现。无论采用哪种方式都要保证两点同一条消息不会重复投递。目标节点异常时消息仍然可以通过历史同步兜底。这里的关键仍然是serverMsgId和幂等处理。只要每条消息有稳定的全局 ID接收节点就可以判断自己是否已经处理过。朋友圈、红包、钱包这类数据不要挤进消息主表IM 系统经常会扩展社交和交易相关能力但技术上不应该把所有业务都放进消息表。朋友圈更接近内容流核心是动态、评论、点赞、可见范围。红包和钱包更接近资金状态核心是余额、冻结、领取记录、流水。聊天消息可以展示一个入口或状态摘要但数据主状态应该由独立模型维护。例如红包消息里可以保存红包 ID 和展示摘要但领取状态、金额变化、退款状态、钱包流水不能依赖聊天消息表。这样做的原因很简单消息表主要服务会话查询高频读写已经足够复杂。如果把内容流、资金流水、评论点赞都塞进去索引会变得混乱后续归档和排查也会变困难。i18n 多语言也要走统一事件和 key四端系统很容易出现文案不一致的问题。Android、iOS、H5、PC 如果各自维护一套语言 key后期同一个功能可能出现不同翻译。更稳定的方式是统一 key 命名。例如chat.send chat.revoke message.unread group.notice wallet.balance file.upload call.invite meeting.join前端根据语言环境加载不同语言包。后端发送系统通知时也可以根据用户语言偏好选择对应文案。i18n 看起来是前端问题但在 IM 系统中系统通知、群事件、钱包提醒、会议邀请都可能由后端生成所以语言 key 最好从全局统一设计。排查 IM 问题要顺着链路走IM 系统出问题时不能只看某一个接口是否返回成功。如果用户反馈“消息发出去了对方没收到”可以按链路排查客户端是否生成了clientMsgId。服务端是否返回 ACK。消息是否写入消息表。接收方 Redis 路由是否存在。目标连接是否还有效。跨节点转发是否成功。接收端是否做了消息去重。如果用户反馈“未读数不准”可以检查会话最大msgSeq是否正确。用户lastReadSeq是否更新。多端已读事件是否下发。Redis 未读计数是否重复累加。客户端本地状态是否覆盖服务端状态。如果用户反馈“群消息顺序错乱”可以检查同一会话内msgSeq是否连续。客户端是否按服务端序号排序。本地临时消息是否正确替换为服务端消息。跨节点投递是否导致重复推送。如果用户反馈“PC 有消息手机没有消息”可以检查Redis 是否按设备维度保存路由。移动端连接是否被后登录设备覆盖。服务端是否只推送到单端。移动端断线重连后是否主动补拉消息。这类排查方式比单纯查看 Controller 日志更有效。IM 系统的稳定性来自连接、协议、消息编号、存储索引、Redis 路由、状态事件和客户端本地队列之间的协作。任何一个环节状态不一致最终都会表现为延迟、重复、丢失、顺序异常或多端不同步。技术实现思路简要归纳宠友(IM即时通讯)app,支持语音、文件、图片、视频等多种类型消息的发送,安全可靠,交流轻松,私有化部署,快速开发极简部署支持群聊管理、语音视频通话...功能https://chongyou.info/1/product/im.html