学习记录之游标翻页实现

游标翻页

本方案参考mallchat实现

一.深翻页问题

普通翻页前端一般会有个分页条。能够指定一页的条数,以及任意选择查看第几页,假设我们想查询第11页的内容

传递过来的参数为:

pageNo=11,pageSize=10

对应的sql查询为:

select * from table limit 100,10

其中100代表需要跳过的条数,10代表跳过指定条数后,往后需要再取的条数。

假设翻页到1w条,那我们要先扫描到这1w条数据,再取10个选用,这样的话效率就会大大降低

二.深翻页问题解决

那么如何能够在每次翻页时,不去扫描前面的记录呢

假设我们还是查第11页的内容

只需要我们加上一个条件

select * from table where id>100 order by id limit 0,10

只要id这个字段有索引,就能直接定位到101这个字段,然后去10条记录。以后无论翻页到多大,通过索引直接定位到读取的位置,效率基本是一样的。这个id>100就是我们的游标,这就是游标翻页。

三.游标翻页简单实现

我们在传递值的时候,不再取传递pageNo字段,而是传递cursor游标字段.cursor是上一次查询结果的位置,作为下一次查询的游标,由后端返回.

但是游标翻页不适合跳页,只能不断的往下翻

3.1游标翻页请求类

@Data
@ApiModel("游标翻页请求")
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageBaseReq {

    @ApiModelProperty("页面大小")
    @Min(0)
    @Max(100)
    private Integer pageSize = 10;

    @ApiModelProperty("游标(初始为null,后续请求附带上次翻页的游标)")
    private String cursor;

    public Page plusPage() {
        return new Page(1, this.pageSize);
    }

    @JsonIgnore
    public Boolean isFirstPage() {
        return StringUtils.isEmpty(cursor);
    }
}

3.2LambdaUtils

package org.fth.mallchat.common.common.utils;

import cn.hutool.core.map.WeakConcurrentMap;
import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.support.ColumnCache;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import lombok.SneakyThrows;
import org.apache.ibatis.reflection.property.PropertyNamer;

import java.io.Serializable;
import java.lang.invoke.SerializedLambda;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 */
public class LambdaUtils {
    /**
     * 字段映射
     */
    private static final Map<String, Map<String, ColumnCache>> COLUMN_CACHE_MAP = new ConcurrentHashMap<>();

    /**
     * SerializedLambda 反序列化缓存
     */
    private static final Map<String, WeakReference<com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda>> FUNC_CACHE = new ConcurrentHashMap<>();

    private static Pattern RETURN_TYPE_PATTERN = Pattern.compile("\\(.*\\)L(.*);");
    private static Pattern PARAMETER_TYPE_PATTERN = Pattern.compile("\\((.*)\\).*");
    private static final WeakConcurrentMap<String, SerializedLambda> cache = new WeakConcurrentMap<>();

    /**
     * 获取Lambda表达式返回类型
     */
    public static Class<?> getReturnType(Serializable serializable) {
        String expr = _resolve(serializable).getInstantiatedMethodType();
        Matcher matcher = RETURN_TYPE_PATTERN.matcher(expr);
        if (!matcher.find() || matcher.groupCount() != 1) {
            throw new RuntimeException("获取Lambda信息失败");
        }
        String className = matcher.group(1).replace("/", ".");
        try {
            return Class.forName(className);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException("无法加载类", e);
        }
    }

    @SneakyThrows
    public static <T> Class<?> getReturnType(SFunction<T, ?> func) {
        com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda lambda = com.baomidou.mybatisplus.core.toolkit.LambdaUtils.resolve(func);
        Class<?> aClass = lambda.getInstantiatedType();
        String fieldName = PropertyNamer.methodToProperty(lambda.getImplMethodName());
        Field field = aClass.getDeclaredField(fieldName);
        field.setAccessible(true);
        return field.getType();
    }

