☰
SQL Server 2000老库mdf收缩:DBCC SHRINKFILE实战
2026/11/4 7:36:43 网站建设 项目流程

简介:文档围绕 SQL Server 2000 数据库文件深度压缩主题展开,面向仍需维护老旧 SQL Server 实例的数据库管理员与开发人员,针对企业管理器“收缩数据库”在频繁删除后释放冗余空间有限的问题,给出基于 DBCC 命令的深度处理思路。资源包仅含 1 个 docx 文件,约 256KB,内容集中、便于随查随用。文档覆盖 DBCC SHRINKDATABASE、DBCC SHRINKFILE 与 DBCC UPDATEUSAGE 等命令的适用场景,涉及数据文件和日志文件的收缩差异、fileid 与目标大小的识别、收缩后的尺寸比对,以及操作前备份、I/O 性能变化和文件碎片等风险提示;并以命令应用、结果核对和风险控制为重点,强调更新使用情况对空间统计准确性的意义。已有 313 人学习,适合需要排查历史库空间占用、理解深度收缩原理与维护边界的读者参考。

1. 老库缩容的真相:企业管理器点了没反应,DBCC 却把 mdf 从 6.8G 压到 2.1G

一台跑了很多年的 SQL Server 2000 实例,日志表清掉了将近四成的历史记录,数据库文件纹丝不动。企业管理器里的“收缩数据库”连着点了一个月,mdf 只掉了十几兆,磁盘告警照旧。后来改用查询分析器逐条执行 dbcc shrinkfile,按 fileid 把数据文件和日志文件分开压,一次操作下来文件从 6.8G 落到 2.1G,回来再看收缩数据库那个按钮,基本成了一个心理安慰。深度压缩数据库文件这件事,SQL Server 2000 时代真正管用的不是图形界面,而是 DBCC 系列命令加上对文件分配结构的理解。这篇笔记面向还在维护 2000 老库的同行:手里有几个 G 到几十个 G 的 mdf 需要腾空间,又不敢乱动生产库,下面的步骤和参数可以直接照抄。

2. 动手前先把文件摸清楚:sysfiles 里的 fileid 和 8KB 页单位

2.1 为什么“收缩数据库”这个按钮总是不给力

SQL Server 2000 的存储单位是页,一页 8KB,八个连续页组成一个区(extent,64KB)。数据文件内部靠 GAM、SGAM、PFS 这几类分配页记录哪些区被占用、哪些页有空闲空间。删除记录时,引擎只是在页上打删除标记、把空间登记回 PFS,文件本身一个字节都不会还给操作系统。

收缩动作的本质是从文件尾部往前扫:遇到尾部有活动页,就找一个靠前的空闲页把数据搬过去,搬完之后截断文件尾部。所以能不能压下去,取决于文件末尾那一段有没有“挪不动的页”。索引根页、堆表的转发记录、text/ntext/image 类型的 LOB 页链、还没提交的事务锁住的页,都属于挪不动的那一类。企业管理器里的“收缩数据库”背后调用的同样是 SHRINKDATABASE,默认不带目标值,只做一轮回收,碰上这些卡点就停在那里。效果不明显不是命令坏了,是它只做了半程。

另一个容易被忽略的开关是数据库选项 autoshrink。它会在后台空闲时偷偷收缩,收缩完文件又因为新数据增长,反复增删文件,碎片越攒越多。

-- 先确认自动收缩是不是开着的,老库上这个开关基本都建议关掉 EXEC sp_dboption '目标库名', 'autoshrink' -- 返回 true 就关掉 EXEC sp_dboption '目标库名', 'autoshrink', 'false'

sp_dboption的第一个参数是库名,第三个参数是目标状态。这条命令只影响指定库,改完不需要重启服务。关掉之后所有收缩动作都由人手工触发,什么时候压、压到多少,全在自己手里,比让后台线程半夜乱动要可控得多。

2.2 用 sysfiles 读出 fileid、初始大小与增长方式

sysfiles是每个数据库自己维护的系统表,记录该库所有数据文件和日志文件的物理路径、初始大小、增长方式。项目正文里让先执行select * from sysfiles,目的就是拿 fileid,因为后面的 shrinkfile 要靠它定位文件。

-- 切到要操作的库,注意查询分析器工具栏上的库名下拉框也要跟着选 USE 目标库名 GO SELECT fileid, name, filename, size AS 页数_8KB, size * 8 / 1024 AS 大小_MB, growth, status FROM sysfiles ORDER BY fileid

