Dunwu Blog

大道至简,知易行难

《深入理解 Sentinel》笔记

开篇词:一次服务雪崩问题排查经历

什么是服务雪崩

服务雪崩是指:在微服务项目中指由于突发流量导致某个服务不可用,从而导致上游服务不可用,并产生级联效应,最终导致整个系统不可用。

当一切正常时,整体系统如下所示:

在分布式系统架构下,这些强依赖的子服务稳定与否对系统的影响非常大。但是,依赖的子服务可能有很多不可控问题:如网络连接、资源繁忙、服务宕机等。例如:下图中有一个 QPS 为 50 的依赖服务 I 出现不可用,但是其他依赖服务是可用的。

当流量很大的情况下,某个依赖的阻塞,会导致上游服务请求被阻塞。当这种级联故障愈演愈烈,就可能造成整个线上服务不可用的雪崩效应,如下图。这种情况若持续恶化,如果上游服务本身还被其他服务所依赖,就可能出现多米洛骨牌效应,导致多个服务都无法正常工作。

img

为什么需要服务降级以及常见的几种降级方式

服务降级是为了保障服务能够稳定运行的一种保护方式,应对流量突增用降级牺牲一些流量换取系统的稳定。常见的服务降级实现方式有:开关降级、限流降级、熔断降级。

限流降级与熔断降级都可以实现在消费端限流或者服务端限流,限流可以根据流量控制策略处理超过阈值的流量。

  • 限流即便没有达到系统的瓶颈,只要流量达到设定的阈值,就会触发限流;
  • 熔断尽最大的可能去完成所有的请求,容忍一些失败,熔断也能自动恢复。熔断的常见降级策略:
    • 在每秒请求异常数超过多少时触发熔断降级
    • 在每秒请求异常错误率超过多少时触发熔断降级
    • 在每秒请求平均耗时超过多少时触发熔断降级

开关降级适用于促销活动这种可以明确预估到并发会突增的场景。

为什么选择 Sentinel,Sentinel 与 Hystrix 的对比

Sentinel Hystrix
社区活跃度 Github 13K star 官方停止维护
隔离策略 信号量隔离 线程池隔离/信号量隔离
熔断降级策略 基于响应时间或失败比率 基于失败比率
实时指标实现 滑动窗口 滑动窗口(基于 RxJava)
规则配置 支持多种数据源 支持多种数据源
扩展性 多个 SPI 扩展点 插件的形式
基于注解的支持 支持 支持
限流 基于 QPS,支持基于调用关系的限流 有限的支持
流量整形 支持慢启动、匀速器模式 不支持
系统负载保护 支持 不支持
控制台 开箱即用,可配置规则、查看秒级监控、机器发现等 不完善
常见框架的适配 Servlet、Spring Cloud、Dubbo、gRPC 等 Servlet、Spring Cloud Netflix

Sentinel 基于滑动窗口的实时指标数据统计

  • WindowWrap 用于包装 Bucket,随着 Bucket 一起创建。
  • WindowWrap 数组实现滑动窗口,Bucket 只负责统计各项指标数据,WindowWrap 用于记录 Bucket 的时间窗口信息。
  • 定位 Bucket 实际上是定位 WindowWrap,拿到 WindowWrap 就能拿到 Bucket。

Sentinel 的一些概念与核心类介绍

  • 资源:资源是 Sentinel 的关键概念。资源,可以是一个方法、一段代码、由应用提供的接口,或者由应用调用其它应用的接口。
  • 规则:围绕资源的实时状态设定的规则,包括流量控制规则、熔断降级规则以及系统保护规则、自定义规则。
  • 降级:在流量剧增的情况下,为保证系统能够正常运行,根据资源的实时状态、访问流量以及系统负载有策略的拒绝掉一部分流量。

核心类:

ResourceWrapper 类用于表示资源。

Node 用于持有实时统计的指标数据。它有几个实现类:DefaultNodeClusterNodeEntranceNodeStatisticNode

  • StatisticNode 是实现实时指标数据统计 Node。
  • DefaultNode 是实现以资源为维度的指标数据统计的 Node。
  • ClusterNode 统计每个资源全局的指标数据,以及统计该资源按调用来源区分的指标数据。
  • EntranceNode 继承 DefaultNode,用于维护一颗树,从根节点到每个叶子节点都是不同请求的调用链路,所经过的每个节点都对应着调用链路上被 Sentinel 保护的资源,一个请求调用链路上的节点顺序正是资源被访问的顺序。
  • Context 代表调用链路上下文,贯穿一次调用链路中的所有 Entry。Context 维持着入口节点(entranceNode)、本次调用链路的 curNode、调用来源(origin)等信息。Context 名称即为调用链路入口名称。Context 通过 ThreadLocal 传递,只在调用链路的入口处创建。
  • Entry 维护了当前资源的 DefaultNode,以及调用来源的 StatisticNode。
  • ProcessorSlot 直译就是处理器插槽,是 Sentinel 实现限流降级、熔断降级、系统自适应降级等功能的切入点。Sentinel 提供的 ProcessorSlot 可以分为两类,一类是辅助完成资源指标数据统计的切入点,一类是实现降级功能的切入点。实现降级功能的 ProcessorSlot:
    • AuthoritySlot:实现黑白名单降级
    • SystemSlot:实现系统自适应降级
    • FlowSlot:实现限流降级
    • DegradeSlot:实现熔断降级

Sentinel 中的责任链模式与 Sentinel 的整体工作流程

Sentinel 的工作流就是使用责任链模式将所有的 ProcessorSlot 按照一定的顺序串成一个单向链表。

实现将 ProcessorSlot 串成一个单向链表的是 ProcessorSlotChain,这个 ProcessorSlotChain 是由 SlotChainBuilder 构造的。

Java SPI 及 SPI 在 Sentinel 中的应用

SPI 全称是 Service Provider Interface,直译就是服务提供者接口,是一种服务发现机制,是 Java 的一个内置标准,允许不同的开发者去实现某个特定的服务。SPI 的本质是将接口实现类的全限定名配置在文件中,由服务加载器读取配置文件,加载实现类,实现在运行时动态替换接口的实现类。

在 sentinel-core 模块的 resources 资源目录下,有一个 META-INF/services 目录,该目录下有两个以接口全名命名的文件,其中 com.alibaba.csp.sentinel.slotchain.SlotChainBuilder 文件用于配置 SlotChainBuilder 接口的实现类。

08 资源指标数据统计的实现全解析(上)

NodeSelectorSlot 负责为资源的首次访问创建 DefaultNode,以及维护 Context.curNode 和调用树。NodeSelectorSlot 被放在 ProcessorSlotChain 链表的第一个位置,这是因为后续的 ProcessorSlot 都需要依赖这个 ProcessorSlot。

09 资源指标数据统计的实现全解析(下)

  • 一个调用链路上只会创建一个 Context,在调用链路的入口创建(一个调用链路上第一个被 Sentinel 保护的资源)。
  • 一个 Context 名称只创建一个 EntranceNode,也是在调用链路的入口创建,调用 Context#enter 方法时创建。
  • 与方法调用的入栈出栈一样,一个线程上调用多少次 SphU#entry 方法就会创建多少个 CtEntry,前一个 CtEntry 作为当前 CtEntry 的父节点,当前 CtEntry 作为前一个 CtEntry 的子节点,构成一个双向链表。Context.curEntry 保存的是当前的 CtEntry,在调用当前的 CtEntry#exit 方法时,由当前 CtEntry 将 Context.curEntry 还原为当前 CtEntry 的父节点 CtEntry。
  • 一个调用链路上,如果多次调用 SphU#entry 方法传入的资源名称都相同,那么只会创建一个 DefaultNode,如果资源名称不同,会为每个资源名称创建一个 DefaultNode,当前 DefaultNode 会作为调用链路上的前一个 DefaultNode 的子节点。
  • 一个资源有且只有一个 ProcessorSlotChain,一个资源有且只有一个 ClusterNode。
  • 一个 ClusterNode 负责统计一个资源的全局指标数据。
  • StatisticSlot 负责记录请求是否被放行、请求是否被拒绝、请求是否处理异常、处理请求的耗时等指标数据,在 StatisticSlot 调用 DefaultNode 用于记录某项指标数据的方法时,DefaultNode 也会调用 ClusterNode 的相对应方法,完成两份指标数据的收集。
  • DefaultNode 统计当前资源的各项指标数据的维度是同一个 Context(名称相同),而 ClusterNode 统计当前资源各项指标数据的维度是全局。

10 限流降级与流量效果控制器(上)

11 限流降级与流量效果控制器(中)

12 限流降级与流量效果控制器(下)

13 熔断降级与系统自适应限流

14 黑白名单限流与热点参数限流

15 自定义 ProcessorSlot 实现开关降级

16 Sentinel 动态数据源:规则动态配置

17 Sentinel 主流框架适配

18 Sentinel 集群限流的实现(上)

19 Sentinel 集群限流的实现(下)

20 结束语:Sentinel 对应用的性能影响如何?

21 番外篇:Sentinel 1.8.0 熔断降级新特性解读

资料

https://wujiuye.com/album/52c96863a60441829497e98226e2c337

服务注册和发现

服务注册和发现的基本原理

服务定义是服务提供者和服务消费者之间的约定,但是在微服务架构中,如何达成这个约定呢?这就依赖于服务注册和发现机制。

注册和发现的角色

在微服务架构下,服务注册和发现机制中主要有三种角色:

  • 服务提供者(RPC Server / Provider)
  • 服务消费者(RPC Client / Consumer)
  • 服务注册中心(Registry)

服务发现通常依赖于注册中心来协调服务发现的过程,其步骤如下:

  1. 服务提供者将接口信息注册到注册中心。
  2. 服务消费者从注册中心读取和订阅服务提供者的地址信息。
  3. 如果有可用的服务,注册中心会主动通知服务消费者。
  4. 服务消费者根据可用服务的地址列表,调用服务提供者的接口。

这个过程很像是生活中的房屋租赁,房东将租房信息挂到中介公司,房客从中介公司查找租房信息。房客如果想要租房东的房子,通过中介公司牵线搭桥,联系上房东,双方谈妥签订协议,就可以正式建立起租赁关系。

img

主流的服务注册与发现的解决方案,主要有两种:

  • 应用内注册与发现:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。
  • 应用外注册与发现:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。

应用内注册与发现

应用内注册与发现方案是:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。最典型的案例要属 Netflix 开源的 Eureka,官方架构图如下:

img

Eureka 的架构主要由三个重要的组件组成:

  • Eureka Server:注册中心的服务端,实现了服务信息注册、存储以及查询等功能。
  • 服务端的 Eureka Client:集成在服务端的注册中心 SDK,服务提供者通过调用 SDK,实现服务注册、反注册等功能。
  • 客户端的 Eureka Client:集成在客户端的注册中心 SDK,服务消费者通过调用 SDK,实现服务订阅、服务更新等功能。

