ACID

ACID 是指数据库管理系统(DBMS)在写入或者更新数据的过程中,为保证事务的正确可靠所具备的四个特征:原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。

原子性(atomicity)

原子性是指,在一个事务中所有的操作,要么全部完成,要么全部不完成,不会结束在中间的某个环节。如果事务在执行过程中发生错误,会被回滚(rollback)到事务开始之前的状态,就像事务从来没有执行过一样。

一致性(consistency)

一致性是指在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的数据完全符合所有预设的约束(唯一性约束、外键约束等)、触发器等。一个经典的例子就是银行转账,不管有几个账户参与转账,也不管事务是否成功,事务结束后总金额必须保持不变。

隔离性(isolation)

隔离性是指多个事务并发对数据进行读写和修改时,事务之间相互隔离,一个事务不应该影响其他事务的正确执行。隔离确保事务并发执行与按顺序执行后数据库的状态相同。SQL 标准根据事务之间影响的程度定义了四种隔离级别,不同的隔离级别下数据库事务的隔离程度不同,只有在使用最严格的隔离级别时,数据库的并发事务才不会出现任何问题,但是考虑到性能问题一般情况下不使用最高级别的隔离。

并发事务的影响

并发事务可能出现的影响主要分为:脏读、不可重复读、幻读以及丢失更新。脏读、不可重复读和幻读都是一个事务在读取,另一个事务在做更新或者添加、删除,而丢失更新是两个事务都在做更新操作。

脏读

脏读可能发生在并发的读和写事务中,就是一个事务读到了另一个事务未提交的数据,而这个数据后来被回滚了。

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 将余额改为 1500 元
T4 查询余额为 1500 元(脏读)
T5 回滚事务,余额恢复为 1000 元

不可重复读

不可重复读可能发生在并发的读和写事务中,多次执行相同的查询却得到了不同的结果,这是因为在某次查询前后,事务 B 对符合事务 A 查询条件的数据进行了修改并提交。

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 查询余额为 1000 元
T4 更新余额改为 900 元
T5 提交事务
T6 查询余额为 900 元(与第一次读不一致)

幻读

幻读可能发生在并发的读和写事务中,多次执行相同的查询却得到了不同的结果,这个不同并不是列值不同,而是记录数不同。原因是在某次查询前后,事务 B 添加(删除不算)了符合事务 A 查询条件的记录。这里主要参考 ANSI SQL-92 规范中的定义

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 统计所有存款超过 10000 元的用户
T4 新增一个用户,存款为 20000 元
T5 提交事务
T6 再次统计所有存款超过 10000 元的用户,发现多了(幻读)

脏写

脏写可能发生在并发的写写事务中,一个事务修改了另一个未提交事务修改过的数据。

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 将余额改为 1100 元
T4 将余额改为 900 元(修改了另一个事务未提交的数据)
T5 提交事务
T6 回滚事务

丢失更新

丢失更新可能发生在并发的写写事务中,一个事务修改了另一个未提交事务修改过的数据,最终先提交的事务修改过的数据被后提交的事务覆盖了。

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 将余额改为 1100 元
T4 将余额改为 900 元(修改了另一个事务未提交的数据)
T5 提交事务
T6 提交事务

我的理解

关于脏写和丢失更新,我个人的理解是:它们都是在一个事务中修改了另一个未提交事务修改过的数据。在任何隔离级别中,都不会允许出现一个事务修改别人还未提交的数据的情况,因为在写数据的时候会加排他锁,这样该事务的修改操作会等待另一个事务提交之后才能继续进行,此时再修改数据就不叫丢失更新了,这就是正常的更新操作。但是在应用层面还是可能会出现丢失更新,这类情况一般是在更新之前先查询了数据,并且之后的更新需要依赖该查询结果。看下面的这个例子:

时间点 事务 A 事务 B
T1 开始事务
T2 开始事务
T3 查询余额为 1000 元
T4 查询余额为 1000 元
T5 取出 100 元,余额改为 900 元
T6 存入 100 元,余额改为 1100 元
T7 提交事务
T8 提交事务

从结果来看,最终的余额变成了 1100 元,而实际上的余额应该是 1000 元。从逻辑上看,取出 100 元又存入了 100 元,余额的总量应该保持不变,这其实并不是数据库的问题,而是逻辑上的问题,需要我们从应用层面去解决。一般有两种解决方法,一种是在查询时加入排他锁,也就是使用 SELECT ... FOR UPDATE 语句;另一种就是使用乐观锁,即添加一个版本号字段,在查询数据时将版本号一同查出,在更新时通过 where 条件判断版本号与之前查询得出的是否一致,只有一致时才会更新。

并发控制

