用 PageHelper 同步数据到 ES:能跑,但这几个坑得先知道

先说人话:PageHelper 在同步任务里干了什么

平时写列表接口,你肯定写过这种代码:

java 复制代码
PageHelper.startPage(1, 10);
List<Product> list = productMapper.selectAll();

背后 PageHelper 干了两件事:

  1. 往当前线程里塞了个小纸条,写着"我要第 1 页,每页 10 条";
  2. 你紧接着调 Mapper 时,它偷偷把 SQL 改成 ... LIMIT 0, 10,再顺手先跑一次 SELECT COUNT(0) 数一数总共有多少条。

在列表接口里这很贴心,因为前端要显示"共 100 条,第 1/10 页"。但同步任务不需要这个贴心,它只想把数据一页一页搬完,不关心总数。这个差别,就是后面所有坑的源头。

坑一:小纸条只对"下一条查询"生效

startPage 塞进去的小纸条,会被接下来第一条 Mapper 查询拿走。听起来没问题,但如果你在中间插了别的查询呢?

java 复制代码
PageHelper.startPage(1, 1000);
int count = productMapper.countToday();   // ← 小纸条被它拿走了!
List<Product> rows = productMapper.selectAll();  // ← 这里没有分页,全表扫出来了

结果就是:selectAll() 把整张表读进内存,直接 OOM。同步任务里这个错误特别致命,因为没人盯着页面,往往是跑到一半服务挂了才发现。

规矩很简单 :startPage 和真正的查询必须挨着,中间不能有任何别的数据库操作。就像你点外卖,地址填完立刻下单,中间别去改收货人,否则快递就送错地方了。

坑二:每翻一页都数一遍总数,白费力气

PageHelper 默认每翻一页都要跑一次 COUNT(0)。列表接口无所谓,用户点一下才查一次。但同步任务是循环翻页 ,100 万条数据、每页 1000 条,就是 1000 页,等于多跑 1000 次 count。

大表上 COUNT(0) 不比普通查询便宜多少,这 1000 次纯属白干。

解决办法就是告诉 PageHelper:"别数了":

java 复制代码
PageHelper.startPage(pageNum, PAGE_SIZE, false);
//                                         ↑ 这个 false 就是"不要 count"

全量同步:完整代码 + 逐行解释

java 复制代码
public void fullSync() {
    int pageNum = 1;
    long total = 0;

    while (true) {
        // 1. 告诉 PageHelper:我要第 pageNum 页,每页 1000 条,不用数总数
        PageHelper.startPage(pageNum, PAGE_SIZE, false);

        // 2. 立刻查询,中间不能插别的东西
        List<ProductDO> rows = productMapper.selectForSync();

        // 3. 查不到数据 = 翻完了,退出
        if (rows.isEmpty()) {
            break;
        }

        // 4. 把这批数据写进 ES(和上一版一样,Bulk 批量写)
        writeBatch(rows);
        total += rows.size();

        log.info("第 {} 页,本页 {} 条,累计 {} 条", pageNum, rows.size(), total);

        // 5. 翻下一页
        pageNum++;
    }

    // 6. 全部写完后刷新一次,让数据能立刻被搜到
    esClient.indices().refresh(r -> r.index("products"));
}

最关键的是第 3 步。 因为你传了 false 不数总数,PageHelper 自己也不知道有没有下一页。所以判断"翻完了没有"只能靠"这一页查出来是空的" 。这就像你翻书,不知道总共有多少页,但翻到空白页就知道到头了。

有同学会问:那我用 PageInfo.isHasNextPage() 判断行不行?不行,或者说不可靠。 不 count 的情况下,PageInfo 里的"总数"其实是错的(它只有当前页的条数),hasNextPage 算出来也不准。所以上面代码干脆不用 PageInfo,就用 rows.isEmpty(),简单可靠。

坑三:数据量一大,越翻越慢

这个坑跟 PageHelper 本身没关系,是 MySQL 的 LIMIT 机制决定的。

sql 复制代码
LIMIT 0, 1000        -- 跳过 0 条,取 1000 条,很快
LIMIT 999000, 1000   -- 跳过 99.9 万条,再取 1000 条,很慢

LIMIT 999000, 1000 的意思是"先数过前 99.9 万行,全部丢掉,再拿后面 1000 行"。前面 99.9 万行的扫描是白干的。

数据量小的时候感觉不到 (百万条以内,翻到最后一页也就慢个几秒)。数据量一大就完蛋(千万条以上,最后几页可能一页要跑几分钟,甚至跑不完)。

