第31讲:如何保证接口的幂等性?常见的实现方案有哪些?

幂等性问题是面试中常见的面试问题,也是分布式系统最常遇到的问题之一。在说幂等性之前,我们先来看一种情况,假如老王在某电商平台进行购物,付款的时候不小心手抖了一下,连续点击了两次支付,但此时服务器没做任何验证,于是老王账户里面的钱被扣了两次,这显然对当事人造成了一定的经济损失,并且还会让用户丧失对平台的信任。而幂等性问题说的就是如何防止接口的重复无效请求。

我们本课时的面试题是,什么是幂等性?如何保证接口的幂等性?

典型回答

幂等性最早是数学里面的一个概念,后来被用于计算机领域,用于表示任意多次请求均与一次请求执行的结果相同,也就是说对于一个接口而言,无论调用了多少次,最终得到的结果都是一样的。比如以下代码:

public class IdempotentExample {
    // 变量
    private static int count = 0;
    /**
     * 非幂等性方法
     */
    public static void addCount() {
        count++;
    }
    /**
     * 幂等性方法
     */
    public static void printCount() {
        System.out.println(count);
    }
}

public class IdempotentExample {
    // 变量
    private static int count = 0;
    /**
     * 非幂等性方法
     */
    public static void addCount() {
        count++;
    }
    /**
     * 幂等性方法
     */
    public static void printCount() {
        System.out.println(count);
    }
}

对于变量 count 来说,如果重复调用 addCount() 方法的话,会一直累加 count 的值,因为 addCount() 方法就是非幂等性方法;而 printCount() 方法只是用来打印控制台信息的。因此,它无论调用多少次结果都是一样的,所以它是幂等性方法。

知道了幂等性的概念,那如何保证幂等性呢?

幂等性的实现方案通常分为以下几类:

  • 前端拦截
  • 使用数据库实现幂等性
  • 使用 JVM 锁实现幂等性
  • 使用分布式锁实现幂等性

下面我们分别来看它们的具体实现过程。

1. 前端拦截

前端拦截是指通过 Web 站点的页面进行请求拦截,比如在用户点击完“提交”按钮后,我们可以把按钮设置为不可用或者隐藏状态,避免用户重复点击。

执行效果如下图所示:

解决幂等性java java如何保证幂等性_docker


按钮点击效果图

核心的实现代码如下:

<script>
    function subCli(){
        // 按钮设置为不可用
        document.getElementById("btn_sub").disabled="disabled";
        document.getElementById("dv1").innerText = "按钮被点击了~";
    }
</script>
<body style="margin-top: 100px;margin-left: 100px;">
    <input id="btn_sub" type="button"  value=" 提 交 "  onclick="subCli()">
    <div id="dv1" style="margin-top: 80px;"></div>
</body>

但前端拦截有一个致命的问题,如果是懂行的程序员或者黑客可以直接绕过页面的 JS 执行,直接模拟请求后端的接口,这样的话,我们前端的这些拦截就不能生效了。因此除了前端拦截一部分正常的误操作之外,后端的验证必不可少。

2. 数据库实现

数据库实现幂等性的方案有三个:

  • 通过悲观锁来实现幂等性
  • 通过唯一索引来实现幂等性
  • 通过乐观锁来实现幂等性
3. JVM 锁实现

JVM 锁实现是指通过 JVM 提供的内置锁如 Lock 或者是 synchronized 来实现幂等性。使用 JVM 锁来实现幂等性的一般流程为:首先通过 Lock 对代码段进行加锁操作,然后再判断此订单是否已经被处理过,如果未处理则开启事务执行订单处理,处理完成之后提交事务并释放锁,执行流程如下图所示:

解决幂等性java java如何保证幂等性_解决幂等性java_02


JVM 锁执行流程图

JVM 锁存在的最大问题在于,它只能应用于单机环境,因为 Lock 本身为单机锁,所以它就不适应于分布式多机环境。

4. 分布式锁实现

分布式锁实现幂等性的逻辑是,在每次执行方法之前先判断是否可以获取到分布式锁,如果可以,则表示为第一次执行方法,否则直接舍弃请求即可,执行流程如下图所示:

解决幂等性java java如何保证幂等性_docker_03


分布式锁执行流程图

需要注意的是分布式锁的 key 必须为业务的唯一标识,我们通常使用 Redis 或者 ZooKeeper 来实现分布式锁;如果使用 Redis 的话,则用 set 命令来创建和获取分布式锁,执行示例如下:

127.0.0.1:6379> set lock true ex 30 nx
OK # 创建锁成功

其中,ex 是用来设置超时时间的;而 nx 是 not exists 的意思,用来判断键是否存在。如果返回的结果为“OK”,则表示创建锁成功,否则表示重复请求,应该舍弃。更多关于 Reids 实现分布式的内容可以查看第 20 课时的内容。

考点分析

幂等性问题看似“高大上”其实说白了就是如何避免重复请求提交的问题,出于安全性的考虑,我们必须在前后端都进行幂等性验证,同时幂等性问题在日常工作中又特别常见,解决的方案也有很多,但考虑到分布式系统情况,我们应该优先使用分布式锁来实现。

