Contents
  1. 1. 数据库
    1. 1.1. 关系型,单机
    2. 1.2. 非关系型,单机
    3. 1.3. 主从复制
  2. 2. 外部组件或系统
    1. 2.1. 缓存
    2. 2.2. 外部系统
  3. 3. 解决方案
    1. 3.1. 恰当的建模
    2. 3.2. 两阶段提交(2PC)
    3. 3.3. 任务重试
    4. 3.4. 操作顺序
    5. 3.5. 异常数据主动监控,补偿
  4. 4. 相关阅读

数据库的 ACID,应该所有后端程序员都听说过,也是我们必须了解的知识。ACID 里面的 C 就是 Consistency(一致性)。

但是,一致性仅仅是 C 吗?从一个普通用户角度来考虑,当然不是。用户角度的一致性,应该是数据库实现了 ACID 后的效果。用现实的例子来说明就是:

我发起银行转账,不能是我帐号的钱减少了,但是接收方却没收到;不能说银行职员能看到双方的钱是对的,但是用户自己看到的不对;不能说我刚刚看到的帐是对的,另一个时候,或者去另一台机器,或者换另一个方式查就不对了。

那我们开发人员,是不是只要利用数据库提供的 ACID 特性,就能达到用户想要的效果呢?要注意什么呢?

数据库

关系型,单机

在单机使用 RDBMS 数据库如 Oracle、MySQL、PostgreSQL 的情况下,数据库本身提供的 ACID 机制,已经能基本保证数据操作后的完整和一致性了。开发人员要做的,只是确保要维持数据一致性的变更操作代码,同在一个 transaction 里面。我刚工作的时候,当时还是用原始的 JDBC 连接 Oracle,还要手动打开关闭数据库 connection 的连接,统一 commit,或者出错后 rollback。现在,Spring 等框架已经能够用 AOP 和 Annotation 的方式来标注 transaction 的范围。

非关系型,单机

最近几年流行的 NoSQL,像 MongoDB, Redis,它们的 ACID 就不一样了。它们并不是 ACID compliant 的。MongoDB 的 ACID 是 Document 级别的。也就是说,一个数据操作,只能保证一个 Collection 里面的一个 Document 上的所有数据改动是同时成功和失败。假设一个数据操作涉及多 Document 的变动,比如用了 multi: true 参数,或者更改不同 Collection 的 Document,这些改动都不能保证所有 Document 的更改同时成功,或者同时失败。而 Redis 的 Transaction 就更不一样了。

主从复制

在 Monolithic system 里面,数据库多数是单机。即便为了灾备需求,或者支持读写分离,甚至声称异地多活的系统,也只是启用了数据库的主从复制功能( Master-Slave 模式的 replication )。一般的主从复制,主库的数据和从库的数据肯定会有延时。即便是使用 Master-Master 和实时同步机制,也有可能有延时,或者数据冲突。如果强制使用更严格的一致性写入确认,如 MongoDB 的 Write Concern 设置为 majority 或者 jornal 的话,数据库的性能又会有很大的影响。

我这方面的经验不多,而且现在还有像新出的 Google Spanner 这样的全球分布式,同步复制的 NewSQL 数据库,需要更多了解一下。欢迎大家给意见和指正。

外部组件或系统

可以看到,仅仅利用数据库提供的 ACID 支持,也不是一定能达到用户想要的一致性效果的。而且,很多时候,一些用户感知的一致性,背后还涉及到数据库以外的系统。

缓存

缓存,可能会是为了解决性能问题,最早引入的组件了。但是,一旦引入缓存,数据的一致性就有可能更容易有偏差,即便是在使用单机服务器的情况下。在文章「业务与缓存」里面,提到的缓存失效和更新的策略,是影响数据一致性的重要因素。

外部系统

有些时候,当数据发生改动时,我们还需要通知外部系统,比如,用户注册成功后发送邮件,或短信通知;给用户打款后,发送微信,或短信通知;SOA 架构下,上游系统的数据改变后,需要通知下游系统等。这时候,用户角度的数据一致性,其实还包含了这些外部系统的相应操作,也应该被触发,被体现。内部系统的数据变动,和外部系统的反应,如何能保持一致?能保持一致吗?

在上一家公司的时候,我们利用 Oracle 的 XA Transaction 支持,来尽量确保数据库的改动,能和 JMS 的消息发送 保持同时成功或者失败。但是,如果数据改动后要发邮件,短信,或微信通知,这些现在没类似的支持,是极有可能无法保持一致的。

很巧的是,公众号「程序人生」最新的文章「不要等客户来通知问题」里面的摩拜单车解锁问题,刚好为我提供了一个很好的例子。作者扫码后,单车锁一直没开,但是又认为作者已经成功开锁使用了。所以,它一直不让作者自己操作结束,又不让他重新扫新的单车。最后,要等问题被反应到摩拜开发人员内部,才得以解决。这个状况发生的原因可能是,手机端上传单车开锁指令后,后台的数据状态已经标记为使用状态了,甚至开锁指令都已经下达到自行车上了,但是自行车锁就是没有成功打开。你说,这里数据一致了吗?对系统来说可能勉强算是,但对用户来说就不是了。

