服务器和客户端状态同步的总结

offline
有一类系统,基本上所有操作都要求在线。在客户端产生的数据,直接提交到服务器,本地不存数据,或者仅保存少量缓存数据。这类应用有一个优势,就是客户端和服务器的数据始终是同步的,罕有两端不一致的情况。但是也有缺点,即对网络条件要求高,在网络条件不好的时候,用户操作需要等待,甚至无法正常使用

我们的一个APP,与上述系统不同,是支持离线操作的。数据在本地和服务器各有一份,然后通过同步机制来保持一致。这样做的缺点是提升了系统的复杂性,因为如果设计有缺陷,就很容易发生两端数据不一致的情况;另外就是如果客户端长期离线,存在数据丢失的风险。当然好处也很明显,即操作不依赖网络,所以非常流畅。即使在无网络条件下,也可以正常使用,只要在网络恢复以后,与服务器做数据同步就可以了

这类数据同步方案的一个常见错误,是___客户端依赖服务器的响应结果___。我们的老方案就踩了不少坑,本文总结几个常见的场景

场景1

  1. 客户端向服务器发起一个请求
  2. 服务器收到此请求以后,往数据库写入一条记录,返回成功响应
  3. 客户端收到响应以后,往本地也写入一条记录

看起来这个方案还不错,很直观,并且两端都有了同样的数据。但是这只是理想情况。有一天我们的系统访问量突然增大,导致服务器的负载升高,每个请求都处理得很慢,于是客户端超时了。所以客户端认为此次请求失败,没有往本地写数据,但是其实服务器稍后还是处理了这条请求。于是服务器就比客户端多了一条数据。更糟糕的情况是,客户端认为操作失败,继续反复发起请求,当然毫无例外地全部超时了,于是服务器里就多了N条记录……这是一个很容易模拟重现的场景,只要在服务端的代码里打一个断点,然后等客户端超时以后,再把断点走下去就行了

后来为了解决这个问题,我们试过增加防重复提交的机制,但是也不理想。因为首先,“重复提交”本身就是一个模糊的概念,什么样的提交算是重复提交呢,如果认为用户在同一个界面重复点击是重复提交,那么如果用户关掉窗口再来一次,是否也算重复提交呢?其次,数据同步机制本来就比较复杂,再加一个新的纠错机制,会让系统更加复杂,隐藏更多出BUG的可能性。最后,即使真的有了完美的防重复提交方案,也只能解决“多N条”的问题,还是解决不了“多一条”的问题

最后我们采取的办法是,先往本地插入一条记录,然后再提交到服务器去异步处理。由于是异步的,必然存在一个时间差(特别是客户端离线的时候,时间差还会比较长),所以会有短时间的两端数据不一致,但是只要最终一致,也就达到了目的

场景2

  1. 客户端生成自从上次同步以后的新差量数据
  2. 将数据发到服务器,服务器处理,返回成功响应
  3. 客户端收到成功响应以后,删除这批数据

和上一个场景也有类似的问题,服务端处理得慢,导致客户端超时,差量数据没有被清除,但是这些数据实际上已经写入了服务器。于是下次同步的时候,重复的数据就又发到了服务器;此外还有另一个BUG,由于上报的时候,有2份差量数据(上次未清除的,此次新生成的),所以上报也发生了错误

后来我们修改了这2个BUG,首先是服务器收到请求以后,不处理已经处理过的数据;客户端则是每次只上报最新的那份差量数据;另外还被迫加入了错误数据的清理机制……为了修复一个BUG,代码又复杂了很多

其实更好的做法,应该是客户端每次要同步的时候,都重新生成一份新的差量,上报以后,即使没有收到响应,也应该把这份差量删除。这样就避免了客户端堆积多份差量文件的情况。然后接下来,就面临一个选择:在没有收到响应的时候,应该怎么标识哪些数据是“新”的。这个时候,服务器的状态是不可知的(而不是确定的成功或者失败),如果假设服务器操作成功,那么就应该在客户端上把这些数据都标识成旧的,下次不要再提交;相反,如果假设服务器操作失败,就应该保留这些数据的状态,下次再提交上去。前者的风险在于,如果服务器真的处理失败了,客户端又不再提交这些数据,那么这部分数据就再也没有机会同步到服务器。后者的问题在于,服务器可能会重复写入。两相权衡,我们认为前者的问题更严重,因为会直接造成数据丢失,所以最终选择了后面的方案,但是在服务器加上防止重复写入的机制

