Redis与MySQL双写一致性:从问题根源到工程落地

Redis与MySQL双写一致性:从问题根源到工程落地

前言

在高并发系统中,Redis 作为缓存层几乎是标配。但只要同时使用了 Redis 和 MySQL,就必然面临一个问题:两份数据如何保持一致?

这个问题在面试中出现频率极高,在生产环境中也是事故高发区。本文从问题根源出发,逐步分析各种方案的优缺点,最终给出可直接落地的工程实践代码。


一、为什么会有双写一致性问题?

先看一个典型场景:用户查询商品库存。

复制代码
正常流程:
1. 请求到达,先查 Redis 缓存
2. 缓存命中,直接返回库存数量
3. 缓存未命中,查 MySQL,将结果写入 Redis,再返回

这个流程看起来没问题,但当写操作发生时,麻烦就来了:

复制代码
更新库存流程(朴素实现):
1. 更新 MySQL 库存为 99
2. 更新 Redis 库存为 99

问题在于,这两步操作不是原子的。在步骤 1 和步骤 2 之间,如果:

  • 进程崩溃了 → Redis 还是旧值
  • 网络超时了 → Redis 更新失败
  • 另一个线程并发读取了 → 读到了不一致的数据

根本原因:Redis 和 MySQL 是两个独立的存储系统,没有分布式事务保证两者同时成功或同时失败。


二、四种缓存操作策略

在解决一致性问题之前,先理清四种常见的缓存策略,它们是后续所有方案的理论基础。

2.1 Cache Aside(旁路缓存)

最常用的策略,由应用层自己管理缓存。

读操作:

复制代码
读缓存 → 命中则返回
       → 未命中则查DB → 写入缓存 → 返回

写操作:

复制代码
更新DB → 删除缓存(注意:是删除,不是更新)

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

更新缓存在并发场景下会引发"写写竞争":两个线程同时更新DB,后更新DB的线程可能先更新了缓存,导致缓存里存的是"旧DB值"。删除缓存则不存在这个问题,下次读取时会从DB重新加载最新值。

2.2 Write Through(直写)

写操作同步更新 DB 和缓存,由缓存层负责写 DB。

复制代码
写请求 → 写缓存 → 缓存层同步写DB → 返回成功

优点:数据一致性强。缺点:写延迟高,且缓存层需要支持此模式(Redis 原生不支持,需要自己实现)。

2.3 Write Behind(异步回写)

先写缓存,缓存层异步批量写 DB。

复制代码
写请求 → 写缓存 → 立即返回成功
                → 异步批量刷新到DB

优点:写性能极高。缺点:数据可能丢失(缓存宕机时未刷盘的数据丢失),一致性最弱。

2.4 Read Through(读穿透)

缓存未命中时,由缓存层自动从 DB 加载数据。

复制代码
读请求 → 查缓存 → 命中则返回
               → 未命中,缓存层自动查DB并回填 → 返回

与 Cache Aside 的区别:Cache Aside 由应用层回填缓存,Read Through 由缓存层自动回填。

策略对比总结:

策略 写操作 一致性 性能 适用场景
Cache Aside 先写DB,删缓存 最终一致 高 通用,最常用
Write Through 同步写DB和缓存 强一致 中 写少读多
Write Behind 先写缓存,异步写DB 弱一致 极高 允许数据丢失的写多场景
Read Through 缓存层自动回填 最终一致 高 读多场景

三、Cache Aside 的并发陷阱

Cache Aside 是最常用的策略,但它在并发场景下有几个经典的坑,必须搞清楚。

3.1 先删缓存 vs 先更新DB

方案A:先删缓存,再更新DB(错误做法)

复制代码
时间线:
T1: 线程A 删除缓存
T2: 线程B 读取缓存,miss,查DB得到旧值,写入缓存
T3: 线程A 更新DB为新值
结果:缓存里是旧值,DB里是新值 → 不一致!

方案B:先更新DB,再删缓存(正确做法)

复制代码
时间线:
T1: 线程A 更新DB为新值
T2: 线程B 读取缓存,命中旧值(此时缓存还未删除)
T3: 线程A 删除缓存
T4: 线程C 读取缓存,miss,查DB得到新值,写入缓存
结果:最终一致 ✓

方案B 也存在极小概率的不一致窗口(T2 时刻读到旧缓存),但这个窗口极短,且下次读取后会自动修正,属于可接受的最终一致性。

3.2 方案B 的极端并发场景

方案B(先更新DB,再删缓存)虽然是正确做法,但在极端情况下仍有一个低概率的不一致场景,面试中经常被追问:

复制代码
前提:缓存中的旧值恰好在此时过期(或从未被缓存过)

T1: 线程A 开始更新DB(还未提交)
T2: 线程B 读取缓存,miss,查DB读到旧值(A的事务未提交,读到提交前的值)
T3: 线程A 更新DB提交成功
T4: 线程A 删除缓存(此时缓存里没有数据,删除是空操作)
T5: 线程B 将旧值写入缓存
结果:缓存里是旧值,DB里是新值 → 不一致!

这个场景需要同时满足三个条件:

  1. 缓存恰好在此时 miss(过期或冷数据)
  2. 线程B 的读DB操作发生在线程A 事务提交之前
  3. 线程B 的写缓存操作发生在线程A 删缓存之后

三个条件同时成立的概率极低,且这个不一致会在下次写操作时自动修复。对于绝大多数业务,这是可以接受的。 如果无法接受,则需要升级到读写锁方案(见第六章)。

3.2 删缓存失败怎么办?

先更新DB、再删缓存的方案中,如果删缓存这一步失败了(网络抖动、Redis 宕机),缓存里就会一直是旧值,直到 TTL 过期。

解决方案:引入重试机制

java 复制代码
@Service
public class ProductService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 更新商品库存
     * 先更新DB,再删缓存,删除失败则发送MQ异步重试
     */
    public void updateStock(Long productId, Integer stock) {
        // 1. 更新数据库
        productMapper.updateStock(productId, stock);

        // 2. 删除缓存
        String cacheKey = "product:stock:" + productId;
        try {
            redisTemplate.delete(cacheKey);
        } catch (Exception e) {
            // 3. 删除失败,发送MQ消息,异步重试
            log.error("删除缓存失败,发送重试消息, key={}", cacheKey, e);
            rabbitTemplate.convertAndSend("cache.delete.exchange", "cache.delete", cacheKey);
        }
    }
}
java 复制代码
@Component
public class CacheDeleteConsumer {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 消费缓存删除重试消息
     * 配置死信队列,超过最大重试次数后告警
     */
    @RabbitListener(queues = "cache.delete.queue")
    public void handleCacheDelete(String cacheKey) {
        try {
            redisTemplate.delete(cacheKey);
            log.info("缓存删除重试成功, key={}", cacheKey);
        } catch (Exception e) {
            log.error("缓存删除重试失败, key={}", cacheKey, e);
            // 抛出异常,触发 RabbitMQ 的重试机制
            throw new RuntimeException("缓存删除失败", e);
        }
    }
}

3.3 TTL 是最后的兜底保障

无论采用哪种方案,给缓存设置合理的 TTL 是必须的。它是所有一致性方案的最后一道防线:即使缓存因为某种极端情况存了脏数据,TTL 到期后也会自动失效,下次读取时从 DB 重新加载。

TTL 设置原则:

数据类型 建议 TTL 理由
用户信息、商品详情 30 分钟 ~ 2 小时 变更不频繁,可以容忍短暂不一致
库存、价格等敏感数据 5 ~ 10 分钟 变更较频繁,TTL 要短
热点排行榜、统计数据 1 ~ 5 分钟 允许一定延迟,TTL 可以短一些
验证码、Token 与业务过期时间一致 强业务语义,不能随意设置

