8579c05a12827a272b6d65c00125846f
漫谈分布式系统(13) -- 再探分布式事务

从 ACID 到 BASE

在业务很简单,数据量不大的时候,传统单机关系数据库就够用了。在各个关系数据库里,很早就实现了单机版的事务,并总结成了 ACID 四个特性。

  • Atomity,原子性,事务内的所有操作要么全部执行,要么全部不执行。
  • Consistency,一致性,事务执行完后,数据库仍然保持合法的状态,如不违反主外键约束等。
  • Isolation,隔离性,事务之间互相隔离,并发执行时也不受影响。
  • Durability,持久性,事务提交后,涉及的变化都会被持久化下来,不会因系统故障而丢失。

另一个关联很大,但容易混淆的概念,是前面文章讲过的的 CAP。

  • Consistency,一致性,不同时刻的不同请求都能返回同样的数据。
  • Availability,可用性,每个外部请求都能得到系统的有效响应。
  • Partition tolerance,分区容忍性,在出现网络分区时,系统仍然能正常运转。

可以看到,ACID 和 CAP 里的 C 有着完全不一样的含义。前者强调数据库状态始终合法有效,后者强调不同副本上的数据对外而言始终一样。

以 MySQL 为典型的单机关系型数据库能很好的支持 ACID,但是,当数据量和请求并发膨胀到一定程度后,必然会横向扩展为分布式数据库。

典型的实现是在单机数据库的基础上,做多库多表。无论是纵向的对同一类数据的切分,比如把用户表切分后放到 100 个数据库实例;还是横向的对不同类型的数据做切分,比如把用户表和商品表分别放到不同的数据库实例,都是难免的。

数据库变成分布式之后,我们自然能像前面文章介绍的那样,在单机事务的基础上,通过 2PC 的方式实现分布式事务。

而出于高可用的需要,还可以采用 binlog 等方式把数据复制到 slave 机器上,这个过程也可以通过 2PC 实现。

但是,任何分布式系统都逃不过 CAP 的诅咒。

前面文章已经详细说明过,2PC 模式的分布式事务,多轮多节点的协商导致性能不佳,并且无法提供分区容忍性。

虽然 ACID 中的 C 和 CAP 中的 C 含义并不一样,但引申到分布式语境下,ACID 确实也隐含了强一致性保证。而基于 2PC 的分布式事务则在分布式的场景下延续了对强一致性的追求,可以称之为 ACID on 2PC。哪怕我们继续优化,比如 ACID on 3PC,也没法从根本上解决问题。

同时,上篇文章说过,在大数据量和高并发的场景下,有时候,可用性和性能(也可以看作可用性的体现)的重要性并不比一致性弱。一个动不动不响应或者响应非常慢的系统,数据再一致,也很难大规模应用起来。

一方面,一致性难以完全保证,另一方面,可用性和性能又不能不管,那对分布式事务而言,到底有没有更好的出路呢?

答案是肯定的。并且已经有人将这个思路总结为 BASE 理论:

  • BA,Basically Available,基本可用,不追求完整的可用性。部分可用也好过完全不可用。
  • Soft state,软状态,不追求状态机那样机械的状态转换,允许出现中间状态。所谓「柔性事务」也是这个意思。
  • Eventually consistency,最终一致,不追求无时无刻的强一致。

简单讲,就是牺牲一部分一致性,来换取可用性。非常重要也非常典型的 trade-off。

在化学术语中,ACID 是酸的意思,而 BASE 则是碱的意思。从名字上也能看出二者的关系。

基于 Dynamo 的分布式事务

对了啊!上篇文章不是刚介绍过 Dynamo 吗,现成的因果(弱)一致性分布式数据库,那干嘛不直接在 Dynamo 的基础上做事务?!

在客户端实现事务

Amazon 官方曾经提供过一个叫 dynamodb-transactions 的库,来帮助应用在客户端实现分布式事务。

