Java 与 MySQL 查询优化复盘:从循环查库到批量查询、联合索引与短缓存

复盘会场、直播和首页查询的优化过程:建立性能基线,定位循环查库与索引问题,精简返回字段,治理历史数据,并按测试条件评估收益。

这篇笔记根据项目的历史测试记录、开发日志、索引迁移脚本和 Java 源码整理,记录我围绕会场列表、直播列表和首页查询开展的一次性能优化。

主要改动集中在四个方面:减少数据库查询次数、改善索引访问路径、缩小返回数据量、减少重复查询。过程中还处理了数据库网络链路和历史数据结构问题。

文中的性能指标来自历史测试记录,整理本文时没有重新压测,也没有重新核对生产环境的索引状态。 不同测试的环境、并发方式和统计口径不同,需要分别理解。

1. 先建立基线,区分接口耗时和网络耗时

最初我先确认服务能够启动、接口正常返回,再对列表建立性能基线。测试入口为:

GET /api/Index/mettingnew?page=1&pagesize=20

mettingnew 在记录中被称为“会议列表”,实际返回的是带有所属会议信息的会场列表。

第一轮使用 ApacheBench,配置为 200 次请求、20 并发、开启 Keep-Alive,结果如下:

指标基线结果
P951385 ms
吞吐量25.59 请求/秒
单请求响应体约 33.9 KB
成功请求200 / 200

当时本地 Java 连接远程 MySQL,所以测试结果包含数据库网络往返耗时。后续分析必须把“SQL 执行慢”和“访问数据库的链路慢”分开。

开发日志中还记录过一次独立的代理问题:JVM 将腾讯云 MySQL 连接经本机代理转发,单条 JDBC SELECT 1 约耗时 305 ms,直连约 20 ms。我在启动脚本中为数据库域名配置代理绕过,避免每次查询承担额外代理延迟。

这属于连接链路优化,不能把它算成索引或 SQL 执行计划优化的效果。

2. 结合执行计划与调用链定位问题

我对实际查询执行 SHOW INDEX 和 EXPLAIN,重点关注使用的索引、访问类型、预计扫描行数和排序方式。

当时发现:

  • 会场表只有主键,列表查询出现 type=ALL,预计扫描约 579 行。
  • 会场查询出现 Using where; Using filesort。
  • 会议表约 161 行、专家表约 3920 行的相关查询也存在全表扫描和额外排序。
  • 会场列表读取当前页后,还会逐条查询所属会议。
  • 登录后的直播列表,还会逐条查询用户是否订阅。
  • 列表读取了大量仅详情页需要的富文本字段。
  • 专家日程在排序比较器中执行数据库查询。

这些问题分成两个层面:单条 SQL 的扫描、排序成本,以及一次接口请求中执行过多 SQL 的网络往返成本。

Using filesort 表示没有直接通过索引顺序完成排序,并不代表一定发生磁盘排序。后续优化也不能概括成“所有 filesort 都已消除”。

3. 将循环关联查询改为按页批量读取

原来的匿名会场列表流程是:

查询总数
→ 查询当前页 N 个会场
→ 遍历 N 个会场,分别查询所属会议

查询次数为 1 次 COUNT + 1 次列表 + N 次会议查询 = 2+N。

登录态直播列表还要逐条读取订阅状态,因此达到 2+2N。一页 20 条记录时,两个场景分别可能执行 22 次和 42 次 SQL。

问题的根源是循环查库,不能仅根据实体之间是一对多还是多对多关系判断。

我把流程改为:

  1. 查询当前页会场。
  2. 收集并去重当前页的 mettingid。
  3. 使用一次参数化 IN (...) 查询读取所属会议。
  4. 登录时,再收集当前页会场 ID,一次读取该用户的订阅状态。
  5. 将会议组织为 Map、订阅会场组织为 Set,在内存中回填结果。
场景优化前优化后
匿名会场或直播列表2+N 次通常 3 次
登录态直播列表2+2N 次通常 4 次
一页 20 条的匿名列表22 次3 次
一页 20 条的登录态列表42 次4 次

以上只计算列表业务链路,登录身份解析等公共查询另计。

这项改动减少了 SQL 执行次数、网络往返和连接占用,数据库位于远程时尤其有价值。但它不表示整体计算量变成常数,返回和组装 N 条记录仍然需要随 N 增长。

4. 将批量读取推广到日程、专家和消息列表

专家会议日程

