B03 · Хранилища и данные#
Слой, где решения дороже всего откатывать. Схема, ключ шардирования и выбор хранилища переживают несколько поколений кода: сервис можно переписать за квартал, а форму хранения десяти терабайт — нет.
Блок не ограничивается реляционной логикой: в нём изоляция транзакций, шардирование, эволюция схемы, специализированные хранилища, геоданные, типы данных и сверка.
Навигация: DATA-01 · DATA-02 · DATA-03 · DATA-04 · DATA-05 · DATA-06 · DATA-07 · DATA-08 · DATA-09 · DATA-10 · DATA-11 · DATA-12 · DATA-13 · DATA-14 · DATA-15 · DATA-16 · DATA-17 · DATA-18 · DATA-19
DATA-01 · Выбор хранилища#
Шаг W3
TL;DR. Реляционная БД — разумное умолчание. Отклонение от умолчания требует довода в виде числа или формы данных, а не моды.
Проблема. Хранилище обычно выбирают раньше, чем сформулированы паттерны доступа: по привычке, по резюме или по статье о том, как компания с миллиардом пользователей ушла от PostgreSQL. Ошибка обнаруживается через год, когда переписывать поздно. Неверный класс хранилища либо не даёт нужный запрос, либо не держит нужную нагрузку, либо требует эксплуатации, на которую нет людей.
Как работает — критерии выбора.
- Форма данных. Связи и агрегаты, требующие соединений, → реляционная. Документы, читаемые целиком, → документная. Граф связей с обходом в глубину → графовая. Метрики по времени → time-series (временные ряды). Ключ — значение с высокой частотой → KV-хранилище.
- Профиль нагрузки. Записей больше, чем чтений, и они append-only (только дописываются) → LSM-хранилища (DATA-05). Чтения преобладают → реляционная с репликами и кэшем.
- Объём и рост. До единиц терабайт и десятков тысяч операций в секунду одна реляционная БД справляется — этого достаточно подавляющему большинству систем.
- Требования к согласованности. Нужны транзакции над несколькими сущностями → реляционная. Достаточно атомарности одного документа → документная.
- Операционная стоимость. Каждое новое хранилище — это бэкапы, мониторинг, обновления, экспертиза и дежурство. Второе хранилище стоит дороже, чем кажется на этапе выбора.
Когда применять что
| Класс | Сильная сторона | Плата |
|---|---|---|
| Реляционная (PostgreSQL, MySQL) | Транзакции, соединения, зрелость, гибкость запросов | Масштабирование записи требует шардирования |
| Документная (MongoDB, DocumentDB) | Гибкая схема, агрегат целиком | Слабые межколлекционные операции |
| Wide-column (Cassandra, ScyllaDB) | Огромная нагрузка на запись, линейный рост | Запросы только по заранее выбранному ключу |
| KV (Redis, DynamoDB) | Минимальная задержка, простота | Нет сложных запросов |
| Поисковая (Elasticsearch) | Полнотекстовый и фасетный поиск | Не источник истины (DATA-13) |
| Колоночная (ClickHouse, BigQuery) | Аналитика по миллиардам строк | Плохо для точечных обновлений |
| Object storage (объектное хранилище: S3) | Дёшево, безгранично для файлов | Нет запросов по содержимому |
Trade-offs и режимы отказа. Polyglot persistence (несколько видов хранилищ в одной системе) умножает не только возможности, но и число мест, где данные расходятся. Любым двум копиям нужен механизм синхронизации (MSG-07). Специализированное хранилище решает одну задачу отлично, а со всеми остальными запросами справляется плохо. Выбор «как в Netflix» переносит решение, принятое под чужие числа.
Что мерить.
- задержку и пропускную способность по каждому паттерну доступа;
- объём и его рост;
- долю запросов, не покрытых индексом;
- стоимость хранения и операций.
Связано: DATA-03 моделирование · DATA-15 OLTP/OLAP · DATA-10 шардирование · W3
Источники: Kleppmann, DDIA, гл. 2–3 · AWS — Purpose-built databases · ByteByteGo — 10+ Key Memory & Storage Systems: Crash Course System Design #5 · ByteByteGo — 7 Must-know Strategies to Scale Your Database
DATA-02 · Специализированные хранилища: time-series, графовые, векторные#
Шаг W3
TL;DR. Реляционная БД тянет почти всё, но на трёх профилях нагрузки проигрывает на порядки: поток метрик, обход графа связей и поиск по близости в векторном пространстве.
Проблема. Каждый из этих профилей ломает предположения, на которых построена реляционная модель. Метрики — это миллионы записей в секунду, которые поштучно почти не читают, зато агрегируют по временным окнам. Обход графа «друзья друзей друзей» превращается в соединение таблицы с собой на каждом уровне, и стоимость растёт экспоненциально. Поиск похожего по эмбеддингу (векторному представлению) вообще не выражается через сравнение значений: нужен поиск ближайших точек в пространстве сотен измерений.
Три класса.
Time-series (временные ряды: Prometheus, VictoriaMetrics, TimescaleDB, ClickHouse). Данные — точки «метрика + набор меток + время + значение». Специализация даёт три вещи:
- сжатие, которое использует монотонность времени и близость соседних значений. Дельта-кодирование и XOR — приём из Gorilla — сокращают объём кратно по сравнению с общим сжатием;
- автоматическое прореживание (downsampling) старых данных и удаление по возрасту;
- запросы, в которых окна и агрегаты — примитивы.
Главный ограничитель — кардинальность: каждая уникальная комбинация меток создаёт отдельный ряд. Метка с идентификатором пользователя выводит систему мониторинга из строя быстрее любой нагрузки (OPS-08).
Графовые (Neo4j, JanusGraph). Связь хранится как ссылка, а не как строка в таблице соединений, поэтому переход к соседям стоит примерно одинаково при любом размере графа. Оправданы там, где обход глубже трёх уровней и глубина заранее неизвестна. Примеры: рекомендации по социальному графу, права доступа вида ReBAC (relationship-based access control, доступ по связям; SEC-04), цепочки в антифроде. До двух-трёх уровней рекурсивный запрос в PostgreSQL справляется — и это правильный первый шаг.
Векторные (pgvector, Qdrant, Milvus, поиск по векторам в Elasticsearch). Хранят эмбеддинги и ищут ближайшие приближённо: точный поиск в многомерном пространстве равен полному перебору. Поэтому используют индексы вроде HNSW (hierarchical navigable small world) — граф связей между точками, спуск по которому ведёт к близким соседям. Ключевой параметр — компромисс между полнотой (recall) и скоростью: индекс настраивают так, что часть настоящих ближайших соседей теряется, — это осознанный размен. Применение: семантический поиск, поиск дубликатов, рекомендации, извлечение контекста для языковых моделей. На практике, как правило, работает гибрид с обычным полнотекстовым поиском (DATA-13).
Правило подхода. Сначала расширить основную БД: расширение для векторов, рекурсивный запрос для графа, колоночная таблица для метрик. Отдельную систему вводить, только когда основная упирается. Каждое новое хранилище стоит эксплуатации, синхронизации и сверки (DATA-16).
Альтернативы
| Задача | Лёгкий путь | Тяжёлый путь |
|---|---|---|
| Метрики | Колоночная таблица, ClickHouse рядом | Специализированная TSDB (БД временных рядов) с прореживанием |
| Граф | Рекурсивный запрос в SQL | Графовая БД с языком обхода |
| Векторы | Расширение к PostgreSQL | Отдельный векторный движок с шардированием |
Trade-offs и режимы отказа. Все три класса дают производную копию данных, которая расходится с источником — нужен конвейер (MSG-07) и сверка. Смена модели эмбеддингов означает полную переиндексацию всего корпуса: это плановая работа на часы, её закладывают заранее. Графовые БД плохо шардируются — связный граф трудно разрезать. Прореживание метрик необратимо: детализация за прошлый год не восстановится.
Что мерить.
- для метрик — число рядов и рост кардинальности;
- для графа — глубину и время обхода;
- для векторов — полноту выдачи на контрольном наборе, задержку поиска и долю запросов, где гибридная выдача лучше чистой векторной.
Связано: DATA-01 выбор хранилища · DATA-13 полнотекстовый поиск · DATA-15 OLTP/OLAP · DATA-16 целостность
Источники: Gorilla: A Fast, Scalable, In-Memory Time Series Database (VLDB 2015) · Prometheus — Storage · Malkov & Yashunin — Efficient and robust ANN search using HNSW · pgvector · Neo4j — Graph database concepts
DATA-03 · Моделирование от паттернов доступа#
Шаг W3
TL;DR. Сначала список запросов с их частотой и требованиями к задержке, потом схема. Не наоборот.
Проблема. Схема, «правильная» по канонам нормализации, может не обслуживать главный запрос системы: ему нужны шесть соединений и сортировка миллиона строк. Обратная крайность — денормализация везде — превращает каждое изменение в каскад обновлений. Без явного списка паттернов доступа выбор между ними делается наугад.
Как работает.
- Выписать запросы: что читаем, по какому ключу, сколько строк, с какой частотой, с каким требованием к задержке и свежести. Пример формулировки: «лента пользователя, последние 20 записей, 5 000 RPS, p99 200 мс, допустимо отставание 10 с».
- Ранжировать: 2–3 запроса обычно дают 90 % нагрузки. Схема оптимизируется под них, остальные обслуживаются как получится.
- Спроектировать ключи и индексы под запросы из шага 1 (DATA-05).
- Решить про денормализацию точечно: дублируем поле, если оно читается на порядок чаще, чем меняется, и рассогласование заметно не сразу.
- Развести истину и проекции. Источник истины нормализован; быстрые чтения обслуживают производные структуры — материализованные представления, поисковый индекс, кэш, отдельная модель чтения (ARCH-06).
Когда применять. В любой системе, но глубина проработки пропорциональна цене ошибки: для wide-column и документных хранилищ схема полностью определяется запросами, для реляционных остаётся пространство для манёвра.
Альтернативы
| Подход | Когда лучше | Цена |
|---|---|---|
| Нормализованная схема | Запросы разнообразны и заранее неизвестны | Соединения на горячем пути |
| Денормализация | Известен доминирующий запрос | Каскадные обновления, риск рассогласования |
| Материализованные представления | Тяжёлая агрегация читается часто | Задержка обновления, стоимость пересчёта |
| Отдельная модель чтения (CQRS) | Профили чтения и записи расходятся радикально | Второй контур данных и его согласование |
Trade-offs и режимы отказа. Денормализация без владельца поля приводит к тому, что копии расходятся и никто не знает, какая верна. Оптимизация под сегодняшний главный запрос усложняет появление завтрашнего. Схема, выведенная из UI-экранов, ломается при первом редизайне — выводить нужно из сущностей и операций домена, а из экранов брать только частоты.
Что мерить.
- долю запросов, обслуженных индексом;
- число строк, прочитанных на один возвращённый;
- частоту изменения денормализованных полей;
- отставание проекций.
Связано: DATA-05 индексы · ARCH-06 CQRS · DATA-10 шардирование · ARCH-02 границы контекстов
Источники: Kleppmann, DDIA, гл. 2 · DynamoDB — Best practices for designing and using partition keys · Martin Fowler — Aggregate
DATA-04 · Типы данных: деньги, время, идентификаторы#
Шаг W3
TL;DR. Ошибки в этом слое не видны на тестах, накапливаются молча и обнаруживаются сверкой через месяцы. Правила простые, но их надо принять один раз и на всю схему.
Проблема. 0.1 + 0.2 в двоичной плавающей точке не равно 0.3. Встреча, назначенная на 9:00 по местному времени, «переезжает» после смены правил перехода на летнее время. Сумма, посчитанная в трёх местах с разным округлением, расходится на копейки, и расхождение растёт с оборотом. Каждую из этих ошибок дёшево исправить при проектировании схемы и дорого — потом.
Деньги.
- Не хранить в числе с плавающей точкой. Только целое в минимальных единицах (копейки, центы) либо десятичный тип фиксированной точности.
- Валюта хранится рядом с суммой и входит в значение: сумма без валюты — не сумма. Код валюты по ISO 4217.
- Округление — это правило и место. Правило (половина вверх, банковское) фиксируется явно; место — одно: округлять на каждом промежуточном шаге значит накапливать погрешность. Распределение суммы по позициям (скидка на корзину, налог) требует алгоритма, который гарантирует, что части складываются в целое.
- Курс конвертации фиксируется на момент операции и хранится вместе с ней, иначе исторические суммы «плывут» при пересчёте.
Время.
- Момент события — в UTC. Метки создания, изменения, логи — только так.
- Будущее событие в календаре — исключение. «Совещание 12 марта в 9:00 по Москве» нужно хранить как локальное время плюс идентификатор зоны IANA: если правила зоны изменятся, момент в UTC должен пересчитаться. Хранение такого события сразу в UTC — тихая ошибка, которая проявится через год.
- Переходы на летнее время дают несуществующие и удвоенные моменты: 02:30 в день перехода может не существовать либо существовать дважды. Любая арифметика над локальным временем должна это учитывать.
- tz database (база часовых поясов) обновляется несколько раз в год. Её версия — зависимость системы, а не деталь операционной системы.
- Сутки не равны 24 часам, месяц — переменной длины: «плюс один день» и «плюс 24 часа» — разные операции.
- Длительность измеряется монотонными часами, а не настенными: коррекция времени может пойти назад и дать отрицательную длительность (DIST-10).
Идентификаторы и строки.
- Тип идентификатора в схеме выбирается один раз и переживает всё остальное (DIST-09).
- Порядковый публичный идентификатор раскрывает объёмы бизнеса — наружу отдают непрозрачный.
- Электронная почта — плохой первичный ключ: она меняется, регистр части адреса формально значим, а варианты с плюсом дают несколько написаний одного ящика.
- Текст нормализуется по Unicode перед сравнением и хранением; ограничения длины считаются в символах, а не в байтах, иначе имя с эмодзи неожиданно не помещается.
- Значения перечислений не переиспользуются: удалённый статус остаётся в истории, и его код не должен означать что-то другое.
Trade-offs и режимы отказа. Целое в копейках упирается в разрядность при сверхбольших суммах и требует внимания к валютам без дробной части. Хранение локального времени с зоной усложняет запросы по диапазону — на практике держат оба представления, помечая, какое из них источник истины. Строгая нормализация строк ломает поиск по «как ввёл пользователь», поэтому хранят и исходное написание, и нормализованную форму для сравнения.
Что мерить.
- расхождения при сверке сумм (DATA-16);
- число операций с округлением в отчётный период;
- ошибки разбора дат по источникам;
- долю записей с неизвестной или пустой зоной времени.
Связано: DATA-03 моделирование · DATA-16 целостность и сверка · DIST-09 уникальные ID · CASE-09 платёжный поток
Источники: Martin Fowler — Money pattern · PostgreSQL — Date/Time Types · IANA Time Zone Database · RFC 3339 — Date and Time on the Internet · Unicode UAX #15 — Normalization Forms
DATA-05 · Индексы: B-tree, LSM, составные и покрывающие#
Шаг W3
TL;DR. Индекс — отдельная структура: она ускоряет чтение и замедляет запись. Выбор между B-tree и LSM определяет, что будет дешевле: читать или писать.
Проблема. Без индекса запрос читает всю таблицу — при миллионах строк это секунды и полная утилизация диска. С индексом на каждое поле запись замедляется в разы, а место удваивается. Между этими крайностями и находится проектирование.
Как работает.
B-tree (B-дерево) — сбалансированное дерево, которое обновляется на месте. Поиск за O(log n) с малой константой (высота дерева 3–4 уровня даже для миллиардов строк). Чтение эффективно и предсказуемо; запись требует изменения страниц и записи в журнал — при случайных ключах страдает локальность.
LSM-дерево (log-structured merge) — записи накапливаются в памяти (memtable), сбрасываются на диск последовательно (SSTable), периодически сливаются (compaction). Запись дешёвая и последовательная; чтение может потребовать просмотра нескольких уровней — это смягчают фильтры Блума. Основа RocksDB, Cassandra, ScyllaDB, HBase; в PostgreSQL и MySQL — B-tree.
Виды индексов, которые решают конкретные задачи:
- Составной — порядок колонок критичен: индекс
(a, b)обслуживает условия поaи поa, b, но не по одномуb(правило левого префикса). - Покрывающий — содержит все нужные запросу колонки, поэтому обращение к таблице не требуется (index-only scan).
- Частичный — только по строкам, удовлетворяющим условию: дешевле и меньше.
- Специальные — GIN/GiST для полнотекста и JSON, геоиндексы, хэш-индексы для точного равенства.
Когда применять. Индекс оправдан, если он покрывает частый запрос и обладает достаточной селективностью. Когда нет: индекс по полю с двумя значениями (флаг) бесполезен; индекс на таблице, которая пишется гораздо чаще, чем читается, — вреден.
Альтернативы
| Вариант | Плюс | Минус |
|---|---|---|
| B-tree | Предсказуемое чтение, диапазоны, сортировка | Дороже запись, фрагментация |
| LSM | Дешёвая запись, хорошее сжатие | Read/space amplification (лишние чтения и лишнее место), паузы на compaction |
| Покрывающий индекс | Убирает обращение к таблице | Дублирование данных, дороже запись |
| Денормализация вместо индекса | Убирает соединение | Рассогласование (DATA-03) |
Trade-offs и режимы отказа. Каждый индекс — это дополнительная запись при каждом INSERT/UPDATE. Индексы «на всякий случай» — типовая причина деградации записи. Планировщик может проигнорировать индекс при неверной статистике или низкой селективности. В LSM накопившийся долг compaction проявляется внезапно: ростом задержки чтения и всплеском ввода-вывода.
Что мерить.
- долю index-only сканов;
- число прочитанных строк на возвращённую;
- размер индексов относительно таблицы;
- неиспользуемые индексы;
- глубину уровней и объём compaction в LSM.
Связано: DATA-06 оптимизация SQL · DATA-03 моделирование · DIST-09 упорядоченные ID
Источники: Kleppmann, DDIA, гл. 3 · Use The Index, Luke · PostgreSQL — Indexes · ByteByteGo — 10 Key Data Structures We Use Every Day
DATA-06 · SQL: порядок выполнения и оптимизация запросов#
Шаг W3
TL;DR. SQL пишется в одном порядке, а выполняется в другом. Понимание фактического порядка объясняет большинство «почему запрос медленный».
Проблема. Запрос читается сверху вниз, начиная с SELECT, а выполняется начиная с FROM. Поэтому фильтр в HAVING вместо WHERE обрабатывает на порядки больше строк, а алиас из SELECT не виден в WHERE. Оптимизировать запрос, не понимая порядка, — угадывать.
Как работает — логический порядок выполнения.
FROM / JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
Отсюда следуют практические правила:
- Фильтровать как можно раньше. Условие в
WHEREотсекает строки до группировки; вHAVING— после.HAVINGнужен только для условий по агрегатам. - Сокращать объём до соединения. Чем меньше строк входит в JOIN, тем дешевле он обходится.
LIMITне спасает от сортировки — чтобы вернуть 10 строк поORDER BY, СУБД должна упорядочить весь результат, если нет подходящего индекса.- Алиасы из
SELECTнедоступны вWHERE, потому чтоSELECTвычисляется позже.
Метод оптимизации.
EXPLAIN (ANALYZE, BUFFERS)— смотреть фактические строки против оценочных: расхождение на порядки означает устаревшую статистику.- Найти самый дорогой узел плана: Seq Scan по большой таблице, Nested Loop с большим числом итераций, внешняя сортировка на диске.
- Проверить, есть ли индекс, покрывающий условие (DATA-05); проверить, не мешает ли функция над колонкой его использовать (
WHERE lower(email) = ...не использует индекс поemail). - Проверить типовые ошибки приложения: N+1 запросов (PERF-06), выборка всех колонок вместо нужных, пагинация по большому
OFFSET(NET-14).
Когда применять. При любой деградации чтения — до того, как обсуждать кэш и реплики: неоптимальный запрос, размноженный кэшем, остаётся неоптимальным.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Индекс под запрос | Запрос частый и селективный | Замедление записи |
| Переписать запрос | План плох из-за формы запроса | Требует понимания плана |
| Материализованное представление | Тяжёлая агрегация, свежесть терпит | Стоимость и задержка пересчёта |
| Денормализация | Соединение неустранимо дорого | Рассогласование |
| Кэш | Один и тот же результат читают многие | Инвалидация (CACHE-03) |
Trade-offs и режимы отказа. Оптимизация под один запрос ухудшает другие (лишние индексы). План меняется при изменении объёма данных — запрос, быстрый на тестовых 10 тысячах строк, может выбрать другой план на десяти миллионах. Хинты (подсказки) планировщику фиксируют решение, которое со временем перестанет быть верным.
Что мерить.
- время выполнения по перцентилям;
- число прочитанных страниц и буферов;
- долю Seq Scan по большим таблицам;
- самые дорогие запросы по суммарному времени (
pg_stat_statements).
Связано: DATA-05 индексы · PERF-06 N+1 и пулы · NET-14 пагинация
Источники: PostgreSQL — Using EXPLAIN · Use The Index, Luke · ByteByteGo — Secret To Optimizing SQL Queries - Understand The SQL Execution Order
DATA-07 · ACID и транзакции#
Шаг W6
TL;DR. Транзакция — способ не думать о частичном выполнении и о параллельных операциях. Из четырёх букв реальную работу делают A, I и D.
Проблема. Операция состоит из нескольких изменений: списать со счёта, зачислить на другой, записать в журнал. Сбой посередине оставляет данные в состоянии, которого не должно существовать. Параллельная операция, увидевшая промежуточное состояние, принимает решение на основе несуществующей реальности.
Как работает.
- Atomicity (атомарность) — всё или ничего. Реализуется журналом отмены: незавершённая транзакция откатывается. Правильнее читать как «abortability» — возможность безопасно прервать.
- Consistency (согласованность) — данные удовлетворяют инвариантам приложения. Это единственная буква, которую обеспечивает не БД, а вы: БД проверит внешние ключи и ограничения, но «сумма остатков не меняется» — ваша ответственность.
- Isolation (изоляция) — параллельные транзакции ведут себя так, будто выполняются по очереди. На практике полная изоляция стоит дорого и, как правило, ослабляется (DATA-08) — здесь прячется большинство сюрпризов.
- Durability (долговечность) — зафиксированное переживает отказ. Её обеспечивают журнал — WAL (write-ahead log, журнал упреждающей записи) — и fsync; в распределённой системе — подтверждением от реплик. Абсолютной durability не существует: сгоревший диск с несинхронизированным журналом теряет данные.
Практические следствия.
- Транзакция должна быть короткой: долгая держит блокировки и раздувает версии MVCC.
- Не выполнять внутри транзакции сетевые вызовы к другим сервисам — время удержания становится неконтролируемым.
- Границы транзакции = границы атомарности. Всё, что не поместилось внутрь, требует распределённого решения (DIST-08).
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Транзакция в одной БД | Данные в одном хранилище | Требует общих границ |
| Атомарность одного документа | Агрегат читается и пишется целиком | Нет операций между агрегатами |
| Saga с компенсациями | Несколько сервисов | Нет изоляции |
| Идемпотентная операция без транзакции | Один эффект, повтор безопасен | Нужен ключ идемпотентности |
Trade-offs и режимы отказа. BASE-хранилища (basically available, soft state, eventual consistency) меняют изоляцию и немедленную согласованность на доступность. Это осознанный размен, а не «отсутствие транзакций». synchronous_commit = off ускоряет запись ценой потери последних транзакций при аварии. Транзакция, охватывающая слишком много, превращается в точку сериализации всей системы.
Что мерить.
- длительность транзакций, p99;
- число откатов и дедлоков;
- возраст самой старой открытой транзакции;
- задержку fsync журнала.
Связано: DATA-08 уровни изоляции · DIST-08 распределённые транзакции · DATA-09 репликация
Источники: Kleppmann, DDIA, гл. 7 · PostgreSQL — Reliability and the Write-Ahead Log · ByteByteGo — ACID Properties in Databases With Examples
DATA-08 · Уровни изоляции, аномалии, MVCC#
Шаг W6
TL;DR. «Транзакция есть» не значит «гонок нет». Уровень изоляции по умолчанию допускает аномалии, которые в бизнес-логике выглядят как потерянные деньги.
Проблема. Самый частый источник тихих багов: два процесса читают одно и то же, каждый принимает решение и записывает результат. Итог не соответствует ни одному из сценариев по отдельности. Классика: два запроса проверили «остаток > 0» и оба списали; два администратора одновременно сняли себя с дежурства, оставив смену пустой.
Аномалии и уровни
| Аномалия | Что происходит | Предотвращается с уровня |
|---|---|---|
| Dirty read (грязное чтение) | Видны незафиксированные изменения | Read Committed |
| Non-repeatable read (неповторяющееся чтение) | Повторное чтение строки даёт другое значение | Repeatable Read / Snapshot |
| Phantom read (фантомное чтение) | Повторный запрос по условию возвращает новые строки | Repeatable Read (в ряде СУБД) / Serializable |
| Lost update (потерянное обновление) | Два «прочитал–изменил–записал» затирают друг друга | Repeatable Read с обнаружением, SELECT FOR UPDATE, atomic UPDATE |
| Write skew | Каждая транзакция видит корректное состояние, вместе нарушают инвариант | Только Serializable |
Как работает MVCC (multiversion concurrency control, многоверсионное управление параллельным доступом). Вместо блокировки читателей СУБД хранит несколько версий строки: транзакция видит снимок данных на момент своего начала. Читатели не блокируют писателей и наоборот — отсюда высокая параллельность. Плата: версии нужно вычищать (vacuum/purge), а долгая транзакция удерживает старые версии и раздувает хранилище.
Что важно знать про конкретные СУБД. В PostgreSQL уровень repeatable read реализован как snapshot isolation (изоляция снимков) и не защищает от write skew. Для этого нужен serializable — SSI (serializable snapshot isolation). Он работает оптимистически и может отменить транзакцию с ошибкой сериализации, а приложение должно её повторить. В MySQL InnoDB уровень по умолчанию repeatable read с блокировками next-key, что даёт другое поведение при тех же названиях. Одинаковые имена уровней в разных СУБД означают разные гарантии.
Как выбирать защиту.
- Инкремент/декремент → атомарный
UPDATE ... SET x = x - 1 WHERE x > 0(без чтения в приложение). - Обновление по прочитанным данным → оптимистическая блокировка: колонка версии и
WHERE version = ?, повтор при нуле обновлённых строк. - Инвариант между разными строками →
SERIALIZABLEили явная блокировка, либо материализация конфликта (строка-замок). - Уникальность → уникальный индекс; это самая надёжная и самая дешёвая проверка.
Trade-offs и режимы отказа. Serializable снижает пропускную способность и требует обработки ошибок сериализации: приложение должно уметь повторять транзакцию. Пессимистические блокировки создают очередь и дедлоки. Оптимистические дешевле, но при высокой конкуренции вырождаются в бесконечные повторы.
Что мерить.
- число дедлоков и ошибок сериализации;
- долю успешных повторов;
- длительность и возраст транзакций;
- рост «мусорных» версий.
Связано: DATA-07 ACID · DIST-07 распределённые блокировки · CASE-08 бронирование
Источники: PostgreSQL — Transaction Isolation · Kleppmann, DDIA, гл. 7 · A Critique of ANSI SQL Isolation Levels (Berenson et al.) · Jepsen — Consistency models
DATA-09 · Репликация#
Шаг W5
TL;DR. Копии данных на нескольких узлах дают доступность и масштаб чтения; цена — отставание, которое видно пользователю.
Проблема. Одна копия данных означает: отказ узла = недоступность, а вся читающая нагрузка ложится на один сервер. Копии решают обе задачи и создают третью — расхождение между копиями.
Как работает.
- Один лидер (single-leader; стандарт для реляционных БД). Запись только в лидера, реплики применяют его журнал. Простая модель, строгий порядок записи. Failover (переключение на резерв) — выбор нового лидера (DIST-05).
- Синхронная репликация: лидер подтверждает клиенту после записи на реплику. Нет потери данных, но задержка растёт, а отказ реплики блокирует запись. Компромисс — кворумная запись на одну из нескольких реплик.
- Асинхронная: подтверждение сразу, реплики догоняют. Быстро, но при отказе лидера последние транзакции теряются: RPO (recovery point objective, допустимая потеря данных) > 0.
- Несколько лидеров (multi-leader) — запись в разных регионах; требует разрешения конфликтов (DIST-10).
- Без лидера (leaderless) — кворумы (DIST-04).
Лаг репликации — главный практический эффект. Пользователь сохранил профиль, следующий запрос попал на отстающую реплику, изменения «исчезли». Лечится гарантиями сессии (DIST-03): читать с лидера в течение нескольких секунд после записи, либо передавать номер версии и ждать, пока реплика догонит.
Когда применять. Реплики чтения — когда нагрузка читающая и запросы допускают отставание. Когда нет: реплики не масштабируют запись (каждая применяет весь поток изменений) и не защищают от логической ошибки (REL-09).
Альтернативы
| Вариант | Даёт | Не даёт |
|---|---|---|
| Реплики чтения | Масштаб чтения, резерв | Масштаб записи |
| Кэш | Ещё большее ускорение чтения | Транзакционность, сложность инвалидации |
| Шардирование | Масштаб записи и объёма | Простоту запросов |
| Бэкапы | Защиту от логической ошибки | Доступность |
Trade-offs и режимы отказа. Автоматический failover при неверном определении отказа даёт двух лидеров и расхождение — split-brain («раздвоение»). Поэтому переключение опирается на кворум. Реплика, отставшая надолго, при переключении теряет данные. Направление всей читающей нагрузки на реплики маскирует проблему до момента, когда лаг становится минутами.
Что мерить.
- лаг репликации в секундах и в байтах;
- долю чтений с лидера;
- время failover;
- число откатов транзакций при переключении.
Связано: DATA-10 шардирование · DIST-03 модели согласованности · REL-08 multi-region · REL-09 бэкапы
Источники: Kleppmann, DDIA, гл. 5 · PostgreSQL — High Availability, Load Balancing, and Replication · ByteByteGo — 7 Must-know Strategies to Scale Your Database
DATA-10 · Шардирование и партиционирование#
Шаг W5
TL;DR. Шардирование — разрезание данных на части, которые живут на разных узлах. Это единственный способ масштабировать запись и самое дорогое решение в дизайне данных.
Проблема. Один узел упирается в потолок по записи, объёму или памяти под индексы. Реплики не помогают: они дублируют весь поток записи. Остаётся разделить данные — но выбранный ключ разделения определит, какие запросы останутся дешёвыми, а какие станут невозможными, и переиграть это позже почти нереально.
Как работает.
- По хэшу ключа — равномерное распределение, но диапазонные запросы требуют обхода всех шардов.
- По диапазону — эффективные диапазонные запросы, риск горячего шарда (последний диапазон принимает все новые записи).
- По справочнику (directory-based) — таблица соответствия «ключ → шард», максимальная гибкость, дополнительный компонент и точка отказа.
- Геошардирование — по региону пользователя; естественно ложится на multi-region.
- Consistent hashing (DATA-11) — минимизирует переезд данных при изменении числа узлов.
Выбор ключа — главное решение. Хороший ключ:
- даёт равномерное распределение;
- присутствует в большинстве запросов — иначе они уходят во все шарды;
- группирует данные, которые читаются вместе: все заказы пользователя — в одном шарде.
Типичный удачный выбор — tenant ID (идентификатор арендатора) или идентификатор пользователя.
Что становится дорогим после шардирования.
- соединения между шардами;
- транзакции, затрагивающие несколько шардов (DIST-08);
- глобально уникальные ограничения;
- вторичные индексы: локальный требует обхода всех шардов при поиске, глобальный — отдельной структуры со своей согласованностью;
- агрегация по всем данным;
- ребалансировка при добавлении узлов.
Когда применять. Когда исчерпаны вертикальный рост, кэш, реплики и асинхронность (W5) — и объём или запись действительно не помещаются. Когда нет: до этого момента; раннее шардирование — самая частая преждевременная оптимизация в дизайне данных.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Партиционирование внутри одной БД | Проблема в размере таблицы, а не в нагрузке | Не снимает нагрузку с узла |
| Вынос части данных в отдельное хранилище | Одна сущность доминирует по объёму | Два контура вместо одного |
| Архивирование холодных данных | Активна малая часть данных | Сложнее доступ к истории |
| Шардирование | Запись и объём не помещаются | Всё перечисленное выше |
Trade-offs и режимы отказа. Горячий шард сводит выигрыш к нулю: 90 % нагрузки на 10 % ключей — обычная ситуация (популярный пользователь, «текущий день»). Ребалансировка при работающей нагрузке — отдельный сложный процесс с двойной записью и переключением. Запросы без ключа шардирования становятся scatter-gather (рассылка по всем шардам и сбор ответов): их задержка определяется самым медленным шардом.
Что мерить.
- распределение нагрузки и объёма по шардам — перекос;
- долю scatter-gather запросов;
- задержку кросс-шардовых операций;
- прогресс и влияние ребалансировки.
Связано: DATA-11 consistent hashing · DIST-09 уникальные ID · DIST-08 распределённые транзакции · DATA-18 мультитенантность
Источники: Kleppmann, DDIA, гл. 6 · Vitess — Sharding · How Discord Stores Trillions of Messages · ByteByteGo — 7 Must-know Strategies to Scale Your Database
DATA-11 · Consistent hashing#
Шаг W5
TL;DR. Consistent hashing (согласованное хэширование) распределяет ключи по узлам так, чтобы при добавлении или потере узла переезжала малая доля данных, а не все.
Проблема. Наивное распределение hash(key) mod N работает, пока N постоянно. Стоит добавить или потерять узел — и почти все ключи меняют владельца: кэш обнуляется целиком, шарды требуют полной перетасовки. Для кэша это означает мгновенный шквал промахов и падение БД под ним.
Как работает.
- Узлы и ключи отображаются на общее кольцо значений хэша. Ключ принадлежит первому узлу по часовой стрелке.
- При удалении узла его ключи переходят только к соседу; при добавлении — забирает часть у соседа. Переезжает примерно
K/Nключей вместо всех. - Виртуальные узлы (vnodes): каждый физический узел представлен десятками-сотнями точек на кольце. Это выравнивает распределение (иначе разброс велик) и позволяет учитывать разную мощность машин — сильному узлу дают больше точек.
- Репликация: копии кладутся на следующие N узлов по кольцу.
Когда применять. Распределённые кэши (клиенты memcached), Dynamo-подобные хранилища (Cassandra, Riak), распределение партиций между потребителями. Когда нет: если состав узлов стабилен и переезд данных допустим, обычный хэш проще; для L7-балансировки запросов чаще достаточно round-robin или least-connections (EDGE-01).
Альтернативы
| Вариант | Плюс | Минус |
|---|---|---|
hash mod N |
Простота | Полная перетасовка при изменении N |
| Consistent hashing + vnodes | Минимальный переезд, учёт весов | Метаданные кольца, сложнее отладка |
| Rendezvous (HRW) hashing | Проще, без кольца, естественные веса | Стоимость O(N) на ключ |
| Jump consistent hash | Быстрый, без памяти | Не поддерживает произвольное удаление узлов |
| Maglev | Равномерность + скорость, для балансировщиков | Сложнее реализация |
Trade-offs и режимы отказа. Без виртуальных узлов распределение получается неравномерным, и отдельные узлы получают кратно больше данных. Hot key (горячий ключ) consistent hashing не лечит — он распределяет ключи, а не нагрузку на один ключ (CACHE-04). При изменении состава кольца часть запросов какое-то время идёт к «неправильному» узлу — для кэша это промахи, для хранилища требует передачи данных.
Что мерить.
- перекос распределения ключей по узлам;
- долю ключей, сменивших владельца при изменении состава;
- всплеск промахов кэша после ребалансировки.
Связано: DATA-10 шардирование · CACHE-05 Redis · DIST-04 кворумы
Источники: Karger et al. — Consistent Hashing and Random Trees (1997) · Dynamo paper (SOSP 2007) · Google — Maglev (NSDI 2016)
DATA-12 · Object storage и работа с файлами#
Шаг W3
TL;DR. Файлы хранят в объектном хранилище, а в БД — только ссылку и метаданные. Загрузка и отдача идут мимо приложения.
Проблема. Картинки, видео, документы и экспорты не помещаются в модель реляционной БД: они раздувают её объём и ломают бэкапы. А отдача файла через приложение занимает поток на всё время передачи. Загрузка стомегабайтного файла через собственный сервис — это прокачка трафика дважды и таймауты на медленных сетях.
Как работает.
- Объектное хранилище (S3, GCS, MinIO) — плоское пространство ключей, HTTP-доступ, практически неограниченный объём, оплата по факту. Объект неизменяем: «изменение» — это перезапись целиком.
- Presigned URL (предподписанная ссылка) — приложение выдаёт клиенту временную подписанную ссылку, и браузер загружает файл напрямую в хранилище. Сервис не видит байтов, не тратит трафик и потоки; права и срок жизни зашиты в подпись.
- Multipart upload (загрузка частями) — большой файл режется на части, части грузятся параллельно и повторяются независимо при сбое.
- CDN перед хранилищем (EDGE-05) — отдача из ближайшей точки присутствия.
- Классы хранения и lifecycle (правила жизненного цикла) — автоматический перевод редко используемых объектов в холодные классы и удаление по возрасту (DATA-19).
- Схема данных: в БД лежат ключ объекта, размер, тип, контрольная сумма, статус (
ожидает загрузки→готов) и владелец. Загрузка подтверждается отдельным вызовом или событием хранилища.
Когда применять. Для любых бинарных данных пользователя. Когда нет: для мелких часто изменяемых значений (настройки, флаги) — там дешевле БД или кэш; для данных, по содержимому которых нужен запрос.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Object storage + presigned URL | Стандартный случай | Нужна обработка «осиротевших» объектов |
| BLOB в реляционной БД | Мелкие файлы, нужна транзакционность со строкой | Раздувание БД и бэкапов |
| Файловая система / NFS | Legacy (унаследованные системы), специфичные требования | Масштабирование и отказоустойчивость на вас |
| Проксирование через приложение | Нужен контроль каждого байта (антивирус, водяной знак) | Трафик и потоки приложения |
Trade-offs и режимы отказа. Прямая загрузка от клиента требует проверки типа и размера после факта, а также ограничения по подписи — иначе хранилище превращается в бесплатный файлообменник. Ссылка на объект и запись в БД не атомарны: нужен либо статус, либо сборщик мусора для незавершённых загрузок. Удаление объекта, на который ещё ссылается кэш или CDN, даёт битые ссылки.
Что мерить.
- долю незавершённых загрузок;
- объём «осиротевших» объектов;
- стоимость по классам хранения;
- долю отдачи через CDN против прямых обращений.
Связано: EDGE-05 CDN · DATA-19 retention · CASE-07 загрузка видео
Источники: AWS S3 — Presigned URLs · AWS S3 — Multipart upload · AWS S3 — Consistency model
DATA-13 · Полнотекстовый поиск и инвертированный индекс#
Шаг W3
TL;DR. Поиск по тексту требует отдельной структуры — инвертированного индекса — и отдельного хранилища, которое, как правило, не должно быть источником истины.
Проблема. LIKE '%слово%' не использует индекс и читает всю таблицу. Кроме того, пользователю нужен не точный подстрочный поиск, а морфология, опечатки, синонимы и ранжирование по релевантности — задача принципиально другого класса, чем выборка по ключу.
Как работает.
- Инвертированный индекс — отображение «терм → список документов, где он встречается» (posting list) с позициями. Поиск по нескольким словам — пересечение списков.
- Анализ текста — токенизация, приведение к нижнему регистру, отбрасывание стоп-слов, нормализация словоформ (стемминг/лемматизация), n-граммы для опечаток и автодополнения. Один и тот же анализатор должен применяться и при индексации, и при запросе.
- Ранжирование — BM25 как современный стандарт (развитие TF-IDF): чем реже терм в коллекции и чаще в документе, тем выше вклад, с поправкой на длину документа. Поверх накладываются бизнес-факторы: свежесть, популярность, персонализация.
- Near-real-time (почти реальное время). Документ становится видимым не мгновенно, а после обновления сегмента (в Elasticsearch — секунда по умолчанию). Это архитектурное свойство, а не настройка производительности.
- Модель данных денормализована: документ содержит всё нужное для выдачи, потому что соединений нет.
- Векторный и гибридный поиск — эмбеддинги и ANN-индексы (approximate nearest neighbor, приближённый поиск ближайших соседей) для семантического сходства, обычно в комбинации с BM25.
Когда применять. Полнотекстовый поиск, фасеты, автодополнение, поиск по логам. Для умеренных объёмов встроенных возможностей PostgreSQL (tsvector + GIN) достаточно, и это дешевле, чем отдельный кластер. Когда нет: поисковый движок — не БД: он не даёт транзакций и надёжной durability, поэтому источником истины остаётся основное хранилище.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Полнотекстовый поиск в PostgreSQL | Объёмы умеренные, не хочется второго хранилища | Меньше возможностей ранжирования и масштаба |
| Elasticsearch / OpenSearch | Большие объёмы, фасеты, аналитика по логам | Отдельный кластер и его эксплуатация |
| Внешний поисковый сервис | Не хочется эксплуатировать | Стоимость, зависимость |
| Векторный поиск | Нужна семантика, а не совпадение слов | Стоимость эмбеддингов, качество зависит от модели |
Trade-offs и режимы отказа. Индекс — производная структура: он расходится с источником истины при сбое конвейера, поэтому нужны переиндексация и сверка. Переиндексация большого корпуса — часы, её планируют заранее (обычно через индексацию в новый индекс и переключение алиаса). Изменение анализатора требует полной переиндексации.
Что мерить.
- отставание индексации;
- долю запросов без результатов;
- задержку поиска по перцентилям;
- метрики качества: CTR (click-through rate, доля кликов) по позициям, долю переформулировок запроса.
Связано: DATA-15 OLTP/OLAP · MSG-07 CDC · CASE-06 автодополнение
Источники: Elasticsearch — Inverted index · PostgreSQL — Full Text Search · Introduction to Information Retrieval (Manning et al.) · ByteByteGo — How Search Really Works
DATA-14 · Геоданные и геоиндексы#
Шаг W3
TL;DR. «Найти ближайшие объекты в радиусе» — запрос, который обычный индекс не обслуживает. Пространство сводят к одномерному ключу (геохэш, ячейки) либо используют пространственный индекс.
Проблема. Два независимых индекса по широте и долготе почти бесполезны: каждый отбирает полосу через всю карту, а пересечение полос всё равно приходится проверять построчно. Вычисление расстояния для всех строк — полный скан. При этом запрос «двадцать ближайших» — основа целого класса продуктов: доставка, такси, карты, поиск по близости.
Подходы.
- Геохэш. Биты широты и долготы перемежаются, результат кодируется строкой: общий префикс означает соседство. Ключ получается одномерным и сортируемым, поэтому работает в любой БД и в упорядоченных множествах Redis. Главная тонкость — граничный эффект: два близких объекта по разные стороны границы ячейки имеют разные префиксы, поэтому запрос должен проверять соседние ячейки, а не только свою.
- Ячеистые сетки: S2 и H3. S2 проецирует сферу на кубы и нумерует ячейки кривой Гильберта, сохраняющей локальность. H3 использует гексагональную сетку: у гексагона все шесть соседей равноудалены, что делает агрегацию и сглаживание корректнее, чем на квадратах. Обе схемы дают уровни детализации и удобны как ключ для агрегатов и для шардирования.
- Пространственный индекс (R-дерево). Хранит ограничивающие прямоугольники; в отличие от хэшей, работает не только с точками, но и с линиями и полигонами: «внутри района», «пересекает маршрут». Это путь PostGIS.
- Двухфазный запрос — применяется почти везде: сначала грубый отбор кандидатов по ячейке или прямоугольнику, затем точное вычисление расстояния и сортировка по небольшому набору.
Что важно помнить. Расстояние по прямой на сфере (гаверсинус) — приближение; для точности нужен эллипсоид, для практики обычно достаточно первого. Расстояние по прямой и по дорогам различаются в разы — «ближайший» по воздуху может быть на другом берегу реки; за маршруты отвечает отдельный сервис. Координаты имеют систему отсчёта, и смешение систем даёт ошибки в сотни метров.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Геохэш в обычной БД или Redis | Точки, простой поиск по близости | Граничные ячейки, только точки |
| H3 / S2 как ключ ячейки | Агрегация по территории, шардирование | Своя библиотека, дискретность уровней |
| PostGIS (R-дерево) | Полигоны, пересечения, сложная геометрия | Расширение и его эксплуатация |
| Специализированный геосервис | Маршруты, пробки, изохроны | Внешняя зависимость |
Trade-offs и режимы отказа. Размер ячейки — компромисс: мелкая даёт точность и много ячеек для проверки, крупная — мало кандидатов, но грубый отбор. Плотные районы создают горячие ячейки: в центре города в одну ячейку попадает столько же объектов, сколько в целую область на периферии. При шардировании по ячейке это перекос (DATA-10). Движущиеся объекты меняют позицию чаще, чем их читают. Для них уместнее хранилище в памяти с коротким TTL (временем жизни записи), а не индекс в основной БД.
Что мерить.
- задержку поиска ближайших по перцентилям;
- число проверенных кандидатов на один результат — эффективность грубого отбора;
- распределение объектов по ячейкам;
- частоту обновления позиций.
Связано: DATA-10 шардирование · CACHE-05 Redis · CASE-10 геопоиск ближайших объектов
Источники: PostGIS — Spatial indexing · Uber H3 — Hexagonal hierarchical spatial index · Google S2 Geometry · Redis — Geospatial indexes
DATA-15 · OLTP vs OLAP: warehouse, lake, lakehouse#
Шаг W3
TL;DR. Транзакционная и аналитическая нагрузки требуют разного физического устройства данных. Их разделение — не роскошь, а условие стабильности боевой системы.
Проблема. Аналитический запрос читает миллионы строк и держит ресурсы минутами. Выполненный на боевой БД, он вытесняет полезные данные из буферного пула, удерживает старые версии строк и деградирует пользовательские запросы. Отдельная реплика «для аналитиков» помогает лишь частично: строковое хранение всё равно неэффективно для агрегаций по нескольким колонкам из сотни.
Как работает.
- OLTP (online transaction processing, транзакционная нагрузка) — много коротких операций, точечные чтения и записи по ключу, строковое хранение, индексы под селективные запросы.
- OLAP (online analytical processing, аналитическая нагрузка) — мало запросов, каждый сканирует и агрегирует огромные объёмы. Колоночное хранение: значения одной колонки лежат подряд, поэтому читается только нужное, а однородные данные сжимаются в разы. Отсюда порядковая разница в скорости агрегаций.
- Data warehouse (склад данных) — структурированное аналитическое хранилище со схемой (Snowflake, BigQuery, Redshift, ClickHouse). Схема при записи, высокое качество данных.
- Data lake (озеро данных) — сырые файлы в объектном хранилище (обычно Parquet). Схема при чтении, дёшево, но легко превращается в «болото» без каталога и контроля качества.
- Lakehouse — табличный формат поверх файлов озера (Iceberg, Delta Lake, Hudi), дающий транзакции, эволюцию схемы и time travel — запросы к прошлым версиям. Совмещает дешёвое хранение с гарантиями склада.
- Доставка данных: batch (пакетный) ETL/ELT (extract, transform, load — выгрузка, преобразование, загрузка) по расписанию или потоковый CDC (MSG-07) для near-real-time.
Когда применять. Разделять контуры, как только аналитика начинает влиять на боевую нагрузку или требовать данных из нескольких систем. Когда нет: пока отчётов мало и они лёгкие, отдельная реплика проще и дешевле полноценного склада.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Отчёты на реплике OLTP | Малые объёмы, простые отчёты | Не масштабируется, строковое хранение |
| Колоночная БД (ClickHouse) | Быстрая аналитика на своих данных | Отдельная система, ограничения обновлений |
| Облачный warehouse | Не хочется эксплуатировать | Стоимость запросов растёт с аппетитом аналитиков |
| Lakehouse | Много сырых данных, нужны и SQL, и ML | Сложность стека и каталога |
Trade-offs и режимы отказа. Дублирование данных требует контроля соответствия: расхождение витрины и источника подрывает доверие ко всей аналитике. Пайплайн — такая же боевая система: его отказ обнаруживается по пустому дашборду через сутки, если нет мониторинга свежести. Данные в озере без каталога и владельца быстро перестают быть используемыми.
Что мерить.
- свежесть витрин — лаг от источника;
- успешность и длительность пайплайнов;
- сверку контрольных сумм и количеств строк;
- стоимость запросов.
Связано: MSG-11 data pipeline · MSG-07 CDC · DATA-01 выбор хранилища
Источники: Kleppmann, DDIA, гл. 3, раздел про аналитику · Apache Iceberg — Table format spec · Databricks — Lakehouse (CIDR 2021) · ByteByteGo — What is a Data Lakehouse? · ByteByteGo — What is Data Pipeline? | Why Is It So Popular?
DATA-16 · Целостность и сверка данных#
Шаг W9
TL;DR. Отказ виден сразу, порча данных — нет. Нужен отдельный механизм, который регулярно проверяет инварианты и сравнивает копии, иначе расхождение обнаружит клиент.
Проблема. Система работает, метрики зелёные, ошибок нет — а цифры неверны. Источники расхождений:
- производная копия — кэш, поисковый индекс, витрина — отстала или потеряла событие;
- распределённая операция оборвалась на середине, и компенсация не выполнилась (DIST-08);
- гонка или баг записали несогласованное состояние;
- миграция обработала часть строк (DATA-17).
Общая черта — тишина: без специальной проверки такое не обнаруживается неделями, а к моменту обнаружения историю уже нельзя пересчитать.
Эшелоны защиты.
- Ограничения в самой БД — самый дешёвый и надёжный уровень:
NOT NULL, внешние ключи,CHECK, уникальные индексы. Их регулярно отключают «ради производительности» и, как правило, об этом жалеют: приложение гарантирует инвариант, только пока в нём нет багов и пока оно одно. - Инварианты как проверяемые утверждения. «Сумма проводок по счёту равна балансу», «у каждого оплаченного заказа есть платёж», «у каждой позиции есть склад». Формулируются как запросы и выполняются по расписанию отдельным процессом. Ненулевой результат — это находка, на которую заведён алерт.
- Сверка копий (reconciliation). Периодическое сравнение источника и производной: по количеству записей, по контрольным суммам блоков, по агрегатам за период. Сравнение делается на срезе с учётом лага репликации, иначе ложные расхождения забьют сигнал.
- Проверки на уровне хранения. Контрольные суммы страниц ловят порчу на диске; восстановление бэкапа с прогоном проверок — единственное доказательство, что бэкап рабочий (REL-09).
- Устранение классов расхождений на входе: идемпотентность (NET-16) убирает дубликаты, outbox (таблица исходящих событий, MSG-07) — потерянные события. Сверка ловит остаток.
- Журнал изменений для расследования: без истории «кто и когда поменял» причину расхождения установить нельзя (SEC-12).
Седьмой случай — арбитр вне системы. Все шесть эшелонов выше сверяют систему с самой собой: копию с источником, агрегат с проводками, страницу с контрольной суммой. Но источником истины бывает физический мир: товар на полке, остаток на складе, документ у контрагента. Тогда ни один эшелон не применим: спросить арбитра стоит человеко-часов, а не запроса.
Меняется сам вопрос: не «как сверять», а «как часто мы имеем право спрашивать». Частота сверки становится параметром решения, а не свойством механизма: во что обходится нужная точность и кто за это платит, решается за пределами карточки. Отсюда три следствия.
- Сплошная сверка — не измеритель, а калибровка. Она редка и точна; выборка часта и смещена. Без сплошной смещение выборки неизвестно, без выборки между сплошными не видно ничего.
- Расхождение, не оставляющее следа в данных, выборкой ловится, потоком — нет. Журнал операций фиксирует отсканированное; переставленная молча коробка следа не оставляет по построению. Поток ловит противоречия (выдача без приёмки, две выдачи одной единицы) и объясняет расхождение, когда оно найдено, — но не находит его.
- Цена измерения входит в цену обещания, а не в накладные расходы: см. форму «корректность» в D-10.
Что делать с найденным. Автоматически исправлять можно только там, где источник истины однозначен — например, перестроить производную из источника. Расхождение между двумя равноправными записями исправляется человеком, а автоисправление без разбора причины маскирует баг: цифры сходятся, ошибка продолжает работать.
Когда применять. Обязательно — деньги, остатки, права доступа, счётчики, любая производная копия. Когда нет: для данных, которые дешевле перестроить целиком, чем сверять, — например, кэш с коротким TTL (временем жизни записи).
Trade-offs и режимы отказа. Сверка — это дополнительная нагрузка, часто тяжёлая: полное сравнение больших таблиц выполняют по частям и в непиковое время. Слишком чувствительные пороги дают ложные срабатывания из-за лага, и на них перестают реагировать. Сверка сама может быть неверной — тогда доверие подрывается сильнее, чем при её отсутствии, поэтому её логику проверяют так же, как боевой код.
Что мерить.
- долю расхождений на прогон;
- время от возникновения расхождения до обнаружения;
- число нарушенных инвариантов по типам;
- возраст самой старой незавершённой распределённой операции;
- долю автоматически исправленных расхождений против ручных.
Связано: D-10 цели уровня обслуживания · DATA-17 миграции · MSG-07 outbox и CDC · DIST-08 распределённые транзакции · REL-09 бэкапы и DR
Источники: седьмой случай и его следствия — разбор прогона E-03 · ADR-0005, где внешним арбитром была ручная инвентаризация · Google SRE Book, гл. 26 «Data Integrity» · Kleppmann, DDIA, гл. 12 — аудит и сквозная проверка · PostgreSQL — Data checksums
DATA-17 · Миграции схемы без простоя#
Шаг W9
TL;DR. Схема меняется чаще архитектуры. Безопасная миграция — это последовательность обратно совместимых шагов, а не одно изменение.
Проблема. Во время выкатки одновременно работают старая и новая версии кода. Изменение, ломающее старую версию (удаление колонки, переименование, ужесточение ограничения), гарантированно вызывает ошибки — либо во время выкатки, либо при откате. Кроме того, сама операция может заблокировать таблицу: ALTER TABLE с перезаписью на большой таблице — это минуты недоступности.
Как работает — паттерн expand → migrate → contract (расширить → перенести → сузить).
- Expand. Добавить новое, ничего не ломая: новая колонка (nullable, без обязательного значения), новая таблица, новый индекс — конкурентно, без долгой блокировки.
- Двойная запись. Новая версия кода пишет и в старое, и в новое место; читает пока из старого.
- Backfill (перенос исторических данных). Перенести исторические данные партиями с паузами, отслеживая нагрузку и лаг репликации. Backfill должен быть возобновляемым и идемпотентным.
- Переключение чтения на новое место — желательно под фича-флагом, чтобы откат был мгновенным (REL-04).
- Contract. Убрать двойную запись и удалить старое — отдельным релизом, через недели, когда откат уже точно не понадобится.
Переименование колонки выполняется как «добавить новую → скопировать → переключить → удалить старую», а не одной командой.
Опасные операции. Добавление NOT NULL со значением по умолчанию, изменение типа, добавление внешнего ключа и уникального индекса — в разных СУБД и версиях они по-разному блокируют таблицу. Проверять поведение нужно для своей версии; для MySQL часто применяют онлайн-инструменты (gh-ost, pt-online-schema-change), в PostgreSQL — CREATE INDEX CONCURRENTLY и добавление ограничений в режиме NOT VALID с последующей валидацией.
Когда применять. Для любой схемы в системе, которая не может позволить себе окно простоя. Когда нет: если окно допустимо и данных мало, простая миграция дешевле по трудозатратам.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Expand/contract | Стандарт для боевых систем | Несколько релизов, дисциплина |
| Окно простоя | Малая система, ночь выходного | Недоступность |
| Теневая таблица + переключение | Радикальная перестройка | Двойной объём, сложное переключение |
| Версионирование в самих данных (schema-on-read, схема при чтении) | Документные хранилища | Логика совместимости в коде навсегда |
Trade-offs и режимы отказа. Backfill без throttling (ограничения скорости) кладёт БД или разгоняет лаг репликации до минут. Двойная запись без транзакционной гарантии расходится — нужна сверка. Забытый шаг contract оставляет систему в переходном состоянии на годы: это самый частый исход, поэтому его планируют сразу с датой.
Что мерить.
- длительность блокировок при DDL;
- прогресс и скорость backfill;
- лаг репликации во время миграции;
- расхождение между старым и новым представлением данных.
Связано: NET-17 версионирование API · OPS-02 стратегии выкатки · REL-04 фича-флаги
Источники: Martin Fowler — Evolutionary Database Design · PostgreSQL — ALTER TABLE · gh-ost — online schema migration for MySQL
DATA-18 · Мультитенантность#
Шаг W3
TL;DR. Три модели изоляции данных tenants (арендаторов): общая таблица, отдельная схема, отдельная БД. Выбор определяется требованиями к изоляции и числом tenants.
Проблема. В B2B-системе данные разных организаций не должны пересекаться — ни по ошибке в запросе, ни по нагрузке. Одновременно нужно обслуживать и тысячи мелких tenants, и нескольких крупных, которым обещаны изоляция производительности и, возможно, хранение в своей юрисдикции.
Как работает.
| Модель | Изоляция | Масштаб по числу tenants | Сложность эксплуатации |
|---|---|---|---|
Общая таблица с tenant_id |
Слабая (только код и политики) | Десятки тысяч | Низкая: одна миграция на всех |
| Схема на tenant | Средняя | Сотни–тысячи | Средняя: миграция на каждую схему |
| БД на tenant | Сильная | Десятки–сотни | Высокая: N бэкапов, N обновлений |
| Гибрид: пул для мелких, выделенное для крупных | Настраиваемая | Практичный компромисс | Две ветки логики |
Обязательные механики для общей таблицы.
tenant_idв каждой таблице и в каждом индексе первым полем — это одновременно и изоляция, и естественный ключ шардирования (DATA-10).- Принудительная фильтрация на уровне БД — row-level security (защита на уровне строк) — или единой прослойки доступа. Полагаться на дисциплину разработчиков нельзя: один забытый
WHERE= утечка между tenants. - Квоты и лимиты на tenant (EDGE-06) — защита от noisy neighbor (шумного соседа).
tenant_idв логах, метриках и трассировках, иначе диагностика инцидента у одного клиента невозможна.
Когда применять. Общая таблица — по умолчанию для SaaS. Выделенная БД — под требования регуляторов, крупных клиентов и для изоляции нагрузки. Когда нет: для одного крупного внутреннего заказчика мультитенантность не нужна.
Trade-offs и режимы отказа. Общая модель делает любую ошибку фильтрации инцидентом безопасности. Выделенные БД превращают миграцию в распределённую операцию над сотнями экземпляров с частичными отказами. Крупный tenant в общем пуле создаёт перекос (DATA-10) и становится шумным соседом. Экспорт и удаление данных одного tenant в общей модели — отдельная работа, которую закладывают заранее (DATA-19).
Что мерить.
- распределение объёма и нагрузки по tenants;
- число запросов без фильтра по tenant — оно должно быть нулевым;
- задержку для крупнейших tenants отдельно от медианы;
- успешность миграций по всем экземплярам.
Связано: DATA-10 шардирование · SEC-04 авторизация · EDGE-06 rate limiting · DATA-19 retention · CASE-12 кейс: мультитенантный SaaS
Источники: AWS SaaS Lens — Well-Architected · PostgreSQL — Row Security Policies · Microsoft — Multi-tenant SaaS database tenancy patterns
DATA-19 · Retention, удаление данных, приватность#
Шаг W0
TL;DR. Сколько хранить и как удалять — это требование, влияющее на схему, бэкапы и стоимость. Его формулируют на входе, а не когда придёт запрос на удаление.
Проблема. Данные копятся бесконечно: они дёшевы поштучно и дороги в сумме — растёт стоимость, замедляются запросы, раздуваются бэкапы. Одновременно закон и договор требуют уметь удалить данные человека, а копии к тому моменту разъехались по репликам, кэшам, поисковому индексу, аналитическому складу, логам и бэкапам.
Как работает.
- Политика хранения по классам данных: операционные (месяцы), финансовые (годы по закону), логи (недели), аналитические агрегаты (бессрочно, но обезличенно).
- Механика удаления. Партиционирование по времени и удаление партиции целиком вместо построчного
DELETE— на порядки дешевле. Там, где хранилище поддерживает TTL (срок жизни записи), — TTL; в объектном хранилище — lifecycle-правила. - Мягкое против жёсткого удаления (soft delete и hard delete). Мягкое (
deleted_at) удобно для отмены и аудита, но для приватности это не удаление, и места оно не освобождает. Обычно нужны оба: мягкое сразу, жёсткое по расписанию. - Карта распространения данных. Для каждого класса персональных данных — список всех мест, где он оказывается. Без неё удаление остаётся неполным.
- Крипто-шреддинг (crypto-shredding) для бэкапов: данные шифруются ключом, привязанным к субъекту; удаление ключа делает данные невосстановимыми, не требуя переписывать архивы.
- Обезличивание вместо удаления там, где нужна историческая аналитика: агрегаты сохраняются, идентификаторы отсекаются.
Когда применять. Когда система хранит данные людей. Формулировать на шаге W0 вместе с остальными требованиями.
Альтернативы
| Вариант | Когда лучше | Цена |
|---|---|---|
| Партиции по времени + drop | Данные естественно стареют | Схема должна это предусматривать |
| TTL в хранилище | Поддерживается движком | Нет контроля момента удаления |
| Мягкое удаление | Нужны отмена и аудит | Не освобождает место, не закрывает приватность |
| Крипто-шреддинг | Нужно «удаление» в бэкапах | Управление ключами на субъект |
| Архивирование в холодное хранилище | Данные нужны редко, но нужны | Медленный доступ |
Trade-offs и режимы отказа. Требование удалить конфликтует с требованием хранить (финансовые записи) — разрешается разделением данных на классы, а не одним решением. Бэкапы — самое частое место, где «удалённые» данные продолжают жить. Массовое DELETE без партиционирования создаёт долгую транзакцию, раздувает версии и роняет производительность.
Что мерить.
- объём по классам данных и его рост;
- возраст самых старых записей относительно политики;
- время выполнения запроса на удаление;
- долю мест из карты распространения, где удаление подтверждено.
Связано: DATA-12 object storage · REL-09 бэкапы · SEC-12 аудит · DATA-18 мультитенантность
Источники: GDPR, ст. 17 — право на удаление · PostgreSQL — Table Partitioning · AWS S3 — Lifecycle configuration