PolarDB 和 MySQL 同源,为什么同一条 SQL 仍会“索引失效”

最近遇到一个看起来很反常的问题。

同一张表、同一个索引、同一条 SQL,在 MySQL 中执行时能够正常走索引,迁移到 PolarDB MySQL 版之后,却变成了全表扫描。

第一反应很容易是:PolarDB 不是兼容 MySQL 吗,为什么连索引行为都不一样?

但继续排查后会发现,所谓“索引失效”通常不是索引突然不能用了,也不是两套数据库对 B+Tree 有两种完全相反的理解。更多时候,是两边的优化器面对不同的数据分布、统计信息、参数和执行成本,分别选择了自己认为更便宜的执行计划。

换句话说:

索引存在且可以使用
        ≠
优化器最终一定选择索引

PolarDB MySQL 版与 MySQL 同源、兼容 MySQL 协议和大量语法,但“兼容”并不等于在任何版本、参数、数据状态下都生成完全相同的执行计划。

本文从一次典型的索引差异出发,拆解为什么相同 SQL 在 MySQL 和 PolarDB 中可能走出不同的路。

1. 先看一个典型现象

假设订单表上有一个联合索引:

CREATE INDEX idx_status_create_time
ON user_order(status, create_time);

查询最近一段时间内状态为已完成的订单:

SELECT id, user_id, amount
FROM user_order
WHERE status = 2
  AND create_time >= '2026-07-01 00:00:00';

在 MySQL 中查看执行计划:

EXPLAIN
SELECT id, user_id, amount
FROM user_order
WHERE status = 2
  AND create_time >= '2026-07-01 00:00:00';

可能得到:

type: range
key: idx_status_create_time
rows: 12000

而在 PolarDB 中,同一条 SQL 可能得到:

type: ALL
key: NULL
rows: 1800000

从结果上看,PolarDB 没有走索引。很多人会直接把它称为“索引失效”。

但这里至少存在两种完全不同的情况:

  • SQL 的写法或字段类型导致索引无法参与定位。
  • 索引可以参与定位,但优化器认为全表扫描成本更低。

如果不先区分这两种情况,后续很容易在错误方向上修改索引和 SQL。

2. “不能使用”与“不愿使用”是两回事

2.1 索引在语义上无法有效使用

这类问题通常和 B+Tree 的有序性有关。例如对索引列进行函数运算:

SELECT *
FROM user_order
WHERE DATE(create_time) = '2026-07-01';

索引中保存的是原始 create_time 值,而查询条件比较的是函数计算结果。普通索引无法直接根据 DATE(create_time) 定位范围,优化器可能只能扫描大量记录后再计算。

更适合的写法是:

SELECT *
FROM user_order
WHERE create_time >= '2026-07-01 00:00:00'
  AND create_time <  '2026-07-02 00:00:00';

其他常见情况还包括:

  • 联合索引没有满足最左前缀。
  • 索引列发生隐式类型转换。
  • 索引列参与计算或表达式处理。
  • 使用前导模糊匹配,例如 LIKE '%keyword'。
  • 字符集或排序规则转换作用在索引列一侧。
  • OR 两侧无法形成合适的索引访问路径。

这些问题在 MySQL 和 PolarDB 中通常都可能出现,只是不同版本的优化器改写能力不同,最终表现未必完全一致。

2.2 索引可以使用,但优化器选择不用

这才是跨数据库迁移后最常见的差异。

例如 status = 2 命中了表中 80% 的数据。即使 status 上存在索引,使用二级索引也意味着先扫描大量索引项,再根据主键回表读取完整记录。

执行链路类似:

扫描二级索引
  -> 找到大量主键
  -> 对每个主键回聚簇索引取数据
  -> 返回结果

如果命中记录太多,频繁回表的随机访问成本可能高于顺序扫描整张表。此时优化器选择全表扫描不是索引坏了,而是经过成本估算后主动放弃了索引。

3. 优化器不是按规则选索引,而是在比较成本

SQL 优化器会为一条查询生成多个候选执行方案,再估算每个方案的成本。

简化来看:

索引扫描成本
≈ 索引读取成本
 + 预计命中行数 × 回表成本
 + 条件过滤成本

