【架构实战】缓存一致性:Cache Aside与双写问题的破解

【架构实战】缓存一致性:Cache Aside与双写问题的破解

一、背景:缓存里的商品价格是错的

2021年双11,运营急call:"前台显示99元,用户下单页面显示129元,用户截图投诉了!"

查了半天:商品价格从129元改成了99元,数据库更新成功,但Redis里的缓存还是129元。缓存没失效,用户看到的是旧价格,直到10分钟后缓存过期才恢复正常。

更糟的是:那10分钟里,有200多个用户以错误的价格看到了商品,半数直接走了。

这不是Bug,是缓存一致性的经典问题:数据库更新了,缓存没更新。

团队复盘时发现,大家都听过"先更新数据库再删缓存",但很少有人真正理解为什么要这么做,以及这么做就万无一失了吗?


二、缓存一致性的核心矛盾

2.1 问题的本质

复制代码
        ┌──────────┐
    读取 │  Redis   │ 读取 (快,但可能不是最新)
用户 ──┤  缓存     ├── 应用 ──> MySQL (慢,但是最新)
    写入 │          │ 写入
        └──────────┘

核心矛盾:数据库和缓存是两个独立的数据源,更新操作无法原子性地同时更新两个存储。

2.2 不一致的场景

所有不一致都源于同一类并发时序问题:

复制代码
时间线  线程A(读)              线程B(写)
  T1                            更新数据库 price=99
  T2    读缓存 → 命中 → 返回129  
  T3                            删除缓存
        
  → 用户看到了129(旧数据),而数据库已经是99

或者反过来:

  T1    缓存过期,读数据库=129  
  T2                            更新数据库 price=99
  T3                            删除缓存(但此时缓存已经过期了)
  T4    把129写入缓存 ← 脏数据!
        
  → 缓存里是129,数据库是99

三、缓存更新策略对比

3.1 四种策略

策略 操作顺序 优点 缺点 一致性
Cache Aside 读: 先读缓存,miss再读DB并回填 写: 先更新DB,再删缓存 实现简单 需要处理并发 最终一致
Read/Write Through 缓存层代理读写,业务无感知 业务代码干净 缓存层复杂 强一致(同事务)
Write Behind 先写缓存,异步写DB 写入快 数据可能丢失 弱一致
双写 同时更新DB和缓存 实时性好 并发时易出错 最终一致

3.2 Cache Aside(旁路缓存)------最推荐的方案

读流程

复制代码
读请求 → 查缓存
         ├── 命中 → 直接返回
         └── 未命中 → 查数据库 → 写入缓存 → 返回

写流程

复制代码
写请求 → 更新数据库 → 删除缓存(不是更新缓存!)

为什么是"删除缓存"而不是"更新缓存"?

复制代码
更新缓存的陷阱:
线程A: 更新数据库 price=99
线程B: 更新数据库 price=129
线程B: 更新缓存 price=129  ← B的缓存更新先到达
线程A: 更新缓存 price=99   ← A的缓存更新后到达
结果:数据库里是129,缓存里却是99 → 价格显示错误

删除缓存不会有这个问题:
线程A: 更新数据库 price=99
线程B: 更新数据库 price=129
线程A: 删除缓存 ✓
线程B: 删除缓存 ✓
结果:缓存被删除,下次读取时从DB加载最新数据

3.3 核心实现

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String CACHE_PREFIX = "product:";
    private static final long CACHE_TTL = 3600;  // 1小时
    
    // 读:Cache Aside模式
    public Product getProduct(Long productId) {
        String cacheKey = CACHE_PREFIX + productId;
        
        // 1. 查缓存
        String json = redisTemplate.opsForValue().get(cacheKey);
        if (json != null) {
            return JSON.parseObject(json, Product.class);
        }
        
        // 2. 缓存miss,查数据库
        Product product = productMapper.findById(productId);
        if (product == null) {
            // 缓存空值,防止穿透(后面文章会讲)
            redisTemplate.opsForValue()
                .set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
            return null;
        }
        
        // 3. 写入缓存
        redisTemplate.opsForValue()
            .set(cacheKey, JSON.toJSONString(product), CACHE_TTL, TimeUnit.SECONDS);
        
        return product;
    }
    
    // 写:先更新DB,再删除缓存
    @Transactional
    public void updateProduct(Product product) {
        // 1. 先更新数据库
        productMapper.update(product);
        
        // 2. 再删除缓存(在事务提交后执行)
        String cacheKey = CACHE_PREFIX + product.getId();
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    redisTemplate.delete(cacheKey);
                    
                    // 延迟双删(可选,进一步降低不一致概率)
                    // delayDelete(cacheKey, 500); // 500ms后再次删除
                }
            }
        );
    }
}

