多线程篇-线程安全-原子性、可见性、有序性解析

在程序中使用多线程的目的是什么?

1、提高效率,增加任务的吞吐量
2、提升CPU等资源的利用率,减少CPU的空转

多线程的应用在日常开发中很多,带来了很多的便利,让我们以前研究下在多线程场景中要注意问题吧,一般主要从这三个方面考虑

1、原子性
2、可见性
3、有序性

如果不能保证原子性、可见性和顺序性会有什么问题?这些问题怎么解决呢?让我们一起来看下

一、原子性

原子性的操作是不可被中断的一个或一系列操作。

个人理解,严格的原子性的操作,其他线程获取操作的变量时,只能获取操作前的变量值和操作后的变量值,不能获取到操作过程中的中间值,在操作过程中其他操作需要获取变量值,需要进入阻塞状态等待操作结束。

如果不能保证原子性会有什么问题呢

让我们一块看一个简单例子吧

首先写一个简单的线程,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ThreadDemo implements Runnable{
int no = 0;
@Override
public void run() {
no++;
System.out.println(no);
}
public static void main(String[] args)
{
ThreadDemo demo = new ThreadDemo();
for(int i=0;i<1000;i++)
{
Thread task = new Thread(demo);
task.start();
}
}
}

Main函数中启动了1000个线程,每个线程都对no进行了一次+1操作,理想情况下no最后的结果应该是1000,可是我的运行最终结果只有996,执行了1000次+1操作,最后的结果为什么不是1000呢,这就要说到no++操作不是原子的问题了(no可见性的问题在下一个小结讨论)

先对no++这个操作分解,可以分为三步:取值,加一,赋值,这三个操作都是原子的,不过合在一起就不行了。两个线程A、B一起来操作no,no初始值是1,线程A读取no值是1,然后做no+1,这时线程B对 no取值还是1,然后A将2赋值给no,B操作no+1结果是2,也将2赋值给no。对1做了两次+1操作,最后结果是2。这个过程可以参考下图

img

所以,需要保证这种不可分割操作的原子性,那要怎么做才能保证原子性呢,有两种方式

1、加锁synchronized,保证同一时间只有一个线程操作变量,其他线程需等待操作结束才能使用临界资源

2、使用CAS操作,变量计算前保留一份旧值a,计算完成后结果值为b,把b刷到内存之前先比较a是否和内存中变量一致,如果一致,就把内存中的变量赋值为b,不一样,重新获取内存中变量值,重复一遍操作,一直到a和内存中一致,操作结束。

Lock和原子类(AtomicInteger等)是通过使用unsafe的compareAndSwap方法实现CAS操作保证原子性的。

二、可见性

线程变量的可见性问题,需要从操作系统的CPU、缓存、内存的矛盾开始说起。读写性能上 CPU>缓存>内存>I/O。CPU/缓存/内存的结构看下图。

img

CPU和内存之间隔着缓存和CPU寄存器。缓存还分为一级、二级、三级缓存。CPU的读写性能上要大于内存,为了提高效率会将数据先取到缓存中,CPU处理完数据后会先放到缓存中,然后同步到内存中。

如果不理解CPU缓存这部分内容的话,可以简单的认为每个线程都有自己的本地工作内存,变量会先缓存到本地工作内存中使用,修改后会先修改工作内存中的存储,然后在同步到主内存中。结构如下图

img

这种内存结构会引起什么问题呢,现在有一个变量var,线程A对var做了一次修改,刚放到缓存(工作内存)还未同步到内存时,另外一个线程B也来使用var,读取到的还是var未修改值。

共享的变量需要保证可见性,怎么保证共享变量的可见性呢

1、加锁(加锁是万能的操作)synchronized和Lock都可以保证。

线程在加锁时,会清空工作内存中共享变量的值,共享变量使用是需要从主内存中重新获取。

线程解锁是,会把共享变量重新刷新到主内存中。

2、使用volatile修饰共享变量,volatile修饰的共享变量在修改后会立即被更新到内存中,其他线程使用共享变量会去内存中读取

优先使用volatile来解决可见性问题,加锁需要消耗的资源太多。

三、有序性

为了优化程序性能,编译器、处理器和运行时会对代码指令进行重排,重排过程中会遵循as-if-serial语义,即不影响单线程的运行结果。

扩展一下 指令重排为什么会提高程序性能呢,我个人理解是CPU是多核处理的,为了保证处理器资源的充分利用,对代码指令进行乱序处理,即可以多个处理器并行处理指令,防止不相关的指令需要等待上一个指令结束才能开始。

代码执行顺序被重排会是什么效果呢,举个简单例子

1
2
3
4
int a =0;
a=2;
int b =1;
int c= a+b;

这段代码中变量a和b 是相互不影响的,优化后可以是如下代码,只要保证执行结果不变,有依赖的变量c在变量a和b之后处理即可

1
2
3
4
int b =1;
int a =0;
a=2;
int c= a+b;

在单线程中这样是没有问题的,如果是多线程呢,看下如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Task {
static Object val =null;
static boolean finish =false;
public static void main(String[] args)
{
Runnable task1 = new Runnable() {
@Override
public void run() {
if(finish)
{
System.out.println(val.toString());
}
}
};
Runnable task2 = new Runnable() {
@Override
public void run() {
val = new Object();
finish =true;
}
};
}
}

分别创建两个任务task1和task2,他们共享两个变量val和finish,按现在的顺序执行的话,task1不会出现val为null时被使用的情况。不过进行了指令重排之后呢,task2中val和finish操作顺序调整对单线程来说是没有任何影响的,所以task2的代码可能会变成

1
2
3
4
5
@Override
public void run() {
finish =true;
val = new Object();
}

这样task1中,就会出现finish为true,val为null的情况了。

那么怎么保证多个线程中的代码顺序一致性呢

1、加锁(还是加锁)synchronized和Lock,保证同一时刻只有一个线程进行操作

2、使用volatile修饰变量,在JMM中volatile的读和写都会插入内存屏障来禁止处理器的重排

这样原子性、可见性、有序性就基本讲完了,其中有很多的知识点没有详细的说,例如:

CAS、volatile、synchronized、lock等等,这些会在后边的文章中慢慢研究。

注:如果弄不清楚原子性和可见性的区别,只要记住下边两点内容

1、原子性针对完整的操作过程,其他操作只能获取操作前或操作后的变量数据

2、可见性主要是变量修改,变量修改后,马上刷新到内存中,而其他线程能感知到变量的修改

RabbitMQ架构模型

RabbitMQ是采用Erlang语言实现AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件。

RabbitMQ架构模型

image-20230729110344452

image-20230729110358056

# Producer:生产者

就是投递消息的一方,生产者创建消息发布到RabbitMQ中。

消息包含2部分:

消息体(payload):

一般是一个带有业务逻辑结构的数据,比如一个JSON字符串。

标签(Label)

用来描述这条消息,比如一个交换器的名称和一个路由键。

生产者把消息交由RabbitMQ,RabbitMQ之后会根据标签吧消息发送给感兴趣的消费者

# Consumer:消费方

就是接受消息一方

消费者连接到RabbitMQ服务器,并订阅到队列上。当消费者消费一条消息时,只是消费消息的消息体(payload)。在消息路由的过程中,消息的标签会丢弃,存入到队列中的消息只有消息体,消费者也只会消费到消息体。

消息如果只是存储在队列里是没有任何用处的。被应用消费掉,消息的价值才能够体现。在 AMQP 0-9-1 模型中,有两种途径可以达到此目的:

  1. 将消息投递给应用 (“push API”)
  2. 应用根据需要主动获取消息 (“pull API”)

# Broker:服务节点

可以将一个RabbitMQ Broker看作一台RabbitMQ服务器。

image-20230729110532514

# Queue队列:

用于存储消息

image-20230729110551724

队列属性

队列跟交换机共享某些属性,但是队列也有一些另外的属性。

  • Name
  • Durable(消息代理重启后,队列依旧存在)
  • Exclusive(只被一个连接(connection)使用,而且当连接关闭后队列即被删除)
  • Auto-delete(当最后一个消费者退订后即被删除)
  • Arguments(一些消息代理用他来完成类似与 TTL 的某些额外功能)

队列创建

队列在声明(declare)后才能被使用。

如果一个队列尚不存在,声明一个队列会创建它。

如果声明的队列已经存在,并且属性完全相同,那么此次声明不会对原有队列产生任何影响。

如果声明中的属性与已存在队列的属性有差异,那么一个错误代码为 406 的通道级异常就会被抛出。

队列持久化

持久化队列(Durable queues)会被存储在磁盘上,当消息代理(broker)重启的时候,它依旧存在。没有被持久化的队列称作暂存队列(Transient queues)。并不是所有的场景和案例都需要将队列持久化。

持久化的队列并不会使得路由到它的消息也具有持久性。倘若消息代理挂掉了,重新启动,那么在重启的过程中持久化队列会被重新声明,无论怎样,只有经过持久化的消息才能被重新恢复。

# Exchange:交换器

生产者将消息发送到Exchange,由交换器将消息路由到一个或者多个队列中,如果路由不到,或许返回给生产者,或许直接丢弃。

image-20230729110643513

交换机有两种状态:

持久(durable):

持久化的交换机会在消息代理(broker)重启后依旧存在,而暂存的交换机则不会(它们需要在代理再次上线后重新被声明)。

并不是所有的应用场景都需要持久化的交换机。

暂存(transient)

类型:

–fanout广播

