Base: An Acid Alternative – 摘要

如果你的 Web 应用依赖数据持久化,那么系统的瓶颈大概率是数据存储。那么我们需要对应用进行扩展。

应用的扩展有两种方式,垂直扩展和水平扩展。最简单的方式是垂直扩展 – 把应用迁移到性能更好的机器上。但它有一些限制,最明显的限制是单机的性能是有限制的。同时垂直扩展比较费钱,因为需要重新购买新机器来替换老的机器。还有就是它带来的 vendor lock-in。

水平扩展更灵活,但更复杂。我们可以通过两个维度来进行水平扩展,如下图所示:

  • 功能性切分。将服务于不同功能的数据拆分到不同的数据库里。
  • Sharding,数据分片。将同一功能的数据拆分到不同的数据库里。
图 水平扩展的两个维度

在依据功能性划分数据的方式中,架构师一般会将所有的表按功能分成不同的组,比如 Users,Products,Transactions。然后通过诸如外键功能来保证不同组之间数据的一致性。但这样就需要将这些表都部署在同一个数据库服务器上。这限制了系统的并发能力。

将数据分片(sharding)可以提供并发能力,但需要将维护数据一致性的工作交给应用本身,因为这时无法通过数据库来实现。同时也带来了分布式事务的问题。

在讨论具体问题之前,我们先回顾一下 CAP 理论和 ACID 原则。

CAP 理论是由加州大学伯克利分校的 Eric Brewer 提出的。他认为,Web 应用无法同时满足以下三个特性,而只能满足两个:

  • Consisency – 数据一致性。所有客户端在同一时刻观察到的数据是同样的,不管这些客户端是连接在哪个节点上。
  • Availability – 可用性。这个很容易理解,就是系统能正常工作。系统有输入便有输出。
  • Partion Tolerance – 分区容错性。在出现网络分区的情况下,系统仍然能够工作。网络分区是指在网络出错的情况下,一个网络被隔离成两个。换个角度理解分区容错性即是:在分布式系统中两个节点丢失了连接或者出现不可接受的延时的情况,系统仍然可以工作。

将数据水平拆分到不同的数据库中时,那么系统必然需要满足分区容错性,这时架构师需要在一致性和可用性之间进行选择。

ACID 原则

ACID 是数据库事务里的概念。数据库的事务满足 ACID,也就是以下四点:

  • Atomicity – 原子性。事务中的所有数据库操作要么同时成功,要么同时失败。
  • Consistency – 一致性。事务不会破坏数据的一致性,如外键限制等。和 CAP 中的 C 是不同的含义。
  • Isolation – 隔离性。事务提交成功或回滚前,事务数据对其他事务不可见。
  • Durability – 持久性。事务对数据库的修改会被永久保存。

数据库厂商很早就意识到分布式场景下的事务,它们提出了两阶段提交(two-phase commit, namely 2PC)。

两阶段提交顾名思义,它主要有两个步骤:

  • 事务协调者询问所有组件是否准备好提交事务。如果可以,则进行步骤2。
  • 事务协调者让所有组件提交事务。

根据 CAP 理论,2PC 保证了一致性,于是它牺牲了可用性。

BASE 原则

作者提出了 BASE 原则:basically available, soft state, eventually consistent。它与 ACID 相反,不要求严格的一致性,而是以最终一致性为目标。

作者给出了一个以 BASE 原则的实现方案,也就是中文网络中称之为 eBay 模式的分布式事务方案(本篇论文作者当时就职于 eBay)。这个方案的核心思想是引入一个可持久化的消息队列和判重表来实现最终一致性。

选择可持久化的消息队列最重要的一点是它要能支持事务,也就是说数据库操作和发送消息可以放在一个事务中。

判重表则是用来保证消息都能被处理到。

我们考虑这样一个系统:系统中有两个数据库,User 数据库和 Transaction 数据库,User 数据库中保存用户的余额,而 Transaction 中保存交易数据。在这样的一个系统中,买家和卖家进行交易,交易完成后 Transaction 表中应该插入一条交易记录,而 User 数据库的表中应该更新 seller 和 buyer 的余额。

按照 BASE 的原则,我们把插入交易记录和更新余额作为两个独立的本地事务提交,这样避免了 2PC。BASE 牺牲了 CAP 中的一致性但获得了更好的可用性。那么我们如何达到最终一致性呢?那就需要消息中间件了。具体流程如下所示:

