缓存与数据库一致性:从理论到实战

前言

当你面试的时候,面试官可能会问你这样的场景问题:

用户下单后,明明已经支付成功,但页面上的订单状态还是"待支付"------刷新一下好了;用户修改了个人资料,再次查看时还是旧信息------等几秒又对了。

这个问题你怎么回答?

这些问题的根源,基本都是缓存与数据库数据不一致

你的回答是:先删缓存,再写数据库? 如果你这样回答,那就来看看这一篇。

缓存能大幅提升系统性能,但引入缓存的同时,也带来了数据一致性的挑战。缓存和数据库是两个独立的存储系统,更新时机、失败处理稍有不当,就会导致数据不一致。

本文将从原理到实战,系统梳理缓存一致性的解决方案,所有代码按文件整合,方便直接参考。


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

1.1 典型场景

大多数系统的读写流程是这样的:

读操作:先查缓存,命中则直接返回;未命中则查数据库,再写入缓存。

写操作:更新数据库,然后更新缓存或删除缓存。

问题就出在写操作上------更新数据库和操作缓存是两个独立的步骤,任何一步失败,都会导致数据不一致。

1.2 不一致的根源

原因 说明
操作非原子 更新数据库和更新缓存是两个操作,无法在一个事务内完成
并发读写 一个线程正在更新缓存,另一个线程同时读取,可能读到中间状态
操作失败 数据库更新成功但缓存更新失败,或反之
延迟同步 缓存更新有延迟,短时间内数据不一致

1.3 一致性级别

在开始之前,先明确我们能接受什么级别的"一致":

级别 含义 适用场景
强一致性 写入后任何时刻都能读到最新值 金融、支付、库存扣减
最终一致性 允许短暂不一致,但最终会一致 大部分互联网业务
弱一致性 不保证什么时候能读到最新值 非关键数据(如浏览量)

💡 对于大多数业务,最终一致性就已经够用了。追求强一致性会带来巨大的性能开销。


二、常见策略对比

策略 核心思路 优点 缺点
Cache Aside(旁路缓存) 读未命中时查DB写缓存;写时更新DB并删除缓存 实现简单,命中率高 删除缓存期间有短暂不一致
Read/Write Through(读写穿透) 缓存层代理所有读写,由缓存层与DB交互 对应用透明 缓存层实现复杂
Write Behind(异步写入) 只写缓存,异步批量刷入DB 写入性能极高 数据丢失风险大
双写 + 重试 先更新DB,再更新缓存,失败则重试 保证最终一致 实现稍复杂

最推荐的是「Cache Aside + 延迟双删」 ------这是业界最成熟、最实用的方案。


三、核心策略详解:Cache Aside

3.1 读流程

text 复制代码
1. 查询缓存
2. 命中 → 直接返回
3. 未命中 → 查数据库 → 写入缓存 → 返回

3.2 写流程(核心)

text 复制代码
1. 更新数据库
2. 删除缓存(不是更新缓存!)

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

做法 问题
更新缓存 并发写可能导致缓存被旧数据覆盖;如果更新频繁,缓存会被无效数据填满
删除缓存 下一次读会查数据库并重建缓存,保证数据是最新的

Cache Aside写流程的核心代码:

java 复制代码
@Service
public class ProductService {
    
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        // 2. 删除缓存(让下次读去重建)
        redisTemplate.delete("product:" + product.getId());
    }
}

3.3 Cache Aside的并发问题

先更新数据库再删除缓存,中间有时间窗口,可能发生:

时间 线程A(写) 线程B(读) 结果
T1 更新数据库(新值)
T2 查缓存(旧值存在)
T3 删除缓存
T4 返回旧值 ❌ 不一致

这就是经典的**「先更新DB再删缓存」的并发问题**------在T2到T3之间,读请求会读到旧缓存。


四、解决方案一:延迟双删

4.1 核心原理

延迟双删的核心思路是:删两次缓存------删完等一会儿,再删一次

text 复制代码
1. 删除缓存(第一次)
2. 更新数据库
3. 休眠一段时间(大于读请求的执行时间)
4. 删除缓存(第二次)

为什么有效?

  • 第一次删除:清空旧缓存,让后续读请求去查数据库
  • 休眠:等待可能正在进行的读请求完成(读请求会把旧数据写回缓存)
  • 第二次删除:把读请求可能写入的旧缓存再清掉

