并发修改与网络延迟:冲突的两大元凶

说实话,很多同学一开始想不通——为什么离线同步就非得有冲突呢?

说白了,冲突的本质就两个:同一时间改了同一块数据,以及网络让时间变慢了。咱们这节课就来揪出这两个家伙的真面目。

一句话总结:并发修改导致数据版本分叉,网络延迟让分叉的人更多、时间更长。

1. 并发修改:谁动了我的奶酪?

我在项目中见过特别多这样的场景。用户A在手机上把商品加入购物车,用户B同时修改这条记录改成已删除。两个请求各自带着自己的状态快照发起同步。结果呢?A觉得自己成功了,B也觉得自己成功了。实际上,数据已经乱套了。

为什么会这样?

Compose 的状态快照本质上是某个时间点的一份“照片”。你修改的是照片里的值,不是原始值。当你把照片发回给服务端时,服务端拿它跟当前值一比较——发现已经变了。这就是经典的并发写冲突。

场景 节点A本地状态 节点B本地状态 服务端状态 结果
用户A修改 score: 10 score: 5 score: 0 A提交 → 10
用户B修改 score: 10 score: 5 score: 10 B提交 → 5?10?
最终冲突 服务端无法确定以谁为准

你想想看,如果两个节点都在离线状态下修改同一个状态快照,分叉就产生了。就像两个人同时站在岔路口,各自选了不同的路,还都觉得自己是对的。

2. 网络延迟:时间成了帮凶

并发修改本身并不是大问题——如果每个人改完立刻就知道别人改了,那就不会冲突。问题在于网络有延迟。

我记得有次做一个IM应用,用户发了一条消息后立刻切换网络(从WiFi切到4G)。结果本地提交的状态快照已经是新版本了,但服务端因为网络抖动,接收到的还是刚才的旧版本。两个状态快照的时间线完全错位了。

网络延迟造成的问题,说白了就三类:

  • 顺序错乱:先产生的操作后到达,后产生的操作先到达。服务端按到达顺序处理,跟本地顺序完全不一样。
  • 超时重试:本地把状态快照发给服务端后,因为网络延迟,超时了。本地自动重试,结果服务端收到两次同样的快照——或者更糟,收到一个已经过期的快照。
  • 连接中断:同步到一半,网络断了。本地拿到的状态快照是个半成品,下次同步时它可能跟完整的快照相冲突。

我曾经踩过的坑:有一次我们只在本地保存最新的状态快照,没有保留版本号。结果网络重新连接后,本地拿旧的状态覆盖了服务端的新状态——数据回滚了!用户看到所有修改都消失了,那场面……嗯,大家懂的。

3. 状态快照的分叉:本质是版本号冲突

其实,不管是并发修改还是网络延迟,最终体现在数据层面都是版本号冲突。

我习惯用一个简单的版本计数器来追踪状态快照的变化。每次修改,版本号+1。本地和服务端各自维护自己的版本号。

// 状态快照的基本结构
data class StateSnapshot(
    val id: String,
    val data: MutableState<T>,
    var version: Long = 0L
)

// 冲突检测逻辑
fun detectConflict(local: StateSnapshot, remote: StateSnapshot): Boolean {
    // 如果本地的版本号小于远程的版本号
    // 说明本地拿到的快照是旧的
    return local.version < remote.version
}

但是,光靠版本号还不够。我遇到过这种情况:两个节点都从版本0开始,各自做了两次修改。节点A的版本是2,节点B的版本也是2。但是两个人的修改完全不一样。服务端无法判断哪个优先级更高。

这时候,就需要更精细的冲突检测机制,比如向量时钟或者操作类型优先级。不过那是后面几节课的内容。这里你只需要记住:并发修改 + 网络延迟 = 状态快照分叉 = 版本号无法唯一确定最新状态。

小提示:如果你在做本地数据库+远程数据库的双向同步,可以考虑使用每行记录的时间戳作为版本号。但要注意时间同步的问题——我建议你用服务端时间戳,而不是设备本地时间戳。否则用户改了手机时间……你懂的。

4. 一个典型的冲突场景剖析

咱们来拆解一个真实的例子。用户在线下编辑了一个Todo列表。操作流程如下:

  1. 用户打开App,获取状态快照A(版本1,包含“买牛奶”)
  2. 用户离线删除“买牛奶”,本地生成快照B(版本2)
  3. 同时,另一个设备在线修改了同一事项,改成“买豆奶”,生成快照C(版本2)
  4. 用户回到网络,本地快照B(版本2)发到服务端
  5. 服务端发现快照B(版本2)跟快照C(也是版本2)冲突

两个版本都是2,但内容完全不同。服务端怎么处理?这就回到了我们最开始说的——谁动了我的奶酪?两个人都动了,都觉得自己是对的。

这就是冲突产生的完整链条。每一步都环环相扣,缺一个环节都不会发生冲突。但现实里,这些环节几乎同时发生——嗯,这就是为什么冲突无法完全避免,只能妥善处理。

下面这张图让你更直观地理解这个流程:

状态快照冲突产生流程 本地并发修改 两个操作同时进行 网络延迟 同步时间错位 状态快照分叉 版本号相同内容不同 服务端收到冲突 版本号无法决策 数据不一致 最终结果不确定 核心问题 1. 并发修改导致多个状态快照在同一版本号下产生不同内容 2. 网络延迟放大冲突窗口,让更多并发操作无法感知对方存在 3. 版本号仅能反映先后顺序,无法区分并发修改

5. 如何防避?先搞清楚“谁在写”

既然冲突不可避免,那咱们至少要做到——在冲突发生前,尽可能减少它的可能性。我总结了三个实用原则:

  • 原则一:减少并发窗口 —— 缩短离线状态下状态快照的生命周期。离线时间越长,别人修改的可能性越大。
  • 原则二:细化粒度 —— 不要整个页面共享一个状态快照。每个字段、每个列表项都维护自己的快照。这样即便冲突也只影响局部。
  • 原则三:明确写入者 —— 谁最后同步,谁拥有最终决定权?还是按角色优先?这个必须在架构设计阶段定好。

我个人的一个小技巧:在状态快照中额外携带一个“上次同步时间戳”。服务端在收到快照时,除了比较版本号,还可以比较时间戳。如果时间戳相差太远,说明这个快照可能已经非常老了——这时候可以直接丢弃,而不是参与冲突比较。

好了,这一章的核心内容就是这些。记住,冲突不是你代码写错了,而是分布式系统天生带有的“性格”。理解它,接受它,然后学会跟它共处。