Redis 缓存和数据库一致性,到底怎么搞?

缓存和数据库不一致,是后端开发里一个绕不开的问题。

很多人一开始觉得,不就是"写库之后删缓存"吗?但真到线上,你会发现事情没那么简单:

  • 删缓存失败了怎么办?
  • 并发读写会不会出现脏数据?
  • 先删缓存还是先更新数据库?
  • 延迟双删到底有没有必要?
  • 要不要用 Canal 监听 binlog?
  • 强一致和最终一致怎么取舍?

这篇文章不堆概念,直接从常见方案 → 容易踩坑的地方 → 工程上怎么选讲清楚。


一、先明确一个前提:缓存一致性不是"绝对一致"

很多文章一上来就讲方案,但容易忽略一个关键点:

在大多数业务场景里,缓存和数据库要的不是"强一致",而是"可接受的最终一致"。

比如商品详情页、用户信息、配置信息,通常允许短暂不一致。

但如果是库存、余额、订单状态这类场景,就不能只靠缓存兜底,甚至很多字段根本不适合放缓存。

所以第一步不是选方案,而是先判断:

表格

场景 一致性要求 建议
商品详情、文章、配置 最终一致,秒级可接受 适合 Redis 缓存
库存、余额、支付状态 强一致或准强一致 谨慎缓存,数据库为主
高频读、低频写 适合缓存 重点解决失效策略
高频写、低频读 不适合强缓存 缓存收益低,风险高

如果业务本身就要求"写完必须立刻看到最新值",那缓存方案再花哨也没用,应该优先考虑数据库一致性、事务、锁、版本号这些机制。


二、最常见的方案:Cache Aside Pattern

最经典的做法就是:

  • 读:先读缓存,缓存没有再读数据库,然后回填缓存
  • 写:先更新数据库,再删除缓存

伪代码大概长这样:

复制代码
public User getUser(Long userId) {
    String key = "user:" + userId;

    // 1. 先查缓存
    String cacheValue = redis.get(key);
    if (cacheValue != null) {
        return JSON.parseObject(cacheValue, User.class);
    }

    // 2. 缓存没有,查数据库
    User user = userMapper.selectById(userId);

    // 3. 回填缓存
    if (user != null) {
        redis.set(key, JSON.toJSONString(user), 60 * 5);
    }

    return user;
}

更新的时候:

复制代码
@Transactional
public void updateUser(User user) {
    // 1. 先更新数据库
    userMapper.updateById(user);

    // 2. 再删除缓存
    redis.del("user:" + user.getId());
}

这个方案看起来很简单,也是大多数项目默认选择。

但它不是没有问题。


三、为什么是"删除缓存",不是"更新缓存"?

很多新人会问:

既然数据库更新了,为什么不直接把新值写进缓存?

因为更新缓存容易出问题

举个例子:

线程 A 和线程 B 同时更新同一个用户。

如果采用"更新缓存":

  1. 线程 A 更新数据库为 v2
  2. 线程 B 更新数据库为 v3
  3. 线程 B 先更新缓存为 v3
  4. 线程 A 后更新缓存为 v2

结果数据库是 v3,缓存却是 v2,脏数据就来了。

而"删除缓存"相对更安全:

  • 不主动写新值
  • 下次读取时再回填
  • 避免多个写请求竞争缓存值

所以一般推荐:

写操作优先删除缓存,而不是更新缓存。

但删除缓存也不是万无一失。


四、先删缓存,还是先更新数据库?

这里有三种组合:

表格

方案 说明
先删缓存,再更新数据库 容易出现并发脏读
先更新数据库,再删缓存 相对更稳,推荐
只更新数据库,不处理缓存 除非缓存 TTL 很短,否则风险大

1. 先删缓存,再更新数据库:不推荐

这个顺序最大的问题是并发读会导致脏数据。

时序大概是这样的:

  1. 线程 A 删除缓存
  2. 线程 B 读缓存,发现没有
  3. 线程 B 读数据库,拿到旧值
  4. 线程 B 把旧值写回缓存
  5. 线程 A 更新数据库为新值

