5 锁 & 事务 & MVCC

前言

本文仅个人的学习所记随笔,如有不对之处,还有各位大佬斧正!谢谢!

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

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

1 事务

1 概念

事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚

2 ACID

  • 原子性

    事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚

    回滚可以用日志 (undo log) 来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可

  • 一致性

    数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对一个数据的读取结果都是相同的

  • 隔离性

    一个事务所做的修改在最终提交以前,对其它事务是不可见的

  • 持久性

    一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。

    可以通过数据库备份和恢复来实现,在系统发生崩溃时,使用备份的数据库进行数据恢复


事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:

  • 只有满足一致性,事务的执行结果才是正确的

  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性

  • 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性

  • 事务满足持久化是为了能应对数据库崩溃的情况

 

3 AUTOCOMMIT

MySQL 默认采用自动提交模式。也就是说,如果不显式使用 start transaction 语句来开始一个事务,那么每个查询都会被当做一个事务自动提交

2 并发一致性问题

  • 丢失修改

    T1和T2两个事务都对一个数据进行修改,T1先修改,T2随后修改,T2的修改覆盖了T1的修改

  • 读脏数据

    T1修改一个数据,T2随后读取这个数据。如果T1撤销了这次修改,那么T2读取的数据是无效,不符合一致性要求

  • 不可重复读

    T2读取一个数据,T1对该数据做了修改。如果T2再次读取这个数据,此时读取的结果和第一次读取的结果不同,不符合隔离性

  • 幻影读

    T1读取某个范围的数据,T2在这个范围内插入新的数据,T1再次读取这个范围的数据,此时读取的结果和第一个读取的结果不同,不符合隔离性

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

  • 未提交读(READ UNCOMMITTED) 事务中的修改,即使没有提交,对其它事务也是可见的。

  • 提交读(READ COMMITTED) 一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。

  • 可重复读(REPEATABLE READ) 保证在同一个事务中多次读取同样数据的结果是一样的。

  • 可串行化(SERIALIZABLE) 强制事务串行执行。

隔离级别脏读不可重复读幻影读
未提交读
提交读×
可重复读××
可串行化×××
  1. 查看事务隔离级别

    SELECT @@TRANSACTION_ISOLATION;

  2. 设置事务隔离级别

    SET [ SESSION | GLOBAL ] TRANSACTION ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE }

注意:事务隔离级别越高,数据越安全,但是性能越低。

3 锁

从对数据库的操作类型分:

  • 读锁(共享锁,简写为 S 锁)

    针对同一份数据,多个读操作可以同时进行而不会互相影响;

  • 写锁(排他锁,简写为 X 锁)

    当前写操作未完成前,它会阻断其他写锁和读锁

从对数据操作的粒度上看分:

  • 表锁

  • 行锁

1 表锁

表级锁,每次操作锁住整张表。锁定粒度大,发生锁冲突的概率最高,并发度最低;一般应用于整表数据迁移的场景,应用在MyISAM、 InnoDB、BDB等存储引擎中

语法

  • 加锁:lock table 表名称 read(write),表名称2 read(write);

  • 释放锁:unlock tables / 客户端断开连接

案列分析(加读锁)

# 对表进行加读锁
lock table sys_role read;
​
# 释放锁
unlock table;


 

案列分析(加写锁)


 

案列结论

  • 对表的读操作(加读锁) ,不会阻寒其他进程对同一表的读请求,但会阻赛对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作

  • 对表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作

2 行锁

行级锁,每次操作锁住对应的行数据。锁定粒度最小,发生锁冲突的概率最低,并发度最高。应用在 InnoDB存储引擎中

可以通过以下SQL,查看意向锁及行锁的加锁情况

# mysql 8.0 可用
select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks;

常见的SQL语句,在执行时,所加的行锁如下:

SQL行锁类型说明
insert排他锁自动加锁
update排他锁自动加锁
delete排他锁自动加锁
select(正常)不加任何锁
select ...... lock in mode共享锁需要手动在 select 之后加 lock in mode
select ...... for update排他锁需要手动在 select 之后加 for update

行锁案例

 

 

案例小结

简而言之,就是写锁(X锁,排他锁)阻塞写锁和读锁;读锁(S锁,共享锁)会阻塞写,但是不会阻塞读

3 意向锁

使用意向锁( Intention Locks )可以更容易的支持多粒度的封锁

在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加上 X 锁,就需要先检测是否有其他事务对表 A 或者表 A 中的任意一行加上了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的

意向锁在原来的 X / S 锁之上引入了 IX / IS,IX / IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁 或 S 锁。有以下两个规定:

  • 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁

  • 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁

