事务还不懂?这一篇文章就够了!详解从事务到分布式事务

引言

事务是一个老生常谈的问题,然而大多数人仅仅是把MySQL的ACID背的滚瓜烂熟,在问到一些关于事务的细节时还是支支吾吾,没办法说出个所以然来,究其原因这是因为出发点是学习MySQL事务的ACID而不是学习事务,这篇文章希望从事务角度出发分析为什么需要事务以及为了实现我们需要哪些技术支持,然后从单机事务扩展至分布式事务,清楚在分布式事务中我们的挑战以及通用的解决方法是什么.

在这里插入图片描述

为什么需要事务

首先我们看看百度上对于事务的解释:

在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)

是这样的,但是为什么我们需要确保我们的一系列读写操作是一个原子操作,即一个执行的整体呢?原因就是数据库底层可能会发生各种预料之外的事情,而我们的操作之间如果不是原子的话可能会出现破坏一致性约束,数据丢失等情况出现,那么会发生哪些事情呢:

  1. 数据库或者客户端突然崩溃,而我们的操作正进行到一半.
  2. 客户端与数据库之间的网络连接突然中断.
  3. 多个客户端对于一个数据同时写入.

这些事情我们都是无法预知的,但我们对于它们的存在不能睁一只眼闭一只眼,我们希望在发生这些事情的时候不会出现系统级的失效.该怎么办呢?解决的方法就是事务.事务可以对应用层的我们屏蔽掉这些错误,使得我们的工作变得轻松愉悦的多.试想你在写一个SQL的时候还要考虑万一数据库崩了数据失效怎么办,这样这个世界就太不美好了.

当然这里的小标题是"为什么需要事务",这在某些情况不是一个并合适的观点,因为当今有大量的数据库选择弱化事务或者干脆不支持事务,究其原因还是希望能够获取更好的效率与可用性,这就意味着事务的实现其实是需要些手段的,而这些手段意味着付出某些性能上的代价,所以数据库对事务的支持程度也可以很好的看出这个数据库在性能与安全上的权衡.

你不会不想知道的ACID特性

ACID是事务提供的安全性保证,安全性意味着在任何情况下都会遵守的约束条件,这四个字母通常代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability).上面我们说到各种数据库对于ACID的实现是不尽相同的,例如在MySQL中对于隔离性的讨论就沸沸扬扬,然而Redis却因为单线程的设计天然支持最高级别的隔离性.但是实现不同不意味着它们没有一个一致的定义,只不过是在性能上的权衡而已.

  1. 原子性: 在出错时终止事务,并丢弃已完成的部分写入.这是因为我们希望一系列读写操作以原子形式完成,如果中途出现错误而不回滚,这意味着我们需要清楚在这次事务中哪些是成功的,哪些是不成功的,这是显然是一个痛苦的过程.
  2. 一致性: 数据库中对某些数据有某些特殊的预期状态,所有的修改需要满足这些预期,也即是:保证完整性约束.一致性是一个值得细细品味的属性,因为它是四个特性中唯一一个贴近于应用层的属性,这并不是数据库可以决定的事情,这是由应用层定义的.而且一致性本身这个名词也有争议,因为它实在是有太多含义了,一致性哈希,强一致性,最终一致性,ACID中的一致性真是让人头大,作为一个头脑清楚的程序员还是要明确它们各自的定义才可.
  3. 隔离性: 并发执行的多个事务不会相互交叉.这意味着在单线程数据库中这是天然支持的,而在多线程数据库中这就显得非常麻烦,像单线程一样串行执行?好像失去了多线程的意义,那么我们其实可以在某些情况下降低这里的隔离性,也称为弱隔离性,它的定义是事务的最终结果与串行处理之后的结果相同.我们将在下面着重探讨.
  4. 持久性: 事务一旦提交成功,数据即使出现数据库宕机也不会丢失.这其实就是数据库的定义之一,这样看来我们好像非遵守不可,其实不然,为了保证每条数据都要成功后不丢失意味着需要刷会内核态,而内核态也是存在缓存的,需要我们使用fsync等函数进行刷入,着通常意味着效率的下降,所以很多nosql并没有保证持久性,而是提供了不同等级的持久化策略(Redis).