将所有发送到交换器的消息路由到所有与该交换器绑定对的队列中【扇形交换机,不再判断routeKey,直接将消息分发到所有绑定的队列】。

–direct直连

把消息路由到哪些BindKey和RoutingKey完全匹配的队列中。【判断touteKey的规则是完全 匹配模式,即发送消息时指定的routeKey要等于绑定的routeKey】。

–topic主题

主题交换机(topic exchanges)通过对消息的路由键和队列到交换机的绑定模式之间的匹配,将消息路由给一个或多个队列。主题交换机经常用来实现各种分发/订阅模式及其变种。主题交换机通常用来实现消息的多播路由(multicast routing)【判断routeKey的规则是模糊匹配方式】

RoutingKey为一个点号“.”分割的字符串(被点号“.”分隔开的每一段独立的字符串称为一个单词)

BindingKey中可以存在两种特殊的字符串“*”和“#”,用于做模糊匹配,

匹配0个或者多个单词

  • 匹配一个单词

image-20230729110759035

–headers头交换机

(不依赖与路由键的匹配规则,基本不用了)

绑定队列与交换器的时候指定一个键值对,当交换器在分发消息的时候会先解开消息体里的headers 数据,然后判断里面是否有所设置的键值对,如果发现匹配成功,才将消息分发到队列中;这种交换器类型在性能上相对来说较差,在实际工作中很少会用到

# RoutingKey:路由键

生产者将消息发送给交换器的时候,一般会指定一个RoutingKey,用来指定消息的路由规则,而这个RoutingKey需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。

在交换器类型和绑定键(BindingKey)固定的情况下,生产者可以发送消息给交换器时,通过指定RoutingKey来决定消息流向哪里。

# BindingKey:绑定

RabbitMQ通过绑定将交换器与队列关联起来,在绑定的时候一般会指定一个绑定键(BindingKey),这样RabbitMQ就知道如何正确的将消息路由到队列了。

image-20230729110830440

# Connection:连接

:Connection 和Channel 。我们知道无论是生产者还是消费者,都需要和RabbitMQ Broker 建立连接,这个连接就是一条TCP 连接,也就是Connection 。

image-20230729110854942

# Channel:信道

一旦TCP 连接建立起来,客户端紧接着可以创建一个AMQP 信道(Channel) ,每个信道都会被指派一个唯一的ID 。信道是建立在Connection 之上的虚拟连接, RabbitMQ 处理的每条AMQP 指令都是通过信道完成的。

我们完全可以直接使用Connection 就能完成信道的工作,为什么还要引入信道呢?

一个应用程序中有很多个线程需要从RabbitMQ 中消费消息,或者生产消息,那么必然需要建立很多个Connection ,也就是许多个TCP 连接。然而对于操作系统而言,建立和销毁TCP 连接是非常昂贵的开销,如果遇到使用高峰,性能瓶颈也随之显现。RabbitMQ 采用类似NIO’ (Non-blocking 1/0) 的做法,选择TCP 连接复用,不仅可以减少性能开销,同时也便于管理。

每个线程把持一个信道,所以信道复用了Connection 的TCP 连接。同时RabbitMQ 可以确保每个线程的私密性,就像拥有独立的连接一样。当每个信道的流量不是很大时,复用单一的Connection 可以在产生性能瓶颈的情况下有效地节省TCP 连接资源。但是当信道本身的流量很大时,这时候多个信道复用一个Connection 就会产生性能瓶颈,进而使整体的流量被限制了。此时就需要开辟多个Connection ,将这些信道均摊到这些Connection 中, 至于这些相关的调优策略需要根据业务自身的实际情况进行调节。

信道在AMQP 中是一个很重要的概念,大多数操作都是在信道这个层面展开的。在代码清单中也可以看出一些端倪,

比如channel.exchangeDeclare 、channel .queueDeclare 、channel.basicPublish 和channel.basicConsume 等方法。

RabbitMQ 相关的API 与AMQP紧密相连,比如channel.basicPublish 对应AMQP 的Basic.Publish 命令.

# vhosts:虚拟主机

为了在一个单独的代理上实现多个隔离的环境(用户、用户组、交换机、队列 等),AMQP 提供了一个虚拟主机(virtual hosts - vhosts)的概念。这跟 Web servers 虚拟主机概念非常相似,这为 AMQP 实体提供了完全隔离的环境。当连接被建立的时候,AMQP 客户端来指定使用哪个虚拟主机。

# RabbitMQ运转流程

生产者发送消息的时候

(1) 生产者连接到RabbitMQ Broker , 建立一个连接( Connection) ,开启一个信道(Channel)

(2) 生产者声明一个交换器,并设置相关属性,比如交换机类型、是否持久化等

(3) 生产者声明一个队列井设置相关属性,比如是否排他、是否持久化、是否自动删除等

(4) 生产者通过路由键将交换器和队列绑定起来

(5) 生产者发送消息至RabbitMQ Broker,其中包含路由键、交换器等信息

(6) 相应的交换器根据接收到的路由键查找相匹配的队列。

(7) 如果找到,则将从生产者发送过来的消息存入相应的队列中。

(8) 如果没有找到,则根据生产者配置的属性选择丢弃还是回退给生产者

(9) 关闭信道。

(10) 关闭连接。

消费者接收消息的过程:

(1) 消费者连接到RabbitMQ Broker ,建立一个连接(Connection ) ,开启一个信道(Channel) 。

(2) 消费者向RabbitMQ Broker 请求消费相应队列中的消息,可能会设置相应的回调函数,
以及做一些准备工作

(3) 等待RabbitMQ Broker 回应并投递相应队列中的消息, 消费者接收消息。

(4) 消费者确认( ack) 接收到的消息。

(5) RabbitMQ 从队列中删除相应己经被确认的消息。

(6) 关闭信道。

(7) 关闭连接

# AMQP协议

RabbitMQ是采用Erlang语言实现AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的消息中间件。

AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。

AMQP 说到底还是一个通信协议,通信协议都会涉及报文交互,从low-level 举例来说,AMQP 本身是应用层的协议,其填充于TCP 协议层的数据部分。而从high-level 来说, AMQP是通过协议命令进行交互的。AMQP 协议可以看作一系列结构化命令的集合,这里的命令代表一种操作,类似于HTTP 中的方法(GET 、POST 、PUT 、DELETE 等)

# 消息机制

消息确认

消费者应用(Consumer applications) - 用来接受和处理消息的应用 - 在处理消息的时候偶尔会失败或者有时会直接崩溃掉。而且网络原因也有可能引起各种问题。这就给我们出了个难题,AMQP 代理在什么时候删除消息才是正确的?AMQP 0-9-1 规范给我们两种建议:

自动确认模式:当消息代理(broker)将消息发送给应用后立即删除。(使用 AMQP 方法:basic.deliver 或 basic.get-ok))
显式确认模式:待应用(application)发送一个确认回执(acknowledgement)后再删除消息。(使用 AMQP 方法:basic.ack)
如果一个消费者在尚未发送确认回执的情况下挂掉了,那 AMQP 代理会将消息重新投递给另一个消费者。如果当时没有可用的消费者了,消息代理会死等下一个注册到此队列的消费者,然后再次尝试投递。

拒绝消息

当一个消费者接收到某条消息后,处理过程有可能成功,有可能失败。应用可以向消息代理表明,本条消息由于 “拒绝消息(Rejecting Messages)” 的原因处理失败了(或者未能在此时完成)。

当拒绝某条消息时,应用可以告诉消息代理如何处理这条消息——销毁它或者重新放入队列。

当此队列只有一个消费者时,请确认不要由于拒绝消息并且选择了重新放入队列的行为而引起消息在同一个消费者身上无限循环的情况发生。

在 AMQP 中,basic.reject 方法用来执行拒绝消息的操作。但 basic.reject 有个限制:你不能使用它决绝多个带有确认回执(acknowledgements)的消息。但是如果你使用的是 RabbitMQ,那么你可以使用被称作 negative acknowledgements(也叫 nacks)的 AMQP 0-9-1 扩展来解决这个问题。

预取消息

在多个消费者共享一个队列的案例中,明确指定在收到下一个确认回执前每个消费者一次可以接受多少条消息是非常有用的。这可以在试图批量发布消息的时候起到简单的负载均衡和提高消息吞吐量的作用。For example, if a producing application sends messages every minute because of the nature of the work it is doing.(例如,如果生产应用每分钟才发送一条消息,这说明处理工作尚在运行。)

注意,RabbitMQ 只支持通道级的预取计数,而不是连接级的或者基于大小的预取。

消息属性

AMQP 模型中的消息(Message)对象是带有属性(Attributes)的。有些属性及其常见,以至于 AMQP 0-9-1 明确的定义了它们,并且应用开发者们无需费心思思考这些属性名字所代表的具体含义。例如:

  • Content type(内容类型)
  • Content encoding(内容编码)
  • Routing key(路由键)
  • Delivery mode (persistent or not)
  • 投递模式(持久化 或 非持久化)
  • Message priority(消息优先权)
  • Message publishing timestamp(消息发布的时间戳)
  • Expiration period(消息有效期)
  • Publisher application id(发布应用的 ID)

有些属性是被 AMQP 代理所使用的,但是大多数是开放给接收它们的应用解释器用的。有些属性是可选的也被称作消息头(headers)。他们跟 HTTP 协议的 X-Headers 很相似。消息属性需要在消息被发布的时候定义。

消息主体

