0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

就这么个破分页,我踩了三次大坑!

小林coding 来源:小林coding 2023-11-27 16:47 次阅读

之前踩到一个比较无语的生产 BUG,严格来说其实也不能算是 BUG,只能说开发同事对于业务同事的需求理解没有到位。

这个 BUG 其实和分页没有任何关系,但是当我去排查问题的时候,我看了一眼 SQL ,大概是这样的:

select * from table order by priority limit 1;

priority,就是优先级的意思。

按照优先级 order by 然后 limit 取优先级最高(数字越小,优先级越高)的第一条 ,结合业务背景和数据库里面的数据,我立马就意识到了问题所在。

想起了我当年在写分页逻辑的时候,虽然场景和这个完全不一样,但是踩过到底层原理一模一样的坑,这玩意印象深刻,所以立马就识别出来了。

借着这个问题,也盘点一下遇到过的三个关于分页查询有意思的坑

职业生涯的第一个生产 BUG

职业生涯的第一个生产 BUG 就是一个小小的分页查询。

当时还在做支付系统,接手的一个需求也很简单就是做一个定时任务,定时把数据库里面状态为初始化的订单查询出来,调用另一个服务提供的接口查询订单的状态并更新。

由于流程上有数据强校验,不用考虑数据不存在的情况。所以该接口可能返回的状态只有三种:成功,失败,处理中。

很简单,很常规的一个需求对吧,我分分钟就能写出伪代码:

//获取订单状态为初始化的数据(0:初始化1:处理中2:成功3:失败)
//select*fromorderwhereorder_status=0;
ArrayListinitOrderInfoList=queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfoorderInfo:initOrderInfoList){
//捕获异常以免一条数据错误导致循环结束
try{
//发起rpc调用
StringorderStatus=queryOrderStatus(orderInfo.getOrderId);
//更新订单状态
updateOrderInfo(orderInfo.getOrderId,orderStatus);
}catch(Exceptione){
//打印异常
}
}

来,你说上面这个程序有什么问题?

其实在绝大部分情况下都没啥大问题,数据量不多的情况下程序跑起来没有任何毛病。

但是,如果数据量多起来了,一次性把所有初始化状态的订单都拿出来,是不是有点不合理了,万一把内存给你撑爆了怎么办?

所以,在我已知数据量会很大的情况下,我采取了分批次获取数据的模式,假设一次性取 100 条数据出来玩。

那么 SQL 就是这样的:

select * from order where order_status=0 order by create_time limit 100;

所以上面的伪代码会变成这样:

while(true){
//获取订单状态为初始化的数据(0:初始化1:处理中2:成功3:失败)
//select*fromorderwhereorder_status=0orderbycreate_timelimit100;
ArrayListinitOrderInfoList=queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfoorderInfo:initOrderInfoList){
//捕获异常以免一条数据错误导致循环结束
try{
//发起rpc调用
StringorderStatus=queryOrderStatus(orderInfo.getOrderId);
//更新订单状态
updateOrderInfo(orderInfo.getOrderId,orderStatus);
}catch(Exceptione){
//打印异常
}
}
}

来,你又来告诉我上面这一段逻辑有什么问题?

作为程序员,我们看到 while(true) 这样的写法立马就要警报拉满,看看有没有死循环的风险。

那你说上面这段代码在什么时候退不出来?

当有任何一条数据的状态没有从初始化变成成功、失败或者处理中的时候,就会导致一直循环。

而虽然发起 RPC 调用的地方,服务提供方能确保返回的状态一定是成功、失败、处理中这三者之中的一个,但是这个有一个前提是接口调用正常的情况下。

如果接口调用一旦异常,那么按照上面的写法,在抛出异常后,状态并未发生变化,还会是停留在“初始化”,从而导致死循环。

当年,测试同学在测试阶段直接就测出了这个问题,然后我对其进行了修改。

我改变了思路,把每次分批次查询 100 条数据,修改为了分页查询,引入了 PageHelper 插件:

//是否是最后一页
while(pageInfo.isLastPage){
pageNum=pageNum+1;
//获取订单状态为初始化的数据(0:初始化1:处理中2:成功3:失败)
//select*fromorderwhereorder_status=0orderbycreate_timelimitpageNum*100,100;
PageHelper.startPage(pageNum,100);
ArrayListinitOrderInfoList=queryInitOrderInfoList();
pageInfo=newPageInfo(initOrderInfoList);
//循环处理这批数据
for(OrderInfoorderInfo:initOrderInfoList){
//捕获异常以免一条数据错误导致循环结束
try{
//发起rpc调用
StringorderStatus=queryOrderStatus(orderInfo.getOrderId);
//更新订单状态
updateOrderInfo(orderInfo.getOrderId,orderStatus);
}catch(Exceptione){
//打印异常
}
}
}

跳出循环的条件为判断当前页是否是最后一页。

由于每循环一次,当前页就加一,那么理论上讲一定会是翻到最后一页的,没有任何毛病,对不对?

我们可以分析一下上面的代码逻辑。

假设,我们有 120 条 order_status=0 的数据。

那么第一页,取出了 100 条数据:

SELECT * from order_info WHERE order_status=0 LIMIT 0,100;

这 100 条处理完成之后,第二页还有数据吗?

第二页对应的 sql 为:

SELECT * from order_info WHERE order_status=0 LIMIT 100,100;

但是这个时候,状态为 0 的数据,只有 20 条了,而分页要从第 100 条开始,是不是获取不到数据,导致遗漏数据了?

确实一定会翻到最后一页,解决了死循环的问题,但又有大量的数据遗漏怎么办呢?

当时我苦思冥想,想到一个办法:导致数据遗漏的原因是因为我在翻页的时候,数据状态在变化,导致总体数据在变化。

那么如果我每次都从后往前取数据,每次都固定取最后一页,能取到数据就代表还有数据要处理,循环结束条件修改为“当前页即是第一页,也是最后一页时”就结束,这样不就不会遗漏数据了?

我再给你分析一下。

假设,我们有 120 条 order_status=0 的数据,从后往前取了 100 天出来进行出来,有 90 条处理成功,10 条的状态还是停留在“处理中”。

第二次再取的时候,会把剩下的 20 条和这次“处理中”的 10 条,共计 30 条再次取出来进行处理。

确保没有数据遗漏。

后来测试环节验收通过了,这个方案上线之后,也确实没有遗漏过数据了。

直到后来又一天,提供 queryOrderStatus 接口的服务异常了,我发过去的请求超时了。

导致我取出来的数据,每一条都会抛出异常,都不会更新状态。从而导致我每次从后往前取数据,都取到的是同一批数据。

从程序上的表现上看,日志疯狂的打印,但是其实一直在处理同一批,就是死循环了。

好在我当时还在新手保护期,领导帮我扛下来了。

最后随着业务的发展,这块逻辑也完全发生了变化,逻辑由我们主动去调用 RPC 接口查询状态变成了,下游状态变化后进行 MQ 主动通知,所以我这一坨骚代码也就随之光荣下岗。

我现在想了一下,其实这个场景,用分页的思想去取数据真的不好做。

还不如用最开始的分批次的思想,只不过在会变化的“状态”之外,再加上另外一个不会改变的限定条件,比如常见的创建时间:

select * from order where order_status=0 and create_time>xxx order by create_time limit 100;

最好不要基于状态去做分页,如果一定要基于状态去做分页,那么要确保状态在分页逻辑里面会扭转下去。

这就是我职业生涯的第一个生产 BUG,一个低级的分页逻辑错误。

还是分页,又踩到坑

这也是在工作的前两年遇到的一个关于分页的坑。

最开始在学校的时候,大家肯定都手撸过分页逻辑,自己去算总页数,当前页,页面大小啥的。

当时功力尚浅,觉得这部分逻辑写起来是真复杂,但是扣扣脑袋也还是可以写出来。

后来参加工作了之后,在项目里面看到了 PageHelper 这个玩意,了解之后发了“斯国一”的惊叹:有了这玩意,谁还手写分页啊。

但是我在使用 PageHelper 的时候,也踩到过一个经典的“坑”。

