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

业务单元规则商品匹配优化 — 面试备战手册


零、简历精简版(可直接粘贴到简历)

业务单元规则商品匹配优化

项目角色:核心开发 + 性能调优
技术栈:Java、MySQL、MyBatis、Elasticsearch、Redis
项目描述:为客户公司提供按平台、品牌、分类、店铺、站点等维度定义独立运营和财务核算规则的能力。业务上期望商品归属到业务单元,系统允许 0/1/多命中,并按约定写入空值、单值或多值。上线后需对 8000W+ 全量商品进行扫描匹配,原始方案在大数据量下出现严重性能退化(800W 数据需 8 小时)甚至 OOM,经过 遍历链路 + 写库策略两条线共 5 轮迭代优化,最终将 800W 匹配耗时降至 17 分钟,内存占用降至最低 300M。
核心工作与产出:

  • 主导全量商品匹配链路从主键游标分页(800W 8 小时)→ ES 滚动查询(ES OOM)→ JDBC/MyBatis 游标查询(应用 OOM)→ JDBC 真流式读取 + 自定义 ResultHandler(800W 17 分钟、1400W 25 分钟)的 4 轮性能演进
  • 深入研读 MyBatis + MySQL JDBC 源码,定位 fetchSize 未让驱动进入真流式读取、MyBatis 默认处理器聚合结果集的双重问题,最终确保底层创建 ResultsetRowsStreaming(真流式)并通过自定义 ResultHandler 逐条消费,内存占用降至最低 300M
  • 设计按店铺分组 + 按业务单元分组批量更新的写库策略,2000 条更新从逐条 WHERE id=xx(7-8s)优化为分组 WHERE id IN(~100ms)
  • 设计 Redis 分布式锁 BUSINESS_UNIT_MATCH_TASK::{enterpriseId} 保证全量匹配与增量匹配互斥,避免规则变更与匹配任务并发导致的数据不一致

一、项目口述(面试自我介绍时用)

这个项目的背景是公司做了一个"业务单元规则"的功能。客户可以按照平台、品牌、分类、店铺、站点这几个维度,定义自己内部独立运营和财务核算的规则。业务上期望商品归属到业务单元,系统允许 0/1/多命中,并按约定写入空值、单值或多值。上线后我们需要全量扫描所有商品进行匹配,数据量是 8000W 以上。

虽然匹配是异步的,但最初的方案性能非常差,做了两条优化线共好几轮迭代才正式上线。

遍历这边,第一版用主键游标分页,也就是 WHERE id > minId ORDER BY id LIMIT size。它不是传统 offset 深分页,但因为生产环境第一个符合条件的商品 ID 可能在 2000W,过滤条件又没有足够有效的联合索引,游标推进过程中仍然会扫大量无效数据,800W 数据跑了 8 个多小时。第二版改成用 ES 滚动查询遍历商品 ID 再回查 MySQL,但全量遍历 ES 会导致 ES 内存不足甚至重启。第三版回到 MySQL 用 JDBC/MyBatis 游标查询,速度快了很多,800W 降到 30-40 分钟,但直接 OOM 了。

为了解决 OOM,我去读了 MyBatis 和 MySQL JDBC 的源码,发现问题分两层:JDBC 层如果没有真正满足流式读取条件,底层创建的仍然是 ResultsetRowsStatic,它会把所有数据读到一个 ArrayList 中;MyBatis 框架层如果走默认 selectList 或没有传入自定义 ResultHandler,DefaultResultHandler 又会把映射后的对象全部装进 List 返回。也就是说,光设置 fetchSize 或光使用 MyBatis Cursor 都不稳,必须同时保证 JDBC 层是真流式读取、MyBatis 层是逐条处理。

最后一版我把 JDBC 查询调整到真正的流式读取条件,并实现了自定义 ResultHandler 处理器,在 handleResult() 回调中直接做匹配,攒够 1000 条后按业务单元分组批量更新,然后 clear 释放引用让 GC 回收。同时按企业入驻的店铺分组遍历,减少不必要的扫描。最终 800W 数据降到 17 分 47 秒,1400W 大约 25 分钟,内存最低降到 300M。

写库这边也是独立优化了一条线:最开始用 MyBatis-Plus 的 batchUpdateById 很慢,改成逐条 WHERE id=xx 更新(500 条一批),生产环境要 7-8 秒。后来改成按匹配后的业务单元结果分组,用 WHERE id IN 批量更新,2000 条只需要 100ms 左右。

