Как мы в Delivery Club outbox оптимизировали

от автора

Привет, Хабр! Решил написать небольшую техническую статью о том, как мы ускорили запрос в таблицу, до которой не доходил autovacuum из-за большой нагрузки на БД примерно в 200 раз, а разгребание outbox очереди — ещё примерно в 3 раза.

Дисклеймер

Для понимания статьи понадобится кое-какое знание PostgreSql и микро-сервисов, углубляться в это я не могу: из-за этого статья разрастётся до неприличных масштабов, но приложу краткий список ссылок на материалы, где всё объяснено подробно:

  1. мощный цикл статей с примерами на понимание, того, что такое: изоляция транзакций, MVCC, VACUUM

  2. что такое паттерн outbox и для чего он нужен.

Базовый сценарий

К нам на вход приходит какое-то событие со статусом заказа, который необходимо опрокинуть куда-то вовне и при этом обработать у нас. Соответственно, делаем мы это в транзакции, кладём событие в БД, после чего оно подтягивает какой-то воркер и обрабатывает.

Требования

  • Статусы у заказа нужно обработать в правильном порядке: от старых к новым.

  • Параллельно статусы одного заказа обрабатывать нельзя.

  • Необходимо держать от 300 RPS постоянной нагрузки. Звучит как немного, но был легаси нюанс, о чём далее 🙂

  • Естественно, разгребание очереди должно параллелиться на несколько воркеров.

Начальная реализация

  • Хранятся события в таблице events в PG, причём это не отдельный инстанс, БД используется сервисом, который сам по себе очень нагруженный (то самое легаси). База не шардирована.

  • Рядом стоят воркеры, которые подключаются к БД и разгребают очередь.

  • Для партиционирования по заказу лок берётся в Redis-е, причём это не отдельный кластер для наших воркеров, а общесервисный кластер (легаси).

Схема и диаграмма работы воркера

Схема и диаграмма работы воркера

Схема таблицы events:

create table events (     id bigserial primary key,     order_id bigserial,     status text not null, -- какой-то статус, неважно: 1, 2, 3     created_at timestamptz not null default now() );  create index events_created_at_idx on events using btree (created_at);

Запрос get_event:

select * from events e order by created_at asc -- обрабатываем сперва старые limit 1 for update -- блокируем запись в транзакции, чтобы её не взял другой воркер skip locked -- заблокированные ранее записи нас не интересуют ;

Проблемы

  1. Первая проблема — таблица events находится в уже нагруженной и большой БД. Нагрузка на БД настолько большая, что autovacuum просто не успевает дойти до нашей таблицы и очистить от мёртвых слепков (dead tuples), отчего производительность падает колоссально. Чтобы понять, как на запросе и схеме выше будет отрабатывать нечищеная таблица, можете побаловаться вот с таким:

create table events (     id bigserial primary key,     order_id bigserial,     status text not null, -- какой-то статус, неважно: 1, 2, 3     created_at timestamptz not null default now() );  create index events_created_at_idx on events using btree (created_at);  — Отключаем автовакуум, тем самым имитируем сценарий, — когда БД неспособна это сделать из-за большой нагрузки. alter table events set (autovacuum_enabled = off);  -- Добавляем 1 миллион записей в таблицу. insert into events (order_id, status, created_at) select id,        (ceil(random() * 5 + 1))::text,        now() from generate_series(1, 1000000) as id;  -- Смотрим план исполнения. explain analyze verbose select * from events e order by created_at asc -- обрабатываем сперва старые limit 1 for update -- блокируем запись в транзакции, чтобы её не взял другой воркер skip locked -- заблокированные ранее записи нас не интересуют ;  -- 1 миллион записей, время исполнения запроса = ~0.180ms -- 10 миллионов записей, время исполнения запроса = ~0.180ms -- Почти нет разницы, что естественно, индекс отсортирован, -- вытаскиваем всего 1 запись.  -- А теперь удаляем все данные из таблицы. delete from events where 1=1;  -- После чего снова запускаем explain запрос. -- 0 страниц в таблице, запрос исполняется с прогретым кешем ~5ms -- что ~ в 25 раз медленнее того, когда в базе было 10 миллионов записей. -- Магия MVCC PostgreSql, если интересно подробнее почему так, выше, -- в начале, в ссылке есть статья на соответствующую тему. -- Если повторить трюк с добавлением + удалением 10млн записей ещё раз, -- получим следующее время запроса ~68ms, что уже примерно в 350 раз -- более медленнее, чем до удаления записей.  -- Так можно посмотреть, что таблица на самом деле не пуста и что там находятся -- миллионы мертволежащих слепков от удалённых ранее страниц. Причем лежат -- они таким образом, что ascending сортировка работает максимально не оптимально. select relname AS ObjectName ,pg_stat_get_live_tuples(c.oid) AS LiveTuples ,pg_stat_get_dead_tuples(c.oid) AS DeadTuples from pg_class c where relname = 'events'; 
  1. Берем блок на order_id в загруженном Redis. Дело было давно, но, если мне не изменяет память, это занимало порядка 150мс, что никуда не годится, но там было много наслоений легаси + сам кластер постоянно стремился упереться в CPU.

  2. Много холостых запросов в БД, причем таких было чуть ли не больше тех, что реально брали событие в работу. Проблема сохранялась даже с учетом внедрения дрожания между итерациями воркера.

  3. Защита от wraparound failure, БД просто иногда отказывалась что-либо сохранять или менять в данных. Но тут, к сожалению, вариантов немного: либо ставить отдельный инстанс базы, либо искать самые нагруженные места и оптимизировать их в существующей базе. Тут выбрали второй подход, на это уже стояли таски, RND. Также опущу момент с администраторскими настройками вакуума и самой PG, а то статья, опасаюсь, будет слишком большой. Расскажу только, что был сделан костыль, БД ставилась на профилактику, время от времени.