字段含义要记牢:size的单位是 8KB 页,不是 MB,直接看数字会吓一跳,乘以 8 再除以 1024 才是 MB。growth是增长步长,值为 0 表示按百分比增长,非 0 表示按固定页数增长,这个值在收缩之后会直接影响文件多久又长回去。filename是带盘符的完整物理路径,顺手确认一下磁盘剩余空间。

fileid 的判别不要图省事。单数据文件的库,fileid=1 是主数据文件(.mdf),fileid=2 是日志文件(.ldf),这是最常见的情况。但只要库里有第二个数据文件(.ndf),日志的 fileid 就会往后排,硬编码 2 会压错文件。稳妥的写法是按扩展名匹配:

-- 按物理文件后缀判断谁是日志文件,比记 fileid 靠得住 SELECT fileid, name, filename FROM sysfiles WHERE filename LIKE '%.ldf'

name是逻辑文件名,收缩命令的第一个参数也可以直接写它,比写数字可读性好,迁移或者加文件之后也不容易出错。

2.3 备份、空间余量与操作窗口检查

项目正文开头特意啰嗦了一句先备份,这句话不是客套。DBCC SHRINKFILE 的页移动是完整记录日志的操作,一个 5G 的库在收缩过程中产生的日志量可能超过 1G,日志盘吃紧的机器直接报 9002 错误失败,中途还留下一个更大的日志文件。

-- 完整备份,STATS = 10 表示每完成 10% 报一次进度 BACKUP DATABASE 目标库名 TO DISK = 'E:\dbbackup\目标库名_full.bak' WITH INIT, STATS = 10 GO -- 备份完立刻校验,确认这个 .bak 是能恢复的 RESTORE VERIFYONLY FROM DISK = 'E:\dbbackup\目标库名_full.bak'

WITH INIT会覆盖同名备份文件,如果目录里已经有当天更早的一份,换个带时间戳的文件名,别图省事。RESTORE VERIFYONLY只读不写,几秒钟就能确认备份集完整,这一步跳过的人,出问题时通常没有第二份后悔药。

空间余量要同时看三处:备份盘要能放下整库,日志盘要预留至少三分之一库大小的空闲,数据文件所在盘要留出收缩过程中的临时开销。操作窗口挑在业务低谷,最好能明确告诉相关方这段时间内不要跑大批量导入导出,收缩期间的大事务会让命令一直等锁,表现就是卡住不动。

3. DBCC SHRINKDATABASE:先把整库水位线拉下来

3.1 语法三个参数:target_percent、NOTRUNCATE 与 TRUNCATEONLY

整库收缩的命令形状是DBCC SHRINKDATABASE (库名 [, 目标百分比] [, NOTRUNCATE | TRUNCATEONLY])。三个参数各管一件事,理解错了就容易出现“跑了但没变化”。

target_percent 是收缩完成后文件内部保留多少百分比的可用空间,取值 0 到 100。填 0 意味着尽量压到最小,文件内部几乎不留余量,紧接着的一次批量写入立刻触发文件增长。

NOTRUNCATE 表示把数据页从文件前部搬到后部,但释放出来的空间不还给操作系统,文件在磁盘上还是原来那么大,只是内部可用空间集中到了尾部。它的价值在于配合后面的截断动作,把搬页和截断拆成两步。

TRUNCATEONLY 则相反,不搬任何页,直接把文件尾部的连续空闲区截掉还给操作系统。速度快,风险低,但它不管文件中间的碎片,只会把“已经空闲的尾巴”切掉。TRUNCATEONLY 会忽略 target_percent。

-- 常规做法:整库收缩,保留 10% 可用空间 DBCC SHRINKDATABASE (目标库名, 10) GO -- 进阶做法:先把页搬干净,再单独截断,两步之间可以看一眼效果 DBCC SHRINKDATABASE (目标库名, 10, NOTRUNCATE) DBCC SHRINKDATABASE (目标库名, 10, TRUNCATEONLY)

上面第二种组合是我在老库上更常用的方式。NOTRUNCATE 阶段耗时最长,因为它真的在搬数据页;TRUNCATEONLY 阶段几乎是瞬间完成。拆开的好处是中途可以中断,也可以先看搬页效果再决定截不截。

3.2 返回的六列怎么读:CurrentSize 与 EstimatedPages 的差值

