Redis to MySQL for inventory

Во время оформления заказа, когда покупатель нажимает «Завершить покупку», нужно гарантировать, что товар всё ещё доступен. Если ошибиться в одну сторону — два покупателя купят один и тот же последний экземпляр товара, и продавцу придётся отменять заказ, отправлять извинения и нести расходы на поддержку. Если ошибиться в другую — покупателю сообщат, что товар распродан, хотя это не так, и продавец потеряет продажу, которая должна была состояться.

При масштабе Shopify любая из этих ошибок быстро накапливается. В Чёрную пятницу 2025 года продавцы на платформе достигли рекордных $5,1 млн продаж в минуту на пике. Каждая из этих транзакций затрагивает инвентарь.

Система защиты от овербукинга решает эту задачу, резервируя товар на время обработки платежа — короткая блокировка, которая не даёт двум параллельным оформлениям заказа претендовать на одну и ту же единицу товара. Годами это работало на Redis. При переходе к унифицированной стратегии баз данных пришлось ответить на непростой вопрос: справится ли MySQL с тем же масштабом?

Ранее попытки уже проваливались. Одна строка с колонкой количества не выдерживала конкуренции за блокировки. Функция SKIP LOCKED в MySQL 8 предложила другой подход: одна строка на единицу товара вместо одной строки на позицию. Вдохновившись подходом 37signals к распределению нагрузки на базе БД, команда переписала резервирование на MySQL и достигла целевых показателей пропускной способности во время пиковой нагрузки 2025 года.

Но главный урок оказался не про дизайн базы данных. Выяснилось, что настоящее узкое место находилось не там, где велись наблюдения и замеры. Далее — о том, как решалась задача, и что обнаружилось по ходу дела.

Задача

Что такое защита от овербукинга

Защита от овербукинга состоит из двух основных операций:

  • Резервирование (Reserve): когда начинается оплата, товары помечаются как зарезервированные (короткая блокировка, например на несколько минут).
  • Списание (Claim): когда платёж успешен, количество безвозвратно списывается из книги учёта инвентаря (источник истины).

Завершение оформления заказа зависит от скорости и корректности этой операции. Медленное резервирование запускает троттлинг и портит опыт покупателя. Ошибки означают либо овербукинг (недовольные клиенты), либо избыточную блокировку товара, который на деле доступен (потерянная выручка).

Требования к масштабу и корректности

Масштаб здесь не абстрактный: Shopify обеспечивает более 14% электронной коммерции США, а в Чёрную пятницу 2025 года продажи в минуту на пике выросли на 11% по сравнению с прошлым годом. Резервирование происходит при каждом оформлении заказа, затрагивающем инвентарь, так что система должна переживать такие всплески без потери запросов и нарушения консистентности.

Требовалось:

  • Поддержать целевые показатели высокой пропускной способности платформы во время пиковой нагрузки
  • Учитывать многосклад��вую модель инвентаря (резервировать только там, откуда можно выполнить доставку)
  • Сохранить гарантии ACID между резервированием и книгой учёта инвентаря
  • Приоритизировать корректность: ни овербукинга, ни потерянных резервов

Модель на Redis и её ограничения

Прежняя система хранила резервы в Redis. У каждой позиции был ключ с количеством, резервирование означало DECR, освобождение — INCR. С конкурентным доступом Redis справлялся неплохо, но резервы и книга учёта инвентаря жили в двух разных системах.

Шаг списания (платёж обработан, инвентарь безвозвратно списан) требовал обновления MySQL и очистки Redis, и эти две операции не удавалось объединить в один атомарный шаг. В зависимости от порядка выполнения это могло привести к овербукингу (товар продан, но никогда не списан из книги учёта) или к избыточной блокировке (товар списан, но всё ещё помечен как зарезервированный).

Вдобавок модель на Redis не имела представления о нескольких складах и добавляла операционные расходы на поддержку отдельного кластера. Перенос резервирования в ту же базу MySQL, где хранится книга учёта, позволил обернуть всё в ACID-транзакции и полностью устранить эти классы отказов.

Решение: SKIP LOCKED

Основная идея: одна строка на единицу товара, ограниченная по дизайну

Вместо одной строки на позицию с колонкой количества используется одна строка на продаваемую единицу товара. Позиция с 10 единицами — это 10 строк. Резервирование трёх единиц означает выбор и перемещение трёх строк в рамках одной транзакции. Держа резервы и книгу учёта инвентаря в одной базе данных, получаем ACID между резервированием и списанием — это устраняет целые классы багов, возможных с Redis (например, платёж прошёл, но инвентарь не списан, или наоборот).

Упрощённый поток резервирования выглядит так:

SKIP LOCKED — вот что делает подход масштабируемым: если другая транзакция заблокировала часть строк, MySQL пропускает их и возвращает другие доступные строки. Никакого ожидания одной и той же строки, меньше конкуренции за блокировки.

