Счётчик автоинкремента упёрся в потолок и заказы перестали создаваться
В пятницу вечером в интернет-магазине перестали проходить заказы. Не какие-то отдельные — вообще все, с одной и той же ошибкой в логе приложения. Метрики не показывали ни перегрузки, ни падения сети, ни проблем с диском — база отвечала на чтение мгновенно. А запись в одну конкретную таблицу ломалась стабильно, как по расписанию. Разбор этого случая — с гипотезами, которые отбросили, и с настоящей причиной, до которой докопались только через SHOW CREATE TABLE.
Содержание
Что мы увидели: заказы перестали создаваться
Первый сигнал пришёл не из мониторинга, а от службы поддержки: клиенты писали, что корзина не оформляется, страница просто зависает на кнопке «Оплатить», а потом выдаёт «Ошибка сервера, попробуйте позже». В логе приложения — россыпь одинаковых исключений от ORM: не удалось вставить строку в таблицу orders, драйвер вернул код ошибки от MySQL.
Смотрим Grafana: CPU базы в норме, IOPS в норме, число активных соединений не растёт, Threads_running не подскакивает. Реплика для чтения отвечает быстро. То есть база не «легла» и не задыхается — она просто отказывается выполнять один конкретный тип операции. Это сразу сужает круг подозреваемых: дело не в ресурсах, а в чём-то на уровне данных, схемы или конфигурации.
Смотрим сам текст ошибки в логе MySQL (/var/log/mysql/error.log или через SHOW ENGINE INNODB STATUS, если ошибка не долетела до error-лога напрямую, а видна только в ответе клиенту):
ERROR 1264 (22003): Out of range value for column 'id' at row 1
Код 1264 в MySQL — это классическая ошибка выхода значения за пределы типа столбца. На неё редко обращают внимание, пока не встретишься с ней вживую: обычно её видят разработчики при валидации форм, а не при вставке автоинкрементного ключа.
Первая гипотеза: перегрузка базы и блокировки
Первая версия — как всегда, самая скучная: слишком много нагрузки, длинные транзакции держат блокировки, вставки в orders встают в очередь. Проверили:
SHOW ENGINE INNODB STATUS\G
SELECT * FROM information_schema.INNODB_TRX ORDER BY trx_started;
SELECT * FROM performance_schema.data_lock_waits;
Ни одной зависшей транзакции дольше нескольких миллисекунд, лок-вейтов нет, дедлоков в логе тоже нет. SHOW PROCESSLIST показывает обычную картину: соединения приходят, выполняются, закрываются. Если бы дело было в блокировках, мы бы увидели растущую очередь Threads_running и рост Innodb_row_lock_waits в SHOW GLOBAL STATUS — ничего подобного. Гипотезу с перегрузкой закрыли в первые пятнадцать минут: ресурсов достаточно, очередей нет, а ошибка детерминированная — воспроизводится на каждой попытке вставки, а не время от времени, как бывает при конкуренции за ресурсы.
Отдельно проверили дисковое место — вдруг таблица не может расшириться из-за нехватки места в файловой системе или в табличном пространстве InnoDB:
df -h /var/lib/mysql
Место есть, с большим запасом. Эта гипотеза тоже отпала — ошибка была бы другой (что-то про «no space left on device» или проблему с расширением tablespace), а не про диапазон значений столбца.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверВторая гипотеза: реплика отстала или сеть моргнула
Вторая версия родилась из того, что приложение подключается к базе через прокси на пути к мастеру, а часть трафика — асинхронно реплицируется на читающую реплику для отчётов. Предположили: может, прокси в какой-то момент перенаправил запись не туда, куда нужно, и приложение пытается писать в реплику, доступную только на чтение.
Проверили статус реплики:
SHOW REPLICA STATUS\G
Seconds_Behind_Source — ноль, репликация идёт без задержек, ошибок нет. Также проверили конфигурацию прокси и подтвердили, что все запросы на запись действительно уходят на мастер — маршрутизация в порядке. О том, как устроена сама механика отставания и на что она влияет, мы отдельно писали в материале про репликацию и отставание реплики — там разобрана логика для случаев, когда дело реально в задержке. Здесь это оказалось не так: ошибка возникала именно на мастере при попытке вставки, а не из-за того, что приложение читало устаревшие данные с реплики.
К этому моменту прошло уже около сорока минут, обе «стандартные» гипотезы отпали, а заказы всё ещё не создавались. Стало ясно, что дело не в поведении под нагрузкой и не в топологии репликации — нужно было смотреть на саму структуру таблицы.
Настоящая причина: id упёрся в потолок INT
Кто-то из команды вспомнил про код ошибки 1264 и предположил самое неприятное: переполнение диапазона числового типа. Проверили определение таблицы:
SHOW CREATE TABLE orders\G
В выводе — id INT NOT NULL AUTO_INCREMENT PRIMARY KEY. Обычный INT, знаковый, без UNSIGNED. Максимальное значение такого типа в MySQL — 2147483647. Проверили текущее значение счётчика:
SELECT AUTO_INCREMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'orders';
Значение было практически равно максимуму диапазона. Ещё несколько вставок — и движок просто не смог бы выделить следующий id, потому что следующее число не помещается в тип столбца. MySQL в этой ситуации не делает никакой магии: она честно возвращает ошибку 1264 и отказывается писать строку. Никакого плавного деградирования, никакого предупреждения заранее — таблица работает штатно ровно до момента, пока счётчик не упрётся в потолок, а потом обрывается разом.
Дальше стали разбираться, почему счётчик вырос настолько сильно относительно реального числа заказов в таблице. Причин оказалось несколько, и по отдельности каждая выглядела безобидно:
- Повторные попытки на стороне фронтенда. При таймауте на оформлении заказа фронтенд ретраил запрос создания заказа несколько раз, а строки от неуспешных попыток (упавших из-за валидации на более поздних этапах) откатывались транзакцией, но счётчик
AUTO_INCREMENTпри откате не возвращается назад — это фундаментальное свойство механизма, а не баг. Заняли значение — не вернули, даже если транзакция откатилась. - Служебные задания. Часть автотестов и нагрузочных прогонов в отдельном окружении почему-то писала в ту же продовую схему через реплицированный дамп для стейджинга, накручивая счётчик быстрее, чем росло реальное число заказов.
- ON DUPLICATE KEY UPDATE и вставки через
INSERT IGNORE. Оба паттерна расходуют значение автоинкремента даже тогда, когда строка в итоге не вставляется или обновляется существующая.
Ни один из этих трёх факторов сам по себе не выглядел катастрофой. Но сложенные вместе, за несколько лет работы магазина они разогнали счётчик заметно быстрее, чем предполагалось при проектировании таблицы, когда INT казался «более чем достаточным» типом для id. Отдельная неприятность: никто не измерял скорость роста счётчика и не сверял её с оставшимся запасом — задача выглядела настолько крайним случаем, что про неё просто забыли поставить мониторинг.
Как чинили: перевод id на BIGINT без долгой остановки
Прямое решение — расширить тип столбца:
ALTER TABLE orders MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT;
Проблема в том, что на таблице с историей заказов за несколько лет обычный ALTER TABLE в MySQL (даже с ALGORITHM=INPLACE, где это применимо) блокирует таблицу на запись на время перестроения, а для изменения типа первичного ключа с автоинкрементом эта операция в большинстве версий требует полного копирования таблицы — то есть блокировки на длительное время, которое магазин, теряющий заказы каждую минуту простоя, точно не мог себе позволить.
Поэтому пошли по пути онлайн-миграции через pt-online-schema-change из Percona Toolkit — она создаёт копию таблицы с новой схемой, копирует данные пачками, синхронизирует изменения через триггеры и в конце подменяет таблицы одним быстрым RENAME:
pt-online-schema-change \
--alter "MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT" \
--host=127.0.0.1 --user=migrator --ask-pass \
D=shop,t=orders \
--execute
Важные нюансы, на которые ушло время до запуска:
- Внешние ключи. У
ordersбыли дочерние таблицы (order_items,order_payments) со ссылками черезFOREIGN KEY.pt-online-schema-changeумеет обрабатывать это через--alter-foreign-keys-method, но требует, чтобы дочерние таблицы тоже обновлялись синхронно — иначе типы разойдутся и внешний ключ перестанет проверяться корректно. Пришлось прогнать миграцию по всей цепочке таблиц, а не только поorders. - Нагрузка на диск и репликацию во время копирования. Копирование многомиллионной таблицы триггерит заметный объём записи в бинлог, что временно увеличивает отставание реплики — контролировали
--max-lagу самого инструмента, чтобы он сам притормаживал копирование, если реплика начинала отставать сильнее заданного порога. - Окно для проверки перед финальным переключением. Перед
RENAMEдали себе время сверить количество строк и контрольные суммы между старой и новой таблицей (pt-table-checksum), чтобы не подменить таблицу с расхождением данных.
После завершения миграции проверили, что новый диапазон реально применился:
SHOW CREATE TABLE orders\G
SELECT AUTO_INCREMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME='orders';
BIGINT даёт диапазон до 9223372036854775807 — при текущей скорости роста счётчика этого хватит с огромным запасом на многие годы вперёд, и повторное переполнение перестало быть реалистичным риском в обозримой перспективе. Общий подход к проверке, что миграция действительно прошла успешно, а не только не выдала ошибку, у нас разобран отдельно в статье как проверить, что миграция прошла успешно — там смысл в том, чтобы сверять не только код возврата команды, но и фактическое состояние данных до и после.
Что изменили после инцидента
Сам факт переполнения устранили, но реальная проблема была не в конкретной таблице, а в том, что такого класса риск вообще не был на радаре команды. После разбора сделали три вещи:
Аудит всех таблиц с автоинкрементом. Прошлись по information_schema.COLUMNS и information_schema.TABLES, чтобы найти все столбцы типа INT/SMALLINT/TINYINT с AUTO_INCREMENT и посчитать, какой процент диапазона уже занят:
SELECT
t.TABLE_NAME,
c.COLUMN_TYPE,
t.AUTO_INCREMENT,
CASE c.COLUMN_TYPE
WHEN 'int(11)' THEN 2147483647
WHEN 'int(10) unsigned' THEN 4294967295
WHEN 'bigint(20)' THEN 9223372036854775807
ELSE NULL
END AS max_value
FROM information_schema.TABLES t
JOIN information_schema.COLUMNS c
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME
WHERE t.TABLE_SCHEMA = 'shop'
AND c.EXTRA LIKE '%auto_increment%';
Нашли ещё две таблицы с похожим профилем роста и INT-счётчиком — их перевели на BIGINT тем же способом заранее, планово, а не в режиме пожара.
Мониторинг заполненности диапазона. Добавили метрику, которая считает отношение текущего AUTO_INCREMENT к максимуму типа для каждой таблицы, и алерт на превышение 70% диапазона — этого запаса достаточно, чтобы спокойно спланировать миграцию, а не делать её ночью под давлением. Общие принципы того, какие метрики баз данных стоит выводить в Grafana и на что реагировать в первую очередь, разбирали в статье про мониторинг баз данных через Grafana.
Пересмотр стандарта для новых таблиц. Договорились, что для всех таблиц, где ожидается заметный поток вставок (заказы, события, логи, платежи), тип первичного ключа по умолчанию — BIGINT, а не INT, даже если сейчас нагрузка кажется небольшой. Разница в размере хранения между INT и BIGINT для одного столбца индекса не настолько велика, чтобы оправдывать риск повторить этот инцидент через несколько лет на новой таблице. Отдельно обсуждали и более радикальный вариант — вынести часть исторических заказов в отдельные таблицы или шарды по периодам, чтобы новые «горячие» таблицы росли медленнее; общие принципы такого подхода разбирали в материале про основы шардирования базы данных, но для нашего объёма данных перевода на BIGINT и мониторинга оказалось достаточно, без усложнения архитектуры.
Отдельно проверили и код приложения: ретраи на фронтенде теперь идемпотентны через отдельный idempotency_key, который проверяется до вставки, а не полагаются на откат транзакции при дублировании — это не остановит рост счётчика полностью, но снизит его паразитную часть.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему MySQL не предупредила заранее, что счётчик скоро закончится?
Она этого не делает — движок не знает и не пытается предсказывать скорость роста своей же метадаты, это ответственность мониторинга приложения и инфраструктуры, а не СУБД.
Разве INT — это не десять цифр, разве не хватит на миллиарды строк?
INT в MySQL знаковый по умолчанию, поэтому реальный диапазон — примерно 2,1 миллиарда, а не 4,3 миллиарда, как у INT UNSIGNED. Путаница между знаковым и беззнаковым диапазоном — частая причина недооценки риска при проектировании схемы.
Можно ли было просто сделать UNSIGNED вместо перехода на BIGINT?
Можно, и это временная мера, которая примерно удваивает запас, но она не решает проблему на годы вперёд, если темп роста счётчика продолжает расти, а не только откладывает тот же инцидент. Для таблицы с активным ростом правильнее сразу переходить на BIGINT.
Что будет, если запустить обычный ALTER TABLE без онлайн-инструмента на боевой таблице с миллионами строк?
Таблица заблокируется на запись (а на некоторых операциях и на чтение) на всё время перестроения — для маленьких таблиц это секунды, для таблиц с историей за годы это может быть от нескольких минут до часов в зависимости от объёма и дисковой подсистемы, поэтому для боевых таблиц такого размера практически всегда стоит использовать pt-online-schema-change или gh-ost.
Актуально ли это и для PostgreSQL?
Да, механика та же: типы integer (используется под капотом у serial) и bigint (под bigserial) имеют аналогичные диапазоны и то же поведение при переполнении — ошибка вставки вместо предупреждения. Разница в основном в инструментах для онлайн-миграции: в экосистеме PostgreSQL для этого чаще используют pg_repack или штатный ALTER TABLE ... ALTER COLUMN ... TYPE bigint с более гибким поведением блокировок в последних версиях.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →