🔄 分布式缓存一致性:让缓存和数据库"心有灵犀"!

41 阅读9分钟

副标题:Cache Aside、Read/Write Through、Write Behind,三大模式谁更强?🎯


🎬 开场:缓存不一致的噩梦

真实故障案例 💥:

某电商平台的惊魂一刻:

10:00  用户购买了最后一件商品
10:00  数据库:库存 = 0
10:00  缓存:库存 = 1  ⚠️ 还没更新!
10:01  另一个用户看到:还有1件库存
10:01  第二个用户下单成功
10:02  仓库发货时发现:没库存了!
10:03  客服电话被打爆...

问题根源:
缓存和数据库数据不一致!

后果:
├── 超卖
├── 用户投诉
├── 赔偿损失
└── 信誉受损

这就是缓存一致性问题的严重性!


📚 核心概念

什么是缓存一致性?

缓存一致性(Cache Consistency):
保证缓存中的数据与数据库中的数据保持一致

理想状态:
数据库:库存 = 100
缓存:  库存 = 100  ✅ 一致

不一致状态:
数据库:库存 = 100
缓存:  库存 = 105  ❌ 不一致

为什么会不一致?

原因1:并发写入
线程A:写数据库 → 写缓存
线程B:写数据库 → 写缓存
         ↓
      顺序错乱,导致不一致

原因2:更新失败
写数据库成功 ✅
写缓存失败 ❌
         ↓
      数据不一致

原因3:缓存过期
数据库已更新
缓存还是旧数据
         ↓
      短暂不一致

🏗️ 三大缓存模式

1️⃣ Cache Aside(旁路缓存)⭐

最常用的模式!

读操作流程

┌─────────┐
│ 应用层  │
└────┬────┘
     │
  ① 读取缓存
     │
┌────▼────┐
│  Redis  │
└────┬────┘
     │
  ② 缓存命中?
     │
     ├─ YES → 返回数据 ✅
     │
     └─ NO → ③ 查询数据库
              │
         ┌────▼────┐
         │  MySQL  │
         └────┬────┘
              │
         ④ 写入缓存
              │
         ⑤ 返回数据

代码实现

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    /**
     * 读取商品(Cache Aside模式)
     */
    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;
        
        // 1. 先查缓存
        Product product = redisTemplate.opsForValue().get(cacheKey);
        
        if (product != null) {
            log.info("缓存命中: {}", productId);
            return product;
        }
        
        // 2. 缓存未命中,查数据库
        product = productMapper.selectById(productId);
        
        if (product != null) {
            // 3. 写入缓存
            redisTemplate.opsForValue().set(
                cacheKey, 
                product, 
                30, 
                TimeUnit.MINUTES
            );
        }
        
        return product;
    }
}

写操作流程

方案1:先删缓存,再写数据库 ❌
┌─────────┐
│ 应用层  │
└────┬────┘
     │
  ① 删除缓存
     ↓
  ② 更新数据库

问题:
如果在①②之间有读请求
会读到旧数据并写入缓存
导致脏数据长期存在!

方案2:先写数据库,再删缓存 ✅
┌─────────┐
│ 应用层  │
└────┬────┘
     │
  ① 更新数据库
     ↓
  ② 删除缓存

优点:
即使②失败,下次读取时
会从数据库读取最新数据
最多只是一次脏读

正确的写操作代码

@Service
public class ProductService {
    
    /**
     * 更新商品(推荐方式)
     */
    @Transactional
    public void updateProduct(Product product) {
        // 1. 先更新数据库
        productMapper.updateById(product);
        
        // 2. 再删除缓存
        String cacheKey = "product:" + product.getId();
        redisTemplate.delete(cacheKey);
        
        // 下次读取时会从数据库加载最新数据
    }
    