最开始的时候,代码是这样的:

PageHelper.startPage(pageNum,100);
Listlist=orderInfoMapper.select(param1);

后来为了避免不带 where 条件的全表查询,我把代码修改成了这样:

PageHelper.startPage(pageNum,100);
if(param!=null){
Listlist=orderInfoMapper.select(param);
}

然后,随着程序的迭代,就出 BUG 了。因为有的业务场景下,param 参数一路传递进来之后就变成了 null。

但是这个时候 PageHelper 已经在当前线程的 ThreadLocal 里面设置了分页参数了,但是没有被消费,这个参数就会一直保留在这个线程上,也就是放在线程的 ThreadLocal 里面。

当这个线程继续往后跑,或者被复用的时候,遇到一条 SQL 语句时,就可能导致不该分页的方法去消费这个分页参数,产生了莫名其妙的分页。

所以,上面这个代码,应该写成下面这个样子:

if(param!=null){
PageHelper.startPage(pageNum,100);
Listlist=orderInfoMapper.select(param);
}

也是这次踩坑之后,我翻阅了 PageHelper 的源码,了解了底层原理,并总结了一句话:需要保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,否则会污染线程。

在正确使用 PageHelper 的情况下,其插件内部,会在 finally 代码段中自动清除了在 ThreadLocal 中存储的对象。

这样就不会留坑。

这次翻页源码的过程影响也是比较深刻的,虽然那个时候经验不多,但是得益于 MyBatis 的源码和 PageHelper 的源码写的都非常的符合正常人的思维,阅读起来门槛不高,再加上我有具体的疑问,所以那是一次古早时期,尚在新手村时,为数不多的,阅读源码之后,感觉收获满满的经历。

分页丢数据

关于这个 BUG 可以说是印象深刻了。

当年遇到这个坑的时候排查了很长时间没啥头绪,最后还是组里的大佬指了条路。

业务需求很简单,就是在管理页面上可以查询订单列表,查询结果按照订单的创建时间倒序排序。

对应的分页 SQL 很简单,很常规,没有任何问题:

select * from table order by create_time desc limit 0,10;

但是当年在页面上的表现大概是这样的:

b35f7ca4-8cf9-11ee-939d-92fbcf53809c.png

订单编号为 5 的这条数据,会同时出现在了第一页和第二页。

甚至有的数据在第二页出现了之后,在第五页又出现一次。

后来定位到产生这个问题的原因是因为有一批数量不小的订单数据是通过线下执行 SQL 的方式导入的。

而导入的这一批数据,写 SQL 的同学为了方便,就把 create_time 都设置为了同一个值,比如都设置为了 2023-09-10 1256 这个时间。

由于 create_time 又是我作为 order by 的字段,当这个字段的值大量都是同一个值的时候,就会导致上面的一条数据在不同的页面上多次出现的情况。

针对这个现象,当时组里的大佬分析明白之后,扔给我一个链接:

https://dev.mysql.com/doc/refman/5.7/en/limit-optimization.html

这是 MySQL 官方文档,这一章节叫做“对 Limit 查询的优化”。

开篇的时候人家就是这样说的:

b3698b72-8cf9-11ee-939d-92fbcf53809c.png

如果将 LIMIT row_count 和 ORDER BY 组合在一起,那么 MySQL 在找到排序结果的第一行 count 行时就停止排序,而不是对整个结果进行排序。

然后给了这一段补充说明:

b377becc-8cf9-11ee-939d-92fbcf53809c.png

如果多条记录的 ORDER BY 列中有相同的值,服务器可以自由地按任何顺序返回这些记录,并可能根据整体执行计划的不同而采取不同的方式。

换句话说,相对于未排序列,这些记录的排序顺序是 nondeterministic 的:

b3813a74-8cf9-11ee-939d-92fbcf53809c.png

然后官方给了一个示例。

首先,不带 limit 的时候查询结果是这样的:

b3918532-8cf9-11ee-939d-92fbcf53809c.png

基于这个结果,如果我要取前五条数据,对应的 id 应该是 1,5,3,4,6。