    /**
     * 获取Lambda表达式的参数类型
     */
    public static List<Class<?>> getParameterTypes(Serializable serializable) {
        String expr = _resolve(serializable).getInstantiatedMethodType();
        Matcher matcher = PARAMETER_TYPE_PATTERN.matcher(expr);
        if (!matcher.find() || matcher.groupCount() != 1) {
            throw new RuntimeException("获取Lambda信息失败");
        }
        expr = matcher.group(1);

        return Arrays.stream(expr.split(";"))
                .filter(StrUtil::isNotBlank)
                .map(s -> s.replace("L", "").replace("/", "."))
                .map(s -> {
                    try {
                        return Class.forName(s);
                    } catch (ClassNotFoundException e) {
                        throw new RuntimeException("无法加载类", e);
                    }
                })
                .collect(Collectors.toList());
    }

    /**
     * 解析lambda表达式,加了缓存。
     * 该缓存可能会在任意不定的时间被清除。
     *
     * 

* 通过反射调用实现序列化接口函数对象的writeReplace方法,从而拿到{@link SerializedLambda}
* 该对象中包含了lambda表达式的所有信息。 *

* * @param func 需要解析的 lambda 对象 * @return 返回解析后的结果 */
private static SerializedLambda _resolve(Serializable func) { return cache.computeIfAbsent(func.getClass().getName(), (key) -> ReflectUtil.invoke(func, "writeReplace")); } }

3.3游标分页工具类

package org.fth.mallchat.common.common.utils;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.util.StrUtil;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import org.fth.mallchat.common.common.domain.vo.req.CursorPageBaseReq;
import org.fth.mallchat.common.common.domain.vo.resp.CursorPageBaseResp;
import org.springframework.data.redis.core.ZSetOperations;

import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * Description: 游标分页工具类
 */
public class CursorUtils {

    public static <T> CursorPageBaseResp<Pair<T, Double>> getCursorPageByRedis(CursorPageBaseReq cursorPageBaseReq, String redisKey, Function<String, T> typeConvert) {
        Set<ZSetOperations.TypedTuple<String>> typedTuples;
        if (StrUtil.isBlank(cursorPageBaseReq.getCursor())) {//第一次
            typedTuples = RedisUtils.zReverseRangeWithScores(redisKey, cursorPageBaseReq.getPageSize());
        } else {
            typedTuples = RedisUtils.zReverseRangeByScoreWithScores(redisKey, Double.parseDouble(cursorPageBaseReq.getCursor()), cursorPageBaseReq.getPageSize());
        }
        List<Pair<T, Double>> result = typedTuples
                .stream()
                .map(t -> Pair.of(typeConvert.apply(t.getValue()), t.getScore()))
                .sorted((o1, o2) -> o2.getValue().compareTo(o1.getValue()))
                .collect(Collectors.toList());
        String cursor = Optional.ofNullable(CollectionUtil.getLast(result))
                .map(Pair::getValue)
                .map(String::valueOf)
                .orElse(null);
        Boolean isLast = result.size() != cursorPageBaseReq.getPageSize();
        return new CursorPageBaseResp<>(cursor, isLast, result);
    }

    public static <T> CursorPageBaseResp<T> getCursorPageByMysql(IService<T> mapper, CursorPageBaseReq request, Consumer<LambdaQueryWrapper<T>> initWrapper, SFunction<T, ?> cursorColumn) {
        //游标字段类型
        Class<?> cursorType = LambdaUtils.getReturnType(cursorColumn);
        LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
        //额外条件
        initWrapper.accept(wrapper);
        //游标条件
        if (StrUtil.isNotBlank(request.getCursor())) {
            wrapper.lt(cursorColumn, parseCursor(request.getCursor(), cursorType));
        }
        //游标方向
        wrapper.orderByDesc(cursorColumn);

        Page<T> page = mapper.page(request.plusPage(), wrapper);
        //取出游标
        String cursor = Optional.ofNullable(CollectionUtil.getLast(page.getRecords()))
                .map(cursorColumn)
                .map(CursorUtils::toCursor)
                .orElse(null);
        //判断是否最后一页
        Boolean isLast = page.getRecords().size() != request.getPageSize();
        return new CursorPageBaseResp<>(cursor, isLast, page.getRecords());
    }

