即使在 READ COMMITTED 中,MySQL InnoDB 也会在删除时锁定主键

vit*_*dze 12 mysql innodb mysql-5.5 locking isolation-level

前言

我们的应用程序运行多个DELETE并行执行查询的线程。查询影响孤立的数据,即不应该存在并发DELETE发生在来自不同线程的相同行上的可能性。但是,根据文档,MySQL 对DELETE语句使用所谓的“next-key”锁,它同时锁定匹配的键和一些间隙。这会导致死锁,我们找到的唯一解决方案是使用READ COMMITTED隔离级别。

问题

DELETE使用JOIN大表执行复杂语句时会出现问题。在特定情况下,我们有一个带有警告的表,只有两行,但查询需要从两个单独的INNER JOINed 表中删除属于某些特定实体的所有警告。查询如下:

DELETE pw 
FROM proc_warnings pw 
INNER JOIN day_position dp 
   ON dp.transaction_id = pw.transaction_id 
INNER JOIN ivehicle_days vd 
   ON vd.id = dp.ivehicle_day_id 
WHERE vd.ivehicle_id=? AND dp.dirty_data=1
Run Code Online (Sandbox Code Playgroud)

当 day_position 表足够大时(在我的测试用例中有 1448 行),那么即使使用READ COMMITTED隔离模式,任何事务也会阻塞整个 proc_warnings表。

该问题始终在此示例数据上重现 - http://yadi.sk/d/QDuwBtpW1BxB9在 MySQL 5.1(在 5.1.59 上检查)和 MySQL 5.5(在 MySQL 5.5.24 上检查)中。

编辑:链接的示例数据还包含查询表的架构和索引,为方便起见,在此处复制:

CREATE TABLE  `proc_warnings` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `transaction_id` int(10) unsigned NOT NULL,
    `warning` varchar(2048) NOT NULL,
    PRIMARY KEY (`id`),
    KEY `proc_warnings__transaction` (`transaction_id`)
);

CREATE TABLE  `day_position` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `transaction_id` int(10) unsigned DEFAULT NULL,
    `sort_index` int(11) DEFAULT NULL,
    `ivehicle_day_id` int(10) unsigned DEFAULT NULL,
    `dirty_data` tinyint(4) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `day_position__trans` (`transaction_id`),
    KEY `day_position__is` (`ivehicle_day_id`,`sort_index`),
    KEY `day_position__id` (`ivehicle_day_id`,`dirty_data`)
) ;

CREATE TABLE  `ivehicle_days` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `d` date DEFAULT NULL,
    `sort_index` int(11) DEFAULT NULL,
    `ivehicle_id` int(10) unsigned DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `ivehicle_days__is` (`ivehicle_id`,`sort_index`),
    KEY `ivehicle_days__d` (`d`)
);
Run Code Online (Sandbox Code Playgroud)

每个事务的查询如下:

  • 交易 1

    set transaction isolation level read committed;
    set autocommit=0;
    begin;
    DELETE pw 
    FROM proc_warnings pw 
    INNER JOIN day_position dp 
        ON dp.transaction_id = pw.transaction_id 
    INNER JOIN ivehicle_days vd 
        ON vd.id = dp.ivehicle_day_id 
    WHERE vd.ivehicle_id=2 AND dp.dirty_data=1;
    
    Run Code Online (Sandbox Code Playgroud)
  • 交易2

    set transaction isolation level read committed;
    set autocommit=0;
    begin;
    DELETE pw 
    FROM proc_warnings pw 
    INNER JOIN day_position dp 
        ON dp.transaction_id = pw.transaction_id 
    INNER JOIN ivehicle_days vd 
        ON vd.id = dp.ivehicle_day_id 
    WHERE vd.ivehicle_id=13 AND dp.dirty_data=1;
    
    Run Code Online (Sandbox Code Playgroud)

其中之一总是因“超出锁定等待超时...”错误而失败。将information_schema.innodb_trx包含以下行:

