事务

简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。MySQL中事务支持是在引擎层实现的。事务拥有四个重要的特性:原子性、一致性、隔离性、持久性,简称为ACID特性,下文将逐一解释。

ACID特性

  • 原子性(Atomicity)
    • 事务开始后所有操作,要么全部完成,要么全部不做,不存在只执行一部分的情况。
  • 一致性(Consistency)
    • 事务开始之前和事务结束之后,数据库的完整性限制未被破坏。
  • 隔离性(Isolation)
    • 在一个事务未执行完毕时,其它事务无法读取该事务的数据。
    • MySQL通过锁机制来保证事务的隔离性。
  • 持久性(Durability)
    • 事务一旦提交,数据将被保存下来,即使发生宕机等故障,数据库也能将数据恢复。
    • MySQL使用redo log来保证事务的持久性。

ACID关系如下图所示:
ACID关系

  • 一致性分为约束一致性和数据一致性:
    • 约束一致性:创建表结构时指定的外键,Check,唯一索引等约束,MySQL不支持Check
    • 数据一致性:由原子性、持久性、隔离性共同保证的结果
  • 原子性:通过Write Ahead Log(WAL)技术实现。
  • 持久性:一旦事务提交,通过原子性,即便是遇到宕机,也可以从逻辑上将数据找回来后再次写入物理存储空间,这样就从逻辑和物理两个方面保证了数据不会丢失
  • 隔离性:一个事务执行不能被其它事务干扰,锁和多版本控制符合隔离性。

并发事务控制

单版本控制-锁

锁用独占的方式保证只有一个版本的情况下事务相互隔离。

多版本控制-MVCC

MVCC(Multi-Version Concurrency Control)即多版本并发控制。

MVCC 是通过数据行的多个版本管理来实现数据库的并发控制,简单来说它的思想就是保存数据的历史版本。这样我们就可以通过比较版本号决定数据是否显示出来。读取数据的时候不需要加锁也可以保证事务的隔离效果。

每次对数据库的修改,都会在Undo日志中记录当前修改记录的事务版本号以及修改前数据状态的存储地址,以便在必要的时候可以回滚到老的数据版本。

MVCC的核心是Undo Log + Read View。

MVCC主要解决以下几个问题:

  • 读写之间阻塞的问题,通过MVCC可以让读写互相不阻塞,提高并发处理能力。
  • 降低了死锁的概率,MVCC采用了乐观锁的方式,读取数据时不需要加锁,对于写操作,只锁定必要的行。
  • 解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。

MVCC最大的好处是读不加锁,读写不冲突,在MVCC中,读操作可分为快照读(Snapshot Read)和当前读(Current Read)。

  • 快照读:读取的是记录的可见版本,不用加锁
  • 当前读:读取的是记录的最新版本,并且当前读返回的记录,都会加锁,保证其它事务不会并发修改这条记录。

# 不加锁的SELECT都是快照读
SELECT * FROM player WHERE ...

# 加锁的SELECT和增删改都是用当前读
SELECT * FROM player LOCK IN SHARE MODE;
INSERT INTO player values ...

MVCC只在Read CommitedRepeatable Read两种级别下工作。核心是Undo Log + Read View。

Read VIew

在 MVCC 机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在 Undo Log 里。而Read View保存了不应该让这个事务看到的其他的事务ID列表。在Read Commited级别下,每一次SELECT查询都会获取一次Read View。如果两次读取的Read View不同,就会产生不可重复读和幻读的情况。

并发事务问题

丢失更新

丢失更新就是两个事务在并发下同时进行更新,后一个事务的更新覆盖了前一个事务更新的情况。基本情景如下表:

时间 事务A 事务B
1 开启事务 开启事务
2 a = 100 a = 100
3 / a -= 10
4 / commit
5 / /
6 a += 20 /
7 commit /

脏读

一个事务读取了另一个未提交的事务写的数据,被称为脏读。基本情景如下:

时间 事务A 事务B
1 开启事务 开启事务
2 a = 100 a = 100
3 / a = 110
4 a = 110 /
5 / rollback

不可重复读

一个事务读取到另一个事务已经提交的修改数据。基本情景如下:

时间 事务A 事务B
1 开启事务 开启事务
a = 100 a = 100
3 / a = 110
4 a = 110 commit

幻读

一个事务按查询条件检索数据,发现其它事务插入了满足条件的数据,这被称为幻读。基本情景如下:

时间 事务A 事务B
1 开启事务 开启事务
2 select * from table where id = 1(记录为0条) /
3 / insert into table (id) value(1)
4 / commit
5 insert into table (id) value(1) /
6 commit(报错,主键冲突) /

隔离级别

由于数据库在并发事务中带来一些问题,数据库提供了事务隔离机制来解决相对应的问题。数据库的锁也是为了构建这些隔离级别而存在的。

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read)
未提交读(Read uncommited) 可能 可能 可能
已提交读(Read commited) 不可能 可能 可能
可重复读(Repeatable read) 不可能 不可能 可能
可串行化(Serializable) 不可能 不可能 不可能
  • 未提交读(Read Uncommitted):一个事务还没提交时,它的修改能被别的事务看到。
  • 提交读(Read Committed):一个事务提交后,它的修改才能被其它事务看到。
  • 可重复读(Repeated Read):一个事务执行过程中看到的数据,总是跟这个事务开始时刻是一致的。
  • 可串行化(Serializable):对于同一行记录,写会加写锁,读会加读锁,当出现读写冲突的时候,后面的事务必须等待前一个事务执行完成才能继续执行。

