
Oracle 几千行的表,Count(*) 耗时分钟级
这次问题最早不是从数据库指标开始的,而是流程专家找我说:有一张表查询一次要等很久。
我第一反应其实很常规:是不是查的数据量太大了?流程类系统里有些历史表一跑就是几年数据,如果查询条件再松一点,慢也不奇怪。结果他补了一句:这张表总共才几千行。
这就不太对了。几千行的表,哪怕 SQL 写得不算好,也不应该慢到让业务侧明显感知。我还是抱着质疑的态度自己执行了一下
SELECT COUNT(*)
FROM table_a;结果真只有6000行数据,耗时将近一分钟。
于是我才开始看这张 Oracle 表的行数、大小和 block 数。原本只是一次普通排查,结果看到一个不太合理的现象:表里的数据行数并不多,字段也不夸张,大多是长度不大的 VARCHAR2,但 BLOCKS 看起来明显偏大。
我的第一反应是怀疑统计信息不准。Oracle 里的 NUM_ROWS、BLOCKS、AVG_ROW_LEN 都来自统计信息,不是实时值。只看一次查询结果就下结论,很容易误判。
所以我先把这件事拆开看:COUNT(*) 看到的是当前真实行数,USER_TABLES 或 DBA_TABLES 看到的是统计信息,DBA_SEGMENTS 看到的是段分配空间。它们回答的不是同一个问题。
SELECT COUNT(*)
FROM TABLE_A;
SELECT
owner,
table_name,
num_rows,
blocks,
empty_blocks,
avg_row_len,
pct_free,
ini_trans
FROM dba_tables
WHERE owner = 'APP_SCHEMA'
AND table_name = 'TABLE_A';
SELECT
owner,
segment_name,
segment_type,
bytes / 1024 / 1024 AS size_mb,
blocks
FROM dba_segments
WHERE owner = 'APP_SCHEMA'
AND segment_name = 'TABLE_A'
AND segment_type = 'TABLE';BLOCKS 不是行数,也不是实时空间
我一开始对 BLOCKS 的理解比较粗:表占了多少块。后来发现这个说法太粗了。
Oracle 读写表数据的基本单位是 block。表的数据放在 segment 里,segment 由 extent 组成,extent 再由 block 组成。DBA_TABLES.BLOCKS 表示统计信息里记录的表块数,通常可以理解为表使用过的块数量。这个值不是 COUNT(*),也不等于每次查询实时扫描出来的精确结果。
这个区别后来帮我少绕了不少路。一个表当前只有几千行,不代表它历史上只装过几千行。
如果一张表曾经插入过大量数据,后来又用 DELETE 删除掉,Oracle 不会自动把高水位线降下来。被删除的空间可能还能被后续插入复用,但高水位线以下的块仍然可能被全表扫描访问。也就是说,数据少了,不代表表扫描就一定变轻。
这也是我当时卡住的地方。
我查到 PCTFREE 是 10,看起来正常。EMPTY_BLOCKS 是 0,也不像有大量完全空块。按直觉,它似乎不该占那么多 blocks。但后来才意识到:EMPTY_BLOCKS = 0 不能排除高水位线问题。它并不是在告诉我“这张表没有被 DELETE 后留下的空间问题”。
在这种场景下,更有价值的信息是:
当前真实行数是多少。
统计信息里的 NUM_ROWS 是否接近真实行数。
BLOCKS 和 AVG_ROW_LEN 是否匹配。
这张表是否发生过大批量 DELETE。
全表扫描是否频繁发生。后来业务背景也对上了:这张表是张高频刷新的表,且目前的刷新机制是先删后增。
为什么 DELETE 后问题还在
DELETE 删除的是行,不是表段本身。
这句话说起来简单,但排查时很容易忽略。尤其是应用侧看到数据已经删掉,就会默认数据库空间也应该释放。Oracle 不是这么处理的。大量 DELETE 之后,表里的行没了,但已经分配给这个表的块不会自动还给表空间,高水位线也不会自动下降。
可以用一个简化示意理解:
插入大量数据:
[used][used][used][used][used][used]
HWM
DELETE 大部分数据后:
[free below HWM][free below HWM][used][free below HWM]
HWM 仍在原位置
全表扫描:
仍然要扫描 HWM 以下的范围所以这类问题的症状通常不是“查不到数据”,而是:
表行数不大,但全表扫描很重。
DBA_TABLES.BLOCKS 明显偏大。
DBA_SEGMENTS.BYTES 看起来和当前数据量不匹配。
统计信息更新后,NUM_ROWS 变小了,但 BLOCKS 仍然不合理。这里有个容易误会的点:重新收集统计信息能让 NUM_ROWS、AVG_ROW_LEN、BLOCKS 更接近当前状态,但它不会降低高水位线。统计信息是描述,不是整理表空间。
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'APP_SCHEMA',
tabname => 'TABLE_A',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
cascade => TRUE
);
END;
/这一步可以先做,用来避免基于旧统计信息误判。但如果根因是 HWM 过高,光收集统计信息不够。
我不太接受只用经验倍数判断
当时有一个很粗的判断方式:BLOCKS > NUM_ROWS * 10 就认为可疑。
这个规则可以粗筛,但不太精准。因为它把两种不同单位的东西硬比在一起:block 是块数,row 是行数。对于一张宽表和一张窄表,这个比例的意义完全不一样。一个 block 能放多少行,取决于 block size、平均行长、行头开销、PCTFREE,还有字段是否经常更新变长。
更稳的方式是先把它们换算成空间。
SELECT
t.owner,
t.table_name,
t.num_rows,
t.blocks,
t.avg_row_len,
ts.block_size,
ROUND(t.blocks * ts.block_size / 1024 / 1024, 2) AS actual_mb,
ROUND(t.num_rows * t.avg_row_len / 1024 / 1024, 2) AS estimated_data_mb,
ROUND(
(t.blocks * ts.block_size)
/ NULLIF(t.num_rows * t.avg_row_len, 0),
2
) AS space_ratio
FROM dba_tables t
JOIN dba_tablespaces ts
ON t.tablespace_name = ts.tablespace_name
WHERE t.owner = 'APP_SCHEMA'
AND t.num_rows > 0
ORDER BY space_ratio DESC;这里我用的是 DBA_TABLESPACES.BLOCK_SIZE,不是 DBA_DATA_FILES。我当时踩过一个小坑:在 Oracle 19c 里直接从不合适的数据字典视图里取 block size。
上面这个 space_ratio 也不是官方定义。它只是一个我更能接受的筛查指标。
我会这么看:
actual_mb 接近 estimated_data_mb:通常不用动。
actual_mb 明显大于 estimated_data_mb:需要继续看历史 DELETE、全表扫描和空间回收。
space_ratio 很高:优先进入候选清单,但不直接执行 MOVE。为什么还不能直接执行?因为 AVG_ROW_LEN 本身来自统计信息,数据类型、空列比例、行头开销都会影响估算。这个指标适合帮我找“最可疑的表”,不是直接下变更结论。
还有几个边界要提前排除。这段 SQL 主要适合普通 heap table。如果表有 LOB 字段,LOB 段的空间通常要另外看 DBA_LOBS 和 DBA_SEGMENTS。如果是分区表,要拆到 DBA_TAB_PARTITIONS 或按 partition segment 看。IOT、cluster table 这类对象也不能直接按普通表理解。否则算出来的比例很吓人,但问题可能根本不在 HWM。
EMPTY_BLOCKS 为 0 也不能说明没问题
这点我印象比较深。
我当时看到 PCTFREE = 10,EMPTY_BLOCKS = 0,第一反应是:那是不是就不是空间浪费?后来才发现这个判断太快。
EMPTY_BLOCKS 说的是段中高水位线以上未使用的块。高水位线以下被 DELETE 后释放出来的空间,不一定会反映成 EMPTY_BLOCKS。这些块可能在表内部可复用,但对全表扫描来说,它们仍然处在扫描范围内。
所以,当表经历过大量 DELETE 时,EMPTY_BLOCKS = 0 只能说明它没有高水位线以上的空块,不能说明高水位线以下没有浪费。
我后来会把判断顺序改成这样:
SELECT COUNT(*) AS real_rows
FROM TABLE_A;
SELECT
num_rows,
blocks,
empty_blocks,
avg_row_len,
pct_free
FROM dba_tables
WHERE owner = 'APP_SCHEMA'
AND table_name = 'TABLE_A';
SELECT
bytes / 1024 / 1024 AS segment_mb,
blocks AS segment_blocks
FROM dba_segments
WHERE owner = 'APP_SCHEMA'
AND segment_name = 'TABLE_A'
AND segment_type = 'TABLE';如果真实行数和 NUM_ROWS 接近,BLOCKS 和 segment size 仍然明显偏大,再结合“大量 DELETE”这个事实,HWM 过高的判断就比较稳。
降低 HWM 不是随手执行一条命令
确认问题之后,常见处理方式有两个:MOVE 和 SHRINK SPACE。
ALTER TABLE ... MOVE 会把表数据重写到新的位置,重新整理数据块,高水位线会下降。
ALTER TABLE TABLE_A MOVE;
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'APP_SCHEMA',
tabname => 'TABLE_A',
cascade => TRUE
);
END;
/但 MOVE 不是无成本操作。普通 MOVE 可能让索引变成 unusable,后面要检查并重建索引。
SELECT
owner,
index_name,
status
FROM dba_indexes
WHERE table_owner = 'APP_SCHEMA'
AND table_name = 'TABLE_A';
ALTER INDEX idx_TABLE_A_01 REBUILD;如果环境支持,也可以考虑 SHRINK SPACE。它通常要求表空间使用 ASSM,并且表需要开启 row movement。
ALTER TABLE TABLE_A ENABLE ROW MOVEMENT;
ALTER TABLE TABLE_A SHRINK SPACE COMPACT;
ALTER TABLE TABLE_A SHRINK SPACE;SHRINK SPACE COMPACT 先整理数据但不马上调整 HWM,后面的 SHRINK SPACE 才回收空间。它不适合所有表,也不应该在高峰期随便跑。尤其是有长事务、触发器、外键、物化视图日志、分区策略这些因素时,必须先确认影响范围。
如果只是清空一张中间表或临时业务表,TRUNCATE 反而更直接。
TRUNCATE TABLE TABLE_A;但这个前提很强:数据确实可以全部清空,同时执行TRUNCATE操作的账号要有较高权限并且业务接受不可按行回滚的语义。把 TRUNCATE 当成普通删除替代品,是另一个风险。
我后来会怎样交付这类排查
这类问题最容易写成一句话:“高水位线太高,MOVE 一下。”
这句话方向可能没错,但作为工程交付不够。别人不知道你怎么判断的,也不知道操作会带来什么副作用。
我现在会把排查结果整理成这种结构:
现象:
表当前行数不大,但 BLOCKS 和 segment size 明显偏大。
证据:
COUNT(*)、DBA_TABLES、DBA_SEGMENTS 三组结果。
历史原因:
表发生过大批量 DELETE。
判断:
统计信息更新后,NUM_ROWS 接近真实行数,但 BLOCKS 仍然异常。
EMPTY_BLOCKS 为 0 不能排除 HWM 问题。
建议:
在维护窗口执行 MOVE 或 SHRINK SPACE。
执行后收集统计信息。
检查索引状态并重建 unusable 索引。
分析HWM的原因,后续能否从根节点杜绝HWM出现。
风险:
锁表、ROWID 变化、索引失效、执行时间、回滚方案。这比直接贴一条 ALTER TABLE ... MOVE 安全得多。
这次经历对我的提醒是:数据库里的“空间问题”经常不是肉眼看到的“表大小”那么简单。COUNT(*)、BLOCKS、EMPTY_BLOCKS、DBA_SEGMENTS.BYTES 各自代表不同层面的事实。只拿其中一个值下结论,很容易走偏。
高水位线不是一个神秘概念,它只是 Oracle 存储管理里一个很实际的边界。问题在于,这个边界不会因为你删了数据就自动降下来。