基于文件的同步方案

后来又追加了基于文件做同步的方案

旧方案

此前的备份方案是基于内容的,每一条记录都有create_date和modify_date字段,同时APP保存有latest_backup_date(上次备份时间)

然后开始备份的时候,就对所有表进行扫描,根据这3个时间的对比,直接生成sql语句,发到服务器执行,写入服务端的mysql数据库;收到服务器的成功响应之后,又刷新latest_backup_date

而恢复逻辑,则是从服务器的mysql数据库里,遍历找到所有的记录,也生成sql语句,发回客户端,客户端再执行sql进行恢复。当发生冲突的时候,以客户端的数据为准,违反主键约束的时候,插入数据就会失败。比如客户端将一个卖品的价格改为200,而服务器mysql里的记录还是100,那么下发的insert语句就无法执行

这个方案有几个问题:

1、客户端的备份逻辑,散落在业务模块里,因为涉及到业务操作的地方,都需要记得修改modify_date和create_date,容易造成数据备份不上去的BUG

2、备份逻辑依赖客户端本地时间,而客户端时间总是不可靠的

3、服务端缺少客户端数据库的完整镜像,也就是说,一旦有BUG导致部分数据没有备份上来,那么如果用户卸载了APP或者PAD丢失,这部分数据就永远找不回来了

4、生成恢复文件之前,需要遍历mysql表,数据量大的时候,容易使客户端超时而恢复失败

5、恢复逻辑以客户端数据为准,在某些场景下不满足需求,比如做不到在服务端对客户端的数据进行干预校正

6、sql是纯文本,当数据量大的时候,在网络间传输的数据太多

新方案

新的方案准备这样做:备份和恢复不再基于内容,而是基于文件。每次备份都把本地的数据库文件上传到服务器。但是在传输上有特别处理,只传文件的差量;在服务器利用差量文件,合并得到完整的客户端数据库文件副本。同时在数据库增加一个差量表,配合trigger,将每次的insert,update,delete操作,写到差量表中。在服务器遍历差量表,将有变化的数据写到mysql里

恢复的时候,就直接把数据库文件发到客户端,替换掉客户端的数据库文件

在这个过程中,当然需要在服务端增加专门的表,来控制整个流程,比如记录文件在OSS里的路径,最后备份的时间等,本文不展开

这个方案相比老方案的优势:

1、客户端业务代码不再需要关注数据同步的逻辑,减少了出错的机会

2、不依赖客户端时间

3、服务端始终有客户端数据库的完整镜像,即使有BUG,也只是没有写到mysql里,对汇总统计有影响,但是不会造成客户端数据直接丢失

4、恢复文件不需要每次生成,速度快

5、可以在服务端直接修改数据库文件,校正客户端的错误;版本升级时如果需要做数据迁移,也可以在服务端统一处理

6、由于每次备份的差异量小,生成的差量文件也很小,需要在网络间传输的文件一般也比较小

新方案的局限性

总的来说,新方案的优势比较明显。但是,这个方案也只能解决单个客户端操作的场景,对于多终端同时操作就无能为力了。比如说,2个PAD同时修改一个会员的余额,那先备份的那条数据将会被覆盖,造成数据错误。所以,还需要保证同时只有一个终端操作数据,这样才能放心地替换文件。因为这种场景下,是不存在数据冲突的

如果要支持离线环境下,多终端同时操作的场景,则还需要在这个方案的基础上更进一步,识别出终端差异,将各终端的数据merge到中心文件,此外还需要保证文件合并的先后顺序等。这种场景比单客户端的场景要复杂很多,不在本文讨论范围,有空单独再写