欢迎来到窥视未来的博客

https://github.com/lwx57280 https://gitee.com/li_VillageHead

把公司的缓存一致性搞砸之后,我翻遍了5种方案,这是我们的终极补救

兄弟们,你们经历过这种绝望吗?凌晨1点,业务方在群里疯狂@你:“用户刚充值的会员,一刷新就没了!”你赶紧查日志,发现缓存里读到的还是旧数据,数据库明明已经更新了。更诡异的是,过了几分钟再刷新,数据又对了。

这就是缓存与数据库数据不一致的典型症状。那天晚上我翻遍了5种补偿方案,从延时双删到Canal,才彻底搞明白这潭水有多深。今天就把这次翻车经历和完整补救方案复盘出来。

一、事故还原:缓存数据怎么就“穿越”了?

我们的架构是标准的读缓存→写数据库→删缓存模式。某天上线了一个会员充值功能,流程是这样的:

Redis-cache

 

看起来没问题对吧?但现实是残酷的——第2步删除缓存和第5步读数据库之间,有一个时间窗口。

实际情况是这样的:

Redis-cache-1

 

根因:MySQL主从复制有延迟(200ms),在缓存被删除后、从库同步完成前,有读请求打到了从库,读到了旧数据,然后把旧数据写回了缓存。缓存被“污染”了。

这就是缓存一致性问题最经典的坑——主从延迟 + 读写分离 + 缓存,三个东西凑在一起,数据就乱了。

二、为什么缓存一致性这么难?

2.1 数据流向的复杂性

在典型的读缓存→写数据库架构中,数据流向是这样的:

mermaid-1782470510198

 不一致产生的根本原因:写链路和读链路是异步的、并发的。主从同步有延迟,缓存操作和数据库操作不是原子的。

2.2 缓存一致性的“不可能三角”

在分布式环境下,缓存一致性面临一个“不可能三角”:

 
目标说明难度
强一致性 缓存和数据库任何时候都完全一致 极高(需要分布式事务)
高性能 读写操作低延迟 容易(但牺牲一致性)
高可用 系统持续可用 中等

现实选择:大多数业务选择最终一致性——允许短时间内数据不一致,但保证最终会一致。

三、方案一:延时双删

3.1 核心思想

延时双删通过两次删除 + 延时等待,在性能与一致性之间寻找平衡。

python 伪代码:

def update_data(key, obj):
    del_cache(key)      # 第一次删除:删除脏数据
    update_db(obj)      # 更新数据库
    logic_sleep(_time)  # 延时等待(让主从同步完成)
    del_cache(key)      # 第二次删除:保证最终一致

Go语言实现:

func UpdateWithDoubleDelete(db *gorm.DB, redis *redis.Client, user *User) error {
    cacheKey := fmt.Sprintf("user:%d", user.ID)
    
    // 第一次删除缓存(防止脏读)
    redis.Del(context.Background(), cacheKey)
    
    // 更新数据库
    if err := db.Save(user).Error; err != nil {
        return err
    }
    
    // 延迟第二次删除(解决主从同步延迟期间的脏读)
    go func() {
        // 根据主从延迟调整,通常1-2秒
        time.Sleep(1 * time.Second)
        redis.Del(context.Background(), cacheKey)
    }()
    
    return nil
}

3.2 为什么要延时?

延时的核心目的是等待MySQL主从同步完成(延时双删(redis-mysql)数据一致性思考)。

mermaid-1782471279243

3.3 延时双删能保证强一致吗?

不能。

 
限制说明
主从同步时间不确定 负载高时同步可能超过延时时间
并发写场景复杂 多个写请求交错,两次删除可能不够
最终一致而非强一致 只能提高一致性概率,不能100%保证

3.4 为什么需要第一次删除?

第一次删除有两个作用:

  1. 删除脏数据:避免其他线程读到旧数据

  2. 提前实现最终一致:不用等四个步骤全部完成

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

