MySQL-MVCC原理以及数据库锁实战

多个事务并发对同一批数据进行增删改查操作时,可能会导致脏写、脏读、不可重复读、幻读问题,MySQL为解决这些问题,设计了事务隔离级别、锁机制、MVCC多版本并发控制隔离机制来解决这些问题。

锁与事务隔离级别

事务隔离级别

读未提交(Read uncommitted):

一个事务可以读取另一个未提交事务的数据。

当A、B两事务同时更新通一条数据时,如果A查询到B未提交的数据,此时B事务回滚,则A查询到的数据就是脏读。

读已提交(Read committed):

一个事务要等另一个事务提交后才能读取数据。

当A事务查询数据、B事务更新数据时,A在B事务提交前后都查询一次数据,两次数据会不一致,导致了不可重复读。

可重复读(Repeatable read):

一个事务A读取另一个事务B的数据,不管事务B是否提交,A读取到的数据是一致的。

可重复读的隔离级别下使用了MVCC(multi-version concurrency control)机制,select操作不会更新版本号,是快照读(历史版本);insert、update和delete会更新版本号,是当前读(当前版本),所以每次读都是相同数据,但是B事务添加后的数据,在A事务中执行更新语句,是可以执行成功的,这就导致了幻读。

是数据库默认隔离级别。

序列化(Serializable):

事务串行执行,只有当事务A操作完成,B事务才能提交。

表锁

每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景。

行锁

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

间隙锁(Gap Lock)

间隙锁,锁的就是两个值之间的空隙。

示例:客户端A,并设置当前事务模式为serializable(序列化),客户端A执行的是一个范围查询,那么该范围内的所有行包括每行记录所在的间隙区间范围(就算该行数据还未被插入也会加锁,这种是间隙锁)都会被加锁。

行锁升级表锁

当无索引时,行锁会升级为表锁。InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁,所以对非索引字段更新,行锁可能会变表锁。

死锁

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方的资源,从而导致恶性循环的现象。

例如:

  1. 事务A执行

select * from account where id=1 for update;
  1. 事务B执行

select * from account where id=2 for update;
  1. 事务A执行

select * from account where id=2 for update;
  1. 事务B执行

select * from account where id=1 for update;

此时A会等待B释放id=2的锁,而B会等待id=1的锁,导致相互之间循环等待对方资源,造成死锁。当然,大多数情况下mysql可以自动监测死锁并回滚产生死锁的那个事务,就如上例,当执行第4步时,mysql会报错,并且回滚事务B,而不会造成死锁。

锁的优化

  1. 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁

  1. 合理设计索引,尽量缩小锁的范围

  1. 尽可能减少检索条件范围,避免间隙锁

  1. 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行

  1. 尽可能低级别事务隔离

MVCC机制

对于读已提交、可重复读的事务隔离级别,MySQL是如何做到的呢?

MVCC多版本并发控制机制(Multi-Version Concurrency Control),其是通过Undo日志中的版本链ReadView一致性视图来实现的。MVCC就是在多个事务同时存在时,SELECT语句找寻到具体是版本链上的哪个版本,然后在找到的版本上返回其中所记录的数据的过程。

相关重要概念:

事务版本号:

每次事务开启前都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。

表格的隐藏列:

DB_ROW_ID(row_id):隐式主键。没有主键、没有唯一性索引时,mysql自动生成的id,所以这个字段不一定有。

DB_TRX_ID:记录当前事务的事务ID

DB_ROLL_PTR(roll_pointer):回滚指针。即undo日志的上一条指针。

undo日志版本链:

undo日志版本链是指一行数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据undo回滚日志,并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链

Read view:

在innodb 中每个事务开启后都会得到一个read_vies。副本主要保存了当前数据库系统中未提交的事务的ID,其实简单的说这个副本中保存的是系统中当前不应该被本事务看到的其他事务id列表。

版本链比对规则:

  1. 如果 row 的 trx_id 落在绿色部分( trx_id<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的;

  1. 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若row 的 trx_id 就是当前自己的事务是可见的);

  1. 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况:

  1. 若 row 的 trx_id 在视图(read-view)数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的);

  1. 若 row 的 trx_id 不在视图(read-view)数组中,表示这个版本是已经提交了的事务生成的,可见

MVCC执行过程

假设表account表字段有:

  1. id:自增主键id

  1. name:姓名

先添加一条数据:

begin;
insert into account values('张三');
commit;

下面模拟MVCC操作:

执行流程:

  1. 事务A:开始事务,开始执行update语句,执行前获取事务id:100;

  1. 事务A:执行update操作,先把历史数据拷贝到undo日志,对数据进行修改,并且将DB_ROLL_PTR指向上一个历史数据的undo日志。

  1. 事务B:开启事务,执行select语句,执行前获得事务id:110;

  1. 事务B:获得一个read_view:

  1. 事务B:开始匹配read_view:

  1. 读undo日志中第一条数据

  1. 事务100>=min_id;

  1. 事务100<=max_id;

  1. 事务100在[100,110]数组中,故undo日志中第一条数据不可见;

  1. 开始匹配历史版本数据,即第二条,事务90<min_id;故此条数据中的值可见

  1. 事务B:获取到数据并返回:


版权声明:本文为qq_39338954原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。