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

原因分析

  1. 大量缓存同时过期:设置了相同的过期时间,集中在某个时间点失效
  2. 无兜底策略:缓存失效后没有降级方案,直接打崩数据库
  3. 没有预热:系统启动时没有提前加载热点数据

解决方案

方案一:随机过期时间 + 逻辑过期

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

原因分析

  1. 恶意攻击:攻击者故意访问大量不存在的 key
  2. 空值没缓存:null 值没有存入缓存,导致每次都查数据库
  3. 没有校验:没有对参数做基本校验

解决方案

方案一:缓存空值

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过期瞬间,大量并发同时访问数据库。

原因分析

  1. 热点key过期:某个热点数据刚好过期
  2. 并发过高:大量请求同时发现缓存不存在
  3. 无锁保护:没有机制让请求排队

解决方案

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

结果线上出现了严重问题:

  1. 锁过期了,但业务还没执行完,另一个线程拿到了锁
  2. 加锁后业务报异常,unlock 没有执行,锁永远不释放
  3. 多个服务实例各自加锁成功,都认为自己拿到了锁

原因分析

  1. 没有设置过期时间:锁不会自动释放
  2. 没有原子性:setIfAbsent 和 expire 不是原子操作
  3. 没有校验锁的持有者:释放了别人的锁
  4. 没有续期:业务执行时间长,锁自动过期了

解决方案

正确使用 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 越来越大。

原因分析

  1. 数据结构选用不当:购物车应该用 Hash,不应该用 String
  2. 序列化开销:每次都要 JSON 序列化/反序列化
  3. 原子性问题: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个致命坑:

  1. 缓存雪崩 → 随机过期时间 + 预热 + 降级方案
  2. 缓存穿透 → 缓存空值 + 布隆过滤器
  3. 缓存击穿 → 分布式锁 + 双重检查
  4. 分布式锁失效 → 使用 Redisson + 正确释放
  5. 数据结构选用错误 → 根据场景选择合适的数据结构

Redis 是后端开发中最常用的中间件之一,但其中的坑也非常多。希望我的血泪教训能帮你少走弯路。

你遇到过哪些 Redis 踩坑经历?欢迎在评论区分享,大家一起避坑!


如果本文对你有帮助,欢迎点赞、收藏、转发!我是 [二白同学],会持续分享实战开发干货。

Logo

更多推荐