Мы заменили Redis на MySQL для резервирования товаров — и система выдержала нагрузку
Во время оформления заказа, когда покупатель нажимает «Завершить покупку», мы обязаны гарантировать, что выбранные товары ещё в наличии. Ошибка в одну сторону — два покупателя приобретают последний экземпляр: продавцу приходится отменять заказ, писать письмо с извинениями и нести расходы на поддержку. Ошибка в другую сторону — мы сообщаем покупателю о распродаже товара, которого на самом деле хватает, и продавец теряет деньги.
На масштабах Shopify любой из этих сбоев нарастает как снежный ком. В «Чёрную пятницу» 2025 года магазины на нашей платформе побили рекорд: $5,1 млн продаж в минуту в пиковый момент. Каждая из этих транзакций затрагивает инвентарь.
Система защиты от перепродажи (oversell protection) решает эту проблему: в процессе обработки платежа товар резервируется — это краткосрочная блокировка, не позволяющая двум одновременным оформлениям заказа претендовать на один и тот же экземпляр. Долгие годы она работала на Redis. Когда мы перешли к единой стратегии с одной базой данных, нам пришлось ответить на непростой вопрос: справится ли MySQL с той же нагрузкой?
Предыдущие попытки заканчивались неудачей. Одна строка со столбцом количества не выдерживала конкурентного доступа. Появившийся в MySQL 8 механизм SKIP LOCKED открыл иной подход: одна строка на единицу товара вместо одной строки на позицию. Вдохновившись подходом 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 строк дают достаточный запас, чтобы пополнение успевало справляться под длительной нагрузкой, не раздувая таблицу до размеров, при которых деградирует производительность запросов.
Что происходит при опустошении пула? Во время экстремальной флеш-распродажи пул горячей позиции может временно иссякнуть. В этом случае путь резервирования инициирует пополнение прямо в процессе работы. Блокировка гарантирует, что пополнением занимается только одна транзакция; остальные параллельные резервирования той же позиции ждут её завершения, вместо того чтобы все разом бросаться вставлять строки — это избавляет от эффекта «стада» (thundering herd). После пополнения ожидающие транзакции продолжают работу с полным пулом. Покупатель никогда не видит товар как недоступный (если только он действительно недоступен). Это добавляет задержку к конкретному резервированию, но сохраняет корректность: покупателю с доступным в наличии товаром не откажут.
Ключевые технические решения
1. Составной первичный ключ: меньше блокировок на строку
В первом прототипе мы использовали автоинкрементный идентификатор в качестве первичного ключа. Наблюдая за поведением блокировок (например, через SHOW ENGINE INNODB STATUS), мы обнаружили, что на каждое резервирование приходится по две блокировки строк вместо одной.
При автоинкрементном первичном ключе InnoDB блокировал и вторичный индекс, используемый в условии WHERE, и кластерный индекс (первичный ключ). Мы переключились на составной первичный ключ (shop_id, inventory_item_id, inventory_group_id, id), включив в него столбцы, по которым фильтруем. Это сократило количество блокировок до одной на строку — что принципиально важно при обработке многих резервирований в секунду.
Вывод: на таких масштабах проектирование индексов и первичных ключей напрямую влияет на количество блокировок и пропускную способность.
2. READ COMMITTED: избавляемся от блокировок диапазона
Когда мы выполняли SELECT … FOR UPDATE SKIP LOCKED на пустой таблице, ожидающей пополнения, мы видели блокировки диапазона (gap locks), в том числе на псевдозапись «supremum». Эти блокировки мешали транзакции пополнения вставлять новые строки и могли приводить к взаимоблокировкам (deadlocks).
Мы изменили уровень изоляции транзакций с REPEATABLE READ (умолчание в MySQL) на READ COMMITTED для этих транзакций. В режиме READ COMMITTED InnoDB не берёт блокировки диапазона тем же образом, поэтому пополнение стало работать беспрепятственно. Огромную помощь в понимании происходящего оказало руководство Яхфера Хусейна по блокировкам InnoDB. Это был наш первый случай использования нестандартного уровня изоляции в данном коде — потребовалась небольшая доработка фреймворка для установки изоляции отдельно на каждую транзакцию.
3. Согласованный порядок блокировок: предотвращение взаимоблокировок
Мы столкнулись со взаимоблокировками, когда операции резервирования и списания обращались к двум таблицам в разном порядке. Резервирование делало INSERT в reserved_quantities, затем DELETE из reservation_units; списание делало DELETE из reserved_quantities. Разные транзакции могли блокировать эти две таблицы в разном порядке, образуя циклическое ожидание.
Решение — стандартизировать порядок: резервирование всегда сначала делает DELETE из таблицы units, а затем 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 (InnoDB thread concurrency) был задан консервативно несколько лет назад и с тех пор не пересматривался. Наша нагрузка изменилась. Увеличив параллелизм там, где было пространство для манёвра, мы устранили узкое место, которое оставалось невидимым до тех пор, пока мы не поставили рядом метрики соединений и CPU.
В совокупности очистка и изменения конфигурации убрали потолок. Мы смогли масштабироваться выше прежнего предела и достичь целевых показателей. Во время высоконагруженных флеш-распродаж загрузка CPU на записывающем узле не превышала 50%, на читающих — 16%, и запас ещё оставался.
Переключение
Мы не щёлкнули выключателем, переходя с Redis на MySQL. Мы запустили обе системы параллельно в так называемом «теневом режиме» (shadow mode): каждое резервирование записывалось и в Redis, и в MySQL, при этом Redis оставался источником истины. Это позволяло сравнивать системы бок о бок, проверяя корректность бизнес-результатов MySQL и его соответствие требованиям к производительности на реальном трафике. Поскольку обе системы работали одновременно, не было никаких незавершённых резервирований, которые нужно было бы мигрировать. Redis-резервирования продолжали выполняться, пока MySQL накапливал собственное состояние.
Убедившись в корректности и производительности, мы переключили источник истины на MySQL. Если бы что-то пошло не так, мы могли вернуться к Redis с помощью аварийного рубильника (kill switch) — двойная запись всё ещё была активна, так что у Redis всегда был полный актуальный срез резервирований. Переход был постепенным, под за подом: начали с подов с низким трафиком и дошли до наших крупнейших продавцов.
Что мы вынесли
Из этого проекта мы извлекли множество уроков, но два главных:
1. Пересматривайте старые решения
То, что пять лет назад было невозможно (например, MySQL для такой нагрузки), сегодня может быть вполне реализуемым — благодаря новым возможностям вроде SKIP LOCKED. То же касается конфигурации: лимиты потоков и другие «эмпирические» настройки стоит периодически пересматривать по мере эволюции нагрузки и железа. Если цифры не сходятся (например, низкий CPU, но очереди растут) — копайте глубже.
2. Начинайте с малого и наблюдайте
Нам дал много небольшой минималистичный прототип: небольшой Ruby-скрипт и MySQL, без полноценного фреймворка вроде Rails. Наблюдение за базой данных (например, за поведением блокировок в соседнем терминале) учит больше, чем одна только теория. Простой инструментарий и тесная петля обратной связи эффективнее громоздких непрозрачных систем на этапе исследования.
MySQL сегодня справляется с нагрузками, которые раньше мы считали уделом специализированной инфраструктуры. Если вы тянетесь к Redis, Kafka или самодельному координационному слою ради высокопроизводительного взаимного исключения — вполне возможно, ваша текущая база данных уже достаточна.
Узкое место оказалось не там, где мы ожидали. Мы неделями оптимизировали запросы и блокировки, а настоящим ограничением было использование соединений в коде, на который мы вообще не смотрели. Если цифры не бьются — низкий CPU, но высокие очереди — инструментируйте весь путь целиком. Ответ чаще всего скрыт в «трубах», а не в «двигателе».
И самое важное: речь шла не о том, чтобы сделать резервирование быстрым. Речь шла о том, чтобы сделать его хорошим соседом. Резервирования делят базу данных с обновлениями корзины, обработкой платежей и созданием заказов. Система, насыщающая пул соединений или удерживающая блокировки дольше необходимого, ставит под угрозу все остальное. Настоящей планкой была поддержка пропускной способности без деградации здоровья базы данных для всего остального.
Для нас выигрыш вполне конкретен: надёжные резервирования означают отсутствие перепродаж и больше успешных покупок у наших продавцов.