计划大纲

一、基础篇

网络基础

TCP三次握手

三次握手过程:

​ 客户端——发送带有SYN标志的数据包——服务端 一次握手 Client进入syn_sent状态

​ 服务端——发送带有SYN/ACK标志的数据包——客户端 二次握手 服务端进入syn_rcvd

​ 客户端——发送带有ACK标志的数据包——服务端 三次握手 连接就进入Established状态

为什么三次:

​ 主要是为了建立可靠的通信信道,保证客户端与服务端同时具备发送、接收数据的能力

为什么两次不行?

​ 1、防止已失效的请求报文又传送到了服务端,建立了多余的链接,浪费资源

​ 2、 两次握手只能保证单向连接是畅通的。(为了实现可靠数据传输, TCP 协议的通信双方, 都必须维 护一个序列号, 以标识发送出去的数据包中, 哪些是已经被对方收到的。 三次握手的过程即是通信双方 相互告知序列号起始值, 并确认对方已经收到了序列号起始值的必经步骤;如果只是两次握手, 至多只有连接发起方的起始序列号能被确认, 另一方选择的序列号则得不到确认)

TCP四次挥手过程

四次挥手过程:

​ 客户端——发送带有FIN标志的数据包——服务端,关闭与服务端的连接 ,客户端进入FIN-WAIT-1状态

​ 服务端收到这个 FIN,它发回⼀ 个 ACK,确认序号为收到的序号加1,服务端就进入了CLOSE-WAIT状态

​ 服务端——发送⼀个FIN数据包——客户端,关闭与客户端的连接,客户端就进入FIN-WAIT-2状态

​ 客户端收到这个 FIN,发回 ACK 报⽂确认,并将确认序号设置为收到序号加1,TIME-WAIT状态

为什么四次:

​ 因为需要确保客户端与服务端的数据能够完成传输。

CLOSE-WAIT:

​ 这种状态的含义其实是表示在等待关闭

TIME-WAIT:

​ 为了解决网络的丢包和网络不稳定所带来的其他问题,确保连接方能在时间范围内,关闭自己的连接

如何查看TIME-WAIT状态的链接数量?

​ netstat -an |grep TIME_WAIT|wc -l 查看连接数等待time_wait状态连接数

为什么会TIME-WAIT过多?解决方法是怎样的?

可能原因: 高并发短连接的TCP服务器上,当服务器处理完请求后立刻按照主动正常关闭连接

解决:负载均衡服务器;Web服务器首先关闭来自负载均衡服务器的连接

1、OSI与TCP/IP 模型

​ OSI七层:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层

​ TCP/IP五层:物理层、数据链路层、网络层、传输层、应用层

2、常见网络服务分层

​ 应用层:HTTP、SMTP、DNS、FTP

​ 传输层:TCP 、UDP

​ 网络层:ICMP 、IP、路由器、防火墙

​ 数据链路层:网卡、网桥、交换机

​ 物理层:中继器、集线器

3、TCP与UDP区别及场景

类型 特点 性能 应用过场景 首部字节
TCP 面向连接、可靠、字节流 传输效率慢、所需资源多 文件、邮件传输 20-60
UDP 无连接、不可靠、数据报文段 传输效率快、所需资源少 语音、视频、直播 8个字节

基于TCP的协议:HTTP、FTP、SMTP

基于UDP的协议:RIP、DNS、SNMP

4、TCP滑动窗口,拥塞控制

TCP通过:应用数据分割、对数据包进行编号、校验和、流量控制、拥塞控制、超时重传等措施保证数据的可靠传输;

拥塞控制目的:为了防止过多的数据注入到网络中,避免网络中的路由器、链路过载

拥塞控制过程:TCP维护一个拥塞窗口,该窗口随着网络拥塞程度动态变化,通过慢开始、拥塞避免等算法减少网络拥塞的发生。

5、TCP粘包原因和解决方法

TCP粘包是指:发送方发送的若干包数据到接收方接收时粘成一包

发送方原因:

​ TCP默认使用Nagle算法(主要作用:减少网络中报文段的数量):

​ 收集多个小分组,在一个确认到来时一起发送、导致发送方可能会出现粘包问题

接收方原因:

​ TCP将接收到的数据包保存在接收缓存里,如果TCP接收数据包到缓存的速度大于应用程序从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接粘到一起的包。

解决粘包问题:

​ 最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪,通过使用某种方案给出边界,例如:

  • 发送定长包。每个消息的大小都是一样的,接收方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息。

  • 包尾加上\r\n标记。FTP协议正是这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界。

  • 包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包体长度,依据包体长度来接收包体。

6、TCP、UDP报文格式

TCP报文格式:

源端口号和目的端口号

​ 用于寻找发端和收端应用进程。这两个值加上ip首部源端ip地址和目的端ip地址唯一确定一个tcp连接。

序号字段:

​ 序号用来标识从T C P发端向T C P收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节。如果将字节流看作在两个应用程序间的单向流动,则 T C P用序号对每个字节进行计数。序号是32 bit的无符号数,序号到达 2^32-1后又从0开始。

  当建立一个新的连接时,SYN标志变1。序号字段包含由这个主机选择的该连接的初始序号ISN(Initial Sequence Number)。该主机要发送数据的第一个字节序号为这个ISN加1,因为SYN标志消耗了一个序号

确认序号

​ 既然每个传输的字节都被计数,确认序号包含发送确认的一端所期望收到的下一个序号。因此,确认序号应当是上次已成功收到数据字节序号加 1。只有ACK标志为 1时确认序号字段才有效。发送ACK无需任何代价,因为 32 bit的确认序号字段和A C K标志一样,总是T C P首部的一部分。因此,我们看到一旦一个连接建立起来,这个字段总是被设置, ACK标志也总是被设置为1。TCP为应用层提供全双工服务。这意味数据能在两个方向上独立地进行传输。因此,连接的每一端必须保持每个方向上的传输数据序号。

首都长度

​ 首部长度给出首部中 32 bit字的数目。需要这个值是因为任选字段的长度是可变的。这个字段占4 bit,因此T C P最多有6 0字节的首部。然而,没有任选字段,正常的长度是 2 0字节。

标志字段:在T C P首部中有 6个标志比特。它们中的多个可同时被设置为1.
  URG紧急指针(u rgent pointer)有效
  ACK确认序号有效。
  PSH接收方应该尽快将这个报文段交给应用层。
  RST重建连接。
  SYN同步序号用来发起一个连接。这个标志和下一个标志将在第 1 8章介绍。
  FIN发端完成发送任务。

窗口大小

​ T C P的流量控制由连接的每一端通过声明的窗口大小来提供。窗口大小为字节数,起始于确认序号字段指明的值,这个值是接收端期望接收的字节。窗口大小是一个 16 bit字段,因而窗口大小最大为 65535字节。

检验和:

​ 检验和覆盖了整个的 T C P报文段:T C P首部和T C P数据。这是一个强制性的字段,一定是由发端计算和存储,并由收端进行验证。

紧急指针

​ 只有当URG标志置1时紧急指针才有效。紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。 T C P的紧急方式是发送端向另一端发送紧急数据的一种方式。

选项

​ 最常见的可选字段是最长报文大小,又称为 MSS (Maximum Segment Size)。每个连接方通常都在通信的第一个报文段(为建立连接而设置 S Y N标志的那个段)中指明这个选项。它指明本端所能接收的最大长度的报文段。

UDP报文格式:

端口号

​ 用来表示发送和接受进程。由于 I P层已经把I P数据报分配给T C P或U D P(根据I P首部中协议字段值),因此T C P端口号由T C P来查看,而 U D P端口号由UDP来查看。T C P端口号与UDP端口号是相互独立的。

长度

​ UDP长度字段指的是UDP首部和UDP数据的字节长度。该字段的最小值为 8字节(发送一份0字节的UDP数据报是 O K)。

检验和

​ UDP检验和是一个端到端的检验和。它由发送端计算,然后由接收端验证。其目的是为了发现UDP首部和数据在发送端到接收端之间发生的任何改动。

IP报文格式:普通的IP首部长为20个字节,除非含有可选项字段。

4位版本

​ 目前协议版本号是4,因此IP有时也称作IPV4.

4位首部长度

​ 首部长度指的是首部占32bit字的数目,包括任何选项。由于它是一个4比特字段,因此首部长度最长为60个字节。

服务类型(TOS)

​ 服务类型字段包括一个3bit的优先权字段(现在已经被忽略),4bit的TOS子字段和1bit未用位必须置0。4bit的TOS分别代表:最小时延,最大吞吐量,最高可靠性和最小费用。4bit中只能置其中1比特。如果所有4bit均为0,那么就意味着是一般服务。

总长度

​ 总长度字段是指整个IP数据报的长度,以字节为单位。利用首部长度和总长度字段,就可以知道IP数据报中数据内容的起始位置和长度。由于该字段长16bit,所以IP数据报最长可达65535字节。当数据报被分片时,该字段的值也随着变化。

标识字段

​ 标识字段唯一地标识主机发送的每一份数据报。通常每发送一份报文它的值就会加1。

生存时间

​ TTL(time-to-live)生存时间字段设置了数据报可以经过的最多路由器数。它指定了数据报的生存时间。TTL的初始值由源主机设置(通常为 3 2或6 4),一旦经过一个处理它的路由器,它的值就减去 1。当该字段的值为 0时,数据报就被丢弃,并发送 ICMP 报文通知源主机。

首部检验和

​ 首部检验和字段是根据 I P首部计算的检验和码。它不对首部后面的数据进行计算。 ICMP、IGMP、UDP和TCP在它们各自的首部中均含有同时覆盖首部和数据检验和码。

以太网报文格式:

目的地址和源地址:

​ 是指网卡的硬件地址(也叫MAC 地址),长度是48 位,是在网卡出厂时固化的。

数据:

​ 以太网帧中的数据长度规定最小46 字节,最大1500 字节,ARP 和RARP 数据包的长度不够46 字节,要在后面补填充位。最大值1500 称为以太网的最大传输单元(MTU),不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包度大于拨号链路的MTU了,则需要对数据包进行分片fragmentation)。ifconfig 命令的输出中也有“MTU:1500”。注意,MTU 个概念指数据帧中有效载荷的最大长度,不包括帧首部的长度。

HTTP协议

1、HTTP协议1.0_1.1_2.0

HTTP1.0:服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态

HTTP1.1:KeepAlived长连接避免了连接建立和释放的开销;通过Content-Length来判断当前请求数据是否已经全部接受(有状态

HTTP2.0:引入二进制数据帧和流的概念,其中帧对数据进行顺序标识;因为有了序列,服务器可以并行的传输数据。

http1.0和http1.1的主要区别如下:
​ 1、缓存处理:1.1添加更多的缓存控制策略(如:Entity tag,If-Match)
​ 2、网络连接的优化:1.1支持断点续传
​ 3、错误状态码的增多:1.1新增了24个错误状态响应码,丰富的错误码更加明确各个状态
​ 4、Host头处理:支持Host头域,不在以IP为请求方标志
​ 5、长连接:减少了建立和关闭连接的消耗和延迟。

http1.1和http2.0的主要区别:
​ 1、新的传输格式:2.0使用二进制格式,1.0依然使用基于文本格式
​ 2、多路复用:连接共享,不同的request可以使用同一个连接传输(最后根据每个request上的id号组合成正常的请求)
​ 3、header压缩:由于1.X中header带有大量的信息,并且得重复传输,2.0使用encoder来减少需要传输的hearder大小
​ 4、服务端推送:同google的SPDUY(1.0的一种升级)一样

2、HTTP与HTTPS之间的区别

HTTP与HTTPS之间的区别:

HTTP HTTPS
默认端口80 HTTPS默认使用端口443
明文传输、数据未加密、安全性差 传输过程ssl加密、安全性较好
响应速度快、消耗资源少 响应速度较慢、消耗资源多、需要用到CA证书

HTTPS链接建立的过程:

​ 1.首先客户端先给服务器发送一个请求

​ 2.服务器发送一个SSL证书给客户端,内容包括:证书的发布机构、有效期、所有者、签名以及公钥

​ 3.客户端对发来的公钥进行真伪校验,校验为真则使用公钥对对称加密算法以及对称密钥进行加密

​ 4.服务器端使用私钥进行解密并使用对称密钥加密确认信息发送给客户端

​ 5.随后客户端和服务端就使用对称密钥进行信息传输

对称加密算法:

​ 双方持有相同的密钥,且加密速度快,典型对称加密算法:DES、AES

非对称加密算法:

​ 密钥成对出现(私钥、公钥),私钥只有自己知道,不在网络中传输;而公钥可以公开。相比对称加密速度较慢,典型的非对称加密算法有:RSA、DSA

3、Get和Post请求区别

HTTP请求:

方法 描述
GET 向特定资源发送请求,查询数据,并返回实体
POST 向指定资源提交数据进行处理请求,可能会导致新的资源建立、已有资源修改
PUT 向服务器上传新的内容
HEAD 类似GET请求,返回的响应中没有具体的内容,用于获取报头
DELETE 请求服务器删除指定标识的资源
OPTIONS 可以用来向服务器发送请求来测试服务器的功能性
TRACE 回显服务器收到的请求,用于测试或诊断
CONNECT HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器

get和Post区别:

GET POST
可见性 数据在URL中对所有人可见 数据不会显示在URL中
安全性 与post相比,get的安全性较差,因为所
发送的数据是URL的一部分
安全,因为参数不会被保存在浏览器
历史或web服务器日志中
数据长度 受限制,最长2kb 无限制
编码类型 application/x-www-form-urlencoded multipart/form-data
缓存 能被缓存 不能被缓存

4、HTTP常见响应状态码

​ 100:Continue — 继续。客户端应继续其请求。

​ 200:OK — 请求成功。一般用于GET与POST请求。

​ 301:Moved Permanently — 永久重定向。

​ 302:Found — 暂时重定向。

​ 400:Bad Request — 客户端请求的语法错误,服务器无法理解。

​ 403:Forbideen — 服务器理解请求客户端的请求,但是拒绝执行此请求。

​ 404:Not Found — 服务器无法根据客户端的请求找到资源(网页)。

​ 500:Internal Server Error — 服务器内部错误,无法完成请求。

​ 502:Bad Gateway — 作为网关或者代理服务器尝试执行请求时,从远程服务器接收到了无效的响应。

5、重定向和转发区别

重定向:redirect:

​ 地址栏发生变化

​ 重定向可以访问其他站点(服务器)的资源

​ 重定向是两次请求。不能使用request对象来共享数据

转发:forward:

​ 转发地址栏路径不变

​ 转发只能访问当前服务器下的资源

​ 转发是一次请求,可以使用request对象共享数据

6、Cookie和Session区别。

​ Cookie 和 Session都是用来跟踪浏览器用户身份的会话方式,但两者有所区别:

​ Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。

​ cookie不是很安全,别人可以分析存放在本地的COOKIE并进行欺骗,考虑到安全应当使用session。

​ Cookie ⼀般⽤来保存⽤户信息,Session 的主要作⽤就是通过服务端记录⽤户的状态

浏览器输入URL过程

过程:DNS解析、TCP连接、发送HTTP请求、服务器处理请求并返回HTTP报文、浏览器渲染、结束

过程 使用的协议
1、浏览器查找域名DNS的IP地址
DNS查找过程(浏览器缓存、路由器缓存、DNS缓存)
DNS:获取域名对应的ip
2、根据ip建立TCP连接 TCP:与服务器建立连接
3、浏览器向服务器发送HTTP请求 HTTP:发送请求
4、服务器响应HTTP响应 HTTP
5、浏览器进行渲染

操作系统基础

进程和线程的区别

进程:是资源分配的最小单位,一个进程可以有多个线程,多个线程共享进程的堆和方法区资源,不共享栈、程序计数器

线程:是任务调度和执行的最小单位,线程并行执行存在资源竞争和上下文切换的问题

协程:是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。

1、进程间通信方式IPC

管道pipe:

​ 亲缘关系使用匿名管道,非亲缘关系使用命名管道,管道遵循FIFO,半双工,数据只能单向通信;

信号:

​ 信号是一种比较复杂的通信方式,用户调用kill命令将信号发送给其他进程。

消息队列:

​ 消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点。

共享内存(share memory):

  • 使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
  • 由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥。

信号量(Semaphores) :

​ 信号量是⼀个计数器,⽤于多进程对共享数据的访问,这种通信⽅式主要⽤于解决与同步相关的问题并避免竞争条件。

套接字(Sockets) :

​ 简单的说就是通信的两⽅的⼀种约定,⽤套接字中的相关函数来完成通信过程。

2、用户态和核心态

用户态:只能受限的访问内存,运行所有的应用程序

核心态:运行操作系统程序,cpu可以访问内存的所有数据,包括外围设备

为什么要有用户态和内核态:

​ 由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络

用户态切换到内核态的3种方式:

a. 系统调用

​ 主动调用,系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

b. 异常

​ 当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,比如缺页异常,这时会触发切换内核态处理异常。

c. 外围设备的中断

​ 当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会由用户态到内核态的切换。

3、操作系统的进程空间

​ 栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。

​ 堆区(heap)— 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。

​ 静态区(static)—存放全局变量和静态变量的存储

​ 代码区(text)—存放函数体的二进制代码。

线程共享堆区、静态区

操作系统内存管理

存管理方式:页式管理、段式管理、段页式管理

分段管理:

​ 将程序的地址空间划分为若干段(segment),如代码段,数据段,堆栈段;这样每个进程有一个二维地址空间,相互独立,互不干扰。段式管理的优点是:没有内碎片(因为段大小可变,改变段大小来消除内碎片)。但段换入换出时,会产生外碎片(比如4k的段换5k的段,会产生1k的外碎片)

分页管理:

​ 在页式存储管理中,将程序的逻辑地址划分为固定大小的页(page),而物理内存划分为同样大小的页框,程序加载时,可以将任意一页放入内存中任意一个页框,这些页框不必连续,从而实现了离散分离。页式存储管理的优点是:没有外碎片(因为页的大小固定),但会产生内碎片(一个页可能填充不满)

段页式管理:

​ 段⻚式管理机制结合了段式管理和⻚式管理的优点。简单来说段⻚式管理机制就是把主存先分成若⼲段,每个段⼜分成若⼲⻚,也就是说 段⻚式管理机制 中段与段之间以及段的内部的都是离散的

1、页面置换算法FIFO、LRU

置换算法:先进先出FIFO、最近最久未使用LRU、最佳置换算法OPT

先进先出FIFO:

​ 缺点:没有考虑到实际的页面使用频率,性能差、与通常页面使用的规则不符合,实际应用较少

最近最久未使用LRU:

​ 原理:选择最近且最久未使用的页面进行淘汰

​ 优点:考虑到了程序访问的时间局部性,有较好的性能,实际应用也比较多

​ 缺点:没有合适的算法,只有适合的算法,lFU、random都可以

/**
 * @program: Java
 * @description: LRU最近最久未使用置换算法,通过LinkedHashMap实现
 * @author: Mr.Li
 * @create: 2020-07-17 10:29
 **/
public class LRUCache {
    private LinkedHashMap<Integer,Integer> cache;
    private int capacity;   //容量大小

    /**
     *初始化构造函数
     * @param capacity
     */
    public LRUCache(int capacity) {
        cache = new LinkedHashMap<>(capacity);
        this.capacity = capacity;
    }

    public int get(int key) {
        //缓存中不存在此key,直接返回
        if(!cache.containsKey(key)) {
            return -1;
        }

        int res = cache.get(key);
        cache.remove(key);   //先从链表中删除
        cache.put(key,res);  //再把该节点放到链表末尾处
        return res;
    }

    public void put(int key,int value) {
        if(cache.containsKey(key)) {
            cache.remove(key); //已经存在,在当前链表移除
        }
        if(capacity == cache.size()) {
            //cache已满,删除链表头位置
            Set<Integer> keySet = cache.keySet();
            Iterator<Integer> iterator = keySet.iterator();
            cache.remove(iterator.next());
        }
        cache.put(key,value);  //插入到链表末尾
    }
}

/**
 * @program: Java
 * @description: LRU最近最久未使用置换算法,通过LinkedHashMap内部removeEldestEntry方法实现
 * @author: Mr.Li
 * @create: 2020-07-17 10:59
 **/
class LRUCache {
    private Map<Integer, Integer> map;
    private int capacity;

    /**
     *初始化构造函数
     * @param capacity
     */
    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > capacity;  // 容量大于capacity 时就删除
            }
        };
    }
    public int get(int key) {
        //返回key对应的value值,若不存在,返回-1
        return map.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        map.put(key, value);
    }
}

最佳置换算法OPT:

​ 原理:每次选择当前物理块中的页面在未来长时间不被访问的或未来不再使用的页面进行淘汰

​ 优点:具有较好的性能,可以保证获得最低的缺页率

​ 缺点:过于理想化,但是实际上无法实现(没办法预知未来的页面)

2、死锁条件、解决方式。

​ 死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象;

死锁的条件:

​ 互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待至占有该资源的进程释放该资源;

​ 请求与保持条件:进程获得一定的资源后,又对其他资源发出请求,阻塞过程中不会释放自己已经占有的资源

​ 非剥夺条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放

​ 循环等待条件:系统中若干进程组成环路,环路中每个进程都在等待相邻进程占用的资源

解决方法:破坏死锁的任意一条件

​ 乐观锁,破坏资源互斥条件,CAS

​ 资源一次性分配,从而剥夺请求和保持条件、tryLock

​ 可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件,数据库deadlock超时

​ 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,从而破坏环路等待的条件,转账场景

Java基础

面向对象三大特性

特性:封装、继承、多态

封装:对抽象的事物抽象化成一个对象,并对其对象的属性私有化,同时提供一些能被外界访问属性的方法;

继承:子类扩展新的数据域或功能,并复用父类的属性与功能,单继承,多实现;

多态:通过继承(多个⼦类对同⼀⽅法的重写)、也可以通过接⼝(实现接⼝并覆盖接⼝)

1、Java与C++区别

​ 不同点:c++支持多继承,并且有指针的概念,由程序员自己管理内存;Java是单继承,可以用接口实现多继承,Java 不提供指针来直接访问内存,程序内存更加安全,并且Java有JVM⾃动内存管理机制,不需要程序员⼿动释放⽆⽤内存

2、多态实现原理

多态的底层实现是动态绑定,即在运行时才把方法调用与方法实现关联起来。

静态绑定与动态绑定:

​ 一种是在编译期确定,被称为静态分派,比如方法的重载;

​ 一种是在运行时确定,被称为动态分派,比如方法的覆盖(重写)和接口的实现。

多态的实现

​ 虚拟机栈中会存放当前方法调用的栈帧(局部变量表、操作栈、动态连接 、返回地址)。多态的实现过程,就是方法调用动态分派的过程,如果子类覆盖了父类的方法,则在多态调用中,动态绑定过程会首先确定实际类型是子类,从而先搜索到子类中的方法。这个过程便是方法覆盖的本质。

3、static和final关键字

static:可以修饰属性、方法

static修饰属性:

​ 类级别属性,所有对象共享一份,随着类的加载而加载(只加载一次),先于对象的创建;可以使用类名直接调用。

static修饰方法:

​ 随着类的加载而加载;可以使用类名直接调用;静态方法中,只能调用静态的成员,不可用this;

final:关键字主要⽤在三个地⽅:变量、⽅法、类。

final修饰变量:

​ 如果是基本数据类型的变量,则其数值⼀旦在初始化之后便不能更改;

​ 如果是引⽤类型的变量,则在对其初始化之后便不能再让其指向另⼀个对象。

final修饰方法:

​ 把⽅法锁定,以防任何继承类修改它的含义(重写);类中所有的 private ⽅法都隐式地指定为 final。

final修饰类:

​ final 修饰类时,表明这个类不能被继承。final 类中的所有成员⽅法都会被隐式地指定为 final ⽅法。

一个类不能被继承,除了final关键字之外,还有可以私有化构造器。(内部类无效)

4、抽象类和接口

抽象类:包含抽象方法的类,即使用abstract修饰的类;抽象类只能被继承,所以不能使用final修饰,抽象类不能被实例化,

接口:接口是一个抽象类型,是抽象方法的集合,接口支持多继承,接口中定义的方法,默认是public abstract修饰的抽象方法

相同点:

​ ① 抽象类和接口都不能被实例化

​ ② 抽象类和接口都可以定义抽象方法,子类/实现类必须覆写这些抽象方法

不同点:

​ ① 抽象类有构造方法,接口没有构造方法

​ ③抽象类可以包含普通方法,接口中只能是public abstract修饰抽象方法(Java8之后可以)

​ ③ 抽象类只能单继承,接口可以多继承

​ ④ 抽象类可以定义各种类型的成员变量,接口中只能是public static final修饰的静态常量

抽象类的使用场景:

​ 既想约束子类具有共同的行为(但不再乎其如何实现),又想拥有缺省的方法,又能拥有实例变量

接口的应用场景:

​ 约束多个实现类具有统一的行为,但是不在乎每个实现类如何具体实现;实现类中各个功能之间可能没有任何联系

5、泛型以及泛型擦除

参考:https://blog.csdn.net/baoyinwang/article/details/107341997

泛型:

​ 泛型的本质是参数化类型。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

泛型擦除:

​ Java的泛型是伪泛型,使用泛型的时候加上类型参数,在编译器编译生成的字节码的时候会去掉,这个过程成为类型擦除。

​ 如List等类型,在编译之后都会变成 List。JVM 看到的只是 List,而由泛型附加的类型信息对 JVM 来说是不可见的。

可以通过反射添加其它类型元素

6、反射原理以及使用场景

Java反射:

​ 是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法;并且都能够调用它的任意一个方法;

反射原理:

​ 反射首先是能够获取到Java中的反射类的字节码,然后将字节码中的方法,变量,构造函数等映射成 相应的 Method、Filed、Constructor 等类

如何得到Class的实例:

     1.类名.class(就是一份字节码)
     2.Class.forName(String className);根据一个类的全限定名来构建Class对象
     3.每一个对象多有getClass()方法:obj.getClass();返回对象的真实类型

使用场景:

  • 开发通用框架 - 反射最重要的用途就是开发各种通用框架。很多框架(比如 Spring)都是配置化的(比如通过 XML 文件配置 JavaBean、Filter 等),为了保证框架的通用性,需要根据配置文件运行时动态加载不同的对象或类,调用不同的方法。

  • 动态代理 - 在切面编程(AOP)中,需要拦截特定的方法,通常,会选择动态代理方式。这时,就需要反射技术来实现了。

    JDK:spring默认动态代理,需要实现接口

    CGLIB:通过asm框架序列化字节流,可配置,性能差

  • 自定义注解 - 注解本身仅仅是起到标记作用,它需要利用反射机制,根据注解标记去调用注解解释器,执行行为。

7、Java异常体系

Throwable 是 Java 语言中所有错误或异常的超类。下一层分为 Error 和 Exception

Error :

​ 是指 java 运行时系统的内部错误和资源耗尽错误。应用程序不会抛出该类对象。如果出现了这样的错误,除了告知用户,剩下的就是尽力使程序安全的终止。

Exception 包含:RuntimeException 、CheckedException

编程错误可以分成三类:语法错误、逻辑错误和运行错误。

