导读
- 阅读本文档需要有一定的后端基础和客户端基础,才能更好地理解本文档的核心内容。
- 本文档的设计是基于后端的视角来撰写可选方案,让客户端更加清楚明白要如何配合后端实现。
- 方案在移动端(Ios、Android、HarmonyOS)和桌面端(Electron、Flutter)都是通用的!!
- 本文档涉及到的相关技术栈有:electron、mysql、sqlite、ts、nestjs、typeorm
- 适用人群:leader、架构师、超级个体
备注:本文着重是方案选型,并非高并发、分布式方案!
一、简介
1.1 背景概述
在即时通讯(IM)应用中,消息管理是核心功能之一,它负责处理消息的发送、接收、存储、同步、撤回等全生命周期管理。消息管理方案的设计直接影响应用的实时性、可靠性和用户体验。良好的消息管理方案能够保证消息的准确送达、快速同步和可靠存储。
1.2 消息管理核心功能
消息管理的核心功能包括:
- 消息发送: 用户发送消息到服务端,包括文本、图片、语音、视频等多种消息类型
- 消息接收: 服务端将消息推送给接收方,支持离线消息缓存
- 消息存储: 消息持久化存储,支持历史消息查询和分页加载
- 消息同步: 多设备间消息状态同步,包括已读状态、撤回状态等
- 消息撤回: 用户撤回已发送的消息,支持时间限制
- 消息重发: 网络失败时的消息重发机制
- 已读回执: 消息已读状态的确认和同步
- 消息搜索: 根据关键词、时间等维度搜索历史消息
- 消息删除: 用户删除本地或服务端消息
- 离线消息: 用户离线期间的消息缓存和推送
二、消息设计基础
2.1 核心概念
消息(Message):
- 指用户在会话中发送和接收的信息单元
- 每条消息都有唯一的标识符(messageId/msgId),由服务端生成
- 消息包含: 发送方、接收方、内容、类型、时间戳、状态等核心信息
消息类型:
- 文本消息: 纯文本信息
- 图片消息: 图片文件及缩略图
- 语音消息: 音频文件及时长
- 视频消息: 视频文件及时长、封面
- 文件消息: 文件及文件元数据
- 位置消息: 地理坐标信息
- 系统消息: 系统通知、提示信息
- 自定义消息: 业务扩展的自定义格式
消息状态:
- 发送中: 消息正在发送,等待服务端确认
- 发送成功: 消息已成功发送到服务端
- 发送失败: 消息发送失败,可重发
- 送达: 消息已送达接收方设备
- 已读: 接收方已阅读消息
- 撤回: 消息已被发送方撤回
消息生命周期:
- 创建: 用户在客户端创建消息
- 发送: 客户端将消息发送到服务端
- 存储: 服务端保存消息到数据库
- 推送: 服务端将消息推送给接收方
- 接收: 接收方客户端接收消息
- 展示: 接收方查看消息
- 撤回: 发送方撤回消息(可选)
- 删除: 用户删除消息(可选)
消息数据流:
- 发送流程: 客户端 → WebSocket服务端 → 数据库存储 → 推送给接收方
- 接收流程: 服务端 → WebSocket推送 → 客户端接收 → 本地存储
- 离线流程: 服务端缓存离线消息 → 用户上线 → 批量推送
- 撤回流程: 客户端请求 → 服务端处理 → 推送撤回通知 → 所有客户端更新
消息管理核心设计原则:
- 唯一性: 每条消息必须有唯一标识,确保消息不重复
- 实时性: 消息推送需要快速及时,保证用户体验
- 可靠性: 消息不能丢失,需要持久化存储和重试机制
- 顺序性: 同一会话中的消息需要保持时间顺序
- 一致性: 多设备间消息状态需要保持一致
- 可追溯: 支持消息的查询和审计
2.2 消息数据模型设计
2.2.1 核心数据字段
消息数据模型包含以下核心字段:
标识字段(后端生成和存储):
- 消息ID: 唯一标识符,服务端生成,后端存储
- 会话ID: 所属会话的标识
- 发送方ID: 发送用户ID
- 接收方ID: 接收用户ID(单聊)或群组ID(群聊)
内容字段(后端存储):
- 消息内容: 文本内容或媒体文件信息
- 消息类型: 文本、图片、语音、视频等
- 扩展字段: 自定义扩展信息
状态字段(后端存储和计算,客户端缓存):
- 消息状态: 发送中、发送成功、发送失败等
- 已读状态: 是否已读
- 撤回状态: 是否被撤回
- 删除状态: 是否被删除
时间字段(后端维护):
- 发送时间: 消息发送时间戳
- 送达时间: 消息送达接收方时间戳
- 已读时间: 消息被阅读时间戳
- 创建时间: 消息创建时间戳
- 更新时间: 消息更新时间戳
元数据(后端维护):
- 数据版本: 用于同步和冲突解决的版本号
- 设备信息: 发送设备标识
- 网络信息: 发送网络标识
2.2.2 后端表设计
2.2.2.1 消息表设计(Mysql)
消息管理表设计方案:为更好地维护消息管理功能,采用多表关联设计方案,各司其职,共同维护消息状态。
消息基本信息表(message_base_info):
CREATE TABLE message_base_info (
message_id VARCHAR(64) PRIMARY KEY COMMENT '消息ID,服务端生成,唯一标识符',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
from_user_id BIGINT NOT NULL COMMENT '发送方用户ID',
to_user_id BIGINT NOT NULL COMMENT '接收方用户ID(单聊时为目标用户,群聊时为群组ID)',
to_type TINYINT NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
message_type TINYINT NOT NULL COMMENT '消息类型:1-文本,2-图片附件,3-语音附件,4-视频附件,5-文件附件,6-位置,99-系统',
content TEXT COMMENT '消息内容(文本消息的文本内容,非文本消息的描述信息)',
extra_data TEXT COMMENT '扩展数据(JSON格式,自定义消息使用)',
status TINYINT DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除(软删除)',
send_time BIGINT NOT NULL COMMENT '发送时间戳',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
version INT DEFAULT 0 COMMENT '数据版本号(用于乐观锁)',
INDEX idx_session_id (session_id),
INDEX idx_from_user_id (from_user_id),
INDEX idx_to_user_id (to_user_id),
INDEX idx_send_time (send_time DESC),
INDEX idx_created_at (created_at DESC),
INDEX idx_status (status),
FOREIGN KEY (session_id) REFERENCES session_base_info(session_id) ON DELETE CASCADE
) COMMENT '消息基本信息表,存储消息核心数据';
消息索引表(message_index):
CREATE TABLE message_index (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
message_id VARCHAR(64) NOT NULL UNIQUE COMMENT '消息ID,引用message_base_info',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
from_user_id BIGINT NOT NULL COMMENT '发送方用户ID',
to_user_id BIGINT NOT NULL COMMENT '接收方用户ID',
to_type TINYINT NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
message_type TINYINT NOT NULL COMMENT '消息类型',
status TINYINT DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除',
send_time BIGINT NOT NULL COMMENT '发送时间戳',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_session_id (session_id),
INDEX idx_from_user_id (from_user_id),
INDEX idx_to_user_id (to_user_id),
INDEX idx_to_type (to_type),
INDEX idx_message_type (message_type),
INDEX idx_status (status),
INDEX idx_is_deleted (is_deleted),
INDEX idx_send_time (send_time DESC),
INDEX idx_created_at (created_at DESC),
INDEX idx_session_send_time (session_id, send_time DESC),
INDEX idx_user_status (from_user_id, status),
FOREIGN KEY (message_id) REFERENCES message_base_info(message_id) ON DELETE CASCADE
) COMMENT '消息索引表,针对message_base_info表中经常查询的字段建立索引,提升查询性能';
消息状态表(message_status):
CREATE TABLE message_status (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
message_id VARCHAR(64) NOT NULL COMMENT '消息ID,引用message_base_info',
user_id BIGINT NOT NULL COMMENT '用户ID',
is_read BOOLEAN DEFAULT FALSE COMMENT '是否已读',
is_delivered BOOLEAN DEFAULT FALSE COMMENT '是否已送达',
read_time BIGINT COMMENT '已读时间戳',
delivered_time BIGINT COMMENT '送达时间戳',
device_id VARCHAR(100) COMMENT '设备ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_message_user (message_id, user_id),
INDEX idx_user_id (user_id),
INDEX idx_is_read (is_read),
INDEX idx_is_delivered (is_delivered),
INDEX idx_read_time (read_time),
INDEX idx_delivered_time (delivered_time),
FOREIGN KEY (message_id) REFERENCES message_base_info(message_id) ON DELETE CASCADE
) COMMENT '消息状态表,记录每条消息对每个用户的状态(已读/未读/送达等)';
消息撤回记录表(message_revoke_logs):
CREATE TABLE message_revoke_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
message_id VARCHAR(64) NOT NULL UNIQUE COMMENT '消息ID,引用message_base_info,唯一索引确保一个消息只能撤回一次',
operator_id BIGINT NOT NULL COMMENT '撤回操作者用户ID',
revoke_time BIGINT NOT NULL COMMENT '撤回时间戳',
revoke_reason VARCHAR(200) COMMENT '撤回原因',
device_id VARCHAR(100) COMMENT '撤回设备ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_operator_id (operator_id),
INDEX idx_revoke_time (revoke_time DESC),
FOREIGN KEY (message_id) REFERENCES message_base_info(message_id) ON DELETE CASCADE
) COMMENT '消息撤回记录表,记录所有撤回操作';
消息附件表(message_attachment):
CREATE TABLE message_attachment (
attachment_id VARCHAR(64) PRIMARY KEY COMMENT '附件ID,服务端生成,唯一标识符',
message_id VARCHAR(64) NOT NULL COMMENT '消息ID,关联message_base_info',
attachment_type TINYINT NOT NULL COMMENT '附件类型:2-图片,3-语音,4-视频,5-文件',
file_url VARCHAR(500) NOT NULL COMMENT '文件URL',
file_name VARCHAR(255) COMMENT '文件名',
file_size BIGINT COMMENT '文件大小(字节)',
file_format VARCHAR(50) COMMENT '文件格式(如jpg、mp3、mp4、pdf等)',
width INT COMMENT '图片/视频宽度(像素)',
height INT COMMENT '图片/视频高度(像素)',
duration INT COMMENT '音视频时长(秒)',
thumb_url VARCHAR(500) COMMENT '缩略图URL(图片/视频)',
extra_data TEXT COMMENT '附件扩展数据(JSON格式)',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_message_id (message_id),
INDEX idx_attachment_type (attachment_type),
INDEX idx_created_at (created_at DESC),
FOREIGN KEY (message_id) REFERENCES message_base_info(message_id) ON DELETE CASCADE
) COMMENT '消息附件表,存储消息的媒体文件信息';
消息扩展信息表(message_ext_info):
CREATE TABLE message_ext_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
message_id VARCHAR(64) NOT NULL COMMENT '消息ID,引用message_base_info',
key VARCHAR(100) NOT NULL COMMENT '扩展键名',
value TEXT COMMENT '扩展值',
value_type TINYINT DEFAULT 1 COMMENT '值类型:1-字符串,2-数字,3-布尔值,4-JSON',
description VARCHAR(200) COMMENT '描述说明',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_message_key (message_id, key),
INDEX idx_message_id (message_id),
INDEX idx_key (key),
FOREIGN KEY (message_id) REFERENCES message_base_info(message_id) ON DELETE CASCADE
) COMMENT '消息扩展信息表,Key-Value形式存储灵活扩展信息';
表关系说明:
message_base_info是核心表,存储消息基本数据message_attachment存储消息的媒体文件信息(图片、语音、视频、文件)message_status管理消息的状态(已读/未读/送达等),支持多用户独立状态管理message_index是消息索引表,针对message_base_info表中经常查询的字段建立索引,提升查询性能message_revoke_logs记录所有撤回操作,便于审计和追溯message_ext_info提供灵活的扩展能力,支持未来业务需求
2.2.2.1.1 消息状态的两种维度
本设计中存在两个容易混淆但含义完全不同的"状态"字段,需要明确区分:
1. 消息发送状态(message_base_info.status) - 发送方视角
status TINYINT DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败'
- 含义:消息从发送方到服务端的传输状态
- 取值:
0-发送中:消息正在上传或处理中1-发送成功:消息已成功存储到服务端2-发送失败:消息发送失败,需要重发
- 使用场景:
- 发送失败的消息自动重发
- 查询发送中的消息(显示发送中状态)
- 统计消息发送成功率
2. 消息接收状态(message_status) - 接收方视角
user_id BIGINT NOT NULL COMMENT '用户ID',
is_read BOOLEAN DEFAULT FALSE COMMENT '是否已读',
is_delivered BOOLEAN DEFAULT FALSE COMMENT '是否已送达',
read_time BIGINT COMMENT '已读时间戳',
delivered_time BIGINT COMMENT '送达时间戳'
- 含义:消息从服务端到接收方的传输和阅读状态,每个用户独立管理
- 取值:
is_read:消息是否已读is_delivered:消息是否已送达read_time:已读时间戳delivered_time:送达时间戳
- 使用场景:
- 单聊:1条消息对应1个接收用户的状态记录
- 群聊:1条消息对应N个接收用户的状态记录
- 已读回执功能
- 显示"已送达"、"已读"等状态
- 统计群聊消息的已读人数
典型场景对比:
| 场景 | message_base_info.status | message_status |
|---|---|---|
| 用户发送消息后网络断开 | 2-发送失败 | - |
| 消息成功到达服务端 | 1-发送成功 | - |
| 服务端推送消息给接收方 | 1-发送成功 | is_delivered=true, delivered_time=xxx |
| 接收方读取消息 | 1-发送成功 | is_read=true, read_time=xxx |
| 群聊消息,A读了B没读 | 1-发送成功 | A:is_read=true, B:is_read=false |
为什么需要分开设计:
- 职责分离:发送状态是消息本身的属性,接收状态是用户-消息关系的属性
- 群聊场景:一条群聊消息需要记录多个用户的已读状态
- 数据一致性:发送方只需要关心消息是否成功发送到服务端,不需要关心每个接收方的阅读状态
- 查询性能:发送失败的消息只需查询主表,已读统计需要关联 message_status 表
注:以上表结构为参考实现,实际应用中可根据业务需求调整字段名称和类型。
2.2.2.2 表设计对消息管理功能的支持评估
本表设计方案能够很好地管理消息管理的十大核心功能,具体支持情况如下:
1. 消息发送 - ✅ 完全支持
- 表支持:
message_base_info提供消息存储能力 - 实现方式:
- 服务端接收客户端消息,保存到
message_base_info表 - 通过
status字段标记消息发送状态 - 通过
send_time记录发送时间
- 服务端接收客户端消息,保存到
2. 消息接收 - ✅ 完全支持
- 表支持:
message_base_info提供消息查询能力 - 实现方式:
- 服务端通过
session_id和to_user_id查询待接收消息 - 支持按时间范围增量查询
- 推送消息给接收方客户端
- 服务端通过
3. 消息存储 - ✅ 完全支持
- 表支持:
message_base_info提供持久化存储 - 实现方式:
- 所有消息都存储在
message_base_info表 - 支持按会话、时间、用户等维度查询
- 提供分页查询能力
- 所有消息都存储在
4. 消息同步 - ✅ 完全支持
- 表支持:
message_base_info.version+message_status - 实现方式:
- 通过
version字段实现增量同步 message_status记录每个用户的状态(已读/未读/送达等)- 支持多设备消息状态同步
- 通过
5. 消息撤回 - ✅ 完全支持
- 表支持:
message_revoke_logs - 实现方式:
- 通过
message_revoke_logs表判断消息是否撤回 - 通过
LEFT JOIN查询未撤回的消息 - 支持时间限制的撤回功能
- 唯一索引确保一个消息只能撤回一次
- 通过
6. 消息重发 - ✅ 完全支持
- 表支持:
message_base_info.status - 实现方式:
- 通过
status字段标识发送失败的消息 - 客户端可重新发送失败的消息
- 服务端支持消息幂等性处理
- 通过
7. 已读回执 - ✅ 完全支持
- 表支持:
message_status - 实现方式:
- 每条用户独立记录已读状态
- 通过
read_time记录已读时间 - 通过
delivered_time记录送达时间 - 支持群聊场景的已读状态统计
8. 消息搜索 - ✅ 完全支持
- 表支持:
message_base_info.content+ 索引 - 实现方式:
- 通过
content字段支持文本搜索 - 可结合消息类型、时间等维度过滤
- 支持全文检索(需额外配置)
- 通过
9. 消息删除 - ✅ 完全支持
- 表支持:
message_base_info.is_deleted - 实现方式:
- 通过
is_deleted字段实现软删除 - 支持本地删除和服务端删除
- 保留删除历史,便于恢复
- 通过
10. 离线消息 - ✅ 完全支持
- 表支持:
message_base_info+ 时间范围查询 - 实现方式:
- 用户离线期间消息正常存储
- 上线后根据最后同步时间增量拉取
- 支持批量推送离线消息
表设计的核心优势
| 表名 | 核心职责 | 对消息管理的贡献 |
|---|---|---|
message_base_info | 存储消息核心数据 | 提供消息标识、内容、状态、时间等基础信息 |
message_attachment | 存储媒体文件信息 | 统一管理所有媒体附件,便于扩展和维护 |
message_status | 消息状态管理 | 支持多用户独立状态记录(已读/未读/送达等)和查询 |
message_index | 消息索引优化 | 针对经常查询的字段建立索引,提升查询性能 |
message_revoke_logs | 撤回操作审计 | 记录所有撤回历史,支持追溯 |
message_ext_info | 灵活扩展 | 支持未来业务需求,无需修改表结构 |
设计亮点
- 职责分离:每个表专注于单一职责,避免数据冗余
- 媒体统一管理:
message_attachment表统一管理所有媒体附件,便于扩展和维护 - 用户级状态:
message_status表让每个用户的状态(已读/未读/送达等)独立管理 - 查询优化:
message_index表针对经常查询的字段建立索引,大幅提升查询性能 - 扩展性:
message_ext_info为未来功能预留空间 - 审计追踪:
message_revoke_logs提供完整撤回历史 - 数据一致性:外键约束和级联删除确保数据完整性
小结
这个表设计方案完全能够胜任消息管理的所有核心功能。它通过:
- 基础表(
message_base_info)提供核心消息数据 - 附件表(
message_attachment)统一管理所有媒体附件 - 索引表(
message_index)优化查询性能 - 状态表(
message_status)管理用户级状态(已读/未读/送达等) - 撤回表(
message_revoke_logs)保证可审计性 - 扩展表(
message_ext_info)确保未来可扩展
实现了消息管理的发送、接收、存储、同步、撤回、重发、已读回执、搜索、删除、离线消息十大核心功能。这是一个专业、完整、可扩展的消息管理数据模型设计。
三、消息维度组合
3.1 消息管理方案的关键维度
消息管理方案的设计涉及多个关键维度,每个维度的选择都会影响最终的技术实现和用户体验。以下是影响方案选择的核心维度:
维度一:消息发送方式:
- 直推模式:消息直接通过WebSocket推送,不存储到服务端
- 存储转发模式:消息先存储到服务端,再推送给接收方
- 混合模式:优先直推,失败后存储转发
维度二:消息存储策略:
- 服务端存储为主:消息主要存储在服务端
- 客户端存储为主:消息主要存储在客户端
- 混合存储:两端都存储,通过同步机制保持一致性
维度三:消息同步机制:
- 实时同步:通过WebSocket实时同步
- 定时同步:定时轮询同步
- 按需同步:根据用户操作按需同步
- 增量同步:基于时间戳或版本号的增量拉取
维度四:消息撤回策略:
- 严格撤回:仅支持未读消息撤回
- 宽松撤回:支持一定时间内撤回
- 无限撤回:支持任意时间撤回
维度五:已读回执机制:
- 自动已读:用户打开会话即标记已读
- 手动已读:用户点击标记已读
- 滚动已读:滚动到消息即标记已读
- 实时已读:消息出现在屏幕即标记已读
维度六:离线消息处理:
- 离线缓存:离线期间消息缓存到服务端
- 离线丢失:离线期间消息丢失
- 离线推送:上线后推送离线消息
维度七:消息持久化:
- 全量持久化:所有消息永久存储
- 部分持久化:重要消息永久存储,普通消息临时存储
- 临时存储:消息不持久化,仅内存存储
维度八:消息顺序保证:
- 严格顺序:消息严格按发送顺序展示
- 宽松顺序:允许消息顺序有轻微偏差
- 无序:不保证消息顺序
3.2 维度分析
3.2.1 消息发送方式
消息发送方式是指消息从发送方到接收方的传输方式,分为直推模式或存储转发模式,是消息管理方案的核心维度之一。
1. 存储转发模式(推荐)
定义:消息先存储到服务端数据库,确认成功后再推送给接收方。
核心思想:消息持久化优先,确保消息不丢失。
优点:
- ✅ 消息可靠性高,不丢失
- ✅ 支持离线消息
- ✅ 支持消息重发和撤回
- ✅ 支持消息审计和追溯
- ✅ 支持多设备同步
缺点:
- ❌ 实时性稍差(需要存储后再推送)
- ❌ 服务端负载较重
- ❌ 数据库压力大
适用场景:
- 企业级IM应用
- 对消息可靠性要求高的场景
- 需要离线消息的场景
- 需要消息审计的场景
相关实现:
📋 点击展开/收起 前端部分
/**
* 发送消息 - 存储转发模式
* 客户端发送消息到服务端,服务端存储后确认成功
*/
async function sendMessage(sessionId: string, content: string, messageType: MessageType) {
// 1. 构造消息对象
const message = {
sessionId,
content,
type: messageType,
timestamp: Date.now()
};
try {
// 2. 通过WebSocket发送消息到服务端
const result = await window.ipcRenderer.invoke('ws:send', {
type: 'message',
data: message
});
// 3. 服务端返回消息ID,表示消息已存储成功
console.log('消息发送成功,消息ID:', result.messageId);
// 4. 更新本地消息状态为发送成功
updateMessageStatus(result.tempId, MessageStatus.Sent);
return result.messageId;
} catch (error) {
console.error('消息发送失败:', error);
// 5. 更新本地消息状态为发送失败
updateMessageStatus(message.tempId, MessageStatus.Failed);
throw error;
}
}
📋 点击展开/收起 后端部分
/**
* 消息处理服务 - 存储转发模式
*/
@Injectable()
export class MessageService {
async handleMessage(message: MessageDTO): Promise<void> {
// 1. 生成消息ID
const messageId = this.generateMessageId();
// 2. 保存消息到数据库
const savedMessage = await this.messageRepository.save({
messageId,
sessionId: message.sessionId,
fromUserId: message.fromId,
toUserId: message.toId,
toType: message.scene === Scene.GroupChat ? 2 : 1,
messageType: message.type,
content: message.content,
status: MessageStatus.Sent,
isRevoked: false,
isDeleted: false,
sendTime: message.timestamp,
createdAt: Date.now(),
updatedAt: Date.now(),
version: 0
});
// 3. 确认消息已存储
await this.acknowledgeMessage(message.fromId, messageId);
// 4. 推送消息给接收方
await this.pushMessageToReceiver(savedMessage);
// 5. 更新会话的最后消息
await this.sessionService.updateLastMessage(message.sessionId, {
messageId: savedMessage.messageId,
content: message.content,
type: message.type,
timestamp: message.timestamp
});
}
/**
* 生成消息ID: {timestamp}_{sequence}_{node_id}
*/
private generateMessageId(): string {
const timestamp = Date.now();
const sequence = this.getNextSequence(timestamp);
const nodeId = this.getNodeId();
return `${timestamp}_${sequence}_${nodeId}`;
}
}
2. 直推模式
定义:消息直接通过WebSocket从发送方推送到接收方,不存储到服务端。
核心思想:实时优先,消息直接传输。
优点:
- ✅ 实时性极佳
- ✅ 服务端负载轻
- ✅ 实现简单
缺点:
- ❌ 消息可能丢失
- ❌ 不支持离线消息
- ❌ 不支持消息撤回
- ❌ 不支持多设备同步
适用场景:
- 实时性要求极高的场景
- 临时聊天场景
- 不需要消息持久化的场景
实现流程:
📋 点击展开/收起 前端部分
/**
* 发送消息 - 直推模式
* 客户端直接推送消息,不经过服务端存储
*/
async function sendMessageDirect(sessionId: string, content: string, messageType: MessageType) {
// 1. 构造消息对象
const message = {
sessionId,
content,
type: messageType,
timestamp: Date.now(),
messageId: this.generateTempMessageId() // 客户端生成临时ID
};
try {
// 2. 直接通过WebSocket推送消息
const result = await window.ipcRenderer.invoke('ws:sendDirect', {
type: 'message_direct',
data: message
});
console.log('消息推送成功');
return result;
} catch (error) {
console.error('消息推送失败:', error);
throw error;
}
}
/**
* 生成临时消息ID
*/
private generateTempMessageId(): string {
return `temp_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
两种发送方式的对比
| 对比维度 | 存储转发模式(推荐) | 直推模式 |
|---|---|---|
| 消息可靠性 | 高(持久化存储) | 低(可能丢失) |
| 实时性 | 良好(需要存储) | 极佳(直接推送) |
| 离线消息 | 支持 | 不支持 |
| 消息撤回 | 支持 | 不支持 |
| 多设备同步 | 支持 | 不支持 |
| 服务端负载 | 重(需要存储) | 轻(仅转发) |
| 实现复杂度 | 中 | 低 |
| 适用场景 | 企业级IM、高可靠性要求 | 实时性要求高、临时聊天 |
| 主流度 | 主流方案 | 特定场景 |
小结
两种方式都可以,存储转发模式适合大多数IM应用,直推模式适合特定场景。
3.2.2 消息存储策略
消息存储策略决定了消息数据在服务端和客户端之间的分布和同步方式,是影响系统性能、一致性和用户体验的核心维度。
消息存储策略分类:
- 服务端存储为主(企业级推荐)
- 客户端存储为主(轻量级方案)
- 混合存储(平衡方案)
1. 服务端存储为主(推荐)
定义:消息主要存储在服务端数据库,客户端仅作为视图层,通过API获取消息列表和历史记录。
核心思想:服务端作为数据权威来源,保证多端数据一致性。
架构特点:
- 消息完全存储在服务端数据库(MySQL/PostgreSQL等)
- 客户端缓存少量最近消息用于离线访问
- 所有消息操作(发送、撤回、删除)都通过服务端API
- 服务端实时推送消息更新给所有客户端
数据流设计:
graph TD
A[用户发送消息] --> B[客户端发送消息]
B --> C[WebSocket传输]
C --> D[服务端接收]
D --> E[存储到数据库]
E --> F[确认成功]
F --> G[推送给接收方]
G --> H[接收方客户端接收]
H --> I[存储到本地缓存]
I --> J[更新UI展示]
优点:
- ✅ 数据一致性高,多端自动同步
- ✅ 服务端作为权威数据源,数据准确可靠
- ✅ 客户端轻量级,实现简单
- ✅ 支持消息审计和追溯
- ✅ 支持复杂的数据查询和统计分析
- ✅ 易于备份和恢复
缺点:
- ❌ 服务端负载较重,需要高性能数据库
- ❌ 网络依赖性强,离线体验受限
- ❌ 需要设计离线缓存策略
- ❌ 实时性依赖网络状况
适用场景:
- 企业级IM应用
- 需要强数据一致性的应用
- 多设备同步要求高的应用
- 需要消息审计和统计分析的应用
2. 客户端存储为主
定义:消息主要存储在客户端本地数据库,服务端仅用于消息传输和部分数据同步。
核心思想:客户端作为数据主人,服务端仅提供消息传输能力。
架构特点:
- 消息数据存储在客户端本地数据库(SQLite、IndexedDB等)
- 服务端仅存储少量元数据和离线消息
- 客户端本地管理消息状态和查询
- 服务端仅同步必要的消息变更
优点:
- ✅ 离线体验极佳,本地数据随时可用
- ✅ 响应速度快,无需等待网络请求
- ✅ 服务端负载轻,节省服务器资源
- ✅ 实现简单,客户端直接管理数据
- ✅ 隐私性好,数据本地化
缺点:
- ❌ 多端数据一致性差
- ❌ 需要设计复杂的同步策略
- ❌ 数据冲突处理复杂
- ❌ 不支持跨设备数据查询
- ❌ 数据备份和恢复困难
适用场景:
- 单设备应用(如桌面端IM)
- 对离线体验要求极高的应用
- 隐私敏感型应用
- 轻量级IM应用
3. 混合存储
定义:消息数据在服务端和客户端都存储,重要数据由服务端统一管理,辅助数据由客户端本地管理。
核心思想:平衡服务端和客户端的优势,实现最佳用户体验。
数据分类:
| 数据类型 | 存储位置 | 同步策略 |
|---|---|---|
| 消息ID | 服务端+客户端 | 必须同步 |
| 消息内容 | 服务端+客户端 | 必须同步 |
| 消息类型 | 服务端+客户端 | 必须同步 |
| 消息状态 | 服务端+客户端 | 实时同步 |
| 已读状态 | 服务端+客户端 | 实时同步 |
| 撤回状态 | 服务端+客户端 | 实时同步 |
| UI展示状态 | 客户端 | 本地存储 |
| 滚动位置 | 客户端 | 本地存储 |
| 临时缓存 | 客户端 | 本地存储 |
优点:
- ✅ 平衡服务端和客户端优势
- ✅ 重要数据保证一致性
- ✅ UI状态响应速度快
- ✅ 离线体验良好
- ✅ 灵活性强,可定制
缺点:
- ❌ 实现复杂度最高
- ❌ 需要仔细划分数据归属
- ❌ 维护成本高
- ❌ 需要设计冲突解决策略
适用场景:
- 需要平衡各种需求的应用
- 业务复杂度高的应用
- 需要良好离线体验的应用
三种存储策略对比
| 对比维度 | 服务端存储为主(推荐) | 客户端存储为主 | 混合存储 |
|---|---|---|---|
| 数据一致性 | 最高(服务端统一管理) | 低(依赖同步) | 高(重要数据服务端) |
| 离线体验 | 差(依赖服务端缓存) | 最好(本地完整) | 良好(关键数据缓存) |
| 响应速度 | 慢(需要网络请求) | 快(本地直接访问) | 较快(重要数据缓存) |
| 服务端负载 | 重(需要高性能数据库) | 轻(仅同步) | 中(管理重要数据) |
| 实现复杂度 | 中 | 低 | 高 |
| 多端同步 | 最好(服务端推送) | 差(需要复杂同步) | 好(关键数据同步) |
| 数据安全 | 高(服务端备份) | 低(本地存储风险) | 高(重要数据服务端) |
| 适用场景 | 企业级IM、多设备同步 | 单设备、离线优先 | 需要平衡的应用 |
| 主流度 | 主流方案 | 特定场景 | 渐趋流行 |
3.2.3 消息同步机制
消息同步机制决定了消息数据如何在服务端和客户端之间保持一致,是影响数据一致性和用户体验的核心维度。
消息同步机制分类:
- 实时同步(推荐)
- 定时同步
- 按需同步
- 增量同步
1. 实时同步(推荐)
定义:通过WebSocket建立持久连接,服务端实时推送消息更新给客户端。
核心思想:服务端主动推送,客户端被动接收,实现准实时同步。
架构特点:
- 建立WebSocket长连接
- 服务端主动推送消息更新
- 客户端接收后立即更新UI
- 双向通信,低延迟
优点:
- ✅ 实时性最佳,消息即时送达
- ✅ 用户体验好,无延迟感
- ✅ 服务端主动推送,无需客户端轮询
- ✅ 支持双向通信
缺点:
- ❌ 需要维护长连接
- ❌ 网络不稳定时需要重连
- ❌ 服务端资源消耗较大
- ❌ 实现复杂度较高
适用场景:
- 对实时性要求高的IM应用
- 大型企业应用
- 多设备同步要求高的应用
2. 定时同步
定义:客户端定时轮询服务端,拉取最新的消息列表。
核心思想:客户端主动拉取,服务端被动响应,定期更新。
架构特点:
- HTTP请求轮询
- 定时间隔(如每5秒轮询一次)
- 服务端返回最新的消息列表
- 客户端对比本地数据,更新差异
优点:
- ✅ 实现简单,无需维护长连接
- ✅ 兼容性好,HTTP协议通用
- ✅ 服务端压力可控
- ✅ 适合低频更新的场景
缺点:
- ❌ 实时性差,有延迟
- ❌ 网络资源浪费(空轮询)
- ❌ 服务器压力随用户数线性增长
- ❌ 用户体验差
适用场景:
- 对实时性要求不高的应用
- 用户规模较小的应用
- 网络环境不稳定的应用
3. 按需同步
定义:用户主动操作时触发同步,如切换会话时拉取最新消息。
核心思想:用户驱动同步,按需拉取数据。
架构特点:
- 用户操作触发同步
- 切换会话时拉取
- 下拉刷新时拉取
- 最小化网络请求
优点:
- ✅ 节省网络资源
- ✅ 实现简单
- ✅ 服务端压力最小
- ✅ 适合离线优先应用
缺点:
- ❌ 实时性最差
- ❌ 用户体验差
- ❌ 需要用户主动操作
- ❌ 消息更新延迟高
适用场景:
- 离线优先应用
- 用户规模很小的应用
- 对实时性要求极低的应用
4. 增量同步(推荐结合)
定义:基于时间戳或版本号的增量拉取,只同步有变化的数据。
核心思想:减少数据传输量,只同步增量数据。
架构特点:
- 基于时间戳或版本号
- 只拉取有变化的消息
- 支持断点续传
- 结合实时同步使用
优点:
- ✅ 减少数据传输量
- ✅ 提高同步效率
- ✅ 节省网络资源
- ✅ 支持离线同步
缺点:
- ❌ 需要维护同步状态
- ❌ 实现复杂度较高
- ❌ 需要处理冲突
适用场景:
- 所有IM应用(建议结合实时同步)
- 网络环境较差的应用
- 数据量较大的应用
四种同步机制对比
| 对比维度 | 实时同步(推荐) | 定时同步 | 按需同步 | 增量同步(推荐结合) |
|---|---|---|---|---|
| 实时性 | 最佳(毫秒级) | 差(秒级) | 最差(分钟级) | 良好(取决于触发) |
| 网络资源 | 中 | 高(空轮询) | 低 | 低 |
| 服务端压力 | 高(长连接) | 高(轮询) | 低 | 低 |
| 实现复杂度 | 高 | 低 | 最低 | 中 |
| 用户体验 | 最佳 | 差 | 最差 | 良好 |
| 适用场景 | 对实时性要求高 | 对实时性要求低 | 离线优先 | 所有应用(建议结合) |
| 主流度 | 主流方案 | 特定场景 | 特定场景 | 主流方案(结合) |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
推荐组合方案:实时同步 + 增量同步
核心思想:
- 实时同步:通过WebSocket实时推送新消息
- 增量同步:上线时增量拉取离线期间的消息
- 互补优势:实时性 + 可靠性
实现流程:
📋 点击展开/收起 实时同步+增量同步代码示例
/**
* 前端部分(渲染进程) - 消息同步管理器
*/
class MessageSyncManager {
private wsConnection: WebSocket | null = null;
private syncState: Map<string, SyncState> = new Map();
/**
* 初始化同步管理器
*/
async initialize(): Promise<void> {
// 1. 建立WebSocket连接(实时同步)
this.connectWebSocket();
// 2. 执行增量同步(拉取离线消息)
await this.incrementalSync();
console.log('消息同步管理器初始化完成');
}
/**
* 连接WebSocket(实时同步)
*/
private connectWebSocket(): void {
this.wsConnection = new WebSocket('ws://your-server/ws');
this.wsConnection.onopen = () => {
console.log('WebSocket连接成功');
};
this.wsConnection.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'new_message':
this.handleNewMessage(message.data);
break;
case 'message_update':
this.handleMessageUpdate(message.data);
break;
case 'message_revoked':
this.handleMessageRevoked(message.data);
break;
case 'message_read_update':
this.handleMessageReadUpdate(message.data);
break;
default:
break;
}
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket错误:', error);
// 连接失败时,重试或降级到HTTP轮询
this.handleWebSocketError();
};
this.wsConnection.onclose = () => {
console.log('WebSocket连接关闭');
// 自动重连
this.reconnectWebSocket();
};
}
/**
* 增量同步(上线时执行)
*/
private async incrementalSync(): Promise<void> {
try {
// 1. 从本地数据库读取同步状态
const syncStates = await this.loadSyncStates();
// 2. 遍历所有会话,执行增量同步
for (const state of syncStates) {
await this.syncSessionMessages(state);
}
console.log('增量同步完成');
} catch (error) {
console.error('增量同步失败:', error);
}
}
/**
* 同步指定会话的消息
*/
private async syncSessionMessages(syncState: SyncState): Promise<void> {
const response = await fetch(`/api/messages/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: syncState.sessionId,
since: syncState.lastSyncTime,
after: syncState.lastMessageId
})
});
if (!response.ok) {
throw new Error(`同步失败: ${response.statusText}`);
}
const data = await response.json();
// 更新本地消息
for (const message of data.messages) {
this.updateOrInsertMessage(message);
}
// 更新同步状态
syncState.lastSyncTime = data.syncTime;
if (data.messages.length > 0) {
syncState.lastMessageId = data.messages[data.messages.length - 1].messageId;
}
await this.saveSyncState(syncState);
console.log(`会话 ${syncState.sessionId} 同步了 ${data.messages.length} 条消息`);
}
/**
* 处理新消息(实时同步)
*/
private handleNewMessage(messageData: any): void {
// 保存到本地数据库
this.saveMessageToLocal(messageData);
// 更新UI
this.updateUI();
// 更新同步状态
const syncState = this.syncState.get(messageData.sessionId);
if (syncState) {
syncState.lastSyncTime = messageData.sendTime;
syncState.lastMessageId = messageData.messageId;
}
}
/**
* 处理消息更新(实时同步)
*/
private handleMessageUpdate(event: MessageUpdateEvent): void {
const { messageId, update } = event;
// 更新本地消息
this.updateMessage(messageId, update);
// 更新UI
this.updateUI();
}
/**
* 处理消息撤回(实时同步)
*/
private handleMessageRevoked(event: MessageRevokeEvent): void {
const { messageId } = event;
// 更新本地消息为已撤回状态
this.revokeMessage(messageId);
// 更新UI
this.updateUI();
}
/**
* 处理已读状态更新(实时同步)
*/
private handleMessageReadUpdate(event: MessageReadUpdateEvent): void {
const { messageId, userId, isRead, readTime } = event;
// 如果是自己标记已读,更新本地消息
if (userId === getCurrentUserId()) {
this.updateMessageReadStatus(messageId, isRead, readTime);
this.updateUI();
}
}
/**
* WebSocket错误处理
*/
private handleWebSocketError(): void {
// 降级到HTTP轮询
console.log('降级到HTTP轮询模式');
this.startHttpPolling();
}
/**
* 自动重连WebSocket
*/
private reconnectWebSocket(): void {
setTimeout(() => {
console.log('尝试重连WebSocket...');
this.connectWebSocket();
}, 3000); // 3秒后重连
}
/**
* 启动HTTP轮询(降级方案)
*/
private startHttpPolling(): void {
setInterval(async () => {
try {
await this.incrementalSync();
} catch (error) {
console.error('HTTP轮询失败:', error);
}
}, 5000); // 每5秒轮询一次
}
}
/**
* 后端部分 - 消息同步服务
*/
@Injectable()
export class MessageSyncService {
constructor(
private messageRepository: MessageRepository,
private messageStatusRepository: MessageStatusRepository
) {}
/**
* 增量同步消息列表
*/
async incrementalSync(sessionId: string, since: number, after: string): Promise<SyncResponse> {
// 1. 查询指定时间之后更新的消息
const messages = await this.messageRepository.getMessagesBySessionIdSince(sessionId, since, after);
// 2. 获取最新消息ID
const lastMessageId = messages.length > 0 ? messages[messages.length - 1].messageId : after;
// 3. 获取当前同步版本号
const syncVersion = await this.getLatestSyncVersion();
const syncTime = Date.now();
return {
messages,
syncTime,
syncVersion,
lastMessageId
};
}
/**
* 获取最新的同步版本号
*/
private async getLatestSyncVersion(): Promise<number> {
const result = await this.messageRepository.getLatestVersion();
return result || 0;
}
}
小结
推荐方案:实时同步 + 增量同步
- 实时同步:通过WebSocket实时推送消息更新
- 增量同步:上线时增量拉取离线消息
- 互补优势:兼顾实时性和可靠性
- 主流方案:绝大多数IM应用采用此方案
3.2.4 消息撤回策略
消息撤回策略决定了用户可以撤回消息的条件和限制,是影响用户体验和数据安全的核心维度。
消息撤回策略分类:
- 严格撤回
- 宽松撤回(推荐)
- 无限撤回
1. 严格撤回
定义:仅支持撤回未读消息,一旦消息被接收方阅读,无法撤回。
核心思想:保护接收方阅读体验,避免消息消失造成困惑。
撤回条件:
- 消息未读
- 仅限发送方操作
- 无时间限制
优点:
- ✅ 保护接收方阅读体验
- ✅ 避免消息消失造成困惑
- ✅ 逻辑简单,易于实现
缺点:
- ❌ 撤回能力受限
- ✅ 用户体验差
- ❌ 无法纠错已读消息
适用场景:
- 强调消息不可篡改的场景
- 正式沟通场景
- 审计要求高的场景
2. 宽松撤回(推荐)
定义:支持在一定时间内撤回消息,即使消息已读也可以撤回(可配置时间限制)。
核心思想:平衡发送方纠错能力和接收方阅读体验。
撤回条件:
- 仅限发送方操作
- 时间限制(如2分钟内,可配置)
- 已读/未读皆可撤回
时间限制建议:
- 默认2分钟
- 可配置为1-10分钟
- 也可设置为无限制
优点:
- ✅ 平衡发送方和接收方体验
- ✅ 支持纠错,用户体验好
- ✅ 时间限制避免滥用
- ✅ 主流方案
缺点:
- ❌ 需要实现时间检查逻辑
- ❌ 已读消息撤回可能造成困惑
适用场景:
- 绝大多数IM应用
- 社交聊天应用
- 企业IM应用
3. 无限撤回
定义:支持任意时间撤回消息,无时间限制。
核心思想:给予发送方最大的纠错能力。
撤回条件:
- 仅限发送方操作
- 无时间限制
- 已读/未读皆可撤回
优点:
- ✅ 发送方纠错能力最强
- ✅ 灵活性最高
缺点:
- ❌ 接收方体验差
- ❌ 消息可能随时消失
- ❌ 容易滥用
- ❌ 数据审计困难
适用场景:
- 内部协作工具
- 临时沟通场景
- 需要强纠错能力的场景
三种撤回策略对比
| 对比维度 | 严格撤回 | 宽松撤回(推荐) | 无限撤回 |
|---|---|---|---|
| 撤回条件 | 仅未读消息 | 2分钟内(可配置) | 无时间限制 |
| 发送方体验 | 差 | 良好 | 最佳 |
| 接收方体验 | 最佳 | 良好 | 差 |
| 实现复杂度 | 低 | 中 | 低 |
| 滥用风险 | 无 | 低 | 高 |
| 数据审计 | 简单 | 中等 | 困难 |
| 适用场景 | 正式沟通 | 绝大多数IM应用 | 内部协作 |
| 主流度 | 特定场景 | 主流方案 | 特定场景 |
| 推荐指数 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
推荐方案:宽松撤回(2分钟内,可配置)
核心设计原则:
- 时间限制:默认2分钟内可撤回,可配置
- 发送方限制:仅发送方可以撤回
- 撤回通知:撤回后推送通知给所有相关方
- 审计记录:记录所有撤回操作
实现流程:
📋 点击展开/收起 宽松撤回代码示例
/**
* 前端部分(渲染进程) - 消息撤回服务
*/
class MessageRevokeService {
private revokeTimeLimit: number = 2 * 60 * 1000; // 默认2分钟,可配置
/**
* 撤回消息
*/
async revokeMessage(messageId: string): Promise<void> {
const message = this.getMessage(messageId);
if (!message) {
throw new Error('消息不存在');
}
// 1. 检查是否可以撤回
const canRevoke = await this.checkCanRevoke(message);
if (!canRevoke) {
throw new Error('消息无法撤回');
}
try {
// 2. 请求服务端撤回消息
await fetch(`/api/messages/${messageId}/revoke`, {
method: 'POST'
});
console.log('消息撤回请求已发送');
// 服务端会通过WebSocket推送撤回通知,客户端接收后自动更新UI
} catch (error) {
console.error('撤回消息失败:', error);
throw error;
}
}
/**
* 检查消息是否可以撤回
*/
private async checkCanRevoke(message: Message): Promise<boolean> {
const currentUserId = getCurrentUserId();
// 1. 检查是否是发送方
if (message.fromUserId !== currentUserId) {
console.log('只能撤回自己发送的消息');
return false;
}
// 2. 检查是否已撤回
if (message.isRevoked) {
console.log('消息已被撤回');
return false;
}
// 3. 检查是否超时(2分钟)
const now = Date.now();
if (now - message.sendTime > this.revokeTimeLimit) {
console.log('消息发送超过2分钟,无法撤回');
return false;
}
return true;
}
/**
* 处理消息撤回事件(WebSocket推送)
*/
handleMessageRevoked(event: MessageRevokeEvent): void {
const { messageId } = event;
const message = this.getMessage(messageId);
if (message) {
// 更新消息为已撤回状态
message.isRevoked = true;
// 保存到本地数据库
this.saveMessageToLocal(message);
// 更新UI
this.updateUI();
}
}
}
/**
* 后端部分 - 消息撤回服务
*/
@Injectable()
export class MessageRevokeService {
constructor(
private messageRepository: MessageRepository,
private messageRevokeLogsRepository: MessageRevokeLogsRepository,
private webSocketGateway: MessageWebSocketGateway,
private sessionService: SessionService,
private configService: ConfigService
) {}
/**
* 撤回消息
*/
async revokeMessage(messageId: string, userId: number): Promise<void> {
// 1. 检查消息是否存在
const message = await this.messageRepository.getMessageById(messageId);
if (!message) {
throw new BadRequestException('消息不存在');
}
// 2. 检查是否是发送方
if (message.fromUserId !== userId) {
throw new BadRequestException('只能撤回自己发送的消息');
}
// 3. 检查是否已撤回
if (message.isRevoked) {
throw new BadRequestException('消息已被撤回');
}
// 4. 检查是否超时(可配置,默认2分钟)
const revokeTimeLimit = this.configService.get<number>('revokeTimeLimit') || (2 * 60 * 1000);
const now = Date.now();
if (now - message.sendTime > revokeTimeLimit) {
throw new BadRequestException('消息发送时间过长,无法撤回');
}
// 5. 更新消息为已撤回状态
await this.messageRepository.updateRevokedStatus(messageId, true);
// 6. 记录撤回操作日志
await this.messageRevokeLogsRepository.save({
messageId,
operatorId: userId,
revokeTime: Date.now(),
deviceId: this.getDeviceId(),
createdAt: Date.now()
});
// 7. 推送撤回通知给所有相关方
await this.pushRevokeNotification(message);
console.log(`消息 ${messageId} 已被撤回`);
}
/**
* 推送撤回通知
*/
private async pushRevokeNotification(message: Message): Promise<void> {
if (message.toType === ToType.User) {
// 单聊:推送给发送方和接收方
await this.webSocketGateway.sendToUser(message.fromUserId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
await this.webSocketGateway.sendToUser(message.toUserId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
} else {
// 群聊:推送给所有群成员
const members = await this.sessionService.getMembersBySessionId(message.sessionId);
for (const member of members) {
await this.webSocketGateway.sendToUser(member.userId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
}
}
}
}
小结
推荐方案:宽松撤回(2分钟内,可配置)
- 时间限制:默认2分钟内可撤回
- 发送方限制:仅发送方可以撤回
- 撤回通知:撤回后推送通知给所有相关方
- 审计记录:记录所有撤回操作
- 主流方案:主流社交IM和协作工具等均采用此方案
3.2.5 已读回执机制
已读回执机制决定了消息已读状态的标记方式和同步策略,是影响用户体验和数据一致性的核心维度。
已读回执机制分类:
- 自动已读(推荐)
- 手动已读
- 滚动已读
- 实时已读
1. 自动已读(推荐)
定义:用户打开会话时,自动标记该会话所有未读消息为已读。
核心思想:简化用户操作,打开即已读。
触发条件:
- 用户打开会话
- 会话切换到前台
- 从后台切换到前台
优点:
- ✅ 用户操作简单,无需手动标记
- ✅ 用户体验好
- ✅ 实现简单
- ✅ 主流方案
缺点:
- ❌ 可能标记未实际阅读的消息为已读
- ❌ 用户无法精确控制已读状态
适用场景:
- 绝大多数IM应用
- 社交聊天应用
- 企业IM应用
2. 手动已读
定义:用户手动点击按钮标记消息为已读。
核心思想:用户精确控制已读状态。
触发条件:
- 用户点击"标记已读"按钮
- 用户执行标记已读操作
优点:
- ✅ 用户精确控制已读状态
- ✅ 避免误标记
- ✅ 适合正式沟通场景
缺点:
- ❌ 用户操作繁琐
- ❌ 用户体验差
- ❌ 容易忘记标记
适用场景:
- 正式沟通工具
- 邮件客户端
- 需要精确控制的场景
3. 滚动已读
定义:用户滚动到消息时,自动标记该消息为已读。
核心思想:滚动即已读,精确到单条消息。
触发条件:
- 消息进入可视区域
- 用户滚动到该消息
优点:
- ✅ 精确到单条消息
- ✅ 用户操作自然
缺点:
- ❌ 实现复杂
- ❌ 性能开销大
- ❌ 容易误标记
- ❌ 用户体验差(快速滚动时大量消息被标记)
适用场景:
- 特定业务场景
- 需要精确追踪阅读状态
4. 实时已读
定义:消息出现在屏幕上时,立即标记为已读。
核心思想:看见即已读,准实时。
触发条件:
- 消息出现在屏幕上
- 消息进入可视区域
优点:
- ✅ 实时性最佳
- ✅ 准确反映用户行为
缺点:
- ❌ 实现复杂
- ❌ 性能开销大
- ❌ 容易误标记
- ❌ 用户体验差(不想看也被标记)
适用场景:
- 特定业务场景
- 需要实时追踪的场景
四种已读回执机制对比
| 对比维度 | 自动已读(推荐) | 手动已读 | 滚动已读 | 实时已读 |
|---|---|---|---|---|
| 触发方式 | 打开会话 | 手动点击 | 滚动到消息 | 消息出现 |
| 用户体验 | 最佳 | 差 | 中 | 差 |
| 精确度 | 会话级别 | 会话级别 | 消息级别 | 消息级别 |
| 实现复杂度 | 低 | 低 | 高 | 高 |
| 性能开销 | 低 | 低 | 中 | 高 |
| 误标记风险 | 中 | 低 | 高 | 高 |
| 适用场景 | 绝大多数IM应用 | 正式沟通 | 特定业务 | 特定业务 |
| 主流度 | 主流方案 | 特定场景 | 特定场景 | 特定场景 |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
推荐方案:自动已读
核心设计原则:
- 打开即已读:用户打开会话时,标记该会话所有未读消息为已读
- 用户级状态:每条用户独立标记已读状态
- 实时同步:已读状态变化时实时推送
- 精确计时:记录已读时间戳
实现流程:
📋 点击展开/收起 自动已读代码示例
/**
* 前端部分(渲染进程) - 已读回执服务
*/
class MessageReadService {
/**
* 标记会话消息为已读
* 用户打开会话时调用,标记该会话所有未读消息为已读
*/
async markSessionAsRead(sessionId: string): Promise<void> {
const currentUserId = getCurrentUserId();
// 1. 获取该会话的所有未读消息
const unreadMessages = this.getUnreadMessages(sessionId);
if (unreadMessages.length === 0) {
return;
}
try {
// 2. 请求服务端标记已读
await fetch(`/api/messages/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId,
userId: currentUserId
})
});
console.log(`已发送标记已读请求,共 ${unreadMessages.length} 条消息`);
// 服务端会通过WebSocket推送已读状态更新,客户端接收后自动更新UI
} catch (error) {
console.error('标记已读失败:', error);
// 网络错误时,先更新本地UI,等待下次同步
unreadMessages.forEach(message => {
message.isRead = true;
message.readTime = Date.now();
});
this.updateUI();
}
}
/**
* 获取会话的未读消息
*/
private getUnreadMessages(sessionId: string): Message[] {
return Array.from(this.messages.values())
.filter(m => m.sessionId === sessionId && !m.isRead && m.fromUserId !== getCurrentUserId());
}
/**
* 处理已读状态更新事件(WebSocket推送)
*/
handleMessageReadUpdate(event: MessageReadUpdateEvent): void {
const { messageId, userId, isRead, readTime } = event;
// 如果是自己标记已读,更新本地消息
if (userId === getCurrentUserId()) {
const message = this.getMessage(messageId);
if (message) {
message.isRead = isRead;
message.readTime = readTime;
// 保存到本地数据库
this.saveMessageToLocal(message);
// 更新UI
this.updateUI();
}
} else {
// 如果是其他人标记已读,更新发送方显示的已读状态
const message = this.getMessage(messageId);
if (message && message.fromUserId === getCurrentUserId()) {
// 更新消息的已读回执列表
this.updateMessageReadReceipt(messageId, userId, isRead, readTime);
this.updateUI();
}
}
}
/**
* 更新消息的已读回执列表
*/
private updateMessageReadReceipt(messageId: string, userId: number, isRead: boolean, readTime: number): void {
// 群聊场景下,更新消息的已读回执列表
// 单聊场景下,仅更新发送方看到的已读状态
// 具体实现取决于业务需求
}
}
/**
* 后端部分 - 已读回执服务
*/
@Injectable()
export class MessageReadService {
constructor(
private messageRepository: MessageRepository,
private messageStatusRepository: MessageStatusRepository,
private webSocketGateway: MessageWebSocketGateway,
private sessionService: SessionService
) {}
/**
* 标记会话消息为已读
*/
async markSessionAsRead(sessionId: string, userId: number): Promise<void> {
// 1. 获取该会话的所有未读消息
const unreadMessages = await this.messageRepository.getUnreadMessagesBySessionId(sessionId, userId);
if (unreadMessages.length === 0) {
return;
}
// 2. 标记每条消息为已读
const readTime = Date.now();
for (const message of unreadMessages) {
await this.messageStatusRepository.updateReadStatus(message.messageId, userId, true, readTime);
}
// 3. 推送已读状态更新给发送方
for (const message of unreadMessages) {
await this.pushReadNotification(message, userId, true, readTime);
}
// 4. 更新会话未读数
await this.sessionService.resetUnreadCount(sessionId, userId);
console.log(`用户 ${userId} 已标记会话 ${sessionId} 的 ${unreadMessages.length} 条消息为已读`);
}
/**
* 推送已读状态更新
*/
private async pushReadNotification(message: Message, userId: number, isRead: boolean, readTime: number): Promise<void> {
// 推送给发送方
await this.webSocketGateway.sendToUser(message.fromUserId, {
type: 'message_read_update',
data: {
messageId: message.messageId,
userId,
isRead,
readTime
}
});
}
}
小结
推荐方案:自动已读
- 触发方式:打开会话即标记已读
- 用户操作:无需手动操作
- 用户体验:最佳
- 主流方案:主流社交IM和协作工具等均采用此方案
3.2.6 离线消息处理
离线消息处理决定了用户离线期间消息的处理方式,是影响用户体验和数据可靠性的核心维度。
离线消息处理分类:
- 离线缓存(推荐)
- 离线丢失
- 离线推送
1. 离线缓存(推荐)
定义:用户离线期间,消息存储到服务端数据库,上线后通过增量同步推送。
核心思想:服务端缓存离线消息,上线后同步。
处理流程:
- 用户离线
- 接收到的消息存储到服务端数据库
- 用户上线
- 服务端增量推送离线消息
- 客户端接收并展示
优点:
- ✅ 消息不丢失
- ✅ 用户体验好
- ✅ 离线消息完整
- ✅ 主流方案
缺点:
- ❌ 服务端需要存储离线消息
- ❌ 需要实现增量同步
- ❌ 上线时可能有大量消息推送
适用场景:
- 绝大多数IM应用
- 企业IM应用
- 社交聊天应用
2. 离线丢失
定义:用户离线期间,消息不存储,直接丢弃。
核心思想:离线不存储,上线不推送。
处理流程:
- 用户离线
- 接收到的消息直接丢弃
- 用户上线
- 无离线消息推送
优点:
- ✅ 实现简单
- ✅ 服务端无需存储
缺点:
- ❌ 消息丢失
- ❌ 用户体验极差
- ❌ 不适合IM应用
适用场景:
- 临时聊天场景
- 不需要消息持久化的场景
3. 离线推送
定义:用户离线期间,消息存储到服务端,上线后批量推送。
核心思想:离线存储,上线批量推送。
处理流程:
- 用户离线
- 接收到的消息存储到服务端数据库
- 用户上线
- 服务端批量推送离线消息
- 客户端接收并展示
优点:
- ✅ 消息不丢失
- ✅ 用户体验好
- ✅ 离线消息完整
缺点:
- ❌ 服务端需要存储离线消息
- ❌ 上线时大量消息推送
- ❌ 可能造成消息积压
适用场景:
- IM应用
- 企业应用
- 需要离线消息的场景
三种离线消息处理对比
| 对比维度 | 离线缓存(推荐) | 离线丢失 | 离线推送 |
|---|---|---|---|
| 消息存储 | 存储到服务端 | 不存储 | 存储到服务端 |
| 消息丢失 | 不丢失 | 丢失 | 不丢失 |
| 上线推送 | 增量推送 | 无推送 | 批量推送 |
| 用户体验 | 最佳 | 极差 | 良好 |
| 实现复杂度 | 中 | 低 | 中 |
| 服务端压力 | 中 | 低 | 高(批量推送) |
| 适用场景 | 绝大多数IM应用 | 临时聊天 | IM应用 |
| 主流度 | 主流方案 | 特定场景 | 较少使用 |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ |
推荐方案:离线缓存 + 增量推送
核心设计原则:
- 服务端缓存:离线期间消息存储到服务端数据库
- 增量推送:上线后增量推送离线消息
- 同步状态:记录每个会话的同步状态(上次同步时间、最后一条消息ID)
- 冲突解决:以服务端数据为准,本地数据作为备份
实现流程:
📋 点击展开/收起 离线消息处理代码示例
/**
* 前端部分(渲染进程) - 离线消息管理器
*/
class OfflineMessageManager {
private syncStates: Map<string, SyncState> = new Map();
/**
* 初始化
*/
async initialize(): Promise<void> {
// 1. 从本地数据库加载同步状态
const syncStates = await this.loadSyncStates();
syncStates.forEach(state => {
this.syncStates.set(state.sessionId, state);
});
// 2. 上线时,执行增量同步(拉取离线消息)
await this.syncOfflineMessages();
console.log('离线消息管理器初始化完成');
}
/**
* 同步离线消息(上线时执行)
*/
async syncOfflineMessages(): Promise<void> {
try {
// 遍历所有会话,执行增量同步
for (const [sessionId, syncState] of this.syncStates) {
await this.syncSessionMessages(sessionId, syncState);
}
console.log('离线消息同步完成');
} catch (error) {
console.error('离线消息同步失败:', error);
}
}
/**
* 同步指定会话的消息
*/
private async syncSessionMessages(sessionId: string, syncState: SyncState): Promise<void> {
// 请求服务端消息列表(增量同步)
const response = await fetch(`/api/messages/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId,
since: syncState.lastSyncTime,
after: syncState.lastMessageId
})
});
if (!response.ok) {
throw new Error(`同步失败: ${response.statusText}`);
}
const data = await response.json();
// 更新本地消息
for (const message of data.messages) {
this.updateOrInsertMessage(message);
}
// 更新同步状态
syncState.lastSyncTime = data.syncTime;
if (data.messages.length > 0) {
syncState.lastMessageId = data.messages[data.messages.length - 1].messageId;
}
await this.saveSyncState(syncState);
console.log(`会话 ${sessionId} 同步了 ${data.messages.length} 条离线消息`);
}
/**
* 更新或插入消息
*/
private updateOrInsertMessage(message: Message): void {
const existing = this.messages.get(message.messageId);
if (existing) {
// 版本检查:只更新服务端版本更高的数据
if (message.serverVersion > existing.serverVersion) {
this.messages.set(message.messageId, message);
}
} else {
// 新增消息
this.messages.set(message.messageId, message);
}
}
/**
* 保存同步状态
*/
private async saveSyncState(syncState: SyncState): Promise<void> {
await window.ipcRenderer.invoke('syncStates:save', syncState);
this.syncStates.set(syncState.sessionId, syncState);
}
}
/**
* 后端部分 - 离线消息服务
*/
@Injectable()
export class OfflineMessageService {
constructor(
private messageRepository: MessageRepository,
private messageStatusRepository: MessageStatusRepository
) {}
/**
* 增量同步消息列表(处理离线消息)
*/
async incrementalSync(sessionId: string, since: number, after: string): Promise<SyncResponse> {
// 1. 查询指定时间之后更新的消息(包括离线期间的消息)
const messages = await this.messageRepository.getMessagesBySessionIdSince(sessionId, since, after);
// 2. 获取最新消息ID
const lastMessageId = messages.length > 0 ? messages[messages.length - 1].messageId : after;
// 3. 获取当前同步版本号
const syncVersion = await this.getLatestSyncVersion();
const syncTime = Date.now();
return {
messages,
syncTime,
syncVersion,
lastMessageId
};
}
/**
* 获取最新的同步版本号
*/
private async getLatestSyncVersion(): Promise<number> {
const result = await this.messageRepository.getLatestVersion();
return result || 0;
}
}
小结
推荐方案:离线缓存 + 增量推送
- 离线期间:消息存储到服务端数据库
- 上线时:增量推送离线消息
- 同步状态:记录每个会话的同步状态
- 主流方案:主流社交IM和协作工具等均采用此方案
3.2.7 消息持久化
消息持久化决定了消息数据的存储方式和保留策略,是影响数据可靠性和存储成本的核心维度。
消息持久化分类:
- 全量持久化(推荐)
- 部分持久化
- 临时存储
1. 全量持久化(推荐)
定义:所有消息永久存储到服务端数据库,支持历史消息查询。
核心思想:所有消息都持久化存储,永不删除。
存储策略:
- 服务端数据库永久存储
- 客户端本地缓存
- 支持历史消息查询
- 支持消息搜索
优点:
- ✅ 消息永不丢失
- ✅ 支持历史消息查询
- ✅ 支持消息搜索
- ✅ 支持消息审计
- ✅ 数据安全性高
缺点:
- ❌ 存储成本高
- ❌ 数据量大,查询性能可能下降
- ❌ 需要定期归档
适用场景:
- 企业IM应用
- 需要消息审计的场景
- 需要历史消息查询的场景
2. 部分持久化
定义:重要消息永久存储,普通消息临时存储(如保留30天后删除)。
核心思想:重要消息永久存储,普通消息限时保留。
存储策略:
- 重要消息永久存储
- 普通消息限时保留(如30天)
- 定期清理过期消息
- 节省存储空间
优点:
- ✅ 节省存储空间
- ✅ 重要消息不丢失
- ✅ 存储成本可控
缺点:
- ❌ 普通消息可能丢失
- ❌ 无法查询历史消息
- ❌ 无法搜索历史消息
- ❌ 用户体验差
适用场景:
- 存储成本敏感的应用
- 对历史消息要求不高的应用
- 临时聊天应用
3. 临时存储
定义:消息不持久化,仅存储在内存中,服务器重启后消息丢失。
核心思想:消息临时存储,不持久化。
存储策略:
- 内存临时存储
- 客户端本地缓存
- 服务器重启后消息丢失
- 无历史消息查询
优点:
- ✅ 实现简单
- ✅ 存储成本最低
- ✅ 响应速度快
缺点:
- ❌ 消息可能丢失
- ❌ 无历史消息查询
- ❌ 无消息搜索
- ❌ 无消息审计
- ❌ 用户体验差
适用场景:
- 临时聊天场景
- 实时聊天室
- 不需要消息持久化的场景
三种消息持久化对比
| 对比维度 | 全量持久化(推荐) | 部分持久化 | 临时存储 |
|---|---|---|---|
| 存储方式 | 数据库永久存储 | 重要消息永久,普通消息限时 | 内存临时存储 |
| 消息丢失 | 不丢失 | 普通消息可能丢失 | 可能丢失 |
| 历史消息查询 | 支持 | 普通消息不支持 | 不支持 |
| 消息搜索 | 支持 | 普通消息不支持 | 不支持 |
| 消息审计 | 支持 | 普通消息不支持 | 不支持 |
| 存储成本 | 高 | 中 | 低 |
| 实现复杂度 | 中 | 中 | 低 |
| 适用场景 | 企业IM应用 | 成本敏感应用 | 临时聊天 |
| 主流度 | 主流方案 | 较少使用 | 特定场景 |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
推荐方案:全量持久化
核心设计原则:
- 永久存储:所有消息永久存储到服务端数据库
- 历史查询:支持历史消息查询和分页加载
- 消息搜索:支持全文检索
- 定期归档:定期归档历史消息,保持数据库性能
- 数据备份:定期备份数据,确保数据安全
实现要点:
- 使用数据库存储消息(MySQL/PostgreSQL等)
- 建立索引优化查询性能
- 定期归档历史消息(如1年以上消息归档)
- 支持分页查询历史消息
- 支持全文检索
小结
推荐方案:全量持久化
- 永久存储:所有消息永久存储到服务端数据库
- 历史查询:支持历史消息查询
- 消息搜索:支持全文检索
- 主流方案:主流企业级IM和协作工具等均采用此方案
3.2.8 消息顺序保证
消息顺序保证决定了消息展示的顺序规则,是影响用户体验和数据一致性的核心维度。
消息顺序保证分类:
- 严格顺序(推荐)
- 宽松顺序
- 无序
1. 严格顺序(推荐)
定义:消息严格按发送时间戳的顺序展示,不能乱序。
核心思想:按发送时间戳严格排序,保证顺序正确。
排序规则:
- 按发送时间戳升序排序
- 同一时间戳的消息按消息ID排序
- 确保多端展示顺序一致
优点:
- ✅ 消息顺序准确
- ✅ 用户体验好
- ✅ 多端展示一致
- ✅ 主流方案
缺点:
- ❌ 需要实现严格的排序逻辑
- ❌ 需要处理延迟消息
- ❌ 实现复杂度较高
适用场景:
- 绝大多数IM应用
- 企业IM应用
- 社交聊天应用
2. 宽松顺序
定义:消息基本按发送时间戳排序,允许有轻微的顺序偏差。
核心思想:基本按时间排序,允许轻微乱序。
排序规则:
- 基本按发送时间戳排序
- 允许网络延迟导致的轻微乱序
- 对用户体验影响较小
优点:
- ✅ 实现简单
- ✅ 性能开销小
- ✅ 容忍网络延迟
缺点:
- ❌ 可能出现轻微乱序
- ❌ 用户体验略差
- ❌ 多端展示可能不一致
适用场景:
- 对顺序要求不高的应用
- 轻量级IM应用
- 临时聊天应用
3. 无序
定义:不保证消息顺序,消息按接收顺序展示。
核心思想:不保证顺序,按接收顺序展示。
排序规则:
- 按接收顺序展示
- 不进行任何排序
- 允许完全乱序
优点:
- ✅ 实现最简单
- ✅ 性能开销最小
- ✅ 无需排序逻辑
缺点:
- ❌ 消息可能完全乱序
- ❌ 用户体验极差
- ❌ 多端展示不一致
- ❌ 不适合IM应用
适用场景:
- 实时聊天室
- 弹幕系统
- 对顺序要求极低的场景
三种消息顺序保证对比
| 对比维度 | 严格顺序(推荐) | 宽松顺序 | 无序 |
|---|---|---|---|
| 排序规则 | 按发送时间戳严格排序 | 基本按时间排序,允许轻微乱序 | 按接收顺序展示 |
| 消息顺序 | 准确 | 可能轻微乱序 | 可能完全乱序 |
| 用户体验 | 最佳 | 良好 | 极差 |
| 多端一致性 | 最佳 | 良好 | 差 |
| 实现复杂度 | 中 | 低 | 最低 |
| 性能开销 | 中 | 低 | 最低 |
| 适用场景 | 绝大多数IM应用 | 轻量级IM应用 | 实时聊天室 |
| 主流度 | 主流方案 | 较少使用 | 特定场景 |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
推荐方案:严格顺序
核心设计原则:
- 按发送时间戳排序:消息严格按发送时间戳升序排序
- 同一时间戳排序:同一时间戳的消息按消息ID排序
- 多端一致性:确保多端展示顺序一致
- 处理延迟消息:正确处理网络延迟导致的消息乱序
实现要点:
- 消息ID包含时间戳和序列号,天然保证顺序
- 客户端接收消息后,按发送时间戳排序
- 支持消息插入(处理延迟到达的消息)
- 定期校验消息顺序
小结
推荐方案:严格顺序
- 排序规则:消息严格按发送时间戳升序排序
- 消息顺序:准确
- 用户体验:最佳
- 多端一致性:最佳
- 主流方案:主流社交IM和协作工具等均采用此方案
3.3 业界最佳实践方案
3.3.1 方案一:传统企业级IM方案
适用场景:大型企业应用,多设备同步,高可靠性要求
维度组合:
- 消息发送:存储转发模式
- 消息存储:混合存储
- 消息同步:实时同步 + 增量同步
- 消息撤回:宽松撤回(3分钟内)
- 已读回执:自动已读
- 离线消息:离线缓存
- 消息持久化:全量持久化
- 消息顺序保证:严格顺序
核心特点:
- 服务端作为权威数据源,保证多端一致性
- 消息先存储再推送,确保消息不丢失
- 支持离线使用,网络恢复后自动同步
- 支持消息撤回和已读回执
优点:
- ✅ 消息可靠性高,不丢失
- ✅ 数据一致性高,多端自动同步
- ✅ 支持离线使用
- ✅ 支持消息撤回和已读回执
- ✅ 消息顺序保证严格
缺点:
- ❌ 服务端负载较重
- ❌ 网络依赖性强
- ❌ 实现复杂度较高
3.3.2 方案二:轻量级实时IM方案
适用场景:中小型应用,实时性要求高,云端存储为主
维度组合:
- 消息发送:直推模式
- 消息存储:云端存储为主
- 消息同步:实时同步
- 消息撤回:严格撤回(仅未读消息)
- 已读回执:手动已读
- 离线消息:离线丢失
- 消息持久化:部分持久化
- 消息顺序保证:宽松顺序
核心特点:
- 消息直接推送,实时性极佳
- 云端统一管理所有消息状态
- 客户端轻量级,主要负责展示
优点:
- ✅ 实时性极佳,消息即时送达
- ✅ 客户端轻量级,实现相对简单
- ✅ 多设备体验一致
- ✅ 云端数据安全可靠
缺点:
- ❌ 离线体验差
- ❌ 网络依赖性强
- ❌ 消息可能丢失
- ❌ 服务端压力大
3.3.3 方案三:混合弹性IM方案(自定义模式)
适用场景:需要平衡各种需求,业务复杂度高
维度组合:
- 消息发送:存储转发模式
- 消息存储:混合存储,重要数据服务端,辅助数据客户端
- 消息同步:实时同步 + 定时同步 + 增量同步
- 消息撤回:宽松撤回(可配置时间限制)
- 已读回执:自动已读 + 滚动已读
- 离线消息:离线缓存 + 上线推送
- 消息持久化:全量持久化 + 本地缓存
- 消息顺序保证:严格顺序
核心特点:
- 灵活的配置策略,可适应不同业务场景
- 平衡各种需求,兼顾实时性和可靠性
- 支持复杂业务逻辑
优点:
- ✅ 灵活性强,可适应不同业务场景
- ✅ 平衡各种需求,兼顾实时性和可靠性
- ✅ 扩展性好,支持复杂业务逻辑
- ✅ 用户体验良好,响应迅速
- ✅ 支持离线使用和消息撤回
缺点:
- ❌ 实现最复杂
- ❌ 维护成本高
- ❌ 需要精心设计
3.3.4 比较总结
| 比较维度 | 方案一:传统企业级IM方案 | 方案二:轻量级实时IM方案 | 方案三:混合弹性IM方案(自定义模式) |
|---|---|---|---|
| 适用场景 | 大型企业应用,多设备同步,高可靠性要求 | 中小型应用,实时性要求高,云端存储为主 | 需要平衡各种需求,业务复杂度高 |
| 消息发送 | 存储转发模式 | 直推模式 | 存储转发模式 |
| 消息存储 | 混合存储 | 云端存储为主 | 混合存储,重要数据服务端,辅助数据客户端 |
| 消息同步 | 实时同步 + 增量同步 | 实时同步 | 实时同步 + 定时同步 + 增量同步 |
| 消息撤回 | 宽松撤回(3分钟内) | 严格撤回(仅未读消息) | 宽松撤回(可配置时间限制) |
| 已读回执 | 自动已读 | 手动已读 | 自动已读 + 滚动已读 |
| 离线消息 | 离线缓存 | 离线丢失 | 离线缓存 + 上线推送 |
| 消息持久化 | 全量持久化 | 部分持久化 | 全量持久化 + 本地缓存 |
| 消息顺序保证 | 严格顺序 | 宽松顺序 | 严格顺序 |
| 优点 | ✅ 消息可靠性高,不丢失 ✅ 数据一致性高,多端自动同步 ✅ 支持离线使用 ✅ 支持消息撤回和已读回执 | ✅ 实时性极佳 ✅ 客户端轻量级 ✅ 多设备体验一致 ✅ 云端数据安全可靠 | ✅ 灵活性强,可适应不同业务场景 ✅ 平衡各种需求,兼顾实时性和可靠性 ✅ 扩展性好,支持复杂业务逻辑 ✅ 用户体验良好,响应迅速 |
| 缺点 | ❌ 服务端负载较重 ❌ 网络依赖性强 ❌ 实现复杂度较高 | ❌ 离线体验差 ❌ 网络依赖性强 ❌ 消息可能丢失 | ❌ 实现最复杂 ❌ 维护成本高 ❌ 需要精心设计 |
四、方案三(自定义模式)的详细设计与实现
4.1 方案概述
方案三采用服务端主导的混合存储策略,平衡了实时性、可靠性和用户体验。
核心设计理念:
- 服务端作为权威数据源:所有消息数据由服务端统一管理和生成
- 客户端智能缓存:客户端缓存消息数据用于离线访问和快速响应
- 实时+增量同步:通过WebSocket实时推送新消息,按需拉取历史消息
- 最终一致性:允许短暂的数据不一致,通过同步机制保证最终一致
维度组合:
| 维度 | 方案 | 说明 |
|---|---|---|
| 消息发送 | 存储转发 | 消息先存储再推送 |
| 消息存储 | 混合存储 | 重要数据服务端,辅助数据客户端 |
| 消息同步 | 实时+增量 | WebSocket推送 + HTTP拉取 |
| 消息撤回 | 宽松撤回 | 支持可配置时间限制的撤回 |
| 已读回执 | 自动已读 | 用户打开会话即标记已读 |
| 离线消息 | 离线缓存+推送 | 离线期间缓存,上线后推送 |
| 消息持久化 | 全量+本地缓存 | 服务端全量存储,客户端本地缓存 |
| 消息顺序保证 | 严格顺序 | 消息严格按发送顺序展示 |
4.2 数据模型设计
4.2.1 服务端数据模型
服务端采用的表设计,详见第2.2.2.1节。核心表包括:
message_base_info:消息基本信息message_attachment:消息附件(图片、语音、视频、文件)message_status:消息状态(已读/未读/送达等)message_index:消息索引(针对经常查询的字段)message_revoke_logs:消息撤回记录message_ext_info:消息扩展信息
设计要点:
- 消息ID由服务端统一生成(
{timestamp}_{sequence}_{node_id}) - 每个用户在
message_status表中有独立的记录,管理个人状态(已读/未读/送达等) - 通过
message_index表针对经常查询的字段建立索引,提升查询性能 - 通过
version字段实现乐观锁,支持离线同步和冲突解决
4.2.2 客户端数据模型
客户端使用SQLite存储消息缓存数据,用于离线访问和快速UI渲染。
客户端消息表(messages):
CREATE TABLE messages (
msg_id TEXT PRIMARY KEY, -- 消息ID(与服务端一致)
session_id TEXT NOT NULL, -- 会话ID
from_user_id INTEGER NOT NULL, -- 发送方用户ID
to_user_id INTEGER NOT NULL, -- 接收方用户ID
to_type INTEGER NOT NULL, -- 接收方类型:1-用户,2-群组
message_type INTEGER NOT NULL, -- 消息类型
content TEXT, -- 消息内容
media_url TEXT, -- 媒体文件URL
media_thumb_url TEXT, -- 媒体缩略图URL
media_size INTEGER, -- 媒体文件大小
media_duration INTEGER, -- 媒体时长
extra_data TEXT, -- 扩展数据(JSON格式)
status INTEGER DEFAULT 0, -- 消息状态:0-发送中,1-发送成功,2-发送失败
is_deleted INTEGER DEFAULT 0, -- 是否删除:0-否,1-是
send_time INTEGER NOT NULL, -- 发送时间戳
read_time INTEGER, -- 已读时间戳
created_at INTEGER, -- 创建时间
updated_at INTEGER, -- 更新时间
sync_time INTEGER, -- 与服务端同步时间
server_version INTEGER DEFAULT 0 -- 服务端数据版本号
);
-- 创建索引
CREATE INDEX idx_session_id ON messages(session_id);
CREATE INDEX idx_from_user_id ON messages(from_user_id);
CREATE INDEX idx_to_user_id ON messages(to_user_id);
CREATE INDEX idx_send_time ON messages(send_time DESC);
CREATE INDEX idx_status ON messages(status);
客户端同步状态表(message_sync_state):
CREATE TABLE message_sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT, -- 会话ID
last_sync_time INTEGER, -- 上次同步时间戳
last_sync_version INTEGER, -- 上次同步时的服务端版本号
last_message_id TEXT, -- 上次同步的最后一条消息ID
pending_operations TEXT, -- 待同步的操作列表(JSON格式)
updated_at INTEGER -- 更新时间
);
CREATE INDEX idx_session_id ON message_sync_state(session_id);
数据分类与存储策略:
| 数据类型 | 存储位置 | 同步策略 | 说明 |
|---|---|---|---|
| 消息ID | 服务端+客户端 | 必须同步 | 服务端生成,客户端使用 |
| 消息内容 | 服务端+客户端 | 必须同步 | 确保多端一致 |
| 消息类型 | 服务端+客户端 | 必须同步 | 确保多端一致 |
| 消息状态 | 服务端+客户端 | 实时同步 | 发送状态实时更新 |
| 已读状态 | 服务端+客户端 | 实时同步 | 已读状态实时同步 |
| 撤回状态 | 服务端+客户端 | 实时同步 | 撤回状态实时同步 |
| UI展示状态 | 客户端 | 本地存储 | 仅影响当前设备 |
| 滚动位置 | 客户端 | 本地存储 | 仅影响当前设备 |
| 临时缓存 | 客户端 | 本地存储 | 仅影响当前设备 |
4.3 核心流程设计
4.3.1 初始化与首次同步
流程说明:
- 应用启动,建立WebSocket连接
- 从本地数据库加载缓存的消息数据
- 通过HTTP请求从服务端拉取最新消息列表
- 合并本地数据和服务器数据(以服务器为准)
- 更新本地数据库和UI
📋 点击展开/收起 初始化流程代码示例
/**
* 前端部分(渲染进程) - 消息管理器初始化
*/
class MessageManager {
private messages: Map<string, Message> = new Map();
private sessionSyncStates: Map<string, MessageSyncState> = new Map();
private isOnline: boolean = true;
/**
* 初始化消息管理器
*/
async initialize(): Promise<void> {
// 1. 从本地数据库加载消息
const localMessages = await this.loadMessagesFromLocalDB();
localMessages.forEach(message => {
this.messages.set(message.msgId, message);
});
// 2. 从本地数据库加载同步状态
const syncStates = await this.loadSyncStates();
syncStates.forEach(state => {
this.sessionSyncStates.set(state.sessionId, state);
});
// 3. 从服务端同步最新消息
await this.syncMessagesFromServer();
// 4. 更新UI
this.updateUI();
console.log('消息管理器初始化完成');
}
/**
* 从本地数据库加载消息
*/
private async loadMessagesFromLocalDB(): Promise<Message[]> {
return await window.ipcRenderer.invoke('messages:getAll');
}
/**
* 从本地数据库加载同步状态
*/
private async loadSyncStates(): Promise<MessageSyncState[]> {
return await window.ipcRenderer.invoke('syncStates:getAll');
}
/**
* 从服务端同步消息列表
*/
async syncMessagesFromServer(): Promise<void> {
try {
// 遍历所有会话,同步每个会话的消息
for (const [sessionId, syncState] of this.sessionSyncStates) {
await this.syncSessionMessages(sessionId, syncState);
}
// 持久化到本地数据库
await this.saveMessagesToLocalDB();
// 更新UI
this.updateUI();
console.log('消息同步完成');
} catch (error) {
console.error('同步消息失败:', error);
// 同步失败时,使用本地缓存数据,不影响用户体验
}
}
/**
* 同步指定会话的消息
*/
private async syncSessionMessages(sessionId: string, syncState: MessageSyncState): Promise<void> {
// 请求服务端消息列表(增量同步)
const response = await fetch(`/api/messages?sessionId=${sessionId}&since=${syncState.lastSyncTime}&after=${syncState.lastMessageId}`);
if (!response.ok) {
throw new Error(`同步失败: ${response.statusText}`);
}
const data = await response.json();
// 更新本地消息
for (const message of data.messages) {
this.updateOrInsertMessage(message);
}
// 更新同步状态
syncState.lastSyncTime = data.syncTime;
syncState.lastSyncVersion = data.syncVersion;
if (data.messages.length > 0) {
syncState.lastMessageId = data.messages[data.messages.length - 1].msgId;
}
await this.saveSyncState(syncState);
console.log(`会话 ${sessionId} 同步了 ${data.messages.length} 条消息`);
}
/**
* 更新或插入消息
*/
private updateOrInsertMessage(message: Message): void {
const existing = this.messages.get(message.msgId);
if (existing) {
// 版本检查:只更新服务端版本更高的数据
if (message.serverVersion > existing.serverVersion) {
this.messages.set(message.msgId, message);
}
} else {
// 新增消息
this.messages.set(message.msgId, message);
}
}
/**
* 保存消息到本地数据库
*/
private async saveMessagesToLocalDB(): Promise<void> {
const messages = Array.from(this.messages.values());
await window.ipcRenderer.invoke('messages:batchSave', messages);
}
/**
* 保存同步状态
*/
private async saveSyncState(syncState: MessageSyncState): Promise<void> {
await window.ipcRenderer.invoke('syncStates:save', syncState);
this.sessionSyncStates.set(syncState.sessionId, syncState);
}
/**
* 更新UI
*/
private updateUI(): void {
const messages = this.getSortedMessages();
this.emit('messages:updated', messages);
}
/**
* 获取排序后的消息列表
*/
private getSortedMessages(): Message[] {
return Array.from(this.messages.values())
.filter(m => !m.isDeleted && !m.isRevoked)
.sort((a, b) => a.sendTime - b.sendTime);
}
}
/**
* 前端部分(主进程) - SQLite消息服务
*/
@Injectable()
export class MessageStorageService {
constructor(
@InjectDatabase() private database: Database
) {}
/**
* 批量保存消息
*/
async saveMessages(messages: Message[]): Promise<void> {
const stmt = await this.database.prepare(`
INSERT OR REPLACE INTO messages (
msg_id, session_id, from_user_id, to_user_id, to_type,
message_type, content, media_url, media_thumb_url,
media_size, media_duration, extra_data, status,
is_deleted, send_time, read_time,
created_at, updated_at, sync_time, server_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const message of messages) {
await stmt.run(
message.msgId,
message.sessionId,
message.fromUserId,
message.toUserId,
message.toType,
message.messageType,
message.content,
message.mediaUrl,
message.mediaThumbUrl,
message.mediaSize,
message.mediaDuration,
JSON.stringify(message.extraData || {}),
message.status,
message.isRevoked ? 1 : 0,
message.isDeleted ? 1 : 0,
message.sendTime,
message.readTime,
message.createdAt,
message.updatedAt,
Date.now(),
message.serverVersion
);
}
await stmt.finalize();
}
/**
* 获取所有消息
*/
async getAllMessages(): Promise<Message[]> {
const rows = await this.database.all(`
SELECT * FROM messages
ORDER BY send_time ASC
`);
return rows.map(this.mapRowToMessage);
}
/**
* 根据会话ID获取消息
*/
async getMessagesBySessionId(sessionId: string): Promise<Message[]> {
const rows = await this.database.all(`
SELECT * FROM messages WHERE session_id = ?
ORDER BY send_time ASC
`, [sessionId]);
return rows.map(this.mapRowToMessage);
}
}
/**
* 后端部分 - 同步服务
*/
@Injectable()
export class MessageSyncService {
/**
* 增量同步消息列表
*/
async incrementalSync(sessionId: string, since: number, after: string): Promise<SyncResponse> {
// 1. 查询指定时间之后更新的消息
const messages = await this.messageRepository.getMessagesBySessionIdSince(sessionId, since, after);
// 2. 获取最新消息ID
const lastMessageId = messages.length > 0 ? messages[messages.length - 1].messageId : after;
// 3. 获取当前同步版本号
const syncVersion = await this.getLatestSyncVersion();
const syncTime = Date.now();
return {
messages,
syncTime,
syncVersion,
lastMessageId
};
}
/**
* 获取最新的同步版本号
*/
private async getLatestSyncVersion(): Promise<number> {
const result = await this.messageRepository.getLatestVersion();
return result || 0;
}
}
4.3.2 消息发送(存储转发模式)
流程说明:
- 用户在客户端创建消息
- 客户端将消息发送到服务端
- 服务端生成消息ID并存储到数据库
- 服务端确认消息已存储
- 服务端推送消息给接收方
- 客户端更新本地消息状态
消息ID生成规则:
- 消息ID:
{timestamp}_{sequence}_{node_id}(例如:1704123456789_0001_01)
📋 点击展开/收起 消息发送代码示例
/**
* 前端部分(渲染进程) - 消息发送服务
*/
class MessageSender {
/**
* 发送消息 - 存储转发模式
* 消息先存储到服务端,再推送给接收方
*/
async sendMessage(sessionId: string, content: string, messageType: MessageType = MessageType.Text): Promise<string> {
// 1. 生成临时消息ID
const tempId = this.generateTempMessageId();
// 2. 构造消息对象
const message: MessageDTO = {
tempId,
sessionId,
fromId: getCurrentUserId(),
toId: await this.getToUserId(sessionId),
scene: await this.getScene(sessionId),
content,
type: messageType,
timestamp: Date.now()
};
try {
// 3. 先在本地创建消息(状态为发送中)
const localMessage: Message = {
msgId: tempId,
sessionId: message.sessionId,
fromUserId: message.fromId,
toUserId: message.toId,
toType: message.scene === Scene.GroupChat ? 2 : 1,
messageType: message.type,
content: message.content,
status: MessageStatus.Sending,
isRevoked: false,
isDeleted: false,
sendTime: message.timestamp,
createdAt: Date.now(),
updatedAt: Date.now(),
syncTime: Date.now(),
serverVersion: 0
};
// 4. 保存到本地数据库
await this.saveMessageToLocal(localMessage);
// 5. 通过WebSocket发送消息到服务端
const result = await window.ipcRenderer.invoke('ws:send', {
type: 'message',
data: message
});
// 6. 服务端返回消息ID,表示消息已存储成功
console.log('消息发送成功,消息ID:', result.messageId);
// 7. 更新本地消息ID和状态
await this.updateMessageId(tempId, result.messageId);
await this.updateMessageStatus(result.messageId, MessageStatus.Sent);
return result.messageId;
} catch (error) {
console.error('消息发送失败:', error);
// 8. 更新本地消息状态为发送失败
await this.updateMessageStatus(tempId, MessageStatus.Failed);
throw error;
}
}
/**
* 生成临时消息ID
*/
private generateTempMessageId(): string {
return `temp_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* 获取目标用户ID
*/
private async getToUserId(sessionId: string): Promise<number> {
const session = await this.sessionService.getSessionById(sessionId);
return session.targetId;
}
/**
* 获取会话场景
*/
private async getScene(sessionId: string): Promise<Scene> {
const session = await this.sessionService.getSessionById(sessionId);
return session.sessionType === SessionType.Group ? Scene.GroupChat : Scene.PrivateChat;
}
/**
* 保存消息到本地数据库
*/
private async saveMessageToLocal(message: Message): Promise<void> {
await window.ipcRenderer.invoke('messages:save', message);
}
/**
* 更新消息ID
*/
private async updateMessageId(tempId: string, messageId: string): Promise<void> {
await window.ipcRenderer.invoke('messages:updateId', { tempId, messageId });
}
/**
* 更新消息状态
*/
private async updateMessageStatus(messageId: string, status: MessageStatus): Promise<void> {
await window.ipcRenderer.invoke('messages:updateStatus', { messageId, status });
}
}
/**
* 前端部分(主进程) - SQLite消息更新服务
*/
@Injectable()
export class MessageUpdateService {
constructor(
@InjectDatabase() private database: Database
) {}
/**
* 更新消息ID
*/
async updateMessageId(tempId: string, messageId: string): Promise<void> {
await this.database.run(`
UPDATE messages SET msg_id = ? WHERE msg_id = ?
`, [messageId, tempId]);
}
/**
* 更新消息状态
*/
async updateMessageStatus(messageId: string, status: MessageStatus): Promise<void> {
await this.database.run(`
UPDATE messages SET status = ?, updated_at = ? WHERE msg_id = ?
`, [status, Date.now(), messageId]);
}
}
/**
* 后端部分 - 消息处理服务(存储转发模式)
*/
@Injectable()
export class MessageService {
constructor(
private messageRepository: MessageRepository,
private sessionService: SessionService,
private messageStatusRepository: MessageStatusRepository,
private messageRevokeLogsRepository: MessageRevokeLogsRepository,
private webSocketGateway: MessageWebSocketGateway
) {}
/**
* 处理消息(存储转发模式)
*/
async handleMessage(message: MessageDTO): Promise<{ messageId: string }> {
// 1. 生成消息ID: {timestamp}_{sequence}_{node_id}
const messageId = this.generateMessageId();
// 2. 保存消息到数据库
const savedMessage = await this.messageRepository.save({
messageId,
sessionId: message.sessionId,
fromUserId: message.fromId,
toUserId: message.toId,
toType: message.scene === Scene.GroupChat ? 2 : 1,
messageType: message.type,
content: message.content,
status: MessageStatus.Sent,
isRevoked: false,
isDeleted: false,
sendTime: message.timestamp,
createdAt: Date.now(),
updatedAt: Date.now(),
version: 0
});
// 3. 创建已读状态记录
if (message.scene === Scene.PrivateChat) {
// 单聊:为接收方创建状态记录
await this.messageStatusRepository.create({
messageId,
userId: message.toId,
isRead: false,
isDelivered: false,
createdAt: Date.now(),
updatedAt: Date.now()
});
} else {
// 群聊:为所有群成员创建状态记录
const members = await this.sessionService.getMembersBySessionId(message.sessionId);
for (const member of members) {
if (member.userId !== message.fromId) {
await this.messageStatusRepository.create({
messageId,
userId: member.userId,
isRead: false,
isDelivered: false,
createdAt: Date.now(),
updatedAt: Date.now()
});
}
}
}
// 4. 推送消息给接收方
await this.pushMessageToReceiver(savedMessage, message.scene);
// 5. 更新会话的最后消息
await this.sessionService.updateLastMessage(message.sessionId, {
messageId: savedMessage.messageId,
content: message.content,
type: message.type,
timestamp: message.timestamp
});
return { messageId };
}
/**
* 生成消息ID: {timestamp}_{sequence}_{node_id}
*/
private generateMessageId(): string {
const timestamp = Date.now();
const sequence = this.getNextSequence(timestamp);
const nodeId = this.getNodeId();
return `${timestamp}_${sequence}_${nodeId}`;
}
/**
* 获取序列号(同一毫秒内的递增序列)
*/
private getNextSequence(timestamp: number): number {
// 实现序列号生成逻辑
// 可以使用Redis或内存计数器
return 1;
}
/**
* 获取节点ID
*/
private getNodeId(): string {
// 可以从配置文件读取或自动生成
return '01';
}
/**
* 推送消息给接收方
*/
private async pushMessageToReceiver(message: Message, scene: Scene): Promise<void> {
if (scene === Scene.PrivateChat) {
// 单聊:推送给接收方
await this.webSocketGateway.sendToUser(message.toUserId, {
type: 'new_message',
data: message
});
} else {
// 群聊:推送给所有群成员(除了发送方)
const members = await this.sessionService.getMembersBySessionId(message.sessionId);
for (const member of members) {
if (member.userId !== message.fromUserId) {
await this.webSocketGateway.sendToUser(member.userId, {
type: 'new_message',
data: message
});
}
}
}
}
}
4.3.3 消息接收(实时推送)
流程说明:
- 服务端检测到新消息
- 服务端通过WebSocket推送新消息给接收方
- 客户端接收WebSocket消息,解析新消息事件
- 客户端将消息存储到本地数据库
- 客户端更新UI展示
📋 点击展开/收起 消息接收代码示例
/**
* 前端部分(渲染进程) - WebSocket监听新消息
*/
class MessageManager {
private wsConnection: WebSocket | null = null;
/**
* 连接WebSocket
*/
connectWebSocket(): void {
this.wsConnection = new WebSocket('ws://your-server/ws');
this.wsConnection.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'new_message':
this.handleNewMessage(message.data);
break;
case 'message_update':
this.handleMessageUpdate(message.data);
break;
case 'message_revoked':
this.handleMessageRevoked(message.data);
break;
default:
break;
}
};
}
/**
* 处理新消息事件
*/
handleNewMessage(messageData: any): void {
const message: Message = {
msgId: messageData.messageId,
sessionId: messageData.sessionId,
fromUserId: messageData.fromUserId,
toUserId: messageData.toUserId,
toType: messageData.toType,
messageType: messageData.messageType,
content: messageData.content,
mediaUrl: messageData.mediaUrl,
mediaThumbUrl: messageData.mediaThumbUrl,
mediaSize: messageData.mediaSize,
mediaDuration: messageData.mediaDuration,
extraData: messageData.extraData,
status: MessageStatus.Sent,
isRevoked: false,
isDeleted: false,
sendTime: messageData.sendTime,
createdAt: Date.now(),
updatedAt: Date.now(),
syncTime: Date.now(),
serverVersion: messageData.version
};
// 保存到本地数据库
this.messages.set(message.msgId, message);
this.saveMessageToLocalDB(message);
// 更新UI
this.updateUI();
}
/**
* 处理消息更新事件
*/
handleMessageUpdate(event: MessageUpdateEvent): void {
const { messageId, update } = event;
const message = this.messages.get(messageId);
if (message) {
// 更新现有消息
Object.assign(message, update);
this.saveMessageToLocalDB(message);
this.updateUI();
}
}
/**
* 处理消息撤回事件
*/
handleMessageRevoked(event: MessageRevokeEvent): void {
const { messageId } = event;
const message = this.messages.get(messageId);
if (message) {
// 更新消息为已撤回状态
message.isRevoked = true;
this.saveMessageToLocalDB(message);
this.updateUI();
}
}
/**
* 保存单个消息到本地数据库
*/
private async saveMessageToLocalDB(message: Message): Promise<void> {
await window.ipcRenderer.invoke('messages:save', message);
}
/**
* 更新UI
*/
private updateUI(): void {
const messages = this.getSortedMessages();
this.emit('messages:updated', messages);
}
/**
* 获取排序后的消息列表
*/
private getSortedMessages(): Message[] {
return Array.from(this.messages.values())
.filter(m => !m.isDeleted && !m.isRevoked)
.sort((a, b) => a.sendTime - b.sendTime);
}
}
/**
* 后端部分 - WebSocket网关
* 用于处理客户端WebSocket连接和消息推送
*/
@WebSocketGateway()
export class MessageWebSocketGateway {
constructor(
private eventBus: EventBus
) {}
@SubscribeMessage('message')
handleMessage(client: Socket, payload: MessageDTO): void {
// 转发消息到消息处理服务
this.eventBus.emit('message:handle', payload);
}
/**
* 向指定用户发送WebSocket消息
*/
sendToUser(userId: number, message: any): void {
// 实现向指定用户发送消息的逻辑
}
}
4.3.4 消息撤回
核心设计原则:
- 时间限制:支持可配置的撤回时间限制(默认2分钟)
- 已读限制:仅支持撤回未读消息
- 撤回通知:撤回后推送通知给所有客户端
- 审计记录:记录所有撤回操作
📋 点击展开/收起 消息撤回代码示例
/**
* 前端部分(渲染进程) - 消息撤回服务
*/
class MessageRevokeService {
/**
* 撤回消息
*/
async revokeMessage(messageId: string): Promise<void> {
const message = this.messages.get(messageId);
if (!message) {
throw new Error('消息不存在');
}
// 1. 检查是否可以撤回
const canRevoke = await this.checkCanRevoke(message);
if (!canRevoke) {
throw new Error('消息无法撤回');
}
try {
// 2. 请求服务端撤回消息
await fetch(`/api/messages/${messageId}/revoke`, {
method: 'POST'
});
console.log('消息撤回请求已发送');
// 服务端会通过WebSocket推送撤回通知,客户端接收后自动更新UI
} catch (error) {
console.error('撤回消息失败:', error);
throw error;
}
}
/**
* 检查消息是否可以撤回
*/
private async checkCanRevoke(message: Message): Promise<boolean> {
const currentUserId = getCurrentUserId();
// 1. 检查是否是发送方
if (message.fromUserId !== currentUserId) {
console.log('只能撤回自己发送的消息');
return false;
}
// 2. 检查是否已撤回
if (message.isRevoked) {
console.log('消息已被撤回');
return false;
}
// 3. 检查是否已读
const isRead = await this.checkMessageRead(message);
if (isRead) {
console.log('消息已读,无法撤回');
return false;
}
// 4. 检查是否超时(2分钟)
const now = Date.now();
const revokeTimeLimit = 2 * 60 * 1000; // 2分钟
if (now - message.sendTime > revokeTimeLimit) {
console.log('消息发送超过2分钟,无法撤回');
return false;
}
return true;
}
/**
* 检查消息是否已读
*/
private async checkMessageRead(message: Message): Promise<boolean> {
// 单聊:检查接收方是否已读
// 群聊:检查是否有成员已读
// 这里简化处理,实际需要从服务端查询
return false;
}
}
/**
* 后端部分 - 消息撤回服务
*/
@Injectable()
export class MessageRevokeService {
constructor(
private messageRepository: MessageRepository,
private messageRevokeLogsRepository: MessageRevokeLogsRepository,
private webSocketGateway: MessageWebSocketGateway,
private sessionService: SessionService
) {}
/**
* 撤回消息
*/
async revokeMessage(messageId: string, userId: number): Promise<void> {
// 1. 检查消息是否存在
const message = await this.messageRepository.getMessageById(messageId);
if (!message) {
throw new BadRequestException('消息不存在');
}
// 2. 检查是否是发送方
if (message.fromUserId !== userId) {
throw new BadRequestException('只能撤回自己发送的消息');
}
// 3. 检查是否已撤回
if (message.isRevoked) {
throw new BadRequestException('消息已被撤回');
}
// 4. 检查是否已读
const isRead = await this.checkMessageRead(message);
if (isRead) {
throw new BadRequestException('消息已读,无法撤回');
}
// 5. 检查是否超时(2分钟)
const now = Date.now();
const revokeTimeLimit = 2 * 60 * 1000; // 2分钟
if (now - message.sendTime > revokeTimeLimit) {
throw new BadRequestException('消息发送超过2分钟,无法撤回');
}
// 6. 更新消息为已撤回状态
await this.messageRepository.updateRevokedStatus(messageId, true);
// 7. 记录撤回操作日志
await this.messageRevokeLogsRepository.save({
messageId,
operatorId: userId,
revokeTime: Date.now(),
createdAt: Date.now()
});
// 8. 推送撤回通知给所有相关方
await this.pushRevokeNotification(message);
}
/**
* 检查消息是否已读
*/
private async checkMessageRead(message: Message): Promise<boolean> {
// 单聊:检查接收方是否已读
if (message.toType === 1) {
const status = await this.messageStatusRepository.getByMessageIdAndUserId(
message.messageId,
message.toUserId
);
return status?.isRead || false;
} else {
// 群聊:检查是否有成员已读
const statuses = await this.messageStatusRepository.getByMessageId(message.messageId);
return statuses.some(status => status.isRead);
}
}
/**
* 推送撤回通知
*/
private async pushRevokeNotification(message: Message): Promise<void> {
if (message.toType === 1) {
// 单聊:推送给发送方和接收方
await this.webSocketGateway.sendToUser(message.fromUserId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
await this.webSocketGateway.sendToUser(message.toUserId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
} else {
// 群聊:推送给所有群成员
const members = await this.sessionService.getMembersBySessionId(message.sessionId);
for (const member of members) {
await this.webSocketGateway.sendToUser(member.userId, {
type: 'message_revoked',
data: { messageId: message.messageId }
});
}
}
}
}
4.3.5 已读回执
核心设计原则:
- 自动已读:用户打开会话即标记消息已读
- 用户级已读:每个用户独立标记已读状态
- 实时同步:已读状态变化时实时推送
- 精确计时:记录已读时间戳
📋 点击展开/收起 已读回执代码示例
/**
* 前端部分(渲染进程) - 已读回执服务
*/
class MessageReadService {
/**
* 标记会话消息为已读
* 用户打开会话时调用,标记该会话所有未读消息为已读
*/
async markSessionAsRead(sessionId: string): Promise<void> {
const currentUserId = getCurrentUserId();
// 1. 获取该会话的所有未读消息
const unreadMessages = Array.from(this.messages.values())
.filter(m => m.sessionId === sessionId && !m.isRead);
if (unreadMessages.length === 0) {
return;
}
try {
// 2. 请求服务端标记已读
await fetch(`/api/messages/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId,
userId: currentUserId
})
});
console.log(`已发送标记已读请求,共 ${unreadMessages.length} 条消息`);
// 服务端会通过WebSocket推送已读状态更新,客户端接收后自动更新UI
} catch (error) {
console.error('标记已读失败:', error);
// 网络错误时,先更新本地UI,等待下次同步
unreadMessages.forEach(message => {
message.isRead = true;
message.readTime = Date.now();
});
this.updateUI();
}
}
/**
* 处理已读状态更新事件
*/
handleMessageReadUpdate(event: MessageReadUpdateEvent): void {
const { messageId, userId, isRead, readTime } = event;
// 如果是自己标记已读,更新本地消息
if (userId === getCurrentUserId()) {
const message = this.messages.get(messageId);
if (message) {
message.isRead = isRead;
message.readTime = readTime;
this.saveMessageToLocalDB(message);
this.updateUI();
}
}
}
}
/**
* 后端部分 - 已读回执服务
*/
@Injectable()
export class MessageReadService {
constructor(
private messageStatusRepository: MessageStatusRepository,
private webSocketGateway: MessageWebSocketGateway,
private sessionService: SessionService
) {}
/**
* 标记会话消息为已读
*/
async markSessionAsRead(sessionId: string, userId: number): Promise<void> {
// 1. 获取该会话的所有未读消息
const unreadMessages = await this.messageRepository.getUnreadMessagesBySessionId(sessionId, userId);
// 2. 标记每条消息为已读
const readTime = Date.now();
for (const message of unreadMessages) {
await this.messageStatusRepository.updateReadStatus(message.messageId, userId, true, readTime);
}
// 3. 推送已读状态更新给发送方
for (const message of unreadMessages) {
await this.pushReadNotification(message, userId, true, readTime);
}
// 4. 更新会话未读数
await this.sessionService.resetUnreadCount(sessionId, userId);
}
/**
* 推送已读状态更新
*/
private async pushReadNotification(message: Message, userId: number, isRead: boolean, readTime: number): Promise<void> {
// 推送给发送方
await this.webSocketGateway.sendToUser(message.fromUserId, {
type: 'message_read_update',
data: {
messageId: message.messageId,
userId,
isRead,
readTime
}
});
}
}
4.3.6 消息删除
核心设计原则:
- 用户级删除:每条用户可以独立删除消息
- 软删除:只标记消息为已删除,保留历史记录
- 本地删除和服务端删除:支持仅本地删除或服务端删除
- 恢复功能:支持恢复已删除的消息(可选)
📋 点击展开/收起 消息删除代码示例
/**
* 前端部分(渲染进程) - 消息删除服务
*/
class MessageDeleteService {
/**
* 删除消息(本地删除)
* 仅在本地删除,不影响其他客户端
*/
async deleteMessageLocal(messageId: string): Promise<void> {
const message = this.messages.get(messageId);
if (!message) {
throw new Error('消息不存在');
}
try {
// 1. 标记本地消息为已删除
message.isDeleted = true;
// 2. 持久化到本地数据库
await this.saveMessageToLocalDB(message);
// 3. 更新UI
this.updateUI();
console.log('消息已本地删除');
} catch (error) {
console.error('删除消息失败:', error);
throw error;
}
}
/**
* 删除消息(服务端删除)
* 在服务端删除,所有客户端同步删除
*/
async deleteMessageServer(messageId: string): Promise<void> {
const message = this.messages.get(messageId);
if (!message) {
throw new Error('消息不存在');
}
try {
// 1. 请求服务端删除消息
await fetch(`/api/messages/${messageId}`, {
method: 'DELETE'
});
console.log('消息删除请求已发送');
// 服务端会通过WebSocket推送删除通知,客户端接收后自动更新UI
} catch (error) {
console.error('删除消息失败:', error);
throw error;
}
}
/**
* 处理消息删除事件
*/
handleMessageDeleted(event: MessageDeleteEvent): void {
const { messageId } = event;
const message = this.messages.get(messageId);
if (message) {
// 更新消息为已删除状态
message.isDeleted = true;
this.saveMessageToLocalDB(message);
this.updateUI();
}
}
}
/**
* 后端部分 - 消息删除服务
*/
@Injectable()
export class MessageDeleteService {
constructor(
private messageRepository: MessageRepository,
private webSocketGateway: MessageWebSocketGateway,
private sessionService: SessionService
) {}
/**
* 删除消息(服务端删除)
*/
async deleteMessage(messageId: string, userId: number): Promise<void> {
// 1. 检查消息是否存在
const message = await this.messageRepository.getMessageById(messageId);
if (!message) {
throw new BadRequestException('消息不存在');
}
// 2. 检查是否是发送方
if (message.fromUserId !== userId) {
throw new BadRequestException('只能删除自己发送的消息');
}
// 3. 标记消息为已删除(软删除)
await this.messageRepository.updateDeletedStatus(messageId, true);
// 4. 推送删除通知给所有相关方
await this.pushDeleteNotification(message);
}
/**
* 推送删除通知
*/
private async pushDeleteNotification(message: Message): Promise<void> {
if (message.toType === 1) {
// 单聊:推送给发送方和接收方
await this.webSocketGateway.sendToUser(message.fromUserId, {
type: 'message_deleted',
data: { messageId: message.messageId }
});
await this.webSocketGateway.sendToUser(message.toUserId, {
type: 'message_deleted',
data: { messageId: message.messageId }
});
} else {
// 群聊:推送给所有群成员
const members = await this.sessionService.getMembersBySessionId(message.sessionId);
for (const member of members) {
await this.webSocketGateway.sendToUser(member.userId, {
type: 'message_deleted',
data: { messageId: message.messageId }
});
}
}
}
}
4.4 数据类型定义
/**
* 消息类型
*/
enum MessageType {
Text = 1, // 文本消息
Image = 2, // 图片消息
Audio = 3, // 音频消息
Video = 4, // 视频消息
File = 5, // 文件消息
Location = 6, // 位置消息
System = 99 // 系统消息
}
/**
* 消息状态
*/
enum MessageStatus {
Sending = 0, // 发送中
Sent = 1, // 发送成功
Failed = 2 // 发送失败
}
/**
* 接收方类型
*/
enum ToType {
User = 1, // 用户(单聊)
Group = 2 // 群组(群聊)
}
/**
* 场景类型(用于区分单聊和群聊场景)
*/
enum Scene {
PrivateChat = 1, // 单聊场景
GroupChat = 2 // 群聊场景
}
/**
* 消息数据传输对象
*/
interface MessageDTO {
tempId?: string; // 临时消息ID(客户端生成,仅用于本地标识)
fromId: number; // 发送方用户ID
toId: number; // 接收方用户ID或群组ID
sessionId: string; // 会话ID
scene: Scene; // 场景类型
content: string; // 消息内容
type: MessageType; // 消息类型
timestamp: number; // 时间戳
}
/**
* 消息数据对象
*/
interface Message {
msgId: string; // 消息ID
sessionId: string; // 会话ID
fromUserId: number; // 发送方用户ID
toUserId: number; // 接收方用户ID
toType: ToType; // 接收方类型
messageType: MessageType;// 消息类型
content?: string; // 消息内容
mediaUrl?: string; // 媒体文件URL
mediaThumbUrl?: string; // 媒体缩略图URL
mediaSize?: number; // 媒体文件大小
mediaDuration?: number; // 媒体时长
extraData?: any; // 扩展数据(JSON格式)
status: MessageStatus; // 消息状态
isRevoked: boolean; // 是否撤回
isDeleted: boolean; // 是否删除
isRead: boolean; // 是否已读
sendTime: number; // 发送时间戳
readTime?: number; // 已读时间戳
createdAt: number; // 创建时间
updatedAt: number; // 更新时间
syncTime: number; // 同步时间
serverVersion: number; // 服务端版本号
}
/**
* 消息更新事件
*/
interface MessageUpdateEvent {
messageId: string; // 消息ID
update: Partial<Message>; // 更新的字段
}
/**
* 消息撤回事件
*/
interface MessageRevokeEvent {
messageId: string; // 消息ID
}
/**
* 消息删除事件
*/
interface MessageDeleteEvent {
messageId: string; // 消息ID
}
/**
* 消息已读更新事件
*/
interface MessageReadUpdateEvent {
messageId: string; // 消息ID
userId: number; // 用户ID
isRead: boolean; // 是否已读
readTime: number; // 已读时间戳
}
/**
* 同步状态
*/
interface MessageSyncState {
sessionId: string; // 会话ID
lastSyncTime: number; // 上次同步时间
lastSyncVersion: number; // 上次同步版本号
lastMessageId: string; // 上次同步的最后一条消息ID
pendingOperations: PendingOperation[];// 待同步的操作
}
/**
* 待同步的操作
*/
interface PendingOperation {
type: 'send' | 'revoke' | 'delete'; // 操作类型
messageData?: any; // 消息数据
messageId?: string; // 消息ID
timestamp: number; // 操作时间
retryCount?: number; // 重试次数
}
/**
* 同步响应
*/
interface SyncResponse {
messages: Message[]; // 消息列表
syncTime: number; // 同步时间
syncVersion: number; // 同步版本号
lastMessageId: string; // 最后一条消息ID
}
五、总结
本文档全面介绍了IM消息管理方案的设计思路、技术选型和最佳实践。以下是对整个文档的核心要点总结。
5.1 消息管理核心概念
消息定义:
- 消息是指用户在会话中发送和接收的信息单元
- 每条消息都有唯一的标识符(messageId),由服务端生成
- 消息包含:发送方、接收方、内容、类型、状态、时间等核心信息
消息类型:
- 文本消息、图片消息、语音消息、视频消息、文件消息、位置消息、系统消息、自定义消息
消息状态:
- 发送中、发送成功、发送失败、送达、已读、撤回
消息核心功能:
- 消息发送:用户发送消息到服务端
- 消息接收:服务端将消息推送给接收方
- 消息存储:消息持久化存储,支持历史消息查询
- 消息同步:多设备间消息状态同步
- 消息撤回:用户撤回已发送的消息
- 消息重发:网络失败时的消息重发机制
- 已读回执:消息已读状态的确认和同步
- 消息搜索:根据关键词、时间等维度搜索历史消息
- 消息删除:用户删除本地或服务端消息
- 离线消息:用户离线期间的消息缓存和推送
5.2 数据模型设计
服务端表设计方案:
| 表名 | 核心职责 | 关键字段 |
|---|---|---|
message_base_info | 存储消息核心数据 | message_id, session_id, from_user_id, to_user_id, message_type, content, status, is_deleted, send_time |
message_attachment | 存储媒体文件信息 | attachment_id, message_id, attachment_type, file_url, file_name, file_size, file_format, width, height, duration, thumb_url |
message_status | 消息状态管理 | message_id, user_id, is_read, is_delivered, read_time, delivered_time |
message_index | 消息索引优化 | message_id, session_id, from_user_id, to_user_id, message_type, status, send_time |
message_revoke_logs | 撤回操作审计 | message_id, operator_id, revoke_time |
message_ext_info | 灵活扩展 | message_id, key, value |
设计亮点:
- 职责分离:每个表专注于单一职责,避免数据冗余
- 媒体统一管理:
message_attachment表统一管理所有媒体附件,便于扩展和维护 - 用户级状态:
message_status表让每个用户的状态(已读/未读/送达等)独立管理 - 查询优化:
message_index表针对经常查询的字段建立索引,大幅提升查询性能 - 扩展性:
message_ext_info为未来功能预留空间 - 审计追踪:
message_revoke_logs提供完整撤回历史 - 数据一致性:外键约束和级联删除确保数据完整性
5.3 消息ID生成规则
核心原则:所有消息ID均由服务端生成,客户端不参与任何ID生成逻辑
生成规则:
消息ID: {timestamp}_{sequence}_{node_id}
- timestamp: 毫秒级时间戳(13位)
- sequence: 同一毫秒内的序列号(4位,从0001到9999)
- node_id: 服务器节点ID(2位,01-99)
- 示例: 1704123456789_0001_01
为什么服务端生成:
- 跨端一致性:确保多个客户端对同一消息使用相同的ID
- 避免冲突:多端同时发送消息时,服务端统一生成ID避免冲突
- 消息去重:服务端生成ID便于消息去重和幂等性处理
- 时序保证:服务端可以根据时间戳和序列号保证消息ID的时序性
- 易于管理:服务端集中管理消息ID,便于后续的消息查询和管理
5.4 方案选型对比
三种主要方案:
| 对比维度 | 方案一:传统企业级IM方案 | 方案二:轻量级实时IM方案 | 方案三:混合弹性IM方案(自定义模式) |
|---|---|---|---|
| 适用场景 | 大型企业应用,多设备同步,高可靠性要求 | 中小型应用,实时性要求高,云端存储为主 | 需要平衡各种需求,业务复杂度高 |
| 消息发送 | 存储转发模式 | 直推模式 | 存储转发模式 |
| 消息存储 | 混合存储 | 云端存储为主 | 混合存储,重要数据服务端,辅助数据客户端 |
| 消息同步 | 实时同步 + 增量同步 | 实时同步 | 实时同步 + 定时同步 + 增量同步 |
| 消息撤回 | 宽松撤回(3分钟内) | 严格撤回(仅未读消息) | 宽松撤回(可配置时间限制) |
| 已读回执 | 自动已读 | 手动已读 | 自动已读 + 滚动已读 |
| 离线消息 | 离线缓存 | 离线丢失 | 离线缓存 + 上线推送 |
| 消息持久化 | 全量持久化 | 部分持久化 | 全量持久化 + 本地缓存 |
| 消息顺序保证 | 严格顺序 | 宽松顺序 | 严格顺序 |
| 优点 | 消息可靠性高,不丢失 数据一致性高,多端自动同步 支持离线使用 支持消息撤回和已读回执 | 实时性极佳 客户端轻量级 多设备体验一致 云端数据安全可靠 | 灵活性强,可适应不同业务场景 平衡各种需求,兼顾实时性和可靠性 扩展性好,支持复杂业务逻辑 用户体验良好,响应迅速 |
| 缺点 | 服务端负载较重 网络依赖性强 实现复杂度较高 | 离线体验差 网络依赖性强 消息可能丢失 | 实现最复杂 维护成本高 需要精心设计 |
推荐方案:方案三(混合弹性IM方案)适合大多数企业级IM应用,能够平衡各种需求,兼顾实时性和可靠性。
5.5 方案三核心设计要点
维度组合:
| 维度 | 方案 | 说明 |
|---|---|---|
| 消息发送 | 存储转发 | 消息先存储再推送 |
| 消息存储 | 混合存储 | 重要数据服务端,辅助数据客户端 |
| 消息同步 | 实时+增量 | WebSocket推送 + HTTP拉取 |
| 消息撤回 | 宽松撤回 | 支持可配置时间限制的撤回 |
| 已读回执 | 自动已读 | 用户打开会话即标记已读 |
| 离线消息 | 离线缓存+推送 | 离线期间缓存,上线后推送 |
| 消息持久化 | 全量+本地缓存 | 服务端全量存储,客户端本地缓存 |
| 消息顺序保证 | 严格顺序 | 消息严格按发送顺序展示 |
核心流程:
-
初始化与首次同步
- 应用启动,建立WebSocket连接
- 从本地数据库加载缓存的消息数据
- 通过HTTP请求从服务端拉取最新消息列表
- 合并本地数据和服务器数据(以服务器为准)
- 更新本地数据库和UI
-
消息发送(存储转发模式)
- 用户在客户端创建消息
- 客户端将消息发送到服务端
- 服务端生成消息ID并存储到数据库
- 服务端确认消息已存储
- 服务端推送消息给接收方
- 客户端更新本地消息状态
-
消息接收(实时推送)
- 服务端检测到新消息
- 服务端通过WebSocket推送新消息给接收方
- 客户端接收WebSocket消息,解析新消息事件
- 客户端将消息存储到本地数据库
- 客户端更新UI展示
-
消息撤回
- 支持可配置的撤回时间限制(默认2分钟)
- 仅支持撤回未读消息
- 撤回后推送通知给所有客户端
- 记录所有撤回操作,便于审计
-
已读回执
- 用户打开会话即标记消息已读
- 每条用户独立标记已读状态
- 已读状态变化时实时推送
- 记录已读时间戳
-
消息删除
- 每个用户可以独立删除消息
- 支持软删除,保留历史记录
- 支持本地删除和服务端删除
- 删除通知实时推送
-
离线消息与同步
- 乐观锁,通过
version字段实现冲突检测 - 增量同步,定期拉取变更数据
- 冲突解决,服务端数据优先,本地数据作为备份
- 操作队列,离线时的操作入队,上线后自动同步
- 乐观锁,通过
5.6 数据分类与存储策略
| 数据类型 | 存储位置 | 同步策略 | 说明 |
|---|---|---|---|
| 消息ID | 服务端+客户端 | 必须同步 | 服务端生成,客户端使用 |
| 消息内容 | 服务端+客户端 | 必须同步 | 确保多端一致 |
| 消息类型 | 服务端+客户端 | 必须同步 | 确保多端一致 |
| 消息状态 | 服务端+客户端 | 实时同步 | 发送状态实时更新 |
| 已读状态 | 服务端+客户端 | 实时同步 | 已读状态实时同步 |
| 撤回状态 | 服务端+客户端 | 实时同步 | 撤回状态实时同步 |
| UI展示状态 | 客户端 | 本地存储 | 仅影响当前设备 |
| 滚动位置 | 客户端 | 本地存储 | 仅影响当前设备 |
| 临时缓存 | 客户端 | 本地存储 | 仅影响当前设备 |
5.7 核心设计原则
- 服务端主导:所有消息数据由服务端统一管理和生成,服务端作为权威数据源
- 客户端智能缓存:客户端缓存消息数据用于离线访问和快速响应,减少与服务端的频繁交互
- 实时+增量同步:通过WebSocket实时推送新消息,按需拉取历史消息,平衡实时性和性能
- 最终一致性:允许短暂的数据不一致,通过同步机制保证最终一致
- 用户级状态管理:每条用户在
message_status表中有独立的记录,管理个人状态(已读/未读/送达等) - 乐观锁机制:通过
version字段实现冲突检测和解决 - 查询性能优化:通过
message_index表针对经常查询的字段建立索引,提升查询性能 - 操作日志审计:所有撤回操作都通过
message_revoke_logs审计追踪,便于追溯
5.8 实施建议
核心实现要点:
- 服务端统一生成和管理消息ID
- 客户端不参与ID生成,直接使用服务端返回的消息ID
- 采用存储转发模式,消息先存储再推送
- 通过WebSocket实时推送消息更新
- 支持离线使用,网络恢复后自动同步
- 实现多设备数据一致性保证
- 实现消息撤回和已读回执功能
- 实现消息删除和恢复功能
实施步骤:
- 设计服务端消息表
- 实现服务端消息ID生成逻辑(
{timestamp}_{sequence}_{node_id}) - 实现消息的存储转发机制
- 实现WebSocket实时推送
- 实现客户端缓存和离线同步
- 实现多设备数据一致性保证
- 实现消息撤回功能(时间限制+已读限制)
- 实现已读回执功能
- 实现消息删除功能
- 实现消息搜索功能
免责声明
- 技术文档性质:本文档为技术方案设计文档,内容基于通用技术实践和业界最佳实践编写
- 内容声明:文档中的技术方案、架构设计、代码示例等内容均为通用技术实现,不涉及任何特定公司或项目的商业机密、专利技术或内部架构
- 参考性质:本文档仅供技术参考和学习使用,不构成任何商业建议或技术实施承诺
- 使用风险:读者应根据自身项目的具体需求对本文档内容进行调整和优化,作者不对因使用本文档内容而造成的任何直接或间接损失承担责任
版权声明
本文档内容为原创技术文档,仅供学习交流使用。文档中的代码示例、架构设计等技术内容为通用技术实践,不涉及任何特定公司的商业机密。如需引用本文档内容,请注明出处。