原先排序比较器会查询会场日期。排序会反复调用比较器,数据库访问次数也随之增加。

改造后先收集会场 ID,一次读取会场名称和日期,再完全在内存中排序。比较器只比较已准备好的数据。

会场详情日程

原先按环节逐个读取日程,改为按照会场和环节 ID 集合一次读取,再按 huanjieid 分组,回填到对应环节。

专家信息补全

医院、科室名称和会员订阅状态等关联信息也通过批量查询组织后回填,避免列表逐条补全。

会员消息列表

邀请函封面的获取由相关子查询改成 LEFT JOIN my_content_mettinghall,统一读取消息和关联封面。

连接查询是否获得更优执行计划仍取决于索引与数据分布。这些改动没有各自独立的性能百分比,不能为每一项分配一个未经测试的提升数字。

5. 精简列表字段,减少数据搬运

列表和详情承担不同职责。我将部分列表查询收缩到展示所需的字段,把富文本 content 留给详情接口。

实际改动包括:

  • 批量所属会议查询仅保留 id、title、indexpic、starttime、endtime、address。
  • 首页、资讯列表和部分专家列表移除不需要的富文本。
  • 直播列表返回 content: "",减少读取的同时保持旧接口字段兼容。

收益覆盖数据库结果传输、JDBC 对象构建、JSON 序列化和 HTTP 响应体积。

一次本地对比中,单请求响应体从约 33,877 bytes 降至 27,493 bytes,减少 18.84%。

这里有一个实现边界:当时普通会场列表仍读取会场自身的 h.content。精简已落实到所属会议批量查询、直播列表和其他指定入口,不能写成“全部列表都已移除富文本”。

6. 按筛选和排序条件设计联合索引

索引设计围绕实际查询展开,而不是为每个字段分别创建一个索引。

查询场景对应索引列
普通会议列表(status, starttime, id)
普通会场列表(status, sort, starttime, mettingid)
直播会场列表(status, starttime, id, mettingid)
首页推荐会议(status, isresume, starttime, id)
用户直播订阅查询(memberid, hallid)
专家科室筛选(status, keshi, isresume, sort, id)
专家展示排序治理后(status, isresume, sort, en, id)
删除会议前的依赖检查会场表和模板表的 (mettingid)

首页分类、资讯、课程和章节也准备了与状态、推荐标记和排序字段相匹配的索引。

基本思路是利用 B+Tree 最左前缀,在条件匹配时缩小候选记录,再尽可能利用索引完成范围读取与排序。一个索引无法覆盖所有筛选组合,所以普通会场列表和直播时间列表使用了不同索引。

MySQL 5.7 和历史表结构的限制

实施中遇到了以下问题:

  • MySQL 5.7 无法利用混合方向索引完整匹配部分 ASC/DESC 混合排序,不能承诺彻底消除 filesort。
  • 历史 my_staff.en 为 utf8mb4 varchar(255),加入联合索引时遇到旧库 767-byte 索引长度限制。
  • 部分历史表带有零日期默认值,迁移需要在会话内兼容处理 SQL Mode,并在结束后恢复。
  • LIKE '%关键词%' 无法依靠普通 B+Tree 做有效的前缀定位,因此没有盲目为医院、科室名称增加索引。

历史记录中,直播查询在隔离库已验证使用范围扫描,会议删除依赖检查已验证使用 ref 索引访问。迁移脚本存在,只能证明已准备相应变更,不能直接证明所有索引都已经发布到生产。

7. 治理专家关联数据和拼音字段

将字符串关系整理为可索引的关系表

旧系统把专家 ID 存在会场、环节和日程的 staffs 字符串中。我增加了 my_meeting_hall_staff 关系表,使用结构化字段:

meeting_id、hall_id、staff_id

并建立对应约束和索引:

索引或约束用途
(hall_id, staff_id) 唯一约束防止重复关系
(staff_id, hall_id)从专家查会场
(meeting_id, hall_id)从会议查关联关系

为了兼容旧 PHP,原有 staffs 字段继续保留,通过事务同步新关系表。历史不存在的专家 ID 进入异常表,回填后再校验缺失关系和多余关系。

这样部分专家关联查询可以通过结构化关系和索引完成。具体环节、日程仍保留旧字段,用于保存绑定位置与顺序,所以不能说整个系统已经完全消除字符串关联查询。

收缩姓名拼音首字母字段

