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

前言

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

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

这个问题你怎么回答?

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

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

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

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


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

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是终极方案 解耦、可靠,适合大型项目
缓存一定要设过期时间 兜底机制,保证最终一致性

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

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

相关推荐
子兮曰3 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
小羊没烦恼!3 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
子兮曰3 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
俊昭喜喜里3 天前
java中的继承和多态的区别
java
小羊没烦恼!3 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
爱勇宝3 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
譕痕3 天前
JSONObject与JSONArray封装数据格式区别
java·json
胡写代码3 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖3 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
此时不提桶,更待何时3 天前
01-06-A-JVM排查实战详解
java·jvm