Назад к блогу

Сбой кэширования пула соединений MySQL: внутри бага 0.001%, который сломал продакшен

Опубликовано 16 октября 2026 г.
Сбой кэширования пула соединений MySQL: внутри бага 0.001%, который сломал продакшен Создано с помощью ИИ

Коротко о главном

Разбираем редкий баг 0.001% в кэшировании пула соединений MySQL: причины, протокольные детали, обнаружение и исправление в вашем продакшене.

Ваш бэкенд возвращает пустые результирующие наборы для запросов, которые всегда возвращают данные. Уровень ошибок — 0.001% — примерно один из каждых 100 000 запросов. Телеметрия ничего не показывает. Трейсы ничего не показывают. И единственная зацепка — это то, что ошибка никогда не возникает, когда кэширование отключено.

Именно в такой ситуации оказалась команда Cloudflare Hyperdrive, когда выводила поддержку MySQL в своём пуле соединений в общую доступность. Найденный ими баг — мастер-класс по тому, как кэширование пула соединений MySQL может незаметно ломаться, когда два уровня расходятся во мнении о формате байтов, передаваемых по сети. Если вы используете пулируемый и кэшируемый слой базы данных для своего игрового бэкенда — а вам стоит, — этот режим отказа касается и вас.

В этом посте разбирается баг, детали протокола, которые сделали его возможным, способы обнаружения и устранения, а также то, что это значит для архитектуры вашего игрового бэкенда.

Зачем вообще нужно пулирование соединений

Установка рукопожатия TCP + TLS + MySQL с базой данных занимает минимум пять сетевых round-trip'ов. Если ваш игровой сервер открывает новое соединение на каждый запрос, вы платите эту цену за каждое чтение лидерборда, каждую загрузку сохранения, каждый запрос инвентаря.

Пулирование решает эту проблему, удерживая тёплые соединения открытыми и выдавая их по требованию. MySQL для этого дружелюбнее, чем Postgres, — Postgres порождает целый процесс ОС на каждое соединение, тогда как соединения MySQL сравнительно дёшевы, — но популярная игра с тысячами одновременных игроков всё равно может быстро исчерпать лимиты соединений.

Однако пулирование вводит и собственную проблему: утечку состояния сессии. Если запрос игрока A устанавливает переменную сессии, а игрок B получает то же соединение, возникает баг корректности. Пулеры решают это очисткой соединения между выдачами. MySQL предоставляет лёгкую команду именно для этого: COM_RESET_CONNECTION.

И именно здесь живёт первый тонкий баг этой истории.

Баг №1: Дрейф кодировки после сброса соединения

Команда Hyperdrive согласовала utf8 в стартовом рукопожатии. Это работало — пока игроки не начали использовать эмодзи в чате и именах персонажей, и данные не стали возвращаться искажёнными.

Проблема в том, что utf8 в MySQL — это устаревший алиас для utf8mb3, который поддерживает только 1–3 байта на кодовую точку Unicode. Полноценный UTF-8 (включая эмодзи, расширения CJK и большинство нелатинских письменностей, которые будет использовать ваша глобальная аудитория) требует до 4 байт на кодовую точку. Исправление — согласовывать вместо него utf8mb4.

Но вот ловушка, в которую попадаются почти все:

-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.

COM_RESET_CONNECTION не восстанавливает соединение до кодировки, согласованной при старте. Он сбрасывает её до кодировки по умолчанию на сервере — а на многих инстансах MySQL это до сих пор latin1. После первого же сброса каждое пулируемое соединение незаметно деградирует до кодировки уровня 1990-х.

Исправление: повторно применяйте конфигурацию сессии после каждого сброса, а не только при старте:

COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';

Практическое правило: после каждого сброса считайте соединение полностью неконфигурированным и заново устанавливайте всё, что вам важно. Всё, что вы задали один раз при подключении, со временем утечёт.

Баг №2: Баг пустого ответа с вероятностью 0.001%

Именно этот баг занял дни на поиск. Крупный клиент сообщил о небольшой, но устойчивой доле пустых ответов на запросы, где непустые ответы были гарантированы. Ошибка возникала примерно в 0.001% случаев — слишком редко, чтобы надёжно захватить сквозной трейс, и слишком часто, чтобы её игнорировать.

Команда прочесала телеметрию, увеличила частоту трейсов, искала сетевые аномалии. Ничего. Единственная зацепка, которая помогла: баг возникал только на инстансах с включённым кэшированием.

Архитектура, которая сделала это возможным

Hyperdrive использует разделённую топологию:

  • Клиентский узел — работает рядом с Worker, занимается локальным кэшированием и пересылает запросы дальше.
  • Endpoint-узел — работает в дата-центре, ближайшем к исходной базе данных, и держит фактический пул соединений.