和此知识点相关的面试题还有以下这些:

  • 幂等性需要注意什么问题?
  • 实现幂等性的关键步骤有哪些?
  • 说一说数据库实现幂等性的执行细节?

知识扩展

1. 幂等性注意事项

幂等性的实现与判断需要消耗一定的资源,因此不应该给每个接口都增加幂等性判断,要根据实际的业务情况和操作类型来进行区分。例如,我们在进行查询操作和删除操作时就无须进行幂等性判断。查询操作查一次和查多次的结果都是一致的,因此我们无须进行幂等性判断。删除操作也是一样,删除一次和删除多次都是把相关的数据进行删除(这里的删除指的是条件删除而不是删除所有数据),因此也无须进行幂等性判断。

2. 幂等性的关键步骤

实现幂等性的关键步骤分为以下三个:

  • 每个请求操作必须有唯一的 ID,而这个 ID 就是用来表示此业务是否被执行过的关键凭证,例如,订单支付业务的请求,就要使用订单的 ID 作为幂等性验证的 Key;
  • 每次执行业务之前必须要先判断此业务是否已经被处理过;
  • 第一次业务处理完成之后,要把此业务处理的状态进行保存,比如存储到 Redis 中或者是数据库中,这样才能防止业务被重复处理。
3. 数据库实现幂等性

使用数据库实现幂等性的方法有三种:

  • 通过悲观锁来实现幂等性
  • 通过唯一索引来实现幂等性
  • 通过乐观锁来实现幂等性

接下来我们分别来看这些实现方式的具体执行过程。

① 悲观锁

使用悲观锁实现幂等性,一般是配合事务一起来实现,在没有使用悲观锁时,我们通常的执行过程是这样的,首先来判断数据的状态,执行 SQL 如下:

select status from table_name where id='xxx';

select status from table_name where id='xxx';

然后再进行添加操作:

insert into table_name (id) values ('xxx');

insert into table_name (id) values ('xxx');

最后再进行状态的修改:

update table_name set status='xxx';

update table_name set status='xxx';

但这种情况因为是非原子操作,所以在高并发环境下可能会造成一个业务被执行两次的问题,当一个程序在执行中时,而另一个程序也开始状态判断的操作。因为第一个程序还未来得及更改状态,所以第二个程序也能执行成功,这就导致一个业务被执行了两次。

在这种情况下我们就可以使用悲观锁来避免问题的产生,实现 SQL 如下所示:

begin;  # 1.开始事务
select * from table_name where id='xxx' for update; # 2.查询状态
insert into table_name (id) values ('xxx'); # 3.添加操作
update table_name set status='xxx'; # 4.更改操作
commit; # 5.提交事务

begin;  # 1.开始事务
select * from table_name where id='xxx' for update; # 2.查询状态
insert into table_name (id) values ('xxx'); # 3.添加操作
update table_name set status='xxx'; # 4.更改操作
commit; # 5.提交事务

在实现的过程中需要注意以下两个问题:

  • 如果使用的是 MySQL 数据库,必须选用 innodb 存储引擎,因为 innodb 支持事务;
  • id 字段一定要是主键或者是唯一索引,不然会锁表,影响其他业务执行。

② 唯一索引

我们可以创建一个唯一索引的表来实现幂等性,在每次执行业务之前,先执行插入操作,因为唯一字段就是业务的 ID,因此如果重复插入的话会触发唯一约束而导致插入失败。在这种情况下(插入失败)我们就可以判定它为重复提交的请求。

唯一索引表的创建示例如下:

CREATE TABLE `table_name` (
  `id` int NOT NULL AUTO_INCREMENT,
  `orderid` varchar(32) NOT NULL DEFAULT '' COMMENT '唯一id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_orderid` (`orderid`) COMMENT '唯一约束'
) ENGINE=InnoDB;

CREATE TABLE `table_name` (
  `id` int NOT NULL AUTO_INCREMENT,
  `orderid` varchar(32) NOT NULL DEFAULT '' COMMENT '唯一id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_orderid` (`orderid`) COMMENT '唯一约束'
) ENGINE=InnoDB;

③ 乐观锁

乐观锁是指在执行数据操作时(更改或添加)进行加锁操作,其他时间不加锁,因此相比于整个执行过程都加锁的悲观锁来说,它的执行效率要高很多。

乐观锁可以通过版本号来实现,例如以下 SQL:

update table_name set version=version+1 where version=0;

update table_name set version=version+1 where version=0;

小结

幂等性不但可以保证程序正常执行,还可以杜绝一些垃圾数据以及无效请求对系统资源的消耗。本课时我们讲了幂等性的 6 种实现方式,包括前端拦截、数据库悲观锁实现、数据唯一索引实现、数据库乐观锁实现、JVM 锁实现,以及分布式锁的实现等方案,其中前端拦截无法防止懂行的人直接绕过前端进行模拟请求的操作。因此后端一定要实现幂等性处理,推荐的做法是使用分布式锁来实现,这样的解决方案更加通用。


