черновик · ~8 мин чтения
Черновик v4, не опубликован

База стала узким местом. Сначала диагноз, потом шардирование

Привет, Хабр! Меня зовут Михей, я разработчик. Когда сервис начинает тормозить, первая мысль у многих звучит так: база не вывозит, пора шардировать. Между «тормозит» и «шардируем» обычно лежит шесть шагов, которые стоят заметно дешевле. Покажу, в каком порядке я по ним иду.

По дороге разберём историю про счётчик голосов, которую мне рассказали на одном собеседовании. Я воспроизвёл её на стенде и получил цифры, а не впечатления. Примеры на PostgreSQL 16, все замеры мои, условия и оговорки описаны рядом с результатами.

Три разных «медленно»

Фраза «база тормозит» прячет три разные ситуации, и лечатся они по-разному.

Три диагноза
Рис. 1. Три диагноза и чем их отличать друг от друга · нажмите, чтобы увеличить

Если спутать диагнозы, неделя уйдёт на индекс, который ничего не изменит. Поэтому первый вопрос такой: сколько длится запрос в приложении и сколько в самой базе. Большая разница значит, что время съели сеть, пул соединений или очередь, и база ни при чём.

Чем смотреть

Для первого диагноза нужен EXPLAIN (ANALYZE, BUFFERS). Я создал таблицу на 2 млн строк с голосами и посмотрел запрос «голоса за карточку за последние 7 дней».

EXPLAIN до и после
Рис. 2. Один и тот же запрос без индекса и с индексом (card_id, created_at) · нажмите, чтобы увеличить

Без индекса Postgres делает Parallel Seq Scan: 174 мс и 12 739 буферов. С индексом 1,15 мс и 159 буферов, то есть в 150 раз быстрее. Цена у этого тоже есть: индекс весит 60 МБ при таблице в 100 МБ, и каждая запись теперь обновляет ещё и его.

Для второго диагноза пригодится расширение pg_stat_statements. Его нужно подключить через shared_preload_libraries, потом:

SELECT calls,
       round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       left(query, 60)                    AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

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

SELECT state, wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

Если много сессий висит в Lock, то pg_blocking_pids(pid) покажет, кто держит блокировку. С этого места диагноз «ожидание» уже можно ставить.

История про счётчик голосов

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

Сначала про то, зачем мьютекс вообще понадобился. Наивная реализация выглядит безобидно:

votes = SELECT votes FROM cards WHERE id = 42   -- прочитали, например 100
votes = votes + 1                               -- прибавили в коде
UPDATE cards SET votes = :votes WHERE id = 42   -- записали 101

На одном пользователе всё работает. На тысяче одновременных два запроса могут прочитать 100 и оба записать 101. Один голос потерян, а ошибки нет.

Гонка
Рис. 3. Потерянное обновление: оба UPDATE выполнились успешно · нажмите, чтобы увеличить

На моём стенде это выглядит жёстко: при 100 параллельных клиентах на одну карточку во всех шести прогонах потеря почти полная: обработано от 4 431 до 8 683 голосов, а в базе осталось от 50 до 96. Это крайний случай, одна горячая строка и сто писателей, но он показывает масштаб.

Мьютекс это лечит: записи идут по одной, потерь нет. Дальше возникает вопрос цены, и я её замерил.

Что показал замер

Стенд: PostgreSQL 16.15 в изолированном контейнере без сети (2 CPU, 1 ГБ), pgbench, 100 клиентов, 6 прогонов по 12 секунд на каждый вариант (две серии по три). Мьютекс я эмулировал advisory-локом, который держится на весь цикл «прочитал, прибавил, записал». По смыслу это тот же мьютекс процесса, обёрнутый вокруг похода в базу.

Замер
Рис. 4. Голосов в секунду, медиана из 6 прогонов · нажмите, чтобы увеличить

Что видно:

Оговорка. Пока я мерил, на хосте крутилась посторонняя нагрузка (load average от 6 до 28), поэтому абсолютные числа низкие и шумные. Сравнивать нужно отношения, а не сами цифры. Это один хост и шесть коротких прогонов, а не бенчмарк для публикации в журнале. Разброс между прогонами большой (белые усы на рис. 4): в первой серии из трёх прогонов отношения мьютекса к атомарному UPDATE вышли 1,4 (одна карточка) и 14 (тысяча карточек), во второй 1,2 и 23. Поэтому верьте порядку величин, а не второму знаку. Есть и вторая оговорка. Я пробовал сделать мьютекс дешевле: снимать блокировку в том же запросе, что и UPDATE. Счётчик транзакций pgbench в том варианте оказался примерно вдвое больше числа голосов в таблице. Скорее всего, блокировка снималась до коммита, и следующий клиент успевал прочитать старое значение. Причину я отдельно не проверял, а эти прогоны в статью не включил. Если это объяснение верно, урок простой: мьютекс нужно отпускать только после коммита.