应用外注册与发现

应用外注册与发现方案是:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。最典型的案例是开源注册中心 Consul。

img

Consul 实现应用外服务注册和发现主要依靠三个重要的组件:

  • Consul:注册中心的服务端,实现服务注册信息的存储,并提供注册和发现服务。
  • Registrator:一个开源的第三方服务管理器项目,它通过监听服务部署的 Docker 实例是否存活,来负责服务提供者的注册和销毁。
  • Consul Template:定时从注册中心服务端获取最新的服务提供者节点列表并刷新 LB 配置(比如 Nginx 的 upstream),这样服务消费者就通过访问 Nginx 就可以获取最新的服务提供者信息。

注册中心的基本功能

从服务注册和发现的流程,可以看出,注册中心是服务发现的核心组件。常见的注册中心组件有:Nacos、Consul、Zookeeper 等。

注册中心的实现主要涉及几个问题:注册中心需要提供哪些接口,该如何部署;如何存储服务信息;如何监控服务提供者节点的存活;如果服务提供者节点有变化如何通知服务消费者,以及如何控制注册中心的访问权限。

元数据定义

构建微服务的首要问题是:服务提供者和服务消费者通信时,如何达成共识。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

常见的定义服务元数据的方式有:

  • XML 文件 - 如果只是企业内部之间的服务调用,并且都是 Java 语言的话,选择 XML 配置方式是最简单的。
  • IDL 文件 - 如果企业内部存在多个跨语言服务,建议使用 IDL 文件方式进行描述服务。
  • REST API - 如果存在对外开放服务调用的情形的话,使用 REST API 方式则更加通用。

XML 文件

XML 配置方式通过在服务提供者和服务消费者之间维持一份对等的 XML 配置文件,来保证服务消费者按照服务提供者的约定来进行服务调用。在这种方式下,如果服务提供者变更了接口定义,不仅需要更新服务提供者加载的接口描述文件 server.xml,还需要同时更新服务消费者加载的接口描述文件 client.xml。但这种方式对业务代码侵入性比较高,XML 配置有变更的时候,服务消费者和服务提供者都要更新,所以适合公司内部联系比较紧密的业务之间采用。支持 XML 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:基于 Spring XML 开发微服务应用)、微博的 Motan。

XML 文件这种方式的服务发布和引用主要分三个步骤:

(1)服务提供者定义接口,并实现接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// The demo service definition.
service DemoService {
rpc sayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

(2)服务提供者进程启动时,通过加载 xml 配置文件将接口暴露出去。

1
2
3
4
5
6
7
8
9
10
11
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-provider"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:protocol name="dubbo" port="20890"/>
<bean id="demoService" class="org.apache.dubbo.samples.basic.impl.DemoServiceImpl"/>
<dubbo:service interface="org.apache.dubbo.samples.basic.api.DemoService" ref="demoService"/>
</beans>

(3)服务消费者进程启动时,通过加载 xml 配置文件来引入要调用的接口。

1
2
3
4
5
6
7
8
9
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-consumer"/>
<dubbo:registry group="aaa" address="zookeeper://127.0.0.1:2181"/>
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.samples.basic.api.DemoService"/>
</beans>

IDL 文件

IDL 就是接口描述语言(interface description language)的缩写,通过一种中立、通用的方式来描述接口,使得在不同的平台上运行的对象和不同语言编写的程序可以相互通信交流。也就是说,IDL 主要用于跨语言的服务之间的调用。支持 IDL 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:IDL 定义跨语言服务),Facebook 的 Thrift,Google 的 gRPC

以 gRPC 协议为例,gRPC 协议使用 Protobuf 简称 proto 文件来定义接口名、调用参数以及返回值类型。比如文件 helloword.proto 定义了一个接口 SayHello 方法,它的请求参数是 HelloRequest,它的返回值是 HelloReply。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}

}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

假如服务提供者使用的是 Java 语言,那么利用 protoc 插件即可自动生成 Server 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private class GreeterImpl extends GreeterGrpc.GreeterImplBase {

@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}

