MAATRIX / Блог / latin1 в MySQL испортил 300 тысяч имён, и мы заметили только через год

latin1 в MySQL испортил 300 тысяч имён, и мы заметили только через год

MAATRIX

Однажды в конце августа маркетинг попросил выгрузить базу пользователей для рассылки — и в CSV половина имён оказалась одним символом «?». Не «??????» вместо кириллицы, а именно одиночный вопросительный знак на всё имя, будто его никогда и не было. Дальше выяснилось, что это не баг экспорта: имена были испорчены в саму секунду регистрации, безвозвратно, и происходило это почти год. Ниже — как мы это нашли, почему все привычные подозреваемые оказались ни при чём, и почему в этом случае даже регулярные бэкапы не спасли ни одной записи.

Как началось: жалоба, которую отфутболили полгода

Первый тикет пришёл ещё в прошлом сентябре: пользователь написал, что в личном кабинете его имя отображается как «?» вместо «Аркадий». Саппорт попросил его перелогиниться, почистить кеш браузера — помогло (пользователь просто заново ввёл имя, и оно снова отобразилось нормально до следующего изменения). Тикет закрыли как единичный глюк фронтенда. За следующие месяцы таких обращений набралось ещё десятка два, они терялись среди сотен других и ни разу не были связаны друг с другом — слишком редкие, слишком похожие на «пользователь что-то напутал».

Настоящий масштаб проблемы вскрылся только когда аналитики выгрузили SELECT id, name, email FROM users для сегментации перед рассылкой и увидели, что из 480 тысяч строк примерно 300 тысяч содержат в поле name один-единственный символ ?. Для базы, где заметная часть аудитории пишет имя кириллицей, это была катастрофа, а не «глюк».

Гипотеза первая: сломан экспорт в CSV

Первая версия — самая дешёвая: где-то в пайплайне экспорта данные проходят через iconv или Excel открывает файл не в той кодировке. Проверили:

mysql -u analytics -p -e "SELECT name FROM users WHERE id=482113" --default-character-set=utf8mb4 \
  | hexdump -C | head

Байты в выводе — это буквально 0x3f, код ASCII-символа ?. Не байты кириллицы, «съеденные» неправильной кодировкой при отображении, а именно записанный в таблицу байт вопросительного знака. Открой это хоть в UTF-8, хоть в CP1251 — там физически ничего, кроме ?, нет. Гипотеза про экспорт отпала за пять минут: проблема была не в том, как данные читают, а в том, что в них записано.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотеза вторая: баг в форме регистрации или API

Дальше подозрение пало на бэкенд: может, где-то в middleware неправильно перекодируется тело запроса. Проверили логи приложения (у сервиса есть debug-режим, логирующий тело запроса до записи в БД):

grep -A2 "POST /api/users/register" app.log | grep -i "Аркадий"

В логах имя было целым и правильным — «Аркадий Литвинов», нормальными UTF-8 байтами, никакой порчи на уровне HTTP-запроса. Строка драйвера подключения тоже была в порядке:

jdbc:mysql://db-host:3306/prod?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci

То есть данные приходили целыми, отправлялись с правильной кодировкой соединения — и всё равно на выходе получался «?». Значит, порча происходила не до записи и не после чтения, а в момент самой вставки, внутри MySQL.

Что показал SHOW CREATE TABLE

Дальше — очевидный шаг, который стоило сделать в первую очередь:

