☰
分页查询查不到数据?从SQL到事务的完整排查指南
2026/10/9 3:44:13 网站建设 项目流程

“数据库里明明有这条记录,列表查询却为空”“总数对不上,第二页开始少了几条”“加了筛选条件后分页接口直接空list,可我复制SQL去Navicat一查又有数据”——这几句话基本就是分页查询问题里最典型的“灵异现场”。我做后端开发这几年,几乎每隔一阵就会接到这类排查请求,尤其是项目上了读写分离、接了缓存、用了MyBatis-Plus这类自动拼接SQL的框架之后,触发概率成倍上升。

分页查询找不到数据、但数据库又存在数据,绝对不是单一原因能解释的。它可能藏在事务隔离里,可能藏在排序不稳定里,也可能藏在缓存、多数据源、逻辑删除、租户拦截器、分页参数计算里。这篇文章我会直接把这些年踩过的坑摊开聊,按问题发生的环节逐层拆解,最后给你一套能直接拿去用的排查流程和堵漏方案。不管你是刚入行的后端开发,还是已经在带团队的资深工程师,照着这个思路查,基本都能在半小时内定位到根因。

1. 先从现场说起:分页查询“查不到”到底长什么样

1.1 最常见的三种出错现场

第一种是列表数据缺页。比如第一页10条正常,第二页开始内容突然和第一页重复,或者干脆往后翻三页、五页时出现空白。这种问题特别迷惑人,因为前几页看起来完全正常,很容易让人怀疑是前端滚动加载或者分页组件的问题。

第二种是总数对不上。接口返回total是106,但业务方实际往数据库里硬插了130条。总数是count(*)算出来的,理论上不该出错,但只要涉及缓存、事务、逻辑删除,总数和明细就可能来自两个完全不同的“数据版本”。

第三种是条件查询不到,但全表分页能查到。比如不加where条件,分页列表里有这条订单;一旦加了“用户ID=888”这个筛选,这条订单就消失了。这种往往指向框架自动拼接条件、租户隔离或SQL拼接顺序问题。

1.2 分页链路里到底哪些环节可能丢数据

很多人一上来就盯着SQL看,这没错,但分页查询是一条完整链路:浏览器页码 → 前端参数 → 接口入参 → 服务端组装查询条件 → ORM/MyBatis生成SQL → 连接池获取连接 → 路由到主库或从库 → 数据库执行 → 结果集返回 → 缓存读取/写入 → 序列化 → 前端渲染。

任何一个环节出问题,最后表现都是“查不到数据”。常见排查误区是一下扎到数据库层面,却忽略了连接池拿到的连接可能连错了库,或者请求根本没有走到真实库,而是从Redis缓存里返回了残缺数据。

1.3 排查前先准备好这几样东西

不要空手查。我每次接手这类问题,第一件事就是让业务方提供复现参数:第几页、每页多少条、筛选条件是什么、大概在哪个时间点发生的。有了参数才能定位;没有参数只能靠猜。

同一时间我还会确认三件事:应用用的什么环境(测试/预发/生产)、数据库连接串指向哪个实例、有没有开慢查询日志或general_log。强烈建议在排查前把三个位置的SQL都拉出来:应用日志里打印的最终SQL、数据库binlog或慢日志里的真实SQL、以及你在数据库客户端手工执行SQL的结果。三者一对比,问题往往当场就能看清。

2. 原因一:数据库事务和隔离级别把你的数据“藏”起来了

2.1 未提交事务:自己能看到,别人看不到

这个场景极其常见,而且特别容易在联调环境发生。某同事往订单表insert了一条记录,还没来得及commit(甚至代码里根本忘了commit),然后你这边分页查询去查,自然查不到。查不到不代表数据不存在,而是数据还在那个连接的事务里,对其他事务不可见。

MySQL默认的存储引擎InnoDB,在REPEATABLE READ隔离级别下,普通SELECT是快照读,读取的是事务开始时生成的快照。别人新增的数据只要没commit,你的查询就绝对看不到。哪怕对方执行了commit,如果你的查询事务在对方commit之前就已经启动了,快照也不会更新,照样看不到。

验证方法很简单:

-- 查看当前是否有长时间未提交的事务 SELECT * FROM information_schema.innodb_trx\G -- 查看线程状态 SHOW FULL PROCESSLIST;