第32讲:TCP 为什么需要三次握手?

TCP 协议是我们每天都在使用的一个网络通讯协议,因为绝大部分的网络连接都是建立在 TCP 协议上的,比如你此刻正在看的这篇文章是建立在 HTTP(Hypertext Transfer Protocol,超文本传送协议) 应用层协议的基础上的,而 HTTP 协议的“底层”则是建立在 TCP 的传输层协议上的。因此可以理解为,你之所以能看到本篇文章就是得益于 TCP 协议的功劳。

我们本课时的面试题是,说一下 TCP 三次握手的执行流程,以及为什么需要三次握手?

典型回答

在回答这个问题之前,首先我们需要搞清楚两个概念,第一,什么是 TCP?第二,什么是 TCP 连接?只有搞明白了这两个问题,我们才能彻底搞懂为什么 TCP 需要三次握手?

什么是 TCP?

首先来说 TCP(Transmission Control Protocol,传输控制协议)是一个面向连接的、可靠的、基于字节流的传输层协议。从它的概念中我们可以看出 TCP 的三个特点:面向连接、可靠性和面向字节流

解决幂等性java java如何保证幂等性_面试_04


TCP 的特点

面向连接:是指 TCP 是面向客户端和服务器端连接的通讯协议,使用它可以将客户端和服务器端进行连接。

可靠性:是指无论网络环境多差,TCP 都可以保证信息一定能够传递到接收端。

TCP 之所以可以保证可靠性主要得益于两个方面,一个是“状态性”,另一个是“可控制性”。所谓状态性是指 TCP 会记录信息的发送状态,例如,哪些数据收到了、哪些数据没收到等状态信息都会被记录;可控制性是指 TCP 会根据状态情况控制自己的行为,比如当 TCP 意识到丢包了就会控制重发此包,这样就实现了 TCP 的可靠性。

面向字节流:是指 TCP 是以字节流的方式进行数据传输的。

RFC 793 对 TCP 连接的定义如下:

Connections:
The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream.
The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.
小贴士:TCP 之所以被广泛应用,首先是因为它是一个标准化的协议,TCP 的标准协议就是由 RFC 793 定义的,它已经有了 30 多年的历史,并且已经被多次更新。RFC(Request For Comments)是 IETF(Internet Engineering Task Force)的正式文档。IETF 是一家制定互联网标准的组织,它制定了 Internet(互联网)的整体协议体系,凡是经过 IETF 评审认可的标准都会被发布为带编号的 RFC 的文档。

TCP 定义的大致意思是,用于保证可靠性和流控制机制的信息,包括 Socket、序列号及窗口大小被称为连接。

其中,Socket 是由 IP 地址加端口号组成的,序列号是用来解决乱序问题的,而窗口大小则是用来做流量控制的。

接下来我们来看 TCP 三次握手的执行流程,如下图所示:

解决幂等性java java如何保证幂等性_docker_05


TCP 三次握手的执行流程图

关键字说明:

  • SYN(Synchronize Sequence Numbers),同步序列编号;
  • ACK(Acknowledge Character),确认字符;
  • SEQ(Sequence Number),序列号。

TCP 的执行流程如下:

  • 最开始时客户端和服务端都处于 CLOSED 状态,然后服务端先主动监听某个端口,此时服务器端就变成了 LISTEN(监听)状态;
  • 然后客户端主动发起连接,发送 SYN(同步序列编号),此时客户端就变成了 SYN-SENT 状态;
  • 服务端接收到信息之后返回 SYN 和 ACK 至客户端,此时服务器端就变成了 SYN-REVD 状态;
  • 客户端接收到消息之后,再发送 ACK 至服务器端,此时客户端就变成了 ESTABLISHED(已确认)状态,服务端收到 ACK 之后,也变成了 ESTABLISHED 状态,此时连接工作就执行完了。
为什么 TCP 需要三次握手?

了解了以上 TCP 的基础概念之后,我们再来看一下 TCP 为什么需要三次握手?

原因一:防止重复连接

首先来说 RFC 793 - Transmission Control Protocol 其实就指出了三次握手的主要原因,它的描述如下:

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

翻译为中文的意思是,三次握手的主要原因是为了防止旧的重复连接引起连接混乱问题。

比如在网络状况比较复杂或者网络状况比较差的情况下,发送方可能会连续发送多次建立连接的请求。如果 TCP 握手的次数只有两次,那么接收方只能选择接受请求或者拒绝接受请求,但它并不清楚这次的请求是正常的请求,还是由于网络环境问题而导致的过期请求,如果是过期请求的话就会造成错误的连接。

所以如果 TCP 是三次握手的话,那么客户端在接收到服务器端 SEQ+1 的消息之后,就可以判断当前的连接是否为历史连接,如果判断为历史连接的话就会发送终止报文(RST)给服务器端终止连接;如果判断当前连接不是历史连接的话就会发送指令给服务器端来建立连接。

原因二:同步初始化序列化

