后端系统的缓存使用浅谈

1. 什么是缓存

缓存有很多种,从 CPU 缓存、磁盘缓存到浏览器缓存等,本文所说的缓存,主要针对后端系统的缓存。也就是将程序或系统经常要使用的对象存在内存中,以便在使用时可以快速调用,也可以避免加载数据或者创建重复的实例,以达到减少系统开销,提高系统效率的目的。

2. 为什么要用缓存

我们一般都会把数据存放在关系型数据库中,不管数据库的性能有多么好,一个简单的查询也要消耗毫秒级的时间,这样我们常说的 QPS 就会被数据库的性能所限制,我们想要提高QPS,只能选择更快的存储设备。

在日常开发有这样的一种场景:某些数据的数据量不大、不经常变动,但访问却很频繁。受限于硬盘 IO 性能或者远程网络等原因,每次都直接获取会消耗大量的资源。可能会导致我们的响应变慢甚至造成系统压力过大,这在一些业务上是不能忍的,而缓存正是解决这类问题的神器。

但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略,下文也会提到。

使用缓存的场景

对于缓存来说,数据不常变更且查询比较频繁是最好的场景,如果查询量不够大或者数据变动太频繁,缓存也就是失去了意义。

3. 缓存的使用

日常工作使用的缓存可以分为内部缓存和外部缓存。

内部缓存一般是指存放在运行实例内部并使用实例内存的缓存,这种缓存可以使用代码直接访问。

外部缓存一般是指存放在运行实例外部的缓存,通常是通过网络获取,反序列化后进行访问。

一般来说对于不需要实例间同步的,都更加推荐内部缓存,因为内部缓存有访问方便,性能好的特点;需要实例间同步的数据可以使用外部缓存。

下面对这两种类型的缓存分别的进行介绍。

3.1 内部缓存

为什么要是用内部缓存

在系统中,有些数据量不大、不常变化,但是访问十分频繁,例如省、市、区数据。针对这种场景,可以将数据加载到应用的内存中,以提升系统的访问效率,减少无谓的数据库和网路的访问。

内部缓存的限制就是存放的数据总量不能超出内存容量,毕竟还是在 JVM 里的。

最简单的内部缓存 - Map

如果只是需要将一些数据缓存起来,避免不必要的数据库查询,那么 Map 就可以满足。

对于字典型的数据,在项目启动的时候加载到 Map 中,程序就可以使用了,也很容易更新。

// 配置存放的Map
Map<String, String> configs = new HashMap<String, String>();

// 初始化或者刷新配置的Map
public void reloadConfigs() {
    Map<String, String> m = loadConfigFromDB();
    configs = m;
}

// 使用
configs.getOrDefault("auth.id", "1");

功能强大的内部缓存 - Guava Cache / Caffeine

如果你需要缓存有强大的性能,或者对缓存有更多的控制,可以使用 Guava 里的 Cache 组件。

它是 Guava 中的缓存工具包,是非常简单易用且功能强大的 JVM 内缓存,支持多种缓存过期策略。

LoadingCache<String, String> configs = CacheBuilder.newBuilder()
        .maximumSize(1000) // 设置最大大小
        .expireAfterWrite(10, TimeUnit.MINUTES) // 设置过期时间, 10分钟
        .build(
            new CacheLoader<String, String>() {
            // 加载缓存内容
                public String load(String key) throws Exception {
                    return getConfigFromDB(key);
                }
                public Map<String, String> loadAll() throws Exception {
                return loadConfigFromDB();
            }
        });

//CacheLoader.loadAll

// 获取某个key的值
try {
    return configs.get(key);
} catch (ExecutionException e) {
    throw new OtherException(e.getCause());
}

// 显式的放入缓存
configs.put(key, value)
// 个别清除缓存
configs.invalidate(key)
// 批量清除缓存
configs.invalidateAll(keys)
// 清除所有缓存项
configs.invalidateAll()

本地缓存的优点:

  • 直接使用内存,速度快,通常存取的性能可以达到每秒千万级
  • 可以直接使用 Java 对象存取

