Поведение INSERT… ON DUPLICATE KEY UPDATE в крайней ситуации

в 6:17, , рубрики: auto_increment, innodb, insert, mysql, update

Несколько недель назад, я работал над проблемой клиента, который столкнулся с падением производительности БД и даже ее отказами, которые происходили приблизительно каждые 4 недели. Ничего особенного в окружении, в железе или запросах. В сущности, большей частью базы данных была одна таблица, в которой присутствовали, кроме прочего, INT AUTO_INCREMENT PRIMARY KEY и UNIQUE KEY.

Запросы, работающие с этой таблицей, почти все были типа INSERT ... ON DUPLICATE KEY UPDATE (далее — INSERT ODKU), где столбцы, перечисленные в INSERT, соответствовали столбцам с UNIQUE KEY. И выполнялись они с частотой, приблизительно 1500-2000 запросов в секунду, непрерывно 24 часа в сутки. Если вы хороши в математике, то наверное, уже догадались в чем дело.

Для целей дискуссии мы будем использовать следующую таблицу, как иллюстрацию к ситуации:

CREATE TABLE update_test (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  username VARCHAR(20) NOT NULL,
  host_id TINYINT UNSIGNED NOT NULL,
  last_modified TIMESTAMP NULL DEFAULT NULL,
  PRIMARY KEY(id),
  UNIQUE KEY(username)
) ENGINE=InnoDB;

Теперь представим следующую последовательность событий:

(root@localhost) [test]> INSERT INTO update_test (username, host_id, last_modified) VALUES ('foo',3,NOW());
Query OK, 1 row affected (0.00 sec)

(root@localhost) [test]> select * from update_test;
+----+----------+---------+---------------------+
| id | username | host_id | last_modified       |
+----+----------+---------+---------------------+
| 1  | foo      |       3 | 2012-10-05 22:36:30 |
+----+----------+---------+---------------------+

Ничего необычного, да? Мы вставили один ряд в пустую таблицу и если мы сделаем SHOW CREATE TABLE, то мы увидим что счётчик AUTO_INCREMENT сейчас имеет значение 2. Если мы сделаем INSERT ODKU в эту таблицу, то увидим следующее:

(root@localhost) [test]> insert into update_test (username,host_id) values ('foo',1) on duplicate key update last_modified=NOW();
Query OK, 2 rows affected (0.00 sec)

(root@localhost) [test]> select * from update_test;
+----+----------+---------+---------------------+
| id | username | host_id | last_modified       |
+----+----------+---------+---------------------+
|  1 | foo      |       3 | 2012-10-05 22:58:28 |
+----+----------+---------+---------------------+
1 row in set (0.00 sec)

И теперь, даже если мы не вставили новый ряд, наш счётчик AUTO_INCREMENT вырос до 3. Это, вообще-то, ожидаемое поведение. InnoDB проверяет ограничения в том порядке, в котором они были определены, и PRIMARY KEY всегда идёт первым. Поэтому MySQL проверяет наш INSERT, видит, что следующее значение AUTO_INCREMENT доступно и использует его, но потом, проверяет UNIQUE KEY и находит нарушение, поэтому вместо INSERT делает UPDATE. Если мы посмотрим счётчики handler status, мы можем увидеть, что был один запрос на вставку, который завершился неудачей, и один запрос на обновление, который прошёл успешно (это объясняет, почему изменены 2 ряда, а не 1).

(root@localhost) [test]> show status like 'handler%';
 *** some rows omitted ***
+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| Handler_commit             | 1     |
| Handler_rollback           | 0     |
| Handler_update             | 1     |
| Handler_write              | 1     |
+----------------------------+-------+

