《Spring Boot + JPA 实战》第15招 JPA也要懂索引

我是老李,最近被问到一个问题:为什么在订单 Entity 上加了几个 @Index,查询还是慢?我让他把慢 SQL 发来,一看,租户隔离的订单列表,过滤 tenant_id 和 status,再按 created_at 倒序分页。SQL 大概长这样:

SELECT id, tenant_id, status, created_at, amount
FROM orders
WHERE tenant_id = 10001 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

表里几百万行,tenant_id 有索引,status 也有索引,但 MySQL 依然全表扫描或 filesort。这时候说“JPA 可以生成 SQL,但不能替你设计 MySQL 索引和执行计划”,就不是一句正确的废话,而是线上事故总结出的结论。

01、痛点

在 ORM 里,加索引往往变成给实体类字段加注解,比如 @Table(indexes = @Index(...))。但注解只是建表 DDL 的一部分,不感知组合查询、排序方向、回表代价。MySQL 优化器可能选择错误的路径,因为多个单列索引不如一个联合索引。问题经常出现在分页查询、租户隔离系统、订单状态流转等核心链路。注解索引造成“有索引”的假象,线上慢查询依然发生。

更麻烦的是,JPA 生成 SQL 时可能对实体关系进行意外 join、distinct、in 展开等,进一步偏离开发者印象。你以为会走 idx_tenant,实际执行计划却走了 idx_status,甚至全表扫描。慢查询不是一个固定 SQL,而是随数据分布、参数变化、优化器统计信息变化而漂移。

02、是什么

先厘清几个概念。JPA 或者具体到 Hibernate,会根据方言把 JPQL、Criteria 或方法名派生的查询转换成数据库 SQL,同时处理分页、批量、缓存。Hibernate 打印出来的 SQL 只是最终字符串,真正执行在数据库。SQL 生成不等于 SQL 执行,中间隔着编译器、优化器和存储引擎。

MySQL 侧的核心概念有这些。

B+Tree 是 InnoDB 的默认索引结构,叶子节点按索引键排序,叶子间有双向链表。主键索引是聚簇索引,整行数据存在叶子节点;二级索引叶子节点存索引键和主键值。

联合索引是多个列组成一个 B+Tree 键,先按第一列排序,第一列相同再按第二列,依次类推。最左匹配要求查询条件包含联合索引最左连续列才能有效利用。例如 (tenant_id, status, created_at) 可以支持 tenant_id= ?,也可以支持 tenant_id=? AND status=? 以及三列全给;但如果只给 status 而没有 tenant_id,就不能有效利用这个联合索引。

覆盖索引指查询需要的所有列都在索引里,直接从索引叶子拿到数据,不再回表。执行计划的 Extra 会出现 Using index。回表是指二级索引找到主键后,还要回到主键索引查完整行,成本随行数线性增加。

Cardinality 是索引列或索引组合中不同值的估计数量,优化器用它估算选择性。数据倾斜会导致估算偏差,需要 ANALYZE TABLE 更新统计信息。EXPLAIN 展示优化器估计的执行计划,包括 type、possible_keys、key、rows、filtered、Extra。EXPLAIN ANALYZE 是 MySQL 8.0.18 起支持的实际执行分析,会真实执行查询并返回每步实际行数、耗时。

03、为什么

为什么在 Entity 上随便加几个 @Index 不叫索引设计?因为索引设计必须与查询模式对齐,必须观察真实 SQL 和真实执行计划。注解只是建表选项,不会根据 Repository 方法的组合条件、排序方向、分页大小动态调整。你写的 @Index(columnList = "tenant_id") 和 @Index(columnList = "status"),是两根单列索引,不等于联合索引。数据库优化器很难同时利用两个单列索引完成等值过滤和排序。

为什么必须捕捉真实 SQL?JPA 可能生成不是你印象中的 SQL。自动分页可能改写成 limit/offset,实体关联可能引入 join,多态查询可能生成 union。参数绑定类型、会话变量、方言差异都会影响最终 SQL 和优化器行为。凭 JPQL 猜索引常常是刻舟求剑。

为什么 ORM 改变不了物理成本?ORM 省去手写 SQL 和结果集映射的力气,但没有消除数据库访问数据页、判断键范围、排序、回表、加锁的成本。无论 SQL 是手写还是框架生成,执行计划都会落到 InnoDB 的 B+Tree 上。框架只能决定发送什么 SQL,不能决定存储引擎怎么执行。

04、架构