    /**
     * 更新商品(更安全的方式 - 延迟双删)
     */
    @Transactional
    public void updateProductSafely(Product product) {
        String cacheKey = "product:" + product.getId();
        
        // 1. 先删除缓存
        redisTemplate.delete(cacheKey);
        
        // 2. 更新数据库
        productMapper.updateById(product);
        
        // 3. 延迟一段时间后再次删除缓存
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(1000);  // 延迟1秒
                redisTemplate.delete(cacheKey);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

Cache Aside的优缺点

优点 ✅:

  • 实现简单
  • 应用层完全控制
  • 缓存失败不影响数据库
  • 最常用,经过验证

缺点 ❌:

  • 存在短暂不一致
  • 需要应用层处理逻辑
  • 可能出现缓存穿透

2️⃣ Read/Write Through(读写穿透)

由缓存层统一处理!

架构图

┌─────────┐
│ 应用层  │
└────┬────┘
     │ 只和缓存层交互
┌────▼────────┐
│  缓存层     │  ← 封装了所有逻辑
│  (Cache)    │
└────┬────────┘
     │ 缓存层自动同步数据库
┌────▼────┐
│  MySQL  │
└─────────┘

Read Through(读穿透)

/**
 * Read Through实现
 * 
 * 缓存层负责加载数据
 */
public class CacheThroughService {
    
    private final LoadingCache<Long, Product> cache;
    
    public CacheThroughService() {
        this.cache = CacheBuilder.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .build(new CacheLoader<Long, Product>() {
                @Override
                public Product load(Long productId) {
                    // 缓存未命中时,自动从数据库加载
                    return productMapper.selectById(productId);
                }
            });
    }
    
    /**
     * 应用层只需要调用get
     * 缓存层自动处理加载逻辑
     */
    public Product getProduct(Long productId) {
        try {
            return cache.get(productId);  // 简单!
        } catch (ExecutionException e) {
            throw new RuntimeException("加载商品失败", e);
        }
    }
}

Write Through(写穿透)

/**
 * Write Through实现
 * 
 * 同步写入缓存和数据库
 */
public class WriteThroughCache {
    
    /**
     * 写入数据
     * 同步更新缓存和数据库
     */
    public void put(String key, Product value) {
        // 1. 同步写入缓存
        cache.put(key, value);
        
        // 2. 同步写入数据库
        productMapper.updateById(value);
        
        // 两个操作都成功才返回
    }
}

完整实现(自定义缓存层)

/**
 * 自定义缓存层实现Read/Write Through
 */
@Component
public class ProductCacheLayer {
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    /**
     * Read Through
     */
    public Product get(Long productId) {
        String key = "product:" + productId;
        
        // 1. 先查缓存
        Product product = redisTemplate.opsForValue().get(key);
        
        if (product == null) {
            // 2. 缓存未命中,查数据库
            product = productMapper.selectById(productId);
            
            if (product != null) {
                // 3. 自动加载到缓存
                redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
            }
        }
        
        return product;
    }
    
    /**
     * Write Through
     */
    @Transactional
    public void put(Product product) {
        String key = "product:" + product.getId();
        
        // 1. 先写数据库
        productMapper.updateById(product);
        
        // 2. 再更新缓存
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
        
        // 两步都成功才提交事务
    }
    
    /**
     * 应用层使用
     */
}

@Service
public class ProductService {
    
    @Autowired
    private ProductCacheLayer cacheLayer;
    
    public Product getProduct(Long productId) {
        // 应用层不关心缓存逻辑
        return cacheLayer.get(productId);
    }
    
    public void updateProduct(Product product) {
        // 应用层不关心缓存逻辑
        cacheLayer.put(product);
    }
}

Read/Write Through的优缺点

优点 ✅:

  • 应用层简单
  • 缓存层统一处理
  • 逻辑集中,易维护

缺点 ❌:

  • 写操作慢(同步写两处)
  • 缓存层复杂
  • 强依赖缓存层

3️⃣ Write Behind(写后置/异步写)

最高性能的模式!

原理

写操作流程:

┌─────────┐
│ 应用层  │
└────┬────┘
     │
  ① 写入缓存(立即返回)⚡ 超快!
     │
┌────▼────┐
│  Cache  │
└────┬────┘
     │
  ② 异步批量写入数据库
     │
┌────▼────┐
│  MySQL  │
└─────────┘

特点:
- 写缓存立即返回
- 后台异步写数据库
- 性能最高!

代码实现

/**
 * Write Behind实现
 */
@Component
public class WriteBehindCache {
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    // 待写入数据库的队列
    private final BlockingQueue<Product> writeQueue = new LinkedBlockingQueue<>(10000);
    
    @PostConstruct
    public void init() {
        // 启动后台线程异步写数据库
        startAsyncWriter();
    }
    
    /**
     * 写入商品(立即返回)
     */
    public void put(Product product) {
        String key = "product:" + product.getId();
        
        // 1. 先写缓存(立即返回)
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
        
        // 2. 加入写队列
        writeQueue.offer(product);
        
        // 立即返回,不等待数据库写入 ⚡
    }
    
    /**
     * 后台线程异步写数据库
     */
    private void startAsyncWriter() {
        // 启动多个写线程
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                while (true) {
                    try {
                        // 批量获取待写入的数据
                        List<Product> batch = new ArrayList<>();
                        Queues.drain(writeQueue, batch, 100, 1, TimeUnit.SECONDS);
                        
                        if (!batch.isEmpty()) {
                            // 批量写入数据库
                            productMapper.batchUpdate(batch);
                            log.info("批量写入{}条数据", batch.size());
                        }
                        
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    } catch (Exception e) {
                        log.error("异步写入失败", e);
                    }
                }
            }).start();
        }
    }
}

