事务管理

default

关系型数据库中的事务管理、并发控制与日志管理

在并发读写数据库时,读操作可能会不一致的数据(脏读)。为了避免这种情况,需要实现数据库的并发访问控制,最简单的方式就是加锁访问。加锁会将读写操作串行化,避免出现不一致的状态;但是读操作会被写操作阻塞,大幅降低读性能。实际上很多数据库中都会采用 Multi-Version Concurrent Control(MVCC) 机制或者其变种,在 MVCC 协议下,每个读操作会看到一个一致性的 Snapshot,并且可以实现非阻塞的读。MVCC 允许数据具有多个版本,这个版本可以是时间戳或者是全局递增的事务 ID,在同一个时间点,不同的事务看到的数据是不同的。

事务基础

ACID

事务提供一种全做,或不做(All or Nothing)的机制,即将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。数据库事务具有 ACID 属性,即原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),在分布式事务 https://url.wx-coder.cn/7p8Xx 中我们也会讨论分布式系统中应该如何实现事务机制。

ACID 包含了描述事务操作的整体性的原子性,描述事务操作下数据的正确性的一致性,描述事务并发操作下数据的正确性的隔离性,描述事务对数据修改的可靠性的持久性。针对数据库的一系列操作提供了一种从失败状态恢复到正常状态的方法,使数据库在异常状态下也能够保持数据的一致性,且面对并发访问时,数据库能够提供一种隔离方法,避免彼此间的操作互相干扰。

  • 原子性(Atomicity):整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。例如:银行转账,从 A 账户转 100 元至 B 账户,分为两个步骤:从 A 账户取 100 元;存入 100 元至 B 账户。这两步要么一起完成,要么一起不完成。

  • 一致性(Consistency):在事务开始之前和事务结束以后,数据库数据的一致性约束没有被破坏;即当事务 A 与 B 同时运行,无论 A,B 两个事务的结束顺序如何,数据库都会达到统一的状态。

  • 隔离性(Isolation):数据库允许多个并发事务同时对数据进行读写和修改的能力,如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。 例如:现有有个交易是从 A 账户转 100 元至 B 账户,在这个交易事务还未完成的情况下,如果此时 B 查询自己的账户,是看不到新增加的 100 元的。

  • 持久性(Durability):当某个事务一旦提交,无论数据库崩溃还是其他未知情况,该事务的结果都能够被持久化保存下来。

隔离级别

SQL 标准定义了 4 类隔离级别,包括了一些具体规则,用来限定事务内外的哪些改变是可见的,哪些是不可见的。低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。

隔离级别

脏读(Dirty Read )

不可重复读(NonRepeatable Read )

幻读(Phantom Read )

未提交读(Read Uncommitted)

可能

可能

可能

提交读(Read Committed )

不可能

可能

可能

可重复读(Repeatable Read )

不可能

不可能

可能

可串行化(Serializable )

不可能

不可能

不可能

Read Uncommitted | 未提交读

在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。

Read Committed 提交读

这是大多数数据库系统的默认隔离级别比如 Sql Server, Oracle 等,但不是MySQL默认的。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的 Commit,所以同一查询可能返回不同结果。

Repeatable Read | 重复读

当隔离级别设置为 Repeatable Read 时,可以避免不可重复读。不可重复读是指事务 T1 读取数据后,事务 T2 执行更新操作,使 T1 无法再现前一次读取结果。具体地讲,不可重复读包括三种情况:

  • 事务 T1 读取某一数据后,事务 T2 对其做了修改,当事务 T1 再次读该数据时,得到与前一次不同的值。例如,T1 读取 B=100 进行运算,T2 读取同一数据 B,对其进行修改后将 B=200 写回数据库。T1 为了对读取值校对重读 B,B 已为 200,与第一次读取值不一致。

  • 事务 T1 按一定条件从数据库中读取了某些数据记录后,事务 T2 删除了其中部分记录,当 T1 再次按相同条件读取数据时,发现某些记录神密地消失了。

  • 事务 T1 按一定条件从数据库中读取某些数据记录后,事务 T2 插入了一些记录,当 T1 再次按相同条件读取数据时,发现多了一些记录,也就是幻读。

这是 MySQL 的默认事务隔离级别,它确保在一个事务内的相同查询条件的多次查询会看到同样的数据行,都是事务开始时的数据快照。虽然 Repeatable Read 避免了不可重复读,但还有可能出现幻读。简单说,就是当某个事务在读取某个范围内的记录时,另外的一个事务又在该范围内插入新的记录。在之前的事务在读取该范围的记录时,就会产生幻行,InnoDB 通过间隙锁(next-key locking)策略防止幻读的出现。

Serializable | 序列化

Serializable 是最高的事务隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。该隔离级别代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读。

并发控制

并发控制旨在针对数据库中对事务并行的场景,保证 ACID 中的一致性(Consistency)与隔离性(Isolation)。假如所有的事务都仅进行数据读取,那么事务之间并不会有冲突;而一旦某个事务读取了正在被其他事务修改的数据或者两个事务修改了相同的数据,那么数据库就必须来保证事务之间的隔离,来避免某个事务因为未见最新的数据而造成的误操作。解决并发控制问题最理想的方式就是能够每当某个事务被创建或者停止的时候,监控所有事务的所有操作,判断是否存在冲突的事务,然后对冲突事务中的操作进行重排序以尽可能少地减少冲突,而后以特定的顺序运行这些操作。绝大部分数据库会采用锁(Locks)或者数据版本控制(Data Versioning)的方式来处理并发控制问题。

