Виды таблиц в MS SQL Server: особенности, создание и выбор

Виды таблиц в MS SQL Server: особенности, создание и выбор Полезное

Таблица в SQL Server (MS SQL) — объект базы данных, который хранит строки с одинаковым набором столбцов. Виды таблиц различаются временем жизни, видимостью и назначением: постоянная таблица живет в базе до DROP TABLE, локальная временная (#имя) — до конца сессии, глобальная временная (##имя) видна всем сессиям, табличная переменная (@имя) живет до конца пакета. Отдельно стоят специальные виды: секционированные, темпоральные (system-versioned), системные, широкие, In-Memory и графовые таблицы.

Ниже — сводная таблица, по примеру создания каждого основного вида и правило выбора между # и @. Целевая версия примеров — SQL Server 2022; синтаксис основных видов работает с SQL Server 2016 (что появилось позже, указано рядом).

Все примеры проверены на SQL Server 2022 (16.0.4295.3, CU27, Developer, Linux-контейнер x86-64, уровень совместимости базы 160) 25.09.2026: код выполнялся по порядку в отдельной учебной базе r19demo, результаты и тексты ошибок ниже — фактический вывод sqlcmd (имя сервера в заголовке ошибок опущено, номер строки считается от начала пакета). Повторять примеры лучше тоже в отдельной учебной базе, а не в рабочей: раздел In-Memory добавляет в базу файловую группу, которую потом нельзя удалить.

Мини-словарь: что путают чаще всего

  • Временная таблица (#t, ##t) — обычная таблица в системной базе tempdb, удаляется автоматически.
  • Темпоральная таблица (temporal, system-versioned) — постоянная таблица, которая сама хранит историю изменений строк. Со «временной» ее путать нельзя: это разные механизмы с похожим названием.
  • Табличная переменная (DECLARE @t TABLE) — переменная табличного типа. Физически она тоже размещается в tempdb, а не «только в памяти».
  • Производная таблица и CTE (подзапрос в FROM, WITH ... AS) — не вид таблицы, а часть запроса: они ничего не хранят и существуют только внутри одной инструкции.

Сводная таблица видов

Вид Как создать Где хранится Время жизни Кто видит Когда брать
Постоянная CREATE TABLE dbo.T файлы базы до DROP TABLE все с правами рабочие данные
Локальная временная CREATE TABLE #T, SELECT ... INTO #T tempdb до конца сессии или процедуры своя сессия промежуточный результат, нужны индексы и статистика
Глобальная временная CREATE TABLE ##T tempdb пока жива создавшая сессия и есть обращения все сессии обмен между сессиями (редко)
Табличная переменная DECLARE @T TABLE (...) tempdb до конца пакета, процедуры, функции текущий пакет небольшие наборы, данные, которые должны пережить ROLLBACK
Секционированная CREATE TABLE ... ON схема_секций(столбец) файлы базы как у постоянной как у постоянной очень большие таблицы с архивом по датам
Темпоральная WITH (SYSTEM_VERSIONING = ON) таблица + таблица истории как у постоянной как у постоянной аудит и запросы «как было на дату»
Системная создает сам SQL Server скрытые системные таблицы в каждой базе, определения объектов sys — в базе Resource пока существует база через представления sys.* чтение метаданных

Постоянная таблица: базовый вид

Минимальный полный пример — таблица с первичным ключом, ограничением CHECK и значением по умолчанию:

CREATE TABLE dbo.Employees (
    EmployeeId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Employees PRIMARY KEY,
    FullName   nvarchar(200) NOT NULL,
    Salary     decimal(12,2) NOT NULL CONSTRAINT CK_Employees_Salary CHECK (Salary >= 0),
    IsActive   bit NOT NULL CONSTRAINT DF_Employees_IsActive DEFAULT (1)
);

INSERT INTO dbo.Employees (FullName, Salary) VALUES (N'Иванова Анна', 150000);
INSERT INTO dbo.Employees (FullName, Salary) VALUES (N'Петров Олег', -1);

Первая вставка пройдет, вторая завершится ошибкой, и строка не попадет в таблицу:

Msg 547, Level 16, State 1, Line 9
The INSERT statement conflicted with the CHECK constraint "CK_Employees_Salary". The conflict occurred in database "r19demo", table "dbo.Employees", column 'Salary'.
The statement has been terminated.

Ошибка 547 прерывает только эту инструкцию, а не весь пакет: первая строка уже сохранена, SELECT * FROM dbo.Employees возвращает одну строку 1, Иванова Анна, 150000.00, 1. Имена ограничений задаем явно: так их проще найти в сообщении об ошибке и удалить через ALTER TABLE ... DROP CONSTRAINT. Как подобрать типы столбцов, подробно разобрано в статье о типах данных MS SQL.

Ключевые лимиты обычной таблицы: до 1024 столбцов и до 8060 байт на строку внутри страницы (столбцы переменной длины вроде varchar(max) могут выноситься за пределы строки). Число строк и размер таблицы ограничены только местом на диске.

Временные таблицы # и

Локальная временная таблица начинается с одного #. Ее видит только создавшая сессия: в соседней вкладке SSMS это уже другая сессия, и таблицы там нет. Если #-таблица создана внутри хранимой процедуры, она удаляется по окончании процедуры, но видна вложенным процедурам, которые та вызывает.

DROP TABLE IF EXISTS #TopPaid;

SELECT TOP (10) EmployeeId, FullName, Salary
INTO #TopPaid
FROM dbo.Employees
ORDER BY Salary DESC;

CREATE INDEX IX_TopPaid_Salary ON #TopPaid (Salary);

SELECT * FROM #TopPaid;

На данных из предыдущего примера запрос вернул одну строку 1, Иванова Анна, 150000.00. SELECT ... INTO создает таблицу по структуре результата, дальше с ней работают как с обычной: индексы, UPDATE, соединения. DROP TABLE IF EXISTS доступен с SQL Server 2016; в более старых версиях проверяют OBJECT_ID(N'tempdb..#TopPaid').

Глобальная временная таблица (##Import) видна всем сессиям экземпляра. Она удаляется, когда завершилась создавшая ее сессия и другие сессии перестали к ней обращаться. Из-за общей видимости в ##-таблицы не стоит класть персональные данные и секреты, а в конкурентном коде имя легко столкнется с чужим. В Azure SQL Database глобальные временные таблицы видны только в пределах одной базы.

Частая проблема: временные таблицы получают сортировку (collation) базы tempdb, а не вашей базы. Если они различаются, соединение строковых столбцов падает с ошибкой конфликта сортировок. Решение — объявлять строковые столбцы с COLLATE DATABASE_DEFAULT.

Табличные переменные и отличие от #-таблиц

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

CREATE TABLE #Log (Id int);
DECLARE @Log TABLE (Id int PRIMARY KEY);

BEGIN TRAN;
    INSERT INTO #Log VALUES (1);
    INSERT INTO @Log VALUES (1);
ROLLBACK;

SELECT (SELECT COUNT(*) FROM #Log) AS temp_rows,
       (SELECT COUNT(*) FROM @Log) AS var_rows;

DROP TABLE #Log;

Фактический результат прогона на SQL Server 2022 — temp_rows = 0, var_rows = 1: ROLLBACK отменяет вставку во временную таблицу, но не в табличную переменную. Поэтому табличную переменную используют, чтобы сохранить журнал ошибок из транзакции, которую потом откатывают.

Критерий #-таблица @-переменная
Статистика для оптимизатора есть нет
Индексы после создания CREATE INDEX в любой момент только в объявлении (PRIMARY KEY, UNIQUE, INDEX с 2014)
Участие в ROLLBACK да нет
Область видимости сессия, вложенные процедуры текущий пакет, процедура, функция
Можно использовать в функции нет да

Правило выбора (упрощенное): десятки и сотни строк, данные нужны в функции или должны пережить откат — @-переменная; тысячи строк, соединения с большими таблицами, нужны индексы и точные оценки — #-таблица. Граница зависит от запроса и версии: с уровня совместимости 150 (SQL Server 2019) оптимизатор при первой компиляции учитывает фактическое число строк в табличной переменной, но статистики по значениям у нее по-прежнему нет. Сравнивать варианты стоит по фактическому плану на своих данных.

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

Секционированная таблица хранит строки в горизонтальных секциях по значению одного столбца, обычно даты. Логически это одна таблица, но старую секцию можно быстро отключить в архив (SWITCH) или очистить (TRUNCATE ... WITH (PARTITIONS ...), с SQL Server 2016).

CREATE PARTITION FUNCTION pfOrderYear (date)
    AS RANGE RIGHT FOR VALUES ('2025-01-01', '2026-01-01');

CREATE PARTITION SCHEME psOrderYear
    AS PARTITION pfOrderYear ALL TO ([PRIMARY]);

CREATE TABLE dbo.Orders (
    OrderId   bigint        NOT NULL,
    OrderDate date          NOT NULL,
    Amount    decimal(12,2) NOT NULL,
    CONSTRAINT PK_Orders PRIMARY KEY (OrderDate, OrderId)
) ON psOrderYear (OrderDate);

INSERT INTO dbo.Orders VALUES (1, '2024-12-31', 100), (2, '2025-06-01', 200), (3, '2026-03-15', 300);

SELECT $PARTITION.pfOrderYear(OrderDate) AS partition_no, COUNT(*) AS row_count
FROM dbo.Orders
GROUP BY $PARTITION.pfOrderYear(OrderDate);

Две границы дают три секции: до 2025 года, 2025 год, с 2026 года. Запрос вернул три строки partition_no = 1, 2, 3 с row_count = 1 у каждой: все заказы попали в свои секции. Столбец секционирования обязан входить в ключ любого уникального индекса на схеме секций, в том числе первичного ключа. Если объявить PRIMARY KEY (OrderId) без OrderDate (таблица dbo.Orders2 на той же схеме секций), создание таблицы падает:

Msg 1908, Level 16, State 1, Line 1
Column 'OrderDate' is partitioning column of the index 'PK_Orders2'. Partition columns for a unique index must be a subset of the index key.
Msg 1750, Level 16, State 1, Line 1
Could not create constraint or index. See previous errors.

При создании схемы секций SQL Server также выводит информационное сообщение Partition scheme 'psOrderYear' has been created successfully. 'PRIMARY' is marked as the next used filegroup in partition scheme 'psOrderYear'. — это не ошибка.

Максимум — 15 000 секций на таблицу или индекс. Секционирование само по себе не ускоряет любой запрос: выигрыш есть, когда условие WHERE отсекает секции по столбцу секционирования.

Темпоральные таблицы (system-versioned)

Темпоральная таблица (с SQL Server 2016) хранит текущие строки, а старые версии SQL Server сам переносит в таблицу истории при каждом UPDATE и DELETE.

CREATE TABLE dbo.Prices (
    ProductId int           NOT NULL PRIMARY KEY CLUSTERED,
    Price     decimal(12,2) NOT NULL,
    ValidFrom datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    ValidTo   datetime2 GENERATED ALWAYS AS ROW END   NOT NULL,
    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PricesHistory));

INSERT INTO dbo.Prices (ProductId, Price) VALUES (1, 100);
WAITFOR DELAY '00:00:01';
DECLARE @before datetime2 = SYSUTCDATETIME();
WAITFOR DELAY '00:00:01';
UPDATE dbo.Prices SET Price = 120 WHERE ProductId = 1;

SELECT ProductId, Price FROM dbo.Prices;
SELECT ProductId, Price FROM dbo.Prices FOR SYSTEM_TIME AS OF @before;

Первый запрос вернул текущую цену 120.00, второй — цену 100.00 на момент @before. Время периода хранится в UTC, поэтому точку отсчета берем через SYSUTCDATETIME(), а не GETDATE(). Паузы WAITFOR нужны только в демонстрации, чтобы моменты вставки, отсечки и обновления не совпали.

Две особенности на практике. Удалить темпоральную таблицу сразу нельзя — DROP TABLE dbo.Prices падает:

Msg 13552, Level 16, State 1, Line 1
Drop table operation failed on table 'r19demo.dbo.Prices' because it is not a supported operation on system-versioned temporal tables.

Сначала ALTER TABLE dbo.Prices SET (SYSTEM_VERSIONING = OFF), затем DROP TABLE для обеих таблиц. И история хранит удаленные строки: если по требованиям данные нужно стереть полностью, чистить придется и таблицу истории. Срок хранения истории HISTORY_RETENTION_PERIOD доступен с SQL Server 2017 и работает, пока у базы включен параметр TEMPORAL_HISTORY_RETENTION (по умолчанию включен; в прогоне sys.databases.is_temporal_history_retention_enabled = 1).

Системные таблицы и как посмотреть структуру

Системные таблицы хранят метаданные: какие объекты есть в базе, их столбцы, права. Напрямую к ним обращаться нельзя, читают их через представления каталога sys.* и INFORMATION_SCHEMA. Старые syscolumns и systypes оставлены только для совместимости, в новом коде их не используют.

SELECT c.column_id, c.name AS column_name, t.name AS type_name,
       c.max_length, c.is_nullable
FROM sys.columns AS c
JOIN sys.types AS t ON t.user_type_id = c.user_type_id
WHERE c.object_id = OBJECT_ID(N'dbo.Employees')
ORDER BY c.column_id;

Прогон вернул четыре строки: EmployeeId int 4, FullName nvarchar 400, Salary decimal 9, IsActive bit 1, у всех is_nullable = 0. max_length — размер в байтах, а не в символах: у nvarchar(200) это 400, у (max) будет -1. Вид таблицы тоже видно из каталога: в sys.tables столбец temporal_type_desc отличает темпоральную таблицу и таблицу истории (в прогоне Prices — SYSTEM_VERSIONED_TEMPORAL_TABLE, PricesHistory — HISTORY_TABLE, остальные — NON_TEMPORAL_TABLE), is_memory_optimized — In-Memory, ledger_type_desc — ledger. Быстрый отчет по одной таблице дает EXEC sp_help N'dbo.Employees';.

Другие специальные виды

Вид С версии Суть
Широкая (wide) 2008 разреженные столбцы (SPARSE) и набор столбцов, до 30 000 столбцов
In-Memory (memory-optimized) 2014 данные в памяти, WITH (MEMORY_OPTIMIZED = ON), нужна отдельная файловая группа
Графовые (NODE, EDGE) 2017 узлы и связи графа, запросы через MATCH
Ledger 2022 защищенная от незаметной подделки история изменений, WITH (LEDGER = ON)

Доступность In-Memory и ledger-таблиц проверяйте по версии и выпуску конкретного сервера: ledger появился в SQL Server 2022 и есть в Azure SQL Database, In-Memory в SQL Server доступен с 2014, в выпусках ниже Enterprise (с 2016 SP1) — с ограничением объема памяти под такие таблицы, а в Azure SQL Database — только на части уровней обслуживания. Ниже — минимальные примеры обоих видов, проверенные на SQL Server 2022 Developer (по функциям совпадает с Enterprise).

In-Memory. Без файловой группы для данных в памяти таблица не создается:

CREATE TABLE dbo.SessionCache (
    SessionId int NOT NULL PRIMARY KEY NONCLUSTERED,
    Payload   nvarchar(400) NULL
) WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_ONLY);
Msg 41337, Level 16, State 100, Line 1
Cannot create memory optimized tables. To create memory optimized tables, the database must have a MEMORY_OPTIMIZED_FILEGROUP that is online and has at least one container.

Сначала добавляем файловую группу и контейнер (путь — каталог данных на сервере; в примере — Linux-контейнер, на Windows это будет путь вида C:\...\DATA\r19demo_mod), затем повторяем CREATE TABLE:

ALTER DATABASE r19demo ADD FILEGROUP r19demo_mod CONTAINS MEMORY_OPTIMIZED_DATA;
ALTER DATABASE r19demo ADD FILE (NAME = N'r19demo_mod', FILENAME = N'/var/opt/mssql/data/r19demo_mod')
    TO FILEGROUP r19demo_mod;

После этого повторный CREATE TABLE прошел, а sys.tables показал is_memory_optimized = 1, durability_desc = SCHEMA_ONLY. SCHEMA_ONLY значит, что после перезапуска сервера останется только структура, данные пропадут; для данных, которые нужно сохранить, берут DURABILITY = SCHEMA_AND_DATA (значение по умолчанию). Файловую группу MEMORY_OPTIMIZED_DATA из базы потом не удалить даже после удаления таблицы: в прогоне REMOVE FILE ответил ошибкой 41802 (Cannot drop the last memory-optimized container), а REMOVE FILEGROUP — ошибкой 5042. Поэтому пробуйте это только в учебной базе, которую потом удаляете целиком (DROP DATABASE).

Ledger. Таблица только для добавления строк (append-only) создается одной инструкцией, отдельная файловая группа не нужна:

CREATE TABLE dbo.AuditLog (
    Id  int           NOT NULL,
    Msg nvarchar(200) NOT NULL
) WITH (LEDGER = ON (APPEND_ONLY = ON));

INSERT INTO dbo.AuditLog VALUES (1, N'first');
UPDATE dbo.AuditLog SET Msg = N'x' WHERE Id = 1;

Вставка прошла, sys.tables.ledger_type_desc показал APPEND_ONLY_LEDGER_TABLE, а UPDATE отклонен:

Msg 37359, Level 16, State 1, Line 7
Updates are not allowed for the append only Ledger table 'dbo.AuditLog'.

Ledger-таблицу нельзя превратить обратно в обычную, а DROP TABLE не стирает ее бесследно: в прогоне после удаления в sys.tables осталась таблица MSSQL_DroppedLedgerTable_AuditLog_<GUID> с is_dropped_ledger_table = 1. Это еще одна причина пробовать ledger в отдельной базе.

Примечание о прежней среде: до 25.09.2026 примеры статьи прогонялись на Azure SQL Edge, который Microsoft вывела из эксплуатации 30.09.2025 (ARM64 он больше не поддерживает). Там MEMORY_OPTIMIZED = ON падал с ошибкой 16310, а LEDGER = ON не распознавался. Это ограничение только Azure SQL Edge, на SQL Server 2022 оба примера работают, как показано выше; для своих опытов Azure SQL Edge не берите.

Если не получилось

  • #-таблица «не найдена» в другой вкладке SSMS — это другая сессия. Выполняйте код в одной вкладке или используйте постоянную таблицу.
  • Табличная переменная «пропала» после GO — GO завершает пакет, а с ним и переменную; следующий пакет падает с Must declare the table variable "@Log". (Msg 1087). Уберите GO внутри блока.
  • Запрос с табличной переменной на больших данных медленный — проверьте оценку строк в фактическом плане и попробуйте #-таблицу с индексом.

Выводы

  • Основные виды таблиц в SQL Server — постоянные, локальные # и глобальные ## временные, табличные переменные @; специальные — секционированные, темпоральные, системные, широкие, In-Memory, графовые, ledger.
  • Временная (#) и темпоральная (system-versioned) таблицы — разные механизмы: первая удаляется сама, вторая хранит историю изменений.
  • #-таблица имеет статистику и индексы и откатывается вместе с транзакцией; @-переменная живет до конца пакета и ROLLBACK не отменяет ее данные.
  • Секционирование помогает управлять большими таблицами, но ускоряет только запросы, которые отсекают секции.
  • Метаданные читают через sys.* и INFORMATION_SCHEMA, а не через устаревшие syscolumns/systypes.

Где применяется / связь с практикой

Освойте тему на практике

Выбор вида таблицы — ежедневная задача разработчика баз данных: временные таблицы в отчетных процедурах, табличные переменные для журналов ошибок, темпоральные таблицы для аудита цен и договоров, секционирование для многолетних таблиц заказов. Системно эти темы вместе с планами запросов, индексами и транзакциями разбирают на курсе «Разработчик MS SQL Server». Попробовать формат можно на бесплатных открытых уроках Otus.

FAQ

Можно ли изменить структуру временной таблицы после создания?
Да, к #-таблице применимы ALTER TABLE и CREATE INDEX. У табличной переменной структуру после объявления изменить нельзя.

Попадают ли временные таблицы в резервную копию базы?
Нет. Они живут в tempdb, которая пересоздается при каждом перезапуске службы SQL Server и не резервируется.

Можно ли сделать уже существующую таблицу темпоральной?
Да: добавить два столбца datetime2 с GENERATED ALWAYS AS ROW START/END и значениями по умолчанию, объявить PERIOD FOR SYSTEM_TIME и включить SYSTEM_VERSIONING через ALTER TABLE.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)