1. 概念
缓存不一致问题是指当发生数据变更后该数据在数据库和缓存中是不一致的,此时查询缓存得到的并不是与数据库一致的数据。
1.1. 缓存不一致会导致什么后果?
比如:查看商品信息的价格与真实价格不一致,影响用户体验,如果直接使用缓存中的价格去计算订单金额更会导致计算结果错误。
造成缓存不一致的原因可能是在写数据库和写缓存两步存在异常,也可能是并发所导致。
写数据库和写缓存导致不一致称为双写不一致,比如:先更新数据库成功了,更新缓存时失败了,最终导致不一致。并发导致缓存不一致举例如下:
执行流程(后来的线程先执行完):
- 线程1先写入数据库X,当去写入缓存X时网络卡顿
- 线程2先写入数据库Y
- 线程2再写入缓存Y
- 线程1 写入缓存旧值X覆盖了新值Y
即使先写入缓存再写数据在并发环境也可能存在问题,如下图:
流程:
线程1先写入缓存X,当去写入数据库X时网络卡顿
线程2先写入缓存Y
线程2再写入数据库Y
线程1 写入数据库旧值X覆盖了新值Y
2. 解决方案
2.1. 使用分布式锁
流程:
- 线程1申请分布式锁,拿到锁。此时其它线程无法获取同一把锁。
- 线程1写数据库,写缓存,操作完成释放锁。
- 线程2申请分布锁成功,写数据库,写缓存。
- 对双写的操作每个线程顺序执行。
对操作异常问题仍需要解决:写数据库成功写缓存失败了,数据库需要回滚,此时就需要使用分布式事务组件。
使用分布式锁解决双写一致性不仅性能低下,复杂度增加。
2.2. 延迟双删
既然双写操作存在不一致,我们把写缓存改为删除缓存呢?
先写数据库再删除缓存,如果删除缓存失败了缓存也就不一致了,那我们改为:先删除缓存再写数据库,如下图:
执行流程:
- 线程1删除缓存
- 线程2读缓存发现没有数据此时查询数据库拿到旧数据写入缓存
- 线程1写入数据库
即使线程1删除缓存、写数据库操作后线程2再去查询缓存也可能存在问题,如下图:
线程1向主数据库写,线程2向从数据库查询,流程如下:
- 线程1删除缓存
- 线程1向主数据库写,数据向从数据库同步
- 线程2查询缓存没有数据,查询从数据库,得到旧数据
- 线程2将旧数据写入缓存
解决上边的问题采用延迟双删:
线程1先删除缓存,再写入主数据库,延迟一定时间再删除缓存。
上图线程1的动作简化为下图:
延迟多长时间呢?
延迟主数据向从数据库同步的时间间隔,如果延迟时间设置不合理也会导致数据不一致。
延迟时间设置的不合理,可能会带来更多问题
- 过短:脏数据
- 过长:更新失效不及时
2.3. 异步同步
延迟双删的目的也是为了保证最终一致性,即允许缓存短暂不一致,最终保证一致性。
保证最终一致性的方案有很多,比如:通过MQ、Canal、定时任务都可以实现。
Canal是一个数据同步工具,读取MySQL的binlog日志拿到更新的数据,再通过MQ发送给异步同步程序,最终由异步同步程序写到redis。此方案适用于对数据实时性有一定要求的场景。
通过Canal加MQ异步任务方式流程如下:
流程如下:
- 线程1写数据库
- canal读取binlog日志,将数据变化日志写入mq
- 同步程序监听mq接收到数据变化的消息
- 同步程序解析消息内容写入redis,写入redis成功正常消费完成,消息从mq删除。
定时任务方式流程如下:
专门启动一个数据同步任务定时读取数据同步到redis,此方式适用于对数据实时性要求不强更新不频繁的数据。
线程1写入数据库(业务数据表,变化日志表)
同步程序读取数据库(变化日志表),根据变化日志内容写入redis,同步完成删除变化日志。
3. 面试话术
3.1. 如何实现数据库与缓存数据一致?
实现方案有下面几种:
- 本地缓存同步:当前微服务的数据库数据与缓存数据同步,可以直接在数据库修改时加入对Redis的修改逻辑,保证一致。
- 跨服务缓存同步:服务A调用了服务B,并对查询结果缓存。服务B数据库修改,可以通过MQ通知服务A,服务A修改Redis缓存数据(推荐)
- 通用方案:使用Canal框架,伪装成MySQL的salve节点,监听MySQL的binLog变化,然后修改Redis缓存数据