命令执行完会返回一张结果集,列是 DbId、FileId、CurrentSize、MinimumSize、UsedPages、EstimatedPages。这六列是判断“还能不能继续压”的唯一依据,单位全部是 8KB 页。

列名含义判读方式
DbId数据库 ID确认操作的是目标库
FileId文件 ID和数据文件、日志文件对应
CurrentSize当前文件页数乘以 8 除以 1024 得到 MB
MinimumSize文件创建时的初始页数压不到比它更小
UsedPages实际使用页数反映真实数据量
EstimatedPages预计收缩后的页数与 CurrentSize 差得越大,压缩空间越大

举例,某库返回 CurrentSize 875000、EstimatedPages 430000,换算下来是 6835MB 对 3359MB,差距接近一半,说明还有得压。如果 EstimatedPages 已经贴着 CurrentSize,再跑十遍也不会动,这时候要回头查是什么页钉在文件尾部。

MinimumSize这一列容易被忽略。文件初始创建时如果是 500MB,后续增长到 6G,收缩命令最多把它压回 500MB 上下,不可能压到零。项目正文里dbcc shrinkfile(2, 0)的第二个参数传 0,表达的也是“压到初始尺寸”这个意图。

3.3 百分比取值与执行时机的确定

target_percent 填多少,直接决定这次收缩能撑多久。填 0 是典型的自欺欺人:文件压到最小,第二天早上的日结批处理立刻让它按 growth 涨回去,涨回去的过程比收缩还慢,中间还要抢磁盘 IO。

我的经验值是按库的写入节奏定。每天有批量写入的库填 20 到 30,每周才写一次的报表库填 5 到 10。填完之后记住 CurrentSize 的 MB 数,下次收缩前对比一下,如果两次之间的增长量远超预留空间,说明预留比例定低了,或者该考虑加盘而不是反复收缩。

执行时机上有个判断标准,比看时钟更实在:先跑一遍EXEC sp_spaceused,看 unallocated space 有多大。这个数字远小于你想腾出来的空间,说明确实有大量已分配但未使用的页,值得压;如果 unallocated space 本来就很小,那压不出多少东西,问题在数据量本身。

4. DBCC SHRINKFILE:按 fileid 逐个压到初始大小

4.1 数据文件与日志文件必须分开处理的原因

数据文件和日志文件的内部结构完全不同,压缩逻辑也就不同。数据文件里是一棵棵 B 树和堆,收缩要搬真实的数据页;日志文件是一串虚拟日志文件(VLF)顺序排列,收缩只能把“逻辑日志尾部”之后的空闲 VLF 释放掉。

这个差别导致一个很常见的翻车现场:日志没截断,直接执行 shrinkfile,命令跑得飞快,返回的 CurrentSize 几乎没变,掉个几十 KB 意思一下。原因不是命令没执行,是逻辑日志尾部后面根本没有可释放的空间,事务日志还挂在那里。

-- 完整恢复模型下,做一次日志备份就能把逻辑日志截断 BACKUP LOG 目标库名 TO DISK = 'E:\dbbackup\目标库名_log.trn' WITH INIT GO -- 简单恢复模型的库,也可以临时打开日志截断选项 EXEC sp_dboption '目标库名', 'trunc. log on chkpt', 'true'

BACKUP LOG和trunc. log on chkpt二选一即可。前者保留完整的日志链,适合要求可回滚到某个时间点的库;后者简单粗暴,检查点一到就截断,适合只要求能恢复到上次全备的库。选哪个取决于业务对数据丢失窗口的容忍度,这个决定不该由 DBA 一个人拍。

4.2 dbcc shrinkfile(2,0) 和 dbcc shrinkfile(1,0) 的执行顺序

项目正文给的顺序是先dbcc shrinkfile(2, 0)压日志,再dbcc shrinkfile(1, 0)压数据。小库上这么干没问题,日志空间充裕,先压后压都一样。库超过两三个 G、日志盘又紧张的时候,我更倾向把顺序反过来:

-- 1) 先压数据文件,这一步本身会产生大量事务日志 DBCC SHRINKFILE (1, 0) GO -- 2) 立刻截断上一步产生的日志,避免日志盘被撑爆 BACKUP LOG 目标库名 TO DISK = 'E:\dbbackup\目标库名_log2.trn' WITH INIT GO -- 3) 最后压日志文件,此时尾部空闲最多,一次能压到位 DBCC SHRINKFILE (2, 0) GO