数据库的并发控制用来避免或减小并发事务可能产生的影响,主要的并发控制方式有:乐观并发控制、悲观并发控制和 MVCC(多版本并发控制)。

悲观并发控制

为了避免并发事务产生的上述影响,就需要在执行可能引发问题的操作之前通过锁将该操作阻塞,在锁释放时恢复执行。

锁类型

封锁使用的两种基本类型的锁包括:共享锁排它锁

  • 共享锁又叫 S 锁,如果事务 T 对数据对象 R 加上了 S 锁后,其他事务只能对 R 再加共享锁,不能加排它锁。获得共享锁的事务只能读取数据,不能修改数据。
  • 排它锁又叫 X 锁,如果事务 T 对数据对象 R 加上了 X 锁后,其他事务不能再对 R 加任何类型的锁,但是可以不加锁地去读取 R。获得排它锁的事务既能读取数据,也能修改数据。

封锁粒度

封锁的对象可以很大也可以很小,大的对象可以是整个表,甚至是整个数据库,小的对象可以是某一行数据或者是某一列字段等等,封锁对象的大小称为封锁的粒度。封锁的粒度越大,并发度也就越小(冲突的可能性也就越大),但是相对的系统开销也就越小;封锁的粒度越小,并发度也就越高(冲突的可能性也就越小),但是相对的开销也就越大。

封锁协议

在使用锁对数据对象加锁时,还需要定义一些规则。例如在何时申请锁,何时释放锁等。为此,数据库理论中提出了封锁协议的概念。

  • 一级封锁协议
    如果事务中对数据对象 R 有修改操作,则必须在该事务第一个读取数据对象 R 之前对其加 X 锁,直到事务结束才释放,事务结束包括正常结束(commit)和非正常结束(rollback)。在一级封锁协议中,如果仅仅是读取数据,并不对其进行修改,是不需要加锁的,因此它不能避免脏读和不可重复读,但是它可以避免丢失更新。
  • 二级封锁协议
    在一级封锁协议的基础上,事务在读取数据对象 R 之前必须先对其加 S 锁,读取完毕后才能释放。二级封锁协议除了能够避免丢失更新以外,还可以避免脏读。但是由于读取完数据即可释放 S 锁,所以没有办法避免不可重复读。
  • 三级封锁协议
    在一级封锁协议的基础上,事务在读取数据对象 R 之前必须先对其加 S 锁,直到事务结束才能释放。三级封锁协议除了能够避免丢失更新和脏读外,还进一步避免了不可重复读。

隔离级别

隔离级别可以看作是三级封锁协议的具体应用和实现。在 SQL 标准规范中定义了四种隔离级别,包括:读未提交、读提交、可重复读和串行化。

隔离级别 可以避免的情况 说明
READ UNCOMMITTED(读未提交) 丢失更新 最低级别的隔离
READ COMMITTED(读提交) 丢失更新、脏读 只有在事务提交后,其更新的结果才能被其他事务看见
REPETABLE READ(可重复读) 丢失更新、脏读、不可重复读 在同一个事务中,对于同一份数据的读取结果总是相同的
SERIALIZABLE(串行化) 丢失更新、脏读、不可重复读和幻读 所有的事务串行化执行,隔离级别最高,可以解决所有的并发事务问题,但是性能最差

读未提交级别下,数据库遵循一级封锁协议,只对修改数据的并发操作做限制,即一个事务不能修改其他事务正在修改的数据,但是可以读取到(不加锁的读)其他事务中尚未提交的修改,如果这些修改被回滚,将会成为脏数据。

读提交级别下,数据库遵循二级封锁协议,只允许读取已经被提交的数据,即如果一个事务正在修改数据并且没有提交,其他事务不能读取该数据。在 MySQL 的 InnoDB 引擎中,这种操作虽然不被允许,但是 MySQL 不会阻塞这种读取操作,而是会查询出数据被修改之前的快照,这种机制被称为 MVCC(多版本并发控制)。MVCC 会在事务并发过程中对数据维护多个版本,不同的事务操作的是不同的数据版本。这种机制反映在应用中就是,在任何时候对数据的查询操作总是可以得到最近提交的数据,未被提交的数据会被隔离起来,无法查询到,从而防止脏读。

可重复读级别下,理论上数据库遵循的是三级封锁协议,但是出于性能考虑,MySQL 的 InnoDB 引擎还是遵循的二级封锁协议,但是在读取的过程中更多的依赖 MVCC。依靠 MVCC,在同一个事务中的查询只能查到版本号(时间戳或事务 ID)不高于当前事务的数据,即在事务中只能看到该事务开始前或者被该事务影响的数据。反过来说,即不允许事务读取在该事务开始之后新提交的数据,这样也就避免了不可重复读。

