用户 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 的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. 当前实现与演进方案
主题 | 当前实现 | 可演进方向 |
|---|---|---|
跨实例缓存击穿 |
| 使用 Redis/Redisson 按 |
Redis 故障降级 | 本地 Caffeine miss 后直接访问 Redis;Redis 连接异常会中断请求,不会自动进入数据库回源 | 在 Redis 读写边界区分“正常 miss”和“连接故障”;使用熔断、数据库限流和 single-flight 进行受保护的回源,缓存写回失败时仍返回数据库结果 |
点赞后的 Redis 页面更新 | 公开 Feed 的 | 删除无效的 Redis 整页更新分支,或把失效与更新逻辑改为面向 |
本地缓存中的用户态 | 数据库回源路径缓存基础页面;Redis 命中路径会把已经按当前 uid 叠加的页面放入 Caffeine,后续命中依靠再次执行 | 让 Redis 组装方法只返回公共基础页面,写入 Caffeine 后再对返回副本执行 |
10. 可执行判断
Feed 分页缓存只共享公共内容;
liked/faved必须在返回前按当前 uid 重新计算。Redis 页骨架只保存文章 ID 顺序,条目片段保存公共卡片字段;不要同时再维护一份公开 Feed 整页 JSON。
single-flight 应锁真正触发回源的
idsKey,并在获得锁后重查缓存;多实例部署时 JVM 内锁不够。任意条目片段缺失时应整页 miss,不能跳过后返回条数和顺序错位的残页。
Redis 故障降级必须同时保护数据库;没有熔断、限流和请求合并的“直接回源”只是把缓存故障转移成数据库雪崩。