查询与修改隔离级别的SQL语句:

-- 查看系统隔离级别:
select @@global.tx_isolation;
-- 查看当前会话隔离级别
select @@tx_isolation;
-- 设置当前会话隔离级别
SET session TRANSACTION ISOLATION LEVEL serializable;
-- 设置全局系统隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

ACID的实现

原子性的实现

每一个写事务,都会修改Buffer Pool,并产生对应的Redo日志,Redo日志以Write Ahead Log方式写,如果不写日志,数据库宕机恢复后,事务无法回滚,无法保证原子性。

持久性的实现

通过WAL可以保证逻辑上的持久性,物理上的持久性通过存储引擎的数据刷盘实现。

隔离性的实现

通常用Read View表示一个事务的可见性,读提交状态每一条读操作语句都会获得一次Read View,每次更新都会获取最新事务的提交状态,即每条语句执行都会更新其可见性视图。可重复读的隔离级别下,可见性视图只有在自己当前事务提交之后,才会更新,所以与其他事务没有关系。

在MySQL中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,可以得到之前一个状态的值。假设一个值从1被按照顺序改成了2,3,4,在回滚日志中会有类似下面的记录。

记录

当前值是4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的read-view,在视图A、B、C中,记录的值分别为1、2、4。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC )。对于read-view A要得到1,就必须将当前值依次执行图中所有的回滚操作得到。

即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C对应的事务是不会冲突的。

一致性的实现

一致性是通过其它三个特性来保证的。而其它三个特性由Redo、Undo来保证的。

在MySQL中有三种级别的锁:页级锁,表级锁,行级锁。其中表锁包括读锁和写锁。

  • 表级锁:开销小,加锁快,不会出现死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低,会发生在:MyISAM、memory、InnoDB、BDB 等存储引擎中。
  • 行级锁:开销大,加锁慢,会出现死锁,锁定粒度最小,发生锁冲突的咖概率最低,并发度最高,会发生在:InnoDB存储引擎
  • 页级锁:开销和加锁时间介于表锁和行锁之间,会出现死锁,锁定粒度介于表锁和行锁之间,并发度一般,会发生在BDB存储引擎中。

InnoDB的锁

InnoDB中,锁分为行锁和表锁,行锁包括两种锁。

  • 共享锁(S):共享锁锁定的资源可以被其它用户读取,但不能修改,在进行SELECT的时候,会将对象进行共享锁锁定,数据读取完毕,释放共享锁,保证在读取的过程中不被修改。
  • 排他锁(X):锁定的数据只允许进行锁定操作的事务使用,其它事务无法对已锁定的数据进行查询和修改
# 给product_comment加上共享锁
LOCK TABLE product_comment READ;

# 解锁
UNLOCK TABLE;

# 对 user_id=912178 的数据行加上共享锁
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 LOCK IN SHARE MODE

# 给 product_comment 数据表添加排它锁
LOCK TABLE product_comment WRITE;

# 解锁
UNLOCK TABLE;

# 对 user_id=912178 的数据行加上排他锁
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;

InnoDB为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁,也都是表锁,表锁分为三种:

  • 意向共享锁(IS):事务计划给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
  • 自增锁(AUTO-INC Locks):特殊表锁,自增长计数器通过该“锁”来获得子增长计数器最大的计数值。自增主键会涉及自增锁,在INSERT结束后立即释放。

意向锁,简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子,你可以给整个房子设置一个标识,告诉它里面有人,即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权,只需要看这个房子的标识即可,不需要再对房子中的每个房间进行查找。

如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。

在加行锁之前必须先获得表级意向锁,否则等待 innodb_lock_wait_timeout 超时后根据innodb_rollback_on_timeout 决定是否回滚事务。

锁关系矩阵如下图所示:

InnoDB锁关系矩阵

InnoDB行锁是通过对索引数据页上的记录加锁实现的,主要有三种算法:

  • Record锁:单个行记录的锁(锁数据,不锁Gap)
  • Gap锁:间隙锁,锁定一个范围,不包括记录本身(不锁数据,锁数据前面的Gap)
  • Next-key锁:锁数据并且锁Gap,可以解决幻读问题。

InnoDB死锁

由于InnoDB是逐行加锁的,极容易产生死锁,产生死锁的四个条件:

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程以获得的资源,在没有使用完之前,不能强行剥夺。
  • 循环等待条件:多个进程之前形成的一种互相循环等待资源的关系。

在发生死锁时,InnoDB 存储引擎会自动检测,并且会自动回滚代价较小的事务来解决死锁问题。但很多时候一旦发生死锁,InnoDB 存储引擎的处理的效率是很低下的或者有时候根本解决不了问题,需要人为手动去解决。

避免死锁产生的建议:

  • 更新SQL的where条件尽量用索引
  • 加锁索引准确,缩小锁定范围
  • 减少范围更新,尤其非主键/非唯一索引的范围更新。
  • 控制事务大小,减少锁定数据量和锁定时间长度
  • 加锁顺序一致,尽可能一次性锁定所有所需的数据行。

InnoDB如何解决幻读的

在可重复读的情况下,InnoDB可以通过Next-key LockMVCC解决幻读问题。

在读已提交的情况下,即使使用MVCC方式也会出现幻读。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

深入浅出MySQL索引 上一篇
ThreadLocalRondom原理剖析 下一篇