一、案例结论与当前状态
本次排查的是 SSO 绑定钉钉页面导出账号时产生的机构批量查询。核心问题是:账号导出每批读取 5000 个账号,随后合并账号的归属机构和操作机构编码,去重后形成包含几千个值的 IN 查询。
本轮最终落地的修改:每批账号查询量从 5000 调整为 2000。 目标是控制单次账号查询及其后续机构查询规模。注意这是 2000 个账号,不是机构 IN 限制为 2000 个;每批机构编码去重前最多仍有 4000 个。
| 事项 | 状态 |
|---|---|
| 精确匹配机构 SQL 的 Mapper 与调用链 | 已核对本地代码 |
| 导出入口与生产慢查的关联 | 用户发现 10 月 20 日导出记录,规模与代码行为吻合;具体四条慢查仍需时间或链路关联 |
| 导出批次 5000 → 2000 | 已修改本地代码,差异检查通过 |
| 编译、运行测试、部署与生产收益 | 本轮未验证,不写成已上线或已解决 |
| 全量机构名称 Map、精简查询列、消除第一页重复查询 | 备选或后续优化,尚未实施 |
生产治理通知标记为 OceanBase;本文按用户要求采用 MySQL 的执行计划分析思路整理,不据此认定生产是原生 MySQL,也不把 MySQL 特定优化器机制直接当作生产根因。
二、业务现象与慢查证据
2.1 生产慢查平台记录
所属域为“业务中台-运营支撑域”,数据库为 aboss_sso,用户为 aboss,获取日期为 2026-10-20。截图展示四条记录:
| 记录 | 执行次数 | 平均响应时间 | 最大响应时间 | 返回行数 |
|---|---|---|---|---|
| 1 | 1 | 2227.96 | 2227.96 | 5113 |
| 2 | 1 | 2233.16 | 2233.16 | 5118 |
| 3 | 1 | 2711.31 | 2711.31 | 5633 |
| 4 | 1 | 2726.99 | 2726.99 | 5634 |
截图没有显示响应时间单位。若平台单位是毫秒,则约为 2.23~2.73 秒;在核对单位前,不把秒级表述作为确定事实。
这些是机构 SQL 的返回行数,不是账号数、IN 参数数量或分页总数。截图下方“10 条/页”是慢查平台展示分页,与业务接口的账号分页无关。
2.2 为什么排除普通小分页作为首要嫌疑
关键词查询接口也会调用相同的机构 SQL,但只收集本页账号的两个机构编码:
- 每页 10 个账号,最多产生 20 个不同机构编码。
- 后端分页 DTO 声明最大每页 100 条;校验和分页正常时,最多产生 200 个机构编码。
- 五六千行机构结果更符合导出或大批量账号查询的规模。
所以,“代码会执行这条 SQL”不能直接推出“这次慢查由该接口触发”。用户随后在绑定钉钉页面发现 10 月 20 日的导出记录,使导出入口成为重点定位对象。
三、理论:怎样读这次执行计划
3.1 先看结构和筛选字段
只读查看:
SHOW CREATE TABLE t_abs_org_basic;
SHOW INDEX FROM t_abs_org_basic;表结构中的关键字段和索引:
| 字段或索引 | 定义 | 本次用途 |
|---|---|---|
| branch_org_code | varchar(64),主键 | IN 筛选条件 |
| branch_org_name | varchar(256) | 回填机构名称 |
| branch_org_manage_parent_code | varchar(64) | 本条 SQL 返回,但名称补全不使用 |
| branch_org_manage_path | text | 本条 SQL 返回,但名称补全不使用 |
| PRIMARY | branch_org_code | 主键查找 |
| IX_BRANCH_ORG_PATH | branch_org_manage_path 前 255 个字符 | 不承担这次 IN 条件筛选 |
SQL 结构如下,IN 列表按实际样本填入:
SELECT
branch_org_code,
branch_org_name,
branch_org_manage_parent_code,
branch_org_manage_path
FROM t_abs_org_basic
WHERE branch_org_code IN (
'236229220005', '216229220000'
-- 此处仅示意参数形式,实测分别使用 42 和 3846 个编码
);3.2 EXPLAIN 与 EXPLAIN ANALYZE 的区别
普通 EXPLAIN 查看估算访问方式、索引与行数;EXPLAIN ANALYZE 会实际执行 SELECT,展示实际行数、迭代器耗时和循环次数。
| 内容 | 含义 |
|---|---|
| type / 访问节点 | 范围扫描、全表扫描等访问方式 |
| key / using PRIMARY | 选择的索引 |
| cost | 估算成本,不是毫秒 |
| 估算 rows | 优化器估算行数 |
| actual 中的 rows | 迭代器实际输出行数,不直接等于慢日志 Rows_examined |
| actual time=a..b | 首行和全部行耗时,MySQL 中单位为毫秒 |
| loops | 循环次数;多循环时,时间是每次循环的平均值 |
父节点时间包含子节点时间,不能逐层相加;Filter 表示条件检查,不等于索引失效。迭代器时间也不能替代应用端完整耗时。
3.3 为什么大 IN 可能转成全表扫描
按 MySQL 的成本模型理解:小批主键查找通常适合索引访问;当匹配记录增多,反复定位大量数据页可能比扫描一遍表更贵,因此优化器可能选择全表扫描。
这没有“IN 超过 1000 或 2000 就不走索引”的固定规则,还取决于表大小、命中比例、统计信息和配置。原生 MySQL 也可能因范围优化器内存限制放弃范围方案,需要结合普通 EXPLAIN 后立即执行的 SHOW WARNINGS 判断;本案例未证明这一原因,更不能直接套用于 OceanBase。
四、实践:从 SQL 追到导出入口
4.1 两组执行计划对比
42 个编码样本:
-> Filter: (branch_org_code in (...))
(cost=50.47 rows=42)
(actual time=2.556..10.013 rows=42 loops=1)
-> Index range scan on t_abs_org_basic using PRIMARY
(cost=50.47 rows=42)
(actual time=2.549..9.973 rows=42 loops=1)3846 个不同编码样本:
-> Filter: (branch_org_code in (...))
(cost=3094.80 rows=3846)
(actual time=1.777..22.658 rows=3818 loops=1)
-> Table scan on t_abs_org_basic
(cost=3094.80 rows=27104)
(actual time=1.763..16.511 rows=26833 loops=1)| 指标 | 42 个编码 | 3846 个编码 |
|---|---|---|
| 访问方式 | 主键范围扫描 | 全表扫描 |
| 底层实际输出行数 | 42 | 26833 |
| 最终返回行数 | 42 | 3818 |
| 最上层迭代器耗时 | 10.013 毫秒 | 22.658 毫秒 |
第二组返回约全表 14.2% 的记录。已证实计划发生变化,但未证明“全表扫描就是生产慢查的全部原因”。约 23 毫秒的这次实测不能当作历史生产耗时,也不能把小样本时间线性放大推算。
4.2 精确匹配 SQL
项目:aboss-sso-support。
Mapper:BasicOrgMapper.selectByCodes,位于:
aboss-sso-support-infrastructure/src/main/java/com/aliyun/fsi/insurance/sso/support/domainobject/mapper/BasicOrgMapper.java
该方法明确选择四个字段,按机构编码 IN 查询,与用户提供的 SQL 结构一致。
4.3 导出调用链
接口:
POST /platform/api/aboss/sso/export-account-binding-data调用链:
AccountWithThirdPartyFacadeController.exportAccountBindingData
→ AccountWithThirdPartyFacadeImpl.exportAccountBindingData
→ ExportAccountBindingDataExe.execute(异步导出)
→ AccountGatewayImpl.queryByAccount
→ 查询一批账号
→ AccountGatewayImpl.writeOrgName
→ BasicOrgGatewayImpl.queryBasicOrgsByCodes
→ BasicOrgMapper.selectByCodes
→ 机构编码 IN 查询writeOrgName 的关键逻辑:
List<String> orgCodes = accountDOList.stream()
.flatMap(a -> Stream.of(a.getBranchOrgCode(), a.getOperatingOrgCode()))
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<String, BasicOrg> orgMap = basicOrgGateway.queryBasicOrgsByCodes(orgCodes)
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(
BasicOrg::getBranchOrgCode, o -> o, (a, b) -> a));这是现有方法的逻辑摘录。它收集归属机构和操作机构,不是只收集 branchOrgCode。已有 distinct,不能把“新增去重”当成本次优化成果。
不传机构参数仍经过 authorizeOrgPaths 计算权限范围,不能笼统描述为所有用户都默认导出全部账号。
4.4 问题落在批次规模
原导出程序每批查询 5000 个账号。每个账号最多提供两个机构编码,去重前最多 10000 个;当账号覆盖较多机构时,去重后仍可能有五六千个值,与截图结果规模吻合。
另外,代码为获取总数先查询第一页,进入循环又从第一页开始,存在第一页及对应机构补全重复读取。这是本次发现的后续优化点,尚未修改,不能据四条记录推断重复查询就是四条慢查的来源。
五、最终优化与方案取舍
5.1 本轮采用:每批账号从 5000 降到 2000
修改文件:
aboss-sso-support-app/src/main/java/com/aliyun/fsi/insurance/sso/support/executor/ExportAccountBindingDataExe.java
// 修改前
int pageSize = 5000;
// 修改后
int pageSize = 2000;后续循环继续按相同分页大小导出全部符合条件的账号,并不是只导出前 2000 个。
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 单批账号上限 | 5000 | 2000 |
| 两类机构编码去重前上限 | 10000 | 4000 |
| 单批账号数量上限降幅 | — | 60% |
| 机构 SQL 耗时、整体导出耗时 | 无优化后数据 | 待验证 |
选择理由是修改范围小,直接收紧上游批次规模。代价是查询批次数增多,同一机构可能跨批重复查询,所以单批变小不代表整体一定变快,也不能保证恢复主键扫描。
当前仅核对了一行差异和格式,未运行测试、部署或取得优化后生产数据。
5.2 备选:每次导出加载一次全量机构名称 Map
用户提出的备选流程:
每次导出开始
→ 查询全部机构的编码与名称
→ 构建本次导出专用 Map<String, String>
→ 所有账号批次复用
→ 导出结束释放仅在评估后实施的只读查询形式:
SELECT branch_org_code, branch_org_name
FROM t_abs_org_basic;该方案可避免每批重复构建大 IN、重复读取相同机构,但每次导出需承担全量加载和内存成本,且整个任务内机构名称基于加载时的数据。还需要确认租户、权限及数据隔离约束。
不能直接把共用 writeOrgName 改成每次读取全部机构。 否则普通分页也会全量读取,导出每批还会重复加载,失去复用价值。本轮未采用、未实现此方案。
5.3 其他后续优化
- 名称补全链路只使用编码与名称,可设计专用的两字段查询,避免读取未使用的父机构字段和 TEXT 路径;不直接修改其他可能需要完整字段的共用方法。
- 复用首次查询的第一页数据,避免第一页重复读取。
- 检查导出分页的稳定排序,验证缩小批次后没有漏导、重复导出等问题;并发数据变化下不能默认多页结果是同一快照。
这些均为待评估项,不计入本轮已完成改动。
5.4 如何验证收益
使用相同账号条件、相同数据规模和可比环境,对比修改前后的:
- 导出记录数、账号标识集合和机构名称回填是否一致。
- 每批账号数、去重机构数、SQL 执行次数与执行计划。
- 单次 SQL 平均及尾部耗时、整体导出耗时、数据库 CPU/IO。
- 导出任务内存占用和并发导出时的表现。
生产平台响应时间先核对单位。保持历史慢查、当前复测和优化后数据分开记录;没有优化后数据时,不写“从 2 秒降到 20 毫秒”或“性能提升 90%”。
六、面试时怎么说
6.1 回答路线
按“业务现象 → SQL 和计划 → 调用链 → 批次放大机制 → 方案取舍 → 验证状态”回答。主线是控制批量读取规模,而不是把大 IN 简单归类为索引失效。
6.2 30 秒版本
我排查过一次 SSO 账号绑定导出的慢 SQL。它不是简单缺索引,而是导出每批查 5000 个账号,再把归属机构和操作机构合并去重,生成几千个值的 IN 查询。我结合执行计划和代码调用链,发现小批量走主键,大批量样本转成全表扫描。本轮先把账号批次改成 2000,控制单次查询规模;同时评估过导出内复用机构名称 Map。当前批次修改已经完成,实际收益还需要同规模验证。
6.3 1~2 分钟版本
当时运维平台报了 SSO 的机构查询慢 SQL,返回机构记录有五六千行。我先检查表结构,发现筛选字段本来就是主键,所以没有直接加索引。
接着对比执行计划:42 个编码的小样本走主键范围扫描,3846 个编码的样本转成全表扫描,扫描了约 2.7 万行。但大样本这次复测的迭代器时间只有约 23 毫秒,我没有把它当成历史慢查耗时,也没有直接认定全表扫描就是唯一根因。
随后我沿代码往上追,找到机构名称回填的共用方法。普通分页查 10 个账号最多只有 20 个机构编码,不符合几千行结果。结合用户在绑定钉钉页面发现的同日导出记录,重点定位到导出入口:后端原来固定每批查 5000 个账号,合并两个机构字段后,即使去重,仍可能产生几千个机构条件。
最终本轮采用小范围修改,把账号批次从 5000 降为 2000,目标是收紧单次查询规模。另一个方案是每次导出只查一次全部机构编码和名称,放在本次任务的 Map 中复用,但不能把所有普通查询都改成全表读取。还发现第一页重复读取和查询了未使用的 TEXT 字段,这些列为后续优化。
当前已经完成的是批次数量修改,生产收益还没有验证。我会对比单次 SQL、整体导出耗时和结果完整性,避免出现单批变快、总耗时反而增加的情况。
6.4 高频追问
为什么没有新增索引?
筛选字段已经是主键。本次先从查询规模和调用方式定位,重复加索引不能直接解决批量读取过大的问题。
为什么 IN 多了就不走索引?
不是达到固定数量就失效。按 MySQL 的成本模型,大量分散查找可能比扫描一遍小表更贵;具体也可能受统计或优化器资源限制影响,需结合版本、计划和警告确认。
为什么选 2000?
这是本次选择的保守批次,不是数据库规定或已经证明的最优值。它把单批账号上限降低 60%,后续应通过相同规模测试确定是否合适。
2000 个账号能保证 IN 不超过 2000 吗?
不能。每个账号最多贡献两个机构编码,所以去重前最多 4000 个;如果目标是机构 IN 上限 2000,需要单独对机构编码分批,那是另一种改动。
为什么不直接查全量机构放 Map?
导出覆盖机构多、批次数多时值得评估,但应每次导出只加载一次,并只取名称补全所需字段。还要评估全量加载成本、内存、隔离要求和名称更新时效。
优化后提升多少?
目前没有优化后的对照数据,不能给出真实提升比例。确定的是批次上限变化和代码调用机制;收益要以同规模验证与上线观测为准。
生产不是 OceanBase 吗?
是,治理通知标记为 OceanBase。执行计划分析在这次讨论中按 MySQL 思路展开,但我不会把 MySQL 优化器机制当作 OceanBase 已确认根因;工程上的批次控制也要在真实生产引擎上验证。