# 事物

在接触数据库系统的其他高级特性之前,需要先了解事物的概念。

事物是一组原子性的 SQL 查询,或一个独立的工作单元。在事物内的语句,那么全部执行成功,要么全部执行失败。

本节非 MySQL 专属内容。讲解事物的 ACID概念

银行应用是解释事物必要性的一个经典例子。这里简要描述下:用户 Jane 支票账户转移 200 美元到她的储蓄账户,那么至少需要三个步骤

  1. 检查支票账户的余额高于 200 美元
  2. 从支票账户余额中减去 200 美元
  3. 在储蓄账户余额中增加 200 美元

这三个步骤操作必须打包在一个事物中,任何一个步骤失败,则必须回滚所有的步骤。

MySQL 中可以用如下语句来描述上述步骤

START TRANSACTION;  -- 开始一个事物
SELECT balance FROM checking WHERE customer_id = 123;
UPDATE checking SET balance= = balance - 200.00 WHERE customer_id = 123;
UPDATE savings SET balance= = balance + 200.00 WHERE customer_id = 123;
COMMIT; -- 提交事物
-- 失败时,可使用 ROLLBACK 撤销所有的更改
1
2
3
4
5
6

单纯的事物概念并不是故事的全部,如果执行到第 4 条数据时服务器崩溃了,会发生什么?这问题就严重了。

除非系统通过严格的 ACID 测试,否则空谈事物的概念是不够的。ACID 表示:

  • 原子性 atomicity

    一个事物必须被视为一个不可分割的最小工作单位,整个事物中的所有操作要么全部提交成功,要么全部失败回滚。

  • 一致性 consistency

    数据库总是从一个一致性的状态转换到另外一个一致性的状态。

    在前面的例子中,一致性确保了,即使在执行第 3 条语句时,系统崩溃了,那么两个账户中的数据不会错乱,因为事物最终没有提交

  • 隔离性 isolation

    通常来说,一个事物所做的修改在最终提交前,对其他事物是不可见的。

    在前面的例子中,当执行完第 3 条语句时,第 4 条还未开始时,此时有另外一个账户汇总程序开始执行,看到支票账户的余额并没有减去 200 美元。后续讨论隔离离别时,会明白为什么这里说「通常来说」是不可见的

  • 持久性 durability

    一旦事物提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。

    持久性是个有点模糊的概念,也分很多不同的级别,因为不可能做到 100% 的持久性保证的策略,后续章节会继续讨论这个话题

一个运行良好的事物处理系统,必须具备这些标准特征;

事物的 ACID 特性可以确保银行不会弄丢你的钱。而在应用逻辑中,要实现这一点非常难,甚至说是不可能完成的任务。一个兼容 ACID 的数据库系统,需要做很多复杂单可能用户没有察觉的工作,才能确保 ACID 的实现

像锁粒度升级会增加系统开销一样,事物处理的额外安全性也会导致额外的系统开销。正因为这样,MySQL 的存储引擎架构可以发挥优势的地方。用户可以根据业务是否需要事物处理,来选择合适的存储引擎。

# 隔离级别

隔离性其实比想象的要复杂,在 SQL 标准中定义了 4 种隔离级别,每一种级别都规定了一个事物中所做的修改,哪些事物内和事物间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统开销也更低。

另外,每种存储引擎实现的隔离级别可能都不太一样,这个需要进一步查阅所选存储引擎的手册。下面简单的介绍下 4 种隔离级别:

  • READ UNCOMMITTED(未提交读)

    事物中的修改,即使没有提交,对其他事物也都是可见的。事物可以读取未提交的数据,这也被称为 脏读(Dirty Read)

    该级别会导致很多问题,从性能上来说,不会比其他的级别好太多,单缺乏其他级别的很多好处,在实际应用中一般很少使用

  • READ COMMITTED(提交读)

    大多数据库默认此级别(但 MySQL 不是)。满足前面提到的隔离性的简单定义:一个事物开始时,只能 「看见」已经提交的事物所做的修改。换句话说,一个事物在提交前所做的操作对其他事物是不可见的。

    有时候也称为 不可重复读(nonrepeatable),因为两次执行同样的查询(单条记录),可能会得到不一样的结果

    这个意思是:A 事物中执行查询,下一个相同的查询还未执行前,B 事物对这部分数据修改了,那么当 A 事物中执行下一条查询语句时,会获取到 B 已提交的数据,导致两次相同查询不一致

  • REPEATABLE READ(可重复读):MySQL 默认事物隔离级别

    解决了 脏读 的问题。保证了同一事物中多次读取相同记录结果是一致的。

    但是理论上可重复读,还是无法解决另一外一个 幻读(Phantom Read) 问题:在某个事物中重复读取某个范围内的记录时,另一个事物在该范围内插入了新的记录,导致两次读取不一致的问题,会产生 幻行(Phantom Row)

    InnoDB 和 XtraDB 存储引擎通过 多版本并发控制(MVCC,Multiversion Concurrency Control)解决了幻读的问题。

  • SERIALIZABLE(可串行化)

    最高的隔离级别。通过强制事物串行执行,避免了前面说的幻读的问题。

    简单来说,会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用问题。

    实际情况很少使用该级别,无并发,强一致性时才会考虑该级别。

汇总隔离级别的问题如下:

隔离级别 脏读可能性 不可重复读可能性 幻读可能性 加锁读
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE

# 死锁

死锁指两个或多个事物在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。

多个事物试图以不同的顺序锁定资源时,就可能会产生死锁。

多个事物同时锁定同一个资源时也会产生死锁。例如下面两个事物同时处理 StockPrice 表

