Redis 缓存策略与实战指南

Redis 缓存策略与实战指南

作者:CaoZH · Geniux 技术博客

适用人群:有基础开发经验的工程师

更新时间:2026-06-18


目录

  1. Redis 基础数据结构回顾
  2. 缓存三大模式
  3. 缓存穿透、击穿、雪崩
  4. 布隆过滤器原理与实现
  5. 过期策略与内存淘汰机制
  6. Redis 分布式锁
  7. 集群模式选型对比
  8. Spring Boot 集成 Redis 缓存实战
  9. 性能优化建议与常见坑

1. Redis 基础数据结构回顾

Redis 之所以能成为缓存领域的首选,其丰富的数据结构功不可没。下面逐一回顾五种核心类型及其典型应用场景。

1.1 String(字符串)

最基础的类型,value 最大 512MB。适用于计数器、分布式 ID、简单缓存。

1
2
3
4
5
6
> SET user:1001 "{\"name\":\"alice\"}"
> GET user:1001
> INCR article:readcount:9527
(integer) 1
> EXPIRE session:token:abc 3600
(integer) 1

注意事项: SET 命令的 NX/XX 参数可用于实现分布式锁;MSET/MGET 可批量操作减少 RTT。

1.2 Hash(哈希)

类似 Java 的 HashMap<String, String>,适合存储对象。

1
2
3
4
5
6
7
8
9
10
11
> HSET user:1001 name "alice" age 28 city "beijing"
(integer) 3
> HGETALL user:1001
1) "name"
2) "alice"
3) "age"
4) "28"
5) "city"
6) "beijing"
> HINCRBY user:1001 age 1
(integer) 29

应用场景: 用户信息、商品详情、会话状态。相比 String + JSON 序列化,Hash 支持部分字段更新,节省带宽。

1.3 List(列表)

底层是双向链表(quicklist),支持左右两端插入。

1
2
3
4
5
6
7
8
> LPUSH queue:task task:001 task:002
(integer) 2
> RPOP queue:task
"task:001"
> LLEN queue:task
(integer) 1
> LRANGE queue:task 0 -1
1) "task:002"

应用场景: 消息队列(LPUSH + BRPOP)、最新消息列表(LTRIM 限制长度)、时间线。

1.4 Set(集合)

无序、去重,支持交并差运算。

1
2
3
4
5
6
7
8
> SADD tag:java "spring" "jvm" "redis"
(integer) 3
> SADD tag:go "goroutine" "redis"
(integer) 2
> SINTER tag:java tag:go
1) "redis"
> SCARD tag:java
(integer) 3

应用场景: 标签系统、共同好友、随机抽奖(SRANDMEMBER / SPOP)。

1.5 Sorted Set(有序集合)

每个元素关联一个 score,按 score 排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
> ZADD leaderboard 100 "user:01" 85 "user:02" 200 "user:03"
(integer) 3
> ZRANGE leaderboard 0 2 WITHSCORES
1) "user:02"
2) "85"
3) "user:01"
4) "100"
5) "user:03"
6) "200"
> ZINCRBY leaderboard 30 "user:02"
"115"
> ZREVRANK leaderboard "user:03"
(integer) 0 # 第一名

应用场景: 排行榜、延时队列(score 作为时间戳)、限流滑动窗口。


2. 缓存三大模式

2.1 Cache Aside(旁路缓存)

最常用的模式,应用代码同时维护缓存和数据库。

读流程:

1
2
3
4
1. 读缓存 → 命中则返回
2. 未命中 → 读数据库
3. 将数据写入缓存
4. 返回数据

写流程:

1
2
1. 更新数据库
2. 删除缓存(淘汰而非更新)

为什么是删除而不是更新? 更新缓存存在并发写覆盖的复杂问题,而删除缓存后再读取时会由读流程重新填充,天然保证一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 读缓存
public User getUser(String id) {
String key = "user:" + id;
User user = redis.get(key);
if (user != null) return user;

user = db.query("SELECT * FROM user WHERE id = ?", id);
if (user != null) {
redis.setex(key, 3600, user);
}
return user;
}

