MySQL事务与锁原理

2.4 MySql事务

2.4.1 概念

  • 数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制、日志机制,用一整套机制来解决多事务并发问题
  • InnoDB和BDB支持事务,但MyISAM和MEMORY不支持事务;
  • 事务起点

begin/start transaction命令并不是一个事务的起点,在执行到他们之后的第一个操作InnoDB表的语句(比如select语句),事务才真正启动,像数据库申请真正的事务id,严格按照事务的启动顺序来分配事务id的;
如果想马上启动事务,命令:start transaction with consistent snapshot

2.4.1 事务特性ACID

  • A(Atomicity)原子性

事务是一个原子操作,对数据的修改,要么全部成功,要么全部失败;
原子性由undolog来实现;

  • C(Consistent)一致性

事务将数据从一个一致状态转移到另一个一致状态;
使用事务的最终目的,由其它3个特性以及业务代码正确逻辑来实现;
代码的正确性是指,当事务内发生会导致数据不一致的异常或错误时,不要catch掉,而是要回滚事务;

  • I(Isolation)隔离性

在事务并发执行时,他们内部的操作不能互相干扰;
隔离性由MySQL的各种以及MVCC机制来实现;

  • D(Durable)持久性

事务完成之后,对数据的修改是永久性的,即使出现系统故障也能保持;
持久性由redolog来实现;

2.4.2 事务隔离级别

事务的隔离级别是为了解决数据库读一致性的问题,比如幻读、脏读、不可重复读等;

隔离级别 脏读 不可重复读 幻读 说明
READ-UNCOMMIT
(读未提交)
可能 可能 可能 事务A可读取事务B未提交修改的数据;
READ-COMMITED
(读已提交)

可能 可能 事务A可读取事务B已提交修改的数据;
oracle默认隔离级别;
REPREATABLE-READ
(可重复读)
可能 事务A不可读取事务B修改已提交的数据,但是可以读到事务B新增已提交的数据;
MySql默认隔离级别;
没有真正地解决幻读,新增数据》修改该数据》查询,还是可以查出新增的数据;
SERILIABLE
(串行化)
读写串行,效率低;
串行化锁表;

可重复读详解:
可重复读隔离级别在事务开启的时候,第一次查询是查的数据库里已提交的最新数据,这时候全数据库会有一个快照,在这个事务之后执行的查询操作都是查快照里的数据,别的事务不管怎么修改数据对当前这个事务的查询都没有影响,但是当前事务如果修改(也包括增、删)了某条数据,那当前事务之后查这条修改的数据就是被修改之后的值,但是查其它数据依然是从快照里查,不受影响。
可重复读级别下,可以通过间隙锁避免幻读,for update

  • 脏读

事务A读取了事务B修改未提交的数据,当数据B回滚后,此修改的数据就是脏数据了;
违反了一致性原则;

  • 脏写

当两个或多个事务选择同一行数据修改,有可能发生更新丢失问题,即最后的更新覆盖了由其他事务所做的更新;
比如在java代码中,事务A将读取的数据运算后再将结果更新到当前行,如果更新前另一个事务B更改了该行该值并已提交了,此时事务A再修改后提交,事务B提交的修改就被覆盖掉了,这就是脏写;

  • 不可重复读

事务A读取了事务B修改已提交的数据,在事务B操作前和操作后同一条件读取的数据不一致了;
违反了隔离性;
可重复读即指事务A读不到事务B已提交的修改数据;

  • 幻读

事务A读取了事务B新增已提交的数据,在区间查询时读取到的数据不一致了,好像出现了“幻觉”;
违反了隔离性;

  • 说明

事务隔离级别越严格,越是趋向于“串行化”,这显然与“并发”是矛盾的;
需要根据应用场景选择不同的事务隔离级别,是趋向于并发性,还是趋向于一致性;
查看事务隔离级别:show variables like 'tx_isolation';
设置事务隔离级别:set tx_isolation='REPEATABLE-READ';
用Spring开发程序时,如果不设置隔离级别默认用Mysql设置的隔离级别,如果Spring设置了就用已经设置的隔离级别;

  • 示例

MySQL事务与锁原理_第1张图片
读未提交:事务A可以读取事务B执行但未提交的修改,此时V1=2,V2=2,V3=2;
读提交:事务A只能读到事务B已提交的数据,此时V1=1,V2=2,V3=2;
可重复读:事务A在开启事务后和提交事务前读取的数据相同,不会受其他事务操作的影响,此时V1=1,V2=1,V3=2;可重复读模式下,会在开启事务时得到一份视图;
串行化:事务开启时执行修改操作,会锁表或锁行,导致其他事务必须等待,此时V1=1,V2=2,V3=2;

2.4.3 MVCC

  • 概念

MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据;

并发事务时,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC);
对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操作加锁互斥来实现的;
Mysql在读已提交(RC)和可重复读(RR)隔离级别下都实现了MVCC机制;
MySQL事务与锁原理_第2张图片

  • MVCC可见性算法

