前两天有个同事跑过来找我,说他们某张订单表又变慢了。索引明明都在,统计信息也刚收集过,但全表扫描一天比一天慢,等值查询偶发性地要多几十毫秒。我让他跑了一条SQL看dba_tables里的chain_cnt,结果链行率已经接近18%。他一脸懵地问我:“这个字段不是自动管理的吗,怎么会链行?”于是我们花了半个小时聊了聊pctfree和pctused,这两个几乎每个建表脚本里都会写、但很少有人认真算过的存储参数。
简单说,pctfree和pctused管的是Oracle数据块里“空间怎么分配、什么时候允许再插入”。它们不决定SQL怎么写,不参与执行计划选择,但它们会悄悄决定表的物理布局,进而影响行迁移、块密度、freelist竞争和全表扫描代价。很多性能问题查到最后,根因往往不是SQL写得差,而是表空间和段的物理参数从一开始就没配合好。
这篇文章适合三类人:刚接触Oracle、准备接手建表脚本的人;被“行迁移”“链行率”“更新后变慢”坑过的DBA;以及那些想搞清楚“为什么改了pctfree没效果”的开发者。我会把参数原理、计算公式、真实案例和排查脚本一次性说透。
1. 先说清楚这两个参数到底管什么
1.1 默认值背后的含义
如果你建表时不写存储参数,Oracle会自动给表段设置PCTFREE 10和PCTUSED 40。这个默认值从Oracle 6/7时代一直沿用到现在,能在绝大多数场景下工作正常,但“正常”不等于“最优”。
我从两个角度理解这两个参数:
PCTFREE管的是“插入时最多填到多少就不再填”。它的默认意思是:一个数据块在插入过程中,最多用到可用空间的90%,剩下10%故意空着,留给未来的UPDATE。PCTUSED管的是“使用率降到多低之后,这个块才允许被重新插入”。默认意思是:一个块的使用率要跌到40%以下,才会重新出现在空闲列表里。
很多文档把这两个参数写得非常官方,比如“pctfree指定数据块中保留的最小空间百分比,用于更新现有的行”。如果只是背书,永远理解不了它为什么重要。我换个说法:pctfree是给块留“后手”,pctused是决定块什么时候“重新营业”。
1.2 pctfree:给未来的更新留后路
Oracle的数据块默认8KB,扣除块头、事务槽、行目录这些开销,一个块能放的数据大约在7.5KB到7.7KB。插入数据时,块会一直往里装,直到剩余空间小于PCTFREE对应的比例,这个块就从空闲列表里摘除,不再接受新的INSERT。
那10%的空间空着干什么?就是为了让已经放进来的行在UPDATE时变大还有地方放。举个例子:一行数据原来占200字节,UPDATE时把某个VARCHAR2字段从10个字符改成500个字符,这行可能要占700字节。如果块里没有预留空间,这行就塞不回原来的位置了。
这时候Oracle只能做一件事:把整行搬到另一个有空闲的块里,在原来的位置留下一个“转发地址”。这个行为叫行迁移(row migration)。以后不管是通过全表扫描还是索引访问,只要碰到这一行,都要先找到旧位置,再顺着转发地址跳到新位置,额外多一次甚至多次物理读。行多了,IO自然就涨。
pctfree不是越大越好,后面我会讲怎么算。但你先记住一点:它存在的目的,是牺牲一点点空间,换未来的更新稳定。
1.3 pctused:什么时候让块重新“接客”
理解pctused之前,先搞清楚Oracle判断块状态的依据是“块使用率”,不是“剩余空间”。
块使用率高到什么程度会停止插入,由pctfree和100之间的差值决定:100 - pctfree。比如pctfree=10,块使用率到90%就不再接收新插入。之后块里的行继续被UPDATE变长,或者被DELETE删掉一部分,使用率会慢慢降。当使用率降到pctused以下,比如默认的40%,这个块才会重新回到空闲列表,允许INSERT再次写入。
我见过很多刚入门的人理解反了,以为“剩余空间大于某个比例就能继续插”。实际上流程是:插入看剩余空间,回列看使用率。pctfree管的是“出列”,pctused管的是“回列”,方向完全不一样。
举个直观的例子:一个块使用率到了95%,拔出空闲列表。你删了块里两行,使用率掉到70%。这个块依然不会回到空闲列表,因为70%还是高于pctused=40。要等删除足够多的行,让使用率跌破40%,这个块才会重新被INSERT选中。这个“从90%到40%”的跨度,就是很多空间利用率问题的来源。
2. 底层机制:freelist 与空间状态切换
2.1 手工段空间管理下的完整生命周期
在Oracle 9i之前,或者说在手工段空间管理(MSSM)模式下,每个段头里维护一个或多个空闲列表(freelist),用来记录哪些块当前是可以插入的。块的状态切换其实是一套完整的生命周期:
- 新建块刚分配出来时,使用率为0,会在空闲列表上。
- INSERT不断向块写入数据,使用率上升。当使用率超过
100 - pctfree,块从空闲列表摘除,只能被UPDATE和DELETE访问,不再接收新的INSERT。 - 块里的行被DELETE,或者UPDATE把行长度缩短,使用率下降。只要使用率低于
pctused,块重新回到空闲列表。 - 回到空闲列表后,这个块又可以参与后续INSERT分配。直到它再次被填满,再次摘除,循环往复。
这套机制本身不复杂,但有个容易忽略的细节:块离开空闲列表的门槛和使用率有关,回到空闲列表的门槛也和“使用率”有关,而不是和“还有多少字节空闲”有关。也就是说,一个块即使空出了30%的空间,只要使用率还高于pctused,它就一直处于“闲置但不可插入”的尴尬状态。这个状态会让已经分配出去的块不能被充分利用,进而导致段不断扩张,段变大了但数据密度反而没跟上。
2.2 两个参数怎么配:间距的艺术
pctfree和pctused之间隔着一段区间,这段区间决定了块处于“非空闲但也不满”状态的时间长短。
默认组合是pctfree=10、pctused=40,区间的跨度是100 - 10 - 40 = 50个百分点。一个块从摘除到重新回列,中间要经历使用率从90%降到40%的过程。这个跨度大,块在“闲置不可插”状态待的时间长,空间利用率可能下降。
如果反过来,把pctused调得很高,比如70,那么pctfree=10时区间跨度只有100 - 10 - 70 = 20个百分点。块的使用率稍微降一点就会回到空闲列表,看起来“空间复用更快”,但代价是块会频繁进出空闲列表,并发INSERT争夺freelist的开销变大,链表扫描次数增多,最终插入性能反而下降。
所以这两个参数不能孤立看待,它们是一对组合。区间太大,空间浪费;区间太小,空闲列表抖动。这也是为什么Oracle默认重心偏保守:10/40在大多数场景下让块留在空闲列表外的时间足够长,避免频繁切换,同时也能容忍一定程度的空间闲置。
2.3 ASSM 为什么把 pctused“废了”
如果你查的是12c、19c的Oracle数据库,创建表空间时默认使用自动段空间管理(ASSM)。在ASSM下,段的空闲空间信息不再维护在freelist里,而是用位图记录每一组块的使用状态。块被分成三类:全空、部分使用、已满。
插入时,Oracle直接根据位图找到“部分使用”的块,直接写入。块满了,位图把它标记成已满;块因为删除而空出空间,位图自动把它从已满降级成部分使用,于是又可以插入。
这套流程根本不需要pctused来“复位”。所以在ASSM表空间里,pctused参数会出现在建表语句里,但不生效。你改成多少都行,Oracle都会忽略它。pctfree在ASSM下依然有效,但它只是影响块内预留空间,不参与空闲列表管理。
这里有个很常见的坑:有人在ASSM表空间里把pctused从40改成70,想提高空间复用效率,跑了一周发现表大小和插入延迟完全没变,还以为参数失效了。实际上不是失效,是它压根没参与工作。查一下表空间的segment_space_management字段就能确认,AUTO就是ASSM,MANUAL才是MSSM。
select tablespace_name, segment_space_management from dba_tablespaces where tablespace_name = 'USERS';3. 参数应该怎么定:公式、场景与权衡
3.1 最实用的 pctfree 估算公式
pctfree设置的核心,是为了覆盖“块内行未来增长的空间需求”。最直接的办法是根据当前行均长和未来行均长来估算:
pctfree ≈ (projected_avg_row_len - current_avg_row_len) / projected_avg_row_len × 100%
什么意思?假设一张表现在平均行长480字节,下一步应用要增加一个备注字段,将来平均行长会到610字节。理论需要预留的比例是:
(610 - 480) / 610 ≈ 21.3%
也就是说,pctfree理论上至少要21%。但这只是单行角度的理论底线,实际使用里我会再加10到15个百分点作为安全垫,因为avg_row_len是平均值,总会有一些行比均值涨得更多。
另一个角度是看“块内所有行的总增长量”。8KB的块,可用空间按7.6KB算,480字节一行大概能放15到16行。如果每行都要涨130字节,16行总计要额外约2080字节,占块可用空间的27%左右。这样算出来pctfree至少要27%,和前面21%的结论方向一致,只是更保守一些。这也是为什么我遇到行增长较明显的表,习惯把pctfree放在30到40之间,而不是默认的10。
注意:pctfree是按百分比预留的,不是在块里留固定字节数。块大小不同,同样比例对应的绝对值不同,但百分比逻辑是一样的,所以公式不随块大小变化。
3.2 不同负载下的推荐组合
没有万能参数,只有适合负载的参数。下面是我在项目里常用的参考值,基于手工段空间管理场景,最好结合前面公式最终确认:
| 场景 | pctfree | pctused | 说明 |
|---|---|---|---|
| 只读/归档表 | 0~5 | 5~20 | 没有UPDATE需求,尽可能把块塞满,减少空间占用 |
| 常规OLTP混合负载 | 10 | 40 | Oracle默认,适合大多数情况,不用乱改 |
| UPDATE频繁且行长会变长 | 25~40 | 50~60 | 为行增长留足空间,块使用率下降后较快回列 |
| 以INSERT为主的流水表 | 0~5 | 20~30 | 减少freelist运维开销,空间优先 |
| 索引段PCTFREE | 15~30 | 无 | 索引没有pctused概念,pctfree高可以减少索引块分裂 |
这里需要特别说明索引:索引段只认pctfree,不认pctused。如果你在创建索引时写了pctused,Oracle会忽略它。索引的pctfree作用是给索引块里的条目预留空间,让未来键值增长或INSERT时不至于频繁触发索引块分裂。对递增主键这类有序插入,pctfree默认10足够;对随机键值插入频繁的索引,可以适当调高到20~30,代价是索引尺寸变大。
3.3 修改参数不等于立即生效
很多人改了表参数之后,发现链行率没有下降,表大小也没有变化,于是以为参数没用。这不是参数没用,而是你只改了“元数据”,没有让现有块按新参数重新布局。
ALTER TABLE ... PCTFREE只是修改了段的属性,它只影响后续新分配的块,对已经存在的数据块基本没有作用。想让存量块按新pctfree重新排布,你必须移动数据:
alter table orders move pctfree 35 pctused 60;MOVE会把表的行重新物理分布,按新的空间参数重新写入块。但MOVE会让所有索引失效,所以紧跟着必须重建索引:
alter index idx_orders_01 rebuild;如果是分区表,可以针对单个分区操作:
alter table orders modify partition p2024_01 pctfree 35; alter table orders move partition p2024_01 pctfree 35;MOVE期间表会被锁定,在线业务不能访问,所以大表一定要安排在维护窗口。如果是几十GB级别的大表,MOVE的锁表时间可能无法接受,这时候要优先考虑在线重定义(DBMS_REDEFINITION),通过重建临时表并交换数据的方式把影响降到最低。
4. 一个真实案例:更新频繁的大表为什么越来越慢
4.1 症状与初步判断
某应用系统里有一张订单表,大约320万行,占用约330MB空间,平均行长480字节。应用上线了一个新版本,把订单附言字段从40字节扩到200字节,并且大量历史订单在后台被程序批量更新,附言字段从短文本变成了长文本。
上线两周后,DBA发现两个明显的性能劣化:
- 全表扫描这张订单表从8秒涨到了30秒以上。
- 索引等值查询偶发变慢,有时同一个查询的耗时相差一倍。
索引没失效,统计信息也重新收集过,执行计划看到的变化只是全表扫描的IO代价明显增加。一开始大家怀疑是缓冲池不够,后来才发现问题在于表段内部出现大量行迁移,全表扫描读取了大量无效的转发地址。
初始排查直接看表的统计信息:
select table_name, num_rows, blocks, empty_blocks, avg_row_len, chain_cnt from dba_tables where owner = 'APP' and table_name = 'ORDERS';结果里chain_cnt达到57万多,除以行数约18%。也就是说每100行里就有18行是链行或迁移行。再看avg_row_len已经涨到610字节,比之前的480字节增长了约27%。结合应用更新的字段大小,基本能确定:pctfree默认10%完全覆盖不了27%的单行增长,大量行在UPDATE时因为空间不足被迫搬家。
4.2 用链行诊断脚本确认根因
chain_cnt这个值只反映规模,要定位哪些行受影响,需要把链行明细导出来看。Oracle提供了一个标准脚本,用来创建chained_rows表,然后把链行数据灌进去。
@?/rdbms/admin/utlchain.sql analyze table app.orders list chained rows into chained_rows; select count(*) from chained_rows;执行之后,chained_rows里记录的就是所有发生链行或迁移的行。接下来要区分是“行链接”还是“行迁移”。
- 行链接:一行数据本身就超过了一个块能容纳的大小,天然要跨块存储,通常是超大行。
- 行迁移:行本来在一个块里放得下,因为UPDATE变大,被移动到新块,旧位置留转发地址。
怎么区分?很简单,看这表的平均行长。这里的avg_row_len是610字节,8KB的块即使扣除块头也能容纳十几行,根本不可能产生物理上的行链接。所以这18%的链行几乎全部来自行迁移,根因就是pctfree不足。如果一张表的avg_row_len接近甚至超过块大小,那才是行链接问题,调pctfree基本没用,要考虑增大块大小或者把大字段拆分到单独表。
注意:
chained_rows表如果存在,analyze之前最好先清空,否则结果会不断累积,影响排查。每次排查前先truncate table chained_rows。
4.3 处理过程与效果对比
确认根因之后,处理方案就是按新行长重新计算pctfree,并让数据块重新排布。
前文提到,理论需要的pctfree至少在27%左右,所以我把pctfree直接定到35%,给尾部数据留了一点安全余量。pctused从40提高到60,让删除空间后使用率下降快的块能较快回到空闲列表,减少空间浪费。然后在维护窗口执行:
alter table app.orders move pctfree 35 pctused 60; -- 重建所有索引 alter index idx_orders_01 rebuild; alter index idx_orders_02 rebuild;MOVE执行完,观察新统计信息和实际效果:
| 指标 | 调整前 | 调整后 |
|---|---|---|
| 链行率 | 18% | 0.4% |
| 全表扫描耗时 | 30秒以上 | 9秒左右 |
| 表段大小 | 约330MB | 约375MB |
表段大小增加了约13%,这是提高pctfree的直接代价,但换来了链行率大幅下降和全表扫描性能恢复。代价完全值得。
这里要说一个很关键的细节:MOVE之后一定要立即重建索引,不是“建议”,是“必须”。移动行的物理位置后,索引里旧的rowid全部指向错误位置,不重建索引的话,等值查询会直接产生大量错误读,性能比不处理前更差。我遇到过不止一次同事MOVE完忘了重建索引,第二天上线被查询慢的问题搞到焦头烂额。
这次案例中,我们本来也想试试pctfree=50能不能让链行率直接降到0,但算了一下空间代价和收益:从35%再往上提,每个块里的行数会明显变少,表段会额外膨胀很多,全表扫描的块数也要增加,反而可能抵消部分收益。0.4%的链行率已经可以接受,不需要追求绝对零迁移。
5. 常见误区与排查速查表
5.1 三个高频误区
误区一:pctfree越大越好。这是最常见的误解。pctfree的本质是“用空间换更新稳定性”,但如果预留过大,块里能装的行数变少,全表扫描和索引范围扫描要读更多的数据块,表段膨胀,空间成本反而拖累性能。我看到有些建表脚本把pctfree写50,理由是“防止以后更新”,结果一张100GB的表膨胀到180GB,扫描性能急剧下降。合理做法是先量化行增长,公式算出来再给安全垫,不要拍脑袋定一个很大的值。
误区二:pctused越高,空间复用越快、利用率越高。pctused高,块确实更容易回到空闲列表,但代价是空闲列表抖动。我来描述一下这个场景:pctused=80时,一个块刚被填到90%摘除,删除几行之后降到75%,立刻回列,INSERT两三条又到90%,再次摘除。这个块在空闲列表上反复横跳,每次进出都要操作链表,并发插入时freelist会成为热点。实际经验是,pctused设得太高,插入TPS反而下降。
误区三:ASSM下调整pctused能优化空间管理。前面已经说过,ASSM表空间里pctused被Oracle直接忽略,位图管理机制已经替代了freelist。除非你的表空间是segmentspacemanagement=manual的老古董,否则不要在pctused上浪费时间。查看表空间管理方式用第2.3节那条SQL,一目了然。
5.2 快速排查清单
我平时排查时有一套固定的动作,分享出来供参考:
| 现象 | 可能原因 | 优先检查 |
|---|---|---|
| 链行率大于5% | pctfree不足,行长增长 | 看avg_row_len与历史对比,analyze list chained rows |
| 插入越来越慢,等待事件频繁 | freelist争用,pctused过高 | 查表空间是否ASSM,查v$segment_statistics |
| 全表扫描IO很大 | pctfree过大,块内密度低 | 查avg_space、empty_blocks |
| 表空间剩余很多但insert还在扩张 | 高水位线未释放,pctused过低导致块不可复用 | 检查blocks与实际数据量,考虑shrink或move |
配合定期巡检,我习惯用一个SQL把链行率高的表捞出来:
select owner, table_name, num_rows, chain_cnt, round(chain_cnt / nullif(num_rows, 0) * 100, 2) as chain_pct from dba_tables where num_rows > 10000 and chain_cnt > 0 order by chain_pct desc;这个SQL执行前提是表的统计信息足够新鲜,尤其是chain_cnt字段。如果长时间没分析,这个字段可能还是旧值,甚至为0。最好在维护窗口先跑一次analyze table或者dbms_stats收集完再查询。
排查时还要区分“行链接”和“行迁移”。行链接常见于超长行,比如把大文本直接塞进普通表字段,而且塞到接近块大小,这种情况调pctfree没用,应该考虑改CLOB、分表或者增大块大小。而行迁移的根源,就是UPDATE时块内空间不够,解决方案就是本文一直在讲的pctfree计算和move重排。
最后多提一句:如果表已经用ASSM管理,链行率又很高,单纯调pctfree之后必须做move或shrink space才能让存量块重新布局。shrink space需要先开启行移动,操作流程是alter table enable row movement,然后alter table shrink space cascade。这个方法的好处是可以在线做,锁表时间比MOVE短,但如果表上有函数索引会有限制,使用时提前确认。
我在实际项目里最深的体会是:pctfree和pctused不是那种“一枝独秀”的优化项,它们更像是表和更新之间的空间交易。调参之前永远先用数据说话——看avg_row_len变化,看chain_cnt规模,看应用接下来会不会改字段长度。把这条习惯养成之后,很多性能问题都能在变成事故之前就被拦下来。参数本身的规则很老,一点都不复杂,复杂的从来都是我们对数据增长的预判。