MySQL事务和锁详解

数据库一般都会并发执行多个事务,多个事务可能会并发的对相同的一批数据进行增删改查操作,可能就会导致我们说的脏写、脏读、不可重复读、幻读这些问题。

这些问题的本质都是数据库的多事务并发问题,为了解决多事务并发问题,数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制,用一整套机制来解决多事务并发问题

事务及其ACID属性

事务是由一组SQL语句组成的逻辑处理单元,事务具有以下4个属性,通常简称为事务的ACID属性。

  • 原子性(Atomicity) :事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。
  • 一致性(Consistent) :在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性。
  • 隔离性(Isolation) :数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
  • 持久性(Durable) :事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。

并发事务处理带来的问题

更新丢失(Lost Update)或脏写

当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题——最后的更新覆盖了由其他事务所做的更新

可以使用锁来解决

脏读(Dirty Reads)

  一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。

  一句话:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。

不可重读(Non-Repeatable Reads)

  一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。

  一句话:事务A内部的相同查询语句在不同时刻读出的结果不一致,不符合隔离性

幻读(Phantom Reads)

  一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。

  一句话:事务A读取到了事务B提交的新增数据,不符合隔离性

脏写是多个事务对同一行记录同时修改引起的,可以通过加锁避免,下面的脏读,不可重复读,幻读,都是事务的隔离级别引起的。需要设置合适的隔离级别

事务隔离级别

“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。

数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。

同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读"和“幻读”并不敏感,可能更关心数据并发访问的能力。

常看当前数据库的事务隔离级别: show variables like ’tx_isolation';

设置事务隔离级别:set tx_isolation='REPEATABLE-READ';

Mysql默认的事务隔离级别是可重复读,用Spring开发程序时,如果不设置隔离级别默认用Mysql设置的隔离级别,如果Spring设置了就用已经设置的隔离级别

锁是计算机协调多个进程或线程并发访问某一资源的机制。

在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。

锁的分类

  • 从性能上分为乐观锁和悲观锁
  • 从数据库操作力度上分为表锁和行锁
  • 从对数据库操作类型分为读锁和写锁(都属于悲观锁)

读锁

读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响,比如:select * from T where id=1 lock in share mode, 单独的 select 是不会加锁的。

写锁

写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁,数据修改操作都会加写锁,查询也可以通过for update加写锁,比如:`select * from T where id=1 for update`

意向锁

意向锁(Intention Lock):又称I锁,针对表锁,主要是为了提高加表锁的效率,是mysql数据库自己加的。当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。

意向锁主要分为:

  1. 意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。
  2. 意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。

表锁

每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景,停掉所有的应用服务器并不能保证不会再向表中写入数据了。可能会有其他DBA再操作该表。最好加一个表锁

表锁的操作
  • 手动增加表锁
    lock table 表名称 read(write),表名称2 read(write);

如, 加读锁 lock table employee read

加写锁
lock table employee write

当前session对该表的增删改查都没有问题,其他session对该表的所有操作被阻塞

  • 查看表上加过的锁
    show open tables;
  • 删除表锁
    unlock tables;

行锁

每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。

InnoDB支持行级锁 MYISAM不支持

InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。

简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞。

事务的隔离级别

读未提交

set tx_isolation='read-uncommitted';

A 事务可以读到B事务中修改的数据,尽管B事务还没有提交 , 如果B事务回滚了,那么A读的就是脏数据,如果A基于这个数据做操作了,就会发生脏写

读已提交

set tx_isolation='read-committed';

假如 用户U1 的 age = 30 有如下时序的操作

  1. A事务开启,读到U1 age 的值为30
  2. B 事务开启, 更新U1 age 为 35
  3. A 事务中读 age 的值为 30
  4. B 事务提交
  5. A 事务中读 age 的值为 35
  6. A 事务提交

在这种隔离级别下,解决了脏写的问题,因为无法读到未提交的数据,只能读到已经提交的数据,但是问题也很明显,在 3 ,5 步骤中,尽管在同一个A事务中,读取同一行数据,不同时间得到的结果不一样,我们以哪次结果为准呢?后面这个值会不会再次发生变化呢?这就是不可重复读的问题

可重复读

set tx_isolation='repeatable-read';

