事务简介
事务的核心是锁和并发, 采用同步控制的方式保证并发的情况下性能尽可能高, 且容易理解. 这种方式的优势是方便理解; 它的劣势是性能比较低.
计算机可以简单的理解为一个标准的打字机, 尽管看起来计算机可以并行处理很多事情, 但实际上每个CPU单位时间内只能做一件事, 要么读取数据、要么计算数据、要么写入数据, 所有的任务都可以看成这三件事的集合. 计算机的这种特性引出了一个问题:当多个人去读、算、写操作时, 如果不加访问控制, 系统势必会产生冲突. 而事务相当于在读、算、写操作之外增加了同步的模块, 进而保证只有一个线程进入事务当中, 而其他线程不会进入.
单个事务单元
事务的四大特性分别是:原子型、一致性、隔离性和持久性.
原子性指的是事务中包含的所有操作要么全做, 要么全不做;
一致性是指在事务开始以前, 数据库处于一致性的状态, 事务结束后, 数据库也必须处于一致性的状态;
隔离性要求系统必须保证事务不受其他并发执行的事务的影响;
持久性是指一个事务一旦成功完成, 它对数据库的改变必须是永久的, 即使是在系统遇到故障的情况下也不会丢失, 数据的重要性决定了事务的持久性的重要.

事务单元是通过Begin-Traction, 然后Commit(Begin-Traction、Commit和Rollback之间所有针对数据的写入、读取的操作都应该添加同步访问), Begin和Commit之间就是一个同步的事务单元. 例如, Bob给Smith 100块钱就是一个事务单元, 这个过程中有很多步操作, 具体如上图所示; 但对业务来说, 仅是一个转账的操作.
事务之间的关系
事务单元之间的happens-before关系: 《事务管理》
amdahl定律: 最快并行, 最慢串行
最快的速度并保证逻辑顺序
目标–提高系统的易用性而不损失系统的性能
数据库使用多线程的原因
事务问题的来源
慢速设备:硬盘和网路 I/O PS过低,吞吐量很高
快速设备:内存
一组事务单元

当三个账户都在进行转账操作时, 每个操作都涉及Smith账户, 所有的事务都会排队, 各自形成一组事务单元.
事务单元之间的Happen-Before关系中的四种可能性:读写、写读、读读、写写.
所有事务之间的关系都可以抽象成这四种之一, 来对应现在所有的业务逻辑处理. 在此基础之上, 需要用最快的速度处理多个事务单元之间的关系, 同时还能保障这四种操作的逻辑顺序.
单个事务单元的其他例子
除了转账操作是事务单元外, 诸如商品要建立一个基于GMT_Modified的索引、从数据库中读取一行记录、向数据库中写入一行记录, 同时更新这行记录的所有索引、删除整张表等都是一个事务单元.
也是一个事务单元:
- 添加索引
- 从数据库读一条数据
- 向数据库写一条记录, 并更新索引
- 删除整张表
事务单元的实现方式

Two Phase Lock(2PL)是数据库中非常重要的一个概念.
数据库操作Insert、Update、Delete都是先读再写的操作, 例如Insert操作是先读取数据, 读取之后判读数据是否存在, 如果不存在, 则写入该数据, 如果数据存在, 则返回错误.
假设在该场景下没有读操作, 只是单纯写入数据, 则数据本身并没有事务操作, Delete、Update操作与之类似.
数据库利用这些操作的特性, 在每一次查询过程中, 只要查到数据, 就会在该数据上加锁.理论上, 所有被读取的数据都已加锁, 不会再被其他人读到, 也就是说对数据进行的中间操作状态对所有人都不可见, 当所有中间状态完成后, 提交操作时, 解开锁, 此时数据对所有系统可见 , 例如在转账过程中, 所有人只能看到两种状态:开始时, A有钱, B没钱; 结束时, B有钱, A没钱, 而中间A减掉钱, B尚未加上钱的状态被锁隐藏掉了, 这个操作就是数据库中处理事务的最标准的方式. 如上图所示:事务中的Trx2(JoeLock)与其他事务不相关, 因此可以并行执行; Trx1需要Lock两个数据Boblock和Smithlock, 而Trx3同样需要Lock这两个数据, 因此Trx3必须等待, 且等待在Boblock上; Joe事务会先结束, Trx3会等到Trx1完成后才会开始.
两阶段提交协议(基础是2pl): 事务单元内, 从读数据开始, 将所读的行锁住, 直到事务提交才释放.
处理事务的常见方法
处理事务的常见方法有排队法、排他锁、读写锁、MVCC等方式, 下面来一一解析.
排队法

