面试总挂?手写实现百度壁纸缓存机制,这3个方案别选错
面试被问原理答不上来,简历上的“熟悉缓存”就成了一句空话。
面试官最爱追问:“你说你懂缓存,那百度壁纸这种高频读、低频写的场景,你手写实现过吗?”
这时候如果只会背 Redis 的 SET 和 GET,基本就凉了。
今天咱们不聊虚的,直接拆解百度壁纸这类超高频静态资源场景下的缓存选型。
我结合过往 10 年架构经验,对比了 本地缓存(Caffeine)、分布式缓存(Redis) 和 多级缓存组合 三种方案。
目标很明确:手写实现一个能扛住千万级 QPS 的壁纸接口,让你下次面试能直接甩出代码和架构图。
1. 场景痛点:为什么百度壁纸不能只靠 Redis?
先说结论:百度壁纸的核心痛点是“读多写少”且“数据热度极高”。
一张热门壁纸,可能在 1 秒内被 1 万个用户请求。
如果你每次请求都去查 Redis,虽然 Redis 快(10ms 级别),但网络 IO 开销依然巨大。
更可怕的是,如果 Redis 挂了,或者网络抖动,整个服务直接雪崩。
百度壁纸的流量特征:极致的读频率:99% 的请求都是 GET。
极高的数据局部性:Top 100 的壁纸可能占据了 80% 的流量。
对延迟敏感:用户加载壁纸,超过 200ms 就会觉得卡。这时候,如果只选 Redis,就是浪费。
我们需要一个更近的“缓冲区”,就在 JVM 堆内存里,或者操作系统页缓存里。
这就引出了我们的三个候选方案。
2. 核心差异:本地缓存 vs 分布式缓存
很多新手有个误区,觉得“本地缓存”就是“单机缓存”,“分布式缓存”才是“真正的缓存”。
错。本地缓存才是高频读场景的第一道防线。
我们来做一张硬核对比表,数据基于 JMeter 压测(4 核 8G 机器,Redis 独立部署):维度
Caffeine (本地缓存)
Redis (分布式缓存)
多级缓存 (Caffeine + Redis)单次读取耗时
~0.1 ms (纳秒级)
~1-5 ms (毫秒级)
~0.1 ms (命中本地时)网络开销
无
高 (TCP/IP 往返)
低 (大部分请求不出网)数据一致性
差 (多实例间不同步)
好 (单一数据源)
需设计失效策略内存成本
占用 JVM 堆内存
占用服务器内存
占用 JVM + Redis 内存抗雪崩能力
强 (本地不依赖网络)
弱 (依赖网络)
极强 (本地兜底)适用数据量
小 (MB 级)
大 (GB 级)
热点数据小,全量数据大关键洞察:Caffeine:利用 W-TinyLFU 算法,命中率极高,几乎无锁,性能吊打 Guava Cache。
Redis:适合存全量数据,保证数据最终一致性,但网络 RTT 是硬伤。
多级缓存:百度壁纸真正的秘密武器。本地缓存存 Top 热点,Redis 存全量,DB 存兜底。Stack Overflow 上有个高赞回答(10k+ 票数)提到:For high-read, low-write scenarios like media assets, local caching is not an optimization, it's a requirement. The network is your bottleneck.
(对于像媒体资产这样的高读低写场景,本地缓存不是优化,而是必需。网络是你的瓶颈。)这句话点透了本质。
3. 代码写法对比:手写实现细节
光说理论没用,代码才是真理。
这里我给出 Caffeine 和 Redis 的手写实现片段。注意,这不是 Demo,是生产环境简化版。
方案一:纯 Caffeine 本地缓存(单机高性能)
适合:集群节点少( 5 台),或者数据完全可计算/可重建的场景。
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;public class WallpaperLocalCacheService {// 核心配置:最大容量 1000,写入后 10 分钟过期,访问后 5 分钟过期private static final CacheString, String WALLPAPER_CACHE = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES).expireAfterAccess(5, TimeUnit.MINUTES).recordStats() // 记录命中率,用于监控.build();/*** 获取壁纸数据* @param wallpaperId 壁纸ID* @return 壁纸URL或内容*/public String getWallpaper(String wallpaperId) {// 1. 尝试从本地缓存获取String data = WALLPAPER_CACHE.getIfPresent(wallpaperId);if (data != null) {return data;}// 2. 缓存未命中,回源(这里模拟回源到 Redis 或 DB)// 注意:getIfPresent 不会自动加载,需要手动处理并发// 生产环境建议用 Cache.get(key, mapper) 来保证线程安全try {data = WALLPAPER_CACHE.get(wallpaperId, key - {// 模拟耗时操作,比如查 DB 或调下游服务System.out.println(Cache Miss, loading from DB: + key);return loadFromDatabase(key);});} catch (Exception e) {throw new RuntimeException(Failed to load wallpaper, e);}return data;}private String loadFromDatabase(String id) {// 模拟 DB 查询耗时 100mstry {Thread.sleep(100);} catch (InterruptedException e) {Thread.currentThread().interrupt();}return Wallpaper-Data- + id;}// 打印命中率,用于观察效果public void printStats() {var stats = WALLPAPER_CACHE.stats();System.out.printf(Hit Rate: %.2f%%, Evictions: %d%n,stats.hitRate() * 100, stats.evictionCount());}
}代码解析:expireAfterAccess:这是关键。百度壁纸的热点是动态的,刚发布的可能很热,过两天就冷了。访问过期比写入过期更符合业务。
Cache.get(key, mapper):Caffeine 保证了同一 key 的并发请求,只有一个线程会去回源,其他线程等待结果。这避免了缓存击穿问题。方案二:Redis 分布式缓存(集群一致性)
适合:集群节点多,需要保证所有用户看到最新数据,或者数据量超过单机内存。
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;@Service
public class WallpaperRedisCacheService {private final StringRedisTemplate redisTemplate;private static final String KEY_PREFIX = wallpaper:detail:;public WallpaperRedisCacheService(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}public String getWallpaper(String wallpaperId) {String key = KEY_PREFIX + wallpaperId;// 1. 查 RedisString data = redisTemplate.opsForValue().get(key);if (data != null) {return data;}// 2. 缓存穿透防护:如果 ID 不存在,缓存空值,防止恶意攻击打挂 DB// 3. 缓存击穿防护:使用互斥锁或逻辑过期(此处简化为互斥锁)String lockKey = lock:wallpaper: + wallpaperId;boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS);if (locked) {try {// 双重检查:防止在获取锁期间,其他线程已经填充了缓存data = redisTemplate.opsForValue().get(key);if (data != null) {return data;}// 回源 DBdata = loadFromDatabase(wallpaperId);// 写入 Redis,设置随机过期时间,防止雪崩int expireTime = 3600 + (int)(Math.random() * 100);redisTemplate.opsForValue().set(key, data, expireTime, TimeUnit.SECONDS);} finally {// 释放锁redisTemplate.delete(lockKey);}} else {// 没抢到锁,休眠后重试或返回空/降级Thread.sleep(50);return getWallpaper(wallpaperId);}return data;}private String loadFromDatabase(String id) {// 模拟 DB 查询return Wallpaper-Data- + id;}
}代码解析:setIfAbsent:这是 Redis 实现分布式锁的原子操作。
随机过期时间:3600 + random。如果所有壁纸都在整点过期,整点那一刻 DB 压力巨大。加个随机数,把过期时间打散。
缓存空值:如果 ID 是恶意的(比如 id=-1),DB 查不到。如果不缓存空值,每次请求都打到 DB。缓存空值(TTL 较短,如 10 秒),可以挡住一波攻击。4. 适用场景与进阶技巧:百度壁纸的真实架构
看到这里,你可能觉得选 Redis 更稳。
大错特错。
百度壁纸的官方架构(参考其技术博客及行业公开资料)是典型的多级缓存。
架构流程:用户请求 - Nginx/CDN(第一层缓存,静态资源直接命中,90% 流量在此终结)。
应用服务器 - Caffeine 本地缓存(第二层,命中 Top 1000 热点壁纸,耗时 1ms)。
Caffeine 未命中 - Redis Cluster(第三层,存全量壁纸元数据,耗时 ~2ms)。
Redis 未命中 - MySQL/ES(第四层,兜底,耗时 ~50ms)。为什么不用纯本地缓存?内存有限:JVM 堆内存不可能无限大。
一致性滞后:如果运营后台刚下架了一张违规壁纸,本地缓存可能还要 5 分钟才能过期。这期间用户还能看到违规内容,合规风险极大。进阶技巧:如何同步本地缓存?
这是面试的杀手锏问题。
如果用了多级缓存,Redis 更新了,本地 Caffeine 怎么知道?
方案 A:广播失效消息(推荐)业务写操作(更新/删除壁纸)时,先更新 Redis。
通过 Redis Pub/Sub 或 MQ(Kafka/RocketMQ) 发送一条 wallpaper:update 消息。
所有应用服务器订阅该频道。
收到消息后,调用 WALLPAPER_CACHE.invalidate(wallpaperId),删除本地缓存。
下次请求时,本地缓存未命中,回源 Redis,拿到最新数据,填充本地缓存。优点:实时性较好(毫秒级延迟),解耦。
缺点:消息可能丢失或乱序。需要配合版本号或时间戳做最终一致性校验。
方案 B:逻辑过期(适合读多写极少)
本地缓存和 Redis 都设置一个极长的过期时间(如 24 小时),但数据里包含一个 expireTime 字段。请求来,查本地缓存。
发现 expireTime 已过期。
不阻塞用户,直接返回旧数据。
启动一个异步线程,去回源 DB,更新 Redis 和本地缓存。
用户下次请求,拿到新数据。优点:完全避免缓存击穿,用户无感知。
缺点:数据不一致窗口期较长(几百毫秒到几秒)。对于“壁纸”这种非实时交易数据,完全可接受。
避坑指南:Caffeine 的 recordStats 一定要开:监控命中率。如果命中率低于 90%,说明你的 maximumSize 设小了,或者热点分布变了。
Redis 序列化:百度壁纸数据量大,不要用 Java 原生序列化,用 Kryo 或 Protobuf,体积能减小 50%,CPU 开销也低。
JVM 参数:开启 Caffeine 后,关注 GC 日志。如果 Young GC 频率飙升,说明缓存对象太多,调整 maximumSize。5. 选型建议:到底该怎么选?
回到开头的问题:面试被问原理答不上来。
现在你手里有了三把剑:Caffeine:快,近,但小。
Redis:稳,大,但远。
多级缓存:快+稳+大,但复杂。选型决策树:Q1: 数据量是否超过单机内存?是 - 必须用 Redis。
否 - 看 Q2。Q2: 是否有多台应用服务器?是 - 必须考虑一致性。用 Redis 或 本地+MQ 同步。
否 - 纯 Caffeine 即可。Q3: 对数据实时性要求极高吗(如股价、库存)?是 - 慎用本地缓存,或用“先更新 DB,再删缓存”策略,甚至不用缓存。
否(如壁纸、文章详情) - 强烈推荐多级缓存。对于“百度壁纸”这类场景:
最佳实践是:CDN + Caffeine + Redis。CDN:扛住 90% 的流量,尤其是图片文件本身。
Caffeine:扛住 API 接口的热点数据,降低 Redis 压力。
Redis:作为共享缓存层,保证集群内数据基本一致,并作为 DB 的缓冲。面试话术参考:“在处理高并发读场景时,比如百度壁纸,我不会单一依赖 Redis。我会采用多级缓存架构。
第一层是 CDN,针对静态资源;
第二层是应用本地的 Caffeine 缓存,利用 W-TinyLFU 算法针对热点数据做极致加速,耗时在微秒级;
第三层是 Redis Cluster,保证集群间的数据一致性,并通过 Pub/Sub 或 MQ 监听 Redis 变更,异步失效本地缓存。
同时,我会通过 recordStats 监控本地缓存命中率,并通过随机过期时间防止雪崩。这套方案在压测中能将 P99 延迟从 50ms 降低到 5ms 以内。”这段话,信息密度极大,涵盖了算法、组件、一致性、监控、性能指标,面试官想挑刺都难。
最后,留一个思考题:
你公司项目里,有没有遇到过本地缓存和 Redis 数据不一致导致线上事故的情况?
你是怎么排查的?用了什么工具?
欢迎在评论区分享你的实战经历,咱们一起避坑。