// 写缓存(先更新DB,再删缓存)
public void updateUser(String id, User data) {
db.execute("UPDATE user SET ... WHERE id = ?", data, id);
redis.del("user:" + id); // 删除缓存
}

注意事项: 先删缓存再更新 DB 存在并发问题(B 线程在 A 删缓存后、更新 DB 前读入旧数据),所以先更新 DB 后删缓存是公认的最佳实践。

2.2 Read-Through(通读缓存)

缓存层(如 Redis + 代理层)自身负责从数据库加载数据,应用只与缓存交互。

1
应用 → 缓存(未命中 → 缓存自动加载 DB) → 返回

在 Redis 层面没有原生 Read-Through 支持,需要客户端库实现(如 Redis-OM、自定义 Cache-aside 封装)。Spring Cache @Cacheable 的底层逻辑本质上是 Read-Through 模式。

2.3 Write-Through(通写缓存)

写操作先写入缓存,由缓存同步写入数据库。Redis 本身不提供此能力,通常结合 Write-Behind 模式在应用层实现。

Write-Behind Caching(异步回写): 数据先写入缓存,异步批量刷回 DB,适合写频繁但对一致性要求不高的场景(如点赞计数、访问统计)。

1
2
3
4
5
// Write-Behind 示例:点赞计数异步落库
public void likePost(String postId) {
redis.incr("post:like:" + postId);
// 定时任务或消息队列异步 sync 到 DB
}

3. 缓存穿透、击穿、雪崩

这是缓存面试的三座大山,也是线上最常遇到的缓存故障。

3.1 缓存穿透

现象: 请求查询一个数据库中也不存在的数据,缓存永远不命中,每次请求都打到 DB。

解决方案:

  • 缓存空值: 即使 DB 返回 null 也缓存一个短过期时间(如 60s)的空值标记
  • 布隆过滤器: 请求前先判断 key 是否存在(见第 4 节)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 缓存空值方案
public Object getData(String key) {
Object val = redis.get(key);
if (val != null) {
// 区分空值标记和正常值
if (val instanceof NullValue) return null;
return val;
}
val = db.query(key);
if (val == null) {
redis.setex(key, 60, new NullValue()); // 缓存空值,60s过期
} else {
redis.setex(key, 3600, val);
}
return val;
}

3.2 缓存击穿

现象: 一个热点 key 在过期瞬间,大量并发请求同时穿透到 DB。

解决方案:

  • 互斥锁(Mutex Key): 只让一个线程去查 DB 重建缓存,其他线程等待
  • 逻辑过期: 缓存永不过期,但 value 中存一个过期时间戳,发现过期时异步更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 互斥锁方案
public Object getHotData(String key) {
Object val = redis.get(key);
if (val != null) return val;

String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
try {
// double check
val = redis.get(key);
if (val != null) return val;

val = db.query(key);
redis.setex(key, 3600, val);
return val;
} finally {
redis.del(lockKey);
}
} else {
// 自旋等待
Thread.sleep(50);
return getHotData(key);
}
}

3.3 缓存雪崩

现象: 大量 key 同时过期,或 Redis 实例宕机,导致海量请求打到 DB。

解决方案:

  • 过期时间加随机值: 避免大量 key 在同一时间过期
  • 多级缓存: 本地缓存(Caffeine)+ Redis 分布式缓存
  • Redis 高可用: 主从 + Sentinel / Cluster
  • 熔断降级: 限流 + 服务降级(直接返回默认值或错误提示)
1
2
3
4
5
// 过期时间加随机偏移
String key = "user:" + id;
int baseTtl = 3600;
int randomOffset = new Random().nextInt(300); // 0~300秒随机
redis.setex(key, baseTtl + randomOffset, value);

4. 布隆过滤器原理与实现

4.1 原理