防止缓存雪崩的 TTL 写法:

java 复制代码
// 错误:所有 key 使用固定 TTL,可能同时大量过期
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);

// 正确:在基础 TTL 上加随机偏移,打散过期时间
long baseTtl = 30;
long randomOffset = ThreadLocalRandom.current().nextLong(0, 10); // 0~10 分钟随机偏移
redisTemplate.opsForValue().set(key, value, baseTtl + randomOffset, TimeUnit.MINUTES);

四、延迟双删策略

在某些场景下(比如先删缓存再更新DB的旧代码改造),可以使用延迟双删来降低不一致的概率:

java 复制代码
public void updateWithDelayDoubleDelete(String key, Object data) {
    // 第一次删除缓存
    redisTemplate.delete(key);

    // 更新数据库
    db.update(data);

    // 延迟一段时间后,第二次删除缓存
    // 目的:覆盖掉在"删缓存→更新DB"期间,其他线程写入的脏缓存
    CompletableFuture.runAsync(() -> {
        try {
            // 延迟时间 > 一次读DB+写缓存的耗时,通常 500ms~1s
            Thread.sleep(500);
            redisTemplate.delete(key);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

延迟双删的局限性:

  • 延迟时间难以精确估算,设短了不够,设长了影响一致性窗口
  • 异步线程本身也可能失败
  • 只是降低了不一致概率,并不能完全消除

结论:延迟双删适合作为临时补丁,不建议作为长期方案。


五、Canal + MQ 方案(推荐的工程级方案)

上面的方案都需要在业务代码里手动处理缓存,侵入性强,容易遗漏。更优雅的方式是监听 MySQL binlog,异步同步缓存。

5.1 架构图

复制代码
┌─────────────┐    写操作     ┌─────────────┐
│  业务服务    │ ──────────→  │    MySQL     │
└─────────────┘              └──────┬──────┘
                                    │ binlog
                             ┌──────▼──────┐
                             │ Canal Server │
                             └──────┬──────┘
                                    │
                             ┌──────▼──────┐
                             │  MQ(Kafka) │
                             └──────┬──────┘
                                    │
                             ┌──────▼──────┐
                             │  缓存消费者  │ ──→ 删除/更新 Redis
                             └─────────────┘

5.2 MySQL 前置配置

Canal 通过伪装成 MySQL 从库来接收 binlog,因此 MySQL 必须先开启 binlog,并创建一个有复制权限的账号。

第一步:开启 binlog(修改 my.cnf)

ini 复制代码
[mysqld]
# 开启 binlog
log-bin=mysql-bin
# binlog 格式必须是 ROW,Canal 依赖行级变更数据
binlog-format=ROW
# server-id 在集群中必须唯一
server-id=1
# 可选:只监听指定数据库,减少 binlog 体积
binlog-do-db=your_database_name

修改后重启 MySQL,验证是否生效:

sql 复制代码
SHOW VARIABLES LIKE 'log_bin';
-- 结果应为 ON

SHOW VARIABLES LIKE 'binlog_format';
-- 结果应为 ROW

第二步:创建 Canal 专用账号

sql 复制代码
-- 创建账号
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';

-- 授予复制权限(Canal 需要这两个权限来读取 binlog)
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';

FLUSH PRIVILEGES;

第三步:Canal Server 配置(conf/example/instance.properties)

properties 复制代码
# MySQL 地址
canal.instance.master.address=127.0.0.1:3306

# 上面创建的账号
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_password

# 监听的数据库和表,支持正则
# 监听所有库所有表:.*\\..*
# 只监听 shop 库的 product 表:shop\\.product
canal.instance.filter.regex=shop\\.product

5.3 Canal 简介

Canal 是阿里开源的 MySQL binlog 增量订阅组件,伪装成 MySQL 的从库,实时接收主库的 binlog 变更事件。

优点:

  • 业务代码零侵入,不需要在每个写操作后手动删缓存
  • 天然异步,不影响主链路性能
  • 失败可重试,消息不丢失

缺点:

  • 引入了 Canal 和 MQ 两个额外组件,运维成本增加
  • 存在毫秒级延迟(binlog 传输 + MQ 消费)

5.3 Spring Boot 集成 Canal 示例

引入依赖:

xml 复制代码
<dependency>
    <groupId>top.javatool</groupId>
    <artifactId>canal-spring-boot-starter</artifactId>
    <version>1.2.1-RELEASE</version>
</dependency>

application.yml 配置:

yaml 复制代码
canal:
  server: 127.0.0.1:11111
  destination: example

定义数据模型:

java 复制代码
@CanalTable("product")
@Data
public class Product {
    @Id
    private Long id;
    private String name;
    private Integer stock;
    private BigDecimal price;
}

编写监听器:

java 复制代码
@Component
@CanalTable("product")
public class ProductCanalHandler implements EntryHandler<Product> {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String CACHE_KEY_PREFIX = "product:";

    /**
     * 数据插入时,可选择预热缓存或直接忽略(等待下次查询时自动加载)
     */
    @Override
    public void insert(Product product) {
        // 通常插入时不主动写缓存,等查询时懒加载
        log.info("product insert, id={}", product.getId());
    }

    /**
     * 数据更新时,删除对应缓存
     */
    @Override
    public void update(Product before, Product after) {
        String key = CACHE_KEY_PREFIX + after.getId();
        redisTemplate.delete(key);
        log.info("product updated, delete cache key={}", key);
    }

    /**
     * 数据删除时,同步删除缓存
     */
    @Override
    public void delete(Product product) {
        String key = CACHE_KEY_PREFIX + product.getId();
        redisTemplate.delete(key);
        log.info("product deleted, delete cache key={}", key);
    }
}

这样,业务代码只需要正常操作 MySQL,缓存的同步完全由 Canal 监听器自动处理,业务层完全解耦。


六、强一致性场景:Redisson 读写锁

对于库存扣减、账户余额等绝对不能读到脏数据的场景,最终一致性是不够的,需要强一致性方案。

使用 Redisson 读写锁可以保证:读操作之间不互斥,读写操作之间互斥,写写操作之间互斥。

引入依赖:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.2</version>
</dependency>

读写锁实现:

java 复制代码
@Service
public class StockService {

    @Autowired
    private RedissonClient redissonClient;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private ProductMapper productMapper;

    private static final String LOCK_KEY_PREFIX = "lock:product:";
    private static final String CACHE_KEY_PREFIX = "product:stock:";
    private static final long CACHE_TTL = 30L;

    /**
     * 查询库存(加读锁)
     * 多个读操作可以并发执行,但写操作进行时读操作会阻塞
     */
    public Integer getStock(Long productId) {
        String lockKey = LOCK_KEY_PREFIX + productId;
        String cacheKey = CACHE_KEY_PREFIX + productId;

        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
        RLock readLock = readWriteLock.readLock();

        readLock.lock();
        try {
            // 先查缓存
            String cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return Integer.parseInt(cached);
            }

            // 缓存未命中,查DB
            Integer stock = productMapper.selectStockById(productId);
            if (stock != null) {
                redisTemplate.opsForValue().set(cacheKey, String.valueOf(stock), CACHE_TTL, TimeUnit.MINUTES);
            }
            return stock;
        } finally {
            readLock.unlock();
        }
    }

    /**
     * 更新库存(加写锁)
     * 写锁期间,所有读操作和写操作都会阻塞
     */
    public void updateStock(Long productId, Integer stock) {
        String lockKey = LOCK_KEY_PREFIX + productId;
        String cacheKey = CACHE_KEY_PREFIX + productId;

        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
        RLock writeLock = readWriteLock.writeLock();

        writeLock.lock();
        try {
            // 更新DB
            productMapper.updateStock(productId, stock);
            // 删除缓存(下次读取时重新加载)
            redisTemplate.delete(cacheKey);
        } finally {
            writeLock.unlock();
        }
    }
}

注意事项:

  • 读写锁会降低并发性能,仅在强一致性要求的场景使用
  • 锁的粒度要尽量细(按 productId 加锁,而不是全局锁)
  • 锁超时时间要合理设置,避免死锁

七、缓存预热

服务刚启动时,Redis 中没有任何数据,所有请求都会打到 MySQL,这就是冷启动问题。在高并发场景下,这可能直接压垮数据库。

缓存预热就是在服务启动时,提前将热点数据加载到 Redis,避免冷启动时的流量冲击。

7.1 启动时预热(ApplicationRunner)

Spring Boot 提供了 ApplicationRunner 接口,在应用启动完成后自动执行,适合做缓存预热:

java 复制代码
@Component
@Slf4j
public class CacheWarmUpRunner implements ApplicationRunner {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String CACHE_KEY_PREFIX = "product:";
    private static final long CACHE_TTL_MINUTES = 60L;

    @Override
    public void run(ApplicationArguments args) {
        log.info("开始缓存预热...");
        long start = System.currentTimeMillis();

        // 查询热点商品(比如销量 Top 1000)
        List<Product> hotProducts = productMapper.selectTopSaleProducts(1000);

        // 批量写入 Redis,使用 Pipeline 减少网络往返次数
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (Product product : hotProducts) {
                String key = CACHE_KEY_PREFIX + product.getId();
                String value = JSON.toJSONString(product);
                // 加随机偏移,避免同时过期
                long ttl = CACHE_TTL_MINUTES + ThreadLocalRandom.current().nextLong(0, 10);
                connection.setEx(
                    key.getBytes(StandardCharsets.UTF_8),
                    ttl * 60,
                    value.getBytes(StandardCharsets.UTF_8)
                );
            }
            return null;
        });

        log.info("缓存预热完成,共预热 {} 条数据,耗时 {} ms",
            hotProducts.size(), System.currentTimeMillis() - start);
    }
}

7.2 定时刷新热点缓存

对于排行榜、首页推荐等数据,可以用定时任务定期刷新,保证热点数据始终在缓存中:

java 复制代码
@Component
@Slf4j
public class HotCacheRefreshTask {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 每 5 分钟刷新一次热点商品缓存
     */
    @Scheduled(fixedRate = 5 * 60 * 1000)
    public void refreshHotProductCache() {
        List<Product> hotProducts = productMapper.selectTopSaleProducts(1000);
        for (Product product : hotProducts) {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 10, TimeUnit.MINUTES);
        }
        log.info("热点缓存刷新完成,共刷新 {} 条", hotProducts.size());
    }
}

