大家好,我是程序员小策。
先做个自测------你现在用的分页方案是什么?
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 框架的 PageHelper、MyBatis-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 < #{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场景。
读完这篇你应该能:
- 清楚解释为什么 LIMIT OFFSET 越翻越慢,并给出量化分析
- 自己实现一套基于游标分页的 Feed 流接口(MyBatis XML + Java)
- 设计一个基于 Redis Sorted Set 的推模式 Feed 流,覆盖边界情况
- 在面试中说出"推拉混合"而不只是"用 Redis 缓存分页"