实际例子理解Redis 缓存与 MySQL 数据一致性 以及常见的细节

文章目录

  • [Redis 缓存与 MySQL 数据一致性:从 Cache Aside 到 SingleFlight 的工程级实践](#Redis 缓存与 MySQL 数据一致性:从 Cache Aside 到 SingleFlight 的工程级实践)
    • [一、背景:缓存 + 数据库的读写流程](#一、背景:缓存 + 数据库的读写流程)
      • [1、 读流程(Read Path)](#1、 读流程(Read Path))
      • [2、写流程(Write Path)](#2、写流程(Write Path))
    • [二、为什么写路径只能是「更新 DB → 删除缓存」](#二、为什么写路径只能是「更新 DB → 删除缓存」)
      • [推荐写路径(Cache Aside)](#推荐写路径(Cache Aside))
      • [为什么不能「更新 DB → 更新缓存」](#为什么不能「更新 DB → 更新缓存」)
      • [为什么不能「先删缓存 → 再更新 DB」](#为什么不能「先删缓存 → 再更新 DB」)
      • [为什么「更新 DB → 删除缓存」是安全的](#为什么「更新 DB → 删除缓存」是安全的)
    • 三、删缓存后的并发问题:缓存击穿
    • 四、SingleFlight(单飞)解决方案
    • [五、SingleFlight 执行流程](#五、SingleFlight 执行流程)
    • [六、完整业务级示例(Spring Boot)](#六、完整业务级示例(Spring Boot))
    • [七、SingleFlight 执行模型深度解析](#七、SingleFlight 执行模型深度解析)
    • 八、总结

Redis 缓存与 MySQL 数据一致性:从 Cache Aside 到 SingleFlight 的工程级实践

在高并发系统中,Redis + MySQL 的缓存一致性问题,是后端工程中绕不开的核心问题。

本文将从真实业务出发,系统性地讲清楚:

  • 读流程、写流程应该如何设计
  • 为什么写操作只能是:更新 DB → 删除缓存
  • 删除缓存后并发请求如何把数据库打爆
  • 如何用 SingleFlight(单飞)合并请求、保护数据库
  • 给出完整可运行的业务级代码
  • 深入理解 SingleFlight 的执行模型与底层原理

一、背景:缓存 + 数据库的读写流程

1、 读流程(Read Path)

典型的读流程如下:

text 复制代码
请求 → 查 Redis
      ├─ 命中:直接返回
      └─ miss:查 MySQL → 回填 Redis → 返回

缓存的职责只有一个:

加速读,而不是保证一致性。


2、写流程(Write Path)

当数据同时存在于:

  • MySQL(最终数据源)
  • Redis(缓存副本)

写操作必须同时考虑二者的一致性问题。


二、为什么写路径只能是「更新 DB → 删除缓存」

推荐写路径(Cache Aside)

text 复制代码
写操作:
更新 DB → 删除 Redis

为什么不能「更新 DB → 更新缓存」

并发场景下会发生旧值回写缓存问题。

text 复制代码
T1(写):更新 DB → v2
T2(读):Redis miss → 查 DB(旧快照)→ v1
T1(写):更新 Redis → v2
T2(读):把 v1 写回 Redis

最终结果

DB    = v2
Redis = v1   ❌

为什么不能「先删缓存 → 再更新 DB」

在并发读写下,仍然可能产生脏数据。

text 复制代码
T1(写):删除 Redis
T2(读):Redis miss → 查 DB(v1)→ 回填 Redis
T1(写):更新 DB → v2

最终仍然不一致。


为什么「更新 DB → 删除缓存」是安全的

缓存中的数据一定来自最新的数据库状态。

text 复制代码
T1(写):更新 DB → v2
T1(写):删除 Redis
T2(读):Redis miss → 查 DB → v2 → 回填 Redis

缓存中的数据一定来自最新的数据库状态。


三、删缓存后的并发问题:缓存击穿

更新 DB → 删除缓存 删除缓存后

并发请求过来 没有缓存 会导致数据库被瞬间打爆。

text 复制代码
场景设定(真实线上场景)

key:user:1001

Redis 中刚被删除

MySQL 是最终数据源

1000 个并发请求同时进来  
  
❌ 没有保护的情况
1000 请求 → Redis miss → 1000 次查 MySQL

这就是 缓存击穿(Cache Breakdown)。


四、SingleFlight(单飞)解决方案

SingleFlight 的目标是:同一个 key 只允许一个请求访问数据库。


五、SingleFlight 执行流程

text 复制代码
并发 1000 请求 → Redis miss

第 1 个请求:
- 创建 Future
- 提交 DB 查询到线程池
- 返回 Future(未完成)

第 2 ~ 1000 个请求:
- 拿到同一个 Future
- join / get 等待结果

DB 查询完成:
- future.complete(result)
- 1000 个请求同时返回
- 回填 Redis

最终效果

DB 查询次数 = 1

缓存回填次数 = 1


六、完整业务级示例(Spring Boot)

Controller

java 复制代码
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

		//读请求
    @GetMapping("/{id}")
    public User get(@PathVariable Long id) {
        return userService.getUserWithSingleFlight(id);
    }
		//写请求
    @PutMapping("/{id}")
    public void update(@PathVariable Long id, @RequestParam String name) {
        userService.updateUser(id, name);
    }
}

Service(SingleFlight 核心)

java 复制代码
@Service
public class UserService {

    private final UserRepository userRepository;
    private final StringRedisTemplate redisTemplate;

    // 单飞:key -> "正在执行中的请求 Future"
    private final ConcurrentHashMap<String, CompletableFuture<User>> flights = new ConcurrentHashMap<>();

    // 用业务线程池跑 DB 查询(不要用 commonPool,避免把公共线程池拖死)
    private final ExecutorService dbExecutor = Executors.newFixedThreadPool(16);

    // 缓存 TTL(示例:60s)
    private static final Duration CACHE_TTL = Duration.ofSeconds(60);
    // 空值短缓存 TTL(防缓存穿透)
    private static final Duration NULL_CACHE_TTL = Duration.ofSeconds(10);

    public UserService(UserRepository userRepository, StringRedisTemplate redisTemplate) {
        this.userRepository = userRepository;
        this.redisTemplate = redisTemplate;
    }

    public User getUserByIdWithCacheSingleFlight(Long userId) {
        String key = buildKey(userId);

        // 1) 先查 Redis
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            // 空值短缓存标记
            if ("__NULL__".equals(cached)) {
                return null;
            }
            return User.fromCacheValue(cached);
        }

        // 2) 缓存 miss:单飞合并并发请求
        CompletableFuture<User> future = flights.computeIfAbsent(key, k -> {
            // 只有第一个到达的线程会创建并触发这个 future
            CompletableFuture<User> f = new CompletableFuture<>();

            dbExecutor.execute(() -> {
                try {
                    // 2.1) 二次检查缓存(可选:降低"刚回填又查DB"的概率)
                    String cached2 = redisTemplate.opsForValue().get(k);
                    if (cached2 != null) {
                        User u = "__NULL__".equals(cached2) ? null : User.fromCacheValue(cached2);
                        f.complete(u);
                        return;
                    }

                    // 2.2) 真正回源 DB(只有这一个线程会走到这里)
                    Optional<User> dbUserOpt = userRepository.findById(userId);
                    User dbUser = dbUserOpt.orElse(null);

                    // 2.3) 回填缓存(含空值短缓存,防穿透)
                    if (dbUser == null) {
                        redisTemplate.opsForValue().set(k, "__NULL__", NULL_CACHE_TTL);
                    } else {
                        redisTemplate.opsForValue().set(k, dbUser.toCacheValue(), CACHE_TTL);
                    }

                    // 2.4) 完成 future:唤醒所有等待者
                    f.complete(dbUser);

                } catch (Exception e) {
                    // 2.5) 异常:让所有等待者一起感知异常
                    f.completeExceptionally(e);
                } finally {
                    // 2.6) 清理 flights,防内存泄漏
                    flights.remove(k);
                }
            });

            return f;
        });

        // 3) 其他并发线程:拿到同一个 future,在这里等待结果(不会查 DB)
        try {
            // 设置等待超时,避免极端情况下无限挂起
            return future.get(800, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            // 超时了:可降级(例如返回 null/兜底值/提示稍后重试)
            throw new RuntimeException("Read timeout, please retry");
        } catch (ExecutionException e) {
            throw new RuntimeException("DB query failed: " + e.getCause().getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted");
        }
    }

    /**
     * 写:更新 DB + 删除缓存(Cache Aside)
     */
    public void updateUserName(Long userId, String newName) {
        userRepository.updateName(userId, newName);

        // 删除缓存(写后删缓存)
        redisTemplate.delete(buildKey(userId));

        // 你也可以在这里加:延迟双删 / 发 MQ 异步重建
    }

    private String buildKey(Long userId) {
        return "user:" + userId;
    }
}

七、SingleFlight 执行模型深度解析

1、 为什么用 ConcurrentHashMap

java 复制代码
ConcurrentHashMap<String, CompletableFuture<T>>
  • key:业务 key(如 user:1001)
  • value:"这个 key 正在进行中的查询"

保证:

  • 并发安全

  • 同一个 key 只会创建一个 Future


2、 为什么用 computeIfAbsent

computeIfAbsent 是 原子操作

  • 同一时刻

  • 同一个 key

  • 最多只有一个线程能创建 value

避免并发下创建多个 Future。


3、 为什么用 CompletableFuture

因为它能同时做到:

表示"结果尚未完成"

支持多个线程等待

一次 complete,唤醒所有等待者 这是"请求合并"的关键


4、为什么 DB 查询要放线程池

避免阻塞业务线程 直接返回 future 然后第二个请求就可以直接使用到这个future了


5、 单飞 ≠ 加锁(非常重要)

对比 SingleFlight
是否互斥
是否合并请求
DB 查询次数 N 1
本质 排队 共享结果

八、总结

缓存不是一致性的源头,数据库才是。

SingleFlight 不是锁,而是"并发请求合并模型"。

相关推荐
Dovis(誓平步青云)2 小时前
《MySQL表的创建与约束:定义结构化数据的存储载体》
数据库·mysql
indexsunny2 小时前
互联网大厂Java面试实战:Spring Boot与微服务在电商场景中的应用
java·spring boot·redis·微服务·kafka·spring security·电商
Maggie_ssss_supp2 小时前
linux-ProxyQSL读写分离
数据库·mysql
QZ_orz_freedom2 小时前
后端学习笔记-Redis
redis·笔记·学习
柏木乃一2 小时前
ext2文件系统(2)inode,datablock映射,路径解析与缓存,分区挂载,软硬连接
linux·服务器·c++·缓存·操作系统
源代码•宸2 小时前
Leetcode—146. LRU 缓存【中等】(哈希表+双向链表)
后端·算法·leetcode·缓存·面试·golang·lru
2501_944521592 小时前
Flutter for OpenHarmony 微动漫App实战:骨架屏加载实现
android·开发语言·javascript·数据库·redis·flutter·缓存
予枫的编程笔记2 小时前
【Java进阶】深度解析Canal:从原理到实战,MySQL增量数据同步的利器
java·开发语言·mysql
时艰.2 小时前
Redis 核心知识点归纳与详解
数据库·redis·缓存