欢迎你来读这篇博客,这篇博客主要是关于 Redis 缓存场景中的几个核心问题:缓存一致性、缓存穿透、缓存击穿、缓存雪崩、HotKey 治理。
这几个问题看起来像面试八股,实际上都是线上系统的老熟人。它们不一定每天出现,但一旦出现,就会像半夜两点的报警电话一样,礼貌,但致命。
序言 缓存的本质,是为了性能主动引入的一份副本 。
数据库是事实源,缓存是加速层。只要系统里同时存在两份数据,就一定会面对一个问题:
这两份数据什么时候一致?什么时候不一致?不一致时业务能不能接受?
很多缓存事故,并不是因为 Redis 不够快,而是因为我们把“缓存是一份副本”这件事忘了。缓存不是数据库的替身,它是数据库的减压阀、缓冲层、加速器。用好了,接口丝滑;用不好,数据库直接表演原地去世。
本文从几个典型问题展开:
缓存一致性:缓存回溯逻辑、双写策略、读更新写删除、延迟双删、删除重试、异步更新、MQ、binlog、lease 租约
缓存穿透:Bloom Filter、Cuckoo Filter、空值缓存、参数校验
缓存击穿:Redis NX 锁、逻辑过期、多级缓存、singleflight、lease
HotKey:发现、治理、多副本、本地缓存、拆分、限流、降级
本文偏 Java/Spring/Redis 实战,示例尽量能直接改造进项目里。
一、缓存系统的基础模型 1.1 什么是缓存? 缓存是一个高速数据层,用来保存一部分常访问数据,让后续请求不必每次都访问慢速存储,例如数据库、远程接口、文件系统。
一个典型的后端系统可能是这样:
flowchart TD
Client[客户端] --> Gateway[网关]
Gateway --> App[业务应用]
App --> LocalCache[本地缓存 Caffeine]
LocalCache --> Redis[分布式缓存 Redis]
Redis --> DB[(数据库 MySQL/PostgreSQL)]
从快到慢,大概是:
1 本地缓存 > Redis > 数据库 > 远程接口
但从一致性和权威性来看,顺序反过来:
也就是说,越靠近应用,越快,也越“不权威”。
1.2 Cache-Aside:最常见的缓存模式 最常见的缓存模式是 Cache-Aside ,也叫旁路缓存、懒加载缓存。
读流程:
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant Cache as Redis
participant DB as 数据库
Client->>App: 查询商品详情 productId=1001
App->>Cache: GET product:detail:1001
alt 缓存命中
Cache-->>App: 返回商品详情
App-->>Client: 返回结果
else 缓存未命中
App->>DB: SELECT * FROM product WHERE id = 1001
DB-->>App: 返回商品详情
App->>Cache: SET product:detail:1001 value EX 600
App-->>Client: 返回结果
end
写流程:
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as 数据库
participant Cache as Redis
Client->>App: 修改商品价格
App->>DB: UPDATE product SET price = 99
DB-->>App: 提交成功
App->>Cache: DEL product:detail:1001
App-->>Client: 修改成功
一句话总结:
读时缓存未命中,就回源数据库并回填缓存;写时先改数据库,再删除缓存。
这就是后面所有问题的起点。
二、缓存一致性 2.1 什么是缓存一致性? 缓存一致性指的是:数据库中的真实数据发生变化后,缓存中的副本也要尽快失效或更新,避免用户长期读到旧数据。
举个例子:
1 2 数据库:商品 1001 的价格 = 89 Redis:商品 1001 的价格 = 99
这时用户看到的价格就是脏数据。
对于缓存一致性,先讲一个大实话:
绝大多数互联网业务追求的是最终一致性,不是强一致性。
如果你做的是余额、支付、库存扣减、财务结算这类强一致业务,缓存只能辅助展示,不能作为最终判断依据。比如“本期应结金额”“本期已结金额”这种财务数据,最终口径应该来自数据库或结算快照,Redis 最多做查询加速。别让缓存背锅,它只是个打工的。
2.2 缓存回溯逻辑:Cache Backfill / Read Repair 缓存回溯逻辑,也可以理解为:缓存未命中时,应用回源数据库,然后把结果写回缓存。
flowchart TD
A[读请求] --> B{Redis 是否命中?}
B -->|命中| C[直接返回缓存]
B -->|未命中| D[查询数据库]
D --> E{数据库是否存在?}
E -->|存在| F[写入 Redis]
E -->|不存在| G[写入空值缓存或直接返回空]
F --> H[返回数据]
G --> H
这个过程看起来简单,但有几个关键细节:
回填缓存必须设置 TTL。
TTL 最好加随机抖动,避免同一批 key 同时过期。
数据不存在时,要考虑空值缓存,防止缓存穿透。
热点 key 回填时要防止并发重建,避免缓存击穿。
回填缓存时要注意旧值覆盖新值的问题。
基础代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Service @RequiredArgsConstructor public class ProductQueryService { private static final String NULL_VALUE = "__NULL__" ; private final StringRedisTemplate stringRedisTemplate; private final ProductRepository productRepository; public ProductDTO getProduct (Long productId) { String key = "product:detail:" + productId; String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { if (NULL_VALUE.equals(json)) { return null ; } return JsonUtils.toBean(json, ProductDTO.class); } ProductDTO product = productRepository.findById(productId); if (product == null ) { stringRedisTemplate.opsForValue().set(key, NULL_VALUE, Duration.ofSeconds(60 )); return null ; } Duration ttl = Duration.ofMinutes(10 ).plusSeconds(ThreadLocalRandom.current().nextLong(30 , 180 )); stringRedisTemplate.opsForValue().set(key, JsonUtils.toJson(product), ttl); return product; } }
这个逻辑就是最基础的“缓存回溯”。
2.3 双写策略:数据库和缓存到底谁先写? 缓存一致性最容易被问的问题是:
数据库和缓存双写,应该先写谁?
常见方案如下。
策略
流程
问题
是否推荐
先写缓存,再写数据库
SET Cache -> UPDATE DB
DB 写失败会导致缓存脏数据
不推荐
先写数据库,再写缓存
UPDATE DB -> SET Cache
并发写下旧值可能覆盖新值
谨慎使用
先删缓存,再写数据库
DEL Cache -> UPDATE DB
并发读可能把旧 DB 值写回缓存
不推荐单独使用
先写数据库,再删缓存
UPDATE DB -> DEL Cache
删除失败会短暂脏读,需要重试兜底
推荐
写数据库,异步更新缓存
UPDATE DB -> MQ/binlog -> SET/DEL Cache
有延迟,链路复杂
适合复杂场景
工程上最常用的是:
注意,是删除缓存,不是更新缓存。
2.4 为什么推荐“更新数据库后删除缓存”? 因为缓存往往不是数据库单表的一行数据。
比如商品详情缓存可能包含:
1 2 3 4 5 6 7 8 商品基础信息 商品价格 库存 店铺信息 优惠券 活动状态 是否收藏 用户权限
如果写操作每次都去“更新缓存”,你会遇到很多麻烦:
需要重复拼装复杂缓存对象。
多个写入口都要维护同一套缓存逻辑。
并发写时旧值可能覆盖新值。
有些字段来自远程服务,更新成本高。
写多读少的数据会制造大量无效缓存。
删除缓存则简单得多:
1 我不确定缓存怎么构造,但我知道这个缓存已经不可信了,删掉它。
读请求下次进来,再自然回源数据库重建。
1 2 3 4 5 @Transactional public void updateProductPrice (Long productId, BigDecimal newPrice) { productRepository.updatePrice(productId, newPrice); redisTemplate.delete("product:detail:" + productId); }
这就是典型的“读更新,写删除”:
1 2 读:缓存没有就查 DB,然后更新缓存。 写:先写 DB,然后删除缓存。
2.5 “先删缓存再写数据库”的并发问题 假设线程 A 修改商品,线程 B 查询商品。
sequenceDiagram
participant A as 线程A 写请求
participant B as 线程B 读请求
participant Cache as Redis
participant DB as 数据库
A->>Cache: DEL product:detail:1001
B->>Cache: GET product:detail:1001,未命中
B->>DB: 查询数据库,读到旧值 price=99
A->>DB: 更新数据库 price=89
B->>Cache: SET product:detail:1001 price=99
最终结果:
1 2 数据库 price=89 缓存 price=99
缓存又被旧值回填了。
这就是为什么不推荐单独使用“先删缓存,再更新数据库”。
2.6 “先写数据库再删缓存”的并发问题 推荐方案也不是绝对无敌,它也有极端并发问题。
sequenceDiagram
participant B as 线程B 读请求
participant A as 线程A 写请求
participant Cache as Redis
participant DB as 数据库
B->>Cache: GET 未命中
B->>DB: SELECT 读到旧值 price=99
A->>DB: UPDATE price=89
A->>Cache: DEL product:detail:1001
B->>Cache: SET product:detail:1001 price=99
最终还是可能旧值回写。
但这个场景发生概率比较低,因为需要满足:
读请求先缓存未命中。
读请求查询数据库比较慢。
写请求在读请求查询 DB 后完成更新和删除。
读请求最后才把旧值写回缓存。
所以工程上常用它,再配合 TTL、延迟双删、删除重试兜底。
2.7 延迟双删 延迟双删的流程:
1 2 3 4 删除缓存 更新数据库 等待一小段时间 再次删除缓存
流程图:
sequenceDiagram
participant App as 应用服务
participant Cache as Redis
participant DB as 数据库
participant Delay as 延迟任务/MQ
App->>Cache: 第一次 DEL
App->>DB: UPDATE DB
DB-->>App: 提交成功
App->>Delay: 投递延迟删除任务
Delay->>Cache: 第二次 DEL
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Transactional public void updateProduct (ProductUpdateCommand command) { String key = "product:detail:" + command.getProductId(); redisTemplate.delete(key); productRepository.update(command); cacheDeleteMessageProducer.sendDelayDeleteMessage(key, Duration.ofMillis(500 )); }
延迟删除消费者:
1 2 3 4 5 6 7 8 9 10 11 @Component @RequiredArgsConstructor public class CacheDelayDeleteConsumer { private final StringRedisTemplate stringRedisTemplate; @RabbitListener(queues = "cache.delay.delete.queue") public void consume (CacheDeleteMessage message) { stringRedisTemplate.delete(message.getCacheKey()); } }
注意,不建议业务线程里直接 Thread.sleep(500)。
原因很简单:业务线程不是闹钟,它还有正经事要做。
更推荐用:
RocketMQ 延迟消息
RabbitMQ 延迟队列
Kafka + 定时扫描
Redis ZSet 延迟队列
本地调度器兜底
2.8 缓存删除失败怎么办?删除重试机制 “更新 DB 后删除缓存”有一个很现实的问题:
例如:
Redis 短暂网络抖动
Redis 主从切换
应用超时
Redis 连接池耗尽
如果没有重试,缓存可能一直是旧值,直到 TTL 到期。
所以删除缓存要有重试机制。
方案一:同步重试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void deleteCacheWithRetry (String key) { int maxRetry = 3 ; for (int i = 1 ; i <= maxRetry; i++) { try { Boolean deleted = redisTemplate.delete(key); if (Boolean.TRUE.equals(deleted)) { return ; } } catch (Exception e) { log.warn("delete cache failed, key={}, retry={}" , key, i, e); } sleep(50L * i); } cacheDeleteRetryRepository.save(CacheDeleteRetryRecord.of(key)); }
同步重试适合偶发失败,但不要无限重试,否则会拖慢写接口。
方案二:重试表 建一张缓存删除重试表:
1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE cache_delete_retry_record ( id BIGINT PRIMARY KEY, cache_key VARCHAR (256 ) NOT NULL , biz_type VARCHAR (64 ) NOT NULL , retry_count INT NOT NULL DEFAULT 0 , max_retry_count INT NOT NULL DEFAULT 10 , next_retry_time TIMESTAMP NOT NULL , status VARCHAR (32 ) NOT NULL , error_msg TEXT, create_time TIMESTAMP NOT NULL , update_time TIMESTAMP NOT NULL );
写操作中记录失败任务:
1 2 3 4 5 6 7 8 9 10 11 @Transactional public void updateProduct (ProductUpdateCommand command) { productRepository.update(command); String cacheKey = "product:detail:" + command.getProductId(); try { redisTemplate.delete(cacheKey); } catch (Exception e) { cacheDeleteRetryRepository.save(CacheDeleteRetryRecord.failed(cacheKey, "PRODUCT_DETAIL" , e)); } }
定时任务重试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Scheduled(fixedDelay = 5000) public void retryDeleteCache () { List<CacheDeleteRetryRecord> records = cacheDeleteRetryRepository.findExecutableRecords(LocalDateTime.now()); for (CacheDeleteRetryRecord record : records) { try { redisTemplate.delete(record.getCacheKey()); record.markSuccess(); } catch (Exception e) { record.markFailed(e.getMessage()); } cacheDeleteRetryRepository.save(record); } }
这个方案非常适合重要业务。别嫌它土,能救命的方案通常都不花哨。
方案三:Outbox Pattern 如果你已经用了消息队列,可以把“缓存删除事件”作为本地事务的一部分写入 outbox 表。
sequenceDiagram
participant App as 应用服务
participant DB as 数据库
participant Outbox as Outbox 表
participant Publisher as 消息发布器
participant MQ as 消息队列
participant Consumer as 缓存消费者
participant Redis as Redis
App->>DB: 更新业务数据
App->>Outbox: 插入 CacheInvalidationEvent
DB-->>App: 本地事务提交
Publisher->>Outbox: 扫描未发送事件
Publisher->>MQ: 发送缓存失效消息
MQ->>Consumer: 消费消息
Consumer->>Redis: DEL cacheKey
优点:
业务数据和事件记录在一个本地事务里提交。
消息发送失败可以重试。
消费失败可以重试。
可以追踪每个缓存删除事件。
Outbox 表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 CREATE TABLE cache_event_outbox ( id BIGINT PRIMARY KEY, event_type VARCHAR (64 ) NOT NULL , aggregate_type VARCHAR (64 ) NOT NULL , aggregate_id VARCHAR (64 ) NOT NULL , cache_key VARCHAR (256 ) NOT NULL , payload TEXT, status VARCHAR (32 ) NOT NULL , retry_count INT NOT NULL DEFAULT 0 , next_retry_time TIMESTAMP NOT NULL , create_time TIMESTAMP NOT NULL , update_time TIMESTAMP NOT NULL );
2.9 异步更新缓存:MQ 模式 有些场景不想让读请求重建缓存,而是写操作后异步更新缓存。
例如首页配置、商品聚合详情、排行榜、活动会场页。
sequenceDiagram
participant App as 应用服务
participant DB as 数据库
participant MQ as MQ
participant Consumer as 缓存构建消费者
participant Redis as Redis
App->>DB: 更新商品/活动数据
DB-->>App: 提交成功
App->>MQ: 发送 ProductChangedEvent
MQ->>Consumer: 消费变更事件
Consumer->>DB: 查询最新聚合数据
Consumer->>Redis: SET 最新缓存
注意这里有两种做法:
做法
操作
适合场景
异步删除缓存
消费消息后 DEL cacheKey
通用,简单,推荐
异步更新缓存
消费消息后 SET newValue
热点、首页、榜单、预计算数据
异步更新缓存的问题是:
消息可能乱序。
消息可能重复。
消费可能失败。
旧事件可能覆盖新缓存。
解决方式:缓存值里带版本号。
1 2 3 4 5 6 7 8 { "version" : 1024 , "data" : { "id" : 1001 , "name" : "MacBook Pro" , "price" : 12999 } }
更新缓存前判断版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 local current = redis.call('GET' , KEYS[1 ])if not current then redis.call('SET' , KEYS[1 ], ARGV[2 ], 'EX' , ARGV[3 ]) return 1 end local currentVersion = tonumber (cjson.decode(current)['version' ])local newVersion = tonumber (ARGV[1 ])if newVersion >= currentVersion then redis.call('SET' , KEYS[1 ], ARGV[2 ], 'EX' , ARGV[3 ]) return 1 end return 0
版本号可以来自:
数据库 update_time
数据库自增版本号 version
binlog 位点
业务事件序号
2.10 binlog / CDC 更新缓存 如果业务系统很多,写入口也很多,那么靠每个业务代码手动删缓存,很容易漏。
这时可以用 binlog / CDC。
典型链路:
flowchart LR
DB[(MySQL/PostgreSQL)] --> CDC[Debezium/Canal/Flink CDC]
CDC --> MQ[Kafka/RocketMQ]
MQ --> Consumer[缓存失效消费者]
Consumer --> Redis[Redis DEL/SET]
例如 MySQL:
1 MySQL binlog -> Debezium MySQL Connector -> Kafka Topic -> Cache Consumer -> Redis
PostgreSQL:
1 PostgreSQL WAL -> Debezium PostgreSQL Connector -> Kafka Topic -> Cache Consumer -> Redis
这种方案的优点:
不侵入业务代码或低侵入。
能覆盖多服务、多写入口。
数据库提交后统一发出变更事件。
适合做搜索索引、缓存、数据同步、审计日志。
缺点:
链路更复杂。
延迟不可避免。
需要处理乱序、重复、失败。
要维护表到缓存 key 的映射关系。
例如商品表变更后,可能需要删除多个缓存:
1 2 3 4 product:detail:1001 product:list:shop:2001:page:1 product:recommend:user:3001 activity:product:1001
所以 CDC 消费者里通常要做一层路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Component public class ProductChangedCacheInvalidator { public List<String> resolveCacheKeys (ProductChangedEvent event) { Long productId = event.getProductId(); Long shopId = event.getShopId(); List<String> keys = new ArrayList <>(); keys.add("product:detail:" + productId); keys.add("product:price:" + productId); keys.add("shop:products:" + shopId + ":*" ); return keys; } }
注意:生产环境慎用 KEYS pattern 删除通配缓存,应该维护索引集合,或者使用异步扫描。
2.11 Lease 租约:防止并发回源和旧值回写 Lease 租约可以理解为:
缓存未命中时,缓存系统只给一个请求发“回源许可证”。拿到许可证的人负责查询数据库并写回缓存,其他人等待或返回旧值。
Facebook 在大规模 Memcache 实践中使用 lease 来处理 stale set 和 thundering herd 问题。它的核心思想不是让所有请求都回源,而是让一个请求拿到租约,其他请求别添乱。
流程:
sequenceDiagram
participant A as 请求A
participant B as 请求B
participant Cache as Redis/Memcache
participant DB as 数据库
A->>Cache: GET product:detail:1001
Cache-->>A: MISS + leaseToken
B->>Cache: GET product:detail:1001
Cache-->>B: MISS,无 lease,等待/重试
A->>DB: 查询数据库
DB-->>A: 返回最新数据
A->>Cache: 带 leaseToken SET 缓存
B->>Cache: 再次 GET
Cache-->>B: 命中缓存
Redis 里可以用 SET key value NX PX timeout 模拟租约。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public ProductDTO queryWithLease (Long productId) { String dataKey = "product:detail:" + productId; String leaseKey = "lease:product:detail:" + productId; String json = stringRedisTemplate.opsForValue().get(dataKey); if (StringUtils.hasText(json)) { return JsonUtils.toBean(json, ProductDTO.class); } String token = UUID.randomUUID().toString(); Boolean leased = stringRedisTemplate.opsForValue() .setIfAbsent(leaseKey, token, Duration.ofSeconds(3 )); if (Boolean.TRUE.equals(leased)) { try { ProductDTO product = productRepository.findById(productId); if (product != null ) { stringRedisTemplate.opsForValue().set(dataKey, JsonUtils.toJson(product), Duration.ofMinutes(10 )); } return product; } finally { releaseLease(leaseKey, token); } } sleep(30 ); return queryWithLease(productId); }private void releaseLease (String leaseKey, String token) { String lua = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ ; stringRedisTemplate.execute( new DefaultRedisScript <>(lua, Long.class), Collections.singletonList(leaseKey), token ); }
Lease 和普通 Redis 锁很像,但语义更偏缓存:
机制
关注点
分布式锁
保护临界区,避免并发执行
Lease 租约
控制缓存 miss 后谁能回源和回填
singleflight
单进程内合并相同 key 的并发请求
实践建议:
1 2 3 单机内:singleflight 多实例间:Redis NX 锁 / lease 极热点:逻辑过期 + 本地缓存 + 异步刷新
三、缓存穿透 3.1 什么是缓存穿透? 缓存穿透指的是:
1 请求查询的数据,在缓存中不存在,在数据库中也不存在。
比如有人疯狂请求:
1 2 3 4 /product/-1 /product/0 /product/999999999999 /user/abc
每次请求都是:
1 2 3 4 查 Redis -> 没有 查 DB -> 也没有 不写缓存 下次继续打 DB
数据库心里话:你礼貌吗?
3.2 方案一:参数校验 很多穿透请求,根本不应该进缓存层。
1 2 3 4 5 6 7 @GetMapping("/products/{id}") public ProductDTO getProduct (@PathVariable Long id) { if (id == null || id <= 0 ) { throw new IllegalArgumentException ("非法商品 ID" ); } return productService.getProduct(id); }
校验内容包括:
ID 必须为正整数
code 长度限制
枚举值是否合法
时间范围是否合法
分页大小限制
用户是否有权限访问
3.3 方案二:空值缓存 数据库查不到,也写一个短 TTL 的空值。
1 SET product:detail:999999 __NULL__ EX 60
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public ProductDTO getProductWithNullCache (Long id) { String key = "product:detail:" + id; String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return "__NULL__" .equals(json) ? null : JsonUtils.toBean(json, ProductDTO.class); } ProductDTO product = productRepository.findById(id); if (product == null ) { stringRedisTemplate.opsForValue().set(key, "__NULL__" , Duration.ofSeconds(60 )); return null ; } stringRedisTemplate.opsForValue().set(key, JsonUtils.toJson(product), Duration.ofMinutes(10 )); return product; }
空值缓存 TTL 不要太长,否则新数据创建后可能短时间查不到。
建议:
1 2 3 普通空值:30 秒到 5 分钟 恶意攻击明显:配合限流和黑名单 核心业务:空值缓存 + 数据变更主动删除
3.4 方案三:Bloom Filter Bloom Filter 是一种概率型数据结构,用来判断一个元素是否可能存在于集合中。
它的特点:
也就是说,Bloom Filter 会有误判,但不会漏判。
适合场景:
商品 ID 是否存在
用户 ID 是否存在
订单号是否存在
黑名单判断
大量非法 ID 防穿透
流程:
flowchart TD
A[请求 productId=1001] --> B{BloomFilter 判断是否可能存在?}
B -->|一定不存在| C[直接返回空,不查 Redis/DB]
B -->|可能存在| D[查询 Redis]
D -->|命中| E[返回缓存]
D -->|未命中| F[查询数据库]
F --> G[回填缓存]
RedisBloom 命令示例:
1 2 3 BF.RESERVE product:bloom 0.001 1000000 BF.ADD product:bloom 1001 BF.EXISTS product:bloom 1001
Java 伪代码:
1 2 3 4 5 6 7 8 public ProductDTO getProduct (Long productId) { boolean mayExist = bloomFilter.mightContain(productId); if (!mayExist) { return null ; } return getProductWithCache(productId); }
使用 Bloom Filter 要注意:
要预估容量和误判率。
数据新增时要同步加入 Bloom Filter。
删除不友好,传统 Bloom Filter 不适合频繁删除。
Bloom Filter 丢失后需要重建。
它只能减少无效请求,不能替代权限校验。
3.5 方案四:Cuckoo Filter 布谷鸟过滤器 Cuckoo Filter 也是概率型集合判断结构,和 Bloom Filter 类似,但它支持删除元素,某些场景下空间效率和查询性能也更好。
它的特点:
特性
Bloom Filter
Cuckoo Filter
判断不存在
一定不存在
一定不存在
判断存在
可能存在
可能存在
误判
有
有
删除
传统 Bloom 不支持直接删除
支持删除
适合场景
大量新增,少删除
有新增也有删除
RedisBloom 中 Cuckoo Filter 命令示例:
1 2 3 4 CF.RESERVE product:cuckoo 1000000 CF.ADD product:cuckoo 1001 CF.EXISTS product:cuckoo 1001 CF.DEL product:cuckoo 1001
如果你的业务里 ID 会频繁删除,比如临时活动、短期 token、优惠券资格、动态黑名单,Cuckoo Filter 会比传统 Bloom Filter 更自然。
防穿透组合拳:
flowchart TD
A[请求进入] --> B{参数是否合法?}
B -->|否| C[拒绝]
B -->|是| D{Bloom/Cuckoo 是否可能存在?}
D -->|一定不存在| E[返回空]
D -->|可能存在| F{Redis 是否命中?}
F -->|命中| G[返回]
F -->|未命中| H[查询 DB]
H --> I{DB 是否存在?}
I -->|否| J[写空值缓存]
I -->|是| K[写正常缓存]
J --> L[返回]
K --> L
四、缓存击穿 4.1 什么是缓存击穿? 缓存击穿指的是:
1 某个热点 key 在高并发访问时突然过期,大量请求同时打到数据库。
例如:
平时 QPS 很高,但都命中 Redis。某一秒 key 过期,所有请求同时发现缓存不存在,于是一起查数据库。
flowchart TD
A[大量请求] --> B{Redis 热点 key 过期}
B --> C[请求1 查 DB]
B --> D[请求2 查 DB]
B --> E[请求3 查 DB]
B --> F[请求N 查 DB]
C --> G[(数据库压力暴涨)]
D --> G
E --> G
F --> G
这就是典型的缓存击穿。
4.2 方案一:Redis NX 锁 思路:
1 2 缓存未命中后,只有抢到锁的请求可以查询数据库并重建缓存。 其他请求等待、重试,或者返回旧值。
Redis 命令:
1 SET lock:product:detail:1001 uuid NX EX 10
含义:
NX:key 不存在时才设置成功
EX 10:锁 10 秒后自动过期
uuid:锁持有者标识,释放锁时要校验
完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 @Service @RequiredArgsConstructor public class ProductCacheService { private static final String NULL_VALUE = "__NULL__" ; private final StringRedisTemplate stringRedisTemplate; private final ProductRepository productRepository; public ProductDTO queryWithMutex (Long id) { String cacheKey = "product:detail:" + id; String lockKey = "lock:product:detail:" + id; String json = stringRedisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return NULL_VALUE.equals(json) ? null : JsonUtils.toBean(json, ProductDTO.class); } String token = UUID.randomUUID().toString(); boolean locked = tryLock(lockKey, token, Duration.ofSeconds(10 )); if (!locked) { sleep(50 ); return queryWithMutex(id); } try { json = stringRedisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return NULL_VALUE.equals(json) ? null : JsonUtils.toBean(json, ProductDTO.class); } ProductDTO product = productRepository.findById(id); if (product == null ) { stringRedisTemplate.opsForValue().set(cacheKey, NULL_VALUE, Duration.ofSeconds(60 )); return null ; } Duration ttl = Duration.ofMinutes(10 ).plusSeconds(ThreadLocalRandom.current().nextLong(30 , 180 )); stringRedisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(product), ttl); return product; } finally { unlock(lockKey, token); } } private boolean tryLock (String key, String token, Duration ttl) { Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, token, ttl); return Boolean.TRUE.equals(success); } private void unlock (String key, String token) { String lua = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ ; stringRedisTemplate.execute( new DefaultRedisScript <>(lua, Long.class), Collections.singletonList(key), token ); } private void sleep (long millis) { try { TimeUnit.MILLISECONDS.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }
注意事项:
锁必须有过期时间。
锁 value 必须是唯一 token。
释放锁必须校验 token。
拿到锁后要双重检查缓存。
重试要限制次数,避免递归过深。
锁过期时间要大于正常回源耗时。
4.3 方案二:逻辑过期 逻辑过期的核心是:Redis key 不设置物理过期,而是在 value 里保存一个逻辑过期时间。
1 2 3 4 5 6 7 8 { "data" : { "id" : 1001 , "name" : "MacBook Pro" , "price" : 12999 } , "expireTime" : "2026-06-07T18:00:00" }
读取流程:
flowchart TD
A[查询 Redis] --> B{缓存是否存在?}
B -->|不存在| C[互斥锁回源重建]
B -->|存在| D{是否逻辑过期?}
D -->|否| E[直接返回]
D -->|是| F{是否抢到刷新锁?}
F -->|是| G[异步刷新缓存]
F -->|否| H[返回旧值]
G --> H
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public ProductDTO queryWithLogicalExpire (Long id) { String cacheKey = "product:detail:" + id; String lockKey = "lock:rebuild:product:detail:" + id; String json = stringRedisTemplate.opsForValue().get(cacheKey); if (!StringUtils.hasText(json)) { return null ; } CacheEnvelope<ProductDTO> envelope = JsonUtils.toBean(json, new TypeReference <>() {}); ProductDTO product = envelope.getData(); if (envelope.getExpireTime().isAfter(LocalDateTime.now())) { return product; } String token = UUID.randomUUID().toString(); boolean locked = tryLock(lockKey, token, Duration.ofSeconds(10 )); if (locked) { CompletableFuture.runAsync(() -> { try { ProductDTO latest = productRepository.findById(id); CacheEnvelope<ProductDTO> newEnvelope = CacheEnvelope.of( latest, LocalDateTime.now().plusMinutes(10 ) ); stringRedisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(newEnvelope)); } finally { unlock(lockKey, token); } }); } return product; }
逻辑过期的优点:
1 热点 key 不会突然物理消失,因此不会把大量请求瞬间打到 DB。
缺点:
适合:
首页数据
榜单数据
活动页
热门商品详情
明星/热点事件详情
大促会场页
不适合:
余额
支付状态
库存扣减的最终判断
强一致结算数据
4.4 方案三:多级缓存 多级缓存是治理击穿和 HotKey 的大杀器。
典型结构:
flowchart TD
Client[客户端] --> CDN[CDN/浏览器缓存]
CDN --> Gateway[网关/Nginx]
Gateway --> App[应用服务]
App --> Local[本地缓存 Caffeine]
Local --> Redis[Redis]
Redis --> DB[(数据库)]
Java 应用中最常见的是:
1 Caffeine 本地缓存 + Redis 分布式缓存 + DB
Caffeine 配置:
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class LocalCacheConfig { @Bean public Cache<Long, ProductDTO> productLocalCache () { return Caffeine.newBuilder() .maximumSize(10_000 ) .expireAfterWrite(Duration.ofSeconds(30 )) .recordStats() .build(); } }
查询:
1 2 3 4 5 6 7 8 9 10 11 12 public ProductDTO getProduct (Long productId) { ProductDTO local = productLocalCache.getIfPresent(productId); if (local != null ) { return local; } ProductDTO remote = redisProductCacheService.getProduct(productId); if (remote != null ) { productLocalCache.put(productId, remote); } return remote; }
本地缓存优点:
极快。
能减少 Redis 网络请求。
对 HotKey 特别有效。
Redis 短暂抖动时可以兜底。
本地缓存缺点:
多实例不一致。
占用 JVM 内存。
服务重启后丢失。
热点切换时可能缓存污染。
所以本地缓存 TTL 要短,例如 5 秒、10 秒、30 秒。
4.5 方案四:singleflight singleflight 的思想是:
同一个进程内,对同一个 key 的并发请求,只让一个请求真正执行加载逻辑,其余请求等待并共享结果。
Go 的 golang.org/x/sync/singleflight 就是这个模型。
Java 可以自己实现一个简化版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Component public class SingleFlight { private final ConcurrentHashMap<String, CompletableFuture<Object>> inFlight = new ConcurrentHashMap <>(); @SuppressWarnings("unchecked") public <T> T doOnce (String key, Supplier<T> loader) { CompletableFuture<Object> future = new CompletableFuture <>(); CompletableFuture<Object> existing = inFlight.putIfAbsent(key, future); if (existing == null ) { try { T result = loader.get(); future.complete(result); return result; } catch (Throwable e) { future.completeExceptionally(e); throw e; } finally { inFlight.remove(key); } } try { return (T) existing.get(3 , TimeUnit.SECONDS); } catch (Exception e) { throw new RuntimeException ("singleflight wait failed, key=" + key, e); } } }
使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public ProductDTO getProduct (Long productId) { String key = "product:detail:" + productId; String json = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return JsonUtils.toBean(json, ProductDTO.class); } return singleFlight.doOnce(key, () -> { String secondCheck = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(secondCheck)) { return JsonUtils.toBean(secondCheck, ProductDTO.class); } ProductDTO product = productRepository.findById(productId); if (product != null ) { redisTemplate.opsForValue().set(key, JsonUtils.toJson(product), Duration.ofMinutes(10 )); } return product; }); }
singleflight 和 Redis 锁的区别:
方案
作用范围
优点
缺点
singleflight
单 JVM 进程
无 Redis 额外开销,速度快
多实例不共享
Redis NX 锁
多实例分布式
跨节点有效
有网络开销和锁超时问题
lease
多实例分布式
更贴近缓存回填语义
实现复杂度更高
推荐组合:
1 2 3 本地 singleflight 合并进程内请求 Redis NX 锁/lease 合并多实例请求 逻辑过期保护极热点
这套下来,数据库终于不用每天被“热心群众”围殴。
五、缓存雪崩 5.1 什么是缓存雪崩? 缓存雪崩指的是:
1 大量 key 在同一时间过期,或者 Redis 整体不可用,导致大量请求同时打到数据库。
它和击穿的区别:
问题
范围
缓存击穿
一个热点 key 过期
缓存雪崩
大量 key 同时过期或缓存集群故障
5.2 TTL 随机值 不要让所有 key 都 30 分钟后一起过期。
错误示例:
1 redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30 ));
推荐:
1 2 3 4 private Duration randomTtl (Duration base) { long jitterSeconds = ThreadLocalRandom.current().nextLong(0 , 300 ); return base.plusSeconds(jitterSeconds); }
5.3 缓存预热 系统启动、大促开始、榜单刷新前,提前加载热点数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Component @RequiredArgsConstructor public class ProductCacheWarmUpRunner implements ApplicationRunner { private final ProductRepository productRepository; private final StringRedisTemplate stringRedisTemplate; @Override public void run (ApplicationArguments args) { List<ProductDTO> hotProducts = productRepository.findHotProducts(); for (ProductDTO product : hotProducts) { String key = "product:detail:" + product.getId(); stringRedisTemplate.opsForValue().set( key, JsonUtils.toJson(product), Duration.ofMinutes(30 ).plusSeconds(ThreadLocalRandom.current().nextLong(0 , 300 )) ); } } }
5.4 限流、熔断、降级 Redis 如果挂了,不能让所有请求无脑打 DB。
应该有降级策略:
flowchart TD
A[请求进入] --> B{Redis 是否可用?}
B -->|可用| C[正常查询]
B -->|不可用| D{是否有本地缓存?}
D -->|有| E[返回本地旧值]
D -->|无| F{是否核心接口?}
F -->|是| G[限流后查 DB]
F -->|否| H[返回降级结果]
可选手段:
Sentinel / Cluster 保证高可用
本地缓存兜底
接口限流
熔断 Redis 访问
非核心接口返回默认值
核心接口保留少量 DB 查询通道
六、HotKey 治理 6.1 什么是 HotKey? HotKey 指的是访问频率极高的缓存 key。
例如:
1 2 3 4 5 product:detail:1001 article:detail:888 activity:home:618 star:profile:某明星ID config:homepage
HotKey 不一定过期,它即使一直存在,也可能把 Redis 打热。
常见表现:
Redis 单节点 CPU 飙高。
Redis 单分片网络流量异常。
某个 slot 特别热。
应用 Redis 访问延迟升高。
大量请求集中访问同一个 key。
HotKey 同时是 BigKey,网络和序列化压力更大。
HotKey 和击穿的区别:
问题
核心
缓存击穿
热点 key 过期后打 DB
HotKey
热点 key 本身把 Redis 打热
6.2 如何发现 HotKey? 方法一:业务埋点 应用层统计 cacheKey 访问次数:
1 2 3 4 public String get (String key) { hotKeyMetric.record(key); return redisTemplate.opsForValue().get(key); }
可以按窗口统计:
1 2 3 最近 10 秒访问 Top 100 key 最近 1 分钟访问 Top 100 key 最近 5 分钟访问 Top 100 key
方法二:Redis 指标 关注:
1 2 3 4 5 6 7 8 9 instantaneous_ops_per_sec instantaneous_input_kbps instantaneous_output_kbps used_cpu_sys used_cpu_user keyspace_hits keyspace_misses evicted_keys expired_keys
方法三:Redis HotKeys Redis 8.6 开始提供 HotKeys 相关命令,可以在服务端追踪热点 key。
示例:
1 2 HOTKEYS START METRICS 2 CPU NET COUNT 20 DURATION 60 SAMPLE 10 HOTKEYS GET
如果你的 Redis 版本或云厂商不支持这个命令,可以使用:
云厂商 HotKey 分析
Redis proxy 采样
客户端 SDK 埋点
网关路径统计
慢日志和网络流量辅助判断
6.3 治理方案一:本地缓存 对于极热点 key,本地缓存是最直接有效的方案。
flowchart TD
A[请求] --> B{Caffeine 本地缓存}
B -->|命中| C[直接返回]
B -->|未命中| D{Redis}
D -->|命中| E[写本地缓存并返回]
D -->|未命中| F[查 DB/回源]
F --> G[写 Redis]
G --> H[写本地缓存并返回]
建议:
1 2 3 普通热点:本地缓存 TTL 5-30 秒 极热点:本地缓存 TTL 1-5 秒 + 异步刷新 配置类:本地缓存 + 变更通知主动失效
6.4 治理方案二:热点 key 多副本 一个 key 只能落到 Redis Cluster 的一个 slot 上。如果单 key 极热,单纯加 Redis 节点不一定能解决。
可以把一个热点 key 拆成多个副本:
1 2 3 4 5 product:detail:1001:copy:0 product:detail:1001:copy:1 product:detail:1001:copy:2 product:detail:1001:copy:3 product:detail:1001:copy:4
读的时候随机读:
1 2 3 4 5 6 7 8 9 10 11 12 public ProductDTO getHotProduct (Long productId) { int copies = 5 ; int index = ThreadLocalRandom.current().nextInt(copies); String key = "product:detail:" + productId + ":copy:" + index; String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return JsonUtils.toBean(json, ProductDTO.class); } return rebuildHotProductCopies(productId, copies); }
写的时候删除所有副本:
1 2 3 4 5 public void deleteHotProductCopies (Long productId, int copies) { for (int i = 0 ; i < copies; i++) { stringRedisTemplate.delete("product:detail:" + productId + ":copy:" + i); } }
注意:多副本会增加一致性维护成本,适合极热点,不要见 key 就复制。缓存不是影分身之术,分太多也会乱。
6.5 治理方案三:拆分 BigKey HotKey 如果同时是 BigKey,杀伤力会翻倍。
例如:
1 home:feed:global -> 5MB JSON
每次访问都要:
1 2 3 Redis 读取 5MB 网络传输 5MB 应用反序列化 5MB
优化方式:
1 2 3 4 5 大 JSON 拆字段 列表分页缓存 Hash 分字段缓存 冷热字段分离 只缓存必要字段
商品详情可以拆成:
1 2 3 4 5 product:base:1001 product:price:1001 product:stock:1001 product:promotion:1001 product:shop:1001
好处:
变化频率不同的字段可以单独失效。
小 key 网络传输更轻。
热点字段可以单独本地缓存。
避免一个大对象拖垮 Redis。
6.6 治理方案四:限流与降级 当某个 key 热到异常,比如突发新闻、秒杀商品、明星事件,可以对这个 key 单独限流。
1 2 3 4 5 6 7 8 9 public ProductDTO getProduct (Long productId) { String hotKey = "product:" + productId; if (hotKeyLimiter.isHot(hotKey) && !hotKeyLimiter.tryAcquire(hotKey)) { return productLocalCache.getIfPresent(productId); } return productCacheService.getProduct(productId); }
限流粒度可以是:
接口级
用户级
IP 级
key 级
店铺级
活动级
降级结果可以是:
返回本地缓存旧值
返回静态默认值
返回“稍后重试”
返回部分字段
关闭非核心模块
6.7 治理方案五:客户端缓存 / 服务端辅助客户端缓存 Redis 支持客户端缓存思路:客户端本地保存热点数据,Redis 在 key 变化时通知客户端失效。
适合:
配置数据
字典数据
权限元数据
低频变更高频读取数据
但是业务实现要注意:
客户端缓存要有 TTL 兜底。
失效消息可能丢失。
多实例一致性要可接受。
强一致数据不要这么玩。
七、Spring Boot 中的缓存实践 7.1 Spring Cache 注解 Spring Cache 常用注解:
注解
作用
@Cacheable
查询时缓存方法返回值
@CacheEvict
删除缓存
@CachePut
执行方法并更新缓存
@Caching
组合多个缓存操作
@CacheConfig
类级别公共缓存配置
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Cacheable(cacheNames = "product:detail", key = "#id") public ProductDTO getById (Long id) { return productRepository.findById(id); } @Transactional @CacheEvict(cacheNames = "product:detail", key = "#command.id") public void update (ProductUpdateCommand command) { productRepository.update(command); } }
但是要注意:
@Cacheable 默认不能很好处理缓存击穿。
@CacheEvict 要注意事务提交时机。
复杂缓存 key 建议自己封装,不要散落在注解里。
热点场景建议手写缓存逻辑,更可控。
7.2 RedisCacheManager 配置 TTL 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager (RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration .defaultCacheConfig() .entryTtl(Duration.ofMinutes(10 )) .disableCachingNullValues() .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer () ) ); Map<String, RedisCacheConfiguration> configMap = new HashMap <>(); configMap.put("product:detail" , defaultConfig.entryTtl(Duration.ofMinutes(10 ))); configMap.put("shop:detail" , defaultConfig.entryTtl(Duration.ofMinutes(30 ))); configMap.put("config:dict" , defaultConfig.entryTtl(Duration.ofHours(1 ))); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .transactionAware() .build(); } }
.transactionAware() 可以让缓存操作感知 Spring 事务,避免事务未提交时缓存已经被操作。
不过,如果你要做延迟双删、MQ 删除、HotKey 多副本、逻辑过期,建议不要完全依赖注解,最好封装专门的 CacheService。
7.3 推荐封装 CacheTemplate 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Component @RequiredArgsConstructor public class CacheTemplate { private static final String NULL_VALUE = "__NULL__" ; private final StringRedisTemplate stringRedisTemplate; private final SingleFlight singleFlight; public <T> T query ( String key, Class<T> type, Supplier<T> dbLoader, Duration ttl, Duration nullTtl ) { String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return NULL_VALUE.equals(json) ? null : JsonUtils.toBean(json, type); } return singleFlight.doOnce(key, () -> { String secondCheck = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(secondCheck)) { return NULL_VALUE.equals(secondCheck) ? null : JsonUtils.toBean(secondCheck, type); } T data = dbLoader.get(); if (data == null ) { stringRedisTemplate.opsForValue().set(key, NULL_VALUE, nullTtl); return null ; } Duration realTtl = ttl.plusSeconds(ThreadLocalRandom.current().nextLong(30 , 180 )); stringRedisTemplate.opsForValue().set(key, JsonUtils.toJson(data), realTtl); return data; }); } public void evict (String key) { stringRedisTemplate.delete(key); } }
使用:
1 2 3 4 5 6 7 8 9 public ProductDTO getProduct (Long productId) { return cacheTemplate.query( "product:detail:" + productId, ProductDTO.class, () -> productRepository.findById(productId), Duration.ofMinutes(10 ), Duration.ofSeconds(60 ) ); }
八、典型业务案例 8.1 商品详情缓存 特点:
1 2 3 4 读多写少 允许短时间旧数据 容易出现 HotKey 适合 Cache-Aside + TTL + 本地缓存 + 互斥锁
方案:
1 2 3 4 5 Caffeine 本地缓存 10 秒 Redis 缓存 10 分钟 + 随机 TTL DB 回源使用 singleflight + Redis NX 锁 写操作更新 DB 后删除 Redis 和本地缓存 重要变更发 MQ 做二次删除
流程:
flowchart TD
A[查询商品] --> B{本地缓存命中?}
B -->|是| C[返回]
B -->|否| D{Redis 命中?}
D -->|是| E[写本地缓存并返回]
D -->|否| F[singleflight 合并本机请求]
F --> G[Redis NX 锁合并多实例请求]
G --> H[查询 DB]
H --> I[写 Redis]
I --> J[写本地缓存]
J --> C
8.2 财务结算单缓存 特点:
1 2 3 4 金额敏感 口径复杂 一致性要求高 缓存不能作为事实源
例如:
建议:
查询列表可以缓存筛选条件对应的结果页,但 TTL 要短。
详情页可以缓存只读快照,但金额计算口径要来自数据库或结算快照表。
回款/付款/费用单变更后,必须主动删除相关缓存。
最终结算动作不能依赖 Redis 判断金额。
可以用 MQ 或 outbox 删除缓存,避免漏删。
流程:
flowchart TD
A[回款/付款/费用单变更] --> B[更新数据库]
B --> C[写入缓存失效事件 Outbox]
C --> D[事务提交]
D --> E[发布 MQ]
E --> F[删除结算单列表缓存]
E --> G[删除结算单详情缓存]
E --> H[删除统计汇总缓存]
这里不建议为了追求极致性能,把金额最终判断放到 Redis。钱这个东西,Redis 可以帮忙跑腿,但别让它当会计。
8.3 秒杀活动页 特点:
1 2 3 4 极高并发 热点集中 允许展示信息短暂旧 库存扣减强一致
方案:
1 2 3 4 5 6 活动开始前预热 Redis 活动页使用逻辑过期 商品详情使用本地缓存 热点 key 多副本 接口限流 库存扣减走专门库存服务/数据库/Redis 原子扣减方案
九、选型总结 9.1 缓存一致性方案选择
场景
推荐方案
普通读多写少
更新 DB 后删除缓存 + TTL
并发读写较高
更新 DB 后删除缓存 + 延迟双删
删除失败不能接受
删除重试表 / Outbox / MQ
多服务写同一数据
binlog / CDC 统一失效
热点聚合数据
MQ 异步更新缓存 + 版本号
极端并发回填
lease / Redis NX 锁 / singleflight
强一致数据
以数据库/事务/快照为准,缓存只辅助展示
9.2 穿透、击穿、雪崩、HotKey 对比
问题
核心原因
典型表现
解决方案
缓存穿透
查不存在的数据
每次都打 DB
参数校验、空值缓存、Bloom、Cuckoo、限流
缓存击穿
热点 key 过期
单 key miss 导致 DB 暴涨
Redis NX 锁、lease、singleflight、逻辑过期
缓存雪崩
大量 key 同时过期或 Redis 故障
DB 流量整体暴涨
TTL 随机、预热、高可用、限流降级
HotKey
单 key 访问过高
Redis 单分片过热
本地缓存、多副本、拆分、限流、降级
BigKey
value 过大
网络和序列化压力大
拆字段、分页、Hash、冷热分离
9.3 一套相对完整的缓存架构 flowchart TD
Client[客户端] --> Gateway[网关限流]
Gateway --> App[应用服务]
App --> ParamCheck{参数校验}
ParamCheck -->|非法| Reject[拒绝请求]
ParamCheck -->|合法| Filter{Bloom/Cuckoo}
Filter -->|一定不存在| Empty[返回空]
Filter -->|可能存在| Local{Caffeine 本地缓存}
Local -->|命中| Return[返回数据]
Local -->|未命中| Redis{Redis 缓存}
Redis -->|命中| FillLocal[写本地缓存]
FillLocal --> Return
Redis -->|未命中| SF[singleflight]
SF --> Lease[Redis NX 锁/Lease]
Lease --> DB[(数据库)]
DB --> Backfill[回填 Redis]
Backfill --> FillLocal
Update[写请求] --> WriteDB[更新数据库]
WriteDB --> Outbox[写 Outbox/MQ]
Outbox --> DeleteCache[异步删除/更新缓存]
DeleteCache --> Redis
DeleteCache --> LocalInvalid[本地缓存失效通知]
这套架构不是让你一上来就全做,而是给你一个演进路线。
小系统先用:
1 Cache-Aside + TTL + 更新 DB 后删除缓存
有并发后加:
1 空值缓存 + Redis NX 锁 + TTL 随机
有热点后加:
1 Caffeine 本地缓存 + singleflight + 逻辑过期
有多服务写入后加:
1 MQ / Outbox / binlog CDC
业务规模不是一天长大的,缓存架构也没必要一出生就穿西装打领带。
十、工程落地清单 10.1 Key 设计 推荐格式:
示例:
1 2 3 4 5 mall:product:detail:1001 mall:product:price:1001 mall:shop:detail:2001 finance:settle:bill:detail:3001 finance:settle:bill:list:shop:1001:202606
不要这样:
1 2 3 4 cache:1 detail:1001 data abc
10.2 TTL 设计
数据
TTL 建议
商品详情
5-30 分钟 + 随机值
店铺信息
10-60 分钟 + 随机值
字典配置
30-120 分钟 + 主动失效
空值缓存
30 秒-5 分钟
财务列表
30 秒-5 分钟,按业务要求
首页榜单
逻辑过期 + 异步刷新
秒杀活动
预热 + 逻辑过期 + 本地缓存
10.3 缓存值设计 普通缓存:
1 2 3 4 5 { "id" : 1001 , "name" : "MacBook Pro" , "price" : 12999 }
更推荐包装一层:
1 2 3 4 5 6 7 8 9 10 { "version" : 1024 , "cacheTime" : "2026-06-07T16:00:00" , "expireTime" : "2026-06-07T16:10:00" , "data" : { "id" : 1001 , "name" : "MacBook Pro" , "price" : 12999 } }
好处:
支持逻辑过期。
支持版本比较。
方便排查缓存生成时间。
方便处理乱序消息。
10.4 监控指标 必须监控:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 缓存命中率 缓存未命中率 Redis QPS Redis P95/P99 延迟 Redis CPU Redis 网络输入/输出 Redis 内存使用 evicted_keys expired_keys keyspace_hits keyspace_misses DB QPS DB 慢查询 缓存重建次数 空值缓存命中次数 锁等待次数 HotKey TopN BigKey TopN MQ 缓存失效积压 缓存删除失败次数
没有监控的缓存系统,就像没有仪表盘的跑车:看起来很猛,撞墙前都挺自信。
结论 缓存设计不是背几个名词,而是要围绕一个问题展开:
数据在数据库、Redis、本地缓存之间流动时,什么时候读?什么时候写?什么时候删?失败了怎么办?并发来了谁负责回源?热点来了怎么分摊?
最终可以总结成几句话:
缓存一致性:优先使用“更新 DB 后删除缓存”,再用 TTL、延迟双删、删除重试、MQ、binlog CDC 兜底。
缓存回溯:读请求缓存未命中后回源数据库并回填缓存,但要防止并发回填。
双写策略:不要轻易“更新缓存”,更多时候应该“删除缓存”。
Lease 租约:让一个请求拿到回源资格,避免大家一起冲向数据库。
缓存穿透:参数校验、空值缓存、Bloom Filter、Cuckoo Filter、限流组合使用。
缓存击穿:Redis NX 锁、singleflight、逻辑过期、多级缓存都能用,关键看场景。
HotKey:本地缓存、多副本、拆分 BigKey、限流降级、热点发现缺一不可。
强一致业务:缓存只辅助展示,最终判断必须回到数据库、事务、快照或专门的一致性服务。
最后一句话:
Redis 是性能放大器,也是设计缺陷放大器。缓存设计做得好,系统丝滑;设计做不好,数据库会在深夜给你发来亲切问候。
参考资料