缓存与数据库一致性终极指南:从入门到放弃?不,到精通!上

简介: 凌晨被投诉惊醒?缓存与数据库不一致是常见难题。本文详解五大解决方案:旁路缓存、双删策略、消息队列补偿、Binlog监听与版本号控制,结合场景分析一致性、性能与复杂度的权衡,助你选型不踩坑。

“小树,你的系统又被投诉数据不一致了!”
凌晨2点,运维的夺命连环call把你惊醒。


问题出在哪里

小树赶紧梳理了一下他缓存数据的流程:

  1. 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。
  2. 如果缓存没数据,再继续查数据库。
  3. 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。
  4. 如果数据库也没数据,则直接返回空。
    具体流程如下图:
    737921192d86d1e010e1a51c9001a438_MD5.jpeg
    这时公司老大哥帮忙一看,就说如果刚查完数据就放到缓存中了,这条数据又被更新了,你再进来查询,还是可以命中缓存,可是缓存里的数据已经和数据库的值不一样了。小树一听就明白了。
    原来是小树没有在数据库更新时同步更新缓存,小树又说那我在更新数据库后同时更新一下缓存是不是就可以。老大哥笑着摇了摇头,可没有那么简单。
    更新数据库和更新缓存是两个操作,先更新哪一个呢?咱们挨个分析一下
  5. 先更新缓存,再更新数据库
  6. 先更新数据库,再更新缓存

    先更新缓存,再更新数据库

我们都知道,网络是容易出问题的,如果在更新数据库的时候,操作失败了,那么缓存中存在,数据库中不存在,缓存中的数据就是脏数据。这种问题已经很严重了,相当于MySQL 事务里提到的脏读,一般都是要尽量避免的

先更新数据库,再更新缓存

先更新数据库的话,可以保证缓存里的数据都是数据库里存在过的,就是更新缓存失败了,缓存里的数据也只是旧数据,而不是不存在于数据库里的。相关于上一个好一点。
再有一个,一般用到缓存,代表访问数据频率比较高,有一定的并发,再高并发的情况下,又有许多问题

  1. 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
  2. 这时候请求b过来了,先写了数据库。
  3. 接下来,请求b顺利写了缓存。
  4. 此时,请求a卡顿结束,也写了缓存。
    9220fc8820b9a89191a9e77e06ae0f92_MD5.jpeg
    按照业务时间来说最后的A应该为2,但由于网络的原因导致更新缓存慢了,最后的缓存中的数据和数据库中的对不上

如果高并发且写多读少的情况下,代表没更新一次数据都要去更新缓存,可能修改了10次,实际只读了一次,这样的话也会造成CPU和内存资源的浪费。实际开发中,这种方案也很少用。

再想想我们的目的,我们为了是数据库更新的时候缓存也能够同时更新,既然更新会浪费资源,那我是不是可以更新数据库时把缓存删掉。这种思想我们给他取了一个名字旁路缓存

方案一:Cache-Aside Pattern(旁路缓存)

通过延迟删除策略,优先保证数据库正确性,接受短暂不一致。

根据上面的思路,我们了解到旁路缓存有两步操作,读操作和写操作

同样删除缓存也会有两种情况

  1. 先删缓存,再写数据库
  2. 先更新数据库,后删缓存

    先删缓存,再写数据库

  3. 请求a先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
  4. 这时请求b过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
  5. 请求b将数据库中的旧值,更新到缓存中。
  6. 此时,请求a卡顿结束,把新值写入数据库
    25d9789f64b3e005f8143d0b576d4d12_MD5.jpeg

此时则会出现缓存和数据库不一致的情况

先更新数据库,后删缓存

我们再回到上一个场景中,

  1. 请求a先过来,先更新数据库。但由于网络原因,没来得及删缓存。
  2. 这时请求b过来了,命中了缓存,有数据,但是旧值。
  3. 此时,请求a卡顿结束,把缓存更新
    我们会发现,也会出现数据不一致的情况,但是只有一次,之后的数据就被更新为正常值了。再不要求完全一致的情况下,好像也可以接受

    隐藏风险

    那这种方案是最多只会出现一次不一致的情况吗?我们先看下面这种场景:

缓存失效或第一次访问:

  1. A线程读取 → 缓存未命中 → 从数据库读到数据
  2. B线程更新数据库 → 删除缓存
  3. A线程回填缓存
  4. 结果:数据库是新数据,缓存却是旧的!
    17f9dc65cc3e4598f0fd747fc6c043ca_MD5.jpeg

    删除缓存失败:
    在写策略时,如果由于网络原因,删除缓存失败了。那在读策略查询缓存的时候仍然会命中缓存,但是数据是旧数据。依然出现了缓存不一致的情况

代码实现

// 读操作
public Product getProduct(Long id) {
   
    Product product = cache.get(id);
    if (product == null) {
   
        product = productRepository.findById(id).orElse(null);
        if (product != null) {
   
            cache.put(id, product);
        }
    }
    return product;
}

// 写操作
public void updateProduct(Product product) {
   
    productRepository.save(product);
    cache.delete(product.getId()); // 删除而非更新
}

小结

说到这里,我们先总结一个这个方案的优缺点

优点 缺点 适用场景
实现简单 短暂不一致(删除失败时) 读多写少(如商品详情)
避免并发写问题 需处理缓存穿透 容忍秒级不一致

大部分读多写少且对一致性没有太高要求的场景下可以使用,注意设置一下缓存过期时间,避免旧数据一直无法删除即可。


方案二:双删策略(Double Delete)

通过两次删除 + 延迟补偿,解决并发场景下的脏数据问题。

上面提到了旁路删除仍然会有两个问题,小树想到,缓存失效或者第一次访问的时候出现缓存不一致,就是更新完数据库马上就删除了缓存,那我稍微慢一点删除,等并发的读都回填完了在删除,不就可以保证最终一致性了吗,为了减少缓存不一致的可能性,我在开始再删一次缓存,按照这个想法,小树写了下面的代码

进阶版双删代码

public void updateWithDoubleDelete(Product product) {
   
    // 第一次删除
    cache.delete(product.getId());

    // 更新数据库
    productRepository.save(product);

    // 延迟二次删除
    scheduler.schedule(() -> {
   
        cache.delete(product.getId());
    }, 500, TimeUnit.MILLISECONDS); // 延迟时间=主从延迟+缓冲
}

为什么是500ms?

延迟时间 = 主从同步延迟 + 业务处理时间 + 安全缓冲(建议100-200ms)

  • 数据库主从同步通常需要200-300ms
  • 留出缓冲时间,确保其他线程已处理完旧数据

老大哥看了看说,不错,这种方案叫做这样做延时双删,确实可以减少缓存不一致的风险,在允许短暂数据不一致的情况下这种风险是可以接受的。但是删除缓存失败了呢?你如何处理,

小树想了两种方法

  • 可以添加重试机制,失败了重新删除缓存。
  • 同时给缓存添加过期时间,避免删除缓存失败,一直读到错误的缓存数据

老大哥点了点头,说:想法不错,那你重试机制如何实现呢,是同步还是异步呢?
小树说:这我还没想好,你给我讲讲呗
老大哥说:不早了,我要睡觉了。明天再跟你讲吧,你先消化一下今天说的方案

小树总结了一下双删策略的优缺点

优缺点

优点 缺点
解决并发脏读 延迟时间难设定
成本低 仍可能不一致

适用边界

  • 适合写后立刻读少的场景(如后台管理系统)
  • 不适用秒杀场景(延迟期间仍可能读到脏数据)

方案三:消息队列补偿

通过异步解耦,确保缓存删除操作最终成功。

架构图

Java实现

// 生产者
public void updateOrder(Order order) {
   
    orderRepository.save(order);
    rocketMQTemplate.send("cache-delete-topic", 
        MessageBuilder.withPayload(order.getId()).build());
}

