卷 IV · CH 14 · 深度 14/18

离线优先不是加缓存,是改写真相的归属

UI 永远读本地真相;网络不是另一份 UI 数据,而是负责把本地意图与服务端事实对账。

OFFLINE FIRSTOUTBOXIDEMPOTENCY
▷ 先答一下

客户端提交消息后超时,不知道服务端是否已写入。最安全的重试依据是什么?

  1. 换一个新消息 ID 再发
  2. 用同一个客户端幂等键重试
  3. 比较消息文本是否重复
  4. 永不重试,避免重复

缓存模式有两条读路径

常见「有网读 API,没网读缓存」把 UI 分成两条路径:网络成功时展示 DTO,离线时展示 Entity。映射、排序与错误行为很容易分叉。离线优先把路径收成一条:UI 始终观察本地数据库;同步器从网络拿到事实后事务写库;Room Flow 再把变化送给 UI。

这不是说本地永远比服务端权威,而是说对当前设备 UI 而言,本地是唯一可观察入口。服务端仍决定全局顺序、权限与最终确认,本地保存已知服务端事实与尚未对账的用户意图。

可以带走的判断:离线优先最先改变的不是缓存策略,而是 UI 的读模型与写入顺序。

发送消息是一笔本地事务

  1. 客户端生成稳定 clientMessageId
  2. 一个数据库事务写入消息行 Pending 与 outbox 命令。
  3. UI 从 Room 立刻看到待发气泡。
  4. WorkManager 在有网时读取 outbox,带同一个幂等键提交。
  5. 服务端若第一次见该键,创建消息;若已处理,返回同一结果。
  6. 客户端事务更新 serverId、服务端序号与状态,并删除 outbox 项。

关键是第 2 步原子化。若先写消息、后写队列,中间崩溃会留下永不发送的 Pending;反过来则可能有工作找不到消息。Room 事务把两项承诺绑在一起。

@Transaction
suspend fun queueMessage(entity: MessageEntity, command: OutboxEntity) {
    messageDao.insert(entity)
    outboxDao.insert(command)
}

重试不是重复执行

移动网络最麻烦的不是明确失败,而是结果未知:服务端已经提交,响应却在路上丢了。幂等键让同一业务动作重放多次,服务端效果仍只有一次。文本、时间戳、文件哈希都不能替代幂等键,因为不同合法消息可能相同。

重试要指数退避并加抖动,避免断网恢复时所有设备同时冲击服务端;永久错误如权限撤销应停止重试并进入可见失败状态;认证过期可以刷新一次令牌,但必须防止多个请求同时触发刷新风暴。

顺序由谁定义

客户端时间不可信:时钟会漂移,设备会离线,多端会并发。会话内顺序应由服务端单调序号或可排序游标定义。本地 Pending 尚无服务端序号时,需要明确展示政策,例如放在尾部并标记「发送中」,确认后按服务端序号归位。

推送只是一声门铃,不是真相本身。FCM 通知可能延迟、合并或丢失;收到后用增量游标拉取缺口并写库。应用回前台也做轻量对账,不能把每一条消息的可靠性寄托在每一条推送上。

系统设计骨架先说不变量:不丢、幂等、会话内顺序可解释、进程死亡可恢复、敏感数据最小落盘。再画本地表、outbox、Worker、API 幂等键、服务端序号与增量游标。最后讲失败矩阵和观测指标。
⌨ 自己跑一遍

用纯 Kotlin 模拟「服务端已收、响应丢失、客户端重试」。同一个幂等键两次提交,只产生一条服务端消息:

first attempt: server stored s-1, response lost
retry: server returns existing s-1
server message count=1
data class ServerMessage(val serverId: String, val text: String)

class FakeServer {
    private val byKey = mutableMapOf<String, ServerMessage>()

    fun send(key: String, text: String): ServerMessage =
        byKey.getOrPut(key) {
            ServerMessage("s-${byKey.size + 1}", text)
        }

    fun size() = byKey.size
}

fun main() {
    val server = FakeServer()
    val first = server.send("c-42", "urgent")
    println("first attempt: server stored ${first.serverId}, response lost")
    val retry = server.send("c-42", "urgent")
    println("retry: server returns existing ${retry.serverId}")
    println("server message count=${server.size()}")
}

Kotlin Playground 运行。把重试 key 改成新值,观察为什么「请求超时就生成新 ID」会制造重复消息。

▸ 在现实里

医疗沟通的弱网与多设备并非边角场景。消息可能包含临床决策,不能靠「多数时候顺序对」;但客户端也不能声称自己能保证全球严格顺序。合理合同是会话内服务端序号、明确的待发状态、可恢复重试与缺口同步。

✗ 这个直觉是错的

「监听 ConnectivityManager,有网就表示请求会成功。」网络能力是提示,不是承诺;DNS、TLS、门户认证与服务端仍可能失败。

直接尝试请求,按错误分类处理;Connectivity 只用于调度优化与界面提示,不能替代幂等、退避和持久队列。

◇ 面试收口

答案是第 2 项。相同客户端幂等键让服务端识别「这是同一个业务动作的再次尝试」,无论第一次响应是否丢失,都返回同一条消息。换 ID 会重复,比较文本会误伤合法重复内容,永不重试则会丢承诺。

这一章的一句话

把意图先原子地写进本地,再用幂等协议与服务端对账,离线才从异常变成正常状态。

下一章处理工程边界:Repository、依赖注入和模块化经常一起出现,但三者分别管理数据来源、对象装配和构建变化,不能用「都能解耦」含糊带过。