事务处理中最重要也是最简单的方案是排队法, 单线程地处理一堆数据. 在Redis中, 如果数据全部在内存中, 则单线程处理所有Put、Get操作效率最高.
这是因为多线程本质是CPU模拟多个线程, 这种模拟是以上下文切换为代价, 而对于内存的数据库来说, 没有上下文切换时效率最高. 因此, 单个CPU绑定一块内存的数据, 针对这块数据做多次读写操作时都是在单个CPU上完成的, 单线程处理方式在内存的情况是效率是最优的.
那么什么时候事务需要用到多线程呢?这个问题的本质取决于下层所使用的存储, 如果是内存操作, 则可以动态地申请和销毁内存块; 而磁盘的IOPS很低, 但吞吐量很高. 如果一个场景涉及多次读写操作, 单线程可以很高的效率对于内存进行读写操作; 但是, 由于磁盘的IOPS仅为内存的几千分之一, 如果依旧用操作内存的方式操作磁盘, 那系统的整体性能将会很低, 这意味着必须将大量的读写操作聚合成一个Batch后再提交时才能达到较好的性能. 而将大量请求攒到一起的方式一是异步, 也就是请求本身和线程不绑定, 线程可以不Block(本质来说还是一种多线程的方式), 处理完一个线程后再处理其他线程. 这种做法的核心是将大量不同的请求提交到一个Buffer中, 再由该Buffer统一读取或者写入磁盘, 从而提高效率. 在慢速设备中, 多线程或异步非常常见, 在设计系统时, 面对磁盘、网络、SSD等慢速设备必须考虑使用多线程.
排他锁

有些场景不适合用单线程操作, 可以利用排他锁的方式来快速隔离并发读写事务. 数据库中有一些事务单元是共享的, 如图中的事务单元1是共享的, 事务单元2/3共享数据; 针对事务单元2/3共享数据的所有读写Block住, 事务单元1单独用一个锁来控制, 用这种方式完成系统的访问控制.
读写锁

如果是一个只读的事务, 例如只对数据进行查询操作, 在该过程中数据一定不被修改, 因此多个查询操作可以并行执行, 因此一种针对读读场景的优化自然而然产生——读写锁. 读写锁的核心是在多次读的操作中, 同时允许多个读者来访问共享资源, 提高并发性.
MVCC

在最初的数据库事务实现中是不存在MVCC的, 它是Oracle在八十年代新加的功能, 本质是Copy On Write, 也就是每次写都是以重新开始一个新的版本的方式写入数据,
因此, 数据库中也就包含了之前的所有版本. 在数据读的过程中, 先申请一个版本号, 如果该版本号小于正在写入的版本号, 则数据一定可以查询到, 无需等到新版本完全写完即可返回查询结果. 这种方式可以在读读不阻塞的前提下, 实现读写/写读不阻塞, 尽可能保证所有的读操作并行, 而写操作串行.
事务的调优原则
事务的调优的思路是在不影响业务应用的前提下:
第一. 尽可能减少锁的覆盖范围, 例如 Myisam表锁到Innodb行锁就是一个减少锁覆盖范围的过程; 对于原位锁(排他锁、读写锁等)可变为MVCC多版本(本质仍然是减少锁的范围).
第二. 增加锁上可并行的线程数, 例如读锁和写锁的分离, 允许并行读取数据.
第三. 选择正确锁类型, 其中悲观锁适合并发争抢比较严重的场景; 乐观锁适合并发争抢不太严重的场景.
分布式事务
目标
容易理解的模型往往性能不好, 性能好的模型往往不容易理解—-这就是生活
分布式事务带来的问题
- 网络带来的问题
- 基于锁的事务实现中遇到的问题
- 从2PL到2PC
- 分布式事务的异常处理
- 分布式日志记录
- 分布式事务延迟变大的问题
- 结合MVCC的事务实现中遇到的问题
并行与并发区别
分布式事务
分布式事务服务(Distributed Transaction Service,DTS)
由于在分布式系统中经常发生丢包、网络故障,分区容忍性是必须要满足的,同时为了兼顾高可用性,绝大部分系统都将强一致性需求转化成最终一致性的需求,并通过幂等机制保证了数据的最终一致性。
理解2PC和3PC协议
为了解决分布式一致性问题,前人在性能和数据一致性的反反复复权衡过程中总结了许多典型的协议和算法。其中比较著名的有二阶提交协议(2 Phase Commitment Protocol),三阶提交协议(3 Phase Commitment Protocol)。
2PC
分布式事务最常用的解决方案就是二阶段提交。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有参与者节点的操作结果并最终指示这些节点是否要把操作结果进行真正的提交。
因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
所谓的两个阶段是指:第一阶段:准备阶段(投票阶段)和第二阶段:提交阶段(执行阶段)。
第一阶段:投票阶段
该阶段的主要目的在于打探数据库集群中的各个参与者是否能够正常的执行事务,具体步骤如下:
- 协调者向所有的参与者发送事务执行请求,并等待参与者反馈事务执行结果。
- 事务参与者收到请求之后,执行事务,但不提交,并记录事务日志。
- 参与者将自己事务执行情况反馈给协调者,同时阻塞等待协调者的后续指令。
第二阶段:事务提交阶段
在第一阶段协调者的询盘之后,各个参与者会回复自己事务的执行情况,这时候存在三种可能:
- 所有的参与者回复能够正常执行事务。
- 一个或多个参与者回复事务执行失败。
- 协调者等待超时。
对于第一种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:
- 协调者向各个参与者发送commit通知,请求提交事务。
- 参与者收到事务提交通知之后,执行commit操作,然后释放占有的资源。
- 参与者向协调者返回事务commit结果信息。

