Redis 缓存

Redis 缓存

缓存的通用模型

缓存与数据库的协同工作有三种经典模式:

  • Cache Aside(旁路缓存) :由应用层负责维护缓存与数据库的一致性

    • 查询:先查缓存,命中则返回;未命中则查数据库,并将结果写入缓存
    • 更新 :先更新数据库,再删除缓存(而非更新缓存)
    • 优点:实现简单、灵活性高
    • ⚠️ 缺点:无法保证强一致性,存在短暂不一致窗口
    • 📌 当前最主流的方案
  • Read/Write Through(读写穿透) :由缓存层代理数据库读写

    • 查询:缓存命中返回;未命中由缓存自动查 DB 并回填
    • 更新:直接写缓存,缓存同步更新 DB
    • ✅ 优点:对应用透明
    • ⚠️ 缺点:缓存需实现复杂逻辑,Redis 本身不支持,需自研中间件
  • Write Behind Caching(写回缓存)写操作只更新缓存,由后台异步线程批量将变更写入数据库

    • 读操作:优先读缓存
    • ✅ 优点:极大提升写性能(适用于日志、计数器等场景)
    • ⚠️ 缺点:一致性最弱,系统崩溃可能丢数据;实现复杂(需处理顺序、重试)
    • 📌 注意 :不是"先写 DB 再同步缓存",而是先写缓存,异步刷 DB

💡 目前绝大多数系统采用 Cache Aside 模型,因其简单、可控、易于调试。


缓存一致性

Cache Aside 模型中,更新操作通常有两种顺序,但都存在并发风险:

方案一:先删除缓存,再更新数据库 ❌(不推荐)

  • 线程1 删除缓存 → 正在更新 DB
  • 线程2 查询:缓存空 → 查 DB(此时 DB 还是旧值)→ 将旧值写入缓存
  • 后续请求全部读到脏数据,且长期不一致

方案二:先更新数据库,再删除缓存 ✅(推荐)

  • 线程1 更新 DB → 删除缓存
  • 线程2 查询:若在删缓存前,会读到旧缓存(短暂不一致,但数据最终正确)
  • 若在删缓存后,会查 DB 获取最新值并重建缓存

为什么选方案二?

虽然仍存在"短暂旧数据返回"的可能,但不会将脏数据写回缓存,最终一致性可保障。

⚠️ 仍需注意的问题

  1. 删除缓存失败

    • 若 DB 更新成功,但删缓存失败 → 长期不一致
    • 解决方案
      • 异步重试(如通过消息队列)
      • 监控告警 + 人工介入
  2. 极端场景下的不一致

    • 可采用 "延迟双删"

      text 复制代码
      删除缓存 → 更新数据库 → sleep(100ms) → 再次删除缓存
    • 目的:防止在更新 DB 期间有旧请求重建缓存

✅ Java 示例:先更新 DB,再删除缓存

java 复制代码
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String USER_CACHE_KEY = "user:";

    // 查询用户
    public User getUserById(Long id) {
        String key = USER_CACHE_KEY + id;
        User user = (User) redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }

        // 缓存未命中,查数据库
        user = userMapper.selectById(id);
        if (user != null) {
            // 设置随机 TTL(防雪崩)
            long ttl = 3600 + new Random().nextInt(300); // 1h ~ 1h5min
            redisTemplate.opsForValue().set(key, user, ttl, TimeUnit.SECONDS);
        } else {
            // 防穿透:缓存空值
            redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
        }
        return user;
    }

    // 更新用户(先更新 DB,再删缓存)
    @Transactional
    public void updateUser(User user) {
        userMapper.updateById(user); // 1. 更新数据库
        String key = USER_CACHE_KEY + user.getId();
        redisTemplate.delete(key);   // 2. 删除缓存
        // ✅ 生产建议:若删除失败,可发消息到 MQ 重试
    }
}

三大缓存异常问题

即使采用正确的一致性策略,仍可能遭遇以下三类高并发场景下的缓存危机:

1. 缓存穿透(Cache Penetration)

  • 定义 :查询一个根本不存在的数据(缓存无,DB 也无)
  • 特点:key 不存在于任何存储层
  • 危害
    • 数据库承受大量无效查询
    • 可被恶意利用进行 DoS 攻击
✅ 解决方案
  • 空值缓存(Null Cache)
  • 布隆过滤器(Bloom Filter)
✅ Java 示例:布隆过滤器(Guava 单机版)
java 复制代码
@Component
public class BloomFilterService {

    private BloomFilter<Long> userIdBloomFilter = 
        BloomFilter.create(Funnels.longFunnel(), 1_000_000, 0.01);

    @PostConstruct
    public void init() {
        // 启动时加载所有合法用户 ID
        List<Long> allUserIds = userMapper.selectAllIds();
        allUserIds.forEach(userIdBloomFilter::put);
    }

    public boolean mightExist(Long userId) {
        return userIdBloomFilter.mightContain(userId);
    }

    public void addUserToBloom(Long userId) {
        userIdBloomFilter.put(userId);
    }
}

// 使用示例
@Service
public class SafeUserService {
    @Autowired
    private BloomFilterService bloomFilterService;

    public User safeGetUser(Long id) {
        if (!bloomFilterService.mightExist(id)) {
            return null; // 一定不存在,直接返回
        }
        return userService.getUserById(id); // 走正常缓存流程
    }
}

⚠️ 注意:Guava 是单机内存版。分布式环境建议使用 RedisBloom 模块 或自研分片布隆过滤器。


2. 缓存雪崩(Cache Avalanche)

  • 定义大量缓存 key 在同一时间失效,导致瞬时所有请求打到数据库
  • 特点:多 key 集体失效,缓存层"崩塌"
  • 危害
    • 数据库 QPS 瞬间飙升,可能被打挂
    • 整体服务不可用
