在应用系统中,使用缓存不算非常难的事,但是设计好一套缓存策略比较麻烦,这样既能起到好的缓存效果也能在合适的时候更新缓存。

今天我们聊聊缓存。

不过,在计算机科学中,有很多种缓存。我们先聊聊缓存的类型,再谈应用系统中的缓存。

我们会涉及哪些缓存知识?

这里我把缓存分为两类:

  • 非应用缓存:难以被程序员干预、控制和使用的缓存,但是我们能通过选择合适的数据结构,写出缓存友好的代码。
  • 应用缓存:容易被程序员干预、控制和使用的缓存。

非应用缓存有:

  • CPU 缓存。例如,使用数组的 index 取数会比指针取数更快,相关指令更容易被 CPU 缓存。
  • 磁盘缓存。尽量通过流的方式来读取磁盘文件,另外也应该避免不必要的 flush 操作。
  • 数据库缓存。数据库常见的缓存有 SQL 缓存和查询缓存,前者只是缓存 SQL 的解析结果,后者会把结果集也缓存下来,一般需要手动开启。
  • ORM 缓存。ORM 会在同一会话中对多次查询进行缓存。
  • 网关缓存或者服务商缓存。例如,Nginx 等网络服务器缓存,经常会出现一些问题。
  • DNS 缓存。DNS 解析也会被缓存下来。
  • CDN 缓存。通过网络服务商城域网对静态资源进行缓存。

非应用缓存通常不会侵入业务,比较透明,我们需要知道它们存在必要时做出配置。另外,对程序员来说更多的关心应用缓存,这些缓存往往和业务相关。

应用缓存有:

  • 内存本地缓存。我们可以使用 WeakHashMap、Guava、Caffeine、EVcache 等方法直接缓存到内存。甚至直接放到对象静态属性上作为单例值。违反认知的地方是,很多地方应该大量使用本地缓存,避免一上来直接使用 Redis 缓存。它的适用场景有:不需要多个实例保持一致性,缓存数据量大,不需要外部失效,需要高速存取;单次请求或者上下文中的数据。
  • 分布式缓存。分布式缓存使用的场景是需要多个实例保持一致性,且规模比较大避免占用应用服务的内存。常见的技术方案有:Redis、Memcached、Tair等。
  • 计算缓存。除了对数据进行缓存外,还可以对计算结果进行缓存,用了节省 CPU 时间。例如递归的记忆化。
  • 前端缓存。利用设置 HTTP 头信息将数据缓存到浏览器上。

通常来说,分布式缓存需要一套有效的缓存策略。回答:缓存哪种类型的对象?缓存的颗粒度如何?什么时候去更新?

缓存对象颗粒度

对于后端服务来说,根据分层会有不同的 POJO(API 返回对象、领域对象、数据库 PO),我们缓存什么呢? 实际项目中这几种情况都会有。

为了取得最好的缓存效果(命中率高,手动失效少),需要权衡被缓存对象的颗粒度。

缓存 API 返回对象(Response)

如果以 Response 为粒度,其实是以用例为视角。比如订单详情,需要组装非常多的数据,且变化不剧烈。

特点是:

  • 颗粒度大,缓存的效果好(纳入缓存的内容多,包括数据和计算逻辑)。
  • 命中概率低,组成 key 的条件太多。
  • 更新策略不好控制,开发难度比较大,需要加很多代码,需要看业务是否能接受,举个例子:用户详情,使用用户 ID 做 Key,相关地方都需要手动刷新,例如地址、积分、消费记录等。
  • 部分电商公司使用该方案,互联网场中景,用户基数比较大,并发请求高,取数代价高。
  • 缓存 key 一般是 URL 中关键路径信息。

缓存领域对象聚合

如果使用 DDD 分层,有聚合概念,可以以聚合粒度缓存。

其特点是:

  • 颗粒度适中,缓存的效果适中。
  • 根据聚合根来控制缓存失效。
  • 部分数据可能不会被纳入缓存,因为组装为 Response 的过程不会被缓存。
  • 依赖 DDD 的取数逻辑,有时候为整存整取。
  • 缓存 key 一般是聚合根 ID。

缓存数据库 PO(Persistent Object)

如果使用了 Spring Data JPA 本质上 PO 在内部被框架实现了,可以自动开启,并按照约定更新缓存。

如果使用 Mybatis、Mybatis Plus 一般会定义自己的 PO 对象,所以可以单独处理缓存策略。

