ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

缓存过期时间怎么定?从TTL原理到雪崩防护实战

缓存过期时间怎么定?从TTL原理到雪崩防护实战 缓存要不要设过期时间这个问题我问过不少候选人也踩过不少坑。大多数系统的线上故障十有八九不是缓存加不加的问题而是过期时间没想清楚。带过期时间的缓存也就是TTL缓存是分布式系统里最常用也最容易被轻视的机制。这篇内容我打算把缓存过期这件事从头到尾捋一遍从底层原理到工程实践再到线上故障排查聊聊那些没人写进文档里的经验。无论你用的是Redis、Caffeine还是本地Map道理都是通用的。1. 带过期时间的缓存先搞懂TTL到底在解决什么问题1.1 一次缓存过期引发的线上事故先说个真实案例。前几年我负责过一个电商类的商品详情页服务平时接口P99在80毫秒左右算得上安稳。某个版本上线后运营提了一个“老用户优先展示”的需求开发顺手把用户维度的商品推荐结果加了缓存过期时间设的是2小时。上线一周后的某个下午数据库CPU突然被打到100%接口P99飙到3秒监控告警瞬间刷屏。排查下来问题出在一个很简单的地方所有用户的推荐结果都在同一时间点写入缓存过期时间又完全一致于是2小时后的同一秒几十万个key集体失效。那一瞬间所有请求全部穿透到数据库直接把库打挂了。事后复盘大家一致承认如果当时多想一想“缓存过期那一刻会发生什么”这事儿完全可以避免。这个案例不是个例。缓存过期就像老化到极限的皮筋你不知道它哪一刻断但大概率断在最要命的时候。搞懂“有时间限制的缓存”本质上就是搞懂一根皮筋的寿命、断法和兜底方案。1.2 过期时间不是给缓存添麻烦是给数据上保质期把缓存想成冰箱里的剩菜。没有保质期的剩菜你不敢吃内存里的数据也一样——没有过期时间的数据迟早变成“馊了的快照”。TTLTime To Live就是给缓存数据贴一张保质期标签。为什么必须要有这个标签因为缓存永远是数据库的“快照”它和源头数据之间天然存在时间差。业务数据是持续变化的要么是用户改了资料要么是价格调了要么是库存变了。如果缓存永远不失效用户看到的就是一个越来越陈旧的世界。设计上的重点是缓存可以旧但旧的程度必须可控。TTL就是控制“旧的程度”的唯一旋钮。设短了缓存命中率低数据库压力大设长了数据陈旧度上升用户投诉增多。很多团队不敢给缓存设过期时间担心数据不一致于是干脆不设——这恰恰是最危险的做法因为不设过期时间等于把“数据要多旧”这个决定权交给了内存淘汰策略而不是业务逻辑。1.3 哪些数据该设过期时间哪些不该设不是所有数据都适合套上TTL这是很多新手最容易犯的错。适合设过期的数据有一个共同特征对陈旧度有天然容忍度而且读多写少。典型的有热销榜、推荐列表、配置字典、用户登录态、短信验证码。这类数据一旦写入短时间内变化有限即便读到几秒前的旧值用户也感知不到。我在实际项目中验证码过期时间通常是5分钟运营页面配置2分钟排行榜5分钟——这些数字不是拍脑袋而是产品和研发一起根据业务容忍度定的。不适合设过期的数据通常是强一致要求高的核心数据。比如订单金额、支付状态、库存扣减。这类数据如果缓存和数据库不一致轻则对不上账重则超卖。我的建议是这类数据干脆不要进缓存或者只做“旁路缓存”——也就是说缓存只服务读多写少的非关键路径。至于本地缓存里的用户Session、分布式锁的锁key虽然也带过期时间但它们本质上是状态而非“快照”逻辑要单独设计不能套用同一个思路。数据类型是否建议设TTL参考过期时间理由商品详情、活动页配置建议5分钟到1小时可容忍短暂陈旧读多写少排行榜、推荐列表建议1到5分钟时效性敏感但允许短暂延迟验证码、登录态必须5到30分钟安全性要求过期即失效订单状态、库存不建议不设或极短强一致要求缓存只做防御性提速字典表、系统配置建议10分钟到1天低频变更过期只是兜底2. 缓存的过期机制惰性、定期、淘汰三个机制撑起整个体系2.1 惰性删除一个关键词过期后并不是立刻消失很多人的认知里缓存key到了过期时间就“没了”。这个认知是错的至少在Redis里不是这样。Redis对过期key的清理第一招叫惰性删除。所谓惰性就是当你去GET一个key的时候Redis才顺便检查它是否过期过期了就删除然后返回nil。这个过程在命令执行路径上完成不额外触发后台任务几乎不消耗CPU。惰性删除的优点是够懒、开销极小。缺点是如果某个过期key一直没有被访问它会一直占着内存成为永不清理的“僵尸key”。我遇到过最典型的场景是业务方在晚上高峰写入一批带24小时过期时间的key第二天中午这批key已经过期十个小时了但因为白天没有请求再碰它们它们依然躺在内存里。如果这种key的数量大到一定程度内存会被白白占满。惰性删除告诉我们一个工程常识过期时间只是逻辑上的“不可用”不是物理上的“立即消失”。理解这一点才能理解为什么内存监控和淘汰策略如此重要。2.2 定期删除后台扫地的钟点工光靠惰性删不行Redis又加了第二招定期删除。Redis主线程会以一个固定的频率默认每秒10次抽取一部分带过期时间的key进行检查如果发现过期就把它们清掉。这个动作有点像家里请的钟点工不是天天来也不是每次都把每个角落都扫一遍但总归定期打扫一下避免屋子彻底变成垃圾堆。定期删除的关键在于“抽样”而不是“全量扫描”。如果Redis每次检查都遍历所有key主线程会被阻塞所有读写命令都得排队等着那性能就崩了。所以Redis做的是从设置了过期时间的key里随机抽一批检查并清理其中过期的部分然后根据清理比例决定下一轮是否加大抽样力度。这个“偷懒”的设计保证了清理动作不会影响正常请求的响应速度。理解了定期删除你就能明白一个常见的监控现象为什么Redis的used_memory会在某个时间点突然下降一截。那不是业务低峰导致的自然回落而是后台定期清理恰好扫到了一大批过期key一次性把它们物理删除了。2.3 内存淘汰内存满了之后的最后一道防线惰性删除和定期删除负责清“已过期”的key但还有一个更棘手的情况内存还没等key过期就满了。这时如果又有新key要写入Redis就必须按配置的内存淘汰策略来做决定。这就是maxmemory-policy的用武之地。默认策略是noeviction也就是内存满了直接返回OOM错误拒绝写入。很多初学者不知道这个策略存在于是线上Redis内存被打满后业务莫名其妙的报错却怎么都查不到原因。常见的策略还有allkeys-lru从所有key里淘汰最久没用的、volatile-lru只从设置了过期时间的key里淘汰最久没用的、allkeys-lfu淘汰访问频率最低的等。这里有个特别容易踩的坑volatile开头的策略只会淘汰“带过期时间”的key。如果某个key没设TTL就算你的Redis配置了volatile-lru这个key也不会被淘汰。等到内存满了你会看到大量noeviction报错。所以如果计划用内存淘汰做兜底要么所有key统一设过期时间要么选allkeys开头的策略二选一别混着来。淘汰策略作用范围选择依据noeviction不淘汰直接报错几乎不使用除非绝不允许丢keyallkeys-lru所有key适合业务数据全读多写少页面访问冷热分明volatile-lru仅带TTL的key希望保留长期key只丢短期数据allkeys-lfu所有key适合访问频率差异极大的场景比如热点内容volatile-random仅带TTL的key数据无冷热之分只求腾出空间2.4 一个key的完整生命周期帮你串起三个机制把三个机制串起来看一个key写入Redis时带上了TTL比如5分钟。在你不断访问它的这5分钟里它正常返回数据。5分钟一到这个key进入“逻辑过期”状态但物理上可能还占着内存。如果之后有请求GET它惰性删除发现它过期直接删除并返回nil。如果没有请求碰它它就一直躺在内存里直到后台定期删除扫到它或者直到内存压力触发淘汰策略把它清掉。这个生命周期告诉我们两个重要的工程判断第一缓存过期不等于立刻释放内存监控内存时不能指望过期时间到了就自动瘦身第二既然有“逻辑过期”和“物理删除”的区别那么在缓存到期瞬间的并发场景下就可能出现大量请求同时穿透的情况——这正是下一节要解决的问题。3. 过期时间怎么定从拍脑袋到科学设计3.1 两个基准业务容忍度和数据更新频率给缓存设过期时间不是产品经理随口说一个“5分钟吧”就完事。我自己的经验是先问两个问题第一业务能容忍的最长数据陈旧时间是多少第二底层数据平均多久变一次第一个问题决定TTL的上限。用户看排行榜晚一分钟刷新可能无所谓但看库存数晚10秒都危险。所以排行榜可以设5分钟库存相关的缓存最多设10到30秒。第二个问题决定TTL的“安全系数”。底层数据如果每小时平均更新一次那TTL设10分钟是合适的——既保证了大部分时间内缓存是新鲜的又不会频繁失效。如果数据每秒钟都在变那我不建议做缓存或者只做极短TTL比如1秒来抗瞬时热点。说到底TTL 业务容忍度 × 安全系数。容忍度越低系数越保守。我自己通常在容忍度的三分之一到二分之一之间取一个值再让开发和产品一起确认。这个值不是算一次就固定的后续要通过命中率监控和业务反馈动态调整。3.2 缓存穿透、击穿、雪崩三个和过期时间纠缠不清的坑这三个问题每个都跟“过期时间”直接相关但成因和解法完全不同。缓存穿透指的是请求的数据本身就不存在缓存里没有数据库里也没有于是每次请求都直接打DB。这类流量往往来自恶意攻击或异常调用和TTL本身无关但因为缓存无法缓存“不存在”的结果它就成了永远打穿的口子。标准解法是布隆过滤器前置过滤或者把空值也缓存起来设一个较短的TTL比如1到2分钟。缓存击穿指的是一个热点key恰好过期大量并发请求瞬间落到数据库。这是最经典的“单点过期”问题。解法通常有两种一是加互斥锁让同一时间只有一个请求去DB加载数据其他请求等待二是用逻辑过期时间值里存一个过期时间戳业务发现逻辑过期后异步刷新缓存请求先返回旧值。第二种方案我越来越推荐因为它不需要线程阻塞性能损耗小。缓存雪崩指的是大量key在同一时间集体过期数据库负载瞬间被打满。这就是开头那个事故的类型。雪崩的解法核心是错峰——给每个key的过期时间加一个随机抖动或者分批次写入让过期时间错开。只要过期时间不要整整齐齐雪崩就能被按下来。3.3 抖动过期一行代码避免连环炸给key的TTL加随机抖动是性价比最高的防雪崩手段没有之一。我习惯的做法是在基础过期时间基础上加上一个0到300秒的随机偏移。比如基础TTL是5分钟最后写入时实际过期时间可能是300秒到600秒之间的任意值。int baseTTL 300; // 基础过期时间单位秒 int jitter ThreadLocalRandom.current().nextInt(300); // 0到5分钟的随机偏移 redisTemplate.expire(product:detail: productId, baseTTL jitter, TimeUnit.SECONDS);这段代码看起来简单但它解决了一个非常现实的问题同一批key写进去时如果都是统一的5分钟过期那5分钟后的同一秒它们会集体失效。加了这个抖动key的失效时间就散开了数据库受到的冲击会平摊到一个时间段内。我在所有生产环境的缓存写入逻辑里都强制加了抖动这不是锦上添花而是防守底线。4. 缓存一致性有了过期时间不等于数据一定正确4.1 先更新数据库还是先删缓存几百字的经典争论过期时间解决的是“数据自愿接受多少陈旧度”的问题但现实中还有另一类问题数据更新了缓存该怎么办这就是缓存一致性的核心矛盾。业界最经典的策略叫Cache Aside旁路缓存具体做法是读的时候先读缓存没命中就读数据库然后回填缓存写的时候先更新数据库然后删除缓存。“先更新DB还是先删缓存”是个争论多年的问题。我直接说结论先更新数据库再删缓存是最稳妥的通用方案。原因很简单如果先删缓存、再更新数据库那么在缓存被删掉之后、数据库更新完成之前如果有请求进来读缓存发现没有就会去DB读旧数据并回填旧值——这段窗口期外加大缓存可能导致旧值长时间残留。而先更新数据库再删缓存虽然也存在“更新库还没删缓存”的短暂窗口但因为这个窗口极短毫秒级而且缓存马上就会被删除下个请求就会读到新值实际影响远小于反向操作。Redis删除缓存失败怎么办那就靠TTL兜底缓存的过期时间保证它最终会被清掉只是存在一段不一致的时间。这也是为什么即便有了删除操作TTL依然不能省——它是最后一道保险。4.2 延迟双删被低估的保命方案先更新数据库再删缓存依然有一个细微的漏洞某个请求在更新数据库之前读到了旧缓存回填了旧值而这个回填行为发生在“删缓存”之后。理论上这个旧值会在缓存里存活到过期时间产生一段不一致窗口。为了解决这个问题有个土办法叫延迟双删。操作顺序是先删除缓存更新数据库隔一小段时间比如500毫秒再删除一次缓存。第二次删除干掉的正是那些“读了旧数据回填缓存”的请求所写入的脏数据。// 伪代码延迟双删 redisTemplate.delete(cacheKey); // 第一次删 updateDatabase(newData); // 更新数据库 Thread.sleep(500); // 等待可能存在的旧值回填 redisTemplate.delete(cacheKey); // 第二次删这个方案的关键是延迟时间的把握。太短旧请求还没完成回填第二次删除就扑了个空太长等于多了500毫秒的额外耗时。我通常在单机部署、网络正常的情况下取300到500毫秒线上实测大多数场景够用。如果系统是分布式高并发延迟双删也不够就需要引入版本号或时间戳方案每次写入缓存时附带一个版本号读请求发现版本号比DB低就主动刷新。这种方案更复杂适合对一致性要求极高的场景一般的业务系统用延迟双删加TTL兜底已经足够。4.3 本地缓存加分布式缓存过期时间要分层设计很多高并发系统不会只用一个缓存而是本地缓存比如Caffeine加分布式缓存比如Redis两层配合。这带来的问题是两个层级的过期时间如果设计不当会放大不一致问题。我自己的缩略方案是“内短外长”本地缓存过期时间设短比如30秒到1分钟Redis设长比如5到10分钟。本地缓存过短是为了尽快感知到Redis和数据源的最新变化减少一级缓存里旧数据的滞留时间Redis设长是为了保证即使本地缓存全部未命中后端的请求也只会落在Redis上不会全部打到数据库。这一层过期时间差本质上是用“快速感知”换“抗冲击能力”。本地缓存和分布式缓存还有一个配合点如果Redis故障本地缓存作为最靠近应用的防线能在秒级内兜住流量但如果本地缓存的过期时间和Redis一样长那Redis一挂本地缓存就成了唯一的“旧数据来源”反而放大了陈旧风险。所以我的经验是本地缓存的主要任务不是追求高命中率而是扛瞬时穿透过期时间宁可短一些也不要为了一点点命中率牺牲一致性。5. 实战总结缓存失效定位与线上治理的几条经验5.1 排查“缓存不生效”的常规思路和检查清单线上最常见的缓存问题是明明设了TTL但某条数据怎么都不更新。排查的时候别急着怀疑Redis先按这几步走。第一步查你的key是不是真的设置了过期时间。很多人用Spring Cache的Cacheable默认是不设置过期时间的需要额外配置CacheManager的TTL。如果没配置那恭喜你这个缓存永远不会过期它就是一条“僵尸数据”。第二步查序列化方式。如果一个key在写入和读取时用的序列化方案不一致读的时候可能拿到的是乱码或旧版对象表现上就类似于“缓存不生效”。第三步查是否存在多个服务实例各自缓存了本地副本。这种情况常见于进程内缓存A服务更新了数据库B服务的本地缓存还掐着旧值不放手过期时间没到就一直展示旧数据。我建议团队把下面这些检查项固化成排查清单出问题时逐项过一遍确认缓存key的TTL是否存在是什么值确认当前命中的是Redis还是本地缓存确认更新DB后是否触发了缓存删除/更新确认删除缓存是否成功有没有删除失败的告警确认缓存的值是否使用了正确的序列化方式确认是否存在多级缓存之间的时间差5.2 C# API定时刷新缓存的一个可运行示例有些场景不依赖外部Redis而是要在API进程内维护一份带过期时间的缓存。C#里我最常用的是MemoryCache配合ChangeToken或者更轻量的ConcurrentDictionary加Timer。下面给一个简单但可跑的示例演示“带TTL的进程内缓存”。public class TtlCacheT { private readonly ConcurrentDictionarystring, CacheItemT _cache new(); private readonly TimeSpan _defaultTtl; public TtlCache(TimeSpan defaultTtl) { _defaultTtl defaultTtl; } public T GetOrAdd(string key, FuncT factory, TimeSpan? ttl null) { var expire DateTime.UtcNow (ttl ?? _defaultTtl); if (_cache.TryGetValue(key, out var item) item.ExpireAt DateTime.UtcNow) return item.Value; // 缓存失效或不存在重新加载数据 var value factory(); _cache[key] new CacheItemT(value, expire); return value; } public void Remove(string key) _cache.TryRemove(key, out _); private class CacheItemTValue { public TValue Value { get; } public DateTime ExpireAt { get; } public CacheItem(TValue value, DateTime expireAt) { Value value; ExpireAt expireAt; } } }这段代码的核心是读取时先检查当前时间有没有越过ExpireAt越过就是失效需要重新加载并回填。它本质上就是“惰性删除”的一个进程内版本。要注意的是进程内缓存的生命周期跟随API进程如果你的服务是多实例部署每个实例都会有一份自己的缓存副本那就要考虑实例间一致性和过期时间差的问题。5.3 线上缓存治理命中率、过期数量、大Key和热点Key带过期时间的缓存上线之后不是一劳永逸的。我见过太多系统跑着跑着Redis内存莫名上涨业务接口莫名变慢最后查出来都是缓存治理缺失。我建议至少盯四类指标。第一是命中率命中率长期低于80%说明缓存价值不大TTL可能设得过短或者key设计有问题。第二是过期key的数量变化如果某个时间点过期key数量骤增大概率有雪崩风险需要检查是不是有大批key用了相同的TTL。第三是大key超过几百KB的key会拖慢Redis的单线程处理速度缓存中的大对象一定要警惕最好拆分成小key或多个字段。第四是热点key某个key的访问量占到一个Redis实例总访问量的一定比例时就要考虑本地缓存或热key复制来分摊压力。这些指标配好告警之后缓存治理就成了一个持续巡检的过程。运维层面我还会定期做TTL的“值班复核”——把线上主要缓存key的TTL列个清单和业务方一起确认当前时间是否仍然合理有没有因为业务变化需要调短调长的。这事听起来不起眼但能避免很多“缓存过期时间不匹配业务需求”的隐性故障。6. 我的几点实操体会做了这么多年系统跟缓存打交道的次数数不清说几点切身的体会。第一凡是加缓存一定要想清楚“缓存失效那一刻会发生什么”。很多人只想着命中率上去了、性能对了却忘了过期那一瞬间才是系统最脆弱的时刻。第二TTL不是配置项是业务需求的数字化表达。设5分钟还是设30秒背后都是业务容忍度、成本、一致性之间的权衡。第三缓存一致性没有银弹只有组合拳——TTL加删缓存加重试加版本号一层一层地堵才能把风险压到可接受的范围。另外一个小技巧我习惯在所有缓存key的注释和文档里写上TTL值和设置原因。这个习惯救过我很多次。半年后你回来看这段代码如果只见一个magic number你根本想不起为什么是5分钟而不是10分钟。写清楚了后来人就不会乱改。缓存这东西看似简单但每一个看似微不足道的参数背后都是一次对系统稳定性的一次量化评估。
返回列表