接手一套基于人大金仓KingbaseES的订单系统时我一开始是有点托大的——毕竟语法和PostgreSQL几乎同源索引操作不就是建索引、删索引、改索引嘛翻文档照着写就行。结果跑了不到两周就被打脸几张几千万行的流水表该走索引的慢查询全在Seq Scan不该建的重叠索引倒是建了一堆业务高峰期CPU被全表扫描拉满接口超时告警一个接一个。后来我才意识到金仓的索引操作看着眼熟实际用起来有一堆自己的脾气和边界。这篇就是我在这类国产数据库上做索引管理时积累的完整经验从索引类型选型、建删改的SQL套路到失效排查、健康度巡检再到从Oracle迁移过来最容易踩的坑一次讲清楚。无论是刚从Oracle切到金仓的DBA还是正在做国产化替代开发的工程师这篇都值得你存一份。1. 先聊一个经常被忽略的判断金仓索引体系到底像谁1.1 金仓与PostgreSQL的血缘关系决定了索引操作的基本盘很多从Oracle切过来的人第一反应是把Oracle那套索引习惯往金仓上套。我见过最典型的例子给一张大表的每个外键列都建反向键索引理由是Oracle里并发插入时反向键能减少索引块竞争。但金仓KingbaseES虽然兼容Oracle模式的SQL语法底层存储引擎和索引实现却更接近PostgreSQL。这直接决定了三件事第一索引物理结构以B-tree为主配合Hash、GIN、GiST、BRIN这些PG系索引类型Oracle里常见的位图索引、反向键索引、索引组织表这类概念在金仓里基本不通用。你建不上去建上去也得不到Oracle里的效果。第二数据表是堆表结构索引叶子节点存的是行指针ctid不是像Oracle那样把整行数据按索引顺序物理排布。所以金仓的索引天然适合高频更新场景但也意味着“索引回表”会带来额外的随机IO——查询能走索引和查询走索引很快中间还隔着一层物理距离。第三优化器的代价模型和PG一脉相承。它会不会选你的索引取决于统计信息、成本估算、work_mem配置这些因素。也就是说索引建得好不好结论不是“建了就快”而是“要建到优化器愿意用、用了真能快”的程度。1.2 索引操作对线上业务的实际影响比想象中大得多索引这个东西平时没人夸它但它一出问题就是大事。在金仓上做索引操作至少要同时盯住三个维度查询性能没错索引的位置直接影响SELECT的速度但这个影响并不总是正向的。比如一张表同时建了(a,b)组合索引和a列单列索引查询条件只有a时优化器可能误选了体积更大的组合索引反而多读了索引页。写入放大每一个索引都是一份独立的写副本。INSERT时每行要写进所有索引UPDATE时涉及索引列的变更要更新对应的索引条目。索引越多写放大越明显主从复制延迟、双机切换后追日志慢很大一部分都是索引数量堆出来的。锁与阻塞大表上CREATE INDEX、DROP INDEX、ALTER INDEX这类DDL操作如果不加并发控制会把整张表锁住业务写入直接堵死。这在OLTP环境里是致命的传到应用层的表现就是“update卡住”“连接池打满”。所以金仓索引操作我建议每一位DBA先建立这样一个认知索引操作不是“写一条SQL跑完就结束”它是一次涉及物理存储、优化器行为、在线并发三个层面的工程决策。后面章节的所有内容都是围绕这三个层面展开的。2. 建索引前先做好选型类型、字段顺序与过滤条件2.1 索引类型选错后面全白搭金仓里最常用的索引类型就那几种我直接给出实际选择时的判断依据而不是把官方文档抄一遍。索引类型典型适用场景实际使用注意事项B-tree等值查询、范围查询、排序、唯一约束、主键默认选择覆盖90%以上场景对前导通配的LIKE查询基本无能为力Hash等值查询只支持等值比较已经走范围就不能用实际用得少B-tree也能应付等值GIN数组类型、JSONB、全文检索、多值类型适合“元素是否包含”类查询小更新频繁会导致pending list膨胀需要配合VACUUMGiST地理位置、范围类型、几何数据空间数据查询首选普通业务字段用不上BRIN超大数据表、字段值与物理顺序强相关如时间戳索引非常小、建得很快但数据乱序插入时效果会很差选型时我基本遵循一条原则普通OLTP业务能B-tree就B-tree不要想着用花哨类型炫技。GIN只在明确遇到数组/JSONB/全文检索需求时才考虑。BRIN则适合那种“订单流水按时间追加、历史数据只读”的日志型大表——这种表用B-tree建时间字段索引会大得离谱换成BRIN能让索引体积缩小几百倍。2.2 组合索引的列顺序是索引设计里最值钱的知识点组合索引设计是团队里最常争论的地方其实核心规则就是两句话等值条件的列放前面范围条件的列放后面。区分度高的列放前面区分度低的列放后面。举个例子。一张订单表最常用的查询是SELECT * FROM t_order WHERE tenant_id 123 AND status 1 AND create_time 2025-01-01 AND create_time 2025-02-01;这是典型的“两个等值 一个范围”组合。组合索引应该建(order_status那个字段等值条件放前create_time范围条件放后)CREATE INDEX idx_tenant_status_time ON t_order(tenant_id, status, create_time);为什么这么排因为组合索引的匹配遵循最左前缀原则。如果你把create_time放最前面那当查询只带tenant_id和status、不带时间范围时索引根本没法用。反之把等值列放前范围列放最后优化器就能先在索引里精确定位到目标行集合再在最后一列做范围过滤扫描量小得多。还有一个容易忽略的细节区分度低的列放前面其实有争议。比如status字段90%的行都是1把它放最前面优化器在估算时很可能认为“用这个索引然后回表还不如全表扫”而直接放弃。我的习惯是优先保证查询条件的组合能被索引最左前缀覆盖再在相同前缀条件下把区分度更高的列前移。如果等值列本身区分度不高可以结合后面的部分索引技巧一起处理。2.3 部分索引和表达式索引懒人设计的高阶工具很多DBA知道索引能建但不知道索引还能“挑着行建”。部分索引就是只对满足条件的行建立索引再配合WHERE条件使用。以用户表为例CREATE INDEX idx_user_active ON t_user(user_id) WHERE status 1;如果业务查询几乎都只关心status1的活跃用户那这个索引就是“穷人版”的优化利器索引体积小、写入放大少、查询命中率高。而全量索引里90%的条目其实都是冷数据白白占空间还拖慢写入。表达式索引解决的是另一类问题查询条件里对字段做了函数处理普通索引无法命中。比如登录账号查询经常写成SELECT * FROM t_account WHERE lower(account_name) alex这里account_name被lower()包住普通索引用不上。解决办法就是建表达式索引CREATE INDEX idx_account_lower ON t_account(lower(account_name));建完之后优化器看到同样的表达式就能命中索引了。注意一个坑表达式索引要求SQL里的表达式写法与索引定义严格一致连空格和括号位置都不能差。所以团队里最好统一SQL中表达式的写法否则这个索引可能白建。3. 日常索引操作的完整套路创建、改名、删除与在线重建3.1 创建索引的标准SQL与关键参数金仓里创建索引的基础语法和PG保持高度一致我列出常用的几种写法每一行都是实际能直接用的-- 普通B-tree索引 CREATE INDEX idx_order_create_time ON t_order(create_time); -- 唯一索引 CREATE UNIQUE INDEX uk_order_no ON t_order(order_no); -- 组合索引 CREATE INDEX idx_tenant_status_time ON t_order(tenant_id, status, create_time); -- 部分索引 CREATE INDEX idx_order_active ON t_order(order_id) WHERE status 1; -- GIN索引用于数组或JSONB类型 CREATE INDEX idx_biz_tags ON t_biz(tags) USING gin;创建时还可以指定几个影响使用体验的参数fillfactor叶子页填充率默认一般是90。对于更新频繁的表可以调低到70左右减少索引页分裂和碎片。tablespace索引可以放到单独表空间。很多团队把大索引放到独立磁盘说白了就是隔离IO但我实测下来收益有限除非你明确知道索引读是瓶颈。storage参数比如enable_fastupdateGIN索引的快速更新小事务多时可以开启但代价是pending list可能膨胀。我一般保持默认。3.2 在线重建索引的两套方案以及失败后的收尾大表上重建索引最怕的就是把业务写锁住。金仓按惯例支持CONCURRENTLY模式的索引操作也就是在线创建/在线重建。实际操作里我总结了两套方案方案一先在线建新索引再删旧索引CREATE INDEX CONCURRENTLY idx_order_time_new ON t_order(create_time); DROP INDEX CONCURRENTLY idx_order_time_old;方案二直接在线重建REINDEX INDEX CONCURRENTLY idx_order_time;两种方案都能避免长时间锁表但有个区别方案一会在旧索引删除前短时间存在两个索引磁盘占用和写入放大是双份的方案二更干净但REINDEX CONCURRENTLY在某些版本上支持的DDL范围有限我遇到过不能对个别系统索引执行的情况。这个方案还有个坑必须提醒CONCURRENTLY创建索引如果中途失败会留下一个invalid的索引。这个索引不仅不会提升查询性能还会在每次DML时跟着更新白白消耗资源。所以在线建索引失败后要立刻检查并清理SELECT indexrelid, indrelid, indisvalid FROM pg_index WHERE indrelid t_order::regclass;如果查到indisvalid为false的索引直接DROP INDEX CONCURRENTLY idx_order_time_new;我在生产环境里不止一次看到“无效索引残留在库里跑了一周”的案例最后都是靠这个查询抓出来的。所以每次在线建索引之后都要养成检查有效性这个习惯。3.3 改名和删除索引这这类琐碎操作其实有不少隐性成本改名索引这个操作看起来简单实际很容易触发连锁反应。先看语法ALTER INDEX idx_order_time_old RENAME TO idx_order_time;改名不影响索引数据也不影响查询计划但有三件事容易被忽视第一应用侧如果通过SQL写死了索引名比如用WITH (options)强制指定索引改名会直接导致SQL报错或计划失效上线前一定要在代码仓库里全局搜索引名。第二唯一约束对应的索引名往往被外键约束或依赖视图引用。如果贸然改这个索引名可能触发约束重建导致不必要的锁表。第三删除索引前要确认它是不是某个约束的默认索引。很多人习惯把数据表的唯一约束直接当成普通索引删一删就报“constraint does not exist”或者把约束也带崩溃了。正确做法是先查约束再动手SELECT conname, contype, conindid FROM pg_constraint WHERE conrelid t_order::regclass;删除索引本身也有并发版本DROP INDEX CONCURRENTLY可以避免锁表。但注意它不能放在事务块里执行而且会等待所有引用该索引的事务结束耗时可能比想象中长。我对生产库删除大索引的建议是确认无人使用、低峰期执行、保留一个新建同名索引的应急预案一旦业务报错立刻重建。4. 索引失效全记录几个真实原因与一次完整排查链路4.1 函数包裹和隐式类型转换是最常见的“索引读了不用”索引失效这件事不是索引不存在而是优化器算了半天觉得“走索引不划算”或者“根本匹配不上索引路径”。函数包裹是最典型的情况。前面提到lower(account_name)的例子这里再补充一个生产里频繁出现的问题SELECT * FROM t_payment WHERE date(pay_time) 2025-06-01;查询条件写成date(pay_time) 某天这会让pay_time列的索引失效因为优化器必须在每一行上先计算date()函数再比较没法直接使用B-tree索引上的有序性。要改就改范围写法SELECT * FROM t_payment WHERE pay_time 2025-06-01 00:00:00 AND pay_time 2025-06-02 00:00:00;这两个写法语义几乎一样但性能天差地别。隐式类型转换是另一种常见的失效原因。字段是varchar类型查询参数是数字类型时数据库会尝试把列值做隐式转换转换之后索引就失效了。比如SELECT * FROM t_customer WHERE id_card 123456789012345678;id_card是varchar字段等值比较时右边用了数字优化器会把列转换为数字再比较索引对转换后的值无能为力。改法很简单参数加引号SELECT * FROM t_customer WHERE id_card 123456789012345678;这类问题在应用代码里非常隐蔽因为Java或者其他语言层很多时候传参类型是自动推断的。排查时直接把SQL打印出来肉眼扫一遍条件列两侧的类型往往就能发现。4.2 统计信息过期与数据倾斜会让优化器“放弃”索引还有一种失效让人最头疼索引确实存在、查询条件也没有函数包裹和类型问题但优化器就是不走索引而是全表扫描。这种多半是统计信息或数据分布的问题。金仓的优化器依赖表的统计信息来决定执行计划。如果一张表在批量导入了大量数据之后没有及时做ANALYZE统计信息里记录的行数可能还是旧值优化器按旧数据估算全表扫描代价可能误以为全表扫更快。我处理过一张流水表明明时间索引就在那里但explain出来就是Seq Scan跑一次ANALYZE以后立刻换成了Index Scan速度从十几秒降到几十毫秒。数据倾斜是另一个层面。拿状态字段举例一张表有1000万行其中status1有990万行status0有10万行。查询status0的订单理论上应该走索引但如果优化器的统计信息认为status分布均匀它可能会觉得走索引要回表10万次太贵直接选全表。这种情况下部分索引就是更优的解法CREATE INDEX idx_order_zero ON t_order(order_id) WHERE status 0;把索引只建在分布稀少的那部分数据上索引体积小、回表次数可控优化器也更愿意选它。4.3 一次典型的慢查询定位复盘从Seq Scan到Index Scan下面这个例子是我在某个报表库上真实处理过的场景步骤完全可复现。问题现象一张每月新增千万行的日志表t_biz_log报表任务统计某业务线的日维数据SQL长这样SELECT biz_type, count(*), sum(amount) FROM t_biz_log WHERE biz_type PAY AND log_time 2025-06-01 00:00:00 AND log_time 2025-06-02 00:00:00 GROUP BY biz_type;这条SQL跑一次要40多秒而日志表上的log_time和biz_type都有单列索引。第一反应是死马当活马医先看执行计划Seq Scan on t_biz_log (cost0.00..198234.45 rows127 width48) Filter: ((log_time ...) AND (log_time ...) AND (biz_type PAY::text))计划显示全表扫描但索引明明存在。接下来按顺序排查第一步确认索引定义。查pg_indexes发现log_time和biz_type各有一个独立索引没有组合索引。SQL里是两个条件的AND关系独立索引的Bitmap合并是有可能发生的但单靠它们合并的开销可能比全表扫还大所以优化器选择放弃。第二步检查统计信息。用SELECT reltuples, relpages FROM pg_class WHERE relnamet_biz_log发现reltuples跟实际行数差了五六倍。说明批量导入后没有collect统计信息。第三步手动收集统计信息VACUUM ANALYZE t_biz_log;第四步再次查看执行计划发现还是Seq Scan但cost已经略有变化。问题还剩在“两个单列索引合并”这件事上。最后我决定直接建组合索引CREATE INDEX CONCURRENTLY idx_biz_log_type_time ON t_biz_log(biz_type, log_time);再跑一次查询执行计划变成Bitmap Heap Scan on t_biz_log Recheck Cond: ... - Bitmap Index Scan on idx_biz_log_type_time查询耗时从40多秒降到2秒以内。这个案例给我的教训是索引失效的排查不是“有没有索引”这一件事而是要把索引定义、统计信息、查询写法、组合顺序四个环节全部扫一遍。其中最容易被忽略的就是统计信息很多看着像“数据库不智能”的问题其实只是统计信息过期了。4.4 模糊查询与排序字段索引也有天然盲区LIKE查询里abc%这种前匹配能走到B-tree索引的范围扫描但%abc这种后匹配就完全没法用。金仓里处理这类需求我推荐使用pg_trgm扩展加GIN索引CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_name_trgm ON t_customer(name) USING gin (name gin_trgm_ops);建完之后前后模糊查询都能命中索引。不过这个索引对中文分词的支持一般中文模糊查询的效果要看版本和内置词典应用前最好拿真实数据测算。还有一个容易忽略的点ORDER BY字段虽然有索引但排序方向搞反也会失效。组合索引(biz_type, log_time DESC)和(biz_type ASC, log_time DESC)是两种不同的索引定义。如果查询经常ORDER BY log_time DESC建议在建索引时就把排序方向定义清楚避免优化器为了排序而额外做一次sort操作。5. 索引健康度巡检找出吃干饭的索引、冗余索引和膨胀索引5.1 一张SQL查出来哪些索引从来没被业务用过索引不是一劳永逸的。很多上线多年的系统里有些索引从创建那天起就没人命中过纯粹占着磁盘、拖慢写入。在金仓里可以通过统计视图pg_stat_user_indexes来查SELECT schemaname, relname AS table_name, indexrelname AS index_name, idx_scan AS index_scan_count, idx_tup_read, idx_tup_fetch FROM pg_stat_user_indexes WHERE schemaname public ORDER BY idx_scan ASC;idx_scan是索引被扫描的次数。数值长期为0或极低的索引基本可以标记为“嫌疑对象”。注意不要只看累计值因为有些索引只是近期业务没走到可能是好几个月才用一次。我会把时间窗口拉长观察至少一个完整业务周期。5.2 重复索引和超长索引怎么一眼判断重复索引的判断思路很简单如果两列组合的前缀一样比如(a,b)和(a)同时存在那么(a)是很可能多余的。当然还要结合具体查询因为(a,b)并不能在所有情况下替代(a)的最左前缀匹配多数情况下是可以的但不绝对。更稳妥的判断方法是从pg_indexes里把索引定义全部拉出来然后人工对比一遍。超长索引则是另一种问题。如果索引字段是一个超长的varchar比如某列的取值是一个很长的描述文本直接对该列建B-tree索引索引页会膨胀得非常厉害每次IO都要读好几页。解决方式是改用hash表达式索引CREATE INDEX idx_long_text_hash ON t_abc (hash(long_text));等值查询时用where hash(long_text)hash(某个值)先哈希碰撞再回表过滤索引体积能缩小几十倍。这种设计牺牲了一点精确性hash可能碰撞但换来的是体积和速度的极大改善适合长文本精确匹配场景。5.3 索引膨胀的判断与重建时机索引膨胀主要来自行版本的更新与删除。PG系的MVCC机制下UPDATE一行时旧版本不会立即消失索引里还有对应的旧指针直到VACUUM真正回收。所以一张频繁更新的大表索引会比实际数据大很多查询时需要扫描的索引页也更多。判断膨胀我一般直接用索引体积和数据体积的比值。金仓里可以这样查SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, pg_size_pretty(pg_relation_size(indrelid)) AS table_size, pg_relation_size(indexrelid)::float / nullif(pg_relation_size(indrelid), 0) AS ratio FROM pg_index JOIN pg_class ON pg_class.oid pg_index.indexrelid ORDER BY ratio DESC;正常情况下主键索引加数据索引的总和应该远小于表体积。如果某张写频繁的表的索引体积已经接近甚至超过表体积就该考虑重建了。重建时机选在业务低峰优先用前面说的在线重建方案避免锁表。统计信息维护这件事同样落在巡检节奏里。我的习惯是批量数据导入超过表总量20%之后立刻VACUUM ANALYZE每周跑一次全库的ANALYZE任务避免优化器用过期统计信息决定执行计划。很多“索引明明存在但优化器不走”的疑难杂症就是这么稀里糊涂被治好的。6. 金仓索引维护我给后来者的几条经验做完一整套索引操作梳理最后分享几条我在实际项目里积累的个人经验全是文档里不太会写的细碎细节。第一从Oracle迁到金仓的团队最需要做的不是学SQL语法而是把Oracle的索引思维掰过来。反向键索引、位图索引、索引组织表这些概念先放一边用B-tree、GIN、BRIN这套PG系的索引模型重新理解数据访问。我见过不少迁移项目代码移植很顺利但性能一上线就崩根子就在“用Oracle的方式管理PG系索引”。第二所有索引操作上线前先在测试环境用explain analyze跑一遍真实数据集的查询计划。你手动筛选出来的“最优索引”放到优化器眼里可能完全是另一回事。金仓的优化器和Oracle的差别不小同一个SQL两个库给出的计划可能南辕北辙。所以测试环境必须保持和生产相近的数据量级否则统计信息不同计划也参考不了。第三保留一份详尽的索引台账。哪张表建了哪些索引、为什么建、对应的业务SQL是什么、索引有效性如何都要记录清楚。我在项目里见过最混乱的情况是索引几十个没人知道用途每次性能出问题就凭着印象瞎加最后索引比表还大写入全被拖垮。好的索引台账能让新接手的人五分钟内搞清楚系统的索引全貌。第四对索引有效性保持持续监控。无效索引、未被使用的索引、重复索引这些都是数据库里的“隐形负债”。没有监控它们不会主动暴露只会持续占用磁盘、消耗写入性能直到某次大促把性能压垮才被发现。我现在每周末固定跑一遍巡检SQL把“吃了干饭没干活”的索引和“膨胀比例异常”的索引单独拉出来评估该删的删该重建的重建。金仓索引操作说到底就三板斧建对索引、维护顺序、监控健康。把这三点刻在脑子里再遇到慢查询时你就不会慌。至少我现在看到一条走Seq Scan的SQL第一时间想起的不再是“加个索引试试”而是先问自己一句这个索引到底应不应该存在