Redis 缓存实战:一致性、穿透、击穿与 HotKey 治理

欢迎你来读这篇博客,这篇博客主要是关于 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
数据库 > 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

这个过程看起来简单,但有几个关键细节:

  1. 回填缓存必须设置 TTL。
  2. TTL 最好加随机抖动,避免同一批 key 同时过期。
  3. 数据不存在时,要考虑空值缓存,防止缓存穿透。
  4. 热点 key 回填时要防止并发重建,避免缓存击穿。
  5. 回填缓存时要注意旧值覆盖新值的问题。

基础代码:

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 有延迟,链路复杂 适合复杂场景

工程上最常用的是:

1
更新数据库成功后,删除缓存。

注意,是删除缓存,不是更新缓存。

2.4 为什么推荐“更新数据库后删除缓存”?

因为缓存往往不是数据库单表的一行数据。

比如商品详情缓存可能包含:

1
2
3
4
5
6
7
8
商品基础信息
商品价格
库存
店铺信息
优惠券
活动状态
是否收藏
用户权限

如果写操作每次都去“更新缓存”,你会遇到很多麻烦:

  1. 需要重复拼装复杂缓存对象。
  2. 多个写入口都要维护同一套缓存逻辑。
  3. 并发写时旧值可能覆盖新值。
  4. 有些字段来自远程服务,更新成本高。
  5. 写多读少的数据会制造大量无效缓存。

删除缓存则简单得多:

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

最终还是可能旧值回写。

但这个场景发生概率比较低,因为需要满足:

  1. 读请求先缓存未命中。
  2. 读请求查询数据库比较慢。
  3. 写请求在读请求查询 DB 后完成更新和删除。
  4. 读请求最后才把旧值写回缓存。

所以工程上常用它,再配合 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 后删除缓存”有一个很现实的问题:

1
数据库更新成功了,但 Redis 删除失败了。

例如:

  • 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

优点:

  1. 业务数据和事件记录在一个本地事务里提交。
  2. 消息发送失败可以重试。
  3. 消费失败可以重试。
  4. 可以追踪每个缓存删除事件。

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. 旧事件可能覆盖新缓存。

解决方式:缓存值里带版本号。

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

这种方案的优点:

  1. 不侵入业务代码或低侵入。
  2. 能覆盖多服务、多写入口。
  3. 数据库提交后统一发出变更事件。
  4. 适合做搜索索引、缓存、数据同步、审计日志。

缺点:

  1. 链路更复杂。
  2. 延迟不可避免。
  3. 需要处理乱序、重复、失败。
  4. 要维护表到缓存 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 是一种概率型数据结构,用来判断一个元素是否可能存在于集合中。

它的特点:

1
2
判断不存在:一定不存在
判断存在:可能存在

也就是说,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 要注意:

  1. 要预估容量和误判率。
  2. 数据新增时要同步加入 Bloom Filter。
  3. 删除不友好,传统 Bloom Filter 不适合频繁删除。
  4. Bloom Filter 丢失后需要重建。
  5. 它只能减少无效请求,不能替代权限校验。

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 在高并发访问时突然过期,大量请求同时打到数据库。

例如:

1
product:detail:1001

平时 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();
}
}
}

注意事项:

  1. 锁必须有过期时间。
  2. 锁 value 必须是唯一 token。
  3. 释放锁必须校验 token。
  4. 拿到锁后要双重检查缓存。
  5. 重试要限制次数,避免递归过深。
  6. 锁过期时间要大于正常回源耗时。

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。

缺点:

1
用户可能短时间读到旧值。

适合:

  • 首页数据
  • 榜单数据
  • 活动页
  • 热门商品详情
  • 明星/热点事件详情
  • 大促会场页

不适合:

  • 余额
  • 支付状态
  • 库存扣减的最终判断
  • 强一致结算数据

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;
}

本地缓存优点:

  1. 极快。
  2. 能减少 Redis 网络请求。
  3. 对 HotKey 特别有效。
  4. Redis 短暂抖动时可以兜底。

本地缓存缺点:

  1. 多实例不一致。
  2. 占用 JVM 内存。
  3. 服务重启后丢失。
  4. 热点切换时可能缓存污染。

所以本地缓存 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 打热。

常见表现:

  1. Redis 单节点 CPU 飙高。
  2. Redis 单分片网络流量异常。
  3. 某个 slot 特别热。
  4. 应用 Redis 访问延迟升高。
  5. 大量请求集中访问同一个 key。
  6. 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

好处:

  1. 变化频率不同的字段可以单独失效。
  2. 小 key 网络传输更轻。
  3. 热点字段可以单独本地缓存。
  4. 避免一个大对象拖垮 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 变化时通知客户端失效。

适合:

  • 配置数据
  • 字典数据
  • 权限元数据
  • 低频变更高频读取数据

但是业务实现要注意:

  1. 客户端缓存要有 TTL 兜底。
  2. 失效消息可能丢失。
  3. 多实例一致性要可接受。
  4. 强一致数据不要这么玩。

七、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);
}
}

但是要注意:

  1. @Cacheable 默认不能很好处理缓存击穿。
  2. @CacheEvict 要注意事务提交时机。
  3. 复杂缓存 key 建议自己封装,不要散落在注解里。
  4. 热点场景建议手写缓存逻辑,更可控。

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
金额敏感
口径复杂
一致性要求高
缓存不能作为事实源

例如:

1
2
3
本期应结金额
本期已结金额
本期结余

建议:

  1. 查询列表可以缓存筛选条件对应的结果页,但 TTL 要短。
  2. 详情页可以缓存只读快照,但金额计算口径要来自数据库或结算快照表。
  3. 回款/付款/费用单变更后,必须主动删除相关缓存。
  4. 最终结算动作不能依赖 Redis 判断金额。
  5. 可以用 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
业务:模块:实体:标识

示例:

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
}
}

好处:

  1. 支持逻辑过期。
  2. 支持版本比较。
  3. 方便排查缓存生成时间。
  4. 方便处理乱序消息。

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、本地缓存之间流动时,什么时候读?什么时候写?什么时候删?失败了怎么办?并发来了谁负责回源?热点来了怎么分摊?

最终可以总结成几句话:

  1. 缓存一致性:优先使用“更新 DB 后删除缓存”,再用 TTL、延迟双删、删除重试、MQ、binlog CDC 兜底。
  2. 缓存回溯:读请求缓存未命中后回源数据库并回填缓存,但要防止并发回填。
  3. 双写策略:不要轻易“更新缓存”,更多时候应该“删除缓存”。
  4. Lease 租约:让一个请求拿到回源资格,避免大家一起冲向数据库。
  5. 缓存穿透:参数校验、空值缓存、Bloom Filter、Cuckoo Filter、限流组合使用。
  6. 缓存击穿:Redis NX 锁、singleflight、逻辑过期、多级缓存都能用,关键看场景。
  7. HotKey:本地缓存、多副本、拆分 BigKey、限流降级、热点发现缺一不可。
  8. 强一致业务:缓存只辅助展示,最终判断必须回到数据库、事务、快照或专门的一致性服务。

最后一句话:

Redis 是性能放大器,也是设计缺陷放大器。缓存设计做得好,系统丝滑;设计做不好,数据库会在深夜给你发来亲切问候。

参考资料


Redis 缓存实战:一致性、穿透、击穿与 HotKey 治理
https://allendericdalexander.github.io/2026/06/07/archtect/cache/
作者
AtLuoFu
发布于
2026年6月7日
许可协议