布隆过滤器(Bloom Filter)由一个很长的位数组和多个哈希函数组成:

  1. 添加元素: 对元素计算 k 个哈希值,将位数组中对应位置设为 1
  2. 判断存在: 计算 k 个哈希值,检查对应位是否全部为 1
    • 全部为 1 → 可能存在(有误判率,False Positive)
    • 任意位为 0 → 一定不存在

特点: 空间效率极高,有误判率(可控制),不能删除元素(除非用 Counting Bloom Filter)。

误判率公式: 位数组长度 m、哈希函数个数 k、元素数量 n 时:

1
误判率 ≈ (1 - e^(-kn/m))^k

4.2 Redis 中的实现

从 Redis 4.0 起,官方提供了 RedisBloom 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 安装 RedisBloom(Docker)
docker run -p 6379:6379 redislabs/rebloom

# 创建过滤器(key, error_rate, capacity)
> BF.RESERVE user_filter 0.01 1000000
OK

# 添加元素
> BF.ADD user_filter user:1001
(integer) 1

# 批量添加
> BF.MADD user_filter user:1002 user:1003 user:1004
1) (integer) 1
2) (integer) 1
3) (integer) 1

# 检查是否存在
> BF.EXISTS user_filter user:1001
(integer) 1 # 可能存在
> BF.EXISTS user_filter user:9999
(integer) 0 # 一定不存在

4.3 手写简易布隆过滤器(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
31
32
33
34
35
36
37
38
39
import java.util.BitSet;

public class SimpleBloomFilter {
private static final int DEFAULT_SIZE = 2 << 24; // 1677万位
private static final int[] SEEDS = {3, 7, 11, 17, 23, 31, 43};
private BitSet bits = new BitSet(DEFAULT_SIZE);
private HashFunction[] funcs = new HashFunction[SEEDS.length];

public SimpleBloomFilter() {
for (int i = 0; i < SEEDS.length; i++) {
funcs[i] = new HashFunction(DEFAULT_SIZE, SEEDS[i]);
}
}

public void add(String value) {
for (HashFunction f : funcs) {
bits.set(f.hash(value), true);
}
}

public boolean mightContain(String value) {
for (HashFunction f : funcs) {
if (!bits.get(f.hash(value))) return false;
}
return true;
}

private static class HashFunction {
private int cap, seed;
HashFunction(int cap, int seed) { this.cap = cap; this.seed = seed; }
int hash(String value) {
int result = 0;
for (char c : value.toCharArray()) {
result = result * seed + c;
}
return (cap - 1) & result;
}
}
}

4.4 穿透防护实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object getDataWithBloom(String key) {
// 先过布隆过滤器
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接拒绝
}
// 走正常缓存逻辑
Object val = redis.get(key);
if (val != null) return val;

val = db.query(key);
if (val != null) {
redis.setex(key, 3600, val);
}
return val;
}

5. 过期策略与内存淘汰机制

5.1 过期策略

Redis 对设置了 TTL 的 key 采用惰性删除 + 定期删除配合策略:

策略 工作方式 优点 缺点
惰性删除 每次访问 key 时检查是否过期,过期则删除 CPU 友好 过期 key 可能长期占用内存
定期删除 每秒执行 10 次(hz=10),随机抽查 20 个 key,删除过期 key 平衡内存和 CPU 不是精确清理,需配合淘汰机制

5.2 内存淘汰机制

当内存达到 maxmemory 上限时,Redis 根据 maxmemory-policy 策略淘汰 key:

策略 含义 适用场景
noeviction(默认) 不淘汰,写操作返回错误 不推荐用于缓存场景
allkeys-lru 所有 key 中淘汰最近最少使用的 最常用,缓存的最佳实践
allkeys-lfu 所有 key 中淘汰最不经常使用的 访问频次差异大的场景
volatile-lru 仅对设置了 TTL 的 key 进行 LRU 淘汰 混合缓存与持久化
volatile-ttl 淘汰 TTL 最小的 key 较少使用
allkeys-random 随机淘汰 兜底策略

