前戏
mysql事务的水较深,是一块非常庞大的知识体系,需要花费大量的时间去学习和实践。
除了事务自有的ACID特性,还要掌握底层数据库的事务机制(例如mysql事务),
以及上层的spring事务处理以及事务的隔离级别,传播级别,事务的各种属性等等,
并且事务要结合索引,表引擎,锁机制(锁机制是深坑)等知识配合使用。
如果事务使用不当,会造成锁表,事务死锁,事务超时,脏数据等等重大事故,
尤其是在大并发的情况下,更是灭顶之灾。(宝宝好怕~)
正因为水深,所以各大名企的java面试题都会围绕着事务来提问,弄的有些小伙伴一脸懵逼。
换句话说,你掌握了事务,你进入名企的障碍就少了一个,因此:
事务已经是你java职业生涯发展上一个必须通关的游戏了。
注意:如果底层数据库使用mysql,则需要选择支持事务的表引擎,例如innodb。
事务这些到底是个啥?我不喜欢用书上难以理解的语言来描述,取而代之的是用通俗易懂的话来讲述。
建议大家从网上or书上看到的知识,都自己实践一下,把别人的知识变成自己的知识,注重总结,
不断提高自己,离开舒适区。
先来个例子:你去银行转账给你的麻麻,转了100块钱,程序要至少干三件事情:
第一件事:检查你的余额是否大于等于100。
第二件事:将你的帐号里的余额扣除100。
第三件事:将你麻麻的帐号里的余额增加100。
这三件事需要要么全部成功(事务提交),要么全部失败(事务回滚),否则:
刚扣完你的钱,然后出bug了,机房断电了,网络通信中断了,或者什么奇葩情况,你麻麻的账户里的钱没有增加,
这事儿,放谁身上谁也不能干对吧?
扣题:
事务是一步或几步基本操作组成的逻辑执行单元,这些基本操作作为一个整体执行单元,他们要么全部执行,
要么全部取消,绝对不能仅仅执行部分,否则数据库中会出现大量的“脏数据”等等。
本文所有场景均在innodb引擎下。
什么是事务
简单的说,事务是由一组SQL语句组成的逻辑执行单元,默认情况下mysql会自动管理事务,
一条SQL语句独占一个事务。
事务具有以下4个属性,通常简称为事务的ACID属性&特性&原则。
-
原子性(Atomicity[ˌætəˈmɪsɪti])
事务是一个原子操作单元,最小执行单位,就像原子是最小的颗粒,具有逻辑上不可再分的特性。
-
一致性(Consistency[kənˈsɪstənsi])
事务执行的结果,必须使数据库从一个一致性状态,变成另一个一致性状态。无论事务执行成功或失败,
数据都要保证一致性的状态,保证数据的完整性,要么需要一起变化要么一起回滚。
如果系统运行发生中断,某个事务尚未完成而被迫中断,而该未完成的事务对数据库所做的修改已被写入数据库,
此时,数据库就处于不一致的状态。
例如:A给B转账,从A中扣除的金额必须与B中存入的金额一致。
-
隔离性(Isolation[ˌaɪsəˈleɪʃn])
数据库系统提供一定的隔离机制,各个事务的执行互不干扰,保证事务在不受并发事务操作影响的“独立”环境执行。
任意一个事务的内部操作对其他并发的事务,都是隔离的,看不到的。并发执行的事务之间不能互相影响。
(当然隔离性越高,性能会越差,反之亦然,例如:当前事务能否看到其他事务未提交的写数据。
后面会衍生出事务隔离级别和锁机制等概念,用于并发事务。)
-
持久性(durability[ˌdjʊrəˈbɪlətɪ])
事务一旦提交,对数据库所做的任何改变,都要记录到永久存储器中,也就是保存进物理数据库。
常见事务命令
-
开始、提交、回滚(当前会话下)
#手动开始一个事务(如果有自动提交的话,会把自动提交挂起,在使用COMMIT命令后恢复自动提交)
BEGIN;或START TRANSACTION;
#事务回滚
ROLLBACK;
#手动事务提交(事务未提交之前,在本事务内可以看到数据变化,但并未真正入库。)
COMMIT;
#注意:在提交和回滚后,AUTOCOMMIT参数又会变成默认值。
-
设置提交模式(当前会话下)
#禁止自动提交(只有遇到COMMIT才会提交,默认自动执行BEGIN或START TRANSACTION,自动开启一个新事务)
SET AUTOCOMMIT=0;
#开启自动提交(每条sql执行完之后自动提交,默认把每条sql当作一个新的事务处理,一条普通查询也是一个事务)
SET AUTOCOMMIT=1;
#查询自动提交模式
SELECT @@autocommit;
SHOW VARIABLES LIKE 'autocommit';
~不管autocommit 是1还是0
START TRANSACTION 后,只有当commit数据才会生效,ROLLBACK后就会回滚。
~当autocommit 为 0 时
不管有没有START TRANSACTION。只有当commit数据才会生效,ROLLBACK后就会回滚。
~如果autocommit 为1并且没有START TRANSACTION时,调用ROLLBACK是没有用的。
(
关于autocommit=1的情况,网上有很多文章说autocommit=1会新建事务,只不过事务自动提交了。
对于这个言论我做了2个小实验(新人可以PASS掉)。
实验1:默认autocommit=1,插入n条数据,在插入的过程中,去查询系统表information_schema.`INNODB_TRX`,
会查询出正在执行的事务,我发现确实能查询出正在执行的insert事务,证明确实是会新建事务。
实验2:使用2个串行化隔离级别的事务,事务A设置autocommit=0,事务B设置autocommit=1,
事务A执行:UPDATE t_user_transaction SET user_age = 3 WHERE key_id = 'alex3'
事务B1执行:SELECT * FROM t_user_transaction WHERE key_id = 'alex3'
事务B2执行:SELECT * FROM t_user_transaction WHERE key_id = 'alex3' FOR UPDATE
事务B3执行:UPDATE t_user_transaction SET user_age = 3 WHERE key_id = 'alex3'
在串行化级别下,正常情况下事务B1、B2、B3应该是会被阻塞的,因为A持有这条数据的排它锁,
而B1会获取这条数据的共享锁,在串行化级别下,select ... where ...=会走一个共享锁。
B2、B3会获取这条数据的排它锁,造成互斥,因此阻塞。
但实验结果是,事务B1不阻塞,事务B2、B3阻塞,而将事务B1设置autocommit=0,事务B1就会阻塞,
理论上来说事务B1就应该是阻塞的。
那就奇怪了,为什么会造成这种奇葩的情况,应该是mysql还有什么隐藏的知识点我没有掌握,惭愧。。。
暂时的结论:autocommit=1确实会为每条执行的语句都自动开启一个新的事务,
但在某些隔离级别下,例如串行化级别,会出现加锁机制混乱的问题,如实验2所示。
)
-
设置事务隔离级别
#设置当前会话事务隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
#设置全局事务隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务隔离级别详见后面的讲解
-
查询事务隔离级别
SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation;
-
查询正在执行的事务
SELECT * FROM information_schema.INNODB_TRX;
并发事务处理带来的问题&缺陷
相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,
从而可以支持更多的用户。(如果不明白什么是串行&并行请留言)
在一个应用程序中,可能有多个事务同时在进行,这些事务应当彼此之间互不知道另一个事务的存在,
由于事务彼此之间的独立性,若读取的是同一个数据的话,就容易发生问题。
随便举个常见的问题,例如:2个事务同时去修改一条数据的某列,初始值为0,让事务分别做+1操作,
我们期待的结果是2(因为有2次+1),但如果出现1的情况,就代表出现了问题。
学习完下面的知识再看看这个问题是什么缺陷?
-
脏读(dirty reads)
一个事务读到了另一个事务未提交的更新数据。
例如:事务B执行过程中修改了数据X,在未提交前,事务A读取了X(此时X变动了),而事务B却回滚了,
这样事务A就形成了脏读。
-
不可重复读(non-repeatable reads)
在同一事务中,多次读取同一数据但返回的结果不同,后续读取可以读到另一事务已提交的更新数据。
例如:事务A首先读取了一条数据,然后事务A在执行逻辑的时候,事务B将这条数据改变并提交,
然后事务A再次读取的时候,发现数据变了(被修改&删除),造成了事务A的数据混乱。
相反,可重复读则为多次读到的数据是一样的,也就是不能读取到其他事务已经提交的更新数据。
-
幻读 &虚读(phantom reads)
一个事务读到另一个事务已提交的insert数据,导致前后读取不一致。
小的时候数手指,第一次数是10个,第二次数是11个,怎么回事?产生幻觉了?
例如:
事务A首先根据条件检索得到10条数据,然后事务B改变了数据库一条数据或新增了一条数据并提交,
导致这条数据也符合事务A当时的搜索条件,此时事务A再次搜索发现还是10条,
但是如果事务A修改了符合条件的事务B的那条数据(update set...),再查询则有11条数据了,就产生了幻读。
注意事项
~幻读与不可重复读有点类似,都是同一个事务中多次读不一致的问题。
~不可重复读的重点在于修改,同样的条件,你读取过的数据,再次读取出来发现值不一样 。
~幻读的重点在于新增,同样的条件,你读取过的数据,再次读取出来发现记录数不一样 ,
幻读的前提是有过符合条件数据的update操作。
-
第一类丢失更新(lost update)
在没有事务隔离的情况下,两个事务都同时更新一行数据,但是第二个事务却中途失败退出,
导致对数据的两个修改都失效了。
例如:张三的工资为5000,事务A中获取工资为5000,事务B获取工资为5000,汇入100,并提交数据库,
工资变为5100,随后事务A发生异常,回滚了,恢复张三的工资为5000,这样就导致事务B的更新丢失了。
这种情况一般不会发生。
-
第二类丢失更新(second lost update)
不可重复读的特例,有两个并发事务同时读取同一行数据,然后其中一个对它进行修改提交,
而另一个也进行了修改提交。这就会造成第一次写操作失效。
例如:在事务A中,读取到张三的存款为5000,操作没有完成,事务还没提交。
与此同时,事务B,存储1000,把张三的存款改为6000,并提交了事务。
随后,在事务A中,存储500,把张三的存款改为5500,并提交了事务,这样事务A的更新覆盖了事务B的更新。
-
这些缺陷的解决办法
为了避免上述问题,需要在某个事务的进行过程中锁定正在更新或者查询的数据,直到目前的事务完成,
然而如果是完全锁定,则另一个事务来查询同一份数据就必须等待,直到前一个事务完成并解除锁定位置,
这会造成性能问题。
在现实场景中,根据需求的不同,并不用完全锁定,可以设置不同的隔离级别来满需求,以及后续文档会讲的乐观锁。
目前我们可以使用事务隔离级别来有效的解决此类问题。
事务隔离级别
各大关系型数据库厂商都会提供四种数据库隔离级别以应对事务的隔离性,这四种隔离级别对应前面提到的五种缺陷。
隔离性越高,性能越低,因此需要权衡使用场景再做决策。
让我们先来看这张对应表:
dirty reads |
non-repeatable reads |
phantom reads |
lost update |
second lost update |
|
SERIALIZABLE |
no |
no |
no |
no |
no |
REPEATABLE READ |
no |
no |
yes |
no |
no |
READ COMMITTED |
no |
yes |
yes |
no |
yes |
READ UNCOMMITTED |
yes |
yes |
yes |
no |
yes |
alex总结:
在两个事务运行时,事务A执行update一条数据,此时事务A没有提交,而事务B再执行update相同的数据,
在SERIALIZABLE与REPEATABLE READ级别下,事务B会进入等待状态。
区别是一个锁住了插入,一个没有锁插入,性能不高。
READ COMMITTED,READ UNCOMMITTED则没有把数据锁上,性能提升。
我们知道并行可以提高数据库的吞吐量和效率,但是并不是所有的并发事务都可以并发运行。
我们要思考,鱼和熊掌不可兼得,是要高并发还是要高一致性。
为了下面的示例,我们需要创建一张表:
CREATE TABLE `t_user_transaction` (
`key_id` varchar(32) NOT NULL DEFAULT '' COMMENT '主键',
`user_name` varchar(50) DEFAULT '' COMMENT '名称(索引列)',
`user_content` varchar(50) NOT NULL DEFAULT '' COMMENT '内容(非索引列)',
`is_used` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '是否删除',
PRIMARY KEY (`key_id`),
KEY `idx_user_name` (`user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
SERIALIZABLE,序列化
最高的隔离级别,它通过强制事务排序,使之不可能相互冲突。
一旦一个事务处理开始了之后,其他的事务则按顺序的排在后面,一个个排成一个序列的形式,串行执行。
一个事务没有处理完,下面的事务无法处理。
可以防止三个缺陷 。简言之,它是在每个读的数据行上加上共享锁(后续介绍)。
在这个级别,事务被处理为串行执,可能导致大量的超时现象和锁竞争,速度最慢,影响性能。
完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞。
事务A |
事务B |
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE; SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation; START TRANSACTION; SELECT * FROM t_user_transaction; |
|
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE; SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation; START TRANSACTION; SELECT * FROM t_user_transaction; #查询并不会被阻塞。 |
|
INSERT INTO t_user_transaction(key_id,user_name) VALUES ('alex2','alex2'); #处于等待状态,事务B没有提交则会阻塞事务A。 #此时增删改操作全部会被阻塞,查询则不会。 #如果事务B一直没有提交则事务A会超时。 #Lock wait timeout exceeded; try restarting transaction |
|
COMMIT; #事务提交 |
|
#1 row(s) affected #插入成功 COMMIT; #事务提交 |
|
SELECT * FROM t_user_transaction; #此时事务B可以查询到该条数据, #如果事务A一直不提交,则会阻塞事务B。 COMMIT; |
|
-
REPEATABLE READ,可重复读(mysql的默认隔离级别)
在安全上会有所妥协,只有一个缺陷,就是会产生幻读,但是在性能上会比序列化提高很多。
除非事务自身更改了数据,否则事务多次读取的数据相同,避免了“脏读”和“不可重复读取”。
事务A |
事务B |
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation; START TRANSACTION; SELECT * FROM t_user_transaction; |
|
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation; START TRANSACTION; SELECT * FROM t_user_transaction; #此时和连接A查询到的数据一样 INSERT INTO t_user_transaction(key_id,user_name) VALUES ('alex3','alex3'); #插入一条数据,此时不会阻塞。 SELECT * FROM t_user_transaction; #在当前事务中可以查询到这条新增数据 COMMIT; #事务提交,无论提交与否,事务A都看不到这条数据, #这就是可重复读,但是没有像序列化那样锁住insert。 |
|
SELECT * FROM t_user_transaction; #查询不到连接B提交的数据哇~ #屏蔽了不可重复读+賍读的缺陷。 UPDATE t_user_transaction SET is_used = 0; #将符合条件的数据全部更新, #也把刚才事务B的数据更新了。 SELECT * FROM t_user_transaction; #把连接B提交的数据也查询出来了,出现了幻觉吗? #刚才还没有呢。。。 #更新之前是不可重复读,更新之后出现了幻读。 |
|
SELECT * FROM t_user_transaction; 看不到事务A的变化,因为事务A还没有提交事务, 规避了賍读。 |
|
COMMIT; 事务提交,连接B也能看到变化了。 |
-
READ COMMITTED,提交读(oracle,sql server的默认隔离级别)
允许读取其他事务已经提交的更新,避免了“脏读”,但是有缺陷:会造成不可重复读, 幻读。
事务A |
事务B |
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation;
SET SESSION binlog_format = 'ROW'; #需要把binlog的格式调成ROW,不然会报错 SELECT @@GLOBAL.binlog_format,@@session.binlog_format; START TRANSACTION; SELECT * FROM t_user_transaction; |
|
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation;
SET SESSION binlog_format = 'ROW'; #需要把binlog的格式调成ROW,不然会报错 SELECT @@GLOBAL.binlog_format,@@session.binlog_format; START TRANSACTION; SELECT * FROM t_user_transaction; UPDATE t_user_transaction SET user_name = 'alex4' WHERE key_id = 'alex3'; |
|
SELECT * FROM t_user_transaction; #在没提交之前,事务A无法查询到事务B的改动。规避了賍读的缺陷 |
|
COMMIT; |
|
SELECT * FROM t_user_transaction; #在同一个事务中再次查询便会查询出不一样的数据,为不可重复读。 COMMIT; |
|
其他缺陷的例子略。。。 |
|
-
READ UNCOMMITED,未提交读
可以读取到其他事务未提交的修改,如果事务回滚,则会出现賍读。
会导致四个缺陷发生。执行速度最快(最不安全,性能最好),如果想要性能,直接不加事务不就行了?
事务A |
事务B |
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation;
SET SESSION binlog_format = 'ROW'; #需要把binlog的格式调成ROW,不然会报错 SELECT @@GLOBAL.binlog_format,@@session.binlog_format; START TRANSACTION; SELECT * FROM t_user_transaction; |
|
SET AUTOCOMMIT=0; SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@GLOBAL.tx_isolation,@@session.tx_isolation;
SET SESSION binlog_format = 'ROW'; #需要把binlog的格式调成ROW,不然会报错 SELECT @@GLOBAL.binlog_format,@@session.binlog_format; START TRANSACTION; SELECT * FROM t_user_transaction; UPDATE t_user_transaction SET user_name = 'alex5' WHERE key_id = 'alex2'; #修改数据,但事务还没有提交 SELECT * FROM t_user_transaction; #事务B自己能查看到修改后的样子。 |
|
SELECT * FROM t_user_transaction; #在同一个事务中再次查询便会查询出另一个事务未提交的数据,为賍读。 |
|
ROLLBACK; #事务回滚 |
|
SELECT * FROM t_user_transaction; #数据又变更了,给连接A的数据造成了很大的混乱。 COMMIT; |
|
其他缺陷的例子略。。。 |
总结
mysql事务是锁机制以及后面的spring事务管理的基础,也是面试题的重要考点,请大家务必要消化理解。
后续的文章会讲解实用性更高&更复杂的mysql锁机制。建议使用小事务,一个事务不要干特别多的事情。
事务较大,会增加事务死锁风险,高并发下性能较低,死锁我们会在锁机制的文章中涉猎。