| trx_id     | trx_state   | trx_started           | trx_requested_lock_id  | trx_wait_started      | trx_wait | trx_mysql_thread_id | trx_query |
| '1A2973A4' | 'LOCK WAIT' | '2012-12-12 20:03:25' | '1A2973A4:0:3172298:2' | '2012-12-12 20:03:25' | '2'      | '3089'              | 'DELETE pw FROM proc_warnings pw INNER JOIN day_position dp ON dp.transaction_id = pw.transaction_id INNER JOIN ivehicle_days vd ON vd.id = dp.ivehicle_day_id WHERE vd.ivehicle_id=13 AND dp.dirty_data=1' |
| '1A296F67' | 'RUNNING'   | '2012-12-12 19:58:02' | NULL                   | NULL | '7' | '3087' | NULL |
Run Code Online (Sandbox Code Playgroud)

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 |
| '1A2973A4:0:3172298:2' | '1A2973A4'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |
| '1A296F67:0:3172298:2' | '1A296F67'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |
Run Code Online (Sandbox Code Playgroud)

正如我所看到的,两个查询都希望X对主键 = 53 的行进行排他锁。但是,它们都不能从proc_warnings表中删除行。我只是不明白为什么索引被锁定。此外,当proc_warnings表为空或day_position表包含的行数较少(即一百行)时,索引不会被锁定。

进一步的调查是运行EXPLAIN类似的SELECT查询。它表明查询优化器不使用索引来查询proc_warnings表,这是我能想象它为什么会阻塞整个主键索引的唯一原因。

简化案例

当只有两个表有几个记录,但子表在父表 ref 列上没有索引时,也可以在更简单的情况下重现问题。

创建parent

CREATE TABLE `parent` (
  `id` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
Run Code Online (Sandbox Code Playgroud)

创建child

CREATE TABLE `child` (
  `id` int(10) unsigned NOT NULL,
  `parent_id` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
Run Code Online (Sandbox Code Playgroud)

填表

INSERT INTO `parent` (id) VALUES (1), (2);
INSERT INTO `child` (id, parent_id) VALUES (1, NULL), (2, NULL);
Run Code Online (Sandbox Code Playgroud)

在两个并行事务中测试:

  • 交易 1

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 1;
    
    Run Code Online (Sandbox Code Playgroud)
  • 交易2

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 2;
    
    Run Code Online (Sandbox Code Playgroud)

这两种情况的共同点是 MySQL 不使用索引。我相信这就是整个表锁定的原因。

我们的解决方案

我们现在能看到的唯一解决方案是将默认的锁等待超时从 50 秒增加到 500 秒,让线程完成清理。然后保持手指交叉。

任何帮助表示赞赏。

JM *_*cks 3

新答案(MySQL 风格的动态 SQL):好的,这个问题以另一张海报所描述的方式解决了问题 - 颠倒互不兼容的排它锁的获取顺序,这样无论发生多少个,它们都只发生在交易执行结束时的最短时间。

这是通过将语句的读取部分分离到它自己的 select 语句中并动态生成一个删除语句来实现的,该删除语句将由于语句出现的顺序而被迫最后运行,并且仅影响 proc_warnings 表。

sql fiddle 提供了演示:

链接显示了带有示例数据的架构,以及对 上匹配的行的简单查询ivehicle_id=2。结果有 2 行,因为它们都没有被删除。

链接显示相同的架构、示例数据,但将值 2 传递给 DeleteEntries 存储程序,告诉 SP 删除 的proc_warnings条目ivehicle_id=2。对行的简单查询不会返回任何结果,因为它们已全部成功删除。演示链接仅演示代码按预期删除。有合适测试环境的用户可以评论一下这是否解决了线程阻塞的问题。

为了方便起见,这里也给出了代码:

CREATE PROCEDURE DeleteEntries (input_vid INT)
BEGIN

    SELECT @idstring:= '';
    SELECT @idnum:= 0;
    SELECT @del_stmt:= '';

    SELECT @idnum:= @idnum+1 idnum_col, @idstring:= CONCAT(@idstring, CASE WHEN CHARACTER_LENGTH(@idstring) > 0 THEN ',' ELSE '' END, CAST(id AS CHAR(10))) idstring_col
    FROM proc_warnings
    WHERE EXISTS (
        SELECT 0
        FROM day_position
        WHERE day_position.transaction_id = proc_warnings.transaction_id
        AND day_position.dirty_data = 1
        AND EXISTS (
            SELECT 0
            FROM ivehicle_days
            WHERE ivehicle_days.id = day_position.ivehicle_day_id
            AND ivehicle_days.ivehicle_id = input_vid
        )
    )
    ORDER BY idnum_col DESC
    LIMIT 1;

    IF (@idnum > 0) THEN
        SELECT @del_stmt:= CONCAT('DELETE FROM proc_warnings WHERE id IN (', @idstring, ');');

        PREPARE del_stmt_hndl FROM @del_stmt;
        EXECUTE del_stmt_hndl;
        DEALLOCATE PREPARE del_stmt_hndl;
    END IF;
END;
Run Code Online (Sandbox Code Playgroud)

这是从事务中调用程序的语法:

CALL DeleteEntries(2);
Run Code Online (Sandbox Code Playgroud)

原始答案(仍然认为它不是太破烂)看起来有两个问题:1)缓慢的查询2)意外的锁定行为

