上一篇我们学习了如何利用 DML 语句对数据进行各种增删改合操作。
当我们在操作数据的同时,其他人或者应用程序可能也在操作相同的数据;此时数据库必须保证多个用户之间不会产生影响,数据不会出现不一致性。这就涉及到一个重要的概念:数据库事务(Transaction)。
什么是数据库事务
在企业应用中,数据库通常需要支持多用户并发访问;并且保证多个用户并发访问相同的数据时,不会造成数据的不一致性和不完整性。同时,在用户执行操作的过程中,可能会遇到系统崩溃、介质失效等故障,数据库也必须能够从失败的状态恢复到一致状态。这些核心功能在数据库中都是通过事务来实现的。
在数据库中,事务是指一组相关的 SQL 语句,它们在业务逻辑上组成一个原子单元。数据库必须保证事务中的所有操作全部成功,或者全部撤销。
最常见的数据库事务就是银行账户之间的转账操作;例如从 A 账户转出 1000 元到 B 账户,数据库操作的流程如下图所示:
以上是一个简化的流程,实际的转账还需要检查账户的状态、收取转账费用等。该事务主要包括以下步骤:
- 查询 A 账户的余额是否足够;
- 从 A 账户减去 1000 元;
- 往 B 账户增加 1000 元;
- 记录本次转账流水。
显然,数据库必须保证所有的操作要么全部成功,要么全部失败。如果从 A 账户减去 1000 元成功执行,但是没有往 B 账户增加 1000 元,意味着客户将会损失 1000 元。用数据库中的术语来说,这种情况导致了数据库的不一致性。
下面我们以 MySQL 数据库(InnoDB 存储引擎)为例进行演示,首先创建一个简化的银行账户表 bankcard 和交易流水表 transaction_log:
— MySQL 实现 CREATE TABLE bank_card( card_id VARCHAR(20) PRIMARY KEY, — 卡号 user_name VARCHAR(50), — 账户名称 balance NUMERIC(10,4), — 余额 CHECK (balance >= 0) ); CREATE TABLE transaction_log ( log_id INT AUTO_INCREMENT PRIMARY KEY, — 交易流水编号,系统自动生成 log_ts TIMESTAMP NOT NULL, — 交易时间戳 txn_type VARCHAR(10) NOT NULL, — 交易类型 from_card VARCHAR(20) NOT NULL, — 源卡号 to_card VARCHAR(20), — 目标卡号 amount NUMERIC(10,4) NOT NULL, — 金额 remark VARCHAR(200), — 备注_ CHECK (txn_type IN(‘存款’,’取现’,’转账’)), CHECK (amount > 0) ); 复制
在其他数据库只需要进行简单的修改,也可以用于下面的演示。
事务控制语句
SQL 定义了用于管理数据库事务的事务控制语句(Transaction Control Language)。MySQL 实现了以下语句:
- BEGIN 或者 START TRANSACTION,开始一个事务;
- COMMIT,提交事务;
- ROLLBACK,撤销事务;
- SAVEPOINT,事务保存点,用于撤销部分事务;
- SET autocommit = {0 | 1},设置事务是否自动提交。
首先,我们在一个事务中为 bankcard 表增加两个账户:
BEGIN; INSERT INTO bank_card VALUES (‘62220801’, ‘A’, 1000); INSERT INTO bank_card VALUES (‘62220802’, ‘B’, 500); COMMIT; 复制
其中,BEGIN 开始一个新的事务;然后插入两条记录;最后使用 COMMIT 提交该事务。此时,即使退出当前会话再重新连接数据库,仍然能够查询到这两条记录;即使系统出现故障,数据库也能够保证数据不会丢失。
实际上,由于 MySQL 默认启用了自动提交(autocommit),任何数据操作都会自动提交:
show variables like ‘autocommit’; Variable_name|Value| ——————-|——-|_ autocommit |ON | INSERT INTO bank_card VALUES (‘62220803’, ‘C’, 2000); 复制
我们没有使用 BEGIN 开始事务,也没有使用 COMMIT 提交事务。但是 MySQL 自动执行了这些操作,因此 bank_card 表中现在包含 3 条记录。
接下来我们看一下 ROLLBACK 命令的作用:
BEGIN; INSERT INTO bank_card VALUES (‘62220804’, ‘D’, 1000); ROLLBACK; 复制
其中,BEGIN 开始一个新的事务;然后插入一条记录;最后使用 ROLLBACK 撤销该事务。因此,最终不会创建卡号为“62220804”的记录。
Oracle 支持事务管理的 COMMIT、ROLLBACK 以及 SAVEPOINT 语句。Oracle 中不需要手动开始事务,一个事务的结束意味着另一个事务的开始。 SQL Server 支持事务管理的 BEGIN TRANSACTION、COMMIT、ROLLBACK 以及 SAVE TRANSACTION 语句。 PostgreSQL 支持事务管理的 BEGIN、COMMIT、ROLLBACK 以及 SAVEPOINT 语句。
通过以上演示我们知道了数据库事务的一些基本概念和控制语句,下面介绍一下数据库事务的重要特性。
事务的 ACID 属性
SQL 标准定义了数据库事务的四种特性:ACID。
原子性
原子性(Atomic)是指一个事务包含的所有 SQL 语句要么全部成功,要么全部失败。例如,某个事务需要更新 100 条记录;但是在更新到一半时系统出现故障,数据库必须保证能够回滚已经修改过的数据,就像没有执行过任何修改一样。
一致性
一致性(Consistency)意味着事务开始之前,数据库位于一致性的状态;事务完成之后,数据库仍然位于一致性的状态。例如,银行转账事务中;如果一个账户扣款成功,但是另一个账户加钱失败,就会出现数据不一致(此时需要回滚已经执行的扣款操作)。另外,数据库还必须保证满足完整性约束,比如账户扣款之后不能出现余额为负数(在余额字段上添加检查约束)。
隔离性
隔离性(Isolation)与并发事务有关,一个事务的影响在提交之前对其他事务不可见,多个并发的事务之间相互隔离。例如,账户 A 向账户 B 转账的过程中,账户 B 查询的余额应该是转账之前的数目;如果多人同时向账户 B 转账,结果也应该保持一致性,和依次进行转账的结果一样。SQL 标准定义了 4 种不同的事务隔离级别,我们将会在下文进行介绍。
持久性
持久性(Durability)表示已经提交的事务必须永久生效,即使发生断电、系统崩溃等故障,数据库都不会丢失数据。数据库系统通常使用重做日志(REDO)或者预写式日志(WAL)实现事务的持久性。简单来说,它们都是在提交之前将数据的修改操作记录到日志文件中;当数据库出现崩溃时,可以利用这些日志重做之前的修改,从而避免数据的丢失。
对于我们开发者而言,重点需要注意的是隔离级别,而隔离级别又与并发访问有关。
并发与隔离级别
数据库的并发意味着多个用户同时访问相同的数据,例如 A 和 C 同时给 B 转账。数据库的并发访问可能带来以下问题:
- 脏读(Dirty Read)。当一个事务允许读取另一个事务修改但未提交的数据时,就可能发生脏读。例如,B 的初始余额为 0;A 向 B 转账 1000 元但没有提交;此时 B 能够看到 A 转过来的 1000 元,并且成功取款 1000 元;然后 A 取消了转账;银行损失了 1000 元。很显然,银行不会允许这种事情发生。
- 不可重复读(Nonrepeatable Read)。一个事务读取某一记录后,该数据被另一个事务修改提交,再次读取该记录时结果发生了改变。例如,B 查询初始余额为 0;此时 A 向 B 转账 1000 元并且提交;B 再次查询发现余额变成了 1000 元,以为天上掉馅饼了。
- 幻读(Phantom Read)。一个事务第一次读取数据后,另一个事务增加或者删除了某些数据,再次读取时结果的数量发生了变化。幻读和非重复读有点类似,都是由于其他事务修改数据导致的结果变化。
- 更新丢失(Lost Update)。第一类:当两个事务更新相同的数据时,如果第一个事务被提交,然后第二个事务被撤销;那么第一个事务的更新也会被撤销。第二类:当两个事务同时读取某一记录,然后分别进行修改提交;就会造成先提交的事务的修改丢失。
为了解决并发访问可能导致的各种问题,SQL标准定义了 4 种不同的事务隔离级别(从低到高):
隔离级别 | 脏读 | 更新丢失 | 不可重复读 | 幻读 |
---|---|---|---|---|
读未提交(Read Uncommitted) | 可能 | 可能 | 可能 | 可能 |
读已提交(Read Committed) | — | 可能 | 可能 | 可能 |
可重复读(Repeatable Read) | — | — | — | 可能 |
序列化(Serializable) | — | — | — | — |
读未提交隔离级别最低,一个事务可以看到其他事务未提交的修改。该级别可能产生各种并发异常;如果一个事务已经修改某个数据,则另一个事务不允许同时修改该数据,写操作一定是按照顺序执行。PostgreSQL 消除了读未提交级别时的脏读。读已提交只能看到其他事务已经提交的数据,不会出现脏读。可重复读可能出现幻读。MySQL 中的 Innodb 存储引擎和 PostgreSQL 在可重复读级别消除了幻读。序列化提供最高级别的事务隔离。它要求事务只能一个接着一个地执行,不支持并发访问。
事务的隔离级别越高,越能保证数据的一致性;但同时会对并发带来更大的影响。不同数据库默认使用的隔离级别如下:
- Oracle、SQL Server 以及 PostgreSQL 默认使用读已提交隔离级别;
- MySQL InnoDB 存储引擎默认使用可重复读隔离级别。
一般情况下,大多数数据库的默认隔离级别为读已提交;此时,可以避免脏读,同时拥有不错的并发性能。尽管可能会导致不可重复度、幻读以及丢失更新,但是可以通过应用程序加锁进行处理。
接下来我们演示一下 MySQL 中默认隔离级别的效果。首先,在会话 1 中开始一个事务,并查询 A 的余额:
— 会话 1 SELECT @@transactionisolation; @@transaction_isolation| ———————————-| REPEATABLE-READ | BEGIN; SELECT FROM bank_card *WHERE user_name = ‘A’; card_id |user_name|balance | ————|————-|————-| 62220801|A |1000.0000| 复制
默认的隔离级别为 REPEATABLE-READ,账号 A 的余额为 1000 元。
然后打开一个新的会话 2,开始一个事务并修改 A 的余额:
— 会话 2 BEGIN; UPDATE bank_card SET balance = balance + 100 WHERE user_name = ‘A’; SELECT FROM bank_card *WHERE user_name = ‘A’; card_id |user_name|balance | ————|————-|————-| 62220801|A |1100.0000| 复制
会话 2 中显示 A 的余额已经被修改为 1100。此时再查询会话 1:
— 会话 1 SELECT FROM bank_card *WHERE user_name = ‘A’; card_id |user_name|balance | ————|————-|————-| 62220801|A |1000.0000| 复制
结果仍然是 1000,没有出现脏读。在会话 2 中提交事务:
— 会话 2 COMMIT; 复制
然后再次查询会话 1:
— 会话 1 SELECT FROM bank_card *WHERE user_name = ‘A’; card_id |user_name|balance | ————|————-|————-| 62220801|A |1000.0000| 复制
结果仍然是 1000,意味着可重复读取(Repeatable Read)。在会话 1 中提交事务:
— 会话 1 COMMIT; SELECT FROM bank_card *WHERE user_name = ‘A’; card_id |user_name|balance | ————|————-|————-|_ 62220801|A |1100.0000| 复制
此时,账户 A 的余额为 1100,会话 1 读取到了会话 2 提交的修改。
隔离级别与并发控制的实现方式通常有两种:锁(Lock)与多版本并发控制(MVCC),如果想要进一步了解,可以参考我的博客文章。
小结
数据库事务是指多个相关操作组成一个原子单元,所有操作全部成功,或者全部失败。事务具有 ACID 属性,能够确保数据库的完整性和一致性。数据库通过隔离来实现对并发事务的支持,隔离级别与并发性能不可兼得,在开发应用程序时需要进行权衡和选择;一般情况下,我们使用数据库的默认隔离级别。
思考题:在上文隔离级别的示例中,将 MySQL 会话 1 的隔离级别设置为读已提交,或者在其他数据库中使用默认隔离级别;结果有什么不同?
提示:使用以下命令可以设置当前事务的隔离级别:
SET TRANSACTION ISOLATION LEVEL { REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED | SERIALIZABLE };