更完善的实现(带重试)

@Component
public class WriteBehindCacheAdvanced {
    
    private final LinkedBlockingQueue<WriteTask> writeQueue = new LinkedBlockingQueue<>();
    
    /**
     * 写入任务
     */
    static class WriteTask {
        private final Product product;
        private int retryCount = 0;
        private final int maxRetry = 3;
        
        public WriteTask(Product product) {
            this.product = product;
        }
        
        public boolean canRetry() {
            return retryCount < maxRetry;
        }
        
        public void incrementRetry() {
            retryCount++;
        }
    }
    
    /**
     * 异步写入
     */
    public void putAsync(Product product) {
        // 1. 写缓存
        redisTemplate.opsForValue().set("product:" + product.getId(), product);
        
        // 2. 加入写队列
        writeQueue.offer(new WriteTask(product));
    }
    
    /**
     * 后台线程处理写队列
     */
    @PostConstruct
    public void startWriter() {
        Executors.newFixedThreadPool(3).execute(() -> {
            while (true) {
                try {
                    WriteTask task = writeQueue.take();
                    
                    try {
                        // 写数据库
                        productMapper.updateById(task.product);
                        
                    } catch (Exception e) {
                        // 失败重试
                        if (task.canRetry()) {
                            task.incrementRetry();
                            writeQueue.offer(task);  // 重新加入队列
                            log.warn("写入失败,第{}次重试", task.retryCount);
                        } else {
                            log.error("写入失败,放弃: {}", task.product.getId());
                            // 记录到失败日志表
                            recordFailure(task.product);
                        }
                    }
                    
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
    }
}

Write Behind的优缺点

优点 ✅:

  • 写性能最高
  • 减少数据库压力
  • 支持批量操作

缺点 ❌:

  • 数据可能丢失(缓存挂了)
  • 实现复杂
  • 不适合强一致性场景

🆚 三种模式对比

维度Cache AsideRead/Write ThroughWrite Behind
复杂度⭐⭐ 简单⭐⭐⭐ 中等⭐⭐⭐⭐ 复杂
性能⭐⭐⭐ 好⭐⭐ 一般⭐⭐⭐⭐⭐ 极好
一致性最终一致强一致最终一致
可靠性
使用场景通用强一致性高并发写
推荐度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

💡 一致性保证方案

1. 延迟双删

/**
 * 延迟双删策略
 */
public void updateWithDoubleDelete(Product product) {
    String key = "product:" + product.getId();
    
    // 第1次删除缓存
    redisTemplate.delete(key);
    
    // 更新数据库
    productMapper.updateById(product);
    
    // 延迟后第2次删除缓存
    CompletableFuture.runAsync(() -> {
        try {
            // 延迟时间取决于主从同步延迟
            Thread.sleep(1000);  
            
            // 第2次删除
            redisTemplate.delete(key);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

为什么要延迟双删?

时间线:

T1: 线程A删除缓存
T2: 线程B读缓存(未命中)
T3: 线程B读数据库(读到旧数据)
T4: 线程A更新数据库
T5: 线程B写入缓存(写入旧数据)❌ 脏数据!
T6: 延迟双删,删除脏数据 ✅

延迟时间 = 主从复制延迟 + 读数据库时间

2. 订阅Binlog

/**
 * 监听MySQL Binlog,自动更新缓存
 */
@Component
public class BinlogCacheUpdater {
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    /**
     * 使用Canal监听Binlog
     */
    @PostConstruct
    public void startListening() {
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "example",
            "",
            ""
        );
        
        connector.connect();
        connector.subscribe("mydb\\.product");  // 订阅product表
        
        while (true) {
            Message message = connector.get(100);
            
            for (CanalEntry.Entry entry : message.getEntries()) {
                if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                    handleRowChange(entry);
                }
            }
        }
    }
    
    /**
     * 处理数据变更
     */
    private void handleRowChange(CanalEntry.Entry entry) {
        CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
        
        for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
            if (rowChange.getEventType() == CanalEntry.EventType.UPDATE) {
                // 数据更新,删除缓存
                Long productId = getProductId(rowData);
                String key = "product:" + productId;
                redisTemplate.delete(key);
                
                log.info("检测到数据变更,删除缓存: {}", key);
            }
        }
    }
}

3. 设置合理的过期时间

/**
 * 不同数据设置不同的过期时间
 */
public class CacheExpireStrategy {
    
    /**
     * 热点数据:过期时间长
     */
    public void cacheHotData(String key, Object value) {
        redisTemplate.opsForValue().set(key, value, 24, TimeUnit.HOURS);
    }
    
    /**
     * 普通数据:过期时间中等
     */
    public void cacheNormalData(String key, Object value) {
        redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
    }
    
    /**
     * 实时性要求高的数据:过期时间短
     */
    public void cacheRealtimeData(String key, Object value) {
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
    }
    
    /**
     * 随机过期时间,避免缓存雪崩
     */
    public void cacheWithRandomExpire(String key, Object value, long baseSeconds) {
        // 基础时间 + 随机时间(0-20%)
        long randomSeconds = (long) (baseSeconds * Math.random() * 0.2);
        long expireSeconds = baseSeconds + randomSeconds;
        
        redisTemplate.opsForValue().set(key, value, expireSeconds, TimeUnit.SECONDS);
    }
}

🎯 最佳实践

1. 选型建议

场景1:电商商品信息
└─ Cache Aside + 延迟双删
└─ 理由:读多写少,允许短暂不一致

场景2:用户余额
└─ Read/Write Through
└─ 理由:强一致性要求

场景3:用户行为日志
└─ Write Behind
└─ 理由:高并发写入,可接受丢失

场景4:库存扣减
└─ 直接操作Redis,定期同步DB
└─ 理由:超高并发,最终一致

2. 监控告警

@Component
public class CacheMonitor {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    /**
     * 监控缓存命中率
     */
    public void recordCacheHit(boolean hit) {
        Counter.builder("cache.hit")
            .tag("hit", String.valueOf(hit))
            .register(meterRegistry)
            .increment();
    }
    
    /**
     * 监控缓存不一致
     */
    @Scheduled(fixedDelay = 60000)  // 每分钟
    public void checkConsistency() {
        // 随机抽样检查
        List<Long> sampleIds = getSampleIds(100);
        
        int inconsistentCount = 0;
        for (Long id : sampleIds) {
            Object cacheValue = getFromCache(id);
            Object dbValue = getFromDB(id);
            
            if (!Objects.equals(cacheValue, dbValue)) {
                inconsistentCount++;
                log.warn("数据不一致: id={}, cache={}, db={}", 
                    id, cacheValue, dbValue);
            }
        }
        
        if (inconsistentCount > 5) {  // 超过5%不一致
            alertService.send("缓存一致性告警", 
                "不一致比例: " + (inconsistentCount * 100 / sampleIds.size()) + "%");
        }
    }
}

🎉 总结

核心要点 ✨

  1. Cache Aside

    • 最常用
    • 先更新DB,再删缓存
    • 延迟双删更安全
  2. Read/Write Through

    • 缓存层统一处理
    • 强一致性
    • 适合金融场景
  3. Write Behind

    • 性能最高
    • 异步写入
    • 适合日志场景

记忆口诀 📝

缓存一致三模式,
场景不同选择异。

Cache Aside最常用,
先更新库再删缓存。
延迟双删更安全,
短暂不一致可接受。

Read Write Through强,
同步更新保一致。
应用简单缓存管,
金融场景适用它。

Write Behind性能高,
异步批量写数据库。
日志统计场景好,
丢失风险要知道!

愿你的缓存永远一致,数据永不出错! 🔄✨