为什么我的MySQL分页效率这么高

MySQL如何实现分页

MySQL通常使用limit关键字实现分页,有以下两种方式能够实现分页:

  • limit n,m:从第n条记录开始(不包含第n条),往后捞m条数据
  • limit m offset n:从第n条记录开始(不包含第n条),往后捞m条数据,n默认为零

两者只是写法不一样,实现效果一样。这两种写法在数据量较少的场景下,并不会有什么很大的问题,但是一旦当数据量在百万级甚至千万级,如果没有做特殊的处理就会出现臭名昭著的深分页问题,导致程序运行十分缓慢。

limit还有一个值的一提的用法就是:limit n,即返回n条数据。

如何高效分页

在如今的互联网企业中,别说百万级数据,亿级数据都是很常见的,那对于这么大的数据量我们如何解决深分页问题呢?

想要让SQL跑的更快,无非要借助索引,同学们思考下,limit关键字实现的就是傻瓜式的逐页往后翻,那我们能不能借助limit n实现往后翻的效果呢。答案是肯定的。我们接下来就来动手实践。看优化后的分页查询效率是如何起飞的。

我们首先创建如下表结构 :

create table t_compare
(
id bigint not null comment '主键ID'
primary key,
name varchar(64) not null comment '名称'
);

并插入十几万数据,以分页使用

既然我们是从索引的角度优化分页,那每个表必不可少的就是主键索引,我们就从主键索引入手,这里补充一个小知识:

MySQL中每个表都有主键索引,其规则如下:

如果字段显示声明了primary key,则该字段为当前表的主键,如果没有显示声明,MySQL会自动去找声明了唯一索引并且不能为空的字段作为该表的主键,如果前两者都找不到,那么MySQL就会自己隐式维护一个主键。

这里因为我们需要使用到主键进行分页,所以我们显示声明id为primary key,我们编写代码如下:

@Test
    public void testManualPage() {
        // 按照id升序查询第一个符合条件的数据
        Long firstId = this.queryFirstId();
        // 按照id分页
        log.info("sumSize:{}", this.loopManualPage(firstId, 1000L, true, 0));
    }

    /**
     * 递归手动分页
     *
     * @param firstId
     * @param pageSize
     */
    private Long loopManualPage(Long firstId, Long pageSize, boolean firstQuery, long sumSize) {
        LambdaQueryWrapper<Compare> lambdaQueryWrapper = new QueryWrapper<Compare>().lambda();
        lambdaQueryWrapper
                .ge(firstQuery, Compare::getId, firstId)
                .gt(!firstQuery, Compare::getId, firstId)
                .last(" limit " + pageSize);
        List<Compare> compareList = compareDao.getBaseMapper().selectList(lambdaQueryWrapper);
        if (CollectionUtils.isEmpty(compareList)) {
            log.info("firstId:{} loop is over", firstId);
            return sumSize;
        }
        // 统计捞的总条数
        sumSize += compareList.size();
        compareList.forEach(compare -> {
            // do something
            log.info("compare:{}", JsonUtils.silentObject2String(compare));
        });
        return loopManualPage(compareList.get(compareList.size() - 1).getId(), pageSize, false, sumSize );
    }

    /**
     * 按照id升序查询第一个符合条件的数据
     *
     * @return
     */
    private Long queryFirstId() {
        LambdaQueryWrapper<Compare> lambdaQueryWrapper = new QueryWrapper<Compare>().lambda();
        // 在此补充其他查询条件,此处仅研究分页
        lambdaQueryWrapper.select(Compare::getId).orderByAsc(Compare::getId).last("limit 1");
        Compare compare = compareDao.getBaseMapper().selectOne(lambdaQueryWrapper);
        return compare.getId();
    }

执行递归查询,我们得到如下结果:

2024-01-22 10:58:06.707  INFO 20128 --- [           main] com.github.OrderDaoTest                  : firstId:524377665847500 loop is over
2024-01-22 10:58:06.707  INFO 20128 --- [           main] com.github.OrderDaoTest                  : sumSize:160225

我们执行以下sql语句:

select * from t_compare t order by t.id desc limit 1;

得到结果如下:

可以看到loop is over对应的id就是524377665847500,总条数160225也是对应的,通过数据表明我们将数据全部通过分页的形式查询出来了。

我们再来看下我们分页时的sql对应的执行计划:

explain select * from t_compare t where t.id > 1 limit 1000;

可以看到我们的sql预计走索引,并且索引级别达到了较好的range级别,虽然目前rows看着有点多,但是当我们再执行以下操作时:

# 查询最后一页第一条
select * from t_compare t where  t.id <= 524377665847500 order by t.id desc limit 1000;

我们得到如下结果:

即最后一页的起始ID是:524377170406700

于是我们执行以下sql:

explain select * from t_compare t where  t.id > 524377170406700 order by t.id asc limit 1000;