本地缓存的缺点:

  • 数据保存在当前实例中,无法共享
  • 重启应用会丢失
Guava Cache 的替代者 Caffeine

Spring 5 使用 Caffeine 来代替 Guava Cache,应该是从性能的角度考虑的。从很多性能测试来看 Caffeine 各方面的性能都要比 Guava 要好。

Caffeine 的 API 的操作功能和 Guava 是基本保持一致的,并且 Caffeine 为了兼容之前 Guava 的用户,做了一个 Guava 的 Adapter, 也是十分的贴心。

如果想了解更多请参考:是什么让 Spring 5 放弃了使用 Guava Cache?

3.2 外部缓存

最著名的外部缓存 - Redis / Memcached

也许是 Redis 太有名,只要一提到缓存,基本上都会说起 Redis。但其实这类缓存的鼻祖应该是 LiveJournal 开发的 Memcached。

Redis / Memcached 都是使用内存作为存储,所以性能上要比数据库要好很多,再加上Redis 还支持很多种数据结构,使用起来也挺方便,所以作为很多人的首选。

Redis 确实不错,不过即便是使用内存,也还是需要通过网络来访问,所以网络的性能决定了 Reids 的性能;

我曾经做过一些性能测试,在万兆网卡的情况下,对于 Key 和 Value 都是长度为 20 Byte 的字符串的 get 和 set 是每秒10w左右的,如果 Key 或者 Value 的长度更大或者使用数据结构,这个会更慢一些;

作为一般的系统来使用已经绰绰有余了,从目前来看,Redis 确实很适合来做系统中的缓存。

如果考虑多实例或者分布式,可以考虑下面的方式:

  • Jedis 的 ShardedJedis( 调用端自己实现分片 )
  • twemproxy / codis( 第三方组件实现代理 )
  • Redis Cluster( 3.0 之后官方提供的集群方案 )

这些方案各有特点,这次先不展开讨论,有兴趣的可以先研究一下。

Redis有很多优点:

  • 很容易做数据分片、分布式,可以做到很大的容量
  • 使用基数比较大,库比较成熟

同时也有一些缺点:

  • Java 对象需要序列化才能保存
  • 如果服务器重启,再不做持久化的情况下会丢失数据,即使有持久化也容易出现各种各样的问题

4. 缓存的更新策略

使用缓存时,更新策略是非常重要的。最常见的缓存更新策略是 Cache Aside Pattern:

  • 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从 cache 中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

不管是内部缓存还是外部缓存,都可以使用这样的更新策略,如果缓存系统支持,也可以通过设置过期时间来更新缓存。

更多的更新策略可以参考左耳朵耗子的这篇缓存更新的套路

5. 缓存使用常见误区

序列化方案的选择

序列化的选择,尽量避免使用 Java 原生的机制,因为原生的序列化依赖 serialVersionUID 来判断版本,如果改变就无法正常的反序列化。

一般推荐使用 Json 或者 Hessian、ProtoBuf 等二进制方式。

缓存大对象

在缓存中存放大对象,存取的代价都比较高。实际使用时,往往只是需要其中的一部分,这样会导致每一次读取都消耗更多的网络和内存资源,也会浪费缓存的容量。

当然如果每次都是用完整的对象,这样做是没有问题的。

使用缓存进行数据共享

使用缓存来当作线程甚至进程之间的数据共享方式,会让系统间产生隐形的依赖,并且也可能会产生一些竞争,常常会发生问题。所以不推荐使用这种方式来共享数据。

没有及时更新或者删除缓存中已经过期或失效的数据

这个理解起来就很简单了,如果没有及时更新或者删除,就有可能读取到错误的数据,从而导致业务的错误。

对于支持设置过期时间的缓存系统,可以对每一个数据设置合适的过期时间,来尽量避免这样的情况。


以上,我们简单介绍了后端系统常用的缓存的一些基本知识,欢迎大家和我们讨论。

1+

我们正在招聘Java工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注