AMQP 的消息除属性外,也含有一个有效载荷 - Payload(消息实际携带的数据),它被 AMQP 代理当作不透明的字节数组来对待。

消息代理不会检查或者修改有效载荷。消息可以只包含属性而不携带有效载荷。它通常会使用类似 JSON 这种序列化的格式数据,为了节省,协议缓冲器和 MessagePack 将结构化数据序列化,以便以消息的有效载荷的形式发布。AMQP 及其同行者们通常使用 “content-type” 和 “content-encoding” 这两个字段来与消息沟通进行有效载荷的辨识工作,但这仅仅是基于约定而已。

消息持久化

消息能够以持久化的方式发布,AMQP 代理会将此消息存储在磁盘上。如果服务器重启,系统会确认收到的持久化消息未丢失。

简单地将消息发送给一个持久化的交换机或者路由给一个持久化的队列,并不会使得此消息具有持久化性质:它完全取决与消息本身的持久模式(persistence mode)。将消息以持久化方式发布时,会对性能造成一定的影响(就像数据库操作一样,健壮性的存在必定造成一些性能牺牲)。

幂等:一个数据或者一个请求,重复了多次,确保对应的数据是不会改变的,不能出错

思路:

  • 如果是redis,就没问题,因为每次都是set(会覆盖),天然幂等性
  • 生产者发送消息的时候会带上一个确据唯一的id,消费者拿到消息之后,先根据这个id去redis里查一下,之前有没有消费过,没后消费过就处理,并且写入这个唯一id到redis中,如果消费过,则不处理
  • 基于数据库做 去重表
    • 利用数据库表单的特性来实现幂等,常用的一个思路是在表上构建唯一性索引,保证某一类数据一旦执行完毕,后续同样的请求不再重复处理了(利用一张日志表来记录已经处理成功的消息ID,如果新到的消息ID,如果新到的消息ID已经在日志表中,那么就不再处理这条消息)
    • 以电商平台为例子,电商平台上的订单 id 就是最适合的 token。当用户下单时,会经历多个环节,比如生成订单,)减库存,减优惠券等等。每一个环节执行时都先检测一下该订单 id 是否已经执行过这一步骤,对未执行的请求,执行操作并缓存结果,而对已经执行过的 id,则直接返回之前的执行结果,不做任何操作。这样可以在最大程度上避免操作的重复执行问题,缓存起来的执行结果也能用于事务的控制等。
    • image-20230729101655318

什么是协程?

协程

协程(Coroutines)是一种比线程更加轻量级的存在。协程完全由程序所控制(在用户态执行),带来的好处是性能大幅度的提升。 一个操作系统中可以有多个进程;一个进程可以有多个线程;同理,一个线程可以有多个协程。

协程是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处继续运行。 一个线程内的多个协程的运行是串行的,这点和多进程(多线程)在多核CPU上执行时是不同的。 多进程(多线程)在多核CPU上是可以并行的。当线程内的某一个协程运行时,其它协程必须挂起。

协程切换

由于协程切换是在线程内完成的,涉及到的资源比较少。不像内核级线程(进程)切换那样,上下文的内容比较多,切换代价较大。协程本身是非常轻巧的,可以简单理解为只是切换了寄存器和协程栈的内容。这样代价就非常小。

如果有不了解进程(线程)切换的,可以参考下面的资料:

深入理解Linux内核进程上下文切换

操作系统(四) – 用户级线程与核心级线程(线程的切换)

线程

协程切换的问题

实际上协程只有在等待IO的过程中才能重复利用线程,也就是说协程本质是通过多路复用来完成的。但是有个问题是,协程本身不是线程,只是一个特殊的函数,它不能被操作系统感知到(操作系统只能感知到进程和内核级线程),如果某个线程中的协程调用了阻塞IO,那么将会导致线程切换发生。因此只有协程是不够的,是无法解决问题的。还需要异步来配合协程。 因此,实际上我们可以把协程可以看做是一种用户级线程的实现。 协程+异步才能发挥出协程的最大作用

协程的使用

  • 计算型的操作,利用协程来回切换执行,没有任何意义,来回切换并保存状态 反倒会降低性能。
  • IO型的操作,利用协程在IO等待时间就去切换执行其他任务,当IO操作结束后再自动回调,那么就会大大节省资源并提供性能,从而实现异步编程(不等待任务结束就可以去执行其他代码)。

协程不是什么银弹,要注意使用场景。

Spring常见面试题总结


此页内容

这是一则或许对你有用的小广告

  • 面试专版:准备 Java 面试的小伙伴可以考虑面试专版:《Java 面试指北 》open in new window (质量很高,专为面试打造,配合 JavaGuide 食用)。
  • 知识星球:专属面试小册/一对一交流/简历修改/专属求职指南,欢迎加入 JavaGuide 知识星球open in new window(点击链接即可查看星球的详细介绍,一定一定一定确定自己真的需要再加入,一定一定要看完详细介绍之后再加我)。

这篇文章主要是想通过一些问题,加深大家对于 Spring 的理解,所以不会涉及太多的代码!

下面的很多问题我自己在使用 Spring 的过程中也并没有注意,自己也是临时查阅了很多资料和书籍补上的。网上也有一些很多关于 Spring 常见问题/面试题整理的文章,我感觉大部分都是互相 copy,而且很多问题也不是很好,有些回答也存在问题。所以,自己花了一周的业余时间整理了一下,希望对大家有帮助。

# Spring 基础

# 什么是 Spring 框架?

Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。

我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IoC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发。

img

Spring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率。

Spring 翻译过来就是春天的意思,可见其目标和使命就是为 Java 程序员带来春天啊!感动!

🤐 多提一嘴:语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架。

Spring 提供的核心功能主要是 IoC 和 AOP。学习 Spring ,一定要把 IoC 和 AOP 的核心思想搞懂!

# Spring 包含的模块有哪些?

Spring4.x 版本

Spring4.x主要模块Spring4.x主要模块

Spring5.x 版本

Spring5.x主要模块Spring5.x主要模块

Spring5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。

Spring 各个模块的依赖关系如下:

Spring 各个模块的依赖关系Spring 各个模块的依赖关系

# Core Container

Spring 框架的核心模块,也可以说是基础模块,主要提供 IoC 依赖注入功能的支持。Spring 其他所有的功能基本都需要依赖于该模块,我们从上面那张 Spring 各个模块的依赖关系图就可以看出来。

  • spring-core:Spring 框架基本的核心工具类。
  • spring-beans:提供对 bean 的创建、配置和管理等功能的支持。
  • spring-context:提供对国际化、事件传播、资源加载等功能的支持。
  • spring-expression:提供对表达式语言(Spring Expression Language) SpEL 的支持,只依赖于 core 模块,不依赖于其他模块,可以单独使用。

# AOP

  • spring-aspects:该模块为与 AspectJ 的集成提供支持。
  • spring-aop:提供了面向切面的编程实现。
  • spring-instrument:提供了为 JVM 添加代理(agent)的功能。 具体来讲,它为 Tomcat 提供了一个织入代理,能够为 Tomcat 传递类文 件,就像这些文件是被类加载器加载的一样。没有理解也没关系,这个模块的使用场景非常有限。

# Data Access/Integration

  • spring-jdbc:提供了对数据库访问的抽象 JDBC。不同的数据库都有自己独立的 API 用于操作数据库,而 Java 程序只需要和 JDBC API 交互,这样就屏蔽了数据库的影响。
  • spring-tx:提供对事务的支持。
  • spring-orm:提供对 Hibernate、JPA、iBatis 等 ORM 框架的支持。
  • spring-oxm:提供一个抽象层支撑 OXM(Object-to-XML-Mapping),例如:JAXB、Castor、XMLBeans、JiBX 和 XStream 等。
  • spring-jms : 消息服务。自 Spring Framework 4.1 以后,它还提供了对 spring-messaging 模块的继承。

# Spring Web

  • spring-web:对 Web 功能的实现提供一些最基础的支持。
  • spring-webmvc:提供对 Spring MVC 的实现。
  • spring-websocket:提供了对 WebSocket 的支持,WebSocket 可以让客户端和服务端进行双向通信。
  • spring-webflux:提供对 WebFlux 的支持。WebFlux 是 Spring Framework 5.0 中引入的新的响应式框架。与 Spring MVC 不同,它不需要 Servlet API,是完全异步。

# Messaging

spring-messaging 是从 Spring4.0 开始新加入的一个模块,主要职责是为 Spring 框架集成一些基础的报文传送应用。

# Spring Test

Spring 团队提倡测试驱动开发(TDD)。有了控制反转 (IoC)的帮助,单元测试和集成测试变得更简单。

Spring 的测试模块对 JUnit(单元测试框架)、TestNG(类似 JUnit)、Mockito(主要用来 Mock 对象)、PowerMock(解决 Mockito 的问题比如无法模拟 final, static, private 方法)等等常用的测试框架支持的都比较好。

# Spring,Spring MVC,Spring Boot 之间什么关系?

很多人对 Spring,Spring MVC,Spring Boot 这三者傻傻分不清楚!这里简单介绍一下这三者,其实很简单,没有什么高深的东西。

Spring 包含了多个功能模块(上面刚刚提到过),其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。

下图对应的是 Spring4.x 版本。目前最新的 5.x 版本中 Web 模块的 Portlet 组件已经被废弃掉,同时增加了用于异步响应式处理的 WebFlux 组件。

Spring主要模块Spring主要模块

Spring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。

img

使用 Spring 进行开发各种配置过于麻烦比如开启某些 Spring 特性时,需要用 XML 或 Java 进行显式配置。于是,Spring Boot 诞生了!