大致来说,是一个多步提交(multi-phase commit protocol)的实现:

  • create,创建一个主键唯一的 TX record,保存为 Dynamo 对象。
  • add,把事务相关的对象添加到 TX 对象的对象列表里。
  • lock,逐个把相关对象的 lock 设置为本 TX id。
  • save,保存相关对象的副本,以备回滚。
  • verify,重读 TX record,确保状态仍是 pending,以防止和其他事务产生竞争。
  • apply,执行事务对应的操作。修改操作会直接改动原对象,删除操作这时不会执行。
  • commit,TX record 状态从 pending 改为 commited。
  • complete,释放事务相关对象的锁,并删除 save 阶段保存的副本。
  • clean,TX record 状态更新为 complete。
  • delete,删除 TX record。

看起来列了很多步,有点吓人,实际上只是操作细节而已,和 2PC 的 Prepare-Commit 差不多。

上面是没有出现冲突时的正常执行流程。一旦出现冲突,则会进入另外的处理流程:

  • decide,从对象锁拿到占用它的 TX id,判断该 TX 状态,如果是 pending,说明没开始,则改成 roll-back(所以上面正常流程才有 verify 这步)。
  • complete,如果该事务状态是 committed,则继续执行正常的提交流程;如果是 roll-back,则执行回滚流程。
  • clean,同上的 clean 操作。

可以看到,事务之间是可以互相帮忙推进流程的,这个比较激进的机制当然有利于推动事务尽快完成,但也加剧了竞争。频繁的互相回滚是可以预见的局面。所以可以考虑在 decide 步骤前加入等待等办法来缓解。

除了事务之间的竞争,DynamoDB 还支持多个 coordinator 处理同一个事务,不过解决竞争的思路类似,就不再赘述了。

需要注意的是,apply 阶段的修改操作会直接更新对象,即使事务没提交,这个修改也是可见的。当然,可以通过加读锁来避免。

在服务端实现事务

客户端自己实现总是不方便且容易出错,于是在 2018 年,DynamoDB 终于在服务端集成了事务功能。

    // 以下省略 checkCustomerValid、markItemSold 和 createOrder 的定义代码
    Collection<TransactWriteItem> actions = Arrays.asList(
        new TransactWriteItem().withConditionCheck(checkCustomerValid),
        new TransactWriteItem().withUpdate(markItemSold),
        new TransactWriteItem().withPut(createOrder));

    TransactWriteItemsRequest placeOrderTransaction = new TransactWriteItemsRequest()
        .withTransactItems(actions)
        .withReturnConsumedCapacity(ReturnConsumedCapacity.TOTAL);

    // Execute the transaction and process the result.
    try {
        client.transactWriteItems(placeOrderTransaction);
        System.out.println("Transaction Successful");

    } catch (ResourceNotFoundException rnf) {
        System.err.println("One of the table involved in the transaction is not found" + rnf.getMessage());
    } catch (InternalServerErrorException ise) {
        System.err.println("Internal Server Error" + ise.getMessage());
    } catch (TransactionCanceledException tce) {
        System.out.println("Transaction Canceled " + tce.getMessage());
    }

如上图的示例代码,通过以 transactWriteItemstransactGetItems 为核心的事务 API,几行熟悉的代码,就能实现事务的效果。

无论是客户端还是服务端的实现,由于 DynamoDB 采用了 W+R 的读写模式,即使事务执行成功,其他请求仍有可能读到过时的副本。不过,可以设置 ConsistentRead 参数强制读足够多的副本来获取最新的数据。

另外,为了降低网络问题导致的事务大量失败带来的用户体验问题,DynamoDB 只支持同一个 region(简单理解为 AWS 上的一个机房) 下的事务,不支持跨 region 的 global table 上的事务。

基于 BASE 的分布式事务

DynamoDB 虽好,但是如果我们已有的系统没用 DynamoDB 呢,或者用了 DynamoDB,但事务里面还包含其他数据库和服务(heterogeneous distributed transactions)呢,又该怎么办?

牺牲一致性

再回过头想想,既然 BASE 想要做的是牺牲一致性来换取可用性和性能(某种程度看也属于可用性)。那就先牺牲起来,怎么牺牲一致性呢?

回顾前面几篇文章,我们提到的单主同步、2PC 和 Paxos 这些强一致性模型,虽然差别很大,但都有一个共性:通过同步的方式做数据交互。

那想要牺牲一致性,只要改同步为异步就好了。这符合我们在系列第 8 篇讲到的内容,也正好解决了提升性能的需求。

而要做异步,最常用的就是 MQ。

top Created with Sketch.