Redis 和 MySQL 数据不一致怎么办?缓存更新策略实战

在高并发系统中,Redis 经常被放在数据库前面,用来降低查询延迟和数据库压力。

一个典型的查询流程如下:

复制代码
客户端请求
    ↓
查询 Redis
    ↓
缓存命中:直接返回
缓存未命中:查询 MySQL
    ↓
将结果写入 Redis
    ↓
返回数据

这个流程看起来并不复杂,但只要业务数据会发生修改,就绕不开一个问题:

MySQL 中的数据已经更新,Redis 中保存的还是旧值,应该怎么办?

例如用户在后台修改昵称:

复制代码
数据库:新昵称
缓存:旧昵称

接口继续读取缓存后,页面上看到的仍然是旧数据。

类似问题还可能出现在:

复制代码
商品价格修改
订单状态变化
用户权限更新
套餐额度扣减
配置参数调整
会议任务状态更新

缓存一致性并不意味着 Redis 和 MySQL 在任何时刻都绝对相同,而是要根据业务要求,在性能、复杂度和一致性之间做取舍。


一、最常见的缓存模式:Cache Aside

大部分业务系统使用的是 Cache Aside,也叫旁路缓存模式。

读取流程:

复制代码
1. 先查询缓存
2. 缓存存在则直接返回
3. 缓存不存在则查询数据库
4. 将数据库结果写入缓存

伪代码如下:

复制代码
public User queryUser(Long userId) {
    String cacheKey = "user:" + userId;

    User cachedUser = redisTemplate
        .opsForValue()
        .get(cacheKey);

    if (cachedUser != null) {
        return cachedUser;
    }

    User user = userMapper.selectById(userId);

    if (user != null) {
        redisTemplate
            .opsForValue()
            .set(
                cacheKey,
                user,
                Duration.ofMinutes(30)
            );
    }

    return user;
}

真正需要讨论的是写入流程。

通常有几种选择:

复制代码
先更新数据库,再更新缓存
先更新缓存,再更新数据库
先删除缓存,再更新数据库
先更新数据库,再删除缓存

这几种方案看起来差别不大,但在并发环境中结果完全不同。


二、为什么不推荐"先更新缓存,再更新数据库"?

假设当前商品价格是 100 元。

业务执行:

复制代码
1. 将缓存改为 80 元
2. 将数据库改为 80 元

如果第一步成功,第二步失败,就会出现:

复制代码
Redis:80
MySQL:100

后续请求读到的是缓存中的 80 元,但数据库真实数据仍然是 100 元。

更麻烦的是,缓存可能持续存在较长时间。

即使系统发现数据库更新失败,也不一定能可靠地把缓存恢复到原值。

因此,通常不建议把"更新缓存"放在"更新数据库"前面。


三、为什么"先更新数据库,再更新缓存"也有问题?

另一种直觉方案是:

复制代码
1. 更新数据库
2. 更新缓存

单个请求执行时没有明显问题。

但并发写入时可能发生覆盖。

假设两个请求同时修改同一条数据:

复制代码
请求 A:把价格改为 80
请求 B:把价格改为 60

实际执行顺序可能是:

复制代码
请求 A 更新数据库为 80
请求 B 更新数据库为 60
请求 B 更新缓存为 60
请求 A 更新缓存为 80

最终结果:

复制代码
MySQL:60
Redis:80

数据库中的最新结果被旧请求写回了缓存。

此外,更新缓存还会增加不必要的操作。

某条数据更新后可能很久都不会再被读取,但系统仍然立即计算并写入了缓存。

所以,在 Cache Aside 模式中,更常见的做法不是更新缓存,而是删除缓存。


四、推荐方案:先更新数据库,再删除缓存

常见写入流程是:

复制代码
1. 更新数据库
2. 删除对应缓存

代码示例:

复制代码
@Transactional
public void updateUser(
    Long userId,
    UpdateUserRequest request
) {
    userMapper.updateUser(
        userId,
        request.getNickname()
    );

    redisTemplate.delete(
        "user:" + userId
    );
}

删除缓存后,下一次查询会重新读取数据库,并把最新结果写入缓存。

这种方案有几个优点:

复制代码
数据库仍然是主要数据源
不需要主动计算新缓存
修改后缓存自然失效
实现相对简单

但它仍然不是绝对一致的。


五、"更新数据库,再删除缓存"什么时候会失败?

假设:

复制代码
1. 数据库更新成功
2. Redis 删除失败

此时缓存中仍然保留旧值。

常见原因包括:

复制代码
Redis 网络超时
Redis 实例故障
应用进程在删除前退出
连接池耗尽
代码执行异常

因此,删除缓存不能被当成一个永远成功的操作。

至少要考虑:

复制代码
失败重试
缓存过期时间
删除失败监控
异步补偿

