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

发布时间:2026/6/24 7:58:28

【知识获取与分享社区项目 | 项目日记第 15 天】Single-Flight 防回源风暴与 Feed 缓存一致性策略 前言前两篇已经整理了 Feed 的三级缓存和 hotkey 探测。这一篇继续看两个非常关键的问题1. 缓存击穿时如何避免同一页大量请求同时回源数据库 2. 点赞、收藏、发布、编辑后Feed 缓存一致性如何处理项目里主要用了两类策略single-flight同一页并发请求只允许一个线程回源 短 TTL 随机抖动降低强一致维护成本 用户态实时覆盖liked/faved 不进入公共缓存 计数事件监听点赞收藏后尝试旁路更新缓存计数 反向索引记录内容出现在哪些 Feed 页面中这一篇重点整理这些策略。一、为什么需要 single-flight假设首页第一页缓存过期。这时同时来了 1000 个请求1000 个请求发现 Caffeine 未命中 1000 个请求发现 Redis 也未命中 1000 个请求同时查数据库这就是典型的缓存击穿。项目里使用ConcurrentHashMapString, Object为同一页创建锁对象privatefinalConcurrentHashMapString,ObjectsingleFlightnewConcurrentHashMap();二、single-flight 实现ObjectlocksingleFlight.computeIfAbsent(idsKey,k-newObject());synchronized(lock){FeedPageResponseagainassembleFromCache(idsKey,hasMoreKey,safePage,safeSize,currentUserIdNullable);if(again!null){feedPublicCache.put(localPageKey,again);singleFlight.remove(idsKey);returnagain;}intoffset(safePage-1)*safeSize;ListKnowPostFeedRowrowsmapper.listFeedPublic(safeSize1,offset);// 回源、写缓存、返回singleFlight.remove(idsKey);}这里有一个很重要的“双重检查”FeedPageResponseagainassembleFromCache(...);因为当前线程在等待锁的时候前一个线程可能已经回源并写入 Redis。所以拿到锁之后必须再查一次缓存如果已经有了就不需要再查数据库。三、回源后写缓存数据库回源后会写入 Redis 片段缓存和 Caffeine。intbaseTtl60;intjitterThreadLocalRandom.current().nextInt(30);DurationfrTtlDuration.ofSeconds(baseTtljitter);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项目中的处理方式是公共缓存只保存基础内容返回时实时覆盖。privateListFeedItemResponseenrich(ListFeedItemResponsebase,Longuid){ListFeedItemResponseoutnewArrayList(base.size());for(FeedItemResponseit:base){booleanlikeduid!nullcounterService.isLiked(knowpost,it.id(),uid);booleanfaveduid!nullcounterService.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.javaEventListenerpublicvoidonCounterChanged(CounterEventevent){if(!knowpost.equals(event.getEntityType())){return;}Stringmetricevent.getMetric();if(like.equals(metric)||fav.equals(metric)){Stringeidevent.getEntityId();intdeltaevent.getDelta();// 后续更新 Feed 缓存计数}}这样点赞/收藏发生后可以尝试调整 Feed 缓存中的计数减少用户看到旧计数的时间。七、反向索引定位受影响页面Feed 写缓存时会为每个内容建立页面反向索引longhourSlotSystem.currentTimeMillis()/3600000L;StringidxKeyfeed: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){ListFeedItemResponseitemsnewArrayList(page.items().size());for(FeedItemResponseit:page.items()){if(eid.equals(it.id())){Longlikeit.likeCount();Longfavit.favoriteCount();if(like.equals(metric)){likeMath.max(0L,(likenull?0L:like)delta);}if(fav.equals(metric)){favMath.max(0L,(favnull?0L:fav)delta);}BooleanlikedpreserveUserFlags?it.liked():null;BooleanfavedpreserveUserFlags?it.faved():null;itnewFeedItemResponse(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更新本地缓存时可以保留用户态写回共享缓存时不要保留用户态这样仍然避免了公共缓存污染。九、写回缓存时保留 TTLprivatevoidwritePageJsonKeepingTtl(Stringkey,FeedPageResponsepage){try{StringjsonobjectMapper.writeValueAsString(page);longttlredis.getExpire(key);if(ttl0){redis.opsForValue().set(key,json,Duration.ofSeconds(ttl));}else{redis.opsForValue().set(key,json);}}catch(Exceptionignored){}}更新缓存时保留原 TTL是为了避免一次计数更新把缓存生命周期重新拉长破坏原有过期策略。虽然当前公共 Feed 更偏片段化缓存但这个监听器的思路仍然很有价值通过反向索引定位页面再做局部修正。十、发布、编辑、删除后的缓存处理在KnowPostServiceImpl中内容确认、元数据更新、删除等操作会调用privatevoidinvalidateCache(longid){StringpageKeyknowpost:detail:id:vDETAIL_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尽量在性能、成本和一致性之间取得平衡。

相关新闻