learn-tech/专栏/周志明的架构课/12_本地事务如何实现隔离性?.md
2024-10-16 06:37:41 +08:00

14 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        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它是指在事务执行的过程中两个完全相同的范围查询得到了不同的结果集。比如我现在准备统计一下Fenixs 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它是指在事务执行过程中对同一行数据的两次查询得到了不同的结果。

比如说现在我要获取Fenixs 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 ControlMVCC的无锁优化方案被主流的商业数据库广泛采用。

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

MVCC的基础原理

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

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

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

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

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

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

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

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

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

小结

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

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

一课一思

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

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

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