【知识获取与分享社区项目 | 项目日记第 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,尽量在性能、成本和一致性之间取得平衡。