Spring 旨在简化 J2EE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。

Spring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!

Spring,Spring MVC,Spring Boot 有什么区别?

image-20230722172641950

# Spring IoC

# 谈谈自己对于 Spring IoC 的了解

IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。

为什么叫控制反转?

  • 控制:指的是对象创建(实例化、管理)的权力
  • 反转:控制权交给外部环境(Spring 框架、IoC 容器)

IoC 图解IoC 图解

将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。

在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IoC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。

在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。

Spring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。

相关阅读:

# 什么是 Spring Bean?

简单来说,Bean 代指的就是那些被 IoC 容器所管理的对象。

我们需要告诉 IoC 容器帮助我们管理哪些对象,这个是通过配置元数据来定义的。配置元数据可以是 XML 文件、注解或者 Java 配置类。

1
2
3
4
<!-- Constructor-arg with 'value' attribute -->
<bean id="..." class="...">
<constructor-arg value="..."/>
</bean>

下图简单地展示了 IoC 容器如何使用配置元数据来管理对象。

img

org.springframework.beansorg.springframework.context 这两个包是 IoC 实现的基础,如果想要研究 IoC 相关的源码的话,可以去看看

# 将一个类声明为 Bean 的注解有哪些?

  • @Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。
  • @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。
  • @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。
  • @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。

# @Component 和 @Bean 的区别是什么?

  • @Component 注解作用于类,而@Bean注解作用于方法。
  • @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。
  • @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。

@Bean注解使用示例:

1
2
3
4
5
6
7
8
@Configuration
public class AppConfig {
@Bean
public TransferService transferService() {
return new TransferServiceImpl();
}

}

上面的代码相当于下面的 xml 配置

1
2
3
<beans>
<bean id="transferService" class="com.acme.TransferServiceImpl"/>
</beans>

下面这个例子是通过 @Component 无法实现的。

1
2
3
4
5
6
7
8
9
10
11
@Bean
public OneService getService(status) {
case (status) {
when 1:
return new serviceImpl1();
when 2:
return new serviceImpl2();
when 3:
return new serviceImpl3();
}
}

# 注入 Bean 的注解有哪些?

Spring 内置的 @Autowired 以及 JDK 内置的 @Resource@Inject 都可以用于注入 Bean。

Annotaion Package Source
@Autowired org.springframework.bean.factory Spring 2.5+
@Resource javax.annotation Java JSR-250
@Inject javax.inject Java JSR-330

@Autowired@Resource使用的比较多一些。

# @Autowired 和 @Resource 的区别是什么?

Autowired 属于 Spring 内置的注解,默认的注入方式为byType(根据类型进行匹配),也就是说会优先根据接口类型去匹配并注入 Bean (接口的实现类)。

这会有什么问题呢? 当一个接口存在多个实现类的话,byType这种方式就无法正确注入对象了,因为这个时候 Spring 会同时找到多个满足条件的选择,默认情况下它自己不知道选择哪一个。

这种情况下,注入方式会变为 byName(根据名称进行匹配),这个名称通常就是类名(首字母小写)。就比如说下面代码中的 smsService 就是我这里所说的名称,这样应该比较好理解了吧。

1
2
3
// smsService 就是我们上面所说的名称
@Autowired
private SmsService smsService;

举个例子,SmsService 接口有两个实现类: SmsServiceImpl1SmsServiceImpl2,且它们都已经被 Spring 容器所管理。

1
2
3
4
5
6
7
8
9
10
11
// 报错,byName 和 byType 都无法匹配到 bean
@Autowired
private SmsService smsService;
// 正确注入 SmsServiceImpl1 对象对应的 bean
@Autowired
private SmsService smsServiceImpl1;
// 正确注入 SmsServiceImpl1 对象对应的 bean
// smsServiceImpl1 就是我们上面所说的名称
@Autowired
@Qualifier(value = "smsServiceImpl1")
private SmsService smsService;

我们还是建议通过 @Qualifier 注解来显式指定名称而不是依赖变量的名称。

@Resource属于 JDK 提供的注解,默认注入方式为 byName。如果无法通过名称匹配到对应的 Bean 的话,注入方式会变为byType

@Resource 有两个比较重要且日常开发常用的属性:name(名称)、type(类型)。

1
2
3
4
public @interface Resource {
String name() default "";
Class<?> type() default Object.class;
}

如果仅指定 name 属性则注入方式为byName,如果仅指定type属性则注入方式为byType,如果同时指定nametype属性(不建议这么做)则注入方式为byType+byName

1
2
3
4
5
6
7
8
9
// 报错,byName 和 byType 都无法匹配到 bean
@Resource
private SmsService smsService;
// 正确注入 SmsServiceImpl1 对象对应的 bean
@Resource
private SmsService smsServiceImpl1;
// 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式)
@Resource(name = "smsServiceImpl1")
private SmsService smsService;

简单总结一下:

  • @Autowired 是 Spring 提供的注解,@Resource 是 JDK 提供的注解。
  • Autowired 默认的注入方式为byType(根据类型进行匹配),@Resource默认注入方式为 byName(根据名称进行匹配)。
  • 当一个接口存在多个实现类的情况下,@Autowired@Resource都需要通过名称才能正确匹配到对应的 Bean。Autowired 可以通过 @Qualifier 注解来显式指定名称,@Resource可以通过 name 属性来显式指定名称。

# Bean 的作用域有哪些?

Spring 中 Bean 的作用域通常有下面几种:

  • singleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
  • prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。
  • request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
  • session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
  • application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
  • websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。

如何配置 bean 的作用域呢?

xml 方式:

1
<bean id="..." class="..." scope="singleton"></bean>

注解方式:

1
2
3
4
5
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Person personPrototype() {
return new Person();
}

# Bean 是线程安全的吗?

Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。

我们这里以最常用的两种作用域 prototype 和 singleton 为例介绍。几乎所有场景的 Bean 作用域都是使用默认的 singleton ,重点关注 singleton 作用域即可。

prototype 作用域下,每次获取都会创建一个新的 bean 实例,不存在资源竞争问题,所以不存在线程安全问题。singleton 作用域下,IoC 容器中只有唯一的 bean 实例,可能会存在资源竞争问题(取决于 Bean 是否有状态)。如果这个 bean 是有状态的话,那就存在线程安全问题(有状态 Bean 是指包含可变的成员变量的对象)。