事务中每更新的一条数据,就会生成对应的undo_log版本链,信息包括:更新所需要的字段数据、当前事务id、上个版本事务id;
readview和可见性算法其实就是记录了sql查询那个时刻数据库里提交和未提交所有事务的状态
要实现RR隔离级别,事务里每次执行查询操作readview都是使用第一次查询时生成的readview,也就是都是以第一次查询时当时数据库里所有事务提交状态来比对数据是否可见,当然可以实现每次查询的可重复读的效果了。
要实现RC隔离级别,事务里每次执行查询操作readview都会按照数据库当前状态重新生成readview,也就是每次查询都是跟数据库里当前所有事务提交状态来比对数据是否可见,当然实现的就是每次都能查到已提交的最新数据效果了。
MySQL事务与锁原理_第3张图片
可重复读隔离级别,当事务开启,执行任何查询sql时会生成当前事务的一致性视图read-view该视图在事务结束之前永远都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成read-view),这个视图由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id(max_id)组成,事务里的任何sql查询结果需要从对应版本链里的最新数据开始逐条跟read-view做比对从而得到最终的快照结果。
版本链比对规则:

  1. 如果 row 的 trx_id 落在绿色部分( trx_id
  2. 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的);
  3. 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况
        a. 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的);
        b. 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。

对于版本连中数据被删除的情况:
可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位写上true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果delete_flag标记位为true,意味着记录已被删除,则不返回数据。

  • 回滚日志何时删除

在不需要的时候删除,就是系统里没有比这个回滚日志更早的read-view的时候;

2.4.4 MySql两阶段提交

MySQL事务与锁原理_第4张图片
两阶段提交的作用:在数据库crash时,如果redolog写成功了,binlog失败了,在数据库重启时,可以通过redolog恢复崩溃前数据库数据,但是此时binlog的数据不完整,在通过binlog做备份时,备份库就会少了一个操作,导致线上库和备份库数据不一致。
redlog保证crash-safe能力,innodb_flush_log_at_trx_commit=1表示每次事务redolog都直接持久化到磁盘,sync_binlog=1每次事务的binlog都是持久化到磁盘。
两阶段提交是prepare和commit状态,但是数据库的commit是什么操作?
mysql的server层通过binlog记录日志;
InnoDB特有的通过redolog记录日志;
mysql server层执行器提交事务时,InnoDB存储引擎先写redolog日志,日志状态为pre(状态A),server层继续写binlog日志(状态B),存储引擎再将redolog日志状态改为commit(状态C),此时事务完成;
如果在状态A或状态B时发生数据库crash,在重启时会通过对比redolog和binlog状态来判断是回滚还是commit。特别的,如果在状态B,存储引擎判断redolog状态为pre,但binlog已存在,此时会将redolog状态置为commit,保证了两阶段提交数据binlog和redolog的一致性;

2.4.5 事务优化

(1)大事务的影响

  • 并发情况下,数据库连接池容易被撑爆;
  • 锁定太多的数据,造成大量的阻塞和锁超时;
  • 执行时间长,容易造成主从延迟;
  • 回滚所需要的时间比较长;
  • undo log膨胀;
  • 查询执行时间超过1秒的事务
SELECT
	* 
FROM
	information_schema.innodb_trx 
WHERE
	TIME_TO_SEC( timediff( now( ), trx_started ) ) > 1; -- 1表示1秒
 
#强制结束事务
kill 事务对应的线程id(就是上面语句查出结果里的trx_mysql_thread_id字段的值)

(2)事务优化

  • 将查询等数据准备操作放到事务外;
  • 事务中避免远程调用,远程调用要设置超时,防止事务等待时间太久;
  • 事务中避免一次性处理太多数据,可以拆分成多个事务分次处理;
  • 更新等涉及加锁的操作尽可能放在事务靠后的位置;

更新放在新增后面,更新会加锁,更新后马上提交释放锁,减少该条记录的加锁时间;

  • 能异步处理的尽量异步处理;
  • 应用侧(业务代码)保证数据一致性,非事务执行;

(3)其他问题

  • 多个查询操作是否需要放在事务里?

一般情况不需要;
当前隔离级别为RR,并且业务要求同一时间点的数据必须一致(两个相同的查询中间有时间间隔),则需要将查询操作放在事务里。如果不放在事务里,两个查询中间时间段数据库数据被修改了(已提交),后面查询的就是数据库最新的数据,与第一个查询结果就不一样了。

2.5 MySql锁

2.5.1 知识点

  • 什么是锁

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

2.5.2 锁操作力度分类

  • 全局锁

数据库备份时使用,MyISAM之类无事务的引擎才会使用lock tables锁库操作,InnoDB之类支持事务的引擎不建议使用锁库,建议使用single-taransaction参数;

  • 表锁

每次操作锁住整张表;
开销小、加锁快,给表加个标识即可,不用扫描整张表;
不会出现死锁;
锁粒度大,发生锁冲突概率最高,并行度最低;
一般用在数据迁移的场景;
加表读锁,当前session和其他session都可以读该表,当前session插入或更新锁定的表会报错,其他session插入或更新都等待;
加表写锁,当前session对当前表增删改查都可以,其他session对该表所有操作都阻塞;

