03-业务单元匹配与OOM优化
对应代码: 集团内不同事业群/部门使用不同的**业务单元**进行组织管理。用户可在系统中配置匹配规则,基于以下 5 个维度将商品自动归类到业务单元: 规则按优先级排序,一个商品可匹配到多个业务单元(逗号分隔)。 1. **全量匹配**:用户
所属领域:大数据量处理 / OOM 排查 / 流式查询 / SQL 优化 / 性能调优
对应代码:
- 核心匹配逻辑:
E:\WorkSpace\codo\creeks\creeks-sales\server\src\main\java\com\codo\tech\creeks\sales\auto\service\impl\BusinessUnitRuleServiceImpl.java- Binlog 同步 + 增量匹配:
E:\WorkSpace\codo\creeks\creeks-sales\server\src\main\java\com\codo\tech\creeks\sales\job\Impl\GoodsJobServiceImpl.java- 自定义 ResultHandler:
E:\WorkSpace\codo\creeks\creeks-sales\server\src\main\java\com\codo\tech\creeks\sales\handler\cursor\GoodsResultHandler.java
一、背景与痛点
业务需求
集团内不同事业群/部门使用不同的业务单元进行组织管理。用户可在系统中配置匹配规则,基于以下 5 个维度将商品自动归类到业务单元:
| 维度 | 说明 |
|---|---|
| 平台 | IN / NOT IN / ALL |
| 品牌 | IN / NOT IN / ALL |
| 分类 | IN / NOT IN / ALL(需递归展开子分类) |
| 店铺 | IN / NOT IN / ALL |
| 站点 | IN / NOT IN / ALL |
规则按优先级排序,一个商品可匹配到多个业务单元(逗号分隔)。
两种匹配场景
- 全量匹配:用户编辑规则后手动触发,扫描企业下所有已配对商品重新匹配
- 增量匹配:新增/修改商品时自动触发(通过 Binlog CDC 同步到 ES 时判断是否需要匹配)
核心痛点
商品表(mall_goods)数据量超 1 亿行,单个企业可能有 2000W+ 商品。全量匹配任务需要遍历企业下所有商品,初期面临两大挑战:
- 性能瓶颈:大表遍历效率低,早期方案处理 2000W 数据耗时 超 2 小时
- OOM 问题:尝试使用游标/流式查询时,偶发 OutOfMemoryError,Pod 频繁重启
二、技术方案演进
整个优化过程经历了 5 个版本迭代,逐步定位问题根因并找到最优方案:
版本 1:minId + LIMIT 分页
SELECT * FROM mall_goods
WHERE status = 1 AND enterprise_id = ? AND id > #{minId}
ORDER BY id ASC
LIMIT 1000
方案思路:利用主键索引避免深翻页,每次查询 1000 条,记录最大 id 作为下一页起点。
问题暴露:
- 测试环境数据量小,首屏 100-300ms,看起来效率不错
- 但生产环境中,如果符合条件的第一条记录 id = 2000W,MySQL 必须扫描前 2000W 行才能找到第一个匹配的记录
WHERE enterprise_id = X AND store_id = Y AND id > minId条件下,MySQL 无法有效利用复合索引(因为enterprise_id和store_id不在主键索引前列),导致回表扫描成本极高- 部分翻页耗时 7~10 秒,总处理时间 2 小时+
版本 2:ES 遍历 + MySQL 回查
通过 ES 查询商品 ID 列表(每次 2000 条),再根据 ID 回查 MySQL 获取完整数据。
问题:
- 仍需返查 MySQL,网络往返成本高
- 大量数据遍历导致 ES 内存不足重启
- 查询耗时约 2s/批
版本 3:MyBatis-Plus 游标查询(伪流式)
// 旧方式:使用 MP 自带的 cursor() 方法
try (Cursor<Goods> cursor = goodsMapper.selectCursor(wrapper)) {
for (Goods goods : cursor) {
// 匹配业务单元
}
}
问题:查询速度极快(几十 ms/批),但偶发 OOM,Pod 频繁重启。
版本 4:按店铺分组 + 游标查询
按企业入驻的店铺进行分组,每个店铺独立执行游标遍历,减少跨店铺数据扫描。
问题:查询速度更快,但仍存在 OOM。
最终版:按店铺分组 + 自定义 ResultHandler(真流式)
根因定位:经过内存分析与 MyBatis/MySQL JDBC 源码排查,确认 MyBatis-Plus 的 cursor() 方法是**"伪流式"**——MySQL JDBC 驱动默认未设置 fetchSize = Integer.MIN_VALUE,导致驱动使用 ResultSetRowsStatic(一次性将所有行加载到内存 ArrayList),而不是 ResultSetRowsStreaming(逐行从 MySQL 读取)。
解决方案:绕过 MP 的 cursor 方法,直接使用 SqlSessionFactory 创建独立 Session,配合自定义 ResultHandler 和 fetchSize="-2147483648",实现真正的逐行流式处理。
三、实现细节
3.1 OOM 根因分析 — MyBatis-Plus "伪流式"源码级排查
这是本次优化最核心的技术发现。理解这个根因需要从 MySQL JDBC 驱动 → MyBatis 框架 两层分析。
MySQL JDBC 驱动层
JDBC 驱动与 MySQL 建立 TCP 连接后,执行 SQL 并将结果封装为 ResultSet 返回。ResultSet 内部委托给 ResultSetRows 对象处理数据,有两个实现:
| 实现类 | 行为 | 内存占用 |
|---|---|---|
ResultSetRowsStatic |
一次性 while 循环读取所有行到 ArrayList | O(全量结果集) — OOM 根因 |
ResultSetRowsStreaming |
等待调用 next() 时才从 MySQL 逐行读取 |
O(1) — 真流式 |
决定是否启用流式的关键方法(com.mysql.cj.NativeMessageBuilder):
protected boolean createStreamingResultSet() {
return ((this.query.getResultType() == Type.FORWARD_ONLY)
&& (this.resultSetConcurrency == ResultSet.CONCUR_READ_ONLY)
&& (this.query.getResultFetchSize() == Integer.MIN_VALUE)); // ← 关键!
}
必须设置 fetchSize = Integer.MIN_VALUE(-2147483648),底层才会创建 ResultSetRowsStreaming。
MyBatis 框架层
MyBatis 执行查询后获取 ResultSet,对每一行回调 ResultHandler:
// DefaultResultSetHandler.handleResultSet
if (resultHandler == null) {
// 默认处理器:将所有结果聚集到 ArrayList
DefaultResultHandler defaultResultHandler = new DefaultResultHandler();
handleRowValues(rsw, resultMap, defaultResultHandler, rowBounds, null);
multipleResults.add(defaultResultHandler.getResultList()); // ← OOM
} else {
// 自定义处理器:逐行处理,不聚集
handleRowValues(rsw, resultMap, resultHandler, rowBounds, null);
}
MyBatis-Plus 的 cursor() 方法做了什么
// MyBatis-Plus 的 cursor() 最终走这里
Cursor<Goods> cursor = sqlSession.selectCursor(...);
它确实传入了自定义 handler 给 MyBatis,MyBatis 层的 ResultSet 处理确实是逐行的。**但 JDBC 驱动层是否流式取决于 fetchSize — MP 的 cursor 方法没有设置 **fetchSize = Integer.MIN_VALUE,所以 JDBC 驱动返回的是 ResultSetRowsStatic,所有数据在 TCP 层就被一次性拉到了 Java 进程内存中。
这就是"伪流式"的真相:MyBatis 框架以为是流式(逐行回调),但底层 JDBC 驱动已经把全量结果集加载到内存了。
验证方法
Debug 查看 ResultSet 内部的 resultRows 类型:
- 如果是
ResultSetRowsStreaming→ 真流式 - 如果是
ResultSetRowsStatic→ 伪流式(OOM)
3.2 真流式实现方案
Mapper 层
<select id="listGoodsIdCursor" fetchSize="-2147483648"
resultMap="baseResultMap">
SELECT * FROM mall_goods
WHERE enterprise_id = #{enterpriseId}
AND status = 1
AND store_id = #{storeId}
</select>
fetchSize="-2147483648"** 是必须写的,否则 JDBC 层不会走流式。**
自定义 GoodsResultHandler
public class GoodsResultHandler implements ResultHandler<Goods> {
public int PAGE_SIZE = 1000;
public List<Goods> updateGoods = new ArrayList<>();
// ... 依赖注入
@Override
public void handleResult(ResultContext<? extends Goods> ctx) {
final Goods goods = ctx.getResultObject();
matchTotal.addAndGet(1);
String businessUnit = businessUnitRuleService.matchBusinessUnitRule(rules, goods);
if (不匹配原业务单元) {
goods.setIsToEs(0);
goods.setBusinessUnit(businessUnit);
updateGoods.add(goods);
}
// 每 1000 条批量更新
if (updateGoods.size() > PAGE_SIZE) {
this.handle();
}
}
public void handle() {
if (!updateGoods.isEmpty()) {
// 按业务单元分组批量更新(关键优化,见 3.3)
this.groupByBusinessUnitAndUpdate(storeId, updateGoods);
// 每批匹配完尝试触发 ES 同步
goodsJobService.goodsSyncToES(false);
updateGoods.clear();
}
}
}
调用方(避免长事务)
public void matchEnterpriseAllGoodsWithCursor(...) {
// 直接使用 SqlSessionFactory,不使用 @Transactional
// 避免将整个流式查询包裹在一个长事务中
SqlSession sqlSession = sqlSessionFactory.openSession();
try {
GoodsResultHandler handler = new GoodsResultHandler(...);
sqlSession.getMapper(GoodsMapper.class).listGoodsIdCursor(wrapper, handler);
handler.handle(); // 处理最后一批
} finally {
sqlSession.close();
}
}
关键设计点:不使用 @Transactional 注解包裹整个流式查询,避免产生长事务(2000W 行数据可能跑 15 分钟)。每批更新操作独立提交事务。
3.3 SQL 更新优化
在优化过程中,还发现了更新阶段的性能瓶颈。经历了 4 轮迭代:
| 迭代 | 方案 | 耗时(2000 条) |
|---|---|---|
| 改造 1 | 原字段 varchar 直接更新 | ~2s |
| 改造 2 | 业务单元字段拆分到独立表 | ~1.4s |
| 改造 3 | 按业务单元结果分组 + 主键 IN | ~100ms |
| 改造 4 | 按店铺分组 + 业务单元分组 + 主键 IN | ~500ms(含查询+匹配+更新全流程) |
最终 SQL:
UPDATE mall_goods
SET business_unit = #{businessUnit}, is_to_es = 0, update_at = #{updateTime}
WHERE id IN (#{goods.id1}, #{goods.id2}, ...)
AND store_id = #{storeId}
按匹配结果分组后批量更新,相同业务单元的商品一次性更新,大幅减少 UPDATE 语句数量。
3.4 增量匹配 — Binlog CDC 集成
在 Maxwell CDC 监听 MySQL Binlog 同步商品到 ES 的流程中,嵌入了业务单元匹配逻辑(GoodsJobServiceImpl.refreshCacheGoodList):
CDC 捕获变更 → 收集待同步商品 → 判断是否需要匹配业务单元
├── 不需要 → 直接同步 ES
└── 需要 → 按企业分组 → 获取分布式锁 → 逐条匹配规则
→ 批量更新 MySQL → 同步 ES
通过 needCheckBusinessUnit(CdcData) 方法智能判断是否需要重新匹配:
- INSERT:新增商品必须匹配
- UPDATE:只有平台、品牌、分类、销售、企业等关键字段变化时才重新匹配
匹配时通过 LockService 获取企业级分布式锁,与全量匹配互斥,保证数据一致性。
3.5 匹配规则引擎
private boolean doMatchRule(BusinessUnitRuleInfo ruleInfo, Goods goods) {
return calculateMatch(ruleInfo.getPlatform(), String.valueOf(goods.getPlatformId()))
&& calculateMatch(ruleInfo.getBrand(), String.valueOf(goods.getBrandId()))
&& calculateMatch(ruleInfo.getCategory(), String.valueOf(goods.getCategoryId()))
&& calculateMatch(ruleInfo.getStore(), goods.getStoreName())
&& calculateMatch(ruleInfo.getNation(), goods.getNationName());
}
private boolean calculateMatch(BusinessUnitRuleDetail detail, String value) {
if (ALL 或 value 为空) return true;
if (IN) return ruleValueList.contains(value);
if (NOT_IN) return !ruleValueList.contains(value);
return false;
}
分类维度需要特殊处理:通过 gRPC 调用商品服务获取分类树,递归展开所有子分类 ID。
四、面试高频问题详解
问题 1:minId 翻页查询效率和流式查询相比差很多吗?为什么选择流式查询?
答案:差距巨大,取决于数据分布。
| 场景 | minId 分页 | 流式查询 |
|---|---|---|
| 首屏(minId 很小) | 100-300ms | 几十 ms |
| 中段(minId 到了百万级) | 500ms-2s | 几十 ms(不变) |
| 尾部(需要跳过大量不匹配数据) | 7-10s/页 | 几十 ms(不变) |
| 总量 2000W 处理 | 2 小时+ | 15 分钟 |
minId 翻页的本质缺陷:
-- minId 翻页看似走了主键索引,但实际上 WHERE 条件是:
WHERE enterprise_id = X AND store_id = Y AND id > minId ORDER BY id LIMIT 1000
-- MySQL 的执行计划:
-- 如果 enterprise_id + store_id 没有合适的复合索引
-- → 走 PRIMARY KEY (id) 全表扫描
-- → 从 id > minId 开始逐行扫描,检查 enterprise_id 和 store_id 是否匹配
-- → 找到 1000 条匹配的记录后返回
-- 如果匹配密度低(如某企业只占总数据的 1%),实际扫描行数可能是 1000 的 100 倍
流式查询的优势:
-- 同样的 WHERE 条件,但流式查询的优势不在于单页效率
-- 而在于:
-- 1. 没有"跳过"开销:MySQL 游标从扫描到的第一条匹配记录开始逐行返回
-- (minId 每次都要从 minId 位置重新开始扫描 + 过滤)
-- 2. 纯顺序 I/O:游标位置在 MySQL 服务端维护,不需要每次重新 seek
-- 3. 时间复杂度稳定:每行处理时间恒定,不随数据量增长而退化
一句话总结:minId 翻页的时间复杂度是 O(累计扫描行数),随翻页次数递增;流式查询是 O(总匹配行数),始终保持线性。数据量越大、匹配密度越低,差距越显著。
问题 2:流式查询不是会有长连接吗?对 MySQL 的内存占用会很大吧?
答案:不会。流式查询在 MySQL 服务端的内存占用几乎为 0。
MySQL JDBC 驱动设置 fetchSize = Integer.MIN_VALUE 后,底层使用 ResultSetRowsStreaming:
MySQL 服务端:
├── 执行查询,生成结果集(与普通查询一样)
├── 但**不将结果集发送给客户端**
├── 等待客户端调用 resultSet.next() 时,**逐行发送**
└── MySQL 服务端内存:只维护游标位置(指针),不缓存结果集
Java 客户端:
├── TCP 连接保持(长连接),但只接收当前行数据
├── 自定义 ResultHandler 逐行处理 + 分批缓冲(1000 条)
└── Java 进程内存:O(缓冲批次大小),而非 O(全量结果集)
MySQL 服务端内存占用分析:
- 普通查询:MySQL 需要将所有结果行序列化后发送到网络缓冲区 → O(全量结果集)
- 流式查询:MySQL 执行完查询后只维护游标位置,客户端拉一行发一行 → O(1)
真正的风险在 Java 端:
- 如果 ResultHandler 处理逻辑太慢(如每行都做一次 HTTP 调用),MySQL 发送的数据会堆积在 TCP 发送缓冲区
- TCP 缓冲区满后,MySQL 发送线程会 阻塞等待,占用一个 MySQL 连接线程
- 连接占用时间过长可能触发 MySQL
wait_timeout或连接池耗尽
我们的方案规避了这个风险:
GoodsResultHandler只做内存匹配(纯 CPU 计算,无 IO)- 每 1000 条批量更新(
PAGE_SIZE = 1000),批处理时间极短 - 整个流式查询内存占用控制在 200MB 以内
问题 3:使用 minId 翻页不会 OOM 吧?
答案:minId 翻页本身不会 OOM,但这恰恰不是本次 OOM 的原因。
| 查询方式 | 是否 OOM | 原因 |
|---|---|---|
| minId 分页(LIMIT 1000) | 不会 | 每次只加载 1000 行到内存 |
MyBatis-Plus cursor() (旧版) |
会 OOM | JDBC 层 ResultSetRowsStatic 全量加载 |
自定义 fetchSize=-2147483648 + ResultHandler |
不会 | JDBC 层 ResultSetRowsStreaming 逐行加载 |
本次 OOM 的根因:
我们使用的是 MyBatis-Plus 的 cursor() 方法,以为是流式查询,但实际上:
- MyBatis-Plus 的
cursor()**没有设置 **fetchSize = Integer.MIN_VALUE - MySQL JDBC 驱动默认使用
ResultSetRowsStatic - 所有匹配的数据在 TCP 层被一次性加载到 Java 进程内存的 ArrayList 中
- 2000W 条商品数据(每条包含几十个字段)→ 数 GB 内存 → OOM
- Pod 因 OOM 被 K8s 重启 → 重启后再次执行 → 再次 OOM → 循环
为什么 minId 不 OOM 但仍然被淘汰?
- minId 不 OOM 是因为每次只
LIMIT 1000 - 但太慢(2 小时),无法满足业务需求
- 流式查询又快又不 OOM(15 分钟,200MB 内存),是最终选择
五、成果与数据
| 指标 | 优化前(minId 分页) | 优化前(伪流式 cursor) | 优化后(真流式) |
|---|---|---|---|
| 2000W 数据处理耗时 | 2 小时+ | 查询快但 OOM 无法完成 | 15 分钟 |
| Java 进程内存 | 低(分批 1000 条) | 数 GB → OOM | < 200MB |
| Pod 稳定性 | 稳定但太慢 | 频繁 OOM 重启 | 稳定 |
| 更新 2000 条耗时 | ~2s | - | ~100ms(分组批量更新) |
| 指标 | 数据 |
|---|---|
| 商品表总量 | 1 亿+ |
| 单企业最大商品量 | 2000W+ |
| 优化后处理时间 | 2 小时 → 15 分钟 |
| 内存占用 | 数 GB → < 200MB |
| 排查深度 | 从应用层深入到 MyBatis + MySQL JDBC 源码级定位 |
| 增量匹配 | CDC Binlog 实时触发,关键字段变更自动重新匹配 |
| 规则引擎 | 5 维度 × 3 操作符(IN / NOT IN / ALL),支持分类递归展开 |
六、简历提炼版
版本A:精简版(2 行,适合合并到性能优化模块)
主导商品业务单元匹配系统从 0 到 1 落地,覆盖全量匹配(手动触发)与增量匹配(CDC Binlog 触发)两种场景。通过源码级排查定位 MyBatis-Plus 游标查询为"伪流式"(JDBC 驱动未设
fetchSize导致全量加载),改用fetchSize=Integer.MIN_VALUE+ 自定义ResultHandler实现真流式处理,2000W 数据处理从 2 小时降至 15 分钟,内存从数 GB 降至 200MB 内。
版本B:完整版(3 段,适合独立亮点)
业务单元匹配系统:设计并落地商品自动归类到业务单元的完整链路。支持 5 维度(平台/品牌/分类/店铺/站点)× 3 操作符(IN/NOT_IN/ALL)的规则引擎,分类维度支持递归展开子分类。全量匹配按企业-店铺分组扫描,增量匹配通过 CDC Binlog 在 ES 同步流程中嵌入匹配逻辑,关键字段变更自动触发重匹配,通过分布式锁保证全量/增量互斥。
2000W 级数据 OOM 排查与优化:全量匹配任务处理企业下 2000W+ 商品时偶发 OOM。通过内存分析与 MyBatis + MySQL JDBC 源码级排查,确认 MyBatis-Plus
cursor()为"伪流式"——默认未设fetchSize = Integer.MIN_VALUE,JDBC 驱动使用ResultSetRowsStatic一次性将全量结果集加载至内存。解决方案:绕过 MP 直接使用SqlSessionFactory+fetchSize=-2147483648+ 自定义ResultHandler实现真流式逐行处理,按批次缓冲(1000 条)+ 按业务单元分组批量更新。优化后:处理 2000W 数据从 2 小时降至 15 分钟,内存从数 GB 降至 200MB 内,Pod 不再 OOM 重启。SQL 更新优化:将业务单元字段拆分到独立表,按匹配结果分组后使用主键 IN 批量更新,2000 条更新耗时从 2s 降至 100ms。
版本C:面试口述要点(备忘)
- OOM 根因一句话:MP 的 cursor 没设 fetchSize,JDBC 层一次性全量加载(ResultSetRowsStatic),不是真正的流式(ResultSetRowsStreaming)
- 验证方法:Debug 看 ResultSet 内部的 resultRows 类型
- minId vs 流式:minId 慢在"跳过开销"——每次从 minId 位置重新扫描 + 过滤不匹配行;流式是纯顺序 I/O,游标位置在 MySQL 服务端维护
- MySQL 内存:流式查询在 MySQL 端几乎无内存开销(只维护游标位置),内存风险在 Java 端(TCP 缓冲区),但我们的 Handler 处理快,不会堆积
- minId 不会 OOM:因为 LIMIT 1000,但太慢所以不用
- 关键数字:2 小时 → 15 分钟,数 GB → 200MB,1 亿商品表
七、深度面试问题补充
问题 4:minId + 按店铺分批 + 分组批量更新优化后,效率是否和流式查询差不多?还需要流式查询吗?
答案:优化后的 minId 方案确实大幅缩短了时间(从 2 小时降到约 3040 分钟),但流式查询仍然更快(15 分钟),差距在 23 倍。
先看你的 minId 优化做了什么:
优化前(版本1):
SELECT * FROM mall_goods WHERE enterprise_id = ? AND status = 1 AND id > #{minId} LIMIT 1000
→ 没有 store_id 过滤,全企业扫描,匹配密度低 → 大量无效扫描
优化后(版本4):
SELECT * FROM mall_goods WHERE enterprise_id = ? AND store_id = ? AND status = 1 AND id > #{minId} LIMIT 1000
→ 按店铺分批,匹配密度大幅提升
→ UPDATE 也从逐条改为 batchUpdateGroupByBusinessUnit(主键 IN + 按业务单元分组)
这个优化确实解决了匹配密度低导致的无效扫描问题。但 minId 方案有一个结构性开销是无法消除的:
minId 的结构性开销
假设某店铺有 200W 条商品,每页 1000 条,需要 2000 次 SQL 查询:
每次查询的固定开销:
├── 网络往返(RTT):~2-5ms(同机房)
├── SQL 解析 + 优化器:~0.5-1ms
├── B+ 树索引 seek(从根到叶):~0.1-0.5ms(3-4 层 I/O)
├── 扫描 1000 行 + 序列化:~3-5ms
└── 合计:~6-12ms / 页
2000 页总计:2000 × 8ms ≈ 16 秒(纯查询)
看起来不多?但这只是理想情况。实际生产中:
1. 索引不一定完美覆盖:如果没有 (enterprise_id, store_id, status, id) 的精确复合索引,
MySQL 可能走主键索引 + 回表,每页实际扫描行数远超 1000
2. Buffer Pool 冷启动:首次查询时数据页不在内存,需要磁盘 I/O
3. 连接池竞争:2000 次查询 = 2000 次获取/归还连接
4. 锁竞争:每次查询都要获取 InnoDB 的 read view
实测数据(你的生产环境):
| 方案 | 200W 行/店铺 | 10 个店铺 = 2000W |
|---|---|---|
| minId(无 store_id) | 12 分钟 | 2 小时 |
| minId(按店铺分批 + 优化 UPDATE) | 3-4 分钟 | 30-40 分钟 |
| 流式查询 | 1-1.5 分钟 | 15 分钟 |
流式查询为什么更快
流式查询的开销:
├── 网络往返:1 次(整个查询只有 1 次 SQL 发送)
├── SQL 解析 + 优化器:1 次
├── B+ 树索引 seek:1 次(只在第一行时 seek)
├── 后续行:纯顺序扫描 B+ 树叶子页(无 seek)
├── 数据传输:逐行通过 TCP,无批量序列化开销
└── 200W 行总计:~60-90 秒(纯查询)
核心差距在于:
| 维度 | minId(2000 页) | 流式(1 次查询) |
|---|---|---|
| SQL 解析次数 | 2000 次 | 1 次 |
| 索引 seek 次数 | 2000 次 × O(log N) | 1 次 |
| 网络往返次数 | 2000 次 | 1 次(后续行通过同一 TCP 连接推送) |
| I/O 模式 | 每页重新定位(随机 I/O) | 顺序扫描叶子页(顺序 I/O) |
| 连接池使用 | 2000 次获取/归还 | 1 次获取,持续使用 |
一句话总结:minId 优化后从 2 小时降到 30-40 分钟,但流式查询 15 分钟。差距从 8 倍缩小到 2-3 倍。流式查询的优势在于消除了 2000 次重复的 SQL 解析 + 索引 seek + 网络往返,这是 minId 方案无法消除的结构性开销。
问题 5:普通查询与流式查询对 MySQL 的 I/O 具体流程是怎样的?
普通查询(ResultSetRowsStatic)的完整 I/O 流程
Java 应用 MySQL 服务端
│ │
1. 发送 SQL ─────────┼──── TCP SYN/ACK ────────────────►│
│ SQL 文本 │
│ │ 2. 解析 SQL
│ │ 生成执行计划
│ │
│ │ 3. 打开 B+ 树游标
│ │ 定位第一条匹配行
│ │
│ │ 4. 逐行扫描:
│ │ ┌─ 读数据页(Buffer Pool 命中 → 内存读)
│ │ │ (Buffer Pool 未命中 → 磁盘读 → 加载到 BP)
│ │ ├─ 序列化为 MySQL 协议包
│ │ ├─ 写入 net_buffer(默认 16KB)
│ │ ├─ net_buffer 满 → flush 到 TCP 发送缓冲区(内核态)
│ │ └─ 继续下一行... 直到所有行处理完毕
│ │
│◄──── TCP 数据包(所有行)──────────│ 5. 发送完毕,关闭游标
│ │
6. JDBC 驱动接收 │ │
所有 TCP 包 │ │
反序列化为 │ │
ResultSetRowsStatic│ │
(ArrayList<Row>) │ │
│ │
7. 应用开始处理 │ │
resultSet.next() │ │
从 ArrayList 取行 │ │
关键特征:
- MySQL 服务端:一口气扫描所有行,序列化后通过 net_buffer → TCP 发送
- 网络层:所有数据包在 MySQL 发送完毕后才到达客户端(取决于 TCP 窗口大小,实际是流水线式的)
- JDBC 驱动:接收完所有数据后才返回 ResultSet 给应用
- 内存峰值:Java 堆中同时存在全量结果集的 ArrayList
流式查询(ResultSetRowsStreaming)的完整 I/O 流程
Java 应用 MySQL 服务端
│ │
1. 发送 SQL ─────────┼──── TCP ──────────────────────────►│
(fetchSize=MIN_VALUE) │
│ │ 2. 解析 SQL
│ │ 生成执行计划
│ │
│ │ 3. 打开 B+ 树游标
│ │ 定位第一条匹配行
│ │
│ │ 4. 读第 1 行:
│ │ 数据页 → 序列化 → net_buffer → TCP
│◄──── 第 1 行数据包 ───────────────│
│ │
5. JDBC 驱动接收 │ │ ← MySQL 线程阻塞等待
反序列化为 1 个 Row│ │ 客户端消费
返回给应用 │ │
│ │
6. 应用处理第 1 行 │ │
(匹配规则+缓冲) │ │
│ │
7. 应用调用 next() │ │
JDBC 从 TCP 读 │──── TCP ACK ──────────────────────►│
│ │ 8. 读第 2 行:
│ │ 数据页 → 序列化 → TCP
│◄──── 第 2 行数据包 ───────────────│
│ │
... 重复 5-8 直到所有行处理完毕 ... │
│ │
N. 最后一行处理完 │ │
JDBC 收到 EOF 包 │◄──── EOF ─────────────────────────│ 关闭游标
关键特征:
- MySQL 服务端:逐行发送,每发一行就等待客户端消费(实际上 TCP 有缓冲,不是严格一行一等,但效果类似)
- 网络层:数据包是交错的——MySQL 发一批,客户端消费一批,再发一批
- JDBC 驱动:每次 next() 从 TCP 缓冲区读一行,不聚集
- 内存峰值:Java 堆中只有当前行 + 应用缓冲区(1000 条)
两者的磁盘 I/O 对比
| 维度 | 普通查询 | 流式查询 |
|---|---|---|
| 数据页读取模式 | 连续扫描,Buffer Pool 预读生效 | 同样连续扫描,预读同样生效 |
| 数据页读取总量 | 相同(都要读所有匹配行的数据页) | 相同 |
| Buffer Pool 压力 | 短时间内大量页面涌入,可能挤出热点页 | 页面逐步涌入,LRU 有时间淘汰冷页 |
| undo log 影响 | 查询快速结束,read view 快速释放 | read view 持续时间长,可能阻塞 purge |
结论:磁盘 I/O 总量相同,但流式查询对 Buffer Pool 更友好(不会瞬间挤出热点页)。
问题 6:MySQL 对于 2000W 结果集的流式查询,怎么保证自身内存不爆?
答案:MySQL 服务端在流式查询中几乎不消耗额外内存,因为它不缓存结果集。
MySQL 内部的内存使用
MySQL 处理流式查询时的内存分配:
1. 线程栈(thread_stack):~256KB,固定
2. 排序缓冲区(sort_buffer_size):如果 ORDER BY 走索引则不需要
3. 读缓冲区(read_buffer_size):~128KB,用于顺序扫描
4. net_buffer(net_buffer_length):~16KB,序列化行数据的临时缓冲
5. InnoDB Buffer Pool:共享内存,不是每个查询独占
总计每个流式查询的独占内存:~400KB(固定,与结果集大小无关)
为什么不需要缓存结果集
MySQL 的 InnoDB 存储引擎使用 B+ 树索引,叶子节点按主键顺序链接。流式查询的游标就是一个指向 B+ 树叶子节点的指针:
B+ 树叶子层(按 id 排序):
[页1: id=1~500] → [页2: id=501~1000] → [页3: id=1001~1500] → ...
↑
游标位置(只是一个指针,不占额外内存)
每次客户端调用 next():
- MySQL 从游标当前位置读取一行
- 将行数据序列化到
net_buffer(16KB) net_buffer满了就 flush 到 TCP- 游标指针前移到下一行
- 如果当前页读完了,沿着叶子节点链表跳到下一页
整个过程中,MySQL 不需要把 2000W 行"存"在任何地方 — 数据本来就在 B+ 树的数据页里,游标只是一个遍历指针。
真正的内存风险:InnoDB Buffer Pool 抖动
虽然流式查询本身不消耗额外内存,但它会持续读取大量数据页到 Buffer Pool:
2000W 行 ÷ 每页约 500 行 = 约 40,000 个数据页 × 16KB/页 = 约 625MB
如果 Buffer Pool 只有 2GB,这 625MB 的冷数据会挤出 31% 的热点数据
→ 其他查询的缓存命中率下降
→ 表现为:流式查询期间,其他业务查询变慢
缓解措施:
- InnoDB 的 young/old 分区 LRU:新读入的页先放到 old 区(占 3/8),只有在 old 区存活超过
innodb_old_blocks_time(默认 1000ms)后才晋升到 young 区。流式查询的页面通常来不及晋升就被淘汰,不会挤出真正的热点页 - 生产环境 Buffer Pool 通常 8-16GB,625MB 的冲击可以承受
另一个隐患:MVCC undo log 膨胀
流式查询持续 15 分钟,期间 InnoDB 的 read view 一直保持打开:
流式查询开始时刻 T0 → 创建 read view(MVCC 快照)
→ 在 T0 之后的所有 UPDATE/DELETE 产生的 undo log 都不能被 purge
→ 如果这 15 分钟内有大量写操作,undo log 会持续增长
流式查询结束时刻 T15 → 释放 read view → purge 线程开始清理 undo log
在我们的场景中这个风险可控:
- 全量匹配任务在非高峰期执行
- 按店铺分批,每个店铺的流式查询时间 ~1-2 分钟(不是 15 分钟一口气)
- 商品表的写入频率相对低(主要是 CDC 增量更新)
问题 7:如果给 minId 查询加上完美的复合索引,还需要流式查询吗?
答案:即使有完美索引,流式查询仍然更快,但差距会缩小到 1.5-2 倍。
假设创建了完美索引:(enterprise_id, store_id, status, id)
-- minId 查询现在可以完美利用索引
SELECT * FROM mall_goods
WHERE enterprise_id = 1 AND store_id = 100 AND status = 1 AND id > #{minId}
ORDER BY id ASC
LIMIT 1000
-- 执行计划:Using index condition, 每页精确扫描 1000 行
此时 minId 每页的效率接近最优,但仍然有 2000 次 SQL 往返的固定开销:
| 开销项 | minId(2000 页) | 流式(1 次) | 差距 |
|---|---|---|---|
| SQL 解析 | 2000 × 0.5ms = 1s | 0.5ms | 2000x |
| 索引 seek | 2000 × 0.2ms = 0.4s | 0.2ms | 2000x |
| 网络 RTT | 2000 × 2ms = 4s | 2ms | 2000x |
| 数据扫描 | 相同 | 相同 | 1x |
| 固定开销合计 | ~5.4s | ~2.7ms | — |
5.4 秒的固定开销看起来不多,但乘以 10 个店铺就是 54 秒。加上实际的数据扫描和 UPDATE 时间,总差距在 1.5-2 倍。
结论:流式查询在任何情况下都比 minId 快,但如果索引完美 + 数据量不是特别大(比如只有 500W),差距可能只有几分钟,此时选择 minId 也是合理的(更简单、无长连接风险)。2000W 级别选流式是正确的。
问题 8:流式查询期间,这个 MySQL 连接能做其他事情吗?
答案:不能。流式查询期间,该连接被独占。
MySQL JDBC 驱动在流式模式下的限制:
1. 同一个 Connection 上不能执行其他 SQL(会抛出异常)
2. 必须消费完所有行(或显式关闭 ResultSet)才能释放连接
3. 如果中途抛异常没有正确关闭,连接会泄漏
这就是为什么代码中使用 SqlSessionFactory.openSession() 创建独立 Session,而不是复用 Spring 管理的连接池连接 — 避免流式查询占用连接池中的连接导致其他业务阻塞。
同时,这也是为什么不用 @Transactional — 事务会绑定连接,流式查询 15 分钟 = 连接被占用 15 分钟 = 连接池少一个可用连接。
问题 9:为什么不用多线程并行处理多个店铺来加速?
答案:可以,但需要权衡。
当前方案是按店铺串行处理:
for (Store store : storeList) {
this.matchEnterpriseAllGoodsWithCursor(businessUnitRules, enterpriseId, store.getId(), ...);
}
如果改为并行:
storeList.parallelStream().forEach(store -> {
this.matchEnterpriseAllGoodsWithCursor(businessUnitRules, enterpriseId, store.getId(), ...);
});
优点:10 个店铺并行,理论上 15 分钟 → 1.5 分钟
风险:
- MySQL 连接数:10 个并行流式查询 = 10 个长连接被占用
- Buffer Pool 抖动加剧:10 个并行扫描同时涌入大量冷数据页
- UPDATE 锁竞争:多个线程同时更新同一张表,行锁竞争加剧
- 内存:10 × 200MB 缓冲 = 2GB,需要确保 Pod 内存充足
当前选择串行的原因:稳定性优先。15 分钟已经满足业务需求(用户手动触发,不需要秒级响应),没必要为了更快而引入并发风险。
问题 10:增量和全量匹配是否存在并发问题?需要解决吗?
先看代码中的锁设计全景:
系统中有 4 个场景使用同一把分布式锁 BUSINESS_UNIT_LOCK + enterpriseId:
| 场景 | 锁参数 | 持锁时长 | 获取失败行为 |
|---|---|---|---|
全量匹配 processTask() |
waitTime=0, leaseTime=-1 | 整个执行期间(~15 分钟) | 标记任务失败 |
增量匹配 batchMatchAndSetBusinessUnit() |
waitTime=0, leaseTime=-1 | 每批商品匹配期间(毫秒级) | setMatchBusinessUnit(false) 跳过匹配 |
编辑规则 editBusinessUnitRule() |
waitTime=3s, leaseTime=-1 | 编辑操作期间(毫秒级) | 抛异常"正在匹配中,无法编辑" |
单条匹配 lockAndMatchBusinessUnitRule() |
waitTime=0, leaseTime=-1 | 单条匹配期间(微秒级) | 返回空字符串 |
关键发现:全量匹配持锁 15 分钟,增量匹配 waitTime=0 立即失败。
场景分析:全量运行中,增量到来
T0: 全量匹配启动,获取锁 BUSINESS_UNIT_LOCK:enterprise_1
T1: CDC 触发增量匹配(商品分类变更)
→ 尝试获取锁,waitTime=0,立即失败
→ catch LockFailedException
→ goodsSync.setMatchBusinessUnit(false) // 跳过业务单元匹配
→ 商品仍然同步到 ES,但保留旧的 business_unit 值
T15: 全量匹配完成,释放锁
→ 触发 goodsSyncToES(false)
→ 下一轮增量匹配正常执行,补上 T1 跳过的商品
结论:不存在数据冲突,但存在短暂的数据"滞后"。
为什么说"没有冲突"是对的?
你的直觉是对的。从数据正确性角度看,全量和增量并发确实不会产生脏数据:
情况 1:全量和增量计算出相同的 business_unit
→ 谁先写谁后写都一样,最终结果正确
情况 2:商品在全量执行期间发生了变更(如分类从 A 改为 B)
→ 全量用旧数据匹配出 business_unit = 'X'
→ 增量用新数据匹配出 business_unit = 'Y'
→ 但增量被锁挡住了,跳过匹配
→ 全量写入 business_unit = 'X'(基于旧分类,结果是"过时的")
→ 全量结束后,下一轮增量重新匹配,写入 business_unit = 'Y'(正确)
→ 最终结果正确,只是有 ≤15 分钟的滞后窗口
那这把锁到底在保护什么?
锁的真正目的不是防止全量和增量的数据冲突,而是:
| 保护场景 | 如果没有锁会怎样 |
|---|---|
| 全量 vs 编辑规则 | 用户在全量执行到一半时修改了规则,前半部分用旧规则、后半部分用新规则,同一批商品匹配结果不一致 |
| 全量 vs 全量 | 两个全量任务同时跑,浪费资源且可能产生写冲突(同一行被两个事务更新) |
| 增量 vs 编辑规则 | 增量用旧规则匹配,紧接着用户改了规则,但商品已经写入了旧结果 |
全量 vs 增量之间的锁是"保守设计"——不加也不会出错,但加了更安全(避免全量期间增量写入的结果被全量覆盖成旧值)。
如果面试官追问:能不能去掉全量和增量之间的锁互斥?
可以,但没必要。 理由:
- 去掉后的收益:增量匹配在全量期间不再被跳过,数据滞后窗口从 15 分钟缩短到 0
- 去掉后的风险:
T0: 全量读取商品 A(categoryId=1),匹配出 business_unit='X'
T1: CDC 触发,商品 A 的 categoryId 改为 2
T2: 增量匹配商品 A,匹配出 business_unit='Y',写入 MySQL
T3: 全量处理到商品 A,写入 business_unit='X'(覆盖了增量的正确结果)
T15: 全量结束
→ 商品 A 的 business_unit 是 'X'(基于旧分类,错误)
→ 需要等下一轮增量才能修正
- 当前方案的滞后是可接受的:业务单元是组织管理字段,不是实时交易字段,15 分钟滞后对业务无影响
- 锁的开销极低:增量匹配被跳过后,商品仍然正常同步 ES,只是 business_unit 保持旧值
面试回答模板:
全量和增量使用同一把企业级分布式锁,全量持锁约 15 分钟,增量 waitTime=0 立即失败后跳过匹配。从数据正确性看,两者并发不会产生脏数据——即使不加锁,最终结果也会被下一轮增量修正。锁的核心目的是防止"全量执行期间规则被编辑"导致的不一致,以及避免全量覆盖增量的正确结果。这是一个保守但安全的设计选择,在业务单元这种非实时字段上,15 分钟的数据滞后完全可接受。