配置建议:

1
2
3
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru

LRU vs LFU 选型:

  • LRU(Least Recently Used): 适合周期性热点(如早晚高峰的新闻)
  • LFU(Least Frequently Used): 适合稳定热点(如核心配置项),Redis 4.0+ 支持

5.3 手动优化过期 key 监控

1
2
3
4
5
6
7
8
9
10
11
12
13
# 查看 key 的剩余 TTL
> TTL user:1001
(integer) 3521

# 查看当前内存淘汰的 key 数量
> INFO stats | grep evicted_keys
evicted_keys:342

# 查看内存使用
> INFO memory
# Memory
used_memory_human:2.34G
maxmemory_human:4.00G

6. Redis 分布式锁

6.1 基础实现:SETNX + 过期时间

从 Redis 2.6.12 起,SET 命令提供了原子化的加锁方式:

1
2
3
4
> SET lock:order:1001 "thread-A" NX EX 30
OK
# ... 业务逻辑 ...
> DEL lock:order:1001
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Jedis 实现
String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID().toString();

// 加锁(原子操作)
String result = jedis.set(lockKey, requestId, SetParams.setParams().nx().ex(30));
if ("OK".equals(result)) {
try {
// 执行业务逻辑
processOrder(orderId);
} finally {
// 释放锁 — 必须用 Lua 保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}
}

关键点:

  • NX: 只在 key 不存在时才设置,实现互斥
  • EX 30: 自动过期,防止死锁
  • requestId(唯一标识): 确保只能释放自己的锁,避免误删
  • Lua 脚本释放: CHECK-THEN-ACT 原子化,避免并发误删

6.2 Redlock 算法

当需要在 Redis 集群(多主节点)中实现高可靠的分布式锁时,使用 Redlock 算法。

算法步骤:

  1. 获取当前时间戳 T1
  2. 依次向 N 个(通常 5 个)独立的 Redis 主节点请求加锁,超时时间短(如 10ms)
  3. 计算获取到的锁数:超过 N/2 + 1 个节点成功,且总耗时 < 锁的生存时间,则加锁成功
  4. 否则,向所有节点发送解锁请求
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
// Redisson 实现(推荐生产使用)
Config config = new Config();
config.useSentinelServers()
.addSentinelAddress("redis://node1:***@Configuration
@EnableCaching
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);

// 使用 Jackson2JsonRedisSerializer 序列化 value
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LazyValidatorFactory.getDefaultTyper(),
ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);

// key 使用 StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}

@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();

return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}

8.3 使用 @Cacheable 注解

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
@Service
public class UserService {

@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User getUserById(Long id) {
// 模拟 DB 查询
return userMapper.selectById(id);
}

@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
userMapper.updateById(user);
return user;
}

@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userMapper.deleteById(id);
}

@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
// 清空 users 缓存
}
}

注解说明:

  • @Cacheable:先查缓存,命中则返回,否则执行方法并缓存结果
  • @CachePut:始终执行方法,并将结果更新到缓存
  • @CacheEvict:删除缓存
  • unless:条件表达式,满足时不缓存(如 #result == null
  • condition:条件表达式,满足时才缓存

8.4 Redis Callback 与 Pipeline

批量操作时务必使用 Pipeline 减少 RTT(Round Trip Time):

1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private RedisTemplate<String, Object> redisTemplate;

public void batchUpdateScores(Map<String, Double> userScores) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
userScores.forEach((userId, score) -> {
byte[] key = ("user:score:" + userId).getBytes();
connection.stringCommands().set(key,
String.valueOf(score).getBytes());
});
return null;
});
}

9. 性能优化建议与常见坑

9.1 性能优化清单

