为什么这个查询会导致死锁?

Kri*_*gji 8 mysql deadlock transaction

我在下面提供了原始 MySQL 查询以及我以编程方式执行此操作的代码。如果同时执行两个请求会导致以下错误模式:

SQLSTATE[40001]:序列化失败:1213 尝试获取锁时发现死锁;请尝试重新启动交易(SQL: update user_chats set updated_at = 2018-06-29 10:07:13 where id = 1

如果我执行相同的查询但没有事务块,它将在许多并发调用中正常工作而不会出错。为什么 ?(交易获得锁,对吗?)

有没有办法在不锁定整个表的情况下解决这个问题?(想尽量避免表级锁)

我知道使用 InnoDB 在 MySql 中插入/更新/删除表需要锁定,但仍然不明白为什么会在这里发生死锁以及如何以最有效的方式解决它。

    START TRANSACTION;

    insert into `user_chat_messages` (`user_chat_id`, `from_user_id`, `content`)
        values (1, 2, 'dfasfdfk);
    update `user_chats`
        set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;

    COMMIT;
Run Code Online (Sandbox Code Playgroud)

以上是原始查询,但我在 PHP Laravel Query Builder 中执行如下:

    /**
     * @param UserChatMessageEntity $message
     * @return int
     * @throws \Exception
     */
    public function insertChatMessage(UserChatMessageEntity $message) : int
    {
        $this->db->beginTransaction();
        try
        {
            $id = $this->db->table('user_chat_messages')->insertGetId([
                    'user_chat_id' => $message->getUserChatId(),
                    'from_user_id' => $message->getFromUserId(),
                    'content' => $message->getContent()
                ]
            );

            //TODO results in lock error if many messages are sent same time
            $this->db->table('user_chats')
                ->where('id', $message->getUserChatId())
                ->update(['updated_at' => date('Y-m-d H:i:s')]);

            $this->db->commit();
            return $id;
        }
        catch (\Exception $e)
        {
            $this->db->rollBack();
            throw  $e;
        }
    }
Run Code Online (Sandbox Code Playgroud)

表的 DDL:

CREATE TABLE user_chat_messages
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    user_chat_id INT(10) unsigned NOT NULL,
    from_user_id INT(10) unsigned NOT NULL,
    content VARCHAR(500) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    CONSTRAINT user_chat_messages_user_chat_id_foreign FOREIGN KEY (user_chat_id) REFERENCES user_chats (id),
    CONSTRAINT user_chat_messages_from_user_id_foreign FOREIGN KEY (from_user_id) REFERENCES users (id)
);
CREATE INDEX user_chat_messages_from_user_id_index ON user_chat_messages (from_user_id);
CREATE INDEX user_chat_messages_user_chat_id_index ON user_chat_messages (user_chat_id);


CREATE TABLE user_chats
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
Run Code Online (Sandbox Code Playgroud)

Wil*_*ema 17

user_chat_messages_user_chat_id_foreign在这种情况下,外键是导致死锁的原因。

幸运的是,鉴于您提供的信息,这很容易重现。

设置

CREATE DATABASE dba210949;
USE dba210949;

CREATE TABLE user_chats
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE TABLE user_chat_messages
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    user_chat_id INT(10) unsigned NOT NULL,
    from_user_id INT(10) unsigned NOT NULL,
    content VARCHAR(500) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    CONSTRAINT user_chat_messages_user_chat_id_foreign FOREIGN KEY (user_chat_id) REFERENCES user_chats (id)
);

insert into user_chats (id,updated_at) values (1,NOW());
Run Code Online (Sandbox Code Playgroud)

请注意,我删除了user_chat_messages_from_user_id_foreign外键,因为它引用了users我们的示例中没有的表。重现问题并不重要。

再现死锁

连接 1

USE dba210949;
START TRANSACTION;
insert into `user_chat_messages` (`user_chat_id`, `from_user_id`, `content`) values (1, 2, 'dfasfdfk');
Run Code Online (Sandbox Code Playgroud)

连接 2

USE dba210949;
START TRANSACTION;
insert into `user_chat_messages` (`user_chat_id`, `from_user_id`, `content`) values (1, 2, 'dfasfdfk');
Run Code Online (Sandbox Code Playgroud)

连接 1

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;
Run Code Online (Sandbox Code Playgroud)

此时,连接 1 正在等待。

连接 2

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;
Run Code Online (Sandbox Code Playgroud)

在这里,连接 2 引发了死锁

ERROR 1213 (40001):尝试获取锁时发现死锁;尝试重新启动事务

在没有外键的情况下重试

让我们重复相同的步骤,但使用以下表格结构。这次唯一的区别是删除了user_chat_messages_user_chat_id_foreign外键。

CREATE DATABASE dba210949;
USE dba210949;

