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 订阅

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值