消息序号如何保证即时通讯源码聊天记录稳定加载

复盘编号:IM-HISTORY-07

涉及范围:单聊、群聊、文件消息、红包记录、引用回复、消息撤回

核心问题:历史消息重复、分页跳过、撤回状态不同步

技术环境:Spring Boot、MySQL 5.7、Redis、JSON

即时通讯系统中的历史消息查询,最初通常只是一个普通分页接口。

客户端提交会话编号和页码,服务端从 MySQL 中查询消息,再按时间倒序返回。文本、图片和表情数量不多时,这种方式基本能够满足使用。

随着群聊记录逐渐增加,文件、红包、个人名片、自定义表情、引用回复和系统通知同时进入消息表,传统分页开始出现一些不易察觉的异常。

本次复盘只讨论一件事:即时通讯源码中的历史消息应该如何稳定地向前加载,并保证撤回、引用和消息漫游结果一致。

09:20 聊天记录出现重复内容

测试过程中发现,用户连续向上加载群聊历史记录时,偶尔会看到同一条消息出现两次。

接口使用的是常见分页语句:

javascript 复制代码
SELECT *
FROM im_message
WHERE conversation_id = ?
ORDER BY create_time DESC
LIMIT 20 OFFSET 40;

第一次读取第 1 页后,群聊中又产生了几条新消息。

此时数据库中的消息位置发生变化。客户端继续请求第 2 页,原本排在第 20 条附近的数据被新消息向后挤动,部分记录便会再次进入查询范围。

问题不在于 SQL 没有排序,而在于页码依赖的是动态位置。

消息表持续插入数据时,"第 2 页"并不是一个稳定的数据区间。

09:45 时间字段不能作为唯一游标

将页码分页改成时间分页后,重复数量有所减少,但没有完全消失。

接口开始携带上一页最早消息的创建时间:

javascript 复制代码
beforeTime=2026-07-18 09:42:16

对应查询条件是:

javascript 复制代码
create_time < beforeTime

这种写法仍然存在边界问题。

同一秒内可能写入多条群消息。如果数据库时间精度不足,或者多条消息拥有相同的 create_time,使用严格小于条件会跳过部分记录,使用小于等于又会重复返回上一页最后一条。

因此,历史消息不能只依赖时间排序,还需要一个在会话内稳定递增的消息序号。

10:10 为每个会话增加消息序号

改造后的消息表保留全局消息编号,同时增加 message_seq

javascript 复制代码
CREATE TABLE im_message (
    id BIGINT NOT NULL PRIMARY KEY,
    conversation_id VARCHAR(64) NOT NULL,
    message_seq BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    message_type VARCHAR(32) NOT NULL,
    message_state VARCHAR(16) NOT NULL,
    content JSON NOT NULL,
    quote_message_id BIGINT DEFAULT NULL,
    create_time DATETIME NOT NULL,

    UNIQUE KEY uk_conversation_seq (
        conversation_id,
        message_seq
    ),

    KEY idx_history_query (
        conversation_id,
        message_seq
    )
);

id 用于全局定位一条消息。

message_seq 只负责当前会话内部的顺序。

例如,一个群聊中的记录可以是:

javascript 复制代码
群聊90018
消息序号 1021
消息序号 1022
消息序号 1023

另一个单聊也可以拥有自己的序号:

javascript 复制代码
单聊10018与10036
消息序号 81
消息序号 82
消息序号 83

客户端不需要理解全局消息编号的生成方式,只需记录当前页面中最小的 message_seq

10:40 历史消息接口改为游标分页

首次进入聊天页面时,客户端不传游标,服务端从当前会话最新消息开始读取。

继续向前加载时,客户端把上一批数据中最小的消息序号传回来:

复制代码
beforeSeq=1021

Spring Boot 查询逻辑可以写成:

javascript 复制代码
@Service
@RequiredArgsConstructor
public class HistoryMessageService {

    private final ImMessageRepository messageRepository;