语法错误(也称编译错误)是在编译过程中出现的错误,由编译器检查发现语法错误

逻辑错误指程序的执行结果与预期不符,可以通过调试定位并发现错误的原因

运行错误是引起程序非正常终端的错误,需要通过异常处理的方式处理运行错误

RuntimeException: 运行时异常,程序应该从逻辑角度尽可能避免这类异常的发生。

​ 如 NullPointerException 、 ClassCastException ;

CheckedException:受检异常,程序使用trycatch进行捕捉处理

​ 如IOException、SQLException、NotFoundException;

数据结构

JavaCollection

1、ArrayList和LinkedList

ArrayList:

​ 底层基于数组实现,支持对元素进行快速随机访问,适合随机查找和遍历,不适合插入和删除。(提一句实际上)
​ 默认初始大小为10,当数组容量不够时,会触发扩容机制(扩大到当前的1.5倍),需要将原来数组的数据复制到新的数组中;当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。

LinkedList:

​ 底层基于双向链表实现,适合数据的动态插入和删除;
​ 内部提供了 List 接口中没有定义的方法,用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。(比如jdk官方推荐使用基于linkedList的Deque进行堆栈操作)

ArrayList与LinkedList区别:

​ 都是线程不安全的,ArrayList 适用于查找的场景,LinkedList 适用于增加、删除多的场景

实现线程安全:

​ 可以使用原生的Vector,或者是Collections.synchronizedList(List list)函数返回一个线程安全的ArrayList集合。
​ 建议使用concurrent并发包下的CopyOnWriteArrayList的。

​ ①Vector: 底层通过synchronize修饰保证线程安全,效率较差

​ ②CopyOnWriteArrayList:写时加锁,使用了一种叫写时复制的方法;读操作是可以不用加锁的

2、List遍历快速和安全失败

①普通for循环遍历List删除指定元素

for(int i=0; i < list.size(); i++){
   if(list.get(i) == 5) 
       list.remove(i);
}

② 迭代遍历,用list.remove(i)方法删除元素

Iterator it = list.iterator();
while(it.hasNext()){
    Integer value = it.next();
    if(value == 5){
        list.remove(value);
    }
}

③foreach遍历List删除元素

for(Integer i:list){
    if(i==3) list.remove(i);
}

fail—fast:快速失败

​ 当异常产生时,直接抛出异常,程序终止;

​ fail-fast主要是体现在当我们在遍历集合元素的时候,经常会使用迭代器,但在迭代器遍历元素的过程中,如果集合的结构(modCount)被改变的话,就会抛出异常ConcurrentModificationException,防止继续遍历。这就是所谓的快速失败机制。

fail—safe:安全失败

    采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModificationException。

    缺点:基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

    场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

3、详细介绍HashMap

角度:数据结构+扩容情况+put查找的详细过程+哈希函数+容量为什么始终都是2^N,JDK1.7与1.8的区别。

参考:https://www.jianshu.com/p/9fe4cb316c05

数据结构:

​ HashMap在底层数据结构上采用了数组+链表+红黑树,通过散列映射来存储键值对数据

扩容情况:

​ 默认的负载因子是0.75,如果数组中已经存储的元素个数大于数组长度的75%,将会引发扩容操作。

​ 【1】创建一个长度为原来数组长度两倍的新数组

​ 【2】1.7采用Entry的重新hash运算,1.8采用高于与运算。

put操作步骤:

img

​ 1、判断数组是否为空,为空进行初始化;

​ 2、不为空,则计算 key 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;

​ 3、查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;

​ 4、存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据;

​ 5、若不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;

​ 6、若不是红黑树,创建普通Node加入链表中;判断链表长度是否大于 8,大于则将链表转换为红黑树;

​ 7、插入完成之后判断当前节点数是否大于阈值,若大于,则扩容为原数组的二倍

哈希函数:

​ 通过hash函数(优质因子31循环累加)先拿到 key 的hashcode,是一个32位的值,然后让hashcode的高16位和低16位进行异或操作。该函数也称为扰动函数,做到尽可能降低hash碰撞,通过尾插法进行插入。

容量为什么始终都是2^N:

​ 先做对数组的⻓度取模运算,得到的余数才能⽤来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是“ (n - 1) & hash ”。(n代表数组⻓度)。方便数组的扩容和增删改时的取模。

JDK1.7与1.8的区别:

JDK1.7 HashMap:

​ 底层是 数组和链表 结合在⼀起使⽤也就是链表散列。如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。扩容翻转时顺序不一致使用头插法会产生死循环,导致cpu100%

JDK1.8 HashMap:

​ 底层数据结构上采用了数组+链表+红黑树;当链表⻓度⼤于阈值(默认为 8-泊松分布),数组的⻓度大于 64时,链表将转化为红⿊树,以减少搜索时间。(解决了tomcat臭名昭著的url参数dos攻击问题)

4、ConcurrentHashMap

​ 可以通过ConcurrentHashMapHashtable来实现线程安全;Hashtable 是原始API类,通过synchronize同步修饰,效率低下;ConcurrentHashMap 通过分段锁实现,效率较比Hashtable要好;

ConcurrentHashMap的底层实现:

JDK1.7的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现;采用 分段锁(Sagment) 对整个桶数组进⾏了分割分段(Segment默认16个),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。

JDK1.8的 ConcurrentHashMap 采⽤的数据结构跟HashMap1.8的结构⼀样,数组+链表/红⿊树;摒弃了Segment的概念,⽽是直接⽤ Node 数组+链表+红⿊树的数据结构来实现,通过并发控制 synchronized 和CAS来操作保证线程的安全。

5、序列化和反序列化

​ 序列化的意思就是将对象的状态转化成字节流,以后可以通过这些值再生成相同状态的对象。对象序列化是对象持久化的一种实现方法,它是将对象的属性和方法转化为一种序列化的形式用于存储和传输。反序列化就是根据这些保存的信息重建对象的过程。

序列化:将java对象转化为字节序列的过程。

反序列化:将字节序列转化为java对象的过程。

优点:

​ a、实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里)Redis的RDB

​ b、利用序列化实现远程通信,即在网络上传送对象的字节序列。 Google的protoBuf

反序列化失败的场景:

​ 序列化ID:serialVersionUID不一致的时候,导致反序列化失败

6、String

String 使用数组存储内容,数组使用 final 修饰,因此 String 定义的字符串的值也是不可变的

StringBuffer 对方法加了同步锁,线程安全,效率略低于 StringBuilder

设计模式与原则

1、单例模式

​ 某个类只能生成一个实例,该实例全局访问,例如Spring容器里一级缓存里的单例池。

优点

唯一访问:如生成唯一序列化的场景、或者spring默认的bean类型。

提高性能:频繁实例化创建销毁或者耗时耗资源的场景,如连接池、线程池。

缺点

​ 不适合有状态且需变更的

实现方式

饿汉式:线程安全速度快

懒汉式:双重检测锁,第一次减少锁的开销、第二次防止重复、volatile防止重排序导致实例化未完成

静态内部类:线程安全利用率高

枚举:effictiveJAVA推荐,反射也无法破坏

2、工厂模式

​ 定义一个用于创建产品的接口,由子类决定生产何种产品。

优点:解耦:提供参数即可获取产品,通过配置文件可以不修改代码增加具体产品。

缺点:每增加一个产品就得新增一个产品类

3、抽象工厂模式

​ 提供一个接口,用于创建相关或者依赖对象的家族,并由此进行约束。

优点:可以在类的内部对产品族进行约束

缺点:假如产品族中需要增加一个新的产品,则几乎所有的工厂类都需要进行修改。

面试题

构造方法

构造方法可以被重载,只有当类中没有显性声明任何构造方法时,才会有默认构造方法。

构造方法没有返回值,构造方法的作用是创建新对象。

初始化块

静态初始化块的优先级最高,会最先执行,在非静态初始化块之前执行。

静态初始化块会在类第一次被加载时最先执行,因此在 main 方法之前。

This

关键字 this 代表当前对象的引用。当前对象指的是调用类中的属性或方法的对象

关键字 this 不可以在静态方法中使用。静态方法不依赖于类的具体对象的引用

重写和重载的区别

重载指在同一个类中定义多个方法,这些方法名称相同,签名不同。

重写指在子类中的方法的名称和签名都和父类相同,使用override注解

Object类方法

toString 默认是个指针,一般需要重写

equals 比较对象是否相同,默认和==功能一致

hashCode 散列码,equals则hashCode相同,所以重写equals必须重写hashCode

finalize 用于垃圾回收之前做的遗嘱,默认空,子类需重写

clone 深拷贝,类需实现cloneable的接口

getClass 反射获取对象元数据,包括类名、方法、

notify、wait 用于线程通知和唤醒

基本数据类型和包装类

image-20210309224910999

类型 缓存范围
Byte,Short,Integer,Long [-128, 127]
Character [0, 127]
Boolean [false, true]

二、JVM篇

JVM内存划分

1、JVM运行时数据区域

​ 堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器

xxx

Heap(堆):

​ 对象的实例以及数组的内存都是要在堆上进行分配的,堆是线程共享的一块区域,用来存放对象实例,也是垃圾回收(GC)的主要区域;开启逃逸分析后,某些未逃逸的对象可以通过标量替换的方式在栈中分配

​ 堆细分:新生代、老年代,对于新生代又分为:Eden区Surviver1Surviver2区;

方法区:

​ 对于JVM的方法区也可以称之为永久区,它储存的是已经被java虚拟机加载的类信息、常量、静态变量;Jdk1.8以后取消了方法区这个概念,称之为元空间(MetaSpace);

​ 当应用中的 Java 类过多时,比如 Spring 等一些使用动态代理的框架生成了很多类,如果占用空间超出了我们的设定值,就会发生元空间溢出

虚拟机栈:

​ 虚拟机栈是线程私有的,他的生命周期和线程的生命周期是一致的。里面装的是一个一个的栈帧,每一个方法在执行的时候都会创建一个栈帧,栈帧中用来存放(局部变量表操作数栈动态链接返回地址);在Java虚拟机规范中,对此区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈动态扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

  • 局部变量表:局部变量表是一组变量值存储空间,用来存放方法参数、方法内部定义的局部变量。底层是变量槽(variable slot)

  • 操作数栈:是用来记录一个方法在执行的过程中,字节码指令向操作数栈中进行入栈和出栈的过程。大小在编译的时候已经确定了,当一个方法刚开始执行的时候,操作数栈中是空发的,在方法执行的过程中会有各种字节码指令往操作数栈中入栈和出栈

  • 动态链接:因为字节码文件中有很多符号的引用,这些符号引用一部分会在类加载的解析阶段第一次使用的时候转化成直接引用,这种称为静态解析;另一部分会在运行期间转化为直接引用,称为动态链接

  • 返回地址(returnAddress):类型(指向了一条字节码指令的地址)

    JIT即时编译器(Just In Time Compiler),简称 JIT 编译器:

    为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,比如锁粗化等

本地方法栈:

​ 本地方法栈和虚拟机栈类似,不同的是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。在HotSpot虚拟机实现中是把本地方法栈和虚拟机栈合二为一的,同理它也会抛出StackOverflowErrorOOM异常。

PC程序计数器:

​ PC,指的是存放下一条指令的位置的一个指针。它是一块较小的内存空间,且是线程私有的。由于线程的切换,CPU在执行的过程中,需要记住原线程的下一条指令的位置,所以每一个线程都需要有自己的PC。

2、堆内存分配策略

img

  • 对象优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。

  • 大对象直接进入老年代(需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。

  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。

    动态对象年龄判定:程序从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代)

  • 每次进行Minor GC或者大对象直接进入老年区时,JVM会计算所需空间大小如小于老年区的剩余值大小,则进行一次Full GC

3、创建一个对象的步骤

步骤:类加载检查、分配内存、初始化零值、设置对象头、执行init方法

①类加载检查:

​ 虚拟机遇到 new 指令时,⾸先去检查是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。

②分配内存:

​ 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存,分配⽅式有 “指针碰撞”“空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。

③初始化零值:

​ 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头:

​ 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

⑤执⾏ init ⽅法:

​ 从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说(除循环依赖),执⾏ new 指令之后会接着执⾏ ⽅法,这样⼀个真正可⽤的对象才算产⽣出来。

4、对象引用

普通的对象引用关系就是强引用

软引用用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。

弱引用对象相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。

虚引用是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。

JVM类加载过程

过程:加载、验证、准备、解析、初始化

img

加载阶段:

​ 1.通过一个类的全限定名来获取定义此类的二进制字节流。

​ 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

​ 3.在Java堆中生成一个代表这个类的java.lang.class对象,作为方法区这些数据的访问入口。

验证阶段:

​ 1.文件格式验证(是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理)

​ 2.元数据验证(对字节码描述的信息进行语意分析,以保证其描述的信息符合Java语言规范要求)

​ 3.字节码验证(保证被校验类的方法在运行时不会做出危害虚拟机安全的行为)

​ 4.符号引用验证(虚拟机将符号引用转化为直接引用时,解析阶段中发生)

准备阶段:

​ 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。将对象初始化为“零”值

解析阶段:

​ 解析阶段时虚拟机将常量池内的符号引用替换为直接引用的过程。

字符串常量池:堆上,默认class文件的静态常量池

运行时常量池:在方法区,属于元空间

初始化阶段:

​ 初始化阶段时加载过程的最后一步,而这一阶段也是真正意义上开始执行类中定义的Java程序代码。

1、双亲委派机制

​ 每⼀个类都有⼀个对应它的类加载器。系统中的 ClassLoder 在协同⼯作的时候会默认使⽤ 双亲委派模型 。即在类加载的时候,系统会⾸先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,⾸先会把该请求委派该⽗类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当⽗类加载器⽆法处理时,才由⾃⼰来处理。当⽗类加载器为null时,会使⽤启动类加载器 BootstrapClassLoader 作为⽗类加载器。

img

使用好处:

​ 此机制保证JDK核心类的优先加载;使得Java程序的稳定运⾏,可以避免类的重复加载,也保证了 Java 的核⼼ API 不被篡改。如果不⽤没有使⽤双亲委派模型,⽽是每个类加载器加载⾃⼰的话就会出现⼀些问题,⽐如我们编写⼀个称为 java.lang.Object 类的话,那么程序运⾏的时候,系统就会出现多个不同的Object 类。

破坏双亲委派机制:

  • 可以⾃⼰定义⼀个类加载器,重写loadClass方法;
  • Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器;

  • Java 的 SPI,发起者 BootstrapClassLoader 已经是最上层了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的。

2、tomcat的类加载机制

步骤:

  1. 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
  2. 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
  3. 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 AppClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
  4. 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
  5. 加载依然失败,才使用 AppClassLoader 继续加载。
  6. 都没有加载成功的话,抛出异常。

总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。

JVM垃圾回收

1、存活算法和两次标记过程

引用计数法:

​ 给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

​ 优点:实现简单,判定效率也很高

​ 缺点:他很难解决对象之间相互循环引用的问题,基本上被抛弃

可达性分析法:

​ 通过一系列的成为“GC Roots”(活动线程相关的各种引用,虚拟机栈帧引用静态变量引用JNI引用)的对象作为起始点,从这些节点ReferenceChains开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的;

两次标记过程:

​ 对象被回收之前,该对象的finalize()方法会被调用;两次标记,即第一次标记不在“关系网”中的对象。第二次的话就要先判断该对象有没有实现finalize()方法了,如果没有实现就直接判断该对象可回收;如果实现了就会先放在一个队列中,并由虚拟机建立的一个低优先级的线程去执行它,随后就会进行第二次的小规模标记,在这次被标记的对象就会真正的被回收了。

2、垃圾回收算法

垃圾回收算法:复制算法、标记清除、标记整理、分代收集

复制算法:(young)

​ 将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收;

​ 优点:实现简单,内存效率高,不易产生碎片

​ 缺点:内存压缩了一半,倘若存活对象多,Copying 算法的效率会大大降低

标记清除:(cms)

​ 标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象

​ 缺点:效率低,标记清除后会产⽣⼤量不连续的碎⽚,需要预留空间给分配阶段的浮动垃圾

标记整理:(old)

​ 标记过程仍然与“标记-清除”算法⼀样,再让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存;解决了产生大量不连续碎片问题

分代收集:

​ 根据各个年代的特点选择合适的垃圾收集算法。

​ 新生代采用复制算法,新生代每次垃圾回收都要回收大部分对象,存活对象较少,即要复制的操作比较少,一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

​ 老年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。

Safepoint 当发生 GC 时,用户线程必须全部停下来,才可以进行垃圾回收,这个状态我们可以认为 JVM 是安全的(safe),整个堆的状态是稳定的。如果在 GC 前,有线程迟迟进入不了 safepoint,那么整个 JVM 都在等待这个阻塞的线程,造成了整体 GC 的时间变长

img

MinorGC、MajorGC、FullGC

MinorGC 在年轻代空间不足的时候发生,

MajorGC 指的是老年代的 GC,出现 MajorGC 一般经常伴有 MinorGC。

FullGC 1、当老年代无法再分配内存的时候;2、元空间不足的时候;3、显示调用 System.gc 的时候。另外,像 CMS 一类的垃圾回收器,在 MinorGC 出现 promotion failure 的时候也会发生 FullGC。

对象优先在 Eden 区分配
大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

大对象直接进入老年代
大对象是指需要连续内存空间的对象,比如很长的字符串以及数组。老年代直接分配的目的是避免在 Eden 区和 Survivor 区之间出现大量内存复制。

长期存活的对象进入老年代
虚拟机给每个对象定义了年龄计数器,对象在 Eden 区出生之后,如果经过一次 Minor GC 之后,将进入 Survivor 区,同时对象年龄变为 1,增加到一定阈值时则进入老年代(阈值默认为 15)

动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代。如果在 Survivor 区中相同年龄的所有对象的空间总和大于 Survivor 区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代。

空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立则进行 Full GC。

3、垃圾收集器

img

JDK3:Serial Parnew 关注效率

Serial:

​ Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。适合用于客户端垃圾收集器。

Parnew:

​ ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。

JDK5:parallel Scavenge+(Serial old/parallel old)关注吞吐量

parallel Scavenge:(关注吞吐量)

​ Parallel Scavenge收集器关注点是吞吐量(⾼效率的利⽤CPU)。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验);高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。

Serial old:

Serial收集器的⽼年代版本,它同样是⼀个单线程收集器,使用标记-整理算法。主要有两个用途:

  • 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  • 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

parallel old:

​ Parallel Scavenge收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。

JDK8-CMS:(关注最短垃圾回收停顿时间)

​ CMS收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,STW。

并发标记:进行 ReferenceChains跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,STW。

并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。

​ 由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

优点:并发收集、低停顿

缺点:对CPU资源敏感;⽆法处理浮动垃圾;使⽤“标记清除”算法,会导致⼤量空间碎⽚产⽣。

JDK9-G1:(精准控制停顿时间,避免垃圾碎片)

​ 是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器.以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征;相比与 CMS 收集器,G1 收集器两个最突出的改进是:

​ 【1】基于标记-整理算法,不产生内存碎片。

​ 【2】可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

​ G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

  • 初始标记Stop The World,仅使用一条初始标记线程对GC Roots关联的对象进行标记

  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢

  • 最终标记Stop The World,使用多条标记线程并发执行

  • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行

JDK11-ZGC:(在不关注容量的情况获取最小停顿时间5TB/10ms)

​ 着色笔技术:加快标记过程

​ 读屏障:解决GC和应用之间并发导致的STW问题

  • 支持 TB 级堆内存(最大 4T, JDK13 最大16TB)

  • 最大 GC 停顿 10ms

  • 对吞吐量影响最大,不超过 15%

4、配置垃圾收集器

  • 首先是内存大小问题,基本上每一个内存区域我都会设置一个上限,来避免溢出问题,比如元空间。
  • 通常,堆空间我会设置成操作系统的 2/3,超过 8GB 的堆,优先选用 G1
  • 然后我会对 JVM 进行初步优化,比如根据老年代的对象提升速度,来调整年轻代和老年代之间的比例
  • 依据系统容量、访问延迟、吞吐量等进行专项优化,我们的服务是高并发的,对 STW 的时间敏感
  • 我会通过记录详细的 GC 日志,来找到这个瓶颈点,借用 GCeasy 这样的日志分析工具,定位问题

4、JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化

常用命令:jps、jinfo、jstat、jstack、jmap

jps:查看java进程及相关信息

jps -l 输出jar包路径,类全名
jps -m 输出main参数
jps -v 输出JVM参数

jinfo:查看JVM参数

jinfo 11666
jinfo -flags 11666
Xmx、Xms、Xmn、MetaspaceSize

jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

jstat [option] LVMID [interval] [count]
其中LVMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(默认一直打印)

option参数解释:
-gc 垃圾回收堆的行为统计
-gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil 垃圾回收统计概述
-gcnew 新生代行为统计
-gcold 年老代和永生代行为统计

jstack:查看JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环

jstack [-l] <pid> (连接运行中的进程)

option参数解释:
-F 当使用jstack <pid>无响应时,强制输出线程堆栈。
-m 同时输出java和本地堆栈(混合模式)
-l 额外显示锁信息

jmap:可以用来查看内存信息(配合jhat使用)

jmap [option] <pid> (连接正在执行的进程)

option参数解释:
-heap 打印java heap摘要
-dump:<dump-options> 生成java堆的dump文件

5、JDK新特性

JDK8

支持 Lamda 表达式、集合的 stream 操作、提升HashMap性能

JDK9

//Stream API中iterate方法的新重载方法,可以指定什么时候结束迭代
IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println);

默认G1垃圾回收器

JDK10

其重点在于通过完全GC并行来改善G1最坏情况的等待时间。

JDK11

ZGC (并发回收的策略) 4TB

用于 Lambda 参数的局部变量语法

JDK12

Shenandoah GC (GC 算法)停顿时间和堆的大小没有任何关系,并行关注停顿响应时间。

JDK13

增加ZGC以将未使用的堆内存返回给操作系统,16TB

JDK14

删除cms垃圾回收器、弃用ParallelScavenge+SerialOldGC垃圾回收算法组合

将ZGC垃圾回收器应用到macOS和windows平台

线上故障排查

1、硬件故障排查

如果一个实例发生了问题,根据情况选择,要不要着急去重启。如果出现的CPU、内存飙高或者日志里出现了OOM异常

第一步是隔离,第二步是保留现场,第三步才是问题排查

隔离

就是把你的这台机器从请求列表里摘除,比如把 nginx 相关的权重设成零。

现场保留

瞬时态和历史态

img

查看比如 CPU、系统内存等,通过历史状态可以体现一个趋势性问题,而这些信息的获取一般依靠监控系统的协作。

保留信息

(1)系统当前网络连接

ss -antp > $DUMP_DIR/ss.dump 2>&1

使用 ss 命令而不是 netstat 的原因,是因为 netstat 在网络连接非常多的情况下,执行非常缓慢。

后续的处理,可通过查看各种网络连接状态的梳理,来排查 TIME_WAIT 或者 CLOSE_WAIT,或者其他连接过高的问题,非常有用。

(2)网络状态统计

netstat -s > $DUMP_DIR/netstat-s.dump 2>&1

它能够按照各个协议进行统计输出,对把握当时整个网络状态,有非常大的作用。

sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1

在一些速度非常高的模块上,比如 Redis、Kafka,就经常发生跑满网卡的情况。表现形式就是网络通信非常缓慢。

(3)进程资源

lsof -p $PID > $DUMP_DIR/lsof-$PID.dump

通过查看进程,能看到打开了哪些文件,可以以进程的维度来查看整个资源的使用情况,包括每条网络连接、每个打开的文件句柄。同时,也可以很容易的看到连接到了哪些服务器、使用了哪些资源。这个命令在资源非常多的情况下,输出稍慢,请耐心等待。

(4)CPU 资源

mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL  > $DUMP_DIR/sar-cpu.dump  2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1

主要用于输出当前系统的 CPU 和负载,便于事后排查。

(5)I/O 资源

iostat -x > $DUMP_DIR/iostat.dump 2>&1

一般,以计算为主的服务节点,I/O 资源会比较正常,但有时也会发生问题,比如日志输出过多,或者磁盘问题等。此命令可以输出每块磁盘的基本性能信息,用来排查 I/O 问题。在第 8 课时介绍的 GC 日志分磁盘问题,就可以使用这个命令去发现。

(6)内存问题

free -h > $DUMP_DIR/free.dump 2>&1

free 命令能够大体展现操作系统的内存概况,这是故障排查中一个非常重要的点,比如 SWAP 影响了 GC,SLAB 区挤占了 JVM 的内存。

(7)其他全局

ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1

dmesg 是许多静悄悄死掉的服务留下的最后一点线索。当然,ps 作为执行频率最高的一个命令,由于内核的配置参数,会对系统和 JVM 产生影响,所以我们也输出了一份。

(8)进程快照,最后的遗言(jinfo)

${JDK_BIN}jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1

此命令将输出 Java 的基本进程信息,包括环境变量和参数配置,可以查看是否因为一些错误的配置造成了 JVM 问题。

(9)dump 堆信息

${JDK_BIN}jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
${JDK_BIN}jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1

jstat 将输出当前的 gc 信息。一般,基本能大体看出一个端倪,如果不能,可将借助 jmap 来进行分析。

(10)堆信息

${JDK_BIN}jmap $PID > $DUMP_DIR/jmap.dump 2>&1
${JDK_BIN}jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
${JDK_BIN}jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
${JDK_BIN}jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null  2>&1

jmap 将会得到当前 Java 进程的 dump 信息。如上所示,其实最有用的就是第 4 个命令,但是前面三个能够让你初步对系统概况进行大体判断。因为,第 4 个命令产生的文件,一般都非常的大。而且,需要下载下来,导入 MAT 这样的工具进行深入分析,才能获取结果。这是分析内存泄漏一个必经的过程。

(11)JVM 执行栈

${JDK_BIN}jstack $PID > $DUMP_DIR/jstack.dump 2>&1

jstack 将会获取当时的执行栈。一般会多次取值,我们这里取一次即可。这些信息非常有用,能够还原 Java 进程中的线程情况。

top -Hp $PID -b -n 1 -c >  $DUMP_DIR/top-$PID.dump 2>&1

为了能够得到更加精细的信息,我们使用 top 命令,来获取进程中所有线程的 CPU 信息,这样,就可以看到资源到底耗费在什么地方了。

(12)高级替补

kill -3 $PID

有时候,jstack 并不能够运行,有很多原因,比如 Java 进程几乎不响应了等之类的情况。我们会尝试向进程发送 kill -3 信号,这个信号将会打印 jstack 的 trace 信息到日志文件中,是 jstack 的一个替补方案。

gcore -o $DUMP_DIR/core $PID

对于 jmap 无法执行的问题,也有替补,那就是 GDB 组件中的 gcore,将会生成一个 core 文件。我们可以使用如下的命令去生成 dump:

${JDK_BIN}jhsdb jmap --exe ${JDK}java  --core $DUMP_DIR/core --binaryheap
  1. 内存泄漏的现象

稍微提一下 jmap 命令,它在 9 版本里被干掉了,取而代之的是 jhsdb,你可以像下面的命令一样使用。

