diff --git a/_coverpage.md b/_coverpage.md index 28b377a..6e7d2da 100644 --- a/_coverpage.md +++ b/_coverpage.md @@ -1,10 +1,10 @@ ![](lemon.png) -# lemon 1.0 +# lemon 1.1 > 一款优秀的开源笔记 -- 操作系统、算法、解决方案 -- JAVA、JVM、数据库、中间件 -- 架构、DevOps、大数据 +- JAVA、JVM、数据库、中间件 +- 操作系统、算法、解决方案、学习笔记 +- 架构、DevOps、大数据、开放性问题 [回到博客](https://view6view.club/) [点击进入](./README.md) \ No newline at end of file diff --git a/_navbar.md b/_navbar.md index 2e29fcf..801e195 100644 --- a/_navbar.md +++ b/_navbar.md @@ -9,5 +9,6 @@ * [✍ 大数据](src/BigData/README) * [✍ DevOps](src/DevOps/README) * [✍ 解决方案](src/Solution/README) + * [✍ 开放性问题](src/Open/README) * [✍ 科班学习](src/university/README) * [✍ 其它](src/Others/README) \ No newline at end of file diff --git a/_sidebar.md b/_sidebar.md index 2e29fcf..801e195 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -9,5 +9,6 @@ * [✍ 大数据](src/BigData/README) * [✍ DevOps](src/DevOps/README) * [✍ 解决方案](src/Solution/README) + * [✍ 开放性问题](src/Open/README) * [✍ 科班学习](src/university/README) * [✍ 其它](src/Others/README) \ No newline at end of file diff --git a/src/BigData/301.md b/src/BigData/301.md index 5da7fe8..a855525 100644 --- a/src/BigData/301.md +++ b/src/BigData/301.md @@ -1,3 +1 @@ -在Storm中,需要先设计一个实时计算结构,我们称之为拓扑(topology)。之后,这个拓扑结构会被提交给集群,其中主节点(master node)负责给工作节点(worker node)分配代码,工作节点负责执行代码。在一个拓扑结构中,包含spout和bolt两种角色。数据在spouts之间传递,这些spouts将数据流以tuple元组的形式发送;而bolt则负责转换数据流。 - -![Apache-Storm](images/BigData/Apache-Storm.png) \ No newline at end of file +![Apache-Hive](images/BigData/Apache-Hive架构图.png) \ No newline at end of file diff --git a/src/BigData/302.md b/src/BigData/302.md index 07801f1..5da7fe8 100644 --- a/src/BigData/302.md +++ b/src/BigData/302.md @@ -1,3 +1,3 @@ -Spark Streaming,即核心Spark API的扩展,不像Storm那样一次处理一个数据流。相反,它在处理数据流之前,会按照时间间隔对数据流进行分段切分。Spark针对连续数据流的抽象,我们称为DStream(Discretized Stream)。 DStream是小批处理的RDD(弹性分布式数据集), RDD则是分布式数据集,可以通过任意函数和滑动数据窗口(窗口计算)进行转换,实现并行操作。 +在Storm中,需要先设计一个实时计算结构,我们称之为拓扑(topology)。之后,这个拓扑结构会被提交给集群,其中主节点(master node)负责给工作节点(worker node)分配代码,工作节点负责执行代码。在一个拓扑结构中,包含spout和bolt两种角色。数据在spouts之间传递,这些spouts将数据流以tuple元组的形式发送;而bolt则负责转换数据流。 -![Apache-Spark](images/BigData/Apache-Spark.png) \ No newline at end of file +![Apache-Storm](images/BigData/Apache-Storm.png) \ No newline at end of file diff --git a/src/BigData/303.md b/src/BigData/303.md index 6c59437..07801f1 100644 --- a/src/BigData/303.md +++ b/src/BigData/303.md @@ -1,530 +1,3 @@ -Apache Flink是一个**框架**和**分布式处理引擎**,用于在**无界**和**有界**数据流上进行**有状态的计算**。Flink被设计为在所有常见的集群环境中运行,以内存中的速度和任何规模执行计算。 +Spark Streaming,即核心Spark API的扩展,不像Storm那样一次处理一个数据流。相反,它在处理数据流之前,会按照时间间隔对数据流进行分段切分。Spark针对连续数据流的抽象,我们称为DStream(Discretized Stream)。 DStream是小批处理的RDD(弹性分布式数据集), RDD则是分布式数据集,可以通过任意函数和滑动数据窗口(窗口计算)进行转换,实现并行操作。 -![Apache-Flink](images/BigData/Apache-Flink.png) - -针对流数据+批数据的计算框架。把批数据看作流数据的一种特例,延迟性较低(毫秒级),且能保证消息传输不丢失不重复。 - -Flink创造性地统一了流处理和批处理,作为流处理看待时输入数据流是无界的,而批处理被作为一种特殊的流处理,只是它的输入数据流被定义为有界的。Flink程序由Stream和Transformation这两个基本构建块组成,其中Stream是一个中间结果数据,而Transformation是一个操作,它对一个或多个输入Stream进行计算处理,输出一个或多个结果Stream。 - - - -### 处理无界和有界数据 - -数据可以作为无界流或有界流被处理: - -- **Unbounded streams**(无界流)有一个起点,但没有定义的终点。它们不会终止,而且会源源不断的提供数据。无边界的流必须被连续地处理,即事件达到后必须被立即处理。等待所有输入数据到达是不可能的,因为输入是无界的,并且在任何时间点都不会完成。处理无边界的数据通常要求以特定顺序(例如,事件发生的顺序)接收事件,以便能够推断出结果的完整性。 -- **Bounded streams**(有界流)有一个定义的开始和结束。在执行任何计算之前,可以通过摄取(提取)所有数据来处理有界流。处理有界流不需要有序摄取,因为有界数据集总是可以排序的。有界流的处理也称为批处理。 - -![处理无界和有界数据](images/BigData/处理无界和有界数据.png) - -**Apache Flink擅长处理无界和有界数据集**。对时间和状态的精确控制使Flink的运行时能够在无边界的流上运行任何类型的应用程序。有界流由专门为固定大小的数据集设计的算法和数据结构在内部处理,从而产生出色的性能。 - - - -### 分层API - -Flink提供了三层API。每个API在简洁性和表达性之间提供了不同的权衡,并且针对不同的使用场景 - -![Flink-分层API](images/BigData/Flink-分层API.png) - - - -### 应用场景 - -Apache Flink是开发和运行许多不同类型应用程序的最佳选择,因为它具有丰富的特性。Flink的特性包括支持流和批处理、复杂的状态管理、事件处理语义以及确保状态的一致性。此外,Flink可以部署在各种资源提供程序上,例如YARN、Apache Mesos和Kubernetes,也可以作为裸机硬件上的独立集群进行部署。配置为高可用性,Flink没有单点故障。Flink已经被证明可以扩展到数千个内核和TB级的应用程序状态,提供高吞吐量和低延迟,并支持世界上一些最苛刻的流处理应用程序。 - -下面是Flink支持的最常见的应用程序类型: - -- Event-driven Applications(事件驱动的应用程序) -- Data Analytics Applications(数据分析应用程序) -- Data Pipeline Applications(数据管道应用程序) - - - -#### Event-driven Applications - -Event-driven Applications(事件驱动的应用程序)。事件驱动的应用程序是一个有状态的应用程序,它从一个或多个事件流中获取事件,并通过触发计算、状态更新或外部操作对传入的事件作出反应。 - -事件驱动的应用程序基于有状态的流处理应用程序。在这种设计中,数据和计算被放在一起,从而可以进行本地(内存或磁盘)数据访问。通过定期将检查点写入远程持久存储,可以实现容错。下图描述了传统应用程序体系结构和事件驱动应用程序之间的区别。 - -![Event-driven-Applications](images/BigData/Event-driven-Applications.png) - -代替查询远程数据库,事件驱动的应用程序在本地访问其数据,从而在吞吐量和延迟方面获得更好的性能。可以定期异步地将检查点同步到远程持久存,而且支持增量同步。不仅如此,在分层架构中,多个应用程序共享同一个数据库是很常见的。因此,数据库的任何更改都需要协调,由于每个事件驱动的应用程序都负责自己的数据,因此更改数据表示或扩展应用程序所需的协调较少。 - -对于事件驱动的应用程序,Flink的突出特性是savepoint。保存点是一个一致的状态镜像,可以用作兼容应用程序的起点。给定一个保存点,就可以更新或调整应用程序的规模,或者可以启动应用程序的多个版本进行A/B测试。 - -典型的事件驱动的应用程序有: - -- 欺诈检测 -- 异常检测 -- 基于规则的提醒 -- 业务流程监控 -- Web应用(社交网络) - - - -#### Data Analytics Applications - -Data Analytics Applications(数据分析应用程序)。传统上的分析是作为批处理查询或应用程序对已记录事件的有限数据集执行的。为了将最新数据合并到分析结果中,必须将其添加到分析数据集中,然后重新运行查询或应用程序,结果被写入存储系统或作为报告发出。 - -有了复杂的流处理引擎,分析也可以以实时方式执行。流查询或应用程序不是读取有限的数据集,而是接收实时事件流,并在使用事件时不断地生成和更新结果。结果要么写入外部数据库,要么作为内部状态进行维护。Dashboard应用程序可以从外部数据库读取最新的结果,也可以直接查询应用程序的内部状态。 - -Apache Flink支持流以及批处理分析应用程序,如下图所示: - -![Data-Analytics-Applications](images/BigData/Data-Analytics-Applications.png) - -典型的数据分析应用程序有: - -- 电信网络质量监控 -- 产品更新分析及移动应用实验评估 -- 消费者技术中实时数据的特别分析 -- 大规模图分析 - - - -#### Data Pipeline Applications - -Data Pipeline Applications(数据管道应用程序)。提取-转换-加载(ETL)是在存储系统之间转换和移动数据的常用方法。通常,会定期触发ETL作业,以便将数据从事务性数据库系统复制到分析数据库或数据仓库。 - -数据管道的作用类似于ETL作业。它们转换和丰富数据,并可以将数据从一个存储系统移动到另一个存储系统。但是,它们以连续流模式运行,而不是周期性地触发。因此,它们能够从不断产生数据的源读取记录,并以低延迟将其移动到目的地。例如,数据管道可以监视文件系统目录中的新文件,并将它们的数据写入事件日志。另一个应用程序可能将事件流物化到数据库,或者增量地构建和完善搜索索引。 - -下图描述了周期性ETL作业和连续数据管道之间的差异: - -![Data-Pipeline-Applications](images/BigData/Data-Pipeline-Applications.png) - -与周期性ETL作业相比,连续数据管道的明显优势是减少了将数据移至其目的地的等待时间。此外,数据管道更通用,可用于更多场景,因为它们能够连续消费和产生数据。 - -典型的数据管道应用程序有: - -- 电商中实时搜索索引的建立 -- 电商中的持续ETL - - - -### 安装Flink - -https://flink.apache.org/downloads.html - -下载安装包,这里下载的是 flink-1.10.1-bin-scala_2.11.tgz - -安装参考 https://ci.apache.org/projects/flink/flink-docs-release-1.10/getting-started/tutorials/local_setup.html - -```shell -./bin/start-cluster.sh # Start Flink -``` - -![安装Flink1](images/BigData/安装Flink1.png) - -访问 http://localhost:8081 - -![安装Flink2](images/BigData/安装Flink2.png) - -运行 WordCount 示例 - -![安装Flink3](images/BigData/安装Flink3.png) - -![安装Flink4](images/BigData/安装Flink4.png) - -![安装Flink5](images/BigData/安装Flink5.png) - - - -### 商品实时推荐 - -基于Flink实现的商品实时推荐系统。flink统计商品热度,放入redis缓存,分析日志信息,将画像标签和实时记录放入Hbase。在用户发起推荐请求后,根据用户画像重排序热度榜,并结合协同过滤和标签两个推荐模块为新生成的榜单的每一个产品添加关联产品,最后返回新的用户列表。 - -#### 系统架构 - -![基于Flink商品实时推荐](images/BigData/基于Flink商品实时推荐.jpg) - -在日志数据模块(flink-2-hbase)中,又主要分为6个Flink任务: - -- **用户-产品浏览历史 -> 实现基于协同过滤的推荐逻辑** - - 通过Flink去记录用户浏览过这个类目下的哪些产品,为后面的基于Item的协同过滤做准备 实时的记录用户的评分到Hbase中,为后续离线处理做准备。数据存储在Hbase的p_history表 - -- **用户-兴趣 -> 实现基于上下文的推荐逻辑** - - 根据用户对同一个产品的操作计算兴趣度,计算规则通过操作间隔时间(如购物 - 浏览 < 100s)则判定为一次兴趣事件 通过Flink的ValueState实现,如果用户的操作Action=3(收藏),则清除这个产品的state,如果超过100s没有出现Action=3的事件,也会清除这个state。数据存储在Hbase的u_interest表 - -- **用户画像计算 -> 实现基于标签的推荐逻辑** - - v1.0按照三个维度去计算用户画像,分别是用户的颜色兴趣,用户的产地兴趣,和用户的风格兴趣.根据日志不断的修改用户画像的数据,记录在Hbase中。数据存储在Hbase的user表 - -- **产品画像记录 -> 实现基于标签的推荐逻辑** - - 用两个维度记录产品画像,一个是喜爱该产品的年龄段,另一个是性别。数据存储在Hbase的prod表 - -- **事实热度榜 -> 实现基于热度的推荐逻辑** - - 通过Flink时间窗口机制,统计当前时间的实时热度,并将数据缓存在Redis中。通过Flink的窗口机制计算实时热度,使用ListState保存一次热度榜。数据存储在redis中,按照时间戳存储list - -- **日志导入** - - 从Kafka接收的数据直接导入进Hbase事实表,保存完整的日志log,日志中包含了用户Id,用户操作的产品id,操作时间,行为(如购买,点击,推荐等)。数据按时间窗口统计数据大屏需要的数据,返回前段展示。数据存储在Hbase的con表 - - - -#### 推荐引擎逻辑 - -**基于热度的推荐逻辑** - -![基于热度的推荐逻辑](images/BigData/基于热度的推荐逻辑.jpg) - -​根据用户特征,重新排序热度榜,之后根据两种推荐算法计算得到的产品相关度评分,为每个热度榜中的产品推荐几个关联的产品。 - - - -**基于产品画像的产品相似度计算方法** - -基于产品画像的推荐逻辑依赖于产品画像和热度榜两个维度,产品画像有三个特征,包含color/country/style三个角度,通过计算用户对该类目产品的评分来过滤热度榜上的产品。 - -![基于产品画像的产品相似度计算方法](images/BigData/基于产品画像的产品相似度计算方法.jpg) - -在已经有产品画像的基础上,计算item与item之间的关联系,通过余弦相似度来计算两两之间的评分,最后在已有物品选中的情况下推荐关联性更高的产品。 - -| 相似度 | A | B | C | -| ------ | ---- | ---- | ---- | -| A | 1 | 0.7 | 0.2 | -| B | 0.7 | 1 | 0.6 | -| C | 0.2 | 0.6 | 1 | - - - -**基于协同过滤的产品相似度计算方法** - -根据产品用户表(Hbase) 去计算公式得到相似度评分: -![基于协同过滤的产品相似度计算方法.jpg](images/BigData/基于协同过滤的产品相似度计算方法.jpg) - - - -**前台推荐页面** - -当前推荐结果分为3列,分别是热度榜推荐,协同过滤推荐和产品画像推荐: -![前台推荐页面.jpg](images/BigData/前台推荐页面.jpg) - - - -### 实时计算TopN热榜 - -本案例将实现一个“实时热门商品”的需求,我们可以将“实时热门商品”翻译成程序员更好理解的需求:每隔5分钟输出最近一小时内点击量最多的前 N 个商品。将这个需求进行分解我们大概要做这么几件事情: - -- 抽取出业务时间戳,告诉 Flink 框架基于业务时间做窗口 -- 过滤出点击行为数据 -- 按一小时的窗口大小,每5分钟统计一次,做滑动窗口聚合(Sliding Window) -- 按每个窗口聚合,输出每个窗口中点击量前N名的商品 - - - -#### 数据准备 - -这里我们准备了一份淘宝用户行为数据集(来自[阿里云天池公开数据集](https://tianchi.aliyun.com/datalab/index.htm))。本数据集包含了淘宝上某一天随机一百万用户的所有行为(包括点击、购买、加购、收藏)。数据集的组织形式和MovieLens-20M类似,即数据集的每一行表示一条用户行为,由用户ID、商品ID、商品类目ID、行为类型和时间戳组成,并以逗号分隔。关于数据集中每一列的详细描述如下: - -| 列名称 | 说明 | -| :--------- | :------------------------------------------------- | -| 用户ID | 整数类型,加密后的用户ID | -| 商品ID | 整数类型,加密后的商品ID | -| 商品类目ID | 整数类型,加密后的商品所属类目ID | -| 行为类型 | 字符串,枚举类型,包括(‘pv’, ‘buy’, ‘cart’, ‘fav’) | -| 时间戳 | 行为发生的时间戳,单位秒 | - -你可以通过下面的命令下载数据集到项目的 `resources` 目录下: - -```shell -$ cd my-flink-project/src/main/resources -$ curl https://raw.githubusercontent.com/wuchong/my-flink-project/master/src/main/resources/UserBehavior.csv > UserBehavior.csv -``` - -这里是否使用 curl 命令下载数据并不重要,你也可以使用 wget 命令或者直接访问链接下载数据。关键是,**将数据文件保存到项目的 `resources` 目录下**,方便应用程序访问。 - - - -#### 编写程序 - - - -#### 创建模拟数据源 - -我们先创建一个 `UserBehavior` 的 POJO 类(所有成员变量声明成`public`便是POJO类),强类型化后能方便后续的处理。 - -```java -/** - * 用户行为数据结构 - **/ -public static class UserBehavior { - public long userId; // 用户ID - public long itemId; // 商品ID - public int categoryId; // 商品类目ID - public String behavior; // 用户行为, 包括("pv", "buy", "cart", "fav") - public long timestamp; // 行为发生的时间戳,单位秒 -} -``` - -接下来我们就可以创建一个 `PojoCsvInputFormat` 了, 这是一个读取 csv 文件并将每一行转成指定 POJO -类型(在我们案例中是 `UserBehavior`)的输入器。 - -```java -// UserBehavior.csv 的本地文件路径 -URL fileUrl = HotItems2.class.getClassLoader().getResource("UserBehavior.csv"); -Path filePath = Path.fromLocalFile(new File(fileUrl.toURI())); -// 抽取 UserBehavior 的 TypeInformation,是一个 PojoTypeInfo -PojoTypeInfo pojoType = (PojoTypeInfo) TypeExtractor.createTypeInfo(UserBehavior.class); -// 由于 Java 反射抽取出的字段顺序是不确定的,需要显式指定下文件中字段的顺序 -String[] fieldOrder = new String[]{"userId", "itemId", "categoryId", "behavior", "timestamp"}; -// 创建 PojoCsvInputFormat -PojoCsvInputFormat csvInput = new PojoCsvInputFormat<>(filePath, pojoType, fieldOrder); -``` - -下一步我们用 `PojoCsvInputFormat` 创建输入源。 - -```java -DataStream dataSource = env.createInput(csvInput, pojoType); -``` - -这就创建了一个 `UserBehavior` 类型的 `DataStream`。 - - - -#### EventTime与Watermark - -当我们说“统计过去一小时内点击量”,这里的“一小时”是指什么呢? 在 Flink 中它可以是指 ProcessingTime ,也可以是 EventTime,由用户决定。 - -- **ProcessingTime**:**事件被处理的时间**。也就是由机器的系统时间来决定 -- **EventTime**:**事件发生的时间**。一般就是数据本身携带的时间 - -在本案例中,我们需要统计业务时间上的每小时的点击量,所以要基于 EventTime 来处理。那么如果让 Flink 按照我们想要的业务时间来处理呢?这里主要有两件事情要做: - -- 告诉 Flink 我们现在按照 EventTime 模式进行处理,Flink 默认使用 ProcessingTime 处理,所以我们要显式设置下。 - - ```java - env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); - ``` - -- 指定如何获得业务时间,以及生成 Watermark。Watermark 是用来追踪业务事件的概念,可以理解成 EventTime 世界中的时钟,用来指示当前处理到什么时刻的数据了。由于我们的数据源的数据已经经过整理,没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做 Watermark。这里我们用 `AscendingTimestampExtractor` 来实现时间戳的抽取和 Watermark 的生成。 - - - -**注意**:真实业务场景一般都是存在乱序的,所以一般使用 `BoundedOutOfOrdernessTimestampExtractor`。 - -```java -DataStream timedData = dataSource - .assignTimestampsAndWatermarks(new AscendingTimestampExtractor() { - @Override - public long extractAscendingTimestamp(UserBehavior userBehavior) { - // 原始数据单位秒,将其转成毫秒 - return userBehavior.timestamp * 1000; - } - }); -``` - -这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。 - - - -#### 过滤出点击事件 - -在开始窗口操作之前,先回顾下需求“每隔5分钟输出过去一小时内点击量最多的前 N 个商品”。由于原始数据中存在点击、加购、购买、收藏各种行为的数据,但是我们只需要统计点击量,所以先使用 `FilterFunction` 将点击行为数据过滤出来。 - -```java -DataStream pvData = timedData - .filter(new FilterFunction() { - @Override - public boolean filter(UserBehavior userBehavior) throws Exception { - // 过滤出只有点击的数据 - return userBehavior.behavior.equals("pv"); - } - }); -``` - - - -#### 窗口统计点击量 - -由于要每隔5分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔5分钟滑动一次。即分别要统计 [09:00, 10:00), [09:05, 10:05), [09:10, 10:10)… 等窗口的商品点击量。是一个常见的滑动窗口需求(Sliding Window)。 - -```java -DataStream windowedData = pvData - // 对商品进行分组 - .keyBy("itemId") - // 对每个商品做滑动窗口(1小时窗口,5分钟滑动一次) - .timeWindow(Time.minutes(60), Time.minutes(5)) - // 做增量的聚合操作,它能使用AggregateFunction提前聚合掉数据,减少 state 的存储压力 - .aggregate(new CountAgg(), new WindowResultFunction()); -``` - - - -**CountAgg** - -这里的`CountAgg`实现了`AggregateFunction`接口,功能是统计窗口中的条数,即遇到一条数据就加一。 - -```java -/** - * COUNT 统计的聚合函数实现,每出现一条记录加一 - **/ -public static class CountAgg implements AggregateFunction { - @Override - public Long createAccumulator() { - return 0L; - } - - @Override - public Long add(UserBehavior userBehavior, Long acc) { - return acc + 1; - } - - @Override - public Long getResult(Long acc) { - return acc; - } - - @Override - public Long merge(Long acc1, Long acc2) { - return acc1 + acc2; - } -} -``` - - - -**WindowFunction** - -`.aggregate(AggregateFunction af, WindowFunction wf)` 的第二个参数`WindowFunction`将每个 key每个窗口聚合后的结果带上其他信息进行输出。这里实现的`WindowResultFunction`将主键商品ID,窗口,点击量封装成了`ItemViewCount`进行输出。 - -```java -/** - * 用于输出窗口的结果 - **/ -public static class WindowResultFunction implements WindowFunction { - @Override - public void apply( - Tuple key, // 窗口的主键,即 itemId - TimeWindow window, // 窗口 - Iterable aggregateResult, // 聚合函数的结果,即 count 值 - Collector collector // 输出类型为 ItemViewCount - ) throws Exception { - Long itemId = ((Tuple1) key).f0; - Long count = aggregateResult.iterator().next(); - collector.collect(ItemViewCount.of(itemId, window.getEnd(), count)); - } -} - -/** - * 商品点击量(窗口操作的输出类型) - **/ -public static class ItemViewCount { - public long itemId; // 商品ID - public long windowEnd; // 窗口结束时间戳 - public long viewCount; // 商品的点击量 - public static ItemViewCount of(long itemId, long windowEnd, long viewCount) { - ItemViewCount result = new ItemViewCount(); - result.itemId = itemId; - result.windowEnd = windowEnd; - result.viewCount = viewCount; - return result; - } -} -``` - -现在我们得到了每个商品在每个窗口的点击量的数据流。 - - - -#### TopN计算最热门商品 - -为了统计每个窗口下最热门的商品,我们需要再次按窗口进行分组,这里根据`ItemViewCount`中的`windowEnd`进行`keyBy()`操作。然后使用 `ProcessFunction` 实现一个自定义的 TopN 函数 `TopNHotItems` 来计算点击量排名前3名的商品,并将排名结果格式化成字符串,便于后续输出。 - -```java -DataStream topItems = windowedData - .keyBy("windowEnd") - .process(new TopNHotItems(3)); // 求点击量前3名的商品 -``` - -`ProcessFunction` 是 Flink 提供的一个 low-level API,用于实现更高级的功能。它主要提供了定时器 timer 的功能(支持EventTime或ProcessingTime)。本案例中我们将利用 timer 来判断何时**收齐**了某个 window 下所有商品的点击量数据。由于 Watermark 的进度是全局的, - -在 `processElement` 方法中,每当收到一条数据(`ItemViewCount`),我们就注册一个 `windowEnd+1` 的定时器(Flink 框架会自动忽略同一时间的重复注册)。`windowEnd+1` 的定时器被触发时,意味着收到了`windowEnd+1`的 Watermark,即收齐了该`windowEnd`下的所有商品窗口统计值。我们在 `onTimer()` 中处理将收集的所有商品及点击量进行排序,选出 TopN,并将排名信息格式化成字符串后进行输出。 - -这里我们还使用了 `ListState` 来存储收到的每条 `ItemViewCount` 消息,保证在发生故障时,状态数据的不丢失和一致性。`ListState` 是 Flink 提供的类似 Java `List` 接口的 State API,它集成了框架的 checkpoint 机制,自动做到了 exactly-once 的语义保证。 - -```java -/** - * 求某个窗口中前 N 名的热门点击商品,key 为窗口时间戳,输出为 TopN 的结果字符串 - **/ -public static class TopNHotItems extends KeyedProcessFunction { - private final int topSize; - public TopNHotItems(int topSize) { - this.topSize = topSize; - } - - // 用于存储商品与点击数的状态,待收齐同一个窗口的数据后,再触发 TopN 计算 - private ListState itemState; - - @Override - public void open(Configuration parameters) throws Exception { - super.open(parameters); - // 状态的注册 - ListStateDescriptor itemsStateDesc = new ListStateDescriptor<>("itemState-state", ItemViewCount.class); - itemState = getRuntimeContext().getListState(itemsStateDesc); - } - - @Override - public void processElement(ItemViewCount input, Context context, Collector collector) throws Exception { - // 每条数据都保存到状态中 - itemState.add(input); - // 注册 windowEnd+1 的 EventTime Timer, 当触发时,说明收齐了属于windowEnd窗口的所有商品数据 - context.timerService().registerEventTimeTimer(input.windowEnd + 1); - } - - @Override - public void onTimer(long timestamp, OnTimerContext ctx, Collector out) throws Exception { - // 获取收到的所有商品点击量 - List allItems = new ArrayList<>(); - for (ItemViewCount item : itemState.get()) { - allItems.add(item); - } - // 提前清除状态中的数据,释放空间 - itemState.clear(); - // 按照点击量从大到小排序 - allItems.sort(new Comparator() { - @Override - public int compare(ItemViewCount o1, ItemViewCount o2) { - return (int) (o2.viewCount - o1.viewCount); - } - }); - - // 将排名信息格式化成 String, 便于打印 - StringBuilder result = new StringBuilder(); - result.append("====================================\n"); - result.append("时间: ").append(new Timestamp(timestamp-1)).append("\n"); - for (int i=0;i 实现基于协同过滤的推荐逻辑** + + 通过Flink去记录用户浏览过这个类目下的哪些产品,为后面的基于Item的协同过滤做准备 实时的记录用户的评分到Hbase中,为后续离线处理做准备。数据存储在Hbase的p_history表 + +- **用户-兴趣 -> 实现基于上下文的推荐逻辑** + + 根据用户对同一个产品的操作计算兴趣度,计算规则通过操作间隔时间(如购物 - 浏览 < 100s)则判定为一次兴趣事件 通过Flink的ValueState实现,如果用户的操作Action=3(收藏),则清除这个产品的state,如果超过100s没有出现Action=3的事件,也会清除这个state。数据存储在Hbase的u_interest表 + +- **用户画像计算 -> 实现基于标签的推荐逻辑** + + v1.0按照三个维度去计算用户画像,分别是用户的颜色兴趣,用户的产地兴趣,和用户的风格兴趣.根据日志不断的修改用户画像的数据,记录在Hbase中。数据存储在Hbase的user表 + +- **产品画像记录 -> 实现基于标签的推荐逻辑** + + 用两个维度记录产品画像,一个是喜爱该产品的年龄段,另一个是性别。数据存储在Hbase的prod表 + +- **事实热度榜 -> 实现基于热度的推荐逻辑** + + 通过Flink时间窗口机制,统计当前时间的实时热度,并将数据缓存在Redis中。通过Flink的窗口机制计算实时热度,使用ListState保存一次热度榜。数据存储在redis中,按照时间戳存储list + +- **日志导入** + + 从Kafka接收的数据直接导入进Hbase事实表,保存完整的日志log,日志中包含了用户Id,用户操作的产品id,操作时间,行为(如购买,点击,推荐等)。数据按时间窗口统计数据大屏需要的数据,返回前段展示。数据存储在Hbase的con表 + + + +#### 推荐引擎逻辑 + +**基于热度的推荐逻辑** + +![基于热度的推荐逻辑](images/BigData/基于热度的推荐逻辑.jpg) + +​根据用户特征,重新排序热度榜,之后根据两种推荐算法计算得到的产品相关度评分,为每个热度榜中的产品推荐几个关联的产品。 + + + +**基于产品画像的产品相似度计算方法** + +基于产品画像的推荐逻辑依赖于产品画像和热度榜两个维度,产品画像有三个特征,包含color/country/style三个角度,通过计算用户对该类目产品的评分来过滤热度榜上的产品。 + +![基于产品画像的产品相似度计算方法](images/BigData/基于产品画像的产品相似度计算方法.jpg) + +在已经有产品画像的基础上,计算item与item之间的关联系,通过余弦相似度来计算两两之间的评分,最后在已有物品选中的情况下推荐关联性更高的产品。 + +| 相似度 | A | B | C | +| ------ | ---- | ---- | ---- | +| A | 1 | 0.7 | 0.2 | +| B | 0.7 | 1 | 0.6 | +| C | 0.2 | 0.6 | 1 | + + + +**基于协同过滤的产品相似度计算方法** + +根据产品用户表(Hbase) 去计算公式得到相似度评分: +![基于协同过滤的产品相似度计算方法.jpg](images/BigData/基于协同过滤的产品相似度计算方法.jpg) + + + +**前台推荐页面** + +当前推荐结果分为3列,分别是热度榜推荐,协同过滤推荐和产品画像推荐: +![前台推荐页面.jpg](images/BigData/前台推荐页面.jpg) + + + +### 实时计算TopN热榜 + +本案例将实现一个“实时热门商品”的需求,我们可以将“实时热门商品”翻译成程序员更好理解的需求:每隔5分钟输出最近一小时内点击量最多的前 N 个商品。将这个需求进行分解我们大概要做这么几件事情: + +- 抽取出业务时间戳,告诉 Flink 框架基于业务时间做窗口 +- 过滤出点击行为数据 +- 按一小时的窗口大小,每5分钟统计一次,做滑动窗口聚合(Sliding Window) +- 按每个窗口聚合,输出每个窗口中点击量前N名的商品 + + + +#### 数据准备 + +这里我们准备了一份淘宝用户行为数据集(来自[阿里云天池公开数据集](https://tianchi.aliyun.com/datalab/index.htm))。本数据集包含了淘宝上某一天随机一百万用户的所有行为(包括点击、购买、加购、收藏)。数据集的组织形式和MovieLens-20M类似,即数据集的每一行表示一条用户行为,由用户ID、商品ID、商品类目ID、行为类型和时间戳组成,并以逗号分隔。关于数据集中每一列的详细描述如下: + +| 列名称 | 说明 | +| :--------- | :------------------------------------------------- | +| 用户ID | 整数类型,加密后的用户ID | +| 商品ID | 整数类型,加密后的商品ID | +| 商品类目ID | 整数类型,加密后的商品所属类目ID | +| 行为类型 | 字符串,枚举类型,包括(‘pv’, ‘buy’, ‘cart’, ‘fav’) | +| 时间戳 | 行为发生的时间戳,单位秒 | + +你可以通过下面的命令下载数据集到项目的 `resources` 目录下: + +```shell +$ cd my-flink-project/src/main/resources +$ curl https://raw.githubusercontent.com/wuchong/my-flink-project/master/src/main/resources/UserBehavior.csv > UserBehavior.csv +``` + +这里是否使用 curl 命令下载数据并不重要,你也可以使用 wget 命令或者直接访问链接下载数据。关键是,**将数据文件保存到项目的 `resources` 目录下**,方便应用程序访问。 + + + +#### 编写程序 + + + +#### 创建模拟数据源 + +我们先创建一个 `UserBehavior` 的 POJO 类(所有成员变量声明成`public`便是POJO类),强类型化后能方便后续的处理。 + +```java +/** + * 用户行为数据结构 + **/ +public static class UserBehavior { + public long userId; // 用户ID + public long itemId; // 商品ID + public int categoryId; // 商品类目ID + public String behavior; // 用户行为, 包括("pv", "buy", "cart", "fav") + public long timestamp; // 行为发生的时间戳,单位秒 +} +``` + +接下来我们就可以创建一个 `PojoCsvInputFormat` 了, 这是一个读取 csv 文件并将每一行转成指定 POJO +类型(在我们案例中是 `UserBehavior`)的输入器。 + +```java +// UserBehavior.csv 的本地文件路径 +URL fileUrl = HotItems2.class.getClassLoader().getResource("UserBehavior.csv"); +Path filePath = Path.fromLocalFile(new File(fileUrl.toURI())); +// 抽取 UserBehavior 的 TypeInformation,是一个 PojoTypeInfo +PojoTypeInfo pojoType = (PojoTypeInfo) TypeExtractor.createTypeInfo(UserBehavior.class); +// 由于 Java 反射抽取出的字段顺序是不确定的,需要显式指定下文件中字段的顺序 +String[] fieldOrder = new String[]{"userId", "itemId", "categoryId", "behavior", "timestamp"}; +// 创建 PojoCsvInputFormat +PojoCsvInputFormat csvInput = new PojoCsvInputFormat<>(filePath, pojoType, fieldOrder); +``` + +下一步我们用 `PojoCsvInputFormat` 创建输入源。 + +```java +DataStream dataSource = env.createInput(csvInput, pojoType); +``` + +这就创建了一个 `UserBehavior` 类型的 `DataStream`。 + + + +#### EventTime与Watermark + +当我们说“统计过去一小时内点击量”,这里的“一小时”是指什么呢? 在 Flink 中它可以是指 ProcessingTime ,也可以是 EventTime,由用户决定。 + +- **ProcessingTime**:**事件被处理的时间**。也就是由机器的系统时间来决定 +- **EventTime**:**事件发生的时间**。一般就是数据本身携带的时间 + +在本案例中,我们需要统计业务时间上的每小时的点击量,所以要基于 EventTime 来处理。那么如果让 Flink 按照我们想要的业务时间来处理呢?这里主要有两件事情要做: + +- 告诉 Flink 我们现在按照 EventTime 模式进行处理,Flink 默认使用 ProcessingTime 处理,所以我们要显式设置下。 + + ```java + env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); + ``` + +- 指定如何获得业务时间,以及生成 Watermark。Watermark 是用来追踪业务事件的概念,可以理解成 EventTime 世界中的时钟,用来指示当前处理到什么时刻的数据了。由于我们的数据源的数据已经经过整理,没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做 Watermark。这里我们用 `AscendingTimestampExtractor` 来实现时间戳的抽取和 Watermark 的生成。 + + + +**注意**:真实业务场景一般都是存在乱序的,所以一般使用 `BoundedOutOfOrdernessTimestampExtractor`。 + +```java +DataStream timedData = dataSource + .assignTimestampsAndWatermarks(new AscendingTimestampExtractor() { + @Override + public long extractAscendingTimestamp(UserBehavior userBehavior) { + // 原始数据单位秒,将其转成毫秒 + return userBehavior.timestamp * 1000; + } + }); +``` + +这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。 + + + +#### 过滤出点击事件 + +在开始窗口操作之前,先回顾下需求“每隔5分钟输出过去一小时内点击量最多的前 N 个商品”。由于原始数据中存在点击、加购、购买、收藏各种行为的数据,但是我们只需要统计点击量,所以先使用 `FilterFunction` 将点击行为数据过滤出来。 + +```java +DataStream pvData = timedData + .filter(new FilterFunction() { + @Override + public boolean filter(UserBehavior userBehavior) throws Exception { + // 过滤出只有点击的数据 + return userBehavior.behavior.equals("pv"); + } + }); +``` + + + +#### 窗口统计点击量 + +由于要每隔5分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔5分钟滑动一次。即分别要统计 [09:00, 10:00), [09:05, 10:05), [09:10, 10:10)… 等窗口的商品点击量。是一个常见的滑动窗口需求(Sliding Window)。 + +```java +DataStream windowedData = pvData + // 对商品进行分组 + .keyBy("itemId") + // 对每个商品做滑动窗口(1小时窗口,5分钟滑动一次) + .timeWindow(Time.minutes(60), Time.minutes(5)) + // 做增量的聚合操作,它能使用AggregateFunction提前聚合掉数据,减少 state 的存储压力 + .aggregate(new CountAgg(), new WindowResultFunction()); +``` + + + +**CountAgg** + +这里的`CountAgg`实现了`AggregateFunction`接口,功能是统计窗口中的条数,即遇到一条数据就加一。 + +```java +/** + * COUNT 统计的聚合函数实现,每出现一条记录加一 + **/ +public static class CountAgg implements AggregateFunction { + @Override + public Long createAccumulator() { + return 0L; + } + + @Override + public Long add(UserBehavior userBehavior, Long acc) { + return acc + 1; + } + + @Override + public Long getResult(Long acc) { + return acc; + } + + @Override + public Long merge(Long acc1, Long acc2) { + return acc1 + acc2; + } +} +``` + + + +**WindowFunction** + +`.aggregate(AggregateFunction af, WindowFunction wf)` 的第二个参数`WindowFunction`将每个 key每个窗口聚合后的结果带上其他信息进行输出。这里实现的`WindowResultFunction`将主键商品ID,窗口,点击量封装成了`ItemViewCount`进行输出。 + +```java +/** + * 用于输出窗口的结果 + **/ +public static class WindowResultFunction implements WindowFunction { + @Override + public void apply( + Tuple key, // 窗口的主键,即 itemId + TimeWindow window, // 窗口 + Iterable aggregateResult, // 聚合函数的结果,即 count 值 + Collector collector // 输出类型为 ItemViewCount + ) throws Exception { + Long itemId = ((Tuple1) key).f0; + Long count = aggregateResult.iterator().next(); + collector.collect(ItemViewCount.of(itemId, window.getEnd(), count)); + } +} + +/** + * 商品点击量(窗口操作的输出类型) + **/ +public static class ItemViewCount { + public long itemId; // 商品ID + public long windowEnd; // 窗口结束时间戳 + public long viewCount; // 商品的点击量 + public static ItemViewCount of(long itemId, long windowEnd, long viewCount) { + ItemViewCount result = new ItemViewCount(); + result.itemId = itemId; + result.windowEnd = windowEnd; + result.viewCount = viewCount; + return result; + } +} +``` + +现在我们得到了每个商品在每个窗口的点击量的数据流。 + + + +#### TopN计算最热门商品 + +为了统计每个窗口下最热门的商品,我们需要再次按窗口进行分组,这里根据`ItemViewCount`中的`windowEnd`进行`keyBy()`操作。然后使用 `ProcessFunction` 实现一个自定义的 TopN 函数 `TopNHotItems` 来计算点击量排名前3名的商品,并将排名结果格式化成字符串,便于后续输出。 + +```java +DataStream topItems = windowedData + .keyBy("windowEnd") + .process(new TopNHotItems(3)); // 求点击量前3名的商品 +``` + +`ProcessFunction` 是 Flink 提供的一个 low-level API,用于实现更高级的功能。它主要提供了定时器 timer 的功能(支持EventTime或ProcessingTime)。本案例中我们将利用 timer 来判断何时**收齐**了某个 window 下所有商品的点击量数据。由于 Watermark 的进度是全局的, + +在 `processElement` 方法中,每当收到一条数据(`ItemViewCount`),我们就注册一个 `windowEnd+1` 的定时器(Flink 框架会自动忽略同一时间的重复注册)。`windowEnd+1` 的定时器被触发时,意味着收到了`windowEnd+1`的 Watermark,即收齐了该`windowEnd`下的所有商品窗口统计值。我们在 `onTimer()` 中处理将收集的所有商品及点击量进行排序,选出 TopN,并将排名信息格式化成字符串后进行输出。 + +这里我们还使用了 `ListState` 来存储收到的每条 `ItemViewCount` 消息,保证在发生故障时,状态数据的不丢失和一致性。`ListState` 是 Flink 提供的类似 Java `List` 接口的 State API,它集成了框架的 checkpoint 机制,自动做到了 exactly-once 的语义保证。 + +```java +/** + * 求某个窗口中前 N 名的热门点击商品,key 为窗口时间戳,输出为 TopN 的结果字符串 + **/ +public static class TopNHotItems extends KeyedProcessFunction { + private final int topSize; + public TopNHotItems(int topSize) { + this.topSize = topSize; + } + + // 用于存储商品与点击数的状态,待收齐同一个窗口的数据后,再触发 TopN 计算 + private ListState itemState; + + @Override + public void open(Configuration parameters) throws Exception { + super.open(parameters); + // 状态的注册 + ListStateDescriptor itemsStateDesc = new ListStateDescriptor<>("itemState-state", ItemViewCount.class); + itemState = getRuntimeContext().getListState(itemsStateDesc); + } + + @Override + public void processElement(ItemViewCount input, Context context, Collector collector) throws Exception { + // 每条数据都保存到状态中 + itemState.add(input); + // 注册 windowEnd+1 的 EventTime Timer, 当触发时,说明收齐了属于windowEnd窗口的所有商品数据 + context.timerService().registerEventTimeTimer(input.windowEnd + 1); + } + + @Override + public void onTimer(long timestamp, OnTimerContext ctx, Collector out) throws Exception { + // 获取收到的所有商品点击量 + List allItems = new ArrayList<>(); + for (ItemViewCount item : itemState.get()) { + allItems.add(item); + } + // 提前清除状态中的数据,释放空间 + itemState.clear(); + // 按照点击量从大到小排序 + allItems.sort(new Comparator() { + @Override + public int compare(ItemViewCount o1, ItemViewCount o2) { + return (int) (o2.viewCount - o1.viewCount); + } + }); + + // 将排名信息格式化成 String, 便于打印 + StringBuilder result = new StringBuilder(); + result.append("====================================\n"); + result.append("时间: ").append(new Timestamp(timestamp-1)).append("\n"); + for (int i=0;i=600 的镜像 +docker search --filter=stars=600 mysql +# --no-trunc 显示镜像完整 DESCRIPTION 描述 +docker search --no-trunc mysql +# --automated :只列出 AUTOMATED=OK 的镜像 +docker search --automated mysql +``` -```bash -# 列出正在运行的容器 -$ docker ps -a +#### 镜像下载 -# 列出所有容器(包括已停止容器) -$ docker ps -l +```shell +# 下载Redis官方最新镜像,相当于:docker pull redis:latest +docker pull redis +# 下载仓库所有Redis镜像 +docker pull -a redis +# 下载私人仓库镜像 +docker pull bitnami/redis ``` -```bash -$ docker exec -it {容器ID} /bin/bash + + +#### 镜像删除 + +```shell +# 单个镜像删除,相当于:docker rmi redis:latest +docker rmi redis +# 强制删除(针对基于镜像有运行的容器进程) +docker rmi -f redis +# 多个镜像删除,不同镜像间以空格间隔 +docker rmi -f redis tomcat nginx +# 删除本地全部镜像 +docker rmi -f $(docker images -q) ``` -停止 Docker 容器: -```bash -$ docker stop {容器ID} + +#### 镜像构建 + +```shell +# 编写dockerfile +cd /docker/dockerfile +vim mycentos +# 构建docker镜像 +docker build -f /docker/dockerfile/mycentos -t mycentos:1.1 ``` -删除指定 Docker 容器: -```bash -$ docker rm -f {容器ID} + +### 容器操作 + +提示:对于容器的操作可使用CONTAINER ID 或 NAMES。 + +#### 容器启动 + +```shell +# 新建并启动容器,参数:-i 以交互模式运行容器;-t 为容器重新分配一个伪输入终端;--name 为容器指定一个名称 +docker run -i -t --name mycentos +# 后台启动容器,参数:-d 已守护方式启动容器 +docker run -d mycentos ``` -删除停止的 Docker 容器: +注意:此时使用"docker ps -a"会发现容器已经退出。这是docker的机制:要使Docker容器后台运行,就必须有一个前台进程。解决方案:将你要运行的程序以前台进程的形式运行。 -```bash -$ docker container prune +```shell +# 启动一个或多个已经被停止的容器 +docker start redis +# 重启容器 +docker restart redis ``` -查看 Docker 容器历史运行日志: -```bash -$ docker logs {容器名} + +#### 容器进程 + +```shell +# top支持 ps 命令参数,格式:docker top [OPTIONS] CONTAINER [ps OPTIONS] +# 列出redis容器中运行进程 +docker top redis +# 查看所有运行容器的进程信息 +for i in `docker ps |grep Up|awk '{print $1}'`;do echo \ &&docker top $i; done +``` + + + +#### 容器日志 + +```shell +$ docker logs [OPTIONS] CONTAINER + Options: + --details 显示更多的信息 + -f, --follow 跟踪实时日志 + --since string 显示自某个timestamp之后的日志,或相对时间,如42m(即42分钟) + --tail string 从日志末尾显示多少行日志, 默认是all + -t, --timestamps 显示时间戳 + --until string 显示自某个timestamp之前的日志,或相对时间,如42m(即42分钟) + +# 查看redis容器日志,默认参数 +docker logs rabbitmq +# 查看redis容器日志,参数:-f 跟踪日志输出;-t 显示时间戳;--tail 仅列出最新N条容器日志; +docker logs -f -t --tail=20 redis +# 查看容器redis从2019年05月21日后的最新10条日志。 +docker logs --since="2019-05-21" --tail=10 redis + +# 查看指定时间后的日志,只显示最后100行 +$ docker logs -f -t --since="2018-02-08" --tail=100 CONTAINER_ID +# 查看最近30分钟的日志 +$ docker logs --since 30m CONTAINER_ID +# 查看某时间之后的日志 +$ docker logs -t --since="2018-02-08T13:23:37" CONTAINER_ID +# 查看某时间段日志 +$ docker logs -t --since="2018-02-08T13:23:37" --until "2018-02-09T12:23:37" CONTAINER_ID +# 查看最后100行,并过滤关键词Exception +$ docker logs -f --tail=100 CONTAINER_ID | grep "Exception" ``` -实时监听 Docker 容器运行日志: -```bash -$ docker logs -f {容器名} + +#### 容器的进入与退出 + +```shell +# 使用run方式在创建时进入 +docker run -it centos /bin/bash +# 关闭容器并退出 +exit +# 仅退出容器,不关闭 +快捷键:Ctrl + P + Q +# 直接进入centos 容器启动命令的终端,不会启动新进程,多个attach连接共享容器屏幕,参数:--sig-proxy=false 确保CTRL-D或CTRL-C不会关闭容器 +docker attach --sig-proxy=false centos +# 在 centos 容器中打开新的交互模式终端,可以启动新进程,参数:-i 即使没有附加也保持STDIN 打开;-t 分配一个伪终端 +docker exec -i -t centos /bin/bash +# 以交互模式在容器中执行命令,结果返回到当前终端屏幕 +docker exec -i -t centos ls -l /tmp +# 以分离模式在容器中执行命令,程序后台运行,结果不会反馈到当前终端 +docker exec -d centos touch cache.txt ``` -### 数据卷命令 +#### 查看容器 -创建 Docker 数据卷: +```shell +# 查看正在运行的容器 +docker ps +# 查看正在运行的容器的ID +docker ps -q +# 查看正在运行+历史运行过的容器 +docker ps -a +# 显示运行容器总文件大小 +docker ps -s -```bash -$ docker volume create {数据卷名} +# 显示最近创建容器 +docker ps -l +# 显示最近创建的3个容器 +docker ps -n 3 +# 不截断输出 +docker ps --no-trunc + +# 获取镜像redis的元信息 +docker inspect redis +# 获取正在运行的容器redis的 IP +docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis ``` -列出所有 Docker 数据卷: -```bash -$ docker volume ls + +#### 容器的停止与删除 + +```shell +#停止一个运行中的容器 +docker stop redis +#杀掉一个运行中的容器 +docker kill redis +#删除一个已停止的容器 +docker rm redis +#删除一个运行中的容器 +docker rm -f redis +#删除多个容器 +docker rm -f $(docker ps -a -q) +docker ps -a -q | xargs docker rm +# -l 移除容器间的网络连接,连接名为 db +docker rm -l db +# -v 删除容器,并删除容器挂载的数据卷 +docker rm -v redis ``` -删除指定 Docker 数据卷: -```bash -$ docker volume rm {数据卷名} + +#### 生成镜像 + +```shell +# 基于当前redis容器创建一个新的镜像;参数:-a 提交的镜像作者;-c 使用Dockerfile指令来创建镜像;-m :提交时的说明文字;-p :在commit时,将容器暂停 +docker commit -a="DeepInThought" -m="my redis" [redis容器ID] myredis:v1.1 ``` -删除未关联(失效) Docker 数据卷: -```bash -$ docker volume prune -$ docker volume rm $(docker volume ls -qf dangling=true) + +#### 容器与主机间的数据拷贝 + +```shell +# 将rabbitmq容器中的文件copy至本地路径 +docker cp rabbitmq:/[container_path] [local_path] +# 将主机文件copy至rabbitmq容器 +docker cp [local_path] rabbitmq:/[container_path]/ +# 将主机文件copy至rabbitmq容器,目录重命名为[container_path](注意与非重命名copy的区别) +docker cp [local_path] rabbitmq:/[container_path] ``` -### 文件操作命令 +## 其它操作 -从主机复制文件到 Docker 容器中: +### 清理环境 -```bash -$ sudo docker cp {主机内文件路径} {容器ID}:{容器内文件存储路径} +```shell +# 查询容器列表 +docker ps -a +# 停用容器 +sudo docker stop [CONTAINER ID] +# 删除容器 +sudo docker rm [CONTAINER ID] +# 删除镜像 +sudo docker rmi [Image ID] +# 检查是否被删除 +sudo docker images + +# 停止所有容器 +docker stop $(docker ps -a -q) +# 删除所有容器 +docker rm $(docker ps -a -q) +# 删除所有镜像 +docker rmi $(docker images -q) ``` -从 Docker 容器中复制文件到主机中: -```bash -$ sudo docker cp {容器ID}:{容器内文件路径} {主机内文件存储路径} -``` +### 文件拷贝 + +```shell +# 从主机复制到容器 +sudo docker cp host_path containerID:container_path +# 从容器复制到主机 +sudo docker cp containerID:container_path host_path +``` \ No newline at end of file diff --git a/src/DevOps/603.md b/src/DevOps/603.md new file mode 100644 index 0000000..a12deea --- /dev/null +++ b/src/DevOps/603.md @@ -0,0 +1,32 @@ +### 清理环境 + +```shell +# 查询容器列表 +docker ps -a +# 停用容器 +sudo docker stop [CONTAINER ID] +# 删除容器 +sudo docker rm [CONTAINER ID] +# 删除镜像 +sudo docker rmi [Image ID] +# 检查是否被删除 +sudo docker images + +# 停止所有容器 +docker stop $(docker ps -a -q) +# 删除所有容器 +docker rm $(docker ps -a -q) +# 删除所有镜像 +docker rmi $(docker images -q) +``` + + + +### 文件拷贝 + +```shell +# 从主机复制到容器 +sudo docker cp host_path containerID:container_path +# 从容器复制到主机 +sudo docker cp containerID:container_path host_path +``` \ No newline at end of file diff --git a/src/DevOps/701.md b/src/DevOps/701.md index 0f956f0..a317c1f 100644 --- a/src/DevOps/701.md +++ b/src/DevOps/701.md @@ -1,147 +1,87 @@ -### 侦听端口 - -```nginx -server { - # Standard HTTP Protocol - listen 80; - # Standard HTTPS Protocol - listen 443 ssl; - # For http2 - listen 443 ssl http2; - # Listen on 80 using IPv6 - listen [::]:80; - # Listen only on using IPv6 - listen [::]:80 ipv6only=on; -} -``` - +## 发布方式 +应用程序升级面临最大挑战是新旧业务切换,将软件从测试的最后阶段带到生产环境,同时要保证系统不间断提供服务。长期以来,业务升级渐渐形成了几个发布策略:蓝绿发布、灰度发布和滚动发布,目的是尽可能避免因发布导致的流量丢失或服务不可用问题。三种方式均可以做到平滑式升级,在升级过程中服务仍然保持服务的连续性,升级对外界是无感知的。那生产上选择哪种部署方法最合适呢?这取决于哪种方法最适合你的业务和技术需求。如果你们运维自动化能力储备不够,肯定是越简单越好,建议蓝绿发布,如果业务对用户依赖很强,建议灰度发布。如果是K8S平台,滚动更新是现成的方案,建议先直接使用。 -### 访问日志 - -```nginx -server { - # Relative or full path to log file - access_log /path/to/file.log; - # Turn 'on' or 'off' - access_log on; -} -``` - - - -### 域名 - -```nginx -server { - # Listen to yourdomain.com - server_name yourdomain.com; - # Listen to multiple domains server_name yourdomain.com www.yourdomain.com; - # Listen to all domains - server_name *.yourdomain.com; - # Listen to all top-level domains - server_name yourdomain.*; - # Listen to unspecified Hostnames (Listens to IP address itself) - server_name ""; -} -``` - - - -### 静态资产 - -```nginx -server { - listen 80; - server_name yourdomain.com; - location / { - root /path/to/website; - } -} -``` - - - -### 重定向 - -```nginx -server { - listen 80; - server_name www.yourdomain.com; - return 301 http://yourdomain.com$request_uri; -} -server { - listen 80; - server_name www.yourdomain.com; - location /redirect-url { - return 301 http://otherdomain.com; - } -} -``` - - - -### 反向代理 - -```nginx -server { - listen 80; - server_name yourdomain.com; - location / { - proxy_pass http://0.0.0.0:3000; - # where 0.0.0.0:3000 is your application server (Ex: node.js) bound on 0.0.0.0 listening on port 3000 - } -} -``` - - - -### 负载均衡 - -```nginx -upstream node_js { - server 0.0.0.0:3000; - server 0.0.0.0:4000; - server 123.131.121.122; -} -server { - listen 80; - server_name yourdomain.com; - location / { - proxy_pass http://node_js; - } -} -``` - - - -### SSL 协议 - -```nginx -server { - listen 443 ssl; - server_name yourdomain.com; - ssl on; - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/privatekey.pem; - ssl_stapling on; - ssl_stapling_verify on; - ssl_trusted_certificate /path/to/fullchain.pem; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_session_timeout 1h; - ssl_session_cache shared:SSL:50m; - add_header Strict-Transport-Security max-age=15768000; -} -# Permanent Redirect for HTTP to HTTPS -server { - listen 80; - server_name yourdomain.com; - return 301 https://$host$request_uri; -} -``` - -其实可以采用可视化的方式对 Nginx 进行配置,我在 GitHub 上发现了一款可以一键生成 Nginx 配置的神器,相当给力。 - -先来看看它都支持什么功能的配置:反向代理、HTTPS、HTTP/2、IPv6, 缓存、WordPress、CDN、Node.js 支持、 Python (Django) 服务器等等。 - -如果你想在线进行配置,只需要打开网站:**https://nginxconfig.io/** \ No newline at end of file +- 蓝绿发布:两套环境交替升级,旧版本保留一定时间便于回滚 +- 滚动发布:按批次停止老版本实例,启动新版本实例 +- 灰度发布:根据比例将老版本升级,例如80%用户访问是老版本,20%用户访问是新版本 + +### 蓝绿发布 + +蓝绿部署中,一共有两套系统:一套是正在提供服务系统(也就是上面说的旧版),标记为“绿色”;另一套是准备发布的系统,标记为“蓝色”。两套系统都是功能完善的,并且正在运行的系统,只是系统版本和对外服务情况不同。正在对外提供服务的老系统是绿色系统,新部署的系统是蓝色系统。 + +![蓝绿发布示意图](images/DevOps/蓝绿发布示意图.jpg) + +蓝色系统不对外提供服务,用来做啥? + +用来做发布前测试,测试过程中发现任何问题,可以直接在蓝色系统上修改,不干扰用户正在使用的系统。蓝色系统经过反复的测试、修改、验证,确定达到上线标准之后,直接将用户切换到蓝色系统, 切换后的一段时间内,依旧是蓝绿两套系统并存,但是用户访问的已经是蓝色系统。这段时间内观察蓝色系统(新系统)工作状态,如果出现问题,直接切换回绿色系统。当确信对外提供服务的蓝色系统工作正常,不对外提供服务的绿色系统已经不再需要的时候,蓝色系统正式成为对外提供服务系统,成为新的绿色系统。原先的绿色系统可以销毁,将资源释放出来,用于部署下一个蓝色系统。 + + + +**特点** + +- 蓝绿部署的目的是减少发布时的中断时间、能够快速撤回发布 +- 发布策略简单 +- 用户无感知,平滑过渡 +- 升级/回滚速度快 + +**缺点** + +- 需要准备正常业务使用资源的两倍以上服务器,防止升级期间单组无法承载业务突发 +- 短时间内浪费一定资源成本 +- 基础设施无改动,增大升级稳定性 + + + +### 滚动发布 + +一般是取出一个或者多个服务器停止服务,执行更新,并重新将其投入使用。周而复始,直到集群中所有的实例都更新成新版本。 + +![滚动发布示意图](images/DevOps/滚动发布示意图.jpg) + +**特点** + +- 用户无感知,平滑过渡 +- 节约资源 + +**缺点** + +- 部署时间慢,取决于每阶段更新时间 +- 发布策略较复杂 +- 无法确定OK的环境,不易回滚 + +**部署过程** + +- 先升级1个副本,主要做部署验证 +- 每次升级副本,自动从LB上摘掉,升级成功后自动加入集群 +- 事先需要有自动更新策略,分为若干次,每次数量/百分比可配置 +- 回滚是发布的逆过程,先从LB摘掉新版本,再升级老版本,这个过程一般时间比较长 +- 自动化要求高 + + + +### 灰度发布 + +灰度发布, 也叫金丝雀发布。是指在黑与白之间,能够平滑过渡的一种发布方式。AB test就是一种灰度发布方式,让一部分用户继续用A,一部分用户开始用B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度,而我们平常所说的金丝雀部署也就是灰度发布的一种方式。 + +![灰度发布示意图](images/DevOps/灰度发布示意图.jpg) + +灰度发布是通过切换线上并存版本之间的路由权重,逐步从一个版本切换为另一个版本的过程。灰度发布只升级部分服务,即让一部分用户继续用老版本,一部分用户开始用新版本,如果用户对新版本没什么意见,那么逐步扩大范围,把所有用户都迁移到新版本上面来。 + +![灰度发布](images/DevOps/灰度发布.png) + +**特点** + +- 保证整体系统稳定性,在初始灰度的时候就可以发现、调整问题,影响范围可控 +- 新功能逐步评估性能,稳定性和健康状况,如果出问题影响范围很小,相对用户体验也少 +- 用户无感知,平滑过渡 + +**缺点** + +- 自动化要求高 + +**部署过程** + +- 从LB摘掉灰度服务器,升级成功后再加入LB +- 少量用户流量到新版本 +- 如果灰度服务器测试成功,升级剩余服务器 \ No newline at end of file diff --git a/src/DevOps/702.md b/src/DevOps/702.md deleted file mode 100644 index 121d917..0000000 --- a/src/DevOps/702.md +++ /dev/null @@ -1,437 +0,0 @@ -### HTTP服务器 - -Nginx本身也是一个静态资源的服务器,当只有静态资源的时候,就可以使用Nginx来做服务器,如果一个网站只是静态页面的话,那么就可以通过这种方式来实现部署。 - -1、 首先在文档根目录`Docroot(/usr/local/var/www)`下创建html目录, 然后在html中放一个test.html; - -2、 配置`nginx.conf`中的server - -```nginx -user mengday staff; - -http { - server { - listen 80; - server_name localhost; - client_max_body_size 1024M; - - # 默认location - location / { - root /usr/local/var/www/html; - index index.html index.htm; - } - } -} -``` - -3、访问测试 - -- `http://localhost/` 指向`/usr/local/var/www/index.html`, index.html是安装nginx自带的html -- `http://localhost/test.html` 指向`/usr/local/var/www/html/test.html` - -> 注意:如果访问图片出现403 Forbidden错误,可能是因为nginx.conf 的第一行user配置不对,默认是#user nobody;是注释的,linux下改成user root; macos下改成user 用户名 所在组; 然后重新加载配置文件或者重启,再试一下就可以了, 用户名可以通过who am i 命令来查看。 - -4、指令简介 - -- server : 用于定义服务,http中可以有多个server块 -- listen : 指定服务器侦听请求的IP地址和端口,如果省略地址,服务器将侦听所有地址,如果省略端口,则使用标准端口 -- server_name : 服务名称,用于配置域名 -- location : 用于配置映射路径uri对应的配置,一个server中可以有多个location, location后面跟一个uri,可以是一个正则表达式, / 表示匹配任意路径, 当客户端访问的路径满足这个uri时就会执行location块里面的代码 -- root : 根路径,当访问`http://localhost/test.html`,“/test.html”会匹配到”/”uri, 找到root为`/usr/local/var/www/html`,用户访问的资源物理地址=`root + uri = /usr/local/var/www/html + /test.html=/usr/local/var/www/html/test.html` -- index : 设置首页,当只访问`server_name`时后面不跟任何路径是不走root直接走index指令的;如果访问路径中没有指定具体的文件,则返回index设置的资源,如果访问`http://localhost/html/` 则默认返回index.html - -5、location uri正则表达式 - -- `.` :匹配除换行符以外的任意字符 -- `?` :重复0次或1次 -- `+` :重复1次或更多次 -- `*` :重复0次或更多次 -- `\d` :匹配数字 -- `^` :匹配字符串的开始 -- `$` :匹配字符串的结束 -- `{n}` :重复n次 -- `{n,}` :重复n次或更多次 -- `[c]` :匹配单个字符c -- `[a-z]` :匹配a-z小写字母的任意一个 -- `(a|b|c)` : 属线表示匹配任意一种情况,每种情况使用竖线分隔,一般使用小括号括括住,匹配符合a字符 或是b字符 或是c字符的字符串 -- `\` 反斜杠:用于转义特殊字符 - -小括号()之间匹配的内容,可以在后面通过`$1`来引用,`$2`表示的是前面第二个()里的内容。正则里面容易让人困惑的是`\`转义特殊字符。 - - - -### 静态服务器 - -在公司中经常会遇到静态服务器,通常会提供一个上传的功能,其他应用如果需要静态资源就从该静态服务器中获取。 - -在`/usr/local/var/www` 下分别创建images和img目录,分别在每个目录下放一张`test.jpg` - -```nginx -http { - server { - listen 80; - server_name localhost; - - - set $doc_root /usr/local/var/www; - - # 默认location - location / { - root /usr/local/var/www/html; - index index.html index.htm; - } - - location ^~ /images/ { - root $doc_root; - } - - location ~* \.(gif|jpg|jpeg|png|bmp|ico|swf|css|js)$ { - root $doc_root/img; - } - } -} -``` - -自定义变量使用set指令,语法 set 变量名值;引用使用变量名值;引用使用变量名; 这里自定义了doc_root变量。 - -静态服务器location的映射一般有两种方式: - -- 使用路径,如 /images/ 一般图片都会放在某个图片目录下, -- 使用后缀,如 .jpg、.png 等后缀匹配模式 - -访问`http://localhost/test.jpg` 会映射到 `$doc_root/img` - -访问`http://localhost/images/test.jpg` 当同一个路径满足多个location时,优先匹配优先级高的location,由于`^~` 的优先级大于 `~`, 所以会走`/images/`对应的location - -常见的location路径映射路径有以下几种: - -- `=` 进行普通字符精确匹配。也就是完全匹配。 -- `^~` 前缀匹配。如果匹配成功,则不再匹配其他location。 -- `~` 表示执行一个正则匹配,区分大小写 -- `~*` 表示执行一个正则匹配,不区分大小写 -- `/xxx/` 常规字符串路径匹配 -- `/` 通用匹配,任何请求都会匹配到 - - - -**location优先级** - -当一个路径匹配多个location时究竟哪个location能匹配到时有优先级顺序的,而优先级的顺序于location值的表达式类型有关,和在配置文件中的先后顺序无关。相同类型的表达式,字符串长的会优先匹配。推荐:[Java面试题大全](http://mp.weixin.qq.com/s?__biz=MzI4Njc5NjM1NQ==&mid=2247504489&idx=1&sn=afd92248113146b086652ad7f89c7a7c&chksm=ebd5ed45dca26453c0cf91265d669711a4e2b4ea52f3ff4a00b063a9de46d69fcc599f151210&scene=21#wechat_redirect) - -以下是按优先级排列说明: - -- 等号类型(=)的优先级最高。一旦匹配成功,则不再查找其他匹配项,停止搜索。 -- `^~`类型表达式,不属于正则表达式。一旦匹配成功,则不再查找其他匹配项,停止搜索。 -- 正则表达式类型(`~ ~*`)的优先级次之。如果有多个location的正则能匹配的话,则使用正则表达式最长的那个。 -- 常规字符串匹配类型。按前缀匹配。 -- / 通用匹配,如果没有匹配到,就匹配通用的 - -优先级搜索问题:不同类型的location映射决定是否继续向下搜索 - -- 等号类型、`^~`类型:一旦匹配上就停止搜索了,不会再匹配其他location了 -- 正则表达式类型(`~ ~*`),常规字符串匹配类型`/xxx/` : 匹配到之后,还会继续搜索其他其它location,直到找到优先级最高的,或者找到第一种情况而停止搜索 - -location优先级从高到底: - -(`location =`) > (`location 完整路径`) > (`location ^~ 路径`) > (`location ~,~* 正则顺序`) > (`location 部分起始路径`) > (`/`) - -```nginx -location = / { - # 精确匹配/,主机名后面不能带任何字符串 / - [ configuration A ] -} -location / { - # 匹配所有以 / 开头的请求。 - # 但是如果有更长的同类型的表达式,则选择更长的表达式。 - # 如果有正则表达式可以匹配,则优先匹配正则表达式。 - [ configuration B ] -} -location /documents/ { - # 匹配所有以 /documents/ 开头的请求,匹配符合以后,还要继续往下搜索。 - # 但是如果有更长的同类型的表达式,则选择更长的表达式。 - # 如果有正则表达式可以匹配,则优先匹配正则表达式。 - [ configuration C ] -} -location ^~ /images/ { - # 匹配所有以 /images/ 开头的表达式,如果匹配成功,则停止匹配查找,停止搜索。 - # 所以,即便有符合的正则表达式location,也不会被使用 - [ configuration D ] -} - -location ~* \.(gif|jpg|jpeg)$ { - # 匹配所有以 gif jpg jpeg结尾的请求。 - # 但是 以 /images/开头的请求,将使用 Configuration D,D具有更高的优先级 - [ configuration E ] -} - -location /images/ { - # 字符匹配到 /images/,还会继续往下搜索 - [ configuration F ] -} - - -location = /test.htm { - root /usr/local/var/www/htm; - index index.htm; -} -``` - -注意:location的优先级与location配置的位置无关 - - - -### 反向代理 - -反向代理应该是Nginx使用最多的功能了,反向代理(Reverse Proxy)方式是指以代理服务器来接受internet上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给internet上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。 - -简单来说就是真实的服务器不能直接被外部网络访问,所以需要一台代理服务器,而代理服务器能被外部网络访问的同时又跟真实服务器在同一个网络环境,当然也可能是同一台服务器,端口不同而已。 - -反向代理通过`proxy_pass`指令来实现。 - -启动一个Java Web项目,端口号为8081 - -```nginx -server { - listen 80; - server_name localhost; - - location / { - proxy_pass http://localhost:8081; - proxy_set_header Host $host:$server_port; - # 设置用户ip地址 - proxy_set_header X-Forwarded-For $remote_addr; - # 当请求服务器出错去寻找其他服务器 - proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; - } - -} -``` - -当我们访问localhost的时候,就相当于访问 `localhost:8081`了 - - - -### 负载均衡 - -负载均衡也是Nginx常用的一个功能,负载均衡其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。 - -简单而言就是当有2台或以上服务器时,根据规则随机的将请求分发到指定的服务器上处理,负载均衡配置一般都需要同时配置反向代理,通过反向代理跳转到负载均衡。而Nginx目前支持自带3种负载均衡策略,还有2种常用的第三方策略。负载均衡通过upstream指令来实现。 - -#### RR(round robin :轮询 默认) - -每个请求按时间顺序逐一分配到不同的后端服务器,也就是说第一次请求分配到第一台服务器上,第二次请求分配到第二台服务器上,如果只有两台服务器,第三次请求继续分配到第一台上,这样循环轮询下去,也就是服务器接收请求的比例是 1:1, 如果后端服务器down掉,能自动剔除。轮询是默认配置,不需要太多的配置,同一个项目分别使用8081和8082端口启动项目: - -```nginx -upstream web_servers { - server localhost:8081; - server localhost:8082; -} - -server { - listen 80; - server_name localhost; - #access_log logs/host.access.log main; - - - location / { - proxy_pass http://web_servers; - # 必须指定Header Host - proxy_set_header Host $host:$server_port; - } - } -``` - -访问地址仍然可以获得响应 `http://localhost/api/user/login?username=zhangsan&password=111111` ,这种方式是轮询的 - - - -#### 权重 - -指定轮询几率,weight和访问比率成正比, 也就是服务器接收请求的比例就是各自配置的weight的比例,用于后端服务器性能不均的情况,比如服务器性能差点就少接收点请求,服务器性能好点就多处理点请求。 - -```nginx -upstream test { - server localhost:8081 weight=1; - server localhost:8082 weight=3; - server localhost:8083 weight=4 backup; -} -``` - -示例是4次请求只有一次被分配到8081上,其他3次分配到8082上。backup是指热备,只有当8081和8082都宕机的情况下才走8083 - - - -#### ip_hash - -上面的2种方式都有一个问题,那就是下一个请求来的时候请求可能分发到另外一个服务器,当我们的程序不是无状态的时候(采用了session保存数据),这时候就有一个很大的很问题了,比如把登录信息保存到了session中,那么跳转到另外一台服务器的时候就需要重新登录了,所以很多时候我们需要一个客户只访问一个服务器,那么就需要用iphash了,iphash的每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。 - -```nginx -upstream test { - ip_hash; - server localhost:8080; - server localhost:8081; -} -``` - - - -#### fair(第三方) - -按后端服务器的响应时间来分配请求,响应时间短的优先分配。这个配置是为了更快的给用户响应 - -```nginx -upstream backend { - fair; - server localhost:8080; - server localhost:8081; -} -``` - -#### url_hash(第三方) - -按访问url的hash结果来分配请求,使每个url定向到同一个后端服务器,后端服务器为缓存时比较有效。在upstream中加入hash语句,server语句中不能写入weight等其他的参数,`hash_method`是使用的hash算法 - -```nginx -upstream backend { - hash $request_uri; - hash_method crc32; - server localhost:8080; - server localhost:8081; -} -``` - -以上5种负载均衡各自适用不同情况下使用,所以可以根据实际情况选择使用哪种策略模式,不过fair和url_hash需要安装第三方模块才能使用。 - - - -### 动静分离 - -动静分离是让动态网站里的动态网页根据一定规则把不变的资源和经常变的资源区分开来,动静资源做好了拆分以后,我们就可以根据静态资源的特点将其做缓存操作,这就是网站静态化处理的核心思路。 - -```nginx -upstream web_servers { - server localhost:8081; - server localhost:8082; -} - -server { - listen 80; - server_name localhost; - - set $doc_root /usr/local/var/www; - - location ~* \.(gif|jpg|jpeg|png|bmp|ico|swf|css|js)$ { - root $doc_root/img; - } - - location / { - proxy_pass http://web_servers; - # 必须指定Header Host - proxy_set_header Host $host:$server_port; - } - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root $doc_root; - } - - } -``` - - - -### 其他 - -#### return指令 - -返回http状态码 和 可选的第二个参数可以是重定向的URL - -```nginx -location /permanently/moved/url { - return 301 http://www.example.com/moved/here; -} -``` - - - -#### rewrite指令 - -重写URI请求 rewrite,通过使用rewrite指令在请求处理期间多次修改请求URI,该指令具有一个可选参数和两个必需参数。 - -第一个(必需)参数是请求URI必须匹配的正则表达式。 - -第二个参数是用于替换匹配URI的URI。 - -可选的第三个参数是可以停止进一步重写指令的处理或发送重定向(代码301或302)的标志 - -```nginx -location /users/ { - rewrite ^/users/(.*)$ /show?user=$1 break; -} -``` - - - -#### error_page指令 - -使用error_page指令,您可以配置NGINX返回自定义页面以及错误代码,替换响应中的其他错误代码,或将浏览器重定向到其他URI。在以下示例中,`error_page`指令指定要返回404页面错误代码的页面(/404.html)。 - -```nginx -error_page 404 /404.html; -``` - - - -#### 日志 - -访问日志:需要开启压缩 gzip on; 否则不生成日志文件,打开`log_format`、`access_log`注释 - -```nginx -log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - -access_log /usr/local/etc/nginx/logs/host.access.log main; - -gzip on; -``` - - - -#### deny 指令 - -```nginx -# 禁止访问某个目录 -location ~* \.(txt|doc)${ - root $doc_root; - deny all; -} -``` - - - -#### 内置变量 - -nginx的配置文件中可以使用的内置变量以美元符`$`开始,也有人叫全局变量。其中,部分预定义的变量的值是可以改变的。另外,关注Java知音公众号,回复“后端面试”,送你一份面试题宝典! - -- `$args` :`#`这个变量等于请求行中的参数,同`$query_string` -- `$content_length` :请求头中的Content-length字段。 -- `$content_type` :请求头中的Content-Type字段。 -- `$document_root` :当前请求在root指令中指定的值。 -- `$host` :请求主机头字段,否则为服务器名称。 -- `$http_user_agent` :客户端agent信息 -- `$http_cookie` :客户端cookie信息 -- `$limit_rate` :这个变量可以限制连接速率。 -- `$request_method` :客户端请求的动作,通常为GET或POST。 -- `$remote_addr` :客户端的IP地址。 -- `$remote_port` :客户端的端口。 -- `$remote_user` :已经经过Auth Basic Module验证的用户名。 -- `$request_filename` :当前请求的文件路径,由root或alias指令与URI请求生成。 -- `$scheme` :HTTP方法(如http,https)。 -- `$server_protocol` :请求使用的协议,通常是HTTP/1.0或HTTP/1.1。 -- `$server_addr` :服务器地址,在完成一次系统调用后可以确定这个值。 -- `$server_name` :服务器名称。 -- `$server_port` :请求到达服务器的端口号。 -- `$request_uri` :包含请求参数的原始URI,不包含主机名,如:”`/foo/bar.php?arg=baz`”。 -- `$uri` :不带请求参数的当前URI,`$uri`不包含主机名,如”`/foo/bar.html`”。 -- `$document_uri` :与`$uri`相同 \ No newline at end of file diff --git a/src/DevOps/801.md b/src/DevOps/801.md index 2af670e..0f956f0 100644 --- a/src/DevOps/801.md +++ b/src/DevOps/801.md @@ -1,58 +1,147 @@ -VMware15.5安装,傻瓜式安装,只记录变动步骤,其余都下一步,软件安装位置自己选择,最好别选c盘,软件地址https://www.nocmd.com/windows/740.html(内含激活码),安装时需要注意它文件不会在一个文件夹下,自己多建一个版本文件夹,方便管理。 +### 侦听端口 + +```nginx +server { + # Standard HTTP Protocol + listen 80; + # Standard HTTPS Protocol + listen 443 ssl; + # For http2 + listen 443 ssl http2; + # Listen on 80 using IPv6 + listen [::]:80; + # Listen only on using IPv6 + listen [::]:80 ipv6only=on; +} +``` + -![在这里插入图片描述](images/DevOps/20210717124755337.png) -![在这里插入图片描述](images/DevOps/2021071712480749.png) - -**文件 > 新建虚拟机**: - -![在这里插入图片描述](images/DevOps/20210717125629709.png) - -![在这里插入图片描述](images/DevOps/20210717125643324.png) - -![在这里插入图片描述](images/DevOps/20210717125659179.png) - -![在这里插入图片描述](images/DevOps/20210717125714713.png) - -![在这里插入图片描述](images/DevOps/20210717125727428.png) - -![在这里插入图片描述](images/DevOps/20210717125743717.png) - -点击安装计算机的设置 > 选择镜像后 > 点确定: - -![在这里插入图片描述](images/DevOps/20210717125911587.png) - -开启虚拟机》选第一个install centos7 - -![在这里插入图片描述](images/DevOps/20210717125946871.png) - -等待一段时间不要乱点,乱点会卡死,》软件选择》最小安装或gui服务器或gnome桌面,选好后点完成。开发中一般都选最小安装,需要什么软件在自行选择,但其它安装可以省略jdk,mysql等安装,会自行安装。 - -![在这里插入图片描述](images/DevOps/20210717130014474.png) - -在这一步也可以选择自动配置分区,这里更快,这里我选择我要配置分区。 - -![在这里插入图片描述](images/DevOps/202107171300526.png)设置好/boot要1G,swap要2G,剩余都在根目录分区大小后,设备类型点标准分区,点完成。 - -![在这里插入图片描述](images/DevOps/20210717130123660.png) - -点接受更改 - -![在这里插入图片描述](images/DevOps/20210717130215238.png) - -网络和主机名设置,需要联网就打开以太网。 - -![在这里插入图片描述](images/DevOps/20210717130237758.png) - -最后一个像一把锁的安检策略可以关闭。 - -点开始安装 -在这个页面配置root账号密码,创建用户账号密码。在实际开发中root账号要复杂点,避免被破解。 - -![在这里插入图片描述](images/DevOps/20210717130319942.png)等待完成后,点击重启。 - -![在这里插入图片描述](images/DevOps/20210717130343591.png) - -![在这里插入图片描述](images/DevOps/20210717130404642.png) - -再把网络连接打开。 \ No newline at end of file +### 访问日志 + +```nginx +server { + # Relative or full path to log file + access_log /path/to/file.log; + # Turn 'on' or 'off' + access_log on; +} +``` + + + +### 域名 + +```nginx +server { + # Listen to yourdomain.com + server_name yourdomain.com; + # Listen to multiple domains server_name yourdomain.com www.yourdomain.com; + # Listen to all domains + server_name *.yourdomain.com; + # Listen to all top-level domains + server_name yourdomain.*; + # Listen to unspecified Hostnames (Listens to IP address itself) + server_name ""; +} +``` + + + +### 静态资产 + +```nginx +server { + listen 80; + server_name yourdomain.com; + location / { + root /path/to/website; + } +} +``` + + + +### 重定向 + +```nginx +server { + listen 80; + server_name www.yourdomain.com; + return 301 http://yourdomain.com$request_uri; +} +server { + listen 80; + server_name www.yourdomain.com; + location /redirect-url { + return 301 http://otherdomain.com; + } +} +``` + + + +### 反向代理 + +```nginx +server { + listen 80; + server_name yourdomain.com; + location / { + proxy_pass http://0.0.0.0:3000; + # where 0.0.0.0:3000 is your application server (Ex: node.js) bound on 0.0.0.0 listening on port 3000 + } +} +``` + + + +### 负载均衡 + +```nginx +upstream node_js { + server 0.0.0.0:3000; + server 0.0.0.0:4000; + server 123.131.121.122; +} +server { + listen 80; + server_name yourdomain.com; + location / { + proxy_pass http://node_js; + } +} +``` + + + +### SSL 协议 + +```nginx +server { + listen 443 ssl; + server_name yourdomain.com; + ssl on; + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/privatekey.pem; + ssl_stapling on; + ssl_stapling_verify on; + ssl_trusted_certificate /path/to/fullchain.pem; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_session_timeout 1h; + ssl_session_cache shared:SSL:50m; + add_header Strict-Transport-Security max-age=15768000; +} +# Permanent Redirect for HTTP to HTTPS +server { + listen 80; + server_name yourdomain.com; + return 301 https://$host$request_uri; +} +``` + +其实可以采用可视化的方式对 Nginx 进行配置,我在 GitHub 上发现了一款可以一键生成 Nginx 配置的神器,相当给力。 + +先来看看它都支持什么功能的配置:反向代理、HTTPS、HTTP/2、IPv6, 缓存、WordPress、CDN、Node.js 支持、 Python (Django) 服务器等等。 + +如果你想在线进行配置,只需要打开网站:**https://nginxconfig.io/** \ No newline at end of file diff --git a/src/DevOps/802.md b/src/DevOps/802.md index 61ec1c2..121d917 100644 --- a/src/DevOps/802.md +++ b/src/DevOps/802.md @@ -1,3 +1,437 @@ -- 桥接模式:同一网段中,最多只能连接255台机子,一旦超出容易造成IP冲突。IP地址的前3位就是网段(192.168.0.1) -- NAT(网络地址装换)模式:虚拟机和外部通信,不会造成IP冲突。虚拟机地址不再是以0开头,而是生成1-255之间的数,如192.168.6.1,然后主机会生成一个对应的虚拟网卡如192.168.6.6,两者能通信。这种模式下虚拟机能访问192.168.0.1,由于网段不同,192.168.0.1不能访问虚拟机 -- 主机模式:虚拟网络对主机可见,虚拟机不能上网 \ No newline at end of file +### HTTP服务器 + +Nginx本身也是一个静态资源的服务器,当只有静态资源的时候,就可以使用Nginx来做服务器,如果一个网站只是静态页面的话,那么就可以通过这种方式来实现部署。 + +1、 首先在文档根目录`Docroot(/usr/local/var/www)`下创建html目录, 然后在html中放一个test.html; + +2、 配置`nginx.conf`中的server + +```nginx +user mengday staff; + +http { + server { + listen 80; + server_name localhost; + client_max_body_size 1024M; + + # 默认location + location / { + root /usr/local/var/www/html; + index index.html index.htm; + } + } +} +``` + +3、访问测试 + +- `http://localhost/` 指向`/usr/local/var/www/index.html`, index.html是安装nginx自带的html +- `http://localhost/test.html` 指向`/usr/local/var/www/html/test.html` + +> 注意:如果访问图片出现403 Forbidden错误,可能是因为nginx.conf 的第一行user配置不对,默认是#user nobody;是注释的,linux下改成user root; macos下改成user 用户名 所在组; 然后重新加载配置文件或者重启,再试一下就可以了, 用户名可以通过who am i 命令来查看。 + +4、指令简介 + +- server : 用于定义服务,http中可以有多个server块 +- listen : 指定服务器侦听请求的IP地址和端口,如果省略地址,服务器将侦听所有地址,如果省略端口,则使用标准端口 +- server_name : 服务名称,用于配置域名 +- location : 用于配置映射路径uri对应的配置,一个server中可以有多个location, location后面跟一个uri,可以是一个正则表达式, / 表示匹配任意路径, 当客户端访问的路径满足这个uri时就会执行location块里面的代码 +- root : 根路径,当访问`http://localhost/test.html`,“/test.html”会匹配到”/”uri, 找到root为`/usr/local/var/www/html`,用户访问的资源物理地址=`root + uri = /usr/local/var/www/html + /test.html=/usr/local/var/www/html/test.html` +- index : 设置首页,当只访问`server_name`时后面不跟任何路径是不走root直接走index指令的;如果访问路径中没有指定具体的文件,则返回index设置的资源,如果访问`http://localhost/html/` 则默认返回index.html + +5、location uri正则表达式 + +- `.` :匹配除换行符以外的任意字符 +- `?` :重复0次或1次 +- `+` :重复1次或更多次 +- `*` :重复0次或更多次 +- `\d` :匹配数字 +- `^` :匹配字符串的开始 +- `$` :匹配字符串的结束 +- `{n}` :重复n次 +- `{n,}` :重复n次或更多次 +- `[c]` :匹配单个字符c +- `[a-z]` :匹配a-z小写字母的任意一个 +- `(a|b|c)` : 属线表示匹配任意一种情况,每种情况使用竖线分隔,一般使用小括号括括住,匹配符合a字符 或是b字符 或是c字符的字符串 +- `\` 反斜杠:用于转义特殊字符 + +小括号()之间匹配的内容,可以在后面通过`$1`来引用,`$2`表示的是前面第二个()里的内容。正则里面容易让人困惑的是`\`转义特殊字符。 + + + +### 静态服务器 + +在公司中经常会遇到静态服务器,通常会提供一个上传的功能,其他应用如果需要静态资源就从该静态服务器中获取。 + +在`/usr/local/var/www` 下分别创建images和img目录,分别在每个目录下放一张`test.jpg` + +```nginx +http { + server { + listen 80; + server_name localhost; + + + set $doc_root /usr/local/var/www; + + # 默认location + location / { + root /usr/local/var/www/html; + index index.html index.htm; + } + + location ^~ /images/ { + root $doc_root; + } + + location ~* \.(gif|jpg|jpeg|png|bmp|ico|swf|css|js)$ { + root $doc_root/img; + } + } +} +``` + +自定义变量使用set指令,语法 set 变量名值;引用使用变量名值;引用使用变量名; 这里自定义了doc_root变量。 + +静态服务器location的映射一般有两种方式: + +- 使用路径,如 /images/ 一般图片都会放在某个图片目录下, +- 使用后缀,如 .jpg、.png 等后缀匹配模式 + +访问`http://localhost/test.jpg` 会映射到 `$doc_root/img` + +访问`http://localhost/images/test.jpg` 当同一个路径满足多个location时,优先匹配优先级高的location,由于`^~` 的优先级大于 `~`, 所以会走`/images/`对应的location + +常见的location路径映射路径有以下几种: + +- `=` 进行普通字符精确匹配。也就是完全匹配。 +- `^~` 前缀匹配。如果匹配成功,则不再匹配其他location。 +- `~` 表示执行一个正则匹配,区分大小写 +- `~*` 表示执行一个正则匹配,不区分大小写 +- `/xxx/` 常规字符串路径匹配 +- `/` 通用匹配,任何请求都会匹配到 + + + +**location优先级** + +当一个路径匹配多个location时究竟哪个location能匹配到时有优先级顺序的,而优先级的顺序于location值的表达式类型有关,和在配置文件中的先后顺序无关。相同类型的表达式,字符串长的会优先匹配。推荐:[Java面试题大全](http://mp.weixin.qq.com/s?__biz=MzI4Njc5NjM1NQ==&mid=2247504489&idx=1&sn=afd92248113146b086652ad7f89c7a7c&chksm=ebd5ed45dca26453c0cf91265d669711a4e2b4ea52f3ff4a00b063a9de46d69fcc599f151210&scene=21#wechat_redirect) + +以下是按优先级排列说明: + +- 等号类型(=)的优先级最高。一旦匹配成功,则不再查找其他匹配项,停止搜索。 +- `^~`类型表达式,不属于正则表达式。一旦匹配成功,则不再查找其他匹配项,停止搜索。 +- 正则表达式类型(`~ ~*`)的优先级次之。如果有多个location的正则能匹配的话,则使用正则表达式最长的那个。 +- 常规字符串匹配类型。按前缀匹配。 +- / 通用匹配,如果没有匹配到,就匹配通用的 + +优先级搜索问题:不同类型的location映射决定是否继续向下搜索 + +- 等号类型、`^~`类型:一旦匹配上就停止搜索了,不会再匹配其他location了 +- 正则表达式类型(`~ ~*`),常规字符串匹配类型`/xxx/` : 匹配到之后,还会继续搜索其他其它location,直到找到优先级最高的,或者找到第一种情况而停止搜索 + +location优先级从高到底: + +(`location =`) > (`location 完整路径`) > (`location ^~ 路径`) > (`location ~,~* 正则顺序`) > (`location 部分起始路径`) > (`/`) + +```nginx +location = / { + # 精确匹配/,主机名后面不能带任何字符串 / + [ configuration A ] +} +location / { + # 匹配所有以 / 开头的请求。 + # 但是如果有更长的同类型的表达式,则选择更长的表达式。 + # 如果有正则表达式可以匹配,则优先匹配正则表达式。 + [ configuration B ] +} +location /documents/ { + # 匹配所有以 /documents/ 开头的请求,匹配符合以后,还要继续往下搜索。 + # 但是如果有更长的同类型的表达式,则选择更长的表达式。 + # 如果有正则表达式可以匹配,则优先匹配正则表达式。 + [ configuration C ] +} +location ^~ /images/ { + # 匹配所有以 /images/ 开头的表达式,如果匹配成功,则停止匹配查找,停止搜索。 + # 所以,即便有符合的正则表达式location,也不会被使用 + [ configuration D ] +} + +location ~* \.(gif|jpg|jpeg)$ { + # 匹配所有以 gif jpg jpeg结尾的请求。 + # 但是 以 /images/开头的请求,将使用 Configuration D,D具有更高的优先级 + [ configuration E ] +} + +location /images/ { + # 字符匹配到 /images/,还会继续往下搜索 + [ configuration F ] +} + + +location = /test.htm { + root /usr/local/var/www/htm; + index index.htm; +} +``` + +注意:location的优先级与location配置的位置无关 + + + +### 反向代理 + +反向代理应该是Nginx使用最多的功能了,反向代理(Reverse Proxy)方式是指以代理服务器来接受internet上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给internet上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。 + +简单来说就是真实的服务器不能直接被外部网络访问,所以需要一台代理服务器,而代理服务器能被外部网络访问的同时又跟真实服务器在同一个网络环境,当然也可能是同一台服务器,端口不同而已。 + +反向代理通过`proxy_pass`指令来实现。 + +启动一个Java Web项目,端口号为8081 + +```nginx +server { + listen 80; + server_name localhost; + + location / { + proxy_pass http://localhost:8081; + proxy_set_header Host $host:$server_port; + # 设置用户ip地址 + proxy_set_header X-Forwarded-For $remote_addr; + # 当请求服务器出错去寻找其他服务器 + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; + } + +} +``` + +当我们访问localhost的时候,就相当于访问 `localhost:8081`了 + + + +### 负载均衡 + +负载均衡也是Nginx常用的一个功能,负载均衡其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。 + +简单而言就是当有2台或以上服务器时,根据规则随机的将请求分发到指定的服务器上处理,负载均衡配置一般都需要同时配置反向代理,通过反向代理跳转到负载均衡。而Nginx目前支持自带3种负载均衡策略,还有2种常用的第三方策略。负载均衡通过upstream指令来实现。 + +#### RR(round robin :轮询 默认) + +每个请求按时间顺序逐一分配到不同的后端服务器,也就是说第一次请求分配到第一台服务器上,第二次请求分配到第二台服务器上,如果只有两台服务器,第三次请求继续分配到第一台上,这样循环轮询下去,也就是服务器接收请求的比例是 1:1, 如果后端服务器down掉,能自动剔除。轮询是默认配置,不需要太多的配置,同一个项目分别使用8081和8082端口启动项目: + +```nginx +upstream web_servers { + server localhost:8081; + server localhost:8082; +} + +server { + listen 80; + server_name localhost; + #access_log logs/host.access.log main; + + + location / { + proxy_pass http://web_servers; + # 必须指定Header Host + proxy_set_header Host $host:$server_port; + } + } +``` + +访问地址仍然可以获得响应 `http://localhost/api/user/login?username=zhangsan&password=111111` ,这种方式是轮询的 + + + +#### 权重 + +指定轮询几率,weight和访问比率成正比, 也就是服务器接收请求的比例就是各自配置的weight的比例,用于后端服务器性能不均的情况,比如服务器性能差点就少接收点请求,服务器性能好点就多处理点请求。 + +```nginx +upstream test { + server localhost:8081 weight=1; + server localhost:8082 weight=3; + server localhost:8083 weight=4 backup; +} +``` + +示例是4次请求只有一次被分配到8081上,其他3次分配到8082上。backup是指热备,只有当8081和8082都宕机的情况下才走8083 + + + +#### ip_hash + +上面的2种方式都有一个问题,那就是下一个请求来的时候请求可能分发到另外一个服务器,当我们的程序不是无状态的时候(采用了session保存数据),这时候就有一个很大的很问题了,比如把登录信息保存到了session中,那么跳转到另外一台服务器的时候就需要重新登录了,所以很多时候我们需要一个客户只访问一个服务器,那么就需要用iphash了,iphash的每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。 + +```nginx +upstream test { + ip_hash; + server localhost:8080; + server localhost:8081; +} +``` + + + +#### fair(第三方) + +按后端服务器的响应时间来分配请求,响应时间短的优先分配。这个配置是为了更快的给用户响应 + +```nginx +upstream backend { + fair; + server localhost:8080; + server localhost:8081; +} +``` + +#### url_hash(第三方) + +按访问url的hash结果来分配请求,使每个url定向到同一个后端服务器,后端服务器为缓存时比较有效。在upstream中加入hash语句,server语句中不能写入weight等其他的参数,`hash_method`是使用的hash算法 + +```nginx +upstream backend { + hash $request_uri; + hash_method crc32; + server localhost:8080; + server localhost:8081; +} +``` + +以上5种负载均衡各自适用不同情况下使用,所以可以根据实际情况选择使用哪种策略模式,不过fair和url_hash需要安装第三方模块才能使用。 + + + +### 动静分离 + +动静分离是让动态网站里的动态网页根据一定规则把不变的资源和经常变的资源区分开来,动静资源做好了拆分以后,我们就可以根据静态资源的特点将其做缓存操作,这就是网站静态化处理的核心思路。 + +```nginx +upstream web_servers { + server localhost:8081; + server localhost:8082; +} + +server { + listen 80; + server_name localhost; + + set $doc_root /usr/local/var/www; + + location ~* \.(gif|jpg|jpeg|png|bmp|ico|swf|css|js)$ { + root $doc_root/img; + } + + location / { + proxy_pass http://web_servers; + # 必须指定Header Host + proxy_set_header Host $host:$server_port; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root $doc_root; + } + + } +``` + + + +### 其他 + +#### return指令 + +返回http状态码 和 可选的第二个参数可以是重定向的URL + +```nginx +location /permanently/moved/url { + return 301 http://www.example.com/moved/here; +} +``` + + + +#### rewrite指令 + +重写URI请求 rewrite,通过使用rewrite指令在请求处理期间多次修改请求URI,该指令具有一个可选参数和两个必需参数。 + +第一个(必需)参数是请求URI必须匹配的正则表达式。 + +第二个参数是用于替换匹配URI的URI。 + +可选的第三个参数是可以停止进一步重写指令的处理或发送重定向(代码301或302)的标志 + +```nginx +location /users/ { + rewrite ^/users/(.*)$ /show?user=$1 break; +} +``` + + + +#### error_page指令 + +使用error_page指令,您可以配置NGINX返回自定义页面以及错误代码,替换响应中的其他错误代码,或将浏览器重定向到其他URI。在以下示例中,`error_page`指令指定要返回404页面错误代码的页面(/404.html)。 + +```nginx +error_page 404 /404.html; +``` + + + +#### 日志 + +访问日志:需要开启压缩 gzip on; 否则不生成日志文件,打开`log_format`、`access_log`注释 + +```nginx +log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + +access_log /usr/local/etc/nginx/logs/host.access.log main; + +gzip on; +``` + + + +#### deny 指令 + +```nginx +# 禁止访问某个目录 +location ~* \.(txt|doc)${ + root $doc_root; + deny all; +} +``` + + + +#### 内置变量 + +nginx的配置文件中可以使用的内置变量以美元符`$`开始,也有人叫全局变量。其中,部分预定义的变量的值是可以改变的。另外,关注Java知音公众号,回复“后端面试”,送你一份面试题宝典! + +- `$args` :`#`这个变量等于请求行中的参数,同`$query_string` +- `$content_length` :请求头中的Content-length字段。 +- `$content_type` :请求头中的Content-Type字段。 +- `$document_root` :当前请求在root指令中指定的值。 +- `$host` :请求主机头字段,否则为服务器名称。 +- `$http_user_agent` :客户端agent信息 +- `$http_cookie` :客户端cookie信息 +- `$limit_rate` :这个变量可以限制连接速率。 +- `$request_method` :客户端请求的动作,通常为GET或POST。 +- `$remote_addr` :客户端的IP地址。 +- `$remote_port` :客户端的端口。 +- `$remote_user` :已经经过Auth Basic Module验证的用户名。 +- `$request_filename` :当前请求的文件路径,由root或alias指令与URI请求生成。 +- `$scheme` :HTTP方法(如http,https)。 +- `$server_protocol` :请求使用的协议,通常是HTTP/1.0或HTTP/1.1。 +- `$server_addr` :服务器地址,在完成一次系统调用后可以确定这个值。 +- `$server_name` :服务器名称。 +- `$server_port` :请求到达服务器的端口号。 +- `$request_uri` :包含请求参数的原始URI,不包含主机名,如:”`/foo/bar.php?arg=baz`”。 +- `$uri` :不带请求参数的当前URI,`$uri`不包含主机名,如”`/foo/bar.html`”。 +- `$document_uri` :与`$uri`相同 \ No newline at end of file diff --git a/src/DevOps/901.md b/src/DevOps/901.md new file mode 100644 index 0000000..2af670e --- /dev/null +++ b/src/DevOps/901.md @@ -0,0 +1,58 @@ +VMware15.5安装,傻瓜式安装,只记录变动步骤,其余都下一步,软件安装位置自己选择,最好别选c盘,软件地址https://www.nocmd.com/windows/740.html(内含激活码),安装时需要注意它文件不会在一个文件夹下,自己多建一个版本文件夹,方便管理。 + +![在这里插入图片描述](images/DevOps/20210717124755337.png) + +![在这里插入图片描述](images/DevOps/2021071712480749.png) + +**文件 > 新建虚拟机**: + +![在这里插入图片描述](images/DevOps/20210717125629709.png) + +![在这里插入图片描述](images/DevOps/20210717125643324.png) + +![在这里插入图片描述](images/DevOps/20210717125659179.png) + +![在这里插入图片描述](images/DevOps/20210717125714713.png) + +![在这里插入图片描述](images/DevOps/20210717125727428.png) + +![在这里插入图片描述](images/DevOps/20210717125743717.png) + +点击安装计算机的设置 > 选择镜像后 > 点确定: + +![在这里插入图片描述](images/DevOps/20210717125911587.png) + +开启虚拟机》选第一个install centos7 + +![在这里插入图片描述](images/DevOps/20210717125946871.png) + +等待一段时间不要乱点,乱点会卡死,》软件选择》最小安装或gui服务器或gnome桌面,选好后点完成。开发中一般都选最小安装,需要什么软件在自行选择,但其它安装可以省略jdk,mysql等安装,会自行安装。 + +![在这里插入图片描述](images/DevOps/20210717130014474.png) + +在这一步也可以选择自动配置分区,这里更快,这里我选择我要配置分区。 + +![在这里插入图片描述](images/DevOps/202107171300526.png)设置好/boot要1G,swap要2G,剩余都在根目录分区大小后,设备类型点标准分区,点完成。 + +![在这里插入图片描述](images/DevOps/20210717130123660.png) + +点接受更改 + +![在这里插入图片描述](images/DevOps/20210717130215238.png) + +网络和主机名设置,需要联网就打开以太网。 + +![在这里插入图片描述](images/DevOps/20210717130237758.png) + +最后一个像一把锁的安检策略可以关闭。 + +点开始安装 +在这个页面配置root账号密码,创建用户账号密码。在实际开发中root账号要复杂点,避免被破解。 + +![在这里插入图片描述](images/DevOps/20210717130319942.png)等待完成后,点击重启。 + +![在这里插入图片描述](images/DevOps/20210717130343591.png) + +![在这里插入图片描述](images/DevOps/20210717130404642.png) + +再把网络连接打开。 \ No newline at end of file diff --git a/src/DevOps/902.md b/src/DevOps/902.md new file mode 100644 index 0000000..61ec1c2 --- /dev/null +++ b/src/DevOps/902.md @@ -0,0 +1,3 @@ +- 桥接模式:同一网段中,最多只能连接255台机子,一旦超出容易造成IP冲突。IP地址的前3位就是网段(192.168.0.1) +- NAT(网络地址装换)模式:虚拟机和外部通信,不会造成IP冲突。虚拟机地址不再是以0开头,而是生成1-255之间的数,如192.168.6.1,然后主机会生成一个对应的虚拟网卡如192.168.6.6,两者能通信。这种模式下虚拟机能访问192.168.0.1,由于网段不同,192.168.0.1不能访问虚拟机 +- 主机模式:虚拟网络对主机可见,虚拟机不能上网 \ No newline at end of file diff --git a/src/DevOps/803.md b/src/DevOps/903.md similarity index 100% rename from src/DevOps/803.md rename to src/DevOps/903.md diff --git a/src/DevOps/804.md b/src/DevOps/904.md similarity index 100% rename from src/DevOps/804.md rename to src/DevOps/904.md diff --git a/src/DevOps/805.md b/src/DevOps/905.md similarity index 100% rename from src/DevOps/805.md rename to src/DevOps/905.md diff --git a/src/DevOps/_sidebar.md b/src/DevOps/_sidebar.md index 11e34cc..4c48143 100644 --- a/src/DevOps/_sidebar.md +++ b/src/DevOps/_sidebar.md @@ -33,12 +33,15 @@ * 🏁 Docker * [✍ 安装](src/DevOps/601 "安装") * [✍ 常用命令](src/DevOps/602 "常用命令") + * [✍ 其它操作](src/DevOps/603 "其它操作") +* 🏁 CI/CD + * [✍ 发布方式](src/DevOps/701 "发布方式") * 🏁 Nginx - * [✍ 常用配置](src/DevOps/701 "常用配置") - * [✍ 应用场景](src/DevOps/702 "应用场景") + * [✍ 常用配置](src/DevOps/801 "常用配置") + * [✍ 应用场景](src/DevOps/802 "应用场景") * 🏁 VMware - * [✍ 虚拟机安装](src/DevOps/801 "虚拟机安装") - * [✍ 虚拟机网络连接方式](src/DevOps/802 "虚拟机网络连接方式") - * [✍ 安装vmtools](src/DevOps/803 "安装vmtools") - * [✍ 虚拟机目录](src/DevOps/804 "虚拟机目录") - * [✍ CentOS7找回root密码](src/DevOps/805 "CentOS7找回root密码") \ No newline at end of file + * [✍ 虚拟机安装](src/DevOps/901 "虚拟机安装") + * [✍ 虚拟机网络连接方式](src/DevOps/902 "虚拟机网络连接方式") + * [✍ 安装vmtools](src/DevOps/903 "安装vmtools") + * [✍ 虚拟机目录](src/DevOps/904 "虚拟机目录") + * [✍ CentOS7找回root密码](src/DevOps/905 "CentOS7找回root密码") \ No newline at end of file diff --git a/src/DevOps/images/DevOps/滚动发布示意图.jpg b/src/DevOps/images/DevOps/滚动发布示意图.jpg new file mode 100644 index 0000000..5f16802 Binary files /dev/null and b/src/DevOps/images/DevOps/滚动发布示意图.jpg differ diff --git a/src/DevOps/images/DevOps/灰度发布.png b/src/DevOps/images/DevOps/灰度发布.png new file mode 100644 index 0000000..77ca87d Binary files /dev/null and b/src/DevOps/images/DevOps/灰度发布.png differ diff --git a/src/DevOps/images/DevOps/灰度发布示意图.jpg b/src/DevOps/images/DevOps/灰度发布示意图.jpg new file mode 100644 index 0000000..dea251c Binary files /dev/null and b/src/DevOps/images/DevOps/灰度发布示意图.jpg differ diff --git a/src/DevOps/images/DevOps/蓝绿发布示意图.jpg b/src/DevOps/images/DevOps/蓝绿发布示意图.jpg new file mode 100644 index 0000000..f5882bf Binary files /dev/null and b/src/DevOps/images/DevOps/蓝绿发布示意图.jpg differ diff --git a/src/Middleware/501.md b/src/Middleware/501.md index e9e5752..e0661c1 100644 --- a/src/Middleware/501.md +++ b/src/Middleware/501.md @@ -1,5 +1,74 @@ -从功能上,流程可以分为服务启动、建立连接、读取数据、业务处理、发送数据、关闭连接以及关闭服务。整体流程如下所示(图中没有包含关闭的部分): +![Netty逻辑架构](images/Middleware/Netty逻辑架构.png) -![Netty整体流程](images/Middleware/Netty整体流程.png) +Netty内部逻辑的流转: + +![Netty内部逻辑的流转](images/Middleware/Netty内部逻辑的流转.png) + +### 网络通信层 + +网络通信层的职责是**执行网络I/O的操作**,它支持多种网络协议和I/O模型的连接操作。当网络数据读取到内核缓冲区后,会触发各种网络事件,这些网络事件会分发给事件调度层进行处理。三个核心组件包括: + +- **BootStrap和ServerBootStrap** + + 主要负责整个Netty程序的启动、初始化、服务器连接等过程,它相当于一条主线,串联了Netty的其它核心组件。Bootstrap和ServerBootStrap十分相似,两者的区别在于: + + - Bootstrap可用于连接远端服务器,只绑定一个EventLoopGroup + - ServerBootStrap则用于服务端启动绑定本地端口,会绑定两个EventLoopGroup,通常称为Boss和Worker(Boss 会不停地接收新的连接,然后将连接分配给一个个Worker处理连接) + +- **Channel** + + 提供了基本的API用于网络I/O操作,如register、bind、connect、read、write、flush 等。Netty的Channel是以JDK NIO Channel为基础的,相比较于JDK NIO,Netty的Channel提供了更高层次的抽象,同时屏蔽了底层Socket的复杂性,赋予了Channel更加强大的功能,在使用Netty时基本不需要再与Java Socket类直接打交道。 + + + + +### 事件调度层 + +事件调度层的职责是通过Reactor线程模型对各类事件进行聚合处理,通过Selector主循环线程集成多种事件(I/O事件、信号事件、定时事件等),实际的业务处理逻辑是交由服务编排层中相关的Handler完成。两个核心组件包括: + +- **EventLoopGroup、EventLoop** + + EventLoopGroup是Netty的核心处理引擎,本质是一个线程池,主要负责接收I/O请求,并分配线程执行处理请求。EventLoopGroup的实现类**NioEventLoopGroup**也是 Netty 中最被推荐使用的线程模型。是基于NIO模型开发的,可以把NioEventLoopGroup理解为一个线程池,每个线程负责处理多个Channel,而同一个Channel只会对应一个线程。 + + ![Netty事件调度层](images/Middleware/Netty事件调度层.png) + + - 一个 EventLoopGroup 往往包含一个或者多个 EventLoop。EventLoop 用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件 + - EventLoop 同一时间会与一个线程绑定,每个 EventLoop 负责处理多个 Channel + - 每新建一个 Channel,EventLoopGroup 会选择一个 EventLoop 与其绑定。该 Channel 在生命周期内都可以对 EventLoop 进行多次绑定和解绑 + + 其实EventLoopGroup是Netty Reactor线程模型的具体实现方式,Netty通过创建不同的EventLoopGroup参数配置,就可以支持Reactor的三种线程模型: + + - **单线程模型**:EventLoopGroup只包含一个EventLoop,Boss和Worker使用同一个EventLoopGroup + - **多线程模型**:EventLoopGroup包含多个EventLoop,Boss和Worker使用同一个EventLoopGroup + - **主从多线程模型**:EventLoopGroup包含多个EventLoop,Boss是主Reactor,Worker是从Reactor,它们分别使用不同的EventLoopGroup,主Reactor负责新的网络连接Channel创建,然后把Channel注册到从Reactor + + + +### 服务编排层 + +服务编排层的职责是负责组装各类服务,是Netty的核心处理链,用以实现网络事件的动态编排和有序传播。核心组件包括: + +- **ChannelPipeline** + + ChannelPipeline 是 Netty 的核心编排组件,负责组装各种 ChannelHandler,实际数据的编解码以及加工处理操作都是由 ChannelHandler 完成的。ChannelPipeline 可以理解为ChannelHandler 的实例列表——内部通过双向链表将不同的 ChannelHandler 链接在一起。当 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。 + + ChannelPipeline 是线程安全的,因为每一个新的 Channel 都会对应绑定一个新的 ChannelPipeline。一个 ChannelPipeline 关联一个 EventLoop,一个 EventLoop 仅会绑定一个线程。 + + ChannelPipeline、ChannelHandler 都是高度可定制的组件。开发者可以通过这两个核心组件掌握对 Channel 数据操作的控制权。下面我们看一下 ChannelPipeline 的结构图: + + ![ChannelPipeline结构图](images/Middleware/ChannelPipeline结构图.png) + + ChannelPipeline中包含入站ChannelInboundHandler和出站 ChannelOutboundHandler两种处理器,结合客户端和服务端的数据收发流程: + + ![ClientServerChannelPipeline](images/Middleware/ClientServerChannelPipeline.png) + + + +- **ChannelHandler & ChannelHandlerContext** + + 数据的编解码工作以及其他转换工作实际都是通过 ChannelHandler 处理的。ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。 + + ![ChannelHandler](images/Middleware/ChannelHandler.png) + + 每创建一个 Channel 都会绑定一个新的 ChannelPipeline,ChannelPipeline 中每加入一个 ChannelHandler 都会绑定一个 ChannelHandlerContext。 -![Netty线程模型](images/Middleware/Netty线程模型.png) \ No newline at end of file diff --git a/src/Middleware/502.md b/src/Middleware/502.md index 806363a..a9e717b 100644 --- a/src/Middleware/502.md +++ b/src/Middleware/502.md @@ -1,85 +1,39 @@ -Netty中Reactor线程和worker线程所处理的事件: +![Reactor线程模型运行机制](images/Middleware/Reactor线程模型运行机制.png) -1、Server端NioEventLoop处理的事件: +Reactor线程模型运行机制的四个步骤,分别为**连接注册、事件轮询、事件分发、任务处理**。 -![Server端NioEventLoop处理的事件](images/Middleware/Server端NioEventLoop处理的事件.png) +- 连接注册:Channel建立后,注册至Reactor线程中的Selector选择器 +- 事件轮询:轮询Selector选择器中已注册的所有Channel的I/O事件 +- 事件分发:为准备就绪的I/O事件分配相应的处理线程 +- 任务处理:Reactor线程还负责任务队列中的非I/O任务,每个Worker线程从各自维护的任务队列中取出任务异步执行 -2、Client端NioEventLoop处理的事件 -![Client端NioEventLoop处理的事件](images/Middleware/Client端NioEventLoop处理的事件.png) +### 单Reactor单线程 +![单Reactor单线程](images/Middleware/单Reactor单线程.png) -### 服务启动 +上图描述了 Reactor 的单线程模型结构,在 Reactor 单线程模型中,所有 I/O 操作(包括连接建立、数据读写、事件分发等),都是由一个线程完成的。单线程模型逻辑简单,缺陷也十分明显: -服务启动时,以 example 代码中的 EchoServer 为例,启动的过程以及相应的源码类如下: +- 一个线程支持处理的连接数非常有限,CPU 很容易打满,性能方面有明显瓶颈 +- 当多个事件被同时触发时,只要有一个事件没有处理完,其他后面的事件就无法执行,这就会造成消息积压及请求超时 +- 线程在处理 I/O 事件时,Select 无法同时处理连接建立、事件分发等操作 +- 如果 I/O 线程一直处于满负荷状态,很可能造成服务端节点不可用 -1. `EchoServer#new NioEventLoopGroup(1)->NioEventLoop#provider.openSelector()` : 创建 selector -2. `EchoServer#b.bind(PORT).sync->AbstractBootStrap#doBind()->initAndRegister()-> channelFactory.newChannel() / init(channel)` : 创建 serverSocketChannel 以及初始化 -3. `EchoServer#b.bind(PORT).sync->AbstractBootStrap#doBind()->initAndRegister()-> config().group().register(channel)` :从 boss group 中选择一个 NioEventLoop 开始注册 serverSocketChannel -4. `EchoServer#b.bind(PORT).sync->AbstractBootStrap#doBind()->initAndRegister()->config().group().register(channel)->AbstractChannel#register0(promise)->AbstractNioChannel#javaChannel().register(eventLoop().unwrappedSelector(), 0, this)` : 将 server socket channel 注册到选择的 NioEventLoop 的 selector -5. `EchoServer#b.bind(PORT).sync()->AbstractBootStrap#doBind()->doBind0()->AbstractChannel#doBind(localAddress)->NioServerSocketChannel#javaChannel().bind(localAddress, config.getBacklog())` : 绑定地址端口开始启动 -6. `EchoServer#b.bind(PORT).sync()->AbstractBootStrap#doBind()->doBind0()->AbstractChannel#pipeline.fireChannelActive()->AbstractNioChannel#selectionKey.interestOps(interestOps|readInterestOp)`: 注册 OP_READ 事件 -上述启动流程中,1、2、3 由我们自己的线程执行,即mainThread,4、5、6 是由Boss Thread执行。相应时序图如下: -![Netty流程-服务启动](images/Middleware/Netty流程-服务启动.jpg) +### 单Reactor多线程 +![单Reactor多线程](images/Middleware/单Reactor多线程.png) -### 建立连接 +由于单线程模型有性能方面的瓶颈,多线程模型作为解决方案就应运而生了。Reactor 多线程模型将业务逻辑交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,例如读取数据依然保留了串行化的设计。当客户端有数据发送至服务端时,Select 会监听到可读事件,数据读取完毕后提交到业务线程池中并发处理。 -服务启动后便是建立连接的过程了,相应过程及源码类如下: -1. `NioEventLoop#run()->processSelectedKey()` NioEventLoop 中的 selector 轮询创建连接事件(OP_ACCEPT) -2. `NioEventLoop#run()->processSelectedKey()->AbstractNioMessageChannel#read->NioServerSocketChannel#doReadMessages()->SocketUtil#accept(serverSocketChannel)` 创建 socket channel -3. `NioEventLoop#run()->processSelectedKey()->AbstractNioMessageChannel#fireChannelRead->ServerBootstrap#ServerBootstrapAcceptor#channelRead-> childGroup.register(child)` 从worker group 中选择一个 NioEventLoop 开始注册 socket channel -4. `NioEventLoop#run()->processSelectedKey()->AbstractNioMessageChannel#fireChannelRead->ServerBootstrap#ServerBootstrapAcceptor#channelRead-> childGroup.register(child)->AbstractChannel#register0(promise)-> AbstractNioChannel#javaChannel().register(eventLoop().unwrappedSelector(), 0, this)` 将 socket channel 注册到选择的 NioEventLoop 的 selector -5. `NioEventLoop#run()->processSelectedKey()->AbstractNioMessageChannel#fireChannelRead->ServerBootstrap#ServerBootstrapAcceptor#channelRead-> childGroup.register(child)->AbstractChannel#pipeline.fireChannelActive()-> AbstractNioChannel#selectionKey.interestOps(interestOps | readInterestOp)` 注册 OP_ACCEPT 事件 -同样,上述流程中 1、2、3 的执行仍由 Boss Thread 执行,直到 4、5 由具体的 Work Thread 执行。 -![Netty流程-建立连接](images/Middleware/Netty流程-建立连接.jpg) +### 主从Reactor多线程 +![主从Reactor多线程](images/Middleware/主从Reactor多线程.png) +主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象。MainReactor 仅负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至 SubReactor。再由 SubReactor 分配线程池中的 I/O 线程与其连接绑定,它将负责连接生命周期内所有的 I/O 事件。 -### 读写与业务处理 - -连接建立完毕后是具体的读写,以及业务处理逻辑。以 EchoServerHandler 为例,读取数据后会将数据传播出去供业务逻辑处理,此时的 EchoServerHandler 代表我们的业务逻辑,而它的实现也非常简单,就是直接将数据写回去。我们将这块看成一个整条,流程如下: - -1. `NioEventLoop#run()->processSelectedKey() NioEventLoop 中的 selector` 轮询创建读取事件(OP_READ) -2. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()`nioSocketChannel 开始读取数据 -3. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()->pipeline.fireChannelRead(byteBuf)`把读取到的数据传播出去供业务处理 -4. `AbstractNioByteChannel#pipeline.fireChannelRead->EchoServerHandler#channelRead`在这个例子中即 EchoServerHandler 的执行 -5. `EchoServerHandler#write->ChannelOutboundBuffer#addMessage` 调用 write 方法 -6. `EchoServerHandler#flush->ChannelOutboundBuffer#addFlush` 调用 flush 准备数据 -7. `EchoServerHandler#flush->NioSocketChannel#doWrite` 调用 flush 发送数据 - -在这个过程中读写数据都是由 Work Thread 执行的,但是业务处理可以由我们自定义的线程池来处理,并且一般我们也是这么做的,默认没有指定线程的情况下仍然由 Work Thread 代为处理。 -![Netty流程-读写与业务处理](images/Middleware/Netty流程-读写与业务处理.jpg) - - - -### 关闭连接 - -服务处理完毕后,单个连接的关闭是什么样的呢? - -1. `NioEventLoop#run()->processSelectedKey()` NioEventLoop 中的 selector 轮询创建读取事件(OP_READ),这里关闭连接仍然是读取事件 -2. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()->closeOnRead(pipeline)`当字节<0 时开始执行关闭 nioSocketChannel -3. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()->closeOnRead(pipeline)->AbstractChannel#close->AbstractNioChannel#doClose()` 关闭 socketChannel -4. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()->closeOnRead(pipeline)->AbstractChannel#close->outboundBuffer.failFlushed/close` 清理消息:不接受新信息,fail 掉所有 queue 中消息 -5. `NioEventLoop#run()->processSelectedKey()->AbstractNioByteChannel#read()->closeOnRead(pipeline)->AbstractChannel#close->fireChannelInactiveAndDeregister->AbstractNioChannel#doDeregister eventLoop().cancel(selectionKey())` 关闭多路复用器的 key - -时序图如下: -![Netty流程-关闭连接.jpg](images/Middleware/Netty流程-关闭连接.jpg) - - - -### 关闭服务 - -最后是关闭整个 Netty 服务: - -1. `NioEventLoop#run->closeAll()->selectionKey.cancel/channel.close` 关闭 channel,取消 selectionKey -2. `NioEventLoop#run->confirmShutdown->cancelScheduledTasks` 取消定时任务 -3. `NioEventLoop#cleanup->selector.close()` 关闭 selector - -时序图如下,为了好画将 NioEventLoop 拆成了 2 块: -![Netty流程-关闭服务.jpg](images/Middleware/Netty流程-关闭服务.jpg) \ No newline at end of file +Netty 推荐使用主从多线程模型,这样就可以轻松达到成千上万规模的客户端连接。在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加 SubReactor 线程的数量,从而利用多核能力提升系统的吞吐量。 \ No newline at end of file diff --git a/src/Middleware/503.md b/src/Middleware/503.md index f81154b..5be2585 100644 --- a/src/Middleware/503.md +++ b/src/Middleware/503.md @@ -1,3 +1,434 @@ -### 更多连接 +### Netty EventLoop原理 -### 更高QPS \ No newline at end of file +EventLoop 这个概念其实并不是 Netty 独有的,它是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。例如 Node.js 就采用了 EventLoop 的运行机制,不仅占用资源低,而且能够支撑了大规模的流量访问。 + +EventLoop 可以说是 Netty 的调度中心,负责监听多种事件类型:I/O 事件、信号事件、定时事件等。 + +#### EventLoop运行模式 + +![EventLoop通用的运行模式](images/Middleware/EventLoop通用的运行模式.png) + +上图展示了 EventLoop 通用的运行模式。每当事件发生时,应用程序都会将产生的事件放入事件队列当中,然后 EventLoop 会轮询从队列中取出事件执行或者将事件分发给相应的事件监听者执行。事件执行的方式通常分为**立即执行、延后执行、定期执行**几种。 + + + +#### NioEventLoop原理 + +在 Netty 中 EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。Netty 中推荐使用 NioEventLoop 作为实现类,那么 Netty 是如何实现 NioEventLoop 的呢?首先我们来看 NioEventLoop 最核心的 run() 方法源码: + +```java +protected void run() { + for (;;) { + try { + try { + switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) { + case SelectStrategy.CONTINUE: + continue; + case SelectStrategy.BUSY_WAIT: + case SelectStrategy.SELECT: + // 轮询 I/O 事件 + select(wakenUp.getAndSet(false)); + if (wakenUp.get()) { + selector.wakeup(); + } + default: + } + } catch (IOException e) { + rebuildSelector0(); + handleLoopException(e); + continue; + } + + cancelledKeys = 0; + needsToSelectAgain = false; + final int ioRatio = this.ioRatio; + if (ioRatio == 100) { + try { + // 处理 I/O 事件 + processSelectedKeys(); + } finally { + // 处理所有任务 + runAllTasks(); + } + } else { + final long ioStartTime = System.nanoTime(); + try { + // 处理 I/O 事件 + processSelectedKeys(); + } finally { + final long ioTime = System.nanoTime() - ioStartTime; + // 处理完 I/O 事件,再处理异步任务队列 + runAllTasks(ioTime * (100 - ioRatio) / ioRatio); + } + } + } catch (Throwable t) { + handleLoopException(t); + } + try { + if (isShuttingDown()) { + closeAll(); + if (confirmShutdown()) { + return; + } + } + } catch (Throwable t) { + handleLoopException(t); + } + } +} +``` + +上述源码的结构比较清晰,NioEventLoop 每次循环的处理流程都包含事件轮询 select、事件处理 processSelectedKeys、任务处理 runAllTasks 几个步骤,是典型的 Reactor 线程模型的运行机制。而且 Netty 提供了一个参数 ioRatio,可以调整 I/O 事件处理和任务处理的时间比例。下面我们将着重从**事件处理**和**任务处理**两个核心部分出发,详细介绍 Netty EventLoop 的实现原理。 + + + +#### 事件处理机制 + +![事件处理机制](images/Middleware/事件处理机制.png) + +结合Netty的整体架构,看上述EventLoop的事件流转图,以便更好地理解 Netty EventLoop 的设计原理。NioEventLoop 的事件处理机制采用的是**无锁串行化的设计思路**: + +- **BossEventLoopGroup** 和 **WorkerEventLoopGroup** 包含一个或者多个 NioEventLoop + + BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是**线程独立**的,不同的 NioEventLoop 线程之间不会发生任何交集。 + +- NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播 + + ChannelPipeline 也是**线程安全**的,数据会被传递到 ChannelPipeline 的第一个 ChannelHandler 中。数据处理完成后,将加工完成的数据再传递给下一个 ChannelHandler,整个过程是**串行化**执行,不会发生线程上下文切换的问题。 + + + +NioEventLoop 无锁串行化的设计不仅使系统吞吐量达到最大化,而且降低了用户开发业务逻辑的难度,不需要花太多精力关心线程安全问题。虽然单线程执行避免了线程切换,但是它的缺陷就是不能执行时间过长的 I/O 操作,一旦某个 I/O 事件发生阻塞,那么后续的所有 I/O 事件都无法执行,甚至造成事件积压。在使用 Netty 进行程序开发时,我们一定要对 ChannelHandler 的实现逻辑有充分的风险意识。 + +NioEventLoop 线程的可靠性至关重要,一旦 NioEventLoop 发生阻塞或者陷入空轮询,就会导致整个系统不可用。在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。Netty 作为一个高性能、高可靠的网络框架,需要保证 I/O 线程的安全性。那么它是如何解决 JDK epoll 空轮询的 Bug 呢?实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。 + + + +抛开其它细枝末节,直接定位到事件轮询select()方法中的最后一部分代码,一起看下Netty是如何解决epoll空轮询的Bug: + +```java +long time = System.nanoTime(); +if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) { + selectCnt = 1; +} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && + selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) { + selector = selectRebuildSelector(selectCnt); + selectCnt = 1; + break; +} +``` + +Netty提供了一种检测机制判断线程是否可能陷入空轮询,具体的实现方式如下: + +- 每次执行 select 操作之前记录当前时间 currentTimeNanos +- **time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos**,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug +- Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象 + +Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。 + + + +#### 任务处理机制 + +NioEventLoop 不仅负责处理 I/O 事件,还要兼顾执行任务队列中的任务。任务队列遵循 FIFO 规则,可以保证任务执行的公平性。NioEventLoop 处理的任务类型基本可以分为三类: + +- **普通任务**:通过 NioEventLoop 的 execute() 方法向任务队列 taskQueue 中添加任务。例如 Netty 在写数据时会封装 WriteAndFlushTask 提交给 taskQueue。taskQueue 的实现类是多生产者单消费者队列 MpscChunkedArrayQueue,在多线程并发添加任务时,可以保证线程安全 +- **定时任务**:通过调用 NioEventLoop 的 schedule() 方法向定时任务队列 scheduledTaskQueue 添加一个定时任务,用于周期性执行该任务。例如,心跳消息发送等。定时任务队列 scheduledTaskQueue 采用优先队列 PriorityQueue 实现 +- **尾部队列**:tailTasks 相比于普通任务队列优先级较低,在每次执行完 taskQueue 中任务后会去获取尾部队列中任务执行。尾部任务并不常用,主要用于做一些收尾工作,例如统计事件循环的执行时间、监控信息上报等 + +下面结合任务处理 runAllTasks 的源码结构,分析下 NioEventLoop 处理任务的逻辑,源码实现如下: + +```java +protected boolean runAllTasks(long timeoutNanos) { + // 1. 合并定时任务到普通任务队列 + fetchFromScheduledTaskQueue(); + // 2. 从普通任务队列中取出任务 + Runnable task = pollTask(); + if (task == null) { + afterRunningAllTasks(); + return false; + } + // 3. 计算任务处理的超时时间 + final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos; + long runTasks = 0; + long lastExecutionTime; + for (;;) { + // 4. 安全执行任务 + safeExecute(task); + runTasks ++; + // 5. 每执行 64 个任务检查一下是否超时 + if ((runTasks & 0x3F) == 0) { + lastExecutionTime = ScheduledFutureTask.nanoTime(); + if (lastExecutionTime >= deadline) { + break; + } + } + task = pollTask(); + if (task == null) { + lastExecutionTime = ScheduledFutureTask.nanoTime(); + break; + } + } + // 6. 收尾工作 + afterRunningAllTasks(); + this.lastExecutionTime = lastExecutionTime; + return true; +} +``` + +在代码中以注释的方式标注了具体的实现步骤,可以分为 6 个步骤: + +- **fetchFromScheduledTaskQueue函数**:将定时任务从 scheduledTaskQueue 中取出,聚合放入普通任务队列 taskQueue 中,只有定时任务的截止时间小于当前时间才可以被合并 +- **从普通任务队列taskQueue中取出任务** +- **计算任务执行的最大超时时间** +- **safeExecute函数**:安全执行任务,实际直接调用的 Runnable 的 run() 方法 +- **每执行 64 个任务进行超时时间的检查**:如果执行时间大于最大超时时间,则立即停止执行任务,避免影响下一轮的 I/O 事件的处理 +- **最后获取尾部队列中的任务执行** + + + +#### EventLoop最佳实践 + +在日常开发中用好 EventLoop 至关重要,这里结合实际工作中的经验给出一些 EventLoop 的最佳实践方案: + +- 网络连接建立过程中三次握手、安全认证的过程会消耗不少时间。这里建议采用 Boss 和 Worker 两个 EventLoopGroup,有助于分担 Reactor 线程的压力 +- 由于 Reactor 线程模式适合处理耗时短的任务场景,对于耗时较长的 ChannelHandler 可以考虑维护一个业务线程池,将编解码后的数据封装成 Task 进行异步处理,避免 ChannelHandler 阻塞而造成 EventLoop 不可用 +- 如果业务逻辑执行时间较短,建议直接在 ChannelHandler 中执行。例如编解码操作,这样可以避免过度设计而造成架构的复杂性 +- 不宜设计过多的 ChannelHandler。对于系统性能和可维护性都会存在问题,在设计业务架构的时候,需要明确业务分层和 Netty 分层之间的界限。不要一味地将业务逻辑都添加到 ChannelHandler 中 + + + +### ChannelPipeline + +Pipeline 的字面意思是管道、流水线。它在 Netty 中起到的作用,和一个工厂的流水线类似。原始的网络字节流经过 Pipeline,被一步步加工包装,最后得到加工后的成品。是Netty的核心处理链,用以实现网络事件的动态编排和有序传播。 + +#### ChannelPipeline内部结构 + +ChannelPipeline 可以看作是 ChannelHandler 的容器载体,它是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起,如下图所示。当有 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。 + +![ChannelPipeline内部结构](images/Middleware/ChannelPipeline内部结构.png) + +根据网络数据的流向,ChannelPipeline 分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器。在客户端与服务端通信的过程中,数据从客户端发向服务端的过程叫出站,反之称为入站。 + +![ChannelPipeline入站和出站](images/Middleware/ChannelPipeline入站和出站.png) + + + +#### ChannelHandler接口设计 + +整个ChannelHandler是围绕I/O事件的生命周期所设计的,如建立连接、读数据、写数据、连接销毁等。ChannelHandler 有两个重要的**子接口**:**ChannelInboundHandler**和**ChannelOutboundHandler**,分别拦截**入站和出站的各种 I/O 事件**。 + +**① ChannelInboundHandler的事件回调方法与触发时机** + +| 事件回调方法 | 触发时机 | +| ------------------------- | -------------------------------------------------- | +| channelRegistered | Channel 被注册到 EventLoop | +| channelUnregistered | Channel 从 EventLoop 中取消注册 | +| channelActive | Channel 处于就绪状态,可以被读写 | +| channelInactive | Channel 处于非就绪状态Channel 可以从远端读取到数据 | +| channelRead | Channel 可以从远端读取到数据 | +| channelReadComplete | Channel 读取数据完成 | +| userEventTriggered | 用户事件触发时 | +| channelWritabilityChanged | Channel 的写状态发生变化 | + + + +**② ChannelOutboundHandler的事件回调方法与触发时机** + +ChannelOutboundHandler 的事件回调方法非常清晰,直接通过 ChannelOutboundHandler 的接口列表可以看到每种操作所对应的回调方法,如下图所示。这里每个回调方法都是在相应操作执行之前触发,在此就不多做赘述了。此外 ChannelOutboundHandler 中绝大部分接口都包含ChannelPromise 参数,以便于在操作完成时能够及时获得通知。 + +![ChannelOutboundHandler](images/Middleware/ChannelOutboundHandler.png) + + + +#### ChannelPipeline事件传播机制 + +上述ChannelPipeline可分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,与此对应传输的事件类型可以分为**Inbound 事件**和**Outbound 事件**。 + +- **Inbound事件**:传播方向为Head->Tail,即按照添加的顺序进行正向传播(A→B→C) + +- **Outbound事件**:传播方向为Tail->Head,即按照添加的顺序进行反向传播(C→B→A) + +代码示例体验 ChannelPipeline 的事件传播机制: + +```java +serverBootstrap.childHandler(new ChannelInitializer() { + @Override + public void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast(new SampleInBoundHandler("SampleInBoundHandlerA", false)) + .addLast(new SampleInBoundHandler("SampleInBoundHandlerB", false)) + .addLast(new SampleInBoundHandler("SampleInBoundHandlerC", true)); + ch.pipeline() + .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerA")) + .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerB")) + .addLast(new SampleOutBoundHandler("SampleOutBoundHandlerC")); + + } +} +``` + +执行结果: + +![SampleOutBoundHandler执行结果](images/Middleware/SampleOutBoundHandler执行结果.png) + + + +#### ChannelPipeline异常传播机制 + +ChannelPipeline 事件传播的实现采用了经典的责任链模式,调用链路环环相扣。那么如果有一个节点处理逻辑异常会出现什么现象呢?ctx.fireExceptionCaugh 会将异常按顺序从 Head 节点传播到 Tail 节点。如果用户没有对异常进行拦截处理,最后将由 Tail 节点统一处理。 + +![Netty异常处理的最佳实践](images/Middleware/Netty异常处理的最佳实践.png) + +建议用户自定义的异常处理器代码示例如下: + +```java +public class ExceptionHandler extends ChannelDuplexHandler { + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + if (cause instanceof RuntimeException) { + System.out.println("Handle Business Exception Success."); + } + } +} +``` + +加入统一的异常处理器后,可以看到异常已经被优雅地拦截并处理掉了。这也是 Netty 推荐的最佳异常处理实践。 + +![Netty统一异常处理验证](images/Middleware/Netty统一异常处理验证.png) + + + +### 定时器TimerTask + + + +### 时间轮HashedWheelTimer + +时间轮其实就是一种环形的数据结构,可以想象成时钟,分成很多格子,一个格子代表一段时间。并用一个链表保存在该格子上的计划任务,同时一个指针随着时间一格一格转动,并执行相应格子中的所有到期任务。任务通过时间取模决定放入那个格子。 + +![HashedWheelTimer](images/Middleware/HashedWheelTimer.png) + +在网络通信中管理上万的连接,每个连接都有超时任务,如果为每个任务启动一个Timer超时器,那么会占用大量资源。为了解决这个问题,可用Netty工具类HashedWheelTimer。 + +Netty 的时间轮 `HashedWheelTimer` 给出了一个**粗略的定时器实现**,之所以称之为粗略的实现是**因为该时间轮并没有严格的准时执行定时任务**,而是在每隔一个时间间隔之后的时间节点执行,并执行当前时间节点之前到期的定时任务。 + +当然具体的定时任务的时间执行精度可以通过调节 HashedWheelTimer 构造方法的时间间隔的大小来进行调节,在大多数网络应用的情况下,由于 IO 延迟的存在,并**不会严格要求具体的时间执行精度**,所以默认的 100ms 时间间隔可以满足大多数的情况,不需要再花精力去调节该时间精度。 + + + +**HashedWheelTimer的特点** + +- 从源码分析可以看出,其实 HashedWheelTimer 的时间精度并不高,误差能够在 100ms 左右,同时如果任务队列中的等待任务数量过多,可能会产生更大的误差 +- 但是 HashedWheelTimer 能够处理非常大量的定时任务,且每次定位到要处理任务的候选集合链表只需要 O(1) 的时间,而 Timer 等则需要调整堆,是 O(logN) 的时间复杂度 +- HashedWheelTimer 本质上是`模拟了时间的轮盘`,将大量的任务拆分成了一个个的小任务列表,能够有效`节省 CPU 和线程资源` + + + +**源码解读** + +```java +public HashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit, + int ticksPerWheel, boolean leakDetection, long maxPendingTimeouts) { + ...... +} +``` + +- `threadFactory`:自定义线程工厂,用于创建线程对象 +- `tickDuration`:间隔多久走到下一槽(相当于时钟走一格) +- `unit`:定义tickDuration的时间单位 +- `ticksPerWheel`:一圈有多个槽 +- `leakDetection`:是否开启内存泄漏检测 +- `maxPendingTimeouts`:最多待执行的任务个数。0或负数表示无限制 + + + +**优缺点** + +- **优点** + - 可以添加、删除、取消定时任务 + - 能高效的处理大批定时任务 +- **缺点** + - 对内存要求较高,占用较高的内存 + - 时间精度要求不高 + + + +**定时任务方案** + +目前主流的一些定时任务方案: + +- Timer +- ScheduledExecutorService +- ThreadPoolTaskScheduler(基于ScheduledExecutorService) +- Netty的schedule(用到了PriorityQueue) +- Netty的HashedWheelTimer(时间轮) +- Kafka的TimingWheel(层级时间轮) + +使用案例: + +```java +// 构造一个 Timer 实例 +Timer timer = new HashedWheelTimer(); + +// 提交一个任务,让它在 5s 后执行 +Timeout timeout1 = timer.newTimeout(new TimerTask() { + @Override + public void run(Timeout timeout) { + System.out.println("5s 后执行该任务"); + } +}, 5, TimeUnit.SECONDS); + +// 再提交一个任务,让它在 10s 后执行 +Timeout timeout2 = timer.newTimeout(new TimerTask() { + @Override + public void run(Timeout timeout) { + System.out.println("10s 后执行该任务"); + } +}, 10, TimeUnit.SECONDS); + +// 取消掉那个 5s 后执行的任务 +if (!timeout1.isExpired()) { + timeout1.cancel(); +} + +// 原来那个 5s 后执行的任务,已经取消了。这里我们反悔了,我们要让这个任务在 3s 后执行 +// 我们说过 timeout 持有上、下层的实例,所以下面的 timer 也可以写成 timeout1.timer() +timer.newTimeout(timeout1.task(), 3, TimeUnit.SECONDS); +``` + + + +### 无锁队列mpsc queue + + + +### FastThreadLocal + + + +### ByteBuf + + + +### 编解码协议 + +netty-codec模块主要负责编解码工作,通过编解码实现原始字节数据与业务实体对象之间的相互转化。Netty支持大多数业界主流协议的编解码器,如**HTTP、HTTP2、Redis、XML**等,为开发者节省了大量的精力。此外该模块提供了抽象的编解码类**ByteToMessageDecoder**和**MessageToByteEncoder**,通过继承这两个类可以轻松实现自定义的编解码逻辑。 + +![Netty协议](images/Middleware/Netty协议.png) + + + +### 拆包粘包 + +- 拆包/粘包的解决方案 + + - **消息长度固定**:每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。 + + - **特定分隔符** + + 既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。 + + - **消息长度 + 消息内容** + + 消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。 \ No newline at end of file diff --git a/src/Middleware/504.md b/src/Middleware/504.md index a0da46b..3bf23a9 100644 --- a/src/Middleware/504.md +++ b/src/Middleware/504.md @@ -1,42 +1,111 @@ -Netty通过Reactor模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss线程池和work线程池,其中boss线程池的线程负责处理请求的accept事件,当接收到accept事件的请求时,把对应的socket封装到一个NioSocketChannel中,并交给work线程池,其中work线程池负责请求的read和write事件,由对应的Handler处理。 +### 服务端启动流程 -### 单线程Reactor线程模型 +Netty 服务端的启动过程大致分为三个步骤: -下图演示了单线程reactor线程模型,之所以称之为单线程,还是因为只有一个accpet Thread接受任务,之后转发到reactor线程中进行处理。两个黄色框表示的是Reactor Thread Group,里面有多个Reactor Thread。一个Reactor Thread Group中的Reactor Thread功能都是相同的,例如第一个黄色框中的Reactor Thread都是处理拆分后的任务的第一阶段,第二个黄色框中的Reactor Thread都是处理拆分后的任务的第二步骤。任务具体要怎么拆分,要结合具体场景,下图只是演示作用。**一般来说,都是以比较耗时的操作(例如IO)为切分点**。 +- 配置线程池 +- Channel 初始化 +- 端口绑定 -![单线程reactor线程模型](images/Middleware/单线程reactor线程模型.png) -特别的,如果我们在任务处理的过程中,不划分为多个阶段进行处理的话,那么单线程reactor线程模型就退化成了并行工作和模型。**事实上,可以认为并行工作者模型,就是单线程reactor线程模型的最简化版本。** +#### 配置线程池 +**单线程模式** +Reactor 单线程模型所有 I/O 操作都由一个线程完成,所以只需要启动一个 EventLoopGroup 即可。 -### 多线程Reactor线程模型 +```java +EventLoopGroup group = new NioEventLoopGroup(1); +ServerBootstrap b = new ServerBootstrap(); +b.group(group); +``` -所谓多线程reactor线程模型,无非就是有多个accpet线程,如下图中的虚线框中的部分。 +**多线程模式** +Reactor 单线程模型有非常严重的性能瓶颈,因此 Reactor 多线程模型出现了。在 Netty 中使用 Reactor 多线程模型与单线程模型非常相似,区别是 NioEventLoopGroup 可以不需要任何参数,它默认会启动 2 倍 CPU 核数的线程。当然,你也可以自己手动设置固定的线程数。 -![多线程reactor线程模型](images/Middleware/多线程reactor线程模型.png) +```java +EventLoopGroup group = new NioEventLoopGroup(); +ServerBootstrap b = new ServerBootstrap(); +b.group(group); +``` +**主从多线程模式** +在大多数场景下,我们采用的都是主从多线程 Reactor 模型。Boss 是主 Reactor,Worker 是从 Reactor。它们分别使用不同的 NioEventLoopGroup,主 Reactor 负责处理 Accept,然后把 Channel 注册到从 Reactor 上,从 Reactor 主要负责 Channel 生命周期内的所有 I/O 事件。 +```java +EventLoopGroup bossGroup = new NioEventLoopGroup(); +EventLoopGroup workerGroup = new NioEventLoopGroup(); +ServerBootstrap b = new ServerBootstrap(); +b.group(bossGroup, workerGroup); +``` -### 混合型Reactor线程模型 -混合型reactor线程模型,实际上最能体现reactor线程模型的本质: -- 将任务处理切分成多个阶段进行,每个阶段处理完自己的部分之后,转发到下一个阶段进行处理。不同的阶段之间的执行是异步的,可以认为每个阶段都有一个独立的线程池。 -- 不同的类型的任务,有着不同的处理流程,划分时需要划分成不同的阶段。如下图蓝色是一种任务、绿色是另一种任务,两种任务有着不同的执行流程 +#### Channel 初始化 -![混合型reactor线程模型](images/Middleware/混合型reactor线程模型.png) +**设置Channel类型** +```java +// 客户端Channel +b.channel(NioSocketChannel.class); +b.channel(OioSocketChannel.class); +// 服务端Channel + b.channel(NioServerSocketChannel.class); + b.channel(OioServerSocketChannel.class); + b.channel(EpollServerSocketChannel.class); -### Netty-Reactor线程模型 +// UDP + b.channel(NioDatagramChannel.class); + b.channel(OioDatagramChannel.class); +``` -![Netty-Reactor](images/Middleware/Netty-Reactor.png) +**注册ChannelHandler** -图中大致包含了5个步骤,而我们编写的服务端代码中可能并不能完全体现这样的步骤,因为Netty将其中一些步骤的细节隐藏了,笔者将会通过图形分析与源码分析相结合的方式帮助读者理解这五个步骤。这个五个步骤可以按照以下方式简要概括: +ServerBootstrap 的 childHandler() 方法需要注册一个 ChannelHandler。ChannelInitializer是实现了 ChannelHandler接口的匿名类,通过实例化 ChannelInitializer 作为 ServerBootstrap 的参数。 -- 设置服务端ServerBootStrap启动参数 -- 通过ServerBootStrap的bind方法启动服务端,bind方法会在parentGroup中注册NioServerScoketChannel,监听客户端的连接请求 -- Client发起连接CONNECT请求,parentGroup中的NioEventLoop不断轮循是否有新的客户端请求,如果有,ACCEPT事件触发 -- ACCEPT事件触发后,parentGroup中NioEventLoop会通过NioServerSocketChannel获取到对应的代表客户端的NioSocketChannel,并将其注册到childGroup中 -- childGroup中的NioEventLoop不断检测自己管理的NioSocketChannel是否有读写事件准备好,如果有的话,调用对应的ChannelHandler进行处理 \ No newline at end of file +```java +b.childHandler(new ChannelInitializer() { +    @Override +    public void initChannel(SocketChannel ch) { +        ch.pipeline() + // HTTP 编解码处理器 +                .addLast("codec", new HttpServerCodec()) + // HTTPContent 压缩处理器 +                .addLast("compressor", new HttpContentCompressor()) + // HTTP 消息聚合处理器 +                .addLast("aggregator", new HttpObjectAggregator(65536))  + // 自定义业务逻辑处理器 +                .addLast("handler", new HttpServerHandler()); +    } +}); +``` + +**设置Channel参数** + +ServerBootstrap 设置 Channel 属性有option和childOption两个方法,option 主要负责设置 Boss 线程组,而 childOption 对应的是 Worker 线程组。 + +```java +b.option(ChannelOption.SO_KEEPALIVE, true); +``` + +常用参数如下: + +| 参数名 | 描述信息 | +| ---------------------- | ------------------------------------------------------------ | +| SO_KEEPALIVE | 设置为 true 代表启用了 TCP SO_KEEPALIVE 属性,TCP 会主动探测连接状态,即连接保活 | +| SO_BACKLOG | 已完成三次握手的请求队列最大长度,同一时刻服务端可能会处理多个连接,在高并发海量连接的场景下,该参数应适当调大 | +| TCP_NODELAY | Netty 默认是 true,表示立即发送数据。如果设置为 false 表示启用 Nagle 算法,该算法会将 TCP 网络数据包累积到一定量才会发送,虽然可以减少报文发送的数量,但是会造成一定的数据延迟。Netty 为了最小化数据传输的延迟,默认禁用了 Nagle 算法 | +| SO_SNDBUF | TCP 数据发送缓冲区大小 | +| SO_RCVBUF | TCP数据接收缓冲区大小,TCP数据接收缓冲区大小 | +| SO_LINGER | 设置延迟关闭的时间,等待缓冲区中的数据发送完成 | +| CONNECT_TIMEOUT_MILLIS | 建立连接的超时时间 | + + + +#### 端口绑定 + +bind() 方法会真正触发启动,sync() 方法则会阻塞,直至整个启动过程完成,具体使用方式如下: + +```java +ChannelFuture f = b.bind(8080).sync(); +``` \ No newline at end of file diff --git a/src/Middleware/506.md b/src/Middleware/506.md index 462ff3b..e9e5752 100644 --- a/src/Middleware/506.md +++ b/src/Middleware/506.md @@ -1,311 +1,5 @@ -### 工作流程 +从功能上,流程可以分为服务启动、建立连接、读取数据、业务处理、发送数据、关闭连接以及关闭服务。整体流程如下所示(图中没有包含关闭的部分): -ByteBuf维护两个不同的索引:`读索引(readerIndex)` 和 `写索引(writerIndex)` 。如下图所示: +![Netty整体流程](images/Middleware/Netty整体流程.png) -![ByteBuf工作流程](images/Middleware/ByteBuf工作流程.png) - -- `ByteBuf` 维护了 `readerIndex` 和 `writerIndex` 索引 -- 当 `readerIndex > writerIndex` 时,则抛出 `IndexOutOfBoundsException` -- `ByteBuf`容量 = `writerIndex` -- `ByteBuf` 可读容量 = `writerIndex` - `readerIndex` -- `readXXX()` 和 `writeXXX()` 方法将会推进其对应的索引。自动推进 -- `getXXX()` 和 `setXXX()` 方法将对 `writerIndex` 和 `readerIndex` 无影响 - - - -### 使用模式 - -ByteBuf本质是一个由不同的索引分别控制读访问和写访问的字节数组。ByteBuf共有三种模式:`堆缓冲区模式(Heap Buffer)`、`直接缓冲区模式(Direct Buffer)` 和 `复合缓冲区模式(Composite Buffer)`。 - -#### 堆缓冲区模式(Heap Buffer) - -堆缓冲区模式又称为`支撑数组(backing array)`。将数据存放在JVM的堆空间,通过将数据存储在数组中实现。 - -- **优点**:由于数据存储在Jvm堆中可以快速创建和快速释放,并且提供了数组直接快速访问的方法 -- **缺点**:每次数据与I/O进行传输时,都需要将数据拷贝到直接缓冲区 - -```java -public static void heapBuffer() { - // 创建Java堆缓冲区 - ByteBuf heapBuf = Unpooled.buffer(); - if (heapBuf.hasArray()) { // 是数组支撑 - byte[] array = heapBuf.array(); - int offset = heapBuf.arrayOffset() + heapBuf.readerIndex(); - int length = heapBuf.readableBytes(); - handleArray(array, offset, length); - } -} -``` - - - -#### 直接缓冲区模式(Direct Buffer) - -Direct Buffer属于堆外分配的直接内存,不会占用堆的容量。适用于套接字传输过程,避免了数据从内部缓冲区拷贝到直接缓冲区的过程,性能较好。对于涉及大量I/O的数据读写,建议使用Direct Buffer。而对于用于后端的业务消息编解码模块建议使用Heap Buffer。 - -- **优点**: 使用Socket传递数据时性能很好,避免了数据从Jvm堆内存拷贝到直接缓冲区的过程。提高了性能 -- **缺点**: 相对于堆缓冲区而言,Direct Buffer分配内存空间和释放更为昂贵 - -```java -public static void directBuffer() { - ByteBuf directBuf = Unpooled.directBuffer(); - if (!directBuf.hasArray()) { - int length = directBuf.readableBytes(); - byte[] array = new byte[length]; - directBuf.getBytes(directBuf.readerIndex(), array); - handleArray(array, 0, length); - } -} -``` - - - -#### 复合缓冲区模式(Composite Buffer) - -Composite Buffer是Netty特有的缓冲区。本质上类似于提供一个或多个ByteBuf的组合视图,可以根据需要添加和删除不同类型的ByteBuf。 - -- Composite Buffer是一个组合视图。它提供一种访问方式让使用者自由组合多个ByteBuf,避免了拷贝和分配新的缓冲区 -- Composite Buffer不支持访问其支撑数组。因此如果要访问,需要先将内容拷贝到堆内存中,再进行访问 - -下图是将两个ByteBuf:头部 + Body 组合在一起,没有进行任何复制过程。仅仅创建了一个视图: - -![CompositeBuffer](images/Middleware/CompositeBuffer.png) - -```java -public static void byteBufComposite() { - // 复合缓冲区,只是提供一个视图 - CompositeByteBuf messageBuf = Unpooled.compositeBuffer(); - ByteBuf headerBuf = Unpooled.buffer(); // can be backing or direct - ByteBuf bodyBuf = Unpooled.directBuffer(); // can be backing or direct - messageBuf.addComponents(headerBuf, bodyBuf); - messageBuf.removeComponent(0); // remove the header - for (ByteBuf buf : messageBuf) { - System.out.println(buf.toString()); - } -} -``` - - - -### 字节级操作 - -#### 随机访问索引 - -ByteBuf的索引与普通的Java字节数组一样。第一个字节的索引是0,最后一个字节索引总是capacity()-1。访问方式如下: - -- readXXX()和writeXXX()方法将会推进其对应的索引readerIndex和writerIndex。自动推进 -- getXXX()和setXXX()方法用于访问数据,对writerIndex和readerIndex无影响 - -```java -public static void byteBufRelativeAccess() { - ByteBuf buffer = Unpooled.buffer(); // get reference form somewhere - for (int i = 0; i < buffer.capacity(); i++) { - byte b = buffer.getByte(i); // 不改变readerIndex值 - System.out.println((char) b); - } -} -``` - - - -#### 顺序访问索引 - -Netty的ByteBuf同时具有读索引和写索引,但JDK的ByteBuffer只有一个索引,所以JDK需要调用flip()方法在读模式和写模式之间切换。ByteBuf被读索引和写索引划分成3个区域:**可丢弃字节区域**、**可读字节区域** 和 **可写字节区域** 。 - -![ByteBuf顺序访问索引](images/Middleware/ByteBuf顺序访问索引.png) - - - -#### 可丢弃字节区域 - -可丢弃字节区域是指:[0,readerIndex)之间的区域。可调用discardReadBytes()方法丢弃已经读过的字节。 - -- discardReadBytes()效果 ----- 将可读字节区域(CONTENT)[readerIndex, writerIndex)往前移动readerIndex位,同时修改读索引和写索引 -- discardReadBytes()方法会移动可读字节区域内容(CONTENT)。如果频繁调用,会有多次数据复制开销,对性能有一定的影响 - - - -#### 可读字节区域 - -可读字节区域是指:[readerIndex, writerIndex)之间的区域。任何名称以read和skip开头的操作方法,都会改变readerIndex索引。 - - - -#### 可写字节区域 - -可写字节区域是指:[writerIndex, capacity)之间的区域。任何名称以write开头的操作方法都将改变writerIndex的值。 - - - -#### 索引管理 - -- markReaderIndex()+resetReaderIndex() ----- markReaderIndex()是先备份当前的readerIndex,resetReaderIndex()则是将刚刚备份的readerIndex恢复回来。常用于dump ByteBuf的内容,又不想影响原来ByteBuf的readerIndex的值 -- readerIndex(int) ----- 设置readerIndex为固定的值 -- writerIndex(int) ----- 设置writerIndex为固定的值 -- clear() ----- 效果是: readerIndex=0, writerIndex(0)。不会清除内存 -- 调用clear()比调用discardReadBytes()轻量的多。仅仅重置readerIndex和writerIndex的值,不会拷贝任何内存,开销较小 - - - -#### 查找操作(indexOf) - -查找ByteBuf指定的值。类似于,String.indexOf("str")操作 - -- 最简单的方法 —— indexOf() -- 利用ByteProcessor作为参数来查找某个指定的值 - -```java -public static void byteProcessor() { - ByteBuf buffer = Unpooled.buffer(); //get reference form somewhere - // 使用indexOf()方法来查找 - buffer.indexOf(buffer.readerIndex(), buffer.writerIndex(), (byte)8); - // 使用ByteProcessor查找给定的值 - int index = buffer.forEachByte(ByteProcessor.FIND_CR); -} -``` - - - -#### 派生缓冲——视图 - -派生缓冲区为ByteBuf提供了一个访问的视图。视图仅仅提供一种访问操作,不做任何拷贝操作。下列方法,都会呈现给使用者一个视图,以供访问: - -- duplicate() -- slice() -- slice(int, int) -- Unpooled.unmodifiableBuffer(...) -- Unpooled.wrappedBuffer(...) -- order(ByteOrder) -- readSlice(int) - -**理解** - -- 上面的6中方法,都会返回一个新的ByteBuf实例,具有自己的读索引和写索引。但是,其内部存储是与原对象是共享的。这就是视图的概念 -- 请注意:如果你修改了这个新的ByteBuf实例的具体内容,那么对应的源实例也会被修改,因为其内部存储是共享的 -- 如果需要拷贝现有缓冲区的真实副本,请使用copy()或copy(int, int)方法 -- 使用派生缓冲区,避免了复制内存的开销,有效提高程序的性能 - -```java -public static void byteBufSlice() { - Charset utf8 = Charset.forName("UTF-8"); - ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8); - ByteBuf sliced = buf.slice(0, 15); - System.out.println(sliced.toString(utf8)); - buf.setByte(0, (byte)'J'); - assert buf.getByte(0) == sliced.getByte(0); // return true -} - -public static void byteBufCopy() { - Charset utf8 = Charset.forName("UTF-8"); - ByteBuf buf = Unpooled.copiedBuffer("Netty in Action rocks!", utf8); - ByteBuf copy = buf.copy(0, 15); - System.out.println(copy.toString(utf8)); - buf.setByte(0, (byte)'J'); - assert buf.getByte(0) != copy.getByte(0); // return true -} -``` - - - -#### 读/写操作 - -如上文所提到的,有两种类别的读/写操作: - -- get()和set()操作 ----- 从给定的索引开始,并且保持索引不变 -- read()和write()操作 ----- 从给定的索引开始,并且根据已经访问过的字节数对索引进行访问 -- 下图给出get()操作API,对于set()操作、read()操作和write操作可参考书籍或API - -![ByteBuf-get](images/Middleware/ByteBuf-get.png) - - - -#### 更多操作 - -![ByteBuf-更多操作](images/Middleware/ByteBuf-更多操作.png) - -下面的两个方法操作字面意思较难理解,给出解释: - -- **hasArray()**:如果ByteBuf由一个字节数组支撑,则返回true。通俗的讲:ByteBuf是堆缓冲区模式,则代表其内部存储是由字节数组支撑的 -- **array()**:如果ByteBuf是由一个字节数组支撑泽返回数组,否则抛出UnsupportedOperationException异常。也就是,ByteBuf是堆缓冲区模式 - - - -### ByteBuf分配 - -创建和管理ByteBuf实例的多种方式:**按序分配(ByteBufAllocator)**、**Unpooled缓冲区** 和 **ByteBufUtil类**。 - -#### 按序分配:ByteBufAllocator接口 - -Netty通过接口ByteBufAllocator实现了(ByteBuf的)池化。Netty提供池化和非池化的ButeBufAllocator: - -- `ctx.channel().alloc().buffer()`:本质就是ByteBufAllocator.DEFAULT -- `ByteBufAllocator.DEFAULT.buffer()`:返回一个基于堆或者直接内存存储的Bytebuf。默认是堆内存 -- `ByteBufAllocator.DEFAULT`:有两种类型: UnpooledByteBufAllocator.DEFAULT(非池化)和PooledByteBufAllocator.DEFAULT(池化)。对于Java程序,默认使用PooledByteBufAllocator(池化)。对于安卓,默认使用UnpooledByteBufAllocator(非池化) -- 可以通过BootStrap中的Config为每个Channel提供独立的ByteBufAllocator实例 - -![img](images/Middleware/ByteBufAllocator.png) - -解释: - -- 上图中的buffer()方法,返回一个基于堆或者直接内存存储的Bytebuf ----- 缺省是堆内存。源码: AbstractByteBufAllocator() { this(false); } -- ByteBufAllocator.DEFAULT ----- 可能是池化,也可能是非池化。默认是池化(PooledByteBufAllocator.DEFAULT) - - - -#### Unpooled缓冲区——非池化 - -Unpooled提供静态的辅助方法来创建未池化的ByteBuf。 - -![Unpooled缓冲区](images/Middleware/Unpooled缓冲区.png) - -注意: - -- 上图的buffer()方法,返回一个未池化的基于堆内存存储的ByteBuf -- wrappedBuffer() ----- 创建一个视图,返回一个包装了给定数据的ByteBuf。非常实用 - -创建ByteBuf代码: - -```java - public void createByteBuf(ChannelHandlerContext ctx) { - // 1. 通过Channel创建ByteBuf - ByteBuf buf1 = ctx.channel().alloc().buffer(); - // 2. 通过ByteBufAllocator.DEFAULT创建 - ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(); - // 3. 通过Unpooled创建 - ByteBuf buf3 = Unpooled.buffer(); -} -``` - - - -#### ByteBufUtil类 - -ByteBufUtil类提供了用于操作ByteBuf的静态的辅助方法: hexdump()和equals - -- hexdump():以十六进制的表示形式打印ByteBuf的内容。非常有价值 -- equals():判断两个ByteBuf实例的相等性 - - - -### 引用计数 - -Netty4.0版本中为ButeBuf和ButeBufHolder引入了引用计数技术。请区别引用计数和可达性分析算法(jvm垃圾回收) - -- 谁负责释放:一般来说,是由最后访问(引用计数)对象的那一方来负责将它释放 -- buffer.release():引用计数减1 -- buffer.retain():引用计数加1 -- buffer.refCnt():返回当前对象引用计数值 -- buffer.touch():记录当前对象的访问位置,主要用于调试 -- 引用计数并非仅对于直接缓冲区(direct Buffer)。ByteBuf的三种模式: 堆缓冲区(heap Buffer)、直接缓冲区(dirrect Buffer)和复合缓冲区(Composite Buffer)都使用了引用计数,某些时候需要程序员手动维护引用数值 - -```java -public static void releaseReferenceCountedObject(){ - ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(); - // 引用计数加1 - buffer.retain(); - // 输出引用计数 - buffer.refCnt(); - // 引用计数减1 - buffer.release(); -} -``` \ No newline at end of file +![Netty线程模型](images/Middleware/Netty线程模型.png) \ No newline at end of file diff --git a/src/Middleware/507.md b/src/Middleware/507.md index 87d24e7..636a334 100644 --- a/src/Middleware/507.md +++ b/src/Middleware/507.md @@ -1,89 +1,40 @@ -**Netty** 的`Zero-copy` 体现在如下几个个方面: +Netty通过Reactor模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss线程池和work线程池,其中boss线程池的线程负责处理请求的accept事件,当接收到accept事件的请求时,把对应的socket封装到一个NioSocketChannel中,并交给work线程池,其中work线程池负责请求的read和write事件,由对应的Handler处理。 -- **通过CompositeByteBuf实现零拷贝**:Netty提供了`CompositeByteBuf` 类,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝 -- **通过wrap操作实现零拷贝**:通过`wrap`操作,可以将byte[]、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作 -- 通过slice操作实现零拷贝:ByteBuf支持`slice`操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝 -- **通过FileRegion实现零拷贝**:通过 `FileRegion` 包装的`FileChannel.tranferTo` 实现文件传输,可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环write方式导致的内存拷贝问题 +### 单线程Reactor线程模型 -### 零拷贝操作 +下图演示了单线程reactor线程模型,之所以称之为单线程,还是因为只有一个accpet Thread接受任务,之后转发到reactor线程中进行处理。两个黄色框表示的是Reactor Thread Group,里面有多个Reactor Thread。一个Reactor Thread Group中的Reactor Thread功能都是相同的,例如第一个黄色框中的Reactor Thread都是处理拆分后的任务的第一阶段,第二个黄色框中的Reactor Thread都是处理拆分后的任务的第二步骤。任务具体要怎么拆分,要结合具体场景,下图只是演示作用。**一般来说,都是以比较耗时的操作(例如IO)为切分点**。 -#### 通过CompositeByteBuf实现零拷贝 +![单线程reactor线程模型](images/Middleware/单线程reactor线程模型.png) -![CompositeByteBuf实现零拷贝](images/Middleware/CompositeByteBuf实现零拷贝.png) +特别的,如果我们在任务处理的过程中,不划分为多个阶段进行处理的话,那么单线程reactor线程模型就退化成了并行工作和模型。**事实上,可以认为并行工作者模型,就是单线程reactor线程模型的最简化版本。** -```java -ByteBuf header = null; -ByteBuf body = null; - -// 传统合并header和body:两次额外的数据拷贝 -ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes()); -allBuf.writeBytes(header); -allBuf.writeBytes(body); - -// 合并header和body:内部这两个 ByteBuf 都是单独存在的, CompositeByteBuf 只是逻辑上是一个整体 -CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer(); -compositeByteBuf.addComponents(true, header, body); -// 底层封装了 CompositeByteBuf 操作 -ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body); -``` +### 多线程Reactor线程模型 -#### 通过wrap操作实现零拷贝 +所谓多线程reactor线程模型,无非就是有多个accpet线程,如下图中的虚线框中的部分。 -```java -byte[] bytes = null; +![多线程reactor线程模型](images/Middleware/多线程reactor线程模型.png) -// 传统方式:直接将byte[]数组拷贝到ByteBuf中 -ByteBuf byteBuf = Unpooled.buffer(); -byteBuf.writeBytes(bytes); -// wrap方式:将bytes包装成为一个UnpooledHeapByteBuf对象, 包装过程中, 是不会有拷贝操作的 -// 即最后我们生成的生成的ByteBuf对象是和bytes数组共用了同一个存储空间, 对bytes的修改也会反映到ByteBuf对象中 -ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes); -``` +### 混合型Reactor线程模型 +混合型reactor线程模型,实际上最能体现reactor线程模型的本质: +- 将任务处理切分成多个阶段进行,每个阶段处理完自己的部分之后,转发到下一个阶段进行处理。不同的阶段之间的执行是异步的,可以认为每个阶段都有一个独立的线程池。 +- 不同的类型的任务,有着不同的处理流程,划分时需要划分成不同的阶段。如下图蓝色是一种任务、绿色是另一种任务,两种任务有着不同的执行流程 -#### 通过slice操作实现零拷贝 +![混合型reactor线程模型](images/Middleware/混合型reactor线程模型.png) -slice 操作和 wrap 操作刚好相反,`Unpooled.wrappedBuffer` 可以将多个 ByteBuf 合并为一个, 而 slice 操作可以将一个 **ByteBuf **`切片` 为多个共享一个存储区域的 ByteBuf 对象. ByteBuf 提供了两个 slice 操作方法: +### Netty-Reactor线程模型 -```java -public ByteBuf slice(); -public ByteBuf slice(int index, int length); -``` +![Netty-Reactor](images/Middleware/Netty-Reactor.png) -不带参数的`slice`方法等同于`buf.slice(buf.readerIndex(), buf.readableBytes())` 调用, 即返回 buf 中可读部分的切片. 而 `slice(int index, int length)`方法相对就比较灵活了, 我们可以设置不同的参数来获取到 buf 的不同区域的切片。 +图中大致包含了5个步骤,而我们编写的服务端代码中可能并不能完全体现这样的步骤,因为Netty将其中一些步骤的细节隐藏了,笔者将会通过图形分析与源码分析相结合的方式帮助读者理解这五个步骤。这个五个步骤可以按照以下方式简要概括: -用 `slice` 方法产生 header 和 body 的过程是没有拷贝操作的, header 和 body 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已,即: - -![slice操作实现零拷贝](images/Middleware/slice操作实现零拷贝.png) - - - -#### 通过FileRegion实现零拷贝 - -Netty 中使用 FileRegion 实现文件传输的零拷贝, 不过在底层 FileRegion 是依赖于 **Java NIO** `FileChannel.transfer` 的零拷贝功能。当有了 FileRegion 后, 我们就可以直接通过它将文件的内容直接写入 **Channel** 中, 而不需要像传统的做法: 拷贝文件内容到临时 **buffer**, 然后再将 **buffer** 写入 **Channel.** 通过这样的零拷贝操作, 无疑对传输大文件很有帮助。 - - - -### 传统IO的流程 - -![传统IO的流程Copy](images/Middleware/传统IO的流程Copy.png) - -![传统IO的流程](images/Middleware/传统IO的流程.png) - -- **「第一步」**:将文件通过 **「DMA」** 技术从磁盘中拷贝到内核缓冲区 -- **「第二步」**:将文件从内核缓冲区拷贝到用户进程缓冲区域中 -- **「第三步」**:将文件从用户进程缓冲区中拷贝到 socket 缓冲区中 -- **「第四步」**:将socket缓冲区中的文件通过 **「DMA」** 技术拷贝到网卡 - - - -### 零拷贝整体流程图 - -![零拷贝CPU](images/Middleware/零拷贝CPU.png) - -![零拷贝整体流程图](images/Middleware/零拷贝整体流程图.png) \ No newline at end of file +- 设置服务端ServerBootStrap启动参数 +- 通过ServerBootStrap的bind方法启动服务端,bind方法会在parentGroup中注册NioServerScoketChannel,监听客户端的连接请求 +- Client发起连接CONNECT请求,parentGroup中的NioEventLoop不断轮循是否有新的客户端请求,如果有,ACCEPT事件触发 +- ACCEPT事件触发后,parentGroup中NioEventLoop会通过NioServerSocketChannel获取到对应的代表客户端的NioSocketChannel,并将其注册到childGroup中 +- childGroup中的NioEventLoop不断检测自己管理的NioSocketChannel是否有读写事件准备好,如果有的话,调用对应的ChannelHandler进行处理 \ No newline at end of file diff --git a/src/Middleware/508.md b/src/Middleware/508.md index e74d2eb..87d24e7 100644 --- a/src/Middleware/508.md +++ b/src/Middleware/508.md @@ -1,111 +1,89 @@ -### 粘包拆包图解 +**Netty** 的`Zero-copy` 体现在如下几个个方面: -![CP粘包拆包图解](images/Middleware/CP粘包拆包图解.png) +- **通过CompositeByteBuf实现零拷贝**:Netty提供了`CompositeByteBuf` 类,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝 +- **通过wrap操作实现零拷贝**:通过`wrap`操作,可以将byte[]、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作 +- 通过slice操作实现零拷贝:ByteBuf支持`slice`操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝 +- **通过FileRegion实现零拷贝**:通过 `FileRegion` 包装的`FileChannel.tranferTo` 实现文件传输,可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环write方式导致的内存拷贝问题 -假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下几种情况: +### 零拷贝操作 -- 服务端分两次读取到两个独立的数据包,分别是D1和D2,没有粘包和拆包 -- 服务端一次接收到了两个数据包,D1和D2粘在一起,发生粘包 -- 服务端分两次读取到数据包,第一次读取到了完整D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,发生拆包 -- 服务端分两次读取到数据包,第一次读取到部分D1包,第二次读取到剩余的D1包和全部的D2包 -- 当TCP缓存再小一点的话,会把D1和D2分别拆成多个包发送 +#### 通过CompositeByteBuf实现零拷贝 +![CompositeByteBuf实现零拷贝](images/Middleware/CompositeByteBuf实现零拷贝.png) +```java +ByteBuf header = null; +ByteBuf body = null; + +// 传统合并header和body:两次额外的数据拷贝 +ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes()); +allBuf.writeBytes(header); +allBuf.writeBytes(body); + +// 合并header和body:内部这两个 ByteBuf 都是单独存在的, CompositeByteBuf 只是逻辑上是一个整体 +CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer(); +compositeByteBuf.addComponents(true, header, body); +// 底层封装了 CompositeByteBuf 操作 +ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body); +``` -### 产生原因 -产生粘包和拆包问题的主要原因是,操作系统在发送TCP数据的时候,底层会有一个缓冲区,例如1024个字节大小: -- 如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题 -- 如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包,也就是将一个大的包拆分为多个小包进行发送 +#### 通过wrap操作实现零拷贝 +```java +byte[] bytes = null; +// 传统方式:直接将byte[]数组拷贝到ByteBuf中 +ByteBuf byteBuf = Unpooled.buffer(); +byteBuf.writeBytes(bytes); -### 解决方案 +// wrap方式:将bytes包装成为一个UnpooledHeapByteBuf对象, 包装过程中, 是不会有拷贝操作的 +// 即最后我们生成的生成的ByteBuf对象是和bytes数组共用了同一个存储空间, 对bytes的修改也会反映到ByteBuf对象中 +ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes); -#### 固定长度 +``` -对于使用固定长度的粘包和拆包场景,可以使用: -- `FixedLengthFrameDecoder`:每次读取固定长度的消息,如果当前读取到的消息不足指定长度,那么就会等待下一个消息到达后进行补足。其使用也比较简单,只需要在构造函数中指定每个消息的长度即可。 -```java - @Override -protected void initChannel(SocketChannel ch) throws Exception { - // 这里将FixedLengthFrameDecoder添加到pipeline中,指定长度为20 - ch.pipeline().addLast(new FixedLengthFrameDecoder(20)); - // 将前一步解码得到的数据转码为字符串 - ch.pipeline().addLast(new StringDecoder()); - // 这里FixedLengthFrameEncoder是我们自定义的,用于将长度不足20的消息进行补全空格 - ch.pipeline().addLast(new FixedLengthFrameEncoder(20)); - // 最终的数据处理 - ch.pipeline().addLast(new EchoServerHandler()); -} -``` +#### 通过slice操作实现零拷贝 +slice 操作和 wrap 操作刚好相反,`Unpooled.wrappedBuffer` 可以将多个 ByteBuf 合并为一个, 而 slice 操作可以将一个 **ByteBuf **`切片` 为多个共享一个存储区域的 ByteBuf 对象. ByteBuf 提供了两个 slice 操作方法: +```java +public ByteBuf slice(); +public ByteBuf slice(int index, int length); +``` -#### 指定分隔符 +不带参数的`slice`方法等同于`buf.slice(buf.readerIndex(), buf.readableBytes())` 调用, 即返回 buf 中可读部分的切片. 而 `slice(int index, int length)`方法相对就比较灵活了, 我们可以设置不同的参数来获取到 buf 的不同区域的切片。 -对于通过分隔符进行粘包和拆包问题的处理,Netty提供了两个编解码的类: +用 `slice` 方法产生 header 和 body 的过程是没有拷贝操作的, header 和 body 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已,即: -- `LineBasedFrameDecoder`:通过换行符,即`\n`或者`\r\n`对数据进行处理 -- `DelimiterBasedFrameDecoder`:通过用户指定的分隔符对数据进行粘包和拆包处理 +![slice操作实现零拷贝](images/Middleware/slice操作实现零拷贝.png) -```java -@Override -protected void initChannel(SocketChannel ch) throws Exception { - String delimiter = "_$"; - // 将delimiter设置到DelimiterBasedFrameDecoder中,经过该解码一器进行处理之后,源数据将会 - // 被按照_$进行分隔,这里1024指的是分隔的最大长度,即当读取到1024个字节的数据之后,若还是未 - // 读取到分隔符,则舍弃当前数据段,因为其很有可能是由于码流紊乱造成的 - ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, - Unpooled.wrappedBuffer(delimiter.getBytes()))); - // 将分隔之后的字节数据转换为字符串数据 - ch.pipeline().addLast(new StringDecoder()); - // 这是我们自定义的一个编码器,主要作用是在返回的响应数据最后添加分隔符 - ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter)); - // 最终处理数据并且返回响应的handler - ch.pipeline().addLast(new EchoServerHandler()); -} -``` +#### 通过FileRegion实现零拷贝 -#### 数据包长度字段 +Netty 中使用 FileRegion 实现文件传输的零拷贝, 不过在底层 FileRegion 是依赖于 **Java NIO** `FileChannel.transfer` 的零拷贝功能。当有了 FileRegion 后, 我们就可以直接通过它将文件的内容直接写入 **Channel** 中, 而不需要像传统的做法: 拷贝文件内容到临时 **buffer**, 然后再将 **buffer** 写入 **Channel.** 通过这样的零拷贝操作, 无疑对传输大文件很有帮助。 -处理粘拆包的主要思想是在生成的数据包中添加一个长度字段,用于记录当前数据包的长度。 -- `LengthFieldBasedFrameDecoder`:按照参数指定的包长度偏移量数据对接收到的数据进行解码,从而得到目标消息体数据。解码过程如下图所示: - ![LengthFieldBasedFrameDecoder](images/Middleware/LengthFieldBasedFrameDecoder.png) +### 传统IO的流程 -- `LengthFieldPrepender`:在响应的数据前面添加指定的字节数据,这个字节数据中保存了当前消息体的整体字节数据长度。编码过程如下图所示: +![传统IO的流程Copy](images/Middleware/传统IO的流程Copy.png) - ![LengthFieldPrepender](images/Middleware/LengthFieldPrepender.png) +![传统IO的流程](images/Middleware/传统IO的流程.png) -```java -@Override -protected void initChannel(SocketChannel ch) throws Exception { - // 这里将LengthFieldBasedFrameDecoder添加到pipeline的首位,因为其需要对接收到的数据 - // 进行长度字段解码,这里也会对数据进行粘包和拆包处理 - ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2)); - // LengthFieldPrepender是一个编码器,主要是在响应字节数据前面添加字节长度字段 - ch.pipeline().addLast(new LengthFieldPrepender(2)); - // 对经过粘包和拆包处理之后的数据进行json反序列化,从而得到User对象 - ch.pipeline().addLast(new JsonDecoder()); - // 对响应数据进行编码,主要是将User对象序列化为json - ch.pipeline().addLast(new JsonEncoder()); - // 处理客户端的请求的数据,并且进行响应 - ch.pipeline().addLast(new EchoServerHandler()); - } -``` +- **「第一步」**:将文件通过 **「DMA」** 技术从磁盘中拷贝到内核缓冲区 +- **「第二步」**:将文件从内核缓冲区拷贝到用户进程缓冲区域中 +- **「第三步」**:将文件从用户进程缓冲区中拷贝到 socket 缓冲区中 +- **「第四步」**:将socket缓冲区中的文件通过 **「DMA」** 技术拷贝到网卡 -#### 自定义粘包拆包器 +### 零拷贝整体流程图 -可以通过实现`MessageToByteEncoder`和`ByteToMessageDecoder`来实现自定义粘包和拆包处理的目的。 +![零拷贝CPU](images/Middleware/零拷贝CPU.png) -- `MessageToByteEncoder`:作用是将响应数据编码为一个ByteBuf对象 -- `ByteToMessageDecoder`:将接收到的ByteBuf数据转换为某个对象数据 \ No newline at end of file +![零拷贝整体流程图](images/Middleware/零拷贝整体流程图.png) \ No newline at end of file diff --git a/src/Middleware/509.md b/src/Middleware/509.md index 2c452c9..e74d2eb 100644 --- a/src/Middleware/509.md +++ b/src/Middleware/509.md @@ -1,5 +1,111 @@ -- **IO线程模型**:同步非阻塞,用最少的资源做更多的事 -- **内存零拷贝**:尽量减少不必要的内存拷贝,实现了更高效率的传输 -- **内存池设计**:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况 -- **串形化处理读写**:避免使用锁带来的性能开销 -- **高性能序列化协议**:支持 protobuf 等高性能序列化协议 \ No newline at end of file +### 粘包拆包图解 + +![CP粘包拆包图解](images/Middleware/CP粘包拆包图解.png) + +假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下几种情况: + +- 服务端分两次读取到两个独立的数据包,分别是D1和D2,没有粘包和拆包 +- 服务端一次接收到了两个数据包,D1和D2粘在一起,发生粘包 +- 服务端分两次读取到数据包,第一次读取到了完整D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,发生拆包 +- 服务端分两次读取到数据包,第一次读取到部分D1包,第二次读取到剩余的D1包和全部的D2包 +- 当TCP缓存再小一点的话,会把D1和D2分别拆成多个包发送 + + + +### 产生原因 + +产生粘包和拆包问题的主要原因是,操作系统在发送TCP数据的时候,底层会有一个缓冲区,例如1024个字节大小: + +- 如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题 +- 如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包,也就是将一个大的包拆分为多个小包进行发送 + + + +### 解决方案 + +#### 固定长度 + +对于使用固定长度的粘包和拆包场景,可以使用: + +- `FixedLengthFrameDecoder`:每次读取固定长度的消息,如果当前读取到的消息不足指定长度,那么就会等待下一个消息到达后进行补足。其使用也比较简单,只需要在构造函数中指定每个消息的长度即可。 + +```java + @Override +protected void initChannel(SocketChannel ch) throws Exception { + // 这里将FixedLengthFrameDecoder添加到pipeline中,指定长度为20 + ch.pipeline().addLast(new FixedLengthFrameDecoder(20)); + // 将前一步解码得到的数据转码为字符串 + ch.pipeline().addLast(new StringDecoder()); + // 这里FixedLengthFrameEncoder是我们自定义的,用于将长度不足20的消息进行补全空格 + ch.pipeline().addLast(new FixedLengthFrameEncoder(20)); + // 最终的数据处理 + ch.pipeline().addLast(new EchoServerHandler()); +} +``` + + + +#### 指定分隔符 + +对于通过分隔符进行粘包和拆包问题的处理,Netty提供了两个编解码的类: + +- `LineBasedFrameDecoder`:通过换行符,即`\n`或者`\r\n`对数据进行处理 +- `DelimiterBasedFrameDecoder`:通过用户指定的分隔符对数据进行粘包和拆包处理 + +```java +@Override +protected void initChannel(SocketChannel ch) throws Exception { + String delimiter = "_$"; + // 将delimiter设置到DelimiterBasedFrameDecoder中,经过该解码一器进行处理之后,源数据将会 + // 被按照_$进行分隔,这里1024指的是分隔的最大长度,即当读取到1024个字节的数据之后,若还是未 + // 读取到分隔符,则舍弃当前数据段,因为其很有可能是由于码流紊乱造成的 + ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, + Unpooled.wrappedBuffer(delimiter.getBytes()))); + // 将分隔之后的字节数据转换为字符串数据 + ch.pipeline().addLast(new StringDecoder()); + // 这是我们自定义的一个编码器,主要作用是在返回的响应数据最后添加分隔符 + ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter)); + // 最终处理数据并且返回响应的handler + ch.pipeline().addLast(new EchoServerHandler()); +} +``` + + + +#### 数据包长度字段 + +处理粘拆包的主要思想是在生成的数据包中添加一个长度字段,用于记录当前数据包的长度。 + +- `LengthFieldBasedFrameDecoder`:按照参数指定的包长度偏移量数据对接收到的数据进行解码,从而得到目标消息体数据。解码过程如下图所示: + + ![LengthFieldBasedFrameDecoder](images/Middleware/LengthFieldBasedFrameDecoder.png) + +- `LengthFieldPrepender`:在响应的数据前面添加指定的字节数据,这个字节数据中保存了当前消息体的整体字节数据长度。编码过程如下图所示: + + ![LengthFieldPrepender](images/Middleware/LengthFieldPrepender.png) + +```java +@Override +protected void initChannel(SocketChannel ch) throws Exception { + // 这里将LengthFieldBasedFrameDecoder添加到pipeline的首位,因为其需要对接收到的数据 + // 进行长度字段解码,这里也会对数据进行粘包和拆包处理 + ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2)); + // LengthFieldPrepender是一个编码器,主要是在响应字节数据前面添加字节长度字段 + ch.pipeline().addLast(new LengthFieldPrepender(2)); + // 对经过粘包和拆包处理之后的数据进行json反序列化,从而得到User对象 + ch.pipeline().addLast(new JsonDecoder()); + // 对响应数据进行编码,主要是将User对象序列化为json + ch.pipeline().addLast(new JsonEncoder()); + // 处理客户端的请求的数据,并且进行响应 + ch.pipeline().addLast(new EchoServerHandler()); + } +``` + + + +#### 自定义粘包拆包器 + +可以通过实现`MessageToByteEncoder`和`ByteToMessageDecoder`来实现自定义粘包和拆包处理的目的。 + +- `MessageToByteEncoder`:作用是将响应数据编码为一个ByteBuf对象 +- `ByteToMessageDecoder`:将接收到的ByteBuf数据转换为某个对象数据 \ No newline at end of file diff --git a/src/Middleware/510.md b/src/Middleware/510.md index 55b9f2b..2c452c9 100644 --- a/src/Middleware/510.md +++ b/src/Middleware/510.md @@ -1,63 +1,5 @@ -### 文件描述符 - -- 设置系统最大文件句柄数 - -```bash -# 查看 -cat /proc/sys/fs/file-max -# 修改 -在/etc/sysctl.conf插入fs.file-max=1000000 -# 配置生效 -sysctl -p -``` - -- 设置单进程打开的最大句柄数 - -默认单进程打开的最大句柄数是 `1024`,通过 `ulimit -a` 可以查看相关参数,示例如下: - -```powershell -[root@test ~]# ulimit -a -core file size (blocks, -c) 0 -data seg size (kbytes, -d) unlimited -scheduling priority (-e) 0 -file size (blocks, -f) unlimited -pending signals (-i) 256324 -max locked memory (kbytes, -l) 64 -max memory size (kbytes, -m) unlimited -open files (-n) 1024 -...... -``` - -当并发接入的TCP连接数超过上限时,就会报“too many open files”,所有新的客户端接入将失败。通过 `vi /etc/security/limits.conf` 命令添加如下配置参数: - -```powershell -* soft nofile 1000000 -* hard nofile 1000000 -``` - -修改之后保存,注销当前用户,重新登录,通过 `ulimit -a` 查看修改的状态是否生效。 - -**注意**:尽管我们可以将单个进程打开的最大句柄数修改的非常大,但是当句柄数达到一定数量级之后,处理效率将出现明显下降,因此,需要根据服务器的硬件配置和处理能力进行合理设置。 - - - -### TCP/IP相关参数 - -需要重点调优的TCP IP参数如下: - -- net.ipv4.tcp_rmem:为每个TCP连接分配的读缓冲区内存大小。第一个值时socket接收缓冲区分配的最小字节数。 - - - -### 多网卡队列和软中断 - -- **TCP缓冲区** - - 根据推送消息的大小,合理设置以下两个参数,对于海量长连接,通常 32K 是个不错的选择: - - - **SO_SNDBUF**:发送缓冲区大小 - - **SO_RCVBUF**:接收缓冲区大小 - -- **软中断** - - 使用命令 `cat /proc/interrupts` 查看网卡硬件中断的运行情况,如果全部被集中在CPU0上处理,则无法并行执行多个软中断。Linux kernel内核≥2.6.35的版本,可以开启RPS,网络通信能力提升20%以上,RPS原理是:根据数据包的源地址、目的地址和源端口等,计算出一个Hash值,然后根据Hash值来选择软中断运行的CPU,即实现每个链接和CPU绑定,通过Hash值来均衡软中断运行在多个CPU上。 \ No newline at end of file +- **IO线程模型**:同步非阻塞,用最少的资源做更多的事 +- **内存零拷贝**:尽量减少不必要的内存拷贝,实现了更高效率的传输 +- **内存池设计**:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况 +- **串形化处理读写**:避免使用锁带来的性能开销 +- **高性能序列化协议**:支持 protobuf 等高性能序列化协议 \ No newline at end of file diff --git a/src/Middleware/511.md b/src/Middleware/511.md index 8510a21..55b9f2b 100644 --- a/src/Middleware/511.md +++ b/src/Middleware/511.md @@ -1,159 +1,63 @@ -### 设置合理的线程数 +### 文件描述符 -**boss线程池优化** +- 设置系统最大文件句柄数 -对于Netty服务端,通常只需要启动一个监听端口用于端侧设备接入,但是如果集群实例较少,甚至是单机部署,那么在短时间内大量设备接入时,需要对服务端的监听方式和线程模型做优化,即服务端监听多个端口,利用主从Reactor线程模型。由于同时监听了多个端口,每个ServerSocketChannel都对应一个独立的Acceptor线程,这样就能并行处理,加速端侧设备的接人速度,减少端侧设备的连接超时失败率,提高单节点服务端的处理性能。 - - - -**work线程池优化(I/O工作线程池)** - -对于I/O工作线程池的优化,可以先采用系统默认值(cpu内核数*2)进行性能测试,在性能测试过程中采集I/O线程的CPU占用大小,看是否存在瓶颈,具体策略如下: - -- 通过执行 `ps -ef|grep java` 找到服务端进程pid -- 执行`top -Hp pid`查询该进程下所有线程的运行情况,通过“shift+p”对CPU占用大小做排序,获取线程的pid及对应的CPU占用大小 -- 使用`printf'%x\n' pid`将pid转换成16进制格式 -- 通过`jstack -f pid`命令获取线程堆栈,或者通过jvisualvm工具打印线程堆栈,找到I/O work工作线程,查看他们的CPU占用大小及线程堆栈,关键词:`SelectorImpl.lockAndDoSelect` - -**分析** - -- 如果连续采集几次进行对比,发现线程堆栈都停留在SelectorImpl.lockAndDoSelect处,则说明I/O线程比较空闲,无需对工作线程数做调整 -- 如果发现I/O线程的热点停留在读或写操作,或停留在ChannelHandler的执行处,则可以通过适当调大NioEventLoop线程的个数来提升网络的读写性能。调整方式有两种: - - 接口API指定:在创建NioEventLoopGroup实例时指定线程数 - - 系统参数指定:通过-Dio.netty.eventLoopThreads来指定NioEventLoopGroup线程池(不建议) - - - -### 心跳检测优化 - -心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。 - -**海量设备接入的服务端心跳优化策略** - -- **要能够及时检测失效的连接,并将其剔除**。防止无效的连接句柄积压,导致OOM等问题 -- **设置合理的心跳周期**。防止心跳定时任务积压,造成频繁的老年代GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停 -- **使用Nety提供的链路空闲检测机制**。不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题 - -**心跳检测机制分为三个层面** - -- **TCP层的心跳检测**:即TCP的 Keep-Alive机制,它的作用域是整个TCP协议栈 -- **协议层的心跳检测**:主要存在于长连接协议中,例如MQTT -- **应用层的心跳检测**:它主要由各业务产品通过约定方式定时给对方发送心跳消息实现 - -**心跳检测机制分类** - -- **Ping-Pong型心跳**:由通信一方定时发送Ping消息,对方接收到Ping消息后立即返回Pong答应消息给对方,属于“请求-响应型”心跳 -- **Ping-Ping型心跳**:不区分心跳请求和答应,由通信双发按照约定时间向对方发送心跳Ping消息,属于”双向心跳“ - -**心跳检测机制策略** - -- **心跳超时**:连续N次检测都没有收到对方的Pong应答消息或Ping请求消息,则认为链路已经发生逻辑失效 -- **心跳失败**:在读取和发送心跳消息的时候,如果直接发生了IO异常,说明链路已经失效 - -**链路空闲检测机制** - -- **读空闲**:链路持续时间T没有读取到任何消息 -- **写空闲**:链路持续时间T没有发送任何消息 -- **读写空闲**:链路持续时间T没有接收或者发送任何消息 - - - -**案例分析** - -由于移动无线网络的特点,推送服务的心跳周期并不能设置的太长,否则长连接会被释放,造成频繁的客户端重连,但是也不能设置太短,否则在当前缺乏统一心跳框架的机制下很容易导致信令风暴(例如微信心跳信令风暴问题)。具体的心跳周期并没有统一的标准,180S 也许是个不错的选择,微信为 300S。 - -在 Netty 中,可以通过在 ChannelPipeline 中增加 IdleStateHandler 的方式实现心跳检测,在构造函数中指定链路空闲时间,然后实现空闲回调接口,实现心跳的发送和检测。拦截链路空闲事件并处理心跳: - -```java -public class MyHandler extends ChannelHandlerAdapter { - @Override - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { - if (evt instanceof IdleStateEvent) { - // 心跳处理 - } - } - } +```bash +# 查看 +cat /proc/sys/fs/file-max +# 修改 +在/etc/sysctl.conf插入fs.file-max=1000000 +# 配置生效 +sysctl -p ``` -**心跳优化结论** - -- 对于百万级的服务器,一般不建议很长的心跳周期和超时时长 -- 心跳检测周期通常不要超过60s,心跳检测超时通常为心跳检测周期的2倍 -- 建议通过IdleStateHandler实现心跳,不要自己创建定时任务线程池,加重系统负担和增加潜在并发安全问题 -- 发生心跳超时或心跳失败时,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常 -- 链路空闲事件被触发后并没有关闭链路,而是触发IdleStateEvent事件,用户订阅IdleStateEvent事件,用于自定义逻辑处理。如关闭链路、客户端发起重新连接、告警和日志打印等 -- 链路空闲检测类库主要包括:IdleStateHandler、ReadTimeoutHandler、WriteTimeoutHandler - - - -### 接收和发送缓冲区调优 - -对于长链接,每个链路都需要维护自己的消息接收和发送缓冲区,JDK 原生的 NIO 类库使用的是java.nio.ByteBuffer, 它实际是一个长度固定的byte[],无法动态扩容。 - -**场景**:假设单条消息最大上限为10K,平均大小为5K,为满足10K消息处理,ByteBuffer的容量被设置为10K,这样每条链路实际上多消耗了5K内存,如果长链接链路数为100万,每个链路都独立持有ByteBuffer接收缓冲区,则额外损耗的总内存Total(M) =1000000×5K=4882M - - - -Netty提供的ByteBuf支持容量动态调整,同时提供了两种接收缓冲区的内存分配器: - -- **FixedRecvByteBufAllocator**:固定长度的接收缓冲区分配器,它分配的ByteBuf长度是固定大小的,并不会根据实际数据报大小动态收缩。但如果容量不足,支持动态扩展 -- **AdaptiveRecvByteBufAllocator**:容量动态调整的接收缓冲区分配器,会根据之前Channel接收到的数据报大小进行计算,如果连续填充满接收缓冲区的可写空间,则动态扩展容量。如果连续2次接收到的数据报都小于指定值,则收缩当前的容量,以节约内存 - -相对于FixedRecvByteBufAllocator,使用AdaptiveRecvByteBufAllocator更为合理,可在创建客户端或者服务端的时候指定RecvByteBufAllocator。 - -```java -Bootstrap b = new Bootstrap(); - b.group(group) - .channel(NioSocketChannel.class) - .option(ChannelOption.TCP_NODELAY, true) - .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT) +- 设置单进程打开的最大句柄数 + +默认单进程打开的最大句柄数是 `1024`,通过 `ulimit -a` 可以查看相关参数,示例如下: + +```powershell +[root@test ~]# ulimit -a +core file size (blocks, -c) 0 +data seg size (kbytes, -d) unlimited +scheduling priority (-e) 0 +file size (blocks, -f) unlimited +pending signals (-i) 256324 +max locked memory (kbytes, -l) 64 +max memory size (kbytes, -m) unlimited +open files (-n) 1024 +...... ``` -**注意**:无论是接收缓冲区还是发送缓冲区,缓冲区的大小建议设置为消息的平均大小,不要设置成最大消息的上限,这会导致额外的内存浪费。 - - +当并发接入的TCP连接数超过上限时,就会报“too many open files”,所有新的客户端接入将失败。通过 `vi /etc/security/limits.conf` 命令添加如下配置参数: -### 合理使用内存池 - -每个NioEventLoop线程处理N个链路。**链路处理流程**:开始处理A链路→**创建接收缓冲区(创建ByteBuf)**→消息解码→封装成POJO对象→提交至后台线程成Task→**释放接收缓冲区**→开始处理B链路。 - -如果使用内存池,则当A链路接收到新数据报后,从NioEventLoop的内存池中申请空闲的ByteBuf,解码完成后,调用release将ByteBuf释放到内存池中,供后续B链路继续使用。使用内存池优化后,单个NioEventLoop的ByteBuf申请和GC次数从原来的N=1000000/64= 15625次减少为最少0次(假设每次申请都有可用的内存)。 - -Netty默认不使用内存池,需要在创建客户端或者服务端的时候进行指定: - -```java -Bootstrap b = new Bootstrap(); - b.group(group) - .channel(NioSocketChannel.class) - .option(ChannelOption.TCP_NODELAY, true) - .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) +```powershell +* soft nofile 1000000 +* hard nofile 1000000 ``` -使用内存池之后,内存的申请和释放必须成对出现,即 retain() 和 release() 要成对出现,否则会导致内存泄露。值得注意的是,如果使用内存池,完成 ByteBuf 的解码工作之后必须显式的调用 ReferenceCountUtil.release(msg) 对接收缓冲区 ByteBuf 进行内存释放,否则它会被认为仍然在使用中,这样会导致内存泄露。 - +修改之后保存,注销当前用户,重新登录,通过 `ulimit -a` 查看修改的状态是否生效。 +**注意**:尽管我们可以将单个进程打开的最大句柄数修改的非常大,但是当句柄数达到一定数量级之后,处理效率将出现明显下降,因此,需要根据服务器的硬件配置和处理能力进行合理设置。 -### 防止I/O线程被意外阻塞 -通常情况不能在Netty的I/O线程上做执行时间不可控的操作,如访问数据库、调用第三方服务等,但有一些隐形的阻塞操作却容易被忽略,如打印日志。 -生产环境中,一般需要实时打印接口日志,其它日志处于ERROR级别,当服务发生I/O异常后,会记录异常日志。如果磁盘的WIO比较高,可能会发生写日志文件操作被同步阻塞,阻塞时间无法预测,就会导致Netty的NioEventLoop线程被阻塞,Socket链路无法被及时管理,其它链路也无法进行读写操作等。 +### TCP/IP相关参数 -常用的log4j虽然支持异步写日志(AsyncAppender),但当日志队列满之后,它会同步阻塞业务线程,直到日志队列有空闲位置可用。 +需要重点调优的TCP IP参数如下: -类似问题具有极强的隐蔽性,往往WIO高的时间持续非常短,或是偶现的,在测试环境中很难模拟此类故障,问题定位难度大。 +- net.ipv4.tcp_rmem:为每个TCP连接分配的读缓冲区内存大小。第一个值时socket接收缓冲区分配的最小字节数。 -### I/O线程与业务线程分离 +### 多网卡队列和软中断 -- 如果服务端不做复杂业务逻辑操作,仅是简单内存操作和消息转发,则可通过调大NioEventLoop工作线程池的方式,直接在I/O线程中执行业务ChannelHandler,这样便减少了一次线上上下文切换,性能反而更高 -- 如果有复杂的业务逻辑操作,则建议I/O线程和业务线程分离。 - - 对于I/O线程,由于互相之间不存在锁竞争,可以创建一个大的NioEventLoopGroup线程组,所有Channel都共享一个线程池 - - 对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与I/O线程绑定,这样既减少了锁竞争,又提升了后端的处理性能 +- **TCP缓冲区** + 根据推送消息的大小,合理设置以下两个参数,对于海量长连接,通常 32K 是个不错的选择: + - **SO_SNDBUF**:发送缓冲区大小 + - **SO_RCVBUF**:接收缓冲区大小 -### 服务端并发连接数流控 +- **软中断** -无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或遇到端侧设备的大量接入,需要通过流控对系统做保护。一般Netty主要考虑并发连接数的控制。 \ No newline at end of file + 使用命令 `cat /proc/interrupts` 查看网卡硬件中断的运行情况,如果全部被集中在CPU0上处理,则无法并行执行多个软中断。Linux kernel内核≥2.6.35的版本,可以开启RPS,网络通信能力提升20%以上,RPS原理是:根据数据包的源地址、目的地址和源端口等,计算出一个Hash值,然后根据Hash值来选择软中断运行的CPU,即实现每个链接和CPU绑定,通过Hash值来均衡软中断运行在多个CPU上。 \ No newline at end of file diff --git a/src/Middleware/512.md b/src/Middleware/512.md index 5348e36..8209f39 100644 --- a/src/Middleware/512.md +++ b/src/Middleware/512.md @@ -1,28 +1,159 @@ -### 疑似内存泄漏 +- 设置合理的线程数 -**环境**:8C16G的Linux + **boss线程池优化** -**描述**:boss为1,worker为6,其余分配给业务使用,保持10W用户长链接,2W用户并发做消息请求 + 对于Netty服务端,通常只需要启动一个监听端口用于端侧设备接入,但是如果集群实例较少,甚至是单机部署,那么在短时间内大量设备接入时,需要对服务端的监听方式和线程模型做优化,即服务端监听多个端口,利用主从Reactor线程模型。由于同时监听了多个端口,每个ServerSocketChannel都对应一个独立的Acceptor线程,这样就能并行处理,加速端侧设备的接人速度,减少端侧设备的连接超时失败率,提高单节点服务端的处理性能。 -**分析**:dump内存堆栈发现Netty的ScheduledFutureTask增加了9076%,达到110W个实例。通过业务代码分析发现用户使用了IdleStateHandler用于在链路空闲时进行业务逻辑处理,但空闲时间比较大,为15分钟。Netty 的 IdleStateHandler 会根据用户的使用场景,启动三类定时任务,分别是:ReaderIdleTimeoutTask、WriterIdleTimeoutTask 和 AllIdleTimeoutTask,它们都会被加入到 NioEventLoop 的 Task 队列中被调度和执行。由于超时时间过长,10W 个长链接链路会创建 10W 个 ScheduledFutureTask 对象,每个对象还保存有业务的成员变量,非常消耗内存。用户的持久代设置的比较大,一些定时任务被老化到持久代中,没有被 JVM 垃圾回收掉,内存一直在增长,用户误认为存在内存泄露,即小问题被放大而引出的问题。 + -**解决**:重新设计和反复压测之后将超时时间设置为45秒,内存可以实现正常回收。 + **work线程池优化(I/O工作线程池)** + 对于I/O工作线程池的优化,可以先采用系统默认值(cpu内核数*2)进行性能测试,在性能测试过程中采集I/O线程的CPU占用大小,看是否存在瓶颈,具体策略如下: + - 通过执行 `ps -ef|grep java` 找到服务端进程pid + - 执行`top -Hp pid`查询该进程下所有线程的运行情况,通过“shift+p”对CPU占用大小做排序,获取线程的pid及对应的CPU占用大小 + - 使用`printf'%x\n' pid`将pid转换成16进制格式 + - 通过`jstack -f pid`命令获取线程堆栈,或者通过jvisualvm工具打印线程堆栈,找到I/O work工作线程,查看他们的CPU占用大小及线程堆栈,关键词:`SelectorImpl.lockAndDoSelect` -### 当心CLOSE_WAIT + **分析** -由于网络不稳定经常会导致客户端断连,如果服务端没有能够及时关闭 socket,就会导致处于 close_wait 状态的链路过多。close_wait 状态的链路并不释放句柄和内存等资源,如果积压过多可能会导致系统句柄耗尽,发生“Too many open files”异常,新的客户端无法接入,涉及创建或者打开句柄的操作都将失败。 + - 如果连续采集几次进行对比,发现线程堆栈都停留在SelectorImpl.lockAndDoSelect处,则说明I/O线程比较空闲,无需对工作线程数做调整 + - 如果发现I/O线程的热点停留在读或写操作,或停留在ChannelHandler的执行处,则可以通过适当调大NioEventLoop线程的个数来提升网络的读写性能。调整方式有两种: + - 接口API指定:在创建NioEventLoopGroup实例时指定线程数 + - 系统参数指定:通过-Dio.netty.eventLoopThreads来指定NioEventLoopGroup线程池(不建议) -close_wait 是被动关闭连接是形成的,根据 TCP 状态机,服务器端收到客户端发送的 FIN,TCP 协议栈会自动发送 ACK,链接进入 close_wait 状态。但如果服务器端不执行 socket 的 close() 操作,状态就不能由 close_wait 迁移到 last_ack,则系统中会存在很多 close_wait 状态的连接。通常来说,一个 close_wait 会维持至少 2 个小时的时间(系统默认超时时间的是 7200 秒,也就是 2 小时)。如果服务端程序因某个原因导致系统造成一堆 close_wait 消耗资源,那么通常是等不到释放那一刻,系统就已崩溃。 + -导致 close_wait 过多的可能原因如下: + ### 心跳检测优化 -- **程序处理Bug**:导致接收到对方的 fin 之后没有及时关闭 socket,这可能是 Netty 的 Bug,也可能是业务层 Bug,需要具体问题具体分析 -- **关闭socket不及时**:例如 I/O 线程被意外阻塞,或者 I/O 线程执行的用户自定义 Task 比例过高,导致 I/O 操作处理不及时,链路不能被及时释放 + 心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。 -**解决方案** + **海量设备接入的服务端心跳优化策略** -- **不要在 Netty 的 I/O 线程(worker线程)上处理业务(心跳发送和检测除外)** -- **在I/O线程上执行自定义Task要当心** -- **IdleStateHandler、ReadTimeoutHandler和WriteTimeoutHandler使用要当** \ No newline at end of file + - **要能够及时检测失效的连接,并将其剔除**。防止无效的连接句柄积压,导致OOM等问题 + - **设置合理的心跳周期**。防止心跳定时任务积压,造成频繁的老年代GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停 + - **使用Nety提供的链路空闲检测机制**。不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题 + + **心跳检测机制分为三个层面** + + - **TCP层的心跳检测**:即TCP的 Keep-Alive机制,它的作用域是整个TCP协议栈 + - **协议层的心跳检测**:主要存在于长连接协议中,例如MQTT + - **应用层的心跳检测**:它主要由各业务产品通过约定方式定时给对方发送心跳消息实现 + + **心跳检测机制分类** + + - **Ping-Pong型心跳**:由通信一方定时发送Ping消息,对方接收到Ping消息后立即返回Pong答应消息给对方,属于“请求-响应型”心跳 + - **Ping-Ping型心跳**:不区分心跳请求和答应,由通信双发按照约定时间向对方发送心跳Ping消息,属于”双向心跳“ + + **心跳检测机制策略** + + - **心跳超时**:连续N次检测都没有收到对方的Pong应答消息或Ping请求消息,则认为链路已经发生逻辑失效 + - **心跳失败**:在读取和发送心跳消息的时候,如果直接发生了IO异常,说明链路已经失效 + + **链路空闲检测机制** + + - **读空闲**:链路持续时间T没有读取到任何消息 + - **写空闲**:链路持续时间T没有发送任何消息 + - **读写空闲**:链路持续时间T没有接收或者发送任何消息 + + + + **案例分析** + + 由于移动无线网络的特点,推送服务的心跳周期并不能设置的太长,否则长连接会被释放,造成频繁的客户端重连,但是也不能设置太短,否则在当前缺乏统一心跳框架的机制下很容易导致信令风暴(例如微信心跳信令风暴问题)。具体的心跳周期并没有统一的标准,180S 也许是个不错的选择,微信为 300S。 + + 在 Netty 中,可以通过在 ChannelPipeline 中增加 IdleStateHandler 的方式实现心跳检测,在构造函数中指定链路空闲时间,然后实现空闲回调接口,实现心跳的发送和检测。拦截链路空闲事件并处理心跳: + + ```java + public class MyHandler extends ChannelHandlerAdapter { + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof IdleStateEvent) { + // 心跳处理 + } + } + } + ``` + + **心跳优化结论** + + - 对于百万级的服务器,一般不建议很长的心跳周期和超时时长 + - 心跳检测周期通常不要超过60s,心跳检测超时通常为心跳检测周期的2倍 + - 建议通过IdleStateHandler实现心跳,不要自己创建定时任务线程池,加重系统负担和增加潜在并发安全问题 + - 发生心跳超时或心跳失败时,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常 + - 链路空闲事件被触发后并没有关闭链路,而是触发IdleStateEvent事件,用户订阅IdleStateEvent事件,用于自定义逻辑处理。如关闭链路、客户端发起重新连接、告警和日志打印等 + - 链路空闲检测类库主要包括:IdleStateHandler、ReadTimeoutHandler、WriteTimeoutHandler + + + + ### 接收和发送缓冲区调优 + + 对于长链接,每个链路都需要维护自己的消息接收和发送缓冲区,JDK 原生的 NIO 类库使用的是java.nio.ByteBuffer, 它实际是一个长度固定的byte[],无法动态扩容。 + + **场景**:假设单条消息最大上限为10K,平均大小为5K,为满足10K消息处理,ByteBuffer的容量被设置为10K,这样每条链路实际上多消耗了5K内存,如果长链接链路数为100万,每个链路都独立持有ByteBuffer接收缓冲区,则额外损耗的总内存Total(M) =1000000×5K=4882M + + + + Netty提供的ByteBuf支持容量动态调整,同时提供了两种接收缓冲区的内存分配器: + + - **FixedRecvByteBufAllocator**:固定长度的接收缓冲区分配器,它分配的ByteBuf长度是固定大小的,并不会根据实际数据报大小动态收缩。但如果容量不足,支持动态扩展 + - **AdaptiveRecvByteBufAllocator**:容量动态调整的接收缓冲区分配器,会根据之前Channel接收到的数据报大小进行计算,如果连续填充满接收缓冲区的可写空间,则动态扩展容量。如果连续2次接收到的数据报都小于指定值,则收缩当前的容量,以节约内存 + + 相对于FixedRecvByteBufAllocator,使用AdaptiveRecvByteBufAllocator更为合理,可在创建客户端或者服务端的时候指定RecvByteBufAllocator。 + + ```java + Bootstrap b = new Bootstrap(); + b.group(group) + .channel(NioSocketChannel.class) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT) + ``` + + **注意**:无论是接收缓冲区还是发送缓冲区,缓冲区的大小建议设置为消息的平均大小,不要设置成最大消息的上限,这会导致额外的内存浪费。 + + + + ### 合理使用内存池 + + 每个NioEventLoop线程处理N个链路。**链路处理流程**:开始处理A链路→**创建接收缓冲区(创建ByteBuf)**→消息解码→封装成POJO对象→提交至后台线程成Task→**释放接收缓冲区**→开始处理B链路。 + + 如果使用内存池,则当A链路接收到新数据报后,从NioEventLoop的内存池中申请空闲的ByteBuf,解码完成后,调用release将ByteBuf释放到内存池中,供后续B链路继续使用。使用内存池优化后,单个NioEventLoop的ByteBuf申请和GC次数从原来的N=1000000/64= 15625次减少为最少0次(假设每次申请都有可用的内存)。 + + Netty默认不使用内存池,需要在创建客户端或者服务端的时候进行指定: + + ```java + Bootstrap b = new Bootstrap(); + b.group(group) + .channel(NioSocketChannel.class) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + ``` + + 使用内存池之后,内存的申请和释放必须成对出现,即 retain() 和 release() 要成对出现,否则会导致内存泄露。值得注意的是,如果使用内存池,完成 ByteBuf 的解码工作之后必须显式的调用 ReferenceCountUtil.release(msg) 对接收缓冲区 ByteBuf 进行内存释放,否则它会被认为仍然在使用中,这样会导致内存泄露。 + + + + ### 防止I/O线程被意外阻塞 + + 通常情况不能在Netty的I/O线程上做执行时间不可控的操作,如访问数据库、调用第三方服务等,但有一些隐形的阻塞操作却容易被忽略,如打印日志。 + + 生产环境中,一般需要实时打印接口日志,其它日志处于ERROR级别,当服务发生I/O异常后,会记录异常日志。如果磁盘的WIO比较高,可能会发生写日志文件操作被同步阻塞,阻塞时间无法预测,就会导致Netty的NioEventLoop线程被阻塞,Socket链路无法被及时管理,其它链路也无法进行读写操作等。 + + 常用的log4j虽然支持异步写日志(AsyncAppender),但当日志队列满之后,它会同步阻塞业务线程,直到日志队列有空闲位置可用。 + + 类似问题具有极强的隐蔽性,往往WIO高的时间持续非常短,或是偶现的,在测试环境中很难模拟此类故障,问题定位难度大。 + + + + ### I/O线程与业务线程分离 + + - 如果服务端不做复杂业务逻辑操作,仅是简单内存操作和消息转发,则可通过调大NioEventLoop工作线程池的方式,直接在I/O线程中执行业务ChannelHandler,这样便减少了一次线上上下文切换,性能反而更高 + - 如果有复杂的业务逻辑操作,则建议I/O线程和业务线程分离。 + - 对于I/O线程,由于互相之间不存在锁竞争,可以创建一个大的NioEventLoopGroup线程组,所有Channel都共享一个线程池 + - 对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与I/O线程绑定,这样既减少了锁竞争,又提升了后端的处理性能 + + + + ### 服务端并发连接数流控 + + 无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或遇到端侧设备的大量接入,需要通过流控对系统做保护。一般Netty主要考虑并发连接数的控制。 \ No newline at end of file diff --git a/src/Middleware/513.md b/src/Middleware/513.md new file mode 100644 index 0000000..5348e36 --- /dev/null +++ b/src/Middleware/513.md @@ -0,0 +1,28 @@ +### 疑似内存泄漏 + +**环境**:8C16G的Linux + +**描述**:boss为1,worker为6,其余分配给业务使用,保持10W用户长链接,2W用户并发做消息请求 + +**分析**:dump内存堆栈发现Netty的ScheduledFutureTask增加了9076%,达到110W个实例。通过业务代码分析发现用户使用了IdleStateHandler用于在链路空闲时进行业务逻辑处理,但空闲时间比较大,为15分钟。Netty 的 IdleStateHandler 会根据用户的使用场景,启动三类定时任务,分别是:ReaderIdleTimeoutTask、WriterIdleTimeoutTask 和 AllIdleTimeoutTask,它们都会被加入到 NioEventLoop 的 Task 队列中被调度和执行。由于超时时间过长,10W 个长链接链路会创建 10W 个 ScheduledFutureTask 对象,每个对象还保存有业务的成员变量,非常消耗内存。用户的持久代设置的比较大,一些定时任务被老化到持久代中,没有被 JVM 垃圾回收掉,内存一直在增长,用户误认为存在内存泄露,即小问题被放大而引出的问题。 + +**解决**:重新设计和反复压测之后将超时时间设置为45秒,内存可以实现正常回收。 + + + +### 当心CLOSE_WAIT + +由于网络不稳定经常会导致客户端断连,如果服务端没有能够及时关闭 socket,就会导致处于 close_wait 状态的链路过多。close_wait 状态的链路并不释放句柄和内存等资源,如果积压过多可能会导致系统句柄耗尽,发生“Too many open files”异常,新的客户端无法接入,涉及创建或者打开句柄的操作都将失败。 + +close_wait 是被动关闭连接是形成的,根据 TCP 状态机,服务器端收到客户端发送的 FIN,TCP 协议栈会自动发送 ACK,链接进入 close_wait 状态。但如果服务器端不执行 socket 的 close() 操作,状态就不能由 close_wait 迁移到 last_ack,则系统中会存在很多 close_wait 状态的连接。通常来说,一个 close_wait 会维持至少 2 个小时的时间(系统默认超时时间的是 7200 秒,也就是 2 小时)。如果服务端程序因某个原因导致系统造成一堆 close_wait 消耗资源,那么通常是等不到释放那一刻,系统就已崩溃。 + +导致 close_wait 过多的可能原因如下: + +- **程序处理Bug**:导致接收到对方的 fin 之后没有及时关闭 socket,这可能是 Netty 的 Bug,也可能是业务层 Bug,需要具体问题具体分析 +- **关闭socket不及时**:例如 I/O 线程被意外阻塞,或者 I/O 线程执行的用户自定义 Task 比例过高,导致 I/O 操作处理不及时,链路不能被及时释放 + +**解决方案** + +- **不要在 Netty 的 I/O 线程(worker线程)上处理业务(心跳发送和检测除外)** +- **在I/O线程上执行自定义Task要当心** +- **IdleStateHandler、ReadTimeoutHandler和WriteTimeoutHandler使用要当** \ No newline at end of file diff --git a/src/Middleware/_sidebar.md b/src/Middleware/_sidebar.md index 5ee9ff4..a432fca 100644 --- a/src/Middleware/_sidebar.md +++ b/src/Middleware/_sidebar.md @@ -40,18 +40,19 @@ * [✍ ZK选举过程](src/Middleware/414 "ZK选举过程") * [✍ Zookeeper安装](src/Middleware/415 "Zookeeper安装") * 🏁 Netty - * [✍ Netty流程](src/Middleware/501 "Netty流程") - * [✍ 处理事件](src/Middleware/502 "处理事件") - * [✍ 长连接优化](src/Middleware/503 "长连接优化") - * [✍ 线程模型](src/Middleware/504 "线程模型") - * [✍ HashedWheelTimer](src/Middleware/505 "HashedWheelTimer") - * [✍ ByteBuf](src/Middleware/506 "ByteBuf") - * [✍ Zero-Copy](src/Middleware/507 "Zero-Copy") - * [✍ TCP粘包拆包](src/Middleware/508 "TCP粘包拆包") - * [✍ 高性能](src/Middleware/509 "高性能") - * [✍ 操作系统调优](src/Middleware/510 "操作系统调优") - * [✍ Netty性能调优](src/Middleware/511 "Netty性能调优") - * [✍ 案例分析](src/Middleware/512 "案例分析") + * [✍ Netty逻辑架构](src/Middleware/501 "Netty逻辑架构") + * [✍ Reactor线程模型](src/Middleware/502 "Reactor线程模型") + * [✍ 核心设计](src/Middleware/503 "核心设计") + * [✍ 核心流程](src/Middleware/504 "核心流程") + * [✍ Netty流程](src/Middleware/505 "Netty流程") + * [✍ 线程模型](src/Middleware/506 "线程模型") + * [✍ ByteBuf](src/Middleware/507 "ByteBuf") + * [✍ Zero-Copy](src/Middleware/508 "Zero-Copy") + * [✍ TCP粘包拆包](src/Middleware/509 "TCP粘包拆包") + * [✍ 高性能](src/Middleware/510 "高性能") + * [✍ 操作系统调优](src/Middleware/511 "操作系统调优") + * [✍ Netty性能调优](src/Middleware/512 "Netty性能调优") +* [✍ 案例分析](src/Middleware/513 "案例分析") * 🏁 RabbitMQ * [✍ 模式介绍](src/Middleware/601 "模式介绍") * [🏁 Dubbo](src/Middleware/701 "Dubbo") diff --git a/src/Middleware/images/Middleware/ChannelHandler.png b/src/Middleware/images/Middleware/ChannelHandler.png new file mode 100644 index 0000000..aaa520c Binary files /dev/null and b/src/Middleware/images/Middleware/ChannelHandler.png differ diff --git a/src/Middleware/images/Middleware/ChannelOutboundHandler.png b/src/Middleware/images/Middleware/ChannelOutboundHandler.png new file mode 100644 index 0000000..9215e81 Binary files /dev/null and b/src/Middleware/images/Middleware/ChannelOutboundHandler.png differ diff --git a/src/Middleware/images/Middleware/ChannelPipeline入站和出站.png b/src/Middleware/images/Middleware/ChannelPipeline入站和出站.png new file mode 100644 index 0000000..9dd41d6 Binary files /dev/null and b/src/Middleware/images/Middleware/ChannelPipeline入站和出站.png differ diff --git a/src/Middleware/images/Middleware/ChannelPipeline内部结构.png b/src/Middleware/images/Middleware/ChannelPipeline内部结构.png new file mode 100644 index 0000000..51a0f9c Binary files /dev/null and b/src/Middleware/images/Middleware/ChannelPipeline内部结构.png differ diff --git a/src/Middleware/images/Middleware/ChannelPipeline结构图.png b/src/Middleware/images/Middleware/ChannelPipeline结构图.png new file mode 100644 index 0000000..fe10342 Binary files /dev/null and b/src/Middleware/images/Middleware/ChannelPipeline结构图.png differ diff --git a/src/Middleware/images/Middleware/ClientServerChannelPipeline.png b/src/Middleware/images/Middleware/ClientServerChannelPipeline.png new file mode 100644 index 0000000..f1e3f56 Binary files /dev/null and b/src/Middleware/images/Middleware/ClientServerChannelPipeline.png differ diff --git a/src/Middleware/images/Middleware/EventLoop通用的运行模式.png b/src/Middleware/images/Middleware/EventLoop通用的运行模式.png new file mode 100644 index 0000000..ad97aa3 Binary files /dev/null and b/src/Middleware/images/Middleware/EventLoop通用的运行模式.png differ diff --git a/src/Middleware/images/Middleware/Netty事件调度层.png b/src/Middleware/images/Middleware/Netty事件调度层.png new file mode 100644 index 0000000..aed23c1 Binary files /dev/null and b/src/Middleware/images/Middleware/Netty事件调度层.png differ diff --git a/src/Middleware/images/Middleware/Netty内部逻辑的流转.png b/src/Middleware/images/Middleware/Netty内部逻辑的流转.png new file mode 100644 index 0000000..f5cd2fb Binary files /dev/null and b/src/Middleware/images/Middleware/Netty内部逻辑的流转.png differ diff --git a/src/Middleware/images/Middleware/Netty协议.png b/src/Middleware/images/Middleware/Netty协议.png new file mode 100644 index 0000000..6bb3494 Binary files /dev/null and b/src/Middleware/images/Middleware/Netty协议.png differ diff --git a/src/Middleware/images/Middleware/Netty异常处理的最佳实践.png b/src/Middleware/images/Middleware/Netty异常处理的最佳实践.png new file mode 100644 index 0000000..d5cafa6 Binary files /dev/null and b/src/Middleware/images/Middleware/Netty异常处理的最佳实践.png differ diff --git a/src/Middleware/images/Middleware/Netty统一异常处理验证.png b/src/Middleware/images/Middleware/Netty统一异常处理验证.png new file mode 100644 index 0000000..2c487be Binary files /dev/null and b/src/Middleware/images/Middleware/Netty统一异常处理验证.png differ diff --git a/src/Middleware/images/Middleware/Netty逻辑架构.png b/src/Middleware/images/Middleware/Netty逻辑架构.png new file mode 100644 index 0000000..1ffc7a5 Binary files /dev/null and b/src/Middleware/images/Middleware/Netty逻辑架构.png differ diff --git a/src/Middleware/images/Middleware/Reactor线程模型运行机制.png b/src/Middleware/images/Middleware/Reactor线程模型运行机制.png new file mode 100644 index 0000000..37904b6 Binary files /dev/null and b/src/Middleware/images/Middleware/Reactor线程模型运行机制.png differ diff --git a/src/Middleware/images/Middleware/SampleOutBoundHandler执行结果.png b/src/Middleware/images/Middleware/SampleOutBoundHandler执行结果.png new file mode 100644 index 0000000..569cc9a Binary files /dev/null and b/src/Middleware/images/Middleware/SampleOutBoundHandler执行结果.png differ diff --git a/src/Middleware/images/Middleware/主从Reactor多线程.png b/src/Middleware/images/Middleware/主从Reactor多线程.png new file mode 100644 index 0000000..9f8b52e Binary files /dev/null and b/src/Middleware/images/Middleware/主从Reactor多线程.png differ diff --git a/src/Middleware/images/Middleware/事件处理机制.png b/src/Middleware/images/Middleware/事件处理机制.png new file mode 100644 index 0000000..ce5c272 Binary files /dev/null and b/src/Middleware/images/Middleware/事件处理机制.png differ diff --git a/src/Middleware/images/Middleware/单Reactor单线程.png b/src/Middleware/images/Middleware/单Reactor单线程.png new file mode 100644 index 0000000..756061a Binary files /dev/null and b/src/Middleware/images/Middleware/单Reactor单线程.png differ diff --git a/src/Middleware/images/Middleware/单Reactor多线程.png b/src/Middleware/images/Middleware/单Reactor多线程.png new file mode 100644 index 0000000..0d2da35 Binary files /dev/null and b/src/Middleware/images/Middleware/单Reactor多线程.png differ diff --git a/src/OS/10.md b/src/OS/10.md index 6656a73..98b572a 100644 --- a/src/OS/10.md +++ b/src/OS/10.md @@ -1,36 +1,14 @@ -信号驱动式I/O是指进程预先告知内核,使得某个文件描述符上发生了变化时,内核使用信号通知该进程。在信号驱动式I/O模型,进程使用socket进行信号驱动I/O,并建立一个SIGIO信号处理函数,当进程通过该信号处理函数向内核发起I/O调用时,内核并没有准备好数据报,而是返回一个信号给进程,此时进程可以继续发起其他I/O调用。也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。当数据报准备好之后,内核会递交SIGIO信号,通知用户空间的信号处理程序,数据已准备好;此时进程会发起recvfrom的系统调用,这一个阶段与阻塞式I/O无异。也就是说,在第二阶段内核复制数据到用户空间的过程中,进程同样是被阻塞的。 +AIO(异步非阻塞IO,即NIO.2)。异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。 -**信号驱动式I/O的整个过程图如下:** +![异步非阻塞IO](images/OS/异步非阻塞IO.png) -![信号驱动式IO](images/OS/信号驱动式IO.png) - -**第一阶段(非阻塞):** - -- ①:进程使用socket进行信号驱动I/O,建立SIGIO信号处理函数,向内核发起系统调用,内核在未准备好数据报的情况下返回一个信号给进程,此时进程可以继续做其他事情 -- ②:内核将磁盘中的数据加载至内核缓冲区完成后,会递交SIGIO信号给用户空间的信号处理程序 - -**第二阶段(阻塞):** - -- ③:进程在收到SIGIO信号程序之后,进程向内核发起系统调用(recvfrom) -- ④:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行IO过程的阶段),直到数据复制完成 -- ⑤:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个I/O操作 - - - -**特点:**借助socket进行信号驱动I/O并建立SIGIO信号处理函数 +**特点:**第一阶段和第二阶段都是有内核完成。 **优点** -- 线程并没有在第一阶段(数据等待)时被阻塞,提高了资源利用率; +- 能充分利用DMA的特性,将I/O操作与计算重叠,提高性能、资源利用率与并发能力 **缺点** - 在程序的实现上比较困难 -- 信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失 - - - -**信号通知机制** - -- **水平触发:**指数据报到内核缓冲区准备好之后,内核通知进程后,进程因繁忙未发起recvfrom系统调用;内核会再次发送通知信号,循环往复,直到进程来请求recvfrom系统调用。很明显,这种方式会频繁消耗过多的系统资源 -- **边缘触发:**内核只会发送一次通知信号 \ No newline at end of file +- 要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 复用式I/O模型为主 \ No newline at end of file diff --git a/src/OS/11.md b/src/OS/11.md new file mode 100644 index 0000000..6656a73 --- /dev/null +++ b/src/OS/11.md @@ -0,0 +1,36 @@ +信号驱动式I/O是指进程预先告知内核,使得某个文件描述符上发生了变化时,内核使用信号通知该进程。在信号驱动式I/O模型,进程使用socket进行信号驱动I/O,并建立一个SIGIO信号处理函数,当进程通过该信号处理函数向内核发起I/O调用时,内核并没有准备好数据报,而是返回一个信号给进程,此时进程可以继续发起其他I/O调用。也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。当数据报准备好之后,内核会递交SIGIO信号,通知用户空间的信号处理程序,数据已准备好;此时进程会发起recvfrom的系统调用,这一个阶段与阻塞式I/O无异。也就是说,在第二阶段内核复制数据到用户空间的过程中,进程同样是被阻塞的。 + +**信号驱动式I/O的整个过程图如下:** + +![信号驱动式IO](images/OS/信号驱动式IO.png) + +**第一阶段(非阻塞):** + +- ①:进程使用socket进行信号驱动I/O,建立SIGIO信号处理函数,向内核发起系统调用,内核在未准备好数据报的情况下返回一个信号给进程,此时进程可以继续做其他事情 +- ②:内核将磁盘中的数据加载至内核缓冲区完成后,会递交SIGIO信号给用户空间的信号处理程序 + +**第二阶段(阻塞):** + +- ③:进程在收到SIGIO信号程序之后,进程向内核发起系统调用(recvfrom) +- ④:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行IO过程的阶段),直到数据复制完成 +- ⑤:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个I/O操作 + + + +**特点:**借助socket进行信号驱动I/O并建立SIGIO信号处理函数 + +**优点** + +- 线程并没有在第一阶段(数据等待)时被阻塞,提高了资源利用率; + +**缺点** + +- 在程序的实现上比较困难 +- 信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失 + + + +**信号通知机制** + +- **水平触发:**指数据报到内核缓冲区准备好之后,内核通知进程后,进程因繁忙未发起recvfrom系统调用;内核会再次发送通知信号,循环往复,直到进程来请求recvfrom系统调用。很明显,这种方式会频繁消耗过多的系统资源 +- **边缘触发:**内核只会发送一次通知信号 \ No newline at end of file diff --git a/src/OS/6.md b/src/OS/6.md index 6e40441..e183ba4 100644 --- a/src/OS/6.md +++ b/src/OS/6.md @@ -1,24 +1,75 @@ -每个客户端的Socket连接请求,服务端都会对应有个处理线程与之对应,对于没有分配到处理线程的连接就会被阻塞或者拒绝。相当于是`一个连接一个线程`。 +### 数据的四次拷贝与四次上下文切换 -用户需要等待read将socket中的数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。 +很多应用程序在面临客户端请求时,可以等价为进行如下的系统调用: -![同步阻塞IO](images/OS/同步阻塞IO.png) +- File.read(file, buf, len); +- Socket.send(socket, buf, len); -**特点:**I/O执行的两个阶段进程都是阻塞的。 +例如消息中间件 Kafka 就是这个应用场景,从磁盘中读取一批消息后原封不动地写入网卡(NIC,Network interface controller)进行发送。在没有任何优化技术使用的背景下,操作系统为此会进行 4 次数据拷贝,以及 4 次上下文切换,如下图所示: -- 使用一个独立的线程维护一个socket连接,随着连接数量的增多,对虚拟机造成一定压力 -- 使用流来读取数据,流是阻塞的,当没有可读/可写数据时,线程等待,会造成资源的浪费 +![Zero-Copy](images/OS/Zero-Copy.png) +如果没有优化,读取磁盘数据,再通过网卡传输的场景性能比较差: +**4次copy** -**优点** +- CPU 负责将数据从磁盘搬运到内核空间的 Page Cache 中 +- CPU 负责将数据从内核空间的 Socket 缓冲区搬运到的网络中 +- CPU 负责将数据从内核空间的 Page Cache 搬运到用户空间的缓冲区 +- CPU 负责将数据从用户空间的缓冲区搬运到内核空间的 Socket 缓冲区中 -- 能够及时的返回数据,无延迟 -- 程序简单,进程挂起基本不会消耗CPU时间 +**4次上下文切换** +- read 系统调用时:用户态切换到内核态 +- read 系统调用完毕:内核态切换回用户态 +- write 系统调用时:用户态切换到内核态 +- write 系统调用完毕:内核态切换回用户态 +**问题分析** -**缺点** +- CPU 全程负责内存内的数据拷贝还可以接受,因为效率还算可以接受,但是如果要全程负责内存与磁盘、网络的数据拷贝,这将难以接受,因为磁盘、网卡的速度远小于内存,内存又远远小于 CPU +- 4 次 copy 太多了,4 次上下文切换也太频繁了 -- I/O等待对性能影响较大 -- 每个连接需要独立的一个进程/线程处理,当并发请求量较大时为了维护程序,内存、线程和CPU上下文切换开销较大,因此较少在开发环境中使用 \ No newline at end of file + + +### 零拷贝技术 + +零拷贝技术是一个思想,指的是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。 + +可见,零拷贝的特点是 CPU 不全程负责内存中的数据写入其他组件,CPU 仅仅起到管理的作用。但注意,零拷贝不是不进行拷贝,而是 CPU 不再全程负责数据拷贝时的搬运工作。如果数据本身不在内存中,那么必须先通过某种方式拷贝到内存中(这个过程 CPU 可以不参与),因为数据只有在内存中,才能被转移,才能被 CPU 直接读取计算。 + +零拷贝技术的具体实现方式有很多,例如: + +- **sendfile** +- **mmap** +- **splice** +- **直接 Direct I/O** + +不同的零拷贝技术适用于不同的应用场景,下面依次进行 sendfile、mmap、Direct I/O 的分析。 + +- DMA 技术回顾:DMA 负责内存与其他组件之间的数据拷贝,CPU 仅需负责管理,而无需负责全程的数据拷贝; +- 使用 page cache 的 zero copy: +- sendfile:一次代替 read/write 系统调用,通过使用 DMA 技术以及传递文件描述符,实现了 zero copy +- mmap:仅代替 read 系统调用,将内核空间地址映射为用户空间地址,write 操作直接作用于内核空间。通过 DMA 技术以及地址映射技术,用户空间与内核空间无须数据拷贝,实现了 zero copy +- 不使用 page cache 的 Direct I/O:读写操作直接在磁盘上进行,不使用 page cache 机制,通常结合用户空间的用户缓存使用。通过 DMA 技术直接与磁盘/网卡进行数据交互,实现了 zero copy + +#### sendfile + +snedfile 的应用场景是用户从磁盘读取一些文件数据后不需要经过任何计算与处理就通过网络传输出去。此场景的典型应用是消息队列。在传统 I/O 下,上述应用场景的一次数据传输需要四次 CPU 全权负责的拷贝与四次上下文切换。sendfile 主要使用到了两个技术: + +- DMA 技术 +- 传递文件描述符代替数据拷贝 + +**利用DMA技术** + +sendfile 依赖于 DMA 技术,将四次 CPU 全程负责的拷贝与四次上下文切换减少到两次,如下图所示: + +![利用DMA技术](images/OS/利用DMA技术.png) + +利用 DMA 技术减少 2 次 CPU 全程参与的拷贝DMA 负责磁盘到内核空间中的 Page cache(read buffer)的数据拷贝以及从内核空间中的 socket buffer 到网卡的数据拷贝。 + + + +链接1:https://blog.csdn.net/weixin_38726452/article/details/120168360 + +链接2:https://mp.weixin.qq.com/s?__biz=Mzg4MDAxNzkyMw==&mid=2247489694&idx=1&sn=3c9f532eb9d650b2ae2b1db5d0c91b09&chksm=cf7acff2f80d46e431c0bd0d60270a8ce3d31af8a50678e11d5b39bb6c8a9dfa333e7ac56704&mpshare=1&scene=23&srcid=0903RuYh2ynm5j60Y7QtETJO&sharer_sharetime=1638369376739&sharer_shareid=0f9991a2eb945ab493c13ed9bfb8bf4b%23rd \ No newline at end of file diff --git a/src/OS/7.md b/src/OS/7.md index 5a52a50..6e40441 100644 --- a/src/OS/7.md +++ b/src/OS/7.md @@ -1,26 +1,24 @@ -服务器端保存一个Socket连接列表,然后对这个列表进行轮询: +每个客户端的Socket连接请求,服务端都会对应有个处理线程与之对应,对于没有分配到处理线程的连接就会被阻塞或者拒绝。相当于是`一个连接一个线程`。 -- 如果发现某个Socket端口上有数据可读时说明读就绪,则调用该Socket连接的相应读操作 -- 如果发现某个Socket端口上有数据可写时说明写就绪,则调用该Socket连接的相应写操作 -- 如果某个端口的Socket连接已经中断,则调用相应的析构方法关闭该端口 +用户需要等待read将socket中的数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。 -这样能充分利用服务器资源,效率得到了很大提高,在进行I/O操作请求时候再用个线程去处理,是`一个请求一个线程`。Java中使用Selector、Channel、Buffer来实现上述效果。 +![同步阻塞IO](images/OS/同步阻塞IO.png) -- `Selector`:Selector允许单线程处理多个Channel。如果应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,得向Selector注册Channel,然后调用他的select方法,这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子入有新连接接进来,数据接收等。 -- `Channel`:基本上所有的IO在NIO中都从一个Channel开始。Channel有点像流,数据可以从channel**读**到buffer,也可以从buffer**写**到channel。 -- `Buffer`:缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松的使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变换情况,Channel提供从文件,网络读取数据的渠道,但是读取或者写入的数据都必须经由Buffer。 +**特点:**I/O执行的两个阶段进程都是阻塞的。 -用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求过程中,虽然用户线程每次发起IO请求后可以立即返回,但为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。 +- 使用一个独立的线程维护一个socket连接,随着连接数量的增多,对虚拟机造成一定压力 +- 使用流来读取数据,流是阻塞的,当没有可读/可写数据时,线程等待,会造成资源的浪费 -![同步非阻塞IO](images/OS/同步非阻塞IO.png) -**特点:**non-blocking I/O模式需要不断的主动询问kernel数据是否已准备好。 **优点** -- 进程在等待当前任务完成时,可以同时执行其他任务进程不会被阻塞在内核等待数据过程,每次发起的I/O请求会立即返回,具有较好的实时性 +- 能够及时的返回数据,无延迟 +- 程序简单,进程挂起基本不会消耗CPU时间 + + **缺点** -- 不断轮询将占用大量CPU时间,系统资源利用率大打折扣,影响性能,整体数据吞吐量下降 -- 该模型不适用web服务器 \ No newline at end of file +- I/O等待对性能影响较大 +- 每个连接需要独立的一个进程/线程处理,当并发请求量较大时为了维护程序,内存、线程和CPU上下文切换开销较大,因此较少在开发环境中使用 \ No newline at end of file diff --git a/src/OS/8.md b/src/OS/8.md index 6a27a67..5a52a50 100644 --- a/src/OS/8.md +++ b/src/OS/8.md @@ -1,15 +1,26 @@ -通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。 +服务器端保存一个Socket连接列表,然后对这个列表进行轮询: -![IO多路复用](images/OS/IO多路复用.png) +- 如果发现某个Socket端口上有数据可读时说明读就绪,则调用该Socket连接的相应读操作 +- 如果发现某个Socket端口上有数据可写时说明写就绪,则调用该Socket连接的相应写操作 +- 如果某个端口的Socket连接已经中断,则调用相应的析构方法关闭该端口 -**特点:**通过一种机制能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个变为可读就绪状态,select()/poll()函数就会返回。 +这样能充分利用服务器资源,效率得到了很大提高,在进行I/O操作请求时候再用个线程去处理,是`一个请求一个线程`。Java中使用Selector、Channel、Buffer来实现上述效果。 + +- `Selector`:Selector允许单线程处理多个Channel。如果应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,得向Selector注册Channel,然后调用他的select方法,这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子入有新连接接进来,数据接收等。 +- `Channel`:基本上所有的IO在NIO中都从一个Channel开始。Channel有点像流,数据可以从channel**读**到buffer,也可以从buffer**写**到channel。 +- `Buffer`:缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松的使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变换情况,Channel提供从文件,网络读取数据的渠道,但是读取或者写入的数据都必须经由Buffer。 + +用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求过程中,虽然用户线程每次发起IO请求后可以立即返回,但为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。 + +![同步非阻塞IO](images/OS/同步非阻塞IO.png) + +**特点:**non-blocking I/O模式需要不断的主动询问kernel数据是否已准备好。 **优点** -- 可以基于一个阻塞对象,同时在多个描述符上可读就绪,而不是使用多个线程(每个描述符一个线程),即能处理更多的连接 -- 可以节省更多的系统资源 +- 进程在等待当前任务完成时,可以同时执行其他任务进程不会被阻塞在内核等待数据过程,每次发起的I/O请求会立即返回,具有较好的实时性 -**缺点:** +**缺点** -- 如果处理的连接数不是很多的话,使用select/poll的web server不一定比使用multi-threading + blocking I/O的web server性能更好 -- 可能延迟还更大,因为处理一个连接数需要发起两次system call \ No newline at end of file +- 不断轮询将占用大量CPU时间,系统资源利用率大打折扣,影响性能,整体数据吞吐量下降 +- 该模型不适用web服务器 \ No newline at end of file diff --git a/src/OS/9.md b/src/OS/9.md index 98b572a..6a27a67 100644 --- a/src/OS/9.md +++ b/src/OS/9.md @@ -1,14 +1,15 @@ -AIO(异步非阻塞IO,即NIO.2)。异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。 +通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。 -![异步非阻塞IO](images/OS/异步非阻塞IO.png) +![IO多路复用](images/OS/IO多路复用.png) -**特点:**第一阶段和第二阶段都是有内核完成。 +**特点:**通过一种机制能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个变为可读就绪状态,select()/poll()函数就会返回。 **优点** -- 能充分利用DMA的特性,将I/O操作与计算重叠,提高性能、资源利用率与并发能力 +- 可以基于一个阻塞对象,同时在多个描述符上可读就绪,而不是使用多个线程(每个描述符一个线程),即能处理更多的连接 +- 可以节省更多的系统资源 -**缺点** +**缺点:** -- 在程序的实现上比较困难 -- 要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 复用式I/O模型为主 \ No newline at end of file +- 如果处理的连接数不是很多的话,使用select/poll的web server不一定比使用multi-threading + blocking I/O的web server性能更好 +- 可能延迟还更大,因为处理一个连接数需要发起两次system call \ No newline at end of file diff --git a/src/OS/_sidebar.md b/src/OS/_sidebar.md index d72d6f5..0c69fa0 100644 --- a/src/OS/_sidebar.md +++ b/src/OS/_sidebar.md @@ -3,11 +3,12 @@ * [✍ Reactor模式](src/OS/3 "Reactor模式") * [✍ Proactor模式](src/OS/4 "Proactor模式") * [✍ select/poll/epoll](src/OS/5 "select/poll/epoll") - * [✍ BIO(同步阻塞I/O)](src/OS/6 "BIO(同步阻塞I/O)") - * [✍ NIO(同步非阻塞I/O)](src/OS/7 "NIO(同步非阻塞I/O)") - * [✍ IO多路复用(异步阻塞I/O)](src/OS/8 "IO多路复用(异步阻塞I/O)") - * [✍ AIO(异步非阻塞I/O)](src/OS/9 "AIO(异步非阻塞I/O)") - * [✍ 信号驱动式I/O](src/OS/10 "信号驱动式I/O") + * [✍ 零拷贝(Zero-Copy)](src/OS/6 "零拷贝(Zero-Copy)") + * [✍ BIO(同步阻塞I/O)](src/OS/7 "BIO(同步阻塞I/O)") + * [✍ NIO(同步非阻塞I/O)](src/OS/8 "NIO(同步非阻塞I/O)") + * [✍ IO多路复用(异步阻塞I/O)](src/OS/9 "IO多路复用(异步阻塞I/O)") + * [✍ AIO(异步非阻塞I/O)](src/OS/10 "AIO(异步非阻塞I/O)") + * [✍ 信号驱动式I/O](src/OS/11 "信号驱动式I/O") * [🏁 TCP](src/OS/101 "TCP") * [✍ 网络模型](src/OS/102 "网络模型") * [✍ TCP状态](src/OS/103 "TCP状态") diff --git a/src/OS/images/OS/IO请求流程.png b/src/OS/images/OS/IO请求流程.png new file mode 100644 index 0000000..ad26634 Binary files /dev/null and b/src/OS/images/OS/IO请求流程.png differ diff --git a/src/OS/images/OS/Zero-Copy.png b/src/OS/images/OS/Zero-Copy.png new file mode 100644 index 0000000..c653e4c Binary files /dev/null and b/src/OS/images/OS/Zero-Copy.png differ diff --git a/src/OS/images/OS/信号驱动IO.png b/src/OS/images/OS/信号驱动IO.png new file mode 100644 index 0000000..eab4e42 Binary files /dev/null and b/src/OS/images/OS/信号驱动IO.png differ diff --git a/src/OS/images/OS/利用DMA技术.png b/src/OS/images/OS/利用DMA技术.png new file mode 100644 index 0000000..896ab2a Binary files /dev/null and b/src/OS/images/OS/利用DMA技术.png differ diff --git a/src/Open/1.md b/src/Open/1.md new file mode 100644 index 0000000..5f2b0a4 --- /dev/null +++ b/src/Open/1.md @@ -0,0 +1,58 @@ +**问题**:A文件有30亿个QQ号码,B文件有40亿个QQ号码,求A文件和B文件中QQ号码的交集,内存大小限制为1GB。 + +### 方案一:直接暴力比较 + +最简单的方法是直接暴力比较:双重循环比较。显然,这种方法要比较的次数是:30亿×40亿,时间复杂度太大。 + + + +### 方案二:利用hashmap + +将B文件中的40亿个QQ号码放入Hash表中,然后遍历B文件中的30亿个QQ号码,准一判断是否已在Hash表中存在。 + +显然,应该用哈希表优化查找速度,使得时间复杂度大大降低,只需要遍历上面一层即可。然而,空间的占用还是太大了,1GB的内存根本无法容纳40亿个QQ号。 + + + +### 方案三:分治切割文件 + +既然内存容纳不下,那就想办法进行切割,比如:根据QQ号码的最后一位的值来切割A文件和B文件,使文件变小。显然,尾数为j的QQ号码只可能在Aj文件和Bj文件中,只需要比较Aj和Bj文件即可。 + +| QQ号最后一位 | A文件的切割 | B文件的切割 | +| ------------ | ----------- | ----------- | +| 0 | A0 | B0 | +| 1 | A1 | B1 | +| 2 | A2 | B2 | +| 3 | A3 | B3 | +| ... | ... | ... | + +通过切割的方法,可以化大为小,让内存容纳得下。需要强调的是,仅仅以QQ号最后一位来划分,那么每个小文件的数据量大约是原来文件的1/10, 可能还是偏大。所以可以考虑以QQ号的最后3位来划分,那么每个小文件的数据量大约是原来大文件的1/1000,甚至还可以更细地来进行划分。通过一定的规则进行分割,把A文件和B文件中同类型数据划分到对应的小文件中,解决了内存问题。 + + + +### 方案四:利用bitmap + +可以对hashmap进行优化,采用bitmap这种数据结构,可以顺利地同时解决时间问题和空间问题。 + +| bit | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | +| ----- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | +| index | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | + +1个unsigned char类型的数据,可以标识0~7这8个整数的存在与否。以此类推: + +- 1个unsigned int类型数据可以标识0~31这32(2^5)个整数的存在与否 +- 2个unsigned int类型数据可以标识0~63这64(2^6)个整数的存在与否 +- 3个unsigned int类型数据可以标识0~127这128(2^7)个整数的存在与否 +- 4个unsigned int类型数据可以标识0~255这256(2^8)个整数的存在与否 +- ...... +- 28个unsigned int类型数据可以标识0~2^32-1这43亿(2^32)个整数的存在与否 + +10位QQ号码的理论最大值为 2^32 - 1 ≈ 43 亿bit。 + +占用内存:43亿bit≈5.37亿bytes≈52万KB≈512MB + +显然,可以推导出:512MB大小足够标识所有QQ号码的存在与否,请注意:QQ号码的理论最大值为2^32 - 1,大概是43亿左右。接下来的问题就很简单了: + +用512MB的unsigned int数组来记录B文件中QQ号码的存在与否,形成一个bitmap。然后遍历A文件中的QQ,看是否在bitmap中,如果在,那么该QQ号码就同时存在于A和B两个文件中。 + +https://mp.weixin.qq.com/s/Q_EvlN9LvkdA5M5EharBBA \ No newline at end of file diff --git a/src/Open/2.md b/src/Open/2.md new file mode 100644 index 0000000..180daf4 --- /dev/null +++ b/src/Open/2.md @@ -0,0 +1,11 @@ +文件中有40亿个QQ号码,请设计算法对QQ号码去重,相同的QQ号码仅保留一个,内存限制1G。 + +https://mp.weixin.qq.com/s/YlLYDzncB6tqbffrg__13w + +### 方法一:排序 + +### 方法二:hashmap + +### 方法三:文件切割 + +### 方法四:bitmap \ No newline at end of file diff --git a/src/Open/3.md b/src/Open/3.md new file mode 100644 index 0000000..c1b3f8b --- /dev/null +++ b/src/Open/3.md @@ -0,0 +1,11 @@ +企业IM比如企业微信、钉钉里面的群消息的有个已读未读的功能,发送者刚发出消息时,当前群里其他群成员都是未读状态,陆陆续续有人看了这个消息,这时候消息的详情变成x人已读,y人未读。每条消息对应一个唯一的`messageid(uint64_t)`,每个用户对应一个唯一的`userid(uint64_t)`,应该如何保存这个消息对应的已读未读详情呢? + +### 简单粗暴方案 + +对于每一个messageid,存当前`read_ids + unread_ids`,当群成员A已读某一条消息时,把A的userid从unread_ids移除写到read_ids上就好了,客户端更新到messageid对应的详情列表,就可以展示m人已读,n人未读。 + +按照目前的设计,每一条消息已读未读详情就要占用8B × 群成员数的内存,如果一个活跃的200人大群,每发一条消息,已读未读就要1600B,如果平均每天消息量是1k,那每个这样的群,每天就要1.6MB磁盘空间,对于客户端来说,特别是手机端,占用磁盘空间是用户不能接受的,又不能把工作消息删了,对于服务器端来说,用户群体如果特别大,那数据库存储这个成本也不小。 + + + +### bitmap方案 \ No newline at end of file diff --git a/src/Open/4.md b/src/Open/4.md new file mode 100644 index 0000000..cafb8b9 --- /dev/null +++ b/src/Open/4.md @@ -0,0 +1,4 @@ +!> 你所查看的内容即将发布,敬请期待!有问题欢迎点击右上角Gitee仓库留言! + + +🚀点击左侧菜单栏查看其它文章吧! \ No newline at end of file diff --git a/src/Open/README.md b/src/Open/README.md new file mode 100644 index 0000000..8545761 --- /dev/null +++ b/src/Open/README.md @@ -0,0 +1,5 @@ +
Open
+ +Introduction:收纳开放性设计问题相关的知识总结! + +## 🚀点击左侧菜单栏开始吧! \ No newline at end of file diff --git a/src/Open/_sidebar.md b/src/Open/_sidebar.md new file mode 100644 index 0000000..1f538d9 --- /dev/null +++ b/src/Open/_sidebar.md @@ -0,0 +1,5 @@ +* 🏁 开放性设计 + * [✍ 求两个文件中的QQ交集](src/Open/1 "求两个文件中的QQ交集") + * [✍ 40亿个QQ号码如何去重](src/Open/2 "40亿个QQ号码如何去重") + * [✍ 群聊消息已读未读功能设计](src/Open/3 "群聊消息已读未读功能设计") + * [✍ 未支付订单超时关闭](src/Open/4 "未支付订单超时关闭") \ No newline at end of file diff --git a/src/Solution/1108.md b/src/Solution/1108.md index a812b86..ab9f663 100644 --- a/src/Solution/1108.md +++ b/src/Solution/1108.md @@ -38,4 +38,33 @@ - **一般**:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级 - **警告**:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警 - **错误**:比如可用率低于90%,或数据库连接池被打爆,或访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级 -- **严重错误**:比如因为特殊原因数据错误了,此时需要紧急人工降级 \ No newline at end of file +- **严重错误**:比如因为特殊原因数据错误了,此时需要紧急人工降级 + + + +### 热点数据 + +数据库里有2000W数据,Redis中只存20W的数据,如何保证 Redis 中的数据都是热点数据? + +当 Redis 中的数据集上升到一定大小的时候,就需要实施数据淘汰策略,以保证 Redis 的内存不会被撑爆;那么如何保证 Redis 中的数据都是热点数据,需要先看看 Redis 有哪些数据淘汰策略。 + +**Redis 数据淘汰策略** + +- volatile-lru:从已设置过期时间的数据集中,挑选最近最少使用的数据淘汰 +- volatile-ttl:从已设置过期时间的数据集中,挑选将要过期的数据淘汰 +- volatile-random:从已设置过期时间的数据集中,任意选择数据淘汰 +- allkeys-lru:当内存不足以容纳新写入数据时,移除最近最少使用的key +- allkeys-random:从数据集中任意选择数据淘汰 +- no-eviction:禁止淘汰数据,也就是说当内存不足时,新写入操作会报错 + +**到了4.0版本后,又增加以下两种淘汰策略:** + +- volatile-lfu:从已设置过期时间的数据集中,挑选最不经常使用的数据淘汰(注意lfu和lru的区别) +- allkeys-lfu:当内存不足以容纳新写入数据时,移除最不经常使用的key + +**如何选择策略规则** + +针对题目中的问题,还需要考虑数据的分布情况: + +- 如果数据呈现幂律分布,一部分数据访问频率高,一部分数据访问频率低,则可以使用allkeys-lru或allkeys-lfu +- 如果数据呈现平等分布,所有的数据访问频率都相同,则使用allkeys-random \ No newline at end of file