一、延时双删是什么?
延迟双删(Delay Double Delete)是一种在数据更新或删除时为了保证数据一致性而采取的策略。这种策略通常用于解决数据在缓存和数据库中不一致的问题。
数据一致性:缓存和数据库一致性问题是指在应用系统中,缓存层(通常是内存中的缓存,如Redis、Memcached等)和数据库层(持久化存储,如MySQL、PostgreSQL等)之间的数据状态不一致的情况。这种情况通常发生在数据更新操作后,缓存中的数据没有及时更新或删除,导致用户读取到的数据不是最新的,从而影响系统的准确性和用户体验。
二、解决办法
那么我们该如何解决数据一致性问题呢?
首先我们需要明确数据一致性问题的主要原因是什么,从主要原因入手才是解决问题的关键!
数据一致性的根本原因是 缓存和数据库中的数据不同步,那么我们该如何让 缓存 和 数据库 中的数据尽可能的即时同步?这就需要选择一个比较好的缓存更新策略了
常见的缓存更新策略:
内存淘汰(全自动)。利用Redis的内存淘汰机制实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据
超时剔除(半自动)。手动给缓存数据添加TTL,到期后Redis自动删除缓存
主动更新(手动)。手动编码实现缓存更新,在修改数据库的同时更新缓存
双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。使用困难,灵活度高。
1)读取(Read):当需要读取数据时,首先检查缓存是否存在该数据。如果缓存中存在,直接返回缓存中的数据。如果缓存中不存在,则从底层数据存储(如数据库)中获取数据,并将数据存储到缓存中,以便以后的读取操作可以更快地访问该数据。
2)写入(Write):当进行数据写入操作时,首先更新底层数据存储中的数据。然后,根据具体情况,可以选择直接更新缓存中的数据(使缓存与底层数据存储保持同步),或者是简单地将缓存中与修改数据相关的条目标记为无效状态(缓存失效),以便下一次读取时重新加载最新数据
使用双写方案需要考虑以下几个问题: 是使用更新缓存模式还是使用删除缓存模式?
更新缓存模式:每次更新数据库都更新缓存,无效写操作较多(不推荐使用) 假如我们执行上百次更新数据库操作,那么就要执行上百次写入缓存的操作,而在这期间并没有查询请求,那么这上百次写入缓存的操作就显得没有什么意义
删除缓存模式:更新数据时更新数据库并删除缓存,查询时更新缓存,无效写操作较少(推荐使用) 选择使用删除缓存模式,那么是先操作缓存还是先操作数据库?
先操作缓存:先删缓存,再更新数据库。当线程1删除缓存到更新数据库之间的时间段,会有其它线程进来查询数据,由于没有加锁,且前面的线程将缓存删除了,这就导致请求会直接打到数据库上,给数据库带来巨大压力。这个事件发生的概率很大,因为缓存的读写速度块,而数据库的读写较慢。 这种方式的不足之处:存在缓存击穿问题,且概率较大
先操作数据库:先更新数据库,再删缓存。 当线程1在查询缓存且未命中,此时线程1查询数据,查询完准备写入缓存时,由于没有加锁线程2乘虚而入,线程2在这期间对数据库进行了更新,此时线程1将旧数据返回了,出现了脏读,这个事件发生的概率很低,因为先是需要满足缓存未命中,且在写入缓存的那段事件内有一个线程进行更新操作,缓存的查询很快,这段空隙时间很小,所以出现脏读现象的概率也很低。
值得注意的是,不管哪种方案,都避免不了Redis存在脏数据的问题,只能减轻这个问题,要想彻底解决,得要用到同步锁和对应的业务逻辑层面解决。
延时双删实现 在前面介绍到,先更新数据库后删Redis缓存是一致性相对最高的。这是就有人举手了:我就想要先删缓存怎么办?这时延时双删就出现了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。
三、延时双删的实现
/**
* 冻结用户
* @author Java小牛马
* @param userId
* @return
*/
@Transactional(rollbackFor = Exception.class)
public UserOperatorResponse freeze(Long userId) {
UserOperatorResponse userOperatorResponse = new UserOperatorResponse();
User user = userMapper.findById(userId);
Assert.notNull(user, () -> new UserException(USER_NOT_EXIST));
Assert.isTrue(user.getState() == UserStateEnum.ACTIVE, () -> new UserException(USER_STATUS_IS_NOT_ACTIVE));
//第一次删除缓存
idUserCache.remove(user.getId().toString());
if (user.getState() == UserStateEnum.FROZEN) {
userOperatorResponse.setSuccess(true);
return userOperatorResponse;
}
user.setState(UserStateEnum.FROZEN);
// 更新数据库
boolean updateResult = updateById(user);
Assert.isTrue(updateResult, () -> new BizException(RepoErrorCode.UPDATE_FAILED));
//加入流水
long result = userOperateStreamService.insertStream(user, UserOperateTypeEnum.FREEZE);
Assert.notNull(result, () -> new BizException(RepoErrorCode.UPDATE_FAILED));
//第二次删除缓存
userCacheDelayDeleteService.delayedCacheDelete(idUserCache, user);
userOperatorResponse.setSuccess(true);
return userOperatorResponse;
}
这里做一个详细介绍: 首先,代码先删除了 Redis 中的缓存数据,以确保接下来的读取操作会从数据库中读取最新的数据。 接着,代码更新了数据库中的数据,将数据更新为最新的值。 在此之后,代码让当前线程休眠一段时间N,这个时间段是为了给数据库操作足够的时间来完成,确保数据已经持久化到数据库中。 最后,代码再次删除 Redis 中的缓存数据。这里是延迟双删的关键步骤。由于之前已经删除了缓存数据,再次删除的目的是为了防止在 Thread.sleep(N) 的时间内有其他线程读取到旧的缓存数据。因为在这段时间内,缓存数据已经被清空,所以其他线程在读取数据时会发现缓存中不存在,然后从数据库中读取最新的数据并写入缓存,从而保证了数据的一致性。 需要注意的是,这种延迟双删策略并不能完全保证数据的一致性。 如果在 Thread.sleep(N) 的时间内发生了其他线程的写入操作,并且将新数据写入了缓存中,那么在第二次删除缓存时,会将这个新数据从缓存中删除,可能导致缓存和数据库中的数据不一致。 因此,延迟双删策略只能在一定程度上提高数据一致性的概率,但不能完全解决数据一致性的问题。更加严格的数据一致性保证需要使用更复杂的机制,比如使用消息队列等。
四、为什么要使用延时双删
在延时双删策略中,当需要更新数据库中的数据时,首先会先删除缓存,然后再进行数据库的更新操作。这样做的目的是为了避免在数据库更新的过程中,有其他请求读取了已经失效的缓存数据。 通过延时双删策略,可以保证在数据库更新期间,其他读取请求在缓存不命中的情况下,会直接读取数据库的最新数据,而不会读取到已经失效的缓存数据。这样就保证了数据的一致性和缓存的即时更新。 延时双删策略虽然会增加一次缓存删除的开销,但是可以有效地提高数据的一致性,并且在高并发读取的场景下,减轻数据库的读取压力,提高读取性能和响应速度。
五、方案选择
延时双删适用于对数据一致性要求较高的场景。它能够保证在数据库更新期间,读取请求不会读取到已经失效的缓存数据,从而保证数据的一致性。但是它需要进行两次缓存删除操作,可能会增加一定的资源开销;
先更新数据库后删除缓存适用于对一致性要求较低,对性能要求较高的场景。它能够减少一次缓存删除的开销,但是在数据库更新期间,读取请求可能会读取到已经失效的缓存数据,从而导致数据不一致。