数据库之事务与实现原理
# 为什么需要事务
典型的就是转账问题了,转账是生活中常见的操作,比如从A账户转账100元到B账号。站在用户角度而言,这是一个逻辑上的单一操作,然而在数据库系统中,至少会分成两个步骤来完成:
- 将A账户的金额减少100元
- 将B账户的金额增加100元。
这个时候可能会出现问题:
- 转账操作的第一步执行成功,A账户上的钱减少了100元,但是第二步执行失败或者未执行便发生系统崩溃,导致B账户并没有相应增加100元。
- 转账操作刚完成就发生系统崩溃,系统重启恢复时丢失了崩溃前的转账记录。
- 同时又另一个用户转账给B账户,由于同时对B账户进行操作,导致B账户金额出现异常。
# 什么是数据库事务
事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。
BEGIN TRANSACTION //事务开始
SQL1
SQL2
COMMIT/ROLLBACK //事务提交或回滚
2
3
4
# ACID
# 原子性(Atomicity)
事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。 回滚可以用回滚日志来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
# 一致性(Consistency)
数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对一个数据的读取结果都是相同的。
# 隔离性(Isolation)
一个事务所做的修改在最终提交以前,对其它事务是不可见的。并发执行的事务不会相互影响,其对数据库的影响和它们串行执行时一样。比如多个用户同时往一个账户转账,最后账户的结果应该和他们按先后次序转账的结果一样。
# 持久性(Durability)
一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。使用重做日志来保证持久性。
# 事务中的ACID
事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久化是为了能应对数据库崩溃的情况。
# 并发一致性问题
在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。
# 丢失更新
丢失更新是指事务覆盖了其他事务对数据的已提交修改,导致这些修改好像丢失了一样。
T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。
# 脏读
脏读是指一个事务读取了另一个事务未提交的数据,在事务1对A的处理过程中,事务2读取了A的值,但之后事务1回滚,导致事务2读取的A是未提交的脏数据。
# 脏写
脏写是指事务回滚了其他事务对数据项的已提交修改,比如下面这种情况,在事务1对数据A的回滚,导致事务2对A的已提交修改也被回滚了。
# 不可重读读
T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
# 幻读
T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。
产生并发不一致性问题主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。 事务的隔离级别越低,可能出现的并发异常越多,但是通常而言系统能提供的并发能力越强。
# 事务的隔离级别
事务具有隔离性,理论上来说事务之间的执行不应该相互产生影响,其对数据库的影响应该和它们串行执行时一样。 然而完全的隔离性会导致系统并发性能很低,降低对资源的利用率,因而实际上对隔离性的要求会有所放宽,这也会一定程度造成对数据库一致性要求降低。
SQL标准为事务定义了不同的隔离级别,从低到高依次是:
- 读未提交(READ UNCOMMITTED)
- 读已提交(READ COMMITTED)
- 可重复读(REPEATABLE READ)
- 串行化(SERIALIZABLE)
# 读未提交(READ UNCOMMITTED)
事务中的修改,即使没有提交,对其它事务也是可见的。
# 读已提交(READ COMMITTED)
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
# 可重复读(REPEATABLE READ)
保证在同一个事务中多次读取同样数据的结果是一样的。
# 串行化(SERIALIZABLE)
强制事务串行执行。需要加锁实现,而其它隔离级别通常不需要。
# MySQL中的事务隔离级别的操作
#查询隔离级别
SELECT @@transaction_isolation
SHOW variables like '%transaction_isolation%';
#设置隔离级别
SET transaction_isolation='READ-UNCOMMITTED';#读未提交
SET transaction_isolation='READ-COMMITTED';#读已提交
SET transaction_isolation='REPEATABLE-READ';#可重复读
SET transaction_isolation='SERIALIZABLE';#串行化
2
3
4
5
6
7
8
# 事务隔离级别的实现-并发控制技术
并发控制技术是实现事务隔离性的关键,实现方式有多种,并发控制策略可以分为两类:
- 乐观并发控制:对于并发执行可能冲突的操作,假定其不会真的冲突,允许并发执行,直到真正发生冲突时才去解决冲突,比如让事务回滚。
- 悲观并发控制:对于并发执行可能冲突的操作,假定其必定发生冲突,通过让事务等待(锁)或者中止(时间戳排序)的方式使并行的操作串行执行。
# 基于封锁的并发控制
# 封锁粒度
MySQL 中提供了两种封锁粒度:行级锁以及表级锁。 应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。 但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销。因此封锁粒度越小,系统开销就越大。 在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。
# 锁的种类
1.读写锁
- 排它锁(Exclusive),简写为 X 锁,又称写锁。加了X锁,其他事务什么锁都不能加。
- 共享锁(Shared),简写为 S 锁,又称读锁。加了S锁其他事务可以加S锁,不能加X锁。
2.意向锁(Intention Locks)
使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。 意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:
- 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
- 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。
通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。
各种锁的兼容关系如下:
- | X | IX | S | IS |
---|---|---|---|---|
X | × | × | × | × |
IX | × | √ | × | √ |
S | × | √ | √ | √ |
IS | × | √ | √ | √ |
解释如下:
- 任意 IS/IX 锁之间都是兼容的,因为它们只是表示想要对表加锁,而不是真正加锁;
- S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S 锁,其它事务可以已经获得对表或者表中的行的 S 锁。
# 三级锁与两段锁协议
# 三级封锁协议
三级封锁协议就是对锁使用的规定,来解决事务并发一致性问题。
a.一级封锁-解决丢失更新
事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。 可以解决丢失更新问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。
b.二级封锁-解决脏读
在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。
可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。
c.三级封锁-解决不可重复读
在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。
可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。
# 两段锁协议
加锁和解锁分为两个阶段进行。
可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。
事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
但不是必要条件,例如以下操作不满足两段锁协议,但是它还是可串行化调度。
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
# MySQL隐式与显示锁定
MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。
InnoDB 也可以使用特定的语句进行显示锁定:
SELECT ... LOCK In SHARE MODE; #S锁
SELECT ... FOR UPDATE; #X锁
2
# 基于时间戳的并发控制
核心思想:对于并发可能冲突的操作,基于时间戳排序规则选定某事务继续执行,其他事务回滚。
系统会在每个事务开始时赋予其一个时间戳,这个时间戳可以是系统时钟也可以是一个不断累加的计数器值,当事务回滚时会为其赋予一个新的时间戳,先开始的事务时间戳小于后开始事务的时间戳。
每一个数据项Q有两个时间戳相关的字段: W-timestamp(Q):成功执行write(Q)的所有事务的最大时间戳 R-timestamp(Q):成功执行read(Q)的所有事务的最大时间戳
具体排序方式就是:
- 假设事务T发出read(Q),T的时间戳为TS a. 若TS(T)<W-timestamp(Q),则T需要读入的Q已被覆盖。此 read操作将被拒绝,T回滚。 b. 若TS(T)>=W-timestamp(Q),则执行read操作,同时把 R-timestamp(Q)设置为TS(T)与R-timestamp(Q)中的最大值
- 假设事务T发出write(Q) a.若TS(T)<R-timestamp(Q),write操作被拒绝,T回滚。 b.若TS(T)<W-timestamp(Q),则write操作被拒绝,T回滚。 c.其他情况:系统执行write操作,将W-timestamp(Q)设置 为TS(T)。
基于时间戳排序和基于锁实现的本质一样:对于可能冲突的并发操作,以串行的方式取代并发执行,因而它也是一种悲观并发控制。它们的区别主要有两点:
- 基于锁是让冲突的事务进行等待,而基于时间戳排序是让冲突的事务回滚。
- 基于锁冲突事务的执行次序是根据它们申请锁的顺序,先申请的先执行;而基于时间戳排序是根据特定的时间戳排序规则。
# 基于有效性检查的并发控制
核心思想:事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。
基于有效性检查的事务执行过程会被分为三个阶段:
- 读阶段: 数据项被读入并保存在事务的局部变量中。所有write操作都是对局部变量进行,并不对数据库进行真正的更新。
- 有效性检查阶段: 对事务进行有效性检查,判断是否可以执行write操作而不违反可串行性。如果失败,则回滚该事务。
- 写阶段: 事务已通过有效性检查,则将临时变量中的结果更新到数据库中。
有效性检查通常也是通过对事务的时间戳进行比较完成的,不过和基于时间戳排序的规则不一样。
该方法允许可能冲突的操作并发执行,因为每个事务操作的都是自己工作空间的局部变量,直到有效性检查阶段发现了冲突才回滚。因而这是一种乐观的并发策略。
# 基于多版本并发控制(MVCC)与快照隔离
# 什么是MVCC
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
可以认为MVCC是行级锁的一个变种,但是在很多情况下又避免了加锁,所以效率比较高。
MySQL的InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列实现:
- 创建版本号:指示创建一个数据行的快照时的系统版本号;
- 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。
其中系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。事务版本号:事务开始时的系统版本号。
MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。
# 实现过程
以下实现过程针对可重复读隔离级别。
当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。
1.SELECT
①只查找版本早于当前事务版本的数据行(行的系统版本号小于等于事务的系统版本号),这样可以保证要么数据行是之前存在的,要么就是自己这个事务自己修改的。
②查找行的删除版本号要么大于当前事务版本号,要么未定义。这样可以保证这个数据行没有被删除的。
2.INSERT
将当前系统版本号作为数据行快照的创建版本号。
3.DELETE
将当前系统版本号作为数据行快照的删除版本号。
4.UPDATE
将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。
# 快照读与当前读
1.快照读
使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。
select * from table ...;
2.当前读 读取的是最新的数据,不去读快照,需要加锁。以下第一个语句需要加 S 锁,其它都需要加 X 锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
2
3
4
5