它的特点是:

  • 颗粒度小,缓存的效果小。
  • 可以借助 ORM 框架缓存,Session 内多次获取,可以避免再查询,需要开启二级缓存。
  • 缓存 key 一般是表的主键。

一般来说,缓存颗粒度越小,失效策略越好处理,但是缓存住的数据和逻辑就越少,在有些场景下不能满足我们的期望。

另外,还有一些特殊场景的缓存。

  • 列表页查询缓存。因为很难触发更新策略,一般不加缓存,直接走读库 CQRS 模式,或者走 ES,推荐 ES 的更新策略为 COW(Copy On Write)。在条件业务允许时,也可以根据查询条件做很短过期的查询。
  • 大 key。大 key 存在反而会导致性能瓶颈,业界 10k 以上会被叫做大 key,需要对 key 进行拆分缓存即可。
  • 写缓存。一般不对写进行缓存,但是一些高并发的场景,会将写数据放入 Redis 异步写入,也可以看做一种缓存。

一般来说:推荐使用聚合缓存;列表不缓存,使用读写分离从库查询;更新均不做缓存,只对单个查缓存。

缓存设计注意事项

缓存雪崩、缓存击穿、缓存穿透

缓存雪崩是指在某一个时刻突然缓存都失效了。原因有两种,一种是缓存服务器宕机了,流量全部进入数据库;另外一种情况是在同一时刻失效了。

对于前者可以通过熔断、高可用等设计,而后者需要对缓存过期时间加一个随机偏移值,避免同时失效。

缓存击穿和雪崩有点类似,业界往往说的是系统健康运行依赖某些热点 key 的缓存,当这些热点 key 失效后流量全部打到数据库上。

为了保证热点 key 安全,在一些关键系统甚至会用多套 Redis 分级处理,或者将其设置为永不过期,通过程序触发更新的方式保证服务可用性。这种思想有点以前 CMS 站点的静态化,将动态页面输出为 HTML 页面静态化。

缓存穿透常常说的是,明明有 Redis 缓存但是偏偏大部分都不能命中进入到数据库。有时候是因为 key 设计不合理,导致命中率很低,其它情况有可能是遇到爬虫或者攻击,制造了大量的无效参数,这些参数不会命中缓存直接进入了数据库查询阶段。

如果频繁发生缓存穿透,刚好条件又合适可以采用返回空对象,避免回源到数据库。

缓存更新策略

我们一般不会主动更新缓存,而是让其失效,在下一次取数时如果没有缓存则更新缓存。

缓存更新在不同场景下有几种策略:

  • 自然过期:通过时间作为自然过期策略。
  • 主动失效:进程内可以通过注解实现,在合适的更新场景触发相关缓存失效;进程间可以通过 MQ 实现封装一个分布式的失效注解。
  • 主动预热:使用脚本,在服务上线后跑一遍热点数据进行预热。

需要缓存的常见场景

  • 热点数据:用户信息、权限数据、配置表或者元数据。
  • 高价值数据:目录树、机构树、DashBoard 统计值。
  • 大 I/O 数据:前端缓存。

序列化和反序列化坑

  • 不推荐使用 Java 自带的序列化。
  • 推荐将对象序列化为 JSON,但是不要使用带类型信息的格式,否则在包调整后反序列化会失败。
  • 上线后最好清掉缓存,重新预热,否则会出现各种反序列化问题。

如何写出方便缓存的代码?

缓存友好的代码,其本质是容易找到一个标识标记这组数据,这也是为什么列表页不适合缓存的原因。

  • 命令和查询分离,对状态更新的操作和返回数据结果的操作不要使用同一个方法。
  • 少用魔法,尽量不使用 IOP 自动填充值或者组装数据
  • 尽量使用参数表传参,少用对象传参,这样方便找到缓存 key

参考资料

  • Java演示CPU级的缓存效果 https://blog.csdn.net/JavaMonsterr/article/details/125147238
  • 什么是缓存雪崩、缓存击穿、缓存穿透?https://zhuanlan.zhihu.com/p/346651831
  • Extension to the DDD skeleton project: caching in the service layer https://dotnetcodr.com/2014/03/24/extension-to-the-ddd-skeleton-project-caching-in-the-service-layer/
  • 8.10.3 The MySQL Query Cache https://dev.mysql.com/doc/refman/5.7/en/query-cache.html
  • Buffer cache: What is it and how does it impact database performance? https://blog.quest.com/buffer-cache-what-is-it-and-how-does-it-impact-database-performance/
  • Webinar 笔记 http://shaogefenhao.com/libs/webinar-notes/java-solution-webinar-25.html
Last Updated:
Contributors: lin