jhsdb jmap  --heap --pid  37340
jhsdb jmap  --pid  37288
jhsdb jmap  --histo --pid  37340
jhsdb jmap  --binaryheap --pid  37340

一般内存溢出,表现形式就是 Old 区的占用持续上升,即使经过了多轮 GC 也没有明显改善。比如ThreadLocal里面的GC Roots,内存泄漏的根本就是,这些对象并没有切断和 GC Roots 的关系,可通过一些工具,能够看到它们的联系。

2、报表异常 | JVM调优

有一个报表系统,频繁发生内存溢出,在高峰期间使用时,还会频繁的发生拒绝服务,由于大多数使用者是管理员角色,所以很快就反馈到研发这里。

业务场景是由于有些结果集的字段不是太全,因此需要对结果集合进行循环,并通过 HttpClient 调用其他服务的接口进行数据填充。使用 Guava 做了 JVM 内缓存,但是响应时间依然很长。

初步排查,JVM 的资源太少。接口 A 每次进行报表计算时,都要涉及几百兆的内存,而且在内存里驻留很长时间,有些计算又非常耗 CPU,特别的“吃”资源。而我们分配给 JVM 的内存只有 3 GB,在多人访问这些接口的时候,内存就不够用了,进而发生了 OOM。在这种情况下,没办法,只有升级机器。把机器配置升级到 4C8G,给 JVM 分配 6GB 的内存,这样 OOM 问题就消失了。但随之而来的是频繁的 GC 问题和超长的 GC 时间,平均 GC 时间竟然有 5 秒多。

进一步,由于报表系统和高并发系统不太一样,它的对象,存活时长大得多,并不能仅仅通过增加年轻代来解决;而且,如果增加了年轻代,那么必然减少了老年代的大小,由于 CMS 的碎片和浮动垃圾问题,我们可用的空间就更少了。虽然服务能够满足目前的需求,但还有一些不太确定的风险。

第一,了解到程序中有很多缓存数据和静态统计数据,为了减少 MinorGC 的次数,通过分析 GC 日志打印的对象年龄分布,把 MaxTenuringThreshold 参数调整到了 3(特殊场景特殊的配置)。这个参数是让年轻代的这些对象,赶紧回到老年代去,不要老呆在年轻代里。

第二,我们的 GC 时间比较长,就一块开了参数 CMSScavengeBeforeRemark,使得在 CMS remark 前,先执行一次 Minor GC 将新生代清掉。同时配合上个参数,其效果还是比较好的,一方面,对象很快晋升到了老年代,另一方面,年轻代的对象在这种情况下是有限的,在整个 MajorGC 中占的时间也有限。

第三,由于缓存的使用,有大量的弱引用,拿一次长达 10 秒的 GC 来说。我们发现在 GC 日志里,处理 weak refs 的时间较长,达到了 4.5 秒。这里可以加入参数 ParallelRefProcEnabled 来并行处理Reference,以加快处理速度,缩短耗时。

优化之后,效果不错,但并不是特别明显。经过评估,针对高峰时期的情况进行调研,我们决定再次提升机器性能,改用 8core16g 的机器。但是,这带来另外一个问题。

高性能的机器带来了非常大的服务吞吐量,通过 jstat 进行监控,能够看到年轻代的分配速率明显提高,但随之而来的 MinorGC 时长却变的不可控,有时候会超过 1 秒。累积的请求造成了更加严重的后果。

这是由于堆空间明显加大造成的回收时间加长。为了获取较小的停顿时间,我们在堆上改用了 G1 垃圾回收器,把它的目标设定在 200ms。G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能。修改之后,虽然 GC 更加频繁了一些,但是停顿时间都比较小,应用的运行较为平滑。

到目前为止,也只是勉强顶住了已有的业务,但是,这时候领导层面又发力,要求报表系统可以支持未来两年业务10到100倍的增长,并保持其可用性,但是这个“千疮百孔”的报表系统,稍微一压测,就宕机,那如何应对十倍百倍的压力呢 ? 硬件即使可以做到动态扩容,但是毕竟也有极限。

使用 MAT 分析堆快照,发现很多地方可以通过代码优化,那些占用内存特别多的对象:

1、select * 全量排查,只允许获取必须的数据

2、报表系统中cache实际的命中率并不高,将Guava 的 Cache 引用级别改成弱引用(WeakKeys)

3、限制报表导入文件大小,同时拆分用户超大范围查询导出请求。

每一步操作都使得JVM使用变得更加可用,一系列优化以后,机器相同压测数据性能提升了数倍。

3、大屏异常 | JUC调优

有些数据需要使用 HttpClient 来获取进行补全。提供数据的服务提供商有的响应时间可能会很长,也有可能会造成服务整体的阻塞。

img

接口 A 通过 HttpClient 访问服务 2,响应 100ms 后返回;接口 B 访问服务 3,耗时 2 秒。HttpClient 本身是有一个最大连接数限制的,如果服务 3 迟迟不返回,就会造成 HttpClient 的连接数达到上限,概括来讲,就是同一服务,由于一个耗时非常长的接口,进而引起了整体的服务不可用

这个时候,通过 jstack 打印栈信息,会发现大多数竟然阻塞在了接口 A 上,而不是耗时更长的接口 B,这个现象起初十分具有迷惑性,不过经过分析后,我们猜想其实是因为接口 A 的速度比较快,在问题发生点进入了更多的请求,它们全部都阻塞住的同时被打印出来了。

为了验证这个问题,我搭建了一个demo 工程,模拟了两个使用同一个 HttpClient 的接口。fast 接口用来访问百度,很快就能返回;slow 接口访问谷歌,由于众所周知的原因,会阻塞直到超时,大约 10 s。 利用ab对两个接口进行压测,同时使用 jstack 工具 dump 堆栈。首先使用 jps 命令找到进程号,然后把结果重定向到文件(可以参考 10271.jstack 文件)。

过滤一下 nio 关键字,可以查看 tomcat 相关的线程,足足有 200 个,这和 Spring Boot 默认的 maxThreads 个数不谋而合。更要命的是,有大多数线程,都处于 BLOCKED 状态,说明线程等待资源超时。通过grep fast | wc -l 分析,确实200个中有150个都是blocked的fast的进程。

问题找到了,解决方式就顺利成章了。

1、fast和slow争抢连接资源,通过线程池限流或者熔断处理

2、有时候slow的线程也不是一直slow,所以就得加入监控

3、使用带countdownLaunch对线程的执行顺序逻辑进行控制

4、接口延迟 | SWAP调优

有一个关于服务的某个实例,经常发生服务卡顿。由于服务的并发量是比较高的,每多停顿 1 秒钟,几万用户的请求就会感到延迟。

我们统计、类比了此服务其他实例的 CPU、内存、网络、I/O 资源,区别并不是很大,所以一度怀疑是机器硬件的问题。

接下来我们对比了节点的 GC 日志,发现无论是 Minor GC,还是 Major GC,这个节点所花费的时间,都比其他实例长得多。

通过仔细观察,我们发现在 GC 发生的时候,vmstat 的 si、so 飙升的非常严重,这和其他实例有着明显的不同。

使用 free 命令再次确认,发现 SWAP 分区,使用的比例非常高,引起的具体原因是什么呢?

更详细的操作系统内存分布,从 /proc/meminfo 文件中可以看到具体的逻辑内存块大小,有多达 40 项的内存信息,这些信息都可以通过遍历 /proc 目录的一些文件获取。我们注意到 slabtop 命令显示的有一些异常,dentry(目录高速缓冲)占用非常高。

问题最终定位到是由于某个运维工程师删除日志时,定时执行了一句命令:

find / | grep “xxx.log”

他是想找一个叫做 要被删除 的日志文件,看看在哪台服务器上,结果,这些老服务器由于文件太多,扫描后这些文件信息都缓存到了 slab 区上。而服务器开了 swap,操作系统发现物理内存占满后,并没有立即释放 cache,导致每次 GC 都要和硬盘打一次交道。

解决方式就是关闭 SWAP 分区。

swap 是很多性能场景的万恶之源,建议禁用。在高并发 SWAP 绝对能让你体验到它魔鬼性的一面:进程倒是死不了了,但 GC 时间长的却让人无法忍受。

5、内存溢出 | Cache调优

有一次线上遇到故障,重新启动后,使用 jstat 命令,发现 Old 区一直在增长。我使用 jmap 命令,导出了一份线上堆栈,然后使用 MAT 进行分析,通过对 GC Roots 的分析,发现了一个非常大的 HashMap 对象,这个原本是其他同事做缓存用的,但是做了一个无界缓存,没有设置超时时间或者 LRU 策略,在使用上又没有重写key类对象的hashcode和equals方法,对象无法取出也直接造成了堆内存占用一直上升,后来,将这个缓存改成 guava 的 Cache,并设置了弱引用,故障就消失了。

关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

内存溢出是一个结果,而内存泄漏是一个原因。内存溢出的原因有内存空间不足、配置错误等因素。一些错误的编程方式,不再被使用的对象、没有被回收、没有及时切断与 GC Roots 的联系,这就是内存泄漏。

举个例子,有团队使用了 HashMap 做缓存,但是并没有设置超时时间或者 LRU 策略,造成了放入 Map 对象的数据越来越多,而产生了内存泄漏。

再来看一个经常发生的内存泄漏的例子,也是由于 HashMap 产生的。代码如下,由于没有重写 Key 类的 hashCode 和 equals 方法,造成了放入 HashMap 的所有对象都无法被取出来,它们和外界失联了。所以下面的代码结果是 null。

//leak example
import java.util.HashMap;
import java.util.Map;
public class HashMapLeakDemo {
    public static class Key {
        String title;
    public Key(String title) {
        this.title = title;
    }
}

public static void main(String[] args) {
    Map<Key, Integer> map = new HashMap<>();
    map.put(new Key("1"), 1);
    map.put(new Key("2"), 2);
    map.put(new Key("3"), 2);
    Integer integer = map.get(new Key("2"));
    System.out.println(integer);
    }
}

即使提供了 equals 方法和 hashCode 方法,也要非常小心,尽量避免使用自定义的对象作为 Key。

再看一个例子,关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

6:CPU飙高 | 死循环

我们有个线上应用,单节点在运行一段时间后,CPU 的使用会飙升,一旦飙升,一般怀疑某个业务逻辑的计算量太大,或者是触发了死循环(比如著名的 HashMap 高并发引起的死循环),但排查到最后其实是 GC 的问题。

(1)使用 top 命令,查找到使用 CPU 最多的某个进程,记录它的 pid。使用 Shift + P 快捷键可以按 CPU 的使用率进行排序。

top

(2)再次使用 top 命令,加 -H 参数,查看某个进程中使用 CPU 最多的某个线程,记录线程的 ID。

top -Hp $pid

(3)使用 printf 函数,将十进制的 tid 转化成十六进制。

printf %x $tid

(4)使用 jstack 命令,查看 Java 进程的线程栈。

jstack $pid >$pid.log

(5)使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid,找到发生问题的线程上下文。

less $pid.log

我们在 jstack 日志搜关键字DEAD,以及中找到了 CPU 使用最多的几个线程id。

可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM,于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了。

三、多线程篇

线程调度

1、线程状态

​ 线程是cpu任务调度的最小执行单位,每个线程拥有自己独立的程序计数器、虚拟机栈、本地方法栈

线程状态:创建、就绪、运行、阻塞、死亡

img

2、线程状态切换

方法 作用 区别
start 启动线程,由虚拟机自动调度执行run()方法 线程处于就绪状态
run 线程逻辑代码块处理,JVM调度执行 线程处于运行状态
sleep 让当前正在执行的线程休眠(暂停执行) 不释放锁
wait 使得当前线程等待 释放同步锁
notify 唤醒在此对象监视器上等待的单个线程 唤醒单个线程
notifyAll 唤醒在此对象监视器上等待的所有线程 唤醒多个线程
yiled 停止当前线程,让同等优先权的线程运行 用Thread类调用
join 使当前线程停下来等待,直至另一个调用join方法的线程终止 用线程对象调用

img

3、阻塞唤醒过程

阻塞:

​ 这三个方法的调用都会使当前线程阻塞。该线程将会被放置到对该Object的请求等待队列中,然后让出当前对Object所拥有的所有的同步请求。线程会一直暂停所有线程调度,直到下面其中一种情况发生:

    ① 其他线程调用了该Object的notify方法,而该线程刚好是那个被唤醒的线程;

    ② 其他线程调用了该Object的notifyAll方法;

唤醒:

​ 线程将会从等待队列中移除,重新成为可调度线程。它会与其他线程以常规的方式竞争对象同步请求。一旦它重新获得对象的同步请求,所有之前的请求状态都会恢复,也就是线程调用wait的地方的状态。线程将会在之前调用wait的地方继续运行下去。

为什么要出现在同步代码块中:

​ 由于wait()属于Object方法,调用之后会强制释放当前对象锁,所以在wait() 调用时必须拿到当前对象的监视器monitor对象。因此,wait()方法在同步方法/代码块中调用。

4、wait和sleep区别

  • wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。

  • wait 方法会主动释放 monitor 锁,在同步代码中执行 sleep 方法时,并不会释放 monitor 锁。

  • wait 方法意味着永久等待,直到被中断或被唤醒才能恢复,不会主动恢复,sleep 方法中会定义一个时间,时间到期后会主动恢复。

  • wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。

5、创建线程方式

实现 Runnable 接口(优先使用)

public class RunnableThread implements Runnable {
    @Override
    public void run() {System.out.println('用实现Runnable接口实现线程');}
}

实现Callable接口(有返回值可抛出异常)

class CallableTask implements Callable<Integer> {
    @Override
    public Integer call() throws Exception { return new Random().nextInt();}
}

继承Thread类(java不支持多继承)

public class ExtendsThread extends Thread {
    @Override
    public void run() {System.out.println('用Thread类实现线程');}
}

使用线程池(底层都是实现run方法)

static class DefaultThreadFactory implements ThreadFactory {
    DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
        namePrefix = "pool-" + poolNumber.getAndIncrement() +"-thread-";
    }
    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r,namePrefix + threadNumber.getAndIncrement(),0);
        if (t.isDaemon()) t.setDaemon(false);  //是否守护线程
        if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); //线程优先级
        return t;
    }
}

线程池

优点:通过复用已创建的线程,降低资源损耗、线程可以直接处理队列中的任务加快响应速度、同时便于统一监控和管理

1、线程池构造函数

/**
* 线程池构造函数7大参数
*/
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,
    TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,
    RejectedExecutionHandler handler) {}

参数介绍:

参数 作用
corePoolSize 核心线程池大小
maximumPoolSize 最大线程池大小
keepAliveTime 线程池中超过 corePoolSize 数目的空闲线程最大存活时间;
TimeUnit keepAliveTime 时间单位
workQueue 阻塞任务队列
threadFactory 新建线程工厂
RejectedExecutionHandler 拒绝策略。当提交任务数超过 maxmumPoolSize+workQueue 之和时,任务会交给RejectedExecutionHandler 来处理

2、线程处理任务过程:

img

  1. 当线程池小于corePoolSize,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程。
  2. 当线程池达到corePoolSize时,新提交任务将被放入 workQueue 中,等待线程池中任务调度执行。
  3. 当workQueue已满,且 maximumPoolSize 大于 corePoolSize 时,新提交任务会创建新线程执行任务。
  4. 当提交任务数超过 maximumPoolSize 时,新提交任务由 RejectedExecutionHandler 处理。
  5. 当线程池中超过corePoolSize 线程,空闲时间达到 keepAliveTime 时,关闭空闲线程 。

3、线程拒绝策略

​ 线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。

JDK 内置的拒绝策略如下:

AbortPolicy:直接抛出异常,阻止系统正常运行。可以根据业务逻辑选择重试或者放弃提交等策略。

CallerRunsPolicy :只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。

​ 不会造成任务丢失,同时减缓提交任务的速度,给执行任务缓冲时间。

DiscardOldestPolicy :丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。

DiscardPolicy :该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。

4、Execuors类实现线程池

img

  • newSingleThreadExecutor():只有一个线程的线程池,任务是顺序执行,适用于一个一个任务执行的场景
  • newCachedThreadPool():线程池里有很多线程需要同时执行,60s内复用,适用执行很多短期异步的小程序或者负载较轻的服务
  • newFixedThreadPool():拥有固定线程数的线程池,如果没有任务执行,那么线程会一直等待,适用执行长期的任务。
  • newScheduledThreadPool():用来调度即将执行的任务的线程池
  • newWorkStealingPool():底层采用forkjoin的Deque,采用独立的任务队列可以减少竞争同时加快任务处理
  • img

因为以上方式都存在弊端:

​ FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列⻓度为 Integer.MAX_VALUE,会导致OOM。
​ CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,会导致OOM。

手动创建的线程池底层使用的是ArrayBlockingQueue可以防止OOM。

5、线程池大小设置

  • CPU 密集型(n+1)

​ CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。

​ CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。

  • IO 密集型(2*n)

​ 由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2

​ 也可以使用公式:CPU 核心数 *(1+平均等待时间/平均工作时间)。

线程安全

1、乐观锁,CAS思想

java乐观锁机制:

​ 乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在Java中 java.util.concurrent.atomic下的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

  乐观锁,大多是基于数据版本 (Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来 实现。 读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

CAS思想:

​ CAS就是compare and swap(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用CAS线程是不会被阻塞的,所以又称为非阻塞同步。CAS算法涉及到三个操作:

​ 需要读写内存值V;进行比较的值A;准备写入的值B

​ 当且仅当V的值等于A的值等于V的值的时候,才用B的值去更新V的值,否则不会执行任何操作(比较和替换是一个原子操作-A和V比较,V和B替换),一般情况下是一个自旋操作,即不断重试

缺点:

ABA问题-知乎

​ 高并发的情况下,很容易发生并发冲突,如果CAS一直失败,那么就会一直重试,浪费CPU资源

原子性:

​ 功能限制CAS是能保证单个变量的操作是原子性的,在Java中要配合使用volatile关键字来保证线程的安全;当涉及到多个变量的时候CAS无能为力;除此之外CAS实现需要硬件层面的支持,在Java的普通用户中无法直接使用,只能借助atomic包下的原子类实现,灵活性受到了限制

2、synchronized底层实现

使用方法:主要的三种使⽤⽅式

修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁

修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员。

修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

总结:synchronized锁住的资源只有两类:一个是对象,一个是

底层实现:

​ 对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息

​ 锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

​ 同步代码块是利用 monitorenter 和 monitorexit 指令实现的,而同步方法则是利用 flags 实现的。

3、ReenTrantLock底层实现

​ 由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能

使用方法:

​ 基于API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

底层实现:

​ ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

和synchronized区别:

​ 1、底层实现:synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。

​ 2、实现原理**:synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁;ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

​ 3、是否可手动释放:synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象

​ 4、是否可中断synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

​ 5、是否公平锁synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁,公平锁性能非常低。

4、公平锁和非公平锁区别

公平锁:

​ 公平锁自然是遵循FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待

优点:所有的线程都能得到资源,不会饿死在队列中。适合大任务

缺点:吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销大

非公平锁:

​ 多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁

img

公平锁效率低原因:

​ 公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒

线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

5、使用层面锁优化

​ 【1】减少锁的时间:
​ 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

​ 【2】减少锁的粒度:
​ 它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;java中很多数据结构都是采用这种方法提高并发操作的效率,比如:

ConcurrentHashMap:

​ java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组:Segment< K,V >[] segments

​ Segment继承自ReenTrantLock,所以每个Segment是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

​ 【3】锁粗化:
​ 大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

​ 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

​ 【4】使用读写锁:

​ ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可并发读,写操作使用写锁,只能单线程写;

​ 【5】使用CAS:

​ 如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

6、系统层面锁优化

自适应自旋锁:

​ 自旋锁可以避免等待竞争锁进入阻塞挂起状态被唤醒造成的内核态和用户态之间的切换的损耗,它们只需要等一等(自旋),但是如果锁被其他线程长时间占用,一直不释放CPU,死等会带来更多的性能开销;自旋次数默认值是10

​ 对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点

锁消除:

​ 锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。Netty中无锁化设计pipeline中channelhandler会进行锁消除的优化。

锁升级:

偏向锁:

​ 如果线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些monitor操作,因为在大部分情况下是没有竞争的,所以使用偏向锁是可以提高性能的;

轻量级锁:

​ 在竞争不激烈的情况下,通过CAS避免线程上下文切换,可以显著的提高性能。

重量级锁:

​ 重量级锁的加锁、解锁过程造成的损耗是固定的,重量级锁适合于竞争激烈、高并发、同步块执行时间长的情况。

7、ThreadLocal原理

ThreadLocal简介:

​ 通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的
专属本地变量该如何解决呢? JDK中提供的 ThreadLocal 类正是为了解决这样的问题。类似操作系统中的TLAB

原理:

​ 首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。

​ 最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。

​ 我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 get()、set() 方法。例如下面的

如何使用:

​ 1)存储用户Session

private static final ThreadLocal threadSession = new ThreadLocal();

​ 2)解决线程安全的问题

private static ThreadLocal<SimpleDateFormat> format1 = new ThreadLocal<SimpleDateFormat>()

ThreadLocal内存泄漏的场景

​ 实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,⽽ value 是强引⽤。弱引用的特点是,如果这个对象持有弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

​ 所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。 假如我们不做任何措施的话,value 永远⽆法被GC 回收,如果线程长时间不被销毁,可能会产⽣内存泄露。

img

​ ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。如果说会出现内存泄漏,那只有在出现了 key 为 null 的记录后,没有手动调用 remove() 方法,并且之后也不再调用 get()、set()、remove() 方法的情况下。因此使⽤完ThreadLocal ⽅法后,最好⼿动调⽤ remove() ⽅法

8、HashMap线程安全

死循环造成 CPU 100%

​ HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。

​ 所以综上所述,HashMap 是线程不安全的,在多线程使用场景中推荐使用线程安全同时性能比较好的 ConcurrentHashMap。

9、String不可变原因

  1. 可以使用字符串常量池,多次创建同样的字符串会指向同一个内存地址

  2. 可以很方便地用作 HashMap 的 key。通常建议把不可变对象作为 HashMap的 key

  3. hashCode生成后就不会改变,使用时无需重新计算

  4. 线程安全,因为具备不变性的对象一定是线程安全的

内存模型

​ Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

img

​ JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。

原子性:

​ 在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。这两个字节码,在 Java 中对应的关键字就是 Synchronized。因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。

可见性:

​ Java 中的 Volatile 关键字修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同

有序性

​ 在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。区别:Volatile 禁止指令重排。Synchronized 保证同一时刻只允许一条线程操作。

1、volatile底层实现

作用:

​ 保证数据的“可见性”:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

​ 禁止指令重排:在多线程操作情况下,指令重排会导致计算结果不一致

底层实现:

​ “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

单例模式中volatile的作用:

防止代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

