Shopify заменила Redis на MySQL для резервирования остатков при оформлении заказа

Shopify перестроила систему резервирования товаров, которая защищает от овербукинга остатков во время оформления заказа: когда покупатель нажимает «Оформить покупку», система должна на короткое время (несколько минут) зарезервировать нужное количество товара, чтобы два покупателя не купили последнюю единицу одновременно, а склад не показывался распроданным, когда товар ещё есть.
Годы подряд эта система работала на Redis: у каждого товара был ключ-счётчик количества, резервирование делалось командой DECR, снятие резерва, INCR. Redis справлялся с конкурентным доступом, но резервы жили в Redis, а окончательное списание товара (после успешной оплаты), в MySQL, в журнале остатков. Эти две операции нельзя было объединить в одну атомарную транзакцию: в зависимости от порядка выполнения возникал либо овербукинг (товар продан, но не списан из журнала), либо, наоборот, «недопродажа» (товар списан, но всё ещё числится зарезервированным). Кроме того, у Redis-модели не было понятия нескольких складов (multi-location) и требовался отдельный кластер в эксплуатации.
Когда Shopify перешла к единой стратегии на одной базе данных, встал вопрос: выдержит ли MySQL ту же нагрузку. Прежние попытки проваливались: одна строка со столбцом «количество» на товар не справлялась с конкуренцией запросов. Решение нашли в функции SKIP LOCKED, появившейся в MySQL 8: вместо одной строки-счётчика на товар, по одной строке на каждую физическую («продаваемую») единицу товара. Резерв трёх единиц, это выбор и перемещение трёх строк в одной транзакции; если строки уже заблокированы другой транзакцией, SKIP LOCKED просто пропускает их и возвращает другие свободные строки вместо ожидания. Идею вдохновил подход компании 37signals к распределению нагрузки через базу данных.
Чтобы миллионы единиц товара не превращались в миллионы строк (пример из текста: 50 000 единиц на 10 складах, это уже 500 000 строк, и запрос резервирования замедлился бы, сканируя их), Shopify держит ограниченный пул доступных строк, не более 1000 на пару «товар + склад». Резервы расходуют строки из пула, а отдельный процесс пополнения подливает их из журнала остатков. Если пул для ходового товара во время всплеска спроса опустошается, резервирование запускает пополнение «на лету»: блокировка гарантирует, что пополняет только одна транзакция, остальные ждут её завершения вместо того, чтобы конкурировать за вставку строк. Покупатель никогда не видит товар как недоступный, если он действительно есть в наличии, ценой чуть большей задержки именно для этого конкретного резерва.
При реализации команда столкнулась с несколькими техническими ловушками. Первый прототип использовал автоинкрементный первичный ключ, из-за чего InnoDB брала по две блокировки строки на резерв, по вторичному индексу из условия WHERE и по кластерному индексу (первичному ключу). Переход на составной первичный ключ (shop_id, inventory_item_id, inventory_group_id, id) сократил число блокировок до одной на строку. Далее, при выполнении SELECT ... FOR UPDATE SKIP LOCKED на пустой таблице, ожидающей пополнения, команда увидела блокировки промежутков (gap locks, включая псевдозапись «supremum»), которые мешали транзакции пополнения вставлять новые строки и приводили к взаимоблокировкам; решением стал переход с уровня изоляции REPEATABLE READ (значение MySQL по умолчанию) на READ COMMITTED для этих транзакций, первый случай использования нестандартного уровня изоляции в этой кодовой базе. Ещё одна причина взаимоблокировок, резерв и списание трогали две таблицы в разном порядке; решение, жёстко зафиксировать порядок операций (резерв всегда сначала удаляет из таблицы единиц, затем вставляет в таблицу зарезервированных количеств; списание трогает только вторую таблицу). Наконец, для корзин с несколькими товарами запросы резервирования объединили через UNION ALL, чтобы забирать все нужные строки за один поход к базе вместо нескольких.
Но главный урок, по словам команды, был не про проектирование базы данных. В продакшене throughput упирался в потолок заметно ниже целевого, хотя задержка резервирования (например, P90) была приемлемой, процессор не был загружен под завязку, а запросы уже были оптимизированы. При нагрузочном тестировании обнаружились очереди потоков в MySQL, скачки CPU при разборе этих очередей и исчерпание соединений к бэкендам MySQL на уровне ProxySQL, то есть само по себе знание, что соединения исчерпаны, не говорило, кто именно их держит. Тогда на уровне приложения каждый SQL-запрос стали помечать комментарием с тегом бизнес-процесса (например, /* conn_tag:checkout_completion */), а на уровне ProxySQL добавили разбор этого тега и подсчёт суммарного времени удержания соединения по каждому вызывающему процессу. Это сразу показало не то, какие запросы медленные, а то, какие процессы дольше всех держат соединения открытыми в рамках длинных транзакций.
Выяснилось, что резервирование было не единственным тяжёлым потребителем соединений: другие участки пути оформления заказа держали соединения дольше необходимого просто потому, что раньше не были первыми, кто упирался в лимит. Резервирование стало «последней каплей» не потому, что было медленным само по себе, а потому что пул соединений и так был почти исчерпан другими процессами. Чистка этого пути убрала 50% операций чтения и 33% транзакций с основной базы данных. Дополнительно команда пересмотрела конфигурацию MySQL: параметр конкурентности потоков InnoDB был выставлен консервативно много лет назад и с тех пор не пересматривался, хотя нагрузка изменилась; его повышение там, где был запас, убрало ещё один незамеченный ранее потолок. В сумме чистка пути и изменение конфигурации сняли ограничение: во время пиковых распродаж загрузка CPU на «писателе» держалась ниже 50%, на «читателях», ниже 16%, с запасом.
Переход делали не «одним рубильником». Обе системы какое-то время работали параллельно в «теневом режиме»: каждый резерв записывался и в Redis, и в MySQL, но источником истины оставался Redis, это позволило сверить, что MySQL даёт корректные бизнес-результаты и укладывается в требования по производительности на реальном продакшен-трафике, без необходимости переносить «зависшие в процессе» резервы. Убедившись в корректности и производительности, источник истины переключили на MySQL, сохранив параллельную запись и аварийный откат на Redis на случай проблем. Раскатывали постепенно, под за подом, начиная с малонагруженных подов и постепенно доходя до самых крупных продавцов. Что именно из выводов команда выделила как «два главных урока», в сохранённом тексте статьи обрывается на середине предложения и недоступно.
Ключевые факты
- Shopify перевела систему защиты от овербукинга остатков (oversell protection) с Redis на MySQL, использовав SKIP LOCKED из MySQL 8: вместо одной строки-счётчика на товар, по одной строке на каждую физическую единицу товара, что убрало класс ошибок из-за несогласованности между Redis и журналом остатков в MySQL.
- На пике Black Friday 2025 продавцы на платформе делали продажи на рекордные $5,1 млн в минуту (рост на 11% к пику годом ранее); Shopify занимает более 14% рынка электронной коммерции США.
- Настоящим узким местом оказалось не проектирование базы данных, а исчерпание пула соединений к MySQL: команда пометила SQL-запросы тегами бизнес-процесса и на уровне ProxySQL стала считать суммарное время удержания соединений по каждому процессу, это показало, кто на самом деле держит соединения дольше всех.
- Чистка пути оформления заказа убрала 50% операций чтения и 33% транзакций с основной базы данных; пересмотр давно не трогавшегося параметра конкурентности потоков InnoDB убрал оставшийся потолок, CPU писателя во время пиковых распродаж держался ниже 50%.
- Переход делали без «переключения рубильника»: обе системы писали данные параллельно в «теневом режиме» (Redis оставался источником истины), затем источник истины постепенно, под за подом, переключили на MySQL, сохранив аварийный откат на Redis.
Почему это важно
Это редкий подробный разбор от инженеров с конкретными числами о том, как система на Redis, годами считавшаяся необходимой для конкурентного доступа к данным, была заменена на реляционную базу без потери масштабируемости, на трафике, где счёт идёт на миллионы долларов продаж в минуту. Кейс также показывает типичную ловушку диагностики: команда была уверена, что упирается в проектирование базы данных, а реальным потолком оказалось исчерпание пула соединений, вызванное совсем другими участками кода.
Кому это важно
Бэкенд- и дата-инженерам, которые проектируют системы резервирования, бронирования или списания ограниченных ресурсов (билеты, остатки на складе, места) под высокую конкуренцию; командам, рассматривающим миграцию с Redis на реляционную базу ради унификации хранилищ; инженерам, которые сталкиваются с загадочными потолками throughput при низкой загрузке CPU и нормальной задержке запросов.
Как это применить
Из статьи можно забрать несколько конкретных техник: SKIP LOCKED для очереди «одна строка на единицу ресурса» вместо счётчика с блокировками; составной первичный ключ, включающий колонки из условия WHERE, чтобы сократить число блокировок строк с двух до одной; переход на уровень изоляции READ COMMITTED там, где REPEATABLE READ по умолчанию порождает лишние блокировки промежутков (gap locks) и взаимоблокировки; жёстко фиксированный порядок блокировки таблиц в разных операциях, чтобы исключить циклические ожидания; батчинг через UNION ALL для сокращения числа обращений к базе. Отдельно, паттерн диагностики: помечать SQL-запросы тегом вызывающего бизнес-процесса на уровне приложения и агрегировать время удержания соединения на уровне прокси (ProxySQL), чтобы находить не медленные запросы, а процессы, которые дольше всех держат соединения открытыми. Для самого перехода, «теневой режим» с параллельной записью в старую и новую систему перед переключением источника истины и постепенный раскат под за подом.
Можно ли доверять
Источник, инженерный блог самой Shopify, первое лицо изложения, с конкретными техническими деталями (названия параметров, порядок операций, точные цифры по нагрузке и CPU), что типично для достоверного инженерного постмортема. Независимой проверки цифр нет, а сохранённый текст обрывается на середине предложения перед разделом с итоговыми выводами, поэтому финальные «два главных урока» команды в пересказе отсутствуют, их не было в доступном тексте.
Риски и подводные камни
Пример «50 000 единиц на 10 складах, 500 000 строк», гипотетическая иллюстрация того, почему неограниченный пул строк не масштабируется, а не описание реального инцидента; не стоит принимать её за цифры из продакшена Shopify. В тексте не названы ни точная дата переключения источника истины на MySQL, ни то, какие именно другие процессы держали соединения дольше необходимого, известно только, что они существовали. Материал описывает решение для конкретной нагрузки и архитектуры Shopify; частности (капы, уровни изоляции, теги соединений) не обязательно переносятся один в один на системы другого масштаба без проверки.
«SKIP LOCKED, вот что делает это масштабируемым: если другая транзакция заблокировала часть строк, MySQL пропускает их и возвращает другие доступные строки. Никакого ожидания одной и той же строки, меньше конкуренции.»
— инженеры Shopify