解决方案

既然那么多情况可能导致数据的不一致,怎么解决呢?

恰当的建模

使用 NoSQL 和 RDBMS 建模的时候,要考虑的因素很不一样。MongoDB 更多是考虑嵌套,冗余,而不是追求更高的范式要求。这在「Node.js 微信后台搭建系列 - 数据建模」,「一个简单的支付业务与模型演变」一文里面也稍微提过。

两阶段提交(2PC)

两阶段提交(Two-Phase Commit)是一种协议和分布式算法,来协调多操作的原子性。前面说的 Oracle XA Transaction 就是利用 2PC 实现的。MongoDB 里面没有提供多 Document 更改的原子性支持,所以一些场景可以通过在 MongoDB 里面用 2PC 来实现多 Document 的 Transaction 确保数据的一致性。但是,在业务复杂的情况下,自己模拟 2PC 还是很麻烦的。

任务重试

出错重试,应该是很常见的操作了。但是,重试的处理,有几个地方是要注意的:

  • 幂等原则
  • 同步还是异步?
  • 重试次数

出错能否重试,要看这个重试的逻辑是否幂等(Idempotency),或者多次执行都生效的影响到底严不严重。

先说影响。比如说你的系统支持用户提现,成功后需要发通知。用户设置的通知有微信,短信,和邮件通知(这是有多担心钱被偷)。假设你实现的重试任务代码,负责所有通知(包括微信,短信,邮件等),而不是微信,短信,邮件等有各自的独立任务。那么在发通知的时候,假如第一次发微信的时候失败了,但是短信邮件成功了,这个重试任务如果还是被标记为失败。下次重试的时候,它就会重复发送了一些通知。这个任务多次执行的影响,对用户来说可能很烦,但是不大。

另一个是幂等。幂等的意思是一个操作如果被多次执行,其结果和第一次执行后是相同的。上面的发通知的例子,如果我们定义结果是能发通知的话,它是幂等的。但是,如果结果是发且仅发一次的话,它就不是幂等了。关于幂等,我以前学 AngularJS 的时候就被自己坑了一次,详情可看「Expression in AngularJS must be idempotent and for multiple calls」。公众号「嘀嗒嘀嗒」的安姐近期一篇「每个工程师都应该了解的:聊聊幂等」更详细说明幂等和解决方法,我就不重复了。

还有一个要考虑的是,选择同步还是异步重试。这取决于业务场景,和出错部分的严重程度。必须一致的关键数据部分出错,要么中止回滚,同时警告用户,要么只能同步重试处理。但是,如果是在 Node.js 这样的单线程服务,可能就不应该重试,或者要严格控制重试次数。要不然,除了当前用户受影响,说不定共用服务的其它用户也遭殃。

操作顺序

如果说数据不一致无法完全避免,那如何最大化避免数据不一致,并在出错后有迹可循呢?

  • 先处理出错可能性低的部分
  • 先内部系统,再外部系统
  • 先记录操作唯一性,再标记不同状态

假如一个系统允许用户提现到微信零钱,这个系统是 MongoDB 作为后台,并且模型里面有 transaction 这样的流水纪录表,也需要更新用户表 user 里面的余额。应该怎么操作呢?

  1. 从用户表 user 中减少提取的额度
  2. 把这个额度记录到 transaction 中的一条包含唯一性的流水记录里,标记处理中
  3. 通过微信 API 通知把提取额度转到用户零钱
  4. 成功后把 transaction 中的流水记录标记成功,否则标记失败

这里涉及 4 步操作。假如每一步都有可能出错,安排 1 和 2 两步在前面是因为同是内部系统,出错可能性低一些。即便第 1 步成功,但是第二步失败,用户的余额还是可以通过 replay transaction 里面所有的收支记录来刷新,或者这里做特定异常处理。

这里面的第 3 步,是外部系统,涉及网络操作,所以是最有可能出错的。所以第 3 步前必须先有操作记录,而且有唯一性(比如订单号)标识。出错后可以通过此标识,像微信查询该转账操作是否成功。

最后才更新流水记录的状态,也是为了能保证最终的完整性,和提供异常数据监控的可能。

异常数据主动监控,补偿

一般来说,如果所有的操作,都是系统内部触发,那么出错的时候,都应该有记录,并且可以重试。但是,像前面提到的摩拜单车的例子,解锁部分的硬件操作,锁有没有打开这个状态并没有反馈回内部系统,导致不一致的状态已经脱离了内部系统范畴。这就不是重试能解决的了。异常数据的主动监控和补偿就派上用场了。

相关阅读

业务与缓存
一个简单的支付业务与模型演变
听听系统的多地部署改造

Contents
  1. 1. 数据库
    1. 1.1. 关系型,单机
    2. 1.2. 非关系型,单机
    3. 1.3. 主从复制
  2. 2. 外部组件或系统
    1. 2.1. 缓存
    2. 2.2. 外部系统
  3. 3. 解决方案
    1. 3.1. 恰当的建模
    2. 3.2. 两阶段提交(2PC)
    3. 3.3. 任务重试
    4. 3.4. 操作顺序
    5. 3.5. 异常数据主动监控,补偿
  4. 4. 相关阅读