错误处理的情况

事务的一个关键特性是:如果发生了意外,所有的操作被终止,之后可以安全的重试.一般数据库在事务违反了ACID以后就会完全放弃整个事务而不是一部分.但是这在某些情况不是一个最优的方案,比如基于多节点系统中,可能因为现在网络的问题一部分操作失败,如果直接报错显然不是最皆大欢喜的场面,也许需要一定程度上的重试机制(一个客户端往往对于连接失败有着安全的重试机制),重试在有时也不是一个完美的解法,它可能遇到如下问题:

  1. 如果已经执行成功,但是回复信息却出现了丢失,这个时候可能会使得一个命令在客户端执行两次,这是我们需要判断这些情况(TCP协议栈中的滑动窗口).
  2. 如果错误是因为系统超负荷导致,重复执行只会导致更糟糕的情况.
  3. 如果因为服务器故障的话重试毫无意义.
  4. 如果客户端重试失败,这样就使得待写入的数据丢失.

弱隔离级别

我们在上面讨论过ACID特性中的隔离性,隔离性保证多个操作同一个数据的事务之间不会出现一些并发问题.显然在单机的一个普通多线程程序中我们如何解决并发问题,究其本质就是使得并发执行的多条指令强加顺序,加锁也好,一个支持多线程的内存模型也好(内存序)也好,它们都是使得并发的指令加上一个顺序来确保不会出现并发问题.类比数据库,我们当然也可以这样,当多个事务并发的修改一个数据的时候这样没有什么问题,但是如果多个事务并发执行,但它们并没有修改同一个数据呢?这样的并发操作倘若强加上一个顺序好像并没有什么特殊意义,还增加了开销.并发带来的复杂性是毋庸置疑的,如果需要我们像多线程编程一样去使用数据库我想这种数据库也不会如此流行.但是我们又希望能得到解决并发问题以后所带来的显著的效率提升.如何能在效率的基础上保证不会出现并发的问题呢?数据库一直尝试通过事务隔离来对应用开发者隐藏内部的各种并发问题.这样程序猿的生活就会更加的轻松愉快.

而实现隔离并没有我们想象的那么简单,实现隔离的最简单有效的方法就是串行,这解决了所有可能遇到的问题,但随之而来的是不可忍受的性能下降,而数据库的高效又是我们非常关心的点.所以大多数数据库实际上采用了较弱的隔离级别,我们称之为弱隔离级别,它们可以预防一些而不是全部的并发问题.下面简单的聊聊这些隔离级别.

Read uncommittied(读-未提交)

这是最不可能使用的一种隔离级别,它对于并发所出现的问题的看法就是----没有看法.使用这个隔离级别的话我们将会遇到所有可能的并发问题,想象以下多线程修改一个变量不加锁会发生什么,这就是现在所面临的问题.如果你真的确定你的业务不会出现并发修改同一个数据的情况,那就冒着被炒鱿鱼的风险去用吧!兄弟姐妹,世界如此美妙,为何要自讨苦吃呢.
在这里插入图片描述

Read committied(读-已提交)

这是最基本的一种事务隔离级别,它提供两个基本保证:

  1. 读数据库时只能看到已提交的数据(避免脏读).
  2. 写数据库时只能覆盖已提交的数据(避免脏写).

脏读的定义就是:某个事务中完成了一般,此时另一个事务是否能看到为提交的写入,如果可以,就是脏读.看个简单的例子吧.

事务1 事务2
key == 2 key == 2
get key (得到2)
set key = 3
get key(得到3 产生脏读)
rollback(出现异常,进行回滚)

这会出现什么问题呢?正如上面例子我们看到的,可能事务1在后面的执行中出现未知的错误,要进行回滚,事务2看到的值就是一个实际上"从未出现过的值".这会引发难以预测的后果.

