logo
企业版

技术分享

DDIA精读|分布式系统中的事务之那些棘手的概念们

以下文章来源于木鸟杂记 ,作者穆尼奥

分布式系统

本文先讨论事务的基本概念,快照隔离和可串行化将在后续与你见面。

目录

  • 棘手的概念
    • ACID 的含义
    • 原子性(Atomicity)
    • 一致性(Consistency)
    • 隔离性(Isolation)
    • 持久性(Durability)
    • 单对象和多对象操作
    • 单对象写入
    • 界定对多对象事务的需求
    • 故障和终止

在分布式数据系统中,任何问题都有可能发生:

  1. 系统侧:数据库系统和硬件系统任何时间都有可能发生故障。

  2. 应用侧:使用数据库的应用程序任何时刻都有可能故障。

  3. 网络侧:应用程序和数据库间,数据库的多个节点间,随时都有可能断开连接。

  4. 多个客户端:并发写入时,可能会有竞态条件和相互覆盖。

  5. 半读:一个客户端可能会读到部分更新的数据库。

复杂度不会凭空消失,只会发生转移。如果数据库对这些故障不做任何处理,应用层就需要处理上述所有相关问题,会极大增加应用侧编程复杂度。事务,就是数据库为了解决类似的问题,所提供的一种保证,以简化应用层的编程模型。

简单来说,事务就是将一组语句(或者说操作)打包成一个逻辑单元进行执行,并提供一种保证,这一组操作要么全部成功( commit,应用到数据库里),要么全部失败(被动 abort,或者主动 rollback),而不会存在只执行了一半的中间状态。此外,如果多个客户端的事务并发执行,会涉及到隔离性的问题,一般来说,数据库允许用户在隔离级别和性能之间做选择。

也可以从时间和空间两个角度来理解事务,从生命周期(时间)来讲,事务要保证一组操作的整体性;从并发控制(空间),事务要做好多个事务间的并发控制。

有了这种语义上的保证,用户在发现事务失败后,可以放心的进行重试,直到成功,就可以确定事务中的所有操作都生效了。但任何便利性都是有代价的,事务便是在一定程度上牺牲了性能和可用性。

这里仅作事务的基本概念讨论,下篇开始针对隔离级别(并发控制)做详细探讨,包括读已提交、快照隔离和可串行化。事务保证和是否分布式在概念上相对正交,但在实现上,分布式系统中事务的实现难度要大的多。

棘手的概念

一般来说,现代关系型数据库和一些非关系型数据库,所支持的事务,大多都遵循第一个 SQL 数据库—— IBM System R 所引入的规范。

但近些年来,NoSQL 的发展对事务的概念造成了一些冲击。2000 年后,为了支持大规模分布式数据的存储,NoSQL 引入了分区、冗余,同时部分放弃了对原事务的完整支持,但通过重新定义”事务“,仍然号称支持事务。或者为了商业的宣传,引入相近的名词。

于是出现了两种极端的观点,一是认为事务与可伸缩性不可兼得,大型分布式系统都必须放弃事务支持以保持高可用性和高性能;一是提出事务是保证高要求的应用不丢数据的必要条件。

两种观点都有失偏颇,和任何技术一样,事务有其优点和局限性,为了理解事务背后相关权衡,有必要探究下事务的详细内涵。

ACID 的含义

说到事务,大家第一反应是 Theo Härder 和 Andreas Reuter 于 1983 提出的 ACID。虽然 ACID 最初提出是为了为数据库中的容错保证给出一种相对精确的描述,但不同数据库对 ACID 的支持并不相同,尤其是 Isolation —— 隔离性。如今,ACID 更多的沦为一个 PR 术语。

下面,将逐一探究 Atomicity、Consistency、Isolation 和 Durability 的精确含义,以此来对事务所要做出的保证建立一个基本的认识。

  • 原子性(Atomicity)

“原子”一般指最小单位,不可再分。在并发编程中,原子性通常和可见性关联,即一个线程无法看到另一个线程执行的原子操作的中间结果。

但,ACID 的隔离性是描述多个客户端并发的所需要解决的问题,而原子性更多的是描述单个客户端\线程内,一组操作可以被原子的执行,如果执行到一半失败,已经执行的操作可以被全部回滚。

因此,ACID 中原子性所提供的保证是:在发生错误时,会回滚该事务所有已经写入的变更。

这个保证很重要,否则用户在执行到一半出错时,很难知道哪些操作已经生效、哪些操作尚未生效。有了此保证,用户如果发现出错,可以安全的进行重试。

  • 一致性(Consistency)