这个项目我觉得最有价值的点在于,遇到问题不是靠猜,而是通过读源码找到根本原因。很多方案看起来"应该"是流式的,但框架层可能做了你看不到的事情。


二、面试官可能会问的问题 & 参考回答

模块一:业务背景与匹配架构

Q1:什么是"业务单元规则"?匹配逻辑是什么?

业务单元规则是客户公司用来定义内部独立运营和财务核算维度的配置。支持五个维度:平台(platform)、品牌(brand)、分类(category)、店铺(store)、站点(nation),每个维度有三种操作符:IN、NOT_IN、ALL(表示不设置该条件)。

匹配过程是拿商品的这五个字段去遍历该企业下的所有规则,逐条判断是否命中。业务上期望商品归属到业务单元,但系统允许 0/1/多命中,并按约定写入空值、单值或多值:没有命中时写空值,命中 1 个时写单值,命中多个时写多值。

规则存在 business_unit_rule 表的 rule_detail 字段,是 JSON 格式。

flowchart LR
    A["商品五维字段平台 / 品牌 / 分类 / 店铺 / 站点"] --> B["读取企业下业务单元规则"]
    B --> C["逐条判断 IN / NOT_IN / ALL"]
    C --> D{"命中数量"}
    D -->|"0 个"| E["写入空值"]
    D -->|"1 个"| F["写入单个业务单元"]
    D -->|"多个"| G["写入多个业务单元"]
    E --> H["标记商品业务单元结果"]
    F --> H
    G --> H

Q2:全量匹配和增量匹配怎么协调?并发怎么处理?

用了一个 Redis 分布式锁 BUSINESS_UNIT_MATCH_TASK::{enterpriseId},保证同一企业同一时间只有一个匹配任务在执行。全量匹配(用户手动触发)和增量匹配(新增/修改商品触发)都需要获取这把锁。

全量匹配获取不到锁时会阻塞等待 5 秒,通常增量匹配比较快。增量匹配获取不到锁则不处理,重新发到队列避免消费超时。

锁的作用有两层:一是防止匹配任务并发,二是保证匹配任务执行过程中无法编辑业务单元规则。这样不需要复杂的规则版本管理,架构简单。

sequenceDiagram
    participant Full as 全量匹配任务
    participant Inc as 增量匹配任务
    participant Lock as Redis企业级锁
    participant Rule as 业务单元规则

    Full->>Lock: 获取企业级匹配锁
    alt 全量任务持锁
        Full->>Rule: 执行全量匹配期间禁止规则编辑
        Inc->>Lock: 尝试获取锁
        Lock-->>Inc: 获取失败
        Inc-->>Inc: 重新投递队列,避免消费超时
    else 增量任务先持锁
        Inc->>Rule: 快速完成单批商品匹配
        Full->>Lock: 最多等待 5 秒
    end

Q3:增量匹配具体是怎么实现的?

增量匹配集成在 ES 同步商品的方法中。因为业务单元也需要同步到 ES,如果单独用消息队列匹配完还得再同步一次 ES,不如直接在 ES 同步时做匹配。

逻辑是:查出标识位为 0 的商品 → 过滤不需要匹配业务单元的商品 → 尝试获取锁(不等待)→ 获取不到锁就只更新商品属性,不修改同步标志位 → 获取到锁就按企业 ID 查询规则进行遍历匹配。


模块二:遍历链路优化(4 轮演进)

flowchart LR
    V1["第一版主键游标分页800W:8小时8分30秒"] --> V2["第二版ES Scroll + 回查 MySQLES OOM"]
    V2 --> V3["第三版JDBC / MyBatis 游标30-40分钟但应用 OOM"]
    V3 --> V4["最终版JDBC 真流式 + ResultHandler800W:17分47秒"]

    V1 -.-> P1["遍历瓶颈扫描大量无效行"]
    V2 -.-> P1
    V3 -.-> P2["内存瓶颈JDBC 和 MyBatis 双层聚合"]
    V4 --> R["结果:耗时下降约 28 倍内存最低约 300M"]

Q4:第一版主键游标分页为什么慢?