// 消费者
@RocketMQMessageListener(topic = "cache-delete-topic", consumerGroup = "cache-group")
public class CacheDeleteListener implements RocketMQListener<String> {
   
    @Override
    public void onMessage(String orderId) {
   
        cache.delete("order::" + orderId); // 支持重试机制
    }
}

优缺点

优点 缺点 适用场景
高可靠性 系统复杂度高 分布式系统
解耦业务 消息可能堆积 订单/支付系统

选型建议

  • 低并发场景:用Redis Stream实现轻量级队列
  • 高并发场景:RocketMQ + 死信队列监控

方案四:Binlog监听(Canal)

通过数据库日志驱动缓存更新,实现业务零侵入。

Canal部署架构

MySQL → Canal Server → Kafka → 缓存服务

优势

  • 完全解耦业务代码
  • 实时性达到毫秒级

    Java实现(Canal客户端)

CanalConnector connector = CanalConnectors.newClusterConnector(
    "127.0.0.1:2181", "example", "", "");

connector.subscribe(".*\\..*");
while (running) {
   
    Message message = connector.getWithoutAck(100);
    for (CanalEntry.Entry entry : message.getEntries()) {
   
        if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
   
            // 解析变更并删除缓存
            String tableName = entry.getHeader().getTableName();
            String id = parseId(entry.getStoreValue());
            cache.delete(tableName + "::" + id);
        }
    }
}

优缺点

优点 缺点 适用场景
业务零侵入 部署复杂 微服务架构
毫秒级同步 需处理DDL事件 数据异构同步

适用场景

  • 微服务架构中多个服务共享缓存
  • 数据异构场景(如ES索引同步)

方案五:版本号控制

通过数据版本校验,实现精准更新。

数据表设计

CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    data JSON,
    version INT DEFAULT 0  -- 新增版本号字段
);

缓存结构

{
   
  "value": "{...}",
  "version": 123 
}

Java实现(JPA乐观锁)

@Entity
public class Product {
   
    @Id private Long id;
    @Version private Integer version; // 乐观锁字段
    // 其他字段...
}

@Transactional
public void updateProduct(Product product) {
   
    Product dbProduct = productRepo.findById(product.getId())
        .orElseThrow();
    if (!dbProduct.getVersion().equals(product.getVersion())) {
   
        throw new OptimisticLockException();
    }
    productRepo.save(product);
    cache.put(product.getId(), product); // 更新缓存
}

额外成本

  • 存储开销增加5%-10%
  • 所有读写操作需校验版本号

回报

  • 缓存命中率提升40%
  • 彻底解决并发更新导致的脏读

终极大招:分布式锁该不该上?

通过互斥锁确保同一时刻只有一个写操作。

Redlock实现方案

public void updateWithLock(Product product) {
    
    String lockKey = "lock:product:" + product.getId();
    // 获取锁(最多等待500ms,锁持有30s)
    boolean locked = redisLock.lock(lockKey, 500, 30000); 
    if (locked) {
    
        try {
    
            db.update(product);
            cache.delete(product.getId());
        } finally {
    
            redisLock.unlock(lockKey);
        }
    }
}

性能数据

  • 吞吐量下降60%(实测)
  • 平均响应时间从20ms → 80ms

使用铁律

  • 仅用于库存扣减等核心场景
  • 必须设置锁超时时间!

**你的业务真的需要强一致性吗?

任何技术方案的选择都都基于业务的,不要看网上吹的某个方案有多么好就无脑选择,适合自己业务的方案才是最好的

场景测试

  • 用户修改昵称后,5秒内看到旧名字 → 能接受
  • 支付成功后,订单状态延迟1分钟更新 → 灾难

结论

  • 90%的业务只需最终一致性
  • 剩下10%的金融/交易场景才需要强一致

最后抉择:一张图帮你选型