Но одна строка на единицу товара для всего инвентаря не выдержала бы масштаба — позиция с 50 000 единиц на 10 складах означала бы 500 000 строк, и запрос резервирования замедлялся бы при их сканировании. Вместо этого поддерживается ограниченный пул доступных строк, максимум 1000 на комбинацию «товар/склад». Резервы забирают строки из этого пула; процесс пополнения восполняет его из книги учёта инвентаря.

Почему именно 1000? Лимит должен быть достаточно большим, чтобы поглощать всплески без опустошения пула, но достаточно малым, чтобы таблица оставалась компактной, а сканирование SKIP LOCKED — быстрым. Значение подбирали на основе наблюдаемых пиковых темпов резервирования на позицию/склад во время флеш-распродаж: 1000 даёт достаточный запас, чтобы пополнение успевало за устойчивой нагрузкой без роста таблицы до состояния, когда производительность запросов падает.

Что происходит, если пул опустеет? Во время экстремальной флеш-распродажи пул для популярного товара может временно истощиться. В этом случае путь резервирования запускает пополнение прямо в процессе выполнения. Блокировка гарантирует, что пополнением занимается только одна транзакция; другие параллельные резервы того же товара ждут её завершения, а не соревнуются за вставку строк, что избегает эффекта «громового стада». После завершения пополнения ожидающие транзакции продолжают работу с полным пулом. Покупатель никогда не видит товар как недоступный (если он действительно доступен). Это добавляет задержку конкретному резерву, но сохраняет корректность: покупателю с доступным товаром никогда не откажут.

Ключевые технические решения

1. Составной первичный ключ: меньше блокировок на строку

Первый прототип использовал автоинкрементный ID как первичный ключ. При наблюдении за поведением блокировок (например, с помощью SHOW ENGINE INNODB STATUS) обнаружились две блокировки строк на одно резервирование вместо одной.

С автоинкрементным первичным ключом InnoDB блокировал и вторичный индекс, использованный в условии WHERE, и кластерный индекс (первичный ключ). Переход на составной первичный ключ (shop_id, inventory_item_id, inventory_group_id, id) сделал так, что колонки, по которым идёт фильтрация, стали частью первичного ключа. Это сократило число блокировок до одной на строку, что было важно при большом числе резервов в секунду.

Вывод: при таком масштабе дизайн индексов и первичного ключа напрямую влияет на количество блокировок и пропускную способность.

2. READ COMMITTED: избегаем gap-блокировок (supremum)

При выполнении SELECT ... FOR UPDATE SKIP LOCKED на пустой таблице, требующей пополнения, обнаружились gap-блокировки (в том числе на псевдо-записи «supremum»). Эти блокировки не давали транзакции пополнения вставлять новые строки и могли привести к дедлокам.

Уровень изоляции транзакций для этих операций изменили с REPEATABLE READ (значение по умолчанию в MySQL) на READ COMMITTED. При READ COMMITTED InnoDB не берёт gap-блокировки таким же образом, поэтому пополнение могло проходить. Разобраться в этом сильно помог разбор блокировок InnoDB от Джахфера Хусейна. Это было первое в кодовой базе использование нестандартного уровня изоляции; потребовалась небольшая поддержка на уровне фреймворка для установки изоляции для конкретной транзакции.

3. Согласованный порядок блокировок: избегаем дедлоков

Дедлоки возникали, когда резервирование и списание обращались к двум таблицам в разном порядке. Резервирование делало INSERT в reserved_quantities, затем DELETE из reservation_units; списание делало DELETE из reserved_quantities. Разные транзакции могли блокировать эти две таблицы в разном порядке и образовывать цикл.

Решением стало стандартизировать порядок: резервирование теперь всегда сначала делает DELETE из таблицы единиц товара, затем INSERT в reserved_quantities. Списание затрагивает только reserved_quantities. Поскольку оба пути теперь захватывают блокировки в одном и том же порядке, ни один из них не может удерживать блокировку, которую ждёт другой — циклических ожиданий больше нет.

4. Батчинг с UNION ALL

Каждый обмен запросами с базой данных имеет свою стоимость. Для корзин с несколькими позициями запросы резервирования батчатся с помощью UNION ALL, чтобы получить все нужные единицы товара за один обмен с базой:

Это сократило общее число обращений и помогло со задержкой при высокой нагрузке.

Настоящее узкое место: соединения, а не CPU

В продакшене уперлись в потолок пропускной способности заметно ниже целевого значения. Задержка резервирования (например, P90) была в норме, CPU не был перегружен, а запросы уже были оптимизированы. Пришлось искать в другом месте.

Попробовали батчить резервы разных чекаутов в один запрос SKIP LOCKED, чтобы использовать меньше соединений. Это помогало в нагрузочных тестах, но добавляло сложности. Также часть нагрузки на чтение перенесли на реплики. Тем не менее, что-то не сходилось.

По следам симптомов

Во время нагрузочных тестов наблюдалось:

  • Очереди потоков в MySQL
  • Всплески CPU при выполнении накопившихся задач
  • Исчерпание соединений к бэкендам MySQL на уровне ProxySQL