SHOW CREATE TABLE users;
CREATE TABLE `users` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
  `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Вот и разгадка. Таблица в целом объявлена как utf8mb4, email — тоже utf8mb4, а вот колонка name осталась на latin1_swedish_ci — дефолтной кодировке MySQL для колонок, которые кто-то создавал руками ещё до того, как в проекте появилось правило «всегда явно указывать charset». Судя по истории миграций, колонка name была добавлена почти два года назад отдельной ALTER-миграцией, до того как БД целиком перевели на utf8mb4 — саму базу перевели, а эту одну колонку в старой таблице забыли, потому что ALTER TABLE ... CONVERT TO CHARACTER SET на боевой таблице с полумиллионом строк — операция с блокировкой, которую тогда решили не делать «пока не будет времени сделать аккуратно». Времени, как водится, не нашлось почти два года.

Дальше — что происходит, когда клиент подключается с кодировкой utf8mb4, а колонка объявлена как latin1. MySQL при вставке автоматически конвертирует байты из кодировки соединения в кодировку колонки. Если символ есть в обеих кодировках (например, латинские буквы, большинство западноевропейских «à», «ö», «ü» — они входят в latin1), конвертация проходит без потерь. Но кириллица, иероглифы, эмодзи — всё, чего в latin1 просто нет, — MySQL по умолчанию заменяет символом ? (0x3F) и сохраняет так, без ошибки. Это не мифический баг драйвера — это документированное, штатное поведение конвертации между кодировками, которое совершенно не создано для того, чтобы его игнорировали.

Почему это работало полгода без единой явной ошибки

Самое неприятное в этом инциденте — что MySQL не молчал. При каждой такой вставке сервер честно выставлял предупреждение:

SHOW WARNINGS;
-- Warning | 1366 | Incorrect string value: '\xD0\x90\xD1\x80...' for column 'name' at row 1

Но SHOW WARNINGS нужно явно запрашивать после каждого INSERT, а используемый ORM его не проверял — он смотрел только на исключения, а конвертация в ? исключения не бросает (это не ошибка, а предупреждение, тем более сервер не работал в строгом sql_mode). Добавьте STRICT_ALL_TABLES — и часть таких вставок начала бы падать с ошибкой уже на INSERT, что как минимум сделало бы проблему видимой сразу же. Но sql_mode был унаследован от старой конфигурации сервера ещё с версии MySQL 5.6 и строгие режимы там не включали намеренно — «чтобы ничего не сломать».

Почему на стейджинге и в тестах это не всплыло — отдельная деталь. Тестовые данные и имена разработчиков были почти исключительно латиницей или с редкими европейскими акцентами вроде «José» — а такие символы, как назло, входят в latin1 и проходят конвертацию без потерь. Кириллица начала массово появляться только когда аудитория выросла за счёт пользователей из России и СНГ — то есть именно того сегмента, ради которого продукт вообще существует. Баг был «невидим» ровно для той выборки данных, на которой его тестировали, и разрушителен ровно для той, на которой его не тестировали.

Почему бэкапы не спасли ни одной записи

Первая реакция после находки — «восстановим из бэкапа». Здесь и вскрылась вторая, более тяжёлая часть проблемы: порча происходила в момент записи строки, а не когда-то потом. То есть уже в тот самый момент, когда пользователь регистрировался и вводил «Аркадий», в таблицу физически попадал байт ? — оригинальное имя нигде на диске никогда не существовало дольше миллисекунд одного INSERT. Ежедневные mysqldump-бэкапы с ротацией на 30 дней добросовестно сохраняли уже испорченные строки — там просто не было точки во времени, откуда можно было бы «отмотать назад» и достать целые данные, потому что порча случилась раньше первой же резервной копии этой строки.

Проверили ещё один шанс — репликацию через binlog в аналитическое хранилище (CDC на Debezium). Не помогло по той же причине: binlog в формате ROW фиксирует байты уже после того, как InnoDB их записал, то есть после конвертации в latin1. Порченые данные утекли в бинлог и оттуда — во все системы, которые на него подписаны.

Частично данные удалось восстановить только там, где имя дублировалось в независимом источнике с собственной, честной кодировкой:

  • в JSON-логе антифрод-системы, который хранил сырые данные регистрации 90 дней в колонке utf8mb4 — оттуда вытащили имена примерно для последних трёх месяцев;
  • в данных платёжного провайдера — там, где пользователь проходил верификацию по карте, у части аккаунтов было отдельное поле ФИО, введённое через другую форму и никогда не проходившее через испорченную колонку.

Совокупно так удалось восстановить около четверти утраченных имён. Для остальных отправили пользователям персональное письмо с просьбой перезаполнить профиль — не самое приятное объяснение для рассылки, но честное. Если у вас в проекте есть похожая точка сбора персональных данных, которая заходит только через одну колонку без дублирования, стоит заранее подумать про резервное копирование баз данных — не только про факт бэкапа, но и про то, какого рода порчу он в принципе способен покрыть, а какую нет.

Что изменили после инцидента

Правки разделили на три уровня — исправление данных, исправление схемы и защита от повтора.

Схему таблицы привели в порядок, но не банальным ALTER TABLE, который заблокировал бы таблицу на боевом трафике: использовали gh-ost, чтобы конвертация прошла без долгой блокировки на чтение и запись:

gh-ost \
  --user="migrator" --password="***" \
  --host=db-host \
  --database="prod" --table="users" \
  --alter="MODIFY name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" \
  --allow-on-master \
  --cut-over=default \
  --execute

Дальше — аудит всей схемы, чтобы убедиться, что «латинисских» колонок больше нет нигде:

SELECT table_name, column_name, character_set_name
FROM information_schema.columns
WHERE table_schema = 'prod' AND character_set_name IS NOT NULL
  AND character_set_name != 'utf8mb4';

Нашли ещё две такие же колонки в менее используемых таблицах (адрес доставки и комментарий к заказу) — их сконвертировали тем же способом, пока они не превратились в собственный отдельный инцидент.

На уровне защиты от повтора сделали три вещи:

  1. Включили sql_mode=STRICT_ALL_TABLES,STRICT_TRANS_TABLES на проде — теперь любая попытка конвертации с потерей данных завершается ошибкой на самом INSERT, а не тихим предупреждением.
  2. Добавили в CI шаг, который прогоняет тот самый запрос к information_schema.columns на каждой миграции и валит билд, если появляется хоть одна не-utf8mb4 колонка.
  3. Поставили простой канареечный тест в мониторинг: раз в час скрипт вставляет тестовую строку с кириллицей и эмодзи в staging-копию схемы, читает её обратно и сверяет байты — если хоть один символ «потерялся», алерт летит в дежурный канал раньше, чем через год. Такую же логику стоит держать рядом и для баз данных через Grafana — метрики важны, но округлая проверка «что реально лежит в строке» ловит именно такие тихие искажения, которые метрики нагрузки не видят вообще.

Отдельно пересмотрели политику ротации бэкапов — не потому, что бэкапы были виноваты (при такой природе порчи они и не могли спасти), а потому что стало ясно: для персональных данных нужен не только регулярный дамп, но и хотя бы один более редкий «холодный» архив с длинным сроком хранения, на случай если проблема обнаружится не через 30 дней, а через год. Если у вас ротация бэкапов настроена по остаточному принципу — стоит свериться со статьёй про сроки хранения и ротацию бэкапов, прежде чем аналогичная история случится и у вас.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, есть ли в моей базе такая же проблема со скрытыми latin1-колонками?

Выполните SELECT table_name, column_name, character_set_name FROM information_schema.columns WHERE table_schema = 'ваша_база' AND character_set_name IS NOT NULL AND character_set_name != 'utf8mb4'; — запрос мгновенный даже на большой схеме и сразу покажет все колонки, где кодировка отличается от ожидаемой.

Почему MySQL не выдал явную ошибку при вставке кириллицы в latin1-колонку?

Потому что по умолчанию (без строгого sql_mode) конвертация символа, которого нет в целевой кодировке, — это предупреждение, а не ошибка: сервер молча подставляет ? и продолжает работу. Включённый STRICT_ALL_TABLES/STRICT_TRANS_TABLES превращает это же поведение в ошибку транзакции.

Можно ли было восстановить оригинальные имена, зная только испорченные строки с ??

Нет — как только байт заменён на 0x3F, исходная информация физически потеряна, никакой алгоритм не восстановит её из одного символа. Восстановление возможно только из независимого источника, где те же данные хранились отдельно и без прохождения через испорченную колонку.

Безопасно ли конвертировать charset колонки на живой боевой таблице через обычный ALTER TABLE?

На маленьких таблицах — да, ALTER TABLE ... CONVERT TO CHARACTER SET отрабатывает быстро. На таблицах от миллиона строк и выше он держит таблицу заблокированной на всё время операции, поэтому для прод-баз с активной нагрузкой лучше использовать gh-ost или pt-online-schema-change, которые переносят данные через теневую таблицу без долгой блокировки.

Как отличить «безобидную» мешанину кодировок (мохибаке) от реальной необратимой потери данных?

Если открыть строку в неправильной кодировке, но байты на диске остались целыми (просто отображаются как кракозябры), это обратимая ситуация — данные можно перечитать в правильной кодировке. Если же в самих байтах на диске уже лежит 0x3F вместо оригинала — это конвертация с потерей, и восстановить её нечем.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →