0%

集群相关

  • kubectl config get-contexts

    • 查看当前有哪些集群context可以使用
  • kubectl config use-context <context name>

    • 切换kubectl当前的集群context

路由

路由可以分为以下三种:

  • 主机路由:表示到某台具体主机的路由(目前来说很少使用了)

  • 网络路由:表达到某个网段的路由

  • 默认路由:即0.0.0.0/0对应的路由项,它用于在路由表中查不到匹配项时进行的默认路由

路由表的匹配采用最长匹配的原则

路由表不用记录同一网段中的其他主机的ip,同一网络中的主机通信直接通过数据链路层的ARP协议查询到IP地址对应的MAC地址后进行通信即可。

linux路由表组成部分

在linux中可以使用ip route命令来查看路由表。路由表项由以下几个部分组成:

  • destination:路由目标路径

  • interface:路由器的出口

  • gateway

    • 直连情况(即两个ip之间没有通过路由器相连):不需要配置gateway,或者值为0.0.0.0

    • 非直连情况:需要配置gateway,其值为下一个路由器在本网络中(当路由器对接多个网络时,会有多个网络地址)的ip地址

路由表配置

pod是k8s集群中的最小调度单元,一个pod中可以有多个容器。k8s引入pod,而不直接对容器进行调度的原因有如下两个:

  • 一个是为了将容器的实现和k8s平台自身引擎的实现进行解耦,从而做到可以支持多种类型的容器(docker、rkt)

  • 另外一个是可以让多个容器共享网络、存储、进程空间,减少资源消耗

使用yaml定义一个pod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apiVersion: v1
kind: pod
metadata:
name: #pod name
namespace: #命名空间,如果无该字段,则使用默认命名空间
labels: #pod标签,可选
key1: value1
key2: value2
spec:
volumes: # 挂载目录,有多种挂载方式
containers:
- name: #容器的名称
image: #容器使用的镜像
ports: # 容器访问
- containerPort:
env: # 容器环境变量
- name:
value:
- name:
value:

- name:
image:
ports:
- containerPort:
env:
- name:
value:
- name:
value:

定义好一个pod描述之后,就可以使用kubectl create -f xxx.yaml来创建一个pod

pod的常用操作

  • 查看pod被调度的节点已经pod ip

    1
    kubectl -n <namespace> get pod -o wide
  • 查看pod的配置

    1
    kubectl -n <namespace> get pod <pod name> -o <yaml|json>
  • 查看pod的信息及事件

    1
    kubectl -n <namespace> describe pod <pod name>
  • 进入pod内的容器

    1
    kubectl -n <namespace> exec <pod name> -c <container name> -it /bin/bash
  • 查看pod内容器日志,显示标准或者错误输出日志

    1
    kubectl -n <namespace> logs -f <pod name> -c <container name>
  • 更新pod

    1
    kubectl apply -f <pod yaml file> # 更新pod是使用更新yaml文件的形式
  • 删除pod

    1
    2
    kubectl delete -f <pod yaml file> # 根据配置文件删除
    kubectl -n <namespace> delete pod <pod name> # 根据pod name删除

Pod健康检查

pod的健康检查由kubelet来进行,pod健康检查有两种机制:

  • LivenessProbe:存活性探测,用于判断容器是否存活,即pod是否为running状态。如果LivenessProbe探针探测到容器不健康,则kubelet将kill掉容器,并根据容器的重启策略是否重启(如果不配置,默认会进行重启),如果一个容器不包含LivenessProbe探针,则kubelet认为容器的LivenessProbe探针的返回值永远成功,即任务容器是健康的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    ...
    containers:
    - name:
    image:
    livenessProbe:
    httpGet: #有三种类型:exec(执行脚本,脚本返回0表示健康)、
    #httpGet(返回200-399状态码表示健康)、
    #tcpSocket(如果能够建立TCP连接,则表示健康)
    path:
    port:
    scheme:
    initialDelaySeconds: # 容器启动后多少秒后第一次执行探测
    periodSeconds: # 多少秒执行一次探,默认10s,最小1s,
    timeoutSeconds: # 探测超时时间默认1s,最小1
    successThreshold: # 连续探测成功多少次才被认为是成功,默认为为1
    failureThreshold: # 连续探测失败多少次才被任务是失败,默认为3
  • ReadinessProbe:可用性探测,用于判断容器是否正常提供服务,即容器的Ready是否为True,是否可以接收请求。如果ReadinessProbe探测失败,则容器的ready将为False,Endpoint Controller控制器会将此Pod的Endpoint从对应的service的Endpoint列表中移除,不再将任何请求调度到此Pod上,直到下次探测成功。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ...
    containers:
    - name:
    image:
    readinessProbe:
    httpGet:
    path:
    port:
    scheme:
    initialDelaySeconds: # 容器启动后多少秒后第一次执行探测
    periodSeconds: # 多少秒执行一次探测
    timeoutSeconds: # 探测超时时间
    ...
对于同一个容器,两种健康检查可以同时设置。

重启策略

Pod的重启策略应用于Pod内的所有容器,并且仅在Pod所处的Node上由Kubelet进行判断和重启操作。当某个容器异常退出或者健康检查失败时,kubelet将根据RestartPolicy的设置来进行相应的操作。Pod的重启策略包括Always、OnFailure和Never,默认值为Always。 * Always:当容器进程退出后,由kubelet自动重启该容器 * OnFailure:当容器进程终止运行且退出码不为0时,由kubelet自动重启该容器 * Never:不论容器运行状态如何,kubelet都不会重启该容器
1
2
3
4
...
spec:
restartPolicy: OnFailure
containers:
# 镜像拉取策略 有如下几种镜像拉取策略: * Always:总是拉取镜像,即使本地有镜像也从镜像仓库拉取 * IfNotPresent:本地有则使用本地镜像,本地没有则去仓库拉取,默认的镜像拉取策略 * Never:只使用本地镜像,本地没有则报错
1
2
3
4
5
6
...
spec:
containers:
- name: myblog
image:
imagePullPolicy: IfNotPresent
# Pod资源限制 为了保证充分利用集群资源,且确保重要容器在运行周期内能够分配到足够的资源稳定运行,因此平台需要具备Pod的资源限制的能力。对于一个Pod来说,资源最基础的2个指标就是CPU和内存。资源限制有两种类型: * requests:容器使用的最小资源要求,作用于schedule阶段,作为容器调度时分配的判断依赖,只有当节点上可分配的资源量>=request时,才允许将容器调度到该节点。requests参数不限制容器的最大可用资源。requests.cpu被转成docker的--cpu-shares参数,与cgroup.cpu.shared功能相同。requests.memory没有对应的docker参数,仅作为k8s调度的依据 * limits:容器使用的最大资源限制,不设置或者设置为0表示对使用的资源不做限制。当pod内存超过limit时,会被oom,当cpu超过limit时,不会被kill,但会被限制不超过limit值(因为cpu的时间片是动态调度)。limits.cpu会被转换成docker的-cpu-quota参数,与cgroup.cpu.cfs\_quota_us功能相同。limits.memory会被转换成docker的-memory参数,用来限制容器使用的最大内存。
1
2
3
4
5
6
7
8
9
10
11
12
...
spec:
containers:
- name:
image:
resources:
requests:
memory: 100Mi
cpu: 500m # 等同于0.5
limits:
memory: 500Mi
cpu: 2
# configMap和secret configMap和Secret是k8s提供的两种资源类型,它们可以用来实现业务配置的统一管理。一般来说会使用configMap存储一些不包含敏感信息的基本配置,secret用于管理敏感信息配置,例如密码、密钥等。 使用yaml定义一个configMap资源:
1
2
3
4
5
6
7
8
apiVersion: v1
kind: ConfigMap
metadata:
name:
namespace:
data:
key1: value1
key2: value2
secret有三种类型: * Service Accout:用来访问k8s API,由k8s 自动创建,并且会自动挂载到pod的/run/secrets/kubernetes.io/serveraccount目录中; * Opaque:base64编码的Secret,用来存储密码,密钥等 * kubernetes.io/dockerconfigjson:用来存储私有docker registry的认证信息
1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Secret
metadata:
name:
namespace:
type: Opaque
data:
key1: base64_encoded_value1
key2: base64_encoded_value2
configMap和secret的使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spec:
containers:
- name:
image:
env:
- name: env_name_1
valueFrom:
secretKeyRef:
name: <secret resouce name>
key: <secret resouce key>
- name: env_name_2
valueFrom:
configMapKeyRef:
name: <config map resource name>
key: <config map resource key>
# pod生命周期 一个pod可能处于如下的几种状态 | 状态值 | 描述 | | ---------------------- | ---------------------------------------- | | Pending | API Server已经创建该Pod,等待调度器调度 | | ContainerCreating | 拉取镜像启动容器中 | | Runing | Pod内容器均已创建,且至少有一个容器处于运行状态、正在启动状态或者正在重启状态 | | Succeeded \| Completed | 容器内所有容器均已成功执行退出,且不再重启 | | Failed \| Error | Pod内所有容器均已退出,但至少有一个容器退出为失败状态 | | CrashLoopBackOff | Pod内容器启动失败,比如配置文件丢失导致进程启动失败 | | Unknown | 由于某种原因无法获取该Pod的状态,可能由于网络通信不畅导致 | pod支持两种hook,post-start hook 和pre-stop hook
1
2
3
4
5
6
7
8
9
10
11
12
...
spec:
containers:
- name:
image:
lifecycle:
postStart:
exec:
command:
preStop:
exec:
command:
pre-stop hook只有在手动kill掉pod才会触发,如果是Pod自己down掉,则不会触发pre-stop hook # Pod控制器(workload工作负载) workload是用于实现管理pod的中间层,确保pod资源符合预期的状态,pod的资源出现故障时,会尝试进行重启,当根据重启策略无效时,则会重新新建pod资源。workload有以下几种类型: * ReplicaSet:pod副本数量,确保pod副本数量符合预期状态,并且支持滚动式自动阔缩容 * Deployment:工作在ReplicaSet之上,用于管理无状态应用,目前来说最好的控制器,支持滚动更新和回滚功能 * DaemonSet:用于确保集群中的每一个节点只运行特定的pod服务,通常用于实现系统级后台服务 * Job:只要完成就立即退出,不需要重启或重建 * Cronjob:周期性任务控制,不需要持续后台运行 * StatefulSet:管理有状态应用 ## Deployment 使用yaml定义一个deployment
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
name:
namespace:
spec:
replicas: 2 # 指定pod副本数
selector: # pod选择器,有对应label的pod都会归于这个deployment管理,不是pod的node selector
matchLabels:
podLabelKey1: podLabelValue
template: # pod 模版,同定义pod的yaml文件
metadata:
labels:
podLabelKey1: podLabelValue #这里定义的lable的key/value要和上面的select字段定义的相对应
spec:
nodeSelector:
nodeLabelKey: nodeLabelValue
containers:
- name:
image:
env:
deployment阔缩容命令:
1
kubectl -n <namespace> scale deployment <deployment name> --replicas=<n>
deployment服务更新(镜像更新) * 通过yaml配置文件的方式(建议)
1
kubectl -n <namespace> apply -f <xxx.yaml>
* 通过命令行直接更新
1
kubectl -n <namespace> set image deployment <deployment name> <container name>=<image>
deploy服务回滚 回滚只能通过命令行进行回滚,不能通过配置文件进行回滚 * 查看可回滚的历史记录
1
kubectl -n <namespace> rollout history deployment <deployment name>
* 回滚
1
kubectl -n <namespace> rollout undo deployment <deployment name> --to-revision=<revsion number>
### deployment更新(回滚)策略配置 deployment的更新(回滚)是滚动更新(回滚)的,即一次只进行一部分pod更新(回滚),直到所有的pod都完成更新(回滚),deployment的更新(回滚)策略有以下几种可配置项: * maxSurge:最大激增数,指更新过程中,最多可以比replicas预先设定值多出的pod数量,可以为固定值或百分比,默认为25%,计算时向上取整 * maxUnavailable:指更新过程中,最多有几个pod处于无法服务状态,可以为固定值或百分比,默认为25%,计算时向下取整 deployment更新的底层实现其实是新建了一个ReplicaSet,这个ReplicaSet使用的最新版本的镜像,然后通过调整新ReplicaSet和老ReplicaSet的副本数来实现滚动升级。同时老的ReplicaSet不会立即删除,目的是为了方便回滚。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: apps/v1
kind: Deployment
metadata:
name:
namespace:
spec:
replicas: 2
selector:
matchLabels:
podLabelKey1: podLabelValue
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
podLabelKey1: podLabelValue
spec:
nodeSelector:
nodeLabelKey: nodeLabelValue
containers:
- name:
image:
env:

Service负载均衡之Cluster IP

通过deployment,我们已经可以创建一组Pod来提供具有高可用性的服务,虽然每个Pod都会分配一个单独的Pod IP, 然而却存在如下两个问题:

  • Pod IP仅仅是集群内部的虚拟IP,在集群内部可以访问,外部却无法访问

  • Pod IP会随着Pod的销毁而消失,当ReplicaSet对Pod进行动态伸缩容时,Pod IP可能随时会发生变化,这样对于我们访问这个服务带来了难度

Services是一组pod服务的抽象,相当于一组pod的load balancer,负责将请求分发到对应的pod。service会有一个IP,一般称为cluster ip,service对象通过selector进行标签选择,找到到对应的pod

使用yaml定义一个service

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name:
namespace:
spec:
ports:
- port: 80 # service 端口
protocol: TCP
targetPort: 8002 # pod 端口
selector:
podLabelName: podLabelValue
type: ClusterIP

创建好service后,在集群内部就可以直接使用service name + pod对服务进行访问,因为集群内部的dns会记录相关解析规则

Service负载均衡之NodePort

Cluster IP也是一个虚拟地址,其目的是为了方便集群内部服务直接的通信,只能在k8s集群内部进行访问,如果需要集群外部访问集群内部服务,实现方式之一为使用NodePort方式。NodePort会默认在30000—32767之间,不指定会随机使用其中的一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name:
namespace:
spec:
ports:
- port: 80
protocol: TCP
targetPort: 8002
selector:
podLabelName: podLabelValue
type: NodePort

kube-proxy

kube-proxy运行在每个节点上,监听API Server中服务对象的变化,再通过创建流量路由规则来实现网络的转发。kube-proxy支持三种模式:

  • User space:让kube-proxy 在用户空间监听一个端口,所有的service都转发到这个端口,然后kube-proxy在内部应用层对其进行转发,所有报文都走一遍用户态,性能不高,在k8s v1.2版本后废弃

  • Iptables:当前默认模式,完全由iptables来实现,通过各个节点上的iptable规则来实现service的负载均衡,但是随着service数量增大,iptables模式由于线性查找匹配、全量更新等特点,其性能会显著下降

  • IPVS:与iptables同样基于Netfilter,但是采用的hash表,因此当service数量达到一定规模时,hash查表的速度优势就会显现出来,从而提高service的服务性能。k8s 1.8版本开始引入,1.11版本开始稳定,需要开启宿主机的ipvs模块

Ingress

对于k8s的service,无论是Cluster-IP还是NodePort的形式,都是四层的负载,集群内的服务如果实现7层的负载均衡,这就需要借助与Ingress。Ingress控制器的实现方式有很多,例如nginx、contour、haproxy,trafik,istio。

ingress-nginx是7层的负载均衡器,根据用户编写的ingress规则(创建的ingress的yaml文件),动态的去更改nginx服务的配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name:
spec:
rules:
- host: # 域名host
http:
- paths: /
backend:
serviceName: <service name>
servicePort: <service port>

ingress实现逻辑

  • ingress controller通过api server,监听集群中ingress规则变化

  • 然后读取ingress规则(规则就是写明了哪个域名对应哪个service),按照自定义的规则,生成一段nginx配置

  • 再写到nginx-ingress-contoller的pod里,nginx-ingress-controller的pod里运行着Nginx服务

网络模式

我们在使用docker run创建Docker容器时,可以用--net选项指定容器的网络模式,docker有以下4中网络模式:

  • bridage模式:docker容器默认的网络模式。它的原理类似交换机设备,而在linux中,能够起虚拟交互作用的,就是网桥(bridge)。它是一个工作在数据链路层的软件,主要功能是根据MAC地址将数据包转发到网桥的不同端口上。

    有了网桥之后,容器在启动时,会执行如下操作:

    1. 创建一对虚拟接口/网卡,也就是veth pair

    2. veth pair一端桥接到默认的名称为docker0的网桥或者其他指定网桥上,并具有一个唯一的名字,如veth9953b75

    3. veth pair一端放到新启动的容器内部,并修改名字作为eth0

    4. 从虚拟网桥可用地址段中(也就是bridge对应的network)获取一个空闲地址分配给容器内的eth0

    5. 容器内部配置默认路由到网桥

    如果容器内部需要访问外部网络,需要经过容器内部的eth0网卡、虚拟网桥、宿主机网卡最终访问到外网。如果容器内部需要访问其他容器网络,需要经过容器内部eth0网卡、虚拟网桥、其他容器内部etho0最终访问到其他容器。

  • host模式:容器内部不会创建网络命名空间(Network Namespace),容器共享宿主机的网络空间。

  • container模式:这个模式指定新创建的容器和已经存在的一个容器共享一个网络命名空间(Network Namespace)、网卡、ip。这种模式在一些特殊的场景中非常有用,例如k8s的pod,k8s为pod创建一个基础设施容器,该pod下的其他容器都以container模式共享这个技术设施容器的网络命名空间,相互之间以localhost访问,构成一个统一的整体。

  • none模式:只会在容器内创建网络命名空间(Network Namespace),不会创建虚拟网卡

Linux Namespace资源隔离

Linux命名空间是全局资源的一种抽象,将资源发到不同的命名空间中,各个命名空间中的资源时相互隔离的。命名空间有以下几种类别

分类 系统调用参数 相关内核版本
Mount Namespace CLONE_NEWNS Linux 2.4.19
UTS Namespace CLONE_NEWUTS Linux 2.6.19
IPC Namespace CLONE_NEWIPC Linux 2.6.19
PID Namespace CLONE_NEWPID Linux 2.6.24
Network Namespace CLONE_NEWNET 始于Linux 2.6.24 完成于2.6.29
User Namespace CLONE_NEWUSER 始于Linux2.6.23 完成于3.8

查看进程的namespace

1
2
3
4
5
6
7
8
9
10
ls -l /proc/<pid>/ns

lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 net -> net:[4026532057]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 pid -> pid:[4026531836]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 user -> user:[4026531837]
lrwxrwxrwx 1 xiangwei.flower xiangwei.flower 0 Apr 3 12:27 uts -> uts:[4026531838]

CGroup资源限制

通过Namespace可以保证容器之间的隔离,但是无法控制容器可以占用多少资源,如果其中的某一个容器正在执行CPU计算密集型任务,那么就会影响其他容器任务的性能与执行效率,导致多个容器相互影响并且抢占资源。

CGroup(Control Group)就是能够隔离宿主机上的物理资源,例如CPU、内存、磁盘I/O和网络带宽。而我们需要做的就是把容器进程加入到指定的CGroup中。

UnionFS 联合文件系统

Linux Namespace和cgroup分别解决了容器的资源隔离与资源限制,那么容器是很轻量的,通常每台机器中可以运行几十上百个容器,这些容器可能会公用一个image。所以容器在启动的时候,不可能各自将这个image复制一份。Docker在内部使用镜像分层存储以及UnionFS来实现多个容器共用一个镜像。

  • 镜像分层存储:docker镜像是由一系列的层组成的,每层代表Dockerfile中的一条指令,比如下面的Dockerfile文件:

    1
    2
    3
    4
    FROM ubuntu:15.04
    COPY . /app
    RUN make /app
    CMD python /app/app.py

    这个dockerfile文件最终生成镜像的时候会生成四层,这四层是不可写的,而通过镜像实例化容器的过程,其实就是在就是在这四层之上添加了一个可写层,也就是我们通常说的容器层。而对容器层的操作,主要是利用了写时复制(CoW,copy on write)的技术。例如,如果当前操作会改变下面四层的某一层,docker会先将该层拷贝到容器层,然后再在容器层中进行操作。

  • UnionFS 其实是一种为Linux操作系统设计的,用于把多个文件系统联合到同一个挂载点的文件系统服务。

第37条:用组合起来的类来实现多层结构,不要用嵌套的内置类型

一般我们在编写项目代码时,由于初始的需求简单,需要用到的数据结构也简单,所以我们会经常使用python提供的容器来用做类的内部状态记录。但是随着需求的迭代,我们可能会使用到数据结构可能会更变得复杂,这时,我们不能简单的对用于记录内部状态的容器进行嵌套,而是应该考虑将内部的某些状态封装成一个类,并在外部的接口类中对这些数据类进行组合。

第38条:让简单的接口接受函数,而不是类的实例

Python有很多内置的API,都允许我们传入某个函数来定制它的行为,这种函数被成为hook函数,API在执行的时候,会调用这些hook函数。例如,list类的sort方法的key参数。在其他编程语音中,hook函数可能是通过抽象类或者接口来定义的(例如Java),但在python中一般是直接使用无状态的函数(即不会对内部状态进行修改)

第39条:通过@classmethod多态来构造同一体系中的各类对象

第27条:用列表推导取代map与filter

Python里面有一种很精简的写法,可以根据某个序列或可迭代对象派生出一份新的列表。用这种写法写成的表达式,叫作列表推导。假设我们要用列表中每个元素的平方值构建一份新的列表:

1
2
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
squares = [x**2 for x in a]

这种功能也可以使用内置函数map实现,它能够从多个列表中分别取出当前位置上的元素,并把它们当作参数传给映射函数,以求出新列表在这个位置上的元素值:

1
alt = map(lambda x: x**2, a)
如果映射关系比较简单,那么用列表推导来写还是要比用map简单一些,因为用map的时候,必须先把映射逻辑定义为lambda函数,这看上去稍微有点繁琐。

列表推导还有一个地方比map好,就是它能够方便地过滤原列表,把某些输入值对应的计算结果从输出结果中排除。例如,假设新列表只需要纳入原列中那些偶数的平方值,那么我们可以在推导的时候再添加一个条件表达式:

1
even_squares = [x**2 for x in a if x % 2 == 0]

这种功能也可以通过内置的filter与map函数来实现,但是这两个函数相结合的写法要比列表推导难懂一些。

1
alt = map(lambda x : x**2, filter(lambda x: x % 2 == 0, a))

上面这个写法是先用filter对a中的元素进行过滤形成新的列表,然后在对这个新的列表用map函数生成最终结果。

字典与集合也有相应的推导机制,分别叫做字典推导与集合推导,可以根据原字典与原集合创建新字典与新集合。
1
2
even_squares_dict = {x: x**2 for x in a if x % 2 == 0}
threes_cubed_set = {x**3 for x in a if x % 3 == 0}

如果改用map与filter实现,那么还必须调用相应的构造器(constructor),这会让代码变得很长,需要分成多行才能写得下。这样看起来比较乱,不如使用推导机制的代码清晰。

1
2
alt_dict = dict(map(lambda x: (x, x**2), filter(lambda x: x % 2 == 0, a)))
alt_set = set(map(lambda x: (x, x**3), filter(lambda x: x % 3 == 0, a)))