我们可以看到,按照我们的分页方式,查询索引不仅达到了range级别,并且越往后翻页越快,这就完完全全的解决了深分页问题。

那么我们再反观下直接使用limit分页,我们也来查询最后一页:

explain select * from t_compare t limit 160000,1000;

我们会发现走了全表扫描。当数据量达到百万级或者千万级,这个扫描行数将是灾难性的。

我们再来对比下手动分页和limit分页的耗时:

limit costTime:8620
manual costTime:4281

可以看到数据量在十几万的级别下,limit分页耗时是8620毫秒,手动分页则是4281毫秒,效率直接提高百分之五十。分页效率提升十分明显。

分页进行中,数据条数减少

这里在研究如何加速分页查询时,突然想到一个问题,如果说我们一个线程中分页已经触发了,这个时候其他线程将我们预计要通过分页查询出来的数据删除了,那数据库是继续查询数据还是报错呢?

我们编写代码如下:

@Test
    public void testDeleteOnPage() {
        Page<Compare> page = new Page<>(1, 1000);
        while (true) {
            compareDao.page(page);
            List<Compare> records = page.getRecords();
            System.out.println(JsonUtils.silentObject2String(records));
            if (CollectionUtils.isEmpty(records)) {
                break;
            }
            compareDao.removeBatchByIds(Arrays.asList(635, 636, 637, 638, 639));
            page.setCurrent(page.getCurrent() + 1);
            if (!page.hasNext()) {
                break;
            }
        }
    }

我们在执行分页时,删除第二页中的635, 636, 637, 638, 639这五条数据,执行程序结果如下:

各位同学可以发现单元测试正常执行,并没有报错,查看日志并没有查询出来这五条数据,查询数据库也发现这五条数据已经被删除了。

分页进行中,数据条数增加

数据减少我们已经验证了,那如果数据增加呢,分页的后续永远有最后一页,那是不是就死循环了?

我们编写代码如下

@Test
    public void testSaveOnPage() {
        Page<Compare> page = new Page<>(1, 1000);
        AtomicInteger atomicInteger = new AtomicInteger();
        while (true) {
            compareDao.page(page);
            List<Compare> records = page.getRecords();
            if (CollectionUtils.isEmpty(records)) {
                break;
            }
            System.out.println(JsonUtils.silentObject2String(records));
            new Thread(()->{
                for (int i = 0; i < 100; i++) {
                    Compare compare = new Compare();
                    long id = System.nanoTime() + atomicInteger.getAndIncrement();
                    System.out.println("id:" + id);
                    compare.setId(id);
                    compare.setName("额外添加,分页永远结束不了");
                    compareDao.save(compare);
                }
            }).start();
            page.setCurrent(page.getCurrent() + 1);
            if (!page.hasNext()) {
                break;
            }
        }
    }

执行后我们会发现程序并不是像我们预想的那样,它不仅把我们存量的数据分页查询出来,还把我们添加线程插入的数据也查询出来了,我们可以看下图:

我们以这个生成的id:281996692058699为例,我们发现全局匹配了两次,一次是插入时输出的,还有紧接的一次查询获取到的。


我们小结一下,MySQL执行分页时,不管数据是增加还是减少,分页都会正常进行,数据减少,MySQL分页会自动跳过删除的数据,获取后续的数据,但是数据增加就会有一个很大的风险,就是如果分页过慢,慢于数据生成的速度,那MySQL分页永远不会结束,也就是会分页死循环,所以同学们一定要提高分页的速度。

MybatisPlus的分页

这里谈到MySQL分页时数据增加的问题,那MybatisPlus分页插件是如何做的呢?

这里我们在配置中开启MybatisPlus的日志打印:

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

我们执行分页查询后发现如下:

如图,发现MybatisPlus每次执行查询都执行了 SELECT COUNT(*) AS total FROM t_compare 语句以获得到最新的条数。

那如果有同学在分页中就是需要忽略新增的数据如何处理呢,MybatisPlus也早给咱们想好了,咱们在创建page对象的时候设置searchCount为false就可以了,那么MybatisPlus就不会再去统计了。那么自然page.hasNext()就失效了,这个需要注意。

page.setSearchCount(false);

以上就是关于MySQL分页相关知识点的分享,如果大家有更好的优化MySQL分页的方式,或者说优化SQL的好点子。希望通过此文抛砖引玉,大家有好想法都抛出来,一起交流,一起进步。如果各位同学觉得对你有所帮助,请关注、点赞、评论、收藏来支持我,手头宽裕的话也可以赞赏来表达各位的认可,各位同学的支持是对我最大的鼓励。未来为大家带来更好的创作。 

分享一句非常喜欢的话:把根牢牢扎深,再等春风一来,便会春暖花开。

版权声明:以上引用信息以及图片均来自网络公开信息,如有侵权,请留言或联系

504401503@qq.com,立马删除。

  • 17
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

咖啡攻城狮Alex

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值