注意: 使用 @Scheduled 需要在启动类上加 @EnableScheduling。


八、缓存穿透、击穿、雪崩

这三个问题与双写一致性密切相关,是缓存体系稳定性的三大威胁,必须一并掌握。

8.1 缓存穿透

现象: 查询一个数据库中根本不存在的数据,缓存永远 miss,每次都打到 DB。恶意攻击者可以用大量不存在的 key 发起请求,直接压垮数据库。

方案一:缓存空值

java 复制代码
public Product getProduct(Long productId) {
    String key = "product:" + productId;
    String cached = redisTemplate.opsForValue().get(key);

    if (cached != null) {
        // 命中缓存,判断是否是空值占位符
        if ("NULL".equals(cached)) {
            return null; // 数据不存在
        }
        return JSON.parseObject(cached, Product.class);
    }

    // 查DB
    Product product = productMapper.selectById(productId);
    if (product == null) {
        // 数据不存在,缓存空值,TTL 设短一点(5分钟),避免占用过多内存
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
        return null;
    }

    redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
    return product;
}

方案二:布隆过滤器(推荐,适合数据量大的场景)

布隆过滤器可以用极小的内存判断一个元素"一定不存在"或"可能存在",在查缓存之前先过滤掉不存在的 key:

java 复制代码
@Component
public class BloomFilterService {

