引言:一个事故
周五下午6点,小明正准备收拾东西下班,突然技术支持群里来了条新消息。
"用户反馈新活动送的积分显示不对!有人说自己是1000,有人说是500,但刷新几次又变成1000了!"运营小王很着急。
小明一下就紧张了起来。
积分系统正是他负责的模块,上周刚刚加了Redis缓存来提升性能。代码逻辑本身很简单:更新积分时先删除缓存,再更新数据库。而且测试环境一直很稳定,没想到上生产就出问题了。
"快看看是咋回事?"领导的消息紧接着就弹了出来。
小明赶紧登录生产环境查看,库里的积分确实是最新的没毛病,但是Redis缓存里还有没更新的!
明明是先删缓存再更新数据库的,怎么缓存里还会有旧数据?小明一脸懵逼,赶紧去找架构师老李求助。
老李正在悠闲地喝茶混加班时长,看了一眼小明的代码,非常淡定:"典型的缓存一致性问题,你遇到的是高并发下的竞态条件。"
"竞态条件?"小明一脸懵。
"来,我给你好好讲讲这个问题的根本原因,以及如何用延迟双删来彻底搞定。" 老李放下茶杯,打开了IDEA。

正文
问题的本质:并发竞态条件
老李在白板上画了个时间轴:"你现在的问题,就是在高并发情况下,删缓存和查缓存发生了竞态条件。"
问题就出在第3步和第4步的时序上。
当线程B读取数据库时,线程A还没有完成数据库更新,所以B读到的还是旧数据,然后把这个旧数据写入了缓存。
小明悟了:"所以需要在更新数据库后,再删除一次缓存!"
"是了!这就是延迟双删的核心思想。" 老李笑着说,"删除两次缓存,第一次是为了避免查询命中旧缓存,第二次是为了清理可能的脏数据。"
延迟双删的完整流程
老李继续在白板上画出延迟双删的流程图:
第一版:基础实现
先来看看最基础的延迟双删实现。
老李开始写代码:
java
@Service
public class UserPointService {
@Autowired
private UserPointMapper userPointMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 更新用户积分
*
* 核心思路:
* 1. 先删除缓存,避免读取到旧数据
* 2. 更新数据库
* 3. 延迟删除缓存,清理可能的脏数据
*/
@Transactional(rollbackFor = Exception.class)
public void updateUserPoint(Long userId, Integer points, String reason) {
String cacheKey = buildCacheKey(userId);
try {
// 第一步:删除缓存
redisTemplate.delete(cacheKey);
log.info("第一次删除缓存成功,key: {}", cacheKey);
// 第二步:更新数据库
UserPoint userPoint = new UserPoint();
userPoint.setUserId(userId);
userPoint.setPoints(points);
userPoint.setUpdateReason(reason);
userPoint.setUpdateTime(new Date());
int affectedRows = userPointMapper.updateByUserId(userPoint);
if (affectedRows == 0) {
throw new BizException("用户不存在或积分更新失败");
}
log.info("数据库更新成功,userId: {}, newPoints: {}", userId, points);
// 第三步:延迟删除缓存
// 这里用简单的新线程来演示概念,生产环境千万不能这么搞
new Thread(() -> {
try {
// 延迟时间需要根据业务场景调整
// 一般设置为:数据库操作耗时 + 读取操作耗时 + 缓冲时间
Thread.sleep(500);
// 第二次删除缓存
redisTemplate.delete(cacheKey);
log.info("延迟删除缓存成功,key: {}", cacheKey);
} catch (InterruptedException e) {
log.error("延迟删除缓存被中断,key: {}", cacheKey, e);
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error("延迟删除缓存失败,key: {}", cacheKey, e);
// 这里可以考虑重试或者告警
}
}).start();
} catch (Exception e) {
log.error("更新用户积分失败,userId: {}", userId, e);
throw new BizException("积分更新失败:" + e.getMessage());
}
}
/**
* 查询用户积分
*
* 查询策略:
* 1. 先查缓存,命中直接返回
* 2. 缓存未命中,查询数据库
* 3. 数据库结果写入缓存,设置过期时间
*/
public UserPoint getUserPoint(Long userId) {
String cacheKey = buildCacheKey(userId);
// 第一步:查询缓存
UserPoint cachedPoint = (UserPoint) redisTemplate.opsForValue().get(cacheKey);
if (cachedPoint != null) {
log.info("缓存命中,userId: {}, points: {}", userId, cachedPoint.getPoints());
return cachedPoint;
}
// 第二步:缓存未命中,查询数据库
UserPoint userPoint = userPointMapper.selectByUserId(userId);
if (userPoint == null) {
throw new BizException("用户积分不存在");
}
// 第三步:写入缓存
// 设置30分钟过期时间,避免数据长期不一致
redisTemplate.opsForValue().set(cacheKey, userPoint, 30, TimeUnit.MINUTES);
log.info("数据库查询并写入缓存,userId: {}, points: {}", userId, userPoint.getPoints());
return userPoint;
}
private String buildCacheKey(Long userId) {
return String.format("user:point:%d", userId);
}
}
小明仔细看了看代码:"这个延迟时间500ms是怎么确定的?"
"好问题!" 老李点点头。
延迟时间的设置很关键,太短了清理不了脏数据,太长了可能影响性能。一般的计算公式是:
延迟时间 = 数据库写操作耗时 + 数据库读操作耗时 + 网络延迟 + 安全缓冲时间
比如你的数据库写操作需要50ms,读操作需要30ms,网络延迟10ms,那么延迟时间可以设置为200ms(50+30+10+110)。
"但是这个基础版本有几个问题。" 老李指着代码说:
- 每次更新都创建新线程,高并发下会有性能问题
- 如果应用重启,延迟任务就丢失了
- 缓存删除失败没有重试机制
- 缺乏监控,没法观察延迟删除的执行情况
第二版:生产级实现
来看看生产环境应该怎么实现。
老李新建了一个改进版的类:
java
@Service
public class UserPointServiceV2 {
@Autowired
private UserPointMapper userPointMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 使用线程池管理延迟任务,避免频繁创建线程
private final ScheduledExecutorService delayDeleteExecutor =
Executors.newScheduledThreadPool(
10, // 核心线程数,根据业务量调整
new ThreadFactoryBuilder()
.setNameFormat("delay-cache-delete-%d")
.setDaemon(true) // 设置为守护线程
.setUncaughtExceptionHandler((t, e) ->
log.error("延迟删除缓存线程异常", e))
.build()
);
// 延迟删除配置
@Value("${cache.delay-delete.delay-time:300}")
private long delayTime; // 延迟时间,单位毫秒
@Value("${cache.delay-delete.max-retry:3}")
private int maxRetryTimes; // 最大重试次数
/**
* 更新用户积分
*/
@Transactional(rollbackFor = Exception.class)
public void updateUserPoint(Long userId, Integer points, String reason) {
String cacheKey = buildCacheKey(userId);
String operationId = generateOperationId(userId);
try {
// 第一步:删除缓存(同步执行,确保删除成功)
deleteCacheWithRetry(cacheKey, "first-delete", operationId);
// 第二步:更新数据库
UserPoint userPoint = buildUserPoint(userId, points, reason);
int affectedRows = userPointMapper.updateByUserId(userPoint);
if (affectedRows == 0) {
throw new BizException("用户不存在或积分更新失败");
}
log.info("积分更新成功 - operationId: {}, userId: {}, newPoints: {}",
operationId, userId, points);
// 第三步:异步延迟删除缓存
scheduleDelayDelete(cacheKey, operationId);
} catch (Exception e) {
log.error("积分更新失败 - operationId: {}, userId: {}, points: {}",
operationId, userId, points, e);
throw new BizException("积分更新失败:" + e.getMessage());
}
}
/**
* 批量更新用户积分
* 对于批量操作,延迟双删同样适用
*/
@Transactional(rollbackFor = Exception.class)
public void batchUpdateUserPoint(List<UserPointUpdateRequest> requests) {
if (requests.isEmpty()) {
return;
}
String operationId = UUID.randomUUID().toString();
Set<String> cacheKeys = requests.stream()
.map(req -> buildCacheKey(req.getUserId()))
.collect(Collectors.toSet());
try {
// 第一步:批量删除缓存
Long deletedCount = redisTemplate.delete(cacheKeys);
log.info("批量删除缓存 - operationId: {}, 删除数量: {}/{}",
operationId, deletedCount, cacheKeys.size());
// 第二步:批量更新数据库
for (UserPointUpdateRequest request : requests) {
UserPoint userPoint = buildUserPoint(
request.getUserId(),
request.getPoints(),
request.getReason()
);
userPointMapper.updateByUserId(userPoint);
}
log.info("批量积分更新成功 - operationId: {}, 更新数量: {}",
operationId, requests.size());
// 第三步:延迟批量删除缓存
delayDeleteExecutor.schedule(() -> {
try {
Long delayDeletedCount = redisTemplate.delete(cacheKeys);
log.info("延迟批量删除缓存成功 - operationId: {}, 删除数量: {}",
operationId, delayDeletedCount);
} catch (Exception e) {
log.error("延迟批量删除缓存失败 - operationId: {}", operationId, e);
// 可以考虑重试或发送告警
}
}, delayTime, TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("批量积分更新失败 - operationId: {}", operationId, e);
throw new BizException("批量积分更新失败:" + e.getMessage());
}
}
/**
* 带重试机制的缓存删除
*/
private void deleteCacheWithRetry(String cacheKey, String operation, String operationId) {
int retryCount = 0;
Exception lastException = null;
while (retryCount <= maxRetryTimes) {
try {
Boolean deleted = redisTemplate.delete(cacheKey);
if (Boolean.TRUE.equals(deleted) || retryCount == 0) {
// 删除成功或者key本来就不存在(第一次尝试)
log.info("缓存删除成功 - operation: {}, operationId: {}, key: {}, retry: {}",
operation, operationId, cacheKey, retryCount);
return;
}
} catch (Exception e) {
lastException = e;
log.warn("缓存删除失败 - operation: {}, operationId: {}, key: {}, retry: {}",
operation, operationId, cacheKey, retryCount, e);
}
retryCount++;
if (retryCount <= maxRetryTimes) {
// 指数退避策略
long sleepTime = Math.min(1000, 100 * (1L << (retryCount - 1)));
try {
Thread.sleep(sleepTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("缓存删除被中断", ie);
}
}
}
// 最终失败
log.error("缓存删除最终失败 - operation: {}, operationId: {}, key: {}",
operation, operationId, cacheKey, lastException);
// 这里可以发送告警或写入失败队列
// alertService.sendAlert("缓存删除失败", cacheKey, operationId);
}
/**
* 调度延迟删除任务
*/
private void scheduleDelayDelete(String cacheKey, String operationId) {
delayDeleteExecutor.schedule(() -> {
deleteCacheWithRetry(cacheKey, "delay-delete", operationId);
}, delayTime, TimeUnit.MILLISECONDS);
log.info("延迟删除任务已调度 - operationId: {}, key: {}, delayTime: {}ms",
operationId, cacheKey, delayTime);
}
private UserPoint buildUserPoint(Long userId, Integer points, String reason) {
UserPoint userPoint = new UserPoint();
userPoint.setUserId(userId);
userPoint.setPoints(points);
userPoint.setUpdateReason(reason);
userPoint.setUpdateTime(new Date());
return userPoint;
}
/**
* 生成操作ID,用于链路追踪
*/
private String generateOperationId(Long userId) {
return String.format("update_point_%d_%d", userId, System.currentTimeMillis());
}
private String buildCacheKey(Long userId) {
return String.format("user:point:%d", userId);
}
/**
* 应用关闭时优雅停机
*/
@PreDestroy
public void shutdown() {
log.info("开始关闭延迟删除线程池");
delayDeleteExecutor.shutdown();
try {
// 等待已提交的任务完成
if (!delayDeleteExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
log.warn("延迟删除任务未能在10秒内完成,强制关闭");
delayDeleteExecutor.shutdownNow();
// 再等待一段时间
if (!delayDeleteExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
log.error("延迟删除线程池强制关闭失败");
}
}
} catch (InterruptedException e) {
log.error("等待延迟删除线程池关闭被中断", e);
delayDeleteExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
log.info("延迟删除线程池已关闭");
}
}
这个版本就比较完善了。
有线程池、还加了重试机制、支持批量操作,还有优雅停机。
小明认真地看着代码:"这个指数退避策略是什么意思?"
"简单来说,就是重试的间隔时间越来越长。"老李解释道,"第一次重试等100ms,第二次等200ms,第三次等400ms。这样可以避免在Redis出现短暂问题时过于频繁的重试。"
第三版:基于消息队列
虽然线程池方案已经比较完善了,但其实还有一个更加可靠的方案。
就是基于消息队列的延迟删除。
小明不解。
老李画了个对比图:
MQ方案的优势很明显:
- 消息存储在MQ中,应用重启也不会丢失
- MQ如果有集群,能保证消息不丢失,达到了高可用
- 自带重试和死信队列
- 可以通过MQ控制台看消息状态,监控很方便
- 多个消费者实例可以并行处理
来看看基于RabbitMQ的实现:
java
@Service
public class UserPointServiceV3 {
@Autowired
private UserPointMapper userPointMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
// 延迟删除相关配置
private static final String DELAY_DELETE_EXCHANGE = "cache.delay.delete.exchange";
private static final String DELAY_DELETE_ROUTING_KEY = "cache.delay.delete";
/**
* 更新用户积分
*/
@Transactional(rollbackFor = Exception.class)
public void updateUserPoint(Long userId, Integer points, String reason) {
String cacheKey = buildCacheKey(userId);
String operationId = generateOperationId(userId);
try {
// 第一步:删除缓存
deleteCacheWithRetry(cacheKey, "first-delete", operationId);
// 第二步:更新数据库
UserPoint userPoint = buildUserPoint(userId, points, reason);
int affectedRows = userPointMapper.updateByUserId(userPoint);
if (affectedRows == 0) {
throw new BizException("用户不存在或积分更新失败");
}
log.info("积分更新成功 - operationId: {}, userId: {}, newPoints: {}",
operationId, userId, points);
// 第三步:发送延迟删除消息
sendDelayDeleteMessage(cacheKey, operationId);
} catch (Exception e) {
log.error("积分更新失败 - operationId: {}, userId: {}, points: {}",
operationId, userId, points, e);
throw new BizException("积分更新失败:" + e.getMessage());
}
}
/**
* 发送延迟删除消息
*/
private void sendDelayDeleteMessage(String cacheKey, String operationId) {
CacheDeleteMessage message = CacheDeleteMessage.builder()
.cacheKey(cacheKey)
.operationId(operationId)
.createTime(System.currentTimeMillis())
.retryCount(0)
.build();
try {
rabbitTemplate.convertAndSend(
DELAY_DELETE_EXCHANGE,
DELAY_DELETE_ROUTING_KEY,
message,
// 设置消息延迟时间
msg -> {
msg.getMessageProperties().setDelay(300); // 延迟300ms
msg.getMessageProperties().setMessageId(operationId);
return msg;
}
);
log.info("延迟删除消息发送成功 - operationId: {}, cacheKey: {}",
operationId, cacheKey);
} catch (Exception e) {
log.error("延迟删除消息发送失败 - operationId: {}, cacheKey: {}",
operationId, cacheKey, e);
// 消息发送失败,可以考虑回退到线程池方案
fallbackToThreadPool(cacheKey, operationId);
}
}
/**
* 消费延迟删除消息
*/
@RabbitListener(queues = "cache.delay.delete.queue")
public void handleDelayDeleteMessage(
@Payload CacheDeleteMessage message,
Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag
) {
String operationId = message.getOperationId();
String cacheKey = message.getCacheKey();
try {
// 执行缓存删除
Boolean deleted = redisTemplate.delete(cacheKey);
if (Boolean.TRUE.equals(deleted)) {
log.info("延迟删除缓存成功 - operationId: {}, cacheKey: {}",
operationId, cacheKey);
} else {
log.info("缓存key不存在,删除成功 - operationId: {}, cacheKey: {}",
operationId, cacheKey);
}
// 手动确认消息
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
log.error("延迟删除缓存失败 - operationId: {}, cacheKey: {}, retryCount: {}",
operationId, cacheKey, message.getRetryCount(), e);
try {
// 增加重试次数
message.setRetryCount(message.getRetryCount() + 1);
if (message.getRetryCount() >= 3) {
// 超过最大重试次数,发送到死信队列
log.error("延迟删除缓存达到最大重试次数 - operationId: {}, cacheKey: {}",
operationId, cacheKey);
channel.basicNack(deliveryTag, false, false);
// 发送告警
// alertService.sendAlert("缓存删除失败", operationId, cacheKey);
} else {
// 拒绝消息,让MQ重新投递
channel.basicNack(deliveryTag, false, true);
}
} catch (IOException ioException) {
log.error("消息确认失败 - operationId: {}", operationId, ioException);
}
}
}
/**
* MQ发送失败时的回退方案
*/
private void fallbackToThreadPool(String cacheKey, String operationId) {
// 使用单独的线程池作为回退方案
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(300);
deleteCacheWithRetry(cacheKey, "fallback-delete", operationId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("回退删除缓存被中断 - operationId: {}", operationId, e);
}
});
}
// 其他方法保持不变...
private void deleteCacheWithRetry(String cacheKey, String operation, String operationId) {
// ... (与V2版本相同的实现)
}
private UserPoint buildUserPoint(Long userId, Integer points, String reason) {
// ... (与V2版本相同的实现)
}
private String generateOperationId(Long userId) {
return String.format("update_point_%d_%d", userId, System.currentTimeMillis());
}
private String buildCacheKey(Long userId) {
return String.format("user:point:%d", userId);
}
}
/**
* 缓存删除消息实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CacheDeleteMessage implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 缓存键
*/
private String cacheKey;
/**
* 操作ID,用于链路追踪
*/
private String operationId;
/**
* 创建时间
*/
private Long createTime;
/**
* 重试次数
*/
private Integer retryCount;
}
这里省略相关的配置,具体代码就由你自行去发挥了。
方案对比
老李在白板上画了个完整的对比表:
特性对比 | 基础版 | 线程池版 | MQ版 |
---|---|---|---|
实现复杂度 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
可靠性 | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
性能开销 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
监控能力 | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
运维成本 | ⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
横向扩展 | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
"选择哪种方案,还是要看你的具体业务场景,不能人云亦云。" 老李总结道:
- 简单业务:基础版足够了,简单快速
- 中型项目/对可靠性有要求:线程池版,平衡复杂度和可靠性
- 大型项目/核心业务:MQ版,最高的可靠性和可扩展性
- 项目已有MQ:必然优先考虑MQ版
如何计算延迟时间
小明问:"还有个问题,延迟时间到底应该怎么设置才恰当?"
确实,如果延迟时间设置不当,要么清理不了脏数据,要么影响性能。
于是老李写了个计算公式:
diff
延迟时间 = Max(数据库写操作时间, 数据库读操作时间) + 网络往返时间 + 安全边界
具体计算示例:
- 数据库写操作:平均50ms,99分位100ms
- 数据库读操作:平均30ms,99分位80ms
- 网络往返:平均5ms,99分位20ms
- 安全边界:建议100ms
延迟时间 = Max(100, 80) + 20 + 100 = 220ms
为了保险起见,一般设置为300ms
。
如果业务对实时性要求很高,可以设置为200ms
;如果对一致性要求极高,可以设置为500ms
。
适用场景
小明又问了:"是不是所有的缓存更新都要用延迟双删呢?"
当然不是。
延迟双删有自己的适用场景:
适合的
- 读多写少
- 有强一致性要求
- 更新不频繁
- 可以容忍短暂延迟,只要保证最终一致性
不适合的
- 秒级多次更新的热点数据
- 弱一致性要求,能接受数据不一致
- 实时性要求特别高
- 写多读少
其他方案
当然除了延迟双删,业内也有一些其他的方案,这里简单列举:
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
延迟双删 | 一致性较好,实现相对简单 | 有延迟,可能误删 | 读多写少,强一致性 |
先更新DB再删缓存 | 实现简单,性能好 | 短暂不一致 | 弱一致性要求 |
读时加锁 | 一致性最强 | 性能差,可能死锁 | 极强一致性要求 |
Canal同步 | 实时性好,解耦 | 架构复杂,依赖多 | 大数据量,高实时性 |
写在最后
这么一趟走下来,是不是感觉延迟双删其实并不复杂。
只要掌握原理,很多难题都能迎刃而解。
技术的本质是服务业务并解决问题。
延迟双删只是手段,真正的目标是保证系统的稳定性和用户体验。
在追求技术完美的同时,也得考虑实现成本和维护复杂度。
这时候小明又冒了出来:"如果在延迟删除执行前,又有新的更新操作怎么办?"
诸君,你怎么看?
