实际管理生产库的时候,最让我捏把汗的不是SQL写得多花哨,而是给一张大表加了索引之后业务反而变慢——这种“优化反变折腾”的情况,相信不少人都撞上过。人大金仓(KingbaseES)作为基于PostgreSQL内核的国产数据库,索引的语法、原理和社区版PostgreSQL高度一致,但真到了运维和调优层面,索引操作里的细节坑一点不比别的数据库少。本文结合我在金仓环境里做压测、性能调优,以及用Docker容器快速搭环境练习的实际经历,把索引的创建、修改、删除、重建、验证和排查这一整套操作完整捋一遍,希望对正在适应金仓的DBA、后端开发和运维同学有参考价值。
1. 索引操作前先把账算清楚:它到底在解决什么问题
1.1 从一次全表扫描的成本开始理解索引
很多新手一上来就背“查询慢就加索引”,但从不追问索引为什么能让查询变快。这个底层逻辑搞不清楚,后面做索引操作就很容易拍脑袋。
先看没有索引的情况。假设一张订单表t_order,里面有1000万行记录,现在执行select * from t_order where user_id = 20245;。数据库没有捷径可走,只能从表的第一页开始,一块一块地把数据块读进内存,然后逐行判断user_id是否满足条件,这个过程就叫顺序扫描(Seq Scan)。1000万行按每行100字节估算,差不多得有1GB左右的物理数据要读,就算放在SSD上,也至少要几百毫秒甚至更久,如果并发一高,IO直接被打满。
而有了B-tree索引之后,情况就变了。你等于给user_id建立了一棵“搜索树”,从根节点到叶子节点通常只需要3到4次逻辑IO,就能定位到目标行的物理位置,然后再回表读取那一行数据。同样是找20245这个用户,成本从“读全表”变成“读一个索引路径+读目标行”,差距不是一倍两倍,而是两三个数量级。
这里还有一个容易被忽略的点:不是所有SQL都适合走索引。如果目标行占全表的比例超过5%到10%,优化器可能宁可选择顺序扫描,因为回表次数太多,还不如一口气把全表读完。这个比例在线性回归一样的经验值,不同的硬件环境下会有波动,后面讲执行计划验证的时候会再细说。
1.2 写入放大:索引不是免费的午餐
索引让查询变快,代价必然是让写入变慢,这是数据库的铁律。每一次insert向表里插入一行,不只是往堆表里写一条数据,还要往这张表上的每个索引里同步插入对应的索引项。update更麻烦,如果更新的字段本身在索引里,那等于这个字段索引里要先删旧值、再插新值。delete也类似,索引项跟着一起删。
我做压测的时候观测过一个场景:一张表没有任何索引,每秒大概能写入2.3万行;给同一个表加了三个B-tree索引之后,写入掉到了每秒8000行左右。索引维护带来的CPU和日志写放大,在OLTP场景里是实打实的开销。所以索引操作第一条原则就是:只为实际业务查询建索引,不为“以后可能用得上”建索引。
写入放大带来的另一个隐患是索引膨胀。一个索引页里被删除的索引项如果没有及时回收,索引文件会越来越大。金仓和PostgreSQL一样依赖VACUUM和索引页内清理机制来回收空间,但现实里很多库的autovacuum参数被调得不合理,结果就是索引膨胀得比表还大。后面讲REINDEX的时候我会专门说怎么处理。
1.3 索引设计的取舍原则
建索引之前,我最少会问自己三个问题:
第一,这条SQL是不是高频SQL?一天跑几十次的报表SQL,和一天跑几十万次的订单查询SQL,优先级完全不一样。数据库资源是有限的,好钢要用在刀刃上。
第二,这个查询的过滤条件能不能高效命中索引?比如where status = 1这种,如果status只有“已支付”“未支付”两种取值,全表一半数据都是status=1,这种低基数条件下索引很难发挥作用,优化器大概率不走索引。
第三,能不能让索引覆盖这次查询需要的所有列?如果能做到索引里就把字段取全,连回表都省了,这种覆盖索引(也叫Index-Only Scan)效果最好。比如查询select user_id, amount from t_order where user_id = 123,在user_id上建索引就够了;如果还想返回create_time,最好在(user_id, create_time)上建复合索引,让索引覆盖足够多的列。
顺手再提一个容易犯的错:很多人给主键建了索引,又给主键字段再单独建一个普通索引,这就是纯浪费。主键本身在系统表里就带着唯一索引,重复索引除了增加写放大和占用磁盘空间,没有任何正向作用。
2. 金仓支持哪些索引类型,以及选型思路
2.1 索引类型一览
人大金仓跟随PostgreSQL的通用能力,常见的索引类型基本都支持。我用过一个通用版本里可以创建的几种类型,做一个对比:
| 索引类型 | 适用场景 | 注意点 |
|---|---|---|
| btree | 等值查询、范围查询、排序、唯一约束,日常90%的场景 | 最通用,默认类型,列基数不能太低 |
| hash | 等值查询,尤其大字段等值比对 | 不支持范围查询,不支持排序,更新开销略大 |
| gin | 数组、JSONB、全文检索等“一个值匹配多个条目”的场景 | 更新慢,适合读多写少的场景 |
| gist | 地理位置、空间数据、范围类型 | 如果只是普通业务表,基本用不上 |
| spgist | 空间数据和部分非平衡数据分布 | 比gist更轻量,适用度偏窄 |
| brin | 超大数据表,且字段与物理存储顺序相关性高 | 索引体积极小,但定位精度差,需要回表较多 |
我自己在实际项目里用得最多的永远是btree。说实话,绝大多数业务系统根本不需要gin、gist这些高级索引类型,能把btree用好、用对,已经能解决80%的性能问题。gin和gist这些类型,更多是在做搜索、地理信息这类特定业务时才会碰见。
2.2 索引类型选型的两个判断维度
选索引类型,本质上是看数据的“形状”和“查询的模式”。
所谓数据形状,就是字段的基数。基数就是某个字段不同取值的个数。比如性别字段,基数只有2;手机号字段,基数和行数差不多。基数越高,btree索引的区分度越好。基数极低的字段,就别硬上索引了,即使建了,优化器也会看不起它。
所谓查询模式,就是SQL后面跟的是什么条件。=、>、<、between、order by,这些统统是btree的强项。where email LIKE '%abc%'这种前缀不确定的模糊查询,btree没法走索引,真要优化只能考虑gin配合pg_trgm这类扩展。where tags @> '["a","b"]'这种数组包含查询,btree更是完全无能为力,必须走gin。所以你在创建索引之前,先看一眼SQL的谓词,比什么都重要。
2.3 复合索引的列顺序选择
复合索引是很多人纠结的地方:两张表的多个条件,到底是拆成两个单列索引,还是一个复合索引?
我的习惯是:优先做复合索引,并且把等值查询的列放在前面,范围查询或排序的列放在后面。举个例子,业务SQL是:
select * from t_order where user_id = 20245 and status = 1 order by create_time desc;这种场景下,复合索引(user_id, status, create_time desc)比单列索引效果好得多。因为B-tree索引能同时用于过滤和排序,user_id和status先把候选集缩小到很小,索引读出来的行天然就是按create_time排好序的,连额外的排序步骤都省了。
这里有一个心得:复合索引的列顺序非常考验“对业务访问模式的理解”。建错顺序的复合索引,很多情况下等于建了一个永远用不全的废索引。所以我建复合索引之前,一定先把这条查询的所有变体SQL都拉出来看一遍,找出公共前缀条件,再去定索引列顺序。
3. 核心命令实战:创建、修改、删除与重建
3.1 用CREATE INDEX创建索引
金仓的创建索引语法和PostgreSQL一脉相承,基本格式如下:
CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] 索引名 ON 表名 [USING 索引类型] (字段名 [ASC|DESC] [NULLS FIRST|NULLS LAST]) [INCLUDE (额外字段)] [WITH (参数=值)] [TABLESPACE 表空间名] [WHERE 谓词];实际业务里最典型的几种写法:
普通单列索引:
CREATE INDEX idx_t_order_user_id ON t_order(user_id);唯一索引:
CREATE UNIQUE INDEX uk_t_order_order_no ON t_order(order_no);复合索引:
CREATE INDEX idx_t_order_user_status_time ON t_order(user_id, status, create_time DESC);部分索引,只索引“待支付”状态的订单:
CREATE INDEX idx_t_order_wait_pay ON t_order(create_time) WHERE status = 0;覆盖索引,让索引直接把需要的字段带出来:
CREATE INDEX idx_t_order_user_amount ON t_order(user_id) INCLUDE (amount);这里说几个参数的细节。CONCURRENTLY表示在线创建索引,不在创建期间阻塞表的写操作。这个参数在生产环境几乎是必加的,但金仓和PostgreSQL一样,CONCURRENTLY不能在事务块里执行,而且执行时间会比普通方式更长,因为需要多扫几遍表。如果表是新建的、没有业务流量,那就没必要用CONCURRENTLY,直接普通创建反而更快更省事。
WITH (fillfactor=90)这个参数容易被忽略。它表示索引页预先留出10%的空白空间,给后续的索引更新留余地。默认值是90,如果一张表是“只读不更新”的,可以把fillfactor调到100,索引体积更小、扫描更快;如果更新频繁,建议保持默认或更低,减少索引页分裂的概率。
3.2 修改索引:ALTER INDEX
索引创建之后并不代表永远不动了,常见的修改操作包括改名、调整存储参数、迁移表空间。
改名:
ALTER INDEX idx_t_order_user_id RENAME TO idx_t_order_uid;调整fillfactor参数:
ALTER INDEX idx_t_order_uid SET (fillfactor=80);把索引迁移到另一个表空间:
ALTER INDEX idx_t_order_uid SET TABLESPACE fast_tbs;这里要注意:ALTER INDEX ... SET (fillfactor=80)并不会立刻重建现有索引数据,它只是改了索引的元数据,新参数要等下次REINDEX或者索引页重建时才真正生效。所以改完参数一定要记得重建索引,否则等于没改。
改名是我经常碰到的一个操作需求,尤其是接手的旧库,索引命名乱七八糟,比如idx_1、idx_2这种完全看不出用途的名字。命名规范建议统一成idx_表名_字段名或者uk_表名_字段名,配合注释一起维护,后面排查问题能少死很多脑细胞。
3.3 删除索引:DROP INDEX
删除索引的语法很简单:
DROP INDEX [CONCURRENTLY] [IF EXISTS] idx_t_order_uid;加CONCURRENTLY同样是为了避免长时间持有锁而阻塞写入。生产环境删除大表上的索引强烈建议加这个参数,否则一条DROP INDEX会把表锁住,业务写入直接卡住。
删除索引前我通常先做两个确认:
第一,确认这个索引没有被外键约束或唯一约束依赖。尤其唯一索引,如果被当成唯一约束在用,直接删除会导致数据重复风险。
第二,确认这个索引确实不被任何SQL使用。我会先查一段时间的统计视图,看看有没有历史扫描记录,再决定删不删,这一点在第4节详细说。
还有一个容易踩的坑:有些同学想“改一下索引字段顺序”,就把旧索引删了再建新的,中间这段空窗期如果业务还在跑,慢查询会瞬间冒出来。这种情况我更推荐直接用CREATE INDEX先建一个新索引,确认没问题之后再删旧索引,中间有短暂的时间两个索引并存,代价是多一点空间,但换来的是业务无感切换。
3.4 重建索引:REINDEX
索引用久了之后,页内会产生大量碎片,查询性能下滑,磁盘占用也在增长。重建索引是唯一彻底的解决办法,金仓命令和PG一致:
REINDEX INDEX idx_t_order_uid;也可以重建整个表的所有索引:
REINDEX TABLE t_order;REINDEX默认会阻塞写入,生产环境推荐REINDEX INDEX CONCURRENTLY idx_t_order_uid;,但和CREATE INDEX一样,加了CONCURRENTLY就不能在事务块里执行。另外,金仓也提供了REINDEX DATABASE和REINDEX SYSTEM,后者可用来重建系统目录的索引,普通业务很少用。
我维护过的一张业务表,总数据量大概5000万行,索引文件膨胀到原始体积的2.3倍,一个晚上执行REINDEX TABLE之后,索引文件缩回正常大小,相关慢查询耗时下降了40%左右。膨胀问题在高并发下删除订单数据的库里特别明显,建议把REINDEX纳入定期运维计划,而不是等问题爆发再处理。
这里要额外提醒:REINDEX之后,统计信息并不会自动更新,某些情况下还需要重新ANALYZE,让优化器拿到最新的索引分布情况,否则可能出现索引重建了、执行计划却还是按旧统计信息走的尴尬局面。
4. 验证索引是否真的生效:系统视图与执行计划
4.1 通过系统视图看索引状态
在金仓里,索引的元数据可以通过系统目录和统计视图来查看。不同版本的对应视图名称会有差异,有的版本下系统表叫sys_*开头的名字,功能上等价于PostgreSQL的pg_*系列;也有的版本直接保留了兼容视图。我的经验是先查官方手册“系统目录”章节确认当前版本的视图名,但字段含义基本一致。
最常用的查询是查看某张表上的索引列表:
SELECT tablename, indexname, indexdef FROM pg_indexes WHERE tablename = 't_order';如果你用的版本里没有pg_indexes,也可以直接读系统目录拼出来,逻辑是一样的。这个查询主要看三件事:这张表上有哪些索引、索引定义是什么、是不是有重复定义。
另外两个统计视图也很关键,一个是pg_stat_user_indexes,一个是pg_stat_all_indexes。它们记录了每个索引被利用的情况,核心字段是idx_scan(该索引被扫描次数)、idx_tup_read(通过该索引读出的行数)、idx_tup_fetch(通过该索引回表取到的行数)。
我排查无用索引的常用SQL是这样的:
SELECT s.relname AS table_name, s.indexrelname AS index_name, pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size, s.idx_scan, s.idx_tup_read FROM pg_stat_user_indexes s WHERE s.idx_scan = 0 ORDER BY pg_relation_size(s.indexrelid) DESC;这个查询会列出所有从未被扫描过的索引,按大小排序。如果某个索引建了好几个月,idx_scan还是0,基本可以怀疑是废弃索引,可以安排下线。
4.2 通过EXPLAIN看执行计划
统计视图只能告诉你“这个索引历史上有没有被用过”,但某个具体的慢SQL到底走没走索引,必须看执行计划。
最简单的用法是:
EXPLAIN SELECT * FROM t_order WHERE user_id = 20245;输出里如果出现Index Scan using idx_t_order_user_id或者Bitmap Index Scan,说明SQL确实用上了索引;如果出现Seq Scan on t_order,那就是全表扫描,索引没被采纳。
想看更真实的时间代价,加ANALYZE选项:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t_order WHERE user_id = 20245;ANALYZE会真正执行这条SQL,BUFFERS会显示命中了多少缓存页。这条命令很有用,但一定记住:它在生产环境中会真实地跑一遍SQL,对大表来说可能造成额外负载,要谨慎使用。
看执行计划的时候,重点不是纠结cost的具体数值,而是看三个关键点:一是有没有发生Seq Scan,二是actual time和rows的估算差多少,三是有没有出现Sort操作。如果SQL明确有条件、有计划但最终还是Seq Scan,说明优化器认为走索引不划算,这个要不要处理,得结合数据分布来判断。
4.3 定位索引失误的关键思路
有一次我处理一个查询,where user_id = 20245 and status = 1,明明两个字段上都建了独立索引,执行计划里却还是Seq Scan。当时第一反应是统计信息有问题,跑了ANALYZE t_order;之后再看,执行计划变成了Bitmap Heap Scan,算是解决了。
这类问题的Word一版是隐式类型转换。比如user_id字段是varchar类型,查询条件写成user_id = 20245,数据库会尝试把字段类型转成数值,索引就失效了。修复方式是把查询改成user_id = '20245',或者统一字段类型。
第二种常见原因是函数包裹。查询写成where date(create_time) = '2025-01-01',这就是在create_time外面套了函数,btree索引没法直接用。正解是改成where create_time >= '2025-01-01' and create_time < '2025-01-02',让索引可以走范围扫描。
第三种原因是数据分布倾斜。如果user_id=20245这个值在表里占了40%的份额,优化器会认为索引扫描不划算。这种情况即便强制走索引,实际效果可能更差,问题的根源反而是业务SQL写法或历史数据需要治理。
5. 常见问题排查与避坑实录
5.1 索引建了但SQL就是不走索引
索引建了但SQL没走,这是运维中问得最多的问题,实际原因通常落在三类:
查一下字段类型是否匹配。金仓对类型要求比想象中严格,字符串字段和数值条件之间做隐式转换,最容易让索引失效。其次是条件字段上有没有函数或表达式。上面说的date(create_time)就是典型。最后是表统计信息过期。表数据量变化很大、但长期没跑ANALYZE,优化器固化的执行计划可能已经不靠谱。
排查顺序建议先看执行计划,确认是不是Seq Scan;再看字段类型和SQL写法;最后跑ANALYZE刷新统计信息。按这个顺序走,绝大多数“有索引不走”的问题都能定位。
5.2 冗余索引和重复索引清理
很多团队的索引是“业务一着急就临时加一个”,几年下来,一张十来个字段的表上挂了二十多个索引,里面一半功能重复。比如有了(user_id, status),又建了一个(user_id),后面这个单列索引用处就不大了,因为复合索引的左边前缀已经能覆盖user_id的过滤需求。
清理冗余索引的步骤是这样的:先把所有索引的indexdef拉出来,人工比对哪些索引的前缀列重复;再用统计视图筛选idx_scan=0的候选;最后挑业务低峰期,一个个删除、观察慢查询有没有新增,确认安全后彻底清理。
这里一定不要图省事一次性把看起来没用的索引全删掉。因为统计视图没法覆盖“某条只在月初跑的报表SQL”,你觉得没用的索引,可能每个月只用一次。
5.3 索引膨胀:为什么索引文件比表还大
索引膨胀和表膨胀是两码事。表膨胀通常是死元组堆积,索引膨胀则是大量索引项被标记删除、但页面没有及时复用。高并发的UPDATE和DELETE是主要成因,尤其是更新索引字段的场景。
判断索引膨胀程度,可以对比索引逻辑大小和物理大小:
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS physical_size, pg_size_pretty(pg_relation_size(indexrelid, 'main')) AS main_size FROM pg_stat_user_indexes WHERE relname = 't_order';不过这个只给物理大小,真正要和逻辑数据量做对比,建议用pageinspect扩展或者直接结合索引扫描效率来判断。如果膨胀明显,低峰期跑REINDEX INDEX CONCURRENTLY,效果立竿见影。
5.4 并发DDL卡死和事务块限制
在线创建索引(CONCURRENTLY)在生产环境是常规操作,但有几个限制很容易踩:CONCURRENTLY不能在事务块里执行;不能在函数或存储过程中直接调用;执行期间需要和表的写入锁短暂互斥,虽然锁范围比普通DDL小,但如果业务刚好有大事务在跑,一样可能互相等待直到锁超时。
处理这种问题,一个实用技巧是设置锁等待超时参数,比如SET lock_timeout = '10s';,让DDL在等不到锁时快速失败,而不是无限期挂起占着连接。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 索引执行后查询无提升 | 索引列基数过低,优化器走全表 | 考虑去掉索引,改用统计分析或报表层处理 |
| SQL里明明有索引却不走 | 隐式类型转换、函数包裹 | 修正SQL写法,保持字段类型和查询值一致 |
| 索引文件超大 | 索引膨胀、autovacuum不够及时 | REINDEX,并检查autovacuum参数 |
| 创建索引阻塞业务 | 普通CREATE INDEX持有写锁 | 改用CREATE INDEX CONCURRENTLY |
| 并发建索引一直等锁 | 锁等待超时配置太长 | 设置lock_timeout并错峰执行 |
| 索引删除后慢查询爆发 | 索引还在被低频SQL使用 | 先观察再删除,使用两个索引短暂并存方式切换 |
6. Docker环境下操作金仓索引的几个注意点
6.1 用容器快速搭一套实验环境
现在很多同学学习或者验证金仓功能,首选是拉一个官方镜像、docker run把数据库跑起来,既不用装虚拟机,也不影响宿主机环境。我也这么干过,几分钟就能得到一个可以随便折腾的库,正好适合拿来练索引操作。
基于容器做练习时,有几个和“索引操作”相关的点要留意。容器默认的存储驱动和IO调度跟物理机不是一回事,dd if=/dev/zero这种简单测试看不出区别,但如果你建索引的数据量一大,或者跑REINDEX,对磁盘IO的敏感度立刻就会暴露出来。我自己在Docker里建一张2000万行的测试表,索引创建耗时比同一台机器上的裸机数据库明显要长;如果把数据目录挂到宿主机SSD上,而不是放在容器默认层,性能会有可感知的提升。
6.2 容器的资源限制会影响索引构建
Docker容器默认是不限制CPU和内存的,但生产调度平台通常会设置--cpus和--memory。如果内存限制得太小,索引创建时排序缓冲区不足,金仓会退到磁盘排序,整个过程会慢得离谱。这种时候,要么调大容器内存,要么给数据库的work_mem参数设一个和容器实际资源匹配的值,别用默认配置硬扛。
另外容器的临时文件目录建议挂载到持久化卷。索引创建或者REINDEX过程中产生的临时文件如果放在容器层,容器重建就全没了,既浪费资源又可能留下隐患。
6.3 把Docker当作索引优化的验证沙箱
索引操作有一个天然特性:建索引、跑EXPLAIN、删索引、再换一种组合重建,这个循环最适合在沙箱环境里快速验证。Docker的价值就在于可以随时销毁、随时重建,不会污染真实环境。
我会顺手把索引操作的关键SQL整理成一个初始化脚本,容器一启动就自动建表、灌数据、建索引,然后接上EXPLAIN (ANALYZE, BUFFERS)去验证不同索引组合的效果。比如同样一条订单查询SQL,我分别在(user_id)、(user_id, status)、(user_id, status, create_time desc)三种索引下跑执行计划,对比cost和实际耗时,很快就能得出最优方案。这种实验做完,再把结论搬到生产库去实施,心里就有底了。
7. 一些只有踩过坑才说得出的经验
最后分享几个我重复踩过、最后才悟出来的细节。
第一个是:建索引之前一定要把SQL的真实参数拿到手,而不是只看模板。很多开发给一条select * from t_order where user_id = ? and status = ?就让建索引,但实际压测时user_id的选择性极低,status又基本全是同一个值,这种索引建了也白建。我当时因为少问了一句“这个user_id分布怎么样”,白白让一张表多扛了两个索引半年多。
第二个是:注意主键字段不要重复建普通索引。主键约束本身就有唯一索引,我在接手的项目里不止一次看到主键上既有一个unique约束生成的索引、又有人为建的单列索引,查完统计视图发现人为建的那个idx_scan长期为0,纯属占空间拖写入。
第三个是:索引操作最好和发布流程绑定。在我现在的团队里,任何加索引的操作都要求附上执行计划和验证结果,建索引就是一个标准的变更单,不能靠“感觉有效”就上生产。上线后第二天统计idx_scan,如果没涨起来就说明这个索引大概率没用到,应该及时回滚删除。这种流程上的事看起来麻烦,实际上省掉的是后面无穷无尽的排查时间。
第四个是关于Docker环境的小提醒:容器实验归实验,但要记住容器里的数据库默认配置和物理机差距挺大,在容器里测出来的索引性能只能做参考,不能直接拿来当生产容量评估依据。真要评估生产环境,还是得拿真实硬件和真实数据量做一轮压测。把Docker当验证语法和逻辑的快车道,把物理机当最终拍板的裁判,两边分开用,既不耽误效率,又不丢准确性。
索引这个东西,说简单就是一条DDL命令,说复杂它牵涉到查询计划、数据分布、写放大、锁机制、统计信息一整条链路。你能在这条链路上看得越清楚,就越不会犯“见了慢查询就加索引”的初级错误。希望这篇基于实际操作的总结,能帮你少走一点弯路。