最后数据库是新值,缓存还是旧值。

这个场景在实际业务里并不罕见,尤其是读多写少的接口,并发一上来就可能触发。

2. 先更新数据库,再删除缓存:更稳

这个顺序也有问题:

  1. 线程 A 更新数据库成功
  2. 线程 A 删除缓存失败

这时候数据库是新值,缓存还是旧值。

但相比"先删缓存",它的风险更可控。

因为删除缓存失败只是暂时不一致,下一次写操作还会再删一次;如果缓存设置了 TTL,也不会一直脏下去。

所以工程上一般默认:

先更新数据库,再删除缓存。


五、删除缓存失败了怎么办?

这才是真正落地时最头疼的问题。

数据库事务成功了,但 Redis 删除失败,怎么办?

常见做法有三种:

1. 重试删除

最简单的方式,就是在删除失败后重试。

复制代码
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);

    boolean deleted = false;
    int retryTimes = 3;

    for (int i = 0; i < retryTimes; i++) {
        if (redis.del("user:" + user.getId()) != null) {
            deleted = true;
            break;
        }
    }

    if (!deleted) {
        // 记录日志、报警、丢消息补偿
        log.error("删除缓存失败,userId={}", user.getId());
    }
}

这种方案简单,但不够可靠。

因为重试也可能失败,而且本地重试解决不了服务宕机、网络分区、Redis 异常这些情况。

2. 异步消息补偿

更稳一点的做法是:

数据库更新成功后,发一条消息到 MQ,由消费者负责删除缓存。

流程大概是:

  1. 更新数据库
  2. 发送"删除缓存"消息
  3. 消费者收到消息后删除 Redis
  4. 失败则重试

这种方式比本地重试可靠,但要注意:

  • 消息不能丢
  • 消费者要幂等
  • 消息延迟会影响一致性窗口
  • MQ 本身也要保证高可用

如果只是普通业务数据,用 MQ 补偿可以接受。

但如果是核心链路,还得考虑消息和数据库事务之间的一致性。

3. 监听 binlog 删除缓存

更彻底一点的做法是监听数据库 binlog,比如用 Canal。

流程:

  1. 业务服务只负责更新数据库
  2. Canal 监听 binlog
  3. 解析变更事件
  4. 异步删除对应缓存

这种方式的好处是业务代码和缓存失效逻辑解耦。

但缺点也很明显:

  • 架构复杂度上升
  • 需要维护 Canal、MQ、消费端
  • binlog 解析有延迟
  • 缓存 key 和数据库表字段映射要设计好
  • 不是所有数据库都方便开 binlog

所以 binlog 方案不是不能用,而是别一上来就上


六、延迟双删有没有必要?

延迟双删的说法很常见:

  1. 先删除缓存
  2. 更新数据库
  3. 延迟一段时间后再删除一次缓存

目的是处理这种场景:

  • 缓存删除后
  • 有读请求把旧值重新加载进缓存
  • 数据库还没来得及更新完成

所以再延迟删一次,把可能被污染的缓存清掉。

伪代码:

复制代码
public void updateUser(User user) {
    redis.del("user:" + user.getId());

    userMapper.updateById(user);

    // 延迟再删一次
    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        redis.del("user:" + user.getId());
    });
}

这个方案不是完全没用,但实际工程里要谨慎。

问题在于:

  • 延迟多久合适?没有标准答案
  • 如果延迟太短,可能没覆盖到并发读
  • 如果延迟太长,又会影响性能
  • 异步删除失败怎么办?
  • 本质上还是在打补丁

如果业务真的对一致性要求很高,延迟双删只能算辅助手段,不能当主方案。

更推荐的做法还是:

先更新数据库,再删除缓存,再配合重试、MQ 或 binlog 补偿。


七、缓存击穿、穿透、雪崩也会影响一致性

很多人把一致性问题只理解成"写操作没删缓存",其实读链路也会出问题。