✅ 解决方案
  • 设置随机 TTL
  • 热点数据永不过期(逻辑过期)
  • 多级缓存
✅ Java 示例:随机 TTL + 逻辑过期
java 复制代码
// 随机 TTL(通用)
long baseTTL = 3600;
long randomTTL = baseTTL + new Random().nextInt(300);
redisTemplate.opsForValue().set(key, data, randomTTL, TimeUnit.SECONDS);

// 逻辑过期封装类
public static class LogicalCache<T> {
    private T data;
    private long expireTime; // 毫秒时间戳
    // getter/setter
}

// 写入逻辑过期缓存
LogicalCache<User> cache = new LogicalCache<>();
cache.setData(user);
cache.setExpireTime(System.currentTimeMillis() + 3600_000);
redisTemplate.opsForValue().set("hot:user:" + id, cache);

// 读取(配合后台刷新线程)
public User getUserWithLogicalExpire(Long id) {
    String key = "hot:user:" + id;
    LogicalCache<User> cache = (LogicalCache<User>) redisTemplate.opsForValue().get(key);
    if (cache != null) {
        if (System.currentTimeMillis() > cache.getExpireTime()) {
            refreshUserCacheAsync(id); // 异步刷新
        }
        return cache.getData(); // 即使过期也返回旧值
    }
    return loadFromDBAndSetCache(id);
}

3. 缓存击穿(Cache Breakdown)

  • 定义某个热点 key 在过期瞬间,大量并发请求同时发现缓存失效,全部查 DB
  • 特点:单个 key 失效 + 高并发 → DB 瞬时压力
✅ 解决方案
  • 互斥锁(Mutex Lock)
  • 热点 key 永不过期
✅ Java 示例:分布式互斥锁重建缓存
java 复制代码
public User getUserWithMutex(Long id) {
    String key = "user:" + id;
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user != null) return user;

    String lockKey = "lock:user:" + id;
    Boolean isLocked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofMillis(500)); // 原子加锁,500ms超时

    if (Boolean.TRUE.equals(isLocked)) {
        try {
            // 双重检查
            user = (User) redisTemplate.opsForValue().get(key);
            if (user != null) return user;

            user = userMapper.selectById(id);
            if (user != null) {
                redisTemplate.opsForValue().set(key, user, 3600, TimeUnit.SECONDS);
            } else {
                redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
            }
            return user;
        } finally {
            redisTemplate.delete(lockKey); // 释放锁
        }
    } else {
        // 未获取锁,短暂等待后重试
        try {
            Thread.sleep(50);
            return getUserWithMutex(id);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }
}

✅ 关键:SET key value NX EX 实现原子锁,必须设超时防死锁。


🛡️ 附加:多级缓存(本地 + Redis)

java 复制代码
private final Cache<Long, User> localCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build();

public User getUserMultiLevel(Long id) {
    // 1. 本地缓存
    User user = localCache.getIfPresent(id);
    if (user != null && !"".equals(user)) return user;

    // 2. Redis
    String redisKey = "user:" + id;
    user = (User) redisTemplate.opsForValue().get(redisKey);
    if (user != null) {
        localCache.put(id, user);
        return user;
    }

    // 3. DB
    user = userMapper.selectById(id);
    if (user != null) {
        redisTemplate.opsForValue().set(redisKey, user, 3600 + new Random().nextInt(300), SECONDS);
        localCache.put(id, user);
    } else {
        redisTemplate.opsForValue().set(redisKey, "", 60, SECONDS);
        localCache.put(id, new User()); // 空对象标记
    }
    return user;
}

✅ 最佳实践总结

问题 推荐方案 Java 实现要点
缓存模型 Cache Aside 先 update DB → delete cache
缓存穿透 空值缓存 + 布隆过滤器 Guava BloomFilter(单机)或 RedisBloom
缓存雪崩 随机 TTL / 逻辑过期 new Random().nextInt() + LogicalCache
缓存击穿 互斥锁 setIfAbsent(..., Duration) + 双重检查
高可用 多级缓存 Caffeine + Redis

💡 核心思想

缓存不是银弹,没有 100% 一致性

所有方案都是在 一致性、可用性、性能 之间做权衡。

根据业务容忍度选择合适策略,才是工程之道。


作者:不会写程序的未来程序员

首发于 CSDN

版权声明:本文为原创文章,转载请注明出处。

相关推荐
kkkkkkkkl244 小时前
从「知道死锁」到「真正理解死锁」:一次 MySQL 锁机制的学习记录
数据库·mysql
He BianGu4 小时前
【笔记】在WPF App.cs中结合 IServiceCollection 进行 IOC 依赖注入
数据库·笔记·wpf
柯南二号4 小时前
【后端】【Java】《Spring Boot 统一接口耗时统计实践:基于 HandlerInterceptor 的工程级方案》
java·开发语言·数据库
m0_740043734 小时前
SpringBoot03-Mybatis框架入门
java·数据库·spring boot·sql·spring·mybatis
Logic1014 小时前
《数据库运维》 郭文明 实验2 MySQL数据库对象管理核心操作与思路解析
运维·数据库·mysql·学习笔记·计算机网络技术·形考作业·国家开放大学
脸大是真的好~4 小时前
黑马消息队列-rabbitMQ2-生产者重连机制-生产者确认机制-数据持久化-LazyQueue-消费者确认机制-失败重试机制-重试耗尽告警手动处理-
java·缓存·中间件
一 乐4 小时前
心理健康管理|基于springboot + vue心理健康管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
yuguo.im4 小时前
SQL 分析函数 `PERCENTILE_CONT` 的兼容性与深度解析
数据库·sql