摘要: 使用 Redis 缓存数据库查询结果时,缓存与数据库之间没有天然的原子性保证。一次普通的“更新数据库、删除缓存”在特定并发时序下仍可能留下旧数据。延时双删通过“删除缓存、更新数据库、等待、再次删除缓存”缩短脏数据存活时间,是 Java 后端面试中的高频方案。本文从 Cache Aside 模式出发,结合并发时序、Spring Boot 示例、失败补偿和方案对比,说明延时双删解决了什么、为什么不能保证强一致性,以及生产环境应如何选择。
一、问题本质:数据库和缓存不是一个事务
假设商品价格保存在 MySQL 中,同时为了降低数据库压力,我们把商品详情缓存在 Redis 中。读请求优先访问 Redis,写请求修改 MySQL 后再让缓存失效。
问题是,MySQL 和 Redis 是两个独立系统:
- MySQL 事务提交成功,不代表 Redis 操作一定成功;
- Redis 删除成功,不代表 MySQL 更新一定成功;
- 多个读写请求并发执行时,不同操作可能交叉;
- 网络延迟、线程阻塞、GC 和进程宕机都会改变实际执行顺序。
因此,缓存一致性问题的本质不是简单背诵“先操作谁”,而是:如何管理数据库与缓存无法原子提交所产生的并发窗口和失败窗口。
表面是:“先删缓存,再更新数据库,休眠 500 毫秒,再删一次缓存。”这只描述了动作,没有解释原理。真正需要回答的是:旧值如何进入缓存?第二次删除清理的是谁写入的旧值?延迟应该覆盖哪个时间窗口?二删失败后怎么办?
二、先理解 Cache Aside 模式
延时双删通常建立在 Cache Aside(旁路缓存)模式上。应用同时管理数据库与缓存,缓存只保存数据库数据的副本。
2.1 读请求
- 根据业务 Key 查询 Redis;
- 缓存命中,直接返回;
- 缓存未命中,查询数据库;
- 把数据库结果写入 Redis,并设置过期时间;
- 返回结果。
伪代码如下:
function query(id):
value = cache.get(id)
if value exists:
return value
value = database.get(id)
cache.set(id, value, ttl)
return value
2.2 写请求为什么倾向于删除缓存
写请求常见的基础做法是:
function update(id, newValue):
database.update(id, newValue)
cache.delete(id)
这里通常选择“删除缓存”,而不是“更新数据库后再更新缓存”,主要有两个原因。
第一,并发更新缓存容易发生顺序倒置。请求 A 先更新数据库,请求 B 后更新数据库,但 B 先写缓存、A 后写缓存,最终缓存可能保留 A 的旧版本。
第二,不是所有写入后的数据都会立刻被读取。每次写库都同步构造并更新缓存,会产生额外计算和网络开销。删除缓存后让真正的读请求按需重建,更符合 Cache Aside 的职责划分。
不过,“更新数据库后删除缓存”也不是绝对安全的。如果数据库已经提交,而删除缓存失败,Redis 会继续保存旧值。因此工程上仍要配合重试、过期时间、消息队列和监控。
三、什么是延时双删
延时双删的典型流程如下:
function update(id, newValue):
cache.delete(id) // 第一次删除
database.update(id, newValue) // 更新数据库
afterTransactionCommit:
schedule(delay):
cache.delete(id) // 第二次删除
四个动作分别承担不同职责:
- 第一次删除缓存:让后续读请求不要继续直接命中旧缓存;
- 更新数据库:修改权威数据源;
- 等待一段时间:尽量覆盖已经开始但尚未完成回填的读请求;
- 第二次删除缓存:清除并发读请求可能重新写入的旧值。
它叫“双删”,并不是因为同一个 Key 一定存在两次,而是两个删除动作处理不同阶段的不确定性。第一次删除负责失效已有副本,第二次删除负责清理并发窗口中可能产生的脏副本。
四、只删除一次,为什么仍会出现旧缓存
理解延时双删,关键是看清“旧值回填”的并发时序。假设数据库中商品价格最初是 100 元,Redis 中没有对应缓存。读线程 A 与写线程 B 同时执行。
| 时刻 | 读线程 A | 写线程 B | 数据库 | Redis |
|---|---|---|---|---|
| T1 | 查询 Redis,未命中 | 100 | 空 | |
| T2 | 查询数据库,读到 100,随后阻塞 | 100 | 空 | |
| T3 | 第一次删除缓存 | 100 | 空 | |
| T4 | 更新价格为 200 并提交 | 200 | 空 | |
| T5 | 把此前读到的 100 回填 Redis | 200 | 100 | |
| T6 | 延迟后第二次删除缓存 | 200 | 空 |
在 T2 时刻,线程 A 已经拿到了旧值 100,但还没来得及写缓存。线程 B 在 T3 删除缓存时,Redis 本来就是空的,所以第一次删除无法影响 A 手里的旧值。B 在 T4 完成数据库更新后,A 才在 T5 把 100 写入缓存。
如果没有第二次删除,后续请求会一直读到 100,直到缓存自然过期。第二次删除的目标正是这种“在途读请求回填的旧值”。
4.1 每一步到底解决什么问题
第一次删除用于失效写请求开始前已经存在的旧缓存。如果不做第一次删除,从写请求开始到最终二删之间,读请求仍会命中旧值。
更新数据库用于修改权威数据源。缓存只是一份可丢弃、可重建的副本,不应成为数据事实的最终来源。
延迟不是为了让数据库“慢慢同步”,也不是 Redis 的特殊要求。它只是给已经开始的旧读请求留出时间,让这些请求先完成可能的旧值回填。
第二次删除用于清理并发窗口中的旧值。二删之后,新请求缓存未命中,会从数据库读取 200 并重建缓存。
五、延迟时间应该设置多久
面试中经常听到“延迟 500 毫秒”或“延迟 1 秒”。这些数字只能是某个系统中的经验值,不能当作通用答案。
延迟需要覆盖一次缓存未命中读请求的完整回填链路:
Redis 查询耗时
+ 数据库连接等待
+ SQL 执行耗时
+ 对象转换和业务计算
+ 应用到 Redis 的网络耗时
+ 序列化与缓存写入耗时
+ 调度、GC 和网络抖动余量
工程上可以统计这条链路的 P99 或 P999 耗时,再增加安全余量。例如,监控显示完整回填 P99 为 180 毫秒,可以从高于该值的延迟开始压测和观察,但这不等于所有系统都应采用某个固定毫秒数。
延迟太短,可能无法覆盖慢查询或线程阻塞,旧值会在二删之后才被写回;延迟太长,脏缓存可能存活更久,同时延迟任务积压更多。因此延迟值应来自监控、压测和业务容忍度,而不是来自博客中的常数。
5.1 必须主动说明的边界
假设线程 A 在读取旧值后阻塞了 3 秒,而第二次删除只延迟 1 秒:
A 读到旧值 -> B 更新数据库 -> 1 秒后 B 二删 -> 3 秒后 A 回填旧值
旧值仍会在二删之后进入缓存。无论延迟设为多少,只要存在更长的阻塞,就仍能构造出失败时序。
延时双删是一种低成本的最终一致性优化方案。它可以降低旧值长期留在缓存中的概率,但不能从理论上保证强一致性。
六、Java + Spring Boot 演示实现
下面给出一份用于说明执行时序的核心实现。示例重点是事务提交和二次删除的关系,省略 Controller、DTO 字段和 Repository 的常规代码。
6.1 读取商品:未命中时回源并回填
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private static final Duration CACHE_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
private final ObjectMapper objectMapper;
private final DelayedCacheDeleteScheduler deleteScheduler;
public ProductView query(long productId) {
String key = cacheKey(productId);
String json = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(json)) {
return fromJson(json);
}
ProductView product = productRepository.findViewById(productId)
.orElseThrow(() -> new IllegalArgumentException("商品不存在"));
redisTemplate.opsForValue().set(key, toJson(product), CACHE_TTL);
return product;
}
private String cacheKey(long productId) {
return "product:detail:" + productId;
}
private String toJson(ProductView product) {
try {
return objectMapper.writeValueAsString(product);
} catch (JsonProcessingException e) {
throw new IllegalStateException("商品缓存序列化失败", e);
}
}
private ProductView fromJson(String json) {
try {
return objectMapper.readValue(json, ProductView.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("商品缓存反序列化失败", e);
}
}
}
示例给缓存设置了 TTL。TTL 是最后一道自动恢复手段:即使缓存删除链路全部失败,旧值也不会无限期存在。但 TTL 不能替代主动失效,因为业务通常不能接受旧数据一直保留到自然过期。
真实项目还要考虑空值缓存、缓存穿透、热点 Key 重建和缓存击穿。它们与本文的一致性主线不同,这里不展开。
6.2 更新商品:事务提交后再安排二删
把下面的方法放入 ProductService:
@Transactional
public void updatePrice(long productId, BigDecimal newPrice) {
String key = cacheKey(productId);
// 第一次删除。Redis 抛出异常时让本次写入失败,由上层重试或补偿。
redisTemplate.delete(key);
int affectedRows = productRepository.updatePrice(productId, newPrice);
if (affectedRows != 1) {
throw new IllegalStateException("商品价格更新失败");
}
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new IllegalStateException("当前方法必须运行在 Spring 事务中");
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
deleteScheduler.schedule(key, Duration.ofMillis(800));
} catch (RuntimeException ex) {
// 数据库已提交,生产环境必须把任务写入可靠补偿通道。
log.error("提交延迟删除任务失败,cacheKey={}", key, ex);
}
}
}
);
}
这里有三个容易忽略的点。
第一,第二次删除要在事务提交成功后安排。 如果更新 SQL 执行完但事务还未提交,就开始计算延迟,慢事务可能吃掉大部分延迟时间。事务最终回滚时,二删任务还可能被错误发送。afterCommit() 明确了动作边界。
第二,afterCommit() 中失败已经无法回滚数据库。 事务提交后,即使调度失败,也不能再假装数据库没有更新。因此必须记录上下文并进入可靠补偿流程,不能只打印日志后永久丢弃。
第三,必须经过 Spring 事务代理调用。 如果在同一对象内部直接调用该方法,@Transactional 可能因自调用而失效。实际项目应通过清晰的 Service 边界调用,并用集成测试验证事务同步确实生效。
代码中的 800 毫秒只用于展示参数传递,不是推荐值。生产配置应外置,并根据前文的回填链路高分位耗时确定。
6.3 非阻塞地执行延迟删除
@Component
@RequiredArgsConstructor
@Slf4j
public class DelayedCacheDeleteScheduler {
private final StringRedisTemplate redisTemplate;
private final ScheduledExecutorService cacheDeleteExecutor;
public void schedule(String cacheKey, Duration delay) {
cacheDeleteExecutor.schedule(() -> {
try {
redisTemplate.delete(cacheKey);
log.info("二次删除缓存成功,cacheKey={}", cacheKey);
} catch (Exception ex) {
// 演示代码只记录失败;生产环境应发送到持久化重试队列。
log.error("二次删除缓存失败,cacheKey={}", cacheKey, ex);
}
}, delay.toMillis(), TimeUnit.MILLISECONDS);
}
}
演示配置如下:
@Configuration
public class CacheExecutorConfiguration {
@Bean(destroyMethod = "shutdown")
public ScheduledExecutorService cacheDeleteExecutor() {
ThreadFactory factory = runnable -> {
Thread thread = new Thread(runnable);
thread.setName("cache-double-delete");
thread.setDaemon(true);
return thread;
};
return new ScheduledThreadPoolExecutor(
2,
factory,
new ThreadPoolExecutor.AbortPolicy()
);
}
}
为什么不在请求线程里这样写?
redisTemplate.delete(key);
productRepository.updatePrice(productId, newPrice);
Thread.sleep(800);
redisTemplate.delete(key);
Thread.sleep() 会占住处理业务请求的线程。写流量上升时,大量请求线程进入睡眠,线程池容易耗尽,接口延迟和超时会迅速放大。
不过,ScheduledExecutorService 也只是更适合讲解。JDK 的 ScheduledThreadPoolExecutor 默认使用无界延迟队列,进程重启时内存任务会丢失,任务堆积还可能持续消耗内存。上面的固定值只是工作线程数量,并不代表任务队列有容量上限。因此,不应把这段进程内调度代码原样作为生产级默认方案。
七、失败场景:重点
延时双删包含 Redis、数据库、事务回调和延迟任务等多个环节。每个环节失败时,处理方式都不一样。
7.1 第一次删除失败
如果 Redis 删除抛出异常,而业务要求较高一致性,可以让异常终止本次数据库写入,交给上层重试。代价是 Redis 故障会影响写接口可用性。
如果业务更看重写入可用性,也可以继续更新数据库,但必须把缓存失效事件写入可靠补偿通道,并缩短缓存 TTL、加强告警。这里没有不付代价的选择:中止写入牺牲可用性,继续写入则接受短暂旧缓存风险。
还要注意,delete() 返回 false 可能只是 Key 原本不存在,不等于 Redis 操作失败;网络异常和服务端错误通常以异常表现。
7.2 数据库更新失败
数据库事务回滚时不应安排第二次删除。第一次删除若已成功,只会造成缓存暂时为空。下一个读请求会从数据库读到尚未修改的旧值,并重新建立正确缓存。
这个场景通常不会产生错误数据,但会增加一次数据库查询和缓存重建。第一次删除与数据库事务回滚不具备原子性,却可以利用“缓存可重建”这一性质恢复。
7.3 第二次删除失败
这是最需要补偿的场景。数据库已经提交,旧值却可能留在 Redis 中。仅记录日志是不够的,生产系统通常需要:
- 对瞬时 Redis 超时做有限次数重试;
- 使用指数退避和随机抖动,避免形成重试风暴;
- 重试失败后写入持久化队列或任务表;
- 超过最大次数后进入死信队列;
- 对积压量、最长延迟和最终失败量配置告警;
- 保留人工重放和按业务 Key 修复的能力。
缓存删除天然幂等:同一个 Key 删除多次,最终状态仍是不存在。因此消息可以采用“至少一次”投递。不过事件最好仍携带事件 ID、业务 Key、数据版本、发生时间和追踪 ID,便于排查重复、乱序和过期事件。旧事件即使只造成一次多余删除,也可能带来缓存击穿和额外回源流量。
7.4 进程在延迟期间宕机
如果二删任务只存在本机内存中,应用在延迟期间重启,任务将永久丢失。线程池无法解决持久化问题。这也是生产环境更倾向于延迟消息、Outbox 任务表或 Binlog 订阅的主要原因。
7.5 Redis 暂时不可用
Redis 故障时不能无限重试,否则故障会从缓存层扩散到业务线程和消息消费者。应限制单次重试次数,结合熔断、限流和告警;读请求是否降级到数据库,要根据数据库容量决定,不能让缓存流量瞬间击穿数据库。
八、生产环境常见改造方案
8.1 可靠延迟消息
数据库事务提交后发送一条延迟消息,消费者到期后删除缓存。消费失败时由消息系统重投,超过阈值进入死信队列。
难点是“数据库事务提交”和“消息发送”仍不是天然原子操作。如果数据库提交成功但消息未发出,二删依旧会丢失。可根据基础设施选择事务消息,或采用 Outbox 模式:在同一个数据库事务中同时更新业务表并写入事件表,再由后台任务可靠投递事件。
begin transaction
update product
insert cache_invalidation_event
commit
event publisher -> message queue -> delayed consumer -> delete Redis key
8.2 订阅 Binlog
另一种方式是通过 Canal 等组件订阅 MySQL Binlog。数据库变更提交后,订阅者根据表名、主键和变更类型删除相应缓存。
优点是缓存失效与业务写代码解耦,多个写入口不容易漏删。缺点是引入额外基础设施,需要处理 Binlog 位点、事件重复、消费积压、表结构变化和 Key 映射。它仍然是异步链路,因此属于最终一致性。
8.3 分布式锁或串行化控制
可以让同一业务 Key 的读写在分布式锁下串行执行,从源头缩小“读旧值后回填”的窗口。但锁会增加网络往返和请求延迟,还要处理锁超时、续约、误释放和热点 Key 竞争。
如果业务要求极严格的一致性,更直接的选择可能是关键读取绕过缓存,或把强一致操作限制在数据库事务内。不要为了保留缓存而引入比缓存收益更高的复杂度。
8.4 方案对比
| 方案 | 一致性能力 | 性能影响 | 实现成本 | 故障恢复 | 适用场景 |
|---|---|---|---|---|---|
| 延时双删 | 概率性改善最终一致性 | 较低 | 低 | 依赖额外补偿 | 允许短暂不一致、改造成本敏感 |
| 可靠延迟消息 | 最终一致性较可靠 | 异步,业务线程影响较小 | 中到高 | 重试、死信、重放 | 已有成熟 MQ 基础设施 |
| Canal/Binlog | 数据库提交后最终一致 | 异步链路有延迟 | 高 | 位点恢复、重复消费 | 写入口多、希望统一失效缓存 |
| 分布式锁 | 可缩小并发窗口 | 延迟和吞吐损耗较大 | 中到高 | 需要完整锁治理 | 单 Key 竞争可控、强一致诉求较高 |
| 关键读绕过缓存 | 依赖数据库一致性 | 数据库压力增加 | 低到中 | 数据库自身机制 | 少量强一致核心数据 |
不存在通用最优方案。选择时要同时评估允许的不一致时长、读写比例、热点程度、数据库容量、基础设施成熟度和故障处置能力。
九、如何验证延时双删真的生效
普通单元测试很难稳定复现并发竞态。可以使用 CountDownLatch 或测试钩子人为控制顺序:
- 清空目标缓存;
- 让读线程查询数据库得到旧值,然后在回填前等待;
- 执行写线程,完成第一次删除和数据库提交;
- 放行读线程,使其把旧值写入 Redis;
- 不执行二删时,断言 Redis 中仍是旧值;
- 执行延迟二删后,断言 Redis Key 不存在;
- 再次查询,断言缓存由数据库新值重建。
除了正常时序,还应验证三类故障:
- 调度器拒绝或消息发送失败后,事件是否进入可靠补偿;
- Redis 超时后,重试是否有次数限制和退避;
- 应用在延迟期间重启后,持久化任务是否仍能被消费。
监控至少应包含二删任务提交量、执行成功量、失败量、重试量、死信量、最长积压时间,以及缓存值版本落后于数据库版本的抽样指标。没有监控的“最终一致性”只是愿望,不是可验证的工程属性。
十、高频追问与参考回答
10.1 什么是延时双删?
延时双删是在写请求中先删除缓存,再更新数据库,数据库事务提交后等待一段时间并再次删除缓存。第一次删除用于失效已有旧缓存,第二次删除用于清理并发读请求可能回填的旧值。它提高最终一致性的概率,但不能保证强一致性。
10.2 为什么删除缓存,而不是直接更新缓存?
并发写直接更新缓存可能发生执行顺序倒置,使旧版本覆盖新版本。删除缓存后按需从数据库重建,逻辑更简单,也避免为暂时不会被读取的数据提前构建缓存。但删除失败仍需重试和补偿。
10.3 为什么先删除缓存,再更新数据库?
这是延时双删的定义之一。第一次删除让写请求开始后的读请求不再直接命中旧缓存;随后二删用于清理更新期间可能回填的旧值。不过它会引入“删缓存后、数据库提交前”的旧值回填窗口,所以必须结合第二次删除理解,不能只看第一删。
10.4 延迟时间应该设置多久?
没有通用固定值。延迟应覆盖缓存未命中后从数据库查询到写回缓存的完整高分位耗时,并留出调度、GC 和网络抖动余量。最终值要通过监控和压测确定,并持续观察二删任务积压与旧值存活时间。
10.5 第二次删除失败怎么办?
先对瞬时故障做有限重试,仍失败则进入持久化重试队列或任务表,超过阈值进入死信队列并告警。缓存删除是幂等操作,适合至少一次投递,但事件仍应携带业务 Key、版本、事件 ID 和追踪信息。
10.6 延时双删能保证强一致性吗?
不能。如果一个读请求在读取旧值后阻塞的时间超过二删延迟,它仍可能在第二次删除之后回填旧值;二删任务本身也可能因宕机或 Redis 故障而失败。延时双删是最终一致性优化,不是跨 MySQL 与 Redis 的分布式事务。
10.7 为什么不能直接使用 Thread.sleep()?
Thread.sleep() 会占用业务请求线程而不做有效工作。高并发下大量睡眠线程会耗尽线程池,放大延迟和超时。演示时可以用调度器表达“稍后执行”,生产环境则更适合持久化延迟消息或任务表。
10.8 使用 ScheduledExecutorService 就够了吗?
不够。进程内任务会因应用重启丢失,默认延迟队列还可能无限增长。它适合演示时序或低风险场景;需要可靠性时,应使用可持久化任务、延迟消息或 Binlog 订阅,并配套重试、死信和告警。
10.9 什么时候考虑 Canal 或 Binlog?
当同一数据有多个写入口、业务代码容易漏删缓存,或者希望统一治理缓存失效时,可以考虑订阅 Binlog。代价是增加基础设施和运维复杂度,还要处理位点恢复、重复事件、消费积压和表到缓存 Key 的映射。
10.10 如果业务必须强一致怎么办?
先确认缓存是否适合承载这类数据。对余额、库存扣减结果等关键读取,可以绕过缓存、缩短缓存参与范围,或在同一业务 Key 上引入串行化控制。强一致需要明确的协调成本,不能靠增加一个固定延迟获得。
十一、适用场景与不适用场景
延时双删比较适合:
- 读多写少,缓存收益明显;
- 允许短时间读到旧值;
- 希望以较低改造成本改善最终一致性;
- 已设置合理 TTL,并具备基础重试和监控。
延时双删不适合:
- 余额、支付状态等必须严格一致的核心数据;
- 热点 Key 写入频繁,延迟删除任务会大量积压;
- 无法接受任何旧值窗口;
- 系统没有重试、告警和故障恢复能力,却把二删当成唯一保障。
十二、总结
- 定义:先删缓存,更新数据库,事务提交后延迟再删一次缓存。
- 原理:第二次删除清理的是并发读请求可能回填的旧值。
- 延迟:根据完整缓存回填链路的高分位耗时估算,不背固定数字。
- 可靠性:二删失败必须重试、持久化补偿、告警,进程内线程池不可靠。
- 边界:延时双删改善最终一致性,但无法保证强一致性。
一个成熟的回答不应该停在“睡眠后再删一次”。真正的重点是能否还原竞态时序、说明失败补偿,并知道什么时候应换成可靠消息、Binlog 订阅、锁或直接绕过缓存。缓存一致性没有免费的答案,只有与业务容忍度相匹配的工程取舍。

1280

被折叠的 条评论
为什么被折叠?



