Redis 与数据库双写一致性(详细实战版)
目标:把 "为什么不一致" → "有哪些方案" → "各自怎么实现" → "选型建议" 一次讲清。
适用场景:MySQL + Redis (但思想对所有缓存都通用)。
结论先行:100% 强一致在高并发下基本不可取,工程上追求"最终一致 + 可控窗口 + 可兜底"。
目录
- [1. 什么是双写一致性问题](#1. 什么是双写一致性问题)
- [2. 不一致到底是怎么产生的](#2. 不一致到底是怎么产生的)
- [3. 四种主流解决思路总览](#3. 四种主流解决思路总览)
- [4. 方案一:Cache Aside(旁路缓存,最主流)](#4. 方案一:Cache Aside(旁路缓存,最主流))
- [5. 方案二:延迟双删(工程补强版)](#5. 方案二:延迟双删(工程补强版))
- [6. 方案三:基于 Binlog / MQ 的最终一致](#6. 方案三:基于 Binlog / MQ 的最终一致)
- [7. 方案四:强一致方案(不推荐但你要知道)](#7. 方案四:强一致方案(不推荐但你要知道))
- [8. 写多读多场景的组合拳](#8. 写多读多场景的组合拳)
- [9. 常见错误方案(踩坑清单)](#9. 常见错误方案(踩坑清单))
- [10. 选型决策表](#10. 选型决策表)
- [11. 面试 / 设计题标准回答模板](#11. 面试 / 设计题标准回答模板)
1. 什么是双写一致性问题
双写一致性 = 数据同时存在于:
- 数据库(MySQL,强一致、持久化)
- 缓存(Redis,高性能、非强一致)
问题核心:
一次更新,需要写 DB + 写 Cache,但这两个操作不在同一个原子事务里。
2. 不一致到底是怎么产生的
假设一次"更新用户信息":
2.1 经典错误顺序:先写缓存,再写数据库 ❌
写缓存成功
↓
写数据库失败
结果:
- 缓存是新数据
- DB 是旧数据
👉 缓存脏读
2.2 看似正确但仍有坑:先写 DB,再删缓存 ⚠️
并发场景:
线程 A:更新数据
线程 B:读取数据
时间线:
A:UPDATE DB (成功)
B:GET cache(miss)
B:SELECT DB(读到旧数据)
B:SET cache(旧数据)
A:DEL cache
结果:
- 缓存里又被写回了旧数据
👉 经典并发不一致
3. 四种主流解决思路总览
| 思路 | 一致性 | 复杂度 | 是否主流 |
|---|---|---|---|
| Cache Aside | 最终一致 | 低 | ⭐⭐⭐⭐⭐ |
| 延迟双删 | 最终一致(更稳) | 中 | ⭐⭐⭐⭐ |
| Binlog/MQ 同步 | 最终一致 | 高 | ⭐⭐⭐⭐ |
| 强一致(分布式锁/事务) | 强 | 极高 | ⭐ |
4. 方案一:Cache Aside(旁路缓存,最主流)
4.1 核心思想
- 读:先读缓存,miss 再读 DB,写回缓存
- 写 :只写 DB,然后 删除缓存
口诀:"写 DB,删缓存;读缓存,miss 查 DB"
5. 方案二:延迟双删(工程补强版)
text
1. DEL cache
2. UPDATE DB
3. sleep(200~1000ms)
4. DEL cache
6. 方案三:基于 Binlog / MQ 的最终一致
- DB 是唯一真相源
- 缓存由 binlog 驱动更新
- 常见:Canal / Debezium + MQ
7. 方案四:强一致方案(不推荐)
- 分布式锁
- 分布式事务
- 吞吐量和复杂度都不可接受
Spring Boot 的"代码级落地":Cache Aside + 延迟双删 + 防击穿互斥重建 +(可选)binlog/MQ 兜底思路。
我用 MySQL + MyBatis-Plus + Redis(StringRedisTemplate) + Redisson 举例(你也可以只用 StringRedisTemplate,Redisson主要用来做分布式锁更省事)
1)依赖(Maven)
xml
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis Plus(你项目大概率有) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.7</version>
</dependency>
<!-- Redisson(做分布式锁 / singleflight) -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.30.0</version>
</dependency>
<!-- JSON(任选一个,你项目可能用 Jackson) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
2)Key 设计 & TTL 约定
用户缓存 key:user:profile:{id}
空值占位 key(防穿透):同一个 key 存 "NULL",TTL 更短(比如 30s)
正常 TTL:比如 10 分钟 + 随机抖动(避免雪崩)
3)Redis 操作封装(含空值占位)
java
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
@Component
@RequiredArgsConstructor
public class CacheClient {
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
public static final String NULL_VAL = "__NULL__";
public <T> T get(String key, Class<T> clazz) {
String v = redis.opsForValue().get(key);
if (v == null) return null;
if (NULL_VAL.equals(v)) return null; // 空值占位
try {
return objectMapper.readValue(v, clazz);
} catch (Exception e) {
throw new RuntimeException("Cache deserialize error, key=" + key, e);
}
}
public void setJson(String key, Object value, Duration ttl) {
try {
String json = objectMapper.writeValueAsString(value);
redis.opsForValue().set(key, json, ttl);
} catch (Exception e) {
throw new RuntimeException("Cache serialize error, key=" + key, e);
}
}
public void setNull(String key, Duration ttl) {
redis.opsForValue().set(key, NULL_VAL, ttl);
}
public void del(String key) {
redis.delete(key);
}
public Duration ttlWithJitterSeconds(long baseSeconds, long jitterSeconds) {
long add = ThreadLocalRandom.current().nextLong(0, Math.max(1, jitterSeconds + 1));
return Duration.ofSeconds(baseSeconds + add);
}
}
4)读:Cache Aside + 防击穿互斥重建(single flight)
思路:
先查缓存
miss 就抢锁(只有一个线程回源 DB + 回填缓存)
没抢到锁的线程稍等再读缓存(避免打 DB)
java
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final CacheClient cache;
private final RedissonClient redisson;
private final UserMapper userMapper; // MyBatis-Plus mapper
private String cacheKey(Long userId) {
return "user:profile:" + userId;
}
public UserDO getUser(Long userId) {
String key = cacheKey(userId);
// 1) 先读缓存
UserDO cached = cache.get(key, UserDO.class);
if (cached != null) return cached;
// 2) 防击穿:加互斥锁(同一个 key 一个锁)
String lockKey = "lock:" + key;
RLock lock = redisson.getLock(lockKey);
boolean locked = false;
try {
// waitTime=50ms:等一下别人释放锁
// leaseTime=2s:防止死锁(业务要保证回源不超过这个时间)
locked = lock.tryLock(50, 2_000, java.util.concurrent.TimeUnit.MILLISECONDS);
if (!locked) {
// 3) 没抢到锁:短暂 sleep,然后再读缓存(大多数情况下别人已经回填)
Thread.sleep(30);
return cache.get(key, UserDO.class); // 可能仍然 null,业务自行处理
}
// 4) 双重检查:拿到锁后再读一次缓存(避免重复回源)
UserDO again = cache.get(key, UserDO.class);
if (again != null) return again;
// 5) 回源 DB
UserDO db = userMapper.selectById(userId);
// 6) 回填缓存(空值也要缓存,防穿透)
if (db == null) {
cache.setNull(key, Duration.ofSeconds(30));
return null;
}
Duration ttl = cache.ttlWithJitterSeconds(600, 60); // 10min + 0~60s
cache.setJson(key, db, ttl);
return db;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (locked) {
lock.unlock();
}
}
}
}
5)写:更新 DB + 删除缓存(标准 Cache Aside)
最关键:先写 DB(事务提交后)再删缓存
如果你在事务里就删缓存,事务回滚会更乱。
5.1 推荐做法:事务提交后删缓存(最稳)
用 Spring 的 TransactionSynchronization 注册 afterCommit 回调:
java
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Service
@RequiredArgsConstructor
public class UserCommandService {
private final CacheClient cache;
private final UserMapper userMapper;
private String cacheKey(Long userId) {
return "user:profile:" + userId;
}
@Transactional
public void updateUserName(Long userId, String newName) {
// 1) 更新 DB
UserDO u = new UserDO();
u.setId(userId);
u.setName(newName);
userMapper.updateById(u);
// 2) 事务提交后删缓存(afterCommit)
String key = cacheKey(userId);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
cache.del(key);
}
});
}
}
6)写加强:延迟双删(解决"并发读把旧值回写")
在 afterCommit 里做两次删除:一次立刻删,一次延迟删。
延迟删别用 Thread.sleep() 卡线程,扔到线程池或定时器。
java
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ScheduledFuture;
@Component
@RequiredArgsConstructor
public class DelayedCacheEvictor {
private final CacheClient cache;
private final ThreadPoolTaskScheduler scheduler;
public void doubleDelete(String key, Duration delay) {
cache.del(key); // 第一次删(立即)
scheduler.schedule(
() -> cache.del(key), // 第二次删(延迟)
java.util.Date.from(java.time.Instant.now().plus(delay))
);
}
}
线程池配置:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class SchedulerConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
s.setPoolSize(2);
s.setThreadNamePrefix("cache-evict-");
s.initialize();
return s;
}
}
写服务改为:
java
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class UserCommandService2 {
private final UserMapper userMapper;
private final DelayedCacheEvictor evictor;
private String cacheKey(Long userId) {
return "user:profile:" + userId;
}
@Transactional
public void updateUserName(Long userId, String newName) {
UserDO u = new UserDO();
u.setId(userId);
u.setName(newName);
userMapper.updateById(u);
String key = cacheKey(userId);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
evictor.doubleDelete(key, Duration.ofMillis(500));
}
});
}
}
7)更硬核兜底:binlog/MQ 异步删缓存(可选)
如果你已经有 Canal/Debezium + MQ:
业务写 DB 后不用管缓存(或只做一次删)
下游消费 binlog 事件:按主键删 user:profile:{id}
最终一致更强、业务更干净
关键点:消费者要幂等(删缓存天然幂等)。
8. 写多读多场景的组合拳
- Cache Aside
- TTL 兜底
- 延迟双删
- 热点 key 互斥重建
9. 常见错误方案
❌ 先写缓存再写 DB
❌ 更新缓存而不是删除
❌ 缓存永不过期
10. 选型决策表
| 场景 | 推荐方案 |
|---|---|
| 中小系统 | Cache Aside |
| 高并发 | Cache Aside + 延迟双删 |
| 多系统 | Binlog + MQ |
11. 一句话总结
一致性以 DB 为准,Redis 只解决性能问题。