Mysql深度分页之MyBatisPlus百万数据分页优化新姿势


1. 前言

前段时间,我司会员表有600多w数据分页查询特别的慢,于是乎我去explain了一下分页查询的sql是由于没有走索引,导致全表扫描,查询特别的慢,由于会员表有一个逻辑删除的字段,所以查询的时候sql条件会默认第一条件就是逻辑删除的字段,这个之前的文章也有分享过这个逻辑删除字段在查询第一个导致索引失效的坑,于是乎在之前加的索引上把逻辑删除字段加在索引第一列之后,分页查询sql就走了索引,没有之前那么慢;索引修改、表结构修改,字段类型修改还是要小心,有可能会导致锁表,把业务系统拖崩溃,所以在表数据量大的情况下还是要评估一下风险,在业务低峰期搞;言归正传,MybatisPlus分页查询在单表600w数据量下后面页查询时间会非常的慢,转圈圈要转20多秒甚至更多更多时间才有查询结果返回,这就是Mysql的Limit深度分页的问题,MyBatisPlus默认分页方式也是使用的是Limit语法,所以在单表大数据量的情况下会有深度分页问题。

深度分页问题不仅仅是Mysql有,还有ES也有,其它数据库也会有,ES的深度分页问题可以参看下面一篇文章:

https://zhuanlan.zhihu.com/p/583639279

2. MyBatisPlus普通分页姿势