Оба узла используют кэш CDN, локальный для своего дата-центра. Когда они работают в одном дата-центре, они делят один кэш. Это даёт выигрыш в производительности — но несёт скрытое предусловие: оба узла должны кэшировать абсолютно одинаковые байты для одного и того же ответа на запрос.

Расхождение протокола

В клиент-серверном протоколе MySQL есть фаза соединения, на которой клиент и сервер согласовывают возможности — опциональные функции вроде CLIENT_DEPRECATE_EOF, которая меняет способ завершения результирующих наборов:

  • Без CLIENT_DEPRECATE_EOF: пакет EOF после определения колонок, затем ещё один EOF в конце результирующего набора.
  • С ней: метаданные EOF опускаются, а финальный EOF превращается в OK-пакет.

Hyperdrive поддерживает эту возможность и согласовывает её с исходным сервером MySQL. Но клиентская библиотека на другой стороне — скажем, mysql2 для Node.js, которая сейчас не поддерживает CLIENT_DEPRECATE_EOF, — может не поддерживать. Поэтому Hyperdrive должен выполнять трансляцию: переписывать результирующие наборы, вставляя EOF-пакеты, преобразовывать OK-пакеты обратно в EOF, чтобы байты совпадали с тем, что ожидает драйвер Worker.

Следствие: байты, которые Hyperdrive записывает обратно клиенту, имеют другой формат, чем байты, полученные от исходного сервера.

Ошибочное допущение

Когда клиентское кэширование реализовывали для Postgres, команда заложила допущение: можно кэшировать ровно те байты, которые вы записали обратно клиенту. Для wire-протокола Postgres это было верно. Для MySQL — нет.

Теперь соберём отказ воедино:

  1. Endpoint обслуживает запрос Q и кэширует байты ответа в формате возможностей Hyperdrive в общий кэш.
  2. Клиент (в том же дата-центре) получает запрос Q, находит закэшированную запись и отдаёт её Worker без трансляции возможностей — потому что байты выглядят так, будто уже записаны для Worker.
  3. Драйвер Worker, который никогда не согласовывал CLIENT_DEPRECATE_EOF, неправильно разбирает результирующий набор, завершённый OK-пакетом, — получая пустые результаты, зависания или рассинхронизацию протокола.

Баг срабатывает только когда Client и Endpoint находятся в одном дата-центре и один читает кэш-запись другого. Поэтому он и был 0.001% и невидим для обычных трейсов. Это wire-эквивалент бага рассинхронизации — два компонента придерживаются разных допущений об общем состоянии. Если вы когда-нибудь отлаживали расхождение состояний между клиентским предсказанием и серверным авторитетом, форма этого отказа покажется знакомой; это тот же класс проблем, что описан в исправлении проблемы репликации RPC в Unreal Engine, ломающей ваши состояния, только на уровне wire-протокола базы данных.

Концептуальное исправление: кэшируйте в каноническом формате (том, что прислал источник) и транслируйте в возможности запрашивающего клиента в момент чтения. Никогда не кэшируйте байты после трансляции, если трансляция зависит от согласования с конкретным клиентом.

Runbook: обнаружение повреждения кэша в вашем стеке

Если вы используете пулер или кэширующий слой перед MySQL (или перед чем-то с stateful wire-протоколом), вот как применить этот постмортем.

Обнаружение

  • Соотносите ошибки с топологией. Баг проявлялся только на инстансах, где Client и Endpoint делили дата-центр и кэш. Логируйте, какой узел обслужил каждый ответ и пришёл ли он из кэша. Если уровень ошибок коррелирует с кэш-хитами, подозревайте несоответствие форматов между писателем и читателем.
  • Охотитесь на редкие, но устойчивые ошибки, а не на всплески. Уровень ошибок 0.001% никогда не сработает в алертах. Настройте в своём трекинге ошибок запрос на повторяющиеся вхождения одной и той же сигнатуры независимо от объёма. Устойчивость на низкой частоте — более сильный сигнал логического бага, чем коррелирующий с трафиком всплеск.
  • Намеренно тестируйте асимметрию возможностей. Подключите один тестовый клиент с CLIENT_DEPRECATE_EOF и один без, включите кэширование и долбите одинаковыми запросами. Большинство багов трансляции протокола всплывают в течение минут при такой асимметрии.

