ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Redis 缓存设计理解

Redis 缓存设计理解 Redis 缓存设计理解摘要本文系统梳理 Redis 缓存设计的完整方法论。核心观点缓存设计的起点不是“如何保持一致”而是“业务能容忍多大程度的不一致”。全文从读、写两个维度展开——读维度覆盖穿透、击穿、雪崩、热 Key/Big Key 四类问题的准确界定与解法写维度剖析双写策略的真实代价并给出“afterCommit 删除 binlog 兜底 TTL 上限”的三层一致性架构。同时明确一致性是有等级的L0~L3需先定等级再选方案并覆盖缓存生命周期管理、Java 工程落地坑Spring Cache、序列化、Lettuce 超时、MQ 兜底、监控告警清单与全景速查表。一句话总结始终预设每一次缓存操作都会失败、每一项配置的默认值都和你想的不一样。〇、先立四个前提比任何技巧都重要缓存永远是副本数据库才是权威。承认这一点“最终一致”就是默认答案“强一致”是需要额外付费的奢侈品。缓存一定会丢。maxmemory 淘汰、节点故障、异步复制丢写、运维 flushdb、重启任何一种都会发生。代码必须能在“缓存永远为空”时安全运行。任何一次缓存操作都可能失败。删缓存可能超时锁可能拿不到MQ 可能堆积。设计时显式写出失败分支不写 happy path。默认配置 ≠ 安全配置。Redis 关键默认值maxmemory 0不限内存、noeviction不淘汰、异步复制对缓存场景都不友好。上线前每一项显式声明不靠默认。一、读维度保护数据库1.1 四类问题的准确界定问题判据本质核心解法缓存穿透查的 key根本不存在于 DB流量绕过缓存直达 DB布隆过滤器、空值缓存、参数校验、网关限流缓存击穿单个热点 key恰好过期单点回源风暴互斥锁重建、逻辑过期、永不过期异步刷新缓存雪崩成因一大量 key 同时过期回源量整体放大TTL 打散、错峰失效、热点永不过期缓存雪崩成因二缓存层整体不可用宕机、网络分区缓存整体消失限流降级、本地缓存兜底、多可用区、客户端熔断主流文献把“同时过期”和“缓存层不可用”都归为雪崩两种成因。设计上必须分开处理TTL 打散只对成因一有效对成因二毫无作用——所以两者要分开设计和演练不要混为一谈。1.2 互斥锁重建防击穿ComponentpublicclassCacheRebuilder{/** 空值占位符合法 JSON 不会以控制字符开头与业务值天然可区分 */privatestaticfinalStringNULL_PLACEHOLDER\u0000NULL;privatestaticfinalintMAX_RETRY3;// ★有界自旋privatestaticfinalDurationLOCK_TTLDuration.ofSeconds(10);privatestaticfinalDurationNULL_TTLDuration.ofSeconds(60);/** ★官方 Distributed Locks 模式GET 比对 token 后再 DEL必须原子 */privatestaticfinalDefaultRedisScriptLongUNLOCKnewDefaultRedisScript(if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end,Long.class);privatefinalStringRedisTemplateredis;// 统一存 JSON 字符串privatefinalObjectMappermapper;// 全局统一配置JavaTimeModule 等publicCacheRebuilder(StringRedisTemplateredis,ObjectMappermapper){this.redisredis;this.mappermapper;}publicTTgetWithMutex(Stringkey,ClassTtype,SupplierTdbLoader){for(intattempt0;attemptMAX_RETRY;attempt){// ★循环替代递归杜绝栈溢出// 1) 读缓存★先识别空值占位符再按业务类型反序列化Stringcachedredis.opsForValue().get(key);if(NULL_PLACEHOLDER.equals(cached))returnnull;// ★占位符不能当业务值返回if(cached!null)returnreadJson(cached,type);// 2) 抢 key 级锁★锁值 随机 token持有者身份StringlockKeylock:key;StringtokenUUID.randomUUID().toString();Booleanlockedredis.opsForValue().setIfAbsent(lockKey,token,LOCK_TTL);// SET NX EX 原子if(Boolean.TRUE.equals(locked)){try{// 3) 双重检查★同样要先识别占位符cachedredis.opsForValue().get(key);if(NULL_PLACEHOLDER.equals(cached))returnnull;if(cached!null)returnreadJson(cached,type);// 4) 回源 DBTvaluedbLoader.get();if(valuenull){// 空值缓存只防同一不存在 key 的重复回源// ★对高基数枚举攻击无效——随机 key 每个都占 60s 内存// 防枚举靠布隆 参数校验 限流不靠空值缓存redis.opsForValue().set(key,NULL_PLACEHOLDER,NULL_TTL);returnnull;}// TTL 打散基础时间 随机抖动longttl1800ThreadLocalRandom.current().nextLong(300);redis.opsForValue().set(key,writeJson(value),Duration.ofSeconds(ttl));returnvalue;}finally{// 5) ★Lua 比对 token 后释放只删自己的锁含异常路径redis.execute(UNLOCK,List.of(lockKey),token);}}sleepQuietly(50ThreadLocalRandom.current().nextInt(30));// ★抖动退避}// 6) ★自旋超限降级直查 DB——生产上此路径必须前置单机限流如 RateLimiter// 否则锁整体失效时等于全体穿透returndbLoader.get();}privateTTreadJson(Stringjson,ClassTtype){try{returnmapper.readValue(json,type);}catch(JsonProcessingExceptione){thrownewIllegalStateException(缓存反序列化失败,e);}}privateStringwriteJson(Objectv){try{returnmapper.writeValueAsString(v);}catch(JsonProcessingExceptione){thrownewIllegalStateException(缓存序列化失败,e);}}privatevoidsleepQuietly(longms){try{Thread.sleep(ms);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}// ★恢复中断位}}八条铁律对照 Redis 官方 Distributed Locks 文档① key 级粒度绝不全局锁②SET NX EX原子获取③锁值必须是随机 token④ 必设 TTL 防死锁——并注意 TTL 与业务耗时的矛盾慢 SQL 10s 时锁先过期互斥语义被破坏对幂等的缓存回填无害但要有监控⑤ 双重检查 占位符识别⑥释放必须 Lua 比对 token官方文档明确的唯一安全释放方式无条件 DEL 会误删他人的锁⑦ 自旋必须有界超限走降级⑧ 异常路径也释放锁finally 中断恢复。手写版的已知局限没有看门狗续期。要彻底解决锁过期与业务耗时不匹配用RedissonRLock看门狗默认 30s 锁、每 10s 自动续期lockWatchdogTimeout可调。多实例强互斥的 Redlock 有已知争议缓存重建场景单实例锁 token 已足够。锁之前更便宜的一层JVM 级 singleflight// 同 key 并发读在 JVM 内合并为一次 DB 查询N 个并发 → 1 次回源 1 次写入privatefinalConcurrentHashMapString,CompletableFutureObjectinflightnewConcurrentHashMap();CompletableFutureObjectfinflight.computeIfAbsent(key,k-CompletableFuture.supplyAsync(dbLoader::get,dbExecutor).whenComplete((r,e)-inflight.remove(k)));绝大多数热点场景本地 singleflight 就把击穿问题消掉了分布式锁只在这 1 次回源里发生甚至可以不用。1.3 逻辑过期牺牲新鲜度换可用性适用于点赞数、排行榜、热度等允许秒级延迟的场景value 自带expireAt物理永不过期。发现过期后仅单个线程tryLock 竞争重建权异步重建其余线程直接返回旧值。补充两点① 返回旧值时建议携带 stale 标记让上层可决策比如前端显示“数据更新于 N 秒前”② 重建线程池要与其他任务隔离且必须有定时刷新兜底——异步线程挂掉时逻辑过期会退化成永久脏数据。1.4 热 Key 与 Big Key热 Key容量规划按单分片 58 万 QPS 做这是生产规划保守值不是 Redis 能力上限官方 redis-benchmark 简单命令 10 万 ops/spipeline 下百万级真实瓶颈在 value 大小、命令复杂度、网络 RTT。解法优先级Caffeine 本地缓存 key 多副本key_0..key_N读随机一份、写全部或写一份异步扩散注意写放大读写分离从节点承担读。检测redis-cli --hotkeys前提maxmemory-policy 必须是 LFU否则返回空、云厂商控制台热 key 分析、客户端埋点采样。Big Key经验阈值非官方硬标准参考阿里云规范String ≤ 10KB、集合类元素数 ≤ 5000 量级。删除用UNLINK4.0异步释放4.0 之前用HSCAN/SSCAN分批渐进删可配lazyfree-lazy-server-del让服务端 DEL 等价 UNLINKlazyfree-lazy-eviction / lazyfree-lazy-expire让淘汰和过期也走异步释放。Big Key 拖慢主从同步、RDB、AOF rewritefork 后 copy-on-write 压力。巡检redis-cli --bigkeysSCAN 采样结果是近似值不是全集、MEMORY USAGE key、RDB 离线分析工具。二、写维度一致性与稳定性2.1 双写策略的真实代价策略主要问题结论先更新缓存再更新 DB并发写脏缓存DB 失败时缓存已脏无简单自愈手段❌ 不用先删缓存再更新 DB删除后至写库完成前的读请求会回填旧值默认不用配合延迟双删线上广泛使用先更新 DB再删缓存① 删失败→长期脏可自愈②单机回填竞态见下③ 主从延迟下从库旧值回填✅ 默认首选必须配兜底 TTL 上限延迟双删第二次删同样可能失败延迟时长无理论保证需 主从延迟回填窗口通常 500ms~数秒建议异步延迟队列实现不要在请求线程 sleep⚠️ 缓解手段不是解法Cache Aside 的单机竞态比主从变体更本质必须写进设计文档T1 读线程缓存 miss T2 读线程从 DB 读到旧值此时写事务尚未提交 T3 写线程更新 DB 并提交 T4 写线程删除缓存 T5 读线程把 T2 读到的旧值回填缓存 ← 脏数据诞生存活至下次失效或 TTL触发条件苛刻要求读的查库早于写提交、回填晚于删除而读通常快于写概率低但恒大于零——这才是“删缓存只能做到最终一致”的普适根因。缓解删除后异步延迟二次删、binlog 兜底、TTL 上限兜底。主从变体从库读时删缓存成功 → 从库未同步 → 读旧值回填。缓解延迟双删 / 该类读走主库 /WAIT numreplicas timeout让上一次写被 N 个从库确认后再删注意它是弱同步语义官方文档明确不能保证故障切换零丢失只缩小窗口。2.2 推荐的三层一致性架构主路径尽力而为多数场景窗口为毫秒级 写 DB事务→ 事务提交后 → best-effort 删 Redis 删本地缓存(Caffeine) 兜底一不依赖业务代码覆盖删失败/漏删/代码 bug binlogCanal/DTS→ 异步删除/更新 Redis → 天然可重试、幂等 ※ binlog 只含已提交数据天然规避事务回滚后误删——这是它比业务代码双删更可靠的根本原因 兜底二 MQ / 本地失效任务表 → 删失败重投 → 指数退避 → 超限死信 告警 最后防线 TTL 上限——任何原因的脏数据存活时间不超过 TTL责任划分业务代码在事务提交后尽力删失败不重试、只打点最终一致交给 binlog 或 MQ。一致性窗口下限 binlog 端到端延迟通常几百 ms~秒级业务路径的即时删除让多数场景窗口为毫秒级。// 主路径必须 afterCommit避免删了缓存、事务却回滚导致旧值回填TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){try{redis.delete(key);localCache.invalidate(key);// best-effort}catch(Exceptione){// 不重试交给 binlog/MQ 兜底打点告警}}});2.3 写引发的连锁问题问题触发解法失效风暴写引发雪崩批量导入/活动上线批量删缓存分批错峰删除、新 key 带随机 TTL、写后预热而非纯删除写并发冲突多线程同 key 更新分布式锁库存类、版本号乐观锁、Lua注意 Cluster 下须同 slotBig Key 写入几 MB JSON、超大集合拆 key、压缩、改分片结构热 Key 写计数器集中自增Lua 原子操作、本地聚合后批量 INCRBY 上报MQ 兜底反噬持续失败消息堆积幂等设计、指数退避、上限转死信、独立消费组隔离三、一致性是有等级的先定等级再选方案顺序不能反。等级容忍度典型场景方案L0 弱一致分钟级延迟可接受商品详情、昵称、配置、推荐Cache Aside 长 TTL 主动刷新L1 最终一致秒级不允许长期脏订单状态、物流、库存展示afterCommit 删除 binlog 异步淘汰 MQ 兜底 TTL 上限L2 近强一致不允许脏读库存扣减、秒杀、优惠券分布式锁串行化 / Lua 原子扣减 扣减流水表 异步落库 对账L3 强一致零容忍账务、计费、余额决策不用缓存做决策读写穿透 DB 事务缓存仅异步加速展示L2 的隐藏坑Lua 扣减成功但落库前 Redis 宕机 扣减记录丢失 超卖/少卖。所以扣减必须先写流水表或 Redis 持久化策略 重放落库与对账兜底而不是裸依赖 Redis 内存。事故往往源于业务要求 L2/L3架构却按 L0 设计。评审先问产品方“这个字段脏 3 秒行不行”四、缓存的生命周期阶段风险措施预热重启/活动前冷启动→击穿启动脚本/定时任务预热热点滚动发布保留部分旧实例命中正常服务多级缓存Caffeine → Redis → DB淘汰见下方专项见下方专项过期集中过期→雪崩TTL 基础值 随机抖动热点永不过期 异步刷新失效手动清理/误操作权限收敛、rename-command禁用 FLUSHALL/KEYS、命令审计空值缓存高基数枚举攻击耗内存30~60s 短 TTL只防同 key 重复回源防枚举靠布隆参数校验限流布隆过滤器有误判、不支持删除新增数据必须同步写入布隆否则新数据 100% 回源仅用于稳定且可增补的集合删除需求用 Counting Bloom/Cuckoo/定期全量重建实现选 RedissonRBloomFilter或 RedisBloom 模块BF.RESERVE可定 error_rate/capacity淘汰专项生产事故高发区显式核对三项maxmemory默认0 不限内存——淘汰机制根本不生效会把机器内存吃满直到 OOM killer。必须显式设置。maxmemory-policy默认noeviction——内存打满后写命令直接报 OOM error不会淘汰。纯缓存场景推荐allkeys-lfu4.0偶发的批量扫描不会把真热点挤掉比 LRU 更适合缓存。组合陷阱volatile-*系列只淘汰设了 TTL 的 key。若同时采用“热点 key 永不过期”策略这些热点变成不可淘汰的常驻内存——两者组合时容量规划必须按热点总量单独预留。容量按峰值 1.52 倍规划且代码必须能在缓存被淘汰后安全重建回退到前提 2。五、Java 工程落地的具体坑5.1 Spring Cache 注解null 缓存修正Spring Data Redis 的RedisCacheConfiguration默认就是cacheNullValues true配 JDK 序列化开箱即用。真正的坑是换GenericJackson2JsonRedisSerializer后缓存 null 抛Cannot construct instance of NullValue——大量团队为消掉这个报错直接disableCachingNullValues()防穿透能力随之静默失效。正确姿势保留 null 缓存让序列化器认识 NullValue如 String 序列化存占位符。结论从“记得打开”改为“别轻易关掉”。默认 TTL 是永不过期entryTtl Duration.ZERO→ 脏数据无上界 内存无上界必须显式entryTtl。CacheEvict/CachePut在方法返回时即执行早于外层事务提交→ 事务回滚后缓存与 DB 不一致。改 afterCommit 或手动控制。SpEL 写错编译期不报错运行时静默失效。建议核心链路弃用注解手动控制缓存读写顺序。5.2 序列化JDK 原生序列化体积大、CPU 高、跨版本兼容差不用。推荐统一StringRedisTemplate 全局 ObjectMapper注册 JavaTimeModule、显式类型读时mapper.readValue(json, type)。缓存层最大的真实坑是类型信息丢失值经Object反序列化回来是LinkedHashMap强转必炸ClassCastException上一版示例的type参数没用到也没人保证 get 回来能 cast。GenericJackson2JsonRedisSerializer存class可解但开启 default typing 有反序列化攻击面缓存内容必须可信或限制类型白名单。Long/BigDecimal 精度问题的边界要认清这是“JSON 流经 JS 客户端”的HTTP 出口层问题Java→Redis→Java 的 Jackson 往返不丢精度。别在缓存层错误地修应在 DTO/出口层转字符串。另外 BigDecimal 默认序列化为 JSON数字而非字符串需要时才显式配字符串。5.3 客户端与超时Lettuce 心智模型非阻塞命令共享单条 native 连接Netty线程安全连接池只服务于阻塞命令BLPOP 等或需要独立连接状态的操作——别照搬 Jedis“必须池化”的心智。要配的是commandTimeoutSpring Boot 对应spring.data.redis.timeout2.x 为spring.redis.timeout、connect/shutdown timeout、clientName。单条慢命令会阻塞共享连接——这是“禁 KEYS/大集合命令”的另一个理由。commandTimeout别用默认 60s——缓存场景建议 50~200ms 上层熔断Resilience4j/SentinelRedis 抖动不许拖垮应用线程池。Cluster 限制多 key 命令与 Lua 涉及的 key 必须同 hash slot否则 CROSSSLOT 报错用 hash taguser:{1001}:profile跨 slot 需应用层拆分。KEYS/FLUSHALL生产禁用rename-command遍历用SCAN/HSCAN注意 SCAN 是渐进式全量大集合本身就是慢操作pipeline/mget 批量优于循环单发pipeline 非原子单批控制数量。5.4 MQ 兜底的正确姿势// 删除是幂等的DEL 不存在的 key 无害消息体只需带 keyRabbitListener(queuescache.evict)publicvoidonEvict(CacheEvictMsgmsg){try{redisTemplate.delete(msg.getKey());}catch(Exceptione){if(msg.getRetry()MAX_RETRY){sendToDeadLetter(msg);// 超限死信 P1 告警人工介入return;}retryWithBackoff(msg);// 指数退避重投}}要点消息只带 key 保证幂等重试上限 死信独立监控告警否则“长期不一致”是静默故障MQ 自身不可用时由本地失效任务表扫描补偿。若未来消息语义从“删”演进为“写”必须带版本号/时间戳做条件更新幂等性不再是免费的。六、监控告警清单指标取数方式阈值建议缓存命中率INFO statskeyspace_hits/misses按业务基线定写多读少天然低突降比绝对值更重要发布/攻击/淘汰信号内存使用INFO memoryused_memory / maxmemory70% 预警同时盯used_memory_peak淘汰量INFO statsevicted_keys增长 容量不足出现即告警淘汰不是正常态慢查询SLOWLOG GETslowlog-log-slower-than默认 10ms出现即分析阈值可收紧到 1~10ms连接/阻塞INFO clientsconnected_clients, blocked_clientsblocked_clients 高 有阻塞命令在跑主从延迟master/slavemaster_repl_offset差值redis-cli --latency1s 告警直接影响延迟双删与从库读fork 延迟INFO statslatest_fork_usec大值 RDB/AOF rewrite 卡顿源缓存 vs DB 对账定时抽样比对差异 0 告警MQ 堆积/死信消费组监控持续增长即告警Big/Hot Key--bigkeys近似/--hotkeys需 LFU/ RDB 离线分析每周巡检报告七、全景速查表维度问题判据解法读穿透key 在 DB 也不存在布隆 空值缓存 参数校验 限流读击穿单热点 key 过期singleflight 有界互斥锁 / 逻辑过期 / 永不过期异步刷新读雪崩过期大量 key 同时过期TTL 打散、错峰读雪崩不可用Redis 宕机限流降级、本地缓存、多 AZ、客户端熔断读热/Big Key单分片压力大、value 过大Caffeine、key 多副本、UNLINKlazyfree、拆结构写双写不一致DB 与缓存不同步afterCommit 删除 binlog 兜底 TTL 上限写单机回填竞态读旧值晚于删除回填异步延迟二次删、binlog、TTL写主从回填从库旧值回填延迟双删 / WAIT / 该类读走主库写并发冲突同 key 并发更新分布式锁、版本号、LuaCluster 限同 slot写失效风暴批量写大量失效分批错峰、写后预热写删失败删缓存超时重试死信对账全局等级错配容忍度 vs 方案不匹配先定 L0~L3 再选型全局生命周期缺失冷启动、淘汰、误删预热、maxmemory/lfu 显式配置、权限收敛全局Redis 丢数据淘汰/宕机/异步复制代码在“缓存为空”下安全运行L2 加流水表八、一句话总结缓存设计的起点不是“如何保持一致”而是“业务能容忍多大程度的不一致”。确定容忍度再配置过期、淘汰、双写顺序、兜底与监控——并且始终预设每一次缓存操作都会失败、每一项配置的默认值都和你想的不一样。
返回列表