java面试题
一、JVM内存模型 (JVM Memory Model)
我们通常所说的JVM内存模型指的是运行时数据区 (Runtime Data Areas),即JVM在执行Java程序时会把它管理的内存划分为若干个不同的数据区域。
主要分为两大块:线程共享区和线程私有区。
1. 线程共享区 (所有线程共享访问)
堆 (Heap)
- 作用:存放对象实例和数组。几乎所有通过
new关键字创建的对象都在这里分配内存。这是GC(垃圾收集)管理的主要区域。 - 特点:
- 是JVM中最大的一块内存区域。
- 线程共享,因此存在线程安全问题。
- 为了优化GC性能,现代垃圾收集器通常又将堆细分为:
- 新生代 (Young Generation):新创建的对象首先在这里分配。它又分为一个 Eden区 和两个 Survivor区 (S0, S1)。
- 老年代 (Old Generation/Tenured):在新生代中经历了多次GC后仍然存活的对象会被移到这里。
- 可以通过
-Xms和-Xmx参数设置堆的初始大小和最大大小。
- 作用:存放对象实例和数组。几乎所有通过
方法区 (Method Area)
- 作用:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 特点:
- 线程共享。
- 在HotSpot VM中,方法区的实现被称为 “永久代” (PermGen),但在Java 8及以后,已被 元空间 (Metaspace) 取代。元空间不再使用JVM内存,而是使用本地内存,从而避免了永久代常见的
OutOfMemoryError: PermGen space错误。
2. 线程私有区 (每个线程独享)
虚拟机栈 (JVM Stack)
- 作用:描述Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧 (Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 特点:
- 每个线程都有自己独立的虚拟机栈。
- 我们常说的堆内存和栈内存,其中的“栈”就是指这里。局部变量(基本数据类型和对象引用)存放在栈的局部变量表中。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError;如果栈可以动态扩展但无法申请到足够内存,则抛出OutOfMemoryError。
本地方法栈 (Native Method Stack)
- 作用:与虚拟机栈非常相似,区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native(本地)方法服务。
程序计数器 (Program Counter Register)
- 作用:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 特点:
- 线程私有,各条线程之间互不影响。
- 是JVM规范中唯一没有规定任何
OutOfMemoryError情况的区域。
二、GC算法 (Garbage Collection Algorithms)
GC的核心任务是回收堆内存中已经“死去”的对象,释放其占用的空间。
1. 判断对象是否可回收的算法
引用计数法 (Reference Counting)
- 原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1。任何时刻计数器为0的对象就是不可能再被使用的。
- 缺点:无法解决对象之间循环引用的问题(例如,A引用B,B也引用A,但再无其他引用指向它们),因此主流JVM均不采用此算法。
可达性分析算法 (Reachability Analysis)
- 原理:通过一系列称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
- 哪些对象可以作为GC Roots?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即Native方法)引用的对象。
2. 垃圾收集算法 (用于回收的算法)
标记-清除算法 (Mark-Sweep)
- 过程:首先标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。
- 缺点:效率不高,且会产生大量不连续的内存碎片。
复制算法 (Copying)
- 过程:将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:实现简单,运行高效,没有内存碎片。
- 缺点:将可用内存缩小了一半,代价高昂。
- 应用:是新生代GC的主流算法。在HotSpot中,将新生代分为一个较大的Eden区和两个较小的Survivor区(通常比例为8:1:1)。每次使用Eden和其中一个Survivor。回收时,将Eden和Survivor中存活的对象一次性复制到另一个Survivor空间上,最后清理掉Eden和刚用过的Survivor。
标记-整理算法 (Mark-Compact)
- 过程:标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:避免了内存碎片问题。
- 缺点:移动对象成本较高。
- 应用:适合老年代的垃圾收集。
分代收集算法 (Generational Collection)
- 思想:当前商业虚拟机的垃圾收集都采用“分代收集”算法。它只是根据对象存活周期的不同将内存划分为几块(一般是新生代和老年代)。然后根据各个年代的特点采用最适当的收集算法。
- 在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除”或“标记-整理” 算法来进行回收。
- 思想:当前商业虚拟机的垃圾收集都采用“分代收集”算法。它只是根据对象存活周期的不同将内存划分为几块(一般是新生代和老年代)。然后根据各个年代的特点采用最适当的收集算法。
三、类加载机制 (Class Loading Mechanism)
JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这个过程就是类加载机制。
1. 类加载的生命周期
包括:加载 (Loading) -> 连接 (Linking) -> 初始化 (Initialization)。其中连接又分为三步:验证 (Verification) -> 准备 (Preparation) -> 解析 (Resolution)。
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证
- 确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
- 包括文件格式验证、元数据验证、字节码验证、符号引用验证。
准备
- 为类变量(static变量) 分配内存并设置初始零值(如
0,false,null等)。 - 注意:这里设置的是初始零值,而不是代码中赋予的值。例如
public static int value = 123;在准备阶段后value的值为0,赋值123的动作在初始化阶段才执行。但对于final static常量(如public static final int value = 123;),其值会在准备阶段被直接赋值为123。
- 为类变量(static变量) 分配内存并设置初始零值(如
解析
- 将常量池内的符号引用替换为直接引用的过程。
初始化
- 执行类构造器
<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的语句合并产生的。 - 触发时机:当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类。或者当遇到
new,getstatic,putstatic,invokestatic等字节码指令时,如果类没有初始化,则需要先触发其初始化。
- 执行类构造器
2. 类加载器 (ClassLoader) 与双亲委派模型
类加载器的作用:实现“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块。
双亲委派模型 (Parents Delegation Model)
- 工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
- 优势:
- 确保Java核心库的类型安全:例如
java.lang.Object类,无论哪个类加载器要加载它,最终都委派给启动类加载器,从而保证了在程序中所用的都是同一个Object类。 - 避免类的重复加载。
- 确保Java核心库的类型安全:例如
三层主要的类加载器:
- 启动类加载器 (Bootstrap ClassLoader):负责加载
JAVA_HOME/lib目录中的核心类库,如rt.jar。由C++实现,是JVM的一部分。 - 扩展类加载器 (Extension ClassLoader):负责加载
JAVA_HOME/lib/ext目录中的类库。 - 应用程序类加载器 (Application ClassLoader):也叫系统类加载器,负责加载用户类路径 (ClassPath) 上指定的类库。一般情况下,这就是程序默认的类加载器。
- 启动类加载器 (Bootstrap ClassLoader):负责加载
好的,我们来深入探讨Spring框架的这些核心概念。
四、Spring IOC / AOP 实现原理
1. IOC (控制反转) 实现
核心思想:将对象的创建、依赖装配和生命周期的控制权从应用程序代码反转到Spring容器(IOC容器)来管理。
实现原理:
- 核心接口:
BeanFactory和ApplicationContext。ApplicationContext是BeanFactory的子接口,提供了更多企业级功能,是更常用的容器。 - 配置元数据:容器通过读取配置元数据(XML、Java注解或Java Config)来知道如何创建、配置和组装应用中的对象。
- 工作流程:
- 加载与解析:容器启动,加载并解析配置文件(如
applicationContext.xml)或扫描注解(如@Component,@Service等)。 - 创建Bean定义:根据解析结果,为每个Bean创建一个
BeanDefinition对象,它包含了Bean的类名、作用域、属性值、依赖关系等信息。 - 实例化:通过Java的反射机制,调用类的无参或有参构造函数来创建Bean的实例。
- 依赖注入:根据Bean定义中的依赖关系,容器将所需的依赖(其他Bean或基本类型值)注入到目标Bean的属性或构造函数中。这解决了对象间的耦合问题。
- 初始化:如果Bean实现了
InitializingBean接口或配置了init-method,容器会调用这些初始化方法。 - 就绪:此时,Bean已经准备就绪,可以被应用程序使用。
- 销毁:当容器关闭时,如果Bean实现了
DisposableBean接口或配置了destroy-method,容器会调用这些销毁方法。
- 加载与解析:容器启动,加载并解析配置文件(如
总结:IOC容器就像一个对象工厂,你只需要告诉它你需要什么对象(通过配置),以及对象之间的依赖关系,它就会在合适的时机为你创建好并组装起来。
2. AOP (面向切面编程) 实现
核心思想:将那些遍布在应用多个模块中的横切关注点(如日志、事务、安全等)从业务逻辑中分离出来,形成一个独立的模块(切面),从而提高代码的模块化和可维护性。
核心概念:
- 切面:横切关注点的模块化,即一个类,如
LoggingAspect。 - 通知:切面要完成的工作,即在特定的连接点执行的动作。类型有:
- 前置通知:在方法执行前执行。
- 后置通知:在方法执行后执行(无论成功与否)。
- 返回通知:在方法成功执行后执行。
- 异常通知:在方法抛出异常时执行。
- 环绕通知:最强大的通知,可以自定义在方法调用前后执行的行为,并决定是否执行目标方法。
- 连接点:程序执行过程中可以插入切面的点,如方法调用、异常抛出等。在Spring AOP中,连接点总是代表方法的执行。
- 切点:一个表达式,用于匹配哪些连接点会被通知增强。例如:
execution(* com.example.service.*.*(..))。 - 引入:向现有的类添加新的方法或属性。
- 织入:将切面应用到目标对象并创建新的代理对象的过程。
实现原理:Spring AOP 默认使用动态代理。
- JDK 动态代理:如果目标对象实现了接口,Spring会使用JDK的
java.lang.reflect.Proxy类来创建代理对象。代理对象会实现与目标对象相同的接口。 - CGLIB 代理:如果目标对象没有实现任何接口,Spring会使用CGLIB库来生成一个目标对象的子类作为代理对象。
过程:当调用一个Bean的方法时,实际上调用的是其代理对象的方法。代理对象在调用目标方法的前后,会根据切点表达式判断是否需要执行相应的通知逻辑。
五、循环依赖解决
循环依赖:指两个或多个Bean相互依赖,形成了一个闭环。例如:Bean A 依赖 Bean B,同时 Bean B 也依赖 Bean A。
Spring 解决循环依赖的条件:
- 必须是单例 Bean(默认就是单例)。
- 不能是构造器注入的循环依赖,只能解决Setter注入或字段注入的循环依赖。
解决原理:三级缓存
Spring容器内部维护了三个Map,称为三级缓存:
- 一级缓存(单例池)
singletonObjects:存放已经完全初始化好的、成熟的Bean。我们日常从Spring容器getBean就是从这里取。 - 二级缓存
earlySingletonObjects:存放早期暴露的Bean对象。这些Bean已经实例化,但还未进行属性填充和初始化。用于解决循环依赖。 - 三级缓存
singletonFactories:存放Bean的对象工厂ObjectFactory,用于生成早期暴露的Bean。
解决流程(以 A 依赖 B,B 依赖 A 为例):
- 开始创建A。调用A的构造器,实例化A(此时A还是一个"半成品")。
- 将A的
ObjectFactory放入三级缓存。 - 准备为A注入属性,发现A依赖B。于是去创建B。
- 调用B的构造器,实例化B(此时B也是一个"半成品")。
- 将B的
ObjectFactory放入三级缓存。 - 准备为B注入属性,发现B依赖A。
- 首先从一级缓存找A,没有。
- 然后从二级缓存找A,也没有。
- 最后从三级缓存中找到A的
ObjectFactory,并通过它获取到A的早期引用(可能是一个原始对象,也可能是经过AOP代理后的对象)。此时,将这个早期引用A放入二级缓存,并从三级缓存中移除A的工厂。 - B成功获得了A的早期引用,完成了属性注入,并执行后续的初始化步骤,最终成为一个完整的Bean,被放入一级缓存。
- 此时,A的创建流程继续,它成功从一级缓存中拿到了已经初始化好的B,完成了自己的属性注入和初始化,最终也被放入一级缓存。
通过这种“提前暴露”不完全初始化的Bean引用的机制,Spring巧妙地解决了单例Bean的Setter/字段注入循环依赖问题。
六、事务传播机制
事务传播行为定义了当一个事务方法被另一个事务方法调用时,事务应该如何传播。
七种传播行为:
REQUIRED(默认):
- 如果当前存在事务,则加入该事务。
- 如果当前没有事务,则创建一个新的事务。
- 适用场景:大多数业务方法。
SUPPORTS:
- 如果当前存在事务,则加入该事务。
- 如果当前没有事务,则以非事务的方式继续运行。
- 适用场景:查询方法,可以适应有无事务的环境。
MANDATORY:
- 如果当前存在事务,则加入该事务。
- 如果当前没有事务,则抛出异常。
- 适用场景:强制要求必须在事务中运行的方法。
REQUIRES_NEW:
- 创建一个新的事务,如果当前存在事务,则把当前事务挂起。
- 适用场景:子事务的执行结果不能影响父事务,如日志记录(即使业务失败,日志也必须记录)。
NOT_SUPPORTED:
- 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
- 适用场景:不支持事务的操作,如某些耗时的批量处理。
NEVER:
- 以非事务方式运行,如果当前存在事务,则抛出异常。
- 适用场景:强制要求不能在事务中运行的方法。
NESTED:
- 如果当前存在事务,则在嵌套事务内执行。
- 如果当前没有事务,则行为与
REQUIRED一样。 - 特点:嵌套事务是外部事务的一部分,只有外部事务提交时,嵌套事务才会提交。嵌套事务可以独立于外部事务进行回滚,而外部事务回滚会导致嵌套事务也回滚。
- 适用场景:复杂的业务场景,如订单创建(主事务)和库存扣减(嵌套事务),库存扣减失败可以回滚而不影响订单创建。
七、SpringBoot 自动配置原理
SpringBoot的核心理念是“约定优于配置”,其自动配置能力大大简化了Spring应用的初始搭建和开发过程。
核心原理:@SpringBootApplication 注解和 spring.factories 文件。
详细流程:
启动入口:主类上的
@SpringBootApplication注解。java@SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } }核心注解:
@SpringBootApplication是一个复合注解,它包含:@SpringBootConfiguration:标记该类为配置类。@ComponentScan:开启组件扫描,扫描当前包及其子包下的@Component,@Service,@Repository,@Controller等。@EnableAutoConfiguration:这是开启自动配置的关键。
@EnableAutoConfiguration的奥秘:- 它使用
@Import注解导入了AutoConfigurationImportSelector类。 AutoConfigurationImportSelector会调用SpringFactoriesLoader.loadFactoryNames()方法,去扫描所有jar包类路径下的META-INF/spring.factories文件。
- 它使用
spring.factories文件:- 这个文件是一个Key-Value形式的配置文件。
- 在
spring-boot-autoconfigure-x.x.x.x.jar中,有一个META-INF/spring.factories文件,它的EnableAutoConfigurationkey 下列出了所有自动配置类的全限定名(如org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration)。
条件化配置:
- 这些自动配置类并不会全部生效,它们上面都标有
@Conditional及其衍生注解(条件注解)。 - 常见条件注解:
@ConditionalOnClass:类路径下存在指定的类时,配置才生效。@ConditionalOnMissingBean:容器中不存在指定的Bean时,配置才生效。这是用户自定义配置可以覆盖默认配置的关键。@ConditionalOnProperty:指定的属性有指定的值时,配置才生效。@ConditionalOnWebApplication:当前应用是Web应用时,配置才生效。
- 过程:SpringBoot会逐个判断这些自动配置类上的条件是否满足。如果满足,则加载该配置类,并按其内部的
@Bean定义向容器中注册Bean。
- 这些自动配置类并不会全部生效,它们上面都标有
总结流程: @SpringBootApplication -> @EnableAutoConfiguration -> AutoConfigurationImportSelector -> 读取 spring.factories -> 加载自动配置类 -> 根据条件注解决定是否生效 -> 向容器注册Bean。
这使得我们只需引入一个Starter依赖(如 spring-boot-starter-web),SpringBoot就能自动为我们配置好Tomcat、Spring MVC等组件,实现了开箱即用的效果。
好的,Redis缓存的击穿、穿透和雪崩是三个经典的高并发场景下缓存异常问题,它们都会导致大量请求直接落到数据库上,从而可能压垮数据库。我们来详细讲解一下。
八、redis缓存的击穿、穿透、雪崩
一、缓存穿透
1. 是什么?
缓存穿透是指查询一个数据库中和缓存中都不存在的数据。由于缓存不具备存储该key的能力,导致每次请求都会穿过缓存,直接查询数据库。
打个比方:有人不停地用不存在的身份证号来查信息,派出所的档案室(缓存)里没有,每次都得去总人口库(数据库)里查,结果总是查无此人,但这个过程依然消耗了总人口库的资源。
2. 产生原因
- 恶意攻击:黑客故意构造大量不存在的key进行请求。
- 业务逻辑bug:程序错误地生成了大量无效的查询。
3. 解决方案
缓存空对象:
- 做法:当数据库查询也为空时,仍然将这个空结果(比如
null)进行缓存,并设置一个较短的过期时间(例如 1-5分钟)。 - 优点:实现简单,能有效应对短时间的恶意攻击。
- 缺点:
- 可能会缓存大量无用的空键,浪费内存空间。
- 在缓存过期的时间内,数据可能被真实写入数据库,导致短期数据不一致(需要业务能容忍)。
- 做法:当数据库查询也为空时,仍然将这个空结果(比如
布隆过滤器:
- 做法:在缓存之前,加一个布隆过滤器。布隆过滤器是一个高效的数据结构,用于判断一个元素“一定不存在”或“可能存在”于某个集合中。
- 流程:
- 将所有可能存在的键(如商品ID、用户ID)预先加载到布隆过滤器中。
- 请求来时,先让布隆过滤器判断key是否存在。
- 如果布隆过滤器说 “不存在”,那么这个key一定不存在,直接返回空,无需查询缓存和数据库。
- 如果布隆过滤器说 “可能存在”,那么再继续后续的缓存和数据库查询流程。
- 优点:内存占用极少,能从根本上防御穿透攻击。
- 缺点:
- 存在一定的误判率(但不会误判“不存在”的情况)。
- 无法删除数据(传统的布隆过滤器不支持删除,不过有变种如计数布隆过滤器可以)。
二、缓存击穿
1. 是什么?
缓存击穿是指一个访问非常频繁的热点key(如明星绯闻、秒杀商品),在缓存过期的瞬间,同时有大量的请求进来。这些请求发现缓存过期,都会去数据库加载数据,并回设缓存。这个过程中,大量并发请求瞬间穿透到数据库,造成数据库压力激增。
打个比方:一个热门店铺的优惠券在上午10点准时开抢。10点整,旧的缓存刚好失效,成千上万人同时点击,导致请求全部涌向数据库。
2. 产生原因
- 热点数据。
- 缓存过期。
- 高并发请求。
3. 解决方案
设置热点数据永不过期:
- 做法:对于极热点数据,不设置过期时间。然后通过后台任务或程序逻辑,在数据更新时主动刷新缓存。
- 优点:从根本上避免了因过期导致的击穿问题。
- 缺点:需要人工识别热点数据,并编写额外的更新逻辑。
互斥锁:
- 做法:当缓存失效时,不是所有线程都去查询数据库,而是让这些线程竞争一个分布式锁(可以用Redis的
SETNX命令实现)。只有拿到锁的线程才有资格去查询数据库并重建缓存,其他线程则等待或重试。 - 优点:能很好地保护数据库,保证只有一个线程去查询。
- 缺点:性能有损耗,可能存在死锁风险,实现复杂度较高。
- 做法:当缓存失效时,不是所有线程都去查询数据库,而是让这些线程竞争一个分布式锁(可以用Redis的
逻辑过期:
- 做法:不给缓存数据设置Redis的物理过期时间,而是在存储的value值中封装一个逻辑过期时间字段。
- 流程:
- 线程从缓存中取出数据,检查其逻辑过期时间。
- 如果数据未逻辑过期,直接返回。
- 如果数据已逻辑过期,则尝试获取互斥锁。
- 拿到锁的线程开启一个新线程去异步更新缓存,自己则返回旧的、已过期的数据。
- 没拿到锁的线程也直接返回旧的、已过期的数据。
- 优点:用户体验好,不会出现所有线程都等待的情况,保证了高可用性。
- 缺点:会返回过期数据,只能保证最终一致性,不能保证强一致性。
三、缓存雪崩
1. 是什么?
缓存雪崩是指大量的缓存key在同一时间段内集中过期,或者Redis缓存服务直接宕机。导致所有请求这些数据的请求都会落到数据库上,引起数据库压力过大甚至宕机,进而导致整个系统崩溃。
打个比方:缓存击穿是“一颗子弹击穿了防线”,而缓存雪崩是“整个防线的地雷在同一时刻全部被引爆”,后果是毁灭性的。
2. 产生原因
- 大量的key设置了相同的过期时间(例如,缓存数据在每天零点统一刷新)。
- Redis集群宕机。
3. 解决方案
错开过期时间:
- 做法:在设置key的过期时间时,加上一个随机值,让key的过期时间均匀分布。
- 例如:
set_redis_key(key, value, time + random(0, 300))// 在基础时间上增加一个0到5分钟的随机数。 - 优点:简单有效,能避免大量key同时失效。
构建高可用的Redis集群:
- 做法:通过主从复制、哨兵模式或Redis Cluster来搭建集群,实现服务的高可用。即使个别节点宕机,集群仍然可以提供服务。
- 优点:从根本上解决了因单点故障导致的雪崩。
服务降级与熔断:
- 做法:当非核心服务出现故障时,暂时将其关闭(降级);当检测到数据库压力过大时,直接拒绝部分请求或返回预定义的默认值(熔断),保护数据库不被拖垮。
- 工具:可以使用Hystrix、Sentinel等组件实现。
- 优点:保证了核心服务的可用性,牺牲了部分非核心功能或用户体验。
持久化缓存:
- 做法:同缓存击穿,对部分非常重要的数据设置永不过期,通过异步方式更新。
总结与对比
| 问题类型 | 核心特征 | 根本原因 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 数据在DB和缓存中都不存在 | 1. 缓存空对象 2. 布隆过滤器 |
| 缓存击穿 | 单个热点key过期时面临高并发 | 热点key失效 + 高并发 | 1. 永不过期 2. 互斥锁 3. 逻辑过期 |
| 缓存雪崩 | 大量key同时过期或Redis服务宕机 | 缓存大规模失效 | 1. 错开过期时间 2. 构建高可用集群 3. 服务降级与熔断 |
在实际项目中,通常需要根据业务场景,组合使用这些方案来构建一个健壮的缓存系统。例如,使用布隆过滤器防止穿透,用互斥锁/逻辑过期防止击穿,用随机过期时间和集群防止雪崩。