解决 MySQL 与 Redis 双写矛盾:Java 开发日记里的实战思路与避坑要点


一、问题现场:当缓存与数据库“打架”

上周线上出现诡异现象:商品库存更新后,前端仍显示旧数据。根本矛盾在于:

  • 先更新数据库还是先删缓存?
  • 如何保证两步操作的原子性?
  • 缓存穿透/雪崩如何规避?
// 错误示范:非原子操作导致脏数据
public void updateProduct(Product product) {
    mysql.update(product);  // 步骤1:更新数据库
    redis.del("product:" + product.getId()); // 步骤2:删除缓存
    // 若步骤2失败,后续请求将读取到旧缓存
}


二、双写一致性核心方案

方案1:延迟双删策略(强一致性场景)
public void updateWithDelayDelete(Product product) {
    // 1. 先删除缓存
    redis.del("product:" + product.getId()); 
    
    // 2. 更新数据库
    mysql.update(product);  
    
    // 3. 延迟500ms再次删除缓存(处理并发读产生的脏数据)
    Thread.sleep(500);
    redis.del("product:" + product.getId()); 
}

适用场景:金融、库存等强一致性业务
避坑点

  • 延迟时间需根据业务压力动态调整
  • 必须配合消息队列重试机制(防止删除失败)

方案2:异步监听 Binlog(最终一致性场景)


通过 Canal 监听 MySQL Binlog,异步更新缓存:

// Canal 客户端伪代码
public void onBinlogEvent(BinlogEvent event) {
    if (event.isUpdate() && event.getTable().equals("product")) {
        Product product = parse(event.getData());
        redis.set("product:" + product.getId(), product); 
    }
}

优势

  • 解耦业务代码与缓存逻辑
  • 天然支持主从延迟场景
    坑点
  • 需处理消息积压问题(Kafka 分区策略优化)
  • 避免循环更新(过滤无关表变更)

三、Java 实战避坑清单

坑1:缓存穿透的防御姿势
// 查询商品时:缓存空值避免穿透
public Product getProduct(long id) {
    String key = "product:" + id;
    Product product = redis.get(key, Product.class);
    
    if (product == null) {
        product = mysql.query(id);
        if (product == null) {
            // 缓存空值(设置短过期时间)
            redis.setex(key, 300, "NULL"); 
        } else {
            redis.set(key, product);
        }
    } else if ("NULL".equals(product)) {
        return null; // 识别空值标记
    }
    return product;
}

坑2:缓存雪崩的分布式锁
// 使用 Redisson 防止缓存重建风暴
public Product getProductWithLock(long id) {
    String key = "product:" + id;
    Product product = redis.get(key, Product.class);
    
    if (product == null) {
        RLock lock = redisson.getLock("lock:" + key);
        try {
            lock.lock(); // 获取分布式锁
            // 双重检查(防止重复查询DB)
            product = redis.get(key, Product.class);
            if (product == null) {
                product = mysql.query(id);
                redis.set(key, product, 60 + random(30)); // 随机过期时间
            }
        } finally {
            lock.unlock();
        }
    }
    return product;
}


四、性能优化关键参数

组件 参数 推荐值 作用
Redis maxTotal 业务连接数 × 2 控制连接池大小
timeout 3000ms 防止线程阻塞
MySQL innodb_flush_log_at_trx_commit 2 平衡安全与性能
Canal parallelThreads CPU核数 × 2 提升Binlog解析速度

五、总结:双写一致性选择指南

  1. 强一致性:延迟双删 + 事务消息补偿
  2. 最终一致性:Binlog监听 + 异步更新
  3. 特殊场景
    • 读多写少 → 优先更新DB再删缓存
    • 写多读少 → 直接操作DB,缓存设短TTL

关键认知:没有银弹方案! 需根据业务容忍度选择策略。
下期预告:《分布式锁的七种武器:从Redis到ZooKeeper的深度对比》

(本文代码实测于JDK11+SpringBoot3.0环境,关注公众号获取完整Demo)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