0. 背景

多端同步是什么?

多端同步指的是:同一个账号在手机、平板、桌面端、Web 端等多个设备上使用时,数据能够在不同端之间保持一致。

典型场景:

  • 微信:手机、电脑、平板之间同步消息、会话、已读状态
  • 飞书/钉钉:多端同步聊天、文档、日程、通知
  • 备忘录:多端同步文本、附件、编辑记录
  • 音乐软件:同步收藏、播放列表、播放进度

这里以“微信这类多端 IM / 协同系统”为背景讲原理,但不讨论任何产品的真实内部实现,只从工程角度抽象其通用架构。

同步系统到底在同步什么?

不是简单地“把数据库复制一份”。 多端同步真正要同步的是:

  • 数据本身:消息、联系人、会话、设置
  • 数据状态:已读、撤回、删除、置顶、免打扰
  • 数据顺序:消息先后、操作先后
  • 数据版本:谁更新了什么,是否有冲突
  • 数据范围:某台设备是否只同步最近数据

1. 多端同步的整体架构

核心架构

一个完整的跨平台同步系统通常由客户端本地层、同步协议层、服务端同步层和存储层组成。

flowchart TD
    subgraph ClientA[Android 客户端]
        A1[UI]
        A2[KMP Shared Sync Core]
        A3[(本地数据库<br/>SQLDelight/Room)]
        A4[WebSocket/HTTP Client]
    end

    subgraph ClientB[iOS 客户端]
        B1[UI]
        B2[KMP Shared Sync Core]
        B3[(本地数据库<br/>SQLDelight/CoreData)]
        B4[WebSocket/HTTP Client]
    end

    subgraph Server[同步服务端]
        S1[接入层<br/>HTTP/WebSocket]
        S2[鉴权与设备管理]
        S3[同步引擎<br/>增量拉取/冲突处理]
        S4[消息推送服务]
    end

    subgraph Storage[存储与中间件]
        DB[(主数据库)]
        LOG[(操作日志 / Event Log)]
        MQ[消息队列]
        CACHE[(Redis<br/>在线状态/游标)]
    end

    A1 --> A2 --> A3
    A2 --> A4 --> S1
    B1 --> B2 --> B3
    B2 --> B4 --> S1
    S1 --> S2 --> S3
    S3 --> DB
    S3 --> LOG
    S3 --> MQ
    MQ --> S4
    S4 --> A4
    S4 --> B4
    S3 --> CACHE

一句话理解

客户端不是每次都“从零拉全量数据”,而是通过本地数据库 + 增量同步 + 推送通知,把远端变化持续合并到本地。

2. 同步系统的核心概念

2.1 本地优先(Local First)

为什么不是所有操作都先请求服务端?

如果用户发消息、改备注、标记已读时都必须等待服务端成功,网络稍差就会卡顿。 更好的体验是:

  1. 用户操作先写入本地数据库
  2. UI 立即根据本地数据刷新
  3. 同步引擎后台上传操作
  4. 服务端确认后更新本地状态
sequenceDiagram
    participant U as 用户
    participant UI as UI
    participant L as 本地数据库
    participant E as 同步引擎
    participant S as 服务端

    U->>UI: 发送消息
    UI->>L: 写入 pending 消息
    L-->>UI: 立即刷新页面
    E->>S: 上传 send_message 操作
    S->>S: 校验、落库、分配服务端序号
    S-->>E: 返回 ack
    E->>L: pending -> synced
    L-->>UI: 更新发送状态

2.2 增量同步

为什么需要游标?

多端同步不能每次都拉全量数据。服务端通常会给每个账号或会话维护一个递增游标:

  • server_seq:服务端全局或会话内递增序号
  • updated_at:更新时间戳
  • version:对象版本号
  • event_id:事件日志 ID

客户端保存自己已经同步到的位置,下次只拉取之后的变化。

flowchart LR
    A[客户端保存 lastSeq=120] --> B[请求 /sync?after=120]
    B --> C[服务端查询 seq > 120 的事件]
    C --> D[返回 121..150]
    D --> E[客户端按顺序合并]
    E --> F[本地 lastSeq 更新为 150]

