Please note that the following info is not intended to be a comprehensive
description of how data pages are laid out, such that one can calculate
the number of bytes used per any set of rows, as that is very complicated.
数据不是8k数据页上唯一占用空间的东西:
有预留空间。您只允许使用8192字节中的8060(即从头开始从未使用过的132字节):
- 页头:恰好是96个字节。
- 插槽数组:每行2个字节,表示每行从页面开始的偏移量。该数组的大小不限于剩余的36个字节(132-96 = 36),否则您将被有效地限制为仅在数据页上最多放置18行。这意味着每一行比您认为的大2个字节。如所报告的
DBCC PAGE
,此值未包含在“记录大小”中,这就是为什么在此处将其保持单独而不是在以下每行信息中包含该值的原因。
- 每行元数据(包括但不限于):
- 大小取决于表定义(例如,列数,可变长度或固定长度等)。从@PaulWhite和@Aaron的评论中获取的信息可以在与该答案和测试有关的讨论中找到。
- 行标题:4个字节,其中2个字节表示记录类型,另外2个字节为NULL位图的偏移量
- 列数:2个字节
- NULL位图:当前是哪些列
NULL
。每8列每组1个字节。对于所有列,甚至是NOT NULL
那些列。因此,最少1个字节。
- 可变长度列偏移数组:最少4个字节。2个字节保存可变长度列的数量,然后每个可变长度列2个字节保存偏移量到它的起始位置。
- 版本控制信息:14个字节(如果您的数据库设置为
ALLOW_SNAPSHOT_ISOLATION ON
或,则将存在READ_COMMITTED_SNAPSHOT ON
)。
- 有关更多详细信息,请参见以下问答:插槽阵列和总页面大小
- 请参阅Paul Randall的以下博客文章,其中包含有关数据页面布局的一些有趣的详细信息:深入探讨DBCC PAGE(第1部分)
LOB指针用于未存储在行中的数据。这样就占了DATALENGTH
+ pointer_size。但是这些不是标准尺寸。有关此复杂主题的详细信息,请参见以下博客文章:(MAX)类型(如Varchar,Varbinary,Etc)的LOB指针的大小是多少?。在该链接的帖子和我已经完成的一些其他测试之间,(默认)规则应如下所示:
- 从SQL Server 2005开始
TEXT
,不再应使用的旧版/不推荐使用的LOB类型(NTEXT
,和IMAGE
):
- 默认情况下,始终将其数据存储在LOB页上,并始终使用16字节的指针指向LOB存储。
- 如果使用 sp_tableoption设置
text in row
选项,则:
- 如果页面上有空间存储该值,并且该值不大于最大行内大小(可配置范围为24-7000字节,默认值为256),则它将存储在行内,
- 否则它将是一个16字节的指针。
- 在SQL Server 2005中引入的较新的LOB类型(
VARCHAR(MAX)
,NVARCHAR(MAX)
,和VARBINARY(MAX)
):
- 默认情况下:
- 如果该值不大于8000个字节,并且页面上有空间,则它将存储在行中。
- 内联根(Inline Root)-对于空间介于8001到40,000(实际为42,000)字节之间的数据,在空间允许的情况下,IN ROW中将有1到5个指针(24-72字节)直接指向LOB页面。初始8k LOB页为24个字节,每增加8k页每增加12个字节,最多可再添加四个8k页。
- TEXT_TREE —对于超过42,000字节的数据,或者如果1到5个指针不能容纳在行中,则指向LOB页面的指针列表的起始页面将只有24个字节的指针(即“ text_tree” “页面)。
- 如果使用 sp_tableoption设置
large value types out of row
选项,则始终使用16字节的指针指向LOB存储。
- 我说:“默认”规则,因为我并没有反对的某些功能,如数据压缩,列级加密,透明数据加密,始终处于加密等冲击试验中,行值
LOB溢出页:如果值是10k,则将需要1个完整的8k页溢出,然后是第二页的一部分。如果没有其他数据可以占用剩余空间(我不确定该规则是否允许),那么在第二个LOB溢出数据页上大约有6kb的“浪费”空间。
未使用的空间:8k数据页就是:8192字节。它的大小没有变化。但是,放置在其上的数据和元数据并不总是很好地适合所有8192字节。行不能拆分为多个数据页。因此,如果您剩余100字节,但没有行(或者没有行适合该位置,具体取决于多个因素),那么数据页仍会占用8192字节,并且您的第二个查询仅计算数据页。您可以在两个位置找到此值(请记住,此值的某些部分是该保留空间的一部分):
DBCC PAGE( db_name, file_id, page_id ) WITH TABLERESULTS;
查找ParentObject
=“ PAGE HEADER:”和Field
=“ m_freeCnt”。该Value
字段是未使用的字节数。
SELECT buff.free_space_in_bytes FROM sys.dm_os_buffer_descriptors buff WHERE buff.[database_id] = DB_ID(N'db_name') AND buff.[page_id] = page_id;
该值与“ m_freeCnt”报告的值相同。这比DBCC容易,因为它可以获得很多页面,而且还需要首先将页面读入缓冲池。
保留空间FILLFACTOR
小于100。新创建的页面不遵守该FILLFACTOR
设置,但是执行REBUILD将在每个数据页面上保留该空间。保留空间背后的想法是,由于可变长度的列将使用稍微更多的数据进行更新(但不足以导致出现错误的情况),因此非连续插入和/或更新将使用它们来扩展页面上行的大小。页面拆分)。但是,您可以轻松地在数据页面上保留空间,这些数据页面自然不会获得新行,也不会更新现有行,或者至少不会以增加行大小的方式进行更新。
分页(碎片):需要在没有空间容纳行的位置添加行将导致分页。在这种情况下,大约50%的现有数据将移至新页面,并将新行添加到2个页面之一。但是您现在有更多的可用空间,这些空间无法通过DATALENGTH
计算来解决。
标记为删除的行。当您删除行时,它们并不总是立即从数据页中删除。如果不能立即将其删除,则会将它们“标记为已死亡”(Steven Segal参考),稍后将通过幽灵清理过程将其物理删除(我相信这是名称)。但是,这些可能与该特定问题无关。
鬼页?不知道这是否是正确的术语,但是有时直到完成“聚集索引”的重建后,数据页才会被删除。那也将占到页面DATALENGTH
总数之和。通常这应该不会发生,但是几年前我已经遇到过一次。
SPARSE列:稀疏列可节省表中的空间(主要用于固定长度的数据类型),在该表中,很大百分比的行NULL
用于一列或多列。该SPARSE
选项使NULL
值类型最多占用0个字节(而不是正常的固定长度数量,例如,为4个字节INT
),但是,非NULL值每个都为固定长度类型占用额外的4个字节,而为可变长度类型。这里的问题是,DATALENGTH
在SPARSE列中不包含用于非NULL值的额外4个字节,因此需要重新添加这4个字节。您可以通过以下方法检查是否有任何SPARSE
列:
SELECT OBJECT_SCHEMA_NAME(sc.[object_id]) AS [SchemaName],
OBJECT_NAME(sc.[object_id]) AS [TableName],
sc.name AS [ColumnName]
FROM sys.columns sc
WHERE sc.is_sparse = 1;
然后针对每一SPARSE
列,更新原始查询以使用:
SUM(DATALENGTH(FieldN) + 4)
请注意,以上添加标准4字节的计算有点简单,因为它仅适用于定长类型。而且,每行有额外的元数据(据我所知,这已经到了),只需具有至少一个SPARSE列即可减少可用于数据的空间。有关更多详细信息,请参见MSDN页面上的Use Sparse Columns。
索引页面和其他页面(例如IAM,PFS,GAM,SGAM等):就用户数据而言,这些不是“数据”页面。这些将增加表格的总大小。如果使用SQL Server 2012或更高版本,则可以使用sys.dm_db_database_page_allocations
动态管理功能(DMF)来查看页面类型(SQL Server的早期版本可以使用DBCC IND(0, N'dbo.table_name', 0);
):
SELECT *
FROM sys.dm_db_database_page_allocations(
DB_ID(),
OBJECT_ID(N'dbo.table_name'),
1,
NULL,
N'DETAILED'
)
WHERE page_type = 1; -- DATA_PAGE
无论是DBCC IND
也sys.dm_db_database_page_allocations
(与WHERE子句)将报告任何索引页,只有DBCC IND
将报告至少一个IAM页。
DATA_COMPRESSION:如果在“聚集索引”或“堆”上启用了“压缩” ROW
或“ PAGE
压缩”,那么您可以忘记到目前为止提到的大多数内容。96字节的页页眉,每行2字节的插槽数组和每行14字节的版本控制信息仍然存在,但是数据的物理表示变得非常复杂(比“压缩”时已经提到的要复杂得多)未使用)。例如,对于行压缩,SQL Server尝试使用最小的容器来容纳每一行的每一列。因此,如果您有一BIGINT
栏否则SPARSE
将占用8个字节(假设也未启用),如果该值在-128到127之间(即带符号的8位整数),则它将仅使用1个字节,并且如果价值可能适合SMALLINT
,它将仅占用2个字节。NULL
或0
不占用空间的整数类型,在映射出列的数组中简单地表示为“是” NULL
或“空”(即0
)。还有很多其他规则。使用Unicode数据(NCHAR
,NVARCHAR(1 - 4000)
,但没有 NVARCHAR(MAX)
,即使储存在行)?在SQL Server 2008 R2中添加了Unicode压缩,但是鉴于规则的复杂性,如果不进行实际压缩,就无法在所有情况下预测“压缩”值的结果。
因此,实际上,您的第二个查询虽然在磁盘上占用的总物理空间方面更为准确,但仅在执行REBUILD
“聚集索引” 时才真正准确。之后,您仍然需要考虑FILLFACTOR
低于100的任何设置。即使如此,总会有页面标题,并且经常有足够的“浪费”空间,这些空间由于太小而无法填充其中的任何行表,或至少在逻辑上应放入该插槽的行。
关于第二个查询在确定“数据使用量”方面的准确性,回退页面头字节似乎是最公平的,因为它们不是数据使用量:它们是业务成本的开销。如果数据页上有1行,而该行只是a TINYINT
,则该1字节仍需要数据页存在,因此需要标头的96个字节。该1个部门应该为整个数据页面付费吗?如果该数据页随后被部门2填满,他们是否会平均分配“开销”成本或按比例支付?似乎最容易将其退出。在这种情况下,使用值8
乘以number of pages
太高。怎么样:
-- 8192 byte data page - 96 byte header = 8096 (approx) usable bytes.
SELECT 8060.0 / 1024 -- 7.906250
因此,使用类似:
(SUM(a.total_pages) * 7.91) / 1024 AS [TotalSpaceMB]
针对“ number_of_pages”列的所有计算。
AND,考虑到使用DATALENGTH
每个字段不能返回每行元数据,应该将其添加到每表查询中,以获取DATALENGTH
每个字段,并在每个“部门”进行过滤:
- 记录类型和偏移量为NULL位图:4个字节
- 列数:2个字节
- 插槽阵列:2个字节(“记录大小”中不包括,但仍需考虑)
- NULL位图:每8列1个字节(对于所有列)
- 行版本控制:14个字节(如果数据库具有
ALLOW_SNAPSHOT_ISOLATION
或READ_COMMITTED_SNAPSHOT
设置为ON
)
- 可变长度列偏移数组:如果所有列都是固定长度,则为0个字节。如果有任何列为可变长度,则为2个字节,每个可变长度列加2个字节。
- LOB指针:这部分非常不精确,因为如果值为
NULL
,则不会有指针;如果该值适合行,则它可以比指针小得多或大得多,并且该值存储在以下位置:行,那么指针的大小可能取决于有多少数据。但是,由于我们只想要一个估计值(即“ swag”),因此似乎可以使用24个字节的值(很好,和其他任何;-一样)。这是每个MAX
字段。
因此,使用类似:
通常(行标题+列数+插槽数组+ NULL位图):
([RowCount] * (( 4 + 2 + 2 + (1 + (({NumColumns} - 1) / 8) ))
通常(自动检测是否存在“版本信息”):
+ (SELECT CASE WHEN snapshot_isolation_state = 1 OR is_read_committed_snapshot_on = 1
THEN 14 ELSE 0 END FROM sys.databases WHERE [database_id] = DB_ID())
如果有任何变长列,则添加:
+ 2 + (2 * {NumVariableLengthColumns})
如果有MAX
/ LOB列,则添加:
+ (24 * {NumLobColumns})
一般来说:
)) AS [MetaDataBytes]
这是不正确的,如果您在堆索引或聚簇索引上启用了行或页面压缩,则再次无法使用,但绝对可以使您更接近。
关于差异15%之谜的更新
我们(包括我本人)非常专注于思考数据页面的布局方式以及如何DATALENGTH
解释这些问题,因此我们没有花很多时间来审查第二个查询。我针对单个表运行了该查询,然后将这些值与报告的值进行了比较sys.dm_db_database_page_allocations
,它们的页数值不同。凭直觉,我删除了聚合函数和GROUP BY
,并将SELECT
列表替换为a.*, '---' AS [---], p.*
。并且然后它变得清晰:其中在这些阴暗的interwebs他们得到他们的信息和脚本的人一定要小心;-)。问题中发布的第二个查询并不完全正确,尤其是对于该特定问题。
次要问题:它不造成太大的意义之外GROUP BY rows
(而不是在一个聚合函数列)之间的JOIN sys.allocation_units
和sys.partitions
不技术上是正确的。分配单元有3种类型,其中一种应加入另一个字段。经常partition_id
并且hobt_id
相同,因此可能永远不会有问题,但是有时这两个字段的值确实不同。
主要问题:查询使用used_pages
字段。该字段涵盖所有类型的页面:数据,索引,IAM等,tc。仅关注实际数据时,还有一个更合适的字段可以使用:data_pages
。
考虑到以上各项,我改编了Question中的第二个查询,并使用了回退页眉的数据页大小。我也删除2 JOIN的这是不必要的:sys.schemas
(与调用替换SCHEMA_NAME()
),和sys.indexes
(聚集索引总是index_id = 1
与我们index_id
的sys.partitions
)。
SELECT SCHEMA_NAME(st.[schema_id]) AS [SchemaName],
st.[name] AS [TableName],
SUM(sp.[rows]) AS [RowCount],
(SUM(sau.[total_pages]) * 8.0) / 1024 AS [TotalSpaceMB],
(SUM(CASE sau.[type]
WHEN 1 THEN sau.[data_pages]
ELSE (sau.[used_pages] - 1) -- back out the IAM page
END) * 7.91) / 1024 AS [TotalActualDataMB]
FROM sys.tables st
INNER JOIN sys.partitions sp
ON sp.[object_id] = st.[object_id]
INNER JOIN sys.allocation_units sau
ON ( sau.[type] = 1
AND sau.[container_id] = sp.[partition_id]) -- IN_ROW_DATA
OR ( sau.[type] = 2
AND sau.[container_id] = sp.[hobt_id]) -- LOB_DATA
OR ( sau.[type] = 3
AND sau.[container_id] = sp.[partition_id]) -- ROW_OVERFLOW_DATA
WHERE st.is_ms_shipped = 0
--AND sp.[object_id] = OBJECT_ID(N'dbo.table_name')
AND sp.[index_id] < 2 -- 1 = Clustered Index; 0 = Heap
GROUP BY SCHEMA_NAME(st.[schema_id]), st.[name]
ORDER BY [TotalSpaceMB] DESC;