深分页之殇:从OFFSET到游标分页

大家好,我是程序员小策。

先做个自测------你现在用的分页方案是什么?

A. LIMIT offset, size --- 最朴素的 SQL 分页

B. WHERE id > ? LIMIT size --- 游标分页(Keyset Pagination)

C. ZREVRANGEBYSCORE key max min --- Redis Sorted Set 滚动分页

D. Elasticsearch search_after --- 分布式搜索引擎分页

如果选了 A,先别急------这张方案在小数据量下完全够用,1 万条数据之前甚至感觉不到慢。但一旦数据量上了百万级,用户翻到第 10000 页的时候,这条 SQL 会让 MySQL 扫描几百万行数据,然后扔掉前面几乎所有结果。你的页面加载时间从 50ms 变成 5s,接口超时、服务雪崩随之而来。

今天聊的就是这个问题的完整解法------从 LIMIT OFFSET 到游标分页,再到企业级 Feed 流的推拉模式分页

一、问题定义:为什么 OFFSET 越大越慢?

先来一个灵魂拷问:你写过 LIMIT 10 OFFSET 999990 这样的 SQL 吗?

写过的话,你应该清楚那种"页面转圈、接口超时"的感觉。

没写过也不要紧。来看这个 SQL:

sql 复制代码
-- 第 1 页:完美,查询耗时 10ms
SELECT * FROM feed ORDER BY publish_time DESC LIMIT 10 OFFSET 0;

-- 第 10000 页:崩溃,查询耗时 5s+
SELECT * FROM feed ORDER BY publish_time DESC LIMIT 10 OFFSET 99990;
sql 复制代码
-- 第 100000 页:直接炸了,MySQL 扫描了 100 万行然后扔掉了 999990 行
SELECT * FROM feed ORDER BY publish_time DESC LIMIT 10 OFFSET 999990;

同样的 LIMIT 10,为什么越往后越慢?

LIMIT OFFSET 分页 :MySQL 需要先扫描 OFFSET + LIMIT 行数据到内存,然后丢弃前 OFFSET 行,只返回最后的 LIMIT 行。OFFSET 越大,扫描的行数越多,内存和 CPU 消耗线性增长。

换句话说,你翻到第 10000 页,MySQL 默默帮你查了 100010 行数据,然后扔掉了前 100000 行,只留下最后 10 行返回给你。前面的 10 万行数据,全是白干的活

这里的核心矛盾是什么?

数据库的 B+ 树索引是按页(Page)存储的,不是按行号(Offset)存储的。 OFFSET 指令要求数据库跳过指定行数,但索引结构不支持"跳过",只能先全部扫描再丢弃------这就导致了性能随着 OFFSET 增大而线性恶化。

二、核心概念:游标分页的类比

要理解游标分页,我们先看一个生活中熟悉的场景。

游标分页(Cursor Pagination / Keyset Pagination):不是通过页码来定位数据,而是通过上一页最后一条数据的某个唯一字段(通常是主键或时间戳)作为"锚点",从该锚点之后继续查询下一页。

想象你现在在高速公路上开车,想去服务区。

LIMIT OFFSET 的做法:我先从起点开始,数 100 个收费站出口,然后告诉你第 100 个服务区在第 100 个出口之后。如果我把这条高速公路从头到尾每个出口都数一遍------这就好比 MySQL 扫描了所有行。

游标分页的做法:你直接告诉我"我在哪个服务区停过",我告诉你在那个服务区之后,开 10 分钟就有下一个服务区。我不需要从头数出口,只需要找到你上次停的位置,然后往后查就行了。

用技术语言翻译一下:

类比要素 LIMIT OFFSET 游标分页
定位方式 从头数到指定位置 以上次位置为锚点
扫描范围 从起点到终点 从锚点到下一页
性能曲线 O(n),越往后越慢 O(1),恒定性能
高速公路 重新从起点出发 从上次服务区继续

本质区别: OFFSET 是"跳过指定行数",需要先扫描再丢弃;游标是"记住你已经到哪了",直接从上次位置继续。

三、实现:从朴素方案到企业级方案

方案一:朴素的 LIMIT OFFSET(不要在生产环境的大表上使用)

