【Redis】缓存读/写操作流程

Redis缓存读/写操作流程

1. Redis读操作和写操作

1.1 读操作

Redis读操作通常遵循以下流程:

复制代码
应用程序 -> 检查缓存 -> 缓存命中 -> 返回数据
              |
              v
           缓存未命中 -> 查询数据库 -> 更新缓存 -> 返回数据

读操作的关键点:

  • 缓存命中时直接返回数据,性能高
  • 缓存未命中时需要访问数据库,性能较低
  • 需要考虑缓存击穿、穿透、雪崩等问题

1.2 写操作

Redis写操作有多种策略,主要包括:

1.2 写操作策略对比

策略 流程 优点 缺点 适用场景
先更新DB,再更新缓存 DB更新 → 缓存更新 数据一致性较好 并发更新可能脏数据 读多写少,一致性要求高
先更新DB,再删除缓存 DB更新 → 缓存删除 简单,避免并发更新问题 可能短暂不一致 推荐:通用场景
先删除缓存,再更新DB 缓存删除 → DB更新 避免脏读 可能缓存击穿 写多读少,强一致性

每种策略都有其适用场景和潜在问题。

2. Redis内存淘汰机制

2.1 淘汰淘汰策略分类

Redis作为内存数据库,当内存使用达到上限时,需要根据淘汰策略来释放空间。Redis提供了多种内存淘汰策略:

2.1 淘汰策略分类

策略 描述
noeviction 默认策略,不淘汰数据,内存满时写操作返回错误
allkeys-lru 从所有key中淘汰最近最少使用的key
allkeys-lfu 从所有key中淘汰最不经常使用的key
allkeys-random 从所有key中随机淘汰
volatile-lru 从设置了过期时间的key中淘汰最近最少使用的key
volatile-lfu 从设置了过期时间的key中淘汰最不经常使用的key
volatile-random 从设置了过期时间的key中随机淘汰
volatile-ttl 从设置了过期时间的key中淘汰剩余时间最短的key

2.2 策略选择建议

  • allkeys-lru:适用于大部分场景,通用性好
  • allkeys-lfu:适用于访问模式相对固定的场景
  • volatile-ttl:适用于明确知道key过期时间的场景
  • noeviction:适用于缓存数据量可控且不希望数据被淘汰的场景

3. 业务场景中缓存更新策略是什么?

这是缓存更新策略中的经典问题,需要根据业务场景和数据一致性要求来选择。

核心概念

  • 缓存:Redis 等内存数据库,用于快速读取数据。
  • 数据库:MySQL 等持久化存储,是数据的"真相源"。
  • 线程1 和 线程2:两个并发执行的请求线程。

3.1 推荐方案:先更新数据库,再删除缓存


步骤顺序如下:

  1. 线程1 查询缓存未命中 -> 查询数据库(获取到 v=10)
  2. 线程2 更新数据库(v = 20)
  3. 线程2 删除缓存
  4. 线程1 写入缓存(把 v=10 写回缓存)

分析:

这种情况确实会发生,但概率较低。

因为通常步骤 1(读数据库)和步骤 4(写缓存)之间的时间间隔很短,在线程2更新数据库并删缓存的操作(步骤2、3)正好发生在它们之间的概率不高。

而且这种不一致会在下次更新或缓存过期时修复。

3.2 不推荐方案:先删除缓存,再更新数据库

步骤顺序如下:

线程1 删除缓存

线程2 查询缓存未命中 → 查数据库(旧值 20)

线程2 写入缓存(旧值 20)

线程1 更新数据库(新值 30)

结果:缓存中是旧值 20,数据库是新值 30,出现不一致。

高级优化建议(生产环境常用):

  1. 双删策略

    • 先删缓存
    • 再更新数据库
    • 延迟一段时间后再次删除缓存(防止其他线程在这期间写入旧值)
  2. 使用消息队列异步更新缓存

    • 数据库更新后发消息给 Redis 更新服务,保证最终一致性
  3. 设置缓存过期时间

    • 即使偶尔出现不一致,也能通过 TTL 自动恢复

总结一句话:

不要先改数据库再删缓存,否则可能让缓存写入旧数据,造成"脏读"。正确的做法是:先删缓存,再改数据库。

这正是图中右边被标记为"胜出"的原因 ------ 它是错误的,应该被淘汰!

