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

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

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

Примеры на PostgreSQL, но логика почти везде одинаковая.

Как понять, что тормозит именно база

Типичные признаки: растёт время ответа на p95 и p99, а не только среднее. Нагрузка на CPU или диск базы высокая, при этом приложение простаивает. В логах появляются таймауты ожидания соединения из пула. Запрос, который раньше выполнялся за миллисекунды, иногда внезапно занимает секунды.

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

Три разных диагноза

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

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

Много быстрых запросов. Каждый по отдельности быстрый, но их тысячи в секунду, и базе просто не хватает ресурсов. Классический пример: N+1, когда на каждую строку списка уходит отдельный запрос. Лечится сокращением числа запросов, кэшем, пулом соединений.

Ожидание. Запросы сами по себе быстрые, но стоят в очереди за блокировкой. CPU при этом может быть почти свободен. Лечится изменением того, как мы обновляем данные.

Если перепутать диагноз, вы потратите неделю на индекс, который ничего не изменит.

Чем смотреть

Для первого сорта проблем нужен EXPLAIN (ANALYZE, BUFFERS). В плане видно, где идёт Seq Scan по большой таблице, сколько строк оценил планировщик и сколько получилось на деле, сколько страниц прочитано с диска.

Для второго сорта подойдёт расширение pg_stat_statements. Оно показывает запросы, отсортированные по суммарному времени и числу вызовов. Часто верхняя строка этого списка оказывается совсем не тем запросом, на который грешили.

Для третьего смотрим pg_stat_activity. Поля state и wait_event показывают, чего именно ждут сессии. Если много сессий висит в ожидании блокировки, а pg_locks показывает, кто её держит, диагноз поставлен.

Кейс: счётчик голосов и один мьютекс

Вот история, которую я услышал на собеседовании. Без названия компании и без имён, нам важна сама техническая схема.

Задача звучала так. У карточки есть счётчик голосов. Голосуют тысячи запросов одновременно. Как сделать так, чтобы счётчик считал правильно?

Наивная реализация выглядит безобидно:

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

Приложение читает значение, прибавляет единицу и записывает обратно. При одном пользователе всё работает. При тысяче одновременных запросов два из них могут прочитать 100, и оба запишут 101. Один голос потерян. Это классическая гонка read-modify-write, и со стороны она выглядит так, будто счётчик иногда не увеличивается.

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

Корректность это даёт. Потерянных голосов больше нет. Но у решения несколько проблем.

Пропускная способность равна одному запросу за раз. И блокировка держится не только на время +1, а на всё время похода в базу: сетевой round-trip, выполнение, ответ. Если обращение занимает пять миллисекунд, больше двухсот голосов в секунду так не обработать. Остальные ждут в очереди, очередь растёт, начинаются таймауты.

Лок общий на все карточки. Голосуют за разные карточки, а ждут друг друга всё равно. Конфликт есть только внутри одной карточки, а блокируем всех.

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

Запись сериализуется на уровне приложения, хотя это работа базы. База умеет делать это эффективнее и корректнее.

Честности ради: мьютекс не глупость. Для одного процесса и небольшой нагрузки он вполне рабочий. Но если вопрос задан про тысячи запросов, он отвечает на какой-то другой.

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

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

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

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

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

INSERT INTO votes (card_id, user_id) VALUES (42, 7)
ON CONFLICT (card_id, user_id) DO NOTHING;

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

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

Шаг 4. Горячая карточка. Остаётся сценарий, когда за одну карточку голосуют очень много и одновременно. Блокировка строки превращается в очередь: каждое обновление ждёт предыдущее. Здесь есть три приёма.

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

Общий порядок действий

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

  1. Запросы и индексы. Топ из pg_stat_statements, планы через EXPLAIN ANALYZE. Часто хватает одного индекса. Но индексы не бесплатны: каждый замедляет запись и занимает место, поэтому лишние нужно удалять.
  2. Число обращений. Устранить N+1, брать только нужные поля, объединять мелкие запросы.
  3. Пул соединений. У PostgreSQL соединение дорогое. Сотни прямых соединений от приложения работают хуже, чем десятки через пулер вроде PgBouncer.
  4. Кэш. Для данных, которые часто читаются и редко меняются. Заранее продумайте, как кэш будет инвалидироваться, иначе получите баги, которые сложно воспроизвести.
  5. Реплики на чтение. Разгружают основную базу, но у репликации есть лаг. Пользователь только что записал данные и не видит их на следующей странице. Это нужно учитывать в логике.
  6. Партиционирование. Большие таблицы, например по дате, режутся на части внутри одной базы. Запросы и обслуживание становятся быстрее.
  7. Шардирование. Данные физически разъезжаются по разным серверам. Это решает предел одной машины, но приносит запросы между шардами, сложные транзакции и ребалансировку при росте. Дёшево шардирование не бывает.

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

Что запомнить

Сначала диагноз, потом лечение. Один тяжёлый запрос, много лёгких и ожидание на блокировках выглядят одинаково медленно, а решения у них разные.

Работу с конкурентными записями лучше отдавать базе. Атомарный UPDATE с блокировкой строки делает то, что мьютекс в приложении делает хуже: корректно работает при нескольких инстансах и блокирует только то, что реально конфликтует.

Масштабирование идёт по ступеням. Шардирование стоит в самом конце, и прийти к нему стоит только после того, как проверено всё, что дешевле.

И последнее. Если на собеседовании или на code review вам говорят «поставили мьютекс», стоит спросить: на какое время, на что именно и что будет со вторым инстансом. Эти три вопроса обычно вскрывают весь разговор.