所以结论很直接:

  • 百万条以内:PageHelper 随便用,代码直观,跟项目其他 Mapper 风格统一;
  • 千万条以上 :全量同步别用 PageHelper,换回上一版的 WHERE id > lastId 游标分页,翻到第几页速度都一样快。

坑四:线程被"污染"

PageHelper 的小纸条是塞在线程里的。同步任务通常跑在定时任务的线程池里,线程用完会还回去给下一个任务用。

如果 startPage 和查询之间抛了异常(比如查的时候数据库超时了),小纸条没被清理掉,还留在那个线程里。下一个任务复用这个线程时,会莫名其妙发现自己被分页了,查出来的数据少了一大截,排查起来极其痛苦。

保险做法是用 try-finally 兜底:

java 复制代码
PageHelper.startPage(pageNum, PAGE_SIZE, false);
List<ProductDO> rows;
try {
    rows = productMapper.selectForSync();
} finally {
    PageHelper.clearPage();  // 不管成功失败,都把纸条撕掉
}

多这一行代码,能省掉未来某个深夜排查"为什么数据少了一半"的时间。

增量同步:PageHelper 反而更合适

增量同步的场景不一样:每次只拉"最近几分钟变更的数据",量很小,翻不了几页。这种场景下 PageHelper 的 count 开销可以忽略,代码还能跟项目里其他 Mapper 保持一致的写法。

java 复制代码
@Scheduled(fixedDelay = 60_000)
public void incrementalSync() {
    LocalDateTime since = lastSyncTime.minusSeconds(5);
    LocalDateTime until = LocalDateTime.now();  // 注意:固定住,别在循环里算

    int pageNum = 1;
    while (true) {
        PageHelper.startPage(pageNum, 500);
        List<ProductDO> rows = productMapper.selectChangedBetween(since, until);

        if (rows.isEmpty()) {
            break;
        }

        writeBatch(rows);
        pageNum++;
    }

    lastSyncTime = until;
}

这里有个容易忽略的细节 :until 必须在循环外面就算好,不能在每一页里调 LocalDateTime.now()。否则你翻页的过程中新数据不断写入,一直有"刚刚变更"的记录进来,循环可能永远停不下来。

什么时候用哪个:一张表说清楚

情况 用哪个 为什么
项目里到处是 PageHelper,不想搞两套写法 PageHelper,但记得传 false 风格统一,维护成本低
全量数据 100 万条以内 PageHelper 速度够用,代码简单
全量数据 1000 万条以上 游标分页 PageHelper 越翻越慢,最后几页可能跑不完
增量同步(按时间窗口拉) PageHelper 每次量小,count 开销可以忽略
需要显示"同步进度百分比" 单独跑一次 count 缓存起来 别让每页都去 count

总结:PageHelper 做同步,记住四句话

  1. startPage 和查询必须挨着,中间插别的查询会出大事。
  2. 同步任务传 false 跳过 count ,判断翻完没翻完用 rows.isEmpty(),别信 PageInfo。
  3. 数据量超过千万,全量同步换游标分页 ,LIMIT 深分页是 MySQL 的硬伤,PageHelper 救不了。
  4. try-finally 里加一句 clearPage() ,防止线程池复用导致下一次任务莫名被分页。

增量同步继续用 PageHelper 完全没问题,量小、窗口固定,上面这些坑基本踩不到。全量同步才是需要仔细挑方案的地方------数据量小就用 PageHelper 图省事,数据量大就老老实实换游标。

相关推荐
金銀銅鐵1 小时前
[Java] 借助GUI展示class文件的内容(v1.0版本)
jvm·后端·ai编程
皮皮林5512 小时前
Java 导出 Excel 利器:JXLS
后端
吃饱了得干活2 小时前
从 LIKE 查询到毫秒级搜索:Elasticsearch 核心原理与 Java 实战
后端·elasticsearch
Flynt2 小时前
我把真实的 ES 查询怼到 7059 星的 Rust 搜索引擎上:1366/1369 的兼容承诺,裂了 3 个缝
elasticsearch·搜索引擎·rust
tianbin9114 小时前
大模型训练与推理:API中转站在企业AI落地中的隐藏价值
大数据·后端·ai
打工仔折腾 AI4 小时前
用 Dashlet 把极空间 NAS 服务入口整理成统一导航首页的实践
java·开发语言·人工智能·后端·python·rpc·ai agent 实战
环境栈笔记5 小时前
指纹浏览器 WebRTC 怎么设置?从 ICE 候选、代理出口到功能验证
前端·人工智能·后端·自动化