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_RETRY=3;// ★有界自旋privatestaticfinalDurationLOCK_TTL=Duration.ofSeconds(10);privatestaticfinalDurationNULL_TTL=Duration.ofSeconds(60);/** ★官方 Distributed Locks 模式:GET 比对 token 后再 DEL,必须原子 */privatestaticfinalDefaultRedisScript<Long>UNLOCK=newDefaultRedisScript<>("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.redis=redis;this.mapper=mapper;}public<T>TgetWithMutex(Stringkey,Class<T>type,Supplier<T>dbLoader){for(intattempt=0;attempt<MAX_RETRY;attempt++){// ★循环替代递归,杜绝栈溢出// 1) 读缓存:★先识别空值占位符,再按业务类型反序列化Stringcached=redis.opsForValue().get(key);if(NULL_PLACEHOLDER.equals(cached))returnnull;// ★占位符不能当业务值返回if(cached!=null)returnreadJson(cached,type);// 2) 抢 key 级锁:★锁值 = 随机 token(持有者身份)StringlockKey="lock:"+key;Stringtoken=UUID.randomUUID().toString();Booleanlocked=redis.opsForValue().setIfAbsent(lockKey,token,LOCK_TTL);// SET NX EX 原子if(Boolean.TRUE.equals(locked)){try{// 3) 双重检查(★同样要先识别占位符)cached=redis.opsForValue().get(key);if(NULL_PLACEHOLDER.equals(cached))returnnull;if(cached!=null)returnreadJson(cached,type);// 4) 回源 DBTvalue=dbLoader.get();if(value==null){// 空值缓存只防"同一不存在 key 的重复回源";// ★对高基数枚举攻击无效——随机 key 每个都占 60s 内存,// 防枚举靠布隆 + 参数校验 + 限流,不靠空值缓存redis.opsForValue().set(key,NULL_PLACEHOLDER,NULL_TTL);returnnull;}// TTL 打散:基础时间 + 随机抖动longttl=1800+ThreadLocalRandom.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(50+ThreadLocalRandom.current().nextInt(30));// ★抖动退避}// 6) ★自旋超限降级直查 DB——生产上此路径必须前置单机限流(如 RateLimiter),// 否则锁整体失效时等于全体穿透returndbLoader.get();}private<T>TreadJson(Stringjson,Class<T>type){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 次写入privatefinalConcurrentHashMap<String,CompletableFuture<Object>>inflight=newConcurrentHashMap<>();CompletableFuture<Object>f=inflight.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:
- 容量规划按单分片 5~8 万 QPS 做(这是生产规划保守值,不是 Redis 能力上限:官方 redis-benchmark 简单命令 10 万+ ops/s,pipeline 下百万级;真实瓶颈在 value 大小、命令复杂度、网络 RTT)。
- 解法优先级:Caffeine 本地缓存 > key 多副本(
key_0..key_N,读随机一份、写全部或写一份异步扩散,注意写放大)>读写分离(从节点承担读)。 - 检测:
redis-cli --hotkeys(前提:maxmemory-policy 必须是 LFU,否则返回空)、云厂商控制台热 key 分析、客户端埋点采样。
Big Key:
- 经验阈值(非官方硬标准,参考阿里云规范):String ≤ 10KB、集合类元素数 ≤ 5000 量级。
- 删除用
UNLINK(4.0+,异步释放);4.0 之前用HSCAN/SSCAN分批渐进删;可配lazyfree-lazy-server-del让服务端 DEL 等价 UNLINK,lazyfree-lazy-eviction / lazyfree-lazy-expire让淘汰和过期也走异步释放。 - Big Key 拖慢主从同步、RDB、AOF rewrite(fork 后 copy-on-write 压力)。巡检:
redis-cli --bigkeys(SCAN 采样,结果是近似值,不是全集)、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): binlog(Canal/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 下须同 slot) |
| Big 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-lfu(4.0+,偶发的批量扫描不会把真热点挤掉,比 LRU 更适合缓存)。- 组合陷阱:
volatile-*系列只淘汰设了 TTL 的 key。若同时采用“热点 key 永不过期”策略,这些热点变成不可淘汰的常驻内存——两者组合时容量规划必须按热点总量单独预留。
容量按峰值 1.5~2 倍规划,且代码必须能在缓存被淘汰后安全重建(回退到前提 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“必须池化”的心智。要配的是
commandTimeout(Spring Boot 对应spring.data.redis.timeout,2.x 为spring.redis.timeout)、connect/shutdown timeout、clientName。单条慢命令会阻塞共享连接——这是“禁 KEYS/大集合命令”的另一个理由。 commandTimeout别用默认 60s——缓存场景建议 50~200ms + 上层熔断(Resilience4j/Sentinel),Redis 抖动不许拖垮应用线程池。- Cluster 限制:多 key 命令与 Lua 涉及的 key 必须同 hash slot(否则 CROSSSLOT 报错),用 hash tag:
user:{1001}:profile;跨 slot 需应用层拆分。 KEYS/FLUSHALL生产禁用(rename-command);遍历用SCAN/HSCAN(注意 SCAN 是渐进式,全量大集合本身就是慢操作);pipeline/mget 批量优于循环单发(pipeline 非原子,单批控制数量)。
5.4 MQ 兜底的正确姿势
// 删除是幂等的(DEL 不存在的 key 无害),消息体只需带 key@RabbitListener(queues="cache.evict")publicvoidonEvict(CacheEvictMsgmsg){try{redisTemplate.delete(msg.getKey());}catch(Exceptione){if(msg.getRetry()>=MAX_RETRY){sendToDeadLetter(msg);// 超限死信 + P1 告警人工介入return;}retryWithBackoff(msg);// 指数退避重投}}要点:消息只带 key 保证幂等;重试上限 + 死信;独立监控告警(否则“长期不一致”是静默故障);MQ 自身不可用时由本地失效任务表扫描补偿。若未来消息语义从“删”演进为“写”,必须带版本号/时间戳做条件更新,幂等性不再是免费的。
六、监控告警清单
| 指标 | 取数方式 | 阈值建议 |
|---|---|---|
| 缓存命中率 | INFO stats:keyspace_hits/misses | 按业务基线定(写多读少天然低);突降比绝对值更重要(发布/攻击/淘汰信号) |
| 内存使用 | INFO memory:used_memory / maxmemory | >70% 预警;同时盯used_memory_peak |
| 淘汰量 | INFO stats:evicted_keys | 增长 = 容量不足,出现即告警(淘汰不是正常态) |
| 慢查询 | SLOWLOG GET(slowlog-log-slower-than默认 10ms) | 出现即分析;阈值可收紧到 1~10ms |
| 连接/阻塞 | INFO clients:connected_clients, blocked_clients | blocked_clients 高 = 有阻塞命令在跑 |
| 主从延迟 | master/slavemaster_repl_offset差值;redis-cli --latency | >1s 告警(直接影响延迟双删与从库读) |
| fork 延迟 | INFO stats:latest_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 多副本、UNLINK+lazyfree、拆结构 |
| 写 | 双写不一致 | DB 与缓存不同步 | afterCommit 删除 + binlog 兜底 + TTL 上限 |
| 写 | 单机回填竞态 | 读旧值晚于删除回填 | 异步延迟二次删、binlog、TTL |
| 写 | 主从回填 | 从库旧值回填 | 延迟双删 / WAIT / 该类读走主库 |
| 写 | 并发冲突 | 同 key 并发更新 | 分布式锁、版本号、Lua(Cluster 限同 slot) |
| 写 | 失效风暴 | 批量写大量失效 | 分批错峰、写后预热 |
| 写 | 删失败 | 删缓存超时 | 重试+死信+对账 |
| 全局 | 等级错配 | 容忍度 vs 方案不匹配 | 先定 L0~L3 再选型 |
| 全局 | 生命周期缺失 | 冷启动、淘汰、误删 | 预热、maxmemory/lfu 显式配置、权限收敛 |
| 全局 | Redis 丢数据 | 淘汰/宕机/异步复制 | 代码在“缓存为空”下安全运行;L2 加流水表 |
八、一句话总结
缓存设计的起点不是“如何保持一致”,而是“业务能容忍多大程度的不一致”。确定容忍度,再配置过期、淘汰、双写顺序、兜底与监控——并且始终预设每一次缓存操作都会失败、每一项配置的默认值都和你想的不一样。