资讯动态

Spring Boot + Redis Stream 实战:达人探店 Feed 流核心设计与踩坑记录

发布时间:2026/10/17 16:13:07 来源:尧图企业网站定制
本地生活类的点评项目做到“达人探店”这个实战环节时我明显感觉难度上了一个台阶。前几个模块还是偏 CRUD 的增删改查到这一章开始真正涉及到内容流、关系链和实时互动用户发布探店笔记其他用户刷列表、看详情、点赞、关注博主最后系统还得把博主的新动态自动推送给粉丝。这套链路把 Spring Boot、Redis、MySQL 三种技术拧在一起如果只是照着视频敲一遍很容易敲完就忘真正把它想透后面做社区类、内容类、甚至电商 Feed 流项目都会轻松很多。这篇笔记就按我自己实际动手的顺序来写从功能拆解、数据模型、接口实现到踩坑记录尽量把每个设计背后的“为什么”讲清楚。适合刚学完 Spring Boot 和 Redis 基础、准备做第一个完整项目的朋友也适合面试前想快速回拢业务场景的同学。想直接照做的可以当成通关手册使用最好先自己动手实现一遍再回来对照几个容易翻车的细节。1. 达人探店功能全景与设计思路1.1 这一章到底要做一套什么功能达人探店中的“达人”本质上是平台上持续产出内容的普通用户。整套功能可以拆成两条业务线内容线和关系线。内容线围绕探店笔记本身展开发布笔记、分页浏览笔记列表、查看笔记详情、点赞和取消点赞、展示点赞榜单。关系线围绕用户之间的关注关系展开关注博主、取消关注、判断是否已关注、查询共同关注以及最核心的关注推送 Feed 流。只要博主发布了新笔记系统要把这条笔记推送进每一个粉丝的“收件箱”。两条线不是孤立存在的而是咬合成一个闭环。没有粉丝关系探店笔记就只是单向展示的内容没有笔记内容关注关系也没有实际意义。所以在实现时需要刻意把这些功能串成一条完整链路用户发布笔记 → 系统推送给粉丝 → 粉丝查看笔记 → 粉丝点赞 → 粉丝关注博主 → 博主再发新笔记继续推送。这个闭环搞懂之后会发现它其实就是一个简化版的内容社区核心流程。把这个模块吃透再去理解信息流产品会顺很多。1.2 核心技术选型与为什么不盲选 List 或 ZSet这个实战项目是典型的单体应用技术组合很主流Spring Boot MyBatis Plus MySQL Redis。在本章里Redis 承担了大部分性能敏感任务比如笔记详情缓存、点赞标记、关注关系缓存、Feed 流的写入和读取。关于关注推送网上很多方案会写“用 Redis List 做 Feed 流”实际做下来发现坑不少。List 的逻辑很简单每个用户一个 List博主发布后往粉丝的 List 里 LPUSH粉丝端 LRANGE 分页读取。看起来挺好懂但它有个致命问题——List 本身不支持按游标分页。粉丝读到第二页时博主又发了新笔记新数据插到列表头部页码整体往后错下一页就会出现重复或者漏读。另外 List 也没有“我消费到哪一条了”这个概念多端登录时很难做到读取进度一致。ZSet 比 List 好一些可以用 score 存时间戳实现滚动分页。但它依然缺少消费者组的概念如果粉丝在多个设备同时拉取 Feed依然可能出现重复消费的问题。Redis Stream 是 5.0 引入的结构既有消息的增删查能力又支持消费者组还能用消息 ID 做精确的滚动分页。它和 Feed 流场景的契合度非常高每个粉丝维护一个 Stream消息 ID 天然单调递增新笔记追加在末尾粉丝端携带上次读到的 ID 继续往后读不会因为新数据插入而错位。下面用一张表把几种方案的适用情况列出来方便对比方案使用场景关键优缺点Redis List简单消息通知、订阅推送实现简单但无法按游标分页多端消费易冲突Redis ZSet排行榜、时间线场景支持按 score 滚动分页但缺少消费者组Redis StreamFeed 流、消息队列支持消费者组 滚动分页功能最贴合MySQL 直接分页内容量小、访问量低实现最直观但高并发下扛不住实际项目里除非明确知道量级很小否则我建议直接上 Stream。学习阶段多花三十分钟把 Stream 搞懂后面面试聊到 Feed 流时能讲得比别人深一层。2. 探店笔记发布与列表页实现2.1 数据模型先打底blog 表与 follow 表先把表结构说清楚后面所有接口都是围绕这两张表转的。blog 表用来存探店笔记核心字段包括id、shop_id、user_id、title、images、content、liked、comments、create_time、update_time。其中 images 字段建议设计成 JSON 字符串比如[url1,url2]。不建议用逗号分隔字符串因为图片链接本身可能包含逗号等特殊字符后续解析容易出岔子用 JSON 数组存储虽然形式上稍微啰嗦但解析稳定前端也直接认。follow 表是关注关系表字段包括id、user_id、follow_user_id、is_follow、create_time。这里要特别强调两个点user_id 是主动关注人也就是粉丝follow_user_id 是被关注的人也就是博主。这个方向一旦搞反后面查询粉丝列表、查询我关注的人都会乱。另外给 user_id 和 follow_user_id 建联合唯一索引防止同一对关系重复插入。很多人在做关注功能时只给单列索引并发场景下双击关注按钮很容易出现两条一模一样的脏数据。2.2 发布笔记接口一个接口里的两条业务链发布接口是这一章的起点代码量不大但包含了两条业务链先把笔记落到 MySQL再把笔记推送给粉丝的 Feed 流。PostMapping(/blog) public Result saveBlog(RequestBody Blog blog) { // 1. 获取当前登录用户 UserDTO user UserHolder.getUser(); blog.setUserId(user.getId()); // 2. 保存笔记 blogService.save(blog); // 3. 查询当前用户的所有粉丝 ListLong fanIds followService.lambdaQuery() .eq(Follow::getFollowUserId, user.getId()) .list() .stream() .map(Follow::getUserId) .collect(Collectors.toList()); // 4. 遍历粉丝往每个粉丝的 Stream 推送笔记 id for (Long fanId : fanIds) { String key RedisConstants.FEED_KEY fanId; stringRedisTemplate.opsForStream().add( key, Map.of(blogId, blog.getId().toString()) ); } return Result.ok(blog.getId()); }这里最容易犯的错误就是查粉丝方向搞反Follow 表里 user_id 是粉丝follow_user_id 是博主。查“当前用户的粉丝”时条件应该用follow_user_id 当前用户id查出的是所有 follow_user_id 等于当前用户的那批记录这些记录的 user_id 才是粉丝。还有一个安全性小细节userId 必须从登录态里取不能信任前端传值。如果接口直接绑定 Blog 对象且允许前端传 userId恶意用户就能伪造发帖人身份。这种问题在演示项目里容易被忽略但在真实项目里是低级安全漏洞。发布这一步如果粉丝量很大for 循环逐个写 Redis 会很慢。项目演示为简洁是同步实现的真实场景一般会改成异步推送这个后面单独聊优化。2.3 列表页把笔记和用户信息“拼”起来探店笔记列表页要做两件事分页查笔记然后把每条笔记的作者昵称、头像补全返回给前端。第一次写的时候很自然的思路是 for 循环查 Userrecords.forEach(blog - { User user userService.getById(blog.getUserId()); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); });这段代码在数据量小时能跑通但存在典型的 N1 查询问题10 条笔记就要执行 10 次用户查询。数据量上去之后这个接口会越来越慢面试官看到这种写法也大概率会追问。正确做法是先批量取 userIds查一次用户列表再在内存里做映射ListLong userIds records.stream() .map(Blog::getUserId) .distinct() .collect(Collectors.toList()); ListUser users userService.listByIds(userIds); MapLong, User userMap users.stream() .collect(Collectors.toMap(User::getId, u - u)); records.forEach(blog - { User user userMap.get(blog.getUserId()); if (user ! null) { blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } });这样无论列表里有多少条记录用户查询永远只有一次。这个“先批查、再内存组装”的思路不光在本章有用后续 Feed 流查询、评论列表、订单列表都会用到值得养成习惯。2.4 列表页两个常见坑类型拷贝与图片解析第一个坑是字段拷贝时把类型弄坏了。Blog 实体里扩展了 name、icon 等展示字段如果直接用 BeanUtils.copyProperties 从 User 对象拷贝两个类里有同名但类型不同的字段时框架可能转换失败或者把值设成 null。我这里建议手动 set 需要的字段办法虽然笨但不会出隐藏问题。第二个坑是 images 字段格式。数据库里存的是 JSON 字符串查出来之后实体里的 is 是 String但前端希望拿到 List。常见的处理方式是在返回之前做一层转换blog.setImages(JsonUtils.parseList(blog.getImages(), String.class));注意不要在实体 getter 里做转换否则每次序列化都可能出现不可预期的问题最好在接口输出前统一处理。我在第一次实现时就是在 getter 里动了手脚结果列表接口和详情接口返回格式不一致调试了好一阵子才发现问题根源。3. 笔记详情与点赞交互3.1 详情页一个标准的缓存加载模板笔记详情接口很适合用来做 Redis 缓存优化因为笔记读多写少而且单条笔记是天然的数据粒度。代码如下GetMapping(/blog/{id}) public Result queryBlogById(PathVariable(id) Long id) { String key RedisConstants.BLOG_CACHE_KEY id; // 1. 查缓存 String blogJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(blogJson)) { return Result.ok(JsonUtils.toBean(blogJson, Blog.class)); } // 2. 缓存未命中查数据库 Blog blog blogService.getById(id); if (blog null) { // 3. 空值缓存防止缓存穿透 stringRedisTemplate.opsForValue().set(key, , RedisConstants.CACHE_NULL_TTL); return Result.fail(笔记不存在); } // 4. 回填缓存 stringRedisTemplate.opsForValue().set(key, JsonUtils.toJson(blog), RedisConstants.CACHE_BLOG_TTL); return Result.ok(blog); }这个模板是缓存 aside 模式的经典写法先查缓存、没命中查库、回填缓存。三个细节值得记住缓存空值要设 TTL不然恶意请求大量不存在的 IDRedis 里会被塞进一堆空 key占内存且无意义缓存 key 粒度到“一条记录”不要用“整个列表”做缓存列表缓存失效会引发大面积缓存击穿笔记内容更新后要主动删缓存否则用户看到的永远是旧笔记。3.2 点赞功能为什么用 Redis Set 而不是数据库表点赞功能在项目里有很多种实现方式最直接的是建一张点赞表记录 user_id 和 blog_id。但实际开发中高频点赞场景下数据库行锁会成为瓶颈所以这一章采用的是 Redis Set MySQL 计数器组合方案。核心设计key 是blog:liked:{blogId}value 是 userId 的集合。点赞时往 Set 里加 userId数据库 liked 字段加一取消点赞时从 Set 里移除数据库减一。示例代码public Result likeBlog(Long id) { Long userId UserHolder.getUser().getId(); String key RedisConstants.BLOG_LIKED_KEY id; // 判断当前用户是否点过赞 Boolean isMember stringRedisTemplate.opsForSet().isMember(key, userId.toString()); if (BooleanUtil.isFalse(isMember)) { // 点赞写入 Set数据库 1 stringRedisTemplate.opsForSet().add(key, userId.toString()); blogService.lambdaUpdate() .setSql(liked liked 1) .eq(Blog::getId, id).update(); } else { // 取消赞从 Set 移除数据库 -1 stringRedisTemplate.opsForSet().remove(key, userId.toString()); blogService.lambdaUpdate() .setSql(liked liked - 1) .eq(Blog::getId, id).update(); } return Result.ok(); }为什么用 Set因为点赞的天然特性就是一个用户对一篇笔记只能有一条记录Set 天然去重而且判断“用户是否点过赞”只是 O(1) 的 sismember 操作非常快。为什么不直接操作数据库表因为热门笔记的点赞请求集中在少数几条记录上数据库的行锁竞争会成为瓶颈而 Redis 单线程 原子操作在几千并发下基本没有压力。这里还要注意一点取消点赞前必须用 isMember 判断一次否则对不存在的点赞记录执行减一数据库里的 liked 值会变成负数这属于业务漏洞。3.3 是否点过赞详情页要多返回一个字段前端进入详情页时需要知道当前用户是否点过赞所以详情接口在返回笔记对象之外还要额外返回 isLike 字段boolean isLike stringRedisTemplate.opsForSet().isMember(key, userId.toString()); MapString, Object result new HashMap(); result.put(blog, blog); result.put(isLike, isLike); return Result.ok(result);这里判断的是 Redis Set 中的数据性能极好。但要注意冷启动问题如果某篇笔记刚上线Redis 里还没有它的点赞 keyisMember 返回 false 是合理的因为数据库里点赞数本来就是 0。但如果 Redis 和数据库因为某种原因出现不一致以 Redis 为准还是以数据库为准需要在设计时想清楚。我的习惯是点赞标记以 Redis 为准计数最终以数据库为准通过定时任务做校正。3.4 点赞排行榜Top5 用户头像怎么取浏览页面通常会展示点赞用户头像列表比如“这些人赞过”。从 Redis Set 中随机取 5 个用户即可SetString top5 stringRedisTemplate.opsForSet().distinctRandomMembers(key, 5); ListLong userIds top5.stream().map(Long::valueOf).collect(Collectors.toList()); ListUser users userService.listByIds(userIds); ListString icons users.stream().map(User::getIcon).collect(Collectors.toList());如果你想让 Top5 按点赞时间排序Set 就做不到了。此时应该换成 ZSetscore 为时间戳用 zrevrange 取前 5 个。虽然项目演示版用 Set 就够了但如果你预期未来会演化成“点赞榜单”功能建议直接上 ZSet 版本后续改动成本最低。3.5 点赞模块的数据一致性问题先更新 Redis 还是先更新数据库我的建议是点赞接口里 Redis Set 和数据库 liked 字段都要更新顺序无所谓最终以 Redis 为准。原因很简单liked 只是一个展示用计数允许短暂不一致。实际操作中我会让 Redis 更新保障实时性数据库的累计值通过定时任务在低峰期校准。具体校准逻辑对比 Redis Set 的 size 和数据库的 liked 值如果 size 与 liked 不一致以 Set 的 size 覆盖数据库的 liked 值。这样既保证展示实时性又逐步收敛数据一致性。这里要特别提醒不要试图在点赞请求中用事务锁同步写 Redis 和 MySQL。Redis 没有回滚能力MySQL 事务回滚后 Redis 已经写进去了两边反而更难对齐。让 Redis 先行、DB 异步校正才是低成本方案。4. 关注、取关与共同关注4.1 关注关系表设计一张表搞定双向关系关注关系在大多数系统里都简单但特别容易搞反。表设计如下字段含义id主键user_id主动关注人即粉丝follow_user_id被关注的人即博主is_follow逻辑删除标记1 关注0 取关create_time创建时间is_follow 字段做逻辑删除是常用的实践。关注是高频操作如果全部物理删除后续做用户关系分析就没有历史数据了。逻辑删除可以让“取关再关注”复用同一行也方便恢复。如果担心查询性能可以给 user_id follow_user_id 加联合唯一索引并保持物理删除方案。两种都能跑通关键是选一种并且保持一致。4.2 关注与取关接口先判后写注意幂等关注接口代码如下PutMapping(/follow/{id}/{isFollow}) public Result follow(PathVariable(id) Long followUserId, PathVariable(isFollow) Boolean isFollow) { Long userId UserHolder.getUser().getId(); if (isFollow) { Follow follow new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); followService.save(follow); } else { followService.lambdaUpdate() .eq(Follow::getUserId, userId) .eq(Follow::getFollowUserId, followUserId) .remove(); } return Result.ok(); }注意接口幂等性重复点关注数据库唯一索引会兜底报错重复点取关删除 0 行也会正常返回不会删除别人的关系。如果你不建联合唯一索引在 save 之前一定要先查一次是否存在否则并发双击关注按钮一条同样的关系会插两遍脏数据悄悄就出现了。关注和取关操作本身不涉及复杂计算性能上没有太多优化点但它是后面 Feed 流推送的前提所以这一环要确保数据准确。4.3 是否关注和共同关注缓存 Set 求交集判断当前用户是否关注某人直接查 Follow 表Follow follow followService.lambdaQuery() .eq(Follow::getUserId, currentUser) .eq(Follow::getFollowUserId, targetUser) .one(); return Result.ok(follow ! null);共同关注则可以使用 Redis Set 求交集。先把两个人的关注列表分别写入两个 Set然后取交集即可。示例代码SetString mySet stringRedisTemplate.opsForSet() .members(REDIS_FOLLOW_KEY currentUser); SetString otherSet stringRedisTemplate.opsForSet() .members(REDIS_FOLLOW_KEY targetUser); SetString intersection mySet.stream() .filter(otherSet::contains) .collect(Collectors.toSet());这里的前提是关注列表已经缓存了。我是这样设计缓存时机的当用户第一次打开“共同关注”入口时先把该用户的关注列表写进 Redis Set后续从缓存直接读取。这样查询速度很快也不需要为了一个低频功能维护复杂的实时缓存一致性。4.4 关注列表的分页与缓存时机在用户主页里展示关注列表和粉丝列表时很容易踩两个坑。第一个坑是分页混淆查“我关注的博主”时条件一定是 user_id 我取 follow_user_id然后根据 id 批量查用户信息查“我的粉丝”时则反过来条件 follow_user_id 我。方向写反列表内容就完全反了。第二个坑是缓存时机。我的建议是关注列表用读时缓存用户打开主页时读取一次写入 RedisTTL 设短一点比如 30 分钟。关注动作发生时不用主动更新缓存等缓存过期后自然重建。这个策略比写时缓存简单而且避免了缓存与数据库频繁同步的烦恼。5. 关注推送 Feed 流把新笔记送进每个粉丝的收件箱5.1 三种 Feed 流方案对比推、拉、推拉结合这一章最有分量的部分是 Feed 流实现也是面试高频追问点。先理解三种经典方案拉模式粉丝主动拉取指博主发布笔记只写入自己的发件箱粉丝浏览时去查“我关注的博主的动态”。优点是博主发布几乎无成本缺点是粉丝每次刷新要遍历几十个博主逐个查最近动态延迟高且查询量大。推模式平台主动推送指博主发布后立即把笔记 id 写到每个粉丝的收件箱粉丝读取时只读自己的收件箱。优点是读取极快、体验好缺点是博主粉丝数量大时写放大严重发布接口可能被拖垮。适合粉丝量不大、实时性要求高的场景。推拉结合混合模式对大 V 用拉普通用户用推或者把粉丝分为活跃和非活跃活跃粉丝推非活跃粉丝拉。优点是兼顾大 V 场景缺点是实现复杂度最高需要同时维护两套逻辑。这个项目属于典型的教学型业务粉丝数不会到百万级需求又要求粉丝立刻看到新笔记所以选择推模式最合理。学习时把这个方案吃透将来遇到大 V 场景自然知道折中在哪里。5.2 搭建发件箱每个粉丝维护一条 Stream发件箱的实现方案是每个粉丝一个 Streamkey 是feed:{粉丝id}。博主发布笔记时遍历粉丝集合往每个 Stream 里追加一条消息消息内容是笔记 id。MapString, Object message new HashMap(); message.put(blogId, blog.getId().toString()); stringRedisTemplate.opsForStream().add( RedisConstants.FEED_KEY fanId, message );这里要刻意把 Stream 当成“每个粉丝的专属队列”不要设计成全局队列。全局队列会让分页复杂度暴增也无法为不同用户定制不同内容每个粉丝一个 key天然隔离滚动分页也简单。粉丝数量大时发布接口里的 for 循环写 Redis 会是性能瓶颈。这时候可以把“遍历粉丝并推送”交给消息队列或线程池异步处理发布接口只负责保存笔记然后发送一个“笔记已发布”的事件由任务队列去写 Stream。项目里为演示简单是同步实现这个优化点可以作为扩展练习。5.3 为什么 Feed 流不能用 limit offset 分页Feed 流最常见的翻页需求是用户读到第二页时博主又发布了新笔记。如果用 MySQL 的 limit offset新数据的插入会让 offset 整体向后移动下一页就会出现重复或遗漏。滚动分页是解决这个问题的标准方案每次携带上次读到的最后一条记录 ID下次只取 ID 大于或小于游标的数据新插入的数据始终排在队列末尾不会影响当前翻页区间。ZSet 的滚动分页可以这样实现score 为时间戳命令类似 zrevrangebyscore key max min limit offset count把上次最后一条 score 作为新的 max。Stream 的滚动分页则直接利用消息 ID 作为游标下面给出可运行代码。5.4 基于 Stream 实现滚动分页完整步骤粉丝端拉取 Feed 流的代码public Result queryFeed(Long lastId, Integer offset) { Long userId UserHolder.getUser().getId(); String key RedisConstants.FEED_KEY userId; // 第一次访问 lastId0后续传上次最后一条消息的 ID ListMapRecordString, Object, Object records stringRedisTemplate.opsForStream().read( Consumer.from(g1, c1), StreamReadOptions.empty().count(5), StreamOffset.create(key, ReadOffset.from(lastId.toString())) ); if (records null || records.isEmpty()) { return Result.ok(Collections.emptyList()); } // 解析消息列表里的 blogId ListLong blogIds records.stream() .map(record - Long.valueOf(record.getValue().get(blogId).toString())) .collect(Collectors.toList()); // 查出笔记列表并做用户信息补全 ListBlog blogs blogService.listByIds(blogIds); // 下一次请求要传的 lastId取最后一条消息 ID String nextId records.get(records.size() - 1).getId().getValue(); MapString, Object result new HashMap(); result.put(blogs, blogs); result.put(lastId, nextId); return Result.ok(result); }注意 Stream 的消息 ID 不是整数而是“毫秒时间戳-序号”的字符串比如1719812778001-0。比较 ID 时不能直接用字符串比大小要按-分隔先比时间戳部分再比序号。框架的 ReadOffset 会处理这种比较但如果你自己解析消息 ID务必分两段比较否则会出隐藏 bug。另一个关键点ReadOffset.from(lastId) 表示从 lastId 之后开始读。所以第一次传 0 没问题再次请求时必须传上一条消息的完整 ID才能保证不重不漏。代码里还写了Consumer.from(g1, c1)这是消费组用法。每个粉丝只有一条 Stream粉丝的多台设备并发消费时消费组会把消息分配给组内不同消费者避免同一条消息被两个设备重复读取。如果你只想做简单的个人拉取也可以直接用 XREAD 而不是 XREADGROUP但消费组版本更贴近真实业务。5.5 Feed 流的边界情况处理项目里有个常见场景用户刚注册没有关注任何人Stream 的 key 都不存在。此时接口直接返回空集合即可不要尝试创建空 Stream。否则会在 Redis 里塞进大量无意义的 key后续 bigkey 扫描和内存回收都会受影响。另一个边界是笔记删除后粉丝 Stream 里还残留 blogId。如果查询时 listByIds 返回结果少了这一条前端会出现“训练列表少了一条”的怪现象。最简单的处理是读时过滤所有查询出来的笔记 id 集合数据库里查不到的直接跳过。这个方案比写时清理可靠得多因为写时清理需要遍历每个粉丝的 Stream代价太大。6. 高频问题排查与优化延伸6.1 常见问题速查表把这一章最容易踩的坑整理成一张表方便自测和排查现象根因解决方案列表接口 User 字段都是 nullfor 循环单查用户或忘记补全先 collect userIds再 listByIds 批量查询images 字段前端显示成字符串数据库存 JSON 字符串实体未转换输出前用 JsonUtils.parseList 转换点赞数越点越多取消分支没有判重对已取消的记录再次 -1取消前用 sismember 判断只有存在才 -1取消点赞后 liked 变负数对不存在的点赞记录执行了减一在取消分支前判断是否点过赞Feed 流第一页出现重复数据使用 limit offset 分页改成基于 Stream 最后一条 ID 的滚动分页Stream 消费后消息丢失没有使用消费组或没有处理 ACK明确消费确认机制或使用消费组版本缓存空 key 越来越多查询不存在 id 时未设置 TTL缓存空字符串并设置短 TTL6.2 缓存穿透、击穿、雪崩在本章的实际对应缓存穿透在详情页最典型恶意请求反复查询不存在的笔记 ID每次都穿透到数据库。处理方式上面已经写了缓存空值TTL 设短一点比如 2 分钟。缓存击穿发生在热门笔记缓存过期的一瞬间大量请求同时打到数据库。这个模块里最简单有效的处理是逻辑过期不直接给缓存设置过期时间而是在 Bean 里存一个“过期时间戳”发现逻辑过期后用互斥锁去更新缓存其他请求先返回旧数据。这个方案不依赖 Redis 的过期机制非常适合热点笔记。缓存雪崩在这个模块不太容易出现因为单条笔记的缓存过期时间彼此独立不会在同一时刻成批失效。但如果真的做全量缓存预热且 TTL 相同就可能出现雪崩所以过期时间都建议加一点随机值。6.3 让项目跑得更稳的四个优化点第一个优化点把发布笔记里的粉丝遍历推送改成异步。用 Spring 的 Async 或者简单线程池都可以用户发布笔记后立刻返回后台异步推进粉丝的 Stream这样发布接口不会因为粉丝数多而变慢。第二个优化点控制 Feed 流 Stream 的体积。一个粉丝长期不登录Stream 会不断累积成为大 key。比较实用的策略是推送前检查 Stream 长度超过阈值就只保留最近 N 条或者干脆在用户登录时才重建 Feed 流。第三个优化点关注列表缓存 TTL 不要设成永久。TTL 设 30 分钟即可将缓存重建时机控制在可控范围内既能提升性能又不至于让关系变化迟迟不生效。第四个优化点点赞排行榜如果后续要扩展把 Set 替换成 ZSetscore 用时间戳。这样能支持“按点赞时间排序的 Top N”接口改动量不大但扩展空间宽很多。6.4 学完这一章该怎么自测自测顺序按业务链路走最有效。先开两个账号A 发布一篇笔记B 关注 AB 的 Feed 流能看到 A 的新笔记B 给笔记点赞再取关Feed 流不再推送。这条链路跑通基本覆盖了本模块的所有核心逻辑。面试时如果被问到“内容社区 Feed 流怎么做”把推模式、拉模式、推拉结合的对比和 Stream 滚动分页讲清楚再结合点赞、关注、缓存一致性说方案基本就比单纯背概念的人强一大截。最后再分享一点个人体会。很多人第一次写“达人探店”模块觉得点赞、关注都是小功能真正难的是 Feed 流。但完整做一遍之后会发现真正决定代码质量的其实是那些容易被跳过的细节发布接口里的 userId 覆盖、分页字段的类型转换、缓存空值的 TTL、粉丝查询的索引方向。这些小点不会让演示 Demo 翻车但会在高并发场景和面试追问时暴露问题。如果你正在学习类似的项目建议严格按照“发布、关注、推送、点赞、滚动分页”的顺序亲手过一遍再回头对照本文整理一份自己的踩坑清单。折腾完这一轮以后面对内容型产品时心里会踏实很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