如何保证数据库与 Redis 缓存的一致性?

发布于:2025-06-28 ⋅ 阅读:(22) ⋅ 点赞:(0)

在现代互联网应用中,Redis 缓存几乎是性能优化的标配。但在使用过程中,一个绕不过去的问题就是:

如何保证 Redis 缓存与数据库之间的数据一致性?

特别是在高并发场景下,读写操作错位可能导致缓存中出现脏数据,影响业务正确性。

问题背景

我们通常使用 Cache Aside 模式(旁路缓存):

  • :先查缓存,没有再查数据库并写入缓存

  • :更新数据库,再删除缓存

这个写入逻辑看似合理,但却存在很多坑。一不小心就会导致“缓存脏读”或“数据回滚”的问题。

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

这是很多开发者最初采用的方式。

🧠 原理:

updateDB(key, newValue)
deleteCache(key)

逻辑非常简单:先改数据库,再删缓存,期待下次读取会重新从数据库加载正确数据。

🖼️ 图解:

用户请求更新数据
        |
[1] 更新数据库 (新数据写入)
        |
[2] 删除 Redis 缓存

❌ 存在问题:

并发场景:
[1] updateDB(key, newValue)    ✅ 更新成功
[2] deleteCache(key)           ❌ 失败,缓存未清除

随后:
[3] 用户查询 getCache(key)    → 命中旧缓存 ❌

    结果是:

    • 数据库中是新值

    • 缓存中是旧值,长时间存在

    • 系统返回的是错误的数据

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

    🧠 原理:

    deleteCache(key)
    updateDB(key, newValue)
    

    🖼️ 图解:

    用户请求更新数据
            |
    [1] 删除 Redis 缓存
            |
    [2] 更新数据库
    

    ❌ 存在问题:

    并发场景:
    Time ---------->
    A: deleteCache(new) -------->
    B: getCache(old) ------------>
    B: writeCache(old) ------------>
    A: updateDb() ---------------->
    
    • 线程 A 删除缓存, 并在之后更新数据库;

    • 线程 B 在 A 删除缓存之前读取了旧缓存,随后把旧值写回缓存;

    • 最终缓存中是 旧数据,数据库是新数据 → ❌ 数据不一致!

    方案三:延迟双删(延迟兜底)

    延迟双删(Delayed Double Delete)是对Cache Aside模式的增强,通过两次删除缓存操作来减少不一致时间窗口。

    实现步骤

    1. 第一次删除:在更新数据库前,先删除缓存
    2. 更新数据库:执行实际的数据库更新操作
    3. 延迟第二次删除:在数据库更新完成后,延迟一段时间再次删除缓存

    public void updateData(Data newData) {
        // 第一次删除缓存
        cache.delete(newData.getId());
        
        // 更新数据库
        database.update(newData);
        
        // 延迟第二次删除
        executor.schedule(() -> {
            cache.delete(newData.getId());
        }, 500, TimeUnit.MILLISECONDS); // 延迟500ms
    }

    为什么需要延迟

    延迟的目的是为了处理以下场景:
    1. 在第一次删除后、数据库更新完成前,可能有请求读取了旧数据并重新填充缓存
    2. 数据库主从复制延迟可能导致从库读取到旧数据

    通过延迟第二次删除,可以清除这些潜在的不一致情况。

    延迟时间如何确定

    延迟时间应考虑:
    - 数据库主从复制延迟时间(通常100-500ms)
    - 业务对一致性的要求程度
    - 系统负载情况

    延迟双删的优化与变种

    异步重试机制

    当第二次删除失败时,可以采用异步重试机制确保最终一致性:
     

    public void deleteWithRetry(String key, int maxRetries) {
        int retries = 0;
        while (retries < maxRetries) {
            try {
                cache.delete(key);
                break;
            } catch (Exception e) {
                retries++;
                if (retries >= maxRetries) {
                    // 记录失败日志或放入死信队列
                    log.error("Failed to delete cache after {} retries", maxRetries);
                    break;
                }
                Thread.sleep(100 * retries); // 指数退避
            }
        }
    }

    方案四:分布式锁(强一致)

    🧠 原理:

    lock(key)
    deleteCache(key)
    updateDB(key)
    unlock(key)
    

    🖼️ 图解:

    用户请求更新数据
            |
    [1] 获取分布式锁
            |
    [2] 删除缓存
            |
    [3] 更新数据库
            |
    [4] 释放锁
    

    ✅ 优点:

    • 写操作串行化,强一致;

    • 不会发生并发导致的数据回滚。

    ❌ 缺点:

    • 实现复杂;

    • 性能开销大,需保证锁系统高可用。

    方案五:监听 Binlog 回刷缓存(如使用 Canal)

    🧠 原理:

    MySQL 更新数据
     ↓
    产生 Binlog
     ↓
    Canal 监听变更事件
     ↓
    主动删除或刷新缓存
    

    🖼️ 图解:

    数据库更新
            |
    [1] 生成 Binlog
            |
    [2] Canal 监听 Binlog
            |
    [3] 删除/刷新 Redis 缓存
    

    ✅ 优点:

    • 非侵入式,一致性强;

    • 精准捕获变化,自动驱动缓存刷新。

    ❌ 缺点:

    • 运维成本高;

    • 延迟取决于 Canal 拉取速度。

    各方案对比总结

    方案 是否一致 并发安全 实现复杂度 备注
    先更新 DB 再删缓存 ❌ 否 简单 常见误区,不能用
    先删缓存再更新 DB ⚠️ 部分 一定程度 简单 可配合延迟双删使用
    延迟双删 ✅ 最终一致 较高 简单 推荐大部分场景
    分布式锁 ✅ 强一致 中等 对性能影响较大
    Binlog + Canal 回刷缓存 ✅ 强一致 推荐核心数据系统使用

    总结

    Redis 缓存作为提升系统性能的利器,也带来了“一致性”的挑战。掌握各种一致性方案,能让你在面对不同业务需求时游刃有余。

    🔑 核心思想是:避免缓存与数据库数据错位时被读取或误写回缓存。