2.1 业务分页方法

    @Override    public Page pageList(MemberV2PageDTO memberV2PageDTO) {        log.info("pageList:{}", JSON.toJSONString(memberV2PageDTO));        Page page = new Page<>();        page.setCurrent(memberV2PageDTO.getCurrent() != null ? memberV2PageDTO.getCurrent() : 1);        page.setSize(memberV2PageDTO.getSize() != null ? memberV2PageDTO.getSize() : 100);        QueryWrapper queryWrappe = new QueryWrapper<>();        LambdaQueryWrapper lambda = queryWrappe.lambda();        lambda.eq(Member::getDeleted, 0);        lambda.eq(Objects.nonNull(memberV2PageDTO.getUid()), Member::getMemberId, memberV2PageDTO.getUid())                .eq(StringUtils.isNotBlank(memberV2PageDTO.getMobile()), Member::getMobile, memberV2PageDTO.getMobile())                .eq(StringUtils.isNotBlank(memberV2PageDTO.getNickname()), Member::getNickname, memberV2PageDTO.getNickname())                .eq(Objects.nonNull(memberV2PageDTO.getVip()), Member::getVip, memberV2PageDTO.getVip())                .eq(Objects.nonNull(memberV2PageDTO.getStatus()), Member::getStatus, memberV2PageDTO.getStatus());        if (CollectionUtil.isNotEmpty(memberV2PageDTO.getCredit())) {            List credits = memberV2PageDTO.getCredit();            if (Objects.nonNull(credits.get(0)) && Objects.nonNull(credits.get(1))) {                log.info("会员分页查询积分区间条件====>credit0:{},credit1:{}", credits.get(0), credits.get(1));                lambda.between(Member::getCredit1, credits.get(0), credits.get(1));            }        }        Page memberV2VOPage = memberMapper.pageList(page, queryWrappe);        //数据逻辑处理        memberV2VOPage.getRecords().forEach(m -> {            String mobile = m.getMobile();            if (StringUtils.isNotBlank(mobile) && mobile.length() == 11) {                m.setHideMobile(mobile.substring(03) + "****" + mobile.substring(7));            }            String credit4Str = m.getCredit4();            if (StringUtils.isNotBlank(credit4Str)) {                String vipTitle = memberExecutor.getVipTitle(BigDecimal.valueOf(Double.valueOf(credit4Str)));                if (StringUtils.isNotBlank(vipTitle)) {                    m.setVipTitle(vipTitle);                } else {                    m.setVipTitle("普通会员");                }            }            if (Objects.nonNull(m.getStatus())) {                if (MemberStatusEnum.DISABLE.getCode() == m.getStatus()) {                    m.setStatusStr(MemberStatusEnum.DISABLE.getName());                } else if (MemberStatusEnum.NORMAL.getCode() == m.getStatus()) {                    m.setStatusStr(MemberStatusEnum.NORMAL.getName());                } else if (MemberStatusEnum.FREEZE.getCode() == m.getStatus()) {                    m.setStatusStr(MemberStatusEnum.FREEZE.getName());                } else if (MemberStatusEnum.CANCELLING_IN_PROGRESS.getCode() == m.getStatus()) {                    m.setStatusStr(MemberStatusEnum.CANCELLING_IN_PROGRESS.getName());                } else if (MemberStatusEnum.CANCELLED.getCode() == m.getStatus()) {                    m.setStatusStr(MemberStatusEnum.CANCELLED.getName());                }            }        });        //Page memberV2VOPage = this.pageListV2(memberV2PageDTO);        return memberV2VOPage;    }


2.2 Mapper接口

@Select("SELECT \n" +            "mb.member_id AS uid,\n" +            "mb.vip AS vip,\n" +            "mb.mobile AS mobile,\n" +            "mb.credit1 AS credit1,\n" +            "mb.nickname AS nickname,\n" +            "mb.credit4 AS credit4,\n" +            "mb.avatar AS avatar,\n" +            "mb.gender AS gender,\n" +            "mb.unionid AS unionid,\n" +            "mb.create_time AS createTime,\n" +            "mb.register_time AS registerTime,\n" +            "mb.status AS status\n" +            "FROM\n" +            "member mb ${ew.customSqlSegment} ")    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)   /* 关联一对多查询   @Results(value = {            @Result(id = true, property = "uid", column = "memberId"),            @Result(property = "fans", column = "memberId",                    many = @Many(select = "xxx.xxxxx.mapper.FansMapper.getByUid", fetchType = FetchType.LAZY)            ),            @Result(property = "wxps", column = "memberId",                    many = @Many(select = "xxx.xxxxx.mapper.WxMappingMapper.getByUid", fetchType = FetchType.LAZY))    })   */    Page<MemberV2VO> pageList(@Param("page"Page<Member> page, @Param(Constants.WRAPPERQueryWrapper<Member> queryWrapper);

以上是Mapper接口注解方式,也可以在xml中的select标签中写。


3. MyBatisPlus百万数据分页优化新姿势

3.1 姿势一

需要主键是自增有序,不一定要主键连续,但是必须要有序自增。

3.1.1 业务分页方法

public Page pageListV2(MemberV2PageDTO dto) {        if (Objects.isNull(dto)) {            throw new RuntimeException("参数不为空!");        }        if (Objects.isNull(dto.getCurrent())) {            dto.setCurrent(1L);        }        if (Objects.isNull(dto.getSize())) {            dto.setSize(100L);        }        if (CollectionUtil.isNotEmpty(dto.getCredit()) && dto.getCredit().size() == 2) {            if (dto.getCredit().get(0) > dto.getCredit().get(1)) {                dto.setCreditStart(dto.getCredit().get(1));                dto.setCreditEnd(dto.getCredit().get(0));            } else {                dto.setCreditStart(dto.getCredit().get(0));                dto.setCreditEnd(dto.getCredit().get(1));            }        }        Page page = new Page<>(dto.getCurrent(), dto.getSize());        Long count = memberMapper.pageListV2Count(dto);        if (count == 0) {            page = new Page<>();            return page;        }        page.setTotal(count);        Long pages = (count + dto.getSize() - 1) / dto.getSize();        page.setPages(pages);        if (page.getCurrent() > pages) {            page.setCurrent(pages);        }        Long pageSize = page.getSize();        Long start = (page.getCurrent() - 1) * page.getSize();        if (count < pageSize) {            pageSize = count;        }        Long mod = count % pageSize;        if (mod != 0 && Objects.equals(dto.getCurrent(), pages)) {            pageSize = mod;        }        //Long end = page.getCurrent() * page.getSize();        List memberV2VOList = memberMapper.pageListV2(start, pageSize, dto);        page.setRecords(memberV2VOList);        return page;    }


3.1.2 Mapper接口

    Long pageListV2Count(@Param("dto") MemberV2PageDTO dto);
    List pageListV2(@Param("start") Long start, @Param("pageSize") Long pageSize, @Param("dto") MemberV2PageDTO dto);


3.1.3 xml的sql写法

<select id="pageListV2Count" resultType="java.lang.Long">        SELECT        COUNT(*)        FROM        dyict_member mb        WHERE mb.deleted = 0        <if test="dto != null and dto.uid != null ">            AND mb.member_id = #{dto.uid}        if>        <if test="dto != null and dto.mobile != null and dto.mobile.trim() neq '' ">            AND mb.mobile = #{dto.mobile}        if>        <if test="dto != null and dto.nickname != null and dto.nickname.trim() neq '' ">            AND mb.nickname = #{dto.nickname}        if>        <if test="dto != null and dto.creditStart !=null and dto.creditEnd !=null ">                                        AND mb.credit1 >= #{dto.creditStart}                            AND mb.credit1 <= #{dto.creditEnd}                     ]]>        if>        <if test="dto != null and dto.vip !=null ">            AND mb.vip = #{dto.vip}        if>        <if test="dto != null and dto.status !=null ">            AND mb.status = #{dto.status}        if>        ORDER BY mb.member_id DESC    select>
    <select id="pageListV2" resultType="com.dy.member.vo.MemberV2VO">        SELECT        mb.member_id AS uid,        mb.vip AS vip,        mb.mobile AS mobile,        mb.credit1 AS credit1,        mb.nickname AS nickname,        mb.credit4 AS credit4,        mb.avatar AS avatar,        mb.gender AS gender,        mb.unionid AS unionid,        mb.create_time AS createTime,        mb.register_time AS registerTime,        mb.STATUS AS STATUS        FROM        //需要注意下面这个是小于等于,主键排序就是DESC        dyict_member mb        WHERE mb.member_id  (        SELECT        member_id        FROM        dyict_member        ORDER BY        member_id DESC        LIMIT        #{start},        1        )        AND mb.deleted = 0        <if test="dto != null and dto.uid != null ">            AND mb.member_id = #{dto.uid}        if>        <if test="dto != null and dto.mobile != null and dto.mobile.trim() neq '' ">            AND mb.mobile = #{dto.mobile}        if>        <if test="dto != null and dto.nickname != null and dto.nickname.trim() neq '' ">            AND mb.nickname = #{dto.nickname}        if>        <if test="dto != null and dto.creditStart !=null and dto.creditEnd !=null ">                                        AND mb.credit1 >= #{dto.creditStart}                            AND mb.credit1 <= #{dto.creditEnd}                     ]]>        if>        <if test="dto != null and dto.vip !=null ">            AND mb.vip = #{dto.vip}        if>        <if test="dto != null and dto.status !=null ">            AND mb.status = #{dto.status}        if>        ORDER BY mb.member_id DESC        LIMIT #{pageSize};    select>

pageListV2的条件查询需要放在外层,如果放在内层就会导致查询结果不正确。


3.2 姿势二

3.2.1 业务分页方法

public Page pageListV2(MemberV2PageDTO dto) {        if (Objects.isNull(dto)) {            throw new RuntimeException("参数不为空!");        }        if (Objects.isNull(dto.getCurrent())) {            dto.setCurrent(1L);        }        if (Objects.isNull(dto.getSize())) {            dto.setSize(100L);        }        if (CollectionUtil.isNotEmpty(dto.getCredit()) && dto.getCredit().size() == 2) {            if (dto.getCredit().get(0) > dto.getCredit().get(1)) {                dto.setCreditStart(dto.getCredit().get(1));                dto.setCreditEnd(dto.getCredit().get(0));            } else {                dto.setCreditStart(dto.getCredit().get(0));                dto.setCreditEnd(dto.getCredit().get(1));            }        }        Page page = new Page<>(dto.getCurrent(), dto.getSize());        Long count = memberMapper.pageListV2Count(dto);        if(count == 0){            page = new Page<>();            return page;        }        page.setTotal(count);        Long pages = (count + dto.getSize() - 1) / dto.getSize();        page.setPages(pages);        if (page.getCurrent() > pages) {            page.setCurrent(pages);        }        Long start = (page.getCurrent() - 1) * page.getSize();        //Long end = page.getCurrent() * page.getSize();        List memberV2VOList = memberMapper.pageListV2(start, page.getSize(), dto);        page.setRecords(memberV2VOList);        return page;    }

3.2.2 Mapper接口

Mapper接口同上姿势一


3.2.3 xml的sql写法

<select id="pageListV2Count" resultType="java.lang.Long">        SELECT        COUNT(*)        FROM        dyict_member mb        WHERE mb.deleted = 0        <if test="dto != null and dto.uid != null ">            AND mb.member_id = #{dto.uid}        if>        <if test="dto != null and dto.mobile != null and dto.mobile.trim() neq '' ">            AND mb.mobile = #{dto.mobile}        if>        <if test="dto != null and dto.nickname != null and dto.nickname.trim() neq '' ">            AND mb.nickname = #{dto.nickname}        if>        <if test="dto != null and dto.creditStart !=null and dto.creditEnd !=null ">                                        AND mb.credit1 >= #{dto.creditStart}                            AND mb.credit1 <= #{dto.creditEnd}                     ]]>        if>        <if test="dto != null and dto.vip !=null ">            AND mb.vip = #{dto.vip}        if>        <if test="dto != null and dto.status !=null ">            AND mb.status = #{dto.status}        if>        ORDER BY mb.member_id DESC    select>
    <select id="pageListV2" resultType="com.dy.member.vo.MemberV2VO">        SELECT        mb.member_id AS uid,        mb.vip AS vip,        mb.mobile AS mobile,        mb.credit1 AS credit1,        mb.nickname AS nickname,        mb.credit4 AS credit4,        mb.avatar AS avatar,        mb.gender AS gender,        mb.unionid AS unionid,        mb.create_time AS createTime,        mb.register_time AS registerTime,        mb.STATUS AS STATUS        FROM        dyict_member mb        inner join(        SELECT        member_id        FROM        dyict_member        WHERE        deleted = 0        <if test="dto != null and dto.uid != null ">            AND member_id = #{dto.uid}        if>        <if test="dto != null and dto.mobile != null and dto.mobile.trim() neq '' ">            AND mobile = #{dto.mobile}        if>        <if test="dto != null and dto.nickname != null and dto.nickname.trim() neq '' ">            AND nickname = #{dto.nickname}        if>        <if test="dto != null and dto.creditStart !=null and dto.creditEnd !=null ">                                        AND credit1 >= #{dto.creditStart}                            AND credit1 <= #{dto.creditEnd}                     ]]>        if>        <if test="dto != null and dto.vip !=null ">            AND vip = #{dto.vip}        if>        <if test="dto != null and dto.status !=null ">            AND status = #{dto.status}        if>        ORDER BY        member_id DESC        LIMIT        #{start},        #{pageSize}        ) AS mb3 ON mb.member_id = mb3.member_id        LIMIT #{pageSize}; --这个可以不用要    select>

姿势二pageListV2的查询条件放外层或内层效果是一样的,测试了没有多大的差别,还有就是最后的LIMIT #{pageSize}; --这个可以不用要,测试也是可有可无。

上面两种方式:姿势一是根据主键找到开始或结束值来分页取数据的,姿势二是内连接的方式,姿势二比姿势一多一层外层的临时表的,两个的性能差不多,推荐使用姿势一,姿势一走主键索引和子查询还有名命中索引,减少回表次数,如果索引设计的好两种方式查询都会命中索引,尽量让离散区分度大的列条件走索引,避免索引失效,索引失效需要排查失效原因,上面两种姿势的pageListV2Count的COUNT()不要写成COUNT(DISTINCT mb.member_id),如果写成COUNT(DISTINCT mb.member_id)这种,会导致pageListV2Count这个查询慢,索引失效,全表扫描,亲测如果写成COUNT(DISTINCT mb.member_id)这种,这个统计查询耗时达到30多秒,非常的慢,所以COUNT()是标准的统计sql写法,直接使用COUNT(*)来统计行数一点问题都没有的,写其它写法会有一些奇怪的问题出现。


4. 总结

结论:上面两种姿势,推荐使用姿势一,这种优化下来,600多W的单表使用MyBatisPlus优化分页查询点击前面的页非常快,毫秒级,中间页平均3秒,最有一页第一次耗时长一点,之后预热了索引数据之后也是平均3秒耗时,前中后分页查询平均耗时3s左右,从之前的好几十秒,可以说是性能提升了10多倍,这个优化已经是优化到极致了,单个条件命中索引数据量大一点就会慢一点,命中索引数据量少一点就会非常的快,组合条件查询走索引也是同样的,耗时取决于索引命中的的数据量大小, 数据量大耗时长,数据量小耗时短,别看这点东西,还是要一点一点的优化分析的,也不简单的,还是要费一点时间和功夫的,所以说姿势很重要,姿势不对努力白费,姿势一对是无比的丝滑流畅,直接起飞,由此可见,Mysql在数据量很大的情况下,上到600w到1000w基本上是遇到瓶颈了,可以考虑使用其它的一些更猛更强更快的数据库产品和大数据技术栈来解决业务的数据查询分析等痛点问题,本次分享到此结束,创作不易,请尊重一下作者,禁止抄袭,发现抄袭直接举报,转载请注明作者及出处,这也是一种开源精神,互学互鉴,共同繁荣,有思考有深度有实践不坑的文章百里挑一,千篇一律复制粘贴的水文坑文烂文百度一大堆,希望我的分享对你有所启发和帮助,请一键三连,么么么哒!

请使用浏览器的分享功能分享到微信等