当前位置: 首页 > news >正文

【知识获取与分享社区项目 | 项目日记第 15 天】Single-Flight 防回源风暴与 Feed 缓存一致性策略

前言

前两篇已经整理了 Feed 的三级缓存和 hotkey 探测。

这一篇继续看两个非常关键的问题:

1. 缓存击穿时,如何避免同一页大量请求同时回源数据库? 2. 点赞、收藏、发布、编辑后,Feed 缓存一致性如何处理?

项目里主要用了两类策略:

single-flight:同一页并发请求只允许一个线程回源 短 TTL + 随机抖动:降低强一致维护成本 用户态实时覆盖:liked/faved 不进入公共缓存 计数事件监听:点赞收藏后尝试旁路更新缓存计数 反向索引:记录内容出现在哪些 Feed 页面中

这一篇重点整理这些策略。


一、为什么需要 single-flight?

假设首页第一页缓存过期。

这时同时来了 1000 个请求:

1000 个请求发现 Caffeine 未命中 1000 个请求发现 Redis 也未命中 1000 个请求同时查数据库

这就是典型的缓存击穿。

项目里使用ConcurrentHashMap<String, Object>为同一页创建锁对象:

privatefinalConcurrentHashMap<String,Object>singleFlight=newConcurrentHashMap<>();

二、single-flight 实现