    @Autowired
    private RedissonClient redissonClient;

    private static final String BLOOM_FILTER_NAME = "product:bloom";

    /**
     * 初始化布隆过滤器(服务启动时调用)
     * expectedInsertions:预期插入数量
     * falseProbability:误判率(0.01 表示 1%)
     */
    public void initBloomFilter(List<Long> productIds) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        bloomFilter.tryInit(1_000_000L, 0.01);
        for (Long id : productIds) {
            bloomFilter.add(id);
        }
    }

    /**
     * 判断 productId 是否可能存在
     */
    public boolean mightExist(Long productId) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        return bloomFilter.contains(productId);
    }
}
java 复制代码
public Product getProductWithBloom(Long productId) {
    // 布隆过滤器判断:如果一定不存在,直接返回
    if (!bloomFilterService.mightExist(productId)) {
        return null;
    }
    // 后续正常走缓存 → DB 流程
    // ...
}

8.2 缓存击穿

现象: 某个热点 key 突然过期,大量并发请求同时 miss,全部打到 DB 重建缓存,造成 DB 瞬间压力飙升。

方案一:互斥锁重建缓存

只让一个线程去查 DB 重建缓存,其他线程等待:

java 复制代码
public Product getProductWithMutex(Long productId) {
    String key = "product:" + productId;
    String cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }

    // 缓存 miss,尝试获取互斥锁
    String lockKey = "lock:product:" + productId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 获取到锁,查DB重建缓存
            Product product = productMapper.selectById(productId);
            if (product != null) {
                redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
            }
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 未获取到锁,短暂等待后重试(自旋)
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithMutex(productId); // 递归重试
    }
}

