type
status
date
slug
summary
tags
category
icon
password

什么是事务

设想一下这样的情境,在现实生活中,有张三和李四这么两个人,他们之间是朋友,都到了同一个银行开了账户,那么就会有对这样的一张 account 表:
notion image
而有一天,李四突然急需用钱,打电话向张三借了 100 元,于是张三就给李四进行了转账 100 元的操作,那么对应到数据库中,就是执行了如下两条 SQL 语句:
但假如这时候服务器突然宕机了怎么办?也就是执行完了第一条 SQL 语句,但是还没来得及执行第二条 SQL 语句。这时就会产生把张三的钱扣了,但是李四并没有收到的情况,从而导致这 100 元不翼而飞了。
由此,为了解决这个问题,MySQL 引入了 事务 的机制,用来保证这类事情不会发生。开启事务之后,如果宕机之前只执行了第一条 SQL,那么在重启之后就会进行 回滚 操作,使数据库恢复到未执行之前的状态。

事务的特性

如何才能判断出一个事务是否能够保证数据库操作不出错呢?这就要从事务遵从的四大特性(ACID)来说明了。
  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成。
    • 如果事务在执行过程中发生错误,会被回滚到事务开始前的状态。就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品仍在商家手中,消费者的钱也没花出去。
  • 一致性(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 元)。
  • 隔离性(Isolation):防止多个事件并发执行时由于交错执行而导致数据不一致问题。
    • 允许多个并发事务同时对其数据进行读写和修改的能力,防止多个事务同时使用相同的数据时相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者 A 购买商品这个事务,是不影响其他消费者 B、C、 D 购买的。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的。
    • 在之后无论发生扫描事故,即便系统故障,本次事务的结果都不会丢失。

事务在并发下可能产生的问题

在实际的业务场景之中,每秒都可能会有成千上万个请求,与之对应的也就是每秒可能有若干个事务需要进行,在此过程中,最重要的就是要保证数据的一致性,即最终的数据是正确的。而在事务的四大特性之中,也就需要满足隔离性。但我们如果想要做到完全的隔离,就有可能导致性能过差,那么在此之中如何进行取舍呢?我们不妨先来看看可能会出现什么问题吧。

脏读

一个事务读到了另一个 “未提交” 事务修改过的数据。
发生时间序号
Session A
Session B
1
BEGIN;
2
BEGIN;
3
UPDATE user SET name = '张三' WHERE id = 1;
4
SELECT name FROM user WHERE id = 1; (如果此时 name 列的值为 '张三',就意味着发生了脏读)
5
COMMIT;
6
RILLBACK;
如上表所示,Session A 和 Session B 各自开启了事务,而 Session B 先将 id = 1 的记录中的 name 列数据改为 '张三',然后 Session A 才去读取 id = 1 的这条记录,得到的结果自然是 '张三'。但是如果此时 Session B 发生了回滚,那么 id = 1 的记录就会回退成 Session B 修改之前的状态,也就意味着 Session A 得到的 name 列数据就是的,这种现象就被称之为脏读。

不可重复读

在一个事务内多次读取同一个数据,出现前后两次读到的数据不一样的情况。
发生时间序号
Session A
Session B(隐式事务)
1
BEGIN;
2
SELECT name FROM user WHERE id = 1; (此时 name 列的值为 ‘张三’)
3
UPDATE user SET name = '李四' WHERE id = 1;
4
SELECT name FROM user WHERE id = 1; (如果此时 name 列的值为 '李四',就意味着发生了不可重复读)
5
UPDATE user SET name = '王五' WHERE id = 1;
6
SELECT name FROM user WHERE id = 1; (如果此时 name 列的值为 '王五',就意味着发生了不可重复读)
如上表所示,每次 Session B 的隐式事务提交之后,Session A 中正在运行的的事务都可以查看到当前数据库中最新的值,这种现象就被称之为不可重复读。

幻读