那么我们如何实现Read committied呢?一般的解决方案就是行锁,也就是当一个事务要修改一个值之前首先要获取行锁,在commit时释放,这样就可以保证后面要修改相同数据的事务无法执行修改了.这里可能会出现一些问题,就是效率.当使用行锁的时候会使得如果一个写操作占用了大量的时间,那么会阻塞一系列的读操作,这里解决的方法就是存储新旧两个版本的数据,在这个事务提交之前读旧数据,写阻塞.在事务提交之后读新数据,这样可以有效的增加读时效率(不是MVCC).

看似它已经解决了我们的问题,它完美的避免了脏读.但仍会造成其他的问题,我们称之为不可重复读(nonrepeatable read)或者读倾斜(read skew),看一个实际的问题:

事务1 事务2
key == 2 key == 2
get key(得到2)
set key = 3
commit
get key(得到3,出现不可重复读)

显然我们希望一个事务是原子的,也就是说期望它的执行结果和串行一致,但却出现了这种问题.你可能不以为然,认为这样的问题不算什么,因为那个数据你早晚都会看到,然而着在有的时候也是一个严峻的问题.比如事务2在进行更新系统而事务1在进行备份,可重复读会导致拷贝的数据中一部分是旧数据,一部分是新数据,这可能会造成严重的影响.

Repeatable Read(可重复读)

这个级别是MySQL中innodb引擎的默认隔离级别,很多人喜欢称这个隔离级别为RR级别,这样其实并不直观,我在第一次听到的时候还是有点不知所措,这是个啥?相比之下我更愿意称其为快照级别隔离.在这种级别之下可以很好的避免不可重复读.如何实现呢?

其实基本流程与Read committied类似,在写时加行锁(避免更新丢失问题),且写操作不会阻塞读操作.那么如果做到避免不可重复读呢?答案就是MVCC(Multi-Version Concurrency Control, 多版本并发控制),这种特殊的快照可以很好的避免不可重复读.我们在Read committied中提到的方法只需要保存两个版本就可以了.MVCC是什么呢?innodb的MVCC的实现就是给每一个数据的行记录后面加两个特殊的列,一个记录了行的创建时间,一个保存行的过期时间(非物理时间,为逻辑时间,单调递增).我们来以MySQL操作为例,阐述MVCC的原理:

  1. SELECT: 只查找版本号小于或等于当前事务的版本号的数据,可以保证这个数据要么在此事务存在前存在,要么是这个事务.然后删除版本号要么不存在,要么大于当前事务版本号,保证要么还未删除,要么删除在这个事务之后,也就是说现在是有效的.
  2. INSERT: 为插入的每一行保留当前事务的版本号
  3. DELETE: 为删除的数据保存当前事务的事务版本号.
  4. UPDATE: 插入一行新纪录,新记录中插入当前事务版本号,并在旧记录中过期时间行填充当前事务版本号.

这里还有一点值得考虑,就是索引如何支持MVCC呢,一种有效的方法就是在索引中指向所有版本的数据,并在查询时过滤掉不需要的版本.

我们上面提到了更新丢失问题.这种问题出现的场合就是查询语句根据某些逻辑来对数据做出更改,写入新值,我们来看个例子:

事务1 书屋2
x == 2 x == 2
set x = x + 1 where x = 2 set x = x + 1 where x = 2
get x (得到3) get x (得到3)
commit commit

这是一个简单的计数器,这样的操作最终答案本应该是4,但却是3,也就是一个操作丢失了.这样的结果也与串行不同,显然违背了我们的定义,这是一个非常实际的问题,解决的方法就是显示加锁.在MySQL中也就是FOR UPDATE命令,可对数据库返回的所有结果行加锁,这样就可以完美的解决这个问题.当然这是一个非常典型的悲观锁,其实这种情况也并不常见,所以乐观锁的解决方案好像更加可行,就是先并发执行,如果发现冲突就终止事务,并重新执行,这样的方案在多数情况显然更加高效,但Innodb并没有这样的功能,需要我们手动加锁.当然了大多数数据库都提供CAS(compare and swap)操作,即原子比较设置,这样也可以轻松的避免更新丢失.