不过,大部分 Bean 实际都是无状态(没有定义可变的成员变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。

对于有状态单例 Bean 的线程安全问题,常见的有两种解决办法:

  1. 在 Bean 中尽量避免定义可变的成员变量。
  2. 在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。

# Bean 的生命周期了解么?

  • Bean 容器找到配置文件中 Spring Bean 的定义。
  • Bean 容器利用 Java Reflection API 创建一个 Bean 的实例。
  • 如果涉及到一些属性值 利用 set()方法设置一些属性值。
  • 如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入 Bean 的名字。
  • 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。
  • 如果 Bean 实现了 BeanFactoryAware 接口,调用 setBeanFactory()方法,传入 BeanFactory对象的实例。
  • 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。
  • 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法
  • 如果 Bean 实现了InitializingBean接口,执行afterPropertiesSet()方法。
  • 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
  • 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法
  • 当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。
  • 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。

图示:

Spring Bean 生命周期Spring Bean 生命周期

与之比较类似的中文版本:

Spring Bean 生命周期Spring Bean 生命周期

# Spring AoP

# 谈谈自己对于 AOP 的了解

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:

SpringAOPProcessSpringAOPProcess

当然你也可以使用 AspectJ !Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。

AOP 切面编程设计到的一些专业术语:

术语 含义
目标(Target) 被通知的对象
代理(Proxy) 向目标对象应用通知之后创建的代理对象
连接点(JoinPoint) 目标对象的所属类中,定义的所有方法均为连接点
切入点(Pointcut) 被切面拦截 / 增强的连接点(切入点一定是连接点,连接点不一定是切入点)
通知(Advice) 增强的逻辑 / 代码,也即拦截到目标对象的连接点之后要做的事情
切面(Aspect) 切入点(Pointcut)+通知(Advice)
Weaving(织入) 将通知应用到目标对象,进而生成代理对象的过程动作

# Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。

Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,

如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比 Spring AOP 快很多。

# AspectJ 定义的通知类型有哪些?

  • Before(前置通知):目标对象的方法调用之前触发
  • After (后置通知):目标对象的方法调用之后触发
  • AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
  • AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
  • Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法

# 多个切面的执行顺序如何控制?

1、通常使用@Order 注解直接定义切面顺序

1
2
3
4
5
// 值越小优先级越高
@Order(3)
@Component
@Aspect
public class LoggingAspect implements Ordered {

2、实现Ordered 接口重写 getOrder 方法。

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@Aspect
public class LoggingAspect implements Ordered {

// ....

@Override
public int getOrder() {
// 返回值越小优先级越高
return 1;
}
}

# Spring MVC

# 说说自己对于 Spring MVC 了解?

MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。

img

网上有很多人说 MVC 不是设计模式,只是软件设计规范,我个人更倾向于 MVC 同样是众多设计模式中的一种。java-design-patternsopen in new window 项目中就有关于 MVC 的相关介绍。

img

想要真正理解 Spring MVC,我们先来看看 Model 1 和 Model 2 这两个没有 Spring MVC 的时代。

Model 1 时代

很多学 Java 后端比较晚的朋友可能并没有接触过 Model 1 时代下的 JavaWeb 应用开发。在 Model1 模式下,整个 Web 应用几乎全部用 JSP 页面组成,只用少量的 JavaBean 来处理数据库连接、访问等操作。

这个模式下 JSP 即是控制层(Controller)又是表现层(View)。显而易见,这种模式存在很多问题。比如控制逻辑和表现逻辑混杂在一起,导致代码重用率极低;再比如前端和后端相互依赖,难以进行测试维护并且开发效率极低。

mvc-mode1mvc-mode1

Model 2 时代

学过 Servlet 并做过相关 Demo 的朋友应该了解“Java Bean(Model)+ JSP(View)+Servlet(Controller) ”这种开发模式,这就是早期的 JavaWeb MVC 开发模式。

  • Model:系统涉及的数据,也就是 dao 和 bean。
  • View:展示模型中的数据,只是用来展示。
  • Controller:处理用户请求都发送给 ,返回数据给 JSP 并展示给用户。

img

Model2 模式下还存在很多问题,Model2 的抽象和封装程度还远远不够,使用 Model2 进行开发时不可避免地会重复造轮子,这就大大降低了程序的可维护性和复用性。

于是,很多 JavaWeb 开发相关的 MVC 框架应运而生比如 Struts2,但是 Struts2 比较笨重。

Spring MVC 时代

随着 Spring 轻量级开发框架的流行,Spring 生态圈出现了 Spring MVC 框架, Spring MVC 是当前最优秀的 MVC 框架。相比于 Struts2 , Spring MVC 使用更加简单和方便,开发效率更高,并且 Spring MVC 运行速度更快。

MVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)。

# Spring MVC 的核心组件有哪些?

记住了下面这些组件,也就记住了 SpringMVC 的工作原理。

  • DispatcherServlet核心的中央处理器,负责接收请求、分发,并给予客户端响应。
  • HandlerMapping处理器映射器,根据 uri 去匹配查找能处理的 Handler ,并会将请求涉及到的拦截器和 Handler 一起封装。
  • HandlerAdapter处理器适配器,根据 HandlerMapping 找到的 Handler ,适配执行对应的 Handler
  • Handler请求处理器,处理实际请求的处理器。
  • ViewResolver视图解析器,根据 Handler 返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给 DispatcherServlet 响应客户端

# SpringMVC 工作原理了解吗?

Spring MVC 原理如下图所示:

SpringMVC 工作原理的图解我没有自己画,直接图省事在网上找了一个非常清晰直观的,原出处不明。

img

流程说明(重要):

  1. 客户端(浏览器)发送请求, DispatcherServlet拦截请求。
  2. DispatcherServlet 根据请求信息调用 HandlerMappingHandlerMapping 根据 uri 去匹配查找能处理的 Handler(也就是我们平常说的 Controller 控制器) ,并会将请求涉及到的拦截器和 Handler 一起封装。
  3. DispatcherServlet 调用 HandlerAdapter适配器执行 Handler
  4. Handler 完成对用户请求的处理后,会返回一个 ModelAndView 对象给DispatcherServletModelAndView 顾名思义,包含了数据模型以及相应的视图的信息。Model 是返回的数据对象,View 是个逻辑上的 View
  5. ViewResolver 会根据逻辑 View 查找实际的 View
  6. DispaterServlet 把返回的 Model 传给 View(视图渲染)。
  7. View 返回给请求者(浏览器)

# 统一异常处理怎么做?

推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

@ExceptionHandler(BaseException.class)
public ResponseEntity<?> handleAppException(BaseException ex, HttpServletRequest request) {
//......
}

@ExceptionHandler(value = ResourceNotFoundException.class)
public ResponseEntity<ErrorReponse> handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) {
//......
}
}

这种异常处理方式下,会给所有或者指定的 Controller 织入异常处理的逻辑(AOP),当 Controller 中的方法抛出异常的时候,由被@ExceptionHandler 注解修饰的方法进行处理。

ExceptionHandlerMethodResolvergetMappedMethod 方法决定了异常具体被哪个被 @ExceptionHandler 注解修饰的方法处理异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Nullable
private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
List<Class<? extends Throwable>> matches = new ArrayList<>();
//找到可以处理的所有异常信息。mappedMethods 中存放了异常和处理异常的方法的对应关系
for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {
if (mappedException.isAssignableFrom(exceptionType)) {
matches.add(mappedException);
}
}
// 不为空说明有方法处理异常
if (!matches.isEmpty()) {
// 按照匹配程度从小到大排序
matches.sort(new ExceptionDepthComparator(exceptionType));
// 返回处理异常的方法
return this.mappedMethods.get(matches.get(0));
}
else {
return null;
}
}

从源代码看出:getMappedMethod()会首先找到可以匹配处理异常的所有方法信息,然后对其进行从小到大的排序,最后取最小的那一个匹配的方法(即匹配度最高的那个)。

# Spring 框架中用到了哪些设计模式?

关于下面这些设计模式的详细介绍,可以看我写的 Spring 中的设计模式详解open in new window 这篇文章。

  • 工厂设计模式 : Spring 使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller

# Spring 事务

关于 Spring 事务的详细介绍,可以看我写的 Spring 事务详解open in new window 这篇文章。

# Spring 管理事务的方式有几种?

  • 编程式事务:在代码中硬编码(不推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。
  • 声明式事务:在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)

# Spring 事务中哪几种事务传播行为?

事务传播行为是为了解决业务层方法之间互相调用的事务问题

当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。

正确的事务传播行为可能的值如下:

1.TransactionDefinition.PROPAGATION_REQUIRED

使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。

2.TransactionDefinition.PROPAGATION_REQUIRES_NEW

创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。

3.TransactionDefinition.PROPAGATION_NESTED

如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED

4.TransactionDefinition.PROPAGATION_MANDATORY

如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)

这个使用的很少。

若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:

  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

# Spring 事务中的隔离级别有哪几种?

和事务传播行为这块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum Isolation {

DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),

READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),

READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),

REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ),

SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);

private final int value;

Isolation(int value) {
this.value = value;
}

public int value() {
return this.value;
}

}

下面我依次对每一种事务隔离级别进行介绍:

  • TransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

# @Transactional(rollbackFor = Exception.class)注解了解吗?

Exception 分为运行时异常 RuntimeException 和非运行时异常。事务管理对于企业应用来说是至关重要的,即使出现异常情况,它也可以保证数据的一致性。

@Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。如果类或者方法加了这个注解,那么这个类里面的方法抛出异常,就会回滚,数据库里面的数据也会回滚。

@Transactional 注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上 rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。

# Spring Data JPA

JPA 重要的是实战,这里仅对小部分知识点进行总结。

# 如何使用 JPA 在数据库中非持久化一个字段?

假如我们有下面一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity(name="USER")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "ID")
private Long id;

@Column(name="USER_NAME")
private String userName;

@Column(name="PASSWORD")
private String password;

private String secrect;

}

如果我们想让secrect 这个字段不被持久化,也就是不被数据库存储怎么办?我们可以采用下面几种方法:

1
2
3
4
5
static String transient1; // not persistent because of static
final String transient2 = "Satish"; // not persistent because of final
transient String transient3; // not persistent because of transient
@Transient
String transient4; // not persistent because of @Transient

一般使用后面两种方式比较多,我个人使用注解的方式比较多。

# JPA 的审计功能是做什么的?有什么用?

审计功能主要是帮助我们记录数据库操作的具体行为比如某条记录是谁创建的、什么时间创建的、最后修改人是谁、最后修改时间是什么时候。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Data
@AllArgsConstructor
@NoArgsConstructor
@MappedSuperclass
@EntityListeners(value = AuditingEntityListener.class)
public abstract class AbstractAuditBase {

@CreatedDate
@Column(updatable = false)
@JsonIgnore
private Instant createdAt;

@LastModifiedDate
@JsonIgnore
private Instant updatedAt;

@CreatedBy
@Column(updatable = false)
@JsonIgnore
private String createdBy;

@LastModifiedBy
@JsonIgnore
private String updatedBy;
}
  • @CreatedDate: 表示该字段为创建时间字段,在这个实体被 insert 的时候,会设置值

  • @CreatedBy :表示该字段为创建人,在这个实体被 insert 的时候,会设置值

    @LastModifiedDate@LastModifiedBy同理。

# 实体之间的关联关系注解有哪些?

  • @OneToOne : 一对一。
  • @ManyToMany:多对多。
  • @OneToMany : 一对多。
  • @ManyToOne:多对一。

利用 @ManyToOne@OneToMany 也可以表达多对多的关联关系。

# Spring Security

Spring Security 重要的是实战,这里仅对小部分知识点进行总结。

# 有哪些控制请求访问权限的方法?

img

  • permitAll():无条件允许任何形式访问,不管你登录还是没有登录。
  • anonymous():允许匿名访问,也就是没有登录才可以访问。
  • denyAll():无条件决绝任何形式的访问。
  • authenticated():只允许已认证的用户访问。
  • fullyAuthenticated():只允许已经登录或者通过 remember-me 登录的用户访问。
  • hasRole(String) : 只允许指定的角色访问。
  • hasAnyRole(String) : 指定一个或者多个角色,满足其一的用户即可访问。
  • hasAuthority(String):只允许具有指定权限的用户访问
  • hasAnyAuthority(String):指定一个或者多个权限,满足其一的用户即可访问。
  • hasIpAddress(String) : 只允许指定 ip 的用户访问。

# hasRole 和 hasAuthority 有区别吗?