一致性,是一个被广泛使用的词,在不同上下文中,有不同含义:

  1. 多副本,前面讨论了多副本一致性,以及最终一致性的问题。

  2. 一致性哈希,是一种分区和调度的方式,在增删机器后,可以较小代价的进行副本迁移和均衡。

  3. CAP 定理,一致性指的是线性一致性,是多副本间一致性的一种特例。

  4. ACID,数据库在应用程序的视角处于某种”一致性的状态“。

因此,我们使用该术语时,一定要明确其所属上下文,进而明确其含义。具体到 ACID 中,一致性是对某些不变性(invariants) 的维持,所谓不变性,即某些约束条件。如,在银行账户中,在任何时刻,账户余额须等于收入减去支出。

不同于 ACID 中其他性质,一致性是需要应用侧和数据库侧共同维护的:

  1. 应用侧需要写入满足应用侧视角约束要求的数据。

  2. 数据库侧需要保证多次写入前后,尤其是遇到问题时,维持该约束。

因此,一致性可以表述为,应用侧依赖数据库提供的原子性、隔离性来实现一致性。可见,一致性并非数据库事务本身的一种特性,更多的是应用侧的一种属性。据此,乔・海勒斯坦(Joe Hellerstein)认为,在 Härder 与 Reuter 的论文中,“ACID 中的 C”是“用来凑数的”。

  • 隔离性(Isolation)

多个客户端并发访问数据库时,如果访问的数据没有交集,是可以随意并发的。但如果有交集,则会产生并发问题,或称竞态条件(race condition)。

隔离性

设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43。

ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务,因此传统上,教科书将事务隔离形式称为:可串行化(Serializability)。即,如果所有事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除任何并发问题。

但在实践中,很少用这么强的隔离性,实际上隔离性强弱类似于一个光谱,数据库系统提供商一般会实现其中几个,用户可以根据业务情况在隔离性和性能间进行选择。

下一篇,会详细讨论除可串行化外的几种弱隔离级别。

  • 持久性(Durability)

持久性是一种保证,即事务一旦提交,即使服务器宕机重启、甚至发生硬件故障,已经提交的事务所写入的数据就不会丢失。

在单机数据库中,持久性意味着以数据页(Page)或日志形式(WAL)写入了非易失性存储。在多副本(Replication)数据库中,持久性意味着写入了多数节点。

但,持久性都只能做到某种程度的保证,而非绝对保证,比如:

  1. 对于单机,可以容忍宕机。但磁盘坏了就完犊子。

  2. 对于多机,可以容忍少数副本损坏,但是多数副本完后也没辙。

因为在现实世界中,存储所涉及到的所有环节,都不是完美的:

  • 写入磁盘后宕机,虽然数据没丢,但机器修复或磁盘转移前,数据服务都是不可用的。但冗余(Replication)系统可以解决该问题。

  • 一个关联性故障,如软件 bug 或者机房断电,可以同时摧毁一个机房中的所有副本。因此多副本的内存数据库仍要定期持久化到外存。

  • 异步复制系统中,当主副本不可用时,由于没来得及同步到多数节点,最近的写入主副本成功的数据可能会丢失。

  • 当突然断电时,固态硬盘可能不能保证数据已完整刷盘,甚至用户显式调用 fsync 也无济于事。此外,硬盘驱动可能也会有 bug。

  • 磁盘上的数据可能会随着时间逐渐损坏,甚至副本数据也可能同时损坏,此时就只能依赖于备份了。

在实践中,要通过多种手段,比如强制刷盘、校验码、异地复制、定时备份等多种手段来保证数据的持久性,但也只能做到大概率的保证(比如五个九),而非绝对保证。

单对象和多对象操作

总结来说,在 ACID 中,原子性和隔离性是数据库对用户进行多个写入时需要提供的保证,并且它们通常假设一个事务中会同时修改多个对象(rows、documents 和 records)。相比单对象事务(single-object transaction),这种多对象事务(multi-objects transaction)是一种更强的保证,且更常用,因为通常多个写入不会只针对单个对象。

设有电子邮件情景,邮箱首页需要如下语句来展示未读邮件数:

SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true

如果邮件过多,为了加快查询,可以使用额外字段将未读邮件数存储存储起来(术语:denormalization),但每次新增、读过邮件之后都要更新该计数值。

如下图,用户 1 插入一封邮件,然后更新未读邮件数;用户 2 先读取读取邮件列表,后读取未读计数。但邮箱列表中显示有新邮件,但未读计数却显示 0。

单对象和多对象操作

隔离性可以避免此问题,使用户 2 要么看到用户 1 的所有更新,要么看不到任何更新。

下图,说明了原子性保证。如果是事务执行过程中发生错误,原子性会保证如果计数器更新失败,新增的邮件也会被撤销。

原子性保证