通过上面的概念我们知道 TCP 的一个重要特征就是可靠性,而 TCP 为了保证在不稳定的网络环境中构建一个稳定的数据连接,它就需要一个“序列号”字段来保证自己的稳定性,而这个序列号的作用就是防止数据包重复发送,以及有效的解决数据包接收时顺序颠倒的问题。

那么在建立 TCP 连接时就需要同步初始化一个序列号来保证 TCP 的稳定性,因此它需要执行以下过程:

  • 首先客户端发送一个携带了初始序列号的 SYN 报文给服务器端;
  • 服务端接收到消息之后会回复一个 ACK 的应答报文,表示客户端的 SYN 报文已被服务端成功接收了;
  • 而客户端收到消息之后也会发送一个 ACK 给服务端,服务器端拿到这个消息之后,我们就可以得到一个可靠的初始化序列号了。

而如果是两次握手的话,就无法进行序列号的确认工作了,因此也就无法得到一个可靠的序列号了,所以 TCP 连接至少需要三次握手。

以上两种原因就是 TCP 连接为什么需要三次握手的主要原因,当然 TCP 连接还可以四次握手,甚至是五次握手,也能实现 TCP 连接的稳定性,但三次握手是最节省资源的连接方式,因此 TCP 连接应该为三次握手。

考点分析

TCP 知识是计算机编程基础,也是面试中常见的面试问题,因为我们现在所使用的大部分连接都是建立在 TCP 基础上的。因此对 TCP 的掌握可以让我们更清楚地理解技术的实现过程,也能帮我们写出更加优秀的代码,以及排查一些和网络相关的问题。

和此知识点相关的面试题还有以下这些:

  • 什么是 UDP?
  • TCP 和 UDP 有什么区别?

知识扩展

UDP 介绍

UDP(User Data Protocol,用户数据报协议)是无连接的、简单的、面向数据报的传输层协议。也就是 UDP 在发送数据之前,无须建立客户端与服务端的连接,直接发送消息即可。

UDP 的协议头有 8 个字节(64 位),如下图所示:

解决幂等性java java如何保证幂等性_docker_06


UDP 的协议头

其中源端口和目标端口是指记录发送方和接收方端口;UDP 包长度是指 UDP 头部加上 UDP 数据的总长度;UDP 校验和用于效验 UDP 的内容是否可靠。

UDP 常见的使用场景有:语音、视频等多媒体通信、DNS(域名转化)、TFTP 等。

TCP VS UDP

TCP 和 UDP 的区别主要体现在以下 7 个方面:

  • 可靠性,TCP 有“状态性”和“可控制性”可以保证消息不重复、按顺序、不丢失的发送和接收,而 UDP 则不能保证消息的可靠性;
  • 连接,TCP 是面向连接的传输层协议,传输数据前先要建立连接,而 UDP 发送数据之前无需建立连接;
  • 服务对象,TCP 服务的对象为一对一的双端应用,而 UDP 可以应用于一对一、一对多和多对多的通信场景;
  • 效率,TCP 的传输效率较低,而 UDP 的传输效率较高;
  • 流量控制,TCP 有滑动窗口可以用来控制流量,而 UDP 则不具备流量控制的能力;
  • 报文,TCP 是面向字节流的传输层协议,而 UDP 是面向报文的传输层协议;
  • 应用场景,TCP 的应用场景是对消息准确性和顺序要求较高的场景,而 UDP 则是应用于对通信效率较高、准确性要求相对较低的场景。

TCP 和 UDP 的使用场景如下图所示:

解决幂等性java java如何保证幂等性_nginx_07


TCP 和 UDP 的使用场景

小结

本课时我们介绍了 TCP 三个特点:面向连接、可靠性和面向字节流,其中可靠性主要是依赖它的状态记录和根据实际情况调整自身的行为方式。例如,当 TCP 意识到丢包时就会重发此包,这样就保证了通信的可靠性。

TCP 之所以需要三次握手的主要原因是为了防止在网络环境比较差的情况下不会进行无效的连接,同时三次握手可以实现 TCP 初始化序列号的确认工作,TCP 需要初始化一个序列号来保证消息的顺序。如果是两次握手则不能确认序列号是否正常,如果是四次握手的话会浪费系统的资源,因此 TCP 三次握手是最优的解决方案,所以 TCP 连接需要三次握手。

最后我们讲了 UDP 的概念,以及 UDP 和 TCP 的区别,在传输效率要求比较高且对可靠性要求不高的情况下可以使用 UDP,反之则应该使用 TCP。


第33讲:Nginx 的负载均衡模式有哪些?它的实现原理是什么?

Nginx 是后端工程师和运维工程师,以及前端工程师必须要掌握的必备技能,尤其在分布式系统应用越来越广泛的今天,Nginx 已经占据了 Web 服务器的大壁江山,并且还在不断地增长,比如国内的 BATJ、网易、新浪等公司都可以看到它的身影。

我们本课时的面试题是,Nginx 的负载均衡模式有哪些?它的实现原理是什么?

典型回答

在正式开始之前,我们先来了解一下什么是 Nginx?

