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

Поддержка MySQL в Hyperdrive теперь общедоступна (GA). Hyperdrive, пул подключений к базам данных для Workers от Cloudflare, изначально поддерживал только Postgres. С момента объявления беты поддержки протокола MySQL в прошлом году мы работали над тем, чтобы обработка MySQL была так же надёжна, как и поддержка Postgres.

Мы извлекли уроки из разработки и эксплуатации пулера подключений MySQL в сети Cloudflare. Оказалось, что разница между бетой и GA — это всего лишь 20 исправлений ошибок в плаще.

Во время беты нашей целью было подтвердить, что использование в реальных условиях достигает целевого показателя доступности в 99,99%. Мы знали о пробелах в надёжности, поскольку отслеживаем, насколько часто клиентские подключения от Worker завершаются ошибкой, связанной с Hyperdrive. Кроме того, у нас была обратная связь от клиентов в бете, которая привела к обнаружению редкой и тонкой ошибки в продакшн-среде. Чтобы объяснить сложность этой конкретной ошибки, сначала нужно рассказать, как работает Hyperdrive.

Как работает Hyperdrive

Hyperdrive — это распределённый пул подключений к базе данных, который связывает Cloudflare Workers с SQL-базами данных. Hyperdrive держит тёплые SQL-подключения открытыми в нашей глобальной сети и разделяет эти подключения между множеством запросов Worker. Помимо пуллинга подключений, Hyperdrive может кэшировать распространённые запросы в нашем глобальном CDN-кэше, уменьшая задержки и нагрузку на исходную базу данных.

Установление подключения к SQL-базе данных дорогостоящие, оно занимает минимум пять сетевых круговых обменов. На серверлесс-платформе вроде Workers установление подключения для каждого запроса было бы непрактично — как из‑за добавляемой задержки к каждому запросу, так и из‑за того, что большое число открытых подключений может приводить к высоким ресурсным затратам на каждое подключение. Хотя MySQL здесь справляется лучше, чем Postgres (MySQL не использует отдельный процесс ОС на каждое подключение), типичное развертывание всё равно может столкнуться с проблемами ресурсов при большом числе подключений.

Некоторые из проблем, с которыми мы столкнулись, были небольшими и легко исправлялись после того, как о них сообщили. Одной из первых проблем в бете было то, что emoji, записанные в базу, возвращались некорректно. Это была ошибка, связанная с кодировкой символов. Hyperdrive согласовывает кодировку для подключения в процессе начального рукопожатия, и мы согласовывали utf8 как кодировку. Как оказалось, utf8 — устаревший псевдоним для utf8mb3, который не покрывает весь UTF-8: он поддерживает только 1–3 байта на кодовую точку Unicode, тогда как полный UTF-8 допускает до 4 байт на кодовую точку. Чтобы поддерживать все символы UTF-8, нам нужно было изменить рукопожатие на использование utf8mb4, что исправило проблему с emoji.

Ещё одна ошибка, связанная с кодировкой, возникла, когда мы добавили очистку соединения через COM_RESET_CONNECTION. Это лёгкая команда MySQL для сброса состояния сессии в данном подключении, что как раз необходимо Hyperdrive, чтобы не допускать утечек состояния уровня сессии между выдачами подключений. Но добавление этого шага очистки снова вызвало проблемы с кодировкой! Оказалось, что COM_RESET_CONNECTION не восстанавливает состояние кодировки до той, которую мы согласовали при установке подключения; он сбрасывает кодировку до значения по умолчанию на сервере. Это значит, что после согласования utf8mb4 на инстансе MySQL с кодировкой по умолчанию latin1, выполняя COM_RESET_CONNECTION, подключение получает кодировку latin1. Исправление — всегда выполнять команду установки кодировки в utf8mb4 после сброса подключения.

Во время беты наш клиент с самым большим объёмом MySQL-трафика сообщил о небольшой, но стабильной частоте пустых ответов на запросы, которые должны были возвращать непустые результаты. Мы начали расследование, просматривая всю телеметрию и логику пользователя. Затем мы увеличили частоту трассирования для этого экземпляра Hyperdrive, надеясь увидеть поток выполнения, приводящий к ошибке. Мы искали сетевые проблемы, которые могли бы объяснить поведение, — но ничего не давало объяснения. Эта ошибка происходила примерно в 0,001% случаев, что сильно усложняло поиск релевантных сквозных трасс.

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

Кэширование Hyperdrive

Чтобы понять кэширование Hyperdrive, нужно сначала рассказать о топологии Hyperdrive и о том, как она взаимодействует с CDN-кэшем. Каждый экземпляр Hyperdrive имеет разделённую архитектуру из двух узлов: Client и Endpoint. Узел Client запускается на той же машине, что и пользовательский Worker, обрабатывает локальное кэширование (если включено) и при необходимости пересылает запросы на Endpoint. Узел Endpoint содержит пул подключений и размещается в дата‑центре, ближайшем к исходной базе данных. Каждый узел использует CDN-кэш Cloudflare, локальный для дата‑центра, в котором он работает. Важно, что это означает: если оба узла работают в одном дата‑центре, они будут использовать общий кэш.

Совместное использование кэша между Client и Endpoint в целом улучшает производительность. Мы используем хеш содержимого кадров сетевого протокола запроса с небольшой нормализацией и сохраняем результирующие байты в кэше. Если Hyperdrive Client, работающий в том же дата‑центре, что и его Endpoint, получает запрос, и этот Endpoint недавно кэшировал результаты того же запроса, Client может отдать ответ непосредственно из общего кэша дата‑центра. Для корректной работы схема требует, чтобы одни и те же байты соответствовали одному и тому же ответу, независимо от того, кем они были занесены в кэш — Client или Endpoint.

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