这是最经典的方案,也是Cache-Aside Pattern的标准做法。

4.1 流程

mermaid-1782471693707

4.2 存在的问题

问题一:删除缓存失败

如果第3步删除缓存失败,缓存中一直是旧数据。

解决方案:重试机制 + 监控告警。

问题二:读写并发导致脏数据

mermaid-1782471819709

 解决方案:使用延迟双删作为兜底。

五、方案三:先更新缓存,再更新数据库

5.1 流程

mermaid-1782472082592

 

5.2 致命缺陷

如果第3步数据库更新失败,缓存中已经是新数据,但数据库还是旧数据——缓存和数据库永久不一致。

结论:这个方案不推荐,除非数据库更新失败的概率极低,且有完善的补偿机制。

六、方案四:MQ消息队列实现最终一致性

6.1 核心思想

将缓存更新操作异步化,通过MQ保证至少一次的执行。

mermaid-1782472569686

6.2 关键设计

 
设计点说明
消息可靠性 MQ开启持久化 + 手动ACK
重试机制 删除失败时重试(指数退避)
死信队列 多次重试失败后进入死信,人工介入
幂等性 删除操作天然幂等

 

6.3 优势与局限

优势:

  • 解耦:应用不直接操作缓存

  • 可靠:MQ保证消息不丢失

  • 可重试:失败自动重试

局限:

  • 引入MQ增加系统复杂度

  • 延迟略高(消息队列的延迟)

  • 需要处理消息积压

七、方案五:Canal + Redis 实现数据双写一致性

7.1 什么是Canal?

Canal是阿里巴巴开源的一个MySQL binlog增量订阅&消费组件。它伪装成MySQL的Slave,订阅主库的binlog,解析后推送给下游。

7.2 架构设计

mermaid-1782474023733

7.3 工作流程

mermaid-1782474277473

 

7.4 核心优势

 
优势说明
业务无侵入 应用只管写DB,不需要关心缓存
数据可靠 基于binlog,不丢数据
顺序保证 binlog天然有序
支持多种目标 Redis、ES、HBase、MQ等

 

7.5 注意事项

  • Canal自身高可用:需要部署Canal集群

  • 消费延迟:binlog解析和消费有延迟(毫秒~秒级)

  • 资源消耗:Canal需要解析binlog,有一定CPU消耗

八、方案对比与选型建议

 
方案一致性级别性能影响实现复杂度业务侵入适用场景
延时双删 最终一致 低 低 中 大多数业务,能接受1-2秒不一致
先删缓存后更新 弱一致 低 低 中 读多写少
先更新后删缓存 最终一致 低 低 中 推荐方案
MQ异步 最终一致 中 中 中 已有MQ infrastructure
Canal同步 最终一致 低 高 无 需要解耦、多目标同步

选型决策树

 

mermaid-1782478198095

 

九、我们最终的补救方案

经过这次翻车,我们采用了分层防御的策略:

mermaid-1782478248156

 

三层保障:

  1. 第一层(主链路):先更新数据库,再删除缓存(Cache-Aside)

  2. 第二层(兜底):Canal + MQ 异步删除,保证最终一致

  3. 第三层(补偿):延时双删作为最后的兜底

十、避坑总结

  1. 没有银弹:缓存一致性没有完美的解决方案,需要在一致性、性能、复杂度之间权衡

  2. 主从延迟是隐形杀手:读写分离场景下,延时双删的延时时间要大于主从同步延迟

  3. 监控比方案更重要:即使用了最好的方案,也要监控缓存命中率、数据一致性指标

  4. 业务兜底:核心业务(如金钱相关)建议读主库,不要依赖缓存

兄弟们,你们在生产环境中遇到过缓存一致性的坑吗?是用延时双删还是Canal?评论区聊聊,我帮你们分析分析。

posted on 2026-06-26 20:52  k8s-Mango  阅读(39)  评论(0)    收藏  举报

导航