优化项 说明 参考值
连接池 使用连接池复用连接,避免频繁创建销毁 max-active=16~32
Pipeline 批量操作合并 RTT 每批 50~200 条命令
批量操作 使用 MSET/MGET 代替逐条 SET/GET
大 Key 拆分 value > 10KB 或集合 > 5000 元素即算大 key,需拆分 value < 10KB
禁用危险命令 KEYSFLUSHALLMONITOR 生产环境禁用 使用 SCAN 替代 KEYS
合理设计 TTL 所有缓存 key 应设置合理过期时间 根据业务需求
慢查询监控 SLOWLOG GET 100 查看慢查询 阈值 < 10ms
内存碎片整理 定期使用 MEMORY PURGE 或重启 activedefrag yes

9.2 常见坑(血泪教训)

❌ 坑 1:缓存穿透未防护

现象: 恶意请求遍历不存在的 ID,DB 连接池被打满。

对策: 布隆过滤器 + 缓存空值 + 参数校验(如 ID 格式校验)。

❌ 坑 2:大 Key 导致集群倾斜

现象: Cluster 模式下某个节点内存使用远超其他节点,请求集中打到一个分片。

对策:

1
2
3
4
5
6
7
8
9
# 发现大 key
> MEMORY USAGE user:1001
(integer) 5242880 # 5MB

> DEBUG OBJECT user:1001
Value at:0x7f... serializedlength:5234567 ...

# 用 redis-cli --bigkeys 扫描
redis-cli --bigkeys

拆分方案: 将大 Hash 拆分为多个小 Hash(如按字段类型分组),或将大 Set 拆分为多个小的。

❌ 坑 3:非原子操作导致并发问题

1
2
3
4
5
6
7
// ❌ 错误:非原子操作
if (redis.get("key") == null) {
redis.set("key", value); // 这里存在并发竞争
}

// ✅ 正确:原子操作
redis.setnx("key", value, 30, TimeUnit.SECONDS);

❌ 坑 4:热 Key 导致单节点瓶颈

现象: 双十一大促期间,一个热门商品 key 的 QPS 达到 10w+,单节点 CPU 打满。

对策:

  • 本地缓存: 热 key 在本地缓存(Caffeine)中再缓存一层
  • 读写分离: 热 key 读取分散到从节点
  • 热 key 拆分: hotkey_1hotkey_2hotkey_N,客户端随机选择
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 热 key 本地缓存 + Redis 二级缓存
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
}

public Object getHotKey(String key) {
// L1 本地缓存
Object val = localCache.getIfPresent(key);
if (val != null) return val;

// L2 Redis
val = redis.get(key);
if (val != null) {
localCache.put(key, val);
}
return val;
}

❌ 坑 5:事务与 Lua 脚本的大坑

Redis 事务 MULTI/EXEC执行过程中不会处理其他命令,但 EXEC 前不会回滚语法错误之外的错误。如果需要在事务中依赖中间结果,必须使用 Lua 脚本。

1
2
3
4
5
6
7
8
-- Lua 脚本可以实现 CAS(Check-And-Set)
-- 扣减库存
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
return tonumber(stock)
1
2
3
4
// Java 调用 Lua
String script = "local stock = redis.call('GET', KEYS[1]) ...";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList("stock:item:1001"));

总结

Redis 作为缓存中间件的王者,其价值不仅仅体现在”快”上。理解数据结构的选择、缓存模式的权衡、故障场景的防御、以及集群的合理选型,才能在生产环境中构建稳定高效的缓存体系。

核心要点回顾:

  1. 数据结构选型:根据访问模式选择合适类型,避免大 key
  2. 缓存模式:Cache Aside 是主流,先更新 DB 再删缓存
  3. 三大故障:穿透(空值+布隆)、击穿(互斥锁+逻辑过期)、雪崩(随机TTL+降级)
  4. 分布式锁:SETNX + Lua 释放 + 唯一标识,Redisson 看门狗自动续期
  5. 集群选型:小规模用 Sentinel,大规模用 Cluster
  6. 性能红线:禁用 KEYS、Pipeline 批量操作、设计合理 TTL

本文由 CaoZH 原创发布于 Geniux 技术博客,转载请注明出处。