高并发后端系统的缓存策略与实战案例
高并发的世界里所有的目光都盯着数据库——那个最脆弱的单点。当请求像潮水一样涌来时数据库的每一次读操作都可能是压垮系统的最后一根稻草。所以缓存不是锦上添花它是生死攸关的救生圈。但如果你以为只要把数据扔进Redis就万事大吉那么你离线上事故只差一次流量尖峰的距离。今天我们就来聊聊那些真正经历过高并发毒打的缓存策略和实战教训。缓存不是银弹但却是高并发的第一道防线很多团队在初期因为“缓存逻辑复杂”而选择裸奔数据库直到接口在99分位延迟突破2秒才发现数据库连接池已被耗尽。缓存的本质是将热点数据从慢存储迁移到快存储用空间换时间用复杂度换吞吐量。但这里有一个致命误区缓存不是万能药它只对“读多写少”的场景有奇效。如果你的业务写操作频繁且一致性要求极高比如金融账户余额缓存反而会成为你的噩梦。什么是高并发下的缓存核心矛盾数据的一致性 vs 系统的可用性。你不可能同时拥有强一致和极高吞吐。大多数互联网业务选择最终一致性牺牲毫秒级的延迟来换取每秒数万次的请求处理能力。理解这个前提你才能设计出合理的缓存策略。旁路缓存最朴素的策略最有效在所有缓存模式中旁路缓存Cache-Aside是最容易理解、也最容易被低估的。它的流程很简单读请求先查缓存命中则直接返回未命中则查数据库写入缓存再返回。写请求则更新数据库同时删除缓存。为什么是“删除”而不是“更新”缓存因为删除操作是幂等的而更新缓存可能因为并发写导致脏数据。旁路缓存的精髓在于让下一次读请求去加载最新的数据而不是在写的时候强行对齐。这种“懒惰”的设计恰恰解决了高并发下的写写冲突。淘宝的高并发商品详情页就大量使用这种策略——数据库是唯一权威源缓存只是加速器随时可以被丢弃重建。但旁路缓存不是完美的。你可能会遭遇“缓存击穿”某个热点key突然失效百万请求瞬间打穿到数据库。这时候你需要配合加锁——单机用互斥锁分布式用RedLock。更常见的做法是“缓存预热”在系统初始化或流量高峰期前主动将热点数据加载到缓存中设置合理的过期时间比如加上随机偏移量避免批量失效。缓存穿透当恶意请求绕过你的盾一个不存在的商品ID每次都查询缓存——未命中——查询数据库——返回空然后数据库就被这种无效请求拖垮了。这就是缓存穿透。攻击者只要遍历所有不存在的ID就能让你的数据库瘫痪。应对策略有三种但只有第三种才称得上高并发下的银弹第一缓存空对象。把查询结果为空的key也缓存起来设置较短的过期时间比如30秒。代价是你会存储大量无意义的key占用内存。第二布隆过滤器。在缓存之前加一层布隆过滤器判断请求的ID是否可能存在。不存在则直接拒绝。注意布隆过滤器只能判断“一定不存在”可能误判“可能存在”。你需要根据业务场景选择合适的误判率通常0.01%。在微博的亿级feed系统中布隆过滤器挡住了99%的非法请求。第三也是最关键的一步在入口层Nginx/API网关做合法性校验。比如ID必须符合特定正则、必须带签名、IP必须白名单。很多缓存穿透攻击都是因为应用层没有基本校验就放行了请求。记住缓存能挡住流量但挡不住恶意流量。缓存雪崩批量失效引发的连锁灾难假设你的系统为了提升性能把所有热卖商品数据都设置了相同的过期时间比如凌晨0点失效。结果到了0点所有缓存同时失效数据库瞬间被10倍于平常的请求淹没。这就是缓存雪崩。解决方案听起来简单但落地细节很重要设置不同的过期时间加上随机因子。比如基础过期时间1小时在此基础上random(0,600)秒。缓存预热微服务间的滑动过期。在商品详情页场景你可以让每个商品在访问时自动延期过期比如每次访问延长30秒。这需要调用Redis的EXPIRE命令注意它有性能损耗但在高并发下是值得的。多级缓存本地缓存如Caffeine分布式缓存Redis的组合。本地缓存命中率更高但不持久Redis持久化但网络开销大。两级缓存可以同时抗住冷热数据。京东的秒杀系统就用了这种方式本地缓存扛住99%的读请求只有本地缓存miss后才去查Redis最后才是数据库。实战案例秒杀系统的缓存架构我们曾经参与一个百万级用户秒杀活动的后端设计。商品库存只有1000件但瞬间涌入的请求是数十万。如果每个请求都去查MySQL的库存表数据库根本扛不住。我们的策略是1. 系统启动时将商品库存数据加载到Redis中。注意这里不是简单的set而是使用Redis的原子操作decr减库存。库存扣减完全在Redis中完成不回写数据库。数据库只做异步落盘保证最终一致性。2. 缓存架构分三层NginxLua 本地共享字典极低延迟用于拒绝非法或重复请求。Redis Cluster存储秒杀活动的元数据、库存剩余量、用户购买记录使用布隆过滤器判断是否重复下单。MySQL只做最终数据持久化不参与实时请求。3. 解决缓存击穿库存key一旦失效所有请求都会穿透到Redis和数据库。我们采用热点key防失效本地标记当库存降为0时设置一个永不过期的key“stock:0”并且本地缓存标记“已售罄”后续请求直接返回失败不再穿透。4. 针对恶意刷单使用漏斗算法 令牌桶限制每个用户的请求频率。Redis中的INCR命令记录用户一秒内的请求次数超过阈值则直接熔断。这个架构支撑了单次秒杀5万QPS数据库的写压力仅为每秒几百条。但教训同样深刻Redis单机处理能力约为10万QPS超过这个阈值要考虑集群和本地缓存的结合。我们的瓶颈最终出在Nginx层的本地字典内存耗尽——因为存储了过多的用户请求记录。最后我们设置定时清理策略只保留最近30秒的记录。缓存的旁路写操作的一致性困局高并发系统的写操作是缓存策略的噩梦。比如一个社交平台的点赞功能用户点赞后应该立即更新Redis中的点赞数还是等数据库写入后再更我们曾遇到一个典型的“缓存与数据库不一致”事故用户A点赞后Redis中点赞数1但数据库写入失败主从延迟或死锁。用户B随后看到点赞数多了1但数据库里没有这条记录导致数据永久不一致。解决方案是写操作永远以数据库为准缓存只做异步刷新。具体做法用户点赞后先更新MySQL使用乐观锁或分布式锁防止并发。MySQL更新成功后发送一条MQ消息或使用Canal监听binlog。后端消费者收到消息后更新Redis中的点赞数。在极端情况下如果用户立刻查询可能看到旧值。对于大多数社交feed类业务5秒内的延迟是可接受的。这叫做“读写不一致窗口”。如果你无法接受任何不一致那么放弃缓存吧——或者使用缓存预写Write-Through模式每次写操作同时更新Redis和MySQL用分布式事务如TCC或Saga保证两者最终一致。代价是吞吐量下降50%以上。绝大多数业务会选择放弃强一致。实战案例商城的购物车缓存购物车是典型的“写频繁但数据小”的业务。用户添加商品到购物车每次修改都需要更新。如果用旁路缓存模式每次修改都要先删缓存下次读再从数据库加载。这导致频繁的缓存未命中性能反而下降。更好的策略是读写穿透Read/Write Through写请求直接更新Redis将购物车商品列表存为Hash结构同时异步将这条写操作append到MySQL的binlog或消息队列。读请求直接从Redis读取不存在则从MySQL加载并写入Redis设置永不过期但根据用户活跃度设置TTL。Redis作为权威源MySQL只做持久化备份。如果Redis宕机可以从MySQL恢复购物车数据但恢复期间会有一段不可用时间。注意这里用了“永不过期”但配合TTL其实是个矛盾为了防止内存泄漏我们给每个用户的购物车key设置TTL比如7天但每次用户访问购物车时刷新TTL。这样不活跃的用户购物车自动过期释放内存活跃用户的购物车始终在缓存中。这个策略在高并发下的一致性保障是写操作几乎实时反映在Redis中用户下一次读一定能看到自己刚添加的商品。就算Redis突然宕机从MySQL恢复的数据可能丢失最后几秒的操作因为异步但大多数用户能接受。缓存策略的终极武器多级缓存与本地缓存当QPS突破百万时Redis也扛不住了——网络开销和GC停顿成为瓶颈。这时候必须引入本地缓存如Caffeine、Guava Cache、Ehcache。本地缓存的延迟是纳秒级Redis是毫秒级差了几个数量级。典型的多级缓存架构客户端缓存CDN/浏览器静态资源、不频繁变化的页面片段如商品图片、描述M页。Nginx层缓存使用lua-resty-memcached或proxy_cache缓存API返回结果。适合一些只读不变的接口。应用层本地缓存Caffeine或手动HashMap。适合存储字典、配置、常量、商品基础信息。需要处理缓存更新通过Redis发布订阅或长轮询通知各节点清除本地缓存。分布式缓存Redis/Memcached存储一致性要求高、数据量大的热点数据。数据库最终屏障。本地缓存的陷阱内存泄漏、数据不一致不同节点本地缓存可能不同步。解决方案设置较小的最大容量比如1000个key使用LRU驱逐。同时为每个key设置较短的过期时间比如60秒配合Redis的主动通知刷新。在微博热搜场景每个本地节点缓存的热词列表只存活10秒即使有短暂不一致对用户来说也毫无感知。再谈缓存失效你永远猜不透的“逻辑过期”除了固定时间过期、随机过例外还有一种高级策略逻辑过期。不设置Redis的TTL而是在数据中添加一个expireTime字段读的时候判断是否过期过期则异步重建。这样做的好处是避免了缓存击穿因为过期后读请求依然返回旧数据同时发起一个后台线程去更新缓存。用户永远不会面对缓存miss延迟极低。典型的实现在Redis中存储一个Hash其中包含数据本身和有效期。读请求时如果当前时间有效期则启动一个异步线程使用分布式锁防止多个线程同时重建去数据库拉取最新数据并写入Redis。旧数据继续返回给用户。这个策略只有在“容忍短暂过期数据”的业务中才能使用比如新闻列表、推荐流绝对不能用于库存扣减。总结选择策略的核心依据是业务语义没有通用的缓存策略。每次技术决策都要问自己三个问题1读多还是写多2数据是否允许最终一致性3QPS的峰值是多少对于读多写少且允许最终一致的场景用旁路缓存本地缓存布隆过滤器对于写多读少的场景用读写穿透异步持久化对于高并发秒杀用Redis原子操作本地标记。最后记住这句话缓存没有最优解只有最合适的妥协。所有文章里的策略都要结合你的业务和线上监控去调整。压测、监控、熔断、降级这是高并发系统的四大法门而缓存只是其中一块拼图。别让自己成为那个“缓存失效导致数据库被打爆”的背锅侠。