《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=小 |
对照表要落到代码仓库,贴在仓库文档或评审模板里。具体步骤:
- 打开 SQL 日志,收集 5 条核心方法的真实 SQL。
- 对每条 SQL 执行 EXPLAIN,记录 type、key、rows、Extra。
- 设计联合索引,遵循最左匹配,把等值条件列放前面,排序列放后面。
- 在从库或预发环境执行 ALTER TABLE,观察写放大和锁等待。
- 执行 EXPLAIN ANALYZE,确认真实行数和耗时。
- 将方法、SQL、Plan、Index 写入对照表,随代码评审一起合并。
- 定期查看慢日志,当数据分布变化或查询模式变化时重新生成对照表。
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 的章节。

浙公网安备 33010602011771号