Ту же картину показывает pg_stat_activity. Я снял срез на 50 клиентах. При мьютексе 49 сессий из 50 ждали Lock / advisory. При атомарном UPDATE на одной карточке 46 из 50 ждали Lock / transactionid. Очередь есть и там, и там. Разница в том, где она стоит: за мьютексом в приложении все ждут всех, а в базе на блокировке строки ждут только те, кто пишет в одну и ту же карточку.

Блокировка
Рис. 5. Один мьютекс на всех и блокировка строки · нажмите, чтобы увеличить

Отдельно, уже без замера, а по устройству: мьютекс живёт внутри одного процесса. Как только вы запустите второй под, у каждого окажется свой мьютекс, и потерянные голоса вернутся. То есть решение ломается ровно тогда, когда вы масштабируете сервис, чтобы справиться с нагрузкой.

Мем про мьютекс
Рис. 6. Если сериализовать всё подряд, конкуренция действительно пропадёт · нажмите, чтобы увеличить

Как сделать лучше

Шаг 1. Атомарное обновление в базе.

UPDATE cards SET votes = votes + 1 WHERE id = 42;

Чтение и запись происходят одной операцией, гонки нет. Блокируется только строка этой карточки и только до конца транзакции. Работает при любом числе инстансов приложения, мьютекс из кода можно удалять.

Шаг 2. Защита от повторного голоса. Обычно нужно ещё, чтобы пользователь не голосовал дважды. Таблицу голосов делаем с уникальным ключом (card_id, user_id) и увеличиваем счётчик только если голос реально вставился:

WITH ins AS (
  INSERT INTO votes (card_id, user_id) VALUES (42, 7)
  ON CONFLICT DO NOTHING
  RETURNING 1
)
UPDATE cards SET votes = votes + 1
WHERE id = 42 AND EXISTS (SELECT 1 FROM ins);

Я прогнал это на трёх голосах, два из которых были от одного пользователя: счётчик показал 2. Всё в одной короткой транзакции, потому что блокировка строки живёт до её конца, и каждая лишняя операция удлиняет очередь.

Шаг 3. Не считать на чтении. Соблазн показывать COUNT(*) по таблице голосов при каждом открытии карточки. На десятке голосов этого не видно, на миллионе получаете тяжёлый запрос на каждый показ. Держите готовое число в карточке или в кэше.

Шаг 4. Горячая карточка. Если за одну карточку голосуют очень много и одновременно, блокировка строки превращается в очередь. Тут три приёма. Первый я проверил замером: вместо одной строки заводим 16 и пишем в случайную, а при чтении суммируем.

UPDATE card_votes_shards SET votes = votes + 1
WHERE card_id = 42 AND shard = floor(random() * 16)::int;

SELECT sum(votes) FROM card_votes_shards WHERE card_id = 42;

Два других приёма я не замерял, поэтому только описываю. Батчинг: голоса копятся в памяти или в Redis через INCR, а в базу уходят пачкой раз в секунду. Очередь с воркером: запросы кладутся в очередь, один потребитель сворачивает их и пишет в базу. У всех трёх одна цена: число на экране может отставать на секунду-другую. Прежде чем выбирать, честно ответьте себе, нужна ли здесь точность в реальном времени. Для счётчика лайков обычно нет, для остатка товара на складе обычно да.

Лестница масштабирования

Если вернуться от счётчика к базе в целом, я иду по лестнице снизу вверх. Каждая ступень дороже предыдущей, и на каждой есть шанс остановиться.

Лестница
Рис. 7. От дешёвого к дорогому · нажмите, чтобы увеличить

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

Чего я бы не делал

Короткий чек-лист

  1. Сравните время запроса в приложении и в базе.
  2. Посмотрите топ pg_stat_statements по total_exec_time.
  3. На подозрительном запросе запустите EXPLAIN (ANALYZE, BUFFERS).
  4. Если запросы быстрые, а ответ медленный, откройте pg_stat_activity и посмотрите wait_event.
  5. Конкурентные записи отдавайте базе: атомарный UPDATE, а не «прочитал и записал» в коде.
  6. Идите по лестнице снизу вверх.

Итоги

«Тормозит» это три разных диагноза, и сначала нужно поставить правильный. Мьютекс вокруг записи в базу решает корректность и платит за это пропускной способностью: на одной карточке разница с атомарным UPDATE небольшая, а на разных карточках в моём замере около 20-кратная. Шардирование стоит в самом конце лестницы.

А как у вас? Встречали мьютексы вокруг базы, и чем это закончилось? Расскажите в комментариях.