发生时间序号
Session A
Session B(隐式事务)
1
BEGIN;
2
SELECT name FROM user WHERE id > 0; (此时 name 列的值只有 ‘张三’ 一条记录)
3
INSERT INTO user VALUES (2, '李四');
4
SELECT name FROM user WHERE id > 0; (如果此时 name 列的值有 '张三'、'李四' 两条记录,就意味着发生了幻读)
如上表所示,Session A 中的事务先根据条件 id > 0这个条件查询表 user,得到了 name 列值为 '张三’ 的记录。之后 Session B 中提交了一个隐式事务,该事务向表 user 中插入了一条新记录;之后 Session A 中的事务再根据相同的条件 id > 0 查询表 user,得到的结果集中包含 Session B 中的事务新插入的那条记录。就如同出现了幻觉一般,所以这种现象被称之为幻读。
假如 Session B 中只是删除了一些符合 id > 0 的记录而不是插入新记录,从而导致 Session A 中之后再根据 id > 0 的条件读取到的记录变少了。这种情况不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后续读取时读到了之前没有读到的记录的情况。

严重性

以上三种问题其实也有轻重缓急之分,严重性大概如下:
脏读 > 不可重复读 > 幻读

事务的隔离级别

四种隔离级别

面对并发事务可能导致的问题,SQL 标准提出了四种隔离级别来规避这些问题。
  • 读未提交(read uncommitted)
    • 一个事务还没提交时,它做的变更就能被其他事务看到。
  • 读提交(read committed)
    • 一个事务提交之后,它做的变更才能被其他事务看到。
  • 可重复读(repeatable read)
    • 一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别
  • 串行化(serializable)
    • 会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
这四种隔离级别的隔离度如下:
串行化 > 可重复读 > 读已提交 > 读未提交
是否会发生
脏读
不可重复读
幻读
读未提交
读已提交
可重复度
串行化
如上表所示:
  • 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
  • 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
  • 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能发生脏读和不可重复读现象;
  • 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。

例子

设想这样一个场景,有一张 user 表,里面有一条 id = 1,balance = 100 的记录。然后有两个并发的事务,事务 A 只负责查询当前 id = 1 记录的剩余余额,事务 B 则会将 id = 1 的 balance 改成 200 ,下面是按照时间顺序执行两个事务的行为:
发生时间序号
Session A
Session B
1
BEGIN;
2
BEGIN;
3
SELECT balance FROM user WHERE id = 1; (查询得到当前 balance = 100)
4
SELECT balance FROM user WHERE id = 1; (查询得到当前 balance = 100)
5
UPDATE user SET balace = 200 WHERE id = 1; (更改 balance = 200)
6
SELECT balance FROM user WHERE id = 1; (查询得到当前 balance = B1)
7
COMMIT;
8
SELECT balance FROM user WHERE id = 1; (查询得到当前 balance = B2)
9
COMMIT;
10
SELECT balance FROM user WHERE id = 1; (隐式事务查询得到当前 balance = B3)
在不同隔离级别下,B1,B2,B3 的三个值可能有所不同:
  • 读未提交
    • 在 5 之后,虽然 Session B 并没有提交,但是已经可以被 Session A 读取到了,所以 B1,B2,B3 均为 200。
  • 读已提交
    • 在 5 之后,因为 Session B没有提交,所以此时 Session A 读取到的值 B1 仍旧是 100,但在 7 之后,Session A 就可以读取到最新的数据了,所以 B2,B3 为 200。
  • 可重复读
    • 在 1 之后,Session A 读取到的数值都以这一时刻为基准,因此即使在 7 之后,Session 读取到的 B2 值始终为 100,所以 B1,B2 为 100,B3 为 200。
  • 串行化
    • 在 4 时,因为 Session A 在 3 时进行了读操作,会导致 Session 的写操作发生冲突,从而导致 Session B 堵塞,所以直到 9 之后,Session B 才可以继续执行,所以 B1,B2 为 100,B3 为 200.

隔离级别是如何实现的

  • 读未提交
    • 因为可以读到未提交事务修改的数据,所以直接读取最新的数据就可以了。
  • 串行化
    • 通过加读写锁的方式来避免并行访问。
  • 读已提交 和 可重复读
    • 它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同。
      "读已提交" 是在 “每个语句执行前” 都会重新生成一个 Read View,而 “可重复读” 隔离级别是 “启动事务时” 生成一个 Read View,然后整个事务期间都在用这个 Read View。
Read View 可以理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。Read View 用于确保当前事务读取数据时读取到的是一个特定版本的数据,而不会读取到其他版本的数据。

参考资料:
  • 《MySQL是怎样运行的:从根儿上理解MySQL》
Tuwilt
Tuwilt
言辞不必有力