关于后端:一个烂分页踩了三个坑

40次阅读

共计 6453 个字符,预计需要花费 17 分钟才能阅读完成。

你好呀,我是歪歪。

前段时间踩到一个比拟无语的生产 BUG,严格来说其实也不能算是 BUG,只能说开发共事对于业务共事的需要了解没有到位。

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

select * from table order by priority limit 1;

priority,就是优先级的意思。

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

想起了我当年在写分页逻辑的时候,尽管场景和这个齐全不一样,然而踩过到底层原理截然不同的坑,这玩意印象粗浅,所以立马就辨认进去了。

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

职业生涯的第一个生产 BUG

歪徒弟职业生涯的第一个生产 BUG 就是一个小小的分页查问。

过后还在做领取零碎,接手的一个需要也很简略就是做一个定时工作,定时把数据库外面状态为初始化的订单查问进去,调用另一个服务提供的接口查问订单的状态并更新。

因为流程上有数据强校验,不必思考数据不存在的状况。所以该接口可能返回的状态只有三种:胜利,失败,解决中。

很简略,很惯例的一个需要对吧,我分分钟就能写出伪代码:

// 获取订单状态为初始化的数据 (0: 初始化 1: 解决中 2: 胜利 3: 失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
// 循环解决这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    // 捕捉异样免得一条数据谬误导致循环完结
    try{
        // 发动 rpc 调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        // 更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);    
    } catch (Exception e){// 打印异样}
}

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

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

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

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

那么 SQL 就是这样的:

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

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

while(true){// 获取订单状态为初始化的数据 (0: 初始化 1: 解决中 2: 胜利 3: 失败)
    //select * from order where order_status=0 order by create_time limit 100;
    ArrayList initOrderInfoList = queryInitOrderInfoList();
    // 循环解决这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        // 捕捉异样免得一条数据谬误导致循环完结
        try{
            // 发动 rpc 调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            // 更新订单状态
            updateOrderInfo(orderInfo.getOrderId,orderStatus);    
        } catch (Exception e){// 打印异样}
    }
}

来,你又来通知我下面这一段逻辑有什么问题?

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

那你说下面这段代码在什么时候退不进去?

当有任何一条数据的状态没有从初始化变成胜利、失败或者解决中的时候,就会导致始终循环。

而尽管发动 RPC 调用的中央,服务提供方能确保返回的状态肯定是胜利、失败、解决中这三者之中的一个,然而这个有一个前提是接口调用失常的状况下。

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

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

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

// 是否是最初一页
while(pageInfo.isLastPage){
    pageNum=pageNum+1;
    // 获取订单状态为初始化的数据 (0: 初始化 1: 解决中 2: 胜利 3: 失败)
    //select * from order where order_status=0 order by create_time limit pageNum*100,100;
    PageHelper.startPage(pageNum,100);
    ArrayList initOrderInfoList = queryInitOrderInfoList();
    pageInfo = new PageInfo(initOrderInfoList);
    // 循环解决这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        // 捕捉异样免得一条数据谬误导致循环完结
        try{
            // 发动 rpc 调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            // 更新订单状态
            updateOrderInfo(orderInfo.getOrderId,orderStatus);    
        } catch (Exception e){// 打印异样}
    }
}

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

因为每循环一次,当前页就加一,那么实践上讲肯定会是翻到最初一页的,没有任何故障,对不对?

咱们能够剖析一下下面的代码逻辑。

假如,咱们有 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);
List<OrderInfo> list = orderInfoMapper.select(param1);

起初为了防止不带 where 条件的全表查问,我把代码批改成了这样:

PageHelper.startPage(pageNum,100);
if(param != null){List<OrderInfo> list = orderInfoMapper.select(param);
}

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

然而这个时候 PageHelper 曾经在以后线程的 ThreadLocal 外面设置了分页参数了,然而没有被生产,这个参数就会始终保留在这个线程上,也就是放在线程的 ThreadLocal 外面。

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

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

if(param != null){PageHelper.startPage(pageNum,100);
    List<OrderInfo> list = orderInfoMapper.select(param);
}

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

在正确应用 PageHelper 的状况下,其插件外部,会在 finally 代码段中主动革除了在 ThreadLocal 中存储的对象。

这样就不会留坑。

这次翻页源码的过程影响也是比拟粗浅的,尽管那个时候教训不多,然而得益于 MyBatis 的源码和 PageHelper 的源码写的都十分的合乎正常人的思维,浏览起来门槛不高,再加上我有具体的疑难,所以那是一次古早期间,尚在新手村时,为数不多的,浏览源码之后,感觉播种满满的经验。

分页丢数据

对于这个 BUG 能够说是印象粗浅了。

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

业务需要很简略,就是在治理页面上能够查问订单列表,查问后果依照订单的创立工夫倒序排序。

对应的分页 SQL 很简略,很惯例,没有任何问题:

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

然而当年在页面上的体现大略是这样的:

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

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

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

而导入的这一批数据,写 SQL 的同学为了不便,就把 create_time 都设置为了同一个值,比方都设置为了 2023-09-10 12:34:56 这个工夫。

因为 create_time 又是我作为 order by 的字段,当这个字段的值大量都是同一个值的时候,就会导致下面的一条数据在不同的页面上屡次呈现的状况。

针对这个景象,过后组里的大佬剖析明确之后,扔给我一个链接:

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

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

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

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

而后给了这一段补充阐明:

如果多条记录的 ORDER BY 列中有雷同的值,服务器能够自在地按任何程序返回这些记录,并可能依据整体执行打算的不同而采取不同的形式。

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

而后官网给了一个示例。

首先,不带 limit 的时候查问后果是这样的:

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

然而当咱们带着 limit 的时候查问后果可能是这样的:

对应的 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 规范的。

尽管我没错,然而我还是能够给你指个路。

如果你十分在意执行后果的程序,那么在 ORDER BY 子句中蕴含一个额定的列,以确保程序具备确定性。

例如,如果 id 值是惟一的,你能够通过这样的排序使给定类别值的行按 id 程序呈现。

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

好了,如果感觉本文对你有帮忙的话,求个收费的点赞,不过分吧?

正文完
 0