第一版用的是 WHERE id > minId ORDER BY id LIMIT 2000 这种主键游标分页,不是 LIMIT offset, size 那种传统深分页。它理论上能避开 offset 扫描和丢弃的问题,但在我们这个场景里还是慢,原因主要有三个:

  1. 起始游标靠后:生产环境中符合条件的第一个商品 ID 可能在 2000W 左右,需要先扫过大量不符合条件的数据才能定位到起始位置
  2. 过滤条件选择性不够好:查询不是单纯按主键取数,还带企业、店铺、状态等业务条件,如果没有足够匹配的联合索引,MySQL 仍然会在主键范围内扫描大量无效行
  3. 每轮推进依赖上一批结果:只能拿上一批最大 ID 继续往后扫,整体吞吐受单次查询耗时影响,无法天然并发

本地测试 100-300ms,生产环境翻页到后面需要 7-10 秒。800W 数据跑了 8 小时 8 分 30 秒。

Q5:第二版 ES 滚动查询为什么会导致 ES OOM?

ES 的 scroll API 会给查询结果建一个"快照",然后在整个滚动过程中维护这个快照的引用。8000W 数据全量遍历,ES 需要维护这个巨大的快照上下文,内存占用会线性增长,最终导致 ES 内存不足甚至重启。

查询本身大概 2 秒内能返回,但返查 MySQL 也需要时间。最关键的是 ES 不应该被用来做大规模全量遍历,它更适合检索场景。

Q5-1:如果不用 Scroll,改成 search_after + PIT,为什么会更合适?它是不是完全没有资源占用?

如果不用 Scroll,我会优先考虑 search_after + PIT。search_after 的优势是它不依赖 ES 服务端保存滚动游标,而是由客户端保存上一页最后一条记录的排序值,下一次请求从这个位置继续查,所以服务端状态更轻。

但只用 search_after 也有问题:如果遍历过程中索引还在写入、更新、删除,前后页的数据视图可能不一致,可能出现漏读或重复读。所以一般会配合 PIT,也就是 Point In Time。PIT 会提供一个固定时间点的索引视图,让多次 search_after 查询都基于同一个快照执行,这样结果更稳定。

它也不是完全没有资源占用。PIT 仍然要在 ES 里保留某个时间点的搜索视图,也会持有相关 segment 引用。只是相比 Scroll,它不需要维护传统 scroll 上下文和每批滚动状态,服务端压力通常更轻,更适合深分页和大结果集遍历。尤其是客户端处理慢的时候,search_after + PIT 的资源模型会比 Scroll 更可控。

面试里可以这样表达:search_after 把翻页位置交给客户端保存,PIT 负责提供稳定视图,二者结合能减少 Scroll 长时间维持 search context 带来的压力。但 PIT 不是零成本方案,仍然需要控制 keep_alive、每批大小,并在任务结束后及时关闭 PIT。对于 8000W 级别的离线任务,我还是会优先评估数据库侧、消息侧或专门离线链路,避免把在线 ES 集群当成全表扫描引擎。

这里还有一个细节:search_after 必须有稳定且唯一的排序字段,比如 update_time + id,或者在 PIT 场景下结合 _shard_doc。不能只按非唯一字段排序,否则相同排序值的数据可能乱序、重复或漏掉。

flowchart TB
    S["Scroll"] --> S1["服务端维护 scroll context"]
    S1 --> S2["每个 shard 保留固定视图和相关 segment 引用"]
    S2 --> S3["慢消费时上下文长期存活"]
    S3 --> S4["堆内存 / 文件句柄 / 页缓存压力升高"]

    P["search_after + PIT"] --> P1["客户端保存上一页最后一条 sort 值"]
    P1 --> P2["PIT 提供固定时间点视图"]
    P2 --> P3["服务端状态更轻,但仍持有 segment 引用"]
    P3 --> P4["控制 keep_alive、批大小,并及时关闭 PIT"]

Q6:第三版游标查询为什么还会 OOM?根本原因是什么?

第三版回到 MySQL 用了 JDBC/MyBatis 游标查询,设置了 fetchSize = Integer.MIN_VALUE,速度快了很多(800W 降到 30-40 分钟),但应用直接 OOM 了。

为了解决这个问题,我去读了源码,发现了两个层面的问题:

JDBC 层:ResultsetRows 有两个实现——ResultsetRowsStatic(全量加载到 ArrayList)和 ResultsetRowsStreaming(按需逐行读取)。以当时使用的 MySQL Connector/J 行为来看,流式读取需要同时满足 FORWARD_ONLY、CONCUR_READ_ONLY、fetchSize = Integer.MIN_VALUE 这几个关键条件:

我们当时虽然设置了 fetchSize,但实际 debug 发现底层创建的还是 ResultsetRowsStatic,而不是 ResultsetRowsStreaming。所以更准确地说,fetchSize 不是通用的“每次只从 MySQL 取 N 条”的魔法开关;如果没有真正进入 streaming 分支,驱动仍然会先把结果行读到内存里。

MyBatis 框架层:DefaultResultSetHandler.handleResultSet() 中,如果 resultHandler == null,会创建 DefaultResultHandler,它内部维护一个 ArrayList,把所有映射后的对象都 add 进去。

所以最终不是只改一个点,而是要同时解决两个问题:JDBC 层必须是真流式读取,MyBatis 层必须用自定义 ResultHandler 逐条消费,不能再把结果集聚合成 List。这俩少一个都容易炸,OOM 这玩意儿就是这么不讲武德。

return ((this.query.getResultType() == Type.FORWARD_ONLY)
 && (this.resultSetConcurrency == CONCUR_READ_ONLY)
 && (this.query.getResultFetchSize() == Integer.MIN_VALUE));
flowchart TD
    A["大结果集查询"] --> B{"JDBC 层是否真流式"}
    B -->|"否"| C["ResultsetRowsStatic结果行先进入 ArrayList"]
    B -->|"是"| D["ResultsetRowsStreamingResultSet.next() 时按需读取"]
    C --> O1["JDBC 层 OOM 风险"]
    D --> E{"MyBatis 层是否自定义 ResultHandler"}
    E -->|"否"| F["DefaultResultHandler映射对象继续聚合到 List"]
    E -->|"是"| G["handleResult 逐条消费批量处理后 clear 释放引用"]
    F --> O2["MyBatis 层 OOM 风险"]
    G --> H["内存保持稳定最低约 300M"]

Q7:你们尝试过 MyBatis Cursor 方案吗?为什么也不行?

试过。Cursor<T> 方案返回 DefaultCursor,通过 Iterator 逐条消费。DefaultCursor 内部的 ObjectWrapperResultHandler 每次只包装一条结果,调用 context.stop() 防止继续读取。

核心逻辑是 fetchNextObjectFromDatabase() 中每次调 handleRowValues 只取一条数据。但这套方案仍然有 OOM 问题——因为底层创建的还是 ResultsetRowsStatic,MySQL JDBC 层并没有真正流式读取,所有数据仍然被一次性加载到了内存中。

这也印证了我们的结论:真正的流式必须先保证 JDBC 层走到 ResultsetRowsStreaming,仅仅在 MyBatis 框架层做 Cursor 包装是不够的。


模块三:写库策略优化(独立演进线)

Q8:写库这边做了哪些优化?

写库是独立于遍历优化的另一条线:

方案 做法 耗时
第一版 MyBatis-Plus batchUpdateById 大数据量下很慢,800W 要 8 小时+
第二版 逐条 WHERE id = xx 更新(500 条一批) 生产环境 7-8s
第三版 按业务单元分组 WHERE id IN (...) 2000 条 ~100ms

第二版的发现是更新效率不理想,生产环境耗时很长,最长需要 7-8 秒。瓶颈主要出在更新这一步。

第三版改成按匹配后的业务单元结果进行分组,同一个业务单元的商品用一条 WHERE id IN (...) 批量更新。如果 2000 条商品匹配到 3 个不同的业务单元,只需要执行 3 条 UPDATE,而不是 2000 条。更新效率大幅提升,2000 条大概只消耗 100ms 左右。

flowchart LR
    A["2000 条待更新商品"] --> B["旧方案逐条 WHERE id = ?"]
    B --> C["约 2000 条 UPDATE生产环境 7-8s"]

    A --> D["新方案按业务单元结果分组"]
    D --> E["BU=A1 条 UPDATE id IN (...)"]
    D --> F["BU=B1 条 UPDATE id IN (...)"]
    D --> G["BU=C1 条 UPDATE id IN (...)"]
    E --> H["约 3 条 UPDATE2000 条约 100ms"]
    F --> H
    G --> H

Q9:为什么不用拆分表的方案?

