Базы данных

Зачем backend-разработчику знать несколько БД, а не только Postgres

Однобазный подход уже не работает для сложных продуктов

Фраза «знаю SQL и Postgres» на уровне Senior backend-разработчика воспринимается как недостаточная подготовка. В реальных системах, которые обслуживают миллионы пользователей и петабайты данных, одна база данных — это исключение, а не правило.

Классическая реляционная база может быть идеальной для финансовых транзакций, но совершенно неэффективной для полнотекстового поиска по каталогу товаров. Она отлично справляется с десятками миллионов строк, но ломается на сотнях миллиардов временных рядов. RDBMS умеет гарантировать сильную консистентность, но платит за это дорогим масштабированием, когда данные не помещаются на один узел.

На собеседованиях Senior-уровня вопрос о выборе технологии — это не «Postgres или MySQL?», а «какую комбинацию БД мы используем для разных слоев и почему?» Именно этот уровень мышления отличает разработчика, готового проектировать архитектуру, от человека, который просто пишет CRUD-код.

Связь выбора БД с архитектурой системы

Выбор хранилища — это не техническая мелочь, это стратегическое решение, которое определяет:

  • Архитектуру микросервисов: если один сервис работает с PostgreSQL для учёта, а другой использует Redis для сессий, а третий — Elasticsearch для поиска, это не случайность, это следствие разных требований к этим сервисам.

  • Профиль нагрузки: RDBMS спроектирована с предположением, что данные помещаются в памяти узла и его SSD. Если нужно обработать петабайт логов, эта архитектура не масштабируется за разумную стоимость.

  • Гарантии консистентности: система платежей требует ACID-гарантий на каждый транзакции. Рекомендации товаров допускают отставание на часы. Это диктует разные выборы БД.

  • Требования к latency: когда API должен ответить за 50 миллисекунд, ты не можешь позволить себе сканирование таблицы в 100 миллионов строк, даже если есть индекс. Нужен другой подход.

  • Модель данных: одна БД с жёсткой схемой и нормализованными таблицами может хорошо работать для CRM, где структура стабильна. Но для user-generated content, где каждый документ может отличаться по структуре, нужна гибкость документной БД.

На собеседовании это звучит так: «Мы не просто выбрали Postgres, потому что все его используют. Мы проанализировали требования, выделили три сценария нагрузки и решили, что транзакции — в RDBMS, кеш часто читаемых данных — в Redis, полнотекстовый поиск — в Elasticsearch. Это позволило масштабировать каждый слой независимо и оптимизировать каждый под его паттерны доступа».

Реальные сценарии выбора

Когда классическая RDBMS идеальна:

Финансовые системы, платежи, бронирование билетов, системы управления складом. Любой сценарий, где нужно гарантировать, что одна операция либо полностью выполнена, либо полностью откачена. Где множество связей между таблицами (заказ → товар → склад → доставка) требуют сложных join'ов и где нарушение целостности данных означает финансовые потери. ACID и транзакции — не опция, а требование.

Когда RDBMS начинает хромать:

Попытка хранить в PostgreSQL логи приложения. Миллион записей в секунду, каждая запись — это просто набор полей (timestamp, уровень, сообщение, trace). RDBMS вынуждена:

  • парсить каждую запись в строку;
  • проверять ограничения (которых здесь нет);
  • обновлять индексы (нам они не нужны);
  • синхронизировать на диск (это тормозит).

В этот момент нужна time-series БД (InfluxDB, TimescaleDB), которая спроектирована на предположении, что это монотонно растущий stream временных рядов. Она пишет в разы быстрее.

Когда нужен кеш:

Пользователь авторизуется, загружается его профиль из Postgres (30 миллисекунд). Он кликает на профиль друга, загружается его профиль (30 миллисекунд). Потом кликает на ещё трёх. За одну секунду 20 запросов к Postgres в поиске одних и тех же популярных профилей.

Redis решает это: храни горячие профили в памяти, ответь за 1 миллисекунду. Когда профиль обновляется, инвалидируй кеш. Это не усложнение архитектуры — это оптимизация.

Когда нужен поисковый движок:

Каталог содержит 100 миллионов товаров. У каждого 200 полей (название, описание, характеристики, отзывы и т.д.). Пользователь ищет: «красные кроссовки для бега размер 42».

SQL-запрос в Postgres:

SELECT * FROM products 
WHERE color LIKE '%red%' 
AND category LIKE '%shoes%' 
AND type LIKE '%running%' 
AND size = 42

Это полное сканирование таблицы, 20 секунд отклика. Elasticsearch делает то же самое за 100 миллисекунд за счёт инвертированных индексов, которые хранят не строки, а токены.

Когда нужна columnar БД:

Аналитический вопрос: «Какой был средний spend по стране по месяцам за последние три года?» На 5 миллиардах транзакций.

В OLTP-БД (Postgres, MySQL) это требует сканирования всех 5 миллиардов строк. Columnar БД (Clickhouse, BigQuery) сканирует только колонки, которые нужны (страна, spend, дата), это на порядок быстрее. Плюс колоночное хранение лучше сжимается, потому что похожие значения расположены рядом.

Как мышление в разных моделях повышает ценность разработчика

Senior backend-разработчик — это не тот, кто пишет CRUD в Spring Boot. Это архитектор, который смотрит на требования и спрашивает:

  • «Какие паттерны доступа к этим данным?» — от этого зависит всё.
  • «Насколько критична консистентность? Насколько критична latency?» — это определяет выбор технологии.
  • «Какой профиль нагрузки — read-heavy, write-heavy, balanced?» — это диктует, как масштабировать.
  • «Стабилиция ли структура данных или она будет эволюционировать?» — это влияет на схему.

Это мышление позволяет:

  1. Не делать ошибки при масштабировании. Вместо того, чтобы «добавить ещё индекс» в Postgres, ты видишь, что паттерны доступа требуют ключ-значение, и добавляешь Redis. Это масштабируется на порядки лучше.

  2. Выбирать правильные абстракции в коде. Когда данные хранятся в Postgres, пишешь JPA Entity, когда в Elasticsearch — отдельный DTO, когда в Redis — отдельный сервис. Неправильная абстракция связывает бизнес-логику с деталями БД.

  3. Общаться с инженерами на одном языке. DevOps спрашивает: «Какая БД нам нужна?» Ты не отвечаешь «Postgres», ты отвечаешь: «Postgres для транзакций, Redis для сессий, потому что сессии короткоживущие и нужны за миллисекунды».

  4. Давать правильные оценки сроков. Если в тех-задаче есть требование — «поиск по млн документов за 100мс» — ты знаешь, что Postgres для этого не подходит по архитектурным причинам, а не потому, что «просто нужен лучший индекс». Это экономит недели бесполезной оптимизации.

На собеседовании это звучит как уверенное: «Да, я знаю SQL и Postgres хорошо, но в последнем проекте я работал с системой, где для разных сценариев нужны были разные БД. Я проектировал архитектуру, которая использует Postgres для ядра, Redis для кеша, Elasticsearch для поиска, и InfluxDB для метрик. Я могу объяснить, почему каждая».


Классы баз данных: обзор карты мира

Что означает класс БД

Класс БД — это не просто название продукта (Postgres, MongoDB, Redis), это целая парадигма, которая характеризуется:

  • Моделью данных: как физически организованы данные. Таблицы? Документы? Пары ключ-значение? Графы? От этого зависит, какие запросы естественны, а какие становятся мучением.

  • Типичными паттернами доступа: какие операции быстрые, какие медленные. В key-value хранилище операция «дай значение по ключу» — O(1). Операция «найди все ключи, содержащие подстроку» — сложна или невозможна.

  • Гарантиями консистентности: ACID? BASE? Eventual consistency? На каком уровне? Одна строка? Транзакция? Весь кластер? Это определяет, какие ошибки возможны.

  • Ожидаемыми нагрузками: write-heavy? read-heavy? Balanced? Малые объёмы данных или петабайты? Latency в миллисекундах или в секундах?

Например, Redis:

  • Модель: ключ → строка, список, хеш, множество.
  • Паттерны: быстрый доступ по ключу, работа с простыми структурами.
  • Консистентность: сильная консистентность в пределах одного узла (если replica lag не считать), нет транзакций между разными ключами.
  • Нагрузка: write-heavy и read-heavy, но фокус на latency, а не на объём.

Классические реляционные БД (RDBMS)

Модель данных и язык запросов

Таблицы, строки, столбцы. Каждая таблица имеет схему — чётко определённое множество столбцов с типами. Связи между таблицами — через первичные ключи и внешние ключи. Нормализация — разбиение данных на таблицы, чтобы избежать дублирования.

SQL — универсальный язык запросов, позволяет:

  • выбирать данные из одной или нескольких таблиц (SELECT ... FROM ... WHERE ...);
  • объединять таблицы через join'ы;
  • агрегировать данные (SUM, COUNT, GROUP BY);
  • обновлять, удалять, вставлять данные атомарно в рамках транзакции.

Сильные стороны RDBMS

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

Мощный язык запросов. SQL позволяет выразить сложные вопросы: «Найди всех клиентов, которые купили товары из категории A и B, но не из C, за последние месяцы, и рассчитай их среднее значение покупки». Это одна сложная query, а не цикл по коллекции в коде.

Хорошо подходит для транзакций. ACID-гарантии означают, что операция либо полностью выполнена, либо полностью откачена. Если платёж прошёл, деньги определённо списаны. Нет промежуточных состояний.

Долгая история и зрелость. RDBMS существует 50 лет. Проблемы известны, решения отработаны. Есть примеры, есть best practices, есть огромное сообщество.

Типичные сценарии RDBMS

  • Финансовые системы (банки, платежи, инвестиции): где ошибка стоит денег.
  • Системы управления (CRM, ERP, HR): где нужна сложная логика ограничений и связей.
  • Бизнес-аналитика в реальном времени: когда нужны свежие данные и сложные отчёты одновременно.
  • Любые системы, где консистентность данных — это не опция, а требование.

Key-value хранилища

Модель данных

Просто: ключ → значение. Ключ — это уникальная строка или число. Значение — может быть что угодно: строка, число, список, хеш, множество, даже binary blob. Никаких таблиц, никаких схем, никаких отношений. Предельно простая модель.

Особенности

Максимальная скорость доступа по ключу. Если нужно получить данные по ключу, это O(1) или близко к нему. Redis хранит данные в памяти, Memcached тоже, DynamoDB использует хеш-таблицы на дисках.

Отсутствие сложных запросов. Ты не можешь написать «дай мне все ключи, где значение > 100». Это не поддерживается (или поддерживается, но медленно). Если нужны такие запросы — это не то хранилище.

Нет join'ов. Если нужны данные из двух ключей — это две операции в коде. Логика агрегации переходит в приложение, а не остаётся в БД.

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

Типичные роли

Кеш. Горячие данные, которые изменяются редко, но читаются часто. Профили пользователей, конфиги приложения, результаты дорогих вычислений. TTL (time to live) — и данные автоматически удаляются.

Хранилище сессий. Каждая сессия пользователя — это ключ (session_id) и значение (JSON с данными сессии). Быстрый доступ, автоматическое истечение.

Быстрые lookups. «Дай email по user_id», «дай товар по sku». Операция за миллисекунду.

Рейтинг-листы, счётчики. Leaderboards в играх, счётчики просмотров, статистика в реальном времени.

Queue и pub-sub. Redis может работать как очередь сообщений или система pub-sub. Не такая мощная, как RabbitMQ, но для простых случаев хватает.

Документно-ориентированные БД

Модель данных

Коллекции документов, обычно в формате JSON или BSON. Вместо строк в таблицах — документы в коллекциях. Каждый документ может иметь свою структуру, не обязательно совпадать с соседями.

Пример (MongoDB):

{
  "_id": 1,
  "name": "Alice",
  "email": "alice@example.com",
  "profile": {
    "age": 30,
    "city": "Moscow",
    "interests": ["coding", "music"]
  },
  "orders": [
    { "id": 101, "total": 5000 },
    { "id": 102, "total": 3000 }
  ]
}

В реляционной БД это было бы три таблицы (users, profiles, orders) с join'ами. В документной БД — один документ, и вложенные структуры хранятся прямо внутри.

Гибкая схема

Нет предварительного определения структуры. Можешь добавить новое поле к документу без миграции базы данных. Это удобно для быстрого прототипирования, когда структура данных ещё не стабильна.

Сценарии документных БД

Пользовательские профили. Каждый юзер может иметь разные поля: один указал возраст и город, другой указал место работы и образование. Документная БД естественно это поддерживает.

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

Агрегированные сущности. Заказ + список позиций + адрес доставки + история — всё в одном документе. Нет нужды собирать из четырёх таблиц через join'ы.

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

Wide-column / колоночные семейства (Cassandra, HBase)

Модель данных

Строки, сгруппированные в семейства столбцов. На поверхности похоже на таблицу, но логика совсем другая.

Пример логики:

  • Ключ строки: user_id.
  • Семейство столбцов: profile_data (содержит name, email, age).
  • Другое семейство: visit_history (содержит даты и времена посещений).

Каждое семейство может иметь разное количество столбцов в каждой строке. Это гибче, чем RDBMS, но в другом смысле.

Дизайн от запросов, а не от сущностей

Это главное отличие от RDBMS. В Postgres думаешь о сущностях (User, Order, Product) и нормализуешь данные. В Cassandra думаешь о том, как ты будешь читать данные.

Пример: хочешь отвечать на вопрос «дай последние 100 событий пользователя». В Cassandra ты создаёшь таблицу, где ключ — это (user_id, timestamp), и данные уже отсортированы по времени. Запрос — это один seek на диске.

В RDBMS это was бы SELECT ... WHERE user_id = ? ORDER BY timestamp LIMIT 100, и БД должна отсортировать результаты.

Оптимизация для больших write-нагрузок

Cassandra спроектирована для append-only нагрузок. Миллион записей в секунду — нормально. Данные пишутся на диск последовательно (очень быстро), а не в случайные места (медленно).

Сценарии Cassandra

Логирование, event-storage. Миллиарды логов в секунду. Пишешь в append-only таблицу, потом читаешь исторические окна.

Time-series с большими объёмами. Метрики систем, отслеживание географических данных, IoT.

Распределённые системы с миллионами узлов. Cassandra хорошо горизонтально масштабируется, нет единого master'а.

Поисковые движки (Elasticsearch, Solr, Meilisearch)

Модель данных и инвертированные индексы

Документы хранятся и индексируются. Каждый документ — это коллекция полей. При индексировании документ разбивается на токены, и для каждого токена создаётся запись: «токен → список документов, содержащих этот токен».

Это инвертированный индекс. Вместо «документ → его слова» хранится «слово → его документы».

Пример: три товара:

  1. «красные кроссовки для бега»
  2. «синие кроссовки для тенниса»
  3. «красные кеды»

Инвертированный индекс:

  • «красные»:

  • «кроссовки»:

  • «бега»:

  • «синие»:

  • «тенниса»:

  • «кеды»:

Запрос «красные кроссовки»: пересечение и = . Ответ за микросекунды.

Полнотекстовый поиск и scoring

Elasticsearch не просто находит совпадения, он ранжирует результаты по релевантности. Алгоритмы (TF-IDF, BM25) учитывают:

  • как часто слово встречается в документе (term frequency);
  • как редко это слово в целом (inverse document frequency);
  • позиция слова в документе.

Плюс можно добавить custom rules для scoring.

Фильтрация и faceted search

Помимо поиска, Elasticsearch позволяет фильтровать результаты: «красные кроссовки размер 42 цена от 5000 до 10000». Это комбинированный запрос search + filter.

Faceted search: «показать, сколько результатов по каждому размеру, цвету, ценовому диапазону».

Сценарии поисковых движков

Каталог товаров. Сотни миллионов товаров, поиск за 100 миллисекунд, полнотекстовая индексация.

Поиск по логам. Миллиарды логов, нужно найти все ошибки за период, содержащие определённый текст.

Поиск по контенту. Медиабиблиотека, поиск по названиям, описаниям, тегам.

Автосугест. Пока пользователь печатает «красные кр», нужно быстро выдать подсказки.

Columnar / OLAP-БД (Clickhouse, BigQuery, Redshift)

Колонночное хранение вместо строчного

Строчное хранение (OLTP): каждая строка хранится целиком.

user_id name city spend
1 Alice Moscow 5000
2 Bob SPB 3000

Хранится как: [1, Alice, Moscow, 5000][2, Bob, SPB, 3000]...

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

Колонночное хранение (OLAP): каждая колонка хранится отдельно.

user_id: [1, 2, 3, ...]
name: [Alice, Bob, Charlie, ...]
city: [Moscow, SPB, Ekb, ...]
spend: [5000, 3000, 7000, ...]

При запросе средний spend ты читаешь только колонку spend, остальные игнорируешь. Это резко экономит I/O.

Сжатие данных

Колонночные БД хранят похожие значения рядом. Это хорошо сжимается (run-length encoding, dictionary encoding). Spend = сжимается в «5000×3, 3000×2». Вместо 10 байтов на число — 2 байта на число.

OLAP vs OLTP

Аспект OLTP (Postgres) OLAP (Clickhouse)
Модель хранения Строка за строкой Колонка за колонкой
Типичный запрос SELECT * FROM orders WHERE id = 1 SELECT sum(spend) FROM orders WHERE date > '2024-01-01' GROUP BY country
Скорость чтения одной строки Быстро Медленно (нужно прочитать большую таблицу)
Скорость агрегации по колонке Медленно (сканирование) Быстро (колонка компактна)
Оптимальный объём Гигабайты, миллионы строк Терабайты, миллиарды строк
Типичная операция INSERT/UPDATE одной строки bulk INSERT, SELECT с агрегацией

Сценарии OLAP

Аналитика и отчёты. Вопросы вроде «какой топ-10 товаров по продажам по месяцам за три года». На миллиардах строк это требует сканирования большого объёма, но columnar БД это делает быстро.

BI-дашборды. Dashboards часто показывают агрегированные данные. Columnar БД это её стихия.

Data warehouse. Centralized хранилище для всех аналитических данных компании.

Time-series БД (InfluxDB, TimescaleDB, Prometheus)

Модель данных

Временные ряды: sequence точек данных с timestamp'ом. Каждая точка — это:

  • timestamp (когда);
  • метрика/измерение (например, CPU usage);
  • набор меток (labels) — например, hostname, region, environment;
  • value (значение метрики).

Пример (Prometheus-подобный формат):

node_cpu_usage{host="server1", region="eu"} 65.5 1700000000
node_cpu_usage{host="server1", region="eu"} 68.3 1700000060
node_cpu_usage{host="server2", region="us"} 42.1 1700000000

Append-only модель

Time-series БД ориентирована на монотонно растущие потоки данных. Ты почти никогда не обновляешь или не удаляешь старые точки. Ты только добавляешь новые.

Это позволяет:

  • писать чрезвычайно эффективно (append = sequential write на диск);
  • не беспокоиться об ACID-операциях между точками;
  • сильно оптимизировать сжатие.

Retention и downsampling

Retention: автоматическое удаление точек старше определённого возраста. Например, детальные метрики хранятся 7 дней, потом удаляются.

Downsampling: агрегирование старых точек. Вместо хранения 1 миллион точек в секунду за месяц, храни усреднённые значения по часам. Это сохраняет место на диске.

Типичные запросы

«Какой был CPU usage на server1 за последний час?» — быстро, потому что данные отсортированы по timestamp и хранятся компактно.

«Какой средний CPU usage за неделю по всем серверам в каждом регионе?» — тоже быстро, потому что для агрегации используются pre-computed downsamples.

«Дай CPU usage server1 между 10:00 и 11:00 и выведи как график» — это основной use case.

Сценарии time-series

Мониторинг систем. Метрики CPU, память, диск, сеть на каждый сервер. Миллионы метрик в секунду.

Приложения. Custom метрики: latency API, count errors, queue depth.

IoT и сенсоры. Датчики отправляют данные с частотой раз в секунду. Миллиарды датчиков × миллиарды метрик.

Аналитика поведения пользователей. Клики, переходы между страницами, время проведения.


Как сравнивать классы БД между собой в голове инженера

Оси сравнения

Когда ты на собеседовании и нужно выбрать БД для новой системы, ты должен уметь быстро сравнивать варианты. Есть несколько осей:

Модель данных.

  • Табличная (RDBMS) — для строгих схем и отношений.
  • Документная (MongoDB) — для гибких, вложенных структур.
  • Key-value (Redis) — для простых и быстрых lookups.
  • Временные ряды (InfluxDB) — для метрик и событий с временем.
  • Граф (Neo4j) — для связей и путей между сущностями.

Выбор модели часто определяет всё остальное.

Типичные запросы.

  • RDBMS: join'ы, агрегация, фильтрация с множеством условий.
  • DocumentDB: поиск по id, фильтрация по одному полю, вложенные данные.
  • Key-value: поиск по ключу, счётчики, списки.
  • TimeSeries: диапазоны по времени, агрегация с временным разрешением.

Баланс read/write.

  • Read-heavy: кеш (Redis), аналитика (OLAP), поиск (Elasticsearch).
  • Write-heavy: логирование (Cassandra), события (Kafka), time-series (InfluxDB).
  • Balanced: RDBMS, DocumentDB.

Консистентность.

  • Strong ACID: RDBMS, запросы к одной таблице.
  • Strong в пределах узла: Redis, key-value без репликации.
  • Eventual consistency: Cassandra в распределённом режиме, документные БД с asynchronous репликацией.

Выбор консистентности часто определяется требованиями бизнеса, а не технологией.

Требования к latency.

  • Миллисекунды (sub-100ms): Redis, в-памяти индексы, кеши.
  • 10–100 миллисекунд: RDBMS с хорошими индексами, Elasticsearch.
  • Секунды и больше: batch-обработка, аналитика, OLAP.

Объёмы данных и рост.

  • Гигабайты: всё подходит.
  • Терабайты: RDBMS начинает требовать шардирования, нужны специализированные системы (Cassandra, Clickhouse).
  • Петабайты: только специализированные системы (OLAP, cloud data warehouse, Cassandra).

Сложность эксплуатации.

  • Простые: managed сервисы в облаке (RDS, Firestore, DynamoDB).
  • Сложные: Cassandra кластер (нужно понимать gossip protocol, consistency levels, repair).

Примеры быстрых сравнений на собеседовании

RDBMS vs Документная БД

Сценарий: нужно хранить профили пользователей.

SQL-подход (RDBMS):

users
  - id (PK)
  - name
  - email
  - created_at

user_profiles
  - user_id (FK)
  - age
  - city
  - bio

user_interests
  - user_id (FK)
  - interest_name

Для получения всех данных пользователя нужны три query'ки или join.

Document-подход (MongoDB):

{
  "_id": ObjectId("..."),
  "name": "Alice",
  "email": "alice@example.com",
  "profile": {
    "age": 30,
    "city": "Moscow",
    "bio": "..."
  },
  "interests": ["coding", "music", "travel"]
}

Один query, один документ.

Компромисс:

  • RDBMS: строгая схема (safety), все связи на месте, нормализованы (нет дублирования). Но сложнее в коде (нужны join'ы).
  • DocumentDB: гибкость, вложенные данные, естественнее для объектов. Но если структура интересов будет меняться часто, нужно следить, чтобы не разъехались данные.

Рекомендация на собеседовании: «Если структура профиля стабильна, выбираю RDBMS. Если она часто меняется и есть гибкие вложенные данные, DocumentDB. RDBMS даёт мне security и consistency, DocumentDB даёт flexibility».

RDBMS/NoSQL vs Search

Сценарий: каталог товаров, 100 миллионов товаров, полнотекстовый поиск.

RDBMS с LIKE:

SELECT * FROM products 
WHERE name LIKE '%красные кроссовки%' 
OR description LIKE '%красные кроссовки%'
LIMIT 100

Это полное сканирование таблицы. На 100 миллионов строк — 10+ секунд.

Elasticsearch:

{
  "query": {
    "multi_match": {
      "query": "красные кроссовки",
      "fields": ["name", "description"]
    }
  }
}

Инвертированный индекс, ответ за 50 миллисекунд.

Компромисс:

  • RDBMS: могу использовать JOIN'ы, фильтровать по другим полям (цена, размер), гарантирована консистентность. Но поиск медленный.
  • Elasticsearch: отлично для текстового поиска, очень быстро. Но это дополнительная система, нужно синхронизировать данные между Postgres и Elasticsearch, нет join'ов.

Рекомендация: «Использую Postgres как source of truth для данных. Elasticsearch как индекс для поиска. Когда обновляется товар в Postgres, я обновляю его в Elasticsearch через event/message bus».

OLTP vs OLAP

Сценарий: аналитика продаж за три года, 5 миллиардов транзакций.

OLTP-подход (Postgres):

SELECT date_trunc('month', order_date) AS month,
       SUM(total) AS revenue
FROM orders
WHERE order_date >= '2022-01-01'
GROUP BY month
ORDER BY month

Это полное сканирование 5 миллиардов строк. На хорошем сервере — 30+ секунд.

OLAP-подход (Clickhouse):

SELECT toYYYYMM(order_date) AS month,
       sum(total) AS revenue
FROM orders
WHERE order_date >= '2022-01-01'
GROUP BY month
ORDER BY month

Колоночное хранение, сжатие, ответ за 100 миллисекунд.

Компромисс:

  • OLTP (Postgres): хорошо для online-запросов, транзакций, операционные данные свежие. Но аналитику масштабировать сложно.
  • OLAP (Clickhouse): отлично для аналитики, петабайты данных, но insert медленнее (batch-ориентирован), нет транзакций.

Рекомендация: «Операционные данные в Postgres, каждый день булькую их в Clickhouse через ETL-pipeline. Дашборды и аналитика используют Clickhouse».


Критерии выбора БД под задачу

Модель данных: с чего начинать

Выбор БД начинается не с названия (Postgres, MongoDB), а с вопроса: какие сущности и как они связаны?

Структурированные данные с чёткими связями:

  • Финансовые транзакции, где транзакция → счета → валюта → операции.
  • Заказы в e-commerce, где заказ → товары → цены → доставка.
  • Здесь RDBMS идеальна.

Гибкие, вложенные данные:

  • Пользовательские профили, где каждый может иметь разные поля.
  • Контент (статьи, видео), где структура варьируется.
  • Здесь DocumentDB удобнее.

Простые lookups по одному ключу:

  • Сессии, кеши, конфиги.
  • Здесь key-value идеален.

Временные последовательности:

  • Метрики систем, события.
  • Здесь time-series БД обязательна.

Граф связей:

  • Социальные сети, рекомендации.
  • Здесь граф-БД (Neo4j) специализирована.

Эволюция схемы:

  • Будет ли структура меняться часто? RDBMS требует миграций (ALTER TABLE), DocumentDB добавляет поля на лету.

Профиль нагрузки: читаешь или пишешь?

Read-heavy системы:

  • Каталог товаров, новостные ленты, дашборды.
  • Решение: много индексов, кеши, денормализация.
  • RDBMS с индексами, Redis cache, Elasticsearch для поиска.

Write-heavy системы:

  • Логирование, аналитика событий, IoT.
  • Решение: append-only структуры, минимальные индексы, горизонтальное масштабирование на запись.
  • Cassandra, InfluxDB, Kafka.

Balanced системы:

  • Социальные сети (постоянно пишут посты и читают ленту).
  • Решение: balance между индексацией и пропускной способностью.
  • RDBMS с оптимизацией, или комбинация write в одну систему, read из кеша.

Объёмы данных:

  • Гигабайты: любая БД.
  • Терабайты: RDBMS нужна хорошая архитектура (шардирование), или специализированная (Cassandra, OLAP).
  • Петабайты: только специализированные (Cassandra, OLAP cloud systems, Kafka).

Latency требования:

  • Sub-10ms: только in-memory (Redis), или очень оптимизированный индекс (Elasticsearch).
  • 10–100ms: RDBMS с индексами, DocumentDB, search.
  • 100ms–1s: batch-системы, OLAP.
  • Асинхронно: Kafka, очереди.

Консистентность и доступность

Strong consistency требуется:

  • Платежи (деньги списаны → перечислены, не может быть промежуточного состояния).
  • Бронирование авиабилетов (один билет → один пассажир).
  • RDBMS с ACID и транзакциями.

Eventual consistency допускается:

  • Рекомендации товаров (обновятся за часы, это OK).
  • Социальные сети (лайк не появится моментально везде, это OK).
  • Документные БД, Cassandra.

Консистентность данных vs доступность (CAP):

  • Если требуется высокая доступность и раздел сети, consistency sacrifice'ется.
  • RDBMS в single-master режиме: consistency + partition tolerance, но при разделе сети — downtime.
  • Cassandra: availability + partition tolerance, но eventual consistency.

Latency требования

Это часто определяется критичностью пути:

Критичные пути (платежи, авторизация):

  • Допустимо: 100–500ms (это всё ещё OK для юзера).
  • RDBMS с индексами, кеш если возможно.

Операционные пути (добавление товара в корзину, загрузка ленты):

  • Допустимо: 200ms–1s.
  • RDBMS, DocumentDB, поиск.

Фоновая обработка:

  • Допустимо: минуты, часы.
  • Batch-обработка, OLAP, Kafka.

API в реальном времени:

  • Допустимо: <100ms.
  • Redis, in-memory индексы, специализированные системы.

Стоимость и операционная сложность

Инфраструктурные затраты:

  • Managed RDS: дешево ($50/месяц за dev), но может быть дорого при масштабировании.
  • Self-hosted Cassandra: дёшево на compute, но дорого на операции и DevOps.
  • Cloud data warehouse (BigQuery): pay-per-query, дорого при большом объёме, но нет infrastructure.

Лицензии:

  • PostgreSQL, MySQL: бесплатно.
  • Some NoSQL: бесплатно.
  • Proprietary (Oracle, SQL Server): дорого.

Сложность эксплуатации:

  • RDBMS: простая (особенно managed).
  • Cassandra: сложная (нужно понимать кольцо, repair, consistency levels).
  • InfluxDB: средняя.

Нужны ли специалисты:

  • Каждый Java-разработчик знает SQL, RDBMS — низкий барьер.
  • Cassandra — требует опытного DBA.
  • InfluxDB — нужна квалификация в мониторинге.

Примеры выборов в реальных сценариях

Сценарий 1: E-commerce система

Требования:

  • Каталог товаров: 10 миллионов товаров.
  • Поиск: полнотекстовый, 100ms latency.
  • Корзина: быстрое добавление/удаление товаров.
  • Заказы: стопроцентная гарантия, что товар не продан дважды.
  • Отчёты: аналитика продаж по дням/неделям.

Выбор:

  • PostgreSQL: заказы, инвентарь, финансовая информация. ACID гарантирует, что одна единица товара не будет продана дважды.
  • Redis: кеш каталога, корзина пользователя (сессия).
  • Elasticsearch: полнотекстовый поиск по каталогу.
  • Clickhouse: отчёты и аналитика.

Синхронизация:

  • Товары обновляются в PostgreSQL, кешируются в Redis (с TTL).
  • Товары индексируются в Elasticsearch через event stream (когда товар добавляется, событие отправляется в Elasticsearch).
  • Каждую ночь отчёты вычисляются из PostgreSQL и загружаются в Clickhouse через ETL.

Сценарий 2: Мониторинг системы

Требования:

  • Миллион метрик в секунду.
  • Выкладка: CPU, memory, disk, network на каждый сервер.
  • Хранение: 30 дней в high resolution, потом downsampling до месячного.
  • Запросы: «какой CPU был на этом сервере за последний час», «какой средний CPU по всему дата-центру за месяц».

Выбор:

  • InfluxDB или Prometheus: time-series БД специально для этого.
    • Миллион метрик в секунду — это внутри нормального.
    • Append-only, быстрое сжатие.
    • Встроенные retention и downsampling.
    • Querying по временным диапазонам — естественно.

Почему не PostgreSQL?

  • На миллион метрик в секунду PostgreSQL даст 10,000x отставание.
  • Запросы по временным диапазонам в RDBMS требуют индексов и сканирования, в time-series БД это оптимизировано.

Сценарий 3: Логирование приложения

Требования:

  • 100,000 логов в секунду.
  • Логи: timestamp, level, message, trace.
  • Запросы: «все ошибки за последний час», «логи содержащие текст 'database'».
  • Хранение: 7 дней.

Выбор:

  • Elasticsearch: для поиска логов + Logstash/Fluentd: для сбора.
    • Или Loki (lighter, optimized for logs).
    • Или ELK stack (Elasticsearch + Logstash + Kibana).

Почему не PostgreSQL?

  • 100,000 логов в секунду = 8 миллиардов логов в день.
  • В PostgreSQL это требует batch-insert'ов и отключения constraints для скорости.
  • Поиск по логам (содержит текст) в Elasticsearch — инвертированный индекс, 10x быстрее.

Сценарий 4: Рекомендации товаров

Требования:

  • Граф: пользователь → товар, пользователь → пользователь.
  • Запросы: «рекомендуй товары на основе истории""", «найди похожих пользователей».
  • Consistency: eventual OK, может быть отставание на часы.

Выбор:

  • Neo4j (граф-БД): если нужны сложные пути и отношения.
  • Cassandra: если рекомендации pre-computed и хранишь в key-value (user_id → list of recommended products).

Почему не PostgreSQL?

  • SQL join'ы на граф могут быть мощными, но неоптимальны для path-finding.
  • Neo4j специализирована на графах, query'ки в Cypher проще.

Polyglot persistence как естественный результат

Идея: одна система = несколько БД

Polyglot persistence — это не новая модная идея, это неизбежный результат масштабирования и усложнения систем.

Когда система маленькая, всё в одной PostgreSQL. Когда растёт, появляются узкие места:

  • Поиск медленный → добавляем Elasticsearch.
  • Кеш нужен → добавляем Redis.
  • Аналитика требует петабайты → добавляем OLAP.
  • Метрики растут → добавляем time-series БД.

Это не ошибка архитектора, это результат правильного проектирования под требования.

Типовые комбинации в production

Минимальный stack (стартап, 100K DAU):

  • PostgreSQL (OLTP, основные данные).
  • Redis (кеш, сессии).

Средний stack (продакшн с требованиями поиска и аналитики):

  • PostgreSQL (OLTP).
  • Redis (кеш).
  • Elasticsearch (полнотекстовый поиск).
  • Prometheus + InfluxDB (мониторинг).

Полный stack (крупная система, петабайты данных):

  • PostgreSQL (OLTP, финансовые и критичные данные).
  • Cassandra или DynamoDB (write-heavy, распределённые данные).
  • Redis (кеш).
  • Elasticsearch (поиск).
  • Clickhouse или BigQuery (OLAP, аналитика).
  • InfluxDB или Prometheus (метрики).
  • Kafka (асинхронная обработка, event streaming).

Каждая БД служит своей цели, и вместе они образуют мощную систему.

Концепция source of truth

В polyglot persistence системе нужно ясно определить, где хранятся официальные, canonical данные (source of truth), а где — проекции, кеши, индексы.

Source of truth:

  • PostgreSQL: официальные заказы, финансовая информация.
  • Cassandra: для write-heavy систем логирования — это source, потому что RDBMS не масштабируется.

Проекции и кеши:

  • Redis: кеш горячих товаров из PostgreSQL.
  • Elasticsearch: индекс товаров для поиска, source — PostgreSQL.
  • Clickhouse: аналитические агрегаты, source — PostgreSQL (данные копируются ночью).

Если есть конфликт между source и его проекциями, источник истины выигрывает. Например, если Redis кеш протух, его можно инвалидировать и пересчитать из Postgres.

Синхронизация между БД

Когда есть несколько хранилищ, возникает вопрос: как они синхронизируются?

Change Data Capture (CDC)

БД хранит логи изменений, и другие системы слушают эти логи. Пример:

  1. Создаётся новый заказ в PostgreSQL.
  2. PostgreSQL пишет запись в WAL (write-ahead log).
  3. Kafka Consumer слушает WAL через CDC connector.
  4. Событие отправляется в Elasticsearch (индекс обновляется).
  5. Событие отправляется в Clickhouse (аналитика обновляется).

Это гарантирует, что все системы синхронизируются в едином order.

Периодические выгрузки (ETL)

Более простой подход для некритичных данных:

  1. Каждую ночь job вычитывает все заказы из PostgreSQL за последний день.
  2. Записывает их в Clickhouse для аналитики.
  3. Никакого реал-тайма, но просто и надёжно.

API-based синхронизация

Приложение обновляет данные в одной БД, потом явно обновляет в других:

// Update in PostgreSQL
orderRepository.save(order);

// Update in Elasticsearch
elasticsearchService.indexOrder(order);

// Update in Redis cache
cacheService.invalidate("order:" + order.getId());

Это простой подход, но требует discipline в коде.

Eventual consistency между системами

В распределённой системе нельзя гарантировать, что все хранилища обновятся мгновенно. Это называется eventual consistency — они будут консистентными в конечном итоге, но может быть временное окно рассинхронизации.

Пример:

  • Пользователь обновляет свой профиль.
  • Данные записаны в PostgreSQL.
  • Событие отправлено в Redis cache, но Redis был перезагружен и не получил событие.
  • Когда пользователь заново загружает профиль, он получает старую версию из Redis.
  • Через 5 минут Redis-кеш истекает, и новая версия загружается из PostgreSQL.

Это OK для многих сценариев (профили, рекомендации). Это не OK для платежей (там нужна strong consistency).

Описание polyglot persistence на собеседовании

Правильный способ звучит так:

«Архитектура нашей системы использует polyglot persistence, потому что разные компоненты имеют разные требования.

PostgreSQL хранит основные данные: заказы, пользователи, платежи. Это source of truth. ACID гарантии обеспечивают, что финансовые операции корректны.

Redis используется для кеша горячих товаров и сессий пользователей. Это значительно улучшает latency.

Elasticsearch индексирует товары для полнотекстового поиска. Когда товар обновляется в Postgres, событие отправляется через Kafka в Elasticsearch.

Clickhouse аккумулирует аналитику. Каждую ночь ETL-job выгружает заказы из Postgres в Clickhouse для reporting.

Prometheus собирает метрики приложения. Это отдельный стек, не связан с основными данными.

Каждый компонент масштабируется независимо. Если нужна большая пропускная способность поиска, добавляем Elasticsearch-узлы. Если нужна скорость кеша, добавляем Redis. Source of truth (Postgres) обновляется реже».

На этом уровне детализации ты показываешь, что:

  1. Понимаешь компромиссы.
  2. Умеешь мыслить архитектурой, а не одной БД.
  3. Знаешь про eventual consistency и как с ней работать.

Что обычно спрашивают на собеседованиях про БД

Типовые направления вопросов

Различия RDBMS vs NoSQL

Вопрос: «В чём разница между SQL и NoSQL?»

Плохой ответ: «SQL хранит таблицы, NoSQL хранит документы».

Хороший ответ: «Это два подхода к моделированию данных с разными компромиссами.

RDBMS (SQL) использует таблицы с жёсткой схемой и нормализацией. Это даёт сильную консистентность и возможность сложных запросов через join'ы. Но при масштабировании требует партиционирования.

NoSQL (документные БД, key-value) используют гибкую схему и часто denormalized данные. Это даёт гибкость и лучше масштабируется горизонтально. Но теряется некоторая consistency и требуется логика join'ов в приложении.

Выбор зависит от требований: если нужна consistency и сложные отношения — RDBMS. Если нужна масштабируемость и гибкость — NoSQL».

Когда выбрать key-value вместо таблицы

Вопрос: «Когда ты бы использовал Redis вместо того, чтобы добавить ещё таблицу в Postgres?»

Плохой ответ: «Redis быстрее».

Хороший ответ: «Это вопрос о требованиях.

Если данные горячие (часто читаются), shortlived (быстро устаревают), и структура простая (ключ → значение или список), Redis идеален. Пример: кеш профилей пользователей, сессии, счётчики.

В Postgres я запомню те же данные, но:

  • Меньше latency: in-memory vs диск.
  • Меньше нагрузки на Postgres: он занят критичными данными.
  • Проще управление временем жизни: TTL в Redis автоматически удаляет старые данные.

Когда не выбирать Redis:

  • Данные долгоживущие и должны пережить перезагрузку.
  • Нужны сложные запросы (join'ы, фильтрация).
  • Нужна strong consistency.

В этом случае лучше таблица в Postgres».

Когда документная модель удобнее

Вопрос: «Когда бы ты выбрал MongoDB вместо Postgres?»

Хороший ответ: «Когда структура данных гибкая или часто меняется.

Пример: CMS, где контент может быть статьёй, видео, подкастом — у каждого своя структура. В Postgres это было бы множество таблиц и NULL'ы. В MongoDB — один документ, каждый с нужной структурой.

Другой пример: user profiles, где каждый пользователь может заполнить разные поля. В Postgres придётся иметь 100 столбцов, где большинство NULL. В MongoDB каждый документ имеет только нужные поля.

Но важный компромисс:

  • Теряем consistency: никто не гарантирует, что у всех документов одна структура.
  • Нет join'ов: логика собирания данных из нескольких коллекций переходит в приложение.
  • Сложнее отчёты: аналитический запрос может стать очень медленным.

Так что это не лучше, это компромисс».

Зачем нужны поисковые движки

Вопрос: «Почему твоя система использует Elasticsearch, если у тебя уже есть Postgres с товарами?»

Хороший ответ: «Потому что they оптимизированы под разные паттерны доступа.

В Postgres я храню товары с индексами. Запрос 'дай товар с id=123' — быстро. Даже LIKE '%search%' может работать.

Но когда нужно 'найди все товары, содержащие слово «красные» ИЛИ «кроссовки», плюс отфильтруй по цене и размеру' — это требует:

  • Полного сканирования таблицы или множество индексов.
  • В Elasticsearch то же самое за 50 миллисекунд из-за инвертированных индексов.

Также Elasticsearch лучше для:

  • Автосугест (while-you-type completion).
  • Faceted search (показать, сколько товаров по каждому размеру).
  • Scoring по релевантности.

Так что Elasticsearch — это не замена Postgres, это специализированный инструмент для поиска. Source of truth остаётся в Postgres».

Зачем отдельная OLAP

Вопрос: «Почему нельзя просто запросить Postgres для аналитики?»

Хороший ответ: «Потому что OLTP и OLAP оптимизированы под разные запросы.

OLTP (Postgres) оптимизирован для:

  • Быстрого поиска одной или нескольких строк.
  • Обновления отдельных значений.
  • Сложных связей и join'ов.

OLAP (Clickhouse, BigQuery) оптимизирован для:

  • Сканирования больших объёмов данных.
  • Агрегации (SUM, COUNT, AVG).
  • Работы с историческими данными.

На 5 миллиардах строк аналитический запрос в Postgres даст 30+ секунд. В Clickhouse — 100 миллисекунд.

Плюс:

  • Если запросить Postgres для аналитики, это заблокирует operational запросы.
  • OLAP обычно работает с историческими данными (копия из прошлого), не live-данными.

Так что это разные инструменты для разных целей».

Глубина вопросов

Базовый уровень

«Расскажи про ACID?» — определения, примеры, почему это важно.

«Что такое индекс?» — как работает, какие типы индексов, когда полезен.

Средний уровень

«Как ты спроектировал бы систему кеширования?» — где хранить кеш, как инвалидировать, как fallback.

«Как масштабировать Postgres?» — репликация, шардирование, read replicas.

Senior уровень

«Как бы ты спроектировал систему с 100 миллионами пользователей, где каждый видит свою ленту?» — нужна комбинация Postgres, Redis, кеша, денормализации.

«У нас есть 10 петабайт логов за 5 лет. Как их анализировать?» — columnar БД, partitioning, OLAP.

«Как синхронизировать данные между Postgres и Elasticsearch?» — CDC, Kafka, eventual consistency.

Как звучат Senior-уровня формулировки

Senior разработчик не говорит «это хорошее решение». Он говорит компромиссы:

«Это решение даёт нам X, но теряем Y. При наших требованиях это приемлемо, потому что Z».

Примеры:

  • «Мы используем Redis для кеша. Это даёт нам sub-millisecond latency, но мы теряем durability и memory overhead. При our peak load это экономит 80% нагрузки на Postgres, так что компромисс стоит».

  • «Мы разделили write и read: write в Postgres, read из read-replica в отдельный Elasticsearch. Это добавило complexity и eventual consistency (replication lag 5 сек), но позволило масштабировать read отдельно от write».

  • «Мы не используем transactions через несколько сервисов, вместо этого event sourcing: каждый сервис публикует события, и другие сервисы реагируют асинхронно. Это даёт нам loose coupling и масштабируемость, но требует handling inconsistency».


Как привязать карту мира БД к Java/backend-практике

Что меняется в коде Java-сервисов

Работа с connection pool'ами

PostgreSQL (RDBMS):

// Spring Boot с HikariCP
@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        return new HikariDataSource(config);
    }
}

// Query
@Repository
public class OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserId(Long userId);
}

Connection pool управляет жизненным циклом соединений. Каждый query получает соединение из pool'а, используется, возвращается.

Redis (key-value):

@Configuration
public class RedisConfig {
    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
}

@Service
public class CacheService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void setCached(String key, Object value, long ttl) {
        redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(ttl));
    }
}

Redis — in-memory, соединение — это просто TCP connection, pool управляет их переиспользованием. TTL управляется Redisом.

Elasticsearch (search):

@Configuration
public class ElasticsearchConfig {
    @Bean
    public RestClient restClient() {
        return RestClient.builder(new HttpHost("localhost", 9200)).build();
    }
}

@Service
public class ProductSearchService {
    @Autowired
    private RestHighLevelClient client;
    
    public void indexProduct(Product product) {
        IndexRequest request = new IndexRequest("products")
            .id(String.valueOf(product.getId()))
            .source(product.toMap(), XContentType.JSON);
        client.index(request, RequestOptions.DEFAULT);
    }
}

Elasticsearch — документно-ориентирована, запросы через REST API, индексирование — это запись документа.

Сериализация и десериализация

RDBMS: JPA Entity → строка в таблице.

@Entity
@Table(name = "orders")
public class Order {
    @Id
    private Long id;
    @Column
    private String status;
    @OneToMany
    private List<OrderItem> items;
}

ORM делает всю работу по маппингу.

DocumentDB: объект → JSON документ.

@Document(collection = "orders")
public class Order {
    @Id
    private String id;
    private String status;
    private List<OrderItem> items;
}

@Service
public class OrderService {
    @Autowired
    private MongoTemplate mongoTemplate;
    
    public void save(Order order) {
        mongoTemplate.save(order); // Сериализуется в BSON
    }
}

Key-value: объект → JSON string.

@Service
public class CacheService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public void cacheOrder(Order order) {
        String json = objectMapper.writeValueAsString(order);
        redisTemplate.opsForValue().set("order:" + order.getId(), json);
    }
    
    public Order getCachedOrder(Long orderId) {
        String json = redisTemplate.opsForValue().get("order:" + orderId);
        return objectMapper.readValue(json, Order.class);
    }
}

Работа с асинхронными драйверами

RDBMS (blocking):

public ResponseEntity<?> getOrder(@PathVariable Long id) {
    Order order = orderRepository.findById(id).orElse(null);
    return ResponseEntity.ok(order); // Thread блокируется пока БД ответит
}

Один thread на один запрос.

Асинхронные драйверы (R2DBC):

@Repository
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
    Mono<Order> findById(Long id);
}

@GetMapping("/orders/{id}")
public Mono<ResponseEntity<?>> getOrder(@PathVariable Long id) {
    return orderRepository.findById(id)
        .map(order -> ResponseEntity.ok((Object) order))
        .defaultIfEmpty(ResponseEntity.notFound().build());
}

Один thread обслуживает множество запросов, они ждут асинхронно.

Кеширование и инвалидация

Simple cache с аннотациями:

@Service
public class UserService {
    @Cacheable("users")
    public User getUser(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }
    
    @CacheEvict("users")
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

Complex cache с Redis и events:

@Service
public class UserService {
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    @Autowired
    private UserRepository userRepository;
    
    public User getUser(Long id) {
        User cached = redisTemplate.opsForValue().get("user:" + id);
        if (cached != null) return cached;
        
        User user = userRepository.findById(id).orElse(null);
        if (user != null) {
            redisTemplate.opsForValue().set("user:" + id, user, Duration.ofHours(1));
        }
        return user;
    }
    
    public User updateUser(User user) {
        User saved = userRepository.save(user);
        redisTemplate.opsForValue().set("user:" + user.getId(), saved, Duration.ofHours(1));
        
        // Publish event
        applicationEventPublisher.publishEvent(new UserUpdatedEvent(user.getId()));
        
        return saved;
    }
}

@Component
public class UserCacheInvalidationListener {
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    @EventListener
    public void onUserUpdated(UserUpdatedEvent event) {
        redisTemplate.delete("user:" + event.getUserId());
    }
}

Влияние на архитектуру микросервисов

Модель 1: один сервис, одна БД (простая):

UserService ← PostgreSQL
OrderService ← PostgreSQL (другая БД)
ProductService ← PostgreSQL (третья БД)

Каждый сервис — своя БД, нет общего мастера. Это позволяет масштабировать независимо, но требует синхронизации через API или events.

Модель 2: один сервис, несколько БД (сложная):

OrderService ←┬─ PostgreSQL (OLTP, заказы)
              ├─ Redis (кеш товаров)
              ├─ Elasticsearch (поиск по заказам)
              └─ Kafka (publish events)

OrderService использует несколько БД для разных ролей. Это требует сложной логики синхронизации в приложении.

Модель 3: распределённая архитектура с polyglot:

Frontend
   ↓
API Gateway
   ├─ UserService ← PostgreSQL + Redis
   ├─ OrderService ← PostgreSQL + Cassandra (write-heavy)
   ├─ SearchService ← Elasticsearch
   └─ AnalyticsService ← Clickhouse

Events Bus (Kafka):
   UserService → OrderService
   OrderService → SearchService, AnalyticsService

Это уже архитектурное решение, не просто выбор БД.

Что показать на собеседовании

На собеседовании не нужно описывать весь stack. Достаточно показать, что ты:

  1. Понимаешь граници каждой БД: «Мы используем Postgres для OLTP, потому что нужны транзакции. Elasticsearch для поиска, потому что инвертированные индексы лучше работают с текстом. Redis для кеша, потому что он in-memory и не требует нормализации».

  2. Видишь компромиссы: «Да, RDBMS может хранить кеш в дополнительной таблице, но это требует синхронизации и требует места на диске. Redis — специализирован, проще инвалидировать через TTL».

  3. Знаешь, как это коддить: «В Java я использую Spring Data JPA для Postgres, StringRedisTemplate для Redis, RestHighLevelClient для Elasticsearch. Каждый с правильным pool'ом и timeout'ами».

  4. Мыслишь архитектурой: «При масштабировании мы не просто добавляем ещё индекс в Postgres. Мы выделяем отдельный сервис для поиска с Elasticsearch, сервис кеширования с Redis, аналитику в отдельное хранилище. Каждый масштабируется независимо».


Итоговый чек-лист: карта мира БД в голове backend-разработчика

8 базовых классов БД

  1. RDBMS (PostgreSQL, MySQL): табличная модель, SQL, ACID, join'ы, нормализация. Use: финансовые системы, CRM, строгая схема.

  2. Key-value (Redis, Memcached): ключ → значение, O(1) lookup, TTL. Use: кеш, сессии, счётчики.

  3. Документные (MongoDB, CouchDB): коллекции документов, гибкая схема, JSON. Use: user profiles, гибкий контент, агрегированные сущности.

  4. Wide-column (Cassandra, HBase): строки в семействах столбцов, append-only, дизайн от запросов. Use: логирование, event-storage, миллиарды записей.

  5. Search (Elasticsearch, Solr): инвертированные индексы, полнотекстовый поиск, scoring. Use: каталоги, поиск по логам, автосугест.

  6. Columnar (Clickhouse, BigQuery): колоночное хранение, сжатие, OLAP. Use: аналитика, отчёты, петабайты исторических данных.

  7. Time-series (InfluxDB, Prometheus): timestamp + метрики, append-only, downsampling. Use: мониторинг, метрики, IoT.

  8. Граф (Neo4j, Gremlin): узлы и рёбра, пути и связи. Use: социальные сети, рекомендации, граф знаний.

Главные критерии выбора

Критерий Вопрос На что влияет
Модель данных Таблицы? Документы? Ключи? Графы? Всё. Это фундамент.
Паттерны доступа Read-heavy? Write-heavy? Range queries? Индексы, денормализация, распределение нагрузки.
Консистентность ACID нужна? Eventual OK? Гарантии, latency, масштабируемость.
Latency Миллисекунды? Секунды? In-memory vs диск, индексы, партиционирование.
Масштаб GB, TB, PB? Вертикальное vs горизонтальное масштабирование.
Стоимость Бюджет на инфру и опс? Managed vs self-hosted, специалисты.

Когда выбирать комбинацию БД

One-БД система — редко. Только очень простые системы.

Двух-БД система — частый случай:

  • RDBMS + Redis: основные данные + кеш.
  • RDBMS + Elasticsearch: основные данные + поиск.

Три+ БД системы — production enterprise:

  • RDBMS + Redis + Elasticsearch: OLTP + кеш + поиск.
  • RDBMS + Cassandra + OLAP: критичные данные + write-heavy + аналитика.
  • RDBMS + Redis + Elasticsearch + Prometheus + Clickhouse: full-stack.

Source of truth принцип

  1. Определи, где хранятся official данные (source of truth).
  2. Все остальные БД — проекции, кеши, индексы.
  3. Если есть конфликт, источник истины побеждает.
  4. Синхронизируй через CDC, events, или periodic ETL.
  5. Допускай eventual consistency, но понимай latency окна.

Красные флаги при выборе БД

  • «Выбираем MongoDB, потому что это NoSQL» — плохо. Нужны причины.
  • «Одна БД для всего» — может работать, но покажет ограничения на масштабе.
  • «Используем PostgreSQL для логирования 100K events/sec» — ошибка. Нужна write-optimized БД.
  • «Elasticsearch как source of truth для товаров» — опасно. Elasticsearch может пересоздаться, потеряет данные.
  • «Без контроля consistency между Redis и Postgres» — проблемы с racecons.

На собеседовании

Вопрос: «Какие БД ты знаешь?»

Ответ Senior: «Я знаю несколько классов БД и когда их использовать.

RDBMS для структурированных данных и транзакций. Key-value для кеша и быстрых lookups. Документные для гибких данных. Elasticsearch для поиска. Time-series для метрик. OLAP для аналитики.

Я спроектировал несколько систем, которые используют комбинацию из 3–4 БД. Каждая для своей роли. Я понимаю компромиссы: выбор БД влияет на consistency, latency, масштабируемость и стоимость».

Это звучит как senior, потому что показываешь:

  • Знание разных классов, не просто названий.
  • Практический опыт в polyglot persistence.
  • Понимание trade-offs.
  • Системное мышление о архитектуре.

MySQL/MariaDB

Зачем Java/backend-разработчику глубоко понимать MySQL / MariaDB

Почему поверхностного знания недостаточно

На собеседованиях и в реальных проектах "я знаю SQL и немного Postgres" — это не главное. MySQL/MariaDB особенно в InnoDB имеют совершенно другую внутреннюю модель: как хранятся данные, как работают транзакции, как блокируются строки, как растёт размер индексов. Абстрактное знание реляционной модели не даст ответ на вопрос "почему при 100 тысячах вставок подряд с автоинкрементом твой primary key становится узким местом".

Вторая причина — MySQL всё ещё доминирует в продакшене. WordPress, веб-приложения на Symfony/Laravel, SaaS-системы, финтех-проекты исторически построены на MySQL. Даже компании, которые позже переходили на Postgres или NoSQL, часто держат какую-то часть данных именно в MySQL. Это значит, что на интервью с большой вероятностью спросят не только теорию, но и прямые вопросы: "работал ли ты с MySQL, что конкретно настраивал, как дебажил".

Третья причина — Java-сервисы тесно связаны с БД через connection pool'ы, таймауты, управление транзакциями. Неправильный размер пула или неверный уровень изоляции транзакций — и весь сервис начинает тормозить или повисает. MySQL/MariaDB особенно критичны в этом: репликационный лаг может привести к чтению несвежих данных, а неправильный партиционинг разнесёт нагрузку неравномерно.

Типичные сценарии, где MySQL всё ещё доминирует

Веб-приложения: Laravel, Symfony, WordPress — всё построено на MySQL/MariaDB как на стандарте. Даже если компания говорит "мы масштабируемые", под капотом часто всё равно MySQL.

Финансовые системы: платежи, биллинг, управление счетами. Требования по ACID и надёжности толкают на классическую RDBMS, и MySQL/MariaDB с InnoDB отлично справляется. Часто вижу банки, которые держат финансовые данные в MySQL с репликацией и строгими уровнями изоляции.

SaaS и многотенантные системы: логика шардирования по tenant_id, учёт данных клиентов — классический MySQL use case. Заливка больших объёмов данных в одну таблицу и затем партиционирование по месяцам/годам.

Аналитика и reporting: реплики MySQL часто используются для OLAP-запросов, чтобы не нагружать primary. Выделенная реплика для аналитики, которая может висеть несколько часов под тяжёлым join'ом — стандартный паттерн.

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

Как особенности InnoDB влияют на дизайн Java-сервисов

Buffer pool — главный кеш InnoDB. Это означает, что размер пула напрямую влияет на память MySQL-сервера. Java-сервис должен учитывать этот факт при планировании ресурсов: если buffer pool занимает 20 ГБ из 32 ГБ RAM, то самому Java-приложению остаётся мало памяти для heap'а.

Clustered index (первичный ключ как физический порядок хранения) меняет стратегию индексирования. Вторичные индексы содержат ссылку на PK, а не на физический адрес строки. Это значит, что неудачный выбор PK (например, случайный UUID вместо автоинкремента) может привести к большим размерам всех индексов и плохой локальности кеша.

MVCC и snapshot-изолированность влияют на длительность транзакций. В Java-коде это значит, что если ты открыл транзакцию и долго думаешь/обрабатываешь данные перед commit'ом, ты тем самым блокируешь очистку старых версий строк в undo log'е и занимаешь место на диске.

Row-level locks и gap locks означают, что порядок обновления строк в транзакции критичен. Если два потока обновляют строки в разном порядке, получится deadlock. Java-код должен гарантировать стабильный порядок операций или быть готовым к retry'е при deadlock'е.

Репликация и async vs semisync влияют на архитектуру чтения-записи. Если ты используешь асинхронную репликацию и читаешь из реплик, ты должен понимать, что видишь данные с лагом. Java-сервис должен выбирать: критичные чтения только из primary, остальные из реплик.

Что интервьюер ожидает услышать

На вопрос "работал ли ты с MySQL, что настраивал" Senior должен ответить с конкретикой:

  • "Настраивал размер buffer pool'а под объём данных и нагрузку. Анализировал hit rate buffer pool'а, чтобы понять, достаточно ли памяти."

  • "Оптимизировал медленные запросы через добавление индексов. Использовал EXPLAIN и анализировал, почему запрос делает full table scan вместо index scan."

  • "Дебажил deadlock'и — посмотрел log'и MySQL, выяснил, какие две транзакции блокировали друг друга, поменял порядок обновлений в Java-коде."

  • "Настраивал репликацию: выбирал между async и semisync, мониторил лаг реплики, учитывал возможность потери последних транзакций при падении primary."

  • "Выбирал primary key для таблицы. Сначала думал использовать UUID, потом переключился на автоинкремент, чтобы уменьшить размер вторичных индексов и улучшить локальность."

Это конкретные примеры, а не размытые ответы про SQL. Интервьюер хочет услышать, что ты действительно работал с MySQL на уровне системного дизайна и оптимизации, а не только писал SELECT'ы.


Архитектура InnoDB и отличие от «абстрактной RDBMS»

Общая схема InnoDB: от физического к логическому

InnoDB — это storage engine MySQL, который физически организует данные совсем не так, как "таблица с рядами и столбцами", которую учат в универе. Понимание этого расхождения между логикой и физикой — ключ к оптимизации.

Tablespace'ы: логические контейнеры для хранения данных. Каждая таблица может иметь свой tablespace (если использовать innodb_file_per_table), или все таблицы хранятся в системном tablespace'е. С точки зрения Java-разработчика, это означает: большие таблицы лучше держать в отдельных файлах, чтобы изолировать их и упростить бэкапы.

Страницы (pages): основная единица работы InnoDB — страница размером обычно 16 кБ (по умолчанию). При чтении строки InnoDB читает страницу целиком из диска в память. Это значит, что если ты читаешь одну строку, ты автоматически читаешь соседние строки из той же страницы. Следствие: порядок строк и их физическое расположение критичны для производительности.

Extent'ы: группы из 64 последовательных страниц (1 МБ). Это минимальная единица выделения места при расширении таблицы. Не нужно вникать в детали, но нужно знать: данные на диске не разбросаны хаотично, они выделяются блоками.

Buffer pool: самый важный компонент для производительности. Это кеш в памяти, где InnoDB хранит страницы данных и индексов. При чтении строки InnoDB сначала проверяет, есть ли нужная страница в buffer pool'е. Если есть — быстро. Если нет — читает с диска, что медленнее на 100-1000 раз. Размер buffer pool'а обычно 60-80% от доступной памяти на сервере MySQL. Это первая переменная, которую нужно настроить в my.cnf.

Redo log: логи изменений, которые InnoDB пишет перед изменением страницы в памяти. Если сервер упадёт, redo log используется для восстановления: MySQL применит все транзакции из redo log'а и вернёт БД в консистентное состояние. Это гарантирует ACID (durability).

Undo log: логи отката изменений. Используется для:

  • отката транзакции (ROLLBACK);
  • реализации MVCC — старые версии строк хранятся через undo log, чтобы другие транзакции могли читать согласованные снимки данных;
  • очистки (purge thread) удаляет undo log записи, когда они больше не нужны.

Важная деталь: если в системе долго висит старая транзакция, которая читает данные, undo log не может быть очищен, потому что этой старой транзакции могут понадобиться старые версии строк. Результат: undo log растёт, занимает место, замедляет систему.

Clustered index как физический порядок хранения

Это одна из главных особенностей InnoDB, которая кардинально отличает её от "абстрактной RDBMS". В InnoDB первичный ключ — это не просто логический идентификатор строки. Первичный ключ (clustered index) определяет физический порядок, в котором строки хранятся на диске.

Суть: все данные строки (все столбцы таблицы) хранятся в листе B-tree index'а по первичному ключу. Если первичный ключ — это автоинкремент, то новые строки вставляются в конец индекса, последовательно. Если первичный ключ — это случайный UUID, то новые строки вставляются в случайные места index'а, фрагментируя его.

Отличие от логической картинки: обычно студента учат думать "таблица хранится отдельно, индекс хранится отдельно". На самом деле в InnoDB первичный ключ И есть таблица. Вторичные индексы просто указывают на значение первичного ключа, а не на физический адрес.

Практический эффект:

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

  • Последовательная вставка по возрастанию PK (как с AUTO_INCREMENT) — это идеально для B-tree index'а: новые значения всегда добавляются справа, нет фрагментации. Результат: вставки быстры, потому что заполняется текущая страница последовательно.

  • Случайная вставка по UUID: каждая новая строка может попасть в случайное место B-tree'а, что требует пересчёта индекса, перемещения страниц. Это медленнее.

  • Диапазонные запросы (например, SELECT * WHERE id BETWEEN 1000 AND 2000) работают эффективно, потому что строки физически расположены рядом на диске.

Логическая модель vs физическая: как это влияет на производительность

Логическая модель: "таблица — это набор строк, каждая строка имеет столбцы, есть индексы для ускорения поиска".

Физическая реальность: "таблица — это B-tree index, где листья содержат страницы с данными, вторичные индексы — это отдельные B-tree'ы с листьями, указывающими на значения первичного ключа, все это живёт в buffer pool'е и на диске, при чтении вся страница (16 кБ) попадает в памяти".

Как это влияет на производительность:

Выбор первичного ключа: если ты выбрал плохой PK, это не просто медленнее, это кардинально меняет размер и структуру всей таблицы и всех индексов. Плохой выбор — неправильный порядок расчёта расходов за месяцы работы.

Размер индексов: вторичный индекс может быть больше исходной таблицы. Если у тебя есть таблица users (100 ГБ) и ты создаёшь 10 вторичных индексов, они могут занять 500 ГБ. Всё должно поместиться в buffer pool'е для нормальной производительности.

Локальность кеша: если ты читаешь строки подряд по первичному ключу, они находятся на соседних страницах, поэтому при следующем чтении страница уже в buffer pool'е. Результат: очень быстро. Если ты читаешь строки по вторичному индексу, вторичный индекс показывает на разные значения первичного ключа, которые могут быть на разных страницах. Результат: много cache miss'ов, медленнее.

Размер записей (write amplification): при обновлении одного поля во вторичном индексе может быть перестроен весь индекс. Изменение первичного ключа — это вообще запрещённая операция в MySQL (нужно DELETE + INSERT).

Page-ориентированная работа: расположение строк и их влияние

Каждая операция чтения или записи в InnoDB работает с полными страницами (16 кБ по умолчанию).

Чтение: запрос ищет нужные строки через B-tree index'ы. Когда найдена нужная ячейка индекса, InnoDB читает страницу, на которую она указывает. Если на одной странице несколько нужных строк — они все прочитаются. Если нужных строк нет на этой странице, нужно читать другую страницу. Каждое чтение с диска — это миллисекунды.

Локальность по первичному ключу: это причина, почему последовательный скан по first primary key очень быстрый. Все строки расположены подряд, и InnoDB читает их страница за страницей в последовательном порядке. Это называется "sequential I/O" и очень быстро на диске.

Случайные чтения: если запрос обращается к строкам по неупорядоченному PK (например, через вторичный индекс с большим разбросом значений), то каждое чтение может требовать новую страницу, а значит новый диск read'а. Это "random I/O" и очень медленно.

Практический пример: таблица с 100 млн строк. Запрос SELECT * FROM users WHERE country='RU' ORDER BY id. Если есть индекс (country, id):

  • Индекс найдёт все строки с country='RU' в порядке их появления в индексе.
  • Затем для каждого значения id будет прочитана строка из кластерного индекса.
  • Если эти id'ы разбросаны (потому что они вставлялись в разном порядке), это будет много random I/O.

Если же есть индекс (country, id), и id'ы внутри каждого country' отсортированы, то при чтении из кластерного индекса строки будут читаться более-менее последовательно, и это будет быстрее.

InnoDB vs «абстрактная RDBMS»

Особенности блокировок:

В "абстрактной RDBMS" (учебник) блокировка — это просто логическое понятие. Ты блокируешь строку, и другой процесс не может её читать/писать.

В InnoDB блокировка намного сложнее:

  • Row-level lock: блокировка конкретной строки. Это хорошо для параллелизма, потому что разные транзакции могут обновлять разные строки одновременно.
  • Gap lock: блокировка диапазона (gap) между двумя значениями индекса. Это предотвращает вставку новых строк в диапазон. Зачем? Чтобы гарантировать, что диапазонный запрос не будет "нарушен" вставкой новой строки между прочитанными.
  • Next-key lock: комбинация row lock + gap lock. Это значит, что при обновлении строки с id=5, блокируется сама строка и диапазон от предыдущей строки до следующей. Это предотвращает phantom read'ы.

Пример сценария: две транзакции, каждая хочет вставить строку в таблицу.

Transaction 1: INSERT INTO users (id, name) VALUES (10, 'Alice');
Transaction 2: INSERT INTO users (id, name) VALUES (10, 'Bob');

Вторая транзакция заблокируется на gap lock'е, пока первая не коммитится. Это правильное поведение для ACID.

MVCC и snapshot-изолированность:

В "абстрактной RDBMS" транзакции блокируют друг друга при чтении и письме.

В InnoDB используется MVCC (Multi-Version Concurrency Control):

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

Поведение при падениях и восстановлении:

В "абстрактной РDBMS" при падении БД неясно, какие изменения остались на диске, какие — нет.

В InnoDB:

  • Все завершённые транзакции (COMMIT'ы) записаны в redo log на диск.
  • При перезагрузке MySQL читает redo log и применяет все транзакции, которые были закоммичены, но не успели попасть в основные файлы данных.
  • Все незавершённые транзакции откатываются (используется undo log).
  • Результат: при перезагрузке БД вернётся в консистентное состояние, и не будет потери завершённых данных.

Индексы в MySQL/InnoDB: primary key, secondary index, покрывающие индексы

Primary key: выбор ключа и его влияние на производительность

Primary key (первичный ключ) в InnoDB — это не просто логический идентификатор. Это физический порядок хранения всех данных таблицы. Выбор PK влияет на всё: размер индексов, скорость вставок, скорость диапазонных запросов, размер вторичных индексов.

Кластерный индекс как физический порядок:

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

SELECT * FROM users WHERE id = 5;

Это очень быстро, потому что данные уже находятся в листе B-tree'а первичного ключа.

Вторичные индексы как указатели на PK:

Каждый вторичный индекс содержит (ключ вторичного индекса, значение первичного ключа). Вторичный индекс не содержит полные строки данных. При запросе через вторичный индекс InnoDB сначала находит значение PK через вторичный индекс, затем ищет полную строку через кластерный индекс. Это называется "double lookup".

Практическое следствие: размер вторичного индекса напрямую зависит от размера первичного ключа. Если PK — это 20-байтовый UUID, то каждый вторичный индекс содержит 20-байтовый UUID. Если у тебя 10 вторичных индексов, ты добавляешь 200 байт к каждой записи индекса. Если PK — это 8-байтовое число (BIGINT), то это 80 байт на индекс. Разница огромна.

Выбор: автоинкрементный surrogate key vs натуральный ключ:

Рекомендация для большинства случаев: используй автоинкремент (surrogate key). Причины:

  1. Размер: BIGINT AUTO_INCREMENT — это 8 байт. UUID — это 16-36 байт (в зависимости от формата). Все вторичные индексы будут меньше.

  2. Вставки: с AUTO_INCREMENT новые строки вставляются в конец B-tree'а, что очень эффективно (sequential write). С UUID вставки попадают в случайные места, фрагментируя индекс.

  3. Локальность: с AUTO_INCREMENT строки, вставленные подряд, находятся физически рядом. Это хорошо для кеша и sequential read'ов.

  4. Производительность: AUTO_INCREMENT может обрабатывать тысячи вставок в секунду. UUID может быть узким местом при очень высокой нагрузке.

Натуральный ключ (например, email) имеет смысл только если:

  • Ты уверен, что этот ключ никогда не изменится.
  • Размер ключа маленький (не более 8-16 байт).
  • Распределение значений ключа почти последовательное (не случайное).

В большинстве случаев это не так, и лучше использовать AUTO_INCREMENT.

Проблема hot-spot при очень высокой нагрузке:

При очень высокой нагрузке вставок (миллионы в секунду) AUTO_INCREMENT может стать узким местом: все новые строки вставляются в одно место индекса, что вызывает конкуренцию за блокировку конца B-tree'а. Решение: использовать витки (rotation) или UUID в таких случаях, но это редкость.

Проблема дыр в последовательности:

При rollback'е транзакции, которая вставила строку с AUTO_INCREMENT, id этой строки не переиспользуется. Результат: в последовательности id'ов могут быть дыры. Это не ошибка, это нормальное поведение (важно для atomicity). На собеседовании не стоит зацикливаться на этом.

Secondary index: структура и double lookup

Вторичный индекс (secondary index, non-clustered index) — это индекс на столбцах, кроме первичного ключа. Например, индекс на email, или индекс на (country, created_at).

Структура вторичного индекса:

Вторичный индекс — это отдельный B-tree, листья которого содержат (значение индекса, значение первичного ключа). Полные данные строки не хранятся в листе вторичного индекса.

Пример:

  • Таблица users: id (PK), email, country, created_at.
  • Индекс на email: (email) -> (id).
  • Вторичный индекс содержит пары (email, id).

Double lookup:

Когда запрос использует вторичный индекс:

  1. MySQL ищет нужное значение в B-tree'е вторичного индекса.
  2. Находит значение первичного ключа (id).
  3. Затем ищет полную строку в B-tree'е первичного ключа (кластерный индекс).

Пример запроса:

SELECT id, email, country FROM users WHERE email = 'test@example.com';
  1. Вторичный индекс по email быстро находит id (например, 12345).
  2. Затем по этому id запрашивается полная строка из кластерного индекса.

Это два обхода B-tree'ов, отсюда название "double lookup".

Стоимость double lookup:

При маленьких результатах (1-10 строк) это не заметно. При больших результатах (миллионы строк) double lookup может быть узким местом, потому что каждая строка требует дополнительного поиска в кластерном индексе.

Когда это проблема:

Запрос SELECT * FROM users WHERE country='RU' LIMIT 1000000. Это потребует миллион double lookup'ов, что может быть медленно. Решение: использовать покрывающий индекс (см. ниже).

Покрывающие индексы (covering index)

Покрывающий индекс (covering index) — это индекс, который содержит все столбцы, необходимые для запроса. InnoDB может выполнить запрос, используя только этот индекс, без необходимости обращаться к кластерному индексу. Это называется "index-only scan".

Определение:

Индекс покрывает запрос, если все столбцы, которые нужны в SELECT, WHERE, ORDER BY, находятся в индексе.

Пример:

  • Запрос: SELECT email, country FROM users WHERE created_at > '2024-01-01' ORDER BY created_at.
  • Индекс на (created_at, email, country) покрывает этот запрос.
  • MySQL может выполнить запрос, используя только этот индекс, не обращаясь к кластерному индексу.

Index-only scan и выигрыш по IO:

При index-only scan'е MySQL:

  1. Читает индекс (обычно меньше по размеру, чем основная таблица).
  2. Находит нужные строки в индексе.
  3. Не обращается к кластерному индексу.

Выигрыш: значительно меньше операций чтения, меньше нагрузка на buffer pool, быстрее.

Пример типичного запроса с покрывающим индексом:

Таблица orders: id (PK), user_id, status, created_at, amount.

Запрос:

SELECT user_id, COUNT(*) FROM orders WHERE status='completed' GROUP BY user_id;

Индекс (status, user_id) покрывает этот запрос. MySQL не нужны другие столбцы, всё есть в индексе. Index-only scan сработает, и запрос будет быстрым, даже если таблица огромная.

Без покрывающего индекса MySQL должен был бы для каждой найденной строки обращаться к кластерному индексу, что медленнее.

Типичные ошибки при создании покрывающих индексов:

  1. Не помещать первичный ключ в индекс вручную. MySQL автоматически добавляет PK в конец каждого вторичного индекса, поэтому если тебе нужен PK в результате, индекс его уже содержит.

  2. Забывать про WHERE условия. Индекс должен начинаться с колонок в WHERE, затем идут колонки ORDER BY, потом остальные.

  3. Делать индекс слишком широким. Каждый индекс занимает место и замедляет INSERT'ы/UPDATE'ы/DELETE'ы. Не нужно делать покрывающий индекс для каждого запроса.

Составные индексы и порядок полей

Составной индекс (composite index, multi-column index) — это индекс на нескольких столбцах. Порядок столбцов критичен.

Правило leftmost prefix:

B-tree индекс сортирует по первому столбцу, затем по второму, затем по третьему. Это значит, что индекс (a, b, c) может использоваться для:

  • WHERE a = ? (первый столбец).
  • WHERE a = ? AND b = ? (первые два столбца).
  • WHERE a = ? AND b = ? AND c = ? (все три).
  • WHERE a = ? ORDER BY b (первый столбец в WHERE, второй в ORDER BY).

Но НЕ может эффективно использоваться для:

  • WHERE b = ? (второй столбец, первый не задан).
  • WHERE c = ? (третий столбец, первые два не задан).
  • WHERE a = ? ORDER BY c (пропущен второй столбец).

Пример неправильного индекса:

CREATE INDEX idx ON users (email, country, age);

Запрос SELECT * FROM users WHERE country = 'RU'. Этот индекс НЕ поможет, потому что первый столбец индекса — email, а в запросе есть только country. MySQL придётся делать full table scan.

Правильный индекс:

CREATE INDEX idx ON users (country, email, age);

Теперь для запроса WHERE country = 'RU' индекс сработает.

Порядок для диапазонных запросов:

Если есть запрос WHERE country = 'RU' AND age > 18, индекс (country, age) будет эффективен:

  • Сначала находим все строки с country = 'RU'.
  • Внутри этого набора используем B-tree'е для диапазона age > 18.

Если индекс (age, country), это будет неэффективно, потому что нужно будет начать с age (что неправильно для запроса).

Типичные ошибки:

  1. Создание множества индексов на двух столбцах (email, country) и (country, email) "на всякий случай". Это плохо: два индекса занимают место, замедляют INSERT/UPDATE/DELETE. Нужно выбрать один порядок на основе запросов.

  2. Не учитывать ORDER BY. Если запрос WHERE a = ? ORDER BY b, то индекс (a, b) поможет для сортировки тоже, но индекс (b, a) не поможет.

Выбор индексов под реальные запросы

Из шаблона WHERE / ORDER BY / JOIN вывести нужный индекс:

Алгоритм:

  1. Посмотри на WHERE условия. Порядок полей в индексе должен совпадать с порядком условий.
  2. Если есть ORDER BY, добавь эти поля после WHERE-полей.
  3. Если нужны ещё столбцы для покрывающего индекса, добавь их в конец.

Пример:

SELECT id, user_id, amount FROM orders 
WHERE status = 'pending' AND created_at > '2024-01-01' 
ORDER BY created_at, user_id;

Индекс (status, created_at, user_id, amount) будет идеален:

  • status для первого условия WHERE.
  • created_at для второго условия WHERE и начала ORDER BY.
  • user_id для завершения ORDER BY.
  • amount для покрытия SELECT.

MySQL может выполнить этот запрос, используя только индекс, без обращения к основной таблице.

Когда индекс больше вредит:

Over-indexing — это создание слишком много индексов. Проблемы:

  1. Замедление INSERT/UPDATE/DELETE: при каждой вставке MySQL должна обновить все индексы. 10 индексов — это 10 операций обновления B-tree'ов.

  2. Занятость памяти: каждый индекс занимает место в buffer pool'е. Если индексы не влезают в память, performance падает.

  3. Выбор плана: MySQL optimizer должен выбрать, какой индекс использовать для запроса. Много индексов — это больше времени на выбор.

  4. Бизнес-риск: индекс кажется полезным для одного запроса, но может быть вредным для других.

Рекомендация: создавай индексы для самых частых и медленных запросов. Мониторь slow query log и анализируй, какие запросы нужны индексы.

Как проверить, помогает ли индекс:

Используй EXPLAIN:

EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

Посмотри на поле "type":

  • "ref" или "eq_ref" — индекс используется, хорошо.
  • "range" — индекс используется для диапазона, нормально.
  • "ALL" — full table scan, плохо, нужен индекс.

Также посмотри на "rows" — сколько строк MySQL планирует прочитать. Если много, индекс не очень помогает.

Типичные interview-темы по индексам

Разница между primary key и secondary index:

Senior должен объяснить:

  • "Primary key в InnoDB — это кластерный индекс, это физический порядок хранения данных. Все данные строки находятся в листе B-tree'а primary key'а."

  • "Secondary index — это отдельный B-tree, листья которого содержат (значение индекса, primary key). Полные данные в отдельном обращении."

  • "Это означает, что primary key всегда быстрее для чтения полных строк, а secondary index помогает найти значения, но требует double lookup."

Почему primary key влияет на размер всех индексов:

  • "Каждый secondary index содержит значение primary key. Если primary key — это большой UUID (16 байт), то каждый secondary index содержит 16 байт на запись. Если у таблицы 100 млн строк и 10 secondary индексов, это разница между 10 ГБ (с BIGINT) и 160 ГБ (с UUID)."

  • "Поэтому выбор primary key влияет на общий размер таблицы и индексов, на то, влезят ли они в buffer pool, на общую производительность."

Что такое covering index:

  • "Covering index содержит все столбцы, нужные для запроса. MySQL может выполнить запрос, используя только индекс, без обращения к основной таблице. Это index-only scan, очень быстро, потому что меньше IO."

  • "Пример: запрос SELECT email FROM users WHERE country = 'RU'. Индекс (country, email) покрывает этот запрос. MySQL найдёт все страны через индекс и не нужно обращаться к основной таблице."


Транзакции, уровни изоляции и MVCC в MySQL

Транзакции и autocommit

Транзакция (transaction) — это последовательность операций SQL, которые выполняются как одно атомарное целое. Либо все операции выполняются, либо откатываются все.

Поведение autocommit по умолчанию:

В MySQL по умолчанию autocommit включён (SET autocommit=1). Это означает, что каждое SQL-выражение автоматически коммитится сразу после выполнения.

INSERT INTO users (name) VALUES ('Alice');  -- auto-commit произойдёт
INSERT INTO users (name) VALUES ('Bob');    -- auto-commit произойдёт

Каждое INSERT — это отдельная транзакция.

Явное управление транзакциями:

Чтобы объединить несколько операций в одну транзакцию:

BEGIN;  -- или START TRANSACTION
INSERT INTO users (name) VALUES ('Alice');
INSERT INTO orders (user_id) VALUES (LAST_INSERT_ID());
COMMIT;

Если что-то пойдёт не так, можно:

ROLLBACK;  -- откатить все операции

Практическое значение для Java-приложений:

В Java обычно используется @Transactional аннотация (Spring) или явный try-catch с rollback'ом. Если ты отправишь несколько SQL-операций в разных "try" блоках без явного управления транзакциями, каждая может быть отдельной транзакцией. Если первая успеет, а вторая упадёт, у тебя будут согласованные данные.

Типичная ошибка: разработчик писал:

userRepository.save(user);       // транзакция 1: COMMIT
orderRepository.save(order);     // транзакция 2: COMMIT

Если вторая операция упадёт, первая уже закоммичена. Данные несогласованные.

Правильно:

@Transactional
public void createUserWithOrder(User user, Order order) {
    userRepository.save(user);
    orderRepository.save(order);
    // если какая-то упадёт, вся транзакция откатится
}

Когда транзакции «незаметно» растягиваются:

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

@Transactional
public void processUsers() {
    List<User> users = userRepository.findAll();  // сложный запрос из БД
    for (User user : users) {
        expensiveCalculation(user);  // долгое вычисление на CPU
        userRepository.save(user);   // обновление в БД
    }
}

Эта транзакция может продолжаться минуты, если expensiveCalculation() долгая или findAll() вернёт много строк. В это время транзакция держит блокировки, занимает место в undo log'е, и другие потоки не могут работать с этими данными.

Правильно: разбить на маленькие транзакции или вынести долгие операции за пределы транзакции.

Уровни изоляции в MySQL

Уровни изоляции (isolation levels) определяют, насколько изолированы друг от друга одновременные транзакции. MySQL поддерживает четыре уровня:

READ UNCOMMITTED (уровень 0):

Транзакция может читать данные, которые ещё не закоммичены другой транзакцией (dirty read). Почти не используется в продакшене, потому что очень опасно. Если другая транзакция откатится, ты прочитал мусор.

READ COMMITTED (уровень 1):

Транзакция может читать только закоммиченные данные. Но если другая транзакция изменит данные и коммитит, ты прочитаешь новые значения (non-repeatable read). Это нормально для многих приложений, но может вызвать странные баги, если ты читаешь одни и те же данные дважды в одной транзакции и получаешь разные значения.

REPEATABLE READ (уровень 2):

Это дефолтный уровень в MySQL. Транзакция создаёт снимок (snapshot) данных в момент начала транзакции и видит только эту версию данных, даже если другие транзакции изменят данные. Результат: если ты прочитаешь одни и те же данные дважды в одной транзакции, ты получишь один и тот же результат (repeatable).

Но есть подвох: фантомы (phantom reads). Если ты выполнишь диапазонный запрос дважды в одной транзакции, и в диапазон вставилась новая строка, ты увидишь разные результаты. Для защиты от фантомов InnoDB использует gap locks (см. ниже).

SERIALIZABLE (уровень 3):

Самый строгий. Транзакции выполняются последовательно, как будто одна за одной. Никакие проблемы с изолированностью невозможны, но производительность очень низкая, потому что много блокировок. Почти не используется.

Выбор уровня для разных сценариев:

  • Приложения, чувствительные к согласованности (финансы): REPEATABLE READ или SERIALIZABLE.
  • Типичное веб-приложение: REPEATABLE READ (дефолт в MySQL).
  • Очень high-load системы, где нужен параллелизм: READ COMMITTED (с осторожностью).

MVCC в InnoDB

MVCC (Multi-Version Concurrency Control) — это механизм, который позволяет разным транзакциям видеть разные версии одних и тех же данных. Это позволяет читать данные без блокировок, что улучшает параллелизм.

Как это работает:

Каждая строка в InnoDB имеет версии. Когда транзакция изменяет строку, старая версия не удаляется, а сохраняется в undo log'е. Новая версия создаётся и хранится как текущая.

Исходная строка: id=1, name='Alice', version=100

Транзакция A (начало в момент 100):
UPDATE users SET name='Alice2' WHERE id=1;
-- версия 101 создана, старая версия сохранена в undo log

Транзакция B (начало в момент 100, до коммита A):
SELECT name FROM users WHERE id=1;
-- B видит версию 100 ('Alice'), потому что версия 101 была создана после начала B

Транзакция A (коммит):

-- версия 101 становится видимой для новых транзакций

Транзакция C (начало в момент 102):
SELECT name FROM users WHERE id=1;
-- C видит версию 101 ('Alice2'), потому что это текущая версия

Snapshot изолированность:

Когда транзакция начинается, она получает "transaction ID" (список активных транзакций в этот момент). При чтении строки InnoDB ищет версию строки, которая была активна в момент начала транзакции. Результат: транзакция видит снимок (snapshot) данных, который не меняется во время её выполнения.

Это очень мощно для REPEATABLE READ: если ты прочитаешь одну и ту же строку дважды, ты получишь одно и то же значение, потому что обе операции чтения видят один и тот же snapshot.

Фантомы и gap-lock'и:

Диапазонный запрос в одной транзакции:

Транзакция 1 (начало):
SELECT * FROM users WHERE age BETWEEN 20 AND 30;  -- результат: 1000 строк

-- Параллельно, Транзакция 2:
INSERT INTO users (age) VALUES (25);  -- вставка новой строки в диапазон
COMMIT;

Транзакция 1 (продолжение):
SELECT * FROM users WHERE age BETWEEN 20 AND 30;  -- результат: 1001 строка (!)

Новая строка появилась, хотя Транзакция 1 должна была видеть стабильный snapshot. Это phantom read. Для защиты от этого InnoDB использует gap locks: при первом диапазонном запросе Транзакция 1 устанавливает блокировку на диапазон, и Транзакция 2 не может вставить строку в этот диапазон, пока не закончится Транзакция 1.

Undo log и долгие транзакции:

Все старые версии строк хранятся в undo log'е. Если транзакция долго висит (не закоммичена и не откачена), undo log не может быть очищен, потому что эта транзакция может понадобиться старая версия строки.

Результаты долгой транзакции:

  1. Undo log растёт, занимает место на диске.
  2. Новые версии строк накапливаются, замедляя операции изменения (потому что нужно проходить по цепочке версий).
  3. Другие операции замедляются.

Это частая проблема в мониторинге: есть долгая транзакция, которая кажется безобидной, но на самом деле блокирует очистку undo log'а, и весь сервер начинает тормозить.

Next-key locks и gap locks

Блокировки в InnoDB — это не просто row-level locks. Есть несколько типов:

Row lock: блокировка конкретной строки. Другие транзакции не могут изменить эту строку, но могут читать (благодаря MVCC).

Gap lock: блокировка диапазона (gap) между двумя значениями индекса. Это предотвращает вставку новых строк в диапазон. Зачем? Чтобы гарантировать, что диапазонный запрос не будет "нарушен" вставкой новой строки.

Next-key lock: комбинация row lock + gap lock. Это блокировка строки и диапазона перед ней.

Пример deadlock'а из-за gap locks:

Транзакция 1:
BEGIN;
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- блокирует id=10 и gap перед id=10

Транзакция 2:
BEGIN;
SELECT * FROM users WHERE id = 20 FOR UPDATE;
-- блокирует id=20 и gap перед id=20

Транзакция 1:
INSERT INTO users (id) VALUES (15);
-- попытка вставить в gap, но Транзакция 2 уже держит блокировку на gap перед id=20
-- WAIT (блокируется)

Транзакция 2:
INSERT INTO users (id) VALUES (5);
-- попытка вставить в gap, но Транзакция 1 уже держит блокировку на gap перед id=10
-- DEADLOCK! Обе транзакции ждут друг друга

Это классический пример deadlock'а, который сложно отследить, потому что нет очевидного конфликта по одной строке.

Как избежать deadlock'ов:

  1. Порядок операций должен быть стабильным. Если всегда обновлять строки в порядке возрастания id, deadlock'и маловероятны.

  2. Держать транзакции короткими. Чем быстрее транзакция закончится, тем быстрее освободятся блокировки.

  3. Минимизировать количество блокировок. Не нужно выбирать FOR UPDATE, если просто нужно прочитать данные.

  4. Использовать LIMIT и OFFSET для разбиения больших операций на маленькие.

Практические эффекты неправильного использования транзакций

Long-running транзакции убивают производительность:

Если транзакция открыта 10 минут (даже если она просто читает, не пишет), то:

  1. Undo log не может быть очищен, растёт размер БД.
  2. Purge thread (процесс очистки) замедляется, потому что нужно проверять, могут ли старые версии быть удалены.
  3. Новые INSERT'ы/UPDATE'ы замедляются, потому что нужно создавать новые версии.
  4. Другие транзакции, которые конфликтуют с блокировками, начинают ждать.

Результат: весь сервер замораживается.

Откуда берутся долгие транзакции в Java-коде:

  1. Разработчик открыл транзакцию, затем выполнил долгую обработку данных (вычисления, API-запросы), затем коммитил. Это плохо.

  2. Разработчик писал @Transactional на уровне контроллера (Spring), и на запрос иногда приходят и долгие операции, и долгие запросы в БД. Результат: долгая транзакция.

  3. Разработчик забыл закрыть транзакцию в исключении. Транзакция остаётся открытой, пока не истечёт timeout, и в это время держит блокировки.

Как диагностировать:

SHOW PROCESSLIST;  -- смотрим долго висящие запросы
SHOW ENGINE INNODB STATUS;  -- смотрим информацию о блокировках и транзакциях

Если видишь "Time: 600" (10 минут) для SELECT запроса, это проблема.

Как на собеседовании объяснить MVCC MySQL

Senior должен сказать примерно так:

"MVCC в MySQL / InnoDB работает через undo log. Когда транзакция обновляет строку, старая версия сохраняется в undo log, и создаётся новая версия. Другие транзакции видят старую версию через snapshot механизм.

При начале транзакции ей присваивается transaction ID и список активных транзакций в этот момент. При чтении строки InnoDB ищет версию, которая была активна на момент начала транзакции. Это даёт эффект стабильного снимка данных: если прочитаешь одну и ту же строку дважды в одной транзакции, получишь один и тот же результат.

Для защиты от фантомов при диапазонных запросах InnoDB использует gap locks: если диапазон был заблокирован, новые строки не могут быть вставлены в этот диапазон.

Минус: долгие транзакции занимают место в undo log и замедляют систему, потому что старые версии не могут быть удалены."


Репликация MySQL/MariaDB: async, semisync, чтение из реплик, схемы HA

Базовая идея репликации

Репликация (replication) — это копирование данных с одного сервера MySQL (primary/source) на другой или несколько (replica/secondary). Идея проста: все изменения на primary записываются в лог изменений (binlog), и этот лог отправляется на реплики, которые применяют эти изменения.

Primary (source): основной сервер MySQL, который принимает все write-запросы. Каждое изменение записывается в binlog.

Replica (secondary/standby): сервер, который получает binlog с primary'я и применяет изменения. Реплика имеет точную копию данных primary'я.

Binlog (binary log): логи изменений на primary'е. Содержит все INSERT'ы, UPDATE'ы, DELETE'ы, в порядке выполнения. На реплике есть собственный relay log, который содержит копию binlog'а от primary'я.

Типичная топология:

Primary (MySQL 1)
  |
  |- writes binlog
  |
Replica 1 (MySQL 2)
  |
  |- reads binlog, applies changes
  |
Replica 2 (MySQL 3)
  |
  |- reads binlog, applies changes

Асинхронная репликация

Асинхронная репликация (async replication) — это стандартный тип репликации в MySQL. Вот как она работает:

Процесс на primary'е:

  1. Приложение отправляет WRITE запрос (INSERT, UPDATE, DELETE).
  2. MySQL выполняет запрос, изменяет данные в памяти и на диске.
  3. Запись попадает в binlog.
  4. MySQL возвращает "OK" приложению.
  5. Параллельно, binlog отправляется на реплики (но primary НЕ ждёт подтверждения от реплик).

Процесс на репліке:

  1. Replica I/O thread читает binlog с primary'я по сети.
  2. Relay log получает копию binlog записей.
  3. Replica SQL thread читает relay log и применяет изменения на репліке.

Вся реплика может отставать на секунды, минуты или часы, в зависимости от нагрузки.

Лаг репликации (replication lag):

Это время между моментом выполнения запроса на primary'е и моментом применения этого запроса на репліке. Измеряется в секундах (обычно).

Причины лага:

  • Нагрузка на primary выше, чем пропускная способность сети и CPU репліки.
  • Сложные запросы на репліке, которые долго применяются.
  • Задержки при отправке binlog по сети.

Последствия лага для приложения:

Если приложение читает из реплик и лаг составляет 5 секунд, то ты видишь данные, которые на 5 секунд старше, чем на primary'е. Если ты только что записал данные и сразу же попытаешься их прочитать из реплики, ты можешь получить старые данные или не получить вообще.

Пример проблемы:

Приложение (на primary):
INSERT INTO users (name) VALUES ('Alice');  -- успешно

Приложение (из реплики):
SELECT * FROM users WHERE name='Alice';  -- может не найти, если реплика отстаёт

Возможная потеря данных при падении primary:

Асинхронная репликация не гарантирует, что все закоммиченные транзакции дошли на реплику. Если primary упадёт до того, как binlog был отправлен на реплику, эти транзакции будут потеряны.

Primary (умирает):
INSERT INTO transactions (...) VALUES (...);  -- коммит
-- binlog создан, но ещё не отправлен на реплику

-- primary вдруг упадёт (отключение питания)

Replica:

-- эта транзакция так и не дошла
-- при переключении на реплику она будет потеряна

Это приемлемо для многих приложений, но неприемлемо для финансовых операций.

Полусинхронная (semisync) репликация

Полусинхронная репликация (semisync replication) — это компромисс между асинхронной и синхронной.

Как это работает:

  1. Приложение отправляет WRITE запрос на primary.
  2. MySQL выполняет запрос, записывает в binlog.
  3. MySQL отправляет binlog на реплики и ЖДЁТ подтверждения от минимум одной реплики.
  4. Только после получения подтверждения MySQL возвращает "OK" приложению.

Trade-off между latency и надёжностью:

  • Латентность: каждый write-запрос теперь включает сетевую задержку на отправку и получение подтверждения. Это может добавить 10-100 мс к каждому запросу, в зависимости от сети и нагрузки. Для высоконагруженных систем это может быть заметно.

  • Надёжность: если primary упадёт, закоммиченные данные гарантированно находятся на минимум одной репліке. При переключении на реплику (failover), данные не будут потеряны (в большинстве случаев).

Когда использовать:

  • Финансовые системы, где потеря данных недопустима: обязательно semisync.
  • Стандартные веб-приложения: async достаточно, и лучше для performance.
  • Гибридный подход: async для менее критичных операций, semisync для финансовых.

Чтение из реплик

Одна из основных причин использования репликации — разделение нагрузки на чтение и запись.

Стратегия: primary для всех write'ов, реплики для read'ов.

Primary MySQL
  |- accept all WRITES
  |
Replica 1
  |- accept read'ы для analytics (READ)
  |
Replica 2
  |- accept read'ы для веб-приложения (READ)

Выигрыш: если у тебя 90% read'ов и 10% write'ов, ты можешь разогнать систему, распределяя read'ы по репликам.

Вопрос лага при чтении из реплик:

Если в приложении логика "критичные read'ы только из primary, остальные из реплик":

// критичный read
User user = userRepository.readFromMaster(userId);

// некритичный read
List<Orders> orders = orderRepository.readFromSlave(userId);

Это работает, если ты понимаешь, какие читаемые данные критичны (то есть данные, которые только что были написаны). Остальные read'ы можно делать из реплик.

Как учитывать репликационный лаг:

  1. Мониторить лаг: используй команду "SHOW SLAVE STATUS" и смотри "Seconds_Behind_Master".

  2. Если лаг > некоторого порога (например, 5 секунд), перенаправить read'ы на primary или дождаться, пока реплика догонит.

  3. В приложении: если критичный read выполнен сразу после write'а, убедиться, что это читается с primary, не из реплики.

Типичные схемы HA (High Availability)

HA (High Availability) — это архитектура, где система должна работать даже при отказе одного или нескольких компонентов.

Один primary + несколько реплик:

Primary (WRITE + READ)
  |
  |- Replica 1 (READ)
  |- Replica 2 (READ)
  |- Replica 3 (READ + BACKUP)

Преимущества:

  • Простая настройка.
  • Read'ы распределяются по репликам.
  • Есть бэкап на отдельной репліке.

Недостатки:

  • Если primary упадёт, write'ы невозможны.
  • Нужно выбрать, какая из реплик станет новым primary'ем (выбор best replica).

Автоматическое переключение (failover):

Есть monitoring tool (Orchestrator, MHA, Patroni), который:

  1. Мониторит здоровье primary'я.
  2. Если primary упадёт, выбирает лучшую реплику (самую свежую).
  3. Автоматически повышает реплику до primary.
  4. Перенаправляет приложения на новый primary.

Это очень сложно и требует тестирования, потому что при неправильном failover'е данные могут быть несогласованными.

Полуавтоматическое переключение (managed failover):

Вместо полного автоматизма, оператор вручную выполняет команду "promote replica to primary" или "failover", и система сама справляется с остальным.

Это безопаснее, потому что оператор может принять правильное решение, но медленнее в отношении downtime'а.

Разделение ролей:

  • Primary: accept all writes, serve critical reads.
  • Replica 1: serve analytics and heavy reads.
  • Replica 2: serve web app reads.
  • Replica 3 (backup): не participates in serving requests, only backup.

Вопросы на собеседовании

"Можно ли читать из реплик? Какие риски?":

Ответ, который ожидает интервьюер:

"Да, можно читать из реплик, это standard practice для распределения read-нагрузки. Но есть риск: репликационный лаг. Если ты только что написал данные и сразу же читаешь из реплики, ты можешь получить старые данные или не получить вообще.

Решение: для критичных read'ов (которые зависят от предыдущих write'ов) используй primary. Для некритичных read'ов (аналитика, логи) можешь использовать реплику и смириться с лагом.

Также нужно мониторить лаг реплики. Если лаг > 5 секунд, это признак проблемы: реплика не успевает применять изменения. Нужно либо улучшить параметры репліки (CPU, IO), либо перенаправить read'ы на primary."

"Что происходит при падении primary? Как переключитесь? Что потеряете?":

Ответ:

"При падении primary:

  1. Все write'ы становятся невозможными.
  2. Если использовалась async репликация, некоторые последние транзакции могут быть не отправлены на реплики. При failover на реплику эти транзакции будут потеряны.
  3. Есть выбор: дождаться восстановления primary'я (долго, downtime) или переключиться на реплику (failover).

Процесс failover:

  1. Выбрать лучшую реплику (с наибольшим binlog position, то есть самую свежую).
  2. Повысить реплику до primary (RESET SLAVE, RESET MASTER).
  3. Перенаправить приложение на новый primary.
  4. Настроить старый primary как реплику нового primary (если он восстановится).

Что потеряется: с async репликацией — последние N транзакций (зависит от лага). С semisync — обычно ничего, потому что данные дошли на минимум одну реплику."


Partitioning / sharding на уровне MySQL

Partitioning: логическое деление таблицы внутри одной БД

Partitioning (партиционирование) — это логическое деление таблицы на несколько партиций внутри одного MySQL-сервера. С точки зрения приложения это одна таблица, но физически данные разбиты на несколько частей.

Типы партиционирования:

RANGE partitioning: партиции основаны на диапазоне значений. Пример:

CREATE TABLE orders (
    id INT,
    created_at DATE,
    amount DECIMAL(10, 2)
) PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION pmax VALUES LESS THAN MAXVALUE
);

Партиции: p2022 (все заказы 2022), p2023 (все заказы 2023) и т.д. Когда новый заказ с 2024 годом вставляется, он автоматически попадает в p2024.

Выигрыш: старые партиции (например, p2022) можно архивировать или удалить, не трогая новые данные.

HASH partitioning: партиции основаны на хеш-функции от значения. Пример:

PARTITION BY HASH (user_id) PARTITIONS 4;

MySQL делит значения user_id на 4 партиции на основе хеша. Это полезно для равномерного распределения данных, когда нет очевидного диапазона для RANGE.

LIST partitioning: партиции основаны на явном списке значений. Пример:

PARTITION BY LIST (country) (
    PARTITION p_ru VALUES IN ('RU'),
    PARTITION p_us VALUES IN ('US'),
    PARTITION p_eu VALUES IN ('DE', 'FR', 'IT')
);

Данные группируются по странам.

Для чего помогает партиционирование:

  1. Большие таблицы: таблица с миллиардом строк разбивается на 10 партиций по 100 млн строк каждая. Индексы каждой партиции меньше, быстрее поиск, лучше локальность кеша.

  2. Архивация старых данных: старые партиции можно удалить или архивировать (экспортировать в файл) без влияния на остальные данные.

  3. Улучшение отдельных запросов: если запрос содержит условие на partition key (например, WHERE year(created_at) = 2024), MySQL может прочитать только нужную партицию, не трогая остальные. Это "partition pruning".

  4. Параллельные операции: некоторые операции (backups, repairs) могут работать с каждой партицией параллельно.

Особенности партиционирования

Ограничения на первичный ключ:

Primary key (или любой unique constraint) должен содержать partition key. Пример: если ты партиционируешь по user_id, первичный ключ должен быть (user_id, id) или содержать user_id.

-- Правильно:
PRIMARY KEY (user_id, id)

-- Неправильно:
PRIMARY KEY (id)  -- id один, не содержит user_id

Это ограничение существует потому, что MySQL должна знать, в какой партиции находится каждая уникальная строка.

Ограничения на вторичные индексы:

Вторичные индексы могут быть или глобальными (охватывают все партиции) или локальными (каждая партиция имеет свой индекс). Глобальные индексы сложнее для обслуживания.

Влияние выбора partition key на эффективность:

Если partition key выбран плохо, партиции могут быть несбалансированными: одна партиция имеет 90% данных, остальные 9 партиций имеют 10%. Результат: нет выигрыша от партиционирования.

Пример плохого выбора: PARTITION BY HASH (country) с биасом в сторону 'RU'. Нужно мониторить размер партиций и переделать партиционирование, если дисбаланс слишком большой.

Sharding: распределение данных по нескольким инстансам MySQL

Sharding (шардирование) — это не встроенная MySQL-функция, а архитектурный паттерн. Данные распределяются по нескольким независимым MySQL-серверам на основе shard key.

Отличие от partitioning:

  • Partitioning: логическое деление внутри одного MySQL-сервера.
  • Sharding: физическое распределение по нескольким серверам.

Выбор shard key и влияние на баланс нагрузки:

Shard key (ключ шардирования) — это значение, по которому определяется, в какой шард попадают данные. Типичные shard key'и:

  • user_id: все данные одного пользователя (заказы, профиль, настройки) находятся в одном шарде.
  • tenant_id: в SaaS системе все данные одного клиента в одном шарде.
  • geographic_id: региональные данные в региональных шардах.

Как определить shard:

Обычно используется функция: shard_id = hash(shard_key) % num_shards. Пример:

shard_id = hash(user_id) % 4  // 4 шарда
user_id = 1234 -> hash(1234) = 5678 -> 5678 % 4 = 2 -> shard 2

Баланс нагрузки:

Если shard key выбран хорошо, данные должны быть примерно равномерно распределены по шардам. Каждый шард получает примерно 25% нагрузки (если 4 шарда).

Если shard key плохой (например, country с biasом в сторону 'RU'), один шард может получить 70% нагрузки, остальные 30%. Это называется "hot shard" и портит весь выигрыш от шардирования.

Проблемы cross-shard join'ов и агрегаций

Cross-shard join: объединение данных из двух таблиц, которые находятся в разных шардах.

Пример: JOIN между users (шардировано по user_id) и orders (шардировано по user_id).

SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'RU' GROUP BY u.id;

Это запрос, который требует:

  1. Найти всех пользователей с country = 'RU' (распределены по разным шардам).
  2. Для каждого пользователя найти его заказы (в том же шарде, потому что и orders шардированы по user_id).
  3. Объединить результаты.

Если shard key совпадает для обеих таблиц (оба по user_id), то для каждого пользователя его заказы находятся в том же шарде. Это хорошо: можно выполнить join внутри шарда без пересылки данных по сети.

Если shard key не совпадает (например, users по user_id, orders по order_id), это проблема: нужно пересылать данные между шардами для join'а, что медленно.

Агрегации (GROUP BY, SUM, COUNT):

Агрегация по нескольким шардам требует:

  1. Выполнить partial агрегацию на каждом шарде.
  2. Собрать результаты на специальном узле (aggregation node).
  3. Выполнить final агрегацию.

Пример:

SELECT country, COUNT(*) FROM users GROUP BY country;
  1. Каждый шард выполняет: SELECT country, COUNT(*) FROM users GROUP BY country.
  2. Результаты собираются: (RU, 1000000), (US, 500000), (EU, 300000) от разных шардов.
  3. Final агрегация: GROUP BY country с суммированием COUNT().

Это медленнее, чем агрегация в одной таблице, потому что требует пересылки данных по сети и дополнительной обработки.

Подходы к шардированию

Логика в приложении (Java-код):

Приложение сам решает, в какой шард идти. Пример:

int shardId = Math.abs(userId.hashCode()) % NUM_SHARDS;
Connection conn = getConnectionForShard(shardId);

Преимущества:

  • Максимальная гибкость.
  • Нет промежуточного слоя.

Недостатки:

  • Логика шардирования в приложении, нужно обновлять при добавлении шардов.
  • Трудно мигрировать на другую стратегию шардирования.

Промежуточные слои / проксей:

Между приложением и MySQL-серверами стоит промежуточный слой (proxy), который решает, в какой шард направить запрос. На уровне идей:

Java App -> MySQL Proxy -> MySQL Shard 1
                       -> MySQL Shard 2
                       -> MySQL Shard 3

Примеры: MaxScale (MySQL), ProxySQL, Vitess (от Google).

Преимущества:

  • Логика шардирования вне приложения, в отдельном сервисе.
  • Легче менять стратегию шардирования.
  • Proxy может кешировать запросы, балансировать нагрузку.

Недостатки:

  • Дополнительный компонент, который может быть узким местом или точкой отказа.
  • Сложность настройки и обслуживания.

Эволюция: от одного инстанса к репликации, затем к шардированию

Типичный путь масштабирования:

Этап 1: один MySQL-сервер:

Java App -> MySQL (одна таблица, все данные)

Работает хорошо, пока данных не очень много (миллиарды записей) и нагрузка не слишком высока.

Этап 2: первичный + реплики:

Java App -> Primary MySQL (writes)
         -> Replica 1 (reads)
         -> Replica 2 (reads)

Чтение масштабируется (read'ы распределяются по репликам), но запись остаётся узким местом. Все write'ы идут на primary.

Этап 3: шардирование:

Java App -> Shard 1 MySQL (write + read для пользователей с user_id % 4 == 0)
         -> Shard 2 MySQL (write + read для пользователей с user_id % 4 == 1)
         -> Shard 3 MySQL
         -> Shard 4 MySQL

Теперь write'ы распределяются по шардам. Каждый шард может обрабатывать write'ы параллельно.

Далее можно скомбинировать: каждый шард имеет реплики для read'ов.

Этап 4: многоуровневое шардирование (редко):

Если одного уровня шардирования недостаточно, можно шардировать по двум ключам: сначала по user_id, потом по order_id. Это очень сложно и обычно не нужно.

Как кратко описать partitioning vs sharding на собеседовании

Senior должен сказать:

"Partitioning — это логическое деление таблицы внутри одного MySQL-сервера. Например, большую таблицу orders делим на партиции по году (2022, 2023, 2024). Это улучшает performance для больших таблиц, потому что индексы каждой партиции меньше, и запросы с условием на partition key могут прочитать только нужную партицию (partition pruning).

Sharding — это распределение данных по нескольким независимым MySQL-серверам. Например, выбираем shard key (user_id), и данные каждого пользователя идут в один шард. Это позволяет масштабировать и read'ы, и write'ы.

Partitioning помогает с производительностью одного сервера, sharding помогает с масштабированием нескольких серверов."


Типичные грабли в MySQL/MariaDB

Автоинкременты (AUTO_INCREMENT)

Hot-spot по PK при кластерном индексе:

Когда ты вставляешь строку с AUTO_INCREMENT, новый id генерируется и строка вставляется в конец B-tree'я первичного ключа. Это очень хорошо: новые строки всегда добавляются в одно место (справа), и это последовательное заполнение очень быстро.

Но при очень высокой нагрузке вставок все потоки конкурируют за место "в конце B-tree'я". Это создаёт contention (конкуренцию) на одну область памяти/диска. Результат: может быть замедление при очень высокой нагрузке (миллионы вставок в секунду).

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

Дырки в последовательности AUTO_INCREMENT:

При rollback'е транзакции, которая вставила строку, id этой строки не переиспользуется. Результат: в последовательности id'ов могут быть дыры.

INSERT INTO users VALUES (1, 'Alice');  -- id = 1
INSERT INTO users VALUES (2, 'Bob');    -- id = 2
BEGIN;
INSERT INTO users VALUES (3, 'Charlie'); -- id = 3
ROLLBACK;  -- откат

SELECT * FROM users;  -- результат: id 1, 2 (id 3 потеряно)

INSERT INTO users VALUES (4, 'Dave');  -- id = 4 (не 3!)

Это нормальное поведение, потому что иначе был бы race condition при распределённых транзакциях. Важно: не нужно полагаться на то, что id'ы идут подряд.

Влияние на системный дизайн:

На собеседовании интервьюер не зацикливается на AUTO_INCREMENT и дырах. Важно понимать, что:

  1. AUTO_INCREMENT хорошо для создания уникальных идентификаторов.
  2. Он плохо при очень высокой нагрузке из-за hot-spot, но это редкость.
  3. UUID часто рекомендуют как альтернативу, но UUID плохо для размера индексов.
  4. Выбор между AUTO_INCREMENT и UUID — это trade-off между производительностью вставок/размером индексов и уникальностью. В большинстве случаев AUTO_INCREMENT лучше.

Большие JOIN'ы

Join без индексов → full scan:

Когда два условия в JOIN не покрыты индексами, MySQL должна выполнить полный скан одной таблицы и для каждой строки искать соответствие во второй таблице (nested loop join).

SELECT * FROM users u 
JOIN orders o ON u.name = o.user_name  -- нет индекса на name, нет индекса на user_name
WHERE u.country = 'RU';  -- нет индекса на country

MySQL:

  1. Полный скан users (все 100 млн строк).
  2. Для каждой строки users полный скан orders (все 1 млрд заказов).
  3. Результат: 100 млн * 1 млрд = 10^17 операций. Это никогда не закончится.

Временные таблицы и сортировка на диске:

Если JOIN требует GROUP BY или ORDER BY, а индекс не помогает, MySQL создаёт временную таблицу в памяти. Если временная таблица не влезает в памяти (tmp_table_size), она сливается на диск. Это очень медленно.

Join'ы многих таблиц в OLTP-системе — анти-паттерн:

SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
JOIN suppliers s ON p.supplier_id = s.id
-- ... ещё 5 таблиц

Даже с индексами, запрос может быть медленным, потому что:

  1. Слишком много операций (даже с индексом каждый join требует обращения к индексам).
  2. Результат может быть очень большой (экспоненциальный рост при join'ах).
  3. Это сложно оптимизировать.

Решение: денормализовать данные. Вместо join'а хранить нужные данные прямо в таблице.

-- Вместо join'ов, хранить в таблице order_items:
CREATE TABLE order_items (
    id INT,
    order_id INT,
    product_name VARCHAR(255),  -- вместо join к products
    category_name VARCHAR(255),  -- вместо join к categories
    supplier_name VARCHAR(255)   -- вместо join к suppliers
);

Когда вынести в отдельный контур:

Если запрос очень сложный и нужен для аналитики, не нужно делать его в OLTP-системе. Вместо этого:

  1. Выполнить запрос на отдельной репліке MySQL (читать из реплики, не из primary).
  2. Использовать OLAP хранилище (DataWarehouse, например, BigQuery).
  3. Реализовать кеширование результата с обновлением раз в час/день.

Lock'и: row-level lock vs table lock

Row-level lock: блокировка конкретной строки. Это хорошо для параллелизма, потому что разные транзакции могут обновлять разные строки.

Table lock: блокировка всей таблицы. Это хорошо для простых операций, но плохо для параллелизма. Когда одна транзакция держит table lock, никто не может работать с таблицей.

Когда происходит table lock:

  1. DDL операции (ALTER TABLE, CREATE INDEX): всегда требуют table lock.
  2. MyISAM engine: использует table lock по умолчанию.
  3. InnoDB редко использует table lock, в основном row-level locks.

Как long-running запросы блокируют операции:

Транзакция 1 (долгая):
SELECT * FROM users WHERE country = 'RU';  -- читает всю таблицу, держит row locks

Транзакция 2:
UPDATE users SET status = 'inactive' WHERE id = 5;
-- пытается заблокировать строку 5, но Транзакция 1 уже держит блокировку
-- WAIT

Если Транзакция 1 выполняется 1 час, Транзакция 2 будет ждать 1 час. Это очень плохо.

Влияние индексов на локи:

Хорошие индексы значительно уменьшают время выполнения запроса и, как результат, время, в течение которого держатся блокировки. Плохие индексы или отсутствие индексов → full table scan → долгое время выполнения → долгие блокировки → другие транзакции ждут.

Deadlock'и

Deadlock (взаимная блокировка) — это ситуация, когда две или более транзакции ждут друг друга и никто не может продвинуться.

Типичный сценарий:

Транзакция 1:
BEGIN;
UPDATE users SET status = 'active' WHERE id = 1;

Параллельно, Транзакция 2:
BEGIN;
UPDATE users SET status = 'inactive' WHERE id = 2;

Транзакция 1:
UPDATE users SET status = 'deleted' WHERE id = 2;
-- пытается заблокировать строку 2, но Транзакция 2 уже держит блокировку
-- WAIT

Транзакция 2:
UPDATE users SET status = 'deleted' WHERE id = 1;
-- пытается заблокировать строку 1, но Транзакция 1 уже держит блокировку
-- DEADLOCK! Обе ждут друг друга

MySQL детектирует deadlock и откатывает одну из транзакций. Приложение получит ошибку и должна переиспробать.

Почему важна стабильность порядка операций:

Если всегда обновлять строки в порядке возрастания id, deadlock'и почти невозможны:

-- Правильно (всегда порядок возрастания id):
Транзакция 1:
UPDATE users SET status = 'active' WHERE id = 1;
UPDATE users SET status = 'active' WHERE id = 2;

Транзакция 2:
UPDATE users SET status = 'inactive' WHERE id = 1;
UPDATE users SET status = 'inactive' WHERE id = 2;

-- Они обновляют в одном порядке, deadlock невозможен

Как диагностировать deadlock:

SHOW ENGINE INNODB STATUS;  -- показывает информацию о последнем deadlock'е

В логе MySQL:

LATEST DETECTED DEADLOCK
------------------------
2024-01-15 10:30:45 0x7f8b1c001700
*** (1) TRANSACTION:
TRANSACTION 1234, ACTIVE 0 sec
2 lock struct(s), heap size 1160, 1 row lock(s)
MySQL thread id 10, OS thread handle 0x7f8b1c001700, query id 567 localhost 127.0.0.1 root
UPDATE users SET status = 'active' WHERE id = 1;

*** (2) TRANSACTION:
TRANSACTION 1235, ACTIVE 0 sec
2 lock struct(s), heap size 1160, 1 row lock(s)
MySQL thread id 11, OS thread handle 0x7f8b1c001800, query id 568 localhost 127.0.0.1 root
UPDATE users SET status = 'inactive' WHERE id = 2;

Из логов видно, какие транзакции конфликтовали и какие запросы были выполнены.

Стратегия обхода deadlock'ов:

  1. Стабильный порядок операций (например, всегда по id в возрастающем порядке).
  2. Короткие транзакции (минимизировать время, в течение которого держатся блокировки).
  3. Добавить индексы, чтобы запросы выполнялись быстрее.
  4. В приложении: retry на deadlock (IsolationException или SQLException с кодом 1213).

Long-running запросы и транзакции

Что они делают:

  1. Держат row locks, другие транзакции ждут.
  2. Занимают undo log (старые версии строк), undo лог растёт, занимает место.
  3. Блокируют очистку (purge), то есть удаление старых версий.
  4. Замедляют систему.

Необходимость лимитов по времени:

Нужно установить максимальное время выполнения запроса. Если запрос выполняется дольше, его нужно остановить. В MySQL:

SET SESSION max_execution_time = 30000;  -- 30 секунд
SELECT * FROM huge_table WHERE ...;  -- если дольше 30 сек, будет ошибка

В Java (Hikari connection pool):

datasource.setConnectionTimeout(10000);  // 10 сек на получение соединения
datasource.setIdleTimeout(600000);       // 10 мин на жизнь соединения

Мониторинг slow query log:

Включить логирование медленных запросов:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;  -- запросы дольше 2 сек

Затем анализировать лог и оптимизировать медленные запросы.

Типичные боевые сценарии:

  1. "Разработчик запустил большой export (SELECT * FROM huge_table), и весь primary MySQL зависнул, потому что экспорт держал table lock'и."

    • Решение: использовать LIMIT, экспортировать порциями или с отдельной репліки.
  2. "Реплика перестала применять binlog, потому что long-running запрос занял 30% CPU и не хватает ресурсов для SQL thread'а."

    • Решение: убить долгий запрос, добавить индекс.
  3. "Undo log вырос до 50 ГБ, потому что долгая транзакция висит и не даёт purge thread'у очистить старые версии."

    • Решение: закрыть долгую транзакцию, перезагрузить MySQL.

Типичные «боевые» темы на собеседованиях

"Как дебажил deadlock?":

Ответ:

"Когда получил ошибку deadlock, посмотрел SHOW ENGINE INNODB STATUS, чтобы увидеть информацию о последнем deadlock'е. Из лога увидел, какие две транзакции конфликтовали и какие запросы были выполнены.

Затем анализировал порядок операций в коде. Обычно deadlock'и бывают из-за того, что две транзакции обновляют строки в разном порядке. Я переписал код, чтобы всегда обновлять в одном порядке (например, по возрастанию id).

Также добавил retry логику в приложении (Java): при deadlock'е (SQLException с кодом 1213) пересчитать транзакцию несколько раз."

"Как оптимизировал медленный запрос в MySQL?":

Ответ:

"Сначала запустил EXPLAIN, чтобы увидеть план выполнения запроса. Увидел, что запрос делает full table scan ('type: ALL'), хотя есть WHERE условие.

Затем добавил индекс на столбцы в WHERE условии. После этого запрос стал использовать индекс ('type: ref'), и время выполнения упало с 10 секунд на 100 миллисекунд.

Также проверил, есть ли ORDER BY в запросе. Если ORDER BY не покрыт индексом, MySQL делает сортировку на диске, что медленно. Добавил ORDER BY столбцы в конец индекса, и query optimizer смог избежать сортировки."

"Что делал, когда реплика начала сильно отставать?":

Ответ:

"Проверил SHOW SLAVE STATUS и увидел Seconds_Behind_Master = 300 (5 минут). Это означает, что реплика отстаёт на 5 минут от primary.

Затем посмотрел, какие запросы выполняются на репліке (SHOW PROCESSLIST). Увидел долгий join (20 минут), который замораживает application SQL thread, и реплика не может применять новые binlog записи.

Решение: убить долгий запрос (KILL), добавить индекс на столбцы join'а. После этого реплика начала догонять primary."


MySQL/MariaDB в контексте Java backend

Connection pool: размер пула и риски

Влияние размера пула на MySQL:

Connection pool (пул соединений) — это набор переиспользуемых соединений к MySQL. Java приложение не создаёт новое соединение для каждого запроса, а берёт соединение из пула, использует его, и возвращает в пул.

Размер пула обычно 10-50 соединений (зависит от нагрузки). Каждое соединение занимает ресурсы на MySQL-сервере:

  • Memory: каждое соединение занимает примерно 1-2 МБ памяти на MySQL-сервере.
  • CPU: при очень большом количестве соединений (тысячи) MySQL должна переключаться между ними, что создаёт overhead.
  • File descriptors: каждое соединение — это отдельное file descriptor на OS.

Пример: если у тебя 10 Java-сервисов, каждый с пулом из 50 соединений, это 500 соединений на MySQL. Это может быть 500-1000 МБ памяти на MySQL-сервере и значительный overhead.

Риск переподключений и превышения лимита соединений:

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

Max_connections = 100 (default в старых MySQL)

Если у тебя несколько Java-приложений или неправильный размер пула, ты можешь превысить лимит. Результат: приложение не может подключиться к MySQL, вся система падает.

Решение:

  1. Увеличить max_connections на MySQL-сервере.
  2. Правильно выбрать размер пула в каждом приложении.
  3. Мониторить количество активных соединений.

Переподключения (reconnection):

Если соединение из пула умерло (break-е сети, timeout MySQL), пул может попытаться переподключиться. Это создаёт лишнюю нагрузку на MySQL.

Решение: настроить пул на проверку соединений перед использованием.

// Hikari (Spring Boot default)
hikari.setMaximumPoolSize(20);
hikari.setMinimumIdle(5);
hikari.setIdleTimeout(600000);       // 10 min
hikari.setConnectionTimeout(30000);  // 30 sec
hikari.setLeakDetectionThreshold(60000);  // 60 sec

Таймауты

Время жизни соединений:

Соединение из пула не может жить вечно. После некоторого времени неиспользования соединение закрывается (idleTimeout). Это освобождает ресурсы на MySQL-сервере.

Если idleTimeout слишком маленький (например, 1 минута), соединения будут часто закрываться и переоткрываться, что создаёт лишнюю нагрузку. Если слишком большой (например, 1 час), это может занять место на MySQL-сервере.

Рекомендация: 10-15 минут для типичных приложений.

Сокетные таймауты:

Timeout на уровне TCP сокета. Если сокет не получает данных от MySQL в течение timeout'а, соединение считается мёртвым и закрывается.

Пример: если MySQL зависнет (из-за OOM или иной проблемы), Java приложение будет ждать данных. Если socketTimeout = 10 секунд, то приложение подождёт 10 секунд, потом разорвёт соединение и выброс исключение.

Что происходит при потере соединения mid-transaction:

Если соединение разрывается в середине транзакции (между BEGIN и COMMIT), то:

  1. Если было автокоммит включено, изменения откатятся (rollback).
  2. Если был explicit BEGIN, MySQL автоматически откатит незавершённую транзакцию.
  3. Приложение должна перейти к retry логике.

Пример в Java:

try {
    connection.setAutoCommit(false);
    connection.prepareStatement("INSERT INTO users VALUES (1, 'Alice')").executeUpdate();
    // соединение разрывается
    connection.commit();  // это вызовет исключение
} catch (SQLException e) {
    connection.rollback();  // откатить
    // retry логика
}

Prepared statements

Выгоды от повторного использования планов:

Prepared statement — это SQL запрос с параметрами (?), которые подставляются перед выполнением. Преимущества:

  1. Кеширование планов: MySQL кеширует план выполнения запроса. Если одновременно выполняется запрос с теми же параметрами, MySQL использует закешированный план, не пересчитывая его.

  2. Безопасность: параметры передаются отдельно, что предотвращает SQL-injection.

  3. Скорость: нет необходимости парсить SQL строку каждый раз.

Пример:

// Плохо (без prepared statement):
String sql = "SELECT * FROM users WHERE email = '" + email + "'";
// каждый раз парсирование и компиляция

// Хорошо (с prepared statement):
String sql = "SELECT * FROM users WHERE email = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, email);
stmt.execute();
// SQL парсируется один раз, план кешируется

Типичные ошибки с динамическим SQL:

  1. SQL-injection: подставление значения прямо в строку.
// ПЛОХО:
String sql = "SELECT * FROM users WHERE id = " + userId;
// если userId = "1 OR 1=1", результат: SELECT * FROM users WHERE id = 1 OR 1=1 (все пользователи!)
  1. Отсутствие кеширования: если использовать динамическое построение SQL строки с подставлением параметров, план не кешируется, потому что каждый раз строка другая.
// ПЛОХО:
String sql = "SELECT * FROM users WHERE status = '" + status + "' AND created_at > '" + date + "'";
// каждый раз разная строка, план не кешируется

Рабочие паттерны

Ограничение пакетов данных (pagination, LIMIT/OFFSET vs keyset pagination):

При выборке больших объёмов данных нужно использовать LIMIT и OFFSET для разбиения на порции:

for (int offset = 0; offset < totalRows; offset += pageSize) {
    List<User> users = userRepository.findAll(
        Pageable.ofSize(pageSize).withPage(offset / pageSize)
    );
    processUsers(users);  // обработка партии
}

Проблема OFFSET: при больших offset значениях (например, OFFSET 1000000) MySQL должна прочитать 1000000 строк, отбросить их, и вернуть 50 нужных строк. Это медленно.

Решение: keyset pagination (или cursor pagination). Вместо OFFSET используй последнее значение id из предыдущей партии:

// Первая партия:
List<User> users = userRepository.findTop50OrderByIdAsc();

// Следующая партия:
int lastId = users.get(users.size() - 1).getId();
users = userRepository.findTop50ByIdGreaterThanOrderByIdAsc(lastId);

Это намного быстрее, потому что MySQL не нужно читать и отбрасывать тысячи строк.

Явное управление транзакциями в Java-коде:

@Transactional
public void transferMoney(long fromUserId, long toUserId, BigDecimal amount) {
    User from = userRepository.findById(fromUserId).orElseThrow();
    User to = userRepository.findById(toUserId).orElseThrow();
    
    from.setBalance(from.getBalance().subtract(amount));
    to.setBalance(to.getBalance().add(amount));
    
    userRepository.save(from);
    userRepository.save(to);
    // если какая-то операция упадёт, вся транзакция откатится
}

Без @Transactional каждый save был бы отдельной транзакцией, и можно остаться в несогласованном состоянии (деньги отправлены, но не получены).

Вынос аналитических запросов из горячего OLTP-кластера:

Тяжёлые запросы (GROUP BY с миллионами строк, JOIN'ы многих таблиц) не должны выполняться на primary MySQL. Они замораживают систему.

Решение:

  1. Выполнять на отдельной репліке (которая может висеть под долгим запросом).
  2. Кешировать результаты (вычислить раз в день).
  3. Использовать OLAP хранилище (DataWarehouse).
// ПЛОХО (OLTP):
SELECT u.country, COUNT(*) FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.country;

// ХОРОШО (кешированный результат, обновляется раз в день):
@Cacheable("country_stats")
@CacheEvict(value = "country_stats", allEntries = true, cron = "0 0 * * * *")  // каждый день в 00:00
public List<CountryStats> getCountryStats() {
    return statsRepository.getCountryStats();  // запрос выполняется раз в день
}

Краткий чек-лист по MySQL/MariaDB для собеседований

Senior Java Backend разработчик должен уверенно проговаривать следующие тезисы:

Как InnoDB хранит данные

"InnoDB использует B-tree индексы для хранения данных. Primary key (первичный ключ) — это кластерный индекс, который определяет физический порядок хранения всех данных. Листья B-tree'я содержат полные строки данных.

Данные организованы в страницы (16 кБ каждая), которые хранятся в buffer pool'е (основном кеше памяти). При чтении строки вся страница читается в buffer pool.

Для гарантии ACID используется redo log (логирование изменений) и undo log (логирование откатов и старых версий)."

Чем primary key отличается от secondary index

"Primary key — это кластерный индекс, он хранит полные данные строк. Это означает, что чтение по primary key очень быстро, потому что все данные сразу есть в индексе.

Secondary index (вторичный индекс) — это отдельный B-tree, листья которого содержат (значение индекса, primary key). Это означает, что при чтении через secondary index нужно два обращения: сначала найти primary key через secondary index, затем найти полную строку через primary key ('double lookup').

Размер вторичного индекса напрямую зависит от размера primary key. Если primary key большой (UUID 16 байт вместо BIGINT 8 байт), все вторичные индексы будут в два раза больше."

Что такое covering index

"Covering index — это индекс, который содержит все столбцы, необходимые для выполнения запроса. MySQL может выполнить запрос, используя только этот индекс, без обращения к основной таблице.

Пример: запрос SELECT email FROM users WHERE country='RU'. Индекс (country, email) покрывает этот запрос. MySQL будет использовать index-only scan, что очень быстро, потому что меньше IO операций."

Как работает MVCC и какие уровни изолированности есть

"MVCC (Multi-Version Concurrency Control) позволяет разным транзакциям видеть разные версии одних и тех же данных. Когда транзакция обновляет строку, старая версия сохраняется в undo log, и создаётся новая версия. Другие транзакции видят старую версию через snapshot механизм.

Уровни изолированности в MySQL:

  • READ UNCOMMITTED: может читать незакоммиченные данные (dirty read). Не используется.
  • READ COMMITTED: может читать только закоммиченные данные, но может быть non-repeatable read. Для высоконагруженных систем.
  • REPEATABLE READ: транзакция видит стабильный снимок данных (дефолт в MySQL). Защита от non-repeatable read через gap locks.
  • SERIALIZABLE: полная изолированность, но низкая производительность.

В MySQL дефолт REPEATABLE READ, что хорошо: даёт нужную изолированность и приличную производительность."

Как устроена репликация и какие режимы

"Репликация копирует данные с primary (source) на replica (standby). Primary пишет все изменения в binlog, replica читает binlog и применяет изменения.

Режимы репликации:

  • Асинхронная (async): primary не ждёт подтверждения от реплики. Быстро, но есть риск потери последних транзакций при падении primary.
  • Полусинхронная (semisync): primary ждёт подтверждения от минимум одной реплики перед commit'ом. Медленнее, но надёжнее.

Репликационный лаг — это время между выполнением запроса на primary и применением на репліке. Может быть от миллисекунд до часов, в зависимости от нагрузки.

Для чтения из реплик: критичные read'ы (которые зависят от предыдущих write'ов) только из primary, остальные read'ы из реплик."

Разница partitioning vs sharding

"Partitioning — это логическое деление таблицы внутри одного MySQL-сервера. Например, таблица orders разбивается на партиции по году (p2022, p2023, p2024). Это помогает с производительностью больших таблиц.

Sharding — это физическое распределение данных по нескольким независимым MySQL-серверам. Данные распределяются по shard key (например, user_id). Каждый shard содержит часть данных.

Partitioning решает проблему с производительностью одного сервера, sharding решает проблему с масштабированием (read'ы и write'ы) через несколько серверов."

Типичные грабли и как их обходить

"Основные проблемы в MySQL:

  1. Long-running транзакции: блокируют другие операции, занимают место в undo log. Решение: короткие транзакции, мониторинг slow query log.

  2. Deadlock'и: две транзакции ждут друг друга. Причина: разный порядок обновления строк. Решение: стабильный порядок операций, retry логика в приложении.

  3. Неправильные индексы или их отсутствие: full table scan, медленные запросы. Решение: анализировать EXPLAIN, добавлять индексы.

  4. Плохой выбор primary key (например, UUID вместо AUTO_INCREMENT): большие индексы, плохая локальность. Решение: выбирать AUTO_INCREMENT для большинства таблиц.

  5. Большие JOIN'ы в OLTP: медленно, использует временные таблицы. Решение: денормализовать или выполнять на репліке/OLAP.

Общий подход: понимать, как устроена MySQL (storage engine, buffer pool, индексы), мониторить систему (slow query log, SHOW PROCESSLIST), и вовремя оптимизировать."

MongoDB

Роль MongoDB в «карте мира» баз данных

Что даёт документная модель по сравнению с классической реляционной

Документная база данных хранит данные в формате документов (обычно JSON-подобных структур), а не в строках таблиц. Это фундаментальное отличие меняет подход к моделированию и оптимизации запросов.

Встроенные вложенные структуры. В реляционной БД сложная сущность (например, заказ с позициями, адресом доставки и история изменений статуса) требует нескольких таблиц и множества join'ов при чтении. В MongoDB такую сущность можно представить одним документом с вложенными объектами и массивами:

{
  _id: ObjectId(...),
  orderNumber: "ORD-12345",
  customer: {
    id: 123,
    name: "John Doe",
    email: "john@example.com"
  },
  items: [
    { productId: 456, quantity: 2, price: 29.99 },
    { productId: 789, quantity: 1, price: 199.99 }
  ],
  shippingAddress: {
    street: "123 Main St",
    city: "Boston",
    zip: "02101"
  },
  statusHistory: [
    { status: "created", timestamp: ISODate("2024-01-15T10:00:00Z") },
    { status: "shipped", timestamp: ISODate("2024-01-16T14:30:00Z") }
  ]
}

Одна операция findOne() получит весь заказ со всеми деталями; в RDBMS потребовались бы join'ы по нескольким таблицам.

Близость к объектной модели приложения. Java backend работает с объектами: Order, Customer, ShippingAddress. Документная модель MongoDB естественно соответствует этой иерархии. При сериализации Java-объекта в BSON (бинарный формат MongoDB) и обратно теряется минимум информации о структуре. В результате код приложения становится проще, меньше impedance mismatch между моделью БД и моделью приложения.

Гибкость схемы. MongoDB не требует предварительного описания схемы. Документы в одной коллекции могут иметь разные наборы полей. Это позволяет эволюционировать модель данных без сложных миграций, что ускоряет разработку прототипов и минимизирует downtime при добавлении новых полей. Новое поле может быть добавлено в одних документах, в других отсутствовать — приложение обрабатывает оба варианта.

Типичные сценарии применения MongoDB

Профили пользователей, настройки, документы с непредсказуемой структурой. Если у каждого пользователя может быть свой набор настроек, достижений, рекомендаций или истории активности, гибкая схема MongoDB удобнее. Настройки одного пользователя могут содержать поля, которых нет у другого. Попытка уложить это в набор таблиц со строгой схемой приведёт к множеству nullable полей или сложной нормализации.

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

Прототипы и быстрый старт продуктов. Когда требования ещё не устоялись, а готовить сложную миграцию в реляционной БД дорого, MongoDB позволяет быстро экспериментировать. Добавил новое поле? Просто добавляй его в новые документы. Понял, что поле больше не нужно? Перестань его заполнять. Никаких ALTER TABLE и миграций схемы.

Почему Senior-разработчик должен понимать нюансы MongoDB

На собеседованиях, особенно для позиций Senior и System Design, часто спрашивают о выборе между RDBMS и NoSQL. Кандидат, который может ясно объяснить trade-off'ы, выглядит сильнее. Вопросы вроде «когда бы вы выбрали MongoDB вместо PostgreSQL?» или «какие проблемы документная БД решает лучше?» — стандартны.

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

Наконец, философия MongoDB (гибкая схема, agile development) всё ещё актуальна. Даже если в проекте используется PostgreSQL, идеи о денормализации, embed vs reference, query-driven design применимы и там.

Документная модель, коллекции и гибкая схема

Основные сущности

База данных (database). MongoDB организует данные в базы данных. На одном сервере (или в одном кластере) может быть несколько баз данных с изолированными пространствами имён.

Коллекция (collection). Коллекция — это аналог таблицы в RDBMS, но без строгой схемы. Содержит документы. Например, коллекция users, коллекция orders.

Документ (document). Документ — это одна запись, обычно представляемая как JSON-объект. На уровне БД хранится в бинарном формате BSON (Binary JSON), который поддерживает дополнительные типы данных (ObjectId, ISODate, Binary, DBRef и др.).

Каждый документ имеет поле _id, которое является primary key коллекции. Если не указать _id при вставке, MongoDB автоматически сгенерирует ObjectId.

Гибкая схема: основная идея и реальность

MongoDB не требует заранее определять структуру документов. Два документа в одной коллекции могут иметь совершенно разные поля:

// Документ 1
{ _id: 1, name: "Alice", age: 30, email: "alice@example.com" }

// Документ 2
{ _id: 2, name: "Bob", role: "admin", joinedAt: ISODate("2024-01-01T00:00:00Z") }

// Документ 3
{ _id: 3, name: "Charlie" }

Все три документа живут в одной коллекции без конфликтов. Отсутствие поля означает, что поле просто не заполнено, а не что-то сломалось.

Плюсы flexible schema

Быстрый старт и эволюция. На ранних стадиях разработки структура сущностей ещё не устоялась. MongoDB позволяет начать писать код и добавлять документы без предварительного описания таблиц и колонок. Понадобилось новое поле — добавь его к новым документам, старые будут без него, приложение обработает оба варианта.

Естественное хранение вложенных структур. Адреса, контакты, списки, параметры — всё это может быть вложено в документ без необходимости нормализации в отдельные таблицы.

Отсутствие затратных миграций. В PostgreSQL добавить новую колонку требует ALTER TABLE, что может заблокировать таблицу на время выполнения. MongoDB позволяет добавлять данные постепенно, без блокировок всей коллекции.

Минусы flexible schema

Риск «зоопарка» схем. Без дисциплины в команде коллекция может стать хаотичной. Одни разработчики добавляют email, другие emailAddress, третьи не заполняют контактные данные вообще. Результат — сложный, непредсказуемый код для валидации и обработки данных.

Сложность валидации и эволюции. Жёсткая схема в RDBMS гарантирует, что в колонке age никогда не окажется строка. В MongoDB валидация — зона ответственности приложения. Легко забыть проверку и получить некорректные данные.

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

Сравнение с реляционной моделью

В RDBMS типичный подход:

  • Нормализация: отдельная таблица для каждой сущности (users, addresses, orders, order_items).
  • Связи через foreign key.
  • Запрос требует join'ов для сборки всех связанных данных.

В MongoDB:

  • Агрегация: связанные данные часто вложены в один документ.
  • Минимум связей (reference) между коллекциями.
  • Один документ часто содержит всё необходимое для бизнес-операции.

Результат: MongoDB запрос часто быстрее и проще (меньше join'ов), но данные могут быть частично денормализованы, что требует механизмов синхронизации.

Валидация схемы в гибком мире

Хотя MongoDB поддерживает JSON Schema валидацию на уровне БД (можно создать валидатор при создании коллекции), в практике часто полагаются на валидацию на приложении.

На уровне БД: MongoDB позволяет указать $jsonSchema при создании коллекции:

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email"],
      properties: {
        name: { bsonType: "string" },
        email: { bsonType: "string" },
        age: { bsonType: ["int", "null"] }
      }
    }
  }
})

Но такая валидация не обязательна и часто не используется на практике.

На уровне приложения: Java-классы (Entity, DTO) с аннотациями (@NotNull, @Email и т.д.) и маппинг из BSON в объекты через драйвер (MongoClient) или ORM-подобные инструменты (Spring Data MongoDB).

Рекомендация: даже в «schema-less» мире нужна дисциплина. Документируй ожидаемую структуру каждой коллекции в коде или в config. Используй типизацию на уровне Java-классов. Это избавит от головной боли при миграции, рефакторинге и от ошибок валидации в production.

Индексы в MongoDB: базовые, compound, partial и TTL

Базовые индексы: single-field

Индекс по одному полю — простейший случай. Если часто ищешь пользователя по email, имеет смысл создать индекс:

db.users.createIndex({ email: 1 })

(1 означает возрастающий порядок, -1 означает убывающий.)

Влияние на поиск. Без индекса MongoDB выполняет collection scan: проходит по всем документам коллекции, фильтруя по условию. С индексом поиск использует B-tree структуру индекса, что даёт логарифмическую сложность вместо линейной. Для миллионов документов разница серьёзная (миллисекунды вместо секунд).

Влияние на сортировку. Если индекс совпадает с направлением сортировки, MongoDB может вернуть результаты уже отсортированными без дополнительного шага сортировки в памяти. Это экономит CPU и память.

Стоимость индекса при записи. Каждый индекс требует обновления при insert, update, delete. Больше индексов — медленнее записи. Нужна мера: индексы на часто-читаемые поля, но не на всё подряд.

Compound-индексы

Индекс по нескольким полям помогает для запросов с фильтрацией по нескольким условиям и сортировкой.

db.orders.createIndex({ userId: 1, createdAt: -1 })

Правило префикса (prefix rule). Индекс {userId, createdAt} эффективен для:

  • Поиска по userId (префикс).
  • Поиска по userId и createdAt вместе (полный индекс).
  • Поиска по userId с сортировкой по createdAt (используется индекс для обоих).

Индекс НЕ эффективен для:

  • Поиска только по createdAt (без userId).
  • Поиска по другому полю, не userId.

Порядок полей имеет значение. В compound-индексе порядок полей критичен. Индекс {userId, createdAt} отличается от {createdAt, userId}. Выбор порядка зависит от типичных access patterns:

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

Практический пример. Запрос: получить последние 10 заказов конкретного пользователя. Без индекса — collection scan по всем заказам, потом filter и sort. С индексом {userId: 1, createdAt: -1} — прямая навигация к заказам пользователя в порядке убывания даты.

Уникальные индексы

Уникальный индекс гарантирует, что в коллекции нет дублей по данному полю:

db.users.createIndex({ email: 1 }, { unique: true })

Попытка вставить или обновить документ с дублирующимся значением email вызовет ошибку.

Особенность при flexible schema. Если поле может отсутствовать в документе, MongoDB считает отсутствие значения как null. По умолчанию, уникальный индекс позволяет несколько документов без поля (несколько null'ов). Если хочешь запретить это, используй опцию sparse: true — индекс будет создан только на документы, у которых есть поле. Документы без поля не будут в индексе и могут быть много.

Partial индексы

Partial индекс включает только документы, соответствующие условию фильтра. Это снижает размер индекса и ускоряет запросы на «активных» данных.

db.orders.createIndex(
  { userId: 1, createdAt: -1 },
  { partialFilterExpression: { status: { $in: ["active", "pending"] } } }
)

Такой индекс используется только для запросов, которые фильтруют по status: active или pending. Запросы по completed заказам не использовать этот индекс.

Типичные сценарии:

  • Индексы на активные пользователей (где isActive: true), неудалённые документы (где deletedAt: null), недавние события (где дата больше порога).
  • На больших коллекциях это даёт существенную экономию памяти и ускорение операций.

TTL-индексы

TTL (Time To Live) индекс автоматически удаляет документы по истечении определённого времени. Очень полезен для временных данных.

db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })

Документы в коллекции sessions с полем createdAt будут автоматически удалены через 3600 секунд (1 час) после их создания.

Типичные применения:

  • Сессии пользователей: удаляются через N часов неиспользования.
  • Одноразовые токены (для восстановления пароля, подтверждения email): удаляются через N минут или N часов.
  • Кэш данных: TTL служит invalidation механизмом.
  • Логи событий: временные события удаляются через неделю или месяц.

Важные детали:

  • Background процесс MongoDB периодически (по умолчанию каждую минуту) проверяет TTL индексы и удаляет истёкшие документы. Удаление не мгновенное.
  • TTL всегда работает с ISODate полем. Другие типы (числовые timestamp'ы) требуют дополнительной работы.
  • Overhead от TTL минимален, но всё равно есть. На очень больших коллекциях с высокой частотой удалений может быть заметен.

Текстовые и гео-индексы

Text индексы для полнотекстового поиска. Если нужна функциональность вроде поиска по названию товара, описанию, автору с морфологией и игнорированием stopwords, используй text индекс:

db.articles.createIndex({ title: "text", content: "text" })
db.articles.find({ $text: { $search: "mongodb database" } })

Используется реже, чем в продакшене часто нужны полнотекстовые движки (Elasticsearch), но MongoDB поддерживает на базовом уровне.

Geo индексы для географических запросов. Если нужна функциональность вроде «найти рестораны в радиусе 5 км», создаёшь geo индекс и используешь geo-запросы:

db.restaurants.createIndex({ location: "2dsphere" })

db.restaurants.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [-73.97, 40.77] },
      $maxDistance: 5000
    }
  }
})

Для geo запросов требуется формат GeoJSON (координаты как [longitude, latitude]).

Анализ использования индексов: explain и планы выполнения

Утилита explain() показывает план выполнения запроса:

db.orders.find({ userId: 123 }).explain("executionStats")

Результат включает информацию о том, какой индекс использован, сколько документов проверено, сколько возвращено. Ключевые метрики:

  • executionStages.stage: "COLLSCAN" означает collection scan (нет индекса), "IXSCAN" означает поиск по индексу.
  • executionStages.keysExamined: количество документов, проверенных через индекс.
  • executionStages.docsExamined: количество документов, прочитанных из коллекции.
  • executionStats.nReturned: количество документов в результате.

Признаки плохого индекса:

  • keysExamined >> nReturned: индекс проверил много ненужных документов.
  • docsExamined >> nReturned: много документов прочитано из коллекции, но возвращено мало (плохая selectivity фильтра).
  • stage = "COLLSCAN": нет индекса, collection scan.

Правило большого пальца: если запрос возвращает 1000 документов, а docsExamined = 1000000, что-то не так. Нужен лучший индекс или переформулировка запроса.

Aggregation framework и типовые запросы

Два уровня запросов

Простые запросы (CRUD). Метод find() с фильтрами, sort(), skip(), limit():

db.users.find({ status: "active" }).sort({ createdAt: -1 }).limit(10)

Эти запросы быстрые для базовых операций чтения, но не подходят для сложных трансформаций данных.

Агрегационные запросы (aggregation pipeline). Когда нужна трансформация, группировка, статистика, используется Aggregation Framework:

db.orders.aggregate([
  { $match: { status: "completed", createdAt: { $gte: ISODate("2024-01-01") } } },
  { $group: { _id: "$userId", totalSpent: { $sum: "$amount" } } },
  { $sort: { totalSpent: -1 } },
  { $limit: 10 }
])

Aggregation pipeline: основная идея

Pipeline — это последовательность этапов. Документы «текут» через цепочку преобразований, на каждом этапе изменяясь:

  1. $match: фильтрует документы (аналог WHERE в SQL).
  2. $project: выбирает/переименовывает/вычисляет поля.
  3. $group: группирует документы по ключу и применяет агрегирующие функции (sum, avg, count, push и др.).
  4. $sort: сортирует.
  5. $limit: ограничивает количество документов.
  6. $skip: пропускает первые N документов (для пагинации).
  7. $lookup: аналог JOIN, подтягивает документы из другой коллекции.
  8. $unwind: разворачивает массив в отдельные документы.
  9. $facet: выполняет несколько агрегаций в параллели на одном наборе данных.
  10. И множество других.

Mental model: думай о pipeline как о потоке обработки. Начальный набор документов проходит через $match, пропуская ненужные. Потом через $group, комбинируя данные. И так далее.

Документы -> $match -> фильтрованные -> $group -> агрегированные -> $sort -> отсортированные -> $limit -> результат

Типичные паттерны

Фильтрация + сортировка + пагинация.

db.posts.aggregate([
  { $match: { authorId: 123, published: true } },
  { $sort: { createdAt: -1 } },
  { $skip: 20 },
  { $limit: 10 }
])

Это стандартный паттерн для списков (feed, поиск с фильтрами).

Агрегаты по полям (суммы, счётчики). Получить сумму расходов по каждой категории:

db.expenses.aggregate([
  { $group: {
      _id: "$category",
      totalAmount: { $sum: "$amount" },
      count: { $sum: 1 },
      avgAmount: { $avg: "$amount" }
    }
  },
  { $sort: { totalAmount: -1 } }
])

Группировка с множественными полями.

db.orders.aggregate([
  { $group: {
      _id: { userId: "$userId", month: { $dateToString: { format: "%Y-%m", date: "$createdAt" } } },
      totalSpent: { $sum: "$amount" },
      orderCount: { $sum: 1 }
    }
  }
])

Агрегирование вложенных массивов. Если документ содержит массив items, можно развернуть этот массив и агрегировать на уровне item'ов:

db.orders.aggregate([
  { $unwind: "$items" },
  { $group: {
      _id: "$items.productId",
      totalQty: { $sum: "$items.quantity" },
      totalRevenue: { $sum: { $multiply: ["$items.quantity", "$items.price"] } }
    }
  }
])

$unwind превращает документ { items: [item1, item2, item3] } в три документа: { items: item1 }, { items: item2 }, { items: item3 }. Потом $group агрегирует по productId.

Lookup для join'ов между коллекциями.

db.orders.aggregate([
  { $match: { status: "completed" } },
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" },
  { $project: {
      orderId: 1,
      orderAmount: 1,
      customerName: "$customer.name",
      customerEmail: "$customer.email"
    }
  }
])

$lookup присоединяет документы из коллекции customers на основе customerId. Результат в поле customer (массив). $unwind разворачивает массив, $project выбирает нужные поля.

Выбор между простым запросом и агрегацией

Когда использовать find() с постобработкой на Java:

  • Простые фильтры и сортировки (небольшой объём данных).
  • Когда логика фильтрации сложная и зависит от business logic приложения.
  • Когда нужна гибкость (например, динамически строишь условия в Java).

Когда использовать aggregation pipeline:

  • Нужна статистика, группировка, сложные трансформации.
  • Большие объёмы данных: выгодно фильтровать и трансформировать на стороне БД, чем отправлять миллионы документов в приложение.
  • Join'ы между коллекциями: $lookup обычно быстрее, чем несколько запросов из Java кода.
  • Результат агрегации нужен как есть (не требует дополнительной обработки).

Rule of thumb: если есть $group, $lookup или сложные трансформации, используй aggregation pipeline. Если просто выбираешь и сортируешь, можешь обойтись простым запросом.

Ограничения aggregation pipeline

Сложность и производительность. Тяжёлые pipeline'ы (много $lookup, сложные вычисления) требуют CPU и памяти. На большом кластере это может повлиять на другие операции. Мониторь выполнение больших агрегаций, используй explain().

Лимиты в памяти и на результат. Каждый stage pipeline'а работает с данными в памяти. Если агрегация на $group генерирует миллионы промежуточных документов, это может исчерпать лимит памяти. Обычно 100 MB лимит на результат одного stage'а (можно увеличить через allowDiskUse: true, но это медленнее).

db.collection.aggregate(pipeline, { allowDiskUse: true })

Вопросы про aggregation на собеседованиях

«Приходилось ли использовать aggregation framework?» Ожидаемый ответ: привести конкретный пример. Например, «да, использовал для подсчёта статистики по заказам: группировал по дате, считал сумму, среднее значение, отсортировал по сумме. Это было быстрее, чем доставать все заказы и считать на Java».

«Что такое $lookup и когда его использовать?» $lookup — это join в MongoDB. Используется для связывания данных из разных коллекций. Например, присоединить информацию о customer'е к каждому order'у. Важно помнить, что $lookup может быть дорогим на больших коллекциях, нужно оптимизировать через индексы и ограничение данных перед lookup'ом.

Репликация, Replica Set и отказоустойчивость

Replica Set: структура и роли

Replica Set — это группа MongoDB инстансов, которые хранят одинаковые данные. Включает:

Primary. Инстанс, принимающий все операции записи (insert, update, delete). При подключении клиента обычно пишет именно в primary.

Secondary (0 или больше). Инстансы, которые реплицируют данные из primary'ия. Могут использоваться для чтения (если клиент явно запросит). По умолчанию не принимают запросы на запись от приложений (но используют свой механизм репликации).

Arbiter (опционально). Инстанс без данных, учитывается только при выборе нового primary при failover'е (подробнее ниже). Снижает overhead для HA сценариев, когда нельзя позволить 3 полноценных инстанса.

Типичная конфигурация: 1 Primary + 2 Secondary (или 1 Primary + 1 Secondary + 1 Arbiter).

Репликация: oplog и применение данных

Primary логирует все операции записи в специальную коллекцию oplog.rs (операционный лог). Secondary'и подписываются на этот лог и применяют те же операции у себя.

Процесс:

  1. Приложение пишет документ в Primary.
  2. Primary выполняет операцию, логирует её в oplog.
  3. Secondary периодически (обычно почти мгновенно) получает изменения из oplog.
  4. Secondary применяет операции в том же порядке.

Опережающее чтение vs актуальность. Репликация асинхронна: data на Secondary'ях всегда немного «старше», чем на Primary'е. Это означает, что чтение с Secondary может вернуть данные, которые ещё не применены из oplog (лаг репликации).

Read preference: где читать

MongoDB позволяет явно выбирать, откуда читать:

Primary. Читаются свежие данные, но нагрузка на primary выше. Используется по умолчанию.

Secondary. Нагрузка на primary снижается, но данные могут быть устаревшими на несколько миллисекунд или секунд (зависит от лага репликации).

Primary preferred / Secondary preferred. Стратегии с fallback: если primary недоступен, читай с secondary и наоборот.

// Java пример с MongoDB драйвером
FindIterable<Document> result = collection.find(query)
  .withReadPreference(ReadPreference.secondaryPreferred());

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

Failover: переключение при сбое primary

Если primary выходит из строя (крах, сетевая недоступность, зависание), MongoDB автоматически выбирает нового primary из secondary'ев.

Процесс выбора. Инстансы в Replica Set периодически проверяют друг друга (heartbeat). Когда обнаруживается, что primary недоступен, secondary'и голосуют за новый primary. Инстанс с наибольшим опережением (наиболее актуальные данные) обычно выбирается.

Время failover'а. Failover не мгновенен. Обычно занимает 10-30 секунд (зависит от конфигурации heartbeat'ов). Во время failover'а приложение не может писать (нет primary'я), хотя может читать с secondary'ей.

Влияние на приложение. Java код должен обрабатывать исключения при failover'е. Драйвер обычно автоматически переподключается к новому primary'у, но операции, которые были в полёте, могут отказать. Нужна обработка ошибок и ретраи с идемпотентными операциями.

try {
  collection.insertOne(document);
} catch (MongoException e) {
  if (e.hasErrorLabel("NotWritablePrimary") || e.hasErrorLabel("Transient")) {
    // Failover, retry
    Thread.sleep(1000);
    collection.insertOne(document); // Retry
  }
}

Replica Set в HA-сценариях

Для описания high-availability сценария на собеседовании можно кратко:

«Используем Replica Set из 3 инстансов: 1 primary + 2 secondary'я. Все три на разных машинах, желательно в разных дата-центрах. Данные автоматически реплицируются с primary'я на secondary'и. При сбое primary'я Replica Set автоматически выбирает нового primary из secondary'ей. Приложение через драйвер подключается к Replica Set (не конкретному инстансу) и автоматически переподключается при failover'е. Это даёт нам автоматическое восстановление при сбое одного сервера».

Шардирование MongoDB: выбор shard key и балансировка

Зачем нужно шардирование

Replica Set гарантирует redundancy (копирование данных) и HA, но не масштабирует объём данных на одном инстансе. Если коллекция растёт до 500 GB, а RAM на сервере всего 64 GB, индексы и рабочий set не поместятся в память, queries будут медленные.

Шардирование (горизонтальное масштабирование) разделяет данные между несколькими Replica Set'ами (шардами). Каждый шард хранит часть данных.

Пример: коллекция users с 1 миллиардом документов. Без шардирования — 1 Replica Set, 1 TB данных. С шардированием по userId — 10 шардов, каждый 100 GB, распределённые нагрузка и запросы.

Архитектурные компоненты

Shards. Каждый shard это Replica Set. Хранит подмножество данных (например, users с userId от 0 до 1000000 на shard 1, от 1000001 до 2000000 на shard 2).

Config servers. Специальные инстансы, хранящие metadata о том, как распределены данные (какой диапазон userId на каком shard'е). Обычно 3 config server'а для redundancy.

Mongos (router). Это промежуточный сервер, который клиент видит вместо конкретного mongod'а. Mongos получает запрос, смотрит в config servers какие shard'ы нужны, маршрутизирует запрос туда, собирает результаты.

Схема:

Приложение -> Mongos -> Config servers (metadata)
                      -> Shard 1 (Replica Set)
                      -> Shard 2 (Replica Set)
                      -> Shard 3 (Replica Set)

Shard key: выбор и требования

Shard key — это поле (или набор полей), по которому выполняется разделение данных. Выбор shard key критичен и напрямую влияет на производительность.

Требования к shard key:

  1. Энтропия (cardinality). Shard key должен иметь много различных значений, чтобы данные равномерно распределились между шардами. Если выбрать shard key с 10 уникальными значениями для 1 миллиарда документов, получится неравномерное распределение: один shard перегруженный, другие пусто.

  2. Неизменяемость. Shard key не должен часто меняться, так как смена значения требует перемещения документа между шардами (дорогая операция).

  3. Распределённость. Значения должны быть распределены так, чтобы не было длинных последовательностей документов на одном shard'е (hot shard проблема).

Типичные выборы:

  • userId: хороший выбор, если у вас много пользователей, значения распределены равномерно.
  • _id (ObjectId): MongoDB автоматически распределяет ObjectId'ы, хороший выбор.
  • Комбинация: {customerId, createdAt} для равномерного распределения.

Плохие выборы:

  • country: только 200 стран, очень плохая карта. Один или два shard'а будут перегружены (USA, China).
  • status: может быть 5 статусов, очень плохо.
  • isActive: только true/false, ужасный выбор.

Hot shard проблема

Если shard key распределяется неравномерно или если access pattern'ы сфокусированы на узком диапазоне значений, один shard будет получать большинство запросов. Это называется hot shard.

Пример: если использован shard key createdAt (timestamp), то последние 24 часа данные очень горячие (последний shard'а получает большинство чтений и записей). Старые данные на других shard'ах практически не используются.

Решение: выбрать shard key, который естественно распределяется по access pattern'ам, или использовать hashed shard key (ниже).

Типы shard key по распределению

Hashed shard key. MongoDB хеширует значение shard key и использует хеш для распределения. Это гарантирует равномерное распределение даже для range-подобных значений:

db.collection.createIndex({ timestamp: "hashed" })
// Шардирование по хешу timestamp

Плюс: гарантированное равномерное распределение. Минус: диапазонные запросы (например, «все заказы за последнюю неделю») могут требовать обращение ко всем shard'ам, потому что хеш разбросает соседние timestamp'ы по разным shard'ам.

Range shard key. MongoDB делит данные по диапазонам значений. Например, userId от 0-1000000 на shard 1, 1000001-2000000 на shard 2.

Плюс: диапазонные запросы могут быть направлены на конкретный shard'а. Минус: риск hot shard, если значения неравномерно распределены.

На собеседовании: объяснить выбор между hash и range, помня о access pattern'ах.

Балансировка и перемещение чанков

MongoDB автоматически делит коллекцию на chunks (куски). Каждый chunk содержит диапазон значений shard key'а. При неравномерном распределении chunks'ов между shard'ами, MongoDB автоматически перемещает chunk'ы (балансировка).

Процесс:

  1. Config servers отслеживают распределение chunk'ов.
  2. Если обнаруживается дисбаланс (один shard имеет намного больше chunk'ов), запускается migrator.
  3. Chunk'ы перемещаются между shard'ами.

Балансировка происходит в background и может повлиять на производительность (extra нагрузка на сеть и дисках). На практике балансировку часто отключают во время peak load'а и включают ночью.

Ограничения при шардировании

Необходимость включать shard key в запросы. Если запрос не содержит shard key, mongos не может определить, на каком shard'е данные, и должен маршрутизировать запрос всем shard'ам (scatter-gather). Это медленно.

// Хороший запрос (содержит shard key userId)
db.orders.find({ userId: 123, status: "completed" })

// Плохой запрос (нет shard key, scatter-gather ко всем shard'ам)
db.orders.find({ status: "completed" })

Сложности с транзакциями. Multi-document транзакции в шардированной системе значительно дороже. Если транзакция затрагивает документы на разных shard'ах, требуется координация (distributed transaction). Лучше избегать транзакций, затрагивающих несколько shard'ов.

Join'ы через $lookup. Lookup на шардированной коллекции может требовать обращение ко всем shard'ам, что медленнее.

Выбор shard key: рекомендации для интервью

На собеседовании, если спрашивают про шардирование:

«Для выбора shard key я бы проанализировал access pattern'ы. Какие поля чаще всего используются в фильтрах? Как распределены значения этого поля? Если выбираю userId, нужно убедиться, что у нас миллионы пользователей, чтобы не было hot shard'ов. Рассмотрел бы trade-off'ы: hash shard key гарантирует равномерное распределение, но диапазонные запросы по этому полю станут дороже. Range shard key позволяет эффективные диапазонные запросы, но требует тщательного выбора, чтобы избежать hot shard'ов. В идеале, shard key должен быть в большинстве запросов, чтобы они были направлены на нужный shard, а не scatter-gather.»

Транзакции в MongoDB: single-document и multi-document

Single-document atomicity (по умолчанию)

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

db.users.updateOne(
  { _id: 123 },
  {
    $set: { name: "Alice", email: "alice@example.com" },
    $inc: { version: 1 },
    $push: { lastModified: new Date() }
  }
)

Это обновление гарантированно применится целиком или откатится. Нет состояния, в котором name обновлён, но email нет.

Важность для моделирования. Если при проектировании коллекции всё, что нужно логически обновить вместе, находится в одном документе, можно избежать необходимости в multi-document транзакциях и их overhead'а.

Multi-document транзакции

Начиная с MongoDB 4.0, поддерживаются транзакции над несколькими документами и даже коллекциями (похоже на ACID транзакции в RDBMS). Это позволяет выполнить несколько операций как одно атомарное целое.

try (ClientSession session = mongoClient.startSession()) {
  session.startTransaction();
  try {
    // Операция 1
    ordersCollection.insertOne(session, newOrder);
    // Операция 2
    inventoryCollection.updateOne(session,
      Filters.eq("_id", productId),
      Updates.inc("stock", -1)
    );
    session.commitTransaction();
  } catch (Exception e) {
    session.abortTransaction();
    throw e;
  }
}

Обе операции (создание заказа и уменьшение количества товара) либо применятся обе, либо откатятся обе.

Когда нужны multi-document транзакции

Сценарии, похожие на классический ACID.

  • Перевод денег между счётами: нужно уменьшить баланс одного аккаунта и увеличить другого. Если операция частично выполнится, деньги потеряются или продублируются.
  • Создание заказа: одновременно создаётся документ заказа, уменьшается количество товара, может создаваться счёт. Все операции либо успешны, либо откатываются.

Когда лучше пересмотреть модель данных. На практике часто можно избежать multi-document транзакций пересмотром моделирования:

  • Вместо отдельной коллекции inventory эмбеддить товары в заказ. Тогда update заказа (включая товары) будет single-document.
  • Использовать «eventual consistency»: создать заказ, потом асинхронно уменьшить inventory. Если что-то пойдёт не так, использовать reconciliation.
  • Использовать счётчики-компенсации: вместо прямого уменьшения inventory использовать систему резервирования/выполнения.

Ограничения multi-document транзакций

Производительность и задержка. Транзакции дороже обычных операций. Несколько операций под одной транзакцией требуют координации (особенно в шардированной системе). Задержка возрастает.

Длительность и timeouts. Транзакции имеют лимит по времени (по умолчанию 30 секунд). Если операция длится дольше, транзакция будет откачена.

Тяжесть на шардированных системах. Если транзакция затрагивает документы на разных shard'ах, требуется distributed transaction, что значительно дороже. MongoDB старается минимизировать такие транзакции.

Рекомендации для дизайна

Первый выбор: single-document atomicity. Моделируй данные так, чтобы логически связанные операции затрагивали один документ. Это даёт атомарность без overhead'а.

Второй выбор: eventual consistency. Используй асинхронные процессы, события (event sourcing). Например, при создании заказа опубликуй событие OrderCreated, а отдельный сервис подписывается на это событие и обновляет inventory. Это масштабируется лучше.

Последний выбор: multi-document транзакции. Используй только если первые два варианта невозможны и операция действительно критична.

Приоритет моделирования:
1. Один документ (single-document atomicity) -> одна операция, мгновенно
2. Несколько операций с eventual consistency -> асинхронно, но масштабируется
3. Multi-document транзакция -> дорого, используй в крайнем случае

Interview пункты

«В чём различие между single-document и multi-document транзакциями в MongoDB?» Ответ: «Single-document атомарны по умолчанию и дешёвые. Multi-document транзакции требуют явного управления сессией и дороже по производительности. На практике стараюсь проектировать схему так, чтобы избежать необходимости в multi-document транзакциях, используя эмбеддинг или eventual consistency».

«Как обойтись без транзакций там, где в RDBMS они были бы нужны?» Ответ может включать: переделать схему (embed данные), использовать eventual consistency с событиями, использовать аппарат компенсирующих транзакций (saga pattern).

Моделирование данных: query-driven design

Почему прямой перенос из RDBMS плохая идея

Классическая ошибка при переходе с PostgreSQL на MongoDB — «поднять» все таблицы один-в-один. Таблица users становится коллекция users с теми же полями. Таблица orders становится коллекция orders. Foreign key'и становятся references.

Результат: много запросов, много join'ов (через $lookup), потеря преимуществ документной модели. Производительность часто хуже, чем в RDBMS, потому что joins в MongoDB медленнее.

Причины:

  • MongoDB оптимизирована для queries, которые вмещают весь нужный контекст в одном документе или минимальном количестве запросов.
  • Когда данные нормализованы как в RDBMS, требуется много операций для сборки результата.
  • Индексирование и планы запросов в MongoDB не такие же efficient как в RDBMS для heavy join'ов.

Query-driven design: методология

Вместо переноса таблиц, нужно:

  1. Определить access pattern'ы. Какие запросы приложение выполняет чаще всего? Какие данные нужны вместе?

    • Получить заказ со всеми деталями (товары, адрес доставки, клиент).
    • Получить все заказы пользователя.
    • Получить товары, отсортированные по популярности.
  2. Спроектировать документ так, чтобы один запрос решал задачу. Для заказа: весь заказ (товары, адрес, клиент) в одном документе. Тогда findOne вернёт всё нужное.

  3. Определить, что эмбеддить, что reference. Если данные часто читаются вместе, embed. Если редко или очень большие, reference.

Не:
Collection "orders": { _id, customerId, totalPrice, ... }
Collection "items": { _id, orderId, productId, quantity, ... }
Collection "customers": { _id, name, email, ... }

Да:
Collection "orders": { 
  _id, 
  customer: { id, name, email },
  items: [ { productId, quantity, price }, ... ],
  totalPrice,
  ...
}

Embed vs Reference

Embed (вложить данные).

Плюсы:

  • Один документ содержит всё необходимое, один запрос.
  • Атомарность: обновление документа включает все вложенные поля.
  • Проще работать с кодом (нет необходимости в join'ах).

Минусы:

  • Дублирование данных (если customer info нужна в нескольких заказах).
  • Размер документа может расти. MongoDB имеет лимит 16 MB на документ.
  • Обновление customer'а требует обновления всех его заказов (если customer info эмбеддена).

Когда embed:

  • Данные логически принадлежат родителю (customer info принадлежит заказу).
  • Данные редко обновляются (customer name редко меняется).
  • Данные не нужны отдельно (customer info нужна только в контексте заказа).
  • Массив не растёт бесконечно (максимум тысячи элементов, не миллионы).

Reference (ссылка на другую коллекцию).

Плюсы:

  • Нет дублирования, single source of truth.
  • Документы меньше.
  • Обновление customer'а автоматически видно во всех заказах.

Минусы:

  • Требуется дополнительный запрос или $lookup для получения referenced данных.
  • Нет гарантии консистентности (customer может быть удалён).

Когда reference:

  • Данные нужны независимо (customer может существовать без заказов).
  • Данные часто обновляются (нужно избежать обновления всех заказов).
  • Массив может расти бесконечно (например, все заказы customer'а).
  • Данные нужны в нескольких контекстах с разными полями (иногда нужно только имя, иногда полная информация).

Примеры моделирования

Пример 1: Заказ + позиции.

// Вариант 1: Embed (рекомендуется для большинства случаев)
db.orders.insertOne({
  _id: ObjectId(),
  orderNumber: "ORD-123",
  customerId: 456,
  createdAt: ISODate(),
  items: [
    { productId: 789, productName: "Widget", quantity: 2, price: 29.99 },
    { productId: 790, productName: "Gadget", quantity: 1, price: 99.99 }
  ],
  totalPrice: 159.97
})

// Query: получить заказ со всеми товарами
db.orders.findOne({ _id: ObjectId() })

Это быстро и просто. Если нужны детали товаров (описание, картинки), можно рассмотреть reference.

// Вариант 2: Reference (если товарные данные тяжёлые или часто обновляются)
db.orders.insertOne({
  _id: ObjectId(),
  orderNumber: "ORD-123",
  customerId: 456,
  itemIds: [
    { itemId: ObjectId(), quantity: 2 },
    { itemId: ObjectId(), quantity: 1 }
  ]
})

db.orderItems.find({ _id: { $in: [itemIds...] } })

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

Пример 2: Пользователь + профиль + настройки.

db.users.insertOne({
  _id: 123,
  username: "alice",
  email: "alice@example.com",
  profile: {
    firstName: "Alice",
    lastName: "Smith",
    avatar: "https://...",
    bio: "..."
  },
  settings: {
    theme: "dark",
    notifications: {
      email: true,
      push: false
    },
    language: "en"
  },
  createdAt: ISODate()
})

// Query: получить профиль пользователя
db.users.findOne({ _id: 123 }, { projection: { profile: 1 } })

Embed здесь естественен: профиль и настройки принадлежат пользователю, редко меняются, часто читаются вместе.

Пример 3: Статьи и комментарии.

// Если комментариев немного (< 1000)
db.articles.insertOne({
  _id: ObjectId(),
  title: "...",
  content: "...",
  comments: [
    { userId: 1, text: "Great!", createdAt: ISODate() },
    { userId: 2, text: "Thanks!", createdAt: ISODate() }
  ]
})

// Если комментариев много
db.articles.insertOne({
  _id: ObjectId(),
  title: "...",
  content: "...",
  commentCount: 50000
})

db.comments.find({ articleId: ObjectId() }).limit(10)

Для большого количества комментариев reference лучше, чтобы избежать документов размером > 16 MB.

Денормализация

Денормализация — это копирование данных в несколько мест для быстроты чтения. Например, customer name может быть скопирована в каждый заказ, даже если customer info хранится в отдельной коллекции.

db.orders.insertOne({
  _id: ObjectId(),
  customerId: 123,
  customerName: "Alice", // копия, для быстроты
  items: [...]
})

Плюсы: быстрый запрос, нет необходимости в join'е.

Минусы: если customer name изменится, нужно обновить все его заказы. Это требует либо фоновой batch-операции, либо события, либо слож иных скриптов синхронизации.

Rule of thumb: денормализируй только для данных, которые:

  • Часто читаются.
  • Редко обновляются.
  • Не критичны быть абсолютно свежими (eventual consistency ok).

Версионирование схемы

При эволюции приложения структура документов меняется. Новое поле добавляется, старое удаляется. Как обрабатывать оба варианта?

// Старая версия (без lastLoginAt)
{ _id: 1, username: "alice", email: "alice@example.com" }

// Новая версия (с lastLoginAt)
{ _id: 2, username: "bob", email: "bob@example.com", lastLoginAt: ISODate() }

Java код обрабатывает оба варианта:

User user = collection.find(...).first();
Date lastLogin = user.getLastLoginAt(); // может быть null, если старый документ
if (lastLogin == null) {
  lastLogin = user.getCreatedAt(); // fallback
}

Во время развёртывания нового кода:

  1. Старые документы в БД остаются без lastLoginAt.
  2. Новый код пишет lastLoginAt для новых документов и при каждом логин'е (update).
  3. Постепенно, все документы получают lastLoginAt.
  4. Когда все документы мигрированы, можно удалить fallback.

Или выполнить batch-миграцию:

db.users.updateMany({}, [{ $set: { lastLoginAt: "$createdAt" } }])

Это одноразовое обновление всех документов.

На собеседовании: query-driven design

Если спросят про моделирование: «Я начинаю с анализа основных access pattern'ов. Какие данные приложение читает вместе? Какие операции выполняются чаще всего? На основе этого решаю, что эмбеддить в один документ, что оставить в отдельной коллекции. Стараюсь минимизировать количество запросов: если данные часто читаются вместе и редко обновляются, embed. Если данные растут быстро или часто обновляются независимо, reference».

Пример: «В одном проекте нам нужно было получать заказы со всеми деталями. Вместо отдельных коллекций для order и items, я поместил items прямо в документ заказа. Это ускорило основной usecase (получить заказ) в 10 раз, потому что больше не нужны join'ы. Для报告 по товарам, которые требуют агрегирования по всем заказам, мы использовали aggregation framework с $unwind для разворачивания items».

MongoDB в контексте Java backend

Драйверы и модель работы

Для работы с MongoDB из Java используется официальный MongoDB Java Driver. Драйвер предоставляет как синхронный, так и асинхронный API.

Синхронный API:

MongoClient client = MongoClients.create("mongodb://localhost:27017");
MongoDatabase database = client.getDatabase("mydb");
MongoCollection<Document> collection = database.getCollection("users");

Document doc = collection.find(new Document("_id", 1)).first();
collection.insertOne(new Document("name", "Alice"));

Асинхронный API:

MongoClient client = MongoClients.create("mongodb://localhost:27017");
MongoDatabase database = client.getDatabase("mydb");
MongoCollection<Document> collection = database.getCollection("users");

collection.find(new Document("_id", 1))
  .subscribe(new Observer<Document>() {
    public void onNext(Document doc) { /* handle document */ }
    public void onError(Throwable e) { /* handle error */ }
    public void onComplete() { /* done */ }
  });

Асинхронный API основан на Reactive Streams (Publisher/Subscriber), что позволяет обрабатывать high-concurrency сценарии без блокирования потоков.

Управление connection pool'ами. Драйвер автоматически управляет пулом соединений. Параметры (размер пула, timeout'ы, retry logic) можно конфигурировать:

MongoClientSettings settings = MongoClientSettings.builder()
  .applyConnectionString(new ConnectionString("mongodb://localhost:27017"))
  .applyToConnectionPoolSettings(builder ->
    builder.maxConnectionPoolSize(100)
           .minConnectionPoolSize(10)
           .maxConnectionIdleTime(30, TimeUnit.SECONDS)
  )
  .build();

MongoClient client = MongoClients.create(settings);

Сериализация и маппинг на классы

Документ в MongoDB это BSON (бинарный JSON). При получении из БД нужно десериализовать BSON в Java-объект.

Два подхода:

  1. Document класс (низкоуровневый).
Document doc = collection.find(...).first();
String name = doc.getString("name");
int age = doc.getInteger("age");

Это просто, но код становится строковым и fragile.

  1. Mapping на классы (рекомендуется).
@Data
class User {
  @BsonId
  private ObjectId id;
  private String name;
  private String email;
  private int age;
}

User user = collection.find(...).first(); // автоматический маппинг

Для автоматического маппинга используются:

  • MongoDB Codecs: встроенная система сериализации.
  • Spring Data MongoDB: аннотации, поддержка репозиториев, трансакций.
  • Другие библиотеки (Morphia, Jackson и т.д.).
// Spring Data MongoDB
@Document(collection = "users")
@Data
class User {
  @Id
  private ObjectId id;
  private String name;
  private String email;
}

@Repository
interface UserRepository extends MongoRepository<User, ObjectId> {
  Optional<User> findByEmail(String email);
}

// Использование
userRepository.findByEmail("alice@example.com");

Риски жёсткого связывания схемы с кодом. Если Java-класс строго соответствует структуре документа (все поля обязательны, нет flexibility), то добавление нового поля в документ потребует изменения класса и перекомпиляции. С гибкой схемой MongoDB это неудобно. Решение: использовать опциональные поля, версионирование, десериализацию через @JsonAnySetter:

@Data
class User {
  private String name;
  private String email;
  private int age;
  
  @JsonAnySetter
  private Map<String, Object> extras = new HashMap<>();
}

Ограничение размера документов

MongoDB имеет жёсткий лимит на размер документа: 16 MB (по умолчанию, можно увеличить до ~2 GB, но не рекомендуется).

Типичные проблемы:

  • Эмбеддинг слишком большого массива (например, 1 миллион комментариев в одном документе).
  • Отсутствие пределов на количество вложенных объектов.

Здравый смысл:

  • Если массив может расти бесконечно, лучше reference или отдельная коллекция.
  • Документ должен вмещать типовой бизнес-case (заказ с 100-1000 товарами — ok, 1 миллион — нет).
  • Мониторь размер документов через db.collection.stats().

Observability: метрики и аналитика

Для monitoring MongoDB операций на боку Java приложения:

Метрики:

  • Latency операций (ms).
  • Throughput (операций/сек).
  • Ошибки и retry'и.
  • Connection pool utilization.

Инструменты:

  • MongoDB Java Driver встроенно поддерживает CommandListener для logging операций.
  • Micrometer + Prometheus для сбора метрик.
  • Log-анализ медленных запросов (query logs).
MongoClientSettings settings = MongoClientSettings.builder()
  .addCommandListener(new CommandListener() {
    public void commandSucceeded(CommandSucceededEvent event) {
      long durationMs = event.getDurationNanos() / 1_000_000;
      if (durationMs > 100) {
        logger.warn("Slow query: {} ms", durationMs);
      }
    }
    // ...
  })
  .build();

На стороне БД:

  • Профилирование медленных операций (profiler).
  • explain() для анализа планов запросов.
  • Мониторинг через MongoDB Ops Manager или云服务.

Отказоустойчивость и поведение при failover

Приложение должно корректно обрабатывать failover'ы Replica Set'а.

Автоматическое переподключение. Java драйвер автоматически переподключается к новому primary'у при failover'е, но операции, которые были в полёте, могут отказать.

Обработка ошибок:

try {
  collection.insertOne(document);
} catch (MongoSocketException e) {
  // Сетевая ошибка, возможно failover
  // Retry...
} catch (MongoWriteConcernException e) {
  // Операция не была репликана на нужное количество узлов
} catch (MongoWriteException e) {
  // Других ошибки записи (duplicate key, schema validation и т.д.)
}

Идемпотентность. Для безопасного ретрая операция должна быть идемпотентной:

  • insert с явным _id — идемпотентен (повторный insert отказит с duplicate key).
  • update с _id фильтром — идемпотентен.
  • delete — идемпотентен.

Но операции типа $inc не идемпотентны (повторный вызов увеличит поле дважды). Для таких нужна осторожность при ретраях.

Write concern и read concern для consistency:

collection.withWriteConcern(WriteConcern.MAJORITY)
  .insertOne(document);

WriteConcern определяет, на сколько узлов должна быть репликана операция перед возвратом. MAJORITY означает, что операция должна быть на primary'е + большинстве secondary'ей. Это медленнее, но гарантирует, что данные не будут потеряны при failover'е.

Краткий чек-лист по MongoDB для собеседований

Ключевые темы, которые Senior должен уверенно обсуждать

  1. Документная модель vs реляционная.

    • Что даёт документная модель: встроенные вложения, близость к объектной модели приложения, гибкость схемы.
    • Когда лучше MongoDB: профили пользователей, логи, контент, события, прототипы.
    • Когда лучше RDBMS: сложные join'ы, жёсткие требования на консистентность, OLAP.
  2. Гибкая схема.

    • Плюсы: быстрый старт, эволюция без миграций, вложенные структуры.
    • Минусы: риск зоопарка схем, сложность валидации.
    • Важна дисциплина на уровне приложения или через JSON Schema валидаторы.
  3. Индексы.

    • Single-field, compound, partial, TTL, text, geo.
    • Prefix rule для compound индексов.
    • Hot shard и выбор индекса для избежания collection scan.
    • explain() для анализа планов запросов.
  4. Aggregation framework.

    • Pipeline как поток документов через этапы обработки.
    • Типичные stages: $match, $group, $sort, $limit, $lookup, $unwind.
    • Когда использовать aggregation vs find + postprocessing.
  5. Replica Set и HA.

    • Архитектура: primary, secondary, arbiter.
    • Репликация через oplog, асинхронна.
    • Read preference: primary, secondary, primary preferred, secondary preferred.
    • Failover: автоматический выбор нового primary.
  6. Шардирование.

    • Зачем: масштаб по объёму и нагрузке.
    • Архитектура: shards (Replica Set'ы), config servers, mongos.
    • Shard key и требования (энтропия, неизменяемость, распределённость).
    • Hot shard проблема, hash vs range distribution.
    • Ограничения: необходимость shard key в запросах, дорогие multi-document транзакции.
  7. Транзакции.

    • Single-document atomicity по умолчанию.
    • Multi-document транзакции (отличие от RDBMS).
    • Когда нужны, ограничения.
    • Альтернативы: переделать схему (embed), eventual consistency, saga pattern.
  8. Моделирование данных.

    • Query-driven design, а не прямой перенос из RDBMS.
    • Embed vs reference (когда что).
    • Денормализация и синхронизация.
    • Версионирование схемы.
  9. Java backend аспекты.

    • Драйвер, connection pools, timeouts.
    • Маппинг на классы (Document, Spring Data, Codecs).
    • Риски жёсткого связывания схемы.
    • Observability: метрики, логи, explain().
    • Отказоустойчивость: ретраи, идемпотентность, write concern.

Примеры формулировок для интервью

Q: Когда вы выбираете MongoDB вместо PostgreSQL? A: «MongoDB лучше для сценариев с гибкой или неопределённой схемой, когда данные часто читаются как целые агрегаты. Например, профили пользователей с различающейся структурой, логи событий, контент. Также хорошо для быстрого прототипирования. Но если требуется сложная нормализация, множество join'ов, жёсткие ACID гарантии, я выбираю PostgreSQL.»

Q: Как бы вы выбирали shard key для большой коллекции? A: «Сначала я анализирую access pattern'ы. Какие поля чаще всего в фильтрах? Как распределены значения? Выбираю поле с высокой cardinality и равномерным распределением. Например, если у нас миллионы пользователей и основной запрос find by userId, это хороший shard key. Рассматриваю hash shard key для гарантированного распределения, но учитываю, что диапазонные запросы будут требовать обращения ко всем shard'ам. Целью является, чтобы большинство запросов были directed (содержат shard key), не scatter-gather.»

Q: Как вы бы модизировали структуру для хранения заказа с товарами? A: «Я бы поместил товары прямо в документ заказа как массив, если количество товаров ограничено (обычно < 1000). Это даёт мне одну операцию для получения всего заказа и атомарность при обновлении. Если товарные данные часто обновляются (цена, описание), я бы рассмотрел embed только ID'ы товаров и количества, а полные данные товаров в отдельной коллекции. Это даёт гибкость при обновлении каталога товаров без влияния на заказы.»

Q: Какие трудности вы встречали с MongoDB в продакшене? A: «Основная сложность была в выборе правильного shard key. Первоначально выбрали ключ, который казался логичным, но привёл к hot shard. Переделали на другой ключ после анализа access pattern'ов. Вторая сложность — управление растущими размерами документов. Нужно было следить, чтобы документы не превышали лимит и рефакторить схему, выделяя отдельные коллекции. Третья — обработка failover'ов на клиенте: нужно было добавить ретраи и правильно обрабатывать ошибки.»

Знание MongoDB в контексте карьеры

Для Senior разработчика знание MongoDB и NoSQL в целом демонстрирует:

  • Понимание trade-off'ов в архитектуре БД.
  • Способность выбрать правильный инструмент для задачи.
  • Опыт работы с масштабированием и распределёнными системами.
  • Гибкость в подходе к моделированию данных.

Даже если в текущем проекте используется только PostgreSQL, знание MongoDB (и других NoSQL) показывает, что разработчик думает шире, может объяснить trade-off'ы и адаптировать подход в зависимости от требований.

На собеседованиях в крупных компаниях (FAANG и подобные) это часто спрашивается как часть System Design раундов. Неспособность обсудить MongoDB и её альтернативы серьёзно ограничивает оценку кандидата на позицию Senior.

Redis

Роль Redis в современной backend-архитектуре

Почему Redis стал дефолтным инструментом для кеширования и быстрых key–value операций

  • Redis — сверхбыстрое in-memory key–value хранилище, оптимизированное для микросекундных операций.
  • Частота использования: часто применяется для кеширования, rate limiting, хранения сессий и точечных инфраструктурных задач.
  • Redis даёт минимальную задержку доступа по ключу (обычно < 1мс) и сильно разгружает основную БД, что критично для реального продакшена.

Отличие Redis от классической RDBMS

In-memory природа и модель key–value

  • Redis держит все данные в оперативной памяти (RAM), что даёт быстрые read/write и predictable latency.
  • Нет медленных дисковых операций на основном пути обработки, но возможна потеря данных при сбоях (если не настроена персистентность).
  • Модель key–value означает, что каждая запись — это уникальный ключ и ассоциированное с ним значение определённого типа.

Другие структуры данных и их влияние на дизайн сервисов

  • В отличие от RDBMS, каждый ключ в Redis привязан к типу значения: string, hash, list, set, zset, stream и др.
  • Это позволяет строить решения, основанные не только на простом чтении/записи значения, но и, например, на подсчётах, агрегировании, очередях, pub/sub-механизмах.
  • Дизайн сервисов часто меняется: под задачи горизонтального масштабирования, единые очереди, разделение доступа между микросервисами.

Другой профиль использования (кеш, временные данные, инфраструктурные задачи)

  • Redis — не хранилище для бизнес-данных «навсегда», а инструмент для:

    • кешей (fast lookup),
    • временных токенов,
    • инфраструктурных паттернов (rate limiting, coordination, pub/sub, locks).
  • Типичная формулировка на собеседовании:
    «Redis — инструмент для микросервисной инфраструктуры. В нём хранят только те данные, которые можно быстро пересоздать или временно потерять».

Вопросы про Redis на собеседованиях

  • Как используете Redis в своих сервисах?
  • Что храните в Redis, а что нет?
  • Были ли проблемы с Redis в продакшене и как решали?
  • Какую схему персистентности и репликации применяете?
  • Как управляете TTL, eviction и hot keys?
  • Какие структуры данных используете и почему?

Структуры данных Redis

Общий подход

  • В Redis каждый ключ строго ассоциирован с одним типом значения (string, hash, list, set, zset, stream и т.д.).
  • Использование ключа с несовместимой командой приводит к ошибке типа.
  • Такой строгий подход позволяет использовать Redis как универсальный in-memory toolbox: выбор структуры под нужду задачи.

String

  • Базовый тип: значение — строка (до 512 МБ).

  • Подходит для хранения чисел, строк, сериализованных структур (JSON, протобуф), бинарных блобов.

  • Операции: set/get, инкременты (incr/decr), атомарные update, bit operations.

  • Примеры:

    • кеш одиночных значений;
    • флаги (например, feature toggle);
    • простой счётчик просмотров (GET/INCR/DECR);
    • сериализованный JSON-объект (но избегать гигантских блоков).

Hash

  • Ассоциативный массив (field -> value) внутри одного ключа.

  • Хорош для объектов с множеством свойств: изменение отдельных полей без переписывания всего объекта.

  • Примеры:

    • профиль пользователя (user:{id} с полями name, email, state);
    • агрегированные состояния (bulk update);
    • настройки, конфиги.
  • Операции: HGET, HSET, HGETALL, HMGET.

List

  • Последовательность строк с доступом по индексу, поддерживает push/pop с двух сторон (LPUSH/RPUSH/LPOP/RPOP).

  • Позволяет реализовать простейшую очередь, стек, временный буфер событий.

  • Примеры:

    • очередь задач на обработку (worker pool);
    • хранение последних N событий;
    • распределённый журнал.
  • Операции: LINDEX, LRANGE, BLPOP/BRPOP (блокирующие pop).

Set

  • Неупорядоченное множество уникальных значений.

  • Позволяет быстро проверять наличие значения, делать пересечения, объединения, разности (SINTER, SUNION, SDIFF).

  • Примеры:

    • множества ID пользователей;
    • списки ролей/прав;
    • уникальные токены/one-time links.
  • Важный паттерн: membership-check (SISMEMBER) за микросекунды.

Sorted Set (ZSet)

  • Множество уникальных значений с float-полем score (пример: рейтинг).

  • В списке хранятся пары «значение + score», есть быстрый выбор диапазона по score (ZRANGE/BYSCORE), определение ранга, удаление.

  • Примеры:

    • рейтинги пользователей (leaderboard);
    • топ-10 статей по просмотрам;
    • приоритетные очереди задач;
    • time-series по score = timestamp.
  • Часто встречается в продакшене для analytics/dashboards.

Streams

  • Структура записи событий: лог с автоинкрементным ID и набором пар «ключ: значение».

  • Поддержка consumer groups для распределённой обработки.

  • Примеры:

    • event log;
    • реализация потоковых очередей;
    • стриминг событий между сервисами.
  • Операции: XADD (добавить), XREAD (читать), XACK (подтвердить), XGROUP.

Pub/Sub

  • Каналы для публикации и подписки сообщений (fire-and-forget, без хранения истории).

  • Подходит для простых broadcast-уведомлений.

  • Примеры:

    • notification-фреймворк;
    • простейшее реалтайм взаимодействие.
  • Важное ограничение: отсутствие гарантий доставки и истории.

Таблица «задача → структура данных Redis»

Задача Структура данных Причина/пример
Кешировать одиночное value string get/set по ключу
Хранить профиль пользователя hash user:{id} c полями
Чекнуть уникальность set SISMEMBER, SADD
Сделать очередь, буфер list, stream очередь тасков; лог событий
Поддерживать рейтинг zset leaderboard: score → userId
Вести event log stream запись всех изменений
Обеспечить broadcast pub/sub моментальные уведомления
Счётчик, rate limiting string/incr INCR, EXPIRE операции

Роли Redis в архитектуре

Redis как кеш

Cache-aside (lazy loading)

  • Алгоритм: сервис сперва запрашивает Redis; при miss — обращается к основной БД, затем пишет в Redis.
  • Преимущества: простота, нет consistency-проблем при write, гибкость TTL.
  • Минусы: cache miss приводит к задержке (DB hit), устаревание данных (если есть директ-апдейты в БД).

Read-through, write-through, write-behind

  • Read-through: приложение всегда читает через кеш; кеш сам тянет с источника.
  • Write-through: запись идёт через кеш → немедленно в источник.
  • Write-behind: запись сначала в кеш, затем асинхронно в источник (может потерять изменения при сбое кеша).
  • Основной trade-off: latency vs consistency.

Кеширование отдельных частей системы

  • Справочники (reference data, частые запросы) — минимизация нагрузки на SQL/NoSQL.
  • Результаты дорогостоящих агрегаций — снижение среднего времени ответа.
  • Промежуточные срезы (например, агрегаты с глубокой иерархией данных).

Redis для сессий

  • Хранение сессионного состояния (user id, авторизации, промежуточные данные).
  • Сессии доступны всем инстансам сервиса, масштабируются горизонтально.
  • TTL устанавливается как время жизни сессии — при неактивности сессия истекает.
  • На практике: минимум чувствительных данных (лучше хранить токены или ссылки на состояние).

Redis для rate limiting

  • Ключи обычно форматируются как rate:{userId}:{endpoint}/rate:{ip} для расчёта лимитов per-user/IP/endpoint.
  • Инкременты (INCR) и EXPIRE задают окно лимитирования.
  • Реализация: sliding window (скользящее окно), fixed window, token bucket.
  • Lua-скрипты позволяют реализовать сложные атомарные схемы (например, смесь sliding window и bucket).

Redis как очередь

  • Для очередей используют lists (push/pop) или streams (read by group/offset).

  • lists + blocking pop — для простых задач без необходимости acknowledgements, например, очереди задач между сервисами.

  • streams + consumer groups — для разделённой, гарантированной доставки или сложного баланса между воркерами.

  • Ограничения:

    • lists — простота, но нет поддержки ack и replay.
    • streams — надёжность, подтверждения, возможна переобработка задач.
  • Лучше Kafka/RabbitMQ — когда требуются долгие истории, гарантии доставки, высокая нагрузка.

Redis для distributed locks

  • Использование ключа как лок с TTL (обычно через SET NX EX).

  • Применение: одиночные задачи (cron из нескольких инстансов), операции, требующие уникальности («операция строго один раз»).

  • Проблемы:

    • clock drift между клиентом и сервером;
    • истечение TTL или сбой лидера → race conditions.
  • Для устойчивых распределённых локов рекомендуется использовать Redlock или аналогичные схемы — набор инстансов Redis и кворум.

Redis как инфраструктурный клей

  • Хранение одноразовых токенов, OTP, кодов подтверждения (с коротким TTL).
  • Координация между сервисами: флаги, barrier, метки для single-flight операций.
  • Быстрое хранение метаданных, распределённых счётчиков.

Персистентность и надёжность Redis

In-memory природа

  • Redis хранит все данные в RAM; скорость — основное преимущество для кеша.
  • Персистентность нужна для восстановления состояния после сбоя или рестарта (если Redis используется для другой роли, а не только кеша).
  • Возможна потеря последних несохранённых изменений при emergency shutdown.

RDB (snapshot)

  • Периодическое создание снапшота состояния Redis на диск (rdb-файл).
  • Достоинства: быстрое восстановление, небольшой overhead.
  • Недостатки: потеря всех изменений после последнего snapshot.
  • Подходит для сценариев, где данные — не критичные (кеш, временные объекты).

AOF (Append Only File)

  • Все операции на запись логируются в отдельный файл (AOF).
  • Позволяет восстановить состояние Redis до самой последней операции (теоретически — с минимальной потерей данных).
  • Обычно используется в режиме fsync на каждую секунду (trade-off между durability и latency).
  • Недостатки: рост размера файла, замедление записей при высокой нагрузке, необходимость регулярной compact/rewrite.

Комбинация RDB + AOF

  • В продакшене часто используется оба механизма: частые snapshot-и и AOF для изменений между снапшотами.
  • Баланс между производительностью и степенью потери данных (durability).
  • Чем чаще fsync и snapshot, тем меньше риск потери данных, но выше latency и нагрузка на storage.

Репликация

  • Redis master/replica схема (один мастер, несколько реплик).
  • Реплики обслуживают чтения, снижают нагрузку на мастер, могут взять его роль при failover.
  • Проблема: задержка репликации (replication lag), eventual consistency.
  • Для критичных данных читать с реплик не рекомендуется без дополнительного контроля consistency.

Sentinel

  • Мониторит мастер-нод, автоматически переключает реплики в master при недоступности текущего лидера.
  • Поддерживает прозрачный failover, изменяет DNS/endpoint для клиентов.
  • Не решает проблемы split-brain, ограничен по возможностям по сравнению с современными кластерными решениями.

Redis Cluster

  • Горизонтальное шардирование данных по slot-ам (16384 слота, каждый выделен определённому узлу).
  • Cluster объединяет несколько нод, каждая хранит свой сегмент данных; поддержка реплик внутри кластера.
  • Клиент должен поддерживать маршрутизацию команд по slot-ам (или использовать Redis-proxy).
  • Ограничение: операции на несколько ключей возможны только если все ключи в одном слоте.

Как говорить на собеседовании

  • Single instance: простая топология для dev/test, не для продакшена.
  • Master+replica (standalone с репликами): баланс читающей нагрузки, максимально быстрый recovery, но single point of failure.
  • Sentinel: автоматизация failover, минимальное ручное вмешательство.
  • Cluster: production-grade шардинг для больших объемов данных, поддержка горизонтального масштабирования.

Политики eviction, TTL и hot keys

TTL

  • Любому ключу можно назначить TTL (время жизни). По истечении TTL ключ удаляется автоматически.

  • Примеры: кеши (cache:{id} с TTL 5 минут), сессии (session:{userId} с TTL 1 час), одноразовые токены.

  • Удаление работает по двум механизмам:

    • Lazy: при обращении к просроченному ключу Redis его удаляет.
    • Active: периодическое сканирование небольшого подмножества ключей с TTL.

Политики eviction

  • Когда Redis доходит до предела по памяти, срабатывает eviction — удаление старых или незначимых записей.

  • Основные политики:

    • noeviction — тайм-аут/ошибка при попытке записи сверх лимита.
    • allkeys-lru — удаление наименее используемых ключей (Least Recently Used).
    • volatile-lru — только среди ключей с TTL.
    • allkeys-lfu — наименее часто используемые (Least Frequently Used).
    • random — случайный выбор among all/volatile.
  • Нужно подбирать политику по сценарию: для кеша — lru/lfu, для хранения важных метаданных — noeviction.

Hot keys

  • Горячий ключ — ключ, к которому слишком часто обращаются (например, популярная запись).

  • Проблема: oversaturation Redis, неравномерное использование CPU/network.

  • Последствия: рост latency, деградация производительности, сеть между приложением и Redis становится bottleneck.

  • Методы защиты:

    • шардировать ключ (добавлять random-prefix: hot:{shardN}:{key});
    • использовать локальный кеш (guava/caffeine на приложении);
    • читать с разных реплик (если используется кластер/репликация).

Cache stampede / thundering herd

  • Массовый cache miss по одному и тому же ключу: большое количество клиентов одновременно обращаются к основной БД/API.

  • Проблема: лавинообразный рост нагрузки на backend.

  • Стратегии борьбы:

    • randomized TTL (размазывание времени жизни на несколько секунд/минут);
    • single-flight (mutex вокруг загрузки ключа, only one процесс обновляет данные, остальные ждут);
    • background refresh/прогрев кеша заранее (warm-up, proactive update).

Типичные паттерны использования Redis в Java-сервисах

Cache-aside поверх RDBMS/NoSQL

  • Путь вызова:
    1. сервис → Redis (GET key);
    2. если miss → БД, потом (SET key, TTL), вернуть данные;
    3. при write → в БД, invalidate ключ в Redis.
  • Ключевые паттерны: entity:{id}, list:{filter}:{page}.
  • Инвалидация кеша: по TTL или при изменении данных (event-driven, pub/sub, прямое удаление).

Кеширование конфигов и справочников

  • Прогрев кеша при запуске/инициализации сервисов.
  • Периодическое обновление либо реакция на event ("invalidate by event").
  • Чаще используют volatile-lru eviction и достаточно длинные TTL.

Rate limiting

  • Схемы: fixed window, sliding window, token bucket.
  • В Redis — атомарные операции INCR, Lua-скрипты для сложных вариантов (например, сброс окна).
  • Корректность обеспечивается за счёт атомарности команд, TTL на ключах window.

Очереди задач и streams

  • Сценарии: распределённая обработка задач множеством воркеров (через lists или streams).

  • Особенности:

    • у lists нет подтверждения обработки/повторов — возможна потеря сообщения;
    • у streams есть поддержка ack, повторная доставка, consumer groups, но более сложное API.

Distributed locks

  • Одиночные задачи во всём кластере (одновременный запуск, cron-task).
  • SET key value NX PX <TTL> — стандартный паттерн lock-схемы.
  • Требуются гарантии удаления lock даже при сбоях.
  • Более безопасно использовать Redlock (несколько инстансов Redis), кворум по majority.

Сессии и одноразовые токены

  • Хранение access/refresh токенов, одноразовых OTP, magic-link-идентификаторов.
  • Обязательно задавать TTL.
  • Безопасная работа: не хранить открытые секретные данные в Redis.

Типичные анти-паттерны использования Redis в Java-сервисах

Redis как primary database

  • Хранение ONLY-критичных бизнес-данных — риск потери при сбоях/перезапусках.
  • Нет ACID, нет инструментов для отчётов, анализа, сложных миграций.

Гигантские значения

  • Хранение огромных blob-ов/JSON (>1 МБ) — долгие операции, блокировка event loop Redis.
  • Прямое влияние на latency всех клиентов.

Отсутствие TTL и контроля памяти

  • Бесконтрольный рост памяти: старые, неиспользуемые ключи занимают RAM.
  • Неожиданный eviction при смене политики, удаление важных данных.

Жёсткая бизнес-логика в структуре ключей

  • Перенос сложной бизнес-логики в форматирование ключей — больно при рефакторингах и миграциях.
  • Трудности масштабирования и поддержки.

Игнорирование отказоустойчивости

  • Использование одиночного Redis без Sentinel/Cluster.
  • Приложение падает при недоступности Redis, отсутствие graceful degradation.

Злоупотребление Lua-скриптами

  • Логика приложения засунута внутрь Redis (Lua).
  • Сложности с тестированием, дебагом, миграциями.

Redis и Java backend: практические аспекты

Клиенты и connection pooling

  • Для Redis используют подключения поверх TCP (standalone, cluster-aware).
  • Обязателен пул соединений: уменьшает overhead, предотвращает блокировки.
  • Размер пула зависит от нагрузки и available file descriptors.
  • В случае блокирующих операций (BRPOP, Lua-скрипты) — connection может быть занят, важно правильно настраивать timeout/checks.

Сериализация

  • Формат хранения: строки, JSON (Jackson/Gson), бинарь (Kryo/protobuf/Avro).
  • Выбор влияет на скорость парсинга, backward compatibility и размер значения.
  • Versioning схемы: при изменении структуры важно поддерживать совместимость.

Таймауты и отказоустойчивость

  • Таймауты на операции (read/write) и соединения.

  • Стратегии поведения при ошибках:

    • fail-open (приложение продолжает работать без Redis);
    • fail-closed (ошибка, сервис недоступен).
  • Различные подходы выборки для разных сценариев (статистика, кеш конфигов, критичные данные).

Observability

  • Ключевые метрики: использование памяти, latency, число соединений, hits/misses, eviction count.
  • Логирование Redis-запросов (уровень debug, без утечки секрета).
  • Monitoring: интеграция с Prometheus, Grafana, custom health-checks.

Краткий чек-лист по Redis для собеседований

  • Основные структуры данных Redis: string, hash, list, set, sorted set, stream, pub/sub.

  • Какие задачи решаются через Redis: кеш, сессии, rate limiting, очереди, distributed locks, coordination.

  • Механизмы персистентности: RDB (snapshot), AOF (лог операций), их trade-off’ы.

  • Репликация, Sentinel, Redis Cluster: схемы отказоустойчивости, где применяются.

  • TTL и eviction policy: влияние на поведение, предотвращение переполнения памяти.

  • Hot keys, cache stampede: что делать при высокой нагрузке на отдельные ключи, как защититься.

  • Типичные паттерны для Java backend: cache-aside cache, кеширование конфигов, rate limiter, очереди на streams, distributed locks.

  • Типичные анти-паттерны: хранить бизнес-данные, гигантские значения, отсутствие TTL, жёсткая бизнес-логика в ключах, игнорировать отказоустойчивость, класть бизнес-логику в Lua.

  • Экспертиза в практических вопросах: connection pool, сериализация, observability, время жизни ключей, обработка ошибок при недоступности Redis.

  • Примеры формулировок:

    • «Для rate limiting мы используем атомарные INCR+EXPIRE, ключи вида rate:{userId}:{endpoint}, Lua для сложных сценариев».
    • «Для distributed lock стандарт — SET key value NX PX, но при критичных задачах мы используем Redlock на нескольких инстансах Redis».
    • «Для кеша сложных агрегатов применяем cache-aside, TTL выставляем с небольшим рандомом, чтобы не было stampede».
    • «Для сессии используем hash, TTL, проверяем репликацию через Sentinel».

Cassandra и ScyllaDB

Cassandra / ScyllaDB: wide-column и линейное масштабирование

Роль Cassandra / ScyllaDB в архитектуре систем

Apache Cassandra и её высокопроизводительная реализация ScyllaDB (на C++ с архитектурой shard-per-core) занимают нишу распределённых NoSQL баз данных класса Wide-Column Store. Это системы, изначально спроектированные для работы в условиях, где реляционные СУБД (RDBMS) перестают справляться либо по объёму данных (сотни терабайт и петабайты), либо по пропускной способности на запись (write throughput).

Классификация и архитектурный подход

С точки зрения теоремы CAP, Cassandra/ScyllaDB относятся к AP-системам (Availability + Partition Tolerance). В архитектуре заложен приоритет доступности записи и чтения даже в случае потери связности сети или выхода из строя части узлов.

Ключевая особенность — отсутствие мастер-узла (Masterless / Peer-to-Peer architecture). Все узлы в кластере равноправны. Это устраняет единую точку отказа (SPOF) и узкое место для записи. Клиент может подключиться к любой ноде (координатору), которая проксирует запрос на нужные узлы, владеющие данными.

Задачи, которые решают Cassandra/ScyllaDB

В современном бэкенде эти базы данных выбирают для трех основных классов задач:

  1. Линейное масштабирование. Увеличение пропускной способности (RPS) и объёма хранилища достигается простым добавлением новых узлов. Зависимость производительности от количества узлов близка к линейной.
  2. Write-heavy нагрузки. Благодаря структуре хранения LSM-Tree (Log-Structured Merge-Tree), запись происходит предельно быстро (append-only в память и лог), без блокировок и перестроения сложных индексов, характерных для B-Tree в RDBMS.
  3. Геораспределённость (Multi-DC). «Из коробки» поддерживается репликация данных между разнесенными дата-центрами. Это позволяет реализовать сценарии Active-Active, когда запись идет в США и Европу одновременно, а данные синхронизируются асинхронно.

Отличия от RDBMS и других NoSQL

Главное ментальное препятствие при переходе с PostgreSQL/MySQL на Cassandra — кардинально иной подход к моделированию.

  • RDBMS: Данные нормализуются, чтобы избежать дублирования. Запросы формируются гибко с помощью JOIN. Приоритет — ACID транзакции.
  • Key-Value (Redis): Простая модель, быстрый доступ по ключу, но слабые возможности выборки диапазонов или сложной структуры.
  • Cassandra/ScyllaDB (Wide-Column):
    • Join'ы отсутствуют. Слияние данных на лету в распределенной системе слишком дорого.
    • Query-driven modelling. Таблицы проектируются строго под конкретные SELECT-запросы, а не под сущности предметной области.
    • Eventual Consistency. Вместо строгой ACID-транзакционности на весь кластер предлагается настраиваемая (tunable) консистентность.

Почему это важно для Senior-разработчика

Даже если в текущем проекте используется только PostgreSQL, понимание Cassandra необходимо для system design интервью и архитектурного видения:

  • Понимание компромиссов (trade-offs) между задержкой (latency) и согласованностью (consistency).
  • Умение проектировать системы, устойчивые к отказу целых регионов (Region Failover).
  • Знание паттернов работы с «горячими» данными и бесконечными стримами событий (IoT, clickstream).

Модель данных: ключи, partition key, clustering columns

В основе эффективности Cassandra лежит специфическая иерархия данных. Понимание того, как данные раскладываются по дискам физически, критично для написания эффективных запросов.

Логическая таблица vs Физическое распределение

Логически данные выглядят как привычная таблица с колонками и строками. Однако физически это распределенная хэш-таблица.

  • Весь диапазон возможных хэш-значений (Token Ring) поделен между узлами кластера.
  • Каждая строка данных попадает на конкретный узел (и его реплики) на основе хэша от Partition Key.

Partition Key (Ключ партицирования)

Это первая часть Primary Key. Она определяет, на каком узле будут лежать данные.

  • Функция: Балансировка нагрузки. Данные должны распределяться по кластеру равномерно.
  • Принцип выбора: Высокая кардинальность (high cardinality).
    • Плохой ключ: status (всего 5 вариантов: 'new', 'processing', etc.). Это приведет к тому, что весь кластер будет простаивать, а 5 узлов будут перегружены.
    • Хороший ключ: user_id, device_id, sensor_id, order_uuid.
  • Ограничение: Все запросы должны (в идеале) указывать Partition Key. Иначе координатору придется опрашивать все узлы кластера (Scatter-Gather), что убивает производительность.

Clustering Columns (Кластерные колонки)

Это вторая часть Primary Key. Она определяет, в каком порядке данные хранятся на диске внутри одной партиции.

  • Функция: Сортировка и группировка. Позволяет эффективно выполнять диапазонные запросы (Range Scans) внутри одной партиции.
  • Физический смысл: Данные внутри партиции лежат одним непрерывным куском на диске (в рамках SSTable), отсортированным по Clustering Key. Чтение диапазона — это последовательное чтение (sequential read), что очень быстро.
  • Пример:
    PRIMARY KEY ((user_id), timestamp)
    
    Здесь user_id — Partition Key (группирует данные юзера вместе), timestamp — Clustering Key (сортирует действия юзера по времени).

Primary Key: Структура

В Cassandra Primary Key — это не просто уникальный идентификатор, а инструкция по размещению:

  1. Simple Primary Key: PRIMARY KEY (id) — здесь id является Partition Key.
  2. Composite Partition Key: PRIMARY KEY ((region, year), id) — данные группируются по паре регион+год.
  3. Partition + Clustering: PRIMARY KEY ((chat_room_id), message_time, message_id) — данные лежат на узле, отвечающем за chat_room_id, и отсортированы сначала по времени, потом по ID.

Широкие строки (Wide Rows)

Термин "Wide Column Store" происходит от возможности иметь партиции, содержащие сотни тысяч или миллионы "строк" (логических строк), которые физически представляют собой одну широкую структуру данных.

  • Плюсы: Локальность данных. Получить все сообщения чата за последний час можно одним обращением к диску (seek) и последовательным чтением.
  • Риски (Hot Partition / Large Partition):
    • Если в одну партицию писать бесконечно (например, логи системы в партицию «2023-10-27»), она вырастет до гигабайтов.
    • Перенос такой партиции между узлами, её компактификация и чтение будут вызывать задержки и GC pause на JVM (в случае Cassandra).
    • Рекомендация: держать размер партиции в пределах 100 МБ (soft limit) - 1 ГБ (hard limit). Если партиция растет, нужно вводить "бакетирование" (добавлять в partition key временной интервал или суррогатный ID).

Ментальная модель: Вложенная Map

Схему Cassandra можно представить как: Map<PartitionKey, SortedMap<ClusteringKey, Columns>> Если разработчик понимает эту структуру, он понимает, почему нельзя сделать ORDER BY по полю, не входящему в Clustering Key, и почему нельзя фильтровать по Clustering Key без указания Partition Key.


Tunable consistency и репликация по дата-центрам

Cassandra не навязывает единую модель консистентности. Она позволяет разработчику выбирать баланс для каждого конкретного запроса (per-query consistency).

Репликация данных

Данные в Cassandra всегда реплицируются для отказоустойчивости.

  • Replication Factor (RF): Настраивается на уровне Keyspace (аналог схемы/базы данных). Типичное значение RF=3 (данные хранятся на 3 разных узлах).
  • Стратегии репликации:
    • SimpleStrategy: для разработки или одного DC.
    • NetworkTopologyStrategy: для продакшена. Позволяет указать, сколько реплик хранить в каждом дата-центре (например, "DC-US: 3, DC-EU: 3"). Cassandra старается разнести реплики по разным стойкам (racks), чтобы отказ питания стойки не привел к потере данных.

Уровни консистентности (Consistency Levels - CL)

CL определяет, подтверждение от скольких реплик должен дождаться координатор, чтобы считать операцию успешной.

Основные уровни:

  • ANY: (Только для записи) Запись считается успешной, если она сохранена хотя бы где-то (даже в hint-логе, если все целевые реплики лежат). Риск потери данных максимален, доступность абсолютная.
  • ONE / TWO / THREE: Нужно подтверждение от 1, 2 или 3 любых реплик.
    • ONE: Минимальная задержка. Высокий риск чтения устаревших данных (stale reads). Подходит для логов, аналитики, счетчиков лайков.
  • QUORUM: (RF / 2) + 1. Большинство реплик.
    • Это «золотой стандарт» для баланса. При RF=3 кворум равен 2.
    • Обеспечивает строгую консистентность (Strong Consistency), если соблюдается формула: R + W > N (Nodes/RF). Например, читаем с QUORUM (2) и пишем с QUORUM (2) при RF=3 -> 2+2 > 3. Мы гарантированно прочитаем последнюю запись.
  • ALL: Нужно подтверждение от всех реплик.
    • Дает самую высокую гарантию чтения, но убивает доступность (Availability). Если упала одна нода из 3, запись/чтение встанет. Используется крайне редко.

Стратегии по дата-центрам (Multi-DC)

В геораспределенных системах важно использовать LOCAL уровни консистентности, чтобы избежать задержек на трансграничную сеть.

  • LOCAL_QUORUM: Кворум собирается только из реплик в том же дата-центре, куда пришел запрос.
    • Сценарий: Пользователь в Европе пишет данные. Запрос идет на европейский узел. Данные реплицируются на 3 узла в Европе синхронно (ждем 2 подтверждения) и асинхронно отправляются в США. Если кабель через океан перерезан, европейский кластер продолжает работать.
  • EACH_QUORUM: Требует кворума в каждом дата-центре. Очень медленно, используется только для критически важных конфигураций.

Аргументация на собеседовании

Типичный вопрос: "Как настроить Cassandra, чтобы не потерять данные, но писать быстро?" Ответ уровня Senior: "Использовать NetworkTopologyStrategy с RF=3 в каждом DC. Писать и читать с LOCAL_QUORUM. Это дает строгую консистентность в рамках локального региона и низкую задержку (нет round-trip через океан). Eventual consistency между регионами допустима для большинства бизнес-задач. Если нужна глобальная консистентность (например, защита от овердрафта баланса), Cassandra может не подойти, либо придется использовать LWT (Lightweight Transactions), но они дорогие".


Денормализация и проектирование таблиц от read-паттернов

В мире RDBMS мы сначала строим ER-диаграмму (Сущности и Связи), нормализуем их до 3-й нормальной формы, а потом пишем любые SQL-запросы. В Cassandra этот подход не работает.

Принцип: Запросы первичны, схема вторична

Процесс проектирования (Query-Driven Design):

  1. Составить список всех запросов, которые приложение будет делать (например, "Получить данные пользователя по ID", "Получить пользователей по Email", "Получить последние 10 заказов пользователя").
  2. Для каждого уникального запроса (или группы похожих) создать отдельную таблицу.

Денормализация и дублирование данных

В Cassandra место на диске дешевое, а CPU и I/O дороги. Поэтому мы осознанно дублируем данные. Пример: У нас есть пользователи. Нам нужно искать их по id и по email. В RDBMS: одна таблица users + индекс по email. В Cassandra: две таблицы.

  1. users_by_id: Partition Key = id.
  2. users_by_email: Partition Key = email.

Обе таблицы содержат поля name, phone, avatar. Да, данные дублируются. Но это позволяет получить данные пользователя по email за одно чтение (O(1)), без сканирования или сложных распределенных индексов.

Materialized Views (Встроенные vs Ручные)

Cassandra поддерживает Materialized Views (автоматическое поддержание вторичной таблицы), но в продакшене (особенно на старых версиях Cassandra) они часто работали нестабильно и вызывали проблемы с производительностью при записи. Best Practice: Выполнять денормализацию на уровне приложения (Application-side joins / Dual writes).

  • Код приложения отправляет запись в users_by_id и users_by_email (параллельно или через batch).
  • Используется паттерн Saga или асинхронная очередь (Kafka) для обеспечения eventual consistency между этими таблицами.

Ограничения модели запросов

  • Нет JOIN: Если нужно соединить "Заказы" и "Товары", приложение сначала вычитывает ID товаров из "Заказов", а потом идет за "Товарами". Либо данные о товаре сразу вкладываются в таблицу заказа (денормализация).
  • Слабая фильтрация: WHERE можно использовать только по колонкам Primary Key (с ограничениями).
  • ALLOW FILTERING: Команда, разрешающая фильтрацию по неиндексированным полям.
    • На собеседовании: "ALLOW FILTERING — это команда, которая заставляет базу сканировать все данные на узле или даже во всем кластере. В продакшене использовать запрещено, только для отладки".

Write-heavy сценарии, time-series и логирование

Cassandra/ScyllaDB часто называют "Write-Optimized" базами. Это кажется контринтуитивным (запись сложнее чтения), но архитектура LSM-Tree делает запись экстремально дешевой.

Механизм записи (LSM-Tree вкратце)

  1. Запись попадает в Commit Log на диске (последовательная запись, append-only) для надежности.
  2. Запись добавляется в MemTable (отсортированная структура в оперативной памяти).
  3. Когда MemTable заполняется, она сбрасывается на диск в виде неизменяемого файла SSTable (Sorted String Table).

Почему это быстро: Нет random I/O. Головка диска не прыгает, чтобы обновить страницу B-дерева. Сброс на диск идет большим последовательным куском.

Time-Series паттерны

Это идеальный кейс для Cassandra: метрики с датчиков, логи приложений, финансовые тики, история перемещений курьеров.

Схема таблицы для Time-Series:

CREATE TABLE sensor_data (
    sensor_id uuid,
    day text,
    event_time timestamp,
    value double,
    PRIMARY KEY ((sensor_id, day), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

Разбор схемы:

  • ((sensor_id, day)) — Составной Partition Key.
    • Почему не просто sensor_id? Если датчик работает годами, партиция станет огромной. Мы разбиваем её на бакеты (buckets) по дням (или неделям).
  • event_time — Clustering Key. Данные внутри дня лежат упорядоченно.
  • ORDER BY (event_time DESC) — Оптимизация для типичного запроса "дай мне последние 10 показаний".

Преимущества для Write-Heavy

  • Пиковые нагрузки сглаживаются MemTable.
  • Отлично держит постоянный поток записи (например, 100к событий в секунду).
  • Линейное масштабирование: уперлись в диск/CPU — добавили 2 ноды, поток перераспределился.

Compaction, TTL, tombstones и их влияние на производительность

Самая сложная часть эксплуатации Cassandra — понимание того, как она чистит данные.

Compaction (Компактификация)

Так как SSTables неизменяемы, обновления и удаления не меняют старые файлы. На диске скапливается много версий одних и тех же данных (или фрагментов одной партиции). Compaction — фоновый процесс, который берет несколько SSTables, сливает их в одну новую, выбрасывая устаревшие данные и склеивая фрагменты партиций.

  • Влияние: Активно потребляет CPU и I/O. Если compaction не успевает за записью, чтение замедляется (нужно читать из многих файлов).
  • Стратегии:
    • STCS (Size Tiered): По умолчанию. Хороша для write-heavy.
    • LCS (Leveled): Хороша для read-heavy, жестче контролирует место на диске.
    • TWCS (Time Window): Идеальна для Time-Series с TTL. Данные стареют и удаляются целыми файлами.

Tombstones (Могильные камни)

В Cassandra нет физического удаления (DELETE) в момент запроса. Вместо этого пишется специальный маркер — Tombstone, который говорит: "данные по этому ключу удалены".

  • Реальное удаление происходит только во время Compaction, когда Tombstone встретится с данными.
  • Проблема: Если приложение делает много удалений (или обновлений c null), чтение начинает "спотыкаться" о тысячи томбстоунов, фильтруя их. Это вызывает Read Timeout и предупреждения в логах.
  • Антипаттерн: Использовать Cassandra как очередь (Queue), где постоянно пишут и сразу удаляют. Это генерирует горы томбстоунов.

TTL (Time To Live)

Cassandra позволяет задать время жизни для каждой колонки или строки: INSERT ... USING TTL 86400.

  • По истечении времени данные не исчезают магически, они превращаются в Tombstones.
  • При интенсивном использовании TTL нужно использовать стратегию компактификации TWCS, чтобы старые данные удалялись эффективно (drop файла), без генерации миллиардов томбстоунов.

Где Cassandra / ScyllaDB оправдана, а где это оверкилл

На System Design интервью важно не просто предложить Cassandra, но и знать границы её применимости.

Сильные кейсы (Green Zone)

  1. IoT и Телеметрия: Миллиарды мелких записей, поиск по ID устройства + времени.
  2. История заказов / Платежей / Чатов: Данные пишутся один раз (immutable) или редко меняются, всегда читаются по ключу пользователя.
  3. Системы рекомендаций: Хранение векторов признаков пользователей (User Profile Store), где важна скорость чтения и записи.
  4. Fraud Detection: Быстрый сбор событий для анализа паттернов на лету.

Слабые кейсы (Red Zone)

  1. Маленькие объемы данных: Если вся база влезает в оперативку одного сервера (100-500 ГБ), PostgreSQL/MySQL справятся лучше и проще.
  2. Сложная аналитика (OLAP): Произвольные запросы вида "посчитать средний чек пользователей из Лондона, купивших носки". Для этого нужен ClickHouse или Spark поверх Cassandra.
  3. Очереди задач: RabbitMQ/Kafka справляются лучше. В Cassandra это приведет к проблеме Tombstones.
  4. Финансовые транзакции со строгим ACID: Перевод денег между счетами требует блокировок и транзакционной целостности, которую сложно реализовать на Cassandra (нужен Paxos/LWT, что медленно).

Cassandra / ScyllaDB в контексте Java backend

Как разработчик взаимодействует с БД из кода.

Драйверы (DataStax Java Driver)

Современные драйверы «умные» (Token Aware).

  • При старте драйвер скачивает карту кольца (Token Map).
  • Когда вы делаете session.execute("SELECT * FROM users WHERE id = ?", id), драйвер локально вычисляет хэш от id, понимает, на какой ноде лежат данные, и отправляет запрос прямо на эту ноду.
  • Это экономит сетевой хоп (не нужно проходить через координатор).

Асинхронность и Реактивность

Драйверы Cassandra полностью асинхронны (Non-blocking I/O на базе Netty).

  • Возвращают CompletionStage или Uni/Mono (в Quarkus/Spring WebFlux).
  • Это позволяет одному Java-сервису держать тысячи параллельных запросов к БД, максимально утилизируя пропускную способность кластера.

Идемпотентность и Ретраи

Так как это распределенная система, ошибки сети и тайм-ауты — норма.

  • Драйвер может автоматически делать ретрай (Speculative Execution) на другую реплику, если первая отвечает долго.
  • Важно: Операции записи должны быть идемпотентными. Запись SET status = 'done' безопасна для ретрая. Инкремент счетчика — нет (нужно специальное API).

Краткий чек-лист по Cassandra / ScyllaDB для собеседований

Тезисы, которые ожидаются от Senior Developer:

  1. Архитектура: Masterless, P2P, линейное масштабирование. Узких мест нет.
  2. CAP: Это AP система. Мы жертвуем мгновенной согласованностью ради доступности (но можем настроить CP для отдельных запросов через QUORUM).
  3. Модель: Primary Key = Partition Key (распределение) + Clustering Key (сортировка).
  4. Запросы: Схема проектируется от запросов. JOIN нет. Денормализация — норма.
  5. Внутренности: LSM-Tree. Запись — это append в память и лог. Обновление — это новая запись. Удаление — это запись Tombstone.
  6. Проблемы: Опасность широких партиций (Wide Partitions), проблемы с Tombstones при частом удалении, необходимость тюнинга JVM (для Cassandra) и Compaction.
  7. ScyllaDB vs Cassandra: ScyllaDB — это Cassandra на стероидах (C++, Seastar framework), без GC пауз, с лучшей утилизацией многоядерных процессоров, но с тем же протоколом и моделью данных.
  8. Когда НЕ использовать: Нужны ACID транзакции, сложные ad-hoc выборки, маленький объем данных.

Elasticsearch/OpenSearch

Роль Elasticsearch / OpenSearch в архитектуре

Elasticsearch (ES) и OpenSearch (OS) — распределённые поисково-аналитические хранилища, построенные вокруг инвертированного индекса и ориентированные на быстрый поиск и агрегацию больших объёмов неструктурированных и полуструктурированных данных. На практике используются как отдельный компонент архитектуры для ускорения поиска и анализа, а не как основная база данных.

Класс системы — поисково-аналитическая СУБД:

  • Оптимизированы для полнотекстового поиска, фильтрации, сложных агрегаций, работы с логами, сценариев аналитики.
  • Основная модель хранения — документо-ориентированная, аналогична коллекции JSON документов, но с расширенной схемой индексации.

Ключевые отличия от классических БД:

  • Использование инвертированного индекса (по словам и токенам), а не B-деревьев.
  • Фокус на поиске по тексту, с поддержкой релевантности, фразового поиска, fuzzy matching.
  • Консистентность по принципу eventual consistency, а не классические ACID транзакции.
  • Высокая скорость поиска и агрегаций по гигабайтам и терабайтам данных за счет шардирования и параллелизма.

Типовые зоны применения:

  • Полнотекстовый поиск: каталоги, справочники, поиск по описаниям товаров, статей, документов.
  • Логирование и observability: централизованный сбор и анализ логов приложений, метрик, событий безопасности.
  • Аналитика событий, аудит: обработка пользовательских действий, событий аудита, построение дашбордов.

Почему Senior должен знать специфику ES/OS:

  • На собеседованиях часто проверяют различие между поисковым движком и транзакционной БД.
  • Грамотное определение зоны ответственности ES/OS позволяет строить надёжную архитектуру и избегать анти-паттернов (например, не делать из ES источник истины).
  • Применение ES/OS как "projection layer" — стандартный паттерн для масштабируемого поиска и аналитики.

Индекс, shard, replica: как устроено распределённое поиск-хранилище

Индекс

  • Индекс — логическая единица хранения, аналог «базы» или «таблицы» в RDBMS.
  • Внутри индекса хранятся документы (JSON), для каждого поля строится инвертированный индекс для быстрого поиска.
  • Маппинг индекса определяет типы полей, используются анализаторы для текстовых данных.

Shard (первичный шард)

  • Shard — физический фрагмент индекса, размещённый на конкретном сервере (ноде) кластера.
  • Каждый индекс делится на N primary shards, что позволяет масштабировать хранение и обработку по горизонтали.
  • Количество шардов фиксируется при создании индекса и далее может изменяться только через reindex/resize.
  • Производительность поиска/индексации напрямую зависит от числа шардов: слишком мало — горячие узлы и узкие места, слишком много — overhead на поддержание структуры, задержки на координацию.

Replica

  • Replica shard — копия primary shard, назначается на другой узел.

  • Задачи:

    • Повышение отказоустойчивости: при сбое primary shard обработку берёт на себя replica.
    • Масштабирование чтения: replica участвуют в распределении поисковых запросов.
  • Реплики не участвуют в записи, только в поиске и выдаче данных.

Распределённый запрос по кластеру

  • Запрос координируется coordinating node, который разбивает его по всем первичным и/или репликам нужного индекса.
  • Шардинг позволяет выполнять поиск/агрегацию параллельно на всех шардах, результат собирается и агрегируется на координаторе.
  • Увеличение числа реплик — масштабирует только чтение, увеличение числа primary shards — и хранение, и обработку запросов.

Планирование числа шардов

  • Чрезмерное дробление (слишком много шардов): лишняя нагрузка на кластер, увеличенный time-to-first-byte, проблемы с распределением ресурсов.
  • Мало шардов: ограничивает масштабирование и не позволяет равномерно загрузить все ноды.
  • Оптимально: исходя из объёма данных (от 10 до 50ГБ данных на шард — ориентир), числа нод, нагрузки на поиск и индексацию.

Архитектурный уровень (system design)

  • Кластер состоит из master-нод (отвечают за управление), data-нод (хранят шарды), coordinating node (выдаёт запросы).

  • Каждый индекс разбит на несколько primary shards, каждый поддерживается набором реплик.

  • При проектировании описывают:

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

Пример формулировки:
«В Elasticsearch данные индексируются в primary shards, которые могут масштабироваться по нодам кластера. Реплики обеспечивают отказоустойчивость и балансировку чтения. Количество шардов подбираем исходя из размера индекса и потенциальной нагрузки.»

Маппинг, типы полей, analyzers и полнотекстовый поиск

Маппинг (mapping)

  • Схема данных на уровне индекса — набор полей с типами и настройками анализаторов.
  • Бывает явный (фиксируются конкретные типы) и динамический (тип определяется на лету по первым документам).
  • Динамический mapping удобен в прототипах, но в продуктиве часто приводит к ошибкам и конфликтам типов (например, разное толкование даты).

Плюсы явного mapping:

  • Предсказуемая структура, типы полей.
  • Защита от "type conflict", ошибка при попытке проиндексировать неожиданный тип.

Риски динамического mapping:

  • Отсутствие контроля, неожиданные поля, разные типы в одном поле при batch-загрузках.

Типы полей

  • Текстовые:

    • text — применяется анализатор, поддерживает полнотекстовый поиск (разбивка по словам, стемминг), не подходит для сортировки и агрегаций.
    • keyword — точное хранение, строчные сравнения и сортировка, фильтрация, агрегации.
  • Числовые: integer, long, float, double — для числовых полей, поддерживают фильтрацию, сортировку, агрегации.

  • Дата/время: date, поддерживает диапазоны, фильтрацию, агрегации по времени.

  • Boolean: логические значения, быстрая фильтрация.

  • Nested: специальные структуры для вложенных объектов (массивы объектов с собственными полями).

  • Geo: для географических координат.

Различие text vs keyword:

  • text: используется, когда требуется полнотекстовый поиск — описание товаров, тексты постов.
  • keyword: для уникальных идентификаторов, тегов, категорий, сортировки и агрегации.

Риск типичной ошибки:
«Если поле объявить как text, а затем попытаться сортировать или агрегировать по нему — будет ошибка или неэффективность.»

Analyzers (анализаторы)

  • Анализатор — набор шагов по разбору текста перед индексированием:

    • Токенизация (разделение на слова).
    • Нормализация (lowercase, стемминг, удаление стоп-слов).
    • Фильтры (удаление знаков препинания, normalization для русского/английского).
  • Standard analyzer: дефолтный вариант для большинства европейских языков.

  • Custom analyzers: задаются отдельно для поддержки языков с морфологией (русский, турецкий), специальных кейсов (email, phone).

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

Полнотекстовый поиск

  • Поисковые запросы:

    • match: релевантностный поиск по одному полю.
    • match_phrase: поиск точных или близких фраз.
    • multi-match: поиск по нескольким полям одновременно.
  • Scoring: вычисление релевантности по TF/IDF, BM25 — чем чаще слово во всех документах, тем меньше вес, чем чаще в конкретном документе — тем выше.

  • Boost: дополнительные веса для важных полей или отдельных терминов.

  • Keyword vs text: в keyword только точное совпадение, в text — поиск по смыслу и с раскладкой по словам.

Exact match vs full-text:

  • Для id, кодов, тегов всегда использовать keyword, для описаний, названий — text.

Типовые ошибки с маппингом:

  • Хранение всего в text, попытка агрегировать/сортировать по нему — не будет работать.
  • Динамический mapping без контроля — появление неожиданных имен и типов полей.

Пример формулировки:
«Поле с типом text предназначено для полнотекстового поиска, а keyword — для фильтрации и агрегаций. Ошибка — пытаться сортировать по text-полю.»

Aggregations, фильтрация, сортировка: “поиск + аналитика”

DLS (query + filter контекст)

  • Filter context:
    • Используется для точной фильтрации без учета релевантности (scoring), результат кешируется.
    • Применяется к датам, числам, булевым значениям, id.
  • Query context:
    • Влияет на scoring (релевантность), используется для полнотекстового поиска.
    • Пример: must, should, match, multi_match.
  • Практика: фильтрация по точным условиям — filter, текстовый поиск — query.

Aggregations

  • Bucket aggregation:
    • Группировка документов по значениям (terms), диапазонам (range), времени (date_histogram).
    • Примеры: найдите количество товаров по категориям или распределение ошибок по часам.
  • Metric aggregation:
    • Числовые метрики: avg, sum, min, max, cardinality (уникальные значения).
    • Пример: средняя цена, общее число событий, уникальные пользователи.
  • Nested aggregation:
    • Работа с вложенными структурами (nested объекты).

Комбинация поиска и аналитики

  • В одном запросе можно найти нужные документы и тут же построить необходимые агрегаты: фасеты для навигации, статистику по срезам.
  • Пример: поиск товаров по запросу + агрегация по брендам и ценовым диапазонам.

Сортировка

  • Доступна по полям типа keyword, числовым, датам.
  • Сортировка по score (релевантность) для полнотекстового поиска.
  • Ошибка — пытаться сортировать по text полю (невозможно, только keyword).

Оптимизация запросов

  • Все, что можно, переводить в filter (кешируется).
  • Минимизировать число вложенных агрегаций, тяжелых операций (cardinality, nested), если нет острой необходимости.
  • Для больших выборок — использовать scroll или search_after.

На собеседовании звучит так:
«Elasticsearch позволяет в одном запросе и искать документы, и строить агрегаты — например, распределение по категориям. Для фильтрации используем filter context, для полнотекста — query context.»

Модель консистентности: транзакционность, eventual consistency, refresh interval

Транзакционность и атомарность

  • Нет поддержки классических транзакций уровня ACID (как в RDBMS).
  • Минимальная атомарность — один документ: операция записи или удаления либо происходит целиком, либо не происходит.

Индексация — commit и refresh

  • Документ при записи добавляется в отдельный сегмент, который не сразу становится видимым для поиска.
  • Commit — физическая запись на диск, устойчивость.
  • Refresh — операция, делающая новые документы доступными для поиска (по умолчанию — каждую секунду, настраивается).

Refresh interval

  • Чем чаще refresh, тем выше нагрузка на кластер.
  • Быстрый refresh = минимальная задержка видимости новых данных, но рост издержек на IO.
  • В большинстве сценариев логирования допустима задержка в 1–5с.

Eventual consistency

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

Bulk-операции

  • Для массовой загрузки/обновления документов используется bulk API.
  • Возможность контролировать refresh (например, отключить его на время загрузки), затем вручную вызвать refresh.
  • Bulk — необходимость для перформанса, иначе много одиночных запросов быстро перегрузят кластер.

Ограничения и best practices

  • ES/OS не подходят для хранения консистентных финансовых данных и управления бизнес-инвариантами.
  • Оператор refresh периодически обновляет индекс — важно для SLA по видимости новых данных.
  • Возможны ситуации дублирования или потери данных при ошибках индексации.

Пример:
«После индексации документы не сразу доступны для поиска, поскольку refresh происходит периодически. Elasticsearch не поддерживает транзакции между несколькими документами.»

Сценарии использования: поиск по каталогу, логирование, аудит, аналитика событий

Поиск по каталогу

  • Индекс товаров с полями: название (text), описание (text), атрибуты (keyword/numeric), цена (float), категории (keyword).
  • Поиск с учётом релевантности, фильтрация по фасетам, диапазонам цен/атрибутов.
  • Синонимы, подсказки, fuzzy-поиск — настроенные анализаторы.
  • Одновременная выборка документов и подсчёт агрегатов для UI (фасетная навигация).

Пример формулировки:
«В поиске по каталогу продукты индексируются с явным разделением на text- и keyword-поля. Фасеты и фильтры реализуются через агрегации, а полнотекстовый поиск позволяет искать по описанию товара.»

Логирование и observability

  • Индексация структурированных логов (JSON), автоматическая разбивка по полям.
  • Сквозная фильтрация по времени, уровню, сервису, id запроса.
  • Использование date_histogram для построения графиков событий.
  • Метрики ошибок, агрегация событий для дашбордов observability (например, ELK-стек).

Так говорят на собеседовании:
«Elasticsearch — ключевая часть стека для централизованного логирования, с поддержкой быстрых выборок по времени, фильтрам и построением аналитических дашбордов.»

Аудит

  • Сценарии: поиск событий по пользователю, объекту, типу операции, временным диапазонам.
  • Анализ паттернов поведения, контроль compliance.
  • Агрегации для поиска подозрительных действий, массовых изменений.

Аналитика событий

  • Индексация событий кликов/просмотров с временными метками.
  • Time-based индексы для масштабирования по времени.
  • Агрегации: сколько действий за период, уникальные пользователи (cardinality), сравнение по временным отрезкам.

Архивирование

  • Time-based индексы позволяют организовывать hot/warm/cold storage:

    • Hot — рабочая зона, скоростные ноды.
    • Warm — менее востребованные данные.
    • Cold — архивированные, редко-используемые индексы, дешёвое хранение.
  • Перемещение на другие tier'ы через ILM (Index Lifecycle Management).

На собеседовании:
«Для аудит- и event-логирования индексы организуются как time-based, что облегчает архивирование и удаление старых данных.»

Анти-паттерны: использовать ES как primary DB

Почему это ошибка

  • Модель консистентности: ES не гарантирует строгой целостности, eventual consistency приводит к временной рассинхронизации данных.
  • Транзакционные операции: нет поддержки атомарных операций между документами, невозможность обеспечить бизнес-инварианты.
  • Сложность обновлений и удалений: операции дорогостоящие, при массовых изменениях происходит reindex.

Типовые анти-паттерны

  • Хранение единственного источника истины (critical data) только в ES.
  • Построение сложных моделей на уровне поиска, например, обработка заказов, транзакций, оплат, без резервной транзакционной БД.
  • Использование ES для хранения данных, требующих строгой согласованности или сложных ограничений.

Как делать правильно

  • Источник истины — надёжная БД (обычно RDBMS).
  • ES/OS — это отдельная проекция данных для поиска и аналитики.
  • Поддержка актуальности индекса — через CDC, события, batch обновления (ETL).
  • ES восстанавливается или реиндексируется из основной БД.

Риски

  • Возможна потеря/дублирование данных между БД и индексом в случае ошибок передачи/индексации.
  • Рассинхронизация между основой и поисковым индексом.
  • Трудоёмкость восстановления индекса, если потеряны события.

Правильная позиция:
«Elasticsearch не должен быть источником истины для бизнес-данных. Все критически важные объекты и инварианты ведём в RDBMS, ES — используем только для проекции и поиска, с возможностью восстановления из основной базы.»

Elasticsearch / OpenSearch в контексте Java backend

Клиентские подходы

  • Основной способ интеграции: HTTP REST API (native clients deprecated, в OpenSearch аналог).
  • Есть Java High/Low Level REST clients, но в большинстве случаев предпочтение явным HTTP-запросам.
  • Для batch индексации используют bulk API — критично уменьшает нагрузку и ускоряет запись.

Модели интеграции

  • Наполнение индекса — через CDC (Change Data Capture), очереди (Kafka, RabbitMQ), batch-выгрузки.

  • Сценарии:

    • Индексация синхронно после записи в основную БД — для поиска с минимальной задержкой.
    • Асинхронно — через очередь событий и batch обработку (лучше масштабируется, проще retry).

Типовые ошибки интеграции

  • Частые одиночные запросы к ES (вместо bulk) — перегрузка, снижение throughput.
  • Отсутствие стратегии retry при сбоях (ошибки индексации должны обрабатываться с idempotency чтобы не было дубликатов/потерь).
  • Неучёт eventual consistency — попытка ожидать instant consistency, критично для индексирования недопустимо.

Observability Elasticsearch

  • Мониторинг состояния кластера: состояние нод (здоровье — green/yellow/red), нагрузка на процессор, RAM, размер индексов.
  • Метрики: длины очередей индексации, latency поиска, grow/shrink индексов.
  • Алерты: задержки по очередям, рост latency, нехватка хранилища.

Формулировка для собеседования:
«В продакшене — только batch индексация, контроль retry и мониторинг здоровья кластера по метрикам очередей и состоянию shard replication.»

Краткий чек-лист по Elasticsearch / OpenSearch для собеседований

  • Устройство кластера: индекс делится на primary shards и replica shards, с распределением по нодам; запросы распараллеливаются и агрегируются на координирующем узле.
  • Mapping: типы полей (text, keyword, числовые, дата, nested), типичная ошибка — использование text там, где нужно точное совпадение или агрегация.
  • Analyzers: разбивают текст на токены, нормализуют ввод, поддерживают мультиязычность; критично для качества поиска.
  • Полнотекстовый поиск: отличается от "LIKE %...%" в RDBMS, строится на анализаторах, scoring по BM25/TF-IDF.
  • Aggregations: позволяют получить витрину с аналитикой по любым полям без отдельного OLAP слоя.
  • Eventual consistency: нет ACID, атомарность — только документ, видимость новых данных через refresh interval; важна настройка SLA по появлению данных.
  • Типовые сценарии: поиск по каталогам (multi-field поиск, фасеты), логирование (ускорение анализа логов), аудит событий.
  • Анти-паттерн: использование ES как основной транзакционной БД или единственного хранилища данных при отсутствии возможности восстановления из основной БД.

ClickHouse и OLAP-системы

Зачем backend-разработчику понимать колоночные OLAP-БД

Разделение OLTP и OLAP

OLTP (Online Transaction Processing) и OLAP (Online Analytical Processing) — два принципиально разных класса рабочих нагрузок, требующих разных подходов к хранению и обработке данных.

OLTP-системы оптимизированы под онлайн-запросы с малым latency. Это классические реляционные БД (MySQL, PostgreSQL) и NoSQL-хранилища (MongoDB, Cassandra и пр.), где нужно быстро найти конкретную запись по первичному ключу, вставить/обновить/удалить строку, гарантировать транзакционность и консистентность. Данные в OLTP лежат строками, индексы построены для point lookup'ов, и весь стек оптимизирован под многочисленные мелкие операции.

OLAP-хранилища предназначены для аналитики огромных объёмов данных. Здесь интересуют не отдельные записи, а агрегаты, срезы, тренды за длительные периоды. Запросы сканируют миллиарды строк, суммируют, группируют, фильтруют по сложным условиям. Для OLAP критичны пропускная способность на чтение, объём данных в памяти и скорость массовых вычислений. Типичные OLAP-системы — ClickHouse, BigQuery, Redshift, Snowflake.

Попытка выполнять аналитику на OLTP-БД приводит к проблемам:

  • Тяжёлые JOIN'ы и GROUP BY на миллиардах строк блокируют или замораживают транзакционные операции
  • Индексы, оптимизированные под поиск по ключу, неэффективны при сканировании большого подмножества записей
  • Ресурсы сервера (CPU, I/O, RAM) истощаются, и отчёты начинают конкурировать с онлайн-приложением за пропускную способность
  • Резервные копии и репликация становятся критичными и дорогостоящими

Поэтому архитектурное разделение: OLTP-БД как source of truth для операций, отдельное OLAP-хранилище для аналитики.

В каких системах появляется потребность в ClickHouse и аналитических хранилищах

Система начинает нуждаться в отдельном OLAP-хранилище, когда объём аналитических запросов или объём данных становится критичным:

  • Отчёты по истории операций: ежедневные/ежечасные отчёты по продажам, платежам, заказам за месяцы или годы. Запросы типа «доход по дням за прошлый год, разбитый по категориям» требуют чтения гигабайтов данных и жёсткой агрегации.

  • Аналитика событий и логов: приложение генерирует клики, просмотры, скролл-события, действия пользователей. Такие события считаются в десятках/сотнях миллиардов в день. Слать всё в основную OLTP-БД невозможно: скорость вставки упадёт, таблицы раздуются, и онлайн-приложение затормозится.

  • Дашборды для продакта и бизнеса: реал-тайм метрики, графики конверсий, когорт-анализ, воронки и прочее. Дашборды часто перезагружаются и дёргают одни и те же срезы данных. На OLTP-БД это означает перегруз и конкуренцию за ресурсы.

  • Персонализированная аналитика и ML-фичи: рекомендации, предсказание churn'а, сегментация, все требуют исторических данных о поведении пользователя. Запросы над историей ещё тяжелее, чем бизнес-отчёты.

На собеседовании интервьюер часто проверяет:

  • Понимает ли кандидат, почему нельзя полагаться только на OLTP-БД для аналитики? (Ответ: производительность, масштабируемость, конкуренция за ресурсы, невозможность гарантировать SLA для онлайн-операций).

  • Умеет ли назвать паттерны задач, для которых нужно выделять отдельное хранилище? (Отчёты, события, дашборды, когорт-анализ).

  • Как выстраивается связь OLTP и OLAP? (Реplication, CDC, batch-ingestion, eventual consistency).


Колоночное хранение vs строковое: зачем и для чего

Строковое хранение (Row-Store)

В строковом хранилище (row-store) данные одной строки физически размещаются рядом. Если в таблице колонки id, name, email, age, то в памяти они лежат так:

[id₁][name₁][email₁][age₁][id₂][name₂][email₂][age₂]...

Это идеально для OLTP-операций:

  • Поиск по PK: индекс за O(log N) или O(1) отправляет нас на нужную строку, мы читаем её целиком в одной операции.

  • Вставка/обновление: мы обновляем только один набор смежных байт, и это быстро.

  • Транзакции и ACID: управление версиями строк, локирование, откаты — всё ориентировано на то, что мы работаем со строкой как с атомарной единицей.

Примеры: классические RDBMS (PostgreSQL, MySQL с InnoDB), NoSQL как MongoDB (документы лежат целиком).

Минусы для аналитики:

  • Если нас интересуют только две колонки (например, name и age) из двадцати, мы всё равно читаем все двадцать в памяти. Пусто тратим I/O и bandwidth.

  • Агрегирование по миллиардам строк означает, что мы прочитали и обработали все неиспользуемые колонки.

Колоночное хранение (Column-Store)

В колоночном хранилище (column-store) значения одного столбца хранятся плотными массивами рядом:

[id₁][id₂][id₃]...[name₁][name₂][name₃]...[email₁][email₂][email₃]...[age₁][age₂][age₃]...

Такая компоновка перестраивает оптимизацию вокруг другого класса запросов:

  • Сканирование одной/нескольких колонок: если нужны name и age, мы читаем именно эти две последовательности данных, минуя остальные. Экономим I/O в разы.

  • Компрессия: однотипные данные в колонке хорошо сжимаются. Целые числа можно закодировать дельта-кодированием, строки — с повторением префиксов. Колоночные БД сжимают данные в 5–20 раз лучше, чем строковые.

  • SIMD и векторизация: процессор CPU имеет операции для параллельной обработки массивов одного типа. Когда в памяти лежат подряд сотни миллионов целых чисел, CPU может обработать их векторизованно (SSE, AVX инструкции), в то время как строковый стор не подходит для такой оптимизации.

Почему колоночные БД эффективны для аналитики

Типичный аналитический запрос выглядит так:

SELECT category, SUM(amount), COUNT(*) 
FROM events 
WHERE event_date BETWEEN '2025-01-01' AND '2025-12-31' 
GROUP BY category

Он читает три колонки (event_date, category, amount) из миллиардов строк. В row-store'е мы загружали бы все остальные колонки впустую. В column-store'е:

  • Читаем серии event_date, category, amount параллельно и независимо. Скорость чтения с диска и из памяти намного выше.

  • SUM(amount) применяется к плотному массиву чисел, что даёт возможность для векторизации и параллельных CPU-вычислений.

  • GROUP BY category группирует только нужные значения, а не целые строки.

  • Агрегация работает в несколько раз быстрее благодаря лучшему использованию кэша процессора.

На практике ClickHouse отвечает на такие запросы в секунды или доли секунды, в то время как MySQL/PostgreSQL обычно тратят минуты или дольше.

Компрессия и кодирование

Колоночные системы используют продвинутые методы сжатия:

  • Delta encoding: если колонка содержит монотонно растущие числа (например, timestamps), сохраняем не сами значения, а разности между соседними. Вместо [1000000, 1000001, 1000002, ...] сохраняем [1000000, 1, 1, 1, ...]. Разности сжимаются очень хорошо.

  • Dictionary encoding: если колонка содержит повторяющиеся строки (например, названия стран, статусы), создаём словарь уникальных значений и сохраняем индексы. Вместо ["RU", "US", "RU", "DE", "RU"] [0, 1, 0, 2, 0] + словарь.

  • LZ4, Zstd и другие generic компрессоры: поверх кодирования применяют стандартные алгоритмы сжатия для дополнительного уменьшения размера.

Эффект компрессии огромен: таблица на 1 ТБ в row-store'е может занимать 50–100 ГБ в column-store'е. Это упрощает управление памятью, резервным копированием, репликацией.

Компрессия также влияет на скорость запросов: когда данные хранятся в сжатом виде, необходимо их распаковывать. Обычно операция распаковки быстрая, а экономия на I/O (загрузка с диска) перевешивает стоимость декомпрессии в памяти.

Ограничения колоночных хранилищ

Колоночные БД не универсальны. Они имеют чёткие ограничения:

  • Point lookup: если нужно найти одну конкретную запись по ID, колоночное хранилище работает медленнее. Вместо быстрого поиска по B-дереву нужно отсканировать колонку ID-шников и найти позицию, а затем собрать значения из всех остальных колонок. Это дороже, чем быстрый lookup в OLTP-БД.

  • Частые UPDATE/DELETE: обновление значения в колонке требует переписывания кусков данных. Если обновления мелкие и частые, column-store работает неэффективно. Она ориентирована на append-only или batch-обновления.

  • Транзакционность: большинство колоночных БД либо вообще не поддерживают транзакции, либо поддерживают их слабо. ClickHouse, например, не гарантирует ACID для отдельных транзакций в классическом смысле; данные добавляются атомарно блоками, но не с уровнем консистентности как в MySQL.

  • Точечное изменение данных: если нужно обновить одно значение в одной ячейке, это может быть неэффективно. Оптимально вставлять данные батчами.

Поэтому архитектура выглядит так: мелкие, быстрые операции — в OLTP-БД. Исторические данные и аналитика — в ClickHouse. Каждой системе — свой класс задач.


Архитектура ClickHouse: MergeTree-таблицы, партиции, sparse-индексы, primary key

Семейство движков MergeTree

ClickHouse предоставляет семейство табличных движков, объединённых под названием MergeTree. Это не один движок, а несколько вариаций, но все они работают по одному принципу: данные хранятся в виде отсортированных кусков (parts), которые периодически сливаются.

Как это работает:

  1. Вставляем данные в таблицу MergeTree.
  2. ClickHouse делит вставленные данные на куски (parts) в зависимости от партиции. Каждый кусок — это набор файлов на диске, где данные отсортированы по primary key.
  3. В фоне запускается процесс merge: система берёт несколько кусков одной партиции и сливает их в один больший кусок, удаляя старые файлы.
  4. При чтении (SELECT) ClickHouse открывает те куски, которые соответствуют фильтрам, и сканирует их параллельно.

Этапы данных:

  • Immutable parts: каждый кусок, когда создан, неизменяем. Обновления и удаления в ClickHouse — это, фактически, вставка новых версий данных и пометка старых как удалённых.

  • Merge process: периодически система выбирает несколько старых кусков, читает их, сливает в один, переписывает данные с сортировкой и переиндексированием. После успешного merge старые куски удаляются.

  • Оптимизация хранения: в процессе merge включаются TTL-правила (удаление старых строк), перекодирование данных, переиндексирование. Это делает хранение компактнее и запросы быстрее.

Основные варианты MergeTree:

  • MergeTree: базовый движок, подходит для большинства случаев. Поддерживает партиции, primary key, sparse-индексы, TTL.

  • ReplicatedMergeTree: распределённая версия, позволяет реплицировать таблицы между разными серверами ClickHouse.

  • SummingMergeTree: специализированный движок, который при merge'е суммирует значения с одинаковыми ключами. Полезна для предагрегированных таблиц.

  • AggregatingMergeTree: похожа на SummingMergeTree, но использует более гибкие функции агрегации.

  • ReplacingMergeTree: при merge'е оставляет только последнюю версию каждого ключа. Используется для таблиц, где строки могут обновляться логически.

Для общего понимания достаточно помнить: MergeTree-таблицы хранят данные отсортированными кусками, которые периодически сливаются. Это оптимизирует как хранение, так и чтение благодаря возможности отсечения ненужных диапазонов при скане.

Партиции (Partitions)

Партиция — это логическое разбиение таблицы на подмножества данных. При создании таблицы указывается ключ партиционирования (partition key), по значению которого данные распределяются.

Типичный пример:

CREATE TABLE events (
  event_date Date,
  user_id Int64,
  event_type String,
  amount Float32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id)

Здесь PARTITION BY toYYYYMM(event_date) означает: разбить таблицу по месяцам. Данные за январь 2025 пойдут в одну партицию, февраль 2025 — в другую, и т.д. На диске каждая партиция имеет свою директорию с независимым набором кусков.

Зачем партиционирование:

  • Управление историей: легко удалить целую партицию (весь месяц данных) одной операцией: ALTER TABLE events DROP PARTITION '202501'. Без партиционирования пришлось бы DELETE-ить по одной строке или с WHERE-условием, что дорого в ClickHouse.

  • Ускорение запросов: если запрос содержит фильтр по дате, ClickHouse может отсечь целые партиции на уровне планирования, не дождаться сканирования. Пример: WHERE event_date >= '2025-06-01' отсекает все партиции до июня.

  • Параллелизм: разные партиции обрабатываются независимо, что даёт хорошую параллелизацию на многоядерных системах.

  • Архивирование и TTL: можно перемещать старые партиции на холодное хранилище (например, S3), оставляя свежие данные быстрыми на локальном диске.

Типовые стратегии партиционирования:

  • По дате: PARTITION BY toYYYYMM(timestamp) или PARTITION BY toDate(timestamp) для дневного разбиения. Подходит для логов, событий, любых временных рядов.

  • По пользователю/клиенту: PARTITION BY user_id % 10 для распределения данных пользователей по 10 партициям. Помогает равномерно загруженнять, если данные по пользователям разного размера.

  • По категории: PARTITION BY category если категорий не очень много. Если категорий тысячи, это плохая идея (слишком много партиций).

Ошибки при выборе партиции:

  • Партиций слишком много (> 10 000): каждая партиция — это директория на диске, metadata становится тяжелой, операции замораживаются.

  • Партиций слишком мало: все данные в одной-двух партициях не даёт преимуществ параллелизма и удаления.

  • Выбор ключа партиции, не совпадающий с типичными запросами: если все запросы фильтруют по дате, а партиция по категории, преимущества теряются.

На собеседовании вопрос часто звучит как: "Как бы ты спроектировал партиционирование для таблицы событий в ClickHouse, которая растёт на 100 ГБ в день?" Ответ: "Партиция по дате, чтобы удалять старые дни по TTL, обеспечивать параллелизм и отсекать ненужные дневные разделы при фильтрации по timestamp".

Primary Key в ClickHouse

Primary key в ClickHouse не то же самое, что PK в RDBMS. В RDBMS PK гарантирует уникальность. В ClickHouse PK — это ключ сортировки данных внутри каждого куска.

Когда вставляем данные в таблицу:

CREATE TABLE sales (
  sale_date Date,
  user_id Int64,
  product_id Int64,
  amount Float32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(sale_date)
ORDER BY (sale_date, user_id)
PRIMARY KEY (sale_date, user_id)

ClickHouse сортирует данные внутри каждого куска по (sale_date, user_id). Это означает:

  • Данные с одинаковым sale_date и user_id лежат рядом в файле.
  • Сканирование диапазона по sale_date или по паре (sale_date, user_id) быстро.
  • Если фильтр WHERE sale_date = '2025-01-15', ClickHouse быстро находит диапазон байт в файле, где лежат эти данные.

PRIMARY KEY vs ORDER BY:

В ClickHouse обычно ORDER BY и PRIMARY KEY совпадают. Но они имеют разные эффекты:

  • ORDER BY определяет порядок сортировки данных на диске.
  • PRIMARY KEY создаёт sparse-индекс (описано ниже) для быстрого поиска диапазонов.

Если не указать PRIMARY KEY явно, он будет равен ORDER BY по умолчанию. На практике обычно совпадают.

Выбор key'а:

  • Часто фильтруемые колонки: если запросы всегда фильтруют по user_id, он должен быть в начале key'а.
  • Cardinality: начинать с низкой cardinality (мало уникальных значений), переходить к высокой. Пример: (region, user_id) — регионов 10, user_id миллиарды. ClickHouse использует это для оптимизации индекса.
  • Компрессия: если данные отсортированы по key'у, они лучше сжимаются. Выбор ключа влияет на финальный размер файлов.

Типовые ключи:

  • Для событий: (user_id, event_date) или (event_date, user_id) в зависимости от типичных запросов.
  • Для sales: (sale_date, product_id) или (product_id, sale_date).
  • Для логов: часто просто (timestamp) или (service_id, timestamp).

Sparse-индексы

ClickHouse использует sparse-индексы, а не полные B-деревья как традиционные RDBMS. Sparse-индекс — это «легковесный» индекс, который хранит не каждый ключ, а только ключи, которые соответствуют boundary'ям данных на диске.

Как это работает:

Предположим, таблица отсортирована по user_id и содержит 1 млн записей. На диске данные разделены на блоки размером, скажем, 65536 строк. Sparse-индекс хранит минимальное значение user_id в каждом блоке:

Block 0: user_id >= 1, user_id < 10000 (минимальный в индексе: 1)
Block 1: user_id >= 10000, user_id < 20000 (минимальный в индексе: 10000)
Block 2: user_id >= 20000, user_id < 30000 (минимальный в индексе: 20000)
...

При запросе SELECT ... WHERE user_id = 15000:

  1. ClickHouse ищет в sparse-индексе: какой блок содержит user_id = 15000?
  2. Находит: это Block 1 (где минимум >= 10000 и < 20000).
  3. Загружает только Block 1 с диска, вместо того чтобы сканировать весь файл.

Отличие от B-деревьев:

  • B-дерево в RDBMS хранит ссылку на каждый ключ. Для таблицы 1 млн строк индекс может быть 50 МБ. Поиск требует несколько уровней обхода дерева (O(log N)).

  • Sparse-индекс хранит один ключ на каждые 65536 строк. Для таблицы 1 млн строк индекс может быть 16 КБ. Поиск — бинарный поиск по маленькому массиву в памяти (О(log(кол-во блоков))).

Sparse-индекс намного более компактный и быстрый для загрузки в память, что делает его идеальным для аналитических запросов с дикими диапазонами.

Secondary-индексы

ClickHouse поддерживает secondary-индексы (также называемые skipping-индексы), но они используются реже и имеют ограничения.

Secondary-индекс создаётся для колонки, которая не входит в primary key. Пример:

CREATE TABLE events (
  event_date Date,
  user_id Int64,
  event_type String,
  amount Float32,
  INDEX event_type_idx event_type TYPE bloom_filter GRANULARITY 1
) ENGINE = MergeTree()
ORDER BY (event_date, user_id)

Здесь bloom_filter индекс по event_type позволяет быстро отсечь блоки, которые не содержат нужного типа события.

Когда использовать:

  • Когда часто фильтруют по колонке, которая не в primary key.
  • Когда cardinality высока и обычное сканирование медленное.

Ограничения:

  • Secondary-индексы не так эффективны, как primary key. Они используют bloom-filter'ы или hash, которые дают false positives (могут ошибаться в оценке).
  • На собеседовании обычно не углубляются в secondary-индексы, достаточно помнить о существовании.

Влияние партиционирования и PK на производительность

На примере табличного запроса:

SELECT SUM(amount) 
FROM sales 
WHERE sale_date >= '2025-01-01' AND sale_date <= '2025-01-31' 
  AND user_id = 12345
  1. Партиционирование по sale_date: ClickHouse отсекает все партиции вне января 2025. Если таблица содержит данные за 5 лет, это означает 59 партиций отсечено, остаётся одна.

  2. Primary key (sale_date, user_id): в партиции за январь sparse-индекс быстро находит диапазон блоков, где лежат строки с user_id = 12345. Сканируется только эти блоки.

  3. Результат: вместо чтения 100 ТБ данных (вся таблица за 5 лет) загружаются десятки МБ (один день, один пользователь).

Если бы key был неправильным (например, только (user_id)), ClickHouse пришлось бы сканировать все блоки с user_id = 12345 во всех датах, что медленнее.

На загрузку данных:

  • Хороший primary key означает, что вставляемые данные уже близки к отсортированному порядку. Это упрощает merge'ю и уменьшает фрагментацию.
  • Плохой key требует полной переиндексации при merge'е, что дорого.

На размер хранения:

  • Отсортированные данные по key'у лучше компрессируются. Если key совпадает с типичными фильтрами (date, category), значения близки друг к другу, что даёт лучшую компрессию.

Паттерны использования: аналитические запросы, отчёты, event-storage

Аналитические запросы

Аналитические запросы в ClickHouse обычно имеют характер сложных агрегаций, группировок и фильтраций.

Типовые паттерны:

  • Агрегация по времени: SELECT toDate(event_date) AS day, SUM(amount), COUNT(*) FROM sales GROUP BY day ORDER BY day DESC LIMIT 30 — последние 30 дней по суммам и количеству. На OLTP-БД это замораживает, на ClickHouse работает в доли секунды.

  • Когорт-анализ: найти пользователей, зарегистрировавшихся в одну дату, и отследить их активность по неделям. Запрос объединяет несколько таблиц, группирует по когортам, считает метрики. На ClickHouse это логично и быстро.

  • Retention: доля пользователей, которые вернулись на день N после первого визита. Требует самосоединения таблицы событий с разными условиями, groupby по пользователю. ClickHouse справляется лучше, чем RDBMS, благодаря лучшей оптимизации больших GROUP BY.

  • Конверсионные воронки: последовательность действий пользователя (клик → заполнение → покупка). Запрос с временными окнами, по порядку событий. В ClickHouse есть специальные функции (arrayJoin, arrayFilter), которые упрощают такие запросы.

  • Сегментация: разделить пользователей на группы по их поведению, демографии и т.д. Например, SELECT user_segment, COUNT(DISTINCT user_id) FROM user_profiles GROUP BY user_segment. На ClickHouse эта операция параллельна и быстра даже для миллиардов строк.

Отчёты

Отчёты — это регулярно вычисляемые срезы данных, которые показывают бизнес и продакту, как дела идут.

Типовые ежедневные отчёты:

  • Доход по категориям: SELECT category, SUM(revenue) FROM sales WHERE date = TODAY() GROUP BY category.
  • Количество активных пользователей: SELECT COUNT(DISTINCT user_id) FROM events WHERE date = TODAY().
  • Top-10 продукты по продажам: SELECT product, COUNT(*) FROM sales WHERE date = TODAY() GROUP BY product ORDER BY COUNT(*) DESC LIMIT 10.
  • Средний чек: SELECT SUM(amount) / COUNT(DISTINCT user_id) FROM sales WHERE date = TODAY().

На OLTP-БД такие отчёты требуют тяжёлых операций и конкурируют с онлайн-приложением. На ClickHouse отчёты вычисляются параллельно, не влияя на production.

Дашборды:

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

Drill-down запросы:

  • Начинаем с суммарного показателя: «доход за день = 1 млн». Кликаем и видим разбивку по категориям. Кликаем на категорию и видим разбивку по продуктам. Кликаем на продукт и видим список сделок.
  • В OLTP-БД каждый drill-down — тяжёлый запрос.
  • На ClickHouse благодаря быстрым GROUP BY и фильтрам drill-down чувствует себя интерактивным (ответ в миллисекунды).

Event-storage

Event-storage — это хранилище, где накапливаются события (клики, просмотры, действия) в огромных количествах.

Характеристики событийных данных:

  • Volume: приложение генерирует тысячи или миллионы событий в секунду. За день может быть 100 млрд событий.
  • Append-only: события добавляются, не обновляются. Это идеально для ClickHouse.
  • Retention: события хранят некоторое время (месяцы или годы) для анализа, но потом удаляют, чтобы сэкономить место.

Типовая таблица событий:

CREATE TABLE events (
  event_time DateTime,
  user_id Int64,
  event_type String,
  properties String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, user_id, event_time)

Паттерны запросов к событиям:

  • Сколько событий произошло?COUNT(*) по всем или с фильтром по дате/типу.
  • Какие типы событий самые частые?SELECT event_type, COUNT(*) FROM events GROUP BY event_type ORDER BY COUNT(*) DESC LIMIT 10.
  • Действия пользователя в конкретное времяSELECT event_type FROM events WHERE user_id = X AND event_time >= ... AND event_time <= ....
  • Очередность событий — группировка по пользователю, сортировка по времени, анализ последовательности (это требует более сложных запросов с window functions).

Вставка событий:

  • Приложение не вставляет события по одному. Вместо этого буферирует события в памяти (в течение секунды или до достижения размера батча), а затем вставляет пачку (микробатч) в ClickHouse.
  • Типовый размер микробатча: 100K–1M событий за раз. Это обеспечивает эффективное добавление без перегруза сети.

Time-series аспекты в ClickHouse

ClickHouse часто используется как time-series база данных. В этом контексте:

  • Timestamp: обычно первая или вторая колонка в key'е. Например, ORDER BY (service_name, timestamp) для метрик одного сервиса.
  • Партиция по дате: PARTITION BY toDate(timestamp) позволяет удалять старые данные по TTL и управлять диском.

Паттерны time-series запросов:

  • Метрика за временной промежуток: SELECT avg(cpu_load) FROM metrics WHERE timestamp >= ... AND timestamp <= .... На ClickHouse sparse-индекс по timestamp'у помогает найти нужные блоки за микросекунды.

  • Агрегация на грубые интервалы (rollup): вместо хранения метрик за каждую секунду, можно хранить средние за 1 минуту, 5 минут, час. Это значительно экономит место и ускоряет запросы для больших временных окон.

  • Поиск аномалий: SELECT timestamp, value FROM metrics WHERE value > (SELECT avg(value) FROM metrics WHERE timestamp >= ... AND timestamp <= ...) * 1.5. Поиск значений, отклоняющихся от среднего.

Когда ClickHouse выигрывает у RDBMS

На собеседовании часто звучит вопрос: "Когда ты выбираешь ClickHouse вместо PostgreSQL/MySQL для отчётов?"

Ответ структурируется так:

  • Большие исторические объёмы: если таблица содержит данные за годы и растёт на гигабайты в день. На RDBMS это затруднит резервное копирование и репликацию. На ClickHouse это штатно.

  • Тяжёлые агрегаты и группировки: если большинство запросов — это GROUP BY по 5–10 ключам с COUNT, SUM, AVG. На RDBMS это медленнее из-за неэффективности row-store'а. На ClickHouse это секунда или доли.

  • Аналитические фильтры: если фильтры сложные и включают множество условий. Пример: WHERE region IN (...) AND status = '...' AND date >= ... AND amount > .... На RDBMS нужны правильные индексы (и их может быть много). На ClickHouse sparse-индекс по PK справляется лучше.

  • Необходимость реалтайма: ClickHouse позволяет вставлять и вычислять агрегаты параллельно, не блокируя друг друга. На RDBMS конкуренция между чтением и записью острее.

  • Не нужна транзакционность: если запросы не требуют строгой ACID (что типично для аналитики), ClickHouse идеален. Если нужны транзакции, то RDBMS.


Загрузка данных: batch-insert, ingestion из логов/стримов

Batch-insert

ClickHouse оптимизирована под добавление данных большими пакетами (batch-insert), а не по одной строке.

Почему по одной строке — плохо:

  • Каждая вставка — это сетевой запрос, парсинг SQL, проверка схемы, поиск таблицы и т.д. Это overhead в десятки микросекунд.
  • Если вставляем 1 млн строк по одной, это 1 млн запросов и 1 млн overhead'ов. Итого: часы работы.
  • На практике производительность падает до 1 000–10 000 строк в секунду, что недопустимо для систем с высоким volume событий.

Batch-insert — правильный подход:

INSERT INTO events (event_date, user_id, amount) 
VALUES 
  ('2025-01-15', 123, 10.5),
  ('2025-01-15', 456, 20.3),
  ('2025-01-15', 789, 15.2)
-- ... ещё 999 997 строк ...

Вставляем 1 млн строк в один запрос. Overhead постоянный (примерно тот же, что для 1 строки). Результат: 100 000–1 000 000 строк в секунду (в зависимости от размера данных и сети).

Оптимальный размер батча:

  • Меньше 10K строк: overhead сети станет заметным, не будет полного использования пропускной способности.
  • 100K–1M строк: goldzone, где throughput хороший и latency приемлемая.
  • Больше 10M строк: может потребоваться больше памяти на клиенте и сервере, риск timeout'ов.

На практике размер батча выбирают между 100K и 1M в зависимости от среднего размера одной строки и требуемой latency.

Ingestion из логов и стримов

Приложения редко вставляют данные напрямую в ClickHouse. Вместо этого используется многоуровневая архитектура:

Типовая pipeline:

  1. Приложение генерирует события: микросервис логирует события в структурированном виде (JSON, протобуф и т.д.) или пишет в лог-файл.

  2. Log-collector (logstash, fluentd, filebeat): собирает логи с приложений и перенаправляет их.

  3. Message broker (Kafka, RabbitMQ, Pulsar): события буферируются. Kafka позволяет масштабировать ingestion и обеспечивает гарантии доставки.

  4. Consumer-worker: приложение, которое читает из Kafka, накапливает события в памяти (микробатч) и вставляет в ClickHouse.

  5. ClickHouse: хранит и предоставляет доступ для аналитики.

Пример с Kafka:

from kafka import KafkaConsumer
import json
import time

consumer = KafkaConsumer('events', bootstrap_servers=['localhost:9092'])
batch = []
batch_size = 100000
last_flush = time.time()

for message in consumer:
  event = json.loads(message.value)
  batch.append(event)
  
  # Вставляем, если батч полон или прошло 5 секунд
  if len(batch) >= batch_size or time.time() - last_flush > 5:
    insert_batch_to_clickhouse(batch)
    batch = []
    last_flush = time.time()

Микробатчи:

Микробатч — это промежуточный размер между одной строкой и полным batch'ем. Идея:

  • Накапливаем события в памяти потребителя (например, в течение 5 секунд).
  • Периодически вставляем накопленные события в ClickHouse (например, каждые 5 сек или когда размер > 100K).
  • Это обеспечивает latency: событие из приложения попадает в ClickHouse за 5–10 секунд, а не за часы.

Staged ingestion:

Иногда используется двухуровневый ingestion:

  1. Staging таблица: промежуточная таблица в ClickHouse, где данные временно хранятся после вставки. Это обеспечивает дедупликацию и проверку.
  2. Main таблица: основная таблица, куда данные переходят после проверки. Может использоваться материализованное view для автоматического переноса.

Такой подход имеет смысл, если нужна гарантия целостности или дополнительная обработка.

Форматы данных

ClickHouse поддерживает множество форматов для import'а и export'а:

  • CSV: простой текстовый формат, медленный для больших объёмов, но удобен для интеграции.
  • JSON: структурированный формат, удобен для логов и событий. Медленнее, чем бинарные форматы.
  • JSONEachRow: каждая строка — отдельный JSON объект. Позволяет потоковую обработку без загрузки всего файла в память.
  • Parquet: колоночный формат, компактный и быстрый. Часто используется для экспорта данных из ClickHouse или импорта из других систем.
  • ORC: аналог Parquet, также колоночный.
  • Native: внутренний формат ClickHouse, самый быстрый, но используется редко (только между ClickHouse серверами).

Выбор формата:

  • Для высокого throughput'а (тысячи запросов в сек): Parquet или бинарный формат.
  • Для удобства и интеграции: JSON или CSV.
  • Для передачи между ClickHouse серверами: Native.

Идёмпотентность и обработка ошибок

При загрузке данных возможны сбои: падение сети, перезагрузка воркера, ошибки на сервере.

Проблема: если воркер вставляет 100K строк, падает на половине и перезагружается, он заново вставляет всё 100K. В результате первые 50K вставляются дважды.

Решение — идёмпотентность:

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

Пример с ReplacingMergeTree:

CREATE TABLE events (
  event_id String,  -- глобально уникальный ID события
  event_date Date,
  user_id Int64,
  amount Float32,
  event_version Int32  -- версия события (для ReplacingMergeTree)
) ENGINE = ReplacingMergeTree(event_version)
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, event_id)

Когда вставляем одно и то же событие дважды (с одинаковым event_id и датой), ReplacingMergeTree при merge'е оставляет только последнюю версию (по event_version).

Обработка ошибок:

  • Retry-логика: если вставка не удалась, повторяем с exponential backoff (1 сек, 2 сек, 4 сек, ...).
  • Dead-letter queue: если после N попыток вставка не удаётся, пишем событие в отдельную очередь для ручного разбора.
  • Логирование: логируем каждую вставку, чтобы можно было отследить, какие данные пропали.

Чтение/запись параллельно

ClickHouse позволяет вставлять и читать параллельно без блокировок (в отличие от RDBMS, где write-lock может заблокировать читателей).

Механизм:

  • Вставки накапливаются в отдельных кусках (parts).
  • Читатели видят старые куски и новые куски одновременно.
  • Merge'я происходит в фоне и не блокирует ни читателей, ни писателей.

Следствия:

  • Можно вставлять большой объём данных, пока дашборд одновременно дёргает метрики. Никаких конфликтов.
  • Eventual consistency: аналитик может видеть данные с lag'ом в несколько минут (пока идёт merge), но это обычно приемлемо.
  • На собеседовании это плюс: "В отличие от RDBMS, ClickHouse отделяет пишущих и читающих, что позволяет масштабировать аналитику без влияния на загрузку данных".

TTL, агрегационные таблицы, материализованные view

TTL на уровне таблиц и колонок

TTL (Time To Live) — это механизм автоматического удаления или перемещения старых данных. ClickHouse поддерживает TTL на уровне таблиц и отдельных колонок.

Table-level TTL:

CREATE TABLE logs (
  timestamp DateTime,
  service_id String,
  message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY timestamp
TTL timestamp + INTERVAL 90 DAY

Здесь TTL timestamp + INTERVAL 90 DAY означает: удалять строки, чьи timestamp старше 90 дней. ClickHouse периодически запускает фоновый процесс, который:

  1. Находит куски, содержащие строки старше 90 дней.
  2. Переписывает куски, удаляя старые строки.
  3. Заменяет старые куски на новые.

Это автоматическое, не требует ручного вмешательства.

Column-level TTL:

CREATE TABLE events (
  event_date Date,
  user_id Int64,
  event_type String,
  details String,
  details_full String  -- храним полные детали, но удаляем после 30 дней
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id)
TTL event_date + INTERVAL 30 DAY GROUP BY event_date, user_id  -- общее правило удаления
-- или
-- TTL event_date + INTERVAL 7 DAY WHERE 1 FOR COLUMN details_full (DELETE)

Column-level TTL позволяет удалять конкретную колонку, оставляя остальные. Пример: хранить идентификатор пользователя навечно для аналитики, но удалять IP-адрес через 30 дней по GDPR.

Типовая стратегия для логов:

CREATE TABLE events (
  event_date Date,
  user_id Int64,
  action String,
  details String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id)
TTL event_date + INTERVAL 365 DAY  -- храним сырые данные 1 год

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

Агрегационные таблицы

Агрегационная таблица (aggregation table) — это отдельная таблица, которая хранит предрасчитанные агрегаты сырых данных.

Типовая архитектура:

  1. Raw-таблица (events): хранит все события как есть. TTL = 90 дней. По 100 ТБ в год (сырые данные быстро растут).

  2. Daily-агрегат (events_daily): хранит суммы/средние/count'ы по дням и категориям. TTL = 5 лет. По 100 ГБ в год (агрегированно много меньше).

  3. Hourly-агрегат (опционально): если нужны часовые метрики, еще одна таблица.

Пример:

Сырая таблица:

CREATE TABLE events (
  event_date Date,
  event_hour DateTime,
  user_id Int64,
  category String,
  amount Float32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, category, user_id)
TTL event_date + INTERVAL 90 DAY

Агрегированная таблица:

CREATE TABLE events_daily (
  event_date Date,
  category String,
  total_amount Float64,
  count Int64,
  avg_amount Float64,
  unique_users Int64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, category)
TTL event_date + INTERVAL 5 YEAR

Заполнение агрегата:

Используется либо scheduled job (ежедневно вычисляем агрегаты за вчерашний день), либо материализованное view (автоматически при вставке).

Пример scheduled job (на уровне идей):

# Каждый день в 00:01 UTC
SELECT event_date, category, 
       SUM(amount) as total_amount,
       COUNT(*) as count,
       AVG(amount) as avg_amount,
       uniq(user_id) as unique_users
FROM events
WHERE event_date = yesterday()
GROUP BY event_date, category
INTO events_daily

Trade-off:

  • Точность: агрегированные данные содержат только несколько ключевых метрик, теряется детализация.
  • Скорость: запросы к агрегатам выполняются в 100–1000 раз быстрее, чем к сырым данным.
  • Место: агрегированные таблицы занимают в 100–1000 раз меньше места.

На практике используют многоуровневую стратегию: сырые данные 3 месяца, daily-агрегаты 5 лет, monthly-агрегаты навечно.

Материализованные view

Материализованное представление (materialized view) — это механизм для автоматического заполнения одной таблицы на основе другой.

Как это работает:

CREATE MATERIALIZED VIEW events_daily_mv TO events_daily AS
SELECT 
  toDate(event_hour) as event_date,
  category,
  SUM(amount) as total_amount,
  COUNT(*) as count,
  AVG(amount) as avg_amount,
  uniq(user_id) as unique_users
FROM events
GROUP BY event_date, category

Теперь каждый раз, когда в таблицу events вставляются новые строки:

  1. ClickHouse выполняет SELECT из materialized view.
  2. Результаты вставляются в целевую таблицу events_daily.
  3. В целевой таблице данные объединяются (если используется SummingMergeTree или аналог) или добавляются как новые строки.

Важно: ClickHouse не сохраняет идентичность строк при обновлении. Если вставить заново данные за вчерашний день, это создаст дубликаты в целевой таблице. Решение: использовать SummingMergeTree для целевой таблицы, которая при merge'е суммирует значения с одинаковыми ключами.

Пример с SummingMergeTree:

CREATE TABLE events_daily (
  event_date Date,
  category String,
  total_amount Float64,
  count Int64,
  unique_users Int64
) ENGINE = SummingMergeTree((total_amount, count))
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, category)

Здесь SummingMergeTree((total_amount, count)) означает: при merge'е суммируй эти колонки. Если данные за день вставляются дважды (из-за ошибки или переобработки), при merge'е дубликаты автоматически суммируются.

Преимущества материализованных view:

  • Автоматизация: не нужно писать scheduled job, всё происходит в фоне.
  • Гарантия синхронности: агрегаты всегда содержат данные, которые в сырой таблице.
  • Прозрачность: новые разработчики видят связь между таблицами и понимают, откуда берутся данные.

Недостатки:

  • Если вставляются ошибочные данные, ошибки попадают и в сырую, и в агрегированную таблицу.
  • Производительность вставки может снизиться, так как ClickHouse выполняет дополнительные вычисления.

Управление жизненным циклом данных

На практике жизненный цикл данных в аналитическом хранилище выглядит так:

  1. Raw данные (детальность 100%): хранят 3–6 месяцев. TTL удаляет автоматически.

  2. Hourly агрегаты: хранят 1–2 года. Позволяют строить детальные графики за месяцы.

  3. Daily агрегаты: хранят 5–10 лет. Позволяют видеть тренды за годы.

  4. Monthly агрегаты: хранят навечно (архивируют на холодное хранилище через несколько лет). Позволяют ностальгировать и видеть исторические данные.

Управление:

  • Холодное хранилище (S3, GCS, Archive): старые партиции (старше 2 лет) перемещают на S3 или другое дешёвое облачное хранилище. Доступ медленнее, но стоимость в 10 раз ниже.

  • Реплики: свежие данные (месячные) реплицируют на несколько серверов для отказоустойчивости. Старые данные — только на одном сервере.

  • Сжатие: перед архивированием на S3 данные сжимают максимально (Zstd с высокой компрессией).

На собеседовании часто говорят:

"Мы спроектировали multi-tiered storage: сырые события храним 90 дней локально, daily-агрегаты 3 года локально, всё старше 3 лет архивируется на S3. Это позволяет держать SSD-диск небольшим и управляемым, одновременно имея исторические данные за годы для анализа".


Взаимодействие с OLTP-БД: отражение данных из MySQL/Postgres в ClickHouse

Идея разделения

На практике архитектура выглядит так:

  • OLTP-БД (MySQL, PostgreSQL): source of truth. Хранит актуальные данные для операций приложения. Быстрые queries по ключу, транзакции, консистентность.

  • ClickHouse: аналитическое зеркало. Хранит копию данных (или подмножество) из OLTP-БД для аналитики. Медленнее на точечные запросы, но быстро на агрегаты и отчёты.

Данные текут в одну сторону: из OLTP в ClickHouse. ClickHouse не пишет обратно в OLTP (это были бы аномалии и рассинхронизация).

Следствия:

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

  • Отдельные индексы: OLTP-БД имеет индексы для быстрого поиска по ID. ClickHouse имеет индексы для быстрого сканирования диапазонов. Никто не конкурирует.

  • Отдельные backup'ы и復制: OLTP-БД реплицируется для отказоустойчивости транзакций. ClickHouse реплицируется для отказоустойчивости аналитики. Независимые.

Способы передачи данных

Способ 1: Периодические выгрузки (Batch ETL)

Ежедневно или ежечасно запускается job, который:

  1. Читает новые/изменённые данные из OLTP-БД (например, таблица с updated_at > last_sync_time).
  2. Выгружает их в файл (CSV, Parquet).
  3. Загружает файл в ClickHouse.

Пример:

# Ежедневно в 02:00 UTC
mysqldump -u user -p database sales > /tmp/sales.csv \
  --where="updated_at > DATE_SUB(NOW(), INTERVAL 1 DAY)"
  
# Загружаем в ClickHouse
clickhouse-client --query="INSERT INTO sales FORMAT CSV" < /tmp/sales.csv

Плюсы: просто реализуется, понятно, стабильно.

Минусы: lag между OLTP и ClickHouse может быть часы (если job запускается раз в сутки).

Способ 2: CDC (Change Data Capture) и event-driven approach

Когда данные в OLTP-БД изменяются, это событие автоматически отправляется в промежуточное хранилище (Kafka, очередь), откуда consumer читает и пишет в ClickHouse.

На примере MySQL Binlog:

  1. MySQL логирует все изменения (INSERT, UPDATE, DELETE) в binary log (binlog).
  2. Приложение CDC (например, Debezium) читает binlog и отправляет события в Kafka: {"op": "UPDATE", "table": "users", "data": {...}}.
  3. Consumer-приложение слушает Kafka и вставляет события в ClickHouse. Если UPDATE, то либо заново вставляем строку (если используется ReplacingMergeTree), либо логируем как отдельное событие.
  4. ClickHouse имеет почти реал-тайм данные (lag 30–60 секунд).

Плюсы: реал-тайм синхронизация, не нужны тяжёлые dump'ы.

Минусы: сложнее в реализации, требует setup Kafka, Debezium или аналога, обработка ошибок нетривиальна.

Способ 3: Гибридный подход

На практике часто комбинируют оба подхода:

  • Для исторических данных (год назад): batch-выгрузка раз в неделю, хватает таких обновлений.

  • Для свежих данных (последний месяц): CDC в реал-тайм, чтобы аналитики видели самые свежие данные.

Это оптимизирует как задержку для важных данных, так и ресурсы для старых.

Согласованность (Consistency)

ClickHouse и OLTP-БД практически никогда не будут 100% синхронны. Это называется eventual consistency: в какой-то момент данные совпадут, но с lag'ом.

Типовый lag:

  • Batch ETL (ежедневно): 12–24 часа.
  • Batch ETL (ежечасно): 30–60 минут.
  • CDC (реал-тайм): 30–120 секунд.

Допустимые lag'и в зависимости от использования:

  • Дашборды для менеджмента: lag в часы приемлемо. Менеджер проверяет дашборд раз в час и ожидает, что данные актуальны на момент предыдущего часа.

  • Рекомендации для пользователей: lag в несколько минут приемлемо. Пользователь добавляет товар в корзину, и через 5 минут система об этом узнаёт.

  • Fraud-detection: lag в секунды критичен. Если транзакция мошенническая, нужно заблокировать её немедленно.

Для fraud-detection обычно используют отдельный путь: события идут напрямую в OLTP-БД, там же выполняется проверка. ClickHouse используется для ретроспективного анализа мошенничества, а не для реал-тайм защиты.

Типовые схемы синхронизации

Схема 1: Полная копия таблицы в ClickHouse

MySQL users (PK: id)
         ↓ (daily dump)
ClickHouse users (ORDER BY id)

Ежедневно выгружаем всю таблицу users из MySQL и перезаписываем таблицу в ClickHouse. Простая, но неэффективна при большой таблице.

Схема 2: Инкрементальная загрузка

MySQL users (PK: id, updated_at: timestamp)
         ↓ (WHERE updated_at > last_sync)
ClickHouse users (ENGINE = ReplacingMergeTree)

Выгружаем только изменённые строки (по updated_at), вставляем в ReplacingMergeTree. При merge'е старые версии удаляются, оставляется только свежая.

Схема 3: CDC с Kafka

MySQL binlog
         ↓ (Debezium)
Kafka topic "users"
         ↓ (Consumer-worker)
ClickHouse users (ENGINE = ReplacingMergeTree)

Любое изменение в MySQL автоматически попадает в Kafka, откуда потребитель забирает и вставляет в ClickHouse. Реал-тайм, но требует инфраструктуры.

Распределение нагрузки

OLTP-БД:

  • Все онлайн-запросы (поиск пользователя, создание заказа, обновление профиля).
  • Быстрые, мелкие операции.
  • Строгая консистентность и транзакции.

ClickHouse:

  • Все отчёты и аналитика.
  • Тяжёлые, долгие запросы (GROUP BY на миллиарды строк).
  • Eventual consistency приемлема.

Специальные случаи:

  • Поиск в большом объёме данных (например, search в каталоге товаров): может идти и в OLTP-БД (если индексы справляются), и в ClickHouse (если объём данных гигантский). Обычно используют Elasticsearch для search, но в простых случаях ClickHouse справляется.

  • Высоконагруженные счетчики (views, likes): иногда хранят в отдельной системе (Redis, DynamoDB) и периодически сбрасывают в ClickHouse для аналитики.

Что важно подчеркнуть на собеседовании

На собеседовании часто спрашивают: "Как выстраивается архитектура, когда нужны и OLTP-операции, и аналитика?"

Ответ, который выглядит профессионально:

"Система разделена на две части. OLTP-БД (MySQL/Postgres) — это source of truth для всех транзакционных операций. Она оптимизирована под быстрые point-lookups и транзакции, но не под тяжелую аналитику. Отдельно развёрнута ClickHouse как аналитическое хранилище. Данные синхронизируются из OLTP в ClickHouse batch-загрузками (или через CDC) с eventual consistency. Это позволяет отчётам и дашбордам работать на ClickHouse без влияния на production-операции в OLTP-БД. На примере: приложение ищет по ID в MySQL за миллисекунду, а BI-система строит отчёт на ClickHouse за 5 секунд, сканируя миллиарды строк, и никто друг другу не мешает".


ClickHouse и другие колоночные OLAP-БД: обобщение

Общие принципы для всех колонночных data warehouse

Колоночные OLAP-системы, несмотря на различия в деталях, работают по единым принципам:

  • Column-store: данные одного столбца хранятся рядом, что даёт компрессию и быстрое сканирование нескольких колонок.

  • Массовые сканы: запросы читают огромные объёмы строк, но выбирают малое число колонок. Оптимизация на это.

  • Тяжёлые агрегаты: GROUP BY, SUM, COUNT, AVG на миллиардах строк выполняются параллельно и быстро.

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

  • Eventual consistency: часто нет гарантий ACID на уровне отдельных транзакций. Данные логически консистентны со временем.

  • Не очень быстрый point-lookup: если нужна одна конкретная запись по ID, это может быть медленнее, чем в OLTP-БД.

Отличие разных решений на высоком уровне

ClickHouse:

  • Low-latency колоночная БД для реал-тайм аналитики.
  • Собственный формат хранения и индексирования.
  • Хорошо масштабируется горизонтально (распределённые таблицы).
  • Поддержка Kubernetes и облачного развёртывания.
  • Ориентирована на on-premise или облако как service (Yandex Cloud, ClickHouse Cloud).

BigQuery (Google):

  • Fully managed data warehouse в облаке.
  • Горизонтальная масштабируемость в масштабе петабайт.
  • SQL as a service, нет управления инфраструктурой.
  • Ценообразование за сканированные данные.
  • Интеграция с Google Cloud ecosystem (DataFlow, Looker, etc.).

Redshift (AWS):

  • Data warehouse для AWS.
  • Управляемый сервис.
  • Ценообразование за часы использования (nodes).
  • Интеграция с S3 для холодного хранилища.

Snowflake:

  • Cloud-agnostic data warehouse.
  • Хорошая поддержка масштабирования и разделения вычислений/хранилища.
  • Простая миграция между облаками.
  • Высокие цены на compute.

DuckDB, Polars (для локального анализа):

  • In-process колоночные БД для анализа больших файлов локально.
  • Не требуют отдельного сервера, запускаются прямо в приложении.
  • Хороши для разработки и малых объёмов.

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

Схожие паттерны использования

Независимо от выбора системы (ClickHouse или BigQuery), паттерны использования примерно одинаковые:

  1. Ingestion: данные из OLTP-БД, логов, событийных очередей попадают в OLAP-хранилище.

  2. Raw + Aggregated layers: хранят сырые данные для детального анализа и агрегированные данные для быстрых отчётов.

  3. TTL и архивирование: старые данные удаляются или архивируются.

  4. BI-tools: подключают Tableau, Looker, Metabase и другие инструменты для визуализации.

  5. SQL API: приложения и аналитики пишут SQL-запросы, которые выполняются параллельно.

Какие вопросы любят задавать

На собеседовании при обсуждении OLAP-БД спрашивают:

  1. "Почему для отчётов выбрали отдельную колонночную БД вместо того, чтобы всё хранить в Postgres?"

    • Ответ: масштабируемость на большие объёмы, скорость агрегатов, разделение нагрузки, отсутствие конкуренции за ресурсы между онлайн-операциями и аналитикой.
  2. "Как переносите данные из OLTP в OLAP? Какой lag приемлем?"

    • Ответ: batch-загрузки или CDC, eventual consistency, lag в часы/минуты в зависимости от требований.
  3. "Как спроектированы партиции и ключи в вашей ClickHouse/BigQuery для оптимизации запросов?"

    • Ответ: партиции по дате (удаление старых данных, параллелизм), primary key по часто фильтруемым колонкам, низкая cardinality в начале key'а.
  4. "Как вы управляете размером хранилища? Что с данными через год?"

    • Ответ: TTL удаляет сырые данные, агрегаты хранят дольше, очень старые данные архивируют на S3.
  5. "Что произойдёт, если вставить ошибочные данные в ClickHouse?"

    • Ответ: зависит от таблицы. Если ReplacingMergeTree, при следующем merge'е новая версия затрёт старую. Если обычный MergeTree, ошибочные данные останутся. Нужна идёмпотентность и проверки на уровне ingestion'а.

Краткий чек-лист по ClickHouse/OLAP-БД для собеседований

При подготовке к собеседованию на Senior Backend/DevOps позицию убедитесь, что уверенно проговариваете следующие тезисы:

Принципы и архитектура

  • Разделение OLTP/OLAP: OLTP-БД оптимизирована под точечные операции, OLAP — под массовые сканы и агрегаты. Разделение позволяет масштабировать оба без конфликтов.

  • Column-store vs row-store: в row-store'е строка лежит целиком, хорошо для OLTP. В column-store'е колонка лежит массивом, хорошо для OLAP (лучше компрессия, лучше сканирование). Для аналитики column-store в 10–100 раз быстрее.

  • Компрессия в ClickHouse: однотипные данные в колонке сжимаются в 5–20 раз (delta-encoding, dictionary, LZ4). Это упрощает хранение и ускоряет запросы.

ClickHouse-специфичное

  • MergeTree-таблицы: данные хранят в отсортированных кусках, периодически сливают. Это оптимизирует как хранение, так и чтение.

  • Партиции: разбивают таблицу по ключу (обычно по дате). Упрощают удаление старых данных, дают параллелизм, ускоряют запросы с фильтром по дате.

  • Primary key — ключ сортировки: не гарантирует уникальность, а определяет порядок данных на диске. Влияет на скорость диапазонных запросов.

  • Sparse-индексы: легковесные индексы, которые хранят по одному ключу на каждые десятки тысяч строк. Намного компактнее B-деревьев.

Паттерны использования

  • Аналитические запросы: GROUP BY по 5–10 ключам, SUM/COUNT/AVG, cohort-анализ, retention. На ClickHouse выполняются в секунды/минуты.

  • Event-storage: приложение логирует события, их буферируют и вставляют батчами. ClickHouse хранит миллиарды событий и быстро отвечает на аналитику.

  • Отчёты: ежедневные/ежечасные снимки метрик для бизнеса. На ClickHouse это работает, пока производится высокий volume ingestion'а.

  • Time-series: ClickHouse хорошо хранит временные ряды (метрики, логи с timestamp'ами) благодаря sparse-индексам по дате.

Операционные аспекты

  • Batch-insert: вставляем не по одной строке, а пакетами в 100K–1M. Это обеспечивает 100K–1M строк/сек вместо 1K–10K.

  • Ingestion из Kafka/логов: данные идут в промежуточную очередь, потом микробатчами в ClickHouse. Обеспечивает latency в минуты.

  • TTL и управление данными: автоматически удаляем старые партиции, архивируем на S3. Это сохраняет диск чистым и управляемым.

  • Агрегационные таблицы и materialized view: агрегируем сырые данные в отдельных таблицах. Агрегаты хранят дольше (5+ лет), сырые данные удаляют раньше (3–6 месяцев).

Синхронизация OLTP и OLAP

  • Source of truth — OLTP-БД: все мастер-данные там. ClickHouse — зеркало для аналитики.

  • Batch-загрузки или CDC: данные синхронизируются периодически (batch) или в реал-тайм (CDC). Eventual consistency приемлема.

  • Lag: от часов (batch раз в день) до минут/секунд (реал-тайм CDC). Зависит от требований и нагрузки.

  • Разделение нагрузки: OLTP обрабатывает онлайн-операции, ClickHouse обрабатывает отчёты. Никто друг другу не мешает.

Time series БД

Роль time-series БД в современной архитектуре

Что такое данные временных рядов

Данные временных рядов — это последовательность точек, каждая из которых содержит timestamp и значение (или набор значений). Типичная структура точки:

  • timestamp — отметка времени (мс, с, минута и т.п.)
  • value(s) — измеряемый параметр (например, температура, RPS, latency)
  • labels/tags — дополнительные разметки для привязки к объекту (сервер, сервис, пользователь, сенсор)

Важность:
Понимание структуры временных рядов критично для проектирования схем хранения, функций агрегации и построения мониторинга.

Типичные источники временных рядов

  • Метрики инфраструктуры: загрузка CPU, память, сеть, диск для серверов и VM
  • Метрики приложений: latency, error rate, количество запросов (RPS), pool utilization
  • Бизнес-события: клики, просмотры, изменения баланса, совершённые транзакции
  • IoT/Сенсоры: температура, влажность, движение, состояние устройств

Типичные формулировки:

  • "Метрики приходят с агентов на каждом сервере каждую секунду."
  • "Собираем события платежей как time-series для анализа частоты транзакций."

Почему отдельная time-series БД лучше обычной таблицы метрик

  • Профиль нагрузки: массовые постоянные insert-операции, практически нет update/delete.
  • Объём данных: гигабайты/терабайты в сутки, длинная история для трендов.
  • Запросы: агрегаты по временным интервалам, rollup, фильтрация по labels.

На собеседованиях спрашивают:

  • "Почему нельзя хранить метрики в обычной RDBMS?"
  • "Какие основные отличия профиля работы time-series storage?"

Особенности таймсерий: много записей, append-only, дешёвые insert, дорогие update

Профиль нагрузки и модель записи

  • Высокий поток вставок: вставки каждую секунду или чаще от сотен и тысяч источников.
  • Append-only: новые точки добавляются в конец ряда, история не изменяется.
  • "Time-series storage оптимизируется под быстрые массовые insert."

Дешёвые insert

  • Использование структур хранения (write-optimized сегменты, LSM-дерево, parquet/column storage), минимизация рандомного IO.
  • Последовательная запись новых точек по времени в память/диск.

Пример:

  • "Prometheus хранит очерёдность точек в chunks, что удешевляет insert."

Дорогие update/delete

  • Обновление/удаление требует поиска точки (часто — чтения сегмента, перепаковки).
  • LSM-архитектура, сегментированные/партиционированные форматы делают update/delete дорогостоящими.
  • Корректировать ошибочные данные — через новые точки-исправления ("compensating events"), а не правку истории.

Пример:

  • "В time-series БД корректировка истории неэффективна. Ошибки компенсируются новыми точками с маркерами correction."

Влияние append-only модели на дизайн

  • Ошибки, исправления: добавление новых исправляющих значений, логика компенсации.
  • Агрегаты: история не переписывается, вычисляются агрегаты (например, среднее за сутки).
  • Хранение истории: объём постоянно растёт — необходимость retention и downsampling.

Типичные высказывания:

  • "В time-series обновление дорогo. Всё заточено на append-only нагрузку."

Объёмы данных и жизненный цикл

  • Быстрый рост хранилища за счёт постоянного потока событий/метрик.
  • Retention: ограничения по длительности хранения (7 дней поинтов с детализацией в 1s, год — с агрегацией по часу).
  • Downsampling: агрегирование старых данных, предотвращение переполнения хранилища.

Модели хранения временных рядов

Общая идея модели

  • Ключ — определяет уникальную серию: например, (метрика, сервис, сервер, data center)
  • Точки ряда — упорядочены по времени (timestamp)
  • Хранилище оптимизируется под "time + labels → значения за интервал"

Prometheus-подобная модель

  • Сущность: метрика + labels (key=value) → уникальный time-series
  • Sample: timestamp + value
  • label-based: гибкая фильтрация, расширяемая схема labels (service, instance, region, environment, status)
  • Фильтрация: выборка рядов по любому набору labels, агрегация по метрике/label-ключу
  • "Prometheus строит серию пересечением метрики и набора labels."

Преимущества:

  • Логичная для мониторинга (метрика есть у всех инстансов).
  • Легко добавлять новые меры/labels без изменения схемы.

InfluxDB-подобная модель

  • Measurement: тип метрик (например, cpu_usage, http_requests)
  • Tags: индексируемые labels (host, region, app)
  • Fields: значения (value, latency, rps)
  • Point: timestamp + fields + tags

Особенности:

  • Tags попадают в индекс (быстрые фильтры),
  • Fields — только значение (для экономии места).
  • Удобно для агрегатов по времени+tags.

Пример формулировки:

  • "InfluxDB разделяет tags и fields для гибкости индексирования."

Timescale-подход (SQL-расширение)

  • HyperTable: логическая "гипертаблица" поверх обычной таблицы (Postgres)
  • Партиционирование: по времени (time) и, опционально, по ключу (space, например, device_id)
  • Плюсы: стандартный SQL-интерфейс, реляционная интеграция, возможность join’ов.

Пример:

  • "Timescale даёт возможность использовать привычный SQL, сохраняя преимущества партиционирования и высокой скорости вставки."

Общее между моделями

  • Основная ось: time + labels/tags
  • Фокус: эффективные массовые insert, rollup/агрегации по времени, быстрая фильтрация по ключам.

Компрессия, retention-политики и downsampling

Компрессия

  • Сжимаемость: значения часто коррелированы, редко меняются ступенчато;
  • Алгоритмы: delta-кодирование (разность между соседними значениями), run-length encoding (повторы), специальные форматы для timestamp’ов (разницы/массивы), Gorilla, Zstd.
  • Зачем: снижение расходов на диск/IO, увеличение retention без потерь в производительности запросов.

Формулировка:

  • "Временные ряды прекрасно ужимаются дельта- и run-length кодированием."

Retention-политики

  • Периоды хранения:

    • «Горячие» данные — с детализацией (обычно 7–30 дней)
    • Архив («тёплый»/«холодный» слой) — с агрегацией, без детализации (месяцы/годы)
  • Автоматизация: автоматическое удаление/архивация старых данных по age/size/label/policy.

  • Уровни хранения: горячий слой — SSD/оперативка, тёплый — HDD/облако, архив — S3/Glacier.

Примеры:

  • "Raw-метрики держим семь дней, агрегаты — три года с шагом по часу."

Downsampling

  • Агрегация: преобразование детализированных данных в агрегированные (например, 1s → 1m → 1h)
  • Методы: AVG/MIN/MAX/SUM/PERCENTILE на интервале окна для каждой серии.
  • Использование: дашборды, отчёты, долгосрочная аналитика.

Выражения:

  • "Downsampling критичен для роста retention без увеличения объёмов хранения."

Баланс детализация/стоимость

  • Какие метрики хранить детально: latency, error rate в SLA-критичных сервисах.
  • Какие агрегировать: массовые логи, background events — можно downsample.
  • Архитектурное обоснование: требования к детализации — часть SLO/SLA, регулируются политиками retention.

Типовые запросы в time-series БД

Агрегаты по времени

  • Time-bucket aggregation: агрегация значений по фиксированным окнам (1m, 5m, 1h)
  • Функции: avg, min, max, sum, count, percentiles (p95/p99)
  • Сравнение периодов: week over week, month over month для выявления трендов

Пример:

  • "Среднее latency по 5-минутным окнам для прошлого месяца."

Rollups

  • Агрегированные ряды: данные по бОльшим интервалам (например, час, сутки)
  • Зачем: быстрый доступ/отрисовка графиков, экономия на детализации
  • Построение: автоматический процесс каждой N минут/час

Типичные запросы:

  • "Построить график по hour-rollup за год."

Срез по labels/tags

  • Фильтрация: по сервису, региону, типу метрики, error_code
  • Группировка: by label — агрегации по dimension (например, по сервису, по error_type)
  • "Оценить error rate по разным кластерам."

Поиск аномалий

  • Пики и провалы: резкие отклонения от нормы (spike detection)
  • Тренды и сезонность: анализ происходящего с учётом дней недели, времени суток
  • Использование: алертинг, capacity planning

Формулировка:

  • "Time-series нужны для оперативного поиска аномалий в поведении системы."

Корреляция между рядами

  • Сопоставление трендов: сравнение разных метрик между сервисами, слоями (например, latency vs error rate)
  • Выявление причинно-следственных связей: рост latency API → рост ошибок payment backend
  • "Корреляция рядов — ключ к диагностике инцидентов."

Почему в RDBMS реализовать сложно

  • Объём: с growth ряды быстро достигают десятков миллиардов записей.
  • Количество окон: агрегация по времени, сложная фильтрация, множественные dimension требуют спец. индексов.
  • Latency: обычный индекс не тянет агрегации/rollups; нагрузка на OLTP storage.

Как time-series БД дополняют основное хранилище в архитектуре

Разделение ролей

  • OLTP (RDBMS/NoSQL): бизнес-данные, транзакции, CRUD, консистентность
  • Time-series БД: метрики, системные/прикладные события, отдельные бизнес-таймсерии для аналитики, алертинга

Пример формулировки:

  • "Отделяя time-series storage, не нагружаем основной OLTP-монолит метриками."

Путь данных

  • Сервисы → метрики → time-series БД
  • Бизнес-события пишутся: и в OLTP (оперативный учёт), и в time-series (аналитика трендов)
  • Дедупликация/выбор слоёв: критичные события и туда и туда, менее важные — только в time-series

Архитектурные паттерны

  • Observability-стек:

    • Метрики → time-series БД,
    • Логи → search store (ELK, Loki),
    • Трейсы → tracing (Jaeger, Zipkin)
  • Корреляция: по traceId, requestId, labels для end-to-end анализа

  • Раскладка слоёв: шкалируемость, изоляция нагрузки

Выражения:

  • "Observability разбивается по слоям: метрики — time-series БД, логи — отдельное хранилище."

Использование time-series данных

  • Алертинг по SLO: latency, error rate, availability — настраиваются пороги, триггеры алертов
  • Capacity planning: прогнозирование загрузки, планирование расширения мощностей
  • Бизнес-аналитика: конверсия, вовлечённость, пользовательское поведение во времени

Связь с системным дизайном

  • Описание на собеседовании: observability слой с отдельной time-series БД для метрик, лог-сторы для логов, трейсинг для полного разбора запросов
  • Обоснование: OLTP-хранилище не нагружается побочными событиями, time-series БД проектируется для своей специфики

Time-series БД и Java backend

Интеграция и экспорт метрик

  • Экспорт метрик:

    • Pull-модель: scraper опрашивает endpoint (Prometheus)
    • Push-модель: сервис отправляет метрики (InfluxDB, pushgateway)
  • Типы метрик на уровне сервиса: JVM (GC, heap, threads), HTTP (latency, error rate), база (pool stats), бизнес-метрики

Примеры:

  • "Spring Boot actuator предоставляет endpoint для метрик."
  • "Micrometer поддерживает экспортеры в Prometheus, InfluxDB и др."

Объём и частота

  • Гранулярность: каждую секунду/min для SLA-метрик, каждую минуту для фона
  • Компромисс: объём метрик/частота влияет на нагрузку и стоимость хранения

Типовые ответы:

  • "Выбор частоты сбора метрик — компромисс между детализацией и overhead."

Обработка отказов

  • Недоступность time-series БД:

    • Drop метрик (можно потерять часть данных, но изолировать приложение)
    • Буферизация во временном storage (in-memory/kafka)
    • Деградация объёма/детализации (запись только базовых)
  • Риски: рост памяти в приложении, всплеск нагрузки при восстановлении БД

Observability как часть эксплуатации

  • Использование временных рядов: диагностика инцидентов, анализ аномалий, бенчмаркинг релизов
  • Связь изменений: релизы → shift в метриках, переходы на новые версии → пиковые нагрузки

Краткий чек-лист по time-series БД для собеседований

  • Данные временных рядов — это последовательные точки (timestamp + value), часто привязанные к объекту/метке.

  • Time-series отличаются от типовых CRUD-данных массовыми append-only вставками и минимальными изменениями истории.

  • Дешёвые insert — ядро архитектуры; update/delete в истории — дорогие, баланс достигается через append-only модель и дополнительные точки-исправления.

  • Основные модели хранения:

    • Prometheus: метрика + labels → unique time-series, фильтрация и агрегация гибкие, модель логична для мониторинга.
    • InfluxDB: measurement, tags, fields; разделение tags/fields ускоряет фильтрацию и агрегаты.
    • Timescale: гипертаблицы, партиционирование (time/space), + SQL.
  • Компрессия использует специфические алгоритмы (delta, RLE), retention-политики регулируют долговечность данных, downsampling снижает стоимость хранения без потери трендов.

  • Типовые запросы: агрегаты по времени (rolling window, bucket), rollups, фильтрация по labels/tags, поиск аномалий, корреляция рядов — всё оптимизировано для append-only модели.

  • Time-series БД дополняет OLTP-хранилище:

    • позволяет вынести нагрузку событий/метрик,
    • делает observability слой независимым,
    • ускоряет диагностику и capacity planning.
  • В системном дизайне: выделение отдельного слоя для time-series метрик — стандарт современной архитектуры. Observability строится отдельным мониторингом событий, трассировок и логов с интеграцией по traceId/labels.

  • Для java backend: интеграция через actuator/micrometer, сбор метрик, настройка ретенции и агрегации, graceful handling отказов, traceability до бизнес-метрик.


Использование нескольких БД

Идея polyglot persistence: правильный инструмент под каждую задачу

Что означает polyglot persistence

Polyglot persistence — это подход к проектированию хранилищ данных, при котором в одной системе сосуществуют несколько типов хранилищ, оптимизированных под разные задачи. В отличие от традиционного подхода, когда вся информация сваливается в одну реляционную БД, здесь каждое хранилище отвечает за свой профиль нагрузки и требований.

Классическая архитектура polyglot persistence включает:

  • RDBMS (MySQL, PostgreSQL) как основное транзакционное хранилище с сильными гарантиями консистентности
  • Key-value store (Redis, Memcached) для быстрого кеша горячих данных
  • Search engine (Elasticsearch, OpenSearch) для полнотекстового поиска и фасетной навигации
  • OLAP warehouse (ClickHouse, BigQuery) для аналитических запросов и отчётности
  • Time-series DB (InfluxDB, Prometheus) для метрик и мониторинга
  • Document store (MongoDB) для гибкого хранения полуструктурированных данных

Ключевой момент: это не просто «давайте используем много БД», а сознательный выбор инструмента для каждого сценария использования. На собеседовании это звучит примерно так: «Мы выбрали PostgreSQL как source of truth для операций, Redis для кеша горячего контента, потому что его latency на порядок ниже, и Elasticsearch для поиска, так как SQL-запросы типа LIKE со специфичностью вроде нечётких совпадений будут медленными в классической RDBMS».

Почему монолитный подход перестаёт работать в сложных системах

Когда система растёт, у разных операций появляются противоречивые требования:

  • OLTP-операции нуждаются в низкой latency и консистентности, но идут часто и с небольшими объёмами данных. Нужны индексы на отдельные строки, быстрый доступ по первичному ключу, гарантия ACID.
  • Аналитические запросы обрабатывают миллионы строк, производят сложные агрегации, полносканируют таблицы. На OLTP-базе такой запрос заблокирует весь пул соединений.
  • Полнотекстовый поиск требует специфичных алгоритмов, инвертированных индексов, поддержки морфологии и синонимов — это не реальная сила SQL.
  • Метрики и логи поступают в огромных объёмах, имеют временную природу, требуют быстрого инжеста, но не нуждаются в ACID-гарантиях.
  • Горячие данные должны быть доступны с latency в миллисекунды, но классической RDBMS нужна минимум сетевая задержка и парсинг SQL.

При попытке решить всё в одной RDBMS происходит следующее:

  • Поисковые запросы full-scan'ят таблицы и замораживают OLTP
  • Аналитические отчёты конкурируют с основным трафиком за ресурсы
  • Кеширование правит бал за счёт кода приложения (N+1, некогерентные кеши)
  • Логирование и метрики либо тормозят основную БД, либо вообще не логируются

Примеры задач, которые плохо решать одним инструментом

Полнотекстовый поиск в чистом SQL: если нужен поиск по множеству полей с релевантностью, синонимами и морфологией, SQL уже не годится. Запрос вроде SELECT * FROM products WHERE name LIKE '%ноутбук%' OR description LIKE '%ноут%' не учитывает релевантность, не работает с синонимами (ноутбук = лэптоп), и на таблице с миллионом строк это будет крайне медленно. Elasticsearch решает это естественно.

Тяжёлые аналитические запросы на боевой OLTP-БД: аналитик хочет посчитать, сколько заказов пришло по каждому городу в каждый день за последний год. Это требует full-scan миллионов строк с группировкой и сортировкой. Если запустить это на production PostgreSQL, очередь запросов от пользователей повисла. ClickHouse или другая OLAP-система пересчитает это в секунды за счёт column-oriented storage и сжатия.

Метрики и логи в классической RDBMS: если логировать каждый HTTP-запрос в PostgreSQL, то при нагрузке в 10k rps это будет 860 миллионов записей в день. RDBMS будет тратить ресурсы на ACID-гарантии и индексы, которые не нужны логам. Prometheus или InfluxDB инжестят такие объёмы естественно, потому что оптимизированы под write-heavy, time-series сценарии.

Гибкая схема и частые изменения структуры: если продакт требует, чтобы клиент сам добавлял новые поля в свой профиль или заказ, реляционная схема становится кошмаром (либо generic JSONB колонка, либо постоянные миграции). MongoDB с document-store моделью позволяет хранить документы разной структуры без миграций.

Базовый принцип: хранение проектируется от сценариев использования

«Правильный инструмент под каждую задачу» означает, что вы начинаете не с вопроса «какую БД выбрать», а с вопроса: «какие есть сценарии использования и к каким данным?» Затем для каждого сценария выбираете хранилище.

Примеры формулировок на собеседовании:

  • «У нас есть три основных сценария: CRUD операции по заказам (RDBMS), поиск заказов по тексту и фильтрам (Elasticsearch), и аналитика доходов по дням и городам (ClickHouse). Каждый инструмент оптимален для своего сценария».
  • «Кеш находится в памяти (Redis), потому что latency критичен — это горячие данные, которые нужны быстро. Search-индекс хранится в Elasticsearch, потому что он умеет работать с текстом лучше SQL. Исторические данные для отчётов идут в warehouse».

Базовые принципы дизайна при polyglot persistence

Разделение ролей между хранилищами

Каждое хранилище в системе имеет чёткую роль:

Transactional store (RDBMS, например PostgreSQL или MySQL)

  • Хранит состояние бизнес-сущностей: заказы, пользователи, платежи, инвентарь
  • Обеспечивает ACID-гарантии и консистентность данных
  • Отвечает за бизнес-инварианты: сумма товаров = количество * цена, баланс не может быть отрицательным
  • Является source of truth для оперативных операций
  • Нагрузка: OLTP (Online Transaction Processing) — много операций, каждая малого объёма

Cache (Redis, Memcached)

  • Ускоряет горячие чтения: данные пользователя, каталог товаров, рекомендации
  • Хранит сессии, одноразовые токены, rate-limiting счётчики
  • Не является источником истины: если кеш упал, данные остаются в RDBMS
  • Эфемерное хранилище: могут теряться данные при перезагрузке
  • Nагрузка: высокая скорость доступа (key-value lookups в памяти)

Search index (Elasticsearch, OpenSearch)

  • Полнотекстовый поиск: поиск по названию товара, тексту статьи, логам
  • Фасетная навигация: фильтры по цене, категории, размеру
  • Не является source of truth: индекс можно пересобрать из source of truth
  • Хранит денормализованные документы, оптимизированные под поиск
  • Нагрузка: высокая скорость поиска и фильтрации, read-heavy

OLAP warehouse (ClickHouse, BigQuery, Redshift)

  • Аналитика и отчётность: агрегации по времени, географии, сегментам
  • Тяжёлые запросы: полносканирование больших таблиц, группировки, сортировки
  • Может храниться с лагом: данные в warehouse могут быть на день или час позади operational store
  • Оптимизирована под read-heavy с большими объёмами данных
  • Нагрузка: OLAP (Online Analytical Processing) — редкие запросы, каждый обрабатывает миллионы строк

Time-series DB (InfluxDB, Prometheus, VictoriaMetrics)

  • Метрики и мониторинг: CPU, memory, latency, custom application metrics
  • Логи структурированного формата с timestamp'ами
  • Write-heavy, time-based retention (удаление старых данных)
  • Не нуждается в ACID-гарантиях
  • Нагрузка: очень быстрый инжест большого объёма, быстрые запросы по time range'ам

Document store (MongoDB, DynamoDB)

  • Гибкое хранение объектов с неоднородной структурой
  • Агрегаты: контент с вложенными комментариями, профили с историей действий
  • Может быть как основным хранилищем (если не нужны сложные связи), так и проекцией
  • Нагрузка: балансируется между OLTP и flexibility

Минимизация числа источников правды

Ключевое правило: на каждый кусок бизнес-модели должен быть ровно один source of truth.

Source of truth — это хранилище, которому вы доверяете для данного аспекта. Это откуда начинается истина. Если есть конфликт между несколькими хранилищами, source of truth всегда побеждает.

Примеры:

  • Source of truth для состояния заказа — PostgreSQL. Заказ создаётся, обновляется, удаляется в PostgreSQL. Elasticsearch имеет копию этого заказа для поиска, но это проекция. Redis может кешировать детали заказа, но это тоже проекция.
  • Source of truth для метрик приложения — Prometheus. Сервисы пишут метрики туда. История метрик хранится в долгосрочном хранилище (типа VictoriaMetrics), но это проекция.

Типичная ошибка: когда данные обновляются в нескольких БД одновременно, и никто не знает, какая из них главная. Например, кешируют заказ в Redis, потом обновляют его в PostgreSQL, но забывают инвалидировать кеш. Через неделю оказывается, что в Redis лежит старый заказ, в PostgreSQL новый — и никто не знает, кто должен быть главным.

Явные проекции и индексы

Проекция — это представление данных из одного хранилища, оптимизированное под конкретный сценарий.

Примеры проекций:

  • Elasticsearch индекс — это проекция RDBMS, денормализованная и переиндексированная для поиска
  • Redis кеш — это проекция горячей части RDBMS
  • ClickHouse таблица — это проекция RDBMS, переформатированная под аналитику
  • Materialized view в RDBMS — это тоже проекция (предсчитанная агрегация)

Правило для Senior: всегда чётко определить, какие данные в какой БД являются проекциями. Это нужно для восстановления после сбоев и для понимания потоков данных.

На диаграмме это выглядит примерно так:

PostgreSQL (source of truth)
    ↓ (события изменений)
    ├→ Redis (проекция: горячие данные)
    ├→ Elasticsearch (проекция: поисковые документы)
    └→ ClickHouse (проекция: аналитические таблицы)

Если один из вторичных хранилищ упал или сломался, всегда можно пересобрать проекцию заново из source of truth. Это критично для надёжности.

Event-driven мышление

Polyglot persistence естественно ведёт к event-driven архитектуре, где события — это основной механизм синхронизации между БД.

Как это работает:

  1. Пользователь создаёт новый заказ → это записывается в PostgreSQL

  2. PostgreSQL генерирует событие OrderCreated

  3. Это событие поступает в очередь или event stream (Kafka, RabbitMQ, AWS Kinesis)

  4. Разные обработчики подписаны на это событие:

    • Обработчик кеша слушает OrderCreated и добавляет заказ в Redis
    • Обработчик поиска слушает и добавляет индекс в Elasticsearch
    • Обработчик аналитики слушает и добавляет запись в ClickHouse
    • Обработчик уведомлений слушает и отправляет email

Это децентрализованный подход, где каждая система «знает» о своей ответственности.

Альтернатива (синтетическая): после записи в PostgreSQL приложение явно говорит Redis'у, Elasticsearch'у и ClickHouse'у обновиться. Это более хрупко, потому что нужно помнить обновить всех.

Event-driven подход более масштабируемый: если добавится новое хранилище (например, отправка аналитики в Mixpanel), просто добавляем нового слушателя события, не трогая основной код.

Типичная связка 1: RDBMS + Redis

Роли в связке

PostgreSQL/MySQL:

  • Основное, надёжное хранилище состояния системы
  • Где лежат заказы, пользователи, все критичные данные
  • Гарантирует ACID
  • Source of truth

Redis:

  • Быстрый кеш
  • В памяти, поэтому latency в миллисекунды
  • Может исчезнуть при перезагрузке (или при достижении memory limit)
  • Проекция горячих данных из RDBMS

Что хранится в RDBMS

  • Состояние бизнес-объектов: статус заказа (создан, оплачен, отправлен, доставлен), баланс счёта
  • Транзакции и история: хронология платежей, логи изменения статуса
  • Сложные связи: пользователь имеет много заказов, каждый заказ имеет много товаров, каждый товар имеет категорию
  • Инварианты: total_price = sum(item.price * item.quantity), баланс не может быть отрицательным
  • Нормализованная схема: таблицы отнормализованы до 3NF+, чтобы избежать аномалий обновления

Что хранится в Redis

  • Горячие детали: полная информация о активных пользователях, популярные товары каталога, их цены
  • Сессии: JWT токены, session cookies, refresh tokens с TTL
  • Одноразовые коды: коды верификации email, reset password tokens
  • Rate limiting: счётчики запросов на IP/пользователя
  • Инфраструктурные счётчики: количество активных соединений, очередь задач

Стратегии кеширования

Cache-aside (Lazy Loading):

GET запрос пришёл
  если есть в Redis → вернуть из Redis
  если нет в Redis → получить из PostgreSQL
                  → записать в Redis с TTL
                  → вернуть клиенту

Это самая популярная стратегия. Кеш нестрогий: если ключа нет, загружаем из БД. Если данные в БД изменились, кеш на время остаётся старым.

Write-through:

PUT запрос пришёл
  обновить в PostgreSQL
  обновить в Redis
  вернуть клиенту

Гарантирует, что кеш всегда согласован с БД. Минус: если Redis недоступен, теряем весь запрос.

TTL и инвалидация:

  • Каждый ключ в Redis имеет TTL (time to live). После истечения ключ удаляется. На собеседовании: «Мы кешируем данные пользователя с TTL 5 минут. Если пользователь изменил профиль, кеш станет старым на до 5 минут. Это приемлемый trade-off: большинство пользователей не обновляют профиль каждый день».
  • Инвалидация по событиям: при обновлении заказа в PostgreSQL генерируется событие, которое явно удаляет ключ заказа из Redis. Новый запрос загрузит свежую версию из БД. Это гарантирует свежесть, но требует дополнительной логики.

Типичные проблемы и как их решать

Устаревшие данные в кеше:

  • Проблема: пользователь обновил профиль в одном браузере, в другом браузере видит старые данные из кеша
  • Решение: либо инвалидация по событиям (удалить кеш при обновлении), либо корректная TTL и понимание того, что лаг приемлем
  • На собеседовании: «У нас горячие данные в кеше с TTL, мы принимаем что могут быть устаревшими на N секунд, потому что это даёт нам 10x выигрыш в latency. Критичные данные вроде статуса платежа мы либо инвалидируем сразу, либо вообще не кешируем».

Cache stampede (thundering herd):

  • Проблема: популярный ключ пропадает из Redis (истёк TTL или кеш упал). 1000 одновременных запросов не нашли ключ и все одновременно полезли в PostgreSQL. Произошла «гроза» запросов к БД.

  • Решение:

    • использовать стратегию «probabilistic early expiration» — обновлять кеш ещё до истечения TTL
    • использовать lock'и: первый запрос, не найдя ключ, получает lock на пересчёт, остальные ждут результата
    • использовать более длинный TTL для популярных ключей
  • На собеседовании: «Cache stampede может убить БД. Если популярный ключ пропадает, все пользователи одновременно загружают данные из БД. Мы защищаемся через probabilistic expiration и lock'ы».

Hot keys:

  • Проблема: один ключ (например, глобальный счётчик действий) получает 100k запросов в секунду, что перегружает единственный Redis узел

  • Решение:

    • распределить горячие ключи по нескольким копиям или shard'ам
    • использовать локальный кеш в памяти приложения для очень горячих keys
    • батчить: не инкрементировать счётчик каждый раз, а накапливать в памяти приложения и писать в Redis раз в секунду
  • На собеседовании: «Горячие ключи требуют особой стратегии. Если счётчик получает миллионы запросов, Redis может стать узким местом. Решаем через батчинг, локальный кеш или репликацию ключа».

Как коротко описать эту связку на собеседовании

«PostgreSQL — source of truth, где хранится всё состояние системы. Redis — это слой ускорения, кеш горячих данных. Для критичных операций мы либо не кешируем, либо инвалидируем кеш сразу при изменении. Для некритичных данных используем TTL с инвалидацией по событиям. Cache stampede защищаем через lock'ы и probabilistic expiration. Если Redis упал, система продолжает работать, просто медленнее».»

Типичная связка 2: RDBMS + Elasticsearch / OpenSearch

Роли в связке

PostgreSQL/MySQL:

  • Основное хранилище сущностей
  • Source of truth для состояния, транзакций, инвариантов
  • Нормализованная, консистентная схема

Elasticsearch / OpenSearch:

  • Полнотекстовый поиск и фасетная навигация
  • Индекс для быстрого поиска по тексту
  • Отображение (projection) из RDBMS, денормализованное под поиск
  • Может пересобраться заново из RDBMS

Что хранится в RDBMS

  • Нормализованные таблицы: products, categories, reviews
  • Связи между сущностями (product → category, review → product → user)
  • История изменений: когда был создан товар, когда обновлена цена
  • Бизнес-инварианты: рейтинг товара вычисляется как average(review.rating)

Что хранится в Elasticsearch

  • Денормализованные документы, оптимизированные под поиск
  • Пример: один документ может содержать название товара, описание, все отзывы, категорию, цену, производителя — всё в одном месте
  • Инвертированные индексы для быстрого полнотекстового поиска
  • Поля для фасетов: brand (keyword), price_range, rating, в_наличии (boolean)
  • Синонимы и морфология: поиск по «ноутбук» найдёт и «лаптоп», и «компьютер»

Модель данных: проекционные документы

При индексации документа из RDBMS в Elasticsearch происходит трансформация. Вместо нормализованной структуры данных создаётся денормализованный документ.

Пример:

RDBMS:

products: { id, name, category_id, price, created_at }
categories: { id, name }
reviews: { id, product_id, user_id, rating, text }

Elasticsearch документ для product:

{
  "id": 123,
  "name": "MacBook Air M2",
  "category": "Ноутбуки",
  "price": 150000,
  "rating_average": 4.7,
  "reviews_count": 245,
  "brand": "Apple",
  "in_stock": true,
  "last_review_text": "Отличный ноутбук, быстрый",
  "all_reviews": [
    { "rating": 5, "text": "Супер", "author": "user1" },
    { "rating": 4, "text": "Хорош", "author": "user2" }
  ],
  "created_at": "2023-01-15"
}

Документ содержит информацию из нескольких таблиц RDBMS, она денормализована и готова к поиску. Это проекция — при изменении исходных данных документ нужно обновить.

Синхронизация RDBMS и Elasticsearch

Синхронная индексация (Near Real-time):

Пользователь создаёт товар в приложении
  ↓
Записываем в PostgreSQL
  ↓
Генерируем событие (или сразу индексируем)
  ↓
Индексируем/обновляем в Elasticsearch
  ↓
Возвращаем ответ пользователю

Плюсы: данные в поиске практически сразу видны Минусы: если Elasticsearch недоступен, операция падает (нужна обработка ошибок и retry)

Асинхронная индексация (через очередь/CDC):

Пользователь создаёт товар
  ↓
Записываем в PostgreSQL
  ↓
Возвращаем ответ пользователю
  ↓
(отдельный процесс слушает CDC или очередь)
  ↓
Индексируем товар в Elasticsearch

Плюсы: основная операция не зависит от Elasticsearch, масштабируется лучше Минусы: лаг между созданием и появлением в поиске (обычно секунды-минуты)

CDC (Change Data Capture):

PostgreSQL имеет логи изменений (WAL — Write-Ahead Log). Специальные инструменты (Debezium, pgoutput) читают эти логи и превращают их в события:

PostgreSQL WAL (логи изменений)
  ↓
Debezium CDC (читает логи)
  ↓
Kafka topic (events: ProductCreated, ProductUpdated, ProductDeleted)
  ↓
Elasticsearch indexer (слушает topic, обновляет индексы)

Это надёжно: если индексер упал, он может восстановиться с того же места в логе.

Особенности связки RDBMS + ES

Eventual consistency:

Между RDBMS и Elasticsearch может быть лаг в несколько секунд. Новый товар создан в PostgreSQL, но в поиске появится через 2 секунды. Это нормально и приемлемо для большинства случаев.

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

Пересборка индекса:

Индекс Elasticsearch — это проекция, которую можно пересобрать заново. Если индекс упал, повредился или стал рассинхронизированным, вы запускаете переиндексацию:

FOR each product in PostgreSQL:
  CREATE Elasticsearch document
  INDEX it

Это может занять время (часы для миллиардов документов), но ничего не потеряется.

На собеседовании про RDBMS + ES

«PostgreSQL — source of truth, Elasticsearch — это проекция для поиска. Синхронизация может быть синхронной (надёжнее) или асинхронной через CDC (масштабируемее). Между ними возможен лаг — это eventual consistency, и это приемлемо. Если Elasticsearch поломался, мы пересобираем индекс из PostgreSQL. Мы не храним критичные данные только в ES».»

Типичная связка 3: OLTP (MySQL/Postgres) + OLAP (ClickHouse/warehouse)

Роли в связке

OLTP база (PostgreSQL, MySQL, MongoDB):

  • Online Transaction Processing — обслуживание текущих операций
  • Создание, обновление, удаление записей в реальном времени
  • Быстрые точечные запросы по ключам
  • Source of truth для оперативных данных

OLAP warehouse (ClickHouse, BigQuery, Redshift, Snowflake):

  • Online Analytical Processing — аналитика и отчётность
  • Тяжёлые агрегирующие запросы
  • Историческое хранилище
  • Может содержать данные с лагом в часы или дни

Потоки данных и синхронизация

Данные текут в одном направлении: OLTP → OLAP. Это не синхронное обновление, а периодическая или стриминговая загрузка.

Batch ETL (периодическая выгрузка):

Каждый час или день запускается процесс:

1. Выгрузить все изменения из OLTP за последний период
2. Преобразовать их в формат OLAP (денормализация, конвертация типов)
3. Загрузить в OLAP warehouse
4. Перестроить денормализованные таблицы и materialized views

Плюсы: простая реализация, легко контролировать нагрузку на OLTP Минусы: лаг между данными в OLTP и OLAP (от часа до суток)

Streaming ETL (реал-тайм потоки):

При каждом изменении в OLTP данные стримятся в OLAP:

Event в OLTP (INSERT/UPDATE/DELETE)
  ↓
CDC или application event
  ↓
Kafka/Kinesis stream
  ↓
ClickHouse Consumer Group
  ↓
Real-time вставка в OLAP

Плюсы: данные в warehouse практически сразу Минусы: сложнее реализовать, нужно следить за обработкой ошибок

Типы задач, решаемые OLTP и OLAP

OLTP (PostgreSQL):

  • Пользователь создаёт заказ → INSERT в таблицу orders
  • Пользователь смотрит свой баланс → SELECT по user_id
  • Система проверяет инвариант (баланс >= 0) → быстрый SELECT для проверки
  • Операция занимает миллисекунды, идёт часто

OLAP (ClickHouse/BigQuery):

  • Отчёт: сколько заказов было каждый день в каждом городе за 2024 год
  • Это 365 дней × 1000 городов × 1m заказов = 365 млрд строк для анализа
  • Full-scan таблицы с группировкой, сортировкой, вычисление агрегатов
  • На OLTP это замёрзнет, на OLAP это займёт 5-10 секунд
  • Запрос редкий (раз в день), но каждый обрабатывает миллионы строк

Time-based партиционирование в OLAP

OLAP warehouse хранит исторические данные, поэтому таблицы партиционируются по времени.

Пример ClickHouse:

CREATE TABLE orders (
  id UInt64,
  user_id UInt64,
  amount Decimal(10, 2),
  created_at DateTime,
  date Date
)
ENGINE = MergeTree()
ORDER BY (created_at, user_id)
PARTITION BY toYYYYMM(date)

Таблица разбита на партиции по месяцам. При запросе за конкретный период ClickHouse автоматически сканирует только нужные партиции, остальные игнорирует. Это критично для производительности.

Инструменты синка данных

Batch инструменты:

  • SQL скрипты: напрямую копируют данные, INSERT INTO warehouse SELECT FROM oltp
  • Apache Airflow: оркестрирует ETL dag'и, может быть расписание или триггеры
  • AWS Glue: managed ETL сервис от Amazon
  • dbt (data build tool): современный подход, где трансформации описаны как SQL-модели

Streaming инструменты:

  • Apache Kafka + Consumer: читаем события из Kafka, пишем в warehouse
  • Debezium: CDC из OLTP, отправляет события в Kafka
  • Apache Flink / Spark Streaming: обработка потоков, трансформация данных
  • ClickHouse Table Engine kafka: ClickHouse сам может слушать Kafka тему

Консистентность между OLTP и OLAP

Лаги — это нормально:

Если отчёт строится раз в день, то лаг может быть от нескольких часов. Данные в warehouse будут на 6-12 часов старше, чем в OLTP. Это приемлемо.

На собеседовании: «Наша аналитика в ClickHouse обновляется каждый час. Если пользователь создал заказ в 14:30, в отчётах он появится в 15:30 или 16:30. Это нормально, потому что отчёты проверяют раз в день или раз в час. Если нужна точная цифра прямо сейчас, мы идём в production БД».

Обработка позднего прихода данных:

Иногда данные приходят не в порядке: заказ создан в 14:00, но в warehouse загрузился только в 15:30, при этом другой заказ, созданный в 14:15, загрузился в 15:20. Некоторые OLAP хранилища (ClickHouse, BigQuery) имеют встроенные инструменты для переообработки данных за прошлые периоды.

Разделение нагрузки между OLTP и OLAP

Защита OLTP:

Тяжёлые аналитические запросы не должны идти в production OLTP, потому что они:

  • Полностью сканируют таблицы (no index)
  • Блокируют пулы соединений
  • Конкурируют за CPU и IO с бизнес-операциями

Решение: запреты в приложении, read-only реплики, отдельный сервер для аналитики.

Независимое масштабирование:

OLAP и OLTP масштабируются по-разному:

  • OLTP нужны частые вставки, индексы, быстрый доступ → нужна мощная сеть, память, IOPS
  • OLAP нужны быстрые full-scan'ы, хорошее сжатие, параллельные вычисления → нужны вычислительные ресурсы, cpu cores

ClickHouse может работать на дешёвых машинах с большим диском и mediocre сетью, потому что компрессирует хорошо. PostgreSQL для OLTP нужна быстрая сеть и быстрый диск.

Типичная связка 4: документная БД + search

Роли в связке

Document store (MongoDB, DynamoDB, Firestore):

  • Гибкое хранение объектов с потенциально разной структурой
  • Агрегаты: в одном документе хранится сущность со всеми вложениями
  • Может быть основным хранилищем (если не нужны транзакции между документами)
  • Source of truth для структуры документа

Search engine (Elasticsearch, OpenSearch):

  • Полнотекстовый поиск
  • Фасетная навигация
  • Проекция документной БД для ускорения поиска

Документная БД: хранение и структура

Document store хранит данные как JSON-документы (или аналогичные структуры). Нет строгой схемы: документы могут иметь разные поля.

Пример MongoDB:

// Документ 1: контент без комментариев
{
  "_id": ObjectId("..."),
  "title": "Как выбрать ноутбук",
  "author": "user1",
  "content": "...",
  "created_at": ISODate("2023-01-15")
}

// Документ 2: контент с вложенными комментариями
{
  "_id": ObjectId("..."),
  "title": "5 способов оптимизации БД",
  "author": "user2",
  "content": "...",
  "created_at": ISODate("2023-01-16"),
  "comments": [
    {
      "author": "user3",
      "text": "Отличная статья!",
      "created_at": ISODate("2023-01-16T10:30:00Z")
    },
    {
      "author": "user4",
      "text": "Согласен, но есть нюансы...",
      "created_at": ISODate("2023-01-16T11:00:00Z")
    }
  ]
}

// Документ 3: видео (другая структура)
{
  "_id": ObjectId("..."),
  "title": "Оптимизация PostgreSQL за 10 минут",
  "author": "user5",
  "video_url": "...",
  "duration_sec": 600,
  "thumbnail_url": "...",
  "created_at": ISODate("2023-01-17")
}

Первые два документа имеют поле comments, третий нет. Это разрешено в MongoDB. Благодаря этому гибкому подходу можно хранить контент разных типов в одной коллекции.

Агреги в document store:

Основной паттерн — хранить связанные данные в одном документе. Вместо нормализованной структуры (документ → таблица comments → таблица users) хранится всё в одном месте.

Search в документной БД

При поиске и фасетной навигации, как правило, документная БД оказывается неоптимальна:

  • MongoDB не умеет полнотекстовый поиск так хорошо как Elasticsearch
  • Фасеты и агрегации работают медленнее
  • Морфология и синонимы требуют дополнительной настройки

Поэтому используется search engine.

Пример документа в Elasticsearch:

{
  "id": "...",
  "type": "article",
  "title": "5 способов оптимизации БД",
  "author": "user2",
  "content": "...",
  "created_at": "2023-01-16",
  "comments_count": 2,
  "comments_text": "Отличная статья! Согласен, но есть нюансы...",
  "author_reputation": "expert",
  "language": "ru"
}

Документ из MongoDB переиндексирован в Elasticsearch для поиска. Поле comments_text — это конкатенация всех комментариев, чтобы полнотекстовый поиск нашёл статьи по тексту комментариев.

Синхронизация документной БД и search

События при изменении:

Пользователь добавляет комментарий в MongoDB
  ↓
Обновляется документ в MongoDB
  ↓
Генерируется событие (change stream в MongoDB)
  ↓
Обновляется документ в Elasticsearch

MongoDB имеет встроенный механизм change streams, который позволяет слушать изменения в реальном времени.

Переиндексация:

Если индекс Elasticsearch сломался, нужно пересобрать его из MongoDB. Берём все документы из MongoDB, трансформируем их в формат для поиска, загружаем в Elasticsearch.

Особенности связки document + search

Трансформация при индексации:

Документы в MongoDB и Elasticsearch имеют разную структуру. Одно изменение в MongoDB может потребовать обновления нескольких полей в Elasticsearch.

Пример:

  • Пользователь удаляет комментарий в MongoDB
  • Документ в MongoDB обновляется: comments массив сокращается
  • При синхронизации нужно пересчитать comments_count и перестроить comments_text
  • Отправить обновление в Elasticsearch

Разные модели данных:

В MongoDB документ может быть очень сложным (вложенные комментарии, истории, метаданные). В Elasticsearch документ нужно денормализовать и упростить для поиска. Это требует явного маппинга/трансформации.

Source of truth и проекции: архитектурное разделение

Понятие Source of Truth

Source of truth (источник истины) — это хранилище, которому доверяют как единственному, неправомерному источнику данных для конкретного аспекта.

Определение для Senior разработчика:

Source of truth — это не просто хранилище, а:

  • Место, где данные создаются и изменяются авторитетно
  • Откуда все остальные системы берут данные для синхронизации
  • Где хранятся бизнес-инварианты и гарантии консистентности
  • То, что выживает при падении всех остальных систем

Если есть конфликт между source of truth и любой другой системой, source of truth всегда выигрывает, остальные переписываются.

Примеры в реальных системах:

  • Source of truth для заказа: PostgreSQL. Даже если Redis/ES/ClickHouse содержат другие данные, истинный статус заказа — в PostgreSQL.
  • Source of truth для метрик: Prometheus. Даже если графики в Grafana показывают другое, истина в Prometheus.
  • Source of truth для логов: Elasticsearch. Логи пишутся туда, остальные системы их читают, но не являются источником.

Проекции: вторичные представления

Проекция (projection) — это представление данных из source of truth, оптимизированное под конкретный сценарий использования.

Характеристики проекций:

  • Содержат подмножество или трансформацию данных из source of truth
  • Могут быть денормализованы, переиндексированы, переформатированы
  • Могут исчезнуть или стать устаревшими — их всегда можно пересобрать из source
  • Не являются авторитетными источниками для данных
  • Оптимизированы под специфичный сценарий (поиск, кеш, аналитика)

Типичные проекции:

Тип проекции Где Для чего
Redis кеш Redis Быстрый доступ к горячим данным
ES индекс Elasticsearch Полнотекстовый поиск и фасеты
OLAP таблица ClickHouse Аналитика и отчётность
Materialized view PostgreSQL Предсчитанная агрегация
Read replica MySQL второй сервер Балансировка читов
Message в queue Kafka Событийное распространение

Диаграмма:

PostgreSQL (source of truth)
    ↓ обновление
    ├→ Redis (проекция: горячий кеш)
    ├→ ES (проекция: индекс для поиска)
    ├→ ClickHouse (проекция: аналитика)
    ├→ Kafka (проекция: события)
    └→ MongoDB (проекция: денормализованные документы)

Правило: один source of truth на каждый кусок бизнес-модели

Это критично: нельзя, чтобы один и тот же аспект имел несколько источников истины.

Ошибка (анти-паттерн):

Заказ может обновляться в PostgreSQL И в Elasticsearch одновременно.
Кто-то обновил в Postgres, кто-то в ES.
Через неделю никто не знает, где правда.

Правильно:

Заказ может обновляться ТОЛЬКО в PostgreSQL.
Elasticsearch содержит копию для поиска, но не может быть обновлён напрямую.
Когда заказ меняется, ES обновляется автоматически из Postgres.

Конфликты и восстановление

Что делать, если данные в проекции расходятся с source of truth?

Сценарий 1: Redis кеш отстал

Пользователь обновил профиль в 10:00 (записано в PostgreSQL)
Запрос на кеш возвращает старые данные (TTL не истёк)
В результате пользователь видит старый профиль в 10:01

Решение:

  • Инвалидировать кеш при обновлении (немедленно)
  • Или установить более короткий TTL
  • Или перестроить кеш из PostgreSQL по требованию

Сценарий 2: Elasticsearch индекс повредился

Elasticsearch упал и потерял часть индекса.
Поиск не работает или возвращает неполные результаты.

Решение:

  • Пересобрать индекс с нуля из PostgreSQL
  • Это может занять часы, но ничего не потеряется
  • Временно поиск не доступен, но данные целы

Сценарий 3: Синхронизация отстала

Новый заказ создан в PostgreSQL в 12:00.
CDC/событие должно было обновить ClickHouse в 12:01.
Но обработчик завис и не обновился.
В 12:30 аналитик смотрит отчёт и не видит заказа.

Решение:

  • Мониторить лаг синхронизации (lag metrics)
  • Если лаг превышает порог, алёрт
  • Повторная обработка: переотправить события, пересчитать проекции
  • Откатить неполные данные и перезапустить обработчик

Разграничение ответственности: явное описание ролей

Senior разработчик всегда имеет явное описание: какие поля в какой БД живут и где они ведущие.

Пример таблица:

Поле Где лежит Source of truth Примечание
order_id PostgreSQL, Elasticsearch, ClickHouse PostgreSQL Уникальный ID
user_id PostgreSQL, Elasticsearch, ClickHouse PostgreSQL Связь с пользователем
status PostgreSQL, Redis, Elasticsearch PostgreSQL Статус заказа (создан/оплачен/отправлен)
created_at PostgreSQL, Elasticsearch, ClickHouse PostgreSQL Дата создания (не меняется)
total_price PostgreSQL, Elasticsearch PostgreSQL Итоговая сумма (вычисляется в БД)
search_text Elasticsearch ES (вычисляется из др. полей при индексации) Денормализованный текст для поиска
daily_revenue ClickHouse CH (вычисляется ежедневно) Агрегация доходов по дням

Из таблицы ясно:

  • PostgreSQL — основной источник для оперативных данных
  • Elasticsearch — проекция с дополнительными полями для поиска
  • ClickHouse — проекция с предсчитанными агрегациями

События, CDC и data-pipeline'ы для синка между базами

Событийный подход: как работает синхронизация между БД

События — это основной механизм передачи информации об изменениях из source of truth в проекции.

Как это работает:

1. Пользователь создаёт новый заказ
   ↓
2. Приложение вставляет запись в PostgreSQL
   ↓
3. PostgreSQL генерирует событие "OrderCreated"
   ↓
4. Это событие поступает в очередь (message broker)
   ↓
5. Разные обработчики подписаны на "OrderCreated":

   - Обработчик Redis кеша: добавляет заказ в кеш
   - Обработчик поиска: индексирует заказ в Elasticsearch
   - Обработчик аналитики: загружает в ClickHouse
   - Обработчик уведомлений: отправляет email

Разрушение типичной ошибки:

Ошибка: «Давайте обновим все БД из приложения напрямую»

UPDATE postgres SET status = 'paid'
redis.set(...)
elasticsearch.index(...)
clickhouse.insert(...)

Проблема: если что-то упадёт посередине, система попадает в неконсистентное состояние (один обновился, другой нет).

Правильно: события — один источник истины, обработчики слушают события.

CDC: Change Data Capture из RDBMS

CDC — это механизм чтения логов изменений БД и превращения их в события.

Как работает CDC в PostgreSQL:

PostgreSQL имеет логический репликационный журнал (logical replication log). Каждый INSERT/UPDATE/DELETE записывается туда.

PostgreSQL WAL (Write-Ahead Log)
    ↓
Логический декодер (logical decoder)
    ↓
Stream of events:

  - {table: "orders", op: "INSERT", data: {id: 123, status: "created", ...}}
  - {table: "orders", op: "UPDATE", data: {id: 123, status: "paid", ...}}
    ↓
Debezium connector (читает поток)
    ↓
Kafka topic "postgres.orders"
    ↓
Consumers слушают тему и обновляют свои БД

Инструменты CDC:

  • Debezium: читает логи из PostgreSQL, MySQL, MongoDB и пишет события в Kafka
  • pgoutput: встроенный логический декодер PostgreSQL
  • MySQL binlog: логи изменений MySQL, может быть прочитан Debezium или maxwell
  • AWS DMS: AWS Database Migration Service с встроенным CDC
  • GCP Datastream: GCP аналог

Преимущества CDC:

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

Недостатки CDC:

  • Добавляет сложность в infrastructure
  • Нужно следить за логами и их ротацией
  • Нужно уметь обрабатывать дублирование и out-of-order события

Data-pipeline'ы: цепочки преобразования

Data-pipeline — это цепочка этапов: extract (взять данные из source) → transform (преобразовать) → load (загрузить в target).

Пример batch pipeline (Airflow DAG):

DAG: daily_orders_sync

Task 1: extract
  - Выгрузить все заказы из PostgreSQL за вчера
  - Выходной формат: CSV с полями (order_id, user_id, status, total_price, created_at)

Task 2: transform
  - Преобразовать в формат ClickHouse
  - Добавить вычисляемые поля: revenue_bucket (0-100, 100-1000, 1000+)
  - Добавить дату партиции: toYYYYMM(created_at)

Task 3: load
  - Загрузить трансформированные данные в ClickHouse таблицу orders_daily

Task 4: aggregate
  - Пересчитать materialized views в ClickHouse

Преимущества: простая реализация, легко дебаг'ить каждый этап, можно перезапустить отдельные таски

Недостатки: лаг в часы/дни, не подходит для критичных операций

Пример streaming pipeline (Kafka → Flink → ClickHouse):

PostgreSQL → Debezium → Kafka (topic: orders_events)
    ↓
Apache Flink Consumer
    ↓
Trnsform stream of events
    ├ if event.operation == INSERT: convert to ClickHouse insert
    ├ if event.operation == UPDATE: convert to ClickHouse update/delete+insert
    └ if event.operation == DELETE: convert to ClickHouse delete (if table supports it)
    ↓
Write to ClickHouse micro-batches (каждую секунду)

Преимущества: real-time, лаг в секунды, масштабируется на миллионы событий

Недостатки: сложнее реализовать, нужно обрабатывать ошибки и дублирование

Идемпотентность: обработка повторов

Когда данные загружаются в вторичные хранилища, может случиться повтор: одно и то же событие обработается дважды.

Сценарий повтора:

1. Kafka отправляет событие "OrderCreated" в ClickHouse loader
2. Loader получил событие, начал загружать
3. Сеть разорвалась прямо перед commit'ом в ClickHouse
4. Loader не получил подтверждение
5. Kafka переотправляет это же событие (message wasn't committed)
6. Loader загружает его снова — дублирование!

Защита: идемпотентность

Загрузка должна быть идемпотентной: загрузить одно событие дважды должно дать тот же результат, что и один раз.

Способы:

  • Deduplication key: каждому событию присваивается уникальный ID. Перед загрузкой проверяем, есть ли уже такой ID. Если есть, пропускаем.
  • Upsert семантика: вместо INSERT используем INSERT OR UPDATE BY UNIQUE KEY. Если запись с таким ключом уже есть, обновим.
  • Partition + offset tracking: для Kafka, отслеживаем, какой offset в какой partition мы уже обработали. Если message с тем же offset пришёл, пропускаем.

Пример кода (Kafka consumer в Java):

for (ConsumerRecord<String, String> record : records) {
  try {
    // Десериализируем событие
    OrderEvent event = objectMapper.readValue(record.value(), OrderEvent.class);
    
    // Проверяем, обработали ли мы уже это событие
    if (deduplicationStore.contains(event.getEventId())) {
      // Идемпотентность: пропускаем повтор
      logger.info("Event {} already processed, skipping", event.getEventId());
      continue;
    }
    
    // Загружаем в ClickHouse (upsert)
    clickhouseService.upsertOrder(event.toOrder());
    
    // Помечаем событие как обработанное
    deduplicationStore.mark(event.getEventId());
    
  } catch (Exception e) {
    logger.error("Failed to process event", e);
    // Если ошибка, не коммитим offset, Kafka переотправит
    throw e;
  }
}

Out-of-order события: обработка позднего прихода данных

События могут приходить не в том порядке, в котором произошли. Например:

OrderCreated (время 12:00) — приходит в 12:05
OrderPaid (время 12:01) — приходит в 12:02
OrderShipped (время 12:02) — приходит в 12:04

Сообщения пришли в неправильном порядке! OrderPaid пришла раньше, чем OrderCreated.

Как это может случиться:

  • Разные partition'ы Kafka обрабатываются параллельно
  • Сетевые задержки
  • Async обработка в приложении

Защита:

  • Event version + timestamp: каждое событие имеет timestamp. При обновлении проекции смотрим: если новое событие старше, чем текущее состояние, игнорируем.
  • State machine: проверяем, допустимо ли переходить из текущего состояния в новое. Если нет, отклоняем или переставляем в очередь.
  • Late-arriving data buckets: некоторые системы (ClickHouse, BigQuery) поддерживают переобработку данных за прошлые периоды.

Пример обработки (ClickHouse):

-- Событие OrderPaid пришло раньше OrderCreated
-- ClickHouse имеет встроенную поддержку переработки

-- При вставке нового события, если его timestamp старше текущего max в таблице,
-- ClickHouse может переписать данные прошлых периодов (ReplicatedMergeTree)

Мониторинг синка: метрики задержки (lag)

Lag — это разница между временем события в source и временем его обработки в target.

Event произошел в PostgreSQL в 12:00:00
Event был обработан в ClickHouse в 12:00:45
Lag = 45 seconds

Метрики для мониторинга:

  • Kafka consumer lag: сколько сообщений в очереди не обработано
  • Pipeline latency: время между событием в source и загрузкой в target
  • Error rate: процент ошибок при обработке
  • Throughput: сколько событий в секунду обрабатывается

Примеры алёртов:

if lag > 5 minutes:
  alert("Sync lagging behind")
  
if error_rate > 1%:
  alert("High error rate in sync pipeline")
  
if throughput < expected:
  alert("Sync pipeline degraded")

На собеседовании:

«Мы мониторим lag между PostgreSQL и ClickHouse. Если lag превышает 5 минут, это значит, что очередь событий растёт быстрее, чем обрабатывается. Поднимаем алёрт, проверяем, не упал ли обработчик, не застрял ли Kafka. Если вдруг лаг очень большой (часы), пересчитываем проекции с нуля из source».»

Тонкие моменты и риски polyglot persistence

Сложность архитектуры: больше компонентов, больше проблем

Polyglot persistence привносит сложность в несколько аспектов:

Отказоустойчивость:

Каждый компонент может упасть независимо. Вместо одной БД, которая либо работает, либо не работает, есть несколько:

- PostgreSQL упал: операции не работают
- Redis упал: кеш не работает, но operations идут, просто медленнее
- Elasticsearch упал: поиск не работает, но REST API работает
- ClickHouse упал: отчёты не работают, но operations в норме

Нужно понимать, как система ведёт себя при падении каждого компонента. Какие функции становятся недоступны? Какие деградируют?

На собеседовании:

«Если Redis упал, система работает в degraded mode — все операции работают, но медленнее, потому что идут в PostgreSQL. Если Elasticsearch упал, поиск недоступен, но CRUD работает. Если ClickHouse упал, отчёты не работают. Мы должны это принять и спроектировать graceful degradation».»

Дебаг и мониторинг:

С одной БД проблема локализуется в одном месте. С polyglot persistence нужно смотреть несколько мест одновременно:

  • Данные в PostgreSQL правильные, но не проиндексированы в Elasticsearch
  • Данные в Redis старые
  • LAG между ClickHouse и PostgreSQL растёт
  • Kafka очередь растёт

Нужны инструменты и expertise для дебага.

Требования к команде:

Нужны специалисты по нескольким технологиям. Разработчик, который знает только Spring + PostgreSQL, не сможет эффективно работать с архитектурой, которая включает Elasticsearch, ClickHouse и Kafka.

Операционная стоимость: разные БД требуют разных навыков

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

PostgreSQL:

  • Требует понимания индексов, explain'а запросов, настройки конфигурации
  • Backup'ы и восстановление требуют специальных инструментов
  • Репликация, failover — нужна настройка

Redis:

  • Требует понимания структур данных и их сложности
  • Memory management и eviction policies
  • Persistence (RDB vs AOF)
  • Кластеризация

Elasticsearch:

  • Требует понимания индексации, sharding, replica
  • Tuning JVM memory
  • Garbage collection issues
  • Index lifecycle management

ClickHouse:

  • Требует понимания column-oriented storage
  • Part merging и optimization
  • Distributed queries и sharding
  • Time-series based partitioning

Если нет expertise в каждой области, система упадёт. На собеседовании это звучит примерно так:

«Polyglot persistence требует больше операционной стоимости. У нас есть DBA для PostgreSQL, инженер по Elasticsearch, инженер по ClickHouse. Это дорого, но даёт нам правильный инструмент для каждой задачи. Альтернатива — иметь одну БД и страдать от её ограничений».»

Eventual consistency: принятие несогласованности

В системе с одной БД всё согласовано в точке времени. В polyglot persistence разные БД содержат несогласованные данные в разные моменты времени.

Пример:

12:00:00 Пользователь создаёт новый заказ
12:00:01 PostgreSQL имеет заказ со статусом "created"
12:00:01 Redis ещё не имеет заказ (кеш не наполнен)
12:00:02 Elasticsearch ещё не имеет заказ (индекс не обновлен)
12:00:03 ClickHouse ещё не имеет заказ (batch sync запустится через час)

Это eventual consistency: в конце концов все системы согласуются, но не немедленно.

Для какого типа данных это приемлемо?

  • Горячие данные: кеш может отставать на несколько минут — приемлемо
  • Поиск: индекс может отставать на несколько секунд — приемлемо
  • Аналитика: warehouse может отставать на часы — приемлемо
  • Статус платежа: должен быть синхронный и актуальный — неприемлемо eventual consistency

На собеседовании:

«Мы используем eventual consistency для некритичных данных. Для поиска это нормально — пользователь подождёт 2 секунды, пока новый товар появится в индексе. Для платежей мы не кешируем статус и не откладываем обновления — это идёт синхронно в PostgreSQL. Eventual consistency — это trade-off между консистентностью и масштабируемостью».»

Эволюция схем: синхронизация изменений

Когда меняется структура данных, это должно отразиться на всех проекциях.

Сценарий: добавили новое поле

В PostgreSQL добавили поле "customer_priority" в таблицу orders.
Теперь нужно:
  1. Обновить схему в PostgreSQL (миграция)
  2. Обновить mapping в Elasticsearch (добавить новое поле в индекс)
  3. Обновить схему в ClickHouse (добавить колонку)
  4. Переиндексировать/пересобрать Elasticsearch (старые документы без этого поля)
  5. Пересчитать данные в ClickHouse за исторические периоды

Это может быть сложно и рискованно.

Версионирование событий:

При появлении нового поля нужно обновить формат событий. Что если старый обработчик получит событие с новым полем, которое он не знает?

v1 события (старое): {order_id, user_id, status}
v2 события (новое): {order_id, user_id, status, customer_priority}

Старый обработчик видит v2 событие, игнорирует customer_priority.
Это обратно совместимо.

Но если добавилось обязательное поле, это нарушает совместимость.

Нужна стратегия миграции и версионирования.

Миграции в polyglot:

День 0: Развернули новое поле в PostgreSQL (nullable, с дефолтом)
День 0-1: Старые приложения продолжают работать, не используя новое поле
День 1: Обновляем Elasticsearch mapping (добавляем поле)
День 1: Переиндексируем старые документы в Elasticsearch
День 2: Обновляем ClickHouse schema
День 2-3: Пересчитываем исторические данные в ClickHouse
День 4: Обновляем приложение, чтобы использовать новое поле

На собеседовании:

«Миграции в polyglot persistence требуют координации. Обновление схемы в одной БД должно быть скоординировано с обновлением проекций. Это требует планирования и часто выполняется постепенно, начиная с добавления нового поля, потом постепенно заполняя его, потом переиндексируя проекции».»

Какие вопросы про polyglot persistence любят задавать на собеседовании

Типовые направления вопросов

«Зачем вам несколько БД, почему одной не хватает?»

Это базовый вопрос, он проверяет, понимаете ли вы вообще идею.

Правильный ответ структурирует по сценариям:

  • Если нужна высокая скорость на операциях (OLTP), RDBMS оптимальна
  • Если нужен полнотекстовый поиск по миллионам документов, SQL очень медленный, нужен специализированный search engine
  • Если нужна аналитика с группировками по миллионам строк, OLTP полностью загружается, нужна отдельная OLAP база
  • Если нужен быстрый кеш (миллисекунды), in-memory key-value store лучше чем SQL
  • Если нужны метрики в огромных объёмах (миллионы метрик в секунду), time-series DB оптимальна

Неправильный ответ: «Потому что это модно» или просто список технологий.

«Где у вас source of truth?»

Это ключевой вопрос. Если вы не можете четко назвать source of truth, это красный флаг.

Правильный ответ: четко и кратко обозначить, какая БД является source of truth для каких данных.

«PostgreSQL является source of truth для всех операций и состояния заказов. Elasticsearch — это проекция для поиска, Redis — проекция для кеша, ClickHouse — проекция для аналитики. Если есть конфликт, PostgreSQL выигрывает».»

Неправильный ответ: неясно, размазано между БД, или ответ типа «каждая БД — своя истина».

«Как вы синхронизируете данные между RDBMS и Elasticsearch/Redis/ClickHouse?»

Это проверка практического опыта. Хотят услышать конкретный механизм.

Правильный ответ включает:

  • Механизм синхронизации (события, CDC, batch ETL)
  • Инструменты (Kafka, Debezium, Airflow)
  • Как обрабатывается отставание (LAG)
  • Как обрабатываются ошибки (retry, idempotency)

«При создании заказа мы вставляем его в PostgreSQL. PostgreSQL генерирует WAL запись. Debezium читает эту запись и отправляет событие в Kafka topic 'orders'. Elasticsearch consumer слушает тему и обновляет индекс. ClickHouse consumer обновляет таблицу. Redis cache инвалидируется по событиям или через TTL. Мы мониторим LAG между PostgreSQL и проекциями, если LAG > 5 минут, поднимаем алёрт».»

Неправильный ответ: «Мы обновляем все вручную из приложения» или неясный процесс.

«Что делаете, если search-индекс отстал или сломался?»

Проверка, как вы восстанавливаетесь от сбоев.

Правильный ответ включает:

  • Диагностику (мониторинг LAG, проверка health индекса)
  • Решение (переиндексация, rebuild)
  • Как система ведёт себя во время rebuild (graceful degradation)
  • Профилактика (регулярные проверки, тесты)

«Если Elasticsearch упал, мы переходим в degraded mode: поиск недоступен, но CRUD работает. Мы пересобираем индекс с нуля: берём все заказы из PostgreSQL, трансформируем в формат ES, индексируем. На миллион заказов это занимает ~10 минут. Мы не блокируем операции, просто поиск будет неполным до окончания переиндексации».»

«Что будет, если Redis или ES внезапно упадут? Как поведёт себя система?»

Проверка resilience и graceful degradation.

Правильный ответ показывает понимание зависимостей:

  • Redis упал: операции работают в обычном режиме, потому что это проекция. Данные получаются из PostgreSQL (медленнее), Redis восстанавливается при загрузке.
  • ES упал: поиск недоступен, но CRUD работает. При включении ES переиндексируется заново.
  • PostgreSQL упал: всё падает, потому что это source of truth. Это критично, нужна HA/репликация.

«Redis и Elasticsearch — это проекции, их падение деградирует производительность, но не деструктивно. PostgreSQL — это source of truth, его падение критично. Мы покрываем PostgreSQL высокой доступностью (primary-standby репликация + failover)».»

Что хочет услышать интервьюер

1. Архитектурное мышление

Не просто список технологий, а понимание trade-off'ов:

  • Почему выбрана именно эта комбинация
  • Какие альтернативы рассматривали
  • Какие компромиссы приняли

«Мы рассматривали одну большую PostgreSQL, но аналитические отчёты были медленными (часы вместо минут). Рассматривали BigQuery, но экономически дорого для нашего объёма. Выбрали ClickHouse как оптимум между стоимостью и производительностью для нашей нагрузки».»

2. Практический опыт

Не теория, а реальные проблемы, которые они решали:

  • Cache stampede и как это защитили
  • Eventual consistency и где это привело к багам
  • Миграции при изменении схемы
  • Восстановление после сбоев

«У нас была проблема: поиск по товарам работал, но возвращал неполные результаты, потому что ES отставал от PostgreSQL на пару минут. Мы добавили в search API badge 'data may be outdated' и настроили LAG monitoring».»

3. Понимание риска

Знание, что polyglot persistence — это не панацея, а trade-off:

  • Добавляет сложность
  • Требует expertise в нескольких технологиях
  • Требует хорошего мониторинга и бизнес-логики обработки отказов

«Polyglot persistence добавил сложность инфраструктуры. Мы не могли просто нанять одного DBA, нужны были специалисты. Но это стоило того, потому что система стала на 10x быстрее для аналитики и на 5x быстрее для поиска».»

4. Операционная грамотность

Не только о коде, но о том, как это работает в production:

  • Мониторинг и алёрты
  • Миграции и downtime
  • Откат изменений
  • Обучение команды

«Мы имеем alert на LAG > 5 минут, на error rate > 1%, на memory usage в Redis > 90%. Миграция новой версии ClickHouse требует координации: сначала тестируем на staging, потом rolling update в production, затем monitoring 24/7».»

Как звучать как Senior

1. Не перечисляй технологии, объясни решение

Неправильно: «Мы используем PostgreSQL, Redis, Elasticsearch и ClickHouse».

Правильно: «Мы выбрали PostgreSQL как source of truth, потому что нужны ACID-гарантии и сложные связи. Redis для кеша горячих данных, потому что latency критичен. Elasticsearch для поиска, потому что SQL LIKE медленен. ClickHouse для аналитики, потому что её нужно разделить от OLTP».

2. Говори через trade-off'ы

Неправильно: «Elasticsearch лучше всех для поиска».

Правильно: «Elasticsearch даёт нам 100x прирост в скорости поиска, но добавляет eventual consistency между RDBMS и индексом, требует дополнительного оборудования и expertise. Мы это приняли, потому что поиск — критичный feature».

3. Вспоминай про мониторинг и операции

Неправильно: тихо про то, как система работает, когда всё хорошо.

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

4. Дай примеры реальных проблем

Неправильно: теория без примеров.

Правильно: «Однажды ES упал в пятницу в 20:00. Поиск был недоступен до понедельника утра. Мы научились: (1) иметь alert на health индекса, (2) иметь manual recovery процедуру, (3) иметь fallback для поиска (может быть, медленный SQL)».

Практические рекомендации по введению polyglot persistence в систему

С чего начинать: инкрементальный подход

Опасность: сразу много БД

Если вы с самого начала решили использовать PostgreSQL + Redis + ES + ClickHouse, это создаст огромную сложность. Команда не готова, инструменты не настроены, нет expertise.

Правильный подход: сначала один надёжный source of truth

Начните с одной RDBMS (PostgreSQL или MySQL). Она должна быть:

  • Хорошо настроена
  • Отказоустойчива (репликация)
  • Мониторится
  • Резервируется

На этом этапе всё работает из этой БД. Медленно? Может быть. Но надёжно и просто.

Второй этап: добавление кеша

Когда видите, что горячие данные часто читаются из БД, добавляйте Redis. Этап:

  1. Выделите горячие данные (профили активных пользователей, популярные товары)
  2. Добавьте cache-aside вокруг чтений из PostgreSQL
  3. Установите TTL и базовую инвалидацию
  4. Измерьте улучшение latency

На этом этапе Redis опционален: если упадёт, работает медленнее, но система работает.

Третий этап: добавление поиска

Когда полнотекстовый поиск становится узким местом, добавляйте Elasticsearch:

  1. Выделите данные, которые нужны для поиска (товары, контент, заказы)
  2. Настройте синхронизацию (события или CDC)
  3. Создайте поисковый API, который идёт в ES
  4. Перенаправьте traffic с SQL поиска на ES

На этом этапе ES опционален: если упадёт, fallback на медленный SQL поиск.

Четвёртый этап: добавление аналитики

Когда аналитические запросы начинают конкурировать с OLTP за ресурсы, добавляйте OLAP:

  1. Выберите аналитическую БД (ClickHouse, BigQuery)
  2. Настройте периодическую (или стриминговую) выгрузку из PostgreSQL
  3. Перенаправьте аналитические запросы на OLAP
  4. OLTP теперь свободна для операций

Инкрементальное внедрение: минимизация риска

Вводите новый layer на ограниченном подмножестве данных

Если добавляете Elasticsearch, не индексируйте все миллионы документов сразу. Начните с миллиона, измерьте, убедитесь, что sync работает, потом растите.

День 1: Индексируем 1 миллион товаров в ES
День 2-3: Мониторим, проверяем точность, ловим баги
День 4: Индексируем дальше, до 10 миллионов
День 5-6: Мониторим
День 7: Полный индекс

Параллельный прогон

Когда добавляете новый storage, заново проверьте результаты:

  • Старый способ: SELECT * FROM products WHERE name LIKE '%X%'
  • Новый способ: ES search for 'X'

Сравните результаты. Если они отличаются, разберитесь, почему, пока это не в production.

Отката механизм

Каждый новый layer должен иметь механизм отката. Если ES поломалась, вернитесь на SQL поиск. Если Redis упал, вернитесь на прямое чтение из БД.

Документация и ментальная модель

Диаграммы потоков данных

Каждый разработчик должен понимать, куда текут данные. Нарисуйте диаграмму:

REST API POST /orders
  ↓
Spring service validateAndSave()
  ↓
PostgreSQL INSERT orders
  ↓ (WAL)
Debezium CDC
  ↓
Kafka topic orders_events
  ├→ Redis cache subscriber
  ├→ Elasticsearch subscriber
  ├→ ClickHouse subscriber
  └→ Notification subscriber

Это диаграмма должна быть в вики, в документации, на whiteboard'е в офисе.

Описание ролей каждой БД

Для каждой БД опишите:

  • Какие данные там живут
  • Является ли она source of truth или проекцией
  • Как она синхронизируется с другими
  • Что делать, если она упадёт

Пример для PostgreSQL:

  • Роль: основное транзакционное хранилище
  • Данные: все таблицы (orders, users, products, payments)
  • SoT: да, для всех данных
  • Синхронизация: WAL отправляется в Debezium
  • Отказ: критично, имеет failover на standby сервер

Пример для Elasticsearch:

  • Роль: индекс для полнотекстового поиска
  • Данные: документы о товарах и заказах, денормализованные
  • SoT: нет, проекция PostgreSQL
  • Синхронизация: события из Kafka обновляют индекс
  • Отказ: некритично, поиск недоступен, fallback на SQL

Правила восстановления

Опишите, что делать при сбое каждого компонента:

  • PostgreSQL упала: объявить incident, включить standby, проверить data consistency
  • Redis упала: мониторить, дождаться восстановления (или рестартовать), проверить, что кеш пересобрался
  • ES упала: переиндексировать, мониторить LAG
  • Kafka упала: incident, попытаться восстановить из log, переотправить события

Контрольный список для внедрения

При добавлении нового storage используйте чек-лист:

Краткий чек-лист по polyglot persistence для собеседований

Вот набор тезисов, которые Senior Backend разработчик должен уверенно проговаривать на собеседовании:

1. Что такое polyglot persistence и почему это нужно

«Polyglot persistence — это использование нескольких типов хранилищ в одной системе. Каждое хранилище оптимизировано под конкретный сценарий: RDBMS под OLTP операции, cache под быстрые чтения, search engine под полнотекстовый поиск, warehouse под аналитику. Попытка сделать всё в одной БД ведёт к компромиссам: или она медленная, или сложная, или дорогая».»

2. Типичные связки

«Самые частые связки: RDBMS + Redis (кеш горячих данных), RDBMS + Elasticsearch (поиск), OLTP + OLAP warehouse (аналитика). Реже встречаются комбинации документных БД с поиском или time-series с основной RDBMS».»

3. Source of truth и проекции — ясное разделение

«Source of truth — это основной источник данных, где они правят и консистентны. Всё остальное — проекции, которые можно пересобрать заново. На примере: PostgreSQL — SoT, Redis — проекция (кеш можно выбросить), ES — проекция (индекс можно переиндексировать)».»

4. Синхронизация между БД

«Синхронизация работает через события или CDC (Change Data Capture). Изменение в PostgreSQL генерирует событие, которое обновляет Redis, ES, ClickHouse. Это может быть синхронно (надёжнее) или асинхронно через очередь вроде Kafka (масштабируемее)».»

5. Консистентность и лаги

«Между хранилищами может быть eventual consistency: они согласуются в конце концов, но не немедленно. Это приемлемо для кешей и поиска (лаг в секунды-минуты), но не для платежей (там синхронность критична)».»

6. Риски и сложность

«Polyglot persistence добавляет сложность: больше компонентов, больше точек отказа, требуется expertise в нескольких технологиях, нужен хороший мониторинг. Но если сделать правильно, это даёт 10x+ улучшение в производительности».

7. Graceful degradation и отказоустойчивость

«Система должна ведать себя корректно, когда отдельные компоненты падают. Если Redis упал, операции работают медленнее, но работают. Если ES упал, поиск недоступен. Если PostgreSQL упал — это критично, и нужна высокая доступность».»

8. Практический опыт и lessons learned

Будьте готовы рассказать о реальных проблемах:

  • Cache stampede и как защитились
  • Eventual consistency bugs и как их ловили
  • Миграции при изменении схемы
  • Инциденты и recovery процедуры

«Однажды мы забыли инвалидировать кеш при обновлении заказа. Пользователи видели старые данные в течение часа. Теперь обновляем Redis синхронно при любом изменении в PostgreSQL».»

9. Архитектурное мышление вместо списка технологий

Не говорите просто «мы используем A, B, C, D». Говорите через мотивацию:

«Мы выбрали эту комбинацию потому что: (1) нужна надёжность — PostgreSQL; (2) нужна скорость чтения горячих данных — Redis; (3) нужен поиск по текстам — Elasticsearch; (4) нужна аналитика отдельно от OLTP — ClickHouse. Альтернативы рассматривали, но эта оптимальна по стоимости и производительности».»

10. Мониторинг и операции

«Мониторим LAG между источником и проекциями, error rate синхронизации, health каждого компонента. При LAG > 5 минут поднимаем алёрт. Если компонент упал, пересобираем/восстанавливаем заново, не теряя данные».»

Держите эти 10 тезисов в голове. На собеседовании они — ваша основа. Разворачивайте каждый в зависимости от конкретного вопроса.

Миграции БД

Зачем думать о эволюции схемы

Почему схема и данные постоянно меняются

Любая живая система в продакшене не стоит на месте. Новые бизнес-требования требуют добавления полей, изменения структуры данных, оптимизации для новых паттернов использования. Регуляторные требования (GDPR, локализация, аудит) вынуждают расширять модель данных. Оптимизация производительности часто означает рефакторинг схемы: денормализация для быстрого чтения, добавление новых индексов, создание аналитических проекций.

На уровне Senior-инженера недостаточно просто добавить поле в таблицу. Нужно понимать, как это изменение повлияет на работающую систему, какие блокировки возникнут, сколько времени займёт миграция, как откатиться, если что-то пойдёт не так. Это различие между junior-разработчиком (пишет новый код) и senior-архитектором (проектирует безопасность эволюции данных).

Риск «одной большой миграции ночью»

Классический сценарий: команда решила массово переделать схему, договорилась на выходные ночью провести миграцию, запустили большой SQL-скрипт ALTER TABLE. Что может пойти не так:

  • Таблица на 200 миллионов строк — миграция занимает 4 часа вместо ожидаемых 30 минут, база блокируется, система недоступна.
  • Во время миграции произойдёт ошибка, откат — ещё час, итого 5 часов даунтайма, трафик теряется.
  • Даже если всё прошло успешно, в коде есть баг, который обнаружится только под нагрузкой — и откатываться нужно при живых пользователях.
  • Доверие пользователей падает, SLA нарушается, могут быть штрафные санкции в контрактах.

Senior-инженер отвечает за то, чтобы миграции проходили прозрачно для пользователей. Это значит: миграция может занимать часы или даже дни, но система остаётся доступной, читать и писать может любой клиент, отката можно избежать через правильное проектирование фаз.

Ожидания от Senior-разработчика

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

  • Понимание различий между RDBMS, NoSQL, поиском и аналитикой. В PostgreSQL есть инструменты (logical replication, pg_dump), а в MongoDB нужна иная стратегия.
  • Умение выбирать правильную стратегию: expand-and-contract, feature-flag'и, batch-миграции, shadow-таблицы.
  • Способность разбить работу на фазы так, чтобы на каждом этапе система была консистентна и откатываема.
  • Ясность в том, как мониторить миграцию, проверять целостность, откатываться при проблемах.

Это не просто техническое знание SQL, а архитектурное мышление: как спланировать эволюцию системы, не создавая отказов в обслуживании.

Эволюция схемы в RDBMS vs schema-less подходы

RDBMS: жёсткая схема и её издержки

В традиционных реляционных БД (MySQL, PostgreSQL, Oracle) схема — это жёсткий контракт. Таблица содержит строго определённые колонки с типами данных. Любое изменение структуры требует DDL-операции (Data Definition Language): ADD COLUMN, ALTER COLUMN, DROP, RENAME и т.д.

Преимущества жёсткой схемы:

  • Ясность инвариантов: если колонка NOT NULL, то гарантированно в любой строке будет значение.
  • Эффективность хранения и запросов: база знает, где искать, какие индексы использовать, как оптимизировать план выполнения.
  • Проверка целостности на уровне БД, а не кода: NOT NULL, UNIQUE, FOREIGN KEY обеспечивают консистентность.

Издержки изменения схемы:

  • DDL-операции часто требуют блокировки таблицы. Даже простой ADD COLUMN на большой таблице может вызвать эксклюзивную блокировку на время операции.
  • Индексы нужно пересчитывать, полные таблицы могут требовать rewrite.
  • Откат немедленно невозможен — нужно заранее подготовить скрипт отката.

Типичные DDL-операции и их влияние:

  • ALTER TABLE users ADD COLUMN email VARCHAR(255) — добавляет колонку. Если есть значение по умолчанию (DEFAULT) или она nullable, операция быстрая даже на больших таблицах в современных БД (MySQL 5.7+, PostgreSQL). Если NOT NULL без DEFAULT — требуется переписать все строки.
  • ALTER TABLE users ADD INDEX idx_email (email) — добавляет индекс. На больших таблицах может занимать минуты-часы, блокирует запись (в некоторых конфигурациях).
  • ALTER TABLE users CHANGE COLUMN email email_address VARCHAR(255) — переименование колонки. Обычно требует переписи таблицы.
  • ALTER TABLE users DROP COLUMN email — удаление колонки. После этого данные теряются, откат требует восстановления из бэкапа.

Schema-less и документные БД

MongoDB, Firestore, DynamoDB — это БД без обязательной схемы на уровне хранилища. Документ может содержать любые поля, и два документа в одной коллекции могут иметь совершенно разные структуры.

Как это работает на практике:

  • Разработчик может добавить новое поле в документ без миграции схемы. Просто пишет { userId: 123, name: "Alice", email: "alice@example.com", phone: "+1234567" } рядом со старыми документами вроде { userId: 124, name: "Bob" }.
  • Чтение требует проверки наличия поля в коде: document.get("phone") может вернуть null или отсутствующее значение, что нужно обработать.
  • Фактическая схема живёт в коде и в соглашениях команды. Обычно добавляют поле schemaVersion или _version, чтобы отслеживать эволюцию структуры.

Гибкость против хаоса:

  • Добавить новое поле просто, но контролировать, какие версии документов есть в БД, сложнее.
  • Если один разработчик добавил поле email, другой — emailAddress, третий — contact_email, то в БД будет путаница.
  • Необходимо явное версионирование и策略 работы с несколькими версиями документов.

Практический подход в schema-less БД:

  • Явное поле версии: { schemaVersion: 2, userId: 123, name: "Alice", email: "alice@example.com" }.
  • Ограничение поддерживаемых версий: если текущая версия 5, то код читает версии 3-5, версию 2 и ниже автоматически мигрирует на чтение или фоновым процессом.
  • Миграция на чтение: при доступе к старому документу код преобразует его в новый формат и сохраняет обратно (lazy migration).
  • Фоновые миграторы: параллельный процесс пробегает коллекцию и обновляет старые документы периодически.

Schema-on-read: гибкость на уровне запросов

Аналитические БД (ClickHouse, Presto, Redshift), data lake и stream-processing системы часто используют schema-on-read подход. Данные хранятся в полу-структурированном виде (JSON, Parquet, Avro), а схема применяется при чтении.

Как это работает:

  • Сырые данные (raw) хранятся в недорогом хранилище или в логах.
  • При запросе система интерпретирует данные согласно указанной схеме: извлекает поля, преобразует типы.
  • Несколько разных схем могут применяться к одному и тому же сырью: аналитик может прочитать JSON с точкой зрения одной схемы, инженер данных — с другой.

Преимущества:

  • Отсутствие жёсткого контракта упрощает добавление новых источников данных.
  • Несколько проектов могут использовать одни и те же raw-данные с разными интерпретациями.
  • Эволюция схемы на уровне приложения, не требует переписи всех данных в хранилище.

Сложности:

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

Компромиссы и выбор подхода

Характеристика RDBMS Schema-less Schema-on-read
Инварианты данных Жёсткие, проверяются БД Мягкие, проверяются кодом Мягкие, проверяются при чтении
Скорость добавления поля Медленно (блокировка таблицы) Быстро (просто пишем) Быстро (просто добавляем в схему)
Контроль хаоса Высокий Требует дисциплины Требует дисциплины
Эволюция данных Плановая, фазовая Органичная, с версионированием Многослойная (raw → витрины)
Откат изменений Сложный, требует бэкапов Простой (удалить поле, код не пишет) Простой (переписать витрину)

На собеседовании нужно объяснять эти различия в терминах архитектуры:

  • «В RDBMS мы заранее планируем эволюцию, потому что изменение схемы дорогое. В MongoDB вмешиваемся мягче, но требует явного версионирования документов.»
  • «Elasticsearch использует schema-on-read: индекс может иметь разные версии маппинга, и мы обновляем его через переиндексацию из источника.»

Стратегии миграций: backward-compatible изменения

Что значит backward-compatible миграция

Backward-compatible (назад-совместимая) миграция — это изменение схемы, при котором:

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

Это критично для систем, где нельзя одновременно остановить все инстансы приложения. На больших инфраструктурах с множеством сервисов откатываемые деплои могут занимать часы, поэтому backward-compatibility даёт буфер для исправления ошибок.

Пример:

  • Версия 1 приложения: читает и пишет поле user.address как строка.
  • Миграция: добавляем поле user.address_object типа JSON с полями { street, city, country }, оставляем address как есть.
  • Версия 2 приложения: пишет в address_object, но при чтении проверяет оба поля (если address_object пусто, использует address).
  • Версия 1 может работать с этой схемой: она не знает о address_object, но это не вредит.
  • Версия 2 может откатиться на версию 1: поле address_object просто игнорируется.

Примеры безопасных изменений

Добавление nullable-колонок с default-значениями:

  • ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL;
  • Старый код не знает о phone, новый пишет туда. Безопасно.
  • В некоторых БД эта операция очень быстра (не требует переписи всех строк).

Добавление новых таблиц/коллекций:

  • Новая таблица user_preferences для расширенной функциональности.
  • Старый код её не трогает, новый использует.
  • Полностью безопасно, но требует синхронизации: если удалить пользователя из users, нужно удалить и из user_preferences.

Добавление индексов:

  • CREATE INDEX idx_users_email ON users(email);
  • Не меняет схему, не влияет на чтение/запись логику.
  • Но требует ресурсов на создание, может замедлить случайные операции записи на время индексирования.

Расширение перечислений (ENUM):

  • В PostgreSQL добавление значения в ENUM требует осторожности.
  • Безопасно, если новое значение добавляется в конец, и код правильно обрабатывает неизвестные значения.

Опасные изменения

Изменение типа колонки:

  • ALTER TABLE users CHANGE COLUMN age age INT; (было STRING, стало INT).
  • Старый код пишет строки вроде "thirty", новый ожидает числа. Несовместимо.
  • Требует двухфазной миграции: добавить новую колонку, мигрировать данные, переключить код, удалить старую.

Переименование колонок/таблиц:

  • ALTER TABLE users RENAME COLUMN email TO email_address;
  • Старый код ищет email, находит NULL или ошибку. Несовместимо.

Жёсткое ужесточение ограничений:

  • ALTER TABLE users MODIFY email VARCHAR(255) NOT NULL;
  • Если в БД есть строки с NULL в email, это вызовет ошибку.
  • Даже если ошибки нет, старый код может писать NULL, что теперь невозможно.

Удаление колонок:

  • ALTER TABLE users DROP COLUMN email;
  • Старый код читает/пишет email, получает ошибку. Несовместимо.

Изменение поведения триггеров или constraints:

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

Принципы безопасной эволюции

  1. Expand before contract: сначала добавляем новые сущности, потом переводим код, потом удаляем старое.

  2. Граждане двух поколений: новый код должен уметь работать как с новой, так и со старой схемой. Это требует логики fallback и проверок.

  3. Избегаем миграций, которые ломают старые версии: даже если у вас есть возможность откатить код, миграция, которая физически удаляет данные, откатываема только через бэкап.

  4. Версионирование на уровне приложения: код знает, с какими версиями схемы он может работать. Например, сервис сообщает: "я работаю с версией схемы 3-5, обновите меня на версию 6".

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

Стратегия expand-and-contract

Общая идея

Expand-and-contract — это паттерн миграции, состоящий из трёх фаз:

Expand (расширение): добавляем новые колонки, таблицы, индексы, но не трогаем старые. Система теперь может работать с обеими версиями структуры. Старый код продолжает писать в старые поля, новый может писать в новые.

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

Contract (сжатие): удаляем старые поля/таблицы, когда уверены, что они больше не нужны. Эта фаза может произойти через недели или месяцы после expand, чтобы обеспечить возможность отката.

Типичный сценарий: переименование поля

Нужно переименовать user.phone_number на user.phone и изменить его тип с VARCHAR на JSONB (с информацией о типе номера, стране и т.д.).

Этап 1: Expand

ALTER TABLE users ADD COLUMN phone JSONB DEFAULT NULL;

Теперь в users есть оба поля. Старый код не трогает phone, новый начинает его использовать.

Этап 2: Миграция данных Фоновый процесс постепенно преобразует phone_number в новый формат:

UPDATE users 
SET phone = jsonb_build_object('type', 'mobile', 'number', phone_number)
WHERE phone IS NULL AND phone_number IS NOT NULL
LIMIT 1000;

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

Этап 3: Переключение кода

  • Новый деплой приложения читает phone, если пусто, читает из phone_number и преобразует.
  • Новый деплой пишет в phone, старый продолжает писать в phone_number.
  • Запускается функция синхронизации: если что-то написано в phone_number, оно конвертируется в phone.

Этап 4: Contract Через неделю-две, когда уверены, что весь трафик использует новое поле:

ALTER TABLE users DROP COLUMN phone_number;

Применимость expand-and-contract

Этот паттерн универсален и применим к различным типам хранилищ:

  • RDBMS: добавляем новый столбец, мигрируем данные, удаляем старый.
  • Документные БД: добавляем новое поле, обновляем документы, удаляем старое поле из всех документов.
  • Search-индексы (Elasticsearch): создаём новый индекс с новым маппингом, переиндексируем, переключаем alias, удаляем старый индекс.
  • Аналитические хранилища: создаём новую витрину, заполняем её, переключаем дашборды, архивируем старую.
  • Многосервисная архитектура: каждый сервис обновляется независимо, старые и новые версии кода работают с обеими версиями схемы.

Как описывать expand-and-contract на собеседовании

Типичный вопрос: «Как бы ты помаял изменить структуру часто используемого документа без даунтайма?»

Ответ в два-три предложения:

  • «Я бы использовал expand-and-contract: сначала добавляю новое поле (expand), код пишет в оба старое и новое, фоновый процесс копирует данные. Потом код полностью переходит на новое поле. И только после завершения миграции и периода наблюдения удаляю старое поле (contract).»

Или для более специфичного случая с Elasticsearch:

  • «Создаю новый индекс с новым маппингом, запускаю reindex из старого индекса. Пока идёт reindex, новые документы пишутся в оба индекса через двойную запись в коде. Когда reindex завершится, переключаю alias на новый индекс и удаляю старый.»

Feature-flag'и и поэтапное раскатывание изменений

Роль feature-flag'ов в миграциях

Feature-flag (фича-флаг) — это переменная, которая управляет включением/отключением определённого поведения кода, без необходимости перегружать приложение. Для миграций схемы флаги дают возможность:

  • Включить новую логику работы с данными только для части пользователей (canary), остальные пользуются старым кодом.
  • Быстро откатить использование новой схемы, если возникли проблемы, без отката всего деплоя.
  • Поэтапно увеличивать долю трафика, использующего новую схему: 1% → 5% → 25% → 100%.

Это критично, потому что даже идеальная миграция может иметь edge-case, который обнаружится только под нагрузкой, и флаги позволяют быстро реагировать.

Примеры использования feature-flag'ов

Сценарий 1: Новый формат поля

Старый формат: user.address — просто строка. Новый формат: user.address_components — объект с { street, city, country, zip }.

if (featureFlags.isEnabled("use_address_components_v2", userId)) {
    // Новый код: пишем в address_components
    addressComponents = parseAddressComponents(input);
    user.setAddressComponents(addressComponents);
} else {
    // Старый код
    user.setAddress(input);
}

В начале флаг включен для 1% пользователей. Новые пользователи получают address_components, старые — address. Через несколько часов, если ошибок нет, флаг включаем для 10%, потом для 100%.

Сценарий 2: Новая таблица/коллекция

Заводим таблицу user_activity_events для логирования активности, но система может работать и без неё.

if (featureFlags.isEnabled("log_activity_events")) {
    eventLogger.log(userId, action, timestamp);
}

Сначала флаг выключен, система работает без логирования. Потом включаем для staging, проверяем нагрузку на БД. Если всё ОК, включаем для 5% продакшена, потом для всех.

Сценарий 3: Двойная запись

Код писал в старую таблицу orders, начинаем писать в новую orders_v2 с изменённой структурой.

orderRepository.save(order); // Старая таблица

if (featureFlags.isEnabled("write_orders_v2")) {
    ordersV2Repository.save(convertToV2(order)); // Новая таблица
}

Флаг позволяет постепенно заполнять новую таблицу, пока код читает только из старой. Когда новая таблица синхронизирована, переключаемся на чтение из неё.

Этапы развёртывания с feature-flag'ами

  1. Подготовка схемы (expand): добавляем новые колонки/таблицы в БД.

  2. Деплой кода с флагом (выключен): выкатываем обновленный код, который умеет работать с новой схемой, но флаг выключен. Система работает как раньше.

  3. Включение флага для canary (1-5%): маленький процент трафика использует новую логику. Мониторим ошибки, логи, метрики.

  4. Постепенное увеличение (5% → 25% → 100%): если никаких проблем, процент растёт.

  5. Полное переключение: флаг включен для всех.

  6. Удаление старого кода (contract): если флаг был включен месяц и всё стабильно, удаляем code path для старого поведения.

Связь с blue-green и canary деплоем

Feature-flag'и работают в паре со стратегиями деплоя:

Blue-green деплой:

  • Blue (текущая версия) обслуживает весь трафик.
  • Green (новая версия) разворачивается параллельно.
  • После проверки Green'a весь трафик переключается с Blue на Green.
  • Если Green упадёт, быстро возвращаемся на Blue.

Feature-flag'и в этом контексте позволяют:

  • Даже если весь трафик на Green, часть функциональности может быть выключена флагом.
  • Более гранулярный контроль: переключать не версию всего сервиса, а отдельные фичи.

Canary деплой:

  • Новая версия запускается на небольшой части инстансов.
  • Часть трафика маршрутируется на новые инстансы, часть остаётся на старых.
  • Если метрики ошибок выросли, откатываем.

Feature-flag'и дополняют это:

  • Даже если инстанс новой версии получил трафик, флаг может выключить использование новой схемы.
  • Позволяет разделить риски: сам код работает, но новые данные используются осторожнее.

На собеседовании:

  • «Я комбинирую blue-green деплой для кода с feature-flag'ами для управления использованием новой схемы. Это дает двойной уровень защиты: если что-то пошло не так на уровне приложения, откатываем версию; если проблема в логике работы с данными, выключаем флаг без отката.»

Миграции больших таблиц без даунтайма

Проблема: почему обычный ALTER не подходит

Таблица orders содержит 500 миллионов строк. Нужно добавить колонку order_status_updated_at типа TIMESTAMP и создать индекс.

Наивный подход:

ALTER TABLE orders ADD COLUMN order_status_updated_at TIMESTAMP DEFAULT NULL;
CREATE INDEX idx_orders_status_updated ON orders(order_status_updated_at);

Что произойдёт:

  • ADD COLUMN в некоторых БД требует переписи всей таблицы: прочитать все 500М строк, переписать их с новой колонкой.
  • Это может занимать от 30 минут до нескольких часов.
  • На время выполнения таблица может быть заблокирована для записи (в зависимости от БД и конфигурации).
  • Индексирование после этого ещё добавляет время.

Результат: система недоступна, пользователи получают ошибки подключения, SLA нарушается.

Подход 1: Batch-копирование с параллельной синхронизацией

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

Фаза 1: Подготовка

CREATE TABLE orders_new (
    id BIGINT PRIMARY KEY,
    order_number VARCHAR(50),
    customer_id BIGINT,
    total_amount DECIMAL(10,2),
    order_status_updated_at TIMESTAMP DEFAULT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    INDEX idx_orders_status_updated (order_status_updated_at)
);

Фаза 2: Фоновое копирование Запускается фоновый процесс (может быть отдельный сервис или scheduled job):

-- Копируем в батчах по 10000 строк
SELECT * FROM orders WHERE id NOT IN (SELECT id FROM orders_new) LIMIT 10000;
INSERT INTO orders_new SELECT * FROM orders WHERE id NOT IN ...;
-- Повторяем, пока не скопируем все данные

Ключевые моменты:

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

Фаза 3: Двойная запись Параллельно копированию приложение начинает писать в обе таблицы:

// Пишем в старую таблицу
ordersRepository.save(order); // INSERT/UPDATE в orders

// Пишем в новую таблицу параллельно
if (featureFlags.isEnabled("write_orders_new")) {
    ordersNewRepository.save(order);
}

Фаза 4: Синхронизация изменений Пока идёт копирование, старые данные в orders могут изменяться. Нужно синхронизировать эти изменения:

  • Способ 1: Триггеры в БД автоматически обновляют orders_new при изменении orders.
  • Способ 2: Приложение при обновлении orders параллельно обновляет orders_new.
  • Способ 3: Событийная система: каждое изменение orders генерирует событие, которое обновляет orders_new.

Фаза 5: Переключение чтения Когда копирование завершено и все данные синхронизированы:

-- Проверяем, что все данные скопированы
SELECT COUNT(*) FROM orders;       -- 500М
SELECT COUNT(*) FROM orders_new;   -- тоже 500М

-- Проверяем, что никакие новые данные не потеряны
SELECT MAX(id) FROM orders;        -- Последний ID
SELECT MAX(id) FROM orders_new;    -- Такой же

Код переключается на чтение из orders_new:

List<Order> orders;
if (featureFlags.isEnabled("read_orders_new")) {
    orders = ordersNewRepository.findAll(...);
} else {
    orders = ordersRepository.findAll(...);
}

Фаза 6: Удаление старой таблицы Через неделю-две, когда уверены, что всё работает, удаляем старую таблицу:

DROP TABLE orders;
RENAME TABLE orders_new TO orders;

Подход 2: Shadow-таблицы

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

Фаза 1: Создание shadow-таблицы

CREATE TABLE orders_shadow AS SELECT * FROM orders WHERE 1=0; -- Копируем структуру
ALTER TABLE orders_shadow ADD COLUMN order_status_updated_at TIMESTAMP DEFAULT NULL;
-- Остальная новая структура

Фаза 2: Параллельная запись Триггер или приложение пишет в orders_shadow каждый раз, когда пишут в orders:

CREATE TRIGGER orders_shadow_sync AFTER INSERT ON orders
FOR EACH ROW
BEGIN
    INSERT INTO orders_shadow (id, order_number, ..., order_status_updated_at)
    VALUES (NEW.id, NEW.order_number, ..., NULL);
END;

Фаза 3: Заполнение исторических данных Пока триггер ловит новые данные, фоновый процесс копирует существующие данные в orders_shadow:

INSERT INTO orders_shadow SELECT id, order_number, ..., NULL FROM orders
WHERE id > ? AND id <= ? LIMIT 100000;

Фаза 4: Переключение чтения Когда всё синхронизировано, код читает из orders_shadow под флагом.

Фаза 5: Удаление старой таблицы После периода стабильности старую таблицу можно удалить или сделать архивной.

Подход 3: Двойная запись на уровне приложения

Не все изменения требуют триггеров на БД. Часто достаточно логики в приложении:

public class OrderMigration {
    private final OrderRepository oldRepo;
    private final OrderNewRepository newRepo;
    private final FeatureFlags flags;
    
    public void saveOrder(Order order) {
        // Пишем в старую таблицу (основной путь)
        oldRepo.save(order);
        
        // Пишем в новую, если флаг включен
        if (flags.isEnabled("write_to_orders_new")) {
            try {
                OrderNew orderNew = convertToOrderNew(order);
                newRepo.save(orderNew);
            } catch (Exception e) {
                // Логируем ошибку, но не ломаем основной путь
                logger.error("Failed to write to orders_new", e);
            }
        }
    }
}

Преимущества:

  • Гибкость: логика синхронизации в коде, легко изменять.
  • Контроль: ошибки в новой таблице не влияют на старую.

Недостатки:

  • Два обращения к БД, может быть медленнее.
  • Нужно гарантировать, что хотя бы одна запись удалась (идемпотентность).

Проверка целостности

После переключения на новую таблицу/индекс нужно убедиться, что миграция прошла корректно:

Счётчик строк:

SELECT COUNT(*) FROM orders;
SELECT COUNT(*) FROM orders_new;

Должны совпадать (с учётом данных, добавленных во время миграции).

Контроль агрегатов:

SELECT COUNT(*), SUM(total_amount), MAX(id) FROM orders;
SELECT COUNT(*), SUM(total_amount), MAX(id) FROM orders_new;

Выборочная проверка данных:

SELECT * FROM orders WHERE id IN (1, 100, 1000, 1000000, 499999999);
SELECT * FROM orders_new WHERE id IN (1, 100, 1000, 1000000, 499999999);

Строки должны совпадать.

Проверка нового индекса:

EXPLAIN SELECT * FROM orders_new WHERE order_status_updated_at > '2024-01-01';

Должен использоваться новый индекс.

На собеседовании:

  • «Для миграции большой таблицы я использую batch-копирование с параллельной синхронизацией. Сначала создаю новую таблицу с нужной схемой, копирую данные маленькими партиями в фоне, пока приложение пишет в обе таблицы. Когда копирование завершено, переключаю чтение на новую таблицу под feature-flag'ом. После периода наблюдения удаляю старую таблицу. Проверку целостности делаю сравнением счётчиков и выборочной проверкой.»

Особенности миграций в MongoDB

Гибкая схема и её вызовы

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

С одной стороны, это позволяет быстро добавлять новые функции: просто пишем документ с новыми полями, и никаких DDL-операций не нужно. С другой стороны, без явной дисциплины коллекция может превратиться в хаос разных версий документов, что усложняет чтение и поддержку.

Типичный сценарий хаоса:

// Документ v1
{ _id: 1, name: "Alice", email: "alice@example.com" }

// Документ v2 (кто-то добавил phone)
{ _id: 2, name: "Bob", email: "bob@example.com", phone: "+1234567" }

// Документ v3 (кто-то переименовал email в user_email)
{ _id: 3, name: "Charlie", user_email: "charlie@example.com", phone: "+7654321" }

// Документ v4 (структурировано: контактная информация)
{ _id: 4, name: "Diana", contact: { email: "diana@example.com", phone: "+7654321" } }

Код должен уметь работать со всеми этими формами, что быстро становится невыносимо.

Подход 1: Миграция на чтении (lazy migration)

Idea: при доступе к документу код проверяет его версию, преобразует в новый формат и сохраняет обратно. Со временем все документы постепенно обновляются.

public class UserService {
    public User getUser(String userId) {
        Document doc = userCollection.find(new Document("_id", userId)).first();
        User user = convertToLatestVersion(doc);
        
        if (doc.getInteger("schemaVersion", 1) < 3) {
            // Документ старой версии, обновляем его
            userCollection.updateOne(
                new Document("_id", userId),
                new Document("$set", user.toLatestDocument())
            );
        }
        return user;
    }
    
    private User convertToLatestVersion(Document doc) {
        int version = doc.getInteger("schemaVersion", 1);
        
        if (version == 1) {
            // Старый формат: email поле
            String email = (String) doc.get("email");
            return migrateFromV1(doc, email);
        } else if (version == 2) {
            // Промежуточный формат
            return migrateFromV2(doc);
        } else {
            // Текущая версия
            return docToUser(doc);
        }
    }
}

Преимущества:

  • Документы обновляются по мере доступа, нет отдельного batch-процесса.
  • Процесс распределён во времени и не добавляет нагрузку всколыхом.

Недостатки:

  • Логика чтения усложняется: нужны проверки версий и преобразования.
  • Если документ редко читается, он останется в старом формате месяцами.
  • Первый доступ к документу может быть медленнее из-за преобразования.

Подход 2: Фоновые миграторы

Отдельный процесс (scheduled job или микросервис) пробегает коллекцию и обновляет старые документы периодически.

@Scheduled(fixedRate = 60000) // Каждую минуту
public void migrateOldDocuments() {
    List<Document> oldDocs = userCollection.find(
        new Document("schemaVersion", new Document("$lt", CURRENT_VERSION))
    ).limit(1000).into(new ArrayList<>());
    
    for (Document doc : oldDocs) {
        Document updated = migrateDocument(doc);
        userCollection.replaceOne(
            new Document("_id", doc.get("_id")),
            updated
        );
    }
}

Преимущества:

  • Специализированный процесс, который не замедляет основное приложение.
  • Можно контролировать темп миграции, нагрузку на БД.
  • Логика преобразования отделена от основного кода.

Недостатки:

  • Нужна отдельная инфраструктура: cron-job, микросервис или scheduler.
  • Может потребоваться явная обработка конфликтов, если документ изменяется во время миграции.

Подход 3: Версионирование и ограничение числа версий

Явное поле schemaVersion указывает на версию документа. Приложение знает, какие версии оно поддерживает.

public class UserMigration {
    private static final int CURRENT_VERSION = 3;
    private static final int MIN_SUPPORTED_VERSION = 2; // Поддерживаем версии 2 и 3
    
    public User readAndMigrate(Document doc) {
        int version = doc.getInteger("schemaVersion", 1);
        
        if (version < MIN_SUPPORTED_VERSION) {
            // Слишком старая версия, требуется обновление приложения или БД
            throw new UnsupportedSchemaVersionException("Document version too old");
        }
        
        if (version < CURRENT_VERSION) {
            // Мигрируем на текущую версию
            return migrateToVersion(doc, version, CURRENT_VERSION);
        }
        
        return docToUser(doc);
    }
}

Правила версионирования:

  • Версия документа указывает его структуру.
  • При добавлении нового поля версия увеличивается.
  • Приложение поддерживает только последние N версий (например, текущую и две предыдущие).
  • Слишком старые документы требуют явной миграции перед использованием.

Практические принципы для MongoDB

  1. Явное версионирование: каждый документ должен иметь поле schemaVersion или _version, чтобы код знал, какой формат имеет данные.

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

  3. Комбинация подходов: lazy migration для частых доступов, фоновые миграторы для очистки старых документов.

  4. Мониторинг разнообразия версий: регулярный запрос типа db.users.aggregate([{ $group: { _id: "$schemaVersion", count: { $sum: 1 } } }]) показывает, какие версии ещё присутствуют в БД.

  5. Тестирование с разными версиями: код должен быть протестирован с документами каждой поддерживаемой версии.

На собеседовании:

  • «В MongoDB я использую явное версионирование документов. Каждый документ имеет поле schemaVersion. Приложение поддерживает несколько последних версий. При чтении код преобразует старый формат в новый (lazy migration) или фоновый процесс пробегает коллекцию и обновляет старые документы. Это позволяет эволюционировать схему без блокировок и даунтайма.»

Особенности миграций в Cassandra

Жёсткий query-based дизайн

Cassandra — это распределённая колоночная БД, оптимизированная для скорости чтения под конкретные паттерны запросов. В отличие от SQL, где вы пишете гибкие запросы, в Cassandra схема проектируется под конкретные запросы (query-driven design).

Пример: Нужно найти все заказы пользователя:

SELECT * FROM orders WHERE user_id = 123;

Для этого нужна таблица, денормализованная под этот запрос:

CREATE TABLE orders_by_user (
    user_id BIGINT,
    order_timestamp TIMESTAMP,
    order_id UUID,
    total_amount DECIMAL,
    PRIMARY KEY (user_id, order_timestamp)
);

Если позже появляется запрос «найти заказы по дате», нужна отдельная таблица:

CREATE TABLE orders_by_date (
    order_date DATE,
    order_timestamp TIMESTAMP,
    order_id UUID,
    total_amount DECIMAL,
    PRIMARY KEY (order_date, order_timestamp)
);

Одни и те же данные хранятся в разных таблицах, оптимизированных под разные запросы. Это денормализация и дублирование по дизайну.

Миграции как создание новых таблиц

Изменение схемы в Cassandra часто означает:

  1. Создание новой таблицы с нужной структурой.
  2. Одновременная запись в старую и новую таблицы.
  3. Постепенный переход чтения на новую таблицу.

Сценарий: добавили новое поле в заказ (e.g., discount_code)

-- Старая таблица
CREATE TABLE orders_by_user (
    user_id BIGINT,
    order_timestamp TIMESTAMP,
    order_id UUID,
    total_amount DECIMAL,
    PRIMARY KEY (user_id, order_timestamp)
);

-- Новая таблица с дополнительным полем
CREATE TABLE orders_by_user_v2 (
    user_id BIGINT,
    order_timestamp TIMESTAMP,
    order_id UUID,
    total_amount DECIMAL,
    discount_code VARCHAR,
    PRIMARY KEY (user_id, order_timestamp)
);

Код пишет в обе таблицы:

public void createOrder(Order order) {
    // Пишем в старую таблицу
    ordersSession.execute(
        insertOrderByUser(order)
    );
    
    // Пишем в новую таблицу
    if (featureFlags.isEnabled("write_orders_v2")) {
        ordersSession.execute(
            insertOrderByUserV2(order)
        );
    }
}

Фоновый процесс копирует данные:

public void migrateHistoricalData() {
    ResultSet rs = ordersSession.execute("SELECT * FROM orders_by_user");
    for (Row row : rs) {
        ordersSession.execute(
            insertOrderByUserV2(convertRowToOrderV2(row))
        );
    }
}

После завершения копирования код читает только из новой таблицы:

public List<Order> getOrdersByUser(long userId) {
    if (featureFlags.isEnabled("read_orders_v2")) {
        ResultSet rs = ordersSession.execute(
            selectOrdersV2ByUser(userId)
        );
    } else {
        ResultSet rs = ordersSession.execute(
            selectOrdersByUser(userId)
        );
    }
    // ... преобразование результатов
}

Удаление старых таблиц

Удаление таблицы — это не просто DROP TABLE. В Cassandra нужно учитывать:

Tombstones: когда строка удаляется, Cassandra создаёт tombstone (маркер удаления) вместо физического удаления. Со временем tombstone'ы накапливаются и требуют очистки через compaction.

DROP TABLE orders_by_user;

После этого:

  • Читать из этой таблицы больше нельзя.
  • Tombstone'ы остаются в системе и будут очищены во время compaction.
  • Никакой код не должен обращаться к удаляемой таблице.

Проверка перед удалением:

  • Убедитесь, что ни один клиент не читает из старой таблицы.
  • Убедитесь, что все новые данные успешно скопированы.
  • Дайте время на распространение информации о смене таблицы через приложение.

Ограничения глобальных миграций

Cassandra спроектирована для распределённой архитектуры, и глобальные миграции сложны:

Нет транзакций между таблицами: если пишем в старую и новую таблицы одновременно, и один из INSERT'ов не пройдёт, окажемся в несогласованном состоянии.

Решение: применяем idempotency. Каждый INSERT должен быть идемпотентным (его можно повторить без вреда). Обычно используют INSERT ... IF NOT EXISTS.

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

Решение: используем batch-копирование с ограничением на размер batches, чтобы не перегрузить кластер.

Практические принципы для Cassandra

  1. Forward-only эволюция: схема в Cassandra обычно только расширяется (добавляются новые таблицы, поля), не меняется.

  2. Новые таблицы для новых запросов: каждый новый паттерн чтения обычно означает новую таблицу.

  3. Двойная запись и постепенное переключение: старая таблица остаётся источником истины, новая заполняется параллельно, потом читаем из новой.

  4. Идемпотентность: все миграционные операции должны быть идемпотентными (безопасны для повтора).

  5. Мониторинг tombstone'ов: регулярная очистка через compaction, чтобы удаленные данные не забивали диск.

На собеседовании:

  • «Cassandra не типичная база для сложных миграций, потому что она оптимизирована под конкретные запросы. Когда нужно изменить схему, обычно создаю новую таблицу, оптимизированную под новый паттерн. Затем пишу в обе таблицы одновременно, фоновый процесс копирует старые данные в новую таблицу, и после синхронизации переключаюсь на чтение из новой. Идемпотентность критична — операции должны быть безопасны для повтора.»

Особенности миграций в Elasticsearch и OpenSearch

Индексы как проекции, а не источник истины

Elasticsearch/OpenSearch часто используются как вторичное хранилище — поиск и аналитика над данными, которые хранятся в основной БД. Индекс — это проекция, построенная из primary source (PostgreSQL, MongoDB и т.д.).

Это принципиально меняет подход к миграциям:

  • Индекс можно пересчитать с нуля, перестроив его из источника.
  • Нет риска потери данных, потому что данные живут в primary DB.
  • Миграция индекса — это операция, которую можно повторить без вреда.

Типичная архитектура:

Primary DB (PostgreSQL)
    ↓ (индексируется)
Elasticsearch (полнотекстовый поиск)

Если нужно изменить структуру индекса, мы:

  1. Создаём новый индекс с новой структурой.
  2. Пересчитываем данные из primary DB.
  3. Переключаем alias на новый индекс.
  4. Удаляем старый индекс.

На любом этапе можно откатиться, просто переключив alias обратно на старый индекс.

Переиндексация: процесс обновления индекса

Сценарий: нужно добавить в индекс новое поле category_id, изменить тип существующего поля price с integer на keyword.

Шаг 1: Создание нового индекса с новой схемой (mapping)

PUT /products_v2
{
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "name": { "type": "text" },
      "price": { "type": "keyword" },
      "category_id": { "type": "keyword" }
    }
  }
}

Шаг 2: Переиндексация из старого индекса

POST /_reindex
{
  "source": { "index": "products" },
  "dest": { "index": "products_v2" },
  "script": {
    "source": "ctx._source.category_id = params.default_category",
    "params": { "default_category": "unknown" }
  }
}

Это копирует все документы из products в products_v2, применяя трансформацию (в этом случае добавляя поле category_id со значением "unknown").

Шаг 3: Переключение alias'а Вместо прямого использования имён индексов, приложение обращается к alias'у (например, products):

// Сначала создаём alias на старый индекс
POST /_aliases
{
  "actions": [
    { "add": { "index": "products", "alias": "products_read" } }
  ]
}

// После переиндексации переключаем alias на новый индекс
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products", "alias": "products_read" } },
    { "add": { "index": "products_v2", "alias": "products_read" } }
  ]
}

Код читает из products_read, и переключение alias'а становится атомарной операцией для клиента.

Шаг 4: Удаление старого индекса

DELETE /products

Версионирование индексов

Индексы часто именуются с версией: products_v1, products_v2 и т.д. Alias указывает на текущую версию.

Преимущества:

  • История всех версий индексов хранится.
  • Легко откатиться на предыдущую версию, просто переключив alias.
  • Можно запустить новую версию параллельно, тестируя перед переключением.

Типичная схема именования:

products_v1 ← alias: products (до миграции)
products_v2 ← alias: products (после миграции)

Если обнаружим проблему в products_v2, просто переключимся обратно:

POST /_aliases
{
  "actions": [
    { "remove": { "index": "products_v2", "alias": "products" } },
    { "add": { "index": "products_v1", "alias": "products" } }
  ]
}

Time-based индексы (например, daily indices)

В системах с высокой нагрузкой данные часто разбиваются по времени: products-2024-01-15, products-2024-01-16 и т.д. Это упрощает удаление старых данных (просто удалить старый индекс) и распараллеливание по шардам.

Миграция в этом случае требует обновления каждого индекса:

POST /_reindex
{
  "source": { "index": "products-2024-*" },
  "dest": { "index": "products_v2-2024-01-01" }
}

Или отдельно для каждого дня:

products-2024-01-15 → products_v2-2024-01-15
products-2024-01-16 → products_v2-2024-01-16
...

Затем обновляется index template, чтобы новые индексы создавались по новой схеме.

Практические подходы для Elasticsearch/OpenSearch

  1. Alias как абстракция: приложение всегда читает из alias'а, который может указывать на разные версии индекса.

  2. Версионирование индексов: каждая версия имеет номер в названии, облегчая отслеживание и откат.

  3. Переиндексация как безопасная операция: можно запустить reindex в фоне, он не блокирует основной трафик.

  4. Параллельная индексация: во время переиндексации приложение может писать в оба индекса (старый и новый) под feature-flag'ом.

  5. Проверка целостности: после переиндексации сравниваем счётчик документов, случайные выборки.

На собеседовании:

  • «Elasticsearch используется как вторичное хранилище, поэтому миграция индекса не критична. Я создаю новый индекс с новой структурой, запускаю переиндексацию из старого индекса, это может быть долго, но не блокирует основной трафик. Когда переиндексация завершена, переключаю alias на новый индекс. Если что-то пойдёт не так, откатываюсь, переключив alias обратно. Старый индекс удаляю после периода наблюдения.»

Особенности миграций в ClickHouse и OLAP-БД

Большие объёмы данных и сложность ALTER

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

Традиционные DDL-операции в этом контексте становятся дорогостоящими:

  • ALTER TABLE users ADD COLUMN age INT DEFAULT 0; может требовать переписи всех данных на каждом узле кластера.
  • Если таблица содержит 100 ТБ данных, распределённые на 10 узлов, это может занимать часы.

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

Подход 1: Создание новых таблиц с новой схемой

Вместо изменения существующей таблицы создаём новую с нужной структурой.

-- Старая таблица
CREATE TABLE user_analytics (
    user_id UInt64,
    name String,
    email String,
    event_date Date
) ENGINE = MergeTree()
ORDER BY (user_id, event_date);

-- Новая таблица с дополнительным полем
CREATE TABLE user_analytics_v2 (
    user_id UInt64,
    name String,
    email String,
    age UInt8,
    event_date Date
) ENGINE = MergeTree()
ORDER BY (user_id, event_date);

Фоновый процесс копирует данные:

INSERT INTO user_analytics_v2 
SELECT user_id, name, email, 0 as age, event_date 
FROM user_analytics;

Или используем CREATE TABLE AS SELECT для трансформации:

CREATE TABLE user_analytics_v2 AS 
SELECT user_id, name, email, 0 as age, event_date 
FROM user_analytics;

Подход 2: Материализованные представления (materialized views)

Материализованное представление — это таблица, которая автоматически обновляется при изменении источника.

-- Таблица с логами событий
CREATE TABLE events (
    event_id UInt64,
    user_id UInt64,
    event_type String,
    timestamp DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, timestamp);

-- Материализованное представление: агрегированные данные по пользователям
CREATE MATERIALIZED VIEW user_events_stats
ENGINE = MergeTree()
ORDER BY user_id
AS SELECT 
    user_id,
    COUNT() as event_count,
    MAX(timestamp) as last_event
FROM events
GROUP BY user_id;

Когда в events добавляется новое событие, user_events_stats автоматически обновляется. Если нужно изменить агрегацию, создаём новое materialized view и постепенно переключаем дашборды.

Подход 3: Послойная миграция (raw → агрегаты → витрины)

ClickHouse часто используется с многослойной архитектурой:

Raw слой: сырые данные, как они пришли из источника (логи, события). Могут содержать жёсткие дополнительные поля.

CREATE TABLE events_raw (
    timestamp DateTime,
    user_id UInt64,
    event_json String,  -- JSON с деталями события
    received_at DateTime
) ENGINE = MergeTree()
ORDER BY (user_id, timestamp);

Агрегаты: предварительно обработанные и агрегированные данные для быстрых запросов.

CREATE TABLE user_daily_stats (
    date Date,
    user_id UInt64,
    event_count UInt32,
    unique_days UInt32
) ENGINE = MergeTree()
ORDER BY (date, user_id);

Витрины: финальные таблицы для дашбордов и отчётности, часто денормализованные.

CREATE TABLE dashboard_users (
    user_id UInt64,
    name String,
    total_events UInt64,
    last_active DateTime
) ENGINE = MergeTree()
ORDER BY user_id;

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

Особенности распределённых таблиц

В распределённом ClickHouse (кластер из нескольких узлов) есть локальные таблицы на каждом узле и распределённые таблицы, которые координируют запросы.

-- Локальная таблица на каждом узле
CREATE TABLE events_local (
    event_id UInt64,
    user_id UInt64,
    ...
) ENGINE = MergeTree() ...;

-- Распределённая таблица, координирующая запросы
CREATE TABLE events (
    event_id UInt64,
    user_id UInt64,
    ...
) ENGINE = Distributed('cluster_name', 'default', 'events_local');

Миграция требует обновления на всех узлах:

  1. На каждом узле создаём новую локальную таблицу events_local_v2.
  2. На координаторе создаём новую распределённую таблицу events_v2, указывающую на events_local_v2.
  3. Переключаем дашборды на новую таблицу.

Совместимость нескольких версий таблиц

Часто невозможно одновременно переключить все дашборды и отчёты. ClickHouse позволяет поддерживать несколько версий таблиц параллельно:

-- Старая таблица
SELECT * FROM user_stats;

-- Новая таблица
SELECT * FROM user_stats_v2;

Дашборды постепенно переключаются на новую версию, старая таблица удаляется только после полного перехода.

Практические подходы для ClickHouse

  1. Не изменяй существующие таблицы, создавай новые: это фундаментальный принцип для больших объёмов данных.

  2. Послойная архитектура: разделяй raw-данные, агрегаты и витрины. Миграции на уровне агрегатов не трогают сырые данные.

  3. Материализованные представления: используй для автоматического обновления агрегатов при изменении источника.

  4. Версионирование таблиц: table_v1, table_v2 и т.д., дашборды явно указывают, какую версию используют.

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

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

На собеседовании:

  • «ClickHouse часто используется с послойной архитектурой: raw-данные, агрегаты и витрины. Для миграции я создаю новую таблицу с нужной структурой, заполняю её данными из существующей таблицы (это фоновая операция, не блокирует основной трафик), потом постепенно переключаю дашборды и отчёты на новую таблицу. Если есть материализованные представления для агрегации, создаю новое представление, потом переключаю витрины. Версионирование таблиц позволяет поддерживать несколько версий параллельно.»

Как говорить о миграциях на собеседовании на уровне архитектуры

Правильный уровень абстракции

На собеседовании оценивают не знание конкретного синтаксиса SQL или Cassandra Query Language, а архитектурное мышление. Поэтому важно говорить на правильном уровне абстракции.

Неправильный уровень (слишком низкий):

«Я бы написал скрипт ALTER TABLE users ADD COLUMN phone VARCHAR(20), потом создал индекс через CREATE INDEX...»

Это выглядит как junior-разработчик, который знает SQL, но не думает о последствиях.

Правильный уровень (архитектурный):

«Я бы использовал expand-and-contract: сначала добавляю новое поле, мой код пишет в оба поля (старое и новое), фоновый процесс постепенно мигрирует данные, потом переключаю код на чтение из нового поля, и только после этого удаляю старое. Это гарантирует, что система остаётся доступной и откатываема на каждом этапе.»

Это показывает понимание фаз, риск-менеджмента и долгосрочного планирования.

Типичные темы на собеседовании

Вопрос 1: Как изменить тип колонки без даунтайма?

Ответ должен охватывать:

  • Создание новой колонки с нужным типом.
  • Миграция данных в фоне (batch-копирование).
  • Параллельную запись в обе колонки.
  • Переключение на чтение из новой колонки под feature-flag'ом.
  • Удаление старой колонки после периода наблюдения.

Вопрос 2: Как мигрировать гигантскую таблицу?

Ответ должен показывать:

  • Понимание, что обычный ALTER заблокирует таблицу.
  • Знание техник batch-копирования и shadow-таблиц.
  • Как контролировать нагрузку (размер батчей, периоды между ними).
  • Как проверить целостность данных.
  • Какие инструменты можно использовать (не обязательно конкретные, но подход).

Вопрос 3: Как синхронизировать основную БД и вторичное хранилище (Elasticsearch, ClickHouse)?

Ответ должен охватывать:

  • Различие между source of truth и проекциями.
  • Возможность пересчитать вторичное хранилище из основного.
  • Двойная запись (write to both during migration).
  • Постепенное переключение чтения.
  • Откат через пересборку вторичного хранилища.

Вопрос 4: Как эволюционирует схема в многосервисной архитектуре?

Ответ должен показывать:

  • Понимание, что разные сервисы обновляются независимо.
  • Backward-compatibility как требование.
  • Feature-flag'и для управления использованием новой схемы.
  • Версионирование API и данных.
  • Как откатиться, если один сервис не совместим.

Желательные акценты в ответах

Backward-compatibility: «Мой новый код должен работать не только с новыми данными, но и со старыми версиями схемы. Это даёт буфер для исправления ошибок.»

Expand-and-contract: «Я никогда не меняю и не удаляю что-то в одной операции. Сначала добавляю новое, мигрирую, потом удаляю старое. Это позволяет откатиться на каждом этапе.»

Feature-flag'и: «Я разделяю деплой кода от использования новой схемы. Код может быть выкачен, но флаг выключен, и система работает как раньше. Флаг включаю постепенно, мониторя ошибки.»

Мониторинг и проверки: «Перед переключением я проверяю счётчик строк, выборочные данные, и оставляю период наблюдения, чтобы убедиться, что всё работает правильно.»

Откат и пересборка: «Если что-то пошло не так, я могу откатиться или пересчитать вторичные хранилища (индексы, аналитические БД). Это возможно, потому что они проекции, а не источник истины.»

Примеры формулировок для собеседования

Компактный ответ на простой вопрос:

«Я добавлю новую колонку, запущу миграцию данных в фоне под feature-flag'ом, переключусь на чтение из новой колонки, потом удалю старую. На каждом этапе система остаётся доступной и откатываема.»

Развёрнутый ответ для сложного вопроса:

«Для больших таблиц я использую batch-копирование. Создаю новую таблицу с нужной схемой, фоновый процесс копирует данные маленькими партиями — например, 100k строк за раз. Это не блокирует основной трафик. Параллельно мой код пишет в обе таблицы. Когда копирование завершено, я проверяю целостность — сравниваю счётчики, выборочные данные — и переключаюсь на чтение под feature-flag'ом. Если всё стабильно неделю, удаляю старую таблицу.»

Ответ про многосервисную архитектуру:

«Каждый сервис обновляется независимо, поэтому миграция должна быть backward-compatible. Я использую expand-and-contract: новый код одновременно поддерживает старую и новую версию схемы. На этапе expand новая версия добавляется, на этапе contract старая удаляется, но только после того, как все сервисы обновлены. Feature-flag'и позволяют мне контролировать, когда включать новое поведение, даже если код уже обновлён.»

Что НЕ нужно говорить на собеседовании

  • Конкретные инструменты без контекста: «Я использую Flyway» звучит как механическое применение инструмента, а не понимание.
  • Слишком низкий уровень: SQL-команды без объяснения стратегии.
  • Предположение о бесконечных ресурсах: «Я просто возьму бэкап и переделаю с нуля» — это не масштабируется для больших объёмов.
  • Игнорирование отката и мониторинга: если вы не упомянули, как будете откатываться или проверять результаты, это выглядит наивно.

Краткий чек-лист по миграциям схем

Набор тезисов, которые Senior должен уверенно проговаривать или хотя бы понимать в контексте своего проекта:

Основные различия подходов

  • Как эволюция схемы отличается в RDBMS (жёсткая, требует DDL) от schema-less (гибкая, требует версионирования) и schema-on-read (многослойная, требует координации).
  • Почему в RDBMS миграция может заблокировать таблицу, а в MongoDB это не происходит (и почему это не всегда хорошо).
  • Как MongoDB требует явного версионирования документов, а PostgreSQL имеет жёсткую схему на уровне БД.

Стратегии миграций

  • Backward-compatible изменения: новый код работает с новой схемой, старый код работает со старой (или с новой через fallback).
  • Expand-and-contract: добавляю новое, мигрирую, удаляю старое. На каждом этапе система откатываема.
  • Feature-flag'и: разделяю деплой кода от использования новой схемы, включаю постепенно, могу откатиться без отката БД.
  • Batch-копирование: большие таблицы мигрирую маленькими партиями в фоне, система остаётся доступной.
  • Shadow-таблицы: параллельное ведение новой таблицы, переключение после синхронизации.
  • Двойная запись: пишу в обе таблицы на время миграции, потом переключаюсь на одну.

Специфика разных БД

  • MongoDB: lazy migration (на чтении), фоновые миграторы, явное версионирование документов.
  • Cassandra: query-driven design означает новые таблицы под новые запросы, forward-only эволюция, идемпотентность критична.
  • Elasticsearch: индексы как проекции, переиндексация и переключение alias'а, версионирование индексов, откат просто — переключил alias обратно.
  • ClickHouse: послойная архитектура (raw → агрегаты → витрины), никогда не меняю существующие таблицы, создаю новые, материализованные представления для автоматизации.

Практические навыки

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

Аспекты многосервисной архитектуры

  • Каждый сервис обновляется независимо, миграция должна быть backward-compatible.
  • Версионирование API и данных позволяет сосуществовать разным версиям.
  • Feature-flag'и критичны для управления миграцией в распределённой системе.
  • Source of truth (основная БД) отделена от проекций (кэши, индексы, аналитика), проекции можно пересчитать.

На собеседовании

  • Приготовь 2-3 примера миграций, которые ты делал или проектировал.
  • Объясняй на уровне архитектуры, а не конкретных команд.
  • Упоминай фазы, риск-менеджмент, откат и мониторинг.
  • Показывай, что ты думаешь о production-reality: большие объёмы, высокая нагрузка, необходимость откатов.
  • Если не знаешь конкретные особенности какой-то БД, честно скажи, но объясни общий подход.

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

Проектирование индексов

Зачем Senior backend-разработчику глубоко понимать индексы и запросы

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

Почему примитивное понимание приводит к проблемам

Когда разработчик воспринимает индекс только как способ ускорить чтение, он часто упускает фундаментальные издержки:

  • Деградация производительности записи. Каждый индекс — это дополнительная структура, которую БД должна поддерживать при insert/update/delete. Неконтролируемое добавление индексов превращает быструю запись в процесс многократного прохождения по B-tree структурам, блокировкам и дисковым операциям. На системе с высокой частотой обновлений это может снизить throughput записи в несколько раз.

  • Кэш-враждебность и full scan. Неправильно подобранный индекс может закэшировать часть данных, которые для конкретного запроса не имеют смысла. Результат: индекс занял память и процессорное время инициализации, но запрос всё равно пошёл на full scan из-за неудачного использования leftmost prefix или функции над колонкой.

  • Маскировка архитектурных ошибок. Если вы создали кучу индексов, чтобы ускорить плохо спроектированную схему, вы не решили реальную проблему — проблему модели данных. При появлении новых требований каждый новый запрос потребует новый индекс, и система станет невероятно сложной в поддержке.

Сигналы продакшена, на которые нужно сразу среагировать

Когда в мониторинге появляются эти паттерны, проблема часто лежит в индексации:

  • Рост latency конкретных endpoint'ов без явной причины. Например, endpoint для получения истории пользователя за последний месяц начал отзываться 5 секунд вместо 200 мс. Причина часто в том, что объём данных вырос, и без индекса по (user_id, created_at) запрос теперь сканирует миллионы строк вместо сотни.

  • Всплеск CPU/IO на БД во время определённых бизнес-часов. Если каждый запрос от аналитики сканит всю таблицу orders с 100 млн записей, то при 100 параллельных запросах БД будет буквально на полу.

  • Появление slow query log'а. Запросы, которые раньше выполнялись мгновенно, внезапно заняли место в slow log'е. Это никогда не случается без причины — либо объём данных вырос критично, либо план запроса изменился из-за статистики, либо индекс начал использоваться неправильно.

  • Memory pressure и swapping. Избыточные индексы занимают оперативную память. Если все индексы перестали помещаться в buffer pool, БД начнёт вытеснять горячие данные и переходить на диск.

Что ожидает услышать интервьюер

На собеседовании интервьюер не ищет ответ вроде «я использую индексы». Вместо этого он ждёт понимания фундаментальных компромиссов:

  • Сложность индексирования растёт экспоненциально с числом условий в WHERE. Если вы попытаетесь покрыть индексом все возможные комбинации фильтров, вы получите сотни индексов. Нужно понимать, какие фильтры будут появляться чаще всего, и оптимизировать под них. «Я бы сначала посмотрел на метрики — какие запросы выполняются чаще всего и сколько времени они занимают. Затем я бы создал индексы под самые дорогие запросы, а не под все подряд.»

  • Индексы — это не только таблицы. В RDBMS важны составные индексы, в Mongo — порядок полей в index, в Elasticsearch — маппинг типов. Ошибка в выборе типа индекса в конкретной БД может полностью обнулить его эффект.

  • Query plan — это язык диалога с БД. Если вы не можете прочитать EXPLAIN ANALYZE и объяснить, почему запрос выполняется именно так, а не иначе, то вы не можете диагностировать проблемы. «Я бы запустил EXPLAIN ANALYZE на медленном запросе, посмотрел, происходит ли index scan или full table scan, где возникает bottleneck, и только потом предложил бы решение.»

  • Индексация — это компромисс читаемости против записи. Нет универсального ответа. Для write-heavy систем вам может потребоваться минимум индексов. Для read-heavy систем вы будете создавать специализированные индексы под каждый критичный query. «В нашем случае это была аналитическая система, где писали редко, но много читали. Поэтому я согласился с денормализацией и добавлением индексов под все типичные разрезы аналитики.»


Общие принципы индексации: что ускоряет индекс, а что ломает его эффективность

Базовая идея и машинерия

Индекс — это вспомогательная структура данных, которая позволяет быстро найти нужные строки (или документы) без необходимости сканировать каждую запись подряд.

Ключевая машинерия:

  • Индекс хранит ключевые значения в отсортированном виде (например, B-tree в RDBMS, инвертированный индекс в Elasticsearch).
  • К каждому значению привязаны ссылки на то, где в основной таблице находятся соответствующие записи (обычно через row ID или документ ID).
  • Поиск по индексу выполняется за O(log n), тогда как full scan — это O(n).

Почему это важно для продакшена:

  • Разница между 10 ms и 10 minutes может быть разницей между единственным благодаря индексом запросом и полным full scan'ом.
  • На таблице с 1 млн строк поиск по индексу может занять считанные миллисекунды, поиск без индекса — секунды или минуты.

Когда индекс действительно помогает

Есть чёткие сценарии, где индекс даёт выигрыш:

  • Поиск по точному значению: WHERE user_id = 123 с индексом по user_id. БД переходит прямо к нужному row ID, не сканируя таблицу.

  • Поиск по диапазону: WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31' с индексом по created_at. БД находит первую запись в диапазоне и читает только нужные строки.

  • Сортировка: ORDER BY timestamp DESC может использовать индекс, если данные уже упорядочены, избегая отдельной операции сортировки в памяти.

  • Join'ы: Когда вы джойните таблицы по foreign key, индекс на FK-колонке в одной таблице позволяет быстро найти соответствующие записи в другой таблице.

  • Агрегация: GROUP BY с индексом по группируемой колонке может ускорить сканирование и группировку.

Цены, которые вы платите за каждый индекс

Ничего не даётся бесплатно. Каждый индекс — это инвестиция, которая окупается только если он действительно используется:

  • Дополнительное место на диске. Индекс занимает место, часто несколько десятков процентов от размера самой таблицы. Таблица размером 100 GB с несколькими индексами может занять 150–200 GB.

  • Дополнительное место в памяти (buffer pool). Если индекс горячий (часто используется), БД кэширует его в RAM. Это вытесняет другие полезные данные. На системе с ограниченной памятью это приводит к замедлению всей БД.

  • Замедление записи. Каждый insert/update/delete должен обновить все индексы. Если у таблицы 10 индексов, то вставка одной строки требует обновления 10 структур данных, 10 дополнительных дисковых операций, 10 потенциальных блокировок.

  • Усложнение плана запроса. Оптимизатор запросов должен решить, какой из доступных индексов использовать. Если их много, это может привести к неправильному выбору.

  • Миграционные трудозатраты. Добавление индекса на большую таблицу может заблокировать другие операции на часы (в зависимости от типа БД и стратегии миграции).

Типичные причины, почему индекс не срабатывает

Даже если индекс создан, он может остаться невостребованным:

Функция или выражение над колонкой. Если у вас есть индекс по user_id, но запрос звучит как WHERE UPPER(name) = 'JOHN', индекс на name не поможет, потому что запрос требует применить функцию к каждому значению.

-- Плохо: функция над колонкой ломает индекс
SELECT * FROM users WHERE UPPER(name) = 'JOHN';

-- Хорошо: индекс может сработать (если индекс на name)
SELECT * FROM users WHERE name = 'John';

Неиспользуемые колонки в фильтрации. Если у вас есть индекс по (user_id, created_at), но запрос фильтрует по created_at И status (где status не индексирован), часть условий не будет покрыта индексом, и может потребоваться additional filtering или full scan.

Нарушение leftmost prefix. Составной индекс (col1, col2, col3) может быть использован для:

  • WHERE col1 = ?
  • WHERE col1 = ? AND col2 = ?
  • WHERE col1 = ? AND col2 = ? AND col3 = ?

Но НЕ может быть эффективно использован для:

  • WHERE col2 = ?
  • WHERE col3 = ?
  • WHERE col1 = ? AND col3 = ? (пропущена col2)

Низкая селективность. Индекс по колонке с 2 уникальными значениями (например, boolean deleted = true/false) практически бесполезен. БД может решить, что быстрее сканировать таблицу целиком, чем прыгать по индексу туда-сюда.

Фильтрация по неиндексированной колонке в составном фильтре. Если у вас есть индекс по (user_id) и запрос WHERE user_id = 1 AND deleted = true, а колонка deleted не индексирована, то БД использует индекс по user_id для поиска записей, но потом всё равно должна проверить условие deleted для каждого найденного row. Это почти не ускоряет запрос, если большинство записей имеют deleted = false.

Баланс: достаточно, но не избыточно

Стратегия индексирования Senior-уровня:

  1. Начните с медленных запросов. Не создавайте индексы упреждающе. Дождитесь, пока реальные запросы покажут узкие места. Используйте slow query log, APM, профайлеры.

  2. Индексируйте критичные колонки фильтрации и JOIN'ов. Если 80% вашего трафика — это запросы вида SELECT * FROM orders WHERE user_id = ? AND status = 'active', создайте индекс (user_id, status).

  3. Оценивайте стоимость записи. Если таблица получает 10k insert'ов в секунду, добавление каждого нового индекса увеличит это время. Может быть, имеет смысл использовать асинхронное индексирование (для некритичных индексов).

  4. Удаляйте мёртвые индексы. Регулярно смотрите, какие индексы никогда не используются, и удаляйте их.

  5. Проверяйте покрытие ключевых запросов. Несколько индексов под самые важные запросы лучше, чем сотни индексов под все возможные комбинации фильтров.


Индексы и запросы в RDBMS (MySQL, PostgreSQL и подобные)

Основные типы индексов

B-tree индекс (дефолт). Это основной и самый универсальный тип индекса в любой RDBMS. Данные хранятся в виде сбалансированного дерева, что гарантирует O(log n) время поиска как для точного совпадения, так и для диапазона.

  • Использует для: WHERE col = value, WHERE col > value, WHERE col BETWEEN a AND b, ORDER BY col.
  • Минусы: требует больше дискового места, медленнее на очень высокой размерности данных.

Составные (multi-column) индексы. Индекс одновременно по нескольким колонкам. Это мощный инструмент, но требует правильного проектирования.

  • Пример: CREATE INDEX idx_orders ON orders(user_id, created_at, status).
  • Использует для: запросы, фильтрующие по префиксу колонок (user_id, затем user_id + created_at, затем user_id + created_at + status).
  • Критично правильное расположение колонок (см. ниже).

Уникальные индексы. Гарантируют, что все значения в индексированной колонке (или комбинации колонок) уникальны.

  • Пример: CREATE UNIQUE INDEX idx_email ON users(email).
  • Помимо ускорения, служат констрейнтом целостности данных.

Partial индексы (в PostgreSQL и некоторых версиях MySQL). Индекс по подмножеству строк таблицы, определённому WHERE условием.

  • Пример: CREATE INDEX idx_active_users ON users(user_id) WHERE status = 'active'.
  • Используется когда: большинство запросов фильтруют по определённому условию (например, all active records).
  • Плюс: меньше места, чем full index.

Expression индексы (PostgreSQL). Индекс не по самой колонке, а по выражению, включающему эту колонку.

  • Пример: CREATE INDEX idx_lower_email ON users(LOWER(email)).
  • Позволяет ускорить запросы вроде WHERE LOWER(email) = 'john@example.com'.

Специальные индексы: GiST, GIN, BRIN (PostgreSQL), HASH (MySQL). Для специфичных типов данных и запросов (геометрия, full-text search, JSON, большие диапазоны).

  • На этом уровне достаточно понимать, что они существуют и когда их использование может быть полезным, чем углубляться в детали.

Составные индексы и порядок колонок (критичная тема)

Порядок колонок в составном индексе — это одна из самых частых ошибок в проектировании.

Правило ESR (Equality, Sort, Range):

Когда вы создаёте составной индекс, расположите колонки в таком порядке:

  1. Equality колонки (WHERE col = value) — те, по которым идёт точное сравнение.
  2. Sort колонки (ORDER BY) — те, по которым нужна сортировка.
  3. Range колонки (WHERE col > value) — те, по которым идёт диапазон.

Пример, на практике:

-- Типичный запрос:
SELECT * FROM orders 
WHERE user_id = 1 AND status = 'completed' 
ORDER BY created_at DESC 
LIMIT 10;

-- Правильный индекс (ESR):
CREATE INDEX idx_orders ON orders(user_id, status, created_at DESC);

-- Неправильный индекс (например, так часто ошибаются):
CREATE INDEX idx_orders_bad ON orders(user_id, created_at, status);
-- Почему плохо? Потому что для сортировки по created_at нужно сначала
-- пройти по user_id (OK), потом по status (OK), но затем колонка
-- created_at уже не гарантирует упорядоченность, если есть разные статусы.

Leftmost prefix и его границы:

Индекс (user_id, status, created_at) может быть использован для:

  • WHERE user_id = 1 — использует префикс (user_id).
  • WHERE user_id = 1 AND status = 'completed' — использует префикс (user_id, status).
  • WHERE user_id = 1 AND status = 'completed' ORDER BY created_at — использует весь индекс.

Но НЕ может быть эффективно использован для:

  • WHERE status = 'completed' — пропущена user_id, весь индекс бесполезен.
  • WHERE user_id = 1 ORDER BY status — если вам нужна сортировка по status, а не по следующей колонке в индексе.

Индексы и JOIN'ы

JOIN'ы — это одна из самых дорогостоящих операций в RDBMS, и правильная индексация критична.

Основной принцип: индексируйте колонки, по которым вы джойните.

-- Две таблицы
SELECT o.*, u.name 
FROM orders o 
JOIN users u ON o.user_id = u.id 
WHERE o.status = 'pending';

-- Необходимые индексы:

-- 1. На колонке JOIN'а справа (users.id) — обычно это PRIMARY KEY, уже есть.
-- 2. На колонке JOIN'а слева (orders.user_id) — часто забывают.
-- 3. На фильтруемой колонке (orders.status) — если это часто фильтруется.

-- Идеально: составной индекс на (user_id, status)
CREATE INDEX idx_orders_join ON orders(user_id, status);

Типы JOIN'ов и их чувствительность к индексам:

  • Nested Loop Join: для каждой строки из внешней таблицы выполняется поиск по внутренней таблице. Если внутренняя таблица большая без индекса, это очень медленно.

  • Hash Join: обе таблицы хешируются и затем сравниваются. Индексы помогают меньше, но всё равно нужны для фильтрации до join'а.

  • Merge Join: обе таблицы должны быть отсортированы по колонке join'а. Если есть индекс, сортировка уже есть.

Покрывающие индексы (covering indexes)

Покрывающий индекс — это индекс, который содержит все колонки, нужные для ответа на запрос. БД не нужно обращаться к основной таблице.

-- Запрос
SELECT user_id, total_amount, status 
FROM orders 
WHERE user_id = 123 AND created_at > '2024-01-01' 
ORDER BY created_at DESC;

-- Обычный индекс: (user_id, created_at)
-- БД найдёт строки по индексу, потом должна прочитать total_amount и status из таблицы.
-- Это требует дополнительных дисковых операций (disk seeks).

-- Покрывающий индекс: (user_id, created_at, total_amount, status)
-- БД находит всю информацию в индексе, не обращаясь к основной таблице.
-- Значительно быстрее.

CREATE INDEX idx_orders_covering ON orders(user_id, created_at) 
INCLUDE (total_amount, status);  -- Некоторые БД используют INCLUDE

Когда использовать:

  • Для часто выполняемых запросов с небольшим числом колонок в SELECT.
  • Когда колонки в SELECT — это значения, которые нелегко получить из основной таблицы.

Минусы:

  • Индекс становится больше, занимает больше памяти.
  • При обновлении INCLUDE-колонок индекс тоже обновляется.

Типичные анти-паттерны в RDBMS

Индекс по boolean или enum с низкой селективностью.

-- Плохо:
CREATE INDEX idx_deleted ON users(deleted);

-- Почему? Колонка deleted имеет только 2 значения (true/false).
-- Если deleted = false для 95% записей, то индекс практически не помогает.
-- БД выполнит full scan быстрее, чем будет прыгать по индексу.

«Я индексирую все подряд».

-- Плохо:
CREATE INDEX idx_1 ON orders(user_id);
CREATE INDEX idx_2 ON orders(status);
CREATE INDEX idx_3 ON orders(created_at);
CREATE INDEX idx_4 ON orders(user_id, status);
CREATE INDEX idx_5 ON orders(user_id, created_at);
-- ... и так далее на сотни комбинаций

-- Это замедляет WRITE операции, занимает много места, усложняет жизнь.

-- Хорошо:
CREATE INDEX idx_main ON orders(user_id, status, created_at);
-- Этого индекса часто достаточно для основных запросов.

Индекс на автоинкрементный PRIMARY KEY.

-- Плохо:
CREATE INDEX idx_id ON orders(id);

-- Почему? id уже PRIMARY KEY и имеет индекс автоматически.
-- Дополнительный индекс — это трата места и времени.

Индексы и запросы в MongoDB

Документная модель и как она влияет на индексацию

MongoDB хранит данные в виде документов (JSON-подобные структуры), а не в строках таблиц. Это кардинально меняет подход к индексации.

// Пример документа в MongoDB
{
  _id: ObjectId("..."),
  user_id: 123,
  email: "john@example.com",
  profile: {
    name: "John",
    age: 30,
    preferences: {
      notifications: true,
      theme: "dark"
    }
  },
  created_at: ISODate("2024-01-15"),
  tags: ["vip", "active"]
}

Индексирование по вложенным полям:

// Индекс по простой колонке
db.users.createIndex({ user_id: 1 });

// Индекс по вложенному полю
db.users.createIndex({ "profile.name": 1 });

// Индекс по полю внутри массива
db.users.createIndex({ tags: 1 });
// Этот индекс позволяет быстро найти все документы с определённым тегом

Порядок и направление в составных индексах:

// Типичный запрос
db.users.find({ user_id: 123, status: "active" }).sort({ created_at: -1 });

// Правильный индекс (ESR: Equality, Sort, Range)
db.users.createIndex({ user_id: 1, status: 1, created_at: -1 });

// Этот индекс покрывает всё:
// - Поиск по user_id и status (равенство)
// - Сортировка по created_at в убывающем порядке
// Без дополнительной сортировки в памяти

Особенности MongoDB-индексации

Необходимость индексов на фильтруемых и сортируемых полях:

// Хорошо
db.orders.createIndex({ user_id: 1 });
db.orders.find({ user_id: 123 });  // Быстро, используется индекс

// Плохо
db.orders.find({ status: "completed" });  // Если нет индекса по status, это COLLSCAN (полное сканирование)
// На большой коллекции это очень медленно

Комбинация фильтра и сортировки в одном индексе:

// Если у вас есть
db.orders.find({ user_id: 123 }).sort({ created_at: -1 });

// И индекс (user_id, created_at), то MongoDB:
// 1. Использует индекс для поиска записей по user_id
// 2. Данные уже упорядочены по created_at, не нужна сортировка в памяти

// Если бы был индекс только по (user_id), MongoDB пришлось бы:
// 1. Использовать индекс для поиска по user_id
// 2. Сканировать результаты (может быть много документов)
// 3. Отдельно сортировать результаты в памяти (SORT stage)

Partial-индексы в MongoDB

Partial-индекс — это индекс, который строится только для документов, удовлетворяющих определённому условию.

// Часто используется для «активных» данных
db.users.createIndex(
  { email: 1 },
  { partialFilterExpression: { status: "active" } }
);

// Этот индекс гораздо меньше, чем полный индекс по email,
// потому что включает только активных пользователей.
// Идеально для систем, где большинство запросов фильтруют активные объекты.

Сценарии использования:

  • Индексация только «горячих» данных (например, заказы за последний месяц, активные сессии).
  • Уменьшение размера индекса и память consumption.
  • Ускорение обновлений: индекс меньше, обновляется быстрее.

TTL-индексы (автоудаление по времени)

TTL-индекс автоматически удаляет документы по истечении определённого времени.

// Пример: сессии, которые должны удаляться через 24 часа
db.sessions.createIndex(
  { created_at: 1 },
  { expireAfterSeconds: 86400 }  // 24 часа
);

// MongoDB периодически ищет документы, где текущее время >= created_at + 24h,
// и удаляет их.

Применимость:

  • Временные данные: сессии, OTP коды, кэши.
  • Автоматическое управление жизненным циклом данных без необходимости писать собственный cleanup код.

Типичные анти-паттерны в MongoDB

Отсутствие индексов на часто используемых полях.

// Плохо
db.orders.find({ customer_id: 456, status: "shipped" });
// Без индексов это COLLSCAN по всей коллекции

// Хорошо
db.orders.createIndex({ customer_id: 1, status: 1 });

Полнотекстовый поиск через aggregation pipeline без индексов.

// Плохо (очень медленно на большом объёме)
db.products.aggregate([
  { $match: { $expr: { $regexMatch: { input: "$name", regex: "laptop" } } } }
]);

// Хорошо (создать text-индекс)
db.products.createIndex({ name: "text" });
db.products.find({ $text: { $search: "laptop" } });

Массивные документы без индексов.

// Пример
{
  _id: 1,
  user_id: 123,
  history: [
    { date: "2024-01-01", action: "login" },
    { date: "2024-01-02", action: "purchase" },
    // Может быть тысячи элементов
  ]
}

// Если часто ищут по истории без индекса,
// MongoDB пришлось бы сканировать и распаковывать каждый документ.
// Лучше индексировать вложенные поля:
db.history.createIndex({ "history.date": 1 });

Индексы и запросы в Elasticsearch / OpenSearch

Инвертированный индекс — фундаментально другой подход

Elasticsearch построен на полностью другой концепции индексирования, чем RDBMS. Вместо индекса по строкам он использует инвертированный индекс по термам (словам).

Как это работает:

Документы:
Doc 1: "The quick brown fox"
Doc 2: "The lazy brown dog"
Doc 3: "Quick foxes are fast"

Инвертированный индекс:
"quick" → [Doc 1, Doc 3]
"brown" → [Doc 1, Doc 2]
"fox" → [Doc 1]
"dog" → [Doc 2]
"lazy" → [Doc 2]
... и т.д.

Запрос "quick brown":
1. Найти все документы с "quick" → [Doc 1, Doc 3]
2. Найти все документы с "brown" → [Doc 1, Doc 2]
3. Пересечение → [Doc 1]
Ответ: Doc 1

Это позволяет искать текст чрезвычайно быстро, даже в огромных объёмах данных.

Почему это отличается от RDBMS:

  • RDBMS-индекс — это упорядоченная структура по значениям (B-tree).
  • ES-индекс — это отображение слов на документы (hash map + optimizations).

Маппинг: text vs keyword

В Elasticsearch каждое поле имеет тип, и от типа зависит, как оно индексируется.

text тип:

{
  "mappings": {
    "properties": {
      "description": {
        "type": "text"
      }
    }
  }
}
  • Поле анализируется (tokenized): текст разбивается на слова (токены).
  • Каждый токен индексируется отдельно в инвертированный индекс.
  • Позволяет полнотекстовый поиск: найти документ, содержащий слово, даже если оно не в начале.
  • Нельзя сортировать и агрегировать по text.

keyword тип:

{
  "mappings": {
    "properties": {
      "status": {
        "type": "keyword"
      }
    }
  }
}
  • Поле не анализируется: хранится как есть.
  • Индексируется как целое значение.
  • Позволяет точное совпадение, сортировку, агрегацию.
  • Не подходит для полнотекстового поиска.

Правильное использование:

{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "email": {
        "type": "keyword"
      },
      "status": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      }
    }
  }
}

Здесь title индексируется как text (для поиска), но также имеет под-поле keyword для сортировки и точного совпадения.

Фильтры vs Query-контекст

Это критичное различие для оптимизации:

Query-контекст:

{
  "query": {
    "match": {
      "description": "laptop computer"
    }
  }
}
  • Вычисляется relevance score (насколько хорошо документ совпадает с запросом).
  • Результаты сортируются по score.
  • Не кэшируется (каждый запрос вычисляется заново).

Filter-контекст:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "laptop" } }
      ],
      "filter": [
        { "term": { "status": "active" } },
        { "range": { "price": { "gte": 100, "lte": 1000 } } }
      ]
    }
  }
}
  • Не вычисляется score, только да/нет (документ совпадает или не совпадает).
  • Результаты кэшируются ES'ом.
  • Значительно быстрее, чем query-контекст.
  • Используется для исключения документов перед поиском.

Практика оптимизации:

Если вы имеете запрос с множеством условий, разделите их:

  • Условия, где важен score (поиск) → must или should в query.
  • Условия, где нужна только фильтрация (да/нет) → filter.
// Хорошо
{
  "query": {
    "bool": {
      "must": { "match": { "title": "laptop" } },
      "filter": [
        { "term": { "status": "active" } },
        { "range": { "updated_at": { "gte": "2024-01-01" } } }
      ]
    }
  }
}

// Плохо (всё в must, всё вычисляется по score)
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "laptop" } },
        { "term": { "status": "active" } },
        { "range": { "updated_at": { "gte": "2024-01-01" } } }
      ]
    }
  }
}

Типичные анти-паттерны в Elasticsearch

Попытка использовать ES как OLTP-БД с частыми обновлениями отдельных полей.

// Плохо: ES не оптимизирован для этого
{
  "query": { "match_all": {} },
  "script": {
    "source": "ctx._source.views += params.count",
    "params": { "count": 1 }
  }
}
// Если вы выполняете это для каждого документа 10k раз в секунду,
// вы переполняет segment merging и degrading performance.

ES лучше работает, когда вы:

  • Индексируете большие батчи данных.
  • Читаете данные чаще, чем обновляете.

Сортировка и агрегация по text полям.

// Плохо
{
  "aggs": {
    "by_description": {
      "terms": {
        "field": "description"  // text field!
      }
    }
  }
}

// Хорошо (если это нужно для других целей)
{
  "aggs": {
    "by_description": {
      "terms": {
        "field": "description.keyword"  // keyword sub-field
      }
    }
  }
}

Игнорирование маппинга и использование дефолтного анализатора.

Если вы не определите маппинг, ES использует дефолтный, который может быть неоптимален. Дефолтный анализатор может не подходить для ваших данных (например, не работает с email'ами, URL'ами и т.п.).


Индексы и запросы в ClickHouse и других колоночных OLAP-БД

Первичный ключ как ключ сортировки

В ClickHouse нет индексов в классическом смысле (как B-tree). Вместо этого:

  • Первичный ключ определяет, как физически упорядочиваются данные на диске.
  • Данные хранятся в колоночном формате (каждая колонка отдельно), а не в строчном.
-- Создание таблицы с первичным ключом
CREATE TABLE orders (
  order_id UInt64,
  user_id UInt64,
  created_at DateTime,
  amount Float64,
  status String
) ENGINE = MergeTree()
ORDER BY (user_id, created_at);
-- ORDER BY === PRIMARY KEY в ClickHouse

Что это значит:

  • Данные на диске отсортированы по (user_id, created_at).
  • Если вы ищите WHERE user_id = 123 AND created_at > '2024-01-01', ClickHouse знает, где на диске находятся эти данные, и читает только нужные блоки.
  • Это намного быстрее, чем full scan.

Sparse-индексы и блокирование данных

ClickHouse использует sparse-индексы, а не плотные индексы как RDBMS.

Как это работает:

Данные на диске (отсортированы):
Block 1: user_id 1-100, created_at Jan 1-15
Block 2: user_id 101-200, created_at Jan 16-31
Block 3: user_id 201-300, created_at Feb 1-15
...

Sparse index (хранит только min/max каждого блока):
Block 1: min_user_id=1, max_user_id=100, min_created_at=Jan1, max_created_at=Jan15
Block 2: min_user_id=101, max_user_id=200, min_created_at=Jan16, max_created_at=Jan31
...

Запрос: WHERE user_id > 150 AND created_at > 'Jan 20'
1. ClickHouse смотрит на sparse-индекс
2. Блок 1: max_user_id=100 < 150 → пропускаем
3. Блок 2: min_user_id=101, max_user_id=200 → может быть, читаем
   Дополнительно проверяем: min_created_at=Jan16 < Jan20, max_created_at=Jan31 > Jan20 → может быть, читаем
4. Блок 3: min_user_id=201 > 150 → может быть, читаем
5. Читаем только блоки 2 и 3, не весь диск!

Отличие от RDBMS B-tree индексов:

  • B-tree индекс указывает на каждую строку (или группу строк).
  • Sparse-индекс указывает на блоки данных; вам нужно прочитать весь блок, если он может содержать нужные строки.
  • Но блоки сжаты, поэтому читать 1000 строк из одного блока дешевле, чем прыгать по B-tree 1000 раз.

Первичный ключ и аналитические запросы

Выбор первичного ключа в ClickHouse — это критичное архитектурное решение.

-- Сценарий 1: часто ищут заказы по пользователю и дате
CREATE TABLE orders ENGINE = MergeTree()
ORDER BY (user_id, created_at);

-- Сценарий 2: часто ищут по region и затем по date
CREATE TABLE sales ENGINE = MergeTree()
ORDER BY (region, created_at);

-- Сценарий 3: очень часто ищут по product_id, но редко по date
CREATE TABLE inventory ENGINE = MergeTree()
ORDER BY (product_id, warehouse_id);

Типичные паттерны первичного ключа в OLAP:

  1. (Time-range, then dimension): ORDER BY (created_at, user_id, region). Хорошо для временных рядов и исторических данных. ClickHouse и другие OLAP часто используют такую схему.

  2. (Dimension, then time-range): ORDER BY (region, created_at). Хорошо для агрегирования по регионам с временным срезом.

  3. (High cardinality, then time): ORDER BY (user_id, created_at). Когда есть много пользователей и часто ищут по user_id.

Материализованные представления и precomputed витрины

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

-- Исходная таблица
CREATE TABLE events (
  timestamp DateTime,
  user_id UInt64,
  event_type String,
  value Float64
) ENGINE = MergeTree() ORDER BY timestamp;

-- Материализованное представление: агрегированные данные по часам
CREATE MATERIALIZED VIEW events_hourly AS
SELECT 
  toStartOfHour(timestamp) AS hour,
  event_type,
  sum(value) AS total_value,
  count() AS count
FROM events
GROUP BY hour, event_type;

-- При каждом insert'е в events автоматически обновляется events_hourly

Плюсы:

  • Часто используемые аналитические запросы выполняются мгновенно (результаты уже посчитаны).
  • Уменьшение нагрузки на основную таблицу.

Минусы:

  • Требует больше места на диске.
  • Более сложная архитектура.

Анти-паттерны в ClickHouse

Попытка использовать ClickHouse как OLTP-БД.

-- Плохо
UPDATE orders SET status = 'shipped' WHERE order_id = 123;
-- ClickHouse не оптимизирован для частых single-row update'ов
-- Это будет очень медленно

ClickHouse лучше подходит для:

  • Bulk insert'ов (millions of rows at a time).
  • Больших аналитических запросов (aggregations, joins on large tables).
  • Append-only данных.

Большое количество маленьких точечных запросов вместо батчей.

-- Плохо
for order_id in range(1, 1000000):
    SELECT * FROM orders WHERE order_id = ?;  -- 1 million queries!

-- Хорошо
SELECT * FROM orders WHERE order_id IN (1, 2, 3, ..., 1000000);
-- Один запрос, результат кэшируется, выполняется намного быстрее

Выбор неправильного первичного ключа.

-- Плохо: если 90% запросов фильтруют по region,
-- а первичный ключ (created_at, user_id)
-- создать неправильный первичный ключ
CREATE TABLE sales ENGINE = MergeTree()
ORDER BY (created_at, user_id);  -- Не оптимально

-- Хорошо:
CREATE TABLE sales ENGINE = MergeTree()
ORDER BY (region, created_at, user_id);  -- region на первом месте

Как читать query plan: концептуальный уровень

Зачем нужен query plan

Query plan — это представление того, как БД на самом деле выполняет запрос, пошагово.

Это критично, потому что:

  • То, что выглядит как простой запрос на SQL, может выполняться совсем не так, как вы ожидаете.
  • План показывает, где находятся узкие места: full scan, дорогостоящие join'ы, сортировки в памяти.
  • Без понимания плана вы не можете диагностировать проблемы производительности.

Концептуальные элементы query plan'а

Тип доступа к данным:

  • Index Scan (B-tree scan в RDBMS): БД использует индекс для поиска данных. Быстро.
  • Range Scan: БД использует индекс для поиска диапазона значений. Быстро для небольших диапазонов.
  • Full Table Scan (Full Collection Scan в Mongo): БД читает всю таблицу с начала до конца. Медленно на больших таблицах.
  • Index-Only Scan (Covering Index в RDBMS): Все данные находятся в индексе, не нужно читать основную таблицу. Очень быстро.

Порядок операций и их стоимость:

Query: SELECT * FROM orders WHERE user_id = 1 AND status = 'active' ORDER BY created_at DESC LIMIT 10

План может выглядеть как:
1. Index Range Scan on (user_id, status, created_at)
   - Rows scanned: 50
   - Rows emitted: 50
2. Sort (created_at DESC)
   - Rows in: 50
   - Rows out: 50
   - Sort type: External (если в памяти не помещается) или Memory (если помещается)
3. Limit 10
   - Rows in: 50
   - Rows out: 10

Анализ:

- Index Range Scan находит 50 строк эффективно (использует индекс).
- Sort: если порядок созданных строк уже был DESC (благодаря индексу created_at в индексе),
  то Sort не нужен. Если нет — это дополнительные операции.
- Limit просто берёт первые 10.

Cardinality (количество строк на каждом этапе):

В плане обычно есть две метрики:

  • Rows scanned: сколько строк БД должна была прочитать.
  • Rows emitted: сколько строк осталось после фильтрации.
Если план показывает:
Index Scan: 1,000,000 rows scanned, 100 rows emitted

То БД прочитала 1 млн строк, но после фильтрации осталось только 100.
Это может означать, что индекс плохо выбран или фильтр очень селективен.

Вопросы, которые должен отвечать план

1. Использует ли запрос нужный индекс?

Плохо:
Full Table Scan
- Rows: 10,000,000

Хорошо:
Index Range Scan on idx_user_created
- Rows: 100

Если видите Full Table Scan там, где ожидается индекс, это повод задаться вопросом:

  • Почему индекс не используется?
  • Может быть, WHERE условие ломает индекс (функция над колонкой)?
  • Может быть, селективность очень низкая, и БД решила, что full scan быстрее?

2. Какие операции самые дорогие?

План:
1. Index Scan: 100 rows, 0.1 ms
2. Hash Join with products: 100 rows, 50 ms  ← ЭТО узкое место!
3. Sort: 100 rows, 0.05 ms
4. Limit: 100 rows, 0.01 ms

Hash Join занял 50 ms из 50.16 ms всего. Это критичный узел для оптимизации.

3. Где происходит сортировка и агрегация?

Если план содержит:
Sort (External) — 200 ms

Это значит, что Sort не смог поместиться в памяти (buffer) и потребовал дискового I/O.
Это дорого. Возможно, нужен индекс, который даст отсортированные данные напрямую.

4. Где возникают full scan'ы и почему?

План:
1. Full Table Scan on orders (10,000,000 rows)
   - Filter: user_id = 123 AND status = 'active'
   - Rows after filter: 100

Анализ:

- Почему full scan? Может быть, нет индекса по (user_id, status)?
- Или индекс есть, но selects считают, что full scan дешевле (очень редко, но бывает).
- Решение: создать индекс (user_id, status) или улучшить статистику.

Как читать plans в разных БД

PostgreSQL / MySQL: EXPLAIN и EXPLAIN ANALYZE

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10;

Вывод:
Limit  (cost=0.42..0.52 rows=10 width=32)
  ->  Index Scan Backward using idx_orders_user_created on orders  
        (cost=0.42..3256.42 rows=10000 width=32)
        Index Cond: (user_id = 1)

Чтение:

- Используется Index Scan (хорошо, не full scan).
- Используется индекс idx_orders_user_created (предполагаем, это (user_id, created_at)).
- cost=0.42..3256.42 — оценка стоимости (единицы произвольные, важна относительность).
- rows=10000 — БД прогнозирует 10,000 строк для user_id = 1 (может быть неправильно, если статистика устарела).
- ANALYZE показывает реальные цифры, а не только прогнозы.

MongoDB: explain()

db.orders.find({ user_id: 123 }).sort({ created_at: -1 }).explain("executionStats");

Вывод:
{
  "executionStats": {
    "executionStages": {
      "stage": "LIMIT",
      "nReturned": 10,
      "executionStages": {
        "stage": "SORT",
        "nReturned": 100,
        "executionStages": {
          "stage": "COLLSCAN",  // ← Полное сканирование коллекции!
          "nReturned": 100,
          "docsExamined": 1000000
        }
      }
    }
  }
}

Анализ:

- COLLSCAN означает полное сканирование (нет индекса).
- docsExamined = 1,000,000, но возвращено только 100 (плохо, значит много фильтруется).
- Стоит создать индекс { user_id: 1, created_at: -1 }.

Elasticsearch: profile API

GET /orders/_search?pretty
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        { "term": { "user_id": 123 } },
        { "range": { "created_at": { "gte": "2024-01-01" } } }
      ]
    }
  }
}

Вывод показывает time_in_nanos для каждого компонента запроса.
Если видите, что фильтры выполняются медленнее, чем ожидается,
это может означать неправильный маппинг или отсутствие оптимизаций.

ClickHouse: EXPLAIN

EXPLAIN
SELECT region, sum(amount) 
FROM sales 
WHERE created_at > '2024-01-01' 
GROUP BY region;

Вывод:
Expression ((Projections [<...>]))
Aggregating
  Filter
    ReadFromMergeTree (default.sales)

Анализ:

- ReadFromMergeTree читает из таблицы (sparse-индекс).
- Filter применяется на created_at (должен быть в первичном ключе для эффективности).
- Aggregating группирует по region (должен быть в первичном ключе или хотя бы индексирован).

Как описывать умение читать plans на собеседовании

Не нужно знать все детали каждого типа БД. Достаточно уметь говорить на концептуальном уровне:

**«Я читаю query plan, смотря на:

  1. Какой тип доступа используется (индекс vs full scan)?
  2. Где находятся дорогие операции (join'ы, сортировка, агрегация)?
  3. Сколько строк обрабатывается на каждом этапе и почему?
  4. Кэшируются ли результаты промежуточных операций?

На основе этого я могу предложить оптимизацию:

  • добавить индекс,
  • изменить порядок условий в WHERE,
  • переписать запрос,
  • денормализовать данные.

Например, если вижу, что запрос делает full scan вместо index scan, я проверяю, есть ли индекс по колонкам WHERE. Если есть, смотрю план подробнее: может быть, функция над колонкой ломает индекс или селективность настолько низкая, что БД считает full scan быстрее. Потом я проверяю на тестовых данных.»**


Шаблоны диагностики: что делать, если запросы начали тормозить

Общий чек-лист для любой БД

Когда вы видите, что запросы неожиданно медленные, следуйте этому порядку:

1. Убедитесь, что проблема действительно в БД.

- Смотрите на Application Performance Monitoring (APM):

  - Время в БД vs время в приложении vs время в сети.
  - Если время в БД составляет 90% latency, проблема в БД.
  - Если 10%, проблема может быть в приложении или сети.
  
- Проверьте connection pool:

  - Может ли приложение вообще подключиться к БД?
  - Не истощены ли все соединения?
  
- Проверьте сетевую задержку:

  - ping до БД.
  - Есть ли packet loss?

2. Идентифицируйте самые медленные запросы.

- Включите slow query log на БД:
  MySQL: SET GLOBAL slow_query_log = 'ON', SET GLOBAL long_query_time = 0.5;
  PostgreSQL: log_min_duration_statement = 500;
  MongoDB: profiler или analysis.
  Elasticsearch: slow log и profile API.
  
- Собирайте метрики:

  - Какие запросы выполняются чаще всего?
  - Какие из них самые медленные?
  - Насколько сильно выросло время выполнения?
  
- Используйте инструменты:

  - pt-query-digest (MySQL).
  - pgBadger (PostgreSQL).
  - Kibana для ES.

3. Посмотрите план выполнения медленных запросов.

- Используйте EXPLAIN / EXPLAIN ANALYZE.
- Ищите красные флаги:

  - Full table scan на большой таблице.
  - Multiple joins без индексов.
  - Sort в памяти на huge dataset.
  - Cardinality mismatch (план прогнозировал 100 rows, на самом деле 1 млн).

4. Оцените текущие индексы и их использование.

- Есть ли индексы по колонкам в WHERE, ORDER BY, JOIN?
- Используются ли эти индексы (смотрите в EXPLAIN)?
- Есть ли неиспользуемые индексы, которые просто замедляют write'ы?

Специфичные шаги для RDBMS

Если есть full table scan:

-- Проблема
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1 AND status = 'completed' ORDER BY created_at DESC;
-- Full Table Scan (10,000,000 rows scanned)

-- Решение 1: добавить индекс
CREATE INDEX idx_orders ON orders(user_id, status, created_at DESC);

-- Решение 2: если индекс есть, но не используется, может быть функция?
-- Плохо:
WHERE YEAR(created_at) = 2024

-- Хорошо:
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

Если join медленный:

-- Проблема
EXPLAIN ANALYZE 
SELECT o.*, u.name 
FROM orders o 
JOIN users u ON o.user_id = u.id 
WHERE o.status = 'pending';
-- Hash Join with 1,000,000 rows

-- Решение: убедитесь, что есть индексы
CREATE INDEX idx_orders_user ON orders(user_id, status);
-- users.id обычно имеет индекс (это PK)

-- Или переделайте запрос, если он слишком сложный
-- Разбейте на несколько шагов:

-- Шаг 1: получить нужные user_id'ы
SELECT DISTINCT user_id FROM orders WHERE status = 'pending';
-- Шаг 2: получить информацию о пользователях
SELECT * FROM users WHERE user_id IN (...);

Если из-за растущего объёма данных:

-- Архивируйте старые данные
CREATE TABLE orders_archive LIKE orders;
INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
DELETE FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);

-- Или используйте партиционирование
ALTER TABLE orders PARTITION BY RANGE (YEAR(created_at)) (
  PARTITION p_2022 VALUES LESS THAN (2023),
  PARTITION p_2023 VALUES LESS THAN (2024),
  PARTITION p_2024 VALUES LESS THAN (2025)
);

Специфичные шаги для MongoDB

Если find/aggregation медленный:

// Проблема
db.orders.find({ user_id: 123 }).sort({ created_at: -1 }).limit(10).explain("executionStats");
// COLLSCAN (1,000,000 documents scanned)

// Решение: добавить индекс
db.orders.createIndex({ user_id: 1, created_at: -1 });

// Проверьте ещё раз
db.orders.find({ user_id: 123 }).sort({ created_at: -1 }).limit(10).explain("executionStats");
// Теперь IXSCAN (index scan)

Если aggregation pipeline медленный:

// Проблема: много $lookup (join'ов)
db.orders.aggregate([
  { $match: { user_id: 123 } },
  { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } },
  { $lookup: { from: "products", localField: "product_id", foreignField: "_id", as: "product" } },
  { $lookup: { from: "reviews", localField: "order_id", foreignField: "order_id", as: "reviews" } },
  { $group: { _id: "$product_id", count: { $sum: 1 } } }
]);

// Решение: денормализуйте некоторые поля или разбейте на несколько запросов
db.orders.aggregate([
  { $match: { user_id: 123 } },
  { $group: { _id: "$product_id", count: { $sum: 1 } } }
  // Потом отдельно fetch'а product информация, если нужна
]);

Если размер документов растёт:

// Проблема: документы содержат огромные history массивы
{
  _id: 1,
  user_id: 123,
  history: [
    // 100,000 элементов!
  ]
}

// Решение: переместите history в отдельную коллекцию
db.users.findOne({ _id: 1 });  // Быстро, документ маленький

db.user_history.find({ user_id: 123 }).sort({ _id: -1 }).limit(10);
// Отдельно fetch history с индексом по user_id

Специфичные шаги для Elasticsearch / OpenSearch

Если запросы медленные:

// Проблема
POST /orders/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "description": "laptop" } },
        { "term": { "status": "active" } },
        { "range": { "price": { "gte": 100, "lte": 1000 } } }
      ]
    }
  }
}

// Решение: переместите фильтры в filter контекст
POST /orders/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "description": "laptop" } }
      ],
      "filter": [
        { "term": { "status": "active" } },
        { "range": { "price": { "gte": 100, "lte": 1000 } } }
      ]
    }
  }
}

// Фильтры кэшируются, запрос выполняется быстрее

Если маппинг неправильный:

// Проблема: поле name имеет тип text, но пытаетесь агрегировать
PUT /products
{
  "mappings": {
    "properties": {
      "name": { "type": "text" }  // Нельзя агрегировать по text!
    }
  }
}

// Решение: добавьте keyword подполе
PUT /products
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      }
    }
  }
}

// Теперь агрегируйте по name.keyword
POST /products/_search
{
  "aggs": {
    "top_names": {
      "terms": {
        "field": "name.keyword",
        "size": 10
      }
    }
  }
}

Если число шардов неправильное:

// Проблема: слишком мало шардов
PUT /orders
{
  "settings": {
    "number_of_shards": 1  // Все данные в одном шарде
  }
}

// Решение: переиндексируйте с правильным числом шардов
PUT /orders_new
{
  "settings": {
    "number_of_shards": 5,  // Распределите данные
    "number_of_replicas": 1
  }
}

POST /_reindex
{
  "source": { "index": "orders" },
  "dest": { "index": "orders_new" }
}

Специфичные шаги для ClickHouse

Если queries медленные:

-- Проблема: неправильный PRIMARY KEY
CREATE TABLE sales (
  id UInt64,
  created_at DateTime,
  region String,
  amount Float64
) ENGINE = MergeTree()
ORDER BY id;  -- Плохо! Данные отсортированы по id, но запросы фильтруют по region и created_at

-- Решение: измените PRIMARY KEY
ALTER TABLE sales MODIFY ORDER BY (region, created_at, id);
-- Теперь данные отсортированы по region и created_at, что ускорит типичные запросы

-- Потом переиндексируйте
OPTIMIZE TABLE sales FINAL;

Если insert'ы медленные:

-- Плохо: 1 миллион отдельных insert'ов
INSERT INTO sales VALUES (1, now(), 'USA', 100);
INSERT INTO sales VALUES (2, now(), 'USA', 200);
-- ... 1 миллион раз

-- Хорошо: один батч insert
INSERT INTO sales VALUES 
(1, now(), 'USA', 100),
(2, now(), 'USA', 200),
... 
(1000000, now(), 'USA', 100);

Если queries не использают правильные части таблицы:

-- Проблема: много маленьких partitions, ClickHouse должен читать все
SELECT count() FROM logs WHERE created_at > '2024-01-01' AND level = 'ERROR';

-- Решение: используйте partition pruning
CREATE TABLE logs (
  created_at DateTime,
  level String,
  message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (level, created_at);

-- Теперь ClickHouse читает только партиции за нужные месяцы

Нагрузочное тестирование и регресс

После любого изменения индексов или запросов обязательно тестируйте:

1. На тестовой базе с реалистичным объёмом данных (мин. 10-20% от production).
2. С ожидаемой нагрузкой (используйте load testing tools):

   - Apache JMeter, Locust, k6 для HTTP API.
   - sysbench, HammerDB для прямого доступа к БД.
   - mongodb-benchmark, elasticsearch-bench.
   
3. Мониторьте:

   - Latency (p50, p95, p99).
   - Throughput (queries per second).
   - Resource usage (CPU, Memory, Disk I/O).
   
4. Сравнивайте с baseline (старыми метриками).
5. Если регресс более чем на 5-10%, откатываетесь и переанализируете.

Баланс между универсальной схемой и схемой под конкретные запросы

Соблазн «идеальной» нормализованной схемы

Многие разработчики в начале карьеры увлекаются идеей создать идеально нормализованную схему (третья нормальная форма, 3NF).

Плюсы:

  • Минимум дублирования данных (одна истина для каждого факта).
  • Легче добавлять новые поля и связи.
  • Понятная и логичная структура.

Минусы на реальных системах:

  • Сложные join'ы (10+ таблиц), которые становятся узкими местами.
  • Высокая latency на read'ах.
  • Трудно масштабировать горизонтально (sharding).
-- Пример: идеально нормализованная схема для интернет-магазина

CREATE TABLE users (
  id INT,
  name VARCHAR(255),
  email VARCHAR(255)
);

CREATE TABLE orders (
  id INT,
  user_id INT,
  created_at TIMESTAMP
);

CREATE TABLE order_items (
  id INT,
  order_id INT,
  product_id INT,
  quantity INT,
  unit_price DECIMAL(10, 2)
);

CREATE TABLE products (
  id INT,
  name VARCHAR(255),
  price DECIMAL(10, 2),
  category_id INT
);

CREATE TABLE categories (
  id INT,
  name VARCHAR(255)
);

CREATE TABLE reviews (
  id INT,
  product_id INT,
  user_id INT,
  rating INT,
  text TEXT
);

-- Запрос: получить заказы пользователя с информацией о товарах и категориях
SELECT 
  o.id, o.created_at,
  oi.product_id, p.name, p.price, c.name as category,
  oi.quantity, oi.unit_price
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
WHERE o.user_id = 123
ORDER BY o.created_at DESC;

-- Это 4 join'а! На большом объёме данных это может быть медленно.

Query-driven design: проектируем от запросов

Вместо создания идеальной схемы, начинаем с того, какие запросы критичны для бизнеса.

-- Анализируем: какие запросы выполняются в 80% случаев?
-- 1. Получить историю заказов пользователя (с товарами и категориями)
-- 2. Получить детали конкретного заказа
-- 3. Агрегировать по категориям (для аналитики)

-- Query-driven решение:

-- Денормализуем данные в таблице заказов

CREATE TABLE orders_denormalized (
  id INT,
  user_id INT,
  created_at TIMESTAMP,
  items JSON,  -- или отдельная таблица
  total_amount DECIMAL(10, 2),
  status VARCHAR(50)
);

-- items содержат:
[
  {
    product_id: 1,
    product_name: "Laptop",
    category_name: "Electronics",
    quantity: 1,
    unit_price: 999.99
  },
  {
    product_id: 2,
    product_name: "Mouse",
    category_name: "Accessories",
    quantity: 2,
    unit_price: 29.99
  }
]

-- Теперь запрос:
SELECT * FROM orders_denormalized WHERE user_id = 123 ORDER BY created_at DESC;
-- Один SELECT, никаких join'ов! Очень быстро.

Плюсы denormalization для критичных запросов:

  • Высокая скорость (no join'ы).
  • Предсказуемая latency.
  • Легче кэшировать результаты.

Минусы:

  • Дублирование данных (if product_name меняется, нужно обновить все заказы с этим товаром).
  • Синхронизация между таблицами (если пишете в products, нужно обновить orders_denormalized).
  • Больше сложности в коде (отдельная логика для обновления denormalized данных).

CQRS-подход (Command Query Responsibility Segregation)

На уровне идей, без полной архитектуры CQRS:

  • Write-модель: основная нормализованная схема (users, orders, products).
  • Read-модели: отдельные таблицы/коллекции, оптимизированные под типичные запросы.
-- Write-модель (нормализованная)
CREATE TABLE products (...);
CREATE TABLE orders (...);
CREATE TABLE order_items (...);

-- Read-модели (denormalized)
CREATE TABLE read_user_orders (  -- для быстрого получения заказов пользователя
  id INT,
  user_id INT,
  order_id INT,
  order_date TIMESTAMP,
  items_json JSON,
  total DECIMAL(10, 2)
);

CREATE TABLE read_product_analytics (  -- для аналитики по товарам
  product_id INT,
  product_name VARCHAR(255),
  category_name VARCHAR(255),
  total_sold INT,
  total_revenue DECIMAL(10, 2),
  last_updated TIMESTAMP
);

-- Процесс:

-- 1. Пишем в основные таблицы (products, orders, order_items).
-- 2. Event или trigger обновляет read-модели.
-- 3. Читаем из read-моделей.

Когда это имеет смысл:

  • Есть четкое разделение between read-heavy и write-heavy операций.
  • Требуется очень высокая скорость чтения.
  • Объем данных так велик, что нормализованная схема не масштабируется.

Polyglot Persistence: разные БД для разных задач

Для максимальной гибкости, используйте разные типы БД для разных нужд:

Архитектура:
┌─────────────────────────────────────────┐
│         Primary Data Store              │
│  PostgreSQL (нормализованная схема)    │
│  - Source of truth                      │
│  - Транзакции, ACID                     │
└─────────────────────────────────────────┘
         ↓ (Write events)
┌──────────────────────────────────────────────────────────────────┐
│               Read Optimization Layer                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  MongoDB (документы)          │  Elasticsearch (full-text)      │
│  - Быстрые fetches            │  - Поиск по тексту              │
│  - Denormalized documents     │  - Фильтрация и аналитика       │
│                               │                                 │
│  ClickHouse (analytics)       │  Redis (кэш)                    │
│  - Агрегированные данные      │  - Session'ы, кэши              │
│  - Анализ поведения           │  - Быстрые lookups              │
│                               │                                 │
└──────────────────────────────────────────────────────────────────┘

Примеры:

  • PostgreSQL для основных данных с ACID.
  • MongoDB для user profiles (часто меняются, сложная структура).
  • Elasticsearch для search и аналитики логов.
  • ClickHouse для агрегированной аналитики.
  • Redis для кэша и sessions.

Управление синхронизацией:

Обычно используют Event Sourcing, Message Queue или Change Data Capture:

1. Обновление в PostgreSQL (source of truth).
2. Событие отправляется в очередь (Kafka, RabbitMQ).
3. Отдельные consumer'ы обновляют Elasticsearch, MongoDB, ClickHouse.
4. Если что-то сломалось, replay события из очереди.

Как говорить об этом на собеседовании

"Я начинаю с анализа критичных запросов: какие операции выполняются чаще всего 
и сколько времени они занимают. Для системы интернет-магазина это может быть 
получение заказов пользователя, поиск товаров, аналитика по продажам.

На основе этого я проектирую схему:

- Для критичных (read-heavy) запросов я готов денормализовать и использовать 
  отдельные таблицы или документы, оптимизированные под конкретный запрос.
- Источником истины остаётся основная (нормализованная) таблица.
- Для очень больших систем я могу использовать polyglot persistence: 
  PostgreSQL для транзакций, Elasticsearch для поиска, ClickHouse для аналитики.

Ключ — это баланс. Я не создаю сотни denormalized таблиц под каждый 
возможный запрос. Я индексирую самые критичные, остальное оптимизирую 
через индексы и query-планы."

Краткий чек-лист для собеседований

Когда вас спрашивают об индексах и оптимизации запросов, убедитесь, что вы уверенно проговариваете эти тезисы:

Общие принципы индексации

  • Индекс — это компромисс между читаемостью и записью. Каждый индекс ускоряет одни запросы, но замедляет insert/update/delete.

  • Не все индексы используются. Индекс может быть создан, но не использован, если WHERE условие не соответствует индексу (функция над колонкой, низкая селективность, неправильный порядок в составном индексе).

  • Индексируйте под реальные запросы. Используйте slow query log, APM, профайлеры. Не создавайте индексы упреждающе.

  • Типичные ошибки: индекс на boolean, индексирование всех подряд колонок, игнорирование порядка колонок в составном индексе.

Отличия между типами БД

  • RDBMS (MySQL, PostgreSQL): B-tree индексы, составные индексы с leftmost prefix, покрывающие индексы, важность правильного выбора типа индекса (partial, expression, специальные).

  • MongoDB: индексы по полям и вложенным полям, порядок колонок важен для фильтрации И сортировки, partial-индексы под «горячие» данные, TTL-индексы для автоудаления.

  • Elasticsearch: инвертированный индекс, маппинг с text vs keyword, фильтры кэшируются (используйте их вместо query для фильтрации), профилирование запросов.

  • ClickHouse (OLAP): первичный ключ как сортировка, sparse-индексы для блоков, оптимизация под аналитические запросы, материализованные представления.

Query plan и диагностика

  • EXPLAIN / EXPLAIN ANALYZE — ваш друг. Используйте для понимания, как БД выполняет запрос.

  • Ищите красные флаги: full table scan, множество join'ов без индексов, sort в памяти, mismatch между cardinality план'а и реальностью.

  • Стоимость операций: index scan < range scan < full scan. Join'ы часто самые дорогие.

Шаги при деградации производительности

  1. Убедитесь, что проблема в БД (APM, monitoring).
  2. Найдите медленные запросы (slow query log).
  3. Смотрите plan'ы этих запросов (EXPLAIN ANALYZE).
  4. Добавьте индексы под критичные WHERE/JOIN/ORDER BY.
  5. Упростите запросы, разбейте на несколько шагов.
  6. Если нужно, денормализуйте.
  7. Тестируйте на staging перед выкатом.

Баланс между схемой и производительностью

  • Универсальная нормализованная схема: хороша для понимания и maintenance, но может привести к сложным join'ам и медленным запросам.

  • Query-driven design: создаёте отдельные таблицы/индексы/денормализованные данные под критичные запросы. Плюсы: высокая скорость. Минусы: дублирование, синхронизация.

  • CQRS на уровне идей: основная (write) модель нормализована, read-модели денормализованы под типичные запросы. Event-driven синхронизация.

  • Polyglot persistence: разные БД под разные нужды (PostgreSQL, Elasticsearch, ClickHouse, Redis), синхронизация через Kafka или CDC.

Как формулировать ответы

Избегайте поверхностных ответов. Вместо:

  • ❌ «Индексы ускоряют запросы»

Говорите:

  • ✅ «Индекс уменьшает количество блоков, которые нужно прочитать, за счёт упорядоченной структуры (B-tree). Но каждый индекс замедляет write'ы. Поэтому я индексирую только под реально критичные запросы, которые видны в slow log'е. На собеседовании я описываю не команды, а логику: как я читаю EXPLAIN, почему я выбрал бы этот индекс вместо другого, как я балансирую между read и write производительностью.»

Выбор базы данных

Зачем нужен чек-лист выбора базы данных

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

Почему выбор БД — архитектурное решение, а не субъективное предпочтение

База данных определяет фундамент системы. Это не просто хранилище, это часть бизнес-логики — консистентность данных, скорость запросов, возможность масштабирования, обработка отказов. Неправильный выбор на ранней стадии приводит к техдолгу, который расплачивается годами.

Различие между кандидатами часто не в том, что одна БД «лучше», а в том, что каждая оптимальна для конкретного профиля нагрузки. Postgres отлично масштабируется вертикально с ACID-гарантиями, но не лучший выбор для миллионов метрик в минуту. Cassandra шинкуется горизонтально, но sacrifice consistency ради availability. Elasticsearch ищет по текстам быстро, но держать в нём основное хранилище — дорого и опасно.

Задача Senior-разработчика на собеседовании и в проекте — продемонстрировать, что он видит эти различия и может обосновать выбор так, чтобы слушающий (лид, архитектор, интервьюер) поверил, что решение принято сознательно.

Как неправильный выбор БД влияет на систему

Производительность: выбрал документную БД для высоконагруженного CRUD-сервиса с частыми join'ами — теперь каждый запрос делает несколько обращений, latency растёт экспоненциально с размером данных. Или выбрал OLAP-систему для online-запросов — она оптимизирована для батч-обработки, а не для single-row lookups. Результат: пользователи ждут ответ в полторы секунды вместо 50 миллисекунд.

Надёжность: выбрал NoSQL без понимания того, что consistency-уровень eventual — теперь при перезагрузке узла данные рассинхронизируются, а приложение не готово к этому. Или не предусмотрел надлежащее резервное копирование, и потеря диска привела к потере активных данных. Иногда система выбирается для масштабирования, но её отказоустойчивость требует сложной конфигурации, которую команда не может поддерживать.

Стоимость владения и операционные риски: введение новой технологии — это не просто лицензия (если платная). Это:

  • Компетенция команды: нужно время на обучение, на поиск специалистов.
  • Инфраструктура: мониторинг, backup-система, восстановление после сбоев.
  • Масштабирование: как горизонтально шинковать, как балансировать нагрузку, как управлять репликацией.
  • Миграции и эволюция: как перейти со старой схемы на новую, как откатиться, если что-то пошло не так.
  • Интеграция с остальным стеком: нужны ли промежуточные слои (ORM, миграции, кеш).

Примерно: добавили новую Elasticsearch-ноду, не понимая, как она интегрируется с основной RDBMS. Теперь при обновлении данных нужно синхронизировать и туда, и сюда, и если синхронизация не атомарна, возникают race conditions. И когда Elasticsearch вдруг требует переиндексирования — весь поиск лежит несколько часов.

Зачем Senior-инженеру иметь в голове системный чек-лист

Быстро сузить выбор до 1–2 вариантов. На собеседовании или в начале проекта редко есть время на тщательный анализ всех 50 популярных БД. Нужно уметь за 5 минут понять, что задача — это OLAP, значит, candidate list: ClickHouse, BigQuery, колоночные СУБД, а не MongoDB или MySQL. Или задача — поиск по каталогу, значит, первый кандидат Elasticsearch, второй — Postgres с GIN-индексом, третий — Algolia если бюджет позволяет.

Аргументированно объяснять выбор. Когда на синхе говоришь: «Я выбираю Postgres потому что его все знают», это звучит слабо. Когда говоришь: «Выбираю Postgres за ACID и сложные join'ы, но буду мониторить latency. Если P99 > 200ms, перейдём на Cassandra и пожертвуем транзакционностью», это звучит как architecture decision, а не как привычка.

Избегать фанатизма по одной технологии. Окружающих раздражает разработчик, который в каждом проекте заводит свою любимую БД. Но и заслуга Senior-инженера не в том, чтобы быть серединой — а в том, чтобы уметь объяснить, почему в проекте А выбираем Postgres, в проекте Б — Cassandra, в проекте В — ClickHouse, и при этом быть адекватным, а не выглядеть как экспериментатор.


Типичный сценарий 1: high-load CRUD-сервис

Характеристики задачи

High-load CRUD-сервис — это типичный backend для мобильного приложения, веб-сервиса, API платформы. Много пользователей, много запросов на чтение и запись, строгие бизнес-инварианты (например, баланс кошелька не может быть отрицательным), требования к предсказуемому latency на каждый запрос.

Примеры: профиль пользователя (create, read, update, delete профиля, аватара, настроек), корзина в e-commerce (добавить товар, убрать, посчитать сумму), лента друзей (прочитать последние посты, найти по автору), платежи (захватить, отпустить, откатить).

Типичные характеристики нагрузки:

  • много запросов чтения и записи (сотни или тысячи RPS);
  • read/write ratio часто 5:1 или 10:1 (больше читаем, реже пишем, но обе части критичны);
  • size of single request small (несколько KB), но количество запросов велико;
  • требуется predictable latency (P95 < 100ms, P99 < 200ms);
  • строгие бизнес-инварианты и транзакции (например, дебет одного счёта должен быть синхронизирован с кредитом другого).

Требования к БД для CRUD-сервиса

ACID-транзакции: если пишешь данные в две таблицы, и часть из них попадёт в БД, а часть нет — это баг. Нужна гарантия atomicity: либо все, либо ничего. Это не просто SQL-фишка, это фундамент корректности приложения.

Надёжные индексы и оптимизация запросов: при 1000 RPS на query типа SELECT * FROM users WHERE id = ? нельзя делать full table scan. Нужны индексы, нужна возможность запланировать query (EXPLAIN PLAN), нужны статистики, которые помогают оптимизатору выбрать правильный путь.

Нормализация (или частичная денормализация): обычно начинают с нормализованной схемы (3-я нормальная форма), чтобы избежать anomaly при обновлении. Если нужна денормализация для скорости, это сознательное решение с описанием trade-off'ов (consistency на запись vs speed на чтение).

Горизонтальное и вертикальное масштабирование: для роста нагрузки нужна возможность:

  • добавить CPU, RAM, диск на той же машине (вертикальное);
  • разделить данные по нескольким машинам (шардирование, горизонтальное), если одна машина перегружена;
  • read replicas для распределения читающей нагрузки.

Типичные кандидаты для CRUD

Postgres или MySQL (MariaDB) как базовый вариант.

Почему именно они:

  • ACID-гарантии из коробки;
  • мощный query optimizer;
  • много способов индексировать (B-tree, Hash, GiST, BRIN, Bloom filter в Postgres);
  • горизонтальное масштабирование через шардирование возможно, но требует логики на уровне приложения;
  • вертикальное масштабирование простое: добавь CPU/RAM, query пойдёт быстрее;
  • большое сообщество, много документации, легко нанять специалистов.

Различия: Postgres считается более надёжным и feature-rich (JSON, массивы, extensions), MySQL быстрее на простых операциях и проще в конфигурировании.

Redis как слой кеша.

Redis не заменяет основную RDBMS в сценарии CRUD, но работает в тандеме. Задачи Redis:

  • кеширование горячих данных (профили активных пользователей, товары в топе);
  • сессии и временные данные (JWT tokens, rate limits);
  • очереди для асинхронной обработки;
  • локи синхронизации (distributed lock для предотвращения race conditions).

Типичный паттерн: пришёл запрос GET /user/123, сначала проверяем Redis (есть ли закеширован профиль). Если нет — идём в Postgres, читаем, кешируем в Redis на 5 минут, отдаём клиенту. При обновлении профиля через PUT /user/123 — обновляем Postgres, инвалидируем ключ в Redis.

NoSQL (Cassandra, MongoDB, DynamoDB) в специфических случаях.

Используются, если:

  • нужно горизонтальное масштабирование из коробки без логики шардирования на уровне приложения;
  • профиль нагрузки асимметричен: либо огромное количество very simple запросов, либо очень высокий write throughput, с которым RDBMS не справляется;
  • данные have no complex relationships (few join'ы) или можно денормализовать;
  • допустима eventual consistency.

Примеры:

  • Cassandra: миллионы записей в минуту с гарантией availability (AP из CAP). Подходит для логирования, метрик, когда eventual consistency приемлема.
  • MongoDB: документная модель, гибкая схема, хорошо для данных, где структура может меняться. Но latency выше, чем у Postgres при той же нагрузке, за счёт сложности документов.
  • DynamoDB (AWS): управляемый сервис, не нужно заботиться о шардировании и репликации. Платишь за throughput, получаешь автоматическое масштабирование. Но latency немного выше, и costs могут взлететь при неожиданной нагрузке.

Какие вопросы уточнить перед выбором

Про данные и связи:

  • Сколько сущностей и какие связи? (например, user, posts, comments — много user'ы к many posts'ам, много posts'ов к many comments'ам).
  • Нужны ли complex join'ы? (например, SELECT * FROM users u JOIN posts p ON u.id = p.user_id WHERE u.country = 'RU' AND p.created_at > now() - interval '7 days').
  • Нужны ли foreign keys и каскадные обновления?
  • Как часто меняется схема?

Если много связей и complex join'ы — склоняемся к Postgres. Если данные более плоские и join'ы редкие — можно рассмотреть NoSQL.

Про нагрузку:

  • Какие текущие и планируемые RPS (read/write отдельно)? На сколько-месячный горизонт планируете рост?
  • Какой read/write ratio? Если 100:1, то можно полагаться на read replicas. Если 2:1, нужна оптимизация и кеширование.
  • Какие операции самые горячие? (например, 80% запросов — это SELECT * FROM users WHERE id = ?, 15% — SELECT * FROM posts WHERE user_id = ? ORDER BY created_at DESC LIMIT 20, 5% — updates).
  • Допустима ли задержка на запись? (synchronous writes всегда медленнее).

Про консистентность:

  • Нужны ли strong ACID-транзакции? Или eventual consistency приемлема?
  • Допустимо ли, что читаем немного старые данные (например, на 1 секунду)?
  • Нужна ли локальная консистентность (в одной БД) или глобальная (distributed transactions)?

Типичный сценарий 2: логирование и метрики

Характеристики задачи

Логирование и метрики — это append-only потоки данных. Приложение выбрасывает события: «пользователь залогинился», «API-запрос обработан за 50ms», «ошибка в модуле XYZ». Объём этих событий огромен. На high-load сервисе за день могут быть сотни миллиардов событий.

Особенности:

  • append-only: новые данные только добавляются, редко обновляются;
  • преобладание записей (write-heavy): на одно чтение может быть 100+ записей;
  • запросы в основном аналитические/диагностические: «покажи среднее время ответа API за последний час», «сколько ошибок 500 на сервере Х с 14:00 до 15:00», «top-10 самых медленных endpoints».

Попытка хранить логи в обычной RDBMS (например, Postgres) приводит к проблемам: таблица растёт на гигабайты в день, индексы становятся огромными, даже простое DELETE старых логов начинает блокировать систему на часы.

Требования к БД для логов и метрик

Дешёвые вставки: нужно уметь писать миллионы записей в секунду без замораживания системы. Это значит:

  • minimal write amplification (не нужно перестраивать индексы на каждую вставку);
  • batch inserts;
  • возможность распределить нагрузку на несколько nodes;
  • no complex transactions (ведь это append-only, не нужна координация между несколькими записями).

Возможность агрегации по времени и атрибутам: логи нужно анализировать:

  • по временным окнам (часы, дни, недели);
  • по типам событий, severity, tags (labels);
  • по источнику (host, container, service).

Нужны эффективные group by, sum, count, p99 (percentiles).

Управляемое хранение истории: логи занимают место. Нужна стратегия:

  • горячие данные (последние дни) хранятся на быстром диске;
  • холодные (архив старше месяца) — на дешёвом хранилище или вообще удаляются;
  • компрессия для уменьшения size;
  • retention policy (автоматическое удаление логов старше X дней).

Типичные кандидаты

Time-series БД (Prometheus, InfluxDB, Victoria Metrics).

Оптимизированы именно для метрик: timestamp + набор tags + value. Примеры:

  • timestamp: 2025-11-27T13:30:45Z
  • tags: service=api, method=POST, status=200
  • value: latency_ms=120

Инструменты:

  • Prometheus: pull-модель (Prometheus сам периодически опрашивает сервис), встроенный query язык PromQL, хорош для мониторинга инфраструктуры;
  • InfluxDB: push-модель (сервис сам пушит данные), higher throughput, горячие данные в памяти;
  • Victoria Metrics: совместима с Prometheus, но масштабируется лучше.

Elasticsearch / OpenSearch для логов.

Elasticsearch — search engine, но с мощным aggregation framework отлично подходит для логов. Типичное использование:

  • каждое событие — это один JSON-документ;
  • индексируется по timestamp, hostname, log level, application name, message;
  • query: GET logs-2025.11.27/_search { "query": { "bool": { "must": [ { "range": { "timestamp": { "gte": "2025-11-27T14:00:00Z" } } }, { "term": { "service": "api" } } ] } }, "aggs": { "by_status": { "terms": { "field": "http_status" } } } }.

Результат: быстро получаешь распределение статусов HTTP за последний час, без того чтобы сканировать все логи.

Недостатки: Elasticsearch требует RAM (индексы в памяти для скорости), требует внимания к retention (или будет заполнять диск). Требует правильной сегментации индексов по дням (иначе индекс за месяц будет размером в сотни GB).

ClickHouse / колоночные OLAP-БД.

ClickHouse — это OLAP-система, оптимизированная для аналитических запросов. Модель: много rows (миллиарды), few columns (10–100), query часто сканирует несколько columns, но не все rows.

Особенности:

  • данные хранятся by column, не by row: вместо [row1: {id, timestamp, service, latency}], хранишь [{id: [1, 2, 3, ...]}, {timestamp: [T1, T2, T3, ...]}, ...];
  • позволяет очень быстро читать одно-два columns, даже если таблица содержит миллиарды rows;
  • встроенная компрессия данных (часто 10x compression ratio);
  • встроенное управление TTL (автоматическое удаление старых данных).

Пример query: SELECT service, count(*) as cnt, avg(latency_ms) as avg_latency FROM events WHERE timestamp >= now() - interval 1 hour GROUP BY service — на ClickHouse выполнится в миллисекунды, даже если events содержит миллиарды строк.

Вопросы и размышления

Объём и горизонт хранения:

  • Сколько событий в секунду? (Prometheus справляется с миллионами, ClickHouse с десятками миллионов).
  • Сколько времени хранить на максимальной детализации? (например, raw logs 7 дней, потом агрегируем до часовых метрик).
  • На сколько месяцев планируется архив?

Критичность query latency:

  • Нужно ли получать результат за 100ms (для live dashboard)?
  • Или 1 сек приемлем (для report, запускаемого раз в день)?

Выделенная инфраструктура или managed service:

  • развёртываешь сам (ClickHouse, Prometheus, Elasticsearch на своих серверах) — контроль, но нужна опытная команда;
  • managed (Datadog, Grafana Cloud, ELK Service) — меньше забот, но дороже и vendor lock-in.

Типичный сценарий 3: поиск по тексту и каталогу

Характеристики задачи

Поиск по тексту и каталогу — типичная задача для e-commerce (поиск товаров), соцсетей (поиск постов и людей), знаний (поиск документов, FAQ). Примеры запросов:

  • найди товары, которые содержат слово "кроссовки" в названии, цена 1000–5000 рублей, рейтинг >= 4.5;
  • найди людей по имени "иван", которые есть в моём списке друзей;
  • подели товары по категориям, покажи самые популярные в каждой;
  • при вводе "крос" в поисковое поле, дай автодополнение: кроссовки, кросс-поезд, крость, кросс....

Требования к БД для поиска

Инвертированные индексы: традиционный индекс в RDBMS работает как B-tree по полю (например, быстро найдёшь все товары с price = 1500). Инвертированный индекс переворачивает логику: для каждого слова хранишь список документов, где это слово встречается.

Пример: индекс { "кроссовки": [1, 5, 12, 45], "кроссовок": [1, 5, 45], "кросс": [1, 5, 12, 45, 100] }. Теперь поиск по "крос" становится простым lookup в индексе, не full table scan.

Анализаторы текста и языковые особенности: при индексировании нужно:

  • удалить стоп-слова (и, в, на);
  • лемматизировать или стемматизировать (кроссовки → кросс, кросс-корп → кросс);
  • обработать опечатки и похожие слова (fuzzy matching);
  • учесть синонимы (кроссовки = кеды = пёрки).

Фасетный поиск и агрегации: наряду с результатами поиска, часто нужно показать фильтры:

  • по категориям (и количество товаров в каждой);
  • по брендам;
  • по диапазонам цен;
  • по рейтингу.

Это не просто filter, это агрегирование результатов по разным атрибутам.

Сортировка и ранжирование: результаты нужно не просто найти, а правильно отранжировать:

  • по релевантности (score, как сильно совпадает с query);
  • по цене, дате, рейтингу, популярности;
  • с бустингом (например, товары одного бренда ранжировать выше).

Типичные кандидаты

Elasticsearch или OpenSearch.

Elasticsearch — это search engine, сделан именно для полнотекстового поиска. В основе инвертированные индексы, встроенная обработка текста, scoring algorithms.

Типичный workflow:

  • индексируешь товары: PUT /products/_doc/1 { "name": "кроссовки Adidas", "price": 5000, "rating": 4.8 };
  • query типа: POST /products/_search { "query": { "bool": { "must": [ { "match": { "name": "кроссовки" } } ], "filter": [ { "range": { "price": { "gte": 1000, "lte": 5000 } } }, { "range": { "rating": { "gte": 4.5 } } } ] } }, "aggs": { "by_brand": { "terms": { "field": "brand" } } } };
  • результат: товары с кроссовками в названии, цена в диапазоне, рейтинг >= 4.5, плюс агрегация по брендам.

Плюсы: out-of-the-box решение для поиска, мощные фильтры и агрегации, fast (если правильно настроить индексы).

Минусы: Elasticsearch heavy (требует RAM), дороговат при большом объёме данных (как основное хранилище), требует opinionated конфигурации (routing, sharding).

RDBMS (Postgres) с полнотекстовыми расширениями.

Postgres имеет встроенный full-text search через расширение tsvector. Пример:

  • создаёшь индекс: CREATE INDEX idx_products_text ON products USING GIN(to_tsvector('russian', name));
  • query: SELECT * FROM products WHERE to_tsvector('russian', name) @@ plainto_tsquery('russian', 'кроссовки') AND price BETWEEN 1000 AND 5000 ORDER BY rating DESC;
  • результат: поиск идёт через GIN-индекс, быстро.

Плюсы: всё в одной системе (не нужно синхронизировать данные между Postgres и Elasticsearch), ACID-гарантии, легко обновлять данные.

Минусы: полнотекстовый поиск в Postgres слабее, чем специализированный search engine; при очень больших объёмах индекс становится огромным; ranking и агрегации менее гибкие.

Algolia или другие SaaS решения.

Если бюджет позволяет, есть managed search сервисы (Algolia, Typesense, MeiliSearch). Они готовые: индексируешь, настраиваешь ranking rules, получаешь fast API.

Плюсы: никаких операционных забот, fast по умолчанию, встроены nice features (analytics, A/B testing);

Минусы: дорого, vendor lock-in, иногда недостаточно настройки.

Вопросы

Сложность поиска:

  • нужны ли подсказки (autocomplete, suggestions)?
  • нужна ли обработка опечаток (fuzzy matching)?
  • нужны ли синонимы?
  • сколько фасет и как часто они меняются?

Объём данных и update frequency:

  • сколько документов (товаров, постов)?
  • как часто обновляются (каждую минуту, раз в день)?

Допустимость eventual consistency:

  • нужно ли, чтобы индекс был на 100% синхронизирован с source of truth?
  • или 1–2 секундная задержка приемлема (typical для async indexing)?

Типичный сценарий 4: аналитика событий

Характеристики задачи

Аналитика событий — это анализ больших объёмов событий для понимания поведения пользователей, трендов, бизнес-метрик. События содержат атрибуты: userId, timestamp, тип события (click, purchase, signup), метаданные (device, country, version приложения).

Примеры запросов:

  • сколько уникальных пользователей кликнули на рекламу за последний месяц?;
  • какова средняя стоимость покупки по дням неделе и часам суток?;
  • какой процент пользователей, которые были активны неделю назад, остались активны на этой неделе? (retention rate);
  • построй cohort analysis: для каждой недели signup'ов, какой процент пользователей делал покупку в каждый из следующих месяцев?.

Эти запросы тяжёлые: нужно обработать миллиарды событий, сгруппировать, провести вычисления, часто — за 5 минут, иногда — за несколько часов.

Требования к БД для аналитики

Эффективные сканы и агрегации по большим объёмам данных: должна быть оптимизирована именно на это, а не на single-row lookups. Time-series DB или OLAP-система, а не OLTP.

Возможность хранения сырья и агрегатов: часто нужно хранить:

  • raw events (все события как есть) для deep dives и отладки;
  • pre-aggregated metrics (например, counts, sums, percentiles по дням/часам) для быстрых dashboard'ов.

Работа в паре с BI/дашбордами: должна быть интеграция с инструментами visualization: Grafana, Looker, Metabase, Superset. Или возможность быстро запустить query и получить результат для report.

Типичные кандидаты

ClickHouse и другие колоночные OLAP-БД.

ClickHouse — самый популярный open-source OLAP-решение для событий в русскоязычных компаниях. Почему:

  • данные хранятся по columns (как в сценарии логирования), очень компактны и быстро читаются;
  • встроены операции агрегации (sum, count, max, percentile) которые работают быстро;
  • встроена компрессия и TTL;
  • можно писать миллионы событий в секунду;
  • query язык SQL, знаком всем.

Пример: SELECT toDate(timestamp) as date, country, count(distinct userId) as unique_users, sum(purchase_amount) as revenue FROM events WHERE event_type = 'purchase' AND timestamp >= now() - interval 30 day GROUP BY date, country ORDER BY revenue DESC.

На таблице с миллиардом событий это выполнится в секунды.

BigQuery (Google), Redshift (AWS), Snowflake — облачные решения.

Это manage OLAP-системы, большие брат ClickHouse. Преимущества:

  • zero ops (Google управляет шардированием, репликацией, backup'ами);
  • pay-as-you-go по объёму данных, которые ты scanned (не по storage);
  • встроенная интеграция с другими Google сервисами (Analytics, Ads).

Недостатки: дороже, чем self-hosted ClickHouse, и есть некоторые latency (не milliseconds, а seconds).

Elasticsearch тоже может использоваться для аналитики событий, если в событиях есть текстовый компонент (например, user feedback). Но обычно ClickHouse эффективнее.

Вопросы

Объём и горизонт:

  • сколько событий в день?
  • на сколько месяцев/лет планируется хранить raw events?
  • сколько лет можно хранить aggregates (часто они хранятся дольше)?

Frequency and latency:

  • как часто запускаются тяжёлые отчёты? (раз в день, раз в час, в real-time?);
  • какой допустимо задержка результатов (real-time, next-hour, next-day)?
  • кто запускает: автоматические job'ы или аналитики по требованию?

Integration:

  • какой BI-инструмент (Grafana, Looker)?
  • нужны ли автоматические alert'ы при аномалиях?

Типичный сценарий 5: реальное время (чат, потоки, онлайн-события)

Характеристики задачи

Real-time системы требуют низкой latency на запись и чтение, и часто наличия большого количества одновременных пользователей/подключений. Примеры:

  • чат (пользователь отправляет сообщение, оно должно появиться у получателя за 100ms);
  • лента в соцсети (свежие посты от друзей, refresh в real-time);
  • онлайн-события (когда пользователь онлайн, когда offline, кто набирает сообщение);
  • live notifications (push уведомления).

Требования

Низкая latency на запись и чтение: каждый запрос должен выполниться за 10–100ms. Это значит:

  • никаких тяжёлых join'ов и полных сканов таблиц;
  • максимум простых key-value lookups или indexed range queries;
  • возможно, кеширование в памяти.

Горизонтальное масштабирование по пользователям/чатам: если у тебя миллион одновременных пользователей, нельзя держать все данные на одной машине. Нужно шардировать: например, chats 1–100k на shard1, 100k–200k на shard2, etc.

Event-driven архитектура: часто real-time системы работают не только с БД, но и с очередями и message streaming:

  • сообщение приходит → попадает в Kafka/RabbitMQ → обрабатывается → сохраняется в БД → отправляется подписчикам (через WebSocket или long polling).

Очень редко можно хранить real-time данные только в БД без промежуточного слоя обработки событий.

Типичные кандидаты

RDBMS (Postgres, MySQL) + Redis + очередь (Kafka/RabbitMQ).

Типичная архитектура для чата:

  • Redis: хранит активные чаты и сообщения (в памяти, быстро);
  • Postgres: долгоживущее хранилище сообщений (backup, archive, поиск по истории);
  • Kafka: транспорт для потока сообщений между компонентами системы.

Workflow:

  1. Пользователь отправляет сообщение → приложение отправляет в Kafka;
  2. Consumer подхватывает → сохраняет в Redis (для быстрого доступа);
  3. Асинхронно, другой consumer → сохраняет в Postgres (для долговечности);
  4. Подписчики на чат читают из Redis (real-time);
  5. Когда пользователь открывает старую историю, читаем из Postgres.

NoSQL (Cassandra, Scylla) при огромной write-нагрузке.

Если пишешь миллионы сообщений в секунду и RDBMS не справляется, переходишь на Cassandra. Она:

  • горизонтально масштабируется: добавляешь node, и throughput растёт;
  • оптимизирована на write-heavy нагрузку (дешёвые inserts);
  • имеет встроенное TTL (автоматическое удаление старых сообщений).

Но Cassandra требует денормализации данных и не поддерживает complex join'ы.

Специализированные стриминговые системы.

Иногда real-time данные вообще не нужно хранить в БД (или хранить только последние N часов):

  • Kafka используется как source of truth для потока событий;
  • каждый subscriber читает из Kafka и может (или не может) persists данные локально;
  • архив можно отдельно писать в OLAP-систему или object storage для долгосрочного анализа.

Это reduces complexity: не нужно беспокоиться о consistency между разными БД, все живет в одном Kafka кластере.

Вопросы

Допустимая latency:

  • нужно ли 100ms (real-time чат, потоки)?
  • или 1 sec приемлем (уведомления)?

История и retention:

  • нужно ли хранить полную историю всех сообщений?
  • или только последние N дней для "подгрузки при скроле вверх"?
  • как долго хранить (неделю, месяц, год)?

Масштаб:

  • сколько одновременных пользователей/чатов?
  • сколько сообщений в секунду?

Как по требованиям и нагрузке сузить выбор до 1–2 вариантов

Оси выбора (основные dimensions)

1. Модель данных:

  • Табличная (RDBMS): структурированные данные, связи между таблицами.
  • Документная (MongoDB, CouchDB): гибкая схема, nested структуры.
  • Key-value (Redis, Memcached): простой кеш, сессии, real-time flags.
  • Граф (Neo4j): связи между entities, социальные сети, рекомендации.
  • Time-series (Prometheus, InfluxDB): timestamp + tags + value.
  • Columnar (ClickHouse, BigQuery): OLAP аналитика.
  • Search (Elasticsearch): полнотекстовый поиск, фасеты, агрегации.

2. Профиль нагрузки:

  • OLTP (Online Transaction Processing): много небольших запросов, latency важен, нужна консистентность. Примеры: CRUD, real-time.
  • OLAP (Online Analytical Processing): тяжёлые аналитические query'ы на большие объёмы данных, latency не критичен. Примеры: логи, аналитика.
  • Write-heavy (логирование, метрики): основная нагрузка на запись, чтение вторичное.
  • Read-heavy (read replicas, кеш): основная нагрузка на чтение.

3. Требования к консистентности (CAP теорема):

  • Strong consistency (immediate): каждый читатель видит последние написанные данные.
  • Eventual consistency: данные распространяются асинхронно, возможны временные расхождения.
  • Локальная (в одной DB): все данные в одном месте, легко гарантировать consistency.
  • Глобальная (multi-DC): данные разбросаны, нужна координация, сложнее гарантировать consistency.

4. Требуемые latency и throughput:

  • Sub-second queries: 100ms–1s (аналитика, отчёты).
  • Millisecond queries: 10–100ms (interactive queries, dashboard'ы).
  • Microsecond/real-time: <10ms (trading, real-time systems).

5. Объём и рост данных:

  • Small (MB–GB): one machine, simple backup.
  • Medium (GB–TB): optimization нужна, но одной машины достаточно.
  • Large (TB–PB): горизонтальное масштабирование обязательно.

6. Требования к аналитике и отчётности:

  • No analytics: только transactional queries, никаких отчётов.
  • Simple reporting: несложные aggregates (count, sum, avg).
  • Complex analytics: cohort analysis, percentiles, retention, forecasting.

Метод фильтрации: пошаговый алгоритм

Шаг 1: Описать 3–5 ключевых сценариев запросов и изменений

На собеседовании интервьюер часто говорит: «Проектируй чат-приложение». Нужно уточнить:

  • Самый частый query: SELECT * FROM messages WHERE chat_id = ? ORDER BY timestamp DESC LIMIT 50 (history of recent messages).
  • Второй: INSERT INTO messages (chat_id, user_id, text, timestamp) VALUES (...) (send message).
  • Третий: SELECT * FROM chats WHERE user_id = ? LIMIT 20 (list of user's chats).
  • Четвёртый: UPDATE users SET status = 'online' WHERE id = ? (set online status).
  • Аналитический: SELECT DATE(timestamp), COUNT(*) as msg_count FROM messages WHERE timestamp > now() - interval '30 days' GROUP BY DATE(timestamp) (daily message volume for last month, не критичный, может быть slow).

Шаг 2: Классифицировать задачу по профилю

Анализируешь эти сценарии:

  • Запросы 1–4: OLTP (lots of small queries, latency matters).
  • Запрос 5: OLAP (heavy scan and aggregation).
  • Профиль нагрузки: balanced read/write (not too biased).
  • Требуемая latency: 10–100ms для основных, можно slow для analytics.
  • Объём: миллионы чатов, миллиарды сообщений → нужна шардирование или горизонтальное масштабирование.
  • Консистентность: strong (очень нежелательно потерять сообщение; eventual consistency для some metadata OK).

Итог классификации: High-load CRUD + real-time, немного аналитики.

Шаг 3: Сузить класс БД

Знаешь, что это CRUD + real-time:

  • RDBMS (Postgres, MySQL): ✓ поддерживают ACID, могут масштабироваться с replicas, могут шардироваться на уровне приложения.
  • Document (MongoDB): ✓ может быть, но нужна денормализация (много duplication).
  • NoSQL (Cassandra): ✓ может быть, если нужна горизонтальная масштабируемость из коробки.
  • Graph: ✗ не нужно для чата.
  • Time-series: ✗ не подходит.
  • OLAP: ✗ не подходит для OLTP-запросов.
  • Search: ~ Elasticsearch может быть для search по сообщениям, но не как main store.

Остаётся: RDBMS (Postgres) как основная, плюс Redis для кеша и real-time.

Шаг 4: Выбрать конкретный движок с учётом команды и инфраструктуры

Между Postgres и Cassandra:

  • Postgres: если команда знает SQL, нужна strong consistency, не планируется многомиллионная write-нагрузка на одну таблицу.
  • Cassandra: если нужно распределить write-нагрузку горизонтально без логики шардирования в приложении, и eventual consistency OK для некоторых операций.

Решение: выбираем Postgres, потому что:

  • команда знает SQL;
  • можем использовать jsonb для flexible schema (если нужна);
  • читаемость и maintainability выше;
  • если нагрузка растёт, можем добавить read replicas и Redis для кеша;
  • если всё ещё недостаточно, шардируем на уровне приложения (это сложнее, чем встроенное в Cassandra, но возможно).

Вспомогательные факторы выбора

Стек компании — что уже есть в production:

Если в компании уже работают несколько Postgres инстансов, team experience с ними, и tooling настроен, то использовать ту же Postgres для нового проекта дешевле и быстрее, чем вводить новую технологию. Даже если Cassandra теоретически лучше, практическая стоимость введения Cassandra (обучение, monitoring, backups, миграции) часто перевешивает выигрыш.

Опыт команды:

Если в команде есть senior, опытный с Elasticsearch и он может онбордить других, то Elasticsearch для поиска по каталогу — хороший выбор. Если никто не знает, то лучше попробовать Postgres с full-text search сначала.

Требования по лицензированию, облакам, open-source:

Некоторые компании не приемлют proprietary лицензии (например, Elasticsearch менял лицензию). Тогда open-source альтернативы (OpenSearch, ClickHouse) становятся приоритетом. Или облако (AWS, GCP, Azure) — тогда managed сервисы (BigQuery, Redshift) могут быть выгоднее, чем self-hosted.


Какие вопросы обязательно задать себе и бизнесу перед выбором БД

Про данные и структуру

Какие сущности и связи нужно хранить?

Ответ определяет, нужна ли табличная модель (RDBMS) или документная/графовая. Примеры:

  • "Пользователи, посты, комментарии, лайки": много связей, много join'ов → RDBMS.
  • "Профили пользователей с nested метаданными": структура может меняться → документная (MongoDB) или RDBMS с jsonb.
  • "Люди, друзья, рекомендации": граф связей → Neo4j или algorithms в основной БД.

Насколько стабильна схема или ожидается частая эволюция?

  • Stable schema (редко меняется): RDBMS с миграциями (ALTER TABLE добавить column).
  • Frequent schema changes (новые поля, новые версии): документная БД (гибкая schema) или RDBMS с jsonb-полем для "flexible" данных.

Нужны ли вложенные структуры, документы, графовые связи?

  • Flat tables (максимум 1 уровень связи): RDBMS.
  • Nested (пользователь содержит список адресов, каждый адрес содержит координаты): документная (MongoDB).
  • Complex relationships (много many-to-many): RDBMS.
  • Graph structures (социальная сеть, рекомендации): граф-БД.

Про нагрузку

Текущие и планируемые RPS (read и write отдельно)?

  • <100 RPS: любая БД;
  • 100–1000 RPS: RDBMS с оптимизацией (индексы, кеш);
  • 1000–10000 RPS: RDBMS + Redis, возможно, read replicas;
  • 10000 RPS: горизонтальное масштабирование (шардирование), рассмотреть NoSQL или специализированные системы.

На какой срок планировать рост? (3 месяца, год, 3 года)

Если в течение года RPS вырастет с 100 до 5000, нужна архитектура, которая позволяет расширяться без полного переписывания. Postgres с индексами и кешем часто справляется с таким ростом. Если вырастет до 50000 в год, нужно предусмотреть шардирование с самого начала.

Строгость требований к latency?

  • P95 < 100ms: требуется оптимизация и кеширование (критичное).
  • P95 < 500ms: приемлемо для большинства сценариев, если нет real-time требований.
  • P95 < 5sec: нормально для аналитики и batch job'ов.

Профиль read/write?

  • Read-heavy (10:1 или выше): read replicas, кеширование, Elasticsearch для поиска.
  • Write-heavy: оптимизация на write (batch inserts, no complex transactions), возможно NoSQL.
  • Balanced: гибкий выбор, но нужна общая оптимизация.

Про консистентность и доступность

Допустимы ли временные расхождения между частями системы (eventual consistency)?

  • Strong consistency обязательна (например, платёжная система, финансовые данные): ACID-транзакции, синхронная репликация.
  • Eventual consistency приемлема (например, социальная сеть, профили пользователей): можно использовать асинхронную репликацию, NoSQL, event streaming.

Требования к RPO/RTO?

  • RPO (Recovery Point Objective): сколько данных максимум ты можешь потерять? (1 час, 1 минута, ноль?).
  • RTO (Recovery Time Objective): сколько времени ты можешь быть offline при сбое? (1 час, 5 минут, ноль?).

RPO = 0 требует синхронной репликации (дорого на latency). RPO = 1 hour допускает асинхронную репликацию. RTO = 5 min требует автоматического failover (усложняет архитектуру).

Нужен ли multi-region / multi-DC?

  • Single DC: one data center, никаких geographic redundancy, но latency с одного региона low.
  • Multi-DC: два региона, репликация между ними, при падении одного DC система работает. Но требует управления консистентностью.
  • Multi-region: разные страны, глобальная репликация, требует решения из облака (Google Spanner, CockroachDB) или сложного кастома.

Про аналитику

Нужны ли тяжёлые отчёты и по каким данным?

  • No analytics: только transactional queries → can use любую OLTP БД.
  • Simple analytics (daily counts, monthly sums): можно писать в основную RDBMS.
  • Complex analytics (cohort analysis, percentiles, retention): лучше separate analytics database.

Допустимо ли строить отдельную аналитическую витрину (data warehouse)?

  • Если да: можешь использовать RDBMS для production, синхронизировать в ClickHouse/BigQuery для analytics.
  • Если нет (нужны real-time analytics): нужна архитектура, которая поддерживает both (например, Postgres + async replication в analytics DB).

Про эксплуатацию

Кто будет администрировать БД?

  • Нет dedicated DBA (small team): нужна система, которая требует minimum ops (managed services, simple config).
  • Есть DBA (large company): можно использовать более сложные системы, которые требуют активного управления.

Есть ли опыт у команды в конкретных технологиях?

Введение новой БД — это learning curve. На 3–6 месяцев производительность может быть ниже, потому что команда не знает как правильно настроить, оптимизировать, дебажить. Учитывать это в планировании.

Допустим ли vendor lock-in или нужен максимально open-source/portable стэк?

  • Допустим lock-in: AWS DynamoDB, BigQuery. Экономишь на ops, платишь vendor lock-in.
  • Open-source only: Postgres, MongoDB, ClickHouse. Можешь запустить anywhere, но нужна своя ops.

Про риски и ограничения

Регуляторика (GDPR, финансовые требования, локальные законы)?

  • GDPR: требуется "право на забывание", шифрование. Нужна система, которая может удалить данные пользователя.
  • Финансовые: требуется аудит, compliance logs, backup strategy.
  • Локальные: например, данные россиян должны храниться в России. Тогда self-hosted или локальный облако, не US-based AWS.

Требования по шифрованию и аудиту?

  • Encryption at rest: данные на диске зашифрованы.
  • Encryption in transit: между компонентами данные передаются по TLS.
  • Audit logs: кто и когда обращался к данным.

Это может быть requirement, и не все БД это поддерживают out-of-the-box.


Баланс между «идеальной» БД и реальностью проекта

Идеальный выбор vs реальный

Часто на бумаге идеальное решение выглядит так: для чата используй Cassandra (горизонтально масштабируется), для поиска Elasticsearch (специализирован на поиске), для аналитики ClickHouse. Но в реальности:

  • Cassandra требует опыта и мониторинга, которого у team нет.
  • Elasticsearch требует постоянной переиндексации, синхронизации с source of truth.
  • ClickHouse требует отдельного ETL pipeline.

Три разные БД → три разные teams компетенций, три разные мониторинга, три разных стратегии backup, три разных миграции.

Практический выбор часто консервативнее: используй Postgres, потому что он is battle-tested, team знает его, и риск меньше. Позже, если нагрузка не справляется с одной осью (например, поиск медленный), добавишь Elasticsearch как отдельный слой.

Стоимость экспериментов

Введение новой БД — это не просто установка и конфигурирование. Это:

  • Обучение команды (неделя–месяц).
  • Integration с существующим stack (ORM, migrations, clients).
  • Monitoring и alerting (custom setup).
  • Backup strategy и disaster recovery testing.
  • Production deployment и rollback procedures.

Часто проще "чуть докрутить" существующую БД (добавить индекс, оптимизировать query, добавить read replica, кеш), чем вводить новую.

Эволюционный путь

Хороший архитектурный выбор — это не "всё сразу", а эволюционный путь:

Стадия 1: Simple (2–10k RPS)

  • Одна Postgres instance, no replicas.
  • Redis для кеша и сессий.
  • Logs в файлы, раз в день grep'ишь.

Стадия 2: Growing (10k–100k RPS)

  • Postgres с несколькими read replicas.
  • Elasticsearch для поиска и логов.
  • ClickHouse для аналитики (async replication из Postgres).
  • Redis cluster для большего throughput.

Стадия 3: Scaling (100k+ RPS)

  • Postgres шардирован по user_id (application-level sharding).
  • Dedicated analytics cluster (ClickHouse, BigQuery).
  • Cassandra для очень write-heavy компонентов (if needed).
  • Kafka для event streaming и reliability.

На каждой стадии добавляешь новые компоненты только когда старая стадия перестаёт справляться. Не нужно предусмотреть everything с самого начала.


Как аккуратно аргументировать выбор БД на собеседовании

Чего избегать

Ошибка 1: "Я всегда использую Postgres, потому что мне он нравится"

Звучит как: я не умею анализировать requirements, я просто кладу всё в одну БД. На собеседовании это красный флаг.

Когда слышишь такой вопрос: "Какую БД выберешь для логирования?", ответ типа "Postgres, потому что я его знаю" — очень слабый. Interviewer поймёт, что ты не думал о требованиях (append-only, миллионы записей в минуту, много читать).

Ошибка 2: Бездоказательные заявления "эта БД самая быстрая/лучшая"

"Elasticsearch самый быстрый для поиска" — не аргумент, это маркетинг. Быстрый в каком смысле? Если у тебя 10 документов, любая БД найдёт за microseconds. Если 10 миллиардов, то инвертированные индексы действительно помогают.

Ошибка 3: Защита одной технологии в каждом проекте

Interviewer может спросить: "А может здесь использовать MongoDB вместо Postgres?" Если твой ответ "нет, потому что я люблю Postgres", это плохо. Хороший ответ: "MongoDB может быть, но потребует денормализации и осложнит join'ы на этапе приложения. Я бы выбрал Postgres потому что X, но MongoDB имеет преимущество Y в сценарии Z".

Как строить аргументацию (правильный подход)

Фаза 1: Описать требования

"Сначала я уточню требования. Нужно понять:

  • какой профиль нагрузки (CRUD, аналитика, поиск)?
  • какой объём данных и RPS?
  • какие требования к консистентности?
  • допустимо ли eventual consistency или нужна strong?
  • нужны ли сложные join'ы или данные более плоские?"

Фаза 2: Связать требования с классом БД

"Исходя из требований, это похоже на CRUD-сценарий с большим read/write ratio. Значит, нам нужна RDBMS с хорошей оптимизацией на запросы и возможностью read replicas."

Фаза 3: Назвать кандидатов и их trade-off'ы

"Вычеты кандидаты:

  1. Postgres — ACID, мощный query optimizer, easily масштабируется через read replicas и sharding. Недостаток: вертикальное масштабирование есть, но горизонтальное требует логики в приложении.
  2. MySQL — похож на Postgres, но немного быстрее на простых операциях, немного хуже на complex queries.
  3. CockroachDB — distributed Postgres, встроенное горизонтальное масштабирование. Но медленнее на single node и дороже.

В данном случае я выбираю Postgres, потому что команда его уже использует, risk меньше, и он справиться с требуемой нагрузкой через read replicas."

Фаза 4: Упомянуть альтернативы и почему менее удачные

"Cassandra была бы лучше для write-heavy нагрузок, но требует денормализации данных, что усложнит логику приложения. Тогда как Postgres позволяет хранить данные в нормализованном виде."

Фаза 5: Обозначить риски и способы их смягчения

"Риск: если в будущем RPS вырастет до 100k и читающая нагрузка не будет распределяться на read replicas, нужно будет мониторить latency и готовиться к шардированию. Я бы рекомендовал добавить мониторинг P99 latency и re-evaluate выбор при P99 > 200ms."

Примеры формулировок (как звучать как expert)

Когда говоришь о trade-off'ах:

"Между Postgres и Cassandra есть trade-off: Postgres даёт мне ACID транзакции и нормализованную схему, но горизонтальное масштабирование требует работы. Cassandra горизонтально масштабируется из коробки, но я должен денормализовать данные и живу с eventual consistency. Для этого проекта ACID важнее, так что выбираю Postgres."

Когда упоминаешь опыт:

"В предыдущем проекте мы использовали Cassandra для логирования метрик (write-heavy нагрузка, eventual consistency OK), и это сработало. Но когда пробовали Cassandra для CRUD-сервиса (где нужны join'ы), пришлось делать слишком много денормализации и потом запутаться в consistency. Поэтому для CRUD я бы выбрал RDBMS."

Когда ты не уверен:

"Здесь есть несколько вариантов. В реальном проекте я бы рекомендовал spike: взять один из вариантов (скажем, Postgres), запустить в production, собрать метрики (latency, throughput, CPU). Если что-то не подходит, switch. Но для начала я бы выбрал Postgres, потому что риск наименьший."

Честность и адекватность:

"Я не знаком глубоко с новой версией ClickHouse, но знаю принципы, по которым выбирают БД. Для этого сценария нужна колоночная OLAP-система, и ClickHouse — хороший кандидат. Но я не могу сейчас дать детальную оценку performance, потому что не у меня есть experience с last release. Я бы рекомендовал POC."


Мини-чек-лист (резюме статьи)

Три–пять осей выбора (главные dimensions)

  1. Модель данных — как структурировать данные (tabular, document, key-value, graph, time-series, columnar).
  2. Профиль нагрузки — OLTP vs OLAP, read-heavy vs write-heavy, single-node vs distributed.
  3. Требования к консистентности — strong vs eventual, локальная vs глобальная.
  4. Latency и throughput — milliseconds vs seconds, сколько RPS.
  5. Объём и эксплуатация — малые данные (1 machine) vs большие (шардирование), опыт team, compliance.

Четыре–пять типовых сценариев (быстрая классификация)

  1. CRUD-сервис → RDBMS + Redis.
  2. Логирование и метрики → Time-series DB или ClickHouse.
  3. Поиск по тексту → Elasticsearch, Postgres Full-Text Search, или Algolia.
  4. Аналитика событий → ClickHouse, BigQuery, Snowflake.
  5. Real-time (чат, потоки) → RDBMS + Redis + Kafka, или NoSQL при огромной write-нагрузке.

Три правила (core principles)

Правило 1: Всегда отталкиваться от сценариев и требований, а не от привычки.

Не "я всегда беру Postgres", а "исходя из требований (X RPS, Y consistency), я выбираю Postgres потому что [аргумент]".

Правило 2: Сузить выбор до 1–2 кандидатов и проговорить trade-off'ы.

Не переанализировать 10 вариантов. Выбрать 1–2 лучших и точно описать, в чём каждый лучше/хуже.

Правило 3: Помнить про команду, эксплуатацию и эволюцию системы.

Идеальная технология может быть bad fit, если team не знает, как её поддерживать. Выбирай системы, которые:

  • команда уже использует или быстро может научиться;
  • имеют простую операционную модель (backup, restore, monitoring);
  • допускают эволюцию (можно добавить компоненты позже).

Быстрый decision tree (для практики)

  1. Какая нагрузка? (OLTP / OLAP / search / real-time)
  2. Какой объём? (small / medium / large)
  3. Какие требования к консистентности? (strong / eventual)

Если OLTP + small/medium + strong consistency → Postgres Если OLTP + large + strong consistency → Postgres + sharding или CockroachDB Если OLAP + large volume → ClickHouse или BigQuery Если search → Elasticsearch Если real-time high write → Postgres + Redis + Kafka или Cassandra Если time-series → Prometheus, InfluxDB, или ClickHouse


Этот чек-лист — стартовая точка. На реальных собеседованиях и проектах дополнительно учитываются нюансы (например, наличие multi-region требований, регуляторные ограничения, размер бюджета). Но суть остаётся: анализируй требования, классифицируй задачу, сузь выбор, обоснуй решение с учётом team и risks.