Shopify масштабирует резервирование остатков: замена Redis на MySQL с SKIP LOCKED
Во время распродаж Черной пятницы 2025 года Shopify зафиксировала пиковый оборот в 5,1 миллиона долларов в минуту при росте продаж на 11% год к году. За каждой транзакцией стоит проверка наличия товара и защита от перепродажи одной вещи двум покупателям. Исторически эта задача решалась счетчиками в оперативной памяти Redis. Однако при масштабировании платформы инженеры перенесли резервирование в реляционную СУБД MySQL 8. Этот кейс показывает, почему базы данных в памяти не всегда превосходят дисковые СУБД на высоких нагрузках.
Архитектурный разрыв двух хранилищ
В электронной коммерции процесс списания делится на две фазы:
- Резервирование (Reserve) — кратковременное удержание товара на несколько минут, пока покупатель вводит адрес и платежные данные.
- Окончательное списание (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:
- Составной первичный ключ против двойных блокировок. Изначально таблица использовала автоинкрементный ключ
id, а поиск шел по вторичному индексу. Движок InnoDB блокировал запись во вторичном индексе и строку в кластерном ключе — по две блокировки на единицу. Замена первичного ключа на составной(shop_id, inventory_item_id, inventory_group_id, id)переместила условия фильтрации в кластерный индекс, сократив число блокировок вдвое. - Изоляция READ COMMITTED против блокировок supremum. При стандартном уровне
REPEATABLE READпустой результатSKIP LOCKEDнакладывал gap-блокировку на псевдозапись supremum, обозначающую конец диапазона. Это блокировало операциюINSERTпри пополнении пула и вызывало дедлоки. Перевод транзакций резервирования наREAD COMMITTEDотключил блокировки диапазонов. - Единый порядок блокировок таблиц. Резервирование вставляло запись в
reserved_quantitiesи удаляло изreservation_units, а списание удаляло изreserved_quantities. Разнонаправленный захват таблиц приводил к дедлокам. Порядок стандартизировали: резервирование всегда делаетDELETEиз единиц иINSERTв резервы, а списание работает только с таблицей резервов. - Пакетная обработка мультитоварных корзин. Заказы из нескольких позиций требовали множества сетевых обращений. Объединение запросов через
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 демонстрирует, что устранение межсистемной рассинхронизации и дисциплина транзакций важнее номинальной скорости кэша в оперативной памяти.