顺序背后的逻辑是:压数据文件是“制造日志”的过程,压日志文件是“清理日志”的过程,清理必须排在制造之后。先压日志再压数据,等于先把垃圾倒了再制造一堆新垃圾,日志文件最后还是大的。

fileid 的部分再强调一次,别硬编码。用动态 SQL 按物理后缀取,多数据文件的库上能省掉一次误操作:

DECLARE @fid int, @sql nvarchar(400) -- 取日志文件的 fileid SELECT @fid = fileid FROM sysfiles WHERE filename LIKE '%.ldf' SET @sql = 'DBCC SHRINKFILE (' + CAST(@fid AS varchar(10)) + ', 0)' EXEC (@sql)

第二个参数传 0 表示压到初始大小;要精确控制就传 MB 数,比如DBCC SHRINKFILE (1, 1024)把数据文件压到 1GB。分阶段压(先压到 3000,再压到 0)在超大库上更稳,单次搬页量小,中途失败也好恢复。

4.3 dbcc updateusage(0):把统计口径校准回真实值

删过大量数据的库,sysindexes里记录的行数和页数是估算值,删完之后不会自动修正。这带来两个后果:sp_spaceused显示的空间虚高,你按这个数字定 target_percent 就会定错;收缩命令判断哪些区可回收时也会被带偏。

-- 0 表示当前 USE 上下文所在的库,不是“所有库” USE 目标库名 GO DBCC UPDATEUSAGE (0) GO

这里有个流传很广的误解,说DBCC UPDATEUSAGE (0)里的 0 代表所有数据库。实际执行时它只作用于当前上下文库,处理多库要逐库切换再跑。我在一个实例上按“0 代表全部”的思路操作过,第二天发现另外几个库的统计还是老样子,白等了一晚上。

执行时间取决于库里的表数量,几十张表的小库几秒钟,上千张表的库要几分钟。它会扫描索引页修正行数,属于重 IO 操作,同样放在维护窗口里做。跑完之后立刻用sp_spaceused对一次,数字会明显变化,那才是真实占用。

4.4 结果验证:sp_spaceused 与资源管理器里的文件尺寸对账

收缩是否成功,不能只看命令返回的结果集,要做三方对账:命令返回的 CurrentSize、sp_spaceused报的 database_size、资源管理器里 .mdf 文件的实际字节数。三个数字应该基本一致,对不上就说明某一环没生效。

USE 目标库名 GO EXEC sp_spaceused GO -- 逻辑文件尺寸,收缩成功后应该和磁盘上的实际文件大小一致 SELECT name, size * 8 / 1024 AS 大小_MB FROM sysfiles

sp_spaceused返回 database_size 和 unallocated space 两项,前者是文件总大小,后者是文件内部未分配的空间。收缩之后 unallocated space 会明显变小,因为尾部空间被截掉了。如果 database_size 变小了但磁盘文件没变,回头查是不是 NOTRUNCATE 之后忘了执行 TRUNCATEONLY。

还有一个细节:Windows 资源管理器有缓存,文件大小可能不刷新,按 F5 或者关掉窗口重开。我因为这个问题怀疑过自己半小时,最后发现是资源管理器没刷新。

5. 避坑排查:五个把生产库搞出问题的瞬间

5.1 日志文件只掉几十 KB

现象:执行dbcc shrinkfile(2, 0)秒回,返回的 CurrentSize 和操作前几乎一样,文件只在几十 KB 的量级上变化。

原因:日志的逻辑尾部之后没有空闲 VLF。库处于完整恢复模型却从没做过日志备份,或者有一个长时间未提交的事务把日志头钉住,收缩只能在活动日志之后截断,能释放的空间自然只有一点点。

解决:先做一次BACKUP LOG,再用DBCC LOGINFO看 VLF 状态,status 为 2 的是活动 VLF,它们的位置决定了能压到哪。同时用sp_who2排查长事务,找到对应 spid 确认能否处理。这两步做完再收缩,日志通常能一次压到位。

5.2 硬编码 fileid=2 结果压错了文件

现象:执行完收缩,数据文件一点没变,或者直接报“文件 2 不存在”之类的错误。

原因:库里存在第二个数据文件(.ndf),fileid=2 分配给了它,真正的日志文件 fileid 变成了 3。命令本身没错,是参数指向了错误的文件。

解决:改成按filename LIKE '%.ldf'取 fileid,或者直接用逻辑文件名DBCC SHRINKFILE ('目标库名_Log', 0)。命令执行前先跑一遍SELECT fileid, name, filename FROM sysfiles,肉眼看一遍再执行,这个习惯能挡掉大部分误操作。