class Singleton{
    private volatile static Singleton instance = null;   //禁止指令重排
    private Singleton() {

    }
    public static Singleton getInstance() {
        if(instance==null) { //减少加锁的损耗
            synchronized (Singleton.class) {
                if(instance==null) //确认是否初始化完成
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

2、AQS思想

​ AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,如:基于AQS实现的lock, CountDownLatch、CyclicBarrier、Semaphore需解决的问题:

状态的原子性管理
线程的阻塞与解除阻塞
队列的管理

​ AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列)队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

lock:

​ 是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。默认为非公平锁,但可以初始化为公平锁; 通过方法 lock()与 unlock()来进行加锁与解锁操作;

CountDownLatch:

​ 通过计数法(倒计时器),让一些线程堵塞直到另一个线程完成一系列操作后才被唤醒;该⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。具体可以使用countDownLatch.await()来等待结果。多用于多线程信息汇总。

CompletableFuture:

​ 通过设置参数,可以完成CountDownLatch同样的多平台响应问题,但是可以针对其中部分返回结果做更加灵活的展示。

CyclicBarrier:

​ 字面意思是可循环(Cyclic)使用的屏障(Barrier)。他要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。可以用于批量发送消息队列信息、异步限流。

Semaphore:

​ 信号量主要用于两个目的,一个是用于多个共享资源的互斥作用,另一个用于并发线程数的控制。SpringHystrix限流的思想

3、happens-before

​ 用来描述和可见性相关问题:如果第一个操作 happens-before 第二个操作,那么我们就说第一个操作对于第二个操作是可见的

​ 常见的happens-before:volatile 、锁、线程生命周期。

四、MySQL篇

WhyMysql?

NoSQL数据库四大家族

  • 列存储 Hbase
  • K-V存储 Redis
  • 图像存储 Neo4j
  • 文档存储 MongoDB

云存储OSS

海量Aerospike

​ Aerospike(简称AS)是一个分布式,可扩展的键值存储的NoSQL数据库。T级别大数据高并发的结构化数据存储,采用混合架构,索引存储在内存中,而数据可存储在机械硬盘(HDD)或固态硬盘(SSD) 上,读写操作达微妙级,99%的响应可在1毫秒内实现。

Aerospike Redis
类型 Nosql数据库 缓存
线程数 多线程 单线程
数据分片 自动处理相当于分片 提供分片算法、平衡各分片数据
数据扩容 动态增加数据卷平衡流量 需停机
数据同步 设置复制因子后可以透明的完成故障转移 手动故障转移和数据同步
载体 内存存储索引+SSD存储数据 内存

​ Aerospike作为一个大容量的NoSql解决方案,适合对容量要求比较大,QPS相对低一些的场景,主要用在广告行业,个性化推荐厂告是建立在了和掌握消费者独特的偏好和习性的基础之上,对消费者的购买需求做出准确的预测或引导,在合适的位置、合适的时间,以合适的形式向消费者呈现与其需求高度吻合的广告,以此来促进用户的消费行为。

image-20210103170039711

​ (ETL数据仓库技术)抽取(extract)、转换(transform)、加载(load)

  • 用户行为日志收集系统收集日志之后推送到ETL做数据的清洗和转换

  • 把ETL过后的数据发送到推荐引擎计算每个消费者的推荐结果,其中推荐逻辑包括规则和算法两部分

  • 收集用户最近浏览、最长停留等特征,分析商品相似性、用户相似性、相似性等算法。

  • 把推荐引擎的结果存入Aerospike集群中,并提供给广告投放引擎实时获取

    分别通过HDFS和HBASE对日志进行离线和实时的分析,然后把用户画像的标签(tag : 程序猿、宅男…)结果存入高性能的Nosql数据库Aerospike中,同时把数据备份到异地数据中心。前端广告投放请求通过决策引擎(投放引擎)向用户画像数据库中读取相应的用户画像数据,然后根据竞价算法出价进行竞价。竞价成功之后就可以展现广告了。而在竞价成功之后,具体给用户展现什么样的广告,就是有上面说的个性化推荐广告来完成的。

Aerospike Mysql
库名 Namespace Database
表名 Set Table
记录 Bin Column
字段 Record Row
索引 key 、 pk 、kv pk

图谱Neo4j

Neo4j是一个开源基于java开发的图形noSql数据库,它将结构化数据存储在图中而不是表中。它是一个嵌入式的、基于磁盘的、具备完全的事务特性的Java持久化引擎。程序数据是在一个面向对象的、灵活的网络结构下,而不是严格的表中,但具备完全的事务特性、企业级的数据库的所有好处。

一种基于图的数据结构,由节点(Node)和边(Edge)组成。其中节点即实体,由一个全局唯一的ID标示,边就是关系用于连接两个节点。通俗地讲,知识图谱就是把所有不同种类的信息,连接在一起而得到的一个关系网络。知识图谱提供了从“关系”的角度去分析问题的能力。

互联网、大数据的背景下,谷歌、百度、搜狗等搜索引擎纷纷基于该背景,创建自己的知识图Knowledge Graph(谷歌)、知心(百度)知立方(搜狗),主要用于改进搜索质量。

自己项目主要用作好友推荐,图数据库(Graph database)指的是以图数据结构的形式来存储和查询数据的数据库。关系图谱中,关系的组织形式采用的就是图结构,所以非常适合用图库进行存储。

  • image-20210103191540372

    优势总结:

  • 性能上,使用cql查询,对长程关系的查询速度快

  • 擅于发现隐藏的关系,例如通过判断图上两点之间有没有走的通的路径,就可以发现事物间的关联

image-20210103192653004

// 查询三层级关系节点如下:with可以将前面查询结果作为后面查询条件
match (na:Person)-[re]-(nb:Person) where na.name="林婉儿" WITH na,re,nb match (nb:Person)- [re2:Friends]->(nc:Person) return na,re,nb,re2,nc
// 直接拼接关系节点查询
match data=(na:Person{name:"范闲"})-[re]->(nb:Person)-[re2]->(nc:Person) return data
// 使用深度运算符
显然使用以上方式比较繁琐,可变数量的关系->节点可以使用-[:TYPE*minHops..maxHops]-。
match data=(na:Person{name:"范闲"})-[*1..2]-(nb:Person) return data

文档MongoDB

MongoDB 是一个基于分布式文件存储的数据库,是非关系数据库中功能最丰富、最像关系数据库的。在高负载的情况下,通过添加更多的节点,可以保证服务器性能。由 C++ 编写,可以为 WEB 应用提供可扩展、高性能、易部署的数据存储解决方案。

image-20210103194830654

什么是BSON

{key:value,key2:value2}和Json类似,是一种二进制形式的存储格式,支持内嵌的文档对象和数组对象,但是BSON有JSON没有的一些数据类型,比如 value包括字符串,double,Array,DateBSON可以做为网络数据交换的一种存储形式,它的优点是灵活性高,但它的缺点是空间利用率不是很理想。

BSON有三个特点:轻量性、可遍历性、高效性

/* 查询 find() 方法可以传入多个键(key),每个键(key)以逗号隔开*/
db.collection.find({key1:value1, key2:value2}).pretty()
/* 更新 $set :设置字段值 $unset :删除指定字段 $inc:对修改的值进行自增*/
db.collection.update({where},{$set:{字段名:值}},{multi:true})
/* 删除 justOne :如果设为true,只删除一个文档,默认false,删除所有匹配条件的文档*/
db.collection.remove({where}, {justOne: , writeConcern: <回执> } )

优点:

  • 文档结构的存储方式,能够更便捷的获取数据。

    对于一个层级式的数据结构来说,使用扁平式的,表状的结构来查询保存数据非常的困难。

  • 内置GridFS,支持大容量的存储。

    GridFS是一个出色的分布式文件系统,支持海量的数据存储,满足对大数据集的快速范围查询。

  • 性能优越

    千万级别的文档对象,近10G的数据,对有索引的ID的查询 不会比mysql慢,而对非索引字段的查询,则是全面胜出。 mysql实际无法胜任大数据量下任意字段的查询,而mongodb的查询性能实在牛逼。写入性能同样很令人满意,同样写入百万级别的数据,mongodb基本10分钟以下可以解决。

缺点:

  • 不支持事务
  • 磁盘占用空间大

MySQL 8.0 版本

1. 性能:MySQL 8.0 的速度要比 MySQL 5.7 快 2 倍。

2. NoSQL:MySQL 从 5.7 版本开始提供 NoSQL 存储功能,在 8.0 版本中nosql得到了更大的改进。

3. 窗口函数:实现若干新的查询方式。窗口函数与 SUM()、COUNT() 这种集合函数类似,但它不会将多行查询结果合并为一行,而是将结果放回多行当中,即窗口函数不需要 GROUP BY。

4. 隐藏索引:在 MySQL 8.0 中,索引可以被“隐藏”和“显示”。当对索引进行隐藏时,它不会被查询优化器所使用。我们可以使用这个特性用于性能调试,例如我们先隐藏一个索引,然后观察其对数据库的影响。如果数据库性能有所下降,说明这个索引是有用的,然后将其“恢复显示”即可;如果数据库性能看不出变化,说明这个索引是多余的,可以考虑删掉。

云存储

OSS 自建
可靠性 可用性不低于99.995%
数据设计持久性不低于99.9999999999%(12个9)
受限于硬件可靠性,易出问题,一旦出现磁盘坏道,容易出现不可逆转的数据丢失。人工数据恢复困难、耗时、耗力。
安全 服务端加密、客户端加密、防盗链、IP黑白名单等。多用户资源隔离机制,支持异地容灾机制。 需要另外购买清洗和黑洞设备。需要单独实现安全机制。
成本 多线BGP骨干网络,无带宽限制,上行流量免费。无需运维人员与托管费用,0成本运维。 单线或双线接入速度慢,有带宽限制,峰值时期需人工扩容。需专人运维,成本高。

使用步骤

​ 1、开通服务

​ 2、创建存储空间

​ 3、上传文件、下载文件、删除文件

​ 4、域名绑定、日志记录

​ 5、根据开放接口进行鉴权访问

功能

​ 图片编辑(裁剪、模糊、水印)

​ 视频截图

​ 音频转码、视频修复

CDN加速

​ 对象存储OSS与阿里云CDN服务结合,可优化静态热点文件下载加速的场景(即同一地区大量用户同时下载同一个静态文件的场景)。可以将OSS的存储空间(Bucket)作为源站,利用阿里云CDN将源内容发布到边缘节点。当大量终端用户重复访问同一文件时,可以直接从边缘节点获取已缓存的数据,提高访问的响应速度

FastDFS

开源的轻量级分布式文件系统。它对文件进行管理,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,解决了大容量存储和负载均衡的问题。使用FastDFS很容易搭建一套高性能的文件服务器集群提供文件上传、下载等服务。如相册网站、视频网站

扩展能力: 支持水平扩展,可以动态扩容;

高可用性: 一是整个文件系统的可用性,二是数据的完整和一致性;

弹性存储: 可以根据业务需要灵活地增删存储池中的资源,而不需要中断系统运行。

image-20210107221022658

特性

  • 和流行的web server无缝衔接,FastDFS已提供apache和nginx扩展模块
  • 文件ID由FastDFS生成,作为文件访问凭证,FastDFS不需要传统的name server
  • 分组存储,灵活简洁、对等结构,不存在单点
  • 文件不分块存储,上传的文件和OS文件系统中的文件一一对应
  • 中、小文件均可以很好支持,支持海量小文件存储
  • 支持相同内容的文件只保存一份,节约磁盘空间
  • 支持多块磁盘,支持单盘数据恢复
  • 支持在线扩容 支持主从文件
  • 下载文件支持多线程方式,支持断点续传

组成

  • 客户端(client)

    通过专有接口,使用TCP/IP协议与跟踪器服务器或存储节点进行数据交互。

  • 跟踪器(tracker)

    Trackerserver作用是负载均衡和调度,通过Tracker server在文件上传时可以根据策略找到文件上传的地址。Tracker在访问上起负载均衡的作用。

  • 存储节点(storage)

    Storageserver作用是文件存储,客户端上传的文件最终存储在Storage服务器上,Storage server没有实现自己的文件系统而是利用操作系统的文件系统来管理文件。存储节点中的服务器均可以随时增加或下线而不会影响线上服务

上传

image-20210107222155291

下载

image-20210107222312338

断点续传

​ 续传涉及到的文件大小MD5不会改变。续传流程与文件上传类似,先定位到源storage,完成完整或部分上传,再通过binlog进行同group内server文件同步

配置优化

配置文件:tracker.conf 和 storage.conf

// FastDFS采用内存池的做法。 
// v5.04对预分配采用增量方式,tracker一次预分配 1024个,storage一次预分配256个。 
max_connections = 10240
// 根据实际需要将 max_connections 设置为一个较大的数值,比如 10240 甚至更大。
// 同时需要将一个进程允许打开的最大文件数调大
vi /etc/security/limits.conf 重启系统生效 
* soft nofile 65535 
* hard nofile 65535
work_threads = 4 
// 说明:为了避免CPU上下文切换的开销,以及不必要的资源消耗,不建议将本参数设置得过大。
// 公式为: work_threads + (reader_threads + writer_threads) = CPU数
// 对于单盘挂载方式,磁盘读写线程分 别设置为 1即可 
// 如果磁盘做了RAID,那么需要酌情加大读写线程数,这样才能最大程度地发挥磁盘性能
disk_rw_separated:磁盘读写是否分离 
disk_reader_threads:单个磁盘读线程数 
disk_writer_threads:单个磁盘写线程数 

避免重复

​ 如何避免文件重复上传 解决方案 上传成功后计算文件对应的MD5然后存入MySQL,添加文件时把文件MD5和之前存入MYSQL中的存储的信息对比 。DigestUtils.md5DigestAsHex(bytes)。

事务

1、事务4大特性

事务4大特性:原子性、一致性、隔离性、持久性

原⼦性: 事务是最⼩的执⾏单位,不允许分割。事务的原⼦性确保动作要么全部完成,要么全不执行

一致性: 执⾏事务前后,数据保持⼀致,多个事务对同⼀个数据读取的结果是相同的;

隔离性: 并发访问数据库时,⼀个⽤户的事务不被其他事务所⼲扰,各并发事务之间数据库是独⽴的;

持久性: ⼀个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发⽣故障也不应该对其有任何影响。

实现保证:

​ MySQL的存储引擎InnoDB使用重做日志保证一致性与持久性,回滚日志保证原子性,使用各种锁来保证隔离性。

2、事务隔离级别

读未提交:最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。

读已提交:允许读取并发事务已经提交的数据,可以阻⽌脏读,但是幻读或不可重复读仍有可能发⽣。

可重复读:同⼀字段的多次读取结果都是⼀致的,除⾮数据是被本身事务⾃⼰所修改,可以阻⽌脏读和不可重复读,会有幻读。

串行化:最⾼的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执⾏,这样事务之间就完全不可能产⽣⼲扰。

隔离级别 并发问题
读未提交 可能会导致脏读、幻读或不可重复读
读已提交 可能会导致幻读或不可重复读
可重复读 可能会导致幻读
可串行化 不会产⽣⼲扰

ms

3、默认隔离级别-RR

默认隔离级别:可重复读;

​ 同⼀字段的多次读取结果都是⼀致的,除⾮数据是被本身事务⾃⼰所修改;

​ 可重复读是有可能出现幻读的,如果要保证绝对的安全只能把隔离级别设置成SERIALIZABLE;这样所有事务都只能顺序执行,自然不会因为并发有什么影响了,但是性能会下降许多。

​ 第二种方式,使用MVCC解决快照读幻读问题(如简单select),读取的不是最新的数据。维护一个字段作为version,这样可以控制到每次只能有一个人更新一个版本。

select id from table_xx where id = ? and version = V
update id from table_xx where id = ? and version = V+1

​ 第三种方式,如果需要读最新的数据,可以通过GapLock+Next-KeyLock可以解决当前读幻读问题

select id from table_xx where id > 100 for update;
select id from table_xx where id > 100 lock in share mode;

4、RR和RC使用场景

​ 事务隔离级别RC(read commit)和RR(repeatable read)两种事务隔离级别基于多版本并发控制MVCC(multi-version concurrency control)来实现。

RC RR
实现 多条查询语句会创建多个不同的ReadView 仅需要一个版本的ReadView
粒度 语句级读一致性 事务级读一致性
准确性 每次语句执行时间点的数据 第一条语句执行时间点的数据

5、行锁,表锁,意向锁

InnoDB⽀持⾏级锁(row-level locking)和表级锁,默认为⾏级锁

​ InnoDB按照不同的分类的锁:

​ 共享/排它锁(Shared and Exclusive Locks):行级别锁,

​ 意向锁(Intention Locks),表级别锁

​ 间隙锁(Gap Locks),锁定一个区间

​ 记录锁(Record Locks),锁定一个行记录

表级锁:(串行化)

​ Mysql中锁定 粒度最大的一种锁,对当前操作的整张表加锁,实现简单 ,资源消耗也比较少,加锁快,不会出现死锁 。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。

行级锁:(RR、RC)

​ Mysql中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。 InnoDB支持的行级锁,包括如下几种:

记录锁(Record Lock): 对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;

间隙锁(Gap Lock): 对索引项之间的“间隙”加锁,锁定记录的范围,不包含索引项本身,其他事务不能在锁范围内插入数据。

Next-key Lock: 锁定索引项本身和索引范围。即Record Lock和Gap Lock的结合。可解决幻读问题。

InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁。

共享锁( shared lock, S )锁允许持有锁读取行的事务。加锁时将自己和子节点全加S锁,父节点直到表头全加IS锁

排他锁( exclusive lock, X )锁允许持有锁修改行的事务。 加锁时将自己和子节点全加X锁,父节点直到表头全加IX锁

意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)

意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)

互斥性 共享锁(S) 排它锁(X) 意向共享锁IS 意向排他锁IX
共享锁(S)
排它锁(X)
意向共享锁IS
意向排他锁IX

6、MVCC多版本并发控制

​ MVCC是一种多版本并发控制机制,通过事务的可见性看到自己预期的数据,能降低其系统开销.(RC和RR级别工作)

​ InnoDB的MVCC,是通过在每行记录后面保存系统版本号(可以理解为事务的ID),每开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会作为事务的ID。这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的,防止幻读的产生。

​ 1.MVCC手段只适用于Msyql隔离级别中的读已提交(Read committed)和可重复读(Repeatable Read).

​ 2.Read uncimmitted由于存在脏读,即能读到未提交事务的数据行,所以不适用MVCC.

​ 3.简单的select快照度不会加锁,删改及select for update等需要当前读的场景会加锁

​ 原因是MVCC的创建版本和删除版本只要在事务提交后才会产生。客观上,mysql使用的是乐观锁的一整实现方式,就是每行都有版本号,保存时根据版本号决定是否成功。Innodb的MVCC使用到的快照存储在Undo日志中,该日志通过回滚指针把一个数据行所有快照连接起来。

版本链

在InnoDB引擎表中,它的聚簇索引记录中有两个必要的隐藏列:

trx_id

这个id用来存储的每次对某条聚簇索引记录进行修改的时候的事务id。

roll_pointer

每次对哪条聚簇索引记录有修改的时候,都会把老版本写入undo日志中。这个roll_pointer就是存了一个指针,它指向这条聚簇索引记录的上一个版本的位置,通过它来获得上一个版本的记录信息。(注意插入操作的undo日志没有这个属性,因为它没有老版本)

每次修改都会在版本链中记录。SELECT可以去版本链中拿记录,这就实现了读-写,写-读的并发执行,提升了系统的性能。

索引

1、Innodb和Myisam引擎

Myisam:支持表锁,适合读密集的场景,不支持外键,不支持事务,索引与数据在不同的文件

Innodb:支持行、表锁,默认为行锁,适合并发场景,支持外键,支持事务,索引与数据同一文件

2、哈希索引

​ 哈希索引用索引列的值计算该值的hashCode,然后在hashCode相应的位置存执该值所在行数据的物理位置,因为使用散列算法,因此访问速度非常快,但是一个值只能对应一个hashCode,而且是散列的分布方式,因此哈希索引不支持范围查找和排序的功能

3、B+树索引

优点:

​ B+树的磁盘读写代价低,更少的查询次数,查询效率更加稳定,有利于对数据库的扫描

​ B+树是B树的升级版,B+树只有叶节点存放数据,其余节点用来索引。索引节点可以全部加入内存,增加查询效率,叶子节点可以做双向链表,从而提高范围查找的效率,增加的索引的范围

​ 在大规模数据存储的时候,红黑树往往出现由于树的深度过大而造成磁盘IO读写过于频繁,进而导致效率低下的情况。所以,只要我们通过某种较好的树结构减少树的结构尽量减少树的高度,B树与B+树可以有多个子女,从几十到上千,可以降低树的高度。

磁盘预读原理:将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。

4、创建索引

CREATE  [UNIQUE | FULLTEXT]  INDEX  索引名 ON  表名(字段名) [USING 索引方法];

说明:
UNIQUE:可选。表示索引为唯一性索引。
FULLTEXT:可选。表示索引为全文索引。
INDEXKEY:用于指定字段为索引,两者选择其中之一就可以了,作用是一样的。
索引名:可选。给创建的索引取一个新名称。
字段名1:指定索引对应的字段的名称,该字段必须是前面定义好的字段。
注:索引方法默认使用B+TREE。

5、聚簇索引和非聚簇索引

聚簇索引:将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据(主键索引

非聚簇索引:将数据与索引分开存储,索引结构的叶子节点指向了数据对应的位置(辅助索引

​ 聚簇索引的叶子节点就是数据节点,而非聚簇索引的叶子节点仍然是索引节点,只不过有指向对应数据块的指针。

6、最左前缀问题

​ 最左前缀原则主要使用在联合索引中,联合索引的B+Tree是按照第一个关键字进行索引排列的。

​ 联合索引的底层是一颗B+树,只不过联合索引的B+树节点中存储的是键值。由于构建一棵B+树只能根据一个值来确定索引关系,所以数据库依赖联合索引最左的字段来构建。

​ 采用>、<等进行匹配都会导致后面的列无法走索引,因为通过以上方式匹配到的数据是不可知的。

SQL查询

1、SQL语句的执行过程

查询语句:

select * from student  A where A.age='18' and A.name='张三';

img

结合上面的说明,我们分析下这个语句的执行流程:

①通过客户端/服务器通信协议与 MySQL 建立连接。并查询是否有权限

②Mysql8.0之前开看是否开启缓存,开启了 Query Cache 且命中完全相同的 SQL 语句,则将查询结果直接返回给客户端;

③由解析器进行语法语义解析,并生成解析树。如查询是select、表名tb_student、条件是id=’1’

④查询优化器生成执行计划。根据索引看看是否可以优化

⑤查询执行引擎执行 SQL 语句,根据存储引擎类型,得到查询结果。若开启了 Query Cache,则缓存,否则直接返回。

2、回表查询和覆盖索引

普通索引(唯一索引+联合索引+全文索引)需要扫描两遍索引树

(1)先通过普通索引定位到主键值id=5;

(2)在通过聚集索引定位到行记录;

这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。

覆盖索引:主键索引==聚簇索引==覆盖索引

​ 如果where条件的列和返回的数据在一个索引中,那么不需要回查表,那么就叫覆盖索引。

实现覆盖索引:常见的方法是,将被查询的字段,建立到联合索引里去。

3、Explain及优化

参考:https://www.jianshu.com/p/8fab76bbf448

mysql> explain select * from staff;
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
|  1 | SIMPLE      | staff | ALL  | NULL          | 索引  | NULL    | NULL |    2 | NULL  |
+----+-------------+-------+------+---------------+------+---------+------+------+-------+
1 row in set

索引优化:

​ ①最左前缀索引:like只用于’string%’,语句中的=和in会动态调整顺序

​ ②唯一索引:唯一键区分度在0.1以上

​ ③无法使用索引:!= 、is null 、 or、>< 、(5.7以后根据数量自动判定)in 、not in

​ ④联合索引:避免select * ,查询列使用覆盖索引

SELECT uid From user Where gid = 2 order by ctime asc limit 10
ALTER TABLE user add index idx_gid_ctime_uid(gid,ctime,uid) #创建联合覆盖索引,避免回表查询

语句优化:

​ ①char固定长度查询效率高,varchar第一个字节记录数据长度

​ ②应该针对Explain中Rows增加索引

​ ③group/order by字段均会涉及索引

​ ④Limit中分页查询会随着start值增大而变缓慢,通过子查询+表连接解决

select * from mytbl order by id limit 100000,10  改进后的SQL语句如下:
select * from mytbl where id >= ( select id from mytbl order by id limit 100000,1 ) limit 10
select * from mytbl inner ori join (select id from mytbl order by id limit 100000,10) as tmp on tmp.id=ori.id;

​ ⑤count会进行全表扫描,如果估算可以使用explain

​ ⑥delete删除表时会增加大量undo和redo日志, 确定删除可使用trancate

表结构优化:

​ ①单库不超过200张表

​ ②单表不超过500w数据

​ ③单表不超过40列

​ ④单表索引不超过5个

数据库范式

​ ①第一范式(1NF)列不可分割

​ ②第二范式(2NF)属性完全依赖于主键 [ 消除部分子函数依赖 ]

​ ③第三范式(3NF)属性不依赖于其它非主属性 [ 消除传递依赖 ]

配置优化:

​ 配置连接数、禁用Swap、增加内存、升级SSD硬盘

4、JOIN查询

left join(左联接) 返回包括左表中的所有记录和右表中关联字段相等的记录

right join(右联接) 返回包括右表中的所有记录和左表中关联字段相等的记录

inner join(等值连接) 只返回两个表中关联字段相等的行

集群

1、主从复制过程

MySQl主从复制:

  • 原理:将主服务器的binlog日志复制到从服务器上执行一遍,达到主从数据的一致状态。
  • 过程:从库开启一个I/O线程,向主库请求Binlog日志。主节点开启一个binlog dump线程,检查自己的二进制日志,并发送给从节点;从库将接收到的数据保存到中继日志(Relay log)中,另外开启一个SQL线程,把Relay中的操作在自身机器上执行一遍
  • 优点
    • 作为备用数据库,并且不影响业务
    • 可做读写分离,一个写库,一个或多个读库,在不同的服务器上,充分发挥服务器和数据库的性能,但要保证数据的一致性

binlog记录格式:statement、row、mixed

​ 基于语句statement的复制、基于行row的复制、基于语句和行(mix)的复制。其中基于row的复制方式更能保证主从库数据的一致性,但日志量较大,在设置时考虑磁盘的空间问题

2、数据一致性问题

“主从复制有延时”,这个延时期间读取从库,可能读到不一致的数据。

缓存记录写key法:

​ 在cache里记录哪些记录发生过的写请求,来路由读主库还是读从库

异步复制:

​ 在异步复制中,主库执行完操作后,写入binlog日志后,就返回客户端,这一动作就结束了,并不会验证从库有没有收到,完不完整,所以这样可能会造成数据的不一致

半同步复制:

​ 当主库每提交一个事务后,不会立即返回,而是等待其中一个从库接收到Binlog并成功写入Relay-log中才返回客户端,通过一份在主库的Binlog,另一份在其中一个从库的Relay-log,可以保证了数据的安全性和一致性。

全同步复制:

​ 指当主库执行完一个事务,所有的从库都执行了该事务才返回给客户端。因为需要等待所有从库执行完该事务才能返回,所以全同步复制的性能必然会收到严重的影响

3、集群架构

Keepalived + VIP + MySQL 主从/双主

​ 当写节点 Master db1 出现故障时,由 MMM Monitor 或 Keepalived 触发切换脚本,将 VIP 漂移到可用的 Master db2 上。当出现网络抖动或网络分区时,MMM Monitor 会误判,严重时来回切换写 VIP 导致集群双写,当数据复制延迟时,应用程序会出现数据错乱或数据冲突的故障。有效避免单点失效的架构就是采用共享存储,单点故障切换可以通过分布式哨兵系统监控。

img

架构选型:MMM 集群 -> MHA集群 -> MHA+Arksentinel。

img

4、故障转移和恢复

转移方式及恢复方法

1. 虚拟IP或DNS服务 (Keepalived +VIP/DNS  和 MMM 架构)

​ 问题:在虚拟 IP 运维过程中,刷新ARP过程中有时会出现一个 VIP 绑定在多台服务器同时提供连接的问题。这也是为什么要避免使用 Keepalived+VIP 和 MMM 架构的原因之一,因为它处理不了这类问题而导致集群多点写入。

2. 提升备库为主库(MHA、QMHA)

​ 尝试将原 Master 设置 read_only 为 on,避免集群多点写入。借助 binlog server 保留 Master 的 Binlog;当出现数据延迟时,再提升 Slave 为新 Master 之前需要进行数据补齐,否则会丢失数据。

面试题

分库分表

如何进行分库分表

分表用户id进行分表,每个表控制在300万数据。

分库根据业务场景和地域分库,每个库并发不超过2000

Sharding-jdbc 这种 client 层方案的优点在于不用部署,运维成本低,不需要代理层的二次转发请求,性能很高,但是各个系统都需要耦合 Sharding-jdbc 的依赖,升级比较麻烦

Mycat 这种 proxy 层方案的缺点在于需要部署,自己运维一套中间件,运维成本高,但是好处在于对于各个项目是透明的,如果遇到升级之类的都是自己中间件那里搞就行了

水平拆分:一个表放到多个库,分担高并发,加快查询速度

  • id保证业务在关联多张表时可以在同一库上操作
  • range方便扩容和数据统计
  • hash可以使得数据更加平均

垂直拆分:一个表拆成多个表,可以将一些冷数据拆分到冗余库中

不是写瓶颈优先进行分表

  • 分库数据间的数据无法再通过数据库直接查询了。会产生深分页的问题

  • 分库越多,出现问题的可能性越大,维护成本也变得更高。

  • 分库后无法保障跨库间事务,只能借助其他中间件实现最终一致性。

分库首先需考虑满足业务最核心的场景:

1、订单数据按用户分库,可以提升用户的全流程体验

2、超级客户导致数据倾斜可以使用最细粒度唯一标识进行hash拆分

3、按照最细粒度如订单号拆分以后,数据库就无法进行单库排重了

三个问题:

  • 富查询:采用分库分表之后,如何满足跨越分库的查询?使用ES的宽表

    借助分库网关+分库业务虽然能够实现多维度查询的能力,但整体上性能不佳且对正常的写入请求有一定的影响。业界应对多维度实时查询的最常见方式便是借助 ElasticSearch

  • 数据倾斜:数据分库基础上再进行分表

  • 分布式事务:跨多库的修改及多个微服务间的写操作导致的分布式事务问题?

  • 深分页问题:按游标查询,或者叫每次查询都带上上一次查询经过排序后的最大 ID

如何将老数据进行迁移

双写不中断迁移

  • 线上系统里所有写库的地方,增删改操作,除了对老库增删改,都加上对新库的增删改
  • 系统部署以后,还需要跑程序读老库数据写新库,写的时候需要判断updateTime
  • 循环执行,直至两个库的数据完全一致,最后重新部署分库分表的代码就行了

系统性能的评估及扩容

和家亲目前有1亿用户:场景 10万写并发,100万读并发,60亿数据量

设计时考虑极限情况,32库*32表~64个表,一共1000 ~ 2000张表

  • 支持3万的写并发,配合MQ实现每秒10万的写入速度
  • 读写分离6万读并发,配合分布式缓存每秒100读并发
  • 2000张表每张300万,可以最多写入60亿的数据

  • 32张用户表,支撑亿级用户,后续最多也就扩容一次

动态扩容的步骤

  1. 推荐是 32 库 * 32 表,对于我们公司来说,可能几年都够了。
  2. 配置路由的规则,uid % 32 = 库,uid / 32 % 32 = 表
  3. 扩容的时候,申请增加更多的数据库服务器,呈倍数扩容
  4. 由 DBA 负责将原先数据库服务器的库,迁移到新的数据库服务器上去
  5. 修改一下配置,重新发布系统,上线,原先的路由规则变都不用变
  6. 直接可以基于 n 倍的数据库服务器的资源,继续进行线上系统的提供服务。

如何生成自增的id主键

  • 使用redis可以
  • 并发不高可以单独起一个服务,生成自增id
  • 设置数据库step自增步长可以支撑水平伸缩
  • UUID适合文件名、编号,但是不适合做主键
  • snowflake雪花算法,综合了41时间(ms)、10机器12序列号(ms内自增)

其中机器预留的10bit可以根据自己的业务场景配置

线上故障及优化

更新失败 | 主从同步延时

以前线上确实处理过因为主从同步延时问题而导致的线上的 bug,属于小型的生产事故。

是这个么场景。有个同学是这样写代码逻辑的。先插入一条数据,再把它查出来,然后更新这条数据。在生产环境高峰期,写并发达到了 2000/s,这个时候,主从复制延时大概是在小几十毫秒。线上会发现,每天总有那么一些数据,我们期望更新一些重要的数据状态,但在高峰期时候却没更新。用户跟客服反馈,而客服就会反馈给我们。

我们通过 MySQL 命令:

show slave status

查看 Seconds_Behind_Master ,可以看到从库复制主库的数据落后了几 ms。

一般来说,如果主从延迟较为严重,有以下解决方案:

  • 分库,拆分为多个主库,每个主库的写并发就减少了几倍,主从延迟可以忽略不计。
  • 重写代码,写代码的同学,要慎重,插入数据时立马查询可能查不到。
  • 如果确实是存在必须先插入,立马要求就查询到,然后立马就要反过来执行一些操作,对这个查询设置直连主库或者延迟查询。主从复制延迟一般不会超过50ms

应用崩溃 | 分库分表优化

​ 我们有一个线上通行记录的表,由于数据量过大,进行了分库分表,当时分库分表初期经常产生一些问题。典型的就是通行记录查询中使用了深分页,通过一些工具如MAT、Jstack追踪到是由于sharding-jdbc内部引用造成的。

​ 通行记录数据被存放在两个库中。如果没有提供切分键,查询语句就会被分发到所有的数据库中,比如查询语句是 limit 10、offset 1000,最终结果只需要返回 10 条记录,但是数据库中间件要完成这种计算,则需要 (1000+10)*2=2020 条记录来完成这个计算过程。如果 offset 的值过大,使用的内存就会暴涨。虽然 sharding-jdbc 使用归并算法进行了一些优化,但在实际场景中,深分页仍然引起了内存和性能问题。

​ 这种在中间节点进行归并聚合的操作,在分布式框架中非常常见。比如在 ElasticSearch 中,就存在相似的数据获取逻辑,不加限制的深分页,同样会造成 ES 的内存问题。

业界解决方案:

方法一:全局视野法

(1)将order by time offset X limit Y,改写成order by time offset 0 limit X+Y

(2)服务层对得到的N*(X+Y)条数据进行内存排序,内存排序后再取偏移量X后的Y条记录

这种方法随着翻页的进行,性能越来越低。

方法二:业务折衷法-禁止跳页查询

(1)用正常的方法取得第一页数据,并得到第一页记录的time_max

(2)每次翻页,将order by time offset X limit Y,改写成order by time where time>$time_max limit Y

以保证每次只返回一页数据,性能为常量。

方法三:业务折衷法-允许模糊数据

(1)将order by time offset X limit Y,改写成order by time offset X/N limit Y/N

方法四:二次查询法

(1)将order by time offset X limit Y,改写成order by time offset X/N limit Y

(2)找到最小值time_min

(3)between二次查询,order by time between $time_min and $time_i_max

(4)设置虚拟time_min,找到time_min在各个分库的offset,从而得到time_min在全局的offset

(5)得到了time_min在全局的offset,自然得到了全局的offset X limit Y

查询异常 | SQL 调优

分库分表前,有一段用用户名来查询某个用户的 SQL 语句:

select * from user where name = "xxx" and community="other";

为了达到动态拼接的效果,这句 SQL 语句被一位同事进行了如下修改。他的本意是,当 name 或者 community 传入为空的时候,动态去掉这些查询条件。这种写法,在 MyBaits 的配置文件中,也非常常见。大多数情况下,这种写法是没有问题的,因为结果集合是可以控制的。但随着系统的运行,用户表的记录越来越多,当传入的 name 和 community 全部为空时,悲剧的事情发生了:

select * from user where 1=1

数据库中的所有记录,都会被查询出来,载入到 JVM 的内存中。由于数据库记录实在太多,直接把内存给撑爆了。由于这种原因引起的内存溢出,发生的频率非常高,比如导入Excel文件时。

通常的解决方式是强行加入分页功能,或者对一些必填的参数进行校验

img

Controller 层

现在很多项目都采用前后端分离架构,所以 Controller 层的方法,一般使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回。这在数据集非常大的情况下,会占用很多内存资源。假如结果集在解析成 JSON 之前,占用的内存是 10MB,那么在解析过程中,有可能会使用 20M 或者更多的内存

因此,保持结果集的精简,是非常有必要的,这也是 DTO(Data Transfer Object)存在的必要。互联网环境不怕小结果集的高并发请求,却非常恐惧大结果集的耗时请求,这是其中一方面的原因。

Service 层

Service 层用于处理具体的业务,更加贴合业务的功能需求。一个 Service,可能会被多个 Controller 层所使用,也可能会使用多个 dao 结构的查询结果进行计算、拼装。

int getUserSize() {
        List<User> users = dao.getAllUser();
        return null == users ? 0 : users.size();
}

代码review中发现了定时炸弹,这种在数据量达到一定程度后,才会暴露问题。

ORM 层

比如使用Mybatis时,有一个批量导入服务,在 MyBatis 执行批量插入的时候,竟然产生了内存溢出,按道理这种插入操作是不会引起额外内存占用的,最后通过源码追踪到了问题。

这是因为 MyBatis 循环处理 batch 的时候,操作对象是数组,而我们在接口定义的时候,使用的是 List;当传入一个非常大的 List 时,它需要调用 List 的 toArray 方法将列表转换成数组(浅拷贝);在最后的拼装阶段,又使用了 StringBuilder 来拼接最终的 SQL,所以实际使用的内存要比 List 多很多。

事实证明,不论是插入操作还是查询动作,只要涉及的数据集非常大,就容易出现问题。由于项目中众多框架的引入,想要分析这些具体的内存占用,就变得非常困难。所以保持小批量操作和结果集的干净,是一个非常好的习惯。

五、Redis篇

WhyRedis

​ 速度快,完全基于内存,使用C语言实现,网络层使用epoll解决高并发问题,单线程模型避免了不必要的上下文切换及竞争条件;

GuavaCache Tair EVCache Aerospike
类别 本地JVM缓存 分布式缓存 分布式缓存 分布式nosql数据库
应用 本地缓存 淘宝 Netflix、AWS 广告
性能 非常高 较高 很高 较高
持久化
集群 灵活配置 自动扩容

​ 与传统数据库不同的是 Redis 的数据是存在内存中的,所以读写速度非常快,因此 redis 被广泛应用于缓存方向,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。另外,Redis 也经常用来做分布式锁。除此之外,Redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。

1、简单高效

​ 1)完全基于内存,绝大部分请求是纯粹的内存操作。数据存在内存中,类似于 HashMap,查找和操作的时间复杂度都是O(1);

​ 2)数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的;

​ 3)采用单线程,避免了多线程不必要的上下文切换和竞争条件,不存在加锁释放锁操作,减少了因为锁竞争导致的性能消耗;(6.0以后多线程)

