多端即时通讯源码技术实践,Redis路由、MySQL持久化与Socket接入
即时通讯系统看起来只是“发送一条消息,再推送给接收方”,但真正进入多端、集群和高并发环境后,消息链路会迅速变得复杂。
同一名用户可能同时登录安卓、iOS、H5和PC端;消息类型也不再局限于文字,还包括图片、文件、自定义表情、红包、名片、引用回复、合并转发、音视频邀请等内容。
此时,系统需要处理的不只是实时推送,还包括:
消息是否重复、顺序是否一致、离线后能否补回、多个终端状态是否同步,以及节点扩容后如何找到用户连接。
基于Java Spring Boot、Redis、MySQL和WebSocket,构建多端即时通讯系统中的可靠消息链路。
01 从一条消息开始拆解IM系统
先看一个普通的群聊场景。
用户通过安卓端向群聊发送一条消息:
@张明 请查看刚刚上传的项目文件这条消息背后可能同时包含:
- 文本内容;
- @群成员;
- 文件引用;
- 发送人信息;
- 群会话标识;
- 客户端消息编号;
- 服务端消息编号;
- 会话序列号;
- 发送时间;
- 消息状态。
如果接收方正在使用PC端,消息需要立即推送到Vue2页面;如果另一名成员处于离线状态,消息还要进入离线同步范围。
发送者本人如果同时登录H5端,H5也需要同步显示刚发送的内容。
因此,即时通讯系统中的一条消息,至少要完成四件事:
接收、存储、路由、同步。
任何一个环节处理不完整,都会出现消息丢失、重复、乱序或多端状态不一致。
02 多端统一的消息结构
安卓、苹果和H5端可以通过uni-app开发,PC端使用Vue2,后端采用Spring Boot。
不同客户端虽然界面实现不同,但必须使用统一的消息模型。
一个基础消息体可以设计为:
{ "clientMessageId": "android_10086_1720589100123", "messageId": "20260710000192384", "conversationId": "group_30001", "senderId": "user_10086", "receiverId": "group_30001", "messageType": 1, "sequence": 98231, "sendTime": 1783645200123, "content": { "text": "请查看刚刚上传的项目文件", "atUserIds": ["user_20001"], "replyMessageId": "20260710000191001" } }其中几个字段承担着不同职责。
| 字段 | 主要用途 |
|---|---|
| clientMessageId | 客户端生成,用于消息幂等 |
| messageId | 服务端生成的全局消息编号 |
| conversationId | 标识单聊或群聊会话 |
| messageType | 区分文本、文件、红包、名片等类型 |
| sequence | 维护会话内消息顺序 |
| atUserIds | 记录群聊中被@的成员 |
| replyMessageId | 关联被引用回复的消息 |
这套结构不只适用于文本消息。
文件、红包、名片分享、自定义表情、语音通话邀请和多人会议通知,都可以通过扩展messageType和content完成。
例如,文件消息可以保存:
文件名称 文件大小 文件格式 资源地址 缩略图地址 上传状态消息表中只保留文件元数据,不直接保存文件二进制内容,从而避免数据库记录过大。
03 WebSocket负责实时,但不负责全部可靠性
WebSocket能够建立长连接,适合单聊、群聊、已读回执、消息撤回等实时事件。
但WebSocket连接成功,并不意味着消息一定可靠。
一次消息发送过程中,可能出现以下异常:
情况一:客户端已发送,服务端未落库
网络请求已经到达服务端,但数据库写入失败。此时如果直接向客户端返回成功,消息会永久丢失。
情况二:服务端已经保存,确认包没有返回
客户端没有收到发送成功通知,于是重新发送,服务端可能插入两条重复消息。
情况三:接收端突然掉线
消息已经生成并持久化,但接收方连接中断,实时推送失败,需要等待重新登录后补拉。
情况四:多个终端状态不一致
手机端已经阅读消息,PC端却仍显示未读数量;或者手机端撤回消息后,H5端仍显示原内容。
所以,WebSocket只是消息传输通道,可靠性还需要持久化、幂等、序列号和状态同步共同完成。
04 一条可靠消息的完整处理链路
可以把消息发送过程拆成三个阶段。
第一阶段:客户端提交
客户端生成临时消息编号,在本地先显示“发送中”状态,再通过Socket或WebSocket发送到服务端。
第二阶段:服务端确认
服务端依次执行:
身份认证 ↓ 会话权限校验 ↓ 重复消息校验 ↓ 生成服务端消息ID ↓ 分配会话序列号 ↓ 写入持久化存储 ↓ 返回发送确认客户端只有收到服务端确认后,才能把消息状态调整为“已发送”。
第三阶段:消息分发
消息落库完成后,服务端查询接收方在线节点:
接收方在线 → 推送到对应连接节点 接收方离线 → 保留在离线同步范围 发送者多端在线 → 同步到其他登录设备这套流程能够保证:即使实时推送失败,消息仍然可以依靠历史记录和离线同步恢复。
05 Redis幂等控制,避免重复消息
移动网络不稳定时,客户端通常会设置超时重试。
比如用户发送文件消息后,五秒内没有收到确认,客户端再次提交同一请求。
如果服务端没有幂等控制,聊天窗口会出现两条完全相同的消息。
解决方式是由客户端生成clientMessageId,服务端使用发送人ID和客户端消息编号组成唯一键。
@Service public class MessageIdempotentService { private final StringRedisTemplate redisTemplate; public MessageIdempotentService( StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public boolean tryAccept( String senderId, String clientMessageId) { String key = "im:message:idempotent:" + senderId + ":" + clientMessageId; Boolean success = redisTemplate.opsForValue() .setIfAbsent( key, "1", Duration.ofMinutes(10) ); return Boolean.TRUE.equals(success); } }第一次请求进入时,Redis中不存在该键,写入成功。
客户端再次重试时,setIfAbsent返回失败,服务端便可以识别为重复消息。
这里不能使用消息正文判断重复。
因为用户连续发送两次“收到”,可能是合法操作。只有客户端消息编号才能明确区分请求是否相同。
涉及钱包和红包时,需要双重幂等
红包、钱包余额、付费礼物等业务不能只依赖Redis。
Redis缓存可能被清除,也可能在节点故障时出现异常,因此数据库层还应增加唯一约束。
ALTER TABLE im_wallet_record ADD UNIQUE KEY uk_business_no (business_no);当同一业务流水重复写入时,MySQL唯一索引能够阻止二次入账。
可以将处理逻辑分为两层:
Redis:快速拦截重复请求 MySQL:保证最终业务唯一这种方式同样适用于重复领取红包、重复提交转账和重复确认支付。
06 用会话序列号处理乱序和断线补偿
消息时间不能完全依赖客户端生成。
原因很简单:不同设备的系统时间可能不一致,网络延迟也可能导致后发送的消息先到达服务端。
因此,每个会话都应维护独立、连续递增的序列号。
例如:
group_30001 消息A sequence = 98231 消息B sequence = 98232 消息C sequence = 98233客户端按sequence排序,而不是只依赖本地时间。
Redis中的原子自增适合生成序列号:
public long nextSequence(String conversationId) { String key = "im:conversation:sequence:" + conversationId; Long sequence = redisTemplate.opsForValue() .increment(key); if (sequence == null) { throw new IllegalStateException( "会话序列号生成失败" ); } return sequence; }序列号还可以解决断线后的消息恢复。
假设客户端最后保存的序列号是98220,服务端当前序列号是98233,那么客户端缺少的消息范围就是:
98221—98233重新连接后,只需要按序列区间拉取缺失记录,不必重新同步整个会话。
序列号还能支撑哪些功能
序列号不只是排序字段,还能参与多个模块:
- 离线消息补拉;
- 历史消息漫游;
- 已读未读计算;
- 跨端同步;
- 撤回消息定位;
- 引用回复定位;
- 消息缺口检测。
当用户在PC端登录新设备时,也可以根据服务端会话游标逐步恢复聊天记录。
07 Redis如何完成集群节点路由
单机部署时,所有用户连接都存在同一个Java进程中。
服务端收到消息后,可以直接从内存连接表中找到接收方。
但进入多机部署后,情况会变成:
用户A → gateway-01 用户B → gateway-03 用户C → gateway-05用户A向用户B发送消息时,gateway-01并没有用户B的本地连接。
因此,需要在Redis中记录用户和节点之间的映射关系。
im:online:user_1001:android → gateway-01 im:online:user_1001:pc → gateway-02 im:online:user_2001:ios → gateway-03消息服务收到请求后,先查询接收方位于哪个节点,再将消息推送给对应网关。
如果用户同时登录多个终端,则需要返回多个节点和设备记录。
多端同步不能只推送给接收方
用户在安卓端发送消息时,自己的PC端和H5端也应同步显示。
所以消息路由对象通常包括两部分:
接收方所有在线终端 发送方除当前设备外的其他在线终端这样才能保证手机、网页和桌面端聊天记录一致。
类似的跨端同步事件还包括:
- 消息已读;
- 消息撤回;
- 会话置顶;
- 消息收藏;
- 好友关系变化;
- 群成员变化;
- 红包领取状态;
- 未读数更新。
08 群聊消息不能同步遍历全部成员
小群聊只有十几人时,发送消息后逐个推送似乎没有明显问题。
但当群成员数量扩大后,每发送一条消息都同步遍历成员,会产生明显压力。
问题主要集中在三个方面:
- 业务线程被长期占用;
- 成员查询次数增加;
- 在线推送与离线处理混在一起。
更合适的群聊消息流程是:
消息落库 ↓ 生成群消息事件 ↓ 拆分群成员或节点分片 ↓ 异步推送在线用户 ↓ 离线用户后续补拉群聊发送接口只负责完成核心校验和消息保存,不等待全部成员推送成功才返回。
群管理权限应优先读取缓存
群聊消息发送前,需要判断:
- 发送人是否仍在群内;
- 是否开启全员禁言;
- 当前成员是否被单独禁言;
- 是否允许普通成员发文件;
- 是否具备@全体成员权限;
- 名片分享是否符合群权限。
群管理员、禁言状态和群成员热点信息可以缓存在Redis中。
MySQL负责保存最终数据,Redis负责高频权限读取。
当管理员移除群成员时,应同时执行:
更新MySQL群成员关系 清理Redis群成员缓存 通知成员所在在线节点 广播群系统消息否则,被移除的成员可能因为旧缓存仍然继续发送消息。
09 MySQL保存事实数据,Redis保存实时状态
Redis速度快,但不适合永久承担全部消息存储。
比较清晰的职责划分是:
MySQL保存
- 消息正文;
- 会话记录;
- 好友关系;
- 群成员关系;
- 红包流水;
- 钱包流水;
- 收藏记录;
- 朋友圈内容;
- 历史文件记录。
Redis保存
- 在线状态;
- 用户所在网关;
- 会话序列号;
- 幂等键;
- 未读计数缓存;
- 群权限热点数据;
- 临时令牌;
- 限流数据。
消息表可以包含:
message_id conversation_id sequence sender_id receiver_id message_type message_content send_time message_status当消息量不断增长时,可以根据会话ID进行分表。
例如:
im_message_00 im_message_01 im_message_02 ... im_message_31通过conversationId计算哈希值,将同一会话尽量写入固定分片。
这样查询历史记录时,可以直接定位到对应消息表。
10 已读未读不要逐条更新
很多即时通讯系统初期会为每条消息保存一个“已读”字段。
单聊消息量较少时还能运行,但群聊和多端同步场景下,逐条更新会产生大量数据库操作。
更轻量的做法是记录:
用户在某个会话中的最大已读序列号。
例如,用户当前已读到6532:
{ "conversationId": "single_1001_1002", "readSequence": 6532 }那么序列号小于或等于6532的消息,都可以视为已读。
未读范围则是:
用户最大已读序列号之后 到 会话最新序列号之间这种方式不需要为每条消息单独执行更新。
群聊已读需要控制数据规模
群聊中如果为每条消息保存每个成员的已读记录,数据量会快速增加。
可以根据实际需求采用不同策略:
| 场景 | 建议方式 |
| 普通群聊 | 保存成员最大已读序列 |
| 大型群聊 | 只维护未读数量 |
| 群公告 | 保存详细已读人员 |
| @消息 | 单独记录提醒状态 |
| 重要通知 | 保存送达和已读回执 |
手机端阅读消息后,服务端还要把新的readSequence同步给PC和H5端,使其他设备及时清除未读标识。
11 消息撤回、引用回复和合并转发
这三类功能看起来都作用于历史消息,但实现方式不同。
消息撤回
撤回操作不应直接删除数据库原记录。
更适合的方式是修改状态:
NORMAL → REVOKED随后向所有在线终端广播撤回事件。
离线客户端重新拉取历史消息时,读取到撤回状态,再显示“该消息已被撤回”。
引用回复
引用回复可以在新消息中保存:
replyMessageId 被引用人 原消息类型 原内容摘要即使原消息位于很早的聊天记录中,客户端也能根据消息ID快速定位。
如果原消息已撤回,引用区域可以保留位置,但不继续展示完整内容。
合并转发
合并转发不是把多条消息直接拼成长文本。
更合适的消息体是:
转发标题 消息数量 消息摘要列表 原消息ID集合 参与人信息当内容包含文件、语音、图片或名片时,客户端根据原始消息类型组合展示。
这种设计能够避免单条消息体过大,也便于后续扩展预览页面。
12 Socket、WebSocket和HTTP如何共存
不同终端对通信协议的要求不同。
- 安卓和苹果可以使用自定义Socket协议;
- H5和PC浏览器适合WebSocket;
- 文件上传、朋友圈加载、群管理接口适合HTTP。
但协议层不应该分别实现一套业务。
可以将整体结构拆分为:
Socket接入层 ─────┐ │ WebSocket接入层 ──┼→ 统一消息命令 → 消息业务服务 │ HTTP接口层 ───────┘接入层只处理:
- 连接建立;
- 心跳检测;
- 数据解码;
- 身份认证;
- 协议适配。
完成解码后,所有请求转换为内部统一命令,再进入Spring Boot业务服务。
这样,好友、单聊、群聊、红包、撤回、收藏等逻辑不会与某一种网络协议绑定。
一个端口识别多种协议
当一个端口支持多协议接入时,可以在连接建立阶段判断数据特征。
例如:
包含HTTP Upgrade头 → WebSocket 包含自定义协议魔数 → Socket协议 普通HTTP请求 → HTTP接口如果后续需要分别扩容,也可以把三种协议拆成独立节点。
浏览器连接增加时,单独扩容WebSocket服务;文件上传增长时,单独扩容HTTP节点。
13 音视频通话不应传输在普通消息通道中
语音通话、视频通话和多人音视频会议会产生大量媒体流。
这些数据不适合直接通过普通聊天WebSocket传输,否则容易影响文本消息、红包通知和文件状态等实时事件。
IM系统在音视频场景中主要负责信令:
发起邀请 接听或拒绝 进入房间 成员加入 成员退出 开启或关闭麦克风 开启或关闭摄像头 主持人操作 会议结束真正的音频和视频数据交给专门的实时音视频服务。
会议邀请仍然可以使用统一消息模型
多人音视频会议邀请可以定义为特殊消息:
{ "messageType": 3001, "conversationId": "group_30001", "content": { "roomId": "meeting_98231", "callType": "video", "status": "inviting", "memberIds": [ "user_1002", "user_1003" ] } }成员接听后进入对应房间。
会议结束时,再发送结束事件,并记录通话时长和参与成员。
这样聊天记录中仍然能够展示会议邀请、未接听、已拒绝和已结束状态。
14 SSL/TLS加密与身份校验
聊天消息中可能包含文件、名片、红包、钱包状态和好友关系,因此传输层需要启用SSL/TLS。
不同协议对应的加密方式包括:
WebSocket → WSS HTTP → HTTPS 自定义Socket → TLS加密通道建立连接后,客户端必须携带有效身份令牌。
服务端不能因为客户端上传了userId,就直接认定连接属于该用户。
还需要校验:
- Token是否有效;
- 账号是否被禁用;
- 设备是否允许登录;
- 会话是否具备发送权限;
- 用户是否仍在群聊中;
- 当前是否处于禁言状态;
- 文件大小与格式是否合法;
- 消息撤回是否超时。
扫一扫不能直接暴露用户ID
扫一扫添加好友或加入群聊时,二维码中不建议直接写入原始用户ID或群ID。
可以生成短期有效的随机凭证:
二维码随机Token ↓ 服务端查询对应用户或群聊 ↓ 校验有效期和使用权限 ↓ 执行添加好友或加入群聊这样能够降低二维码长期传播和参数被修改带来的风险。
15 从单机部署扩展到多机集群
初期即时通讯系统可以使用单机部署:
客户端 ↓ Spring Boot服务 ↓ Redis + MySQL但随着在线人数增长,连接层、业务层和存储层需要逐步拆分。
一种常见结构是:
安卓 / iOS / H5 / PC ↓ 负载均衡 ↓ Socket与WebSocket网关 ↓ 消息业务服务 ↓ Redis集群 / MySQL数据库连接网关负责保存长连接。
消息业务服务负责:
- 消息校验;
- 消息持久化;
- 好友关系;
- 群权限;
- 红包和钱包;
- 收藏与朋友圈;
- 在线路由查询。
长连接扩容与普通HTTP不同
HTTP请求执行完成后连接可以释放,而IM长连接需要长期保留。
因此,用户连接到某个网关后,必须把节点关系写入Redis。
当网关节点发生故障时,客户端执行自动重连:
连接断开 ↓ 切换其他可用网关 ↓ 重新完成身份认证 ↓ 上报本地最大序列号 ↓ 补拉缺失消息这套流程使节点故障不会直接造成历史消息丢失。
16 高并发连接需要关注的并不只是Java代码
“单机支持多少在线连接”不能只看服务器内存。
实际连接能力还会受到以下因素影响:
- 操作系统文件句柄;
- 网络缓冲区;
- 心跳频率;
- 每条消息大小;
- 是否使用阻塞线程;
- TLS加密开销;
- 消息持久化速度;
- Redis访问频率;
- 网关节点硬件配置。
高并发连接层通常采用事件驱动模型,而不是为每个用户创建一个独立线程。
Spring Boot可以负责业务组织、接口管理和模块编排,长连接层则使用异步网络模型处理大量Socket或WebSocket连接。
当压力继续增加时,可以按模块独立扩容:
Socket网关独立扩容 WebSocket网关独立扩容 HTTP接口独立扩容 消息服务独立扩容 文件服务独立扩容所有节点通过Redis共享在线路由和实时状态,通过MySQL保存最终业务数据。
17 从底层链路看功能模块如何接入
当消息模型、序列号、在线路由和持久化链路稳定后,业务功能可以逐步接入,而不需要反复修改通信核心。
例如:
好友功能 → 建立单聊会话关系 群聊与群管理 → 复用群成员权限和群消息链路 红包与钱包 → 复用消息通知,增加资金流水幂等 表情与自定义表情 → 扩展消息类型和资源地址 文件发送 → 上传文件后发送文件消息 收藏 → 记录用户与原消息之间的关联 朋友圈 → 复用用户关系、评论通知和资源存储 语音视频通话 → 使用IM传输邀请和状态信令 多人会议 → 使用群成员关系生成会议邀请范围这些模块表面上差异很大,但最终都依赖几项基础能力:
身份认证、消息编号、会话关系、状态同步和可靠存储。
底层通信链路保持统一后,安卓、苹果、H5和PC端只需根据消息类型完成不同界面展示,服务端也能在保持轻量结构的同时继续扩展集群部署、独立部署和后续业务模块。
技术实现思路的简要归纳说明宠友(IM即时通讯)app,支持语音、文件、图片、视频等多种类型消息的发送,安全可靠,交流轻松,私有化部署,快速开发,极简部署,支持群聊管理、语音视频通话...功能https://chongyou.info/1/product/im.html