最基础的分页写法,所有 ORM 框架的 PageHelperMyBatis-Plus Page 底层都是这个实现。

java 复制代码
// KnowPostFeedServiceImpl.java
// 来自知文项目中的公开Feed流查询------这是典型的LIMIT OFFSET分页模式

/**
 * 获取公开的首页 Feed(按发布时间倒序)。
 * 采用三级缓存策略缓解深分页性能问题:
 *   L1: Caffeine 本地缓存
 *   L2: Redis 页面缓存  
 *   L3: Redis 片段缓存(ids + item详情 + 计数)
 * 数据库回源仍使用 LIMIT OFFSET,通过 size+1 技巧判断是否有下一页。
 *
 * @param page                   页码(从1开始)
 * @param size                   每页数量
 * @param currentUserIdNullable  当前登录用户ID(匿名时为null,用于叠加点赞/收藏状态)
 * @return FeedPageResponse      包含分页信息和已叠加用户状态的条目列表
 */
@Override
public FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable) {
    // 参数安全校验:确保 size 在 [1,50] 之间,page >= 1
    int safeSize = Math.min(Math.max(size, 1), 50);
    int safePage = Math.max(page, 1);

    // ========== L1: 本地缓存 (Caffeine) ==========
    String localPageKey = cacheKey(safePage, safeSize);
    FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
    if (local != null && local.items() != null) {
        // 命中本地缓存,只需叠加用户状态返回(不污染缓存)
        List<FeedItemResponse> enrichedLocal = enrich(local.items(), currentUserIdNullable);
        return new FeedPageResponse(enrichedLocal, safePage, safeSize, local.hasMore());
    }

    // ========== L2: Redis 片段缓存 ==========
    // 以小时为粒度分片,避免跨小时整站内容更新导致大面积缓存失效
    long hourSlot = System.currentTimeMillis() / 3600000L;
    String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
    String hasMoreKey = idsKey + ":hasMore";

    FeedPageResponse fromCache = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
    if (fromCache != null) {
        feedPublicCache.put(localPageKey, fromCache);
        return fromCache;
    }

    // ========== L3: 数据库回源 (LIMIT OFFSET) ==========
    // 使用单航班锁(Singleflight)防止缓存击穿 -> 同一时间只允许一个请求回源
    Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
    synchronized (lock) {
        // 双重检查:进入锁后再次检查缓存是否已被其他线程写入
        FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
        if (again != null) {
            feedPublicCache.put(localPageKey, again);
            return again;
        }

        // 核心查询:读取 size+1 条以判断是否有下一页
        int offset = (safeSize * (safePage - 1));
        List<KnowPosts> knowPosts = lambdaQuery()
                .eq(KnowPosts::getStatus, "published")
                .eq(KnowPosts::getVisible, "public")
                .orderByDesc(KnowPosts::getPublishTime)
                .last("limit " + (safeSize + 1) + " offset " + offset)
                .list();

        // size+1 技巧:如果查出来比 size 多1条,说明还有下一页
        boolean hasMore = knowPosts.size() > safeSize;
        if (hasMore) {
            knowPosts = knowPosts.subList(0, safeSize);
        }

        // 空结果缓存(防止缓存穿透):TTL 设短一些,30秒
        if (CollectionUtil.isEmpty(knowPosts)) {
            writeCaches(localPageKey, idsKey, hasMoreKey, safeSize,
                    Collections.emptyList(), Collections.emptyList(), false, Duration.ofSeconds(30));
            return new FeedPageResponse(Collections.emptyList(), safePage, safeSize, false);
        }

        // ... 拼装作者信息、计数、返回结果等逻辑 ...
        // 写入 Redis 片段缓存(TTL 加随机抖动,防止同时过期)
        int baseTtl = 60;
        int jitter = ThreadLocalRandom.current().nextInt(30);
        writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, Duration.ofSeconds(baseTtl + jitter));
        
        return new FeedPageResponse(enriched, safePage, safeSize, hasMore);
    }
}

这段代码说明了什么?

这个项目用一个典型的三级缓存策略来"对冲" LIMIT OFFSET 的深分页问题------把深分页的查询结果缓存起来,让大多数请求命中缓存,只有"缓存未命中"时才回源数据库。