可以看看松哥的这篇文章:Spring Security 中的 hasRole 和 hasAuthority 有区别吗?open in new window,介绍的比较详细。

# 如何对密码进行加密?

如果我们需要保存密码这类敏感数据到数据库的话,需要先加密再保存。

Spring Security 提供了多种加密算法的实现,开箱即用,非常方便。这些加密算法实现类的父类是 PasswordEncoder ,如果你想要自己实现一个加密算法的话,也需要继承 PasswordEncoder

PasswordEncoder 接口一共也就 3 个必须实现的方法。

1
2
3
4
5
6
7
8
9
10
public interface PasswordEncoder {
// 加密也就是对原始密码进行编码
String encode(CharSequence var1);
// 比对原始密码和数据库中保存的密码
boolean matches(CharSequence var1, String var2);
// 判断加密密码是否需要再次进行加密,默认返回 false
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

img

官方推荐使用基于 bcrypt 强哈希函数的加密算法实现类。

# 如何优雅更换系统使用的加密算法?

如果我们在开发过程中,突然发现现有的加密算法无法满足我们的需求,需要更换成另外一个加密算法,这个时候应该怎么办呢?

推荐的做法是通过 DelegatingPasswordEncoder 兼容多种不同的密码加密方案,以适应不同的业务需求。

从名字也能看出来,DelegatingPasswordEncoder 其实就是一个代理类,并非是一种全新的加密算法,它做的事情就是代理上面提到的加密算法实现类。在 Spring Security 5.0 之后,默认就是基于 DelegatingPasswordEncoder 进行密码加密的。

# 参考


著作权归Guide所有 原文链接:https://javaguide.cn/system-design/framework/spring/spring-knowledge-and-questions-summary.html

# 线程池状态

img

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务。
  • SHUTDOWN:指调用了 shutdown() 方法,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。
  • STOP:指调用了 shutdownNow() 方法,不再接受新提交的任务,同时抛弃阻塞队列里的所有任务并中断所有正在执行任务。
  • TIDYING: 所有任务都执行完毕,workerCount 有效线程数为 0。
  • TERMINATED:终止状态,当执行 terminated() 后会更新为这个状态。

# 线程状态

线程实际上是分为六种状态的,既

  • 1.初始状态(NEW)

    • 线程被构建,但是还没有调用start方法
  • 2.运行状态(RUNNABLE)