4.2 完整代码实现

📁 项目结构

text 复制代码
src/
├── main/
│   ├── java/com/example/demo/
│   │   ├── service/
│   │   │   └── ProductService.java
│   │   └── annotation/
│   │       └── CacheEvict.java
│   └── resources/
│       └── application.yml
└── pom.xml

ProductService.java(延迟双删完整实现)

java 复制代码
package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.mapper.ProductMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;

@Service
public class ProductService {

    @Resource
    private ProductMapper productMapper;
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    private static final String CACHE_KEY_PREFIX = "product:";

    /**
     * 更新商品 - 延迟双删方案
     */
    @Transactional
    public void updateProduct(Product product) {
        Long id = product.getId();
        String cacheKey = CACHE_KEY_PREFIX + id;

        // 1. 第一次删除缓存
        redisTemplate.delete(cacheKey);

        // 2. 更新数据库
        productMapper.updateById(product);

        // 3. 延迟一段时间(异步执行第二次删除)
        delayDeleteCache(cacheKey);
    }

    /**
     * 异步延迟删除缓存
     * 延迟时间应大于读请求的执行时间(通常100-300ms)
     */
    private void delayDeleteCache(String cacheKey) {
        // 方式一:使用线程池异步执行
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(200); // 休眠200ms
                redisTemplate.delete(cacheKey);
                log.info("延迟双删完成: {}", cacheKey);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }

    /**
     * 读操作:查缓存 → 未命中则查数据库 → 写缓存
     */
    public Product getProduct(Long id) {
        String cacheKey = CACHE_KEY_PREFIX + id;

        // 1. 查缓存
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (product != null) {
            return product;
        }

        // 2. 查数据库
        product = productMapper.selectById(id);
        if (product != null) {
            // 3. 写缓存(设置过期时间,防止缓存雪崩)
            redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
        }
        return product;
    }
}

4.3 延迟双删的注意事项

要点 说明
延迟时间怎么定? 根据业务实际的读耗时来定,一般100-300ms,建议压测后确定
异步删除必须用 同步sleep会阻塞主线程,影响接口响应时间
第二次删除失败怎么办? 记录日志 + 定时任务补偿(见下文)

五、解决方案二:订阅Binlog(终极方案)

延迟双删能解决大部分问题,但有一个前提:第二次删除必须成功。如果第二次删除失败,数据仍然不一致。

更可靠的方式是:监听MySQL的binlog,异步删除缓存

5.1 原理

text 复制代码
1. 更新数据库(业务正常执行)
2. Canal监听binlog,捕获数据变更事件
3. 解析变更事件,异步删除对应的缓存

这个方案的好处是:

  • 解耦:缓存删除和业务逻辑完全分离
  • 可靠:binlog是MySQL的官方机制,不会丢数据
  • 最终一致:只要binlog被消费,缓存就一定会被删除

5.2 架构图

text 复制代码
┌──────────┐    更新DB    ┌──────────┐
│ 业务应用  │ ──────────► │ MySQL主库 │
└──────────┘              └──────────┘
                                │
                           binlog推送
                                ▼
                          ┌──────────┐
                          │  Canal   │
                          └──────────┘
                                │
                           解析事件
                                ▼
                          ┌──────────┐
                          │  MQ/消费者│
                          └──────────┘
                                │
                            删除缓存
                                ▼
                          ┌──────────┐
                          │  Redis   │
                          └──────────┘

5.3 代码实现(Canal + MQ)

📁 消费端代码

java 复制代码
package com.example.demo.consumer;

import com.alibaba.fastjson.JSON;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;

@Component
@RabbitListener(queues = "cache.delete.queue")
public class CacheDeleteConsumer {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @RabbitHandler
    public void handleBinlogEvent(String messageJson) {
        // 解析binlog事件
        BinlogEvent event = JSON.parseObject(messageJson, BinlogEvent.class);
        
        // 只处理product表的变更
        if (!"product".equals(event.getTableName())) {
            return;
        }

        // 删除对应的缓存
        String cacheKey = "product:" + event.getData().get("id");
        redisTemplate.delete(cacheKey);
        log.info("binlog触发删除缓存: {}", cacheKey);
    }
}

@Data
class BinlogEvent {
    private String tableName;
    private String operation; // INSERT/UPDATE/DELETE
    private Map<String, Object> data;
}

