多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

数据在库却查不到?分页查询排查思路:传参、SQL、事务与主从延迟

数据在库却查不到?分页查询排查思路:传参、SQL、事务与主从延迟 1. 先别慌把“看不见”的原因圈定出来做后台开发好多年数据库里躺着一条数据前端列表却死活查不出来这事儿几乎每个写业务代码的人都撞见过。尤其现在是前后端分离前端一句“接口返回空数组”后端一顿操作猛如虎最后发现数据就在那儿安静得像在嘲笑你。分页查询找不到数据但库里有记录听起来像玄学其实是几个固定原因在轮流捣乱。我通常把它分成三层排查第一层是参数传错了第二层是SQL条件过滤了第三层是事务或架构层面的“时差”。今天就把这套排查思路完整写出来踩过的坑、查过的方法、最终定位的手段全在里面新老司机都能直接抄作业。先记住一个总原则先看前端拿到什么参数再看后端SQL打印出什么语句最后看数据库事务隔离级别和主从链路。只要这三层过一遍90%的“灵异事件”都会现出原形。剩下的10%往往是分页插件和数据库方言在底层悄悄作祟这种坑虽然恶心但定位路径一样。1.1 死磕传参层从“前端页码”追溯到“后端偏移量”最常见的坑就是页码逻辑不一致。前端UI上显示的是第1页、第2页但接口协议里pageNo到底是0还是1很多人根本没对齐。我见过一个项目前端默认传page1后端拿到之后直接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, 10MySQL必须扫描前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自力更生执行一遍。逻辑顺序如下先跑基础SQLSELECT * 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执行不了就是执行不了条件不匹配就是不匹配反而是分页、事务、路由这三座“大雾山”最容易让人迷路。把这套思路存脑子里下次再有人拍你工位说“数据库有数据怎么查不到”你可以淡定地打开命令行告诉他“来咱们让数据自己说说它在哪儿。”
返回列表