方案二:逻辑过期(适合极热点数据)

不设置 Redis 的物理 TTL,而是在缓存值中存一个逻辑过期时间。过期后异步重建,读请求直接返回旧值,不阻塞:

java 复制代码
@Data
public class CacheWrapper<T> {
    private T data;
    private LocalDateTime expireTime;
}
java 复制代码
public Product getProductWithLogicalExpire(Long productId) {
    String key = "product:" + productId;
    String cached = redisTemplate.opsForValue().get(key);

    if (cached == null) {
        // 未命中(冷数据),返回 null 或触发缓存重建
        return null;
    }

    CacheWrapper<Product> wrapper = JSON.parseObject(cached, new TypeReference<CacheWrapper<Product>>() {});

    // 判断逻辑是否过期
    if (LocalDateTime.now().isBefore(wrapper.getExpireTime())) {
        // 未过期,直接返回
        return wrapper.getData();
    }

    // 已过期,尝试获取锁,异步重建缓存
    String lockKey = "lock:product:" + productId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (Boolean.TRUE.equals(locked)) {
        // 异步重建,不阻塞当前请求
        CompletableFuture.runAsync(() -> {
            try {
                Product newProduct = productMapper.selectById(productId);
                CacheWrapper<Product> newWrapper = new CacheWrapper<>();
                newWrapper.setData(newProduct);
                newWrapper.setExpireTime(LocalDateTime.now().plusMinutes(30));
                // 不设置 TTL,永不物理过期
                redisTemplate.opsForValue().set(key, JSON.toJSONString(newWrapper));
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }

    // 返回旧数据(允许短暂不一致)
    return wrapper.getData();
}

8.3 缓存雪崩

现象: 大量 key 在同一时间过期,或 Redis 服务宕机,导致所有请求同时打到 DB。

防范措施:

java 复制代码
// 措施1:TTL 加随机偏移(已在第三章提到)
long ttl = 30 + ThreadLocalRandom.current().nextLong(0, 10);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.MINUTES);

// 措施2:服务降级 + 限流(使用 Sentinel)
// 当 Redis 不可用时,限制打到 DB 的 QPS,保护数据库
// 具体配置见 Sentinel 文档

// 措施3:多级缓存(本地缓存 + Redis)
// 即使 Redis 宕机,本地缓存(Caffeine)仍可承接部分流量

本地缓存兜底示例(Caffeine + Redis 二级缓存):

java 复制代码
@Component
public class TwoLevelCacheService {

    // 本地缓存:最多 1000 条,过期时间 5 分钟
    private final Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private ProductMapper productMapper;

    public Product getProduct(Long productId) {
        // 第一级:本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }

        // 第二级:Redis 缓存
        String key = "product:" + productId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            product = JSON.parseObject(cached, Product.class);
            localCache.put(productId, product); // 回填本地缓存
            return product;
        }

        // 第三级:数据库
        product = productMapper.selectById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
            localCache.put(productId, product);
        }
        return product;
    }
}

