跳到主要内容

事务

定义

事务(traction)是进行一次处理的基本单元,要么完全执行,要么都不执行。 事务保证了一次处理的完整性,也保证了数据库中数据的一致性。它是一种高级的数据处理方式,如果在增删改查的时候任一环节出现错误,它允许我们回滚到之前的状态。

因此,事务非常适合应用在安全性高的场景里,比如金融行业等。

事务的特性ACID

事务要么全部执行,要么都不执行,具有四个特性:

  1. A, 原子性(Atomicity)。原子的概念就是不可分割,可以把它理解成组成物质的基本单位,也就是我们进行数据处理操作的基本单位。
  2. C, 一致性(Consistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交之后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。
  3. I, 隔离性(Isolation)。每个事务都是彼此独立的, 不会收到其它事务的执行影响。一个事务再提交前,对其他事务都是不可见的。
  4. D, 持久性(Durability)。 事务提交之后都数据的修改是持久性的。即便是系统出故障的情况下,比如系统崩溃或者存储介质发生故障,数据的修改依然是有效的。因为当事务完成,数据库的日志就会被更新,这时可以通过日志,让系统恢复到最后一次成功的更新状态。

ACID 可以说是事务的四大特性,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是目的。

一致性

一致性本身是由具体的业务定义的,也就是说,任何写入数据库中的数据都要满足事先定义好的约束规则。

比如,在数据表中我们将姓名字段设置为唯一性约束,这时当事务进行提交或者事务发生回滚的时候,如果数据表中的姓名非唯一,就破坏了事务的一致性要求。所以说,事务操作会让数据表的状态变成另一种一致的状态,如果事务中的某个操作失败了,系统 就会自动撤销当前正在执行的事务,返回到事务操作之前的状态。

持久性

持久性是通过事务日志来保证的,事务日志包括了回滚日志和重做日志。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库也能找到没有更新到数据库中的重做日志,重新执行,从而使事务具有持久性。

事务的控制

Oracle 是支持事务的,而在 MySQL 中,则需要选择适合的存储引擎才可以支持事务,可以通过 SHOW ENGINES 命令来查看当前 MySQL 支持ide存储引擎有哪些,以及这些存储引擎是否支持事务。

mysql> show engines;
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| Engine | Support | Comment | Transactions | XA | Savepoints |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| InnoDB | DEFAULT | Supports transactions, row-level locking, and foreign keys | YES | YES | YES |
| MRG_MYISAM | YES | Collection of identical MyISAM tables | NO | NO | NO |
| MEMORY | YES | Hash based, stored in memory, useful for temporary tables | NO | NO | NO |
| BLACKHOLE | YES | /dev/null storage engine (anything you write to it disappears) | NO | NO | NO |
| MyISAM | YES | MyISAM storage engine | NO | NO | NO |
| CSV | YES | CSV storage engine | NO | NO | NO |
| ARCHIVE | YES | Archive storage engine | NO | NO | NO |
| PERFORMANCE_SCHEMA | YES | Performance Schema | NO | NO | NO |
| FEDERATED | NO | Federated MySQL storage engine | NULL | NULL | NULL |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
9 rows in set (0.08 sec)

在 MySQL 中,InnoDB 是支持事务的,而 MyISAM 存储引擎不支持事务。

事务的常用控制语句都有下面这些:

  1. START TRACTION 或者 BEGIN;,作用是显式开启一个事务。
  2. COMMIT :提交事务,当事务提交后,对数据库的修改是持久的。
  3. ROLLBACK 或者 ROLLBACK TO [SAVEPOINT], 回滚事务,撤销正在执行的所有没有提交的修改,或者将事务回滚到某个保存点。
  4. SAVEPOINT: 在事务中创建保存点,方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
  5. RELEASE SAVEPOINT: 释放保存点,删除保存点。
  6. SET TRANSACTION , 设置事务的隔离界别。

使用事务有两种方式,分别为显示事务和隐式事务。隐式事务实际上就是自动提交,Oracle 默认不自动提交,需要手写 COMMIT 命令,而 MySQL 默认自动提交,当前也可以配置 MySQL 的参数:

mysql > set autocommit = 0; // 关闭自动提交
mysql > set autocommit = 1; // 开启自动提交

在 MySQL 的默认状态下,下面这个事务最后的处理结果是什么:

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;
BEGIN;
INSERT INTO test SELECT '关羽';
COMMIT;
BEGIN;
INSERT INTO test SELECT '张飞';
INSERT INTO test SELECT '张飞';
ROLLBACK;
SELECT * FROM test;

结果是:

mysql> SELECT * FROM test;
+-------+
| name |
+-------+
| 关羽 |
+-------+
1 row in set (0.00 sec)

在这个事务中,整个 SQL 一共执行了 2 个事务,第一个是插入 “关羽”,提交后执行成功,第二个是插入两次 “张飞”,这里需要注意的是,将 name 设置为了主键,也就是说主键的值是唯一的,那么第二次插入“张飞“ 时就会产生错误,然后执行 ROLLBACK 相当于对事务进行了回滚,所以 最终结果只有一行数据,也就是第一个事务执行之后的结果,即”关羽“。

如果进行下面的操作又会怎么样呢?

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;
BEGIN;
INSERT INTO test SELECT '关羽';
COMMIT;
INSERT INTO test SELECT '张飞';
INSERT INTO test SELECT '张飞';
ROLLBACK;
SELECT * FROM test;

上次操作把两次插入 "张飞" 放到一个事务中,而这次操作它们不在同一个事务里,那么对于 MySQL 来说,默认情况下这实际就是两个事务,因为在 autocommit = 1 的情况下,MySQL 会进行隐式事务,也就是自动提交,因此在第一次插入 ”张飞“ 之后,数据表里就存在了两行数据,第二次插入 ”张飞“ 就会报错。 1062 - Duplicate entry '张飞' for key 'PRIMARY'

最后在执行 ROLLBACK 的时候,实际上事务已经自动提交了,就没法进行回滚了。

CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;
SET @@completion_type = 1;
BEGIN;
INSERT INTO test SELECT '关羽';
COMMIT;
INSERT INTO test SELECT '张飞';
INSERT INTO test SELECT '张飞';
ROLLBACK;
SELECT * FROM test;

上面这个操作,在 MySQL 中,会自动将两个 SQL 放到一个事务中,那么结果就会和上面一样,只有一行数据,即第一个事务执行之后的结果。

  1. completion_type = 0 , 默认值,执行 COMMIT 的时候会提交事务,在执行下一个事务时,还需要使用 START TRANSACTION 或者 BEGIN 显式开启事务。
  2. completion_type = 1 , 当我们提交事务后,相当于执行了 COMMIT AND CHAIN ,也就是开启一个链式事务,即当我们提交事务之后会开启一个相同隔离级别的事务。
  3. completion_type = 2 , 这种情况下 COMMIT = COMMIT AND RELEASE ,也就是当我们提交后,会自动与服务器断开连接。

在上面的这段代码里,使用了 completion = 1 ,也就是说当提交之后,相当于在下一行写了一个 START TRANSACTION 或 BEGIN 。 此时两次插入 ”张飞“ 会被认为是在同一个事务之内的操作, 那么第二次插入”张飞“就会导致事务失败,而回滚也将这次事务进行了撤销,所以最终结果只有一行数据,即第一个事务执行之后结果。

当我们设置 autocommit = 0 时, 不论是否采用 START TRANSACTION 或者 BEGIN 的方式来开启事务,都需要用 COMMIT 进行提交,让事务生效,使用 ROLLBACK 对事务进行回滚。

当我们设置 autocommit = 1 ,每条 SQL 语句都会自动进行提交。

不过这时,如果采用 START TRANSACTION 或者 BEGIN 的方式来显式的开启事务,那么这个事务只有在 COMMIT 时才会生效,在 ROLLBACK 时才会回滚。

事务隔离

隔离性是事务的基本特征之一,它可以防止数据库在并发处理时出现数据不一致的情况。最严格的情况下,可以采用串行化的方式来执行每一个事务,这就意味着事物之间是相互独立的,不存在并发的情况。然而在实际生产环境下,考虑到随着用户量的增多,会存在大规模并发访问的情况,这就要求 数据库有更高的吞吐能力,这时候串行化的方式就无法满足数据库高并发访问的需求,还需要降低数据库的隔离标准,来换取事务之间的并发能力。

有时候需要牺牲一定的正确性来换取效率的提升,也即是说,需要通过设置不同的隔离等级,以便在正确性和效率之间进行平衡。同时,随着 RDBMS 种类和应用场景的增多,数据库的设计者需要统一 对数据库隔离级别进行定义,说明这些隔离标准都解决了那些问题。

事务并发处理可能存在的异常

SQL-92 标准中已经对 3 中异常情况进行了定义,这些异常情况级别分别为脏读(Dirty Read)、不可重复读(Nonrepeatable Read)和幻读(Phantom Read)。

脏读、不可重复读和幻读 都代表了什么,用一个例子来举例。比如说我们有个英雄表 heros_temp ,如下所示:

这张英雄表,我们会记录很多英雄的姓名,假设我们不对事务进行隔离操作,那么数据库在进行事务的并发处理时会出现什么样的情况。

脏读

第一天,小张访问数据库,正在进行事务操作,往里面写入一个新的英雄 ”吕布“:

SQL > BEGIN ;
SQL > INSERT INTO heros_temp VALUES(4, '吕布');

当小张还没有提交事务的时候,小李又对数据表进行了访问,他想看下这张表都有那些英雄:

SQL > SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 刘备 |
| 2 | 张飞 |
| 3 | 关羽 |
| 4 | 吕布 |
+----+-------+
4 rows in set (0.00 sec)

此时小张还没有提交事务,但是小李却读到了小张还没有提交的数据,这种现象我们称之为脏读。

不可重复读

第二天,小张想查看 id = 1 的英雄是谁,于是他进行了 SQL 查询:

SQL> BEGIN;
SQL> SELECT name FROM heros_temp WHERE id = 1;
+-------+
| name |
+-------+
| 刘备 |
+-------+
1 row in set (0.00 sec)

然而此时,小李开始了一个事务操作,他对 id = 1 的英雄姓名进行了修改,把原来的 "张飞" 改成了 "张翼德" :

SQL > BEGIN;
SQL > UPDATE heros_temp SET name = '张翼德' WHERE id = 1;

然后小张再次进行查询,同样也是查看 id = 1 的英雄是谁:

SQL > SELECT name FROM heros_temp WHERE id = 1;
+-------+
| name |
+-------+
| 张翼德 |
+-------+
1 row in set (0.00 sec)

两次查询的结果并不一样,这种情况称之为,不可重复读。也就是同一条记录,两次读取的结果不同。

幻读

第三天,小张想要看下数据表里都有那些英雄,他开始执行下面这条语句:

SQL > SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 刘备 |
| 2 | 张飞 |
| 3 | 关羽 |
+----+-------+
4 rows in set (0.00 sec)

这是当小张执行完之后,小李又开始了一个事务,往数据库里插入一个新的英雄 ”吕布“:

SQL > BEGIN;
SQL > INSERT INTO heros_temp VALUES(4, '吕布');

不巧的是,小张这时忘记了英雄都有那些,又重新执行了一遍查询;

SQL > SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 刘备 |
| 2 | 张飞 |
| 3 | 关羽 |
| 4 | 吕布 |
+----+-------+
5 rows in set (0.00 sec)

他发现这一次查询多了一个英雄,原来只有 3 个,现在变成了 4 个。这种异常情况称之为幻读。

  1. 脏读: 读到了其它事务还没有提交的数据。
  2. 不可重复读:对某数据进行读取,发现两次读取的结果不同,也就是说没有读到相同的内容。这是因为有其它事务对这个数据同时进行了修改或删除。
  3. 幻读: 事务 A 根据条件查询得到了 N 条数据,但此时事务 B 更改或者增加了 M 条符合事务 A 查询条件的数据,这样当事务 A 再次进行查询的时候发现会有 N + M 条数据,产生了幻读。

事务的隔离级别有哪些

脏读、不可重复读和幻读这三种异常情况,是在 SQL-92 标准中定义的,同时 SQL-92 标准定义了 4 中隔离级别来解决这些异常情况。

解决异常数量从少到多的顺序(比如读未提交可能存在 3 种异常,可串行化不会存在这些异常),决定了隔离级别的高度,这四种隔离级别从低到高分别是:读未提交(READ UNCOMMITED)、读已提交(READ COMMITED)、可重复读(REPEATABLE READ)和可串行化(SERIALIZABLE)。这些隔离级别能解决的异常情况如下表所示:

可串行化能避免所有的异常情况,而读未提交则允许异常情况发生。

关于这四种级别,简单来说:

  1. 读未提交,也就是允许读到未提交的数据,这种情况下查询是不会使用锁的,可能会产生脏读、不可重复读、幻读等情况。
  2. 读已提交就是只能读到已经提交的内容,可以避免脏读的产生,属于 RDBMS 种常见的默认隔离级别(比如 Oracle 和 SQL Server),但如果想要避免不可重复读或者幻读,就需要在 SQL 查询的时候写带加锁的 SQL 语句。
  3. 可重复读: 保证一个事务在相同查询条件下两次查询得到的数据结果是一致的,可以避免不可重复读和脏读,但无法避免幻读。MySQL 的默认隔离级别就是可重复读。
  4. 可串行化: 将事务进行串行化,也就是在一个队列种按照顺序执行,可串行化是最高级别的隔离等级,可以解决事务读取中所有可能出现的异常情况,但是它牺牲了系统的并发性。

模拟三种异常

首先需要一个英雄数据表 heros_temp, 具体表结构和数据如下:

-- ----------------------------
-- Table structure for heros_temp
-- ----------------------------
DROP TABLE IF EXISTS `heros_temp`;
CREATE TABLE `heros_temp` (
`id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of heros_temp
-- ----------------------------
INSERT INTO `heros_temp` VALUES (1, '张飞');
INSERT INTO `heros_temp` VALUES (2, '关羽');
INSERT INTO `heros_temp` VALUES (3, '刘备');

模拟的时候需要开两个 MySQL 客户端,分别是 客户端 1 和客户端 2.

在客户端 1 中,先来查看当前会话的隔离级别,使用命令:

mysql> SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.15 sec)

然后能看到当前的隔离级别是 REPEATABLE-READ, 也就是可重复读。

现在把隔离级别降到最低,设置为 READ UNCOMMITTED(读未提交)。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.03 sec)

然后再查看当前会话(SESSION)下的隔离级别,结果如下:

mysql> SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | READ-UNCOMMITTED |
+-----------------------+-----------------+
1 row in set (0.00 sec)

因为 MySQL 默认是事务自动提交,还需要将 autocommit 参数设置为 0 ,如下:

mysql> SET autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> SHOW VARIABLES LIKE 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
1 row in set (0.00 sec)

接着我们以同样的操作启动客户端 2 ,也就是将隔离级别设置为 READ UNCOMMITTED(读未提交),并设置 autocommit 为 0。

脏读

在客户端 2 中开启一个事务,在 heros_temp 表中写入一个新的英雄 “卢布”,注意,这时候不要提交。

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO heros_temp VALUES (4, '卢布');
Query OK, 1 row affected (0.00 sec)

然后在客户端 1 中查询 heros_temp 表,结果如下:

mysql> SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 张飞 |
| 2 | 关羽 |
| 3 | 刘备 |
| 4 | 卢布 |
+----+-------+
4 rows in set (0.00 sec)

这里出现了一个脏读,客户端 1 中读取了客户端 2 未提交的新英雄“吕布”,实际上客户端 2 可能马上回滚,从而造成了“脏读”。

不可重复读

用客户端 1 来查看 id = 1 的英雄:

mysql> SELECT * FROM heros_temp WHERE id = 1;
+----+-------+
| id | name |
+----+-------+
| 1 | 张飞 |
+----+-------+
1 row in set (0.00 sec)

然后用客户端 2 对 id = 1 的英雄姓名进行修改:

mysql> UPDATE heros_temp SET name = '张翼德' WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

用客户端 1 再次查看 id = 1 的英雄:

mysql> SELECT * FROM heros_temp WHERE id = 1;
+----+-------+
| id | name |
+----+-------+
| 1 | 张翼德 |
+----+-------+
1 row in set (0.00 sec)

这里出现了一个不可重复读,客户端 1 中读取了客户端 2 修改的英雄“张翼德”,实际上客户端 2 可能马上回滚,从而造成了“不可重复读”。

幻读

首先用客户端查询数据库中的所有英雄:

mysql> SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 张飞 |
| 2 | 关羽 |
| 3 | 刘备 |
+----+-------+
3 rows in set (0.00 sec)

然后用客户端 2 来添加一个新的英雄 “卢布” :

mysql> INSERT INTO heros_temp VALUES (4, '卢布');
Query OK, 1 row affected (0.00 sec)

用客户端 1 再次查询数据库中的所有英雄:

mysql> SELECT * FROM heros_temp;
+----+-------+
| id | name |
+----+-------+
| 1 | 张飞 |
| 2 | 关羽 |
| 3 | 刘备 |
| 4 | 卢布 |
+----+-------+
4 rows in set (0.00 sec)

这里出现了一个幻读,客户端 1 中读取了客户端 2 新增的英雄“卢布”,实际上客户端 2 可能马上回滚,从而造成了“幻读”。

总结

在进行数据库操作的时候,可能会失败,但正式因为有事务的存在,即使在数据库操作失败的情况下,也能保证数据的一致性。

同样,多个应用程序访问数据库的时候,事务可以提供隔离,保证事务之间不被干扰。最后,事务一旦提交,结果就会说永久性的,即便是系统崩溃了,数据库也可以对数据进行恢复。

事务是数据库区别于文件系统的重要特征之一,有了事务就会让数据库始终保持一致性,同时我们还能通过事务的机制恢复到某个时间点,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。

隔离级越低,意味着系统吞吐量(并发程度)越大,但同时也意味着出现异常问题的可能性更大。在实际使用过程中,我们往往需要在性能和正确性上进行权衡和取舍,没有完美的解决方案,只有适合与否。

隔离级别越高,就是越接近串行化操作。