12 _ 本地事务如何实现隔离性? 你好。我是周志明。

今天我们接着上一节课的话题,继续来探讨数据库如何实现隔离性。

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上,我们就能感觉到隔离性肯定与并发密切相关。如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。

但在现实情况中不可能没有并发,要在并发下实现串行的数据访问,该怎样做?几乎所有程序员都会回答到:加锁同步呀!现代数据库都提供了以下三种锁:

  • 写锁(Write Lock,也叫做排他锁eXclusive Lock,简写为X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
  • 读锁(Read Lock,也叫做共享锁Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有一个事务加了读锁,那可以直接将其升级为写锁,然后写入数据。
  • 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被读取,也不能被写入。如下语句是典型的加范围锁的例子: SELECT /* FROM books WHERE price < 100 FOR UPDATE;

请注意“范围不能写入”与“一批数据不能写入”的差别,也就是我们不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,这是一组排他锁的集合无法做到的。

本地事务的四种隔离级别

了解了这三种锁的概念之后,如果我们要继续探讨数据库是如何实现隔离性的,那就得先理解事务的隔离级别。接下来,我就按照隔离强度从高到低来给你一一介绍。

可串行化

串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。

可串行化比较符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为Two-Phase Lock)。

但数据库不考虑性能肯定是不行的,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,这样做的根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

可重复读

可串行化的下一个隔离级别是可重复读(Repeatable Read)。可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。

可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行的过程中,两个完全相同的范围查询得到了不同的结果集。比如我现在准备统计一下Fenix’s Bookstore中售价小于100元的书有多少本,就可以执行以下第一条SQL语句: SELECT count(1) FROM books WHERE price < 100 //* 时间顺序:1,事务: T1 // INSERT INTO books(name,price) VALUES (‘深入理解Java虚拟机’,90) // 时间顺序:2,事务: T2 // SELECT count(1) FROM books WHERE price < 100 // 时间顺序:3,事务: T1 /*/

那么,根据前面对范围锁、读锁和写锁的定义,我们可以知道,假如这条SQL语句在同一个事务中重复执行了两次,并且这两次执行之间,恰好有另外一个事务在数据库中插入了一本小于100元的书籍(这是当前隔离级别允许的操作),那这两次相同的查询就会得到不一样的结果。原因就是,可重复读没有范围锁来禁止在该范围内插入新的数据。

这就是一个事务遭到其他事务影响,隔离性被破坏的表现。

这里我要提醒你注意一个地方,我这里的介绍实际上是以ARIES理论作为讨论目标的,而具体的数据库并不一定要完全遵照着这个理论去实现。

我给你举个例子。MySQL/InnoDB的默认隔离级别是可重复读,但它在只读事务中就可以完全避免幻读问题。

比如在前面这个例子中,事务T1只有查询语句,它是一个只读事务,所以这个例子里出现的幻读问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,比如例子中的事务T1,如果在其他事务插入新书后,不是重新查询一次数量,而是要把所有小于100元的书全部改名,那就依然会受到新插入书籍的影响。

读已提交

可重复读的下一个隔离级别是读已提交(Read Committed)。读已提交对事务涉及到的数据加的写锁,会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。

读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。

比如说,现在我要获取Fenix’s Bookstore中《深入理解Java虚拟机》这本书的售价,同样让程序执行了两条SQL语句。而在这两条语句执行之间,恰好有另外一个事务修改了这本书的价格,从90元调整到了110元,如下所示: SELECT /* FROM books WHERE id = 1; //* 时间顺序:1,事务: T1 // UPDATE books SET price = 110 WHERE ID = 1; COMMIT; // 时间顺序:2,事务: T2 // SELECT / FROM books WHERE id = 1; COMMIT; //* 时间顺序:3,事务: T1 /*/

所以到这里,你其实也会发现,如果隔离级别是读已提交,那么这两次重复执行的查询结果也会不一样。原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。而此时,事务T2中的更新语句可以马上提供成功,这也是一个事务遭到其他事务影响,隔离性被破坏的表现。

不过,假如隔离级别是可重复读的话,由于数据已被事务T1施加了读锁,并且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。

读未提交

读已提交的下一个级别是读未提交(Read Uncommitted)。读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。

读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行的过程中,一个事务读取到了另一个事务未提交的数据。

比如说,我觉得《深入理解Java虚拟机》从90元涨价到110元是损害消费者利益的行为,又执行了一条更新语句,把价格改回了90元。而在我提交事务之前,同事过来告诉我,这并不是随便涨价的,而是印刷成本上升导致的,按90元卖要亏本,于是我随即回滚了事务。那么在这个场景下,程序执行的SQL语句是这样的: SELECT /* FROM books WHERE id = 1; //* 时间顺序:1,事务: T1 // // 注意没有COMMIT // UPDATE books SET price = 90 WHERE ID = 1; // 时间顺序:2,事务: T2 // // 这条SELECT模拟购书的操作的逻辑 // SELECT / FROM books WHERE id = 1; //* 时间顺序:3,事务: T1 // ROLLBACK; // 时间顺序:4,事务: T2 /*/

不过,在我修改完价格之后,事务T1已经按90元的价格卖出了几本。出现这个问题的原因就在于,读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,也就是我前面所说的,事务T1中两条查询语句得到的结果并不相同。

这里,你可能会有点疑问,“为什么完全不加读锁,反而令它能读到其他事务加了写锁的数据”,这句话中的“反而”代表的是什么意思呢?不理解也没关系,我们再来重新读一遍写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据。

所以说,如果事务T1读取数据时,根本就不用去加读锁的话,就会导致事务T2未提交的数据也能马上就被事务T1所读到。这同样是一个事务遭到其他事务影响,隔离性被破坏的表现。

那么,这里我们假设隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁。而读已提交级别是要求先加读锁后读数据的,所以T1中的查询就会被阻塞,直到事务T2被提交或者回滚后才能得到结果。

理论上还有更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write,即一个事务没提交之前的修改可以被另外一个事务的修改覆盖掉),脏写已经不单纯是隔离性上的问题了,它会导致事务的原子性都无法实现,所以一般隔离级别不会包括它,会把读未提交看作是最低级的隔离级别。

这四种隔离级别属于数据库的基础知识,多数大学的计算机课程应该都会讲到,但不少教材、资料都把它们当作数据库的某种固有设定来进行讲解,导致很多人只能对这些现象死记硬背。其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。

除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读+另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

接下来我们就一起讨论下MVCC。

MVCC的基础原理

MVCC是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。

这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID(事务ID是一个全局严格递增的数值),然后:

  • 数据被插入时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。
  • 数据被删除时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。
  • 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务ID,CREATE_VERSION为空。复制出来的新数据的CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为空。

此时,当有另外一个事务要读取这些发生了变化的数据时,会根据隔离级别来决定到底应该读取哪个版本的数据:

  • 隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被Commit的那个版本的数据记录。

另外,两个隔离级别都没有必要用到MVCC,读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以查看到,根本无需版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时无锁优化的,自然就不会放到一起用。

MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,加锁几乎是唯一可行的解决方案。

稍微有点讨论余地的是“乐观加锁”(Optimistic Locking)或“悲观加锁”(Pessimistic Locking),对此我们还可以根据实际情况去商量一下。

前面我介绍的加锁都属于悲观加锁策略,也就是数据库认为如果不先做加锁再访问数据,就肯定会出现问题。与之相对的,乐观加锁策略认为,事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该一开始就加锁,而是应当出现竞争时再找补救措施。这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),这一点我就不再展开了。不过提醒一句,不要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而会更慢

小结

今天的内容再加上上一讲,这两节课我们总结了本地事务中原子性、持久性和隔离性的实现模式。如果你是后端程序员,只要你实际开发过用于生产的软件系统,几乎一定会使用过本地事务。

但在Spring等框架的声明式事务的简化下,对多数程序员来说,事务可能仅仅是一个注解、一种概念,却未必真正理解它们的原理和运作。希望通过这两节课的学习,你能对这些常用却不常为人所注意到的知识点有更进一步的理解。

一课一思

现在大多数系统都把本地事务控制在底层,在系统特定分层中开启和结束,对普通开发人员尽量透明。你在开发时会考虑事务吗?你认为以上“透明式”的事务管理是否合适?普通开发人员是否应该意识到事务的存在?

欢迎在留言区分享你的见解。如果你身边的朋友,也对实现本地事务中隔离性的方法感兴趣,欢迎你把今天的内容分享给TA,我们一起交流探讨。

好,感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/12%20_%20%e6%9c%ac%e5%9c%b0%e4%ba%8b%e5%8a%a1%e5%a6%82%e4%bd%95%e5%ae%9e%e7%8e%b0%e9%9a%94%e7%a6%bb%e6%80%a7%ef%bc%9f.md