先说人话:PageHelper 在同步任务里干了什么
平时写列表接口,你肯定写过这种代码:
java
PageHelper.startPage(1, 10);
List<Product> list = productMapper.selectAll();
背后 PageHelper 干了两件事:
- 往当前线程里塞了个小纸条,写着"我要第 1 页,每页 10 条";
- 你紧接着调 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 做同步,记住四句话
startPage和查询必须挨着,中间插别的查询会出大事。- 同步任务传
false跳过 count ,判断翻完没翻完用rows.isEmpty(),别信PageInfo。 - 数据量超过千万,全量同步换游标分页 ,
LIMIT深分页是 MySQL 的硬伤,PageHelper 救不了。 try-finally里加一句clearPage(),防止线程池复用导致下一次任务莫名被分页。
增量同步继续用 PageHelper 完全没问题,量小、窗口固定,上面这些坑基本踩不到。全量同步才是需要仔细挑方案的地方------数据量小就用 PageHelper 图省事,数据量大就老老实实换游标。