尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis缓存设计与治理:穿透、击穿、雪崩及一致性实践

Redis缓存设计与治理:穿透、击穿、雪崩及一致性实践 Redis本身是个内存数据库但绝大多数项目里用到Redis说白了就是拿它当缓存使。我见过太多团队把Redis部署得漂漂亮亮结果缓存那几个经典问题一个没躲过缓存穿透把数据库打崩、热点Key把单节点CPU干到100%、更新数据库和删缓存顺序搞反导致数据错乱。这篇文章不聊虚的就聚焦Redis做缓存这件事从数据结构选型、缓存读写模式、三大经典问题、一致性方案到治理经验全部基于我在生产环境里的实际落地总结。先说说为什么需要Redis来做缓存。数据库查询走的是磁盘IO即使是SSD随机读写也就几千IOPS的级别而Redis单实例读写轻松到10万 QPS。更重要的是Redis的数据结构能直接对应业务模型不是单纯把数据库的行记录原样塞进去而是按访问维度重新组织数据。所谓缓存本质上就是把高频读、低频写的数据从慢速存储搬到高速存储里用空间换时间。1. 缓存场景设计的核心思路与选型分析1.1 什么样的数据适合放进Redis缓存这不是拍脑袋决定的。一个数据要不要进缓存我一般看三个特征第一是读多写少。典型的就是商品详情、用户信息、配置中心的数据读频率可能是写频率的几十倍甚至上百倍。如果读写频率相近缓存带来的收益就大打折扣还得承担一致性成本。第二是数据访问有热点。缓存之所以快是因为把“热数据”放在内存里。如果数据访问均匀分散没有明显的二八效应缓存命中率上不去Redis就变成了一个昂贵的内存替身。第三是数据的强一致性要求没那么高。所有缓存系统都有一个共同点数据最终一致但存在短暂的延迟窗口。账户余额、库存这种强一致数据不适合直接走缓存或者说需要配合分布式锁和双写策略来控制。一个比较稳妥的做法是先统计线上数据库的慢查询日志把Top 10的读SQL拎出来再配合业务分析判断哪些场景满足以上特征。哪个模式访问频率高就先优化哪个。1.2 为什么选择Redis而不是本地缓存或其他内存数据库很多小伙伴问Caffeine这类本地缓存性能更高为什么不全用本地缓存答案是Redis解决的不仅仅是“快”还有“共享”。本地缓存是进程级的每个应用实例维护自己的一份副本节点多了缓存各自为政更新还要广播失效复杂度很高。而且本地缓存容量受限于单个JVM堆内存撑不起大热数据。Redis是所有应用节点共享的一份缓存写入一次、处处可读天然解决多实例一致性问题。相比MemcachedRedis的优势是数据结构更丰富不只是set/get还支持List、Hash、ZSet等类型这就让缓存不只是“存字符串”而是能缓存一个对象的多个字段、一个列表的翻页数据、一个排行榜的排名数据业务表达能力完全不在一个量级。1.3 缓存粒度与KV结构设计的两个原则设计缓存Key的时候第一原则是可辨识性。Key必须能直观看出属于什么业务、对应什么ID。业界通常的命名规范是 业务名:实体名:ID:字段比如user:profile:10086线上排查问题的时候一眼就知道这个Key是存什么的。第二原则是容量可控。很多团队喜欢把整条数据库记录序列化成一个JSON塞进Redis做法简单但解读数据的灵活性就差了。比如商品信息有基础字段、价格字段、库存字段访问频率各不相同全量存成一个String意味着任何一个小字段更新都要重写整个缓存。这种情况下我会拆成Hash字段级别做缓存更新和过期控制。2. Redis数据类型在缓存场景下的实战应用2.1 String类型最基础也最容易踩坑的缓存方式String是Redis最基础的缓存结构存JSON序列化后的字符串、存页面渲染片段、存计数器都是它。实际项目里用的最多的是setnx做分布式锁以及incr做接口频率限制、秒杀库存扣减。但用String做缓存有两点容易踩坑。坑一是序列化问题。很多团队用默认的JDK序列化key会出现一串可读性极差的\xAC\xED\x00\x05t\x00前缀value是Java对象的二进制流不但肉眼排查困难而且字节膨胀严重同一个对象比JSON方式多占用60%以上的内存。我现在的做法统一用JSON序列化配置RedisTemplate时指定StringRedisSerializer和GenericJackson2JsonRedisSerializer有效兼顾可读性和体积。坑二是大Value问题。一个String类型的Value超过10KB就得警惕了超过100KB基本就是事故隐患。Redis是单线程模型一次大的Value写入会导致整个Redis实例短暂阻塞阻塞期间所有请求全部排队线上表现为接口延迟毛刺明显。一次真实的教训是有次缓存一个活动配置Value里塞了完整的上千条策略明细序列化后有将近1MB高峰期一更新Redis抖动立刻把整个集群拖慢了。解法是拆Key把配置拆成activity:config和activity:strategies两个或者按条数分片。2.2 Hash类型对象级缓存的正确打开方式Hash把缓存对象按字段维度存储每条字段独立操作非常适合“对象整体缓存、局部字段更新”的场景。比如用户信息用user:info:8888做Hash字段分别是name、age、avatar。用户换了头像只需要hset user:info:8888 avatar new_url其他字段完全不受影响。而且可以只取需要的字段配合hgetall或hmget按需加载网络传输量小很多。Hash还有个容易被忽略的用途缓存计数场景的字段聚合。比如一个文章详情页既需要展示点赞数、收藏数、阅读数又不想每次请求都三次get可以用Hash存入一个Key三个字段分别计数读的时候一次hgetall拿到全部。2.3 List与ZSet列表和排序场景的缓存策略List类型在缓存场景下主要做分页列表缓存。比如社区的信息流列表每次翻页都查数据库做limit offset页深了性能极差。可以先把第一页到第五页的数据一次性灌入List后续翻页用lrange取值。不过用List做分页缓存有个明显缺陷List不支持按区间删除中间的某个元素如果列表中间的数据发生变更比如置顶、删除处理起来很别扭。这种场景我更倾向于ZSetscore就是业务排序值用zrangebyscore做翻页用zrem删除任意元素维护成本低很多。ZSet真正的杀手锏是排行榜类缓存。实时热度榜、销量榜、积分榜写入的时候给对应成员加score读取的时候zrevrange取TopN完全不需要数据库参与计算。实际项目里一个几十万用户的热度榜ZSet在内存里几毫秒就能算完Top100如果走MySQL一条带order by和limit的SQL在千万级数据上没个几百毫秒都下不来。2.4 过期时间与淘汰策略的选择逻辑缓存不设置过期时间等于埋雷。我见过有团队为了省事把用户信息这种缓存设成永久Key导致的后果是用户改名后缓存永远是旧数据最后只能手动清理。设置过期时间有一个基本原则过期时间要略大于数据最长的可容忍延迟。比如商品价格运营后台改价后希望1分钟内全网生效那缓存TTL就设成30~60秒。过早导致缓存命中率下降过晚导致数据失效慢这个需要业务方给指标。淘汰策略方面如果是完全当作缓存用的Redis实例没有持久化诉求我用allkeys-lru。这样即使Cache Key数量超出内存Redis会优先淘汰最久没被访问的Key而不是报错拒写。如果实例里既有缓存Key又有持久化业务Key就用volatile-lru只对设置了过期时间的Key做淘汰不影响永久Key。3. 缓存读写一致性一个必须较真的细节3.1 Cache Aside模式的落地流程缓存读写有一套业界公认的标准模式叫Cache Aside旁路缓存。读流程是先读缓存缓存命中直接返回缓存没有就去数据库查查到后回填Redis并设置过期时间。写流程是有讲究的先更新数据库再删除缓存。很多人不理解为什么写的时候是“删缓存”而不是“更新缓存”。原因有两个。第一更新缓存需要额外做一次写操作删缓存只需要一次del代价更小。第二如果同时有两个线程并发更新后一个请求先更新了数据库前一个请求后更新了缓存那缓存就是旧数据而删除缓存在下一次读请求时重建能有效规避这个乱序覆盖问题。实际操作中删除缓存时还要考虑事务内和事务外的时序。如果先删缓存再更新数据库正好数据库更新失败那缓存已经删了旧数据又回去了下次查询触发的却是旧数据回填所以标准做法是数据库更新成功后再删缓存可以借助事务同步器在事务提交后执行缓存删除。3.2 延迟双删的适用边界与参数计算Cache Aside模式有一个时间窗口线程A更新数据库线程B在A删除缓存前读到了旧缓存把旧缓存回填等A删除缓存旧缓存还在数据就是错的。最常用的兜底方案是延迟双删更新数据库后先删除一次缓存等待一定时间再次删除缓存。这个等待时间的计算公式大致是等待时间 数据库主从同步的耗时 业务读请求在应用层的平均耗时。如果主从延迟是200ms业务读耗时50ms等待时间建议在300~500ms之间。不过延迟双删真的只是一个“最后防线”不是银弹。删除等待期间缓存有可能被旧请求重建防不胜防。我现在的方案是用消息队列异步删缓存更新数据库时发送一个“删除某个Key”的消息消费者收到后延时执行一次删除操作既解耦又可靠。当然这个方案需要引入消息组件小项目用延迟双删就够用了。3.3 缓存与数据库不一致的兜底机制无论设计得多精细线上总会有漏网之鱼。兜底方案是让缓存带上一个last_modified_time字段或者版本号回填缓存时对比一下数据时间戳如果发现缓存里存的是个老旧版本就不使用而是触发一次数据库回源。还有个实用的小工具是定期做数据对账。用一个定时任务扫描一部分缓存Key和数据库的值做比对发现不一致就强制重建。代价是额外一次数据库扫描频次别太高一小时一次差不多命中率极低但能兜住那些“万年不遇”的坑。4. 缓存穿透、击穿、雪崩的应对方案4.1 缓存穿透用布隆过滤器和空值缓存兜底缓存穿透是查一个缓存和数据库都不存在的数据。比如一个用户通过API查询一个不存在的订单ID每次请求都在Redis里查不到然后去数据库查也查不到白白打了一次数据库。如果是恶意攻击这种请求能瞬间让数据库连接池耗尽。标准解决方案有两个。第一是参数校验非法的ID直接返回错误这能过滤掉大部分无效请求第二是空值缓存数据库查询结果为空时也在Redis里存一个空值并加上一个较短的过期时间比如60秒后续相同请求直接返回空避免打到数据库。如果穿透量很大而且Key是随机变化的比如遍历数字ID攻击空值缓存就挡不住了这时候要用布隆过滤器。把合法的订单ID通过哈希映射到一个超大的位数组缓存查询前先判断ID是否在过滤器中不存在直接返回不用碰数据库。注意布隆过滤器存在误判把不存在的判成存在但不会漏判存在的不会被判成不存在所以它只能减少无效查询不能完全替代空值缓存。4.2 缓存击穿互斥锁与逻辑过期击穿和穿透很容易混击穿是某个热点Key在缓存过期的瞬间突然有大量并发请求同时回源访问数据库。典型场景是首页热点新闻缓存Keynews:hot过期的那一刻所有用户的首页请求都发现缓存没数据一起去数据库砸。互斥锁方案的思路是缓存过期后只有拿到分布式锁的那个线程可以去数据库回源其他线程先自旋等待再从缓存里拿数据。代码实现上用setnx加锁回填完缓存后del释放锁如果回填空数据也建议缓存起来。这个方案实现简单但存在一个锁等待导致的延迟问题。逻辑过期方案更平滑给缓存Key额外存一个逻辑过期时间戳读取时发现逻辑过期不等待而是拿锁的线程去后台刷新其他线程直接返回“旧数据”。用户的请求不会被阻塞数据在后台异步更新体验最顺滑。我专门给热点数据用了逻辑过期方案设置一个后台线程池专门处理缓存刷新任务。4.3 缓存雪崩打散过期时间和多级缓存配合雪崩是大面积缓存Key在同一时间集中过期导致大量请求同时打到数据库。最典型的是业务启动时统一设置的缓存过期时间比如所有商品信息都是午夜12点过期那午夜12点整一整波流量直接穿越Redis打到了MySQL上。解决思路第一条是给过期时间加随机值。TTL 基础过期时间 Random(0, 300)秒让Key的失效时间错峰分布。这是最简单也最有效的一招成本几乎为零。第二条是使用多级缓存做缓冲。Redis之上再加一层本地缓存比如CaffeineTTL设成60秒Redis的TTL设成10分钟。这样即使Redis整体失效本地缓存还能扛住60秒的流量冲击。多级缓存还能降低Redis的网络IO高峰期Redis的压力能降一个量级。值得注意的是雪崩不只是“同时过期”这一种形态还会有“整个Redis集群不可用”这种形态。所以高并发服务里必须预留降级开关Redis挂了或超时直接走数据库并加上限流绝不把故障扩大。5. 缓存治理与容量规划5.1 内存容量规划与maxmemory设置Redis做缓存内存规划不好迟早出事故。我见过最典型的事故一个几百万会员的数据被全部缓存进Redis每个用户的JSON有2KB总共接近2GB数据但Redis实例内存限额只给了1GB结果淘汰策略触发后频繁回收命中率雪崩式下降。内存规划有一个估算公式预估缓存数据量 单条数据大小 × 数据条目数 × (1 冗余系数)。冗余系数一般给20%左右留给序列化开销和Redis自身的元数据消耗。比如一条缓存记录JSON约1KB预计缓存100万条有效数据那当前实例的used_memory至少按1.2GB规划。实操上可以用redis-cli info memory查看used_memoryredis-cli --bigkeys扫描大Key分布。建议每周做一次内存增长趋势看板发现used_memory快逼近maxmemory时先排查是不是有失效Key没清掉再考虑扩容或优化数据结构。5.2 大Key与热Key的治理经验大Key会阻塞Redis单线程热Key会把单一节点的CPU打满这两个问题在缓存场景下是高频出现的。大Key的治理手段String大Value拆分成多个小KeyHash大字段拆成多个Hash按业务维度分区List和Set超过万级元素换成分页存储。有一个好办法是在写入时设置单Key大小上限比如写入前先看JSON字符串的length超过20KB就告警。热Key的治理要复杂一些。先要识别热Key可以通过Redis的hotkeys参数Redis 4.0以上支持redis-cli --hotkeys或者自己在代码里给每个Key的访问加上计数。发现热Key后常用手段有四种本地缓存兜底把热Key在应用本地缓存一份减少对Redis的访问副本读给热Key做多个副本Key比如key#0到key#9读的时候随机选一个分散单Key压力多级缓存热Key加上Caffeine一层缓存过期时间错开避免所有请求集中在同一个过期时间戳上。5.3 命中率监控与优化缓存命中率是衡量缓存效果的核心指标。计算公式是命中率 命中次数 / (命中次数 未命中次数)。业界标准是缓存命中率建议保持在90%以上低于85%基本说明缓存策略出了问题。命中率低不是简单加内存就能解决的。先要分类统计不命中的Key看看是数据量超过内存、过期时间太短、还是访问不集中。通过Redis的info stats里的keyspace_hits和keyspace_misses可以算出整体命中率加上代码里的日志可以精确到具体Key级别的未命中原因。我优化过一次命中率从75%到95%的案例原因是团队把用户登录状态的缓存过期时间设成了10分钟但用户平均会话时长是30分钟很多用户还在线缓存却已经过期了于是每次刷新页面都会回源数据库。把过期时间调整到比平均会话时长略长之后命中率直接提升20个点。这类问题往往不是技术难度高而是业务理解不到位。5.4 缓存清理与版本管理Redis缓存清理不只是手动删几个Key那么简单。如果是上线新代码、缓存结构发生变化需要统一清缓存时我在生产环境有三种手段配合使用第一种是直接通过Redis客户端批量删除指定前缀的Key需要用到scan命令配合del绝不能用keys *在全量大Key下执行会直接卡死Redis第二种是在代码里用动态版本号Key里带上版本前缀比如user:info:v3:10086上线直接切版本旧版本Key自然过期这是最优雅的方案第三种是开发一个统一的缓存治理接口低频调用用于清理特定的业务缓存。专门提醒一句不要在生产任意执行FLUSHALL。Redis的FLUSHALL是清空所有数据库的命令一个失误就是灾难性的。真到了必须整体清理的场景先在测试环境验证再在业务低峰期执行。6. 常见问题与排查技巧实录6.1 缓存命中率突然下降的排查路径这种情况我碰到过好几次大部分时候不是缓存代码出问题而是线上的访问模式变了。排查步骤我固定如下第一步先用redis-cli info stats看keyspace_hits和keyspace_misses确认命中率下降是整体性的还是局部性的。第二步用redis-cli --hotkeys和代码日志结合找出哪些Key的未命中次数最多这些Key往往就是问题源。第三步逐个分析高未命中的Key是缓存过期太频繁是数据被淘汰了还是缓存根本没被写入第四步检查是否最近上线发布过缓存相关代码比如改过Key命名、改过TTL、改过序列化方式。有一半的缓存事故都是代码发布引起的这个环节不能跳过。6.2 Redis内存暴涨但数据量没增长内存暴涨但业务数据量没涨一般有四个原因大Key被频繁写入、内存碎片率高、Key数量膨胀比如把缓存Key拼上了时间戳、value里的无用字段太多。先用redis-cli info memory看mem_fragmentation_ratio如果大于1.5说明碎片化严重可以在维护窗口执行memory purge或者重启实例再看used_memory里Key数量和过期Key数量如果大量过期Key占据内存没释放检查是不是maxmemory-policy设置成了noeviction不淘汰、不删除过期数据导致内存持续被过期但不被清理的Key占用。最有效的内存排查动作是跑一遍redis-cli --bigkeys把最大的几个Key揪出来确认是业务正常数据还是异常膨胀。6.3 缓存雪崩时如何快速止损处理线上缓存雪崩第一时间不是写代码优化而是止损。我经历过一次首页流量把数据库打挂的事故当时的止损顺序先立即关闭部分非核心业务的缓存回源开关让它们直接返回降级数据再对核心接口启用单机限流把打到数据库的并发压制在安全水位然后手动预热核心热点数据如果Redis实例可用就提前写入热点Key提前拉长过期时间最后等恢复后再把降级开关一个个打开。整个过程要在10分钟内完成。所以我在平时就准备了所有核心接口的降级开关和预热脚本上线前就会演练一遍。6.4 缓存穿透的恶意访问特征识别攻击型的缓存穿透有很明显的特征请求的ID往往是无规律的大整数或者UUID片段单IP或单用户短时间内访问大量不存在的Key。识别手段包括网关层限制单IP的QPS、客户端参数校验拦截非法ID格式、布隆过滤器快速返回不存在。要特别提醒的是空值缓存的Key必须设置一个短TTL比如30~60秒否则空Key越积越多本身就变成了一个大Key风险。对于攻击类流量我还会把空值缓存的对应用户ID拉入黑名单直接拒绝一段时间内的请求。7. 从缓存到分布式锁Redis能力的自然延伸有缓存的地方往往就有并发控制的需求Redis做分布式锁是缓存场景下最常被问到的延伸能力。虽然不完全是“缓存”但在业务落地过程中Redis一旦被引入分布式锁几乎是必然要配套使用的。极简实现是利用set key value NX PX 3000原子命令NX保证只有一个客户端能设置成功PX保证锁秒级自动释放。这里有两个常踩的坑第一个是锁的value要用唯一标识比如UUID释放锁的时候先比较value再del否则可能把别人刚获取的锁删掉第二个是锁的超时时间要大于业务执行时间如果业务执行超过锁的TTL要用看门狗续期否则业务还没做完锁就自动释放了又会出现并发问题。不过要说一句实话Redis分布式锁在极端情况下主节点宕机、数据丢失做不到绝对安全。如果是秒杀、转账这种资金级强一致场景建议引入Redisson红锁或者直接使用数据库悲观锁Redis锁更适合做接口防重、缓存重建互斥这类非资金级场景。使用Redis做缓存的过程绝不只是“调一个API存一段JSON”那么简单。数据结构的合理选型、过期策略的精细配置、一致性方案的有意设计这些才是决定线上稳定性和性能的核心。我踩过不少坑最大的感受是缓存设计必须在系统设计初期就想清楚而不是等性能问题爆发后再救火。最后再分享一个个人习惯每次在Redis里新增一类缓存Key我都会顺手把Key结构、过期时间、重建逻辑、不一致兜底方案记录在技术文档里。半年之后你再回头看这份文档帮你省下的排查时间远超当时写它花掉的时间。
返回列表