Java后端面试题精选。
Java I/O
1、何为 I/O?
I/O(Input/Outpu) 即输入/输出 。
我们先从计算机结构的角度来解读一下 I/O。
根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
我们再先从应用程序的角度来解读一下 I/O。
为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。并且,用户空间的程序不能直接访问内核空间。
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间。
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
内核等待 I/O 设备准备好数据。
内核将数据从内核空间拷贝到用户空间。
2、有哪些常见的 IO 模型?
UNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
(1)BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型 。
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
![图源:《深入拆解Tomcat & Jetty》](https://oss.javaguide.cn/p3-juejin/6a9e704af49b4380bb686f0c96d33b81~tplv-k3u1fbpfcp-watermark.png)
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
(2)NIO (Non-blocking I/O)
Java 中的 NIO 提供了 Channel
, Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
我们先来看看 同步非阻塞 IO 模型。
![图源:《深入拆解Tomcat & Jetty》](https://oss.javaguide.cn/p3-juejin/bb174e22dbe04bb79fe3fc126aed0c61~tplv-k3u1fbpfcp-watermark.png)
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
这个时候,I/O 多路复用模型 就上场了。
![img](https://oss.javaguide.cn/github/javaguide/java/io/88ff862764024c3b8567367df11df6ab~tplv-k3u1fbpfcp-watermark.png)
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
- select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
![Buffer、Channel和Selector三者之间的关系](https://oss.javaguide.cn/github/javaguide/java/nio/channel-buffer-selector.png)
(3)AIO (Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
![img](https://oss.javaguide.cn/github/javaguide/java/io/3077e72a1af049559e81d18205b56fd7~tplv-k3u1fbpfcp-watermark.png)
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
Netty
1、Netty 是什么?
- Netty 是⼀个 基于 NIO 的 client-server(客户端服务器)框架,使用它可以快速简单地开发网络应用程序。
- 它极大地简化并优化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面甚至都要更好。
- 支持多种协议 如 FTP,SMTP,HTTP 以及各种二进制和基于文本的传统协议。
2、为什么要用 Netty?
Netty 具有下面这些优点,并且相比于直接使用 JDK 自带的 NIO 相关的 API 来说更加易用。
- 统一的 API,支持多种传输类型,阻塞和非阻塞的。
- 简单而强大的线程模型。
- 自带编解码器解决 TCP 粘包/拆包问题。
- 自带各种协议栈。
- 真正的无连接数据包套接字支持。
- 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。
- 安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。
- 社区活跃
- 成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty, 比如我们经常接触的 Dubbo、RocketMQ 等等。
3、Netty 应用场景了解么?
Netty 主要用来做网络通信 :
- 作为 RPC 框架的网络通信工具 :我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务节点之间的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧!
- 实现一个自己的 HTTP 服务器 :通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为 Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。
- 实现一个即时通讯系统 :使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。
- 实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的。
4、Netty 核心组件与作用
(1)Bytebuf(字节容器)
网络通信最终都是通过字节流进行传输的。 ByteBuf 就是 Netty 提供的⼀个字节容器,其内部是⼀个字节数组。 当我们通过 Netty 传输数据的时候,就是通过 ByteBuf 进行的。我们可以将 ByteBuf 看作是 Netty 对 Java NIO 提供了 ByteBuffer 字节容器的封装和抽象。
(2)Bootstrap 和 ServerBootstrap(启动引导类)
Bootstrap 是客户端的启动引导类/辅助类 ,ServerBootstrap 是服务端的启动引导类/辅助类 。
Bootstrap 通常使用 connect() 方法连接到远程的主机和端口,作为⼀个 Netty TCP 协议通信中的客户端。另外, Bootstrap 也可以通过 bind()方法绑定本地的⼀个端口,作为 UDP 协议通信中的⼀端。
ServerBootstrap 通常使用bind() 方法绑定本地的端口上,然后等待客户端的连接。
Bootstrap 只需要配置⼀个线程组EventLoopGroup , 而 ServerBootstrap 需要配置两个线程组EventLoopGroup ,⼀个用于接收连接,⼀个用于具体的 IO 处理。
(3)Channel
Channel 接口是 Netty 对网络操作的抽象类。通过 Channel 我们可以进行 I/O 操作。⼀旦客户端成功连接服务端,就会新建⼀个 Channel 同该用户端进行绑定。
比较常用的 Channel 接口实现类是 :NioServerSocketChannel (服务端)、NioSocketChannel (客户端),这两个 Channel 可以和 BIO 编程模型中的 ServerSocket 以及 Socket 两个概念对应上。
(4)EventLoop(事件循环)
EventLoop 的主要作用实际就是责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理。
Channel 和 EventLoop 的关系?
Channel 为 Netty 网络操作(读写等操作)抽象类, EventLoop 负责处理注册到其上的 Channel 的 I/O 操作,两者配合进行 I/O 操作。
EventloopGroup 和 EventLoop 的关系?
EventLoopGroup 包含多个 EventLoop (每⼀个 EventLoop 通常内部包含⼀个线程),它管理着所有的 EventLoop 的生命周期。并且, EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。
![image-20220221133237066](https://img-blog.csdnimg.cn/img_convert/af43835447d6d467f9fe659c88854e0a.png)
(5)ChannelHandler(消息处理器) 和 ChannelPipeline(ChannelHandler 对象链表)
ChannelHandler 是消息的具体处理器,主要负责处理客户端/服务端接收和发送的数据。
当Channel被创建时,它会自动地分配到它专属的ChannelPipeline。一个Channel包含一个ChannelPipeline。ChannelPipeline是ChannelHandler的链,一个pipeline上可以有很多的ChannelHandler。
可以在ChannelPipeline上通过addLast()方法添加一个或者多个ChannelHandler(一个数据或者事件可能会被多个Handler处理)。当一个ChannelHandler处理完之后就会将数据交给下一个ChannelHandler。
当ChannelHandler被添加到ChannelPipeline,它会有一个CHannelHandlerContext,代表一个ChannelHandler和ChannelPipeline之间的“绑定”。ChannelPipeline通过ChannelHandlerContext来间接管理ChannelHandler。
(6)ChannelFuture操作执行结果
Netty中所有的IO操作都为异步的,我们不能立刻得到操作是否执行成功。
但是可以通过ChannelFuture接口的addListener()方法注册一个ChannelFutureListener,当操作执行成功或者失败时,监听就会自动触发返回结果。
并且,你还可以通过 ChannelFuture 的 channel() 方法获取连接相关联的 Channel 。 另外,我们还可以通过 ChannelFuture 接口的 sync() 方法让异步的操作编程同步的。
(7)NioEventLoopGroup 默认的构造函数会起多少线程?
NioEventLoopGroup 默认的构造函数实际会起的线程数为 CPU核心数*2。
另外,如果你继续深入下去看构造函数的话,你会发现每个 NioEventLoopGroup 对象内部都会分配⼀组 NioEventLoop ,其大小是 nThreads , 这样就构成了⼀个线程池, ⼀个 NIOEventLoop 和⼀个线程相对应,这
和我们上面说的 EventloopGroup 和 EventLoop 关系这部分内容相对应。
5、Reactor线程模型
Reactor是一种经典的线程模型,Reactor模式基于事件驱动,特别适合海量的IO事件。
Reactor线程模型分为单线程模型、多线程模型以及主从多线程模型。
(1)单线程Reactor
所有的 IO 操作都由同⼀个 NIO 线程处理。
单线程 Reactor 的优点是对系统资源消耗特别小,但是,没办法支撑大量请求的应用场景并且处理请求的时间可能非常慢,项目中一般不使用 。
(2)多线程Reactor
⼀个线程负责接受请求,⼀组 NIO 线程处理 IO 操作。
大部分场景下多线程 Reactor 模型是没有问题的,但是在⼀些并发连接数比较多(如百万并发)的场景下,⼀个线程负责接受客户端请求就存在性能问题了。
(3)主从多线程Reactor
一组NIO线程负责接受请求,一组NIO线程处理IO操作。
6、什么是TCP粘包、拆包?
1)粘包拆包发生场景
因为TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。
如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。
如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。
2)常见的解决方案
对于粘包和拆包问题,常见的解决方案有四种:
- 发送端将每个包都封装成固定的长度,比如100字节大小。如果不足100字节可通过补0或空等进行填充到指定长度;
- 发送端在每个包的末尾使用固定的分隔符,例如\r\n。如果发生拆包需等待多个包发送过来之后再找到其中的\r\n进行合并;例如,FTP协议;
- 将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;
- 通过自定义协议进行粘包和拆包的处理。
3)Netty对粘包和拆包问题的处理
Netty对解决粘包和拆包的方案做了抽象,提供了一些解码器(Decoder)来解决粘包和拆包的问题。如:
- LineBasedFrameDecoder:以行为单位进行数据包的解码;
- DelimiterBasedFrameDecoder:以特殊的符号作为分隔来进行数据包的解码;
- FixedLengthFrameDecoder:以固定长度进行数据包的解码;
- LenghtFieldBasedFrameDecode:适用于消息头包含消息长度的协议(最常用);
基于Netty进行网络读写的程序,可以直接使用这些Decoder来完成数据包的解码。对于高并发、大流量的系统来说,每个数据包都不应该传输多余的数据(所以补齐的方式不可取),LenghtFieldBasedFrameDecode更适合这样的场景。
7、Netty的长连接和心跳机制
(1)TCP长连接和短连接
TCP在进行读写之前,server与client之间必须提前建立一个连接。建立连接的过程,需要三次握手,释放连接时需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的。
短连接:server端与client端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。
优点:管理和实现比较简单,
缺点:每一次的读写都要建立连接必然会带来大量网络资源消耗,并且连接的建立也需要耗费时间。
长连接:client向server双方建立连接之后,即使client与server完成一次读写,他们之间的连接也不会主动关闭,后续的读写操作会继续使用这个连接。
长连接可以省去较多的TCP建立和关闭的操作,降低对网络资源的依赖,节约时间。对于拼房请求资源的客户来说,非常适合长连接。
(2)心跳机制
在TCP保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候,client与server之间如果没有交互的话,他们是无法发现对方已经掉线的,为了解决这个问题,引入了心跳机制。
心跳机制的工作原理:在client与server之间在一定时间内没有数据交互时,即处于idle状态时,客户端或服务端就会发送一个特殊的数据包给对方,当接收方收到这个数据报文后,也立即发送一个特殊的数据报文回应给对方,这就是一个PING+PONG交互。所以当某一端收到心跳信息后,就知道对方仍然在线,这就确保了TCP连接的有效性。
TCP实际上自带的就有长连接选项,本身也有心跳包机制,也就是TCP的选项:SO_KEEP_ALIVE。但是,TCP协议层面的长连接灵活性不够,所以,一般情况下我们都是在应用层协议之上实现自定义心跳机制,也就是在Netty层面通过编码实现。通过Netty实现心跳机制,核心类时IdleStateHandler。
8、Netty的零拷贝
在 OS 层⾯上的 Zero-copy 通常指避免在用户态(User-space) 与 内核态(Kernel-space) 之间来回拷贝数据。而在 Netty 层面 ,零拷贝主要体现在对于数据操作的优化。
零拷贝技术的核心思想是将数据从内核空间直接传输到网络适配器的缓冲区,避免了数据在内核空间和用户空间之间的复制。在使用零拷贝技术时,应用程序通过调用操作系统提供的API(如sendfile、mmap等)将数据直接映射到网络适配器的缓冲区,然后通过网络传输到远程主机。这样,数据只需要经过一次复制操作,从而大大提高了数据传输的效率和性能。
Netty 中的零拷贝体现在以下几个方面:
- 使用 Netty 提供的 CompositeByteBuf 类,可以将多个 ByteBuf 合并为⼀个逻辑上的 ByteBuf ,避免了各个 ByteBuf 之间的拷贝。
- ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同⼀个存储区域的 ByteBuf ,避免了内存的拷贝。
- 通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,可以直接将文件缓冲区的数据发送到目标 Channel ,避免了传统通过循环 write 方式导致的内存拷贝问题。
Dubbo
1、什么是RPC?
RPC就是远程方法调用,和本地方法调用不同,本地方法调用指的是进程内部的方法调用,而远程方法调用指的是两个进程内的方法互相调用。
要实现远程方法调用,必然通过网络进行数据传输,于是就有了:
- RPC & HTTP:通过HTTP协议传输数据;
- RPC & TCP:通过TCP协议传输数据;
2、什么是Dubbo?
目前,官网上是这么介绍的:Apache Dubbo 是⼀款⾼性能、轻量级的开源 Java 服务框架。
在几个月前,官网的介绍是:Apache Dubbo 是⼀款⾼性能、轻量级的开源 Java RPC框架。
为什么会将RPC改为服务?
Dubbo⼀开始的定位就是RPC,专注于两个服务之间的调用。但随着微服务的盛行,除了服务调用之外,
Dubbo也在逐步的涉猎服务治理、服务监控、服务网关等等,所以现在的Dubbo目标已经不止是RPC框架
了,而是和Spring Cloud类似想成为了⼀个服务框架。
Dubbo 内置支持 Dubbo2、Triple 两款高性能通信协议。其中
- Dubbo2 是基于 TCP 传输协议之上构建的二进制私有 RPC 通信协议,是一款非常简单、紧凑、高效的通信协议。
- Triple 是基于 HTTP/2 的新一代 RPC 通信协议,在网关穿透性、通用性以及 Streaming 通信上具备优势,Triple 完全兼容 gRPC 协议。
3、原理
4、服务发现
Dubbo 提供的是一种 Client-Based 的服务发现机制,依赖第三方注册中心组件来协调服务发现过程,支持常用的注册中心如 Nacos、Consul、Zookeeper 等。
以下是 Dubbo 服务发现机制的基本工作原理图:
服务发现包含提供者、消费者和注册中心三个参与角色,其中,Dubbo 提供者实例注册 URL 地址到注册中心,注册中心负责对数据进行聚合,Dubbo 消费者从注册中心读取地址列表并订阅变更,每当地址列表发生变化,注册中心将最新的列表通知到所有订阅的消费者实例。
配置Nacos注册中心:
Nacos :Nacos 注册中心的基本使用和工作原理。
5、负载均衡
在集群负载均衡时,Dubbo 提供了多种均衡策略,缺省为 weighted random 基于权重的随机负载均衡策略。
具体实现上,Dubbo 提供的是客户端负载均衡,即由 Consumer 通过负载均衡算法得出需要将请求提交到哪个 Provider 实例。
负载均衡策略
目前 Dubbo 内置了如下负载均衡算法,可通过调整配置项启用。
使用方式
只需要调整 loadbalance 相应取值即可,每种负载均衡策略取值请参见文档最上方表格。
- 服务端服务级别
1 | <dubbo:service interface="..." loadbalance="roundrobin" /> |
- 客户端服务级别
1 | <dubbo:reference interface="..." loadbalance="roundrobin" /> |
- 服务端方法级别
1 | <dubbo:service interface="..."> |
- 客户端方法级别
1 | <dubbo:reference interface="..."> |
6、流量管控
Dubbo 的流量管控规则可以基于应用、服务、方法、参数等粒度精准的控制流量走向,根据请求的目标服务、方法以及请求体中的其他附加参数进行匹配,符合匹配条件的流量会进一步的按照特定规则转发到一个地址子集。流量管控规则有以下几种:
- 条件路由规则
- 标签路由规则
- 脚本路由规则
- 动态配置规则
具体见文档:https://cn.dubbo.apache.org/zh-cn/overview/core-features/traffic/
7、通信协议
Dubbo 框架提供了自定义的高性能 RPC 通信协议:基于 HTTP/2 的 Triple 协议 和 基于 TCP 的 Dubbo2 协议。除此之外,Dubbo 框架支持任意第三方通信协议,如官方支持的 gRPC、Thrift、REST、JsonRPC、Hessian2 等,更多协议可以通过自定义扩展实现。这对于微服务实践中经常要处理的多协议通信场景非常有用。
具体见文档:https://cn.dubbo.apache.org/zh-cn/overview/core-features/protocols/
8、SPI机制
(1)Java SPI
Java SPI(Service Provider Interface)是一种服务发现机制,它允许在运行时为某个接口自动发现并加载多个实现。这一机制主要用于设计可插拔的模块化应用程序,使得应用程序的组件或服务的具体实现可以在部署时灵活选择或替换,而不需修改应用程序的源代码,从而达到解耦和增强可扩展性的目的。
SPI的核心概念包括:
- 接口与实现分离:SPI的核心思想是将接口定义与其实现分离。API的提供者仅定义接口规范,不提供具体的实现,具体的实现交由第三方开发者提供。
- 配置文件注册:实现者需要在自己jar包的
META-INF/services
目录下创建一个以接口全限定名命名的文本文件,并在这个文件中指定实现类的全限定名。这样,当应用启动时,Java SPI机制会扫描这些配置文件,自动发现并加载所有可用的实现。 - 服务查找与加载:在应用中,通过
java.util.ServiceLoader
类来加载服务提供者。ServiceLoader.load(Class<T> service)
方法会查找并加载所有可用的服务提供者,然后可以遍历这些实现,选择合适的使用。
(2)为什么dubbo自己实现了SPI?
因为 Java SPI 在查找扩展实现类的时候遍历 SPI 的配置文件并且将实现类全部实例化,假设一个实现类初始化过程比较消耗资源且耗时,但是你的代码里面又用不上它,这就产生了资源的浪费。
因此 Dubbo 就自己实现了一个 SPI,给每个实现类配了个名字,通过名字去文件里面找到对应的实现类全限定名然后加载实例化,按需加载。
9、Dubbo的分层架构设计
Dubbo整体架构分为10层,从宏观上把握这10层架构,并了解各层的功能和扩展点,可以帮助我们更好的了解 Dubbo。
代理层—Proxy
Proxy 层的功能就是使用动态代理的方式为接口创建代理类,Proxy 层最主要的接口就是 ProxyFactory。其默认的扩展点有:stub、jdk、javassist。jdk 使用反射的方式创建代理类,javassist 通过拼接字符串然后编译的方式创建代理类。
- 对于服务提供者,代理的对象是接口的真实实现。
- 对于服务消费者,代理的对象是远程服务的 invoker 对象。
注册层—Registry
注册层主要负责的就是服务的注册发现。这层的主要接口就是 RegistryFactory,其接口方法有 @Adaptive 注解,会根据参数 protocol 来选择实现,默认的扩展实现有:
- zookeeper
- redis
- multicast(广播模式)
- 内存
集群层—Cluster
集群层主要是对多提供者调用场景的抽象,是 Dubbo 整个集群容错的抽象层。主要的扩展点有:容错(Cluster)、路由(RouterFactory)、负载均衡(LoadBalance)。这层的这些扩展点参考之前的博客。
- 容错
机制名 | 简介 |
---|---|
Failover 默认 | 失败重试。默认失败后在重试1次,重试其他服务器。通常使用在读/幂等写的场景。会对下游服务造成较大压力。 |
Failfast | 快速失败。失败就返回异常。使用在非幂等写的场景。但受网络影响大。 |
Failsafe | 失败不做处理,直接忽略异常。使用在不关心调用结果,且成功与否不重要的场景。 |
Failback | 失败后放入队列,并定时重试。使用在要保持最终一致或异步处理的场景。 |
Forking | 并行调用多个服务,只要一个成功就可以。使用在实时性要求高的场景,但会造成消费者资源浪费。 |
Broadcast | 广播给所有提供者,有一个失败就失败。 |
Mock | 出现异常就会使用默认的返回内容,使用在服务降级的场景。 |
Available | 不用负载均衡,找到第一个可用提供者就调用。 |
Mergeable | 把多个节点的返回结果合并。 |
- 路由
- 条件路由;
- 文件路由;
- 脚本路由。
- 负载均衡
- 权重随机负载均衡(默认);
- 权重轮询负载均衡;
- 一致性 hash 负载均衡;
- 最小活跃数负载均衡。
协议层—Protocol
协议层是 Dubbo RPC 的核心,在这一层发起服务暴露(protocol.export)和服务引用(protocol.refer)。
Dubbo 提供的协议有:
- Dubbo (默认);
- injvm;
- rmi;
- http;
- hessian;
- thrift。
信息交换层—Exchange
信息交换层的作用就是封装 Request/Response 对象。
传输层—Transport
数据传输就是在传输层发生的,所以这层包含 Transport(传输)、Dispatcher(分派)、Codec2(编解码)、ThreadPool(线程池),这几个接口。这也很好理解,数据传输发生在传输层,所以传输层一定需要具备数据传输的能力,也就是 Transport 和 Codec2,其中 Transport 就是 Netty 等网络传输的接口,编解码不必说了,传输肯定需要;除了传输能力,数据接收之后的处理,Dispatcher 和 ThreadPool,也就是分派任务和任务提交给线程池处理,也是必不可少的。
- Transport
- Netty;
- Mina;
- Grizzly。
- Dispatcher
策略 | 用途 |
---|---|
all | 所有消息都派发到线程池,包括请求,响应,连接事件,断开事件等,默认 |
direct | 所有消息都不派发到线程池,全部在 IO 线程上直接执行 |
message | 只有请求和响应消息派发到线程池,其它消息均在 IO 线程上执行 |
execution | 只有请求消息派发到线程池,不含响应。其它消息均在 IO 线程上执行 |
connection | 在 IO 线程上,将连接断开事件放入队列,有序逐个执行,其它消息派发到线程池 |
- ThreadPool
线程池类型 | 说明 |
---|---|
fixed | 固定大小线程池,默认线程数200,启动时建立,不会关闭,一直存在 |
cached | 缓存线程池,空闲一分钟自动删除,需要时重建 |
limited | 可伸缩线程池,但只会扩大不会缩小。这么做的目的是避免收缩的时候来大流量带来性能问题 |
eager | 优先创建 worker 线程池。任务数大于 corePoolSize 小于 maxPoolSize,创建 worker 处理,线程数大于 maxPoolSize ,任务放入阻塞队列,阻塞队列满了走拒绝策略。 |
序列化层—Serialize
序列化的作用是把对象转化为二进制流,然后在网络中传输。Dubbo 提供的序列化方式有:
- Hessian2(默认);
- Fastjson;
- fst;
- Java;
- Kayo;
- protobuff。
10、集群容错有哪些?
集群调用失败时,Dubbo 提供的容错方案
在集群调用失败时,Dubbo 提供了多种容错方案,缺省为 failover 重试。
各节点关系:
- 这里的
Invoker
是Provider
的一个可调用Service
的抽象,Invoker
封装了Provider
地址及Service
接口信息 Directory
代表多个Invoker
,可以把它看成List<Invoker>
,但与List
不同的是,它的值可能是动态变化的,比如注册中心推送变更Cluster
将Directory
中的多个Invoker
伪装成一个Invoker
,对上层透明,伪装过程包含了容错逻辑,调用失败后,重试另一个Router
负责从多个Invoker
中按路由规则选出子集,比如读写分离,应用隔离等LoadBalance
负责从多个Invoker
中选出具体的一个用于本次调用,选的过程包含了负载均衡算法,调用失败后,需要重选
10.1、集群容错模式
Failover Cluster
失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。可通过 retries="2"
来设置重试次数(不含第一次)。
重试次数配置如下:
1 | <dubbo:service retries="2" /> |
或
1 | <dubbo:reference retries="2" /> |
或
1 | <dubbo:reference> |
Failfast Cluster
快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
Failsafe Cluster
失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
Failback Cluster
失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
Forking Cluster
并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2"
来设置最大并行数。
Broadcast Cluster
广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。
现在广播调用中,可以通过 broadcast.fail.percent
配置节点调用失败的比例,当达到这个比例后,BroadcastClusterInvoker
将不再调用其他节点,直接抛出异常。 broadcast.fail.percent
取值在 0~100
范围内。默认情况下当全部调用失败后,才会抛出异常。 broadcast.fail.percent
只是控制的当失败后是否继续调用其他节点,并不改变结果(任意一台报错则报错)。broadcast.fail.percent
参数 在 dubbo2.7.10 及以上版本生效。
Broadcast Cluster
配置 broadcast.fail.percent
。
broadcast.fail.percent=20
代表了当 20% 的节点调用失败就抛出异常,不再调用其他节点。
1 |
Available Cluster
调用目前可用的实例(只调用一个),如果当前没有可用的实例,则抛出异常。通常用于不需要负载均衡的场景。
Mergeable Cluster
将集群中的调用结果聚合起来返回结果,通常和group一起配合使用。通过分组对结果进行聚合并返回聚合后的结果,比如菜单服务,用group区分同一接口的多种实现,现在消费方需从每种group中调用一次并返回结果,对结果进行合并之后返回,这样就可以实现聚合菜单项。
ZoneAware Cluster
多注册中心订阅的场景,注册中心集群间的负载均衡。对于多注册中心间的选址策略有如下四种
- 指定优先级:
preferred="true"
注册中心的地址将被优先选择
1 | <dubbo:registry address="zookeeper://127.0.0.1:2181" preferred="true" /> |
- 同中心优先:检查当前请求所属的区域,优先选择具有相同区域的注册中心
1 | <dubbo:registry address="zookeeper://127.0.0.1:2181" zone="beijing" /> |
- 权重轮询:根据每个注册中心的权重分配流量
1 | <dubbo:registry id="beijing" address="zookeeper://127.0.0.1:2181" weight="100" /> |
- 缺省值:选择一个可用的注册中心
11、负载均衡方式有哪些?
11.1、RandomLoadBalance
RandomLoadBalance 是加权随机算法的具体实现,它的算法思想很简单。
假设我们有一组服务器 servers = [A, B, C],他们对应的权重为 weights = [5, 3, 2],权重总和为10。现在把这些权重值平铺在一维坐标值上,[0, 5) 区间属于服务器 A,[5, 8) 区间属于服务器 B,[8, 10) 区间属于服务器 C。接下来通过随机数生成器生成一个范围在 [0, 10) 之间的随机数,然后计算这个随机数会落到哪个区间上。比如数字3会落到服务器 A 对应的区间上,此时返回服务器 A 即可。权重越大的机器,在坐标轴上对应的区间范围就越大,因此随机数生成器生成的数字就会有更大的概率落到此区间内。只要随机数生成器产生的随机数分布性很好,在经过多次选择后,每个服务器被选中的次数比例接近其权重比例。
11.2、LeastActiveLoadBalance
LeastActiveLoadBalance 翻译过来是最小活跃数负载均衡。活跃调用数越小,表明该服务提供者效率越高,单位时间内可处理更多的请求。此时应优先将请求分配给该服务提供者。
11.3、ConsistentHashLoadBalance
一致性 hash 算法由麻省理工学院的 Karger 及其合作者于1997年提出的,算法提出之初是用于大规模缓存系统的负载均衡。
11.4、RoundRobinLoadBalance
加权轮询负载均衡的实现 RoundRobinLoadBalance。我们先来了解一下什么是加权轮询。这里从最简单的轮询开始讲起,所谓轮询是指将请求轮流分配给每台服务器。举个例子,我们有三台服务器 A、B、C。我们将第一个请求分配给服务器 A,第二个请求分配给服务器 B,第三个请求分配给服务器 C,第四个请求再次分配给服务器 A。这个过程就叫做轮询。轮询是一种无状态负载均衡算法,实现简单,适用于每台服务器性能相近的场景下。但现实情况下,我们并不能保证每台服务器性能均相近。如果我们将等量的请求分配给性能较差的服务器,这显然是不合理的。因此,这个时候我们需要对轮询过程进行加权,以调控每台服务器的负载。经过加权后,每台服务器能够得到的请求数比例,接近或等于他们的权重比。比如服务器 A、B、C 权重比为 5:2:1。
12、Dubbo优雅停机
优雅停机是指在停止应用时,执行的一系列保证应用正常关闭的操作。这些操作往往包括等待已有请求执行完成、关闭线程、关闭连接和释放资源等,优雅停机可以避免非正常关闭程序可能造成数据异常或丢失,应用异常等问题。优雅停机本质上是JVM即将关闭前执行的一些额外的处理代码。
适用场景:
- JVM主动关闭(
System.exit(int)
; - JVM由于资源问题退出(
OOM
); - 应用程序接受到
SIGTERM
或SIGINT
信号。
12.1、配置方式
- 服务的优雅停机
在Dubbo中,优雅停机是默认开启的,停机等待时间为10000毫秒。可以通过配置dubbo.service.shutdown.wait
来修改等待时间。
例如将等待时间设置为20秒可通过增加以下配置实现:
1 | dubbo.service.shutdown.wait=20000 |
- 容器的优雅停机
当使用org.apache.dubbo.container.Main
这种容器方式来使用 Dubbo 时,也可以通过配置dubbo.shutdown.hook
为true
来开启优雅停机。
- 通过QOS优雅上下线
基于ShutdownHook
方式的优雅停机无法确保所有关闭流程一定执行完,所以 Dubbo 推出了多段关闭的方式来保证服务完全无损。
多段关闭即将停止应用分为多个步骤,通过运维自动化脚本或手工操作的方式来保证脚本每一阶段都能执行完毕。
在关闭应用前,首先通过 QOS 的offline
指令下线所有服务,然后等待一定时间确保已经到达请求全部处理完毕,由于服务已经在注册中心下线,当前应用不会有新的请求。这时再执行真正的关闭(SIGTERM 或 SIGINT)流程,就能保证服务无损。
QOS可通过 telnet 或 HTTP 方式使用,具体方式请见Dubbo-QOS命令使用说明。
12.2、流程
Provider在接收到停机指令后
- 从注册中心上注销所有服务;
- 从配置中心取消监听动态配置;
- 向所有连接的客户端发送只读事件,停止接收新请求;
- 等待一段时间以处理已到达的请求,然后关闭请求处理线程池;
- 断开所有客户端连接。
Consumer在接收到停机指令后
- 拒绝新到请求,直接返回调用异常;
- 等待当前已发送请求执行完毕,如果响应超时则强制关闭连接。
当使用容器方式运行 Dubbo 时,在容器准备退出前,可进行一系列的资源释放和清理工。
例如使用 SpringContainer时,Dubbo 的ShutdownHook
线程会执行ApplicationContext
的stop
和close
方法,保证 Bean的生命周期完整。
12.3、实现原理
- 在加载类
org.apache.dubbo.config.AbstractConfig
时,通过org.apache.dubbo.config.DubboShutdownHook
向JVM注册ShutdownHook
。
1 | /** |
- 每个
ShutdownHook
都是一个单独的线程,由JVM在退出时触发执行org.apache.dubbo.config.DubboShutdownHook
。
1 | /** |
- 首先关闭所有注册中心,这一步包括:
- 从注册中心注销所有已经发布的服务;
- 取消订阅当前应用所有依赖的服务;
- 断开与注册中心的连接。
- 执行所有
Protocol
的destroy()
,主要包括:
- 销毁所有
Invoker
和Exporter
; - 关闭
Server
,向所有已连接Client
发送当前Server
只读事件; - 关闭独享/共享
Client
,断开连接,取消超时和重试任务; - 释放所有相关资源。
- 执行完毕,关闭
JVM
。
注意事项:
- 使用
SIGKILL
关闭应用不会执行优雅停机; - 优雅停机不保证会等待所有已发送/到达请求结束;
- 配置的优雅停机等待时间
timeout
不是所有步骤等待时间的总和,而是每一个destroy
执行的最大时间。例如配置等待时间为5秒,则关闭Server、关闭Client等步骤会分别等待5秒。
13、手写RPC框架
14、使用示例
Dubbo使用的具体操作步骤如下:
- 定义接口:
- 首先,你需要定义服务接口,即提供给其他模块调用的方法和参数。
- 实现接口:
- 编写接口的具体实现类,实现接口中定义的方法。
- 配置提供者:
- 创建一个Dubbo的配置文件,例如dubbo-provider.xml。
- 在配置文件中配置服务提供者的相关信息,包括注册中心地址、端口号、服务接口、实现类等。
- 启动提供者:
- 在提供者端启动应用程序,加载Dubbo的配置文件。
- 这样提供者就可以将自己注册到注册中心,并提供服务。
- 配置消费者:
- 创建一个Dubbo的配置文件,例如dubbo-consumer.xml。
- 在配置文件中配置服务消费者的相关信息,包括注册中心地址、超时时间、服务接口等。
- 引用服务:
- 在消费者端,通过Dubbo的引用机制,引用提供者提供的服务。
- 在消费者的配置文件中,配置引用的服务接口、版本号、负载均衡策略等。
- 调用服务:
- 在消费者端,通过引用的服务接口,调用提供者提供的方法。
- 监控和管理:
- Dubbo提供了控制台用于监控和管理服务。
- 配置控制台的相关参数,例如注册中心地址、端口号等。
- 在控制台中,你可以查看服务的调用情况、性能指标等。
以上是Dubbo的基本使用步骤。你可以根据实际需求和具体情况,进行相应的配置和调整。同时,你也可以参考Dubbo的官方文档和示例代码,以获取更详细的操作指南。
负载均衡
1、什么是负载均衡?
负载均衡 指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力以及可靠性。负载均衡服务可以有由专门的软件或者硬件来完成,一般情况下,硬件的性能更好,软件的价格更便宜。
2、负载均衡分为哪几种?
负载均衡可以简单分为 服务端负载均衡 和 客户端负载均衡 这两种。
(1)服务端负载均衡
服务端负载均衡 主要应用在 系统外部请求 和 网关层 之间,可以使用 软件 或者 硬件 实现。
下图是我画的一个简单的基于 Nginx 的服务端负载均衡示意图:
![基于 Nginx 的服务端负载均衡](https://oss.javaguide.cn/github/javaguide/high-performance/load-balancing/server-load-balancing.png)
根据 OSI 模型,服务端负载均衡还可以分为:
- 二层负载均衡
- 三层负载均衡
- 四层负载均衡
- 七层负载均衡
最常见的是四层和七层负载均衡。
![OSI 七层模型](https://oss.javaguide.cn/github/javaguide/cs-basics/network/osi-7-model.png)
四层负载均衡 工作在 OSI 模型第四层,也就是传输层,这一层的主要协议是 TCP/UDP,负载均衡器在这一层能够看到数据包里的源端口地址以及目的端口地址,会基于这些信息通过一定的负载均衡算法将数据包转发到后端真实服务器。也就是说,四层负载均衡的核心就是 IP+端口层面的负载均衡,不涉及具体的报文内容。
七层负载均衡 工作在 OSI 模型第七层,也就是应用层,这一层的主要协议是 HTTP 。这一层的负载均衡比四层负载均衡路由网络请求的方式更加复杂,它会读取报文的数据部分(比如说我们的 HTTP 部分的报文),然后根据读取到的数据内容(如 URL、Cookie)做出负载均衡决策。也就是说,七层负载均衡器的核心是报文内容(如 URL、Cookie)层面的负载均衡,执行第七层负载均衡的设备通常被称为 反向代理服务器 。
(2)客户端负载均衡
客户端负载均衡 主要应用于系统内部的不同的服务之间,可以使用现成的负载均衡组件来实现。
在客户端负载均衡中,客户端会自己维护一份服务器的地址列表,发送请求之前,客户端会根据对应的负载均衡算法来选择具体某一台服务器处理请求。
![img](https://oss.javaguide.cn/github/javaguide/high-performance/load-balancing/spring-cloud-lb-gateway.png)
3、负载均衡常见的算法有哪些?
(1)随机法
随机法 是最简单粗暴的负载均衡算法。
如果没有配置权重的话,所有的服务器被访问到的概率都是相同的。如果配置权重的话,权重越高的服务器被访问的概率就越大。
(2)轮询法
轮询法是挨个轮询服务器处理,也可以设置权重。
如果没有配置权重的话,每个请求按时间顺序逐一分配到不同的服务器处理。如果配置权重的话,权重越高的服务器被访问的次数就越多。
(3)一致性 Hash 法
相同参数的请求总是发到同一台服务器处理,比如同个 IP 的请求。
(4)最小连接法
当有新的请求出现时,遍历服务器节点列表并选取其中活动连接数最小的一台服务器来响应当前请求。活动连接数可以理解为当前正在处理的请求数。
最小连接法可以尽可能最大地使请求分配更加合理化,提高服务器的利用率。
4、七层负载均衡可以怎么做?
简单介绍两种项目中常用的七层负载均衡解决方案:DNS 解析和反向代理。
(1)DNS解析
DNS 解析实现负载均衡的原理是这样的:在 DNS 服务器中为同一个主机记录配置多个 IP 地址,这些 IP 地址对应不同的服务器。当用户请求域名的时候,DNS 服务器采用轮询算法返回 IP 地址,这样就实现了轮询版负载均衡。
(2)反向代理
客户端将请求发送到反向代理服务器,由反向代理服务器去选择目标服务器,获取数据后再返回给客户端。对外暴露的是反向代理服务器地址,隐藏了真实服务器 IP 地址。
数据库优化
1、读写分离
(1)什么是读写分离?
见名思意,根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。
![读写分离示意图](https://oss.javaguide.cn/github/javaguide/high-performance/read-and-write-separation-and-library-subtable/read-and-write-separation.png)
一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。
(2)读写分离会带来什么问题?
读写分离对于提升数据库的并发非常有效,但是,同时也会引来一个问题:主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟 。
解决方案:
1、强制将读请求路由到主库处理
既然从库的数据过期了,那我就直接从主库读取嘛!这种方案虽然会增加主库的压力,但是,实现起来比较简单。
2、延迟读取
对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。
(3)如何实现读写分离?
1、代理方式
![代理方式实现读写分离](https://oss.javaguide.cn/github/javaguide/high-performance/read-and-write-separation-and-library-subtable/read-and-write-separation-proxy.png)
我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。
提供类似功能的中间件有 MySQL Router(官方)、Atlas(基于 MySQL Proxy)、MaxScale、MyCat。
2、组件方式
在这种方式中,我们可以通过引入第三方组件来帮助我们读写请求。
这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 sharding-jdbc
,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。
(4)主从复制原理是什么?
- 主库将数据库中数据的变化写入到 binlog
- 从库连接主库
- 从库会创建一个 I/O 线程向主库请求更新的 binlog
- 主库会创建一个 binlog dump 线程来发送 binlog ,从库中的 I/O 线程负责接收
- 从库的 I/O 线程将接收的 binlog 写入到 relay log 中。
- 从库的 SQL 线程读取 relay log 同步数据本地(也就是再执行一遍 SQL )。
MySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog。
2、分库分表
读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?换言之,我们该如何解决 MySQL 的存储压力呢?
答案之一就是 分库分表。
(1)什么是分库?
分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。
垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。
举个例子:将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。
水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。
举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。
![img](https://cdn.nlark.com/yuque/0/2023/png/36098302/1696840359616-e56d101a-be31-4e4a-95f1-f2f1423857ee.png?x-oss-process=image%2Fresize%2Cw_741%2Climit_0)
(2)什么是分表?
分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。
垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。
举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。
水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。
举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。
水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。
(3)什么情况下需要分库分表?
遇到下面几种场景可以考虑分库分表:
- 单表的数据达到千万级别以上,数据库读写速度比较缓慢。
- 数据库中的数据占用的空间越来越大,备份时间越来越长。
- 应用的并发量太大。
(4)常见的分片算法有哪些?
分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。
- 哈希分片:求指定 key(比如 id) 的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。
- 范围分片:按照特性的范围区间(比如时间区间、ID 区间)来分配数据,比如 将
id
为1~299999
的记录分到第一个库,300000~599999
的分到第二个库。范围分片适合需要经常进行范围查找的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。 - 地理位置分片:很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。
- 融合算法:灵活组合多种分片算法,比如将哈希分片和范围分片组合。
(5)分库分表会带来什么问题呢?
join 操作:同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。不过,很多大厂的资深 DBA 都是建议尽量不要使用 join 操作。因为 join 的效率低,并且会对分库分表造成影响。
事务问题:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。
分布式 ID:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。
跨库聚合查询问题:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。为了实现这些操作,需要编写复杂的业务代码,或者使用中间件来协调分片间的通信和数据传输。
(6)分库分表比较推荐的方案?
Apache ShardingSphere 是一款分布式的数据库生态系统, 可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。
![ShardingSphere 提供的功能](https://oss.javaguide.cn/github/javaguide/high-performance/shardingsphere-features.png)
分库分表的实战文章:《芋道 Spring Boot 分库分表入门》
mycat
基于 cobar 改造的, 属于 proxy 层方案, 支持的功能非常完善, 而且目前应该是非常火的而且不断流行的数据库中间件, 社区很活跃, 也有一些公司开始在用了。 但是确实相比于 sharding jdbc 来说, 年轻一些, 经历的锤炼少一些。
总结:
sharding-jdbc 这种 client 层方案的优点在于不用部署, 运维成本低, 不需要代理层的二次转发请求, 性能很高, 但是如果遇到升级啥的需要各个系统都重新升级版本再发布, 各个系统都需要耦合 sharding-jdbc 的依赖;
mycat 这种 proxy 层方案的缺点在于需要部署, 自己运维一套中间件, 运维成本高, 但是好处在于对于各个项目是透明的, 如果遇到升级之类的都是自己中间件那里搞就行了。
(7)分库分表后,数据怎么迁移呢?
分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?
比较简单同时也是非常常用的方案就是停机迁移,写个脚本老库的数据写到新库中。比如你在凌晨 2 点,系统使用的人数非常少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,你写一个脚本将老库的数据都同步到新库中。
如果你不想停机迁移数据的话,也可以考虑双写方案。双写方案是针对那种不能停机迁移的场景,实现起来要稍微麻烦一些。具体原理是这样的:
我们对老库的更新操作(增删改),同时也要写入新库(双写)。如果操作的数据不存在于新库的话,需要插入到新库中。 这样就能保证,咱们新库里的数据是最新的。
在迁移过程,双写只会让被更新操作过的老库中的数据同步到新库,我们还需要自己写脚本将老库中的数据和新库的数据做比对。如果新库中没有,那咱们就把数据插入到新库。如果新库有,旧库没有,就把新库对应的数据删除(冗余数据清理)。
重复上一步的操作,直到老库和新库的数据一致为止。
想要在项目中实施双写还是比较麻烦的,很容易会出现问题。我们可以借助上面提到的数据库同步工具 Canal 做增量数据迁移(还是依赖 binlog,开发和维护成本较低)。
消息队列
1、消息队列选型
Kafka、 ActiveMQ、 RabbitMQ、 RocketMQ 有什么优缺点?
![image.png](https://cdn.nlark.com/yuque/0/2023/png/36098302/1697247192909-b475b66c-cf87-4f59-b5fd-17734fe1f140.png?x-oss-process=image%2Fresize%2Cw_739%2Climit_0)
2、Kafka 核心概念
(1)什么是 Producer、Consumer、Broker、Topic、Partition?
Kafka 将生产者发布的消息发送到 Topic(主题) 中,需要这些消息的消费者可以订阅这些 Topic(主题),如下图所示:
![img](https://oss.javaguide.cn/github/javaguide/high-performance/message-queue20210507200944439.png)
Producer(生产者) : 生产消息的一方。
Consumer(消费者) : 消费消息的一方。
Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。
Topic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。
Kafka 中的 Partition(分区) 实际上可以对应成为消息队列中的队列。
(2)Kafka 的多副本机制
Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。
生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。
带来的好处:
- Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。
- Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。
3、Kafka 消费顺序、消息丢失和重复消费
(1)Kafka 如何保证消息的消费顺序?
我们知道 Kafka 中 Partition(分区)是真正保存消息的地方,我们发送的消息都被放在了这里。而我们的 Partition(分区) 又存在于 Topic(主题) 这个概念中,并且我们可以给特定 Topic 指定多个 Partition。
每次添加消息到 Partition(分区) 的时候都会采用尾加法。 Kafka 只能为我们保证 Partition(分区) 中的消息有序。
消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。
所以,我们就有一种很简单的保证消息消费顺序的方法:1 个 Topic 只对应一个 Partition。这样当然可以解决问题,但是破坏了 Kafka 的设计初衷。
Kafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 Partition 的话,所有消息都会被发送到指定的 Partition。并且,同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key 。
总结一下,对于如何保证 Kafka 中消息消费的顺序,有了下面两种方法:
- 1 个 Topic 只对应一个 Partition。
- (推荐)发送消息的时候指定 key/Partition,然后对于 N 个消费方线程,每个线程分别消费一个Partition 即可,这样就能保证顺序性。
(2)Kafka 如何保证消息不丢失?
1、生产者丢失消息的情况
生产者(Producer) 调用send
方法发送消息之后,消息可能因为网络问题并没有发送过去。
为了确定消息是发送成功,我们要判断消息发送的结果。我们可以通过 get()
方法获取调用结果。
1 | ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(topic, o); |
如果消息发送失败的话,我们检查失败的原因之后重新发送即可!
解决办法:
设置acks=all, 一定不会丢, 要求是, 你的 leader 接收到消息, 所有的 follower都同步到了消息之后, 才认为本次写成功了。 如果没满足这个条件, 生产者会自动不断的重试, 重试无限次。
2、消费者丢失消息的情况
消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。偏移量(offset)表示 Consumer 当前消费到的 Partition(分区)的所在的位置。Kafka 通过偏移量(offset)可以保证消息在分区内的顺序性。
当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题,试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。
解决办法也比较粗暴,我们手动关闭自动提交 offset,每次在真正消费完消息之后再自己手动提交 offset 。
但是,细心的朋友一定会发现,这样会带来消息被重新消费的问题。比如你刚刚消费完消息之后,还没提交offset,结果自己挂掉了,那么这个消息理论上就会被消费两次。
3、Kafka 弄丢了消息
我们知道 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。
试想一种情况:假如 leader 副本所在的 broker 突然挂掉,那么就要从 follower 副本重新选出一个 leader ,但是 leader 的数据还有一些没有被 follower 副本的同步的话,就会造成消息丢失。
设置 acks = all,解决办法就是我们设置 acks = all。
设置 replication.factor >= 3
为了保证 leader 副本能有 follower 副本能同步消息,我们一般会为 topic 设置 replication.factor >= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。
设置 min.insync.replicas > 1
一般情况下我们还需要设置 min.insync.replicas> 1 ,这样配置代表消息至少要被写入到 2 个副本才算是被成功发送。min.insync.replicas 的默认值为 1 ,在实际生产中应尽量避免默认值 1。另外需要确保需要确保 replication.factor > min.insync.replicas。
(3)Kafka 如何保证消息不重复消费?
kafka 出现消息重复消费的原因:
- 服务端侧已经消费的数据没有成功提交 offset(根本原因)。
- Kafka 侧 由于服务端处理业务时间长或者网络链接等等原因让 Kafka 认为服务假死,触发了分区 rebalance。
1、kafka的rebalance机制:consumer group中的消费者与topic下的partion重新匹配的过程
2、何时会产生rebalance:
consumer group中的成员个数发生变化
consumer消费超时
group订阅的topic个数发生变化
group订阅的topic的分区数发生变化
解决方案:
消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。
将
enable.auto.commit
参数设置为 false,关闭自动提交,开发者在代码中手动提交 offset。那么这里会有个问题:什么时候提交 offset 合适?
- 处理完消息再提交:依旧有消息重复消费的风险,和自动提交一样
- 拉取到消息即提交:会有消息丢失的风险。允许消息延时的场景,一般会采用这种方式。然后,通过定时任务在业务不繁忙(比如凌晨)的时候做数据兜底
(4)大量消息在 MQ 里长时间积压,该如何解决?
一般这个时候, 只能临时紧急扩容了, 具体操作步骤和思路如下:
先修复 consumer 的问题, 确保其恢复消费速度, 然后将现有 consumer 都停掉。
新建一个 topic, partition 是原来的 10 倍, 临时建立好原先 10 倍的 queue 数量。
然后写一个临时的分发数据的 consumer 程序, 这个程序部署上去消费积压的数据, 消费之后不做耗时的处理, 直接均匀轮询写入临时建立好的 10 倍数量的 queue。
接着临时征用 10 倍的机器来部署 consumer, 每一批 consumer 消费一个临时 queue 的数据。 这种做法相当
于是临时将 queue 资源和 consumer 资源扩大 10 倍, 以正常的 10 倍速度来消费数据。等快速消费完积压数据之后, 得恢复原先部署的架构, 重新用原先的 consumer 机器来消费消息。
海量数据处理
1、Bitmap
bitmap是什么? 在计算机中一个字节(byte) = 8位(bit), 这里的bit就是位,数据的最小表示单位,map一般是表示地图或者映射,加一起叫作位图。
简单回顾一下二进制的一些知识:
1byte = 8bit
一个bit有2种状态,0 或者 1
所以1个byte可以表示0000 0000 -> 1111 1111, 也就是十进制的 0 到 255。
有10亿个不重复的无序的数字,如何快速排序?
在大部分编程语言里面,int类型一般的都是占4个byte,也是32位,不管你这个数字是1 或者是1亿你都得占32位,所以如果你现在有10亿数字需要存放在内存里面,需要多少内存呢?
1000000000 * 4 / 1024 / 1024 = 3800MB,大概需要3800MB内存。
为了解决这个问题,bitmap采用了一种映射机制,举个例子,假如有 1,3, 7,2, 5 这5个数字需要存放,正常情况下你需要5*4=20byte,但bitmap只需要1byte,它是咋做到呢?
假设下面是1byte,首先将所有位置为0:
0 0 0 0 0 0 0
从第一个0开始数数,把对应数字的位置置为1,比如说第一个1那就是第2个位置置为1,第二个3就是把第4个位置置为1,依此论推…
1 | 1 => 0 1 0 0 0 0 0 0 |
叠加起来最终的串就是:
1 | 0 1 1 1 0 1 0 1 |
其实最终的数字和二进制没有什么关系,纯粹是数数,这个串就可以代表最大到7的数字,然后我们就开始数数,从0开始:
1 | 比如第1个位置是1,那就记个1 |
结果就是 1 2 3 5 7,不仅仅排序了,而且还去重了!
2、问题一:统计不同号码的个数
这类题目其实是求解数据重复的问题。对于这类问题,可以使用位图法处理
8位电话号码可以表示的范围为00000000~99999999。如果用 bit表示一个号码,那么总共需要1亿个bit,总共需要大约10MB的内存。
- 创建一个长度为10^8的位图(Bitmap),每个位代表一个电话号码是否出现过。初始时,所有位都设置为0
- 读取文件中的每个电话号码,将其转换为整数。
- 对于每个电话号码,检查对应的位图位置是否为0。如果为0,则将该位置的位图设置为1,表示该号码已经出现过。
- 继续读取下一个电话号码,重复步骤3,直到读取完所有号码。
- 统计位图中值为1的位的个数,即为不同号码的个数。
3、问题二:出现频率最高的100个词
假如有一个1G大小的文件,文件里每一行是一个词,每个词的大小不超过16byte,要求返回出现频率最高的100个词。内存大小限制是10M
方案:
由于内存限制,我们无法直接将大文件的所有词一次性读到内存中。
可以采用分治策略,把一个大文件分解成多个小文件,保证每个文件的大小小于10M,进而直接将单个小文件读取到内存中进行处理。
第一步,首先遍历大文件,对遍历到的每个词x,执行 hash(x) % 500
,将结果为i的词存放到文件f(i)中,遍历结束后,可以得到500个小文件,每个小文件的大小为2M左右;
第二步,接着统计每个小文件中出现频数最高的100个词。可以使用HashMap来实现,其中key为词,value为该词出现的频率。
第三步,在第二步中找出了每个文件出现频率最高的100个词之后,通过维护一个小顶堆来找出所有小文件中出现频率最高的100个词。
4、问题三:查找两个大文件共同的URL
给定 a、b 两个文件,各存放 50 亿个 URL,每个 URL 各占 64B,找出 a、b 两个文件共同的 URL。内存限制是 4G。
方案:
每个 URL 占 64B,那么 50 亿个 URL占用的空间大小约为 320GB。5,000,000,000 * 64B ≈ 320GB
由于内存大小只有 4G,因此,不可能一次性把所有 URL 加载到内存中处理。
可以采用分治策略,也就是把一个文件中的 URL 按照某个特征划分为多个小文件,使得每个小文件大小不超过 4G,这样就可以把这个小文件读到内存中进行处理了。
首先遍历文件a,对遍历到的 URL 进行哈希取余 hash(URL) % 1000
,根据计算结果把遍历到的 URL 存储到 a0, a1,a2, …, a999,这样每个大小约为 300MB。使用同样的方法遍历文件 b,把文件 b 中的 URL 分别存储到文件 b0, b1, b2, …, b999 中。这样处理过后,所有可能相同的 URL 都在对应的小文件中,即 a0 对应 b0, …, a999 对应 b999,不对应的小文件不可能有相同的 URL。那么接下来,我们只需要求出这 1000 对小文件中相同的 URL 就好了。
接着遍历 ai( i∈[0,999]
),把 URL 存储到一个 HashSet 集合中。然后遍历 bi 中每个 URL,看在 HashSet 集合中是否存在,若存在,说明这就是共同的 URL,可以把这个 URL 保存到一个单独的文件中。
最后总结一下:
- 分而治之,进行哈希取余;
- 对每个子文件进行 HashSet 统计。
Maven
1、Maven依赖冲突及解决
Maven是一个常用的构建和项目管理工具,它可以帮助我们管理项目的依赖关系。在使用Maven构建项目时,可能会遇到依赖冲突的问题,即不同的依赖项引入了同一个类或版本不一致的类。
以下是几种常见的解决依赖冲突的方法:
- 排除冲突依赖:在pom.xml文件中,可以通过
<exclusions>
标签排除某个依赖项的特定传递依赖。例如:
1 | <dependency> |
- 引入统一版本:如果项目中引入了多个依赖项,且它们引用了相同的库但版本不同,可以通过在pom.xml中显式指定一个统一的版本来解决冲突。例如:
1 | <dependency> |
使用dependencyManagement:在父项目的pom.xml中,可以使用
<dependencyManagement>
标签来集中管理依赖项的版本。子项目可以继承父项目的依赖管理,确保所有子项目使用相同的依赖版本。使用Maven插件:Maven提供了一些插件来解决依赖冲突问题,如Maven Dependency Plugin和Maven Enforcer Plugin。这些插件可以帮助分析和解决依赖冲突,提供冲突报告和冲突解决策略。
手动调整依赖:在某些情况下,可能需要手动调整项目的依赖关系,例如移除冲突的依赖项或选择合适的替代依赖项。
解决依赖冲突问题需要根据具体情况进行分析和调整。在解决冲突时,建议使用最新的稳定版本,并确保依赖项的版本兼容性。同时,及时更新和维护项目的依赖关系,以避免潜在的冲突问题。
场景问题设计
1、对于高并发数据量,10亿级,怎么实现一个redis排行榜
对于高并发数据量,10亿级的情况下,实现一个Redis排行榜可以采取以下策略:
- 使用有序集合(Sorted Set):Redis的有序集合数据结构非常适合实现排行榜。可以将每个元素作为有序集合的成员,分数作为排序依据。分数可以是用户的得分、点击量、浏览量等。
- 分片存储:将数据按照一定的规则进行分片存储,将不同的排行榜数据分散到不同的Redis节点上。这样可以降低单个Redis节点的负载压力,提高并发处理能力。
- 合理设置过期时间:根据业务需求,合理设置排行榜数据的过期时间,避免长时间无效数据的积累。可以使用Redis的过期时间功能,自动清理过期的排行榜数据。
- 使用批量操作:对于大量的数据插入或更新操作,可以使用Redis的批量操作命令(如
ZADD
)来一次性处理多个元素,减少网络开销和提高效率。 - 使用Redis集群或分布式部署:当数据量非常大时,可以考虑使用Redis集群或将数据分布到多个Redis实例中。这样可以水平扩展Redis的处理能力,提高并发处理和数据存储的能力。
- 合理选择数据结构和算法:根据具体的排行榜需求,选择合适的数据结构和算法。例如,如果需要支持按照时间范围查询排行榜,可以使用Redis的有序集合结合时间戳作为分数,使用
ZREVRANGEBYSCORE
命令来查询指定时间范围内的排行榜数据。 - 监控和优化:定期监控Redis的性能指标,如QPS(Queries Per Second)、内存使用情况等,并进行优化。可以使用Redis的性能监控工具或第三方监控工具来帮助发现性能瓶颈和优化点。 需要根据具体的业务需求和系统架构选择适合的实现方式,并进行性能测试和调优,以确保Redis排行榜的高并发处理能力和稳定性。
2、实现用户多设备同时登录
要实现用户多设备同时登录的功能,可以使用Java中的会话管理和并发控制机制来实现。以下是一个基本的实现方案:
- 用户登录时,生成一个唯一的会话ID,并将该会话ID与用户绑定。可以使用UUID类生成唯一的会话ID。
- 将会话ID存储在后端服务器的会话管理器中,可以使用内存存储、数据库或缓存等方式。
- 每次用户在新设备上登录时,生成一个新的会话ID,并将其与用户绑定。
- 在用户每次请求时,将会话ID作为请求的一部分发送到后端服务器。
- 后端服务器接收到请求后,根据会话ID验证用户的身份,并进行相应的处理。
- 对于并发登录控制,可以使用读写锁(ReentrantReadWriteLock)来实现。在用户登录时,获取写锁,防止其他线程同时修改会话信息。在用户请求时,获取读锁,允许多个线程同时读取会话信息。
- 可以使用定时任务或心跳机制来定期清理过期的会话,以释放资源并保持会话的有效性。 需要根据具体的业务需求和系统架构选择适合的实现方式。同时,为了保证用户登录的安全性,还需考虑加密、防护攻击等安全措施。