臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、案例结论与当前状态

本次排查的是 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。截图展示四条记录:

记录执行次数平均响应时间最大响应时间返回行数
112227.962227.965113
212233.162233.165118
312711.312711.315633
412726.992726.995634

截图没有显示响应时间单位。若平台单位是毫秒,则约为 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_codevarchar(64),主键IN 筛选条件
branch_org_namevarchar(256)回填机构名称
branch_org_manage_parent_codevarchar(64)本条 SQL 返回,但名称补全不使用
branch_org_manage_pathtext本条 SQL 返回,但名称补全不使用
PRIMARYbranch_org_code主键查找
IX_BRANCH_ORG_PATHbranch_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 个编码
访问方式主键范围扫描全表扫描
底层实际输出行数4226833
最终返回行数423818
最上层迭代器耗时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 个。

项目修改前修改后
单批账号上限50002000
两类机构编码去重前上限100004000
单批账号数量上限降幅—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 如何验证收益

使用相同账号条件、相同数据规模和可比环境,对比修改前后的:

  1. 导出记录数、账号标识集合和机构名称回填是否一致。
  2. 每批账号数、去重机构数、SQL 执行次数与执行计划。
  3. 单次 SQL 平均及尾部耗时、整体导出耗时、数据库 CPU/IO。
  4. 导出任务内存占用和并发导出时的表现。

生产平台响应时间先核对单位。保持历史慢查、当前复测和优化后数据分开记录;没有优化后数据时,不写“从 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 已确认根因;工程上的批次控制也要在真实生产引擎上验证。

七、参考资料