关于问题#1,慢速查询通常可以通过串联查询语句简化和对索引进行有用的添加或修改中的相同两种技术来解决。您自己已经建立了与索引的连接 - 没有它们,优化器无法搜索要处理的有限行集,并且每个表中的每一行乘以每个额外行扫描必须完成的额外工作量。

在查看架构和索引后进行修改:但我想通过确保拥有良好的索引配置,您将获得查询的最大性能优势。为此,您可以寻求更好的删除性能,甚至可能更好的删除性能,同时权衡较大的索引,并且在添加了额外索引结构的相同表上可能会显着降低插入性能。

更好一些:

CREATE TABLE  `day_position` (
    ...,
    KEY `day_position__id_rvrsd` (`dirty_data`, `ivehicle_day_id`)

) ;


CREATE TABLE  `ivehicle_days` (
    ...,
    KEY `ivehicle_days__vid_no_sort_index` (`ivehicle_id`)
);
Run Code Online (Sandbox Code Playgroud)

此处也进行了修改:因为它需要与运行一样长的时间,所以我将 dirty_data 留在索引中,当我将其按索引顺序放置在 ivehicle_day_id 之后时,我肯定也犯了错误 - 它应该是第一个。

但是,如果我现在掌握了它,因为必须有大量数据才能花那么长时间,所以我会选择所有覆盖索引,以确保我获得了最佳索引如果没有其他办法可以排除这部分问题的话,我的故障排除时间是可以买到的。

最佳/覆盖索引:

CREATE TABLE  `day_position` (
    ...,
    KEY `day_position__id_rvrsd_trnsid_cvrng` (`dirty_data`, `ivehicle_day_id`, `transaction_id`)
) ;

CREATE TABLE  `ivehicle_days` (
    ...,
    UNIQUE KEY `ivehicle_days__vid_id_cvrng` (ivehicle_id, id)
);

CREATE TABLE  `proc_warnings` (

    .., /*rename primary key*/
    CONSTRAINT pk_proc_warnings PRIMARY KEY (id),
    UNIQUE KEY `proc_warnings__transaction_id_id_cvrng` (`transaction_id`, `id`)
);
Run Code Online (Sandbox Code Playgroud)

最后两个更改建议寻求两个性能优化目标:
1)如果连续访问的表的搜索键与当前访问的表返回的聚集键结果不同,我们消除了需要做的事情对聚集索引进行第二组索引查找和扫描操作
2) 如果不是后者,则优化器至少仍然有可能选择更有效的连接算法,因为索引将保持所需的连接键按排序顺序排列。

