├── images ├── io-buffer-image.png ├── innodb-deadlock-thread-id-01.png └── innodb-deadlock-thread-id-02.png ├── README.md ├── toy-io-buffer.md ├── innodb-deadlock-thread-id.md └── innodb.md /images/io-buffer-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ichirin2501/doc/HEAD/images/io-buffer-image.png -------------------------------------------------------------------------------- /images/innodb-deadlock-thread-id-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ichirin2501/doc/HEAD/images/innodb-deadlock-thread-id-01.png -------------------------------------------------------------------------------- /images/innodb-deadlock-thread-id-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ichirin2501/doc/HEAD/images/innodb-deadlock-thread-id-02.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doc 2 | いちりんめも 3 | 4 | * [MySQLのInnoDBのロック挙動調査](https://github.com/ichirin2501/doc/blob/master/innodb.md) 5 | * [スレッドIDを利用したMySQLのデッドロック解析手法](innodb-deadlock-thread-id.md) -------------------------------------------------------------------------------- /toy-io-buffer.md: -------------------------------------------------------------------------------- 1 | C言語でI/Oバッファリング遊び 2 | ---- 3 | 4 | ```c 5 | #include 6 | #include 7 | #include 8 | 9 | int main () { 10 | printf("|'-') hi ichirin desu\n"); 11 | write(1, "|'o')\n", 6); 12 | pid_t pid = fork(); 13 | if (pid == 0) { 14 | exit(0); 15 | } 16 | return 0; 17 | } 18 | ``` 19 | 20 | ``` 21 | $ gcc fork.c 22 | $ ./a.out 23 | |'-') hi ichirin desu 24 | |'o') 25 | $ ./a.out > out.txt 26 | $ cat out.txt 27 | |'o') 28 | |'-') hi ichirin desu 29 | |'-') hi ichirin desu 30 | ``` 31 | 32 | ### 一見不思議なこと 33 | 34 | - printf(3)の文字列が2回出力される 35 | - write(2)の文字列は1回しか出力されない 36 | - write(2)の出力がprintf(3)の出力より先行している 37 | 38 | 39 | ### 各関数のポイント 40 | 41 | - printf(3)はライブラリ関数でwrite(2)はシステムコール 42 | - printf(3), fscanf(3), fgets(3), などのstdioライブラリ関数は内部でバッファを持つ(ユーザメモリ空間) 43 | - fork(2)はプロセスの複製でstdioバッファもその対象 44 | - exit(3)はstdioバッファをフラッシュする 45 | 46 | 47 | ### printf(3)の文字列が2回出力される件 48 | stdioライブラリだと出力が端末の場合はデフォルトで行バッファリングになるため、改行が含まれる文字列はprintfでも直後に表示される。改行が含まれない場合はバッファリングされる。出力がファイルの場合はデフォルトでブロックバッファリングになる。リダイレクトすることによって標準出力先がファイルになり、改行ではフラッシュされなくなる。fork(2)時点ではstdioバッファ内に溜まったままなので、子プロセスにも複製される。親/子プロセスがexit(3)を実行することにより、それぞれが持つstdioバッファがフラッシュされて結果的に同じ文字列が出力される。ちなみに標準エラー出力はデフォルトで非バッファリングなので、今回のように2回出力 & 出力が前後することもない。 49 | 50 | ### write(2)の文字列は1回しか出力されない 51 | write(2)はシステムコールなのでstdioライブラリ関数のようにユーザメモリ空間にバッファリングしないため、fork(2)による複製が行われない。write(2)はどこに書くかと言うとカーネルバッファに直接書くようになっている。 52 | 53 | 54 | ### write(2)の出力がprintf(3)の出力より先行している 55 | まず、出力が前後することとfork(2)は関係がなく、fork処理がなくても今回のケースでは出力の前後が発生する。端末に出力するときは行バッファリングで改行文字によりフラッシュされてwrite(2)システムコールが呼ばれていたが、リダイレクトにより出力がブロックバッファリングになった関係ですぐにフラッシュされずにwrite(2)システムコールを呼び出すタイミングが遅れた。そのため、直後のwrite(2)の文字列出力が先行することになった。 56 | 57 | - write(2) => カーネルバッファに書き込み 58 | - printf(3) => stdioバッファに書き込み => exit(3)でstdioバッファをフラッシュ => カーネルバッファに書き込み 59 | 60 | ![io-buffer](https://github.com/ichirin2501/doc/blob/master/images/io-buffer-image.png) 61 | Linuxプログラミングインタフェースから拝借 62 | 63 | ### 参考 64 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man2/write.2.html 65 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man2/fork.2.html 66 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man2/open.2.html 67 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man3/setbuf.3.html 68 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man3/stdio.3.html 69 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man3/stdout.3.html 70 | - https://linuxjm.osdn.jp/html/LDP_man-pages/man3/exit.3.html 71 | - https://www.oreilly.co.jp/books/9784873115856/ 72 | -------------------------------------------------------------------------------- /innodb-deadlock-thread-id.md: -------------------------------------------------------------------------------- 1 | スレッドIDを利用したMySQLのデッドロック解析手法 2 | ---- 3 | 4 | ## TL;DR 5 | - `thread_id`を使ってbinlogを調査すればデッドロックが発生したトランザクションの更新クエリがわかる(かも 6 | 7 | ## 目次 8 | - [はじめに](#はじめに) 9 | - [今回のデッドロック解析手法](#今回のデッドロック解析手法) 10 | - [検証データセット](#検証データセット) 11 | - [1. binlog_format=STATEMENTで検証](#binlog_format=STATEMENTで検証) 12 | - [2. binlog_format=ROWで検証](#binlog_format=ROWで検証) 13 | - [参考資料](#参考資料) 14 | 15 | ## はじめに 16 | これはMySQL(InnoDB)のログに記録されたthread_idを利用して既に発生したデッドロックの解析手法を紹介する内容である。`show engine innodb status\G`などの`LATEST DETECTED DEADLOCK`だけでは、トランザクション全容の情報に欠け、間接的にデッドロックの原因となったクエリがわからないことがある。そのため、その情報だけでは実際にクエリを発行しているアプリケーションロジック側をどのように修正すればよいのかわからず、解決が難しいという問題がある。今回紹介するthread_idを利用した解析手法では、成功したトランザクション処理を追うことでデッドロック解決に役立つ情報が増えるメリットがある。それとは別に全トランザクション処理のクエリログを収集出来るオプション(general_log)もある。これを有効にすると負荷が大きく、本番稼動している現場のMySQLでは通常はOFFにするため、デッドロック発生時のトランザクション情報は収集出来ない。そのような現場のMySQLでも冗長化のためにレプリケーション構成を取られることが多く、構築過程でbinlogを生成するオプションを有効にするので、今回紹介する解析手法が使える。MySQLのデッドロック解析手法は他でも解説されているが、今のところ日本語の情報でthread_idを利用した方法については述べられてないように思ったので改めて紹介したい。これを利用してデッドロックの原因を突き止め、一つでも多くのトランザクション処理がエラーにならないことを祈る。 17 | 18 | ## 今回のデッドロック解析手法 19 | 1. `show engine innodb status\G`などでデッドロックが発生した`thread_id`を採取 20 | `innodb_print_all_deadlocks`を使えばエラーログに出力されるので便利 21 | 2. トランザクションが成功した`thread_id`を使ってbinlogを検索 22 | 3. 対象トランザクションの更新クエリが見えて嬉しい ٩( ᐛ )و 23 | 24 | binlogにはSTATEMENT/ROW/MIXEDの3つのフォーマットがあり、見方が微妙に異なる。MIXEDはSTATEMENTとROWの混合のため、今回はSTATEMENTとROWフォーマットの見方を挙げるだけに留める(後述)。binlogにはトランザクション処理が成功した更新クエリしか記録されないため、デッドロックによって失敗した側のトランザクション情報や発行した明示的なロック(SELECT-FOR-UPDATE)なども記録されない(後述)ことに注意が必要である。 25 | 26 | ## 検証データセット 27 | 28 | ```bash 29 | #!/bin/bash 30 | 31 | cat < BEGIN; 54 | TB> BEGIN; 55 | TA> UPDATE t1 SET number = 777 WHERE id = 30; 56 | TB> UPDATE t1 SET number = 888 WHERE id = 750; 57 | TA> UPDATE t1 SET number = 7777 WHERE id = 750; // 待たされる 58 | TB> UPDATE t1 SET number = 8888 WHERE id = 30; // deadlock 59 | TA> COMMIT; 60 | ``` 61 | 62 | 余談だが、 63 | https://dev.mysql.com/doc/refman/5.6/ja/innodb-deadlock-detection.html 64 | > InnoDB では、自動的にトランザクションのデッドロックが検出され、デッドロックを解除するためにトランザクション (複数の場合あり) がロールバックされます。InnoDB は、小さいトランザクションを選択してロールバックしようと試みます。トランザクションのサイズは、挿入、更新、または削除された行数によって決定されます。 65 | 66 | なので、先に待たされているクエリ側が成功するとは限らない。 67 | 68 | 以下はdeadlockになった直後に`show engine innodb status\G`を実行して`LATEST DETECTED DEADLOCK`だけを抜粋したログ。 69 | 70 | ``` 71 | ------------------------ 72 | LATEST DETECTED DEADLOCK 73 | ------------------------ 74 | 2018-03-23 19:09:38 7f2eddd75700 75 | *** (1) TRANSACTION: 76 | TRANSACTION 3846, ACTIVE 18 sec starting index read 77 | mysql tables in use 1, locked 1 78 | LOCK WAIT 3 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 1 79 | MySQL thread id 2, OS thread handle 0x7f2edddb6700, query id 34 localhost root updating 80 | UPDATE t1 SET number = 7777 WHERE id = 750 81 | *** (1) WAITING FOR THIS LOCK TO BE GRANTED: 82 | RECORD LOCKS space id 6 page no 5 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 3846 lock_mode X locks rec but not gap waiting 83 | Record lock, heap no 475 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 84 | 0: len 4; hex 800002ee; asc ;; 85 | 1: len 6; hex 000000000f07; asc ;; 86 | 2: len 7; hex 07000001400110; asc @ ;; 87 | 3: len 4; hex 80000378; asc x;; 88 | 89 | *** (2) TRANSACTION: 90 | TRANSACTION 3847, ACTIVE 11 sec starting index read 91 | mysql tables in use 1, locked 1 92 | 3 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 1 93 | MySQL thread id 7, OS thread handle 0x7f2eddd75700, query id 35 localhost root updating 94 | UPDATE t1 SET number = 8888 WHERE id = 30 95 | *** (2) HOLDS THE LOCK(S): 96 | RECORD LOCKS space id 6 page no 5 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 3847 lock_mode X locks rec but not gap 97 | Record lock, heap no 475 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 98 | 0: len 4; hex 800002ee; asc ;; 99 | 1: len 6; hex 000000000f07; asc ;; 100 | 2: len 7; hex 07000001400110; asc @ ;; 101 | 3: len 4; hex 80000378; asc x;; 102 | 103 | *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 104 | RECORD LOCKS space id 6 page no 4 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 3847 lock_mode X locks rec but not gap waiting 105 | Record lock, heap no 31 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 106 | 0: len 4; hex 8000001e; asc ;; 107 | 1: len 6; hex 000000000f06; asc ;; 108 | 2: len 7; hex 060000013f0110; asc ? ;; 109 | 3: len 4; hex 80000309; asc ;; 110 | 111 | *** WE ROLL BACK TRANSACTION (2) 112 | ``` 113 | 114 | 実際にデッドロックが発生した`(1) TRANSACTION`と`(2) TRANSACTION`のクエリ情報が出力されており、トランザクション1のスレッド情報は`MySQL thread id 2`, トランザクション2は`MySQL thread id 7`である。また、`WE ROLL BACK TRANSACTION (2)`とあるように、ロールバックされたのはトランザクション2なので、少なくともbinlogにはトランザクション2(thread_id=7)のログは残らない。トランザクション1がCOMMITされていればbinlogに残るため、それを期待して`thread_id=2`かつデッドロック発生時刻`2018-03-23 19:09:38`前後のトランザクションログをbinlog内で検索する。 115 | 116 | binlog内のトランザクションログ。 117 | ``` 118 | # at 120 119 | #180323 19:09:20 server id 1004003039 end_log_pos 199 CRC32 0xbcd78d4c Query thread_id=2 exec_time=0 error_code=0 120 | SET TIMESTAMP=1521799760/*!*/; 121 | SET @@session.pseudo_thread_id=2/*!*/; 122 | SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/; 123 | SET @@session.sql_mode=1575485472/*!*/; 124 | SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/; 125 | /*!\C latin1 *//*!*/; 126 | SET @@session.character_set_client=8,@@session.collation_connection=8,@@session.collation_server=45/*!*/; 127 | SET @@session.lc_time_names=0/*!*/; 128 | SET @@session.collation_database=DEFAULT/*!*/; 129 | BEGIN 130 | /*!*/; 131 | # at 199 132 | #180323 19:09:20 server id 1004003039 end_log_pos 313 CRC32 0x45491d66 Query thread_id=2 exec_time=0 error_code=0 133 | use `test`/*!*/; 134 | SET TIMESTAMP=1521799760/*!*/; 135 | UPDATE t1 SET number = 777 WHERE id = 30 136 | /*!*/; 137 | # at 313 138 | #180323 19:09:33 server id 1004003039 end_log_pos 429 CRC32 0xc7f885c6 Query thread_id=2 exec_time=5 error_code=0 139 | SET TIMESTAMP=1521799773/*!*/; 140 | UPDATE t1 SET number = 7777 WHERE id = 750 141 | /*!*/; 142 | # at 429 143 | #180323 19:09:50 server id 1004003039 end_log_pos 460 CRC32 0x16dd33eb Xid = 28 144 | COMMIT/*!*/; 145 | DELIMITER ; 146 | # End of log file 147 | ROLLBACK /* added by mysqlbinlog */; 148 | /*!50003 SET COMPLETION_TYPE=@OLD_COMPLETION_TYPE*/; 149 | /*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=0*/; 150 | ``` 151 | 152 | `show engine innodb status\G`の`LATEST DETECTED DEADLOCK`ではトランザクション1で`UPDATE t1 SET number = 7777 WHERE id = 750`が実行されたログしかなかったが、binlogからはそのクエリより前に実行された`UPDATE t1 SET number = 777 WHERE id = 30`がログに残されている。 153 | 154 | ### thread-idを使った調査前 155 | ![tx-before](images/innodb-deadlock-thread-id-01.png) 156 | 157 | ### thread-idを使った調査後 158 | ![tx-after](images/innodb-deadlock-thread-id-02.png) 159 | 160 | 以上より、トランザクション2側のロック待ち①はトランザクション1側の`UPDATE t1 SET number = 777 WHERE id = 30`が直接的な原因である、という仮説が立てられるようになる。失敗したトランザクション2はbinlogに残らないため、トランザクション1側のロック待ち②については不明瞭なままである。 161 | また、binlogには成功したトランザクション処理、さらには更新系クエリのログしか記録されない。そのため、次のような参照の明示的なロック獲得はthread_idを利用した解析方法では追うことが出来ない。 162 | 163 | ```sql 164 | TA> BEGIN; 165 | TB> BEGIN; 166 | TA> SELECT * FROM t1 WHERE id = 1 FOR UPDATE; 167 | TB> SELECT * FROM t1 WHERE id = 300 FOR UPDATE; 168 | TA> SELECT * FROM t1 WHERE id = 300 FOR UPDATE; // 待たされる 169 | TB> SELECT * FROM t1 WHERE id = 1 FOR UPDATE; // deadlock 170 | TA> COMMIT; 171 | ``` 172 | 173 | ## binlog_format=ROWで検証 174 | 環境 175 | - MySQL-5.6.37 176 | - tx_isolation: REPETABLE-READ 177 | - binlog_format = ROW 178 | 179 | ```sql 180 | TA> BEGIN; 181 | TB> BEGIN; 182 | TA> UPDATE t1 SET number = 999 WHERE id = 1; 183 | TB> UPDATE t1 SET number = 222 WHERE id = 500; 184 | TA> UPDATE t1 SET number = 9999 WHERE id = 500; // 待たされる 185 | TB> UPDATE t1 SET number = 2222 WHERE id = 1; // deadlock 186 | TA> COMMIT; 187 | ``` 188 | 189 | 続いて`binlog_format=ROW`でも同様に解析する。 190 | 191 | ``` 192 | ------------------------ 193 | LATEST DETECTED DEADLOCK 194 | ------------------------ 195 | 2018-03-23 22:28:17 7f602aec6700 196 | *** (1) TRANSACTION: 197 | TRANSACTION 4361, ACTIVE 17 sec starting index read 198 | mysql tables in use 1, locked 1 199 | LOCK WAIT 3 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 1 200 | MySQL thread id 2, OS thread handle 0x7f602af07700, query id 37 localhost root updating 201 | UPDATE t1 SET number = 9999 WHERE id = 500 202 | *** (1) WAITING FOR THIS LOCK TO BE GRANTED: 203 | RECORD LOCKS space id 6 page no 5 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 4361 lock_mode X locks rec but not gap waiting 204 | Record lock, heap no 225 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 205 | 0: len 4; hex 800001f4; asc ;; 206 | 1: len 6; hex 00000000110a; asc ;; 207 | 2: len 7; hex 09000001420110; asc B ;; 208 | 3: len 4; hex 800000de; asc ;; 209 | 210 | *** (2) TRANSACTION: 211 | TRANSACTION 4362, ACTIVE 12 sec starting index read 212 | mysql tables in use 1, locked 1 213 | 3 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 1 214 | MySQL thread id 3, OS thread handle 0x7f602aec6700, query id 38 localhost root updating 215 | UPDATE t1 SET number = 2222 WHERE id = 1 216 | *** (2) HOLDS THE LOCK(S): 217 | RECORD LOCKS space id 6 page no 5 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 4362 lock_mode X locks rec but not gap 218 | Record lock, heap no 225 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 219 | 0: len 4; hex 800001f4; asc ;; 220 | 1: len 6; hex 00000000110a; asc ;; 221 | 2: len 7; hex 09000001420110; asc B ;; 222 | 3: len 4; hex 800000de; asc ;; 223 | 224 | *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 225 | RECORD LOCKS space id 6 page no 4 n bits 624 index `PRIMARY` of table `test`.`t1` trx id 4362 lock_mode X locks rec but not gap waiting 226 | Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 227 | 0: len 4; hex 80000001; asc ;; 228 | 1: len 6; hex 000000001109; asc ;; 229 | 2: len 7; hex 08000001410110; asc A ;; 230 | 3: len 4; hex 800003e7; asc ;; 231 | 232 | *** WE ROLL BACK TRANSACTION (2) 233 | ``` 234 | 235 | `binlog_format=ROW`の場合は、更新クエリに対するmysqlbinlogコマンドでオプションを指定(--verbose)すればbase64でエンコードされた差分内容の擬似SQLを出力するのでそれを利用する。`WE ROLL BACK TRANSACTION (2)`とあるので、トランザクション1の`thread_id=2`とデッドロック時刻`2018-03-23 22:28:17`周辺のbinlog内を検索する。 236 | 237 | ``` 238 | # at 120 239 | #180323 22:28:00 server id 1004003039 end_log_pos 192 CRC32 0x7eb7cd6c Query thread_id=2 exec_time=0 error_code=0 240 | SET TIMESTAMP=1521811680/*!*/; 241 | SET @@session.pseudo_thread_id=2/*!*/; 242 | SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/; 243 | SET @@session.sql_mode=1575485472/*!*/; 244 | SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/; 245 | /*!\C latin1 *//*!*/; 246 | SET @@session.character_set_client=8,@@session.collation_connection=8,@@session.collation_server=45/*!*/; 247 | SET @@session.lc_time_names=0/*!*/; 248 | SET @@session.collation_database=DEFAULT/*!*/; 249 | BEGIN 250 | /*!*/; 251 | # at 192 252 | #180323 22:28:00 server id 1004003039 end_log_pos 238 CRC32 0x850ffe6c Table_map: `test`.`t1` mapped to number 70 253 | # at 238 254 | #180323 22:28:00 server id 1004003039 end_log_pos 292 CRC32 0x24cf6e02 Update_rows: table id 70 flags: STMT_END_F 255 | 256 | BINLOG ' 257 | 4AC1WhPf3tc7LgAAAO4AAAAAAEYAAAAAAAEABHRlc3QAAnQxAAIDAwACbP4PhQ== 258 | 4AC1Wh/f3tc7NgAAACQBAAAAAEYAAAAAAAEAAgAC///8AQAAAAEAAAD8AQAAAOcDAAACbs8k 259 | '/*!*/; 260 | ### UPDATE `test`.`t1` 261 | ### WHERE 262 | ### @1=1 /* INT meta=0 nullable=0 is_null=0 */ 263 | ### @2=1 /* INT meta=0 nullable=1 is_null=0 */ 264 | ### SET 265 | ### @1=1 /* INT meta=0 nullable=0 is_null=0 */ 266 | ### @2=999 /* INT meta=0 nullable=1 is_null=0 */ 267 | # at 292 268 | #180323 22:28:11 server id 1004003039 end_log_pos 338 CRC32 0xf1e2f726 Table_map: `test`.`t1` mapped to number 70 269 | # at 338 270 | #180323 22:28:11 server id 1004003039 end_log_pos 392 CRC32 0x7d577b97 Update_rows: table id 70 flags: STMT_END_F 271 | 272 | BINLOG ' 273 | 6wC1WhPf3tc7LgAAAFIBAAAAAEYAAAAAAAEABHRlc3QAAnQxAAIDAwACJvfi8Q== 274 | 6wC1Wh/f3tc7NgAAAIgBAAAAAEYAAAAAAAEAAgAC///89AEAAPQBAAD89AEAAA8nAACXe1d9 275 | '/*!*/; 276 | ### UPDATE `test`.`t1` 277 | ### WHERE 278 | ### @1=500 /* INT meta=0 nullable=0 is_null=0 */ 279 | ### @2=500 /* INT meta=0 nullable=1 is_null=0 */ 280 | ### SET 281 | ### @1=500 /* INT meta=0 nullable=0 is_null=0 */ 282 | ### @2=9999 /* INT meta=0 nullable=1 is_null=0 */ 283 | # at 392 284 | #180323 22:28:28 server id 1004003039 end_log_pos 423 CRC32 0x06cb62d3 Xid = 30 285 | COMMIT/*!*/; 286 | DELIMITER ; 287 | # End of log file 288 | ``` 289 | 290 | `binlog_format=STATEMENT`と少し異なるがthread_idはトランザクション開始地点(at 120)に記録されている。直近のCOMMITまでのログに異なるthread_idの差分内容が突然混ざることはないはず(要出典)なので、解析は可能である。 291 | ただし、先に挙げたSELECT-FOR-UPDATEによる明示的なロックによるデッドロックと、変更差分がないような更新クエリは`binlog_format=ROW`ではログに出力されない。特に後者はROWのみに発生するため、注意が必要である。 292 | また、これはMySQLのバグだが、`show engine innodb status\G`で表示されたthread_idが32bit符号なし整数の範囲を超えている場合、binlogに記録されるthread_idはunsigned int型のため、オーバーフローした値として記録されてしまっている。そのときは`show engine innodb status\G`で確認したthread_id値をunsigned int型に変換した値を検索すれば発見出来る。 293 | 294 | ## 参考資料 295 | - Percona Database Performance Blog (2014) - [How to deal with MySQL deadlocks](https://www.percona.com/blog/2014/10/28/how-to-deal-with-mysql-deadlocks/) -------------------------------------------------------------------------------- /innodb.md: -------------------------------------------------------------------------------- 1 | # MySQLのInnoDBのロック挙動調査 2 | 3 | ## 目次 4 | * [トランザクション分離レベル](#トランザクション分離レベル) 5 | * [インデックスの構造](#インデックスの構造) 6 | * [ロックの種類](#ロックの種類) 7 | * [行ロックについて](#行ロックについて) 8 | * [シャドーロックについて](#シャドーロックについて) 9 | * [Index Condition Pushdown(MySQL-5.6)](#index-condition-pushdown) 10 | * [あるあるロック問題](#あるあるロック問題) 11 | * [パーティション下のロックの挙動](#パーティション下のロックの挙動) 12 | 13 | ##### 他の資料 14 | [外部キー制約に伴うロックの小話](http://www.slideshare.net/ichirin2501/ss-44642631) 15 | 16 | ## はじめに 17 | RDBSのMySQL(InnoDBストレージエンジン)を利用するうえで、高速かつ安全なプログラムを書くために 18 | ロックの挙動を詳しく知る必要があります。この文章はそのために調査したメモ書きです。 19 | また、掲載されている内容が正しいとは限りません、真実は自らの手で摑み取ってください。 20 | ロックの挙動についてはmysql-5.5.27,**REPEATABLE READ** で調べたものです。 21 | Index Condition Pushdownについてはmysql-5.6.24で調べたものになります。 22 | READ COMMITEDは気が向いたら書きます。 23 | 24 | 25 | ## トランザクション分離レベル 26 | ACID特性のうちIsolationに関する概念。以下はmysql特有のものではなく、ANSI/ISO SQL標準で定められている。 27 | InnoDBはいずれのトランザクション分離レベルもサポートしている。 28 | 29 | * READ UNCOMMITTED 30 | * READ COMMITTED 31 | * REPEATABLE READ 32 | * SERIALIZABLE 33 | 34 | 35 | ACID特性とは以下のことである。 36 | 37 | 1. Atomicity: タスクが全て実行されるか、あるいは全く実行されないことを保証する性質 38 | 1. Consistency: 整合性を満たすことを保証する性質 39 | 1. Isolation: 操作の過程が他の操作から隠蔽される 40 | 1. Durability: 完了通知をユーザが受けた時点でその操作は永続的となり結果が失われないこと 41 | 42 | それぞれの分離レベルはトランザクション処理の影響度合いが異なる。 43 | ANSI/ISO SQLでは以下のように定義されている。 44 | 45 | | 分離レベル | ダーティリード | ファジーリード | ファントムリード | 46 | |:----------:|:-----------:|:------------:|:----:| 47 | | READ UNCOMMITTED | あり | あり | あり | 48 | | READ COMMITTED | なし | あり | あり | 49 | | REPEATABLE READ | なし | なし | **あり** | 50 | | SERIALIZABLE | なし | なし | なし | なし | 51 | 52 | あくまで仕様() 53 | 各ストレージエンジンで実装・動作が異なる。 54 | **InnoDBのREPEATABLE READはファントムリード現象も防ぐ実装** になっている。 55 | 表には載せてないがロストアップデートはSERIALIZABLE以外全てに起こりうる。 56 | 57 | * ダーティリード: 未コミットのトランザクションの更新を別トランザクションが読み取る現象 58 | * ファジーリード: トランザクション内で一度読み取ったデータを再度読み取るときに、コミット済みの別トランザクション(更新or削除)によって結果が変わる現象 59 | * ファントムリード: トランザクション内で一度読み取ったデータを再度読み取るときに、コミット済みの別トランザクション(挿入)によって結果が変わる現象 60 | * ロストアップデート: 後続トランザクションの更新で先行していたトランザクションの更新内容を失う現象 61 | 62 | 詳細なトランザクション分離レベルやトランザクション自体の話はkumagi先生の資料が良い 63 | - [Isolation Levelの階層](https://qiita.com/kumagi/items/1dc1a91ec007365ac694) 64 | - [一人トランザクション技術 Advent Calendar 2016](https://qiita.com/advent-calendar/2016/transaction) 65 | 66 | 67 | ## インデックスの構造 68 | 69 | * [知って得するInnoDBセカンダリインデックス活用術! - 漢のコンピュータ道](http://nippondanji.blogspot.jp/2010/10/innodb.html) 70 | * [INDEX FULL SCANを狙う - MySQL Casual Advent Calendar 2011 - SH2の日記](http://d.hatena.ne.jp/sh2/20111217) 71 | 72 | 大変参考になります。この辺りを読んでおけば十分でしょう。 73 | primary-keyがクラスタインデックス、それ以外のindexはセカンダリインデックスで押さえておけばよいです。 74 | 75 | 76 | ## ロックの種類 77 | テーブルロックと行ロック! 78 | テーブルロックについては詳しく調べてないです() 79 | 80 | それから基本は以下の2つのモードです 81 | 82 | * 共有ロック(S-Lock) 83 | SELECT ~ LOCK IN SHARE MODE, INSERT失敗時(DUPLICATE) 84 | * 排他的ロック(X-Lock) 85 | INSERT文, UPDATE文, DELETE文, SELECT ~ FOR UPDATE; 86 | 87 | ろっくまとりーっくす 88 | 89 | | | X | S | 90 | |:-:|:-:|:-:| 91 | | X | Conflict | Conflict | 92 | | S | Conflict | Compatible | 93 | 94 | 共有ロック同士はブロックが発生せずにレコードを読み取ることができる。 95 | 本来なら他にもロックの種類があるが割愛。 96 | ロックを獲得してレコードを読むとき、必ず最新のデータを読み取る。 97 | InnoDBにはMVCC(Multi Version Concurrency Control)システムが実装されており、 98 | この機能でダーティリード現象とファジーリード現象を防いでいる。 99 | これを利用してREPEATABLE-READでは、 100 | トランザクションを開始して最初のクエリを発行したタイミングのデータベースのスナップショットを取り、 101 | 以降の読み取りにおいてはそのときの状態を返すが、ロックを獲得する読み取りにおいてはそうではないことに注意 102 | その辺りの話については、以下の記事の「Locking Read - 問題点:100% 一貫性読み取りではない」の項目でも触れられている。 103 | * [InnoDBのREPEATABLE READにおけるLocking Readについての注意点](http://nippondanji.blogspot.jp/2013/12/innodbrepeatable-readlocking-read.html) 104 | 105 | ```sql 106 | create table t1 (id int auto_increment, number int, primary key(id)); 107 | mysql> select * from t1; 108 | +----+--------+ 109 | | id | number | 110 | +----+--------+ 111 | | 1 | 1 | 112 | | 2 | 5 | 113 | | 3 | 5 | 114 | +----+--------+ 115 | 116 | TA> begin; 117 | TB> begin; 118 | TA> select * from t1 where id = 2; 119 | +----+--------+ 120 | | id | number | 121 | +----+--------+ 122 | | 2 | 5 | 123 | +----+--------+ 124 | TB> update t1 set number = 10 where id = 2; 125 | TB> commit; 126 | TA> select * from t1 where id = 2 for update; 127 | +----+--------+ 128 | | id | number | 129 | +----+--------+ 130 | | 2 | 10 | 131 | +----+--------+ 132 | TA> select * from t1 where id = 2; 133 | +----+--------+ 134 | | id | number | 135 | +----+--------+ 136 | | 2 | 5 | 137 | +----+--------+ 138 | ``` 139 | 140 | ### ALTER文 141 | 運営していると避けられないALTER文 142 | 143 | * [ALTER TABLEを上手に使いこなそう。 - 漢のコンピュータ道](http://nippondanji.blogspot.jp/2009/05/alter-table.html) 144 | * [開発スピードアクセル全開ぶっちぎり!日本よ、これがMySQL 5.6だッ!! - 漢のコンピュータ道](http://nippondanji.blogspot.jp/2012/10/mysql-56.html) 145 | 146 | MySQL5.5以下ならこんな感じ、たぶん。 147 | 148 | * テーブルを共有ロック 149 | * テーブルを全コピーするので一時的に容量が2倍 150 | * ALTER文の回数だけテーブル全コピー、同テーブルへの変更点は1行にまとめる 151 | * ALTERは暗黙のコミットを引き起こすステートメントの一つ 152 | 153 | 手動でALTER文を含めた他のSQL作業が入るときに、ALTER文は暗黙的にコミットされることに気をつける。 154 | 155 | ```text 156 | > begin; 157 | > update t1 set number = 10 where id = 20; 158 | > alter table t2 ~; # この時点でalter実行前に暗黙commitが走るので t1に対するupdateもcommitされる 159 | > rollback; # alterの内容は勿論、t1へのupdateもrollbackされない 160 | ``` 161 | 162 | ちなみにMySQL-5.6からはALTER TABLE実行中でもテーブルの更新が出来るオンラインDDL機能が追加された、便利。 163 | オンラインDDLとして実行出来るALTER文には条件があり、それは[こちらの公式ドキュメント](https://dev.mysql.com/doc/refman/5.6/ja/innodb-create-index-overview.html)を読めば分かるようになっている。 164 | 覚えたり毎回確認するのは面倒なので、LOCK=NONEを付けて試すことによってオンラインDDLかどうかを簡単に確かめることが出来る。以下のyoku0825先生の記事にまとまっている。 165 | * [そのALTER TABLEがオンラインALTER TABLEかどうかを確かめる方法](https://yoku0825.blogspot.jp/2016/06/alter-tablealter-table.html) 166 | 167 | 168 | ## 行ロックについて 169 | 170 | * レコードロック : インデックスレコードのロック 171 | * ギャップロック : インデックスレコード間にあるギャップのロック、先頭のインデックスレコードの前や末尾のインデックスレコードのあとにあるギャップのロック、のいずれか 172 | * ネクストキーロック : インデックスレコードに対するレコードロックと、そのインデックスレコードの前にあるギャップに対するギャップロックとを組み合わせ 173 | 174 | インデックスレコードとは、クラスタインデックスとセカンダリインデックスのこと。 175 | 'レコードロック'ではなく、 **"インデックスレコードロック"** だということが重要です。 176 | 例えば、 177 | ```sql 178 | create table t1 (id int auto_increment, number int, hoge int, primary key(id), index(number)); 179 | mysql> select * from t1; 180 | +----+--------+------+ 181 | | id | number | hoge | 182 | +----+--------+------+ 183 | | 1 | 1 | 1 | 184 | | 2 | 5 | 2 | 185 | | 3 | 5 | 3 | 186 | | 4 | 10 | 4 | 187 | | 5 | 50 | 5 | 188 | | 6 | 51 | 6 | 189 | | 7 | 100 | 7 | 190 | +----+--------+------+ 191 | 192 | mysql> begin; 193 | mysql> select * from t1 where number = 5 and hoge = 2 for update; 194 | +----+--------+------+ 195 | | id | number | hoge | 196 | +----+--------+------+ 197 | | 2 | 5 | 2 | 198 | +----+--------+------+ 199 | 1 rows in set (0.00 sec) 200 | ``` 201 | 202 | 検索結果は1行ですが、このとき(number,hoge)=(5,3)の行もロック(+前後のギャップ)される。 203 | 204 | 1. オプティマイザが選択したインデックスで検索されたレコードをロックする 205 | 1. ロック獲得後、それらのうち条件にマッチする行を検索結果として返す 206 | 207 | インデックスレコードロックなので**検索結果の行と実際にロックがかかる行は必ずしも等しいわけではない** 208 | 209 | ギャップロックに対する認識は、とりあえず、 210 | 211 | 1. 他TXが獲得したギャップロック範囲に対するINSERT文は全てブロックされる 212 | 2. ギャップロックはギャップロックをブロックしない(排他的ロックだけど...) 213 | 214 | と覚えておけば良いでしょう。(あとで解説します) 215 | 216 | それから、ある行をロックしようとするとき、**インデックスの種類(pkey,ukey,key)でロック範囲が異なります** 217 | 218 | * [InnoDBのロックの範囲とネクストキーロックの話](http://blog.kamipo.net/entry/2013/12/03/235900) 219 | * [InnoDBで行ロック/テーブルロックになる条件を調べた #mysqlcasual Advent Calendar 2013](http://bluerabbit.hatenablog.com/entry/2013/12/07/075759) 220 | * [MySQLのロックについて](http://dbstudy.info/files/20140907/mysql_lock_r2.pdf) 221 | 222 | 「MySQLのロックについて」でインデックスの違いからロックの挙動を図で分かり易く説明されている。 223 | 是非読むべき資料である。蛇足だが過去に書いた文章は残しておく(下記) 224 | 225 | ### 非indexのとき 226 | テーブルロックと効果が等しい 227 | 実際は全ての行に対してロックをかけているだけであり、InnoDBはロックエスカレーションしない 228 | ```text 229 | create table t1 (id int auto_increment, number int, hoge int, primary key(id), index(number)); 230 | SELECT * FROM t1 WHERE hoge = 4 FOR UPDATE; 231 | # t1テーブルの全ての行とギャップにロックがかかります。 232 | ``` 233 | 234 | ### 通常indexのとき 235 | 対象の行と、その周辺にギャップロックがかかります 236 | ```text 237 | create table t3 (id int auto_increment, number int, primary key(id), index(number)); 238 | mysql> select * from t3; 239 | +----+--------+ 240 | | id | number | 241 | +----+--------+ 242 | | 1 | 1 | 243 | | 2 | 5 | 244 | | 3 | 5 | 245 | | 4 | 10 | 246 | | 5 | 50 | 247 | | 6 | 51 | 248 | | 7 | 100 | 249 | +----+--------+ 250 | 251 | # パターン1: 5の手前にinsertしようとしたとき 252 | TA> begin; 253 | TB> begin; 254 | TA> select * from t3 where number = 5 for update; 255 | TB> insert into t3 (number) values(2); # 待たされる 256 | 257 | # パターン2: 5の後にinsertしようとしたとき 258 | TA> begin; 259 | TB> begin; 260 | TA> select * from t3 where number = 5 for update; 261 | TB> insert into t3 (number) values(6); # 待たされる 262 | 263 | number=5 for updateで、number=5の存在する行のロックと、 264 | number = [1,5),[5,10) 265 | までがギャップロックされるので上記のinsertが待たされることになります 266 | 267 | 268 | 面白いことに処理順序を逆転すると成功する 269 | TA> insert into t3 (number) values(6); 270 | TB> select * from t3 where number = 5 for update; # 待たされない 271 | insert->selectの処理順だと、insert後はnumber[5,10)までのgapを (LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION) の状態にする 272 | selectの範囲は5の前後のgapに対してもLOCK_XになるがLOCK_INSERT_INTENTIONフラグが含まれてるため、 273 | 待たされることはない。最初のselect->insertの処理順だと、selectで5の前後のgapに対してもLOCK_Xになる。 274 | insert時に挿入先のgapとロック状態が衝突してLOCK_INSERT_INTENTIONフラグがなく、かつ、LOCK_GAPがセットされてる場合は待つことになる。 275 | 276 | gap-lock magic! 277 | 278 | 279 | 通常のindexは常にギャップロックがセットになるので、末端に注意が必要です 280 | TA> BEGIN; 281 | TB> BEGIN; 282 | TA> SELECT * FROM t3 WHERE number = 100 FOR UPDATE; 283 | TB> INSERT INTO t3 (number) VALUES(300); # 待たされる 284 | ギャップロック範囲(number) = [51,100),[100,positive infinity) 285 | 286 | ``` 287 | 複合indexのギャップロックの範囲は昇順ソートしたときのギャップの範囲になります。 288 | ```text 289 | CREATE TABLE `benio` ( 290 | `id` int(11) NOT NULL AUTO_INCREMENT, 291 | `a` int(11) NOT NULL, 292 | `b` int(11) NOT NULL, 293 | `c` int(11) NOT NULL, 294 | PRIMARY KEY (`id`), 295 | KEY `a` (`a`,`b`,`c`) 296 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 297 | 298 | +----+----+-----+----+ 299 | | id | a | b | c | 300 | +----+----+-----+----+ 301 | | 1 | 1 | 1 | 10 | 302 | | 2 | 1 | 1 | 20 | 303 | | 3 | 1 | 100 | 10 | 304 | | 4 | 10 | 50 | 15 | 305 | | 5 | 20 | 10 | 30 | 306 | +----+----+-----+----+ 307 | 308 | SELECT * FROM benio WHERE a = 1 AND b = 1 AND c = 20 FOR UPDATE; 309 | このときのギャップロックの範囲は以下のようになります 310 | (a,b,c) = [ [1,1,10],[1,1,20] ) , [ [1,1,20],[1,100,10] ) 311 | 312 | INSERT INTO benio (a,b,c) VALUES(1,1,9); # 待たされない 313 | INSERT INTO benio (a,b,c) VALUES(1,1,10); # 待たされる 314 | INSERT INTO benio (a,b,c) VALUES(1,1,21); # 待たされる 315 | INSERT INTO benio (a,b,c) VALUES(1,10,10); # 待たされる 316 | INSERT INTO benio (a,b,c) VALUES(1,100,9); # 待たされる 317 | INSERT INTO benio (a,b,c) VALUES(1,100,11); # 待たされない 318 | 319 | 要素を一つ減らした場合は単純に範囲が広がるだけです 320 | SELECT * FROM benio WHERE a = 10 AND b = 50 FOR UPDATE; 321 | ギャップロック範囲(a,b,c) = [ [1,100,10],[10,50] ) , [ [10,50], [20,10,30] ) 322 | 323 | left-most index! 324 | ``` 325 | 326 | ### primary-key, unique-keyのとき 327 | pkeyとukeyの挙動は等しく、基本的にはギャップロックが発生しない 328 | ```text 329 | CREATE TABLE `kobeni` ( 330 | `id` int(11) NOT NULL, 331 | `number` int(11) DEFAULT NULL, 332 | PRIMARY KEY (`id`), 333 | KEY `number` (`number`) 334 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 335 | 336 | +-----+--------+ 337 | | id | number | 338 | +-----+--------+ 339 | | 1 | 1 | 340 | | 5 | 5 | 341 | | 10 | 10 | 342 | +-----+--------+ 343 | 344 | TA> BEGIN; 345 | TB> BEGIN; 346 | TC> BEGIN; 347 | 348 | TA> SELECT * FROM kobeni WHERE id = 5 FOR UPDATE; # idはprimary-key 349 | TB> INSERT INTO kobeni (id,number) VALUES(4,4); # 待たされない 350 | TC> INSERT INTO kobeni (id,number) VALUES(6,6); # 待たされない 351 | 352 | # pkeyやukeyは一意に定まる。存在する行であれば対象行のみをロックし、ギャップロックは発生しない 353 | ``` 354 | 355 | **複合の場合は少し注意が必要** 356 | ```text 357 | player_quest_nonauto` ( 358 | `id` bigint(20) unsigned NOT NULL, 359 | `player_id` bigint(20) unsigned NOT NULL, 360 | `quest_id` smallint(5) unsigned NOT NULL, 361 | PRIMARY KEY (`id`,`quest_id`), 362 | UNIQUE KEY `player_quest_idx` (`player_id`,`quest_id`) 363 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 364 | 365 | +----+-----------+----------+ 366 | | id | player_id | quest_id | 367 | +----+-----------+----------+ 368 | | 1 | 1 | 1000 | 369 | | 2 | 2 | 1000 | 370 | | 3 | 3 | 1000 | 371 | | 6 | 5 | 1020 | 372 | | 27 | 8 | 1020 | 373 | | 4 | 10 | 2000 | 374 | | 10 | 20 | 900 | 375 | | 11 | 20 | 1100 | 376 | | 12 | 20 | 1200 | 377 | | 13 | 30 | 2001 | 378 | | 18 | 50 | 1010 | 379 | +----+-----------+----------+ 380 | 381 | > SELECT * FROM player_quest_nonauto WHERE id = 18 AND quest_id = 1010 FOR UPDATE; 382 | 複合pkeyの要素を正しく指定していれば単一カラムのpkey(ukeyも同様)と挙動は同じになります 383 | 384 | しかし、 385 | > SELECT * FROM player_quest_nonauto WHERE id = 18 FOR UPDATE; 386 | 複合要素が欠けてしまうと、ロックの挙動が変化します 387 | このとき、複合pkey(id,quest_id)なのでidのみでもインデックスは効きますが、ロックの挙動は通常indexと等しくなります 388 | つまり、id=18の行ロックと、 389 | (id,quest_id) = [ [13,2002] , [18,1010] ), [ [18,1011] ~ [27,1020] ) の範囲に対してギャップロックがかかります 390 | ``` 391 | 392 | ### ギャップロックの罠 393 | >ギャップロックはギャップロックをブロックしない(排他的ロックだけど...) 394 | 395 | 良い資料 396 | * [MySQL InnoDBのinsertとlockの話](http://tech.voyagegroup.com/archives/8085782.html) 397 | 398 | 399 | 例を見たほうが早いですね 400 | ```text 401 | CREATE TABLE `t4` ( 402 | `id` int(11) NOT NULL DEFAULT '0', 403 | `number` int(11) DEFAULT NULL, 404 | PRIMARY KEY (`id`), 405 | KEY `number` (`number`) 406 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 407 | 408 | +----+--------+ 409 | | id | number | 410 | +----+--------+ 411 | | 1 | 1 | 412 | | 15 | 5 | 413 | | 17 | 8 | 414 | | 20 | 10 | 415 | | 26 | 55 | 416 | | 27 | 60 | 417 | | 28 | 65 | 418 | | 29 | 70 | 419 | +----+--------+ 420 | 421 | TA> BEGIN; 422 | TB> BEGIN; 423 | TA> SELECT * FROM t4 WHERE id = 22 FOR UPDATE; 424 | Empty set (0.01 sec) 425 | TB> SELECT * FROM t4 WHERE id = 25 FOR UPDATE; # 待たされない 426 | Empty set (0.01 sec) 427 | 428 | 空打ちになっても、そのギャップに対してロックを獲得している状態になります 429 | この時点で、TA,TBともに 範囲id=[21,26)に対してギャップロックを獲得しています 430 | 続いて、INSERTとしようとするとデッドロックになります 431 | 432 | TA> INSERT INTO t4 (id,number) VALUES(22,100); # 待たされる!! 433 | TB> INSERT INTO t4 (id,number) VALUES(25,200); 434 | ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 435 | 436 | TB側がデッドロックエラーとなり、TA側のINSERTが成功します 437 | ``` 438 | INSERTしようと思ってる範囲に自TXがギャップロックを獲得しても、他TXも同じ範囲でギャップロック獲得可能です。 439 | 他TXがギャップロックした範囲に対してINSERT文はブロックされるため、このようなことになります。 440 | **行があったらFOR-UPDATEで排他的ロック、なかったらINSERTする**ような処理を書くときは注意が必要です。 441 | REPEATABLE-READでのベストな解決方法は自分の中で見出せていない。(現在の見解については後述 442 | 空打ちロックに限らず、通常indexは常にギャップロックを伴うので思わぬところで、 443 | デッドロック・パフォーマンス悪化に繋がる可能性があります。 444 | 445 | ### ネクストキーロック 446 | InnoDB-REPEATABLEREADに存在するロックの種類`ギャップロック+レコードロック = ネクストキーロック` 447 | MVCCと合わせてファントムリードを防ぐ仕組みである. 448 | InnoDB-REPEATABLEREADの範囲ロック(between,<,etc)は広めに取られる。以下が分かり易い 449 | [MySQL InnoDBのネクストキーロック おさらい - SH2日記](http://d.hatena.ne.jp/sh2/20090112) 450 | 451 | 452 | ### INSERT ON DUPLICATE KEY UPDATEの挙動に注意 453 | 454 | ```text 455 | CREATE TABLE `t4` ( 456 | `id` int(11) NOT NULL DEFAULT '0', 457 | `number` int(11) DEFAULT NULL, 458 | PRIMARY KEY (`id`), 459 | KEY `number` (`number`) 460 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 461 | 462 | TA> SELECT * FROM t4; 463 | Empty set (0.00 sec) 464 | 465 | TA> BEGIN; 466 | TB> BEGIN; 467 | TA> INSERT INTO t4 (id,number) values(1,100) ON DUPLICATE KEY UPDATE num = 300; # insert成功 468 | TB> INSERT INTO t4 (id,number) values(1,100) ON DUPLICATE KEY UPDATE num = 300; # 待たされる 469 | TA> COMMIT; 470 | TB> # ロック獲得, num = 300でupdateされる 471 | TB> COMMIT; 472 | 473 | TA> SELECT * FROM t4; 474 | +----+--------+ 475 | | id | number | 476 | +----+--------+ 477 | | 1 | 300 | 478 | +----+--------+ 479 | ``` 480 | どちらのトランザクションも開始時点では行が存在しない状態だったが、 481 | いずれかのトランザクションでinsertされた後、続く別のトランザクションではupdate処理になる 482 | 483 | ## シャドーロックについて 484 | 注意:自分が勝手に呼んでる現象 485 | 待たされているクエリも部分的にロック獲得している現象のことをここでは便宜上シャドーロックとする 486 | ```text 487 | > SELECT * FROM t4; 488 | +----+--------+ 489 | | id | number | 490 | +----+--------+ 491 | | 1 | 1 | 492 | | 15 | 5 | 493 | | 17 | 8 | 494 | | 20 | 10 | 495 | | 26 | 55 | 496 | | 27 | 60 | 497 | | 28 | 65 | 498 | | 29 | 70 | 499 | | 30 | 100 | 500 | .... 501 | 502 | > SELECT COUNT(*) FROM t4; 503 | +----------+ 504 | | count(*) | 505 | +----------+ 506 | | 160 | 507 | +----------+ 508 | 509 | TA> BEGIN; 510 | TB> BEGIN; 511 | TC> BEGIN; 512 | TA> SELECT * FROM t4 WHERE id = 28 FOR UPDATE; 513 | TB> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) FOR UPDATE; # 待たされる 514 | TC> SELECT * FROM t4 WHERE id = 29 FOR UPDATE; # これは問題ない 515 | TC> SELECT * FROM t4 WHERE id = 27 FOR UPDATE; # 待たされる 516 | ``` 517 | TBのIN句によるクエリ-ロックは、インデックスを昇順から辿ってid=26,27を順に排他ロックを獲得し、 518 | id=28の排他ロックを獲得しようとして待たされている(TAが既に獲得済み 519 | そのため、TBはid=29,30に対してロックは未獲得の状態のまま待たされることになる。 520 | 当然、その間にid=29,30のロックを他TXから獲得することが可能であり、また、 521 | id=26,27に対しては他TXがロックを獲得することはできない状態である。 522 | シャドーロックとは、このようにクエリが待たされていても部分的にロック獲得する現象のことを言う。 523 | IN句に限らず、BETWEENなど複数行ロックするクエリは例外なくシャドーロックが発生する。 524 | ここでTAがCOMMITするとどうなるか? 525 | ```text 526 | TA> BEGIN; 527 | TB> BEGIN; 528 | TC> BEGIN; 529 | TA> SELECT * FROM t4 WHERE id = 28 FOR UPDATE; 530 | TB> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) FOR UPDATE; # 待たされる 531 | TC> SELECT * FROM t4 WHERE id = 29 FOR UPDATE; # これは問題ない 532 | TC> SELECT * FROM t4 WHERE id = 27 FOR UPDATE; # 待たされる 533 | TA> COMMIT; 534 | TC!> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 535 | ``` 536 | TAがCOMMITしたことでTBがid=28のロックを獲得、次にid=29のロックを獲得しようとしてTCとデッドロックが発生する。 537 | 繰り返しになるがInnoDBのロックはインデックスレコードロックであり、走査順にロックを獲得していく実装になっている。 538 | つまり、`order by`でインデックスの走査順が異なるとシャドーロックの挙動も少し違ってくる。 539 | 検証1 540 | ```text 541 | TA> BEGIN; 542 | TB> BEGIN; 543 | TC> BEGIN; 544 | TA> SELECT * FROM t4 WHERE id = 28 FOR UPDATE; 545 | TB> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) FOR UPDATE; # 待たされる 546 | TC> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) FOR UPDATE; # 待たされる 547 | TA> COMMIT; 548 | TB> # ブロック解除 549 | TC> # 待たされたまま, 正常! 550 | ``` 551 | 検証2 552 | ```text 553 | TA> BEGIN; 554 | TB> BEGIN; 555 | TC> BEGIN; 556 | TA> SELECT * FROM t4 WHERE id = 28 FOR UPDATE; 557 | TB> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) FOR UPDATE; # 待たされる 558 | TC> SELECT * FROM t4 WHERE id IN(26,27,28,29,30) ORDER BY id DESC FOR UPDATE; # 待たされる 559 | TA> COMMIT; 560 | TB> # ブロック解除 561 | TC!> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 562 | ``` 563 | 原因は先程のケースと同じである。 564 | 実際あまり意識する機会はないかもしれないが、待たされているからと言って、 565 | ロックを獲得していないとは限らない。 566 | 567 | 568 | ## Index Condition Pushdown 569 | ### Index Condition Pushdownとは 570 | 略してICP, MySQL 5.6, MariaDB 5.3.3から追加されたクエリ高速化のための機能で、 571 | デフォルトだとONに設定されており、EXPLAINだとExtra項目に Using index condition と表示される。 572 | 複合index(ex: col1, col2, col3)でWHERE条件を定義された順に指定しなくても部分的に機能する。 573 | 奥野幹也さんの[こちらの記事](http://enterprisezine.jp/dbonline/detail/3606?p=3)を読んでください。 574 | 575 | ### ICPの注意点 576 | 577 | * セカンダリインデックスによる検索しか意味がない 578 | * Covering Indexになる場合も意味がない 579 | * SELECT文にしか作用しない(MySQL 5.6.24現在) 580 | * ロックの挙動に癖がある 581 | 582 | ### ICPのロック挙動の説明 583 | こちらもREPEATABLE-READで検証を行った。 584 | 以下は検証データ 585 | ```text 586 | CREATE TABLE `icp_test` ( 587 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 588 | `col1` bigint(20) unsigned NOT NULL, 589 | `col2` bigint(20) unsigned NOT NULL, 590 | `col3` bigint(20) unsigned NOT NULL, 591 | `value` int(11) DEFAULT NULL, 592 | PRIMARY KEY (`id`), 593 | KEY `icp_text_idx_col1_col2_col3` (`col1`,`col2`,`col3`) 594 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 595 | 596 | mysql> select * from icp_test; 597 | +-------+------+------+------+-------+ 598 | | id | col1 | col2 | col3 | value | 599 | +-------+------+------+------+-------+ 600 | | 11251 | 1 | 1 | 1 | NULL | 601 | | 11252 | 1 | 1 | 2 | NULL | 602 | | 11253 | 1 | 1 | 3 | NULL | 603 | | 11254 | 1 | 1 | 4 | NULL | 604 | | 11255 | 1 | 1 | 5 | NULL | 605 | | 11256 | 2 | 1 | 1 | NULL | 606 | | 11257 | 2 | 1 | 2 | NULL | 607 | | 11258 | 2 | 1 | 3 | NULL | 608 | | 11259 | 2 | 1 | 4 | NULL | 609 | | 11260 | 2 | 1 | 5 | NULL | 610 | ... 611 | 612 | mysql> EXPLAIN SELECT * FROM icp_test WHERE col1 = 1 AND col3 = 2; 613 | +----+-------------+----------+------+-----------------------------+-----------------------------+---------+-------+------+-----------------------+ 614 | | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | 615 | +----+-------------+----------+------+-----------------------------+-----------------------------+---------+-------+------+-----------------------+ 616 | | 1 | SIMPLE | icp_test | ref | icp_text_idx_col1_col2_col3 | icp_text_idx_col1_col2_col3 | 8 | const | 5 | Using index condition | 617 | +----+-------------+----------+------+-----------------------------+-----------------------------+---------+-------+------+-----------------------+ 618 | 1 row in set (0.00 sec) 619 | ``` 620 | 今まではセカンダリインデックスからのロックでは実際に引いてくる行だけでなく、 621 | セカンダリインデックスから走査したクラスタインデックスの行全てロックされていたが、 622 | ICPが効く場合は実際に引いてきたクラスタインデックスの行のみをロックするようになる。 623 | ```text 624 | TA> BEGIN; 625 | TB> BEGIN; 626 | TA> SELECT * FROM icp_test WHERE col1 = 1 AND col3 = 2 FOR UPDATE; # 1行だけhit 627 | +-------+------+------+------+-------+ 628 | | id | col1 | col2 | col3 | value | 629 | +-------+------+------+------+-------+ 630 | | 11252 | 1 | 1 | 2 | NULL | 631 | +-------+------+------+------+-------+ 632 | 1 row in set (0.03 sec) 633 | 634 | TB> SELECT * FROM icp_test WHERE id = 11251 FOR UPDATE; # 今まではこれも待たされてたが、ICPだとロックされてない 635 | +-------+------+------+------+-------+ 636 | | id | col1 | col2 | col3 | value | 637 | +-------+------+------+------+-------+ 638 | | 11251 | 1 | 1 | 1 | NULL | 639 | +-------+------+------+------+-------+ 640 | 1 row in set (0.00 sec) 641 | 642 | TB> SELECT * FROM icp_test WHERE id = 11252 FOR UPDATE; # 待たされる 643 | ``` 644 | セカンダリインデックスによる条件のロック範囲は狭くなっていると言える。 645 | しかし、クラスタインデックス側のロック範囲が狭くなっているだけに過ぎない。 646 | セカンダリインデックス側はロックされて、クラスタインデックス側はロックされてない場合、 647 | FOR-UPDATEでロックを獲得していても実際の更新時に待たされる問題が発生する。 648 | ```text 649 | TA> BEGIN;↲ 650 | TB> BEGIN;↲ 651 | TA> SELECT * FROM icp_test WHERE col1 = 1 AND col3 = 2 FOR UPDATE; # 1行だけhit↲ 652 | TB> SELECT * FROM icp_test WHERE id = 11251 FOR UPDATE; # 今まではこれも待たされてたが、ICPだとロックされてない↲ 653 | TB> UPDATE icp_test SET col2 = 2 WHERE id = 11251; # 待たされる 654 | ``` 655 | これはTA側でcol1=1のセカンダリインデックスは全てロックされているため、 656 | id=11251もそれに含まれており、クラスタインデックス指定のFOR-UPDATEでロック獲得できても 657 | セカンダリインデックスに作用する更新処理は待たされてしまう。 658 | FOR-UPDATEでロック獲得したのにUPDATE文で待たされるという期待しない動作に繋がるため注意が必要である。 659 | ICPに限らず、セカンダリインデックスからのロックは期待しない動作になりがちなので、 660 | ロックはクラスタインデックスの条件指定のほうが安全でしょう。 661 | 662 | 663 | ## あるあるロック問題 664 | ### 1.よくある交差のパターン 665 | ```sql 666 | TA: BEGIN; 667 | TA: SELECT * FROM tableA WHERE id = 2501 FOR UPDATE; 668 | TB: BEGIN; 669 | TB: SELECT * FROM tableA WHERE id = 2502 FOR UPDATE; 670 | TA: SELECT * FROM tableA WHERE id = 2501 FOR UPDATE; # TBがロック獲得してるので待たされる 671 | TB: SELECT * FROM tableA WHERE id = 2502 FOR UPDATE; # ここでデッドロック 672 | ``` 673 | 多:多の関係で更新するような処理でありがちである 674 | 例えば、フォロー(A->B, B->Aが同時に走る)、チーム移動など 675 | ロック順序の問題ではなく構造上仕方ないのだが、 676 | 同じテーブルに限りデッドロックを避けることができる 677 | ```text 678 | TA: BEGIN; 679 | TA: SELECT * FROM tableA WHERE id IN (2501,2502) FOR UPDATE; 680 | TB: BEGIN; 681 | TB: SELECT * FROM tableA WHERE id IN (2502,2501) FOR UPDATE; # 待たされる 682 | TA: #ごにょごにょ 683 | TA: COMMIT; 684 | TB: #ごにょごにょ #ロックから解放されて処理が進む 685 | TB: COMMIT; # 幸せ 686 | ``` 687 | 実はIN句のなかの順序はロック獲得処理において関係がない 688 | __解決方法: 同じテーブルなら一度のクエリでまとめてロックを取る__ 689 | 690 | 691 | ### 2.なかったら挿入、あったらロックしたい 692 | ギャップロックの悲しい事実を上記で説明した通りだが、あるあるパターンの一つである。 693 | よくあるケースにも関わらず、解決が難しい問題である。現在の自分の見解を述べておく。 694 | REPEATABLE-READの場合、ギャップロックが発生するため以下のような処理を行っていると、 695 | Deadlockだけでなく、そのギャップに対するINSERTは全てブロックされることからパフォーマンス悪化にも繋がります。 696 | ```text 697 | > SELECT ~ FOR UPDATE 698 | > データがあるなら -> なにもしない 699 | > データがないなら -> INSERT ~ 700 | ``` 701 | READ-COMMITEDなどギャップロックが発生しない分離レベルなら、Duplicateは発生してもINSERTが全てブロックされることだけはない。 702 | あくまでREPEATABLE-READでDuplicateは諦めてパフォーマンス悪化だけを避けたいなら以下のような対処で良い。 703 | ```text 704 | > SELECT ~ 705 | > データがあるなら -> SELECT ~ FOR UPDATE 706 | > データがないなら -> INSERT ~ 707 | ``` 708 | Duplicateが発生したときはその行に対して共有ロックを獲得してしまうが、共有ロックを獲得しても問題ないなら 709 | そのまま処理を継続すれば良いし、嫌ならROLLBACKしてリトライする方法もある(許容できるなら)。 710 | どちらでもなく、Duplicateによる共有ロックをどうしても避けたいという場合は、 711 | `INSERT ON DUPLICATE KEY UPDATE`を応用すれば回避することができる...が、バッドノウハウ気味。 712 | これはデータがなかったら挿入、なかったら更新するMySQL特有の構文である。 713 | これで無意味な更新を行えば排他ロックだけの獲得になり、直後にSELECTでrowを取得するだけで良くなる。 714 | 715 | ```text 716 | TA> BEGIN; 717 | TB> BEGIN; 718 | TA> INSERT INTO t4 (id,number) VALUES(30, 100) ON DUPLICATE KEY UPDATE number = number; 719 | TB> INSERT INTO t4 (id,number) VALUES(30, 300) ON DUPLICATE KEY UPDATE number = number; # 待たされる 720 | TA> SELECT * FROM t4 WHERE id = 30; 721 | +----+--------+ 722 | | id | number | 723 | +----+--------+ 724 | | 30 | 100 | 725 | +----+--------+ 726 | 1 row in set (0.00 sec) 727 | TA> COMMIT; # TBのブロックが解除 728 | TB> SELECT * FROM t4 WHERE id = 30; 729 | +----+--------+ 730 | | id | number | 731 | +----+--------+ 732 | | 30 | 100 | 733 | +----+--------+ 734 | 1 row in set (0.00 sec) 735 | # TA, TBでnumberの値が異なっていても、後続のINSERTパラメーターで上書きされない 736 | ``` 737 | 738 | ### 3.外部キー制約による共有ロック 739 | 外部キー制約が設定されていると、挿入時に外部キーに共有ロックがかかる 740 | ```text 741 | TA: BEGIN; 742 | TA: INSERT INTO tableA (id,foreign_id) values (1,10); 743 | # foreign_id=10の行にも共有ロックがかかる、例えば 744 | TB: BEGIN; 745 | TB: SELECT * FROM foreign_table WHERE id = 10 FOR UPDATE; # 待たされる 746 | TA: UPDATE foreign_table SET num = num + 1 WHERE id = 10; # デッドロック 747 | ``` 748 | 最後のクエリはupdate文に限らず排他的ロックならデッドロックになる。なぜか? 749 | 同じトランザクション内でも、共有ロック獲得と排他的ロック獲得は別物になります 750 | ちなみに、ロックの性質から排他的ロックは共有ロックを含んでいるので順序次第ではデッドロックになりません. 751 | ```sql 752 | TA: BEGIN; 753 | TA: SELECT * FROM tableA WHERE id = 1001 LOCK IN SHARE MODE; 754 | TB: BEGIN; 755 | TB: SELECT * FROM tableA WHERE id = 1001 FOR UPDATE; # wait 756 | TA: SELECT * FROM tableA WHERE id = 1001 FOR UPDATE; # deadlock 757 | ... 758 | 759 | TA: BEGIN; 760 | TA: SELECT * FROM tableA WHERE id = 1001 FOR UPDATE; 761 | TB: BEGIN; 762 | TB: SELECT * FROM tableA WHERE id = 1001 FOR UPDATE; # wait 763 | TA: SELECT * FROM tableA WHERE id = 1001 LOCK IN SHARE MODE; # no deadlock !!!! 764 | ``` 765 | 今回の場合は前者です。外部キー制約があり、TAがINSERTで共有ロックを取ってるからと言って、 766 | あとから排他的ロックが素直に取れるかと言うとそうではありません。 767 | この隙間に別トランザクションがロックで待たされてると交差扱いとなるデッドロックになります 768 | 外部キー制約を設定しているテーブルの挿入時は、ロックの獲得順序を考えなければいけない 769 | (最初に外部キー側のテーブルをロックで済むなら良いですね><) 770 | 771 | ### 4.スナップショットを取るタイミング 772 | 先にも述べたが、REPEATABLE-READではトランザクションを開始して最初のクエリを発行したタイミングのデータベースのスナップショットを取る。 773 | これは最初のクエリのテーブルだけに限らず、__同じデータベース内の全てが対象__である 774 | ロックを取るタイミングが遅いと、古い情報を参照してバグに繋がってしまう 775 | 背景:あるユーザーの所属チームを移籍する。移籍後は人数を更新する 776 | 人数管理は行数を数え直しており、仕様の都合で人数によって処理が若干異なる 777 | 今回は同じチームメンバーが別チームに移籍しようとしたとき 778 | ```text 779 | TA: BEGIN; 780 | TA: SELECT * FROM user WHERE id = 1 FOR UPDATE; # ユーザー->チームの順でロック 781 | TA: SELECT * FROM team WHERE id = 1001 FOR UPDATE; 782 | TB: SELECT * FROM user WHERE id = 2 FOR UPDATE; # 地点A 783 | TB: SELECT * FROM team WHERE id = 1001 FOR UPDATE; # 同じチームでここで待たされる 784 | TA: SELECT COUNT(*) FROM team_member WHERE team = 1001; 785 | # 人数次第でアプリ側の処理が変わる 786 | TA: COMMIT; # 諸々処理が終わる 787 | TB: SELECT count(*) FROM team_member WHERE team = 1001; # 処理が進む 788 | # TBのcountで獲得したチーム人数は地点A時点での人数になる 789 | # TBで処理してたアプリ側の処理がバグってしまう 790 | ``` 791 | このようなバグを避けるためには、トランザクションが開始してからは必ず最初のクエリでロックを取り、 792 | 影響がある処理は最初のクエリ(ロック)で止まるようにプログラムを組まなければなりません。 793 | もしくはRedisを使った排他制御を使うなど、解決策はありますが 794 | InnoDBのREPEATABLE-READのスナップショットの挙動は把握しておきましょう 795 | 796 | ### 5.範囲ロック 797 | 範囲ロックでまとめてロックを取るときに最後の処理でギャップロックになり、 798 | INSERTが全て詰まるケースが存在する. 799 | バッチ処理などで古いデータの更新していくツールを書くときに注意である. 800 | ```sql 801 | mysql> desc t2; 802 | +--------+---------+------+-----+---------+----------------+ 803 | | Field | Type | Null | Key | Default | Extra | 804 | +--------+---------+------+-----+---------+----------------+ 805 | | id | int(11) | NO | PRI | NULL | auto_increment | 806 | | number | int(11) | YES | | NULL | | 807 | +--------+---------+------+-----+---------+----------------+ 808 | 2 rows in set (0.00 sec) 809 | mysql> select * from t2; 810 | +----+--------+ 811 | | id | number | 812 | +----+--------+ 813 | | 1 | 1 | 814 | | 2 | 2 | 815 | | 3 | 10 | 816 | | 4 | 50 | 817 | | 5 | 100 | 818 | | 6 | 110 | 819 | | 7 | 120 | 820 | | 8 | 130 | 821 | | 9 | 140 | 822 | | 10 | 150 | 823 | | 11 | 160 | 824 | | 12 | 170 | 825 | | 13 | 180 | 826 | | 14 | 210 | 827 | | 15 | 220 | 828 | +----+--------+ 829 | 15 rows in set (0.00 sec) 830 | 831 | ----- 832 | TA: BEGIN; 833 | TA: UPDATE t2 SET number = number + 1 WHERE id BETWEEN 1 AND 3; 834 | TA: COMMIT; 835 | TA: BEGIN; 836 | TA: UPDATE t2 SET number = number + 1 WHERE id BETWEEN 4 AND 6; 837 | TA: COMMIT; 838 | ... 839 | TA: BEGIN; 840 | TA: UPDATE t2 SET number = number + 1 WHERE id BETWEEN 13 AND 15; 841 | TB: INSERT INTO t2 (number) VALUES (999); # 止まる 842 | TC: INSERT INTO t2 (number) VALUES (2000); # 止まる... 843 | ``` 844 | InnoDB-REPEATABLE-READでは範囲ロックではギャップロック+次の本当のレコードロック(意味不明)になる. 845 | 最後の範囲処理だとid=[16,+inf)のギャップロックとなり、挿入が全て止まってしまう. 846 | 現状では範囲ロックで実行する以上避けられないので、 847 | 最後だけ`id = 16 FOR UPDATE`とかでレコードロックを獲得して処理するしかない 848 | 849 | 850 | ### 6.インデックス走査順によるデッドロック 851 | シャドーロックの説明で走査順でロックを獲得することの例を簡単に示した。 852 | それの影響を受けてよくあるデッドロックパターンとして、 853 | あるテーブルに複数のインデックスが張られており、異なるインデックスを利用するクエリが 854 | 並行して複数行のロックを獲得しようするときに発生するものがある。 855 | ```text 856 | テーブル定義 857 | CREATE TABLE `shadow_lock` ( 858 | `id` INTEGER unsigned NOT NULL auto_increment, 859 | `code_id` INTEGER unsigned NOT NULL, 860 | `token_id` INTEGER unsigned NOT NULL, 861 | `value` INTEGER unsigned NOT NULL, 862 | PRIMARY KEY (`id`), 863 | UNIQUE `shadow_lock_code_id` (`code_id`), 864 | UNIQUE `shadow_lock_token_id` (`token_id`) 865 | ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4; 866 | 867 | mysql> select * from shadow_lock; 868 | +------+---------+----------+---------+ 869 | | id | code_id | token_id | value | 870 | +------+---------+----------+---------+ 871 | | 1000 | 1 | 299 | 10000 | 872 | | 1001 | 2 | 298 | 20000 | 873 | | 1002 | 3 | 297 | 30000 | 874 | … 875 | | 1099 | 100 | 200 | 1000000 | 876 | | 1100 | 101 | 199 | 1010000 | 877 | | 1101 | 102 | 198 | 1020000 | 878 | | 1102 | 103 | 197 | 1030000 | 879 | | 1103 | 104 | 196 | 1040000 | 880 | | 1104 | 105 | 195 | 1050000 | 881 | | 1105 | 106 | 194 | 1060000 | 882 | | 1106 | 107 | 193 | 1070000 | 883 | | 1107 | 108 | 192 | 1080000 | 884 | | 1108 | 109 | 191 | 1090000 | 885 | | 1109 | 110 | 190 | 1100000 | 886 | | 1110 | 111 | 189 | 1110000 | 887 | | 1111 | 112 | 188 | 1120000 | 888 | ... 889 | ``` 890 | 891 | 簡単に再現する例として、 892 | ```text 893 | カラムA(昇順) -> id(昇順)=> code_id 894 | カラムB(昇順) -> id(降順)=> token_id 895 | ``` 896 | このようにセカンダリインデックスからクラスタインデックスへのアクセスが 897 | 逆順になるようなテーブル定義・データを用意する。 898 | 899 | そして、以下のようなクエリを用意する 900 | ```text 901 | TA> BEGIN; 902 | TB> BEGIN; 903 | TA> SELECT * FROM shadow_lock WHERE code_id IN('100','101','102','103','104','105','106','107','108','109','110') FOR UPDATE; 904 | TB> SELECT * FROM shadow_lock WHERE token_id IN('190','191','192','193','194','195','196','197','198','199','200') FOR UPDATE; 905 | ``` 906 | **のんびりと手動で実行するとデッドロックは発生しない** 907 | **のんびりと手動で実行するとデッドロックは発生しない** 908 | 909 | 並列処理で高速に実行するとデッドロックが発生する 910 | 検証コード : https://gist.github.com/ichirin2501/f4b22e50356890a52621 911 | 912 | **トランザクションAのロック獲得の概要** 913 | ```text 914 | 1.セカンダリインデックスのcode_id=100からクラスタインデックスのid=1099をロック 915 | 2.セカンダリインデックスのcode_id=101からクラスタインデックスのid=1100をロック 916 | ... 917 | 9.セカンダリインデックスのcode_id=109からクラスタインデックスのid=1108をロック 918 | 10.セカンダリインデックスのcode_id=110からクラスタインデックスのid=1109をロック 919 | ``` 920 | 921 | **トランザクションBのロック獲得の概要** 922 | ```text 923 | 1.セカンダリインデックスのtoken_id=190からクラスタインデックスのid=1109をロック 924 | 2.セカンダリインデックスのtoken_id=191からクラスタインデックスのid=1108をロック 925 | ... 926 | 9.セカンダリインデックスのtoken_id=199からクラスタインデックスのid=1100をロック 927 | 10.セカンダリインデックスのtoken_id=200からクラスタインデックスのid=1099をロック 928 | ``` 929 | 930 | 上記の通り、各クエリのクラスタインデックスに対するアクセス順が原因でデッドロックになる。 931 | また、IN句のtoken_idを逆順にしても意味がない。このような場合もあると頭の片隅に置いておきましょう。 932 | さすがにここまで考慮してアプリは書きたくない気持ちはあるが、回避したいなら 933 | ロックを獲得する際に用いるインデックスは統一するようにする、ぐらいかと思います。 934 | 例えば、token_idを条件にSELECTで引いたあと、code_idでSELECT-FOR-UPDATEで再取得するなど。 935 | 一度引いてロック獲得のために再取得するのを徹底するなら、PRIMARY-KEYが良いでしょう。 936 | 937 | ## パーティション下のロックの挙動 938 | 939 | まず、MySQL-5.1から利用できるパーティショニングには以下の種類がある. 940 | 941 | 1. RANGE 942 | 2. LIST 943 | 3. [LINEAR] HASH 944 | 4. [LINEAR] KEY 945 | 946 | 今回は面倒なのでRANGEのみ調査を行った. 947 | 基本を押さえておけば(たぶん)、どれも同じだと思う. 948 | 押さえておくべき基本事項としては、 949 | __パーティション毎にクラスタインデックスを構築している__ 950 | という点である. 951 | 952 | ### パーティションの区分けはギャップの切れ目 953 | パーティショニングしていないテーブルのギャップロックは, 954 | 指定箇所のギャップ区間がそのままロックされるのを思い出してください. 955 | 956 | ``` 957 | CREATE TABLE `range_test01` ( 958 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 959 | `num` int(10) unsigned NOT NULL DEFAULT '0', 960 | PRIMARY KEY (`id`) 961 | ) ENGINE=InnoDB AUTO_INCREMENT=2001 DEFAULT CHARSET=utf8mb4; 962 | 963 | # こんな感じのデータが入っていると仮定 964 | +-----+-----+ 965 | | id | num | 966 | +-----+-----+ 967 | ... 968 | | 69 | 0 | 969 | | 70 | 0 | 970 | | 108 | 20 | 971 | | 110 | 0 | 972 | | 111 | 0 | 973 | ... 974 | 975 | # 976 | mysql> SELECT * FROM range_test01 WHERE id = 80 FOR UPDATE; 977 | # 空打ち、id = [71,108) の範囲にギャップロック. 978 | 979 | mysql> SELECT * FROM range_test01 WHERE id < 80 FOR UPDATE; 980 | # 範囲ロックなので、id = [-inf, 108] の区間を排他的ロック 981 | ``` 982 | パーティショニングされていると、少しギャップロックの範囲が変わります 983 | ``` 984 | CREATE TABLE `range_test01` ( 985 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 986 | `num` int(10) unsigned NOT NULL DEFAULT '0', 987 | PRIMARY KEY (`id`) 988 | ) ENGINE=InnoDB AUTO_INCREMENT=2001 DEFAULT CHARSET=utf8mb4 989 | /*!50500 PARTITION BY RANGE COLUMNS(id) 990 | (PARTITION p_type_code0 VALUES LESS THAN (100) ENGINE = InnoDB, 991 | PARTITION p_type_code1 VALUES LESS THAN (200) ENGINE = InnoDB, 992 | PARTITION p_type_code2 VALUES LESS THAN (300) ENGINE = InnoDB, 993 | PARTITION pmax VALUES LESS THAN (MAXVALUE) ENGINE = InnoDB) */ 994 | 995 | # こんな感じのデータが入っていると仮定 996 | +-----+-----+ 997 | | id | num | 998 | +-----+-----+ 999 | ... 1000 | | 69 | 0 | 1001 | | 70 | 0 | 1002 | | 108 | 20 | 1003 | | 110 | 0 | 1004 | | 111 | 0 | 1005 | ... 1006 | 1007 | # 1008 | mysql> SELECT * FROM range_test01 WHERE id = 80 FOR UPDATE; 1009 | # 空打ち、id = [71,100) の範囲にギャップロック. 1010 | 1011 | mysql> SELECT * FROM range_test01 WHERE id < 80 FOR UPDATE; 1012 | # 範囲ロックなので、id = [-inf, 100) の区間を排他的ロック 1013 | 1014 | # 上記のようなロックで、対象のギャップがパーティションで区切られている場合、 1015 | # それを跨ぐようなロックはかかりません. 1016 | 1017 | mysql> EXPLAIN PARTITIONS SELECT * FROM range_test01 WHERE id < 80 FOR UPDATE; 1018 | +----+-------------+--------------+--------------+-------+---------------+---------+---------+------+------+-------------+ 1019 | | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra | 1020 | +----+-------------+--------------+--------------+-------+---------------+---------+---------+------+------+-------------+ 1021 | | 1 | SIMPLE | range_test01 | p_type_code0 | range | PRIMARY | PRIMARY | 8 | NULL | 32 | Using where | 1022 | +----+-------------+--------------+--------------+-------+---------------+---------+---------+------+------+-------------+ 1023 | 1024 | # partitions項目を見るとわかりますが、検索対象のパーティションが一つになっているからです 1025 | # パーティションを跨ぐような条件だと、 1026 | mysql> EXPLAIN PARTITIONS SELECT * FROM range_test01 WHERE id < 101 FOR UPDATE; 1027 | +----+-------------+--------------+---------------------------+-------+---------------+---------+---------+------+------+-------------+ 1028 | | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra | 1029 | +----+-------------+--------------+---------------------------+-------+---------------+---------+---------+------+------+-------------+ 1030 | | 1 | SIMPLE | range_test01 | p_type_code0,p_type_code1 | range | PRIMARY | PRIMARY | 8 | NULL | 33 | Using where | 1031 | +----+-------------+--------------+---------------------------+-------+---------------+---------+---------+------+------+-------------+ 1032 | 1 row in set (0.00 sec) 1033 | 1034 | # 次のパーティションも対象、範囲ロックであるため、id = [-inf, 108] の区間を排他的ロックになります 1035 | ``` 1036 | 1037 | ### 刈り込みなしのロックは死が見えるぞい 1038 | 今度はよくある(id,datetime)をprimary-keyとし, 1039 | datetimeカラムでパーティショニングした想定で動作確認しました. 1040 | datetimeカラムじゃないのはご愛嬌. 1041 | 結論から言うと、刈り込みが効かない場合は全パーティションを走査します. 1042 | つまり、各クラスタインデックスに対して、ロックをかけにいくことになります. 1043 | ``` 1044 | CREATE TABLE `range_test02` ( 1045 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 1046 | `serial` bigint(20) unsigned NOT NULL DEFAULT '0', 1047 | `num` int(10) unsigned NOT NULL DEFAULT '0', 1048 | `rank` int(10) unsigned NOT NULL DEFAULT '0', 1049 | PRIMARY KEY (`id`,`serial`), 1050 | KEY `range_test02_idx_rank` (`rank`) 1051 | ) ENGINE=InnoDB AUTO_INCREMENT=103 DEFAULT CHARSET=utf8mb4 1052 | /*!50500 PARTITION BY RANGE COLUMNS(`serial`) 1053 | (PARTITION p_type_code0 VALUES LESS THAN (100) ENGINE = InnoDB, 1054 | PARTITION p_type_code1 VALUES LESS THAN (200) ENGINE = InnoDB, 1055 | PARTITION p_type_code2 VALUES LESS THAN (300) ENGINE = InnoDB, 1056 | PARTITION pmax VALUES LESS THAN (MAXVALUE) ENGINE = InnoDB) */ 1057 | 1058 | +-----+--------+-----+------+ 1059 | | id | serial | num | rank | 1060 | +-----+--------+-----+------+ 1061 | | 15 | 53 | 0 | 15 | 1062 | | 22 | 60 | 0 | 22 | 1063 | | 33 | 110 | 0 | 33 | 1064 | | 34 | 111 | 0 | 34 | 1065 | | 35 | 112 | 0 | 35 | 1066 | | 65 | 210 | 0 | 65 | 1067 | | 66 | 211 | 0 | 66 | 1068 | | 67 | 212 | 0 | 67 | 1069 | +-----+--------+-----+------+ 1070 | # 実際はもっとデータを入れてます 1071 | 1072 | mysql> SELECT * FROM range_test02 WHERE id = 15 FOR UPDATE; 1073 | +----+--------+-----+------+ 1074 | | id | serial | num | rank | 1075 | +----+--------+-----+------+ 1076 | | 15 | 53 | 0 | 15 | 1077 | +----+--------+-----+------+ 1078 | 1 row in set (0.00 sec) 1079 | 1080 | # ('-') 死んだ, explainを見てみましょう 1081 | 1082 | mysql> EXPLAIN PARTITIONS SELECT * FROM range_test02 WHERE id = 20 FOR UPDATE; 1083 | +----+-------------+--------------+---------------------------------------------+------+---------------+---------+---------+-------+------+-------+ 1084 | | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | Extra | 1085 | +----+-------------+--------------+---------------------------------------------+------+---------------+---------+---------+-------+------+-------+ 1086 | | 1 | SIMPLE | range_test02 | p_type_code0,p_type_code1,p_type_code2,pmax | ref | PRIMARY | PRIMARY | 8 | const | 3 | | 1087 | +----+-------------+--------------+---------------------------------------------+------+---------------+---------+---------+-------+------+-------+ 1088 | 1089 | # partitions項目を見るとが全て対象になっています. 1090 | # これは(id,serial)で複合pkey, かつ, パーティション設定のカラムはserialなので 1091 | # idの条件だけでは, SQL側でパーティションの刈り込みが判断できず、全探索じゃ〜ってなってます 1092 | 1093 | # 肝心のロック範囲ですが id = 15 のレコードロック + 1094 | # [p_type_code0] = [ [-inf,-inf], [15,53] ) 1095 | # [p_type_code0] = [ [15,54], [22,60] ) 1096 | # [p_type_code1] = [ [-inf,100], [33,110) ) 1097 | # [p_type_code2] = [ [-inf,200], [65,210] ) 1098 | # [p_type_code3] = [-inf,+inf] 1099 | # [ pmax ] = [-inf,+inf] 1100 | # 以上のギャップがロックされます. 1101 | 1102 | # ちなみにクエリを並べると以下のようになります 1103 | > INSERT INTO range_test02 (id,serial,num,rank) VALUES(13,21,0,13); # wait 1104 | > INSERT INTO range_test02 (id,serial,num,rank) VALUES(20,21,0,20); # wait 1105 | > INSERT INTO range_test02 (id,serial,num,rank) VALUES(23,21,0,23); # no wait 1106 | > INSERT INTO range_test02 (id,serial,num,rank) VALUES(1,120,0,1); # wait 1107 | > INSERT INTO range_test02 (id,serial,num,rank) VALUES(33,111,0,33); # no wait 1108 | > ... 1109 | ``` 1110 | __ロックするとき、刈り込み、絶対__ 1111 | 検索するパーティションが決まった後のロックの挙動はパーティションなしと同じです. 1112 | 刈り込みなしだとそれらの合成になってしまうため、非常に広いロック範囲になってしまいます. 1113 | LOCK_INSERT_INTENTIONのないGAP-LOCK同士は互いにブロックしません. 1114 | (SELECT-FOR-UPDATE文では止まらないということ) 1115 | 上記のように刈り込みなしクエリロックだと、そのGAP-LOCKが大量に発生するので 1116 | LOCK_INSERT_INTENTIONフラグを持つINSERT文を同トランザクション内で発行してると、 1117 | あっという間にデッドロック・パフォーマンス悪化に見舞われてしまいます. 1118 | --------------------------------------------------------------------------------