可以把一次查询的路径拆成:Repository 方法 -> Hibernate 生成 SQL -> JDBC 发送 -> MySQL 解析/优化 -> 选择索引 -> InnoDB 读取 B+Tree -> 回表/排序 -> 返回结果集。每一层都可能出问题,但最容易忽略的是优化器选择索引和 InnoDB 扫描范围。

针对 tenant_id + status + created_at 的订单查询,如果只有 idx_tenant 和 idx_status 两根单列索引,优化器通常会选择选择性更高的一列,先用索引扫出一批主键,再回表过滤另一列,最后对 created_at 做 filesort。这个 filesort 可能成为灾难,因为每一页查询都要把一个大租户的所有订单捞出来排序,再取前 20 条。由于 ORDER BY 和过滤条件无法同时被一个索引覆盖,性能随数据量增长而急剧下降。

如果建立联合索引 (tenant_id, status, created_at),执行路径会完全不同。tenant_id 和 status 是等值条件,命中前两列;created_at 在第三列,索引天然按 created_at 升序排列。查询要求 ORDER BY created_at DESC,MySQL 可以反向扫描索引,从最后一个满足 tenant_id 和 status 的索引条目开始向回读取,直接得到有序结果,不再需要 filesort。如果查询只需要索引里的列,还能变成覆盖扫描,连回表都省了。

05、实战

先拿真实 SQL。打开 Hibernate 日志,可以用 show_sql、format_sql,或者把 org.hibernate.SQL 的日志级别调到 DEBUG。看到 SQL 后,把绑定参数替换成具体值。慢查询示例:

SELECT id, tenant_id, status, created_at, amount
FROM orders
WHERE tenant_id = 10001 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

初始表结构:

CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  tenant_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  created_at DATETIME NOT NULL,
  amount DECIMAL(10,2),
  INDEX idx_tenant (tenant_id),
  INDEX idx_status (status)
) ENGINE=InnoDB;

对这 SQL 执行 EXPLAIN,结果类似:

id select_type table type possible_keys key key_len rows Extra
1 SIMPLE orders ref idx_tenant idx_tenant 8 180000 Using where; Using filesort

type=ref 表示用了非唯一索引,但 rows 还是 18 万,说明一个租户下订单很多。status 过滤发生在回表之后,所以 Extra 是 Using where;排序无法利用索引,所以 Using filesort。

建立联合索引:

ALTER TABLE orders ADD INDEX idx_tenant_status_created (tenant_id, status, created_at);

再执行 EXPLAIN,结果可能变成:

id select_type table type possible_keys key key_len rows Extra
1 SIMPLE orders ref idx_tenant_status_created idx_tenant_status_created 70 20 Using index condition; Backward index scan

rows 从 18 万降到 20,不再有 Using filesort。Backward index scan 表示优化器反向扫描联合索引来满足 ORDER BY created_at DESC。Using index condition 表示使用索引条件下推,减少回表行数。

如果业务查询只需要 id、tenant_id、status、created_at、amount 五列,可以把 amount 也加入索引,做成覆盖索引:

ALTER TABLE orders ADD INDEX idx_tenant_status_created_cover 
  (tenant_id, status, created_at, id, amount);

在执行计划中 Extra 可能出现 Using index,表示直接从二级索引取数,不再回表。代价是索引变大,写入性能略微下降。需要根据读写比例权衡。

再用 EXPLAIN ANALYZE 看真实耗时:

EXPLAIN ANALYZE
SELECT id, tenant_id, status, created_at, amount
FROM orders
WHERE tenant_id = 10001 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

输出可能包含 actual time、rows、loops,例如:

-> Limit: 20 row(s) (actual time=0.213..0.221 rows=20 loops=1)
    -> Index lookup on orders using idx_tenant_status_created (tenant_id=10001, status='PAID') (actual time=0.210..0.216 rows=20 loops=1)

这说明实际只扫描了 20 行,而不是估计值。

有时优化器不选联合索引,可以检查 Cardinality。执行:

ANALYZE TABLE orders;
SHOW INDEX FROM orders WHERE Key_name = 'idx_tenant_status_created';

重点关注 Cardinality 一列,它不是精确值,但用于估算选择性。如果租户数据倾斜,某些 tenant_id 下有大量订单,优化器可能认为过滤性不足而放弃索引。此时需要结合 query 类型和实际排序需求决定是否使用 force index 或调整索引列顺序。

06、洞见