    private static String toCursor(Object o) {
        if (o instanceof Date) {
            return String.valueOf(((Date) o).getTime());
        } else {
            return o.toString();
        }
    }

    private static Object parseCursor(String cursor, Class<?> cursorClass) {
        if (Date.class.isAssignableFrom(cursorClass)) {
            return new Date(Long.parseLong(cursor));
        } else {
            return cursor;
        }
    }
}

关键方法解释
1.getCursorPageByRedis
  • 作用:通过 Redis 的有序集合(ZSet)实现游标分页。
  • 参数:
    • cursorPageBaseReq: 传入的分页请求对象,包含游标 (cursor)、每页大小 (pageSize) 等信息。
    • redisKey: Redis 中的键,表示要查询的数据所在的有序集合。
    • typeConvert: 用于将 Redis 返回的字符串类型的值转换为目标类型的函数。
  • 实现:
    • 如果请求的游标为空,则表示是第一次请求,使用 zReverseRangeWithScores 获取 Redis 中的前 pageSize 个数据。
    • 如果请求的游标不为空,则通过 zReverseRangeByScoreWithScores 从游标指定的位置开始获取数据。
    • 结果通过 Pair.of 组合值和分数(score),并对结果按分数(score)进行降序排序。
    • 返回新的游标(当前页最后一个数据的 score 值)和是否是最后一页的标识(isLast)。
  • 适用场景:适用于通过 Redis 存储并分页查询的数据,如排行榜、消息队列等场景。
2.getCursorPageByMysql
  • 作用:通过 MySQL 查询实现游标分页。
  • 参数:
    • mapper: MyBatis-Plus 提供的 IService 接口,用于执行数据库查询。
    • request: 包含游标(cursor)、每页大小等信息的分页请求对象。
    • initWrapper: 一个消费者函数,用于对 LambdaQueryWrapper 进行初始化,以添加额外的查询条件。
    • cursorColumn: 游标字段(通常为表中的某个字段,用作分页的依据)。
  • 实现:
    • 获取游标字段的类型,并构造 LambdaQueryWrapper
    • 如果请求的游标不为空,则在 wrapper 中添加条件,表示从游标位置之前的数据进行分页查询。
    • 通过 mapper.page() 方法查询数据,并根据游标字段排序。
    • 计算新的游标值(即最后一条记录的游标字段值),判断是否为最后一页。
  • 适用场景:适用于基于数据库的分页查询,尤其适合数据量较大、数据不断增加的场景,如实时数据分析等。
3. toCursor
  • 作用:将游标字段的值转换为字符串,方便传递和存储。
  • 实现:
    • 如果游标值是 Date 类型,则将其转换为时间戳(Long 类型的值)。
    • 如果是其他类型,直接调用 toString() 方法。
  • 适用场景:用于将不同类型的游标字段转换为可传递的字符串格式。
4. parseCursor
  • 作用:将字符串类型的游标解析为正确的类型。
  • 实现:
    • 如果游标字段是 Date 类型,则将字符串转换为 Date 对象。
    • 对于其他类型,直接返回字符串。
  • 适用场景:用于将请求中的游标字符串转换为对应的字段类型,进行比较操作。

该类为实现游标分页提供了丰富的功能,支持 Redis 和 MySQL 两种数据源的游标分页查询,适用于大规模数据处理的场景。它不仅提供了分页查询的基础功能,还支持游标字段的类型转换,确保了分页逻辑的通用性和灵活性。

四.使用实例

public CursorPageBaseResp<UserFriend> getFriendPage(Long uid, CursorPageBaseReq cursorPageBaseReq) {
        return CursorUtils.getCursorPageByMysql(this, cursorPageBaseReq,
                wrapper -> wrapper.eq(UserFriend::getUid, uid), UserFriend::getId);
    }

你可能感兴趣的:(Java学习之路,项目实战技巧,java,mysql,redis)