Kafka是一个分布式数据流平台,可以运行在单台服务器上,也可以在多台服务器上部署形成集群。它提供了发布和订阅功能,使用者可以发送数据到Kafka中,也可以从Kafka中读取数据(以便进行后续的处理)。Kafka具有高吞吐、低延迟、高容错、可水平扩展、支持流数据处理等特点。
Kafka作为一个高度可扩展可容错的消息系统,一个典型的kafka集群中包含若干producer,若干broker,若干consumer,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在consumer group发生变化时进行rebalance。producer使用push模式将消息发布到broker,consumer使用pull模式从broker订阅并消费消息:
下面是kafka的一些专业术语:
Kafka实现了零拷贝原理来快速移动数据,避免了内核之间的切换。Kafka可以将数据记录分批发送,从生产者到文件系统(Kafka主题日志)到消费者,可以端到端的查看这些批次的数据。批处理能够进行更有效的数据压缩并减少I/O延迟,Kafka采取顺序写入磁盘的方式,避免了随机磁盘寻址的浪费,总结一下其实就是四个要点:
在kafka中,消息都是以 topic 进行分类的,生产者和消费者都是面向topic的,在 kafka 中,一个 topic 可以分为多个 partition,一个 partition可以分为多个segment, 每个 segment 对应两个文件:.index 和 .log 文件;topic 是逻辑上的概念,而 patition 是物理上的概念,每个 patition 对应一个 log 文件,而 log 文件中存储的就是 producer 生产的数据,patition 生产的数据会被不断的添加到 log 文件的末端,且每条数据都有自己的 offset。消费组中的每个消费者,都是实时记录自己消费到哪个 offset,以便出错恢复,从上次的位置继续消费。
由于生产者生产的消息会不断追加到 log 文件末尾,为防止 log 文件过大导致数据定位效率低下,Kafka 采取了分片和索引机制,将每个 partition 分为多个 segment。每个 segment 对应两个文件——.index文件和 .log文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号。
如下,我们创建一个只有一个分区一个副本的 topic:
1 | bin/kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic dawn |
然后可以在 kafka-logs 目录(server.properties 默认配置)下看到会有个名为 dawn-0 的文件夹。如果,starfish 这个 topic 有三个分区,则其对应的文件夹为 dawn-0,dawn-1,dawn-2。
这些文件的含义如下:
| 类别 | 作用 |
|---|---|
| .index | 偏移量索引文件,存储数据对应的偏移量 |
| .timestamp | 时间戳索引文件 |
| .log | 日志文件,存储生产者生产的数据 |
| .snaphot | 快照文件 |
| leader-epoch-checkpoint | 保存了每一任leader开始写入消息时的offset,会定时更新。follower被选为leader时会根据这个确定哪些消息可用 |
index 和 log 文件以当前 segment 的第一条消息的 offset 命名。偏移量 offset 是一个 64 位的长整形数,固定是20 位数字,长度未达到,用 0 进行填补,索引文件和日志文件都由此作为文件名命名规则。
从上图可以看出,我们的偏移量是从 0 开始的,.index 和 .log 文件名称都为 00000000000000000000。
在server.properties 文件中会配置日志文件的最大值,当生产者生产数据量较多,一个 segment 存储不下触发分片时,在日志 topic 目录下会看到类似如下所示的文件:
1 | 00000000000000000000.index |
在Kafka中,Topic的同一个 partition 可能会有多个 replication( 对应 server.properties 配置中的 default.replication.factor=N)。
没有 replication 的情况下,一旦 broker 宕机,其上所有 patition 的数据都不可被消费,同时 producer 也不能再将数据存于其上的 patition。比如一个有 3 台 Broker 的 Kafka 集群上的副本分布情况。主题 1 分区 0 的 3 个副本分散在 3 台 Broker 上,其他主题分区的副本也都散落在不同的 Broker 上,从而实现数据冗余。
引入 replication 之后,同一个 partition 可能会有多个 replication,而这时需要在这些 replication 之间选出一 个 leader, producer 和 consumer 只与这个 leader 交互,其它 replication 作为 follower 从 leader 中复制数据。
producer 写入消息流程如下:
producer 采用推(push) 模式将消息发布到 broker,每条消息都被追加(append) 到分区(patition) 中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障 kafka 吞吐率)。
Kafka 的消息组织方式实际上是三级结构:主题 - 分区 - 消息。因此topic下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份。分区提供了系统的负载均衡能力,能够实现系统的高伸缩性,不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。这样,当性能不足的时候可以通过添加新的节点机器来增加整体系统的吞吐量。
producer将消息发送到哪个分区又分区策略决定,kafka为我们提供了默认的分区策略,同时支持用户自定义分区策略,常见的分区策略有:
1. 轮询策略:Round-robin 策略,即顺序分配到每个分区。该策略总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是最常用的分区策略之一。
2. 随机策略-Randomness 策略:随机就是随意地将消息放置到任意一个分区上。
3. 按消息键保序策略- Key ordering 策略:为每条消息定义消息键Key,相同 Key 的所有消息都进入到相同的分区里面。
数据分区投递的基本原则:
当生产者发送多个消息到同一个topic时,为了减少网络带来的开销,kafka会对消息进行批量发送;主要涉及3个参数如下,满足其一即会批量发送:
为保证 producer 发送的数据,能可靠的发送到指定的 topic,topic 的每个 partition 收到 producer 数据后,都需要向 producer 发送 ack ( acknowledgement 确认收到),如果 producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。
Leader将消息写入到本地后,需要确保有 Follower 与 Leader 同步完成后才能发送 ACK,这样才能保证 Leader 挂掉之后,能在 Follower 中选举出新的 Leader 而不丢数据,Follower数据同步发送ACK主要有两种策略:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 半数以上完成同步,就发送ack | 延迟低 | 选举新的 leader 时,容忍n台节点的故障,需要2n+1个副本 |
| 全部完成同步,才发送ack | 选举新的 leader 时,容忍n台节点的故障,需要 n+1 个副本 | 延迟高 |
Kafka 选择了第二种方案,原因如下:
采用第二种方案之后,会出现一个问题:leader 收到数据,所有 follower 都开始同步数据,但此时有一个 follower 故障导致不能与 leader 保持同步,此时leader 得一直等下去,直到它完成同步,才能发送 ack,这会造成消息无法确定。
为了解决此问题,leader 维护了一个动态的 in-sync replica set(ISR),意为和 leader 保持同步的 follower 集合。当 ISR 中的follower 完成数据的同步之后,leader 就会给 follower 发送 ack。如果 follower 长时间未向 leader 同步数据,则该 follower 将会被踢出 ISR,该时间阈值由 replica.lag.time.max.ms 参数设定。leader 发生故障之后,就会从 ISR 中选举新的 leader。
对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等 ISR 中的follower全部接收成功。所以Kafka为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡
0:producer 不等待 broker 的 ack,这一操作提供了一个最低的延迟,broker 一接收到还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据;
1:producer 等待 broker 的 ack,partition 的 leader 落盘成功后返回 ack,如果在 follower 同步成功之前 leader 故障,那么将会丢失数据;
-1(all):producer 等待 broker 的 ack,partition 的 leader 和 follower 全部落盘成功后才返回 ack。但是 如果在 follower 同步完成后,broker 发送 ack 之前,leader 发生故障,那么就会造成数据重复。
由于我们并不能保证 Kafka 集群中每时每刻 follower 的长度都和 leader 一致(即数据同步是有时延的),那么当leader 挂掉选举某个 follower 为新的 leader 的时候(原先挂掉的 leader 恢复了成为了 follower),可能会出现leader 的数据比 follower 还少的情况。为了解决这种数据量不一致带来的混乱情况,Kafka 提出了以下概念:
消费者和 leader 通信时,只能消费 HW 之前的数据,HW 之后的数据对消费者不可见,因此:
所以数据一致性并不能保证数据不丢失或者不重复,这是由 ack 控制的。HW 规则只能保证副本之间的数据一致性!
将服务器的 ACK 级别设置为 -1,可以保证 Producer 到 Server 之间不会丢失数据,即 At Least Once 语义。相对的,将服务器 ACK 级别设置为 0,可以保证生产者每条消息只会被发送一次,即 At Most Once语义。
At Least Once 可以保证数据不丢失,但是不能保证数据不重复。相对的,At Most Once 可以保证数据不重复,但是不能保证数据不丢失。但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义。在 0.11 版本以前的 Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大的影响。
0.11 版本的 Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指 Producer 不论向 Server 发送多少次重复数据。Server 端都会只持久化一条,幂等性结合 At Least Once 语义,就构成了 Kafka 的 Exactily Once 语义,即:At Least Once + 幂等性 = Exactly Once
要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true 即可。Kafka 的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的 Producer 在初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带 Sequence Number。而 Broker 端会对Sequence Number做校验,如果发现PID对应的Sequence Number重复,就会丢弃此消息
但是 PID 重启就会变化,同时不同的 Partition 也具有不同主键,所以幂等性无法保证跨分区会话的 Exactly Once。
Consumer 采用 Pull(拉取)模式从 Broker 中读取数据。Pull 模式则可以根据 Consumer 的消费能力以适当的速率消费消息。Pull 模式不足之处是,如果 Kafka没有数据,消费者可能会陷入循环中,一直返回空数据。
因为消费者从 Broker 主动拉取数据,需要维护一个长轮询,针对这一点, Kafka 的消费者在消费数据时会传入一个时长参数 timeout。如果当前没有数据可供消费,Consumer 会等待一段时间之后再返回,这段时长即为 timeout。
消费者是以 consumer group 消费者组的方式工作,由一个或者多个消费者组成一个组, 共同消费一个 topic。每个分区在同一时间只能由 group 中的一个消费者读取,但是多个 group 可以同时消费这个 partition。
通过消费者组的模式,消费者可以通过水平扩展的方式同时读取大量的消息。另外,如果一个消费者失败了,那么其他的 group 成员会自动负载均衡读取之前失败的消费者读取的分区。
消费者组最为重要的一个功能是实现广播与单播的功能。一个消费者组可以确保其所订阅的 Topic 的每个分区只能被从属于该消费者组中的唯一一个消费者所消费;如果不同的消费者组订阅了同一个 Topic,那么这些消费者组之间是彼此独立的,不会受到相互的干扰。
如果我们希望一条消息可以被多个消费者所消费,那么可以将这些消费者放到不同的消费者组中,这实际上就是广播的效果;如果希望一条消息只能被一个消费者所消费,那么可以将这些消费者放到同一个消费者组中,这实际上就是单播的效果。
一个 consumer group 中有多个 consumer,一个 topic 有多个 partition,所以必然会涉及到 partition 的分配问题,即确定哪个 partition 由哪个 consumer 来消费。
Kafka 有两种分配策略,一是 RoundRobin,一是 Range。
1. RoundRobin:RoundRobin 即轮询的意思,比如现在有一个三个消费者 ConsumerA、ConsumerB 和 ConsumerC 组成的消费者组,同时消费 TopicA 主题消息,TopicA 分为 7 个分区,如果采用 RoundRobin 分配策略,过程如下所示:
2. Range: Range 顾名思义就是按范围划分的意思,Kafka 默认采用 Range 分配策略,
比如现在有一个三个消费者 ConsumerA、ConsumerB 和 ConsumerC 组成的消费者组,同时消费 TopicA 主题消息,TopicA分为7个分区,如果采用 Range 分配策略,过程如下所示:
由于 consumer 在消费过程中可能会出现断电宕机等故障,consumer 恢复后,需要从故障前的位置继续消费,所以 consumer 需要实时记录自己消费到了哪个 offset,以便故障恢复后继续消费。
Kafka 0.9 版本之前,consumer 默认将 offset 保存在 Zookeeper 中,从 0.9 版本开始,consumer 默认将 offset保存在 Kafka 一个内置的 topic 中,该 topic 为 _consumer_offsets。
Kafka 事务基于幂等性实现,通过事务机制,Kafka 可以实现对多个 Topic 、多个 Partition 的原子性的写入,即处于同一个事务内的所有消息,最终结果是要么全部写成功,要么全部写失败。
Kafka 事务分为生产者事务和消费者事务,但它们并不是强绑定的关系,消费者主要依赖自身对事务进行控制,因此这里我们主要讨论的是生产者事务。
为了了实现跨分区跨会话的事务,需要引入一个全局唯一的 TransactionID,并将 Producer 获得的 PID 和Transaction ID 绑定。这样当 Producer 重启后就可以通过正在进行的 TransactionID 获得原来的 PID。
为了管理 Transaction,Kafka 引入了一个新的组件 Transaction Coordinator。Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态。Transaction Coordinator 还负责将事务所有写入 Kafka 的一个内部 Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。
Apache Flink 诞生于柏林工业大学的一个研究性项目,原名 StratoSphere 。2014 年,由 StratoSphere 项目孵化出 Flink,并于同年捐赠 Apache,之后成为 Apache 的顶级项目。
Flink 是一个分布式的流处理框架,它能够对有界和无界的数据流进行高效的处理。Flink 的核心是流处理,当然它也能支持批处理,Flink 将批处理看成是流处理的一种特殊情况,即数据流是有明确界限的。
有界数据集:数据是静止不动的,例如mysql中的数据。对数据的处理不需要考虑追加写入的数据。在某个时间内的结果进行计算,这种计算称之为批计算,批处理。Batch Processing
无界数据集:数据会持续变更,例如实时日志数据。对于此类持续变更、追加的数据的计算方式称之为流计算。Streaming Processing
Flink 核心架构的第二层是 Runtime 层, 该层采用标准的 Master - Slave 结构, 其中,Master 部分又包含了三个核心组件:Dispatcher、ResourceManager 和 JobManager,而 Slave 则主要是 TaskManager 进程。它们的功能分别如下:
上面我们提到:TaskManagers 实际执行的是 SubTask,而不是 Task,这里解释一下两者的区别:
在执行分布式计算时,Flink 将可以链接的操作 (operators) 链接到一起,这就是 Task。之所以这样做, 是为了减少线程间切换和缓冲而导致的开销,在降低延迟的同时可以提高整体的吞吐量。 但不是所有的 operator 都可以被链接,如下 keyBy 等操作会导致网络 shuffle 和重分区,因此其就不能被链接,只能被单独作为一个 Task。 简单来说,一个 Task 就是一个可以链接的最小的操作链 (Operator Chains) 。如下图,source 和 map 算子被链接到一块,因此整个作业就只有三个 Task:
解释完 Task ,我们在解释一下什么是 SubTask,其准确的翻译是: A subtask is one parallel slice of a task,即一个 Task 可以按照其并行度拆分为多个 SubTask。如上图,source & map 具有两个并行度,KeyBy 具有两个并行度,Sink 具有一个并行度,因此整个虽然只有 3 个 Task,但是却有 5 个 SubTask。Jobmanager 负责定义和拆分这些 SubTask,并将其交给 Taskmanagers 来执行,每个 SubTask 都是一个单独的线程。
理解了 SubTasks ,我们再来看看其与 Slots 的对应情况。一种可能的分配情况如下:
这时每个 SubTask 线程运行在一个独立的 TaskSlot, 它们共享所属的 TaskManager 进程的TCP 连接(通过多路复用技术)和心跳信息 (heartbeat messages),从而可以降低整体的性能开销。此时看似是最好的情况,但是每个操作需要的资源都是不尽相同的,这里假设该作业 keyBy 操作所需资源的数量比 Sink 多很多 ,那么此时 Sink 所在 Slot 的资源就没有得到有效的利用。
基于这个原因,Flink 允许多个 subtasks 共享 slots,即使它们是不同 tasks 的 subtasks,但只要它们来自同一个 Job 就可以。假设上面 souce & map 和 keyBy 的并行度调整为 6,而 Slot 的数量不变,此时情况如下:
可以看到一个 Task Slot 中运行了多个 SubTask 子任务,此时每个子任务仍然在一个独立的线程中执行,只不过共享一组 Sot 资源而已。那么 Flink 到底如何确定一个 Job 至少需要多少个 Slot 呢?Flink 对于这个问题的处理很简单,默认情况一个 Job 所需要的 Slot 的数量就等于其 Operation 操作的最高并行度。如下, A,B,D 操作的并行度为 4,而 C,E 操作的并行度为 2,那么此时整个 Job 就需要至少四个 Slots 来完成。通过这个机制,Flink 就可以不必去关心一个 Job 到底会被拆分为多少个 Tasks 和 SubTasks。
Elasticsearch是一个基于Apache Lucene的开源搜索引擎,其依靠Lucene完成索引创建和搜索功能,可以将ElstaicSearch理解为是一个Lucene的分布式封装的搜索引擎。
搜索的核心需求是全文检索,全文检索简单来说就是要在大量文档中找到包含某个单词出现的位置,在传统关系型数据库中,数据检索只能通过 like 来实现,例如需要在手机数据中查询名称包含苹果的手机,需要通过如下 sql 实现:
1 | select * from phone_table where phone_brand like '%苹果手机%'; |
这种实现方式实际会存在很多问题,例如需要全表扫描,性能差,无法得内容与搜索条件的相关性等,为了高效的实现全文检索,我们可以通过倒排索引来解决
倒排索引是区别于正排索引的概念
FST全程Finite State Transducer 全称有穷状态转换器;FST是一种类似于字典的数据结构,但是它是k:v结构的,使用FST有两个优先
O(len(str)) 如果我们要对 cat、 deep、 do、 dog、dogs这五个单词进行插入构建FST(工具演示:http://examples.mikemccandless.com/fst.py?terms=&cmd=Build+it%21),执行过程如下:
插入cat, 每个字母一条边,其中t边指向终点

插入deep, 与前一个单词 cat 进行最大前缀匹配,发现没有匹配则直接插入,P边指向终点

插入do, 与前一个单词deep进行最大前缀匹配,发现是 d,则在d边后增加新边o,o边指向终点

插入dog, 与前一个单词 do 进行最大前缀匹配,发现是 do,则在o边后增加新边g,g边指向终点

插入dogs, 与前一个单词 dog 进行最大前缀匹配,发现是dog,则在g后增加新边s,s边指向终点。

在Lucene中,写入数据的基本单元称之为Document,一个Document是由Field构成,Field是Term的集合,Segment是最小的独立索引单元,由多个Documents构成;在每个Segment范畴内,每个Document都被分配到了一个唯一的数字ID, 称之为DocID, 同时根据每个Field的名字定一个唯一的FieldNaming,对于索引字段进到Postings之前也会被分配一个唯一的TermID。Field除了FieldNaming之外也有一个FieldNumber,与DocID和TermID一样都是一个自增的数值。
倒排索引需要将 terms 映射到包含该单词 (term) 的文档列表,这样的映射列表我们称之为:倒排列表(postings list)。具体某一条映射数据称之为:倒排索引项(Posting)。
根据倒排索引的概念,最简单的我们可以用一个Map来描述这个结构, Map 的 Key 存储分词后的单词(也叫Term),Map的Value存储一些列的文档ID的集合,但是全文搜索引擎在海量数据的情况下需要存储大量的文本,如果是用Map存储Directory的话有这样几个明显的缺点:
因此上面说的基于 Map 的实现方式几乎是不可行的。在海量数据背景下,倒排索引的实现直接关系到存储成本以及搜索性能,为此,Lucene 引入了多种巧妙的数据结构和算法,同时也把倒排索引的结构组织成如下图所示:
.tip.doc、.pay、.pox,分别记录Postings的DocId信息和Term的词频、Payload信息、pox是记录位置信息.tim,它是Term与Postings的关系纽带,存储了Term和其对应的Postings文件指针。通过Terms Index(.tip) 能够快速地在Terms Dictionary(.tim) 中找到你的想要的Term,以及它对应的Postings文件指针与Term在Segment作用域上的统计信息。
postings: 实际上Postings包含的东西并不仅仅是DocIDs(我们通常把这一个有序文档编号系列叫DocIDs),它还包括文档编号、以及词频、Term在文档中的位置信息、还有Payload数据。
Terms Index存储在.tip文件中,实际张是有一个或者多个FST组成的,Segment上每个字段都有自己的一个FST(FST Index)记录在.tip上,所以图中FST Index的个数即是Segment拥有字段的个数。
Term Directory存储了Index field 经过去重、时态统一、大小写统一、近义词处理等操作后的词项数据,最后存储在.tim文件中(也就是Term Directory 存储所有的Term数据),同时它也是 Term 与 Postings 的关系纽带,存储了每个 Term 和其对应的 Postings 文件位置指针。通过Term Directory可以知道Term的统计信息,包括Term在Segment中出现的频率等。
Terms Dictionary 内部采用 NodeBlock 这种结构对 Term 进行压缩存储,处理过程会将相同前缀的 Term 压缩为一个 NodeBlock,然后将每个 Term 的后缀以及对应 Term 的 Posting 关联信息处理为一个 Entry 保存到 Block,如下图所示:
PostingList 包含文档 id、词频、位置等多个信息,这些数据之间本身是相对独立的,因此 Lucene 将 Postings List 拆成三个文件存储:
这个做法有没有点列式存储的味道?其好处也很明显:一来可以提高读取效率,因为基本所有的查询都会用 .doc 文件获取文档 id,但一般的查询仅需要用到 .doc 文件就足够了,只有对于近似查询等位置相关的查询才需要用位置相关数据, 二来这样存储数据很方便对数据进行压缩
三个文件整体实现差不太多,这里仅介绍.doc 文件,.doc 文件存储的是每个 Term 对应的文档 Id 和词频。每个 Term 都包含一对 TermFreqs 和 SkipData 结构,其中 TermFreqs 存放 docId 和词频信息,SkipData 为跳表结构,用于实现 TermFreqs 内部的快速跳转
Posting List 采用多个文件进行存储,最终我们可以得到每个 Term 的如下信息:
为了压缩Posting List的存储空间,需要对Posting List进行压缩,Lucene采用的压缩算法是: FOR + RBM
lucene的搜索过程如下:
在项目开发中,正对项目的测试分为多个分类,比如单元测试、集成测试、端到端测试等,按照Mike Cohn提出的“测试金字塔”概念,测试分为4个层次:
最下面的是单元测试,单元测试针对的是代码进行测试,针对的是局部代码功能;再而之上是集成测试,它针对的是服务的接口进行测试;接着网上是端到端的测试,也就是我们所说的链路测试,它针对的是服务的功能进行测试,负责从一个链路的入口输入测试用例,验证输出的系统的结果;再上一层是我们最常用的UI测试,就是测试人员在UI界面上根据功能进行点击测试。
测试金字塔建议我们:
(1)尽可能地多做单元测试 和 集成测试,因为他们的执行速度相较于上层的几个测试类型来说快很多且相对稳定,可以一天多次执行。
(2)尽可能地少做 组件测试、端到端测试 和 探索性测试,因为他们的执行速度相较单元测试 和 集成测试 会慢很多,且不够稳定,无法做到一天多次执行,每次执行都要等很久才能获得反馈结果。
画外音:金字塔里,越往下速度越快且越稳定,那么就可以频繁执行,反正执行一次也花不了多久时间,开发人员还可以知道我的代码有没有影响到其他模块。越往上则速度越慢且越不稳定,跑一次要N久,开发人员往往会觉得还是先继续开发吧,到时候出了bug再说,我可不想加班等测试结果。
单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、超类、抽象类等中的方法。单元测试就是软件开发中对最小单位进行正确性检验的测试工作。
不同地方对单元测试有的定义可能会有所不同,但有一些基本共识:
golang自带的testing包已经可以完美支持单元测试,我们在写一个文件,函数的时候,可以直接在需要单元测试的文件旁边增加一个_test.go的文件。而后直接使用 go test 直接跑测试用例就可以了。但是因为单元测试是与环境无关的,因此在编写测试用例时总是需要把一些第三发依赖给mock掉,因此需要借助其他的测试组件来完成,下面是一些常见的mock框架:
go monkey 可以动态的执行打桩,目标是让用户在单元测试中低成本的完成打桩,从而将精力聚焦于业务功能的开发。gomonkey支持多种打桩方式,可以无入侵的实现打桩,但是需要注意的是:
运行 monkey需要关闭 Go 语言的内联优化才能生效,也就是编译时需要添加 -gcflags=all=-l 参数才行
注意:
- 如果是 M1 系统,编译需要指定 goarch 为 amd64
@MAC-M1# GOARCH=amd64 go build -gcflags=all=-l -o xxx main.go
@MAC-INTEL# go build -gcflags=all=-l -o xxx main.go
解决的问题:monkey mac m1 execute syscall.Mprotect error:panic: permission denied [recovered]
ref: https://github.com/agiledragon/gomonkey/issues/57
ref: https://blog.csdn.net/qw790707988/article/details/119710144- monkey不应该用于生产系统, 否则可能引发未知事故
- monkey 需要在运行的时候修改内存代码段,因而无法在一些对安全性要求比较高的系统上工作
go开发者一般使用Ginkgo + Gomega 实现集成测试与E2E测试。Ginkgo /ˈɡɪŋkoʊ / 是Go语言的一个行为驱动开发(BDD, Behavior-Driven Development)风格的测试框架,通常和库Gomega一起使用。Ginkgo在一系列的“Specs”中描述期望的程序行为。
inkgo是Go语言的一个行为驱动开发(BDD, Behavior-Driven Development)风格的测试框架,通常和库Gomega一起使用。Ginkgo在一系列的“Specs”中描述期望的程序行为。
1 | go get -u github.com/onsi/ginkgo/ginkgo |
1 | # 断言 |
etcd是使用Go语言开发的一个开源的、高可用的分布式key-value存储系统,可以用于配置共享和服务的注册和发现。
类似项目有zookeeper和consul。
etcd具有以下特点:
etcd支持单机模式,以及使用集群模式部署,支持部署在各种操作系统中。
mac安装etcd整体上来说非常简单,只需要一个命令即可(linux其实也是一个命令即可)
1 | brew install etcd |
1 | NAME: |
etcdctl 命令分为几大模块,分别是跟etcd集群现网的命令例如member, endpoint, 第二个是租约有关的lease(类似ttl), 第三个是数据相关的,包括watch、put、get等,第四个是跟角色认证相关的信息,常见的命令如下:
1 | # -w 参数用于输出执行的格式,只是fields, json, protobuf, sample, table 默认sample |
1 | package main |
etcd 是一个基于 Raft 共识算法实现的分布式键值存储服务,在项目结构上采用了模块化设计,其中最主要的三个部分是实现分布式共识的 Raft 模块、实现数据持久化的 WAL 模块和实现状态机存储的 MVCC 模块。
Raft 是一种用来管理日志复制过程的算法,Raft 通过『领导选举机制』选举出一个 Leader,由它全权管理日志复制来实现一致性。一个 Raft 集群包含若干个服务器节点,每一个节点都有一个唯一标识 ID。Raft 算法论文规定了三种节点身份:Leader、Follower 和 Candidate,etcd 的实现中又添加了 PreCandidate 和 Learner 这两种身份。
preVote状态(切换为 PreCandidate 身份),在它准备发起一次选举之前,需要尝试连接集群中的其他节点,并询问它们是否愿意参与选举近期,有同学反馈,所有调用的微博图床图片都无法加载并提示“403 Forbidden”了。百度上说新浪开启了防盗链,查遍了网上一堆复制/粘贴出来的文章,不是开启反向代理就是更改请求头,但是这些方案都不行。最终方案只能自己自建图床(其实自建图床的成本并不高,只是自己懒得搞,注册了个七牛云的对象存储,但是需要绑定域名,所以上次只能作罢,这次就使用腾讯COS作为图床吧~),为了批量把文档中的新浪图片替换成腾讯cos,也是花了丢时间写了个脚本用于批量替换。
1 | package main |
quic草案的阅读总是晦涩难懂的,如果没有多读几遍,压根就不懂这说的是啥意思,阅读草案之前建议读者先了解相关知识,带着相关知识去阅读更易理解。
连接用途在客户端和服务器之间建立连接。和tcp不同的是(tcp是通过连接四元组[client ip、client port、server ip、server port]来确定一个连接),quic是通过连接id(connectionId)来标识一个连接。
stream是一个抽象的概念,它表达了一个有序传输的字节流,而这些字节其实就是由Stream Frame(一系列的帧)排在一起构成。在一个quic connection上,可以同时传输多条流。
流可以是单向的或双向的,单向流只能往一个方向传输数据,双向流允许双端向对端发送数据。
在连接中,通过StreamID来标志一个流,通过流ID的最小有效位标志流的发起者,流ID的次小有效位标志流的类型。
流帧封装应用层发送的数据。终端使用流帧的流ID及偏移字段整理数据并将流数据以一个有序字节流传递给应用层,终端可以从一条流的同一个偏移位置多次接收数据,如果数据已经被接收过,则直接丢弃此数据。
流有两种状态,分为发送流和接收流
在流的发送部分,应用层协议可以:
在流的接收部分,应用层协议可以:
在对QUIC数据包进行解密且去除掉header后,packet的荷载里都是frame(至少包括1个)。
如果packet的荷载里,不包括ACK, PADDING, CONNECTION_CLOSE这种三种类型的帧,那么这个packet则被定义为ack确认帧,意味着对端必须对这种packet生成相应的ack通知发送方,以确保数据没有丢失。
packet的荷载里frames的类型在多达30种类型,每种类型都有自己的应用场景,如ACK Frame用于可靠传输(Recovery),Crypto用于安全传输(TLS握手),Stream Frame用于业务数据传递,MAX_DATA/DATA_BLOCKED用于流控,PING Frame可以用于mtu探测,具体参考(https://autumnquiche.github.io/RFC9000_Chinese_Translation/#19_Frame_Types_and_Formats)
一个UDP报文包含一个或者多个数据包,QUIC定义了两类数据包头,长包头和短包头。区分长包头还是短包头主要是根据第一个字节的最高位来区分。
除了1-RTT属于短包头外,其余的数据包都属于长包头(initial包,0-RTT包,handshake包,重试数据包都属于长包头),长包头被用于在1-RTT密钥建立前发送的数据包。一旦有了1-RTT密钥,发送方就会改用短包头发送数据包
地址校验主要是用于确保端点不会被用于流量放大攻击(traffic amplification attack)。攻击者如果伪造数据包的源地址为受害者的地址,发送大量的数据包给服务端,如果服务端没有进行地址验证,直接响应大量数据包给源地址(受害者),就会被攻击者利用、进行流量放大攻击。
QUIC 针对放大攻击的主要防御措施是验证端点是否能够在其声明的传输地址接收数据包。地址验证在连接建立(connection establishment)期间和连接迁移(connection migration)期间进行。
连接建立时,为了验证客户端的地址是否是攻击者伪造的,服务端会生成一个令牌(token)并通过重试包(Retry packet)响应给客户端。客户端需要在后续的初始包(Initial packet)带上这个令牌,以便服务端进行地址验证。
服务端可以在当前连接中通过 NEW_TOKEN 帧预先发布令牌,以便客户端在后续的新连接使用,这是 QUIC 实现 0-RTT 很重要的一个功能。
重试数据包(Retry packet)中提供的令牌只能立即使用,不能用于后续连接的地址验证。而 NEW_TOKEN 帧生成的令牌可以在一个时间范围内使用,这个令牌应该有一个过期时间,可以是显式的过期时间,也可以是可用于动态计算过期时间的时间戳(timestamp)。服务端可以存储过期时间,也可以在令牌中以加密的形式包含它。
需要注意的是:
在验证客户端的地址之前,服务端发送的字节数不能超过它接收到的字节数的三倍,用于避免攻击者在地址验证之前进行放大攻击。
客户端必须确保初始数据包(Initial packets)的大小至少1200 字节,如果少于 1200 字节则可以添加 PADDING 帧填充。
如果客户端没有收到来自服务端发送的初始数据包或者握手包,而且客户端没有发送额外的初始包或者握手包,那么这会引发死锁。因此为了防止死锁,客户端必须在探测超时(PTO)时(重新)发送数据包,如果客户端没有握手秘钥,那么他必须(重新)发送初始化数据包,如果有握手秘钥,那么它应该发送一个握手数据包
路径验证用于(端点)连接迁移时校验更新后路径是否可达,在路径验证中,端点会校验本地地址和对端地址间的可达性(这里说的地址是指IP+端口组成的二元组)
路径验证测试是在一条路径上发送给对端的数据包有没有被对端收到,使用地址验证用于确保端点从迁移方收到的数据包不携带伪造的源地址。
端点通过发送一个PATH_CHALLENGE帧用于启动路径验证,PATH_ChALLENGE帧中的必须包含一个不可预测的 payload,以便它可以将对端响应的PATH_CHALLENGE帧关联起来。
端点可以发送多个 PATH_CHALLENGE 帧以防止数据包丢失。但是不应该在同一个数据包(packet)中发送多个 PATH_CHALLENGE 帧,而是要分别在不同的数据包(packet)中发送。
端点必须将包含PATH_CHALLENGE帧的数据包扩展到至少1200字节(如果不足1200字节需要使用PADDING帧填充)
端点不应该以高于初始化数据包(Initial包)的频率发送PATH_CHALLENGE帧,以确保连接迁移不会比建立新连接带来更多的载荷
端点在接收到 PATH_CHALLENGE 帧时,必须通过 PATH_RESPONSE 帧响应,PATH_RESPONSE帧的payload跟PATH_CHALLENGE帧一致。除非受到拥塞控制(congestion control)的限制,否则端点不得延迟传输包含 PATH_RESPONSE 帧的数据包。
PATH_RESPONSE 帧必须在接收到 PATH_CHALLENGE 的那条路径上发送。
端点(也)必须将包含 PATH_RESPONSE 帧的数据报扩展到 至少1200 字节。
端点不能发送多个 PATH_RESPONSE 帧来响应一个 PATH_CHALLENGE 帧。
QUIC标准化的过程中,发布了多个版本的草案,市面上的QUIC协议实现可能基于不同的版本(daft-29,daft-30),这意味着客户端跟服务端支持的quic协议版本不一样,因此在建立连接时需要先进行版本协商,使用双发都支持的一个版本。
客户端发送的第一个包(Initial包)决定服务端是否发送版本协商包,客户端和服务端创建连接时,客户端在首次发起请求时需要带上它支持的协议版本号。
需要注意的是:
客户端收到版本协商包后,从服务端所支持的版本集合里面挑选它所支持的版本。
如果客户端已接收并成功处理了任何其他包(包括早期的版本协商包),则客户端必须丢弃它后来新收到的版本协商包。
关于QUIC的版本:
QUIC 版本使用 32 位无符号数字标识,版本号 0x00000000 被保留用来表示版本协商。版本号 0x00000001 作为 RFC 发布的协议版本
0x?a?a?a?a 格式的版本号被保留(reserved)用于强制执行版本协商
注意事项:
Packet Number 为整型变量,其值在 0 到 2^62-1 之间,它也用于生成数据包加密所需的 nonce。通讯双方维护各自的 Packet Number 体系, 并且分为三个独立的上下文空间:
所谓的 Packet Number 空间,指得是一种上下文关系,在这个上下文关系里,数据包被处理, 被确认。数据包在不同的Packet Number Namespace有不同的加密等级,初始数据包只能使用初始数据包专用的密钥,也只能确认初始数据包。类似的, 握手包只能使用握手包专用的密钥,也只能确认握手数据包。
从 Initial 阶段进入 Handshake 阶段后, Initial 阶段使用的密钥就可以被丢弃了,0-RTT 和 1-RTT 共享同一个 Packet Number 空间,这样做是为了更容易实现这两类数据包的丢包处理算法。
在同一连接同一个 Packet Number 空间里,你不能复用包号,包号必须是单调递增的,当然,具体实现的时候草案并不强制要求每次都递增1, 你可以递增 20,30。当 Packet Number 达到 2^62 -1 时,发送方必须关闭该连接。
需要注意的是:在特定的包号空间里,有些帧是被禁止使用的(https://autumnquiche.github.io/RFC9000_Chinese_Translation/#12.4_Frames_and_Frame_Types)
0x1c)的连接关闭帧可以出现在任何数据包号空间中。标志着应用错误(类型为0x1d)的连接关闭帧必须只能出现在应用数据空间中。
在连接建立期间,双端会对各自的传输参数作出验证声明,传输参数通过在quic_transport_parameters字段中定义,传输参数包括最大超时时间(max_idle_timeout), 无状态重置令牌(stateless_reset_token), 最大udp有效载荷(max_udp_payload_size),初始最大数据量(initial_max_data)等,具体参数参考(https://autumnquiche.github.io/RFC9000_Chinese_Translation/#18_Transport_Parameter_Encoding)
同一个传输参数在特定的传输扩展中不能声明多次,一旦握手完成,由对端声明的传输参数就生效了
开启0-RTT,终端需要保存服务端传输参数的值及在连接上收到的任何会话票据(session ticket),终端也需要保存其他任何应用协议或加密握手所需要的信息。但是需要注意的是不是所有的传输参数都需要保存(因为部分参数不会在0-RTT建连期间起作用), ack_delay_exponent、max_ack_delay、initial_source_connection_id等这些个参数的值不能被保存。
如果终端不支持某一些传输参数,那么必须忽略他(例如B定义了一个传输参数x, A不支持此参数,那么需要忽略他)
当客户端发送初始包时,会生成不可预测值填充发送的初始包的目标连接ID字段。目标连接ID的长度必须至少8字节。客户端在一条连接上必须使用同一个目标连接ID,直到收到服务端发来的数据包为止。
客户端发送的首个初始数据包的目标连接ID字段用于确定初始数据包的包保护密钥。这些密钥在收到重试数据包后变更。(初始秘钥值是通过对值为0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a的盐和值为目标连接ID进行HKDF算法而得出)
在首次收到从服务端发来的初始数据包或重试数据包后,客户端将服务的提供的源连接ID作为后续发送数据包的目标连接ID,包括任何0-RTT包。这意味着客户端可能需要在连接建立阶段将目标连接ID字段的值变更两次:一次是响应服务端发来的重试数据包,一次是响应服务端发来的初始数据包
TCP的连接标识是通过“源ip+源端口+目标ip+目标端口+协议”唯一五元组构成,当其中的任何一个变量改变,都会造成tcp的重新建连。而quic也有唯一标识,他是通过一个64位的Connection ID表示,当用户在WIFI和移动网络发生网络切换时,用户的IP和Port可能会发生改变,但是quic的Connection ID不会发生改变,因此无需重新建立连接,这种用户无感知的网络切换,叫做连接迁移。
探测帧(probing frames):PATH_CHALLENGE、PATH_RESPONSE、NEW_CONNECTION_ID、PADDING,发起的包是探测包(probing packet)
非探测帧(non-probing frames):除探测帧之外的是非探测帧,发起的包是非探测包(non-probing packet)
在发起连接之前,客户端会发起探测帧,校验新地址到对端是否可达,也就是路径验证,校验不通过表示该条路径不通,但不会关闭连接。
客户端发起非探测帧数据包给对端,从而实现连接迁移。新路径没有老路径的发送速率,所以新路径会重置拥塞控制和RTT相关配置。
当对端收到非探测帧数据包时,意味着连接迁移成功。
共有三种方法终止一个已建立的quic连接:
每个终端会在传输参数中指定的最大空闲超时时间(max_idle_timeout),如果某个连接持续空闲时间超过了两个终端宣告的max_idle_timeout中较小值,那么这个连接就会被静默关闭,并且它的状态会被丢弃。
当终端收到并且处理了一个来自对端的数据包时,终端需要重置它的空闲计时器。当正要发送ACK触发包时,如果自上一次接收并处理数据包后还没有发送过任何ACK触发包,那么终端也会重启它的空闲计时器。
如果终端正在等待响应数据但是没有或无法发送应用数据,那么终端可能需要发送ACK触发包以避免空闲超时。终端可以定期发送一个Ping帧,使得对端重置自己的空闲超时定时器。
终端可以发送连接关闭帧(CONNECTION_CLOSE)来终止连接,连接关闭帧会使所有的流都被立即关闭,当终端发起立即关闭帧后进入关闭状态,当终端收到连接关闭帧后立即进入排空状态(排空状态许多方面都跟关闭状态一致,但是处于排空状态的终端必须不发送任何数据包。一旦连接处于排空状态,就没有必要再保留数据包保护密钥了)
终端在收到连接关闭帧后在进入排空状态之前可以发送一个包含连接关闭帧的数据包。
处于关闭状态的连接在收到连接关闭帧后可以转化为排空状态
崩溃或中断可能造成对端持续向一个没有正常地维持连接的终端发送数据,终端可以在接收到一个它无法关联到某个活跃连接的数据包时发送无状态重置作为响应。
无状态重置不适合用来表明在活跃连接中出现的错误。想要传达致命的连接错误这一消息的终端在有能力的情况下必须使用连接关闭帧。
为了支持无状态重置,终端会签发一个无状态重置令牌,它是一个难以猜测的16字节长的值。如果对端后续收到了无状态重置,也就是一个以那个无状态重置令牌结尾的UDP数据报,那么对端将立即结束这条连接。
QUIC传输协议[RFC9000]为传输可靠的应用数据流提供了一个安全的、多路复用的连接。QUIC使用携带了多种类型帧的数据包传输数据,需要可靠传输的应用数据流使用 STREAM 帧发送。但是有些应用,尤其是需要传输实时数据的应用,更倾向于使用不可靠传输,QUIC为了实现不可靠传输,通过定义新的Datagram帧类型。
QUIC在握手期间可以用传输参数(name=max_datagram_frame_size,value=0x20)来通告对端是否支持datagram帧,默认值为0表示不支持DATAGRAM帧,大于 0 的值表示端点支持 DATAGRAM 帧类型并且告诉对端自己可以接收datagram帧的最大长度。
端点在握手期间(如果使用0-RTT,则是上一次握手期间),在未收到具有非零值的 max_datagram_frame_size 传输参数之前,不得发送 DATAGRAM 帧, 端点不得发送大于对端通告的 max_datagram_frame_size 长度的 DATAGRAM 帧,如果未收到是否支持datagram帧的通道而收到datagram帧,那么需要以PROTOCOL_VIOLATION类型错误而终止。
max_datagram_frame_size 传输参数可以是单向的,也就是可以单端使用。
当应用在QUIC连接上发送数据报时,QUIC将生成一个新的 DATAGRAM 帧并在第一个可用数据包中发送。该帧应该尽快投递并且可以与其他帧合并。当 QUIC 端点接收到一个有效的 DATAGRAM 帧时,应该立即传递给应用。
与 STREAM 帧一样,DATAGRAM 帧包含应用数据,并且必须使用 0-RTT 或 1-RTT 密钥进行保护。
虽然 DATAGRAM 帧在丢包检测时不会重传,但它们也是 ACK 触发帧,所以接收方应该支持延迟发送 ACK 帧以对接收到仅包含 DATAGRAM 帧的数据包做出响应,因为即使这些包短期内未被确认,发送方也不会采取任何行动。
与任何 ACK 触发帧一样,当发送方怀疑仅包含 DATAGRAM 帧的数据包丢失时,它会发送探测包以引发更快的 ACK 确认。如果发送方检测到包含特定 DATAGRAM 帧的数据包可能已经丢失,则QUIC实现可以通知应用它认为数据报已丢失了。
如果包含 DATAGRAM 帧的数据包被确认,则QUIC实现可以通知发送方,应用数据报已被成功发送和接收,需要注意的是,对 DATAGRAM 帧的确认仅表明接收方的传输层接收并处理了该帧,并不保证接收方的应用层成功处理了该数据。
DATAGRAM帧没有明确的流控信号且DATAGRAM帧不影响其他流的流量控制(也就是DATAGRAM帧不在stream的流量控制范围内)。
DATAGRAM 帧的拥塞控制属于连接级别,QUIC实现可以选择让应用指定一个发送过期时间,超过该时间,受拥塞控制的 DATAGRAM 帧应该被丢弃不传输。
当协商多路径选项时,想要使用附加路径的客户端必须首先使用PATH_CHALLENGE 和 PATH_RESPONSE 帧启动地址验证过程。在新路径上接收到来自客户端的数据包后,如果服务器决定使用新路径,则服务器必须执行路径验证,除非它之前已经验证了该地址。
如果验证成功,客户端可以在新路径上发送非探测的 1-RTT 数据包。服务端收到新路径上的非探测1-RTT数据包表示新路径可以使用,但是不能表示连接迁移到新路径。
每个端点可以管理一组路径,如果端点想要关闭指定路径,应该通过PATH_ABANDON帧来终止路径,一旦路径被标记为“已放弃”,端点可以释放与该路径相关的资源,例如已使用的连接ID。
当包含 PATH_ABANDON 帧的数据包对端被确认时,发送 PATH_ABANDON 帧的端点应该认为一条路径已被放弃。当释放该路径的资源时,端点应该为路径上使用的连接 ID 发送一个 RETIRE_CONNECTION_ID 帧,如果有的话。
PATH_ABANDON 帧的接收者不应立即释放其资源,而应等待接收到已使用的连接 ID的RETIRE_CONNECTION_ID 帧或 3 个 RTO 的 RETIRE_CONNECTION_ID 帧。
PATH_ABANDON 帧向接收对等方指示发送方不再打算在该路径上发送任何数据包,PATH_ABANDON 帧的接收者也可以发送一个 PATH_ABANDON 帧来表示它自己愿意不再在这条路径上发送任何数据包。
PATH_ABANDON 帧可以在任何路径上发送,而不仅仅是在要关闭的路径上发送。如果在废弃路径上发送并被认为丢失的可重传帧应该在其他路径上重传。
QUIC中如果只有一条路径存活并且服务端收到了PATH_ABANDON帧,那么服务端应该发送CONNECTION_CLOSE 帧并进入关闭状态,如果客户端在唯一一条存活路径上收到PATH_ABANDON 帧,它可能会尝试打开新路径(如果可用),并且仅在路径验证失败或从服务器接收到 CONNECTION_CLOSE 帧时才启动连接关闭。
在路径验证过程中,端点可以通过不发送PATH_RESPONSE帧来拒绝对等方发起的新路径建立。
端点使用 PATH_STATUS 帧来通知对等方应该按照这些帧表达的偏好发送数据包。需要注意的是,端点可能不遵循对等方的通告
PATH_STATUS 帧描述了路径的两种状态:
端点使用 PATH_STATUS 帧中的路径标识符字段来标识哪个路径的状态将被更改。PATH_STATUS 帧可以通过不同的路径发送。如果端点收到的路径状态帧会使所有路径都不可用,那么端点可以忽略PATH_STATUS帧。
PATH_STATUS利用PATH_ID来标识它希望改变哪个路径的状态。另外利用path-status-seq-num来标记PATH_STATUS的有效性:
负载均衡(Load Balance)的职责是将网络请求,或者其他形式的负载“均摊”到不同的机器上,让每台服务器获取到适合自己处理能力的负载。在为高负载服务器分流的同时,还可以避免资源浪费。负载均衡的原理就是当用户的请求到达前端负载均衡器(Director Server)时,通过设置好的调度算法,智能均衡的将请求分发到后端真正服务器上(Real Server)。根据请求类型的不同可以将负载均衡分为四层负载均衡(L4)和七层负载均衡(L7), 常见的负载均衡器包括LVS,Nginx, HAProxy等。
LVS是Linux Virtual Server的简称, 也就是 Linux 虚拟服务器,工作在 OSI 模型的传输层,即四层负载均衡。LVS主要由两部分组成,包括ipvs和ipvsadm。
LVS的底层是利用NETIFILTER的钩子能力:
前言说到负载均衡器原理是根据负载均衡算法把请求转发到后端真实服务器上。LVS作为四层负载均衡器,针对不同的网络服务需求和服务器配置,LVS调度器实现了多种负载均衡调度算法,主要包括如下:
轮询调度 rr(Round Robin):这种算法是最简单的,就是按依次循环的方式将请求调度到不同的服务器上,该算法最大的特点就是简单。轮询算法假设所有的服务器处理请求的能力都是一样的,调度器会将所有的请求平均分配给每个真实服务器,不管后端 RS 配置和处理能力,非常均衡地分发下去。
加权轮叫 wrr(Weighted Round Robin):这种算法比 rr 的算法多了一个权重的概念,可以给 RS 设置权重,权重越高,那么分发的请求数越多,权重的取值范围 0 – 100。主要是对 rr 算法的一种优化和补充,LVS 会考虑每台服务器的性能,并给每台服务器添加要给权值,如果服务器 A 的权值为 1,服务器 B 的权值为 2,则调度到服务器 B 的请求会是服务器 A 的 2 倍。权值越高的服务器,处理的请求越多。
最少链接 lc(Least Connections):这个算法会根据后端 RS 的连接数来决定把请求分发给谁,比如 RS1 连接数比 RS2 连接数少,那么请求就优先发给 RS1。
加权最少链接 wlc(Weighted Least Connections):这个算法比最少链接 lc算法多了一个权重的概念。
基于局部性的最少链接 lblc(Locality-Based Least Connections):这个算法是针对目标 IP 地址的负载均衡,目前主要用于 Cache 集群系统。该算法根据请求的目标 IP 地址找出该目标 IP 地址最近使用的服务器,若该服务器 是可用的且没有超载,将请求发送到该服务器;若服务器不存在,或者该服务器超载且有服务器处于一半的工作负载,则用 “最少链接” 的原则选出一个可用的服务 器,将请求发送到该服务器。
复杂的基于局部性最少的连接算法 lblcr(Locality-Based Least Connections with Replication)*:这个算法也是针对目标 IP 地址的负载均衡,目前主要用于 Cache 集群系统。它与 LBLC 算法的不同之处是它要维护从一个 目标 IP 地址到一组服务器的映射,而 LBLC 算法维护从一个目标 IP 地址到一台服务器的映射。该算法根据请求的目标 IP 地址找出该目标 IP 地址对应的服务 器组,按 “最小连接” 原则从服务器组中选出一台服务器,若服务器没有超载,将请求发送到该服务器,若服务器超载;则按 “最小连接” 原则从这个集群中选出一 台服务器,将该服务器加入到服务器组中,将请求发送到该服务器。同时,当该服务器组有一段时间没有被修改,将最忙的服务器从服务器组中删除,以降低复制的 程度。
目标地址散列 dh(Destination Hashing):这个算法根据请求的目标 IP 地址,作为散列键(Hash Key)从静态分配的散列表找出对应的服务器,若该服务器是可用的且未超载,将请求发送到该服务器,否则返回空。
源地址散列 sh(Source Hashing):这个调度算法根据请求的源 IP 地址,作为散列键(Hash Key)从静态分配的散列表找出对应的服务器,若该服务器是可用的且未超载,将请求发送到该服务器,否则返回空。
根据负载均衡器对数据包的处理方式分类,LVS支持三种工作模式,分别为NAT模式,DR模式以及TUN模式。原生的LVS工作模式不支持FULLNAT,FULLNAT模式需要自己重新编译LVS。在介绍LVS工作模式之前有必要先解释下相关名词:
一、使用Docker模拟(适用于没虚拟机的情况)
1 | 1. 起两个nginx容器,分别是 |
二、使用虚拟机模拟
1 | 前置条件: |
DR(Direct Routing 直接路由模式)模式时 LVS 调度器只接收客户发来的请求并将请求转发给后端服务器,后端服务器处理请求后直接把内容直接响应给客户,而不用再次经过 LVS 调度器。LVS 只需要将网络帧的 MAC 地址修改为某一台后端服务器 RS 的 MAC,该包就会被转发到相应的 RS 处理,注意此时的源 IP 和目标 IP 都没变。RS 收到 LVS 转发来的包时,链路层发现 MAC 是自己的,到上面的网络层,发现 IP 也是自己的,于是这个包被合法地接受,RS 感知不到前面有 LVS 的存在。而当 RS 返回响应时,只要直接向源 IP(即用户的 IP)返回即可,不再经过 LVS。
为了解决保证前端路由将目标地址为 VIP 报文统统发给 Director Server,而不是 RS。的问题一般有如下解决方案:
arp_ignore 和 arp_announce)将 RS 上的 VIP 配置在 lo 接口的别名上,并限制其不能响应对 VIP 地址解析请求。arp_ignore:定义接收 ARP 请求时的响应级别
1 | arp_ignore - INTEGER |
arp_announce:定义将自己地址向外通告时的通告级别
1 | arp_announce - INTEGER |
1 | 前置条件: |
TUN工作在三层,TAP工作在二层
1 | 前置条件: |
LVS NAT, DR模式中,RS跟DS必须在同一个VLAN中,当集群规模较小时,使用 NAT、DR 模式都是没有问题的,当集群内有几十台以上时,那么这些服务器通常都不在同一个 VLAN/网段 内了。这时,必须再研发出一种能支持跨 VLAN/网段 通信的模式,FULLNAT 模式就是为了解决这个问题而生的。
LVS FullNAT 模式几乎和 LVS NAT 模式相同,不同之处即是:引入 Local Address(内网 IP 地址)。CIP->VIP 转换换为 LIP->RIP,而 LIP 和 RIP 均为 IDC 内网 IP,因此可以跨 VLAN 通讯。LVS原生模式不支持FULLNAT,因此需要自己手动编译内核~
注意:内核编译的内核包需要跟自己的操作系统内核版本对应上(大版本对应上~),暂时未找到centos7对应的内核包在哪儿。
本机的内核版本为:2.6.32-220
1 | yum -y install elfutils-devel.x86_64 audit-libs-devel.x86_64 |
1 | 内核包 |
1 | rpm -ivh kernel-2.6.32-220.23.1.el6.src.rpm |
4. 打 LVS补丁1 | tar -zxvf Lvs-fullnat-synproxy.tar.gz |
1 | 最好不要重新修改内核版本(遇到过改了内核版本后,编译没问题,重启后触发Kernel panic – not syncing: Attempted to kill init的问题) |
1 | reboot |
1 | cd ~/lvs-fullnat-synproxy && tar -zxvf lvs-tools.tar.gz |
1 | 输入 ipvsadm -h 可以看到有fullnat 模式 |
1 | 虚拟机4的IP地址为192.168.58.100, 虚拟机2,3的IP地址为192.168.56.102, 192.168.56.104, 在虚拟机上执行: |
Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.
1 | $ hexo new "My New Post" |
More info: Writing
1 | $ hexo server |
More info: Server
1 | $ hexo generate |
More info: Generating
1 | $ hexo deploy |
More info: Deployment
]]>Go 语言作为一个原生支持用户态进程(Goroutine)的语言,当提到并发编程、多线程编程时,往往都离不开锁这一概念。锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。go语言在Sync包中提供了用于同步的一些基本原语,包括常见的sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 和 sync.Cond:
悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,不管读还是写,悲观锁在执行操作之前都先上锁。
对读对写都需要加锁导致性能低,所以悲观锁用的机会不多。但是在多写的情况下,还是有机会使用悲观锁的,因为乐观锁遇到写不一致的情况下会一直重试,会浪费更多的时间。
乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过。乐观锁的实现方案主要包含CAS和版本号机制。乐观锁适用于多读的场景,可以提高吞吐量。
CAS即Compare And Swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。CAS涉及三个关系:指向内存一块区域的指针V、旧值A和将要写入的新值B。CAS实现的乐观锁会带来ABA问题,同时整个乐观锁在遇到数据不一致的情况下会触发等待、重试机制,这对性能的影响较大。
版本号机制是通过一个版本号version来实现版本控制。
之前介绍的CAS就是自旋锁的一种。同一时刻只能有一个线程获取到锁,没有获取到锁的线程通常有两种处理方式:
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
但是如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被OS调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。
Golang的Mutex其实是在不断改进的,到目前为止Mutex经历的四个阶段的改进,具体可以参考链接二中大佬的博客。
Go 语言的 sync.Mutex 由两个字段 state 和 sema 组成。其中 state 表示当前互斥锁的状态,而 sema 是用于控制锁状态的信号量。
1 | type Mutex struct { |
其中state的最低三位mutexLocked, mutexWoken, mutexStarving表示锁的三种状态:
mutexLocked — 表示互斥锁的锁定状态;mutexWoken — 表示从正常模式被从唤醒;mutexStarving — 当前的互斥锁进入饥饿状态;waitersCount — 当前互斥锁上等待的 Goroutine 个数;
互斥锁的加锁过程比较复杂,它涉及自旋、信号量以及调度等概念:
mutexLocked 加锁;mutexLocked 状态并且在普通模式下工作,会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;runtime.sync_runtime_SemacquireMutex 将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒;互斥锁的解锁过程与之相比就比较简单,其代码行数不多、逻辑清晰,也比较容易理解:
sync.Mutex.Unlock 会直接抛出异常;mutexLocked 标志位;sync.runtime_Semrelease 唤醒对应的 Goroutine; 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
例如:两个线程A、B各自持有一个无法共享的资源,并且他们都需要获取对方现在持有的资源才能进行下一步,但是他们又必须等对方释放了才能去获取,于是A等待B,B也在等待A。如此这般,死锁就产生了。
死锁的产生必须具备如下四个必要条件:
这四个条件太抽象了,现在就以一个例子说明:
两个线程各自持有一个无法共享(互斥条件)的资源,并且他们都需要获取(请求与保持条件)对方现在持有的资源才能进行下一步,但是他们又必须等对方释放了才能去获取(不可剥夺条件),于是A等待B,B也在等待A(环路等待条件)。如此这般,死锁就产生了。
要预防死锁,只需要破坏四个必要条件中的一个或多个,使死锁永远无法满足即可。
云原生(Cloud Native)是一套技术体系和方法论,它由2个词组成,云(Cloud)和原生(Native)。云(Cloud)表示应用程序位于云中,而不是传统的数据中心;原生(Native)表示应用程序从设计之初即考虑到云的环境,原生为云而设计,在云上以最佳状态运行,充分利用和发挥云平台的弹性和分布式优势。
根据CNCF的定义,CNCF 将不可变基础设施、微服务、声明式 API、容器和服务网格列为云原生架构的技术块。
不可变基础设施意味着用于托管云原生应用程序的服务器在部署后保持不变。如果应用程序需要更多计算资源,则会丢弃旧服务器,并将应用程序移至新的高性能服务器。通过避免手动升级,不可变基础设施使云原生部署成为一个可预测的过程。
微服务是小型的独立软件组件,它们作为完整的云原生软件共同运行。每个微服务都侧重于一个小而具体的问题。微服务是松散耦合的,这意味着它们是相互通信的独立软件组件。开发人员通过处理单个微服务来更改应用程序。这样,即使一个微服务出现故障,应用程序仍能继续运行。
应用程序编程接口(API)是两个或多个软件程序用来交换信息的方法。云原生系统使用 API 将松散耦合的微服务整合在一起。API 会告诉您微服务想要什么数据以及它能给您带来什么结果,而不是指定实现结果的步骤。
服务网格是云基础设施中的一个软件层,用于管理多个微服务之间的通信。开发人员使用服务网格来引入其他功能,而无需在应用程序中编写新代码。
容器是云原生应用程序中最小的计算单元。它们是将微服务代码和其他必需文件打包在云原生系统中的软件组件。通过容器化微服务,云原生应用程序独立于底层操作系统和硬件运行。这意味着软件开发人员可以在本地、云基础设施或混合云上部署云原生应用程序。 开发人员使用容器将微服务与其各自的依赖项(例如主应用程序运行所需的资源文件、库和脚本)打包。
Go语言内置运行时(Runtime), 抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法,全称Thread-Caching Malloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
万字长文深入浅出 Golang Runtime // go夜读的分享,推荐去看下go夜读
TC Malloc 内存分配原理简析 // 简洁tc malloc的原理
图解Go语言内存分配 // 绕全成大佬写的,通俗易懂
golang内存分配原理及make和new的区别 // 强烈推荐这篇文档,这位大佬的go系列文章都很牛批
在golang语言中创建协程(Goroutine)的成本非常低,因此稍不注意就可能创建出大量的协程,一方面会造成资源的浪费,例如有一万个任务需要处理,如果启用一万个goroutine同时处理,意味了CPU内存资源大量的飙升,所以一般会控制goroutine的数量,例如最多只有一百个goroutine在运行,本章将看下如何控制goroutine的并发数量
在说明goroutine并发控制前,先看下并发不控制的代码逻辑
1 | package main |
上面的输出如下, 在多核的场景下,goroutine不一定是顺序输出的:
1 | ...... |
下图展示了为每个 job 创建一个 goroutine 的情况(换句话说,goroutine 的数量是不受控制的)。此种情况虽然生成了很多的 goroutine,但是每个 CPU 核上同一时间只能执行一个 goroutine;当 job 很多且生成了相应数目的 goroutine 后,会出现很多等待执行的 goroutine,从而造成资源上的浪费。
给每个 job 生成一个 goroutine 的方式显得粗暴了很多,那么可以通过什么样的方式控制 goroutine 的数目呢?其实上面的代码通过一个 for-range 循环完成了两件事情:①为每个 job 创建 goroutine;②把任务相关的标识传给相应的 goroutine 执行。为了控制 goroutine 的数目,完全可以把上面的两个过程拆分开:a)先通过一个 for-range 循环创建指定数目的 goroutine,b)然后通过 channel/buffered channel 给每个 goroutine 传递任务相关的信息(这里的channel是否缓冲无所谓,主要用到的是 channel 的线程安全特性)。如下图所示。
针对上面的代码,如果想达到goroutine并发执行的控制,我们可以加个buffer channel来限制最多只有多少个goroutine在执行,代码如下:
1 | package main |
通过channel限制最多有三个goroutine在执行,其余的被挂起等待中,但是此方案也有个缺陷,那就是goroutine还是都被创建了,只不过这些goroutine被挂起了而已。
1 | package main |
在方案二中,我们起了三个goroutine一直去消费woker以达到限制goroutine最大并发数的目的。
什么是goroutine? Goroutine 可以看作对 thread 加的一层抽象,它更轻量级,可以单独执行。
goroutine跟线程的区别可以从内存消耗、创建与销毀、切换三个维度说明
在 Go 语言中,每一个 goroutine 是一个独立的执行单元,相较于每个 OS 线程固定分配 2M 内存的模式,goroutine 的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达 1GB(64 位机器最大是 1G,32 位机器最大是 256M),且完全由 golang 自己的调度器 Go Scheduler 来调度。此外,GC 还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go 程序可以同时并发成千上万个 goroutine 是得益于它强劲的调度器和高效的内存模型。
将 goroutines 调度到线程上执行,仅仅是 runtime 层面的一个概念,在操作系统之上的层面,在golang中有三个基础的结构体来实现 goroutines 的调度。g,m,p, 俗称GPM模型:
除了GPM外,还有两个比较重要的组件: 全局可运行队列(GRQ)和本地可运行队列(LRQ)。 LRQ 存储本地(也就是具体的 P)的可运行 goroutine,GRQ 存储全局的可运行 goroutine,这些 goroutine 还没有分配到具体的 P。
在四种情形下,goroutine 可能会发生调度,但也并不一定会发生,只是说 Go scheduler 有机会进行调度。
go: go 创建一个新的 goroutine,Go scheduler 会考虑调度当 G 需要进行系统调用时,根据调用的类型,它所依附的 M 有两种情况:同步和异步。
对于同步的情况,M 会被阻塞,进而从 P 上调度下来,P 可不养闲人,G 仍然依附于 M。之后,一个新的 M 会被调用到 P 上,接着执行 P 的 LRQ 里嗷嗷待哺的 G 们。一旦系统调用完成,G 还会加入到 P 的 LRQ 里,M 则会被“雪藏”,待到需要时再“放”出来。

