之前在深入了解数据库理论的时候, 了解到事物的不同隔离级别可能存在的问题. 为了更好的理解所以在 MySQL 数据库中测试复现这些问题. 关于脏读和不可重复读在相应的隔离级别下都很容易的复现了. 但是对于幻读, 我发现在可重复读的隔离级别下没有出现, 当时想到难道是 MySQL 对幻读做了什么处理?
测试:
创建一张测试用的表 dept:
- CREATE TABLE `dept` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(20) DEFAULT NULL,
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8
- insert into dept(name) values("后勤部")
事物 1 | 事物 2 |
---|---|
begin | begin |
select * from dept | |
- | insert into dept(name) values("研发部") |
- | commit |
select * from dept | |
commit |
根据上面的流程执行, 预期来说应该是事物 1 的第一条 select 查询出一条数据, 第二个 select 查询出两条数据(包含事物 2 提交的数据).
但是在实际测试中发现第二条 select 实际上也只查询处理一条数据. 这是但是根据数据库理论的可重复读的实现 (排他锁和共享锁) 这是不应该的情况.
在了解实际原因前我们先复习下事物的相关理论.
数据库原理理论
事物
事务 (Transaction), 一般是指要做的或所做的事情. 在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit). 事务由事务开始(begin transaction) 和事务结束 (end transaction) 之间执行的全体操作组成. 在关系数据库中, 一个事务可以是一组 SQL 语句或整个程序.
为什么要有事物
一个数据库事务通常包含对数据库进行读或写的一个操作序列. 它的存在包含有以下两个目的:
为数据库操作提供了一个从失败中恢复到正常状态的方法, 同时提供了数据库在异常状态下仍能保持一致性的方法.
当多个应用程序在并发访问数据库时, 可以在这些应用程序之间提供一个隔离方法, 保证彼此的操作互相干扰.
事物特性
事务具有 4 个特性: 原子性, 一致性, 隔离性, 持久性. 这四个属性通常称为 ACID 特性.
原子性(atomicity):
一个事务应该是一个不可分割的工作单位, 事务中包括的操作要么都成功, 要么都不成功.
一致性(consistency):
事务必须是使数据库从一个一致性状态变到另一个一致性状态. 一致性与原子性是密切相关的.
隔离性(isolation):
一个事务的执行不能被其他事务干扰. 即一个事务内部的操作及使用的数据在事物未提交前对并发的其他事务是隔离的, 并发执行的各个事务之间不能互相影响.
持久性(durability):
一个事务一旦成功提交, 它对数据库中数据的改变就应该是永久性的. 接下来的其他操作或故障不应该对其有任何影响.
事物之间的几个特性并不是一组同等的概念:
如果在任何时刻都只有一个事物, 那么其天然是具有隔离性的, 这时只要保证原子性就能具有一致性.
如果存在并发的情况下, 就需要保证原子性和隔离性才能保证一致性.
数据库并发事物中存在的问题
如果不考虑事务的隔离性, 会发生以下几种问题:
脏读: 脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据. 当一个事务正在多次修改某个数据, 而在这个事务中这多次的修改都还未提交, 这时一个并发的事务来访问该数据, 就会造成两个事务得到的数据不一致.
不可重复读: 不可重复读是指在对于数据库中的某条数据, 一个事务范围内多次查询返回不同的数据值(这里不同是指某一条或多条数据的内容前后不一致, 但数据条数相同), 这是由于在查询间隔, 该事物需要用到的数据被另一个事务修改并提交了. 不可重复读和脏读的区别是, 脏读是某一事务读取了另一个事务未提交的脏数据, 而不可重复读则是读取了其他事务提交的数据. 需要注意的是在某些情况下不可重复读并不是问题.
幻读: 幻读是事务非独立执行时发生的一种现象. 例如事务 T1 对一个表中所有的行的某个数据项做了从 "1" 修改为 "2" 的操作, 这时事务 T2 又对这个表中插入了一行数据项, 而这个数据项的数值还是为 "1" 并且提交给数据库. 而操作事务 T1 的用户如果再查看刚刚修改的数据, 会发现还有一行没有修改, 其实这行是从事务 T2 中添加的, 就好像产生幻觉一样, 这就是发生了幻读. 幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同), 所不同的是不可重复读可能发生在 update,delete 操作中, 而幻读发生在 insert 操作中.
排他锁, 共享锁
排它锁(Exclusive), 又称为 X 锁, 写锁.
共享锁(Shared), 又称为 S 锁, 读锁.
读写锁之间有以下的关系:
一个事务对数据对象 O 加了 S 锁, 可以对 O 进行读取操作, 但是不能进行更新操作. 加锁期间其它事务能对 O 加 S 锁, 但是不能加 X 锁.
一个事务对数据对象 O 加了 X 锁, 就可以对 O 进行读取和更新. 加锁期间其它事务不能对 O 加任何锁.
即读写锁之间的关系可以概括为: 多读单写
事物的隔离级别
在事物中存在以下几种隔离级别:
读未提交(Read Uncommitted): 解决更新丢失问题. 如果一个事务已经开始写操作, 那么其他事务则不允许同时进行写操作, 但允许其他事务读此行数据. 该隔离级别可以通过 "排他写锁" 实现, 即事物需要对某些数据进行修改必须对这些数据加 X 锁, 读数据不需要加 S 锁.
读已提交(Read Committed): 解决了脏读问题. 读取数据的事务允许其他事务继续访问该行数据, 但是未提交的写事务将会禁止其他事务访问该行. 这可以通过 "瞬间共享读锁" 和 "排他写锁" 实现, 即事物需要对某些数据进行修改必须对这些数据加 X 锁, 读数据时需要加上 S 锁, 当数据读取完成后立刻释放 S 锁, 不用等到事物结束.
可重复读取(Repeatable Read): 禁止不可重复读取和脏读取, 但是有时可能出现幻读数据. 读取数据的事务将会禁止写事务(但允许读事务), 写事务则禁止任何其他事务. MySQL 默认使用该隔离级别. 这可以通过 "共享读锁" 和 "排他写锁" 实现, 即事物需要对某些数据进行修改必须对这些数据加 X 锁, 读数据时需要加上 S 锁, 当数据读取完成并不立刻释放 S 锁, 而是等到事物结束后再释放.
串行化(Serializable): 解决了幻读的问题的. 提供严格的事务隔离. 它要求事务序列化执行, 事务只能一个接着一个地执行, 不能并发执行. 仅仅通过 "行级锁" 是无法实现事务序列化的, 必须通过其他机制保证新插入的数据不会被刚执行查询操作的事务访问到.
MySQL 中的隔离级别的实现
上面的内容解释了一些数据库理论的概念, 但是在 MySQL,Oracle 这样的数据库中, 为了性能的考虑并不是完全按照上面介绍的理论来实现的.
MVCC
多版本并发控制 (Multi-Version Concurrency Control, MVCC) 是 MySQL 中基于乐观锁理论实现隔离级别的方式, 用于实现读已提交和可重复读取隔离级别的实现.
实现(隔离级别为可重复读)
在说到如何实现前先引入两个概念:
系统版本号: 一个递增的数字, 每开始一个新的事务, 系统版本号就会自动递增.
事务版本号: 事务开始时的系统版本号.
在 MySQL 中, 会在表中每一条数据后面添加两个字段:
创建版本号: 创建一行数据时, 将当前系统版本号作为创建版本号赋值
删除版本号: 删除一行数据时, 将当前系统版本号作为删除版本号赋值
SELECT
select 时读取数据的规则为: 创建版本号 <= 当前事务版本号, 删除版本号为空或> 当前事务版本号.
创建版本号<= 当前事务版本号保证取出的数据不会有后启动的事物中创建的数据. 这也是为什么在开始的示例中我们不会查出后来添加的数据的原因
删除版本号为空或>当前事务版本号保证了至少在该事物开启之前数据没有被删除, 是应该被查出来的数据.
INSERT
insert 时将当前的系统版本号赋值给创建版本号字段.
UPDATE
插入一条新纪录, 保存当前事务版本号为行创建版本号, 同时保存当前事务版本号到原来删除的行, 实际上这里的更新是通过 delete 和 insert 实现的.
DELETE
删除时将当前的系统版本号值给删除版本号字段, 标识该行数据在那一个事物中会被删除, 即使实际上在位 commit 时该数据没有被删除. 根据 select 的规则后开启懂数据也不会查询到该数据.
MVCC 真的解决了幻读?
从最开始我们的测试示例和上面的理论支持来看貌似在 MySQL 中通过 MVCC 就解决了幻读的问题, 那既然这样串行化读貌似就没啥意义了, 带着疑问继续测试.
测试前数据:
事物 1 | 事物 2 |
---|---|
begin | begin |
select * from dept | |
- | insert into dept(name) values("研发部") |
- | commit |
update dept set name="财务部"(工作中如果不想被辞退一定要写 where 条件) | |
commit |
根据上面的结果我们期望的结果是这样的:
id name
1 财务部
2 研发部
但是实际上我们的经过是:
本来我们希望得到的结果只是第一条数据的部门改为财务, 但是结果确实两条数据都被修改了. 这种结果告诉我们其实在 MySQL 可重复读的隔离级别中并不是完全解决了幻读的问题, 而是解决了读数据情况下的幻读问题. 而对于修改的操作依旧存在幻读问题, 就是说 MVCC 对于幻读的解决时不彻底的.
快照读和当前读
出现了上面的情况我们需要知道为什么会出现这种情况. 在查阅了一些资料后发现在 RR 级别中, 通过 MVCC 机制, 虽然让数据变得可重复读, 但我们读到的数据可能是历史数据, 不是数据库最新的数据. 这种读取历史数据的方式, 我们叫它快照读 (snapshot read), 而读取数据库最新版本数据的方式, 叫当前读 (current read).
select 快照读
当执行 select 操作是 innodb 默认会执行快照读, 会记录下这次 select 后的结果, 之后 select 的时候就会返回这次快照的数据, 即使其他事务提交了不会影响当前 select 的数据, 这就实现了可重复读了. 快照的生成当在第一次执行 select 的时候, 也就是说假设当 A 开启了事务, 然后没有执行任何操作, 这时候 B insert 了一条数据然后 commit, 这时候 A 执行 select, 那么返回的数据中就会有 B 添加的那条数据. 之后无论再有其他事务 commit 都没有关系, 因为快照已经生成了, 后面的 select 都是根据快照来的.
当前读
对于会对数据修改的操作 (update,insert,delete) 都是采用当前读的模式. 在执行这几个操作时会读取最新的记录, 即使是别的事务提交的数据也可以查询到. 假设要 update 一条记录, 但是在另一个事务中已经 delete 掉这条数据并且 commit 了, 如果 update 就会产生冲突, 所以在 update 的时候需要知道最新的数据. 也正是因为这样所以才导致上面我们测试的那种情况.
select 的当前读需要手动的加锁:
- select * from table where ? lock in share mode;
- select * from table where ? for update;
有个问题说明下
在测试过程中最开始我以为使用 begin 语句就是开始一个事物了, 所以在上面第二次测试中因为先开始的事物 1, 结果在事物 1 中却查到了事物 2 新增的数据, 当时认为这和前面 MVCC 中的 select 的规则不一致了, 所以做了如下测试:
SELECT * FROM information_schema.INNODB_TRX // 用于查询当前正在执行中的事物
可以看到如果只是执行 begin 语句实际上并没有开启一个事物.
下面在 begin 后添加一条 select 语句:
所以要明白实际上是对数据进行了增删改查等操作后才开启了一个事物.
如何解决幻读
很明显可重复读的隔离级别没有办法彻底的解决幻读的问题, 如果我们的项目中需要解决幻读的话也有两个办法:
使用串行化读的隔离级别
MVCC+next-key locks:next-key locks 由 record locks(索引加锁) 和 gap locks(间隙锁, 每次锁住的不光是需要使用的数据, 还会锁住这些数据附近的数据)
实际上很多的项目中是不会使用到上面的两种方法的, 串行化读的性能太差, 而且其实幻读很多时候是我们完全可以接受的.
来源: http://www.linuxidc.com/Linux/2019-04/158279.htm