web-dev-qa-db-fra.com

Pourquoi cette requête aboutit-elle à un blocage?

Je fournis ci-dessous la requête MySQL brute et également le code dans lequel je le fais par programmation. Si deux demandes sont effectuées en même temps, le modèle d'erreur suivant se produit:

SQLSTATE [40001]: échec de sérialisation: 1213 Deadlock trouvé lors de la tentative d'obtention du verrou; essayez de redémarrer la transaction (SQL: update user_chats set updated_at = 2018-06-29 10:07:13 where id = 1)

Si j'exécute la même requête mais sans bloc de transaction, cela fonctionnera sans erreur avec de nombreux appels simultanés. Pourquoi ? (La transaction acquiert le verrou, non?)

Existe-t-il un moyen de résoudre ce problème sans verrouiller la table entière? (Vous voulez essayer d'éviter les verrous au niveau de la table)

Je sais qu'un verrou est acquis pour insérer/mettre à jour/supprimer des tables dans MySql avec InnoDB mais je ne comprends toujours pas pourquoi le blocage se produit ici et comment le résoudre de la manière la plus efficace.

    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;

Ci-dessus est la requête brute, mais je le fais en PHP Laravel Query Builder comme suit:

    /**
     * @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;
        }
    }

DDL pour les tables:

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
);
7
Kristi Jorgji

La CLÉ ÉTRANGÈRE user_chat_messages_user_chat_id_foreign Est la cause de votre impasse, dans cette situation.

Heureusement, cela est facile à reproduire compte tenu des informations que vous avez fournies.

Installer

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());

Notez que j'ai supprimé la clé étrangère user_chat_messages_from_user_id_foreign Car elle fait référence à la table users, que nous n'avons pas dans notre exemple. Ce n'est pas important pour reproduire le problème.

Reproduire l'impasse

Connexion 1

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

Connexion 2

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

Connexion 1

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;

À ce stade, la connexion 1 attend.

Connexion 2

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;

Ici, Connection 2 jette un blocage

ERREUR 1213 (40001): blocage trouvé lors de la tentative de verrouillage; essayez de redémarrer la transaction

Nouvelle tentative sans clé étrangère

Répétons les mêmes étapes, mais avec les structures de table suivantes. La seule différence cette fois-ci est la suppression de la clé étrangère 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());

Reproduire les mêmes étapes qu'auparavant

Connexion 1

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

Connexion 2

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

Connexion 1

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;

À ce stade, la connexion 1 s'exécute, au lieu d'attendre comme avant.

Connexion 2

update `user_chats` set `updated_at` = '2018-06-28 08:33:14' where `id` = 1;

La connexion 2 est maintenant celle qui attend maintenant, mais elle n'est pas bloquée.

Connexion 1

commit;

La connexion 2 cesse désormais d'attendre et exécute sa commande.

Connexion 2

commit;

Terminé, sans impasse.

Pourquoi?

Regardons la sortie de 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)

Vous pouvez voir que la transaction 1 a un lock_mode X sur la clé PRIMAIRE de user_chats, Tandis que la transaction 2 a lock_mode S, et attend lock_mode X. C'est le résultat de l'obtention d'un verrou partagé d'abord (à partir de notre instruction INSERT), puis d'un verrou exclusif (à partir de notre UPDATE).

Donc, ce qui se passe, c'est que la connexion 1 prend le verrou partagé en premier, puis la connexion 2 prend un verrou partagé sur le même enregistrement. C'est bien, pour l'instant, car ce sont deux verrous partagés.

La connexion 1 essaie ensuite de passer à un verrou exclusif pour effectuer la MISE À JOUR, pour constater que la connexion 2 a déjà un verrou. Les verrous partagés et exclusifs ne se mélangent pas bien, comme vous pouvez probablement en déduire par leur nom. C'est pourquoi il attend la commande UPDATE sur la connexion 1.

Ensuite, Connection 2 essaie de UPDATE, ce qui nécessite un verrou exclusif, et InnoDB va "whelp, je ne pourrai jamais résoudre cette situation par moi-même", et déclare un blocage. Il tue la connexion 2, libère le verrou partagé que la connexion 2 détenait et permet à la connexion 1 de se terminer normalement.

Solutions)

À ce stade, vous êtes probablement prêt à arrêter avec le yap yap yap et souhaitez une solution. Voici mes suggestions, par ordre de préférence personnelle.

1. Évitez complètement la mise à jour

Ne vous embêtez pas du tout avec la colonne updated_at Dans la table user_chats. À la place, ajoutez un index composite sur user_chat_messages Pour les colonnes (user_chat_id, created_at).

ALTER TABLE user_chat_messages
ADD INDEX `latest_message_for_user_chat` (`user_chat_id`,`created_at`)

Ensuite, vous pouvez obtenir l'heure de mise à jour la plus récente avec la requête suivante.

SELECT MAX(created_at) AS created_at FROM user_chat_messages WHERE user_chat_id = 1

Cette requête s'exécutera extrêmement rapidement en raison de l'index et ne vous oblige pas à stocker la dernière heure updated_at Dans la table user_chats Également. Cela permet d'éviter la duplication des données, c'est pourquoi c'est ma solution préférée.

Assurez-vous de définir dynamiquement id sur la valeur $message->getUserChatId(), et non codé en dur sur 1, Comme dans mon exemple.

C'est essentiellement ce que propose Rick James.

2. Verrouillez les tables pour sérialiser les demandes

SELECT id FROM user_chats WHERE id=1 FOR UPDATE

Ajoutez ce SELECT ... FOR UPDATE Au début de votre transaction et il sérialisera vos demandes. Comme précédemment, assurez-vous de définir dynamiquement id sur $message->getUserChatId(), et non codé en dur sur 1, Comme dans mon exemple.

C'est ce que propose Gerard H. Pille.

3. Déposez la clé étrangère

Parfois, il est simplement plus facile de supprimer la source de l'impasse. Déposez simplement la clé étrangère user_chat_messages_user_chat_id_foreign Et le problème est résolu.

Je n'aime pas particulièrement cette solution en général, car j'aime l'intégrité des données (que la clé étrangère fournit), mais parfois vous devez faire des compromis.

4. Relancez la commande après le blocage

Il s'agit de la solution recommandée pour les blocages en général. Attrapez simplement l'erreur et réessayez toute la demande. Cependant, il est plus facile à implémenter si vous vous y êtes préparé dès le début, et la mise à jour du code hérité peut être difficile. Étant donné qu'il existe des solutions plus faciles (comme 1 et 2 ci-dessus), c'est pourquoi c'est ma solution la moins recommandée pour votre situation.

14
Willem Renzema

Comme première étape de votre transaction, verrouillez $ this-> db-> table ('user_chats') -> where ('id', $ message-> getUserChatId ()). Cela évitera une impasse.

0
Gerard H. Pille

S'il n'y a qu'une seule ligne dans user_chats? Sinon, quelle est la sémantique de id? Est-ce un "utilisateur"? Ou un "numéro de chat"? Ou autre chose?

Il semble que toutes les connexions tentent de contourner l'ID de la dernière conversation (id = 1). Si vous en avez besoin, pensez à lancer le UPDATE et à le faire à la place lorsque vous voulez la dernière date:

SELECT MAX(created_at) FROM user_chat_messages.
0
Rick James