一个最基础的保护措施,是所有业务缓存都设置过期时间。

即使删除失败,旧数据也不会永久存在。

复制代码
redisTemplate
    .opsForValue()
    .set(
        cacheKey,
        value,
        Duration.ofMinutes(30)
    );

过期时间不能完全解决一致性问题,但可以限制旧数据的最长存活时间。


六、为什么一般不推荐"先删除缓存,再更新数据库"?

流程如下:

复制代码
1. 删除缓存
2. 更新数据库

在并发读取时,可能出现下面的情况:

复制代码
请求 A 删除缓存
请求 B 查询缓存,发现不存在
请求 B 查询数据库,读到旧值
请求 A 更新数据库
请求 B 将旧值写回缓存

最终:

复制代码
MySQL:新值
Redis:旧值

而且这个旧缓存可能一直存在到过期。

因此,相比"先删缓存再更新数据库",通常更推荐:

复制代码
先更新数据库
再删除缓存

七、什么是延迟双删?

为了处理部分并发场景,有些系统会使用延迟双删。

流程如下:

复制代码
1. 删除缓存
2. 更新数据库
3. 等待一段时间
4. 再次删除缓存

伪代码:

复制代码
public void updateProduct(
    Long productId,
    ProductUpdateRequest request
) {
    String key = "product:" + productId;

    redisTemplate.delete(key);

    productMapper.updateProduct(
        productId,
        request
    );

    delayedExecutor.execute(
        () -> redisTemplate.delete(key),
        500,
        TimeUnit.MILLISECONDS
    );
}

第二次删除的作用是清理并发读取过程中可能重新写入的旧缓存。

但延迟双删并不是通用答案。

它存在几个问题:

复制代码
延迟时间很难准确设置
线程休眠会占用资源
应用重启后延迟任务可能丢失
第二次删除仍然可能失败
代码流程更加复杂

如果确实要使用,建议通过延迟消息或任务队列实现,而不是在业务线程中直接 sleep


八、读写并发下的缓存回填问题

即使采用"更新数据库,再删除缓存",仍然存在一个概率较低的并发问题。

假设初始数据库值为 A。

执行顺序:

复制代码
请求 1 查询缓存,未命中
请求 1 开始查询数据库,读到 A
请求 2 将数据库更新为 B
请求 2 删除缓存
请求 1 将刚才读到的 A 写入缓存

最终:

复制代码
MySQL:B
Redis:A

这个问题出现需要满足一个条件:

复制代码
旧查询在数据库更新前开始,
但在缓存删除后才完成回填。

如果查询速度很快、写操作较少,这种情况概率通常较低。

但在高一致性业务中,仍然需要额外处理。


九、方案一:给缓存写入增加版本号

可以在数据库记录中增加版本字段:

复制代码
CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    price DECIMAL(10, 2),
    version BIGINT NOT NULL
);

更新时版本递增:

复制代码
UPDATE product
SET price = 80,
    version = version + 1
WHERE id = 10001;

缓存中同时保存:

复制代码
{
  "id": 10001,
  "price": 80,
  "version": 12
}

回填缓存前,对比当前版本。

只有新版本数据才能覆盖旧版本。

这种方案思路清晰,但实现成本较高,通常适合:

复制代码
状态变更频繁
数据不能回退
同一 Key 并发读写较多

的业务。


十、方案二:使用分布式锁控制缓存重建

缓存失效后,大量请求可能同时查询数据库。

可以在缓存重建时使用分布式锁:

复制代码
public Product queryProduct(Long productId) {
    String cacheKey =
        "product:" + productId;

    Product product =
        getFromCache(cacheKey);

    if (product != null) {
        return product;
    }

    String lockKey =
        "lock:product:" + productId;

    boolean locked =
        tryLock(lockKey, Duration.ofSeconds(5));

    if (!locked) {
        sleepShortTime();
        return queryProduct(productId);
    }

    try {
        product = getFromCache(cacheKey);

        if (product != null) {
            return product;
        }

        product =
            productMapper.selectById(productId);

        setCache(cacheKey, product);

        return product;
    } finally {
        unlock(lockKey);
    }
}

锁内需要再次检查缓存,因为等待锁期间,其他线程可能已经完成缓存重建。

这就是常见的双重检查。

分布式锁主要解决:

复制代码
缓存击穿
并发回填
数据库瞬时压力

但不要给所有缓存查询无差别加锁,否则 Redis 锁本身可能成为性能瓶颈。


十一、方案三:通过消息队列异步删除缓存

为了降低删除失败的影响,可以在数据库更新成功后发送消息。

复制代码
数据库更新成功
    ↓
发送缓存失效消息
    ↓
消费者删除 Redis Key
    ↓
失败后自动重试

消息内容可以非常简单:

复制代码
{
  "bizType": "PRODUCT",
  "bizId": 10001,
  "cacheKey": "product:10001"
}

消费者:

复制代码
public void consume(
    CacheInvalidationMessage message
) {
    redisTemplate.delete(
        message.getCacheKey()
    );
}

优势:

复制代码
删除失败可以重试
业务更新与缓存处理解耦
可以统一监控缓存失效任务

但也会引入新的问题:

复制代码
消息是否发送成功
消息是否重复消费
删除缓存是否具备幂等性
队列积压时旧缓存存在多久

删除操作天然具有较好的幂等性。

同一个 Key 删除多次,最终结果仍然是不存在。


十二、数据库事务和消息发送怎么保证一致?

下面这种写法存在风险:

复制代码
@Transactional
public void updateProduct(Product product) {
    productMapper.update(product);
    messageProducer.send(
        new CacheInvalidationMessage(...)
    );
}

可能出现:

复制代码
数据库事务回滚,但消息已经发出
数据库提交成功,但消息发送失败

更可靠的方式包括:

复制代码
事务消息
本地消息表
Outbox Pattern
订阅数据库 Binlog

十三、使用本地消息表

可以在同一个数据库事务中:

复制代码
更新业务数据
写入待发送消息

示例:

复制代码
@Transactional
public void updateProduct(
    ProductUpdateRequest request
) {
    productMapper.update(request);

    OutboxEvent event =
        new OutboxEvent();

    event.setEventType(
        "CACHE_INVALIDATE"
    );

    event.setPayload(
        JSON.toJSONString(
            Map.of(
                "cacheKey",
                "product:" + request.getId()
            )
        )
    );

    outboxEventMapper.insert(event);
}

后台任务持续扫描未发送消息:

复制代码
查询待发送事件
发送到消息队列
更新事件状态

因为业务数据和消息记录处于同一个数据库事务中,所以不会出现只成功一半的情况。

这种方案可靠性较高,但需要额外维护消息表、扫描任务和清理机制。


十四、订阅 Binlog 删除缓存

另一种思路是不让业务代码主动发送缓存失效消息。

系统订阅 MySQL Binlog:

复制代码
MySQL 数据发生变化
    ↓
Binlog 事件
    ↓
同步服务解析变更
    ↓
删除相关 Redis 缓存

这种方式可以减少业务代码侵入。

优点:

复制代码
数据库变更是最终依据
业务服务不需要显式处理缓存
多个写入入口可以统一覆盖

局限:

复制代码
需要维护 Binlog 消费系统
表结构变化会影响解析
数据库字段与缓存 Key 需要映射
缓存失效存在一定延迟

适合数据量较大、写入入口较多、缓存体系较复杂的系统。


十五、缓存过期时间应该怎么设置?

缓存时间不是越长越好,也不是越短越安全。

过期时间太长:

复制代码
旧数据保留时间更长
删除失败影响更明显

过期时间太短:

复制代码
缓存命中率下降
数据库查询压力增加
大量 Key 可能同时失效

通常要考虑:

复制代码
数据更新频率
业务可接受的旧数据时间
查询压力
缓存重建成本

例如:

数据类型 参考策略
国家地区列表 数小时或数天
用户基本资料 数十分钟
商品详情 数分钟到数十分钟
库存 短缓存或不直接缓存最终结果
权限信息 较短时间,并在修改后主动删除
系统配置 主动通知刷新,同时保留过期兜底

为了避免大量 Key 同时失效,可以增加随机时间:

复制代码
long baseSeconds = 1800;
long randomSeconds =
    ThreadLocalRandom.current()
        .nextLong(0, 300);

Duration ttl = Duration.ofSeconds(
    baseSeconds + randomSeconds
);

这样可以降低缓存雪崩风险。


十六、如何处理缓存空值?

查询一个不存在的用户时,如果不缓存空结果,每次请求都会访问数据库。

攻击者可以不断使用随机 ID 请求:

复制代码
user:100000001
user:100000002
user:100000003

这类问题称为缓存穿透。

一种简单方式是缓存空值:

复制代码
if (user == null) {
    redisTemplate
        .opsForValue()
        .set(
            cacheKey,
            NULL_VALUE,
            Duration.ofMinutes(2)
        );

    return null;
}

空值缓存时间通常应短于正常数据。

否则数据刚创建后,用户可能因为旧的空值缓存而暂时查询不到。


十七、实时业务中的缓存一致性

实时语音、在线会议和 AI 翻译系统同样会使用缓存。

常见缓存内容包括:

复制代码
用户套餐和剩余时长
当前会议状态
模型配置
语言设置
术语关键词
语音播报选项
短期会话信息

例如**同言翻译(Transync AI)**这类实时翻译产品,在会议过程中可能频繁读取用户设置和会话配置。

这类数据需要区分两种情况。

第一类:允许短时间不一致

