Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Войти
Дайджесты новостей
Архитектура высоконагруженного резервирования остатков на MySQL с механизмом SKIP LOCKED и пулом соединений ProxySQL в пиксель-арт стиле

Shopify масштабирует резервирование остатков: замена Redis на MySQL с SKIP LOCKED

Опыт команды Shopify по переводу резервирования складских остатков с Redis на MySQL 8 в условиях пиковых нагрузок Черной пятницы: изоляция READ COMMITTED против блокировок supremum, исключение дедлоков строгим порядком операций и устранение скрытого исчерпания пула соединений на уровне ProxySQL.

Shopify масштабирует резервирование остатков: замена Redis на MySQL с SKIP LOCKED

Во время распродаж Черной пятницы 2025 года Shopify зафиксировала пиковый оборот в 5,1 миллиона долларов в минуту при росте продаж на 11% год к году. За каждой транзакцией стоит проверка наличия товара и защита от перепродажи одной вещи двум покупателям. Исторически эта задача решалась счетчиками в оперативной памяти Redis. Однако при масштабировании платформы инженеры перенесли резервирование в реляционную СУБД MySQL 8. Этот кейс показывает, почему базы данных в памяти не всегда превосходят дисковые СУБД на высоких нагрузках.

Архитектурный разрыв двух хранилищ

В электронной коммерции процесс списания делится на две фазы:

  1. Резервирование (Reserve) — кратковременное удержание товара на несколько минут, пока покупатель вводит адрес и платежные данные.
  2. Окончательное списание (Claim) — постоянная фиксация покупки в главном реестре учета (inventory ledger) после подтверждения оплаты.

Раньше постоянный реестр хранился в MySQL, а оперативное резервирование выполнялось в Redis атомарными декрементами DECR. Между Redis и MySQL отсутствовала единая транзакционная граница ACID. Если сначала списывать товар в MySQL, а затем удалять резерв в Redis, любой сетевой сбой приводил к зависанию остатка и потере продаж. Если сначала очищать резерв в Redis, а затем фиксировать списание в MySQL, параллельный покупатель успевал перехватить товар, вызывая перепродажу (oversell).

Дополнительной проблемой стала мультилокационность: складской учет требует резервировать товар на конкретном региональном складе, способном выполнить доставку. Раздельная координация десятков локаций в Redis чрезмерно усложняла систему, поэтому Shopify объединила резервы и постоянный учет в MySQL 8.

Модель ограниченного пула и SKIP LOCKED

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

При покупке трех единиц транзакция выполняет запрос:

SELECT id FROM reservation_units
WHERE shop_id = 100 AND inventory_item_id = 500 AND inventory_group_id = 10
LIMIT 3
FOR UPDATE SKIP LOCKED;

Конструкция SKIP LOCKED позволяет пропускать строки, занятые другими покупателями, и мгновенно захватывать свободные без ожидания. Чтобы избежать разрастания таблицы (например, 50 000 товаров на 10 складах породили бы 500 000 строк), размер пула ограничен 1000 записями на комбинацию товара и склада. Этого запаса хватает для пиковых распродаж, а сканирование остается быстрым. При опустошении пула транзакция запускает инлайн-пополнение из главного реестра под эксклюзивным мьютексом, защищающим от эффекта «громоподобного стада» (thundering herd).

Четыре барьера в ядре InnoDB

Внедрение пула со SKIP LOCKED потребовало устранения четырех узких мест в движке InnoDB:

  1. Составной первичный ключ против двойных блокировок. Изначально таблица использовала автоинкрементный ключ id, а поиск шел по вторичному индексу. Движок InnoDB блокировал запись во вторичном индексе и строку в кластерном ключе — по две блокировки на единицу. Замена первичного ключа на составной (shop_id, inventory_item_id, inventory_group_id, id) переместила условия фильтрации в кластерный индекс, сократив число блокировок вдвое.
  2. Изоляция READ COMMITTED против блокировок supremum. При стандартном уровне REPEATABLE READ пустой результат SKIP LOCKED накладывал gap-блокировку на псевдозапись supremum, обозначающую конец диапазона. Это блокировало операцию INSERT при пополнении пула и вызывало дедлоки. Перевод транзакций резервирования на READ COMMITTED отключил блокировки диапазонов.
  3. Единый порядок блокировок таблиц. Резервирование вставляло запись в reserved_quantities и удаляло из reservation_units, а списание удаляло из reserved_quantities. Разнонаправленный захват таблиц приводил к дедлокам. Порядок стандартизировали: резервирование всегда делает DELETE из единиц и INSERT в резервы, а списание работает только с таблицей резервов.
  4. Пакетная обработка мультитоварных корзин. Заказы из нескольких позиций требовали множества сетевых обращений. Объединение запросов через UNION ALL позволило блокировать все позиции корзины за один сетевой раундтрип.

Сравнение архитектурных подходов

ХарактеристикаСчетчик в RedisОграниченный пул в MySQL 8
АтомарностьРазрыв между памятью и дискомЕдиная транзакция ACID с реестром
ПараллелизмМонопольное изменение одного ключаРазбор свободных строк через SKIP LOCKED
ЛокацииСложная ручная координация ключейЕстественная привязка к складу в SQL
КонкуренцияЕдиная точка отказа популярного товараРаспределенный пул из 1000 строк
Сбои сетиРиск оверселла или зависшей брониГарантированная целостность транзакции

Парадокс соединений ProxySQL

После запуска новой модели мониторинг выявил аномалию: при низкой загрузке процессоров баз данных (менее 20% CPU) и быстром выполнении запросов (до 2 мс) клиенты выстраивались в очереди.

Причина обнаружилась на прокси-слое соединений ProxySQL. Разработчики разметили SQL-запросы комментариями /* conn_tag:checkout_completion */. Анализ показал, что сторонние модули чекаута (платежные шлюзы, антифрод, расчет налогов) открывали транзакцию базы данных в самом начале оформления заказа. Соединение оставалось занятым сотни миллисекунд во время ожидания внешних API, исчерпывая лимиты пула ProxySQL. Вынос внешних сетевых вызовов за пределы транзакций восстановил пропускную способность.

Миграция и практические выводы

Перевод трафика проходил через теневой режим (shadow mode): двойная запись в Redis и MySQL позволила сверить консистентность данных без влияния на пользователей перед переключением источника истины.

Чеклист для проектирования конкурентного резервирования:

  • Определите границы транзакций: время жизни брони, условия списания и единое место хранения баланса.
  • Используйте составные первичные ключи для объединения фильтров выборки с кластерным индексом InnoDB.
  • Выбирайте уровень изоляции READ COMMITTED для очередей со SKIP LOCKED, предотвращая блокировки интервалов supremum.
  • Стандартизируйте порядок операций DELETE и INSERT во всех сценариях работы с общими таблицами.
  • Контролируйте длительность удержания соединений, исключая внешние сетевые вызовы из контекста открытых транзакций базы данных.

Модель ограниченного пула в MySQL 8 демонстрирует, что устранение межсистемной рассинхронизации и дисциплина транзакций важнее номинальной скорости кэша в оперативной памяти.