全表扫描成本
≈ 数据页扫描成本
 + 全表条件过滤成本

当优化器估算:

索引扫描成本 > 全表扫描成本

它就可能选择 type = ALL。

真正影响结果的不是“有没有索引”这一个条件,而是:

  • 表中总共有多少行。
  • 查询条件预计命中多少行。
  • 索引的区分度有多高。
  • 需要读取哪些列,是否必须回表。
  • 数据页和索引页的读取成本。
  • 是否可以使用多范围读、索引下推或并行扫描。
  • 当前数据库版本采用了什么成本模型。

因此,同一条 SQL 在两套数据库上得到不同计划,并不违反 MySQL 兼容性。只要输入给成本模型的信息不同,结果就可能不同。

4. PolarDB 与 MySQL 为什么会做出不同选择

4.1 数据量相同,不代表数据分布相同

迁移数据时,两边的总行数可能一致,但数据分布不一定完全一致。

例如源库历史数据已经清理,目标库却保留了全量历史;或者迁移后又有一段时间的双写和增量同步。即使表结构一样,status 字段各取值所占比例也可能发生变化。

可以分别执行:

SELECT status, COUNT(*)
FROM user_order
GROUP BY status;

如果 MySQL 中 status = 2 只占 5%,PolarDB 中却占 70%,两边选择不同计划完全合理。

4.2 统计信息不同

优化器不会每次都把整张表扫描一遍再决定计划,它主要依赖统计信息估算数据量。

统计信息可能包括:

  • 表的估算行数。
  • 索引基数,也就是不同值的估算数量。
  • 数据页和索引页数量。
  • 列值分布或直方图信息。

数据迁移、批量导入、大量删除后,如果统计信息没有及时更新,优化器看到的可能仍然是旧世界。

例如实际只有 1% 的记录符合条件,统计信息却估算会命中 60%,优化器就可能错误地认为回表成本太高,从而放弃索引。

可以先对比索引基数:

SHOW INDEX FROM user_order;

再根据实际环境评估是否更新统计信息:

ANALYZE TABLE user_order;

ANALYZE TABLE 会影响后续执行计划,生产环境执行前应评估表规模、版本行为和业务影响,不能把它当成无成本操作。

4.3 数据库版本和优化器版本不同

“MySQL”本身也不是一个固定不变的实现。MySQL 5.7、MySQL 8.0 的优化器能力和成本模型就存在明显差异。

PolarDB MySQL 版同样有不同兼容版本和内核版本。即使 SQL 语法兼容,以下能力的实现和启用条件也可能不同:

  • Index Condition Pushdown,索引条件下推。
  • Multi-Range Read,多范围读。
  • Batched Key Access,批量键访问。
  • 派生表合并和子查询改写。
  • 半连接和反连接策略。
  • 直方图与基数估算。
  • 并行查询能力。

优化器能力越不同,候选执行计划和成本估算就越可能不同。

因此排查时不能只说“两边都是 MySQL”,而要记录完整版本:

SELECT VERSION();

4.4 PolarDB 的存储架构会改变成本判断

传统单机 MySQL 通常将计算和本地存储部署在同一台主机上。PolarDB 采用计算与存储分离的云原生架构,计算节点共享分布式存储。

从 SQL 层看,用户仍然在使用熟悉的表和索引;但在底层,数据访问路径、缓存命中、I/O 调度和并行能力的成本不一定与单机 MySQL 相同。

这并不意味着 PolarDB 一定更容易全表扫描,也不意味着索引一定更快。准确的说法是:

底层执行成本发生变化
        ↓
优化器使用的成本参数和候选能力可能变化
        ↓
相同 SQL 的最优计划可能变化

特别是在数据量大、返回比例高,或者能够使用并行查询的场景中,PolarDB 可能认为扫描比大量离散回表更划算。

4.5 参数和会话环境不同

优化器行为还会受到数据库参数影响。例如:

SHOW VARIABLES LIKE 'optimizer_switch';
SHOW VARIABLES LIKE 'sql_mode';
SHOW VARIABLES LIKE 'collation_connection';
SHOW VARIABLES LIKE 'character_set_connection';

