Redis缓存与数据库的双写一致性保卫战

91 阅读10分钟

📌 引言

在高并发系统架构中,Redis作为缓存中间件与数据库的配合使用已成为标配。然而,当我们同时操作缓存和数据库时,如何保证两者数据的一致性,成为了每个开发者必须面对的挑战。😓

本文将深入探讨Redis缓存与数据库双写不一致问题的原理、常见场景以及多种解决方案,并通过Java代码示例帮助你彻底理解并解决这一技术难题。

🤔 什么是缓存与数据库双写不一致问题?

问题本质

双写不一致问题指的是在更新操作中,缓存数据和数据库数据不一致的情况。当我们对一条数据进行更新时,需要同时更新数据库和缓存,而这两个更新操作的顺序以及并发情况,都可能导致数据不一致。

典型场景分析

在单线程环境下,无论是先更新数据库还是先更新缓存,只要没有异常发生,理论上都能保证数据一致性。但在以下情况下,问题就会显现:

  1. 单线程异常场景:在更新过程中发生异常,导致只有一方的数据被更新
  2. 高并发场景:多个线程同时操作同一数据,导致更新顺序错乱

🔍 双写不一致的核心场景分析

场景一:先更新数据库,再更新缓存

假设有两个并发请求A和B同时更新同一条数据:

  1. 请求A更新数据库中的值为1
  2. 请求B更新数据库中的值为2
  3. 请求B更新缓存中的值为2
  4. 请求A更新缓存中的值为1

最终结果:数据库中的值是2,而缓存中的值是1,出现了不一致!

场景一:先更新数据库,再更新缓存的并发问题

场景二:先更新缓存,再更新数据库

同样的并发请求A和B:

  1. 请求A更新缓存中的值为1
  2. 请求B更新缓存中的值为2
  3. 请求B更新数据库中的值为2
  4. 请求A更新数据库中的值为1

最终结果:缓存中的值是2,而数据库中的值是1,同样出现了不一致!

场景三:先删除缓存,再更新数据库(Cache Aside策略)

考虑"读+写"并发的情况:

  1. 请求A要更新数据,先删除缓存
  2. 请求B此时读取数据,发现缓存不存在,从数据库读取旧值
  3. 请求A更新数据库为新值
  4. 请求B将从数据库读取的旧值写入缓存

最终结果:数据库中是新值,缓存中是旧值,出现了不一致!

场景二:先删除缓存,再更新数据库的并发问题

场景四:先更新数据库,再删除缓存(推荐策略)

同样考虑"读+写"并发:

  1. 请求A从数据库读取数据,但还未写入缓存
  2. 请求B更新数据库,并删除缓存
  3. 请求A将旧值写入缓存

理论上也会出现不一致,但实际上这种情况发生概率很低,因为缓存的写入通常比数据库的写入要快得多

💡 主流解决方案及其优缺点

1. Cache Aside策略(旁路缓存模式)

这是最常用的缓存策略,包含读策略和写策略:

写策略

  • 更新数据库
  • 删除缓存(不是更新缓存)

读策略

  • 读取缓存,命中则返回
  • 未命中则读取数据库,并将结果写入缓存

优点:实现简单,适合读多写少的场景 缺点:在高并发下仍有一致性风险

2. 延迟双删策略

实现方式

  1. 删除缓存
  2. 更新数据库
  3. 休眠一段时间(例如500ms)
  4. 再次删除缓存

优点:能够降低不一致发生的概率
缺点:不能完全保证一致性,延迟时间难以确定

3. 分布式读写锁

实现方式

  • 对同一资源的读操作使用共享锁
  • 对写操作使用排他锁
  • 确保写操作时,不会有并发的读和写

优点:能够从根本上解决一致性问题
缺点:实现复杂,性能开销大,可能影响系统吞吐量

分布式锁解决方案

4. 消息队列(MQ)异步处理

实现方式

  1. 更新数据库
  2. 发送消息到MQ
  3. 消费者接收消息并删除对应缓存

优点:解耦数据库与缓存操作,提高系统可用性
缺点:增加系统复杂度,消息可能丢失

5. 数据库变更日志订阅(如Canal)

实现方式

  1. 订阅数据库的binlog
  2. 当检测到数据变更时,删除对应的缓存

优点:对业务代码无侵入
缺点:增加系统复杂度,依赖外部组件

🚀 如何选择合适的方案?

