阴阳大论之事务
目录
锁
Mysql锁粒度
-
行级锁
- 行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁 和 排他锁。
- InnoDB行锁是通过给索引上的索引项加锁来实现的,因此只有通过索引条件检索数据,InnoDB才会使用行锁,否则使用表锁。
- 特点:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
-
表级锁
- 表级锁是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MYISAM与INNODB都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)。
- 特点:开销小,加锁快;不会出现死锁;锁定粒度大,发出锁冲突的概率最高,并发度最低。
-
页级锁
- 页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB支持页级锁
- 特点:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
InnoDB锁模式
-
共享锁(S)
- 允许一个事务去读一行,阻止其他事务获得相同的数据集的排他锁。
- SELECT ... LOCK IN SHARE MODE;
- 对查询结果集中每行都添加共享锁。
-
排他锁(X)
- 允许获得排他锁的事务更新数据,但是阻止其他事务获得相同数据集的共享锁和排他锁。
- SELECT ... FOR UPDATE;
- 对查询结果集中每行都添加排他锁,在事务操作中,任何对记录的更新与删除操作会自动加上排他锁
-
意向共享锁(IS)
表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁
- 意向排他锁(IX)
表示事务准备给数据行加入排他锁,说明事务在一个数据行加排他锁前必须先取得该表的IX锁。
- 记录锁(Record Locks)
锁定指定行的索引项
- 间隙锁(Gap Locks)
锁定某一个范围内(以(主键列、辅助索引列)为间隙点,两个间隙点之间的数据区域加锁)的索引,但不包括记录本身;防止幻读、防止间隙内有新数据插入、防止已存在的数据更新为间隙内的数据。
- 间隙锁定(Next-Key Locks)
锁定一个范围内的索引,并且锁定记录本身: Next-Key Locks = Record Locks + Gap Locks
- 死锁
TODO
锁模式 | 排他锁(X) | 意向排他锁(IX) | 共享锁(S) | 意向共享锁(IS) |
---|---|---|---|---|
排他锁(X) | N | N | N | N |
意向排他锁(IX) | N | Y | N | Y |
共享锁(S) | N | N | Y | Y |
意向共享锁(IS) | N | Y | Y | Y |
X和所有锁冲突;IX和IX,IS兼容;S和S,IS兼容;IS和S,IS,IX兼容
事务
名词
- 应用程序( AP )
- 事务管理器( TM )
- 资源管理器( RM )
- 通信资源管理器( CRM )
- Dirty page:脏页
一般业务运行过程中,当业务需要对某张的某行数据进行修改的时候,innodb会先将该数据从磁盘读取到缓存中去,然后在缓存中对这条数据进行修改,这样缓存中的数据就和磁盘的数据不一致了,这个时候缓存中的数据就称为dirty page,只有当脏页统一刷新到磁盘中才会是clean page
- 事务:一个事务是一个完整的工作单元,由多个独立的计算任务组成,这多个任务在逻辑上是原子的
- 全局事务:对于一次性操作多个资源管理器的事务,就是全局事务
- 分支事务:在全局事务中,某一个资源管理器有自己独立的任务,这些任务的集合作为这个资源管理器的分支任务
- 控制线程:用来表示一个工作线程,主要是关联AP,TM,RM三者的一个线程,也就是事务上下文环境。简单的说,就是需要标识一个全局事务以及分支事务的关系
特性ACID
- 原子性(Atomicity)
事务执行的最小单元;一个事务内的CRUD操作要么全部执行完成,要么全部不执行。MySQL是通过redo log实现原子性的。
- 一致性(Consistency)
数据状态总是要保持一致性,从一个一致性状态转为另一个一致性状态。MySQL是通过undo log实现一致性的。
- 隔离性(Isolation)
事务之间相互隔离。MySQL是通过锁来实现隔离性的。
- 持久性(Durability)
指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。MySQL是通过redo log 实现的持久性。
事务的执行过
- 系统会为每个事务开辟一个私有工作区
- 事务读操作将从磁盘中拷贝数据项到工作区中,在执行写操作前所有的更新都作用于工作区中的拷贝.
- 事务的写操作将把数据输出到内存的缓冲区中,等到合适的时间再由缓冲区管理器将数据写入到磁盘。
存在的问题
数据库存在立即修改和延迟修改,所以在事务执行过程中可能存在以下情况
- 在事务提交前出现故障,但是事务对数据库的部分修改已经写入磁盘数据库中。这导致了事务的原子性被破坏。
- 在系统崩溃前事务已经提交,但数据还在内存缓冲区中,没有写入磁盘。系统恢复时将丢失此次已提交的修改。这是对事务持久性的破坏。
事务并发问题
问题 | 描述 |
---|---|
更新丢失(Lost update) | 两个事务都同时更新一行数据但是第二个事务却中途失败退出(回滚)导致对数据两个修改都失效了。 |
脏读取(Dirty Reads) | 一个事务开始读取了某行数据但是另外一个事务已经更新了此数据但没有能够及时提交。可能所有操作都被回滚。 |
不可重复读取(Non-repeatable Reads) | 一个事务对同一行数据重复读取两次但是却得到了不同结果。是因为另一个事务在T1两次查询之间更新了数据,导致T1的第二次查询不一致。 |
两次更新问题(Second lost updates problem) | 有两个并发事务同时读取同一行数据,然后其中一个对它进行修改提交,而另一个也进行了修改提交这就会造成第一次写操作失效。不可重复读取特例。 |
幻读(Phantom Reads) | 事务在操作过程中进行两次查询,第二次查询结果包含了第一次查询中未出现的数据。产生幻读的原因是事务一在进行范围查询的时候没有增加范围锁。对应的是insert操作。 |
- 更新丢失 + 脏读
时间 | 事务A | 事务B | 时间 | 事务A | 事务B |
---|---|---|---|---|---|
T1 | 开始事务 | T1 | 开始事务 | ||
T2 | 开始事务 | T2 | 开始事务 | ||
T3 | 查询余额500 | T3 | 查询余额500 | ||
T4 | 查询余额500 | T4 | 增加100,余额600 | ||
T5 | 增加100,余额600 | T5 | 查询余额600(脏读) | ||
T6 | 增加100,余额600 | T6 | 回滚事务,余额500 | ||
T7 | 提交事务 | T7 | 增加100,余额700 | ||
T8 | 回滚事务,余额500(更新丢失) | T8 | 提交事务 |
- 不可重复读取 + 两次更新问题
时间 | 事务A | 事务B | 时间 | 事务A | 事务B |
---|---|---|---|---|---|
T1 | 开始事务 | T1 | 开始事务 | ||
T2 | 开始事务 | T2 | 开始事务 | ||
T3 | 查询余额500 | T3 | 查询余额500 | ||
T4 | 查询余额500 | T4 | 查询余额500 | ||
T5 | 增加100,余额600 | T5 | 增加200,余额600 | ||
T6 | 提交事务 | T6 | 提交事务 | ||
T7 | 查询余额600(不可重复读取) | T7 | 减少100,余额400 | ||
T8 | 提交事务 | T8 | 提交事务(两次更新问题) |
- 幻读
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | |
T2 | 开始事务 | |
T3 | 统计出100条 | |
T4 | 插入一条记录 | |
T5 | 提交事务 | |
T6 | 统计出101条(幻读) | |
T7 | 提交事务 | |
T8 |
事务隔离级别
隔离级别 | 描述 | 丢失更新 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|---|
未提交读Read Uncommitted | 一个事务可以读到另外一个事务未提交的数据 | 是 | 是 | 是 | 是 |
已提交读Read Committed | 一个事务修改数据过程中,如果事务还没提交,其他事务不能读该数据 | 是 | 否 | 是 | 是 |
可重复读Repeatable Read | 在一个事务内两次读取同一条记录结果却不一致。 | 是 | 否 | 否 | 是 |
可串行化Serializable | 事务间顺序执行 | 是 | 否 | 否 | 否 |
事务隔离级别原理 参考
隔离级别 | 实现原理 | 描述 |
---|---|---|
未提交读 | 事务在读数据时候并未对数据加锁;事务在更新数据时,对其加 行级排它锁,写完释放 | T1读r时,T2可读r也可写r(S); T1写r时(S),T2可读r不可写r(S)。不能同时更新 |
已提交读 | 事务在读取数据时,对其加 行级共享锁,读完释放;事务在更新数据时,对其加 行级排他锁,事务结束时释放 | T1读r(S),T2可读r(S)不可写r(X),T1读完释放后可写(X); T1写r时(X),T2不可读r(S)不可写r(X) |
可重复读 | 事务在读取数据时,对其加 行级共享锁,事务结束时释放;事务在更新数据时,对其加 行级排他锁,事务结束时释放 | T1读r时(S),T2可读r(S)不可写r(X);T1写r时(X),T2不可读r(S)不可写r(X) |
可串行化 | 事务在读取数据时,对其加 表级共享锁,事务结束时释放;事务在更新数据时,对其加 表级排他锁,事务结束时释放 | T1读r时(S),T2可读r(S)不可写r(X);T1写r时(X),T2不可读r(S)不可写r(X) |
隔离级别 | 操作 | 锁 | 生命周期 | 操作 | 锁 | 生命周期 |
---|---|---|---|---|---|---|
未提交读 | 读 | 写 | 行级排它锁 | 立即释放 | ||
已提交读 | 读 | 行级共享锁 | 立即释放 | 写 | 行级排它锁 | 事务结束 |
可重复读 | 读 | 行级共享锁 | 事务结束 | 写 | 行级排它锁 | 事务结束 |
可串行化 | 读 | 表级共享锁 | 事务结束 | 写 | 表级排他锁 | 事务结束 |
MySQL日志 参考
Undo log(回滚日志)
- 内容
逻辑日志,记录了对MySQL数据库执行更改的所有操作。保存了事务发生之前的数据的一个版本。
- 作用
提供回滚和多个行版本控制(MVCC)。确保事务的一致性。在执行undo的时候,仅仅是将数据从逻辑上恢复至事务之前的状态,而不是从物理页面上操作实现的,这一点是不同于redo log的。
- 什么时候产生
事务开始之前,将当前是的版本生成undo log,undo 也会产生 redo 来保证undo log的可靠性
- 什么时候释放
当事务提交之后,undo log并不能立马被删除,
而是放入待清理的链表,由purge线程判断是否由其他事务在使用undo段中表的上一个事务之前的版本信息,决定是否可以清理undo log的日志空间。
Redo log(重做日志)
- 内容
物理日志,记录的是数据页的物理修改后的值,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。
- 作用
确保事务的持久性+原子性。防止在发生故障的时间点的时候,尚有脏页未写入磁盘。在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性。
- 什么时候产生
在事务开始时就产生Redo log,日志会先被写入内存中的日志缓冲区(redo log buffer);在满足某条件时,日志被写入磁盘上的重做日志文件(redo log file)。
- 什么时候释放
当对应事务的脏页写入到磁盘之后,redo log的使命也就完成了,重做日志占用的空间就可以重用(被覆盖)。
- 什么时候刷新到磁盘
- Master Thread 每秒一次执行刷新Innodb_log_buffer到重做日志文件。
- 每个事务提交时会将重做日志刷新到重做日志文件。
- 当重做日志缓存可用空间 少于一半时,重做日志缓存被刷新到重做日志文件
Binlog(二进制日志)
- 内容
逻辑格日志,可以简单认为就是执行过的事务中的sql语句。但又不完全是sql语句这么简单,而是包括了执行的sql语句(增删改)反向的信息,
也就意味着delete对应着delete本身和其反向的insert;update对应着update执行前后的版本的信息;insert对应着delete和insert本身的信息。
-
作用
- 用于复制,在主从复制中,从库利用主库上的binlog进行重播,实现主从同步。
- 用于数据库的基于时间点的还原。
-
什么时候产生:
事务提交的时候,一次性将事务中的sql语句(一个事物可能对应多个sql语句)按照一定的格式记录到binlog中。这里与redo log很明显的差异就是redo log并不一定是在事务提交的时候刷新到磁盘,redo log是在事务开始之后就开始逐步写入磁盘。
因此对于事务的提交,即便是较大的事务,提交(commit)都是很快的,但是在开启了bin_log的情况下,对于较大事务的提交,可能会变得比较慢一些。这是因为binlog是在事务提交的时候一次性写入的造成的,这些可以通过测试验证。
- 什么时候释放:
binlog的默认是保持时间由参数expire_logs_days配置,也就是说对于非活动的日志文件,在生成时间超过expire_logs_days配置的天数之后,会被自动删除。
- 对应的物理文件:
配置文件的路径为log_bin_basename,binlog日志文件按照指定大小,当日志文件达到指定的最大的大小之后,进行滚动更新,生成新的日志文件。
对于每个binlog日志文件,通过一个统一的index文件来组织。
UndoLog、RedoLog和binLog的差异
- 作用不同:redo log是保证事务的持久性的,是事务层面的;binlog作为还原的功能,是数据库层面的;undo log是保证事务一致性的。
- 内容不同:redo log是物理日志,是数据页面的修改之后的物理记录,binlog是逻辑日志,可以简单认为记录的就是sql语句
- 两者日志产生的时间,可以释放的时间,在可释放的情况下清理机制,都是完全不同的。binlog是在事务提交之后,只写入磁盘一次;redolog是在事务进行中,不断地写入日志文件(buffer)
- 恢复数据时候的效率,基于物理日志的redo log恢复数据的效率要高于语句逻辑日志的binlog
- MySQL通过两阶段提交过程来完成事务的一致性的,也即redo log和binlog的一致性的,理论上是先写redo log,再写binlog,两个日志都提交成功(刷入磁盘),事务才算真正的完成。
Log持久化过程
一般所说的log file并不是磁盘上的物理日志文件,而是操作系统缓存中的log file,官方手册上的意思也是如此。所以在本文后续内容中都以os buffer或者file system buffer来表示官方手册中所说的Log file,然后log file则表示磁盘上的物理日志文件,即log file on disk。另外,之所以要经过一层os buffer,是因为open日志文件的时候,open没有使用O_DIRECT标志位,该标志位意味着绕过操作系统层的os buffer,IO直写到底层存储设备。不使用该标志位意味着将日志进行缓冲,缓冲到了一定容量,或者显式fsync()才会将缓冲中的刷到存储设备。使用该标志位意味着每次都要发起系统调用。比如写abcde,不使用o_direct将只发起一次系统调用,使用o_object将发起5次系统调用。
MySQL支持用户自定义在commit时如何将log buffer中的日志刷log file中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有3种值:0、1、2,默认为1。但注意,这个变量只是控制commit动作是否刷新log buffer到磁盘。
- 当设置为1的时候,事务每次提交都会将log buffer中的日志写入os buffer并调用fsync()刷到log file on disk中。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO的性能较差。
- 当设置为0的时候,事务提交时不会将log buffer中日志写入到os buffer,而是每秒写入os buffer并调用fsync()写入到log file on disk中。也就是说设置为0时是(大约)每秒刷新写入到磁盘中的,当系统崩溃,会丢失1秒钟的数据。
- 当设置为2的时候,每次提交都仅写入到os buffer,然后是每秒调用fsync()将os buffer中的日志写入到log file on disk。
事务实现原理
事务其实就是并发控制的基本单位;相信我们都知道,事务是一个序列操作,其中的操作要么都执行,要么都不执行,它是一个不可分割的工作单位;数据库事务的 ACID 四大特性是事务的基础,了解了 ACID 是如何实现的,我们也就清除了事务的实现,接下来我们将依次介绍数据库是如何实现这四个特性的。
原子性(Atomicity)
原子性的定义就是一个事务内的一系列操作,要么全部成功,要么全部不成功。所以保证全部成功的日志文件就是redo log,它会使所有操作记录持久化到磁盘;如果又一部分操作失败了,则需要将全部操作回滚,能完成这个功能的就是undo log,undo log会记录操作前的版本,可以将此次操作回滚到之前的版本状态。
一致性(Consistency)
- 一致性的定义就是数据从一个一致性状态转为另一个一致性状态,似乎可以理解为原子性操作的一个结果。从不会产生一个中间态。保证状态一致的日志文件是redo log。
- 对数据一致性的破坏主要来自两个方面
- 事务的并发执行
- 事务故障或系统故障
- 数据库系统是通过并发控制技术和日志恢复技术来避免这种情况发生的
- 并发控制技术保证了事务的隔离性,使数据库的一致性状态不会因为并发执行的操作被破坏。
- 日志恢复技术保证了事务的原子性,使一致性状态不会因事务或系统故障被破坏。同时使已提交的对数据库的修改不会因系统崩溃而丢失,保证了事务的持久性。
持久性(Durability)
持久性的实现也是通过重做日志(redo log)。重做日志在生成的时候分两部分,一部分会先写入内存中的日志缓冲区,再满足某条件时,将缓冲区的日志持久化到磁盘。
隔离性(Isolation)
数据库为了保证事务之间的隔离性,提出了四个隔离级别,数据库对于隔离级别的实现就是使用并发控制机制对在同一时间执行的事务进行控制,限制不同的事务对于同一资源的访问和更新,而最重要也最常见的并发控制机制,在这里我们将简单介绍三种最重要的并发控制器机制的工作原理。
-
锁
- 锁是一种最为常见的并发控制机制,在一个事务中,我们并不会将整个数据库都加锁,而是只会锁住那些需要访问的数据项, MySQL 和常见数据库中的锁都分为两种,共享锁(Shared)和互斥锁(Exclusive),前者也叫读锁,后者叫写锁。
- 读锁保证了读操作可以并发执行,相互不会影响,而写锁保证了在更新数据库数据时不会有其他的事务访问或者更改同一条记录造成不可预知的问题。
-
时间戳
- 类似版本控制,客观锁
- PostgreSQL 会为每一条记录保留两个字段;读时间戳中保存了所有访问该记录的事务中的最大时间戳,而记录行的写时间戳中保存了将记录改到当前值的事务的时间戳。
-
多版本和快照隔离
- MVCC-通过维护多个版本的数据,数据库可以允许事务在数据被其他事务更新时对旧版本的数据进行读取,很多数据库都对这一机制进行了实现;因为所有的读操作不再需要等待写锁的释放,所以能够显著地提升读的性能。
基于锁的并发控制 参考
- 核心思想:对于并发可能冲突的操作,比如读-写,写-读,写-写,通过锁使它们互斥执行。
- 共享锁(S):事务T对数据A加共享锁,其他事务只能对A加共享锁但不能加排他锁。
- 排他锁(X):事务T对数据A加排他锁,其他事务对A既不能加共享锁也不能加排他锁
- 并发控制流程
- 事务根据自己对数据项进行的操作类型申请相应的锁(读申请共享锁,写申请排他锁)
- 申请锁的请求被发送给锁管理器。锁管理器根据当前数据项是否已经有锁以及申请的和持有的锁是否冲突决定是否为该请求授予锁。
- 若锁被授予,则申请锁的事务可以继续执行;若被拒绝,则申请锁的事务将进行等待,直到锁被其他事务释放。
- 可能出现的问题
- 死锁:多个事务持有锁并互相循环等待其他事务的锁导致所有事务都无法继续执行。
- 饥饿:数据项A一直被加共享锁,导致事务一直无法获取A的排他锁
基于时间戳的并发控制
- 核心思想:对于并发可能冲突的操作,基于时间戳排序规则选定某事务继续执行,其他事务回滚
- 控制流程:系统会在每个事务开始时赋予其一个时间戳,这个时间戳可以是系统时钟也可以是一个不断累加的计数器值,当事务回滚时会为其赋予一个新的时间戳,先开始的事务时间戳小于后开始事务的时间戳。
- 关键词
- W-timestamp(Q):成功执行write(Q)的所有事务的最大时间戳
- R-timestamp(Q):成功执行read(Q)的所有事务的最大时间戳
- 时间戳排序规则
- 假设事务T发出read(Q),T的时间戳为TS 若TS(T)<W-timestamp(Q),则T需要读入的Q已被覆盖。此read操作将被拒绝,T回滚。
- 假设事务T发出read(Q),T的时间戳为TS 若TS(T)>=W-timestamp(Q),则执行read操作,同时把R-timestamp(Q)设置为TS(T)与R-timestamp(Q)中的最大值。
- 假设事务T发出write(Q),a.若TS(T)<R-timestamp(Q),则write操作被拒绝,T回滚。
- 假设事务T发出write(Q),b.若TS(T)<W-timestamp(Q),则write操作被拒绝,T回滚。
- 假设事务T发出write(Q),c.其他情况:系统执行write操作,将W-timestamp(Q)设置为TS(T)。
- 基于时间戳排序和基于锁实现的异同点
- 对于可能冲突的并发操作,以串行的方式取代并发执行(相同),因而它也是一种悲观并发控制
- 基于锁是让冲突的事务进行等待,而基于时间戳排序是让冲突的事务回滚。
- 基于锁冲突事务的执行次序是根据它们申请锁的顺序,先申请的先执行;而基于时间戳排序是根据特定的时间戳排序规则。
基于有效性检查的并发控制
- 核心思想:事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。
- 执行过程
- 读阶段:数据项被读入并保存在事务的局部变量中。所有write操作都是对局部变量进行,并不对数据库进行真正的更新。
- 有效性检查阶段:对事务进行有效性检查,判断是否可以执行write操作而不违反可串行性。如果失败,则回滚该事务。
- 写阶段:事务已通过有效性检查,则将临时变量中的结果更新到数据库中
- 基于有效性检查和基于时间戳排序的异同点
- 有效性检查通常也是通过对事务的时间戳进行比较完成的,不过和基于时间戳排序的规则不一样。
- 允许可能冲突的操作并发执行,因为每个事务操作的都是自己工作空间的局部变量,直到有效性检查阶段发现了冲突才回滚。因而这是一种乐观的并发策略。
基于快照隔离的并发控制
快照隔离是多版本并发控制(mvcc)的一种实现方式。
- 核心思想是:数据库为每个数据项维护多个版本(快照),每个事务只对属于自己的私有快照进行更新,在事务真正提交前进行有效性检查,使得事务正常提交更新或者失败回滚。
由于快照隔离导致事务看不到其他事务对数据项的更新,为了避免出现丢失更新问题,可以采用以下两种方案避免:
- 先提交者获胜:对于执行该检查的事务T,判断是否有其他事务已经将更新写入数据库,是则T回滚否则T正常提交。
- 先更新者获胜:通过锁机制保证第一个获得锁的事务提交其更新,之后试图更新的事务中止。
事务间可能冲突的操作通过数据项的不同版本的快照相互隔离,到真正要写入数据库时才进行冲突检测。因而这也是一种乐观并发控制。
本地事务
- 开启事务
- 操作
- 提交或回滚事务
全局事务
全局事务类似于在本地事务基础上做了一层嵌套,实际上多次事务提交和回滚对性能影响较大,占用的资源也比较多。而且全局事务遇到mysql_connection.commit后宕机,尽管oracle_connection提交失败需要回滚,此时已经无法回滚mysql_connection,存在数据不一致的风险。
use order_db;
## 分别开启事务
mysql_connection.begin;
oracle_connection.begin;
## 分别执行操作
update mysql_orders set order_money = order_money + 0.01 where id = 171517987004616228;
update oracle_orders set order_money = order_money + 0.01 where id = 171517987004616228;
## 分别提交,如果有一个失败,则分别进行rollback操作
mysql_connection.commit;
oracle_connection.commit;
全局事务DTP模型
分布式事务
X/Open XA 协议
XA的ACID特性
- 原子性:XA议使用2PC原子提交协议来保证分布式事务原子性
- 隔离性:XA要求每个RMs实现本地的事务隔离,子事务的隔离来保证整个事务的隔离。
- 一致性:通过原子性、隔离性以及自身一致性的实现来保证“数据库从一个一致状态转变为另一个一致状态”;通过MVCC来保证中间状态不能被观察到。
XA的优缺点
- 优点: 对业务无侵入,对RM要求高
- 缺点:
- 同步阻塞:在二阶段提交的过程中,所有的节点都在等待其他节点的响应,无法进行其他操作。这种同步阻塞极大的限制了分布式系统的性能。
- 单点问题:协调者在整个二阶段提交过程中很重要,如果协调者在提交阶段出现问题,那么整个流程将无法运转。更重要的是,其他参与者将会处于一直锁定事务资源的状态中,而无法继续完成事务操作。
- 数据不一致:假设当协调者向所有的参与者发送commit请求之后,发生了局部网络异常,或者是协调者在尚未发送完所有 commit请求之前自身发生了崩溃,导致最终只有部分参与者收到了commit请求。这将导致严重的数据不一致问题。
- 容错性不好:如果在二阶段提交的提交询问阶段中,参与者出现故障,导致协调者始终无法获取到所有参与者的确认信息,这时协调者只能依靠其自身的超时机制,判断是否需要中断事务。显然,二阶段提交协议没有设计较为完善的容错机制,任意一个节点是失败都会导致整个事务的失败。
TCC/Try Confirm Cancel模式
TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
执行流程
- 第一阶段:CanCommit
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。- 事务询问:协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应
- 响应反馈:参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态;否则反馈No。
- 第二阶段:PreCommit
协调者在得到所有参与者的响应之后,会根据结果执行2种操作:执行事务预提交,或者中断事务- 执行事务预提交
- 发送预提交请求:协调者向所有参与者节点发出 preCommit 的请求,并进入 prepared 状态。
- 事务预提交:参与者受到 preCommit 请求后,会执行事务操作,对应 2PC 准备阶段中的 “执行事务”,也会 Undo 和 Redo 信息记录到事务日志中。
- 各参与者响应反馈:如果参与者成功执行了事务,就反馈 ACK 响应,同时等待指令:提交(commit) 或终止(abort)
- 中断事务
- 发送中断请求:协调者向所有参与者节点发出 abort 请求 。
- 中断事务:参与者如果收到 abort 请求或者超时了,都会中断事务。
- 执行事务预提交
- 第三阶段:Do Commit
该阶段进行真正的事务提交,也可以分为以下两种情况
+ 执行提交
- 发送提交请求:协调者接收到各参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送 doCommit 请求。
- 事务提交:参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
- 响应反馈:事务提交完之后,向协调者发送 ACK 响应。
- 完成事务:协调者接收到所有参与者的 ACK 响应之后,完成事务。
+ 中断事务:协调者没有接收到参与者发送的 ACK 响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
- 发送中断请求:协调者向所有参与者发送 abort 请求。
- 事务回滚:参与者接收到 abort 请求之后,利用其在阶段二记录的 undo 信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
- 反馈结果:参与者完成事务回滚之后,向协调者发送 ACK 消息。
- 中断事务:协调者接收到参与者反馈的 ACK 消息之后,完成事务的中断。
TCC场景
TCC方案其实是两阶段提交的一种改进。其将整个业务逻辑的每个分支显式的分成了Try、Confirm、Cancel三个操作。
- Try部分完成业务的准备工作,
- confirm部分完成业务的提交,
- cancel部分完成事务的回滚。
事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的try接口,完成一阶段准备。之后事务协调器会根据try接口返回情况,决定调用confirm接口或者cancel接口。如果接口调用失败,会进行重试。
- 对应用的侵入性强。
业务逻辑的每个分支都需要实现try、confirm、cancel三个操作,应用侵入性较强,改造成本高。
- 实现难度较大。
需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口必须实现幂等。
TCC的ACID特性
- 原子性:TCC 模型也使用 2PC 原子提交协议来保证事务原子性。
- Try 操作对应2PC 的一阶段准备(Prepare);
- Confirm 对应 2PC 的二阶段提交(Commit),
- Cancel 对应 2PC 的二阶段回滚(Rollback),可以说 TCC 就是应用层的 2PC。 - 隔离性:隔离的本质是控制并发,放弃在数据库层面加锁通过在业务层面加锁来实现。【比如在账户管理模块设计中,增加可用余额和冻结金额的设置】
- 一致性:通过原子性保证事务的原子提交、业务隔离性控制事务的并发访问,实现分布式事务的一致性状态转变;事务的中间状态不能被观察到这点并不保证[本协议是基于柔性事务理论提出的]。
TCC的优缺点
- 优点:
相对于二阶段提交,三阶段提交主要解决的单点故障问题,并减少了阻塞的时间。因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行 commit。而不会一直持有事务资源并处于阻塞状态。 - 缺点:
三阶段提交也会导致数据一致性问题。由于网络原因,协调者发送的 Cancel 响应没有及时被参与者接收到,那么参与者在等待超时之后执行了 commit 操作。这样就和其他接到 Cancel 命令并执行回滚的参与者之间存在数据不一致的情况。
基于XA协议的两阶段提交方案
二阶段提交(Two-phaseCommit)是指,在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常,二阶段提交也被称为是一种协议(Protocol))。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
准备阶段
事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种“万事俱备,只欠东风”的状态。
- 1)协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。
- 2)参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)
- 3)各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。
提交阶段
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)
- 当协调者节点从所有参与者节点获得的相应消息都为”同意”时:
- 1)协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
- 2)参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
- 3)参与者节点向协调者节点发送”完成”消息。
- 4)协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。
- 如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
- 1)协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
- 2)参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
- 3)参与者节点向协调者节点发送”回滚完成”消息。
- 4)协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。
2PC的缺点
- 1、同步阻塞问题。
执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
- 2、单点故障。
由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
- 3、数据不一致。
在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
- 4、二阶段无法解决的问题
协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
基于XA协议的三阶段提交方案
与两阶段提交不同的是,三阶段提交有两个改动点。
- 1、引入超时机制。同时在协调者和参与者中都引入超时机制。
- 2、在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
CanCommit阶段
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
- 1.事务询问
协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
- 2.响应反馈
参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
PreCommit阶段
协调者根据参与者的反应情况来决定是否可以记性事务的PreCommit操作。根据响应情况,有以下两种可能。
-
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
- 1.发送预提交请求 :协调者向参与者发送PreCommit请求,并进入Prepared阶段。
- 2.事务预提交 :参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
- 3.响应反馈 :如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
-
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
- 1.发送中断请求 :协调者向所有参与者发送abort请求。
- 2.中断事务 :参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
doCommit阶段
-
执行提交
- 1.发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
- 2.事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
- 3.响应反馈 事务提交完之后,向协调者发送Ack响应。
- 4.完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
-
中断事务 协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
- 1.发送中断请求 协调者向所有参与者发送abort请求
- 2.事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
- 3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
- 4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。 )
2PC与3PC的区别
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
基于消息的最终一致性方案
消息一致性方案是通过消息中间件保证上、下游应用数据操作的一致性。基本思路是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。
消息方案从本质上讲是将分布式事务转换为两个本地事务,然后依靠下游业务的重试机制达到最终一致性。基于消息的最终一致性方案对应用侵入性也很高,应用需要进行大量业务改造,成本较高。
可靠事件通知模式-参考
- 同步事件
可靠事件通知模式的设计理念比较容易理解,即是主服务完成后将结果通过事件(常常是消息队列)传递给从服务,从服务在接受到消息后进行消费,完成业务,从而达到主服务与从服务间的消息一致性。首先能想到的也是最简单的就是同步事件通知,业务处理与消息发送同步执行,实现逻辑见下方代码及时序图。
public void trans() {
try {
// 1. 操作数据库
bool result = dao.update(data);// 操作数据库失败,会抛出异常
// 2. 如果数据库操作成功则发送消息
if(result){
mq.send(data);// 如果方法执行失败,会抛出异常
}
} catch (Exception e) {
roolback();// 如果发生异常,就回滚
}
}
上面的逻辑看上去天衣无缝,如果数据库操作失败则直接退出,不发送消息;如果发送消息失败,则数据库回滚;如果数据库操作成功且消息发送成功,则业务成功,消息发送给下游消费。然后仔细思考后,同步消息通知其实有两点不足的地方。
-
异步事件
- 本地事件服务
- 外部事件服务
- 可靠事件通知模式的注意事项
-
最大努力通知模式
相比可靠事件通知模式,最大努力通知模式就容易理解多了。最大努力通知型的特点是,业务服务在提交事务后,进行有限次数(设置最大次数限制)的消息发送,比如发送三次消息,若三次消息发送都失败,则不予继续发送。所以有可能导致消息的丢失。同时,主业务方需要提供查询接口给从业务服务,用来恢复丢失消息。最大努力通知型对于时效性保证比较差(既可能会出现较长时间的软状态),所以对于数据一致性的时效性要求比较高的系统无法使用。这种模式通常使用在不同业务平台服务或者对于第三方业务服务的通知,如银行通知、商户通知等,这里不再展开。
- 业务补偿模式
接下来介绍两种补偿模式,补偿模式比起事件通知模式最大的不同是,补偿模式的上游服务依赖于下游服务的运行结果,而事件通知模式上游服务不依赖于下游服务的运行结果。首先介绍业务补偿模式,业务补偿模式是一种纯补偿模式,其设计理念为,业务在调用的时候正常提交,当一个服务失败的时候,所有其依赖的上游服务都进行业务补偿操作。举个例子,小明从杭州出发,去往美国纽约出差,现在他需要定从杭州去往上海的火车票,以及从上海飞往纽约的飞机票。如果小明成功购买了火车票之后发现那天的飞机票已经售空了,那么与其在上海再多待一天,小明还不如取消去上海的火车票,选择飞往北京再转机纽约,所以小明就取消了去上海的火车票。这个例子中购买杭州到上海的火车票是服务a,购买上海到纽约的飞机票是服务b,业务补偿模式就是在服务b失败的时候,对服务a进行补偿操作,在例子中就是取消杭州到上海的火车票。
补偿模式要求每个服务都提供补偿借口,且这种补偿一般来说是不完全补偿,既即使进行了补偿操作,那条取消的火车票记录还是一直存在数据库中可以被追踪(一般是有相信的状态字段“已取消”作为标记),毕竟已经提交的线上数据一般是不能进行物理删除的。
业务补偿模式最大的缺点是软状态的时间比较长,既数据一致性的时效性很低,多个服务常常可能处于数据不一致的情况。