Проектирование схемы: ключи, нормализация, партиционирование, шардирование
Кратко о теме
Заголовок раздела «Кратко о теме»Проектирование схемы — это выбор того, где хранится каждый факт и кто отвечает за его непротиворечивость. В реляционной модели за это отвечают ключи и ограничения: первичный ключ даёт каждой строке устойчивую идентичность, уникальные ограничения запрещают дубликаты по бизнес-смыслу, внешние ключи гарантируют, что ссылка ведёт на существующую строку, а NOT NULL/CHECK отсекают невозможные состояния. Практический принцип: любое правило, которое можно выразить ограничением в БД, лучше выразить именно там, потому что приложений у базы обычно несколько (сервис, миграции, ручной SQL аналитика, бэкофис), и только сама база проверяет правило всегда.
Второй слой модели в голове — нормализация. Нормальные формы (1NF, 2NF, 3NF, BCNF) — это не ритуал, а формальный способ сказать «каждый факт хранится ровно в одном месте, и он зависит от целого ключа, а не от его части или от неключевого атрибута». Пока факт не дублируется, аномалии вставки/обновления/удаления невозможны. Дальше начинается инженерный компромисс: нормализованная схема требует джойнов, поэтому осознанно и точечно применяют денормализацию (кешированные счётчики, материализованные представления, продублированные «горячие» колонки), заранее решая, кто и когда будет пересчитывать копию и что будет, если она разъедется.
Третий слой — масштабирование, где кандидаты чаще всего путают три ортогональные вещи. Партиционирование — разрезание одной таблицы на физические части внутри одного сервера: цель — управляемость (быстро удалить старые данные через DETACH/DROP партиции), pruning по ключу партиционирования и меньшие индексы. Репликация — копирование тех же самых данных на другие узлы: цель — отказоустойчивость и масштабирование чтения; бывает синхронной (коммит ждёт подтверждения реплики — потери нет, латентность выше) и асинхронной (быстро, но при падении мастера возможна потеря последних транзакций). Шардирование — разрезание данных по разным узлам с разными наборами строк: единственный способ масштабировать запись и объём за пределы одной машины, но ценой распределённых транзакций, отсутствия глобальных FK и болезненного решардинга. Формула для собеседования: «партиционирование — про удобство и локальность, репликация — про надёжность и чтение, шардирование — про запись и объём».
Отдельная тема, которая всегда идёт рядом, — эволюция схемы. В продакшене DDL пишут по схеме expand → migrate → contract: сначала добавляем совместимое (nullable-колонка, новый индекс CONCURRENTLY), потом бэкфиллим батчами и переключаем код, и только затем удаляем старое. Ключевой навык — знать, какая операция берёт ACCESS EXCLUSIVE и перезаписывает таблицу, а какая меняет только каталог, и всегда ставить lock_timeout, чтобы миграция не выстроила за собой очередь из всех запросов приложения.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Что такое первичный ключ? Какие свойства у него есть?
Заголовок раздела «Что такое первичный ключ? Какие свойства у него есть?»Коротко. Первичный ключ — это минимальный набор колонок, который однозначно идентифицирует строку в таблице. Его свойства: уникальность, запрет NULL, единственность (один PK на таблицу) и стабильность — значение ключа не должно меняться в течение жизни строки.
Глубже. В PostgreSQL PRIMARY KEY — это синтаксический сахар над UNIQUE NOT NULL: под ограничение автоматически создаётся уникальный B-tree индекс, и именно он обеспечивает проверку. Формально ключей-кандидатов (candidate key) в таблице может быть несколько (например, id и email), один из них объявляют первичным, остальные — UNIQUE. Кроме идентификации, PK играет служебную роль: по умолчанию он является replica identity для логической репликации (без PK/REPLICA IDENTITY UPDATE/DELETE на публикуемой таблице упадут с ошибкой), на него ссылаются внешние ключи, а ORM и инструменты миграций используют его для точечного обновления строк. Требование стабильности — практическое: если PK меняется, придётся каскадно обновлять все ссылки, поэтому в качестве PK обычно берут технический (суррогатный) ключ, а не бизнес-атрибут вроде email или номера паспорта.
Может ли первичный ключ быть составным?
Заголовок раздела «Может ли первичный ключ быть составным?»Коротко. Да, PRIMARY KEY (a, b) — нормальная практика. Уникальность требуется от комбинации значений, при этом каждая колонка составного ключа обязана быть NOT NULL.
Глубже. Классический случай — таблица-связка для many-to-many: PRIMARY KEY (project_id, employee_id) одновременно даёт идентичность и запрещает дубли связей. Важные нюансы: порядок колонок в составном PK определяет порядок колонок в его индексе, а значит по одной первой колонке индекс работает, а по одной второй — практически нет (нужен отдельный индекс на обратный порядок); в партиционированных таблицах PostgreSQL требует, чтобы ключ партиционирования входил в состав PK/UNIQUE, поэтому там составные ключи вида (id, created_at) возникают вынужденно. Минус составного естественного ключа — он «протекает» во все ссылающиеся таблицы: FK на него тоже станет составным и раздует индексы, поэтому широкие составные PK часто заменяют суррогатным bigint плюс отдельным UNIQUE (a, b).
Что такое нормализация базы данных?
Заголовок раздела «Что такое нормализация базы данных?»Коротко. Нормализация — приведение схемы к виду, в котором каждый факт хранится ровно в одном месте, за счёт разбиения таблиц и вынесения зависимостей в отдельные отношения. Цель — устранить избыточность и аномалии вставки, обновления и удаления.
Глубже. Формально нормализация — это последовательное устранение нежелательных функциональных зависимостей. Пример аномалии: если в таблице orders рядом с заказом хранится customer_name, то переименование клиента требует обновить N строк, и любая пропущенная строка даёт противоречивые данные — это аномалия обновления. Вынесли клиента в customers и оставили customer_id — аномалия исчезла структурно, а не за счёт аккуратности кода. Важно понимать границу: нормализация борется с логической избыточностью (один факт в нескольких местах), а не с любым дублированием байтов; кешированный агрегат, который явно помечен как производная величина с известным способом пересчёта, — это уже осознанная денормализация, а не ошибка нормализации.
С какой целью применяют денормализацию базы данных? Какие минусы у этого подхода?
Заголовок раздела «С какой целью применяют денормализацию базы данных? Какие минусы у этого подхода?»Коротко. Денормализуют ради скорости чтения: убрать джойны и агрегаты с горячего пути, уложиться в SLA на запрос. Минусы — риск расхождения копий данных, дороже запись, больше места и сложнее инварианты.
Глубже. Типовые приёмы: счётчик comments_count в posts вместо COUNT(*); продублированная author_name в списке, чтобы отрисовать страницу без джойна; материализованное представление под отчёт; массив/jsonb вместо таблицы-связки, если элементы читаются всегда целиком. Ключевое правило: у каждой денормализованной копии должен быть один явный владелец обновления — триггер, транзакция в приложении или регулярный пересчёт, — плюс сверка (reconciliation), которая находит расхождения. Минусы стоит называть конкретно: (1) любая копия рано или поздно разъезжается — нужен способ это заметить и исправить; (2) запись становится дороже и шире (обновляем и факт, и его копии, вырастает WAL, появляются точки блокировок — например, один счётчик на популярный пост становится точкой конкуренции); (3) растёт объём и, следовательно, кеш вымывается быстрее; (4) схема хуже переживает изменение требований. Практика: сначала нормализованная схема плюс индексы, денормализация — по замеренной проблеме, а не заранее.
Что такое FOREIGN KEY?
Заголовок раздела «Что такое FOREIGN KEY?»Коротко. FOREIGN KEY — ограничение ссылочной целостности: значения колонок дочерней таблицы обязаны существовать в уникальном ключе родительской (или быть NULL). База не даст вставить «висячую» ссылку и не даст удалить родителя, на которого ещё ссылаются.
Глубже. Синтаксис REFERENCES parent(id) ON DELETE ... ON UPDATE ... c вариантами NO ACTION (по умолчанию, проверка в конце операции), RESTRICT (проверка сразу, нельзя отложить), CASCADE, SET NULL, SET DEFAULT. Реализовано это системными триггерами: при вставке/обновлении дочерней строки берётся FOR KEY SHARE блокировка на родительскую строку — отсюда возможная конкуренция и дедлоки при массовых вставках в разном порядке. Два практически важных факта: PostgreSQL не создаёт индекс на ссылающуюся колонку автоматически (его нужно добавлять руками, иначе DELETE в родителе будет делать seq scan дочерней таблицы), и ограничение можно объявить DEFERRABLE INITIALLY DEFERRED, чтобы проверка выполнялась в конце транзакции — это спасает при циклических ссылках и массовых загрузках. Начиная с PostgreSQL 12 FK может ссылаться на партиционированную таблицу.
Что можно улучшить в схеме БД?
Заголовок раздела «Что можно улучшить в схеме БД?»Коротко. Это открытый вопрос: интервьюер даёт схему и ждёт системного прохода. Иду по чек-листу: ключи и уникальности, типы и NOT NULL, ссылочная целостность и индексы под FK, нормальные формы, индексы под реальные запросы, рост таблицы и партиционирование, аудит/soft delete/временные поля.
Глубже. Что обычно есть что улучшать в реальной схеме: (1) нет PK или PK на бизнес-атрибуте — добавить суррогатный bigint/uuid, бизнес-ключ оставить UNIQUE; (2) всё text и всё nullable — уточнить типы (timestamptz вместо timestamp, numeric вместо float для денег, enum/справочник вместо строкового статуса), поставить NOT NULL и CHECK; (3) timestamp without time zone для событий — почти всегда баг; (4) отсутствуют FK или FK есть, но нет индекса на дочерней колонке; (5) EAV или «широкая таблица на 80 nullable-колонок» — разделить на сущности или вынести редкие атрибуты в jsonb; (6) many-to-many, реализованный массивом строк или CSV в колонке — вынести в таблицу-связку; (7) дубли фактов (customer_name в каждом заказе); (8) нет индексов под фактические WHERE/ORDER BY, зато есть неиспользуемые и дублирующие индексы; (9) большая append-only таблица без партиционирования по времени и без стратегии удаления; (10) нет created_at/updated_at, нет идемпотентных ключей для внешних интеграций. Хороший ответ обязательно уточняет профиль нагрузки: «что улучшить» зависит от того, OLTP это или отчётность.
Что такое первичный ключ? Обязательно ли иметь в таблице первичный ключ?
Заголовок раздела «Что такое первичный ключ? Обязательно ли иметь в таблице первичный ключ?»Коротко. См. выше про свойства PK. Формально PostgreSQL позволяет таблицу без первичного ключа, но практически он нужен почти всегда: без него нельзя надёжно адресовать конкретную строку, ссылаться на неё внешним ключом и корректно репликовать UPDATE/DELETE логической репликацией.
Глубже. Осмысленные исключения: партиции и таблицы фактов/логов, куда только пишут и читают диапазонами (тогда PK — лишний индекс и лишний WAL), временные и staging-таблицы под загрузку. Но и там стоит помнить о последствиях: без PK/REPLICA IDENTITY FULL логическая репликация и CDC (Debezium, pglogical) на UPDATE/DELETE сломаются; без уникальности дубликат от ретрая клиента ничем не отсечётся; в PostgreSQL строку всё ещё можно адресовать системным ctid, но он меняется при UPDATE/VACUUM FULL, поэтому в качестве идентификатора не годится. Отдельно: в PostgreSQL таблица без PK не «неупорядочена как-то особенно» — heap не упорядочен в любом случае, в отличие от InnoDB, где PK является кластерным индексом и при его отсутствии MySQL всё равно создаёт скрытый 6-байтный DB_ROW_ID.
Что можешь сказать про тип jsonb?
Заголовок раздела «Что можешь сказать про тип jsonb?»Коротко. jsonb хранит JSON в разобранном бинарном виде: не сохраняет порядок ключей, пробелы и дубликаты ключей, зато быстро достаёт поля и поддерживает индексирование (GIN) и богатый набор операторов. json хранит текст как есть — быстрее на вставке, медленнее на чтении и почти без индексов.
Глубже. Практические детали: операторы ->/->> (доступ), #>/#>> (по пути), @> (containment), ?/?|/?& (наличие ключа), ||, -, jsonb_set, а также SQL/JSON path — @? и @@ с jsonpath. Индексы: CREATE INDEX ... USING gin (doc) (по умолчанию jsonb_ops — поддерживает и ключи, и containment) или gin (doc jsonb_path_ops) (компактнее и быстрее, но только @>/path); для конкретного поля почти всегда выгоднее обычный B-tree по выражению — CREATE INDEX ON t ((doc->>'user_id')) — или generated-колонка (GENERATED ALWAYS AS ... STORED, PG 12+) с индексом по ней. Значение целиком уходит в TOAST и сжимается, но при чтении одного поля разжимается весь документ — поэтому «широкий документ вместо колонок» дорог на чтении. Статистики по внутренностям jsonb планировщик практически не имеет, отсюда плохие оценки селективности и странные планы. Вывод для собеседования: jsonb хорош для реально схемо-свободных данных (payload от внешней системы, настройки, разреженные атрибуты, аудит), но плохая замена нормальным колонкам там, где набор полей известен, — теряются типы, NOT NULL, FK и адекватные оценки планировщика.
What ’s the difference between partitioning and sharding?
Заголовок раздела «What ’s the difference between partitioning and sharding?»Коротко. Partitioning splits one logical table into physical pieces inside a single database instance; sharding splits the dataset across independent nodes. Partitioning buys manageability and pruning; sharding buys write throughput and capacity beyond one machine, at the cost of cross-node queries and transactions.
Глубже. Consequences that follow from that one difference: with partitioning you keep a single transaction manager, so ACID, FKs and joins work as usual, and the planner prunes partitions by the partition key; with sharding every node has its own WAL and clock, so a cross-shard write needs either 2PC or an application-level saga/outbox, cross-shard joins have to be done in a coordinator (Citus, Vitess, ProxySQL) or in the app, and global uniqueness needs UUIDs or a dedicated ID service. Resharding is the real pain point: adding a node means moving data, which is why hash-based schemes use many virtual shards (or consistent hashing) mapped onto few physical nodes. Note that the two are commonly combined: shard by tenant_id, then range-partition each shard’s big tables by month.
Kafka questions: delivery guarantees, consumer groups, and what happens if there are more consumers than partitions? What is synchronous and asynchronous replication?
Заголовок раздела «Kafka questions: delivery guarantees, consumer groups, and what happens if there are more consumers than partitions? What is synchronous and asynchronous replication?»Коротко. Kafka даёт три уровня гарантий: at-most-once, at-least-once (практический дефолт) и exactly-once внутри Kafka (идемпотентный продюсер + транзакции + isolation.level=read_committed). Внутри consumer group одна партиция закреплена ровно за одним консьюмером, поэтому лишние консьюмеры простаивают. Синхронная репликация ждёт подтверждения от реплик до подтверждения коммита (нет потери, выше латентность), асинхронная — не ждёт (быстро, но при падении лидера возможна потеря последних записей).
Глубже. Гарантии на стороне продюсера настраиваются acks (0 — не ждём никого, 1 — только лидер, all — все реплики из ISR) в связке с min.insync.replicas на топике: acks=all + min.insync.replicas=2 при RF=3 — стандартная «безопасная» конфигурация. enable.idempotence=true (дефолт с Kafka 3.0) убирает дубли от ретраев внутри сессии продюсера за счёт producer id и sequence number; полноценный exactly-once даёт транзакционный API (transactional.id, initTransactions/beginTransaction/sendOffsetsToTransaction/commitTransaction) — он атомарно фиксирует и записанные сообщения, и смещения консьюмера, но только внутри Kafka: побочный эффект во внешней БД так не откатится, там нужна идемпотентность по ключу или паттерн outbox/inbox. На стороне консьюмера гарантия определяется порядком: закоммитил offset до обработки — at-most-once, после обработки — at-least-once. Про «больше консьюмеров, чем партиций»: назначение делает assignor (range/roundrobin/sticky/cooperative-sticky), лишние члены группы получают пустое назначение и висят в idle как горячий резерв — они подхватят партиции при ребалансе, если кто-то отвалится; параллелизм чтения в группе ограничен числом партиций, и увеличить его можно только увеличением партиций. Про репликацию в Kafka: она реализована как «синхронная в пределах ISR» — follower’ы тянут данные с лидера, и запись считается committed, когда её получили все реплики в ISR; реплика, отставшая больше replica.lag.time.max.ms, выпадает из ISR (и тогда, при min.insync.replicas, продюсер начнёт получать NotEnoughReplicas). unclean.leader.election.enable=true разрешает выбрать лидером отставшую реплику — это выбор доступности в обмен на потерю данных.
Что такое партиционирование, шардинг и репликация?
Заголовок раздела «Что такое партиционирование, шардинг и репликация?»Коротко. Партиционирование — деление одной таблицы на части внутри одной БД (управляемость, pruning, быстрый DROP старых данных). Репликация — копирование тех же данных на другие узлы (отказоустойчивость, масштабирование чтения). Шардинг — распределение разных строк по независимым узлам (масштабирование записи и объёма).
Глубже. Полезно отвечать через ортогональные оси: партиционирование меняет физическую раскладку внутри узла, репликация добавляет копии тех же данных, шардинг добавляет узлы с разными данными. Отсюда стоимость: партиционирование почти бесплатно с точки зрения семантики (транзакции и FK работают), но требует, чтобы запросы содержали ключ партиционирования, иначе получаем скан всех партиций; репликация даёт eventual consistency на асинхронных репликах (реплика может отставать, поэтому «прочитать сразу после записи» с реплики небезопасно — в PostgreSQL это лечат synchronous_commit=remote_apply или чтением с мастера для read-after-write); шардинг ломает глобальные ограничения — нет глобального FK, нет глобального UNIQUE без выбора ключа шардирования как части ключа, распределённые джойны и транзакции требуют координатора. В типичной эволюции их применяют именно в порядке «индексы → репликация чтения → партиционирование → шардирование».
there is a project, a project has employees;
Заголовок раздела «there is a project, a project has employees;»Коротко. Это условие задачи на моделирование. Ключевой вопрос — «сотрудник может участвовать в нескольких проектах?»: если да — это many-to-many через таблицу-связку, если строго один проект на сотрудника — one-to-many с FK project_id в employees.
Глубже. Канонический ответ — M:N, потому что на практике сотрудник почти всегда попадает в несколько проектов, и связка сразу даёт место для атрибутов связи (роль, доля занятости, период участия):
CREATE TABLE projects ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE employees ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, full_name text NOT NULL, email text NOT NULL UNIQUE);
CREATE TABLE project_employees ( project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, role text NOT NULL, allocation numeric(5,2) NOT NULL DEFAULT 100 CHECK (allocation > 0 AND allocation <= 100), joined_at date NOT NULL DEFAULT current_date, PRIMARY KEY (project_id, employee_id));
-- обратный доступ «проекты сотрудника» — отдельный индекс,-- PK покрывает только (project_id, ...)CREATE INDEX ON project_employees (employee_id);Что интервьюер обычно спрашивает дальше: как найти сотрудников без проектов (LEFT JOIN ... WHERE pe.project_id IS NULL или NOT EXISTS), как посчитать число сотрудников по проектам (GROUP BY), как сделать «один менеджер на проект» (частичный уникальный индекс CREATE UNIQUE INDEX ON project_employees (project_id) WHERE role = 'manager') и что произойдёт при удалении проекта (зависит от ON DELETE).
personId - первичный ключ.
Заголовок раздела «personId - первичный ключ.»Коротко. Обрывок исходника, вопрос не восстанавливается. По контексту это строка из условия задачи на связь person/address: personId объявлен PK таблицы людей.
Глубже. Если такое условие дают, отвечают по сути так: PK — суррогатный bigint/uuid, у людей уникальный бизнес-ключ (email/паспорт) выносится в UNIQUE, а связь с адресом реализуется в зависимости от кардинальности — FK person_id в addresses для 1:N, либо UNIQUE на этом FK для 1:1.
addressId - первичный ключ.
Заголовок раздела «addressId - первичный ключ.»Коротко. Обрывок исходника, вопрос не восстанавливается. Парная строка к предыдущей: PK таблицы адресов.
Глубже. Содержательная часть такой задачи — как связать две таблицы с собственными PK. Вариант «FK в дочерней таблице» (addresses.person_id) естественен, когда у человека много адресов; вариант «общий PK» (addresses.person_id одновременно PK и FK) корректно выражает строгое 1:1; вариант «FK в обе стороны» — почти всегда ошибка, он допускает несогласованные состояния и требует DEFERRABLE ограничений.
Что такое нормализация баз данных? Что знаешь про нормальные формы?
Заголовок раздела «Что такое нормализация баз данных? Что знаешь про нормальные формы?»Коротко. См. выше про нормализацию. Нормальные формы: 1NF — атомарные значения, никаких повторяющихся групп; 2NF — 1NF плюс нет зависимости неключевого атрибута от части составного ключа; 3NF — 2NF плюс нет транзитивных зависимостей (неключевой атрибут зависит от неключевого); BCNF — любой детерминант является суперключом. На практике целятся в 3NF/BCNF.
Глубже. Примеры «на пальцах». Нарушение 1NF: колонка phones = '+7900...,+7901...' или массив адресов, элементы которого нужно искать и обновлять по одному. Нарушение 2NF: таблица order_items(order_id, product_id, qty, product_name) — product_name зависит только от product_id, то есть от части ключа. Нарушение 3NF: employees(id, dept_id, dept_name) — dept_name зависит от dept_id, а не от id. BCNF ужесточает 3NF для случаев с несколькими перекрывающимися ключами-кандидатами (классический пример — (студент, курс, преподаватель), где преподаватель определяет курс). Дальше: 4NF убирает многозначные зависимости (два независимых списка в одной таблице — навыки и языки сотрудника), 5NF — зависимости соединения, 6NF используется в темпоральных моделях (anchor modeling). Разумная позиция в ответе: «формы 1–3 и BCNF — рабочий инструмент, 4NF и 5NF встречаются редко и обычно устраняются вместе с 3NF, а декомпозиция всегда должна быть без потерь и с сохранением зависимостей».
Что такое primary key и foreign key? Для чего нужны?
Заголовок раздела «Что такое primary key и foreign key? Для чего нужны?»Коротко. PK идентифицирует строку внутри таблицы (уникален, NOT NULL, один на таблицу). FK связывает таблицы: значение в дочерней таблице обязано существовать в уникальном ключе родительской. Вместе они дают идентичность строк и ссылочную целостность — базу для любых связей 1:1, 1:N, M:N.
Глубже. Помимо целостности, у обоих есть эффекты, о которых полезно упомянуть. PK автоматически создаёт уникальный индекс, поэтому поиск по нему быстрый, и является replica identity по умолчанию. FK влияет на планировщик: с PostgreSQL 9.6 планировщик использует внешние ключи для более точной оценки селективности джойнов, а с PostgreSQL 12+ работает join elimination в ряде случаев. Обратная сторона: FK делает запись дороже (проверка + FOR KEY SHARE на родителе), требует ручного индекса на дочерней колонке и мешает при массовых загрузках и решардинге — типовой приём загрузки большого объёма это ALTER TABLE ... DROP CONSTRAINT / загрузка / ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT (последняя фаза не блокирует чтение и запись, а берёт SHARE ROW EXCLUSIVE).
Какие есть типы связи в Postgres? И какие вообще бывают? (1-to-1, 1-to-many, many-to-many)
Заголовок раздела «Какие есть типы связи в Postgres? И какие вообще бывают? (1-to-1, 1-to-many, many-to-many)»Коротко. Типов связи в самом Postgres нет — есть три модельных вида, которые выражаются ключами: 1:1 (FK + UNIQUE на нём или общий PK), 1:N (FK на стороне «многих»), M:N (отдельная таблица-связка с составным PK из двух FK).
Глубже. Реализации по порядку. 1:N — orders.customer_id REFERENCES customers(id) плюс индекс на customer_id; это базовый случай, «многие» всегда хранят ссылку. 1:1 — тот же FK, но с UNIQUE (customer_id), либо user_profiles.user_id одновременно PK и FK (жёстче и без лишнего индекса); применяется для выноса редко используемых или чувствительных колонок. M:N — PRIMARY KEY (a_id, b_id) в связке, плюс индекс на вторую колонку для обратного обхода; связка — хорошее место для атрибутов связи. Отдельно стоит назвать самоссылку (employees.manager_id REFERENCES employees(id), обход через рекурсивный CTE WITH RECURSIVE), иерархии (adjacency list, materialized path, closure table, ltree) и полиморфные связи (entity_type + entity_id) — последние в PostgreSQL нельзя защитить внешним ключом, поэтому обычно вместо них делают несколько nullable-FK с CHECK, что ровно один заполнен, или отдельные таблицы-связки.
Что такое денормализация?
Заголовок раздела «Что такое денормализация?»Коротко. Осознанное отступление от нормальных форм: намеренное дублирование или предвычисление данных, чтобы ускорить чтение — счётчики, продублированные колонки, материализованные представления, вложенные jsonb-структуры вместо джойнов.
Глубже. См. выше про цели и минусы. Отличие этого вопроса — акцент на том, что денормализация ≠ «плохая схема»: критерий корректности не «есть ли дубли», а «есть ли у копии единственный владелец, задокументированный способ пересчёта и контроль расхождений». Инструменты в PostgreSQL: триггеры AFTER INSERT/UPDATE/DELETE для счётчиков, MATERIALIZED VIEW + REFRESH MATERIALIZED VIEW CONCURRENTLY (требует уникального индекса и не блокирует чтение), generated-колонки для детерминированных производных значений, отдельные read-модели, наполняемые из outbox/CDC. Важный нюанс про счётчики: обновление одной строки-счётчика из многих транзакций сериализует их на этой строке, поэтому под высокой нагрузкой применяют шардированные счётчики (N строк с последующим суммированием) или асинхронную агрегацию.
Какой первичный ключ лучше - числовой или uuid?
Заголовок раздела «Какой первичный ключ лучше - числовой или uuid?»Коротко. По умолчанию — числовой bigint с identity/sequence: 8 байт против 16, монотонный рост даёт хорошую локальность в B-tree и меньше WAL. UUID берут, когда ID нужно генерировать на клиенте или в нескольких независимых узлах (шардинг, оффлайн-режим, слияние баз) либо когда ID не должен раскрывать порядок и количество записей.
Глубже. Механика проблемы UUID: случайный UUIDv4 попадает в произвольные страницы индекса, вставки идут в «случайный» лист, растёт число грязных страниц, падает эффективность кеша и распухает индекс из-за постоянных split’ов; при этом ID шире, а значит все вторичные индексы и FK тоже тяжелее. Решение — упорядоченные по времени идентификаторы: UUIDv7 (закреплён в RFC 9562, 2024) или ULID; они сохраняют «глобальную генерируемость», но возвращают монотонность и локальность вставок. В PostgreSQL 18 появилась встроенная функция uuidv7() (и uuidv4()); до этого использовали расширения (pgcrypto/gen_random_uuid() для v4, pg_uuidv7) или генерацию на стороне приложения (в Go — github.com/google/uuid, где есть uuid.NewV7()). Минусы числового ключа тоже надо назвать: он последовательный, значит перечислимый и утечка «сколько у вас заказов» — если ID уходит в публичный URL, стоит либо использовать UUID, либо отдельный внешний идентификатор. Гибридный вариант — bigint PK внутри и uuid/публичный slug как UNIQUE для внешнего мира.
Что происходит с таблицей при добавлении первичного ключа?
Заголовок раздела «Что происходит с таблицей при добавлении первичного ключа?»Коротко. В PostgreSQL ALTER TABLE ... ADD PRIMARY KEY берёт ACCESS EXCLUSIVE (полная блокировка таблицы), строит уникальный B-tree индекс полным сканом и проставляет колонкам NOT NULL; физического переупорядочивания heap не происходит. В MySQL/InnoDB, наоборот, PK — кластерный индекс, поэтому его добавление перестраивает таблицу целиком.
Глубже. Практические последствия для больших таблиц: на время построения индекса блокируются даже SELECT, поэтому в продакшене делают в два шага — CREATE UNIQUE INDEX CONCURRENTLY idx ... (долго, но не блокирует запись), затем ALTER TABLE ... ADD CONSTRAINT pkey PRIMARY KEY USING INDEX idx (короткая блокировка). Второй шаг всё равно потребует, чтобы колонки были NOT NULL; чтобы не сканировать таблицу под блокировкой, NOT NULL заранее «подготавливают» через ALTER TABLE ... ADD CONSTRAINT chk CHECK (id IS NOT NULL) NOT VALID + VALIDATE CONSTRAINT (в PostgreSQL 12+ валидный CHECK позволяет установить SET NOT NULL без полного скана). Если в колонке есть дубликаты или NULL, операция упадёт целиком — транзакционный DDL откатит всё. Дополнительно: появление PK меняет replica identity таблицы (логическая репликация начинает передавать ключ), даёт планировщику уникальность (возможны более дешёвые планы, unique index scan, join elimination) и добавляет постоянные накладные расходы на запись — поддержку ещё одного индекса.
Приведите плюсы и минусы практики применения FOREIGN KEY
Заголовок раздела «Приведите плюсы и минусы практики применения FOREIGN KEY»Коротко. Плюсы: гарантированная целостность независимо от приложения, самодокументируемая схема, каскадные действия, лучшие оценки планировщика. Минусы: накладные расходы и блокировки на записи, необходимость руками индексировать дочернюю колонку, боль при массовых загрузках, миграциях и шардировании.
Глубже. Плюсы подробнее: правило работает для любого клиента (второй сервис, ручной SQL, миграция), баги «осиротевших» строк отсекаются в момент возникновения, а не обнаруживаются через месяц в отчёте; ON DELETE CASCADE/SET NULL убирают ручную логику очистки; PostgreSQL 9.6+ использует FK для оценки селективности джойнов. Минусы подробнее: каждая вставка/обновление дочерней строки — дополнительный поиск в родителе и FOR KEY SHARE на родительской строке, что при «горячем» родителе (например, все заказы ссылаются на одну строку tenants) даёт конкуренцию, а при разном порядке операций — дедлоки; DELETE/UPDATE родителя без индекса на дочерней колонке превращается в seq scan; TRUNCATE требует CASCADE; ON DELETE CASCADE умеет неожиданно удалить полтаблицы; при шардировании и при миграции «выключим FK, зальём, включим» ограничения приходится снимать; некоторые схемы партиционирования и ETL-инструменты работают с FK хуже. Итоговая позиция: в OLTP FK держат почти всегда, а отключают точечно и осознанно — в аналитических/ETL-схемах, в очень горячих таблицах-логах и в шардированных установках, где целостность всё равно приходится обеспечивать на уровне приложения.
Что такое нормализация БД? Какие есть нормальные формы?
Заголовок раздела «Что такое нормализация БД? Какие есть нормальные формы?»Коротко. См. выше — дубль вопроса про нормализацию и нормальные формы. Кратко для повторения: 1NF (атомарность), 2NF (нет частичных зависимостей от составного ключа), 3NF (нет транзитивных зависимостей), BCNF (каждый детерминант — суперключ), далее 4NF/5NF.
Глубже. Отличие, которое стоит добавить именно в этой формулировке, — критерий «когда останавливаться». Ориентир: 3NF/BCNF для OLTP, потому что дальше выигрыш от устранения избыточности перестаёт компенсировать усложнение запросов; для аналитики намеренно уходят в денормализованные звезду/снежинку (Kimball), где факты и измерения устроены под сканирование и агрегаты, а не под точечные UPDATE. И ещё: декомпозиция должна быть lossless join — восстановление исходного отношения соединением не должно давать лишних строк; проверяется тем, что общий атрибут разбитых таблиц является ключом хотя бы в одной из них.
В каком формате хранить денежные средства?
Заголовок раздела «В каком формате хранить денежные средства?»Коротко. Либо numeric/DECIMAL с фиксированным масштабом (numeric(19,4)), либо целое число минимальных единиц (bigint — копейки/центы). Никогда float/double/real — двоичная плавающая точка не представляет 0.1 точно и накапливает ошибку. Валюту хранить отдельной колонкой (char(3) по ISO 4217).
Глубже. numeric в PostgreSQL — точная десятичная арифметика произвольной точности, медленнее целых, но безопаснее и удобнее для сумм с разным масштабом; масштаб 4 (а не 2) берут потому, что промежуточные величины — цены за единицу, курсы, доли налога — требуют больше знаков, чем итог. Целочисленные минорные единицы быстрее и компактнее, дают полную предсказуемость округления, но требуют явного знания exponent’а валюты (в JPY нет копеек, в BHD три знака) и аккуратности при делении (расщепление платежа обязано сохранять сумму до последней копейки). Тип money в PostgreSQL использовать не стоит: у него фиксированные 2 знака, вывод и парсинг зависят от lc_monetary, а сама валюта нигде не хранится. В Go: float64 для денег недопустим, используют int64 минорных единиц или github.com/shopspring/decimal; в pgx numeric естественно маппится на pgtype.Numeric/decimal.Decimal, а не на float64. Отдельно — правила округления (банковское против «от нуля») должны быть зафиксированы в одном месте, и для денежных операций почти всегда добавляют неизменяемый журнал проводок (double-entry ledger) вместо изменяемого поля balance.
Что такое партиционирование?
Заголовок раздела «Что такое партиционирование?»Коротко. Разбиение одной логической таблицы на несколько физических частей по ключу партиционирования внутри одной БД. Для приложения это одна таблица, для движка — набор таблиц-партиций, из которых планировщик выбирает только нужные (partition pruning).
Глубже. В PostgreSQL с версии 10 есть декларативное партиционирование: CREATE TABLE events (...) PARTITION BY RANGE (created_at); и партиции CREATE TABLE events_2026_08 PARTITION OF events FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');. До этого использовали наследование плюс CHECK-ограничения и триггеры — этот способ ещё встречается в старых системах. Что реально даёт партиционирование: (1) DETACH/DROP партиции вместо DELETE миллионов строк — мгновенно и без раздувания таблицы и без работы для VACUUM; (2) меньшие индексы на каждую часть — они лучше держатся в кеше; (3) pruning на этапе планирования и в рантайме (PostgreSQL 11+) — сканируется одна партиция вместо всей таблицы; (4) обслуживание по частям (VACUUM, REINDEX, разные tablespace для «холодных» данных). Чего оно не даёт: это не масштабирование за пределы сервера, и это не замена индексам. Ограничения, которые нужно знать: глобальных индексов нет — индекс на партиционированной таблице разворачивается в индексы на каждой партиции, а UNIQUE/PRIMARY KEY обязаны включать ключ партиционирования; запросы без ключа партиционирования идут по всем партициям; при большом числе партиций (сотни-тысячи) растёт время планирования; менять партиционирование существующей большой таблицы дорого, поэтому ключ выбирают заранее и по способу доступа/удаления данных.
В чем ключевое различие между шардированием и партиционированием?
Заголовок раздела «В чем ключевое различие между шардированием и партиционированием?»Коротко. Границей узла. Партиционирование — внутри одного инстанса (единая транзакция, FK, джойны работают), шардирование — между независимыми узлами (нужен координатор или логика в приложении, глобальных FK и простых распределённых транзакций нет).
Глубже. См. выше английский вариант вопроса. Дополнительно стоит проговорить, что «ключевое различие» тянет за собой разный набор проблем: у партиционирования — выбор ключа, число партиций и время планирования; у шардирования — маршрутизация запросов, глобальные уникальные ID, распределённые транзакции (2PC/saga/outbox), решардинг и rebalance, а также «горячие» шарды при неудачном ключе. И что технически шардирование часто реализуют тем же партиционированием, только «наружу»: Citus превращает таблицу в набор шардов на воркерах, FDW-схемы делают партиции внешними таблицами (postgres_fdw + партиционирование = ручной шардинг), а в MySQL-мире эту роль играет Vitess.
Использовали ли вы шардирование или партиционирование в своих проектах?
Заголовок раздела «Использовали ли вы шардирование или партиционирование в своих проектах?»Коротко. Вопрос про личный опыт: интервьюер проверяет, отличаешь ли ты «читал» от «делал», и понимаешь ли, откуда взялась потребность. Отвечать надо конкретной историей: какая была метрика проблемы, какое решение выбрано, какой ключ и почему, что пошло не так.
Глубже. Каркас ответа: (1) контекст и числа — «таблица событий росла на ~50 млн строк в месяц, DELETE старых данных не успевал и раздувал таблицу, autovacuum не догонял»; (2) что рассматривали — архивная таблица, pg_partman, ручное партиционирование; (3) решение — RANGE-партиционирование по месяцу, ключ выбран потому, что 95% запросов идут по диапазону времени, а retention — «хранить год»; (4) как внедряли без простоя — создали новую партиционированную таблицу, перелили данные батчами, переключили через ATTACH/переименование, или использовали pg_partman с автосозданием партиций; (5) результат в числах — удаление старых данных стало DROP TABLE за миллисекунды, размер горячего индекса упал в 12 раз, p99 отчётных запросов улучшился; (6) что оказалось неприятным — запросы без ключа партиционирования, UNIQUE пришлось расширить ключом партиционирования, выросло время планирования, понадобился крон на создание будущих партиций. Если реального опыта нет, честнее сказать «партиционирование делал, шардирования на уровне БД не было — масштабировали репликами чтения и разделением по сервисам», и затем разобрать, при каких признаках вы бы пошли в шардинг. Типичные ошибки: заявить «шардировали» и не назвать ключ шардирования; путать шардинг с партиционированием; не помнить чисел.
Расскажите об брокере Kafka. Какие основные концепции вы знаете? Как обеспечиваются гарантии доставки сообщений? Что такое партиционирование и как оно работает?
Заголовок раздела «Расскажите об брокере Kafka. Какие основные концепции вы знаете? Как обеспечиваются гарантии доставки сообщений? Что такое партиционирование и как оно работает?»Коротко. Kafka — распределённый лог: топики делятся на партиции, каждая партиция — упорядоченный append-only лог с монотонными offset’ами, реплицированный на несколько брокеров (лидер + follower’ы, ISR). Продюсер пишет в партицию (по хешу ключа или по round-robin), консьюмеры внутри группы делят партиции между собой и хранят собственные offset’ы. Гарантии складываются из настроек продюсера (acks, идемпотентность, транзакции), топика (min.insync.replicas, RF) и порядка коммита offset’а у консьюмера.
Глубже. Основные концепции, которые ждут услышать: broker, topic, partition, offset, replication factor, leader/follower и ISR, producer, consumer, consumer group и group coordinator, rebalance, retention (по времени/размеру) и compaction (cleanup.policy=compact — хранить последнее значение на ключ), __consumer_offsets, KRaft вместо ZooKeeper (ZooKeeper удалён в Kafka 4.0). Партиционирование в Kafka решает две задачи: параллелизм (число партиций — верхняя граница числа полезных консьюмеров в группе) и порядок (упорядоченность гарантируется только внутри партиции, поэтому все сообщения одного ключа — например, order_id — должны идти в одну партицию; ключ → партиция считается через хеш ключа по модулю числа партиций). Отсюда следствие: увеличение числа партиций меняет отображение ключей и рвёт порядок для уже существующих ключей, а уменьшить число партиций нельзя вовсе. Про гарантии: at-most-once (коммит offset’а до обработки), at-least-once (коммит после обработки — практический дефолт, требует идемпотентного обработчика), exactly-once внутри Kafka (идемпотентный продюсер + транзакции + read_committed); при интеграции с БД вместо EOS применяют outbox/inbox с дедупликацией по ключу сообщения. Также важно: сохранение порядка на продюсере требует либо max.in.flight.requests.per.connection=1, либо включённой идемпотентности (с ней порядок сохраняется до 5 in-flight запросов).
Что вы можете рассказать про шардирование, репликацию и партиционирование? В чем их различия и когда что применять?
Заголовок раздела «Что вы можете рассказать про шардирование, репликацию и партиционирование? В чем их различия и когда что применять?»Коротко. См. выше про три оси. Когда что применять: партиционирование — когда одна таблица стала неуправляемой (retention, размер индексов, обслуживание) при том, что сервер справляется; репликация — когда нужна отказоустойчивость, резерв и масштабирование чтения; шардирование — когда упёрлись в запись, объём или лимиты одного узла и другие способы исчерпаны.
Глубже. Порядок действий, который стоит озвучить как инженерный: сначала запросы и индексы, потом вертикальное масштабирование и пул соединений (pgbouncer), потом реплики чтения и вынос аналитики, потом партиционирование и retention, и только потом шардирование — потому что оно необратимо усложняет всё остальное. Для репликации в PostgreSQL полезно различать физическую (streaming WAL, вся кластерная копия, реплика read-only, инструменты Patroni, repmgr) и логическую (CREATE PUBLICATION/SUBSCRIPTION, выборочные таблицы, разные версии, возможность записи на подписчике; с PostgreSQL 16 логическую репликацию можно вести и со standby). Для шардирования — стратегии по ключу: hash (равномерно, но диапазонные запросы идут во все шарды), range (диапазоны эффективны, но легко получить горячий шард на «последнем» диапазоне), directory/lookup (гибко, но нужен доступный и консистентный каталог), geo/tenant (естественно для мультиарендных систем и требований локальности данных). И обязательно про то, что комбинируется: шардируем по tenant_id, внутри шарда партиционируем по времени, каждый шард имеет синхронную и асинхронную реплики.
Расскажите про Apache Kafka: producer, consumer, partition, consumer group, offset. Что такое партиции и для чего они нужны? Можно ли уменьшить или увеличить количество партиций? Какие виды гарантий доставки сообщений существуют? Что такое Dead Letter Queue?
Заголовок раздела «Расскажите про Apache Kafka: producer, consumer, partition, consumer group, offset. Что такое партиции и для чего они нужны? Можно ли уменьшить или увеличить количество партиций? Какие виды гарантий доставки сообщений существуют? Что такое Dead Letter Queue?»Коротко. Producer пишет сообщения в партиции топика, partition — упорядоченный лог, offset — позиция сообщения в партиции, consumer читает и коммитит offset, consumer group — набор консьюмеров, между которыми партиции делятся без перекрытия. Партиции нужны для параллелизма и для гарантии порядка внутри ключа. Число партиций можно только увеличить, уменьшить нельзя. Гарантии: at-most-once, at-least-once, exactly-once. DLQ — отдельный топик, куда складывают сообщения, которые не удалось обработать после ретраев.
Глубже. Про увеличение партиций: kafka-topics --alter --partitions N добавляет партиции, но не перераспределяет уже записанные данные и меняет результат hash(key) % N, из-за чего сообщения одного ключа после изменения могут попасть в другую партицию — порядок по ключу «на стыке» ломается, а компактируемые топики от этого страдают особенно сильно; поэтому число партиций планируют заранее с запасом (ориентир — целевая пропускная способность и максимальное число консьюмеров в группе). Уменьшение не поддерживается, потому что пришлось бы удалять или сливать логи с уже выданными offset’ами; на практике создают новый топик и переливают данные. Про DLQ: в самом брокере такого понятия нет — это прикладной паттерн. В Kafka Connect он встроен (errors.tolerance=all, errors.deadletterqueue.topic.name), в собственных консьюмерах DLQ делают руками: N ретраев с backoff, затем публикация в <topic>.DLQ с исходным payload и заголовками (причина, стек, число попыток, оригинальные topic/partition/offset), плюс отдельный процесс/ручка для разбора и переигрывания. Ключевые вопросы дизайна DLQ: не блокировать партицию «отравленным» сообщением (иначе застревает вся партиция), сохранить достаточно контекста для replay и следить за размером DLQ как за метрикой качества.
Делали ли вы миграции схемы БД? Каким инструментом? Как откатывали миграцию при ошибке?
Заголовок раздела «Делали ли вы миграции схемы БД? Каким инструментом? Как откатывали миграцию при ошибке?»Коротко. Вопрос про опыт. Ждут: инструмент, порядок применения (версионированные файлы в репозитории, применение в CI/CD до или вместе с деплоем), и главное — понимание, что «откат» в продакшене чаще делается не down-миграцией, а совместимым forward-fix.
Глубже. Каркас ответа. Инструменты в Go-мире: golang-migrate, pressly/goose, atlas, реже sql-migrate; в JVM-мире Flyway/Liquibase. Что стоит сказать про механику: каждая миграция — пара up/down (или только up при forward-only политике), версии хранятся в служебной таблице (schema_migrations), применение под advisory lock, чтобы два пода не мигрировали одновременно. Про откат честный ответ звучит так: DDL в PostgreSQL транзакционен, поэтому упавшая миграция откатывается сама (исключения — CREATE INDEX CONCURRENTLY, VACUUM, CREATE DATABASE: они не работают в транзакции и при падении оставляют мусор — например, невалидный индекс, который нужно найти через pg_index.indisvalid и удалить); но откат уже применённой и отработавшей миграции опасен, если она теряет данные (DROP COLUMN, DROP TABLE) — данные down-скриптом не восстановить. Отсюда правило: разрушающие шаги выделяют в отдельную поздднюю миграцию (contract-фаза) и применяют, когда старый код гарантированно выведен; всё остальное делают обратно совместимым, чтобы откат приложения не требовал откатa схемы. Хорошо упомянуть lock_timeout/statement_timeout в миграциях, разбиение бэкфилла на батчи вне транзакции миграции, тестирование миграций на копии продовых данных и линтеры вроде squawk/atlas lint. Типичные ошибки на собесе: сказать «откатили down-миграцией» без оговорок про потерю данных; не знать, что PostgreSQL умеет транзакционный DDL; не упоминать блокировки.
Как вы управляете изменениями схемы в zero-downtime деплое?
Заголовок раздела «Как вы управляете изменениями схемы в zero-downtime деплое?»Коротко. Паттерн expand → migrate → contract: сначала только совместимые изменения (новая nullable-колонка, новый индекс CONCURRENTLY, новая таблица), затем бэкфилл батчами и деплой кода, который умеет читать и писать оба варианта, затем переключение чтения, и только в отдельном позднем релизе удаление старого. Всё DDL — с lock_timeout и ретраями, чтобы миграция не собрала за собой очередь.
Глубже. Что важно знать про стоимость операций в PostgreSQL: ADD COLUMN с константным DEFAULT не перезаписывает таблицу с версии 11 (значение хранится в каталоге), а ADD COLUMN ... DEFAULT <volatile> — перезаписывает; DROP COLUMN мгновенен (колонка помечается удалённой, место освобождается постепенно); увеличение длины varchar(n) и varchar(n) → text не требуют rewrite, а смена типа с изменением представления (int → bigint) требует полной перезаписи с ACCESS EXCLUSIVE; SET NOT NULL в PostgreSQL 12+ можно сделать дёшево, если заранее есть валидный CHECK (col IS NOT NULL); ADD FOREIGN KEY делают в два шага NOT VALID + VALIDATE CONSTRAINT; индексы — только CREATE INDEX CONCURRENTLY/DROP INDEX CONCURRENTLY. Обязательно упомянуть про очередь блокировок: даже быстрый ALTER TABLE, ожидающий ACCESS EXCLUSIVE, блокирует все последующие запросы к таблице, поэтому ставят SET lock_timeout = '2s' и повторяют попытку, а не ждут бесконечно. Переименование колонки/таблицы «на живом» не делают — вместо этого добавляют новое имя и синхронизируют (двойная запись из кода или триггер), а старое удаляют потом; для смены int → bigint на большой таблице тот же приём: новая колонка, триггер синхронизации, бэкфилл батчами, переключение. Для больших перезаписей и «дефрагментации» без длинной блокировки — pg_repack. И общее правило: код должен переживать оба состояния схемы, потому что во время rolling-деплоя старые и новые поды работают одновременно.
Что такое шардирование? Какие виды шардирования ты знаешь? Партиционирование. Репликация
Заголовок раздела «Что такое шардирование? Какие виды шардирования ты знаешь? Партиционирование. Репликация»Коротко. Шардирование — горизонтальное деление данных по независимым узлам, каждый хранит свой поднабор строк. Виды по способу выбора шарда: hash (по хешу ключа), range (по диапазонам), directory/lookup (через каталог соответствия), geo/tenant-based (по региону или арендатору). Партиционирование — то же деление, но внутри одного сервера; репликация — копии одних и тех же данных на разных узлах.
Глубже. Свойства видов: hash — равномерное распределение и простая маршрутизация, но диапазонные и «все данные по префиксу» запросы разлетаются по всем шардам, а добавление узла требует перераспределения (лечится виртуальными шардами: делим на 1024 логических шарда и раскладываем их по физическим узлам, при добавлении узла перемещаем часть логических); range — эффективные диапазонные запросы и простое добавление узла «сверху», но горячая точка на свежем диапазоне (все записи идут в последний шард); directory — максимальная гибкость и возможность точечно переселять арендаторов, ценой доступности и консистентности каталога (кешируется, но становится точкой отказа); geo/tenant — естественно для мультиарендности и требований локальности данных, но арендаторы бывают очень разного размера, отсюда перекос. Ещё различают шардирование на уровне приложения (маршрутизация в коде/библиотеке), прокси (Vitess, ProxySQL, pgcat) и расширения БД (Citus). Обязательные темы при шардировании: глобально уникальные ID (UUIDv7/Snowflake/выделенные диапазоны sequence), отсутствие кросс-шардовых FK и джойнов, распределённые транзакции (2PC, saga, outbox), решардинг без простоя, backfill и двойная запись при переезде, наблюдаемость по шардам.
Для чего нужны view (представления) в базе данных?
Заголовок раздела «Для чего нужны view (представления) в базе данных?»Коротко. View — сохранённый запрос, который используется как таблица. Нужен для инкапсуляции сложной логики, стабильного контракта для клиентов при меняющейся физической схеме, ограничения доступа (показать только часть колонок/строк) и переиспользования кода запросов.
Глубже. Обычный view не хранит данные — при обращении его определение подставляется в запрос, поэтому он не ускоряет ничего сам по себе (частая ошибка на собесе — сказать, что view кеширует результат). Простые view в PostgreSQL автоматически обновляемые (INSERT/UPDATE/DELETE работают, если это один источник без агрегатов и DISTINCT; PostgreSQL 9.3+), для сложных пишут INSTEAD OF триггеры или RULE. WITH CHECK OPTION запрещает через view вставлять строки, которые в него потом не попадут. Для разграничения доступа важен security_barrier (иначе планировщик может поднять пользовательскую функцию из внешнего предиката выше фильтра view и «подсмотреть» скрытые строки), а с PostgreSQL 15 есть security_invoker view — проверка прав от имени вызывающего, что удобно вместе с RLS. Отдельно материализованные представления (MATERIALIZED VIEW): они хранят результат физически, их можно индексировать, но данные нужно обновлять (REFRESH MATERIALIZED VIEW [CONCURRENTLY], где CONCURRENTLY требует уникального индекса и не блокирует чтение) — то есть это уже денормализация с явным владельцем обновления. Полезный практический сценарий: view как слой совместимости при zero-downtime миграции — переименовали таблицу/колонку, а старое имя оставили как view.
Что такое primary key? (unique + not null)
Заголовок раздела «Что такое primary key? (unique + not null)»Коротко. Да, по сути PRIMARY KEY = UNIQUE + NOT NULL плюс роль «главного» ключа: один на таблицу, автоматически создаётся уникальный индекс, на него по умолчанию ссылаются внешние ключи и он используется как replica identity.
Глубже. Разница между PRIMARY KEY и UNIQUE NOT NULL не в проверках, а в метаданных и соглашениях: PK единственный, помечен в каталоге (pg_constraint.contype = 'p'), берётся по умолчанию в REFERENCES parent без указания колонки, используется ORM и логической репликацией. Тонкость про UNIQUE без NOT NULL: в стандартном SQL и в PostgreSQL NULL не равен NULL, поэтому несколько строк с NULL в уникальной колонке допустимы — именно поэтому NOT NULL в PK принципиален. С PostgreSQL 15 это поведение можно изменить: UNIQUE NULLS NOT DISTINCT считает NULL-ы равными.
Какое поле обычно делают PK? (serial или uuid)
Заголовок раздела «Какое поле обычно делают PK? (serial или uuid)»Коротко. Чаще всего — суррогатный автоинкрементный bigint; в современном PostgreSQL правильнее GENERATED BY DEFAULT AS IDENTITY, а не устаревший serial. UUID (лучше упорядоченный v7) берут, когда ID генерируется вне БД или на нескольких узлах.
Глубже. Почему identity вместо serial: serial — не тип, а макрос, который создаёт sequence и DEFAULT nextval(...); из этого вытекают неудобства — права на последовательность нужно выдавать отдельно, связь «колонка ↔ sequence» держится через owned-by и легко теряется при CREATE TABLE ... LIKE/CREATE TABLE AS, значение можно перезаписать вручную и «уехать» от последовательности. GENERATED ... AS IDENTITY (PostgreSQL 10+, часть стандарта SQL) описывается в каталоге как свойство колонки, вариант ALWAYS защищает от случайной ручной вставки (обойти можно через OVERRIDING SYSTEM VALUE). Про разрядность: int (2.1 млрд) экономит 4 байта, но исчерпание int4 PK на растущей таблице — известная авария с дорогой миграцией, поэтому по умолчанию bigint. Отдельно стоит помнить, что последовательности не транзакционны: при откате транзакции номер не возвращается, поэтому в ID будут «дырки», и использовать PK как «номер документа без пропусков» нельзя — бизнес-нумерацию делают отдельной таблицей-счётчиком.
Что такое внешний ключ? Сослаться можно на любое поле или нет (кроме pk)? (в пг - нет, в мускуле - дa)
Заголовок раздела «Что такое внешний ключ? Сослаться можно на любое поле или нет (кроме pk)? (в пг - нет, в мускуле - дa)»Коротко. Внешний ключ — ограничение, требующее, чтобы значение существовало в родительской таблице. В PostgreSQL ссылаться можно не только на PK, но обязательно на колонки с уникальным ограничением/уникальным индексом — на произвольную неуникальную колонку нельзя. MySQL/InnoDB здесь мягче: он требует лишь наличия индекса на referenced-колонке и допускает неуникальный, что отклоняется от стандарта.
Глубже. Формулировка для PostgreSQL точная: REFERENCES parent(col) требует, чтобы на col (или на весь набор колонок) существовало PRIMARY KEY либо UNIQUE — иначе ошибка there is no unique constraint matching given keys for referenced table. Причина логичная: если ссылка может указывать на несколько строк, семантика ON DELETE/ON UPDATE и сама проверка перестают быть определёнными. Частичные уникальные индексы (UNIQUE INDEX ... WHERE) и индексы по выражениям для FK не подходят — нужно полноценное уникальное ограничение по колонкам. В MySQL InnoDB ссылка на неуникальный индекс формально работает, но это источник неопределённого поведения при каскадах, и полагаться на это не стоит; кроме того, в MySQL FK молча игнорировался движком MyISAM — исторический источник «FK объявлен, а целостности нет». Дополнительно про PostgreSQL: FK может быть составным, может ссылаться на ту же таблицу (self-reference), с версии 12 может ссылаться на партиционированную таблицу, и в него можно добавить MATCH FULL/MATCH SIMPLE (по умолчанию SIMPLE: если хотя бы одна колонка составного FK равна NULL, проверка не выполняется).
Что такое партиционирование, для чего нужно, какие виды?
Заголовок раздела «Что такое партиционирование, для чего нужно, какие виды?»Коротко. См. выше про партиционирование. Виды в PostgreSQL: RANGE (по диапазонам — чаще всего по дате), LIST (по перечислению значений — регион, статус, тип), HASH (по остатку хеша — равномерное распределение). Плюс исторический способ через наследование таблиц.
Глубже. Как выбирать вид: RANGE по времени — дефолт для событий, логов, метрик, где есть retention и запросы по интервалам; LIST — когда есть небольшой стабильный набор дискретных значений, по которым данные и запрашиваются, и удаляются (например, country_code или tenant_id для крупных арендаторов); HASH — когда нужно просто разложить большую таблицу равномерно (нет естественного диапазона, цель — уменьшить размер индексов и распараллелить обслуживание), но pruning тогда работает только при точном равенстве по ключу. Полезно упомянуть DEFAULT-партицию (ловит значения, не попавшие ни в один диапазон — без неё вставка «в будущее» упадёт; с ней добавление новой партиции требует проверки данных в default), ATTACH PARTITION c заранее созданным CHECK, чтобы не сканировать таблицу под сильной блокировкой, DETACH PARTITION CONCURRENTLY (PostgreSQL 14+), enable_partitionwise_join/enable_partitionwise_aggregate (по умолчанию выключены) и pg_partman для автоматического создания и удаления партиций.
Можно ли партиционировать партицию?
Заголовок раздела «Можно ли партиционировать партицию?»Коротко. Да, PostgreSQL поддерживает вложенное партиционирование (sub-partitioning): партиция сама может быть объявлена PARTITION BY с другим ключом — например, RANGE по месяцу, внутри HASH по tenant_id.
Глубже. Синтаксически это CREATE TABLE events_2026_08 PARTITION OF events FOR VALUES FROM (...) TO (...) PARTITION BY HASH (tenant_id); и далее создание листовых партиций. Данные физически лежат только в листьях; промежуточные уровни — «пустые» контейнеры-каталоги. Практический совет: вкладывать стоит только при реальной необходимости, потому что общее число листовых партиций растёт как произведение уровней, а с ним растут время планирования, число файлов, количество объектов для autovacuum и объём метаданных; кроме того, UNIQUE/PK обязан включать ключи партиционирования всех уровней, что делает ключи широкими. Уточнение на всякий случай: обычный CHECK-набор партиции менять нельзя произвольно, и «перепартиционировать» существующую партицию на месте нельзя — её отсоединяют (DETACH), пересоздают в нужном виде и переливают данные.
Как можно партиционировать, какие виды?
Заголовок раздела «Как можно партиционировать, какие виды?»Коротко. См. выше: RANGE, LIST, HASH (декларативно, PostgreSQL 10+), плюс исторический подход через наследование и триггеры. Различают также горизонтальное партиционирование (по строкам — это всё вышеперечисленное) и вертикальное (вынос редко используемых или широких колонок в отдельную таблицу 1:1).
Глубже. Отличие от предыдущей формулировки — стоит добавить про вертикальное разделение и про то, что PostgreSQL уже делает часть этой работы сам: длинные значения автоматически уходят в TOAST-таблицу, поэтому «вынести blob-колонку, чтобы ускорить seq scan» иногда бессмысленно, а иногда полезно (если колонка короче порога TOAST ~2 КБ, но при этом раздувает каждую строку). Ещё один вид, который любят упоминать — партиционирование по типу нагрузки: горячие партиции на быстрых диcках, холодные — в отдельный TABLESPACE на дешёвом хранилище или во внешнюю таблицу через postgres_fdw/file_fdw.
Что такое нормализованная форма?
Заголовок раздела «Что такое нормализованная форма?»Коротко. Нормальная форма — это набор формальных требований к отношению; таблица «в N-й нормальной форме», если удовлетворяет требованиям N-го уровня (и всех предыдущих). Требования формулируются через функциональные зависимости: 1NF — атомарность значений, 2NF — нет частичных зависимостей от составного ключа, 3NF — нет транзитивных, BCNF — каждый детерминант является суперключом.
Глубже. Формулировка «нормализованная форма» в вопросе, скорее всего, оговорка от «нормальная форма»; отвечать надо про нормальные формы. Полезно уметь проверять форму механически: выписать функциональные зависимости, найти ключи-кандидаты, и посмотреть, есть ли зависимость вида «часть ключа → неключевой атрибут» (нарушение 2NF) или «неключевой → неключевой» (нарушение 3NF). И удобная короткая формула Кодда для 3NF: «каждый неключевой атрибут зависит от ключа, от всего ключа и ни от чего кроме ключа».
Нормализованная и денормализованная будет меньше занимает место на диске?
Заголовок раздела «Нормализованная и денормализованная будет меньше занимает место на диске?»Коротко. Как правило меньше места занимает нормализованная схема — она не хранит один и тот же факт много раз, длинные строки заменяются на короткие FK. Но это не абсолютное правило: нормализация добавляет таблицы, суррогатные ключи и индексы под FK, а у каждой таблицы и каждой строки есть накладные расходы.
Глубже. Конкретика для PostgreSQL: у каждой строки есть 23-байтовый заголовок кортежа плюс выравнивание, у каждого индекса — свои страницы, у каждого FK — обязательный (на практике) индекс на дочерней колонке. Поэтому вынос атрибута из 5-миллионной таблицы в справочник из трёх строк почти всегда экономит (было text 'В обработке' в каждой строке — стало smallint), а вот разбиение на два отношения 1:1 без нужды может даже увеличить объём: появятся второй заголовок кортежа, второй PK-индекс и FK-индекс. Дополнительные факторы, которые смещают ответ: TOAST-сжатие делает дублирование длинных текстов дешевле, чем кажется; денормализованные колонки в широкой таблице ухудшают не столько объём, сколько эффективность кеша (на страницу влезает меньше строк) и стоимость UPDATE (в PostgreSQL UPDATE создаёт новую версию всей строки, а HOT-обновление возможно только если не задеты индексируемые колонки); частичное/выражение-индексирование и fillfactor тоже меняют картину. Хороший ответ поэтому звучит так: «нормализованная обычно меньше, потому что нет дублей, но экономия — не основная цель нормализации, основная — отсутствие аномалий; а место надо мерить pg_total_relation_size на реальных данных, потому что вклад индексов и накладных расходов на строку часто больше вклада самих дублей».
Для чего нужна нормализация в реляционных БД?
Заголовок раздела «Для чего нужна нормализация в реляционных БД?»Коротко. Чтобы каждый факт хранился в одном месте, и невозможные/противоположные состояния данных были исключены структурно: без дублей нет аномалий вставки, обновления и удаления. Побочные эффекты — меньше места, понятнее схема, проще менять правила предметной области.
Глубже. См. выше про нормализацию. Стоит добавить прикладной аргумент: нормализованная схема ставит целостность на уровень БД, а не на уровень «все разработчики помнят, что при переименовании клиента надо обновить ещё три таблицы». Это особенно важно, когда с базой работает больше одного сервиса и когда живут долгоживущие данные — код перепишут, а данные останутся. И честная оговорка про границу применимости: для аналитических хранилищ и read-моделей нормализация не самоцель, там сознательно строят денормализованные схемы под сканирование.
Прием заказов: Система должна принимать заказы от различных внешних источников; Формат заказов может отличаться, требуется нормализация входящих данных.
Заголовок раздела «Прием заказов: Система должна принимать заказы от различных внешних источников; Формат заказов может отличаться, требуется нормализация входящих данных.»Коротко. Это фрагмент системного задания. Стандартное решение — два слоя: staging (сырой payload как пришёл, jsonb + метаданные источника + идемпотентный ключ) и канонический нормализованный слой (orders, order_items, справочники), между ними — адаптер на каждый источник с версионируемым маппингом и валидацией.
Глубже. Что важно проговорить в дизайне. (1) Сырое сохранять всегда и неизменно: raw_orders(id, source, external_id, received_at, payload jsonb, signature, status, error) с UNIQUE (source, external_id) — это и идемпотентность при повторной доставке, и возможность переиграть обработку после исправления бага, и доказательство «что нам реально прислали». (2) Идемпотентность обязательна, потому что внешние источники ретраят: ключ — их external_id (или хеш payload’а, если ID нет), вставка через INSERT ... ON CONFLICT DO NOTHING. (3) Адаптеры (по одному на источник/версию формата) переводят их модель в каноническую: нормализуют единицы (деньги в минорных единицах + ISO 4217, время в timestamptz из их таймзоны, телефоны в E.164, страны в ISO 3166), приводят справочные значения через таблицы соответствия source_value → canonical_id, а не через switch в коде. (4) Канонический слой — нормализованный: orders(id, source, external_id UNIQUE, customer_id, status, currency, total_minor, created_at), order_items(order_id, sku, qty, price_minor), справочники товаров и статусов; неотображённые поля источника остаются в extra jsonb рядом, чтобы ничего не терять. (5) Ошибки: невалидный заказ не должен блокировать поток — он получает статус rejected с причиной и попадает в очередь разбора (аналог DLQ), метрика «доля отказов по источнику» становится показателем качества интеграции. (6) Изменения формата: версия маппинга хранится вместе с записью, чтобы можно было переиграть старые заказы новым адаптером. (7) Статус заказа — не одно поле «последнее значение», а журнал переходов (order_events), если требуется восстанавливать историю. На этом же вопросе часто спрашивают про транзакционность: заказ и его позиции пишутся в одной транзакции, публикация события наружу — через outbox в той же транзакции, чтобы не получить «заказ есть, события нет».
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Путать партиционирование, шардирование и репликацию: называть партиционирование «шардингом внутри базы» без уточнения, что границей является узел, и не понимать, что репликация не увеличивает ёмкость записи.
- Утверждать, что PostgreSQL сам создаёт индекс на колонке внешнего ключа. Индекс на родительской (уникальной) стороне создаётся автоматически, на ссылающейся — нет.
- Говорить, что
PRIMARY KEY«физически упорядочивает таблицу». Это верно для InnoDB (кластерный индекс), но не для PostgreSQL, где heap не упорядочен, аCLUSTER— одноразовая операция. - Считать, что обычный
VIEWкеширует результат или ускоряет запрос. КешируетMATERIALIZED VIEW, и его надо обновлять вручную. - Хранить деньги в
float/double(или в PostgreSQL-типеmoney) и не хранить валюту отдельной колонкой. - Говорить «денормализовали для скорости» без ответа на вопрос, кто и когда пересчитывает копию и как обнаруживается расхождение.
- Считать, что число партиций в Kafka можно уменьшить, или что увеличение партиций безопасно для порядка по ключу.
- Обещать «exactly-once» в Kafka как универсальную гарантию, не оговаривая, что транзакции работают внутри Kafka, а побочные эффекты во внешней БД требуют идемпотентности или outbox.
- В зачёт zero-downtime миграции называть только «CREATE INDEX CONCURRENTLY», не упоминая, что любой
ALTER TABLE, ждущийACCESS EXCLUSIVE, выстраивает за собой очередь всех запросов — и потому нуженlock_timeout. - Использовать
jsonbтам, где набор полей известен, теряя типы,NOT NULL, внешние ключи и адекватные оценки селективности. - Считать
UNIQUEполной заменойPRIMARY KEY, забывая, чтоNULL-ы в уникальной колонке по умолчанию не конфликтуют между собой.
Что почитать
Заголовок раздела «Что почитать»- PostgreSQL docs: DDL — Constraints и Table Partitioning — первоисточник про ключи, FK и все виды партиционирования с ограничениями.
- PostgreSQL docs: JSON Types и JSON Functions and Operators —
jsonb, индексы GIN, jsonpath. - PostgreSQL docs: ALTER TABLE (раздел Notes про блокировки и перезапись) и Explicit Locking — база для безопасных миграций.
- Kafka Documentation: Design и Producer/Consumer Configs —
acks,min.insync.replicas, ISR, идемпотентность и транзакции. - Martin Kleppmann, «Designing Data-Intensive Applications», гл. 5–6 — репликация и партиционирование/шардирование с разбором компромиссов.