Redis 实战:缓存、分布式锁、过期策略、防穿透击穿
Redis 实战:缓存、分布式锁、过期策略、防穿透击穿
没有缓存的系统就像没有电梯的百层大楼——用户爬到一半就跑了。Redis 就是这部电梯,用对了,TPS 直接起飞。
一、Redis 在 SpringBoot 中的四重身份
Redis 不只是个"存 key-value 的内存数据库",在 SpringBoot 生态中它承担多种角色:
- 缓存层:挡在数据库前面,热点数据毫秒级返回
- 分布式锁:多实例环境下保证同一时刻只有一个线程执行关键代码
- 会话管理:Spring Session 集成,告别粘性会话
- 排行榜/计数器:Sorted Set 天然支持排名,INCR 原子递增
二、SpringBoot 整合 Redis
2.1 添加依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>2.2 配置文件
spring:data:redis:host:localhostport:6379database:0timeout:3000ms2.3 RedisTemplate 序列化配置
默认的 JDK 序列化存在两个问题:可读性差(一堆乱码)且占用空间大。推荐改用 JSON 序列化:
@ConfigurationpublicclassRedisConfig{@BeanpublicRedisTemplate<String,Object>redisTemplate(RedisConnectionFactoryfactory){RedisTemplate<String,Object>template=newRedisTemplate<>();template.setConnectionFactory(factory);// Key 用 String 序列化(可读性好)template.setKeySerializer(newStringRedisSerializer());// Value 用 JSON 序列化template.setValueSerializer(newGenericJackson2JsonRedisSerializer());template.setHashKeySerializer(newStringRedisSerializer());template.setHashValueSerializer(newGenericJackson2JsonRedisSerializer());returntemplate;}}三、Spring Cache 缓存注解三件套
Spring 提供了声明式缓存注解,无需手动写 get/set 代码:
| 注解 | 作用 | 触发时机 |
|---|---|---|
@Cacheable | 查缓存,有则返回,无则查库并放入缓存 | 方法调用前 |
@CacheEvict | 删除缓存 | 方法调用后 |
@CachePut | 强制更新缓存(不管 key 是否存在都执行方法并写缓存) | 方法调用后 |
@ServicepublicclassUserService{@Cacheable(value="user",key="#id")publicUsergetUserById(Longid){// 先查缓存,命中直接返回;未命中则执行方法并缓存结果returnuserMapper.selectById(id);}@CachePut(value="user",key="#user.id")publicUserupdateUser(Useruser){userMapper.updateById(user);returnuser;}@CacheEvict(value="user",key="#id")publicvoiddeleteUser(Longid){userMapper.deleteById(id);}}四、缓存三大经典问题:穿透、击穿、雪崩
这是面试高频题,也是生产环境最容易出事故的缓存场景。
4.1 缓存穿透
场景:查询一个数据库中永远不存在的数据(比如 id = -1),每次请求都穿过缓存直接打到数据库。
解决方案:
- 布隆过滤器:在缓存前面加一层布隆过滤器,不存在的 key 直接拦截。Redis 4.0 以上支持
BF.ADD命令 - 空值缓存:把 null 结果也存进缓存,设置极短的过期时间(如 60 秒)
@Cacheable(value="user",key="#id",unless="#result == null")publicUsergetUserById(Longid){Useruser=redisTemplate.opsForValue().get("user:"+id);if(user!=null)returnuser;// 查数据库user=userMapper.selectById(id);if(user!=null){redisTemplate.opsForValue().set("user:"+id,user,30,TimeUnit.MINUTES);}else{// 空值缓存,防止穿透redisTemplate.opsForValue().set("user:"+id,newUser(),1,TimeUnit.MINUTES);}returnuser;}4.2 缓存击穿
场景:某个热点 key 在过期的瞬间,大量并发请求同时打到数据库。
解决方案:
- 互斥锁:第一个线程获得锁并查库更新缓存,其他线程等待
- 逻辑过期:不设物理过期,值里包含过期时间戳,后台线程异步刷新
4.3 缓存雪崩
场景:大量 key 在同一时间过期,缓存集体失效,数据库瞬间被压垮。
解决方案:给过期时间加上随机值,比如30 分钟 + random(1-5 分钟):
intrandomMinutes=newRandom().nextInt(5)+1;redisTemplate.opsForValue().set(key,value,30+randomMinutes,TimeUnit.MINUTES);五、分布式锁:从 SETNX 到 Redisson
5.1 为什么需要分布式锁?
单机环境下synchronized或ReentrantLock能搞定并发问题。但服务一部署多个实例,JVM 锁就失效了——锁信息不在同一个 JVM 里。这时需要一个所有实例都能访问的"公共锁"——Redis。
5.2 Redisson 分布式锁(推荐)
手写 SETNX + 过期时间容易出死锁、误删锁等问题。Redisson 是 Redis 官方推荐的 Java 客户端,自带看门狗机制——自动续期,防止业务执行时间超过锁过期时间导致锁被误释放。
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.27.0</version></dependency>@ServicepublicclassOrderService{@AutowiredprivateRedissonClientredissonClient;publicvoiddeductStock(LongproductId){StringlockKey="lock:stock:"+productId;RLocklock=redissonClient.getLock(lockKey);try{// 尝试加锁,最多等待10秒,锁30秒后自动释放if(lock.tryLock(10,30,TimeUnit.SECONDS)){// 查询库存intstock=getStockFromDB(productId);if(stock<=0)thrownewRuntimeException("库存不足");// 扣减库存updateStock(productId,stock-1);}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{// 判断锁是否仍被当前线程持有再释放,防止误删if(lock.isHeldByCurrentThread()){lock.unlock();}}}}看门狗机制:tryLock第三个参数是锁过期时间。如果不传(使用单参数重载),Redisson 默认设 30 秒,然后每 10 秒自动续期一次。即便业务执行了 2 分钟,锁也不会过期。
六、完整示例:缓存查询 + 分布式锁防击穿
把缓存和分布式锁结合起来,做一道"防击穿缓存查询":
publicUsergetUserByIdWithCache(Longid){StringcacheKey="user:"+id;// 1. 先查缓存Useruser=(User)redisTemplate.opsForValue().get(cacheKey);if(user!=null)returnuser;// 2. 缓存未命中,加分布式锁StringlockKey="lock:user:"+id;RLocklock=redissonClient.getLock(lockKey);try{if(lock.tryLock(5,10,TimeUnit.SECONDS)){// 3. 双重检查user=(User)redisTemplate.opsForValue().get(cacheKey);if(user!=null)returnuser;// 4. 查数据库user=userMapper.selectById(id);if(user!=null){redisTemplate.opsForValue().set(cacheKey,user,30,TimeUnit.MINUTES);}}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{if(lock.isHeldByCurrentThread())lock.unlock();}returnuser;}七、缓存使用最佳实践总结
| 实践要点 | 说明 |
|---|---|
| 缓存什么 | 读多写少的数据(字典、配置、商品详情) |
| 不缓存什么 | 实时性要求高、频繁修改、超大对象 |
| 过期时间 | 热点数据 30min,普通数据 5-10min,随机偏移防雪崩 |
| Key 命名 | 业务:标识:ID如user:info:123,统一规范便于管理 |
| 序列化 | 生产环境用 JSON,避免 JDK 序列化的性能和可读性问题 |
| 监控 | 关注命中率,低于 90% 需要排查 |
Redis 用好是银弹,用不好是毒药。缓存更新的策略(先删缓存还是先更数据库)没有银弹,根据业务场景选择。分布式锁务必考虑超时和死锁场景,Redisson 的看门狗能救你半条命。
