写点什么

作者推荐 | 企业级缓存技术解析,你必须知道的“9“大技术问题与常见误区

作者:洛神灬殇
  • 2024-02-01
    江苏
  • 本文字数:6376 字

    阅读完需:约 21 分钟

作者推荐 | 企业级缓存技术解析,你必须知道的“9“大技术问题与常见误区

注意:特此声明:本文首发在掘金:https://juejin.cn/post/7329860429384466471,未经允许,请勿进行侵权私自转载

"9"大常见误区

本文将深入剖析导致上述问题的九大根源,并提供相应的解决方案。请注意,本文以 Java 为例进行代码演示,但同样适用于其他技术平台的朋友。只需根据相应技术平台替换相关代码即可!


首先,让我们就先来看看者九大根源误区都是什么?如下图所示:



后面,我们便会针对于这 9 大误区进行逐个分析,最终对应的指定对应的解决方案和逐个击破

背景介绍

若要持续优化站点或应用程序,最迅速且最显著的方式无疑是采用缓存技术。我们通常会将常用或需耗费大量资源与时间生成的数据进行缓存,以确保后续使用的流畅性。


尽管缓存的优点颇多,但在实际应用中,其效果往往不尽如人意。假设缓存能将性能提升至 100%,但实际效果往往只有 80%、70%或更低,甚至可能导致性能严重下降。尤其在分布式缓存的使用中,这种现象尤为明显。

本地缓存

为了更好地阐述后续内容并使文章更加完整,我们首先来了解一下缓存的两种形式:本地缓存和分布式缓存。



从上图中可以清楚地看出:


  1. 应用程序将数据缓存在本地计算机的内存中,当需要时直接从本地内存中获取。

  2. 对于该应用程序而言,在获取缓存中的数据时,是通过对象的引用去内存中查找数据对象的。也就是说,如果我们通过引用获取了数据对象之后,我们直接修改这个对象,实际上我们真正修改的是位于内存中的缓存对象。

分布式缓存

对于分布式缓存系统,数据被存储在独立的缓存服务器上。因此,应用程序需要跨进程地访问这些分布式缓存服务器以获取所需数据,如图 2 所示:



无论缓存服务器位于何处,由于涉及到跨进程甚至跨域访问缓存数据,因此在发送到缓存服务器之前,缓存数据需要先进行序列化。当应用程序服务器接收到序列化的数据时,会将其反序列化以供使用。序列化与反序列化的过程对 CPU 资源消耗较大,许多问题正是源于此。

问题 1:过于依赖默认的序列化机制

提高序列化的能力,包括:速度+压缩程度,当我们在应用中使用跨进程的缓存机制,例如,分布式缓存 memcached 或者 Redis、KeyDB,此时数据被缓存在应用程序之外的进程中。



当我们要把一些数据缓存起来的时候,缓存的 API 就会把数据首先序列化为字节的形式,然后把这些字节发送给缓存服务器去保存。当我们在应用中要再次使用缓存的数据的时候,缓存服务器就会将缓存的字节发送给应用程序,而缓存的客户端类库接受到这些字节之后就要进行反序列化的操作了,将之转换为我们需要的数据对象。

三个要点

需要特别注意的三个方面如下:



  1. 序列化与反序列化的过程仅在应用程序服务器上发生,而缓存服务器的职责仅限于存储序列化后的数据。

  2. Java 中默认的序列化机制并非最优选择。由于它依赖于反射机制,这会显著增加 CPU 的负担,特别是在处理复杂的数据对象时。

  3. 鉴于此问题,我们应自主选择一个更高效的序列化方法,以尽量减少对 CPU 资源的消耗。

序列化优化的必要性

有些人可能会认为,序列化只是开发中的一个小细节,没有必要过分关注。然而,在构建一个高性能应用(例如网站)的过程中,从架构设计到代码编写,再到最终部署,每一个环节都需要精心优化。一个小小的序列化问题,乍一看似乎微不足道,但当我们的应用面临百万、千万甚至更高级别的访问量时,这些访问都需要获取一些公共的缓存数据,那时,原本看似微不足道的问题就会变得尤为重要!

问题 2:缓存大对象

消耗资源过大过重、不宜经常创建


首先,我们需要明确大对象的定义。在 Java 中,从我个人的经验之谈而言,一般我会将大对象定义为占用内存大于 85K 的对象。接下来,我们将通过一个示例来说明如何判断一个集合是否是大对象。