通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其他事务对表 A 加了 X / IX / S / IS 锁,如果加了就表示有其他事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败

各种锁的兼容关系如下:

-XIXSIS
X××××
IX××
S××
IS×

解释如下:

  • 任意 IS / IX 锁之间都是兼容的,因为它们只是想要对表加锁,而不是真正加锁

  • S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S 锁,其他事务可以获得对表或者表中的行的 S 锁

4 锁协议(锁 & 事务并发)

  • 一级封锁协议

    事务 T 要修改数据 A 时必须加上 X 锁,直到事务结束才释放锁

    可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖

    T1T2
    lock - X(A)
    read A = 20
    lock - x(A)
    wait
    write A = 19...
    commit...
    unlock - X(A)...
    obtain
    read A = 19
    write A = 21
    commit
    unlock - X(A)
  • 二级封锁协议

    在一级的基础上,要求读取数据时必须加上 S 锁,读取完马上释放 S 锁

    可以解决脏读问题,因为一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据

    T1T2
    lock - X(A)
    read A = 20
    write A = 19
    lock -S(A)
    wait
    rollback...
    A = 20...
    unlock - X(A)...
    obtain
    read A = 20
    unlock - S(A)
    commit
  • 三级封锁协议

    在二级的基础上,要求读取数据时必须加 S 锁,直到事务结束了才能释放 S 锁

    可以解决不可重复的问题,因为读 A 时,其他事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变

    T1T2
    lock - S(A)
    read A = 20
    lock - X(A)
    wait
    read A = 20...
    commit...
    unlock - S(A)...
    obtain
    read A = 20
    write A = 19
    commit
    unlock - X(A)
  • 两段锁新协议

    加锁和解锁分为两个阶段进行

    可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同

    事务遵循两段锁协议是保证可串行化调度的充分条件;例如以下操作满足两段锁协议,它是可串行化调度

    lock-x(A) ... lock-s(B) ... lock-s(C) ... unlock(A) ... unlock(C) ... unlock(B)

    但不是必要条件;例如以下操作不满足两段锁协议,但是它还是可串行化调度

    lock-x(A) ... unlock(A) ... lock-s(B) ... unlock(B) ... lock-s(C) ... unlock(C)

4 多版本并发控制

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现

1 版本号

  • 系统版本号:是一个递增的数字,每开始一个事务,系统版本号就会自动递增

  • 事务版本号:事务开始之前的系统版本号

2 隐藏的列

MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:

  • 创建版本号:指示创建一个数据行的快照时的系统版本号

  • 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了

隐藏字段含义
DB_TRX_ID最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID
DB_ROLL_PTR回滚指针,指向这条记录的上一个版本,用于配合 undo log,指向上一个版本

3 快照读与当前读

  • 当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁

    select ... lock in share mode(共享锁),select ... for update(排他锁)、update、insert、delete 都是一种当前读

  • 快照读:简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读

    • read committed:每次select,都生成一个快照读

    • repeatable read:开启事务后第一个 select(不加锁) 语句才是快照读的地方,后续的 select(不加锁)都是快照读,保证了可重复读

    • serializable:快照读会退化为当前读

4 undo log

回滚日志,在 insert 、update 、delete 的时候产生的便于数据回滚的日志

当 insert 的时候,产生的 undo log 日志只在回滚时需要,在事务提交后,可被立即删除

而 update 、delete 的时候,产生的 undo log 日志不仅在回滚时需要,在快照读时也需要,不会立即被删除

1 版本链

有一条新增的原始数据:

idagenameDB_TRX_IDDB_ROLL_PTR
3030A301null

假设有4个事务同时在访问这个数据:

  • 第一步

T1T2T3T4
beginbeginbeginbegin
update age = 3select id = 30
commit
update name = A3
select id = 30
commit
update age = 10
select id = 30
select id = 30
commit
commit;

当 T1 执行第一条修改语句时,会记录 undo log 日志,记录数据变更之前的样子;然后更新记录, 并且记录本次操作的事务ID

回滚指针:用来指定如果发生回滚,回滚到哪一个版本

 

  • 第二步

当 T2 执行修改语句时,也会记录 undo log日志,记录数据变更之前的样子; 然后更新记录,并且记录本次操作的事务ID

 

  • 第三步

当 T2 执行修改语句时,也会记录 undo log日志,记录数据变更之前的样子; 然后更新记录,并且记录本次操作的事务ID

同第二步

不同事务或相同事务对同一条记录进行修改,会导致该记录的 undo log 生成一条记录版本链表

链表的头部是最新的旧记录,链表尾部是最早的旧记录

5 ReadView

