文章目录
- [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 不是锁,而是"并发请求合并模型"。