如果发现某个连接一直处于Sleep状态,但trx_started时间已经很长,基本就是有事务没提交。这种问题最气人的地方在于:业务方打开数据库客户端手动一查,明明能看到数据,因为客户端是新开连接、新开事务,当然能看到;而应用服务里那个连接才是“瞎子”。

2.2 默认隔离级别REPEATABLE READ下的快照读陷阱

这里要展开讲一下快照读的机制。REPEATABLE READ是MySQL的默认隔离级别,在事务第一次执行SELECT时生成一个ReadView,之后整个事务内的所有普通SELECT都复用这个ReadView。这意味着同一个事务内,你多次分页查询同一张表,看到的数据是“冻结”的,永远等于第一次查询那一刻的数据版本。

举个例子:你在Service方法里用@Transactional包了一个大事务,事务内先做了分页查询第一页,然后业务逻辑处理了比较长时间,期间别人往表里插入了几条新数据并提交。等你在事务内再查第二页时,查到的还是旧快照,而数据库客户端另开的连接已经能看到新数据。这就是“数据库有,分页没有”的经典来源。

2.3 事务范围没控制好,是大多数应用侧根因

很多框架,比如Spring的@Transactional,使用非常方便,但也容易埋雷。我见过最夸张的一次,一个导出报表的接口把事务开到了方法入口,里面有大量远程调用和文件处理,整个事务存活了十几秒。期间该事务对某张状态表的查询全部基于旧快照,导致导出的分页数据丢失了最新记录。

排查这类问题,直接把事务范围缩小到必需的数据库操作上,或者完全去掉只读场景的@Transactional。如果确实需要事务,尽量控制事务内不做远程调用、不等待锁、不执行耗时逻辑。

实操心得:在排查“分页查不到”时,第一步就查information_schema.innodb_trx,配合performance_schema.events_statements_history看一眼当前连接最近执行过什么。很多时候根因还没进入SQL优化,就已经在这步真相大白了。

3. 原因二:分页排序不稳定,同一条数据在每一页之间“漂移”

3.1 没有ORDER BY时,数据库不保证返回顺序

很多人写分页SQL时习惯只写LIMIT,不写ORDER BY。这在MySQL里是非常危险的。数据库优化器会根据它认为最优的访问路径返回数据,比如走哪个索引、用不用临时表、是否并行扫描。同一个查询,这条记录这次在第二页,下次可能就跑到第三页去了。

我第一次踩这个坑是在一个订单列表上,当时项目里一位同事图省事,分页SQL只写了LIMIT #{offset}, #{size}。前10条数据倒是正常,但翻到第二页时,和第一页完全重复;实际查询出来的数据条数也少于正常业务预期。后来加上明确的排序条件,问题立刻消失。

3.2 排序字段重复,是“漏数据”的隐藏杀手

更隐蔽的是:你明明写了ORDER BY create_time DESC,但create_time在表里有大量重复值。比如秒级时间戳,同一秒内插入了上百条订单,那么这批create_time相同的记录,在每次分页查询时的相对顺序完全取决于索引扫描顺序、缓冲池状态、甚至并发连接数。结果是同一行记录可能同时出现在第一页和第二页,而另一行则被“挤”出这一批,几页翻下来,数据不是多了就是少了。

这类问题要定位,需要对比第一页和最后一页拿到的ID集合,看看有没有出现交叉。

3.3 怎么设计稳定排序键组合

稳定的分页排序必须满足“全局唯一可比较”的约束。最稳妥的方案是加一个主键或唯一键作为二级排序条件:

SELECT order_id, order_no, amount, create_time FROM t_order WHERE deleted = 0 AND create_time < '2024-05-01 00:00:00' ORDER BY create_time DESC, order_id DESC LIMIT 10;

主键的唯一性保证相同排序值下,每行记录依然有确定的先后顺序。

如果表数据量特别大,建议放弃LIMIT offset, size这种深分页模式,改用游标分页(Keyset Pagination):

SELECT order_id, order_no, amount, create_time FROM t_order WHERE (create_time, order_id) < ('2024-05-01 00:00:00', 10086) ORDER BY create_time DESC, order_id DESC LIMIT 10;

游标分页的好处是无论翻到第几页,数据库都只扫描目标范围内的记录,性能稳定,也不会因为别的记录插入、删除导致页码错乱。

4. 原因三:读写分离、缓存和同步工具让“查询源”和“真库”不是同一个

4.1 主从复制延迟:写主库,读从库,从库还没同步