Objectlock=singleFlight.computeIfAbsent(idsKey,k->newObject());synchronized(lock){FeedPageResponseagain=assembleFromCache(idsKey,hasMoreKey,safePage,safeSize,currentUserIdNullable);if(again!=null){feedPublicCache.put(localPageKey,again);singleFlight.remove(idsKey);returnagain;}intoffset=(safePage-1)*safeSize;List<KnowPostFeedRow>rows=mapper.listFeedPublic(safeSize+1,offset);// 回源、写缓存、返回singleFlight.remove(idsKey);}

这里有一个很重要的“双重检查”:

FeedPageResponseagain=assembleFromCache(...);

因为当前线程在等待锁的时候,前一个线程可能已经回源并写入 Redis。

所以拿到锁之后必须再查一次缓存,如果已经有了,就不需要再查数据库。


三、回源后写缓存

数据库回源后会写入 Redis 片段缓存和 Caffeine。

intbaseTtl=60;intjitter=ThreadLocalRandom.current().nextInt(30);DurationfrTtl=Duration.ofSeconds(baseTtl+jitter);writeCaches(localPageKey,idsKey,hasMoreKey,safeSize,rows,items,hasMore,frTtl);feedPublicCache.put(localPageKey,respForCache);

这里的写入内容包括:

feed:public:ids:{size}:{hourSlot}:{page} feed:item:{id} feed:public:ids:{size}:{hourSlot}:{page}:hasMore feedPublicCache 本地缓存

这样后续等待锁的请求就能直接命中缓存。


四、single-flight 的作用边界

single-flight 不是全局锁。

它的锁粒度是:

idsKey

也就是同一页同一时间段。

不同页之间不会互相阻塞:

feed:public:ids:20:493201:1 feed:public:ids:20:493201:2

它们对应不同锁对象。

这样既能防止同一页回源风暴,又不会把所有 Feed 请求串行化。


五、用户态不进公共缓存

缓存一致性里最容易出错的是用户态字段:

liked faved

项目中的处理方式是:公共缓存只保存基础内容,返回时实时覆盖。

privateList<FeedItemResponse>enrich(List<FeedItemResponse>base,Longuid){List<FeedItemResponse>out=newArrayList<>(base.size());for(FeedItemResponseit:base){booleanliked=uid!=null&&counterService.isLiked("knowpost",it.id(),uid);booleanfaved=uid!=null&&counterService.isFaved("knowpost",it.id(),uid);out.add(newFeedItemResponse(it.id(),it.title(),it.description(),it.coverImage(),it.tags(),it.authorAvatar(),it.authorNickname(),it.tagJson(),it.likeCount(),it.favoriteCount(),liked,faved,it.isTop()));}returnout;}

这一步保证了公共缓存可以被所有用户共享。


六、计数事件旁路更新缓存

点赞和收藏会产生CounterEvent

Feed 监听器会监听这个事件:

// src/main/java/com/tongji/knowpost/listener/FeedCacheInvalidationListener.java@EventListenerpublicvoidonCounterChanged(CounterEventevent){if(!"knowpost".equals(event.getEntityType())){return;}Stringmetric=event.getMetric();if("like".equals(metric)||"fav".equals(metric)){Stringeid=event.getEntityId();intdelta=event.getDelta();// 后续更新 Feed 缓存计数}}

这样点赞/收藏发生后,可以尝试调整 Feed 缓存中的计数,减少用户看到旧计数的时间。


七、反向索引定位受影响页面

Feed 写缓存时,会为每个内容建立页面反向索引:

longhourSlot=System.currentTimeMillis()/3600000L;StringidxKey="feed:public:index:"+it.id()+":"+hourSlot;redis.opsForSet().add(idxKey,pageKey);redis.expire(idxKey,frTtl);

Key 示例:

feed:public:index:10001:493201

它记录:

内容 10001 出现在哪些 Feed 页面中

点赞事件发生后,监听器就可以通过这个索引找到受影响页面。


八、更新页面计数

privateFeedPageResponseadjustPageCounts(FeedPageResponsepage,Stringeid,Stringmetric,intdelta,booleanpreserveUserFlags){List<FeedItemResponse>items=newArrayList<>(page.items().size());for(FeedItemResponseit:page.items()){if(eid.equals(it.id())){Longlike=it.likeCount();Longfav=it.favoriteCount();if("like".equals(metric)){like=Math.max(0L,(like==null?0L:like)+delta);}if("fav".equals(metric)){fav=Math.max(0L,(fav==null?0L:fav)+delta);}Booleanliked=preserveUserFlags?it.liked():null;Booleanfaved=preserveUserFlags?it.faved():null;it=newFeedItemResponse(it.id(),it.title(),it.description(),it.coverImage(),it.tags(),it.authorAvatar(),it.authorNickname(),it.tagJson(),like,fav,liked,faved,it.isTop());}items.add(it);}returnnewFeedPageResponse(items,page.page(),page.size(),page.hasMore());}

这里有一个很细的设计:

preserveUserFlags
  • 更新本地缓存时可以保留用户态
  • 写回共享缓存时不要保留用户态

这样仍然避免了公共缓存污染。


九、写回缓存时保留 TTL

privatevoidwritePageJsonKeepingTtl(Stringkey,FeedPageResponsepage){try{Stringjson=objectMapper.writeValueAsString(page);longttl=redis.getExpire(key);if(ttl>0){redis.opsForValue().set(key,json,Duration.ofSeconds(ttl));}else{redis.opsForValue().set(key,json);}}catch(Exceptionignored){}}

更新缓存时保留原 TTL,是为了避免一次计数更新把缓存生命周期重新拉长,破坏原有过期策略。

虽然当前公共 Feed 更偏片段化缓存,但这个监听器的思路仍然很有价值:通过反向索引定位页面,再做局部修正。


十、发布、编辑、删除后的缓存处理

KnowPostServiceImpl中,内容确认、元数据更新、删除等操作会调用:

privatevoidinvalidateCache(longid){StringpageKey="knowpost:detail:"+id+":v"+DETAIL_LAYOUT_VER;redis.delete(pageKey);knowPostDetailCache.invalidate(pageKey);}

对于 Feed 列表而言,项目主要依靠:

短 TTL 随机抖动 片段缓存 反向索引 计数事件旁路更新

来控制一致性窗口。

也就是说,它不追求所有 Feed 缓存强一致,而是让缓存成为可丢弃、可过期、可修正的读模型。


十一、为什么不追求 Feed 强一致?

Feed 是高频读接口。

如果每次内容发布、编辑、点赞都要同步清理所有可能出现过的页面缓存,会带来很高的复杂度和维护成本。

项目选择的是:

业务事实强一致 Feed 缓存最终一致

业务事实包括:

know_posts 表 点赞位图 SDS 计数

Feed 缓存只是读优化。

短暂不一致可以通过 TTL、事件更新和实时覆盖逐步修正。


十二、知识点总结

1. single-flight 解决什么问题?

解决同一个缓存 Key 失效时,大量并发请求同时回源数据库的问题。

2. 为什么拿到锁后还要再查一次缓存?

因为等待锁期间,其他线程可能已经完成回源并写入缓存。

这一步可以避免重复查数据库。

3. 反向索引有什么用?

它可以记录某条内容出现在哪些 Feed 页面里,后续内容变化或计数变化时,就能快速定位影响范围。

4. Feed 缓存为什么适合最终一致?

Feed 是读优化场景,短时间旧数据通常可以接受。

只要业务事实正确,并且缓存能过期、能修正,就能在性能和一致性之间取得平衡。


总结

这一篇主要整理了 Feed 流中的 single-flight 和缓存一致性策略。

single-flight 用同页锁避免并发回源风暴,双重检查避免重复查库;公共 Feed 缓存不保存用户态,而是在返回前实时叠加;点赞收藏事件通过监听器尝试旁路更新计数;反向索引用来定位内容出现过的页面。

整体来看,Feed 缓存不是强一致数据源,而是高性能读模型。它通过短 TTL、随机抖动、事件修正、用户态实时覆盖和 single-flight,尽量在性能、成本和一致性之间取得平衡。

http://www.jsqmd.com/news/908838/

相关文章:

  • 从消息传递到AMP:一个压缩感知工程师的实践笔记(含Python代码示例)
  • 2026年诸城市正规上门黄金白银回收品牌门店名录:K金+铂金+金条+银条回收门店联系方式推荐+指南 - 前途无量YY
  • 邯郸珍宝黄金回收|本地黄金回收哪家靠谱?正规流程 + 报价公式全透明,十年老店值得信赖 - 润富黄金珠宝行
  • 【C++】weak_ptr、循环引用与线程安全
  • 如何在3分钟内将Windows电脑变成免费WiFi热点:VirtualRouter终极指南
  • 免费去水印的软件免费下载|AI去水印工具实测推荐
  • RePKG终极指南:5步解锁Wallpaper Engine壁纸资源
  • NCMDump终极指南:3步解锁网易云音乐加密音频文件
  • 2026年诸暨市正规上门黄金白银回收品牌门店名录:K金+铂金+金条+银条回收门店联系方式推荐+指南 - 前途无量YY
  • 2026年最新阳春市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 2026楚雄市本地人必选的公共卫生检测专业机构TOP5推荐!美容院、足疗店、酒店宾馆卫生检测、许可证办理,正规CMA资质检测公司排名推荐 (2026年5月商铺卫生办证最新深度调研方案) - 一修哥咨询
  • RoboMimic Deploy使用笔记
  • 图像搜索引擎背后的秘密:用Python颜色直方图实现‘以图搜图’原型
  • 2026年驻马店市正规上门黄金白银回收品牌门店名录:K金+铂金+金条+银条回收门店联系方式推荐+指南 - 前途无量YY
  • c语言练习:关机程序
  • 山东EPDM塑胶厂家排行:彩色颗粒定制能力实测对比 - 奔跑123
  • 基于大语言模型与Vue ue 3的智能简历生成系统设计与实现
  • 2026年最新阳江市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 终极指南:如何免费在Windows上创建高性能虚拟显示器
  • 量子模拟中的Trotter步进与电路压缩技术
  • 免费开源AMD Ryzen调试工具:SMUDebugTool完全指南
  • 对计算机视觉的基本认知三(表征学习与变换)
  • 三步掌握抖音批量下载助手:告别手动收集的繁琐时代
  • 视频去水印的软件哪个好用又免费?2026实测推荐
  • DS4Windows电池管理终极指南:告别游戏中断的完整解决方案
  • 2026年最新阳泉市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 2026年庄河市正规上门黄金白银回收品牌门店名录:K金+铂金+金条+银条回收门店联系方式推荐+指南 - 前途无量YY
  • Linux调试说明——CAN设备收发测试
  • VL31N/VL32N之外:SAP内部交货单BAPI性能对比与选型建议(GN_DELIVERY_CREATE vs BAPI_DELIVERYPROCESSING_EXEC)
  • Flutter+HarmonyOS跨端实战—第02篇:路由与状态管理实战