// Transaction DB side
Begin transaction
 Insert into transaction(id, seller_id, buyer_id, amount);
 Queue message “update user(“seller”, seller_id, amount)”;
 Queue message “update user(“buyer”, buyer_id, amount)”;
End transaction


// User DB side
For each message in queue
 Peek message
 Begin transaction
  Select count(*) as processed where trans_id=message.trans_id 
    and balance=message.balance and user_id=message.user_id
  If processed == 0
   If message.balance == “seller”
    Update user set amt_sold=amt_sold + message.amount 
    where id=message.id;
   Else
    Update user set amt_bought=amt_bought + message.amount
     where id=message.id;
   End if
   Insert into updates_applied
      (message.trans_id, message.balance, message.user_id);
  End if
 End transaction
 If transaction successful
  Remove message from queue
 End if
End for

判重表 updates_applied:

Updates_Applied Table Columns
trans_id
user_id
balance

假如我们没有判重表,那么消费队列的消费操作和更新 user 表操作就需要放到一个事务中。但问题在于我们为了 Transaction 那边能够把插入交易数据和发送消息放入一个本地事务,那么这个持久化的消息队列必然是用 Transaction DB 实现的。而这样在消费端,消息消息和更新 user 表就仍需要 2PC 了。怎么解决这个问题呢?通过 updates_applied 表记录消息的处理情况,我们能够让消费消息和更新 user 表放到两个独立的本地事务中。通过判重表我们判断这消息是否处理过,这样即使在更新 user 成功但 deqeue 失败的情况下也不会重复消费消息。

还有一点,如果消息的顺序对消费端(如 user 服务)的业务有关联,那么可以在 user 表中添加一个 lastUpdatedTimestamp 字段。如果消息中的时间戳比 user 表中的时间戳早,就丢弃掉。注意这个方法只对特定情况有效,如果下游系统严格要求有序消息,则需要选择支持有序消息的消息队列。

总结和引申

BASE 的提出者一个与 ACID 相反的分布式系统设计指导思想 BASE。从 CAP 的角度,ACID 提供了很强的一致性却损失了可用性;BASE 放松了一致性的要求而获得了更好的可用性。同时 ACID 在分布式的情况下需要用到 2PC 这种复杂的实现方式,而 BASE 则只要本地事务支持就可以。

在 BASE 的论文中,给出的所谓 eBay 方案只是个基本的方案概要。比如文章中提到的可持久化的消息队列,文中假定是利用数据库实现的从而可以和数据库插入放进同一个本地事务。而实际上,我们一般会使用一些常见的消息中间件,如 Kafka、RabbitMQ、RocketMQ 等,它们有些支持事务有些不支持。所以我引申以下,在具体的应用场景中我们怎么通过这些 MQ 来实现 BASE。

  • RocketMQ
    • 支持事务消息,即发消息和数据库操作可以作为一个整体性的事务。这样就可以直接套用 eBay 方案。
  • Kafka
    • 不支持事务消息。需要在 Producer 端建一张消息表。往消息表插记录和业务的数据库操作作为事务提交,然后发送消息。然后定时任务轮训消息表重发发送失败的消息。
      • 发送消息时依据业务要求选择要不是使用 Kafka producer 的 Exactly-Once 特性。
      • Kafka producer 的 Exactly-Once 特性内部是通过 Idempotence + Transaction 实现的。Idempotence 通过消息的内部唯一 ID 实现 per partition & per session 的只发一次特性。Transaction 特性则保证 cross partition & cross session 的只发一次特性。
    • 消费者端需要保证 At-Least-Once 或者 Exactly-Once 的消费语义,同时做到幂等。
      • 自动提交 offset 时,Kafka 消费者通过缩短消息 commit 时间间隔也不能完全保证 At-Least-Once。
      • 手动提交时,在消费成功后提交 offset,可以保证所谓的 At-Least-Once 语义。
      • 手动提交时,可以把 offset 存在同一个数据库里,这样消费端处理消息和 commit 消息可以作为本地事务提交,达成 Exactly-Once 的语义。

对于一些重试消费也失败的消息,可以选择人工介入处理。

Leave a Reply