ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GBase索引失效排查实战:从执行计划到SQL优化的完整路径

GBase索引失效排查实战:从执行计划到SQL优化的完整路径

“凌晨两点半,业务方甩了条SQL过来:昨天还是50毫秒的查询,今天突然跑了45秒还没出结果。我登上去一看,索引没删、表结构没改、数据量也没暴增,怎么就走全表扫描了呢?”

做GBase数据库运维的,几乎都撞见过这种场景。索引明明摆在那儿,执行计划就是不用,或者用了也跟没用一样。这事儿的专业说法叫“索引失效”,本质是优化器在成本和代价的计算中,认为走索引还不如全表扫一遍划算。它不是你肉眼能看见的那种“坏”,更像是数据库内部的“决策失误”。

这篇内容,我准备把这几年在GBase生产环境里排查索引失效的经验梳理一遍:先讲清楚失效到底是怎么回事,再逐个拆解高频失效场景和规避手段,最后给一个真实案例和一套排查速查表。不管你是刚接手GBase运维的新手,还是被慢SQL折磨过几次的老人,照着这套思路去定位,大部分索引失效问题都能在半小时内找到方向。

1. 索引失效的本质:先搞懂优化器为什么变“笨”

1.1 一个被误解的常识:索引不是越密越快

很多人一遇到SQL慢,第一反应就是“加索引”。这个思路不能说错,但不完整。GBase的优化器是基于代价的,它会为一条SQL生成多种候选执行计划,再给每个计划估算一个成本值,最后选成本最低的那个。

把优化器理解成一个精打细算的会计就行:走索引要经历索引扫描、回表取数据、可能还有排序合并这些操作,每一步都有成本。全表扫描虽然看着粗暴,但它是顺序读,数据页连续时效率并不差。如果优化器估算出走索引需要回表几千次,而全表扫描一次读下来只需要更少的I/O,它会毫不犹豫地放弃索引。

所以“索引失效”这个词,严格说是不够准确的。索引没有失效,只是优化器在特定条件下“不选它了”。我们做运维要解决的,不是去逼优化器用索引,而是搞清楚是什么因素让它的成本估算跑偏了,把那些干扰项清掉,让优化器看到“真实的账本”。

这就有意思了:多数索引失效,不是数据库坏了,而是优化器拿到了一份错误的信息,基于错误信息做出了“正确”的错误决策。

1.2 统计信息与基数估算:优化器的“算盘”从哪来

优化器估算成本,主要靠两样东西:统计信息和基数。统计信息描述表的行数、列的唯一值数量、数据分布情况、NULL值占比、索引的聚合度等。基数估算则是在统计信息基础上,预估某条过滤条件能筛出多少行。

我在生产上排查过一个典型的例子。某张订单表,销售状态字段有4个值,正常分布很均匀。但因为一次批量更新任务跑了很久,大量订单被更新成“已完成”,导致这个值的数据占比突然超过了85%。优化器手上的统计信息还是旧的,它以为过滤“已完成”只会筛出25%的数据,走索引很划算。等到实际执行时,85%的行都命中这个条件,回表代价飙到天上去,一条原本秒回的查询变成分钟级。

这不是索引失效,而是统计信息失效。优化器被过时的数据误导了。

另一类常见情况是“统计信息没坏,但直方图缺失”。GBase和大多数关系型数据库一样,如果没有对倾斜列收集直方图统计信息,优化器对非均匀分布的数据只能用统一估算法去猜。猜出来的基数和实际差个几十倍是很正常的。基数算错,成本就跟着错,最后索引被弃用也就不奇怪了。

所以在聊任何规避策略之前,我的忠告是:先看统计信息新不新,再看SQL写法有没有坑,最后才谈索引结构设计。这个排查顺序反了,前面全是白忙。

2. 高发场景盘点:运维手边最常见的八种失效

2.1 类型不匹配如何“杀死”一个索引

隐式类型转换是GBase生产环境里最常见的索引失效原因,没有之一。

我见过太多这样的例子:表里的手机号字段定义成了varchar,程序里习惯性传一个数字进来。GBase的SQL引擎在处理这种比较操作时,会把字段值转成数字再比,等于在索引列上悄悄套了一层类型转换函数。

