常见的数据库有四种隔离级别,从强到弱分别为:可串行化(Serializable)、可重复读(Repeatable Read)、读已提交(Read Committed)、读未提交(Read Uncommitted)。
不同隔离级别在实现上的本质是各种锁的使用有所不同,包括锁的多样性和锁的粒度。
现有的文章大多是直接深入到数据库的细节中讨论这几种隔离级别,而且介绍的也很全面了,这里我尝试站在锁的角度来对这几种隔离级别做个讨论。
可串行化(Serializable)
可串行化使用了最全的锁:写锁、读锁、范围锁。
读写锁平时比较常见,这里简单介绍下范围锁。
范围锁的定义为:对于某个范围直接加排他锁,在这个范围内的数据不能被写入。
要注意的时这里的访问内的数据不止包括已有的数据,即使不存在的数据也会被加锁,可以理解为不允许在这个范围内新增数据。
我举个例子,比如我现在有这样一些数据:
1 | id price |
当我们在一个事务中使用范围查询 price<100
时,在这个事务还未结束的情况下,其他事务无法在新增一个 price 为 20 的数据。
可串行化保障了最好的隔离级别,但也是这几种隔离级别中性能最差的。
可重复读(Repeatable Read)
可重复读只使用了读锁和写锁,未使用范围锁。
还用上边的数据举例,这种情况下会产生的一个问题是:当一个事务在第一次查询 price<100
时返回了 4 条数据,这时候另一个事务新增了一条 price 为 20 的数据,当第一个事务再次查询 price<100
的数据时发现变成了 5 条,也就是说出现了幻读。
幻读:在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。
读已提交(Read Committed)
读已提交表面上看和可重复度使用的锁相同,都使用了读锁和写锁,但在读锁的加锁粒度上和之前有所区别。
在上边的可重复读中,读锁是一直锁到事务结束,但在读已提交中,读锁在查询完成后会立即释放,下边我写两个 Go 程序来演示下这两种情况的区别。
可重复读程序(读锁锁到事务结束)
1 | package main |
输出:
1 | 1 |
读已提交程序(读锁锁到查询完成)
1 | package main |
输出:
1 | 1 |
这个程序和上边的程序区别在于读锁是锁了整个事务还是只锁了查询的瞬间,在读已提交的情况下,第一个事务读取数据并打印出 1 后就释放了读锁,这时候另一个事务可以拿到写锁并将数据修改为 2,之后第一个事务再次读取时就读到了另一个事务修改后的数据。
这种情况我们称之为不可重复读问题。
不可重复读问题:在事务执行过程中,对同一行数据的两次查询得到了不同的结果。
读未提交(Read Uncommitted)
读未提交只使用了写锁,同样我们也通过一个 Go 程序观察下这个情况。
1 | package main |
输出:
1 | 2 |
这里演示的是,一个事务对数据进行修改,另一个事务只是读取数据,由于在读未提交下不存在读锁,可以直接读数据。
- 数据初始值为 1,写事务将值修改为 2(但并未释放写锁)
- 读事务在 1 秒后读到了 2
- 写事务在 2 秒后又将数据修改为 3(由于后边的 sleep,也并没有立即释放写锁)
- 读事务在 3 秒后又读到了 3
可以看到,我们的只读事务读到了写事务还没有提交的数据,我们称之为脏读。
脏读:在事务执行过程中,一个事务读取到了另一个事务未提交的数据。
总结一下
锁 | 脏读 | 不可重复读 | 幻读 | 隔离级别 |
---|---|---|---|---|
写锁、读锁、范围锁 | 否 | 否 | 否 | 可串行化 |
读锁、写锁 | 否 | 否 | 是 | 可重复读 |
读锁(读完释放)、写锁 | 否 | 是 | 是 | 读已提交 |
写锁 | 是 | 是 | 是 | 读未提交 |