Skip to content

如何更新缓存

更新缓存的步骤特别简单,共两步: 更新数据库和更新缓存。
但这简单的两步中需要考虑很多问题。

(1) 先更新数据库还是先更新缓存?更新缓存时先删除还是直接更新?
(2) 假设第一步成功了,第二步失败了怎么办?
(3) 假设两个线程同时更新同一个数据,A 线程先完成第一步,B 线程先完成第二步怎么办?

其中,第 1 个问题就存在 5 种组合方案,下面逐一进行介绍(以上 3 个问题因为紧密关联,无法单独考虑,下面就一起说明)

组合1:先更新缓存,再更新数据库

对于这个组合,会遇到这种情况: 假设第二步更新数据库失败了,要求回滚缓存的更新,这时该怎么办呢?
Redis 不支持事务回滚,除非采用手工回滚的方式,先保存原有数据,然后再将缓存更新回原来的数据,这种解决方案有些缺陷。

这里简单举个例子

(1) 原来缓存中的值是 a,两个线程同时更新库存
(2) 线程 A 将缓存中的值更新成 b,且保存了原来的值 a,然后更新数据库
(3) 线程 B 将缓存中的值更新成 c,且保存了原来的值 b,然后更新数据库
(4) 线程 A 更新数据库时失败了,它必须回滚,那现在缓存中的值更新成什么呢?理论上应该更新成 c,因为数据库中的值是 c,但是,线程 A 里面无从获得 c 这个值

如果在线程 A 更新缓存与数据库的整个过程中,先把缓存及数据库都锁上,确保别的线程不能更新,是否可行?
当然是可行的。但是其他线程能不能读取?

假设线程 A 更新数据库失败回滚缓存时,线程 C 也加入进来,它需要先读取缓存中的值,这时又返回什么值?

看到这个场景,是不是有点儿熟悉?不错,这就是典型的事务隔离级别场景
所以就不推荐这个组合,因为此处只是需要使用一下缓存,而这个组合就要考虑事务隔离级别的一些逻辑,成本太大。接着考虑别的组合。

组合2:先删除缓存,再更新数据库

使用这种方案,即使更新数据库失败了也不需要回滚缓存。这种做法虽然巧妙规避了失败回滚的问题,却引出了两个更大的问题。

(1) 假设线程 A 先删除缓存,再更新数据库。
在线程 A 完成更新数据库之前,后执行的线程 B 反而超前完成了操作,读区 key 发现没有数据后,将数据库中的旧值存放到了缓存中。
线程 A 在线程 B 都完成后再更新数据库,这样就会出现缓存(旧值)与数据库的值(新值)不一致的问题

(2) 为了解决一致性问题,可以让线程 A 给 key 加锁,因为写操作特别耗时,这种处理方法导致大量的读请求卡在锁中

以上描述的是典型的高可用和一致性难以两全的问题,如果再加上分区容错就是 CAP(一致性 Consistency、可用性 Availability、分区容错性 Partition Tolerance) 了

组合3:先更新数据库,再更新缓存

对于组合 3,同样需要考虑两个问题。

(1) 假设第一步(更新数据库)成功,第二步(更新缓存)失败了怎么办?
因为缓存不是主流程,数据库才是,所以不会因为更新缓存失败而回滚第一步对数据库的更新。
此时一般采取的做法是重试机制,但重试机制如果存在延时还是会出现数据库与缓存不一致的情况,不好处理

(2) 假设两个线程同时更新同一个数据,线程 A 先完成了第一步,线程 B 先完成了第二步怎么办?
线程 A 把值更新成 a,线程 B 把值更新成 b,此时数据库中的最新值是 b,因为线程 A 先完成了第一步,后完成第二步,所以缓存中的最新值是 a,数据库与缓存的值还是不一致,这个逻辑还是有问题的

因此,也不建议采用这个组合

组合4:先更新数据库,再删除缓存

针对组合 4,先看看它能不能解决组合 3 的第二个问题。

假设两个线程同时更新同一个数据,线程 A 先完成第一步,线程 B 先完成第二步怎么办?

线程 A 把值更新成 a,线程 B 把值更新成 b,此时数据中的最新值是 b,因为线程 A 先完成了第一步,所以第二步谁先完成已经不重要了,因为都是直接删除缓存数据。这个问题解决了。

那么,它能解决组合 3 的第一个问题吗?假设第一步成功,第二步失败了怎么办?

这种情况的出现概率与组合 3 相比明显低不少,因为删除比更新容易多了。
虽然这个组合方案不完美,但出现一致性问题的概率较低

除了组合 3 会碰到的问题,组合 4 还会碰到别的问题吗?

是的。假设线程 A 要更新数据,先完成第一步更新数据库,在线程 A 删除缓存之前,线程 B 要访问缓存,那么取得的就是旧数据。这是一个小小的缺陷。

那么,以上问题有办法解决吗?

组合5:先删除缓存,更新数据库,再删除缓存

还有一个方案,就是先删除缓存,再更新数据库,再删除缓存。
这个方案其实和先更新数据库,再删除缓存差不多,因为还是会出现类似的问题: 假设线程 A 要更新数据库,先删除了缓存,这一瞬间线程 C 要读缓存,先把数据迁移到缓存;
然后线程 A 完成了更新数据库的操作,这一瞬间线程 B 也要访问缓存,此时它访问到的就是线程 C 放到缓存里面的旧数据

不过组合 5 出现类似问题的概率更低,因为要刚好有 3 个线程配合才会出现问题(比先更新数据库,再删除缓存的方案多了一个需要配合的线程)

但是相比于组合 4,组合 5 规避了第二步删除缓存失败的问题 -- 组合 5 是先删除缓存,再更新数据库,假设它的第三步 "再删除缓存" 失败了,也没关系,因为缓存已经删除了。

其实没有一个组合是完美的,它们都有读到脏数据(这里指旧数据)的可能性,只不过概率不同。
根据以上分析,组合 5 相对来说是比较好的选择

不过这个组合也有一些问题要考虑,具体如下。

(1) 删除缓存数据后变相出现缓存击穿

(2) 删除缓存失败如何重试?这个重试可以做得复杂一点,也可以做得简单一点。
简单一点就是使用 try...catch...,假设删除缓存失败了,在 catch 里面重试一次即可;
复杂一点就是使用一个异步线程不断重试,甚至用到 MQ。
不过这里没有必要大动干戈。而且异步重试的延时大,会带来更多的读脏数据的可能性。所以仅仅同步重试一次就可以了。

(3) 不可避免的脏数据问题。虽然这个问题在组合 5 中出现的概率已经大大降低了,但是还是有的。
关于这一点就需要与业务沟通,毕竟这种情况比较少见,可以根据实际业务情况判断是否需要解决这个瑕疵

Tips:
任何一个方案都不是完美的,但如果剩下 1% 的问题需要花好几倍的代价去解决,从技术上来讲得不偿失,这就要求架构师去说服业务方,去平衡技术的成本和收益