MySQL · 捉虫动态 · 唯一键约束失效

唯一键是数据库设计中常用的索引类型,主要用于约束数据,不允许出现重复的键值记录。可以想象,如果唯一键约束失效了,将可能产生可怕的逻辑错误。本文主要讨论下最近MySQL爆出来的两个唯一键约束失效导致二级索引corruption的问题。

问题一: 检查重复键加锁逻辑不当

影响版本:MySQL 5.6.21之前,5.6.12之后的版本

介绍分析

在5.6.12之前的版本中,当插入一条带唯一约束的记录时,如果表上已经存在了这条记录,或者有一条标记删除的相同键值记录时,就需要对这条记录加S GAP (类型为LOCK_ORDINARY)锁,即使你使用的是READ-COMMIT的隔离级别。因此有人report了bug#68021 ,认为在RC级别下,检查duplicate key时无需加GAP锁,在MySQL 5.6.12版本里针对RR级别依然加LOCK_ORDINARY类型的S锁,而针对RC级别加LOCK_REC_NOT_GAP类型的S锁。

上述修复带来了严重的退化,在RC隔离级别下,使用DELETE + 并发INSERT冲突键值的场景,将可能触发唯一键失效,我们简单描述下冲突产生的过程:

  1. 开启一个session,执行flush tables tbname for export,这会使purge操作停下来;
  2. 删除某条记录,其二级索引为uk1, 执行的是标记删除,由于purge被我们人为的停止,因此这条记录不会立刻被清理掉;
  3. 插入记录,包含唯一索引记录uk1,由于step 2的记录还在(没被purge),因此需要检查唯一性,在函数row_ins_scan_sec_index_for_duplicate中,根据隔离级别在记录上加S NOT GAP 锁,唯一性检查后提交mtr释放block锁;
  4. 和step 3 类似,另外一个session也插入uk1, 同样加上S NOT GAP锁,因为S锁是相容的,因此可以成功加上锁,提交mtr释放block锁;
  5. 两个session现在可以进行插入,因为受block x锁限制,插入过程是顺序的。但两次插入都能成功,原因是在做插入锁检查时,会检查相邻记录是否存在与(LOCK_X LOCK_GAP LOCK_INSERT_INTENTION)相冲突的锁,而GAP 锁和NOT GAP的S锁是不冲突的(参考函数lock_rec_has_to_wait), 因此两次插入都能顺利进行下去。

修复

直接把针对 bug#68021 的补丁给revert了。也就是说,在检查duplicate key时总是加GAP类型的S锁(LOCK_ORDINARY),这样上述过程的加锁类型可以归纳为:

SESSION1 持有 LOCK_ORDINARY S LOCK
SESSION2 持有 LOCK_ORDINARY S LOCK
SESSION1 INSERT RECORD ... CONFLICT, ENQUEUE (LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION) ——> WAIT
SESSION2 INSERT RECORD ... CONFLICT, ENQUEUE LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION ——> DEAD LOCK HAPPEN

如上描述,这里会有一定的几率发生死锁,并且死锁信息通常让人无法捉摸,如果你发现两条插入相同唯一键的SQL出现在死锁信息里,那有很大的可能是这个问题导致的。