    public HistoryPage query(
            String conversationId,
            Long beforeSeq,
            int pageSize
    ) {
        int limit = Math.min(Math.max(pageSize, 1), 50);

        List<ImMessage> messages;

        if (beforeSeq == null) {
            messages = messageRepository
                    .findLatest(conversationId, limit);
        } else {
            messages = messageRepository
                    .findBeforeSeq(conversationId, beforeSeq, limit);
        }

        Long nextCursor = messages.isEmpty()
                ? null
                : messages.get(messages.size() - 1).getMessageSeq();

        return new HistoryPage(
                messages,
                nextCursor,
                messages.size() == limit
        );
    }
}

对应 SQL 的核心条件是:

javascript 复制代码
conversation_id = 当前会话
message_seq < beforeSeq

排序固定为:

javascript 复制代码
ORDER BY message_seq DESC

无论查询过程中是否有新消息进入,会话中序号小于 beforeSeq 的范围都不会改变。

11:15 撤回消息不能从历史记录中直接消失

游标分页稳定后,又出现了另一个现象。

用户引用了一条文件消息,随后发送者撤回原消息。部分设备重新加载历史记录时,引用区域仍然正常,但原消息已经完全查不到,聊天时间线中出现了断层。

最初的撤回实现执行了物理删除:

javascript 复制代码
DELETE FROM im_message WHERE id = ?

这种处理会带来三个问题:

  1. 消息序号中间出现空缺;
  2. 引用关系失去目标;
  3. 已经加载原消息的页面与重新查询的页面显示不同。

撤回操作应修改消息状态,而不是删除记录。

javascript 复制代码
message_state = REVOKED

历史消息接口仍然返回这条数据,但不再返回原始正文,而是转换为撤回占位消息:

javascript 复制代码
{
  "messageId": 78263196,
  "messageSeq": 1023,
  "messageType": "SYSTEM",
  "messageState": "REVOKED",
  "content": {
    "event": "MESSAGE_REVOKED",
    "operatorId": 10018
  }
}

这样,消息序号保持连续,聊天记录位置也不会突然消失。

引用回复可以根据业务规则显示原摘要,或者显示"引用内容已撤回",但引用消息自身无需被删除。

11:50 文件和红包历史记录只保存业务引用

历史消息接口并不适合返回文件二进制内容,也不应直接计算红包余额变化。

文件消息写入消息表时,只保存文件资源引用:

javascript 复制代码
{
  "fileId": "file_981726",
  "fileName": "项目排期.xlsx",
  "fileSize": 152617,
  "fileType": "xlsx"
}

客户端读取历史消息后,再根据文件编号获取当前资源状态。

文件可能处于:

javascript 复制代码
AVAILABLE
EXPIRED
DELETED

即使文件已经过期,历史记录仍然可以保留文件名称和大小,只是不再提供下载能力。

红包消息也采用类似方式:

javascript 复制代码
{
  "redPacketId": "rp_202607180016",
  "title": "恭喜发财"
}

红包是否已领取、是否领完以及是否退款,由红包业务表维护。历史消息只负责保留当时发送过红包这一事实。

这种分离方式可以避免每次查询聊天记录时,都对钱包表执行复杂关联。

12:20 收藏记录不能依赖原消息长期存在

用户收藏一条文本、文件或合并转发消息时,如果收藏表只保存 message_id,原消息撤回或群聊解散后,收藏页面可能无法继续展示内容。

收藏记录更适合保存一份有限快照:

复制代码
收藏用户
原消息编号
消息类型
展示摘要
文件名称
资源编号
收藏时间

这里的快照不是复制完整聊天记录,而是保留收藏列表需要的必要内容。

例如,文件消息被撤回后,聊天窗口显示撤回提示,但用户之前收藏的文件记录仍可按照既定规则保留文件名称。

历史消息、收藏记录和消息撤回因此形成三套不同的数据关系:

复制代码
历史消息保存会话事实
收藏记录保存用户快照
撤回状态控制会话展示

三者不能简单共用一个删除标记。

13:00 群成员可见起点需要进入查询条件

新成员加入群聊后,是否能够读取入群前的消息,需要由群聊规则决定。

如果群聊不允许查看历史内容,可以在成员关系表中记录加入时的消息序号:

复制代码
join_message_seq = 1056

查询历史消息时,服务端增加可见范围限制:

复制代码
message_seq >= join_message_seq

最终条件变为:

javascript 复制代码
conversation_id = 当前群聊
message_seq < beforeSeq
message_seq >= joinMessageSeq

