跳到正文
Kale的数字花园
目录 文章
/22 分钟/8 阅读/0 评论

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

规则按优先级排序,一个商品可匹配到多个业务单元(逗号分隔)。

两种匹配场景

  1. 全量匹配:用户编辑规则后手动触发,扫描企业下所有已配对商品重新匹配
  2. 增量匹配:新增/修改商品时自动触发(通过 Binlog CDC 同步到 ES 时判断是否需要匹配)

核心痛点

商品表(mall_goods)数据量超 1 亿行,单个企业可能有 2000W+ 商品。全量匹配任务需要遍历企业下所有商品,初期面临两大挑战:

  1. 性能瓶颈:大表遍历效率低,早期方案处理 2000W 数据耗时 超 2 小时
  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() 方法,以为是流式查询,但实际上:

  1. MyBatis-Plus 的 cursor() **没有设置 **fetchSize = Integer.MIN_VALUE
  2. MySQL JDBC 驱动默认使用 ResultSetRowsStatic
  3. 所有匹配的数据在 TCP 层被一次性加载到 Java 进程内存的 ArrayList 中
  4. 2000W 条商品数据(每条包含几十个字段)→ 数 GB 内存 → OOM
  5. 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():

  1. MySQL 从游标当前位置读取一行
  2. 将行数据序列化到 net_buffer(16KB)
  3. net_buffer 满了就 flush 到 TCP
  4. 游标指针前移到下一行
  5. 如果当前页读完了,沿着叶子节点链表跳到下一页

整个过程中,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 分钟

风险:

  1. MySQL 连接数:10 个并行流式查询 = 10 个长连接被占用
  2. Buffer Pool 抖动加剧:10 个并行扫描同时涌入大量冷数据页
  3. UPDATE 锁竞争:多个线程同时更新同一张表,行锁竞争加剧
  4. 内存: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 增量之间的锁是"保守设计"——不加也不会出错,但加了更安全(避免全量期间增量写入的结果被全量覆盖成旧值)。

如果面试官追问:能不能去掉全量和增量之间的锁互斥?

可以,但没必要。 理由:

  1. 去掉后的收益:增量匹配在全量期间不再被跳过,数据滞后窗口从 15 分钟缩短到 0
  2. 去掉后的风险:
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'(基于旧分类,错误)
→ 需要等下一轮增量才能修正
  1. 当前方案的滞后是可接受的:业务单元是组织管理字段,不是实时交易字段,15 分钟滞后对业务无影响
  2. 锁的开销极低:增量匹配被跳过后,商品仍然正常同步 ES,只是 business_unit 保持旧值

面试回答模板:

全量和增量使用同一把企业级分布式锁,全量持锁约 15 分钟,增量 waitTime=0 立即失败后跳过匹配。从数据正确性看,两者并发不会产生脏数据——即使不加锁,最终结果也会被下一轮增量修正。锁的核心目的是防止"全量执行期间规则被编辑"导致的不一致,以及避免全量覆盖增量的正确结果。这是一个保守但安全的设计选择,在业务单元这种非实时字段上,15 分钟的数据滞后完全可接受。

相关阅读

8 分钟

Creeks 系统线程池与多线程使用分析

整个系统使用了 **3 种多线程模式**,涉及 **4 个独立的线程池配置类**,覆盖 **定时任务、并行查询、异步通知、报告处理** 等场景。 假设服务器 8 核 CPU: Amazon 同步服务选 是明智的——调用者自己跑任务,自动降速

面试#其他7 阅读 · 0 评论

22 分钟

多平台财务核算系统

集团旗下跨境电商业务覆盖 eBay、AliExpress(速卖通)、Wish、Shopee、Walmart、Allegro、美客多、Joom、Joybuy、CDiscount、自建站(Shopify)等 **13+ 电商平台**,每个平台的

面试#其他8 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。