@Override
public void sayHelloAgain(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello again " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}

假如服务消费者使用的也是 Java 语言,那么利用 protoc 插件即可自动生成 Client 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void greet(String name) {
logger.info("Will try to greet " + name + " ...");
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
try {
response = blockingStub.sayHelloAgain(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
}

假如服务消费者使用的是其他语言,也可以利用相应的插件生成代码。

由此可见,gRPC 协议的服务描述是通过 proto 文件来定义接口的,然后再使用 protoc 来生成不同语言平台的客户端和服务端代码,从而具备跨语言服务调用能力。

有一点特别需要注意的是,在描述接口定义时,IDL 文件需要对接口返回值进行详细定义。如果接口返回值的字段比较多,并且经常变化时,采用 IDL 文件方式的接口定义就不太合适了。一方面可能会造成 IDL 文件过大难以维护,另一方面只要 IDL 文件中定义的接口返回值有变更,都需要同步所有的服务消费者都更新,管理成本就太高了。

REST API

REST API 方式主要被用作 HTTP 或者 HTTPS 协议的接口定义,即使在非微服务架构体系下,也被广泛采用。由于 HTTP 本身就是公开标准网络协议,所以几乎没有什么额外学习成本。支持 REST API 的主流 RPC 有:Eureka,下面以 Eureka 为例。

服务提供者定义接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class ProviderController {

private final DiscoveryClient discoveryClient;

public ProviderController(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}

@GetMapping("/send")
public String send() {
String services = "Services: " + discoveryClient.getServices();
System.out.println(services);
return services;
}

}

服务消费者消费接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ConsumerController {

private final LoadBalancerClient loadBalancerClient;
private final RestTemplate restTemplate;

public ConsumerController(LoadBalancerClient loadBalancerClient,
RestTemplate restTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
}

@GetMapping("/recv")
public String recv() {
ServiceInstance serviceInstance = loadBalancerClient.choose("eureka-provider");
String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/send";
System.out.println(url);
return restTemplate.getForObject(url, String.class);
}

}

元数据存储

注册中心本质上是一个用于保存元数据的分布式存储。你如果明白了这一点,就会了解实现一个注册中心的所有要点都是围绕这个目标去构建的。

想要构建微服务,首先要解决的问题是,服务提供者如何发布一个服务,服务消费者如何引用这个服务。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

服务的元数据信息通常有以下信息:

  • 服务节点信息,如 IP、端口等。
  • 接口定义,如接口名、请求参数、响应参数等。
  • 请求失败的重试次数
  • 序列化方式
  • 压缩方式
  • 通信协议
  • 等等

在具体存储时,注册中心一般会按照“服务 - 分组 - 节点信息”的层次化的结构来存储。以 ZooKeeper 为例:

  • 在 ZooKeeper 中,数据按目录层级存储,每个目录叫作 znode,并且其有一个唯一的路径标识。
  • znode 可以包含数据和子 znode。
  • znode 中的数据可以有多个版本,比如某一个 znode 下存有多个数据版本,那么查询这个路径下的数据需带上版本信息。

img

注册中心 API

既然是分布式存储,势必要提供支持读写数据的接口,也就是 API,一般来说,需要支持以下功能:

  • 服务注册接口:服务提供者通过调用服务注册接口来完成服务注册。
  • 服务反注册接口:服务提供者通过调用服务反注册接口来完成服务注销。
  • 心跳汇报接口:服务提供者通过调用心跳汇报接口完成节点存活状态上报。
  • 服务订阅接口:服务消费者通过调用服务订阅接口完成服务订阅,获取可用的服务提供者节点列表。
  • 服务变更查询接口:服务消费者通过调用服务变更查询接口,获取最新的可用服务节点列表。

除此之外,为了便于管理,注册中心还必须提供一些后台管理的 API,例如:

  • 服务查询接口:查询注册中心当前注册了哪些服务信息。
  • 服务修改接口:修改注册中心中某一服务的信息。

服务健康检测

注册中心除了要支持最基本的服务注册和服务订阅功能以外,还必须具备对服务提供者节点的健康状态检测功能,这样才能保证注册中心里保存的服务节点都是可用的。注册中心通常使用长连接或心跳探测方式检查服务健康状态

还是以 ZooKeeper 为例,它是基于 ZooKeeper 客户端和服务端的长连接和会话超时控制机制,来实现服务健康状态检测的。在 ZooKeeper 中,客户端和服务端建立连接后,会话也随之建立,并生成一个全局唯一的 Session ID。服务端和客户端维持的是一个长连接,在 SESSION_TIMEOUT 周期内,服务端会检测与客户端的链路是否正常,具体方式是通过客户端定时向服务端发送心跳消息(ping 消息),服务器重置下次 SESSION_TIMEOUT 时间。如果超过 SESSION_TIMEOUT 后服务端都没有收到客户端的心跳消息,则服务端认为这个 Session 就已经结束了,ZooKeeper 就会认为这个服务节点已经不可用,将会从注册中心中删除其信息。

服务状态变更通知

一旦注册中心探测到有服务提供者节点新加入或者被剔除,就必须立刻通知所有订阅该服务的服务消费者,刷新本地缓存的服务节点信息,确保服务调用不会请求不可用的服务提供者节点。注册中心通常基于服务状态订阅来实现服务状态变更通知。

继续以 ZooKeeper 为例,基于 ZooKeeper 的 Watcher 机制,来实现服务状态变更通知给服务消费者的。服务消费者在调用 ZooKeeper 的 getData 方法订阅服务时,还可以通过监听器 Watcher 的 process 方法获取服务的变更,然后调用 getData 方法来获取变更后的数据,刷新本地缓存的服务节点信息。

集群部署

注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。根据 CAP 理论,三种特性无法同时达成,必须在可用性和一致性之间做取舍。于是,根据不同侧重点,注册中心可以分为 CP 和 AP 两个阵营:

  • CP 型注册中心 - 牺牲可用性来换取数据强一致性,最典型的例子就是 ZooKeeper,etcd,Consul 了。ZooKeeper 集群内只有一个 Leader,而且在 Leader 无法使用的时候通过算法选举出一个新的 Leader。这个 Leader 的目的就是保证写信息的时候只向这个 Leader 写入,Leader 会同步信息到 Followers,这个过程就可以保证数据的强一致性。但如果多个 ZooKeeper 之间网络出现问题,造成出现多个 Leader,发生脑裂的话,注册中心就不可用了。而 etcd 和 Consul 集群内都是通过 Raft 协议来保证强一致性,如果出现脑裂的话, 注册中心也不可用。
  • AP 型注册中心 - 牺牲一致性(只保证最终一致性)来换取可用性,最典型的例子就是 Eureka 了。对比下 Zookeeper,Eureka 不用选举一个 Leader,每个 Eureka 服务器单独保存服务注册地址,因此有可能出现数据信息不一致的情况。但是当网络出现问题的时候,每台服务器都可以完成独立的服务。

以开源注册中心 ZooKeeper 为例,ZooKeeper 集群中包含多个节点,服务提供者和服务消费者可以同任意一个节点通信,因为它们的数据一定是相同的,这是为什么呢?这就要从 ZooKeeper 的工作原理说起:

  • 每个 Server 在内存中存储了一份数据,Client 的读请求可以请求任意一个 Server。
  • ZooKeeper 启动时,将从实例中选举一个 leader(Paxos 协议)。
  • Leader 负责处理数据更新等操作(ZAB 协议)。
  • 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改 。

通过上面这种方式,ZooKeeper 保证了高可用性以及数据一致性。

img

注册中心的扩展功能

多注册中心

对于服务消费者来说,要能够同时从多个注册中心订阅服务;

对于服务提供者来说,要能够同时向多个注册中心注册服务。

并行订阅服务

如果只支持串行订阅,如果服务消费者订阅的服务较多,并且某些服务节点的初始化连接过程中出现连接超时的情况,则后续所有的服务节点的初始化连接都需要等待它完成,这就会导致消费者启动非常慢。

可以每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了 30 秒以内。

批量注销服务

在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”。

需要定时去清理注册中心中的“僵尸节点”,如果支持批量注销服务,就可以一次调用就把该节点上提供的所有服务同时注销掉。

服务变更信息增量更新

为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息。尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴

心跳开关保护机制

在网络频繁抖动的情况下,注册中心中可用的节点会不断变化,这时候服务消费者会频繁收到服务提供者节点变更的信息,于是就不断地请求注册中心来拉取最新的可用服务节点信息。当有成百上千个服务消费者,同时请求注册中心获取最新的服务提供者的节点信息时,可能会把注册中心的带宽给占满,尤其是注册中心是百兆网卡的情况下。

所以针对这种情况,需要一种保护机制,即使在网络频繁抖动的时候,服务消费者也不至于同时去请求注册中心获取最新的服务节点信息

我曾经就遇到过这种情况,一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给 10% 的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的 1/10。

当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在 10s 内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。

服务节点摘除保护机制

服务提供者在进程启动时,会注册服务到注册中心,并每隔一段时间,汇报心跳给注册中心,以标识自己的存活状态。如果隔了一段固定时间后,服务提供者仍然没有汇报心跳给注册中心,注册中心就会认为该节点已经处于“dead”状态,于是从服务的可用节点信息中移除出去。

如果遇到网络问题,大批服务提供者节点汇报给注册中心的心跳信息都可能会传达失败,注册中心就会把它们都从可用节点列表中移除出去,造成剩下的可用节点难以承受所有的调用,引起“雪崩”。但是这种情况下,可能大部分服务提供者节点是可用的,仅仅因为网络原因无法汇报心跳给注册中心就被“无情”的摘除了。

这个时候就需要根据实际业务的情况,设定一个阈值比例,即使遇到刚才说的这种情况,注册中心也不能摘除超过这个阈值比例的节点

这个阈值比例可以根据实际业务的冗余度来确定,我通常会把这个比例设定在 20%,就是说注册中心不能摘除超过 20% 的节点。因为大部分情况下,节点的变化不会这么频繁,只有在网络抖动或者业务明确要下线大批量节点的情况下才有可能发生。而业务明确要下线大批量节点的情况是可以预知的,这种情况下可以关闭阈值保护;而正常情况下,应该打开阈值保护,以防止网络抖动时,大批量可用的服务节点被摘除。

白名单机制

在实际的微服务测试和部署时,通常包含多套环境,比如生产环境一套、测试环境一套。开发在进行业务自测、测试在进行回归测试时,一般都是用测试环境,部署的 RPC Server 节点注册到测试的注册中心集群。但经常会出现开发或者测试在部署时,错误的把测试环境下的服务节点注册到了线上注册中心集群,这样的话线上流量就会调用到测试环境下的 RPC Server 节点,可能会造成意想不到的后果。

为了防止这种情况发生,注册中心需要提供一个保护机制,你可以把注册中心想象成一个带有门禁的房间,只有拥有门禁卡的 RPC Server 才能进入。在实际应用中,注册中心可以提供一个白名单机制,只有添加到注册中心白名单内的 RPC Server,才能够调用注册中心的注册接口,这样的话可以避免测试环境中的节点意外跑到线上环境中去。

静态注册中心

因为服务提供者是向服务消费者提供服务的,服务是否可用,服务消费者应该比注册中心更清楚。因此,可以直接在服务消费者端,根据调用服务提供者是否成功来判定服务提供者是否可用。如果服务消费者调用某一个服务提供者节点连续失败超过一定次数,可以在本地内存中将这个节点标记为不可用。并且每隔一段固定时间,服务消费者都要向标记为不可用的节点发起保活探测,如果探测成功了,就将标记为不可用的节点再恢复为可用状态,重新发起调用。

参考资料

分布式共识

什么是分布式共识

分布式系统最重要的抽象之一就是共识(consensus):所有的节点就某一项提议达成一致

共识问题通常形式化如下:一个或多个节点可以提议(propose) 某些值,而集群中的所有有效节点根据共识算法进行协商,最终决议(decides) 采纳某个节点的提议。

而共识算法必须满足以下性质:

  1. 达成一致(Uniform agreement) - 没有两个节点的决定不同。
  2. 完整性(Integrity) - 每个节点最多决议一次。
  3. 有效性(Validity) - 如果一个节点决定了值 v ,则 v 由某个节点所提议。
  4. 终止(Termination) - 由所有未崩溃的节点来最终决议。

达成一致完整性定义了共识算法的核心思想:所有人同意了相同的结果,且一旦决定了,就不能改变主意。有效性 主要是为了排除无效的提案。如果不关心容错,那么满足前三个属性很容易:你可以将一个节点做为 “独裁者”,并让该节点做出所有的决定。但如果该节点失效,那么系统就无法再做出任何决定。事实上,2PC 就存在这种问题:如果协调者失效,那么存疑的参与者就无法决定提交还是中止。

终止 意味着:即使部分节点出现故障,其他节点也必须达成共识。当然,算法可以容忍的失效节点数是有限的:需要超过半数以上的服务器达成一致。假设有 N 台服务器, 大于等于 N/2 + 1 台服务器就算是半数以上了 。

共识(Consensus)与一致性(Consistency)的区别:一致性是指数据不同副本之间的差异;而共识是指达成一致性的方法与过程。很多中文资料把 Consensus 翻译为一致性,但其实是不准确的。

为什么需要分布式共识

对于一个主从复制的数据库,如果主节点发生失效,就需要切换到另一个节点。如果主节点故障了,集群就会天下大乱,就好比一个国家的皇帝驾崩了,国家大乱一样。比如,数据库集群中主节点故障后,可能导致每个节点上的数据会不一致。这,就应了那句话“国不可一日无君”,对应到分布式系统中就是“集群不可一刻无主”。集群中的有效节点可以采用共识算法来选举新的主节点

某一时刻必须只有一个主节点,所有的节点必须就此达成一致。如果有两个节点都自认为是主节点,就会发生脑裂,导致数据丢失。正确实现共识算怯则可以避免此类问题。

一致性保证

线性化

线性化(一种流行的一致性模型) 其目标是使多副本对外看起来好像是单一副本,然后所有操作以原子方式运行,就像一个单线程程序操作变量一样。线性化的概念简单,容易理解,但它的主要问题在于性能,特别是在网络延迟较大的环境中。

顺序保证

线性化是将所有操作都放在唯一的、全局有序时间线上,而因果性则不同,它为我们提供了一个弱一致性模型: 允许存在某些井发事件,所以版本历史
是一个包含多个分支与合井的时间线。因果一致性避免了线性化昂贵的协调开销,且对网络延迟的敏感性要低很多。

分布式共识能否达成

Fischer、Lynch 和 Paterson (FLP)在 Impossibility of Distributed Consensus with One Faulty Process 论文中论证了:在一个异步系统中,即使只有一个进程出现了故障,也没有算法能保证达成共识。

简单来说,在一个异步系统中,由于进程可以随时发出响应,所以没有办法分辨一个进程是速度很慢还是已经崩溃,这不满足终止性(Termination)。

共识的不可能性

FLP 是一种限制性很强的模型,它假定共识性算法不能使用任何时钟或超时。如果允许算法使用 超时 或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),则共识变为一个可解的问题。因此,虽然 FLP 是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的。

分布式共识算法

共识意味着就某一项提议,所有节点做出一致的决定,而且决定不可撤销。通过逐一分析,事实证明,多个广泛的问题最终都可以归结为共识,并且彼此等价(这就意味着,如果找到其中一个解决方案,就可以比较容易地将其转换为其他问题的解决方案)。这些等价的问题包括:

  • 可线性化的比较-设置寄存器 - 寄存器需要根据当前值是否等于输入的参数, 来自动决定接下来是否应该设置新值。
  • 原子事务提交 - 数据库需要决定是否提交或中止分布式事务。
  • 全序广播 - 消息系统要决定以何种顺序发送消息。
  • 锁与租约 - 当多个客户端争抢锁或租约时,要决定其中哪一个成功。
  • 成员/协调服务 - 对于失败检测器(例如超时机制),系统要决定节点的存活状态(例如基于会i舌超时)。
  • 唯一性约束 - 当多个事务在相同的主键上试图井发创建冲突资源时,约束条件要决定哪一个被允许,哪些违反约束因而必须失败。

如果系统只存在一个节点,或者愿意把所有决策功能都委托给某一个节点,那么事情就变得很简单。这和主从复制数据库的情形是一样的,即由主节点负责所有的决策事宜,正因如此,这样的数据库可以提供线性化操作、唯一性约束、完全有序的复制日志等。

然而,如果唯一的主节点发生故障,或者出现网络中断而导致主节点不可达,这样的系统就会陷入停顿状态。有以下三种基本思路来处理这种情况:

  • 系统服务停止,井等待主节点恢复。许多XA I JTA 事务协调者采用了该方式。本质上,这种方怯并没有完全解决共识问题,因为它不满足终止性条件,试想如果主节点没法恢复,则系统就会永远处于停顿状态。
  • 人为介入来选择新的主节点,并重新配置系统使之生效。许多关系数据库都采用这种方怯。本质上它引入了一种“上帝旨意” 的共识, 即在计算机系统之外由人
    类来决定最终命运。故障切换的速度完全取决于人类的操作,通常比计算机慢。
  • 采用算i法来自动选择新的主节点。这需要一个共识算法,我们建议采用那些经过验证的共识系统来确保正确处理各种网络异常。

共识算法选举主节点的过程如同投票选举领导者(Leader),参选者(Candidate)需要说服大多数投票者(Follower)投票给他。一旦选举出领导者,就由领导者发号施令,所有追随者必须服从命令。

常见的分布式共识算法有:

这些算法之间有不少相似之处,但并不相同。下面,将大致介绍一下它们的共同思想。

全序广播

全序广播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。这相当于进行了几轮共识:在每一轮中,节点提议下一条要发送的消息,然后决定在全序中下一条要发送的消息。

所以,全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):

  • 由于 一致同意 属性,所有节点决定以相同的顺序传递相同的消息。
  • 由于 完整性 属性,消息不会重复。
  • 由于 有效性 属性,消息不会被损坏,也不能凭空编造。
  • 由于 终止 属性,消息不会丢失。

Raft 和 Zab 直接实现了全序广播,因为这样做比重复一次一值(one value a time)的共识更高效。在 Paxos 的情况下,这种优化被称为 Multi-Paxos。

主从复制和共识

主从复制将所有的写入操作都交给领导者,并以相同的顺序将状态变化广播同步到追随者,从而保持一致性。这实际上不就是一个全序广播吗?为什么不需要担心共识问题呢?

因为,这种场景下实际是一种独裁型的共识模型:只有一个节点被允许接收写入(即决定写入复制日志的顺序),如果该节点发生故障,则系统将无法写入,直到选出新的领导者。

纪元和法定人数

为了保证领导者是独一无二的,共识算法通常会定义一个逻辑时钟,用于表示选举领导者的投票轮次(纪元),而共识算法要保证每界选举得出的领导者是惟一的。不同算法中,对代表逻辑时钟的值定义不同,但作用是共通的:在 Paxos 中称其为选票(ballot);在 Raft 中称其为任期(term);在 Zab 中称其为纪元(epoch)。

每当现任领导者被认为宕机时,节点间就会发起一场投票,选举出新的领导者。这次选举被赋予一个全序且单调递增的纪元编号。如果出现两个不同时代的领导者,则以更高纪元编号的领导为主。

在每轮选举中,参选者如果要赢得选举,当选领导者,必须获得法定人数(quorum) 的选票。通常,会约定法定人数为超过半数以上,举例来说:假设总共有 N 张投票, 大于等于 N/2 + 1 张投票就算是半数以上了 。

共识的局限性

共识对于集群节点数的限制

多数派共识算法的核心是少数服从多数,获得投票多的节点胜出。这对于集群节点数有以下限制:

  • 集群中最多可以容忍半数以下的节点出现故障。因为,一旦故障节点数达到半数,则无法在选举中获得半数以上投票。举例来说:如果集群有 3 个节点,最多允许 1 个节点出现故障;如果集群中有 5 个节点,最多允许 2 个节点出现故障。
  • 集群的节点数一般要求是奇数。如果集群节点数为偶数,就很有可能在选主时出现某两个节点均获得半数以上投票的情况,这种情况下就必须重新投票选举。

选举会影响性能

共识系统通常依靠超时来检测失效的节点。在网络延迟高度变化的环境中,特别是在地理上散布的系统中,经常发生一个节点由于暂时的网络问题,错误地认为领导者已经失效。虽然这种错误不会损害安全属性,但频繁的领导者选举会导致糟糕的性能表现,因系统最后可能花在权力倾扎上的时间要比花在建设性工作的多得多。

参考资料

《极客时间教程 - 深入浅出分布式技术原理》笔记

开篇词 掌握好学习路径,分布式系统原来如此简单

导读:以前因后果为脉络,串起网状知识体系

分布式系统解决了什么问题

  • 首先,分布式系统解决了单机性能瓶颈导致的成本问题。——水平扩展
  • 然后,解决了用户量和数据量爆炸性地增大导致的成本问题。——水平扩展
  • 接着,满足了业务高可用的要求。——解决单点问题,鸡蛋不要都放在一个篮子里
  • 最后,分布式系统解决了大规模软件系统的迭代效率和成本的问题。——分而治之,化繁为简

如何思考和处理分布式系统引入的新问题

  • 怎么找到服务——服务注册和发现
  • 怎么找到实例——路由、负载均衡
  • 怎么管理配置——配置中心
  • 怎么进行协同——分布式锁
  • 怎么确保请求只执行一次——重试+幂等
  • 怎么避免雪崩——限流、熔断、降级、快速失败、弹性扩容
  • 怎么监控告警和故障恢复——分布式链路追踪

分布式存储如何内部协调

  • 首先,理解 ACID、CAP、BASE
  • 然后,确定分片策略,常见方案有 Hash、Region 分片
  • 接着,确定复制方案,常见方案有:
    • 中心化方案:主从复制、一致性协议,比如 Raft 和 Paxos 等
    • 去中心化方案: Quorum 和 Vector Clock
  • 最后,如何处理分布式事务
    • 分布式 ID
    • 2PC、3PC 等分布式事务方案

新的挑战:分布式系统是银弹吗?我看未必!

  • 故障处理
  • 网络不可靠——超时处理
  • 时间不可靠——NTP、逻辑时钟
  • 共识协同——共识性算法

CAP 理论:分布式场景下我们真的只能三选二吗?

在一个分布式系统中,当发生网络分区时,那么强一致性和可用性只能二选一

注册发现: AP 系统和 CP 系统哪个更合适?

服务注册的关键:

  • 统一的中介存储:调用方在唯一的地方获得被调用服务的所有实例的信息。
  • 状态更新与通知:服务实例的信息能够及时更新并且通知到服务调用方。

注册中心的特性要求:

  • 可用性要求非常高:因为服务注册发现是整个分布式系统的基石,如果它出现问题,整个分布式系统将不可用。
  • 性能要求中等:只要设计得当,整体的性能要求还是可控的,不过需要注意的是性能要求会随分布式系统的实例数量变多而提高。
  • 数据容量要求低:因为主要是存储实例的 IP 和 Port 等元数据,单个实例存储的数据量非常小。
  • API 友好程度:是否能很好支持服务注册发现场景的“发布/订阅”模式,将被调用服务实例的 IP 和 Port 信息同步给调用方。

注册中心选择 AP 还是 CP:

因为服务发现是整个分布式系统的基石,所以可用性是最关键的设计目标。

负载均衡:从状态的角度重新思考负载均衡

负载均衡策略:

  • 轮询
  • 随机
  • 加权轮询/随机
  • 最少连接/请求
  • 最少响应时间
  • Hash
  • 一致性 Hash
  • 虚拟一致性 Hash

配置中心:如何确保配置的强一致性呢?

配置中心的关键挑战:

  • 统一的配置存储:一个带版本管理的存储系统,按服务的维度,存储和管理整个分布式系统的配置信息,这样可以很方便地对服务的配置信息,进行搜索、查询和修改。
  • 配置信息的同步:所有的实例,本地都不存储配置信息,实例能够从配置中心获得服务的配置信息,在配置修改后,能够及时将最新的配置,同步给服务的每一个实例。

配置中心特性要求:

  • 可用性要求非常高
  • 性能要求中等
  • 数据容量要求低
  • API 友好程度

分布式锁:所有的分布式锁都是错误的?

重试幂等:让程序 Exactly-once 很难吗?

在分布式系统中,程序不能保证 Exactly-once:响应超时的情况下,请求方无法判断接收方是否处理过这个请求。过程中有可能出现网络丢包问题或服务端故障。

幂等设计要点:

  • 使用唯一性 ID 来标记请求,通过 ID 进行去重
  • 保存状态快照+回滚模式——代价太高,一般不会用

雪崩(一):熔断,让故障自适应地恢复

在服务调用链中,服务调用时由于某一个服务故障,导致级联服务故障,并逐步扩散引起大范围服务故障的现象,称为雪崩效应。

在熔断机制的模式下,服务调用方需要为每一个调用对象,可以是服务、实例和接口,维护一个状态机,在这个状态机中有三种状态。

首先,是闭合状态( Closed )。在这种状态下,我们需要一个计数器来记录调用失败的次数和总的请求次数,如果在一个时间窗口内,请求的特定错误码的比例达到预设的阈值,就切换到断开状态。

其次,是断开状态( Open )。在该状态下,发起请求时会立即返回错误,也可以返回一个降级的结果,我们会在后面的课程“降级”中再详细讨论。在断开状态下,会启动一个超时计时器,当计时器超时后,状态切换到半打开状态。

最后,是半打开状态( Half-Open )。在该状态下,允许应用程序将一定数量的请求发往被调用服务,如果这些调用正常,那么就可以认为被调用服务已经恢复正常,此时熔断器切换到闭合状态,同时需要重置计数。如果这部分仍有调用失败的情况,我们就认为被调用方仍然没有恢复,熔断器会切换到断开状态,然后重置计数器。所以半打开状态能够有效防止正在恢复中的服务,被突然出现的大量请求再次打垮的情况。

雪崩(二):限流,抛弃超过设计容量的请求

常见限流算法

  • 固定窗口限流
  • 滑动窗口限流
  • 漏桶限流
  • 令牌桶限流

雪崩(三):降级,无奈的丢车保帅之举

降级机制能从全局角度对资源进行调配,通过牺牲非核心服务来保障核心服务的稳定性

如何实现降级:

  • 手动降级
  • 自动降级:当系统的某些指标或接口调用出现错误时,直接启动降级逻辑

雪崩(四):扩容,没有用钱解决不了的问题

如何实现动态扩容

通过可观测性系统监控核心指标

过载判断 - 一旦核心指标达到阈值,触发扩容

自动扩容 - 利用 K8S 进行容器化扩容

可观测性(一):如何监控一个复杂的分布式系统?

搭建一个可观测性平台,主要通过对日志( Logs )、链路( Traces )与指标( Metrics )这三类数据进行采集、计算和展示。

  • 日志信息( Logs ) - 代表:ELK
  • 追踪链路( Traces ) - 代表:Jaeger、Zipkin、SkyWalking
  • 指标信息( Metrics ) - 代表:Prometheus + Grafana

四个黄金指标:延迟、流量、错误和饱和度

可观测性(二):如何设计一个高效的告警系统?

告警系统的评价指标:

  • 信噪比:指有效告警通知数和无效告警通知数的比例,信噪比越高越好,是用来评估“多报”问题的。
  • 覆盖率:指被告警系统通知的故障占全部线上故障的比例,同样,覆盖率也是越高越好,是用来评估“漏报”问题的。
  • 转交率:指被转交的告警通知数占全部告警通知数的比例,转交率越低越好,是用来评估“对比人”问题的。

故障(一):预案管理竟然能让被动故障自动恢复?

故障评价标准:

  • 平均出现故障的频率:指平均多少时间出现一次故障,这个频率越低越好。
  • 平均故障恢复的时间:指出现故障后,系统在多长时间恢复到正常状态,这个时间越短越好,并且,我认为这是一个更关键的指标。

被动故障的来源:

  • DNS 解析问题:用户本地网络的 DNS 服务不能将我们的域名正确解析到 IP 地址。

  • 网络连通性问题:用户已经解析到正确的 IP 地址,但是从用户网络到我们服务器的 IP 地址之间的网络慢或者不通。

  • 系统内部的硬件设施故障:比如机器突然宕机,内部网线中断等。

  • 系统依赖的各种第三方服务:比如 CDN 服务、短信网关、语音识别等第三方服务故障。

故障(二):变更管理,解决主动故障的高效思维方式

主动故障的来源:

  • 程序发布变更:指服务器、App 和 Web 等发布了新版本的程序和服务。
  • 实例数目变更:指服务器新增实例和下线实例。
  • 配置发布变更:指发布了新版本的配置。
  • 运营策略变更:指举办了导致用户流量增长的运营活动,比如购买了新的推广广告等。

分片(一):如何选择最适合的水平分片方式?

分片(二):垂直分片和混合分片的 trade-off

复制(一):主从复制从副本的数据可以读吗?

复制的三种方案:

  • 主从复制:整个系统中只有一个主副本,其他的都为从副本。
  • 多主复制:系统中存在多个主副本,客户端将写请求发送给其中的一个主副本,该主副本负责将数据变更发送到其他所有的主副本。
  • 无主复制:系统中不存在主副本,每一个副本都能接受客户端的写请求,接受写请求的副本不会将数据变更同步到其他的副本。

Mysql、PostgreSql、Redis、MongoDB、Kafka 都支持主从复制。

主从复制的关键在于采用同步复制还是异步复制。

复制(二):多主复制的多主副本同时修改了怎么办?

为什么需要多主复制——为了提供更好的容灾能力,需要多机房、多数据中心来进行冗余,这就需要多主复制或无主复制。

如何实现多主复制

  • 首先,每一个主从复制单元内部是一个常规的主从复制模式,这里的主副本、从副本之间的复制可以是同步的,也可以是异步的。
  • 其次,多个主从复制单元之间,每一个主副本都会将自己的修改复制到其他的主副本,主副本之间的复制可以是同步的,也可以是异步的。

问题:

同步会导致整个模式退化为主从复制的形式。

异步模式的多主复制会存在数据一致性的问题。

如何解决冲突

写入冲突是由于多个主副本同时接受写入,并且主副本之间异步复制导致的。

注:文中并未给出完整的解决方案。

复制(三):最早的数据复制方式竟然是无主复制?

无主复制由于写入不依赖主节点,所以在主节点故障时,不会出现不可用的情况。但是,也是由于写入不依赖主节点,可能导致副本之间的写入顺序不相同,会影响数据的一致性。

在实现无主复制时,有两个关键问题:数据读写和数据修复。数据读写是通过仲裁条件 w + r > n 来保证的,如果满足 w + r > n ,那么读副本和写副本之间就一定有交集,即一定能读取到最新的写入。而数据修复是通过读修复和反熵过程实现的,这两个方法在数据的持久性和一致性方面存在一定的问题,如果对数据有强一致性的要求,就要谨慎采用无主复制。

然后,我们了解了 Sloppy Quorum ,它相比于传统的 Quorum ,为了系统的可用性而牺牲了数据的一致性,这里我们可以进一步得出,无主复制是一个可用性优先的复制模型

事务(一):一致性,事务的集大成者

事务是一个或多个操作的组合操作,它需要保证这组操作要么都执行,要么都不执行。

事务(二):原子性,对应用层提供的完美抽象

简单介绍了 2PC

事务(三):隔离性,正确与性能之间权衡的艺术

简单介绍了事务隔离级别

事务(四):持久性,吃一碗粉就付一碗粉的钱

简单介绍了 Redo Log + WAL

一致性与共识(一):数据一致性都有哪些级别?

按照一致性强度由高到低,有以下模型:

线性一致性——现在可以实现的一致性级别最强的是线性一致性,它是指所有进程看到的事件历史一致有序,并符合时间先后顺序, 单个进程遵守 program order,并且有 total order。

顺序一致性——它是指所有进程看到的事件历史一致有序,但不需要符合时间先后顺序, 单个进程遵守 program order,也有 total order。

因果一致性——它是指所有进程看到的因果事件历史一致有序,单个进程遵守 program order,不对没有因果关系的并发排序。

最终一致性——它是指所有进程互相看到的写无序,但最终一致。不对跨进程的消息排序。

一致性与共识(二):它们是鸡生蛋还是蛋生鸡?

一致性与共识(三):共识与事务之间道不明的关系

分布式计算技术的发展史:从单进程服务到 Service Mesh

分布式存储技术的发展史:从 ACID 到 NewSQL

春节加餐 技术债如房贷,是否借贷怎样取舍?

春节加餐 深入聊一聊计算机系统的时间

春节加餐 系统性思维,高效学习和工作的利器

结束语 在分布式技术的大潮流中自由冲浪吧!

参考资料

逻辑时钟

什么是逻辑时钟

1978 年,Lamport 在 Time, Clocks, and the Ordering of Events in a Distributed System 中提出了逻辑时钟的概念,来解决分布式系统中区分事件发生的时序问题。

逻辑时钟指的是分布式系统中用于区分事件的发生顺序的时间机制

为什么需要逻辑时钟

对于程序来说,时间维度非常重要,很多业务逻辑都依赖于时间。常见的场景有:

  • 某个请求是否超时了?
  • 某项服务 P99 的响应时间是多少?
  • 在过去五分钟,服务平均每秒处理多少个查询?
  • 用户在我们的网站上浏览花了多段时间?
  • 这篇文章什么时候发表?
  • 在什么时间发送提醒邮件?
  • 这个缓存条目何时过期?
  • 日志文件中错误消息的时间戳是多少?

分布式系统,意味着整个系统中有多个节点。为了让多节点的系统时间保持同步,需要有一个对表机制,来保证各节点的时间一致。一种常见方法是使用 NTP,它的工作机制是使用专门的高精度时间服务器来作为基准,调整服务器的本地时间。即使使用了 NTP,也难免存在微小的误差,在有些场景中(如金融)是不能接受的。

在分布式系统中,由于跨节点通信不可能即时完成,因此在多节点上难以确定事件的先后顺序。而逻辑时钟就是一种定义时序先后顺序的方案。

全序和偏序

全序和偏序是集合论中的概念,用于描述集合中元素之间的关系。

什么是偏序

偏序是指集合中的元素之间存在一种关系,使得任意两个元素之间可能存在比较,但不一定所有元素都可以相互比较。这种关系不一定是传递的或者反对称的。例如,集合中的子集关系就是一个偏序关系,因为不是所有的子集都可以相互比较。

设 R 是集合 A 上的一个二元关系,若 R 满足:

(1)自反性:对任意 x∈A,有 xRx

(2)反对称性(即反对称关系):对任意 x,y∈A,若 xRy,且 yRx,则 x=y

(3)传递性:对任意 x,y,z∈A,若 xRy,且 yRz,则 xRz

则称 R 为 A 上的偏序关系。

什么是全序

全序是指集合中的元素之间存在一种关系,使得任意两个元素都可以进行比较,且这种比较关系是传递的,反对称的。换句话说,任意两个元素都可以比较大小,并且不会出现无法比较的情况。例如,实数集合上的小于等于关系就是一个全序关系。

设集合 X 上有一全序关系,如果我们把这种关系用 ≤ 表述,则下列陈述对于 X 中的所有 a, b 和 c 成立:

如果 a ≤ b 且 b ≤ a 则 a = b(反对称性

如果 a ≤ b 且 b ≤ c 则 a ≤ c(传递性

a ≤ b 或 b ≤ a (完全性)

注意

完全性本身也包括了自反性。 所以,全序关系必是偏序关系

时序的关键

两个事件可以建立因果(时序)关系的前提是:两个事件之间是否发生过信息传递。在分布式系统中,进程间通信的手段(共享内存、消息发送等)都属于信息传递,如果两个进程间没有任何交互,实际上他们之间内部事件的时序也无关紧要。但是有交互的情况下,特别是多个节点的要保持同一副本的情况下,事件的时序非常重要。

逻辑时钟

分布式系统中按是否存在节点交互可分为三类事件,一类发生于节点内部,二是发送事件,三是接收事件。Lamport 时间戳原理如下:

Lamport timestamps space time (图片来源: wikipedia)_

  1. 每个事件对应一个 Lamport 时间戳,初始值为 0
  2. 如果事件在节点内发生,时间戳加 1
  3. 如果事件属于发送事件,时间戳加 1 并在消息中带上该时间戳
  4. 如果事件属于接收事件,时间戳 = Max(本地时间戳,消息中的时间戳) + 1

假设有事件 a、b,C(a)、C(b)分别表示事件 a、b 对应的 Lamport 时间戳,如果 a->b,则 C(a) < C(b),a 发生在 b 之前(happened before),例如图 1 中有 C1 -> B1。通过该定义,事件集中 Lamport 时间戳不等的事件可进行比较,我们获得事件的偏序关系(partial order)。

如果 C(a) = C(b),那 a、b 事件的顺序又是怎样的?假设 a、b 分别在节点 P、Q 上发生,Pi、Qj 分别表示我们给 P、Q 的编号,如果 C(a) = C(b) 并且 Pi < Qj,同样定义为 a 发生在 b 之前,记作 a => b。假如我们对图 1 的 A、B、C 分别编号 Ai = 1、Bj = 2、Ck = 3,因 C(B4) = C(C3) 并且 Bj < Ck,则 B4 => C3。

通过以上定义,我们可以对所有事件排序、获得事件的全序关系(total order)。上图例子,我们可以从 C1 到 A4 进行排序。

向量时钟

Lamport 时间戳帮助我们得到事件顺序关系,但还有一种顺序关系不能用 Lamport 时间戳很好地表示出来,那就是同时发生关系(concurrent)(4)。例如图 1 中事件 B4 和事件 C3 没有因果关系,属于同时发生事件,但 Lamport 时间戳定义两者有先后顺序。

Vector clock 是在 Lamport 时间戳基础上演进的另一种逻辑时钟方法,它通过 vector 结构不但记录本节点的 Lamport 时间戳,同时也记录了其他节点的 Lamport 时间戳(5)(6)。Vector clock 的原理与 Lamport 时间戳类似,使用图例如下:

Vector clock space time (图片来源: wikipedia)

假设有事件 a、b 分别在节点 P、Q 上发生,Vector clock 分别为 Ta、Tb,如果 Tb[Q] > Ta[Q] 并且 Tb[P] >= Ta[P],则 a 发生于 b 之前,记作 a -> b。到目前为止还和 Lamport 时间戳差别不大,那 Vector clock 怎么判别同时发生关系呢?

如果 Tb[Q] > Ta[Q] 并且 Tb[P] < Ta[P],则认为 a、b 同时发生,记作 a <-> b。例如图 2 中节点 B 上的第 4 个事件 (A:2,B:4,C:1) 与节点 C 上的第 2 个事件 (B:3,C:2) 没有因果关系、属于同时发生事件。

版本向量时钟

基于 Vector clock 我们可以获得任意两个事件的顺序关系,结果或为先后顺序或为同时发生,识别事件顺序在工程实践中有很重要的引申应用,最常见的应用是发现数据冲突(detect conflict)。

分布式系统中数据一般存在多个副本(replication),多个副本可能被同时更新,这会引起副本间数据不一致,Version vector 的实现与 Vector clock 非常类似,目的用于发现数据冲突。下面通过一个例子说明 Version vector 的用法:

Version Vector Clock

  • client 端写入数据,该请求被 Sx 处理并创建相应的 vector ([Sx, 1]),记为数据 D1
  • 第 2 次请求也被 Sx 处理,数据修改为 D2,vector 修改为([Sx, 2])
  • 第 3、第 4 次请求分别被 Sy、Sz 处理,client 端先读取到 D2,然后 D3、D4 被写入 Sy、Sz
  • 第 5 次更新时 client 端读取到 D2、D3 和 D4 3 个数据版本,通过类似 Vector clock 判断同时发生关系的方法可判断 D3、D4 存在数据冲突,最终通过一定方法解决数据冲突并写入 D5

Vector clock 只用于发现数据冲突,不能解决数据冲突。如何解决数据冲突因场景而异,具体方法有以最后更新为准(last write win),或将冲突的数据交给 client 由 client 端决定如何处理,或通过 quorum 决议事先避免数据冲突的情况发生(11)。

由于记录了所有数据在所有节点上的逻辑时钟信息,Vector clock 和 Version vector 在实际应用中可能面临的一个问题是 vector 过大,用于数据管理的元数据(meta data)甚至大于数据本(12)。

解决该问题的方法是使用 server id 取代 client id 创建 vector (因为 server 的数量相对 client 稳定),或设定最大的 size、如果超过该 size 值则淘汰最旧的 vector 信息(10)(13)。

参考资料

ZAB 协议

ZooKeeper 并没有直接采用 Paxos 算法,而是采用了名为 ZAB 的一致性协议。**ZAB 协议不是 Paxos 算法**,只是比较类似,二者在操作上并不相同。Multi-Paxos 实现的是一系列值的共识,不关心最终达成共识的值是什么,不关心各值的顺序。而 ZooKeeper 需要确保操作的顺序性。

ZAB 协议是 Zookeeper 专门设计的一种支持故障恢复的原子广播协议

ZAB 协议是 ZooKeeper 的数据一致性和高可用解决方案。

ZAB 协议定义了两个可以无限循环的流程:

  • 选举 Leader - 用于故障恢复,从而保证高可用。
  • 原子广播 - 用于主从同步,从而保证数据一致性。

选举 Leader

故障恢复

ZooKeeper 集群采用一主(称为 Leader)多从(称为 Follower)模式,主从节点通过副本机制保证数据一致。

  • 如果 Follower 节点挂了 - ZooKeeper 集群中的每个节点都会单独在内存中维护自身的状态,并且各节点之间都保持着通讯,只要集群中有半数机器能够正常工作,那么整个集群就可以正常提供服务
  • 如果 Leader 节点挂了 - 如果 Leader 节点挂了,系统就不能正常工作了。此时,需要通过 ZAB 协议的选举 Leader 机制来进行故障恢复。

ZAB 协议的选举 Leader 机制简单来说,就是:基于过半选举机制产生新的 Leader,之后其他机器将从新的 Leader 上同步状态,当有过半机器完成状态同步后,就退出选举 Leader 模式,进入原子广播模式。

术语

  • myid - 每个 Zookeeper 服务器,都需要在数据文件夹下创建一个名为 myid 的文件,该文件包含整个 Zookeeper 集群唯一的 ID(整数)。
  • zxid - 类似于 RDBMS 中的事务 ID,用于标识一次更新操作的 Proposal ID。为了保证顺序性,该 zxid 必须单调递增。因此 Zookeeper 使用一个 64 位的数来表示,高 32 位是 Leader 的 epoch,从 1 开始,每次选出新的 Leader,epoch 加一。低 32 位为该 epoch 内的序号,每次 epoch 变化,都将低 32 位的序号重置。这样保证了 zxid 的全局递增性。

服务器状态

  • LOOKING - 不确定 Leader 状态。该状态下的服务器认为当前集群中没有 Leader,会发起 Leader 选举
  • FOLLOWING - 跟随者状态。表明当前服务器角色是 Follower,并且它知道 Leader 是谁
  • LEADING - 领导者状态。表明当前服务器角色是 Leader,它会维护与 Follower 间的心跳
  • OBSERVING - 观察者状态。表明当前服务器角色是 Observer,与 Folower 唯一的不同在于不参与选举,也不参与集群写操作时的投票

选票数据结构

每个服务器在进行领导选举时,会发送如下关键信息

  • logicClock - 每个服务器会维护一个自增的整数,名为 logicClock,它表示这是该服务器发起的第多少轮投票
  • state - 当前服务器的状态
  • self_id - 当前服务器的 myid
  • self_zxid - 当前服务器上所保存的数据的最大 zxid
  • vote_id - 被推举的服务器的 myid
  • vote_zxid - 被推举的服务器上所保存的数据的最大 zxid

投票流程

(1)自增选举轮次 - Zookeeper 规定所有有效的投票都必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的 logicClock 进行自增操作。

(2)初始化选票 - 每个服务器在广播自己的选票前,会将自己的投票箱清空。该投票箱记录了所收到的选票。例:服务器 2 投票给服务器 3,服务器 3 投票给服务器 1,则服务器 1 的投票箱为(2, 3), (3, 1), (1, 1)。票箱中只会记录每一投票者的最后一票,如投票者更新自己的选票,则其它服务器收到该新选票后会在自己票箱中更新该服务器的选票。

(3)发送初始化选票 - 每个服务器最开始都是通过广播把票投给自己。

(4)接收外部投票 - 服务器会尝试从其它服务器获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其它服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。

(5)判断选举轮次 - 收到外部投票后,首先会根据投票信息中所包含的 logicClock 来进行不同处理

  • 外部投票的 logicClock 大于自己的 logicClock。说明该服务器的选举轮次落后于其它服务器的选举轮次,立即清空自己的投票箱并将自己的 logicClock 更新为收到的 logicClock,然后再对比自己之前的投票与收到的投票以确定是否需要变更自己的投票,最终再次将自己的投票广播出去。
  • 外部投票的 logicClock 小于自己的 logicClock。当前服务器直接忽略该投票,继续处理下一个投票。
  • 外部投票的 logickClock 与自己的相等。当时进行选票 PK。

(6)选票 PK - 选票 PK 是基于(self_id, self_zxid)(vote_id, vote_zxid) 的对比

  • 外部投票的 logicClock 大于自己的 logicClock,则将自己的 logicClock 及自己的选票的 logicClock 变更为收到的 logicClock
  • 若 logicClock 一致,则对比二者的 vote_zxid,若外部投票的 vote_zxid 比较大,则将自己的票中的 vote_zxid 与 vote_myid 更新为收到的票中的 vote_zxid 与 vote_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。如果票箱内已存在(self_myid, self_zxid)相同的选票,则直接覆盖
  • 若二者 vote_zxid 一致,则比较二者的 vote_myid,若外部投票的 vote_myid 比较大,则将自己的票中的 vote_myid 更新为收到的票中的 vote_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱

(7)统计选票 - 如果已经确定有过半服务器认可了自己的投票(可能是更新后的投票),则终止投票。否则继续接收其它服务器的投票。

(8)更新服务器状态 - 投票终止后,服务器开始更新自身状态。若过半的票投给了自己,则将自己的服务器状态更新为 LEADING,否则将自己的状态更新为 FOLLOWING

通过以上流程分析,我们不难看出:要使 Leader 获得多数 Server 的支持,则 ZooKeeper 集群节点数必须是奇数。且存活的节点数目不得少于 N + 1

每个 Server 启动后都会重复以上流程。在恢复模式下,如果是刚从崩溃状态恢复的或者刚启动的 server 还会从磁盘快照中恢复数据和会话信息,zk 会记录事务日志并定期进行快照,方便在恢复时进行状态恢复。

原子广播(Atomic Broadcast)

ZooKeeper 通过副本机制来实现高可用

那么,ZooKeeper 是如何实现副本机制的呢?答案是:ZAB 协议的原子广播。

ZAB 协议的原子广播要求:

**所有的写请求都会被转发给 Leader,Leader 会以原子广播的方式通知 Follow。当半数以上的 Follow 已经更新状态持久化后,Leader 才会提交这个更新,然后客户端才会收到一个更新成功的响应**。这有些类似数据库中的两阶段提交协议。

在整个消息的广播过程中,Leader 服务器会每个事务请求生成对应的 Proposal,并为其分配一个全局唯一的递增的事务 ID(ZXID),之后再对其进行广播。

ZAB 是通过“一切以领导者为准”的强领导者模型和严格按照顺序提交日志,来实现操作的顺序性的,这一点和 Raft 是一样的。

参考资料

初识 Python

Python 简介

Python 是一种广泛使用的解释型、高级和通用的编程语言。Python 支持多种编程范型,包括结构化、过程式、反射式、面向对象和函数式编程。它拥有动态类型系统和垃圾回收功能,能够自动管理内存使用,并且其本身拥有一个巨大而广泛的标准库。

Python 历史

1991 年,Python 的第一个解释器诞生。

1994 年,Python 1.0 版本发布。它包含了异常处理、函数和模块等基本特性。

2000 年,Python 2.0 版本发布。它引入了新的特性,如列表推导式、垃圾回收机制等。

2008 年,Python 3.0 版本发布。它进行了重大修订而不能完全后向兼容。

2020 年,Python 2.0 停止更新。

Python 应用

Python 在以下领域都有用武之地。

  • 后端开发 - Python / Java / Go / PHP
  • DevOps - Python / Shell / Ruby
  • 数据采集 - Python / C++ / Java
  • 量化交易 - Python / C++ / R
  • 数据科学 - Python / R / Julia / Matlab
  • 机器学习 - Python / R / C++ / Julia
  • 自动化测试 - Python / Shell

Python 开发环境

目前,Python 有两个版本,一个是 2.x 版,一个是 3.x 版,这两个版本是不兼容的。由于 3.x 版本越来越普及,所以推荐安装 3.x 版本。

安装 Python

Linux

Linux 环境自带了 Python 2.x 版本,但是如果要更新到 3.x 的版本,可以在 Python 官网 下载 Python 的源代码并通过源代码构建安装的方式进行安装,具体的步骤如下所示(以 CentOS 为例):

(1)安装依赖库(因为没有这些依赖库可能在源代码构件安装时因为缺失底层依赖库而失败)。

1
yum -y install wget gcc zlib-devel bzip2-devel openssl-devel ncurses-devel sqlite-devel readline-devel tk-devel gdbm-devel db4-devel libpcap-devel xz-devel libffi-devel

(2)下载 Python 源代码并解压缩到指定目录。

1
2
3
wget https://www.python.org/ftp/python/3.7.6/Python-3.7.6.tar.xz
xz -d Python-3.7.6.tar.xz
tar -xvf Python-3.7.6.tar

(3)切换至 Python 源代码目录并执行下面的命令进行配置和安装。

1
2
3
cd Python-3.7.6
./configure --prefix=/usr/local/python37 --enable-optimizations
make && make install

(4)修改 .bash_profile 文件

1
2
cd ~
vim .bash_profile

配置 PATH 环境变量并使其生效

1
2
3
4
5
# ... 此处省略上面的代码 ...

export PATH=$PATH:/usr/local/python37/bin

# ... 此处省略下面的代码 ...

(5)激活环境变量

1
source .bash_profile

Mac

Mac 系统自带的 Python 版本是 2.7。要安装 Python 3.x,有两个方法:

方法一、从 Python 官网 下载 Python 的安装程序,下载后双击运行并安装。

方法二、如果安装了 Homebrew,直接通过命令 brew install python3 安装即可。

Windows

Python 官网 下载合适的 Windows 安装版本(64 位还是 32 位),下载后双击运行并安装。

注:要勾选 Add Python 3.x to PATH 选项,将安装路径自动添加到环境变量,否则需要自行配置。

运行 Python

执行以下命令可以检查 python 版本:

1
python --version

直接执行 python 命令可以进入交互式环境。

第一个程序

新建一个 hello.py 文件,内容如下:

1
print('hello world')

在终端执行如下命令:

1
python hello.py

打印如下内容

1
hello world

Python 开发工具

PyCharm

PyCharm 是由 JetBrains 打造的一款 Python IDE,支持 macOS、 Windows、 Linux 系统。

我认为,PyCharm 是最好用的 Python IDE,功能丰富,UI 很酷,缺点是正版比较贵。

VSCode

VSCode(全称:Visual Studio Code)是一款由微软开发且跨平台的免费源代码编辑器,VSCode 开发环境非常简单易用。

pip

pip 是 Python 包管理工具,该工具提供了对 Python 包的查找、下载、安装、卸载的功能。

目前最新的 Python 版本已经预装了 pip。

查看是否已经安装 pip,可以使用以下命令:

1
pip --version

下载安装包,可以使用以下命令:

1
pip install some-package-name

卸载安装包,可以使用以下命令:

1
pip uninstall some-package-name

查看已安装的包,可以使用以下命令:

1
pip list

IPython

IPython 是一种基于 Python 的交互式解释器。相较于原生的 Python 交互式环境,IPython 提供了更为强大的编辑和交互功能。可以通过 Python 的包管理工具 pip 安装 IPython,具体的操作如下所示。

1
pip install ipython

1
pip3 install ipython

Anaconda

Anaconda 是一个集成的数据科学和机器学习环境,其中包括了 Python 解释器以及大量常用的数据科学库和工具。Anaconda 发行版包含了 Python。

Anaconda 包及其依赖项和环境的管理工具为 conda 命令,与传统的 Python pip 工具相比 Anaconda 的conda 可以更方便地在不同环境之间进行切换,环境管理较为简单。

Anaconda详细安装与介绍参考:Anaconda 教程。

参考资料

Python 基础语法

编码

默认情况下,Python 3 源码文件以 UTF-8 编码,所有字符串都是 unicode 字符串。 当然你也可以为源码文件指定不同的编码:

1
# -*- coding: cp-1252 -*-

注释

Python 中的注释有三种形式:

  • 单行注释# 开头
  • 多行注释可以用 '''""" 标记开始和结尾
1
2
3
4
5
6
7
8
9
10
11
12
13
# 单行注释

'''
这是多行注释,用三个单引号
这是多行注释,用三个单引号
这是多行注释,用三个单引号
'''

"""
这是多行注释,用三个双引号
这是多行注释,用三个双引号
这是多行注释,用三个双引号
"""

保留字

Python 保留字意味着,不能将这些关键字用作任何标识符名称。

Python 的标准库提供了一个 keyword 模块,可以输出当前版本的所有关键字:

1
2
3
>>> import keyword
>>> keyword.kwlist
['False', 'None', 'True', 'and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']

变量

Python 中的变量不需要声明。每个变量在使用前都必须赋值,变量赋值以后该变量才会被创建。

Python 基本赋值

1
2
3
4
5
6
7
8
9
10
a = 1
b = 2.0
c = "test"
print(f'a={a}')
print(f'b={b}')
print(f'c={c}')
# 输出
# a=1
# b=2.0
# c=test

数据类型

Python3 中有六个标准的数据类型:

  • 不可变数据(3 个):Number(数字)、String(字符串)、Tuple(元组);
  • 可变数据(3 个):List(列表)、Dictionary(字典)、Set(集合)。

操作符

Python 语言支持以下类型的运算符:

  • 算术运算符
  • 比较(关系)运算符
  • 赋值运算符
  • 逻辑运算符
  • 位运算符
  • 成员运算符
  • 身份运算符
  • 运算符优先级

语句

python 最具特色的就是使用缩进来表示代码块,不需要使用大括号 {}

缩进的空格数是可变的,但是同一个代码块的语句必须包含相同的缩进空格数。

1
2
3
4
if True:
print ("True")
else:
print ("False")

以下代码最后一行语句缩进数的空格数不一致,会导致运行错误:

1
2
3
4
5
6
if True:
print ("Answer")
print ("True")
else:
print ("Answer")
print ("False") # 缩进不一致,会导致运行错误

Python 通常是一行写完一条语句,但如果语句很长,我们可以使用反斜杠 \ 来实现多行语句,例如:

1
2
3
total = item_one + \
item_two + \
item_three

[], {}, 或 () 中的多行语句,不需要使用反斜杠 \ ,例如:

1
2
total = ['item_one', 'item_two', 'item_three',
'item_four', 'item_five']

ifwhiledefclass 这样的复合语句,首行以关键字开始,以冒号( : )结束,该行之后的一行或多行代码构成代码组。

我们将首行及后面的代码组称为一个子句(clause)。

同一行显示多条语句

Python 可以在同一行中使用多条语句,语句之间使用分号 ; 分割,以下是一个简单的实例:

1
2
3
import sys; x = 'test'; sys.stdout.write(x + '\n')
# 输出
# test

使用交互式命令行执行,输出结果为:

1
2
3
>>> import sys; x = 'test'; sys.stdout.write(x + '\n')
test
5

此处的 5 表示字符数,test 有 4 个字符,\n 表示一个字符,加起来 5 个字符。

1
2
3
>>> import sys
>>> sys.stdout.write(" hi ") # hi 前后各有 1 个空格
hi 4

多个语句构成代码组

缩进相同的一组语句构成一个代码块,我们称之代码组。

像 if、while、def 和 class 这样的复合语句,首行以关键字开始,以冒号( : )结束,该行之后的一行或多行代码构成代码组。

我们将首行及后面的代码组称为一个子句(clause)。

如下实例:

1
2
3
4
5
6
if expression :
suite
elif expression :
suite
else :
suite

控制语句

Python 支持三类控制语句:

  • 选择语句 - if...elif...elsematch...case(Python 3.10 新增)
  • 循环语句 - whilefor
  • 中断语句 - breakcontinuepass

函数

Python 定义函数使用 def 关键字。

语法格式如下:

1
2
def 函数名(参数列表):
# do something

函数示例:

1
2
3
4
5
6
# 函数定义
def hello():
print("hello world!")

# 函数调用
hello()

输入和输出

print 默认输出是换行的,如果要实现不换行需要在变量末尾加上 end=""

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
a = "a"
b = "b"
# 换行输出
print(a)
print(b)

print('---------')

# 不换行输出
print(a, end="")
print(b, end="")
print()

# 输出
# a
# b
# ---------
# ab

input 输入

执行下面的程序在按回车键后就会等待用户输入:

1
input("\n按下 enter 键后退出。\n")

以上代码中,一旦用户按下 enter 键时,程序将退出。

模块

在 python 用 import 或者 from...import 来导入相应的模块。

将整个模块(somemodule)导入,格式为: import somemodule

从某个模块中导入某个函数,格式为: from somemodule import somefunction

从某个模块中导入多个函数,格式为: from somemodule import firstfunc, secondfunc, thirdfunc

将某个模块中的全部函数导入,格式为: from somemodule import *

【示例】导入 sys 模块

1
2
3
4
5
6
7
import sys

print('================Python import mode==========================')
print('命令行参数为:')
for i in sys.argv:
print(i)
print('\n python 路径为', sys.path)

【示例】导入 sys 模块的 argv,path 成员

1
2
3
4
from sys import argv,path  #  导入特定的成员

print('================python from import===================================')
print('path:',path) # 因为已经导入path成员,所以此处引用时不需要加sys.path

命令行参数

很多程序可以执行一些操作来查看一些基本信息,Python 可以使用 -h 参数查看各参数帮助信息:

1
2
3
4
5
6
7
8
9
$ python -h
usage: python [option] ... [-c cmd | -m mod | file | -] [arg] ...
Options and arguments (and corresponding environment variables):
-c cmd : program passed in as string (terminates option list)
-d : debug output from parser (also PYTHONDEBUG=x)
-E : ignore environment variables (such as PYTHONPATH)
-h : print this help message and exit

[ etc. ]

我们在使用脚本形式执行 Python 时,可以接收命令行输入的参数,具体使用可以参照 Python 3 命令行参数

参考资料

Python 变量和数据类型

变量

变量简介

Python 中的变量不需要声明。每个变量在使用前都必须赋值,变量赋值以后该变量才会被创建。

Python 基本赋值

1
2
3
4
5
6
7
8
9
10
a = 1
b = 2.0
c = "test"
print(f'a={a}')
print(f'b={b}')
print(f'c={c}')
# 输出
# a=1
# b=2.0
# c=test

Python 允许多个变量同时赋值

1
2
3
4
5
6
7
8
a = b = c = 1
print(f'a={a}')
print(f'b={b}')
print(f'c={c}')
# 输出
# a=1
# b=1
# c=1

Python 允许为多个变量同时赋不同的值

1
2
3
4
5
6
7
8
a, b, c = 1, 2.0, "test"
print(f'a={a}')
print(f'b={b}')
print(f'c={c}')
# 输出
# a=1
# b=2.0
# c=test

变量命名规则

  • 第一个字符必须是字母表中字母或下划线 _
  • 标识符的其他的部分由字母、数字和下划线组成。
  • 标识符对大小写敏感。

在 Python 3 中,可以用中文作为变量名,非 ASCII 标识符也是允许的了。

数据类型

在 Python 中,变量就是变量,它没有类型,我们所说的”类型”是变量所指的内存中对象的类型。

Python3 中有六个标准的数据类型:

  • 不可变数据(3 个):Number(数字)、String(字符串)、Tuple(元组);
  • 可变数据(3 个):List(列表)、Dictionary(字典)、Set(集合)。

Python 内置的 type() 函数可以用来查询变量所指的对象类型

1
2
3
4
5
6
7
8
9
10
a, b, c, d = 1, 2.0, True, 3.14j
print(f'a={type(a)}')
print(f'b={type(b)}')
print(f'c={type(c)}')
print(f'd={type(d)}')
# 输出
# a=<class 'int'>
# b=<class 'float'>
# c=<class 'bool'>
# d=<class 'complex'>

Number

在 Python 中,Number 数据类型用于存储数值。

数据类型是不允许改变的,这就意味着如果改变 Number 数据类型的值,将重新分配内存空间。

Python 中数学运算常用的函数基本都在 math 模块、cmath 模块中。

Python math 模块提供了许多对浮点数的数学运算函数。

Python cmath 模块包含了一些用于复数运算的函数。

cmath 模块的函数跟 math 模块函数基本一致,区别是 cmath 模块运算的是复数,math 模块运算的是数学运算。

字符串

字符串是 Python 中最常用的数据类型。可以使用引号 ( ) 来创建字符串。

Python 中单引号 ' 和双引号 " 使用完全相同。

使用三引号('''""")可以指定一个多行字符串。

转义符 \

反斜杠可以用来转义,使用 r 可以让反斜杠不发生转义。 如 r”this is a line with \n”\n 会显示,并不是换行。

按字面意义级联字符串,如 “this “ “is “ “string” 会被自动转换为 this is string

字符串可以用 + 运算符连接在一起,用 ***** 运算符重复。

Python 中的字符串有两种索引方式,从左往右以 0 开始,从右往左以 -1 开始。

Python 中的字符串不能改变。

Python 没有单独的字符类型,一个字符就是长度为 1 的字符串。

字符串切片 str[start:end],其中 start 是切片开始的索引,end 是切片结束的索引(但不包括该索引指向的字符)。

字符串的切片可以加上步长参数 step,语法格式如下:str[start:end:step]

字符串的截取的语法格式如下:变量[头下标:尾下标:步长]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
str='123456789'

print(str) # 输出字符串
print(str[0:-1]) # 输出第一个到倒数第二个的所有字符
print(str[0]) # 输出字符串第一个字符
print(str[2:5]) # 输出从第三个开始到第六个的字符(不包含)
print(str[2:]) # 输出从第三个开始后的所有字符
print(str[1:5:2]) # 输出从第二个开始到第五个且每隔一个的字符(步长为2)
print(str * 2) # 输出字符串两次
print(str + '你好') # 连接字符串

print('------------------------------')

print('hello\nrunoob') # 使用反斜杠(\)+n转义特殊字符
print(r'hello\nrunoob') # 在字符串前面添加一个 r,表示原始字符串,不会发生转义

列表

序列是 Python 中最基本的数据结构。序列中的每个元素都分配一个数字 - 它的位置,或索引,第一个索引是 0,第二个索引是 1,依此类推。

Python 有 6 个序列的内置类型,但最常见的是列表和元组。

序列都可以进行的操作包括索引,切片,加,乘,检查成员。

列表的数据项不需要具有相同的类型。创建一个列表,只要把逗号分隔的不同的数据项使用方括号括起来即可。

1
2
3
list1 = ['physics', 'chemistry', 1997, 2000]
list2 = [1, 2, 3, 4, 5 ]
list3 = ["a", "b", "c", "d"]

元祖

Python 的元组与列表类似,不同之处在于元组的元素不能修改。

元组使用小括号,列表使用方括号。

元组创建很简单,只需要在括号中添加元素,并使用逗号隔开即可。

1
2
3
tup1 = ('physics', 'chemistry', 1997, 2000)
tup2 = (1, 2, 3, 4, 5 )
tup3 = "a", "b", "c", "d"

字典

字典是另一种可变容器模型,且可存储任意类型对象。

字典的每个键值 key:value 对用冒号 : 分割,每个键值对之间用逗号 , 分割,整个字典包括在花括号 {} 中。

1
2
3
tinydict = {'Alice': '2341', 'Beth': '9102', 'Cecil': '3258'}
tinydict1 = { 'abc': 456 }
tinydict2 = { 'abc': 123, 98.6: 37 }

数据类型转换

Python 数据类型转换可以分为两种:

  • 隐式类型转换
  • 显式类型转换

隐式类型转换示例

1
2
3
4
5
6
7
8
9
10
11
12
num_int = 1
num_float = 2.0
num_new = num_int + num_float
print("num_int 数据类型为:", type(num_int))
print("num_float 数据类型为:", type(num_float))
print("num_new 值为:", num_new)
print("num_new 数据类型为:", type(num_new))
# 输出
# num_int 数据类型为: <class 'int'>
# num_float 数据类型为: <class 'float'>
# num_new 值为: 3.0
# num_new 数据类型为: <class 'float'>

显示类型转换方法:

  • int() - 将指定的数值或字符串转换成整数,可以指定进制。
  • float() - 将指定的字符串转换成浮点数。
  • str() - 将指定的对象转换成字符串,可以指定编码。
  • chr() - 将指定的整数转换成该编码对应的字符。
  • ord() - 将指定的字符转换成对应的编码(整数)。
1
2
3
4
5
6
7
8
9
10
a = int("100")
b = float(2)
c = str(3.0)
print(f'a={a}, type={type(a)}')
print(f'b={b}, type={type(b)}')
print(f'c={c}, type={type(c)}')
# 输出
# a=100, type=<class 'int'>
# b=2.0, type=<class 'float'>
# c=3.0, type=<class 'str'>

参考资料

Python 操作符

Python 语言支持以下类型的运算符:

  • 算术运算符
  • 比较(关系)运算符
  • 赋值运算符
  • 逻辑运算符
  • 位运算符
  • 成员运算符
  • 身份运算符
  • 运算符优先级

算术运算符

假设变量: a=10,b=20

运算符 描述 实例
+ 加 - 两个对象相加 a + b 输出结果 30
- 减 - 得到负数或是一个数减去另一个数 a - b 输出结果 -10
* 乘 - 两个数相乘或是返回一个被重复若干次的字符串 a * b 输出结果 200
/ 除 - x 除以 y b / a 输出结果 2
% 取模 - 返回除法的余数 b % a 输出结果 0
** 幂 - 返回 x 的 y 次幂 a**b 为 10 的 20 次方, 输出结果 100000000000000000000
// 取整除 - 返回商的整数部分 9//2 输出结果 4 , 9.0//2.0 输出结果 4.0

比较运算符

假设变量: a=10,b=20

运算符 描述 实例
== 等于 - 比较对象是否相等 (a == b) 返回 False。
!= 不等于 - 比较两个对象是否不相等 (a != b) 返回 True。
<> 不等于 - 比较两个对象是否不相等。python3 已废弃。 (a <> b) 返回 True。这个运算符类似 != 。
> 大于 - 返回 x 是否大于 y (a > b) 返回 False。
< 小于 - 返回 x 是否小于 y。所有比较运算符返回 1 表示真,返回 0 表示假。这分别与特殊的变量 True 和 False 等价。 (a < b) 返回 True。
>= 大于等于 - 返回 x 是否大于等于 y。 (a >= b) 返回 False。
<= 小于等于 - 返回 x 是否小于等于 y。 (a <= b) 返回 True。

赋值运算符

假设变量: a=10,b=20

运算符 描述 实例
= 简单的赋值运算符 c = a + b 将 a + b 的运算结果赋值为 c
+= 加法赋值运算符 c += a 等效于 c = c + a
-= 减法赋值运算符 c -= a 等效于 c = c - a
*= 乘法赋值运算符 c *= a 等效于 c = c * a
/= 除法赋值运算符 c /= a 等效于 c = c / a
%= 取模赋值运算符 c %= a 等效于 c = c % a
**= 幂赋值运算符 c **= a 等效于 c = c ** a
//= 取整除赋值运算符 c //= a 等效于 c = c // a

位运算符

运算符 描述 实例
& 按位与运算符:参与运算的两个值,如果两个相应位都为 1,则该位的结果为 1,否则为 0 (a & b) 输出结果 12 ,二进制解释: 0000 1100
| 按位或运算符:只要对应的二个二进位有一个为 1 时,结果位就为 1。 (a | b) 输出结果 61 ,二进制解释: 0011 1101
^ 按位异或运算符:当两对应的二进位相异时,结果为 1 (a ^ b) 输出结果 49 ,二进制解释: 0011 0001
~ 按位取反运算符:对数据的每个二进制位取反,即把 1 变为 0,把 0 变为 1 (~a ) 输出结果 -61 ,二进制解释: 1100 0011, 在一个有符号二进制数的补码形式。
<< 左移动运算符:运算数的各二进位全部左移若干位,由”<<”右边的数指定移动的位数,高位丢弃,低位补 0。 a << 2 输出结果 240 ,二进制解释: 1111 0000
>> 右移动运算符:把”>>”左边的运算数的各二进位全部右移若干位,”>>”右边的数指定移动的位数 a >> 2 输出结果 15 ,二进制解释: 0000 1111

逻辑运算符

运算符 逻辑表达式 描述 实例
and x and y 布尔”与” - 如果 x 为 False,x and y 返回 False,否则它返回 y 的计算值。 (a and b) 返回 20。
or x or y 布尔”或” - 如果 x 是 True,它返回 x 的值,否则它返回 y 的计算值。 (a or b) 返回 10。
not not x 布尔”非” - 如果 x 为 True,返回 False 。如果 x 为 False,它返回 True。 not(a and b) 返回 False

成员运算符

运算符 描述 实例
in 如果在指定的序列中找到值返回 True,否则返回 False。 x 在 y 序列中 , 如果 x 在 y 序列中返回 True。
not in 如果在指定的序列中没有找到值返回 True,否则返回 False。 x 不在 y 序列中 , 如果 x 不在 y 序列中返回 True。

身份运算符

运算符 描述 实例
is is 是判断两个标识符是不是引用自一个对象 x is y, 如果 id(x) 等于 id(y) , is 返回结果 1
is not is not 是判断两个标识符是不是引用自不同对象 x is not y, 如果 id(x) 不等于 id(y). is not 返回结果 1

运算符优先级

运算符 描述
** 指数 (最高优先级)
~ + - 按位翻转, 一元加号和减号 (最后两个的方法名为 +@ 和 -@)
* / % // 乘,除,取模和取整除
+ - 加法减法
>> << 右移,左移运算符
& 位 ‘AND’
^ | 位运算符
<= < > >= 比较运算符
<> == != 等于运算符
= %= /= //= -= += *= **= 赋值运算符
is is not 身份运算符
in not in 成员运算符
not or and 逻辑运算符

参考资料