4. 高一致性要求场景的解决方案

对于对数据一致性要求极高的场景,可以考虑以下方案:

41 延迟双删

java 复制代码
public void updateProductWithDelayDelete(Product product) {
    // 1. 删除缓存
    String cacheKey = "product:" + product.getId();
    redisCache.deleteProduct(cacheKey);
    
    // 2. 更新数据库
    productService.updateProductInDatabase(product);
    
    // 3. 延迟删除缓存(防止其他请求将旧数据写入缓存)
    Thread.sleep(100); // 短暂延迟
    redisCache.deleteProduct(cacheKey);
}

4.2 异步更新缓存

java 复制代码
public void updateProductWithAsyncCache(Product product) {
    // 1. 更新数据库
    productService.updateProductInDatabase(product);
    
    // 2. 删除缓存
    String cacheKey = "product:" + product.getId();
    redisCache.deleteProduct(cacheKey);
    
    // 3. 异步更新缓存
    executorService.submit(() -> {
        // 延迟一段时间后更新缓存,确保数据库事务已提交
        Thread.sleep(1000);
        Product updatedProduct = productService.getProductFromDatabase(product.getId());
        redisCache.setProduct(cacheKey, updatedProduct, 300); // 5分钟过期
    });
}

5. 实际应用思考

5.1 选择合适的缓存更新策略

  1. 读多写少:可以容忍短暂不一致,使用"先更新数据库,再删除缓存"
  2. 强一致性要求:使用延迟双删或异步更新缓存
  3. 写多读少:考虑是否真的需要缓存,或者使用较短的过期时间

5.2 监控和报警

  1. 缓存命中率监控:确保缓存有效
  2. 数据库查询次数监控:避免缓存失效导致数据库压力增大
  3. 缓存更新失败监控:及时发现和处理异常情况

5.3 缓存设计原则

  1. 合理设置过期时间:根据业务特点设置合适的过期时间
  2. 缓存预热:系统启动时预加载热点数据
  3. 缓存穿透防护:对空值也进行缓存
  4. 缓存雪崩防护:设置随机过期时间
  5. 缓存击穿防护:使用互斥锁或逻辑过期

6. 线程安全问题

在高并发场景下,缓存操作的线程安全是一个重要考虑因素。当多个线程同时访问缓存时,可能会出现以下线程安全问题:

5.1 缓存击穿与线程安全

缓存击穿是指热点数据在缓存中过期时,大量请求同时访问数据库的情况。这不仅会造成数据库压力,还可能导致线程安全问题。

1) 使用同步锁解决

最简单的解决方案是使用synchronized关键字:

java 复制代码
public Product getProductWithSynchronized(Long productId) {
    String cacheKey = "product:" + productId;
    
    // 1. 先从缓存中获取
    Product product = redisCache.getProduct(cacheKey);
    if (product != null) {
        return product;
    }
    
    // 2. 缓存中没有,需要从数据库获取,使用同步锁保证只有一个线程去查询数据库
    synchronized (this) {
        // 双重检查,可能其他线程已经查询并放入缓存
        product = redisCache.getProduct(cacheKey);
        if (product != null) {
            return product;
        }
        
        // 从数据库获取
        product = productService.getProductFromDatabase(productId);
        
        // 如果数据库中有数据,放入缓存
        if (product != null) {
            redisCache.setProduct(cacheKey, product, 5); // 设置5秒过期
        }
    }
    
    return product;
}

这种方法在单机环境下有效,但在分布式环境下无法跨节点生效。

2)使用分布式锁解决

在分布式系统中,需要使用分布式锁来保证线程安全:

java 复制代码
public Product getProductWithDistributedLock(Long productId) {
    String cacheKey = "product:" + productId;
    String lockKey = "lock:product:" + productId;
    
    // 1. 先从缓存中获取
    Product product = redisCache.getProduct(cacheKey);
    if (product != null) {
        return product;
    }
    
    // 2. 获取分布式锁
    Jedis jedis = jedisPool.getResource();
    try {
        // 尝试获取锁,超时时间10秒,过期时间30秒
        String lockValue = UUID.randomUUID().toString();
        boolean lockAcquired = jedis.setnx(lockKey, lockValue) == 1;
        if (lockAcquired) {
            jedis.expire(lockKey, 30);
            
            try {
                // 双重检查
                product = redisCache.getProduct(cacheKey);
                if (product != null) {
                    return product;
                }
                
                // 从数据库获取
                product = productService.getProductFromDatabase(productId);
                
                // 如果数据库中有数据,放入缓存
                if (product != null) {
                    redisCache.setProduct(cacheKey, product, 5);
                }
            } 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, 1, lockKey, lockValue);
            }
        } else {
            // 获取锁失败,短暂等待后重试
            Thread.sleep(100);
            return getProductWithDistributedLock(productId); // 递归重试
        }
    } finally {
        jedis.close();
    }
    
    return product;
}

