本文内容
参考
- https://tech.meituan.com/2014/08/20/innodb-lock.html
- http://hedengcheng.com/?p=771
- https://www.cnblogs.com/AlmostWasteTime/p/11466520.html
- https://www.aneasystone.com/archives/2017/10/solving-dead-locks-one.html
事务的四大特性
- Atomic
- 原子性,事务的操作看做一个整体,要么全部成功,要么全部失败
- Consistency
- 一致性,事务在完成的时候,数据的状态保持一致。简单理解就是比如说 A 和 B 一共有 100 块钱。那么在转账事务里,不管怎么转账,他们的总和都应该是 100
- Isolation
- 隔离性,事务可以具有不同的隔离级别,从而具有不同的性质。
- Durability
- 持久性,事务一旦成功,产生的变化是持久的,不会因为断电之类的导致数据丢失
隔离级别和问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不可能 | 可能 | 可能 |
| 可重复读 | 不可能 | 不可能 | 可能 |
| 串行 | 不可能 | 不可能 | 不可能 |
数据库锁和隔离级别的关系
隔离级别是一种实现的效果,而数据库锁则是实现隔离级别的一种手段。
举个不是特别贴切的例子,就比如说实现进程间的方法调用,也就是 RPC 的效果,使用 HTTP 协议就是实现这个效果的一种手段,使用自定义二进制协议或者 protobuf 等协议也是实现 RPC 的一种手段,使用管道或者 FIFO 也是一种手段。
数据库想要达成隔离级别带给我们的语义,不是天生就能实现的,数据库本身的底层实现中也要通过读写锁、MVCC 等方式来实现隔离级别的语义。
也就是说:隔离级别是一种高层次上的视角,当我们使用隔离级别提交事务的时候,底层有可能使用读写锁来达成相应隔离级别的效果,也有可能通过MVCC等方式来实现相应隔离级别的效果
可重复读的两种实现原理
读写锁的实现原理
- 读取的时候施加读锁,这时其它事务也可以读
- 写入的时候施加写锁,这时其它事务不可以读也不可以写
这样,导致一旦有事务读取了记录,那么记录就有了读锁,其它事务无法对该记录添加写锁,也就无法修改该记录,从而就保证了事务可以重复读取(理解成可重入的读锁)
| 事务A | 事务B | 记录的状态 |
|---|---|---|
| 读取第一行 | 读取第一行 | 读锁 * 2 |
| 更新第一行:阻塞等待读锁释放 | 读锁 * 2,事务B等待 | |
| 提交 | 事务继续执行 | 无锁,事务 A、B 先后释放读锁 |
| 更新继续执行 | 写锁 |
可以看到,事务 B 是无法更新事务 A 已经读取了的行的,所以事务 A 具有可重复读的特性,同时事务 B 也具有可重复读的特性
MVCC 的实现原理
MVCC 就是多版本并发控制,它的原理就是:事务读取的时候都是读取的自己的快照版本,这样无论其他事务做了任何修改,当前的读取事务读到的都是一样的数据,也就是可重复读
在 MVCC 里,读取分成两种读取,一种是快照读,一种是当前读。
快照读
快照读就是上述所描述的,事务仅仅读取自己事务开始的时候的数据的版本,被其它事务后来更新的新版本对于本事务是不可见的。这种特性最大的好处就是:快照读的时候不会加锁,后续的更新类的事务一样可以更新当前事务正在读取的记录,本事务由于读取的是快照,其它事务的更新不会对自己读取快照产生任何影响。
这种方式读取不加锁,只有写入才加锁,可以很大的提高并发性。
快照读的案例:select * from table where ? ; 也就是简单的 select 读取
可以理解成:一旦第一次读取,立刻生成一个视图,后续事务更新不会影响这个视图
当前读
当前读,读取的是数据的最新版本,包括有:
select * from table where ? lock in share mode;select * from table where ? for update;insert into table values (…);update table set ? where ?;delete from table where ?;
简单来说就是:加锁类型的读取、更新语句
针对当前读,RR隔离级别保证对读取到的记录加锁 (记录锁),同时保证对读取的范围加锁,新的满足查询条件的记录不能够插入 (间隙锁),不存在幻读现象。
当前读返回的记录:查询返回的记录、更新语句操作的记录,会被加入到当前快照中,比如事务 A 第一次查询返回 8 条记录,事务 B 插入了新的 2 条记录。事务 A 使用当前读,读到了 2 条里其中的一条,那么事务 A 再次全部查找的时候,会找到 9 条记录
对于更新类语句来说,所谓当前读返回的记录就是更新语句更新的记录。
总结
现在主流的实现方式是 MVCC 实现可重复读,优点是:
- 快照读取不加锁,提高了并发的性能
- 一定程度上解决了幻读
MVCC 还是有可能会发生第二类丢失更新(提交覆盖),但是读写锁实现可重复读是不会发生第二类丢失更新的。
原因:
- 读写锁实现的可重复读,读取的时候阻塞了写入。如果事务 A 读取,事务 B 写入的话,此时事务 B 被阻塞,直到事务 A 处理完成,保证事务 A 读取和写入的都是最新记录。如果事务 A 和 B 都是先读取后写入的话,要么其中一个事务,假设是 A 一次性抢到读锁和写锁,事务 B 直接阻塞。要么是事务 A 和 B 都抢到读锁,都无法写入。这两种情况都不会发生第二类丢失更新
- MVCC 实现的可重复读,读取的时候可以被写入新版本。我们更新的时候有可能是拿到旧版本的数据去更新的。此时就可能发生第二类丢失更新。
- 解决方法一个是给读取加锁:select for update、select lock in share mode
- 另一个是乐观锁:给记录加个版本号字段,每次更新操作执行前先查询版本号,更新时同时校验版本号,执行更新的时候版本号同步加1
可重复读和幻读
参考:
- https://www.cnblogs.com/hellopretty/p/5020093.html
- https://blog.csdn.net/zcl_love_wx/article/details/82382582
定义
- 不可重复读:多次读取的时候,记录的内容发生了改变
- 例如读取 name = czp 对应的记录,发现第一次读取是 3,第二次读取是 5
- 幻读:多次读取的时候,记录的数量发生了改变
- 例如读取 name = czp 对应的记录,发现第一次读取的是 3,第二次读取的是 [3, 5](插入了一条记录)
RC
- 不加锁的时候(快照读)是不可重复读的,也就是读取的时候,记录的内容是可以发生变化的
- 加锁的时候(当前读)
- 是可重复读的,也就是读取的时候,记录的内容是不会发生变化的
- 同时是可能发生幻读的,也就是读取的时候,记录的数量是可以发生变化的
- 原因是加锁的时候,锁住的是当前存在的数据,我们插入的时候是可以进行的,那么就会被下一次读取所读到,于是就发生了幻读
RR
快照读
- 是可重复读的,因为会在一个一致性的快照版本中读取,新事物提交的记录不会对快照读产生影响
- 不会发生幻读,理由也是一样的,RR 的快照读在一个一致性的快照版本中读取,新提交的记录我快照读的时候是看不到的
当前读
是可重复读的,因为当前读会对读取的记录加锁,记录的内容无法被修改
不会发生幻读,理由是:
当前读会加上间隙锁,也就是对查询条件加锁,例如
select * from user where age > 10 for update ; // 当前读才会加锁那么当另一个事务想要插入 age 大于 10 的记录的时候,将会被阻塞,这就是间隙锁的一种理解(实际上间隙锁是锁住记录本身,以及最靠近记录集合的相邻页)
因此其它事务在当前事务是当前读的时候,无法进行插入当前读读到的范围的内容,也就避免了当前读场景下的幻读
RR 中存在的幻读的情况
从上面的分析中可以发现,RR 中有两种情况下,不会发生幻读:
- 只使用快照读
- 只使用当前读
但是,如果我们混合使用两种方式,就会发生一种所谓的幻读:
测试前数据:
| 事务A | 事务B |
|---|---|
| begin | begin |
| select * from dept | |
| insert into dept(name) values("研发部") | |
| commit | |
| update dept set name="财务部" | |
| commit |
我们会发现结果是:
原因是:我们第一次使用的是快照读,第二次使用了当前读,两次读取就不保证可重复读了,因而也就发生了幻读。
但是如果我们在事务 A 提交之前,再次运行事务 B,将会发现事务 B 阻塞,从而保证了事务 A 后续不会发生幻读
总结
RC:
- 快照读不可重复读,同时也有幻读
- 当前读可以保证可重复读,但是无法避免幻读
RR:
- 快照读可重复读,同时避免幻读
- 当前读可重复读,同时避免幻读
上面的表中说可重复读可能发生幻读,这个针对的是普通的可重复读实现,针对 mysql mvcc 加上间隙锁的实现来说,是可以避免幻读的。
总体来说,RR 解决了幻读的问题,唯一存在幻读的情况就是先使用快照读再使用当前读,两次读取不一致。