cloudopt-next-cache 是基于 caffeine 和 lettuce 的分布式二级缓存插件。

目前缓存的解决方案一般有两种:

  • 内存缓存(如 caffeine ) —— 速度快,进程内可用。
  • 集中式缓存(如 Redis)—— 可同时为多节点提供服务。

现有的缓存框架已经非常成熟而且优秀,J2Cache 无心造一个新的轮子,它要解决的几个问题如下:

  1. 使用内存缓存时,一旦应用重启后,由于缓存数据丢失,缓存雪崩,给数据库造成巨大压力,导致应用堵塞。

  2. 使用内存缓存时,多个应用节点无法共享缓存数据。

  3. 使用集中式缓存,由于大量的数据通过缓存获取,导致缓存服务的数据吞吐量太大,带宽跑满。现象就是 Redis 服务负载不高,但是由于机器网卡带宽跑满,导致数据读取非常慢。

在遭遇问题1、2 时,很多人自然而然会想到使用 Redis 来缓存数据,因此就难以避免的导致了问题3的发生。

当发生问题 3 时,又有很多人想到 Redis 的集群,通过集群来降低缓存服务的压力,特别是带宽压力。

但其实,这个时候的 Redis 上的数据量并不一定大,仅仅是数据的吞吐量大而已。

咱们假设这样一个场景:

有这么一个网站,某个页面每天的访问量是 1000万,每个页面从缓存读取的数据是 50K。缓存数据存放在一个 Redis 服务,机器使用千兆网卡。那么这个 Redis 一天要承受 500G 的数据流,相当于平均每秒钟是 5.78M 的数据。而网站一般都会有高峰期和低峰期,两个时间流量的差异可能是百倍以上。我们假设高峰期每秒要承受的流量比平均值高 50 倍,也就是说高峰期 Redis 服务每秒要传输超过 250 兆的数据。请注意这个 250 兆的单位是 byte,而千兆网卡的单位是“bit” ,你懂了吗? 这已经远远超过 Redis 服务的网卡带宽。

所以如果你能发现这样的问题,一般你会这么做:

  1. 升级到万兆网卡 —— 这个有多麻烦,相信很多人知道,特别是一些云主机根本没有万兆网卡给你使用(有些运维工程师会给这样的建议)

  2. 多个 Redis 搭建集群,将流量分摊多多台机器上。

如果你采用第2种方法来解决上述的场景中碰到的问题,那么你最好准备 5 个 Redis 服务来支撑。在缓存服务这块成本直接攀升了 5 倍。你有钱当然没任何问题,但是结构就变得非常复杂了,而且可能你缓存的数据量其实不大,1000 万高频次的缓存读写 Redis 也能轻松应付,可是因为带宽的问题,你不得不付出 5 倍的成本。

那么两级缓存的用武之处就在这里。

如果我们不用每次页面访问的时候都去 Redis 读取数据,那么 Redis 上的数据流量至少降低 1000 倍甚至更多,以至于一台 Redis 可以轻松应付。

cloudopt-next-cache 其实不是一个缓存框架,它是一个缓存框架的桥梁。它利用现有优秀的内存缓存框架作为一级缓存,而把 Redis 作为二级缓存。所有数据的读取先从一级缓存中读取,不存在时再从二级缓存读取,这样来确保对二级缓存 Redis 的访问次数降到最低。

cloudopt-next-cache 目前已经内置了节点间数据同步的方案 —— Redis Pub/Sub 。当某个节点的缓存数据需要更新时,J2Cache 会通过 Redis 的消息订阅机制来通知集群内其他节点。当其他节点收到缓存数据更新的通知时,它会清掉自己内存里的数据,然后重新从 Redis 中读取最新数据。

L1 缓存 —— caffeine

Caffeine 是基于 JAVA 1.8 Version 的高性能缓存库。Caffeine提供的内存缓存使用参考 Google guava 的 API。Caffeine 是基于 Google Guava Cache 设计经验上改进的成果。

Caffeine 可以通过建造者模式灵活的组合以下特性:

  • 通过异步自动加载实体到缓存中
    基于大小的回收策略
    基于时间的回收策略
    自动刷新
    key自动封装虚引用
    value自动封装弱引用或软引用
    实体过期或被删除的通知
    写入外部资源
    统计累计访问缓存

鉴于 caffeine 越来越主流以及极高的性能, cloudopt-next-cache 选用 caffeine 作为 L1 缓存。

L2 缓存 —— Redis

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。

Redis 也是目前市面上最主流的分布式的、持久化的内存型数据库。cloudopt-next-cache 选用 redis 作为 L2 缓存。

Lettuce 是一个可伸缩线程安全的 Redis 客户端。多个线程可以共享同一个 RedisConnection。它利用优秀 netty NIO 框架来高效地管理多个连接。cloudopt-next-cache 选用 Lettuce 作为操作 redis 的工具。

Region

