news 2026/9/26 4:17:54

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Feed 缓存不要缓存用户态:用页骨架和条目片段拆开共享数据

用户 A 点赞后立即刷新首页,用户 B 同时打开同一页:标题、封面和作者可以共用缓存,但两个人看到的liked必须不同。

公开 Feed 的缓存核心不是多堆一层数据,而是把可共享的公共内容与按用户变化的状态拆开:Caffeine 抗热点,Redis 保存页骨架和条目片段,liked/faved返回前按当前用户重新计算。

本文只讨论公开 Feed 的读取、组装、击穿保护和一致性边界;不讨论文章详情缓存,也不展开点赞计数如何经 Kafka 写入汇总结构。


1. 当前所谓“三级缓存”到底是哪三层

KnowPostFeedServiceImpl.getPublicFeed()的实际读取顺序是:

JVM 内 Caffeine 整页基础结果 → Redis 页骨架 idsKey + 条目片段 feed:item:{id} → 数据库回源

如果按缓存数据结构来数,三层分别是:

  1. feedPublicCache:当前服务进程里的 Caffeine 页面缓存;

  2. feed:public:ids:{size}:{hourSlot}:{page}:Redis 页骨架;

  3. 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 的liked=true被当作公共结果复用,用户 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 使用:

ConcurrentHashMap<String, 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 是允许短暂误差的软缓存

hasMoreKey:

feed:public:ids:{size}:{hourSlot}:{page}:hasMore

页骨架 TTL 约为 60~89 秒,hasMore只缓存约 10~20 秒。新内容发布后,“是否还有下一页”可能先于当前页 ID 列表需要刷新,短 TTL 可以缩短旧hasMore=false阻止用户继续翻页的窗口。

hasMoreKey缺失时,代码使用:

idList.size() == size

进行兜底。满页就猜还有下一页,不满页就判断没有。最后一页刚好满页时会误判;数据库回源时查询size + 1条,才是更准确的判断。

6.3 随机 TTL 防集中失效,热点续期只延长条目片段

回源后,Redis 片段 TTL 使用:

60 秒 + 0~29 秒随机抖动

随机抖动避免大量 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 后直接访问 Redis;Redis 连接异常会中断请求,不会自动进入数据库回源

在 Redis 读写边界区分“正常 miss”和“连接故障”;使用熔断、数据库限流和 single-flight 进行受保护的回源,缓存写回失败时仍返回数据库结果

点赞后的 Redis 页面更新

公开 Feed 的writeCaches()不再写 Redis 整页 JSON,但FeedCacheInvalidationListener仍尝试按页面 key 读取并改写整页 JSON

删除无效的 Redis 整页更新分支,或把失效与更新逻辑改为面向idsKey、feed:item和计数服务;保留反向索引用于本地页面通知时应明确其目标

本地缓存中的用户态

数据库回源路径缓存基础页面;Redis 命中路径会把已经按当前 uid 叠加的页面放入 Caffeine,后续命中依靠再次执行enrich()覆盖用户态

让 Redis 组装方法只返回公共基础页面,写入 Caffeine 后再对返回副本执行enrich(),保证共享缓存对象从结构上不包含用户私有状态


10. 可执行判断

  1. Feed 分页缓存只共享公共内容;liked/faved必须在返回前按当前 uid 重新计算。

  2. Redis 页骨架只保存文章 ID 顺序,条目片段保存公共卡片字段;不要同时再维护一份公开 Feed 整页 JSON。

  3. single-flight 应锁真正触发回源的idsKey,并在获得锁后重查缓存;多实例部署时 JVM 内锁不够。

  4. 任意条目片段缺失时应整页 miss,不能跳过后返回条数和顺序错位的残页。

  5. Redis 故障降级必须同时保护数据库;没有熔断、限流和请求合并的“直接回源”只是把缓存故障转移成数据库雪崩。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 4:17:44

企业 AI 自动化应用落地的接口、验收与回退:技术实践判断框架

企业 AI 自动化应用落地的判断框架 企业把 AI 自动化接入日常业务时&#xff0c;技术落地的稳定性远比一次性演示重要。读者通常关心的是&#xff1a;在不绑定具体服务商的前提下&#xff0c;如何用接口边界、测试样例、验收方法、维护责任这四类条件&#xff0c;判断一项 AI 自…

作者头像 李华
网站建设 2026/9/26 4:17:26

基于Python的OpenCV轮廓检测聚类

简介在计算机视觉领域, 工程师们经常会用到某些特定的“”功能”。因为这些功能的存在, 大家只需编写寥寥几行代码, 就能够检测出轮廓或者对应的对象。不过, 需要注意的是, 通过这种方法检测出来的轮廓, 往往呈现出一种分散的状态。举例来说, 一张内容丰富且包含较多细节的图片…

作者头像 李华
网站建设 2026/9/26 4:17:26

OpenAI工程师30天API调用耗资130万美元 测试AI辅助开发极限能力

现在, AI来帮忙写代码, 这已经成了科技这个行业里用来提高干活速度的最关键的办法了, 那些大公司都在不停地投钱、花精力去试试看这个本事到底有多大。到了2026年5月16日的那一天, 有个叫彼得施泰因贝格尔的人, 他既是这家公司的员工, 也是这个项目的创办人, 他向外头公开晒出了…

作者头像 李华
网站建设 2026/9/26 4:17:26

盐湖卤水提锂工艺参数优化与萃取效率建模——基于响应面方法的镁锂分离与吸附工艺优化研究

一、问题背景锂是新能源产业的核心战略金属&#xff0c;广泛应用于锂离子电池、核聚变燃料、航空航天合金等领域。我国是全球最大的锂电池生产国&#xff0c;但锂资源对外依存度较高。值得注意的是&#xff0c;我国青藏高原盐湖蕴藏着丰富的锂资源&#xff0c;锂储量约占全国总…

作者头像 李华