一、事务
概念
事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚
- 事务最经典也经常被拿出来说例子就是转账了
- 假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:将小明的余额减少 1000 元,将小红的余额增加 1000元, 万一在这两个操作之间突然出现错误比如银行系统崩溃,导致小明余额减少而小红的余额没有增加,这样就不对了
- 事务就是保证这两个关键操作要么都成功,要么都要失败
ACID
1. 原子性(Atomicity)
事务被视为不可分割的最小单元,事务的所有操作要么全部完成,要么完全不起作用
回滚可以用回滚日志(Undo Log)来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可
2. 一致性(Consistency)
数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对同一个数据的读取结果都是相同的
- 例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的
3. 隔离性(Isolation)
并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间是独立的
- 事务执行时就好像它是数据库上唯一正在执行的事务 (意即不受其他事务影响)
4. 持久性(Durability)
一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失
系统发生崩溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改
事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:
- 只有满足一致性,事务的执行结果才是正确的
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性
- 事务满足持久化是为了能应对系统崩溃的情况
AUTO COMMIT
MySQL 默认采用自动提交模式。也就是说,如果不显式使用START TRANSACTION
语句来开始一个事务,那么每个查询操作都会被当做一个事务并自动提交
二、并发一致性问题
在并发环境下,事务的隔离性很难保证,例如 多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对统一数据进行操作), 因此会出现很多并发一致性问题
1. 丢失修改 — Lost Update
- 丢失修改指一个事务的更新操作被另外一个事务的更新操作替换
- 一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据
- 例如:T1 和 T2 两个事务都对一个数据进行修改,T1 先修改并提交生效,T2 随后修改,T2 的修改覆盖了 T1 的修改
2. 读脏数据 — Dirty read
- 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据
- 因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据 “脏数据” 所做的操作可能是不正确的
- 例如:T1 修改一个数据但未提交,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据
- 总结: 脏读发生在 修改数据 → 提交数据 之间
3. 不可重复读 — Non repeatable read
- 不可重复读指在一个事务内多次读取同一数据。在这一事务还未结束前,另一事务也访问了该同一数据集合并做了修改
- 由于第二个事务的修改,第一次事务的两次读取的数据可能不一致, 这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读
- 例如:T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
4. 幻读 — Phantom read
- 幻读本质上也属于不可重复读的情况
- 一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读
不可重复读和幻读的区别
- 不可重复读的重点是修改,幻读的重点在于新增或者删除
- 例 1 -(同样的条件, 你读取过的数据, 再次读取出来发现值不一样了):事务 1 中的 A 先生读取自己的工资为 1000 的操作还没完成,事务 2 中的 B 先生就修改了 A 的工资为 2000,导 致A再读自己的工资时工资变为 2000;这就是不可重复读。
- 例 2 -(同样的条件, 第1次和第2次读出来的记录数不一样 ):假某工资单表中工资大于 3000 的有 4 人,事务1读取了所有工资大于 3000 的人,共查到 4 条记录,这时事务 2 又插入了一条工资大于 3000 的记录,事务 1 再次读取时查到的记录就变为了 5条,这样就导致了幻读。
产生并发不一致性问题的主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
三、封锁
封锁粒度
MySQL (InnoDB) 中提供了两种封锁粒度:行级锁以及表级锁
- 应该尽量只锁定需要修改的那部分数据 (选择合适的粒度),而不是所有的资源
- 锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高
但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销
- 因此封锁粒度越小,系统开销就越大
在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡
封锁类型
1. 读写锁
- 互斥锁(Exclusive),简写为 X 锁,又称写锁。
- 共享锁(Shared),简写为 S 锁,又称读锁。
有以下两个规定:
- 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁
- 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁
在 InnoDB 中
- 对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会自动给涉及的数据集加排他锁(X)
- 对于普通 SELECT 语句,InnoDB 会自动给涉及数据集加共享锁(S)
锁的兼容关系如下:
2. 意向锁
使用意向锁(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 锁失败
各种锁的兼容关系如下:
解释如下:
- 任意 IS / IX 锁之间都是兼容的,因为它们只表示想要对表加锁,而不是真正加锁
- 这里兼容关系针对的是表级锁,而表级的 IX 锁和行级的 X 锁兼容,两个事务可以对两个数据行加 X 锁
- E.g. 事务 T1 想要对数据行 R1 加 X 锁,事务 T2 想要对同一个表的数据行 R2 加 X 锁,两个事务都需要对该表加 IX 锁,但是 IX 锁是兼容的,并且 表级 IX 锁与行级的 X 锁也是兼容的,因此两个事务都能加锁成功,对同一个表中的两个数据行做修改。
封锁协议
1. 三级封锁协议
一级封锁协议
- 事务 T 要修改数据 A 之前必须对其加 X 锁,直到事务 T 结束才释放锁, 事务结束包括正常结束(COMMIT)和非正常结束 (ROLLBACK)
- 可以解决丢失修改问题, 但不能保证可重复读和不读“脏”数据
- 因为我们要求如果一个事务想要去修改数据,读数据之前要加上 X 锁,所以如果两个事务都要去修改的话,前一个在读的时候加上了锁,另外一个事物在读取的时候也需要加锁,但是 X 锁只能上一个,于是就就只能等了,所以就解决了更新丢失问题
二级封锁协议
- 一级封锁协议再加上,事务 T 在读取数据 A 之前必须对其加 S 锁,读取完马上释放 S 锁
- 可以解决丢失修改, 读脏数据问题, 但是不能保证可重复读
- 如果一个事务 A 正在修改数据,加上了 X 锁,然后另外一个事务 B 打算来读取数据,B 如果不需要加锁就可以去读数据, 那么 B 就有可能够读取到 A 事务没有提交的数据,导致了脏读
- 所以, 二级协议就是在一级协议的基础上规定,一个事务要读取数据之前,必须要加 S 锁,如果另外一个事务正在写,就会导致当前读事务被阻塞了,也就解决了脏读问题
- 由于是读完马上释放 S 锁, 所以一个事务中有可能多次获取和释放 S 锁, 这就导致了可能在一个事务中多次获取的数据不一样,也就是不可重复读
三级封锁协议
- 在一级封锁协议的基础上增加事务 T 在读取数据 A 之前必须加 S 锁,直到事务结束了才能释放 S 锁
- 可以解决丢失修改, 读脏数据, 不可重复读的问题
- 三级协议同样是补充了二级协议,二级协议在一个事务中可能会多次获取和释放 S 锁,这就导致了可能在一个事务中多次获取的数据不一样,也就是不可重复读
- 因为在事务结束前,数据 A 上一直有 S 锁,其他事务不能再在 A 上加 X 锁来修改 A,在读取期间数据的值不会发生改变
2. 两段锁协议
加锁和解锁分为两个阶段进行
可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。串行执行的事务互不干扰,不会出现并发一致性问题。
事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度
但不是必要条件,例如以下操作不满足两段锁协议,但它还是可串行化调度
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)
MySQL 隐式与显示锁定
MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。
InnoDB 也可以使用特定的语句进行显示锁定:
SELECT ... LOCK In SHARE MODE;
SELECT ... FOR UPDATE;
四、隔离级别
读未提交(READ UNCOMMITTED)
- 最低的隔离级别,允许读取尚未提交的数据变更 (一个事务还未提交, 它的变更就能被别的事务看到)
- 可能会导致脏读、幻读或不可重复读
读已提交(READ COMMITTED)
- 允许读取并发事务已经提交的数据,换句话说,一个事务所做的修改在提交之前对其它事务是不可见的
- 可以阻止脏读,但是幻读或不可重复读仍有可能发生
可重复读(REPEATABLE READ)
- 一个事务过程中读取到的数据, 总是跟这个事务启动的时候读到的数据是一致的
- 可以阻止脏读和不可重复读,但幻读仍有可能发生
可串行化(SERIALIZABLE)
- 最高的隔离级别,完全服从 ACID 的隔离级别
- 所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰
- 也就是说,该级别可以防止脏读、不可重复读以及幻读
参考:
https://www.cyc2018.xyz/
https://tech.meituan.com/2014/08/20/innodb-lock.html