但是当我们带着 limit 的时候查询结果可能是这样的:

b3a22ca2-8cf9-11ee-939d-92fbcf53809c.png

对应的 id 实际是 1,5,4,3,6。

这就是前面说的:如果多条记录的 ORDER BY 列中有相同的值,服务器可以自由地按任何顺序返回这些记录,并可能根据整体执行计划的不同而采取不同的方式。

从程序上的表现上来看,结果就是 nondeterministic。

所以看到这里,我们大概可以知道我前面遇到的分页问题的原因是因为那一批手动插入的数据对应的 create_time 字段都是一样的,而 MySQL 这边又对 Limit 参数做了优化,运行结果出现了不确定性,从而页面上出现了重复的数据。

而回到文章最开始的这个 SQL,也就是我一眼看出问题的这个 SQL:

select * from table order by priority limit 1;

因为在我们的界面上,只是约定了数字越小优先级越高,数字必须大于 0。

所以当大家在输入优先级的时候,大部分情况下都默认自己编辑的数据对应的优先级最高,也就是设置为 1,从而导致数据库里面有大量的优先级为 1 的数据。

而程序每次处理,又只会按照优先级排序只会,取一条数据出来进行处理。

经过前面的分析我们可以知道,这样取出来的数据,不一定每次都一样。

所以由于有这段代码的存在,导致业务上的表现就很奇怪,明明是一模一样的请求参数,但是最终返回的结果可能不相同。

好,现在,我问你,你说在前面,我给出的这样的分页查询的 SQL 语句有没有毛病?

select * from table order by create_time desc limit 0,10;

没有任何毛病嘛,执行结果也没有任何毛病?

有没有给你按照 create_time 排序?

摸着良心说,是有的。

有没有给你取出排序后的 10 条数据?

也是有的。

所以,针对这种现象,官方的态度是:我没错!在我的概念里面,没有“分页”这样的玩意,你通过组合我提供的功能,搞出了“分页”这种业务场景,现在业务场景出问题了,你反过来说我底层有问题?

这不是欺负老实人吗?我没错!

所以,官方把这两种案例都拿出来,并且强调:

在每种情况下,查询结果都是按 ORDER BY 的列进行排序的,这样的结果是符合 SQL 标准的。

b3ccf69e-8cf9-11ee-939d-92fbcf53809c.png

虽然我没错,但是我还是可以给你指个路。

如果你非常在意执行结果的顺序,那么在 ORDER BY 子句中包含一个额外的列,以确保顺序具有确定性。

例如,如果 id 值是唯一的,你可以通过这样的排序使给定类别值的行按 id 顺序出现。

你这样去写,排序的时候加个 id 字段,就稳了:

b3e51652-8cf9-11ee-939d-92fbcf53809c.png

好了,本文的技术部分就到这里啦。


声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 数据
    +关注

    关注

    8

    文章

    7030

    浏览量

    89036
  • 代码
    +关注

    关注

    30

    文章

    4788

    浏览量

    68613
  • BUG
    BUG
    +关注

    关注

    0

    文章

    155

    浏览量

    15670

原文标题:就这么个破分页,我踩了三次大坑!