即使客户端手动修改 beforeSeq,也不能读取加入群聊之前的数据。

成员退出群聊后是否还能查看旧记录,也应根据成员状态和退出序号判断,而不是只看客户端是否保存了会话编号。

13:35 Redis缓存必须携带游标边界

历史消息可以使用 Redis 缓存最近一段记录,但缓存键不能只包含会话编号。

错误的缓存方式是:

javascript 复制代码
im:history:group:90018

因为不同用户请求的游标和可见起点可能不同。

可以缓存会话最近的固定消息区间,例如最新 100 条,再由服务端根据 message_seq 和成员可见范围筛选。

也可以把游标放入缓存键:

javascript 复制代码
im:history:group:90018:before:1056:size:20

不过这种方式容易产生大量离散缓存,一般只适合访问集中的短期数据。

更常见的处理是:

复制代码
最近消息使用Redis
较早消息查询MySQL
成员权限始终重新校验
撤回状态变更后删除相关缓存

缓存不能作为历史消息的最终数据来源。

14:10 消息类型变化不应修改分页协议

历史记录中可能同时出现:

javascript 复制代码
文本
图片
语音
视频
文件
红包
个人名片
群名片
自定义表情
位置
通话记录
会议邀请
系统通知

分页接口不需要为每种消息类型增加一套参数。

所有消息都使用统一外层结构:

javascript 复制代码
messageId
messageSeq
messageType
messageState
senderId
content
createTime

客户端根据 messageType 决定展示方式。

新增一种消息类型时,只需要扩展消息内容解析和展示组件,历史查询的游标规则不发生变化。

这也是消息外层结构保持稳定的重要原因。

15:00 上线前验证记录

本次改造没有继续使用页码,也没有将创建时间作为单一分页条件。

验证过程覆盖以下情况:

验证场景 预期结果
加载历史期间收到新消息 已加载记录不重复
多条消息创建时间相同 不跳过任何记录
最早消息被撤回 保留原序号位置
引用消息被撤回 引用消息仍可展示
文件资源已经过期 保留文件历史摘要
红包已经领完 聊天记录仍然存在
新成员禁止查看旧消息 无法越过入群序号
连续请求同一游标 返回相同消息区间
历史记录不足一页 返回结束标记
Redis缓存失效 可以从MySQL重新查询

接口返回结果中增加 nextCursorhasMore,不再返回总页数。

即时通讯消息会持续增加,计算总页数本身没有稳定意义。客户端只需知道下一次从哪个消息序号继续读取,以及前面是否还有记录。

页面交互逻辑拆解记录宠友(IM即时通讯)app,支持语音、文件、图片、视频等多种类型消息的发送,安全可靠,交流轻松,私有化部署,快速开发,极简部署,支持群聊管理、语音视频通话...功能https://chongyou.info/1/product/im.html

历史消息链路最终保持为:

javascript 复制代码
验证会话访问权限
→ 确定成员可见起点
→ 读取beforeSeq游标
→ 按messageSeq倒序查询
→ 转换撤回与失效状态
→ 返回nextCursor
相关推荐
圣光SG14 小时前
Java Web入门基础知识笔记
java·前端·笔记
小柯南敲键盘14 小时前
批量图片翻译与视频字幕一站式解决高效跨境电商沟通难题
大数据·人工智能·python·音视频
行思理15 小时前
微信支付“商家转账用户确认模式”,新手教程
java·开发语言·微信
空中湖15 小时前
Spring AI RAG 完整实战:从零搭建企业知识库问答系统
java·人工智能·spring
Ai拆代码的曹操16 小时前
K8s Pod Pending 逐层排查:从 FailedScheduling 到 PVC StorageClass 不存在的实战记录
java·linux·kubernetes
hold?fish:palm16 小时前
RDB全量快照备份
c++·redis·后端
什巳16 小时前
JAVA练习306- 翻转二叉树
java·数据结构·算法·leetcode
smj2302_7968265216 小时前
解决leetcode第3989题网格中保持一致的最大列数
python·算法·leetcode
笨蛋不要掉眼泪16 小时前
MySQL架构揭秘:慢查询日志详解
数据库·mysql·架构