事务隔离:为什么你改了我还看不见?

SoruxGPT
发布于 2024-08-23 / 5 阅读
0

事务隔离:为什么你改了我还看不见?

事务隔离:为什么你改了我还看不见?

提到事务,你肯定会想到 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天我们就来说说其中 I,也就是“隔离性”。

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。

在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面我逐一为你解释:

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。

  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。

  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。

  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

mysql> create table T(c int) engine=InnoDB;insert into T(c) values(1);

  • 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。

  • 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。

  • 若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。

  • 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

事物隔离的实现

其实在MySQL中,每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值通过回滚操作都可以得到前一个状态的值。

假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。

同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

基于上面的说明,我们来讨论一下为什么建议你尽量不要使用长事务。长事务意味着系统里面会存在很老的事务视图。

由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

举个例子:

mysql> CREATE TABLE `t` (  `id` int(11) NOT NULL,  `k` int(11) DEFAULT NULL,  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

隔离等级:可重复读

事物A:查出来是1

事物B:查出来是3

其实对于表中的每一行都还有两个隐藏字段:trx_idroll_pointer

read view的结构如下:

是否可见的判断:

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;

  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;

  3. 如果落在黄色部分,那就包括两种情况

a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;

b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

总结就是:

版本未提交,不可见;

版本已提交,但是是在视图创建后提交的,不可见;

版本已提交,而且是在视图创建前提交的,可见。


这里,我们不妨做如下假设:

  1. 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;

  2. 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;

  3. 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

这样,事务 A 的视图数组就是[99,100], 事务 B 的视图数组是[99,100,101], 事务 C 的视图数组是[99,100,101,102]。

为了简化分析,我先把其他干扰语句去掉,只画出跟事务 A 查询逻辑有关的操作:

最开始更新的是事物C,事物C把值修改为了(1,2)。

然后事物B也要去更新,事物B的更新不能再从快照中获取数据,事物B这次的数据读取属于当前读操作,需要去数据库中拿到真实的数据,也就是(1,2),事物B自己的操作对自己是可见的,所以事物B查询到的值就是3。

而事物A无法看到事物B和C的操作,因为他们的操作都在事物A创建视图之后所以A看到的还是1。

其实,除了 update 语句外,select 语句如果加锁,也是当前读。

所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。

mysql> select k from t where id=1 lock in share mode;mysql> select k from t where id=1 for update;

如果C也开启一个事物,并且在B更新语句之后再commit会怎么样呢?

虽然事物C还没有提交,但是(1,2)这个版本已经生成了,也是当前最新的版本,那么事物B应该如何处理呢?

事物C还没有提交,也就是说(1,2)这个版本的写锁还没有得到释放,事物B是当前读,必须要读最新的版本,而且必须要加锁。在写锁没有释放之前,读锁是没有办法拿到的,也就没有办法读到数据,这就是数据库中的读写锁。

因此,事物B在事物C执行commit操作之前都会一直阻塞住。

事物A:查询出1

事物B:查询出3

如果是在读提交 事物隔离等级下呢?

如果时读提交,在每次执行sql语句之前read view都会更新一次。

那么A读取到值2,B读取到值3。