事务简介
事务的核心是锁和并发
采用同步控制的方式保证并发的情况下性能尽可能高,且容易理解。
优势是方便理解;它的劣势是性能比较低。
尽管看起来计算机可以并行处理很多事情,但实际上每个CPU单位时间内只能做一件事。
而且计算机就做三件事,读、写、算来回重复做这三件事,所有的任务都可以看成这三件事的集合。
计算机的这种特性引出了一个问题:当多个人去读、算、写操作时,如果不加访问控制,系统势必会产生冲突。
而事务相当于在读、算、写操作之外增加了同步的模块,进而保证只有一个线程进入事务当中,而其他线程不会进入。
事务与并发控制相通
单个事务单元
事物就三个指令
beginTransaction
commit
rollback
begin和commit之间的过程就是一个事务单元
事务的四大特性分别是:原子性、一致性、隔离性和持久性
- 原子性,指的是事务中包含的所有操作要么全做,要么全不做;
- 一致性,是指在事务开始以前,数据库处于一致性的状态,事务结束后,数据库也必须处于一致性的状态;
- 隔离性要求系统必须保证事务不受其他并发执行的事务的影响;
-
持久性是指一个事务一旦成功完成,它对数据库的改变必须是永久的,即使是在系统遇到故障的情况下也不会丢失,数据的重要性决定了事务的持久性的重要。
事务单元是通过Begin-Traction,然后Commit(Begin-Traction、Commit和Rollback之间所有针对数据的写入、读取的操作都应该添加同步访问),Begin和Commit之间就是一个同步的事务单元。例如,Bob给Smith 100块钱就是一个事务单元,这个过程中有很多步操作,具体如上图所示;但对业务来说,仅是一个转账的操作。
一组事务单元
当三个账户都在进行转账操作时,每个操作都涉及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上,Trx3会等到Trx1完成后才会开始;
- Joe事务会先结束。
单机事务的典型异常应对策略
处理事务的常见方法
处理事务的常见方法有排队法、排他锁、读写锁、MVCC等方式。
排队法
事务处理中最重要也是最简单的方案是排队法,单线程地处理一堆数据。在Redis中,如果数据全部在内存中,则单线程处理所有Put、Get操作效率最高。
这是因为多线程本质是CPU模拟多个线程,这种模拟是以上下文切换为代价,而对于内存的数据库来说,没有上下文切换时效率最高。因此,单个CPU绑定一块内存的数据,针对这块数据做多次读写操作时都是在单个CPU上完成的,单线程处理方式在内存的情况是效率是最优的。
下层所使用的存储决定了是否需要用到多线程。
如果是内存操作,则可以动态地申请和销毁内存块;
而磁盘的IOPS很低(100--150),但吞吐量很高。
IOPS: 每秒进行读写(I/O)操作的次数,多用于数据库等场合,衡量随机访问的性能,
由于磁盘的IOPS仅为内存的几千分之一,意味着必须将大量的读写操作聚合成一个Batch后再提交时才能达到较好的性能。而将大量请求攒到一起的方式就是异步,也就是请求本身和线程不绑定,线程可以不Block(本质来说还是一种多线程的方式),处理完一个线程后再处理其他线程。这种做法的核心是将大量不同的请求提交到一个Buffer中,再由该Buffer统一读取或者写入磁盘,从而提高效率。
在慢速设备中,多线程或异步非常常见,在设计系统时,面对磁盘、网络、SSD等慢速设备必须考虑使用多线程。
排他锁
多线程场景,可以利用排他锁快速隔离并发读写事务。
数据库中有一些事务单元是共享的,
上图中的事务单元1是共享的,事务单元2/3共享数据;
针对事务单元2/3共享数据的所有读写Block住,事务单元1单独用一个锁来控制,用这种方式完成系统的访问控制。
读写锁
只读事务过程中,数据一定不被修改,因此多个查询操作可以并行执行,针对读读场景的优化自然而然产生——读写锁。
读写锁的核心是在多次读的操作中,同时允许多个读者来访问共享资源,提高并发性。
MVCC
本质是Copy On Write,也就是每次写都是以重新开始一个新的版本的方式写入数据,数据库包含了之前的所有版本。在数据读的过程中,先申请一个版本号,如果该版本号小于正在写入的版本号,则数据一定可以查询到,无需等到新版本完全写完即可返回查询结果。
读读不阻塞,读写/写读不阻塞,只有写写操作串行。
事务的调优原则
事务的调优的思路是在不影响业务应用的前提下:
- 第一,尽可能减少锁的覆盖范围,例如Myisam表锁到Innodb的行锁就是一个减少锁覆盖范围的过程;对于原位锁(排他锁、读写锁等)可变为MVCC多版本(本质仍然是减少锁的范围)。
- 第二,增加锁上可并行的线程数,例如读锁和写锁的分离,允许并行读取数据。
- 第三,选择正确锁类型,其中悲观锁适合并发争抢比较严重的场景; 乐观锁适合并发争抢不太严重
分布式事务
二段式提交
分为准备阶段和提交阶段
准备阶段
- 协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。
- 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)
- 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。
提交阶段
- 如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;
- 否则,发送提交(Commit)消息;
- 参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)
当协调者节点从所有参与者节点获得的相应消息都为”同意”时
- 协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
- 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送”完成”消息。
- 协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。
如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时
- 协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
- 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送”回滚完成”消息。
- 协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。
三段式提交
三阶段提交有两个改动点
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
- 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
CanCommit阶段
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
- 事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
- 响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
PreCommit阶段
协调者根据参与者的反应情况来决定是否可以事务的PreCommit操作。
根据响应情况,有以下两种可能。
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
- 发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
- 事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
- 响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
- 发送中断请求 协调者向所有参与者发送abort请求。
- 中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
doCommit阶段
该阶段进行真正的事务提交,也可以分为以下两种情况。
执行提交
- 发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
- 事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
3.响应反馈 事务提交完之后,向协调者发送Ack响应。
4.完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
中断事务
协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
- 发送中断请求 协调者向所有参与者发送abort请求
- 事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,会继续进行事务的提交。
这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。
一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了
所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。
一致性
分布式事务控制的最终目标是实现一致性,方案大体分为实时一致性和最终一致性两种:
两阶段提交是比较典型的实时一致性方案
提供补偿事务和基于消息队列的异步处理方案是最终一致性方案。
目前的主流数据库在本地事务提交后都不能回滚。
事务隔离性的本质就是如何正确处理读写冲突和写写冲突,这在分布式事务中又是一个难点
分布式事务最初起源于处理多个数据库之间的数据一致性问题,但随着IT技术的高速发展,大型系统中逐渐使用SOA服务化接口替换直接对数据库操作,所以如何保证各个SOA服务之间的数据一致性也被划分到分布式事务的范畴。
两段式事务
两段式事务也就是著名的XA事务,XA是由X/Open组织提出的分布式事务的规范,也是使用最为广泛的多数据库分布式事务规范,目前市面上主流的数据库MySQL,Oralce,SQLServer等都支持XA事务。
基于SOA接口的分布式事务
目前比较流行的SOA分布式事务解决方案是TCC事务,TCC事务的全称为:Try-Confirm/Cancel,翻译成中文即:尝试、确定、取消。
TCC模式的扣除金币操作,接口提供者针对扣除金币这一操作需要提供三个SOA接口:
- 扣除金币Try接口,尝试扣除金币,这里只是锁定玩家账户中需要被扣除的金币,并没有真正扣除金币,类似于信用卡的预授权;假设玩家账户中100金币,调用该接口锁定60金币后,锁定的金币不能再被使用,玩家账户中还有40金币可用
- 扣除金币Confirm接口,确定扣除金币,这里将真正扣除玩家账户中被锁定的金币,类似于信用卡的确定预授权完成刷卡
- 扣除金币Cancel接口,取消扣除金币,被锁定的金币将返还到玩家的账户中,类似于信用卡的撤销预授权取消刷卡
SOA接口调用者如何使用这三个接口呢?
调用者先执行扣除金币Try接口,再去执行其他任务(比如添加道具),当其他任务执行成功,调用者执行扣除金币Confirm接口确认扣除金币,而当其他任务执行异常,调用者则执行扣除金币Cancel接口取消扣除金币。
即使我们使用了TCC事务,也无法完美的保证各个SOA服务之间的数据一致性。
但TCC事务为我们屏蔽了大多数异常导致的数据不一致,同时一般情况下,进行Confirm或Cancel操作时产生异常的概率极小极小,所以对于一些强一致性系统,我们还是会使用TCC事务来保证多个SOA服务之间的数据一致性。
TCC事务存在不小的性能问题
其实我们使用的基于后置提交的多数据库事务与TCC事务都属于强一致性事务,使用强一致性事务能保证事务的实时性,但却很难在高并发环境中保证性能。
最终一致性事务这几个字看起来很牛逼,但说白了就是异步数据补偿,即在核心流程我们只保证核心数据的实时数据一致性,对于非核心数据,我们通过异步程序来保证数据一致性。
异步数据补偿
目前主流触发异步数据补偿的方式有两种:
- 使用消息队列实时触发数据补偿,核心流程在保证核心数据的一致性后,使用消息队列的方式通知异步程序进行数据补偿,这种方式能近乎实时的使数据达到最终一致性,但如果消息队列或异步程序出现异常,数据一致性也将不能保证
- 使用定时任务周期性触发数据补偿,核心流程在保证核心数据的一致性后直接返回,由定时任务周期性触发数据补偿程序,这种方式虽不能像消息队列那样能近乎实时的使数据达到最终一致性,但数据补偿程序出现异常时,我们能比较容易在下个周期对数据进行修复,能最大限度的保证数据的一致性
上面两种异步数据补偿的方式各有利弊,消息队列方式实时性强,但在异常情况下一致性弱,而定时任务方式实时性弱,但在异常情况下一致性强。
注:消息系统还是很稳定的,一般选择第一种
其实最优的策略是同时使用消息队列与定时任务触发数据补偿。
正常情况下,我们使用消息队列近乎实时的异步触发数据补偿,而针对那些极少发生的异常,我们使用定时任务周期性的修补数据。
大师谈经验
对原理的取舍
形成模式,产品
在理解了原理的情况下,做出更多取舍,同业务贴合更好的解决业务诉求
容易理解的模型往往性能都不好,性能好的模型往往不容易理解
《分布式系统原理与范型》
理解了数据库原理,你才能知道我们在那里做了取舍
排它锁是最高级锁
其它锁都是读写锁的升级
单机事务——小结
原子性、一致性、隔离性(扩展MVCC/SNAPSHOT ISOLATION)、持久性
从单机事务到分布式事务
GTS(全局事务服务)柔性事务
事务就是保证代码向你写的一样,真实的执行,这就是事务的核心
所有的时间戳分配器都会面临单机的自增问题
分布式事务的主要难点
事务延迟变大问题
事务异常处理
日志记录
MVCC的顺序问题
分布式数据库方案
共享磁盘方案
锁延迟变大问题
采用远程内存直接访问RDMA (远程直接内存读写)(FC/IB) infin band
Oracle RAC(非常有代表性)
事务的异常处理
放弃分布式事务
RAC
Raid的方案,Shared Disk
优势:
兼容性好,SQL全兼容
能提升读性能
劣势:
写性能巨大下降,原因要等待多台Cache全写入,这是一个同步等待的过程
高可用切换时间长,就是不可用时切换到备机时间长
扩展性有限,4台基本是极限,否则写入时间没法要了
传递距离有限制。
从北京到深圳,光速需30ms
广播算法,面包法算法(弃用)
一般谈分布式DB,就会谈2PC,就会谈MVCC
标准的两阶段提交,只要可读就说明数据是一致的,因为两阶段提交是可以保证一致性
事务要解决的是四种情况下,操作的冲突问题
WR
WW
RR
RW
读写锁只能在RR的时候能够并行
MVCC只有在WW时,不能并行
MVCC有个单点问题
PG-XL/PG-XC方案:发号机
存储可以扩展
TrueTimeAPI
可以在多机房内做数据同步
在未来的一段时间内,因为表的误差很小,那么很长时间就不用对表。
使用原子钟+GPS来维持时间准确性
利用TrueTimeAPI来保证happen before,时间为2e
利用这个时间服务器,就可以当做全局SCN或Trx_id服务使用
代价:
每个事务的提交之间的延迟
山寨货NTP服务
机器与机器之间时间误差比较大(150ms)
无法保证happen-before
Spanner解决跨城数据高可用,自动容错,标准的2PC+MVCC