Redis 缓存失效与分布式锁实战踩坑:我被这些问题坑了3次后终于搞懂了
Redis 缓存失效与分布式锁实战踩坑:我被这些问题坑了3次后终于搞懂了
前言:作为一名后端开发,Redis 可以说是最常用的中间件了。但每次使用 Redis,我都会被各种奇怪的问题折磨到怀疑人生——缓存失效、分布式锁失效、数据不一致…本文将分享我在 Redis 实际使用中踩过的5个致命坑,每个坑都是血泪教训,建议先收藏再看。
一、缓存雪崩:为什么我的服务突然"炸了"?
踩坑现场
某天凌晨3点,监控突然报警:服务响应时间从 50ms 飙升到 5000ms,数据库 CPU 100%。我紧急上线一看,MySQL 直接被打崩了。
排查后发现:之前为了测试,我给所有缓存都设置了 2 小时过期时间,结果凌晨2点整大批缓存同时过期,所有请求都直接打到了数据库。
// 问题代码
public User getUserById(Long id) {
// 查缓存
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 缓存不存在,查数据库
User user = userMapper.selectById(id);
// 存入缓存,设置过期时间 2 小时
redis.setex(cacheKey, 7200, JSON.toJSONString(user));
return user;
}
原因分析
- 大量缓存同时过期:设置了相同的过期时间,集中在某个时间点失效
- 无兜底策略:缓存失效后没有降级方案,直接打崩数据库
- 没有预热:系统启动时没有提前加载热点数据
解决方案
方案一:随机过期时间 + 逻辑过期
public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
User user = JSON.parseObject(cached, User.class);
// 逻辑过期:缓存不过期,但数据可能是旧的
// 如果发现数据太旧,异步更新
if (isStale(user)) {
threadPool.execute(() -> refreshCache(id));
}
return user;
}
// 查数据库
User user = userMapper.selectById(id);
// 随机过期时间:2-3小时之间
int expireSeconds = 7200 + new Random().nextInt(3600);
redis.setex(cacheKey, expireSeconds, JSON.toJSONString(user));
return user;
}
方案二:布隆过滤器 + 缓存预热
@Configuration
public class CachePreheatConfig implements CommandLineRunner {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
@Override
public void run(String... args) {
// 系统启动时预热热点数据
List<User> hotUsers = userMapper.selectHotUsers();
for (User user : hotUsers) {
String cacheKey = "user:" + user.getId();
// 设置较长的过期时间
redisTemplate.opsForValue().set(cacheKey, user, 24, TimeUnit.HOURS);
}
}
}
方案三:服务降级(加锁排队)
public User getUserByIdWithLock(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 获取分布式锁
String lockKey = "lock:user:" + id;
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(acquired)) {
try {
// 双重检查
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
redis.setex(cacheKey, 7200, JSON.toJSONString(user));
return user;
} finally {
redis.delete(lockKey);
}
} else {
// 等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserByIdWithLock(id);
}
}
注意事项
- 缓存过期时间要随机化,不要设置固定时间
- 热点数据要预热
- 必须考虑缓存失效后的降级方案
二、缓存穿透:为什么我的数据库被"空值"打爆?
踩坑现场
某天运营突然说:有个用户不断访问一些不存在的ID(比如 -1、0、99999999),一开始我没在意,结果数据库被打崩了。
查看日志发现:这些不存在的用户每次都查询数据库,返回 null,但 null 不会缓存,所以每次都会查数据库。
// 问题代码
public User getUserById(Long id) {
if (id <= 0) {
return null; // 直接返回,不缓存
}
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return "null".equals(cached) ? null : JSON.parseObject(cached, User.class);
}
// 每次都查数据库
User user = userMapper.selectById(id);
return user;
}
原因分析
- 恶意攻击:攻击者故意访问大量不存在的 key
- 空值没缓存:null 值没有存入缓存,导致每次都查数据库
- 没有校验:没有对参数做基本校验
解决方案
方案一:缓存空值
public User getUserById(Long id) {
if (id <= 0) {
return null;
}
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
if ("null".equals(cached)) {
return null; // 缓存的空值
}
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
// 空值也要缓存,设置较短过期时间(如5分钟)
if (user == null) {
redis.setex(cacheKey, 300, "null");
} else {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
}
方案二:布隆过滤器
@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<String> bloomFilter() {
// 预期插入数量:100万,误判率:0.01
RBloomFilter<String> bloomFilter = RedisBloomFilter.create(
StrUitls.of("user:bloom"),
1_000_000,
0.01
);
return bloomFilter;
}
}
@Service
public class UserService {
@Autowired
private RBloomFilter<String> bloomFilter;
public User getUserById(Long id) {
String cacheKey = "user:" + id;
// 先检查布隆过滤器
if (!bloomFilter.mightContain(cacheKey)) {
// 一定不存在,直接返回
return null;
}
// 查缓存
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 查数据库
User user = userMapper.selectById(id);
if (user != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
}
}
注意事项
- 缓存空值时过期时间要短,不能太长
- 布隆过滤器有误判率,不能作为唯一方案
- 要做好参数校验,过滤非法输入
三、缓存击穿:为什么热点数据突然"失效"了?
踩坑现场
双11那天,某个大V用户的信息特别热门,缓存刚好过期了。结果瞬间 thousands of 请求同时打到数据库,数据库又被打崩了。
这就是典型的缓存击穿——热点key过期瞬间,大量并发同时访问数据库。
原因分析
- 热点key过期:某个热点数据刚好过期
- 并发过高:大量请求同时发现缓存不存在
- 无锁保护:没有机制让请求排队
解决方案
public User getUserByIdWithLock(Long id) {
String cacheKey = "user:" + id;
// 第一步:查缓存
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 第二步:获取分布式锁
String lockKey = "lock:user:" + id;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(acquired)) {
try {
// 第三步:双重检查(可能其他线程已经更新了缓存)
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 第四步:查数据库
User user = userMapper.selectById(id);
// 第五步:写入缓存(永不过期)
if (user != null) {
redis.set(cacheKey, JSON.toJSONString(user));
}
return user;
} finally {
// 第六步:释放锁
redis.delete(lockKey);
}
} else {
// 第七步:等待后重试(自旋)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserByIdWithLock(id);
}
}
使用 Redisson 实现分布式锁(推荐)
@Service
public class UserService {
@Autowired
private RedissonClient redissonClient;
public User getUserById(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 使用 Redisson 分布式锁
RLock lock = redissonClient.getLock("lock:user:" + id);
lock.lock(30, TimeUnit.SECONDS);
try {
// 双重检查
cached = redis.get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
注意事项
- 分布式锁要设置合理的过期时间
- 一定要双重检查,避免重复查询数据库
- 使用 Redisson 可以避免手写锁的很多坑
四、分布式锁失效:为什么我的锁"不管用"?
踩坑现场
我按照网上教程写了分布式锁:
// 问题代码
public Boolean lock(String key) {
return redisTemplate.opsForValue().setIfAbsent(key, "1");
}
public void unlock(String key) {
redisTemplate.delete(key);
}
结果线上出现了严重问题:
- 锁过期了,但业务还没执行完,另一个线程拿到了锁
- 加锁后业务报异常,unlock 没有执行,锁永远不释放
- 多个服务实例各自加锁成功,都认为自己拿到了锁
原因分析
- 没有设置过期时间:锁不会自动释放
- 没有原子性:setIfAbsent 和 expire 不是原子操作
- 没有校验锁的持有者:释放了别人的锁
- 没有续期:业务执行时间长,锁自动过期了
解决方案
正确使用 Redisson 分布式锁
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("password")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
}
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(Order order) {
String lockKey = "lock:order:" + order.getUserId();
// 获取锁(自动续期)
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待10秒,锁持有30秒
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("获取锁失败,请稍后重试");
}
// 执行业务逻辑
// 注意:Redisson 会自动续期,业务执行超过30秒会续期
orderMapper.insert(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断");
} catch (Exception e) {
throw new RuntimeException("下单失败:" + e.getMessage());
} finally {
// 释放锁(必须判断是否持有)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Lua 脚本实现(不依赖 Redisson)
@Service
public class LuaLockService {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:保证原子性
private static final String LOCK_SCRIPT =
"if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then " +
" return 1 " +
"else " +
" return 0 " +
"end";
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
// 加锁(使用 UUID 作为锁的持有者标识)
public Boolean lock(String key, String value, int seconds) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(key), value, String.valueOf(seconds));
return result != null && result == 1;
}
// 解锁(只释放自己的锁)
public Boolean unlock(String key, String value) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(key), value);
return result != null && result == 1;
}
}
注意事项
- 分布式锁必须保证原子性
- 必须校验锁的持有者
- 推荐使用 Redisson,它自动处理了续期问题
- 业务执行时间要预估好,设置合理的锁过期时间
五、Redis 数据结构选用错误:为什么我的数据"存取不对"?
踩坑现场
我需要存储用户的购物车数据,每个用户有多个商品。我一开始用的是 String:
// 问题代码:购物车用 String 存储
public void addToCart(Long userId, Long productId, int quantity) {
String key = "cart:" + userId;
// 每次都要先获取,再解析,再添加,再设置
String cartJson = redis.get(key);
Map<Long, Integer> cart = cartJson != null
? JSON.parseObject(cartJson, new TypeReference<Map<Long, Integer>>(){})
: new HashMap<>();
cart.put(productId, cart.getOrDefault(productId, 0) + quantity);
redis.set(key, JSON.toJSONString(cart));
}
public Map<Long, Integer> getCart(Long userId) {
String key = "cart:" + userId;
String cartJson = redis.get(key);
return cartJson != null
? JSON.parseObject(cartJson, new TypeReference<Map<Long, Integer>>(){})
: new HashMap<>();
}
结果每次添加商品都要:get → parse → modify → set,性能很差。而且商品多了之后,String 越来越大。
原因分析
- 数据结构选用不当:购物车应该用 Hash,不应该用 String
- 序列化开销:每次都要 JSON 序列化/反序列化
- 原子性问题:get→set 不是原子操作,可能丢数据
解决方案
@Service
public class CartService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 购物车使用 Hash 结构
public void addToCart(Long userId, Long productId, int quantity) {
String key = "cart:" + userId;
// HINCRBY:原子递增,不存在会自动创建
Long newQuantity = redisTemplate.opsForHash().increment(key, productId.toString(), quantity);
// 如果数量 <= 0,删除该商品
if (newQuantity <= 0) {
redisTemplate.opsForHash().delete(key, productId.toString());
}
}
public Map<Object, Object> getCart(Long userId) {
String key = "cart:" + userId;
return redisTemplate.opsForHash().entries(key);
}
public void removeFromCart(Long userId, Long productId) {
String key = "cart:" + userId;
redisTemplate.opsForHash().delete(key, productId.toString());
}
public void clearCart(Long userId) {
String key = "cart:" + userId;
redisTemplate.delete(key);
}
}
常用数据结构选择指南
| 场景 | 推荐数据结构 | 说明 |
|---|---|---|
| 单个对象缓存 | String | 简单对象,直接 JSON 序列化 |
| 对象属性频繁更新 | Hash | 只更新单个字段 |
| 购物车、计数器 | Hash | 原子递增/递减 |
| 排行榜、好友列表 | Sorted Set | 自动排序 |
| 标签、好友关系 | Set | 去重、集合运算 |
| 延迟队列 | Sorted Set | 时间戳作为 score |
| 分布式锁 | String + Lua | 保证原子性 |
注意事项
- 根据业务场景选择合适的数据结构
- Hash 比 String 更适合对象属性频繁更新的场景
- 注意原子性,优先使用 Redis 提供的原子命令
总结
以上就是我在 Redis 使用过程中踩过的5个致命坑:
- 缓存雪崩 → 随机过期时间 + 预热 + 降级方案
- 缓存穿透 → 缓存空值 + 布隆过滤器
- 缓存击穿 → 分布式锁 + 双重检查
- 分布式锁失效 → 使用 Redisson + 正确释放
- 数据结构选用错误 → 根据场景选择合适的数据结构
Redis 是后端开发中最常用的中间件之一,但其中的坑也非常多。希望我的血泪教训能帮你少走弯路。
你遇到过哪些 Redis 踩坑经历?欢迎在评论区分享,大家一起避坑!
如果本文对你有帮助,欢迎点赞、收藏、转发!我是 [二白同学],会持续分享实战开发干货。
更多推荐




所有评论(0)