5.3 收缩卡在 90% 不动或超时

现象:命令跑了几十分钟不返回,查询分析器报超时断开,但服务端的会话还在跑,CPU 和磁盘持续有压力。

原因:三种可能。页移动逐页进行并且完整记日志,日志文件涨满后写不进去直接挂住;表上有大事务或长时间锁,收缩在等锁;客户端连接超时设置太短,命令还在跑,界面先断了。

解决:查询分析器里把连接超时改成 0(工具菜单下的选项里设),避免界面误报;收缩前确认日志盘余量;超大库拆成几段压,先压到中间值再压到 0。命令超时断开后不要立刻重跑,先用sp_who2确认之前的会话真的结束了。

5.4 updateusage 之后数字反而变大

现象:跑完dbcc updateusage(0)再执行sp_spaceused,reserved 和 data 两项比之前还大,看起来像是把库撑大了。

原因:之前的数字是估算值,删完数据后sysindexes没更新,显示偏小。updateusage 把它校正成真实占用,数字变大说明之前看到的都是虚的,库从来没变小过。

解决:把 updateusage 放到收缩之前执行,先用真实数字判断该不该压、压到多少,别拿假数据定参数。校正之后再收缩,返回的 UsedPages 才有参考价值。

5.5 收缩完业务查询突然变慢

现象:文件确实小了,磁盘也腾出来了,但某些按范围查询的语句从几百毫秒变成几秒,日结批处理耗时翻倍。

原因:页移动打乱了聚集索引的物理顺序,产生大量外部碎片;堆表在搬页过程中生成转发记录,原本连续的读变成随机读。文件小了不等于性能好了。

解决:收缩完成后对关键表做一遍DBCC DBREINDEX重建索引,或者用DBCC INDEXDEFRAG做在线整理。这一步安排在同一个维护窗口里,别留到第二天。另外把 autoshrink 关掉,避免后台反复收缩,碎片刚整理完又被搅乱。

6. 把深度压缩做成可重复的维护动作:脚本、对账与节奏

单次收缩解决的是当下这次磁盘告警,真正省事的是把整套动作固化成一段能重复执行的脚本,每次照着跑,不靠记忆。

USE 目标库名 GO SET NOCOUNT ON -- 1. 先备份,失败就停,别带着风险往下走 BACKUP DATABASE 目标库名 TO DISK = 'E:\dbbackup\目标库名_full.bak' WITH INIT, STATS = 25 IF @@ERROR <> 0 BEGIN RAISERROR('备份失败,停止后续收缩', 16, 1) RETURN END -- 2. 校准统计口径,让后面的判断基于真实数字 DBCC UPDATEUSAGE (0) -- 3. 压数据文件,这一步会产生大量日志 DBCC SHRINKFILE (1, 0) -- 4. 截断日志,把上一步制造的日志清掉 BACKUP LOG 目标库名 TO DISK = 'E:\dbbackup\目标库名_log.trn' WITH INIT -- 5. 压日志文件 DBCC SHRINKFILE (2, 0) -- 6. 对账 EXEC sp_spaceused GO

脚本里第 3 步和第 5 步的 fileid 仍然建议按 4.2 的动态 SQL 取,上面写成 1 和 2 只是为了读起来直观。RAISERROR的严重级别 16 会让客户端收到错误,配合RETURN中止批处理,比让脚本带着失败的备份继续跑安全得多。

验证环节看三个数字就够了:收缩前的sp_spaceused里的 database_size、收缩后同一个值、资源管理器里 .mdf 的实际大小。前两个差值就是这次腾出来的空间,第三个用来确认截断真的落到了磁盘上。三个数字对不上,回头查是不是漏了 TRUNCATEONLY 或者日志没截断。

节奏上,我的做法是一年两次计划性收缩,跟着硬件扩容或者年度归档一起做,平时不动。频繁收缩带来的碎片收益远小于它制造的麻烦,尤其是那些每天删几万行、第二天又写回去的表,压完再长、长完再压,纯粹在给磁盘做无用功。

从那以后我每次在生产库上跑收缩,都强制走一遍“备份 → 看 sysfiles → 跑 sp_spaceused 对账”这三步,宁可多花二十分钟,也不想再经历一次中午十二点盯着卡住的会话、手里却没有第二份备份的场面。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询