缓存更新并发问题——高并发写入时如何保证数据一致性

缓存更新并发问题——高并发写入时如何保证数据一致性

问题的本质

缓存更新并发问题指的是在高并发场景下,多个线程/进程同时执行缓存和数据库的更新操作,导致数据不一致、脏数据、丢失更新等问题。

核心矛盾:缓存是高性能的临时存储,数据库是可靠持久化存储。两者之间的数据同步在并发条件下必然面临一致性挑战。

典型并发问题场景

场景一:先更新数据库,再删除缓存

时间 | 线程 A                 | 线程 B                 | 数据库 | 缓存
T1   | 写数据库 v1 →          |                        | v1    | (旧)
T2   |                        | 读缓存(未命中)       | v1    | (旧)
T3   |                        | 读数据库 v0(旧值)    | **v1** | (旧)
T4   | 删除缓存 ←            |                        | v1    | 无
T5   |                        | 写缓存 v0(旧值)      | v1    | **v0 ← 脏数据**

结果:缓存保留了旧值 v0,数据库是新的 v1,不一致!

场景二:先删除缓存,再更新数据库

时间 | 线程 A                 | 线程 B                 | 数据库 | 缓存
T1   | 删除缓存 ←            |                        | v0    | 无
T2   |                        | 读数据库 v0(未命中)  | v0    | 无
T3   |                        | 写缓存 v0              | v0    | v0
T4   | 写数据库 v1 →          |                        | **v1** | **v0 ← 脏数据**

结果:同样的脏数据问题。

经典解决方案

方案一:延迟双删(推荐)

先删缓存,再写数据库,再延迟删缓存:

public void updateUser(User user) {
    redis.delete("user:" + user.getId());   // 第一次删除
    userDao.update(user);                   // 更新数据库
    // 延迟 500ms 再删一次
    scheduledExecutor.schedule(() -> {
        redis.delete("user:" + user.getId());   // 第二次删除
    }, 500, TimeUnit.MILLISECONDS);
}

优点:实现简单,业界广泛使用
缺点:第二次删除是异步的,短暂不一致;延迟时间不好确定

方案二:应用层队列 + 同步写入

将对同一个 key 的写操作串行化:

public CompletableFuture<Void> updateUser(User user) {
    CompletableFuture<Void> future = new CompletableFuture<>();
    queue.enqueue("user:" + user.getId(), () -> {
        try {
            userDao.update(user);
            redis.delete("user:" + user.getId());
            future.complete(null);
        } catch (Exception e) {
            future.completeExceptionally(e);
        }
    });
    return future;
}

优点:严格串行,不会出现并发问题
缺点:牺牲吞吐量,队列本身可能成为瓶颈

方案三:乐观锁(版本号)

每次写缓存时带上版本号,拒绝旧的版本:

// 数据模型带版本号
class User {
    Long id;
    String name;
    Long version;  // 每次更新递增
}

public void updateUser(User user) {
    // 先读当前版本
    Long currentVersion = getCurrentVersion(user.getId());
    if (user.getVersion() < currentVersion) {
        throw new StaleDataException("数据已过期,请刷新");
    }

    user.setVersion(currentVersion + 1);
    userDao.update(user);
    // 删除缓存,下次读取自动加载最新
    redis.delete("user:" + user.getId());
}

优点:能检测到并发冲突
缺点:增加版本管理复杂度

方案四:Binlog 监听(最终一致性)

不通过业务代码更新缓存,而是通过监听数据库 binlog 来同步:

业务代码 → 更新数据库
Canal 监听 MySQL binlog → 推送到 MQ
消费者 → 根据 binlog 解析变更 → 更新/删除缓存

优点:业务代码无需关心缓存,逻辑解耦
缺点:引入 Canal、Kafka 等组件,架构复杂

业界常用组合

业务场景 推荐方案
中小项目、一致性要求不高 先删缓存 → 写 DB → 延迟双删
高并发、短时间不一致可接受 先更新 DB → 再删缓存 + 异步兜底
严格一致、允许牺牲性能 应用层队列串行化
大规模、数据流转复杂 Canal binlog 监听 + MQ 异步同步
面试最佳答案 “延迟双删 + 设置合理过期时间兜底”

不能忽视的兜底策略

无论采用哪种方案,一个带过期时间的缓存是最后的安全网:

// 即使出现了脏数据,TTL 到期后也会自动消失
redis.opsForValue().set(key, value, 5, TimeUnit.MINUTES);

面试要点

  • 能清楚说出”先删缓存再写 DB”和”先写 DB 再删缓存”各有缺陷
  • 延迟双删是最常见的实战方案
  • 能分析出”缓存更新本质上做不到强一致性,只能追求最终一致性”
  • 知道 binlog 监听方案适配了大厂场景
  • 加分点:提及 “Cache-Aside + 延迟双删 + 兜底 TTL” 的组合策略
© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容