四、Cache Aside 的并发问题

4.1 极端情况下的不一致

即使使用了"先更新DB再删缓存",极端并发下仍可能不一致:

复制代码
时间线  线程A(读)                    线程B(写)
  T1    缓存未命中,准备查数据库
  T2                                 更新数据库 price=129
  T3                                 删除缓存(这是空操作,因为T1时缓存已不存在)
  T4    从数据库读出 price=129
  T5    写入缓存 price=129
  T6                                 更新数据库 price=99
  T7                                 删除缓存 ← 这一步删的是T5写入的129
  T8    缓存被删除,下次读会加载price=99 ✓ 最终一致

这个场景下最终还是一致的。真正有问题的是:

复制代码
时间线  线程A(读)                    线程B(写)
  T1    缓存未命中,准备查数据库
  T2                                 更新数据库 price=129
  T3                                 删除缓存
  T4    从数据库读出 price=129
  T5    写入缓存 price=129
  T6                                 用户看到了price=129(B的更新已生效)
  
  → 这个场景下数据是一致的!不一致发生的概率极低。

4.2 延迟双删(Double Delete)

为了进一步降低不一致概率,可以采用延迟双删:

java 复制代码
@Transactional
public void updateProduct(Product product) {
    // 1. 第一次删缓存
    redisTemplate.delete(CACHE_PREFIX + product.getId());
    
    // 2. 更新数据库
    productMapper.update(product);
    
    // 3. 事务提交后,延迟第二次删缓存
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 延迟500ms(等待其他线程的读操作完成)
                CompletableFuture.runAsync(() -> {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    redisTemplate.delete(CACHE_PREFIX + product.getId());
                });
            }
        }
    );
}

原理:第二次删除可以清理"读线程在写操作期间写入缓存的旧数据"。

适用场景:对一致性的容忍度极低的业务(如价格、库存)。


五、读写穿透策略

5.1 订阅Binlog异步更新缓存

这是目前大厂最主流的方案:监听MySQL的Binlog,异步更新缓存。

复制代码
架构:
MySQL → Canal监听Binlog → Kafka → 缓存更新服务 → Redis

┌─────────┐     ┌─────────┐     ┌────────┐     ┌─────────┐
│  MySQL  │────>│  Canal  │────>│  Kafka │────>│ 缓存更新 │
│ (Binlog)│     │ (解析)  │     │(消息)  │     │ 服务    │
└─────────┘     └─────────┘     └────────┘     └────┬────┘
                                                    │
                                                    ▼
                                               ┌─────────┐
                                               │  Redis  │
                                               └─────────┘
java 复制代码
// Canal监听Binlog的简化实现
@Component
public class BinlogCacheSync {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 处理Binlog事件
     */
    public void handleBinlogEvent(CanalEntry entry) {
        // 解析Binlog,获取变更的表和数据
        String table = entry.getHeader().getTableName();
        String eventType = entry.getHeader().getEventType().name();
        
        if ("product".equals(table)) {
            for (CanalEntry.RowData rowData : entry.getRowChange().getRowDatasList()) {
                if ("UPDATE".equals(eventType) || "DELETE".equals(eventType)) {
                    // 解析变更后的数据
                    Long productId = parseProductId(rowData);
                    
                    if ("DELETE".equals(eventType)) {
                        // 删除缓存
                        redisTemplate.delete("product:" + productId);
                    } else {
                        // 更新缓存(如果有的话,或者直接删除)
                        redisTemplate.delete("product:" + productId);
                        // 或者从DB重新加载后更新缓存
                    }
                }
            }
        }
    }
}