​ 4)使用EPOLL多路 I/O 复用模型,非阻塞 IO;

​ 5)使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;

2、Memcache

redis Memcached
内存高速数据库 高性能分布式内存缓存数据库
支持hash、list、set、zset、string结构 只支持key-value结构
将大部分数据放到内存 全部数据放到内存中
支持持久化、主从复制备份 不支持数据持久化及数据备份
数据丢失可通过AOF恢复 挂掉后,数据不可恢复
单线程(2~4万TPS) 多线程(20-40万TPS)

使用场景:

​ 1、如果有持久方面的需求或对数据类型和处理有要求的应该选择redis。
​ 2、如果简单的key/value 存储应该选择memcached。

3、Tair

​ Tair(Taobao Pair)是淘宝开发的分布式Key-Value存储引擎,既可以做缓存也可以做数据源(三种引擎切换)

  • MDB(Memcache)属于内存型产品,支持kv和类hashMap结构,性能最优
  • RDB(Redis)支持List.Set.Zset等复杂的数据结构,性能次之,可提供缓存和持久化存储两种模式
  • LDB(levelDB)属于持久化产品,支持kv和类hashmap结构,性能较前两者稍低,但持久化可靠性最高

分布式缓存

大访问少量临时数据的存储(kb左右)

用于缓存,降低对后端数据库的访问压力

session场景

高速访问某些数据结构的应用和计算(rdb)

数据源存储

快速读取数据(fdb)

持续大数据量的存入读取(ldb),交易快照

高频度的更新读取(ldb),库存

痛点:redis集群中,想借用缓存资源必须得指明redis服务器地址去要。这就增加了程序的维护复杂度。因为redis服务器很可能是需要频繁变动的。所以人家淘宝就想啊,为什么不能像操作分布式数据库或者hadoop那样。增加一个中央节点,让他去代理所有事情。在tair中程序只要跟tair中心节点交互就OK了。同时tair里还有配置服务器概念。又免去了像操作hadoop那样,还得每台hadoop一套一模一样配置文件。改配置文件得整个集群都跟着改。

4、Guava

​ 分布式缓存一致性更好一点,用于集群环境下多节点使用同一份缓存的情况;有网络IO,吞吐率与缓存的数据大小有较大关系;

​ 本地缓存非常高效,本地缓存会占用堆内存,影响垃圾回收、影响系统性能。

本地缓存设计:

​ 以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 jvm 的销毁而结束,并且在多实例的情况,每个实例都需要各自保存一份缓存,缓存不具有一致性。

解决缓存过期:

​ 1、将缓存过期时间调为永久

​ 2、将缓存失效时间分散开,不要将缓存时间长度都设置成一样;比如我们可以在原有的失效时间基础上增加一个随机值,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

解决内存溢出:

第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)

 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。

 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。

Google Guava Cache

自己设计本地缓存痛点:

  • 不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。
  • 清除数据时的回调通知
  • 并发处理能力差,针对并发可以使用CurrentHashMap,但缓存的其他功能需要自行实现
  • 缓存过期处理,缓存数据加载刷新等都需要手工实现

Guava Cache 的场景:

  • 对性能有非常高的要求
  • 不经常变化,占用内存不大
  • 有访问整个集合的需求
  • 数据允许不实时一致

Guava Cache 的优势

  • 缓存过期和淘汰机制

在GuavaCache中可以设置Key的过期时间,包括访问过期和创建过期。GuavaCache在缓存容量达到指定大小时,采用LRU的方式,将不常使用的键值从Cache中删除

  • 并发处理能力

GuavaCache类似CurrentHashMap,是线程安全的。提供了设置并发级别的api,使得缓存支持并发的写入和读取,采用分离锁机制,分离锁能够减小锁力度,提升并发能力,分离锁是分拆锁定,把一个集合看分成若干partition, 每个partiton一把锁。更新锁定

  • 防止缓存击穿

一般情况下,在缓存中查询某个key,如果不存在,则查源数据,并回填缓存。(Cache Aside Pattern)在高并发下会出现,多次查源并重复回填缓存,可能会造成源的宕机(DB),性能下降 GuavaCache可以在CacheLoader的load方法中加以控制,对同一个key,只让一个请求去读源并回填缓存,其他请求阻塞等待。(相当于集成数据源,方便用户使用)

  • 监控缓存加载/命中情况

统计

问题:

​ OOM->设置过期时间、使用弱引用、配置过期策略

5、EVCache

EVCache是一个Netflflix(网飞)公司开源、快速的分布式缓存,是基于Memcached的内存存储实现的,用以构建超大容量、高性能、低延时、跨区域的全球可用的缓存数据层。

E:Ephemeral:数据存储是短暂的,有自身的存活时间

V:Volatile:数据可以在任何时候消失

EVCache典型地适合对强一致性没有必须要求的场合

典型用例:Netflflix向用户推荐用户感兴趣的电影

image-20210103185340548

EVCache集群在峰值每秒可以处理200kb的请求,

Netflflix生产系统中部署的EVCache经常要处理超过每秒3000万个请求,存储数十亿个对象,

跨数千台memcached服务器。整个EVCache集群每天处理近2万亿个请求。

EVCache集群响应平均延时大约是1-5毫秒,最多不会超过20毫秒。

EVCache集群的缓存命中率在99%左右。

典型部署

EVCache 是线性扩展的,可以在一分钟之内完成扩容,在几分钟之内完成负载均衡和缓存预热。

image-20210103185611516

1、集群启动时,EVCache向服务注册中心(Zookeeper、Eureka)注册各个实例

2、在web应用启动时,查询命名服务中的EVCache服务器列表,并建立连接。

3、客户端通过key使用一致性hash算法,将数据分片到集群上。

6、ETCD

和Zookeeper一样,CP模型追求数据一致性,越来越多的系统开始用它保存关键数据。比如,秒杀系统经常用它保存各节点信息,以便控制消费 MQ 的服务数量。还有些业务系统的配置数据,也会通过 etcd 实时同步给业务系统的各节点,比如,秒杀管理后台会使用 etcd 将秒杀活动的配置数据实时同步给秒杀 API 服务各节点

image-20210418174251742

Redis底层

1、redis数据类型

类型 底层 应用场景 编码类型
String SDS数组 帖子、评论、热点数据、输入缓冲 RAW << EMBSTR << INT
List QuickList 评论列表、商品列表、发布与订阅、慢查询、监视器 LINKEDLIST << ZIPLIST
Set intSet 适合交集、并集、查集操作,例如朋友关系 HT << INSET
Zset 跳跃表 去重后排序,适合排名场景 SKIPLIST << ZIPLIST
Hash 哈希 结构化数据,比如存储对象 HT << ZIPLIST
Stream 紧凑列表 消息队列

2、相关API

http://redisdoc.com

String SET SETNX SETEX GET GETSET INCR DECR MSET MGET
Hash HSET HSETNX HGET HDEL HLEN HMSET HMGET HKEYS HGETALL
LIST LPUSH LPOP RPUSH RPOP LINDEX LREM LRANGE LLEN RPOPLPUSH
ZSET ZADD ZREM ZSCORE ZCARD ZRANGE ZRANK ZREVRANK ZREVRANGE
SET SADD SREM SISMEMBER SCARD SINTER SUNION SDIFF SPOP SMEMBERS
事务 MULTI EXEC DISCARD WATCH UNWATCH

3、redis底层结构

SDS数组结构,用于存储字符串和整型数据及输入缓冲。

struct sdshdr{ 
  int len;//记录buf数组中已使用字节的数量 
  int free; //记录 buf 数组中未使用字节的数量 
  char buf[];//字符数组,用于保存字符串
}

跳跃表:将有序链表中的部分节点分层,每一层都是一个有序链表。

​ 1、可以快速查找到需要的节点 O(logn) ,额外存储了一倍的空间

​ 2、可以在O(1)的时间复杂度下,快速获得跳跃表的头节点、尾结点、长度和高度。

字典dict: 又称散列表(hash),是用来存储键值对的一种数据结构。

​ Redis整个数据库是用字典来存储的(K-V结构) —Hash+数组+链表

​ Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)

​ 字典达到存储上限(阈值 0.75),需要rehash(扩容)

​ 1、初次申请默认容量为4个dictEntry,非初次申请为当前hash表容量的一倍。

​ 2、rehashidx=0表示要进行rehash操作。

​ 3、新增加的数据在新的hash表h[1] 、修改、删除、查询在老hash表h[0]

​ 4、将老的hash表h[0]的数据重新计算索引值后全部迁移到新的hash表h[1]中,这个过程称为 rehash。

渐进式rehash

 由于当数据量巨大时rehash的过程是非常缓慢的,所以需要进行优化。 可根据服务器空闲程度批量rehash部分节点

压缩列表zipList

​ 压缩列表(ziplist)是由一系列特殊编码的连续内存块组成的顺序型数据结构,节省内容

sorted-set和hash元素个数少且是小整数或短字符串(直接使用)

​ list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合。(间接使用)

整数集合intSet

​ 整数集合(intset)是一个有序的(整数升序)、存储整数的连续存储结构。

​ 当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储。

快速列表quickList

​ 快速列表(quicklist)是Redis底层重要的数据结构。是Redis3.2列表的底层实现。

​ (在Redis3.2之前,Redis采 用双向链表(adlist)和压缩列表(ziplist)实现。)

Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)。

listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内 容。

Rax树是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操 作。

4、Zset底层实现

​ 跳表(skip List)是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供O(logN)的时间复杂度

​ Zset数据量少的时候使用压缩链表ziplist实现,有序集合使用紧挨在一起的压缩列表节点来保存,第一个节点保存member,第二个保存score。ziplist内的集合元素按score从小到大排序,score较小的排在表头位置。 数据量大的时候使用跳跃列表skiplist和哈希表hash_map结合实现,查找删除插入的时间复杂度都是O(longN)

​ Redis使用跳表而不使用红黑树,是因为跳表的索引结构序列化和反序列化更加快速,方便持久化。

搜索

​ 跳跃表按 score 从小到大保存所有集合元素,查找时间复杂度为平均 O(logN),最坏 O(N) 。

插入

  选用链表作为底层结构支持,为了高效地动态增删。因为跳表底层的单链表是有序的,为了维护这种有序性,在插入前需要遍历链表,找到该插入的位置,单链表遍历查找的时间复杂度是O(n),同理可得,跳表的遍历也是需要遍历索引数,所以是O(logn)。

删除

  如果该节点还在索引中,删除时不仅要删除单链表中的节点,还要删除索引中的节点;单链表在知道删除的节点是谁时,时间复杂度为O(1),但针对单链表来说,删除时都需要拿到前驱节点O(logN)才可改变引用关系从而删除目标节点。

Redis可用性

1、redis持久化

持久化就是把内存中的数据持久化到本地磁盘,防止服务器宕机了内存数据丢失

Redis 提供两种持久化机制 RDB(默认)AOF 机制,Redis4.0以后采用混合持久化,用 AOF 来保证数据不丢失,作为数据恢复的第一选择; 用 RDB 来做不同程度的冷备

RDB:是Redis DataBase缩写快照

​ RDB是Redis默认的持久化方式。按照一定的时间将内存的数据以快照的形式保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。

优点:

​ 1)只有一个文件 dump.rdb,方便持久化;

​ 2)容灾性好,一个文件可以保存到安全的磁盘。

​ 3)性能最大化,fork 子进程来进行持久化写操作,让主进程继续处理命令,只存在毫秒级不响应请求。

​ 4)相对于数据集大时,比 AOF 的启动效率更高。

缺点:

​ 数据安全性低,RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。

AOF:持久化

​ AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令记录到单独的日志文件中,当重启Redis会重新将持久化的日志中文件恢复数据。

优点:

​ 1)数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。

​ 2)通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。

缺点:

​ 1)AOF 文件比 RDB 文件大,且恢复速度慢。

​ 2)数据集大的时候,比 rdb 启动效率低。

2、redis事务

​ 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis事务的概念

​ Redis 事务的本质是通过MULTI、EXEC、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis的事务总是具有ACID中的一致性和隔离性,其他特性是不支持的。当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有耐久性。

Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的

事务命令:

MULTI:用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。

EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。

WATCH :是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。(秒杀场景

DISCARD:调用该命令,客户端可以清空事务队列,并放弃执行事务,且客户端会从事务状态中退出。

UNWATCH:命令可以取消watch对所有key的监控。

3、redis失效策略

内存淘汰策略

1)全局的键空间选择性移除

noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。(字典库常用)

allkeys-lru:在键空间中,移除最近最少使用的key。(缓存常用)

allkeys-random:在键空间中,随机移除某个key。

2)设置过期时间的键空间选择性移除

volatile-lru:在设置了过期时间的键空间中,移除最近最少使用的key。

volatile-random:在设置了过期时间的键空间中,随机移除某个key。

volatile-ttl:在设置了过期时间的键空间中,有更早过期时间的key优先移除。

缓存失效策略

定时清除:针对每个设置过期时间的key都创建指定定时器

惰性清除:访问时判断,对内存不友好

定时扫描清除:定时100ms随机20个检查过期的字典,若存在25%以上则继续循环删除。

4、redis读写模式

CacheAside旁路缓存

写请求更新数据库后删除缓存数据。读请求不命中查询数据库,查询完成写入缓存

在这里插入图片描述

​ 业务端处理所有数据访问细节,同时利用 Lazy 计算的思想,更新 DB 后,直接删除 cache 并通过 DB 更新,确保数据以 DB 结果为准,则可以大幅降低 cache 和 DB 中数据不一致的概率

​ 如果没有专门的存储服务,同时是对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂的业务,适合使用 Cache Aside 模式。如微博发展初期,不少业务采用这种模式

// 延迟双删,用以保证最终一致性,防止小概率旧数据读请求在第一次删除后更新数据库
public void write(String key,Object data){
    redis.delKey(key);
    db.updateData(data);
    Thread.sleep(1000);
    redis.delKey(key);
}

高并发下保证绝对的一致,先删缓存再更新数据,需要用到内存队列做异步串行化。非高并发场景,先更新数据再删除缓存,延迟双删策略基本满足了

  • 先更新db后删除redis:删除redis失败则出现问题
  • 先删redis后更新db:删除redis瞬间,旧数据被回填redis
  • 先删redis后更新db休眠后删redis:同第二点,休眠后删除redis 可能宕机
  • java内部jvm队列:不适用分布式场景且降低并发

Read/Write Though(读写穿透)

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据.

在这里插入图片描述

​ 先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中。

在这里插入图片描述

​ 用户读操作较多.相较于Cache aside而言更适合缓存一致的场景。使用简单屏蔽了底层数据库的操作,只是操作缓存.

场景:

微博 Feed 的 Outbox Vector(即用户最新微博列表)就采用这种模式。一些粉丝较少且不活跃的用户发表微博后,Vector 服务会首先查询 Vector Cache,如果 cache 中没有该用户的 Outbox 记录,则不写该用户的 cache 数据,直接更新 DB 后就返回,只有 cache 中存在才会通过 CAS 指令进行更新。

Write Behind Caching(异步缓存写入)

img

比如对一些计数业务,一条 Feed 被点赞 1万 次,如果更新 1万 次 DB 代价很大,而合并成一次请求直接加 1万,则是一个非常轻量的操作。但这种模型有个显著的缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。

5、多级缓存

浏览器本地内存缓存:专题活动,一旦上线,在活动期间是不会随意变更的。

浏览器本地磁盘缓存:Logo缓存,大图片懒加载

服务端本地内存缓存:由于没有持久化,重启时必定会被穿透

服务端网络内存缓存:Redis等,针对穿透的情况下可以继续分层,必须保证数据库不被压垮

为什么不是使用服务器本地磁盘做缓存?

​ 当系统处理大量磁盘 IO 操作的时候,由于 CPU 和内存的速度远高于磁盘,可能导致 CPU 耗费太多时间等待磁盘返回处理的结果。对于这部分 CPU 在 IO 上的开销,我们称为 iowait

Redis七大经典问题

1、缓存雪崩

​ 指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

  • Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃
  • 本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死
  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

  • 逻辑上永不过期给每一个缓存数据增加相应的缓存标记,缓存标记失效则更新数据缓存

  • 多级缓存,失效时通过二级更新一级,由第三方插件更新二级缓存。

2、缓存穿透

https://blog.csdn.net/lin777lin/article/details/105666839

​ 缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

​ 1)接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

​ 2)从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒。这样可以防止攻击用户反复用同一个id暴力攻击;

​ 3)采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。(宁可错杀一千不可放过一人)

3、缓存击穿

​ 这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库

解决方案:

​ 1)设置热点数据永远不过期,异步线程处理。

​ 2)加写回操作加互斥锁,查询失败默认值快速返回。

​ 3)缓存预热

​ 系统上线后,将相关可预期(例如排行榜)热点数据直接加载到缓存。

​ 写一个缓存刷新页面,手动操作热点数据(例如广告推广)上下线。

4、数据不一致

​ 在缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。

  • Cache 更新失败后,可以进行重试,则将重试失败的 key 写入mq,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性

  • 缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。

  • 不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。

5、数据并发竞争

​ 数据并发竞争在大流量系统也比较常见,比如车票系统,如果某个火车车次缓存信息过期,但仍然有大量用户在查询该车次信息。又比如微博系统中,如果某条微博正好被缓存淘汰,但这条微博仍然有大量的转发、评论、赞。上述情况都会造成并发竞争读取的问题。

  • ​ 加写回操作加互斥锁,查询失败默认值快速返回。
  • ​ 对缓存数据保持多个备份,减少并发竞争的概率

6、热点key问题

​ 明星结婚、离婚、出轨这种特殊突发事件,比如奥运、春节这些重大活动或节日,还比如秒杀、双12、618 等线上促销活动,都很容易出现 Hot key 的情况。

如何提前发现HotKey?

  • 对于重要节假日、线上促销活动这些提前已知的事情,可以提前评估出可能的热 key 来。
  • 而对于突发事件,无法提前评估,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。而对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。

解决方案:

  • 这 n 个 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载

  • 缓存集群可以单节点进行主从复制和垂直扩容

  • 利用应用内的前置缓存,但是需注意需要设置上限

  • 延迟不敏感,定时刷新,实时感知用主动刷新

  • 和缓存穿透一样,限制逃逸流量,单请求进行数据回源并刷新前置

  • 无论如何设计,最后都要写一个兜底逻辑,千万级流量说来就来

7、BigKey问题

​ 比如互联网系统中需要保存用户最新 1万 个粉丝的业务,比如一个用户个人信息缓存,包括基本资料、关系图谱计数、发 feed 统计等。微博的 feed 内容缓存也很容易出现,一般用户微博在 140 字以内,但很多用户也会发表 1千 字甚至更长的微博内容,这些长微博也就成了大 key

  • 首先Redis底层数据结构里,根据Value的不同,会进行数据结构的重新选择
  • 可以扩展新的数据结构,进行序列化构建,然后通过 restore 一次性写入
  • 将大 key 分拆为多个 key,设置较长的过期时间

Redis分区容错

1、redis数据分区

Hash:(不稳定)

​ 客户端分片:哈希+取余

​ 节点伸缩:数据节点关系变化,导致数据迁移

​ 迁移数量和添加节点数量有关:建议翻倍扩容

​ 一个简单直观的想法是直接用Hash来计算,以Key做哈希后对节点数取模。可以看出,在key足够分散的情况下,均匀性可以获得,但一旦有节点加入或退出,所有的原有节点都会受到影响,稳定性无从谈起。

一致性Hash:(不均衡)

