面试题一:什么是缓存雪崩?如何解决缓存雪崩?
缓存雪崩指的是在短时间内,有大量的请求直接查询术后句酷,从而对数据库造成大量的压力,严重情况下可能导致数据库宕机的情况叫做缓存雪崩
我们可以看一下正常的情况下程序执行流程图:
当出现缓存雪崩的时候,流程图如下:
由此可以看出缓存雪崩造成的影响,导致缓存雪崩的主要原因有以下几种:
- 缓存过期时间设置不合理:当大量缓存数据在同一时间失效时,会导致大量请求直接打到数据库或者后端服务
- 缓存服务器故障:如果缓存服务器发生故障,无法体哦共缓存服务,那么所有请求都会直接访问数据库或后端服务
- 缓存数据的热点分布不均:如果某些热门数据集中一部分缓存节点上,当这些节点发生故障或者数据失效的 ,会导致请求直接打到数据库或者后端服务
如何解决缓存雪崩
缓存雪崩的常见解决方法有以下几种:
- 随机生成缓存过期时间:随机生成缓存过期时间,可以避免缓存同时过期,从而让避免缓存雪崩问题
//缓存原本的失效时间
int exTime = 10*60;
//随机数生成类
Random random = new Random();
//缓存设置
jedis.setex(cacheKey,exTime+random.nextInt(1000) ,value);
- 使用多级缓存:可以使用多级缓存架构,将热门数据同时缓存在多个缓存节点上,避免单一节点故障导致请求直接访问数据库或者后端服务,例如可以设计二级换内存(分布式缓存+本地缓存),如图:
- 缓存过期前预加载:在缓存即将过期之前,提前异步加载缓存,避免在缓存失效时大量请求直接打到数据库或者后端服务
- 开启限流或降级功能:当缓存发生雪崩时,采用限流或降级的机制来减轻服务器压力,保证系统可用性
- 实时及监控和预警:通过监控缓存的状态和命中率,及时发现缓存的问题
面试题二:什么是缓存穿透?如何解决缓存穿透?
缓存穿透是指,当我们查询一个数据库和缓存中都不存在的数据时,由于数据库查询结果为空,出于容错考虑,我们通常不会将这个空结果保存到缓存中。因此,每次对这个数据的请求都会直接查询数据库,而不是缓存。这就导致数据库需要处理额外的查询压力,从而可能降低系统的整体性能
简单来说,缓存穿透就是指数据库查询没有数据,出于容错考虑,不会将结果保存到缓存中,因此每次请求都会去查询数据库
缓存穿透执行流程如下:
其中红色路径代表缓存穿透的执行路径,可以看出缓存穿透会给数据库造成很大压力
如何解决缓存穿透
- 缓存空对象:对于查询结果为null或不存在的数据,也可以将它们以特殊值(如:NULL或特殊符号)进行缓存,并设置较短的过期时间。这样,短时间相同的查询请求就可以直接从缓存中获得响应,避免了对数据库的直接查询
- 布隆过滤器:在请求达到缓存之前,先通过布隆过滤器判断数据可能存在还是一定不存在。对于不存在的数据,可以直接返回;可能存在的则继续查询缓存和数据库。布隆过滤器是一种空间效率极高的概率型数据结构,他会给出“可能存在”或者“一定不存在”的答案
- 开启限流功能:当发现大量连续未命中的请求的时候,可以采用限流策略限制同一时间内向数据库发送的查询请求数量,减轻数据库压力
面试题三:什么是缓存击穿?如何解决缓存击穿?
缓存击穿是指某个热点缓存,在某一时刻恰好失效了,然后此时刚好有大量的并发请求,此时这些请求会给数据库造成巨大的压力
缓存击穿的执行流程:
缓存击穿主要的原因是热点数据在缓存中失效或被淘汰,并发请求同属访问该数据,导致缓存无法命中
如何解决缓存击穿
- 设置永不过期:对于某些热点缓存,我们可以设置成永不过期,这样就保证缓存的稳定性,但是需要注意在数据更改之后,要及时更新此热点缓存,不然会造成查询结果的误差
- 缓存过期前预加载:在缓存即将过期之前,提前异步加载缓存,避免在缓存失效时大量的请求直接打到数据库或者后端服务
- 使用多级缓存:可以使用多级缓存架构,将热门数据同时缓存在多个缓存节点上,避免单一节点故障导致请求直接访问数据库或者后端服务。例如可以设计多级缓存,也就是使用分布式缓存(Redis)+本地缓存(Caffeine/Guava Cache) ,如下图所示:
- 开启限流或降级功能:当缓存发生雪崩时,采用限流或降级的机制来减轻服务器压力,保证系统可用性
面试题四:什么是缓存预热?如何实现缓存预热?
缓存与炽热是指在系统启动、高峰期来临之前或者数据变更之后,提前将热门或者需要经常访问的数据加载到缓存中,以提高系统的响应性能和缓存命中率。通过缓存预热,可以避免在实际请求到来的时候出现缓存穿透和缓存击穿的情况,减少对后端存储的直接访问
实现缓存你预热的一般步骤如下:
- 确定热门数据:首先需要确定哪些数据是热门或者经常访问的数据。可以通过系统日志、业务需求、数据统计分析等方式进行评估
- 加载数据到缓存:在系统启动、高峰期来临之前或者数据变更之后,提前将热门数据加载到缓存中。可以通过定时任务、异步加载、批量加载等方式来实现数据加载
- 设置适当的过期时间:根据业务需求和数据的访问频率,设置适当的缓存过期时间。过期时间可以根据不同的数据进行灵活调整,以保证缓存数据的有效性
- 监控和维护:在缓存预热完成后,需要进行监控和维护。可以通过监控缓存命中率、缓存失效率指标来评估
缓存预热的实现
手动初始化:在程序启动阶段或者服务初始化的时候,通过编写代码主动的从数据库加载热点数据,并将其放入缓存中(如:Redis)
//初始化阶段加载热点数据
public void warmUpCache() {List<HotData> hotDatas = loadHotDataFromDatabase();for (HotData data : hotDatas) {string key = buildKey(data.getId());redisTemplate.opsForValue().set(key, data expirationTime TimeUnit.MINUTES);}
}
定时任务:使用定时任务定期刷新或者加载数据到缓存中,可以是固定时间间隔,也可以是在数变更后触发
事件驱动:当有新的数据添加到数据库时,触发一个时间来通知缓存系统加载数据
使用框架:某些框架或者中间件提供了缓存预热功能的支持。例如,在Spring Boot项目中,可以通过实现CommandLineRunner或ApplicationRunner接口,在应用启动自动加载数据到缓存
你的代码片段似乎有一些语法错误,我已经为你修正了。在Spring Boot中,一个类可以同时实现CommandLineRunner
和ApplicationRunner
接口,并且可以在run
方法中添加自定义的操作。以下是修正后的代码:
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;@Component
public class MyRunner implements CommandLineRunner, ApplicationRunner {@Overridepublic void run(String... args) throws Exception {System.out.println("This is CommandLineRunner"); // 实现自定义操作}@Overridepublic void run(ApplicationArguments args) throws Exception {System.out.println("This is ApplicationRunner"); // 实现自定义操作}
}
在这段代码中,MyRunner
类实现了CommandLineRunner
和ApplicationRunner
接口。当Spring Boot应用启动完成后,它会自动执行这两个接口的run
方法。在这两个方法中,你可以添加自定义的操作,比如加载数据到缓存(即缓存预热)等
面试题五:在SpringBoot中如何实现缓存预热?
缓存预热是指在项目启动时,预先将数据加载到缓存系统(如Redis)中的一种机制。在Spring Boot项目中,可以通过以下几种方式实现缓存预热123:
-
使用启动监听事件实现缓存预热:可以使用ApplicationListener监听ContextRefreshedEvent或ApplicationReadyEvent等应用上下文初始化完成事件,在这些事件触发后执行数据加载到缓存的操作。
@Component public class CacheWarmer implements ApplicationListener<ContextRefreshedEvent> { @Override public void onApplicationEvent(ContextRefreshedEvent event) { // 执行缓存预热业务... cacheManager.put("key", dataList); } }
-
使用@PostConstruct注解实现缓存预热:在需要进行缓存预热的类上添加@Component注解,并在其方法中添加@PostConstruct注解和缓存预热的业务逻辑。
@Component public class CachePreloader { @Autowired private YourCacheManager cacheManager; @PostConstruct public void preloadCache() { // 执行缓存预热业务... cacheManager.put("key", dataList); } }
-
使用CommandLineRunner或ApplicationRunner实现缓存预热:CommandLineRunner和ApplicationRunner都是Spring Boot应用程序启动后要执行的接口,它们都允许我们在应用启动后执行一些自定义的初始化逻辑,例如缓存预热。
@Component public class MyCommandLineRunner implements CommandLineRunner { @Override public void run(String ... args) throws Exception { // 执行缓存预热业务... cacheManager.put("key", dataList); } }
面试题六:什么是IoC?它解决了什么问题?为什么要使用它?
IoC和AOP是Spring中最核心的两个概念。IoC全称为Inversion of Control,中文意为“控制反转”。它不是一种技术,而是一种设计思想。在Java开发中,IoC意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制它
IoC解决了什么问题
其实IoC就是把具体实例化对象的步骤交给容器处理。这样可以降低对象之间的耦合度,是得资源变得更容易管理
为什么要用IoC
因为IoC可以帮助我们解决传统开发模式中遇到的问题,比如创建了许多重复的对象,造成大量的资源浪费,更换实现类需要改动多个地方,创建和配置组件工作繁杂,给组件调用方带来极大不便。通过使用IoC,我们可以将对象的控制权(创建、管理)交由IoC容器去管理,我们在使用的时候直接向IoC容器“要”就可以了。这样,我们就可以专注于业务逻辑的实现,而不需要关心对象的创建和管理等一系列的事情
面试题七:IoC和DI有什么关系?IoC的实现除了DI之外,还有其他实现方式吗?
IoC(Inversion of Control,控制反转)和DI(Dependency Injection,依赖注入)是两个密切相关的概念,它们都是面向对象编程和设计模式中的重要思想
IoC是一种设计思想,它基本思想是将对象的创建、销毁、依赖关系的维护等控制权从程序代码中转移出去,交给容器管理。这样可以降低对象之间的耦合度,使得资源变得容易管理,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活
DI是IoC的一种具体的实现方式,它是指将对象锁以来的其他对象(即依赖)通过构造方法、Setter方法或其他方式注入到对象中,从而消除对象之间的耦合关系.DI可以使对象之间的依赖关系更加清晰、简单和可维护
以下是如何在Spring中实现DI:
- 构造方法注入:
@Component public class UserService {private final DbDriver dbDriver;public UserService(DbDriver dbDriver) {this.dbDriver = dbDriver;}public void doSomethingWithUser() {List<User> users = dbDriver.searchUsers();// 处理users} }
- Setter方法注入:
@Component public class UserService {private DbDriver dbDriver;@Autowiredpublic void setDbDriver(DbDriver dbDriver) {this.dbDriver = dbDriver;}public void doSomethingWithUser() {List<User> users = dbDriver.searchUsers();// 处理users} }
总的来说,IoC是一种思想,它强调将对象之间的依赖关系的控制权交给容器来管理;DI则是一种具体的实现方式,它强调将对象所依赖的其他对象通过注入的方式来消除对象之间的耦合关系
面试题八:BeanFactory和FactoryBean有什么区别?
BeanFactory和FactoryBean完全不同的两个接口,BeanFactory是用来管理Bean对象的,而FactoryBean本质上是一个Bean,也是归BeanFactory管理的,但是使用FactoryBean可以来创建普通的Bean对象和AOP代理对象,它们具体区别如下:
BeanFactory:
BeanFactory
是 Spring 框架的核心接口之一,用于管理和查找 Spring Bean。- 它是一个工厂模式的实现,负责创建和管理 Bean 对象。在 Spring 容器中,BeanFactory 负责实例化、装配和管理 Bean 的生命周期。
BeanFactory
的实现类包括XmlBeanFactory
和DefaultListableBeanFactory
。
其中ApplicationContext就是BeanFactory的子类,咱们通常会使用ApplicationContext来获取某个Bean:
BeanFactory使用示例:
// 创建BeanFactory容器
BeanFactory beanFactory = new XmlBeanFactory(newClassPathResource("applicationContext.xml"));
// 获取bean实例
YourBean yourBean =(YourBean) beanFactory.getBean("yourBeanName");
BeanFactory的主要使用场景:从IoC容器中获取Bean对象
FactoryBean:
FactoryBean
是一个接口,用于创建复杂的 Bean 实例,可以作为一种更高级别的工厂,允许在创建 Bean 时进行更多的控制。- 实现
FactoryBean
接口的类必须实现getObject()
方法,该方法定义了创建和返回实际 Bean 实例的逻辑。 - 当将实现了
FactoryBean
接口的类配置到 Spring 容器中时,实际上容器会管理这个FactoryBean
实例,而不是它创建的实际 Bean。要获取实际 Bean,需要调用FactoryBean
的getObject()
方法。
import org.springframework.beans.factory.FactoryBean;public class MyBean implements FactoryBean<Object> {private String message;// 通过构造方法初始化实例public MyBean() {this.message = "通过构造方法初始化实例";}// 方法增强@Overridepublic Object getObject() throws Exception {// 通过 FactoryBean.getObject() 创建实例return new MyBean("通过 FactoryBean.getObject() 创建实例");}@Overridepublic Class<?> getObjectType() {return MyBean.class;}public String getMessage() {return message;}
}
面试题九:什么是AOP?实际工作中AOP的使用场景有哪些?
AOP(面向切片编程)可以说是OOP(面向对象编程)的补充和完善,OOP引入了封装、继承、多态等概念来建立一种公共对象处理的能力,当我们需要处理公共行为的时候,OOP就会显得无能为力,而AOP的出现正好解决了这个问题。比如统一的日志处理模块、授权验证模块等都可以使用AOP很轻松的处理
AOP主要有以下几种:
- 集中处理某一类问题,方便维护
- 逻辑更加清晰
- 降低模块之间的耦合度
AOP常见的使用场景有以下几种:
- 用户登录和鉴权
- 统一日志记录
- 统一方法执行时间统计
- 统一返回格式设置
- 统一的异常处理
- 声明式事务实现
我们用例子更好的理解,AOP的使用场景:
考虑一个面向对象的情景,有一个服务类 UserService
包含一些方法,如 createUser
、updateUser
等。如果我们想要在每个方法执行前后记录日志,可能会倾向于在每个方法中添加日志记录的代码。这可能会导致代码重复,而且如果需要修改日志记录的方式,需要在每个方法中进行修改
public class UserService {public void createUser(User user) {// 日志记录 - 方法开始log.info("Creating user: " + user.getName());// 具体的创建用户逻辑// ...// 日志记录 - 方法结束log.info("User created successfully: " + user.getName());}public void updateUser(User user) {// 日志记录 - 方法开始log.info("Updating user: " + user.getName());// 具体的更新用户逻辑// ...// 日志记录 - 方法结束log.info("User updated successfully: " + user.getName());}// 其他方法...
}
在这种情况下,OOP 在处理日志记录时可能显得繁琐,因为我们需要在每个方法中添加相似的日志记录代码
现在,让我们使用 AOP 来处理这个横切关注点。我们可以创建一个日志切面,将日志记录的逻辑从服务类中分离出来。这样,我们只需在切面中定义一次日志记录的逻辑,然后通过配置将切面应用到需要的方法中
@Aspect
@Component
public class LoggingAspect {@Before("execution(* com.example.UserService.*(..))")public void logMethodStart(JoinPoint joinPoint) {log.info("Method start: " + joinPoint.getSignature().toShortString());}@After("execution(* com.example.UserService.*(..))")public void logMethodEnd(JoinPoint joinPoint) {log.info("Method end: " + joinPoint.getSignature().toShortString());}
}
通过 AOP,我们将日志记录逻辑从业务代码中解耦,实现了统一的日志记录,避免了代码的重复和耦合。这是 AOP 在处理横切关注点时的一个实际应用例子。
面试题十:说一下AOP的底层实现原理?
AOP底层原理可以划分成四个阶段:创建代理对象阶段、拦截目标对象阶段、调用代理对象阶段、调用目标对象阶段
第一阶段:创建代理对象阶段
- 通过getBean()方法创建Bean实例
- 根据AOP的配置匹配目标类的类名,判断是否满足切面规则,规则指的是:
// 切面规则:匹配所有以 "Service" 结尾的类的所有方法 execution(* com.example.*Service.*(..))
- 如果满足规则,调用ProxyFactory创建代理Bean并缓存到IoC容器中
- 根据目标对象的是否实现接口选择不同的代理策略,通常是JDK Proxy(基于接口的代理)或Cglib Proxy(基于类的代理)
目标对象:就是我们自己写的业务代码
第二阶段:拦截目标对象阶段
- 当用户调用目标对象的方法的时候,被一个名为AopProxy的对象拦截
- AopProxy对象封装了所有的调用策略,并且实现了IncationHandler接口
- 在IncationHandler的invoke()方法中,出发了MethodInvocation的proceed()方法
- proceed()方法按照顺序执行符合所有AOP拦截规则的拦截器链
其中invoke()
方法用于定义切面的逻辑,而 proceed()
方法用于在拦截器链中继续执行下一个拦截器或最终执行目标方法
MethodInvocation:负责执行拦截器链,在proceed()方法中执行;
第三阶段:调用代理对象阶段
- AOP拦截器链中的每个元素被称为MethodInterceptor,即切面配置中的Advice通知
- MethodInterceptor接口的invoke()方法被织入的代码片段
- 这些被织入的代码片段在这个阶段执行,通常是切片配置中定义的通知方法
织入代码:就是要在我们自己写的业务代码增加的代码片段;
切面通知:就是封装织入代码片段的回调方法;
负责执行织入的代码片段,在invoke()方法中执行。
第四阶段:调用目标对象阶段
- MethodInterceptor接口中的invoke()方法触发对目标对象方法的调用,即反射调用目标对象的方法,例如:
public class MyInterceptor implements MethodInterceptor {@Overridepublic Object invoke(MethodInvocation invocation) throws Throwable {System.out.println("Before method execution");// 通过反射调用目标对象的方法Object result = invocation.proceed();System.out.println("After method execution");return result;} }