Проектирование и эксплуатация
Как спроектировать схему, которая переживёт годы роста, и как эксплуатировать базу под нагрузкой: нормальные формы и ключи, загрузки и отчёты, партиционирование и изменение схемы на живой системе. На уровне Senior это разговор о компромиссах и рисках, а не о синтаксисе.
Перед этой темой: Индексы и планы запросов, Транзакции и блокировки.
Что такое нормализация и когда оправдана денормализация?
Заголовок раздела «Что такое нормализация и когда оправдана денормализация?»Что это
Нормализация — проектирование таблиц так, чтобы один факт хранился в одном месте. Название магазина лежит в таблице Store, а продажа ссылается на него по StoreId.
Нормальные формы
- 1НФ. В ячейке одно значение, нет повторяющихся групп: не колонки
Phone1,Phone2,Phone3, а отдельная таблица телефонов. - 2НФ. При составном ключе колонка зависит от всего ключа. В строке заказа
(OrderId, ProductId)название товара зависит только отProductId— ему место вProduct. - 3НФ. Неключевая колонка не зависит от другой неключевой. Адрес магазина зависит от магазина, а не от продажи.
Частая ошибка
Хранить список через запятую в одной колонке: 'A12,B7,C3'. Такой список не проиндексировать, а поиск превращается в LIKE '%B7%'.
Как устроено
Нормальные формы убирают три аномалии:
- вставки — нельзя завести магазин, пока у него нет продаж;
- обновления — адрес магазина надо менять в тысячах строк, и часть копий останется старой;
- удаления — удалили последнюю продажу и потеряли данные о магазине.
| Форма | Требование |
|---|---|
| 1НФ | атомарные значения, нет повторяющихся групп |
| 2НФ | неключевые колонки зависят от всего составного ключа |
| 3НФ | неключевые колонки не зависят друг от друга |
| BCNF | любой детерминант — потенциальный ключ |
Когда что выбирать
- Транзакционная база — 3НФ. Запись дешёвая, данные непротиворечивы, чтение ускоряют индексы.
- Денормализация — когда измерено, что соединения или агрегаты на горячем пути дороги: итог в шапке чека, остаток товара, отчётная таблица.
Ловушки
- История — не дубль. Цена в строке чека — цена на момент продажи, отдельный факт. Ссылка на текущую цену из справочника исказила бы старые чеки.
- Копия без владельца. Денормализованную колонку меняют из нескольких мест, и она расходится с источником.
Правило
Сначала 3НФ, денормализация — по измерениям и с явным механизмом синхронизации.
Под капотом
- Функциональная зависимость
X → Y: значениеXоднозначно определяетY. 3НФ допускает зависимость, еслиYвходит в потенциальный ключ; BCNF запрещает и её. - Расхождение 3НФ и BCNF бывает только при нескольких пересекающихся составных ключах, поэтому на практике таблица в 3НФ почти всегда уже в BCNF.
Цена денормализации
| Способ | Кто синхронизирует | Цена |
|---|---|---|
Вычисляемая колонка PERSISTED |
движок | только выражения по своей строке |
| Индексированное представление | движок, в той же транзакции | дороже запись в базовые таблицы, много ограничений |
| Колонка-агрегат: итог, остаток | код или триггер | горячая строка, риск расхождения |
| Отчётная таблица | загрузка по расписанию | данные отстают |
Компромиссы
- Денормализация переносит стоимость с чтения на запись. Остаток товара в одной строке становится точкой конкуренции: все продажи товара обновляют её.
- Отчётные базы денормализуют намеренно: их пишет одна загрузка, а читают многие запросы.
- Перебор тоже вреден. Модель EAV («сущность — атрибут — значение») ради гибкости теряет типы и ограничения, а каждый атрибут превращается в соединение.
Как проверить в проде
- Регулярная сверка: агрегат против пересчёта из исходных строк.
- Горячая строка-агрегат видна по ожиданиям блокировок
LCK_M_UиLCK_M_Xна одном ключе.
Итог: нормализуют ради дешёвой и непротиворечивой записи; денормализуют точечно, когда ясно, кто и как синхронизирует копию.
-- До: название и адрес магазина повторяются в каждой продаже (нарушение 3НФ)CREATE TABLE dbo.SaleFlat ( SaleId bigint NOT NULL PRIMARY KEY, StoreId int NOT NULL, StoreName nvarchar(100) NOT NULL, StoreAddress nvarchar(200) NOT NULL, Amount decimal(12,2) NOT NULL);
-- После: факт о магазине хранится один разCREATE TABLE dbo.Store ( StoreId int NOT NULL PRIMARY KEY, Name nvarchar(100) NOT NULL, Address nvarchar(200) NOT NULL);
CREATE TABLE dbo.Sale ( SaleId bigint NOT NULL PRIMARY KEY, StoreId int NOT NULL REFERENCES dbo.Store (StoreId), SoldAt datetime2(0) NOT NULL, Amount decimal(12,2) NOT NULL);
-- Переезд магазина: одна строка вместо тысячUPDATE dbo.Store SET Address = N'New address' WHERE StoreId = 42;- Чем 3НФ отличается от BCNF? — BCNF требует, чтобы любой детерминант был потенциальным ключом; 3НФ допускает зависимость, если зависимая колонка входит в ключ.
- Нарушает ли нормализацию цена в строке чека? — Нет, это цена на момент продажи, отдельный исторический факт.
- Как денормализовать, не теряя согласованности? — Отдать синхронизацию движку (вычисляемая колонка, индексированное представление); иначе — один владелец копии и сверка.
- Что такое EAV и почему его избегают? — «Сущность — атрибут — значение»: гибко, но без типов и ограничений, с соединением на каждый атрибут.
- Почему отчётные базы денормализованы? — Их пишет одна загрузка, а читают многие запросы; меньше соединений — быстрее агрегаты.
Какой первичный ключ выбрать: identity, GUID или естественный ключ?
Заголовок раздела «Какой первичный ключ выбрать: identity, GUID или естественный ключ?»Что это
| Вид ключа | Пример |
|---|---|
| Суррогатный числовой | int или bigint IDENTITY |
| Суррогатный GUID | uniqueidentifier |
| Естественный | код товара, ИНН, код магазина |
Как пользоваться
- По умолчанию —
bigint IDENTITY(1, 1): компактный, растёт по порядку, не меняется. - Естественный ключ остаётся колонкой с ограничением
UNIQUE: дубли запрещены, но внешние ключи ссылаются на суррогатный. - GUID — когда идентификатор нужен до вставки: его создаёт приложение или другая база.
Частая ошибка
Сделать первичным ключом штрихкод или ИНН. Штрихкоды переиспользуют, ИНН вводят с ошибкой, и менять приходится ключ во всех ссылающихся таблицах.
Как устроено
- Первичный ключ по умолчанию создаёт кластерный индекс: строки таблицы хранятся упорядоченными по ключу.
- Ключ кластерного индекса служит указателем на строку в каждом некластерном индексе. GUID на 16 байт вместо
bigintна 8 увеличивает все индексы таблицы. - Растущий ключ дописывает строки в последнюю страницу. Случайный
NEWID()вставляет в середину: страница делится пополам, индекс фрагментируется и заполнен наполовину.
Когда что выбирать
| Ключ | Когда |
|---|---|
bigint IDENTITY |
по умолчанию, одна база |
SEQUENCE |
номер нужен до вставки или общий для нескольких таблиц |
| Последовательный GUID | id создаёт приложение, данные сливаются из нескольких баз |
NEWID() |
почти никогда как кластерный ключ |
Ловушки
- Дыры в
IDENTITY. Откат транзакции или перезапуск сервера оставляют пропуски. Номер чека без пропусков так не получить. NEWSEQUENTIALID()работает только вDEFAULT, а после перезапуска Windows может начать с меньшего значения.- UUIDv7.
Guid.CreateVersion7()упорядочен по времени для .NET, но не дляuniqueidentifierв SQL Server.
Правило
bigint IDENTITY по умолчанию; GUID — когда id создаёт приложение, и тогда последовательный в порядке SQL Server.
Под капотом
- SQL Server сравнивает
uniqueidentifierне слева направо: старшими считаются последние 6 байт. В UUIDv7 время лежит в первых 6 байтах, поэтому порядок вставки в индекс почти случайный. NEWSEQUENTIALID()иSequentialGuidValueGenerator(генератор EF Core по умолчанию для SQL Server) переставляют байты под этот порядок.- UUIDv7 сохраняет порядок в
binary(16), записанный в big-endian:ToByteArray(bigEndian: true)(.NET 8+).binaryсравнивается побайтно.
Цена
- Растущий ключ на горячей таблице упирается в конкуренцию за последнюю страницу (
PAGELATCH_EX).OPTIMIZE_FOR_SEQUENTIAL_KEY(SQL Server 2019) её сглаживает. - GUID — 16 байт в каждом индексе и внешнем ключе: на сотнях миллионов строк это гигабайты буферного пула.
- Последовательные id легко угадать: публичный номер заказа отделяют от внутреннего ключа.
Компромиссы
- Естественный ключ экономит соединения, но его смена каскадом идёт по всем ссылкам. Исключение — короткие неизменные коды вроде ISO-кода валюты.
- Первичный ключ не обязан быть кластерным: GUID-ключ делают некластерным, а кластеризуют по растущей колонке.
IDENTITYизвестен только после вставки, поэтому пакетная вставка связанных строк требуетOUTPUTилиSEQUENCE.
Как проверить в проде
sys.dm_db_index_physical_stats показывает фрагментацию и заполненность страниц (avg_page_space_used_in_percent), а ожидания PAGELATCH_EX на одной странице — конкуренцию за конец индекса.
Итог: ключ выбирают по размеру, порядку вставки и месту генерации; «последовательный» GUID должен быть последовательным в порядке сравнения SQL Server.
CREATE TABLE dbo.Product ( -- Суррогатный ключ: 8 байт, растёт по порядку, не меняется ProductId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY CLUSTERED, -- Естественный ключ: уникален, но внешние ключи на него не ссылаются Sku varchar(20) NOT NULL CONSTRAINT UQ_Product_Sku UNIQUE, Name nvarchar(200) NOT NULL);
CREATE TABLE dbo.Receipt ( -- GUID, последовательный в порядке сравнения SQL Server ReceiptId uniqueidentifier NOT NULL CONSTRAINT DF_Receipt_Id DEFAULT NEWSEQUENTIALID() CONSTRAINT PK_Receipt PRIMARY KEY CLUSTERED, StoreId int NOT NULL, CreatedAt datetime2(0) NOT NULL);
-- Горячая вставка в конец индекса (SQL Server 2019+)ALTER INDEX PK_Product ON dbo.Product SET (OPTIMIZE_FOR_SEQUENTIAL_KEY = ON);using System.Data.SqlTypes;
var ids = new List<Guid>();for (var i = 0; i < 5; i++){ ids.Add(Guid.CreateVersion7()); // .NET 9+: время в мс + случайные биты Thread.Sleep(2); // внутри одной мс порядок не гарантирован}
Console.WriteLine(ids[0].Version); // 7Console.WriteLine(ids.SequenceEqual(ids.Order())); // True: в .NET порядок по времени
// SqlGuid сравнивает как SQL Server: старшие — последние 6 байт, а там случайные битыvar asSqlServer = ids.OrderBy(id => new SqlGuid(id));Console.WriteLine(ids.SequenceEqual(asSqlServer)); // False (почти всегда)
// binary(16) сравнивается побайтно: big-endian сохраняет порядок по времениvar asBinary = ids.OrderBy(id => Convert.ToHexString(id.ToByteArray(bigEndian: true)), StringComparer.Ordinal);Console.WriteLine(ids.SequenceEqual(asBinary)); // TrueSqlGuid из System.Data.SqlTypes повторяет правила сравнения SQL Server, поэтому показывает, как UUIDv7 ляжет в индекс по uniqueidentifier.
- Почему случайный GUID плох для кластерного ключа? — Вставка в середину индекса: разделение страниц, фрагментация, полупустые страницы, 16 байт в каждом индексе.
- Почему в
IDENTITYбывают пропуски? — Значения не возвращаются при откате и теряются из кеша при перезапуске сервера. - Подходит ли
Guid.CreateVersion7()для SQL Server? — Дляuniqueidentifierпорядок теряется; нуженNEWSEQUENTIALID(), генератор EF Core или хранение вbinary(16). - Обязан ли первичный ключ быть кластерным? — Нет: можно сделать его некластерным, а кластерный индекс построить по другой колонке.
- Когда естественный ключ уместен как первичный? — Когда он короткий и никогда не меняется: ISO-код валюты, код страны.
Как устроен ETL и как строят загрузки в SSIS?
Заголовок раздела «Как устроен ETL и как строят загрузки в SSIS?»Что это
ETL — три шага:
- Extract — прочитать данные из источника: базы, файла, API.
- Transform — очистить и привести к формату цели: типы, справочники, дубли.
- Load — записать в целевую базу.
Как устроен пакет SSIS
- Control flow — задачи и порядок их выполнения: «очистить staging», «загрузить данные», «выполнить процедуру».
- Data flow — задача, через которую идут строки: источник → преобразования → приёмник.
Как пользоваться
Пакет разрабатывают в Visual Studio, разворачивают на сервер в каталог SSISDB и запускают по расписанию заданием SQL Server Agent.
Частая ошибка
Каждый раз перезагружать всю таблицу. Пока данных мало, это работает, а через год загрузка перестаёт помещаться в ночное окно.
Как устроено
Инкрементальная загрузка по шагам:
- Прочитать из управляющей таблицы последний водяной знак.
- Data flow забирает строки новее него и быстрой вставкой кладёт в staging.
- Процедура переносит staging в цель через
MERGEилиUPDATEиINSERT. - В той же транзакции сохранить новый водяной знак.
Когда что выбирать
| Способ | Плюс | Минус |
|---|---|---|
| Водяной знак | просто, без настройки источника | не видит удалений |
| Change tracking | дёшево, видит удаления | только ключи, без старых значений |
| CDC | значения до и после из журнала | SQL Server Agent, нагрузка на журнал |
Ловушки
- Построчная обработка. OLE DB Command и Slowly Changing Dimension выполняют запрос на каждую строку. Большие объёмы обрабатывают в staging одним запросом.
- Блокирующие преобразования. Sort и Aggregate ждут весь поток и держат его в памяти. Сортировку отдают источнику (
ORDER BYиIsSortedна выходе). - Дата изменения как водяной знак. Время ставится до коммита, и строки долгой транзакции проскакивают.
Правило
Запуск безопасен для повтора: staging очищается в начале, перенос идёт по бизнес-ключу, водяной знак двигается только после успеха.
Под капотом
- Data flow работает буферами в памяти. Синхронные преобразования (Derived Column, Lookup) меняют строки в том же буфере, асинхронные копируют их в новые.
- OLE DB Destination в режиме fast load вставляет пакетами;
Maximum insert commit sizeзадаёт размер транзакции. - Checkpoints перезапускают пакет с упавшей задачи control flow, но не с середины data flow, а циклы Foreach выполняются заново. Идемпотентные шаги надёжнее.
Компромиссы
- Change tracking синхронный: каждая запись платит за учёт. Если последняя синхронизация старше
CHANGE_TRACKING_MIN_VALID_VERSION, нужна полная перезагрузка. - CDC асинхронный, но журнал не усекается, пока задание захвата его не обработало.
- В SSIS 2025 удалены CDC-компоненты Attunity и Microsoft Connector for Oracle, а 32-битный режим объявлен устаревшим. Для новых загрузок Microsoft предлагает Azure Data Factory и Data Factory в Fabric; готовые пакеты запускают в ADF на Azure-SSIS Integration Runtime.
- Загрузку на .NET (
SqlBulkCopyв staging, процедура сMERGE) проще тестировать и версионировать, но логирование, перезапуск и параллелизм пишутся вручную.
Как проверить в проде
- Каталог SSISDB хранит историю запусков:
catalog.executionsиcatalog.event_messages, а с уровнем логирования Performance — время компонентов вcatalog.execution_component_phases. - После загрузки сверяют количество строк и суммы источника и цели.
Итог: надёжный ETL — это инкремент без потерь, идемпотентный перенос и наблюдаемость; инструмент вторичен.
SET XACT_ABORT ON;
DECLARE @from binary(8) = (SELECT LastVersion FROM etl.Watermark WHERE TableName = N'Sale');-- Верхняя граница: строки незавершённых транзакций дождутся следующего запускаDECLARE @to binary(8) = MIN_ACTIVE_ROWVERSION();
-- Extract: в SSIS это data flow из источника в stagingTRUNCATE TABLE stg.Sale;INSERT INTO stg.Sale (SaleId, StoreId, Amount)SELECT SaleId, StoreId, AmountFROM src.SaleWHERE RowVer >= @from AND RowVer < @to;
-- Load: повторный запуск не создаст дублейBEGIN TRAN;
MERGE dw.Sale AS tUSING stg.Sale AS s ON t.SaleId = s.SaleIdWHEN MATCHED THEN UPDATE SET t.StoreId = s.StoreId, t.Amount = s.AmountWHEN NOT MATCHED THEN INSERT (SaleId, StoreId, Amount) VALUES (s.SaleId, s.StoreId, s.Amount);
-- Водяной знак двигается вместе с даннымиUPDATE etl.Watermark SET LastVersion = @to WHERE TableName = N'Sale';
COMMIT;Если @@DBTS взять вместо MIN_ACTIVE_ROWVERSION(), строка из транзакции, которая закоммитится позже, получит версию ниже сохранённой границы и не загрузится никогда.
- Чем change tracking отличается от CDC? — CT синхронно хранит ключи изменённых строк и версию, CDC асинхронно читает журнал и хранит значения до и после.
- Почему
@@DBTSнельзя брать как водяной знак? — Строки незавершённых транзакций закоммитятся с версией ниже границы и будут пропущены. - Как перезапустить упавший пакет SSIS? — Checkpoints с упавшей задачи control flow; надёжнее идемпотентные шаги, которые можно просто запустить заново.
- Как задать разные строки подключения для теста и прода? — Параметры проекта и окружения (environments) в каталоге SSISDB.
- Как ускорить data flow? — Убрать построчные и блокирующие преобразования, fast load в staging, сортировку и соединения отдать базе.
Как построить отчётность, чтобы отчёты не положили рабочую базу?
Заголовок раздела «Как построить отчётность, чтобы отчёты не положили рабочую базу?»Что это
| OLTP | OLAP | |
|---|---|---|
| Запросы | короткие, по ключу | агрегаты по миллионам строк |
| Пример | продажа на кассе | выручка по магазинам за год |
| Схема | нормализованная | денормализованная, «звезда» |
Зачем разделять
Тяжёлый отчёт занимает процессор и диск рабочей базы и может блокировать запись. Кассы и сайт начинают ждать.
Как пользоваться
- Отчёты строят в SSRS: отчёт описан в файле RDL, сервер отчётов выполняет запрос и отдаёт результат в браузер, Excel или PDF.
- Тяжёлые отчёты подключают не к рабочей базе, а к её копии или к хранилищу данных.
Частая ошибка
Лечить блокировки отчётов подсказкой NOLOCK. Отчёт начинает читать незакоммиченные данные и может пропустить или дважды посчитать строки.
Как устроено
Отчёт мешает рабочей базе тремя путями:
- Ресурсы. Сканы и сортировки занимают CPU, диск и память под запросы.
- Кеш. Прочитанные страницы вытесняют из буферного пула горячие данные.
- Блокировки. Под READ COMMITTED чтение ставит разделяемые блокировки и конфликтует с записью.
Когда что выбирать
| Решение | Задержка данных | Когда |
|---|---|---|
| RCSI на рабочей базе | нет | отчёты лёгкие, мешают только блокировки |
| Кеш и снимки SSRS | минуты или сутки | один отчёт открывают многие |
| Реплика Always On | секунды | оперативные отчёты, Enterprise |
| Витрина данных | до следующего ETL | аналитика, история, тяжёлые агрегаты |
Ловушки
- Реплика — та же схема. Индекс под отчёт создают на первичной базе, и он замедляет запись. Свои индексы возможны только в витрине или в базе-подписчике репликации.
- Параметры без границ. Период «за всё время» по умолчанию превращает отчёт в полный скан.
- Подписки в одно время. Сотни рассылок в 9:00 — пик нагрузки, который легко размазать.
Правило
Сначала найти тяжёлые отчёты, затем уводить их по допустимой задержке: оперативные — на реплику, аналитику — в витрину.
Под капотом
- Читаемая реплика применяет журнал потоком redo. Все запросы на ней идут под snapshot isolation, подсказки блокировок игнорируются.
- Платит первичная база: при читаемой реплике к изменённым строкам добавляются 14 байт под версии, что может вызвать разделение страниц.
- Долгий отчёт на реплике держит Sch-S и может задержать redo DDL и очистку ghost-записей на первичной.
- Columnstore хранит данные по колонкам со сжатием и обрабатывает пакетами строк. С SQL Server 2016 некластерный columnstore на рабочей таблице обновляемый — это real-time operational analytics.
Компромиссы
| Вариант | Цена |
|---|---|
| Реплика Always On | Enterprise; схема и индексы как у первичной; лаг растёт под нагрузкой |
| Columnstore на рабочей таблице | дороже каждая запись, место под индекс |
| Витрина со звездой | ETL, задержка, ещё одна модель данных |
Basic availability group в Standard реплику для чтения не даёт.
Отчётный сервер в 2025+
SSRS 2022 — последняя версия, поддержка до 11 января 2033 года. С SQL Server 2025 локальная отчётность — Power BI Report Server: те же RDL-отчёты плюс интерактивные PBIX, право на него входит в лицензии Enterprise и Standard. Облачный путь — Power BI, где RDL работают как paginated reports.
Как проверить в проде
ExecutionLog3в базе сервера отчётов показывает по каждому запуску время получения данных, обработки и рендеринга.- Отставание реплики —
sys.dm_hadr_database_replica_states:log_send_queue_size,redo_queue_size.
Итог: отчётность разводят с транзакциями по допустимой задержке, а платформу выбирают с учётом конца линейки SSRS.
-- База сервера отчётов: по умолчанию ReportServer, журнал хранится 60 днейUSE ReportServer;
-- Какие отчёты дольше всех ждали базу за неделюSELECT TOP (10) ItemPath, COUNT(*) AS Runs, SUM(CAST(TimeDataRetrieval AS bigint)) / 1000 AS DataSecTotal, AVG(CAST(TimeDataRetrieval AS bigint)) / 1000 AS DataSecAvg, MAX([RowCount]) AS MaxRowsFROM dbo.ExecutionLog3WHERE TimeStart >= DATEADD(DAY, -7, SYSDATETIME())GROUP BY ItemPathORDER BY DataSecTotal DESC;-- Оперативная аналитика прямо по рабочей таблице (SQL Server 2016+):-- транзакции пишут в обычный индекс, агрегаты читают сжатые колонкиCREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Sale ON dbo.Sale (StoreId, SoldAt, Amount);
SELECT StoreId, SUM(Amount) AS RevenueFROM dbo.SaleWHERE SoldAt >= '2026-10-01'GROUP BY StoreId;- Чем схема «звезда» отличается от рабочей схемы? — Таблица фактов и денормализованные измерения под агрегаты против нормализованных таблиц под запись.
- Почему отчёт на реплике не блокирует запись? — На читаемой реплике все запросы идут под snapshot isolation, подсказки блокировок игнорируются.
- Можно ли создать индекс только на реплике Always On? — Нет, схема общая; индекс создают на первичной либо используют витрину или репликацию.
- Что будет с SSRS после SQL Server 2025? — Новых версий нет, SSRS 2022 поддерживается до 2033 года, замена — Power BI Report Server.
- Как найти отчёт, который грузит базу? —
ExecutionLog3в базе сервера отчётов: время получения данных и число запусков.
Как масштабировать базу: партиционирование, реплики, шардирование и архив?
Заголовок раздела «Как масштабировать базу: партиционирование, реплики, шардирование и архив?»Что это
- Партиционирование — таблица хранится секциями по значению колонки, например по месяцу продажи. Для запросов это одна таблица.
- Реплика для чтения — копия базы, на которую уводят отчёты.
- Шардирование — данные разных магазинов или клиентов лежат в разных базах на разных серверах.
- Архивирование — старые данные переносят из рабочей таблицы в архив.
Зачем
Большую таблицу дольше обслуживать, копировать и чистить. Удалить прошлый год через DELETE — часы блокировок и гигабайты журнала, а с партиционированием это переключение секции.
Частая ошибка
Ждать, что партиционирование само ускорит запросы. Если в условии нет колонки секционирования, запрос читает все секции и может стать медленнее.
Как устроено партиционирование
- Функция секционирования задаёт границы:
RANGE RIGHT FOR VALUES ('2026-09-01', '2026-10-01'). - Схема секционирования раскладывает секции по файловым группам.
- Таблицу и индексы создают на схеме. Индекс на той же схеме — выровненный;
SWITCHработает только с выровненными индексами.
Скользящее окно для архива
SPLIT RANGEдобавляет пустую секцию под следующий месяц.SWITCH PARTITIONпереносит старейший месяц в архивную таблицу той же структуры.MERGE RANGEубирает опустевшую границу.
Когда что выбирать
| Проблема | Решение |
|---|---|
| Таблицу долго обслуживать и чистить | партиционирование |
| Чтение перегружает сервер | реплики для чтения |
| Запись не помещается в один сервер | шардирование |
Ловушки
- Уникальный индекс включает колонку секционирования, иначе он не выровнен. Отсюда составной ключ
(SaleId, SoldOn). - Запрос без ключа секционирования ищет в каждой секции. Microsoft прямо пишет, что в OLTP партиционирование редко ускоряет запросы.
SPLITиMERGEнепустой секции перемещают строки и пишут журнал, поэтому крайние секции держат пустыми.
Под капотом
SWITCHменяет принадлежность страниц, данные не копируются. Условия: одинаковые колонки и индексы, та же файловая группа, цель пуста; при загрузке в секцию источник подтверждает диапазон CHECK-ограничением.SWITCHнужна монопольная блокировка обеих таблиц. С SQL Server 2014WAIT_AT_LOW_PRIORITYне даёт ей встать в очередь перед всеми запросами.TRUNCATE TABLE ... WITH (PARTITIONS (2))(SQL Server 2016+) очищает секцию без архива. Партиционирование есть во всех редакциях с SQL Server 2016 SP1.
Цена
- До 15 000 секций, но тысячи секций стоят памяти, а запросы без исключения секций замедляются.
- Читаемые реплики — только Enterprise; basic availability group в Standard не даёт читать со вторичной.
Компромиссы шардирования
| Что теряем | Чем платим |
|---|---|
| Соединения и агрегаты между шардами | запросы ко всем шардам, отчёты через хранилище |
| Транзакции между шардами | саги, outbox, идемпотентность |
| Глобальная уникальность | GUID или ключ с номером шарда |
| Равномерность | перебалансировка, «горячие» шарды |
Справочники вроде товаров и магазинов копируют в каждый шард.
Как проверить в проде
sys.dm_db_partition_statsпоказывает строки по секциям, а план —Actual Partition Count.- Отставание реплик —
sys.dm_hadr_database_replica_states.
Итог: один сервер выжимают до конца, партиционируют ради обслуживания, а шардируют, только когда запись упёрлась в сервер и есть естественный ключ раздела.
-- RANGE RIGHT: граница — первое число месяца; крайние секции держим пустымиCREATE PARTITION FUNCTION pfMonth (date) AS RANGE RIGHT FOR VALUES ('2026-08-01', '2026-09-01', '2026-10-01', '2026-11-01');CREATE PARTITION SCHEME psMonth AS PARTITION pfMonth ALL TO ([PRIMARY]);
CREATE TABLE dbo.Sale ( SaleId bigint NOT NULL, SoldOn date NOT NULL, Amount decimal(12,2) NOT NULL, CONSTRAINT PK_Sale PRIMARY KEY (SaleId, SoldOn) -- ключ включает колонку секционирования) ON psMonth (SoldOn);
-- Архив: та же структура и файловая группа, пустойCREATE TABLE dbo.SaleArchive ( SaleId bigint NOT NULL, SoldOn date NOT NULL, Amount decimal(12,2) NOT NULL, CONSTRAINT PK_SaleArchive PRIMARY KEY (SaleId, SoldOn)) ON [PRIMARY];
-- 1. Пустая секция под декабрь, пока данных за ноябрь ещё нетALTER PARTITION SCHEME psMonth NEXT USED [PRIMARY];ALTER PARTITION FUNCTION pfMonth() SPLIT RANGE ('2026-12-01');
-- 2. Август (секция 2) уходит в архив: меняются только метаданныеALTER TABLE dbo.Sale SWITCH PARTITION 2 TO dbo.SaleArchive WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));
-- 3. Убрать границу между двумя пустыми секциямиALTER PARTITION FUNCTION pfMonth() MERGE RANGE ('2026-08-01');- Ускоряет ли партиционирование запросы? — Только при исключении секций по ключу секционирования; иначе часто замедляет.
- Почему
SWITCHвыполняется мгновенно? — Меняются метаданные принадлежности страниц, строки не копируются. - Зачем держать крайние секции пустыми? — Тогда
SPLITиMERGEне перемещают строки и не раздувают журнал. - Чем шардирование отличается от партиционирования? — Секции живут в одной базе на одном сервере, шарды — в разных базах на разных серверах.
- Как выбрать ключ шардирования? — По границе, внутри которой живёт почти каждая транзакция, и с равномерной нагрузкой: магазин, регион, клиент.
Как менять схему базы без простоя?
Заголовок раздела «Как менять схему базы без простоя?»Что это
Изменение схемы без простоя — база меняется, пока приложение работает и магазины продолжают продавать.
Как пользоваться
Переименование колонки Phone в PhoneNumber по шагам:
- Добавить колонку
PhoneNumber, разрешивNULL. Старое приложение её не замечает. - Выпустить версию приложения, которая пишет в обе колонки.
- Скопировать старые данные небольшими пакетами.
- Выпустить версию, которая читает только
PhoneNumber. - Удалить
Phone, когда старых версий приложения не осталось.
Частая ошибка
Переименовать или удалить колонку в одном релизе с кодом. Пока обновлены не все серверы, старая версия обращается к колонке, которой уже нет, и падает.
Как устроено
Expand/contract — три фазы:
- Expand. Только совместимые изменения: новая nullable-колонка, таблица, индекс. Старый код работает.
- Migrate. Код пишет в старое и новое, backfill переносит историю, чтение переключается на новое.
- Contract. Когда старого кода нет и окно отката прошло, старое удаляют.
Сколько держится блокировка
| Операция | Как выполняется |
|---|---|
| Новая nullable-колонка | метаданные, мгновенно |
| NOT NULL с константным default | метаданные в Enterprise (2012+), иначе перезапись строк |
Смена типа, например int → bigint |
перезапись всех строк под Sch-M |
Индекс с ONLINE = ON |
онлайн, короткие блокировки в начале и конце; Enterprise |
Ловушки
- Очередь за Sch-M.
ALTERждёт долгий отчёт, а все новые запросы к таблице ждутALTER. Перед DDL ставятSET LOCK_TIMEOUTи повторяют попытку. - Один большой
UPDATE. Backfill одной транзакцией раздувает журнал, вызывает эскалацию блокировок до таблицы и задерживает реплики. Нужны пакеты по несколько тысяч строк. - Переименование в один шаг.
sp_renameколонки, которую читает работающая версия, — ломающее изменение.
Правило
Каждая миграция совместима с текущей и предыдущей версией приложения.
Под капотом
- Sch-M несовместима ни с одной блокировкой, даже со стабильностью схемы у читателей. Важно, сколько DDL её держит и сколько ждёт её получения.
- Эскалация срабатывает примерно на 5 000 блокировках одного оператора на одном объекте. Пакеты меньше этого порога держат построчные блокировки и не останавливают остальных.
- ADR (SQL Server 2019+) делает откат долгой транзакции мгновенным и усекает журнал во время неё; без ADR отмена часового
UPDATEможет длиться столько же.
Смена типа на большой таблице
- Добавить колонку нового типа, nullable.
- Синхронизировать новые изменения — кодом или триггером.
- Backfill пакетами по диапазонам ключа.
- Переключить чтение и запись, затем удалить старую колонку.
ALTER COLUMN ... WITH (ONLINE = ON) (SQL Server 2016+, Enterprise) оставляет таблицу доступной, но строки всё равно переписываются.
Компромиссы
- State-based развёртывание (dacpac,
sqlpackage) строит скрипт сравнением схем и при некоторых изменениях пересоздаёт таблицу копированием, поэтому скрипт читают до прода. Миграции (EF Core, DbUp) явны, но порядок шагов на вас. - После expand откатывают только код: схема совместима со старой версией. Обратные миграции с потерей данных на проде заменяют исправлением вперёд.
- Триггер синхронизации ловит всех писателей, включая ETL, но удорожает каждую запись.
Как проверить в проде
- Во время backfill следят за
log_reuse_wait_descвsys.databasesиredo_queue_sizeреплик; пауза между пакетами — регулятор. - Очередь за DDL видна в
sys.dm_exec_requests:wait_typeLCK_M_SCH_Mиblocking_session_id.
Итог: без простоя — это совместимость с двумя версиями кода, короткие блокировки и управляемая очередь за Sch-M.
-- DDL ждёт блокировку не дольше 5 секунд и не держит очередь за собойSET LOCK_TIMEOUT 5000;
-- Expand: новая nullable-колонка шире старой — только метаданныеALTER TABLE dbo.Receipt ADD TotalAmount decimal(14,2) NULL;
-- Backfill пакетами по диапазону ключа: короткие транзакции без эскалацииDECLARE @batch bigint = 4000, @from bigint = 0, @max bigint;SELECT @max = MAX(ReceiptId) FROM dbo.Receipt;
WHILE @from < @maxBEGIN UPDATE dbo.Receipt SET TotalAmount = Total WHERE ReceiptId > @from AND ReceiptId <= @from + @batch AND TotalAmount IS NULL; -- повторный запуск продолжит с места остановки
SET @from += @batch; WAITFOR DELAY '00:00:00.100'; -- место для рабочей нагрузки и репликEND;
-- Contract — отдельным релизом, когда код больше не читает старую колонку-- ALTER TABLE dbo.Receipt DROP COLUMN Total;Пока идёт backfill, новая версия приложения пишет в обе колонки, поэтому новые чеки уже заполнены.
- Почему
ALTER TABLEна миллисекунды может положить систему? — Он ждёт Sch-M за долгим запросом, а все новые запросы встают в очередь за ним. - Как добавить NOT NULL-колонку в таблицу на миллиард строк? — В Enterprise с константным default это метаданные; иначе nullable-колонка, backfill пакетами, а финальный
NOT NULLпроверит все строки. - Зачем пакеты по несколько тысяч строк? — Короткие транзакции, нет эскалации до таблицы, журнал усекается, реплики не отстают.
- Как откатить релиз с изменением схемы? — Откатывают код: после expand схема совместима со старой версией; удаление идёт отдельным поздним релизом.
- Чем dacpac отличается от миграций? — Скрипт генерируется сравнением схем и может пересоздать таблицу; миграции явны, но порядок шагов на вас.