但这只是掩盖了问题,没有解决根本。当用户翻到一个冷门的深页面(比如第 5000 页,这个页面平时没人访问,缓存不会命中),回源数据库时该慢还是慢。

方案二:游标分页(Keyset Pagination)------真正的解法

游标分页的思路很简单:不要用页码定位,用"上一页的最后一条记录"定位

java 复制代码
/**
 * 游标分页查询器 ------ 企业级 Feed 流的核心分页方案
 * 
 * 核心思想:利用索引的有序性,直接从游标位置开始扫描,而不是从头扫到指定OFFSET。
 * 适用于按时间倒序排列的 Feed 流、朋友圈、微博等场景。
 * 
 * 要求:
 * 1. 排序列必须有唯一索引(通常是主键 id 或时间戳 + id 组合)
 * 2. 前端需要传递上一页最后一条记录的 id 或时间戳
 * 3. 不支持跳页(不能从第1页直接跳到第100页)
 */
@Component
public class CursorPaginationQuery {

    // MyBatis Plus 提供的 Mapper(数据库操作接口)
    private final KnowPostsMapper knowPostsMapper;

    public CursorPaginationQuery(KnowPostsMapper knowPostsMapper) {
        this.knowPostsMapper = knowPostsMapper;
    }

    /**
     * 基于游标的分页查询(使用 MyBatis XML 中定义的动态 SQL)
     *
     * @param lastId  上一页最后一条记录的 id(首次查询传 null 或 0)
     * @param limit   每页条数
     * @return FeedItemResponse 列表
     */
    public List<FeedItemResponse> queryByCursor(Long lastId, int limit) {
        // limit+1 技巧:多查1条用于判断是否还有下一页
        List<KnowPosts> posts = knowPostsMapper.cursorQuery(lastId, limit + 1);
        
        boolean hasMore = posts.size() > limit;
        if (hasMore) {
            // 去掉用来判断下一页的那条多余数据
            posts = posts.subList(0, limit);
        }
        
        // 返回的 posts 列表中最后一条的 id 就是下一页的游标
        // 前端拿到后,下次请求时传回来即可
        return convertToResponse(posts, hasMore);
    }
}

对应的 MyBatis XML 映射文件:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper">

    <!-- 游标分页查询
        原理:利用 id 主键索引的有序性,直接定位到游标位置,
        从游标后取 limit+1 条,比 OFFSET 方式性能稳定 100 倍以上
        
        传参说明:
        - lastId:上一页最后一条 id,首次传 null 则查询最新数据
        - limit:需要的条数(调用方已经 +1,用于判断 hasMore)
    -->
    <select id="cursorQuery" resultType="com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts">
        SELECT id, title, description, content, img_urls, tags,
               creator_id, publish_time, status, visible
        FROM know_posts
        WHERE status = 'published'
          AND visible = 'public'
          <!-- 游标条件:利用主键索引过滤,直接定位到上一页之后 -->
          <if test="lastId != null and lastId > 0">
              AND id &lt; #{lastId}
          </if>
        ORDER BY id DESC
        LIMIT #{limit}
    </select>

</mapper>
sql 复制代码
-- LIMIT OFFSET:第100页的查询,扫描了1000行数据
SELECT * FROM feed ORDER BY id DESC LIMIT 10 OFFSET 990;
-- 执行过程:扫描1000行,丢弃990行,返回10行

-- 游标分页:第100页的查询,只扫描了11行数据!
SELECT * FROM feed WHERE id < 990 ORDER BY id DESC LIMIT 11;
-- 执行过程:利用主键索引直接定位到 id=990 的位置,向后取11行

游标分页的核心优势:

  • 性能恒定 O(1) :无论在第 1 页还是第 10000 页,都只扫描 limit+1 行数据
  • 完全利用索引WHERE id < ? ORDER BY id DESC 直接走主键索引,无需 filesort
  • 数据一致性更高:不会因为数据新增导致分页结果出现"重复"或"跳位"

方案三:Redis Sorted Set 推模式(Fanout)------Feed 流的终极武器

在微博、朋友圈这种"关注流"场景下,单纯靠数据库已经不够了。我们需要一种"发帖时就把内容推给粉丝收件箱"的模式。

