在当下的世界,互联网已经如同水、电、气一般的广泛的存在于我们的生活中,构建离线应用好像不再必要。或者说,丢失网络连接的情况也只是暂时性的,因此现代软件越来越依赖网络连接。

但是如果有一天,产品经理告诉你,你正在开发的软件需要支持离线使用呢?

先别着急挠头,我们来看下软件需要在离线情况下遇到的挑战和问题,以及如何解决它们。

01. 场景和挑战

计算机网络是这个世界上最伟大的分布式系统,如果需要允许离线使用接入互联网的软件,那么摆在我们面前的第一道难关就是 CAP 定理。在分布式系统中,CAP 定理就像万有引力一样束缚着地球上的人们。

CAP 指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容忍性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能全部实现。这里需要解释几点:

  1. 分区容忍性(Partition tolerance)很多地方翻译成分区容错性,会非常难以理解。Partition tolerance 的意思是是否允许由不同的独立组件来完成同一个任务,和容错没有关系。
  2. CAP 强调时间,如果分区发生的时间足够短,可以看做同时。

也就是说,离线之后,分区必然存在,只能在一致性和可用性之间取舍。用矛盾论来描述就是,离线应用的矛盾是一致性和可用性之间的矛盾。

我总喜欢用现实世界的比喻来描述计算机问题,这里用一个典型的场景来描述 CAP:

在古代,有俩人合伙经营了一个戏园子,一开始他们在门口销售戏票。但是生意不好,于是想到可以带上戏票去闹市销售。

俩人各自带着一些戏票去东西两个街市售卖(分布式分区)。第一次各自带上一半的戏票出门,但是东市的生意更好很快卖完了,西市的生意不好还剩了很多。于是,俩人回去一合计,东市的人说我可以多卖一些,我们放一些票在戏园子作为公共票池,自己心里有数就行,卖了就回去取。

这样很好,销量有了大的提高,但是偶尔会出现东市多卖了,但是西市也卖超了,当俩人都回去取的时候出现了冲突。

为了减小这种情况的出现,俩人开始增加跑回来的频率,尝试减低冲突的产生。

在这个场景中,因为俩人无法和公共票池沟通,于是容易产生冲突。映射到 CAP 中:

  • 一致性(Consistency):是否发生了超卖的情况。
  • 可用性(Availability): 是否允许销售公共票池的票。
  • 分区容忍性(Partition tolerance): 是否允许俩人分开出去卖票。

如果允许俩人分开出去卖票并能够销售公共票池的票,可能出现超卖的情况;如果俩人走在一起(单体系统),什么问题也没有;如果超卖了老板出去贴脸道歉,也不是不可以。离线应用和分布式系统类似,挑战主要来自于一致性和可用性之间的权衡。类似的例子还有两军问题(注意区分拜占庭将军问题)。

除了这个挑战外,还有离线检测、数据同步的相关问题。离线检测是指如何高效和准确的检测到网络中断;数据同步则是如何在获得网络后将数据增量的同步到服务器端。

下面展开聊下这几种情况。

02. 协同冲突处理

协同冲突处理和分布系统共识算法,都是为了解决分布式系统下数据一致性产生的,但是往往容易被混同。

在现实中,我们会尽量的避免有状态的分布式组件存在。比如对于数据库来说,一般都是主从、主备结构,他们通过选举或者配置来确定谁是状态的持有者。因此,这类系统依赖一些选举算法如 Raft/ZAB 等。这方面已经非常成熟,实际上我们每天都在用的路由协议 OSPF 中使用了 DR/BDR 算法。

在一群节点中选取合适的 Leader 是共识算法要解决的问题,可以阅读拜占庭将军问题了解相关内容。在离线应用中,Leader 可以认为就是服务器上的数据库,所以不存在共识问题,需要将精力放到协同冲突处理上。

后赢策略

后赢策略就是躺平什么主动的行为都不做。比如有两个客户端对同一条数据进行更新,先上线的客户端对数据做出的修改,会被后面的数据直接覆盖。后赢策略一般是大部分离线应用的默认策略,有时候开发者甚至不会意识到这样有什么不妥之处。

后赢策略用于对数据一致性要求不高的情况,在早期的一些行业软件,比如会员管理系统、商超软件往往会简单的使用这种策略实现离线能力。

局部更新

在有些情况下,可以对后赢策略进行优化。比如对于一张协同的画布来说,看到的所有元素都可以被抽象成图的节点(边可以设计为特殊的节点),一些细节信息被设计为节点的属性。

在这种场景中,局部更新是比较好的一种策略,只需要简单的以节点为单位进行更新覆盖即可。

这种策略和后赢策略一样,只是将冲突带来的信息损失降低到业务可以接受的程度。

先赢策略

后赢策略的问题是会直接覆盖之前的提交,虽然优点是简单。在有些情况下,这种策略不太适合。因此,在一些离线应用设计的时候,会将本地的变更存储为事件(Activities),当然也会写入本地数据,只是会标记为脏数据(Dirty changes)。上线后,客户端发送所有的事件,由服务器执行业务提交,然后再同步数据回本地,并覆盖掉本地的脏数据。

这种策略适合业务一致性强,且要求离线操作的场景。当然,代价是需要传输的数据量大,性能比较差。

MVCC

除了使用后赢、先赢后策略外,MVCC(Multi-Version Concurrency Control) 是一个广泛存在于各种数据库中的算法,使用版本号来进行对比,避免读写锁。