这层转换,直接让索引列变成了“表达式”,优化器无法再直接使用它进行定位。道理很简单:索引里存储的是原始字符值,你让它去匹配一个经过转换后的数字值,没法走B树的有序查找,只能把索引树整个扫一遍再算。结果就是索引扫描退化成索引全扫描,甚至直接跳回全表扫描。

规避策略不复杂:把字段类型和传入参数类型统一。要么改表定义,把手机号这类固定长度的字符数据改成数值类型;要么在应用层就把参数转成字符串再绑定。这两条路,优先改应用参数,因为改表定义涉及范围大,容易引发其他SQL兼容问题。

有个自查技巧:GBase的SQL日志里如果出现类似“converting varchar to numeric”的告警,基本就是在告诉你这里发生了隐式转换。把这类日志当成重点筛查对象,能提前发现很多还没爆发的性能隐患。

2.2 函数运算、模糊前导和联合索引顺序问题

这三个场景我放到一块儿说,因为它们的本质都是索引的有序性被破坏了。

索引列参与函数运算,是很隐蔽的失效。比如你在WHERE条件里写了WHERE create_date = trunc(sysdate),如果create_date本身是索引列,trunc函数包裹之后,索引就无法直接定位了。正确做法是把计算放到条件的常量一侧,改成区间写法:WHERE create_date >= trunc(sysdate) AND create_date < trunc(sysdate) + 1。这样索引列还是原样,优化器才能用它。

模糊查询里的前导通配符更典型。LIKE '%关键字%'这种写法,B树索引是按前缀有序排列的,你让它查找“某个字符串出现在任意位置”,这跟顺序查找有什么区别?它只能把索引全部扫描一遍。真遇到这种需求,建议考虑全文索引方案,或者把搜索条件明确成前缀查询LIKE '关键字%'。

联合索引顺序被跳过的场景,需要多啰嗦几句。GBase的联合索引遵循最左前缀原则:索引(store_id, order_date, order_status)只能支撑以store_id开头、或store_id加order_date组合的查询条件。如果SQL里只写了WHERE order_date = ?,这个三列联合索引就从中间位置断掉了,优化器没法用它。

我在生产上还见过一种更容易踩的版本:SQL里包含了store_id和order_status,但跳过了中间的order_date。这种情况联合索引能用,但只能用到第一列,后续的order_status过滤是在索引扫描结果上做的,性能打了折扣。排查这类问题时,直接用执行计划看“访问谓词”和“过滤谓词”两栏,访问谓词里没出现的关键列,说明它在联合索引里没被充分利用。

2.3 优化器“正确”地选择了全表扫描

还有一类“失效”,是优化器算对了账,但我们运维心里那道坎过不去。数据分布极度倾斜或过滤条件选择性太差时,走索引本来就是亏的。

比如一张表有1亿行,某个状态列只有两个值,各占一半。你查WHERE status = 'active',走索引需要回表5000万次,这个代价远高于全表扫描。优化器选全表扫描,不是因为索引坏了,而是因为这笔账它算得太明白了。

但这类情况里也有可以优化的空间。如果你的业务确实经常按“状态+时间”过滤,可以在状态列上加上时间条件,形成联合索引,提升选择性;或者对表做分区,按时间维度把数据物理分隔开,让查询只需要扫描一个分区。说白了,面对“优化器正确地选了全表扫”的场景,硬逼它用单列索引没有意义,要从数据模型层面调整查询的访问路径。

比这更隐蔽的还有绑定变量导致的执行计划固化。GBase开启绑定变量后,第一次硬解析生成的执行计划会被后续会话复用。如果第一次解析时传入的参数值刚好命中一个高基数值域,优化器会认为这个查询选择性很好,生成了索引扫描计划。等后来绑定变量的值变成低基数的高频值时,复用的执行计划还傻乎乎地走索引,结果大量回表反而慢过全表扫描。

针对这类情况,我建议把统计信息更新做成周期任务,重点监控高倾斜列的直方图。如果发现某条SQL的执行计划长期固定,但数据分布已经明显变化,就需要主动刷新统计信息并清掉旧的执行计划缓存,让优化器重新做一次硬解析。

3. 实战实录:一次大SQL查询的索引重构全过程

3.1 故障现场:SQL为什么突然慢了上百倍

之前接手一个系统,某天下午突然收到监控告警,数据库CPU瞬时飙升到85%以上,持续了大约10分钟。查看慢SQL日志,锁定到一条订单查询语句:

SELECT order_id, store_id, order_amount, order_status FROM t_order WHERE store_id = 2001 AND order_date >= '2024-05-01 00:00:00' AND order_date < '2024-05-08 00:00:00' ORDER BY order_date DESC;

这条SQL优化前的执行时间是38秒,返回到第1行数据也要3秒多。而监控显示,一周前同样的条件返回只需要30毫秒左右。

按照1.2节里的排查顺序,我先查了表的基本情况:数据总量约4200万行,store_id这个字段的定义是varchar类型,但程序传入的是数字2001;表上有一个联合索引idx_store_date(store_id, order_date),还有一个单列索引idx_order_date(order_date)。

看到这里,第一个嫌疑已经锁定:store_id的varchar字段和数字参数发生了隐式类型转换。联合索引idx_store_date的第一列因为类型转换无法正常使用,整个索引等于废了。

3.2 定位手段:执行计划里的三个关键行

GBase查看执行计划,我习惯先用EXPLAIN配合真实参数值跑一遍:

EXPLAIN SELECT order_id, store_id, order_amount, order_status FROM t_order WHERE store_id = 2001 AND order_date >= '2024-05-01 00:00:00' AND order_date < '2024-05-08 00:00:00' ORDER BY order_date DESC;

执行计划里最关键的三个信息点:一是访问路径,显示的是“INDEX SCAN”还是“SEQ SCAN”还是“INDEX SKIP SCAN”;二是估算行数,对比实际返回行数,偏差过大就说明基数估算有问题;三是访问谓词和过滤谓词的分布,能直观地看出哪些列真正参与到了索引定位里。

这个案例里,执行计划显示idx_order_date被选用了,但访问路径是索引全扫描加回表,估算过滤后还有180万行。而order_date这个条件其实挺有选择性的——只查一周的数据,按经验估算大概在几万行以内。为什么会偏差这么大?

因为优化器发现store_id条件没法用索引,只能退而求其次评估order_date单列索引。但这个单列索引在4200万行的表上,先扫描出全部匹配order_date区间的数据,再回表逐行过滤store_id,估算下来回表代价超过了全表扫描成本。于是它又想把store_id的隐式转换考虑进去,算出来全表扫描的成本反而是最低的。

所以执行计划最终停留在全表扫描上。索引没有彻底失效,但两条路都被堵死了一条,剩下一条的代价又太高,最后形成了“索引明明存在却不用”的尴尬局面。

3.3 优化方案:从SQL改写、索引调整到HINT兜底

问题定位清楚后,我按了三步走。

第一步,验证隐式转换的影响。我临时把SQL改成了字符串参数再执行:

SELECT order_id, store_id, order_amount, order_status FROM t_order WHERE store_id = '2001' AND order_date >= '2024-05-01 00:00:00' AND order_date < '2024-05-08 00:00:00' ORDER BY order_date DESC;

这条SQL执行时间一下子回到80毫秒以内,执行计划也变成了联合索引idx_store_date的正确使用。到这里,根因已经确认,不是索引结构问题,是SQL写法问题。

第二步,确定改动成本最小的方案。最彻底的修复是把store_id字段类型从varchar改成数值类型,但这张表有几百个引用方,改动影响面太大,排期至少要两周。我选择先在应用层把参数统一改成字符串绑定,这个改动只涉及一个查询接口的参数类型转换逻辑。

与此同时,我在app接口层加了一条兜底策略:如果SQL在传入数值参数的情况下被拼装执行,应用层主动添加注释提示,引导优化器走正确的联合索引。这一步用GBase的hint实现:

SELECT /*+ INDEX(t_order idx_store_date) */ order_id, store_id, order_amount, order_status FROM t_order WHERE store_id = 2001 AND order_date >= '2024-05-01 00:00:00' AND order_date < '2024-05-08 00:00:00' ORDER BY order_date DESC;

需要说明的是,hint是后手方案,不是首选。它的问题在于把执行计划写死了,一旦数据分布或索引结构变化,这条SQL可能从“用了对的索引”变成“用了错的索引”,反而更难调优。

第三步,更新统计信息并重建必要索引。我执行了GBase的统计信息收集命令,刷新了t_order的统计信息和直方图。顺带检查了一下idx_store_date的索引聚合度,发现因为t_order频繁更新,联合索引的叶子块顺序已经开始混乱,于是做了一次在线重建索引。