本地测试过把业务单元字段拆分到独立表,2000 条更新从 2s 降到 1.4s,再配合按业务单元分组更新可以降到 100ms。但改造成本比较大,需要改动查询业务单元的逻辑。最终选了不新建表但按业务单元分组的方案,500ms,在性能和改造成本之间做了权衡。


模块四:最终方案与 ResultHandler 实现

Q10:最终方案的具体实现是怎样的?

四个关键点:

// 1. 手动管理只读流式查询的 SqlSession,不用 @Transactional 包住整个扫描过程(避免长事务)
SqlSession sqlSession = null;
try {
 sqlSession = sqlSessionFactory.openSession();

// 2. 按店铺分组遍历,减少不必要的扫描
 LambdaQueryWrapper<Goods> wrapper = Wrappers.<Goods>lambdaQuery()
 .eq(Goods::getEnterpriseId, enterpriseId)
 .eq(Goods::getStatus, 1)
 .eq(Goods::getStoreId, storeId);

// 3. 自定义 ResultHandler 逐条消费
 GoodsResultHandler handler = new GoodsResultHandler(
 this, goodsJobService, matchTotal, matchSuccess,
 businessUnitRules, storeId, goodsMapper);
 sqlSession.getMapper(GoodsMapper.class).listGoodsIdCursor(wrapper, handler);

// 4. 最后一批数据处理,批量更新走独立短事务,避免读 ResultSet 的连接上再执行 UPDATE
 handler.handle();
} finally {
 if (sqlSession != null) {
 sqlSession.close();
 }
}

这里还有一个容易被问到的点:流式读取的 SqlSession 只负责遍历结果集,不在这个连接未关闭 ResultSet 的情况下继续执行批量 UPDATE。批量更新放到独立 Service/Mapper 的短事务里处理;不要在流式查询的同一个 SqlSession/Connection 上执行更新,否则 MySQL streaming ResultSet 没消费完时可能直接报错,或者被迫退化成缓存结果集。读写连接隔离,简单、稳定、少挖坑。

sequenceDiagram
    participant Task as 匹配任务
    participant Read as 只读 SqlSession
    participant JDBC as MySQL Streaming ResultSet
    participant Handler as GoodsResultHandler
    participant Write as 写库短事务
    participant ES as ES 同步

    Task->>Read: openSession
    Read->>JDBC: 发起真流式查询
    loop 每读取一条商品
        JDBC-->>Handler: handleResult(goods)
        Handler->>Handler: 规则匹配,收集变更商品
    end
    alt 攒够 1000 条或最后一批
        Handler->>Write: 按业务单元分组批量更新
        Write-->>Handler: 提交短事务
        Handler->>ES: 标记或触发同步
        Handler->>Handler: clear 释放对象引用
    end
    Read-->>Task: close

自定义 GoodsResultHandler 的逻辑:

  • handleResult() 回调中逐条匹配规则,计数器 matchTotal +1
  • 如果业务单元发生变化,设置 is_to_es = 0 标记需要重新同步 ES
  • 业务单元发生变化的商品加到临时 updateGoods List
  • 攒够 PAGE_SIZE(1000 条)后触发 handle()
  • handle() 中按业务单元分组批量更新,然后触发 ES 同步,clear() 释放引用
@Override
public void handleResult(ResultContext<? extends Goods> resultContext) {
 final Goods goods = resultContext.getResultObject();
 matchTotal.addAndGet(1);
 String businessUnit = businessUnitRuleService.matchBusinessUnitRule(businessUnitRules, goods);
 boolean isMatchSuccess = validateIsMatch(businessUnit);
 if (isMatchSuccess) {
 matchSuccess.addAndGet(1);
 }
 if (!Objects.equals(businessUnit, goods.getBusinessUnit())) {
 goods.setIsToEs(0);
 goods.setBusinessUnit(businessUnit);
 goods.setUpdateAt(new Date());
 updateGoods.add(goods);
 }
 if (updateGoods.size() >= PAGE_SIZE) {
 this.handle();
 }
}

Q11:按店铺分组的意义是什么?

按企业入驻的店铺进行分组遍历后,每次查询加了 store_id 条件,减少了分区的遍历范围。配合 WHERE id IN (...) AND store_id = #{storeId} 的更新 SQL,利用了 store_id 的索引进一步提升了更新效率。

相对第三版(不按店铺分组)能减少分区的遍历,查询速度更快。

Q12:最终的执行时长数据是多少?