Поэтому добавили видимость того, какие бизнес-процессы удерживают соединения с базой и на сколько долго. Знание о том, что соединения исчерпаны, само по себе не говорит, кто их держит. Нужна была атрибуция по вызывающей стороне.

На стороне приложения каждый SQL-запрос стали помечать комментарием, указывающим бизнес-процесс, например /* conn_tag:checkout_completion */. На уровне ProxySQL добавили трекинг, который разбирает эту метку и измеряет, как долго каждый вызывающий держит соединение. Результат: общее время удержания соединений в разбивке по бизнес-процессам.

Это сразу показало, какие вызывающие стороны потребляют больше всего времени соединений. Не какие запросы медленные, а какие процессы держат соединения в долгих транзакциях. Если упираетесь в лимиты соединений и не можете понять, почему — этот паттерн (метка на уровне приложения, агрегация на уровне прокси) реализуется просто и сразу даёт результат, на который можно действовать.

Что обнаружилось

Как только использование соединений стало видимым, выяснилось, что резервы — не единственный тяжёлый потребитель. Другие части пути оформления заказа держали соединения дольше, чем нужно. Их не оптимизировали раньше, потому что они не были первыми, кто упирался в лимит. Соединения — ресурс конечный: при высокой пропускной способности нужно много коротких транзакций в секунду. Когда другой код держал соединения дольше, резервы становились той самой последней каплей — не потому что резервы были медленными, а потому что пул уже был почти истощён.

Чистка пути оформления заказа убрала 50% операций чтения и 33% транзакций на основной базе данных. Также пересмотрели конфигурацию MySQL. Параметр параллелизма потоков InnoDB был настроен консервативно много лет назад и никогда не пересматривался. Нагрузка изменилась. После увеличения параллелизма потоков там, где был запас, устранили узкое место, невидимое до тех пор, пока метрики соединений и CPU не легли рядом друг с другом.

В сочетании чистка и изменения конфигурации сняли потолок. Стало возможным масштабироваться за пределы прежнего лимита и достичь целевых показателей. Во время флеш-распродаж с высоким объёмом трафика CPU на writer-инстансе оставался ниже 50%, а на reader-инстансах — ниже 16%, с запасом.

Переключение

Переход с Redis на MySQL не был мгновенным переключением рубильника. Обе системы работали параллельно в режиме, который назвали «shadow mode»: каждый резерв записывался и в Redis, и в MySQL, при этом Redis оставался источником истины. Это позволило сравнить обе системы бок о бок, проверяя, что MySQL даёт корректные бизнес-результаты и соответствует требованиям к производительности на реальном продакшн-трафике. Поскольку обе системы работали живьём, не было незавершённых резервов для миграции. Резервы в Redis продолжали действовать, пока MySQL накапливал собственное состояние.

Когда убедились в корректности и производительности, источник истины переключили на MySQL. Если бы что-то пошло не так, можно было бы вернуться к Redis с помощью выключателя; путь двойной записи оставался активным, так что у Redis всегда была полная картина резервов. Выкатка шла постепенно, под-за-подом, начиная с малонагруженных инстансов и заканчивая продавцами с наибольшим объёмом трафика.

Выводы

Из этого проекта извлекли множество уроков, но два главных вывода такие:

1. Пересматривать старые решения

То, что было невозможно пять лет назад (например, MySQL для такой нагрузки), сегодня возможно благодаря новым возможностям, таким как SKIP LOCKED. То же касается конфигурации: лимиты потоков и другие настройки «на глазок» стоит перепроверять по мере эволюции нагрузки и железа. Если цифры не сходятся (например, низкий CPU, но при этом очереди) — стоит копнуть глубже.

2. Начинать с малого и наблюдать

Минимальный прототип принёс много пользы: небольшой скрипт на Ruby и MySQL, без полноценного фреймворка вроде Rails. Наблюдение за базой данных (например, за поведением блокировок во втором терминале) дало больше понимания, чем одна только теория. Простые инструменты и быстрый цикл обратной связи оказываются полезнее больших непрозрачных систем на этапе исследования.

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

Узкое место оказалось не там, где ожидали. Запросы и блокировки оптимизировали неделями; настоящий предел был в использовании соединений кодом, на который даже не смотрели. Если цифры не сходятся — низкий CPU, но высокие очереди — стоит инструментировать весь путь целиком. Ответ часто находится в «сантехнике», а не в «двигателе».

Важно, что задача была не в том, чтобы сделать резервы быстрыми. Задача была в том, чтобы сделать их безопасными соседями. Резервы делят базу данных с обновлениями корзины, обработкой платежей и созданием заказов. Система, которая насыщает соединения или держит блокировки слишком долго, ставит под угрозу всё остальное. Настоящей планкой было поддерживать пропускную способность без деградации здоровья базы данных для всех остальных процессов.

Практический итог: более надёжное резервирование означает отсутствие овербукинга и больше успешных покупок у продавцов.

Water line vs race meme: inventory