-- 事物 1
START TRANSACTION; 
UPDATE StockPrice SET close = 45.0 WHERE id = 2 and date = '2002-05-01';
UPDATE StockPrice SET close = 45.2 WHERE id = 3 and date = '2002-05-02';
COMMIT; 
-- 事物 2
START TRANSACTION; 
UPDATE StockPrice SET close = 45.7 WHERE id = 3 and date = '2002-05-02';
UPDATE StockPrice SET close = 45.8 WHERE id = 2 and date = '2002-05-01';
COMMIT; 
1
2
3
4
5
6
7
8
9
10

当同时执行了第一条 UPDATE 语句,并更新了一行数据,由于在事物中,该行数据被锁定,每个事物的第二条数据都去尝试执行,发现都被对方锁定了,导致死锁。除非有外部因素介入才可能解除死锁

为了解决这种问题,数据库系统实现了各种 死锁检测死锁超时机制。

  • 死锁检测:比如 InnoDB 存储引擎,检测到死锁的循环依赖,立即返回一个错误,减少了死锁导致出现非常慢的查询。
  • 死锁超时:当查询的时间到达锁等待超时的设置后放弃锁请求,该方式通常来说不太好。 InnoDB 目前处理死锁的方法是:将持有最少行级排他锁的事物进行回滚(这是相对比较简单的死锁回滚算法)

锁的行为和顺序是和存储引擎实现相关的,相同的语句某些引擎会产生死锁,而有的则不会。死锁的产生有双重因素:

  • 真正数据冲突,此种情况通常很难避免
  • 存储引擎实现方式导致

死锁发现以后,只有部分或完全回滚其中的一个事物,才能打破死锁。对于事务型系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事物即可

# 事物日志

事物日志可以帮助提高事物的效率。使用事物日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事物日志中,而不用每次都将修改的数据本身持久到磁盘中。事物日志采用的是追加的方式,因此写日志的操作是磁盘上一小块区域内的顺序 I/O,而不像随机 I/O 需要在磁盘的多个地方移动磁头,所以采用事物日志的方式相对来说要快得多。

事物日志持久以后,内存中被修改的数据在后台可以慢慢刷回到磁盘。目前大多数的存储引擎都是这样实现的,通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘

如果数据的修改已经记录到事物日志并持久化,但数据本身还没有写回磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。具体的恢复方式则视存储引擎而定

TIP:笔者这一块没有太明白修改内存拷贝,怎么能实现让其他事物能够查询到的。

# MySQL 中的事物

MySQL 提供了两种事务型的存引擎:InnoDB 和 NDB Cluster。还有一些第三方存储引擎也支持事物,知名的有 XtraDB 和 PBXT。后面会详细讨论他们的特点

# 自动提交(AUTOCOMMIT)

MySQL 默认采用自动提交模式。也就是说,如果不是显式的开始一个事物,则每个查询都被当做一个事物执行提交操作。在当前的连接中,可以通过设置 AUTOCOMMIT 变量来启用或禁用自动提交模式

mysql> SHOW VARIABLES LIKE 'AUTOCOMMIT';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)
mysql> SET AUTOCOMMIT = 1;
1
2
3
4
5
6
7
8

1 或 ON 表示启用,0 或 OFF 表示禁用。当等于 0 时,所有的查询都在一个事物中,需要显示的执行 COMMIT 提交或 ROLLBACK 回滚,该事物才会结束,同时又开始了另一个新事物。

该变量对非事务性的表没有影响,如 MyISAM 或则内存表,他们没有这个概念,也可以说一直处于自动提交模式

另外还有一些命令,在执行之前会强制执行 COMMIT 提交当前的活动事物。典型的例子:在数据定义语言(DDL)中,如果是会导致大量数据改变的操作,比如 ALTER TABLE 。还有 LOCK TABLES 等其他语句也会导致同样的结果。如果有需要,可以检查对应版本的官方文档来确认所有可能导致自动提交的语句列表

可以你通过如下指令设置隔离级别,新的级别会在下一个事物开始时生效,也可以在配置文件中设置整个数据库的隔离级别

-- 回话级别的隔离级别设置
mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
1
2

# 在事物中混合使用存储引擎

MySQL 服务器层不管理事物,事物由下层的存储引擎实现。所以在同一个事物中,使用多种存储引擎是不可靠的。

所以为每张表选择合适的存储引擎非常重要,另外再不支持事物的引擎上执行事物相关的操作,通常不会发出警告或则报错,有时候只有回滚的时候会发出一个警告:「某些非事务性的表上的变更不能被回滚」

# 隐式和显示锁定

InnoDB 采用的是 两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以锁定,锁只有在执行 COMMIT 或则 ROLLBACK 的时候才会释放,并且所有的锁时在同一时刻被释放。这些描述是 隐式锁定,InnoDB 会根据隔离级别在需要的时候自动加锁。

另外,InnoDB 也支持通过特定的语句进行显式锁定,这些语句不属于 SQL 规范(这些语句经常被滥用,实际上应该尽量避免使用,第 6 章详细讨论)

SELECT ... LOCK IN SHARE MODE
SELECT ... FOR UPDATE
1
2

MySQL 也支持 LOCK TABLES 和 UNLOCK TABLES 语句,这是在 服务器层实现的,和存储层无关。他们有自己的用途,但是不能替代事物处理。

LOCK TABLES 和事物之间相互影响,可能会造成无法预料的结果。因此本书建议,除了事物中禁用了 AUTOCOMMIT 时,可以使用 LOCK TABLES,其他任何时刻都不要显示的执行 LOCK TABLES,不管是使用的是什么存储引擎

比如,应用已经将表从 MyISAM 转换到 InnoDB 了,但还是显示的使用 LOCK TABLES 语句。