The $4,000 month ghost storage optimizing the Outbox Pattern on Aurora MySQL
Имат ли значение за вас разходите за хостинг? В Yotpo се полагат усилия всеки цент да бъде похарчен разумно. Ето защо темата става приоритетна, когато Yotpo трябва плати допълнителни $150 за сутрешното кафе.
За повечето фирми това е бюджетно неудобство. За Системата за поръчки – гръбнакът на компанията, обработващ 100 000 заявки в минута в пиковите моменти – това е системна заплаха. Тази система представлява „точката, от която няма връщане“. Евентуален срив би спрял бизнеса: спира обработката на поръчки, изпращането на заявки за ревюта и начисляването на точки за лоялност. Клъстерът Aurora MySQL е сърцето на системата, където се запазва състоянието на поръчките и се генерират outbox събития за останалите услуги.
След анализ на метриките е открита тревожна тенденция. Само за шест седмици Aurora MySQL е нараснал с 15 терабайта. Този ръст съвпада с периода около Black Friday/Cyber Monday, което води до първоначалното му неглижиране на фона на scale up на услугите и увеличения в бюджети.


Екипът проследява увеличението на съхранението до една-единствена таблица, използвана от Outbox Pattern за генериране на събития.
Забавен факт: Тази таблица винаги е празна и въпреки това заема 15 TB.
Ето как Yotpo успява да разкрие мистерията на ,,призрачните данни”, защо стандартните DELETE операции не помогат и как е решен проблемът.
Разследването: The „InnoDB is Not a Queue“ Anti-Pattern
Първоначалната теория на екипа е за заседнала транзакция, блокираща undo логовете. Прегледът на логовете обаче показва, че най-дългите транзакции завършват за под 60 секунди.
Реалността е по-проста и по-болезнена: Несъответствие в скоростта.
Aurora MySQL се използва като опашка с висока пропускателна способност. В Outbox модела всяко съобщение преминава през жизнен цикъл от INSERT -> SELECT -> DELETE. Опитът базата данни да замести Kafka често води до архитектурен хаос.
В InnoDB операцията DELETE не освобождава дисковото пространство веднага. Тя маркира реда като изтрит и записва нов запис в undo лога.
Проблемът: Нишките за почистване (Purge Threads) трябва физически да преминат през тези undo логове, за да възстановят пространството. Базата маркира данни за изтриване (undo логове) по-бързо, отколкото процесът на почистване може физически да ги освободи от диска.