​ 客户端分片:哈希+顺时针(优化取余)

​ 节点伸缩:只影响邻近节点,但是还是有数据迁移

​ 翻倍伸缩:保证最小迁移数据和负载均衡

​ 一致性Hash可以很好的解决稳定问题,可以将所有的存储节点排列在收尾相接的Hash环上,每个key在计算Hash后会顺时针找到先遇到的一组存储节点存放。而当有节点加入或退出时,仅影响该节点在Hash环上顺时针相邻的后续节点,将数据从该节点接收或者给予。但这又带来均匀性的问题,即使可以将存储节点等距排列,也会在存储节点个数变化时带来数据的不均匀

Codis的Hash槽

​ Codis 将所有的 key 默认划分为 1024 个槽位(slot),它首先对客户端传过来的 key 进行 crc32 运算计算 哈希值,再将 hash 后的整数值对 1024 这个整数进行取模得到一个余数,这个余数就是对应 key 的槽位。

RedisCluster

​ Redis-cluster把所有的物理节点映射到[0-16383]个slot上,对key采用crc16算法得到hash值后对16384取模,基本上采用平均分配和连续分配的方式。

2、主从模式=简单

​ 主从模式最大的优点是部署简单,最少两个节点便可以构成主从模式,并且可以通过读写分离避免读和写同时不可用。不过,一旦 Master 节点出现故障,主从节点就无法自动切换,直接导致 SLA 下降。所以,主从模式一般适合业务发展初期,并发量低,运维成本低的情况

Drawing 1.png

主从复制原理:

​ ①通过从服务器发送到PSYNC命令给主服务器

​ ②如果是首次连接,触发一次全量复制。此时主节点会启动一个后台线程,生成 RDB 快照文件

​ ③主节点会将这个 RDB 发送给从节点,slave 会先写入本地磁盘,再从本地磁盘加载到内存中

​ ④master会将此过程中的写命令写入缓存,从节点实时同步这些数据

​ ⑤如果网络断开了连接,自动重连后主节点通过命令传播增量复制给从节点部分缺少的数据

缺点

​ 所有的slave节点数据的复制和同步都由master节点来处理,会照成master节点压力太大,使用主从从结构来解决,redis4.0中引入psync2 解决了slave重启后仍然可以增量同步。

3、哨兵模式=读多

​ 由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。哨兵模式适合读请求远多于写请求的业务场景,比如在秒杀系统中用来缓存活动信息。 如果写请求较多,当集群 Slave 节点数量多了后,Master 节点同步数据的压力会非常大。

image-20201220231241725

当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。

检测主观下线状态

​ Sentinel每秒一次向所有与它建立了命令连接的实例(主服务器、从服务器和其他Sentinel)发送PING命 令

​ 实例在down-after-milliseconds毫秒内返回无效回复Sentinel就会认为该实例主观下线(SDown)

检查客观下线状态

​ 当一个Sentinel将一个主服务器判断为主观下线后 ,Sentinel会向监控这个主服务器的所有其他Sentinel发送查询主机状态的命令

​ 如果达到Sentinel配置中的quorum数量的Sentinel实例都判断主服务器为主观下线,则该主服务器就会被判定为客观下线(ODown)。

选举Leader Sentinel

​ 当一个主服务器被判定为客观下线后,监视这个主服务器的所有Sentinel会通过选举算法(raft),选出一个Leader Sentinel去执行failover(故障转移)操作。

Raft算法

​ Raft协议是用来解决分布式系统一致性问题的协议。 Raft协议描述的节点共有三种状态:Leader, Follower, Candidate。 Raft协议将时间切分为一个个的Term(任期),可以认为是一种“逻辑时间”。 选举流程:
①Raft采用心跳机制触发Leader选举系统启动后,全部节点初始化为Follower,term为0

​ ②节点如果收到了RequestVote或者AppendEntries,就会保持自己的Follower身份

​ ③节点如果一段时间内没收到AppendEntries消息,在该节点的超时时间内还没发现Leader,Follower就会转换成Candidate,自己开始竞选Leader。 一旦转化为Candidate,该节点立即开始下面几件事情:
​ –增加自己的term,启动一个新的定时器
​ –给自己投一票,向所有其他节点发送RequestVote,并等待其他节点的回复。

​ ④如果在计时器超时前,节点收到多数节点的同意投票,就转换成Leader。同时通过 AppendEntries,向其他节点发送通知。

​ ⑤每个节点在一个term内只能投一票,采取先到先得的策略,Candidate投自己, Follower会投给第一个收到RequestVote的节点。

​ ⑥Raft协议的定时器采取随机超时时间(选举的关键),先转为Candidate的节点会先发起投票,从而获得多数票。

主服务器的选择

​ 当选举出Leader Sentinel后,Leader Sentinel会根据以下规则去从服务器中选择出新的主服务器。

  1. 过滤掉主观、客观下线的节点
  2. 选择配置slave-priority最高的节点,如果有则返回没有就继续选择
  3. 选择出复制偏移量最大的系节点,因为复制偏移量越大则数据复制的越完整
  4. 选择run_id最小的节点,因为run_id越小说明重启次数越少

故障转移

​ 当Leader Sentinel完成新的主服务器选择后,Leader Sentinel会对下线的主服务器执行故障转移操作,主要有三个步骤:

​ 1、它会将失效 Master 的其中一个 Slave 升级为新的 Master , 并让失效 Master 的其他 Slave 改为复制新的 Master ;

​ 2、当客户端试图连接失效的 Master 时,集群会向客户端返回新 Master 的地址,使得集群当前状态只有一个Master。

​ 3、Master 和 Slave 服务器切换后, Master 的 redis.conf 、 Slave 的 redis.conf 和 sentinel.conf 的配置文件的内容都会发生相应的改变,即 Master 主服务器的 redis.conf配置文件中会多一行 replicaof 的配置, sentinel.conf 的监控目标会随之调换。

4、集群模式=写多

​ 为了避免单一节点负载过高导致不稳定,集群模式采用一致性哈希算法或者哈希槽的方法将 Key 分布到各个节点上。其中,每个 Master 节点后跟若干个 Slave 节点,用于出现故障时做主备切换,客户端可以连接任意 Master 节点,集群内部会按照不同 key 将请求转发到不同的 Master 节点

​ 集群模式是如何实现高可用的呢?集群内部节点之间会互相定时探测对方是否存活,如果多数节点判断某个节点挂了,则会将其踢出集群,然后从 Slave 节点中选举出一个节点替补挂掉的 Master 节点。整个原理基本和哨兵模式一致

​ 虽然集群模式避免了 Master 单节点的问题,但集群内同步数据时会占用一定的带宽。所以,只有在写操作比较多的情况下人们才使用集群模式,其他大多数情况,使用哨兵模式都能满足需求

5、分布式锁

利用Watch实现Redis乐观锁

​ 乐观锁基于CAS(Compare And Swap)比较并替换思想,不会产生锁等待而消耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实现乐观锁(秒杀)。具体思路如下:

1、利用redis的watch功能,监控这个redisKey的状态值
2、获取redisKey的值,创建redis事务,给这个key的值+1
3、执行这个事务,如果key的值被修改过则回滚,key不加1

利用setnx防止库存超卖
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。 利用Redis的单线程特性对共享资源进行串行化处理

// 获取锁推荐使用set的方式
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
String result = jedis.setnx(lockKey, requestId); //如线程死掉,其他线程无法获取到锁
// 释放锁,非原子操作,可能会释放其他线程刚加上的锁
if (requestId.equals(jedis.get(lockKey))) { 
  jedis.del(lockKey);
}
// 推荐使用redis+lua脚本
String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object result = jedis.eval(lua, Collections.singletonList(lockKey),

分布式锁存在的问题

  • 客户端长时间阻塞导致锁失效问题

​ 计算时间内异步启动另外一个线程去检查的问题,这个key是否超时,当锁超时时间快到期且逻辑未执行完,延长锁超时时间。

  • Redis服务器时钟漂移问题导致同时加锁
    redis的过期时间是依赖系统时钟的,如果时钟漂移过大时 理论上是可能出现的
    会影响到过期时间的计算。

  • 单点实例故障,锁未及时同步导致丢失

    RedLock算法

  1. 获取当前时间戳T0,配置时钟漂移误差T1

  2. 短时间内逐个获取全部N/2+1个锁,结束时间点T2

  3. 实际锁能使用的处理时长变为:TTL - (T2 - T0)- T1

    该方案通过多节点来防止Redis的单点故障,效果一般,也无法防止:

  • 主从切换导致的两个客户端同时持有锁

    大部分情况下持续时间极短,而且使用Redlock在切换的瞬间获取到节点的锁,也存在问题。已经是极低概率的时间,无法避免。Redis分布式锁适合幂等性事务,如果一定要保证安全,应该使用Zookeeper或者DB,但是,性能会急剧下降

与zookeeper分布式锁对比

  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk 分布式锁,注册个监听器即可,不需要不断主动尝试获取锁,ZK获取锁会按照加锁的顺序,所以是公平锁,性能和mysql差不多,和redis差别大

Redission生产环境的分布式锁

​ Redisson是基于NIO的Netty框架上的一个Java驻内存数据网格(In-Memory Data Grid)分布式锁开源组件。

image-20201221000119586

但当业务必须要数据的强一致性,即不允许重复获得锁,比如金融场景(重复下单,重复转账),请不要使用redis分布式锁。可以使用CP模型实现,比如:zookeeper和etcd。

Redis zookeeper etcd
一致性算法 paxos(ZAB) raft
CAP AP CP CP
高可用 主从集群 n+1 n+1
实现 setNX createNode restfulAPI

6、redis心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率向主服务器发送ACK命令:

​ 1、检测主从的连接状态 检测主从服务器的网络连接状态

​ lag的值应该在0或1之间跳动,如果超过1则说明主从之间的连接有 故障。

​ 2、辅助实现min-slaves,Redis可以通过配置防止主服务器在不安全的情况下执行写命令

min-slaves-to-write 3 (min-replicas-to-write 3 )

min-slaves-max-lag 10 (min-replicas-max-lag 10)

​ 上面的配置表示:从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10 秒时,主服务器将拒绝执行写命令。

​ 3、检测命令丢失,增加重传机制

​ 如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发 送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量, 然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

Redis实战

1、Redis优化

img

读写方式
简单来说就是不用keys等,用range、contains之类。比如,用户粉丝数,大 V 的粉丝更是高达几千万甚至过亿,因此,获取粉丝列表只能部分获取。另外在判断某用户是否关注了另外一个用户时,也只需要关注列表上进行检查判断,然后返回 True/False 或 0/1 的方式更为高效。

KV size
如果单个业务的 KV size 过大,需要分拆成多个 KV 来缓存。拆分时应考虑访问频率

key 的数量
如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。

读写峰值
如果小于 10万 级别,简单分拆到独立 Cache 池即可
如果达到 100万 级的QPS,则需要对 Cache 进行分层处理,可以同时使用 Local-Cache 配合远程 cache,甚至远程缓存内部继续分层叠加分池进行处理。(多级缓存)

命中率
缓存的命中率对整个服务体系的性能影响甚大。对于核心高并发访问的业务,需要预留足够的容量,确保核心业务缓存维持较高的命中率。比如微博中的 Feed Vector Cache(热点资讯),常年的命中率高达 99.5% 以上。为了持续保持缓存的命中率,缓存体系需要持续监控,及时进行故障处理或故障转移。同时在部分缓存节点异常、命中率下降时,故障转移方案,需要考虑是采用一致性 Hash 分布的访问漂移策略,还是采用数据多层备份策略。

过期策略

​ 可以设置较短的过期时间,让冷 key 自动过期;也可以让 key 带上时间戳,同时设置较长的过期时间,比如很多业务系统内部有这样一些 key:key_20190801。

缓存穿透时间
平均缓存穿透加载时间在某些业务场景下也很重要,对于一些缓存穿透后,加载时间特别长或者需要复杂计算的数据,而且访问量还比较大的业务数据,要配置更多容量,维持更高的命中率,从而减少穿透到 DB 的概率,来确保整个系统的访问性能。

缓存可运维性
对于缓存的可运维性考虑,则需要考虑缓存体系的集群管理,如何进行一键扩缩容,如何进行缓存组件的升级和变更,如何快速发现并定位问题,如何持续监控报警,最好有一个完善的运维平台,将各种运维工具进行集成。

缓存安全性
对于缓存的安全性考虑,一方面可以限制来源 IP,只允许内网访问,同时加密鉴权访问。

2、Redis热升级

在 Redis 需要升级版本或修复 bug 时,如果直接重启变更,由于需要数据恢复,这个过程需要近 10 分钟的时间,时间过长,会严重影响系统的可用性。面对这种问题,可以对 Redis 扩展热升级功能,从而在毫秒级完成升级操作,完全不影响业务访问。

热升级方案如下,首先构建一个 Redis 壳程序,将 redisServer 的所有属性(包括redisDb、client等)保存为全局变量。然后将 Redis 的处理逻辑代码全部封装到动态连接库 so 文件中。Redis 第一次启动,从磁盘加载恢复数据,在后续升级时,通过指令,壳程序重新加载 Redis 新的 redis-4.so 到 redis-5.so 文件,即可完成功能升级,毫秒级完成 Redis 的版本升级。而且整个过程中,所有 Client 连接仍然保留,在升级成功后,原有 Client 可以继续进行读写操作,整个过程对业务完全透明。

六、Kafka篇

Why kafka

消息队列的作用:异步、削峰填谷、解耦

中小型公司,技术实力较为一般,技术挑战不是特别高,用 RabbitMQ (开源、社区活跃)是不错的选择;大型公司,基础架构研发实力较强,用 RocketMQ(Java二次开发) 是很好的选择。

如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范。

image-20210107225921930

RabbitMQ

RabbitMQ开始是用在电信业务的可靠通信的,也是少有的几款支持AMQP协议的产品之一。

优点:

  • 轻量级,快速,部署使用方便
  • 支持灵活的路由配置。RabbitMQ中,在生产者和队列之间有一个交换器模块。根据配置的路由规则,生产者发送的消息可以发送到不同的队列中。路由规则很灵活,还可以自己实现。
  • RabbitMQ的客户端支持大多数的编程语言,支持AMQP协议。

image-20210107231826261

缺点:

  • 如果有大量消息堆积在队列中,性能会急剧下降
  • 每秒处理几万到几十万的消息。如果应用要求高的性能,不要选择RabbitMQ。
  • RabbitMQ是Erlang开发的,功能扩展和二次开发代价很高。

RocketMQ

借鉴了Kafka的设计并做了很多改进,几乎具备了消息队列应该具备的所有特性和功能

  • RocketMQ主要用于有序,事务,流计算,消息推送,日志流处理,binlog分发等场景。
  • 经过了历次的双11考验,性能,稳定性可靠性没的说。
  • java开发,阅读源代码、扩展、二次开发很方便。
  • 对电商领域的响应延迟做了很多优化。
  • 每秒处理几十万的消息,同时响应在毫秒级。如果应用很关注响应时间,可以使用RocketMQ。
  • 性能比RabbitMQ高一个数量级,。
  • 支持死信队列,DLX 是一个非常有用的特性。它可以处理异常情况下,消息不能够被消费者正确消费而被置入死信队列中的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进而可以改善和优化系统

缺点

​ 跟周边系统的整合和兼容不是很好。

Kafka

高可用,几乎所有相关的开源软件都支持,满足大多数的应用场景,尤其是大数据和流计算领域,

  • Kafka高效,可伸缩,消息持久化。支持分区、副本和容错。
  • 对批处理和异步处理做了大量的设计,因此Kafka可以得到非常高的性能。
  • 每秒处理几十万异步消息消息,如果开启了压缩,最终可以达到每秒处理2000w消息的级别。
  • 但是由于是异步的和批处理的,延迟也会高,不适合电商场景。

What Kafka

  • Producer API:允许应用程序将记录流发布到一个或多个Kafka主题。
  • Consumer API:允许应用程序订阅一个或多个主题并处理为其生成的记录流。
  • Streams API:允许应用程序充当流处理器,将输入流转换为输出流。

image-20210106203420526

消息Message

​ Kafka的数据单元称为消息。可以把消息看成是数据库里的一个“数据行”或一条“记录”。

批次

​ 为了提高效率,消息被分批写入Kafka。提高吞吐量却加大了响应时间

主题Topic

​ 通过主题进行分类,类似数据库中的表,

分区Partition

​ Topic可以被分成若干分区分布于kafka集群中,方便扩容

​ 单个分区内是有序的,partition设置为一才能保证全局有序

副本Replicas

​ 每个主题被分为若干个分区,每个分区有多个副本。

生产者Producer

​ 生产者在默认情况下把消息均衡地分布到主题的所有分区上:

  • 直接指定消息的分区
  • 根据消息的key散列取模得出分区
  • 轮询指定分区。

消费者Comsumer

​ 消费者通过偏移量来区分已经读过的消息,从而消费消息。把每个分区最后读取的消息偏移量保存在Zookeeper 或Kafka上,如果消费者关闭或重启,它的读取状态不会丢失

消费组ComsumerGroup

​ 消费组保证每个分区只能被一个消费者使用,避免重复消费。如果群组内一个消费者失效,消费组里的其他消费者可以接管失效消费者的工作再平衡,重新分区

节点Broker

​ 连接生产者和消费者,单个broker可以轻松处理数千个分区以及每秒百万级的消息量。

  • broker接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存
  • broker为消费者提供服务,响应读取分区的请求,返回已经提交到磁盘上的消息

集群

​ 每隔分区都有一个首领,当分区被分配给多个broker时,会通过首领进行分区复制

生产者Offset

​ 消息写入的时候,每一个分区都有一个offset,即每个分区的最新最大的offset。

消费者Offset

​ 不同消费组中的消费者可以针对一个分区存储不同的Offset,互不影响

LogSegment

  • 一个分区由多个LogSegment组成,
  • 一个LogSegment由.log .index .timeindex组成
  • .log追加是顺序写入的,文件名是以文件中第一条message的offset来命名的
  • .Index进行日志删除的时候和数据查找的时候可以快速定位。
  • .timeStamp则根据时间戳查找对应的偏移量

How Kafka

优点

  • 高吞吐量:单机每秒处理几十上百万的消息量。即使存储了TB及消息,也保持稳定的性能。
    • 零拷贝 减少内核态到用户态的拷贝,磁盘通过sendfile实现DMA 拷贝Socket buffer
    • 顺序读写 充分利用磁盘顺序读写的超高性能
    • 页缓存mmap,将磁盘文件映射到内存, 用户通过修改内存就能修改磁盘文件。
  • 高性能:单节点支持上千个客户端,并保证零停机和零数据丢失。
  • 持久化:将消息持久化到磁盘。通过将数据持久化到硬盘以及replication防止数据丢失。
  • 分布式系统,易扩展。所有的组件均为分布式的,无需停机即可扩展机器。
  • 可靠性 - Kafka是分布式,分区,复制和容错的。
  • 客户端状态维护:消息被处理的状态是在Consumer端维护,当失败时能自动平衡。

应用场景

  • 日志收集:用Kafka可以收集各种服务的Log,通过大数据平台进行处理;
  • 消息系统:解耦生产者和消费者、缓存消息等;
  • 用户活动跟踪:Kafka经常被用来记录Web用户或者App用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到Kafka的Topic中,然后消费者通过订阅这些Topic来做运营数据的实时的监控分析,也可保存到数据库;

生产消费基本流程

image-20210106213944461

  1. Producer创建时,会创建一个Sender线程并设置为守护线程。

  2. 生产的消息先经过拦截器->序列化器->分区器,然后将消息缓存在缓冲区。

  3. 批次发送的条件为:缓冲区数据大小达到batch.size或者linger.ms达到上限。

  4. 批次发送后,发往指定分区,然后落盘到broker;

    • acks=0只要将消息放到缓冲区,就认为消息已经发送完成。

    • acks=1表示消息只需要写到主分区即可。在该情形下,如果主分区收到消息确认之后就宕机了,而副本分区还没来得及同步该消息,则该消息丢失。

    • acks=all (默认)首领分区会等待所有的ISR副本分区确认记录。该处理保证了只要有一个ISR副本分区存活,消息就不会丢失。

  5. 如果生产者配置了retrires参数大于0并且未收到确认,那么客户端会对该消息进行重试。

  6. 落盘到broker成功,返回生产元数据给生产者。

Leader选举

  • Kafka会在Zookeeper上针对每个Topic维护一个称为ISR(in-sync replica)的集合

  • 当集合中副本都跟Leader中的副本同步了之后,kafka才会认为消息已提交

  • 只有这些跟Leader保持同步的Follower才应该被选作新的Leader

  • 假设某个topic有N+1个副本,kafka可以容忍N个服务器不可用,冗余度较低

    如果ISR中的副本都丢失了,则:

    • 可以等待ISR中的副本任何一个恢复,接着对外提供服务,需要时间等待
    • 从OSR中选出一个副本做Leader副本,此时会造成数据丢失

副本消息同步

​ 首先,Follower 发送 FETCH 请求给 Leader。接着,Leader 会读取底层日志文件中的消 息数据,再更新它内存中的 Follower 副本的 LEO 值,更新为 FETCH 请求中的 fetchOffset 值。最后,尝试更新分区高水位值。Follower 接收到 FETCH 响应之后,会把消息写入到底层日志,接着更新 LEO 和 HW 值。

相关概念LEOHW

  • LEO:即日志末端位移(log end offset),记录了该副本日志中下一条消息的位移值。如果LEO=10,那么表示该副本保存了10条消息,位移值范围是[0, 9]
  • HW:水位值HW(high watermark)即已备份位移。对于同一个副本对象而言,其HW值不会大于LEO值。小于等于HW值的所有消息都被认为是“已备份”的(replicated)

Rebalance

  • 组成员数量发生变化
  • 订阅主题数量发生变化
  • 订阅主题的分区数发生变化

leader选举完成后,当以上三种情况发生时,Leader根据配置的RangeAssignor开始分配消费方案,即哪个consumer负责消费哪些topic的哪些partition。一旦完成分配,leader会将这个方案封装进SyncGroup请求中发给coordinator,非leader也会发SyncGroup请求,只是内容为空。coordinator接收到分配方案之后会把方案塞进SyncGroup的response中发给各个consumer。这样组内的所有成员就都知道自己应该消费哪些分区了。

分区分配算法RangeAssignor

  • 原理是按照消费者总数和分区总数进行整除运算平均分配给所有的消费者。

  • 订阅Topic的消费者按照名称的字典序排序,分均分配,剩下的字典序从前往后分配

增删改查

kafka-topics.sh --zookeeper localhost:2181/myKafka --create --topic topic_x 
                                --partitions 1 --replication-factor 1
kafka-topics.sh --zookeeper localhost:2181/myKafka --delete --topic topic_x
kafka-topics.sh --zookeeper localhost:2181/myKafka --alter --topic topic_x
                                --config max.message.bytes=1048576
kafka-topics.sh --zookeeper localhost:2181/myKafka --describe --topic topic_x

如何查看偏移量为23的消息?

通过查询跳跃表ConcurrentSkipListMap,定位到在00000000000000000000.index ,通过二分法在偏移量索引文件中找到不大于 23 的最大索引项,即offset 20 那栏,然后从日志分段文件中的物理位置为320 开始顺序查找偏移量为 23 的消息。

img

切分文件

  • 大小分片 当前日志分段文件的大小超过了 broker 端参数 log.segment.bytes 配置的值
  • 时间分片 当前日志分段中消息的最大时间戳与系统的时间戳的差值大于log.roll.ms配置的值
  • 索引分片 偏移量或时间戳索引文件大小达到broker端 log.index.size.max.bytes配置的值
  • 偏移分片 追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE

一致性

幂等性

保证在消息重发的时候,消费者不会重复处理。即使在消费者收到重复消息的时候,重复处理,也

保证最终结果的一致性。所谓幂等性,数学概念就是: f(f(x)) = f(x)

image-20210107000942286

如何实现?

​ 添加唯一ID,类似于数据库的主键,用于唯一标记一个消息。

ProducerID:#在每个新的Producer初始化时,会被分配一个唯一的PID
SequenceNumber:#对于每个PID发送数据的每个Topic都对应一个从0开始单调递增的SN值

image-20210107001546404

如何选举

  1. 使用 Zookeeper 的分布式锁选举控制器,并在节点加入集群或退出集群时通知控制器。
  2. 控制器负责在节点加入或离开集群时进行分区Leader选举。
  3. 控制器使用epoch忽略小的纪元来避免脑裂:两个节点同时认为自己是当前的控制器。

可用性

  • 创建Topic的时候可以指定 –replication-factor 3 ,表示不超过broker的副本数
  • 只有Leader是负责读写的节点,Follower定期地到Leader上Pull数据。
  • ISR是Leader负责维护的与其保持同步的Replica列表,即当前活跃的副本列表。如果一个Follow落后太多,Leader会将它从ISR中移除。选举时优先从ISR中挑选Follower。
  • 设置 acks=all 。Leader收到了ISR中所有Replica的ACK,才向Producer发送ACK。

面试题

线上问题rebalance

因集群架构变动导致的消费组内重平衡,如果kafka集内节点较多,比如数百个,那重平衡可能会耗时导致数分钟到数小时,此时kafka基本处于不可用状态,对kafka的TPS影响极大

产生的原因:

  • 组成员数量发生变化

  • 订阅主题数量发生变化

  • 订阅主题的分区数发生变化

    组成员崩溃和组成员主动离开是两个不同的场景。因为在崩溃时成员并不会主动地告知coordinator此事,coordinator有可能需要一个完整的session.timeout周期(心跳周期)才能检测到这种崩溃,这必然会造成consumer的滞后。可以说离开组是主动地发起rebalance;而崩溃则是被动地发起rebalance。

    img

解决方案:

加大超时时间 session.timout.ms=6s
加大心跳频率 heartbeat.interval.ms=2s
增长推送间隔 max.poll.interval.ms=t+1 minutes

这些年,为了进阿里背过的面试题

ZooKeeper 的作用

目前,Kafka 使用 ZooKeeper 存放集群元数据、成员管理、Controller 选举,以及其他一些管理类任务。之后,等 KIP-500 提案完成后,Kafka 将完全不再依赖于 ZooKeeper。

  • 存放元数据是指主题分区的所有数据都保存在 ZooKeeper 中,其他“人”都要与它保持对齐。
  • 成员管理是指 Broker 节点的注册、注销以及属性变更等 。
  • Controller 选举是指选举集群 Controller,包括但不限于主题删除、参数配置等。

一言以蔽之:KIP-500 ,是使用社区自研的基于 Raft 的共识算法,实现 Controller 自选举

同样是存储元数据,这几年基于Raft算法的etcd认可度越来越高

​ 越来越多的系统开始用它保存关键数据。比如,秒杀系统经常用它保存各节点信息,以便控制消费 MQ 的服务数量。还有些业务系统的配置数据,也会通过 etcd 实时同步给业务系统的各节点,比如,秒杀管理后台会使用 etcd 将秒杀活动的配置数据实时同步给秒杀 API 服务各节点

Replica副本的作用

Kafka 只有 Leader 副本才能 对外提供读写服务,响应 Clients 端的请求。Follower 副本只是采用拉(PULL)的方 式,被动地同步 Leader 副本中的数据,并且在 Leader 副本所在的 Broker 宕机后,随时准备应聘 Leader 副本。

  • 自 Kafka 2.4 版本开始,社区可以通过配置参数,允许 Follower 副本有限度地提供读服务。
  • 之前确保一致性的主要手段是高水位机制, 但高水位值无法保证 Leader 连续变更场景下的数据一致性,因此,社区引入了 Leader Epoch 机制,来修复高水位值的弊端。

为什么不支持读写分离?

  • 自 Kafka 2.4 之后,Kafka 提供了有限度的读写分离。

  • 场景不适用。读写分离适用于那种读负载很大,而写操作相对不频繁的场景。

  • 同步机制。Kafka 采用 PULL 方式实现 Follower 的同步,同时复制延迟较大。

如何防止重复消费

  • 代码层面每次消费需提交offset
  • 通过Mysql的唯一键约束,结合Redis查看id是否被消费,存Redis可以直接使用set方法
  • 量大且允许误判的情况下,使用布隆过滤器也可以

如何保证数据不会丢失

  • 生产者生产消息可以通过comfirm配置ack=all解决
  • Broker同步过程中leader宕机可以通过配置ISR副本+重试解决
  • 消费者丢失可以关闭自动提交offset功能,系统处理完成时提交offset

如何保证顺序消费

  • 单 topic,单partition,单 consumer,单线程消费,吞吐量低,不推荐
  • 如只需保证单key有序,为每个key申请单独内存 queue,每个线程分别消费一个内存 queue 即可,这样就能保证单key(例如用户id、活动id)顺序性。

【线上】如何解决积压消费

  • 修复consumer,使其具备消费能力,并且扩容N台
  • 写一个分发的程序,将Topic均匀分发到临时Topic中
  • 同时起N台consumer,消费不同的临时Topic

如何避免消息积压

  • 提高消费并行度
  • 批量消费
  • 减少组件IO的交互次数
  • 优先级消费
if (maxOffset - curOffset > 100000) {
  // TODO 消息堆积情况的优先处理逻辑
  // 未处理的消息可以选择丢弃或者打日志
  return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;

如何设计消息队列

需要支持快速水平扩容,broker+partition,partition放不同的机器上,增加机器时将数据根据topic做迁移,分布式需要考虑一致性、可用性、分区容错性

  • 一致性:生产者的消息确认、消费者的幂等性、Broker的数据同步
  • 可用性:数据如何保证不丢不重、数据如何持久化、持久化时如何读写
  • 分区容错:采用何种选举机制、如何进行多副本同步
  • 海量数据:如何解决消息积压、海量Topic性能下降

性能上,可以借鉴时间轮、零拷贝、IO多路复用、顺序读写、压缩批处理

七、Spring篇

设计思想&Beans

1、IOC 控制反转

​ IoC(Inverse of Control:控制反转)是⼀种设计思想,就是将原本在程序中⼿动创建对象的控制权,交由Spring框架来管理。 IoC 在其他语⾔中也有应⽤,并⾮ Spring 特有。

​ IoC 容器是 Spring⽤来实现 IoC 的载体, IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注⼊。这样可以很⼤程度上简化应⽤的开发,把应⽤从复杂的依赖关系中解放出来。 IoC 容器就像是⼀个⼯⼚⼀样,当我们需要创建⼀个对象的时候,只需要配置好配置⽂件/注解即可,完全不⽤考虑对象是如何被创建出来的。

DI 依赖注入

​ DI:(Dependancy Injection:依赖注入)站在容器的角度,将对象创建依赖的其他对象注入到对象中。

2、AOP 动态代理

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

​ Spring AOP就是基于动态代理的,如果要代理的对象,实现了某个接⼝,那么Spring AOP会使⽤JDKProxy,去创建代理对象,⽽对于没有实现接⼝的对象,就⽆法使⽤ JDK Proxy 去进⾏代理了,这时候Spring AOP会使⽤基于asm框架字节流的Cglib动态代理 ,这时候Spring AOP会使⽤ Cglib ⽣成⼀个被代理对象的⼦类来作为代理。

3、Bean生命周期

单例对象: singleton

总结:单例对象的生命周期和容器相同

多例对象: prototype

出生:使用对象时spring框架为我们创建

活着:对象只要是在使用过程中就一直活着

死亡:当对象长时间不用且没有其它对象引用时,由java的垃圾回收机制回收

img

IOC容器初始化加载Bean流程:

@Override
public void refresh() throws BeansException, IllegalStateException { synchronized (this.startupShutdownMonitor) {
  // 第一步:刷新前的预处理 
  prepareRefresh();
  //第二步: 获取BeanFactory并注册到 BeanDefitionRegistry
  ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
  // 第三步:加载BeanFactory的预准备工作(BeanFactory进行一些设置,比如context的类加载器等)
  prepareBeanFactory(beanFactory);
  try {
    // 第四步:完成BeanFactory准备工作后的前置处理工作 
    postProcessBeanFactory(beanFactory);
    // 第五步:实例化BeanFactoryPostProcessor接口的Bean 
    invokeBeanFactoryPostProcessors(beanFactory);
    // 第六步:注册BeanPostProcessor后置处理器,在创建bean的后执行 
    registerBeanPostProcessors(beanFactory);
    // 第七步:初始化MessageSource组件(做国际化功能;消息绑定,消息解析); 
    initMessageSource();
    // 第八步:注册初始化事件派发器 
    initApplicationEventMulticaster();
    // 第九步:子类重写这个方法,在容器刷新的时候可以自定义逻辑 
    onRefresh();
    // 第十步:注册应用的监听器。就是注册实现了ApplicationListener接口的监听器
    registerListeners();
    //第十一步:初始化所有剩下的非懒加载的单例bean 初始化创建非懒加载方式的单例Bean实例(未设置属性)
    finishBeanFactoryInitialization(beanFactory);
    //第十二步: 完成context的刷新。主要是调用LifecycleProcessor的onRefresh()方法,完成创建
    finishRefresh();
    }
  ……
} 

总结:

四个阶段

  • 实例化 Instantiation
  • 属性赋值 Populate
  • 初始化 Initialization
  • 销毁 Destruction

多个扩展点

  • 影响多个Bean
    • BeanPostProcessor
    • InstantiationAwareBeanPostProcessor
  • 影响单个Bean
    • Aware

完整流程

  1. 实例化一个Bean--也就是我们常说的new
  2. 按照Spring上下文对实例化的Bean进行配置--也就是IOC注入
  3. 如果这个Bean已经实现了BeanNameAware接口,会调用它实现的setBeanName(String)方法,也就是根据就是Spring配置文件中Bean的id和name进行传递
  4. 如果这个Bean已经实现了BeanFactoryAware接口,会调用它实现setBeanFactory(BeanFactory)也就是Spring配置文件配置的Spring工厂自身进行传递
  5. 如果这个Bean已经实现了ApplicationContextAware接口,会调用setApplicationContext(ApplicationContext)方法,和4传递的信息一样但是因为ApplicationContext是BeanFactory的子接口,所以更加灵活
  6. 如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessBeforeInitialization()方法,BeanPostProcessor经常被用作是Bean内容的更改,由于这个是在Bean初始化结束时调用那个的方法,也可以被应用于内存或缓存技
  7. 如果Bean在Spring配置文件中配置了init-method属性会自动调用其配置的初始化方法。
  8. 如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessAfterInitialization(),打印日志或者三级缓存技术里面的bean升级
  9. 以上工作完成以后就可以应用这个Bean了,那这个Bean是一个Singleton的,所以一般情况下我们调用同一个id的Bean会是在内容地址相同的实例,当然在Spring配置文件中也可以配置非Singleton,这里我们不做赘述。
  10. 当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean这个接口,或者根据spring配置的destroy-method属性,调用实现的destroy()方法

4、Bean作用域

名称 作用域
singleton 单例对象,默认值的作用域
prototype 每次获取都会创建⼀个新的 bean 实例
request 每⼀次HTTP请求都会产⽣⼀个新的bean,该bean仅在当前HTTP request内有效。
session 在一次 HTTP session 中,容器将返回同一个实例
global-session 将对象存入到web项目集群的session域中,若不存在集群,则global session相当于session

默认作用域是singleton,多个线程访问同一个bean时会存在线程不安全问题

保障线程安全方法:

  1. 在Bean对象中尽量避免定义可变的成员变量(不太现实)。

  2. 在类中定义⼀个ThreadLocal成员变量,将需要的可变成员变量保存在 ThreadLocal 中

    ThreadLocal

    ​ 每个线程中都有一个自己的ThreadLocalMap类对象,可以将线程自己的对象保持到其中,各管各的,线程可以正确的访问到自己的对象。

    ​ 将一个共用的ThreadLocal静态实例作为key,将不同对象的引用保存到不同线程的ThreadLocalMap中,然后在线程执行的各处通过这个静态ThreadLocal实例的get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。

5、循环依赖

​ 循环依赖其实就是循环引用,也就是两个或者两个以上的 Bean 互相持有对方,最终形成闭环。比如A 依赖于B,B又依赖于A

Spring中循环依赖场景有:

  • prototype 原型 bean循环依赖

  • 构造器的循环依赖(构造器注入)

  • Field 属性的循环依赖(set注入)

    其中,构造器的循环依赖问题无法解决,在解决属性循环依赖时,可以使用懒加载,spring采用的是提前暴露对象的方法。

懒加载@Lazy解决循环依赖问题

​ Spring 启动的时候会把所有bean信息(包括XML和注解)解析转化成Spring能够识别的BeanDefinition并存到Hashmap里供下面的初始化时用,然后对每个 BeanDefinition 进行处理。普通 Bean 的初始化是在容器启动初始化阶段执行的,而被lazy-init=true修饰的 bean 则是在从容器里第一次进行context.getBean() 时进行触发

三级缓存解决循环依赖问题

循环依赖问题

  1. Spring容器初始化ClassA通过构造器初始化对象后提前暴露到Spring容器中的singletonFactorys(三级缓存中)。

  2. ClassA调用setClassB方法,Spring首先尝试从容器中获取ClassB,此时ClassB不存在Spring 容器中。

  3. Spring容器初始化ClassB,ClasssB首先将自己暴露在三级缓存中,然后从Spring容器一级、二级、三级缓存中一次中获取ClassA 。

  4. 获取到ClassA后将自己实例化放入单例池中,实例 ClassA通过Spring容器获取到ClassB,完成了自己对象初始化操作。

  5. 这样ClassA和ClassB都完成了对象初始化操作,从而解决了循环依赖问题。

Spring注解

1、@SpringBoot

声明bean的注解

@Component 通⽤的注解,可标注任意类为 Spring 组件

@Service 在业务逻辑层使用(service层)

@Repository 在数据访问层使用(dao层)

@Controller 在展现层使用,控制器的声明(controller层)

注入bean的注解

@Autowired:默认按照类型来装配注入,@Qualifier:可以改成名称

@Resource:默认按照名称来装配注入,JDK的注解,新版本已经弃用

@Autowired注解原理

​ @Autowired的使用简化了我们的开发,

​ 实现 AutowiredAnnotationBeanPostProcessor 类,该类实现了 Spring 框架的一些扩展接口。
​ 实现 BeanFactoryAware 接口使其内部持有了 BeanFactory(可轻松的获取需要依赖的的 Bean)。
​ 实现 MergedBeanDefinitionPostProcessor 接口,实例化Bean 前获取到 里面的 @Autowired 信息并缓存下来;
​ 实现 postProcessPropertyValues 接口, 实例化Bean 后从缓存取出注解信息,通过反射将依赖对象设置到 Bean 属性里面。

@SpringBootApplication

@SpringBootApplication
public class JpaApplication {
    public static void main(String[] args) {
        SpringApplication.run(JpaApplication.class, args);
    }
}

@SpringBootApplication注解等同于下面三个注解:

  • @SpringBootConfiguration: 底层是Configuration注解,说白了就是支持JavaConfig的方式来进行配置
  • @EnableAutoConfiguration:开启自动配置功能
  • @ComponentScan:就是扫描注解,默认是扫描当前类下的package

其中@EnableAutoConfiguration是关键(启用自动配置),内部实际上就去加载META-INF/spring.factories文件的信息,然后筛选出以EnableAutoConfiguration为key的数据,加载到IOC容器中,实现自动配置功能!

它主要加载了@SpringBootApplication注解主配置类,这个@SpringBootApplication注解主配置类里边最主要的功能就是SpringBoot开启了一个@EnableAutoConfiguration注解的自动配置功能。

@EnableAutoConfiguration作用:

它主要利用了一个

EnableAutoConfigurationImportSelector选择器给Spring容器中来导入一些组件。

@Import(EnableAutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration 

2、@SpringMVC

@Controller 声明该类为SpringMVC中的Controller
@RequestMapping 用于映射Web请求
@ResponseBody 支持将返回值放在response内,而不是一个页面,通常用户返回json数据
@RequestBody 允许request的参数在request体中,而不是在直接连接在地址后面。
@PathVariable 用于接收路径参数
@RequestMapping("/hello/{name}")申明的路径,将注解放在参数中前,即可获取该值,通常作为Restful的接口实现方法。

SpringMVC原理

  1. 客户端(浏览器)发送请求,直接请求到 DispatcherServlet 。
  2. DispatcherServlet 根据请求信息调⽤ HandlerMapping ,解析请求对应的 Handler 。
  3. 解析到对应的 Handler (也就是 Controller 控制器)后,开始由HandlerAdapter 适配器处理。
  4. HandlerAdapter 会根据 Handler 来调⽤真正的处理器开处理请求,并处理相应的业务逻辑。
  5. 处理器处理完业务后,会返回⼀个 ModelAndView 对象, Model 是返回的数据对象
  6. ViewResolver 会根据逻辑 View 查找实际的 View 。
  7. DispaterServlet 把返回的 Model 传给 View (视图渲染)。
  8. 把 View 返回给请求者(浏览器)

3、@SpringMybatis

@Insert : 插入sql ,和xml insert sql语法完全一样
@Select : 查询sql, 和xml select sql语法完全一样
@Update : 更新sql, 和xml update sql语法完全一样
@Delete : 删除sql, 和xml delete sql语法完全一样
@Param : 入参
@Results : 设置结果集合@Result : 结果
@ResultMap : 引用结果集合
@SelectKey : 获取最新插入id 

mybatis如何防止sql注入?

​ 简单的说就是#{}是经过预编译的,是安全的,${}是未经过预编译的,仅仅是取变量的值,是非安全的,存在SQL注入。在编写mybatis的映射语句时,尽量采用“#{xxx}”这样的格式。如果需要实现动态传入表名、列名,还需要做如下修改:添加属性statementType=”STATEMENT”,同时sql里的属有变量取值都改成${xxxx}

Mybatis和Hibernate的区别

Hibernate 框架:

Hibernate是一个开放源代码的对象关系映射框架,它对JDBC进行了非常轻量级的对象封装,建立对象与数据库表的映射。是一个全自动的、完全面向对象的持久层框架。

Mybatis框架:

Mybatis是一个开源对象关系映射框架,原名:ibatis,2010年由谷歌接管以后更名。是一个半自动化的持久层框架。

区别:

开发方面

​ 在项目开发过程当中,就速度而言:

​ hibernate开发中,sql语句已经被封装,直接可以使用,加快系统开发;

​ Mybatis 属于半自动化,sql需要手工完成,稍微繁琐;

​ 但是,凡事都不是绝对的,如果对于庞大复杂的系统项目来说,复杂语句较多,hibernate 就不是好方案。

sql优化方面

​ Hibernate 自动生成sql,有些语句较为繁琐,会多消耗一些性能;

​ Mybatis 手动编写sql,可以避免不需要的查询,提高系统性能;

对象管理比对

​ Hibernate 是完整的对象-关系映射的框架,开发工程中,无需过多关注底层实现,只要去管理对象即可;

​ Mybatis 需要自行管理映射关系;

4、@Transactional

@EnableTransactionManagement 
@Transactional

注意事项:

​ ①事务函数中不要处理耗时任务,会导致长期占有数据库连接。

​ ②事务函数中不要处理无关业务,防止产生异常导致事务回滚。

事务传播属性

1) REQUIRED(默认属性) 如果存在一个事务,则支持当前事务。如果没有事务则开启一个新的事务。

2) MANDATORY 支持当前事务,如果当前没有事务,就抛出异常。

3) NEVER 以非事务方式执行,如果当前存在事务,则抛出异常。

4) NOT_SUPPORTED 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

