Слой данных: хранилища глазами прикладного разработчика
Эта глава — не про устройство баз данных. Про устройство есть отдельный трек из двадцати глав: там разбирают B-деревья и планы запросов («Индексы и планы запросов»), уровни изоляции («Транзакции и изоляция»), репликацию и шардирование («Репликация и шардирование»), десяток конкретных движков. Начать погружение стоит с обзора трека «Базы данных».
Здесь другая задача. Прикладной разработчик редко пишет СУБД — он выбирает её, подключается к ней, описывает схему, держит границу транзакции, меняет схему на живой системе и отвечает за данные, когда что-то пошло не так. Это отдельный набор навыков, и его почти никогда не преподают: в вузе учат нормальным формам, на работе сразу дают ORM и говорят «пиши фичи». Между этими двумя точками — пропасть, в которую проваливаются N+1-запросы, пулы соединений на 200 коннектов, миграции без плана отката и бэкапы, которые никто ни разу не восстанавливал.
Предыдущая глава — «Серверная сторона» — закончилась на том, что сервер держит состояние. Разберёмся, где именно он его держит.
Зачем СУБД, если есть файлы
Представим, что мы отказались от базы. Заказы храним в orders.json, читаем при старте, пишем при каждом изменении. Работает — ровно до первого столкновения с реальностью. Разберём, что именно ломается: каждый пункт этого списка — многолетняя работа, которую СУБД уже сделала за нас.
Конкурентный доступ. Два HTTP-запроса одновременно прочитали файл, каждый добавил свой заказ, каждый записал файл целиком. Один заказ исчез. Это классическая потеря обновления (lost update), и лечится она блокировками или версионированием — то есть тем, что внутри СУБД называется механизмом конкурентного доступа. Написать его самому корректно — тема диссертации, а не спринта.
Атомарность и восстановление после сбоя. Процесс упал на середине записи файла. Теперь на диске половина JSON — не читается вообще ничего. СУБД пишет журнал (WAL) до того, как трогает данные, и после аварийного рестарта откатывает незавершённое и накатывает подтверждённое. Транзакция либо была целиком, либо её не было.
Декларативные запросы. «Дай десять последних заказов клиента X со статусом paid» — на файлах это полный перебор, который вы напишете руками, а потом ещё раз перепишете, когда заказов станет миллион. В SQL это одна строка, и оптимизатор сам решит, как её выполнить: сканом, по индексу, каким соединением. Вы описываете «что», а не «как» — и когда данных станет в тысячу раз больше, «как» изменится без правки кода.
Контроль целостности. Заказ без существующего клиента, отрицательное количество товара, два пользователя с одним email — всё это база умеет запрещать декларативно: FOREIGN KEY, CHECK, UNIQUE, NOT NULL. Проверка в приложении спасает только пока приложение одно и в нём нет багов. Ограничение в схеме работает всегда: и для скрипта миграции, и для аналитика, зашедшего в консоль, и для соседнего сервиса.
Точка резервного копирования и восстановления во времени. Консистентный снимок «на 14:32 вчера», из которого можно поднять систему. На россыпи файлов это не выражается вообще.
Отсюда полезный тест: если данные читает больше одного процесса, если их потеря болезненна и если по ним задают заранее неизвестные вопросы — нужна СУБД. Если это кэш, который не жалко, или локальный конфиг — файла достаточно. Промежуточный вариант — встраиваемая база: SQLite даёт транзакции и SQL в одном файле, без сервера («SQLite и встраиваемые»).
База данных против электронной таблицы
Самый понятный вход в тему — сравнение с Excel, потому что таблица тоже хранит строки и столбцы.
| Свойство | Электронная таблица | СУБД |
|---|---|---|
| Модель пользователя | один автор, изредка совместное редактирование | десятки и тысячи параллельных клиентов |
| Согласованность при параллельной правке | последний сохранивший победил | транзакции, изоляция, разрешение конфликтов |
| Целостность | формула и «глазами» | ограничения в схеме, которые нельзя обойти |
| Объём | тысячи–сотни тысяч строк | от гигабайт до петабайт |
| Язык доступа | формулы и фильтры на видимом листе | декларативный язык запросов и оптимизатор |
| Разграничение доступа | доступ к файлу целиком | права на таблицу, колонку, строку |
| Аудит и восстановление | «версия из корзины» | журнал, PITR, реплики |
Отсюда и типичная траектория продукта: начинается с гугл-таблицы, в которую отдел заносит заявки, а заканчивается базой — ровно в тот момент, когда людей стало больше одного, а цена ошибки перестала измеряться «переспрошу у Маши».
Три термина, которые постоянно путают
Разговор становится точнее, когда разведены три вещи.
База данных — сам упорядоченный набор данных: таблицы, индексы, файлы на диске. Это что хранится.
СУБД (DBMS) — программа, которая этими данными управляет: разбирает запросы, планирует выполнение, пишет журнал, следит за блокировками, выдаёт права. PostgreSQL, MySQL, Oracle, MongoDB, ClickHouse — это СУБД. Один сервер СУБД обычно держит много баз.
Система баз данных — всё вместе: данные, СУБД, приложения, схемы, скрипты миграции, бэкапы, дежурные инженеры и регламенты. Именно она «падает», «мигрирует» и «стоит денег».
Различие не педантичное. Фраза «база упала» почти всегда означает одно из трёх: закончились соединения к СУБД, кончилось место на диске под журналом или запрос без индекса съел все ядра. Три разных диагноза, три разных лечения — и ни один из них не про «данные». Умение сказать, какой из слоёв сломался, экономит половину времени инцидента («Эксплуатация и эволюция системы»).
Схема как контракт
Схема — самый долгоживущий артефакт системы. Код перепишут трижды, фреймворк сменят дважды, а таблица orders переживёт всех. Поэтому её проектируют не «под текущий экран», а под инварианты предметной области. Возьмём маленький интернет-магазин.
В этой крошечной модели спрятаны три решения, за которые платят годами.
Уникальность email — в базе, а не в коде. Проверка «сначала SELECT, потом INSERT» в приложении гарантированно даст дубли под нагрузкой: между двумя запросами успеет вклиниться другой процесс. Уникальный индекс — единственная надёжная защита; приложение лишь красиво обрабатывает ошибку нарушения уникальности.
Цена товара продублирована в позиции заказа. Формально это нарушает нормализацию: unit_price можно «вычислить» из product. Практически — нельзя: цена товара меняется, а чек, выданный год назад, меняться не должен. Это правило шире, чем БД: факты о прошлом копируют, ссылки на настоящее хранят ссылкой.
Статус — не свободный текст. CHECK (status IN (...)) или отдельный тип. Иначе через полгода в проде появятся paid, PAID и payed, и любой отчёт станет ложью.
Нормализация — про то, чтобы один факт хранился в одном месте; денормализация — осознанная плата производительностью чтения за дублирование. Правило простое: сначала нормализуйте, потом денормализуйте по измеренной необходимости, и всегда записывайте почему. Глубже — в «Реляционная модель» и «Моделирование данных»; про то, как схема соотносится с доменной моделью — «Репозитории и персистентность».
Как мы сюда пришли: эволюция моделей данных
Каждая волна в истории хранилищ решала конкретную боль предыдущей и платила своей ценой. Знать это полезно не из уважения к истории, а чтобы узнавать паттерн: когда очередная технология обещает «всё то же самое, только без недостатков», обычно она просто переносит недостатки в другое место.
Ключевая точка — 1970 год. Статья Кодда «A Relational Model of Data for Large Shared Data Banks» сделала одну радикальную вещь: отделила логическое описание данных от физического способа их достать. До неё программа явно шла по указателям от записи к записи, и любое изменение размещения данных ломало код. После — программа описывает желаемый результат, а СУБД сама выбирает путь. Всё, чем мы сегодня пользуемся бесплатно — индексы, которые можно добавить без правки приложения; оптимизатор, который перестроил план на других объёмах, — следствие этого разделения.
Дальше история почти циклична. Объектные СУБД 1990-х пытались снять «impedance mismatch» между объектами в памяти и строками в таблицах — не взлетели, но оставили в наследство ORM. NoSQL 2000-х отказался от схемы, джойнов и транзакций ради горизонтального масштаба — и обнаружил, что схема никуда не исчезла, она просто переехала в код приложения, где её никто не проверяет. NewSQL 2010-х вернул SQL и ACID в распределённую среду ценой задержки на согласование между узлами («NewSQL и распределённые СУБД»).
Сегодняшняя картина — полиглотное хранение: у продукта одновременно живут PostgreSQL для транзакций, Redis для кэша и сессий, ClickHouse для аналитики, S3 для файлов, Elasticsearch для поиска. Это не мода, а признание факта: у разных вопросов к данным разная физика ответа. Плата — каждая новая система в стеке требует своей эксплуатации, мониторинга и людей.
Пара терминов из старых текстов, которые стоит перевести на современный: «автономные» (self-driving) базы — это управляемые облачные сервисы с автотюнингом, автобэкапом и автопатчингом; они снимают рутину DBA, но не отменяют знания схемы и планов запросов. «Многомодельные» СУБД — движки, умеющие несколько моделей сразу; частный и самый практичный случай — PostgreSQL с JSONB, полнотекстовым поиском, pgvector и PostGIS, что часто позволяет не заводить пять систем («PostgreSQL»).
Карта семейств хранилищ
| Семейство | Для чего создано | Типичные представители | Глубже |
|---|---|---|---|
| Реляционные | произвольные вопросы к связанным данным + инварианты | PostgreSQL, MySQL, MS SQL, Oracle | «Реляционная модель» |
| Документные | читать и писать агрегат целиком, гибкая форма записи | MongoDB, Couchbase | «MongoDB» |
| Key-value | доступ по ключу за микросекунды, кэш и сессии | Redis, Memcached | «Redis» |
| Wide-column | огромный поток записи и скан внутри партиции | Cassandra, ScyllaDB, HBase | «Cassandra и wide-column» |
| Колоночные OLAP | агрегация по нескольким колонкам поперёк миллиардов строк | ClickHouse, DuckDB, BigQuery | «ClickHouse и OLAP» |
| Поисковые | релевантный полнотекстовый поиск, фасеты | Elasticsearch, OpenSearch | «Временные ряды и поиск» |
| Временных рядов | метрики: запись потоком, запрос по окну времени | TimescaleDB, InfluxDB, Prometheus | «Временные ряды и поиск» |
| Графовые | обход связей на много шагов, «друзья друзей» | Neo4j, JanusGraph | «Графовые и key-value» |
| Векторные | поиск похожего по смыслу, ANN | pgvector, Qdrant, Milvus | «Векторные базы данных» |
| Объектные | большие неизменяемые файлы, дёшево и надолго | S3, MinIO, GCS | «Объектное хранилище» |
| Распределённые SQL | SQL и транзакции при горизонтальном росте | CockroachDB, YugabyteDB, Spanner | «NewSQL и распределённые СУБД» |
Отдельно про лицензии: «open-source» перестало быть однозначным. Часть популярных движков переехала на BSL/SSPL, что для коммерческого продукта может оказаться юридическим риском, а не деталью. Проверять лицензию нужно до того, как система попала в архитектуру, а не после — как и любую другую зависимость («Сборка и зависимости»).
OLTP и OLAP — два разных мира
OLTP (online transaction processing) — тысячи коротких операций в секунду, каждая трогает единицы строк: создать заказ, списать деньги, обновить статус. Важны задержка на 99-м перцентиле, корректность при конкуренции и мгновенная видимость записанного.
OLAP (online analytical processing) — единицы тяжёлых запросов, каждый читает миллионы строк, но лишь несколько колонок: «выручка по регионам за квартал». Важна пропускная способность; данные могут отставать на минуты и это нормально.
Разница не в объёме, а в физике. OLTP-движок хранит строку целиком рядом, чтобы достать её одним чтением. OLAP-движок хранит колонку подряд, чтобы сжать её в разы и просканировать не трогая остальные. Одно и то же хранение не может быть оптимальным для обоих профилей.
Отсюда правило, которое стоит выучить до первого инцидента: не гоняйте аналитику по продовой OLTP-базе. Не из вредности, а по трём конкретным причинам. Первая: тяжёлый скан вымывает буферный кэш, и после отчёта обычные запросы какое-то время идут с диска — деградирует всё. Вторая: долгая транзакция мешает очистке старых версий строк (в PostgreSQL это VACUUM), таблица распухает, планы портятся. Третья: аналитик неизбежно однажды запустит запрос без LIMIT в час пик.
Правильные варианты по возрастанию цены: реплика только для чтения; регулярная выгрузка в колоночное хранилище; поток изменений через CDC в аналитический контур («ETL против ELT»). Архитектурное обобщение этого разделения — CQRS («CQRS и event sourcing»).
Как выбрать хранилище
Ядро главы. Плохой разговор о выборе звучит как «Postgres или Mongo?». Хороший идёт по порядку, в котором каждый следующий вопрос имеет смысл только после ответа на предыдущий.
Выпишите 5-7 реальных запросов"] --> B{"Запросы заранее
известны и всегда
по одному ключу?"} B -- "нет, вопросы меняются" --> C["2. Есть ли инварианты
между сущностями?"] B -- "да, всегда по ключу" --> D["Key-value или документное
хранилище"] C -- "да: деньги, остатки,
уникальность" --> E["Реляционная СУБД.
Транзакции обязательны"] C -- "нет: события, логи,
метрики" --> F["3. Что делаем с потоком?"] F -- "агрегируем" --> G["Колоночная OLAP"] F -- "ищем по тексту" --> H["Поисковый движок"] F -- "смотрим окно времени" --> I["Time-series"] E --> J{"4. Объём и нагрузка
через 2 года
влезают в один узел?"} J -- "да" --> K["Один PostgreSQL
плюс реплики чтения"] J -- "нет" --> L["Шардирование или
распределённый SQL"] K --> M{"5. Кто это эксплуатирует?"} L --> M M -- "команда без DBA" --> N["Управляемый сервис облака"] M -- "есть экспертиза" --> O["Своя инсталляция"]
Шаг 1. Форма запросов. Выпишите пять-семь конкретных запросов, которые система будет делать чаще всего, с указанием частоты. Не «нам нужна гибкость» — а «получить заказ по id, 3000 rps» и «показать ленту пользователя, 500 rps». Модель данных выбирается под доминирующий паттерн доступа, всё остальное вторично.
Шаг 2. Инварианты и согласованность. Есть ли правила, нарушение которых недопустимо даже на секунду: баланс не уходит в минус, товар не продаётся дважды, счёт не выставляется дважды? Если да — нужны транзакции и, скорее всего, реляционная СУБД. Если данные — это независимые факты, требования мягче («Модели согласованности»).
Шаг 3. Объём и профиль нагрузки. Сколько данных через два года, каково соотношение чтений и записей, влезает ли «горячее» множество в память. Порядок величин важнее точности: 50 ГБ, 5 ТБ и 5 ПБ — это три разных мира. Большинство продуктов навсегда остаются в первом.
Шаг 4. Эксплуатация. Кто будет чинить это в три часа ночи, есть ли управляемая версия у вашего облака, сколько людей на рынке умеет с этим работать. Технология, которую в команде понимает один человек, — это риск, а не преимущество.
Шаг 5. И только теперь — мода. Конференционный доклад про то, как компания в сто раз больше вашей решила проблему, которой у вас нет, — плохое основание для выбора.
Ориентир по умолчанию: начинайте с PostgreSQL и уходите от него по измеренной причине. Он покрывает реляционные данные, документы (JSONB), полнотекстовый поиск, гео, очереди (SKIP LOCKED), векторы (pgvector) и временные ряды (TimescaleDB) — на масштабах, где живёт подавляющее большинство продуктов. Одна система в эксплуатации вместо пяти — это огромная экономия, которую редко считают.
Четыре честных сценария
Лента социальной сети. Доминирующий запрос — «последние N постов от тех, на кого я подписан», 90% чтений. Инвариантов почти нет: если пост появится в ленте на секунду позже, никто не пострадает. Наивный JOIN подписок с постами не масштабируется. Решение — материализация ленты при публикации (fan-out on write) в key-value или wide-column хранилище, где чтение ленты — это один скан по ключу пользователя. Для пользователей с миллионами подписчиков делают исключение и досчитывают их посты на чтении. Источником истины при этом всё равно остаётся реляционная база с постами и подписками.
Биллинг. Доминируют инварианты: деньги не создаются и не исчезают, счёт не выставляется дважды, всякая операция должна быть объяснима через год. Нагрузка на фоне ленты смешная. Выбор очевиден и не обсуждается: реляционная СУБД с полноценными транзакциями, ограничениями и, как правило, журналом операций в стиле двойной записи. Здесь же — идемпотентные ключи на входящих запросах, чтобы повторная отправка не списала деньги дважды (см. «Интеграция»). Хранить деньги в float нельзя никогда: только numeric/decimal или целые копейки.
Логи и метрики. Запись потоком, огромный объём, читают редко и всегда по окну времени с фильтром. Ценность падает с возрастом: вчерашние логи нужны, прошлогодние — почти никогда. Реляционная база здесь худший выбор: индексы будут больше данных. Нужны колоночное хранилище или движок временных рядов с автоматическим устареванием (TTL) и партиционированием по времени, где «удалить старое» — это отсоединить партицию, а не DELETE на миллиард строк.
Каталог товаров с поиском. Два разных запроса в одном продукте. «Карточка товара по id» и «оформление заказа» — транзакционные, живут в реляционной базе. «Найти беспроводные наушники до 5000 рублей с сортировкой по релевантности и фасетами» — поисковый запрос, и реляционный LIKE '%...%' его не потянет. Правильная конструкция: источник истины в SQL, поисковый индекс — производная, обновляемая асинхронно. Ключевой вопрос такой архитектуры — не «как синхронизировать», а «на сколько секунд индекс имеет право отставать»: ответ определяет всю сложность решения.
Развёрнутое сравнение всех классов по одним осям и плейбук миграции — «Как выбрать БД и как мигрировать».
Практика подключения
Выбрали. Теперь надо подключиться — и здесь начинается территория, где ошибаются практически все.
Драйвер и пул соединений
Соединение с СУБД — дорогой ресурс: TCP-хендшейк, TLS, аутентификация, а в PostgreSQL ещё и отдельный процесс на сервере с собственной памятью. Открывать его на каждый запрос — расточительство, поэтому приложения держат пул.
Главное заблуждение: «пул побольше — производительность повыше». Наоборот. База обрабатывает запросы параллельно ровно настолько, насколько хватает ядер и дисков; всё сверх этого встаёт в очередь внутри СУБД, где очередь стоит дороже, чем снаружи. Переразмеренный пул превращает деградацию в обвал: вместо «часть запросов ждёт» получаем «все запросы медленные, все таймаутят, все ретраятся».
Ориентир из руководства по PostgreSQL: размер пула порядка число_ядер * 2 + число_дисков. Для типичной машины это 10–20, а не 200. И считать надо суммарно: пул 20 на инстанс приложения при 10 инстансах — это 200 соединений к базе, а max_connections у вас, скорее всего, 100. Когда инстансов много, между приложением и базой ставят внешний пулер (PgBouncer) в режиме транзакционного пулинга.
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg://app@db.internal:5432/shop",
pool_size=10, # постоянных соединений на ОДИН процесс приложения
max_overflow=5, # сколько временных допускаем на всплеске
pool_timeout=2, # ждать свободное соединение не больше 2 с, потом ошибка
pool_recycle=1800, # пересоздавать раз в 30 мин: файрволы рвут долгие TCP молча
pool_pre_ping=True, # проверять живость перед выдачей — спасает после рестарта БД
connect_args={
"connect_timeout": 3,
# серверные таймауты: убивают запрос и брошенную транзакцию без нашего участия
"options": "-c statement_timeout=5000 "
"-c idle_in_transaction_session_timeout=10000 "
"-c lock_timeout=3000",
},
)
Обратите внимание на pool_timeout=2. Ждать соединение бесконечно — значит превратить локальную проблему в отказ всего сервиса: воркеры кончатся, очередь HTTP-запросов вырастет, балансировщик начнёт выводить инстансы. Быстрый отказ лучше медленного зависания — это общий принцип («Шаблоны устойчивости»).
Таймауты: их всегда четыре
Запрос без таймаута — это запрос, который в худший день будет выполняться вечно. Настроить нужно все уровни, потому что каждый ловит свою беду:
- connect timeout — сеть или база недоступны;
- statement timeout — запрос выполняется слишком долго (обычно из-за плохого плана);
- lock timeout — запрос ждёт блокировку, которую держит кто-то другой;
- idle in transaction timeout — приложение открыло транзакцию и «ушло», забыв закрыть.
Последний — самый недооценённый. Открытая простаивающая транзакция держит блокировки и мешает очистке старых версий строк; одна забытая транзакция в отладочной сессии способна за ночь раздуть таблицу в несколько раз.
Граница транзакции и unit of work
Транзакция — не «обёртка вокруг записи в базу», а граница согласованности бизнес-операции. Всё, что должно случиться вместе или не случиться вовсе, лежит внутри; всё остальное — снаружи.
соединение всё равно возвращается в пул
Никаких сетевых вызовов внутри транзакции. Внешний API отвечает за 200 мс или за 30 с — и всё это время вы держите блокировки и соединение. Если внешнее действие обязано произойти вместе с записью, используйте паттерн outbox: записать намерение в ту же транзакцию, отправить отдельным процессом («Идемпотентность и гарантии доставки»).
Одна бизнес-операция — одна транзакция. Не «транзакция на каждый метод репозитория» (тогда согласованности нет) и не «транзакция на весь HTTP-запрос вместе с рендерингом» (тогда она слишком длинная).
Транзакция может честно провалиться. На высоких уровнях изоляции СУБД имеет право отменить транзакцию из-за конфликта сериализации; правильная реакция — повторить её целиком, а не считать это ошибкой пятисотки. Значит, тело транзакции должно быть безопасно повторяемым — без побочных эффектов наружу.
ORM: что даёт и где врёт
ORM решает реальную задачу: объекты в памяти связаны ссылками, строки в таблицах — ключами, и ручное перекладывание одного в другое — тонны скучного кода с ошибками. ORM даёт маппинг, отслеживание изменений, identity map, параметризацию запросов (а значит защиту от SQL-инъекций, «Инъекции») и связку с миграциями.
Врёт он в одном месте, но принципиально: делает вид, что обращение к базе стоит столько же, сколько обращение к полю объекта. Отсюда главная патология.
N+1: как выглядит и как чинится
from sqlalchemy import select
# ПЛОХО. Один SELECT за заказами + ещё по одному за позициями КАЖДОГО заказа.
orders = session.scalars(
select(Order).where(Order.customer_id == customer_id)
).all()
for order in orders:
# обращение к order.items незаметно вызывает отдельный SELECT
total = sum(item.unit_price * item.quantity for item in order.items)
Кода про базу здесь нет вообще — и в этом ловушка. На 10 заказах будет 11 запросов и никто не заметит; на 500 заказах — 501 запрос, и страница откроется за шесть секунд. По времени это O(N) обращений к сети, где каждое стоит сотни микросекунд на локальной базе и единицы миллисекунд на удалённой. Хуже всего то, что деградация линейная и приходит из данных, а не из релиза: код не менялся, просто у клиента стало больше заказов.
from sqlalchemy.orm import selectinload
# ХОРОШО. Ровно два запроса независимо от числа заказов: O(1) обращений.
orders = session.scalars(
select(Order)
.where(Order.customer_id == customer_id)
.options(selectinload(Order.items)) # второй запрос: WHERE order_id IN (...)
).all()
Два способа лечения различаются по цене. selectinload даёт два запроса и не дублирует данные — годится почти всегда. joinedload даёт один запрос через LEFT JOIN, но строка заказа приезжает столько раз, сколько у него позиций; на связи «один ко многим» с большими коллекциями это дороже, чем два запроса. На связи «многие к одному» (позиция → товар) joinedload наоборот оптимален. В других экосистемах то же самое называется Include (EF Core), JOIN FETCH (JPA/Hibernate), includes/preload (Rails).
Как ловить N+1 до прода, а не после:
echo=Trueили логирование SQL в dev-режиме — просто посмотреть, сколько запросов уходит на страницу;- счётчик запросов в интеграционных тестах: «этот эндпоинт делает не больше 5 запросов» — такой тест ловит регрессию мгновенно («Интеграционное тестирование»);
- APM/трейсинг в проде, где видно число обращений к БД внутри одного запроса («Наблюдаемость и дежурства»).
Правило разделения труда
ORM — для CRUD и доменных операций. SQL — для отчётов, агрегатов и массовых изменений. Попытка выразить оконную функцию, рекурсивный CTE или GROUP BY с несколькими соединениями через объектный API даёт код, который невозможно ни прочитать, ни оптимизировать. Такие запросы пишут на SQL и хранят рядом с кодом — это нормально и не является «протечкой абстракции».
-- Отчёт: топ-10 клиентов по выручке за месяц.
-- Через ORM это будет нечитаемо; на SQL — очевидно и оптимизируемо по плану.
SELECT c.id,
c.email,
sum(o.total_amount) AS revenue,
count(*) AS orders_count,
rank() OVER (ORDER BY sum(o.total_amount) DESC) AS position
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND o.created_at >= date_trunc('month', now())
GROUP BY c.id, c.email
ORDER BY revenue DESC
LIMIT 10;
И ещё одно: ORM не освобождает от знания планов запросов. EXPLAIN (ANALYZE, BUFFERS) — базовый инструмент прикладного разработчика, а не только DBA («Индексы и планы запросов», «Производительность БД»).
Миграции схемы без простоя
Схема меняется постоянно; вопрос лишь в том, ляжет ли при этом сервис. Базовые правила: миграции лежат в репозитории рядом с кодом, применяются автоматически в пайплайне («CI/CD»), пронумерованы, forward-only (откат делается новой миграцией, а не «отменой» старой) и не содержат ручных шагов из головы дежурного. Инструменты: Alembic (Python), Flyway и Liquibase (JVM), golang-migrate, EF Core Migrations.
Ключевая трудность в том, что во время выкладки в проде одновременно работают две версии приложения: старая ещё не остановлена, новая уже принимает трафик. Значит, схема обязана быть совместима с обеими. Отсюда паттерн expand → migrate → contract.
Смысл в одной фразе: между «перестали писать в колонку» и «удалили колонку» должен пройти хотя бы один полный релизный цикл. Если удалить в том же релизе, то откат приложения на предыдущую версию (а он случается ночью, быстро и нервно) упрётся в отсутствующую колонку — и откатывать будет некуда.
Практические грабли, специфичные для DDL на живой базе:
- Долгие блокировки.
ALTER TABLE, требующий переписывания таблицы, держит эксклюзивную блокировку. На таблице в 200 ГБ это часы простоя. Индексы строят черезCREATE INDEX CONCURRENTLY. lock_timeoutперед DDL обязателен. Иначе миграция встанет в очередь за долгим запросом и заблокирует всех, кто пришёл после неё, — типичный сценарий «положили прод одной безобидной миграцией».NOT NULLс дефолтом на большой таблице. В современном PostgreSQL это дёшево, в старых версиях и в других СУБД — переписывание таблицы целиком. Проверяйте поведение своей версии.- Backfill — не одним
UPDATE. Миллиард строк одной командой означает гигантскую транзакцию, распухший журнал и заблокированную таблицу. Батчами, с паузами, с возможностью остановиться.
"""Alembic: backfill батчами. Отдельная миграция, вне общей транзакции."""
import sqlalchemy as sa
from alembic import op
revision, down_revision = "0042_backfill_currency", "0041_add_currency"
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("SET lock_timeout = '3s'"))
while True:
# SKIP LOCKED — не деремся за строки с рабочей нагрузкой
result = conn.execute(sa.text("""
WITH batch AS (
SELECT id FROM orders
WHERE currency IS NULL
ORDER BY id
LIMIT 5000
FOR UPDATE SKIP LOCKED
)
UPDATE orders o SET currency = 'RUB'
FROM batch b WHERE o.id = b.id
"""))
conn.commit() # фиксируем каждый батч отдельно
if result.rowcount == 0: # строк не осталось — выходим
break
def downgrade() -> None:
# Backfill не откатывается: данные уже проставлены и это безопасно.
pass
Перед выкладкой миграции на прод полезно ответить на четыре вопроса письменно: сколько строк она затронет, какую блокировку возьмёт, сколько будет выполняться на объёме прода (а не на пустой локальной базе) и как её откатить, если через пять минут станет ясно, что всё плохо. Если ответа на четвёртый нет — миграция не готова.
Данные — это долгосрочное обязательство
Разница между кодом и данными: код можно выбросить и написать заново, данные — нет. Отсюда несколько обязательств, которые прикладной разработчик принимает вместе с первой таблицей.
Схема переживёт код. Через пять лет фреймворка не будет, а таблица останется, и в ней будут строки, записанные всеми версиями приложения за это время. Поэтому в схеме избегают «умных» решений, понятных только текущему коду: колонок-мешков с непрозрачным JSON, значений-флагов вроде status = 7, полей, смысл которых зависит от значения соседнего поля.
Персональные данные — отдельный класс. Что именно храним, зачем, как долго и где физически лежат серверы — не абстрактные вопросы, а требования GDPR и российского 152-ФЗ с реальными последствиями. Правило минимизации простое: не собирайте того, что не нужно; несобранные данные невозможно утечь. Для остального — шифрование, псевдонимизация, ограничение доступа по колонкам и, обязательно, работающая процедура удаления по запросу пользователя (включая бэкапы и аналитические копии — про них забывают всегда). Подробности — «Приватность и комплаенс».
Резидентность данных. Требование хранить данные граждан внутри страны — архитектурное ограничение, которое проще заложить сразу, чем прикручивать потом: оно влияет на выбор облака, на схему репликации и на структуру ключей.
Бэкап, который не восстанавливали, — не бэкап. Это не афоризм, а статистика инцидентов: копии годами пишутся в никуда, потому что никто не проверял. Минимум, который надо иметь: заявленные RPO (сколько данных допустимо потерять) и RTO (за сколько обязаны подняться), автоматические копии с восстановлением на точку во времени, регулярное учебное восстановление на отдельный стенд с замером времени и сверкой данных. Хорошая практика — раз в квартал восстанавливать прод-бэкап и проверять, что цифры сходятся («Учения и хаос-инжиниринг»). И отдельно: реплика — не бэкап, она мгновенно повторит DROP TABLE.
Типичные ошибки
SELECT *. Тянет колонки, которые не нужны, ломается при изменении схемы, мешает index-only scan и незаметно вытаскивает по сети мегабайтныеTEXT-поля. Перечисляйте колонки.- Индексы «на всякий случай» и отсутствие индексов под реальные запросы. Индекс ускоряет чтение и замедляет запись, занимает место и требует обслуживания. Создают их под конкретный запрос, проверив планом, что он используется; неиспользуемые — удаляют, это видно в системных представлениях.
- Бизнес-логика в триггерах и хранимых процедурах. Соблазнительно (быстро, атомарно), но такая логика невидима из кода, не покрыта тестами, не проходит код-ревью и не версионируется вместе с приложением. Триггеры хороши для технических задач: аудит,
updated_at, поддержание производных данных. - «Пусть ORM разберётся». Не разберётся. Разработчик обязан знать, сколько запросов и какого веса порождает его эндпоинт.
- Отсутствие таймаута запроса. Один тяжёлый запрос без ограничения способен утащить за собой весь пул.
- Миграция на проде без плана отката и без представления о времени выполнения на реальном объёме.
- Строки вместо типов. Даты как
text, деньги какfloat, перечисления как свободный текст, UUID какvarchar. Каждое такое решение позже стоит миграции на терабайтной таблице. - Отсутствие ограничений в схеме с обоснованием «мы всё проверяем в коде». Кода со временем становится несколько, а проверок в нём — нет.
- Кэш как затычка вместо запроса. Кэш перед неоптимизированным запросом маскирует проблему до момента инвалидации, а потом всё падает разом («Кэширование»).
Мини-итог
- СУБД — это готовое решение пяти задач, каждая из которых стоила бы вам лет: конкурентный доступ, атомарность, восстановление после сбоя, декларативные запросы и контроль целостности.
- Разводите термины: данные, движок, система. Инцидент почти всегда в одном конкретном слое.
- Схема — самый долгоживущий артефакт. Инварианты держит база, а не «мы договорились».
- Выбор хранилища идёт по порядку: форма запросов → инварианты → объём и нагрузка → эксплуатация → и только потом отраслевая мода. PostgreSQL по умолчанию, уход от него — по измеренной причине.
- Пул соединений маленький, таймауты — все четыре, транзакция короткая и без походов в сеть.
- ORM для CRUD, SQL для отчётов; N+1 ищут логом запросов и тестом на число обращений.
- Миграции: expand → migrate → contract, forward-only, с планом отката и оценкой времени на объёме прода.
- Бэкап, который не восстанавливали, не существует.
Источники
- E. F. Codd. A Relational Model of Data for Large Shared Data Banks, CACM, 1970 — статья, с которой всё началось.
- Number Of Database Connections — почему пул должен быть маленьким.
- Martin Kleppmann. Designing Data-Intensive Applications — лучшая книга про выбор и стыковку хранилищ.
- PostgreSQL: Explicit Locking и ALTER TABLE — что именно блокирует ваша миграция.
- Alembic и Flyway — документация по миграциям.
Что дальше
Данные лежат, приложение с ними работает. Но система редко состоит из одной части: клиент разговаривает с сервером, сервер — с другими сервисами, и у каждого стыка есть контракт, который легко сломать.
Дальше — Интеграция.