场景特征 推荐方案 一致性强度 性能影响
读多写少 Cache-Aside 最终一致 无影响
高并发写入 双删+消息队列 最终一致+ 中等
金融/交易场景 分布式锁+版本号 强一致

结语:没有完美方案,只有合适的选择

记住这个黄金公式
一致性成本 = 业务损失 × 发生概率 - 技术投入成本

下次当老板质问“为什么又出问题”时,你可以从容反问:
“您愿意为数据一致性支付多少成本?”

记住:技术方案的选择永远是在性能、一致性和复杂度之间寻找平衡点的艺术!

相关文章
|
2月前
|
消息中间件 canal 缓存
缓存与数据库一致性终极指南:从入门到放弃?不,到精通!下
本文探讨缓存一致性难题,从延时双删到重试机制,分析同步重试、异步重试、消息队列补偿及Binlog监听(Canal)等方案,结合优缺点与适用场景,最终提出根据业务需求权衡一致性与性能,选择合适策略。
|
消息中间件 存储 负载均衡
【Kafka】Kafka 分区
【4月更文挑战第5天】【Kafka】Kafka 分区
|
安全 数据安全/隐私保护
【接口加密】理解接口加密的基础概念
【接口加密】理解接口加密的基础概念
|
消息中间件 数据库 RocketMQ
分布式事务常见解决方案
分布式事务常见解决方案
2229 0
|
1月前
|
机器学习/深度学习 人工智能 自然语言处理
94_提示压缩:减少token消耗
在大语言模型(LLM)应用中,提示工程已成为提升模型性能和控制输出的关键技术。然而,随着模型能力的增强和应用场景的复杂化,提示文本往往变得冗长,导致token消耗急剧增加。这不仅直接影响到API调用成本,还可能超出模型的上下文窗口限制,特别是在使用GPT-4、Claude 3等大模型时,每1000个token的成本可能高达数美分。对于需要频繁交互或批量处理的应用场景,如客服系统、内容生成平台或自动化工作流,token消耗的优化就显得尤为重要。
|
12月前
|
SQL 关系型数据库 MySQL
大厂面试官:聊下 MySQL 慢查询优化、索引优化?
MySQL慢查询优化、索引优化,是必知必备,大厂面试高频,本文深入详解,建议收藏。关注【mikechen的互联网架构】,10年+BAT架构经验分享。
大厂面试官:聊下 MySQL 慢查询优化、索引优化?
|
存储 Dubbo API
SpringCloud工程部署启动
本节笔者带领大家完成了SpringCloud工程从0->1的搭建,当然你不想搭建也可以直接采用方案一,二者等效,至此读者们完成了一个微服务工程的搭建、部署、访问。同时在本节最后一章,笔者基于RestTemplate发起的http请求实现远程调用,实现当A系统想要获取B系统数据时的跨系统数据交互。然而RESTful API访问并不是微服务的唯一解决方案,如Dubbo的交互一样可以实现,希望读者们能不限于此。
|
12月前
|
消息中间件 存储 Kafka
RocketMQ 工作原理图解,看这篇就够了!
本文详细解析了 RocketMQ 的核心架构、消息领域模型、关键特性和应用场景,帮助深入理解消息中间件的工作原理。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
RocketMQ 工作原理图解,看这篇就够了!
|
12月前
|
存储 关系型数据库 MySQL
MySQL 字段类型探究:深入理解 Varchar(50) 与 Varchar(500)
在MySQL数据库中,`VARCHAR`类型是一种常用的字符串存储类型,它允许定义一个可变长度的字符串。然而,`VARCHAR(50)`和`VARCHAR(500)`之间的差异不仅仅是长度的不同,它们在存储和性能方面也有显著的区别。本文将深入探讨这两种字段类型的区别,以及它们在实际应用中的选择。
447 3
|
SQL 存储 监控
SQL Server的并行实施如何优化?
【7月更文挑战第23天】SQL Server的并行实施如何优化?
502 13