Исправление

  • Канонизируйте кэш-записи. Храните сырой формат ответа источника; транслируйте при чтении. Это правильное исправление для любого пулера, у клиентов которого гетерогенные возможности.
  • Повторно применяйте состояние сессии после сбросов. Как показано выше, COM_RESET_CONNECTION восстанавливает серверные значения по умолчанию, а не ваши согласованные настройки.
  • Версионируйте ключи кэша. Если вы меняете какое-либо поведение протокола (возможности, кодировку, обрамление ответов), включите версию формата в ключ кэша, чтобы старые записи не могли быть отданы новым кодом во время деплоя.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
    # format_version bumps whenever wire-format handling changes,
    # preventing stale-format entries from poisoning new deploys
    return sha256(f"{schema_version}:{format_version}:{query_normalized}")

Профилактика

  • Пишите тесты на соответствие wire-протоколу против двух клиентских библиотек, а не одной. Пробел с CLIENT_DEPRECATE_EOF был виден только потому, что популярный Node.js-драйвер его не поддерживал.
  • Не допускайте ничего об «одинаковых байтах» между уровнями. Любой компонент, переписывающий сообщения, не должен предполагать, что его выход побайтово идентичен входу.
  • Проводите нагрузочное тестирование с включённым кэшем с первого дня. Команда Hyperdrive месяцами проверяла надёжность, прежде чем всплыл баг, специфичный для кэша, — потому что пересечение функций (пулирование × кэширование × трансляция возможностей) — это место, где живут редкие баги.

Пять лучших практик для пулирования и кэширования в игровых бэкендах

  1. Всегда согласовывайте utf8mb4, никогда utf8. Имена игроков, чат и данные сохранений будут содержать 4-байтовые символы. Затем повторно применяйте SET NAMES utf8mb4 после каждого COM_RESET_CONNECTION, потому что сброс восстанавливает серверные значения по умолчанию, а не ваше рукопожатие.
  2. Кэшируйте канонические данные, а не данные в клиентском формате. Если какой-то уровень преобразует ответ перед отправкой, преобразованные байты никогда не должны попадать в общий кэш. Храните формат источника; транслируйте для каждого читателя.
  3. Добавляйте версии формата в ключи кэша. Изменение в одну строку — спасает от устаревших протокольных записей во время rolling-деплоев.
  4. Алертите на устойчивость ошибок, а не только на частоту. Повторяющаяся сигнатура с 0.001% без корреляции с трафиком — это логический баг. Всплеск, коррелирующий с трафиком, — проблема ёмкости. Обрабатывайте их по-разному.
  5. Тестируйте клиентов с несовпадающими возможностями. Корректность вашего пулера зависит от обработки каждой клиентской библиотеки, с которой может подключаться ваша команда (или пользователи вашего SDK).

Урок для архитектуры бэкенда

Самый глубокий урок здесь вообще не про MySQL. Он про многослойные системы с согласованием на уровне каждого пира. Как только вы вставляете промежуточный элемент — пулер, прокси, кэш, — который говорит на протоколе с обеих сторон, вы берёте на себя обязательство и точно транслировать, и кэшировать только форматы, универсально валидные. Postgres работал; MySQL сломался; в самой логике кэширования не было ничего неправильного, кроме одного допущения, унаследованного от другого протокола.

Для игровой студии это весомый аргумент против создания собственного слоя «пулер плюс кэш» вручную. Инженерные усилия, чтобы заставить работать счастливый путь, — может быть, неделя; инженерные усилия, чтобы правильно сделать редкие пути — согласование возможностей, дрейф кодировки, совместное размещение кэша, — это то, что потребовало от выделенной инфраструктурной команды дней расследования ради одного бага с вероятностью 0.001%. Собственное признание Cloudflare стоит повторить: разница между бетой и GA — это «всего 20 багфиксов в плаще».

Если вашему игровому бэкенду нужна надёжная персистентность без необходимости поддерживать пулер, схему инвалидации кэша и слой трансляции протокола, именно здесь Backend-as-a-Service оправдывает себя. horizOn обрабатывает привязанные к аккаунту Cloud Save с чтениями и записями, учитывающими ревизии, — ваш клиент разрешает конфликты, и вы пропускаете всё минное поле корректности «пул соединений плюс кэш» для данных игроков. В паре с лидербордами и удалённой конфигурацией проблемы с задержкой запросов, описанные в этом посте, в основном перестают быть вашей проблемой.

Следующий шаг: проверьте конфигурацию своего пулера уже сегодня. Проверьте три вещи — согласованную кодировку, повторно ли применяется состояние сессии после сбросов соединения и хранит ли ваш кэш канонические или транслированные байты. Если вы не можете ответить на все три вопроса за десять минут, вам есть над чем работать — а если вы предпочли бы не делать этого вовсе, взгляните на horizOn и посмотрите, покрывают ли его Cloud Save и лидерборды то, что ваш слой базы данных сейчас делает вручную.


Источник: Внутри Hyperdrive: как мы решили редкий баг кэширования MySQL