1. 为什么写这个脚本:看清库里的"家底"是运维的必修课
干数据库运维这行,最怕的不是慢查询,不是死锁,而是"你觉得这个库也就几十G,结果一查发现光日志就占了200G,某张日志表里堆了三个亿的老数据"。SQL Server跑久了,业务表、中间表、临时表、归档表混在一起,谁占地方、谁行数疯涨、谁该清理,全凭感觉是不靠谱的。我接手过一个老系统,应用侧反馈"查询越来越慢",我上去第一件事就是统计全库各表行数和空间占用,结果发现一张名为Log_Detail的表占了整个库80%的物理空间,但业务方早就没在用了——这就是典型的"家底不清,优化白做"。
这篇东西的目标很直接:给你一套能直接在SQL Server上跑的统计脚本,一次性算出每张表的数据量(行数)和总数据量(行数+索引+未分配空间),再把全库总数据量汇总出来。适合三类人看:刚接手别人数据库的运维、准备做数据库迁移或容量评估的开发、以及需要定期做数据库体检的DBA。我会把原理讲透、把坑指出来,脚本直接复制就能用。
先说结论:最稳的方案是sys.partitions+sys.allocation_units系统视图组合,既能拿行数又能拿空间,速度极快,对生产库几乎无压力;而用COUNT(*)逐表统计虽然行数绝对精确,但在大库上可能导致IO和锁开销激增。两种方案我都会给出完整脚本,并告诉你什么场景该用哪一种。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. sys.partitions 方案:三分钟摸清所有表的行数分布
2.1 为什么优先用系统视图而不是 COUNT(*)
初学者最容易想到的办法是:
sql复制SELECT COUNT(*) FROM dbo.Users
问题在于这只能查一张表,而一个库里往往有几百张表。你可能会想到用sp_msforeachtable循环跑:
sql复制EXEC sp_msforeachtable 'SELECT ''?'' AS TableName, COUNT(*) AS RowCnt FROM ?'
这在小库上能跑通,在稍微大点的库上就是灾难。原因有二:第一,COUNT(*)是精确扫描,对每张表做全表或全索引统计,一个大表就够你等半天;第二,循环执行期间会对表施加共享锁,在高并发生产环境可能引发阻塞。我在一个单表1.2亿行的库里实测过,单跑这一张表的COUNT(*)就要4分多钟,几百张表跑完基本等于把库查瘫痪。
sys.partitions是SQL Server的系统视图,它直接读取元数据中关于表和索引的分区信息,每一行代表一个表或索引的一个分区。因为存储引擎本身就在维护每个分区的行数(用于统计信息、基数估计),所以读取是内存级的,毫秒级返回,不需要触碰数据页。这是它比COUNT(*)快几个数量级的根本原因。
2.2 核心脚本:行数+表名+架构名一次拿全
先来一个最基础的版本,把每个用户表的行数统计出来:
sql复制SELECT
SCHEMA_NAME(t.schema_id) AS SchemaName,
t.name AS TableName,
SUM(p.rows) AS RowCnt
FROM sys.tables t
INNER JOIN sys.partitions p ON t.object_id = p.object_id
WHERE p.index_id IN (0, 1) -- 0表示堆表,1表示聚集索引,各表只统计一次
GROUP BY t.schema_id, t.name
ORDER BY RowCnt DESC;
几个关键点说一下:
p.index_id IN (0, 1):这里的逻辑很讲究。index_id = 0代表堆表(没有聚集索引的表),index_id = 1代表有聚集索引的表。一个表如果有多个非聚集索引,每个索引在sys.partitions里也有一行,rows值相同。如果不加这个过滤条件直接SUM,行数会被乘以索引数量,数字翻好几倍——这是新手最容易犯的错。SUM(p.rows):对于分区表,同一个表会有多个分区行,必须聚合求和,否则列表里同一个表会出现多行。这也是为什么不能直接SELECT p.rows。
这个脚本在自己的开发机上跑,100张表不到1秒。作为日常巡检,这个粒度已经够用了。
2.3 精度问题:这个数字为什么会"失真"
sys.partitions.rows并不保证和COUNT(*)完全一致。主要有三种情况会导致偏差:
- 延迟的ghost清理:删除数据时,SQL Server不会立刻物理清除数据页上的行,而是标记为"ghost记录",由后台进程异步清理。在清理完成前,
sys.partitions.rows可能还包含这些已删除的行。生产库中大量删除后,这个差额可能达到几千甚至几万行。 - 索引重建/重组后的统计更新滞后:有些操作虽然更新了数据,但元数据层面的行数刷新会有延迟。我遇到过重建聚集索引后行数反而显示为0的情况,过几分钟才恢复。
- 内存优化表(Memory-Optimized Tables):这类表的行数不走
sys.partitions常规逻辑,直接用这个脚本查不到。
所以请你把这个方案定位成"数量级统计",而不是"精确计数"。做容量规划、定位大表、判断增长趋势,这个精度完全够用;但如果是要做数据对账、账实核对,那必须用COUNT(*)精确统计。
3. 精确方案:动态 SQL 逐表 COUNT,代价与收益的平衡
3.1 什么场景下必须上 COUNT(*)
虽然sys.partitions又快又省资源,但它给不了"精确值"。下面几种场景你就不能依赖系统视图:
- 财务报表/业务对账:每一条记录都关系到钱,行数差一条都没法交代。
- 迁移前后的数据校验:从A库迁到B库,两边表行数必须完全一致。
- 系统视图数据明显可疑时:比如你怀疑统计信息损坏或长时间未更新,需要精确数字做校准。
精确统计的唯一可靠方式就是COUNT(*)。注意,COUNT(*)在SQL Server中的性能其实经过大量优化,它并不是傻乎乎地扫所有列,而是选择最短的索引或堆来扫描。但无论如何,它要真实扫描数据页,大表就是慢,这是物理规律,躲不掉。
3.2 一个自动生成并执行 COUNT 的存储过程
逐张手写COUNT太蠢,我用动态SQL写了一个存储过程,自动遍历所有用户表并统计行数,同时支持跳过某些大表。
sql复制CREATE PROCEDURE dbo.USP_CountAllTableRows
@ExcludeTables NVARCHAR(MAX) = NULL -- 可选:排除表名,逗号分隔,如 'Log_Detail,Temp_Data'
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Sql NVARCHAR(MAX) = N'';
DECLARE @TableName NVARCHAR(256);
DECLARE @ExcludeList TABLE (TableName NVARCHAR(256));
-- 解析排除表列表
IF @ExcludeTables IS NOT NULL
BEGIN
INSERT INTO @ExcludeList (TableName)
SELECT LTRIM(RTRIM(value)) FROM STRING_SPLIT(@ExcludeTables, ',');
END
-- 临时表存放结果
IF OBJECT_ID('tempdb..#RowCountResult') IS NOT NULL DROP TABLE #RowCountResult;
CREATE TABLE #RowCountResult (TableName NVARCHAR(256), RowCnt BIGINT);
DECLARE cur CURSOR FOR
SELECT QUOTENAME(SCHEMA_NAME(schema_id)) + '.' + QUOTENAME(name) AS FullTableName
FROM sys.tables
WHERE name NOT IN (SELECT TableName FROM @ExcludeList)
ORDER BY name;
OPEN cur;
FETCH NEXT FROM cur INTO @TableName;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @Sql = N'INSERT INTO #RowCountResult (TableName, RowCnt) SELECT '''
+ @TableName + N''', COUNT(*) FROM ' + @TableName + N' WITH (NOLOCK)';
BEGIN TRY
EXEC sp_executesql @Sql;
END TRY
BEGIN CATCH
INSERT INTO #RowCountResult (TableName, RowCnt) VALUES (@TableName, -1); -- -1 表示查询失败
END CATCH
FETCH NEXT FROM cur INTO @TableName;
END
CLOSE cur;
DEALLOCATE cur;
SELECT TableName, RowCnt FROM #RowCountResult ORDER BY RowCnt DESC;
END
用法示例:
sql复制-- 统计所有表
EXEC dbo.USP_CountAllTableRows;
-- 跳过 Log_Detail 和 Temp_Data 两张表
EXEC dbo.USP_CountAllTableRows @ExcludeTables = 'Log_Detail,Temp_Data';
说明几个细节:
STRING_SPLIT需要SQL Server 2016及以上版本,如果是2012或2008 R2,需要改用XML解析字符串,避免兼容性问题。WITH (NOLOCK)在这里的作用是避免统计过程中长时间持有共享锁。代价是可能读到未提交的数据(脏读),但对于行数统计这种场景,几十行的偏差通常可以接受。如果业务要求绝对精确且允许锁等待,去掉WITH (NOLOCK)即可。- 游标在这个场景中是最直观的写法,表数少无所谓性能。如果你对速度有执念,可以先用
FOR XML PATH拼出一个大动态SQL一次执行,但出错时定位会更麻烦。
3.3 实测数据:一个小规模库的对比结果
我在一个约200张表、整体约20GB的测试库上做了对比:
| 方案 | 耗时 | 行数值 | 对生产影响 |
|---|---|---|---|
| sys.partitions 查询 | <0.5秒 | 与精确值差0~几百行 | 几乎为零 |
| 逐表 COUNT(*)(含NOLOCK) | 约35秒 | 精确 | 低,但仍产生IO |
| 逐表 COUNT(*)(不加NOLOCK) | 约40秒 | 精确 | 可能阻塞写入 |
结论很清楚:日常体检用sys.partitions,需要精确数据时再上COUNT(*),不要把精确统计当作例行任务天天跑。
4. 空间占用统计:行数只是入门,容量评估得看大小
4.1 行数多≠空间大:从数据页的角度看容量
如果只统计行数,你会被一个表象骗过去。就拿订单表和日志表来说,订单表可能只有1000万行,但一行有50个字段,包含大量变长文本,占用好几个GB;而日志表可能有一个亿行,但每行只有ID和时间两个字段,整表也就2GB。所以要做容量评估,光看行数远远不够,得把每个表的数据空间、索引空间、未分配空间都算出来。
SQL Server存储空间的基本单位是页(Page),每页8KB,8个连续页组成一个区(Extent),共64KB。表占用的空间由三部分组成:
- 数据页:堆表或聚集索引的叶子页,存实际数据行。
- 索引页:非聚集索引的叶子页和上下级索引页。
- 未分配空间:已经分给表但没有写入数据的页,以及IAM(索引分配映射)页等辅助结构。
把这些算清楚,才是真正的"总数据量"。
4.2 完整脚本:一次输出行数、数据空间、索引空间、总空间
下面是我实际项目里在用的一个完整脚本,通过sys.dm_db_partition_stats(或sys.partitions)联sys.allocation_units,得到每个用户表的行数和空间占用:
sql复制SELECT
SCHEMA_NAME(t.schema_id) AS SchemaName,
t.name AS TableName,
SUM(CASE WHEN p.index_id IN (0, 1) THEN p.rows ELSE 0 END) AS RowCnt,
CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(12, 2)) AS TotalSpaceMB,
CAST(SUM(CASE WHEN a.type IN (1, 0) THEN a.total_pages ELSE 0 END) * 8.0 / 1024 AS DECIMAL(12, 2)) AS DataSpaceMB,
CAST(SUM(CASE WHEN a.type = 2 THEN a.total_pages ELSE 0 END) * 8.0 / 1024 AS DECIMAL(12, 2)) AS IndexSpaceMB,
CAST(SUM(CASE WHEN a.type IN (1, 0) THEN a.used_pages ELSE 0 END) * 8.0 / 1024 AS DECIMAL(12, 2)) AS DataUsedMB,
CAST((SUM(a.total_pages) - SUM(a.used_pages)) * 8.0 / 1024 AS DECIMAL(12, 2)) AS UnusedSpaceMB
FROM sys.tables t
INNER JOIN sys.indexes i ON t.object_id = i.object_id
INNER JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
INNER JOIN sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.is_ms_shipped = 0 -- 排除系统表
AND i.object_id > 255 -- 兼容旧版本,也排除系统对象
GROUP BY t.schema_id, t.name
ORDER BY TotalSpaceMB DESC;
看一下几个关键字段的含义:
sys.allocation_units.type:1表示数据页(In-row data)、2表示索引页、0表示Lob数据页(大对象,如TEXT/IMAGE/VARCHAR(MAX)数据)。所以type IN (1, 0)才是真正的数据页,type = 2是索引页。很多人写统计脚本时分不清数据和索引,导致结果偏差很大。total_pages:分配给这个分区/索引的总页数,包含已用和未用的。used_pages:实际使用的页数,total_pages - used_pages就是碎片和预留空间。- 页换算:
total_pages * 8 / 1024,从KB转成MB。如果你的表大到TB级别,需要一个CASE判断动态显示GB或TB。
4.3 如何把结果汇总成"全库总数据量"
上面的脚本已经输出了每张表的明细,你可以在外面套一层SUM拿到全库总量:
sql复制;WITH SpaceCTE AS (
-- 把上面那段查询原样放进来
)
SELECT
COUNT(*) AS TableCount,
SUM(RowCnt) AS TotalRows,
CAST(SUM(TotalSpaceMB) AS DECIMAL(12, 2)) AS TotalSpaceMB,
CAST(SUM(DataSpaceMB) AS DECIMAL(12, 2)) AS TotalDataSpaceMB,
CAST(SUM(IndexSpaceMB) AS DECIMAL(12, 2)) AS TotalIndexSpaceMB
FROM SpaceCTE;
这个汇总值配合sys.databases里的database_id,还可以进一步按库维度做多库对比。如果你管理的实例上有几十个库,可以写个循环把每个库都查一遍,输出一张库级别的容量清单——这对服务器磁盘规划非常有价值。
5. 我踩过的坑:统计脚本在大生产库上的真实教训
5.1 案例一:sys.partitions 的行数被索引数量"放大"
第一次写统计脚本时,我没加index_id IN (0,1)条件,直接SUM(p.rows)。结果一张3000万行的订单表显示成了1.2亿。排查半天才发现,这张表上有4个非聚集索引,每个索引在sys.partitions里各占一行,rows字段值都是3000万,四个加起来自然就变成了1.2亿。这个错误很隐蔽,因为列表按行数排序后,排名靠前的全是这种"多索引大表",看起来就像数据真的那么多。所以这里再强调一次:sys.partitions里同一个表的行数会在每个索引上各存一份,统计时必须过滤。
5.2 案例二:COUNT(*) 在2TB大库上跑了一夜
有个客户要求"把所有表的行数精确导出来",一张近2000张表的库,其中最大的表有4.8亿行。我没多想,直接全表跑COUNT(*),结果3个小时过去了才跑了不到一半。更麻烦的是,因为部分表没有加NOLOCK,引发了几个应用的阻塞告警。最后只能杀掉进程,换方案:先用sys.partitions按行数排序取Top 50,只对这50张表跑COUNT(*),其他的直接信任系统视图。总耗时控制在20分钟内。这个教训告诉我,精确统计的代价是线性的,在大库上必须做"分级处理"。
5.3 案例三:tempdb 差点被撑爆
有一次我在统计脚本里同时开了多个窗口,每个窗口都执行了大型COUNT(*)动态SQL。本来这也不是大问题,但当时的动态SQL里包含了SELECT ... INTO #TempTable的写法,循环几百张表,每张表都往tempdb里塞数据,结果tempdb文件一路狂涨到磁盘满,整个实例被迫重启。后来我改成了直接在内存中聚合、实时输出结果,不在循环里反复创建临时表,才彻底解决。从那以后,凡是写多表遍历的统计脚本,我都会先看一眼tempdb的剩余空间。
| 坑 | 后果 | 规避方式 |
|---|---|---|
| 忽略 index_id 过滤 | 行数虚增N倍 | 只统计 index_id IN (0,1) |
| 大库无差别 COUNT(*) | 阻塞、耗时过长 | 分级处理:系统视图粗筛+COUNT细查 |
| 循环中频繁写临时表 | tempdb膨胀 | 实时输出,避免反复写临时表 |
| 不停库直接统计 | 日志暴涨 | 优先系统视图,NOLOCK中运行 |
6. 生产环境落地方案:把统计脚本变成日常体检工具
6.1 整理成可直接执行的"一键体检脚本"
既然脚本写好了,就不要每次手动复制粘贴,我把它封装成一个视图加一个存储过程,每次想看直接查视图,想生成报告就执行存储过程。
先创建一个视图,把行数和空间统一输出:
sql复制CREATE OR ALTER VIEW dbo.vw_TableSizeReport AS
SELECT
SCHEMA_NAME(t.schema_id) AS SchemaName,
t.name AS TableName,
SUM(CASE WHEN p.index_id IN (0, 1) THEN p.rows ELSE 0 END) AS RowCnt,
CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(12, 2)) AS TotalSpaceMB,
CAST(SUM(CASE WHEN a.type IN (1, 0) THEN a.used_pages ELSE 0 END) * 8.0 / 1024 AS DECIMAL(12, 2)) AS DataUsedMB,
CAST(SUM(CASE WHEN a.type = 2 THEN a.used_pages ELSE 0 END) * 8.0 / 1024 AS DECIMAL(12, 2)) AS IndexUsedMB,
CAST((SUM(a.total_pages) - SUM(a.used_pages)) * 8.0 / 1024 AS DECIMAL(12, 2)) AS UnusedMB
FROM sys.tables t
INNER JOIN sys.indexes i ON t.object_id = i.object_id
INNER JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
INNER JOIN sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.is_ms_shipped = 0
GROUP BY t.schema_id, t.name;
创建完视图后,随时一条语句查看Top N:
sql复制SELECT TOP 20 * FROM dbo.vw_TableSizeReport ORDER BY TotalSpaceMB DESC;
6.2 按业务模式分组统计,快速定位"问题表"
光看总量还不够,我习惯把表按业务类型分组分类,比如订单类、日志类、临时表类,再分组统计。下面这个脚本通过表名关键词粗略分组,可以快速看出哪一类表占用了最多空间:
sql复制SELECT
CASE
WHEN t.name LIKE '%log%' OR t.name LIKE '%history%' THEN '日志/历史类'
WHEN t.name LIKE '%temp%' OR t.name LIKE '%tmp%' THEN '临时表类'
WHEN t.name LIKE '%order%' OR t.name LIKE '%trade%' THEN '订单交易类'
ELSE '业务核心类'
END AS BizCategory,
COUNT(*) AS TableCount,
SUM(CASE WHEN p.index_id IN (0, 1) THEN p.rows ELSE 0 END) AS TotalRows,
CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(12, 2)) AS TotalSpaceMB
FROM sys.tables t
INNER JOIN sys.indexes i ON t.object_id = i.object_id
INNER JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
INNER JOIN sys.allocation_units a ON p.partition_id = a.container_id
GROUP BY CASE
WHEN t.name LIKE '%log%' OR t.name LIKE '%history%' THEN '日志/历史类'
WHEN t.name LIKE '%temp%' OR t.name LIKE '%tmp%' THEN '临时表类'
WHEN t.name LIKE '%order%' OR t.name LIKE '%trade%' THEN '订单交易类'
ELSE '业务核心类'
END
ORDER BY TotalSpaceMB DESC;
这个分类规则是启发式的,你可以按自己业务的表命名习惯调整。这个分组统计在给领导汇报"数据库容量现状"时特别有用,一张表就能说清楚"日志类占了多少、核心业务占了多少"。
6.3 定个周期跑一次:把统计做成例行巡检
我的习惯是每周跑一次统计,把结果保存到一张历史表里,这样不但能看到当前状态,还能追踪增长趋势。最简单的做法是先创建一个历史表,然后定时执行:
sql复制CREATE TABLE dbo.TableSizeHistory (
StatDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
SchemaName NVARCHAR(128) NOT NULL,
TableName NVARCHAR(256) NOT NULL,
RowCnt BIGINT,
TotalSpaceMB DECIMAL(12,2),
DataUsedMB DECIMAL(12,2),
IndexUsedMB DECIMAL(12,2),
CONSTRAINT PK_TableSizeHistory PRIMARY KEY (StatDate, SchemaName, TableName)
);
然后每周通过SQL Server Agent作业执行一次:
sql复制INSERT INTO dbo.TableSizeHistory (StatDate, SchemaName, TableName, RowCnt, TotalSpaceMB, DataUsedMB, IndexUsedMB)
SELECT CAST(GETDATE() AS DATE), SchemaName, TableName, RowCnt, TotalSpaceMB, DataUsedMB, IndexUsedMB
FROM dbo.vw_TableSizeReport;
有了历史数据之后,你可以随时对比本周和上周的差量:
sql复制SELECT
t1.SchemaName,
t1.TableName,
t1.RowCnt - ISNULL(t2.RowCnt, 0) AS RowGrowth,
t1.TotalSpaceMB - ISNULL(t2.TotalSpaceMB, 0) AS SpaceGrowthMB
FROM dbo.TableSizeHistory t1
LEFT JOIN dbo.TableSizeHistory t2 ON t2.SchemaName = t1.SchemaName
AND t2.TableName = t1.TableName
AND t2.StatDate = DATEADD(WEEK, -1, t1.StatDate)
WHERE t1.StatDate = CAST(GETDATE() AS DATE)
ORDER BY SpaceGrowthMB DESC;
这个增长差量是容量预测的关键依据——如果某张表每周稳定增长20GB,那你就能算清磁盘还能撑多久,提前加盘或归档。
7. 一些后话:这个工具还能怎么扩展
行数和空间的统计只是数据库体检的第一步。跑完这个之后,我通常会继续往下查三件事:第一,哪些表的数据页碎片率过高,需要做索引维护;第二,哪些表的unused空间占比异常高,说明频繁删改导致页面空洞多,该收缩还是重建;第三,结合sys.dm_db_index_usage_stats看哪些表虽然大但很久没被访问,直接列入归档候选。
在实际项目里,我通常会把今天这套脚本和索引使用统计、碎片率统计、阻塞历史组合成一个完整的数据库健康检查包,每个月自动出一份报告。这里面的核心不是某个脚本多炫技,而是"先知道有什么,再去判断怎么办"。当你面对一个新接手的数据库时,先花三分钟把每张表的行数和空间拉出来看一眼,你对接下来的所有优化动作都会更有底。
另外补充一个实用小技巧:如果你用的是SQL Server 2016及以上版本,别忘了STRING_SPLIT给动态SQL遍历带来的便利;如果是2012或2008 R2,尽量把脚本里的新函数都换成兼容写法,不然生产环境跑一半报错,体验很糟。这些东西看起来琐碎,但真到生产环境出问题的时候,能帮你省下大把时间。
