面试官问了这么一道场景题:
“平台用了Caffeine本地缓存、Redis分布式缓存、MySQL数据库,现在有十万个用户同时访问,相当于每个用户对应一个请求,这多级缓存该怎么设计?”
多级缓存就是“分层扛压”,把十万用户的请求,从快到慢、从近到远,一层层分流,别让所有请求都直接打MySQL。
设计方案
定好每一层的职责
| 缓存层级 | 职责 | 放什么数据 | 容量/速度 |
|---|---|---|---|
| Caffeine本地缓存 | 扛超级热点中的超级热点,大幅减少 Redis 压力 | 访问频率 Top10 的极致热门数据(比如首页轮播爆款、正在进行的秒杀活动) | 容量小(几百MB)、速度极快(纳秒级) |
| Redis分布式缓存 | 扛全局通用数据,挡MySQL | 全平台的业务数据(比如商品详情、用户信息、订单数据) | 容量大(几十GB)、速度快(毫秒级) |
| MySQL数据库 | 数据最终兜底,保证数据不丢 | 所有的原始业务数据 | 容量无限、速度慢(秒级) |
缓存预热
平台刚启动的时候,Caffeine和Redis里都是空的,如果这时候十万用户同时进来,所有请求都会直接打MySQL,直接把数据库打垮。
为了解决这个问题,考虑采用缓存预热:
平台启动前,先做两级缓存预热:
- 把访问频率最高的Top1000 个全量热门 Key(比如全平台热门商品、活动规则),提前从 MySQL 里查出来,全量加载到 Redis;
- 把其中访问频率最高的Top10 个超级热点 Key(比如首页轮播商品、正在进行的爆款活动),额外加载到每台服务器的 Caffeine 本地缓存,作为最外层的扛压层。
缓存雪崩、击穿、穿透
缓存雪崩:十万个Key同时过期
比如你给所有Key都设置了“1小时过期”,结果十万个Key在同一时间全部过期,所有请求直接打MySQL。
解决这个问题的话,就是别给所有Key都设一样的过期时间,可以设置“1小时 + 0~30分钟的随机值”,让Key的过期时间分散开,避免同时过期;
对于首页Top10商品这种超级热点数据,还可以设置成“永不过期”,只在数据更新的时候主动删除缓存。
缓存击穿:超级热门Key突然过期
比如“双11爆款商品详情”这个Key,每秒有十万个请求,结果它突然过期了,十万个请求同时打Redis,发现没有,又同时打MySQL,直接把两者都打垮。
这个问题也可以对超级热点Key不设过期时间,只在更新时主动删;
或者加Redis 分布式互斥锁:
如果 Key 真的过期了,只让第一个抢到锁的请求去查 MySQL,查出来后更新缓存并释放锁;
其他没抢到锁的请求,每隔 100ms 重试一次查缓存,直到缓存更新完成,彻底避免十万请求同时打 MySQL。
缓存穿透:查根本不存在的Key
比如有人恶意攻击,每秒发十万个请求查“id=999999999”的商品(这个商品根本不存在),缓存里没有,直接打MySQL,把数据库打垮。
这个问题优先采用布隆过滤器拦截:
- 平台启动时,提前把 MySQL 里所有商品的 id 全量加载到布隆过滤器里完成预热;
- 用户请求进来,先过布隆过滤器,如果布隆过滤器判定这个商品 id 不存在,直接返回【商品不存在】,完全不会打到缓存和 MySQL;
- 只有布隆过滤器判定 id 存在,才会继续走【Caffeine→Redis→MySQL】的查询流程。
兜底方案:
缓存空值。如果查 MySQL 发现这个 Key 真的不存在,就把【空值】缓存到 Redis 里(设置 5 分钟短过期时间),下次再查这个 Key,直接从 Redis 里拿空值返回,避免重复打 MySQL。
小贴士:
空值缓存必须设置短过期时间(3-5 分钟),同时限制单 IP 的请求频率,避免恶意攻击生成大量空值缓存撑爆 Redis 内存。
十万并发下的 Redis 压力分摊方案
十万个请求同时进来,如果都打到单实例 Redis 上,大概率会直接把 Redis 打宕机,必须做以下 3 件事分摊压力:
- 热点隔离,本地优先扛压:把访问频率 Top10 的极致超级热点 Key(比如首页轮播爆款、正在进行的秒杀活动),全量放在每台服务器的 Caffeine 本地缓存里。十万请求里 90% 都能直接在本地命中,Redis 的压力直接降 90%。
- 用 Redis Cluster 集群,把数据分片存储在多个节点上,每个节点只扛一部分请求,避免单节点压力过载;
- Redis 主节点只负责数据更新的写请求,多个从节点负责处理十万级的读请求,把读压力分摊到多个从节点上。
多实例下的本地缓存一致性
服务基本都是多实例集群部署的(比如 10 台服务器),每台服务器都有自己独立的 Caffeine 本地缓存,很容易出现数据不一致的问题:
比如在 A 实例更新了商品价格,删了 A 实例的 Caffeine 和 Redis 缓存,但 B 实例的 Caffeine 里还存着旧价格,用户访问 B 实例就会看到脏数据。
考虑用延迟双删 + Redis 发布订阅的组合方案解决这个问题:
- 延迟双删:保证 Redis 和 MySQL 的数据一致
① 先删除 Redis 缓存,让缓存失效;
② 再更新 MySQL 数据库,写入新数据;
③ 等待 1 秒(延迟时间必须略大于你的业务接口平均耗时,根据实际压测结果调整,不能盲目设置),再次删除 Redis 缓存。
延迟的目的是等其他实例已经从 MySQL 读到旧数据、更新到缓存后,再删一次缓存,避免脏数据回写。
- Redis 发布订阅:保证多实例 Caffeine 的数据一致
①A实例更新完数据、完成延迟双删后,通过 Redis 的发布订阅功能,给集群里所有服务实例广播一条消息:商品 id=123 的缓存已失效,请删除本地缓存;
②所有实例收到消息后,立刻删除自己本地Caffeine里对应的Key;
③下次用户访问任何实例,都会去 Redis 里查询新数据,再更新到本地 Caffeine,保证全集群所有实例的数据一致。
完整的请求+数据更新全流程