假如 用户U1 的 age = 30 有如下时序的操作

  1. A事务开启,读到U1 age 的值为30
  2. B 事务开启, 更新U1 age 为 35
  3. A 事务中读 age 的值为 30
  4. A事务执行查询语句 select * from employee where age = 40;
  5. B 事务新增一条数据 U2,age = 40;
  6. B 事务提交
  7. A 事务中读 age 的值为 30
  8. A 事务执行SQL语句 update employee set age = age + 5 where id =1;
  9. A事务执行查询语句 select * from employee where age = 40;
  10. A 事务提交

在这种级别下,在A事务读取的结果始终为 30, 无论其他事务是否修改,是否提交。

注意

但是在执行更新的时候,是以此时此刻数据库中最新的数据为准的,A事务提交后,age 的值是 40。而不是35, 因为在步骤8中,对 age 进行修改,是以当前 age 的值为准,也就是 35. 如果是在内存中进行的算术运算,先取出 age 的值,然后用程序将 age = age +5, 然后再将age更新到数据库,此时age 就是35了。发生错误,这一点要注意。

这种可重复读的机制是如何实现的呢?难道每个事务开启的时候,都给表进行快照一次吗?

答案是 MVCC, MVCC 的原理请看下一篇博客

在步骤4, 9中,4 没有结果,9 能查询到结果,就是 B 事务新增的那一条数据,发生了幻读,幻读也是不可重复读的一种,只不过不可重复读是针对数据的更新,幻读是数据的新增或者删除。

由此可见,可重复读的级别也没有完全将事务隔离开。

串行化

set tx_isolation='serializable';

在这种级别下,所有的查询语句和更新语句都会被加上行锁。

这种隔离级别并发性极低,开发中很少会用到。

如果客户端A执行的是一个范围查询,那么该范围内的所有行包括每行记录所在的间隙区间范围(就算该行数据还未被插入也会加锁,这种是间隙锁)都会被加锁。此时如果客户端B在该范围内插入数据都会被阻塞,所以就避免了幻读。

再谈锁

知道了事务的隔离级别后,再看下面的锁

间隙锁 Gap-Lock

例子:

有 employee 表:

  1. A 事务执行 select * from employee where age >= 30 and age <= 40;
  2. B 事务执行 update employee set age = age + 1 where id = 2;
    C 事务执行 insert into employee(id, name, age) values(5, u5, 37);

在步骤1时,会有一个间隙锁,锁的范围是 30 <= age <= 40, B 事务和C事务都会被阻塞。尽管 还没有 age = 37 这条数据。

如果步骤1 执行的是 select * employee where age >= 30 ,那么锁的范围是 (30, 正无穷)

临键锁(Next-key Locks)

Next-Key Locks是行锁与间隙锁的组合。

锁升级

无索引行锁会升级为表锁(RR级别会升级为表锁,RC级别不会升级为表锁)

锁主要是加在索引上,如果对非索引字段更新,行锁可能会变表锁

InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。

锁定某一行还可以用lock in share mode(共享锁) 和for update(排它锁),例如:select * from test_innodb_lock where a = 2 for update; 这样其他session只能读这行数据,修改则会被阻塞,直到锁定行的session提交

锁的一些指标

通过检查innoDB_row_lock状态变量来分析系统上的行锁的争夺情况

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: 系统启动后到现在总共等待的次数

对于这5个状态变量,比较重要的主要是:

  • Innodb_row_lock_time_avg (等待平均时长)
  • Innodb_row_lock_waits (等待总次数)
  • Innodb_row_lock_time(等待总时长)

尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。

查看INFORMATION_SCHEMA系统库锁相关数据表

-- 查看事务 
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 ;

死锁演示

set tx_isolation='repeatable-read';

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;

大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况mysql没法自动检测死锁

锁优化建议

  • 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
  • 合理设计索引,尽量缩小锁的范围
  • 尽可能减少检索条件范围,避免间隙锁
  • 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行
  • 尽可能低级别事务隔离

总结

Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的。当系统并发量高的时候,Innodb的整体性能和MYISAM相比就会有比较明显的优势了。

但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候,可能会让Innodb的整体性能表现不仅不能比MYISAM高,甚至可能会更差。