3.4 实测数据:优化前后的代价可见地变化

优化做完,我把全量执行对比做成了表格,方便后续复盘和汇报:

场景执行计划返回首行耗时全量返回耗时全表扫描/回表行数估算
原始SQL(数值参数)全表扫描3.2秒38秒扫描4200万行
仅SQL侧改字符串参数联合索引正确命中18毫秒78毫秒索引定位行数约6.3万
加hint兜底(数值参数)强制联合索引35毫秒260毫秒回表行数约6.3万

这里有个值得注意的点:hint兜底虽然比原始SQL快很多,但比改字符串参数慢了不少,原因是回表行数比预想多了一些。这再次说明,hint只是止血用,真正解决问题还是要靠消除隐式转换。

后面我又把这套优化方案推广到了其他几张存在varchar字段和数值参数混用的表里。其实这类问题的排查模式是完全可复制的:先怀疑隐式转换,再验证执行计划,最后决定是改应用还是改表结构。

4. 避坑技巧与排查速查表

4.1 排查索引失效的标准工具箱

做GBase索引失效排查,我平时离不开这几样工具:

慢SQL日志是第一个要看的。重点看执行时间、扫描行数和返回行数三项。扫描行数比返回行数高出几个数量级,说明过滤没有下推到位,索引大概率没用上或没完全用上。

执行计划是所有排查的核心。GBase里支持多种方式的执行计划输出,我用得最多的是explain加真实参数值,因为能还原最接近实际执行的情况。关键看访问方式、访问谓词、过滤谓词、估算行数、实际处理行数这几行。

统计信息视图是判断统计是否滞后的依据。查看表的最后分析时间和采样规模,如果最后分析日期很早,或者采样比例过低,优先刷新统计信息再讨论索引。我在生产上的经验是,很多所谓的“索引失效”其实只要刷新一次统计信息就能解决。

最后是索引管理视图。检查索引的状态、可见性、碎片率和聚合度。索引碎片超过一定比例后,它的有序性和I/O效率都会下降,可能导致优化器重新评估路径。

4.2 常见故障速查:给新运维的对照表

我汇总了一张排查对照表,新接手GBase运维的同事照着这个表去查,能省下不少时间:

故障特征最常见的根因首要排查方向常用规避手段
条件列是varchar,传入数值隐式类型转换SQL日志中的类型转换告警参数改字符串;或改表字段类型
查询使用了日期函数包裹索引列函数运算破坏索引有序性执行计划访问谓词改为区间条件
LIKE '%关键词%'前导通配符不可利用B树索引确认SQL写法改前缀查询;或用全文索引
联合索引中间列缺失违反最左前缀原则访问谓词内容调整索引顺序或补索引
数据量翻倍后SQL突然变慢统计信息滞后统计信息最后分析时间执行统计信息更新
某条件选择性强但占比大倾斜分布导致的基数误判直方图是否有该列更新直方图;或分区裁剪
同一条SQL时快时慢绑定变量窥探与计划固化执行计划缓存清缓存重新硬解析;或分离高低频参数
索引重建后还是慢索引设计本身不合理索引聚合度与选择性重新设计联合索引顺序

这张表的逻辑,本质上就是把“失效原因”和“排查入口”一一对应起来。新运维可以先从故障特征反查根因,再按对应手段去操作,比翻优化手册快得多。

4.3 三个容易忽略的“隐形杀手”

除了上面这些被人讲烂的场景,我在实际运维中还踩过几个不容易被发现的坑,单独列出来。

第一个是NULL值的处理。在GBase里,如果WHERE条件写成column != '某值',而该列存在大量NULL值,优化器在做过滤时会额外处理NULL行的判断,影响索引的选择性。如果业务上允许,建议给这类列加上NOT NULL约束,避免NULL值蚕食索引树的统计收益。

第二个是排序字段和索引方向不一致。联合索引的列顺序如果和ORDER BY子句相反,优化器可能需要额外的排序操作,成本一高索引就被弃用了。GBase索引默认升序排列,如果业务经常按时间倒序查,设计联合索引时就得考虑是否用降序索引来匹配排序方式。