5.2 缓存更新的线程安全

在缓存更新时,也需要考虑线程安全问题,特别是在高并发写操作场景下。

逻辑过期避免并发更新

逻辑过期是一种有效的线程安全方案,它避免了物理过期时的并发问题:

java 复制代码
public class LogicalExpireWrapper {
    private Product product;
    private long expireTime; // 逻辑过期时间戳
    
    // 构造函数、getter、setter省略
}

public Product getProductWithLogicalExpire(Long productId) {
    String cacheKey = "product_logical:" + productId;
    
    try (Jedis jedis = jedisPool.getResource()) {
        String cachedValue = jedis.get(cacheKey);
        if (cachedValue != null) {
            // 解析缓存值和逻辑过期时间
            LogicalExpireWrapper wrapper = objectMapper.readValue(cachedValue, LogicalExpireWrapper.class);
            
            // 检查是否逻辑过期
            if (System.currentTimeMillis() < wrapper.getExpireTime()) {
                return wrapper.getProduct();
            }
        }
        
        // 缓存不存在或已逻辑过期,需要查询数据库
        // 获取该商品的锁
        ReentrantLock lock = lockMap.computeIfAbsent(productId, k -> new ReentrantLock());
        lock.lock();
        
        try {
            // 双重检查
            String cachedValueAgain = jedis.get(cacheKey);
            if (cachedValueAgain != null) {
                LogicalExpireWrapper wrapper = objectMapper.readValue(cachedValueAgain, LogicalExpireWrapper.class);
                if (System.currentTimeMillis() < wrapper.getExpireTime()) {
                    return wrapper.getProduct();
                }
            }
            
            // 查询数据库
            Product product = productService.getProductFromDatabase(productId);
            
            if (product != null) {
                // 设置逻辑过期时间为5秒后
                LogicalExpireWrapper wrapper = new LogicalExpireWrapper(
                        product, 
                        System.currentTimeMillis() + 5000);
                String wrapperJson = objectMapper.writeValueAsString(wrapper);
                jedis.set(cacheKey, wrapperJson);
            }
            
            return product;
        } finally {
            lock.unlock();
        }
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

6. 总结

Redis缓存更新策略的选择需要综合考虑业务场景、数据一致性要求、系统复杂度等因素。

对于大多数业务场景,采用「先更新数据库,再删除缓存」策略,配合合适的过期时间和监控告警,即可满足性能和数据一致性要求。对于特殊的高并发、强一致性场景,再考虑使用更复杂的方案。

相关推荐
艾菜籽5 小时前
SpringMVC练习:加法计算器与登录
java·spring boot·spring·mvc
十安_数学好题速析6 小时前
数论探秘:如何用模4思想破解平方数谜题
笔记·学习·高考
程序员小凯6 小时前
Spring MVC 多租户架构与数据隔离教程
spring·架构·mvc
cmc10286 小时前
127.XIlinx fpga端的pcie(XDMA)与驱动是如何交换数据的
笔记·fpga开发
optimistic_chen6 小时前
【Java EE进阶 --- SpringBoot】Mybatis操作数据库(基础二)
xml·数据库·spring boot·笔记·java-ee·mybatis
Knight_AL6 小时前
代理模式 vs AOP:支付服务中的日志增强实践(含执行顺序详解)
spring·代理模式
摇滚侠6 小时前
Spring Boot 3零基础教程,properties文件中配置和类的属性绑定,笔记14
java·spring boot·笔记
洲覆7 小时前
基于 clangd 搭建 Redis 6.2 源码阅读与调试环境
开发语言·数据库·redis·缓存
凯子坚持 c7 小时前
Redis 事务深度解析:从基础到实践
数据库·redis·缓存