我对事务的理解
以下内容存属虚构,主要目的在于帮助自我理解四种隔离级别的由来,真正如何解决丢失更新问题,还请参见官网。
英文:https://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html
中文【翻译差,不推荐】:https://www.mysqlzh.com/
https://www.cnblogs.com/digdeep/p/4947694.html
大厂是如何解决丢失更新的【MySQL InnoDB存储引擎为例】
事务是指一组逻辑上的操作,要么都执行,要么都不执行。事务有四个特性:原子性、一致性、隔离性、持久性。
即多个操作要么都成功,要么都失败,这多个操作被视为一个原子性操作,不可分割。
执行事务的前后,数据保持一致。即:如果事务失败,则所有针对数据的操作,都视为无效,所有被操作的数据归还为原始数据。如果事务成功,则针对数据的操作,都视为有效,所有被操作的数据将变更内容。
事务被提交后,它对数据库中数据改变是持久的,即使数据库发生故障也不应该对其有影响。
【以下内容摘自《码农翻身:用故事给技术加点料》】事务的要求是要么全做,要么全不做,怎么实现这样一个功能呢?我们可以用一个叫做‘Undo’日志的方式来实现它。也就是说,在执行真正的操作之前,要先记录数据项原来的值。拿一个转账的例子来说,我们把这个事务命名为T1,在做真正的转账之前,一定要先把这样的日志写入日志文件中:
[T1,电子系原有余额,1000]
[T1,数学系原有余额,2000]
如果事务执行到一半就断电了,那数据库重启以后我们就根据Undo日志文件进行恢复,不管断电多少次,我们恢复的数据就是原始数据,所以余额并不会出现差错。但是在恢复数据的时候,怎么才能知道一个事务到底有没有完成呢?我们不仅需要在Undo日志文件中记录原始数据,还要记录一个事务开始和结束符,像这样:
[开始事务 T1] [T1,电子系原有余额,1000] [T1,数学系原有余额,2000] [提交事务 T1]
如果我在日志文件中看到了[提交事务 T1],或者[回滚事务 T1],我就知道这个事务已经结束,就不用再去理会它了,更不用做恢复。如果只看到[开始事务 T1],而找不到提交或回滚,那我就得恢复。比如下面这样:
[开始事务 T1] [T1,电子系原有余额,1000] [T1,数学系原有余额,2000]
特别是,我恢复以后,需要在日志文件中补上一行[回滚事务 T1],这样下次恢复我就可以忽略T1这个事务了。
但是由于事务日志也是一个文件,如果日志还没有写入文件就断电了怎么办呢?其实我们只要遵循以下两个原则就可以:
1、在把新余额写入硬盘的数据文件之前,一定要先把对应的日志写入硬盘的日志文件。如果日志文件都没写入就断电了,那数据自然不会被写入硬盘,相当于什么都没做。如果日志文件写入了,数据写入前后断电了,那就恢复数据,相当于用户的本次操作没有被执行过。
2、[提交事务 T1],这样的Undo日志记录一定要在所有新余额写入硬盘之后再写入日志文件。如果所有余额写入硬盘前后断电了,这是一条没有终止状态的事务,所以数据会被恢复,相当于用户本次操作无效,数据还变成原来的数据。
事务A与事务B同时存在,它们俩是互不干扰,完全隔离的,说到这我们先来讲一下完全隔离的两个事务应该是什么样的,完全隔离可以理解成:事务本身完全不考虑其他事务会不会对自己操作的数据有影响,所以对于一个事务来说,多次查询相同条件的数据,是没必要的,只需要记住第一次查询的数据就行了,因为它坚信没有其他事务会对它拿到的这批数据做更新。其次你需要了解的是对于select语句来说天然的就是一次查询,除此之外,如下update语句
update account set balance = balance - 500 where name = "电子系"
也会造成一次查询,在balance - 500的时候,会查询数据库中当前balance的值,所以如果一个事务里出现了如下两条SQL语句:
select balance from account where name = "电子系";
update account set balance = balance - 500 where name = "电子系"
则会将第一次查询(即select语句执行后)的balance,记录暂存起来,它的存储形式应该是类似下面这样的(为了下面方便我们给它起个名字叫firstQuery):
{"tabel":"account","index":"56","value":{"balance":1000,"name":"电子系"}}
在第二次update做更新的时候,由于update更新时也会查询一次,先从当前firstQuery里查找有没有符合当前条件的数据,如果有直接从暂存的数据里取出balance做计算,如果没有,重新查一次数据库,然后把数据存到firstQuery里,然后用重查的值做计算。最后把计算后的结果将写入数据库。
在读完上面“数据库事务的实现”章节之后,我们知道,在提交事务这样的日志记录写入日志文件之前,数据库是已经执行了操作了的,也就是说数据已经更新了,如果两个事务同时执行,你可能会得到如下两种日志记录情况【注意如下:“(***)”括号里是真实数据库操作,为了方便大家理解,我也写在了上面】:
[开始事务 T1] [T1,电子系原有余额,1000] 【T1第一次查询到where name = "电子系" 的balance,暂存它】 [T1,数学系原有余额,2000] 【T1第一次查询到where name = "数学系系" 的balance,暂存它】 [开启事务T2] [T2,电子系原有余额,1000] 【T2第一次查询到where name = "电子系" 的balance,暂存它】 [T2,数学系原有余额,2000] 【T2第一次查询到where name = "数学系" 的balance,暂存它】 (T2,电子系set balance = balance -500,执行,数据库中电子系余额:500) 【balance从T2事务第一次查询中取】 (T2,数学系set balance = balance +500,执行,数据库中数学系余额:2500) 【balance从T2事务第一次查询中取】 (T1,电子系set balance = balance -200,执行,数据库中电子系余额:800) 【balance从T1事务第一次查询中取】 (T1,数学系set balance = balance +200,执行,数据库中数学系余额:2200) 【balance从T1事务第一次查询中取】 [提交事务 T1] [回滚事务 T2] =》 [数据库中电子系余额:1000,数据库中数学系余额:2000]
用表格表示如下(接下来我们都尽量用表格表示,表格看的更清晰,表格红色代表最终结果异常,绿色代表正常):
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
事务T1和T2都进行了第一次查询,将查询后的数据都暂存到自己的firstQuery里 |
t3 |
|
电子系余额减去200,数学系余额加上200 |
T2使用自己firstQuery的数据进行操作,数据被同步到数据库,库中电子系余额:800,数学系余额2200 |
t4 |
电子系余额减去500,数学系余额加上500 |
|
T1使用自己firstQuery的数据进行操作,数据库被同步到数据库,库中电子系余额:500,数学系余额:2500 |
t5 |
提交事务T1 |
|
此刻提交事务T1,事务T1还自信的认为数据库中电子系和数学系余额没有被其他事务更改过。此刻电子系余额:500,数学系余额2500 |
t6 |
|
回滚事务T2 |
回滚到T2之前firstQuery时数据的样子。此刻T2还自信的认为没有人更改过这批数据。所以最终数据库的数据:电子系余额:1000,数学系余额2000 |
我们称这种情况为:第一类丢失更新,导致的原因很明显,事务并发执行,且完全封闭隔离,事务T2在回滚时,完全不考虑有没有其他事务对这批数据更新并提交过,不管三七二十一,直接用自己原来暂存的数据进行回滚,然后覆盖T1事务提交的数据。简而言之,只要回滚覆盖了提交后的数据【错误的责任归咎于回滚】,我们都称之为第一类丢失更新。
排列组合,罗列第一类丢失更新其他情况(情况很多,下面简单列出几个):
[开始事务 T1] [T1,电子系原有余额,1000] 【暂存】 [T1,数学系原有余额,2000] 【暂存】 [开启事务T2] [T2,电子系原有余额,1000] 【暂存】 [T2,数学系原有余额,2000] 【暂存】 (T1,电子系set balance = balance -200,执行,数据库中电子系余额:800) 【balance从T1事务第一次查询中取】 (T1,数学系set balance = balance +200,执行,数据库中数学系余额:2200) 【balance从T1事务第一次查询中取】 [提交事务 T1] (T2,电子系set balance = balance -500,执行,数据库中电子系余额:500) 【balance从T2事务第一次查询中取】 (T2,数学系set balance = balance +500,执行,数据库中数学系余额:2500) 【balance从T2事务第一次查询中取】 [回滚事务 T2] =》 [数据库中电子系余额:1000,数据库中数学系余额:2000]
[开始事务 T1] [T1,电子系原有余额,1000] 【暂存】 [T1,数学系原有余额,2000] 【暂存】 [开启事务T2] [T2,电子系原有余额,1000] 【暂存】 [T2,数学系原有余额,2000] 【暂存】 (T1,电子系set balance = balance -200,执行,数据库中电子系余额:800) 【balance从T1事务第一次查询中取】 (T1,数学系set balance = balance +200,执行,数据库中数学系余额:2200) 【balance从T1事务第一次查询中取】 (T2,电子系set balance = balance -500,执行,数据库中电子系余额:500) 【balance从T2事务第一次查询中取】 (T2,数学系set balance = balance +500,执行,数据库中数学系余额:2500) 【balance从T2事务第一次查询中取】 [提交事务 T1] [回滚事务 T2] =》 [数据库中电子系余额:1000,数据库中数学系余额:2000]
对比来看,本质都是一样的,都是由于事务完全封闭隔离状态下回滚覆盖了提交后的数据。
[开始事务 T1] [T1,电子系原有余额,1000] 【暂存】 [T1,数学系原有余额,2000] 【暂存】 [开启事务T2] [T2,电子系原有余额,1000] 【暂存】 [T2,数学系原有余额,2000] 【暂存】 (T1,电子系set balance = balance -200,执行,数据库中电子系余额:800) 【balance从T1事务第一次查询中取】 (T1,数学系set balance = balance +200,执行,数据库中数学系余额:2200) 【balance从T1事务第一次查询中取】 [提交事务 T1] (T2,电子系set balance = balance -500,执行,数据库中电子系余额:500) 【balance从T2事务第一次查询中取】 (T2,数学系set balance = balance +500,执行,数据库中数学系余额:2500) 【balance从T2事务第一次查询中取】 [提交事务 T2]
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
事务T1和T2都进行了第一次查询,将查询后的数据都暂存到自己的firstQuery里 |
t3 |
电子系余额减去200,数学系余额加上200 |
|
T1使用自己firstQuery里的数据进行操作,数据被同步到数据库,库中电子系余额:800,数学系余额2200 |
t4 |
提交事务T1 |
|
提交事务T1后,数据库电子系余额:800,数学系余额2200 |
t5 |
|
电子系余额减去500,数学系余额加上500 |
T2使用自己firstQuery里的数据进行操作,数据库被同步到数据库,库中电子系余额:500,数学系余额:2500 |
t6 |
|
提交事务T2 |
提交事务T1后,数据库电子系余额:500,数学系余额:2500。 |
我们称这种情况为:第二类丢失更新,导致的原因很明显,事务并发执行,事务完全封闭隔离状态下,T2事务在更新数据的时候完全不考虑T1有没有更新数据,不管三七二十一的,直接拿自己暂存的数据进行计算,然后覆盖T1提交的数据。简而言之,只要是一个事务提交导致另一个事务提交的数据异常情况【错误的责任归咎于提交】,我们都称之为第二类丢失更新。
排列组合,罗列第二类丢失更新的其它情况(情况很多,下面简单列出几个):
[开始事务 T1] [T1,电子系原有余额,1000] 【暂存】 [T1,数学系原有余额,2000] 【暂存】 [开启事务T2] [T2,电子系原有余额,1000] 【暂存】 [T2,数学系原有余额,2000] 【暂存】 (T2,电子系set balance = balance -500,执行,数据库中电子系余额:500) (T2,数学系set balance = balance 额+500,执行,数据库中数学系余额:2500) (T1,电子系set balance = balance -200,执行,数据库中电子系余额:800) (T1,数学系set balance = balance +200,执行,数据库中数学系余额:2200) [提交事务 T1] [提交事务 T2] =》 [数据库中电子系余额:500,数据库中数学系余额:2500]
对比来看,本质都是一样的,都是由于事务提交覆盖了另一个事务更新后的数据。
现在我们该如何解决这两种丢失更新的情况呢?如果你是数据库的设计者,你该怎么做?
不管是第一类丢失更新还是第二类丢失更新,导致的问题都是难以接受的,如何能解决这种问题?能想到最简单的方式就是给数据库中的记录加锁,比如说上面的案例:
[开始事务 T1] [T1,电子系原有余额,1000] - lock [T1,数学系原有余额,2000] - lock [开启事务T2] [T2,电子系原有余额,1000] - wait 【T2第一次查询电子系余额时未获得锁,进入等锁队列】 [T2,数学系原有余额,2000] - wait 【T2第一次查询数学系余额时未获得锁,进入等锁队列】 (T2,电子系原有余额-500,执行,数据库中电子系余额:500) - wait (T2,数学系原有余额+500,执行,数据库中数学系余额:2500) - wait (T1,电子系原有余额-200,执行,数据库中电子系余额:800) - 持有lock锁,继续操作 (T1,数学系原有余额+200,执行,数据库中数学系余额:2200) - 持有lock锁,继续操作 [提交事务 T1] - release ==== while等待T1释放锁后,执行 [开启事务T2] [T2,电子系原有余额,800] - lock [T2,数学系原有余额,2200] - lock (T2,电子系原有余额-500,执行,数据库中电子系余额:300) - 持有lock锁,继续操作 (T2,数学系原有余额+500,执行,数据库中数学系余额:2700) - 持有lock锁,继续操作 ==== [回滚事务 T2] =》 [数据库中电子系余额:800,数据库中数学系余额:2200]
给事务T1操作的某一个文件的某一行加上行锁之后,其他事务要想在访问这一行就要进入等锁队列,这样也就意味着只有事务T1执行完成,释放了锁之后其他事务才能去争抢这一行的锁。这样就避免了上面出现了两类丢失更新的情况。
但是有时候我感觉做又太极端了,而且就相当于两个事务被串行执行了,压根没有并发执行,效率方面很低。该怎么做优化呢?又要并发,又要解决上面那两种丢失更新,这可不是好做的。主要考虑到以下两个方面:
1、这两类更新都是由于在做更新时,没有及时的在查一次数据库,并用最新的数据做计算,计算时存在一定问题。
2、在回滚时,没有在查库,看看是否数据变更,该回滚到哪个时刻的数据。
我们先看第一个,如果我们让更新时都重新查一次数据用最新的数据去做计算然后更新到数据库,会不会解决这个问题呢?为了下面方便,我们把更新时重查的数据叫做updateQuery,它的数据格式如下:
[ {"tabel":"account","index":"56","value":{"balance":1000,"name":"电子系"},"newValue":{"balance":500,"name":"电子系"}}, {"tabel":"account","index":"57","value":{"balance":2000,"name":"数学系"},"newValue":{"balance":2200,"name":"数学系"}}, ]
其中value值为第一次查库得到的最最原始的数据,newValue值为每次计算后准备更新到数据库的新数据。如果需要回滚,我们直接回滚的最原始数据就好【注意,我们现在先不考虑2】。
有了updateQuery我们就不在需要firstQuery了。我们把这种每次更新都重查一次数据库的解决方案称为:更新重查,下面我们就来使用看看效果如何。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
第一次查库,T1和T2分别记录自己的updateQuery的某行数据的value |
t3 |
|
电子系余额减去500,数学系余额加上500 |
T2锁住这两行数据,然后查询出最新数据,然后用查出来的数据做计算,把计算后的新数据存到updateQuery的newValue里,最后写入数据库,数据库电子系余额:500,数学系余额2500。释放锁。 |
t4 |
电子系余额减去200,数学系余额加上200 |
|
T1在要更新时发现这两行数据被其他人锁住了,只好进入等锁队列,直到别人释放锁,然后自己在去抢。 |
t5 |
|
|
T1抢到这两行的锁,查询出最新数据,得到电子系余额:500,数学系余额:2500,做计算,把计算后的新数据存到updateQuery的newValue里,然后更新数据到数据库。最终库中电子系余额:300,数学系余额2700。直到操作完,释放锁。 |
t6 |
提交事务T1 |
|
|
t7 |
|
回滚事务T2 |
由于T2在第一次查时,暂存了那个时刻查到的最原始数据,所以回滚到那个时候暂存的value数据,最终数据库电子系余额:1000,数学系余额:2000 |
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
第一次查库,T1和T2分别记录自己的updateQuery的某行数据的value |
t3 |
电子系余额减去200,数学系余额加上200 |
|
T1锁住这两行数据,然后查询出最新数据,然后做计算,把计算后的新数据存updateQuery的newValue里,然后更新数据到数据库。最终库中电子系余额:800,数学系余额2200。直到操作完,释放锁。 |
t4 |
|
电子系余额减去500,数学系余额加上500 |
T2在更新时,也看看这两行有没有被加锁,发现被人加锁了,进入等锁队列,直到轮到自己。 |
t5 |
|
|
T2抢到这两行的锁,查询出最新数据,得到电子系余额:800,数学系余额:2200,做计算,把计算后的新数据存到updateQuery的newValue里,然后更新数据到数据库。最终库中电子系余额:300,数学系余额2700。直到操作完,释放锁。 |
t6 |
提交事务T1 |
|
|
t7 |
|
回滚事务T2 |
由于T2在第一次查时,暂存了那个时刻查到的最原始数据,所以回滚到那个时候暂存的value数据,最终数据库电子系余额:1000,数学系余额:2000 |
还有几种情况,我就不列出了。可以看到光有更新重查还不行,针对回滚,我们下面还必须要采取点措施。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
第一次查库,T1和T2分别记录自己的updateQuery的某行数据的value |
t3 |
电子系余额减去200,数学系余额加上200 |
|
T1锁住这两行数据,然后查询出最新数据,做计算,把查到的数据暂存updateQuery的value里,计算后的新数据存到newValue里, 然后更新数据到数据库。最终库中电子系余额:800,数学系余额2200。直到操作完,释放锁。 |
t4 |
提交事务T1 |
|
提交事务T1后,数据库电子系余额:800,数学系余额2200 |
t5 |
|
电子系余额减去500,数学系余额加上500 |
T1在抢到锁后,立马锁住这两行数据,然后查询出最新数据,电子系余额:800,数学系余额:2200,做计算,把查到的数据暂存到updateQuery的value里,计算后的新数据存到newValue里, 然后更新数据到数据库。最终库中电子系余额:300,数学系余额2700。直到操作完,释放锁。 |
t6 |
|
提交事务T2 |
提交事务T1后,数据库电子系余额:300,数学系余额:2700。 |
观察表3/4中看到的结果,是由于事务T2不知道自己在更新到回滚这一段时间段内,有其他事务也更新了数据,然后自己直接回滚到暂存在updateQuery中第一次查到的数据值,导致覆盖了事务T1提交的数据。看来,我们不仅需要更新重查,还需要记录上一次针对该行更新后的数据是啥,如果需要回滚的话,我直接回滚到最后一次更新后的数据,不就解决了这个问题吗?
分析之后,我们应该有这样一个记录操作的存储结构(为了下面方便我们把它起个名字叫:recodeList),它即可以记录每一次操作数据,格式应该类似如下:
[ { "name":"T2", "time":200, "tabel":null", "index":null, "operation":null, "status":"start", }, { "name":"T1", "time":200, "tabel":null, "index":null, "operation":null, "status":"start", }, {...},{...}, { "name":"T2", "time":268, "tabel":"account", "index":"56", "operation":{ "type":"select", // type=select/delete/insert/update "value":{"balance":1000,"name":"电子系"} "newValue":null }, "status": null, }, { "name":"T2", "time":291, "tabel":"account", "index":"56", "operation":{ "type":"update", "value":{"balance":1000,"name":"电子系"}, // 未更新前的值 "newValue":{"balance":500,"name":"电子系"} // 更新后的值 }, "status": null, }, {...},{...}, { "name":"T1", "time":292, "tabel":"account", "index":"56", "operation":null, "status":"committed", // committed/callback } ]
如果遇到查询:我们就记录一条查询的记录存到recodeList里。
如果遇到更新:由于我们还是使用更新重查的策略,把每次重查的数据放到value里,计算后的新数据放入newValue里。
如果遇到回滚:首先我们需要判断在回滚之前,除了自己更新以外还有谁更新,如果找到其他人更新的就回滚到最后一个其他人更新的newValue值,如果没找到,就回滚到自己事务开始时最早查到的值。
有了这样一个数据结构后,我们就不需要之前的updateQuery数据格式了。这次是在更新重查上做的一次改进,我们主要是针对回滚问题的,所以起个名字就叫回滚重设吧。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
事务T1和T2都进行了第一次查询,它们分别把第一次查到的数据记录到recodeList。 |
t3 |
|
电子系余额减去500,数学系余额加上500 |
T2锁住这两行数据,然后查询出最新数据,然后用查出来的数据做计算,把相应记录存到recodeList,最后写入数据库,数据库电子系余额:500,数学系余额2500。释放锁。 |
t4 |
电子系余额减去200,数学系余额加上200 |
|
T1在要更新时发现这两行数据被其他人锁住了,只好进入等锁队列,知道别人释放锁,然后自己在去抢。 |
t5 |
|
|
T1抢到这两行的锁,查询出最新数据,得到电子系余额:500,数学系余额:2500,然后做计算,把相应记录存到recodeList,然后更新数据到数据库。最终库中电子系余额:300,数学系余额2700。直到操作完,释放锁。 |
t6 |
提交事务T1。 注意:提交/回滚也是要往recodeList插入一条记录的,代表着当前事务执行/回滚完了。 |
|
|
t7 |
|
回滚事务T2 注意:提交/回滚也是要往recodeList插入一条记录的,代表着当前事务执行/回滚完了。 |
遍历从t2时刻到t7时刻所有的操作数据,先看看除了T2自己之外,有没有其他人更新数据,发现有,找到最后一次关于电子系和数学系这两行数据 update后的值【T1更新的】,进行回滚。最终数据库电子系余额:300,数学系余额2700 |
仔细看这个例子,你可能觉得它是属于第一类丢失更新,在这里你细细的看,T2回滚时找到的最后一次update是T1事务更新的,不是T2自己更新的。对于回滚来说,不知道该回滚到哪个时候的值的情况下回滚到最后一次更新的值是合理的。为什么最终结果却是错的呢?原因是事务T1压根没考虑到事务T2会不会回滚这批更新的数据,就不管三七二十一的拿这批数据去做计算,一旦事务T2回滚,那对于事务T1来说,之前更新重读时的数据就是脏数据,我们把这种情况称之为:脏读。我们把错误归咎在事务T1之上,也就是提交上,而不是回滚上,所以我们把它归在“第二类丢失更新”里。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
|
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
查询电子系余额为1000,查询数学系余额为2000 |
事务T1和T2都进行了第一次查询,它们分别把第一次查到的数据记录到recodeList。 |
t3 |
电子系余额减去200,数学系余额加上200 |
|
T1锁住这两行数据,然后查询出最新数据,做计算,把相应数据存到recodeList,然后更新数据到数据库。最终库中电子系余额:800,数学系余额2200。直到操作完,释放锁。 |
t4 |
|
电子系余额减去500,数学系余额加上500 |
T2在更新时,也看看这两行有没有被加锁,发现被人加锁了,进入等锁队列,直到轮到自己。 |
t5 |
|
|
T2抢到这两行的锁,查询出最新数据,得到电子系余额:800,数学系余额:2200,做计算,把相应数据存到recodeList,然后更新数据到数据库。最终库中电子系余额:300,数学系余额2700。直到操作完,释放锁。 |
t6 |
提交事务T1 注意:提交/回滚也是要往recodeList插入一条记录的,代表着当前事务执行/回滚完了。 |
|
|
t7 |
|
回滚事务T2 注意:提交/回滚也是要往recodeList插入一条记录的,代表着当前事务执行/回滚完了。 |
遍历从t2时刻到t7时刻所有的操作数据,先看看除了T2自己之外,有没有其他人更新数据,发现有,找到除自己之外最后一次关于电子系和数学系这两行数据update后的值【T1更新的】,进行回滚。最终数据库电子系余额:800,数学系余额:2200 |
现在对于完全封闭隔离的事务,有了更新重查和回滚重设的解决方案后,至少由于回滚导致的第一类丢失更新不会在出现了(注意:脏读不属于回滚重设导致的,它是属于第二类丢失更新)。
到此,经过多次改进后,还有一点点缺陷,接下来我们就致力于解决这个缺陷和优化,针对这两方面,我考虑了一些方案。
解决脏读问题,是一个十分困难的问题,看上面导致脏读的案例可知,脏读是由于事务T1读取了事务T2未提交的数据,要想解决脏读,也就意味着事务T1只能读取其他事务已提交的数据,未提交的数据不能读取使用。我们上面更新重查的设计方案,根本没考虑重新读取是读取已提交的还是未提交的,只是一股脑的重新查一次数据库,有可能查的是已经提交的,也有可能查的是未提交的,不管是提交还是未提交的,我们都直接拿过来用了。所以如果直接用未提交的就导致了脏读。要解决这个问题,就需要考虑到事务和事务之间更新数据后能相互读取的许可级别,也就是说事务不在是完全封闭隔离的了,要把自己做的某些操作让别人可以知道。这个许可级别我们可以分为两种:
1、我可以在任何时候读取别人未提交的数据,简称:读-未提交。
2、我可以在任何时候读取别人已提交的数据,简称:读-已提交。
许可级别用行话来说就是:隔离级别。对于脏读,应该属于第一种读-未提交的隔离级别,事务T2允许事务T1读取自己未提交的数据,导致了脏读。我们可以用读-已提交的隔离级别去解决脏读问题。
还有非常重要的一点,之前我们都是认为只要执行更新语句,就立马更新到数据库,现在我们使用了隔离级别,还能这么干吗?
[开始事务 T1] [T1,电子系原有余额,1000] [T1,数学系原有余额,2000] (T1,电子系原有余额-200,执行,数据库中电子系余额:800) (T1,数学系原有余额+200,执行,数据库中数学系余额:2200) [开始事务 T2] [T2,电子系原有余额,1000] // 读-已提交,没找到已提交的,按照原来逻辑这是要重新查库的,查到的是未提交的 [T2,数学系原有余额,2000]
如果这么干的话,很明显事务T2开始读取的数据是错误的,所有我们现在使用隔离之后,不能在更新数据库立马就把数据写入数据库了,应该先存到recodeList里,必须要等到提交时才真正操作数据库更新。
[开始事务 T1] [T1,电子系原有余额,1000] [T1,数学系原有余额,2000] [开始事务 T2] [T2,电子系原有余额,1000] [T2,数学系原有余额,2000] (T1,电子系原有余额-200,执行,数据库中电子系余额:800) // 不操作库,只是把操作数据记录到recodeList (T1,数学系原有余额+200,执行,数据库中数学系余额:2200) (T2,电子系原有余额-500,执行,数据库中电子系余额:500) (T2,数学系原有余额+500,执行,数据库中数学系余额:2500) [提交事务 T1] // 电子系800,数学系2200 [提交事务 T2] // 最终库中结果:电子系500,数学系2500,事务T2覆盖了T1
按照这个案例,我们在更新时不操作库,只是把记录存到recodeList,如果在recodeList我们都记录好结果数据了(即newValue),直接在提交时更新即可。如果这样想,上面案例走下去肯定是错的。所以,我们不但只能等到提交时才执行数据,而且还要在只执行时才获取参照数据做计算。还是上面案例,我们改造如下:
[开始事务 T1]
[T1,电子系原有余额,1000]
[T1,数学系原有余额,2000]
[开始事务 T2]
[T2,电子系原有余额,1000]
[T2,数学系原有余额,2000]
(T1,电子系 余额-200) // 仅记录表达式:set balance = balance - 200,不计算
(T1,数学系 余额+200) // 仅记录表达式:set balance = balance + 200,不计算
(T2,电子系 余额-500) // 仅记录表达式:set balance = balance - 500,不计算
(T2,数学系 余额+500) // 仅记录表达式:set balance = balance + 500,不计算
[提交事务 T1] // 找到已提交的参照数据,发现没有,直接操作库吧=》电子系800,数学系2200
[提交事务 T2] // 找到已提交的参照数据,发现有,以参照数据原始操作数据,电子系:800-500=300,数学系:2200+500=2700
有了上面更新后仅提交时才计算更新库,如果不提交仅仅相当于在recodeList里存了几条无用数据罢了,我们还用不用在使用回滚重设的策略了啊?根本不用嘛,反正数据并没有被真正更新到数据库。
现在大想法有了,实现思路怎么走?看看我们之前无意设计的recodeList结构:
[ { "name":"T2", "time":291, "operation":{ "tabel":"account", "index":"56", "type":"update", "value":{"balance":1000,"name":"电子系"}, // 未更新前的值 "newValue":{"balance":500,"name":"电子系"} // 更新后的值 }, "status":null, } ]
我们用type记录了操作的类型,用status记录的当前事务是否结束,而恰好我们可以用这个status去区分读的数据是已经提交的还是未提交的,如果遍历recodeList发现某个事务的最终有这样一条记录:
{ "name":"T1", "time":292, "tabel":"account", "index":"56", "operation":null, "status":"committed", // committed/callback }
说明这是一个已经提交的事务,如果一个事务的status为callback则说明这是一个回滚的事务,不管status是committed还是callback都说明这个事务已经结束了。如果某个事务没有这样的记录说明这个事务还没提交。
怎么做?类似回滚重置那样,不过按照现在的逻辑,我们应该可以在任何时刻只要操作数据库,都应该先去查找recodeList,例如某个事务T,开启了读-未提交的约束,它的任务是先查数据然后更新数据,那么在它查的时候应该先去查recodeList,找到所有未被提交的事务,拿到最后一次某事物未提交的数据,如果能找到就直接使用,找不到就查库,然后把当前记录加入recodeList,更新时也是如此,从此刻开始向上找,找到最后一次未被提交的数据(找别人的,如果没找到就看自己上面是否查过,查过就直接用查过的),如果能找到就直接使用,找不到就查库(当且仅当在当前事务内,更新之前没有任何查库动作)。
上面例子,每次都遍历recodeList找未提交和已提交的数据太麻烦了,为了方便的查找那些未提交和已提交的数据,我们应该有个专门存储未提交和已提交事务的数据结构:
[ // begin:事务开始时间,end为事务截至时间。 // 随事务状态改变切换end和status {"name": "T1", "begin": 221, end: -1, "status": 1}, // status:1表示未提交 {"name": "T2", "begin": 222, end: 278, "status": 2}, // status:2表示已提交 {"name": "T3", "begin": 226, end: 231, "status": 3}, // status:3表示回滚 ]
有了commitList之后,我们的recodeList就可以不用在记录事务的开始和是否提交回滚了,我们把他精简如下:
[ { "name":"T2", "time":268, "tabel":"account", "index":"56", "operation":{ "type":"select", "value":{"balance":1000,"name":"电子系"} } }, { "name":"T2", "time":291, "tabel":"account", "index":"56", "operation":{ "type":"update", "value":"set balance = balance - 200", // update时:记录表达式,不计算 } } ]
没错,在recodeList我们只记录一些数据操作相关的记录,和事务状态相关数据我们用commitList记录就好。下面只要牵扯到查提交或未提交数据我们都默认查commitList。
在上面我们事务在更新操作时,不然三七二十一就直接加个锁,禁止别人在做任何操作,比如说我在修改表中的某一行数据时,如果我在那一行加了行锁,不让任何人在操作,但如果这个人(路人甲)只是想访问这一行数据,对于我来说,他访问数据并不会导致我本次操作出现错误,只有可能他那边出现查询数据问题,这和我一毛钱关系都没有,我只操心的是在我做操作(增删改)的时候,只要我操作没干完(没提交/回滚),其他人就不许也做操作(增删改|drop|alert等),但是其他人是可以查询的,等到我说我把活干完了,其他人爱咋折腾咋折腾。对于这种情况做优化的话,我只需要考虑把行锁的限定范围改一改就好了,让这个行锁只针对于在我增删改这一行的时候其他人不允许操作(增删改|drop|alert等),我们把这种锁称之为:排他锁,简称X锁。
但是细想想,哪天我变成了那个路人甲,我在查询某行重要数据的时候,不知道哪个家伙在这行数据库加了排他锁,尼玛,在我查的时候,这狗比把这行数据给删了!!这怎么行!看来我还要在设计一种锁,在我查数据的时候,大家都只可以查,不允许有人去操作数据(增删改|drop|alert等),这样我在查的时候就放心了,我把这一种锁称之为:共享锁,简称S锁,大家都可以用来查询数据,但是不允许修改数据。
综上来看,行级锁,我按照操作行为分为了两种,第一种:排他锁,只针对增删改操作行为的,在我锁定某一行后,只能我自己做增删改操作,其他人可以查,但不可以在进行增删改。还有,在我给这一行加排他锁的时候,其他人不许在家排他锁或者共享锁,不然不就相互矛盾了吗,排他锁只能有一把。第二种:共享锁,只针对查询数据时,当我加共享锁的时候,就相当于告诉别人,只需查,不许改,其他人来了,也可以继续加共享锁,因为我查询完了,我就把自己的共享锁撤走了,其他人可能还没查完,他当然也要加他的共享锁,以保证在他查的时候,数据不被修改,当然在这一行上只要存在共享锁,那肯定就不能加排他锁。嗯,进行了以上锁的优化之后,我们在接下来的探索中就先按这个优化去走。
在上一章节末尾的总结时,我们提出了要把事务划分隔离级别,并分析了一些细节,在这一章节,我们先把我们上面重设计的隔离级别用在之前的案例上,验证一下可行性。
对于读-未提交,我们在事务T1任何操作时都可以读取到其他事务未提交的数据。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
|
存在的两个事务都未被提交,但是都没有原始数据,查库 |
|
|
查询电子系余额为1000,查询数学系余额为2000 |
刚才事务T1已经查过了,而且事务T1属于未提交,可以拿到T1读的数据。 |
t3 |
|
电子系余额减去500,数学系余额加上500 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
电子系余额减去200,数学系余额加上200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
提交事务T1 |
|
此刻真正操作数据库,锁住行,获取事务T1的操作表达式set balance = balance - 200,参照值为balance,读到未提交的T2参照表达式set balance = balance - 500,不断追溯balance,然后计算,最后更新,最终数据库,电子系:300,数学系:2700 |
t7 |
|
回滚事务T2 |
忽略事务T2所有的操作。此刻库中:电子系:300,数学系:2700 |
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
|
存在的两个事务都未被提交,但是都没有原始数据,查库 |
|
|
查询电子系余额为1000,查询数学系余额为2000 |
刚才事务T1已经查过了,而且事务T1属于未提交,可以拿到T1读的数据。 |
t3 |
电子系余额减去200,数学系余额加上200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
|
电子系余额减去500,数学系余额加上500 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
提交事务T1 |
|
此刻真正操作数据库,锁住行,更新【t3在t4之前】,最终数据库,电子系:800,数学系:2200 |
t7 |
|
回滚事务T2 |
忽略事务T2所有的操作。此刻库中:电子系:800,数学系:2200 |
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
|
存在的两个事务都未被提交,但是都没有原始数据,查库 |
|
|
查询电子系余额为1000,查询数学系余额为2000 |
刚才事务T1已经查过了,而且事务T1属于未提交,可以拿到T1读的数据。 |
t3 |
电子系余额减去200,数学系余额加上200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
提交事务T1 |
|
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:800,数学系:2200 |
t5 |
|
电子系余额减去500,数学系余额加上500 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
|
提交事务T2 |
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:500,数学系:2500 |
可以看到,使用读-未提交,我们不仅会导致脏读,还会导致其他异常情况的数据,下面我们迫切的需要看看读-已提交是否能解决上面遇到的问题。【注意在MySQL中这个案例的结果是正确的,我不希望用我的思路最终误导大家,MySQL中结果正确的原因可想而知,在MySQL中只要是更新操作被提交,在更新被计算执行时,不采用之前已经读过的旧数据作为参照数据,而是找有没有未提交的数据,发现没有,然后重新读取的数据库作为参照数据】
直接把上面两个异常的案例拿过来,用读-已提交走一遍看看效果:
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
|
查库 |
|
|
查询电子系余额为1000,查询数学系余额为2000 |
事务T1未提交,获取不到它查过的数据,只好自己重查库。 |
t3 |
|
电子系余额减去500,数学系余额加上500 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
电子系余额减去200,数学系余额加上200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
提交事务T1 |
|
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:800,数学系:2200 |
t7 |
|
回滚事务T2 |
忽略事务T2所有的操作。此刻库中:电子系:800,数学系:2200 |
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
查询电子系余额为1000,查询数学系余额为2000 |
|
查库 |
|
|
查询电子系余额为1000,查询数学系余额为2000 |
事务T1未提交,获取不到它查过的数据,只好自己重查库。 |
t3 |
电子系余额减去200,数学系余额加上200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
提交事务T1 |
|
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:800,数学系:2200 |
t5 |
|
电子系余额减去500,数学系余额加上500 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
|
提交事务T2 |
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:300,数学系:2700 |
到此使用读-已提交的隔离方案后,至少数据更新不会在出现问题了,那对于数据读取呢?我们来看一个这样的案例:
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
电子系王老师查询电子系余额为1000 |
|
查库 |
|
|
电子系李老师查询电子系余额为1000 |
事务T1未提交,获取不到它查过的数据,只好自己重查库。 |
t3 |
|
李老师,买设备, 余额减去100 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t4 |
王老师,买设备,余额减去200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t5 |
|
李老师,买设备, 余额减去800 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t6 |
|
提交事务T2 |
此刻真正操作数据库,锁住行,更新,最终数据库,电子系:100 |
|
提交事务T2 |
|
此刻真正操作数据库,锁住行,更新,发现100-200不够减,拒绝支付 |
对于整个事务来说,上面的案例操作是完全正确的,没有任何问题,但是对于操作事务的王老师来说,他刚才查的时候还是1000,刚要买东西时发现买不了,账户钱不够,就很纳闷,在查时发现余额只剩100了,钱去哪了?整个过程中对于王老师来说,余额是不可重复读取的,什么叫不可重复读取的,就拿复读机读某个磁带来说,你读第一遍和读第n遍结果必然是一样的,这叫可重复读取,读取的结果不会改变才叫能复读,如果每次读取的结果不一样就不叫能复读了。那不可重复读取就是说这个数据是变的,每次读到的数据可能不一样。
解决不可重复读的关键在于在同一个事务内,用户多次读取的值不应该发生变化,也就是说王老师一旦开启了事务查询或者更新了某一行,李老师就不能在查询或者更新这一行了,必须等王老师查询或者更新完之后,李老师才能更新或查询。也就是说两个事务不能同时操作某一行数据,如果要操作同一行的数据,必须要排队操作。这样的话不但效率会低一些,又在读-已提交的约束上又加了一层新约束,现在我们有三个隔离级别了,如下:
1、我可以在任何时候读取别人未提交的数据,简称:读-未提交。
2、我可以在任何时候读取别人已提交的数据,简称:读-已提交。
3、在读-已提交的基础上:如果你要操作的数据和我操作的数据是同一行数据,那你必须等我这个事务执行完,你才能操作,简称:可重复读。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
开启事务T1 |
开启事务T2 |
两个事务都被开启,处于未被提交状态。 |
t2 |
电子系王老师查询电子系余额为1000 |
|
查库,在读的时候就要在这一行加上排他锁 |
|
|
电子系李老师查询电子系余额 |
发现数据被锁了,只好等待锁释放,整个事务终止,进入等锁队列。 |
t3 |
|
|
|
t4 |
王老师,买设备,余额减去200 |
|
并不真正更新到库,只是把操作表达式记录到recodeList里 |
t5 |
|
|
|
t6 |
|
|
|
|
提交事务T2 |
|
此刻真正操作数据库,锁住行,更新,电子系余额:800,释放锁。 |
|
|
事务T2终于等到锁 |
|
|
|
电子系李老师查询电子系余额为800 |
查库,在读的时候就要在这一行加上排他锁 |
|
|
李老师,买设备, 余额减去100 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
|
|
李老师,知道总余额800,刚才已经花了100,还剩700,最多只能再买700块钱的设备了,李老师花光700。余额减去700 |
并不真正更新到库,只是把操作表达式记录到recodeList里 |
|
|
提交事务T2 |
此刻真正操作数据库,锁住行,更新,电子系余额:0,释放锁。 |
[开启事务T1] [T1 查到电子系余额1000] [开始事务 T2] [T2 电子系余额-200] [提交事务T2] [T1 查询电子系余额,还是1000] [提交事务 T1]
[开启事务T1] [T1 查到电子系余额1000] [T1 电子系余额-500] [T1 查询电子系余额:500] [提交事务 T1]
[开启事务T1] [T1 查到电子系余额1000] [开始事务 T2] [T2 电子系余额-200] [提交事务T2] [T1 电子系余额-500] [T1 查询电子系余额,并不是500,而是300] [提交事务 T1]
使用可重复读的方式我们第一次操作的时候就使用排他锁成功解决了不可重复读的问题,但是依旧存在其他问题,我们再看一个案例:
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
|
开启事务T2 |
|
t2 |
|
电子系主任查询电子系消费记录为10条,准备打印 |
锁住消费记录表这10条数据,不允许别人查和更新 |
|
开启事务T1 |
|
|
t3 |
王老师买了一批设备,【消费记录和账户余额是两张表】 |
|
消费记录表插入一条新数据 |
t4 |
提交事务 |
|
|
t5 |
|
打印消费记录得到11条 |
系主任刚才查了只有10条,但是打印了11条,他会认为多打印的这一条是不存在的,这种场景我们称之为幻读。 |
对比之前读-已提交的不可重复读有些人会觉得幻读和不可重复读的好像啊,都是一开始读到的数据和之后读到的数据是不一样的,明面上确实是这样,但是它们背后导致的原因确实不一样的,不可重复读是由于更新导致的,而幻读是由于新增导致的。所以我们还是要区分开它们,便于我们从根本上解决。
导致幻读的原因很简单,因为我们可重复读只是锁住了要操作的那些行,而没有锁住那些行与行之间的间隙【你要知道新增操作其实就是在行与行的间隙里或行首前的间隙或行尾后的间隙等位置插入数据】,问题找到了,那要解决这个问题,只要在锁住所有间隙不就行了,这样数据就插入不进来了,只有等到其他事务操作完,释放了排他锁和间隙锁【我们姑且这么称为吧】,其他事务才能插入数据。这样我们在可重复读的隔离级别上又加了一层新约束,隔离级别更高了一层,现在我们有四种隔离级别了,分别如下:
1、我可以在任何时候读取别人未提交的数据,简称:读-未提交。
2、我可以在任何时候读取别人已提交的数据,简称:读-已提交。
3、在读-已提交的基础上:如果你要操作的数据和我操作的数据是同一行数据,那你必须等我这个事务执行完,你才能操作,简称:可重复读。
4、在可重复读的基础上:如果你要插入数据,请排队,等我这个事务干完你在操作吧,简称:序列化。
其实可重复读已经是做了读/写的序列化,但是没有做增操作的序列化,现在我们的序列化隔离级别把这三个操作都做了,是一个完全的序列化了。
时间 |
事务T1 |
事务T2 |
结果 |
t1 |
|
开启事务T2 |
|
t2 |
|
电子系主任查询电子系消费记录为10条,准备打印 |
锁住这10条数据,同时锁住所有行间隙。 |
|
开启事务T1 |
|
|
t3 |
|
|
所有行间隙都被锁了,只好行间隙锁被释放。事务T1进入等锁队列。 |
t4 |
|
|
|
t5 |
|
打印消费记录得到10条 |
释放排他锁、间隙锁 |
|
事务T1获得间隙锁 王老师买了一批设备 |
|
锁住准备插入的那个间隙就好了 |
|
提交事务 |
|
|
隔离级别 |
脏读 |
不可重复读 |
幻读 |
读-未提交 |
√ |
√ |
√ |
读-已提交 |
× |
√ |
√ |
可重复读 |
× |
× |
√ |
序列化 |
× |
× |
× |
大厂是如何解决丢失更新的【MySQL InnoDB存储引擎为例】
所有的猜想和思路都是我自己遇到的疑惑和在mysql的官方文档里找到的方案,在此,我深深的建议读到这篇文章的码友去看一看,mysql的innoDB引擎是怎么设计事务的。自己尝试着,把所有的逻辑走一遍,希望本篇文章能给你带来更清晰的关于事务的认识。
操作Mysql事务: 修改MySQL隔离级别 SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE} 如:SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; 查询MySQL的隔离级别 SELECT @@global.tx_isolation; //查询全局隔离级别 SELECT @@session.tx_isolation;//查询当前会话隔离级别 SELECT @@tx_isolation;//同上 事务操作 开启事务 start transaction; 提交事务 commit; 回滚事务 rollback;