使用 Caffeine 需要引入依赖:

xml 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

九、方案选型指南

场景 推荐方案 理由
普通业务,允许短暂不一致(秒级) Cache Aside + MQ 异步重试删缓存 实现简单,性能好
大规模微服务,不想改业务代码 Canal + MQ 业务零侵入,天然解耦
库存、余额等强一致性场景 Redisson 读写锁 保证强一致,但性能有损耗
写操作极其频繁,允许少量数据丢失 Write Behind 写性能最高,适合日志类数据

十、常见面试追问

Q:为什么是删除缓存,而不是更新缓存?

更新缓存在并发写场景下会出现"写写竞争":两个线程同时更新 DB,后写 DB 的线程可能先写了缓存,导致缓存里存的是"旧 DB 值"。删除缓存不存在这个问题,下次读取时会从 DB 加载最新值。此外,很多缓存值是经过计算或聚合的(比如用户的订单总金额),更新缓存需要重新计算,不如直接删除让下次读时重建来得简单。


Q:Cache Aside 先更新 DB 再删缓存,还是有不一致的可能吗?

有,但概率极低。需要同时满足三个条件:

  1. 缓存恰好在此时 miss(过期或冷数据)
  2. 另一个线程在写线程更新 DB 之前读取了旧值
  3. 该线程的写缓存操作发生在写线程删缓存之后

这个时间窗口极短(微秒级),通常可以接受。如果无法接受,升级到读写锁方案。


Q:Canal 方案能保证强一致性吗?

不能。Canal 是异步的,存在毫秒级延迟。在 binlog 传输和 MQ 消费期间,缓存里可能还是旧值。如果需要强一致性,必须使用读写锁方案。


Q:如果 MQ 消费失败,缓存删除消息丢了怎么办?

需要配置死信队列(DLQ)和重试策略:

  • RabbitMQ:配置 x-dead-letter-exchange,消息重试超过次数后进入死信队列,触发告警人工处理
  • Kafka:消费失败后不提交 offset,消息会被重新消费,天然支持重试
  • 最终兜底:TTL 到期后缓存自动失效,下次读取时从 DB 重新加载

Q:Redis 主从切换时,会不会丢失删缓存的操作?

会。Redis 主从复制是异步的,主节点删除了缓存,如果在同步到从节点之前主节点宕机,从节点晋升为主节点后,缓存里仍然是旧值。这是 Redis 本身的 AP 特性(可用性优先,牺牲强一致性)。

应对方式:

  • 接受这个极低概率的不一致,依赖 TTL 兜底
  • 对于强一致性场景,使用读写锁,不依赖缓存删除的可靠性

Q:缓存穿透、缓存击穿、缓存雪崩的区别?

