按照时间排序的分布式游标分页

背景

     最近有这么一个需求,就是在分页查询的时候,需要返回最近的pagesize条记录,即按照时间倒序的近pagesize条记录。有两个问题:

  • 一个就是这些记录来自于不同的存储位置,不能通过一次查询统一排序取数据,而需要分开查询读入,再汇总统一排序
  • 另一个就是在进行分页的时候,要保证当前页数据与上一页的连贯性,有点类似刷短视频的瀑布流。

即分页查询是统一的,而数据存储是分布式的。

方案

     由于是在高并发的场景下,pagesize会有限制,这里想的是对每个需要取数的缓存/数据库分别取最近pagesize条记录,然后在内存统一排序,最后返回前pagesize条。由于每个数据库都取出了pagesize条,是一定能保证取出全局的近pagesize条记录的,有点类似多路归并排序的思想。
代码差不多长这样:

List<CompletableFuture<List<Data>>> futures = todoList.stream()
                .map(x -> CompletableFuture.supplyAsync(() -> getListData(x), executor)).toList();
        try {
            return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                    .thenApply(v -> futures.stream()
                            .map(CompletableFuture::join)
                            .flatMap(List::stream)
                            .sorted((a, b) -> b.getTime().compareTo(a.getTime()))
                            .skip(pagesize)
                            .collect(Collectors.toList()))
                    .get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }

     第一个问题解决了,还有第二个问题。就是怎么保证分页过程中,不同数据源之间的数据连贯性。这里受到前辈的启发,通过回传上一页的最后一条记录的时间戳实现,有点类似游标分页的感觉。
     就是在分页是时候,假如第一页取得是最近20条数据,那么第二页应该就是最近20-最近40条数据,我们可以拿上一页最后一条记录的时间戳作为过滤条件,再走一遍分页查询的过程。这样我们会去每个数据源中筛选,数据时间在上一页最后一条数据以前的数据,分别再取20条,然后再进行汇总排序,此时前20条就是全局的最近20-最近40条数据。

问题

     在高并发的情况下,大量数据产生时间集中,秒级别的时间单位不足以进行精确区分,例如很多数据的时间处于同一秒,在传入秒级别时间戳进行排序过滤的时候,会产生很多冗余数据,需要将时间替换为纳秒级别(视实际情况而定)。
     这就需要两个地方同时做修改:

  • 数据写入:数据写入时间转换为纳秒级别时间戳以便比较
  • 数据查询:分页查询结果中的纳秒时间戳转换为纳秒格式的时间类型显示即yyyy-MM-dd HH:mm:ss.SSSSSS

在这个过程中,就会出现一系列的字符串与时间戳及时间类型的转换,如:

回传字符串( “2025-02-26 17:34:47.000232”) <= =>
纳秒时间戳(1.740591287000232E9) <= =>
纳秒时间类型(2025-02-26T17:34:47.000232)

这个过程中,如果实体变量使用的是Date类型存储,就会出现精度丢失问题,Date类型只能精确到毫秒级别,当转换纳秒级别时间戳的时候,就会丢失精度,必须使用LocalDateTime类型,此外,纳秒级别的LocalDateTime 转换到 double 时也会产生精度丢失,我们需要保证它们之间无精度丢失的转换,同时保证转换后时间戳的大小顺序,这里有两种方法:

  • 通过BigDecimal 转换:使用BigDecimal 能做到无精度丢失的接收LocalDateTime,但是接收类型为BigDecimal ,不是double,而且会有一定的性能消耗,不符合我当前的业务场景
  • 通过**(秒.纳秒)拼接**:将秒数作为整数部分,将纳秒数规范化为9位数字作为小数部分,double 类型可以精确表示最多15-17位十进制数字,而(秒.纳秒)通常不会超过这个范围。

这里我选择使用第二种方式,代码如下:

// 纳秒级别double时间戳 => LocalDateTime 
public static LocalDateTime getLocalDateTimeByDouble(double score) {
        // 获取整数部分(秒)
        long seconds = (long) score;
        // 获取小数部分(纳秒)
        double fractionalPart = score - seconds;
        // 将小数部分转换为纳秒
        int nanos = (int) Math.round(fractionalPart * 1_000_000_000);
        
        return LocalDateTime.ofEpochSecond(seconds, nanos, ZoneOffset.UTC);
    }
// LocalDateTime => 纳秒级别double时间戳
public static double getDoubleWithNanosByLocalDateTime(LocalDateTime localDateTime) {
        long seconds = localDateTime.toEpochSecond(ZoneOffset.UTC);
        int nanos = localDateTime.getNano();
        // 将纳秒转换为9位数,确保前导零
        String nanosStr = String.format("%09d", nanos);
        // 拼接秒和纳秒部分
        return Double.parseDouble(seconds + "." + nanosStr);
    }

     最后通过这个转换,总算是实现了这个分布式分页查询的数据连贯性。

你可能感兴趣的:(记录,分布式)