第28条:控制推导逻辑的子表达式不要超过两个

列表推导除了最基本的用法外,列表推导还支持多层循环。例如,要把二维列表转化为普通的一维列表,那么可以在推导时,使用两条for子表达式。这些子表达式会按照从左到右的顺序解读。

1
2
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]

这样写简单易懂,也是多层循环在列表推导中的合理用法。多层循环还可以用来重制那种两层深的结构。例如,要根据二维矩阵里每个元素的平方值来构建一个新的二维矩阵:

1
squared = [[x**2 for x in row] for row in matrix]

如果推导过程中还要再加一层循环,那么语句就会变得很长,必须把它分成多行来写,例如下面是把一个三维矩阵转化成普通一维列表的代码:

1
2
3
4
5
6
7
my_lists = [
[[1, 2, 3], [4, 5, 6]],
...
]
flat = [x for sublist1 in my_lists
for sublist2 in sublist1
for x in sublist2]
在这种情况下,采用列表推导来实现,其实并不会比传统的for循环节省多少代码。
1
2
3
4
flat = []
for sublist1 in my_lists:
for sublist2 in sublist1:
flat.extend(sublist2)

推导的时候,可以使用多个if条件,如果这些if条件出现在同一层循环内,那么它们之间默认是and关系,也就是必须同时成立。例如,如果要用原列表中大于4且是偶数的值来构建新列表,那么既可以连用两个if,也可以只用一个if,下面两种写法效果相同:

1
2
3
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = [x for x in a if x > 4 if x % 2 == 0]
c = [x for x in a if x > 4 and x % 2 == 0]

在推导时,每一层的for子表达式都可以带有if条件。假如要根据原矩阵构建新的矩阵,把其中各元素之和大于等于10的那些行选出来,而且只保留其中能够被3整除的那些元素。这个逻辑用列表推导来写,并不需要太多的代码,但是这些代码理解起来会很困难:

1
2
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
filterd = [[x for x in row if x % 3 == 0] for row in matrix if sum(row) >= 10]
总之,在表示推导逻辑时,最多只应该写两个子表达式(例如两个if条件、两个for循环,或者一个if条件与一个for循环)。 只要实现的逻辑比这还复杂,那就应该采用普通的if和for语句来实现。 # 第29条:用赋值表达式消除推导中的重复代码 推导list、dict与set等结构时,经常要在多个地方用到同一个计算结果。例如,我们要给制作紧固件的公司编写程序以管理订单。顾客下单后,我们要判断当前库存能否满足这份订单,也就是说,要和产每种产品的数量有没有达到可以发货的最低限制(8个为一批,至少要一批,才能发货)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
stock = {
'nails': 125,
'screws': 35,
'wingnuts': 8,
'washers': 24,
}

order = ['screws', 'wingnuts', 'clips']

def get_batches(count, size):
return count // size

result = {}
for name in order:
count = stock.get(name, 0)
batches = get_batches(count, 8)
if batches:
result[name] = batches

print(result)

>>>
{'screws': 4, 'wingnuts': 1}
这段循环逻辑,如果改用字典推导来写,会简单一些:
1
2
3
result = {name: get_batches(stock.get(name, 0), 8) 
for name in order
if get_batches(stock.get(name, 0), 8)}
这样写虽然比刚才简短,但问题是,它把`get_batches(stock.get(name, 0), 8)`写了两遍。有个简单的办法可以解决这个问题,那就是在推导的过程中使用Python3.8新引入的 `:=`操作符进行赋值表达
1
2
result = {name: batches for name in order 
if (batches := get_batches(stock.get(name, 0), 8))}
在推导过程中,描述新值的那一部分也可以出现赋值表达式,但如果在其他部分引用了定义在那一部分的变量,那么程序可能就会在运行时出错:
1
2
3
4
5
6
result = {name: (tenth := count // 10)
for name, count in stock.items() if tenth > 0}

>>>
Traceback ...
NameError: name 'tenth' is not defined

但是,如果把赋值表达式移动到if条件里面,就可以解决这个问题:

1
2
result = {name: tenth for name, count in stock.items() 
if (tenth := count // 10) > 0}

第30条:不要让函数直接返回列表,应该让它逐个生成列表里面的值

如果函数要返回的是个包含许多结果的序列,那么最简单的办法就是把这些结果放到列表中。例如,我们要返回字符串里每个单词的首字母在字符串中所对应的下标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def index_words(text):
result = []
if text:
result.append(0)
for i, latter in enumerate(text):
if latter == ' ':
result.apppend(index + 1)

the_text = 'Four score and seven years ago...'
result = index_words(the_text)
print(result[:10])

>>>
[0, 5, 11, 15, 21, 27]

上面的index_words函数也可以改用生成器来实现。生成器由包含yield表达式的函数创建。下面就定义一个生成器函数,实现与刚才那个函数相同的效果:

1
2
3
4
5
6
def index_words_iter(the_text):
if text:
yield 0
for index, letter in enumerate(text):
if letter == ' ':
yield index + 1
调用生成器函数并不会让其中的代码立刻得到执行,它会返回一个迭代器(iterator)。把迭代器传给Python内置的next函数,就可以将生成器函数推进到它的下一条yield表达式。生成器会把yield表达式的值通过迭代器返回给调用者。
1
2
3
4
5
6
7
it = index_words_iter(the_text)
print(next(it))
print(next(it))

>>>
0
5

如果确实要制作一份列表,那可以把生成器函数返回的迭代器传给内置的list函数:

1
result = list(index_words_iter(the_text))

index_words_iter相对于index_words来说,不必一次性把所有结果都保存到列表中,在数据的数据较多的情况下,index_words有可能因为耗尽内存而导致程序崩溃。

使用这些生成器函数时,只有一个地方需要注意,就是调用者无法重复使用函数所返回的迭代器,因为迭代器是有状态的(参见第31条)。

第31条:谨慎地迭代函数所接受的可迭代参数

如果函数接受的参数是个可迭代对象,那么我们可能会在函数中对其迭代多次。例如,我们要分析美国德克萨斯州的游客数量。原始数据保存在一份列表中,其中的每个元素表示每年有多少游客(单位是百万)。我们要统计每个城市的游客数占游客总数的百分比。

1
2
3
4
5
6
7
8
def normalize(numbers):
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)

return result

normalize函数中会对numbers参数进行两次迭代,一次是在sum函数的调用中,一次是在for循环中。

如果我们给nomalize函数传入参数的是一个列表,我们可以的得到正确的结果:

1
2
3
4
5
6
visits = [15, 35, 80]
percentages = normalize(visits)
print(percentages)

>>>
[11.538, 26.924, 61.538]

但是如果我们传给nomalize函数的是个迭代器,例如在数据规模较大,需要从文件中读取数据时:

1
2
3
4
def read_visits(data_path):
with open(data_path) as f:
for line in f:
yield int(line)

奇怪的是,对read_visits所返回的迭代器调用normalize函数之后,并没有得到结果:

1
2
3
4
5
6
it = read_visits('my_numbers.txt')
percentages = normalize(it)
print(percentages)

>>>
[]

出现这种状况的原因在于,迭代器只能进行一次迭代,并且迭代后不可重置。在sum函数中,已经对迭代器进行过一次迭代了,所以在for循环中由于没有数据可迭代,所以也就不会进行循环内部。

一种解决办法是让normalize函数接受另外一个函数,使它每次要使用迭代器时,都要向那个函数去索要:

1
2
3
4
5
6
7
8
9
10
def normalize_func(get_iter):
total = sum(get_iter())
result = []
for value in get_iter()
percent = 100 * value / total
result.append(percent)

return result

percentages = normalize_func(lambda: read_visits('my_numbers.txt'))

这么做虽然可行,但是每次调用normalize_func都需要传入一个函数,更好的方法是自定义一种容器类,并让其实现迭代器协议(iterator protocol)。

Python的for循环及相关的表达式,正是按照迭代器协议来遍历容器内容的。Python执行`for x in foo`这样的语句时,实际上会调用`iter(foo)`,也就是把foo传给内置的iter函数。这个函数会触发`foo.__iter__`的特殊方法,该方法必须返回一个迭代器对象(即要实现`__next__`特殊方法)。最后,Python会用迭代器对象反复调用内置的`next`函数,知道迭代完成。
1
2
3
4
5
6
7
8
class ReadVisits:
def __init__(self, data_path):
self.data_path = data_path

def __iter__(self):
with open(self.data_data_path) as f:
for line in f:
yield int(line)
我们只需要把新的容器传给最早的那个normalize函数即可,函数的代码无需修改:
1
2
3
4
5
6
visits = ReadVisits(p[11.538, 26.924, 61.538]ath)
percentage = normalize(visits)
print(percentages)

>>>
[11.538, 26.924, 61.538]
# 第32条:考虑用生成器表达式改写数据量较大的列表推导 列表推导可以根据输入序列中的每个元素创建一个包含派生元素的新列表。如果输入的数据量比较小,那么这么做没有问题,但如果数据量很大,那么程序就有可能因为内存耗尽而崩溃。例如,我们要读取一份文件并返回每行的字符数:
1
value = [len(x) for x in open('my_file.txt')]
上面的代码有可能因为文件行数太多而导致list过长。要想处理大规模的数据,可以使用生成器表达式来做,它扩展了列表推导和生成器机制。程序在对生成器表达式求值时,并不会让它把包含输出结果的那个序列立即构建出来,而是会把它当成一个迭代器,该迭代器每次可以根据表达式中的逻辑给出一个结果。 生成器表达式的写法与列表推导类似,只不过它是写在一对圆括号里,而不是方括号里:
1
2
3
it = (len(x) for x in open('my_file.txt'))
print(next(it))
print(next(it))

生成器表达式还有个强大的特性,就是可以组合起来,例如,可以用刚才那条生成器表达式所形成的it迭代器作为输入,编写一条新的生成器表达式:

1
roots = (x, x**0.5) for x in it)

第33条:通过yield from把多个生成器连起来用

生成器(yield)有很多好处,能够解决很多常见的问题。生成器的用途很广,所以许多程序都会频繁使用它们,而且是一个连一个地用。

例如,我们要编写一个图形程序,让它在屏幕上面移动图像,从而形成动画效果。假设要实现这样一段动画:图片先快速移动一段时间,然后暂停,接下来慢速移动一段时间。我们用生成器来表示图片在当前时间段内应该保持的速度:

1
2
3
def move(period, speed):
for _ in range(period):
yield speed

为了把完整的动画制作出来,我们需要调用三次move:

1
2
3
4
5
6
7
def animate():
for delta in move(4, 5.0)
yield delta
for delta in move(3, 0.0)
yield delta
for delta in move(2, 3.0)
yield delta

上面这种写法的问题在于,animate函数里有很多重复的地方。比如它反复使用for结构来操作生成器,而且每个for结构都使用相同的yield表达式。为了解决这个问题,我们可以改用yield from形式的表达式来实现。这种形式,会先从嵌套进去的小生成器里面取值,如果该生成器已经用完,那么程序的控制流程就会回到yield from所在的这个函数之中:

1
2
3
4
def animate():
yield from move(4, 5.0)
yield from move(3, 0.0)
yield from move(2, 3.0)

上面使用yield from的代码看上去更清晰、更直观,并且这种实现方式的运行效率要更快。

第34条:不要用send给生成器注入数据

第35条:不要通过throw变换生成器的状态

说实话,第34条和第35条没怎么看懂,第一主要是生成器的这两个高级特性使用的场景也并不多,第二是感觉作者的代码示例也不太贴合实际场景中会写的代码。

第36条:考虑用itertools处理迭代器与生成器

Python内置的itertools模块中有很多函数,可以用来对迭代器进行一些高级处理。下面分三大类,列出其中最重要的函数。

  • 连接多个迭代器

    • chain : 可以把多个迭代器从头连接到尾形成一个新的迭代器

      1
      2
      3
      4
      5
      6
      7
      it1 = iter([1, 2, 3])
      it2 = iter([4, 5, 6])
      it3 = itertools.chain(it1, it2)
      print(list(it))

      >>>
      [1, 2, 3, 4, 5, 6]
    • repeat : 可以制作这样的一个迭代器,它会不停得输出某个值,或者通过第二个参数来控制最多能输出几次

      1
      2
      3
      4
      5
      it = itertools.repeat('hello', 3)
      print(list(it))

      >>>
      ['hello', 'hello', 'hello']
    • cycle : 可以制作这样的一个迭代器,它会循环地输出某段内容之中的各个元素

      1
      2
      3
      4
      5
      6
      it = itertools.cycle([1, 2])
      result = [next(it) for _ in range(5)]
      print(result)

      >>>
      [1, 2, 1, 2, 1]
    • tee : 可以让一个迭代器分裂成多个平行迭代器,具体个数由第二个参数指定。如果这些迭代器推进的速度不一样,那么程序可能要用大量内存做缓存,以存放进度落后的迭代器会用到的元素。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      it1, it2, it3 = itertools.tee([1, 2, 3], 3)
      print(list(it1))
      print(list(it2))
      print(list(it3))

      >>>
      [1, 2, 3]
      [1, 2, 3]
      [1, 2, 3]
    • zip_longest : 它与内置的zip函数类似(参见第8条),但区别是,如果源迭代器的长度不同,那么它会用fillvalue参数的值来填补提前耗尽的那些迭代器所留下的空缺。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      keys = ['one', 'two', 'three']
      values = [1, 2]

      normal = zip(keys, values)
      print('zip:', list(normal))

      it = itertools.zip_longest(key, values, fillvalue='nope')
      print('zip_longest:', list(it))

      >>>
      zip: [('one', 1), ('two', 2)]
      zip_longest: [('one', 1), ('two', 2), ('three', 'nope')]
  • 过滤迭代器中的元素

    • islice : 可以在不拷贝数据的前提下,按照下标切割源迭代器,这种切割方式与标准的序列切片以及步进机制类似

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
      first_five = itertools.islice(it, 5)
      print('First Five:', list(first_five))

      middle_odds = itertools.islice(it, 2, 8, 2)
      print('Middle odds:', list(middle_odds))

      >>>
      First five: [1, 2, 3, 4, 5]
      Middle odds: [3, 5, 7]
    • takewhile : 会一值从源迭代器里获取元素,直到某元素让测试函数返回False为止:

      1
      2
      3
      4
      5
      6
      it1 = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
      it2 = itertools.takewhile(lambda x: x < 7, it1)
      print(list(it2))

      >>>
      [1, 2, 3, 4, 5, 6]
    • dropwhile : 与takewhile相反,dropwhile会一直跳过源序列里的元素,直到某元素让测试函数返回True为止,然后它会从这个地方开始逐个取值

      1
      2
      3
      4
      5
      6
      it1 = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
      it2 = itertools.dropwhile(lambda x: x < 7, it1)
      print(list(it2))

      >>>
      [7, 8, 9, 10]
    • filterfalse : 和内置的filter函数相反,它会逐个输出源迭代器里使得测试函数返回False的那些元素

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
      evens = lambda x : x % 2 == 0

      filter_result = filter(evens, it)
      print('Filter:', list(filter_result))

      filter_false_result = itertools.filterfalse(evens, it)
      print('Filter false:', list(filter_false_result))

      >>>
      Filter: [2, 4, 6, 8, 10]
      Filter false: [1, 3, 5, 7, 9]
  • 用源迭代器中的元素合成新元素

    • accumulate : accumulate 会从源代码迭代器取出一个元素,并把已经累计的结果与这个元素一起传给表示累加逻辑的函数,然后输出那个函数的计算结果,并把结果当成新的累计值。这与内置的functools模块中的reduce函数,实际上是一样的,只不过这个函数每次只给出一项累加值。如果调用者没有指定表示累加逻辑的函数,那么默认的逻辑就是两值相加。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
      sum_reduce = itertools.accumulate(it)
      print('Sum:', list(sum_reduct))

      def sum_modulo_20(first, second):
      output = first + second
      return output % 20
      modulo_reduce = itertools.accumulate(it, sum_modulo_20)
      print('Modulo:' list(module_reduce))

      >>>
      Sum: [1, 3, 6, 10, ]
    • product : 会从一个或多个源迭代器里获取元素,并计算笛卡尔积,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      single = itertools.product([1, 2], repeat=2)
      print('Single:', list(single))

      multiple = itertools.product([1, 2], ['a', 'b'])
      print('Multiple:', list(multiple))

      >>>
      Single: [(1, 1), (1, 2), (2, 1), (2, 2)]
      Multiple: [(1, '1'), (1, 'b'), (2, 'a'), (2, 'b')]
    • product : 会从源迭代器中能给出的全部元素,并逐个输出由其中N个元素组成的有序排列

      1
      2
      3
      4
      5
      it = itertools.permutations([1, 2, 3], 2)
      print(list(it))

      >>>
      [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]
    • combinations : 会从源迭代器中能给出的全部元素,并逐个输出由其中N个元素组成的无序组合

      1
      2
      3
      4
      5
      it = itertools.combinations([1, 2, 3], 2)
      print(list(it))

      >>>
      [(1, 2), (1, 3), (2, 3)]
    • combinations_with_replacement : 和combination类似,但是它允许同一个元素在组合里多次出现:

      1
      2
      3
      4
      it = itertools.combinations_with_replacement([1, 2, 3], 2)
      print(list(it))
      >>>
      [(1, 1), (1, 2), (1, 3), (2, 2) (2, 3), (3, 3)]

第19条:不要把函数返回的多个值拆分到三个以上的变量中

python的unpacking机制允许python函数返回一个以上的值,函数返回一个以上的值的时候,实际上返回的是一个元组。

1
2
3
4
def get_min_max(numbers):
minimum = min(numbers)
maximum = max(numbers)
return minimum, maximum

在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值(参考第13条)

1
2
3
4
5
6
7
def get_avg_ratio(numbers):
average = sum(numbers) / len(numbers)
scaled = [x / average for x in numbers]
scaled.sort(reverse=True)
return scaled

longest, *middle, shortest = get_avg_ratio(numbers)

当我们用超过三个变量去接收函数的返回值时,会很容易出现将顺序弄错的情况。所以一般来时,一个元组最多只拆分到三个普通变量或者拆分到两个普通变量与一个万能变量(带星号的变量)。假如要拆分的值确实很多,那最好还是定义一个轻便的类或namedtuple(参见第37条),并让函数返回这样的实例。

第20条:遇到意外状况时应该抛出异常,不要返回None

编写工具函数(utility function)时,许多python程序员都爱用None这个返回值来表示特殊情况。对于某些函数来说,这或许有几分道理。例如,我们要编写一个辅助函数计算两数相除的结果,在除数是0的情况下,返回None似乎合理,因为这种除法的结果是没有意义的。

1
2
3
4
5
6
7
8
9
10
11
def careful_devide(a, b):
try:
return a / b
except ZeroDivisionError:
return None


x, y = 1, 0
result = careful_divide(x, y)
if result is None:
print('invalid inputs')

但是,如果传给careful_divide函数的被除数为0时,会怎么样呢?在这种情况下,只要除数不为0,函数返回的结果就应该是0。但是问题时,别人在使用这个工具函数时,在if表达式中不会明确判断返回值是否是None,而是去判断返回值是否相当于False:

1
2
3
4
x, y = 0, 5
result = careful_divide(x, y)
if not result:
print('invalid inputs')

上面这种if语句,会把函数返回0的情况和返回None的情况一样处理。由于这种写法经常出现在python代码里,因此,像careful_divide这样,用None来表示特殊情况的函数是很容易出错的。有两种办法可以减少这样的错误。

第一种,利用二元组把计算结果分成两部分返回,元组的首个元素表示操作是否成功,第二个元素表示计算的实际值:

1
2
3
4
5
6
7
8
9
10
def careful_divide(a, b):
try:
return True, a / b
except ZeroDivisionError:
return False, None


success, result = careful_divide(x, y)
if not success:
print('invalid inputs')

但是,有些调用方总喜欢忽略元组的第一个部分。第二种方法比刚才那种更好,就是不采用None表示特例,而是向调用方抛出异常,让他们自己去处理。

1
2
3
4
5
6
7
8
9
10
11
12
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError:
raise ValueError('invalid inputs')


x, y = 5, 2
try:
result = careful_divide(x, y)
except ValueError:
print('invalid inputs')

我们还可以利用类型注解指明函数返回float类型,这样就对外说明不会返回None了,但是,我们无法在函数的接口上说明函数可能抛出哪些异常,所以,我们只好把有可能抛出的异常写在文档里面,并希望调用方能够根据这份文档适当得捕获相关的异常(参见第84条)。

1
2
3
4
5
6
7
8
9
10
def careful_divide(a: float, b:float) -> float:
"""Divides a by b

Raises:
ValueError: When the inputs cannot by divided
"""
try:
return a / b
except ZeroDivisionError as e:
raise ValueError('invalid inputs')

总结:用返回值None表示特殊情况是很容易出错的,因为这样的值在条件表达式里面没法与0、空字符串、空数组之类的值进行区分,这些值都相当于False。

个人觉得作者在此处使用的代码示例不是很好,这个抛出异常版本的careful_divide函数根据没啥实际用处,使用者还不如直接去捕获ZeroDivisionError,作者的目的可能只是为了简明得解释这条建议。

第21条:了解如何在闭包里面使用外围作用域中的变量

假设,现在有一个需求,我们要给列表中的元素排序,而且要优先把在另外一个群组的元素放在其他元素的前面。实现这种做法的一种常见方案,是把辅助函数通过key参数传给列表的sort方法,让这个方法根据辅助函数返回的值来决定元素在列表中的先后顺序,辅助函数先判断当前元素是否处在重要群组里,如果在,就把返回值的第一项写成0,让它能够排在不属于这个组的那些元素之前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def sort_priority(values, group):
def helper(x):
if x in group:
return 0, x
return 1, x

values.sort(key=helper)


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

sort_priority(numbers, priority_group)
print(numbers)

>>>
[2, 3, 5, 7, 1, 4, 6, 8]

在sort_priority函数中,引用了外部函数的group参数,在一个内部函数中,对外部作用域的变量进行引用,那么内部函数就被认为是闭包。

假设现在需求新增,sort_priority函数还需要告诉我们,列表里面是否有位于重要群组之中,那么第一个想法就是添加一个标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def sort_priority(values, group):
found = False

def helper(x):
if x in group:
found = True
return 0, x
return 1, x

values.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

found = sort_priority(numbers, priority_group)
print(numbers)
print('found: ', found)

>>>
[2, 3, 5, 7, 1, 4, 6, 8]
found: False

虽然排序结果没有问题,但是却发现标志本应该为True,但是返回的确是False。

在表达式中引用某个变量时,Python解释器会按照下面的顺序,在各个作用域(scope)里面查找这个变量,以解析这次引用(变量出现在=右边时)。

  1. 当前函数作用域

  2. 外围作用域(例如包含当前函数的其他函数所对应的作用域)

  3. 包含当前代码的那个模块所对应的作用域(也叫全局作用域,global scope)

  4. 内置作用域(built-in scope,也就是包含len与str等函数的那个作用域)

如果这些作用域中都没有定义名称相符的变量,那么程序就抛出NameError异常。

当对变量进行赋值时(变量出现在=左边),需要分两种情况处理:如果变量已经定义在当前作用域中,那么直接把新值赋给它就行了。如果当前作用域中不存在这个变量,那么即使外围作用域里有同名的变量,Python也还是会把这次赋值操作当成变量的定义来处理。这会产生一个重要的效果,也就是说,Python会把包含赋值操作的这个函数当作新定义的这个变量的作用域。这也就解释了为什么found还是为False。

这种问题有时也称为作用域bug(scoping bug),Python新手可能认为这样的赋值规则很奇怪,但实际上Python是故意这么设计的,因为这样可以防止函数的局部变量污染外围模块,假设不这么做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这不仅混乱,而且会让全局变量之间彼此交互影响,从而导致更多难以探查的bug。

Python有一种特殊的写法,可以把闭包里面的数据赋给闭包外面的变量。用`nonlocal`描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。然而,nonlocal有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def sort_priority(values, group):
found = False

def helper(x):
if x in group:
nonlocal found
found = True
return 0, x
return 1, x

values.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

found = sort_priority(numbers, priority_group)
print(numbers)
print('found: ', found)

nonlocal语句清楚地说明,我们要把数据赋给闭包之外的变量。有一种跟它互补的语句,叫做global,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域中。

1
2
3
4
5
6
7
8
9
def to_global():
global a
a = 2

to_global()
print(a)

>>>
2
我们都知道全局变量不应该滥用,其实nonlocal也是这样,除比较简单的函数外,建议不要使用nonlocal语句。因为它造成的副作用有时很难发现。尤其是在那些比较长的函数里,nonlocal语句与其关联变量的赋值操作之间可能隔很远。 # 第22条:用数量可变的位置参数给函数设计清晰的参数列表 让函数接收数量可变的位置参数,可以把函数设计得更清晰(这些位置参数通常称为varargs,或者称为star args,因为我们习惯用\*args指代)。例如假设我们要记录调试信息。如果采用参数数量固定的方案来设计,那么函数应该接受一个表示信息的message参数和value列表,这个列表用于存放需要用来debug的一些变量值。
1
2
3
4
5
6
7
8
def log(msg, values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('the numbers are', [1, 2])
在调用log函数时,每次都需要传入一个列表,更好的方式是给values参数加上前缀\*,让其变量为数量可变的参数。
1
2
3
4
5
6
7
8
def log(msg, *values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('the numbers are', 1, 2)
如果想把已有序列里面的元素当成参数传给像log这样的参数个数可变的函数,那么可以在传递序列时采用\*操作符,这样Python把序列中的元素都当成位置参数传给这个函数。
1
2
numbers = [1, 2, 3]
log('numbers are', *numbers)
但是,令函数接受数量可变的位置参数,可能导致两个问题。第一个问题是,程序总是必须把这些参数转化为一个元组,然后才能把他们当成可选的位置参数传给函数。这意味着,在调用函数时,把带\*的操作符的生成器传了过去,那么程序必须先把这个生成器的所有元素迭代完(以便形成元组),然后才能继续往下执行(参见第30条)。这个元组包含生成器所给出的每个值,这可能耗费大量的内存,甚至会让程序崩溃。
1
2
3
4
5
6
7
8
9
10
11
12
def my_generator():
for i in range(10000):
yield i

def my_func(*args):
print(args)

it = my_generator()
my_func(*it)

>>>
(0, 1, 2, 3, ... , 9999)
接受\*args参数的函数,适合处理输入值不太多,而且数量可以提前预估的情况。在调用这种函数时,传给\*args这一部分的应该是许多个字面值或变量名。Python的这种机制主要是为了让代码写起来更方便、读起来更清晰。 第二个问题是,如果采用了\*args之后,又要给函数添加新的位置参数,那么原来的调用操作就需要全部更新。例如给log函数的参数列表开头添加新的位置参数sequence,那么原来的调用就会表现有问题。
1
2
3
4
5
6
7
8
9
10
11
12
def log(sequence, msg, *values):
if not values:
print(f'{sequence} - {message}')
else:
values_str = ', '.join(str(x) for x in values)
print(f'{sequence} - {message}: {values_str}')


log('the numbers are', 1, 2)

>>>
the numbers are - 1: 2
关键的问题是,之前的函数调用是不会报语法错误,只是行为不正常甚至是导致运行时错误。这样的bug有时很难去排查。为了避免这种漏洞,在给这种\*args函数添加参数时,应该使用只能通过关键字来指定的参数(keyword-only argument,参见25条)。要是想做得更稳妥一些,可以考虑添加类型注解(参见第90条)。 # 第23条:用关键字参数来表示可选的行为 与大多数其他编程语音一样,Python运行在调用函数时,按照位置传递参数,即按照参数列表所指定的顺序依次传递参数。
1
2
3
4
def remainder(number, divisor):
return number % divisor

assert remainder(20, 7) == 6
Python函数里面的所有普通参数,除了按位置传递外,还可以按关键字传递:调用函数时,在调用括号内可以把关键字的名称放在`=`左边,把参数写在右边。这种写法不在乎参数的顺序,只要把指定的所有位置参数全部传过去即可。另外,关键字形式与位置形式也可以混用,下面这四种写法效果相同:
1
2
3
4
remiander(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)
如果混用,那么位置参数必须出现在关键字参数之前,否则就会报错。
1
2
3
4
5
remainder(number=20, 7)

>>>
Traceback ...
SyntaxError: positional argument follows keyword argument
每个参数只能指定一次,不能既通过位置形式指定,又通过关键字形式指定。
1
remainder(20, number=7)

如果有一份字典,而且字典里面的内容能够用来调用remainder这样的函数,那么可以吧**运算符加在字典前面,这会让Python把字典里面的键值以关键字参数的形式传给函数。

1
2
3
4
5
my_kwargs = {
'number': 20,
'divisor': 7,
}
assert remainder(**my_kwargs) == 6

调用函数时,带**操作符的参数可以和位置参数或关键字参数混用,只要不重复指定就行。

1
2
3
4
5
my_kwargs = {
'divisor': 7
}

assert remainder(number=20, **my_kwargs) == 6

也可以对多个字典分别施加**操作,只要这些字典所提供的参数不重叠就好。

1
2
3
4
5
6
7
my_kwargs = {
'number': 20
}
other_kwargs = {
'divisor': 7
}
assert remainder(**my_kwargs, **other_kwargs) == 6
定义函数时,如果想让这个函数接受任意数量的关键字参数,那么可以在参数列表里写上万能形参\*\*kwarg,它会把调用者传进来的参数集合到一个字典里面。
1
2
3
4
5
6
7
8
9
def print_parameters(**kwargs):
for key, value in kwargs.items():
print(f'{key} = {value}')

print_parameters(alpha=1.5, beta=9, gamma=4)
>>>
alpha = 1.5
beta = 9
gamma = 4
使用关键字参数调用函数有三个好处: 1. 用关键字参数调用函数可以让初次阅读代码的人更容易看懂 2. 它可以带有默认值,该值在定义函数时指定 3. 我们可以很灵活地扩充函数的参数,而不担心会影响原来函调用的代码 对于函数中定义的非万能关键字参数,python仍然可以按照位置来传递参数
1
2
3
4
5
def calculate_flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2):
...


calculate_flow_rate(100, 20, 3600, 2.5)
通过位置来指定可选参数,可能会让读代码的人有点糊涂,所有最好是能以关键字的形式给这些参数传值,而不要按位置去传。从设计函数的角度来说,还可以考虑用更加明确的方案以降低出错概率(参见25条)。 # 第24条:用None和docstring来描述默认值会变的参数 有时,我们想把那种不能够提前固定的值,当作关键字参数的默认值。例如,记录日志消息时,默认的时间应该是触发事件的那一刻。所以,如果调用者没有明确指定时间,那么就默认把调用函数的那一刻当成这条日志的记录时间。如果我们写如下代码来实现:
1
2
3
4
5
from time import sleep
from datetime import datetime

def log(msg, when=datetime.now()):
print(f'{when}: {msg}')
这样写是不行的,因为`datetime.now()`只会执行一次,所有每条日志的时间戳都会相同。参数的默认值只会在系统加载这个模块的时候,计算一遍,而不会在每次执行时都重新计算,这通常意味着这些默认值在程序启动后,就已经定下来了。

要想在Python里实现这种效果,惯用的办法是把参数的默认值设为None,同时在docstring文档里面写清楚,这个参数为None时,函数会怎么运作(参见第84条)。给函数写实现代码时,在内部对参数进行判断。

1
2
3
4
5
6
7
8
9
10
11
def log(msg, when=None):
"""Log a message with a timestamp

Args:
msg: message to print
when: datetime of when the message occured.
Defaults to the present time
"""
if when is None:
when = datetime.now()
print(f'{when}: {msg}')

把参数的默认值写成None还有个重要的意义,就是用来表示那种以后可能由调用者修改内容的默认值(例如某个可变容器)。例如,我们要写一个函数对采用JSON格式编码的数据进行解码。如果无法解码,那么就返回调用时所指定的默认结果:

1
2
3
4
5
6
7
import json

def decode(data, default={}):
try:
return json.loads(data)
except ValueError:
return default

这样的写法与前面的datetime.now()的例子有同样的问题,系统只会计算一次default参数(在加载这个模块时),所有每次调用这个函数时,给调用者返回的都是一开始分配的那个字段,这就相当于凡是以默认值返回来调用这个函数的代码都共用的同一份字典。这会让程序出现奇怪的效果:

1
2
3
4
5
6
7
8
9
10
11
foo = decode('bad data')
foo['stuff'] = 5

bar = decode('bad data')
bar['meep'] = 1
print('Foo:', foo)
print('Bar:', bar)

>>>
Foo: {'stuff': 5, 'meep': 1}
Bar: {'stuff': 5, 'meep': 1}

我们的本意是让这两次操作得到两个不同的空白字典,但是实际上foo和bar是同一个字典。要解决这个问题,可以把默认值设置为None,而且在docstring文档里面说明,函数在这个值为None时会怎么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def decode(data, default=None):
"""Load JSON data from a string

Args:
data: JSON data to decode
default: Value to return if decoding fails.
Defaults to an empty dictionary.
"""
try:
return json.loads(data)
except ValueError:
if default is None:
default = {}
return default

第25条:用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表

按关键字传递参数是Python函数的一项强大特性,这种关键字参数特别灵活,在很多情况下,都能让我们写出一看就冬的函数代码。

例如,计算两数相除的结果时,可能需要仔细考虑各种特殊情况。例如在除数为0的情况下,时抛出异常还是返回无穷;在结果益处的情况下,是抛出异常还是返回0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def safe_division(number, divisor, 
ignore_overflow=False,
ignore_zero_dvision=False):
try:
return number / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return number * float('inf')
else:
raise

result = safe_division(1.0, 0, ignore_overflow=False)

调用者可以根据自己的需要对ignore_overflow和ignore_zero_division参数进行指定,而且调用者使用关键字形式进行传递会让代码显得更清晰。但是,按照上面的函数定义形式,我们没有办法要求调用者必须按照关键字形式来指定这两个参数。他们还是可以用传统的写法,按位置给safe_divison函数传递参数。

1
save_division(number, divisor, False, True)

对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数(keyword-only argument),这样的话,写出来的代码就能清楚地反映调用者的想法了。这种参数只能用关键字来指定,不能按位置传递。具体操作方式是使用*符号把参数列表分成两组,左边是位置参数,右边是只能通过关键字指定的参数。

1
2
3
4
def save_division(number, divisor, *,
ignore_overflow=False,
ignore_zero_division=False):
...

这时,如果按位置给只能用关键字指定的参数传值,那么程序就会出错。

1
2
3
4
5
save_division(1.0, 0, True, False)

>>>
Traceback ...
TypeError: save_divisoin() takes 2 positional arguments but 4 were given

但是,这样改依然还是有问题,因为在这个函数中,调用者在提供number和divisor参数时,既可以按位置提供,也可以按关键字提供,还可以把这两种方式混起来用:

1
save_division(number=2, 5)

在未来,也许因为扩展函数的需要,甚至是因为代码风格的变化,或许要修改这两个参数的名字。

1
2
3
4
def save_division(numerator, denominator, *,
ignore_overflow=False,
ignore_zero_division=False):
...

这看起来只是字面上的微调,但之前所有通过关键字形式来指定这两个参数的调用代码,都会出错。其实最重要的问题在于,我们根本没有打算把number和divisor这两个名称纳入函数的接口;我们只是在编写函数时,随意挑了两个比较顺口的名称而已。

Python3.8引入了一项新特性,可以解决这个问题,这就是只能按位置传递的参数(positional-only argument)。这种参数与刚才的只能通过关键字指定的参数相反,它们必须按位置指定,绝不能通过关键字形式指定。具体操作方式是使用`/`符号表示左边的参数只能通过位置来指定:
1
2
3
4
def save_division(numerator, denominator, /, *,
ignore_overflow=False,
ignore_zero_division=False):
...
这时候,如果调用者使用关键字形式来指定numerator和denominator参数,程序就会在运行时抛出异常。 在函数的参数列表中, `/` 符号左侧的参数是只能按位置指定的参数,`*`符号右侧的参数则是只能按照关键字形式指定的参数。如果`*`出现在`/`的左边,则会出现语法错误;如果有参数出现在`/`和`*`的中间,那意味着,这两个符号之间的参数,既可以按照位置提供,又可以用关键字形式指定(其实,如果不特别说明,Python函数的参数全都属于这种参数)。 # 第26条:用functools.wraps定义函数修饰器 Python中有一种特殊的写法,可以用修饰器(decorator)来封装某个函数,从而让程序在执行这个函数之前与执行这个函数之后,分别运行某些代码。这意味着,调用者传给函数的参数值、函数返回给调用者的值,以及函数抛出的异常,都可以有修饰器访问并修改,这是个很有用的机制。 假如,我们要把函数执行时收到的参数与返回的值记录下来,这在调试递归函数时是很有用的,因为我们需要知道,这个函数执行没一层递归时,输入的是什么参数,返回的是什么参数。下面我们就定义一个修饰器,在实现这个修饰器时,用\*args与\*\*kwargs表示受修饰的原函数func所收到的参数:
1
2
3
4
5
6
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__}({args!r}, {kwargs!r}) -> {result!r}'
return result
return wrapper
写好之后,我们用`@`符号把修饰器运用在想要调试的函数上面。
1
2
3
4
5
6
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return (fibonacci(n-2) + fibonacci(n-1))
这样写,相当于先把受修饰的函数传给修饰器,然后将修饰器所返回的值赋给原来那个函数,这样的话,如果我们继续通过原来那个名字调用函数,那么执行的就是修饰器之后的函数。
1
2
3
4
5
6
7
fibonacci(4)

>>>
fibonacci((0,), {}) -> 0
fibonacci((1,), {}) -> 1
fibonacci((2,), {}) -> 1
fibonacci((1,), {}) -> 1

这样写确实能够满足要求,但是会带来一个我们不愿意看到的副作用。使用修饰器对fibonacci函数进行修饰后,fibonacci函数的名字本质上不再是fibonacci。

1
2
3
4
print(fibonacci)

>>>
<funtion trace.<locals>.wrapper at 0x108955dc>

这种现象解释起来并不困难。trace函数返回的,是它里面定义的wrapper函数,所以,当我们把这个返回值赋给fibonacci之后,fibonacci这个名称所表示的自然就是wrapper了。问题在于,这个可能会干扰需要利用反射机制来运作的工具。

例如,如果用内置的help函数来查看修饰后的fibonacci,那么打印出来的并不是我们想看的帮助文档,它本来应该打印前面定义时的那行’Return the n-th Fibonacci number文本才对’。

1
2
3
4
5
6
help(fibonacci)

>>>
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

对象序列化器也无法正常运作,因为它不能确定受修饰的那个原始函数的位置。

1
2
3
4
5
6
7
import pickle

pickle.dumps(fibonacci)

>>>
Traceback ...
AttibuteError: Can't pickle local object 'trace.<locals>.wrapper'

想要解决这些问题,可以改用functool内置模块之中的wraps辅助函数来实现。wraps本身也是个修饰器,它可以帮助你编写自己的修饰器。把它运用到wrapper函数上面,它就会将重要的元数据全部从内部函数复制到外部函数。

1
2
3
4
5
6
7
8
9
10
11
12
from functools import wraps

def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
...

return wrapper

@trace
def fibonacci(n):
...

现在我们就可以通过help函数看到正确的文档了,对象序列化器也可以正常使用,不会抛出异常了。

复制主要指通过互联网络在多台机器上保存相同数据的副本,通过数据复制方案,人们通常希望达到以下目的:

  • 使数据在地理位置上更接近用户,从而降低访问延迟

  • 当部分组件出现故障,系统依然可以继续工作,从而提高可用性

  • 扩展至多台机器以同时提供数据访问服务,从而提高吞吐量

本章讨论的内容都是在假设数据规模比较小,集群的每一台机器都可以保存数据集的完整副本。在接下来的第6章中,我们讨论单台机器无法容纳整个数据集的情况(即必须分区)。在后面的章节中,我们还将讨论复制过程中可能出现的各种故障,以及该如何处理这些故障。

如果复制的数据一成不变,那么复制就非常容易:只需将数据复制到每个节点,一次即可搞定。然而所有的技术挑战都在于处理哪些持续更改的数据,而这正是本章讨论的核心。我们将讨论是那种流行的复制变化数据的方法:主从复制、多节点复制和无主节点复制。几乎所有的分布式数据库都使用上述方法中的某一种,而三种方法各有优缺点。

主从复制

每个保存数据库完整数据集的节点称之为副本。当有了多个副本,不可避免地会引入一些问题:如何确保所有副本之间的数据是一致的?

对于每一笔数据写入,所有副本都需要随之更新,否则,某些副本将出现不一致。最常见的解决方案是基于主节点的复制,也即主从复制。主从复制的工作原理如下:

  1. 指定某一个副本为主副本(或主节点)。当客户写数据库时,必须将写请求发送给主副本

  2. 其他副本则全称为从副本(或从节点)。主副本把数据写入本地存储后,将数据更改为复制的日志或更改流发送给所有从副本。每个从副本获得更改日志后将其应用到本地,且严格保持与主副本相同的写入顺序。

  3. 客户端从数据库中读数据时,既可以在主副本也可以在从副本上执行查询。

//TODO: 贴图

许多关系型数据库都内置支持主从复制,例如PostgresSQL、Mysql、SQL Server。一些非关系型数据库如MongoDB、RethinkDB和Espresso也支持主从复制。另外,主从复制技术也不仅限于数据库,还广泛应用于分布式消息队列如Kafka和RabbitMQ,以及一些网络文件系统和复制块设备(如DRBD)

同步复制与异步复制

复制非常重要的一个设计选项是同步复制还是异步复制。对于关系数据库系统,同步或异步通常是一个可配置的选项;而其他系统则可能是硬性指定或者只能二选一。

结合一个例子,假设网站用户需要更新首页的头像图片。其基本流程是,客户将更新请求发送给主节点,主节点接收到请求,接下来将数据更新转发给从节点。最后,由主节点来通知客户端更新完成。

TODO://贴图

在上图中,从节点1的复制是同步的,即主节点需等待直到从节点1确认完成了写入,然后才会向用户报告完成,并且将最新的写入对其他客户端可见。而从节点2的复制是异步的:主节点发送完消息之后立即返回,不用等待从节点2完成确认。

从节点2在接收到复制日志并完成数据同步有一段延迟,通常情况下,复制速度会非常快,例如多数数据库系统可以在一秒之内完成所有从节点的更新,但是,系统其实并没有保证一定会在多长时间内完成复制。有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如,由于从节点刚从故障中恢复,或者系统已经接近最大设计上限,或者节点之间的网络出现问题。

同步复制的优点是,一旦向用户确认,从节点可以明确保证完成了与主节点的更新同步,数据已经处于最新版本。万一主节点发生故障,总是可以在从节点继续访问最新数据。缺点则是,如果同步的从节点无法完成确认(例如由于从节点发生崩溃,或者网络故障,或任何其他原因),写入就不能视为成功。节点会阻塞所有的写操作,直到同步副本确认完成。

因此,把所有的节点都配置为同步复制有些不切实际。因为这样的话,任何一个同步节点的中断都会导致整个系统更新停滞不前。实践中,如果数据库启用了同步复制,通常意味着其中某一个从节点是同步的,而其他节点则是异步模式。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模型。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。这种配置有时也称为半同步。

主从复制还经常会被配置为全异步模式。此时如果主节点发送失败且不可恢复,则所有尚未复制到从节点的写请求都会丢失。这意味着即使向客户端确认了写操作,却无法保证数据一定会持久化存储到。但全异步配置的优点则是,不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的吞吐性能更好。

全异步模式这种弱化的持久性听起来是一个非常不靠谱的折中设计,但是异步复制还是被广泛使用,特别是那些从节点数量巨大或者分布于广域地理环境。

配置新的从节点

如果出现一下情况时,如需要增加副本数以提高容错能力,或者替换失败的副本,就需要考虑增加新的从节点,但如何确保新的从节点和主节点保持数据一致呢?

简单地将数据文件从一个节点复制到另一个节点通常是不够的,主要是因为客户端仍在不断向数据库写入新数据,数据始终处于不断变化之中,因此常规的文件拷贝方式将会导致不同节点上呈现不同时间点的数据,这不是我们所期待的。

或许应该考虑锁定数据库(使其不可写)来使磁盘上的文件保持一致,但这会违反高可用的设计目标,好在我们可以做到不停机、数据服务不中断的前提下完成从节点的设置。逻辑上的主要操作步骤如下:

  1. 在某个时间点对主节点的数据副本产生一个一致性快照,这样避免长时间锁定整个数据库。
  2. 将此快照拷贝到新的从节点
  3. 从节点连接到主节点请求快照点所发生的数据更改日志。因为在第一步创建快照时,快照与系统复制日志的某个确定位置相关联。
  4. 获取日志之后,从节点来应用这些快照点之后所有数据变更,这个步骤称之为追赶。

建立新的从副本具体操作步骤可能因数据库系统而异,某些系统中,这个过程是全自动化的,而在某些系统中由于所设计的步骤、流程可能会比较复杂,甚至需要管理员手动介入。

处理节点失效

k8s 组件

一个k8s集群(cluster)由一组被称为节点(node)的机器组成,这些节点上运行k8s所管理的容器化应用,一个集群至少拥有一个节点

控制面板组件

控制面板组件对集群做出全局决策(比如调度),以及检测和响应集群事件(例如,当不满足部署的replicas时,启动新的pod)。

控制面板组件可以在集群中的任何节点上运行,然后,为了简单起见,通常会在同一个计算机上启动所有控制面板组件,并且不会在此计算机上运行用户容器。

kube-apiserver

api server组件实现了Kubernetes API供外部调用

etcd

etcd是兼具一致性和高可用性的键值数据库,可以作为保存kubernetes所有集群数据的后台数据库,这个数据库是给k8s自己使用的。

kube-scheduler

该组件负责监控新创建的、并未pods分配运行的节点。

kube-controller-manager

对控制器进行管理的组件,kubernetes有如下控制器

  • 节点控制器(Node Controller):负责节点出现故障时进行通知和响应

  • 任务控制器(Job Controller):检测代表一次性任务的job对象,然后创建pods来运行这些任务直至这些任务运行完成

  • 端点控制器(Endpoints Controller): 填充端点对象(即加入service和pod)

  • 服务账号和令牌控制器(Service Account & Token Controller):为新的命名空间创建默认账户和API访问令牌

cloud-controller-manager

云控制管理器使得你可以将你的集群连接到云服务商提供的API之上,并将与该云平台交互的组件同与你的集群交互的组件分离开来。cloud-controller-manager仅运行于云平台的控制回路。如果你在自己的环境中运行kubernetes,或者在本地计算机运行学习环境,所部署的环境中不需要云控制器管理器。下面的控制器包含对云平台驱动的依赖:

  • 节点控制器(Node Controller):用于在节点终止响应后检查云提供商以确定节点是否已被删除

  • 路由控制器(Route Controller):用于在底层云基础架构中设置路由

  • 服务控制器(Server Controller):用于创建、更新和删除云服务商提供负载均衡器

node组件

节点组件用于维护运行pod并提供kubernetes运行环境

kubelet

一个在集群中每个节点(node)上运行的代理,它的主要任务有如下几点:

  • pod管理:kubelet定期从所监听的数据源获取节点上pod/container的期望状态(运行容器、运行的副本数量、网络或者存储如何配置等等),并调用对应的容器平台接口达到这个状态。

  • 容器健康检查:kubelet创建了容器之后还要检查容器是否正常运行,如果容器运行出错,就要根据pod设置的重启策略进行处理

  • 容器监控:kubelet会监控所在节点的资源使用情况,并是定时向master报告

kube-proxy

kube-proxy是集群中每个节点(node)上运行的网络代理,kube-proxy维护节点上的网络规则(例如iptable和ipvs规则),这些网络规则允许从集群内部或外部的网络会话与pod进行网络通信。

容器运行时(Container Runtime)

容器运行环境时负责运行容器的软件,kubernetes支持多个容器允许环境:Docker、contrainerd、CRI-O以及任何实现Kubernetes CRI(容器运行环境接口)的容器。

节点(Node)

kubernetes通过将容器放入在节点(Node)上运行的Pod来执行你的工作负载。节点可以是一个虚拟机或者物理机器。通常一个集群会有会有若干个节点。节点上的组件包括:kubelet、容器运行时以及kube-proxy。

节点状态

一个节点的状态包含一下信息

  • 地址

    • HostName:由节点的内核设置,可以通过kubelet的--hostname-override参数进行覆盖

    • ExternalIP:通常是节点的可从集群外访问的IP地址

    • InternalIP:通常是节点的仅可在集群内部访问的IP地址

  • 状态

    • Ready:如节点是健康的并已准备好接收Pod则为True;False表示节点不健康而且不能接收Pod;Unknown表示节点控制器在最近的node-monitor-grace-period期间(默认40秒)没有收到节点的消息

    • DiskPressure:Ture表示节点存在磁盘空间压力,否则为False

    • MemoryPressure:Ture表示节点存在内存压力,即节点内存可用量低,否则为False

    • PIDPressure:True表示接单存在进程压力,即节点上进程过多,否则为False

    • NetworkUnavailable:True表示节点网络配置不正确,否则为False

  • 容量与可分配:CPU、内存和可以调度到节点上的Pod的个数上限

    • capacity:标示节点拥有的资源总量

    • allocatable:标示节点上可供普通Pod消耗的资源量

  • 信息:描述节点的一般信息,如内核版本、Kubernetes版本(kubelet和kube-proxy版本)、容器运行时详细信息,已经节点使用的操作系统。kubelet从节点收集这些信息并将其发送到Kubernetes API

k8s Namespace

集群内的虚拟概念,类似于资源池的概念,一个资源池里可以有各种资源类型,绝大多数的资源都必须属于某一个namespace。一个集群初始化安装好后,会默认有如下几个namespace

  • default

  • kube-node-release

  • kube-public

  • kube-system

  • kubenetes-dashboard

可以使用kubectl get namespaces来获取当前有哪些命名空间。在k8s中,不是所有的资源都必须归属于一个命名空间,可以使用kubectl api-resources命令来查看哪些资源需要归属到一个namespace下。另外可以使用kubectl create namespace xxx来创建namespace。

k8s常见的工作流程(以创建新pod为例)

  1. 用户准备一个配置文件,通过调用API向api server发起请求创建调用

  2. api server写etcd,并将api response返回给用户

  3. 同时scheduler持续监听api server(轮训?),获取是否有需要进行pod调度,则通过调度算法,计算出最适合该pod运行的节点,同时调用api将信息更新到etcd中

  4. kubelet同样持续监听api server,判断是否有新的pod需要创建到本节点,如果有新pod需要创建,创建pod,并调用api将相关信息写入etcd

数据编码格式

一个程序通常使用两种不同的数据表示形式:

  1. 在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)

  2. 将数据写入文件或者通过网络发送时,必须将其编码为某种自包含的的字节序列(例如json文档)。由于一个进程的指针对于其他进程来说是没有意义的,所以这个字节序列会与内存中使用的数据结构不大一样