数据库技术中主流的三种并发控制技术分别是: Multi-version Concurrency Control (MVCC), Strict Two-Phase Locking (S2PL), 以及 Optimistic Concurrency Control (OCC),每种技术也都有很多的变种。在 MVCC 中,每次写操作都会在旧的版本之上创建新的版本,并且会保留旧的版本。当某个事务需要读取数据时,数据库系统会从所有的版本中选取出符合该事务隔离级别要求的版本。MVCC 的最大优势在于读并不会阻塞写,写也不会阻塞读;而像 S2PL 这样的系统,写事务会事先获取到排他锁,从而会阻塞读事务。PostgreSQL 以及 Oracle 等 RDBMS 实际使用了所谓的 Snapshot Isolation(SI)这个 MVCC 技术的变种。Oracle 引入了额外的 Rollback Segments,当写入新的数据时,老版本的数据会被写入到 Rollback Segment 中,随后再被覆写到实际的数据块。PostgreSQL 则是使用了相对简单的实现方式,新的数据对象会被直接插入到关联的 Table Page 中;而在读取表数据的时候,PostgreSQL 会通过可见性检测规则(Visibility Check Rules)来选择合适的版本。

锁管理器(Lock Manager)

数据库使用锁是为了对共享资源进行并发访问控制,从而保证数据的完整性和一致性。在根据加锁的范围(Lock Granularity),可以分为:全局锁、表级锁、行锁。全局锁会把整个数据库实例加锁,将使数据库处于只读状态,其他数据写入和修改表结构等语句会阻塞,一般在备库上做全局备份使用。而表级锁有两种,一种是表锁,和读写锁一样,另外一种是元数据锁,也叫意向锁,不需要显示申明,当执行修改表结构,加索引的时候会自动加元数据写锁,对表进行增删改查的时候会加元数据读锁。这样当两条修改语句的事务之间元数据锁都是读锁不互斥,但是修改表结构的时候执行更新由于互斥就需要阻塞。还有一种行级锁称为间隙锁,他锁定的是两条记录之间的间隙,防止其他事务往这个间隙插入数据,间隙锁是隐式锁,是存储引擎自己加上的。

表锁更适用于以查询为主,只有少量按索引条件更新数据的应用;行锁更适用于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用。

基于锁的方式基础理念为:如果某个事务需要数据,则对数据加锁,操作完毕后释放锁;如果过程中其他事务需要锁,则需要等到该事务释放数据锁,这种锁也就是所谓的排他锁(Exclusive Lock)。不过使用排他锁会带来极大的性能损耗,其会导致其他那些仅需要读取数据的事务也陷入等待。从锁的策略上,可以分为共享锁与排他锁:

  • 共享锁又称读锁,是读取操作创建的锁。其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。

  • 排他锁又称写锁,如果事务 T 对数据 A 加上排他锁后,则其他事务不能再对 A 加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。

MySQL 实战 https://url.wx-coder.cn/Tu5dq 中我们也讨论了如何触发锁机制,譬如查询加锁,select * from testlock where id=1 for update;,即查询时不允许更改,该语句在自动提交为 off 或事务中生效,相当于更改操作,模拟加锁;而更新类操作 update testlock name=name; 则是会自动加锁。

MVCC

并发编程导论 https://url.wx-coder.cn/Yagu8 中我们讨论了两种不同类型的锁:乐观锁(Optimistic Lock)与悲观锁(Pessimistic Lock),前文介绍的各种锁即是悲观锁,而 MVCC(Multiple Version Concurrency Control) 这样的基于数据版本的锁则是乐观锁,它能够保证读写操作之间不会相互阻塞:

  • 每个事务都可以在同一时间修改相同的数据;

  • 每个事务会保有其需要的数据副本;

  • 如果两个事务修改了相同的数据,那么仅有单个更改操作会被接收,另一个操作会被回滚或者重新执行。

乐观锁,大多是基于数据版本(Version)记录机制实现。数据版本即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 version 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

image.png

总结而言,MVCC 能够为我们解决如下的问题:

  • 保证了事务周期内数据的一致性。事务 A 开启后,即使事务 B 对数据做了修改/新增/删除,不管事务 B 有没有提交,这些变更对于事务 A 的 SELECT 语句都是不可见的,因为这些变更是其它事务发起的,并且是在事务 A 开启后发生的。也就是说,它解决了不可重复读和幻读的问题。

  • 提高了数据库的并发性能,试想,如果一个数据只有一个版本,那么多个事务对这个数据进行读写是不是需要读写锁来保护? 加锁的话就会造成阻塞,阻塞就会降低并发性能。而在 MVCC 里,每一个事务都有对应的数据版本,事务 A 开启后,即使数据被事务 B 修改,也不影响事务 A 那个版本的数据,事务 A 依然可以无阻塞的读取该数据,当然,只是读取不阻塞,写入还是阻塞的,如果事务 A 也想修改该数据,则必须要等事务 B 提交释放所有锁后,事务 A 才可以修改。所以 MVCC 解决的只是读-写的阻塞问题,写-写依然还是阻塞的。

PostgreSQL 中是依赖于 txid 以及 Commit Log 结合而成的可见性检测机制来实现 MVCC,详情可以参考 PostgreSQL 架构机制 https://url.wx-coder.cn/SgRDQ 中关于并发控制相关的介绍。