.NET Tutorial
Senior7 мин чтенияВопрос 7 из 12

Высокая нагрузка: как масштабировать чтение и запись (кэши, реплики, CQRS, очереди, шардирование)?

Стратегия по шагам

Измерить: профилирование,
медленные запросы, метрики

Оптимизировать запросы,
индексы, N+1, проекции

Кэширование

Реплики для чтения

Асинхронная обработка
и батчинг записей

Read-модели (CQRS),
поисковые индексы

Шардирование

Измерить: профилирование,
медленные запросы, метрики

Оптимизировать запросы,
индексы, N+1, проекции

Кэширование

Реплики для чтения

Асинхронная обработка
и батчинг записей

Read-модели (CQRS),
поисковые индексы

Шардирование

Порядок действий при росте нагрузки

Масштабирование чтения

Многоуровневый кэш

Браузер
Cache-Control, ETag

CDN / edge
статика, публичные ответы

Балансировщик /
Output Cache

Приложение
L1: in-memory

L2: Redis

БД

Браузер
Cache-Control, ETag

CDN / edge
статика, публичные ответы

Балансировщик /
Output Cache

Приложение
L1: in-memory

L2: Redis

БД

Уровни кэширования
Уровень Что кэшировать Риски
Браузер / CDN Статика, публичные страницы и ответы API (каталог) Персональные данные в публичном кэше, инвалидация
Output cache Целые HTTP-ответы Вариации по пользователю/языку
In-memory (L1) Горячие, редко меняющиеся данные; микросекунды Расхождение между экземплярами
Redis (L2) Общие данные для всех экземпляров; ~1 мс Отказ Redis, «стадо» при промахе

HybridCache (.NET 9) объединяет L1 + L2 и защищает от cache stampede (одновременная перезагрузка одного ключа множеством запросов).

Реплики

запись + чтение своих изменений

отчёты, списки, поиск

чтение

репликация
(задержка мс–с)

Приложение

Primary

Реплика 1

Реплика 2

запись + чтение своих изменений

отчёты, списки, поиск

чтение

репликация
(задержка мс–с)

Приложение

Primary

Реплика 1

Реплика 2

Разделение чтения и записи
Два контекста: для записи и для чтения
builder.Services.AddDbContext<ShopDbContext>(o => o.UseNpgsql(cfg.GetConnectionString("Primary")));
builder.Services.AddDbContext<ShopReadDbContext>(o => o
    .UseNpgsql(cfg.GetConnectionString("Replica"))
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Помните о задержке репликации: после записи пользователь может не увидеть изменения на реплике (решения — в вопросе о моделях согласованности).

Read-модели и предвычисление

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

Масштабирование записи

Асинхронность и очереди

БДВоркеры (N)ОчередьAPIКлиентPOST /events (10 000/с)опубликовать202 Acceptedзабрать пачку (100сообщений)один INSERT на 100 строк
БДВоркеры (N)ОчередьAPIКлиентPOST /events (10 000/с)опубликовать202 Acceptedзабрать пачку (100сообщений)один INSERT на 100 строк
Принять быстро — обработать позже

Очередь сглаживает пики: БД получает постоянную комфортную нагрузку, а не всплески. Цена — задержка обработки и необходимость идемпотентности.

Батчинг

Накопление записей и пакетная вставка
var channel = Channel.CreateBounded<ClickEvent>(new BoundedChannelOptions(50_000) { FullMode = BoundedChannelFullMode.DropOldest });

// фоновой обработчик: до 1000 событий или каждые 500 мс
await foreach (var batch in channel.Reader.ReadAllAsync(ct).Chunk(1000))
{
    await using var writer = await conn.BeginBinaryImportAsync("COPY clicks (link_id, at, country) FROM STDIN (FORMAT BINARY)", ct);
    foreach (var e in batch) await writer.WriteRowAsync(ct, e.LinkId, e.At, e.Country);
    await writer.CompleteAsync(ct);
}

Одна вставка 1000 строк через COPY на порядки дешевле 1000 отдельных INSERT с транзакциями.

Горячие строки

Счётчик просмотров популярного товара, остаток популярного товара во время распродажи — все записи конкурируют за одну строку (блокировки).

Приём Как
Шардированный счётчик N строк-счётчиков, запись в случайную, чтение — сумма
Агрегация в памяти/Redis INCR в Redis, периодический сброс в БД
Append-only Записывать события, а итог считать агрегацией
Резервирование пачками Выдавать воркерам «квоты» остатка вместо списания по одному

Шардирование

Приложение

shard = hash(customer_id) % 4

Шард 0

Шард 1

Шард 2

Шард 3

Приложение

shard = hash(customer_id) % 4

Шард 0

Шард 1

Шард 2

Шард 3

Маршрутизация по ключу шардирования

Выбор ключа — самое важное решение: запросы должны в основном затрагивать один шард (все данные клиента — в его шарде), ключ должен равномерно распределять нагрузку (не дата — все записи «сегодня» попадут в один шард). Используйте consistent hashing или таблицу маршрутизации, чтобы перераспределение не требовало переноса всех данных. Готовые решения: Citus для PostgreSQL, Vitess для MySQL, распределённые SQL-БД (CockroachDB, YugabyteDB).

Защита при перегрузке

Входящий трафик
выше ёмкости

Rate limiting
по клиенту/тенанту → 429

Backpressure
ограниченные очереди

Load shedding
отбросить низкоприоритетное

Деградация
отключить рекомендации,
отдать кэш

Входящий трафик
выше ёмкости

Rate limiting
по клиенту/тенанту → 429

Backpressure
ограниченные очереди

Load shedding
отбросить низкоприоритетное

Деградация
отключить рекомендации,
отдать кэш

Что делать, когда нагрузка выше ёмкости

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

Пример: лента новостей

Подход Как работает Плюсы Минусы
Fan-out on read При открытии ленты собрать посты всех подписок Дешёвая запись Дорогое чтение
Fan-out on write При публикации записать пост в ленты всех подписчиков Мгновенное чтение Знаменитость с 10 млн подписчиков — 10 млн записей
Гибрид Обычным — fan-out on write, знаменитостей подмешивать при чтении Баланс Сложность

Вопросы на засыпку

Почему кэш может уронить базу данных?

При массовом истечении ключей или перезапуске Redis все запросы одновременно идут в БД (cache stampede / thundering herd). Защита: случайный разброс TTL, блокировка перезагрузки одного ключа (single flight, HybridCache), прогрев кэша, ограничение параллелизма запросов к БД.

Когда реплики не помогают?

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

Чем партиционирование отличается от шардирования?

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

Как оценить, хватит ли одной базы данных?

Посчитать пиковые RPS записи и чтения, объём данных и рабочего набора, сравнить с возможностями сервера (с запасом 2–3×). Учесть, что большую часть чтения можно снять кэшем и репликами. Многие системы с миллионами пользователей живут на одном хорошо настроенном primary с репликами.

Итог

Масштабирование начинается с измерений и оптимизации запросов. Чтение масштабируют кэшами на нескольких уровнях, репликами и предвычисленными read-моделями; запись — асинхронной обработкой через очереди, батчингом, снижением конкуренции за горячие строки и, в крайнем случае, шардированием по правильно выбранному ключу. Каждый шаг покупается ценой согласованности и сложности, а под перегрузкой лучше быстро отказывать и деградировать, чем падать целиком.