41から始めました

文字通り41歳から始めたブログです。DB(MySQL)を使ってお仕事してるので、DB周りの話を中心に最近始めたこととかをTwitterのノリで書いています。なお、本サイトにおいて示されている見解は私個人の見解であり、所属団体や組織を代表するものではありません。

SELECT ... FOR UPDATEとUPDATEでデッドロックが出る人へ

はじめに

最近は主に花粉症に悩まされており、目が痒くてたまりません。

また、娘の生活がガラッと変わったせいで、毎日貧乏ヒマ無しです。

そんな中、たまたま早起きできたので奮起して久々に書いてみました。

問題が起きる環境

  • MySQL8.0.17以前
  • transaction_isolationがREAD-COMMITTED
  • WHERE句の条件が一意ではない。(フルテーブルスキャンだと発生しやすくなる)
  • キーの値がたすきがけになってる
  • トランザクション開始+SELECT ...FOR UPDATE→UPDATEのようにロックを取っている
  • 先に実行されたトランザクションが、たすきがけになっているキー値の若い(っていうのかな?)方のロックを取る

何が起きるかと言うと、SELECT ...FOR UPDATEのWHERE句で抽出した行に対してロックを取ってるのに、 後から別セッションで実行されたSELECT ...FOR UPDATEのせいで、先取りしたほうがデッドロックして負けるというお話。

これ、世の中のシステムで意外と起きてそうだなーと思ったが、文章にするとわかりづらいので、具体例を記載しながら説明する。

詳細

準備

1.まず、こんなテストテーブルを構築。

mysql> use test;
mysql> show create table tu\G
*************************** 1. row ***************************
       Table: tu
Create Table: CREATE TABLE `tu` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` varchar(10) DEFAULT NULL,
  `c` varchar(10) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `u` (`a`,`b`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

2.データはこちら

mysql> INSERT INTO tu VALUSE(1,1,'a','a'),(2,2,'c','2'),(3,3,'b','3'),(4,4,'d','4');
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> SELECT * FROM tu;
+----+------+------+------+
| id | a    | b    | c    |
+----+------+------+------+
|  1 |    1 | a    | a    |
|  2 |    2 | c    | 2    |
|  3 |    3 | b    | 3    |
|  4 |    4 | d    | 4    |
+----+------+------+------+
4 rows in set (0.00 sec)

キーはPKがid、UKがa,bで作られ、データはbカラムのところでid=2と3で入れ子にしている。

(PKはaカラムとbカラムの組み合わせでもOK)

検証

1. 1つ目のトランザクションでSELECT...FOR UPDATEを行う。

3行目のデータを更新対象に見据えて、SELECT...FOR UPDATE文を実行する。

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

mysql> SELECT * FROM tu WHERE c = '3' FOR UPDATE;
+----+------+------+------+
| id | a    | b    | c    |
+----+------+------+------+
|  3 |    3 | b    | 3    |
+----+------+------+------+
1 row in set (0.00 sec)

mysql> EXPLAIN SELECT * FROM tu WHERE c = '3' FOR UPDATE;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | tu    | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    4 |    25.00 | Using WHERE |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

ここではWHERE句の条件をインデックスを使わず、フルテーブルスキャンになるようにしている。

この時、どのようなロックが取得されているかと言うと、以下の通り。

> SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137104:1061:140229071664344
ENGINE_TRANSACTION_ID: 3593
            THREAD_ID: 47
             EVENT_ID: 13
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140229071664344
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137104:4:4:7:140229071661352
ENGINE_TRANSACTION_ID: 3593
            THREAD_ID: 47
             EVENT_ID: 13
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140229071661352
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 3
2 rows in set (0.00 sec)

テーブルに対し、インサートインテンションロック、対象行に対し、排他ロックがかかる。

(ちなみに、MySQL5.7以前だとperformance_schema.data_locksは無い)

2. 2つ目のトランザクションでもSELECT...FOR UPDATEを行う。

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

mysql> SELECT * FROM tu WHERE c = '2' FOR UPDATE;

待たされる。

この時、どのようなロックが取得されているかと言うと、以下の通り。

mysql> SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137968:1061:140229071670264
ENGINE_TRANSACTION_ID: 3595
            THREAD_ID: 48
             EVENT_ID: 16
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140229071670264
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137968:4:4:3:140229071667384
ENGINE_TRANSACTION_ID: 3595
            THREAD_ID: 48
             EVENT_ID: 16
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140229071667384
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137968:4:4:7:140229071667728
ENGINE_TRANSACTION_ID: 3595
            THREAD_ID: 48
             EVENT_ID: 16
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140229071667728
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: WAITING
            LOCK_DATA: 3
*************************** 4. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137104:1061:140229071664344
ENGINE_TRANSACTION_ID: 3593
            THREAD_ID: 47
             EVENT_ID: 13
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140229071664344
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 5. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140229171137104:4:4:7:140229071661352
ENGINE_TRANSACTION_ID: 3593
            THREAD_ID: 47
             EVENT_ID: 13
        OBJECT_SCHEMA: test
          OBJECT_NAME: tu
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140229071661352
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 3
5 rows in set (0.00 sec)

上の3つが2つ目のトランザクションによるロック。

"3. row" のロックが LOCK_STATUS: WAITING となっているのでこれにより待たされていることがわかる。

これだけを見ると、1つ目のトランザクションでは正常に後続のUPDATE文が通りそうなもんであるが、しかし・・・

3. 1つ目のトランザクションでUPDATEが失敗する

mysql> UPDATE tu SET c='c2' WHERE c = '3';
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

ちなみに上記のデッドロック、もし2つ目のトランザクションの条件(where c = '2')を先に実行した場合はデッドロックが発生しない。

原因

リリースノートにあるコレ

InnoDB: A deadlock was possible when a transaction tries to upgrade a record lock to a next key lock. (Bug #23755664, Bug #82127)

解決方法

3つあって

  • MySQL8.0.18以降にする
  • transaction_isolationをREPEATABLE-READにする
  • 更新対象を一意にする

根治するならバージョンアップ一択なんですが、バージョンいきなりやれって言われてもツライと思うし、

transaction_isolation変えるなんて、それもまたシステムへの影響多いだろうから、

まずは更新対象をできるだけ絞る、可能なら一意にするってところから始めてくれればかと。

もし、EXPLAIN取ってtypeがALLなんて出てたら、それを潰すところからですね。

インデックス張りましょう。抽出対象を絞れるようにしましょう。

最後に

そういえば、2月15日で勤続1年になりました。

チームのメンバー、イルカさん関係者の皆さん、外部のイルカ大好きな皆様、そしてお客様に恵まれ、非常にやりがいもあり成長させていただき、充実した一年でした。

まだまだ力不足でご迷惑をおかけすることもあるかもしれませんが、今後ともよろしくお願いします。

参照情報

bugs.mysql.com