写倾斜与幻读

写倾斜其实与更新丢失类似,可以把写倾斜看做广义上的更新丢失,我们举个简单的例子:假设某个晚上大门必须至少有一人看守,今天晚上有两个人同时看门,他们又恰巧都感到不适,在同一时间请假,假设有如下存储过程:

begin transaction

number = (
	select count(*) from Employee 
	where date = 20200412 and on = yes; // 在2020年4月12号这天晚上看门的有多少人
)
if(number >=1 ){
	update Employee
	set on = no 
	where name = '李兆龙' and date = 20200412;
}

倘若这天晚上本来有两个人,他们同时执行这个存储过程这样第一步同时执行,都得到了2,这样就会造成这一天晚上没人看门的问题,在这种情况下显然我们还可以加锁解决,就是锁住第一步要查询的那些值,但是如果出现第一步的值我们没办法锁住怎么办?比如这样:

事务A 事务B
select * from Employee where number > 100;
insert into Employee(number) values(101);
commit;
select * from Employee where number > 100;(出现幻读)

烦躁!这可麻烦了,我们没有办法在第一步加锁,怎么可以锁住根本不存在的值呢?这个问题看似已经将我们这些弱小无助的程序员逼入了死角,但是其实时有办法解决的,我在这篇文章中做出了详细解答 避免幻读 : next-key锁与MVCC,有兴趣的朋友可以进一步学习.

Serializable(可序列化)

这是我们一直在说的一种解决方案,但始终因为效率问题把它抛在一旁,现在是时候重拾这个问题了,是否它真的像我们想象的那样不堪?事实上在MySQL中在Repeatable Read中我们通过合理的操作可以避免绝大多数问题,但是我们实在是无法预测一个大型系统中出现的所有问题.怎么办呢?也许串行不是一个那么坏的选择,它向我们保证最终执行结果与串行的结果一致,目前有三种可行的串行化策略:

  1. 真实串行执行.
  2. 两阶段加锁(悲观).
  3. 可串行化快照隔离(乐观).

真实串行执行

正如名称所述,不搞花里胡哨,解决并发问题的最好方法就是不并发.这并不是说笑,诸如Redis,VoltDB都采用了单线程执行事务,这可以避免锁开销,但是同时也局限与效率瓶颈,还好可以水平扩展.当然在这种模式下事务必须简介有效,否则会阻塞后面的事务执行,这也就是Redis中一个事务执行时间过长以后会暂停先执行后面的事务.而且最好数据能够都加载在内存中,否则这将是一个漫长的过程.

两阶段加锁

这是一个被广泛使用的串行话算法,就是两阶段加锁(two-phase locking 2PL),其实就是在事务出现写入操作的时候进行加锁,允许多个事务读取同一个对象,具体如下:

  1. 事务A读取某个对象,事务B想要写入,必须等待事务A完成或终止.
  2. 事务A已经修改某个对象,事务B想要读取,必须等待事务A提交或终止以后.

这样看来其实就是加上读写锁.之所以叫做两阶段是因为在事务执行之前获取锁,在事务结束之后释放锁.当然它的缺点也就是效率,因为锁的开销和降低并发性的原因其效率远低于前面几个隔离级别.这也是一个典型的悲观锁.

可串行化快照隔离

也叫作SSI(Serializable Snapshot Isolation,可串行化快照隔离).这是一个以乐观的方式来进行的串行化算法,算法假设事务可并发执行,并在执行完后检查条件,如果与事务执行前的条件冲突,就会导致事务终止,主要分为两种情况,即:

  1. 检测写以后后先前读的条件是否改变.
  2. 检测写是否影响了以前的读.

悲观与乐观的两种方式显然适用于请求较多和请求较少的情况,但毋庸置疑的是它们都突破了单CPU的限制.

又一个艰巨挑战 分布式事务

在这里插入图片描述
分布式事务,就是在分布式系统中运行的事务,由多个本地事务组合而成.此时对事务的操作就不是单机了,而是来此多个不同的机器,此时保证事务的ACID特性显的尤为困难,所以衍生出了BASE理论这样放弃强一致性而选择最终一致性的经验之谈.在分布式事务中我们通常有以下解决方案:

  1. 两阶段提交.
  2. 三阶段提交.
  3. 基于消息的最终一致性方法.

