一个字节造成的巨大性能差异——SQL Server存储结构

本文通过对比两张表的存储情况,深入探讨了SQLServer内部存储结构,并解释了因一字节之差导致存储空间翻倍的原因。同时给出了提高数据库查询性能的设计建议。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

今天同事问了我一个SQL的问题,关于SQL Server内部存储结构的,我觉得挺有意思,所以写下这篇博客讨论并归纳了一下。问题是这样的:

首先我们创建两张表,一张表的列长度是4039字节,另一张表的长度是4040字节,他们就只有一个字节的差距,比如以下创建表的SQL:

CREATE   TABLE  tb4039(c1  INT   IDENTITY ,c2  char ( 4035 not   null )
CREATE   TABLE  tb4040(c1  INT   IDENTITY ,c2  char ( 4036 not   null )

 

由于INT类型是4个字节,所以我们创建的tb4039表有4+4035=4039个字节的长度,tb4040中的c2字段比tb4039中的c2字段多了一个字节,总长度是4040字节,其他没有区别了。接下来是向这两个表中插入数据,比如插入100条数据,SQL语句是:

DECLARE   @i   INT
SET   @i = 1
WHILE   @i <= 100
BEGIN
    
INSERT   INTO  tb4039 (c2)  VALUES ( ' test ' + CONVERT ( VARCHAR ( 5 ), @i ));
    
INSERT   INTO  tb4040 (c2)  VALUES ( ' test ' + CONVERT ( VARCHAR ( 5 ), @i ));
    
SET   @i = @i + 1
END

 

好,现在我们使用SSMS来查看一下这两个表的空间占用量,如果是SQL2005,那么可以使用SSMS自带的报表查看,如果是SQL2008,那么直接使用对象资源管理器详细信息界面进行查看(如果使用的是SQL2008而不知道怎么查看表空间使用量那么请查看我以前写的一篇博客:SQL Server 2008新特性之SSMS增强)。我这里使用的是SQL2008,查看到的情况如图:

image

当然,我们也可以使用T-SQL来查询系统视图,得出这两个表的数据占用的空间,查询代码为:

SELECT   OBJECT_NAME (i. object_id AS  TableName,data_pages * 8   AS  DataSize  -- 这里返回的是数据页个数,1页是8K,所以乘以8
FROM  sys.indexes  as  i
JOIN  sys.partitions  as  p  ON  p. object_id   =  i. object_id   and  p.index_id  =  i.index_id
JOIN  sys.allocation_units  as  a  ON  a.container_id  =  p.partition_id
where  i. object_id = OBJECT_ID ( ' tb4039 ' OR  i. object_id = OBJECT_ID ( ' tb4040 ' )

 

系统返回结果:

TableName    DataSize
tb4039           400
tb4040           800

和我们通过报表或者SSMS查看到的结果相同,两个表只相差了一个字节,可是一个占用了400K的存储空间,另一个却占用了800K的存储空间,是另一个表的双倍!!!

一个字节的差距就造成了存储空间成倍的增加,为什么会这样呢?这就要从SQL Server存储结构讲起。

------------------------------------------------华丽的分割线,进入主题-----------------------------------------------------------------

SQL Server最小的存储单位是页(Page),一个页的大小是8K=8192字节。一个数据页是由3部分组成:页头、数据行和行偏移矩阵,具体结构如图:

image

页头保存了页的编号、上一页ID、下一页ID、可以字节数等等关于该页的基本信息。页头的大小是固定的96个字节,所以剩下8192-96=8096个字节用于存储数据行和行偏移矩阵。

行偏移矩阵在页的最后面,而且是倒序排列的,使用2个字节来表示数据行在页面内部的偏移量,有1行数据则行偏移矩阵的大小是2字节,有2行数据则行偏移矩阵的大小是4字节,以此类推。

除了页头占用的空间和行偏移矩阵占用的空间,中间剩下的空间就是给数据行使用的。假设我们要在一个页中保存2行数据,那么这2行数据可以使用8096-4=8092个字节的空间,也就是说1行数据可以使用8092/2=4046个字节的空间。这里的4046个字节并不是完全都用来保存数据行,一个数据行中还存在其他的信息用于表示该行数据,具体的结构是这样的:

状态位A状态位B定长数据类型的长度定长数据的内容列数
NULL位图
变长列的个数变长列的偏移矩阵变长列的数据
1字节
1字节2字节具体定长数据字节2字节
列数/8个字节
2字节
变长列个数*2个字节具体变长数据字节

不管我们对表的定义是多么的简单,一行数据除了数据自身占用的空间外,至少还要占用1+1+2+2+1=7个字节。如果定义的数据列很多,或者里面有变长数据列,那么占用的空间可能会更多。

现在回到我们前面讲到的2个表tb4039和tb4040,要存储tb4039中的一行数据需要1+1+2+4039+2+1=4046字节,所以正好可以在一个页中保存2行数据。所以插入了100行数据,实际上是保存在50个数据页中,大小就是8K*50=400K。而对于tb4040表,要存储一行数据需要4047个字节,没法在一个页中保存2行数据,所以一行数据就占用一个数据页,100行数据占用了100个数据页,大小就是8K*100=800K。

--------------------------------------------做了一堆加减乘除,下面总结下--------------------------------------------

这里只是举了一个极端的例子,所以造成了一个字节的偏差而使占用的存储空间翻倍,在实际应用中很少会出现这么极端的情况,但是很有可能使一个页存储5条数据的因为某个列多了1个2个字节所以只能存储4条数据。也许大家认为少存一条数据并没有什么,但是在数据量变的非常庞大以后一页4条数据和一页5条数据将会产生明显的性能差异。使得一页中存放更多的数据并不是为了节约存储成本,现在的硬盘已经很便宜了很多服务器都是几百个G的硬盘,本来5G的数据现在变长了10G,相对几百个G上T的硬盘来说又算得了什么。

实际上我们要让一个数据页中存放更多行的数据主要是出于性能的考虑。SQL Server进行数据库读写操作的基本单位是页,如果一页中存放了更多的数据,那么对表进行扫描和查找时进行的IO操作将减少,毕竟IO操作是非常消耗时间影响性能的。假设tb4039中有100W条数据,那么进行全表扫描就要读取50W个数据页,如果读取10W个数据页花费1秒钟,那么对表tb4039进行扫描需要花费5秒钟时间,而如果是使用tb4040存储这100W条数据,进行全表扫描则需要读取100W个数据页,总共花费10秒钟时间。就一个字节的差别,一个是5秒另一个是10秒,对性能的影响非常明显。

为了提高数据库查询的性能,在表设计时可以遵循以下建议:

  • 主键尽可能的短,能用tinyint的就不要用int,能用char(5)的就不要用成varchar(50)。
  • 计算好表列的长度,能够在一个页中存放5条数据的,那就不要将字段设置的太长使得一个页中只能存放3条或者4条数据。
  • 尽量将字段设置为不允许为NULL,因为NULL值在存储和数据处理时系统需要专门的处理,降低了性能。
  • 能够用固定长度的就不要用变长字段,比如身份证号就可以使用CHAR(18),而不应该使用VARCHAR(18)。
  • 不要在一个表中建立太多的列,如果一个实体的属性太多时可以考虑进行垂直分割,将常用的字段放在一个表,不常用的字段放另外的表,这样可以减小常用字段表中数据列占用的空间,使得一个数据页中存储更多的数据行。
  • 不要将大对象、长字符串和常用的字段放在同一个表中。同样还是出于性能上的考虑,比如有个产品表,里面有产品ID、产品名字、产品售价、产品图片、产品描述等字段,那么我们可以将产品ID、产品名字、产品售价这几个常用的而且占用空间小的列放在一个表,然后建立产品ID、产品图片、产品描述这样的表,通过外键约束的方式将大对象数据和长字符串数据放在另一个表中。
<think>嗯,用户想了解SQL Server中timestamp数据类型的用途和使用场景。首先需要明确timestamp在SQL Server中的特殊性质——它其实和时间无关,而是个版本戳机制。用户可能被名称误导了,以为这是时间字段。 参考引用内容[1]说得很清楚:SQL Server timestamp是二进制数字,表示数据修改的相对顺序,最初用于支持恢复算法。每次修改页面时用@@DBTS标记,然后自增。这解释了核心机制。引用[2]补充了语法细节:timestamp列名可自动生成,但rowversion同义词必须指定列名,说明微软更推荐使用rowversion这个名称以避免歧义。 用户可能实际需要的是记录时间的功能,但timestamp无法满足。需要强调三点:1)二进制本质 2)行版本追踪作用 3)替代方案(如datetime2)。引用[3]提到MySQL的timestamp是真实时间类型,正好形成对比,可以说明SQL Server的特殊设计。 使用场景方面,并发控制是重点。结合引用[1]的恢复算法用途,可以延伸出事务日志关联性。但要注意提醒用户timestamp已被弃用,需改用rowversion。最后要给出明确建议:需要时间戳就用datetime2,需要版本戳就用rowversion。 用户可能还混淆了timestamp和rowversion的关系,需要明确说明二者是同义词但rowversion更准确。语法差异(如列名规则)也要提及,引用[2]正好支持这点。性能方面,虽然引用没提,但可以补充8字节二进制比时间戳更轻量。</think>好的,我们来详细解释 SQL Server 中 `timestamp` 数据类型的**真正用途**和使用场景。需要特别强调的是,尽管名字叫 `timestamp`,但它**与日期和时间完全无关**,这是一个常见的误解点。 **核心用途:行版本戳 (Row Versioning)** 1. **本质:** `timestamp` 是一个**自动递增的二进制数**(8字节),在数据库范围内唯一。它的主要作用是**标识表中特定行发生数据修改的相对顺序**。 2. **行为:** * 每当向表中插入新行或更新现有行时,该行的 `timestamp` 列的值**会自动更新**为当前数据库的 `@@DBTS` 值(数据库时间戳,一个单调递增的计数器)。 * 更新后,`@@DBTS` 的值会自动加 1。 * 这个值**不是时间值**,而是数据库内部用来追踪数据修改发生顺序的机制[^1]。 * 每个数据库有自己独立的 `@@DBTS` 计数器。 3. **主要目的:** * **支持恢复算法 (Historical):** 最初设计是为了帮助 SQL Server 的恢复过程确定数据页修改发生的相对顺序[^1]。虽然现代恢复机制可能更复杂,但这个底层机制仍然存在。 * **乐观并发控制 (Optimistic Concurrency Control):** 这是 `timestamp` 在现代应用开发中最常用的场景。 * **原理:** 当应用程序读取一行数据时,它会同时获取该行当前的 `timestamp` 值。稍后当应用程序尝试更新该行时,它会在 `WHERE` 子句中包含最初读取的 `timestamp` 值。 * **检测冲突:** 如果在此期间(读取后、更新前)有其他用户或进程修改了该行,那么该行的 `timestamp` 值就会改变(因为每次修改都会更新它)。此时,应用程序的更新语句(`WHERE timestamp = @OriginalTimestamp`)将找不到匹配的行(因为 `timestamp` 值变了),更新操作会报告“0 行受影响”。 * **处理冲突:** 应用程序检测到“0 行受影响”时,就知道数据在它读取之后已经被别人修改过了(发生了冲突)。应用程序可以据此决定如何处理,例如:重新读取最新数据并提示用户重新操作、合并更改、或者放弃本次更新。 * **优点:** 避免了在读取时锁定行(悲观并发控制),提高了并发性能,特别适合在冲突发生概率较低的场景。 **使用场景:** 1. **乐观并发控制:** 如前所述,这是最主要的应用场景。用于检测在读取数据和提交更新之间,其他会话是否修改了同一行数据[^1]。 2. **行变化检测 (有限场景):** 如果需要快速判断**某一行**是否自上次读取后被修改过(而不关心具体修改了什么),可以比较当前的 `timestamp` 值和之前读取的值。如果值变了,说明行被修改过。*注意:这通常不如直接查询修改时间或使用 `CHECKSUM`/`HASHBYTES` 计算行哈希值精确,因为 `timestamp` 只告诉你变了,不告诉你哪里变了。* 3. **生成唯一键 (不推荐):** 理论上,`timestamp` 在数据库范围内是唯一的,可以用于生成唯一标识符(结合其他列或单独使用 `CONVERT(bigint, timestamp)`)。然而,`uniqueidentifier` (GUID) 或 `IDENTITY` 列通常是更好的选择,因为 `timestamp` 值在数据库恢复或某些操作后可能会重置(虽然非常罕见)。 **重要注意事项和替代方案:** 1. **`timestamp` 已弃用 (Deprecated):** **强烈建议使用其同义词 `rowversion` 代替 `timestamp`。** `rowversion` 在语义上更清晰,明确表示这是行版本标识,避免与时间概念混淆。功能上两者完全等价[^2]。 2. **列名规则:** * 使用 `timestamp` 关键字时,可以省略列名(SQL Server 会自动生成一个名字,通常是 `timestamp`)。 * 使用 `rowversion` 关键字时,**必须显式指定列名**[^2]。例如: ```sql CREATE TABLE MyTable ( ID INT PRIMARY KEY, Data VARCHAR(50), RV rowversion -- 必须指定列名 RV ); ``` 3. **与时间和日期无关:** 再次强调,`timestamp`/`rowversion` **存储的是二进制数据,不是日期或时间**。如果你需要存储日期和时间,应该使用 `datetime`, `datetime2`, `smalldatetime`, 或 `date`/`time` 类型[^1][^3]。 4. **每个表只能有一列:** 一个表只能有一个 `timestamp`/`rowversion` 类型的列。 5. **不可为空:** `timestamp`/`rowversion` 列不允许为 `NULL`。 6. **`@@DBTS`:** 这个系统函数返回数据库当前最新的 `timestamp`/`rowversion` 值。 7. **与 MySQL 的 `TIMESTAMP` 完全不同:** MySQL 的 `TIMESTAMP` 类型是真正的日期时间类型,通常用于记录行的创建或最后修改时间,并且有时区转换行为。这与 SQL Server 的 `timestamp`/`rowversion` 在本质和用途上截然不同[^3]。 **总结:** SQL Server 的 `timestamp`(应优先使用 `rowversion`)数据类型是一个**自动递增的二进制行版本标识符**。它的核心作用是: 1. **支持乐观并发控制:** 检测在读取和更新之间数据行是否被其他会话修改,是处理并发更新冲突的有效机制。 2. **(历史上) 辅助恢复过程:** 确定数据修改发生的相对顺序。 **请务必记住:** 它**不存储任何日期或时间信息**。如果需要记录时间,请使用 `datetime2` 等日期时间类型。 **相关问题
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值