这个方案的最大优势是:彻底解耦,缓存删除不依赖业务代码,只要binlog被消费,缓存一定会被删除。


六、方案三:双写 + 重试补偿

有些场景下,我们需要更新缓存 而不是删除缓存(比如缓存的是聚合数据,重建成本高)。这时候可以采用双写 + 重试方案。

6.1 原理

text 复制代码
1. 更新数据库
2. 更新缓存
3. 如果缓存更新失败 → 记录失败日志 → 定时任务重试

6.2 完整代码实现

📁 文件结构

text 复制代码
src/
├── main/
│   ├── java/com/example/demo/
│   │   ├── entity/
│   │   │   └── CacheRetry.java          # 重试记录表实体
│   │   ├── mapper/
│   │   │   └── CacheRetryMapper.java
│   │   ├── service/
│   │   │   ├── ProductService.java      # 业务服务
│   │   │   └── CacheRetryService.java   # 重试补偿服务
│   │   └── consumer/
│   │       └── CacheDeleteConsumer.java # 消费端
│   └── resources/
│       └── db/
│           └── schema.sql               # 建表语句

① 重试记录表(schema.sql)

sql 复制代码
CREATE TABLE cache_retry (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    cache_key VARCHAR(255) NOT NULL COMMENT '缓存Key',
    cache_value TEXT NOT NULL COMMENT '缓存值(JSON)',
    operation VARCHAR(20) NOT NULL COMMENT '操作类型:SET/DELETE',
    status TINYINT DEFAULT 0 COMMENT '0-待重试 1-成功 2-失败',
    retry_count INT DEFAULT 0 COMMENT '已重试次数',
    max_retry INT DEFAULT 5 COMMENT '最大重试次数',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    KEY idx_status_retry (status, retry_count)
);

② ProductService.java(双写逻辑)

java 复制代码
package com.example.demo.service;

import com.example.demo.entity.CacheRetry;
import com.example.demo.entity.Product;
import com.example.demo.mapper.CacheRetryMapper;
import com.example.demo.mapper.ProductMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;

@Service
public class ProductService {

    @Resource
    private ProductMapper productMapper;
    @Resource
    private CacheRetryMapper cacheRetryMapper;
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    private static final String CACHE_KEY_PREFIX = "product:";

    /**
     * 更新商品 - 双写 + 失败记录
     */
    @Transactional
    public void updateProduct(Product product) {
        Long id = product.getId();
        String cacheKey = CACHE_KEY_PREFIX + id;

        // 1. 更新数据库
        productMapper.updateById(product);

        // 2. 更新缓存
        try {
            redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
        } catch (Exception e) {
            // 3. 缓存更新失败 → 记录重试表
            CacheRetry retry = new CacheRetry();
            retry.setCacheKey(cacheKey);
            retry.setCacheValue(JSON.toJSONString(product));
            retry.setOperation("SET");
            retry.setStatus(0); // 待重试
            retry.setRetryCount(0);
            cacheRetryMapper.insert(retry);
            log.error("缓存更新失败,已记录重试: {}", cacheKey, e);
        }
    }
}

③ CacheRetryService.java(定时补偿)

java 复制代码
package com.example.demo.service;

import com.example.demo.entity.CacheRetry;
import com.example.demo.mapper.CacheRetryMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;

@Component
public class CacheRetryService {

    @Resource
    private CacheRetryMapper cacheRetryMapper;
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    // 每分钟扫描一次,重试失败的缓存操作
    @Scheduled(fixedDelay = 60000)
    public void retryFailedCacheOperations() {
        LambdaQueryWrapper<CacheRetry> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(CacheRetry::getStatus, 0)
                .lt(CacheRetry::getRetryCount, CacheRetry::getMaxRetry)
                .orderByAsc(CacheRetry::getCreateTime)
                .last("limit 100");

        List<CacheRetry> retryList = cacheRetryMapper.selectList(wrapper);

        for (CacheRetry retry : retryList) {
            try {
                if ("SET".equals(retry.getOperation())) {
                    // 重建缓存
                    Object value = JSON.parseObject(retry.getCacheValue(), Object.class);
                    redisTemplate.opsForValue().set(retry.getCacheKey(), value, 30, TimeUnit.MINUTES);
                } else if ("DELETE".equals(retry.getOperation())) {
                    redisTemplate.delete(retry.getCacheKey());
                }
                // 标记成功
                retry.setStatus(1);
                cacheRetryMapper.updateById(retry);
            } catch (Exception e) {
                // 重试失败,增加重试次数
                retry.setRetryCount(retry.getRetryCount() + 1);
                cacheRetryMapper.updateById(retry);
                log.error("缓存重试失败: {}", retry.getCacheKey(), e);
            }
        }
    }
}

