这篇笔记根据项目的历史测试记录、开发日志、索引迁移脚本和 Java 源码整理,记录我围绕会场列表、直播列表和首页查询开展的一次性能优化。
主要改动集中在四个方面:减少数据库查询次数、改善索引访问路径、缩小返回数据量、减少重复查询。过程中还处理了数据库网络链路和历史数据结构问题。
文中的性能指标来自历史测试记录,整理本文时没有重新压测,也没有重新核对生产环境的索引状态。 不同测试的环境、并发方式和统计口径不同,需要分别理解。
1. 先建立基线,区分接口耗时和网络耗时
最初我先确认服务能够启动、接口正常返回,再对列表建立性能基线。测试入口为:
GET /api/Index/mettingnew?page=1&pagesize=20
mettingnew 在记录中被称为“会议列表”,实际返回的是带有所属会议信息的会场列表。
第一轮使用 ApacheBench,配置为 200 次请求、20 并发、开启 Keep-Alive,结果如下:
| 指标 | 基线结果 |
|---|---|
| P95 | 1385 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。
问题的根源是循环查库,不能仅根据实体之间是一对多还是多对多关系判断。
我把流程改为:
- 查询当前页会场。
- 收集并去重当前页的
mettingid。 - 使用一次参数化
IN (...)查询读取所属会议。 - 登录时,再收集当前页会场 ID,一次读取该用户的订阅状态。
- 将会议组织为
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 轮 | 列表 P95 | 1170 → 529 ms,降低 54.79% |
| 同一组新旧系统对照 | 吞吐量 | 7.13 → 29.33 RPS,约为原来的 4.1 倍 |
| 同一组新旧系统对照 | P99 | 6938 → 964 ms,降低 86.11% |
| Java 查询优化后的本地测试,20 并发、3 轮 | 列表 P95 | 三轮中位数 218 ms,600 次请求均成功 |
| 本地列表响应体对比 | 响应体积 | 减少 18.84% |
| Java 优化前后双实例,预热后各串行请求 30 次 | 即将开始直播 P95 | 162.4 → 57.1 ms,降低 64.8% |
| 同一组直播专项测试 | 结束回放 P95 | 217.7 → 54.4 ms,降低 75.0% |
| 同一组首页专项测试,匿名缓存命中 | 首页 P95 | 145.6 → 3.5 ms,降低 97.6% |
这些结果能说明什么
- 系统迁移对照不能全部归因于某个索引。
1170 → 529 ms包含运行框架、查询实现和返回数据等差异,两边总记录数也有小幅差异。 - 本地基线与优化后测试不是严格控制变量实验。 优化前只有一轮,优化后取三轮中位数,不能直接把
1385 → 218 ms包装成严格的改善率。 - 直播专项属于串行联调测试。 不能将其描述为高并发生产压测。
- 首页 97.6% 是缓存命中收益。 首次未命中的记录为 297.5 ms。
- 请求没有失败不代表真实业务负载性能达标。 另一次隔离库 2200 请求没有 HTTP 失败,但业务数据为空,不能推断生产数据量下的性能。
- 缺少监控对照的指标不作定量结论。 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%。这些结果分别对应各自的测试条件,不作为高并发生产环境的统一性能承诺。