Оригинал в блоге
Впервые опубликовано Shopify Engineering 12 мая 2026
TL;DR
Shopify обрабатывает более 14% всех транзакций в сфере электронной коммерции в США. Чтобы последний оставшийся товар не был продан двум покупателям одновременно, каждое оформление заказа защищено системой резервирования. Когда покупатель переходит к оплате, Shopify резервирует выбранные товары на несколько минут. Если платеж проходит успешно, Shopify окончательно списывает их из системы складского учета продавца.
Годами эти два шага выполнялись в разных базах данных. Redis отслеживал, сколько единиц товара покупатели временно зарезервировали, а MySQL вел учет реального количества товаров на складе продавца. Так как ни одна транзакция не могла атомарно обновить обе базы, сбой между двумя записями приводил к рассинхронизации: система либо скрывала товар, который на самом деле был доступен, либо позволяла продать то, чего на складе уже не осталось.
Shopify перенес резервирование из Redis в MySQL, и теперь обе операции
выполняются в рамках одной транзакции. Вместо одной строки на товар со счетчиком
доступного остатка система использует ограниченный пул строк, где каждая строка
представляет одну единицу товара, доступную для бронирования. Благодаря
механизму SKIP LOCKED в MySQL 8 параллельные покупатели могут захватывать
разные строки, не дожидаясь друг друга, а процесс пополнения не дает пулу
разрастаться до масштабов всего складского запаса продавца. Переработанная
система успешно выдержала Черную пятницу 2025 года, когда продажи на пике
достигли рекордных 5,1 миллиона долларов в минуту.
Этот пример интересен, потому что в нем показан прием "одна строка на место", знакомый по системам бронирования билетов: для каждого места выделяется отдельная строка, благодаря чему покупатели конкурируют за разные строки, и этот подход доказал свою эффективность в реальном продакшене под колоссальной нагрузкой. Кроме того, эта история наглядно показывает, что реляционная база данных с правильно спроектированной схемой таблиц способна выдерживать нагрузки, ради которых многие инженеры по привычке внедряют специализированную инфраструктуру. Оригинальная статья также заслуживает прочтения.
Проблема
Как работает защита от перепродажи
Представьте распродажу: осталась всего одна толстовка, и два покупателя одновременно нажимают кнопку оплаты. Shopify должен гарантировать, что купить ее сможет только один из них.
В этом и заключается задача механизма защиты от перепродажи (oversell protection). Когда покупатель начинает оплату, система резервирует товары на несколько минут, чтобы никто другой не мог их забрать, пока обрабатывается платеж. Если платеж проходит успешно, Shopify окончательно вычитает эти позиции из складского остатка продавца. Если платеж завершается ошибкой или истекает таймаут, система снимает бронь и снова делает товары доступными для покупки.
Ошибка в любую сторону оборачивается прямыми убытками для продавца. Если два покупателя оплатят одну и ту же последнюю толстовку, один из заказов придется отменять. Если же Shopify скроет толстовку, которая все еще лежит на складе, продавец упустит выгоду. Этот сервис находится на критическом пути каждого оформления заказа с контролем остатков, поэтому при масштабах Shopify даже редкие ошибки быстро становятся проблемой.
Почему пришлось отказаться от Redis
Многие годы Shopify хранил временные брони в Redis, выделяя по одному счетчику на каждый товар. Резервирование или возврат единиц товара обновляли этот счетчик. А так как Redis выполняет команды строго по очереди, два покупателя не могли одновременно зарезервировать одну и ту же последнюю единицу. Как инструмент управления конкурентным доступом Redis прекрасно справлялся.
Проблема заключалась в том, что основной реестр складских остатков продавца хранился в MySQL. После успешной оплаты Shopify требовалось выполнить два действия: списать купленные единицы в MySQL и снять временную бронь в Redis. Эти операции записи происходили в разных базах данных, поэтому их невозможно было объединить в одну транзакцию.
Представим, что Shopify сначала обновляет MySQL, а затем аварийно завершает работу до того, как успевает снять бронь в Redis. Покупка совершена, но Redis по-прежнему считает эти единицы зарезервированными, поэтому Shopify скрывает товар, который на самом деле доступен. Если поменять порядок на противоположный, возникнет обратная проблема: Shopify снимает бронь, падает до обновления MySQL и снова делает единицы товара доступными для покупки, хотя они уже были проданы.
Изменение порядка операций лишь определяет, какой именно сценарий сбоя произойдет. Оно никак не устраняет опасное временное окно между двумя записями.
Стандартный набор решений, таких как двухфазный коммит (two-phase commit, 2PC) или паттерн Transactional Outbox, также не устранял это окно. Двухфазный коммит требовал бы от обеих баз данных участия в едином протоколе координации, что невозможно для Redis, поскольку он не поддерживает подобные протоколы. А паттерн Outbox или фоновый процесс сверки со временем выявили бы и исправили расхождение, но до этого момента данные об остатках оставались бы неверными. В разгар распродажи даже кратковременное расхождение данных может привести к повторной продаже одной и той же единицы товара.
Решение
Одна строка на каждую доступную единицу товара
Перенос резервирования в MySQL решал проблему транзакционности, но ранее Shopify уже пробовал очевидный вариант архитектуры в MySQL, и эта попытка провалилась: одна строка на товар со счетчиком количества.
-- Упрощенная схема первой неудачной попытки
UPDATE reservations
SET available = available - 1
WHERE inventory_item_id = 13 AND available >= 1;Каждое оформление заказа на популярный товар пыталось обновить одну и ту же строку. Движок хранения InnoDB выдает блокировку строки только одной транзакции в каждый момент времени, поэтому все остальные покупатели вынуждены ждать. Во время распродажи тысячи запросов на покупку одного и того же товара упирались в одну блокировку, вынуждая базу данных выстраивать их в очередь и обрабатывать строго друг за другом, из-за чего скорость работы сильно падала.
Новая схема распределяет эту конкуренцию по пулу строк. Каждая строка в таблице
reservation_units представляет одну единицу товара, которую можно
зарезервировать в данный момент. Чтобы зарезервировать три толстовки, Shopify
выбирает и блокирует три свободные строки, удаляет их из пула и создает одну
активную запись о брони в таблице reserved_quantities - все в рамках одной
транзакции.
Главная особенность решения строится на конструкции SELECT ... FOR UPDATE SKIP LOCKED в MySQL 8, объединяющей SELECT ... FOR UPDATE и указание SKIP LOCKED. Обычный запрос FOR UPDATE встает в ожидание, натолкнувшись на строку,
заблокированную другой транзакцией. Конструкция SKIP LOCKED указывает MySQL
пропускать заблокированные строки и продолжать поиск свободных. Благодаря этому
два покупателя, заказывающие один и тот же товар, блокируют разные строки и
завершают транзакции параллельно, вместо того чтобы вставать в очередь к единому
счетчику.
Это тот же прием "одна строка на место", что и в нашем разборе Ticketmaster. Вместо того чтобы заставлять всех покупателей бороться за одну строку с количеством, мы даем им отдельные строки для захвата.
BEGIN;
SELECT id FROM reservation_units
WHERE shop_id = 1
AND inventory_item_id = 13
AND inventory_group_id = 7
LIMIT 3
FOR UPDATE SKIP LOCKED;
-- Удаляем выбранные единицы из пула доступных
-- и создаем одну активную запись о бронировании в reserved_quantities.
COMMIT;Ограниченный пул строк
С ростом масштаба у этой схемы возникает очевидная трудность: для одного популярного товара пришлось бы создавать сотни тысяч преимущественно простаивающих строк, а по мере разрастания таблицы сканирование работало бы все медленнее. С учетом того, что Shopify обслуживает миллионы различных товаров, выделение отдельной строки под каждую физическую единицу на складе не самый оптимальный подход.
Поэтому Shopify ограничил размер пула: для любого товара создается максимум 1000 строк для бронирования, а фоновый процесс пополнения следит за тем, чтобы пул оставался заполненным.
При сильном наплыве покупателей пул может опустеть раньше, чем фоновый процесс успеет его пополнить. В этом случае запрос на бронирование выполняет синхронное пополнение пула (inline refill) прямо в ходе выполнения, после чего повторяет попытку. Блокировка гарантирует, что пополнение выполняет только одна транзакция, пока остальные запросы на этот же товар ожидают, что предотвращает дублирование создаваемого запаса. Когда такое происходит, запрос, инициировавший пополнение, выполняется дольше, однако Shopify не отклоняет заказ покупателя, пока сервис складского учета подтверждает наличие товара.
В оригинальной публикации не раскрывается, какую дополнительную задержку привносит этот механизм и как Shopify обрабатывает заказы, размер которых превышает размер пула в 1000 строк.
Оптимизация работы блокировок
Базовая схема устранила единую горячую строку. Однако для надежной работы под реальной нагрузкой потребовалось внести еще три изменения в то, как InnoDB захватывает блокировки.
В первом прототипе использовался обычный автоинкрементный первичный ключ, а запросы бронирования искали данные через вторичный индекс. InnoDB упорядочивает саму таблицу по первичному ключу, поэтому блокирующий запрос через вторичный индекс блокирует одновременно и запись во вторичном индексе, и саму строку в таблице.
Поэтому Shopify включил столбцы, по которым фильтрует запрос резервирования, прямо в состав составного первичного ключа:
PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id, id)Теперь запрос сканирует и блокирует строки таблицы напрямую. Это означает одну блокировку на каждую зарезервированную единицу вместо двух, что вдвое снижает накладные расходы на блокировки на самом критическом пути.
Вторая проблема проявлялась, когда пул оказывался пустым. На стандартном для
MySQL уровне изоляции REPEATABLE READ блокирующий запрос блокирует не только
найденные строки, но и интервалы между ними (gap locks), не позволяя другой
транзакции вставить новую строку в этот диапазон во время выполнения запроса.
Когда пул пуст, строк для блокировки нет, поэтому запрос накладывает блокировку на интервал в самом конце индекса. Но именно в это место процесс пополнения должен вставлять новые строки. Из-за этого запросы бронирования могли блокировать пополнение пула или приводить к взаимным блокировкам (deadlocks).
Shopify перевел эти транзакции на уровень изоляции READ COMMITTED, при котором
сканирование не берет подобные блокировки интервалов (gap locks). Это был первый
случай за всю историю компании, когда в кодовой базе Shopify применили отличный
от дефолтного уровень изоляции.
Последней проблемой стали классические взаимоблокировки из-за несовпадающего порядка обращений: разные пути выполнения обращались к таблицам резервирования в разной последовательности, и две транзакции могли удерживать блокировки, необходимые друг другу.
Решение заключалось в том, чтобы заставить все сценарии захватывать блокировки в
строго одинаковой последовательности. Теперь резервирование сначала удаляет
строки из reservation_units, и только затем вставляет запись о бронировании в
reserved_quantities, строго соблюдая порядок из примера выше. Когда каждая
транзакция следует единому порядку, циклическое ожидание полностью исключается.
Что происходит, если оплата так и не завершилась
Оригинальная статья не касается этой части системы, хотя это один из самых интересных инженерных аспектов. Сам факт резервирования товара пользователем еще не гарантирует, что покупка действительно состоится. После резервирования возможны три состояния:
- Успешная оплата: Shopify подтверждает списание товаров и окончательно вычитает их из системы складского учета продавца.
- Ошибка оплаты или выход пользователя из оформления заказа: система получает явный сигнал и снимает бронь.
- Истечение срока резервирования: товар не удерживается бесконечно, большинство сервисов устанавливают таймер около 10 минут, и по его истечении бронь должна сниматься автоматически.
В оригинальной публикации разобран только первый сценарий и не упоминаются два оставшихся. Второй сценарий достаточно прост, поскольку ошибка при обработке платежа сама по себе инициирует снятие брони. А вот третий сценарий представляет наибольший интерес. Закрытая крышка ноутбука не посылает системе никакого сигнала, и если ничто не вернет эти единицы обратно, в разгар распродажи товар будет числиться распроданным, хотя физически останется на складе.
Компонент, отвечающий за возврат, не может находиться в браузере покупателя или на сервере, принявшем исходный запрос: к моменту истечения таймера ни браузер, ни этот сервер могут уже не работать. Срок действия должен храниться в самих данных, а реагировать на его истечение должен непрерывно работающий сервис. В блоге не уточняется, как это было устроено, поэтому ниже мы делимся нашими наиболее вероятными предположениями для каждого из двух поколений архитектуры.
Истечение срока бронирования в Redis (старая система)
Для старой системы на Redis напрашивается очевидное решение - использовать TTL. Redis без проблем удаляет ключ через заданное количество секунд после записи, что выглядит ровно тем поведением, которое требуется для истекающей брони. Однако Shopify хранил один счетчик на товар, инкрементируя и декрементируя его по мере создания и снятия броней. Установка TTL на такой ключ удалила бы весь счетчик целиком, а не бронь конкретного покупателя. Чтобы использовать TTL, пришлось бы создавать отдельный ключ под каждое бронирование, что является весьма распространенным паттерном. Именно так работают распределенные блокировки в Redis, и именно такую схему мы рекомендуем в разборе Ticketmaster. Для блокировок это правильный инструмент, поскольку блокировка защищает один конкретный ресурс. Ключ представляет собой конкретное посадочное место, следующий покупатель проверяет доступность попыткой занять именно этот ключ, а сам факт существования ключа и есть полное состояние ресурса. Когда ключ удаляется, очистка считается завершенной.
Но со складскими запасами Shopify все обстоит иначе. Никто не резервирует
толстовку номер 37: покупатель резервирует любую из 500 одинаковых толстовок,
поэтому доступность товара определяется как совокупное число по всем активным
броням. При этом в Redis нет быстрого способа получить эту сумму из множества
индивидуальных ключей бронирования. Пространство ключей представляет собой
плоскую хеш-таблицу, поэтому поиск всех ключей вида hold:* требует полного
сканирования базы со сложностью O(n) по всем ключам. Выполнять такое
сканирование при каждой попытке резервирования невозможно. Именно поэтому
изначально и появился единый счетчик. Но при использовании счетчика истечение
срока брони должно вызывать запись в другое место - операцию INCR,
возвращающую товар в пул доступных, а механизм истечения срока в Redis не умеет
выполнять side-эффекты: он просто удаляет ключ.
Redis позволяет замаскировать эту проблему с помощью механизма уведомлений о
пространстве ключей (keyspace
notifications),
публикуя событие expired, которое фоновый воркер может преобразовать в команду
INCR. Однако эти уведомления работают по модели Pub/Sub без гарантий доставки
(fire-and-forget). Если воркер потерял соединение в момент публикации события
или упал после его прочтения до выполнения записи, событие теряется
безвозвратно.
Поэтому вместо того чтобы позволять Redis удалять запись, надежнее сохранять ее до тех пор, пока она не будет гарантированно обработана. Сортированное множество (sorted set) отлично подходит для этой цели. Каждая бронь сохраняется в нем со значением оценки (score), равным времени окончания срока действия, поэтому поиск истекших броней сводится к запросу всех элементов со значением оценки меньше текущего времени. Фоновые воркеры (или cron-задачи) непрерывно извлекают истекщие записи, а атомарный скрипт за один шаг удаляет каждую бронь и восстанавливает счетчик, исключая ситуацию, когда два параллельных процесса пытаются обработать одну и ту же бронь. Минус заключается в зависимости от частоты запуска фоновых воркеров. Для большинства обычных товаров эту задержку никто не заметит, однако во время распродажи даже несколько секунд промедления с возвратом зависших броней покажут статус "распродано" тем покупателям, которые были готовы оплатить заказ.
Другой подход - выполнять возврат непосредственно в рамках самого процесса резервирования. Когда пользователь бронирует товар, тот же атомарный скрипт сначала просматривает начало сортированного множества, возвращает несколько просроченных броней и лишь затем оформляет новую бронь.
# Атомарный скрипт, выполняемый при каждом бронировании
ZRANGEBYSCORE holds:hoodie -inf <now> LIMIT 0 10 # Находим до 10 просроченных броней
ZREM holds:hoodie res:41 # Удаляем их из множества
INCR available:hoodie # И возвращаем единицы в доступный остаток
GET available:hoodie # Проверяем наличие единицы, иначе останавливаемся
DECR available:hoodie # Забираем новую единицу
ZADD holds:hoodie <now + 600> res:87 # И планируем время истечения брониЛимит на количество броней, возвращаемых за один вызов скрипта, играет ключевую роль: во время выполнения скрипта Redis блокирует все остальные команды, поэтому попытка синхронно обработать слишком большой накопившийся хвост заблокирует всех последующих покупателей. Кроме того, если поток заказов на товар прекратится, новые брони перестанут поступать, поэтому фоновые воркеры для очистки длинного хвоста все равно необходимы.
Истечение срока бронирования в MySQL (новая система)
В новой архитектуре при бронировании одна транзакция захватывает строки из пула
с помощью FOR UPDATE SKIP LOCKED, удаляет их и создает одну запись в таблице
reserved_quantities с указанием количества и срока истечения expires_at.
Когда платеж проходит успешно, подтверждение покупки удаляет эту бронь и
списывает единицы из системы складского учета продавца. Но если оплата так и не
завершилась, бронь просто остается в таблице, а строки пула, которые она
поглотила, уже удалены.
Преимущество схемы в том, что в момент истечения срока бронирования ничего
инициировать не нужно. Пул и без того поддерживается процессом пополнения,
который непрерывно вычисляет целевой размер: остаток по системе учета за вычетом
всех активных броней. Достаточно определить активные брони как записи, у которых
expires_at находится в будущем, и механизм истечения срока срабатывает сам
собой. Как только таймаут бронирования проходит, бронь перестает учитываться в
формуле, и следующее пополнение пула (фоновое или синхронное) автоматически
восстанавливает емкость.
-- Единицы товара в активных бронях с точки зрения процесса пополнения
SELECT SUM(quantity) FROM reserved_quantities
WHERE inventory_item_id = 42
AND expires_at > NOW();Именно эту проблему так мучительно решала схема на Redis. При хранении отдельных
ключей под каждую бронь требовалось полное сканирование пространства ключей,
из-за чего Shopify и выбрал общий счетчик, а счетчик вынуждал фиксировать каждое
освобождение брони отдельной операцией записи. В MySQL это решается простым
запросом с индексированным условием WHERE, аналогично проверке expires_at в
запросе доступности мест в нашем разборе
Ticketmaster.
Фоновая задача для удаления истекших броней по-прежнему полезна, но теперь это обычная сборка мусора, а не критичное для корректности действие. Она защищает таблицу от разрастания и гарантирует актуальность данных для всех остальных компонентов, поскольку просроченная запись о бронировании выглядит занятой для любого запроса, в котором разработчик забудет указать фильтр по времени. Если процесс очистки задержится, ничего не сломается и ни одна продажа не будет упущена. И для ясности: никто не удерживает блокировку в базе данных на протяжении всего 10-минутного окна бронирования. Бронь - это просто строка с таймаутом. Блокировки возникают лишь внутри коротких транзакций, работающих с этой строкой, и каждая из них длится считанные миллисекунды.
Заключение
Ключевое требование заключалось в том, чтобы резервирование и списание фиксировались строго совместно со складской системой учета, а обеспечить это могут только данные, находящиеся в той же самой базе данных, что и сама система учета. Redis все это время корректно обрабатывал конкурентные уменьшения счетчиков, но от него все равно пришлось отказаться, поскольку он не мог участвовать в транзакциях основной базы данных.
Статья завершается мыслью: "Если вы задумываетесь о Redis, Kafka или дополнительном слое координации для взаимного исключения в высокопроизводительной среде, возможностей вашей текущей базы данных уже может оказаться достаточно". Практическая польза этого совета начинается с понятия атомарности. Если временная бронь и защищаемая ею запись должны фиксироваться строго вместе, храните их в одной базе данных. Если же строгой атомарности не требуется, счетчик в Redis остается более простым решением, и эта история ни в коей мере не пытается это опровергнуть.
Ничего из этого не было возможно до появления SKIP LOCKED в MySQL 8. Поэтому
вывод о том, что MySQL не справится с такой нагрузкой, был полностью справедлив,
когда Shopify впервые сделал его, и перестал быть верным, когда инженеры
пересмотрели его спустя годы. Если ваша команда когда-то отказалась от
стандартной реляционной базы данных, причины того решения определенно стоит
перепроверить с учетом возможностей современных СУБД.
Не спешите слепо копировать решение с пулом строк: именно сюда ушла вся эксплуатационная сложность. Пополнение пула - это отдельный процесс, который необходимо запускать, мониторить и для которого нужно настраивать дежурства, а его очевидным сценарием отказа является опустошение пула в разгар распродажи. Shopify обменял кластер Redis на этот процесс пополнения, заменив один составной элемент другим, и этот обмен оказался оправданным, поскольку новый компонент работает внутри транзакционных границ, имевших решающее значение.