【事务笔记】事务的相关整理
文前说明
作为码农中的一员,需要不断的学习,我工作之余将一些分析总结和学习笔记写成博客与大家一起交流,也希望采用这种方式记录自己的学习之旅。
本文仅供学习交流使用,侵权必删。
不用于商业目的,转载请注明出处。
1. 事务的定义
- 事务表示一个由一系列的数据库操作组成的不可分割的逻辑单位,其中的操作要么全做要么全都不做。
- 事务是访问数据库的一个操作序列,应用系统通过事务集来完成对数据库的存取。
- 事务的正确执行使得数据库从一种状态转换为另一种状态。
1.1 事务的特性(ACID 属性)
-
原子性(Atomicity)同一个事务的操作要么全部成功执行,要么全部撤消。
- 与某个事务关联的操作具有共同的目标,并且是相互依赖的。
-
一致性(Consistency)在操作过程中不会破坏数据的完整性。
- 事务结束时,所有的内部数据结构(如 B 树索引或双向链表)都必须是正确的。
-
隔离性(Isolation)由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。
- 当事务可序列化时将获得最高的隔离级别。
- 在此级别上,从一组可并行执行的事务获得的结果与通过连续运行每个事务所获得的结果相同。
- 由于高度隔离会限制可并行执行的事务数,所以一些应用程序降低隔离级别以换取更大的吞吐量。
- 持久性(Durability)事务的结果必须持久保存于介质上,接下来的其他操作和故障不应该对其有任何影响。
1.2 事务的隔离
1.2.1 并发事务不进行隔离会产生的问题
-
脏读(Dirty Read)
- 脏读发生在一个事务 A 读取了被另一个事务 B 修改,但是还未提交的数据。
- 假如 B 回退,则事务 A 读取的是无效的数据。
- 脏读发生在一个事务 A 读取了被另一个事务 B 修改,但是还未提交的数据。
时间 | 事务 A(转账) | 事务 B(取款) |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 查询账户余额 1000 | |
4 | 取款 500,账户余额 500 | |
5 | 查询账户余额 500(脏读) | |
6 | 撤销事务,账户余额恢复为 1000 | |
7 | 汇入 100,账户余额 600 | |
8 | 提交事务 |
-
不可重复读(Not Repeatable Read)
- 指 A 事务读取了 B 事务已经提交的更改数据。
- 在基于锁的并行控制方法中,如果在执行 select 时不添加读锁,就会发生不可重复读问题。
- 在多版本并行控制机制中,当一个遇到提交冲突的事务需要回退但却被释放时,会发生不可重复读问题。
- 指 A 事务读取了 B 事务已经提交的更改数据。
- 事务 B 提交成功,所做的修改已经可见。然而,事务 A 已经读取了一个其它的值。
- 在序列化和可重复读的隔离级别中,数据库管理系统会返回旧值,即在被事务 B 修改之前的值。
- 在提交读和未提交读隔离级别下,可能会返回被更新的值,这就是 "不可重复读"。
- 防止不可重复读问题发生的策略
- 推迟事务 B 的执行,直至事务 A 提交或者回退。这种策略在使用锁时应用。
- 在多版本并行控制中,事务 B 可以被先提交。而事务 A,继续执行在旧版本的数据上。当事务 A 终于尝试提交时,数据库会检验它的结果是否和事务 A、事务 B 顺序执行时一样。如果是则事务 A 提交成功,如果不是事务 A 被回退。
时间 | 事务 A(取款) | 事务 B(转账) |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 查询账户余额 1000 | |
4 | 汇入 100,账户余额 1100 | |
5 | 提交事务 | |
6 | 查询账户余额 1100(不可重复读) |
-
幻读/虚读(Phantom Read)
- A 事务读取了 B 事务提交的新增数据。幻读一般发生在计算统计数据的事务中。
- 幻读发生在当两个完全相同的查询执行时,第二次查询所返回的结果集跟第一个查询不相同。
- 发生的情况是没有范围锁。
时间 | 事务 A(统计) | 事务 B(创建) |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 统计 A 账户总存款 10000 | |
4 | 创建 B 账户,存款 100 | |
5 | 提交事务 | |
6 | 统计 A 账户总存款 10100(幻读) |
- 脏读与不可重复读类似,但是第二个事务不需要执行提交。
- 不可重复读和幻读的区别在于前者是读到了已经提交的事务的(修改/删除)数据,后者是读到了其他已经提交事务的(新增)数据。
-
第一类丢失更新(Update Lost)
- A 事务撤销时,把已经提交的 B 事务的更新数据覆盖。
时间 | 事务 A(取款) | 事务 B(转账) |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 查询账户余额 1000 | |
4 | 查询账户余额 1000 | |
5 | 汇入 100,账户余额 1100 | |
6 | 提交事务 | |
7 | 取出 100,账户余额 900 | |
8 | 撤销事务,账户余额 1000 |
-
第二类丢失更新(Second Update Lost)
- A 事务覆盖 B 事务已经提交的数据,造成 B 事务所做操作的丢失。
时间 | 事务 A(转账) | 事务 B(取款) |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 查询账户余额 1000 | |
4 | 查询账户余额 1000 | |
5 | 取款 100,账户余额 900 | |
6 | 提交事务 | |
7 | 汇入 100,账户余额 1100 | |
8 | 提交事务 |
1.2.2 锁机制
- 数据库通过锁机制来解决上述并发问题。
1.2.2.1 数据库锁分类
-
悲观锁
- 对于数据被外界修改持保守态度,认为数据随时会修改,整个数据处理中需要将数据加锁。
- 悲观锁一般都是依靠关系数据库提供的锁机制,关系数据库中的行锁,表锁不论是读写锁都是悲观锁。
-
乐观锁
- 操作数据的时候认为没有人会来修改它,所以不去加锁,但是在更新的时候会去判断在此期间数据有没有被修改,需要用户自己去实现。
- 对于读操作远多于写操作的时候,大多数都是读取,这时候一个更新操作加锁会阻塞所有读取,降低了吞吐量。乐观锁是为了解决极少量的更新操作的同步问题而使用的。
- 像数据库如果提供类似于 write_condition 机制也是提供的乐观锁。
- 乐观锁不能解决(脏读)的问题。
1.2.2.2 悲观锁按使用性质划分
-
共享锁(Share Lock)
- S 锁,也叫读锁,用于所有的只读数据操作。共享锁是非独占的,允许多个并发事务读取其锁定的资源。
- 多个事务可封锁同一个共享页。
- 任何事务都不能修改该页。
- 通常是该页被读取完毕,S 锁立即被释放。
- S 锁,也叫读锁,用于所有的只读数据操作。共享锁是非独占的,允许多个并发事务读取其锁定的资源。
例 1
select * from table
- 首先锁定第一页,读取之后,释放对第一页的锁定,然后锁定第二页,依次进行。允许在读操作过程中,修改未被锁定的第一页。
例 2(表级锁)
select * from table(holdlock)
- 在整个查询过程中,保持对表的锁定,直到查询完成才释放锁定。
例 3
T1 | T2 |
---|---|
select * from table | update table set column='xx' |
T1 运行,加(共享锁) | |
T1 运行 | T2 等待 |
T1 运行完成,释放(共享锁) | T2 运行,加(排他锁) |
- 数据库规定同一资源上不能同时共存共享锁和排他锁。
- T2 必须等 T1 执行完,释放了共享锁,才能加上排他锁,然后才能开始执行 update 语句。
例 4
T1 | T2 |
---|---|
select * from table | select * from table |
T1 运行,加(共享锁) | T2 运行,加(共享锁) |
- 两个共享锁是可以同时存在于同一资源上的(比如同一个表上)。
- 这被称为共享锁与共享锁兼容。这意味着共享锁不阻止其它 session 同时读资源,但阻止其它 session update。
例 5
T1 | T2 | T3 |
---|---|---|
select * from table | select * from table | update table set column='xx' |
T1 运行,加(共享锁) | T2 运行加(共享锁) | |
T1 运行 | T2 运行 | T3 等待 |
T1 运行完成,释放(共享锁) | T2 运行 | T3 等待 |
T2 运行完成,释放(共享锁) | T3 运行,加(排他锁) |
- 同一资源上不能同时共存共享锁和排他锁,资源上的所有共享锁都释放了,才能增加排他锁。
例 6(死锁的发生)
T1 | T2 |
---|---|
select * from table(holdlock) | select * from table(holdlock) |
T1 运行,加(共享锁) | T2 运行加(共享锁) |
T1 运行完成,事务未完成,不释放(共享锁) | |
update table set column='xx' | T1 运行完成,事务未完成,不释放(共享锁) |
等待(共享锁释放),才能添加(排他锁) | update table set column='xx' |
继续等待 | 等待(共享锁释放),才能添加(排他锁) |
例 7
- id 加索引的情况。
- T1 和 T2 各更新各的,互不影响。
T1 | T2 |
---|---|
update table set column='xx' where id=1 | update table set column='xx' where id=2 |
找到 id=1 这条记录,加(排他锁) | 找到 id=2 这条记录,加(排他锁) |
- id 不加索引的情况。
T1 | T2 |
---|---|
update table set column='xx' where id=1 | update table set column='xx' where id=2 |
全表扫描后,加(排他锁) | 等待(排他锁释放),才能全表扫描,加(共享锁/更新 |
锁/排他锁,策略不同,处理不一样)
例 8(解决死锁问题)
T1 | T2 |
---|---|
select * from table(xlock) | select * from table(xlock) |
T1 运行,直接加(排他锁) | T2 等待 |
update table set column='xx' | T2 等待 |
T1 运行完成,释放(排他锁) | T2 运行,直接加(排他锁) |
- 虽然解决了死锁,但是性能太低,后续的 T3,T4,T5 都需要等待。因为引入(更新锁)。
-
排他锁/独占锁(Exclusive Lock)
- X 锁,也叫写锁,表示对数据进行写操作。如果一个事务对对象加了排他锁,其他事务就不能再给它加任何锁了。
- 仅允许一个事务封锁此页。
- 其他任何事务必须等到 X 锁被释放才能对该页进行访问。
- X 锁一直到事务结束才能被释放。
- X 锁,也叫写锁,表示对数据进行写操作。如果一个事务对对象加了排他锁,其他事务就不能再给它加任何锁了。
例 1(行级锁)
select * from table for update
例 2
T1 | T2 |
---|---|
update table set column='xx' where id<1000 | update table set column='xx' where id>1000 |
T1 运行,对 <1000 数据加(排他锁) | T2 运行,对 >1000 数据加(排他锁) |
- 互不影响,不会阻塞。
T1 | T2 |
---|---|
update table set column='xx' where id<1000 | update table set column='xx' where id>900 |
T1 运行,对 <1000 数据加(排他锁) | T2 等待 |
T1 运行完成,释放(排他锁) | T2 运行,对 >900 数据加(排他锁) |
-
更新锁(Update Lock)
- U 锁,在修改操作的初始化阶段用来锁定可能要被修改的资源,这样可以(避免使用共享锁造成的死锁现象)。
- 使用共享锁修改数据的步骤。
- 首先获得一个共享锁,读取数据。
- 将共享锁升级为排他锁,再执行修改操作。
- 如果有两个或多个事务同时对一个事务申请了共享锁,在修改数据时,这些事务都要将共享锁升级为排他锁。这些事务都不会释放共享锁,而是一直等待对方释放,这样就造成了死锁。
- 如果数据在修改前直接申请更新锁,在数据修改时再升级为排他锁,就可以避免死锁。
- 用来(预定)要对此页施加 X 锁,这时允许其他事务读,但不允许再施加 U 锁或 X 锁。
- 当被读取的页要被更新时,直接升级为 X 锁。
- U 锁一直到事务结束时才能被释放。
例 1
T1 | T2 | T3 |
---|---|---|
select * from table(updlock) | select * from table(updlock) | select * from table |
T1 运行,加(更新锁) | T2 等待 | T3 运行,加(共享锁) |
update table set column='xx' | T2 等待 | T3 运行完成,释放(共享锁) |
T1 运行,加(排他锁) | T2 等待 | |
T1 运行完成,释放(排他锁) | T2 运行,加(更新锁) |
- 一个事务只能有一个更新锁获取资格。
- 共享锁和更新锁可以同时在同一个资源上,共享锁和更新锁是兼容的。
例 2
T1 | T2 |
---|---|
select * from table(updlock) | select * from table |
T1 运行,加(更新锁) | T2 运行,加(共享锁) |
update table set column='xx' | |
update table set column='xx' | T2 等待 |
T1 运行,加(排他锁) | T2 等待 |
T1 运行完成,释放(排他锁) | T2 运行,加(排他锁) |
- 排他锁与更新锁是不兼容,不能同时加在同一子资源上。
-
意向锁(Intent Locks)
-
当一个表中的某一行被加上排他锁后,该表就不能再被加表锁。
- 数据库程序如何知道该表不能被加表锁。
- 一种方式是逐条的判断该表的每一条记录是否已经有排他锁。
- 另一种方式是直接在表这一层级检查表本身是否有意向锁,不需要逐条判断。显然后者效率高。
- 数据库程序如何知道该表不能被加表锁。
例 1
T1 | T2 |
---|---|
select * from table(xlock) where id=1 | select * from table(tablock) |
T1 运行,加(行级排他锁),同时对表加(表级意向排他锁) | T2 发现(意向排他锁)等待。 |
- 不用对每一行数据进行行级排他判断,效率高很多。
-
计划锁(Schema Locks)
- DDL 语句都会加计划锁,该锁不允许任何其它 session 连接该表。
alter table ....
-
批量更新锁(Bulk Update Locks)
- 主要在批量导数据时用(比如用类似于 oracle 中的 imp/exp 的 bcp 命令)。
-
手动加锁
-
数据库自动加锁情况。
例 1
T1 | T2 |
---|---|
update table set column='xx' where id=1 | select * from table where id=1 |
T1 运行,数据库自动加(排他锁) | T2 等待 |
T1 运行完成,释放(排他锁) | T2 运行,加(共享锁) |
- 手动设置加锁,通过 hint 设置事务隔离级别
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED --事物隔离级别为允许脏读
T1 | T2 |
---|---|
update table set column='xx' where id=1 | select * from table where id=1 |
T1 运行,数据库自动加(排他锁) | T2 运行,不加(共享锁,允许脏读) |
1.2.2.3 悲观锁按作用范围划分
-
行级锁(row-level)
- 锁的作用范围是行级别。是一种排他锁,防止其他事务修改此行。
- Oracle 会自动应用行级锁的语句有:
- INSERT、UPDATE、DELETE、SELECT … FOR UPDATE [OF columns] [WAIT n | NOWAIT]
- SELECT … FOR UPDATE 语句允许用户一次锁定多条记录进行更新。
- 使用 COMMIT 或 ROLLBACK 语句释放锁。
- 行级锁消耗最大,最容易发生死锁。
- 使用行级锁定的主要是 InnoDB 存储引擎。
-
页级锁(page-level)
- 页级锁定是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。
- 页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。
- 页级锁定和行级锁定一样,会发生死锁。
- 使用页级锁定的主要是 BerkeleyDB(BDB) 存储引擎。
-
表级锁(table-level)
- 锁的作用范围是整张表。
- 该锁定机制最大的特点是实现逻辑非常简单,带来的系统负面影响最小。所以获取锁和释放锁的速度很快。
- 可以很好的避免死锁问题。
- 锁定颗粒度大所带来最大的负面影响就是出现锁定资源争用的概率也会最高。
- 使用表级锁定的主要是 MyISAM,MEMORY,CSV 等一些非事务性存储引擎。
- 锁的作用范围是整张表。
表级锁分类 | 说明 |
---|---|
行共享(ROW SHARE) | 禁止排他锁定表。 |
行排他(ROW EXCLUSIVE) | 禁止使用排他锁和共享锁。 |
共享锁(SHARE) | 锁定表,对记录只读不写,多个用户可以同时在同一个表上应用此锁。 |
共享行排他(SHARE ROW EXCLUSIVE) | 比共享锁有更多的限制,禁止使用共享锁及更高的锁。 |
排他(EXCLUSIVE) | 限制最强的表级锁,仅允许其他用户查询该表的行。禁止修改和锁定表。 |
-
应该使用表级锁的情况。
- 事务需要更新大部分或全部数据,表又比较大,如果使用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。
- 事务涉及多个表,比较复杂,很可能引起死锁,造成大量事务回滚。这种情况也可以考虑一次性锁定事务涉及的表,从而避免死锁、减少数据库因事务回滚带来的开销。
-
总结
- (表级锁)开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
- (行级锁)开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
- (页级锁)开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
-
表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如 Web 应用。
-
行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。
- InnoDB 引擎
- InnoDB 是目前事务型存储引擎中使用最为广泛的存储引擎,它实现的默认是行级锁定。
- InnoDB 的锁定模式有:共享锁(S),排他锁(X),意向共享锁(IS)和意向排他锁(IX)。
- 四种模式的共存逻辑关系。
共享锁(S) | 排他锁(X) | 意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|---|---|
共享锁(S) | 兼容 | 冲突 | 冲突 | 冲突 |
排他锁(X) | 冲突 | 冲突 | 冲突 | 冲突 |
意向共享锁(IS) | 兼容 | 冲突 | 兼容 | 兼容 |
意向排他锁(IX) | 冲突 | 冲突 | 兼容 | 兼容 |
-
如果一个事务请求的锁模式与当前的锁兼容,InnoDB 就将请求的锁授予该事务。反之,如果两者不兼容,该事务就需要等待锁释放。
-
意向锁是 InnoDB 自动添加,不需用户干预。
-
对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会自动给涉及数据集加(排他锁)。
-
对于普通 SELECT 语句,InnoDB 不会加任何锁。
-
显示设置锁模式。
- 共享锁模式 SELECT * FROM table WHERE ... LOCK IN SHARE MODE。
- 排他锁模式 SELECT * FROM table WHERE ... FOR UPDATE
-
InnoDB 行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。
- MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。
- 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
- 即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划,以确认是否真正使用了索引。
-
间隙锁(Next-Key 锁)
- 用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB 会给符合条件的已有数据记录的索引项加锁。
- 对于键值在条件范围内但并不存在的记录,叫做 " 间隙(GAP) ",InnoDB 也会对这个 " 间隙 " 加锁,这种锁机制就是间隙锁(Next-Key 锁)。
-
在 InnoDB 的事务管理和锁定机制中,有专门检测死锁的机制,会在系统中产生死锁之后的很短时间内就检测到该死锁的存在。
- 当 InnoDB 检测到系统中产生了死锁之后,InnoDB 会通过相应的判断来选这产生死锁的两个事务中(较小)的事务来回滚,而让另外一个较大的事务成功完成。
- 两个事务各自插入、更新或者删除的数据量来判定两个事务的大小。事务所改变的记录条数越多,死锁中越不会被回滚。
- 如果 InnoDB 无法判断出死锁,则只能通过死锁超时(InnoDB_lock_wait_timeout)来解决。
- 当 InnoDB 检测到系统中产生了死锁之后,InnoDB 会通过相应的判断来选这产生死锁的两个事务中(较小)的事务来回滚,而让另外一个较大的事务成功完成。
-
InnoDB 行锁优化
- 尽可能让所有的数据检索都通过索引来完成,从而避免 InnoDB 因为无法通过索引键加锁而升级为表级锁定。
- 合理设计索引,让 InnoDB 在索引键上面加锁的时候尽可能准确,尽可能的缩小锁定范围,避免造成不必要的锁定而影响其他查询的执行。
- 尽可能减少基于范围的数据检索过滤条件,避免因为间隙锁带来的负面影响而锁定了不该锁定的记录。
- 尽量控制事务的大小,减少锁定的资源量和锁定时间长度。
- 在业务环境允许的情况下,尽量使用较低级别的事务隔离,以减少 MySQL 因为实现事务隔离级别所带来的附加成本。
1.2.2.4 乐观锁的实现方式
-
版本号(version)
- 给数据增加一个版本标识。增加一个 version 字段。
- 每次更新这个字段加 1。读取数据的时候把 version 读出来,更新的时候比较 version。读取的 version 与旧的一致则更新,读取的 version 比旧的大则说明已经被其他事务更新增加了版本。后续处理由用户自定。 version 的处理必须为原子单元操作。
- 需要使用 update ... where ... and version="old version" 这样的语句,根据返回结果是 0 还是非 0 来获得更新结果,如果返回 0 说明更新没有成功,因为 version 已被修改,如果返回非 0 说明更新成功。
- 给数据增加一个版本标识。增加一个 version 字段。
-
时间戳(timestamp)
- 和版本号基本一样,只是通过时间戳来判断而已,时间戳要使用数据库服务器的时间戳不能是业务系统的时间。
-
待更新字段
- 和版本号方式相似,不增加额外字段,直接使用有效数据字段做版本控制信息。
-
所有字段
- 和待更新字段类似,只是使用所有字段做版本控制信息,只有所有字段都没变化才会执行更新。
- 新系统设计可以使用 version 方式和 timestamp 方式,需要增加字段,应用范围是整条数据,所有字段修改都会更新 version,两个事务更新同一条记录的两个不相关字段也是互斥的,不能同步进行。
- 旧系统不能修改数据库表结构的时候使用数据字段作为版本控制信息,不需要新增字段,待更新字段方式只要其他事务修改的字段和当前事务修改的字段没有重叠就可以同步进行,并发性更高。
1.2.2.5 并发控制会造成两种锁
-
活锁
- 指的是 T1 封锁了数据 R,T2 同时也请求封锁数据 R,T3 也请求封锁数据 R,当 T1 释放了锁之后,T3 会锁住 R,T4 也请求封锁 R,则 T2 就会一直等待下去。
- 采用 "先来先服务" 策略可以避免。
- 指的是 T1 封锁了数据 R,T2 同时也请求封锁数据 R,T3 也请求封锁数据 R,当 T1 释放了锁之后,T3 会锁住 R,T4 也请求封锁 R,则 T2 就会一直等待下去。
-
死锁(尽量从业务层避免和预防)
- T1 封锁了数据 R1,正请求对 R2 封锁,而 T2 封住了 R2,正请求封锁 R1。
- (一次封锁法)一次性把所需要的数据全部封锁住,但是这样会扩大了封锁的范围,降低系统的并发度。
- (顺序封锁法)事先对数据对象指定一个封锁顺序,要对数据进行封锁,只能按照规定的顺序来封锁,但是这个一般不大可能的。
- T1 封锁了数据 R1,正请求对 R2 封锁,而 T2 封住了 R2,正请求封锁 R1。
-
系统判定死锁的方法
- 如果某个事物的等待时间超过指定时限,则判定为出现死锁(超时判定)。
- 如果事务等待图中出现了回路,则判断出现了死锁。(等待图判定)。
-
几种避免死锁的常用方法。
- 应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表。
- 程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录。
- 事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突。
- 在 REPEATABLE-READ 隔离级别下,如果两个线程同时对相同条件记录用 SELECT...FOR UPDATE 加排他锁,在没有符合该条件记录情况下,两个线程都会加锁成功。程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。这种情况下,将隔离级别改成 READ COMMITTED,就可避免问题。
- 当隔离级别为 READ COMMITTED 时,如果两个线程都先执行 SELECT...FOR UPDATE,判断是否存在符合条件的记录,如果没有,就插入记录。此时,只有一个线程能插入成功,另一个线程会出现锁等待,当第 1 个线程提交后,第 2 个线程会因主键重出错,但虽然这个线程出错了,却会获得一个排他锁。这时如果有第 3 个线程又来申请排他锁,也会出现死锁。对于这种情况,可以直接做插入操作,然后再捕获主键重异常,或者在遇到主键重错误时,总是执行 ROLLBACK 释放获得的排他锁。
1.2.3 隔离级别
-
SQL 标准定义了 4 类隔离级别,包括了一些具体规则,用来限定事务内外的哪些改变是可见的,哪些是不可见的。
- 低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。
- 由低到高依次为 Read Uncommitted 、Read Committed 、Repeatable Read 、Serializable。
-
Read UnCommitted(读未提交)
- 最低的隔离级别。一个事务可以读取另一个事务并未提交的更新结果。
-
Read Committed(读提交)
- 大部分数据库采用的默认隔离级别。
- 一个事务的更新操作结果只有在该事务提交之后,另一个事务才可以的读取到同一笔数据更新后的结果。
-
Repeatable Read(重复读)
- Mysql 的默认级别。
- 整个事务过程中,对同一笔数据的读取结果是相同的,不管其他事务是否在对共享数据进行更新,也不管更新提交与否。
-
Serializable(序列化)
- 最高隔离级别。所有事务操作依次顺序执行。
- 会导致并发度下降,性能最差。
- 通常会用其他并发级别加上相应的并发锁机制来取代它。
是否允许 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
Read UnCommitted | √ | √ | √ |
Read Committed(大部分数据库默认级别) | × | √ | √ |
Repeatable Read(MySQL 默认级别) | × | × | √ |
Serializable | × | × | × |
1.2.3 MVCC
-
MVCC(Multi-Version Concurrency Control),对数据库的任何修改的提交都不会直接覆盖之前的数据,而是产生一个新的版本与老版本共存,使得读取时可以完全不加锁。
-
MVCC 的实现
- 每个数据记录携带两个额外的数据
created_by_txn_id
和deleted_by_txn_id
。 - 数据被 insert 时,
created_by_txn_id
记录下插入该数据的事务 ID ,deleted_by_txn_id
留空。 - 当一个数据被 delete 时,该数据的
deleted_by_txn_id
记录执行该删除的事务
ID。 - 当一个数据被 update 时,原有数据的
deleted_by_txn_id
记录执行该更新的事务 ID,并且新增一条新的数据记录,其created_by_txn_id
记录下更新该数据的事务 ID。 - 支持 MVCC 的数据库一般会有一个背景任务来定时清理那些肯定没用的数据。
- 只要一个数据记录的
deleted_by_txn_id
不为空,并且比当前还没结束的事务 ID
中最小的一个还要小,该数据记录就可以被清理掉。- PostgreSQL 中,这个任务叫做 VACUUM 进程。
- MySQL InnoDB 中,叫做 purge。
- 每个数据记录携带两个额外的数据
-
有了 MVCC,Read Committed 和 Repeatable Read 实现直观。
- 对于 Read Committed,每次读取时,总是取最新的,被提交的那个版本的数据记录。
- 对于 Repeatable Read,每次读取时,总是取
created_by_txn_id
小于等于当前事务 ID 的那些数据记录。- 在这个范围内,如果某一数据多个版本都存在,则取最新的。
-
隔离级别可以是一个 Session 级别的配置。即每一个 Session 可以在运行时选择自己希望使用什么隔离级别,也可以随时修改(只要当前没有尚未结束的事务)。每个 Session 的隔离级别和其他 Session 是什么隔离级别完全无关。Session 只要根据自己的隔离级别,选择用 MVCC 提供的合适的版本即可。
-
MySQL InnoDB、PostgreSQL、Oracle (从版本 4 开始)、MS SQL Server(从版本 2005 开始)都实现了 MVCC。
1.2.4 MySQL 和 PostgreSQL 对比
隔离级别 | MySQL | PostgreSQL |
---|---|---|
Read Uncommitted | 支持 | 不支持,等价于 Read Committed |
Read Committed | 支持,基于 MVCC 实现 | 支持,基于 MVCC 实现 |
Repeatable Read | 支持,基于 MVCC 实现了 Snapshot Isolation,可避免幻读 | 支持,基于 MVCC 实现了 Snapshot Isolation,可避免幻读 |
Serializable | 支持,Repeatable Read + 共享锁 | 支持,基于 MVCC 实现了 Serialized Snapshot Isolation |
默认级别 | Repeatable Read | Read Committed |
MVCC 实现 | 基于 Undo Log | 基于 B+ 树直接记录多个版本 |
- Read Committed 和 Repeatable Read 虽然都可以得到很好的实现。但是对于某些业务代码来讲,丢失更新(Lost Updates)始终无法避免。
- 简单来说,这个问题就是在修改的事务在提交时,无法确保这个修改的前提是否还可靠。
- 解决这类问题的办法。
- 数据库支持某种代码块,这个代码块的执行是排他的(Actual Serial Execution)。
- 一段代码在数据库服务器端执行时不会受到其他并发控制的干扰。最简单的实现方案是让整个数据库只能单线程跑。一些 NoSQL 的存储,如 Redis、VoltDB
都是这么实现的。因为他们都是基于内存的存储,其数据操作的延迟相对于网络 IO
几乎可以忽略不计。即使是单线程,配合 nonblocking IO,并发性能也可以非常高。
- 一段代码在数据库服务器端执行时不会受到其他并发控制的干扰。最简单的实现方案是让整个数据库只能单线程跑。一些 NoSQL 的存储,如 Redis、VoltDB
- 加悲观锁,把期望依赖的数据独占,在修改完成前不允许其他并发修改发生。(手工或者自动加锁)。
- 在事务过程中,根据不同的 SQL 指令加锁。
- 锁定直到这个事务被提交或者回滚(包括等待超时造成回滚)时释放。
- 加乐观锁,在事务提交的一刹那(注意是 commit 时,不是修改时),检查修改的依赖是不是没有被修改。(Serializable Snapshot Isolation,SSI)
- 整个事务是 Snapshot Isolation,事务在进行过程中,除了对数据进行操作外,还要对整个事务的所有修改操作的依赖数据做追踪。当事务被 commit 时,当前事务会检查是否被其他事务修改过,如果是,则回滚掉当前事务。PostgreSQL 的
Serializable 基于 SSI 实现。
- 整个事务是 Snapshot Isolation,事务在进行过程中,除了对数据进行操作外,还要对整个事务的所有修改操作的依赖数据做追踪。当事务被 commit 时,当前事务会检查是否被其他事务修改过,如果是,则回滚掉当前事务。PostgreSQL 的
- 数据库支持某种代码块,这个代码块的执行是排他的(Actual Serial Execution)。
- 如果冲突太多,SSI(乐观锁方式)会造成大量的资源浪费。
- 如果冲突不是很多,加锁方案(悲观锁方式)带来锁等待和死锁的负面效果更显著。
1.3 传播行为
传播行为 | 说明 |
---|---|
PROPAGATION_REQUIRED | (支持事物)如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。 |
PROPAGATION_SUPPORTS | (支持事物)支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。 |
PROPAGATION_MANDATORY | (支持事物)支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。 |
PROPAGATION_REQUIRES_NEW | (支持事物)创建新事务,无论当前存不存在事务,都创建新事务。 |
PROPAGATION_NOT_SUPPORTED | (不支持事物)以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。 |
PROPAGATION_NEVER | (不支持事物)以非事务方式执行,如果当前存在事务,则抛出异常。 |
PROPAGATION_NESTED | (不支持事物)如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与 PROPAGATION_REQUIRED 类似的操作。 |
2. 事务的类型
- Java 事务的类型有三种:JDBC 事务、JTA(Java Transaction API)事务、容器事务。
2.1 JDBC 事务
- JDBC 事务是用 Connection 对象控制的。
- 事务管理实际上是在 JDBC Connection 中实现。
- 事务周期限于 Connection 的生命周期。
- JDBC Connection 接口(java.sql.Connection)提供了两种事务模式:自动提交和手工提交。
- 使用 JDBC 事务界定时,可以将多个 SQL 语句结合到一个事务中。
- JDBC 事务的范围局限于一个数据库连接。
- 一个 JDBC 事务不能跨越多个数据库。
提供的事务方法 | 说明 |
---|---|
public void setAutoCommit(boolean) | 设置自动提交(默认为自动提交)。 |
public boolean getAutoCommit() | 获取是否自动提交。 |
public void commit() | 事务提交。 |
public void rollback() | 事务回滚。 |
2.2 JTA(Java Transaction API)事务
-
JTA 提供了跨数据库连接(或其他 JTA 资源)的事务管理能力。
- 可横跨多个 JDBC Connection 生命周期,对众多 Connection 进行调度,实现其事务性要求。
-
JTA 事务管理则由 JTA 容器实现 J2EE 框架中事务管理器与应用程序,资源管理器以及应用服务器之间的事务通讯。
-
JTA 的主要接口位于 javax.transaction 包中。
接口 | 说明 |
---|---|
UserTransaction 接口 | 让应用程序得以控制事务的开始、挂起、提交、回滚等。由Java 客户端程序或 EJB 调用。 |
TransactionManager 接口 | 用于应用服务器管理事务状态。 |
Transaction 接口 | 用于执行相关事务操作。 |
XAResource接口 | 用于在分布式事务环境下协调事务管理器和资源管理器的工作。 |
Xid 接口 | 事务标识符的 Java 映射。 |
- JTA 可以处理任何提供符合 XA 接口的资源。包括:JDBC 连接,数据库,JMS,商业对象等等。
- XA 连接与非 XA 连接不同。XA 连接参与了 JTA 事务。
- XA 连接不支持 JDBC 的自动提交功能。
2.2.1 JTA 架构
-
JTA 架构包括事务管理器(Transaction Manager)和一个或多个支持 XA 协议的资源管理器 ( Resource Manager ) 两部分。
- 事务管理器承担着所有事务参与单元的协调与控制。(面向开发人员的使用接口)
- 资源管理器是任意类型的持久化数据存储。(面向服务提供商的实现接口)
-
开发人员通过接口在信息系统中实现分布式事务,而实现接口则用来规范提供商(如数据库连接提供商)所提供的事务服务,它约定了事务的资源管理功能,使得 JTA 可以在异构事务资源之间执行协同沟通。
-
面向开发人员的接口为 UserTransaction ,通常只使用此接口实现 JTA 事务管理。
UserTransaction 定义的方法 | 说明 |
---|---|
begin() | 开始一个分布式事务(后台 TransactionManager 会创建一个 Transaction 事务对象并把此对象通过 ThreadLocale 关联到当前线程上)。 |
commit() | 提交事务(后台 TransactionManager 会从当前线程下取出事务对象并把此对象所代表的事务进行提交)。 |
rollback() | 回滚事务(后台 TransactionManager 会从当前线程下取出事务对象并把此对象所代表的事务进行回滚)。 |
getStatus() | 返回关联到当前线程的分布式事务的状态 (Status 对象里边定义了所有的事务状态 ) |
setRollbackOnly() | 标识关联到当前线程的分布式事务将被回滚。 |
- 面向提供商的实现接口主要涉及到 TransactionManager 和 Transaction 两个对象。
Transaction 定义的方法 | 说明 |
---|---|
commit() | 协调不同的事务资源共同完成事务的提交。 |
rollback() | 协调不同的事务资源共同完成事务的回滚。 |
setRollbackOnly() | 标识关联到当前线程的分布式事务将被回滚。 |
getStatus() | 返回关联到当前线程的分布式事务的状态。 |
enListResource(XAResource xaRes, int flag) | 将事务资源加入到当前的事务中。 |
delistResourc(XAResource xaRes, int flag) | 将事务资源从当前事务中删除。 |
registerSynchronization(Synchronization sync) | 回调接口,以便在事务完成时得到通知从而触发一些处理工作,如清除缓存等。可以通过此接口将回调程序注入到事务中,当事务成功提交后,回调程序将被激活。 |
- TransactionManager 本身并不承担实际的事务处理功能,更多的是充当用户接口和实现接口之间的桥梁。
TransactionManager 定义的方法 | 说明 |
---|---|
begin() | 开始事务。 |
commit() | 提交事务。 |
rollback() | 回滚事务。 |
getStatus() | 返回当前事务状态。 |
setRollbackOnly() | 标记当前事务将回滚。 |
getTransaction() | 返回关联到当前线程的事务。 |
setTransactionTimeout(int seconds) | 设置事务超时时间。 |
resume(Transaction tobj) | 继续当前线程关联的事务。 |
suspend() | 挂起当前线程关联的事务。 |
- UserTransaction 对象不会对事务进行任何控制, 所有的事务方法都是通过 TransactionManager 传递到实际的事务资源即 Transaction 对象上。
- 调用 UserTransaction.begin() 方法时 TransactionManager 会创建一个 Transaction 事务对象(标志着事务的开始)并把此对象通过 ThreadLocale 关联到当前线程上。
- UserTransaction.commit() 会调用 TransactionManager.commit(),该方法将从当前线程下取出事务对象 Transaction 并把此对象所代表的事务提交, 即调用 Transaction.commit()。
- Transaction 对象本身就代表了一个事务,在它被创建的时候就表明事务已经开始。
2.3 容器事务
- 容器事务主要是 J2EE 应用服务器提供,容器事务大多是基于 JTA 实现的,是基于 JNDI 的。
- 相对编码实现 JTA 事务管理,还可以通过 EJB 容器提供的容器事务管理机制(CMT)完成同一个功能,这项功能由 J2EE 应用服务器提供。一旦指定,容器将负责事务管理任务。
- 局限于 EJB 应用使用。
参考
https://www.jianshu.com/p/cb97f76a92fd
https://www.cnblogs.com/balfish/p/8298296.html