对于异步的情况,M 不会被阻塞,G 的异步请求会被“代理人” network poller 接手,G 也会被绑定到 network poller,等到系统调用结束,G 才会重新回到 P 上。M 由于没被阻塞,它因此可以继续执行 LRQ 里的其他 G。

g是goroutine的缩写,是goroutine的控制结构,是对goroutine的抽象。看下它内部主要的一些结构:
1 | type g struct { |
其中包含了栈信息stackbase和stackguard,有运行的函数信息fnstart。这些就足够成为一个可执行的单元了,只要得到CPU就可以运行。goroutine切换时,上下文信息保存在结构体的sched域中。goroutine切换时,上下文信息保存在结构体的sched域中。goroutine是轻量级的线程或者称为协程,切换时并不必陷入到操作系统内核中,很轻量级。
1 | struct Gobuf |
P是Processor的缩写。结构体P的加入是为了提高Go程序的并发度,实现更好的调度。M代表OS线程。P代表Go代码执行时需要的资源。
1 | type p struct { |
跟G不同的是,P不存在waiting状态。MCache被移到了P中,但是在结构体M中也还保留着。在P中有一个Grunnable的goroutine队列,这是一个P的局部队列。当P执行Go代码时,它会优先从自己的这个局部队列中取,这时可以不用加锁,提高了并发度。如果发现这个队列空了,则去其它P的队列中拿一半过来,这样实现工作流窃取的调度。这种情况下是需要给调用器加锁的。
1 | type m struct { |
和G类似,M中也有alllink域将所有的M放在allm链表中。lockedg是某些情况下,G锁定在这个M中运行而不会切换到其它M中去。M中还有一个MCache,是当前M的内存的缓存。M也和G一样有一个常驻寄存器变量,代表当前的M。同时存在多个M,表示同时存在多个物理线程。
在说明什么是Webassembly之前,我们有必要了解一下asm.js。2012年,Mozilla 的工程师 Alon Zakai 在研究 LLVM 编译器时突发奇想:许多 3D 游戏都是用 C / C++ 语言写的,如果能将 C / C++ 语言编译成 JavaScript 代码,它们不就能在浏览器里运行了吗?众所周知,JavaScript 的基本语法与 C 语言高度相似。于是,他开始研究怎么才能实现这个目标,为此专门做了一个编译器项目 Emscripten。这个编译器可以将 C / C++ 代码编译成 JS 代码,但不是普通的 JS,而是一种叫做 asm.js 的 JavaScript 变体,性能差不多是原生代码的50%。
之后Google开发了Portable Native Client,也是一种能让浏览器运行C/C++代码的技术。 后来可能是因为彼此之间有共同的更高追求,Google, Microsoft, Mozilla, Apple等几家大公司一起合作开发了一个面向Web的通用二进制和文本格式的项目,那就是WebAssembly。asm.js 与 WebAssembly 功能基本一致,就是转出来的代码不一样:asm.js 是文本,WebAssembly 是二进制字节码,因此运行速度更快、体积更小。
WebAssembly(又称 wasm) 是一种新的字节码格式,主流浏览器都已经支持 WebAssembly。 和 JS 需要解释执行不同的是,WebAssembly 字节码和底层机器码很相似可快速装载运行,因此性能相对于 JS 解释执行大大提升。 也就是说 WebAssembly 并不是一门编程语言,而是一份字节码标准,需要用高级编程语言编译出字节码放到 WebAssembly 虚拟机中才能运行, 浏览器厂商需要做的就是根据 WebAssembly 规范实现虚拟机。
这里引用MDN上官方对其的解释:WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++ / Rust等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作(总结一句话:Webassembly就像是JVM虚拟机,可以执行二进制码)。
有人这么评价Webassembly: Webassembly是Rust押宝的唯一使用场景(Rust语言的使用场景太少了,不像golang在云原生跟中间键领域有很多应用)。
自从 JavaScript 诞生起到现在已经变成最流行的编程语言,这背后正是 Web 的发展所推动的。Web 应用变得更多更复杂,但这也渐渐暴露出了 JavaScript 的问题:
针对以上两点缺陷,近年来出现了一些 JS 的代替语言,例如:
以上尝试各有优缺点,其中:
三大浏览器巨头分别提出了自己的解决方案,互不兼容,这违背了 Web 的宗旨; 是技术的规范统一让 Web 走到了今天,因此形成一套新的规范去解决 JS 所面临的问题迫在眉睫。于是 WebAssembly 诞生了,WebAssembly 是一种新的字节码格式,主流浏览器都已经支持 WebAssembly。 和 JS 需要解释执行不同的是,WebAssembly 字节码和底层机器码很相似可快速装载运行,因此性能相对于 JS 解释执行大大提升。 也就是说 WebAssembly 并不是一门编程语言,而是一份字节码标准,需要用高级编程语言编译出字节码放到 WebAssembly 虚拟机中才能运行, 浏览器厂商需要做的就是根据 WebAssembly 规范实现虚拟机。
总结一句话就是:主流厂商极力主导 + webassembly速度快
要搞懂 WebAssembly 的原理,需要先搞懂计算机的运行原理。 电子计算机都是由电子元件组成,为了方便处理电子元件只存在开闭两种状态,对应着 0 和 1,也就是说计算机只认识 0 和 1,数据和逻辑都需要由 0 和 1 表示,也就是可以直接装载到计算机中运行的机器码。 机器码可读性极差,因此人们通过高级语言 C、C++、Rust、Go 等编写再编译成机器码。
由于不同的计算机 CPU 架构不同,机器码标准也有所差别,常见的 CPU 架构包括 x86、AMD64、ARM, 因此在由高级编程语言编译成可自行代码时需要指定目标架构。WebAssembly 字节码是一种抹平了不同 CPU 架构的机器码,WebAssembly 字节码不能直接在任何一种 CPU 架构上运行, 但由于非常接近机器码,可以非常快的被翻译为对应架构的机器码,因此 WebAssembly 运行速度和机器码接近,这听上去非常像 Java 字节码。
在javascript中,JavaScript引擎是执行 JavaScript 代码的程序或解释器。JavaScript引擎可以实现为标准解释器,或者以某种形式将JavaScript编译为字节码的即时编译器。不用的浏览器使用不同的引擎如下所示:
V8 — 开源,由 Google 开发,用 C ++ 编写
Rhino — 由 Mozilla 基金会管理,开源,完全用 Java 开发
SpiderMonkey — 是第一个支持 Netscape Navigator 的 JavaScript 引擎,目前正供 Firefox 使用
JavaScriptCore — 开源,以Nitro形式销售,由苹果为Safari开发
KJS — KDE 的引擎,最初由 Harri Porten 为 KDE 项目中的 Konqueror 网页浏览器开发
Chakra (JScript9) — Internet Explorer
Chakra (JavaScript) — Microsoft Edge
Nashorn, 作为 OpenJDK 的一部分,由 Oracle Java 语言和工具组编写
JerryScript — 物联网的轻量级引擎
因此我们看下javascript在V8引擎下的工作机制: javacript运行中,引擎首先解析javascript生成抽象语法树(AST), 然后生成机器码。
接着通过V8 的优化编译器 (TurboFan)的优化,把优化后的机器码推向后端(所谓的后端就是即时编译器JIT)
但是webassembly并不需要以上的全部步骤-如下所示是它被插入到执行过程示意图:
可以看到webassembly并不需要编译阶段,而是直接把编译后的字节码发送给后端,因此速度更快。
目前能编译成 WebAssembly 字节码的高级语言有:
AssemblyScript:语法和 TypeScript 一致,对前端来说学习成本低,为前端编写 WebAssembly 最佳选择;
C\C++:官方推荐的方式,详细使用见文档;
其他语言: 详细见文档。
总体而言,WASM不会替代JavaScript,但是却可以辅助解决很多JavaScript无法解决的问题。下面是Webassembly的一些场景:
更好的让一些语言和工具可以编译到 Web 平台运行。
图片/视频编辑。
游戏:
AAA 级,资源量很大的游戏。
游戏门户(代理/原创游戏平台)
P2P 应用(游戏,实时合作编辑)
音乐播放器(流媒体,缓存)
图像识别
视频直播
VR 和虚拟现实
CAD 软件
科学可视化和仿真
互动教育软件和新闻文章。
模拟/仿真平台(ARC, DOSBox, QEMU, MAME, …)。
语言编译器/虚拟机。
游戏分发服务(便携、安全)。
服务端执行不可信任的代码。
服务端应用。
移动混合原生应用。
多节点对称计算
基于golang实现webassembly的demo
]]>require/exports 出生在野生规范当中,什么叫做野生规范?即这些规范是 JavaScript 社区中的开发者自己草拟的规则,得到了大家的承认或者广泛的应用。比如 CommonJS、AMD、CMD 等等。import/export 则是名门正派。TC39 制定的新的 ECMAScript 版本,即 ES6(ES2015)中包含进来。
关于 import 和 require 的不同,其实可以理解成 CommonJs 和 ES Module 的区别。这两者都是前端模块化的规范。
Nodejs 是 CommonJS 规范的主要实践者,在 CommonJs 里每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
1 | class MyClass { |
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。所以ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。
1 | export default class MyClass { |
| 命令 | 规范 | 调用 | 本质 | 特点 |
|---|---|---|---|---|
| require | CommonJS规范 | 运行时调用 | 赋值过程 | 非语言层面的标准。 社区方案,提供了服务器/浏览器的模块加载方案。只能在运行时确定模块的依赖关系及输入/输出的变量,无法进行静态优化。 |
| import | es6+的语法标准 | 编译时调用 | 解构过程 | 语言规格层面支持模块功能。支持编译时静态分析,便于JS引入宏和类型检验。动态绑定 |
在命令top中可以方便的查看系统的cpu和内存信息(如下图所示), 然后你可能不清楚每一个字段代表的含义,现在就一块揭秘top命令中每个字段的含义
us:user time,表示 CPU 执行用户进程的时间,包括 nice 时间。通常都是希望用户空间CPU越高越好。
sy:system time,表示 CPU 在内核运行的时间,包括 IRQ 和 softirq。系统 CPU 占用越高,表明系统某部分存在瓶颈。通常这个值越低越好。
ni:nice time,具有优先级的用户进程执行时占用的 CPU 利用率百分比。
id:idle time,表示系统处于空闲期,等待进程运行。
wa:waiting time,表示 CPU 在等待 IO 操作完成所花费的时间。系统不应该花费大量的时间来等待 IO 操作,否则就说明 IO 存在瓶颈。
hi:hard IRQ time,表示系统处理硬中断所花费的时间。
si:soft IRQ time,表示系统处理软中断所花费的时间。
st:steal time,被强制等待(involuntary wait)虚拟 CPU 的时间,此时 Hypervisor 在为另一个虚拟处理器服务
在上面的top命令中跟cpu相关的信息还有一个叫load average, load average这一列中的三个数值分别表示过去一分钟,五分钟,十五分钟这个节点上的load average。那么到底啥是load average呢?
这里的load average表示对CPU资源需求的度量(说的很好,但是等于放屁~),举个例子你可能就懂了,对于一个单个 CPU 的系统,如果在 1 分钟的时间里,处理器上始终有一个进程在运行,同时操作系统的进程可运行队列中始终都有 9 个进程在等待获取 CPU 资源。那么对于这 1 分钟的时间来说,系统的”load average”就是 1+9=10(这个定义对绝大部分的Unix 系统都适用)。
总结一句话就是: load average 等于单位时间内正在运行的进程+可运行队列的进程(注意,这里说的是unix系统),因此:
对于linux系统来说: load average = 可运行队列的进程 + 处于TASK_UNINTERRUPTIBLE状态的进程(D 状态进程)
TASK_UNINTERRUPTIBLE 是 Linux 进程状态的一种,是进程为等待某个系统资源而进入了睡眠的状态,并且这种睡眠的状态是不能被信号打断的。俗称D状态进程
因此,如果发现系统的load average很高,而CPU还是处于空闲状态,说明有很多进程处于阻塞状态,这时候得检查下代码写的是否有问题啦~(通过 ps 命令可以查阅D状态进程的信息)
分析了top命令中cpu字段的含义,你肯定还有疑问top命令中的数值是怎么计算出来的,