事务一般指的是逻辑上的一组操作,或者作为单个逻辑单元执行的一系列操作。同属与一个事务的操作会作为一个整体提交给系统,这些操作要么全部执行成功,要么全部执行失败。

事务的特性

      事务存在四大特性,分别是原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
请输入图片描述

原子性

      事务的原子性指的是构成事务的所有操作要么全部执行成功,要么全部执行失败,不可能出现部分执行成功,部分执行失败的情况。

一致性

      事务的一致性指的是在事务执行之前和执行之后,数据始终处于一致的状态。

隔离性

      事务的隔离性指的是并发执行的两个事务之间互不干扰。也就是说,一个事务在执行的过程中不能看到其他事务运行过程的中间状态。

MySql 通过锁和 MVCC 机制来保证事务的隔离性

持久性

      事务的持久性指的是事务提交完成后,此事务对数据的更改操作会被持久化到数据库中,并且不会被回滚。

事务的类型

      事务主要分为五大类:扁平事务带有保存点的扁平事务链式事务嵌套事务分布式事务

扁平事务

      扁平事务是事务操作中最常见,也是最简单的事务。在数据库中,扁平事务通常由begin或者start transaction字段开始,由commit或者rollback字段结束。在这之间的所有操作要么全部执行成功,要么全部执行失败(回滚)。
      扁平事务虽然是最常见、最简单的事务,但是无法提交或者回滚整个事务中的部分事务,只能把整个事务全部提交或者回滚。为了解决这个问题,带有保存点的扁平事务就出现了。

带有保存点的扁平事务

      带有保存点的扁平事务通过在事务内部的某个位置设置保存(savepoint),达到将当前事务回滚到此位置的目的。
      在 MySql 中,通过如下命令设置事务的保存点。

savepoint [savepoint_name]

      如果要回滚指定的保存点,命令如下:

rollback to [savepoint_name]

      删除保存点命令:

release savepoint [savepoint_name]

      小试牛刀,执行过程如下所示:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user_tab where id=1;
+----+---------+----------+
| id | name    | password |
+----+---------+----------+
|  1 | CatWing | 123      |
+----+---------+----------+
1 row in set (0.01 sec)

mysql> update user_tab set name = 'CatWing2' where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from user_tab where id=1;
+----+----------+----------+
| id | name     | password |
+----+----------+----------+
|  1 | CatWing2 | 123      |
+----+----------+----------+
1 row in set (0.00 sec)

## 设置保存点,保存点名为change_name
mysql> savepoint change_name;
Query OK, 0 rows affected (0.00 sec)

mysql> update user_tab set password='123456' where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from user_tab where id=1;
+----+----------+----------+
| id | name     | password |
+----+----------+----------+
|  1 | CatWing2 | 123456   |
+----+----------+----------+
1 row in set (0.00 sec)

## 回滚到change_name保存点
mysql> rollback to change_name;
Query OK, 0 rows affected (0.00 sec)