选择合适的方案需要考虑以下因素:

  1. 业务对一致性的要求程度

    • 对一致性要求极高:考虑分布式读写锁
    • 可容忍短暂不一致:Cache Aside + 缓存过期时间可能足够
  2. 系统的并发规模

    • 高并发:考虑MQ或Canal等异步方案
    • 并发不高:简单的Cache Aside可能足够
  3. 技术栈复杂度接受程度

    • 倾向于简单实现:Cache Aside + 缓存过期时间
    • 可接受复杂实现:分布式锁或异步消息方案

最佳实践建议:对于大多数系统,推荐采用"先更新数据库,再删除缓存"的策略,并设置合理的缓存过期时间作为兜底方案。

📝 Java代码实战:双写不一致问题演示与解决

场景一:先更新数据库,再更新缓存导致的不一致

/**
 * 场景一:先更新数据库,再更新缓存导致的不一致问题
 */
public static void demonstrateUpdateDbThenCache() throws InterruptedException {
    System.out.println("\n===== 场景一:先更新数据库,再更新缓存 =====");
    
    // 初始化数据
    String productId = "product:1";
    mysqlDb.update(productId, "原始商品信息");
    redisCache.set(productId, "原始商品信息");
    
    // 创建线程池和同步工具
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);
    
    // 线程A:更新商品信息为A
    executor.submit(() -> {
        try {
            System.out.println("线程A开始更新商品信息为A");
            mysqlDb.update(productId, "商品信息A");
            // 模拟线程A执行较慢
            Thread.sleep(300);
            redisCache.set(productId, "商品信息A");
            System.out.println("线程A完成更新");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    // 线程B:更新商品信息为B
    executor.submit(() -> {
        try {
            System.out.println("线程B开始更新商品信息为B");
            Thread.sleep(100); // 确保在线程A更新数据库后执行
            mysqlDb.update(productId, "商品信息B");
            redisCache.set(productId, "商品信息B");
            System.out.println("线程B完成更新");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    latch.await();
    
    // 检查最终状态
    System.out.println("\n最终状态检查:");
    System.out.println("数据库中的值: " + mysqlDb.select(productId));
    System.out.println("缓存中的值: " + redisCache.get(productId));
    System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
    
    executor.shutdown();
}

场景二:先删除缓存,再更新数据库导致的不一致

/**
 * 场景二:先删除缓存,再更新数据库导致的不一致问题
 */
public static void demonstrateDeleteCacheThenUpdateDb() throws InterruptedException {
    System.out.println("\n===== 场景二:先删除缓存,再更新数据库 =====");
    
    // 初始化数据
    String productId = "product:2";
    mysqlDb.update(productId, "原始商品信息");
    redisCache.set(productId, "原始商品信息");
    
    // 创建线程池和同步工具
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);
    
    // 线程A:更新商品信息
    executor.submit(() -> {
        try {
            System.out.println("线程A开始更新商品");
            // 先删除缓存
            redisCache.delete(productId);
            
            // 模拟网络延迟,此时线程B可能会读取数据
            Thread.sleep(200);
            
            // 再更新数据库
            mysqlDb.update(productId, "更新后的商品信息");
            System.out.println("线程A完成更新");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    // 线程B:读取商品信息
    executor.submit(() -> {
        try {
            // 确保在缓存删除后执行
            Thread.sleep(100);
            
            System.out.println("线程B开始读取商品");
            // 缓存中没有数据,从数据库读取
            Object value = redisCache.get(productId);
            if (value == null) {
                System.out.println("线程B发现缓存未命中");
                value = mysqlDb.select(productId);
                // 将从数据库读取的旧值写入缓存
                redisCache.set(productId, value);
            }
            System.out.println("线程B读取完成: " + value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    latch.await();
    
    // 检查最终状态
    System.out.println("\n最终状态检查:");
    System.out.println("数据库中的值: " + mysqlDb.select(productId));
    System.out.println("缓存中的值: " + redisCache.get(productId));
    System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
    
    executor.shutdown();
}

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

/**
 * 场景三:先更新数据库,再删除缓存的解决方案
 */
public static void demonstrateUpdateDbThenDeleteCache() throws InterruptedException {
    System.out.println("\n===== 场景三:先更新数据库,再删除缓存(推荐方案) =====");
    
    // 初始化数据
    String productId = "product:3";
    mysqlDb.update(productId, "原始商品信息");
    redisCache.set(productId, "原始商品信息");
    
    // 创建线程池和同步工具
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);
    
    // 线程A:更新商品信息
    executor.submit(() -> {
        try {
            System.out.println("线程A开始更新商品");
            // 先更新数据库
            mysqlDb.update(productId, "更新后的商品信息");
            
            // 再删除缓存
            redisCache.delete(productId);
            System.out.println("线程A完成更新");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    // 线程B:读取商品信息
    executor.submit(() -> {
        try {
            // 确保在数据库更新后执行
            Thread.sleep(250);
            
            System.out.println("线程B开始读取商品");
            // 尝试从缓存读取
            Object value = redisCache.get(productId);
            if (value == null) {
                System.out.println("线程B发现缓存未命中");
                value = mysqlDb.select(productId);
                // 将从数据库读取的新值写入缓存
                redisCache.set(productId, value);
            }
            System.out.println("线程B读取完成: " + value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    });
    
    latch.await();
    
    // 检查最终状态
    System.out.println("\n最终状态检查:");
    System.out.println("数据库中的值: " + mysqlDb.select(productId));
    System.out.println("缓存中的值: " + redisCache.get(productId));
    System.out.println("数据一致性: " + mysqlDb.select(productId).equals(redisCache.get(productId)));
    
    executor.shutdown();
}

分布式锁解决方案

/**
 * 使用分布式锁保证缓存与数据库的一致性
 */
public static class ProductService {
    private static final String LOCK_PREFIX = "lock:product:";
    private static final long LOCK_EXPIRE_TIME = 5000; // 5秒过期
    
    // 更新商品信息
    public void updateProduct(String productId, String productInfo) {
        String lockKey = LOCK_PREFIX + productId;
        String requestId = UUID.randomUUID().toString();
        
        try {
            // 尝试获取分布式锁
            boolean locked = redisClient.tryLock(lockKey, requestId, LOCK_EXPIRE_TIME);
            if (!locked) {
                System.out.println("获取锁失败,稍后重试");
                return;
            }
            
            // 获取锁成功,执行更新操作
            try {
                // 1. 更新数据库
                database.update(productId, productInfo);
                
                // 2. 删除缓存
                redisClient.delete(productId);
                
                System.out.println("商品信息更新成功: " + productId);
            } finally {
                // 释放锁
                redisClient.releaseLock(lockKey, requestId);
            }
        } catch (Exception e) {
            System.err.println("更新商品信息异常: " + e.getMessage());
        }
    }
    
    // 获取商品信息
    public Object getProduct(String productId) {
        String lockKey = LOCK_PREFIX + productId;
        String requestId = UUID.randomUUID().toString();
        
        try {
            // 1. 先查询缓存
            Object productInfo = redisClient.get(productId);
            if (productInfo != null) {
                return productInfo;
            }
            
            // 2. 缓存未命中,尝试获取分布式锁
            boolean locked = redisClient.tryLock(lockKey, requestId, LOCK_EXPIRE_TIME);
            if (!locked) {
                // 获取锁失败,直接查询数据库但不更新缓存
                System.out.println("获取锁失败,直接查询数据库");
                return database.select(productId);
            }
            
            try {
                // 双重检查,再次检查缓存
                productInfo = redisClient.get(productId);
                if (productInfo != null) {
                    return productInfo;
                }
                
                // 3. 查询数据库
                productInfo = database.select(productId);
                
                // 4. 更新缓存
                if (productInfo != null) {
                    redisClient.set(productId, productInfo);
                }
                
                return productInfo;
            } finally {
                // 释放锁
                redisClient.releaseLock(lockKey, requestId);
            }
        } catch (Exception e) {
            System.err.println("获取商品信息异常: " + e.getMessage());
            // 发生异常时,直接查询数据库
            return database.select(productId);
        }
    }
}

📊 总结与最佳实践

在处理Redis缓存与数据库双写一致性问题时,我们需要根据业务场景和一致性要求选择合适的方案:

  1. 对于一般业务场景,采用"先更新数据库,再删除缓存"的策略,并设置合理的缓存过期时间作为兜底方案。
  2. 对于高并发且对一致性要求较高的场景,可以考虑"延迟双删"或"消息队列异步处理"方案。
  3. 对于一致性要求极高的核心业务,可以使用"分布式读写锁"方案,但需要权衡性能开销。
  4. 无论采用哪种方案,都应该设置缓存过期时间,作为最终的兜底措施。
  5. 在实际业务中,应该避免过度设计,根据业务对一致性的实际需求选择合适的方案,在一致性和性能之间找到平衡点。

记住,没有完美的解决方案,只有最适合你业务场景的方案。希望本文能帮助你更好地理解和解决Redis缓存与数据库双写一致性问题!