
【架构实战】缓存架构设计实战从Redis到多级缓存彻底搞定高并发读今天早上聊了消息队列解决的是写和异步的问题。但高并发系统还有另一半江山读。互联网业务的常态是读多写少——商品详情页、微博首页feed、新闻列表、排行榜读流量往往是写的几十倍甚至上百倍。如果每次读都打到数据库MySQL在几千QPS时就趴下了。缓存就是专门解决读的武器。缓存这东西又爱又恨用好了系统QPS从几百冲到几万延迟从几十毫秒降到亚毫秒用不好缓存穿透、击穿、雪崩三连击能让你半夜被告警叫起来。这篇从缓存选型、读写策略、三大灾难、一致性问题到多级缓存和商品详情页实战一次讲透。一、为什么需要缓存先说清楚缓存到底解决了什么。减轻数据库压力数据库尤其关系型的并发能力有限且每查一次都要走磁盘IO、加锁、解析SQL。把热点数据放内存90%的读请求根本到不了DB。降低响应延迟内存访问是纳秒级磁盘是毫秒级差了几个数量级。缓存命中时响应直接从微秒级降到亚毫秒级用户体验质变。承接突发流量热点事件明星塌房、秒杀开始会让读流量瞬间暴涨几十倍。缓存层像一道防洪堤把洪峰拦在外面DB只处理漏过来的涓涓细流。成本优势一台Redis顶几十台MySQL的读能力用便宜的内存换昂贵的数据库连接和CPU性价比极高。经典经验法则80%的请求集中在20%的数据上二八定律。只要把这20%的热点缓存住系统整体性能就上去了。二、缓存的核心概念不管用什么缓存中间件这几个概念必须刻进DNA命中率Hit Rate命中数 / 总请求数。命中率低于90%的缓存基本等于没用要查为什么缓存粒度太粗过期太快。缓存穿透Penetration查一个根本不存在的数据缓存没有、DB也没有请求每次都穿透到DB。恶意攻击最爱用这个刷库。缓存击穿Breakdown某个热点key突然过期瞬间海量请求同时穿透到DB把DB打挂。缓存雪崩Avalanche大量key在同一时刻集中过期或Redis宕机请求全部打到DBDB瞬间崩。预热Warm Up系统启动或大促前提前把热点数据加载进缓存避免冷启动被打爆。降级Degradation缓存不可用时直接返回默认值/静态页/稍后重试保住核心链路。三、Redis核心数据结构与实战选型Redis不是简单的key-value它的数据结构选型直接决定代码复杂度。选错结构代码又臭又长。3.1 String计数器与分布式锁# 商品库存计数 INCR article:view:10086 # 分布式锁 SET lock:order:123 uuid NX EX 30最通用但存对象要自己序列化JSON频繁更新单个字段会很浪费整存整取。3.2 Hash对象存储HSET user:10086 name 张三 age 28 score 99 HGET user:10086 name适合字段多、需要单独更新的对象用户资料、商品属性。更新单个字段不用整体重写省带宽。3.3 ZSet排行榜与延迟队列ZADD leaderboard 99 张三 88 李四 ZREVRANGE leaderboard 0 9 WITHSCORES # 取Top10分数score自动排序排行榜、热帖、延迟任务score执行时间戳都靠它。3.4 布隆过滤器判断可能存在缓存穿透的终极武器后面详讲。它用位数组多个哈希函数能高效判断某元素是否一定不存在空间极小。选型经验简单KV用String对象且要局部更新用Hash排名/范围用ZSet去重/存在性判断用Set或布隆过滤器。四、缓存读写策略核心这是缓存设计里最容易写错的地方。错误的读写顺序 数据不一致的根源。4.1 Cache Aside Pattern旁路缓存——最常用读先查缓存命中直接返回未命中查DB写入缓存返回。写先更新数据库再删除缓存不是更新缓存。# 读defget_user(user_id):dataredis.get(fuser:{user_id})ifdata:returnjson.loads(data)# 缓存命中userdb.query_user(user_id)# 查DBredis.setex(fuser:{user_id},3600,json.dumps(user))# 回写returnuser# 写defupdate_user(user_id,new_data):db.update_user(user_id,new_data)# 1. 先更新DBredis.delete(fuser:{user_id})# 2. 再删除缓存不是set为什么是删除而不是更新缓存因为更新缓存可能把还没提交的事务数据写进去也可能多个写并发导致脏数据。删掉让它下次读时自动回写最简单也最安全。4.2 Read/Write Through穿透型缓存自己负责和DB同步应用只和缓存打交道。CacheLoader模式如Caffeine、Spring Cache就是这种。对应用透明但实现复杂。4.3 Write Behind异步回写写只写缓存由缓存异步批量刷回DB。性能极高减少DB写次数但丢数据风险大缓存挂了没刷盘的就没了多用于对一致性不敏感的场景。我的结论绝大多数业务用Cache Aside 先更新DB再删缓存就够了简单可靠。五、缓存三大灾难穿透、击穿、雪崩这是面试和实战必考。逐一拆解。5.1 缓存穿透查不存在的数据现象黑客用一堆不存在的ID狂刷如user_id-1、user_id99999999缓存永远miss请求全打到DB。解法1缓存空值defget_user(user_id):dataredis.get(fuser:{user_id})ifdatabNULL:# 缓存了空标记returnNoneuserdb.query_user(user_id)ifnotuser:redis.setex(fuser:{user_id},300,NULL)# 空值也缓存短过期returnNoneredis.setex(fuser:{user_id},3600,json.dumps(user))returnuser缺点大量空值会占内存适合不存在的key有限的场景。解法2布隆过滤器推荐# 所有合法user_id预先放入布隆过滤器bloom.add(user_id)defget_user(user_id):ifnotbloom.contains(user_id):# 一定不存在直接拒绝returnNone# 继续走缓存/DB逻辑布隆过滤器说不存在就一定不存在从根上挡住了穿透。代价是有误判率说存在可能不存在且不支持删除。5.2 缓存击穿热点key过期瞬间现象某个爆款商品key突然过期几万QPS同时发现缓存miss一窝蜂查DBDB被冲垮。解法1互斥锁分布式锁defget_hot_product(pid):dataredis.get(fproduct:{pid})ifdata:returndata# 只有一个线程能拿到锁去查DB其他等待重试ifredis.set(flock:{pid},1,nxTrue,ex3):try:datadb.query_product(pid)redis.setex(fproduct:{pid},3600,data)finally:redis.delete(flock:{pid})returndataelse:time.sleep(0.05)returnget_hot_product(pid)# 重试读缓存解法2逻辑过期不设真过期代码里判断key不设置TTL值里带expire时间戳发现逻辑过期时开一个后台线程重建缓存当前请求先返回旧值。用户体验最好永远不等待但实现稍复杂。5.3 缓存雪崩大量key同时失效现象几百个key设了相同过期时间比如都3600秒到点一起失效DB瞬间被海量请求淹没。解法过期时间加随机值expire base random(0, 300)错开失效时间。多级缓存本地缓存兜底Redis挂了还有Caffeine扛着。限流降级DB前置限流超出阈值的请求直接返回降级页。Redis高可用主从哨兵或集群避免单点宕机导致全量雪崩。# 加随机过期避免集体失效importrandom ttl3600random.randint(0,300)redis.setex(key,ttl,value)六、缓存与数据库一致性这是缓存设计里最让人纠结的问题缓存和DB的数据怎么保证一致6.1 先删缓存再更新DB还是反过来先删缓存再更新DB在删缓存和更新DB之间如果有读请求进来会读DB旧值并回写缓存导致缓存是脏数据且长期存在直到下次更新。❌ 不推荐。先更新DB再删缓存Cache Aside极端情况下读请求在DB更新前读到旧值且恰好缓存刚失效可能写回旧值。但读比写快得多这个窗口极小且可以靠延迟双删兜底。✅ 推荐。6.2 延迟双删应对上面的极端竞态defupdate_with_double_delete(user_id,data):redis.delete(fuser:{user_id})# 1. 删缓存db.update_user(user_id,data)# 2. 更新DBtime.sleep(0.5)# 3. 等旧读请求回写完成redis.delete(fuser:{user_id})# 4. 再删一次清掉脏回写sleep时间要大于读请求查DB回写缓存的耗时。简单有效但sleep不优雅。6.3 终极方案binlog订阅Canal业务只更新DB用Canal监听MySQL binlog异步删除/更新Redis。彻底解耦一致性最强是大厂标配。代价是引入中间件架构变重。结论中小业务用 Cache Aside 延迟双删足矣大规模强一致诉求上Canal。记住一句话——缓存本来就是容忍短暂不一致换性能的追求强一致就别用缓存或接受最终一致。七、多级缓存架构把性能榨到极致单级Redis在极端流量下也会成瓶颈网络IO、序列化开销。真正的高并发系统是多级缓存请求 → Nginx/CDN静态资源 ↓动态请求 本地缓存 CaffeineJVM内存纳秒级扛80%流量 ↓miss Redis集群内存微秒级扛剩余流量 ↓miss MySQL磁盘兜底L1 本地缓存Caffeine进程内零网络开销纳秒级。容量小几百MB存最热的数据。适合读多写少、允许短暂不一致的数据。L2 分布式缓存Redis跨进程共享所有节点一致容量大。扛本地缓存miss的流量。L3 静态化/CDN商品详情页整页HTML直接CDN分发连应用都不进。// Caffeine本地缓存示例CacheString,ProductcacheCaffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10,TimeUnit.MINUTES).build();ProductgetProduct(Stringpid){returncache.get(pid,id-{// 本地miss查Redis再miss查DBreturnredisOrDbQuery(id);});}关键设计本地缓存要能主动失效通过Redis的Pub/Sub广播失效消息否则一台机器更新了DB其他机器的本地缓存还是旧的。八、生产级案例商品详情页一个日活千万级的商品详情页典型架构长这样用户请求商品详情 ↓ CDN命中整页静态HTML→ 直接返回占比~60% ↓ miss NginxLua读取本地缓存OpenResty shared dict→ 返回占比~15% ↓ miss 应用层Caffeine本地缓存→ 返回占比~15% ↓ miss Redis商品缓存→ 返回并回写本地占比~9% ↓ miss~1% MySQL查库 → 回写Redis 本地缓存全链路命中率99%最终打到DB的请求不到1%。即使Redis宕机Caffeine和CDN还能扛住大部分流量DB稳如老狗。这就是多级缓存的威力。九、避坑清单过来人的血泪经验坑1缓存大key一个String存了整个商品列表几MB序列化/反序列化慢网卡打满Redis阻塞。解决拆分按页/按字段、压缩、用Hash分字段。坑2热key集中打爆单节点某个爆款key全落在Redis一个slot/节点该节点CPU 100%。解决热key打散key加随机后缀hotkey:{0~9}或本地缓存兜底。坑3缓存和DB数据长期不一致忘了先更新DB再删缓存或更新DB后删缓存失败异常没catch。解决删缓存操作放finally或引入binlog补偿。坑4缓存过期时间统一引发雪崩见5.3加随机值错峰。坑5用了缓存就不设兜底降级Redis挂了请求全穿透到DBDB被冲死整个系统雪崩。解决Redis不可用时本地缓存/默认值/限流降级保住核心链路。坑6序列化选型随意用JDK原生序列化体积大、跨语言不兼容。解决统一用JSON或Protobuf/Kryo体积小、可读、跨语言。坑7缓存命中率不监控上线后从不看命中率缓存其实一直在miss等于没用。解决监控keyspace_hits / (keyspace_hits keyspace_misses)低于90%就要查原因。十、总结定位缓存解决读多写少的高并发读问题和MQ解决写/异步是黄金搭档。策略Cache Aside 先更新DB再删缓存简单可靠强一致诉求上Canal订阅binlog。三大灾难穿透用布隆过滤器/空值缓存击穿用互斥锁/逻辑过期雪崩用随机过期多级缓存限流。多级缓存CDN → 本地(Caffeine) → Redis → DB把99%流量拦在DB之外。一致性缓存天生容忍短暂不一致追求强一致就别用缓存接受最终一致才是工程现实。缓存用好了是高并发系统的护城河用不好是半夜故障的温床。今天这篇和早上的消息队列合起来基本覆盖了高并发系统读写两侧的骨架。下一篇可以聊聊怎么用限流、熔断、降级把这些组件串成一座不宕机的系统——关注我别错过。我是做架构的关注我一起搞定分布式系统里的那些坑。