因为做了一个扩展,很多用户反馈希望能加入多端同步数据的功能。所以前段时间研究了一下对于离线优先应用的多端同步的方式。上来先看来一些现有的应用的同步实现方式以及一些现有的解决方案。然后最终选择了一个比较简易的方案。

现有的一些应用的实现方式

印象笔记的同步方式可以参考官方的文档。以一个笔记为最小粒度,基本思路是通过一个标识 USN 来表示笔记的版本。每一次更新都使得 USN 加一。如果更新前的 USN 和远程的 USN 不同的话说明发生了冲突。而印象笔记处理冲突的方式是创建一个新的笔记,同时保留远程的和本地的版本。然后由用户来选择是否删除其中一个。如果不发生冲突的话可以上传对一个笔记的修改。

Chromium 对于用户数据的同步方式一直是我很好奇的。尤其是对于书签这种又包含多个属性和类型,又可以多层级,又有序的这种复杂的数据类型。像是历史记录这种不可变的,时序的数据同步起来非常简单,只要完全增量就可以了。而书签这种就比较复杂。Chromium 的文档中有对于同步服务的大体框架。这篇文章中提到对于 Chromium 来说希望开发者可以尽量避免涉及同步服务的细节。而通过一个统一的框架来进行同步服务的开发。

Sync service

这个图大体描述了一个同步的流程:上传变更,下载变更并进行合并,之后将变更应用于本地数据。对于每种数据来说,需要实现一个 processor 用于应用更改;一个 merger 用于合并更改就可以了。具体如何合并和应用更改还是可以客户端决定的。而服务器还是会保留所有客户端上传的更改。

对于书签来说,比如本地进行了对一个书签的改名操作,然后下载变更后有另一个对这个书签的改名操作。这时遇到冲突的情况,Chromium 就可能会创建一个新的书签。导致书签的重复。而 Chromium 除此之外也会储存书签的很多其他的元数据。比如创建时间,修改时间和最后访问时间。对于最后访问时间。如果发生的冲突。那么直接保留一个值最大的修改操作就可以了。然而对于书签来说还有很多种不同的操作,比如重排序,移动到其他文件夹等。都可能有不同的策略。对于书签的同步的具体实现可以看 ./components/sync_bookmarks 这个目录的代码。

然后除了同步的流程之外,还需要有很多逻辑来控制何时下载,何时上传等状态,这个文件 编写了各种操作变更同步状态的逻辑。所以对于 Chromium 来说同步服务异常的复杂。除此之外还有 UI 层和数据层直接的同步。毕竟 UI 不能每一次操作都完整读写整个储存。所以可以 UI 和数据层共用相同的 processor 这种方式,UI 和数据层直接也只需要传送修改操作就行了。

并且也有很多策略来验证一致性,简化传输的数据等。chrome://sync-internals/ 可以查看 Chromium 所有同步所储存的数据,状态,发送的消息等信息。

一些现有的解决方案

shareDB

这个是为了一个 Node.js 多端同步应用开发框架 derbyjs 实现的一个同步数据库。基于 WebSocket 在多终端之间同步。Demo 中的效果还是很好的。但是这个库并不是离线优先,在离线时的操作都会被丢弃。

但是这个库有一定的参考价值,它的数据保存和传送使用了对于 JSON 的 Operational Transformation (OT)。并且作者自己实现了一套 OT 的标准和库:ottypes。主要作用是通过约定好的格式来简化传输时的数据大小。并且可以对多个操作进行撤销,交换顺序等。

PouchDB | CouchDB

根据官网的介绍写着来自 Apache CouchDB。后来发现这个 PouchDB 其实就是 CouchDB 接口的一个 JS 的实现。所以就直接研究这个 CouchDB 了。

这个 CouchDB 其实还是挺有意思的。官网首页就号称大数据和稳定性:

Seamless multi-master sync, that scales from Big Data to Mobile, with an Intuitive HTTP/JSON API and designed for Reliability.

看了一下文档发现是 erlang 实现的。主要同步原理是通过储存所有变动,保留所有冲突。所以才有了号称的稳定性。然后每次查询都可以看到所有冲突的版本。然后可以将所有版本展现给用户,由用户来决定保留哪个版本。

这样确实做到了离线优先和多端同步。但是过于重量级了。使用他需要额外的数据库实例,并且只是把冲突问题交给了用户来解决,和 evernote 的方式差不多了。而且客户端也需要保留很多冗余的数据。之后可以写一篇关于 CouchDB 的文章来专门介绍一下。之后的应用如果有需要也可以采用这个。但是这个项目还是想轻量一点。所以也就没有采用 PouchDB。

总结

很重要的一点就是最小数据粒度。对于最小粒度的数据,只需要一个记录了最后更新时间的时间戳就可以确定正确的状态了。如果远程的数据更新了,在客户端没有获取到远程更新的状态时如果客户端对该数据进行了更改,那么上传时远程只要直接更新到该客户端修改后的状态即可。因为客户端的修改也是来自用户。如果只有用户可以触发修改那么该状态也是经由用户确认的正确的状态。

最终的采用的方案

现在的方案是以一个列表为最小粒度。以最后修改时间为标识。保留最后修改时间最大的修改。当获取完整数据时先获取每个列表的 ID 和最后修改时间。如果最后修改时间大于本地的修改时间则下载完整列表数据进行替换。并且每次进行更新时,确保最后修改时间小于当前时间且大于上一个版本的时间。

参考