第三个是复合条件里的OR。WHERE store_id = '2001' AND (order_status = '1' OR pay_status = '1')这种写法,优化器做条件分解时会撕裂访问路径,导致原本可以走联合索引的计划被替换成两个单列索引合并甚至全表扫描。规避方式是把OR拆成两个SQL用UNION ALL合并,或者整理成IN条件的形式,让优化器更容易生成索引合并计划。

这类问题不好排查,因为执行计划不会直接告诉你“OR条件导致失效”,需要人工结合SQL原文去分析。我的习惯是,只要发现复杂逻辑条件里带了OR,先手工重写一遍测执行计划,能走通再让开发侧改。

5. 更长期的做法:统计信息与索引资产的主动管理

5.1 统计信息收集:别用默认值糊弄生产

很多团队只有在SQL已经慢了才想起收集统计信息,这种“救火式”更新非常被动。因为统计信息更新本身也有代价,在业务高峰执行大规模统计会造成额外的I/O和锁竞争。

我的建议是把统计信息收集做成常规运维任务,根据数据表的变更频率制定不同的收集策略。高变更频率的大表,比如交易流水表,每天至少更新一次统计信息,采样范围可以放宽到全量采样——虽然耗时多,但换取的是执行计划的长期稳定。中低频变更表,一周或者半个月更新一次就行。归档表则可以在索引维护窗口顺带处理。

还有一个容易忽略的点,是统计信息不能只做“全表级别”的更新。对于有分区的大表,统计信息按分区粒度去收集更精细,能让优化器更准确地估算具体分区的数据分布,实现分区裁剪时选对执行路径。

5.2 索引的生命周期:从创建到下线

索引不是建完就一劳永逸。我倾向于把索引当成需要持续维护的“资产”,而不是一次性的“配置”。

生产环境里最常见的问题是无用索引堆积。很多表上叠了五六个索引,实际业务SQL只用到了其中一两个。多余的索引不仅浪费存储,还会拖慢DML性能,因为每次插入更新都要同步维护全部索引。我维护的系统里出现过一次更新批量任务变慢,排查后发现就是一张表上多了三个几乎不会被查询使用的冗余索引。

建议用GBase的索引使用统计视图做监控,定期查看哪些索引长时间没有被访问。一个索引在连续30天以上没有任何读操作,基本可以判定为冗余索引。下线前先保留一段时间观察业务是否有报错,确认无影响后再彻底删除。

索引碎片处理同样重要。频繁增删改的表,叶子节点会逐渐无序,逻辑顺序和物理顺序的偏差会直接推高扫描成本。我在大表上按月做在线重建,小表则随统计信息任务顺带执行。重建之后配合统计信息更新,效果是肉眼可见的。

5.3 与开发侧联动的SQL规范:把问题拦在发布前

最后的长期策略,说起来其实和运维关系不大,但影响最大:把索引失效的规避经验转化成一套SQL规范,在开发阶段就拦住。

我整理过一份轻量级的SQL开发检查清单,在代码评审阶段用,效果很好。清单不长,核心就几条:

  • 条件列和参数类型必须一致,绝不允许隐式类型转换。
  • 索引列不能出现函数、运算或类型转换包裹。
  • LIKE查询禁止使用前导通配符。
  • 联合索引的使用,必须满足最左前缀要求,且条件中跳过联合索引中间列时要重新设计索引。
  • 排序字段和索引顺序保持一致。
  • OR条件要明确评估是否能拆分或改写。
  • 新增查询必须用explain验证执行计划,确认访问行数和估算行数在可接受范围内。

这套规范真正落地,不靠文档,靠工具。我在测试环境加了一个定时巡检脚本,每晚自动扫描当天新增或变更的SQL,检查有没有违反上述规则的写法,自动生成风险报告发给开发侧。这样一来,索引失效问题基本在开发和测试阶段就被消化掉了,生产环境里冒出来的慢SQL数量直线下降。

在实际运维过程中,我最大的体会是:索引失效不是某一条命令能解决的孤立事件,它牵扯出的是SQL写法、统计信息、索引设计三者的平衡。你越懂优化器怎么“算账”,越能在问题发生前预判它的行为。

最后再分享一个延续思路。GBase的索引优化空间远不止这篇文章覆盖的内容,如果你已经能熟练处理这些高频失效场景,可以继续深入研究分区索引、覆盖索引和索引合并策略。把这三块吃透,你在遇到复杂查询时会有更多可用的“武器”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进