java 复制代码
/**
 * Feed流推模式发帖服务(Fanout on Write)
 * 
 * 核心思想:博主发帖时,直接将该帖写入所有粉丝的 Redis Sorted Set 收件箱。
 * 读取时只需从自己的收件箱按时间戳范围捞出即可,零数据库查询!
 * 
 * 适用场景:粉丝数在万级以内的中腰部博主
 * 不适场景:千万粉丝大V(粉丝写扩散成本过高,需切换为拉模式或推拉混合)
 */
@Service
@Slf4j
public class FeedFanoutService {

    // Redis 操作客户端,用于操作 Sorted Set
    private final StringRedisTemplate redis;
    // 用于从数据库查询博主信息
    private final KnowPostsMapper knowPostsMapper;

    // Feed 流的 Redis Key 前缀:feed:inbox:{userId}
    private static final String FEED_INBOX_KEY = "feed:inbox:%s";
    // 每个用户收件箱最大容量:防止 Redis 内存爆炸
    private static final long MAX_INBOX_SIZE = 1000L;

    public FeedFanoutService(StringRedisTemplate redis, KnowPostsMapper knowPostsMapper) {
        this.redis = redis;
        this.knowPostsMapper = knowPostsMapper;
    }

    /**
     * 发帖并推送给所有粉丝(Fanout 写扩散)
     * 
     * 执行步骤:
     * 1. 将帖子写入数据库
     * 2. 查询博主的粉丝列表(从 Redis Set 或 DB 中获取)
     * 3. 遍历每个粉丝,在对应收件箱 Sorted Set 中插入帖子ID
     *    - member: 帖子ID(作为唯一标识)
     *    - score: 当前时间戳(作为排序依据)
     * 4. 裁剪收件箱大小,防止无限膨胀
     *
     * @param blog    博客文章实体
     * @return 发帖结果
     */
    public Result saveBlog(Blog blog) {
        // 1. 先写数据库------持久化是底线,Redis 只是加速层
        knowPostsMapper.insert(blog);

        // 2. 获取粉丝列表(从 Redis Set 中查关注关系,避免查 DB)
        //    follower:targetUserId 是一个 Set,存的是粉丝的用户ID
        Set<String> followerIds = redis.opsForSet()
                .members("follower:" + blog.getUserId());

        // 3. 如果没有粉丝,就不用写扩散了
        if (followerIds == null || followerIds.isEmpty()) {
            return Result.ok(blog.getId());
        }

        // 4. 批量写入每个粉丝的收件箱 Sorted Set
        //    使用 Pipeline 批量发送指令,减少网络往返(RTT)
        redis.executePipelined((RedisCallback<Object>) connection -> {
            for (String followerId : followerIds) {
                // Key: feed:inbox:{userId} ------ 每个粉丝都有一个专属收件箱
                String inboxKey = String.format(FEED_INBOX_KEY, followerId);
                
                // ZADD key score member:将帖子ID加入有序集合
                // score 使用当前毫秒时间戳,保证按时间倒序排列
                connection.zSetCommands().zAdd(
                        inboxKey.getBytes(),
                        System.currentTimeMillis(),  // score = 时间戳
                        String.valueOf(blog.getId()).getBytes()  // member = 帖子ID
                );

                // ZREMRANGEBYRANK key 0 -MAX_INBOX_SIZE:裁剪收件箱
                // 只保留最新的 1000 条,防止无限增长撑爆 Redis
                connection.zSetCommands().zRemRangeByRank(
                        inboxKey.getBytes(),
                        0,
                        -(MAX_INBOX_SIZE + 1)  // 保留排名从 0 到 -MAX_INBOX_SIZE 的元素
                );
            }
            return null;
        });

        return Result.ok(blog.getId());
    }

    /**
     * 滚动查询 Feed 流(基于 Sorted Set 的游标分页)
     * 
     * 核心逻辑:
     * - 使用 ZREVRANGEBYSCORE 从收件箱中按时间戳倒序取帖
     * - max 和 offset 共同构成"游标":从某个时间戳开始,跳过 offset 条
     * - 相比 LIMIT OFFSET,这种方法性能恒定且天然支持滚动
     *
     * @param max    上次返回的最小时间戳(首次传当前时间戳 + 1)
     * @param offset 偏移量(用于处理同一时刻多条帖子的场景,首次传0)
     * @return Feed流数据
     */
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 获取当前登录用户ID(从 Token 或 ThreadLocal 中获取)
        Long userId = UserHolder.getUser().getId();
        String key = String.format(FEED_INBOX_KEY, userId);

        // ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count
        // 从收件箱中取出 2 条(多查 1 条判断是否还有下一页)
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redis.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 3);

        // 判空:如果收件箱为空,直接返回空列表
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok(Collections.emptyList());
        }

        // 解析结果:取前 2 条作为当前页数据,第 3 条用来判断 hasMore
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os = 1;  // 默认偏移量

        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            // member 存储的是帖子ID字符串,需要转为 Long
            ids.add(Long.valueOf(tuple.getValue()));
        }

        // 偏移量计算:如果本条不是最后一条,说明还有下一页
        // minTime 用来记录当前页最后一条的时间戳
        // offset 用来记录同一时间戳下的偏移量(防止同一毫秒多条数据重复/遗漏)
        if (typedTuples.size() <= 2) {
            // 取到的数据 <=2 条,说明已经没有更多了
            // hasMore = false
            return Result.ok(convertToResponse(ids, false));
        } else {
            // 取到了第 3 条,说明还有更多数据
            // minTime 设为第 2 条的时间戳(作为下一页的 max 游标)
            // os 计数同时间戳的重复条数(作为下一页的 offset)
            // hasMore = true
            return Result.ok(convertToResponse(ids.subList(0, 2), true));
        }
    }
}

核心逻辑图解:

复制代码
发帖推流流程(Fanout on Write):

  博主发帖 → 查粉丝列表 → 遍历粉丝 → ZADD feed:inbox:{followerId} score=时间戳
                                          ↓
                                 每个粉丝的收件箱
                                  (Sorted Set, 最多1000条)
                                          ↓
  粉丝刷Feed → ZREVRANGEBYSCORE feed:inbox:{myId} → 拿到帖子ID列表
                                          ↓
                                  根据ID批量查数据库/缓存
                                          ↓
                                  返回带详情的Feed流

四、边界情况与陷阱

看起来很完美了对吧?但生产环境里,每一个方案都有自己的"死穴"。

陷阱一:游标分页不支持跳页

游标分页必须依次翻页------你只能从第 1 页翻到第 2 页,不能从第 1 页直接跳到第 100 页。

后果:如果产品经理要求"支持跳转到第 100 页",游标分页直接废掉。

解法:跟产品经理讲清楚需求场景。如果真的有跳页需求(如管理后台的日志查询),保留 LIMIT OFFSET 方案但严格控制数据量+添加限制条件(不允许深分页)。

陷阱二:排序字段不唯一导致数据重复/遗漏

如果只用 publish_time 做游标,同一毫秒发了两条帖,游标定位会不准确。

sql 复制代码
-- 问题:两条帖子的 publish_time 都是 2026-05-30 10:00:00.000
-- 游标定位到这个时间戳,但 next 到底是从上一条还是下一条开始?
SELECT * FROM feed WHERE publish_time < '2026-05-30 10:00:00' ORDER BY publish_time DESC LIMIT 10;

后果:用户翻页时看到重复的帖子,或者跳过了某条帖子。

解法 :游标必须用"唯一字段组合",通常是 (publish_time, id) 二元组:

sql 复制代码
-- 正确做法:游标包含时间和ID两个字段,确保唯一性
SELECT * FROM feed 
WHERE (publish_time < #{lastTime}) 
   OR (publish_time = #{lastTime} AND id < #{lastId})
ORDER BY publish_time DESC, id DESC 
LIMIT #{limit}

陷阱三:Redis Sorted Set 的推模式在亿级粉丝下失效

千万粉丝的大V发一条微博,要遍历 1000 万个粉丝写入收件箱------写扩散量级过于恐怖。

后果:发帖接口 RT 从 50ms 飙升到 10s+,Redis CPU 打满。

解法:对粉丝量超过阈值的大V切换为"拉模式"或"推拉混合模式"。普通博主用推模式,大V用拉模式------粉丝刷Feed时再去大V的主页拉取最新帖子。

五、高级考量:Feed 流的三种分发模式

聊到企业级 Feed 流,绕不开一个经典的架构决策:推模式 vs 拉模式 vs 推拉混合

模式 写开销 读开销 适用场景 典型代表
推模式(Fanout on Write) 高(O(粉丝数)) 低(O(1)) 中小博主、朋友圈 微信朋友圈
拉模式(Pull on Read) 低(O(1)) 高(O(关注数)) 大V、热点事件 微博大V
推拉混合 综合方案 微博、抖音

推模式(上面已经实现)

写时扩散:我发了帖子,写入所有粉丝收件箱。读取时粉丝直接取自己的收件箱。

优点 :读性能极好,粉丝刷 Feed 只需从 Redis 取。

缺点:大V发帖时写扩散量过大。

拉模式

读时扩散:我发了帖子,只写自己的主页。粉丝刷 Feed 时,遍历所有关注的人,拉取他们的最新帖子,再在内存中做归并排序。

优点 :发帖成本恒定,不因粉丝量变化。

缺点:读性能差,粉丝多的时候需要归并几十个有序列表。

java 复制代码
/**
 * 拉模式 Feed 流查询(Pull on Read)
 * 
 * 适用场景:大V发帖、或推拉混合模式中处理大V的部分。
 * 粉丝刷Feed时,需要遍历所有已关注的博主,分别拉取最新帖子,
 * 然后在应用层做归并排序(Merge Sort)。
 *
 * @param userId          当前用户ID
 * @param lastTimestamps  每个关注博主的游标 Map(博主ID -> 上次看到的时间戳)
 * @param limit           每页条数
 * @return 归并排序后的 Feed 列表
 */
public List<FeedItemResponse> pullFeed(Long userId, 
                                        Map<Long, Long> lastTimestamps, 
                                        int limit) {
    // 1. 获取当前用户关注的博主列表
    Set<Long> followeeIds = getFollowees(userId);

    // 2. 并发拉取每个博主的最新帖子(使用虚拟线程或 CompletableFuture)
    List<CompletableFuture<List<FeedItemResponse>>> futures = followeeIds.stream()
            .map(followeeId -> CompletableFuture.supplyAsync(() -> {
                // 每个博主拉取 limit 条(从游标之后)
                Long cursor = lastTimestamps.getOrDefault(followeeId, Long.MAX_VALUE);
                return queryPostsByUserAndCursor(followeeId, cursor, limit);
            }))
            .collect(Collectors.toList());

    // 3. 等待所有并发查询完成
    List<List<FeedItemResponse>> allLists = futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());

    // 4. 多路归并排序:合并多个有序列表为一个全局有序列表
    //    使用 PriorityQueue(优先队列)实现归并
    PriorityQueue<IndexedItem> queue = new PriorityQueue<>(
            (a, b) -> Long.compare(b.item.publishTime(), a.item.publishTime())
    );

    // 初始化:每个列表的第一条入队
    for (int i = 0; i < allLists.size(); i++) {
        if (!allLists.get(i).isEmpty()) {
            queue.offer(new IndexedItem(allLists.get(i).get(0), i, 0));
        }
    }

    // 依次弹出最大元素,并从对应列表中补充下一条
    List<FeedItemResponse> result = new ArrayList<>(limit);
    while (!queue.isEmpty() && result.size() < limit) {
        IndexedItem smallest = queue.poll();
        result.add(smallest.item);

        // 从同一个列表中取出下一条补充入队
        int listIdx = smallest.listIdx;
        int itemIdx = smallest.itemIdx + 1;
        if (itemIdx < allLists.get(listIdx).size()) {
            queue.offer(new IndexedItem(
                    allLists.get(listIdx).get(itemIdx), listIdx, itemIdx));
        }
    }

    return result;
}

推拉混合(企业级标配)

大部分普通用户用推模式(直接读收件箱),大V用户切换为拉模式(粉丝刷 Feed 时实时去大V主页拉取)。

判断依据:粉丝数超过某个阈值(如 100 万)时,自动标记为"大V",发帖时不写扩散,粉丝查询时走拉模式。

复制代码
推拉混合典型流程:

1. 博主发帖 → 系统判断粉丝数
   ├── 粉丝数 < 100万 → 推模式:写入所有粉丝收件箱
   └── 粉丝数 ≥ 100万 → 拉模式:只写入博主自己的主页

2. 粉丝刷 Feed
   ├── 从收件箱 Sorted Set 中取普通博主的帖子(推模式,3条)
   ├── 从大V主页拉取最新帖子(拉模式,3条)
   └── 归并排序后返回(总计6条)

六、面试追问

如果这篇文章是面试准备的一部分,下面这几个追问是面试官大概率会问的:

面试追问 1:游标分页怎么处理"全局置顶"的帖子?

回答方向 :置顶帖子拥有更靠前的排序权重,游标分页的排序条件不能只用时间或ID。需要引入"置顶分值"作为排序字段的第一优先级。例如 ORDER BY is_top DESC, publish_time DESC,游标条件也要相应变为三元组 (is_top, publish_time, id)。或者把置顶帖单独拉出来放在一个独立的缓存区,不参与滚动分页。

面试追问 2:Sorted Set 的收件箱如果超过了 MAX_INBOX_SIZE 怎么办?

回答方向 :有两种策略。一是裁剪策略 (上面代码中的做法):每次写入时 ZREMRANGEBYRANK 只保留最新的 N 条。二是过期策略:给收件箱设置 TTL,用户登录时触发重建。生产上一般是两者结合------裁剪保证容量可控,过期重建保证用户不会永久丢失数据。

面试追问 3:推拉混合模式下,怎么判断粉丝刷 Feed 时大V的帖子往哪里插入?

回答方向 :核心问题是"推模式收件箱"和"拉模式大V帖子"的归并时机和位置 。常见做法有两种:①客户端归并 ------服务端返回推模式的收件箱列表,前端再额外拉取大V帖子自行合并。②服务端归并------服务端在返回前先把大V帖子查到,内存归并后再返回。服务端归并会增加接口延迟,通常使用缓存预热的策略来缓解。

面试追问 4:同一时间戳下有多条数据,用 Sorted Set 滚动分页怎么保证不丢不重?

回答方向 :这是经典的"分页断页"问题。解法是记录 offset 偏移量 。ZREVRANGEBYSCORE 的 API 支持 offset 参数,表示在同分值中跳过前 N 个元素。前端在翻页时需要同时传 max(时间戳)和 offset(偏移量)。如果上一页的最后一条数据的时间戳和更早的数据时间戳相同,下一页就要跳过这些已经被取过的数据。

七、对比表格

方案 核心原理 深分页性能 是否支持跳页 实现复杂度 适用场景
LIMIT OFFSET 扫描全部+丢弃 O(n),差 支持 极低 后台管理、数据量<100万
游标分页(Keyset) 利用索引定位 O(1),优 不支持 内容Feed流、朋友圈
Redis Sorted Set 推模式 写时扩散收件箱 O(1),极优 不支持 微博、抖音(中小博主)
拉模式归并 读时多路归并 O(关注数),中 不支持 大V、热点事件
推拉混合 推+拉结合 O(1)~O(关注数) 不支持 综合型企业级Feed

一句话选型建议: 业务初期用 LIMIT OFFSET + 缓存兜底撑到百万级;一旦用户量和数据量上来,果断迁移到"游标分页 + Redis Sorted Set 推模式"的架构;面对千万粉丝的大V,补充拉模式归并,走推拉混合。

八、总结

深分页的核心矛盾不是"数据太多",而是"定位方式错了"。

OFFSET 要求数据库"从头数到指定位置",这在索引结构上天然就是低效的。游标分页解决了定位方式的问题,Redis Sorted Set 推模式解决了"读扩散"的性能瓶颈,推拉混合解决了"写扩散"的大V场景。

读完这篇你应该能:

  1. 清楚解释为什么 LIMIT OFFSET 越翻越慢,并给出量化分析
  2. 自己实现一套基于游标分页的 Feed 流接口(MyBatis XML + Java)
  3. 设计一个基于 Redis Sorted Set 的推模式 Feed 流,覆盖边界情况
  4. 在面试中说出"推拉混合"而不只是"用 Redis 缓存分页"