ReadView 是快照读 SQL 执行时 MVCC 提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id

ReadView中包含了四个核心字段:

字段含义
m_ids当前正在执行且还没有提交的事务 id
min_trx_idm_ids中最小的事务 id
max_trx_id下一个要生成的事务 id
creator_trx_id每开启一个事务都会生成一个 ReadView,而 creator_trx_id 就是这个开启的事务的 id

而在readview中就规定了版本链数据的访问规则:

  • trx_id 代表当前 undo log 版本链对应事务 ID

条件是否可以访问说明
trx_id == creator_trx_id可以访问说明数据是当前这个事务更改的
trx_id < min_trx_id可以访问说明数据已经提交了
trx_id >= max_trx_id不可以访问说明该事务是在 ReadView 生成后才开启的
min_trx_id <= trx_id < max_trx_id如果 trx_id 不在 m_ids 中,是可以访问的说明数据已经提交

此处如果 m_ids 为 [5,6,7,9,10]

  • 若 trx_id 在 m_ids 中,比如是 6:说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问

  • 若 trx_id 不在 m_ids 中,比如是 8:说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问

不同的隔离级别,生成ReadView的时机不同:

  • read committed:在事务中每一次执行快照读时生成 ReadView

  • repeatable read:仅在事务中第一次执行快照读时生成 ReadView,后续复用该 ReadView

6 实现流程

T1T2T3T4
beginbeginbeginbegin
update age = 3select id = 30
commit
update name = A3
select id = 30
commit
update age = 10
select id = 30
select id = 30
commit
commit

1 RR

假设事务的隔离等级为:repeatable read

当 T4 执行第一次的查询语句时,m_ids 中应该包含了 T2、T3 两个正在活跃的事务,m_ids = [ 2, 3 ];max_trx_id 为下一个开启事务的 Id;当前的事务 Id 为 4;所以 max_trx_id = 5;

 

当 T4 第一次执行查询语句时,该行当前数据的 undo log 如下图所示:

当 T4 查询时,会拿该行记录的 DB_TRX_ID 去跟 min_trx_id、max_trx_id、m_ids 进行比较

 

先拿记录中的 DB_TRX_ID 去跟记录中的 min_trx_id 比较,看 4 是否小于 2;不符合就继续判断是否大于等于 max_trx_id;不符合就继续判断是否符合 min_trx_id <= trx_id < max_trx_id;如果还不符合就查找该记录的 undo log 链表中的下一个记录;继续判断

当只要满足一个条件时,判断结束

 

5 Next-Key Locks

Next-Key Locks 是 MySQL 的 InnoDB 存储引擎的一种锁实现,是记录锁(record lock)与间隙锁(gap lock)的结合

MVCC 不能解决幻读的问题,Next-Key Locks 就是为了解决这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Locks 可以解决幻读问题

假设数据:

idnamebalance
1lilei450
2hanmei10000
3lucy2400
10xlp1000
20wjz2000

那么以上数据的间隙就有(3,10)、(10,20)、(20,+∞)三个区间

如果有个 session 执行以下的语句:

update stu set name = 'aaa' where id > 8 and id < 18

那么就会加 Next-Key Locks 锁住 10 U (3,10)U(10,20】:表示对 id = 10 的索引项加锁 + 对 id 在(3,10)加间隙锁 + 对 id 在(10,20】加间隙锁

间隙锁唯一目的是防止其他事务插入间隙

间隙锁可以共存,一个事务采用的间隙锁不会 阻止另一个事务在同一间隙上采用间隙锁

间隙锁是在可重复读隔离级别下才会生效

6 补充

在 InnoDB 的 RR 级别中,当快照读和当前读同时使用时,仍然可能会发生不可重复读

1 RR出现不可重复读

假设数据:

idnameage
1xlp23

假设有以下两个事务:

T1T2
beginbegin
select id = 1update age = 18
commit
update name = aaa
select id = 1

显然 T1 在第二次执行查询语句时出现了不可重复读

1 为什么出现不可重复读

在 T2 执行 update 语句时采用的是当前读,会对这条记录进行加锁、修改 age、修改 trx_id = 2(假设在插入数据时,trx_id = 1)

在 T1 执行 update 语句时也是当前读,所以记录的 trx_id = 1

在 T1 第二次执行查询语句时,采用的是快照读,根据可见性算法,这条记录最新的 trx_id 就是当前活跃的事务,所以是可见的

所以 T1 在第二次查询时读取到了 T2 的修改,这就表示发生了不可重复读

2 如何解决

可以使用行锁解决

在第一次查询时,语句使用 for update,为记录加上行锁

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值