上了读写分离后,最常见的分页问题就是:业务操作刚把数据写入主库,紧接着就调用分页查询接口,结果查不到。原因非常直白——你的查询路由到了从库,而从库的复制延迟可能高达几百毫秒甚至数秒。

我在一次排查中亲眼见过从库落后主库二十多分钟的情况,主从同步的线程被一个大事务阻塞,导致那个时间段所有订单列表查询都少了最新数据。

处理方案:

  • 核心读场景强制走主库。比如刚提交订单后立刻查订单列表,可以在代码里给这个查询打上强制主库读的标记。大多数ORM/数据源框架都支持这个能力,比如MyBatis的@Master注解、ShardingSphere的ReadwriteSplittingRule配置。
  • 用数据库同步软件做从库同步时,要配置监控告警。像Canal、DataX这类同步工具,断点续传、延迟监控必须配上,否则从库延迟会在业务高峰时悄悄拉大。
  • 实在对一致性要求高,采用“写后读”补偿:写入后短暂延迟100~200毫秒再查列表,或者用SELECT ... FOR UPDATE强制读主库最新版本。

4.2 Redis缓存命中旧数据,导致分页内容不完整

缓存引发的问题更难排查,因为应用日志里SQL都正常,数据库也有数据,就是接口返回不对。常见套路是:列表接口先去Redis查key为order_page_1_10的缓存,命中了就直接返回,但这个key是半小时前生成的,后续新增的订单根本没有更新到缓存里。

这里容易犯的一个错误是只刷新total、不刷新list,或者只清掉第一页的缓存、不清后面页。Redis缓存列表数据时,建议采用写后删缓存策略:先更新数据库,再删除相关分页缓存,而不是尝试“更新缓存里的某一条”。删除比更新简单可靠得多,缓存Miss后下次请求会重新从数据库加载完整数据。

如果对极端一致性有要求,还要考虑“延迟双删”:更新数据库后删一次缓存,隔几百毫秒再删一次,避免并发情况下旧缓存被重新写回。

4.3 多数据源/分库分表配置串了,连的根本不是同一张表

连接池配置错误导致连错库,这个问题我在老项目里见过多次。有次排查一个报表分页问题,应用配置里数据源指向的是测试库,业务方却在生产库灌的数据,列表当然查不到。还有分库分表中间件配置了按用户ID取模路由,分页查询时路由到的分片里没有目标数据。

排查这类问题,最简单有效的方法是在数据库客户端执行SELECT DATABASE()和SELECT @@hostname,确认当前连接的服务实例和库名,再对比应用配置里的JDBC URL。

如果代码里配置了MyBatis多数据源,注意事务路由是否正确。有些团队用AOP切面做动态数据源切换,切面顺序不对或者事务已经开启,就会导致整个查询走错数据源。我之前排查过一个“偶发查不到”的问题,后来发现是连接池里两个数据源共用了同一个连接,某个连接被重置后回到了连接池,再次取出时把事务上下文带乱了。

5. 原因四:逻辑删除、权限过滤和框架自动拼接的“隐形条件”

5.1 软删除字段(deleted/status)带来的假阳性

逻辑删除是很多系统的标配:删除操作只是把deleted字段置为1,不真正删行。但这也给“数据库明明有数据、分页查不到”提供了最肥沃的土壤——你手工在数据库查询时直接SELECT * FROM user WHERE id=123,能查到;而应用代码里所有查询自动带上AND deleted=0,自然查不到。

还有更隐晦的情况:删除操作执行了一半。比如批量更新把一批数据的deleted字段全置为1了,但是业务逻辑没有走完,没有补一条日志;或者某次数据订正SQL写错了条件,误伤了正常数据。这种问题不看应用生成的最终SQL,根本意识不到。

处理建议:排查这类问题时,手工执行SQL不要省略条件。你要按应用日志里打印出来的最终SQL原样执行,不要自己“简化”成SELECT * FROM xxx WHERE id = ?。

5.2 多租户/数据权限隔离在SQL上动了手脚

MyBatis-Plus的TenantLineInnerInterceptor是自动拼装tenant_id = ?条件的典型。如果一条数据插入时没有写入tenant_id,或者写入的租户ID和当前登录用户的租户不一致,分页查询死活查不到。

类似的还有数据权限拦截器:部门数据权限、角色可见范围等。企业级应用里经常会有这种需求,比如普通用户只能看到自己所在部门的数据,管理员才能全部可见。自定义拦截器一旦拼接逻辑出错,轻则条件拼错,重则SQL直接查不到数据。

