在高并发分布式系统中,缓存(如Redis)是提升读取性能的核心组件,但缓存与数据库的一致性问题一直是架构设计的难点。本文将深度分析「先写缓存/先写数据库」「先删缓存/先写数据库」四类经典更新策略,结合高并发场景的痛点,给出工业级最优解决方案。
一、核心问题背景
缓存更新的本质是平衡数据一致性、系统性能和实现复杂度:
-
一致性:缓存数据需尽可能与数据库保持一致,避免「脏数据」;
-
性能:更新操作不能引入过高开销,需适配高并发场景;
-
复杂度:方案需工程落地简单,避免过度设计。
下文将逐一分析四种主流更新策略的优劣。
二、四种缓存更新策略全解析
1. 先写缓存,再写数据库
1.1. 核心流程
是
否
是
否
接收更新请求
写入缓存
缓存写入成功?
写入数据库
返回失败
数据库写入成功?
返回成功
返回失败
1.2. 核心问题
-
数据不一致风险极高:缓存写入成功但数据库写入失败(如网络抖动、数据库宕机),会导致缓存存在脏数据,数据库无对应数据,且脏数据会长期存在(除非手动清理)。
-
高并发下覆盖问题:多线程场景下,后写入的缓存可能被先写入的数据库操作覆盖,最终缓存与数据库数据永久不一致。
示例:
-
线程A:写缓存(值=100)→ 等待写数据库
-
线程B:写缓存(值=200)→ 写数据库(值=200)
-
线程A:写数据库(值=100)
最终结果:数据库=100,缓存=200,数据完全不一致。
1.3. 适用场景
几乎无适用场景,仅允许极端数据不一致的非核心场景可临时使用,高并发下完全不可选。
2. 先写数据库,再写缓存
2.1. 核心流程
是
否
是
否
接收更新请求
写入数据库
数据库写入成功?
写入缓存
返回失败
缓存写入成功?
返回成功
返回失败
2.2. 核心问题
- 高并发更新覆盖:这是该策略最致命的问题,会导致永久脏数据。
示例:
-
线程1:写数据库(值=100)→ 准备写缓存
-
线程2:写数据库(值=200)→ 写缓存(值=200)
-
线程1:写缓存(值=100)
最终结果:数据库=200,缓存=100,数据不一致且无法自动恢复。
-
无效缓存写入:很多场景下,刚写入的缓存数据短期内不会被读取,额外的写缓存操作浪费系统资源。
-
缓存写入失败风险:缓存集群宕机或网络问题导致缓存写入失败,同样会引发数据不一致。
2.3. 适用场景
仅适用于低并发、读极少更新的场景(如静态配置更新),高并发下不推荐。
3. 先删缓存,再写数据库
3.1. 核心流程
是
否
是
否
接收更新请求
删除缓存
缓存删除成功?
写入数据库
返回失败
数据库写入成功?
返回成功
返回失败
3.2. 核心问题
- 缓存击穿+脏数据:高并发读场景下,会出现「缓存miss→读旧数据→回写缓存」的致命问题。
示例:
-
线程1:删除缓存 → 准备写数据库(新值=200)
-
线程2:读取缓存(miss)→ 从数据库读旧值=100 → 写入缓存
-
线程1:写数据库(新值=200)
最终结果:数据库=200,缓存=100,形成永久脏数据(直到缓存过期或手动删除)。
- 虽可通过「延迟双删」(删缓存→写数据库→延迟1秒再删缓存)缓解,但无法100%解决(延迟时间难以精准控制),且增加了实现复杂度。
3.3. 适用场景
仅适用于低并发读、更新频率极低的场景,高并发下需谨慎使用。
4. 先写数据库,再删缓存(最优方案)
4.1. 核心流程
是
否
是
否
接收更新请求
写入数据库
数据库写入成功?
删除缓存
返回失败
缓存删除成功?
返回成功
记录日志+异步重试
4.2. 核心优势
-
数据不一致风险最低:
-
即使缓存删除失败,后续读取请求会从数据库读取最新数据并回写缓存,脏数据仅临时存在(直到缓存过期);
-
高并发下,即使删除缓存延迟,读取请求最多读到「旧缓存」,但数据库已存新数据,后续缓存会被自动修正。
-
-
实现简单:无需复杂的锁、延迟逻辑,工程落地成本低。
-
性能损耗小:删除缓存是轻量操作(比写缓存快),避免了「无效写缓存」的开销。
4.3. 潜在问题及解决方案
| 潜在问题 | 解决方案 |
|---|---|
| 数据库写成功,缓存删失败 → 缓存存旧数据 | 1. 给缓存设置过期时间 (兜底,过期后自动失效); 2. 增加「删除缓存重试机制」(如消息队列异步重试); 3. 监控缓存删除失败情况,人工兜底 |
| 缓存删除后大量请求直接打数据库 → 数据库压力陡增(缓存击穿) | 1. 给热点key加「互斥锁」(同一时间仅一个线程读库回写缓存); 2. 热点数据设置「永不过期」+ 主动更新; 3. 布隆过滤器拦截无效请求 |
4.4. 适用场景
所有高并发场景的首选方案,是工业界主流的缓存更新策略(如电商、金融、社交等核心系统均采用)。
三、最优方案代码实现(Java + Redis)
以「更新用户余额」为例,展示「先写数据库,再删缓存」的核心实现,包含缓存重试、互斥锁防击穿等关键细节:
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class UserBalanceService {
@Resource
private UserBalanceMapper userBalanceMapper; // 数据库操作Mapper
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RetryQueueService retryQueueService; // 异步重试队列服务
// 缓存key前缀
private static final String CACHE_KEY_USER_BALANCE = "user:balance:";
// 锁key前缀
private static final String LOCK_KEY_USER_BALANCE = "lock:user:balance:";
// 缓存过期时间(30分钟)
private static final long CACHE_EXPIRE_TIME = 30L;
// 锁过期时间(5秒,防止死锁)
private static final long LOCK_EXPIRE_TIME = 5L;
/**
* 更新用户余额:先写数据库,再删缓存
* @param userId 用户ID
* @param newBalance 新余额
*/
public void updateUserBalance(Long userId, Long newBalance) {
// 1. 先更新数据库(保证数据持久化)
boolean dbUpdateSuccess = userBalanceMapper.updateBalance(userId, newBalance);
if (!dbUpdateSuccess) {
log.error("更新用户{}余额失败,数据库写入失败", userId);
throw new RuntimeException("余额更新失败");
}
// 2. 再删除缓存(核心步骤)
String cacheKey = CACHE_KEY_USER_BALANCE + userId;
try {
redisTemplate.delete(cacheKey);
log.info("删除用户{}余额缓存成功,key:{}", userId, cacheKey);
} catch (Exception e) {
// 缓存删除失败,记录日志并加入异步重试队列
log.error("删除用户{}余额缓存失败,key:{},原因:{}", userId, cacheKey, e.getMessage());
retryQueueService.addRetryTask(cacheKey, "DELETE"); // 加入重试队列
}
}
/**
* 读取用户余额:缓存优先,加互斥锁防击穿
* @param userId 用户ID
* @return 用户余额
*/
public Long getUserBalance(Long userId) {
String cacheKey = CACHE_KEY_USER_BALANCE + userId;
// 1. 先查缓存
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return (Long) cacheValue;
}
// 2. 缓存miss,加互斥锁防止缓存击穿
String lockKey = LOCK_KEY_USER_BALANCE + userId;
Long dbValue = null;
try {
// 尝试获取锁(SET NX EX)
boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE_TIME, TimeUnit.SECONDS);
if (lockSuccess) {
// 3. 获取锁成功,读取数据库
dbValue = userBalanceMapper.getBalance(userId);
if (dbValue != null) {
// 4. 回写缓存(设置过期时间)
redisTemplate.opsForValue().set(cacheKey, dbValue, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
log.info("回写用户{}余额缓存成功,key:{},值:{}", userId, cacheKey, dbValue);
}
} else {
// 未获取到锁,等待50ms后重试
log.info("用户{}余额缓存miss,未获取到锁,等待重试", userId);
TimeUnit.MILLISECONDS.sleep(50);
return getUserBalance(userId); // 递归重试
}
} catch (InterruptedException e) {
log.error("获取用户{}余额锁失败,原因:{}", userId, e.getMessage());
Thread.currentThread().interrupt();
} finally {
// 释放锁(无论是否成功,都释放)
redisTemplate.delete(lockKey);
}
return dbValue;
}
}
补充说明
-
RetryQueueService:异步重试队列服务,可基于RabbitMQ/RocketMQ实现,核心逻辑是对删除失败的缓存key进行3次重试(间隔1s、3s、5s); -
数据库操作:实际项目中建议使用事务保证数据库写入的原子性;
-
监控:需监控缓存删除失败率、数据库更新成功率,及时发现异常。
四、总结
-
最优策略:高并发系统中优先选择「先写数据库,再删缓存」,这是平衡一致性、性能和复杂度的最优解;
-
核心兜底措施:给缓存设置过期时间(解决删除失败的脏数据问题),给热点key加互斥锁(解决缓存击穿问题);
-
避坑提醒:「写缓存」类策略(先写缓存/后写缓存)高并发下易导致永久脏数据,「先删缓存再写数据库」易引发缓存击穿,均不推荐作为核心策略。
通过以上方案,可在高并发场景下最大限度保证缓存与数据库的一致性,同时兼顾系统性能和工程实现的简洁性。