数据量 执行时长
800W 17 分 47 秒
1400W 25 分钟左右
规则变化不大时 10 分钟内

对比最初的 800W 8 小时 8 分 30 秒,提升了大约 28 倍。


模块五:深度追问

Q13:源码中 fetchSize 为什么没有达到预期流式效果?

我查了 TextResultsetReader 的源码,创建 ResultSet 时的核心逻辑:

if (!streamResults) {
 // ResultsetRowsStatic:while 循环读取全部数据到 ArrayList
 ArrayList<ResultsetRow> rowList = new ArrayList<>();
 ResultsetRow row = this.protocol.read(ResultsetRow.class, trf);
 while (row != null) {
 if ((maxRows == -1) || (rowList.size() < maxRows)) {
 rowList.add(row);
 }
 row = this.protocol.read(ResultsetRow.class, trf);
 }
 rows = new ResultsetRowsStatic(rowList, cdef);
} else {
 // ResultsetRowsStreaming:延迟读取,等 next() 才从 MySQL 读
 rows = new ResultsetRowsStreaming<>(this.protocol, cdef, false, resultSetFactory);
 this.protocol.setStreamingData(rows);
}

决定是否走流式的开关在 createStreamingResultSet() 中,关键条件必须同时满足。但我们当时即使设置了 fetchSize,实际 debug 发现走的是 ResultsetRowsStatic 分支。

更准确的结论是:fetchSize 不是通用的“每次只从 MySQL 取 N 条”的开关。只有驱动进入 ResultsetRowsStreaming 分支,才是真正按 next() 逐行读取;如果仍然创建 ResultsetRowsStatic,就还是全量加载。并且就算 JDBC 层已经真流式,如果 MyBatis 层走 selectList 或默认 DefaultResultHandler,业务对象仍然会被聚合到 List 里。所以最终必须同时解决两层问题:JDBC 层真流式读取,MyBatis 层自定义 ResultHandler 逐条处理。

Q14:debug 跟踪的完整调用链路是怎样的?

MapperMethod.execute() 判断方法返回 void 且有 ResultHandler 参数,走 executeWithResultHandler → SqlSession.select() → CachingExecutor.query() → BaseExecutor.queryFromDatabase() → doQuery() → StatementHandler.query() → ResultSetHandler.handleResultSets()。

在 handleResultSet() 中,关键分支:

if (resultHandler == null) {
 // 默认:创建 DefaultResultHandler,内部 ArrayList 聚集全部结果
 DefaultResultHandler defaultResultHandler = new DefaultResultHandler(objectFactory);
 handleRowValues(rsw, resultMap, defaultResultHandler, rowBounds, null);
 multipleResults.add(defaultResultHandler.getResultList());
} else {
 // 自定义 handler:直接回调,不聚集
 handleRowValues(rsw, resultMap, resultHandler, rowBounds, null);
}

debug 走的流程来看,底层主链路和旧方式接近,关键差异在两个地方:JDBC 层要进入 ResultsetRowsStreaming,避免一次性加载全部结果行;MyBatis 层要走自定义 ResultHandler,避免把业务对象聚合到 List。

Q15:如果数据量从 8000W 增长到 5 亿,这个方案还适用吗?

ResultHandler 方案本身内存是 O(1) 的,数据量增长不会 OOM。但处理时间会线性增长,单线程串行可能太慢。

5 亿级别建议引入分片并发:按商品 ID 取模分片,启动多个 worker 各自负责一个分片的流式扫描和匹配。或者按业务维度(企业、站点)拆分任务。

另外,这么大的数据量应该改为增量匹配为主、定期全量校验为辅的策略。

Q16:流式查询有几种方式?有什么异同?底层原理是什么?

严格来说,"流式查询"不是单一 API,而是一类避免全量结果集一次性进入内存的处理方式。常见方式有这几种:

方式 本质 是否真流式 适用场景 主要风险
主键游标分页 多次 SQL,按 WHERE id > lastId ORDER BY id LIMIT size 分批推进 否,属于分页式扫描 可断点、可重试、可分片并发的离线任务 依赖稳定排序和索引,过滤条件选择性差时仍会扫大量无效行
JDBC 特殊流式读取 单 SQL,驱动按 ResultSet.next() 逐行读取 是 大结果集顺序扫描,内存要求低 连接会被 ResultSet 长时间占用,未读完前不适合复用该连接执行其他 SQL
JDBC 服务端游标 服务端维护 cursor,客户端按 fetchSize 分批拉取 是 希望按批拉取、控制每批大小的扫描场景 依赖驱动配置和服务端预编译语句,服务端 cursor 和连接都会被占用
MyBatis Cursor<T> MyBatis 在框架层封装 Iterator 取决于底层 JDBC 调用方希望用迭代器逐条消费 如果 JDBC 层不是真流式,Cursor 外壳也救不了 OOM
MyBatis ResultHandler 每行映射后立即回调业务处理 取决于底层 JDBC 离线任务、匹配任务、导出任务等不需要返回 List 的场景 必须配合 JDBC 真流式,否则底层结果行仍可能被全量加载
Spring/JPA Stream 框架把查询结果包装成 Stream 取决于底层 JDBC 和持久化上下文管理 Spring/JPA 项目中希望用 Stream 消费结果 持久化上下文可能缓存实体,需要定期 clear
R2DBC/Reactive Stream 非阻塞驱动 + 背压模型 是另一套非 JDBC 体系 高并发响应式系统 改造成本高,不适合只为一个离线任务硬上

这几种方式最容易混淆的是三层概念:

  1. 分页式扫描:比如主键游标分页,本质是多次 SQL,不是同一个 ResultSet。优点是可断点、可重试、可并发,适合任务系统。
  2. JDBC 层真流式读取:比如 MySQL Connector/J 进入 ResultsetRowsStreaming,结果行不会一次性全读进内存,而是 next() 时再从网络流里读。
  3. 框架层流式处理:比如 MyBatis Cursor、ResultHandler,只是避免 MyBatis 把映射后的 Java 对象全部聚合成 List。它不能替代 JDBC 层真流式。

普通查询的执行链路大概是:

SQL 执行
  -> 驱动读取全部结果行
  -> ResultsetRowsStatic / ArrayList 缓存
  -> MyBatis 映射所有对象
  -> selectList 返回 List
  -> 大数据量下 OOM

真正稳定的流式处理链路应该是:

SQL 执行
  -> JDBC 层进入 ResultsetRowsStreaming
  -> ResultSet.next() 时读取一行或一批
  -> MyBatis 每映射一行就回调 ResultHandler
  -> 业务处理后释放引用
  -> 内存保持稳定

所以最终方案不是简单说"用了 Cursor"或者"用了 ResultHandler"。准确表达应该是:JDBC 层负责不全量加载结果行,MyBatis ResultHandler 负责不聚合 Java 对象。这两个点一个管驱动层,一个管框架层,少一个都可能被大数据量干爆。

如果要选型,可以按这个原则:

  • 要可断点、可重试、可分片并发,优先主键游标分页
  • 要单 SQL 扫大表、压低内存,使用 JDBC 真流式读取 + MyBatis ResultHandler
  • 想用 MyBatis Cursor,可以用,但必须先确认底层 JDBC 真的进入 streaming 分支
  • 扫描过程中还要批量更新,读连接和写连接必须隔离,写库走独立短事务,不要在流式 ResultSet 没消费完的连接上执行 UPDATE

三、架构扩展与优化方向(加分项)

Qn:你觉得这个系统最大的瓶颈在哪里?后续怎么优化?

回顾整个项目,我觉得有几个方向可以继续优化:

  1. 分片并发:当前是单线程串行处理 8000W 数据。可以按商品 ID 取模分片,启动多个 worker 并行处理,或者按业务维度(企业、站点)拆分任务。
  2. 批量写库:当前攒 1000 条后按业务单元分组更新,可以考虑用 MyBatis 的 BatchExecutor 或 JDBC addBatch() 进一步提升批量写入性能。
  3. 断点续传:中途进程挂了需要从头开始。可以加一张进度表,记录当前处理到的主键位置,下次从断点继续。
  4. 增量为主:很多商品不会频繁变化,结合 binlog 监听商品变更,只对新增或修改的商品做匹配,全量扫描作为兜底周期性执行。
  5. 规则预编译:当前匹配是遍历规则逐条比对。可以用规则引擎(如 Aviator、QLExpress)把规则编译成表达式树,减少匹配时的判断开销。
  6. 规则版本管理:给规则表加 version 字段,匹配任务记录开始时的版本号。规则变更后,自动触发增量修正,不需要等用户手动全量重跑。