Протокол клиент/сервер MySQL

Протокол описывает, как MySQL‑клиент общается с MySQL‑сервером. Это может быть несколько запутанно в случае пуллинга подключений, поскольку у нас есть два компонента, действующие как клиенты, и два, действующие как серверы. Первый клиент — это Worker, использующий библиотеку MySQL-клиента, и он подключается к первому компоненту, выступающему как сервер: Hyperdrive Client. Hyperdrive Client подключается к Hyperdrive Endpoint, который действует как MySQL‑клиент, подключающийся к реальному MySQL‑серверу.

Протокол начинается с Фазы соединения, где MySQL‑клиент и MySQL‑сервер договариваются о совместном методе аутентификации и возможностях. После установления соединения клиент и сервер переходят в Фазу команд, где клиент отправляет команды (например, SQL‑выражения) для выполнения, а сервер отвечает и ждёт следующей команды.

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

Эта договорённость возможностей может быть сложной для Hyperdrive, поскольку Hyperdrive выступает клиентом по отношению к исходной базе и сервером по отношению к Worker. Чтобы Hyperdrive мог корректно разбирать сообщения от исходного MySQL‑сервера, Hyperdrive использует фиксированный набор флагов возможностей при общении с origin‑базой. Поскольку набор возможностей Hyperdrive может отличаться от набора возможностей клиента‑Worker, Hyperdrive должен переводить все ответы origin, закодированные с его возможностями, в те возможности, которые ожидает Worker.

При попытках отладки мы обнаружили, что одна популярная MySQL‑библиотека для Node.js (mysql2) в текущей версии не поддерживает capability CLIENT_DEPRECATE_EOF, которая изменяет способ завершения наборов результатов. Без этой возможности сервер посылает пакет EOF между описаниями колонок и строками, а затем ещё один EOF в конце набора результатов. Когда же CLIENT_DEPRECATE_EOF согласована, метаданный EOF‑пакет опускается, а финальный EOF‑пакет заменяется пакетом OK. MySQL серверы начиная с версии 5.7.5 поддерживают эту возможность, но поведение с OK‑пакетом используется только если клиент её согласовал.

Хотя Hyperdrive поддерживает CLIENT_DEPRECATE_EOF, возможно, MySQL‑драйвер клиента Worker — нет. Чтобы это учесть, Hyperdrive подстраивает своё поведение под возможности, которые ожидает драйвер клиента Worker. Это означает переписывание наборов результатов, чтобы включать EOF‑пакеты, и преобразование всех OK‑пакетов в EOF‑пакеты, когда это требуется.

После дней изучения телеметрии, документации и кода Hyperdrive всё встало на свои места. Мы, наконец, нашли последний недостающий кусок: ошибочное предположение в коде кэширования Hyperdrive Client. При реализации кэширования для Postgres мы заложили допущение, что можно кэшировать ровно те байты, которые мы отдали Worker. Для проводного протокола Postgres это было разумно, но для MySQL это уже не верно из‑за рассогласования возможностей. Если Client узел кэширует ровно те байты, которые он возвращает Worker, эти байты имеют формат, отличающийся от того, который ожидает Endpoint. Дело в том, что Endpoint всегда работает с возможностями Hyperdrive, в то время как Client возвращает байты, переведённые под возможности Worker.

Собрав все подсказки воедино, мы поняли сценарии, в которых Hyperdrive может вернуть MySQL‑ответ, закодированный с неверными возможностями, что приводит к неожиданным пустым результатам или зависаниям. Баг проявляется, когда и Client, и Endpoint находятся в одном дата‑центре, используют общий кэш, и когда один из них читает значение, закэшированное другим.

Примеры проблемных сценариев

Два примера таких сценариев выглядят так:

  1. Endpoint в процессе обслуживания запроса Q получает от origin ответ R и кэширует результатные байты R_hyperdrive (ответ, закодированный возможностями Hyperdrive) в общий кэш.
  2. Client получает другой запрос для Q, проверяет общий кэш и находит R_hyperdrive.
  3. Client отвечает Worker R_hyperdrive без перевода в возможности Worker.
  4. Client завершает обслуживание запроса Q и записывает в кэш переведённые байты ответа R_worker.
  5. Endpoint в процессе обслуживания запроса Q проверяет кэш и находит R_worker.
  6. Endpoint отправляет R_worker другому Client, и этот Client пытается перевести R_worker в возможности Worker, в результате чего отправляется преждевременный EOF или происходит зависание.

Как только мы обнаружили такой режим отказа, исправление оказалось простым. Теперь мы всегда записываем в кэш каноническую версию байтов результата запроса, закодированную с использованием возможностей Hyperdrive, независимо от того, какой узел их сохранил. После выпуска этого изменения проблема с пустыми строками исчезла, и наш показатель успешных клиентских подключений достиг 99,99%.

Поддержка MySQL в Hyperdrive доступна уже сегодня! Создайте Hyperdrive к существующей базе данных, совместимой с MySQL, через панель управления или wrangler, или подключитесь к новой базе PlanetScale. Если у вас возникнут проблемы или вопросы, обращайтесь в наш Discord.

Подпишитесь, чтобы получать уведомления о новых публикациях.