问题二:锁继承逻辑缺陷导致约束失效(bug#76927)

影响版本:MySQL 5.1 ~ MySQL 5.7全系列版本,上游已确认,尚未fix。

介绍分析

这个问题是最近Percona的开发人员Alexey发现的,触发条件是一次DELETE + 并发REPLACE INTO操作,DELETE和REPLACE操作相同的唯一键值。

和INSERT操作不同,通过REPLACE INTO、LOAD DATAFILE REPLACE、INSERT…ON DUPLICATE执行的SQL,在检查唯一建约束时,总是给冲突的记录加LOCK_ORDINARY类型的X锁 (而非上例的S锁)。

问题产生的场景如下:

  1. 和上例一样,先让purge线程暂时停止下来;
  2. 删除包含uk1的记录,由于purge已经停止了,记录会留在物理文件中不会被及时清理掉;
  3. 执行REPLACE INTO,插入一条包含uk1的记录,由于存在标记删除但尚未清理的冲突键值,且当前操作为replace into,因此给记录加LOCK_ORDINARY类型的X锁;完成冲突检测后,提交mtr释放block锁;
  4. 开启另外一个session执行REPLACE INTO,同样插入冲突键值UK1,由于Step 3 已经加了X锁,因此这里再加X锁产生锁等待,进入等待队列。这时候我们查看innodb_locks表,会发现已经存在两个锁对象了
     mysql> select * from information_schema.innodb_locks;
     +------------+-------------+-----------+-----------+-------------+------------+------------+-----------+----------+-----------+
     | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data |
     +------------+-------------+-----------+-----------+-------------+------------+------------+-----------+----------+-----------+
     | 1300:6:4:2 | 1300 | X | RECORD | `test`.`t1` | a | 6 | 4 | 2 | 1 |
     | 1299:6:4:2 | 1299 | X | RECORD | `test`.`t1` | a | 6 | 4 | 2 | 1 |
     +------------+-------------+-----------+-----------+-------------+------------+------------+-----------+----------+-----------+
     2 rows in set (0.00 sec)
    
  5. 开启purge线程,purge操作会清理掉之前标记删除的物理记录,然而在step3 和step4上已经在这条记录上加了记录锁,记录被清掉了,对应的锁记录也需要做处理,InnoDB会尝试将锁继承给下一条记录,我们来看看锁继承的逻辑,调用函数lock_rec_inherit_to_gap
     for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
          lock != NULL;
          lock = lock_rec_get_next(heap_no, lock)) {
    
             if (!lock_rec_get_insert_intention(lock)
                 && !((srv_locks_unsafe_for_binlog
                       || lock->trx->isolation_level
                       <= TRX_ISO_READ_COMMITTED)
                      && lock_get_mode(lock) == LOCK_X)) {
    
                     lock_rec_add_to_queue(
                             LOCK_REC | LOCK_GAP | lock_get_mode(lock),
                             heir_block, heir_heap_no, lock->index,
                             lock->trx, FALSE);
             }
     }
    

    当满足如下条件时,不会做锁继承:

    • 锁类型为插入意向锁
    • srv_locks_unsafe_for_binlog打开且锁类型为X锁
    • 锁对应事务的隔离级别小于等于RC且锁类型为X锁

    由于当前的隔离级别为RC,并且REPLACE INTO操作加的是X锁,因此锁没有被相邻记录继承,我们从INNODB_LOCKS系统表中也可以发现这一点:

     mysql> select * from information_schema.innodb_locks;
     Empty set (0.00 sec)
    
  6. 唤醒第二个replace 操作(正在等待X锁),执行插入操作成功;
  7. 唤醒第一个replace 操作,由于已经完成duplicate key检测,插入成功。

修复

从上述逻辑可以看出,当purge线程被激活后,记录和记录锁对象都被移除了,purge操作悄悄的破坏了InnoDB的加锁协议。

修复的方法也比较简单,InnoDB认为只可能加S锁来维持一致性约束,因此当记录被物理删除时,只有S类型的锁才被继承。但对于REPLACE这样的操作,加的是X类型的锁,这种锁类型必须也要考虑进去,将其继承给下一条记录。Alexey已经将patch push到percona server,改动也就一行,可以参考Percona的 补丁

问题三:事务可见性导致的唯一键“失效”

我们来看看另外一个在REPEATABLE READ 隔离级别下,唯一键“失效”的问题,考虑如下执行序列。

创建测试表:create table t1 (a int primary key, b int unique key) engine = innodb;

