Redis 缓存策略与实战指南
作者:CaoZH · Geniux 技术博客
适用人群:有基础开发经验的工程师
更新时间:2026-06-18
目录
Redis 基础数据结构回顾
缓存三大模式
缓存穿透、击穿、雪崩
布隆过滤器原理与实现
过期策略与内存淘汰机制
Redis 分布式锁
集群模式选型对比
Spring Boot 集成 Redis 缓存实战
性能优化建议与常见坑
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; } 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 public void likePost (String postId) { redis.incr("post:like:" + postId); }
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()); } 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 { 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 ); redis.setex(key, baseTtl + randomOffset, value);
4. 布隆过滤器原理与实现 4.1 原理 布隆过滤器(Bloom Filter)由一个很长的位数组 和多个哈希函数 组成:
添加元素: 对元素计算 k 个哈希值,将位数组中对应位置设为 1
判断存在: 计算 k 个哈希值,检查对应位是否全部为 1
全部为 1 → 可能存在 (有误判率,False Positive)
任意位为 0 → 一定不存在
特点: 空间效率极高,有误判率(可控制),不能删除元素(除非用 Counting Bloom Filter)。
误判率公式: 位数组长度 m、哈希函数个数 k、元素数量 n 时:
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 docker run -p 6379:6379 redislabs/rebloom > 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 ; 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 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 > TTL user:1001 (integer ) 3521 > INFO stats | grep evicted_keys evicted_keys:342 > INFO 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 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 { 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 算法。
算法步骤:
获取当前时间戳 T1
依次向 N 个(通常 5 个)独立的 Redis 主节点请求加锁,超时时间短(如 10ms)
计算获取到的锁数:超过 N/2 + 1 个节点成功 ,且总耗时 < 锁的生存时间,则加锁成功
否则,向所有节点发送解锁请求
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 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) { 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 () { } }
注解说明:
@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
禁用危险命令
KEYS、FLUSHALL、MONITOR 生产环境禁用
使用 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 > MEMORY USAGE user:1001 (integer ) 5242880 > DEBUG OBJECT user:1001 Value at:0x7f... serializedlength:5234567 ... 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_1、hotkey_2…hotkey_N,客户端随机选择
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Bean public Cache<String, Object> localCache () { return Caffeine.newBuilder() .maximumSize(10000 ) .expireAfterWrite(30 , TimeUnit.SECONDS) .build(); } public Object getHotKey (String key) { Object val = localCache.getIfPresent(key); if (val != null ) return val; 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 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 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 作为缓存中间件的王者,其价值不仅仅体现在”快”上。理解数据结构的选择、缓存模式的权衡、故障场景的防御、以及集群的合理选型,才能在生产环境中构建稳定高效的缓存体系。
核心要点回顾:
数据结构选型 :根据访问模式选择合适类型,避免大 key
缓存模式 :Cache Aside 是主流,先更新 DB 再删缓存
三大故障 :穿透(空值+布隆)、击穿(互斥锁+逻辑过期)、雪崩(随机TTL+降级)
分布式锁 :SETNX + Lua 释放 + 唯一标识,Redisson 看门狗自动续期
集群选型 :小规模用 Sentinel,大规模用 Cluster
性能红线 :禁用 KEYS、Pipeline 批量操作、设计合理 TTL
本文由 CaoZH 原创发布于 Geniux 技术博客 ,转载请注明出处。