在数据库事务并发执行时,如果不考虑隔离级别,可能会出现以下三个经典问题:
脏读指一个事务读取了另一个事务尚未提交的数据。如果后者回滚,前者读取的就成了"脏数据"。
同一事务内多次读取同一数据,但因为其他事务在此期间对数据进行了更新并提交,导致本事务前后读取结果不一致。
幻读指在同一事务内,连续执行两次相同的查询,第二次查询结果出现了第一次查询没有的行,就像出现了"幻觉"一样。这通常是由于其他事务在此期间插入了新数据。
SQL标准定义了四种事务隔离级别,MySQL的InnoDB引擎实现了这四种级别:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(Read Uncommitted) | 是 | 是 | 是 |
读已提交(Read Committed) | 否 | 是 | 是 |
可重复读(Repeatable Read) | 否 | 否 | 在InnoDB中否 |
串行化(Serializable) | 否 | 否 | 否 |
脏读:将隔离级别提升到读已提交即可解决,此级别下事务无法读取其他事务未提交的数据。
不可重复读:将隔离级别提升到可重复读即可解决。在该级别下,每个事务开始时会创建一个快照,事务内的查询都基于此快照,因此不受其他事务更新的影响。
幻读:这是最复杂的问题,InnoDB通过间隙锁(Gap Lock)在可重复读隔离级别下解决了这个问题。
间隙锁(Gap Lock)是InnoDB存储引擎独特的锁机制,它不锁定记录本身,而是锁定记录之间的"空白区域"。这一设计思想源于数据库系统对并发控制的不断探索,反映了数据库设计者对"读-写"冲突的深刻理解。
从数据结构的角度看,间隙锁实际上锁定的是B+树索引中节点之间的范围,这种锁定方式与传统行锁的最大区别在于:它锁定的是可能存在的记录空间,而非已存在的数据。这种"预防性"的锁定策略,体现了数据库系统在并发控制上的前瞻性思维。
幻读是指在同一事务内,连续执行两次相同的查询,第二次查询看到了第一次查询没有看到的行(幻行)。这种情况就好像发生了幻觉一样,因此称为"幻读"。
举个生动的例子:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但在这个过程中,系统管理员B新增了一条具体分数的记录。当系统管理员A完成修改后,发现还有一条记录没有被改过来,就好像系统"变魔术"出现了一条新记录一样。
幻读的正式定义是:一个事务读取到了另一个已提交事务插入的数据,导致前后两次读取的结果不一致。关键点在于新增数据,这与不可重复读关注的更新数据有本质区别。
幻读与不可重复读虽然都是读取一致性问题,但本质上有显著差异:
幻读问题的难点在于,它是对"不存在数据"的保护,这超出了传统锁模型的设计边界。多数数据库系统在串行化隔离级别下通过表锁或谓词锁(Predicate Lock)解决幻读,但这会极大降低并发性能。
幻读在某些场景下会导致严重问题:
数据一致性问题:例如,事务A统计某个条件下的记录数为5条,基于此执行某些操作,但同时事务B插入了一条满足该条件的记录并提交。当事务A再次检查时,会发现记录变为6条,这可能导致业务逻辑错误。
数据完整性约束失效:某些业务约束可能会被绕过。例如,事务A检查某用户名是否存在,发现不存在后准备插入,但在此期间事务B已经插入了同名记录并提交,导致事务A插入时违反了唯一约束。
日志与数据不一致:在某些复制架构下,幻读可能导致主从数据不一致,特别是使用基于语句的复制时。
InnoDB通过间隙锁实现了在可重复读隔离级别下防止幻读,同时保持相对较高的并发性能。这是一种权衡的艺术,比表锁更精细,比单纯的行锁更全面。
通过对索引记录之间"间隙"的锁定,InnoDB 阻止了其他事务在查询范围内插入新记录,从而有效避免了幻读问题。这是InnoDB区别于其他数据库引擎的重要特性,使其能够在可重复读隔离级别下提供更高的数据一致性保证。
InnoDB的间隙锁实际上分为三种类型:
值得注意的是,InnoDB的间隙锁只在使用唯一索引进行精确查找时不会被使用。这种针对性的优化进一步提高了系统性能。
对于表中的数据:
id: 5, 10, 15, 20, 25
其间隙范围为:
(-∞, 5), (5, 10), (10, 15), (15, 20), (20, 25), (25, +∞)
当执行例如SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE
时,InnoDB会锁定以下Next-Key范围:
(5, 10], (10, 15], (15, 20], (20, 25)
当执行类似SELECT * FROM t WHERE id = 7 FOR UPDATE
的查询时,如果id=7的记录不存在,InnoDB会在(5,10)的间隙上加锁。这样,其他事务便无法在此间隙内插入id=7的记录,从而防止了幻读。
具体工作流程为:
间隙锁与其他锁的兼容性是理解死锁的关键:
锁类型A\锁类型B | 间隙锁(Gap) | 记录锁(Record) | Next-Key Lock |
---|---|---|---|
间隙锁(Gap) | 兼容 | 兼容 | 部分兼容 |
记录锁(Record) | 兼容 | 不兼容 | 不兼容 |
Next-Key Lock | 部分兼容 | 不兼容 | 不兼容 |
关键点:间隙锁之间是兼容的,这意味着多个事务可以同时持有同一间隙的锁,但当它们尝试在该间隙插入记录时,才会发生锁冲突。这种"延迟冲突"是导致死锁的主要原因之一。
事务A和事务B同时锁定同一间隙,然后尝试在该间隙中插入数据,形成循环等待。
考虑下面的示例:
SessionA | SessionB
----------------------------------------------|------------------------------------------
begin; |
select * from t where id = 9 for update; |
| begin;
| select * from t where id = 9 for update;
insert into t values(9,9,9); /* 阻塞 */ |
| insert into t values(9,9,9); /* 死锁 */
死锁发生原理:间隙锁的共享特性允许多个事务持有同一间隙的锁,但插入操作会转换为意向排他锁,导致冲突。
具体分析:
事务A持有范围间隙的共享锁,事务B持有部分范围的排他锁,双方互相等待对方释放锁资源。
sessionA | sessionB
--------------------------------------------------------|---------------------------
begin; |
select id from t where c = 10 lock in share mode; |
| update t set d = d+1 where c = 10; /* 阻塞 */
insert into t values(8,8,8); /* 死锁 */ |
死锁检测:InnoDB会通过等待图(Wait-for Graph)算法检测并解决死锁,通常会选择回滚较小的事务。
间隙锁虽然解决了幻读问题,但也带来了性能开销:
针对间隙锁带来的挑战,可采取以下优化策略:
值得注意的是,MySQL 8.0后引入了不可见索引功能,可以在特定场景临时禁用某些索引,影响查询执行计划,间接控制加锁范围。
尽管InnoDB的可重复读隔离级别已经解决了幻读问题,但很多大型互联网公司仍选择使用读已提交隔离级别,主要原因有:
在读已提交级别下,由于没有间隙锁保护,幻读问题仍然存在。业务层面通常采用以下策略应对:
某电商平台订单系统初期使用可重复读隔离级别,随着订单量增长,系统开始出现间歇性的死锁和性能下降。分析发现主要是热点数据的间隙锁争用导致,将隔离级别调整为读已提交并配合以下措施后性能显著提升:
如果您喜欢我的文章,请点击下面按钮随意打赏,您的支持是我最大的动力。
最新评论