一、简介
即时通讯(IM)系统的消息同步是确保用户在不同设备、不同网络状态下都能获得一致消息体验的核心技术。消息同步不仅仅是简单地将消息从一个设备传输到另一个设备,而是涉及实时性、可靠性、一致性、性能优化等多方面挑战的综合解决方案。
1.1 消息同步的核心价值
-
多设备一致性:用户可能在手机、电脑、平板等多个设备上使用IM应用,需要保证所有设备看到的消息历史、顺序和状态一致。
-
离线消息处理:用户网络不稳定或完全离线时,需要可靠存储消息并在网络恢复后同步。
-
实时通信体验:消息延迟需控制在毫秒级,提供流畅的实时对话体验。
-
数据可靠性:防止消息丢失、重复或乱序,保证通信的可靠性。
1.2 技术挑战
-
网络环境复杂:弱网、断网、网络切换等场景下的可靠传输
-
高并发处理:海量用户同时在线,消息吞吐量巨大
-
多端状态同步:消息的已读/送达状态在多设备间保持一致
-
存储与检索性能:海量消息的高效存储和快速检索
-
安全性保障:消息加密、防篡改、权限控制
1.3 同步维度
IM消息同步涉及多个层面的同步需求:
| 同步维度 | 描述 | 技术要点 |
|---|---|---|
| 消息内容同步 | 同步具体的消息文本、图片、文件等内容 | 增量同步、实时推送、离线队列 |
| 消息状态同步 | 同步消息的发送中/已发送/已送达/已读状态 | 状态广播、ACK机制 |
| 会话状态同步 | 同步会话的未读数、置顶、静音等状态 | 状态变更通知、增量更新 |
| 设备状态同步 | 同步多设备在线状态、同步进度 | 设备注册、状态追踪 |
二、业界的生产企业级方案
方案一:增量同步方案(基于版本号)
设计思路
核心思想:基于严格的递增序列号(SyncKey/Seq)只拉取上次同步之后的新消息,避免全量传输,提高同步效率。
设计要点:
-
序列号生成:使用分布式ID生成器(如雪花算法)生成严格递增的序列号
-
同步游标:客户端维护最后同步序列号(
last_sync_key),可以是全局的或按会话维护 -
增量查询:客户端携带
last_sync_key请求服务端,获取to_user_id = ? AND sync_key > last_sync_key的消息 -
分页机制:支持分页拉取,避免单次响应数据量过大
-
冲突处理:通过序列号保证消息顺序,处理消息重复等边界情况
-
状态同步:支持消息撤回、已读状态等操作的序列号关联
核心优势:
-
严格顺序:序列号严格递增,绝无重复,保证消息和操作的精确顺序
-
全局唯一:跨设备、跨服务器全局唯一,支持分布式部署
-
操作同步:每个操作(消息、撤回、已读等)都有独立的序列号,增量拉取时能拿到所有变更
-
断点续传:任意断点都可以精确恢复,不会漏掉任何数据
适用场景:
-
历史消息加载
-
网络恢复后的消息补全
-
多设备间的消息同步
-
离线消息同步
-
作为实时推送的兜底方案
流程图
sequenceDiagram
participant CR as 客户端(渲染进程)
participant CM as 客户端(主进程)
participant CD as 客户端(数据库)
participant S as 服务端
participant SD as 服务端数据库
Note over CR,CD: 初始化阶段(首次登录/启动应用)
CR->>CD: 读取本地last_sync_key(上次同步的序列号)
alt last_sync_key不存在(首次启动)
CR->>CR: 设置last_sync_key=0(触发全量拉取)
end
Note over CR,S: 增量同步阶段(事件驱动触发)
Note over CR: 触发时机:网络恢复、应用前台切换、定时兜底
CR->>S: HTTP POST /messages/sync?last_sync_key=xxx&page_size=100
Note over CR: 携带参数:user_id、session_id(可选)、last_sync_key、page_size
S->>S: 参数验证(验证last_sync_key合法性)
S->>SD: 查询sync_key > last_sync_key的消息
Note over SD: WHERE to_user_id=? AND sync_key>? ORDER BY sync_key ASC LIMIT 100<br/>如果指定session_id则追加:AND session_id=?
SD-->>S: 返回消息列表(含sync_key、message_id、content等)
alt 查询到新消息
S->>S: 按sync_key升序排序
S->>S: 分页限制结果(page_size=100)
S-->>CR: 返回消息列表(含nextSyncKey、has_more标志)
Note over S: 返回数据:messages[]、nextSyncKey、has_more、total
CR->>CM: IPC调用存储消息
CM->>CD: 事务开始(批量存储消息)
loop 遍历消息列表
CM->>CD: 检查消息是否已存在(基于message_id防重复)
alt 消息不存在
CM->>CD: 插入消息到本地数据库
end
end
CM->>CD: 事务提交
CD-->>CM: 批量存储成功
CM-->>CR: 存储完成
CR->>CD: 将nextSyncKey(最新序列号)更新到last_sync_key
CD-->>CR: 更新成功
CR->>CR: 刷新UI显示新消息
alt has_more=true(还有更多消息)
CR->>S: 请求下一页(?last_sync_key=yyy&page_size=100)
Note over CR: 使用返回的nextSyncKey作为新的游标
else has_more=false
CR->>CR: 标记同步完成
CR->>CD: 更新last_sync_time为当前时间戳
end
else 无新消息(last_sync_key已是最新)
S-->>CR: 返回空消息列表(has_more=false)
CR->>CR: 标记同步完成
CR->>CD: 更新last_sync_time为当前时间戳
end
Note over CR,CD: 消息操作同步(撤回、删除等)
Note over CR: 服务端会推送或客户端主动拉取操作记录
alt 收到消息撤回操作(operation_sync_key > last_sync_key)
CR->>S: 请求操作记录(?last_sync_key=xxx)
S->>SD: 查询操作记录
SD-->>S: 返回操作列表
S-->>CR: 返回操作数据
CR->>CM: IPC调用更新消息状态
CM->>CD: 更新对应消息的状态(标记为已撤回)
CM-->>CR: 更新完成
CR->>CR: 刷新UI(移除或标记被撤回的消息)
CR->>CD: 将最新的operation_sync_key更新到last_sync_key
end
流程描述
客户端(渲染进程)流程
-
游标读取:通过IPC调用从**客户端(主进程)**读取最后同步序列号(
last_sync_key),可以是全局的或按会话维护 -
增量请求:通过IPC调用请求客户端(主进程)向服务端发送同步请求,携带
user_id、last_sync_key和分页大小(可选携带session_id) -
消息处理:收到响应后,通过IPC调用通知客户端(主进程)按
sync_key升序存储消息到客户端(数据库) -
去重处理:在存储前检查
message_id去重,避免消息重复 -
游标更新:使用响应中的
nextSyncKey(最新的sync_key)通过IPC调用通知**客户端(主进程)**更新本地last_sync_key -
循环拉取:如果响应指示还有更多消息,继续请求下一页
-
状态保存:同步完成后通过IPC调用保存同步状态和时间戳
-
操作同步:通过IPC调用请求**客户端(主进程)**处理消息撤回、删除等操作记录
-
UI刷新:收到新消息或操作记录后刷新UI显示
客户端(主进程)流程
-
接收请求:接收**客户端(渲染进程)**的IPC调用请求
-
数据库操作:
-
读取
last_sync_key:从**客户端(数据库)**读取同步游标 -
存储消息:开启事务,遍历消息列表,检查
message_id去重后插入消息 -
更新游标:将
nextSyncKey(最新的sync_key)存储到客户端(数据库) -
更新时间戳:记录同步完成时间
-
网络请求:向服务端发送HTTP请求(
/messages/sync) -
返回结果:将服务端的响应数据通过IPC返回给客户端(渲染进程)
服务端流程
-
参数验证:验证
last_sync_key合法性,确保为正整数 -
增量查询:在服务端数据库中查询
to_user_id = ? AND sync_key > last_sync_key的消息,如果指定session_id则追加AND session_id = ? -
排序分页:按
sync_key升序排序,限制返回数量(如100条) -
元数据计算:计算是否还有更多消息,返回
has_more标志 -
数据封装:返回消息列表和
nextSyncKey(最新的sync_key值) -
操作查询:查询
sync_key大于last_sync_key的操作记录(撤回、删除等)
流程图中字段的详细说明
| 流程图中的字段名 | 说明 |
|---|---|
| last_sync_key | 客户端(渲染进程)维护的同步游标(前端代码中可使用lastSyncKey),记录上次同步到的sync_key值,用于增量查询。首次启动时为0,触发全量拉取。通过IPC调用客户端(主进程)从客户端(数据库)读取和更新 |
| sync_key | 服务端为每条消息分配的严格递增的全局唯一标识(前端代码中可使用syncKey),用于消息排序、增量查询和防重复 |
| message_id | 服务端消息ID(前端代码中可使用messageId),消息的主键标识,用于客户端去重处理 |
| operation_sync_key | 操作记录(如撤回、删除)的同步版本号(前端代码中可使用operationSyncKey),与消息共用同一序列号空间,用于同步消息状态的变更 |
| session_id | 会话ID(前端代码中可使用sessionId),标识消息所属的会话,用于按会话查询消息 |
| has_more | 服务端返回的分页标志(前端代码中可使用hasMore),true表示还有更多消息,false表示已同步完成(非数据库字段) |
| total | 查询结果的总数,用于客户端(渲染进程)显示消息总数(非数据库字段) |
| user_id | 用户ID(前端代码中可使用userId),标识操作的用户,用于区分不同用户的数据(服务端字段) |
| device_id | 设备ID(前端代码中可使用deviceId),标识具体的设备,用于支持多设备同步(服务端字段) |
| page_size | 分页大小(前端代码中可使用pageSize),单次同步的消息数量限制(非数据库字段) |
| from_user_id | 发送者用户ID(前端代码中可使用fromUserId),标识消息的发送方 |
| to_user_id | 接收者用户ID(前端代码中可使用toUserId),标识消息的接收方 |
| message_type | 消息类型(前端代码中可使用messageType):1-文本 2-图片 3-语音 4-视频 5-文件 |
| content | 消息内容,存储消息的具体内容信息 |
| status | 消息状态(前端代码中可使用status):0-发送中 1-发送成功 2-发送失败 |
| create_time | 消息创建时间(前端代码中可使用createTime),记录消息的生成时间 |
| last_sync_time | 最后同步时间(前端代码中可使用lastSyncTime),记录上次成功同步的时间戳(存储在客户端(数据库),由客户端(渲染进程)通过IPC调用客户端(主进程)读写) |
数据库设计
后端MySQL表设计
-- 消息表
CREATE TABLE `message_base_info` (
`message_id` VARCHAR(64) NOT NULL COMMENT '消息ID,服务端生成,唯一标识符',
`sync_key` BIGINT(20) NOT NULL COMMENT '同步版本号(严格递增)',
`session_id` VARCHAR(64) NOT NULL COMMENT '会话ID',
`from_user_id` BIGINT(20) NOT NULL COMMENT '发送者用户ID',
`to_user_id` BIGINT(20) NOT NULL COMMENT '接收者用户ID',
`to_type` TINYINT(4) NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
`message_type` TINYINT(4) NOT NULL COMMENT '消息类型:1-文本,2-图片附件,3-语音附件,4-视频附件,5-文件附件,6-位置,99-系统',
`content` TEXT NOT NULL COMMENT '消息内容',
`status` TINYINT(4) NOT NULL DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
`is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除(软删除)',
`send_time` BIGINT(20) NOT NULL COMMENT '发送时间戳',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
`updated_at` BIGINT(20) NOT NULL COMMENT '更新时间',
`version` INT DEFAULT 0 COMMENT '数据版本号(用于乐观锁)',
PRIMARY KEY (`message_id`),
UNIQUE KEY `uk_sync_key` (`sync_key`),
KEY `idx_to_user_sync` (`to_user_id`, `sync_key`),
KEY `idx_session_sync` (`session_id`, `sync_key`),
KEY `idx_send_time` (`send_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';
-- 消息操作记录表(用于记录撤回、删除等操作)
CREATE TABLE `message_revoke_logs` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`message_id` VARCHAR(64) NOT NULL UNIQUE COMMENT '消息ID',
`sync_key` BIGINT(20) NOT NULL COMMENT '同步版本号(与消息共享同一序列号空间)',
`operation_type` TINYINT(4) NOT NULL COMMENT '操作类型:1-撤回 2-删除',
`operator_id` BIGINT(20) NOT NULL COMMENT '操作者ID',
`revoke_time` BIGINT(20) NOT NULL COMMENT '撤回时间戳',
`revoke_reason` VARCHAR(200) COMMENT '撤回原因',
`device_id` VARCHAR(100) COMMENT '撤回设备ID',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sync_key` (`sync_key`),
KEY `idx_message_id` (`message_id`),
KEY `idx_operator_id` (`operator_id`),
KEY `idx_revoke_time` (`revoke_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息操作记录表';
-- 用户同步记录表
CREATE TABLE `user_sync_record` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`device_id` VARCHAR(64) NOT NULL COMMENT '设备ID',
`current_sync_key` BIGINT(20) NOT NULL DEFAULT 0 COMMENT '当前同步版本号',
`last_sync_time` BIGINT(20) NOT NULL COMMENT '最后同步时间戳',
`last_online_time` BIGINT(20) NOT NULL COMMENT '最后在线时间戳',
`sync_count` INT(11) NOT NULL DEFAULT 0 COMMENT '同步次数',
`push_count` INT(11) NOT NULL DEFAULT 0 COMMENT '推送次数',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
`updated_at` BIGINT(20) NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_device` (`user_id`, `device_id`),
KEY `idx_last_sync_time` (`last_sync_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同步记录表';
前端SQLite表设计
-- 本地消息表
CREATE TABLE message_base_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE COMMENT '服务端消息ID',
sync_key BIGINT NOT NULL COMMENT '同步版本号',
session_id TEXT NOT NULL COMMENT '会话ID',
from_user_id INTEGER NOT NULL COMMENT '发送者用户ID',
to_user_id INTEGER NOT NULL COMMENT '接收者用户ID',
to_type INTEGER NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
message_type INTEGER NOT NULL COMMENT '消息类型:1-文本,2-图片附件,3-语音附件,4-视频附件,5-文件附件,6-位置,99-系统',
content TEXT NOT NULL COMMENT '消息内容',
status INTEGER NOT NULL DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
is_deleted INTEGER NOT NULL DEFAULT 0 COMMENT '是否删除:0-否,1-是',
send_time INTEGER NOT NULL COMMENT '发送时间戳',
created_at INTEGER NOT NULL COMMENT '创建时间',
updated_at INTEGER NOT NULL COMMENT '更新时间',
version INTEGER NOT NULL DEFAULT 0 COMMENT '数据版本号(用于乐观锁)',
sync_time INTEGER NOT NULL COMMENT '同步时间',
receive_time INTEGER NOT NULL COMMENT '接收时间(客户端特有字段)',
receive_type INTEGER NOT NULL DEFAULT 0 COMMENT '接收类型:0-增量同步 1-实时推送(客户端特有字段)'
);
-- 同步配置表
CREATE TABLE sync_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE COMMENT '配置键(如last_sync_key或last_sync_key_xxx)',
value TEXT NOT NULL COMMENT '配置值',
updated_at INTEGER NOT NULL COMMENT '更新时间'
);
-- 消息操作记录表(本地)
CREATE TABLE message_revoke_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE COMMENT '服务端消息ID',
sync_key BIGINT NOT NULL COMMENT '同步版本号',
operation_type INTEGER NOT NULL COMMENT '操作类型:1-撤回 2-删除',
operator_id INTEGER NOT NULL COMMENT '操作者ID',
revoke_time INTEGER NOT NULL COMMENT '撤回时间戳',
created_at INTEGER NOT NULL COMMENT '创建时间'
);
优缺点
优点
-
高效传输:只传输增量数据,大大减少网络流量
-
顺序保证:严格递增的序列号保证消息顺序和完整性
-
服务器压力小:相比实时推送,服务器资源消耗更低
-
断点续传:支持从任意游标位置继续同步
-
带宽友好:适合移动网络和流量敏感场景
-
支持操作同步:通过序列号关联撤回、删除等操作
缺点
-
实时性不足:依赖客户端主动拉取,存在同步延迟
-
游标管理复杂:需要维护准确的游标状态
-
客户端状态依赖:游标丢失或损坏会导致同步问题
-
需要配合定时任务:为了及时发现新消息,需要定时拉取
-
需要分布式ID生成器:需要额外组件生成严格递增的序列号
代码示例
后端代码(Node.js + MySQL)
// 伪代码:生成sync_key(使用分布式ID生成器,如雪花算法)
function generateSyncKey() {
return Snowflake.generateId();
}
// 伪代码:生成message_id(消息ID)
function generateMessageId() {
return Snowflake.generateId();
}
// 伪代码:发送消息时生成sync_key(版本号)
async function sendMessage(fromUserId, toUserId, sessionId, msgType, content) {
try {
// 生成严格递增的版本号和消息ID
const syncKey = generateSyncKey();
const messageId = generateMessageId();
const currentTime = Date.now();
const query = `
INSERT INTO message_base_info (message_id, sync_key, session_id, from_user_id, to_user_id, message_type, content, status, send_time, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
await db.query(query, [messageId, syncKey, sessionId, fromUserId, toUserId, msgType, content, 1, currentTime, currentTime, currentTime]);
return {
code: 0,
data: {
messageId: messageId,
syncKey: syncKey
}
};
} catch (error) {
console.error('发送消息失败:', error);
return {
code: -1,
message: '发送消息失败'
};
}
}
// 伪代码:增量同步消息接口(基于sync_key)
async function syncMessagesBySyncKey(userId, deviceId, lastSyncKey, sessionId = null) {
try {
const limit = 100;
// 构建查询条件
let whereClause = 'to_user_id = ? AND sync_key > ?';
let queryParams = [userId, lastSyncKey];
if (sessionId) {
whereClause += ' AND session_id = ?';
queryParams.push(sessionId);
}
// 查询sync_key之后的消息
const query = `
SELECT * FROM message_base_info
WHERE ${whereClause}
ORDER BY sync_key ASC
LIMIT ?
`;
const messages = await db.query(query, [...queryParams, limit]);
// 转换字段名为驼峰命名(用于返回给前端)
const formattedMessages = messages.map(msg => ({
messageId: msg.message_id,
syncKey: msg.sync_key,
sessionId: msg.session_id,
fromUserId: msg.from_user_id,
toUserId: msg.to_user_id,
toType: msg.to_type,
messageType: msg.message_type,
content: msg.content,
status: msg.status,
isDeleted: msg.is_deleted,
sendTime: msg.send_time,
createdAt: msg.created_at,
updatedAt: msg.updated_at,
version: msg.version
}));
// 获取最新的sync_key
let nextSyncKey = lastSyncKey;
if (messages.length > 0) {
nextSyncKey = messages[messages.length - 1].sync_key;
}
// 更新用户同步记录(服务端记录)
// 注意:user_sync_record是服务端用于记录用户同步状态的表,用于服务端监控和统计
// 客户端使用sync_config表来维护lastSyncKey
const currentTime = Date.now();
const updateQuery = `
INSERT INTO user_sync_record (user_id, device_id, current_sync_key, last_sync_time, last_online_time, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
current_sync_key = VALUES(current_sync_key),
last_sync_time = VALUES(last_sync_time),
last_online_time = VALUES(last_online_time),
updated_at = VALUES(updated_at)
`;
await db.query(updateQuery, [userId, deviceId, nextSyncKey, currentTime, currentTime, currentTime, currentTime]);
return {
code: 0,
data: {
messages: formattedMessages,
nextSyncKey: nextSyncKey,
has_more: messages.length >= limit
}
};
} catch (error) {
console.error('增量同步消息失败:', error);
return {
code: -1,
message: '增量同步消息失败'
};
}
}
// 伪代码:撤回消息时生成sync_key(版本号)
async function recallMessage(userId, messageId) {
try {
// 生成操作记录的版本号(与消息共用同一序列号空间)
const syncKey = generateSyncKey();
const currentTime = Date.now();
// 插入操作记录
const query = `
INSERT INTO message_revoke_logs (sync_key, operation_type, message_id, operator_id, revoke_time, created_at)
VALUES (?, 1, ?, ?, ?, ?)
`;
await db.query(query, [syncKey, messageId, userId, currentTime, currentTime]);
// 更新原消息状态
await db.query('UPDATE message_base_info SET status = 2 WHERE message_id = ?', [messageId]);
return {
code: 0,
data: {
syncKey: syncKey
}
};
} catch (error) {
console.error('撤回消息失败:', error);
return {
code: -1,
message: '撤回消息失败'
};
}
}
前端代码(客户端)
// 伪代码:增量同步消息(基于sync_key)【前端伪代码 - 客户端(渲染进程)】
async function syncMessagesBySyncKey(session_id = null) {
try {
// 通过IPC调用从客户端(主进程)读取last_sync_key(版本号游标)
const last_sync_key = await ipcRenderer.invoke('read-sync-key', session_id);
// 向服务端请求增量同步(查询sync_key > last_sync_key的消息)
const response = await ipcRenderer.invoke('sync-messages-by-synckey', last_sync_key, session_id);
if (response.code === 0) {
const { messages, nextSyncKey, has_more } = response.data;
if (messages.length > 0) {
// 通过IPC调用客户端(主进程)批量插入新消息(使用INSERT OR IGNORE避免重复)
await ipcRenderer.invoke('save-messages', messages);
// 通过IPC调用客户端(主进程)更新last_sync_key为最新版本号
await ipcRenderer.invoke('update-sync-key', nextSyncKey, session_id);
console.log(`增量同步成功,新增 ${messages.length} 条消息,版本号: ${last_sync_key} -> ${nextSyncKey}`);
// 如果还有更多消息,继续拉取
if (has_more) {
await syncMessagesBySyncKey(session_id);
}
} else {
console.log('暂无新消息,last_sync_key:', last_sync_key);
}
}
} catch (error) {
console.error('增量同步消息失败:', error);
}
}
// 以下为客户端(主进程)的伪代码实现
// 读取同步游标
ipcMain.handle('read-sync-key', async (event, sessionId = null) => {
let config;
if (sessionId) {
config = await db.get('SELECT value FROM sync_config WHERE key = ?', [`last_sync_key_${sessionId}`]);
} else {
config = await db.get('SELECT value FROM sync_config WHERE key = ?', ['last_sync_key']);
}
return config ? parseInt(config.value) : 0;
});
// 保存消息列表
ipcMain.handle('save-messages', async (event, messages) => {
const currentTime = Date.now();
const insertPromises = messages.map(msg => {
return db.run(`
INSERT OR IGNORE INTO message_base_info (message_id, sync_key, session_id, from_user_id, to_user_id, to_type, message_type, content, status, send_time, created_at, updated_at, version, sync_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [msg.messageId, msg.syncKey, msg.sessionId, msg.fromUserId, msg.toUserId, msg.toType, msg.messageType, msg.content, msg.status, msg.sendTime, msg.createdAt, msg.updatedAt, msg.version, currentTime]);
});
await Promise.all(insertPromises);
return { code: 0 };
});
// 更新同步游标
ipcMain.handle('update-sync-key', async (event, nextSyncKey, sessionId = null) => {
const currentTime = Date.now();
if (sessionId) {
await db.run(`
INSERT OR REPLACE INTO sync_config (key, value, updated_at)
VALUES (?, ?, ?)
`, [`last_sync_key_${sessionId}`, nextSyncKey, currentTime]);
} else {
await db.run(`
INSERT OR REPLACE INTO sync_config (key, value, updated_at)
VALUES ('last_sync_key', ?, ?)
`, [nextSyncKey, currentTime]);
}
return { code: 0 };
});
方案二:增量同步 + 实时推送(混合方案)
设计思路
核心思想:综合运用WebSocket实时推送和增量同步两种方式,在线时通过WebSocket实现毫秒级实时性,离线或断线时通过增量同步补齐消息,兼顾实时性和可靠性。
设计要点:
-
在线实时推送:用户在线时,服务端通过WebSocket主动推送新消息
-
离线增量同步:用户离线或断线后,通过增量同步拉取离线消息
-
消息去重:客户端通过
message_id去重,避免WebSocket推送和增量同步导致的重复 -
断线重连:WebSocket断线后自动重连,重连后进行增量同步补齐
-
兜底机制:定时增量同步作为兜底,防止WebSocket推送失败导致消息丢失
-
状态同步:通过WebSocket或增量同步同步消息状态变更(已读、撤回等)
适用场景:
-
对实时性要求高的社交聊天应用
-
多设备协同的企业IM系统
-
需要兼顾在线和离线场景的IM应用
-
大规模用户的高并发场景
流程图
sequenceDiagram
participant CR as 客户端(渲染进程)
participant CM as 客户端(主进程)
participant CD as 客户端(数据库)
participant S as 服务端
participant SD as 服务端数据库
participant OfflineQueue as 离线队列
Note over CR,SD: 初始化阶段
Note over CR: 应用启动,检查网络状态
CR->>CD: 读取本地last_sync_key
CR->>S: HTTP POST /messages/sync?last_sync_key=xxx
S->>SD: 查询to_user_id = ? AND sync_key > last_sync_key的消息
SD-->>S: 返回离线消息列表
S-->>CR: 返回离线消息数据
CR->>CM: IPC调用存储离线消息
CM->>CD: 保存离线消息到本地数据库
CR->>CD: 将最新的sync_key更新到last_sync_key
Note over CR,S: 建立WebSocket连接
CR->>CM: IPC调用建立WebSocket连接
CM->>S: 建立WebSocket连接
S-->>CM: 连接成功
CM->>S: 发送认证消息(user_id + token)
S-->>CM: 认证成功
CM->>CM: 启动心跳定时器(每30秒)
CM-->>CR: IPC通知连接就绪
Note over S,SD: 实时推送阶段(在线期间)
Note over S: 新消息到达(from_user_id -> to_user_id)
S->>SD: 保存消息(生成sync_key)
SD-->>S: 保存成功,返回message_id和sync_key
S->>S: 查询to_user_id的在线设备
alt to_user_id有在线WebSocket连接
S->>CM: WebSocket推送新消息
Note over S,CM: 推送数据:message_id、sync_key、session_id、content等
CM->>CM: 接收并解析消息
CM->>CR: IPC推送消息到渲染进程
alt 消息不存在(基于message_id判断)
CR->>CM: IPC调用存储消息
CM->>CD: 保存消息到本地数据库
CR->>CD: 将最新的sync_key更新到last_sync_key
CR->>CR: 刷新UI显示新消息
else 消息已存在
CR->>CR: 跳过(消息重复,可能是增量同步已经拉取过)
end
else to_user_id无在线连接
S->>OfflineQueue: 消息存入离线队列
Note over OfflineQueue: 离线队列:user_id、message_id、sync_key等(服务端内存或Redis)
end
Note over CM,S: 心跳维持
loop 每30秒
CM->>S: 发送心跳包(PING)
S-->>CM: 返回心跳响应(PONG)
end
Note over CR,S: 断线重连阶段
S-->>CM: WebSocket连接断开
CM->>CR: IPC通知连接断开
CM->>CM: 停止心跳定时器
CM->>CM: 记录断线时间
Note over CR: 检测到网络恢复或用户重新上线
CR->>CR: 启动重连机制
CR->>CM: IPC调用重新建立WebSocket连接
CM->>S: 重新建立WebSocket连接
S-->>CM: 连接成功
CM->>S: 发送认证消息
Note over CR,SD: 重连后进行增量同步(补齐断线期间的消息)
CR->>S: HTTP POST /messages/sync?last_sync_key=xxx
S->>SD: 查询to_user_id = ? AND sync_key > last_sync_key的消息
SD-->>S: 返回离线期间的新消息
S-->>CR: 返回离线消息数据
alt 有离线消息
CR->>CM: IPC调用存储消息
CM->>CD: 合并并存储消息(去重)
CR->>CD: 将最新的sync_key更新到last_sync_key
CR->>CR: 刷新UI显示
end
Note over CR,SD: 定时兜底同步阶段(每3-5分钟 - 补充方式)
Note over CR: 触发时机:客户端定时触发,作为兜底机制
CR->>S: HTTP POST /messages/sync?last_sync_key=xxx
S->>SD: 查询to_user_id = ? AND sync_key > last_sync_key的消息
SD-->>S: 返回消息列表
S-->>CR: 返回消息数据
alt 有新消息
CR->>CM: IPC调用存储消息
CM->>CD: 存储消息(去重)
CR->>CD: 将最新的sync_key更新到last_sync_key
CR->>CR: 刷新UI
end
Note over S,CR: 消息状态同步(撤回、已读等)
Note over S: 用户撤回消息
S->>SD: 更新消息状态为已撤回
S->>SD: 生成操作记录(operation_sync_key)
alt 用户在线(WebSocket连接)
S->>CM: WebSocket推送撤回操作
CM->>CR: IPC推送撤回操作到渲染进程
CR->>CM: IPC调用更新消息状态
CM->>CD: 更新本地消息状态
CR->>CR: 刷新UI(移除或标记被撤回的消息)
else 用户离线
Note over SD: 操作记录已存储,用户上线时通过增量同步拉取
end
流程描述
【当前客户端流程】
1. 初始化阶段:
-
客户端(渲染进程):
-
应用启动时,检查网络状态
-
从**客户端(数据库)**读取本地
last_sync_key -
向服务端发送HTTP请求进行增量同步,拉取离线消息(
to_user_id = ? AND sync_key > last_sync_key) -
接收离线消息后,通过IPC调用将消息传递给客户端(主进程)
-
通知**客户端(主进程)**更新
last_sync_key到数据库 -
通过IPC调用请求**客户端(主进程)**建立WebSocket连接
-
监听IPC消息,准备接收实时推送
-
客户端(主进程):
-
接收渲染进程的IPC调用
-
将离线消息保存到客户端(数据库)
-
更新
last_sync_key到数据库 -
向服务端建立WebSocket连接,发送认证信息(包含用户Token)
-
认证成功后,启动心跳定时器(每30秒发送一次心跳)
-
通过IPC通知**客户端(渲染进程)**WebSocket连接就绪
-
监听WebSocket消息
2. 实时推送阶段(在线期间):
-
客户端(主进程):
-
接收到服务端的WebSocket新消息推送时,解析消息数据
-
通过IPC调用将消息传递给客户端(渲染进程)
-
定时向服务端发送心跳包(每30秒),保持连接活跃
-
接收心跳响应,确认连接正常
-
客户端(渲染进程):
-
监听**客户端(主进程)**的IPC消息
-
当收到新消息推送时,解析消息数据
-
通过
message_id判断消息是否已存在(去重处理) -
如果消息不存在,通过IPC调用将消息传递给**客户端(主进程)**进行存储,更新
last_sync_key,刷新UI -
如果消息已存在,跳过处理(可能是增量同步已经拉取过)
3. 断线重连阶段:
-
客户端(主进程):
-
检测到WebSocket连接断开时
-
停止心跳定时器
-
记录断线时间
-
通过IPC通知**客户端(渲染进程)**连接断开
-
当检测到网络恢复时
-
尝试重新建立WebSocket连接
-
重连成功后,重新发送认证消息
-
通过IPC通知**客户端(渲染进程)**重新连接成功
-
客户端(渲染进程):
-
接收到**客户端(主进程)**IPC通知连接断开
-
当检测到网络恢复或用户重新上线时
-
通过IPC通知**客户端(主进程)**启动重连机制
-
向服务端发送增量同步请求,拉取断线期间的新消息
-
接收消息后,通过IPC调用传递给客户端(主进程)
-
通知**客户端(主进程)**更新
last_sync_key,刷新UI
4. 定时兜底同步阶段(每3-5分钟 - 补充方式):
-
触发时机:**客户端(渲染进程)**定时(如每3-5分钟)主动发起一次增量同步请求
-
作用:防止因网络抖动、推送失败导致的数据不一致,确保最终一致性
-
执行流程:
-
客户端(渲染进程):
-
向服务端发送增量同步请求,携带
last_sync_key -
接收该时间点之后所有更新的消息
-
通过IPC调用传递给**客户端(主进程)**进行存储和去重
-
更新
last_sync_key,触发UI刷新,显示最新消息 -
客户端(主进程):
-
接收渲染进程的IPC调用
-
将消息保存到客户端(数据库)(去重处理)
-
更新
last_sync_key
【服务端流程】
1. 处理增量同步请求:
-
服务端:
-
接收客户端的同步请求,包含
last_sync_key和分页参数 -
向服务端数据库查询
to_user_id = ? AND sync_key > last_sync_key的消息 -
按sync_key升序排序,限制返回数量(如100条)
-
计算是否还有更多消息,返回
has_more标志 -
返回消息列表和最新的
sync_key值
2. WebSocket连接管理:
-
服务端:
-
接收客户端的WebSocket连接请求
-
验证客户端认证信息,提取用户ID
-
保存用户ID到WebSocket连接的映射关系(支持多设备连接)
-
处理心跳消息,更新最后心跳时间
-
监听连接断开事件,清理映射关系
3. 消息推送(实时性保障):
-
服务端:
-
收到新消息后,保存到服务端数据库,生成
sync_key -
查询接收者用户的所有在线WebSocket连接
-
如果有在线连接:通过WebSocket实时推送新消息
-
如果无在线连接:消息存入离线队列,等待用户上线后通过增量同步拉取
4. 状态同步(撤回、已读等):
-
服务端:
-
监听消息状态变更事件(撤回、删除、已读等)
-
如果用户在线,通过WebSocket推送操作记录
-
如果用户离线,操作记录存储在服务端数据库,用户上线时通过增量同步拉取
流程图中字段的详细说明
| 流程图中的字段名 | 说明 |
|---|---|
| last_sync_key | 客户端(渲染进程) 维护的同步游标(前端代码中可使用lastSyncKey),存储在**客户端(数据库)中,记录当前已同步到的sync_key值,用于增量查询。首次启动时为0,触发全量拉取。通过IPC调用通知客户端(主进程)**进行更新 |
| sync_key | 服务端为每条消息分配的严格递增的全局唯一标识(前端代码中可使用syncKey),用于消息排序、增量查询和防重复 |
| message_id | 服务端消息ID(前端代码中可使用messageId),消息的主键标识,用于客户端去重处理 |
| operation_sync_key | 操作记录(如撤回、删除)的同步版本号(前端代码中可使用operationSyncKey),与消息共用同一序列号空间,用于同步消息状态的变更 |
| session_id | 会话ID(前端代码中可使用sessionId),标识消息所属的会话,用于按会话查询消息 |
| connection_id | WebSocket连接的唯一标识,用于管理连接会话 |
| user_id | 用户ID(前端代码中可使用userId),标识连接的用户,用于认证和消息推送 |
| token | 认证令牌(前端代码中可使用token),用于WebSocket连接时的身份验证(非数据库字段) |
| device_id | 设备ID(前端代码中可使用deviceId),标识具体的设备,用于支持多设备连接 |
| from_user_id | 发送者用户ID(前端代码中可使用fromUserId),标识消息的发送方 |
| to_user_id | 接收者用户ID(前端代码中可使用toUserId),标识消息的接收方 |
| message_type | 消息类型(前端代码中可使用messageType):1-文本 2-图片 3-语音 4-视频 5-文件 |
| content | 消息内容,存储消息的具体内容信息 |
| status | 消息状态(前端代码中可使用status):0-发送中 1-发送成功 2-发送失败 |
| create_time | 消息创建时间(前端代码中可使用createTime),记录消息的生成时间 |
| server_ip | 服务端IP,记录WebSocket服务器地址 | | server_port | 服务端端口,记录WebSocket服务器端口 | | connect_time | 连接时间,记录WebSocket建立连接的时间 | | last_heartbeat_time | 最后心跳时间,记录最后一次心跳响应的时间,用于检测连接是否活跃 | | last_sync_time | 最后同步时间,记录上次成功同步的时间戳(服务端字段) | | last_online_time | 最后在线时间,记录用户最后在线的时间(服务端字段) | | sync_count | 同步次数,记录累计同步的次数(服务端字段) | | push_count | 推送次数,记录累计推送的次数(服务端字段) | | sync_time | 同步时间,记录消息同步到**客户端(数据库)的时间(前端字段) | | receive_time | 接收时间,记录客户端(渲染进程)**接收到消息的时间(前端字段) | | receive_type | 接收类型:0-增量同步 1-实时推送(前端字段) |
数据库设计
后端MySQL表设计
-- 消息表
CREATE TABLE `message_base_info` (
`message_id` VARCHAR(64) NOT NULL COMMENT '消息ID,服务端生成,唯一标识符',
`sync_key` BIGINT(20) NOT NULL COMMENT '同步版本号(严格递增)',
`session_id` VARCHAR(64) NOT NULL COMMENT '会话ID',
`from_user_id` BIGINT(20) NOT NULL COMMENT '发送者用户ID',
`to_user_id` BIGINT(20) NOT NULL COMMENT '接收者用户ID',
`to_type` TINYINT(4) NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
`message_type` TINYINT(4) NOT NULL COMMENT '消息类型:1-文本,2-图片附件,3-语音附件,4-视频附件,5-文件附件,6-位置,99-系统',
`content` TEXT NOT NULL COMMENT '消息内容',
`status` TINYINT(4) NOT NULL DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
`is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除(软删除)',
`send_time` BIGINT(20) NOT NULL COMMENT '发送时间戳',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
`updated_at` BIGINT(20) NOT NULL COMMENT '更新时间',
`version` INT DEFAULT 0 COMMENT '数据版本号(用于乐观锁)',
PRIMARY KEY (`message_id`),
UNIQUE KEY `uk_sync_key` (`sync_key`),
KEY `idx_to_user_sync` (`to_user_id`, `sync_key`),
KEY `idx_session_sync` (`session_id`, `sync_key`),
KEY `idx_send_time` (`send_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';
-- 消息操作记录表
CREATE TABLE `message_revoke_logs` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`message_id` VARCHAR(64) NOT NULL UNIQUE COMMENT '消息ID',
`sync_key` BIGINT(20) NOT NULL COMMENT '同步版本号',
`operation_type` TINYINT(4) NOT NULL COMMENT '操作类型:1-撤回 2-删除',
`operator_id` BIGINT(20) NOT NULL COMMENT '操作者ID',
`revoke_time` BIGINT(20) NOT NULL COMMENT '撤回时间戳',
`revoke_reason` VARCHAR(200) COMMENT '撤回原因',
`device_id` VARCHAR(100) COMMENT '撤回设备ID',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sync_key` (`sync_key`),
KEY `idx_message_id` (`message_id`),
KEY `idx_operator_id` (`operator_id`),
KEY `idx_revoke_time` (`revoke_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息操作记录表';
-- 用户同步记录表
CREATE TABLE `user_sync_record` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`device_id` VARCHAR(64) NOT NULL COMMENT '设备ID',
`current_sync_key` BIGINT(20) NOT NULL DEFAULT 0 COMMENT '当前同步版本号',
`last_sync_time` BIGINT(20) NOT NULL COMMENT '最后同步时间戳',
`last_online_time` BIGINT(20) NOT NULL COMMENT '最后在线时间戳',
`sync_count` INT(11) NOT NULL DEFAULT 0 COMMENT '同步次数',
`push_count` INT(11) NOT NULL DEFAULT 0 COMMENT '推送次数',
`created_at` BIGINT(20) NOT NULL COMMENT '创建时间',
`updated_at` BIGINT(20) NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_device` (`user_id`, `device_id`),
KEY `idx_last_sync_time` (`last_sync_time DESC`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同步记录表';
前端SQLite表设计
-- 本地消息表
-- 注意:receive_time 和 receive_type 是客户端特有的字段,用于追踪消息的接收来源和时间
-- 服务端数据库中不包含这两个字段
CREATE TABLE message_base_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE COMMENT '服务端消息ID',
sync_key BIGINT NOT NULL COMMENT '同步版本号',
session_id TEXT NOT NULL COMMENT '会话ID',
from_user_id INTEGER NOT NULL COMMENT '发送者用户ID',
to_user_id INTEGER NOT NULL COMMENT '接收者用户ID',
to_type INTEGER NOT NULL COMMENT '接收方类型:1-用户(单聊),2-群组(群聊)',
message_type INTEGER NOT NULL COMMENT '消息类型:1-文本,2-图片附件,3-语音附件,4-视频附件,5-文件附件,6-位置,99-系统',
content TEXT NOT NULL COMMENT '消息内容',
status INTEGER NOT NULL DEFAULT 0 COMMENT '消息状态:0-发送中,1-发送成功,2-发送失败',
is_deleted INTEGER NOT NULL DEFAULT 0 COMMENT '是否删除:0-否,1-是',
send_time INTEGER NOT NULL COMMENT '发送时间戳',
created_at INTEGER NOT NULL COMMENT '创建时间',
updated_at INTEGER NOT NULL COMMENT '更新时间',
version INTEGER NOT NULL DEFAULT 0 COMMENT '数据版本号(用于乐观锁)',
sync_time INTEGER NOT NULL COMMENT '同步时间',
receive_time INTEGER NOT NULL COMMENT '接收时间(客户端特有字段)',
receive_type INTEGER NOT NULL DEFAULT 0 COMMENT '接收类型:0-增量同步 1-实时推送(客户端特有字段)'
);
-- 同步配置表
CREATE TABLE sync_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE COMMENT '配置键',
value TEXT NOT NULL COMMENT '配置值',
updated_at INTEGER NOT NULL COMMENT '更新时间'
);
优缺点
优点
-
实时性最佳:在线时毫秒级延迟,用户体验极佳
-
可靠性高:离线消息通过增量同步保证不丢失
-
双重保障:WebSocket推送 + 增量同步,相互兜底
-
适应性强:同时支持在线和离线场景
-
状态同步完善:支持消息状态、操作记录的实时同步
缺点
-
实现复杂:需要同时实现WebSocket和增量同步
-
资源消耗大:WebSocket长连接占用服务器资源
-
需要去重:WebSocket推送和增量同步可能导致消息重复
-
断线处理复杂:需要处理断线重连和消息补齐
-
维护成本高:需要维护两套同步机制
代码示例
后端代码(Node.js + WebSocket + MySQL)
// WebSocket服务端实现
const WebSocket = require('ws');
const Redis = require('ioredis');
const wss = new WebSocket.Server({ port: 8080 });
const redis = new Redis();
// 存储用户连接映射
const userConnections = new Map();
// 生成连接ID
function generateConnectionId() {
return Snowflake.generateId();
}
// 生成消息ID
function generateMessageId() {
return Snowflake.generateId();
}
// 生成同步版本号
function generateSyncKey() {
return Snowflake.generateId();
}
// 处理WebSocket连接
wss.on('connection', (ws, req) => {
const connectionId = generateConnectionId();
console.log('新连接:', connectionId);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'auth') {
// 认证
const authResult = await authenticateUser(data.token);
if (authResult.code === 0) {
ws.userId = authResult.user_id;
ws.deviceId = data.deviceId;
ws.connectionId = connectionId;
// 存储连接映射
const key = `connection:${authResult.user_id}:${data.deviceId}`;
userConnections.set(key, ws);
// 订阅用户的消息队列
await redis.subscribe(`user:${authResult.user_id}`);
// 发送认证成功响应
ws.send(JSON.stringify({
type: 'auth',
code: 0,
message: '认证成功'
}));
console.log('用户认证成功:', authResult.user_id);
} else {
ws.send(JSON.stringify({
type: 'auth',
code: -1,
message: '认证失败'
}));
}
} else if (data.type === 'heartbeat') {
// 心跳
ws.lastHeartbeatTime = Date.now();
ws.send(JSON.stringify({ type: 'heartbeat', code: 0 }));
}
} catch (error) {
console.error('处理消息失败:', error);
}
});
ws.on('close', () => {
console.log('连接断开:', connectionId);
// 清理连接映射
if (ws.userId && ws.deviceId) {
const key = `connection:${ws.userId}:${ws.deviceId}`;
userConnections.delete(key);
// 取消订阅
redis.unsubscribe(`user:${ws.userId}`);
}
});
ws.on('error', (error) => {
console.error('WebSocket错误:', error);
});
});
// 发送消息
// 消息状态定义:0-发送中,1-发送成功,2-发送失败
async function sendMessage(fromUserId, toUserId, sessionId, msgType, content) {
try {
const syncKey = generateSyncKey();
const messageId = generateMessageId();
const currentTime = Date.now();
// 保存消息到数据库
const query = `
INSERT INTO message_base_info (message_id, sync_key, session_id, from_user_id, to_user_id, message_type, content, status, send_time, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
await db.query(query, [messageId, syncKey, sessionId, fromUserId, toUserId, msgType, content, 0, currentTime, currentTime, currentTime]);
const messageData = {
messageId: messageId,
syncKey: syncKey,
sessionId: sessionId,
fromUserId: fromUserId,
toUserId: toUserId,
msgType: msgType,
content: content,
status: 0,
sendTime: currentTime,
createdAt: currentTime,
updatedAt: currentTime
};
// 第二步:尝试WebSocket推送
let pushed = false;
for (const [key, ws] of userConnections) {
if (ws.userId === parseInt(toUserId) && ws.readyState === WebSocket.OPEN) {
try {
// 推送时更新status为1(发送成功)
messageData.status = 1;
ws.send(JSON.stringify({
type: 'message',
data: messageData
}));
pushed = true;
console.log('WebSocket推送成功:', toUserId);
} catch (error) {
console.error('WebSocket推送失败:', error);
}
}
}
// 第三步:根据推送结果更新消息状态
if (pushed) {
// 推送成功:status=1(发送成功)
await db.query('UPDATE message_base_info SET status=1 WHERE message_id=?', [messageId]);
console.log('消息状态更新为发送成功:', messageId);
} else {
// 推送失败,尝试存入离线队列
try {
await redis.lpush(`offline:${toUserId}`, JSON.stringify(messageData));
// 存入离线队列成功:status=1(发送成功,因为有离线队列兜底)
await db.query('UPDATE message_base_info SET status=1 WHERE message_id=?', [messageId]);
console.log('消息存入离线队列,状态更新为发送成功:', messageId);
} catch (error) {
// 离线队列也失败:status=2(发送失败)
await db.query('UPDATE message_base_info SET status=2 WHERE message_id=?', [messageId]);
console.error('离线队列存储失败,消息状态更新为发送失败:', error);
}
}
return {
code: 0,
data: messageData
};
} catch (error) {
console.error('发送消息失败:', error);
return {
code: -1,
message: '发送消息失败'
};
}
}
// 增量同步接口(用于离线消息拉取)
async function syncMessagesMixed(userId, deviceId, lastSyncKey) {
try {
const limit = 100;
// 查询sync_key之后的消息
const query = `
SELECT * FROM message_base_info
WHERE to_user_id = ?
AND sync_key > ?
ORDER BY sync_key ASC
LIMIT ?
`;
const messages = await db.query(query, [userId, lastSyncKey, limit]);
// 转换字段名为驼峰命名(用于返回给前端)
const formattedMessages = messages.map(msg => ({
messageId: msg.message_id,
syncKey: msg.sync_key,
sessionId: msg.session_id,
fromUserId: msg.from_user_id,
toUserId: msg.to_user_id,
toType: msg.to_type,
messageType: msg.message_type,
content: msg.content,
status: msg.status,
isDeleted: msg.is_deleted,
sendTime: msg.send_time,
createdAt: msg.created_at,
updatedAt: msg.updated_at,
version: msg.version
}));
// 获取最新的sync_key
let nextSyncKey = lastSyncKey;
if (messages.length > 0) {
nextSyncKey = messages[messages.length - 1].sync_key;
}
// 更新用户同步记录(服务端记录)
// 注意:user_sync_record是服务端用于记录用户同步状态的表,用于服务端监控和统计
// 客户端使用sync_config表来维护lastSyncKey
const currentTime = Date.now();
const updateQuery = `
INSERT INTO user_sync_record (user_id, device_id, current_sync_key, last_sync_time, last_online_time, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
current_sync_key = VALUES(current_sync_key),
last_sync_time = VALUES(last_sync_time),
last_online_time = VALUES(last_online_time),
updated_at = VALUES(updated_at)
`;
await db.query(updateQuery, [userId, deviceId, nextSyncKey, currentTime, currentTime, currentTime, currentTime]);
return {
code: 0,
data: {
messages: formattedMessages,
nextSyncKey: nextSyncKey,
has_more: messages.length >= limit
}
};
} catch (error) {
console.error('增量同步消息失败:', error);
return {
code: -1,
message: '增量同步消息失败'
};
}
}
前端代码(客户端)
// WebSocket客户端实现
class WebSocketClient {
constructor() {
this.ws = null;
this.userId = null;
this.deviceId = null;
this.reconnectCount = 0;
this.maxReconnectCount = 10;
this.reconnectInterval = 3000;
this.heartbeatInterval = 30000;
this.heartbeatTimer = null;
this.lastSyncKey = 0;
}
// 初始化
async init(userId, deviceId, token) {
this.userId = userId;
this.deviceId = deviceId;
// 先进行增量同步(拉取离线消息)
await this.syncIncremental(userId, deviceId);
// 然后建立WebSocket连接
await this.connect(userId, deviceId, token);
}
// 增量同步(拉取离线消息)
async syncIncremental(userId, deviceId) {
try {
// 读取本地sync_key
const config = await db.get('SELECT value FROM sync_config WHERE key = ?', ['last_sync_key']);
this.lastSyncKey = config ? parseInt(config.value) : 0;
// 请求增量消息
const response = await ipcRenderer.invoke('sync-messages-mixed', this.lastSyncKey, userId, deviceId);
if (response.code === 0) {
const { messages, nextSyncKey, has_more } = response.data;
if (messages.length > 0) {
// 存储离线消息
const currentTime = Date.now();
const insertPromises = messages.map(msg => {
return db.run(`
INSERT OR IGNORE INTO message_base_info (message_id, sync_key, session_id, from_user_id, to_user_id, to_type, message_type, content, status, send_time, created_at, updated_at, version, sync_time, receive_time, receive_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [msg.messageId, msg.syncKey, msg.sessionId, msg.fromUserId, msg.toUserId, msg.toType, msg.messageType, msg.content, msg.status, msg.sendTime, msg.createdAt, msg.updatedAt, msg.version, currentTime, currentTime, 0]);
});
await Promise.all(insertPromises);
// 更新sync_key
this.lastSyncKey = nextSyncKey;
await db.run(`
INSERT OR REPLACE INTO sync_config (key, value, updated_at)
VALUES ('last_sync_key', ?, ?)
`, [nextSyncKey, currentTime]);
console.log(`增量同步成功,新增 ${messages.length} 条离线消息`);
// 如果还有更多消息,继续拉取
if (hasMore) {
await this.syncIncremental(userId, deviceId);
}
} else {
console.log('暂无离线消息');
}
}
} catch (error) {
console.error('增量同步失败:', error);
}
}
// 建立WebSocket连接
async connect(userId, deviceId, token) {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`ws://localhost:8080?token=${token}&deviceId=${deviceId}`);
this.ws.onopen = () => {
console.log('WebSocket连接成功');
// 发送认证信息
this.ws.send(JSON.stringify({
type: 'auth',
token: token,
deviceId: deviceId
}));
// 启动心跳
this.startHeartbeat();
this.reconnectCount = 0;
resolve();
};
this.ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'auth') {
if (data.code === 0) {
console.log('WebSocket认证成功');
} else {
reject(new Error('认证失败'));
}
} else if (data.type === 'message') {
// 处理接收到的消息
await this.handleMessage(data.data);
} else if (data.type === 'heartbeat') {
console.log('收到心跳响应');
}
} catch (error) {
console.error('处理消息失败:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
reject(error);
};
this.ws.onclose = () => {
console.log('WebSocket连接关闭');
this.stopHeartbeat();
// 断线重连
if (this.reconnectCount < this.maxReconnectCount) {
this.reconnectCount++;
console.log(`尝试重连 (${this.reconnectCount}/${this.maxReconnectCount})`);
setTimeout(() => {
this.connect(userId, deviceId, token);
}, this.reconnectInterval);
}
};
} catch (error) {
console.error('连接WebSocket失败:', error);
reject(error);
}
});
}
// 启动心跳
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'heartbeat' }));
}
}, this.heartbeatInterval);
}
// 停止心跳
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// 处理WebSocket接收到的消息
async handleMessage(message) {
try {
console.log('收到实时消息:', message);
// 检查消息是否已经存在(去重)
const exists = await db.get('SELECT id FROM message_base_info WHERE message_id = ?', [message.messageId]);
if (!exists) {
// 存储新消息(标记为实时推送)
const currentTime = Date.now();
await db.run(`
INSERT OR IGNORE INTO message_base_info (message_id, sync_key, session_id, from_user_id, to_user_id, to_type, message_type, content, status, send_time, created_at, updated_at, version, sync_time, receive_time, receive_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [message.messageId, message.syncKey, message.sessionId, message.fromUserId, message.toUserId, message.toType, message.messageType, message.content, message.status, message.sendTime, message.createdAt, message.updatedAt, message.version || 0, currentTime, currentTime, 1]);
// 更新syncKey
this.lastSyncKey = message.syncKey;
await db.run(`
INSERT OR REPLACE INTO sync_config (key, value, updated_at)
VALUES ('last_sync_key', ?, ?)
`, [message.syncKey, currentTime]);
console.log(`实时消息存储成功,syncKey: ${message.syncKey}`);
} else {
console.log('消息已存在,跳过存储');
}
// 更新UI
updateUI(message);
} catch (error) {
console.error('处理实时消息失败:', error);
}
}
// 断开连接
disconnect() {
this.stopHeartbeat();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.reconnectCount = this.maxReconnectCount; // 停止重连
}
}
三、方案对比分析
3.1 综合对比表
| 对比维度 | 增量同步(基于版本号) | 增量同步 + 实时推送(混合方案) |
|---|---|---|
| 实时性 | ⭐⭐⭐ (秒级,依赖拉取频率) | ⭐⭐⭐⭐⭐ (毫秒级) |
| 网络效率 | ⭐⭐⭐⭐⭐ (只传增量) | ⭐⭐⭐⭐ (推送模式 + 增量) |
| 服务器压力 | ⭐⭐⭐⭐ (短连接,按需请求) | ⭐⭐⭐ (长连接多,但客户端按需加载可减轻压力) |
| 客户端资源 | ⭐⭐⭐⭐⭐ (按需请求) | ⭐⭐⭐⭐ (持续连接 + 按会话懒加载,资源可控) |
| 实现复杂度 | ⭐⭐ (游标管理、去重) | ⭐⭐⭐⭐ (连接管理、断线重连、去重) |
| 可靠性 | ⭐⭐⭐⭐⭐ (严格顺序、断点续传) | ⭐⭐⭐⭐⭐ (双重保障) |
3.2 方案特性深度对比
3.2.1 消息获取时机对比
| 维度 | 增量同步(基于版本号) | 增量同步 + 实时推送(混合方案) |
|---|---|---|
| 历史消息获取 | 应用启动时全量拉取,或定时轮询 | 首次进入会话时按需拉取,灵活高效 |
| 新消息获取 | 定时轮询(如每30秒)或用户手动刷新 | WS实时推送(毫秒级) + 定时兜底 |
| 多设备同步 | 完全依赖增量同步,需频繁轮询 | WS推送 + 增量同步,按需拉取更高效 |
| 离线消息 | 上线后自动增量同步 | 上线后自动增量同步 |
3.2.2 会话管理对比
| 维度 | 增量同步(基于版本号) | 增量同步 + 实时推送(混合方案) |
|---|---|---|
| 会话初始化 | 全局统一管理,无需特殊处理 | 按会话懒加载,每个会话独立维护last_sync_key |
| 未读数更新 | 增量同步时计算 | WS推送实时更新,或增量同步时计算 |
| 会话切换 | 无需特殊处理,消息已全部加载 | 切换会话时按需加载该会话消息 |
| 历史消息加载 | 全量加载,内存占用大 | 按需加载,内存占用可控 |
3.2.3 技术架构对比
| 维度 | 增量同步(基于版本号) | 增量同步 + 实时推送(混合方案) |
|---|---|---|
| 连接方式 | 纯HTTP短连接 | WebSocket长连接 + HTTP短连接 |
| 推送机制 | 客户端主动拉取(Pull模式) | 服务端主动推送(Push模式) + 客户端拉取(Pull模式) |
| 消息去重 | 增量同步时基于message_id去重 | WS推送和增量同步双源去重 |
| 断线处理 | 恢复网络后自动增量同步 | WS断线重连 + 增量同步补齐 |
| 状态同步 | 依赖增量同步拉取操作记录 | WS推送操作记录 + 增量同步兜底 |
3.3 场景化选型建议
3.3.1 适用增量同步(方案一)的场景
- 无实时性要求的IM系统
-
邮件类应用
-
客服系统(可接受秒级延迟)
-
内部通知系统
- 弱网环境或流量敏感场景
-
移动网络不稳定的地区
-
对流量有严格限制的应用
- 离线优先的应用
-
用户主要离线使用
-
网络连接不稳定
- 资源受限的设备
-
低配手机或嵌入式设备
-
WebSocket长连接资源消耗过大
3.3.2 适用增量同步 + 实时推送(方案二)的场景
- 社交聊天应用
-
需要毫秒级实时性
- 企业IM系统
-
多设备协同办公
- 已建立WebSocket连接的应用
-
应用启动时已建立WS连接
-
利用现有WS基础设施
- 按会话懒加载架构
-
首次进入会话时加载历史消息
-
WS实时推送新消息
-
多会话并发,按需加载
- 高并发场景
-
海量用户同时在线
-
需要兼顾实时性和性能
3.4 Electron桌面应用特殊考量
Electron桌面应用在消息同步方面有以下特殊性:
优势
- 本地数据库性能强
-
SQLite查询速度快
-
可存储大量历史消息
-
支持复杂查询和索引
- 网络稳定
-
桌面端网络通常比移动端稳定
-
WebSocket连接更可靠
-
断线重连更容易
- 资源充足
-
CPU、内存资源相对充足
-
可支持多个会话同时加载
-
WebSocket长连接资源消耗可接受
挑战
- 多窗口同步
-
Electron支持多窗口
-
多个渲染进程需要同步消息
-
主进程需要统一管理消息分发
- 跨进程通信
-
主进程 ↔ 渲染进程IPC通信
-
消息需要在多个进程间同步
-
需要考虑IPC通信性能
- 应用生命周期
-
应用最小化/最大化
-
系统休眠/唤醒
-
需要处理各种状态变化
建议
Electron应用推荐使用方案二(增量同步 + 实时推送),理由:
-
桌面端网络稳定:WebSocket长连接更可靠,实时性优势明显
-
资源充足:可支持多个会话同时加载和WebSocket连接
-
按会话懒加载:结合WS实时推送,既保证实时性又控制内存占用
-
多窗口场景:WS统一在主进程管理,多个渲染进程共享消息推送
具体实现要点:
-
主进程维护WebSocket连接,统一接收服务端推送
-
每个渲染进程按会话懒加载历史消息
-
主进程收到WS消息后,IPC广播给所有相关渲染进程
-
每个会话独立维护
last_sync_key_${sessionId} -
渲染进程首次加载会话时增量同步历史消息
四、总结与建议
4.1 核心总结
IM消息同步是一个系统工程,业界主流方案主要有两种:
-
增量同步方案(基于版本号):这是最基础、最可靠的同步方案,通过严格递增的序列号保证消息顺序和完整性,适合作为同步的基础设施。该方案无需维护WebSocket连接,实现简单,但实时性依赖客户端轮询频率。
-
增量同步 + 实时推送(混合方案):这是业界最主流、最完善的方案,在线时通过WebSocket实现毫秒级实时性,离线时通过增量同步补齐消息,兼顾实时性和可靠性。该方案特别适合已建立WebSocket连接、按会话懒加载消息的场景。
核心原则:
-
实时性 vs 可靠性:实时推送提供最佳体验但实现复杂,增量同步更可靠但实时性不足
-
网络适应性:不同网络环境下需要不同的同步策略
-
多设备一致性:消息状态在多设备间的同步是技术难点
-
按需加载:懒加载架构下,首次进入会话时加载历史消息,实时消息通过推送接收
-
没有银弹:每种同步策略都有其适用场景和局限性
4.2 实施建议
技术选型建议
- 协议层面:
-
实时推送:WebSocket(主流选择)或gRPC(高性能场景)
-
增量同步:HTTP/2 + RESTful API(兼容性好)
- 数据存储:
-
消息存储:MySQL/PostgreSQL(关系型,事务支持)
-
实时状态:Redis(内存缓存,快速读写)
-
离线队列:消息队列(RabbitMQ/Kafka)
- 序列号生成:
-
使用雪花算法等分布式ID生成器
-
保证严格递增和全局唯一
-
预留足够的位数,避免溢出
-
支持消息和操作的序列号关联
- 客户端框架:
-
Web端:原生WebSocket + IndexedDB
-
移动端:平台特定推送 + 本地数据库(SQLite/Core Data)
-
桌面端:Electron + SQLite
性能优化要点
-
连接管理:连接池、心跳优化、断线快速重连
-
数据传输:消息压缩、协议优化、批量传输
-
存储优化:数据库索引、读写分离、缓存策略
-
客户端优化:本地缓存、懒加载、虚拟滚动
-
去重机制:基于message_id去重,避免重复消息
-
按需加载:按会话懒加载历史消息,减少初始流量和内存占用
关键技术点
-
序列号设计:严格递增,全局唯一,支持消息操作(撤回、删除)
-
消息去重:客户端通过message_id判断消息是否已存在
-
断线重连:WebSocket断线后自动重连,重连后进行增量同步
-
ACK机制:客户端收到消息后发送确认,保证可靠性
-
心跳维持:定时发送心跳包,检测连接状态
-
兜底机制:定时增量同步作为兜底,防止消息丢失
-
会话懒加载:首次进入会话时按需加载历史消息,每个会话独立维护last_sync_key
-
多窗口同步:Electron多窗口场景下,主进程统一管理WebSocket,渲染进程按需拉取消息
4.3 特殊场景处理建议
4.3.1 会话懒加载场景
当采用按会话懒加载架构时(如Electron应用):
- 同步游标设计
-
使用
last_sync_key_${sessionId}为每个会话维护独立游标 -
首次进入会话时,若不存在则初始化为0(触发全量拉取)
- 消息获取策略
-
历史消息:首次进入会话时,通过增量同步接口拉取历史消息
-
实时消息:WebSocket推送实时消息,根据会话加载状态决定是否展示
- 未读消息处理
-
会话未打开时:仅更新会话列表的未读数,不存储消息详情
-
会话打开后:实时消息直接插入消息列表并展示
- WebSocket与增量同步协同
-
WebSocket:负责实时推送,保持长连接
-
增量同步:负责历史消息加载和断线补齐
4.3.2 多窗口同步场景(Electron特有)
- 主进程职责
-
维护WebSocket连接
-
接收服务端推送
-
通过IPC广播给所有渲染进程
- 渲染进程职责
-
按会话懒加载历史消息
-
监听IPC消息接收实时推送
-
根据当前打开的会话决定是否展示消息
- 消息分发策略
-
主进程收到消息后,判断哪些渲染进程打开了对应会话
-
仅向相关渲染进程IPC推送消息
- 本地数据库共享
-
主进程统一管理本地数据库
-
渲染进程通过IPC读写数据
4.3.3 断线重连场景
- 断线检测
-
WebSocket心跳超时
-
网络状态变化监听
- 重连策略
-
指数退避算法,避免频繁重连
-
最大重连次数限制
- 重连后补齐
-
重连成功后,对每个打开的会话进行增量同步
-
补齐断线期间的消息
-
更新会话的last_sync_key
- 用户感知优化
-
断线时显示连接状态提示
-
重连成功后自动刷新消息
版权声明
本文档内容为原创技术文档,仅供学习交流使用。文档中的代码示例、架构设计等技术内容为通用技术实践,不涉及任何特定公司的商业机密。如需引用本文档内容,请注明出处。