做了这么多年数据库运维,我几乎每个月都能遇到一两次“数据库快把磁盘撑爆了”的求救。客户说某业务库已经膨胀到几百GB,备份耗时越来越长,磁盘空间告警邮件天天半夜弹出来,但一问实际业务数据量,往往连一半都不到。这种“虚胖”基本都是同一个套路:日志文件失控、索引碎片堆积、过期数据没有归档、文件空间管理混乱。这篇文章就把我在 Sql Server 数据库瘦身这件事上踩过的坑、验证过的方法、以及可以直接抄作业的脚本一次讲清楚,适合 DBA、后端开发和运维同学参考。
1. 为什么数据库会越长越“胖”
瘦身的前提是先搞清楚体积是从哪里涨起来的。Sql Server 的磁盘占用主要落在两个文件上:数据文件(.mdf / .ndf)和日志文件(.ldf)。这俩膨胀的机制完全不同,处理方式也截然不同,混为一谈是新手最容易犯的错误。
1.1 数据文件膨胀的常见来源
数据文件体积大,不代表“数据真的多”,更常见的是数据页里有大量空洞。举个生活化的例子:你的 C 盘里有个 Excel 文件,里面删掉了几万行数据,但 Excel 文件本身还是那么大。数据库也一样,DELETE 或 UPDATE 操作只是把数据页里的行标记为“可重用”,但不会自动把空间还给操作系统。日积月累,文件就虚胖了。
数据文件膨胀还有几个隐形推手:
- 频繁 DELETE 大量数据后没有收缩,数据页空了大半。
- 索引碎片严重,索引页被拆得七零八落,为了装入同样多的索引条目,占用了更多页。
- 堆表(Heap Table)在频繁删除后,空间碎片化严重,页面密度极低。
- 开启了某些功能后额外产生内部版本记录,例如查询用了较长时间的运行事务快照隔离。
- 没有任何过期数据清理策略,历史数据不断堆积,也没有滚动归档。
需要注意,数据文件一旦设置了自动增长(AUTOGROWTH),每次增长都会申请新的空间,而 Sql Server 不会自动把空出来的页归还给操作系统。所以“文件很大”和“空间利用率高”是两回事,这个区别整个瘦身过程都要记住。
1.2 日志文件膨胀的特殊性
日志文件膨胀的原因跟数据文件完全不是一回事。事务日志(LDF)记录的是所有修改操作的完整前映像和后映像,它的大小取决于两个因素:产生了多少日志量,以及这些日志空间是否被“截断重用”。
很多数据库被配置成“完整恢复模式(FULL)”,但从未配置过定期的事务日志备份。完整恢复模式下,日志备份是唯一能让日志空间被截断重用的手段。只要日志备份没成功,或者日志备份链路断了,日志文件就会一直增长,哪怕业务量不大,一次大的批量 UPDATE 就能把日志撑出几百GB。
更麻烦的是,即使日志内部已经截断重用了,日志文件的物理大小同样不会自动缩小。这跟数据文件是一个道理,你删掉了日志内部的内容,但不主动执行 SHRINKFILE,文件在磁盘上依然占着几百GB。所以要瘦身,必须把“逻辑收缩”和“物理文件缩小”两个动作配合起来做。
2. 瘦身前必做的全面体检
不管接手哪个库,第一步永远不是直接动手收缩,而是先回答三个问题:数据文件和日志文件分别多大、里面实际用了多少空间、哪些索引和表在浪费空间。没有这个体检结果,后面所有操作都是盲目的。
2.1 文件大小与空间使用率检查
先看整个库的文件分布和可用空间。下面这段脚本在任何一个版本上都适用:
SELECT DB_NAME(database_id) AS db_name, type_desc AS file_type, name AS logical_name, size * 8 / 1024 AS size_mb, FILEPROPERTY(name, 'SpaceUsed') * 8 / 1024 AS used_mb, CASE WHEN max_size = -1 THEN -1 ELSE max_size * 8 / 1024 END AS max_size_mb, growth * 8 / 1024 AS growth_mb FROM sys.master_files WHERE database_id = DB_ID();这个脚本直接显示了当前库各个文件的逻辑名、总大小、实际使用大小和增长步长。如果 “size_mb” 远大于 “used_mb”,恭喜你,有巨大的收缩空间;如果两者接近,那说明文件确实是“真胖”,问题出在数据量本身或索引碎片上。
数据文件内部的空闲空间还可以精确到页分配类型级别。用下面的动态管理视图可以看得更细:
SELECT type_desc, total_pages * 8 / 1024 AS total_mb, allocated_pages * 8 / 1024 AS allocated_mb, unallocated_pages * 8 / 1024 AS unallocated_mb FROM sys.dm_db_file_space_usage WHERE database_id = DB_ID();这段脚本的价值在于把“未分配的页”单独列出来,也就是整个文件里还完全没有被任何对象使用的空间。这一数字直接决定了你能收缩出多少物理空间。
2.2 索引碎片与表空间排行
索引碎片是数据库“虚胖”的重要来源。用系统自带的动态管理函数扫描当前库所有索引:
SELECT OBJECT_NAME(ps.object_id) AS table_name, i.name AS index_name, ps.index_type_desc, ps.avg_fragmentation_in_percent AS fragment_pct, ps.page_count, ps.avg_page_space_used_in_percent AS page_space_used_pct FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') ps JOIN sys.indexes i ON ps.object_id = i.object_id AND ps.index_id = i.index_id WHERE ps.page_count > 1000 ORDER BY ps.avg_fragmentation_in_percent DESC;注意最后加了个过滤条件:只查 page_count 大于 1000 的索引。碎片率对“只有几页的索引”没有意义,重点看大表和频繁增删改的索引。如果 avg_fragmentation_in_percent 超过 30%,同时 avg_page_space_used_in_percent 低于 70%,这个索引就是瘦身首要目标。
表级别的空间占用排行同样重要,快速定位哪几张表吃了大部分空间:
SELECT OBJECT_NAME(ps.object_id) AS table_name, SUM(ps.reserved_page_count) * 8 / 1024 AS reserved_mb, SUM(ps.used_page_count) * 8 / 1024 AS used_mb FROM sys.dm_db_partition_stats ps GROUP BY ps.object_id ORDER BY reserved_mb DESC;一般跑一遍下来,就能确定瘦身方案重灾区在哪:日志文件还是数据文件,数据文件里是大表碎片还是历史数据堆积,一目了然。
3. 文件收缩的正确打开方式
体检完成之后才进入“瘦身”的核心动作:收缩文件。这部分内容网上一搜一大把,但很多人直接把 SHRINKDATABASE 整个库一收,然后抱怨性能变差、文件又涨回去。问题出在没用对工具和流程。
3.1 SHRINKDATABASE 与 SHRINKFILE 怎么选
Sql Server 提供两个命令:
- DBCC SHRINKDATABASE:对整个库的所有文件统一收缩,自动跳过没有收缩价值的文件。
- DBCC SHRINKFILE:针对单个逻辑文件按指定目标大小收缩。
我的经验是:优先用 SHRINKFILE,一条一条地来。SHRINKDATABASE 的问题是粒度太大,你不能控制“只收缩日志文件”或者“数据文件收缩到某个具体目标值”。生产环境里你往往只想处理那个失控的日志文件,而数据文件收缩后会产生大量碎片,动它需要更谨慎。SHRINKFILE 可以精确控制收缩目标,还能用 TRUNCATEONLY 参数只释放未使用空间,不搬运数据页,对数据文件的压力最小。
3.2 数据文件与日志文件的收缩步骤
收缩日志文件的标准流程,我建议按照下面的顺序操作:
先看一下日志逻辑名和当前恢复模式。然后执行一次事务日志备份(如果你处于完整恢复模式),让日志内部完成截断。接着收缩目标文件,设置一个理想的目标大小。
-- 1. 查看当前恢复模式和日志逻辑名 SELECT name, recovery_model_desc FROM sys.databases WHERE database_id = DB_ID(); -- 2. 完整恢复模式下先做日志备份,截断日志 BACKUP LOG [YourDB] TO DISK = N'D:\Backup\YourDB_LogBackup.trn'; -- 3. 收缩日志文件到目标大小 1GB USE [YourDB]; DBCC SHRINKFILE (N'YourDB_log', 1024);如果日志文件里面还有大量“活动的”VLF(虚拟日志文件)没有截断,SHRINKFILE 会收缩失败或只能缩到某个卡点。这时先重复执行日志备份,或者把恢复模式短暂切到 SIMPLE 让它完成截断,再切回 FULL。生产环境切换恢复模式前务必备份完整数据库。
数据文件收缩要更谨慎。假如数据文件的实际已使用空间是 180GB,物理文件 500GB,那目标大小设成 250GB 比设成 185GB 明智得多。理由很简单:目标设得越低,收缩时 Sql Server 需要搬移的数据页越多,IO 压力越大,操作耗时越长,碎片也越严重。收缩的最终目标不该是“刚好塞满”,而是留出合理的增长余量。
-- 收缩数据文件到目标大小 250GB ALTER DATABASE [YourDB] SET RECOVERY SIMPLE; DBCC SHRINKFILE (N'YourDB', 256000); ALTER DATABASE [YourDB] SET RECOVERY FULL;注意上面这段在切换恢复模式前必须先有完整备份。SIMPLE 模式下做收缩只是为了绕开日志截断链路,收缩完成后必须立即恢复 FULL 模式,然后补一次完整备份,确保备份链完整。
3.3 收缩的代价与频率控制
收缩不是免费的。SHRINKFILE 在移动数据页时会把页面从文件末尾搬到文件前面,这个过程必然导致索引碎片急剧上升。收缩完成后紧接着做一次索引碎片整理是刚需操作,不是可选操作。
另外收缩操作本身会消耗大量 IO 和 CPU,选在业务低谷期做,同时监控数据库的等待类型。如果 PAGEIOLATCH 等待飙升,说明收缩挤占了正常业务读写的 IO,必须降低收缩速度或错峰。
还有一条铁律:不要频繁收缩。数据库维护计划里千万别挂一个“每天收缩一次”的定时任务。频繁收缩会造成索引碎片反复出现,也让文件增长机制反复触发,既伤性能又伤磁盘寿命。收缩只应该作为“一次性的补救动作”,而不是“日常维护动作”。
4. 从根上治理:索引碎片整理与无效索引清理
很多人只知道收缩文件,忽略了索引维护。文件收缩只是把“虚胖”减掉,但如果索引碎片不处理,过不了一个月数据库又会涨回来,因为碎片让页面利用率持续走低,数据文件只能靠自动增长扩容来兜底。
4.1 碎片阈值怎么定
索引碎片整理有两条路:重组(REORGANIZE)和重建(REBUILD)。选哪条路,看体检数据。
- 碎片率在 5%~30% 之间:用 ALTER INDEX ... REORGANIZE,也就是在线重组,锁需求低,IO 压力小。
- 碎片率超过 30%:用 ALTER INDEX ... REBUILD,直接重建整个索引。如果索引很大,考虑使用 ONLINE=ON 参数(企业版支持)。
- 碎片率低于 5% 且页面填充率正常:不需要处理,动了反而浪费 IO。
我通常关注的除了碎片率,还有 avg_page_space_used_in_percent。一个索引碎片率只有 8%,但页面填充率只有 40%,说明页面里全是空隙。这种情况重组效果有限,重建顺便把页密度提上来,才是瘦身的关键收益。
给一个批量生成维护脚本的示例:
SELECT 'ALTER INDEX [' + i.name + '] ON [' + OBJECT_NAME(ps.object_id) + '] ' + CASE WHEN ps.avg_fragmentation_in_percent > 30 THEN 'REBUILD WITH (ONLINE = ON, SORT_IN_TEMPDB = ON)' ELSE 'REORGANIZE' END + ';' AS maintenance_script FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') ps JOIN sys.indexes i ON ps.object_id = i.object_id AND ps.index_id = i.index_id WHERE ps.page_count > 1000 AND ps.avg_fragmentation_in_percent > 5 ORDER BY ps.avg_fragmentation_in_percent DESC;这一段会直接输出所有需要维护索引的可执行语句。大索引重建建议加 SORT_IN_TEMPDB=ON,意思是排序操作放到 tempdb 里做,能显著减少源数据库的日志压力。
4.2 那些“看不见”的空间浪费
除了碎片整理,更该做的是“删索引”。不少业务系统上线后经历了大量功能迭代,索引也东加一个西加一个,很多索引压根没人用。索引一多,数据文件自然膨胀,因为每个索引都有自己的 B+ 树页面。
查看索引真实使用情况可以用这个脚本:
SELECT OBJECT_NAME(i.object_id) AS table_name, i.name AS index_name, i.type_desc, s.user_seeks + s.user_scans + s.user_lookups AS total_reads, s.user_updates AS total_writes FROM sys.dm_db_index_usage_stats s JOIN sys.indexes i ON s.object_id = i.object_id AND s.index_id = i.index_id WHERE s.database_id = DB_ID() ORDER BY total_reads ASC;如果某个索引的 total_reads 是 0,而 user_updates 很高,说明这个索引除了拖慢写入、浪费空间之外没有任何价值。删之前先和业务确认,确认没有定期查询用到它再 DROP。这一招能释放出来的空间,有时比整个收缩操作还多。
4.3 填充因子(Fill Factor)不要乱调
有人为了“防止碎片”把填充因子调成 50%,结果索引页面撑不满一半,数据文件反而多占了一倍空间。填充因子是控制索引页预留多少空闲空间的参数,适合的值大多在 80%~90% 之间。对于读写比高的业务,默认 0(相当于 100%)反而最省空间,但更新频繁的索引可以设 85% 左右,减少页分裂。调填充因子后必须重建索引才会生效,并且它会直接增加数据文件占用,瘦身时不要为了“防碎片”而牺牲空间,得不偿失。
5. 数据清理与归档:真正的长效瘦身方案
如果数据文件的“胖”是因为历史数据堆了几年没动过,那么收缩和索引维护都只是治标。真正的瘦身方案是建立数据生命周期管理:哪些数据留在线库、哪些数据进归档库、哪些数据直接销毁,要形成一套规则。
5.1 分批 DELETE 与日志截断配合
一次性 DELETE 几千万行历史数据,会导致事务日志瞬间爆炸,还可能撑爆临时空间。正确做法是分批删除,每批删除一定数量后提交一次事务。
DECLARE @batch_size INT = 10000; DECLARE @rows_affected INT = 1; WHILE @rows_affected > 0 BEGIN DELETE TOP (@batch_size) FROM Orders WHERE OrderDate < '2020-01-01'; SET @rows_affected = @@ROWCOUNT; CHECKPOINT; END这里的 CHECKPOINT 是为了确保脏页写入磁盘,释放部分日志空间。在完整恢复模式下,每批删除后也应该配合事务日志备份或定期截断,不然你删了 1 亿条数据,日志文件照样能涨到天上去。分批 DELETE 的批量大小要根据你的硬件和日志文件大小来调整,业务高峰期宁可调小一点,也别让事务日志失控。
5.2 分区表与归档库的取舍
历史数据按时间归档,最优雅的方案是分区表滑动窗口。把月表数据按月份分区,用分区切换(SWITCH)把过期分区整块移出到归档库,几秒就能完成一个月的归档操作,几乎不产生日志压力。这个方案适合“数据量大、按时间查询频繁、需要在线保留最近 N 年”的业务场景。
分区切换的具体做法是通过 ALTER TABLE ... SWITCH PARTITION 语法,把当前生产库某个分区切换到归档表或归档库。切换完成后,生产库不再保留该分区数据,文件空间会显著释放。这里有个细节:归档表和源表结构必须完全一致,而且索引和约束也要对齐,否则 SWITCH 操作会报错。
如果没有条件改造成分区表,也可以直接在两个库之间用 SSIS 或者 SQL Agent 作业定期搬运数据,搬完删除生产库老数据。这种方式实现简单,缺点是删除过程会产生大量日志、搬运期间业务表会锁。各有利弊,看你的维护窗口和团队能力选择合适的方案。
5.3 小心 TRUNCATE 和 DELETE 的区别
清理数据时,新手容易把 TRUNCATE TABLE 当成 DELETE 的“效率版”,但两者差别非常大。TRUNCATE 只记录页释放信息,几乎不产生事务日志,同时也无法按条件过滤——整个表的数据一次性清空。DELETE 可以带 WHERE 条件,每删除一行都会记日志,删除量的日志甚至比数据本身还大。
如果你只想清空一张表且确认不需要回滚,TRUNCATE 确实香。但生产环境往往有外键约束,有 CDC(变更数据捕获)之类机制在监听,TRUNCATE 在这些场景下可能是被禁止的。还有一点,DELETE 之后可以做空间收缩,TRUNCATE 之后表的物理空间也已经立即标记为可重用,但文件同样不会自动变小,仍然需要 SHRINKFILE 配合。
6. 日志文件长期可控的维护机制
日志文件是数据库超空间问题的“重灾区”,但很多人处理完日志文件就以为大功告成了,结果下个季度日志又膨胀一次。说到底,日志的瘦身不是“事后补救”,而是“长效机制”。
6.1 恢复模式决定日志空间能否重用
首先要跟你负责的库确认一件事:它处于什么恢复模式?
- SIMPLE 模式:自动截断日志,但只支持上一次完整备份后的恢复,存在数据丢失风险。
- FULL 模式:必须依赖事务日志备份链完成截断,否则日志只增不减。
- BULK_LOGGED 模式:部分操作最小化日志,适合批量导入场景,但仍需日志备份截断。
很多系统的数据库被设置成 FULL 模式,是因为当初建库的人默认选了“完整”,但后续没人配置日志备份。正确做法是:对每个 FULL 模式的库,必须规划事务日志备份作业,频率至少是每 30 分钟一次,业务高峰期可以加密到 15 分钟。日志备份间隔越长,单个日志备份文件越大,崩溃恢复时丢失的数据也越多。
SIMPLE 模式不是“不产生日志”,而是“日志空间被重用得快”。如果一个库允许 RPO(数据恢复点目标)大于 0,可以接受最近若干分钟的损失,那么 SIMPLE 模式既省心又省空间。但把生产核心库切成 SIMPLE 之前,一定要想清楚恢复策略。
6.2 日志备份失败的常见排查点
日志文件膨胀往往和“日志备份一直失败”叠加出现。备份失败常见原因也就那么几个:
- 备份目标磁盘满了,日志备份写不进去。
- 备份作业的账号没有足够权限。
- 作业计划设置错了,只在周末备份,而业务天天在产生日志。
- 备份完成后没有校验,备份链断了但没人发现。
我用过最土也最有效的方案是给日志备份作业配上失败告警。一旦日志备份失败,第一时间收到邮件,而不是等磁盘快满了才发现。日志文件一旦失控,瘦身流程虽然能救回来,但风险远远高于事前拦截。
7. 瘦身实操中的高频问题与避坑记录
最后这部分,整理下我实际做过的多个瘦身项目里反复遇到的坑。这些坑几乎每个团队都会踩一遍,说明书上不会写,记录下来比任何理论都有用。
7.1 数据文件收缩为什么特别慢
有一次某业务系统数据文件 1.2TB,实际数据只有 400GB,SHRINKFILE 跑了 9 个小时还没结束。排查发现文件里有一个巨大的堆表,碎片率高达 60%,扫描和搬移数据页的 IO 压力远远超过预期。
后来学到的经验:数据文件收缩前,先针对碎片严重的表做一次索引重建或重组。先把页面填满,收缩需要搬移的页数量会大幅度下降,总耗时反而比直接收缩少得多。这属于“先整理再装箱”,比直接搬运整个文件高效得多。
另外 SHRINKFILE 如果长时间没有进度输出,不代表卡死了,它本来就是单线程后台操作。用系统视图监控收缩进度时,观察文件的 used_pages 是否持续下降。如果数值在动就安心等着,别中途杀掉,因为中断后下次还要从头再来。
7.2 收缩后性能不升反降的原因
上次给某库做完瘦身,第二天开发反馈几个报表查询变慢了。查了一下,问题就出在收缩时移动了大量数据页,导致聚簇索引碎片率从 12% 飙到 55%。文件收缩确实释放了磁盘空间,但查询性能被拖垮了。
这件事给我的教训是:文件收缩和索引维护必须放在同一个维护窗口里执行,顺序是“先收缩、后重建索引”。收缩会打乱所有数据页的物理顺序,重建索引负责把页重新排列,把碎片率降回去。顺序反了或者只做一半,等于白干一半。
7.3 日志文件的“缩了又涨”死循环
有人每个月执行一次日志收缩,日志缩到 1GB,然后半个月后回到 200GB。这类反复膨胀几乎都是恢复模式是 FULL 但日志备份没跟上。日志收缩只是把物理文件缩小,日志备份才是让日志内部空间持续重用的根本。
这种情况的正确解法是:检查日志备份作业是否存在并正常运行,恢复模式是否合适,最后再谈收缩。如果日志备份作业已经稳定运行,日志文件自然维持在一个稳定水位,根本不需要每个月手动收缩。日志文件膨胀是“结果”,日志备份缺失才是“原因”。只解决结果不解决原因,瘦身就是打地鼠游戏。
7.4 磁盘空间不够,日志备份做不了
日志备份目的地满了,会导致日志备份失败,进而让日志文件越长越大,最后陷入死循环。常规处理是在磁盘还剩余几GB 时,立刻清理备份目录或挂载新磁盘。买不起新磁盘的紧急项目,也可以把日志备份临时改到网络共享路径,先把截断链路恢复起来,再做后续清理。还有一招:在紧急情况下,可以把备份压缩开起来(BACKUP LOG ... WITH COMPRESSION),日志备份体积通常能缩小一个数量级。
8. 关于数据库瘦身的最后一点经验
数据库瘦身这件事,我认为最重要的是“换个视角看问题”:压缩文件大小只是手段,不是目标。目标应该是让数据文件、日志文件都维持在一个稳定的、可预测的水位,空间利用率合理,备份耗时可控,故障恢复路径清晰。文件收缩永远是对过去管理失误的补救,不是日常应该频繁执行的动作。
如果把这个项目比作一次大扫除,那么正确的工作量配比大概是这样的:日志机制修正占一半,历史数据归档占三成,文件收缩和索引维护占两成。每次做数据库瘦身项目,我都会先把“根因”这条线画清楚——日志备份有没有、恢复模式对不对、历史数据要不要留、索引有没有冗余。这四个问题有了答案,剩下的无非是执行时间和风险控制。
最后一个小技巧送给大家:做完整个瘦身项目之后,建一个每周执行的“数据库空间体检”作业,输出文件使用率、日志状态、碎片率、TOP 表空间报表,发到 DBA 邮箱或者运维群。只要这个例行体检在跑,任何数据库从“虚胖”走向“真胖”的过程都会在早期被发现。几十行脚本,换来的是以后再也不用半夜爬起来救急。这点小投入,是我做数据库维护多年里觉得最划算的一笔投资。