我们也可以使用这个思路在应用层实现冲突控制。它的思路是对每条记录增加一个版本号,当发生冲突时进行版本对比即可,接受版较高的数据。

这种策略算是对后赢策略的改进,在不影响性能的情况下,提高一致性的准确性。

OT 算法

OT(Operation Transform)是一种因果一致性算法。它不仅能使用在离线操作中,也可以使用在线协同编辑中,各种在线文档基本使用了这种算法。

简单来说,OT 算法传输的是数据的偏移量。例如,用户 A 在初始文档 “abcde” 的第 3 个字符后面增加了 "x"。传数据的数据就是:

insert(3,'x')。

因果一致的算法不能用于有严格业务逻辑的场景,比如库存扣减。

CRDT 算法

OT 算法通常用于文本,但是如果需要将类似的策略应用到其他数据结构上,基于偏移的方法就不行了。所以 CRDT(Conflict-Free Replicated Data Types) 算法基于状态变化的方法处理冲突,CRDT 算是 OT 的超集,实际上他们之间没有太多关系。

基于偏移量的算法需要为不同的数据结构设计不同的操作方法,比如最简单的计数器,可以使用增加和扣减两种运算,并要求每一次状态的应用是幂等的。

CRDT 算法表述很复杂,但是基于这个思想的处理策略非常多见。在提交数据前,还可以对 CRDT 算法产生的状态变化进行合并,来改进性能。

03. 离线检测

离线应用的另外一个麻烦在离线检测上,我们可以直接使用网卡的连接状态来检测。

客户端想知道物理网络是否断开非常简单,操作系统提供了相关的 API。比如 Android 提供了 WifiManager; 在 Linux 中读取相关的设备文件就行; Win32 API 可以提供 Window 的网络资源。

在浏览器上可以使用 navigator.onLine/navigator.connection 两个属性来或者网络状态,也有相关的网络状态事件。

但是这种非常不准确。比如,即使路由器 WIFI 连接成功,但是可能没有互联网访问、防火墙限制、欠费等情况,实际上连接并没有建立。因此需要更具体的检查方法,TCP/IP 网络本质是数据包的网络,建立的虚拟连接,只能使用 Ping/Pong 机制。

如果是原生的 TCP 连接实现的 socket,按理说不用专门考虑离线检测问题,TCP keep-alive 提供了传输层的心跳机制,也就是定时打声招呼告诉连接的对方会话还在。

但是由于一些原因 TCP keep-alive 的心跳机制并不可靠。因为网络中的各种代理、网络设备具备解析 TCP 包的能力往往会过滤、篡改一些数据,所以需要在应用层自己实现心跳。

TCP 的心跳机制有这么几个特点:

  1. 默认是关闭的
  2. 默认超时时间是 2 小时(7200秒)
  3. 不是全链路的,比如客户端发出的 Ping 包,路由器帮忙回复了 Pong,根本起不到作用

另外 TCP 的心跳机制也只能检测连接是否存活,应用是否存活也是无法检测的。

如果我们使用更高级(协议栈的高级)一些的连接协议,比如 WebSocket、MQTT 连接。在这些协议中也是实现了自己的心跳机制,可以不需要我们自己再实现。

04. 数据同步

离线应用的另外一个问题是如何实现数据同步,尤其是数据增量的同步。说到数据同步,可能大家第一时间想到数据的主从复制、同步机制(当然也可以获取数据库的增量日志送入 MQ 来完成同步,这种做法叫做 Change Data Capture)。

这种做法在服务之间非常流行,但是对于客户端来说,这种方法极其不推荐。

原因如下:

  1. 难以处理数据校验和业务逻辑的校验
  2. 数据库模式变化非常难处理
  3. 多客户端版本问题
  4. 不稳定的网络

因此,离线应用的数据同步一般在应用层进行,通过 SQL 获取数据库的差异再进行数据同步。因此需要使用一些策略来完成这项工作,主要的实现方法有两种:时间戳、版本号。

时间戳

这种策略需要给数据库的每条记录增加一个最后一次更新的时间戳,当同步发生时候接收端发送最后一次同步时间戳,发送端根据时间戳排序查询即可。

这种策略简单、性能好,但是需要注意开启自动时钟同步,只能用于客户端能被受控的场景。

版本号

和时间戳类似,增加一个版本号,当数据修改后增加版本号(表级别,而不是行级别),再根据版本号发送数据。

这种策略需要手动更新版本号,如果需要配置触发器也需要每张表进行管理。

除了两种数据差异对比的策略之外,关于数据同步还有其他的注意事项:

  1. 幂等性实现。可以将 insert 和 update 转化为 upsert 实现幂等,让 insert 操作不会因为重复执行报错。
  2. 同步窗口。实现了幂等性后,可以给数据查询条件增加一个偏移量,修正潜在的误差。比如 select * from product where last_updated > (last_sync_time - window)
  3. 删除的数据同步。不建议传输删除操作,而是使用标记删除,并通过定时任务将一定时间后的数据清理掉。
  4. 避免双向数据同步。非常重要的一个注意事项,选择数据产生量大的一方作为数据的发送方,对小量的数据修改通过另外一条数据通道发送。一般的实践是通过 MQTT/XMPP 等消息机制发送数据,并在本地数据库标记为脏数据,建立单向的数据流。
Last Updated:
Contributors: lin