1. 缓存穿透

缓存穿透是指查询一个根本不存在的数据,缓存和数据库都没有,每次请求都打到数据库。

常见解法:

  • 缓存空值
  • 布隆过滤器
  • 参数校验前置

缓存空值要注意 TTL 不能太长,否则会影响数据恢复。

复制代码
User user = userMapper.selectById(userId);

if (user == null) {
    redis.set(key, "", 60);
    return null;
}

redis.set(key, JSON.toJSONString(user), 60 * 5);

2. 缓存击穿

某个热点 key 突然失效,大量请求同时打到数据库。

常见解法:

  • 互斥锁
  • 逻辑过期
  • 热点 key 永不过期 + 异步刷新

互斥锁简单,但会降低并发。

复制代码
String lockKey = "lock:user:" + userId;

if (redis.set(lockKey, "1", "NX", "EX", 5)) {
    try {
        User user = userMapper.selectById(userId);
        if (user != null) {
            redis.set(key, JSON.toJSONString(user), 60 * 5);
        }
    } finally {
        redis.del(lockKey);
    }
}

3. 缓存雪崩

大量 key 同时过期,或者 Redis 挂了,导致数据库压力暴增。

常见解法:

  • TTL 加随机值

  • 多级缓存

  • 限流降级

  • Redis 高可用

    int expire = 60 * 5 + RandomUtils.nextInt(0, 60);
    redis.set(key, value, expire);

这些问题和一致性有关,但不是一回事。

一致性更关注"数据对不对",击穿、穿透、雪崩更关注"系统扛不扛得住"。


八、强一致场景怎么处理?

如果是库存、余额、优惠券核销、支付状态这种场景,不能只靠"删缓存"解决。

这类数据建议:

  • 数据库事务为主
  • 缓存只做加速读
  • 写操作不能依赖缓存结果
  • 关键操作加乐观锁或分布式锁
  • 必要时直接不缓存,或者缓存很短时间

比如库存扣减,不能这样:

复制代码
Integer stock = redis.get("stock:" + skuId);

if (stock > 0) {
    stock--;
    redis.set("stock:" + skuId, stock);
}

这种写法在并发场景下很容易出问题。

更稳的方式是数据库层面控制:

复制代码
UPDATE stock
SET stock = stock - 1
WHERE sku_id = #{skuId}
  AND stock > 0;

缓存可以后续异步刷新,但不能让缓存决定业务正确性。


九、工程上怎么选?

不用把方案想得太复杂,可以按这个顺序判断:

1. 普通读多写少场景

比如用户信息、商品详情、文章、配置。

推荐:

  • 先更新数据库
  • 再删除缓存
  • 缓存设置合理 TTL
  • 删除失败做重试或异步补偿

这个方案已经能覆盖大部分业务。

2. 对一致性要求稍高的场景

比如订单状态、用户等级、积分余额展示。

推荐:

  • 先更新数据库
  • 删除缓存失败后发 MQ 补偿
  • 消费者幂等删除
  • 关键接口可以加版本号或短缓存

3. 写操作非常频繁的场景

比如实时排行榜、计数器、频繁变更的状态。

推荐:

  • 不要盲目缓存
  • 如果缓存,TTL 要短
  • 考虑批量刷新
  • 避免每次写都删缓存造成大量失效

4. 强一致场景

比如库存、余额、支付、核销。

推荐:

  • 数据库事务为主
  • 缓存只做读加速
  • 写链路不依赖缓存
  • 必要时加锁或版本号控制

十、几个容易踩坑的点

1. 不要把缓存当成数据源

缓存只是数据库的加速层。

如果缓存和数据库冲突,最终应该以数据库为准。

2. 不要给所有字段都加缓存

有些字段更新频繁,但读取不频繁,缓存意义不大。

有些字段涉及金额、状态、权限,缓存错了代价很高。

3. 不要迷信延迟双删

延迟双删能缓解部分并发问题,但不能解决所有问题。