两边即使使用同一份 SQL,以下差异也可能影响计划:

  • optimizer_switch 中部分优化策略是否开启。
  • 字符集与排序规则是否一致。
  • 内存和临时表相关参数是否一致。
  • 是否启用并行查询或特定执行能力。
  • 预编译参数的类型是否一致。

迁移验证时只对比表结构,而不对比关键参数,结论往往是不完整的。

4.6 看起来相同的表,索引定义可能并不相同

有时问题并不在优化器,而在迁移后的 DDL 已经发生变化。

例如:

  • 联合索引列顺序不同。
  • 字符串索引的前缀长度不同。
  • 字段类型、长度或是否允许空值不同。
  • 表和字段使用了不同的字符集或排序规则。
  • 索引在目标库未成功创建,或创建的是不可见索引。

不要只看索引名称。应分别导出完整建表语句进行对比:

SHOW CREATE TABLE user_order;

索引名称相同,只能说明名字一样,不能证明它们的列顺序、前缀和可见性完全一致。

5. 隐式类型转换为什么会让两边表现不一致

假设业务字段是字符串:

user_code VARCHAR(32)

但查询传入了数字:

SELECT *
FROM user_info
WHERE user_code = 10086;

数据库需要在字符串和数字之间完成类型转换。如果转换作用在索引列上,B+Tree 原有的有序关系就可能无法被直接用于定位。

正确做法是让参数类型与字段类型一致:

SELECT *
FROM user_info
WHERE user_code = '10086';

这类问题在应用中更隐蔽,因为最终 SQL 可能来自预编译语句:

preparedStatement.setLong(1, userCode);

字段虽然是 VARCHAR,Java 却以 Long 类型绑定参数。数据库版本、驱动版本或优化器处理方式不同时,执行计划可能出现差异。

所以排查时不能只复制日志里打印出来的 SQL 字符串,还要确认 JDBC 实际绑定的参数类型。

6. 联合索引的最左前缀没有因为兼容性而消失

假设索引为:

INDEX idx_tenant_status_time (
    tenant_id,
    status,
    create_time
)

下面的查询能够从最左侧列开始匹配:

WHERE tenant_id = 10
  AND status = 2
  AND create_time >= '2026-07-01'

而下面的查询跳过了 tenant_id:

WHERE status = 2
  AND create_time >= '2026-07-01'

传统判断中,它不能完整利用这个联合索引进行高效范围定位。某些新版本优化器可能通过跳跃扫描等能力为特定场景生成索引计划,但不能把版本相关的优化能力当成稳定前提。

如果 MySQL 一侧恰好生成了索引计划,PolarDB 一侧没有,不应立刻得出“PolarDB 索引失效”的结论。先确认两边版本是否支持并启用了相同优化能力,再判断索引设计是否真正符合查询模式。

7. 一套更可靠的对照排查方法

遇到 MySQL 走索引、PolarDB 不走索引时,可以按以下顺序排查。

7.1 确认 SQL 和参数完全一致

不仅要对比 SQL 文本,还要确认:

  • 参数值是否一致。
  • 参数类型是否一致。
  • 会话字符集是否一致。
  • 是否处于相同事务和会话环境。

不同参数可能命中完全不同的数据比例,自然可能生成不同计划。

7.2 对比表结构和索引

在两边分别执行:

SHOW CREATE TABLE user_order;
SHOW INDEX FROM user_order;

重点检查字段类型、字符集、排序规则、索引列顺序、索引基数和索引可见性。

7.3 对比执行计划

至少保存两边的:

EXPLAIN FORMAT=JSON
SELECT ...;

不要只看 key 是否为空,还要比较:

  • possible_keys:优化器认为哪些索引可以使用。
  • key:最终选择了哪个索引。
  • rows:预计扫描多少行。
  • filtered:预计过滤后保留多少比例。
  • cost_info:版本支持时查看成本估算。
  • Extra:是否使用索引条件下推、临时表或文件排序。

一个很有价值的判断是:

possible_keys 有值,key 为 NULL

这通常说明索引不是不能用,而是优化器经过成本计算后没有选择它。

