【架构实战】缓存架构设计实战:从Redis到多级缓存,彻底搞定高并发读
【架构实战】缓存架构设计实战:从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宕机,请求全部打到DB,DB瞬间崩。
- 预热(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):data=redis.get(f"user:{user_id}")ifdata:returnjson.loads(data)# 缓存命中user=db.query_user(user_id)# 查DBredis.setex(f"user:{user_id}",3600,json.dumps(user))# 回写returnuser# 写defupdate_user(user_id,new_data):db.update_user(user_id,new_data)# 1. 先更新DBredis.delete(f"user:{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_id=99999999),缓存永远miss,请求全打到DB。
解法1:缓存空值
defget_user(user_id):data=redis.get(f"user:{user_id}")ifdata==b"NULL":# 缓存了空标记returnNoneuser=db.query_user(user_id)ifnotuser:redis.setex(f"user:{user_id}",300,"NULL")# 空值也缓存,短过期returnNoneredis.setex(f"user:{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,一窝蜂查DB,DB被冲垮。
解法1:互斥锁(分布式锁)
defget_hot_product(pid):data=redis.get(f"product:{pid}")ifdata:returndata# 只有一个线程能拿到锁去查DB,其他等待重试ifredis.set(f"lock:{pid}","1",nx=True,ex=3):try:data=db.query_product(pid)redis.setex(f"product:{pid}",3600,data)finally:redis.delete(f"lock:{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 ttl=3600+random.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(f"user:{user_id}")# 1. 删缓存db.update_user(user_id,data)# 2. 更新DBtime.sleep(0.5)# 3. 等旧读请求回写完成redis.delete(f"user:{user_id}")# 4. 再删一次,清掉脏回写sleep时间要大于"读请求查DB+回写缓存"的耗时。简单有效,但sleep不优雅。
6.3 终极方案:binlog订阅(Canal)
业务只更新DB,用Canal监听MySQL binlog,异步删除/更新Redis。彻底解耦,一致性最强,是大厂标配。代价是引入中间件,架构变重。
结论:中小业务用 Cache Aside + 延迟双删足矣;大规模强一致诉求上Canal。记住一句话——缓存本来就是"容忍短暂不一致换性能"的,追求强一致就别用缓存,或接受"最终一致"。
七、多级缓存架构:把性能榨到极致
单级Redis在极端流量下也会成瓶颈(网络IO、序列化开销)。真正的高并发系统是多级缓存:
请求 → Nginx/CDN(静态资源) ↓(动态请求) 本地缓存 Caffeine(JVM内存,纳秒级,扛80%流量) ↓(miss) Redis集群(内存,微秒级,扛剩余流量) ↓(miss) MySQL(磁盘,兜底)- L1 本地缓存(Caffeine):进程内,零网络开销,纳秒级。容量小(几百MB),存最热的数据。适合"读多写少、允许短暂不一致"的数据。
- L2 分布式缓存(Redis):跨进程共享,所有节点一致,容量大。扛本地缓存miss的流量。
- L3 静态化/CDN:商品详情页整页HTML直接CDN分发,连应用都不进。
// Caffeine本地缓存示例Cache<String,Product>cache=Caffeine.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 Nginx:Lua读取本地缓存(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挂了,请求全穿透到DB,DB被冲死,整个系统雪崩。解决: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之外。
- 一致性:缓存天生容忍短暂不一致,追求强一致就别用缓存,接受"最终一致"才是工程现实。
缓存用好了是高并发系统的护城河,用不好是半夜故障的温床。今天这篇和早上的消息队列合起来,基本覆盖了"高并发系统"读写两侧的骨架。下一篇可以聊聊怎么用限流、熔断、降级把这些组件串成一座不宕机的系统——关注我,别错过。
我是做架构的,关注我,一起搞定分布式系统里的那些坑。