真正要解决一致性,还是要回到"删除失败怎么补偿"。

4. 不要忽略 TTL

TTL 是缓存一致性的最后一道兜底。

即使删除失败、补偿失败,TTL 到期后缓存也会自动失效。

但 TTL 不能太短,否则缓存没意义;也不能太长,否则不一致窗口太大。

5. 不要忽略监控

线上最怕的不是方案不完美,而是出了问题没人知道。

至少要监控:

  • Redis 删除失败率
  • 缓存命中率
  • 数据库慢查询
  • MQ 消费延迟
  • 接口响应时间
  • 关键业务数据不一致告警

十一、一个比较稳的通用方案

如果不想搞太复杂,可以先按这个来:

  1. 读请求:先查 Redis,没有再查数据库,查到后回填缓存
  2. 写请求:先更新数据库,再删除缓存
  3. 删除失败:本地重试 2~3 次
  4. 重试仍失败:发送 MQ 消息异步删除
  5. 消费者:幂等删除缓存,失败继续重试
  6. 所有缓存:设置合理 TTL
  7. 强一致字段:不缓存或极短缓存,数据库事务兜底

伪代码:

复制代码
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);

    String key = "user:" + user.getId();

    boolean deleted = false;

    for (int i = 0; i < 3; i++) {
        if (redis.del(key) != null) {
            deleted = true;
            break;
        }
    }

    if (!deleted) {
        cacheConsistencyProducer.sendDeleteMessage(key);
    }
}

消费者:

复制代码
public void handleDeleteMessage(String key) {
    int retryTimes = 3;

    for (int i = 0; i < retryTimes; i++) {
        if (redis.del(key) != null) {
            return;
        }
    }

    log.error("缓存删除补偿失败,key={}", key);
}

这个方案不算最极致,但足够稳,也适合大多数项目。


十二、最后总结

Redis 缓存和数据库一致性,本质上是在权衡三件事:

  • 一致性要求
  • 系统复杂度
  • 业务可接受的不一致窗口

不要一上来就追求强一致,也不要随便一句"最终一致"就把问题糊过去。

真正落地时,记住几个原则就够了:

  1. 写操作优先先更新数据库,再删除缓存
  2. 删除缓存失败必须有补偿机制
  3. 缓存 TTL 是兜底,不能省
  4. 强一致数据不要依赖缓存做最终判断
  5. 方案越复杂,越要评估维护成本

大部分项目,不需要 Canal、延迟双删、分布式事务一起上。

先把"先更新数据库,再删除缓存 + 失败补偿 + TTL 兜底"做稳,已经能解决绝大多数一致性问题。

相关推荐
sugar__salt1 小时前
MyBatis-Plus 从入门到实战:高效简化数据库开发的完整指南
数据库·spring boot·mybatis·数据库开发
小当家.1051 小时前
LLM服务缓存与连接池管理:三层缓存架构与ChatClient池化
java·后端·spring·缓存·llm·agent
CodeBlog-star1 小时前
AI Agent 高并发实战:Redis 限流、队列、缓存与高可用全栈方案
数据库·redis·缓存
TDengine (老段)1 小时前
TDengine taosX 与 Explorer — 数据集成与可视化管理
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
Leon-Ning Liu1 小时前
Oracle 26ai新特性:Lock-Free Reservations(无锁预留)
数据库·oracle
xiaopai9451 小时前
WPS能否替代工程项目管理系统
大数据·数据库·项目管理系统·建米软件
小当家.1051 小时前
Token成本控制实战:语义缓存、上下文压缩与工具缓存
java·spring·缓存·token·上下文
数据库小学妹1 小时前
空间数据库查询慢怎么排查?索引失效、表膨胀、SQL优化实战(附排查命令)
数据库·性能优化·信创·故障排查·索引调优·空间数据库
Cloud云卷云舒2 小时前
从墨天轮排名中,分析近期增速快、中长期具备高潜力国产数据库
数据库·haishandb·工业云