您的查询似乎已尽可能简化(复制到此处以防稍后编辑):

DELETE pw 
FROM proc_warnings pw 
INNER JOIN day_position dp 
    ON dp.transaction_id = pw.transaction_id 
INNER JOIN ivehicle_days vd 
    ON vd.id = dp.ivehicle_day_id 
WHERE vd.ivehicle_id=2 AND dp.dirty_data=1;
Run Code Online (Sandbox Code Playgroud)

当然,除非有关书面连接顺序的某些内容会影响查询优化器的处理方式,在这种情况下,您可以尝试其他人提供的一些重写建议,包括可能带有索引提示的建议(可选):

DELETE FROM proc_warnings
FORCE INDEX (`proc_warnings__transaction_id_id_cvrng`, `pk_proc_warnings`)
WHERE EXISTS (
    SELECT 0
    FROM day_position
    FORCE INDEX (`day_position__id_rvrsd_trnsid_cvrng`)  
    WHERE day_position.transaction_id = proc_warnings.transaction_id
    AND day_position.dirty_data = 1
    AND EXISTS (
        SELECT 0
        FROM ivehicle_days
        FORCE INDEX (`ivehicle_days__vid_id_cvrng`)  
        WHERE ivehicle_days.id = day_position.ivehicle_day_id
        AND ivehicle_days.ivehicle_id = ?
    )
);
Run Code Online (Sandbox Code Playgroud)

至于#2,意外的锁定行为。

正如我所看到的,两个查询都需要主键 = 53 的行上的独占 X 锁。但是,它们都不能从 proc_warnings 表中删除行。我只是不明白为什么索引被锁定。

我猜想是索引被锁定,因为要锁定的数据行位于聚集索引中,即单行数据本身驻留在索引中。

它将被锁定,因为:
1)根据http://dev.mysql.com/doc/refman/5.1/en/innodb-locks-set.html

...a DELETE 通常会在 SQL 语句处理过程中扫描的每个索引记录上设置记录锁。语句中是否存在排除该行的 WHERE 条件并不重要。InnoDB不记得确切的WHERE条件,而只知道扫描了哪些索引范围。

上面你也提到了:

...对我来说,READ COMMITTED 的主要特点是它如何处理锁。它应该释放不匹配行的索引锁,但它没有。

并为此提供了以下参考:
http://dev.mysql.com/doc/refman/5.1/en/set-transaction.html#isolevel_read-comfilled

与您的陈述相同,除了根据相同的参考文献,有一个释放锁的条件:

此外,在 MySQL 评估 WHERE 条件后,不匹配行的记录锁将被释放。

本手册页也重申了这一点http://dev.mysql.com/doc/refman/5.1/en/innodb-record-level-locks.html

使用 READ COMMITTED 隔离级别或启用 innodb_locks_unsafe_for_binlog 还存在其他影响:在 MySQL 评估 WHERE 条件后,将释放不匹配行的记录锁。

因此,我们被告知必须先评估 WHERE 条件,然后才能释放锁。不幸的是,我们没有被告知何时评估 WHERE 条件,并且它可能会从一个计划更改为优化器创建的另一个计划。但它确实告诉我们,锁释放在某种程度上取决于查询执行的性能,正如我们上面讨论的那样,查询的优化取决于语句的仔细编写和索引的明智使用。它也可以通过更好的表格设计来改进,但这可能最好留给一个单独的问题。

此外,当 proc_warnings 表为空时索引也不会被锁定

如果索引中没有记录,数据库将无法锁定记录。

此外,当……day_position表包含较少的行数(即一百行)时,索引不会被锁定。

这可能意味着很多事情,例如但可能不限于:由于统计数据的变化而导致的不同的执行计划,由于数据集小得多而导致执行速度快得多而导致的太短暂而难以观察到的锁定/加入操作。