因此,在这两种表示之间需要进行类型的转化。从内存中的表示到字节序列的表示称为编码(或序列化),相反的过程称为解码(或反序列化)

语言特定的编解码格式

许多语言都内置支持将内存中的对象序列化为字节序列的工具包,例如java有java.io.Serializable,ruby有Marshal,python有pickle等。这些序列化反序列化库使用其他很方便,它们只需要很少的代码就可保存和恢复内存中的对象。然后也有一些问题:

  • 它们通常和语言绑定在一起,而使用另外一种语言时访问数据就非常困难

  • 效率,有些编程语言的序列化反序列化工具库性能非常差,例如java

由于这些原因,使用语言内置的编码方案通常不是一个好主意

JSON、XML

JSON和XML是两种被广泛支持的,可有不同编程语言编写和读取的标准化编码,虽然XML经常被批评过于冗长与不必要的复杂。

JSON与XML都是文本格式,因此具有不错的可读性,但是它们也有一些小问题:

  • 对数字的编码有很多模糊之处。在XML中,无法区分数字和数字组成的字符串。JSON区分字符串和数字,但是不区分整数和浮点数,并且不指定精度。

    这在处理大数字时是一个问题,大于$2^{53}$的整数在IEEE 754标准中的双精度浮点数不能精确显示,所以这些数据在使用浮点数的语言(如JavaScript)中进行分析时,会得到不准确的结果。

  • JSON和XML对二进制数据支持得不是很好,通常的处理是将二进制数据用base64编码为文本来解决这个限制,虽然可行,但是会使数据变得混乱,而且会使二进制数据大小相对于原来增加33%左右