例如:

复制代码
界面偏好
最近使用的声音
悬浮字幕窗口位置

短时间读取旧值通常不会造成严重后果。

第二类:要求较强一致性

例如:

复制代码
剩余可用时长
会员状态
企业席位状态
会议任务是否已结束

这类数据如果只依赖缓存,可能导致:

复制代码
额度重复扣减
过期用户继续使用
任务状态回退
多个设备同时修改产生覆盖

因此,缓存只能作为查询加速层。

涉及额度、支付和权益的最终判断,通常仍应以数据库事务或专门的计费服务为准。


十八、是否所有业务都需要强一致?

不是。

越强的一致性通常意味着:

复制代码
更高延迟
更复杂实现
更多外部依赖
更低系统可用性

可以按业务分类。

强一致场景

复制代码
支付
余额
库存最终扣减
会员权益
计费额度

这些数据不应该只依赖缓存结果。

最终一致场景

复制代码
用户资料
商品详情
配置项
会议状态展示
统计数据

短时间不一致通常可以接受。

可容忍旧数据场景

复制代码
热门排行
推荐结果
历史报表
非核心页面信息

这类数据甚至可以在数据库异常时直接返回旧缓存。

在设计缓存方案前,先明确业务到底能接受多长时间的不一致。


十九、缓存一致性需要监控什么?

建议监控:

复制代码
缓存命中率
缓存删除失败次数
缓存重建耗时
数据库回源请求量
热点 Key 请求量
消息队列积压数量
缓存失效消息重试次数
Redis 响应延迟
空值缓存数量

还可以对关键数据进行定期抽样比对:

复制代码
随机读取一批数据库记录
读取对应缓存
对比关键字段是否一致

发现不一致后记录:

复制代码
{
  "cacheKey": "product:10001",
  "dbVersion": 12,
  "cacheVersion": 10,
  "detectedAt": "2026-06-16T10:30:00"
}

没有监控时,缓存问题通常只能等用户反馈后才被发现。


二十、缓存一致性检查清单

开发缓存功能时,可以逐项检查:

复制代码
1. 数据库是否是最终数据源?
2. 写入后是更新缓存还是删除缓存?
3. 是否采用先更新数据库、再删除缓存?
4. 缓存删除失败后如何重试?
5. 所有缓存是否设置过期时间?
6. 是否避免大量 Key 同时过期?
7. 缓存回填是否可能覆盖新数据?
8. 热点 Key 是否需要分布式锁?
9. 是否缓存不存在的数据?
10. 消息消费是否具备幂等性?
11. 数据库事务与消息发送如何保持一致?
12. 是否需要本地消息表或 Binlog 订阅?
13. 哪些业务要求强一致?
14. 哪些业务允许最终一致?
15. 是否监控删除失败和回源流量?

总结

Redis 和 MySQL 的缓存一致性,没有一种适用于所有业务的标准答案。

在大多数旁路缓存场景中,可以优先采用:

复制代码
读取:
先查缓存
未命中再查数据库并回填

写入:
先更新数据库
再删除缓存

在此基础上,再根据业务风险增加:

复制代码
合理的缓存过期时间
删除失败重试
消息队列异步补偿
分布式锁控制缓存重建
版本号防止旧值覆盖
本地消息表或 Binlog 订阅

真正重要的不是追求任何时刻都绝对一致,而是先明确:

复制代码
业务允许旧数据存在多久?
数据错误会造成什么后果?
系统愿意为一致性付出多少成本?

只有回答这三个问题,才能选择合适的缓存更新策略。

相关推荐
the sun342 小时前
数据库中间件 ShardingSphere的安装与连通性配置
mysql
翼龙云_cloud2 小时前
阿里云国际代理商:如何使用RDS MySQL 构建网站数据库?
数据库·mysql·阿里云
程序猿乐锅2 小时前
【 苍穹外卖day03 | 菜品管理 】
java·开发语言·数据库·mysql
闪电悠米2 小时前
黑马点评-Redis ZSet-实现关注 Feed 流
服务器·网络·数据库·redis·缓存·junit·lua
云梦泽࿐้3 小时前
字符串操作全攻略:格式化、切片、正则
数据库·mysql
Devin~Y3 小时前
大厂 Java 面试实录:从音视频内容社区到 AI RAG 的全链路技术设计
java·spring boot·redis·spring cloud·微服务·kafka·音视频
承渊政道3 小时前
【MySQL数据库学习】(MySQL表的内外连接)
数据库·学习·mysql·leetcode·bash·数据库开发·数据库系统
小小工匠3 小时前
Redis - 主从集群脑裂:数据丢失的隐藏杀手
数据库·redis
摇滚侠13 小时前
mariadb-libs 被 mysql-community-libs-5.7.28-1.el7.x86_64 取代
数据库·mysql·mariadb