--手动增加表锁
lock table 表名称 read(write),表名称2 read(write);
--查看表上加过的锁
show open tables;
--删除表锁
unlock tables;
  • 页锁

BDB存储引擎支持页锁,页即为主键索引对应的节点;

  • 行锁

每次操作锁住当前行;
开销大、加锁慢;
会出现死锁;
锁粒度小,发生锁冲突低,并行度高;
行锁是加在索引项上的,不是针对整个记录加的锁,如果索引失效,会导致行锁变为表锁(RR级别会升级为表锁,RC级别不会升级为表锁)
比如我们在RR级别执行如下sql:

select * from account where name = 'lilei' for update;   --where条件里的name字段无索引

则其它Session对该表任意一行记录做修改操作都会被阻塞住;
两阶段锁协议:在InnoDB事务中,行锁在需要的时候加上,但并不是在不需要时释放,而是要等到事务结束后才释放;
行锁导致死锁:循环依赖行锁会导致死锁,通过并发锁的语句后移,减少锁的持有时间;默认开启了死锁检查,消耗cpu性能,业务逻辑控制访问mysql行的并发数;

加行锁:select * from table_name where id=123 for update;
查看行锁:show status like 'innodb_row_lock%';
  • 范围锁(间隙锁)

间隙锁,锁的就是两个值之间的空隙,间隙锁是在可重复读(RR)隔离级别下才会生效
Mysql默认级别是repeatable-read,有幻读问题,间隙锁是可以解决幻读问题的。
假设account表里数据如下:
MySQL事务与锁原理_第5张图片
那么间隙就有 id 为 (3,10),(10,20),(20,正无穷) 这三个区间,在Session_1下面执行如下sql:

select * from account where id = 18 for update;

则其他Session没法在这个(10,20)这个间隙范围里插入任何数据。
如果执行下面这条sql:

select * from account where id = 25 for update;

则其他Session没法在这个(20,正无穷)这个间隙范围里插入任何数据。
也就是说,只要在间隙范围内锁了一条不存在的记录会锁住整个间隙范围,不锁边界记录,这样就能防止其它Session在这个间隙范围内插入数据,就解决了可重复读隔离级别的幻读问题。

  • 临键锁(Next-key Locks)

Next-Key Locks是行锁与间隙锁的组合,开区间变闭区间。

2.5.3 锁操作类型分类

  • 读锁(共享锁)

针对同一份数据,多个读操作可以同时进行而不会相互影响,但不允许其他事务修改;
事务中所有对表的增删改查,都需要先获取表的MDL(元数据锁)读锁。说明:如果事务A执行查询语句A1,事务B执行删除表语句B1,事务A执行查询语句A2,此时如果没有MDL元数据锁,会导致事务异常;
四种隔离级别,只有“串行化”隔离级别才会自动加读锁;
示例:

select * from t where id=1 lock in share mode;
  • 写锁(排它锁)

当前写操作(insert/update/delete)没有完成之前,它会阻断其他读锁和写锁;
查询操作也可以加写锁,示例:

select * from t where id=1 for update;
  • 意向锁

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

2.5.4 锁性能分类

  • 乐观锁

通过版本version对比或CAS机制实现乐观锁,更新失败后重试;
适用于读多写少的场景;
重试可以借助于Spring注解@Retryable;

  • 悲观锁

每次修改都会认为有争抢,读锁和写锁都属于悲观锁;
适用于写多读少的场景,读多写少的场景用悲观锁会导致比对次数过多,影响性能;
示例:

select * from user for update;

2.5.5 锁优化

尽可能让所有数据检索通过索引来完成,避免让行锁升级为表锁;
合理设计索引,减少锁范围;
尽可能减少查询条件范围,避免间隙锁;
尽量控制事务大小,减少锁定的数据量和时间长度,涉及事务加锁的sql尽量放在事务最后执行;
尽可能低级别事务隔离;
尽量不要用长事务,因为开启事务时,会创建回滚日志,如果事务段过长,导致回滚日志过长,占用磁盘资源;

  • 锁等待分析

通过检查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;
-- 查看锁,8.0之后需要换成这张表performance_schema.data_locks
select * from INFORMATION_SCHEMA.INNODB_LOCKS;  
-- 查看锁等待,8.0之后需要换成这张表performance_schema.data_lock_waits
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;  

-- 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id

-- 查看锁等待详细信息
show engine innodb status; 
  • 死锁

多个事务相互竞争锁导致死锁;
比如,事务A获取id=1的行锁,事务B获取id=2的行锁,事务A获取id=2的行锁,事务B获取id=1的行锁,锁相互等待阻塞导致死锁;

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没法自动检测死锁,这种情况我们可以通过日志分析找到对应事务线程id,可以通过kill杀掉。

你可能感兴趣的:(#,MySQL,mysql,数据库,mysql事务,mysql锁,mvcc)