在多对象事务中,一个关键点是如何确定多个操作是否属于同一事务:

  • 从物理上来考虑:可以通过 TCP 连接来确定,在同一个连接中,BEGIN TRANSACTION 和 COMMIT语句之间的所有内容,可以认为属于同一个事务。但会有一些 corner case,如在客户端提交请求后,服务器确认提交之前,网络中断,连接断开,此时客户端则无从得知事务是否被成功提交。

  • 从逻辑上来考虑:使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。

实际中基本上使用第二种方法。

有一些非关系型数据库,虽然提供 Batch 操作接口,但它们并不一定有事务语义,即可能有些对象成功,另外一些对象操作却失败。

  • 单对象写入

当只更改单个对象时,仍会面临原子性和隔离性的问题。假设,在文档数据库中,你正在写入一个 20 KB 的 JSON 文档:

  1. 如果发送了前 10 kb 数据后,网络断开,数据库是否已经存储了这不完整的 10k 数据?

  2. 如果该操作是正在覆盖一个老版本同 id 数据,覆盖一半时电源发生故障,数据库是否会存在一半旧值一半新值?

  3. 如果有另一个客户端同时在读取该文档,是否会看到半更新状态?

这些问题都非常棘手,如果数据库不提供任何保证,用户侧得写很多的错误处理逻辑。因此,一般的数据库哪怕不支持完整的事务,也会提供针对单个对象的原子性和隔离性。比如,可以使用写前日志来保证原子性,使用锁来保证隔离性。

其他一些数据库,也会提供更复杂的原子支持,如原子的自增操作,从而避免图 7-1 中的交错更新。另一种更泛化的原子性保证是提供单个对象上的 CAS 操作,允许用户原子的执行针对单个对象的 read-modify-write 操作。当然,如果咬文嚼字一下,原子自增(atomic increment),在 ACID 中应该是属于隔离性(Isolation)的范畴,此处的原子自增其实是多线程的概念。

有的 NoSQL 数据库将上述支持宣传为”轻量级事务“,甚而 PR 成”ACID”。但这是极其不负责任的,通常来说,事务是一种将针对多个对象的多个操作封装为一个执行单元的机制。

  • 界定对多对象事务的需求

由于跨机器的分布式事务很难实现、且非常损失性能(可能在一个数量级),很多分布式数据库选择不支持多对象事务。但有些场景确实需要多对象事务,因此一些数据库多将是否打开事务设为一个开关。

因此,在用户侧,在数据库选型时,有必要审视一下是否真的需要多对象事务,键值对模型和单对象事务是否能够满足需求。一些情况下,是足够的,但更多的情景,需要协同更新多个对象:

  • 在关系型数据库中,一些表通常会有一些外键。在更新时,需要进行同步更新。

  • 在文档型数据库中,相关的数据通常会放到一个文档中,但由于大部分文档数据库不支持 Join,因此不得不使用前文提到的 denormalization 对信息进行冗余存储,便产生了同步更新需求。

  • 在支持次级索引的数据库中,数据和对应的多个索引需要进行同步更新。

如果没针对多对象事务,上述保证只能在应用侧实现,徒增复杂度,而且容易出错。

  • 故障和终止

如前所述,事务的一个重要特点是在执行到一半时,可以安全的终止并重试。事务的设计哲学是:当出现违反原子性、隔离性和持久性的危险时,就丢弃而非保留已经发生的修改。

但,另一些场景,如多副本中的无主模型,就采用了“尽力而为”的模型,即尽可能的保证完成任务,如不能完成,也不会回滚已经发生的修改。因此,应用侧需要处理这种半完成的状态。

尽管无脑重试被终止的事务简单且有效,但并不万能:

  1. 事务已经被成功提交,但返回给用户时出错。用户如果简单重试,就会使该事务中的操作执行两次,从而造成数据冗余,除非用户在应用代码侧进行去重(如多次执行这些语句,效果一样)。

  2. 由于系统负载过高,而造成事务执行失败。如果无脑重试,会进一步加重系统负担。此时可以使用指数后退方式重试,并且限制最大重试次数。

  3. 一些临时错误,如死锁、异常、网络抖动和故障切换时,重试才有效;对于一些永久故障,重试是没有意义的。

  4. 某事务在数据库之外如有副作用,重试事务时,会造成副作用的多次发生。如果某个事务的副作用是发送邮件,则肯定不希望事务每次重试时都发送一次电子邮件。如果想进行多个系统间的协同,可以考虑两阶段提交。

  5. 如果客户端在写入数据时故障,则所有该客户端正在执行的事务所涉及的数据修改都会丢失。


谢谢你读完本文(///▽///)

如果你想尝鲜图数据库 NebulaGraph,记得去 GitHub 下载、使用、(^з^)-☆ star 它 -> GitHub;如果你有更高的性能、易用性、运维实施等方面的需求,你也可以随时 联系我们,获取进一步的帮助哦~