2.3 操作日志(Operation Log)

同步的基础不是表,而是事件

直接同步数据库表会遇到很多问题:删除怎么表示?顺序如何保证?冲突如何判断? 因此同步系统更常见的做法是保存一条条操作日志:

  • message_created
  • message_recalled
  • conversation_read
  • contact_updated
  • note_edited
  • device_logged_in
flowchart TD
    A[用户操作] --> B[生成 Operation]
    B --> C[(Operation Log)]
    C --> D[更新业务表]
    C --> E[推送在线设备]
    C --> F[离线设备下次增量拉取]

2.4 设备与会话

多端系统一定要区分账号和设备

同一个用户可能有多台设备:

  • Android 手机
  • iPhone
  • iPad
  • macOS 客户端
  • Windows 客户端

每台设备都需要独立维护:

  • device_id
  • 登录态
  • 在线状态
  • 同步游标
  • 推送通道
erDiagram
    USER ||--o{ DEVICE : owns
    USER ||--o{ CONVERSATION : joins
    DEVICE ||--o{ SYNC_CURSOR : has
    CONVERSATION ||--o{ MESSAGE : contains
    MESSAGE ||--o{ MESSAGE_EVENT : produces

    USER {
        long user_id
        string nickname
    }
    DEVICE {
        string device_id
        long user_id
        string platform
        string push_token
    }
    SYNC_CURSOR {
        string device_id
        long last_seq
    }

3. 推拉结合:实时与离线

同步通常不是单纯推送,也不是单纯轮询

正确模式是:

  • 在线时通过 WebSocket 推送“有新变化”
  • 客户端收到通知后按游标拉取增量数据
  • 离线时服务端不强推,等设备上线后补拉
sequenceDiagram
    participant A as 手机端
    participant S as 同步服务
    participant B as 电脑端
    participant L as Operation Log

    A->>S: 发送消息
    S->>L: 写入 seq=501
    S-->>A: ACK seq=501
    S-->>B: WebSocket 推送 sync_hint(lastSeq=501)
    B->>S: /sync?after=480
    S->>L: 查询 481..501
    S-->>B: 返回增量事件
    B->>B: 合并到本地数据库

为什么推送里只放 hint?

推送通道可能不稳定,也可能乱序或丢失。 如果推送只负责告诉客户端“有更新”,真正数据通过 /sync 按游标拉取,就能保证最终一致。

4. 冲突处理

4.1 为什么会冲突?

多端同时修改怎么办?

例如:

  • 手机端把会话设置为置顶
  • 电脑端几乎同时取消置顶
  • 两个操作都离线发生,稍后才上传

服务端必须判断最终以哪个为准。

4.2 常见策略

策略说明适合场景
服务端时间优先以后到服务端的操作为准简单设置项
版本号 CAS上传时携带版本,不匹配则冲突资料编辑、配置修改
操作合并把操作当成事件合并聊天消息、点赞、计数
CRDT多端独立编辑后可自动收敛协作文档
人工解决提示用户选择版本重要文档
flowchart TD
    A[客户端上传操作<br/>baseVersion=3] --> B{服务端当前版本是否为 3?}
    B -->|是| C[应用操作]
    C --> D[version=4]
    B -->|否| E[发现冲突]
    E --> F{是否可自动合并?}
    F -->|是| G[合并并生成新版本]
    F -->|否| H[返回冲突信息给客户端]

IM 场景的常见做法

聊天消息通常不采用”覆盖式更新”,而是追加事件。 发送、撤回、已读、删除都可以看成事件,因此比文档编辑更容易保证最终一致。

4.3 分层冲突矩阵(生产级设计)

真实系统不只用一种冲突策略

生产级的同步引擎按数据类型分层,不同层选用不同策略:

层级数据类型冲突策略一致性模型分区容忍典型示例
L1幂等事件流追加操作日志(无需冲突处理)最终一致高消息发送、撤回、已读游标
L2主人翁数据LWW + HLC最终一致高昵称、头像、个人设置
L3协同数据版本向量 / CRDT最终-收敛高协作文档、白板
L4有状态聚合事务性 CAS + 人工仲裁强一致低订单、余额、库存

为什么要分层?

如果所有数据都用 CRDT 或强一致 CAS,服务端压力会指数级上升。按数据性质选择策略:越往上(L1→L4)冲突概率越低、但一旦冲突的代价越高;越往下对一致性的要求越强。

flowchart LR
    subgraph Client["客户端上传"]
        A["{dataType, payload, baseVersion}"]
    end

    subgraph Server["服务端冲突引擎"]
        B{"数据层级判断"}
        B -->|L1 事件流| C["直接追加到操作日志<br/>不检查冲突"]
        B -->|L2 主人翁| D["HLC 时间比较"]
        B -->|L3 协同| E["版本向量并发检查"]
        B -->|L4 聚合| F["CAS 乐观锁 + 事务"]
    end

    C --> G["写入成功"]
    D --> H{"HLC 新于当前?"}
    H -->|是| G
    H -->|否| I["Reject + 返回当前值"]
    E --> J{"存在并发?"}
    J -->|否| G
    J -->|是| K["CRDT 合并 / 标记冲突"]
    F --> L{"version == expected?"}
    L -->|是| G
    L -->|否| M["返回冲突 + 客户端重试"]

4.4 混合逻辑时钟(HLC)—— LWW 的真正生产级方案

为什么不能用 updated_at 做 LWW?

  1. 多设备系统时钟不同步:iOS 设备快 5 分钟 → “未来数据”覆盖”正确数据”
  2. 同一毫秒内可能有多个操作,时间戳精度不够
  3. 无法表示因果依赖关系

HLC(Hybrid Logical Clock) 结合物理时钟和逻辑计数器,保证:

  • 所有 HLC 值可全局比较(全序关系)
  • 不依赖设备时钟同步(物理时间偏差仅在比较时影响先后)
  • 同一毫秒内的并发操作通过 nodeId 字典序打破平局
// commonMain/kotlin/sync/hlc/HybridTimestamp.kt
package sync.hlc
 
import kotlinx.serialization.Serializable
import kotlin.math.max
 
/**
 * 混合逻辑时钟(Hybrid Logical Clock)时间戳。
 * 相比单纯 updated_at,HLC 可全局全序比较,
 * 且不要求各设备时钟同步。
 */
@Serializable
data class HybridTimestamp(
    val wallClockMillis: Long,     // 物理时间(NTP 同步或本地系统时间)
    val logicalTick: Int,          // 同一毫秒内递增的逻辑计数器
    val nodeId: String             // 设备/节点标识,用于打破最终平局
) : Comparable<HybridTimestamp> {
 
    companion object {
        var lastWallClock: Long = 0
        var lastLogicalTick: Int = 0
        val nodeId: String = generateNodeId()
 
        /**
         * 生成新的 HLC 时间戳。
         * 保证单调递增,即使物理时钟回拨。
         */
        fun generate(): HybridTimestamp {
            val now = currentWallClock()
            val tick = when {
                now > lastWallClock -> {
                    lastLogicalTick = 0
                    0
                }
                now == lastWallClock -> ++lastLogicalTick
                else -> {
                    // 物理时钟回拨:逻辑计数器递增,时间戳不变
                    ++lastLogicalTick
                }
            }
            lastWallClock = max(now, lastWallClock)
            return HybridTimestamp(lastWallClock, tick, nodeId)
        }
 
        /**
         * 从远端接收到的 HLC 更新本地状态。
         * 确保本地 HLC 永远 ≥ 收到的最大 HLC。
         */
        fun updateWithRemote(remote: HybridTimestamp) {
            if (remote.wallClockMillis > lastWallClock) {
                lastWallClock = remote.wallClockMillis
                lastLogicalTick = 0
            } else if (remote.wallClockMillis == lastWallClock) {
                lastLogicalTick = max(lastLogicalTick, remote.logicalTick) + 1
            }
        }
 
        private fun currentWallClock(): Long = System.currentTimeMillis()
        private fun generateNodeId(): String = "device-${platformDeviceId()}"
    }
 
    override fun compareTo(other: HybridTimestamp): Int {
        return when {
            this.wallClockMillis != other.wallClockMillis ->
                this.wallClockMillis.compareTo(other.wallClockMillis)
            this.logicalTick != other.logicalTick ->
                this.logicalTick.compareTo(other.logicalTick)
            else -> this.nodeId.compareTo(other.nodeId)
        }
    }
}

回拨处理说明

第 3 种分支处理的是”物理时间回拨”:当 NTP 校正或用户手动调时间导致系统时间比上次记录的值还小时,HLC 不后退时间戳,而是递增逻辑计数器,保证单调性不受影响。

// LWW Register 基于 HLC 的合并判断
fun lwwMerge(local: ValueWithHlc, remote: ValueWithHlc): ValueWithHlc {
    return if (remote.hlc >= local.hlc) remote else local
}

4.5 向量时钟——并发冲突检测的精确方案

向量时钟的核心思想

每个设备维护一个”版本视图”:{deviceA: 5, deviceB: 3, deviceC: 7},表示当前已知各设备产生的操作数。

冲突判断:

  • VC₁ 所有分量 ≤ VC₂ 对应分量 → VC₁ 是旧版本(VC₂ 为新)
  • VC₁ 有分量 > VC₂ 且 VC₂ 也有分量 > VC₁ → 并发冲突
flowchart TD
    A["设备 A 版本: {A: 2, B: 1}"] --> C{"与服务端版本比较"}
    B["服务端版本: {A: 1, B: 3}"] --> C
    C --> D{"A 的 2 > 1<br/>B 的 1 < 3"}
    D -->|双向比较均有大/小| E["判定: 并发冲突"]
    E --> F["按规则合并或标记"]
// commonMain/kotlin/sync/vc/VectorClock.kt
package sync.vc
 
import kotlinx.serialization.Serializable
 
/**
 * 向量时钟。
 * 映射:nodeId -> 该节点产生的操作计数。
 */
@Serializable
data class VectorClock(
    private val clock: Map<String, Int> = emptyMap()
) {
    /**
     * 本设备递增自己的计数器。
     */
    fun tick(nodeId: String): VectorClock {
        val newClock = clock.toMutableMap()
        newClock[nodeId] = (clock[nodeId] ?: 0) + 1
        return VectorClock(newClock)
    }
 
    /**
     * 合并两个向量时钟(取每个分量的最大值)。
     * 这是 CRDT 中 join 操作的基础。
     */
    fun merge(other: VectorClock): VectorClock {
        val allKeys = clock.keys + other.clock.keys
        val merged = allKeys.associateWith { key ->
            maxOf(clock[key] ?: 0, other.clock[key] ?: 0)
        }
        return VectorClock(merged)
    }
 
    /**
     * 检测本时钟是否"先于"other(所有分量 ≤)。
     * true 表示本时钟完全被 other 涵盖。
     */
    fun happensBefore(other: VectorClock): Boolean {
        if (this == other) return false
        val allKeys = clock.keys + other.clock.keys
        return allKeys.all { key ->
            (clock[key] ?: 0) <= (other.clock[key] ?: 0)
        }
    }
 
    /**
     * 检测是否与 other 存在并发冲突。
     * 即:!happensBefore(other) && !other.happensBefore(this)。
     */
    fun concurrent(other: VectorClock): Boolean {
        return !happensBefore(other) && !other.happensBefore(this)
    }
}

向量时钟的代价

  • 设备数 n → 向量长度 n。IM 场景 5-10 台设备可接受,但 IoT 场景数百台设备不可行
  • 生产优化方案:DVV(Dotted Version Vector)——用 {deviceId: maxVersion} 的摘要 + 每个值的 {dot: version} 标记,将复杂度从 O(n²) 降到 O(1) 常见情况

4.6 CRDT 的工程真相——选型前的权衡

CRDT 不是银弹

业界对 CRDT 有过度美化的倾向。下表列出各变体的真实工程代价:

CRDT 类型适用场景优点真实代价
G-Counter点赞数(只增不减)自动收敛,实现简单不能减,不适用于”取消点赞”
PN-Counter可增减的计数(如库存)支持增减内部两个 G-Counter,状态膨胀一倍
LWW-Register简单字段(昵称、标题)实现最简单丢失”为什么覆盖”的语义信息
RGA协作文本文本支持有序列表插入删除Tombstone 堆积→长文档性能退化严重
Map + LWWJSON 结构数据自动合并嵌套冲突时外层覆盖内层,粒度太粗

4.6.1 Tombstone 堆积问题(RGA 的核心缺陷)

文档版本 1: [A, B, C]
文档版本 2: [A, (B已删除), C]    ← B 标记为 tombstone(墓碑)
文档版本 3: [A, (B已删除), C, D] ← 插入 D,B 的 tombstone 仍在增长
...
文档版本 1000: tombstones 占 40% 空间

生产级解决:定期 cleanup + 快照

纯 CRDT 不适合长时间运行的协作文档。通常采用 CRDT + 定期快照(Snapshot):

  1. 每次操作写入增量 CRDT op log
  2. 每隔 N 个操作或 M 小时生成一份全量快照(移除 tombstone)
  3. 新加入的设备直接拉快照,再从快照后的增量开始同步

4.6.2 混合架构建议

纯 CRDT 不是最优解,
CRDT + OT(操作转换)混合架构 才是生产级方案:
 
┌─ 字符级编辑 ──→ OT(Google Docs / Figma 路线)
├─ 结构数据    ──→ CRDT Map + 版本向量
├─ 顺序列表    ──→ RGA(配合定期快照)
└─ 完整性校验  ──→ Merkle Tree(对比两端数据是否一致)

4.7 冲突策略选型决策树

flowchart TD
    Q1{"数据是否可以建模为<br/>追加事件流?"}
    Q1 -->|是| A["策略:操作日志追加<br/>(无需冲突处理)"]
    Q1 -->|否| Q2{"数据只有单一主人<br/>(如个人设置)?"}
    Q2 -->|是| B["策略:LWW + HLC<br/>最终一致,实现简单"]
    Q2 -->|否| Q3{"需要保证因果一致性?"}
    Q3 -->|是| Q4{"设备数规模"}
    Q4 -->|"≤ 20 台"| C["策略:向量时钟<br/>精确检测并发冲突"]
    Q4 -->|"> 20 台"| D["策略:DVV<br/>(Dotted Version Vector)"]
    Q3 -->|否| Q5{"需要无冲突自动合并?"}
    Q5 -->|是| Q6{"数据类型"}
    Q6 -->|"纯文本"| E["策略:RGA CRDT<br/>+ 定期快照"]
    Q6 -->|"JSON/结构化"| F["策略:Map+LWW<br/>+ 版本向量"]
    Q6 -->|"计数器"| G["策略:PN-Counter<br/>可增可减"]
    Q5 -->|否| Q7{"业务能容忍<br/>写入失败?"}
    Q7 -->|是| H["策略:CAS 乐观锁<br/>版本号校验"]
    Q7 -->|否| I["策略:人工仲裁<br/>标记冲突给用户"]

    style A fill:#4CAF50,color:#fff
    style B fill:#8BC34A,color:#fff
    style C fill:#FFC107
    style D fill:#FFC107
    style E fill:#FF5722,color:#fff
    style F fill:#FF5722,color:#fff
    style G fill:#FF5722,color:#fff
    style H fill:#F44336,color:#fff
    style I fill:#9C27B0,color:#fff

选型总结

  • IM/聊天类:90% 场景用 L1 事件追加,少数设置项用 L2 LWW+HLC
  • 协作文档/笔记:L3 CRDT(推荐 RGA + 快照回退)
  • 电商/金融:L4 CAS + 事务,必要时人工仲裁
  • 文件同步:Merkle Tree 比对文件块差异 + LWW 最后写入

5. Kotlin 跨平台实践方案

为什么用 Kotlin Multiplatform?

多端同步中,最容易重复造轮子的部分是:

  • 同步协议
  • 数据模型
  • 增量合并逻辑
  • 冲突处理
  • 重试队列
  • API DTO

这些逻辑与 Android / iOS UI 无关,适合放到 KMP 的 commonMain 中共享。

5.1 KMP 分层架构

flowchart TD
    subgraph Shared[KMP shared module]
        A[commonMain<br/>同步模型/协议/合并逻辑]
        B[commonMain<br/>Repository / UseCase]
        C[expect 接口<br/>本地存储/安全存储/网络状态]
    end

    subgraph Android[androidMain]
        D[actual 实现<br/>Room/DataStore/Keystore]
        E[Android UI<br/>Compose]
    end

    subgraph IOS[iosMain]
        F[actual 实现<br/>SQLDelight/Keychain]
        G[iOS UI<br/>SwiftUI/Compose]
    end

    A --> B --> C
    C --> D --> E
    C --> F --> G

5.2 共享数据模型

// commonMain/kotlin/sync/model/SyncModels.kt
package sync.model
 
import kotlinx.serialization.Serializable
 
@Serializable
data class SyncEvent(
    val eventId: String,
    val seq: Long,
    val type: SyncEventType,
    val aggregateId: String,
    val payload: String,
    val serverTimeMillis: Long
)
 
@Serializable
enum class SyncEventType {
    MESSAGE_CREATED,
    MESSAGE_RECALLED,
    CONVERSATION_READ,
    CONVERSATION_PINNED,
    CONTACT_UPDATED
}
 
@Serializable
data class PendingOperation(
    val operationId: String,
    val type: SyncEventType,
    val aggregateId: String,
    val payload: String,
    val baseVersion: Long?,
    val createdAtMillis: Long
)
 
@Serializable
data class SyncResult(
    val events: List<SyncEvent>,
    val latestSeq: Long,
    val hasMore: Boolean
)

5.3 平台无关的同步接口

// commonMain/kotlin/sync/core/SyncPorts.kt
package sync.core
 
import sync.model.PendingOperation
import sync.model.SyncEvent
import sync.model.SyncResult
 
interface LocalSyncStore {
    suspend fun getLastSeq(): Long
    suspend fun updateLastSeq(seq: Long)
    suspend fun loadPendingOperations(limit: Int): List<PendingOperation>
    suspend fun markOperationSynced(operationId: String)
    suspend fun saveEvents(events: List<SyncEvent>)
}
 
interface RemoteSyncApi {
    suspend fun pushOperations(operations: List<PendingOperation>)
    suspend fun pullEvents(afterSeq: Long, limit: Int): SyncResult
}
 
interface NetworkMonitor {
    fun isOnline(): Boolean
}

5.4 同步引擎

// commonMain/kotlin/sync/core/SyncEngine.kt
package sync.core
 
class SyncEngine(
    private val localStore: LocalSyncStore,
    private val remoteApi: RemoteSyncApi,
    private val networkMonitor: NetworkMonitor
) {
    suspend fun syncOnce() {
        if (!networkMonitor.isOnline()) return
 
        // 1. 先上传本地离线期间产生的操作
        pushPendingOperations()
 
        // 2. 再拉取服务端新增事件
        pullRemoteEvents()
    }
 
    private suspend fun pushPendingOperations() {
        val operations = localStore.loadPendingOperations(limit = 100)
        if (operations.isEmpty()) return
 
        remoteApi.pushOperations(operations)
 
        // 服务端成功接收后,再把本地 pending 标记为 synced
        operations.forEach { operation ->
            localStore.markOperationSynced(operation.operationId)
        }
    }
 
    private suspend fun pullRemoteEvents() {
        var lastSeq = localStore.getLastSeq()
        var hasMore: Boolean
 
        do {
            val result = remoteApi.pullEvents(afterSeq = lastSeq, limit = 200)
 
            // 必须先保存事件,再推进游标,避免中途崩溃造成数据缺失
            localStore.saveEvents(result.events)
            localStore.updateLastSeq(result.latestSeq)
 
            lastSeq = result.latestSeq
            hasMore = result.hasMore
        } while (hasMore)
    }
}

顺序非常关键

拉取增量时必须遵循:

  1. 保存事件
  2. 应用事件到本地业务表
  3. 推进游标

如果先推进游标再写数据,中途崩溃会导致客户端以为自己已经同步过,但本地其实缺数据。

5.5 expect / actual 处理平台差异

哪些东西不适合放 commonMain?

  • Android Keystore / iOS Keychain
  • 系统网络状态监听
  • 推送 token 获取
  • 后台任务调度
  • 平台数据库驱动初始化
// commonMain/kotlin/sync/platform/SecureStore.kt
package sync.platform
 
expect class SecureStore {
    suspend fun saveToken(token: String)
    suspend fun readToken(): String?
    suspend fun clearToken()
}
// androidMain/kotlin/sync/platform/SecureStore.android.kt
package sync.platform
 
actual class SecureStore {
    actual suspend fun saveToken(token: String) {
        // 实际项目中可使用 EncryptedSharedPreferences 或 Android Keystore
    }
 
    actual suspend fun readToken(): String? {
        // 从 Android 安全存储读取登录凭证
        return null
    }
 
    actual suspend fun clearToken() {
        // 清理 Android 本地凭证
    }
}
// iosMain/kotlin/sync/platform/SecureStore.ios.kt
package sync.platform
 
actual class SecureStore {
    actual suspend fun saveToken(token: String) {
        // 实际项目中调用 Keychain 保存登录凭证
    }
 
    actual suspend fun readToken(): String? {
        // 从 iOS Keychain 读取登录凭证
        return null
    }
 
    actual suspend fun clearToken() {
        // 清理 iOS 本地凭证
    }
}

6. 服务端同步接口设计

最小可用接口

接口作用
POST /sync/push上传本地 pending 操作
GET /sync/pull?afterSeq=xxx按游标拉取增量事件
GET /sync/snapshot首次登录或游标过旧时拉取快照
POST /device/register注册设备与推送 token
POST /device/logout设备退出登录
sequenceDiagram
    participant C as Client
    participant S as Sync Server
    participant DB as DB
    participant LOG as Event Log

    C->>S: POST /sync/push operations
    S->>DB: 校验并更新业务数据
    S->>LOG: 写入事件并分配 seq
    S-->>C: push ok

    C->>S: GET /sync/pull?afterSeq=100
    S->>LOG: 查询 seq > 100
    S-->>C: events + latestSeq

首次同步与游标过旧

如果一个设备很久没上线,服务端可能已经清理了早期操作日志。 此时不能继续增量同步,需要返回 SNAPSHOT_REQUIRED,让客户端拉取一份当前快照,再从新的游标开始同步。

7. 常见问题与解决方案

7.1 消息乱序

解决方式

  • 服务端分配单调递增 seq
  • 客户端按 seq 合并
  • 缺号时暂停推进游标并重新拉取

7.2 重复事件

解决方式

  • event_id 唯一
  • 本地事件表对 event_id 建唯一索引
  • 重复插入直接忽略

7.3 离线发送

解决方式

  • 本地先生成 operation_id
  • UI 显示 pending 状态
  • 网络恢复后自动上传
  • 服务端根据 operation_id 做幂等处理

7.4 多端已读

已读同步不是删除消息

已读状态可以建模成事件: conversation_read(conversationId, readSeq)

每端收到事件后,将本地会话已读游标推进到 readSeq。

flowchart LR
    A[手机端读到 seq=900] --> B[上传 conversation_read]
    B --> C[服务端写入 readSeq=900]
    C --> D[推送到电脑端]
    D --> E[电脑端更新未读数]

8. 总结

跨平台同步的核心

跨平台同步并不是简单的数据复制,而是:

  • 本地优先保证体验
  • 操作日志保证可追溯
  • 游标机制实现增量同步
  • WebSocket + HTTP 实现推拉结合
  • 幂等与版本号解决重复和冲突
  • Kotlin Multiplatform 复用同步模型和合并逻辑

最终目标是:每个端都可以离线工作,网络恢复后自动追上服务端,并且所有设备最终收敛到一致状态。