Nginx 是一款开源的高性能轻量级 Web 服务器(也叫 HTTP 服务器),它主要提供的功能是:反向代理、负载均衡HTTP 缓存。它于 2004 年首次公开发布,2011 年成立同名公司以提供支持,2019 年 3 月被 F5 Networks 以 6.7 亿美元收购。

之所以需要使用负载均衡是因为,如果我们使用的是一台服务器,那么在高峰期时很多用户就需要排队等待系统响应,因为一台服务器能处理的并发数是固定的。例如,一个 Tomcat 在默认情况下只能开启 150 个线程(Tomcat 8.5.x 版本)来处理并发任务,如果并发数超过了最大线程数,那么新来的请求就只能排队等待处理了,如下图所示:

解决幂等性java java如何保证幂等性_docker_08

然而如果有负载均衡的话,我们就可以将所有的请求分配到不同的服务器上。假如 1 台服务器可以处理 2000 个请求,那么 5 台服务器就可以处理 10000 个请求了,这样就大大提高了系统处理业务的能力,如下图所示:

解决幂等性java java如何保证幂等性_解决幂等性java_09

知道了负载均衡的好处之后,我们来看下 Nginx 负载均衡的功能。

Nginx 主要的负载均衡策略(内置的负载均衡)有以下四种:

  • 轮询策略(默认负载均衡策略)
  • 最少连接数负载均衡策略
  • ip-hash 负载均衡策略
  • 权重负载均衡策略
1. 轮询策略

轮询负载策略是指每次将请求按顺序轮流发送至相应的服务器上,它的配置示例如下所示:

http {
    upstream myapp1 {
        server srv1.example.com;
        server srv2.example.com;
        server srv3.example.com;
    }
    server {
        listen 80;
        location / {
            proxy_pass http://myapp1;
        }
    }
}

http {
    upstream myapp1 {
        server srv1.example.com;
        server srv2.example.com;
        server srv3.example.com;
    }
    server {
        listen 80;
        location / {
            proxy_pass http://myapp1;
        }
    }
}

在以上实例中,当我们使用“ip:80/”访问时,请求就会轮询的发送至上面配置的三台服务器上。
Nginx 可以实现 HTTP、HTTPS、FastCGI、uwsgi、SCGI、memcached 和 gRPC 的负载均衡。

2. 最少连接数负载均衡

此策略是指每次将请求分发到当前连接数最少的服务器上,也就是 Nginx 会将请求试图转发给相对空闲的服务器以实现负载平衡,它的配置示例如下:

upstream myapp1 {
    least_conn;
    server srv1.example.com;
    server srv2.example.com;
    server srv3.example.com;
}

upstream myapp1 {
    least_conn;
    server srv1.example.com;
    server srv2.example.com;
    server srv3.example.com;
}
3. 加权负载均衡

此配置方式是指每次会按照服务器配置的权重进行请求分发,权重高的服务器会收到更多的请求,这就相当于给 Nginx 在请求分发时加了一个参考的权重选项,并且这个权重值是可以人工配置的。因此我们就可以将硬件配置高,以及并发能力强的服务器的权重设置高一点,以更合理地利用服务器的资源,它配置示例如下:

upstream myapp1 {
    server srv1.example.com weight=3;
    server srv2.example.com;
    server srv3.example.com;
}

upstream myapp1 {
    server srv1.example.com weight=3;
    server srv2.example.com;
    server srv3.example.com;
}

以上配置表示,5 次请求中有 3 次请求会分发给 srv1,1 次请求会分发给 srv2,另外 1 次请求会分发给 srv3。

4. ip-hash 负载均衡

以上三种负载均衡的配置策略都不能保证将每个客户端的请求固定的分配到一台服务器上。假如用户的登录信息是保存在单台服务器上的,而不是保存在类似于 Redis 这样的第三方中间件上时,如果不能将每个客户端的请求固定的分配到一台服务器上,就会导致用户的登录信息丢失。因此用户在每次请求服务器时都需要进行登录验证,这样显然是不合理的,也是不能被用户所接受的,所以在特殊情况下我们就需要使用 ip-hash 的负载均衡策略。

ip-hash 负载均衡策略可以根据客户端的 IP,将其固定的分配到相应的服务器上,它的配置示例如下:

upstream myapp1 {
    ip_hash;
    server srv1.example.com;
    server srv2.example.com;
    server srv3.example.com;
}

upstream myapp1 {
    ip_hash;
    server srv1.example.com;
    server srv2.example.com;
    server srv3.example.com;
}

Nginx 的实现原理是,首先客户端通过访问域名地址发出 HTTP 请求,访问的域名会被 DNS 服务器解析为 Nginx 的 IP 地址,然后将请求转发至 Nginx 服务器,Nginx 接收到请求之后会通过 URL 地址和负载均衡的配置,匹配到配置的代理服务器,然后将请求转发给代理服务器,代理服务器拿到请求之后将处理结果返回给 Nginx,Nginx 再将结果返回给客户端,这样就完成了一次正常的 HTTP 交互。

考点分析