session 1:
mysql> insert into t1 values (1,2);
Query OK, 1 row affected (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t1;
+---+------+
| a | b |
+---+------+
| 1 | 2 |
+---+------+
1 row in set (0.00 sec)

session 2:
mysql> delete from t1;
Query OK, 1 row affected (0.00 sec)

session 1:

mysql> insert into t1 values (2,2);
Query OK, 1 row affected (0.00 sec)

mysql> select * from t1;
+---+------+
| a | b |
+---+------+
| 1 | 2 |
| 2 | 2 |
+---+------+
2 rows in set (0.00 sec)

b列是唯一键,session1成功插入一条刚被删除的相同键值,并且能查询出来两条相同键值的记录。看起来似乎是唯一键约束被破坏了,这实际上和InnoDB的内部实现有关。

在上述序列中,session 2执行删除操作,将唯一键进行标记删除,由于session1 已经开启了一个活跃的视图,根据REPEATABLE-READ的可见性原则,session 2所做的数据变更对session 1而言是不可见的,purge线程也无法去物理清理该记录。只要session 1不提交事务,总应该能看到被标记删除的记录(1,2)。

当session 1插入相同唯一键值记录(2,2)时,会检查到文件中存在冲突的唯一建,但修改该唯一键的事务已经提交,因此session 1认为插入记录(2,2)是合法的,完成插入后,唯一索引页上就存在两条物理记录,并且对session 1都是可见的。

这个问题是不是bug很难界定,毕竟他没有违反RR级别下可见性原则,唯一索引数据本身也是完好的,据我所知,PostgreSQL也遵循相同的逻辑。

时间: 2024-09-23 23:26:32

MySQL · 捉虫动态 · 唯一键约束失效的相关文章

MySQL · 捉虫动态·DROP DATABASE外键约束的GTID BUG

背景 MySQL的DDL没有被设计成事务操作,因此DDL操作是无法回滚的(像PgSQL把DDL也设计成事务操作,DDL就可以在执行成功后被回滚操作取消).这就会导致如果某个DDL语句内部被拆分为多个原子的DDL调用,那么这个DDL语句就不具备中途执行失败后回滚整个DDL语句的能力,也就是说,即使语句逻辑内的某个原子DDL调用失败了,也无法回滚已经完成的那些原子DDL调用. 问题描述 DROP DATABASE 就是一个例子,对于MySQL而言,DROP DATABASE 并非是一个原子DDL操作

MySQL · 捉虫动态 · 并行复制外键约束问题二

背景 并行复制可以大大提高备库的 binlog 应用速度,内核月报也多次对并行复制特性进行介绍,感兴趣的朋友可以回顾下:5.6 并行复制实现分析.5.6 并行复制恢复实现 和 5.6并行复制事件分发机制. 在早期的内核月报,有一篇 并行复制外建约束问题,介绍阿里在 5.5 版本中自己实现并行复制时遇到的外键约束问题,本文接着前作继续介绍并行复制外键约束问题,这次场景不一样,并且目前官方 5.6 最新版本(5.6.30)中也有这个问题. 问题描述 一般情况的复制是 A->B 这样一主一备,本文要描

MySQL · 捉虫动态 · show binary logs 灵异事件

问题背景 最近在运维 MySQL 中遇到一个神奇的问题,分享给大家.现象是这样的,show binary logs 没有返回结果,flush binary logs 后也不行, 但是 binlog 是正常工作的,show master staus 是有输出的. mysql> show binary logs; Empty set (0.00 sec) mysql> show master status\G *************************** 1. row *********

MySQL · 捉虫动态 · MySQL 外键异常分析

外键约束异常现象 如下测例中,没有违反引用约束的插入失败. create database `a-b`; use `a-b`; SET FOREIGN_KEY_CHECKS=0; create table t1(c1 int primary key, c2 int) engine=innodb; create table t2(c1 int primary key, c2 int) engine=innodb; alter table t2 add foreign key(c2) referen

MySQL · 捉虫动态 · 删除索引导致表无法打开

问题背景 最近线上遇到一个问题,用户重启实例后发现有张表打不开了,经调研后发现是用户之前的霸蛮操作导致的,下面给出复现步骤: create table t1 (id int not null primary key, name varchar(100) not null) engine=innodb; create table t2 (id int not null primary key, fid int not null, name varchar(100) not null, CONSTR

MySQL · 捉虫动态 · ORDER/GROUP BY 导致 mysqld crash

问题描述 表结构如下所示: show create table test\G Table: test Create Table: CREATE TABLE `test` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `id2` varchar(50) DEFAULT NULL `id3` varchar(100) DEFAULT NULL `some_text` varchar(200) DEFAULT NULL `name` varchar(

MySQL · 捉虫动态 · 信号处理机制分析

背景 在 AliSQL 上面有人提交了一个 bug,在使用主备的时候 service stop mysql 不能关闭主库,一直显示 shutting down mysql -,到底怎么回事呢,先来看一下 service stop mysql 是怎么停止数据库的.配置 MySQL 在系统启动时启动需要把 MYSQL_BASEDIR/support-files 目录下的脚本 mysql.sever 放到 /etc/init.d/ 目录下,脚本来控制 mysqld 的启动和停止.看一下脚本中的代码 :

MySQL · 捉虫动态 · left-join多表导致crash

有一天小编胡乱写SQL, left join了30张表, 结果导致了Mysql server gone away- 我们来看看crash堆栈 <signal handler called> base_list_iterator::next update_ref_and_keys make_join_statistics JOIN::optimize mysql_execute_select 可以看出, 在产生执行计划过程中crash了. 追查 堆栈表明, update_ref_and_keys

MySQL · 捉虫动态 · ALTER IGNORE TABLE导致主备不一致

背景 我们知道当一张表的某个字段存在重复值时,这个字段没办法直接加UNIQUE KEY,但是MySQL提供了一个 ALTER IGNORE TABLE的方式,可以忽略修改表结构过程中出现的错误,但是要忽略UNIQUE重复值,就需要打开old_alter_table,也就是拷贝表的方式来ALTER TABLE. 例如这样: CREATE TABLE t1(c1 int) ENGINE = InnoDB; INSERT INTO t1 VALUES (1), (1); SET old_alter_t