尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Feed 缓存不要缓存用户态:用页骨架和条目片段拆开共享数据

发布时间:2026/9/26 4:17:55

资讯中心
01
ARTICLE

Feed 缓存不要缓存用户态:用页骨架和条目片段拆开共享数据

Feed 缓存不要缓存用户态:用页骨架和条目片段拆开共享数据
用户 A 点赞后立即刷新首页用户 B 同时打开同一页标题、封面和作者可以共用缓存但两个人看到的liked必须不同。公开 Feed 的缓存核心不是多堆一层数据而是把可共享的公共内容与按用户变化的状态拆开Caffeine 抗热点Redis 保存页骨架和条目片段liked/faved返回前按当前用户重新计算。本文只讨论公开 Feed 的读取、组装、击穿保护和一致性边界不讨论文章详情缓存也不展开点赞计数如何经 Kafka 写入汇总结构。1. 当前所谓“三级缓存”到底是哪三层KnowPostFeedServiceImpl.getPublicFeed()的实际读取顺序是JVM 内 Caffeine 整页基础结果 → Redis 页骨架 idsKey 条目片段 feed:item:{id} → 数据库回源如果按缓存数据结构来数三层分别是feedPublicCache当前服务进程里的 Caffeine 页面缓存feed:public:ids:{size}:{hourSlot}:{page}Redis 页骨架feed:item:{id}Redis 单条内容片段。数据库是缓存 miss 后的事实回源不应被描述成第三层缓存。完整流程可以压缩成公开 Feed 请求 → 查本地 Caffeine → 命中后按当前 uid 重新计算 liked/faved → 未命中则从 Redis 读取 idsKey → 按 ID 批量读取 feed:item:{id} → 从 CounterService 刷新 likeCount/favoriteCount → 返回前叠加 liked/faved → 任意片段缺失则整页 miss → 按 idsKey 做 single-flight → 锁内重查 Redis仍 miss 才查询数据库 → 写回 idsKey、hasMoreKey、feed:item 和 Caffeine这条主线由getPublicFeed()、assembleFromCache()、writeCaches()和enrich()共同完成。2. 页骨架只管顺序条目片段只管公共卡片字段页骨架feed:public:ids:{size}:{hourSlot}:{page}它只保存某个 Feed 分页中的文章 ID 顺序例如第 1 页 [101, 87, 56, 12]这里的“页”是首页信息流的分页不是文章详情的正文分页。条目片段feed:item:{文章ID}它保存 Feed 卡片需要的公共字段例如标题、描述、封面、标签、作者头像和作者昵称。它不是完整文章正文也不应成为当前用户点赞状态的事实来源。assembleFromCache()先按idsKey拿到顺序再通过multiGet批量获取条目片段。随后从CounterService刷新点赞数和收藏数并根据 uid 计算用户状态。如果任意feed:item缺失或 JSON 无法反序列化方法直接返回 null让整页进入 miss 路径。不能跳过缺失项继续返回页骨架[101, 87, 56] 缺少feed:item:87 错误残页[101, 56]跳过会让页面条数和 ID 顺序与骨架不一致。当前实现选择整页回源而不是只补缺失的一条。3. Redis 不存整页 JSON是为了避免重复和更新放大同一篇文章可能同时出现在不同页、不同size的分页里。如果 Redis 为每一页保存完整 JSON标题、封面、作者等公共字段会被复制很多份。文章标题变化后还要定位所有包含它的页面并逐页重写。内容重复越多写放大和一致性维护成本越高。writeCaches()已明确不再写公开 Feed 的 Redis 整页 JSON而是写idsKey这一页的文章 ID 顺序 hasMoreKey是否还有下一页 feed:item:{id}单条公共卡片片段同一个feed:item:101可以被多页复用内容更新时也只需要围绕文章101处理。本地 Caffeine 可以缓存整页基础结果因为它的目标是减少当前 JVM 对 Redis 的重复组装容量和 TTL 都受控。Redis 是跨请求、跨页面复用的共享层更需要避免整页重复存储。4. liked/faved 是用户态不能以共享页面缓存为准需要先区分四个字段likeCount这篇文章一共有多少赞 favoriteCount这篇文章一共有多少收藏 liked当前用户是否点赞 faved当前用户是否收藏前两个是公共计数后两个是用户私有状态。如果用户 A 的likedtrue被当作公共结果复用用户 B 会看到自己已经点赞。这不是普通的缓存陈旧而是用户状态串线。enrich()会根据当前 uid 调用counterService.isLiked() counterService.isFaved()并创建新的返回条目。用户刚点赞后立即刷新即使公共likeCount仍有短暂延迟他自己的liked也能以 Bitmap 行为事实为准。数据库回源路径会先构造基础页面并写入 Caffeine再对返回副本执行enrich()。需要注意Redis 命中路径当前把assembleFromCache(..., uid)返回的已叠加页面直接放入 Caffeine后续本地命中仍会重新执行enrich()所以响应不会依赖旧用户态但缓存对象本身并不始终是纯基础页面。更干净的实现应把“组装公共基础页面”和“按 uid 叠加用户态”彻底分成两步。5. single-flight 要锁真正的回源粒度本地 Caffeine miss 不代表必须查数据库。Redis 页骨架和条目片段可能仍然存在只需要重新组装并写回本地缓存。真正决定是否回源的是idsKey所以getPublicFeed()使用它作为 single-flight 的“航班号”同一 idsKey 并发 miss → 只有一个请求进入回源区 → 其他请求等待获得锁后必须再次调用assembleFromCache()。等待期间前一个请求可能已经查完数据库并写好 Redis如果拿锁后直接查询数据库只会把并发重复回源变成串行重复回源。当前 single-flight 使用ConcurrentHashMapString, Object synchronized(lock)它只覆盖单个 JVM。一台实例上 1000 个同页请求通常只有一次回源三台实例同时 miss最坏仍会各自回源一次。当前不能称为分布式锁。6. hourSlot、hasMore 和 TTL 各自解决不同问题6.1 hourSlot 给页骨架增加时间命名空间idsKey包含feed:public:ids:{size}:{hourSlot}:{page}hourSlot每小时变化。它只作用于页骨架不会让feed:item:{id}按小时复制一份条目片段仍按文章 ID 跨页复用。这个设计让旧小时的页面骨架可以按自己的 TTL 退出新小时使用新的排序快照。小时切换后新 key 仍然可能冷启动因此 single-flight 依旧必要不能把hourSlot说成彻底消除了击穿。6.2 hasMore 是允许短暂误差的软缓存hasMoreKeyfeed:public:ids:{size}:{hourSlot}:{page}:hasMore页骨架 TTL 约为 6089 秒hasMore只缓存约 1020 秒。新内容发布后“是否还有下一页”可能先于当前页 ID 列表需要刷新短 TTL 可以缩短旧hasMorefalse阻止用户继续翻页的窗口。hasMoreKey缺失时代码使用idList.size() size进行兜底。满页就猜还有下一页不满页就判断没有。最后一页刚好满页时会误判数据库回源时查询size 1条才是更准确的判断。6.3 随机 TTL 防集中失效热点续期只延长条目片段回源后Redis 片段 TTL 使用60 秒 029 秒随机抖动随机抖动避免大量 key 在同一秒失效后同时回源降低缓存雪崩风险。recordItemHotKey()按文章 ID 统计热度并动态延长feed:item:{id}的 TTL。热点条目可以被多页复用续期能减少标题、封面和作者信息的重复回源。不应因为某篇文章热门而长期续期整页idsKey。页骨架代表页面排序长期保留旧骨架会阻止新文章及时进入首页。7. 点赞后只更新受影响页面不重建整份 Feed点赞不会改变标题、作者、封面和页面 ID 顺序。用户自己的liked返回前查 Bitmap公共likeCount允许最终一致。FeedCacheInvalidationListener.onCounterChanged()通过反向索引定位包含文章的页面feed:public:index:{文章ID}:{hourSlot}监听器会查询当前小时和上一个小时的索引并使用adjustPageCounts()只修改目标文章的likeCount/favoriteCount。旧索引可以暂时保留如果页面里已没有目标文章遍历不会修改其他条目只会带来额外查询和写放大。但这里存在一个实现不一致writeCaches()已不再写公开 Feed 的 Redis 整页 JSON监听器仍调用redis.opsForValue().get(pageKey)尝试读取并更新它。这条 Redis 整页更新路径通常无法命中。当前更可靠的是本地 Caffeine 页面快照更新以及下次 Redis 片段组装时重新从CounterService获取计数。所以不能说点赞后 Feed 已完全即时一致。准确边界是当前用户的liked以 Bitmap 实时计算公共likeCount通过本地旁路更新和后续组装刷新允许短暂延迟。8. Redis 故障不会自动降级到数据库getPublicFeed()直接调用assembleFromCache()。Redis key 不存在时会返回 miss可以继续回源Redis 连接失败时会抛异常而当前调用处没有捕获这类故障。因此当前行为是本地 Caffeine miss → Redis 连接异常 → 请求失败Feed 服务当前没有“Redis 不可用就自动查询数据库”的降级通道。即使补降级也不能把所有缓存流量直接放进数据库否则 Redis 故障会演变成数据库雪崩。合理的演进需要同时包含Redis 快速失败或熔断、按idsKey的 single-flight、数据库并发隔离或限流以及 Redis 写回失败不影响已经查出的正常结果。短时间返回已过期但可接受的本地快照也可以作为兜底策略。9. 当前实现与演进方案主题当前实现可演进方向跨实例缓存击穿KnowPostFeedServiceImpl使用 JVM 内ConcurrentHashMap synchronized按idsKey合并回源多台实例仍可能各查一次数据库使用 Redis/Redisson 按idsKey加分布式锁持锁后重查 Redis缓存写完再解锁并配置自动过期或续期防止持锁实例宕机造成死锁Redis 故障降级本地 Caffeine miss 后直接访问 RedisRedis 连接异常会中断请求不会自动进入数据库回源在 Redis 读写边界区分“正常 miss”和“连接故障”使用熔断、数据库限流和 single-flight 进行受保护的回源缓存写回失败时仍返回数据库结果点赞后的 Redis 页面更新公开 Feed 的writeCaches()不再写 Redis 整页 JSON但FeedCacheInvalidationListener仍尝试按页面 key 读取并改写整页 JSON删除无效的 Redis 整页更新分支或把失效与更新逻辑改为面向idsKey、feed:item和计数服务保留反向索引用于本地页面通知时应明确其目标本地缓存中的用户态数据库回源路径缓存基础页面Redis 命中路径会把已经按当前 uid 叠加的页面放入 Caffeine后续命中依靠再次执行enrich()覆盖用户态让 Redis 组装方法只返回公共基础页面写入 Caffeine 后再对返回副本执行enrich()保证共享缓存对象从结构上不包含用户私有状态10. 可执行判断Feed 分页缓存只共享公共内容liked/faved必须在返回前按当前 uid 重新计算。Redis 页骨架只保存文章 ID 顺序条目片段保存公共卡片字段不要同时再维护一份公开 Feed 整页 JSON。single-flight 应锁真正触发回源的idsKey并在获得锁后重查缓存多实例部署时 JVM 内锁不够。任意条目片段缺失时应整页 miss不能跳过后返回条数和顺序错位的残页。Redis 故障降级必须同时保护数据库没有熔断、限流和请求合并的“直接回源”只是把缓存故障转移成数据库雪崩。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。