В этом месте вы можете подумать — «Ну и что?». Давайте вернёмся к нашему клиенту. 1500 INSERT ODKU в секунду, непрерывно 24 часа в сутки. PRIMARY KEY их таблицы такой же, как я использовал в демонстрационной таблице — INT UNSIGNED. Считаем. Максимальное значение для INT UNSIGNED – это 4294967295. Делим это на 1500 запросов в секунду и делим на 86400, что является количеством секунд в сутках, и мы получаем 33.1 дней, или чуть больше чем 4 недели. Совпадение? Я так не думаю. Итак, что именно происходит, когда мы выходим за пределы значения? Некоторое поведение может вас удивить. Вернёмся к нашей демонстрационной таблице и вставим в нее ряд с максимальным значением для столбца с AUTO_INCREMENT, а потом вставим ещё один.

(root@localhost) [test]> insert into update_test (id,username,host_id) values (4294967295, 'bar', 10);
Query OK, 1 row affected (0.00 sec)
(root@localhost) [test]> flush status;
(root@localhost) [test]> insert into update_test (username,host_id) values ('baz', 10);
ERROR 1062 (23000): Duplicate entry '4294967295' for key 'PRIMARY'

(root@localhost) [test]> show status like 'handler%';
 *** some rows omitted ***
+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| Handler_rollback           | 1     |
| Handler_write              | 1     |
+----------------------------+-------+

Итак, мы попытались вставить ряд и это не вышло, т.к. AUTO_INCREMENT уже имел максимальное значение и запрос не прошёл. Но, что случится если мы попробуем сделать INSERT ODKU? Сначала, посмотрим что у нас в таблице:

(root@localhost) [test]> select * from update_test;
+------------+----------+---------+---------------------+
| id         | username | host_id | last_modified       |
+------------+----------+---------+---------------------+
|          1 | foo      |       3 | 2012-10-05 22:58:28 |
| 4294967295 | bar      |      10 | NULL                |
+------------+----------+---------+---------------------+
2 rows in set (0.00 sec)

(root@localhost) [test]> INSERT INTO update_test (username, host_id) VALUES ('foo', 7) ON DUPLICATE KEY UPDATE host_id=7, last_modified=NOW();
Query OK, 2 rows affected (0.00 sec)

Выглядит нормально, да? 2 ряда изменено, очевидно, что для ряда который соответствовал условию username = "foo", были обновлены host_id и last_modified, и мы можем радоваться. К сожалению, это не так:

(root@localhost) [test]> select * from update_test;
+------------+----------+---------+---------------------+
| id         | username | host_id | last_modified       |
+------------+----------+---------+---------------------+
|          1 | foo      |       3 | 2012-10-05 22:58:28 |
| 4294967295 | bar      |       7 | 2012-10-05 23:24:49 |
+------------+----------+---------+---------------------+
2 rows in set (0.00 sec)

Опа, обновлён был последний ряд, у которого id равен максимальному значению нашего AUTO_INCREMENT, а UNIQUE KEY на столбце username был проигнорирован.

Теперь мы можем легко понять в чем проблема клиента, чья база данных послужила вдохновением для этого поста. 1500 запросов в секунду, пытающихся заблокировать и обновить один и тот же ряд, ни к чему хорошему не приведут. Конечно, есть простое решение — изменить тип данных AUTO_INCREMENT-столбца c INT на BIGINT.

Оказывается, такое поведение документировано. Мануал говорит, что наш INSERT ODKU на таблице с несколькими уникальными индексами, будет эквивалентен запросу UPDATE update_test SET host_id = 7, last_modified = NOW() WHERE id = 4294967295 OR username = "foo" LIMIT 1 и конечно оптимизатор скорее выберет PRIMARY, нежели вторичный UNIQUE.

Чему же мы здесь научились?

  • Исчерпать AUTO_INCREMENT намного легче, чем кажется. Реальная таблица клиента содержала менее 500k рядов.
  • Использование SIGNED типов для AUTO_INCREMENT — почти всегда плохая идея. Вы теряете половину диапазона доступных значений.
  • Интуиция, подобно законам физики, часто подводит в крайних ситуациях.

Автор: truezemez

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js