ORM 改变 SQL 的生成方式,却不会改变数据库执行 SQL 的物理成本。这是整件事的核心。很多团队把性能问题归咎于 JPA 不行,或者怪 Hibernate 生成 SQL 难看,但实际上多数慢查询的根子在索引和执行计划。框架可以替你写 SQL,但不能替你决定数据怎么存、怎么扫、怎么排序。

索引设计是数据库侧的工程,不是注解层面的工程。它要回答的是:这条查询会访问多少行?能不能靠索引完成过滤和排序?会不会回表?索引维护成本能否接受?这些问题只有对着真实 SQL 和 EXPLAIN 才答得出来。

另一个容易被忽视的点是,执行计划不是一劳永逸。表数据增长、统计信息过期、查询参数改变,都会导致优化器改变决策。因此核心查询需要持续监测,而不是上线后就不再回头。

07、行动

为 5 条核心 Repository 查询建立“方法 → SQL → Plan → Index”对照表。这是最小可行动作。示例:

Repository 方法 生成 SQL 摘要 初始 EXPLAIN 索引设计 优化后 EXPLAIN
findPageByTenantAndStatusOrderByCreatedAtDesc select ... where tenant_id=? and status=? order by created_at desc limit ? type=ref, key=idx_tenant, rows=180000, Using filesort (tenant_id, status, created_at) type=ref, key=idx_tenant_status_created, rows=20, Backward index scan
findByIdList select ... where id in (?, ?, ?) type=range, key=PRIMARY, rows=取决于列表长度 保持主键索引 type=range, key=PRIMARY
countByTenantAndStatus select count(*) ... where tenant_id=? and status=? type=ref, key=idx_tenant, rows=大 (tenant_id, status) type=ref, key=idx_tenant_status_created
findRecentByTenant select ... where tenant_id=? order by id desc limit ? type=ref, key=idx_tenant, rows=大, Using filesort (tenant_id, id) 或依赖主键顺序 type=ref, key=复合索引, Backward index scan
findExpiredOrders select ... where status='EXPIRED' and created_at < ? order by created_at type=ref 或 range,但排序差 (status, created_at) type=range, key=idx_status_created, rows=小

对照表要落到代码仓库,贴在仓库文档或评审模板里。具体步骤:

  1. 打开 SQL 日志,收集 5 条核心方法的真实 SQL。
  2. 对每条 SQL 执行 EXPLAIN,记录 type、key、rows、Extra。
  3. 设计联合索引,遵循最左匹配,把等值条件列放前面,排序列放后面。
  4. 在从库或预发环境执行 ALTER TABLE,观察写放大和锁等待。
  5. 执行 EXPLAIN ANALYZE,确认真实行数和耗时。
  6. 将方法、SQL、Plan、Index 写入对照表,随代码评审一起合并。
  7. 定期查看慢日志,当数据分布变化或查询模式变化时重新生成对照表。

08、小结

JPA 能给你生成 SQL,但不能替你设计 MySQL 索引和执行计划。索引设计必须从真实 SQL 出发,结合 B+Tree、联合索引、最左匹配、覆盖索引、回表和 Cardinality 来判断。EXPLAIN 是侦查,EXPLAIN ANALYZE 是验证。对核心查询建立方法到 SQL 到 Plan 到 Index 的对照表,比在实体上加一堆注解有用得多。数据库的物理成本不会因为 ORM 而消失,它只会在你忽略执行计划时,在线上慢慢累积。

09、链接与概念说明

B+Tree:InnoDB 使用的平衡多路搜索树,叶子节点有序并携带数据或主键。

联合索引:多列按序构成的索引键,能同时加速等值过滤和排序,但需满足最左匹配。

最左匹配:查询条件必须包含联合索引的最左连续列,否则索引不能完整使用。

覆盖索引:索引包含查询需要的所有列,Extra 可看到 Using index,避免回表。

回表:通过二级索引定位主键后,再回聚簇索引读取完整行的过程。

Cardinality:优化器用于估算索引选择性的统计量,可用 SHOW INDEX 和 ANALYZE TABLE 查看或更新。

EXPLAIN:输出优化器估计的执行计划,关注 type、key、rows、Extra。

EXPLAIN ANALYZE:MySQL 8.0.18 起支持,实际执行查询并输出每步真实行数和耗时。

更多细节见 MySQL 官方手册中关于 InnoDB 索引、优化器统计信息、EXPLAIN 和 EXPLAIN ANALYZE 的章节。

posted @ 2026-10-10 23:46  李福春  阅读(6)  评论(0)    收藏  举报