排查这种方式:先看应用日志打印的完整SQL,把tenant_id = ?那段拿掉后再手工执行。如果去掉后能查到,问题就在自动拼接条件上。

5.3 MyBatis-Plus / 通用Mapper 生成的SQL和你想的不一样

用MyBatis-Plus的Page对象做分页时,它会在你写的Wrapper基础上自动拼LIMIT。但注意,MyBatis-Plus的Page查询和手动LIMIT有个细微差别:Page对象查询会先执行统计count,再查询数据。如果这两条SQL之间正好有人删改数据,count和数据就可能来自两个不同瞬间。

还有一种典型情况是Wrapper里某个条件被and和or的组合搞坏。比如:

queryWrapper.eq("status", 1).or().eq("source", 2)

不加括号时,生成的SQL条件优先级可能变成status = 1 OR source = 2,再把分页条件一拼,结果完全不同。排查这类问题,一定要把MyBatis日志配置为输出完整SQL,然后拿着SQL手工执行,对比结果。

6. 原因五:分页参数、总数统计和前端页码的“连环坑”

6.1 pageNo、pageSize、offset这些参数到底谁对谁

分页参数混乱是新手最容易踩的坑,而且这类问题通常表现为“第一页正常,后面全乱”。

常见错误包括:

  • pageNo从0开始还是从1开始没对齐。前端传1,后端按0处理,结果天然少一页。
  • pageSize为0或者负数时直接返回空集。
  • 手工拼SQL时offset计算错误,写成了(pageNo) * pageSize,而不是(pageNo - 1) * pageSize。
  • 前端组件自动把页码当成从0开始,但后端接口文档说从1开始。

我遇到过最夸张的一个Bug:后端把pageNo当1处理,LIMIT 1, 10,翻译过来是跳过第1条取10条,而不是取第一页。第一页看起来“少了第一条”,但如果第一条恰好是目标数据,用户就会觉得分页查询找不到数据。

正确的offset计算是:

int offset = (pageNo - 1) * pageSize;

如果使用PageHelper或MyBatis-Plus内置分页,不需要自己算offset,但必须保证传入的pageNo是语义一致的。

6.2 COUNT正确,但列表数据对不上

有一种情况是分页总数查询没问题(比如106条),但列表接口返回的结果集却是空的或不满10条。这个如果发生在带GROUP BY的SQL上,格外常见。比如报表统计SQL:

SELECT user_id, COUNT(*) AS cnt FROM t_order GROUP BY user_id ORDER BY cnt DESC LIMIT 10;

COUNT查询返回的是GROUP BY后的分组数,而数据查询返回的是明细行。如果底层表结构有变化,或者HAVING条件导致部分分组被过滤,分页就经常出现“总数正常但当前页没有数据”。

这类问题的排查关键是:分别独立执行COUNT语句和分页数据语句,看两者是否基于同一份数据集。如果发现COUNT用了独立接口、没有带分页条件,那就是计数和数据不一致。

6.3 前端缓存页码,删掉一条数据后全部错位

很少人注意到前端页面也会造成“分页查不到”。比如列表页做成了“前端分页”,数据一次性全部返回,然后在前端用slice((page-1)*size, page*size)切分。这种情况下,如果后端接口数据已经变更,前端缓存的分页数组没有刷新,用户看到的自然还是旧数据或缺失数据。

还有一种场景是删除操作之后,没有跳回第一页。比如用户在第5页删了一条数据,数据库总数从106变成105,但前端还停留在当前页码,请求第5页时,可能只有4条数据甚至返回空。说到底不是后端问题,而是交互逻辑问题。

排查这类问题,直接看浏览器Network面板里接口请求返回的JSON,对比页面渲染的结果。如果接口返回数据正确,问题一定在前端。

7. 一套能直接拿去用的排查流程和堵漏方案

7.1 五分钟快速定位表

下面这张表是我在实际排查中总结的问题速查,可以直接当应急预案用:

现象特征最可能的原因核实方法
刚插入的数据列表查不到事务未提交/主从延迟查innodb_trx、看从库延迟
第一页正常,后面翻页重复/漏排序不稳定补稳定排序键,对比各页ID
总数正确但当前页为空count与list数据来源不一致分别执行两条SQL
数据存在但全部查询都查不到软删除/租户条件去掉过滤条件手工执行
偶发查不到,时好时坏连接池/多数据源串库打印连接实例信息对比
某个环境有,另一个没有连错库/数据同步延迟对比JDBC URL和库名

