Overview

了解事务的 ACID 特性、隔离级别、并发控制、锁……

事务

ACID 特性

  • **原子性(atomicity):**整个事务中的操作要么都做,要么都不做。
  • **一致性(consistency):**事务不能改变数据库的一致性,如事务中的操作打破了定义的约束条件,这时候就得回滚。
  • **隔离性(isolation):**事务之间相互隔离,事务未提交之前,其更改对其他事务都不可见。
  • **持久性(durability):**事物一旦提交,其结果就是永久性的,即使崩溃也要能够恢复。

隔离级别

  • 事务并发时的 数据不一致性 问题:
    • 脏读:读到了其他事务已修改但尚未提交的数据。
    • 不可重复读:一个事务中多次读取相同记录,发现内容不一致,因为另一个事务进行了修改操作并已提交。
    • 幻读:一个事务中多次读取的数据条数不一致,另一个事务进行了增删改操作。
  • 为了解决上述问题,提出的隔离级别:
    • 读未提交(Read Uncommitted):一个事务可以看到其他事务已修改但尚未提交的数据。
    • 读已提交(Read Committed,RC):一个事务只能看到其他事务已经提交的数据。
    • 可重复读(Repeatable Read,RR):一个事务在执行过程中看到的数据,总是和它启动时看到的一样。
    • 可串行化(Serializable):读写都要加锁,所有操作均可以转化为一个串行化序列,避免并行访问。

注意:隔离级别越高,并发度越低,MySQL 默认为可重复读。

隔离级别脏读不可重复读幻读
读未提交
读已提交×
可重复读××
可串行化×××

并发控制

事务的隔离级别是 为了解决数据的不一致性问题,是通过加锁的手段实现的,但如果只是单纯的加锁,如加读写锁使其可串行化避免并行访问,业务方面那当然不会有问题。只是加锁的同时我们还要考虑其并发性,我们知道读读是不冲突的,而读写、写写都会冲突,如果有一个办法能够保证一致性读的同时还能够进行写操作(即一致性非锁定读),那是极好的。

这时候就有了 MySQL 的 多版本并发控制(Multiple Version Concurrent Control,MVCC)。

多版本并发控制(MVCC)

MVCC 的目的就是实现一致性非锁定读,这就需要为每个事务维护一个 一致性快照(consistent snapshot),这个快照当然不是物理上真实存在的,而是通过给每行记录加上一个 版本号 再配合 undo log 实现的。

undo log:当记录更新时,都会在 undo log 中记录一条回滚操作,即更新的逆操作,通过这个逆操作我们可以使记录回滚到之前的一个状态。当系统内没有比某条 undo log 更早的快照时,这条 undo log 就可以删除。

MVCC 是针对 RC、RR 两个隔离级别而言的,区别就在于 RR 下一致性快照是在事务开启时创建的,RC 下事务中的每一条语句都会开启一个一致性快照,达到读已提交的效果。

MySQL 通过 MVCC 解决 RR 下的不可重复读,但是为什么 RC 也要使用 MVCC,因为 MVCC 不只解决了重复读问题,还提供了一致性非锁定读。

  • 下面针对 MySQL 的默认隔离级别 RR 对 MVCC 进行讨论:

InnoDB 会按照严格的递增顺序为每个事务分配一个 唯一的事务 ID,记为 trx_id;而前面提到的给表中每一行记录都附加了一个 版本号,记为 row_id,就是 用来记录某事务 ID 的,指示该行数据的当前版本是被哪一个事务更新过的

历史版本数据并不会真实的保存,而是需要时通过 undo log 进行还原,undo log 除了记录更新操作对应的逆操作之外,还记录了 trx_id 以指示这个更新是哪个事务做的。

在一个事务开启的瞬间,InnoDB 为该事务创建了一个 数组,用来记录此刻系统内正在活动的事务 ID。这个数组中的最大值(即当前事务 ID)和最小值将表中的记录按照其 row_id 分为了三个部分:

  1. 小于最小值的部分,都是已提交的事务更新过的数据,则当前事务可以看到。

  2. 中间部分

    1. 若记录的 row_id 在当前事务的数组中,说明当前事务开启的瞬间,row_id 指向的事务还没提交,当前事务看不到。

    2. 若记录的 row_id 不在当前事务的数组中,说明当前事务开启的瞬间,row_id 指向的事务已经提交,当前事务可以看到。

    • 注意:此处就是 RR 和 RC 的区别,对于 RC 只要提交了都能看到,而对于 RR,只要当前事务开启的瞬间,没提交的事务即便后来已经提交,当前事务也看不见。
  3. 大于最大值的部分,都是未来事务提交过的数据,当前事务看不到。

利用这个机制,查询时根据版本号判断某记录是不是当前事务能够看到的,若能看到,则返回该记录,若看不到,则使用 undo log 回滚到第一个碰到的、当前事务能看到的记录,再将其返回。