优点

  • 完全解耦:业务代码不需要关心缓存,只管写数据库
  • 一致性高:只要Binlog不丢,缓存最终一定一致
  • 可观测:Kafka消息可追踪,方便排查

缺点

  • 引入Canal + Kafka,架构复杂度增加
  • 有延迟(通常100ms-500ms),不适用强一致场景

5.2 缓存一致性最佳实践总结

复制代码
一致性保证的层次:

Level 1: 基础方案
  写:更新DB → 删除缓存
  读:查缓存 → miss → 查DB → 回填缓存

Level 2: 增强方案
  写:删缓存 → 更新DB → 延迟双删
  读:同上

Level 3: 企业方案
  写:只更新DB → Canal监听Binlog → 异步更新/删缓存
  读:同上

Level 4: 终极方案
  Level 3 + 定时对账 + 不一致自动修复

六、常见坑点

坑1:在事务中操作缓存

java 复制代码
// ❌ 错误:在事务中操作缓存
@Transactional
public void updateProduct(Product product) {
    productMapper.update(product);         // DB操作
    redisTemplate.delete(cacheKey);        // 缓存操作
}
// 问题:如果DB事务回滚了,缓存已经删了,导致缓存中数据丢失

// ✅ 正确:缓存操作在事务提交后
@Transactional
public void updateProduct(Product product) {
    productMapper.update(product);
    
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisTemplate.delete(cacheKey);  // 只在DB提交成功后删缓存
            }
        }
    );
}

坑2:更新缓存而不是删除缓存

前面已经详细解释了,永远优先选择删缓存而不是更新缓存。更新缓存的并发问题无解。

坑3:高并发下缓存频繁失效

热点数据频繁更新,刚删除缓存就有大量请求涌入,全部打到DB。

解决:热点数据不强求实时一致,用本地缓存+短TTL(1-3秒)做缓冲。


七、总结

缓存一致性的核心原则

  1. Cache Aside是基础:先更新DB,再删除缓存------最简单且有效的方案
  2. 删除优于更新:永远不要并发更新缓存,只删除
  3. 事务提交后才操作缓存:避免DB回滚但缓存已变更
  4. 延迟双删做兜底:对一致性要求高的场景,加延迟双删
  5. Binlog异步是最佳实践:业务代码零侵入,一致性有保障

做不到强一致就接受最终一致

缓存一致性没有完美的实时方案。如果在业务上可以接受几百毫秒的不一致,那Cache Aside足够了。如果不能接受,那就别用缓存,直接读数据库。

缓存一致性的终极答案不是技术,是业务妥协。接受最终一致性,用对账系统兜底,比追求完美的一致性要靠谱得多。


个人观点,仅供参考

相关推荐
栈溢出了2 小时前
Redis 分片集群与哈希槽笔记
java·redis·笔记·spring
feiyu_gao3 小时前
别再让 AI 从零开始了
人工智能·架构·aigc
通问AI3 小时前
GPT-5.6 三模型架构深度解析:Sol/Terra/Luna 技术对比与工程落地指南
gpt·架构
QN1幻化引擎4 小时前
我写了一个「有八层意识结构」的 Python 认知引擎,它没有用任何 LLM
人工智能·算法·架构
一路向北North7 小时前
Spring Security OAuth2.0(20):完善环境配置
java·后端·spring
还有多久拿退休金7 小时前
AI 写了代码,谁替你"试毒"?
前端·架构·ai编程
青山木9 小时前
Redis 高可用的最后一公里:Cluster 分片、Gossip 与故障转移全流程
数据库·redis·后端·缓存
Slice_cy9 小时前
Docker 体系化完整知识框架(从构建到运行到编排)
架构
hbugs0019 小时前
PNETLab vs EVE-NG Pro 流量洞察功能底层架构技术白皮书
网络·架构·eve-ng·bpf·流量洞察