7.2 逐层排查的十步操作法

第1步,复现问题并记录完整查询参数:页码、每页条数、筛选条件、排序字段、精确时间点。

第2步,打开应用日志的SQL打印。MyBatis设置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,打印执行SQL和参数。

第3步,把应用执行的那条最终SQL原样复制到数据库客户端手工执行,参数原样代入。手工执行有数据,应用查不到,问题在应用侧;手工执行也没数据,问题在SQL本身。

第4步,核对数据源。在数据库客户端执行:

SELECT DATABASE(), @@hostname, @@port;

再对比应用配置文件里的JDBC连接串,确认连的是同一个实例同一个库。

第5步,检查事务。SELECT * FROM information_schema.innodb_trx;,看有没有长时间未提交的事务。再查看应用代码里是否把@Transactional开到了不必要的大范围。

第6步,检查缓存。看分页接口是否在读Redis或本地缓存,缓存key的失效策略是什么。写操作后是否删了对应缓存。

第7步,替换排序条件。确认分页SQL有没有ORDER BY,排序字段组合是否唯一可确定顺序。把ORDER BY create_time DESC改成ORDER BY create_time DESC, id DESC再试。

第8步,检查自动拼接条件。看日志里SQL后面是不是多了AND deleted=0、AND tenant_id=xxx这类条件;在手工执行时逐个去掉验证。

第9步,检查分页参数计算。打个日志输出pageNo, pageSize, offset, count,确认数值是否符合预期。

第10步,修复后写回归测试。尤其是要覆盖“插入后立刻查询”“翻页多页后数据不重复不丢失”两个场景。

7.3 代码层面怎么加固

光会排查还不够,工程上要做几件防御性的事:

  • 统一分页参数结构。后端DTO里定义pageNo、pageSize、total、totalPages,前端和后端严格共用这套命名,避免转换偏差。
  • 禁止裸SQL拼接。分页逻辑尽量交给成熟的分页插件,不要自己手写字符串拼接,否则条件放错位置、参数漏传的情况很难防住。
  • 强制稳定排序。分页查询的排序条件必须在代码里校验,至少保证存在主键作为末级排序。没有排序字段时直接报错,而不是默默执行。
  • 监控关键状态。数据库连接池活跃数、主从同步延迟、事务运行时长、慢SQL数量这些指标都要收入监控,否则问题发生了你只会在业务方投诉时才察觉。
  • 缓存统一封装。在他读写删除缓存的操作封装成公共方法,强制“事务提交后删除缓存”的顺序,不给人留手动写漏的机会。

8. 几个让我印象深刻的真实案例收尾

先讲一个商城订单的案例。某天业务反馈用户下单完成后,订单列表看不到刚刚下的单。查了一圈SQL没问题,事务也没问题,最后发现是读写分离中间件把订单列表查询路由到了只读从库,而从库的复制延迟在高峰期达到2到3秒。用户下单后马上刷新列表,自然就“没订单”。修复方案很简单:用户查看自己最新订单的接口强制走主库,同时给用户提示“订单处理中”的缓冲状态。

再说一个困扰了好几天的报表分页问题。业务方说某个报表第7页之后查不到数据,但数据库里数据都在。最后定位到是报表SQL里带了GROUP BY,而COUNT统计用的是全量查询,数据明细查询用的是分组后的结果集;另外一个问题是分页排序字段是聚合函数计算出来的值,排序不稳定。两重因素叠加,导致分页数据一会多一会少。

最后提一个深分页的性能坑:一张千万级的流水表,后端用LIMIT 1000000, 20查最后一页数据,扫描回表几十万行,接口超时,前端直接把超时当成“没有更多数据”,表现就是分页到底之后查不到。后来改成游标分页,用上一页最后一条记录的ID和金额做边界,性能从秒级降到毫秒级,接口也稳定了。

这三个案例分别对应了我在前面说的主从延迟、排序稳定性、分页性能与参数问题。查分页问题的核心,永远是先确认“应用真正执行的SQL”和“数据库当前的真实状态”这两者是否一致。每次排查时我都提醒自己:不要相信任何中间环节的默认行为,事务、缓存、拦截器、连接池、分页插件,全是可疑对象。把最终SQL打印出来,在数据库客户端原样执行一遍,十有八九,真相就在那一条SQL里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询