同步案例学习
不久之前,我和 Chris 一起为一个大型青年运动组织开发企业 iPad 应用。我们选择 Core Data 作为数据持久化工具,并根据需求定制了数据同步的解决方案。根据 Drew 文章中提到的同步方式分类表格,我们使用的是异步客户端-服务器(client-server)方式。
本文将对我们决策和实现的过程进行案例分析,以供大家学习如何定制自己的同步方案。我们的最终方案并不是完美或者普遍适用的,但是现阶段它能够满足我们的需求。
在我们深入研究之前,如果你对数据同步方案感兴趣(既然你在读这篇文章,我觉得这应该是肯定的),我强烈建议你去 Brent 的博客阅读一下 Verper 应用同步方案的系列文章。跟随 Brent 的思路来分析 Vesper 的同步方案实现将会是一次绝妙的阅读体验。
应用场景
如 「iCloud 和 Core Data」或者 Dropbox 数据存储 API 中所示,现在大部分的同步方案都面向同一用户多个设备之间数据同步的问题。不过我们面临的需求略有不同,我们的应用将会被部署在组织里的大约 50 个设备中,每个设备属于不同的组织成员,大家都对同一个数据集进行操作,我们需要在这些设备间进行数据同步。
数据本身结构复杂,包含大约一打实体和其间的各种关系。我们需要处理的数据量很大,真实情况下数据记录的量将迅速达到十万量级。
虽然大部分情况下组织成员都能够连上 Wi-Fi,网络连接的质量其实是相当差的。保证大部分情况下组织成员能够使用应用并访问数据集是非常重要的,所以我们需要实现离线情况下的各种数据操作。
要求
将上一小节描述的应用场景搞清楚之后,我们同步方案的需求其实已经相当清楚了:
- 无论有没有网络连接,每一台设备都能够访问完整的数据集。
- 因为网络连接不稳定,数据同步时发起的请求数量要尽可能少。
- 数据更改必须基于最新的数据,因为任何人都不应该在不知晓其他人修改的情况下覆盖那些改动。
设计
API
由于数据模型的嵌套结构和可能出现的高网络延迟,传统的 REST 风格 API 并不是一个好选择。例如为了在应用中显示一个仪表盘(dashboard)视图,必须遍历好几个数据层级来获取所需的所有数据:队伍、队员联盟、队员、单屏显示(screen)和单屏显示元素(screen item)。如果我们分别获取这几类数据,在数据更新完毕之前我们需要发起很多次请求。
实际中我们采用了更原子化的操作,在同步数据量不变的情况下发起请求数更少。客户端与服务器仅使用一个 API 接口进行交互:/sync。
为了实现这个方案,我们需要自定义客户端与服务器交互的数据格式,从而在一次数据同步同求中包含所有需要的数据。
数据格式
客户端与服务器之间通过自定义的 JSON 格式数据进行交互。无论请求由谁发起,都采用同样的格式,如下是一个简单的示例:
{
"maxRevision": 17382,
"changeSets: [
...
]
}
上例中的 JSON 数据顶层含有 maxRevision
和 changeSets
两个 key。maxRevision
用來唯一标识客户端当前数据版本的版本号,changeSets
则是一个以数据修改集(change set)为元素的列表,如下所示:
{
"types": [ "users" ],
"users": {
"create": [],
"update": [ 1013 ],
"delete": [],
"attributes": {
"1013": {
"first_name": "Florian",
"last_name": "Kugler",
"date_of_birth": "1979-09-12 00:00:00.000+00"
"revision": 355
}
}
}
}
顶层的 types
对应这个修改集中涉及的所有数据实体类型。每个类型又会对应一个针对它自己的修改集合,包含创建(create
)、更新(update
)和删除(delete
)操作,每个操作对应指定的记录 ID。这些 ID 最后会对应到针对这条记录的哪些属性(attributes
)进行了新建或更新操作。
这套数据结构参考了之前 Web 端使用的数据结构,当时采用的结构有利于原有客户端的数据处理,也同样满足现在的需求。
接下来我们看一个复杂一点的例子。假设我们为一台设备上其中一名队员添加了新的单屏显示数据,当需要同步到服务器上时,如下是请求中包含的数据结构:
{
"maxRevision": 1000,
"changeSets": [
{
"types": [ "screen_instances", "screen_instance_items" ],
"screen_instances": {
"create": [ -10 ],
"update": [],
"delete": [],
"attributes": {
"-10": {
"screen_id": 749,
"date": "2014-02-01 13:15:23.487+01",
"comment": ""
}
}
},
"screen_instance_items: {
"create": [ -11, -12 ],
"update": [],
"delete": [],
"attributes": {
"-11": {
"screen_instance_id": -10,
"numeric_value": 2
},
"-12": {
...
}
}
}
}
]
}
注意其中涉及的记录 ID 是负数,这是因为它们是新建的条目。新建的 screen_instance
条目 ID 是 -10
,在后面的 screen_instance_items
条目中引用到了这个 ID 作为外键。
当服务器处理完这个请求后(假设没有冲突或者权限问题),发回给客户端的响应请求中将包含如下 JSON 数据:
{
"maxRevision": 1001,
"changeSets": [
{
"conflict": false,
"types": [ "screen_instances", "screen_instance_items" ],
"screen_instances": {
"create": [ 321 ],
"update": [],
"delete": [],
"attributes": {
"321": {
"__oldId__": -10
"revision": 1001
"screen_id": 749,
"date": "2014-02-01 13:15:23.487+01",
"comment": "",
}
}
},
"screen_instance_items: {
"create": [ 412, 413 ],
"update": [],
"delete": [],
"attributes": {
"412": {
"__oldId__": -11,
"revision": 1001,
"screen_instance_id": 321,
"numeric_value": 2
},
"413": {
"__oldId__": -12,
"revision": 1001,
...
}
}
}
}
]
}
客户端在请求中包含版本号 1000
,而服务器返回版本号 1001
,同时将 1001
这个版本赋予所有这次新建成功的记录。(从服务器返回的版本号只增加了 1 我们可以知道客户端的修改是基于最新数据进行的。)
原来的负数 ID 现在已经被服务器用真实的 ID 替换。为了保留记录间的联系,负数外键也被相应更新成了实际的 ID。但是客户端仍然可以获得临时负数 ID 与服务器返回的永久性正数 ID 之间的关联关系,因为服务器将临时负数 ID 作为记录属性的一部分返回了。
如果客户端的修改不是基于最新的数据(假如客户端的版本号是 995
),服务器会返回多个修改集以将客户端数据更新到最新版本。具体来说,服务器会将 995
版本到 1000
版本的更新操作与客户端发送的 1001
版本一起返回。
解决冲突
如前所述,在这个所有人操作同一套数据的应用场景下,任何人都不应该在不知晓其他人修改的情况下覆盖那些改动。我们采取的方案就是只要你没有看到其他人对数据的最新修改,你就不能直接对这些修改进行覆盖。
有了版本号的帮助,这个方案变得很容易实现。客户端发送给服务器的任何修改,都包含有对应数据条目的版本号。因为客户端从来不修改已有版本号,所以版本号反映了客户端上一次与服务器交互的数据情况。服务器就可以根据版本号搜索对应的条目,并屏蔽基于非最新数据的修改。
这个设计的优雅之处在于采用的数据交互格式允许事务性修改。JSON 数据中的一个修改集可以包含对不同数据实体的多个修改。在服务器端,修改集中所有修改以事务方式进行,如果其中任何操作导致冲突,则之前的操作全部回滚,然后服务器为该修改集加上冲突(conflict
)标识返回给客户端。
问题在于客户端在冲突发生后如何将数据恢复到正常状态。因为修改可能在客户端处于离线状态下进行,一天之后才会上传到服务器,所以必须保存一份精确的修改日志并存储起来。这样我们就可以在冲突发生时撤销任何修改。
我们最终采用了一种不同的方案:因为只有服务器能够确定数据的正确状态,所以只需要在冲突发生时将正确的数据返回即可。服务器端针对此方案的实现非常简单,而客户端则需要做很多工作来保证这一方案的正确。
例如客户端删除了一条不允许删除的记录,服务器就会返回一个有冲突(conflict
)标识的修改集,其中包含被错误删除的记录,以及与该记录相关联的其他记录。这样客户端就很容易恢复删除的记录,而不用在本地记录每一次操作。
实现
之前我们已经讨论了数据同步的基本概念,现在来看一下具体实现的细节。
后端
后端是使用 node.js 写的轻量级应用,使用 PostgreSQL 存储结构化数据,同时使用 Redis 缓存所有数据库修改事务的修改集(换言之,每个修改集对应从 x 版本到 x+1 版本的全部修改)。在客户端发起同步请求时,服务器可以从缓存的修改集中迅速找到客户端缺失的最新修改并返回,而不用临时去查询数据库。
后端实现的具体细节超出了本文的范围,但是老实说这些细节中并没有什么激动人心的地方。服务器只是简单地为每一个接收到的修改集发起一个事务,然后尝试将这些操作写入数据库。如果发生冲突,事务就进行回滚,然后构造一个正确状态的修改集。如果没有错误发生,服务器将以一个包含最新版本号的修改集确认这次修改。
处理完客户端发送过来的修改后,服务器会检查客户端的最新版本号是否落后于自己的,如果是的话就将上面构造的正确修改集返回给客户端以同步到最新状态。
Core Data
在客户端使用了 Core Data,所以我们需要在后台记录用户的每一次修改并提交到服务器。同时我们也需要处理服务器发送过来的数据并与本地数据进行合并。
为了达到以上目的,我们使用了一个主队列管理用户界面相关的对象上下文(object context)(包括用户输入的所有数据),另一个独立的私有队列则用于管理服务器发送过来的数据。
当用户修改数据的时候,主上下文(main context)会被保存,而我们监听了保存事件的通知。从通知中我们可以获取用户操作中插入、更新和删除的数据对象,从而构造一个修改集加入到队列中等候最终发送给服务器。这个队列是持久化的(队列本身和其中的对象使用 NSCoding
协议),所以即使应用在与服务器同步前退出了我们也不会丢失任何修改。
当客户端与服务器建立好连接之后,就从队列中拿出所有的修改集并转换为上文提及的 JSON 格式,带上当前最新的版本号发送给服务器。
当服务器的响应返回时,客户端查看收到的所有修改集,然后更新私有队列中相应的本地数据。只有当这次更新成功完成时,客户端才会将服务器发送过来的最新版本号存储到 Core Data 中指定的数据实体中。
最后很重要的一点是私有队列中的修改会合并到主上下文中,所以用户界面会相应更新。
当以上所有操作完成后,我们就可以接着处理队列中新出现的修改以发起下一次同步请求。
合并策略
我们必须妥善处理用于存储服务器返回数据的私有队列与主上下文之间的冲突。用户再修改主上下文时很有可能后台正在接收服务器的数据。
因为服务器返回的数据才是绝对正确的,在将私有队列中的数据合并到主上下文的时候,我们的策略就是持久化存储的数据相比内存中的数据更优先采用。
当用户修改一个服务器已经更新(比如已经删除)的对象时,这种合并策略会遇到一些问题。当这种修改在私有队列中保存但还未合并到主上下文时可以发送一个自定义的通知,这样用户界面可以针对此作出反应。
初始数据导入
由于我们需要为移动设备处理大量的数据条目(十万级),将所有的数据从服务器下载并导入到 iOS 设备中将花费很长时间。因此,我们会在应用中附带一份数据集的最新快照。这些快照使用经过特殊设置的模拟器运行生成,该设置可以在本地数据不是最新的情况下从服务器获取所需的数据。
然后我们对生成的 SQLite 数据库文件运行如下两个命令:
sqlite> PRAGMA wal_checkpoint;
sqlite> VACUUM;
第一条命令确保日志中记录的所有之前的修改同步到主 .sqlite
文件中,第二条命令确保文件不会过大。
当应用第一次启动时,数据文件从应用中被拷贝到最终的位置。想对这个过程以及其他导入数据到 Core Data 中的方法有更多了解,可以参考这篇文章。
因为 Core Data 数据模型中含有一个存储版本号的特殊数据实体,应用中包含的数据文件会自动将正确的版本号写入该实体中作为初始版本号。
压缩
因为 JSON 格式数据体积相对较大,使用 gzip 格式压缩发送给服务器的数据就变得非常重要。在请求中加入 Accept-Encoding: gzip
头信息能让服务器同样使用 gzip 压缩返回的数据。不过这只对服务器返回的数据有效,并不会在发送时启用压缩。
客户端包含 Accept-Encoding
头信息仅仅是为了告诉服务器自己能够支持 gzip 格式的数据,所以服务器应该在支持 gzip 压缩的情况下返回压缩过的数据。一般情况下客户端并不知道发送请求时服务器本身是否支持压缩,所以默认情况下客户端发送的数据不能进行压缩。
在我们的这个案例中,因为服务器也是我们能够控制的,所以我们可以保证能够支持 gzip 压缩。所以我们可以在发送数据到服务器时加上 Content-Encoding: gzip
头信息并压缩数据,因为我们知道服务器肯定能够处理。可以参考这个 NSData
category 获取一个 gzip 压缩的案例。
临时 ID 和永久 ID
创建新的记录时,客户端会为这些记录分配临时 ID,这样就可以记录它们之间的关系并发送给服务器。我们使用了负数作为临时 ID,从 -1 开始依次递减。当前最新的临时 ID 会持久化存储在标准的用户预设值(standard user defaults)中。
由于我们采用了这种策略,一次只处理一个同步请求是非常重要的,同时我们也需要维护临时 ID 与服务器返回的真实 ID 之间的映射关系。
发送同步请求之前,我们会检查是否已经收到对应待提交修改的永久 ID。如果有的话,我们将这些待提交修改的临时 ID 换成永久 ID,并更新相应的外键。如果我们不这样做或者一次发送多个同步请求,可能会导致多次创建同一条记录而不是在已有基础上进行更新,因为我们使用临时 ID 将这条记录多次发送给了服务器。
因为私有队列(在导入修改时)和主上下文一样也需要访问这种映射关系,为了线程安全我们将对其的访问封装在了一个顺序队列中。
结论
构建自己的数据同步方案并不是一个简单的任务,很可能将花费超出你想象的时间。至少处理本文中提及的各种同步系统边界情况就会占用你很多时间。不过相应的你也能得到灵活性和控制权,比如同一套后端既为 Web 接口提供数据,又在后台做数据分析。
如果你面对的是一个罕见的同步场景(比如文章提到的例子中,我们需要在很多人的设备之间相互同步),你也许只能自己定制解决方案。也许这将是一个痛苦的过程,因为你需要在脑子里不停地考虑各种边界情况,不过这也意味着这是一个值得做的有趣项目。