异步复制的最终一致性问题

异步复制的最终一致性问题

全球分布式数据库因为地理距离较远(上万公里), 网络通信延迟一般在 100ms 级别, 所以只能采取异步复制的方案. 采取异步复制方案, 那就决定了最终数据被复制的时效性无法得到保证, 例如正常情况仅仅比网络延迟多几毫秒(100ms+). 但坏情况时, 例如, 因为网络线路不好, 数据可能要花费数秒甚至数分钟才能同步. 这就导致了非常恼人的用户体验.

考虑这样的场景:某网络游戏平台的用户 A 在中国,而用户 B 是他曾经的邻居,目前在美国. 某日, 用户 A 将游戏中的道具转给了用户 B,A 在游戏中看到了明确的操作成功的提示, 而且刷新也确认道具已经转交。但是B 一直查询不到该道具。

这就是经典的异步复制(最终一致性)导致的问题。首先,我们把结构简化,整个系统只有两个数据库,分别在中国和美国,还有两个用户,A 和 B 分别在中国和美国。A 和 B 虽然通过游戏平台的业务逻辑代码访问数据,但我们简化,让他们直接访问数据,让他们可以直接看到数据库的数据。

方案1:用户读回源

这个方案很直观, 既然 A 是在中国做的操作, 写的是中国的数据库, 那么 B 也应该访问中国的数据, 而不是美国的数据库. 如果这样做, 绝对不会出现不一致的情况. 也就是说, 单点存储没有一致性问题.

但是, B 在什么情况下应该回源访问中国的数据库呢? 如果他每一次查看背包, 都要访问中国的数据库, 那么他会觉得游戏平台的服务非常”慢”. 而平台在美国部署的数据库没有起作用, 因为部署美国数据库的一个初衷就是让美国的用户访问速度”快”起来.

方案2:用户写多处

这个方案也很直观, 用户 A 不仅仅写中国的数据库, 还主动连接美国的数据库写, 双写. 所以, 只要写成功, B 在美国就能立即看到, 因为已经写了美国的数据库啊…

这个方案也有问题, 第一个问题是慢的问题, A 要连接美国的数据库写数据, 显然会很慢. 但是, 没办法, 要么读慢, 要么写慢, 一致性只能用通信来保证, 而通信又受光速所限, 逃不掉.

第二个问题是, 如果写中国数据库成功, 而写美国数据成功, 那么用户应该认为写操作了还是没成功呢? 这个问题没有唯一解, 只能用户自己决定. 如果用户认为成功了, 那么就会出现开头例子所说的不一致的问题. 如果认为没成功, 那么用户只能不断重试. 哈哈, 这就是经典的 CAP 理论, 想要一致性, 就必须要放弃严格的一致性. 不过, 我们开头举的例子是追求一致性的, 所以, 结论是: 不成功, 用户重试.

方案3:数据库强一致性读写

前面的两种方案, 不仅有缺陷, 重要的是, 把责任全推给用户. 其实, 数据库本身可以做很多事, 如果数据库做了这些事, 用户就能省功夫. 所以, 我们看看数据库系统能做什么.

如果把中国和美国两个数据库共同组成一个 raft 复制组(集群), 这样的话, 似乎能解决问题? 等等, raft 具有单一的固定 leader, 所以, 如果 leader 在中国, 美国用户肯定要回源到中国写数据. 但是, 我们可以提供配套的 SDK, 用户不需要关心自己有没有回源.

读操作也要回源, 无论用 raft 还是 paxos, 都是基于通信, 所谓通信, 就是回源. 不过, 可以优化回源的数据量, 例如, raft 就可以通过 ReadIndex 技术只回源 binlog 序号, 减少了通信数据量.

这个方案无论是写还是读, 都很慢, 方案不好. 这也违反了”小范围同步复制(强一致), 大范围异步复制(最终一致)”的原则.

方案4:数据库提供同步(sync)原语

“小范围同步复制(强一致), 大范围异步复制(最终一致)”, 这个原则是真理, 不能违反. 所以, 数据库自己不能解决全部问题, 还是需要用户一起配合.

借鉴 Memory Barrier 思想, 数据库可以提供一些同步(sync)原语, 以确保数据能同步到期望的地方. 数据同步仍然由数据库来做, 脏活累活由数据库来做, 用户只需要提出请求.

数据库提供 sync_write 原语, 用户 A 在中国写入数据库之后, 请求数据库 sync_write. 数据库收到该请求, 立即获取本地的 binlog 序号, 然后向美国查询 binlog 序号, 如果美国的序号比中国的序号小, 说明之前的写入操作可能还没有同步到美国, 等待 , 然后继续轮询. 直到确认同步状态后, 再返回响应给用户 A. 这时, 用户 A 再私下通过微信告诉 B 去查看游戏背包, B 一定会立即看到更新.

数据库提供 sync_read 原语, 用户 B 在美国读取数据前, 先请求数据库 sync_read. 数据库收到该请求, 立即向中国查询最新的 binlog 序号, 然后和自己本地的序号比较. 如果美国的序号比中国的序号小, 那么就 等待 , 然后继续比较一次. 直至确认美国的序号等于或者大于刚才查询到的中国的序号, 这才返回响应. B 拿到响应后, 再去查看游戏背包, 一定会看到更新. 注意: 查询中国的序号只做一次.

只要用户 A sync_write, 或者用户 B sync_read, 就能满足一致性, 不需要两人同时做. 而其它的正常请求, 不需要这两个同步原语, 不会有速度慢的问题.