RR下某事务的查询过程

  • 注意:
  1. 对于默认的查询操作,那要判断版本号,而对于更新操作,采用 “当前读” 操作,即只获取最新版本。因为若更新时也判断能不能看得到,就会丢失未来事务做的更改。
  2. 使用默认的查询称为 一致性非锁定读,即没有加读锁。而使用 select ... for updateselect ... lock in share mode 则会使用 一致性锁定读,即正常的加读、写锁(是行锁),保证了读取的是最新版本同时也不能让后面的事务进行更新操作。一致性锁定读说是锁定,但也不会影响后面的读,因为后面的读会进行 undo 找到可用版本。

如何解决幻读

  • 注意:RR 解决不了幻读,Innodb 通过间隙锁来改善幻读。说改善是 因为对于 当前读 来说,RR 是无法避免幻读的,但 Innodb 使用间隙锁防止了数据的新增操作,结合行锁,防止了删除操作。
间隙锁

来自MySQL实战45讲

RR 隔离级别在 当前读 的情况下无法解决幻读问题,串行化代价又太高,而究其原因,幻读是因为其他事务在当前事务第一次查询时返回的结果集范围内部区域进行了增删改操作。只通过行锁不能解决这个问题,MySQL 使用间隙锁(Gap Lock)配合 行锁改善 这个问题。

间隙锁:即两条数据之间的空隙,给这个空隙加上锁,以防止其他事务在当前事务第一次当前读产生的结果集范围内增删数据。

  • 注意:
    • RR 非当前读不会出现幻读问题,因为一开始就建立了一致性快照。
    • 间隙锁之间、间隙锁和行锁之间都不会冲突,间隙锁只和往这个间隙之中插入的操作发生冲突
    • 间隙锁 + 行锁称为 “next-key lock”
加锁机制
  1. 查询过程中访问到的记录都会被加锁。
    • 注意:
      • 即使没找到记录也会加间隙锁,提交时释放。
      • 若查询的是辅助索引,不回表就不会在主键索引上加锁。
  2. 加锁的单位是 next-key lock,是一个左开右闭区间。
  3. 当进行等值查询时,有两点优化:
    1. 给唯一索引加锁时,next-key lock 降级为行锁,即只会锁一条记录。
    2. 给普通索引加锁时,遇到第一个不满足等值条件的记录时,next-key lock 降级为间隙锁,即最后一个区间为间隙锁。
  4. 一个 BUG:唯一索引上的范围查询会访问到第一个不满足条件的值为止,并且不会降级为间隙锁。
  • start transaction VS begin

    前者执行后就会开启事务,后者执行到第一条语句前才会开启事务。


锁的目的:解决并发访问下的数据一致性问题。

  • 全局锁:对整个数据库实例加锁,典型的应用场景是数据库备份。
    • 使用命令:Flush tables with read lock。
  • 表锁 分为两种:
    • 表锁:lock tables … read/write
      • 是由 MySQL Server 提供的。
    • 元数据锁(meta data lock,MDL):也分读、写锁,不需要显式使用,当 访问一个表时 就会自动加上,作用是阻塞对表结构的变更。MDL 写锁会阻塞后面的读操作。
    • Innodb 中有 意向锁(Intention Lock), 意向共享锁 IS,意向排它锁 IX,当申请相应的 行级锁 S 或 X 时,必须先持有相应的意向锁。
      • IS、IX 不会和行级锁冲突,只会和表级锁冲突。
      • 意向锁的意义:要加表锁的时候需要判断两点,①当前表上有没有表锁;②当前表上有没有行级锁;在判断第二点的时候,如果一行行去查看非常浪费时间,所以引入意向锁,只要表上有意向锁就说明表中有相应的行级锁,从而阻塞加表锁的操作。
    • Innodb 什么时候会使用表锁?
      • 非索引字段作为查询条件而引起的全表操作;
      • 索引失效而引起的全表操作;
      • 更新表结构、多表复杂的联合查询的时候。
  • 行锁:分读写锁还有上面 2.2 中提到的间隙锁。
  • 注意:
    • Innodb 会自动为外键索引列创建索引,为了防止表锁;
    • Innodb 的表锁由 MySQLServer 提供;
  • 加锁的方式:

两段锁协议:将事务加锁和解锁分为锁的扩张和收缩两阶段,一旦开始收缩就不能再申请锁。若并发执行的事务遵守两段锁协议,则这些事务的调度都将是可串行化的,但仍有可能发生死锁。

事务开始后就处于加锁阶段,一直到执行 ROLLBACK 或 COMMIT 之前都是加锁阶段,ROLLBACK 和 COMMIT 使事务进入解锁阶段。给我们的启示是:将最有可能冲突的锁最后申请,减少加锁的时间,提高并发度。

  • 一个简单的死锁
/*
A 使用 S 锁,锁住一条记录;
B 尝试删除该条记录,则 B 尝试加的 X 锁会阻塞在 A 的 S 锁上;
A 尝试删除该记录,会阻塞在 B 的 X 锁上;
造成死锁。
*/
A: select * from t where id = 1 lock in share mode;
B: delete from t where id = 1;
A: delete from t where id = 1;