文章出处:【微信号:小林coding,微信公众号:小林coding】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    讲一讲的TCP三次握手和四挥手

    如果你学过网络基础知识,那么你一定对TCP三次握手不陌生。今天想用通俗的话来给大家讲一讲TCP三次握手和四挥手。毕竟,这个知识点在面试时被问到的概率很高!
    的头像 发表于 02-03 10:43 2717次阅读
    讲一讲的TCP<b class='flag-5'>三次</b>握手和四<b class='flag-5'>次</b>挥手

    verilog实现三次样条插值

    本帖最后由 来看看你在干什么 于 2018-5-15 09:10 编辑 用verilog实现EMD算法,需要用到三次样条插值法,请问有做过类似算法实现的吗,可以讲一下verilog实现三次样条插值的思路,或者相互交流探讨
    发表于 05-13 21:34

    三次谐波和五谐波失真严重是由哪些原因造成的?

    各位前辈,最近初学DSM,搭了一DT的2阶CIFB调制器,但是出现三次谐波和五谐波失真严重的问题,想请教一下前辈们主要是由哪调制器些
    发表于 06-24 07:15

    低压三次谐波滤波器(TSF)

    低压三次谐波滤波器(TSF)非线性的单相负荷会在接入相与中性线之间产生三次谐波电流,并在中性线上叠加,造成电流和电压畸变。三次谐波电流会引起中性线过载,还会形成1
    发表于 12-31 19:28 39次下载

    中国IT制造业面临“第三次大变革”

    IT制造业面临第三次大变革?这个几年前还是天方夜谭的话题,如今却在逐步变成现实。
    发表于 06-06 08:45 961次阅读

    TCP三次握手的过程描述

    本文档主要描述TCP三次握手的过程,一完整的三次握手也就是 请求---应答---再次确认
    发表于 03-02 15:37 8次下载

    关于三次谐波电流治理方案的浅析

    现代大量LED灯、LED屏使用,而其电源为开关电源。开关电源的输入端为整流电路,整流是典型的的谐波源,其中单相桥式整流电路的谐波电流有3、5、7、9等。这里面就涉及到我们所谈的
    发表于 06-30 18:06 3567次阅读
    关于<b class='flag-5'>三次</b>谐波电流治理方案的浅析

    三次谐波是什么,三次谐波会造成哪些影响

    正弦波形分量其频率为基波(频率为50Hz)的倍,在物理学和电类学科中都有三次谐波的概念 任何一波函数都可以进行傅里叶分解:f(t)=(k=1,n)cos(kwt+ak),当k=1时的分量f(t)=cos(wt+a)成为基波分
    发表于 11-16 15:44 3.1w次阅读
    <b class='flag-5'>三次</b>谐波是什么,<b class='flag-5'>三次</b>谐波会造成哪些影响

    基于点插值的三次PH曲线构造方法

    为推广三次PH曲线的实际应用,研究在给定3平面型值点条件下的三次PH曲线构造方法。三次PH曲线具有鲜明的几何性质和代数特征,采用平面参数曲线的复数表示方法,
    发表于 06-04 11:40 16次下载

    射频识别技术漫谈(12)——三次相互认证

    射频识别技术漫谈(12)——三次相互认证
    的头像 发表于 10-11 16:19 1348次阅读
    射频识别技术漫谈(12)——<b class='flag-5'>三次</b>相互认证

    说说TCP三次握手的过程?为什么是三次而不是两、四

    三次而不是两或四。 首先,我们需要了解TCP是一种面向连接的协议。在进行数据传输之前,发送端和接收端需要建立一可靠的连接。TCP三次
    的头像 发表于 02-04 11:03 683次阅读

    谐波和三次谐波区别 二谐波危害没有三次谐波大?

    谐波和三次谐波区别 二谐波危害没有三次谐波大? 在现代电力系统中,谐波问题逐渐引起人们的关注。谐波是指频率是基波频率的倍数的电流或电压成分。二
    的头像 发表于 04-08 17:11 5949次阅读

    三次谐波对注入式定子接地影响

    引言 随着电力系统的快速发展,电力系统的谐波问题日益突出。三次谐波作为电力系统中常见的一种谐波,对电力系统的安全稳定运行产生了一定的影响。特别是在注入式定子接地系统中,三次谐波的影响尤为明显。 三次
    的头像 发表于 07-25 14:55 756次阅读

    三次谐波定子接地保护动作条件

    三次谐波定子接地保护是电力系统中一种重要的保护方式,主要用于保护发电机、变压器等设备的定子绕组。 一、三次谐波定子接地保护的基本原理 1.1 三次谐波的产生 在电力系统中,由于非线性负载、变压器铁芯
    的头像 发表于 07-25 14:57 1056次阅读

    简述TCP协议的三次握手机制

    机制是建立一可靠的连接的关键步骤。以下是对TCP协议三次握手机制的介绍: 概述 TCP协议的三次握手机制是一种用于在两通信实体之间建立连接的过程。这个过程确保
    的头像 发表于 08-16 10:57 1013次阅读