Cache 插件的 Region 来源于 Ehcache 的 Region 概念。

一般我们在使用像 Redis、Caffeine、Guava Cache 时都没有 Region 这样的概念,特别是 Redis 是一个大哈希表,更没有这个概念。

在实际的缓存场景中,不同的数据会有不同的 TTL 策略,例如有些缓存数据可以永不失效,而有些缓存我们希望是 30 分钟的有效期,有些是 60 分钟等不同的失效时间策略。在 Redis 我们可以针对不同的 key 设置不同的 TTL 时间。但是一般的 Java 内存缓存框架(如 Ehcache、Caffeine、Guava Cache 等),它没法为每一个 key 设置不同 TTL,因为这样管理起来会非常复杂,而且会检查缓存数据是否失效时性能极差。所以一般内存缓存框架会把一组相同 TTL 策略的缓存数据放在一起进行管理。

如果我们传入的 region 参数(假设为:region1)没有在 配置文件中定义的话,那么会报错。为了以免万一,cloudopt-next-cache 会自动创建名为 『default』 的 region。

所以要用好缓存首先要确保以下几点:

  1. 根据业务规划好不同的 region 来存放不同的缓存数据
  2. 根据实际情况确定每个 region 的缓存数据数量和 TTL 时间
  3. 尽量必要未经定义直接使用一个全新的 region (避免使用 default 数据)

引入插件

注意:cache 插件是依赖于 redis 插件的,所以请先提前引入 redis 插件。

  1. <dependency>
  2. <groupId>net.cloudopt.next</groupId>
  3. <artifactId>cloudopt-next-cache</artifactId>
  4. <version>${version}</version>
  5. </dependency>
  6. <dependency>
  7. <groupId>com.github.ben-manes.caffeine</groupId>
  8. <artifactId>caffeine</artifactId>
  9. <version>${version}</version>
  10. </dependency>
  11. <dependency>
  12. <groupId>io.lettuce</groupId>
  13. <artifactId>lettuce-core</artifactId>
  14. <version>${version}</version>
  15. </dependency>

配置

  1. {
  2. "cache": {
  3. "cluster": true,
  4. "regions": [
  5. {
  6. "name": "testRegion",
  7. "expire": "10m",
  8. "maxSize": 10000
  9. }
  10. ]
  11. }
  12. }

上面是一个典型的 application.json,在加载 cache 插件之前,请先在配置文件中配置好,所有配置都在 cache 下。

cluster 默认为 false,开启后会自动注册 redis 的 Pub/Sub(同样需要将 redis 的设置中的 publish 和 subscribe 设置为 true) 。当某个节点的缓存数据需要更新时,插件会通过 Redis 的消息订阅机制来通知集群内其他节点。当其他节点收到缓存数据更新的通知时,它会清掉自己内存里的数据,然后重新从 Redis 中读取最新数据。
regions 区域设置中 name 就是区域名称、expire是过期时间,可以设置为s、m、h、d,分别代表秒、分钟、小时、天(如 3h 代表三小时。),maxSize 是该区域的最大条数。如果超过数量限制 caffeine 会自动清理不用的缓存。

加载插件

请提前加载 redis 插件。

  1. NextServer.addPlugin(RedisPlugin())
  2. NextServer.addPlugin(CachePlugin())
  3. NextServer.run()

请在使用之前将配置文件配置好并将插件放入 Next 中。

操作缓存

cache 插件是非阻塞的,使用了 await 语法糖,所以在调用的方法上需要声明 suspend。另外你可以直接放入对象,取出来的时候也可以是对象,会自动使用 fastjson 进行序列化和反序列化。
当你在删除缓存的时候,如果设置中的 cluster 为 true,会自动通过 redis 的 pub/sub 通知其它服务删除该缓存。
如果日志等级开启了 debug 等级,还可以看到缓存具体是从 L1 缓存中拿到的还是 L2 中拿到的。

CacheManager.set(regionName, key, value)
val value:String = CacheManager.get(regionName, key) as String
CacheManager.delete(regionName, key)

使用注解

@GET("cacheable/:id")
@Cacheable("testRegion", key = "@{url}-@{id}", keyGenerator = DefaultKeyGenerator::class, l2 = true)
fun cacheable() {
    renderJson(json("name" to "cacheable"))
}

在路由方法上使用 @Cacheable 即可自动截获输出的结果缓存起来并且在下次访问时自动返回缓存中的数据。

第一个参数主要是声明 region 的具体名称,第二个声明是缓存的 key,默认的 keyGenerator 会自动替换 @{url} 这样的参数为 Http 请求中的参数的值。如 @{id} 就会替换为 Http 传过来的 id 参数。

默认的 keyGenerator 会自动将访问的路径传入进去,你可以通过 @{url} 和 @{absoluteURI} 获取。

如果你需要自定义 key 生成器的话,只需要实现 KeyGenerator 即可。

interface KeyGenerator {
    fun generate(key: String, resource: Resource): String
}