尽管存在一些缺陷,但JSON和XML已经可用于很多应用。特别是作为数据交换格式,在某些情况下,只要人们就格式本身达成一致,格式多么美观或者高效往往不太重要。

二进制编码

虽然JSON不像XML那样冗长,但是与二进制格式相比,两者仍然占用大量空间,虽然有很多JSON和XML的二进制变体(例如BSON),这些格式在一些细分领域被采用,但是没有一个像JSON和XML那样被广泛采用。另外,由于JSON和XML没有规定格式,所以需要在编码数据时包含所有的对象字段名称。

1
2
3
4
5
{
"username": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}

在上面的JSON文档中,它们必须在包含字符串userName,favoriteNumber,interest。

Thrift和Protocol Buffers

Apache Thrift和Protocol Buffers是目前使用得最广泛的两种二进制编码。Protocol Buffers最初是在Google开发的,Thrift最初是在Facebook开发的,并且都是在2007~2008年开源的。

Thrift和Protocol Buffers都需要模式来编码任意的数据,它们都使用接口描述语言来描述模式。Thrift的IDL示例:
1
2
3
4
5
struct Person {
1: required string userName,
    2: optional i64 favoriteNumber,
    3: optional list<string> interests
}
Protocol Buffers IDL示例:
1
2
3
4
5
message Person {
required string user_name = 1;
optional int64 favorte_number = 2;
repeated string interests = 3;
}
Thrift 和 Portocol Buffers各有对应的代码生成工具,采用和上面类似的模式定义,并生成支持多种编程语言的类,应用程序可以直接调用生成的代码来编码或解码该模式的数据。 ### Thrift的编码模式 Thrift有三种二进制编码格式和两种基于JSON的编码格式,这里主要讨论两种二进制的编码格式——BinaryProtocol和CompactProtocol(这两种二进制编码是跨语音的,第三种DenseProtocol只支持c++实现)。 先看看使用BinaryProtocol编码上面同格式的json数据是怎么的: 每一个字段都使用一个字节进行类型标注(用于指定它是字符串、整数、列表等),并且在需要时指定数据长度(包括字符串的长度、列表中的项数),数据中的字符串被编码成UTF-8格式的编码。与JSON相比,最大的区别是没有字段名,相反,编码数据包含数字类型的字段标签(1、2和3)。这些是模式定义中出现的数字,字段标签就像字段的别名,用来指示当前的字段。 上面的JSON文本编码需要占用81字节(去掉空格),而BinaryProtocol编码只需要59字节。 Thrift CompactProtocol编码在语意上等同于BinaryProtocol,它编码出来同格式的数据如下: CompactProtocol编码出来的数据只有34字节,它通过将字段类型和字段标签打包到单个字节中,并使用可变长度整数来对数字进行编码。对数字1337,不使用全部8字节,而是使用两个字节进行编码,每字节的最高位来指示是否还有更多的字节(但是这也意味着每个字节都会失去一位有效数字,在某些情况下使用字节数还会比BinaryProtocol用得多)。 ### Protocol Buffers的编码模式 Protocol Buffers只有一种二进制编码格式,对上面的JSON数据进行编码,它的结果如下: Protocol Buffers将字段类型和字段标签打包到单个字节中,并且数字类型只有变长编码的方式,并且对于list类型的编码是通过重复类型和字段tag来实现的,这一点和thirft也不同。 ## 字段标签和字段增删 在Thrift和Protocol Buffers的编码中可以看到,字段标签对编码数据的含义至关重要,我们可以轻松更改模式中字段的名称,但不能随便更改字段的标签,因为编码永远不直接引用字段名称。