对于第二、三种情况,协调者均认为参与者无法正常成功执行事务,为了整个集群数据的一致性,所以要向各个参与者发送事务回滚通知,具体步骤如下:
- 协调者向各个参与者发送事务rollback通知,请求回滚事务。
- 参与者收到事务回滚通知之后,执行rollback操作,然后释放占有的资源。
- 参与者向协调者返回事务rollback结果信息。

两阶段提交协议解决的是分布式数据库数据强一致性问题,其原理简单,易于实现,但是缺点也是显而易见的,主要缺点如下:
- 单点问题:协调者在整个两阶段提交过程中扮演着举足轻重的作用,一旦协调者所在服务器宕机,那么就会影响整个数据库集群的正常运行,比如在第二阶段中,如果协调者因为故障不能正常发送事务提交或回滚通知,那么参与者们将一直处于阻塞状态,整个数据库集群将无法提供服务。
- 同步阻塞:两阶段提交执行过程中,所有的参与者都需要听从协调者的统一调度,期间处于阻塞状态而不能从事其他操作,这样效率及其低下。
- 数据不一致性:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
3PC
针对两阶段提交存在的问题,三阶段提交协议通过引入一个“预询盘”阶段,以及超时策略来减少整个集群的阻塞时间,提升系统性能。三阶段提交的三个阶段分别为:can_commit,pre_commit,do_commit。
第一阶段:can_commit
该阶段协调者会去询问各个参与者是否能够正常执行事务,参与者根据自身情况回复一个预估值,相对于真正的执行事务,这个过程是轻量的,具体步骤如下:
- 协调者向各个参与者发送事务询问通知,询问是否可以执行事务操作,并等待回复。
- 各个参与者依据自身状况回复一个预估值,如果预估自己能够正常执行事务就返回确定信息,并进入预备状态,否则返回否定信息。
第二阶段:pre_commit
本阶段协调者会根据第一阶段的询盘结果采取相应操作,询盘结果主要有三种:
- 所有的参与者都返回确定信息。
- 一个或多个参与者返回否定信息。
- 协调者等待超时。
针对第一种情况,协调者会向所有参与者发送事务执行请求,具体步骤如下:
- 协调者向所有的事务参与者发送事务执行通知。
- 参与者收到通知后,执行事务,但不提交。
- 参与者将事务执行情况返回给客户端。
在上面的步骤中,如果参与者等待超时,则会中断事务。 针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发出abort通知,请求退出预备状态,具体步骤如下:
- 协调者向所有事务参与者发送abort通知
- 参与者收到通知后,中断事务

第三阶段:do_commit
如果第二阶段事务未中断,那么本阶段协调者将会依据事务执行返回的结果来决定提交或回滚事务,分为三种情况:
- 所有的参与者都能正常执行事务。
- 一个或多个参与者执行事务失败。
- 协调者等待超时。
针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:
- 协调者向所有参与者发送事务commit通知。
- 所有参与者在收到通知之后执行commit操作,并释放占有的资源。
- 参与者向协调者反馈事务提交结果。

针对第二、三种情况,协调者认为事务无法正常执行,于是向各个参与者发送事务回滚请求,具体步骤如下:
- 协调者向所有参与者发送事务rollback通知。
- 所有参与者在收到通知之后执行rollback操作,并释放占有的资源。
- 参与者向协调者反馈事务提交结果。

在本阶段如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的commit或rollback请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是等待超时后继续commit。相对于两阶段提交虽然降低了同步阻塞,但仍然无法避免数据的不一致性。
高吞吐 高性能 和单机事务一样易用
ACID
spandex
xa
java 协调器
分布式日志记录
隔离级别 事务的传递性
幂等
【相关文献】