写DB再删除缓存,结果Redis宕机?
作者:程序员马丁
热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。
回答话术
一般来说,为了避免因执行 Redis 操作而引起的性能问题对 MySQL 数据库造成事务长时间不提交等影响,我们会将 Redis 的执行操作置于数据库事务之外。
我猜测大部分同学在实际写代码中,可能会把缓存和数据库事务都放一起了,通常不建议这么做,具体原因看下文讲解。
因为数据库事务已经提交了,所以删除 Redis 缓存操作即使失败,也不能让接口操作返回失败。Redis 操作缓存失败有以下几种情况:
- 客户端执行 Redis 操作超时,这个超时通常由客户端设置。导致超时的情况有很多种,比如 Redis 节点主线程执行某个流程阻塞,就有可能导致大量操作超时。
- 获取不到 Redis 连接,因为单个 Redis 节点的连接有限,如果用完了则无法获取,进而报错。
- 如果 Redis 服务器宕机或网络不可用,将导致无法执行任何 Redis 操作。
- 由于网络故障或延迟,可能导致 Redis 操作失败。
以上几个,在面试中说出 1-2 个即可,说太多也没啥用,主要就是突出执行操作会失败。
我们可以通过以下几种解决方案完成缓存和数据库的最终一致性。
1. 设置缓存 Key 有效期
设置 Redis 中的 Key 较短的有效期,这个有效期不能太长,会直接影响到用户看到脏数据的时间。
这种解决方案是最简单的,不需要代码变更以及引入新的中间件技术等。不过也会带来一个问题,那就是 Redis 恢复了,但是不能马上解决数据问题,而是需要等过期时间。在这个期间,全靠数据库来做兜底。
除非 Redis 宕机又恢复后,有另一个相同操作变更了数据库,进而直接删除缓存。否则在这个期间缓存和数据库将无法保持一致。
2. 通过定时任务 + 数据库
定义数据库表 t_del_cache_event
,其中有两个字段,需要删除的 Key 字段 del_cache_key
,以及是否已执行字段 executed
。
当删除 Redis Key 报错时,在 catch 块中将该数据存入数据库,设置状态为未执行。然后项目中应该有个定时任务在执行扫描 t_del_cache_event
表,扫描的周期很有讲究,建议设置为预期让 Redis 服务恢复的时间。比如你觉得公司恢复 Redis 大概在 5 分钟,就可以设置为 5 分钟扫描一次。
执行删除缓存 Key 后,我们就可以设置是否执行字段 executed
为已执行,后续再定期删除。
该解决方案引入了定时任务和数据库,带来了一定的复杂性。不过相比上一个方案硬等 Key 自己过期,这个方案的能更及时的更新数据,保证数据的时效性。
3. 通过延时消息队列
和上面定时任务 + 数据库方式类似,都是通过在一段时间后执行删除 Redis Key 的行为。延时队列可以通过比如说 RocketMQ 的延时消息来解决,延迟时间同样定义为 Redis 初步估计恢复的时间。
这种方案不论从时效性还是最终一致性来说,都 比之前的两个方案要好。并且,如果项目本身就引入了支持延迟消息的消息队列,那实施成本就更低了。
问题详解
1. 为何不推荐将 Redis 操作包含在数据库事务中?
不止是 Redis 操作不建议包含在数据库事务,而是任何非数据库的三方行为,比如发送 MQ、操作对象存储或者远程调用等都不建议放在数据库事务中。
上文中提到过,Redis 可能会触发一些性能问题,导致访问 Redis 会比较慢。这样的话,会影响 MySQL 的事务提交,进而变成长事务。更严重的话,会导致连接被占用进而影响其他关联的数据库事务等。
2. 编程式事务
很多同学喜欢将事务注解放到 Service 方法上,这样的话省事省力。
@Service
public class OrderServiceImpl implements OrderService {
@Transactional(rollbackFor = Exception.class)
@Override
public void createOrder(OrderCreateReqDTO requestParam) {
// 创建订单
// 创建商品子订单
// xxxxxx
// 发送 MQ 延迟关闭订单
// 发送用户下单消息
}
}
但是一个方法可能会执行 N 多的流程,如果说方法中不仅操作了数据库,还有一些其它可能会耗时的行为,都会对数据库事务产生影响。
在这种情况下,我推荐大家使用编程式事务,举个例子:
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private TransactionTemplate transactionTemplate;
@Override
public void createOrder(OrderCreateReqDTO requestParam) {
// 开启编程式事务
transactionTemplate.executeWithoutResult(status -> {
try {
// 创建订单
// 创建商品子订单
// xxxxxx
} catch(Exception ex) {
// 回滚数据库操作
status.setRollbackOnly();
throw ex;
}
}
// 发送 MQ 延迟关闭订单
// 发送用户下单消息
}
}
如果换成上述这种写法,就能比较好的隔离数据库事务和其他中间件的操作,即使对中间件操作出现问题,也不会影响到数据库事务的正常提交。
建议大家在生产代码中,多使用该模式,将事务的域降到最低。