## 回滚到保存点后刚刚更新的password又还原了
mysql> select * from user_tab where id=1;
+----+----------+----------+
| id | name     | password |
+----+----------+----------+
|  1 | CatWing2 | 123      |
+----+----------+----------+
1 row in set (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

mysql> select * from user_tab where id=1;
+----+----------+----------+
| id | name     | password |
+----+----------+----------+
|  1 | CatWing2 | 123      |
+----+----------+----------+
1 row in set (0.00 sec)

链式事务

      链式事务是在带有保存点的扁平事务的基础上,自动将当前事务的上下文隐式地传递给下一个事务。也就是说,一个事务的提交操作和下一个事务的开始操作具备原子性,上一个事务的处理结果对下一个事务是可见的,事务与事务之间就像链条一样传递下去。

嵌套事务

      嵌套事务就是多个事务处于嵌套状态,共同完成一项任务的处理,整个任务具备原子性。嵌套事务最外层有一个顶层事务,这个顶层事务控制着所有内部子事务,内部子事务提交完成后,整体事务并不会提交,只有最外层的顶层事务提交完成后,整体事务才算提交完成。
      关于嵌套事务需要注意以下几点:

  1. 回滚嵌套事务内部的子事务时,会将事务回滚到顶层事务的开始位置;
  2. 嵌套事务的提交是从内部的子事务向外一次进行的,直到最外层的顶层事务提交完成;
  3. 回滚嵌套事务最外层的顶层事务时,会会馆嵌套事务包含的所有事务,包括已提交的内部子事务。

分布式事务

      分布式事务指的是事务的参与者、事务所在的服务器、涉及的资源服务器以及事务管理器等分别位于不同分布式系统的不同服务或数据库节点上。
      例如,在电商系统的下单减库存业务中,订单业务所在的数据库为事务A的节点,库存业务所在的数据库为事务B所在的节点。事务A和事务B组成了一个具备 ACID 特性的分布式事务,要么全部提交成功,要么全部提交失败。

MySQL 事务基础

并发事务带来的问题

      数据库一般会并发执行多个事务,而多个事务可能会并发地对相同的数据进行增加、删除、修改和查询操作,进而导致并发事务问题。
请输入图片描述

更新丢失(脏写)

      当两个或者两个以上的事务选择数据库中的同一行数据,并基于最初选定的值更新改行数据时,因为每个事务之间都无法感知彼此的存在,所以会出现最后的更新操作覆盖之前由其他事务完成的更新操作的情况。

更新丢失(脏写)本质上是写操作的冲突,解决办法是让每个事务按照串行的方式执行,按照一定的顺序依次进行写操作。

脏读

      一个事务正在对数据库中的一条记录进行修改操作,在这个事务完成并提交之前,当有另一个事务来读取正在修改的这条数据记录时,如果没有对这两个事务进行控制,则第二个事务就会读取到没有被提交的脏数据,并更加这些脏数据做进一步处理,此时就会产生未提交的数据依赖关系。我们将这种现象称为脏读,也就是一个事务读取了另一个事务未提交的数据。

脏读本质上是读写操作的冲突,解决办法是先写后读

不可重复读

      一个事务读取了某些数据,在一段时间后,这个事务再次读取之前读过的数据,此时发现读取的数据发生了变化,或者其中的某些记录已经被删除,这种现象就叫作不可重复读。即同一个事务,使用相同的查询语句,在不同时刻读取到的结果不一致。

不可重复读本质上是读写操作的冲突,解决办法是先读后写,也就是读完之后再写

幻读

      一个事务按照相同的查询条件重新读取之前读过的数据,此时发现其他事务插入了满足当前事务查询条件的新数据,这种现象叫作幻读。即一个事务两次读取一个范围的数据记录,两次读取到的结果不同。

看起来幻读和不可重复读一样呀,那么幻读和不可重复读到底有何区别呢?

  1. 不可重复读的重点在于更新和删除操作,而幻读的重点在于插入操作;
  2. 使用锁机制实现事务隔离级别时,在可重复读隔离级别中,SQL 语句第一次读取到数据后,会将相应的数据加锁,使得其他事务无法修改和删除这些数据,此时可以实现可重复读。这种方法无法对新插入的数据加锁。如果事务 A 读取了数据,或者修改和删除了数据,此时事务 B 还可以进行插入操作,导致事务 A 莫名其妙地多了一条之前没有的数据,这就是幻读。
  3. 幻读无法通过行级锁来避免,需要使用串行化的事务隔离级别,但是这种事务隔离级别会极大地降低数据库的并发能力。
  4. 从本质上讲,不可重复读和幻读最大的区别在于如何通过锁机制解决问题。

MySQL 事务隔离级别

      IonnDB 默认是可重复读(RR)的隔离级别。共有四种隔离级别:
请输入图片描述

      可以在命令行用--transaction-isolation选项或者在 MySQL 的配置文件 my.cnf,my.ini 里,为所有连接设置默认的事务隔离级别。

transaction-isolation = {READ-UNCOMMITED | READ-COMMITTED | REPEATABLE-READ | SERIALIZABLE}

      也可以使用SET TRANSACTION命令改变单个或者所有新连接的事务隔离级别,基本语法如下所示:

SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
  • 不带 SESSION 或者 GLOBAL 关键字设置事务隔离解绑,指的是为下一个事务设置隔离级别
  • 使用 GLOBAL 关键字指的是对全局设置事务隔离级别,也就是设置后的事务隔离级别对所有新产生的数据库连接生效
  • 使用 SESSION 关键字指的是对当前的数据库连接设置事务隔离级别,此时的事务隔离级别只对当前连接的后续事务生效
  • 任何客户端都能自由改变当前会话的事务隔离级别,可以在事务中间改变,也可以改变下一个事务的隔离解绑

      使用以下命令可以查询全局级别和会话级别的事务隔离级别:

SELECT @@global.transaction_isolation;
SELECT @@session.transaction_isolation;
SELECT @@transaction_isolation;
mysql> SELECT @@global.transaction_isolation;
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| REPEATABLE-READ                |
+--------------------------------+
1 row in set (0.00 sec)

mysql> SELECT @@session.transaction_isolation;
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| REPEATABLE-READ                 |
+---------------------------------+
1 row in set (0.00 sec)

mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1 row in set (0.01 sec)

MySQL 中各种事务隔离级别的区别

      4种事务隔离级别对于并发事务带来的问题的解决程度不同,如下表所示:

事务隔离级别 脏读 不可重复读 幻读
读未提交
读已提交
可重复读
串行化

MySQL 事务隔离级别最佳实践

      执行以下 SQL 完成数据准备:

CREATE DATABASE test;

CREATE TABLE `account` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
  `balance` int NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

BEGIN;
INSERT INTO `account` (`id`, `name`, `balance`) VALUES (1, 'Jake', 300);
INSERT INTO `account` (`id`, `name`, `balance`) VALUES (2, 'Lucy', 350);
INSERT INTO `account` (`id`, `name`, `balance`) VALUES (3, 'Lily', 500);
COMMIT;

读未提交

      打开终端 A,登录 MySql,设置当前会话的事务隔离级别为read uncommitted,开启事务A,查询 account 数据。

mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      打开终端 B,登录 MySql,设置当前会话的事务隔离级别为read uncommitted,开启事务B,将 id 为1的数据余额增加100。

mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance=balance+100 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     400 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务B 更新完数据,但是尚未提交,事务B 能查询到刚刚更新的数据,那么事务A如何呢?

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     400 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务A 可以查询到事务B 尚未提交的数据。如果事务B 回滚了数据,那么事务A 查询到的数据就是脏数据,不能拿来做任何业务操作。

读已提交

      打开终端 A,登录 MySql,设置当前会话的事务隔离级别为read committed,开启事务A,查询 account 数据。

mysql> set session transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      打开终端 B,登录 MySql,设置当前会话的事务隔离级别为read committed,开启事务B,将 id 为1的数据余额增加100。

mysql> set session transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance=balance+100 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     400 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务B 更新完数据,但是尚未提交,事务B 能查询到刚刚更新的数据,那么事务A如何呢?

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务A 查询到的数据依旧是事务B 更新前的数据,说明此时已经解决了脏读的问题。
      提交事务B,事务A 再次查询数据,事务A 在事务B 提交前和提交后读取到的数据不一致,产生了不可重复读的问题。

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     400 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

可重复读

      打开终端 A,登录 MySql,设置当前会话的事务隔离级别为repeatable read,开启事务A,查询 account 数据。

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      打开终端 B,登录 MySql,设置当前会话的事务隔离级别为repeatable read,开启事务B,将 id 为1的数据余额增加100。

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance=balance+100 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     400 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务B 更新完数据,但是尚未提交,事务B 能查询到刚刚更新的数据,那么事务A如何呢?

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      事务A 查询到的数据依旧是事务B 更新前的数据,说明此时已经解决了脏读的问题。
      提交事务B,事务A 再次查询数据,事务A 在事务B 提交前和提交后读取到的数据一致,说明次数已经解决了不可重复读的问题。

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     300 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      此时在事务A 中为 Jake 的账户余额增加100。

mysql> update account set balance=balance+100 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     500 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      此时 Jake 的账户余额为500,而不是300,这是因为事务B已经为 Jake 的余额增加了100。故最终的余额为500,数据一致性未遭到破坏。
      在终端B 开启事务C,插入一条数据后提交事务:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account(name,balance) values ('Meimei',100);
Query OK, 1 row affected (0.01 sec)

mysql> select * from account;
+----+--------+---------+
| id | name   | balance |
+----+--------+---------+
|  1 | Jake   |     400 |
|  2 | Lucy   |     350 |
|  3 | Lily   |     500 |
|  4 | Meimei |     100 |
+----+--------+---------+
4 rows in set (0.01 sec)

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

      此时在事务A 中查询数据,未查询到新增的Meimei, 说明没有出现幻读【真的吗?】。

mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | Jake |     500 |
|  2 | Lucy |     350 |
|  3 | Lily |     500 |
+----+------+---------+
3 rows in set (0.00 sec)

      在事务A 中为Meimei的账户增加100,再次查询数据。

mysql> update account set balance=balance+100 where id=4;
Query OK, 1 row affected (7.36 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from account;
+----+--------+---------+
| id | name   | balance |
+----+--------+---------+
|  1 | Jake   |     500 |
|  2 | Lucy   |     350 |
|  3 | Lily   |     500 |
|  4 | Meimei |     200 |
+----+--------+---------+
4 rows in set (0.00 sec)

      居然查询到了新增的账户信息,出现了幻读。

使用RR的隔离级别还是出现了幻读,如何解决该问题呢?答案是使用串行化的事务隔离级别或者间隙锁和临键锁

串行化

      串行化的事务隔离级别,任何操作都是串行的,自然不存在并发问题,不再进行额外演示。

MySQL 中锁的分类

      锁是一种协调多个进程或者多个线程对某一资源的访问的限制,MySQL 使用锁和 MVCC 机制实现了事务隔离级别。MySQL 中的锁可以从以下几个方面进行分类。
请输入图片描述

悲观锁和乐观锁

悲观锁:悲观锁对于数据库中数据的读写持悲观态度,即在整个数据处理的过程中,它会将相应的数据锁定。在悲观锁机制下,读取数据库中的数据时需要加锁,此时不能对这些数据进行修改操作。修改数据库中的数据时也需要加锁,此时也不能继续读取操作。
乐观锁:悲观锁会极大地降低数据库的性能,特别是对长事务而言。乐观锁则在一定程度上解决了这个问题。实现乐观锁的一种常见做法是为数据增加一个版本标识,如果是通过数据库实现,往往会在数据表中增加一个类似version的版本号字段。在查询数据时会将版本号字段的数据一起读取出来,当更新数据时,会令版本号字段的值+1,将提交数据的版本与数据表中的版本进行对比,如果提交数据的版本号大于等于数据表中当前要修改的数据版本号,则对数据进行修改,否则不修改表中数据。

读锁和写锁

      读锁:读锁又称为共享锁或 S 锁(Shared Lock),针对同一份数据,可以加多个读锁而互不影响。
      写锁:写锁又称为排他锁或者 X 锁(Exclusive Lock),如果当前写锁未释放,它会阻塞其他的写锁和读锁。

表锁、行锁和页面锁

      表锁:表级锁,在整个数据表上对数据进行加锁和释放锁。锁的粒度比较大,发生锁冲突的概率最高。在 MySQL 中有两种表级锁模式,一种是表共享锁(Table Shard Lock),另一种是表独占锁(Table Write Lock)。
      可以使用如下命令手动增加表锁:

lock table [tableName1] read(write),[tableName2] read(write);

      使用如下命令释放表锁:

unlock tables;

      行锁:行级锁,在数据行上对数据进行加锁和释放锁。开销比较大,加锁速度慢,可能会出现死锁,锁定的粒度最小,发生锁冲突的概率最小,并发读最高。在 InnoDB 存储引起中,有两种类型的行锁:共享锁和排他锁。

使用行锁时需要注意以下几点:

  1. 行锁主要加在索引上,如果对非索引的字段设置条件进行更新,行锁可能会变成表锁;
  2. InnoDB 的行锁时针对索引加锁,不是针对记录加锁,并且加锁的索引不能失效,否则行锁可能会变成表锁;
  3. 锁定某一行时,可以使用lock in share model命令来制定共享锁,使用for update命令来指定排他锁。

      页面锁:页级锁,在页面级别对数据进行加锁和释放锁。对数据的加锁开销介于表锁和行锁之间,可能会出现死锁,锁定的粒度大小介于表锁和行锁之间,并发度一般。

间隙锁和临键锁

      间隙锁:在 MySQL 中使用范围查询时,如果请求共享锁或排他锁,InnoDB 会给符合条件的已有数据的索引项加锁。如果键值在条件范围内,而这个范围内并不存在记录,则认为此时出现了间隙(GAP)。IonnDB 存储引擎会对这个间隙加锁,而这种加锁机制就是间隙锁(GAP Lock)。间隙锁在可重复读的隔离级别下在某些程度下可以解决幻读问题。
      account表数据如下所示。

mysql> select * from account;
+----+----------+---------+
| id | name     | balance |
+----+----------+---------+
|  1 | Jake     |     500 |
|  2 | Lucy     |     350 |
|  3 | Lily     |     500 |
|  4 | Meimei   |     200 |
| 15 | Zhangsan |     100 |
| 20 | Liming   |     200 |
+----+----------+---------+
6 rows in set (0.00 sec)

      account数据表中的间隙包括id为(4,15]、[15,20)、[20,+∞]的三个区间。
      开启事务A,执行以下命令将符合条件的用户的账户余额增加100。

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance=balance+100 where id>4 and id<16;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

      同时开启事务B,插入新用户:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account(id,name,balance) values(6,'wangwu',1000);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

      由于事务A 间隙锁的原因,导致事务B 获取锁超时。
      临键锁(Next-Key Lock):是行锁和间隙锁的组合,上面例子中的(15,20]就可以称为临键锁。

Last modification:December 1st, 2024 at 09:37 pm
如果觉得我的文章对你有用,请随意赞赏