    • Java线程把操作系统中就绪和运行两种状态统一称为“运行中”
  • 3.阻塞状态(BLOCKED)
    表示线程进入等待状态,也就是线程因为某种原因放弃了CPU的使用权,阻塞也分为几种情况(当一个线程试图获取一个内部的对象锁(非java.util.concurrent库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态。)

等待阻塞:运行的线程执行了Thread.sleep、wait、join等方法,JVM会把当前线程设置为等待状态,当sleep结束,join线程终止或者线程被唤醒后,该线程从等待状态进入阻塞状态,重新占用锁后进行线程恢复

同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程锁占用了,那么JVM会把当前项城放入到锁池中

其他阻塞:发出I/O请求,JVM会把当前线程设置为阻塞状态,当I/O处理完毕则线程恢复

  • 4.等待(WAITING)
    • 等待状态,没有超时时间(无限等待),要被其他线程或者有其他的中断操作

​ 执行wait、join、LockSupport.park()

  • 5.超时等待(TIME_WAITING)
    • 与等待不同的是,不是无限等待,超时后自动返回

​ 执行sleep,带参数的wait等可以实现

  • 6.终止(Teminated)
    代表线程执行完毕

image-20230719235443525

# 核心线程 和 救急线程的区别

救急线程是有个生存时间的,它执行完任务了,过了一段时间,没有新任务了,救急线程就会销毁掉,变成结束的状态

核心线程没有生存时间,它执行完任务后,它仍然会被保存在线程池中,不会让核心线程结束,会让核心线程一直去运行

KeepAliveTime 生存时间、unit时间单位,这两个参数就是针对于救急线程的

image-20221014221100359

使用救急线程的前提,是要配合有界队列的使用。

如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线程来救急。

如果队列选择的是无界队列,那么就不会用到救急线程,任务会一直存入无界队列,然后由核心线程来轮流去处理无界队列里的任务。

如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 4 种实现,

但是很多第三方框架都不是使用的jdk提供的,而是选择使用 更功能上的增强,在这些 功能上进行扩展

image-20221014222804634

# Executors-固定大小线程池

image-20221015090229540

# Executors-单线程线程池

image-20221015091919686

# 线程池中shutdown()和shutdownNow()方法的区别

一般情况下,当我们频繁的使用线程的时候,为了节约资源快速响应需求,我们都会考虑使用线程池,线程池使用完毕都会想着关闭,关闭的时候一般情况下会用到shutdown和shutdownNow,这两个函数都能够用来关闭线程池,那么他们俩之间的区别是什么呢?下面我就用一句话来说明白shutdown和shutdownNow的区别。

一句话说明白shutdown和shutdownNow的区别:

​ shutdown只是将线程池的状态设置为SHUTWDOWN状态,正在执行的任务会继续执行下去,没有被执行的则中断。

​ 而shutdownNow则是将线程池的状态设置为STOP,正在执行的任务则被停止,没被执行任务的则返回。

​ 例子:举个工人吃包子的例子,一个厂的工人(Workers)正在吃包子(可以理解为任务),假如接到shutdown的命令,那么这个厂的工人们则会把手头上的包子给吃完,没有拿到手里的笼子里面的包子则不能吃!而如果接到shutdownNow的命令以后呢,这些工人们立刻停止吃包子,会把手头上没吃完的包子放下,更别提笼子里的包子了。

1、shutDown()

当线程池调用该方法时,线程池的状态则立刻变成SHUTDOWN状态。此时,则不能再往线程池中添加任何任务,否则将会抛出RejectedExecutionException异常。但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。

2、shutdownNow()

执行该方法,线程池的状态立刻变成STOP状态,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。
它试图终止线程的方法是通过调用Thread.interrupt()方法来实现的,但是大家知道,这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。

image-20221015095349872

image-20221015095425618

# ABA 问题及解决

CAS引发的ABA问题

ABA问题是指在CAS操作时,其他线程将变量值A改为了B,但是又被改回了A,等到本线程使用期望值A与当前变量进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过,这与乐观锁的设计思想不符合。ABA问题的解决思路是,每次变量更新的时候把变量的版本号加1,那么A-B-A就会变成A1-B2-A3,只要变量被某一线程修改过,改变量对应的版本号就会发生递增变化,从而解决了ABA问题。在JDK的java.util.concurrent.atomic包中提供了AtomicStampedReference来解决ABA问题,该类的compareAndSet是该类的核心方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
public boolean compareAndSet(V   expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}

我们可以发现,该类检查了当前引用与当前标志是否与预期相同,如果全部相等,才会以原子方式将该引用和该标志的值设为新的更新值,这样CAS操作中的比较就不依赖于变量的值了。

但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了

AtomicMarkableReference
基本和AtomicStampedReference差不多,AtomicStampedReference主要关注版本号,即reference的值被修改了多少次;AtomicMarkableReference是使用boolean mark来标记reference是否被修改过

# Synchronized 和 Lock 的主要区别

Synchronzied 和 Lock 的主要区别如下:

  • 存在层面:Syncronized 是Java 中的一个关键字,存在于 JVM 层面,Lock 是 Java 中的一个接口

  • 锁的释放条件:1. 获取锁的线程执行完同步代码后,自动释放;2. 线程发生异常时,JVM会让线程释放锁;Lock 必须在 finally 关键字中释放锁,不然容易造成线程死锁

  • 锁的获取: 在 Syncronized 中,假设线程 A 获得锁,B 线程等待。如果 A 发生阻塞,那么 B 会一直等待。在 Lock 中,会分情况而定,Lock 中有尝试获取锁的方法,如果尝试获取到锁,则不用一直等待

  • 锁的状态:Synchronized 无法判断锁的状态,Lock 则可以判断

  • 锁的类型:Synchronized 是可重入,不可中断,非公平锁;Lock 锁则是 可重入,可判断,可公平锁

  • 锁的性能:Synchronized 适用于少量同步的情况下,性能开销比较大。Lock 锁适用于大量同步阶段:

  • Lock 锁可以提高多个线程进行读的效率(使用 readWriteLock)

  • 在竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;

  • ReetrantLock 提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。

Redis的缓存穿透、缓存击穿、缓存雪崩

Redis的缓存穿透、缓存击穿、缓存雪崩

一、概述

① 缓存穿透:大量请求根本不存在的key

② 缓存雪崩:Redis中大量key集体过期

③ 缓存击穿:Redis中一个热点key过期

三者出现的根本原因:Redis命中率下降,请求直接打在DB上
正常情况下,大量的资源请求都会被redis响应,在redis得不到响应的小部分请求才会去请求DB,这样DB的压力是非常小的,是可以正常工作的(如下图)

image-20230715135117005

​ 如果大量的请求在redis上得不到响应,那么就会导致这些请求会直接去访问DB,导致DB的压力瞬间变大而卡死或者宕机。如下图:

① 大量的高并发的请求打在redis上

② 这些请求发现redis上并没有需要请求的资源,redis命中率降低

③ 因此这些大量的高并发请求转向DB(数据库服务器)请求对应的资源

④ DB压力瞬间增大,直接将DB打垮,进而引发一系列“灾害”

image-20230715135155569

​ 那么为什么redis会没有需要访问的数据呢?通过分析大致可以总结为三种情况,也就对应着redis的雪崩、穿透和击穿(下文开始进行详解)

image-20230715135236580

二、情景分析 (详解)

(一)缓存击穿

概念:

产生缓存雪崩的原因:redis中的某个热点key过期,但是此时有大量的用户访问该过期key

image-20230715135655943

情景:

缓存击穿的原因通常有以下几种:

  1. 缓存中不存在所需的热点数据:当系统中某个热点数据需要被频繁访问时,如果这个热点数据最开始没有被缓存,那么就会导致系统每次请求都需要直接查询数据库,造成数据库负担。

  2. 缓存的热点数据过期:当一个热点数据过期并需要重新缓存时,如果此时有大量请求,那么就会导致所有请求都要直接查询数据库。

     类似于“某男明星塌房事件”上了热搜,这时候大量的“粉丝”都在访问该热点事件,但是可能由于某种原因,redis的这个热点key过期了,那么这时候大量高并发对于该key的请求就得不到redis的响应,那么就会将请求直接打在DB服务器上,导致整个DB瘫痪。
    

解决方案:

1.设置永不过期(提前对热点数据进行设置)

类似于新闻、某博等软件都需要对热点数据进行预先设置在redis中

2.加锁排队

(方式一)双重检查锁:

只有一个请求A可以获取到互斥锁,其它请求在外排队,然后线程A到DB中将数据查询并返回到Redis,之后所有请求就可以从Redis中得到响应(这些请求有两种情况:一,已经进入排队的请求获得锁之后,可在第二重查询redis中获取数据;二,没有进入排队的请求【也就是没有通过 if(obj == null) 而进入争取锁的队列中的请求】,直接在外部的查询redis获取到数据)

image-20230715140125819

(方式二)分布式锁:
不好之处:

高并发的情况下,影响性能。但大多数情况下访问是可以从外层就可以获取到缓存数据的了,而只有在偶尔的情况下会因为key突然过期,才会导致那个时间的请求进入锁机制,而且进入排队的,也有二重检查来减轻对数据库的压力。

3.监控数据,适时调整

监控哪些数据是热门数据,实时的调整key的过期时长

(二)缓存雪崩

概念:

缓存雪崩产生的原因:redis中大量的key集体过期

image-20230715141420609

举例:

    当redis中的大量key集体过期,可以理解为redis中的大部分数据都被清空了(失效了),那么这时候如果有大量并发的请求来到,那么redis就无法进行有效的响应(命中率急剧下降),请求就都打到DB上了,到时DB直接崩溃

情景:

  • 大量key集体过期

    • 解决方法
      • 1.加锁排队 + 将失效时间分散开
      • 2.使用多级缓存架构
      • 3.设置缓存标记
  • Redis服务宕机

    • 解决方法:redis高可用(集群、哨兵模式)
  • 机房断电

    • 解决方法:提前做好灾备,做好多机房,一个机房挂掉了,马上切换到另外一个地方的机房

解决方式:

1.加锁排队 + 将失效时间分散开

通过使用自动生成随机数使得key的过期时间是随机的,防止集体过期

image-20230715142038202

2.使用多级架构

使用nginx缓存+redis缓存+其他缓存,不同层使用不同(过期时间)的缓存,可靠性更强

3.设置缓存标记

记录缓存数据是否过期,如果过期会去跟新实际的key。

(1)不另外启一个线程,而是在value里面,储存了个逻辑过期时间(相当于实际过期时间我们设置1小时,但逻辑过期时间可能是50分钟),取值的时候,判断 实际时间 > 逻辑时间,则进行加锁更新,其余的线程,拿不到锁的先全部返回旧数据。

(2)异步处理:但判断 实际时间 > 逻辑时间,通知另外的线程进行更新

4.redis高可用(集群、哨兵模式)

如果是Redis服务宕机,那就需要提前给Redis做好集群,并做好哨兵模式,发现宕机,另外的补上。

(三)缓存穿透

概念:

缓存穿透产生的原因:请求根本不存在的资源(DB本身就不存在,Redis更是不存在)

image-20230715143842809

举例(情景在线):客户端发送大量的不可响应的请求(如下图)

image-20230715143928303

    当大量的客户端发出类似于:http://localhost:8080/user/19833?id=-3872 的请求,就可能导致出现缓存穿透的情况。因为数据库DB中本身就没有id=-3872的用户的数据,所以Redis也没有对应的数据,那么这些请求在redis就得不到响应,就会直接打在DB上,导致DB压力过大而卡死情景在线或宕机。
    缓存穿透很有可能是黑客攻击所为,黑客通过发送大量的高并发的无法响应的请求给服务器,由于请求的资源根本就不存在,DB就很容易被打垮了。

解决方式:

1.缓存空对象(+加锁排队 + 将失效时间分散开)

image-20230715144221629

  • 类似于上面的例子,虽然数据库中没有id=-3872的用户的数据,但是在redis中对他进行缓存(key=-3872,value=null),这样当请求到达redis的时候就会直接返回一个null的值给客户端,避免了大量无法访问的数据直接打在DB上
    • 注意:
      • 使用空值作为缓存的时候,key设置的过期时间不能太长,防止占用太多redis资源(比如大量的恶意攻击)
      • 当前访问的数据可能当时数据库中没有,但后面可能会有,所以设置过期时间不能太长,建议随机的短时间

2.布隆过滤器

  • 黑名单:把请求不存在的数据存进黑名单,下次访问数据前先判断布隆过滤器中是都存在该key,存在则拒绝访问。
  • 白名单:把数据库存在的数据存进布隆过滤器,请求访问判断到布隆过滤器中有才释放后续访问数据,不存在则拒绝后续访问。

注意:

  1. 要做好数据同步,因为不是所有的数据都是一直在黑名单或白名单的,增删改会导致变动。所以这种方式的缺点就是要做数据同步。

  2. 布隆过滤器是有一定的误差,所以一般需要配合一些接口流量的限制(规定用户在一段时间内访问的频率)、权限校验、黑名单等来解决缓存穿透的问题

3.实时监控:

​ 对redis进行实时监控,当发现redis中的命中率下降的时候进行原因的排查,配合运维人员对访问对象和访问数据进行分析查询,从而进行黑名单的设置限制服务(拒绝黑客攻击)

4.接口校验

​ 类似于用户权限的拦截,对于id=-3872这些无效访问就直接拦截,不允许这些请求到达Redis、DB上。

Spring Boot中如何解决Redis的缓存穿透、缓存击穿、缓存雪崩?

大家好,我是飘渺!今天给大家介绍一下如何在SpringBoot中解决Redis的缓存穿透、缓存击穿、缓存雪崩的问题。

缓存穿透

什么是缓存穿透

缓存穿透指的是一个缓存系统无法缓存某个查询的数据,从而导致这个查询每一次都要访问数据库。

常见的Redis缓存穿透场景包括:

  1. 查询一个不存在的数据:攻击者可能会发送一些无效的查询来触发缓存穿透。
  2. 查询一些非常热门的数据:如果一个数据被访问的非常频繁,那么可能会导致缓存系统无法处理这些请求,从而造成缓存穿透。
  3. 查询一些异常数据:这种情况通常发生在数据服务出现故障或异常时,从而造成缓存系统无法访问相关数据,从而导致缓存穿透。

如何解决

我们可以使用Guava在内存中维护一个布隆过滤器。具体步骤如下:

  1. 添加Guava和Redis依赖:
1
2
3
4
5
6
7
8
9
10
xml复制代码<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>29.0-jre</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 创建一个BloomFilterUtil类,用于在缓存中维护Bloom Filter。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class BloomFilterUtil {
// 布隆过滤器的预计容量
private static final int expectedInsertions = 1000000;
// 布隆过滤器误判率
private static final double fpp = 0.001;
private static BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), expectedInsertions, fpp);
/**
* 向Bloom Filter中添加元素
*/
public static void add(String key){
bloomFilter.put(key);
}
/**
* 判断元素是否存在于Bloom Filter中
*/
public static boolean mightContain(String key){
return bloomFilter.mightContain(key);
}
}
  1. 在Controller中查询数据时,先根据请求参数进行Bloom Filter的过滤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码@Autowired
private RedisTemplate<String, Object> redisTemplate;

@GetMapping("/user/{id}")
public User getUserById(@PathVariable Long id){
// 先从布隆过滤器中判断此id是否存在
if(!BloomFilterUtil.mightContain(id.toString())){
return null;
}
// 查询缓存数据
String userKey = "user_"+id.toString();
User user = (User) redisTemplate.opsForValue().get(userKey);
if(user == null){
// 查询数据库
user = userRepository.findById(id).orElse(null);
if(user != null){
// 将查询到的数据加入缓存
redisTemplate.opsForValue().set(userKey, user, 300, TimeUnit.SECONDS);
}else{
// 查询结果为空,将请求记录下来,并在布隆过滤器中添加
BloomFilterUtil.add(id.toString());
}
}
return user;
}

缓存击穿

什么是缓存击穿

缓存击穿指的是在一些高并发访问下,一个热点数据从缓存中不存在,每次请求都要直接查询数据库,从而导致数据库压力过大,并且系统性能下降的现象。

缓存击穿的原因通常有以下几种:

  1. 缓存中不存在所需的热点数据:当系统中某个热点数据需要被频繁访问时,如果这个热点数据最开始没有被缓存,那么就会导致系统每次请求都需要直接查询数据库,造成数据库负担。
  2. 缓存的热点数据过期:当一个热点数据过期并需要重新缓存时,如果此时有大量请求,那么就会导致所有请求都要直接查询数据库。