可以添加新的字段到模式,只要给每个字段一个新的标记号码。如果旧的的代码试图解析新代码编码的数据,遇到它不能识别的字段标记号码时,则它可以简单忽略该字段。

新代码也可以加解析旧代码编码的数据,因为之前的标记号码仍有意义,唯一的要求是,如果添加一个新的字段,不能使其成为必须的字段。如果将添加的字段设置为required,当新代码读取旧代码写入的数据时,则会检测失败,因为旧代码不会写入添加的required字段。

删除字段和添加字段一样,只不过只能删除可选的字段,不能删除必须的字段,而且删除之后的字段号码,后面添加字段时,不要使用已经删除的字段号码,因为很有可能仍然有代码还在写入已经删除的字段。

字段类型变动

另外一个问题,是否可以改变字段的数据类型呢?这是有可能的,但是会存在数据精度丢失或者数据被截断的风险。

Avro

avro 是另一种二进制编码格式,它与Protocol Buffers和Thrift有着一些有趣的差异。由于Thrift不适合Hadoop的用例,因此Avro在2009年作为Hadoop的子项目而启动。

Avro也使用模式来指定编码的数据结构,它有两种模式语音,一种Avro IDL易于人工编辑,另一种(基于JSON)更易于机器读取。

用Avro IDL示例:

1
2
3
4
5
record Person {
string userName;
    union { null, long } favoriteNumber = null;
    array<string> interests;
}

注意,模式中没有标签编号,其对应等价的JSON表示:

1
2
3
4
5
6
7
8
9
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}