Решения

  1. Первым делом начали искать, можно ли как-то, не делая ничего с БД, ускорить запрос? Оказалось, что можно. Самая зависающая часть в запросе — это order by. Из-за природы хранения слепков (как и почему так — отдельная большая тема) у базы начинаются сложности именно с ascending сортировкой. Справились с проблемой довольно просто:

-- Делаем дополнительный индекс на order_id. create index events_order_id_idx on events using btree (order_id);  -- Меняем запрос get_event таким образом. select * from events e where order_id = ( -- Уменьшаем выборку для сортировки, -- то есть вернутся статусы только по -- конкретному заказу, а это на порядки меньше, -- чем эвентов в общем. select order_id from events order by id desc limit 1 ) -- Из-зв where клозы выше отсортировать нужно единицы записей. order by created_at desc limit 1 for update skip locked;  -- Время исполнения 0.150ms, что даже быстрее, чем первоначальный -- запрос, при этом ему еще и обилие мертвых слепков нипочём. 

Внимательный читатель заметит тут единственный костыль, а именно order by id desc на строке 15. Дело все в том, что, если сортировать asceding , сталкиваемся с той же проблемой, что и раньше. Чтобы отдать последнюю запись, descending сортировка, нужно просто обратиться один раз в конец btree, а в случае asc придется идти по дереву, проверяя по пути все мертвые слепки.

То есть раньше работало так:
— Достаем самое старое событие.

Сейчас же:
— Достаем самое старое событие для последнего, добавленного order_id

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

  1. Тут решение оказалось проще, чем мы думали: просто отказались от Redis. Решаем сразу 2 проблемы: походы в Redis и холостые запросы. PG и сама за нас почти бесплатно возьмет блокировку, да еще и заботливо её отпустит на commit\rollback. За нас все это сделаетpg_try_advisory_xact_lock. Запрос теперь выглядит вот так:

select * from events e where order_id = ( select order_id from events -- Блочим последний идентификатор, -- если не получилось, берем предпоследний. -- Выборка order by отрабатывает за линейное время, -- никак существенно не влияя на скорость исполнения. where pg_try_advisory_xact_lock(order_id) order by id desc limit 1 ) order by created_at desc limit 1;  -- "for update skip locked" больше не требуется, -- другой статус для заблокированного order_id взять не получится. 

Теперь схема выглядит в разы проще:

Схема и диаграмма работы воркера после оптимизации

Схема и диаграмма работы воркера после оптимизации

Резюмирую

Можно было бы, конечно, поставить новый инстанс базы, но на самом деле схема таблицы events больше и там много внешних ключей и связей, переезд был бы довольно болезненным. Вместо этого пошли по пути экспериментов, смотрели на решения по типу PGQ, под капотом там, по факту, — прямая работа с PG через SQL, ничего магического. Пробовали различные комбинации btree индексов, по-разному отсортированных btree индексы, hash индексы (который работал значительно медленнее в данной ситуации). Но все эксперименты не заняли много времени и отняли в разы меньше человеко-часов, чем если бы мы стали как-то глобально менять архитектуру, а это, как мне кажется, один из самых главных компромиссов в деле разработчика. Идеальных решений не бывает, но часто вещи можно значительно улучшить, причём сравнительно небольшими усилиями.


ссылка на оригинал статьи https://habr.com/ru/articles/848768/