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 随机偏移(雪崩)

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

相关推荐
AI-好学者2 小时前
阶段二-Cypher查询语言详解
数据库·rag·graphrag
龙仔7252 小时前
SQL Server 批量多库只读账号一键脚本(带逐行注释完整脚本 + 分段逐句详细解释)
服务器·数据库·sql·sqlserver·dba
咏方舟【长江支流】3 小时前
【开源】跨语言·跨平台·跨数据库(2) —— 用宝轻量框架设计必读
数据库·架构·开源·ai编程·咏方舟-长江支流·用宝框架
手握风云-3 小时前
Redis:不只是缓存那么简单(十五)
数据库·redis·缓存
weixin_307779134 小时前
Linux下Docker Compose里运行PostgreSQL数据库故障诊断Shell脚本
linux·运维·数据库·docker·postgresql
名字还没想好☜4 小时前
用 Redis + Redisson 实现分布式锁:从踩坑到生产可用
java·redis·分布式·junit·分布式锁·redisson
醉城夜风~4 小时前
MySQL 常用内置函数全解:字符串、数值、日期、流程控制实战教程
数据库·mysql
程序猿秃头之路4 小时前
DDD 系列:实体和值对象详解
数据库·oracle·ddd·领域驱动设计