导读
- 阅读本文档需要有一定的后端基础和客户端基础,才能更好地理解本文档的核心内容。
- 本文档的设计是基于后端的视角来撰写可选方案,让客户端更加清楚明白要如何配合后端实现。
- 方案在移动端(Ios、Android、HarmonyOS)和桌面端(Electron、Flutter)都是通用的!!
- 本文档涉及到的相关技术栈有:electron、mysql、sqlite、ts、nestjs、typeorm
- 适用人群:leader、架构师、超级个体
一、简介
会话同步是即时通讯(IM)系统的核心机制之一,负责在不同设备、不同客户端之间同步会话列表及其元数据状态。
1.1 会话同步的核心目标
- 数据一致性:确保用户在不同设备上登录时,看到的会话列表(包括会话基本信息、未读计数、置顶/免打扰状态等)都是一致的
- 实时性:会话状态的变化能够及时同步到所有在线设备
- 可靠性:在网络不稳定的情况下,保证数据不丢失、不重复
- 高效性:最小化数据传输量,降低带宽消耗和服务器负载
二、业界生产企业级方案
2.1 方案一:增量拉取 + WebSocket推送
2.1.1 设计思路
本方案采用混合同步机制,结合HTTP请求和WebSocket推送两种方式:
- 增量拉取:客户端定期通过HTTP接口拉取会话更新,基于时间戳实现增量同步,减少数据传输量
- WebSocket推送:服务端通过WebSocket实时推送会话变化事件,保证关键事件的实时性
- 兜底机制:定期同步作为兜底,确保即使WebSocket推送失败,数据也能最终一致
这种设计既保证了实时性,又兼顾了可靠性和效率,是业界最主流的方案。
2.1.2 流程图
服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段 定期同步兜底 loop [每30秒] 实时推送(WebSocket) 会话更新(其他客户端触发) HTTP POST /sessions/sync?lastSyncTime=0 查询所有会话 返回会话列表 返回全量会话数据 IPC调用保存会话数据 保存到本地数据库 建立WebSocket连接(通过HTTP升级协议) 发送认证消息 HTTP POST /sessions/sync(增量拉取) 查询更新的会话(update_time > lastSyncTime) 返回增量数据 返回增量会话数据 IPC调用更新会话数据 更新本地数据 刷新UI 更新会话 更新成功 WebSocket推送session.updated事件 IPC调用更新会话数据 更新本地数据 刷新UI
2.1.3 流程描述
【客户端流程】
-
初始化阶段:
- 客户端启动时,检查本地存储的
lastSyncTime - 如果是首次启动(
lastSyncTime = 0),发送HTTP请求全量拉取会话列表 - 接收到会话数据后,保存到本地数据库
- 更新本地存储的
lastSyncTime为服务器返回的currentTime
- 客户端启动时,检查本地存储的
-
建立WebSocket连接:
- 客户端与服务端建立WebSocket连接
- 连接成功后,发送认证消息(包含用户Token)
- 监听WebSocket消息,处理各种会话事件
-
增量同步阶段:
- 客户端定期(如每30秒)发送增量拉取请求,携带
lastSyncTime - 服务端返回该时间点之后所有更新的会话数据
- 客户端更新本地数据库中的会话数据
- 更新
lastSyncTime为新的时间戳
- 客户端定期(如每30秒)发送增量拉取请求,携带
-
实时推送处理:
- 监听WebSocket推送的会话更新事件
- 接收到
session_update事件时,更新本地对应的会话 - 接收到
session_created事件时,添加新会话到本地 - 接收到
session_deleted事件时,删除本地对应的会话 - 触发UI刷新,显示最新的会话列表
-
断线重连:
- 检测到WebSocket连接断开时,自动尝试重连
- 使用指数退避策略,避免频繁重连
- 重连成功后,重新发送认证消息
【服务端流程】
-
处理增量拉取请求:
- 接收客户端的同步请求,包含
lastSyncTime和pageSize - 如果
lastSyncTime = 0,执行全量拉取,查询所有未删除的会话 - 如果
lastSyncTime > 0,执行增量拉取,查询update_time > lastSyncTime的会话 - 合并会话设置(置顶、免打扰等)
- 返回会话数据和当前服务器时间
currentTime
- 接收客户端的同步请求,包含
-
WebSocket连接管理:
- 接收客户端的WebSocket连接请求
- 验证客户端认证信息,提取用户ID
- 保存用户ID到WebSocket连接的映射关系
- 监听连接断开事件,清理映射关系
-
事件监听和推送:
- 监听会话相关的事件(创建、更新、删除、设置变更、未读数变更)
- 当事件触发时,根据会话的
user_id找到对应的WebSocket连接 - 通过WebSocket推送事件数据给客户端
- 推送失败时(连接已断开),不做处理,依赖定期同步兜底
-
定时任务:
- 定期清理过期的快照或日志数据
- 定期检查WebSocket连接状态,清理无效连接
2.1.4 优缺点
优点:
- 实时性好:WebSocket推送保证关键事件能够实时到达客户端
- 可靠性高:定期同步作为兜底机制,确保数据最终一致性
- 实现简单:相对其他方案,实现复杂度适中,易于理解和维护
- 适应性强:适用于大多数IM场景,从小型到中型系统都能胜任
- 数据传输量小:增量拉取只传输更新的数据,减少带宽消耗
- 扩展性好:可以方便地扩展其他实时功能(如消息推送)
缺点:
- 网络依赖:需要稳定的网络连接,网络不佳时用户体验会受影响
- 推送可能丢失:WebSocket推送可能因网络问题丢失,依赖定期同步补偿
- 服务器负载:需要维护大量的WebSocket长连接,增加服务器资源消耗
- 实现WebSocket:需要额外实现WebSocket服务和连接管理
- 心跳开销:需要定期发送心跳保持连接,增加少量网络开销
2.1.5 代码示例
【前端代码】会话同步服务完整实现
typescript
/**
* 会话同步服务
* 采用增量拉取 + WebSocket推送的混合同步机制
*/
class SessionSyncService {
private lastSyncTime: number = 0
private readonly syncInterval = 30000 // 30秒定期同步
private wsConnection: WebSocket | null = null
private reconnectAttempts: number = 0
private readonly maxReconnectAttempts: number = 5
private isInitialized: boolean = false
/**
* 初始化会话同步服务
*/
async initialize() {
if (this.isInitialized) {
return
}
// 1. 加载本地保存的同步时间
this.lastSyncTime = await this.loadLastSyncTime()
// 2. 首次全量拉取或增量拉取
await this.syncSessions()
// 3. 建立WebSocket连接接收实时更新
this.connectWebSocket()
// 4. 开启定期同步作为兜底
this.startPeriodicSync()
this.isInitialized = true
}
/**
* 同步会话(全量或增量)
*/
private async syncSessions() {
try {
const response = await httpClient.request({
url: '/saas-reim-im/saas/data/getSession',
method: 'post',
data: {
lastSyncTime: this.lastSyncTime || 0,
pageSize: 500
}
})
const sessions = response.data.sessions
// 存储到主进程数据库
await this.saveToLocalDB(sessions)
// 更新同步时间戳
this.lastSyncTime = response.data.currentTime || Date.now()
await this.saveLastSyncTime(this.lastSyncTime)
// 通知UI更新
this.emit('sessions:synced', sessions)
console.log(`[会话同步] 成功同步 ${sessions.length} 个会话`)
} catch (error) {
console.error('[会话同步] 同步失败:', error)
throw error
}
}
/**
* 建立WebSocket连接
*/
private connectWebSocket() {
const wsUrl = 'wss://api.example.com/ws/sessions'
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onopen = () => {
console.log('[WebSocket] 连接已建立')
// 重置重连计数
this.reconnectAttempts = 0
// 发送认证消息
this.sendAuthMessage()
}
this.wsConnection.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
this.handleWebSocketMessage(message)
} catch (error) {
console.error('[WebSocket] 消息解析失败:', error)
}
}
this.wsConnection.onerror = (error) => {
console.error('[WebSocket] 连接错误:', error)
}
this.wsConnection.onclose = () => {
console.log('[WebSocket] 连接已关闭')
// 尝试重连
this.attemptReconnect()
}
}
/**
* 处理WebSocket消息
*/
private async handleWebSocketMessage(message: any) {
switch (message.type) {
case 'session_update':
await this.handleSessionUpdate(message.data)
break
case 'session_created':
await this.handleSessionCreated(message.data)
break
case 'session_deleted':
await this.handleSessionDeleted(message.data)
break
case 'session_settings_changed':
await this.handleSessionSettingsChanged(message.data)
break
case 'unread_count_changed':
await this.handleUnreadCountChanged(message.data)
break
default:
console.warn('[WebSocket] 未知消息类型:', message.type)
}
}
/**
* 处理会话更新
*/
private async handleSessionUpdate(data: any) {
const session = data.session
// 更新本地数据库
await this.updateLocalSession(session)
// 通知UI更新
this.emit('session:updated', session)
console.log(`[会话更新] 会话 ${session.session_id} 已更新`)
}
/**
* 处理会话创建
*/
private async handleSessionCreated(data: any) {
const session = data.session
// 添加到本地数据库
await this.addLocalSession(session)
// 通知UI更新
this.emit('session:created', session)
console.log(`[会话创建] 会话 ${session.session_id} 已创建`)
}
/**
* 处理会话删除
*/
private async handleSessionDeleted(data: any) {
const sessionId = data.sessionId
// 从本地数据库删除
await this.deleteLocalSession(sessionId)
// 通知UI更新
this.emit('session:deleted', { sessionId })
console.log(`[会话删除] 会话 ${sessionId} 已删除`)
}
/**
* 处理会话设置变化
*/
private async handleSessionSettingsChanged(data: any) {
const settings = data.settings
// 更新本地会话设置
await this.updateLocalSessionSettings(settings)
// 通知UI更新
this.emit('session:settings_changed', settings)
console.log(`[会话设置] 会话 ${settings.session_id} 设置已更新`)
}
/**
* 处理未读数变化
*/
private async handleUnreadCountChanged(data: any) {
const { sessionId, unreadCount } = data
// 更新本地未读数
await this.updateLocalUnreadCount(sessionId, unreadCount)
// 通知UI更新
this.emit('session:unread_changed', { sessionId, unreadCount })
console.log(`[未读数] 会话 ${sessionId} 未读数为 ${unreadCount}`)
}
/**
* 发送认证消息
*/
private sendAuthMessage() {
const token = this.getAuthToken()
if (this.wsConnection && this.wsConnection.readyState === WebSocket.OPEN) {
this.wsConnection.send(JSON.stringify({
type: 'auth',
token: token,
timestamp: Date.now()
}))
}
}
/**
* 尝试重连
*/
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[WebSocket] 已达到最大重连次数,停止重连')
return
}
this.reconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000) // 指数退避,最大30秒
console.log(`[WebSocket] ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`)
setTimeout(() => {
this.connectWebSocket()
}, delay)
}
/**
* 开启定期同步
*/
private startPeriodicSync() {
setInterval(() => {
this.syncSessions().catch(error => {
console.error('[会话同步] 定期同步失败:', error)
})
}, this.syncInterval)
}
/**
* 保存到本地数据库(主进程)
*/
private async saveToLocalDB(sessions: any[]) {
await window.electron.ipcRenderer.invoke('session:save', sessions)
}
/**
* 更新本地会话(主进程)
*/
private async updateLocalSession(session: any) {
await window.electron.ipcRenderer.invoke('session:update', session)
}
/**
* 添加本地会话(主进程)
*/
private async addLocalSession(session: any) {
await window.electron.ipcRenderer.invoke('session:add', session)
}
/**
* 删除本地会话(主进程)
*/
private async deleteLocalSession(sessionId: string) {
await window.electron.ipcRenderer.invoke('session:delete', sessionId)
}
/**
* 更新本地会话设置(主进程)
*/
private async updateLocalSessionSettings(settings: any) {
await window.electron.ipcRenderer.invoke('session:update-settings', settings)
}
/**
* 更新本地未读数(主进程)
*/
private async updateLocalUnreadCount(sessionId: string, unreadCount: number) {
await window.electron.ipcRenderer.invoke('session:update-unread', { sessionId, unreadCount })
}
/**
* 加载本地保存的同步时间
*/
private async loadLastSyncTime(): Promise<number> {
const time = localStorage.getItem('session_last_sync_time')
return time ? parseInt(time) : 0
}
/**
* 保存同步时间
*/
private async saveLastSyncTime(time: number) {
localStorage.setItem('session_last_sync_time', time.toString())
}
/**
* 获取认证Token
*/
private getAuthToken(): string {
return localStorage.getItem('auth_token') || ''
}
/**
* 触发事件
*/
private emit(event: string, data: any) {
window.dispatchEvent(new CustomEvent(event, { detail: data }))
}
/**
* 销毁服务
*/
destroy() {
if (this.wsConnection) {
this.wsConnection.close()
this.wsConnection = null
}
this.isInitialized = false
}
}
// 导出单例
export const sessionSyncService = new SessionSyncService()
【后端代码】会话服务完整实现
typescript
/**
* 会话服务
* 提供会话数据的CRUD操作和同步功能
*/
@Injectable()
export class SessionService {
constructor(
@InjectRepository(SessionEntity)
private readonly sessionRepository: Repository<SessionEntity>,
@InjectRepository(SessionSettingsEntity)
private readonly settingsRepository: Repository<SessionSettingsEntity>,
@InjectRepository(SessionUnreadsEntity)
private readonly unreadsRepository: Repository<SessionUnreadsEntity>,
private readonly eventEmitter: EventEmitter2
) {}
/**
* 获取用户会话列表(支持全量和增量)
*/
async getUserSessions(
userId: string,
lastSyncTime: number = 0,
pageSize: number = 500
): Promise<{ sessions: Session[], currentTime: number }> {
const currentTime = Date.now()
if (lastSyncTime === 0) {
// 全量拉取
const sessions = await this.sessionRepository.find({
where: { user_id: userId, is_deleted: false },
order: { last_message_time: 'DESC' },
take: pageSize
})
return {
sessions: await this.mergeSessionSettings(sessions),
currentTime
}
} else {
// 增量拉取
const sessions = await this.sessionRepository.find({
where: {
user_id: userId,
is_deleted: false,
update_time: MoreThan(new Date(lastSyncTime))
},
order: { update_time: 'DESC' },
take: pageSize
})
return {
sessions: await this.mergeSessionSettings(sessions),
currentTime
}
}
}
/**
* 创建或更新会话
*/
async saveSession(sessionData: any): Promise<Session> {
const { session_id, user_id, ...rest } = sessionData
let session: SessionEntity
if (session_id) {
// 更新现有会话
session = await this.sessionRepository.findOneBy({ session_id })
if (!session) {
throw new Error(`会话 ${session_id} 不存在`)
}
Object.assign(session, rest)
session.update_time = new Date()
session = await this.sessionRepository.save(session)
// 触发更新事件
this.eventEmitter.emit('session.updated', {
sessionId: session.session_id,
session: await this.toSessionDTO(session)
})
} else {
// 创建新会话
session = this.sessionRepository.create({
...rest,
user_id,
session_id: this.generateSessionId(user_id, rest.to_id, rest.scene),
create_time: new Date(),
update_time: new Date()
})
session = await this.sessionRepository.save(session)
// 触发创建事件
this.eventEmitter.emit('session.created', {
sessionId: session.session_id,
session: await this.toSessionDTO(session)
})
}
return this.toSessionDTO(session)
}
/**
* 合并会话设置
*/
private async mergeSessionSettings(sessions: SessionEntity[]): Promise<Session[]> {
const sessionIds = sessions.map(s => s.session_id)
const settings = await this.settingsRepository.find({
where: { session_id: In(sessionIds) }
})
const settingsMap = new Map(settings.map(s => [s.session_id, s]))
return sessions.map(session => this.toSessionDTO(session, settingsMap.get(session.session_id)))
}
/**
* 生成会话ID
*/
private generateSessionId(userId: string, toId: string, scene: string): string {
if (scene === 'private') {
// 单聊:两个用户ID拼接,按用户ID从小到大排序
const [id1, id2] = [userId, toId].sort()
return `${id1}_${id2}`
} else {
// 群聊:直接使用群ID作为会话ID
return toId
}
}
/**
* 转换为DTO
*/
private async toSessionDTO(
session: SessionEntity,
settings?: SessionSettingsEntity
): Promise<Session> {
return {
...session,
pinned: settings?.is_pinned || false,
muted: settings?.is_disturb || false
} as Session
}
}
/**
* 会话控制器
*/
@Controller('sessions')
export class SessionController {
constructor(private readonly sessionService: SessionService) {}
/**
* 获取会话列表
*/
@Post('sync')
async syncSessions(
@Body('userId') userId: string,
@Body('lastSyncTime') lastSyncTime: number = 0,
@Body('pageSize') pageSize: number = 500
) {
return this.sessionService.getUserSessions(userId, lastSyncTime, pageSize)
}
}
【后端代码】WebSocket推送服务完整实现
typescript
/**
* WebSocket服务
* 实现会话实时推送
*/
@Injectable()
export class SessionWebSocketService {
private clients: Map<string, WebSocket> = new Map()
private heartbeatInterval: NodeJS.Timeout | null = null
constructor(private readonly eventEmitter: EventEmitter2) {
this.setupEventListeners()
this.startHeartbeat()
}
/**
* 用户连接
*/
handleConnection(client: WebSocket, userId: string) {
this.clients.set(userId, client)
console.log(`[WebSocket] 用户 ${userId} 已连接,当前连接数: ${this.clients.size}`)
}
/**
* 用户断开
*/
handleDisconnect(userId: string) {
this.clients.delete(userId)
console.log(`[WebSocket] 用户 ${userId} 已断开,当前连接数: ${this.clients.size}`)
}
/**
* 推送会话更新
*/
pushSessionUpdate(userId: string, session: Session) {
this.sendMessage(userId, {
type: 'session_update',
data: { session },
timestamp: Date.now()
})
}
/**
* 推送会话创建
*/
pushSessionCreated(userId: string, session: Session) {
this.sendMessage(userId, {
type: 'session_created',
data: { session },
timestamp: Date.now()
})
}
/**
* 推送会话删除
*/
pushSessionDeleted(userId: string, sessionId: string) {
this.sendMessage(userId, {
type: 'session_deleted',
data: { sessionId },
timestamp: Date.now()
})
}
/**
* 发送消息
*/
private sendMessage(userId: string, message: any) {
const client = this.clients.get(userId)
if (client && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message))
}
}
/**
* 设置事件监听器
*/
private setupEventListeners() {
this.eventEmitter.on('session.created', (event) => {
this.pushSessionCreated(event.session.user_id, event.session)
})
this.eventEmitter.on('session.updated', (event) => {
this.pushSessionUpdate(event.session.user_id, event.session)
})
this.eventEmitter.on('session.deleted', (event) => {
this.pushSessionDeleted(event.userId, event.sessionId)
})
}
/**
* 开始心跳
*/
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
this.clients.forEach((client, userId) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'heartbeat',
timestamp: Date.now()
}))
} else {
// 连接已断开,移除
this.clients.delete(userId)
}
})
}, 30000) // 每30秒发送一次心跳
}
}
/**
* WebSocket网关
*/
@WebSocketGateway({
cors: {
origin: '*'
}
})
export class SessionWebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly wsService: SessionWebSocketService,
private readonly jwtService: JwtService
) {}
/**
* 处理连接
*/
async handleConnection(client: WebSocket, request: IncomingMessage) {
try {
// 验证Token
const token = this.extractToken(request)
const payload = this.jwtService.verify(token)
const userId = payload.sub
// 保存连接
this.wsService.handleConnection(client, userId)
} catch (error) {
console.error('[WebSocket] 连接失败:', error)
client.close(4001, '认证失败')
}
}
/**
* 处理断开
*/
handleDisconnect(client: WebSocket) {
// WebSocketService已经通过userId管理连接
}
/**
* 提取Token
*/
private extractToken(request: IncomingMessage): string {
const authHeader = request.headers.authorization
if (!authHeader) {
throw new UnauthorizedException('未提供认证信息')
}
const [type, token] = authHeader.split(' ')
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('认证信息格式错误')
}
return token
}
}
2.2 方案二:全量拉取 + 增量推送
2.2.1 设计思路
本方案采用最简单的同步机制,结合全量拉取和增量推送:
- 全量拉取:客户端启动时,从服务端拉取完整的会话列表
- 增量推送:服务端通过WebSocket实时推送会话变化事件
- 简单可靠:实现简单,适用于数据量不大的场景
- 实时性好:WebSocket推送保证关键事件的实时性
- 上限设定:通过分页机制限制单次拉取的数据量,通常设置1000-5000条的上限,防止数据量过大导致网络传输慢或客户端内存溢出
这种设计适合小型IM系统或快速原型开发,数据量小,追求快速实现。
2.2.2 流程图
服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段 实时推送(WebSocket) 会话更新(其他客户端触发) 重新连接恢复 alt [网络断开后重连] HTTP GET /sessions(全量拉取) 查询所有会话 返回所有会话 返回完整会话列表 IPC调用保存会话数据 保存到本地数据库 建立WebSocket连接(通过HTTP升级协议) 发送认证消息 更新会话 更新成功 WebSocket推送更新 IPC调用更新会话数据 更新本地数据 刷新UI HTTP GET /sessions(全量拉取) 返回完整会话列表 IPC调用保存会话数据 覆盖本地数据 重新建立WebSocket连接
2.2.3 流程描述
【客户端流程】
-
初始化阶段:
- 客户端启动时,发送HTTP请求拉取完整的会话列表
- 接收所有会话数据,保存到本地数据库
- 建立WebSocket连接,接收实时推送
-
实时推送处理:
- 监听WebSocket推送的会话事件
- 接收到事件后,更新本地对应的会话数据
- 触发UI刷新,显示最新的会话列表
-
重新连接恢复:
- 检测到WebSocket连接断开时,尝试重新连接
- 如果重新连接失败,退回到定期全量拉取
- 重新连接成功后,重新拉取全量数据确保一致性
-
错误处理:
- 如果全量拉取失败,使用本地缓存数据
- 记录错误日志,定期重试
- 提供手动刷新按钮,让用户手动触发同步
【服务端流程】
-
全量数据接口:
- 提供接口返回用户的所有会话数据
- 支持分页,避免单次返回数据量过大,默认上限设置为1000条,可根据业务需求调整
- 实现会话列表全量拉取的上限设定,防止数据量过大导致网络传输慢或客户端内存溢出
- 包含会话基本信息和设置信息
-
WebSocket推送:
- 维护客户端的WebSocket连接
- 监听会话相关的事件(创建、更新、删除等)
- 通过WebSocket实时推送给对应的客户端
-
连接管理:
- 管理WebSocket连接的生命周期
- 处理连接断开和重连
- 验证客户端身份,确保安全性
-
数据一致性保证:
- 保证全量拉取和增量推送的数据一致性
- 使用事务确保操作的原子性
- 提供版本号机制,避免数据冲突
2.2.4 优缺点
优点:
- 实现简单:逻辑简单,开发周期短
- 可靠性高:全量拉取保证数据完整性
- 实时性好:WebSocket推送保证关键事件实时性
- 易于调试:数据流清晰,易于排查问题
- 适合小数据量:数据量小的时候性能表现好
- 快速原型:适合快速验证产品想法
缺点:
- 数据传输量大:每次全量拉取都传输所有数据
- 网络消耗大:数据量大时网络消耗显著
- 服务器压力大:全量拉取对数据库压力大
- 不适合大数据量:数据量大时性能急剧下降
- 实时性依赖WebSocket:WebSocket断开时实时性丧失
- 数据冗余:每次拉取都包含未变化的数据
- 需要合理设定上限值:上限值设置过小可能导致数据不完整,设置过大则无法有效控制数据传输量
2.2.5 业界代表
- 小型IM应用:初创公司或小团队开发的IM应用
- 快速原型:产品验证阶段的快速实现
- 内部工具:企业内部使用的简单通讯工具
- 早期版本应用:产品早期版本采用的简单方案
- 教育项目:教学演示项目采用的简单实现
2.2.6 代码示例
【前端代码】全量拉取+增量推送实现
typescript
/**
* 会话全量拉取+增量推送服务
*/
class SessionFullSyncService {
private wsConnection: WebSocket | null = null
private readonly reconnectInterval = 5000 // 5秒重连间隔
private reconnectTimer: NodeJS.Timeout | null = null
/**
* 初始化同步服务
*/
async initialize() {
// 1. 全量拉取会话数据
await this.fullSync()
// 2. 建立WebSocket连接
this.connectWebSocket()
// 3. 设置断线重连
this.setupReconnection()
}
/**
* 全量拉取会话数据
*/
private async fullSync() {
try {
const response = await httpClient.request({
url: '/api/sessions',
method: 'get',
params: {
page: 1,
// 会话列表全量拉取的上限设定,默认1000条,防止数据量过大
pageSize: 1000
}
})
const sessions = response.data.sessions
// 保存到本地数据库
await this.saveToLocalDB(sessions)
// 通知UI更新
this.emit('sessions:synced', sessions)
console.log(`[全量同步] 成功拉取 ${sessions.length} 个会话`)
} catch (error) {
console.error('[全量同步] 拉取失败:', error)
// 使用本地缓存
const localSessions = await this.loadFromLocalDB()
this.emit('sessions:synced', localSessions)
}
}
/**
* 建立WebSocket连接
*/
private connectWebSocket() {
const wsUrl = 'wss://api.example.com/ws/sessions'
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onopen = () => {
console.log('[WebSocket] 连接已建立')
// 发送认证消息
this.sendAuthMessage()
}
this.wsConnection.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
this.handleWebSocketMessage(message)
} catch (error) {
console.error('[WebSocket] 消息解析失败:', error)
}
}
this.wsConnection.onerror = (error) => {
console.error('[WebSocket] 连接错误:', error)
}
this.wsConnection.onclose = () => {
console.log('[WebSocket] 连接已关闭')
// 触发重连
this.scheduleReconnection()
}
}
/**
* 处理WebSocket消息
*/
private async handleWebSocketMessage(message: any) {
switch (message.type) {
case 'session_update':
await this.handleSessionUpdate(message.data)
break
case 'session_created':
await this.handleSessionCreated(message.data)
break
case 'session_deleted':
await this.handleSessionDeleted(message.data)
break
case 'heartbeat':
// 心跳响应
break
default:
console.warn('[WebSocket] 未知消息类型:', message.type)
}
}
/**
* 处理会话更新
*/
private async handleSessionUpdate(data: any) {
const session = data.session
// 更新本地数据库
await this.updateLocalSession(session)
// 通知UI更新
this.emit('session:updated', session)
console.log(`[实时推送] 会话 ${session.session_id} 已更新`)
}
/**
* 处理会话创建
*/
private async handleSessionCreated(data: any) {
const session = data.session
// 添加到本地数据库
await this.addLocalSession(session)
// 通知UI更新
this.emit('session:created', session)
console.log(`[实时推送] 会话 ${session.session_id} 已创建`)
}
/**
* 处理会话删除
*/
private async handleSessionDeleted(data: any) {
const sessionId = data.sessionId
// 从本地数据库删除
await this.deleteLocalSession(sessionId)
// 通知UI更新
this.emit('session:deleted', { sessionId })
console.log(`[实时推送] 会话 ${sessionId} 已删除`)
}
/**
* 发送认证消息
*/
private sendAuthMessage() {
const token = this.getAuthToken()
if (this.wsConnection && this.wsConnection.readyState === WebSocket.OPEN) {
this.wsConnection.send(JSON.stringify({
type: 'auth',
token: token,
timestamp: Date.now()
}))
}
}
/**
* 设置重连机制
*/
private setupReconnection() {
// 监听网络状态
window.addEventListener('online', () => {
console.log('[网络] 网络已恢复')
this.scheduleReconnection()
})
window.addEventListener('offline', () => {
console.log('[网络] 网络已断开')
this.clearReconnection()
})
}
/**
* 安排重连
*/
private scheduleReconnection() {
this.clearReconnection()
this.reconnectTimer = setTimeout(() => {
console.log('[WebSocket] 尝试重新连接')
this.connectWebSocket()
}, this.reconnectInterval)
}
/**
* 清除重连定时器
*/
private clearReconnection() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
/**
* 保存到本地数据库
*/
private async saveToLocalDB(sessions: any[]) {
// 清空本地数据库
await this.db.sessions.clear()
// 保存新数据
for (const session of sessions) {
await this.db.sessions.add(session)
}
}
/**
* 从本地数据库加载
*/
private async loadFromLocalDB(): Promise<any[]> {
return await this.db.sessions.toArray()
}
/**
* 更新本地会话
*/
private async updateLocalSession(session: any) {
await this.db.sessions.put(session)
}
/**
* 添加本地会话
*/
private async addLocalSession(session: any) {
await this.db.sessions.add(session)
}
/**
* 删除本地会话
*/
private async deleteLocalSession(sessionId: string) {
await this.db.sessions.delete(sessionId)
}
/**
* 获取认证Token
*/
private getAuthToken(): string {
return localStorage.getItem('auth_token') || ''
}
/**
* 触发事件
*/
private emit(event: string, data: any) {
window.dispatchEvent(new CustomEvent(event, { detail: data }))
}
/**
* 手动刷新
*/
async manualRefresh() {
console.log('[手动刷新] 触发全量同步')
await this.fullSync()
}
/**
* 销毁服务
*/
destroy() {
this.clearReconnection()
if (this.wsConnection) {
this.wsConnection.close()
this.wsConnection = null
}
}
}
// 导出单例
export const sessionFullSyncService = new SessionFullSyncService()
【后端代码】全量拉取+增量推送实现
typescript
/**
* 会话全量服务
*/
@Injectable()
export class SessionFullService {
constructor(
@InjectRepository(SessionEntity)
private readonly sessionRepository: Repository<SessionEntity>
) {}
/**
* 获取用户所有会话
*/
async getUserSessions(
userId: string,
page: number = 1,
// 会话列表全量拉取的上限设定,默认1000条,防止数据量过大
pageSize: number = 1000
): Promise<{ sessions: Session[], total: number }> {
const [sessions, total] = await this.sessionRepository.findAndCount({
where: { user_id: userId, is_deleted: false },
order: { last_message_time: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize
})
return { sessions, total }
}
/**
* 创建会话
*/
async createSession(sessionData: any): Promise<Session> {
const session = this.sessionRepository.create({
...sessionData,
create_time: new Date(),
update_time: new Date()
})
const savedSession = await this.sessionRepository.save(session)
// 触发事件
this.eventEmitter.emit('session.created', {
sessionId: savedSession.session_id,
session: savedSession
})
return savedSession
}
/**
* 更新会话
*/
async updateSession(sessionId: string, updates: any): Promise<Session> {
const session = await this.sessionRepository.findOneBy({ session_id: sessionId })
if (!session) {
throw new Error(`会话 ${sessionId} 不存在`)
}
Object.assign(session, updates)
session.update_time = new Date()
const updatedSession = await this.sessionRepository.save(session)
// 触发事件
this.eventEmitter.emit('session.updated', {
sessionId: updatedSession.session_id,
session: updatedSession
})
return updatedSession
}
/**
* 删除会话
*/
async deleteSession(sessionId: string, userId: string): Promise<void> {
await this.sessionRepository.softDelete({ session_id: sessionId })
// 触发事件
this.eventEmitter.emit('session.deleted', {
sessionId,
userId
})
}
}
/**
* 会话全量控制器
*/
@Controller('sessions')
export class SessionFullController {
constructor(
private readonly sessionService: SessionFullService
) {}
/**
* 获取用户所有会话
*/
@Get()
async getSessions(
@Query('userId') userId: string,
@Query('page') page: number = 1,
@Query('pageSize') pageSize: number = 1000
) {
return this.sessionService.getUserSessions(userId, page, pageSize)
}
}
/**
* WebSocket推送服务
*/
@Injectable()
export class SessionWebSocketPushService {
private clients: Map<string, WebSocket> = new Map()
constructor(private readonly eventEmitter: EventEmitter2) {
this.setupEventListeners()
}
/**
* 处理连接
*/
handleConnection(client: WebSocket, userId: string) {
this.clients.set(userId, client)
console.log(`[WebSocket推送] 用户 ${userId} 已连接`)
}
/**
* 处理断开
*/
handleDisconnect(userId: string) {
this.clients.delete(userId)
console.log(`[WebSocket推送] 用户 ${userId} 已断开`)
}
/**
* 推送会话更新
*/
private pushSessionUpdate(userId: string, session: Session) {
this.sendMessage(userId, {
type: 'session_update',
data: { session },
timestamp: Date.now()
})
}
/**
* 推送会话创建
*/
private pushSessionCreated(userId: string, session: Session) {
this.sendMessage(userId, {
type: 'session_created',
data: { session },
timestamp: Date.now()
})
}
/**
* 推送会话删除
*/
private pushSessionDeleted(userId: string, sessionId: string) {
this.sendMessage(userId, {
type: 'session_deleted',
data: { sessionId },
timestamp: Date.now()
})
}
/**
* 发送消息
*/
private sendMessage(userId: string, message: any) {
const client = this.clients.get(userId)
if (client && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message))
}
}
/**
* 设置事件监听器
*/
private setupEventListeners() {
this.eventEmitter.on('session.created', (event) => {
this.pushSessionCreated(event.session.user_id, event.session)
})
this.eventEmitter.on('session.updated', (event) => {
this.pushSessionUpdate(event.session.user_id, event.session)
})
this.eventEmitter.on('session.deleted', (event) => {
this.pushSessionDeleted(event.userId, event.sessionId)
})
}
}
2.3 方案三:增量快照 + 差异同步
2.3.1 设计思路
本方案基于快照和差异对比的同步机制,通过定期生成数据快照并比较差异来实现高效同步:
- 快照机制:服务端定期生成会话数据的完整快照,每个快照有唯一版本号
- 差异对比:客户端通过对比本地快照和云端快照的差异,只同步变化的部分
- 增量更新:基于差异结果,只传输需要更新的数据,极大减少传输量
- 版本管理:通过版本号管理快照,支持断点续传和回滚
这种设计适合大规模IM系统,数据量大但变化相对缓慢的场景。
2.3.2 流程图
服务端数据库 快照管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 快照管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段(客户端) 定期同步(客户端) alt [版本有更新] [版本无更新] loop [每5分钟] 快照生成(服务端定时任务) 定时任务触发 请求初始快照(version=0) 获取初始快照 查询当前会话数据 返回会话数据 生成快照(version=1) 存储快照 返回快照数据 返回完整快照数据 IPC调用保存快照数据 保存快照到本地数据库 请求最新快照版本号 查询最新版本号 查询最新版本号 返回最新版本号 返回最新版本号 返回最新版本号 请求版本差异(fromVersion=1, toVersion=2) 计算版本差异 查询快照差异数据 返回差异数据 返回差异数据 返回差异数据 IPC调用应用差异 应用差异更新 刷新UI IPC调用更新本地版本号 更新本地快照版本 无需同步 查询当前所有会话数据 返回会话数据 生成新快照(version=2) 计算与前一个快照的差异 存储快照并存储差异数据
2.3.3 流程描述
【客户端流程】
-
初始化阶段:
- 客户端首次启动时,请求服务端的初始快照(version=0)
- 接收完整的快照数据,保存到本地数据库
- 记录当前快照版本号
-
定期同步阶段:
- 客户端定期(如每5分钟)向服务端请求最新版本号
- 比较本地版本号和服务器版本号
- 如果版本号不同,请求两个版本之间的差异数据
- 接收差异数据,应用到本地数据库
- 更新本地快照版本号
-
差异应用阶段:
- 解析差异数据,包含新增、更新、删除的操作
- 按照顺序应用这些操作到本地数据库
- 保证操作的原子性和一致性
-
错误处理阶段:
- 如果差异应用失败,回滚到上一个版本
- 重新请求完整快照作为恢复机制
- 记录错误日志,便于调试
【服务端流程】
-
快照管理器(服务端组件):
- 快照生成阶段:定时任务(如每小时)触发快照生成,查询数据库中的当前所有会话数据,生成新快照并分配递增的版本号
- 差异计算阶段:比较两个快照之间的所有数据,识别新增的会话、更新的会话、删除的会话,生成差异数据集,包含操作类型和数据
- 请求处理阶段:接收客户端的版本号查询请求,返回当前最新的快照版本号,接收差异数据请求,根据fromVersion和toVersion返回差异,如果版本跨度太大,返回完整快照
- 存储管理阶段:管理快照存储,定期清理过期的快照,维护快照索引,提高查询效率,监控存储使用情况,避免存储溢出
-
业务逻辑层(服务端组件):
- 接收客户端的同步请求
- 调用快照管理器获取快照数据或差异数据
- 返回数据给客户端
2.3.4 优缺点
优点:
- 传输效率高:只传输差异数据,极大减少网络传输量
- 适合大规模数据:对于数据量大但变化缓慢的场景非常高效
- 版本管理完善:支持版本回滚和历史查看
- 断点续传支持:基于版本号支持断点续传
- 数据一致性高:快照机制保证数据一致性
- 适合离线同步:可以预先下载快照,离线使用
缺点:
- 实现复杂度高:需要实现快照生成和差异计算
- 存储开销大:需要存储多个版本的快照
- 实时性较差:依赖定期快照生成,实时性不如WebSocket
- 计算资源消耗:差异计算需要较多的CPU和内存资源
- 延迟同步:数据更新有延迟,不是实时同步
- 版本冲突可能:多端同时修改可能导致版本冲突
2.3.5 业界代表
- WhatsApp:在大规模消息同步中使用快照机制
- Telegram:采用快照+差异同步优化数据传输
- Signal:使用快照机制保证数据一致性
- 企业网盘系统:如Dropbox、Google Drive使用类似机制
- 数据库备份系统:如MySQL binlog机制类似
2.3.6 代码示例
【前端代码】快照差异同步服务实现
typescript
/**
* 会话快照差异同步服务
*/
class SessionSnapshotSyncService {
private currentSnapshotVersion: number = 0
private readonly syncInterval = 300000 // 5分钟同步一次
/**
* 初始化同步服务
*/
async initialize() {
// 1. 加载本地快照版本
this.currentSnapshotVersion = await this.getLocalSnapshotVersion()
// 2. 如果本地没有快照,请求初始快照
if (this.currentSnapshotVersion === 0) {
await this.requestInitialSnapshot()
}
// 3. 开启定期同步
this.startPeriodicSync()
}
/**
* 请求初始快照
*/
private async requestInitialSnapshot() {
try {
const response = await httpClient.request({
url: '/api/sessions/snapshot/initial',
method: 'get'
})
const snapshot = response.data.snapshot
// 保存快照到本地
await this.saveSnapshot(snapshot)
// 更新本地版本号
this.currentSnapshotVersion = snapshot.version
await this.saveLocalSnapshotVersion(snapshot.version)
console.log(`[快照同步] 初始快照加载完成,版本: ${snapshot.version}`)
} catch (error) {
console.error('[快照同步] 初始快照请求失败:', error)
throw error
}
}
/**
* 定期同步
*/
private async periodicSync() {
try {
// 1. 获取最新版本号
const latestVersion = await this.getLatestSnapshotVersion()
// 2. 比较版本号
if (latestVersion > this.currentSnapshotVersion) {
console.log(`[快照同步] 发现新版本: ${this.currentSnapshotVersion} -> ${latestVersion}`)
// 3. 请求差异数据
const diff = await this.getSnapshotDiff(this.currentSnapshotVersion, latestVersion)
// 4. 应用差异
await this.applyDiff(diff)
// 5. 更新版本号
this.currentSnapshotVersion = latestVersion
await this.saveLocalSnapshotVersion(latestVersion)
console.log(`[快照同步] 成功同步到版本: ${latestVersion}`)
} else {
console.log('[快照同步] 当前已是最新版本')
}
} catch (error) {
console.error('[快照同步] 同步失败:', error)
}
}
/**
* 获取最新版本号
*/
private async getLatestSnapshotVersion(): Promise<number> {
const response = await httpClient.request({
url: '/api/sessions/snapshot/latest-version',
method: 'get'
})
return response.data.version
}
/**
* 获取快照差异
*/
private async getSnapshotDiff(fromVersion: number, toVersion: number): Promise<SnapshotDiff> {
const response = await httpClient.request({
url: '/api/sessions/snapshot/diff',
method: 'post',
data: {
fromVersion,
toVersion
}
})
return response.data.diff
}
/**
* 应用差异
*/
private async applyDiff(diff: SnapshotDiff): Promise<void> {
// 应用新增的会话
for (const session of diff.added) {
await this.db.sessions.add(session)
this.emit('session:created', session)
}
// 应用更新的会话
for (const session of diff.updated) {
await this.db.sessions.put(session)
this.emit('session:updated', session)
}
// 应用删除的会话
for (const sessionId of diff.deleted) {
await this.db.sessions.delete(sessionId)
this.emit('session:deleted', { sessionId })
}
console.log(`[快照同步] 应用差异: 新增${diff.added.length}个, 更新${diff.updated.length}个, 删除${diff.deleted.length}个`)
}
/**
* 保存快照
*/
private async saveSnapshot(snapshot: Snapshot): Promise<void> {
// 清空本地数据库
await this.db.sessions.clear()
// 保存快照数据
for (const session of snapshot.sessions) {
await this.db.sessions.add(session)
}
// 保存快照元数据
await this.db.snapshotMetadata.add({
version: snapshot.version,
timestamp: snapshot.timestamp,
sessionCount: snapshot.sessions.length
})
}
/**
* 开启定期同步
*/
private startPeriodicSync() {
setInterval(() => {
this.periodicSync()
}, this.syncInterval)
}
/**
* 获取本地快照版本
*/
private async getLocalSnapshotVersion(): Promise<number> {
const version = localStorage.getItem('snapshot_version')
return version ? parseInt(version) : 0
}
/**
* 保存本地快照版本
*/
private async saveLocalSnapshotVersion(version: number) {
localStorage.setItem('snapshot_version', version.toString())
}
/**
* 触发事件
*/
private emit(event: string, data: any) {
window.dispatchEvent(new CustomEvent(event, { detail: data }))
}
}
// 导出单例
export const sessionSnapshotSyncService = new SessionSnapshotSyncService()
【后端代码】快照管理服务实现
typescript
/**
* 快照实体
*/
@Entity({ name: 'session_snapshot' })
export class SessionSnapshotEntity {
@PrimaryColumn({ type: 'bigint' })
version: number // 快照版本号
@Column({ type: 'json' })
data: any // 快照数据
@Column({ type: 'datetime' })
create_time: Date // 创建时间
@Column({ type: 'bigint' })
session_count: number // 会话数量
}
/**
* 快照差异实体
*/
@Entity({ name: 'session_snapshot_diff' })
export class SessionSnapshotDiffEntity {
@PrimaryColumn({ type: 'bigint' })
id: number
@Column({ type: 'bigint' })
from_version: number // 源版本
@Column({ type: 'bigint' })
to_version: number // 目标版本
@Column({ type: 'json' })
diff_data: any // 差异数据
@Column({ type: 'datetime' })
create_time: Date // 创建时间
}
/**
* 会话快照管理服务
*/
@Injectable()
export class SessionSnapshotService {
private currentVersion: number = 0
constructor(
@InjectRepository(SessionSnapshotEntity)
private readonly snapshotRepository: Repository<SessionSnapshotEntity>,
@InjectRepository(SessionSnapshotDiffEntity)
private readonly diffRepository: Repository<SessionSnapshotDiffEntity>,
@InjectRepository(SessionEntity)
private readonly sessionRepository: Repository<SessionEntity>
) {
this.initCurrentVersion()
}
/**
* 初始化当前版本号
*/
private async initCurrentVersion() {
const latestSnapshot = await this.snapshotRepository.findOne({
order: { version: 'DESC' }
})
this.currentVersion = latestSnapshot?.version || 0
}
/**
* 生成新快照
*/
async generateSnapshot(): Promise<number> {
// 1. 获取当前所有会话数据
const sessions = await this.sessionRepository.find({
where: { is_deleted: false },
order: { session_id: 'ASC' }
})
// 2. 递增版本号
this.currentVersion++
// 3. 保存快照
const snapshot = this.snapshotRepository.create({
version: this.currentVersion,
data: sessions,
create_time: new Date(),
session_count: sessions.length
})
await this.snapshotRepository.save(snapshot)
// 4. 计算与前一个快照的差异
if (this.currentVersion > 1) {
await this.calculateDiff(this.currentVersion - 1, this.currentVersion, sessions)
}
console.log(`[快照服务] 生成快照完成,版本: ${this.currentVersion}, 会话数: ${sessions.length}`)
return this.currentVersion
}
/**
* 计算差异
*/
private async calculateDiff(
fromVersion: number,
toVersion: number,
currentSessions: SessionEntity[]
): Promise<void> {
// 1. 获取前一个快照
const previousSnapshot = await this.snapshotRepository.findOneBy({
version: fromVersion
})
if (!previousSnapshot) {
return
}
const previousSessions: SessionEntity[] = previousSnapshot.data
// 2. 计算差异
const diff = this.computeDiff(previousSessions, currentSessions)
// 3. 保存差异
const diffEntity = this.diffRepository.create({
from_version: fromVersion,
to_version: toVersion,
diff_data: diff,
create_time: new Date()
})
await this.diffRepository.save(diffEntity)
console.log(`[快照服务] 计算差异完成: ${fromVersion} -> ${toVersion}`)
}
/**
* 计算两个快照的差异
*/
private computeDiff(
previous: SessionEntity[],
current: SessionEntity[]
): SnapshotDiff {
const previousMap = new Map(previous.map(s => [s.session_id, s]))
const currentMap = new Map(current.map(s => [s.session_id, s]))
const added: SessionEntity[] = []
const updated: SessionEntity[] = []
const deleted: string[] = []
// 检查新增和更新
for (const [sessionId, session] of currentMap) {
const prevSession = previousMap.get(sessionId)
if (!prevSession) {
// 新增
added.push(session)
} else if (!this.isEqual(prevSession, session)) {
// 更新
updated.push(session)
}
}
// 检查删除
for (const [sessionId, session] of previousMap) {
if (!currentMap.has(sessionId)) {
deleted.push(sessionId)
}
}
return { added, updated, deleted }
}
/**
* 比较两个会话是否相等
*/
private isEqual(session1: SessionEntity, session2: SessionEntity): boolean {
// 简单比较关键字段
const fields = ['session_name', 'avatar', 'last_message_content', 'last_message_time', 'unread_count']
for (const field of fields) {
if (session1[field] !== session2[field]) {
return false
}
}
return true
}
/**
* 获取最新版本号
*/
async getLatestVersion(): Promise<number> {
return this.currentVersion
}
/**
* 获取快照差异
*/
async getDiff(fromVersion: number, toVersion: number): Promise<SnapshotDiff> {
// 1. 检查是否可以直接获取差异
const diff = await this.diffRepository.findOneBy({
from_version: fromVersion,
to_version: toVersion
})
if (diff) {
return diff.diff_data
}
// 2. 如果差异不存在,计算差异
const fromSnapshot = await this.snapshotRepository.findOneBy({
version: fromVersion
})
const toSnapshot = await this.snapshotRepository.findOneBy({
version: toVersion
})
if (!fromSnapshot || !toSnapshot) {
throw new Error('快照不存在')
}
const previousSessions: SessionEntity[] = fromSnapshot.data
const currentSessions: SessionEntity[] = toSnapshot.data
return this.computeDiff(previousSessions, currentSessions)
}
/**
* 获取初始快照
*/
async getInitialSnapshot(): Promise<Snapshot> {
const latestSnapshot = await this.snapshotRepository.findOne({
order: { version: 'DESC' }
})
if (!latestSnapshot) {
// 如果没有快照,生成一个
const version = await this.generateSnapshot()
return this.getSnapshotByVersion(version)
}
return {
version: latestSnapshot.version,
sessions: latestSnapshot.data,
timestamp: latestSnapshot.create_time.getTime()
}
}
/**
* 根据版本获取快照
*/
async getSnapshotByVersion(version: number): Promise<Snapshot> {
const snapshot = await this.snapshotRepository.findOneBy({ version })
if (!snapshot) {
throw new Error(`快照 ${version} 不存在`)
}
return {
version: snapshot.version,
sessions: snapshot.data,
timestamp: snapshot.create_time.getTime()
}
}
/**
* 清理过期快照
*/
async cleanupOldSnapshots(keepCount: number = 10): Promise<void> {
const snapshots = await this.snapshotRepository.find({
order: { version: 'DESC' },
skip: keepCount
})
for (const snapshot of snapshots) {
// 删除快照
await this.snapshotRepository.delete({ version: snapshot.version })
// 删除相关的差异记录
await this.diffRepository.delete([
{ from_version: snapshot.version },
{ to_version: snapshot.version }
])
}
console.log(`[快照服务] 清理了 ${snapshots.length} 个过期快照`)
}
}
/**
* 快照控制器
*/
@Controller('sessions/snapshot')
export class SessionSnapshotController {
constructor(
private readonly snapshotService: SessionSnapshotService
) {}
/**
* 获取最新版本号
*/
@Get('latest-version')
async getLatestVersion() {
const version = await this.snapshotService.getLatestVersion()
return { version }
}
/**
* 获取初始快照
*/
@Get('initial')
async getInitialSnapshot() {
const snapshot = await this.snapshotService.getInitialSnapshot()
return { snapshot }
}
/**
* 获取快照差异
*/
@Post('diff')
async getDiff(
@Body('fromVersion') fromVersion: number,
@Body('toVersion') toVersion: number
) {
const diff = await this.snapshotService.getDiff(fromVersion, toVersion)
return { diff }
}
}
2.4 方案四:操作日志 + 版本号同步
2.4.1 设计思路
本方案基于操作日志和版本号的同步机制,通过记录所有会话操作的日志,客户端根据版本号拉取对应的操作:
- 操作日志:服务端记录所有会话操作的日志,包括创建、更新、删除、设置变更等
- 版本号机制:每个操作分配一个递增的全局版本号,客户端维护自己的当前版本号
- 顺序执行:客户端按照版本号顺序执行操作,保证数据一致性
- 断点续传:基于版本号实现断点续传,支持中断后继续同步
这种设计对数据一致性要求极高,适合企业级IM系统。
2.4.2 流程图
服务端数据库 操作日志管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 操作日志管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段(客户端) 定期同步(客户端) loop [每30秒] 会话操作(其他客户端触发) 用户创建会话 HTTP GET /operations/sync?fromVersion=0 查询用户当前版本号 查询用户版本号 返回当前版本号 查询操作日志(version > 0) 返回操作列表 返回操作列表和最新版本号 返回操作列表和最新版本号 IPC调用执行操作 按顺序执行操作 IPC调用更新本地版本号 更新本地版本号 HTTP GET /operations/sync?fromVersion=100 查询操作日志(version > 100) 查询操作日志 返回增量操作列表 返回增量操作和最新版本号 返回增量操作和最新版本号 IPC调用执行操作 按顺序执行操作 刷新UI IPC调用更新本地版本号 更新本地版本号 创建会话 记录操作日志(version=101) 保存操作日志 更新用户版本号
2.4.3 流程描述
【客户端流程】
-
初始化阶段:
- 客户端启动时,从本地存储读取当前版本号
- 如果是首次启动(版本号=0),发送请求拉取所有操作日志
- 接收到操作日志后,按照版本号顺序执行每个操作
- 更新本地存储的版本号为服务器返回的最新版本号
-
增量同步阶段:
- 客户端定期(如每30秒)发送同步请求,携带当前版本号
- 服务端返回该版本号之后的所有操作日志
- 客户端按照版本号顺序执行每个操作
- 更新本地版本号为新的版本号
-
操作执行:
- 根据操作类型执行对应的本地操作
- CREATE_SESSION:创建会话到本地数据库
- UPDATE_SESSION:更新本地会话数据
- DELETE_SESSION:删除本地会话
- UPDATE_SESSION_SETTINGS:更新会话设置
- UPDATE_UNREAD_COUNT:更新未读数
-
错误处理:
- 如果某个操作执行失败,记录失败的版本号
- 下次同步时,从失败的版本号开始重新拉取
- 保证所有操作最终都能正确执行
【服务端流程】
-
操作日志管理器(服务端组件):
- 记录操作日志:接收用户操作请求(创建、更新、删除会话等),执行数据库操作更新会话数据,递增全局版本号生成新的版本号,记录操作日志包含版本号、用户ID、操作类型、操作数据,更新用户的当前版本号
- 处理同步请求 :接收客户端的同步请求包含
fromVersion和limit,查询该用户的操作日志版本号大于fromVersion的记录,按照版本号升序返回操作日志,同时返回最新的版本号 - 用户版本号管理 :维护每个用户的当前版本号,首次同步时返回当前用户的版本号,后续同步时根据
fromVersion返回增量操作 - 定时清理:定期清理过期的操作日志(如30天前的日志),定期清理不活跃用户的版本号记录
-
业务逻辑层(服务端组件):
- 接收客户端的同步请求
- 调用操作日志管理器获取操作日志
- 返回操作日志给客户端
2.4.4 优缺点
优点:
- 数据一致性强:版本号机制保证数据严格一致性
- 可靠性高:顺序执行保证不丢失,断点续传保证不重复
- 可追溯:完整的操作日志便于审计和调试
- 支持离线:客户端可以在离线状态下缓存操作,上线后同步
- 冲突处理:版本号机制天然支持冲突解决
- 扩展性好:可以方便地扩展到其他数据类型的同步
缺点:
- 实现复杂:需要维护操作日志和版本号系统
- 服务器负载高:需要存储和查询大量操作日志
- 性能影响:日志记录和查询可能影响性能
- 存储开销大:操作日志会占用大量存储空间
- 清理复杂:需要合理规划日志清理策略
- 延迟较高:相比WebSocket推送,实时性略差
2.4.5 业界代表
- Slack:采用操作日志机制,保证多端数据一致性
- Microsoft Teams:使用版本号同步,支持离线操作
- Notion:基于操作日志实现实时协作
- Confluence:使用操作日志记录所有变更
- Google Docs:采用操作日志+版本号实现协作编辑
2.4.6 代码示例
【前端代码】操作日志同步服务实现
typescript
/**
* 会话操作日志同步服务
*/
class SessionOperationSyncService {
private currentVersion: number = 0
/**
* 初始化同步服务
*/
async initialize() {
// 1. 获取当前版本号
this.currentVersion = await this.getLocalVersion()
// 2. 拉取操作日志
await this.syncOperations()
// 3. 开启定期同步
this.startPeriodicSync()
}
/**
* 同步操作日志
*/
private async syncOperations() {
try {
const response = await httpClient.request({
url: '/api/sessions/operations/sync',
method: 'post',
data: {
fromVersion: this.currentVersion,
limit: 100
}
})
// 按顺序执行操作日志
for (const operation of response.data.operations) {
await this.applyOperation(operation)
}
// 更新本地版本号
this.currentVersion = response.data.toVersion
await this.saveLocalVersion(this.currentVersion)
console.log(`[操作日志同步] 成功同步 ${response.data.operations.length} 个操作`)
} catch (error) {
console.error('[操作日志同步] 同步失败:', error)
throw error
}
}
/**
* 应用操作日志
*/
private async applyOperation(operation: SessionOperation) {
try {
switch (operation.type) {
case 'CREATE_SESSION':
await this.createSession(operation.data)
break
case 'UPDATE_SESSION':
await this.updateSession(operation.data)
break
case 'DELETE_SESSION':
await this.deleteSession(operation.data.sessionId)
break
case 'UPDATE_SESSION_SETTINGS':
await this.updateSessionSettings(operation.data)
break
case 'UPDATE_UNREAD_COUNT':
await this.updateUnreadCount(operation.data)
break
default:
console.warn('[操作日志] 未知操作类型:', operation.type)
}
} catch (error) {
console.error(`[操作日志] 执行操作失败 (version=${operation.version}):`, error)
throw error
}
}
/**
* 创建会话
*/
private async createSession(data: any) {
// 存储到本地数据库
await this.db.sessions.add(data)
// 触发UI更新
this.emit('session:created', data)
console.log(`[操作日志] 创建会话: ${data.session_id}`)
}
/**
* 更新会话
*/
private async updateSession(data: any) {
// 更新本地数据库
await this.db.sessions.put(data)
// 触发UI更新
this.emit('session:updated', data)
console.log(`[操作日志] 更新会话: ${data.session_id}`)
}
/**
* 删除会话
*/
private async deleteSession(sessionId: string) {
// 从本地数据库删除
await this.db.sessions.delete(sessionId)
// 触发UI更新
this.emit('session:deleted', { sessionId })
console.log(`[操作日志] 删除会话: ${sessionId}`)
}
/**
* 更新会话设置
*/
private async updateSessionSettings(data: any) {
// 更新本地会话设置
await this.db.session_settings.put(data)
// 触发UI更新
this.emit('session:settings_changed', data)
console.log(`[操作日志] 更新会话设置: ${data.session_id}`)
}
/**
* 更新未读数
*/
private async updateUnreadCount(data: any) {
// 更新本地未读数
await this.db.session_unreads.put(data)
// 触发UI更新
this.emit('session:unread_changed', data)
console.log(`[操作日志] 更新未读数: ${data.session_id}`)
}
/**
* 开启定期同步
*/
private startPeriodicSync() {
setInterval(() => {
this.syncOperations().catch(error => {
console.error('[操作日志同步] 定期同步失败:', error)
})
}, 30000) // 每30秒同步一次
}
/**
* 加载本地版本号
*/
private async getLocalVersion(): Promise<number> {
const version = localStorage.getItem('operation_version')
return version ? parseInt(version) : 0
}
/**
* 保存本地版本号
*/
private async saveLocalVersion(version: number) {
localStorage.setItem('operation_version', version.toString())
}
/**
* 触发事件
*/
private emit(event: string, data: any) {
window.dispatchEvent(new CustomEvent(event, { detail: data }))
}
}
// 导出单例
export const sessionOperationSyncService = new SessionOperationSyncService()
【后端代码】操作日志管理服务实现
typescript
/**
* 会话操作日志实体
*/
@Entity({ name: 'session_operation_log' })
export class SessionOperationLogEntity {
@PrimaryColumn({ type: 'bigint' })
version: number // 操作版本号(自增)
@Column({ type: 'varchar', length: 64 })
user_id: string // 用户ID
@Column({ type: 'varchar', length: 32 })
operation_type: string // 操作类型
@Column({ type: 'json' })
operation_data: any // 操作数据
@Column({ type: 'datetime' })
create_time: Date // 操作时间
}
/**
* 用户版本号实体
*/
@Entity({ name: 'session_user_version' })
export class SessionUserVersionEntity {
@PrimaryColumn({ type: 'varchar', length: 64 })
user_id: string // 用户ID
@Column({ type: 'bigint' })
version: number // 当前版本号
@Column({ type: 'datetime' })
update_time: Date // 更新时间
}
/**
* 会话操作日志服务
*/
@Injectable()
export class SessionOperationLogService {
private globalVersion: number = 0
constructor(
@InjectRepository(SessionOperationLogEntity)
private readonly logRepository: Repository<SessionOperationLogEntity>,
@InjectRepository(SessionUserVersionEntity)
private readonly userVersionRepository: Repository<SessionUserVersionEntity>
) {
this.initGlobalVersion()
}
/**
* 初始化全局版本号
*/
private async initGlobalVersion() {
const latestLog = await this.logRepository.findOne({
order: { version: 'DESC' }
})
this.globalVersion = latestLog?.version || 0
}
/**
* 记录操作日志
*/
async logOperation(
userId: string,
operationType: string,
operationData: any
): Promise<number> {
// 递增全局版本号
this.globalVersion++
// 保存操作日志
const log = this.logRepository.create({
version: this.globalVersion,
user_id: userId,
operation_type: operationType,
operation_data: operationData,
create_time: new Date()
})
await this.logRepository.save(log)
// 更新用户版本号
await this.updateUserVersion(userId, this.globalVersion)
console.log(`[操作日志] 记录操作: version=${this.globalVersion}, type=${operationType}, userId=${userId}`)
return this.globalVersion
}
/**
* 获取用户操作日志
*/
async getUserOperations(
userId: string,
fromVersion: number,
limit: number
): Promise<{ operations: SessionOperationLog[], toVersion: number }> {
const operations = await this.logRepository.find({
where: {
user_id: userId,
version: MoreThan(fromVersion)
},
order: { version: 'ASC' },
take: limit
})
const toVersion = operations.length > 0
? operations[operations.length - 1].version
: fromVersion
return { operations, toVersion }
}
/**
* 获取用户当前版本号
*/
async getUserVersion(userId: string): Promise<number> {
const userVersion = await this.userVersionRepository.findOneBy({
user_id: userId
})
return userVersion?.version || 0
}
/**
* 更新用户版本号
*/
private async updateUserVersion(userId: string, version: number) {
const userVersion = await this.userVersionRepository.findOneBy({
user_id: userId
})
if (userVersion) {
await this.userVersionRepository.update(
{ user_id: userId },
{
version,
update_time: new Date()
}
)
} else {
await this.userVersionRepository.save({
user_id: userId,
version,
update_time: new Date()
})
}
}
/**
* 清理过期日志
*/
async cleanExpiredLogs(days: number = 30) {
const expireDate = new Date()
expireDate.setDate(expireDate.getDate() - days)
await this.logRepository.delete({
create_time: LessThan(expireDate)
})
console.log(`[操作日志] 清理了 ${days} 天前的过期日志`)
}
}
/**
* 会话操作日志控制器
*/
@Controller('sessions/operations')
export class SessionOperationController {
constructor(
private readonly operationLogService: SessionOperationLogService
) {}
/**
* 同步操作日志
*/
@Post('sync')
async syncOperations(
@Body('userId') userId: string,
@Body('fromVersion') fromVersion: number = 0,
@Body('limit') limit: number = 100
) {
const { operations, toVersion } = await this.operationLogService.getUserOperations(
userId,
fromVersion,
limit
)
return {
operations,
toVersion
}
}
/**
* 获取当前版本号
*/
@Get('version')
async getCurrentVersion(@Query('userId') userId: string) {
const version = await this.operationLogService.getUserVersion(userId)
return { version }
}
}
三、方案对比总结
3.1 对比表格
| 维度 | 方案一:增量拉取+WebSocket推送 | 方案二:全量拉取+增量推送 | 方案三:增量快照+差异同步 | 方案四:操作日志+版本号同步 |
|---|---|---|---|---|
| 实时性 | ⭐⭐⭐⭐⭐ (WebSocket推送) | ⭐⭐⭐⭐ (WebSocket推送) | ⭐⭐ (定期快照) | ⭐⭐⭐ (定期拉取) |
| 可靠性 | ⭐⭐⭐⭐ (兜底机制) | ⭐⭐⭐⭐ (全量保证) | ⭐⭐⭐⭐⭐ (快照机制) | ⭐⭐⭐⭐⭐ (顺序执行) |
| 数据一致性 | ⭐⭐⭐⭐ (增量同步) | ⭐⭐⭐⭐ (简单一致) | ⭐⭐⭐⭐⭐ (差异对比) | ⭐⭐⭐⭐⭐ (版本号保证) |
| 网络依赖 | ⭐⭐⭐⭐ (定期同步) | ⭐⭐⭐⭐⭐ (高依赖) | ⭐⭐⭐ (中等依赖) | ⭐⭐⭐⭐ (依赖网络) |
| 实现复杂度 | ⭐⭐⭐ (中等) | ⭐⭐ (低) | ⭐⭐⭐⭐⭐ (高) | ⭐⭐⭐⭐ (较高) |
| 服务器负载 | ⭐⭐⭐ (中等) | ⭐⭐⭐⭐ (高) | ⭐⭐⭐⭐⭐ (高) | ⭐⭐⭐⭐ (较高) |
| 存储开销 | ⭐⭐⭐⭐ (低) | ⭐⭐⭐⭐⭐ (低) | ⭐ (大) | ⭐⭐⭐ (中等) |
| 离线支持 | ⭐⭐ (有限) | ⭐ (有限) | ⭐⭐⭐ (中等) | ⭐⭐ (有限) |
| 冲突处理 | ⭐⭐⭐ (最后写入胜) | ⭐⭐⭐ (简单) | ⭐⭐⭐ (中等) | ⭐⭐⭐⭐ (版本号) |
| 适用场景 | 通用型IM系统 | 小型IM系统 | 大规模IM系统 | 企业级IM系统 |
3.2 选择建议
如果您的项目需要:
- 通用型IM系统 ,追求平衡的性能和可靠性 → 选择方案一
- 企业级IM系统 ,对数据一致性要求极高 → 选择方案四
- 大规模IM系统 ,数据量大但变化缓慢 → 选择方案三
- 小型IM系统 ,追求快速实现和简单维护 → 选择方案二
四、最佳实践方案
4.1 选择理由
基于通用IM应用的特点和需求,我们选择方案一:增量拉取 + WebSocket推送作为最佳实践方案:
- 技术成熟度:这是业界最主流、最成熟的方案
- 实现复杂度:在保证功能完整的前提下,实现复杂度可控
- 适合Electron环境:Electron应用运行在桌面端,网络相对稳定
- 扩展性好:后续可以方便地扩展其他功能
- 维护成本低:代码结构清晰,易于维护和调试
4.2 关键技术点
- 增量同步:使用时间戳实现增量同步,减少数据传输
- 实时推送:使用WebSocket推送关键事件,保证实时性
- 兜底机制:定期同步作为兜底,确保数据最终一致性
- 错误处理:完善的错误处理机制,提高系统稳定性
- 性能优化:数据库索引、网络优化、前端优化等多方面优化
- 安全性:WebSocket认证、限流、加密等安全措施
4.3 未来展望
随着IM系统的发展,会话同步机制也在不断演进,未来的发展方向包括:
- 边缘计算:利用边缘计算降低延迟
- P2P同步:实现去中心化的P2P同步
- AI优化:利用AI优化同步策略
- 跨云同步:实现多云环境的同步
- 实时协作:支持实时协作场景
五、参考资源
免责声明
- 技术文档性质:本文档为技术方案设计文档,内容基于通用技术实践和业界最佳实践编写
- 内容声明:文档中的技术方案、架构设计、代码示例等内容均为通用技术实现,不涉及任何特定公司或项目的商业机密、专利技术或内部架构
- 参考性质:本文档仅供技术参考和学习使用,不构成任何商业建议或技术实施承诺
- 使用风险:读者应根据自身项目的具体需求对本文档内容进行调整和优化,作者不对因使用本文档内容而造成的任何直接或间接损失承担责任
- 第三方引用:本文档引用的第三方技术文章、开源项目、API文档等均为公开资料,引用时已注明出处
版权声明
本文档内容为原创技术文档,仅供学习交流使用。文档中的代码示例、架构设计等技术内容为通用技术实践,不涉及任何特定公司的商业机密。如需引用本文档内容,请注明出处。