作者:胖头鱼的鱼缸(尹海文) Oracle ACE Pro: Database PostgreSQL ACE 10年+数据库行业经验 拥有OCM 11g/12c/19c、MySQL 8.0 OCP、Exadata、CDP等认证 墨天轮MVP,ITPUB认证专家 圈内拥有“总监”称号,非著 名社恐(社交恐怖分子) 全网同名:胖头鱼的鱼缸 ITPUB:yhw1809 除授权转载并标明出处外,均为“非法”抄袭

一张 300 万行的订单表,status 列只有 8 个取值。某天顺手查了下它的索引大小:65 MB。
8 种值,65 MB。这个数字盯上三秒,很难不上火——里面到底装了什么?
没装错,B-Tree 就是这么老实。表里每一行,它都规规矩矩地在叶子上放一条记录——8 种值不假,但 300 万行也是 300 万行,重复的键值,被一笔一画抄了 300 万遍。
说白了,索引里掺着水分。
翻金仓 KES V9 新版说明书(V009R002C016)的时候,在可用性那一节看到一行:
新增B-Tree 索引数据去重功能,支持通过deduplicate_items 存储参数灵活配置,有效节省存储空间并降低磁盘 I/O 开销。
一句话,别的什么都没说。到底能省多少、有没有代价——摆一局实测,把数记全。
众所周知,索引的作用是"帮你找到每一行",所以每条索引记录里有两样东西:索引列的值、指向表里那一行的位置。再加一点内部开销,就是一条记录的全部。
两样里,位置是必须的——每一行都得能被指到。冤的是"值":status 就 8 种取值,值本身几十个字节打得住了,但它出现 300 万次,每一次出现都从头到尾自带一份完整拷贝。像一份 8 页的说明书,每页都把封面重印一遍。

叶页里的记录是按键排好序的,排在一起的那些记录,键相同的机会很大——status 这种列上,一个叶子页里大部分键都一样。于是就有了"掺水"的结构性原因: 同一个页内,重复的键各存各的。
把账摊开算:那张表 300 万行,索引 67,870,720 字节,摊到每行是 22.6 个字节。
那"不掺水"的版本该长什么样?想象一下:每个值只存一份,行位置一个不少地全留着。这么算下来,每行只需要摊到那几个字节的行位置,加上很少的摊销开销。
实测里这个数是 7.8 个字节。
22.6 到 7.8,多出来的那 14.8 个,就是水分。
V009R002C016 给 B-Tree 加的去重能力,说穿了很朴素:
让重复的键只存一份,后面挂一串行位置。参数名叫
deduplicate_items。
先把测试的摆法交代清楚,再上数字。这轮一共测了四组:索引体积、查询 I/O、写入耗时,还有不同重复度下的收益变化——
on 索引,和"不去重"的基准比体积;8 个值那列多建两个——明确
off 的,和
什么都不写的(文档没写默认值,那就别猜,建出来比大小)一句丑话放前面: 单台测试环境、单次测量,下面的数字只代表这套环境下的量级——换台机器,绝对值会有出入,比例关系才是要看的。

结果很干脆: 65 MB 变 22 MB,省掉 65.5%;页数从 8,285 掉到 2,857,差了接近三倍。
但这轮测试里最重要的发现不是省了多少,是图上第二行:
什么都不写,跟 off 一字节不差(67,870,720 字节,页数一模一样)。
也就是说:
这个能力默认是关的。 升级到新版本不会自动生效,新建索引也不会自动去重——想用,得明确写上
WITH (deduplicate_items = on)。
去重干的是"页内合并相同的键",那顺理成章的问题:重复少到什么程度,这招就不灵了?
我把 300 万行造出不同重复度的列,一档一档量下来,结果跟直觉不太一样:

最值得记下的结论是: 去重挑的不是"重复的总量",而是"有没有重复"。
B-Tree 里相同的键天然排在一起,页内总能凑出可合并的邻居——从"平均每值重复 30 条"往上,收益就不再变化。真正零收益的只有一种: 事实唯一的列——订单号、流水号、UUID 这类每行一个新值的。
所以挑对象的口径也跟着换了:不是"低基数列才值得开",而是——
空间省下来了,下一个问题:查起来、写起来有变化吗?
查:读的页少了
同一条查询,我放到两张结构完全一样的表上——一张只挂去重索引,一张只挂普通索引。(不这么摆的话,优化器从多个索引里挑谁用是随机的,对比没法归因。)VACUUM 之后跑同一条等值查询,命中 37.5 万行:

读页数少了 65.5%,跟体积一个比例——索引小了,同样的扫描读的页就少,这很好理解。
但耗时只快不到两成(16%)。为什么没跟着页数同比例降?因为省的是 I/O——这条查询里还有一块不缩水的开销:把 37.5 万行数一遍的 CPU。索引再小,数数的活儿一分没少。
写:基本相当

这几组数得把口径说清楚:单台环境、单次测量,而且这个量级的操作,测量本身的波动不小(同一个操作两遍能差出两三成)。所以准确的说法是—— 写入侧没有肉眼可见的代价,不是"更快"。
本期跟随总监,把要用的 SQL 摆出来。三步:看、开、验。
第一步,看:哪些索引里有水
按大小排个队,从占地方最多的往下看:
SELECT indexrelname, sys_size_pretty(sys_relation_size(indexrelid)) AS size FROM sys_stat_user_indexes WHERE relname = '你的表名' ORDER BY sys_relation_size(indexrelid) DESC;
队伍排出来之后,看一眼列上的重复情况——只要不是事实唯一(订单号、UUID 这类),都够得着收益;排在前面的,就是最值得先挤的。
第二步,开
新索引,建的时候写明:
CREATE INDEX idx_xxx ON 表名 (列名) WITH (deduplicate_items = on);
存量索引,先改参数、再 REINDEX:
ALTER INDEX idx_xxx SET (deduplicate_items = on); REINDEX INDEX idx_xxx;
两步缺一不可。实测里:
光 ALTER 改参数,体积一动不动——reloptions 已经显示
on 了,65 MB 还是 65 MB;只有 REINDEX 之后,才真正变成 22 MB。ALTER 登记的是"下次重建时用新参数",物理结构得重建才落地。

第三步,验
参数看 reloptions:
SELECT relname, reloptions FROM sys_class WHERE relname = 'idx_xxx';
体积看
\di+,或者把第一步的查询再跑一遍,前后一对就清楚了。

回到开头那 65 MB。
去重没什么魔法,就是把叶页里重复的键合并成一份,行位置一个不少地留着。效果实在:300 万行、8 个值的场景,65 MB 变 22 MB,同样的查询少读 65.5% 的页。
实测里还有几个容易略过的点,我觉得比"省了 65%"本身更值得记住:
所以我的建议:
老规矩,知道写了些啥。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/31466763/viewspace-3186773/ ,如需转载,请注明出处,否则将追究法律责任。