1. 先别慌,把“看不见”的原因圈定出来
做后台开发好多年,数据库里躺着一条数据,前端列表却死活查不出来,这事儿几乎每个写业务代码的人都撞见过。尤其现在是前后端分离,前端一句“接口返回空数组”,后端一顿操作猛如虎,最后发现数据就在那儿,安静得像在嘲笑你。
分页查询找不到数据但库里有记录,听起来像玄学,其实是几个固定原因在轮流捣乱。我通常把它分成三层排查:第一层是参数传错了,第二层是SQL条件过滤了,第三层是事务或架构层面的“时差”。今天就把这套排查思路完整写出来,踩过的坑、查过的方法、最终定位的手段全在里面,新老司机都能直接抄作业。
先记住一个总原则:先看前端拿到什么参数,再看后端SQL打印出什么语句,最后看数据库事务隔离级别和主从链路。只要这三层过一遍,90%的“灵异事件”都会现出原形。剩下的10%,往往是分页插件和数据库方言在底层悄悄作祟,这种坑虽然恶心,但定位路径一样。
1.1 死磕传参层:从“前端页码”追溯到“后端偏移量”
最常见的坑就是页码逻辑不一致。前端UI上显示的是第1页、第2页,但接口协议里pageNo到底是0还是1,很多人根本没对齐。我见过一个项目,前端默认传page=1,后端拿到之后直接limit (page-1)*size, size,看起来没毛病对吧?结果前端组件库的初始值也是1,第一次请求没问题,翻到第二页却查出来第一页的数据,再往后翻直接空白。
还有一种更隐蔽的:前端把参数拼错了。比如调列表接口时,把筛选条件里的status拼到了分页参数上,或者页码字段名写成了current,后端约定的却是pageNum。Spring MVC那种参数绑定严格的项目会直接报错,但用Map接收参数的项目静默吞掉了错误,后端拿到的pageNum是null,默认值又写成了1,于是永远查第一页,而第一页的数据恰好被逻辑条件过滤光了。
排查这类问题,我的土办法很管用:在Redis或日志里把Controller入口的原始参数打印出来,跟前端Network面板里的Query String Parameters逐字对比。别小看这一步,能直接筛掉一半以上的低级事故。如果你用的是MyBatis-Plus,记得看一下PaginationInnerInterceptor的配置,Overflow参数是否开启会决定超过总页数时是返回空还是返回最后一页,这个后面讲分页插件时细说。
1.2 条件过滤层的“隐形墙”:明明有权限却查不到人
第二层是SQL层面的过滤。数据存在,但WHERE条件把那条记录屏蔽了,而且这种屏蔽往往很隐晦。最常见的是逻辑删除标记,比如表里有个deleted字段,0代表正常,1代表已删除。业务代码里一句WHERE deleted = 0是标配,但假如某条数据是旧系统迁过来的,deleted字段是NULL,那deleted = 0的分支永远匹配不到它。
还有权限过滤。部门经理只能看本部门数据,SQL里就会追加AND dept_id = ?。如果当前登录用户在数据权限表里的映射关系坏了,或者部门树调整导致dept_id对不上,那么他自然什么都查不到。这种问题单看SQL语句是看不出来的,必须结合当前用户的上下文去看,我通常直接在测试环境模拟一个超级管理员账号,把权限过滤的拼装逻辑关掉,再查同一条SQL,如果数据出来了,那问题就锁定在权限拦截器上。
另外,连表查询里Innodb的默认排序也会造成误解。比如ORDER BY create_time DESC,但查询结果里个别记录的create_time字段是NULL,在MySQL里NULL排序是排在最前面的,如果第一页正好被这群NULL占了,第二页翻过去可能就断层了。虽然这不算“查不到”,但观感上跟“数据消失”一模一样。
2. 数据明明提交了,查询却看不到?掰开事务隔离级别看看
如果你在前端操作完新增、编辑,紧接着就去查列表,刷新后发现查不到,别急着改代码,先想想事务提交了吗?这中间有一个特别容易踩的坑:前端调用了新增接口,但后端业务方法里事务还没提交,或者提交失败被异常吞掉了,前端却拿到了“成功”的响应,于是立刻发起查询请求。查询线程如果走的是另一个数据库连接,由于隔离级别的存在,可能读不到尚未提交的数据。
2.1 未提交事务与MVCC快照读:一次真实的事故复盘
我之前排查过一个订单模块的问题。用户支付成功后创建了订单,跳转到订单列表页,接口返回“暂无数据”。我当时第一反应是SQL错了,查了半天没查到。后来把MySQL的general_log打开,发现新增订单的INSERT语句确实执行了,也拿到了自增主键,但紧接着的列表查询却没有带任何条件,直接SELECT * FROM order_info WHERE user_id = xxx LIMIT 0, 10。
看着SQL没问题,问题出在事务上。新增订单的方法上标了@Transactional,内部调用了第三方支付网关,支付回调比较慢,事务一直没走完。而列表查询走的是另一个Service方法,默认的隔离级别是REPEATABLE READ,它开启快照读的一瞬间,订单还没提交,自然读不到。等事务真正提交后,第一条快照还读不到,只有等到下一次开启快照读才能看到。这种“神隐”现象太容易误导人了。
所以排查这类问题,先确认一个事实:列表查询的那个连接,和写操作的那个连接,是不是同一个数据库连接。在Spring里,如果没有用@Transactional强制指定写连接,读操作通常会走连接池里随机分配的一个连接,共享主库数据但快照读时机不同。最简单的绕开办法是:在写操作返回之前,强制把当前事务提交掉(或者让写操作先于读操作完成),必要时在接口上标注读写分离策略,让上游强制路由到主库。
2.2REPEATABLE READ下的快照一致性:为什么MySQL自己查得到,程序查不到
很多刚接触MySQL的人不理解,为什么在Navicat里查那条数据明明能看到,程序里就是查不到?这是因为MySQL默认隔离级别REPEATABLE READ下的快照读。Navicat打开一个新会话,开启了一个新的Read View,它看到的永远是那个时刻已提交的数据。程序的长连接可能会复用之前开启的Read View,如果这个连接是从连接池里拿出来的,并且事务还没结束,那么后续语句看到的都是第一次查询时的快照。
我之前就在生产环境碰到过这种问题:某个Service方法里先做了一次分页查询(开启快照),接着同一条连接上执行了一个UPDATE操作,然后再次查询同一张表,数据居然消失了。因为UPDATE之后虽然数据变了,但快照读的Read View没有更新,旧快照里那条数据的状态还是“不可见”的。解决方案也简单:不要在同一个事务里混用查询和更新,或者干脆把隔离级别下调到READ COMMITTED,代价是并发写入时幻读概率增加,但大多数业务系统根本感知不到。
如果你用的是Spring + MyBatis,还能在代码里加一行@Transactional(isolation = Isolation.READ_COMMITTED)临时验证,如果你把隔离级别改低之后数据能正常查到,那基本实锤了事务隔离级别背着锅。
3. 分页插件和LIMIT语法的隐藏坑:看似查了数据,实则查了寂寞
分页查询归根到底就是LIMIT语句的问题。但分页插件(比如PageHelper、MyBatis-Plus的分页拦截器)在底层会做很多额外工作,比如拦截SQL、改写SQL、解析Count查询等,一旦“数学没学好”,就会出现边界条件Bug。
3.1 翻页时翻过“最后一页”:边界算错导致空数据
分页查询一个非常典型的边界Bug:总共有10条数据,每页10条,第1页返回正常,点击第2页时,明明数据库里还是有数据的,但接口返回了空数组。为什么?因为后端把页码基数搞错了。
如果约定pageNum从1开始,总页数算出来是(total + pageSize - 1) / pageSize,第2页的偏移量是(2 - 1) * 10 = 10,那么查第11条到第20条,但库里只有10条,自然返回空。这时候很多人会怀疑是数据没查询出来,其实是前端把“当前页”传成了0,或者后端在计算偏移量时没有对pageNum <= 0的情况做保护,导致偏移量是负数,SQL直接报错或返回空集。
更隐蔽的情况出现在分页插件身上。PageHelper的Page对象一旦超过总页数,如果开启了合理性检查,会强制跳到合理页;如果没开启,就会拼出一条偏移量巨大的SQL。由于MySQL执行这种SQL需要扫描大量行,一旦查询超时,表现就是接口一直转圈,最后返回失败或空数据。所以排查时我会先核对前端传的页码和总页数,再在后台打印出PageHelper最终生成的分页SQL,把偏移量代入数据库手跑一遍,看能否出结果。
3.2 MySQL大偏移量的性能陷阱:数据存在但你等不到它
有一种“数据存在但你查不到”的情况,本质是超时。当数据量一大,比如单表几百万行,你执行LIMIT 100000, 10,MySQL必须扫描前100000行再扔掉,这可不慢。App层设置的查询超时时间一过,连接被断开,前端拿到的结果就是“无数据”。
解决思路不是避免分页,而是换一种分页方式。第一种叫“游标分页”,基于上一页最后一条记录的ID来限定范围,比如WHERE id > 100 ORDER BY id LIMIT 10,这样每次都走索引,速度极快,数据量再大也不慌。第二种是延迟关联,先查出需要的主键集合,再回表取完整数据,也能大幅降低偏移量扫描成本。
我之前优化过一个报表查询,当时用的还是PageHelper不停往后翻页,一旦翻到第50页就卡得怀疑人生,接口超时返回“暂无数据”。后来改成游标分页,把前端页码逻辑改成“加载更多”模式,压测数据翻了几百页依旧毫秒级返回,成本和体感都好太多。所以如果你遇到“数据库有数据但前端报暂无数据”,别忘了顺手看一眼是不是深分页的超时在作怪。
4. 主从复制延迟与多数据源路由:架构层面的“时间差”
如果单表、单库都没查出问题,SQL也没毛病,那就要往上层架构看,尤其是读写分离的主从延迟。很多公司数据库架构都是主库写、从库读,Spring配置里可能同时注入了主数据源和从数据源,或者通过中间件做了主从路由。在这种架构下,一个非常经典的坑是:刚往主库插了一条记录,立刻去查询列表,查询请求被路由到了从库,而从库的BinLog还没同步过来,刚才那条数据自然找不到。
4.1 主从延迟的“幽灵数据”:刚写完就查不到
主从延迟的时间通常在毫秒到秒级,平时感知不到,但一旦业务峰值一到,主库压力大,从库复制延迟上升到好几秒,这种“幽灵数据”现象就会集中爆发。我之前在一家电商公司就处理过一起工单,运营反馈说后台新建了一个商品,刷新商品列表却看不到,下架再上架又正常了,这样的case八成就是主从延迟。
排查这种问题,直接看从库的Seconds_Behind_Master状态值,如果你的监控面板上有主从复制延迟指标,一眼就能确认。解决思路主要有三种:第一种是在写操作结束后,立刻在执行线程内再压一个读操作,并强制走主库查询;第二种是对于“刚创建完马上查看”的高概率场景,把写后读的接口强制路由到主库;第三种是前台做“假成功”优化,先把新数据追加到内存或Redis,等异步同步完成后再从缓存里展示。
4.2 多数据源路由失败:或者你压根没走要走的那个库
还有一种情况是,系统里配了多个数据源,但代码在动态切换时失败了。比如Nacos配置中心里配了订单库和用户库,结果列表查询的DataSource切成了历史库,当然查不到新写入的数据。这种错误排查起来特别闹心,因为你在本机连的订单库怎么查都有数据,一上生产就查不到。
我习惯在接线上加一个切面,把当前线程绑定的数据源名称打到日志里,对于高频查询接口,直接看日志里走的是哪个数据库连接串。如果发现走到了只读从库或者错误的分片,那就去查DynamicDataSourceContextHolder的切库逻辑。还有个小技巧,你可以在SQL后面拼接一个注释,比如/*master*/ SELECT...,然后在数据库中间件上强制该条SQL走主库,很多MySQL Proxy版本都支持这种路由标记,调试时很好用。
5. 复盘实录:我从这个坑里挖出来的“保命”检查清单
前面讲的所有场景,总结成一句话就是:分页查询返回空不等于数据库无数据,要查链路、查条件、查时机、查路由。接下来我把自己工作中沉淀的排查套路完整摊开,你照着做,几分钟内就能定位绝大多数问题。
5.1 一套能覆盖95%场景的SQL排查法
不管多玄学的Bug,我习惯先跑到数据库命令行里,把SQL自力更生执行一遍。逻辑顺序如下:
- 先跑基础SQL:
SELECT * FROM 表 WHERE 主键 = ?,确定那条数据本身是存在的。条件过滤的问题通过这一步基本排除。 - 再跑业务SQL:把后端打印出的完整SQL(包括所有条件)复制到数据库中,替换成真实参数执行。如果查不到,看是哪个条件把它滤掉了,直接一句句去掉条件二分定位。
- 接着跑排序SQL:把
ORDER BY字段一起带上,确认排序字段是否有NULL值,是否因为排序导致数据显示位置靠前或靠后。 - 最后跑分页SQL:分别执行
LIMIT 0,10和LIMIT 900,10,对比返回行数。确认是不是因为偏移量过大走了慢SQL超时。
我遇到过大概四成案例,最终都定位在第2步——业务SQL里多了一个deleted = 0,而那条数据恰巧是历史脏数据,值是1或NULL。给表加个索引前,先把脏数据清洗干净,比啥都强。
5.2 写在代码里的几个小习惯,能让你少熬几个夜
第一,分页查询的代码里不要把页码计算逻辑藏太深,最好封装成一个工具类,统一处理pageNum <= 0时的默认值,以及pageNum > totalPage时返回最后一页,这样能避免边界Bug连环爆。
第二,所有列表接口的返回结构里务必带上total和currentPage,前端拿不到这两样东西,就会盲猜,猜错了自然把页码传飞。我见过不少前端拿total当pageNum传的,导致查询条件完全错位,最后全怪后端。
第三,事务方法里尽量不要先查后改,万一必须得这么写,记得给查询方法单独标注传播行为REQUIRES_NEW,强制新开事务获取最新快照,免得被老事务卡住视野。
第四,主从分离的项目,把数据库中间件的强制主库路由参数配好,对一些关键接口就硬性走主库,宁可牺牲一点读性能,也要保住数据一致性。尤其后台管理、运营中台这类系统,实时性远大于吞吐量,主从延迟这种事根本不该背锅。
最后再分享一个压箱底的方法论:任何“查不到”问题,严格按照看参数、看SQL、看事务、看路由四步走,别一上来就怀疑代码逻辑。代码逻辑其实很诚实,SQL执行不了就是执行不了,条件不匹配就是不匹配,反而是分页、事务、路由这三座“大雾山”最容易让人迷路。把这套思路存脑子里,下次再有人拍你工位说“数据库有数据怎么查不到”,你可以淡定地打开命令行,告诉他:“来,咱们让数据自己说说它在哪儿。”