一次凌晨3点的故障复盘
从一次凌晨3点的P0故障说起:缓存键没设计好,整个API网关直接被打到宕机,数据库连接池耗尽,用户投诉电话打爆了客服。那晚之后我给自己立了条规矩——缓存键不是随便拼个字符串,它是系统架构里最容易翻车的暗坑。
今天跟你聊聊我在带团队做大规模API场景时,关于缓存键设计和失效机制的那些血泪教训。先说好,这些经验都是真金白银换来的,有些甚至是在凌晨3点一边重启服务一边总结出来的。
一、缓存键不是拼字符串,是在画架构图
很多人觉得缓存键就是个唯一标识,把用户ID、接口名、参数一拼就完事了。真的,我面试的时候十个候选人里有八个都这么干。
但当你面对每秒几万次调用、几百个微服务同时读写缓存的时候,这种“随手拼”的做法会直接演变成灾难。
我先给你看个数据。2024年双11大促前,我们团队在优化一个商品详情页API时,发现Redis里同一个SPU(标准化产品单元)的数据被存了47份。47份。原因是不同客户端传的参数顺序、大小写、甚至多余的空白字符都不一样。缓存命中率只有31%,但Redis内存却吃掉了将近40G。当时用的是Redis 7.2.4,集群模式,8个节点每个16G内存,光这个API就占了快4个节点的容量。
踩坑实录: 当时我让团队排查为什么缓存“看起来有但实际用不上”,结果发现前端同学传参时 color=red&size=XL,后端同学测试用 size=XL&color=red,缓存键就不一样了。这还不是最离谱的,有个版本客户端把布尔值传成了 true 和 True,直接让缓存键翻倍。我记得查日志的时候看到这两条:
2024-11-03 02:47:23.421 ERROR CacheKeyMismatch: key=product:spu:882391:color=red&size=XL
2024-11-03 02:47:23.438 ERROR CacheKeyMismatch: key=product:spu:882391:size=XL&color=red当时运维同事看到这个,骂了一句国粹。
解决方案: 我们后来定了个铁律——所有缓存键必须经过“规范化层”处理。具体做法是:
- 参数排序:不管客户端怎么传,后端先按字母序排好
- 值标准化:布尔值统一转小写,枚举值做映射,多余空格trim掉
- 哈希化:对于参数特别多的场景,直接用MD5生成短键,避免Redis的key长得吓人
等等,这里我要更正一下——后来我们改成了SHA256,因为MD5在参数组合特别多的场景下碰撞概率比预期高。我们生产环境实测,日均3亿次缓存写入,用MD5出现过两次碰撞,虽然概率很低,但这种bug查起来简直想死。
改造之后,缓存命中率从31%涨到了89%,Redis内存直接省了60%。这个ROI比加机器划算太多了。想起来都觉得侥幸,要是双11当天才发现这个问题,后果不敢想。
二、缓存键的粒度之争:粗了好还是细了好?
这个问题我跟架构组的同学吵过不止一次。主张粗粒度的说“一个接口一个缓存,简单省事”,主张细粒度的说“按参数缓存,命中率更高”。其实都对,也都不对。关键看场景。
嗯...这个比较复杂,我尽量说清楚。
案例1:用户维度的粗粒度缓存
我们有个用户信息API,调用频率极高,几乎每个接口都会调它来拿用户基础信息。一开始我们按 user_info:{user_id} 做缓存,TTL设30分钟。看起来没问题,但上线后发现一个诡异现象——用户改了昵称,有些页面立刻显示新昵称,有些页面半小时后才更新。
问题出在哪儿?缓存失效不及时。用户修改操作只清了 user_info:{user_id} 这一个键,但用户信息还被嵌在其他聚合缓存里,比如 article_detail:{article_id} 里包含了作者昵称。这就是典型的“缓存键粒度没跟数据依赖关系对齐”。
我觉得很多人会忽略这个点。
解决方案: 我们引入了“缓存依赖图”的概念。每个缓存键在创建时,都注册它依赖了哪些基础数据。当基础数据变更时,通过消息队列通知所有依赖方做失效。具体用的是RocketMQ 5.1.3的广播消息,tag按数据维度打。比如用户信息变更就发tag user_update:{user_id},所有缓存了这个用户数据的服务都会收到,然后清自己的本地缓存和Redis。
听起来复杂,其实实现起来也就多了几百行代码。
案例2:商品列表的细粒度缓存
另一个极端是商品搜索API,参数组合多达几十种:关键词、类目、价格区间、排序方式、分页偏移量……如果每个参数组合都缓存,键空间会爆炸。我们预估过,按日均UV 200万、平均每人10次搜索、20%的参数组合是唯一的来算,一个月键数量就会超过1.2亿。Redis集群直接变键猪。
我们的做法是“两级缓存策略”:
- 第一级:热点参数组合(分析日志找出Top 20%的查询模式),全量缓存,TTL设5分钟
- 第二级:长尾查询只缓存商品ID列表,具体数据再从商品详情缓存里取
热点分析这块,我们用了ElasticSearch的日志聚合,每天凌晨跑个离线任务统计前一天的查询模式TopN。上线第一周的数据是:缓存命中率从41%涨到78%,键数量控制在了1.8万以内(相比之前预估的百万级),P99从200ms降到12ms。
我记得那天凌晨上线完,我盯着Grafana看了半小时,确认曲线稳定了才敢去睡觉。
三、失效机制:别让缓存成为“过期数据”的温床
缓存失效比缓存命中更容易出问题。我总结过三种常见翻车模式,你看看中过几招。
翻车模式1:删除还是更新?
很多团队在数据变更时选择更新缓存,而不是删除缓存。这看起来更“高效”——缓存里一直有数据,不用等下次查询重建。但问题在于并发场景下,两个更新操作可能以错误的顺序执行,导致缓存里永远是旧数据。
我吃过这个亏。2025年春节期间,订单状态变更时,两个服务几乎同时更新缓存,结果缓存里订单显示“已发货”,实际已经“已签收”了。客服那边直接炸锅,大年初三我们整个组被拉起来紧急修复。日志长这样:
2025-01-31 14:23:11.156 ServiceA: update order_status=shipped for order_id=2938471
2025-01-31 14:23:11.189 ServiceB: update order_status=signed for order_id=2938471
2025-01-31 14:23:11.201 Redis: set order:2938471 status=shipped (后执行的覆盖了先执行的)典型的写后写(Write-After-Write)竞态条件。
教训: 除非你能保证更新操作的严格顺序(比如用分布式锁或版本号),否则删除缓存永远比更新缓存安全。让下次查询来重建,虽然多一次DB查询,但数据一致性有保障。我们后来改成了“先删缓存,再更新DB,最后再删一次缓存”的模式,业内叫Cache Aside的变体。据我了解,大部分大厂都是这么干的。
翻车模式2:缓存雪崩的连锁反应
这个太经典了。2024年9月我们做中秋活动,一个热门活动的缓存同时过期,瞬间几万QPS直接打到数据库。PostgreSQL 15的连接池(当时配的是max_connections=500,pgbouncer pool_size=200)直接被占满,然后所有依赖这个数据库的服务都开始超时,整个链路雪崩。
那次故障持续了23分钟。我永远记得这个数字。
解决方案: 给TTL加随机偏移量。比如基础TTL是10分钟,实际设置时加一个0到120秒的随机值。这个简单的改动就能让缓存过期时间分散开。
另外,对于核心数据,我们做了“永不过期+异步刷新”的策略——缓存不设TTL,但后台有个定时任务每分钟检查数据源,有变更才刷新。定时任务用的是XXL-Job 2.4.1,监控数据变更用的是Canal 1.1.7监听MySQL binlog。
翻车模式3:缓存穿透的恶意利用
如果有人故意查询不存在的数据,缓存里没有,每次请求都会穿透到数据库。我们遇到过竞争对手用脚本遍历商品ID,专门查下架商品。当时从Nginx日志里发现,有个IP段在凌晨3-5点之间请求了大概80万个不存在的商品ID。后来查出来是某竞品公司的爬虫。
应对: 对不存在的数据也缓存一个空值,TTL设短一点(1分钟)。更进一步的方案是用布隆过滤器,在缓存前面加一层“数据是否存在”的快速判断。我们用Redisson 3.23.5自带的布隆过滤器实现,预计能容纳1亿条数据,误判率设的0.01%。上线后缓存穿透率降低了99.7%,这个数字我记得特别清楚,因为当时给老板汇报的时候特意截了图。
四、大规模场景下的进阶玩法
当你的API调用量从万级涨到百万级甚至更高,常规的缓存策略就需要升级了。分享两个我们团队实践过的方案。
多级缓存架构:
我们在网关层加了本地缓存(Caffeine 3.1.8,配的maximumSize=10000, expireAfterWrite=15s),Redis作为二级缓存,数据库是最后兜底。本地缓存TTL设得很短,但命中率能达到70%以上,因为热点数据的高频访问都在这层被挡住了。
有个数据值得参考:加了本地缓存后,API的P99延迟从45ms降到了8ms,Redis的QPS从12万降到了3万。但要注意本地缓存的失效一致性问题,我们用Redis的Pub/Sub广播失效消息,通知所有实例清除本地缓存。这里有个坑,Pub/Sub消息可能会丢,所以本地缓存TTL一定要设得比Redis短。
等等,我刚才说Caffeine配了15秒TTL,但其实生产环境后来改成了10到20秒的随机值,原因跟前面说的一样,防止集中过期。
缓存预热与降级:
大促场景下,我们会在活动开始前把热点数据提前加载到缓存里。具体做法是提前分析前7天的用户行为日志,预测活动当天的高频访问数据,然后写了个预热脚本,在活动开始前30分钟跑,把数据灌进Redis。
同时做了降级开关——用Sentinel 1.8.6监控缓存命中率,如果低于80%,自动触发限流,部分非核心接口直接返回兜底数据(比如商品列表默认返回Top 100热销品),保证核心链路可用。
这个方案在2024年双11扛住了零点那波洪峰。我记得当时的QPS峰值是日常的17倍,但系统稳住了,P99没超过200ms。老板第二天在群里发了个红包,虽然也就一人20块。
写在最后
缓存键设计和失效机制,表面上看是技术细节,实际上考验的是你对数据流和业务场景的理解深度。我面试高级工程师时经常问一个问题:“如果让你设计一个缓存系统,你会怎么处理数据一致性和性能的平衡?”能回答好这个问题的人,往往不只是会写代码,而是真正理解系统设计。
说实话,我自己也是踩了无数坑才慢慢摸索出来的。就像开头说的,凌晨三点重启服务的感觉,经历过一次就不想再来第二次。
你在项目中遇到过哪些缓存相关的坑?是缓存键冲突、失效风暴,还是别的诡异问题?评论区聊聊,我看看有没有比我们那次47份重复缓存更离谱的案例。或者你们用的是什么缓存方案?Redis、Memcached还是本地缓存为主?也分享一下呗。
对了,如果你也在准备系统设计面试,或者正在做缓存架构升级,需要我分享当时的技术方案文档的话,留言说一声,我整理一下发出来。
#系统设计 #缓存架构 #API优化 #后端开发 #技术管理
读者评论 3