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

11 阅读6分钟

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

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

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 查询拿走。听起来没问题,但如果你在中间插了别的查询呢?

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:“别数了”:

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

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

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 机制决定的。

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 兜底:

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

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

增量同步:PageHelper 反而更合适

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

@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 图省事,数据量大就老老实实换游标。