使用avro对之前的json数据进行编码其结果如下

从上图中的字节序列中可以看出,其中没有标识字段或数据类型,用于标记长度的字节中的最后一位用来标记数据是否为null,编码只是由连在一起的一些列值组成。一个字符串只是一个长度前缀,后紧跟UTF-8字节流。

为了解析二进制数据流,需要按照定义的模式顺序遍历这些字段,然后采用模式告诉的每个字段的类型,这意味着avro的编解码和模式是强相关的,那么Avro是如何支持模式的演化的呢?

写模式和读模式

avro在对某些数据进行编码时,它使用的模式成为写模式,反之,当arvo解码某些数据时使用的模式成为读模式。

avro的关键思想是,写模式和读模式不必是完全一模一样的,它们只需要保持兼容。只需要给出对应的写模式和读模式给avro,相关avro库内部会解决这种差异。

  • 如果字段顺序不同,解析模式匹配字段名称即可

  • 如果字段在写模式中有,但是在读模式中无,则忽略该字段

  • 如果字段在读模式中有,但是在写模式中没有,则使用读模式中声明的默认值填充

模式演化规则

为了保持兼容性,只能添加或删除具有默认值的字段。如果要添加一个没有默认值的字段,新的reader将无法获取旧的writer写的数据,因此将破坏向后兼容性。如果删除没有默认值的字段,旧reader将无法读取新writer写入的数据,因此将破坏向前兼容性。 在某些编程语言中,null是所有变量可以接受的默认值,但在avro中并非如此:如果要允许字段为null,则必须使用联合类型,例如`union{ null, long, string}`字段,表示该字段可以是数字、字符串或null,只有当null是联合的分支之一时,才可以使用null作为默认值(确切地说,null必须是联合的第一个可能类型)。这比默认情况下所有类型都可为空显得更加冗长一些,但是通过明确什么能为null和不能为null可以帮助防止一些错误。 avro对于类型转换更方便,因为编码后并不会标记字段类型,但是对于字段名称的改变会比较难处理。avro可以在模式中为字段定义别名,因此旧writer模式可以和新reader的字段别名进行匹配做到向后兼容,但是不能做到向前兼容。

对于在union类型中添加新分支也是向后兼容的,但不能向前兼容。删除union类型的分支是向前兼容的,但不能向后兼容。

avro使用的场景

由于avro的编辑码需要确切的知道使用的写模式和读模式是什么,如果在每个编码数据中都包含一份写模式用于reader去解码是不太现实的,因为模式有时甚至比编码数据还要大得多,这样使用avro二进制编码所节省的空间都变得没有意义。

但是在一些特点的使用场景下可以避免这个问题

  • 有很多记录的大文件

    • avro的一个常见用于,尤其是在Hadoop的上下文中,是用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码,该文件的writer可以仅在文件的开头包含writer的模式信息
  • 在数据库中保存写模式

    • 将写模式的变更记录在数据库中,并使用版本号进行标记。在每个编码记录的开始只需要包含一个版本号,reader根据版本号去数据库中获取到对应的写模式
  • 网络长连接

    • 当两个进程通过网络连接进行通信时,可以在建立连接时协商使用的模式,然后在后续的生命周期中使用该模式。这也是Avro RPC协议的基本原理。

动态生成的模式

与Protocol Buffers和Thrift相比,Avro的一个优点是不包含任何标签号,这样的关键之处在于avro对动态生成的模式更友好。例如,假如有一个关系数据库,想把它的内容转储到一个二进制文件中,如果使用avro,可以很容易根据数据库表的关系模式来动态生成一个avro模式(一个数据表对应一个avro record,每个列成为该record中的一个字段),并使用该模式对数据库中的数据进行编码。

现在,假如数据库中的数据表的关系模式发生了变化,则可以动态生成新的avro模式,并使用新的avro模式来导出数据。相比之下,如果使用Thrift和Protocol Buffers,则可能必须手动分配字段标签:每次数据库模式更改时,管理员都必须手动更新从数据库名到字段标签的映射(这个部署可以通过代码来完成自动化,但是编写代码时必须非常小心,不要分配到以前使用的字段标签,尤其是删除或者增加列的情况)。

代码生成和动态类型语言

Thrift和Protocol Buffers依赖于代码生成:在定义了模式之后,可以使用选择的编程语言生成实现此模式的代码。这在Java、C++等静态类型语言中很有用,因为它允许使用高效的内存结构来解码数据。

在动态类型编程语言中,如JavaScript、Ruby或Python,因为没有编译时类型检查,生成代码没有太多意义。代码生成在这些语言中经常被忽视。

Avro为静态类型编程语言提供了可选的代码生成,但是它可可以在不生成代码的情况下直接使用,入股有一个avro文件(它嵌入了writer模式信息),可以简单地使用avro库打开它,并用和查看JSON文件一样的方式查看数据,该文件也是自描述的,它包含了所有必须的元信息。

模式的优点

  • 它们在大多数情况下比各种“二进制JSON”变体更紧凑,可以节省编码数据中的字段名称

  • 有较好的向前和向后兼容性支持

  • 对于静态类型编程语言来说,从模式生成代码的能力是很有用的,它能编译时进行类型检查

数据流模式

二进制数据流从一个进程流向另外一个进程通常有以下几种方式:

  • 通过数据库

  • 通过服务调用

  • 通过异步消息传递

基于数据库的数据流

在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行编码。可能只有一个进程会方法数据库,在这种情况下,reader只有一个进程,但是这个reader可能在不同时候用不同版本的模式对数据库进行读写,例如reader用模式写入后,就进行了服务更新,之后会使用新模式来读取数据库的内容。此时向后兼容性就很重要,否则未来的自己将无法解码以前自己写的内容。

但是,一般而言,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的服务,也可能是一个服务的几个实例。无论哪种情况,访问数据库的进程可能某些运行着较新的代码,而其他运行着较旧的代码。这意味着既有可能数据库中的值由较旧的代码写入,较新的代码进行读取,又有可能由较新的代码写入,较旧的代码读取,所以向前和向后的兼容性都很重要。

另外,还存在一个问题,假设在记录模式中添加了一个字段,并且较新的代码将该新字段的值写入数据库,随后,旧版本的代码(尚不知道该新字段)将其读取、更新记录并写回数据库,在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。之前讨论的编码格式支持未知字段的保存,但是有时候还需要注意应用层面的影响,例如,如果将数据库中的值解码为应用程序中的对象,然后重新编码这些模型对象,则在转换过程中可能会丢失未知字段,在编写代码的时候要有这方面的意识

不同时间写入不同的值

数据库通常支持在任何时候写入,这意味着在单个数据库中,可能有一些值是在5ms前写入的,而有些值可能是在5年前写入的。

部署新版本的服务应用程序时,可以在几分钟内用新版本完全替换旧版本。但是数据库内容的情况并不是这样的:将旧数据重写(迁移)为新模式当然是可能的,但在大型数据集上执行此操作代价不菲,因此很多数据库都进可能避免此操作。大多数关系数据库允许进行简单的模式更改,例如添加具有默认值为空的新列,而不重写现有数据。读取旧行时,数据库会为磁盘上编码数据缺失的所有列填充为空值。因此,模式演化支持整个数据库看起来像是采用了单个模式编码,即使底层存储可能包含各个版本模式所编码的记录。

归档存储

有些时候我们会不时地为数据库创建快照,例如用于备份或加载到数据仓库,在这种情况下,数据转储通常使用最新的编码模式进行编码,即使源数据库中的数据包含了不同时代的各种模式版本。由于无论如何都要复制数据,所以此时最好对数据副本进行统一编码。

由于数据转储时一次写入的,而且以后可能不可改变,因此像arvo这样的编码格式非常适合。

基于服务的数据流:REST和RPC

网络http服务

http服务有两种流行的服务方法:REST和SOAP,它们在设计理念方面几乎式截然相反的。

REST不是一种协议,而是一个基于HTTP原则的设计理念,它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控、身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,根据REST原则所设计的API称为RESTful。

相比之下,SOAP是一种基于XML的协议,用于发送网络API请求,虽然它最长用与HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能,相反,它带有庞大而复杂的多种相关标准和新增的各种功能,SOAP Web服务的API使用被称为WSDL(Web Service Description Language,一种基于XML的语言)。WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架进行解码)来访问远程服务。

由于WSDL的设计目标不是人类可读的,而且SOAP消息通常过于复杂,无法手动构建,SOAP用户严重依赖工具支持、代码生成和IDE。对于没有SOAP提供商支持的编程语言用户来说,试图集成SOAP服务非常困难。由于这些原因,尽管它在某些大型企业中仍有使用,但是已经不再收到大多数小公司的青睐。

远程过程调用RPC

远程过程调用的思想从20世纪70年代以来就一直存在。RPC模式试图使向远程网络发送请求看起来与在同一进程调用编程语言中的函数或方法相同。虽然RPC起初看起来很方便,但是这种方法在根本上是有缺陷的,网络请求与本地函数调用非常不同:

  • 本地函数调用是可预测的,并且成功或失败仅取决于参数的控制。网络请求是不可预测的:请求或响应可能有由于网络问题而丢失,或者远程计算机可能速度慢或不可用,这些问题完全不在控制范围之内,网络问题很常见,因此必须有所准备,例如重试失败的请求。

  • 本地函数调用要门返回一个结果,要么抛出一个异常,或者永远不会返回(因为进入无限循环或者进程崩溃)。网络请求有另外一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,根本不知道发生了什么:如果没有接收到来自远程服务的响应,无法直到请求是否成功

  • 当你重试失败的网络请求时,可能发生请求实际上通过,但是只有响应丢失的情况,在这种情况下,重试将导致该操作被执行多次,除非操作是幂等的。

  • 每次调用本地功能时,通常需要大致相同的时间来执行,网络请求比函数调用要慢很多,而且其延迟会随着网络环境和机器负载而波动

  • 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。

  • 客户端和服务可以用不同的编程语音实现,所以RPC框架必须将数据从一种语音翻译成另外一种语音,这样可能会出问题,因为不是所有的语音都具有相同的数据类型(例如JavaScript数字大于$2^{53}$的问题)。

  • 所有这些因素意味着尝试使远程服务看起来像编程语音中的本地函数调用一样是毫无意义的,因为这是两个根本不同的事情。REST的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实。

RPC的当前方向

尽管有这样那样的问题,RPC不会消失:thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现。

新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,gPRC支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间的一系列请求和响应。 由于REST具有方便实验和调试(只需使用web浏览器或者命令行工具curl,无需任何代码生成或软件安装即可发送请求),能被所有主流的编程语音和平台所支持,还有大量可用的工具的生态系统(服务器、缓存、负载均衡、代理、防火墙、监控、调试和测试工具),REST已经成为公共API的主要风格,RPC的主要重点在于同一组织内部服务器之间的请求。

RPC数据编码的演化

对于RPC可演化性,关注的是可以独立更改和部署RPC客户端和服务器。与通过数据库流动的数据相比,一般来说都是所有的服务器先进行更新,其次再是客户端进行更新。所以RPC数据编码的演化需要考虑的是在请求上具有向后兼容性(服务器端对还未更新的客户端发来的请求可以识别并处理),并在响应上具有向前兼容(未更新的客户端对已经完成更新的服务端返回的响应也能够处理)。

异步消息传递中的数据流

进程间异步消息的传递通常是通过消息代理(消息队列)实现的,与直接RPC相比,使用消息代理有几个优点:

  • 如果接受消息的进程不可用或过载,消息代理可以充当消息代理,从而提供系统的可靠性。

  • 避免消息发送的进程需要知道接收进程的IP地址和端口号

  • 它允许将一条消息广播给多个接收方

  • 将消息发送方和接收方进行解耦