5) REQUIRES_NEW 新建事务,如果当前存在事务,把当前事务挂起。

6) SUPPORTS 支持当前事务,如果当前没有事务,就以非事务方式执行。

7) NESTED局部回滚) 支持当前事务,新增Savepoint点,与当前事务同步提交或回滚。 嵌套事务一个非常重要的概念就是内层事务依赖于外层事务。外层事务失败时,会回滚内层事务所做的动作。而内层事务操作失败并不会引起外层事务的回滚。

Spring源码阅读

1、Spring中的设计模式

参考:spring中的设计模式

单例设计模式 : Spring 中的 Bean 默认都是单例的。

⼯⼚设计模式 : Spring使⽤⼯⼚模式通过 BeanFactory 、 ApplicationContext 创建bean 对象。

代理设计模式 : Spring AOP 功能的实现。

观察者模式: Spring 事件驱动模型就是观察者模式很经典的⼀个应⽤。

适配器模式:Spring AOP 的增强或通知(Advice)使⽤到了适配器模式、spring MVC 中也是⽤到了适配器模式适配 Controller 。

八、SpringCloud篇

Why SpringCloud

​ Spring cloud 是一系列框架的有序集合。它利用 spring boot 的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册配置中心消息总线负载均衡断路器数据监控等,都可以用 spring boot 的开发风格做到一键启动和部署。

SpringCloud(微服务解决方案) Dubbo(分布式服务治理框架)
Rest API (轻量、灵活、swagger) RPC远程调用(高效、耦合)
Eureka、Nacos Zookeeper
使用方便 性能好
即将推出SpringCloud2.0 断档5年后17年重启

​ SpringBoot是Spring推出用于解决传统框架配置文件冗余,装配组件繁杂的基于Maven的解决方案,旨在快速搭建单个微服务,SpringCloud是依赖于SpringBoot的,而SpringBoot并不是依赖与SpringCloud,甚至还可以和Dubbo进行优秀的整合开发

​ MartinFlower 提出的微服务之间是通过RestFulApi进行通信,具体实现

  • RestTemplate:基于HTTP协议
  • Feign:封装了ribbon和Hystrix 、RestTemplate 简化了客户端开发工作量
  • RPC:基于TCP协议,序列化和传输效率提升明显
  • MQ:异步解耦微服务之间的调用

img

Spring Boot

Spring Boot 通过简单的步骤就可以创建一个 Spring 应用。

Spring Boot 为 Spring 整合第三方框架提供了开箱即用功能

Spring Boot 的核心思想是约定大于配置

Spring Boot 解决的问题

  • 搭建后端框架时需要手动添加 Maven 配置,涉及很多 XML 配置文件,增加了搭建难度和时间成本。

  • 将项目编译成 war 包,部署到 Tomcat 中,项目部署依赖 Tomcat,这样非常不方便。

  • 应用监控做的比较简单,通常都是通过一个没有任何逻辑的接口来判断应用的存活状态。

Spring Boot 优点

自动装配:Spring Boot 会根据某些规则对所有配置的 Bean 进行初始化。可以减少了很多重复性的工作。

​ 比如使用 MongoDB 时,只需加入 MongoDB 的 Starter 包,然后配置 的连接信息,就可以直接使用 MongoTemplate 自动装配来操作数据库了。简化了 Maven Jar 包的依赖,降低了烦琐配置的出错几率。

内嵌容器:Spring Boot 应用程序可以不用部署到外部容器中,比如 Tomcat。

​ 应用程序可以直接通过 Maven 命令编译成可执行的 jar 包,通过 java-jar 命令启动即可,非常方便。

应用监控:Spring Boot 中自带监控功能 Actuator,可以实现对程序内部运行情况进行监控,

​ 比如 Bean 加载情况、环境变量、日志信息、线程信息等。当然也可以自定义跟业务相关的监控,通过Actuator 的端点信息进行暴露。

spring-boot-starter-web          //用于快速构建基于 Spring MVC 的 Web 项目。
spring-boot-starter-data-redis   //用于快速整合并操作 Redis。
spring-boot-starter-data-mongodb //用于对 MongoDB 的集成。
spring-boot-starter-data-jpa     //用于操作 MySQL。

自定义一个Starter

  1. 创建 Starter 项目,定义 Starter 需要的配置(Properties)类,比如数据库的连接信息;

  2. 编写自动配置类,自动配置类就是获取配置,根据配置来自动装配 Bean;

  3. 编写 spring.factories 文件加载自动配置类,Spring 启动的时候会扫描 spring.factories 文件,;

  4. 编写配置提示文件 spring-configuration-metadata.json(不是必须的),在添加配置的时候,我们想要知道具体的配置项是什么作用,可以通过编写提示文件来提示;

  5. 在项目中引入自定义 Starter 的 Maven 依赖,增加配置值后即可使用。

Spring Boot Admin(将 actuator 提供的数据进行可视化)

  • 显示应用程序的监控状态、查看 JVM 和线程信息

  • 应用程序上下线监控

  • 可视化的查看日志、动态切换日志级别

  • HTTP 请求信息跟踪等实用功能

GateWay / Zuul

GateWay⽬标是取代Netflflix Zuul,它基于Spring5.0+SpringBoot2.0+WebFlux等技术开发,提供统⼀的路由⽅式(反向代理)并且基于 Filter(定义过滤器对请求过滤,完成⼀些功能) 链的⽅式提供了⽹关基本的功能,例如:鉴权、流量控制、熔断、路径重写、⽇志监控。

组成:

  • 路由route: ⽹关最基础的⼯作单元。路由由⼀个ID、⼀个⽬标URL、⼀系列的断⾔(匹配条件判断)和Filter过滤器组成。如果断⾔为true,则匹配该路由。

  • 断⾔predicates:参考了Java8中的断⾔Predicate,匹配Http请求中的所有内容(类似于nginx中的location匹配⼀样),如果断⾔与请求相匹配则路由。

  • 过滤器filter:标准的Spring webFilter,使⽤过滤器在请求之前或者之后执⾏业务逻辑。

    请求前pre类型过滤器:做参数校验权限校验流量监控⽇志输出协议转换等,

    请求前post类型的过滤器:做响应内容响应头的修改、⽇志的输出流量监控等。

image-20210105001419761

GateWayFilter 应⽤到单个路由路由上 、GlobalFilter 应⽤到所有的路由上

Eureka / Zookeeper

服务注册中⼼本质上是为了解耦服务提供者和服务消费者,为了⽀持弹性扩缩容特性,⼀个微服务的提供者的数量和分布往往是动态变化的。

image-20210103231405882

区别 Zookeeper Eureka Nacos
CAP CP AP CP/AP切换
可用性 选举期间不可用 自我保护机制,数据不是最新的
组成 Leader和Follower 节点平等
优势 分布式协调 注册与发现 注册中心和配置中心
底层 进程 服务 Jar包

Eureka通过⼼跳检测健康检查客户端缓存等机制,提⾼系统的灵活性、可伸缩性和可⽤性。

image-20210103232900353

  1. us-east-1c、us-east-1d,us-east-1e代表不同的机房,每⼀个Eureka Server都是⼀个集群
  2. Service作为服务提供者向Eureka中注册服务,Eureka接受到注册事件会在集群和分区中进⾏数据同步,Client作为消费端(服务消费者)可以从Eureka中获取到服务注册信息,进⾏服务调⽤。
  3. 微服务启动后,会周期性地向Eureka发送⼼跳(默认周期为30秒)以续约⾃⼰的信息
  4. Eureka在⼀定时间内(默认90秒)没有接收到某个微服务节点的⼼跳,Eureka将会注销该微服务节点
  5. Eureka Client会缓存Eureka Server中的信息。即使所有的Eureka Server节点都宕掉,服务消费者依然可以使⽤缓存中的信息找到服务提供者

Eureka缓存

新服务上线后,服务消费者不能立即访问到刚上线的新服务,需要过⼀段时间后才能访问?或是将服务下线后,服务还是会被调⽤到,⼀段时候后才彻底停⽌服务,访问前期会导致频繁报错!

image-20210103233902439

​ 服务注册到注册中⼼后,服务实例信息是存储在Registry表中的,也就是内存中。但Eureka为了提⾼响应速度,在内部做了优化,加⼊了两层的缓存结构,将Client需要的实例信息,直接缓存起来,获取的时候直接从缓存中拿数据然后响应给 Client。

  • 第⼀层缓存是readOnlyCacheMap,采⽤ConcurrentHashMap来存储数据的,主要负责定时与readWriteCacheMap进⾏数据同步,默认同步时间为 30 秒⼀次。

  • 第⼆层缓存是readWriteCacheMap,采⽤Guava来实现缓存。缓存过期时间默认为180秒,当服务下线、过期、注册、状态变更等操作都会清除此缓存中的数据。

  • 如果两级缓存都无法查询,会触发缓存的加载,从存储层拉取数据到缓存中,然后再返回给 Client。

    Eureka之所以设计⼆级缓存机制,也是为了提⾼ Eureka Server 的响应速度,缺点是缓存会导致 Client获取不到最新的服务实例信息,然后导致⽆法快速发现新的服务和已下线的服务。

解决方案

  • 我们可以缩短读缓存的更新时间让服务发现变得更加及时,或者直接将只读缓存关闭,同时可以缩短客户端如ribbon服务的定时刷新间隔,多级缓存也导致C层⾯(数据⼀致性)很薄弱。
  • Eureka Server 中会有定时任务去检测失效的服务,将服务实例信息从注册表中移除,也可以将这个失效检测的时间缩短,这样服务下线后就能够及时从注册表中清除。

自我保护机制开启条件

  • 期望最小每分钟能够续租的次数(实例 频率 比例==10 2 0.85)
  • 期望的服务实例数量(10)

健康检查

  • Eureka Client 会定时发送心跳给 Eureka Server 来证明自己处于健康的状态

  • 集成SBA以后可以把所有健康状态信息一并返回给eureka

Feign / Ribbon

  • Feign 可以与 Eureka 和 Ribbon 组合使用以支持负载均衡,
  • Feign 可以与 Hystrix 组合使用,支持熔断回退
  • Feign 可以与ProtoBuf实现快速的RPC调用

img

  • InvocationHandlerFactory 代理

    采用 JDK 的动态代理方式生成代理对象,当我们调用这个接口,实际上是要去调用远程的 HTTP API

  • Contract 契约组件

    比如请求类型是 GET 还是 POST,请求的 URI 是什么

  • Encoder 编码组件 \ Decoder 解码组件

    通过该组件我们可以将请求信息采用指定的编码方式进行编解码后传输

  • Logger 日志记录

    负责 Feign 中记录日志的,可以指定 Logger 的级别以及自定义日志的输出

  • Client 请求执行组件

    负责 HTTP 请求执行的组件,Feign 中默认的 Client 是通过 JDK 的 HttpURLConnection 来发起请求的,在每次发送请求的时候,都会创建新的 HttpURLConnection 链接,Feign 的性能会很差,可以通过扩展该接口,使用 Apache HttpClient 等基于连接池的高性能 HTTP 客户端。

  • Retryer 重试组件

    负责重试的组件,Feign 内置了重试器,当 HTTP 请求出现 IO 异常时,Feign 会限定一个最大重试次数来进行重试操作。

  • RequestInterceptor 请求拦截器

    可以为 Feign 添加多个拦截器,在请求执行前设置一些扩展的参数信息。

