【MySQL】事务详解

发布于:2024-11-29 ⋅ 阅读:(10) ⋅ 点赞:(0)

一、事务概念

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

在这里插入图片描述

SHOW ENGINES命令可以来查看当前MySQL支持的存储引I擎都有哪些,以及这些存储引I擎是否支持事务。能看出在MySQL中,只有InnoDB是支持事务的。

事务(Transaction)是一组逻辑操作单元,使数据从一种状态变换到另一种状态。

事务处理的原则:保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态。


二、事务的ACID特性

  • 原子性(Atomicity:原子性是指事务是一个不可分割的工作单位。一个事务中的所有操作,要么全部完成,要么失败回滚,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
    • 如果无法保证原子性,就会出现数据不一致的情形,A账户减去100元,而B账户增加100元操作失败,系统将无故丢失100元。
  • 一致性(Consistency:根据定义,一致性是指事务执行前后,数据从一个合法状态变换到另一个合法状态。这种状态是语义上的,而不是语法上的,跟具体的业务有关。满足预定的约束的状态就叫做合法的状态。通俗一点,这状态是由你自己来定义的(比如满足现实世界中的约束)。满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的!如果事务中的某个操作失败了,系统就会自动撤销当前正在执行的事务,返回到事务操作之前的状态。
    • 比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。因此此处定义了一个状态,要求A+B的总余额不变。
  • 隔离性(Isolation:事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
    • 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。
  • 持久性(Durability:持久性是指一个事务一旦被提交,它对数据库中数据的改变就是水久性的,接下来的其他操作和数据库故障不应该对其有任何影响。

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

InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?

  • 持久性是通过 redo log (重做日志)来保证的。
  • 原子性是通过 undo log(回滚日志) 来保证的。
  • 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的。
  • 一致性则是通过持久性+原子性+隔离性来保证。

数据库事务,其实就是数据库设计者为了方便起见,把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称为一个事务。

事务的状态

事务其实对应着一个或多个数据库操作,MySQL根据这些操作所执行的不同阶段,把事务分为几个阶段。从开始到结束经过不同的阶段,以下是数据库事务的五种典型状态:

  1. Active(活动状态)
    事务在开始执行后进入活动状态,此时事务正在执行操作,还没有提交或回滚。事务在这一阶段可以进行增删改查等操作。
  2. Partially Committed(部分提交状态)
    当事务执行完所有操作,准备提交时,进入部分提交状态。此时事务已完成全部数据库操作,但还未将数据真正持久化(操作都在内存中执行,所造成的影响并没有刷新到磁盘)。因此,事务在这一阶段并不一定成功,需要等待提交完成。
  3. Failed(失败状态)
    如果事务在执行过程中遇到错误或故障,进入失败状态。发生故障可能是由于系统崩溃、数据冲突或违反了约束条件等。此时事务无法继续进行,数据库系统会中止该事务并回滚已执行的操作。
  4. Aborted(中止状态)
    当事务进入失败状态后,数据库系统执行回滚操作,将事务已完成的操作撤销,恢复到事务开始之前的状态。事务回滚完成后,进入中止状态。此时数据库可以选择重新启动事务,或放弃该事务。
  5. Committed(已提交状态)
    当事务的所有操作成功执行且持久化后,进入已提交状态。已提交状态表示事务的操作已永久写入数据库(即从内存写入到磁盘),即使系统出现崩溃,也不会丢失数据。进入此状态后,事务就算完成了全部执行流程。

在这里插入图片描述

这些事务状态是数据库管理系统(DBMS)实现事务管理的核心机制。通过这些状态,DBMS可以确保事务的ACID特性,即原子性、一致性、隔离性和持久性,保证数据的可靠性和一致性。


三、事务的使用

事务处理transaction processing)可以用来维护数据库的完整性,它保证成批的MySQL操作要么完全执行,要么完全不执行。

  • 保留点(savepoint)指事务处理中设置的临时占位符(place-holder),你可以对它发布回退(与回退整个事务处理不同)。

MySQL使用下面的语句来标识事务的开始:

START TRANSACTION [ read only / read write / WITH CONSISTENT SNAPSHOT]
# 或
begin
  • READ ONLY:开启一个只读事务,用于确保事务期间不会对数据进行修改。适用于查询操作密集的情况,能优化性能。
  • READ WRITE:开启一个读写事务,这是默认选项。允许在事务中对数据进行增删改操作。
  • WITH CONSISTENT SNAPSHOT:启动一致性读。用于设置一致性快照(Snapshot),在 REPEATABLE READ 隔离级别下确保事务在启动时所有读取操作的数据一致。适合需要读取一致性数据的情况。

补充:只读事务中只是不允许修改那些其他事务也能访问到的表中的数据,对于临时表来说(我们使用CREATE IMEPORARY TABLE创建的表),由于它们只能在当前会话中可见,所以只读事务其实也是可以对临时表进行增、删、改操作的。

BEGINSTART TRANSACTION 的一种简写形式,用于启动事务,但不能附加参数。

MySQL的ROLLBACK用来回退MySQL语句,而且只能在一个事务处理内使用,即在执行一条 START TRANSACTION后。

事务处理可以用来管理INSERTUPDATEDELETE。不能回退selectCREATEDROP操作。

一般的MySQL语句都是直接针对数据库表执行和编写的。这就是所谓的隐含提交implicit commit),即提交(写或保存)操作是自动进行的。

mysql> show variables like "autocommit";
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set, 1 warning (0.03 sec)

默认情况是on,如果 autocommitON,则每条独立的 SQL 语句会自动提交,一旦执行,数据更改将立即生效,无法回滚。

  • autocommit = ON:每条 SQL 语句在执行后立即提交,这相当于每条语句都是一个独立的事务。默认情况下,MySQL 的 autocommit 变量是开启的,即 autocommit = ON

  • autocommit = OFF:多条语句可以作为一个事务进行处理,直到显式执行 COMMITROLLBACK。在 autocommit = OFF 的情况下,可以更灵活地控制事务的提交或回滚。

可以使用以下命令来修改 autocommit 的设置:

  1. 在当前会话中关闭 autocommit
    此设置仅在当前连接有效,一旦连接断开,设置恢复默认。

    SET autocommit = 0;  -- 或 SET autocommit = OFF;
    
  2. 开启 autocommit

    SET autocommit = 1;  -- 或 SET autocommit = ON;
    
  3. 全局设置 autocommit
    使用 GLOBAL 参数可以在全局范围内修改 autocommit,对所有会话生效,前提是有足够权限。

    SET GLOBAL autocommit = 0;
    
-- 关闭自动提交
SET autocommit = 0;

-- 开启一个事务
START TRANSACTION;

-- 执行数据操作
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;

-- 事务提交,数据更改才会生效
COMMIT;

-- 如果不提交,可以回滚
ROLLBACK;

但是,在事务处理块中,提交不会隐含地进行。为进行明确的提交,使用COMMIT语句。

COMMITROLLBACK语句执行后,事务会自动关闭。(将来的更改会隐含提交)。

简单的ROLLBACKCOMMIT语句就可以写入或撤销整个事务处理。但是,只是对简单的事务处理才能这样做,更复杂的事务处理可能需要部 分提交或回退

为了支持回退部分事务处理,必须能在事务处理块中合适的位置放置占位符

这样,如果需要回退,可以回退到某个占位符。 这些占位符称为保留点。为了创建占位符,可如下使用SAVEPOINT 语句:

SAVEPOINT delete1;

每个保留点都取标识它的唯一名字,以便在回退时,MySQL知道要回退到何处。为了回退到本例给出的保留点,可如下进行:

ROLLBACK TO delete1;

保留点在事务处理完成后自动释放(执行一条ROLLBACKCOMMIT)。可以使用 RELEASE SAVEPOINT明确释放保留的。

隐式提交数据的情况

在 MySQL 中,即使没有显式地使用 COMMIT 提交事务,有些操作会导致事务被 隐式提交。这意味着一旦这些操作执行,当前事务会自动结束并提交所做的更改。以下是会触发事务隐式提交的几种常见情况:

  1. DDL 语句(数据定义语句)

DDL 语句会自动提交事务。即使在事务中包含 DDL 语句,MySQL 也会在执行前隐式提交当前事务,并在执行后再次提交。这些语句包括:

  • CREATE:创建数据库或表
  • DROP:删除数据库或表
  • ALTER:修改表结构
  • RENAME:重命名表
  • TRUNCATE:清空表

示例

START TRANSACTION;
INSERT INTO users (name) VALUES ('Alice');
... -- 事务中的其他语句

CREATE TABLE new_table (id INT);  -- 隐式提交,此语句会隐式的提交前边语句所属的事务
-- 事务被提交,前面的插入操作已生效
  1. 更改数据库的结构或设置的操作

更改数据库的配置、权限等,也会导致隐式提交。例如:

  • SET AUTOCOMMIT:更改自动提交模式
  • SET TRANSACTION:更改事务隔离级别
  • GRANTREVOKE:修改用户权限
  • RENAME USERDROP USER
  • ALTEE USERCREATE USER

示例

START TRANSACTION;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1;
SET AUTOCOMMIT = 1;  -- 隐式提交
-- 事务已被提交,之前的更新操作已生效
  1. LOCK 和 UNLOCK 操作

一些锁操作也会导致事务隐式提交,例如:

  • LOCK TABLES:显式锁定表
  • UNLOCK TABLES:解锁表

在执行这些操作之前,MySQL 会隐式提交当前事务。

示例

START TRANSACTION;
DELETE FROM orders WHERE order_id = 123;
LOCK TABLES orders WRITE;  -- 隐式提交
-- 事务已被提交,之前的删除操作已生效
  1. 分析表和优化表

以下操作也会触发隐式提交:

  • ANALYZE TABLE:分析表的统计信息
  • OPTIMIZE TABLE:优化表
  • REPAIR TABLE:修复表

这些操作在执行前会隐式提交当前事务。

示例

START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 456;
ANALYZE TABLE inventory;  -- 隐式提交
-- 事务已被提交,更新操作已生效

在 MySQL 中,某些特定的操作会导致事务隐式提交。在事务操作中应避免使用这些语句,以免意外提交事务导致数据不一致。


四、事务的隔离级别

MySQL 是一款基于客户端/服务器模型的数据库软件。单个 MySQL 服务器可以同时接受多个客户端的连接,每个客户端一旦与服务器建立连接,就会形成一个独立的会话(Session。在这些会话中,客户端可以发送 SQL 请求给服务器,而这些请求可能包含在一个事务内。服务器能够并行处理多个事务。

事务的一个重要特性是隔离性,这意味着当一个事务正在访问某个数据时,理论上其他事务应该等待,直到该事务完成并提交后,其他事务才能访问相同的数据。然而,如果严格遵循这种做法,会对数据库性能造成较大影响。

由于MySQL 服务端是允许多个客户端连接的,这意味着 MySQL 会出现同时处理多个事务的情况。

那么在同时处理多个事务的时候,就可能出现脏写(dirty write)、脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题

在数据库并发事务处理时,隔离性和并发性往往需要进行权衡。为了在不同的隔离需求和并发性能之间取得平衡,事务隔离性在SQL标准中被分为四种级别:读未提交(Read Uncommitted)读已提交(Read Committed)可重复读(Repeatable Read)串行化(Serializable)。以下是常见的并发问题及其对应的隔离级别要求:

  1. 脏写(Dirty Write):对于两个事务 Session A、Session B,如果事务Session A 修改了另一个未提交事务Session B修改过的数据就意味着发生了脏读。如果一个事务「写了或修改了」了另一个「未提交事务修改过的数据」,就意味着发生了「脏写」现象。
  2. 脏读(Dirty Read):如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。
  3. 不可重复读(Non-Repeatable Read):在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。
  4. 幻读(Phantom Read):在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前读取没有获取到的记录。


五、SQL中的四种隔离级别

  • 脏读:读到其他事务未提交的数据;
  • 不可重复读:前后读取的数据不一致;
  • 幻读:前后读取的记录数量不一致。

脏写问题是每个隔离级别都不会发生的。查看数据库的隔离级别:

mysql> show variables like "transaction_isolation";
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1 row in set (0.00 sec)

上面介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题有轻重缓急之分,我们给这些问题按照严重性来排一下序:

脏写 > 脏读 > 不可重复读 > 幻读

我们可以舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越,并发问题发生的就越多。 SQL标准 中设立了4个隔离级别。

隔离级别越高,性能效率就越低。按隔离水平高低排序如下:

  • 读未提交(read uncommitted:指一个事务还没提交时,它做的变更就能被其他事务看到。在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。

    • 事务能够读取其他事务未提交的数据。
  • 读提交(read committed:指一个事务提交之后,它做的变更才能被其他事务看到。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。

    • 事务只能读取已提交的数据。
  • 可重复读(repeatable read:指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别。事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。

    • 事务在执行期间可以多次读取同一数据且读取结果一致。
  • 串行化(serializable:会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。

    • 强制事务按顺序执行。

为了在隔离性和并发性之间进行权衡,可以根据业务需求选择适当的隔离级别。一般来说:

  • 若系统对数据一致性要求较低,可以选择读未提交读已提交,提高并发性。
  • 若系统对数据一致性要求较高且能接受适当的性能损失,可选择可重复读串行化隔离级别。

选择隔离级别时,企业需要在系统的吞吐量和数据的一致性之间找到平衡,以满足不同场景下的需求。

在读未提交下的脏读:

时间 事务A 事务B
T1 set session transaction isolation level read uncommitted;
start transaction;(开启事务)
update account set balance = balance+100 where id=1;
select * from account where id=1;结果为200
T2 set session transaction isolation level read uncommitted;
start transaction;
select * from account where id=1;查询余额结果为200,脏读
T3 rollback;
T4 commit;
T5 select * from account where id=1;查询余额结果为100

T1时刻,Session A设置隔离级别为 读未提交。 然后开始事务操作,将 id=1 的账户余额增加了 100,结果余额为 200。随后,Session A查询 id=1 的余额,得到了 200 的结果。

T2时刻,Session B设置隔离级别为 read uncommitted 并开启事务。它随后执行查询操作,读取 id=1 的余额。因为隔离级别为“未提交读”,Session B 可以读取到Session A 尚未提交的数据,因此查询得到了余额 200 的结果。这种现象被称为脏读(Dirty Read),因为Session B 读取了Session A 的未提交更新数据。

在时间点 T3,Session A 回滚了之前的操作,使账户余额恢复到更新前的状态。

在时间点 T4,Session B提交事务。

在时间点 T5,系统查询 id=1 的余额,得到了 100 的结果,说明Session A的更新被回滚,Session B 在回滚后读取到的 200 是不正确的。

MySQL不允许脏写,因此在脏写时会发生阻塞:

时间 事务A 事务B
T1 set session transaction isolation level read uncommitted;
start transaction;(开启事务)
update account set balance = balance-100 where id=1;
update account set balance = balance+100 where id=2;
select * from account where id=1;结果为0
T2 set session transaction isolation level read uncommitted;
start transaction;
select * from account where id=2; 结果为100
update account set balance = balance-100 where id=2;更新语句被阻塞
T3 rollback;
T4 commit

时间点T1,Session A设置隔离级别为 读未提交。 然后开始事务操作,它执行了两次更新操作:第一次将 id=1 的账户余额减少 100,然后将 id=2 的账户余额增加 100,导致 id=1 的余额为 0。

时间点T2,Session B设置隔离级别为 read uncommitted 并开启事务。它随后执行查询操作,读取 id=2 的余额。因为隔离级别为“未提交读”,随后,Session B尝试将 id=2 的余额减少 100,但更新语句被阻塞,因为 id=2 的行被Session A锁定。

时间点 T3,Session A 回滚了之前的操作,使账户余额恢复到更新前的状态。

时间点 T4,Session B提交事务。但它的更新语句成功执行,此时 id=1的余额为200和id=2的余额为-100。

在读已提交下,发生的不可重复读:

时间 事务A 事务B
T1 set session transaction isolation level read committed;
start transaction;
select * from account where id=2; #结果为0
T2 set session transaction isolation level read committed;
start transaction;
update account set balance = balance+100 where id=2;
select * from account where id=2; #结果为100
T3 select * from account where id=2; #结果仍然为0,未发生脏读
T4 commit;
T5 select * from account where id=2;#结果为100
commit;

时间点 T1:事务 A 设置隔离级别为 read committed 并开启事务。事务 A 执行 SELECT 查询 id=2 的账户余额,结果为 0,因为当前事务还未对该行进行更新。

时间点 T2:事务 B 设置隔离级别为 read committed 并开启事务。随后,事务 B 更新了 id=2 的余额,加上 100,使余额为 100。此时,事务 B 查询 id=2 的余额,结果为 100,但未提交该更改。

时间点 T3:事务 A 再次查询 id=2 的余额,结果仍然是 0,因为事务 B 尚未提交。此时,由于隔离级别为 “已提交读”,事务 A 只能读取已提交的更改,因此未发生脏读。

时间点 T4:事务 B 提交事务,使余额更新对其他事务可见。

时间点 T5:事务 A 再次查询 id=2 的余额,结果为 100,因为事务 B 已提交更新。这一变化使得事务 A 第三次读取时看到的值与之前不同,这就是 “不可重复读” 的现象。

在 “已提交读” 隔离级别下,事务 A 在未发生脏读的前提下,遇到了不可重复读的问题。事务 B 在提交更改之前,事务 A 无法读取到事务 B 的未提交数据,防止了脏读。

在可重复读条件下解决了不可重复读:

时间 事务A 事务B
T1 set session transaction isolation level repeatable read;
start transaction; (开启事务)
select * from account where id=2; #结果为 0
T2 set session transaction isolation level repeatable read;
start transaction;
update account set balance = balance+100 where id=2;
select * from account where id=2; #结果为 100
T3 commit;
T4 select * from account where id=2; #结果依然为 0
commit;
select * from account where id=2; #结果为 100

时间点 T1:事务 A 设置隔离级别为 repeatable read,开启事务,并查询 id=2 的账户余额。此时,余额结果为 0,因为在事务 A 开始前没有其他事务更新 id=2 的账户数据。

时间点 T2:事务 B 设置隔离级别为 repeatable read,开启事务。事务 B 更新 id=2 的余额,增加 100,使余额变为 100。事务 B 随后查询 id=2 的余额,结果为 100,这表明更新在事务 B 内部是可见的。

时间点 T3:事务 B 提交了更新,使得余额变更对其他事务(如事务 A)也变得可见。此时,数据库中 id=2 的余额正式更新为 100。

时间点 T4:事务 A 再次查询 id=2 的余额,结果依然是 0。这是因为在 可重复读 隔离级别下,事务 A 自事务开始以来的数据视图是一致的,它看不到事务 B 在其事务期间的更改,因此读到的余额仍为事务开始时的数据(0)。

时间点 T5:事务 A 提交事务后,再次查询 id=2 的余额,此时结果为 100。提交事务后,事务 A 的数据视图更新为最新的数据视图,因此读取到事务 B 提交后的更新结果。

可重复读 隔离级别下,事务 A 在整个事务过程中,读取同一数据项的结果保持一致,不受其他事务提交影响。事务 A 在事务期间的所有读取操作都是基于事务开始时的数据快照,因此无法看到事务 B 提交的更新。

在可重复条件下发生的幻读:

时间 事务A 事务B
T1 set session transaction isolation level repeatable read;
start transaction; (开启事务)
select count(*) from account where id = 3; #结果为 0
T2 set session transaction isolation level repeatable read;
start transaction;
insert into account (id,name,balance)values(3,'王五',0);
T3 insert into account (id,name,balance)values(3,'王五',0);#主键重复插入失败
T4 select count(*) from account where id = 3 ; #结果依然为 0
rollback;

时间点 T1:事务 A 将隔离级别设置为 repeatable read,并开启事务。事务 A 查询 id=3 的记录数量,结果为 0,因为在事务 A 开始之前,该记录尚不存在。

时间点 T2:事务 B 将隔离级别设置为 repeatable read,并开启事务。事务 B 向 account 表插入一条新记录 (id=3, name='王五', balance=0)

时间点 T3:事务 A 尝试插入 (id=3, name='王五', balance=0) 的记录,由于事务 B 在此时已经插入了该记录,但事务 A 无法看到这个插入操作,因此导致主键重复错误。

时间点 T4:事务 A 再次查询 id=3 的记录数量,结果仍然是 0,因为在 Repeatable Read 隔离级别下,事务 A 保持了它在事务开始时的视图。事务 A 回滚,结束事务。

Repeatable Read 隔离级别下,事务 A 无法看到事务 B 插入的 id=3 记录,因为 MySQL 的可重复读隔离级别实现了“当前读”视图锁定,从而避免了不可重复读问题。事务 A 在插入时未能检测到已经存在的记录,导致主键冲突。实际上,这就是 幻读 问题的体现:事务 A 读取到的记录集在其事务过程中未能及时更新。

幻读并不是说,两次业务读取获取的结果集不同,幻读侧重的方面是某一次的selcet操作得到的结果所表征的数据状态无法支持后续的业务操作。也就是说,select某记录是否存在,不存在,准备插入此记录,但执行insert时发现此记录已存在,无法插入,此时就发生了幻读。

Repeatable Read隔离级别 也是能够避免幻读的,方法是通过对 SELECT 操作手动加行级独占锁(行X锁),这正是 SERIALIZABLE 隔离级别下隐式执行的操作。即便当前记录不存在(例如 id=3 不存在),当前事务也会获得一把记录锁。由于 InnoDB 的行锁是基于索引锁定的,因此无论记录是否存在,事务都会对该索引加锁:

  • 如果记录存在,MySQL 为其加行级X锁(独占锁)。
  • 如果记录不存在,MySQL 会加间隙锁(Gap Lock),确保没有其他事务在该位置插入新数据。

因此,其他事务无法插入或修改此索引上的记录,从而有效避免了幻读。

SERIALIZABLE 隔离级别下,执行步骤 1 时,系统会隐式地为该查询添加行级锁(X锁)和间隙锁(Gap Lock)。这样,步骤 2 将被阻塞,直到Session A 提交。此时,步骤 3 执行时,Session B会因为主键冲突而执行失败。对于Session A 来说,之前的读取结果完全可以支撑后续的业务操作。成功地阻塞了Session B,避免了对Session A 业务的干扰。

因此,MySQL 的幻读 并非指读取两次返回的结果集不同,而是指在事务尝试插入记录时,发现记录已经存在,之前的读取结果就如同“鬼影”一般,给事务带来了不可预见的错误。


六、事务的分类

从事务理论的角度来看,事务可以根据其结构和实现方式分为以下几种类型,每种类型在不同的应用场景下具有各自的优点和适用性:

  • 扁平事务Flat Transactions
  • 带有保存点的扁平事务Flat Transactions with Savepoints
  • 链事务Chained Transactions
  • 嵌套事务Nested Transactions
  • 分布式事务Distributed Transactions

  1. 扁平事务(Flat Transactions)

    • 定义:扁平事务是最基本的事务类型,通常由一组连续的操作组成,所有操作作为一个整体执行。在事务中,要么所有操作都成功并提交,要么在遇到错误时全部回滚。

    • 特点:这种事务没有子事务或嵌套结构,所有操作在一个层级上执行,不支持分段回滚。

    • 应用场景:适用于较简单的业务流程,要求事务内的所有操作要么全部成功要么全部失败。

    • 示例:

      BEGIN TRANSACTION;
      -- 一组数据库操作
      UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
      UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
      COMMIT;
      
  2. 带有保存点的扁平事务(Flat Transactions with Savepoints)

    • 定义:这种事务在扁平事务的基础上增加了**保存点(Savepoints)**的功能,允许在事务执行过程中设置多个保存点。如果发生错误,可以回滚到指定的保存点而不是整个事务的起点。

    • 特点:带有保存点的事务能够部分回滚,增加了事务的灵活性。使用 SAVEPOINT 设置保存点,使用 ROLLBACK TO SAVEPOINT 回滚到指定保存点。

    • 应用场景:适用于较长或复杂的事务流程,在某些操作失败时不需要全部回滚,仅需部分回滚。

    • 示例:

      BEGIN TRANSACTION;
      SAVEPOINT sp1;
      UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
      SAVEPOINT sp2;
      UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
      ROLLBACK TO sp1;  -- 回滚到保存点sp1
      COMMIT;
      
  3. 链事务(Chained Transactions)

    • 定义:链事务是一系列扁平事务的组合,每个事务完成后自动提交并启动下一个事务。这种事务结构强调事务间的顺序性,通常用于多步骤的业务流程。

    • 特点:链事务之间存在依赖关系,每个事务的成功与否可能影响后续事务的执行。链事务中的每个事务都是独立的,一旦提交无法回滚。

    • 应用场景:适用于业务流程的多个独立步骤中,每一步的成功会触发下一步,但不适用于单个事务内的数据一致性需求较高的场景。

    • 示例:

      -- 事务链的第一步
      BEGIN TRANSACTION;
      UPDATE orders SET status = 'shipped' WHERE order_id = 1;
      COMMIT;
      
      -- 事务链的第二步
      BEGIN TRANSACTION;
      INSERT INTO logs (event, timestamp) VALUES ('Order Shipped', NOW());
      COMMIT;
      
  4. 嵌套事务(Nested Transactions)

    • 定义:嵌套事务允许在一个事务中嵌套多个子事务,每个子事务可以独立地提交或回滚。当子事务失败时,可以仅回滚该子事务,而不影响父事务的其他子事务。

    • 特点:嵌套事务允许更细粒度的控制,可以独立地处理事务中的一部分。通常只有当所有子事务都成功时,父事务才会提交。

    • 应用场景:适用于复杂业务逻辑,事务中包含多个子任务,每个子任务都需要独立的事务控制。

    • 示例:

      BEGIN TRANSACTION;  -- 父事务
      -- 子事务1
      BEGIN TRANSACTION;
      UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
      COMMIT;
      
      -- 子事务2
      BEGIN TRANSACTION;
      UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
      ROLLBACK;  -- 如果子事务2失败,仅回滚子事务2
      
      COMMIT;  -- 提交父事务
      
  5. 分布式事务(Distributed Transactions)

    • 定义:分布式事务是跨多个数据库、多个服务器或多个系统的事务,通常用于协调不同资源管理器中的多个事务操作,保证整体数据一致性。
    • 特点:分布式事务涉及多个独立的数据源,通常依赖于两阶段提交(2PC)或三阶段提交(3PC)协议来保证所有参与方的数据一致性。
    • 应用场景:适用于需要跨多个数据库、多个系统操作的场景,如在微服务架构下确保数据一致性,或在跨系统的银行转账中。
    • 示例:在银行系统中,用户 A 在系统 A 上发起转账到用户 B,在系统 B 上需要确保转账数据一致性。
事务类型 特点 应用场景
扁平事务 简单结构,所有操作在一个层级中进行 单一的简单业务逻辑
带有保存点的扁平事务 支持设置保存点,可以实现部分回滚 复杂事务流程中的部分回滚需求
链事务 每个事务独立存在,顺序执行 多步骤、顺序性的业务流程
嵌套事务 支持子事务,每个子事务可以独立控制 复杂业务逻辑,多个子任务需要独立的事务控制
分布式事务 跨多个数据库或系统,确保全局数据一致性 多系统间的数据一致性要求,如银行转账或跨系统操作

不同类型的事务在不同场景下具有不同的优势。根据业务需求和系统架构,选择合适的事务类型能够有效提高系统的可靠性和数据一致性。