Автор оригинала: Pranoy Dutta, Cloudflare

Поддержка MySQL в Hyperdrive вышла в GA. Hyperdrive — это пулер соединений с базой данных для Cloudflare Workers, и сначала он умел работать только с Postgres. Год назад мы анонсировали бету с поддержкой протокола MySQL и с тех пор доводили обработку MySQL до той же надёжности, что и у Postgres.

За это время мы кое-что узнали о том, каково строить и эксплуатировать пулер соединений MySQL в сети Cloudflare. Оказалось, что разница между бетой и GA — это «20 багфиксов в одном пальто».

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

Что такое Hyperdrive

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

Установить соединение с SQL-базой дорого: нужно минимум пять сетевых round trip’ов. На serverless-платформе вроде Workers открывать соединение на каждый запрос было бы непрактично — и из-за добавленной задержки, и из-за стоимости ресурсов на каждое открытое соединение. MySQL здесь в лучшем положении, чем Postgres: он не заводит отдельный процесс ОС на каждое соединение. Но и у него при большом числе соединений типичный деплой упирается в ресурсы.

Подробности — в нашем посте с представлением Hyperdrive.

Путь к стабильности

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

Другой баг с кодировкой появился, когда мы добавили очистку соединения через 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, нужно сначала разобраться с его топологией и с тем, как он взаимодействует с CDN-кэшем. У каждого экземпляра Hyperdrive архитектура из двух узлов: Client и Endpoint. Client работает на той же машине, что и Worker пользователя, занимается локальным кэшированием (если оно включено) и при необходимости пересылает запросы на Endpoint. Endpoint держит пул соединений и стоит в дата-центре, ближайшем к исходной базе. Каждый узел использует CDN-кэш Cloudflare, локальный для дата-центра, в котором он запущен. Из этого важно следствие: если оба узла работают в одном дата-центре, у них общий кэш.

Hyperdrive состоит из узлов Client и Endpoint, оба используют кэш дата-центра
Hyperdrive состоит из узлов Client и Endpoint, оба используют кэш дата-центра
Когда Client и Endpoint Hyperdrive находятся в одном дата-центре, у них общий кэш
Когда Client и Endpoint Hyperdrive находятся в одном дата-центре, у них общий кэш

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

Клиент-серверный протокол MySQL

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

Hyperdrive Client согласует с Worker возможности MySQL и транслирует ответы на запросы, а Hyperdrive Endpoint общается с исходным сервером базы
Hyperdrive Client согласует с Worker возможности MySQL и транслирует ответы на запросы, а Hyperdrive Endpoint общается с исходным сервером базы

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

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

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

Для Hyperdrive согласование возможностей — штука хитрая: для исходной базы он клиент, а для Worker — сервер. Чтобы Hyperdrive мог разбирать сообщения исходного сервера MySQL, у него есть фиксированный набор флагов возможностей, который он сообщает исходной базе. Набор возможностей Hyperdrive может отличаться от набора клиента в Worker, поэтому все ответы исходной базы, закодированные по возможностям Hyperdrive, нужно транслировать в возможности, которых ожидает Worker.

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

С включённой CLIENT_DEPRECATE_EOF MySQL опускает пакет EOF после описаний столбцов и завершает набор результатов пакетом OK; без неё границы отмечают два пакета EOF
С включённой CLIENT_DEPRECATE_EOF MySQL опускает пакет EOF после описаний столбцов и завершает набор результатов пакетом OK; без неё границы отмечают два пакета EOF

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 байты, транслированные под возможности Worker.

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

Разберём два таких сценария.

Endpoint кэширует, Client читает

  • Endpoint обрабатывает запрос Q. Он получает ответ R от исходного сервера и кладёт в общий кэш байты результата R_hyperdrive (ответ, закодированный по возможностям Hyperdrive).

  • Client получает ещё один запрос Q, проверяет общий кэш и находит R_hyperdrive.

  • Client отвечает Worker’у байтами R_hyperdrive, не транслируя их в возможности Worker.

Вот схема этого потока:

Схема: Endpoint кэширует, Client читает
Схема: Endpoint кэширует, Client читает

Client кэширует, Endpoint читает

  • Client заканчивает обслуживать запрос Q и кладёт в кэш транслированные байты ответа R_worker.

  • Endpoint в это время обрабатывает запрос Q, проверяет кэш и находит R_worker.

  • Endpoint отправляет R_worker другому Client’у, тот пытается транслировать R_worker под возможности Worker и в итоге присылает преждевременный EOF или зависает.

Это показано здесь:

Схема: Client кэширует, Endpoint читает
Схема: Client кэширует, Endpoint читает

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

Надёжность — это функция

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

npx wrangler hyperdrive create HYPERDRIVE_NAME --connection-string="mysql://user:password@db-host.example.com:3306/dbname"

Комментарии (0)