依靠上面的机制,已经做到了在事务内数据的内容不变,但是不能保证多次查询得到的数据数量一致。因为在一个事务 T 执行的过程中别的事务完全可以执行数据的插入或删除,当插入或删除了刚好符合事务 T 查询条件的数据时,就会引发查询结果集的变化,导致幻读。InnoDB 提供的间隙锁机制可以在一定程度上避免幻读。

串行化级别下,所有的事务必须一个接一个地执行,这样虽然解决了并发事务的所有问题,但是会造成大量的事务等待、阻塞,系统性能最差。

大多数的数据库管理系统默认使用的隔离级别就是 READ COMMITTED(读提交),比如 Oracle、SQL Server、PostgreSQL 和 Db2,而 MySQL 的 InnoDB 引擎默认使用的隔离级别是 REPETABLE READ(可重复读)。理论上可重复读只能避免脏读和不可重复读的问题,但是 MySQL 默认的隔离级别又通过 MVCC(多版本并发控制)避免了部分幻读的情况。至于为什么说是部分避免,可以参考这篇文章

理论上,在任何隔离级别下,都不允许一个事务删除或者修改另一个事务影响但没有提交的数据。因为事务在增删或者修改数据时,会对数据对象加上排它锁,在该事务结束前,其他事务无法修改该数据,也就避免了丢失更新。

乐观并发控制

乐观并发控制又被称为乐观锁,虽然它的名称中带有锁,但它依赖的是程序逻辑,并不依赖数据库的锁机制(**这里有的说法是乐观锁在数据检查时会加锁,但是时间会很短,具体请参考知乎的这篇问答**)。乐观锁的一般做法是在数据库表中添加一个整型字段,通常叫做 version(版本号)。在读取数据时,连同该版本号一同读出。之后更新时,将提交数据的版本号和数据库表中的当前版本号对比,如果版本号相同,则予以更新;否则,说明已经该记录已经被其他事务更新过了,不予更新。在更新其他字段的同时,版本号字段要递增。逻辑类似如下:

1
2
3
4
5
6
7
-- 查询记录
select status, version from goods where id = #{id}

-- 更新记录
update goods
set status = 1, version = version + 1
where id = #{id} and version = #{oldVersion}

乐观锁适用于读多写少的情况,即更新冲突很少发生的情况,因为如果冲突很严重,更新经常是失败的,上层应用可能就需要不断重试,这会导致很多次请求使用的资源被白白浪费。在并发冲突很严重的情况下,一般采用悲观锁,即数据库加锁的方式。

多版本并发控制

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制的方式。并发控制最简单的方式就是加锁,但是这种方式性能很差。MVCC 使用了另一种思路,即每个数据库连接,在某个瞬间看到的数据只是数据库的一个快照,一个事务的写操作造成的影响在事务结束之前对于其他事务是不可见的。

当一个事务需要更新一条数据记录时,它不会直接用新数据覆盖旧数据,而是将旧数据标记为过时(obsolete)并在别处增加新版本的数据,这样数据库就会存储多个版本的数据,但是只有一个是最新的。这种方式允许一个事务读取在它读之前就已经存在的数据,即使在读取的过程中这个数据已经被其他事务修改或者删除过了,对当前正在读的事务也没有任何影响。

MVCC 并发控制下的读事务一般使用时间戳或者单向增长的事务 ID 去标记当前读的数据库的状态(或者叫做版本),读、写事务互相隔离,不需要加锁,写操作会根据当前数据库的版本,创建一个新的版本,并发的读事务依旧访问旧版本的数据。

持久性(durability)

持久性是指事务结束后,对数据的修改就会永久的保存在数据库中,即使设备发生了故障,如断电等。

数据库的持久化一般都是通过 WAL(write-ahead logging)技术来实现的。在使用 WAL 技术的系统中,所有的修改在提交之前都要先写入到 log 文件中,log 文件通常包括 redo 和 undo 信息。这样我们就不用每次提交事务的时候把数据页冲刷到磁盘,因为我们知道在出现崩溃的情况下,可以通过日志来恢复数据库,任何尚未被附加到数据页的记录都将从日志记录中重做(向前滚动恢复,也叫作 redo),而那些未提交的事务做的修改都将从数据页中删除(向后滚动恢复,也叫作 undo)。在使用了 WAL 机制之后,磁盘写操作只有传统的回滚日志的一半左右,大大提高了数据库磁盘 I/O 操作的效率,从而提高了数据库的性能。

参考

The SQL-92 standard

对数据库事务、隔离级别、锁、封锁协议的理解及其关系的理解

mysql 的锁–行锁,表锁,乐观锁,悲观锁

乐观锁和 MVCC 的区别?

关于 MVCC 的基础