ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Feed流场景下二级缓存设计:Caffeine与Redis的实战指南

Feed流场景下二级缓存设计:Caffeine与Redis的实战指南 1. Feed流场景下为什么单层缓存扛不住1.1 Feed流读多写少的“骗局”你以为的读多写少其实是读热点写分散今天这篇是Feed流缓存设计系列的第5天聊的是二级缓存。先说个容易被新手忽略的事实Feed流确实是典型的读多写少场景但它的读多写少跟普通业务不太一样。普通业务比如商品详情读多写少是真的均匀分布——一万个用户读一万个商品每个商品都没啥压力。Feed流不一样它读的是“个人时间线”每个用户刷首页看的是一堆关注博主的最新内容这意味着数据天然呈金字塔形分布头部博主的Feed被海量用户反复拉取长尾博主的Feed几乎没人看。我经历过一个真实案例上线初期只用了Redis缓存当时以为“Redis抗住几十万QPS没问题”结果压测一上来就被打脸。为什么因为Feed拉取的请求不是简单地查一条数据而是先查关注列表、再查每个博主的Feed索引、再批量拉Feed详情一次刷首页背后要打3到5次Redis。再加上热点博主的内容全部集中在少数几个Key上Redis单分片的CPU直接拉满整个集群都跟着抖。还有个隐藏问题响应时间。用户刷Feed不是要几百毫秒返回而是希望像刷本地相册一样流畅——首屏要求在200ms以内。Redis虽然快但毕竟有一次网络开销极端情况下加上GC停顿和网络抖动200ms根本兜不住。你算一下就知道了内网Redis平均耗时大概是1ms到2ms一次首页请求打5次Redis就是10ms听起来还行但那是不排队、不抖动的理想情况。所以单层缓存的核心矛盾是热度不均匀让Redis节点吃力不均网络往返让尾延迟不可控。要解决这两个问题就必须在更靠近用户的地方再加一层缓存也就是二级缓存。1.2 二级缓存的三层职责划分本地只放热点Redis放全量二级缓存不是一个新鲜概念操作系统有L1/L2/L3缓存数据库有Buffer Pool它背后的思想是通用的越靠近CPU的缓存越快但容量越小、成本越高。放到Feed流系统里这个分层就变得更具体了。我做的二级缓存方案是这么拆的一级缓存本地缓存Caffeine部署在业务进程内扛绝大部分热点读请求。它最大的优势是零网络开销每次读取就是一次内存访问耗时通常在微秒级连1ms都不到。劣势是容量小一般按JVM堆的5%到10%配置并且多实例之间数据不共享。二级缓存Redis集群跨进程共享的集中式缓存部署在业务进程之外。本地缓存没命中时再去查Redis虽然有一次网络开销但它能扛住更大的数据量而且所有实例共享一份数据能保证跨节点的一致性基础。兜底数据库MySQL或TiDB等本地和Redis都没命中才打到DBDB是最终的数据底座要保证高可用和稳定。三层职责不是平均分配的我的经验是本地缓存负责“稳”Redis负责“全”数据库负责“底”。本地缓存不用存很多数据只需要存那些真正热门的Key比如头部博主的Feed索引、热搜话题下的时间线Redis存所有用户的Feed索引和Feed详情缓存用于本地缓存未命中时的兜底加速数据库保证最终一致性和数据可靠性。这样分下来你会发现一个有意思的现象一次Feed拉取请求90%以上的情况在本地缓存就结束了根本不会触达Redis更不会打到DB。整个系统的读压力和延迟都降下来了。2. 二级缓存的核心设计从选型到Key规划2.1 一级缓存选型Caffeine凭什么比Guava强本地缓存的选择很多人第一反应是用Guava Cache因为它出道早、用的人多。我早期项目也用过Guava后来换成了Caffeine因为Caffeine的淘汰算法是基于TinyLFUTiny Least Frequently Used简单说就是它不仅记录“最近有没被访问”还会统计“最近被访问了多少次”。这种算法的好处是一个被高频访问的Key不会因为短时间内没被访问就被淘汰掉能更准确地识别真正的热点而Guava的默认淘汰算法偏向LRU偶发的大批量扫描容易把热点Key挤出去。选Caffeine还有一个实际考虑它提供了异步加载、异步淘汰的回调接口方便我做缓存失效后的自动回填跟Spring Cloud的集成也更自然。Caffeine的配置我直接贴出来这是我压测后敲定的一组参数Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats() .build();简单解释一下maximumSize控制最大条目数防止本地缓存撑爆JVMexpireAfterWrite是写入后30秒过期这个时间窗口是经过权衡的——太短的话缓存命中率低太长的话数据一致性风险高recordStats开启统计方便后续接入监控看本地命中率。2.2 二级缓存选型Redis部署架构与连接池调优二级缓存基本没有悬念选Redis。但Redis的部署架构值得多花一笔单机、主从、哨兵、Cluster各有适用场景。如果Feed用户量在几十万以内数据量不太大单机加主从就够了主节点扛读写从节点做备份和读扩展。如果QPS已经上万或者单分片内存接近上限必须上Redis Cluster。Cluster把数据按slot均匀分布到多个节点好处是容量和性能都能横向扩展坏处是引入了跨节点访问的跳转开销以及批量操作MGET可能跨slot失败。我做的是Cluster方案连接池配置也要跟着调。Redis连接数不是越多越好太多反而增加上下文切换成本。我的线上配置是maxTotal200maxIdle50minIdle10连接池耗尽时阻塞等待200ms后直接降级返回空列表绝对不能无限等下去否则线上业务会被连接池拖死。数据体量上也要提前规划。Feed索引是按用户维度存的一个用户一条数据假设有1000万活跃用户每个用户的Feed索引列表按200个Feed ID算一条数据撑死在1KB到2KB总量也就2GB左右Redis完全没压力。真正占用空间大的是Feed详情缓存这个需要按全量Feed的规模估算我一般是控制缓存条目数结合过期时间控制内存水位。2.3 Key设计与数据模型Feed索引、Feed详情、用户时间线二级缓存设计里Key的规划直接决定了缓存能不能按预期命中。Feed流业务至少涉及三类数据互相不能搞混我的命名规范和数据结构如下Feed索引缓存这个缓存存的是“某个用户的个人时间线有哪些Feed ID”按用户维度拆分而不是按热点维度。Key格式定义为feed:timeline:{userId}。Value用Redis的SortSetSorted Set结构score用Feed的发布时间戳这样天然支持按时间倒序分页。String key feed:timeline: userId; redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);Feed详情缓存这是Feed内容本身的缓存包括作者信息、正文、图片信息、点赞评论数等。Key统一为feed:detail:{feedId}Value用JSON序列化。Feed详情是典型的“一写多读”数据同一个Feed可能被几百万粉丝的用户看到所以这个缓存特别值得做二级缓存。用户关注列表缓存拉取Feed之前要先拿到当前用户关注了哪些博主Key是feed:following:{userId}Value用Set结构存博主ID列表。这个数据的变更频率不高但每次拉Feed都会用到非常适合放在本地缓存。三类Key设计有一个共性原则Key要可预测、可批量查询、避免大Key。比如Feed详情尽量用MGET批量捞取不要循环单个GET否则一个页面20个Feed就要打20次网络请求。2.4 容量规划与淘汰策略的平衡容量规划是个经验活没有标准答案但有明确的权衡方向。我把规则整理成一张表供参考缓存层容量上限过期策略淘汰策略主要用途Caffeine本地缓存JVM堆的5%到10%30秒写入过期TinyLFU热点Feed索引、热点Feed详情Redis按数据估算1.5到2倍余量5到10分钟写入过期LRU默认allkeys-lru全量用户Feed索引、全量Feed详情本地缓存的过期时间一定要比Redis短这是一个容易忽略的细节。如果本地缓存的过期时间比Redis长Redis里的数据更新了本地还是旧数据脏数据会“活”得比源缓存更久。我一开始踩过这个坑本地缓存设了10分钟Redis设了5分钟结果改了达人信息后本地缓存的旧头像挂了快10分钟。淘汰策略上Caffeine我建议开启recordStats通过监控命中率动态调整容量。Redis侧明确禁止使用allkeys-random必须用LRU系列尽量保留热点数据。3. 缓存更新与数据一致性二级缓存最难啃的骨头3.1 Cache Aside模式主流但不完美的选择缓存更新的模式有Cache Aside、Read Through、Write Through、Write BackFeed流这种读多写少、一致性要求不算极端严格的业务Cache Aside是最合适的。Cache Aside的逻辑不复杂读数据时先读缓存缓存没命中就读DB并回填缓存写数据时先更新DB再删除缓存。这里最容易踩的坑是“先删缓存再更新DB”这种做法在并发读写下会产生脏数据线程A更新了DB还没提交线程B读到旧数据并回填缓存线程A提交后缓存里还是旧数据等过期才恢复。我用的顺序是先更新DB再删除缓存。这个顺序也不是绝对安全但脏数据窗口很短而且后续的读请求会把新数据回填进缓存配合过期时间兜底整体可控。3.2 本地缓存与Redis的一致性失效广播是关键先更新DB、再删Redis这两个动作之间还是有时间窗口。更麻烦的是我引入了本地缓存后问题升级了Redis缓存删掉了但每个业务实例的Caffeine里还残留着旧数据。如果不处理用户访问打到本地缓存相当于绕过了Redis这一层。解决这个问题的方案有两个思路第一个思路是缩短本地缓存过期时间用时间换一致性。把Caffeine的过期时间调低到15秒最坏情况下某个实例上的旧数据存活15秒对于Feed流这种“晚15秒看到新内容”的场景很多产品是可以接受的。第二个思路是主动失效广播。Redis删缓存时通过Redis的Pub/Sub或者MQ发一条消息所有业务实例监听这条消息把本地缓存里对应的Key删掉。这个方案能做到秒级一致代价是多一套消息链路增加了复杂度和故障点。我做的时候选了Pub/Sub因为实现简单误删一条缓存最多就是一次额外DB查询对系统的冲击可以忽略。3.3 延迟双删与最终一致性策略“先更新DB、再删除Redis”这个方案在极端并发下还是会有脏数据风险。比如线程A读到DB旧数据还没回填线程B更新DB为”新值“并删缓存此时线程A把旧数据回填到缓存。这时缓存里就是旧数据直到过期或下一次更新。业内常用的补救方案是延迟双删更新DB后删除一次缓存然后隔一个延时比如500ms再删一次。第二次删除的目的是清掉“在第一次删除后、数据更新完成前”被回填的旧数据。延时怎么定经验值是比“读DB回填缓存”的耗时略长一般是500ms到1s。这个时延不适合设太大否则缓存空窗期变长容易被穿透打到DB。策略操作步骤适用场景风险先删缓存再更新DB删缓存 - 更新DB少用缓存长期旧数据先更新DB再删缓存更新DB - 删缓存多数场景极端并发短时脏数据延迟双删更新DB - 删缓存 - 延时后再删一致性要求较高的核心链路空窗期变长做完延迟双删后一致性基本做到了“最终一致”配合缓存过期时间兜底线上几乎没有出现过持续性的脏读问题了。3.4 版本号兜底给缓存加上时间戳验签延迟双删的短板在于它没有真正验证数据的新旧只是“盲删”。如果你对一致性有更高的追求可以在Feed详情这个粒度引入版本号机制。具体做法是在Feed表主表上加一个lastUpdateTime字段缓存Value里除了业务数据额外带上这个时间戳。读缓存时浅层校验一下时间戳是不是最新如果不是就强制穿透到DB拉取最新数据并刷新缓存。用版本号的代价是每次读取都要多做一次时间戳比对其实就在内存里Redis侧存储开销增加一点点但换来的是清晰一致性的保障。Feed流这个场景时间戳验签适合用在明星达人的Feed上因为这类数据热度极高、一致性敏感——达人们改了简介粉丝必须马上看到普通用户的长尾Feed用延迟双删足够。4. 实操从零搭建Feed二级缓存的关键代码4.1 Caffeine与Redis的初始化配置这一节直接给可用的代码。先看Caffeine的完整配置我一般把构造和参数独立成一个配置类Configuration public class CacheConfig { Bean(feedLocalCache) public CacheString, ListFeedItem feedLocalCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(15)) .recordStats() .build(); } Bean(feedDetailLocalCache) public CacheLong, FeedDetail feedDetailLocalCache() { return Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofSeconds(15)) .recordStats() .build(); } }Redis侧Spring Boot的自动配置已经帮我们解决了大部分工作但有几个参数必须调spring: redis: cluster: nodes: 192.168.1.10:6379,192.168.1.11:6379,192.168.1.12:6379 lettuce: pool: max-active: 200 max-idle: 50 min-idle: 10 max-wait: 200ms shutdown-timeout: 100msRedis连接超时和命令执行的超时建议显式设置防止某个分片抖动时请求无限阻塞。我习惯用Lettuce连接池性能比Jedis稳定在线程模型上更适合异步场景。4.2 Feed拉取主流程本地缓存 - Redis - DB的降级链Feed流拉取是一个典型的“多级查询”过程我把它拆成几个步骤每一步都有对应的缓存操作。先看代码骨架public ListFeedItem getFeedList(Long userId, int pageSize, long cursor) { // 1. 先查本地缓存 String localKey feed:timeline: userId; ListFeedItem localResult localCache.get(localKey, key - loadFromRedis(key, userId, pageSize, cursor)); if (!localResult.isEmpty()) { return localResult; } // 2. 本地缓存没命中返回null或空走Redis ListLong feedIds loadFeedIdsFromRedis(userId, cursor, pageSize); if (feedIds null || feedIds.isEmpty()) { // 3. Redis也没有走DB兜底 feedIds loadFeedIdsFromDb(userId, cursor, pageSize); asyncFillFeedIdsToRedis(userId, feedIds); } // 4. 批量加载Feed详情 ListFeedItem items batchLoadFeedDetails(feedIds); return items; }这里有个关键细节本地缓存里存什么我建议只存Feed索引列表也就是用户时间线不要连Feed详情一起存因为用户的时间线返回的是一个id列表详情部分数据量大、重复度高不适合在本地按用户维度存。Feed详情的加载是另一条链路public ListFeedDetail batchLoadFeedDetails(ListLong feedIds) { return feedIds.stream() .map(feedId - detailLocalCache.get(feedId, id - loadFeedDetailFromRedis(id, 3))) .filter(Objects::nonNull) .collect(Collectors.toList()); } private FeedDetail loadFeedDetailFromRedis(Long feedId, int retryTimes) { String key feed:detail: feedId; String json redisTemplate.opsForValue().get(key); if (StringUtils.isEmpty(json)) { // Redis没有查DB并回填 FeedDetail detail loadFeedDetailFromDb(feedId); if (detail ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(detail), 5, TimeUnit.MINUTES); } return detail; } return JSON.parseObject(json, FeedDetail.class); }注意上面loadFeedDetailFromRedis里面的retryTimes参数主要用来扛瞬时的缓存击穿当某个feedId的缓存失效后大量请求同时穿透这里我用了带重试标记的回源逻辑实际生产里更推荐用单飞SingleFlight或者互斥锁后面讲击穿的时候再展开。4.3 缓存回填与异步化不能阻塞主链路Feed拉取主链路上所有回填动作都要异步化。比如从DB查到Feed索引后回填Redis、从DB查到Feed详情后回填Redis和本地缓存这些动作如果同步做用户请求会多几次DB调用和Redis写入的耗时。我的做法是封装一个异步执行器用独立的线程池处理Service public class CacheAsyncService { Async(cacheExecutor) public void fillFeedDetailCache(Long feedId, FeedDetail detail) { String key feed:detail: feedId; redisTemplate.opsForValue().set(key, JSON.toJSONString(detail), 5, TimeUnit.MINUTES); detailLocalCache.put(feedId, detail); } }线程池的配置我单独给了核心线程数和队列大小核心线程数不宜太大我用的是8队列容量设为4096满了就丢弃回填任务不要影响主请求线程。注意Async不是银弹如果系统本身没有Spring可以直接用CompletableFuture.runAsync()思路是一样的。4.4 缓存预热与降级上线前必须做的两件事二级缓存上线前有两个动作一定不能省。第一个是缓存预热。Feed流系统的热点是会漂移的但绝大多数场景下头部博主是相对稳定的。我写了一个预热Job每小时扫描一次头部用户的Feed索引批量把他们的Feed ID和Feed详情回填到Redis同时通过广播消息触发各实例刷新本地缓存。预热能显著降低刚上线或刚发布热点内容时的缓存穿透压力。第二个是降级开关。缓存系统最怕的不是缓存失效而是缓存组件挂掉。我在调用Redis的所有入口都加了降级逻辑如果Redis出现异常直接跳过Redis查DB并尝试从本地缓存读取。本地缓存读不到就查DB保证用户至少能拿到数据只是会慢一点。public ListLong loadFeedIdsFromRedis(Long userId, long cursor, int pageSize) { try { String key feed:timeline: userId; SetLong ids redisTemplate.opsForZSet().reverseRange(key, cursor, cursor pageSize - 1); return ids null ? Collections.emptyList() : new ArrayList(ids); } catch (Exception e) { log.warn(load feed ids from redis failed, userId{}, userId, e); return Collections.emptyList(); } }降级逻辑的核心是宁可多查一次DB也不让缓存故障拖垮整个Feed接口。5. 常见问题与排查技巧实录5.1 缓存穿透空结果也要缓存缓存穿透指的是查询一个不存在的Key请求绕过了缓存直接打到DB如果攻击者或爬虫用不存在的Feed ID刷接口DB就遭殃了。Feed场景下用户刷新一个已经删除的Feed返回结果本来就是空如果空结果不缓存这个请求会反复穿透。解决办法很朴素空值也缓存。查询结果为null时在Redis里存一个空占位符过期时间设置为30秒这样同样一个不存在的Key在短时间内的反复请求都命中了空缓存不会打到DB。还要加一层布隆过滤器做前缀拦截在进入缓存查询之前判断Key大概率是否存在。布隆过滤器的代价是存在误判把不存在的Key判断为存在但能拦截掉99%以上的无效请求。5.2 缓存击穿热点Feed一瞬间全部打到DB击穿和穿透的区别在于击穿针对的是“热点Key”这个Key本来有缓存但缓存刚过期瞬间涌入的大量请求全部穿透到DB。对于明星达人发布的Feed一个Key可能同时被几百万用户读取击穿会造成DB瞬时QPS暴涨。我的解决手段是互斥锁回源。在缓存失效后先尝试获取分布式锁Redis的SETNX只有拿到锁的线程才允许去DB查询并回填缓存其他线程等锁释放后再读缓存。这里有个细节等待锁的线程要带超时不能让请求无限等下去。用Redisson的tryLock可以很方便地实现RLock lock redissonClient.getLock(lock:feed:detail: feedId); boolean locked lock.tryLock(200, 500, TimeUnit.MILLISECONDS); if (locked) { try { FeedDetail detail loadFeedDetailFromDb(feedId); fillFeedDetailCache(feedId, detail); return detail; } finally { lock.unlock(); } } else { // 拿不到锁就重读缓存通常此时已经被回填 Thread.sleep(50); return loadFeedDetailFromRedis(feedId, 0); }本地Caffeine层也可以用类似思路Caffeine本身就支持buildAsync和CacheLoader配置了refreshAfterWrite之后能在后台自动刷新不过这个功能我用得不多因为分布式的锁方案更通用。5.3 缓存雪崩过期时间要加随机扰动雪崩是大量Key同时过期的现象。如果缓存的过期时间是固定值比如统一设5分钟那么5分钟的边界点会有大量Key同时失效DB会瞬间承受一波请求洪峰极可能把库打死。解决方式有两个思路。第一个是过期时间加随机偏移每个Key的过期时间在5分钟基础上随机加0到60秒让过期时间自然地分散开。第二个是热点数据永不过期后台用定时任务异步刷新热点Key的过期时间保证它一直在缓存里。我实际的做法是组合使用普通Feed加随机过期明星达人Feed走后台刷新任务。5.4 本地缓存与Redis的脏数据排查你看到的“老数据”可能根本不是DB的问题线上排查最多的问题就是“明明更新了用户看到的还是旧内容”。我踩过两次坑都跟二级缓存的交互有关。第一次是本地缓存没有主动失效Redis已经更新了但业务实例的Caffeine还在返回旧数据。后来加了Pub/Sub广播才真正解决。第二次是异步回填的线程把旧数据重新写回了Redis延迟双删的第二次删除还没执行异步回填已经把旧数据写进Redis了最终缓存里还是旧值。这个问题的根源是回填和删除的顺序不确定我的解决办法是给数据加时间戳回填时校验缓存里的时间戳是否比新数据旧如果是就放弃回填。用一句话总结就是缓存更新从来不是“删缓存”或者“写缓存”单点操作而是一套有时间序的协作流程。如果你也遇到脏数据问题排查顺序建议是先看Redis里的值和DB是否一致不一致就是Redis写入链路有问题。Redis和DB一致但用户页面不对那就是本地缓存的问题检查Caffeine是否被正确广播失效。本地缓存设置过期时间很短但仍然出现老数据检查是否有异步回填把旧数据重新塞进去了。5.5 一句话总结我的避坑经验二级缓存的本地层不要追求“全”要追求“精”只放热点。缓存Key必须按业务维度拆分不要搞大而全的JSON缓存。所有回填动作异步化但异步任务必须做幂等和时效控制。任何一处Redis访问都要做好降级预案缓存不能成为系统的单点。上线前后一定要压测用真实的“热点长尾”混合流量去验证命中率和尾延迟。提到压测我再多说一句压测时不要只测平均QPS要看P99延迟。本地缓存命中时P99延迟能稳定在1ms到2ms一旦本地缓存失效、大量请求穿透到RedisP99可能直接飙到15ms以上这时候就要考虑给本地缓存扩容或者调整过期策略。二级缓存设计本来就是一条不断权衡和调优的路没有完美的方案只有最适合你业务现状的方案。我在实际开发中最大的体会是二级缓存带来的不只是性能提升它让Feed接口在DB抖动、Redis故障时依然能靠本地缓存兜底这种“扛得住波动”的稳健性比单纯的性能指标更有价值。
返回列表