负载均衡和缓存功能是 Nginx 最常用的两个功能,这两个功能都属于高性能的调优手段,也和后端人员的关系比较密切,只有了解并会使用它们才能更好地调试和运行自己的项目,因此 Nginx 的相关知识几乎是面试中都会出现。

和此知识点相关的面试题还有以下这些:

  • 如果代理的服务器宕机了 Nginx 会如何处理?
  • Nginx 的缓存功能是如何使用的?

知识扩展

健康检测

被代理的服务器出现宕机的情况,如果被 Nginx 发现,那么 Nginx 就会将其自动标识为不可用,并且在一段时间内会禁止入站的请求访问到该服务器上。

而这个发现服务器宕机的过程就是健康检测的功能了。Nginx 的健康检测分为两种类型,主动检测和被动检测,默认的非商用 Nginx 采用的是被动检测。

所谓的被动检测是指只有访问了该服务器之后发现服务器不可用了,才会将其标识为不可用,并且在一定时间内禁止请求分发到该服务器上,而不是主动以一定的频率去检查服务器是否可用。

健康检测有两个重要参数 max_failsfail_timeout

fail_timeout 定义了健康检查的执行时长,而 max_fails 表示服务不可用的最大尝试次数,当一定时间内(此时间由 fail_timeout 定义),发生了一定次数的服务器不响应的事件(此次数由 max_fails 定义),那么 Nginx 就会将该服务器标识为不可用的服务器,并且在一定时间内禁止请求分发到该服务器。默认情况下 max_fails 设置为 1,当它设置为 0 时表示禁用此服务器的运行状况检查,它的配置示例如下:

upstream cluster{
    server srv1.example.com max_fails=2 fail_timeout=10s;
    server srv2.example.com max_fails=2 fail_timeout=10s;
}

upstream cluster{
    server srv1.example.com max_fails=2 fail_timeout=10s;
    server srv2.example.com max_fails=2 fail_timeout=10s;
}

以上配置表示,如果 10s 内发生了两次服务不可用的情况就会将该服务器标识为不可用的状态。
当服务器被标识为不可用时,只有达到了 fail_timeout 定义的时间后,才会进行再一次的健康请求检测。

而主动健康检测的实现方案有两种,一种是使用商用的 Nginx Plus 来配置主动健康检测,另一种是使用开源的第三方模块 nginx_upstream_check_module 来实现主动健康检测。

Nginx Plus 和 nginx_upstream_check_module 模块的主动健康检查配置大体都是一样的,它的配置示例如下:

