谈谈缓存风险

233 阅读8分钟

缓存污染

缓存污染是指缓存中的数据与真实数据源中的数据不一致的现象。尽管缓存通常不追求强一致性,但是不能等同于缓存和数据源之间的最终的一致性都可以不保证了。

谈谈一致性

一致性在不同的不同的场景有着不同的含义,例如:

  • 一致性哈希是某些系统用于动态分区再平衡的方法。
  • ACID 中,一致性主要是指数据库处于应用程序所期待的“预期状态”。
  • CAP 中,一致性等同于所有节点访问同一份最新的数据副本。

在这里可以先简单理解为 mysqlredis 中的数据保持一致。

三个经典的缓存模式

一般我们是如何使用缓存呢?有三种经典的缓存模式:

  • Cache-Aside Pattern
  • Read Through/Write through
  • Write behind

Cache-Aside Pattern

Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。

Cache-Aside Pattern 的读请求流程如下:

cacheside.png

  • 读的时候,先读缓存,缓存命中的话,直接返回数据。
  • 缓存没有命中的话,则去读数据库,从数据库取出数据,放入缓存后,同时返回响应。

Cache-Aside Pattern 的写请求流程如下: write.png

更新的时候,先更新数据库,然后再删除缓存

Read Through/Write Through

在上面的 Cache Aside 更新模式中,应用代码需要维护两个数据存储,一个是缓存,一个是数据库。而在Read/Write Through 更新模式中,应用程序只需要维护缓存,数据库的维护工作由缓存代理了。 Read Through 的请求流程如下: Read Through 模式就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside 模式是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载。 Write Through 的请求流程如下: Write Through 模式和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库(这是一个同步操作)。

Write behind

Write Behind Caching 更新模式就是在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是直接操作内存速度快。因为异步,Write Behind Caching 更新模式还可以合并对同一个数据的多次操作到数据库,所以性能的提高是相当可观的。

但其带来的问题是,数据不是强一致性的,而且可能会丢失。另外,Write Behind Caching 更新模式实现逻辑比较复杂,因为它需要确认有哪些数据是被更新了的,哪些数据需要刷到持久层上。只有在缓存需要失效的时候,才会把它真正持久起来。

删除缓存还是更新缓存

从上面我们看到 Cache Aside 在更新时是删除缓存,大家可能会有疑问为什么不直接更新缓存呢。根据人们的直觉来说,更新缓存似乎更加合理,但是从数据安全的角度,更新缓存会存在一些致命的问题。

error.png 通过上图我们可以看到如果采用的是更新缓存的方式,数据库中的值为 B,而缓存中的值 A,这就造成了数据库与缓存的数据不一致现象,所以删除缓存是一种比较常用的选择。

先操作数据库还是先操作缓存

在上一小节我们分析了是要删除缓存还是更新缓存,那么更新数据库和删除缓存这两个的操作的先后顺序有什么需要注意的吗。

如果在单线程的情况下或者说这两个操作可以合并为一个原子性操作,对先后顺序也没什么要求,但是在多线程的情况下,会出现数据不一致的问题。 dberror.png 通过上图我们看到缓存里面的值是旧值,数据库与缓存中的数据不一致。所以应该先更新数据库然后再删除缓存。

缓存穿透

缓存的目的是为了缓解 CPU 或者 I/O 的压力,比如对数据库做缓存,大部分请求都从缓存中直接返回,只有缓存未能命中的数据请求才会流到数据库中,这样数据库的压力会小很多。如果查询的数据在数据库中不存在的话,缓存中自然也不会有,这类请求的流量每次都会流到末端的数据库,缓存就起不到缓解压力的作用了,这种查询不存在数据的现象被称为缓存穿透

缓存穿透有可能是业务逻辑本身就存在的固有问题,也有可能是被恶意攻击请求不存在的数据所导致,为了解决缓存穿透,通常会采取下面两种办法:

  • 对于业务逻辑本身就不能避免的缓存穿透,可以约定在一定时间内对返回为空的 Key 值依然进行缓存(注意是正常返回但是结果为空,不应把抛异常的也当作空值来缓存了),使得在一段时间内缓存最多被穿透一次。如果后续业务在数据库中对该 Key 值插入了新记录,那应当在插入之后主动清理掉缓存的 Key 值。如果业务时效性允许的话,也可以将对缓存设置一个较短的超时时间来自动处理。

  • 对于恶意攻击导致的缓存穿透,通常会在缓存之前设置一个布隆过滤器来解决。所谓恶意攻击是指请求者刻意构造数据库中肯定不存在的 Key 值,然后发送大量请求进行查询。布隆过滤器是用最小的代价来判断某个元素是否存在于某个集合的办法。如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。虽然维护布隆过滤器本身需要一定的成本,但比起攻击造成的资源损耗仍然是值得的。

缓存击穿

缓存的基本工作原理是首次从真实数据源加载数据,完成加载后回填入缓存,以后其他相同的请求就从缓存中获取数据,缓解数据源的压力。如果缓存中某些热点数据忽然因某种原因失效了,譬如典型地由于超期而失效,此时又有多个针对该数据的请求同时发送过来,这些请求将全部未能命中缓存,都到达真实数据源中去,导致其压力剧增,这种现象被称为缓存击穿。要避免缓存击穿问题,通常会采取下面的两种办法:

  • 加锁同步,以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。如果是进程内缓存出现问题,施加普通互斥锁即可,如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。
  • 热点数据不设置过期时间由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。

缓存雪崩

缓存击穿是针对单个热点数据失效,由大量请求击穿缓存而给真实数据源带来压力。有另一种可能是更普遍的情况,不需要是针对单个热点数据的大量请求,而是由于大批不同的数据在短时间内一起失效,导致了这些数据的请求都击穿了缓存到达数据源,同样令数据源在短时间内压力剧增。

出现这种情况,往往是系统有专门的缓存预热功能,也可能大量公共数据是由某一次冷操作加载的,这样都可能出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。还有一种情况是缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效,这种现象被称为缓存雪崩。要避免缓存雪崩问题,通常会采取下面的三种办法:

  • 提升缓存系统可用性,建设分布式缓存的集群。
  • 启用透明多级缓存,各个服务节点一级缓存中的数据通常会具有不一样的加载时间,也就分散了它们的过期时间。
  • 将缓存的生存期从固定时间改为一个时间段内的随机时间,譬如原本是一个小时过期,在缓存不同数据时加减一段五分钟内的随机时时间,来避免同一时间失效。