如何解决

主要思路 : 在遇到缓存击穿问题时,我们可以在查询数据库之前,先判断一下缓存中是否已有数据,如果没有数据则使用Redis的单线程特性,先查询数据库然后将数据写入缓存中。

  1. 添加Redis依赖
1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 在Controller中查询数据时,先从缓存中查询数据,如果缓存中无数据则进行锁操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码@Autowired
private RedisTemplate<String, Object> redisTemplate;

@GetMapping("/user/{id}")
public User getUserById(@PathVariable Long id){
// 先从缓存中获取值
String userKey = "user_"+id.toString();
User user = (User) redisTemplate.opsForValue().get(userKey);
if(user == null){
// 查询数据库之前加锁
String lockKey = "lock_user_"+id.toString();
String lockValue = UUID.randomUUID().toString();
try{
Boolean lockResult = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 60, TimeUnit.SECONDS);
if(lockResult != null && lockResult){
// 查询数据库
user = userRepository.findById(id).orElse(null);
if(user != null){
// 将查询到的数据加入缓存
redisTemplate.opsForValue().set(userKey, user, 300, TimeUnit.SECONDS);
}
}
}finally{
// 释放锁
if(lockValue.equals(redisTemplate.opsForValue().get(lockKey))){
redisTemplate.delete(lockKey);
}
}
}
return user;
}

缓存雪崩

什么是缓存雪崩

指缓存中大量数据的失效时间集中在某一个时间段,导致在这个时间段内缓存失效并额外请求数据库查询数据的请求大量增加,从而对数据库造成极大的压力和负荷。

常见的Redis缓存雪崩场景包括:

  1. 缓存服务器宕机:当缓存服务器宕机或重启时,大量的访问请求将直接命中数据库,并在同一时间段内导致大量的数据库查询请求,从而将数据库压力大幅提高。
  2. 缓存数据同时失效:在某个特定时间点,缓存中大量数据的失效时间集中在一起,这些数据会在同一时间段失效,并且这些数据被高频访问,将导致大量的访问请求去查询数据库。
  3. 缓存中数据过期时间设计不合理:当缓存中的数据有效时间过短,且数据集中在同一时期失效时,就容易导致大量的请求直接查询数据库,加剧数据库压力。
  4. 波动式的访问过程:当数据的访问存在波动式特征时,例如输出某些活动物品或促销商品时,将会带来高频的查询请求访问,导致缓存大量失效并产生缓存雪崩。

如何解决

在遇到缓存雪崩时,我们可以使用两种方法:一种是将缓存过期时间分散开,即为不同的数据设置不同的过期时间;另一种是使用Redis的多级缓存架构,通过增加一层代理层来解决。具体步骤如下:

  1. 添加相关依赖
1
2
3
4
5
6
7
8
9
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>2.10.6</version>
</dependency>
  1. 在application.properties中配置Ehcache缓存
1
2
properties
复制代码spring.cache.type=ehcache
  1. 创建一个CacheConfig类,用于配置Ehcache:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public EhCacheCacheManager ehCacheCacheManager(CacheManager cm){
return new EhCacheCacheManager(cm);
}
@Bean
public CacheManager ehCacheManager(){
EhCacheManagerFactoryBean cmfb = new EhCacheManagerFactoryBean();
cmfb.setConfigLocation(new ClassPathResource("ehcache.xml"));
cmfb.setShared(true);
return cmfb.getObject();
}
}
  1. 在ehcache.xml中添加缓存配置
1
2
3
4
5
6
7
8
9
10
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
updateCheck="true"
monitoring="autodetect"
dynamicConfig="true">

<cache name="userCache" maxEntriesLocalHeap="10000" timeToLiveSeconds="60" timeToIdleSeconds="30"/>

</ehcache>
  1. 在Controller中查询数据时,先从Ehcache缓存中获取,如果缓存中无数据则再从Redis缓存中获取数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private CacheManager ehCacheManager;

@GetMapping("/user/{id}")
@Cacheable(value = "userCache", key = "#id")
public User getUserById(@PathVariable Long id){
// 先从Ehcache缓存中获取
String userKey = "user_"+id.toString();
User user = (User) ehCacheManager.getCache("userCache").get(userKey).get();
if(user == null){
// 再从Redis缓存中获取
user = (User) redisTemplate.opsForValue().get(userKey);
if(user != null){
ehCacheManager.getCache("userCache").put(userKey, user);
}
}
return user;
}

以上就是使用SpringBoot时如何解决Redis的缓存穿透、缓存击穿、缓存雪崩的常用方法。

为何要用分布式锁&Redis实现分布式锁

为何要用分布式锁

一、为什么要使用分布式锁

为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题

二、分布式锁应该具备哪些条件

在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:
1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;

2、高可用的获取锁与释放锁;

3、高性能的获取锁与释放锁;

4、具备可重入特性;

5、具备锁失效机制,防止死锁;

6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

image-20230604103343211

image-20230604103310644

数据库实现分布式锁原理:

image-20230604102928951

Redis实现分布式锁原理:

image-20230604103157783

image-20230604103116683

问题注意:

  • 业务失败锁还在,就会产生死锁,可以加一个过期时间自动释放锁,但是自动释放可能出现释放掉其他jvm锁的情况,所以要给锁加一个唯一标识,删除前先看看是不是本机持有的锁,是的话再删除,还要保证查询和删除是一个原子操作,可以使用lua脚本[脱单doge]
  • 产生死锁现象,导致虚拟机实例无法再次获取资源,可以设置失效时间,缺陷:因为不确定业务执行时间的长短,所以失效时间的设置具有不确定性。优化:使用try catch finally 语句块,在finally语句中调用del方法删除key完成释放锁的目的,这样下次虚拟机实例请求资源时便能通过setNx()方法获取到锁,执行响应业务逻辑![脱单doge]

看门狗:

对于Redis集群而言可能存在的问题:

  • 问题:主从切换的时候,主从同步延迟,可能锁信息没有同步到新主
  • 解决:
    • 利用多个redis实例来存储共享,加锁时给每个redis都加锁
      • 第一步:获取当前系统时间 (主要为了计算客户端对多个实例加锁所耗费的一个总时间)
      • 第二步:依次对多个实例进行加锁,加锁完成后,计算客户端对多个实例加锁所耗费的一个总时耗时
      • 如果加锁的总耗时比锁设置的有效时间短,说明加锁成功

Redis分布式锁的正确实现方式

前言

分布式锁一般有三种实现方式:

  1. 数据库乐观锁;
  2. 基于Redis的分布式锁;
  3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁。虽然网上已经有各种介绍Redis分布式锁实现的博客,然而他们的实现却有着各种各样的问题,为了避免误人子弟,本篇博客将详细介绍如何正确地实现Redis分布式锁。

可靠性

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

代码实现

组件依赖

首先我们要通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:

1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>

加锁代码

正确姿势

Talk is cheap, show me the code。先展示代码,再带大家慢慢解释为什么这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class RedisTool {

private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";

/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;

}

}

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

错误示例1

比较常见的错误示例就是使用jedis.setnx()jedis.expire()组合实现加锁,代码如下:

1
2
3
4
5
6
7
8
9
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {

Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}

}

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

错误示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {

long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}

// 如果锁存在,获取锁的过期时间
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}

// 其他情况,一律返回加锁失败
return false;

}

这一种错误示例就比较难以发现问题,而且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。代码如下:

那么这段代码问题在哪里?1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。

解锁代码

正确姿势

还是先展示代码,再带大家慢慢解释为什么这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class RedisTool {

private static final Long RELEASE_SUCCESS = 1L;

/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;

}

}

可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

img

简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例1

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

1
2
3
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}

错误示例2

这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:

1
2
3
4
5
6
7
8
9
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {

// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}

}

如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。


总结

本文主要介绍了如何使用Java代码正确实现Redis分布式锁,对于加锁和解锁也分别给出了两个比较经典的错误示例。其实想要通过Redis实现分布式锁并不难,只要保证能满足可靠性里的四个条件。互联网虽然给我们带来了方便,只要有问题就可以google,然而网上的答案一定是对的吗?其实不然,所以我们更应该时刻保持着质疑精神,多想多验证。

如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件,链接在参考阅读章节已经给出。

其它博文参考:深入解析 Redis 分布式锁原理 - 知乎 (zhihu.com)

看门狗,给锁续时

【Java进阶】五分钟梳理看门狗的实现原理,手写Redis锁续期功能,打造核心竞争力_哔哩哔哩_bilibili

  • 方案:可基于HashedWheelTimer,加上自旋的方式来实现
  • HashedWheelTimer:时间轮,异步的延时执行任务的工具类

Redis分布式锁解决高并发场景 - 知乎 (zhihu.com)

image-20230520161301132

网络通信层

Bootstrap

负责客户端启动并用来连接远程Netty Server

但这里不用Bootstrap,因为 IM实战的通讯方式是通过WebSocket前端网页来链接连接我们的netty的server端的

image-20230520162251684

image-20230520162457445

image-20230520162426147

上图的远离机制:

Client这三个客户端,会发送消息到BossGroup;BossGroup是一个线程池,中有一个Selector主要作用是会生成 SocketChannel;SocketChannel会封装成NIOSocketChannel;NIOSocketChannel会注册到工作线程中的Selector;若要读数据或写数据,那么工作线程中selector就会分发到不同对应的Handler中进行处理