写在前面:做过社交类项目的同学一定遇到过Feed流的性能问题。用户打开首页,要加载关注的人的动态,数据量大、并发高,直接查数据库肯定扛不住。今天聊聊微博、抖音都在用的方案:三级缓存设计,让Feed流查询从10秒优化到100毫秒。

文章目录
-
- 一、Feed流的性能挑战
-
- [1.1 什么是Feed流?](#1.1 什么是Feed流?)
- [1.2 直接查数据库的问题](#1.2 直接查数据库的问题)
- 二、三级缓存架构设计
-
- [2.1 整体架构](#2.1 整体架构)
- [2.2 为什么三级缓存比单级缓存好?](#2.2 为什么三级缓存比单级缓存好?)
- 三、各级缓存详细实现
-
- [3.1 L1 本地缓存(Caffeine)](#3.1 L1 本地缓存(Caffeine))
- [3.2 L2 Redis缓存(List/ZSet)](#3.2 L2 Redis缓存(List/ZSet))
- [3.3 L3 数据库(MySQL + 推模式)](#3.3 L3 数据库(MySQL + 推模式))
- 四、缓存一致性保障
-
- [4.1 写入时失效策略](#4.1 写入时失效策略)
- [4.2 缓存预热策略](#4.2 缓存预热策略)
- [4.3 定时刷新策略](#4.3 定时刷新策略)
- 五、预判问题与解答
- 六、与微博架构的对比
- 七、面试高频考点
- 八、总结
- 参考资料
一、Feed流的性能挑战
1.1 什么是Feed流?
实际场景:你打开微博,首页显示你关注的人发的微博;打开抖音,推荐页显示你关注的人发的视频。这就是Feed流------基于关注关系的个性化内容流。
Feed流的特点:
| 特点 | 说明 | 挑战 |
|---|---|---|
| 读多写少 | 用户刷Feed的频率远高于发内容 | 读性能要求高 |
| 数据量大 | 大V有千万粉丝,一条内容要推给千万人 | 存储和查询压力大 |
| 实时性要求 | 新内容要尽快出现在粉丝Feed中 | 延迟敏感 |
| 个性化 | 每个用户的Feed内容不同 | 无法简单缓存 |
1.2 直接查数据库的问题
踩坑提醒 :我见过最糟糕的做法是用户每次刷新Feed,都现查数据库:
SELECT * FROM post WHERE user_id IN (SELECT follow_id FROM follow WHERE user_id = ?) ORDER BY create_time DESC LIMIT 20。粉丝多的用户,这个SQL能执行10秒!
问题分析:
sql
-- 用户关注了1000人,这1000人发了10万条微博
-- 每次刷新都要查10万条数据,排序后取前20条
SELECT * FROM post
WHERE user_id IN (SELECT follow_id FROM follow WHERE user_id = 10086)
ORDER BY create_time DESC
LIMIT 20
性能瓶颈:
- IN查询性能差:1000个ID的IN查询,MySQL优化器可能选择全表扫描
- 排序成本高:10万条数据排序,CPU和内存压力大
- 并发扛不住:1000个用户同时刷新,数据库直接被打挂
二、三级缓存架构设计
2.1 整体架构
用户请求Feed
↓
L1 本地缓存(Caffeine)< 1ms
↓ 未命中
L2 Redis缓存 < 5ms
↓ 未命中
L3 数据库 < 50ms
三级缓存分工:
| 层级 | 存储 | 数据范围 | 命中率 | 延迟 |
|---|---|---|---|---|
| L1 本地缓存 | Caffeine | 用户自己的Feed(最近20条) | 80% | < 1ms |
| L2 Redis缓存 | Redis List/ZSet | 热点用户的Feed(最近100条) | 15% | < 5ms |
| L3 数据库 | MySQL | 全量数据 | 5% | < 50ms |
经验之谈:本地缓存挡住80%请求,Redis挡住15%,数据库只承担5%。这是Feed流优化的黄金比例。
2.2 为什么三级缓存比单级缓存好?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 只用数据库 | 数据一致性好 | 性能差,扛不住高并发 |
| 只用Redis | 性能好 | 单点瓶颈,网络IO开销 |
| 只用本地缓存 | 性能最好 | 数据不一致,重启丢失 |
| 三级缓存 | 兼顾性能和一致性 | 实现复杂,需要维护缓存一致性 |
三级缓存的核心价值:
- L1本地缓存:消除网络IO,性能极致
- L2 Redis缓存:分布式共享,容量大
- L3数据库:持久化,最终兜底
三、各级缓存详细实现
3.1 L1 本地缓存(Caffeine)
存储内容:每个用户自己的Feed列表(最近20条)
java
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String, List<Post>> feedCache() {
return Caffeine.newBuilder()
// 最大缓存条目数
.maximumSize(10000)
// 写入后30秒过期
.expireAfterWrite(30, TimeUnit.SECONDS)
// 记录命中率统计
.recordStats()
.build();
}
}
@Service
public class FeedService {
@Autowired
private Cache<String, List<Post>> feedCache;
@Autowired
private RedisTemplate<String, Post> redisTemplate;
@Autowired
private PostMapper postMapper;
/**
* 获取用户Feed
*/
public List<Post> getFeed(Long userId, int page, int size) {
String cacheKey = "feed:" + userId + ":" + page;
// 1. 查L1本地缓存
List<Post> posts = feedCache.getIfPresent(cacheKey);
if (posts != null) {
log.debug("L1缓存命中,userId={}", userId);
return posts;
}
// 2. 查L2 Redis缓存
posts = getFromRedis(userId, page, size);
if (posts != null) {
log.debug("L2缓存命中,userId={}", userId);
// 回填L1缓存
feedCache.put(cacheKey, posts);
return posts;
}
// 3. 查L3数据库
posts = getFromDatabase(userId, page, size);
log.debug("L3数据库查询,userId={}", userId);
// 回填L2和L1缓存
putToRedis(userId, page, posts);
feedCache.put(cacheKey, posts);
return posts;
}
}
关键参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
| maximumSize | 10000 | 最多缓存1万个用户的Feed |
| expireAfterWrite | 30秒 | 写入30秒后过期,保证最终一致性 |
| 缓存条目 | userId + page | 每个用户每页数据独立缓存 |
经验之谈:本地缓存TTL设置短(30秒),是因为Feed流实时性要求高。新内容发布后,最多30秒就能被所有用户看到。
3.2 L2 Redis缓存(List/ZSet)
存储结构选择:
| 数据结构 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| List | 按时间序排列的Feed | LPUSH/RPOP效率高 | 不支持按分数排序 |
| ZSet | 需要按热度排序的Feed | 支持按分数排序 | 内存占用稍高 |
| Hash | 存储单条内容详情 | 字段独立更新 | 不适合存储列表 |
Feed流推荐用List:
java
@Service
public class FeedRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String FEED_KEY_PREFIX = "feed:";
private static final int REDIS_FEED_SIZE = 100; // 每个用户缓存100条
/**
* 从Redis获取Feed
*/
public List<Post> getFromRedis(Long userId, int page, int size) {
String key = FEED_KEY_PREFIX + userId;
// 计算起始位置
int start = page * size;
int end = start + size - 1;
// 从List中获取指定范围
List<String> postIds = redisTemplate.opsForList()
.range(key, start, end);
if (postIds == null || postIds.isEmpty()) {
return null;
}
// 批量查询内容详情(Pipeline优化)
return getPostDetails(postIds);
}
/**
* 写入Redis缓存
*/
public void putToRedis(Long userId, int page, List<Post> posts) {
if (page != 0) {
// 只缓存第一页,其他页走数据库
return;
}
String key = FEED_KEY_PREFIX + userId;
// 提取ID列表
List<String> postIds = posts.stream()
.map(p -> String.valueOf(p.getId()))
.collect(Collectors.toList());
// 使用Pipeline批量写入
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection stringConn = (StringRedisConnection) connection;
// 删除旧数据
stringConn.del(key.getBytes());
// 批量插入(RPUSH)
for (String postId : postIds) {
stringConn.rPush(key.getBytes(), postId.getBytes());
}
// 设置过期时间(5分钟)
stringConn.expire(key.getBytes(), 300);
return null;
});
}
/**
* 新内容发布时,推送到粉丝的Feed
*/
public void pushToFans(Post post, List<Long> fanIds) {
String postId = String.valueOf(post.getId());
// 批量推送到粉丝的Redis缓存
for (Long fanId : fanIds) {
String key = FEED_KEY_PREFIX + fanId;
// LPUSH插入到头部(最新的在前面)
redisTemplate.opsForList().leftPush(key, postId);
// 只保留最近100条
redisTemplate.opsForList().trim(key, 0, REDIS_FEED_SIZE - 1);
// 设置过期时间
redisTemplate.expire(key, 5, TimeUnit.MINUTES);
}
// 同时删除本地缓存,让粉丝下次请求时重新加载
for (Long fanId : fanIds) {
invalidateLocalCache(fanId);
}
}
}
Redis存储结构:
Key: feed:10086
Type: List
Value: ["10010", "10009", "10008", ...] // Post ID列表,按时间倒序
TTL: 300秒
3.3 L3 数据库(MySQL + 推模式)
推模式 vs 拉模式:
| 模式 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 拉模式 | 用户刷新时,实时查询关注人的内容 | 实现简单 | 查询性能差,大V粉丝多查询慢 | 小系统 |
| 推模式 | 用户发内容时,推送到粉丝的Feed表 | 查询性能极好 | 写放大,大V发内容要写千万条 | 大系统 |
| 推拉结合 | 普通用户推模式,大V拉模式 | 平衡读写 | 实现复杂 | 微博、抖音 |
推模式表设计:
sql
-- 用户Feed表(每个用户一张表,或按用户ID分表)
CREATE TABLE user_feed_10086 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
post_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
create_time DATETIME NOT NULL,
INDEX idx_create_time (create_time)
) ENGINE=InnoDB;
-- 查询用户Feed(超级快,单表查询)
SELECT post_id FROM user_feed_10086
ORDER BY create_time DESC
LIMIT 20;
推模式写入逻辑:
java
@Service
public class FeedPushService {
@Autowired
private UserFollowMapper followMapper;
@Autowired
private FeedMapper feedMapper;
@Autowired
private FeedRedisService feedRedisService;
/**
* 用户发布内容
*/
@Transactional
public void publishPost(Post post) {
// 1. 保存内容
postMapper.insert(post);
// 2. 获取粉丝列表
List<Long> fanIds = followMapper.selectFanIds(post.getAuthorId());
// 3. 推送到粉丝的Feed表(批量插入)
if (fanIds.size() <= 1000) {
// 普通用户,直接推送到所有粉丝
batchPushToFans(post, fanIds);
} else {
// 大V用户,只推送给在线粉丝,离线粉丝用拉模式
batchPushToActiveFans(post, fanIds);
}
// 4. 推送到Redis缓存
feedRedisService.pushToFans(post, fanIds);
}
private void batchPushToFans(Post post, List<Long> fanIds) {
// 批量插入(每批1000条)
List<UserFeed> feeds = fanIds.stream()
.map(fanId -> {
UserFeed feed = new UserFeed();
feed.setUserId(fanId);
feed.setPostId(post.getId());
feed.setAuthorId(post.getAuthorId());
feed.setCreateTime(post.getCreateTime());
return feed;
})
.collect(Collectors.toList());
// 分批插入,避免SQL过长
Lists.partition(feeds, 1000).forEach(batch -> {
feedMapper.batchInsert(batch);
});
}
}
经验之谈:推模式的查询性能极好,因为每个用户的Feed是独立的表/分区,查询时不需要JOIN,单表按时间倒序查即可。代价是写入放大,大V发一条内容要写千万条记录。
四、缓存一致性保障
4.1 写入时失效策略
java
@Service
public class FeedService {
/**
* 发布内容后的缓存处理
*/
public void afterPublish(Post post, List<Long> fanIds) {
// 1. 写入Redis(推送到粉丝Feed)
feedRedisService.pushToFans(post, fanIds);
// 2. 删除粉丝本地缓存(让他们下次从Redis加载)
for (Long fanId : fanIds) {
invalidateLocalCache(fanId);
}
// 3. 写入数据库(异步,不阻塞主流程)
asyncExecutor.execute(() -> {
batchInsertToDatabase(post, fanIds);
});
}
private void invalidateLocalCache(Long userId) {
// 删除该用户的所有页缓存
for (int i = 0; i < 5; i++) {
feedCache.invalidate("feed:" + userId + ":" + i);
}
}
}
4.2 缓存预热策略
java
@Component
public class FeedCacheWarmer implements ApplicationRunner {
@Autowired
private FeedService feedService;
@Override
public void run(ApplicationArguments args) {
// 系统启动时,预热热点用户的Feed缓存
List<Long> hotUserIds = Arrays.asList(10086L, 10010L, 88888L);
for (Long userId : hotUserIds) {
try {
// 查询并缓存第一页
feedService.getFeed(userId, 0, 20);
log.info("预热用户Feed缓存,userId={}", userId);
} catch (Exception e) {
log.error("预热失败,userId={}", userId, e);
}
}
}
}
4.3 定时刷新策略
java
@Scheduled(fixedRate = 60000) // 每分钟执行
public void refreshHotUserFeed() {
// 获取热点用户(最近1小时内有请求的用户)
Set<Long> hotUserIds = getHotUserIds();
for (Long userId : hotUserIds) {
// 删除本地缓存,下次请求时重新加载
invalidateLocalCache(userId);
}
}
五、预判问题与解答
Q1:本地缓存和Redis数据不一致怎么办?
问题场景:用户A发了新内容,写入Redis,但其他用户的本地缓存还没过期,看不到这条新内容。
解答:
方案一:短TTL + 主动失效(推荐)
- 本地缓存TTL设置短(30秒)
- 新内容发布时,主动删除相关用户的本地缓存
- 最多30秒不一致,业务可接受
方案二:版本号机制
java
// 每个用户Feed有一个版本号
Long version = redisTemplate.opsForValue().get("feed:version:" + userId);
// 本地缓存也存版本号
cacheKey = "feed:" + userId + ":" + page + ":" + version;
// 版本号变更时,缓存自然失效
方案三:Canal监听MySQL Binlog
- 数据库变更后,Canal推送消息到MQ
- 消费者删除Redis和本地缓存
- 保证最终一致性
Q2:大V发内容,写入放大怎么解决?
解答:推拉结合模式
java
public void publishPost(Post post) {
List<Long> fanIds = followMapper.selectFanIds(post.getAuthorId());
if (fanIds.size() > 10000) {
// 大V用户:推拉结合
// 1. 推送给在线粉丝(通过WebSocket或长连接)
List<Long> onlineFans = getOnlineFans(fanIds);
pushToFans(post, onlineFans);
// 2. 离线粉丝用拉模式(不推送,等他们刷新时查数据库)
} else {
// 普通用户:全部推送
pushToFans(post, fanIds);
}
}
大V发内容流程:
- 写入自己的Feed表
- 推送给在线粉丝(Redis + 本地缓存失效)
- 离线粉丝不推送,等他们刷新时走拉模式
- 粉丝打开App时,先查自己的Feed表,再查关注的大V的最新内容(合并)
Q3:Redis内存不够怎么办?
解答:
方案一:只缓存活跃用户
java
public void putToRedis(Long userId, int page, List<Post> posts) {
// 只缓存最近7天有登录的用户
if (!isActiveUser(userId)) {
return;
}
// ...
}
方案二:LRU淘汰
java
// Redis配置maxmemory-policy allkeys-lru
// 内存不足时,自动淘汰最近最少使用的Key
方案三:压缩存储
java
// 不存完整的Post对象,只存ID列表
// 内容详情走另外的缓存(如post:10010 -> Post对象)
Q4:本地缓存命中率怎么监控?
解答:
java
// Caffeine自带统计功能
CacheStats stats = feedCache.stats();
log.info("本地缓存统计:命中率={}, 命中次数={}, 未命中次数={}, 加载次数={}",
stats.hitRate(),
stats.hitCount(),
stats.missCount(),
stats.loadCount()
);
// 输出:命中率=0.85, 命中次数=8500, 未命中次数=1500
监控指标:
- 命中率目标:> 80%
- 如果命中率低,检查TTL是否设置太短
- 如果命中率过高,检查是否缓存了不常用的数据
Q5:Feed流分页怎么实现?
解答:
游标分页(推荐):
java
public List<Post> getFeed(Long userId, Long lastPostId, int size) {
String key = FEED_KEY_PREFIX + userId;
// 找到lastPostId的索引
Long index = redisTemplate.opsForList().indexOf(key, String.valueOf(lastPostId));
if (index == null) {
index = 0L;
}
// 从index+1开始取size条
List<String> postIds = redisTemplate.opsForList()
.range(key, index + 1, index + size);
return getPostDetails(postIds);
}
优点:
- 不会出现跳页、重复问题
- 性能稳定,不需要OFFSET
缺点:
- 不能跳转到指定页码
- 适合瀑布流,不适合传统分页
六、与微博架构的对比
微博的Feed流架构更复杂,采用推拉结合+多级缓存:
用户刷新Feed
↓
L1 本地缓存(JVM内)
↓
L2 Redis集群(Tair)
↓
L3 分布式缓存(Memcached)
↓
L4 MySQL(分库分表)
↓
L5 HBase(历史数据)
微博优化技巧:
- 冷热数据分离:最近7天数据在MySQL,历史数据在HBase
- 异步合并:用户关注1000人,合并1000个Feed源,异步加载
- 智能预加载:预测用户行为,提前加载下一页
七、面试高频考点
面试官问:请介绍一下Feed流的三级缓存设计
参考答案:
Feed流三级缓存设计是为了解决高并发场景下的个性化内容查询性能问题。
三级架构:
- L1本地缓存(Caffeine):存储用户自己的Feed,命中率80%,延迟<1ms
- L2 Redis缓存:存储热点用户Feed,命中率15%,延迟<5ms
- L3数据库:全量数据,命中率5%,延迟<50ms
核心策略:
- 推模式写入:用户发内容时推送到粉丝的Feed表和Redis
- 短TTL+主动失效:本地缓存30秒过期,新内容发布时主动删除缓存
- 缓存预热:系统启动时预热热点用户Feed
效果:查询性能从10秒优化到100毫秒,数据库压力降低95%。
面试官问:推模式和拉模式有什么区别?怎么选择?
参考答案:
| 模式 | 写入 | 读取 | 适用场景 |
|---|---|---|---|
| 推模式 | 重(发内容时要写粉丝Feed表) | 轻(单表查询) | 粉丝少的用户 |
| 拉模式 | 轻(只写自己的Feed表) | 重(要查所有关注的人) | 粉丝多的大V |
实际方案:推拉结合
- 普通用户(粉丝<1万):推模式
- 大V用户(粉丝>1万):拉模式或推拉结合
微博的做法:
- 普通用户发内容:推送到所有粉丝
- 大V发内容:只推送给在线粉丝,离线粉丝用拉模式
面试官问:本地缓存和Redis怎么保证一致性?
参考答案:
最终一致性方案:
- 新内容发布时,先写Redis,再删除本地缓存
- 本地缓存TTL设置短(30秒),过期后自动从Redis加载
- 最多30秒不一致,业务可接受
强一致性方案(不推荐):
- 使用分布式锁,写入时锁定
- 写入完成后,广播通知所有节点删除缓存
- 性能开销大,不适合高并发场景
经验:Feed流场景下,最终一致性足够,强一致性代价太高。
面试官问:大V发内容,写入放大怎么解决?
参考答案:
推拉结合:
- 大V发内容时,只推送给在线粉丝(通过WebSocket或长连接)
- 离线粉丝不推送,等他们刷新时走拉模式
- 粉丝打开App时,合并自己的Feed表和关注大V的最新内容
优化写入:
- 批量插入(每批1000条)
- 异步写入(不阻塞主流程)
- 限流保护(大V发内容频率限制)
八、总结
Feed流三级缓存设计核心价值:
- 性能极致:本地缓存<1ms,Redis<5ms,数据库<50ms
- 高并发:本地缓存挡住80%请求,数据库压力降低95%
- 最终一致:短TTL+主动失效,保证数据一致性
- 灵活扩展:推拉结合,应对不同规模用户
一句话总结:通过L1本地缓存+L2 Redis+L3数据库的三级架构,配合推模式写入和短TTL失效策略,实现Feed流从10秒到100毫秒的性能飞跃。
参考资料
互动话题:你在项目中是怎么实现Feed流的?是用推模式还是拉模式?有没有遇到过本地缓存和Redis不一致的问题?欢迎在评论区分享你的实践经验!
如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇
本文为【Java后端技术亮点】系列第4篇,持续更新中...