两阶段提交

2PC(two-phase commit 两阶段提交),它看起来好像和2PL长的有些类似,但是它们之间其实除了2P这两个字符以外就没有什么共同的地方了.它可以保证多节点之间的事务要么全部提交,要么全部不提交.
2PC映入了一个新的组件,我们称之为事务管理器,一个2PC的过程是这样的:

  1. 某个客户端向事务管理器请求一个全局唯一的事务ID.
  2. 向参与的每一个节点执行事务,但并不提交.
  3. 当客户端进行提交的时候请求事务管理器,其向所有的节点发送准备请求,并附带事务ID.
  4. 参与者在收到这个准备信息的时候确保可以安全的执行事务(事务写入磁盘,查看存储空间等),然后回复"是",这意味着无论出现任何情况如果事务管理器希望它执行时必须执行完毕.
  5. 事务管理器收到全部的回复时把决定写入日志(防止自己宕机),向所有的节点发送提交或者放弃请求,就算出现了消息发送失败的情况也要一直重试.当所有事务执行完毕以后向事务管理器发送HaveCommitted消息,代表事务完成.

正是第4,5条保证了这个算法能够保证事务的原子性.我们可以看出这个算法的核心在于事务管理器.当事务管理器宕机的时候整个事务就无法继续运作了.

我们来举一个实际的例子:
在这里插入图片描述
假设要买某个商品,第一阶段在订单系统中向事务管理器回复OK,而库存系统回复NO,这样就会使得事务协调者向所有的节点发送终止的请求,从而保证事务的原子性,这样看来这个过程其实类似于不容忍节点故障的分布式共识问题.

我们可以看到第一阶段结束时事务管理器需要全部的回复,缺一不可,这也侧面显示出了分布式事务的性能,其一是为了实现两个保证需要把数据刷回磁盘(fsync),其二是额外的网络开销,而且网络的延迟是不可预测的,这也意味着这个算法的上确界是不存在的.

其次其实两阶段提交有些明显的问题:

  1. 所有参与节点都是事务阻塞型的.也就是说如果资源正在被使用的时候申请就会阻塞事务.
  2. 当协调者出现问题的时候整个系统就处于停滞状态,而且最麻烦的是第一阶段事务的锁还存在.
  3. 在第二阶段,当事务向参与者发送提交请求之后,如果发生了局部网络异常,或者在发送提交请求的过程中事务管理器发生了故障,就会导致只有一部分参与者接收到了提交请求并执行提交操作,但其他未接到提交请求的那部分参与者则无法执行事务提交.于是整个分布式系统便出现了数据不一致的问题.

目前分布式事务分为两种:

  1. 数据库内部的分布式事务: 所有节点运行同一种软件.
  2. 异构分布式事务: 所有节点运行不同的软件.

XA事务

针对于异构分布式事务在不同系统上的兼容问题,提出了X/Open XA(eXtended Architecture)这样在异构环境下实现2PC的一个工业标准.在java中就实现了相关的功能JTA(Java Transaction API).在参考中有几篇对MySQLXA的详细解释.

总结

事务显然在数据库中是一个至关重要的概念,但由于资料较少的原因很多人对这个概念的认识只是停留在表面,并不进行深究,这对于我们的学习来说其实并不是一个好事,因为仔细思考事务,其实可以发现与其他知识点有着千丝万缕的联系.这也正是我写下这篇文章的目的.

参考:
XA事务

MySQL分布式XA事务

全序与偏序关系

MySQL XA官方文档

MySQL XA中文文档

BASE理论

完整性约束

事务隔离级别

Designing Data-Intensive Applications

<<高性能MySQL>>

聂鹏程 分布式技术原理与算法解析

Three-phase commit protocol

三阶段提交

发布了157 篇原创文章 · 获赞 85 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_43705457/article/details/105443927
今日推荐