四、技术要点速查表

技术点 关键词 面试话术要点
主键游标分页 WHERE id > minId ORDER BY id LIMIT size 避免 offset 深分页,但过滤条件选择性低或起始 ID 靠后时,仍会扫描大量无效行
JDBC 真流式读取 fetchSize = Integer.MIN_VALUE MySQL 驱动特殊约定,需同时满足关键条件才走 ResultsetRowsStreaming
JDBC 服务端游标 useCursorFetch=true、useServerPrepStmts=true 服务端维护 cursor,客户端按 fetchSize 分批拉取,连接和 cursor 会长期占用
ResultsetRowsStatic 全量加载 未进入 streaming 分支时,会把结果行读到 ArrayList 中
ResultsetRowsStreaming 真流式 发送 SQL 后不立即读取,等调用 next() 才从 MySQL 逐行读取
MyBatis selectList 陷阱 DefaultResultHandler 即使底层流式读取,selectList 仍把所有结果装入 ArrayList 返回
MyBatis Cursor 方案 DefaultCursor 框架层 Iterator 包装,如果 JDBC 层不是真流式,仍然 OOM
ResultHandler 回调 handleResult(ResultContext) 框架层逐行回调,必须配合 JDBC 真流式读取,处理完即可 GC,内存最低降至 300M
流式查询分层 JDBC 读取 + 框架处理 JDBC 负责不全量加载结果行,MyBatis 负责不聚合 Java 对象
按业务单元分组更新 WHERE id IN (...) 2000 条从 7-8s 降至 ~100ms
按店铺分组遍历 WHERE store_id = xx 减少分区遍历范围,配合分组更新效率更高
长事务避免 手动 sqlSessionFactory.openSession() 不用 @Transactional 包住整段扫描,读流式连接和写库短事务隔离
分布式锁 Redis BUSINESS_UNIT_MATCH_TASK::{enterpriseId} 全量/增量互斥,同时防止规则编辑与匹配并发

各版本完整对比(面试时可主动画出)

设计版本 遍历商品表方式 缺陷 更新方式 执行时长(800W)
第一版 主键游标分页(minId + limit) 过滤条件选择性低,依赖第一条商品 ID MyBatis-Plus batchUpdateById 8 小时 8 分 30 秒
第二版 ES 滚动查询 + 回查 MySQL ES 内存不足重启 逐条 WHERE id=xx(500 一批) 查询 ~2s,更新 7-8s
第三版A ES 滚动查询 + 回查 MySQL ES 内存不足重启 按业务单元分组 WHERE id IN 40-60 分钟
第三版B 游标查询(旧方式) 应用 OOM 按业务单元分组 WHERE id IN 30-40 分钟
第四版 按店铺分组 + 游标遍历 仍然 OOM 按业务单元分组 WHERE id IN + store_id -
最终版 按店铺分组 + JDBC 真流式读取 + 自定义 ResultHandler 无 按业务单元分组 WHERE id IN + store_id 17 分 47 秒

面试加分项

  • 源码阅读能力:不是凭感觉优化,而是读了 MyBatis + MySQL JDBC 源码,找到 ResultsetRowsStatic 全量加载和 DefaultResultHandler 聚集结果的根本原因
  • 问题拆解能力:遍历链路和写库策略两条独立的优化线,每一版都明确了解决了什么、引入了什么、为什么不行
  • 数据敏感度:有完整的执行时长数据(8 小时 → 40-60 分钟 → 30-40 分钟 → 17 分钟),写库优化数据(7-8 秒 → 100ms),内存数据(最低 300M)
  • 诚实面对不足:能说出当前方案的局限性和后续优化方向
  • 框架理解深度:清楚区分了"流式读取"(JDBC 层 ResultsetRowsStreaming)和"流式处理"(框架层 ResultHandler 回调)的本质区别,也理解了 fetchSize 参数在 MySQL 驱动中的真实行为
相关阅读

27 分钟

质检模块业务全景与现状梳理

数据来源:7份技术方案文档 + 代码库实际代码扫描 质检模块从0到1经历了以下迭代,每一版都是前一版的改造和扩展: 材料问题、工艺问题、破损问题、二次工艺、印绣花工艺、规格问题、版型问题、外观问题、包装问题、功能问题、性能问题 Contro

面试#简历项目11 阅读 · 0 评论

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

还没有评论,来抢沙发。