七、方案对比与选型建议

方案 一致性级别 实现复杂度 对业务侵入 适用场景
延迟双删 最终一致 大多数业务,对实时性要求不高
订阅Binlog 最终一致 极低 要求高可靠、低侵入的大型项目
双写+重试 最终一致 缓存重建成本高、需要更新缓存的场景
读写穿透(Read/Write Through) 强一致 对一致性要求高的场景
只设过期时间 最终一致 极低 极低 对一致性要求极低的场景

选型思路

text 复制代码
是否需要强一致性?
    ├── 是 → 读写穿透(Write Through)或 放弃缓存
    │
    └── 否 → 最终一致性
            ├── 业务简单、快速上线 → 延迟双删
            ├── 追求可靠性、低侵入 → 订阅Binlog
            └── 缓存重建成本高 → 双写+重试

八、最佳实践与避坑指南

8.1 核心原则

原则 说明
缓存要有过期时间 兜底方案,即使一致性出问题,数据最终也会自动修复
先更新DB,再操作缓存 避免缓存成功但DB失败导致的数据不一致
缓存操作要设置超时 防止缓存操作阻塞主流程
记录失败日志 便于排查和补偿

8.2 常见踩坑点

后果 解决方案
先更新缓存再更新DB 缓存成功但DB失败,数据永久不一致 永远先更新DB
更新缓存而不是删除缓存 并发写导致旧数据覆盖新数据 优先用删除,而不是更新
缓存操作失败不回滚 DB成功但缓存失败,数据不一致 记录失败日志,异步补偿
延迟双删同步sleep 接口响应变慢,线程阻塞 必须异步执行第二次删除
缓存雪崩 大量缓存同时过期,DB被打爆 设置不同的过期时间(加随机值)
缓存穿透 查询不存在的数据,直接打到DB 布隆过滤器 或 缓存空对象

8.3 监控告警建议

  • 监控缓存命中率:低于阈值说明缓存失效频繁
  • 监控缓存操作失败率:连续失败需告警
  • 监控重试表堆积量:大量待重试说明缓存服务有问题
  • 监控主从延迟:binlog消费延迟过高会影响一致性

九、总结

缓存一致性是引入缓存后必须面对的问题。没有完美的方案,只有在一致性、性能、复杂度之间做权衡

回顾全文,几个核心要点:

要点 说明
先更新DB,后操作缓存 这是底线原则,不能打破
删缓存优于更新缓存 删除后由读请求重建,更安全
延迟双删是入门首选 实现简单,能解决大部分问题
订阅Binlog是终极方案 解耦、可靠,适合大型项目
缓存一定要设过期时间 兜底机制,保证最终一致性

💡 最后送大家一句话 :缓存一致性没有银弹,接受最终一致性,设计好补偿机制,比追求完美的强一致更务实。

希望这篇文章能帮你从容应对缓存一致性问题。如有问题,欢迎交流讨论!

相关推荐
520拼好饭被践踏1 小时前
JAVA+Agent学习day22
java·开发语言·后端·学习
whi1 小时前
V 编译器 v3 ownership 模式:编译与使用指南
后端·编译器
笨蛋不要掉眼泪1 小时前
Java虚拟机:堆的参数配置
java·开发语言·jvm
swipe1 小时前
05|(前端转后全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
前端·后端·全栈
霸道流氓气质1 小时前
SpringBoot中使用JasperReports 报表引擎 — 介绍、原理与使用实践
java·spring boot·后端
swipe1 小时前
04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
前端·后端·全栈
花生了什么事o1 小时前
DDD 分层架构:六层分层架构
java·架构·ddd
苏三说技术1 小时前
为什么越来越多人使用WebFlux?
后端
Y3815326621 小时前
3 种 SERP 数据处理方案对比:流式 / 批处理 / 缓存
java·spring·缓存