CREATE TABLE user_chats
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE TABLE user_chat_messages
(
    id INT(10) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
    user_chat_id INT(10) unsigned NOT NULL,
    from_user_id INT(10) unsigned NOT NULL,
    content VARCHAR(500) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

insert into user_chats (id,updated_at) values (1,NOW());
Run Code Online (Sandbox Code Playgroud)

重现与之前相同的步骤

连接 1

USE dba210949;
START TRANSACTION;
insert into `user_chat_messages` (`user_chat_id`, `from_user_id`, `content`) values (1, 2, 'dfasfdfk');
Run Code Online (Sandbox Code Playgroud)

连接 2

USE dba210949;
START TRANSACTION;
insert into `user_chat_messages` (`user_chat_id`, `from_user_id`, `content`) values (1, 2, 'dfasfdfk');
Run Code Online (Sandbox Code Playgroud)

连接 1

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;
Run Code Online (Sandbox Code Playgroud)

此时,连接 1 执行,而不是像以前那样等待。

连接 2

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;
Run Code Online (Sandbox Code Playgroud)

现在连接 2 是正在等待的连接,但它并没有死锁。

连接 1

commit;
Run Code Online (Sandbox Code Playgroud)

连接 2 现在停止等待并执行其命令。

连接 2

commit;
Run Code Online (Sandbox Code Playgroud)

完成,没有死锁。

为什么?

让我们看看输出 SHOW ENGINE INNODB STATUS

------------------------
LATEST DETECTED DEADLOCK
------------------------
2018-07-04 10:38:31 0x7fad84161700
*** (1) TRANSACTION:
TRANSACTION 42061, ACTIVE 55 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 2, OS thread handle 140383222380288, query id 81 localhost root updating
update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3711 page no 3 n bits 72 index PRIMARY of table `dba210949`.`user_chats` trx id 42061 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 00000001; asc     ;;
 1: len 6; hex 00000000a44b; asc      K;;
 2: len 7; hex b90000012d0110; asc     -  ;;
 3: len 4; hex 5b3ca335; asc [< 5;;
 4: len 4; hex 5b3ca335; asc [< 5;;

*** (2) TRANSACTION:
TRANSACTION 42062, ACTIVE 46 sec starting index read
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 3, OS thread handle 140383222109952, query id 82 localhost root updating
update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 3711 page no 3 n bits 72 index PRIMARY of table `dba210949`.`user_chats` trx id 42062 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 00000001; asc     ;;
 1: len 6; hex 00000000a44b; asc      K;;
 2: len 7; hex b90000012d0110; asc     -  ;;
 3: len 4; hex 5b3ca335; asc [< 5;;
 4: len 4; hex 5b3ca335; asc [< 5;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3711 page no 3 n bits 72 index PRIMARY of table `dba210949`.`user_chats` trx id 42062 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 00000001; asc     ;;
 1: len 6; hex 00000000a44b; asc      K;;
 2: len 7; hex b90000012d0110; asc     -  ;;
 3: len 4; hex 5b3ca335; asc [< 5;;
 4: len 4; hex 5b3ca335; asc [< 5;;

*** WE ROLL BACK TRANSACTION (2)
Run Code Online (Sandbox Code Playgroud)

您可以看到事务 1在 的 PRIMARY 键上有一个lock_mode Xuser_chats,而事务 2 有lock_mode S,并且正在等待lock_mode X。这是因为它首先获得共享锁(来自我们的INSERT语句),然后是排他锁(来自我们的UPDATE)。

所以,发生的事情是连接 1 首先获取共享锁,然后连接 2 获取同一记录上的共享锁。现在很好,因为它们都是共享锁。

然后,连接 1 尝试升级到排他锁以执行 UPDATE,却发现连接 2 已经拥有锁。共享锁和排他锁不能很好地混合,正如您可能从它们的名字推断出来的那样。这就是它UPDATE在连接 1 上的命令之后等待的原因。

然后连接 2 尝试UPDATE,这需要一个排他锁,并且 InnoDB 变得“whelp,我永远无法自己解决这种情况”,并声明一个死锁。它杀死连接 2,释放连接 2 持有的共享锁,并允许连接 1 正常完成。

解决方案

在这一点上,您可能已经准备好停止 yap yap yap 并想要一个解决方案。以下是我的建议,按个人喜好排序。

1.完全避免更新

根本不要理会表格中的updated_atuser_chats。相反,user_chat_messages为列 ( user_chat_id, created_at)添加一个复合索引。

ALTER TABLE user_chat_messages
ADD INDEX `latest_message_for_user_chat` (`user_chat_id`,`created_at`)
Run Code Online (Sandbox Code Playgroud)

然后,您可以通过以下查询获取最近更新的时间。

SELECT MAX(created_at) AS created_at FROM user_chat_messages WHERE user_chat_id = 1
Run Code Online (Sandbox Code Playgroud)

由于有索引,此查询将执行得非常快,并且不需要您也将最新updated_at时间存储在user_chats表中。这有助于避免数据重复,这就是为什么它是我首选的解决方案。

确保将 动态设置id$message->getUserChatId()值,而不是硬编码为1,如我的示例中所示。

这基本上就是 Rick James 的建议。

2.锁定表以序列化请求

SELECT id FROM user_chats WHERE id=1 FOR UPDATE
Run Code Online (Sandbox Code Playgroud)

将此添加SELECT ... FOR UPDATE到您的事务的开头,它将序列化您的请求。和以前一样,确保动态地将 设置id$message->getUserChatId()值,而不是1像我的示例中那样硬编码为。

这就是 Gerard H. Pille 的建议。

3.删除外键

有时,消除死锁的根源更容易。只需删除user_chat_messages_user_chat_id_foreign外键,问题就解决了。

总的来说,我并不是特别喜欢这个解决方案,因为我喜欢数据完整性(外键提供的),但有时您需要进行权衡。

4.死锁后重试命令

这是一般情况下推荐的死锁解决方案。只需捕获错误,然后重试整个请求即可。但是,如果您从一开始就做好了准备,那么它最容易实现,并且更新遗留代码可能很困难。鉴于有更简单的解决方案(如上面的 1 和 2),这就是为什么这是我最不推荐的解决方案。