7.4 核对真实数据分布

不要只依赖 SHOW INDEX 中的估算值。针对查询条件,直接统计实际分布:

SELECT status, COUNT(*)
FROM user_order
GROUP BY status;

对于组合条件,还应统计组合后的命中数量:

SELECT COUNT(*)
FROM user_order
WHERE status = 2
  AND create_time >= '2026-07-01 00:00:00';

如果查询本来就会返回全表大部分数据,全表扫描可能是合理计划,强制使用索引反而会变慢。

7.5 检查并更新统计信息

确认目标库是否刚完成数据迁移、批量写入或大量删除。必要时在评估影响后执行:

ANALYZE TABLE user_order;

更新前后保存执行计划,判断问题是否来自陈旧的基数估算。

7.6 对比版本和优化器参数

SELECT VERSION();
SHOW VARIABLES LIKE 'optimizer_switch';
SHOW VARIABLES LIKE 'sql_mode';

如果是 PolarDB 特有的并行查询、列存或其他优化能力,还需要结合对应集群版本和参数继续确认。

7.7 用实际执行信息验证估算

如果数据库版本支持,可以在可控环境使用 EXPLAIN ANALYZE 对比预计行数和实际行数。

EXPLAIN ANALYZE
SELECT ...;

需要注意,EXPLAIN ANALYZE 会真实执行 SQL。对更新、删除或超大查询不能直接在生产环境盲目使用。

如果预计扫描 100 行,实际却扫描 100 万行,问题通常已经指向统计信息、数据倾斜或估算模型。

8. FORCE INDEX 能解决问题吗

看到全表扫描后,最直接的修复方式可能是:

SELECT ...
FROM user_order FORCE INDEX (idx_status_create_time)
WHERE status = 2
  AND create_time >= '2026-07-01 00:00:00';

它有时能够立即降低查询耗时,但不应该成为第一反应。

FORCE INDEX 相当于人为缩小优化器的选择空间。当前数据分布下索引也许更快,但随着数据量和命中比例变化,未来可能变成更差的计划。

使用 Hint 前至少应确认:

  • 优化器是否因为错误统计信息做出误判。
  • 索引设计是否真正匹配查询条件。
  • 强制索引后的实际扫描行数和耗时。
  • 不同参数值下是否仍然稳定。
  • 数据增长后是否需要重新评估。

更理想的修复顺序通常是:

修正 SQL 和参数类型
  -> 校准统计信息
  -> 调整索引设计
  -> 验证版本与优化器参数
  -> 最后再评估 Hint

9. 如何判断索引到底有没有“失效”

可以用下面这组判断快速分类:

执行计划现象更可能的原因
possible_keys 为空SQL 条件无法匹配现有索引,或缺少合适索引
possible_keys 有值但 key 为空优化器认为全表扫描成本更低
选择索引但扫描行数很大索引区分度低、范围过大或估算不准
两边 SHOW CREATE TABLE 不同DDL 或索引迁移不一致
更新统计信息后恢复走索引原统计信息陈旧或采样偏差
更换参数值后计划变化数据倾斜、选择率差异或参数敏感

这比简单地说一句“PolarDB 索引失效”更接近问题本质。

10. 最后

PolarDB MySQL 版与 MySQL 同源,意味着它们拥有大量相同的语法、协议、存储结构和优化思想,但不意味着它们永远拥有完全相同的执行成本。

索引是否存在,是表结构问题;索引是否具备使用条件,是 SQL 和 B+Tree 匹配问题;优化器最终是否选择索引,则是成本问题。

把这三层混在一起,就会得到一句模糊的“索引失效”。把它们拆开,排查路径就清楚了:

先确认索引能不能用
再确认统计信息准不准
最后确认优化器为什么不选

同一条 SQL 在 MySQL 中走索引、在 PolarDB 中全表扫描,并不一定是谁做错了。它更可能说明,两套数据库看到的数据世界不同,或者对同一条访问路径计算出了不同的价格。

真正需要解决的,不是强迫两边的 EXPLAIN 长得一模一样,而是让它们在各自环境中都得到稳定、可解释、经过真实数据验证的执行计划。