Дължината на списъка с история от 4,7 милиарда не е внезапен скок; това е дефицит, който се е натрупва в продължение на месец и половина – като вана с широко отворен кран и запушен сифон.
Борбата: Четири решения, които се провалят
Екипът прекарва седмици в опити да възстанови това пространство, без да повлияе на системата по никакъв начин. Повечето опити не проработват.
1. Настройка на InnoDB Purge Threads
Първото решение е да увеличат стойностите на параметрите innodb_purge_threads и innodb_purge_batch_size.
Нищожно въздействие. Не може да се реши чрез настройки фундаментално архитектурно несъответствие. Множество нишки не могат да почистват една и съща таблица едновременно.
2. Percona Online Schema Change
Второто решение е да се пресъздаде таблицата наново.
Неуспешно. Инструментът не успява да се справи с конкуренцията за заключване (lock contention), породена от високата скорост на записване/изтриване.
3. Стратегията „Table Swap“
Третото решение, което екипът изпозва, е да създаде нова таблица и да направи атомарно заместване (atomic swap). Така старата таблица се заменя с идентична нова, за да може старата да бъде изтрита.
Временна илюзия. Новата таблица веднага започва да натрупва същия проблем.
4. MySQL Engine BLACKHOLE
Четвъртото решение е да се използва друга подсистема за дисковото пространство BLACKHOLE. Предимството тук е, че тя записва само в BinLog и не записва данни на диска.
Отхвърлено. Макар и теоретично перфектно за outbox, това решение нарушава Debezium/CDC процесите, многократно отчитайки грешка „Invalid position at binlog file“. Файлът и позицията са валидни, след като екипът от Yotpo ги верифицира с mysqlbinlog, а рестартирането на конектора решава проблема временно. Има възможност Debezium да не поддържа тази комбинация.
Explore more
Решението: Стратегическо превключване
Екипът осъзнава, че трябва да спре да изисква от базата данни да трие редове един по един и че се нуждае от начин за масово изтриване на данни, който да не използва Purge Threads в InnoDBи отговорът е Partitioning.
Чрез разделяне на таблицата на дневни сегменти, екипът може да премахва стари данни с DROP PARTITION. Това е атомарна операция – тя е мигновена и не генерира undo логове. Purge Thread дори не се намесва.
Ето как става това:
1. Промяна на схемата: Справяне със сложността на PK
Partitioning изисква ключът на сегмента (в техния случай created_at) да бъде част от главния ключ (primary key).
CREATE TABLE outbox_events_partitioned (
id VARCHAR(36) NOT NULL, -- UUID
event_payload JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- The partition key must be part of the PK
PRIMARY KEY (id, created_at))
PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
PARTITION p_2024_01_01 VALUES LESS THAN (UNIX_TIMESTAMP(’2024-01-02 00:00:00’)),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
Един риск при composite primary keys (id, created_at) е загубата на уникалност на самото ID. Това не е проблем за тях, тъй като техните ID-та са UUID-та, генерирани от приложението. Уникалността е гарантирана от приложението, така че може безопасно да се добавят timestamp към главния ключ на таблицата без да рискуват колизия.
2. The Downstream Config: Saving the Consumers
Промяната на Primary Key обикновено засяга консуматорите, но в конфигурацията на Debezium е скрито работещо решение.
Екипът е конфигурирал Debezium с transforms.outbox.table.field.event.id = id. Това отделя техните ключове на Kafka съобщения от главните ключове на базата данни. Въпреки че схемата на таблицата се променя на (id, created_at), събитията (events), изпращани към Kafka, запазват оригиналния id като ключ, гарантирайки, че консуматорите стават напълно незасегнати.
3. The Migration: The “Replay” Safety Net
Тъй като не могат да копират 15TB данни в новата Partition таблица, защото това ще отнеме твърде много време, а и без това после трябва да ги изтрият, те извършват смяна по време на прозорец за поддръжка, използвайки стратегия за „Повторение“ (Replay).
- Спиране на записващите процеси: Първо екипът спира всички консуматори, които записват в БД, като оставя само API-то, тъй като то извършва малък обем записи. Това е с цел да се минимизира броят на събитията, които ще трябва да се „преиграят“ впоследствие.
- Спиране на „кървенето“: Debezium конекторите са паузирани.
- Атомарна замяна: Таблиците са разменени чрез RENAME TABLE.
- „Повторението“: Екипът идентифицира всички активни поръчки, създадени по време на прозореца за поддръжка, и извършва „no-op“ актуализация върху тях чрез API-то (напр. „UPDATE orders SET updated_at = NOW()“).
Защо това е безопасно:
Екипът се уповава на строгите гаранции при консуматорите:
- Тригерът: „No-op“ актуализацията генерира ново събитие със същите данни, но с по-нов времеви маркер.
- Ограничаване на обхвата: Outbox таблицата проследява само евенти за поръчки.
- Логиката на консуматора: Консуматорите проследяват времевия маркер на последното обработено събитие. Те автоматично отхвърлят всяко събитие, по-старо от тяхната текуща граница (watermark).
Ако консуматорът вече е обработил поръчката, той получава новото събитие, вижда, че състоянието съвпада, и го третира като идемпотентна актуализация (без промяна). Ако консуматорът е пропуснал поръчката (рядко), той обработва новото събитие като възстановяване. Това превръща рискованата миграция на данни в безопасен цикъл за синхронизация на състоянието.
4. The Maintenance: Automating the Cleanup
Partitioning решава проблема със съхранението, но въвежда допълнителни процеси по поддръжка. Екипът не иска да управляват сегменти ръчно и затова създава Java-базиран Kubernetes CronJob, изпълняващ се два пъти дневно по политика ,,7+7“:
- Поглед напред: Предварително създава сегменти за следващите 7 дни, за да гарантира, че винаги има сегмент за запис. В противен случай записите в таблицата ще се провалят, което ще доведе до неуспех на CRUD операциите за поръчки.
- Поглед назад: Изтрива сегменти, по-стари от 7 дни (периодът на задържане).
Екипът създава и аларма, ако Cron задачата се провали два поредни пъти в рамките на 24 часа. Тази проверка казва на OpsGenie да се обади на дежурния инженер по време на работните часове. Това им дава 6-дневен буфер за поправка на CronJob, преди да изчерпят предварително създадените сегменти. Също така те винаги могат да създадат сегменти ръчно, докато не решат проблема.
Резултатът: 24-часовият прозорец
Резултатите са огромни, но не идват мигновено.
Докато командата DROP TABLE върху старата таблица е мигновена, възстановяването на дисковото пространство не е. На Storage Engine на Aurora му отнема около 24 часа, за да освободи 15TB пространство. Екипът следи DB CPU, Disk Queue Depth и бавните заявки. Заедно с това наблюдават и тяхното API, за да са сигурни, че няма регресия в бързината му.
След като всичко приключи:

- Разходи за съхранение: Стабилизирани.
- Дължина на списъка с история: Спадна от 4,4 милиарда до почти нула.
Урокът?
При голям мащаб DELETE е скъпа операция. Понякога най-добрият начин за почистване е премахването на цели сегменти, а най-сигурният път за миграция – повторното записване на данните от приложението.







