最近在排查一个线上问题时,发现接口返回的数据总量(total)与实际记录数(items.length)不一致。比如分页查询返回total=100,但items数组只有10条记录。这种数据不一致性会导致前端分页组件显示异常,严重影响用户体验和数据可信度。
这种情况在分页查询中其实非常常见。根据我的经验,当系统出现以下症状时,很可能遇到了类似问题:
大多数分页查询的实现流程是这样的:
sql复制-- 先查询总数
SELECT COUNT(*) FROM table WHERE conditions;
-- 再查询当前页数据
SELECT * FROM table WHERE conditions LIMIT offset, pageSize;
这种"先查总数再查数据"的两步操作,就是问题的温床。在高并发场景下,两次查询之间的数据可能已经发生了变化。
考虑这个时间线:
这样用户就会看到"显示1-10条,共100条",但实际上数据库只有90条记录。更糟的是,当用户翻到最后一页时,可能会得到空列表。
除了并发修改,以下情况也会导致总数与记录数不符:
最彻底的解决方案是在事务中执行两次查询:
java复制@Transactional
public PageResult queryPage(QueryCondition condition) {
long total = dao.count(condition);
List<Item> items = dao.queryList(condition);
return new PageResult(total, items);
}
重要提示:事务隔离级别至少要是REPEATABLE_READ(MySQL默认级别)。READ_COMMITTED仍可能出现幻读问题。
对于MySQL,可以使用SQL_CALC_FOUND_ROWS和FOUND_ROWS():
sql复制SELECT SQL_CALC_FOUND_ROWS * FROM table WHERE conditions LIMIT 0,10;
SELECT FOUND_ROWS() AS total;
虽然这只需要一次数据查询,但要注意:
如果允许短暂的数据不一致,可以考虑:
这种方案适合对实时性要求不高的场景,能显著降低数据库压力。
无论后端如何保证,前端都应该做好防御性编程:
javascript复制// 实际显示总数取Math.min(返回total, 实际记录数推算值)
const displayTotal = Math.min(response.total,
(response.page - 1) * response.pageSize + response.items.length);
当表数据量超过百万时,COUNT(*)会变得很慢。可以考虑:
在分库分表环境下,获取准确总数代价很高。常见的折中方案:
对于无限滚动分页,可以改用游标分页:
json复制{
"next_cursor": "1234567890",
"items": [...]
}
这种方式完全不需要总数统计,更适合动态数据集。
在我的基准测试中(100万数据表):
建议对以下指标进行监控:
自定义PageImpl可以自动处理总数校正:
java复制public class SafePage<T> extends PageImpl<T> {
@Override
public long getTotalElements() {
return Math.min(super.getTotalElements(),
(getNumber() * getSize()) + getNumberOfElements());
}
}
改造PageHelper插件,增加事务支持:
java复制@Intercepts(@Signature(type= Executor.class, method="query",
args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class TransactionalPageInterceptor extends PageInterceptor {
// 重写query方法,确保在事务内执行
}
遵循Relay连接规范设计分页:
graphql复制{
items(first: 10) {
totalCount
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
}
}
}
确保覆盖以下测试用例:
示例测试代码:
java复制@Test
public void testPageConsistency() {
// 初始插入100条
insertTestData(100);
// 启动一个线程删除50条
new Thread(() -> {
deleteTestData(50);
}).start();
// 查询分页
PageResult result = service.queryPage(new PageRequest(1, 10));
// 验证总数不超过 (page-1)*size + items.size
assertTrue(result.getTotal() <= 10 + result.getItems().size());
}
当接到"分页数据不准"的投诉时,按以下步骤排查:
对于高并发系统,分页查询的优化方向:
在实际项目中,我通常会根据业务特点选择组合方案。比如对于运营后台使用精确分页,而对C端用户展示使用近似分页+缓存策略。