Feign最佳使用技巧

  • 继承特性

  • 拦截器

    比如添加指定的请求头信息,这个可以用在服务间传递某些信息的时候。

  • GET 请求多参数传递

  • 日志配置

    FULL 会输出全部完整的请求信息。

  • 异常解码器

    异常解码器中可以获取异常信息,而不是简单的一个code,然后转换成对应的异常对象返回。

  • 源码查看是如何继承Hystrix

    HystrixFeign.builder 中可以看到继承了 Feign 的 Builder,增加了 Hystrix的SetterFactory, build 方法里,对 invocationHandlerFactory 进行了重写, create 的时候返回HystrixInvocationHandler, 在 invoke 的时候会将请求包装成 HystrixCommand 去执行,这里就自然的集成了 Hystrix

Ribbon

img

使用方式

  • 原生 API,Ribbon 是 Netflix 开源的,没有使用 Spring Cloud,需要使用 Ribbon 的原生 API。

  • Ribbon + RestTemplate,整合Spring Cloud 后,可以基于 RestTemplate 提供负载均衡的服务

  • Ribbon + Feign

    img

负载均衡算法

  • RoundRobinRule 是轮询的算法,A和B轮流选择。

  • RandomRule 是随机算法,这个就比较简单了,在服务列表中随机选取。

  • BestAvailableRule 选择一个最小的并发请求 server

自定义负载均衡算法

  • 实现 Irule 接口
  • 继承 AbstractLoadBalancerRule 类

自定义负载均衡使用场景(核心)

  • 灰度发布

    灰度发布是能够平滑过渡的一种发布方式,在发布过程中,先发布一部分应用,让指定的用户使用刚发布的应用,等到测试没有问题后,再将其他的全部应用发布。如果新发布的有问题,只需要将这部分恢复即可,不用恢复所有的应用。

  • 多版本隔离

    多版本隔离跟灰度发布类似,为了兼容或者过度,某些应用会有多个版本,这个时候如何保证 1.0 版本的客户端不会调用到 1.1 版本的服务,就是我们需要考虑的问题。

  • 故障隔离

    当线上某个实例发生故障后,为了不影响用户,我们一般都会先留存证据,比如:线程信息、JVM 信息等,然后将这个实例重启或直接停止。然后线下根据一些信息分析故障原因,如果我能做到故障隔离,就可以直接将出问题的实例隔离,不让正常的用户请求访问到这个出问题的实例,只让指定的用户访问,这样就可以单独用特定的用户来对这个出问题的实例进行测试、故障分析等。

Hystrix / Sentinel

服务雪崩场景

自己即是服务消费者,同时也是服务提供者,同步调用等待结果导致资源耗尽

解决方案

服务方:扩容、限流,排查代码问题,增加硬件监控

消费方:使用Hystrix资源隔离,熔断降级,快速失败

img

Hystrix断路保护器的作用

  • 封装请求会将用户的操作进行统一封装,统一封装的目的在于进行统一控制。
  • 资源隔离限流会将对应的资源按照指定的类型进行隔离,比如线程池信号量
    • 计数器限流,例如5秒内技术1000请求,超数后限流,未超数重新计数
    • 滑动窗口限流,解决计数器不够精确的问题,把一个窗口拆分多滚动窗口
    • 令牌桶限流,类似景区售票,售票的速度是固定的,拿到令牌才能去处理请求
    • 漏桶限流,生产者消费者模型,实现了恒定速度处理请求,能够绝对防止突发流量
  • 失败回退其实是一个备用的方案,就是说当请求失败后,有没有备用方案来满足这个请求的需求。
  • 断路器这个是最核心的,,如果断路器处于打开的状态,那么所有请求都将失败,执行回退逻辑。如果断路器处于关闭状态,那么请求将会被正常执行。有些场景我们需要手动打开断路器强制降级
  • 指标监控会对请求的生命周期进行监控,请求成功、失败、超时、拒绝等状态,都会被监控起来。

Hystrix使用上遇到的坑

  • 配置可以对接配置中心进行动态调整

    Hystrix 的配置项非常多,如果不对接配置中心,所有的配置只能在代码里修改,在集群部署的难以应对紧急情况,我们项目只设置一个 CommandKey,其他的都在配置中心进行指定,紧急情况如需隔离部分请求时,只需在配置中心进行修改以后,强制更新即可。

  • 回退逻辑中可以手动埋点或者通过输出日志进行告警

    当请求失败或者超时,会执行回退逻辑,如果有大量的回退,则证明某些服务出问题了,这个时候我们可以在回退的逻辑中进行埋点操作,上报数据给监控系统,也可以输出回退的日志,统一由日志收集的程序去进行处理,这些方式都可以将问题暴露出去,然后通过实时数据分析进行告警操作

  • ThreadLocal配合线程池隔离模式需当心

    当我们用了线程池隔离模式的时候,被隔离的方法会包装成一个 Command 丢入到独立的线程池中进行执行,这个时候就是从 A 线程切换到了 B 线程,ThreadLocal 的数据就会丢失

  • Gateway中多用信号量隔离

    网关是所有请求的入口,路由的服务数量会很多,几十个到上百个都有可能,如果用线程池隔离,那么需要创建上百个独立的线程池,开销太大,用信号量隔离开销就小很多,还能起到限流的作用。

[^常见问题]: Hystrix的超时时间要⼤于Ribbon的超时时间,因为Hystrix将请求包装了起来,特别需要注意的是,如果Ribbon开启了重试机制,⽐如重试3 次,Ribbon 的超时为 1 秒,那么Hystrix 的超时时间应该⼤于 3 秒,否则就会出现 Ribbon 还在重试中,⽽ Hystrix 已经超时的现象。

Sentinel

Sentinel是⼀个⾯向云原⽣微服务的流量控制、熔断降级组件。

替代Hystrix,针对问题:服务雪崩、服务降级、服务熔断、服务限流

Hystrix区别:

  • 独⽴可部署Dashboard(基于 Spring Boot 开发)控制台组件
  • 不依赖任何框架/库,减少代码开发,通过UI界⾯配置即可完成细粒度控制

image-20210104212151598

丰富的应⽤场景:Sentinel 承接了阿⾥巴巴近 10 年的双⼗⼀⼤促流量的核⼼场景,例如秒杀、消息削峰填⾕、集群流量控制、实时熔断下游不可⽤应⽤等。

完备的实时监控:可以看到500 台以下规模的集群的汇总也可以看到单机的秒级数据。

⼴泛的开源⽣态:与 SpringCloud、Dubbo的整合。您只需要引⼊相应的依赖并进⾏简单的配置即可快速地接⼊ Sentinel。

区别:

  • Sentinel不会像Hystrix那样放过⼀个请求尝试⾃我修复,就是明明确确按照时间窗⼝来,熔断触发后,时间窗⼝内拒绝请求,时间窗⼝后就恢复。
  • Sentinel Dashboard中添加的规则数据存储在内存,微服务停掉规则数据就消失,在⽣产环境下不合适。可以将Sentinel规则数据持久化到Nacos配置中⼼,让微服务从Nacos获取。
# Sentinel Hystrix
隔离策略 信号量隔离 线程池隔离/信号量隔离
熔断降级策略 基于响应时间或失败比率 基于失败比率
实时指标实现 滑动窗口 滑动窗口(基于 RxJava)
扩展性 多个扩展点 插件的形式
限流 基于 QPS,支持基于调用关系的限流 不支持
流量整形 支持慢启动、匀速器模式 不支持
系统负载保护 支持 不支持
控制台 开箱即用,可配置规则、查看秒级监控、机器发现等 不完善
常见框架的适配 Servlet、Spring Cloud、Dubbo、gRPC Servlet、Spring Cloud Netflix

Config / Nacos

Nacos是阿⾥巴巴开源的⼀个针对微服务架构中服务发现配置管理服务管理平台

Nacos就是注册中⼼+配置中⼼的组合(Nacos=Eureka+Confifig+Bus)

Nacos功能特性

  • 服务发现与健康检查
  • 动态配置管理
  • 动态DNS服务
  • 服务和元数据管理

保护阈值:

当服务A健康实例数/总实例数 < 保护阈值 的时候,说明健康实例真的不多了,这个时候保护阈值会被触发(状态true),nacos将会把该服务所有的实例信息(健康的+不健康的)全部提供给消费者,消费者可能访问到不健康的实例,请求失败,但这样也⽐造成雪崩要好,牺牲了⼀些请求,保证了整个系统的⼀个可⽤。

Nacos 数据模型(领域模型)

  • Namespace 代表不同的环境,如开发dev、测试test、⽣产环境prod
  • Group 代表某项⽬,⽐如爪哇云项⽬
  • Service 某个项⽬中具体xxx服务
  • DataId 某个项⽬中具体的xxx配置⽂件

可以通过 Spring Cloud 原⽣注解 @RefreshScope 实现配置⾃动更新

Bus / Stream

Spring Cloud Stream 消息驱动组件帮助我们更快速,更⽅便的去构建消息驱动微服务的

本质:屏蔽掉了底层不同MQ消息中间件之间的差异,统⼀了MQ的编程模型,降低了学习、开发、维护MQ的成本,⽬前⽀持Rabbit、Kafka两种消息

Sleuth / Zipkin

全链路追踪

image-20210104234058218

Trace ID:当请求发送到分布式系统的⼊⼝端点时,Sleuth为该请求创建⼀个唯⼀的跟踪标识Trace ID,在分布式系统内部流转的时候,框架始终保持该唯⼀标识,直到返回给请求⽅

Span ID:为了统计各处理单元的时间延迟,当请求到达各个服务组件时,也是通过⼀个唯⼀标识SpanID来标记它的开始,具体过程以及结束。

Spring Cloud Sleuth (追踪服务框架)可以追踪服务之间的调⽤,Sleuth可以记录⼀个服务请求经过哪些服务、服务处理时⻓等,根据这些,我们能够理清各微服务间的调⽤关系及进⾏问题追踪分析。

耗时分析:通过 Sleuth 了解采样请求的耗时,分析服务性能问题(哪些服务调⽤⽐较耗时)

链路优化:发现频繁调⽤的服务,针对性优化等

聚合展示:数据信息发送给 Zipkin 进⾏聚合,利⽤ Zipkin 存储并展示数据。

安全认证

  • Session

    认证中最常用的一种方式,也是最简单的。存在多节点session丢失的情况,可通过nginx粘性Cookie和Redis集中式Session存储解决

  • HTTP Basic Authentication

    服务端针对请求头中base64加密的Authorization 和用户名和密码进行校验

  • Token

    Session 只是一个 key,会话信息存储在后端。而 Token 中会存储用户的信息,然后通过加密算法进行加密,只有服务端才能解密,服务端拿到 Token 后进行解密获取用户信息

  • JWT认证

JWT(JSON Web Token)用户提供用户名和密码给认证服务器,服务器验证用户提交信息的合法性;如果验证成功,会产生并返回一个 Token,用户可以使用这个 Token 访问服务器上受保护的资源。

img

  1. 认证服务提供认证的 API,校验用户信息,返回认证结果
  2. 通过JWTUtils中的RSA算法,生成JWT token,token里封装用户id和有效期
  3. 服务间参数通过请求头进行传递,服务内部通过 ThreadLocal 进行上下文传递。
  4. Hystrix导致ThreadLocal失效的问题可以通过,重写 Hystrix 的 Callable 方法,传递需要的数据。

Token最佳实践

  • 设置较短(合理)的过期时间

  • 注销的 Token 及时清除(放入 Redis 中做一层过滤)。

    虽然不能修改 Token 的信息,但是能在验证层面做一层过滤来进行处理。

  • 监控 Token 的使用频率

    为了防止数据被别人爬取,最常见的就是监控使用频率,程序写出来的爬虫程序访问频率是有迹可循的

  • 核心功能敏感操作可以使用动态验证(验证码)。

    比如提现的功能,要求在提现时再次进行验证码的验证,防止不是本人操作。

  • 网络环境、浏览器信息等识别。

    银行 APP 对环境有很高的要求,使用时如果断网,APP 会自动退出,重新登录,因为网络环境跟之前使用的不一样了,还有一些浏览器的信息之类的判断,这些都是可以用来保证后端 API 的安全。

  • 加密密钥支持动态修改。

    如果 Token 的加密密钥泄露了,也就意味着别人可以伪造你的 Token,可以将密钥存储在配置中心,以支持动态修改刷新,需要注意的是建议在流量低峰的时候去做更换的操作,否则 Token 全部失效,所有在线的请求都会重新申请 Token,并发量会比较大。

灰度发布

痛点:

  • 服务数量多,业务变动频繁,一周一发布

  • 灰度发布能降低发布失败风险,减少影响范围

    通过灰度发布,先让一部分用户体验新的服务,或者只让测试人员进行测试,等功能正常后再全部发布,这样能降低发布失败带来的影响范围。

  • 当发布出现故障时,可以快速回滚,不影响用户

    灰度后如果发现这个节点有问题,那么只需回滚这个节点即可,当然不回滚也没关系,通过灰度策略隔离,也不会影响正常用户

可以通过Ribbon的负载均衡策略进行灰度发布,可以使用更可靠的Discovery

Discovery

基于Discovery 服务注册发现、Ribbon 负载均衡、Feign 和 RestTemplate 调用等组件的企业级微服务开源解决方案,包括灰度发布、灰度路由、服务隔离等功能

img

  1. 首先将需要发布的服务从转发过程中移除,等流量剔除之后再发布。

  2. 部分机器中的版本进行升级,用户默认还是请求老的服务,通过版本来支持测试请求。

  3. 测试完成之后,让新的版本接收正常流量,然后部署下一个节点,以此类推。

grayVersions = {"discovery-article-service":["1.01"]}

多版本隔离

img

本地复用测试服务-Eureka Zone亮点

region 地理上的分区,比如北京、上海等

zone 可以简单理解为 region 内的具体机房

​ 在调用的过程中会优先选择相同的 zone 发起调用,当找不到相同名称的 zone 时会选择其他的 zone 进行调用,我们可以利用这个特性来解决本地需要启动多个服务的问题。

[^]: 当你访问修改的服务 A 时,这个服务依赖了 B、C 两个服务,B 和 C 本地没有启动,B 和 C 找不到相同的 zone 就会选择其他的 zone 进行调用,也就是会调用到测试环境部署的 B 和 C 服务,这样一来就解决了本地部署多个服务的问题。

各组件调优

当你对网关进行压测时,会发现并发量一直上不去,错误率也很高。因为你用的是默认配置,这个时候我们就需要去调整配置以达到最优的效果。

首先我们可以对容器进行调优,最常见的就是内置的 Tomcat 容器了,

server.tomcat.accept-count //请求队列排队数
server.tomcat.max-threads //最大线程数
server.tomcat.max-connections //最大连接数

Hystrix 的信号量(semaphore)隔离模式,并发量上不去很大的原因都在这里,信号量默认值是 100,也就是最大并发只有 100,超过 100 就得等待。

//信号量
zuul.semaphore.max-semaphores //信号量:最大并发数
//线程池
hystrix.threadpool.default.coreSize //最大线程数
hystrix.threadpool.default.maximumSize //队列的大
hystrix.threadpool.default.maxQueueSize //等参数

配置Gateway并发信息,

gateway.host.max-per-route-connections //每个路由的连接数 
gateway.host.max-total-connections //总连接数

调整Ribbon 的并发配置,

ribbon.MaxConnectionsPerHost //单服务并发数
ribbon.MaxTotalConnections   //总并发数

修改Feign默认的HttpURLConnection 替换成 httpclient 来提高性能

feign.httpclient.max-connections-per-route//每个路由的连接数
feign.httpclient.max-connections //总连接数

Gateway+配置中心实现动态路由

Feign+配置中心实现动态日志

九、分布式篇

分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统。

发展历程

  • 入口级负载均衡
    • 网关负载均衡
    • 客户端负载均衡
  • 单应用架构

    • 应用服务和数据服务分离
    • 应用服务集群
    • 应用服务中心化SAAS
  • 数据库主备读写分离

    • 全文搜索引擎加快数据统计
    • 缓存集群缓解数据库读压力
    • 分布式消息中间件缓解数据库写压力
    • 数据库水平拆分适应微服务
    • 数据库垂直拆分解决慢查询
  • 划分上下文拆分微服务

    • 服务注册发现(Eureka、Nacos)
    • 配置动态更新(Config、Apollo)
    • 业务灰度发布(Gateway、Feign)
    • 统一安全认证(Gateway、Auth)
    • 服务降级限流(Hystrix、Sentinel)
    • 接口检查监控(Actuator、Prometheus)
    • 服务全链路追踪(Sleuth、Zipkin)

CAP

  • 一致性(2PC、3PC、Paxos、Raft)
    • 强一致性:数据库一致性,牺牲了性能
      • ACID:原子性、一致性、隔离性、持久性
    • 弱一致性:数据库和缓存延迟双删、重试
    • 单调读一致性:缓存一致性,ID或者IP哈希
    • 最终一致性:边缘业务,消息队列
  • 可用性(多级缓存、读写分离)
    • BASE 基本可用:限流导致响应速度慢、降级导致用户体验差
      • Basically Availabe 基本可用
      • Soft state 软状态
      • Eventual Consistency 最终一致性
  • 分区容忍性(一致性Hash解决扩缩容问题)

一致性

XA方案

2PC协议:两阶段提交协议,P是指准备阶段,C是指提交阶段

  • 准备阶段:询问是否可以开始,写Undo、Redo日志,收到响应
  • 提交阶段:执行Redo日志进行Commit,执行Undo日志进行Rollback

3PC协议:将提交阶段分为CanCommitPreCommitDoCommit三个阶段

CanCommit:发送canCommit请求,并开始等待

PreCommit:收到全部Yes,写Undo、Redo日志。超时或者No,则中断

DoCommit:执行Redo日志进行Commit,执行Undo日志进行Rollback

区别是第二步,参与者自身增加了超时,如果失败可以及时释放资源

Paxos算法

如何在一个发生异常的分布式系统中,快速且正确地在集群内部对某个数据的值达成一致

​ 参与者(例如Kafka)的一致性可以由协调者(例如Zookeeper)来保证,协调者的一致性就只能由Paxos保证了

Paxos算法中的角色:

  • Client:客户端、例如,对分布式文件服务器中文件的写请求。
  • Proposer:提案发起者,根据Accept返回选择最大N对应的V,发送[N+1,V]
  • Acceptor:决策者,Accept以后会拒绝小于N的提案,并把自己的[N,V]返回给Proposer
  • Learners:最终决策的学习者、学习者充当该协议的复制因素
//算法约束
P1:一个Acceptor必须接受它收到的第一个提案。
//考虑到半数以上才作数,一个Accpter得接受多个相同v的提案
P2a:如果某个v的提案被accept,那么被Acceptor接受编号更高的提案必须也是v
P2b:如果某个v的提案被accept,那么从Proposal提出编号更高的提案必须也是v
//如何确保v的提案Accpter被选定后,Proposal都能提出编号更高的提案呢
针对任意的[Mid,Vid],有半数以上的Accepter集合S,满足以下二选一:
  S中接受的提案都大于Mid
  S中接受的提案若小于Mid,编号最大的那个值为Vid

image-20210112225118095

面试题:如何保证Paxos算法活性

​ 假设存在这样一种极端情况,有两个Proposer依次提出了一系列编号递增的提案,导致最终陷入死循环,没有value被选定

  • 通过选取主Proposer,规定只有主Proposer才能提出议案。只要主Proposer和过半的Acceptor能够正常网络通信,主Proposer提出一个编号更高的提案,该提案终将会被批准。
  • 每个Proposer发送提交提案的时间设置为一段时间内随机,保证不会一直死循环

ZAB算法

Raft算法

Raft 是一种为了管理复制日志的一致性算法

Raft使用心跳机制来触发选举。当server启动时,初始状态都是follower。每一个server都有一个定时器,超时时间为election timeout(一般为150-300ms),如果某server没有超时的情况下收到来自领导者或者候选者的任何消息,定时器重启,如果超时,它就开始一次选举

Leader异常:异常期间Follower会超时选举,完成后Leader比较彼此步长

Follower异常:恢复后直接同步至Leader当前状态

多个Candidate:选举时失败,失败后超时继续选举

数据库和Redis的一致性

全量缓存保证高效读取

image-20211205121457205

所有数据都存储在缓存里,读服务在查询时不会再降级到数据库里,所有的请求都完全依赖缓存。此时,因降级到数据库导致的毛刺问题就解决了。但全量缓存并没有解决更新时的分布式事务问题,反而把问题放大了。因为全量缓存对数据更新要求更加严格,要求所有数据库已有数据和实时更新的数据必须完全同步至缓存,不能有遗漏。对于此问题,一种有效的方案是采用订阅数据库的 Binlog 实现数据同步

image-20211205121611997

​ 现在很多开源工具(如阿里的 Canal等)可以模拟主从复制的协议。通过模拟协议读取主数据库的 Binlog 文件,从而获取主库的所有变更。对于这些变更,它们开放了各种接口供业务服务获取数据。

image-20211205121730145

​ 将 Binlog 的中间件挂载至目标数据库上,就可以实时获取该数据库的所有变更数据。对这些变更数据解析后,便可直接写入缓存里。优点还有:

  • 大幅提升了读取的速度,降低了延迟

  • Binlog 的主从复制是基于 ACK 机制, 解决了分布式事务的问题

    如果同步缓存失败了,被消费的 Binlog 不会被确认,下一次会重复消费,数据最终会写入缓存中

缺点不可避免:1、增加复杂度 2、消耗缓存资源 3、需要筛选和压缩数据 4、极端情况数据丢失

image-20211205121850418

可以通过异步校准方案进行补齐,但是会损耗数据库性能。但是此方案会隐藏中间件使用错误的细节,线上环境前期更重要的是记录日志排查在做后续优化,不能本末倒置。

可用性

心跳检测

固定的频率向其他节点汇报当前节点状态的方式。收到心跳,说明网络和节点的状态是健康的。心跳汇报时,一般会携带一些附加的状态、元数据,以便管理

周期检测心跳机制:超时未返回

累计失效检测机制:重试超次数

多机房实时热备

image-20211205122631299

两套缓存集群可以分别部署到不同城市的机房。读服务也相应地部署到不同城市或不同分区。在承接请求时,不同机房或分区的读服务只依赖同样属性的缓存集群。此方案有两个好处。

  1. 提升了性能。读服务不要分层,读服务要尽可能地和缓存数据源靠近。
  2. 增加了可用。当单机房出现故障时,可以秒级将所有流量都切换至存活的机房或分区

此方案虽然带来了性能和可用性的提升,但代价是资源成本的上升。

分区容错性

分布式系统对于错误包容的能力

通过限流、降级、兜底、重试、负载均衡等方式增强系统的健壮性

日志复制

image-20210114154435003

  1. Leader把指令添加到日志中,发起 RPC 给其他的服务器,让他们复制这条信息
  2. Leader会不断的重试,直到所有的 Follower响应了ACK并复制了所有的日志条目
  3. 通知所有的Follower提交,同时Leader该表这条日志的状态,并返回给客户端

主备(Master-Slave)

​ 主机宕机时,备机接管主机的一切工作,主机恢复正常后,以自动(热备)或手动(冷备)方式将服务切换到主机上运行,MysqlRedis中常用。

​ MySQL之间数据复制的基础是二进制日志文件(binary log fifile)。它的数据库中所有操作都会以“事件”的方式记录在二进制日志中,其他数据库作为slave通过一个I/O线程与主服务器保持通信,并监控master的二进制日志文件的变化,如果发现master二进制日志文件发生变化,则会把变化复制到自己的中继日志中,然后slave的一个SQL线程会把相关的“事件”执行到自己的数据库中,以此实现从数据库和主数据库的一致性,也就实现了主从复制

互备(Active-Active)

​ 指两台主机同时运行各自的服务工作且相互监测情况。在数据库高可用部分,常见的互备是MM模式。MM模式即Multi-Master模式,指一个系统存在多个master,每个master都具有read-write能力,会根据时间戳业务逻辑合并版本。

集群(Cluster)模式

​ 是指有多个节点在运行,同时可以通过主控节点分担服务请求。如Zookeeper。集群模式需要解决主控节点本身的高可用问题,一般采用主备模式。

分布式事务

XA方案

两阶段提交 | 三阶段提交

  • 准备阶段的资源锁定,存在性能问题,严重时会造成死锁问题
  • 提交事务请求后,出现网络异常,部分数据收到并执行,会造成一致性问

TCC方案

Try Confirm Cancel / 短事务

  • Try 阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留

  • Confirm 阶段:这个阶段说的是在各个服务中执行实际的操作

  • Cancel 阶段:如果任何一个服务的业务方法执行出错,那么就需要进行补偿/回滚

Saga方案

事务性补偿 / 长事务

  • 流程、流程、调用第三方业务

本地消息表(eBay)

MQ最终一致性

image-20210117220405706

比如阿里的 RocketMQ 就支持消息事务(核心:双端确认,重试幂等

  1. A(订单) 系统先发送一个 prepared 消息到 mq,prepared 消息发送失败则取消操作不执行了
  2. 发送成功后,那么执行本地事务,执行成功和和失败发送确认和回滚消息到mq
  3. 如果发送了确认消息,那么此时 B(仓储) 系统会接收到确认消息,然后执行本地的事务
  4. mq 会自动定时轮询所有 prepared 消息回调的接口,确认事务执行状态
  5. B 的事务失败后自动不断重试直到成功,达到一定次数后发送报警由人工来手工回滚补偿

最大努力通知方案(订单 -> 积分)

  1. 系统 A 本地事务执行完之后,发送个消息到 MQ;
  2. 这里会有个专门消费 MQ 的最大努力通知服务,接着调用系统 B 的接口;
  3. 要是系统 B 执行失败了,就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃

你找一个严格资金要求绝对不能错的场景,你可以说你是用的 TCC 方案

如果是一般的分布式事务场景,例如积分数据,可以用可靠消息最终一致性方案

如果分布式场景允许不一致,可以使用最大努力通知方案

面试题

分布式Session实现方案

  • 基于JWT的Token,数据从cache或者数据库中获取
  • 基于Tomcat的Redis,简单配置conf文件
  • 基于Spring的Redis,支持SpringCloud和Springb

  转载请注明: Hi 高虎 计划大纲

 上一篇
设计大纲 设计大纲
Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见.
2022-02-22
下一篇 
ClickHose原理解析与应用实践 ClickHose原理解析与应用实践
ClickHouse 是一个真正的列式数据库管理系统(DBMS)。在 ClickHouse 中,数据始终是按列存储的,包括矢量(向量或列块)执行的过程。只要有可能,操作都是基于矢量进行分派的,而不是单个的值,这被称为«矢量化查询执行»,它有利于降低实际的数据处理开销。
2021-12-18
  目录