今天让我们来看看「同程旅行」Java后端开发的面经,问题相比大厂是少了一些,总共 20 多个问题,其中有 10 多个是八股,剩下有些是项目问题,这次我们重点看看八股的问题,无手撕算法。
大家看看难度如何?
考察的知识点,我给大家罗列了一下:
- Java:反射、stream、线程创建与同步、线程池、JWT
- RocketMQ:使用场景
- Redis:缓存雪崩、缓存穿透
Java
介绍一下反射的特性
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类中的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性。
这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。反射具有以下特性:
- 运行时类信息访问:反射机制允许程序在运行时获取类的完整结构信息,包括类名、包名、父类、实现的接口、构造函数、方法和字段等。
- 动态对象创建:可以使用反射API动态地创建对象实例,即使在编译时不知道具体的类名。这是通过Class类的newInstance()方法或Constructor对象的newInstance()方法实现的。
- 动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。这通过Method类的invoke()方法实现,允许你传入对象实例和参数值来执行方法。
- 访问和修改字段值:反射还允许程序在运行时访问和修改对象的字段值,即使是私有的。这是通过Field类的get()和set()方法完成的。
应用场景:
- Java反射机制在现代软件开发中,尤其是在企业级应用和框架设计中扮演着重要角色,尤其是在我们平时用的spring框架中,很多地方都用到了反射,让我们来看看Spring的IoC和AOP是如何使用反射技术的:
- Spring框架的依赖注入(DI)和控制反转(IoC)
Spring框架是Java生态系统中最流行的框架之一,它大量使用反射来实现其核心特性——依赖注入。在Spring中,开发者可以通过XML配置文件或者基于注解的方式声明组件之间的依赖关系。
当应用程序启动时,Spring容器会扫描这些配置或注解,然后利用反射来实例化Bean(即Java对象),并根据配置自动装配它们的依赖。
例如,当一个Service类需要依赖另一个DAO类时,开发者可以在Service类中使用@Autowired注解,而无需自己编写创建DAO实例的代码。Spring容器会在运行时解析这个注解,通过反射找到对应的DAO类,实例化它,并将其注入到Service类中。这样不仅降低了组件之间的耦合度,也极大地增强了代码的可维护性和可测试性。
- 动态代理的实现
在需要对现有类的方法调用进行拦截、记录日志、权限控制或是事务管理等场景中,反射结合动态代理技术被广泛应用。一个典型的例子是Spring AOP(面向切面编程)的实现。Spring AOP允许开发者定义切面(Aspect),这些切面可以横切关注点(如日志记录、事务管理),并将其插入到业务逻辑中,而不需要修改业务逻辑代码。
例如,为了给所有的服务层方法添加日志记录功能,可以定义一个切面,在这个切面中,Spring会使用JDK动态代理或CGLIB(如果目标类没有实现接口)来创建目标类的代理对象。
这个代理对象在调用任何方法前或后,都会执行切面中定义的代码逻辑(如记录日志),而这一切都是在运行时通过反射来动态构建和执行的,无需硬编码到每个方法调用中。
这两个例子展示了反射机制如何在实际工程中促进松耦合、高内聚的设计,以及如何提供动态、灵活的编程能力,特别是在框架层面和解决跨切面问题时。
Java中stream的API介绍一下
Java 8引入了Stream API,它提供了一种高效且易于使用的数据处理方式,特别适合集合对象的操作,如过滤、映射、排序等。Stream API不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理。让我们通过两个具体的例子来感受下Java Stream API带来的便利,对比在Stream API引入之前的传统做法。
案例1:过滤并收集满足条件的元素
问题场景:从一个列表中筛选出所有长度大于3的字符串,并收集到一个新的列表中。没有Stream API的做法:
List<String> originalList = Arrays.asList("apple", "fig", "banana", "kiwi"); List<String> filteredList = new ArrayList<>();
for (String item : originalList) {
if (item.length() > 3) {
filteredList.add(item);
}
}
这段代码需要显式地创建一个新的ArrayList,并通过循环遍历原列表,手动检查每个元素是否满足条件,然后添加到新列表中。使用Stream API的做法:
List<String> originalList = Arrays.asList("apple", "fig", "banana", "kiwi");
List<String> filteredList = originalList.stream()
.filter(s -> s.length() > 3)
.collect(Collectors.toList());
这里,我们直接在原始列表上调用.stream()
方法创建了一个流,使用.filter()
中间操作筛选出长度大于3的字符串,最后使用.collect(Collectors.toList())
终端操作将结果收集到一个新的列表中。代码更加简洁明了,逻辑一目了然。
案例2:计算列表中所有数字的总和
问题场景:计算一个数字列表中所有元素的总和。没有Stream API的做法:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (Integer number : numbers) {
sum += number;
}
这个传统的for-each循环遍历列表中的每一个元素,累加它们的值来计算总和。使用Stream API的做法:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();
通过Stream API,我们可以先使用.mapToInt()
将Integer流转换为IntStream(这是为了高效处理基本类型),然后直接调用.sum()
方法来计算总和,极大地简化了代码。
线程的创建方式有哪些
1.继承Thread类
这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。
class MyThread extends Thread {
@Override
public void run() {
// 线程执行的代码
}
}
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
采用继承Thread类方式
- 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程
- 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类
2.实现Runnable接口
如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。
class MyRunnable implements Runnable {
@Override
public void run() {
// 线程执行的代码
}
}
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start();
}
采用实现Runnable接口方式:
- 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
- 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。
- 实现Callable接口与FutureTask
java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
// 线程执行的代码,这里返回一个整型结果
return 1;
}
}public static void main(String[] args) {
MyCallable task = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start();try { Integer result = futureTask.get(); // 获取线程执行结果 System.out.println("Result: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); }
}
采用实现Callable接口方式:
- 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。
- 优点:线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
- 使用线程池(Executor框架)
从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。
class Task implements Runnable {
@Override
public void run() {
// 线程执行的代码
}
}
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10); // 创建固定大小的线程池
for (int i = 0; i < 100; i++) {
executor.submit(new Task()); // 提交任务到线程池执行
}
executor.shutdown(); // 关闭线程池
}
采用线程池方式:
- 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
- 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。
线程同步介绍一下
线程同步是多线程编程中的一个重要概念,用于控制多个线程对共享资源的访问,确保在任一时刻只有一个线程能够访问共享资源,以防止数据不一致、脏读等问题。Java提供了多种线程同步机制来保证线程安全,主要包括:
synchronized关键字
- 方法同步:在方法声明上使用synchronized关键字,这样一次只有一个线程可以访问该方法。
public synchronized void method() {
// 方法体
}
- 代码块同步:可以在特定的代码块前加上synchronized关键字,指定一个锁对象,这样同步代码块在执行时会锁定该对象,其他任何需要该锁的代码必须等待锁释放。
public void method() {
synchronized(this) { // 或者是特定的对象实例
// 需要同步的代码块
}
}
Lock接口及其实现
从Java 5开始,提供了java.util.concurrent.locks.Lock接口作为synchronized的替代,它提供了比synchronized更灵活的锁定机制,如尝试获取锁、可定时获取锁以及公平锁等。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class Counter {
private final Lock lock = new ReentrantLock();public void increment() { lock.lock(); // 获取锁 try { // 增量操作 } finally { lock.unlock(); // 一定要在finally块中释放锁 } }
}
volatile关键字
虽然volatile主要用于变量的可见性保证,而不是同步,但它可以用来确保多线程环境下的变量修改对其他线程是立即可见的,适用于状态标记等简单场景。
private volatile boolean flag = false;
public void setFlag(boolean newValue) {
flag = newValue;
}
public boolean getFlag() {
return flag;
}
Atomic类
java.util.concurrent.atomic包提供了一系列原子操作类,如AtomicInteger、AtomicBoolean等,它们通过CAS(Compare and Swap)无锁算法实现线程安全的原子操作,适合于简单的数值操作场景。
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger count = new AtomicInteger(0);public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); }
}
介绍一下线程池
线程池原理
线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗。
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
线程池的参数有哪些?
线程池的构造函数有7个参数:
- corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。
- maximumPoolSize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。
- keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。
- unit:就是keepAliveTime时间的单位。
- workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。
- threadFactory:线程工厂。可以用来给线程取名字等等
- handler:拒绝策略。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略
线程池种类
- FixedThreadPool:它的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
ExecutorService executor = Executors.newFixedThreadPool(5);
- CachedThreadPool:可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
ExecutorService executor = Executors.newCachedThreadPool();
- SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
ExecutorService executor = Executors.newSingleThreadExecutor();
- SingleThreadScheduledExecutor:它实际和 ScheduledThreadPool 线程池非常相似,它只是 ScheduledThreadPool 的一个特例,内部只有一个线程。
ExecutorService executor = Executors.newScheduledThreadPool(5);
前端是如何存储JWT的?
JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案。互联网服务离不开用户认证。
一般流程如下:
- 用户向服务器发送用户名和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
- 服务器向用户返回一个 session_id,写入用户的 Cookie。
- 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
- 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
客户端收到服务器返回的 JWT,可以储存在 Local Storage 里面,也可以储存在Cookie里面,还可以存储在Session Storage里面。下面将说明存在上述各个地方的优劣势:
- Local Storage(本地存储)
- 优点:Local Storage 提供了较大的存储空间(一般为5MB),且不会随着HTTP请求一起发送到服务器,因此不会出现在HTTP缓存或日志中。
- 缺点:存在XSS(跨站脚本攻击)的风险,恶意脚本可以通过JavaScript访问到存储在Local Storage中的JWT,从而盗取用户凭证。
- Session Storage(会话存储)
- 优点:与Local Storage类似,但仅限于当前浏览器窗口或标签页,当窗口关闭后数据会被清除,这在一定程度上减少了数据泄露的风险。
- 缺点:用户体验可能受影响,因为刷新页面或在新标签页打开相同应用时需要重新认证。
- Cookie
- 优点:可以设置HttpOnly标志来防止通过JavaScript访问,减少XSS攻击的风险;可以利用Secure标志确保仅通过HTTPS发送,增加安全性。
- 缺点:大小限制较小(通常4KB),并且每次HTTP请求都会携带Cookie,可能影响性能;设置不当可能会受到CSRF(跨站请求伪造)攻击。
cookie和session之间区别,介绍一下
Cookie和Session都是Web开发中用于跟踪用户状态的技术,但它们在存储位置、数据容量、安全性以及生命周期等方面存在显著差异:
- 存储位置:Cookie的数据存储在客户端(通常是浏览器)。当浏览器向服务器发送请求时,会自动附带Cookie中的数据。Session的数据存储在服务器端。服务器为每个用户分配一个唯一的Session ID,这个ID通常通过Cookie或URL重写的方式发送给客户端,客户端后续的请求会带上这个Session ID,服务器根据ID查找对应的Session数据。
- 数据容量:单个Cookie的大小限制通常在4KB左右,而且大多数浏览器对每个域名的总Cookie数量也有限制。由于Session存储在服务器上,理论上不受数据大小的限制,主要受限于服务器的内存大小。
- 安全性:Cookie相对不安全,因为数据存储在客户端,容易受到XSS(跨站脚本攻击)的威胁。不过,可以通过设置HttpOnly属性来防止JavaScript访问,减少XSS攻击的风险,但仍然可能受到CSRF(跨站请求伪造)的攻击。Session通常认为比Cookie更安全,因为敏感数据存储在服务器端。但仍然需要防范Session劫持(通过获取他人的Session ID)和会话固定攻击。
- 生命周期:Cookie可以设置过期时间,过期后自动删除。也可以设置为会话Cookie,即浏览器关闭时自动删除。Session在默认情况下,当用户关闭浏览器时,Session结束。但服务器也可以设置Session的超时时间,超过这个时间未活动,Session也会失效。
- 性能:使用Cookie时,因为数据随每个请求发送到服务器,可能会影响网络传输效率,尤其是在Cookie数据较大时。使用Session时,因为数据存储在服务器端,每次请求都需要查询服务器上的Session数据,这可能会增加服务器的负载,特别是在高并发场景下。
如果客户端禁用了cookie,session还能用吗?
默认情况下禁用 Cookie 后,Session 是无法正常使用的,因为大多数 Web 服务器都是依赖于 Cookie 来传递 Session 的会话 ID 的。
客户端浏览器禁用 Cookie 时,服务器将无法把会话 ID 发送给客户端,客户端也无法在后续请求中携带会话 ID 返回给服务器,从而导致服务器无法识别用户会话。
但是,有几种方法可以绕过这个问题,尽管它们可能会引入额外的复杂性和/或降低用户体验:
- URL重写:每当服务器响应需要保持状态的请求时,将Session ID附加到URL中作为参数。例如,原本的链接http://example.com/page变为http://example.com/page;jsessionid=XXXXXX,服务器端需要相应地解析 URL 来获取 Session ID,并维护用户的会话状态。这种方式的缺点是URL变得不那么整洁,且如果用户通过电子邮件或其他方式分享了这样的链接,可能导致Session ID的意外泄露。
- 隐藏表单字段:在每个需要Session信息的HTML表单中包含一个隐藏字段,用来存储Session ID。当表单提交时,Session ID随表单数据一起发送回服务器,服务器通过解析表单数据中的 Session ID 来获取用户的会话状态。这种方法仅适用于通过表单提交的交互模式,不适合链接点击或Ajax请求。
RocketMQ
RocketMQ的使用场景有哪些?
- 解耦:可以在多个系统之间进行解耦,将原本通过网络之间的调用的方式改为使用MQ进行消息的异步通讯,只要该操作不是需要同步的,就可以改为使用MQ进行不同系统之间的联系,这样项目之间不会存在耦合,系统之间不会产生太大的影响,就算一个系统挂了,也只是消息挤压在MQ里面没人进行消费而已,不会对其他的系统产生影响。
- 异步:加入一个操作设计到好几个步骤,这些步骤之间不需要同步完成,比如客户去创建了一个订单,还要去客户轨迹系统添加一条轨迹、去库存系统更新库存、去客户系统修改客户的状态等等。这样如果这个系统都直接进行调用,那么将会产生大量的时间,这样对于客户是无法接收的;并且像添加客户轨迹这种操作是不需要去同步操作的,如果使用MQ将客户创建订单时,将后面的轨迹、库存、状态等信息的更新全都放到MQ里面然后去异步操作,这样就可加快系统的访问速度,提供更好的客户体验。
- 削峰:一个系统访问流量有高峰时期,也有低峰时期,比如说,中午整点有一个抢购活动等等。比如系统平时流量并不高,一秒钟只有100多个并发请求,系统处理没有任何压力,一切风平浪静,到了某个抢购活动时间,系统并发访问了剧增,比如达到了每秒5000个并发请求,而我们的系统每秒只能处理2000个请求,那么由于流量太大,我们的系统、数据库可能就会崩溃。这时如果使用MQ进行流量削峰,将用户的大量消息直接放到MQ里面,然后我们的系统去按自己的最大消费能力去消费这些消息,就可以保证系统的稳定,只是可能要跟进业务逻辑,给用户返回特定页面或者稍后通过其他方式通知其结果
Redis
Redis雪崩和穿透介绍一下,解决方案是什么?
缓存雪崩
当大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
对于缓存雪崩问题,我们可以采用两种方案解决。
- 将缓存失效时间随机打散: 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。
- 设置缓存不过期: 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。
缓存穿透
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
应对缓存穿透的方案,常见的方案有三种。
- 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
- 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。