【MySQL】深入理解MySQL事务隔离级别与锁机制
MySQL事务及ACID特性详解
概述
我们的数据库一般都会并发执行多个事务,多个事务可能会并发的对相同的一批数据进行增删改查操作,可能就会导致我们说的脏写、脏读、不可重复读、幻读这些问题。
这些问题的本质都是数据库的多事务并发问题,为了解决多事务并发问题,数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制,用一整套机制来解决多事务并发问题。
事务及其ACID属性
事务是由一组SQL语句组成的逻辑处理单元,事务具有以下4个属性,通常简称为事务的ACID属性。
原子性(Atomicity)
事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。(同时成功,同时失败)
一致性(Consistent)
在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性。(修改过的数据,必须都修改,不能有的改了,有的没有改)
隔离性(Isolation)
数据库系统提供一定的隔离机制,保证事务不受外部并发操作的影响。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
持久性(Durable)
事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。
并发事务处理带来的问题
更新丢失(Lost Update)或脏写
当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题。
最后的更新覆盖了由其他事务所做的更新
脏读(Dirty Reads)
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。
事务A 读取到了 事务B 已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚(rollback),A读取的数据无效,不符合一致性要求
不可重读(Non-Repeatable Reads)
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
一个事务内 对于 相同的查询语句,其查询结果不一致
幻读(Phantom Reads)
一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。
事务A读取到了事务B提交的新增数据,不符合隔离性
MySQL事务隔离级别详解
四种隔离级别
上面我们提到的 “脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大(性能低!),因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。
同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读"和“幻读”并不敏感,可能更关心数据并发访问的能力!
常看当前数据库的事务隔离级别
-- 在 MySQL8.0.3 之前,现在也是被废了...
show variables like 'tx_isolation'
官网描述
The
tx_isolation
andtx_read_only
system variables have been removed. Usetransaction_isolation
andtransaction_read_only
instead.
所以现在查看 | 设置事务隔离级别的方法如下:
-- 查询隔离级别,REPEATABLE-READ
show variables like 'transaction_isolation';
-- 设置隔离级别
set transaction_isolation='REPEATABLE-READ';
MySQL默认的事务隔离级别是可重复读。
Spring中设置的隔离级别
用Spring开发程序时,如果不设置隔离级别默认用MySQL设置的隔离级别(可重复读)。如果Spring设置了就用已经设置的隔离级别!
XML方式
<tx:advice id="advice" transaction-manager="transactionManager">
<tx:attributes>
<tx:method name="fun*" propagation="REQUIRED" isolation="DEFAULT"/>
</tx:attributes>
</tx:advice>
注解的方式
@Transactional(isolation=Isolation.DEFAULT)
public void fun(){
dao.add();
dao.udpate();
}
MySQL锁机制详解
在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。
如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。
MySQL中各种锁的分类
1. 按照性能来分类(乐观锁 和 悲观锁)
乐观锁: 顾名思义,就是对数据的处理持乐观态度,乐观的认为数据一般情况下不会发生冲突,只有提交数据更新时,才会对数据是否冲突进行检测。
我们一般会采用添加版本号的方式来实现乐观锁。
在更新的时候set一下version+1,然后再where里面判断一下version是否符合,不符合则回滚。
<!-- 乐观锁更新 -->
<update id="updateCount">
update
goods_sale
set count = #{record.count}, data_version = data_version + 1
where goods_sale_id = #{record.goodsSaleId}
and data_version = #{record.dataVersion}
</update>
悲观锁: 顾名思义,就是对于数据的处理持悲观态度,总认为会发生并发冲突,获取和修改数据时,别人会修改数据。所以在整个数据处理过程中,需要将数据锁定。
2. 按照数据库操作类型分类(读锁、写锁、意向锁)
读锁(共享锁 (lock in share mode),S锁)
针对同一份数据,多个读操作可以同时进行而不会互相影响。但是不允许其它事务进行“写”操作。
查询也可以通过 lock in share mode 加读锁
select * from T where id=1 lock in share mode;
写锁(排它锁(for update),X锁)
当前写操作没有完成前,它会阻断其他写锁和读锁,数据修改操作都会加写锁。
查询也可以通过for update加写锁
select * from T where id=1 for update;
意向锁(Intention Lock)
又称 I锁 ,针对表锁,主要是为了提高加表锁的效率,是mysql数据库自己加的。用于避免为了判断表是否存在行锁而去扫描全表的系统消耗
当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。
- 意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。
- 意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。
3. 按照数据操作的粒度(表锁 和 行锁)
(1)表锁
每次操作锁住整张表。
开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景。
-- 建表SQL
CREATE TABLE mylock (
`id` INT (11) NOT NULL AUTO_INCREMENT,
`NAME` VARCHAR (20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 插入数据
INSERT INTO mylock (`id`, `NAME`) VALUES ('1', 'a');
INSERT INTO mylock (`id`, `NAME`) VALUES ('2', 'b');
INSERT INTO mylock (`id`, `NAME`) VALUES ('3', 'c');
INSERT INTO mylock (`id`, `NAME`) VALUES ('4', 'd');
手动增加表锁
lock table 表名称 read(write);
加读锁
- 当前session和其他session都可以读该表
- 当前session中插入或者更新锁定的表都会报错,其他session插入或更新则会等待
加写锁
- 当前session对该表的增删改查都没有问题,其他session对该表的所有操作被阻塞
查看表上加过的锁
show open tables;
可以看到在In_use列被标记为1
使用场景
表锁在实际开发中基本没有用,一般用于数据迁移。在迁移过程中为了防止一些不必要的麻烦(迁移一半数据突然被别人修改了),一般在迁移前先加上表锁!
删除表锁
unlock tables;
(2)行锁
每次操作锁住一行数据。
开销大,加锁慢(需要找到那一行);会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。
InnoDB 与 MYISAM 的最大不同有两点
- InnoDB支持事务(TRANSACTION)
- InnoDB支持行级锁
行锁演示
如果长时间卡在这里就会报错:ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
总得来说
MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁。
InnoDB在执行查询语句SELECT时,因为有MVCC机制是不会加锁的。但是update、insert、delete操作会加行锁。
简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞。
行锁与事务隔离级别案例分析
案例SQL准备
CREATE TABLE account (
`id` INT (11) NOT NULL AUTO_INCREMENT,
`NAME` VARCHAR (20) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `account` (`name`, `balance`) VALUES ('lilei', '450');
INSERT INTO `account` (`name`, `balance`) VALUES ('hanmei', '16000');
INSERT INTO `account` (`name`, `balance`) VALUES ('lucy', '2400');
(1)读未提交案例
我们在开始前先设置一下MySQL事务的隔离级别
-- 如果是5.7的版本,使用的应该是 tx_isolation
-- MySQL默认的是 REPEATABLE-READ
show variables like 'transaction_isolation';
-- 设置隔离级别为:读未提交
set transaction_isolation='read-uncommitted';
在第二个窗口中,可以查看到未提交事务!一旦窗口一的事务(执行的update)回滚,那么窗口二查到的数据就是脏数据!!!也就是发生了脏读
(2)读已提交案例
先修改一下隔离级别为:read-committed
set transaction_isolation='read-committed';
显然,读已提交是可以解决“脏读”的问题,但是不能解决“可重复读”的问题!!!
(3)可重复读案例
set transaction_isolation='repeatable-read';
注:左边是事务一,右边是事务二
在可重复读这种隔离级别中,数据的一致性倒是没有被破坏!
可重复读的隔离级别下使用了MVCC(multi-version concurrency control)机制
- select操作不会更新版本号,是快照读(历史版本)
- insert、update和delete会更新版本号,是当前读(当前版本)
可重复读中的幻读问题!
(4)串行化
set transaction_isolation='serializable';
如果使用了串行化,那么之前我们所提到的所有问题(脏读、不可重复读、幻读)都可以解决!
不论是读还是写,MySQL的存储引擎都会给它上锁!
这种隔离级别并发性极低,开发中很少会用到!
间隙锁(Gap Lock)
临键锁(Next-key Locks)
总结!!!
无索引行锁会升级为表锁
- 可重复读(RR)级别会升级为表锁
- 读取已提交(RC)级别不会升级为表锁
锁主要是加在索引上,如果对非索引字段更新,行锁可能会变表锁
InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。
另外!锁定某一行还可以用lock in share mode(共享锁) 和for update(排它锁)
select * from test_innodb_lock where a = 2 for update;
这样其他session只能读这行数据,修改则会被阻塞,直到锁定行的session提交
行锁性能分析
show status like 'innodb_row_lock%';
上述的各个状态量含义:
Innodb_row_lock_current_waits: 当前正在等待锁定的数量
Innodb_row_lock_time: 从系统启动到现在锁定总时间长度(等待总时长)
Innodb_row_lock_time_avg: 每次等待所花平均时间(等待平均时长)
Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间
Innodb_row_lock_waits: 系统启动后到现在总共等待的次数(等待总次数)
尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待?然后找到相应的代码部分,进行分析、修改!
查看INFORMATION_SCHEMA系统库锁相关数据表
MySQL5.7版本
-- 5.6版本的,在8.0版本就不可以这么用了...
-- 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
-- 查看锁
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
-- 查看锁等待
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
-- 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id
-- 查看锁等待详细信息
show engine innodb status\G;
MySQL8.0版本
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM
PERFORMANCE_SCHEMA.data_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;
死锁
set transaction_isolation='repeatable-read';
大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务
Session_1执行:select * from account where id=1 for update;
Session_2执行:select * from account where id=2 for update;
Session_1执行:select * from account where id=2 for update;
Session_2执行:select * from account where id=1 for update;
查看近期死锁日志信息
show engine innodb status\G;
MySQL锁优化建议
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能减少检索条件范围,避免间隙锁
- 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的SQL尽量放在事务最后执行
- 尽可能低级别事务隔离