专家姓名拼音首字母统一由后端生成,历史上回填了 4155 条专家记录,并将 en 从 utf8mb4 varchar(255) 收缩为 CHAR(1) ASCII,随后建立相应联合索引。

这改善了数据一致性、字段紧凑性和索引可建立性,但没有单独测得的接口提升百分比。

8. 给匿名首页增加短缓存,并约束分页开销

首页需要读取会议、专家、资讯、课程等多张表。我将匿名首页公共结果缓存在 Java 进程内,过期时间为 10 秒,减少短时间内的重复读取。

实现包含过期时间和锁内二次检查,以减少同一实例缓存过期时的重复构建。登录用户继续实时读取,不共享会员和收藏状态。

缓存的边界包括:

  • 内容更新存在约 10 秒的缓存窗口。
  • 每个 Java 实例维护自己的缓存。
  • 缓存未命中仍然需要查询数据库。

这里使用的是进程内缓存,不能在复盘时写成 Redis 首页缓存。

另外,会员中心通用分页限制了最大 pageSize=100,并使用 long 计算 offset,减少超大单页请求和整数溢出的风险。这属于分页防护,现有记录没有证明已经完成深分页的游标化改造。

9. 用业务回归和分口径测试验证结果

验证不仅检查编译,还覆盖列表总数、分页条数、会场字段、登录订阅状态和旧接口字段兼容。

例如结束回放场景,优化前后均返回 483 条总记录、每页 12 条,确认响应变快没有以丢失数据为代价。

测试口径指标结果
PHP 与 Java 同机等价功能对照,10 并发、3 轮列表 P951170 → 529 ms,降低 54.79%
同一组新旧系统对照吞吐量7.13 → 29.33 RPS,约为原来的 4.1 倍
同一组新旧系统对照P996938 → 964 ms,降低 86.11%
Java 查询优化后的本地测试,20 并发、3 轮列表 P95三轮中位数 218 ms,600 次请求均成功
本地列表响应体对比响应体积减少 18.84%
Java 优化前后双实例,预热后各串行请求 30 次即将开始直播 P95162.4 → 57.1 ms,降低 64.8%
同一组直播专项测试结束回放 P95217.7 → 54.4 ms,降低 75.0%
同一组首页专项测试,匿名缓存命中首页 P95145.6 → 3.5 ms,降低 97.6%

这些结果能说明什么

  1. 系统迁移对照不能全部归因于某个索引。 1170 → 529 ms 包含运行框架、查询实现和返回数据等差异,两边总记录数也有小幅差异。
  2. 本地基线与优化后测试不是严格控制变量实验。 优化前只有一轮,优化后取三轮中位数,不能直接把 1385 → 218 ms 包装成严格的改善率。
  3. 直播专项属于串行联调测试。 不能将其描述为高并发生产压测。
  4. 首页 97.6% 是缓存命中收益。 首次未命中的记录为 297.5 ms。
  5. 请求没有失败不代表真实业务负载性能达标。 另一次隔离库 2200 请求没有 HTTP 失败,但业务数据为空,不能推断生产数据量下的性能。
  6. 缺少监控对照的指标不作定量结论。 CPU、内存、锁等待、磁盘 I/O 和慢查询数量没有完整前后对照,不能声称降低了具体比例。

测试还发现手机号、短信记录查询缺少合适索引。这些属于后续建议,现有记录不足以证明相关索引优化和复测已经完成。

10. 面试时如何讲清楚这段经历

我先通过接口基线测试区分应用、数据库与网络耗时,再结合实际 SQL、SHOW INDEX 和 EXPLAIN,定位全表扫描、额外排序及循环关联查询。

针对会场和直播列表,我将逐条查询会议、订阅状态改为按页收集 ID、批量 IN 查询并在内存回填,使匿名列表查询从 2+N 收敛到通常 3 次,登录态列表从 2+2N 收敛到通常 4 次。同时精简富文本字段,按筛选和排序条件设计联合索引,并处理 MySQL 5.7 的索引长度、混合排序和历史零日期兼容问题。

随后通过数据一致性回归、执行计划复核和优化前后测试验证效果。在预热后串行请求的直播专项测试中,P95 降低 64.8%~75.0%;本地列表响应体减少 18.84%。匿名首页使用 10 秒进程内缓存,缓存命中场景 P95 降低 97.6%。这些结果分别对应各自的测试条件,不作为高并发生产环境的统一性能承诺。