upstream backend {
    server srv1.example.com;
    server srv2.example.com;
    check interval=3000 rise=1 fall=3 timeout=2000 type=http;
    check_http_send "HEAD /status HTTP/1.0\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

upstream backend {
    server srv1.example.com;
    server srv2.example.com;
    check interval=3000 rise=1 fall=3 timeout=2000 type=http;
    check_http_send "HEAD /status HTTP/1.0\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

其中,check_http_send 表示发送请求的内容,而 check_http_expect_alive 是服务器正常情况下的响应状态码,如果后端服务器的响应状态包含在此配置中,则说明是健康的状态。

Nginx 缓存

我们可以开启 Nginx 的静态资源缓存,将一些不变的静态文件,比如图片、CSS、JS 等文件进行缓存,这样在客户端访问这些资源时就不用去访问服务器了,因此响应的速度就可以大幅提升,并且节省了宝贵的服务器资源。

Nginx 开启缓存需要在 http 节点中配置 proxy_cache_path 信息,以及 server 节点中配置要缓存资源的后缀名,它的配置示例如下:

http {
  // 忽略其他的配置信息......
  proxy_cache_path  /data/cache levels=1:2 keys_zone=nuget-cache:20m max_size=50g inactive=1d;
  include nginx_proxy.conf;
  server {
    listen  80;
    server_name  srv1.example.com;    
    location ~ .*\.(gif|jpg|png|css|js)(.*) { # 要缓存的文件的后缀
      access_log off;
      add_header Cache-Control "public,max-age=24*3600";
      proxy_pass http://localhost:8080;
    }
  }
}

其中,proxy_cache_path 配置的是缓存的目录信息,以及缓存的保存时间 inactive,还有缓存的大小等信息;而“access_log off”表示关闭日志功能,proxy_pass 表示当第一次没有缓存时的请求地址,之后便会将访问到的资源缓存起来。

小结

本课时我们介绍了 Nginx,并讲了 Nginx 的四种内置负载均衡的执行策略:轮询策略(默认负载均衡策略)、最少连接数负载均衡策略、ip-hash 负载均衡策略和权重负载均衡策略,其中 ip-hash 的负载均衡策略会将客户端的请求固定分发到一台服务器上。

后面我们还介绍了 Nginx 的健康检测:主动健康检测和被动健康检测;最后我们还讲了 Nginx 的缓存功能,它可以帮我们更快的访问到静态资源。

学完本课时后,相信你对 Nginx 已经有了一个大体的认识,其中面试中被问到最多的知识点是 Nginx 的四种负载均衡以及健康检查的相关内容。


第34讲:Docker 有什么优点?使用时需要注意什么问题?

Docker 从 2013 年发展到现在,它的普及率已经可以和最常用的 MySQL 和 Redis 并驾齐驱了,从最初偶尔出现在面试中,到现在几乎成为面试中必问的问题之一。如果再不了解 Docker 相关的知识点,可能就会与自己心仪的职位擦肩而过。所以本课时将会带领你对 Docker 相关的知识做一个全面的认识。

我们本课时的面试题是,Docker 是什么?它有什么优点?

典型回答

Docker 是一个开源(开放源代码)的应用容器引擎,可以方便地对容器进行管理。可通过 Docker 打包各种环境应用配置,比如安装 JDK 环境、发布自己的 Java 程序等,然后再把它发布到任意 Linux 机器上。

Docker 中有三个重要的概念,具体如下。

  • 镜像(Image):一个特殊的文件操作系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的配置参数(如匿名卷、环境变量、用户等), 镜像不包含任何动态数据,其内容在构建之后也不会被改变。
  • 容器(Container):它是用来运行镜像的。例如,我们拉取了一个 MySQL 镜像之后,只有通过创建并启动 MySQL 容器才能正常的运行 MySQL,容器可以进行创建、启动、停止、删除、暂停等操作。
  • 仓库(Repository):用来存放镜像文件的地方,我们可以把自己制作的镜像上传到仓库中,Docker 官方维护了一个公共仓库 Docker Hub,你也可以点击这里查询并下载所有的公共镜像

解决幂等性java java如何保证幂等性_面试_10

在 Docker 出现之前,我们如果要发布自己的 Java 程序,就需要在服务器上安装 JDK(或者 JRE)、Tomcat 容器,然后配置 Tomcat 参数,对 JVM 参数进行调优等操作。然而如果要在多台服务器上运行 Java 程序,则需要将同样繁杂的步骤在每台服务器都重复执行一遍,这样显然比较耗时且笨拙的。

后来有了虚拟机的技术,我们就可以将配置环境打包到一个虚拟机镜像中,然后在需要的服务器上装载这些虚拟机,从而实现了运行环境的复制,但虚拟机会占用很多的系统资源,比如内存资源和硬盘资源等,并且虚拟机的运行需要加载整个操作系统,这样就会浪费掉好几百兆的内存资源,最重要的是因为它需要加载整个操作系统所以它的运行速度就很慢,并且还包含了一些我们用不到的冗余功能。

因为虚拟机的这些缺点,所以在后来就有了 Linux 容器(Linux Containers,LXC),它是一种进程级别的 Linux 容器,用它可以模拟一个完整的操作系统。相比于虚拟机来说,Linux 容器所占用的系统资源更少,启动速度也更快,因为它本质上是一个进程而非真实的操作系统,因此它的启动速度就比较快。

而 Docker 则是对 Linux 容器的一种封装,并提供了更加方便地使用接口,所以 Docker 一经推出就迅速流行起来。Docker 和虚拟机(VM)区别如下图所示:

解决幂等性java java如何保证幂等性_nginx_11

Docker 具备以下 6 个优点。

  • 轻量级:Docker 容器主要利用并共享主机内核,它并不是完整的操作系统,因此它更加轻量化。
  • 灵活:它可以将复杂的应用程序容器化,因此它非常灵活和方便。
  • 可移植:可以在本地构建 Docker 容器,并把它部署到云服务器或任何地方进行使用。
  • 相互隔离,方便升级:容器是高度自给自足并相互隔离的容器,这样就可以在不影响其他容器的情况下更换或升级你的 Docker 容器了。
  • 可扩展:可以在数据中心内增加并自动分发容器副本。
  • 安全:Docker 容器可以很好地约束和隔离应用程序,并且无须用户做任何配置。

考点分析

通过此面试题可以考察出面试者是否真的使用或了解过 Docker 技术,然而对于面试官来说,最关注的是面试者是否了解 Docker 和 Java 程序结合时会出现的一些问题,因此这部分的内容需要读者特别留意一下。

和此知识点相关的面试题还有以下这些:

  • Docker 的常用命令有哪些?
  • 在 Docker 中运行 Java 程序可能会存在什么问题?

知识扩展

Docker 常用命令

我们在安装了 Docker Disktop(客户端)就可以用 docker --version 命令来查看 Docker 的版本号,使用示例如下:

$ docker --version
Docker version 19.03.8, build afacb8b

$ docker --version
Docker version 19.03.8, build afacb8b

然后可以到 Docker Hub 上查找我们需要的镜像,比如 Redis 镜像,如下图所示:

解决幂等性java java如何保证幂等性_面试_12

接着我们选择并点击最多人下载的镜像,如下图所示:

解决幂等性java java如何保证幂等性_nginx_13

从描述中找到我们需要装载 Redis 的版本,然后使用 docker pull redis@ 版本号来拉取相关的镜像,或者使用 docker pull redis 直接拉取最新(版本)的 Redis 镜像,如下所示:

$ docker pull redis
Using default tag: latest
latest: Pulling from library/redis
Digest: sha256:800f2587bf3376cb01e6307afe599ddce9439deafbd4fb8562829da96085c9c5
Status: Image is up to date for redis:latest
docker.io/library/redis:latest

紧接着就可以使用 docker images 命令来查看所有下载的镜像,如下所示:

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
redis latest 235592615444 13 days ago 104MB

有了镜像之后我们就可以使用 docker run 来创建并运行容器了,使用命令如下:

$ docker run --name myredis -d redis
22f560251e68b5afb5b7b52e202dcb3d47327f2136700d5a17bca7e37fc486bf

查看运行中的容器,命令如下:

¥ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
22f560251e68 redis "docker-entrypoint.s…" About a minute ago Up About a minute 6379/tcp myredis

其中,“myredis”为容器的名称,“6379/tcp”为 Redis 的端口号,容器的 ID 为“22f560251e68”。
最后我们使用如下命令来连接 Redis:

$ docker  exec -it myredis  redis-cli
127.0.0.1:6379>

其他常用命令如下:

  • 容器停止:docker stop 容器名称
  • 启动容器:docker start 容器名称
  • 删除容器:docker rm 容器名称
  • 删除镜像:docker rmi 镜像名称
  • 查看运行的所有容器:docker ps
  • 查看所有容器:docker ps -a
  • 容器复制文件到物理机:docker cp 容器名称:容器目录 物理机目录
  • 物理机复制文件到容器:docker cp 物理机目录 容器名称:容器目录
Docker 可能存在的问题

Java 相对于 Docker 来说显然具有更悠久的历史,因此在早期的 Java 版本中(JDK 8u131)因为不能很好地识别 Docker 相关的配置信息,从而导致可能会出现 Java 程序意外被终止的情况或者是过度创建线程数而导致并发性能下降的问题。

Java 程序意外终止的主要原因是因为,在 Docker 中运行的 Java 程序因为没有明确指定 JVM 堆和直接内存等参数,而 Java 程序也不能很好地识别 Docker 的相关容量配置,导致 Java 程序试图获取了超过 Docker 本身的容量,而被 Docker 容器强制结束进程的情况(这是 Docker 自身的防御保护机制)。

过度创建线程是因为早期的 Java 版本并不能很好地识别 Docker 容器的 CPU 资源,因此会错误地识别和创建过多的线程数。比如 ParallelStreams 和 ForkJoinPool 等,它们默认就是根据当前系统的 CPU 核心数来创建对应的线程数的,但因为在 Docker 中的 Java 程序并不能很好地识别 CPU 核心数,就会导致创建的线程数量大于 CPU 的核心数量,从而导致并发效率降低的情况。

ParallelStreams 的基本用法如下:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
numbers.parallelStream().forEach(count -> {
    System.out.println("val:" + count);
});

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
numbers.parallelStream().forEach(count -> {
    System.out.println("val:" + count);
});

ParallelStreams 是将任务提交给 ForkJoinPool 来实现的,ForkJoinPool 获取本地 CPU 核心数的源码如下:

private static ForkJoinPool makeCommonPool() {
    // 忽略其他代码
    if (parallelism < 0 && // default 1 less than #cores
        (parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
        parallelism = 1;
    if (parallelism > MAX_CAP)
        parallelism = MAX_CAP;
    return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
                            "ForkJoinPool.commonPool-worker-");
}

private static ForkJoinPool makeCommonPool() {
    // 忽略其他代码
    if (parallelism < 0 && // default 1 less than #cores
        (parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
        parallelism = 1;
    if (parallelism > MAX_CAP)
        parallelism = MAX_CAP;
    return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
                            "ForkJoinPool.commonPool-worker-");
}

其中,“Runtime.getRuntime().availableProcessors()”是用来获取本地线程数量的。

要解决以上这些问题的方法,最简单的解决方案就是升级 Java 版本,比如 Java 10 就可以很好地识别 Docker 容器的这些限制。但如果使用的是老版本的 Java,那么需要在启动 JVM 的时候合理的配置堆、元数据区等内存区域大小,并指定 ForkJoinPool 的最大线程数,如下所示:

-Djava.util.concurrent.ForkJoinPool.common.parallelism=8

-Djava.util.concurrent.ForkJoinPool.common.parallelism=8

小结

本课时我们介绍了 Docker 的概念以及 Docker 中最重要的三个组件:镜像、容器和仓库,并且介绍了 Docker 的 6 大特点:轻量级、灵活、可移植、相互隔离、可扩展和安全等特点;同时还介绍了 Docker 的常见使用命令;最后介绍了 Docker 可能在老版本(JDK 8u131 之前)的 Java 中可能会存在意外停止和线程创建过多的问题以及解决方案。

通过以上内容的学习相信你对 Docker 已经有了一个系统的认识,需要特别注意是 Docker 在 Java 老版本中可能出现的问题以及解决方案,这一点在面试中经常会被问到。

OK,这一课时就讲到这里啦,恭喜你已经学习完了关于本系列的所有课程。