问题 触发条件 影响 解决方案
缓存穿透 查询不存在的 key 每次都打到 DB 缓存空值 / 布隆过滤器
缓存击穿 热点 key 突然过期 瞬间大量请求打到 DB 互斥锁重建 / 逻辑过期
缓存雪崩 大量 key 同时过期 / Redis 宕机 DB 被全量流量压垮 TTL 随机偏移 / 多级缓存 / 限流降级

Q:本地缓存(Caffeine)和 Redis 同时使用,如何保证本地缓存的一致性?

本地缓存的一致性比 Redis 更难保证,因为每个服务实例都有自己的本地缓存,更新时需要通知所有实例。常见方案:

  • MQ 广播:数据变更时,发送广播消息,所有实例收到后清除本地缓存对应的 key
  • Redis Pub/Sub:利用 Redis 的发布订阅功能,数据变更时发布消息,所有订阅的实例清除本地缓存
  • 短 TTL:本地缓存设置较短的 TTL(1~5 分钟),接受短暂不一致,到期自动失效
java 复制代码
// Redis Pub/Sub 通知本地缓存失效示例
@Component
public class LocalCacheInvalidator {

    private final Cache<Long, Product> localCache; // 注入 Caffeine 缓存

    @Autowired
    private StringRedisTemplate redisTemplate;

    // 发布失效通知
    public void publishInvalidate(Long productId) {
        redisTemplate.convertAndSend("cache:invalidate:product", String.valueOf(productId));
    }

    // 订阅失效通知,清除本地缓存
    @Bean
    public MessageListenerAdapter cacheInvalidateListener() {
        return new MessageListenerAdapter(new MessageListener() {
            @Override
            public void onMessage(Message message, byte[] pattern) {
                Long productId = Long.parseLong(new String(message.getBody()));
                localCache.invalidate(productId);
                log.info("本地缓存失效, productId={}", productId);
            }
        });
    }
}

总结

Redis 与 MySQL 的双写一致性没有银弹,核心是根据业务对一致性的要求选择合适的方案。

一句话记忆:

  • 普通业务 → Cache Aside(先更新 DB,再删缓存)+ MQ 重试兜底
  • 工程解耦 → Canal 监听 binlog,业务代码零侵入
  • 强一致性 → Redisson 读写锁,用性能换一致性
  • 防冷启动 → ApplicationRunner 预热 + 定时刷新热点数据
  • 防三大问题 → 布隆过滤器(穿透)+ 互斥锁/逻辑过期(击穿)+ TTL 随机偏移(雪崩)

理解每种方案背后的权衡,才能在面试和实际工作中做出正确的技术决策。

相关推荐
AIgorithmGEEK11 小时前
MySQL 索引全解(下):建、查、删实战与知识速查表
数据库·mysql·b+树·索引
Lost_the_wind11 小时前
MySQL学习笔记四
mysql
行百里er13 小时前
Redis 完结篇——Redis 热点问题在 Spring Boot 项目中实战
redis·后端
唠点键盘之外的13 小时前
26 向量数据库实战:从 0 搭一条能用的检索链路(pgvector + Spring AI)
数据库·人工智能·spring
云和恩墨13 小时前
数据库监控简史:监控不止于告警——从图表大爆发到健康管理闭环
数据库
ly768913 小时前
MongoDB 事务开销实测:快照隔离下的写冲突、重试与分片事务边界
数据库·mongodb·事务·分片集群·快照隔离·写冲突
豆角焖肉13 小时前
Spring AOP 自定义注解实现系统操作日志
数据库·自定义注解·springaop·操作日志·aop 切面
金伟API102415 小时前
不会写复杂SQL怎么办?
数据库·sql
执笔为剑15 小时前
2.1.2-1、V8R3迁移到V8R6-Oracle兼容特性
数据库
geovindu15 小时前
sql:Search-Optimized SQL Queries using mysql 9.0
android·数据库·sql·mysql·数据库开发·数据库架构