import java.util.ArrayList;import java.util.List;
class Person { // 假设每个Person对象占用1K的内存}
public class Main { public static void main(String[] args) { List<Person> personList = new ArrayList<>(); for (int i = 0; i < 100; i++) { personList.add(new Person()); } boolean isLargeObject = isLargeObject(personList); System.out.println("Is the personList a large object? " + isLargeObject); }
public static boolean isLargeObject(List<Person> personList) { // 计算集合中所有Person对象的总内存占用 int totalMemoryUsage = personList.size() * 1024; // 每个Person对象占用1K的内存 // 判断是否为大对象 return totalMemoryUsage > 85 * 1024; // 85K }}
复制代码


在这个示例中,我们创建了一个Person类,并假设每个Person对象占用 1K 的内存。然后,我们创建了一个包含 100 个Person对象的集合personList。接着,我们调用isLargeObject方法来判断这个集合是否是大对象。isLargeObject方法首先计算集合中所有Person对象的总内存占用,然后判断是否大于 85K。如果大于 85K,则返回true,表示这个集合是大对象;否则返回false

内存碎片的问题

在 Java 中,大对象是分配在大对象托管堆(简称“堆”)上的。堆的内存分配机制会寻找合适的内存空间,这可能导致内存碎片和内存不足的问题。(即使在使用标记-整理也会出现短暂的碎片!)


此外,分配对象时需要遍历大堆以找到合适的空间,这个过程会消耗一定的成本。如果某些空间小于 85K,那么就无法进行分配,导致内存碎片的产生。最后,将对象进行序列化和反序列化时,缓存的对象越大(例如 1M 等),整个过程中消耗的 CPU 进行重新分配和开辟资源就越多。

方案建议(一切都在细节当中)

主要将缓存的使用和分配场景划分为以下三种类型:



  1. 对于大对象的处理,我们需要根据其使用频率、是否为共享数据以及是否每个用户都需要生成来决定是否进行缓存。

  2. 因为一旦缓存,就会消耗缓存服务器的内存和应用程序服务器的 CPU 资源。如果使用不频繁,建议每次生成新的大对象。

  3. 如果是共享数据,可以通过测试比较生成大对象的成本与缓存时消耗的内存和 CPU 成本,选择成本较低的方案。如果每个用户都需要生成大对象,可以考虑是否可以分解,如果不能分解,可以进行缓存,但需要及时释放。

问题 3:使用缓存机制在线程间进行数据的共享

当数据被存储在缓存中时,我们的程序的多个线程可以同时访问这个公共区域。然而,这会导致一些竞争条件,这是多线程编程中常见的问题。下面我们将从本地内存缓存和分布式缓存两个方面来探讨这个问题。

本地缓存

假设我们有三个线程,它们可能会并发地访问和修改同一份数据。在某些情况下,线程 1 可能读取到的值是 1,线程 2 可能是 2,线程 3 可能是 3。当然,这只是一种可能性,实际情况可能会有所不同。

解决方案


为了解决数据访问冲突的问题,我们通常会采用队列排队机制。这种机制可以有效地减少数据访问冲突,提高系统的稳定性和性能。此外,我们还可以使用加锁处理(如 Lock、LockSupport、Sync)来确保数据的一致性和安全性。然而,在高并发场景下,使用乐观锁(如 CAS)可能会带来一定的性能开销。因此,我们需要根据具体的应用场景和需求来选择合适的解决方案。


注意,有的时候甚至需要加分布式锁进行控制!

分布式缓存

情况就变得更加复杂了。因为数据的修改不是立即反映在本机的内存中,而是需要经过一个跨进程的过程。这就可能导致一些意想不到的问题。


为了解决这个问题,有一些缓存模块已经实现了加锁/原子排队处理机制,例如 Redis 以及其他的分布式缓存技术、Lua 脚本原子化控制。在使用这些缓存模块时,我们需要特别注意这一点。



有时候,当我们调用了缓存的 API 之后,我们可能会认为数据已经被成功存储在缓存中,然后我们就可以直接从缓存中读取数据。然而,实际情况可能并非如此。很多问题就是这样产生的。因此,我们在使用缓存时,需要谨慎对待这个问题。

问题 4:缓存大量的数据集合,而读取其中一部分

拆分序列化的广度和范围


在很多情况下,我们通常会缓存一个对象的集合。然而,在实际读取时,我们可能只需要每次读取其中的一部分数据。举个例子来说明这个问题(虽然这个例子可能不太恰当,但足以说明问题)。

案例说明

假设,在一个购物网站上,用户输入了“25 寸电视机”作为搜索关键词,然后查找相关的产品信息。在这种情况下,后台系统可以查询数据库并找到几百条与该关键词相关的数据。然后,我们将这几百条数据作为一个缓存项进行缓存。


注意,在实际应用中,我们可能只需要读取其中一部分数据,而不是全部数据。因此,在读取缓存时,需要根据具体需求来选择读取的数据范围。


同时,我们对找出的产品进行分页的显示,每次展示 25 条。其实在每次分页的时候,我们都是根据缓存的键去获取数据,然后选择下一个 25 条数据,然后显示。

问题分析

如果是使用本地内存缓存,那么这可能不是什么问题,如果是采用分布式缓存,问题就来了。下图可以清楚的说明这个过程,如图所示:



相信大家看完这个图,然后结合之前的讲述应该很清楚了问题所在了:每次都按照缓存键获取全部数据,然后在应用服务器那里反序列化全部数据,但是只是取其中 25 条。除非你修改成其他模式的数据结构。



为了解决这个问题,这里可以将数据集合再次拆分,分为例如 1-30,31-60 等的缓存项,如下图所示:当然,查询和缓存的方式有很多,拆分的方式也有很多,这里这是给出一些常见的问题!

缓存大量具有图结构的对象导致内存浪费(控制序列化的深度)

(纵向) 拆分序列化粒度 - 深度


如果我们要把一些大量具有图结构的对象数据缓存起来,这里就可以可能出现两个问题:



  1. 在使用默认序列化机制时,或者没有适当地添加相应的属性(Attribute),可能会导致缓存了一些原本不需要缓存的数据。

  2. 在缓存 Customer 信息的同时,为了更快地获取 Customer 的 Order 信息,将 Order 信息缓存在了另一个缓存项中,从而导致同一份数据被缓存了两次。


为了避免这种情况,我们需要仔细审查缓存策略并确保只缓存必要的数据,下面,我们就分别来看看这两个问题。

问题分析

第一个问题-导致缓存了一些原本不需要缓存的数据

当我们使用分布式缓存来缓存一些数据信息时,如果没有自己实现自定义的序列化机制,而是采用默认的序列化机制,那么在序列化 Object 对象时,会将 Object 所引用的对象也进行序列化。这样会导致整个对象图被序列化,包括 id、Name 等。如果这种情况是我们想要的,那么没有问题;如果不是,那么我们就浪费了很多资源。

解决这个问题有两种方法

第二个问题-导致缓存了一些原本不需要缓存的数据

这个问题主要是由于第一个问题引起的。例如,原本在缓存 Object1 时,已经将 Object1 的其他信息(如 P1 和 P2)缓存了。但是很多技术人员不清楚这一点,又把 Object1 的 P1 信息缓存在了其他缓存项中。这样在使用缓存时,根据 Object1 的标识(如 ID)去缓存中获取 P1 信息。


对此也有了两个解决方案进行处理和控制,如下所示。



为了避免这种重复缓存的问题,我们需要确保在整个应用程序中对缓存的使用是一致的,并且避免在不同的地方缓存相同的数据。

问题 5:缓存应用程序的配置信息

由于缓存系统内置了数据失效检测机制,可以根据预设的时间周期(如固定有效期或相对有效期)自动更新内容,许多技术人员倾向于将部分动态变化的数据存储在缓存中,以充分利用这一特性。例如,应用中的配置信息,尤其是那些可能会频繁调整的设置,如数据库连接字符串,便是一个理想的缓存对象。

积极的方面

当配置信息被缓存后,在其失效周期结束时会触发重新读取配置文件的动作。这样一来,当下一次读取发生时,如果有任何更改,则可以确保获取到最新的配置状态,并通过缓存迅速地同步到各个依赖该配置的应用实例中。特别是在多服务器集群上部署同一站点的情况下,采用分布式缓存来管理配置信息尤其高效,因为只需更新一处配置文件,所有关联的服务器站点即可实时共享变更,从而极大地简化了运维流程并提升了可靠性。

负面的问题(缓存依赖性过强)

这种方法虽看似便捷,但并非适用于所有类型的配置信息。尤其是在某些情况下,各服务器可能需要保持独立且不同的配置设定。



此外,还必须考虑到一种潜在风险:若缓存服务出现故障或宕机,所有依赖此缓存中配置信息的站点将可能受到影响,导致运行异常。

建议

对关键配置文件采取更为稳健和主动的管理模式,比如实施文件变动监控策略。一旦配置文件发生变更,系统应立即自动重新加载新的配置信息,这样既能保证配置的时效性,又能有效降低因缓存服务中断带来的潜在风险。

问题 6:使用很多不同的键指向相同的缓存项

我们有时候会遇到这样的情况:我们将一个对象缓存起来,并使用一个键来获取这个数据。然后,我们又通过一个索引来获取这个数据,如下所示的代码:


// 通过缓存键获取数据String data = cache.get(key);// 通过索引获取数据 String indexedData = data[index];
复制代码


我们之所以这样写,主要是因为我们可能以多种方式从缓存中读取数据。例如,在进行循环遍历时,我们需要通过索引来获取数据(例如 index++)。而在其他情况下,我们可能需要通过其他方式,例如产品名来获取产品的信息。


如果遇到这样的情况,建议将这些多个键组合起来,形成如下形式:


// 将多个键组合成一个复合键String compositeKey = `${key}:${index}`;// 通过复合键获取数据String data = cache.get(compositeKey);
复制代码

相同的数据被缓存在不同的缓存项

例如,如果用户查询尺寸为 36 寸的彩电,可能会有一个编号为 100 的电视产品出现在结果中,我们将结果缓存起来。然后,用户又查询生产厂家为 TCL 的电视,如果编号为 100 的电视产品再次出现在结果中,我们将结果缓存在另外一个缓存项中。这个时候,很显然,出现了内存的浪费。

解决方案

个人建议,将这种数据进行统一化管理,集中为数据服务或数据平台。将数据从业务层和表现层中解耦,作为原子数据缓存。


这样做的好处是可以提高数据的复用性和一致性。通过将数据统一管理,可以避免数据的重复存储和冗余。同时,数据服务或数据平台可以提供更高效的数据访问和查询接口,以满足不同业务场景的需求。



将数据作为原子数据缓存,可以提高数据的访问速度和响应性能。原子数据缓存可以将数据存储在高速缓存中,以减少对底层数据存储系统的访问次数,从而提高数据的读取和写入速度。

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

这种情况是使用缓存时最常见的问题之一。例如,我们获取了一个 Customer 的所有未处理订单的信息,并将其缓存起来。类似的代码如下所示:


// 获取未处理订单的信息Order orders = getUnprocessedOrders();// 将订单信息缓存起来cache.set('unprocessedOrders', orders);
复制代码


然后,用户的一个订单被处理了,但是缓存还没有更新,这时缓存中的数据就会出现问题!当然,我这里只是列举了最简单的场景,实际应用中可能会出现更复杂的情况,导致缓存中的数据与实际数据库中的数据不一致。



现在很多情况下,我们已经容忍了这种短时间的数据不一致情况。实际上,对于这种情况,并没有非常完美的解决方案。如果要解决这个问题,可以实现一种每次修改或删除数据时都遍历缓存中的所有数据,并进行相应操作的方法。但是这样做往往得不偿失。


这个问题可以推荐查本人的《【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(数据缓存不一致分析)》这篇文章,里面有详细对应的缓存数据不一致问题的解决方案和分析,这里就不多啰嗦了。

最后卖个关子,嘻嘻

相信众多读者已经注意到,本文尚有两个问题尚未剖析。在此,我巧妙地卖个关子,既然已为大家呈现了七分之九的深度解析,那么余下的两个挑战及其应对策略,我想邀请大家共同探索。



对此感兴趣的朋友们不妨尝试研究,并分享相应的实例应用场景,无论是私信交流还是在评论区与我一起探讨,我都热烈欢迎。因为,只有众人拾柴才能火焰高,如果仅由我一人完成全部分析,那未免失去了集体智慧碰撞的意义。充满见解的伙伴们,让我们携手并进,一同攻克这两个难题吧!倘若大家仍感困惑,届时我将亲自揭晓问题的答案和解决之道!

发布于: 刚刚阅读数: 3
用户头像

洛神灬殇

关注

🏆 InfoQ写作平台-签约作者 🏆 2020-03-25 加入

👑 后端技术架构师,前优酷资深工程师 📕 个人著作《深入浅出Java虚拟机—JVM原理与实战》 💻 10年开发经验,参与过多个大型互联网项目,定期分享技术干货和项目经验

评论

发布
暂无评论
作者推荐 | 企业级缓存技术解析,你必须知道的“9“大技术问题与常见误区_分布式缓存_洛神灬殇_InfoQ写作社区