集群相关
kubectl config get-contexts
- 查看当前有哪些集群context可以使用
kubectl config use-context <context name>
- 切换kubectl当前的集群context
路由可以分为以下三种:
主机路由:表示到某台具体主机的路由(目前来说很少使用了)
网络路由:表达到某个网段的路由
默认路由:即0.0.0.0/0
对应的路由项,它用于在路由表中查不到匹配项时进行的默认路由
路由表不用记录同一网段中的其他主机的ip,同一网络中的主机通信直接通过数据链路层的ARP协议查询到IP地址对应的MAC地址后进行通信即可。
在linux中可以使用ip route
命令来查看路由表。路由表项由以下几个部分组成:
destination:路由目标路径
interface:路由器的出口
gateway
直连情况(即两个ip之间没有通过路由器相连):不需要配置gateway,或者值为0.0.0.0
非直连情况:需要配置gateway,其值为下一个路由器在本网络中(当路由器对接多个网络时,会有多个网络地址)的ip地址
pod是k8s集群中的最小调度单元,一个pod中可以有多个容器。k8s引入pod,而不直接对容器进行调度的原因有如下两个:
一个是为了将容器的实现和k8s平台自身引擎的实现进行解耦,从而做到可以支持多种类型的容器(docker、rkt)
另外一个是可以让多个容器共享网络、存储、进程空间,减少资源消耗
1 | apiVersion: v1 |
定义好一个pod描述之后,就可以使用kubectl create -f xxx.yaml
来创建一个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 | kubectl delete -f <pod yaml file> # 根据配置文件删除 |
pod的健康检查由kubelet来进行,pod健康检查有两种机制:
LivenessProbe:存活性探测,用于判断容器是否存活,即pod是否为running状态。如果LivenessProbe探针探测到容器不健康,则kubelet将kill掉容器,并根据容器的重启策略是否重启(如果不配置,默认会进行重启),如果一个容器不包含LivenessProbe探针,则kubelet认为容器的LivenessProbe探针的返回值永远成功,即任务容器是健康的。
1 | ... |
ReadinessProbe:可用性探测,用于判断容器是否正常提供服务,即容器的Ready是否为True,是否可以接收请求。如果ReadinessProbe探测失败,则容器的ready将为False,Endpoint Controller控制器会将此Pod的Endpoint从对应的service的Endpoint列表中移除,不再将任何请求调度到此Pod上,直到下次探测成功。
1 | ... |
1 | ... |
1 | ... |
1 | ... |
1 | apiVersion: v1 |
1 | apiVersion: v1 |
1 | spec: |
1 | ... |
1 | apiVersion: apps/v1 |
1 | kubectl -n <namespace> scale deployment <deployment name> --replicas=<n> |
1 | kubectl -n <namespace> apply -f <xxx.yaml> |
1 | kubectl -n <namespace> set image deployment <deployment name> <container name>=<image> |
1 | kubectl -n <namespace> rollout history deployment <deployment name> |
1 | kubectl -n <namespace> rollout undo deployment <deployment name> --to-revision=<revsion number> |
1 | apiVersion: apps/v1 |
通过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 | apiVersion: v1 |
创建好service后,在集群内部就可以直接使用service name + pod对服务进行访问,因为集群内部的dns会记录相关解析规则
Cluster IP也是一个虚拟地址,其目的是为了方便集群内部服务直接的通信,只能在k8s集群内部进行访问,如果需要集群外部访问集群内部服务,实现方式之一为使用NodePort方式。NodePort会默认在30000—32767之间,不指定会随机使用其中的一个。
1 | apiVersion: v1 |
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模块
对于k8s的service,无论是Cluster-IP还是NodePort的形式,都是四层的负载,集群内的服务如果实现7层的负载均衡,这就需要借助与Ingress。Ingress控制器的实现方式有很多,例如nginx、contour、haproxy,trafik,istio。
ingress-nginx是7层的负载均衡器,根据用户编写的ingress规则(创建的ingress的yaml文件),动态的去更改nginx服务的配置文件。
1 | apiVersion: networking.k8s.io/v1beta1 |
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地址将数据包转发到网桥的不同端口上。
有了网桥之后,容器在启动时,会执行如下操作:
创建一对虚拟接口/网卡,也就是veth pair
veth pair一端桥接到默认的名称为docker0
的网桥或者其他指定网桥上,并具有一个唯一的名字,如veth9953b75
veth pair一端放到新启动的容器内部,并修改名字作为eth0
从虚拟网桥可用地址段中(也就是bridge对应的network)获取一个空闲地址分配给容器内的eth0
容器内部配置默认路由到网桥
如果容器内部需要访问外部网络,需要经过容器内部的eth0网卡、虚拟网桥、宿主机网卡最终访问到外网。如果容器内部需要访问其他容器网络,需要经过容器内部eth0网卡、虚拟网桥、其他容器内部etho0最终访问到其他容器。
host模式:容器内部不会创建网络命名空间(Network Namespace),容器共享宿主机的网络空间。
container模式:这个模式指定新创建的容器和已经存在的一个容器共享一个网络命名空间(Network Namespace)、网卡、ip。这种模式在一些特殊的场景中非常有用,例如k8s的pod,k8s为pod创建一个基础设施容器,该pod下的其他容器都以container模式共享这个技术设施容器的网络命名空间,相互之间以localhost访问,构成一个统一的整体。
none模式:只会在容器内创建网络命名空间(Network 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 | ls -l /proc/<pid>/ns |
通过Namespace可以保证容器之间的隔离,但是无法控制容器可以占用多少资源,如果其中的某一个容器正在执行CPU计算密集型任务,那么就会影响其他容器任务的性能与执行效率,导致多个容器相互影响并且抢占资源。
CGroup(Control Group)就是能够隔离宿主机上的物理资源,例如CPU、内存、磁盘I/O和网络带宽。而我们需要做的就是把容器进程加入到指定的CGroup中。
Linux Namespace和cgroup分别解决了容器的资源隔离与资源限制,那么容器是很轻量的,通常每台机器中可以运行几十上百个容器,这些容器可能会公用一个image。所以容器在启动的时候,不可能各自将这个image复制一份。Docker在内部使用镜像分层存储以及UnionFS来实现多个容器共用一个镜像。
镜像分层存储:docker镜像是由一系列的层组成的,每层代表Dockerfile中的一条指令,比如下面的Dockerfile文件:
1 | FROM ubuntu:15.04 |
这个dockerfile文件最终生成镜像的时候会生成四层,这四层是不可写的,而通过镜像实例化容器的过程,其实就是在就是在这四层之上添加了一个可写层,也就是我们通常说的容器层。而对容器层的操作,主要是利用了写时复制(CoW,copy on write)的技术。例如,如果当前操作会改变下面四层的某一层,docker会先将该层拷贝到容器层,然后再在容器层中进行操作。
UnionFS 其实是一种为Linux操作系统设计的,用于把多个文件系统联合到同一个挂载点的文件系统服务。
一般我们在编写项目代码时,由于初始的需求简单,需要用到的数据结构也简单,所以我们会经常使用python提供的容器来用做类的内部状态记录。但是随着需求的迭代,我们可能会使用到数据结构可能会更变得复杂,这时,我们不能简单的对用于记录内部状态的容器进行嵌套,而是应该考虑将内部的某些状态封装成一个类,并在外部的接口类中对这些数据类进行组合。
Python有很多内置的API,都允许我们传入某个函数来定制它的行为,这种函数被成为hook函数,API在执行的时候,会调用这些hook函数。例如,list类的sort方法的key参数。在其他编程语音中,hook函数可能是通过抽象类或者接口来定义的(例如Java),但在python中一般是直接使用无状态的函数(即不会对内部状态进行修改)
Python里面有一种很精简的写法,可以根据某个序列或可迭代对象派生出一份新的列表。用这种写法写成的表达式,叫作列表推导。假设我们要用列表中每个元素的平方值构建一份新的列表:
1 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
这种功能也可以使用内置函数map实现,它能够从多个列表中分别取出当前位置上的元素,并把它们当作参数传给映射函数,以求出新列表在这个位置上的元素值:
1 | alt = map(lambda x: x**2, a) |
列表推导还有一个地方比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 | even_squares_dict = {x: x**2 for x in a if x % 2 == 0} |
如果改用map与filter实现,那么还必须调用相应的构造器(constructor),这会让代码变得很长,需要分成多行才能写得下。这样看起来比较乱,不如使用推导机制的代码清晰。
1 | alt_dict = dict(map(lambda x: (x, x**2), filter(lambda x: x % 2 == 0, a))) |
列表推导除了最基本的用法外,列表推导还支持多层循环。例如,要把二维列表转化为普通的一维列表,那么可以在推导时,使用两条for子表达式。这些子表达式会按照从左到右的顺序解读。
1 | matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] |
这样写简单易懂,也是多层循环在列表推导中的合理用法。多层循环还可以用来重制那种两层深的结构。例如,要根据二维矩阵里每个元素的平方值来构建一个新的二维矩阵:
1 | squared = [[x**2 for x in row] for row in matrix] |
如果推导过程中还要再加一层循环,那么语句就会变得很长,必须把它分成多行来写,例如下面是把一个三维矩阵转化成普通一维列表的代码:
1 | my_lists = [ |
1 | flat = [] |
推导的时候,可以使用多个if条件,如果这些if条件出现在同一层循环内,那么它们之间默认是and关系,也就是必须同时成立。例如,如果要用原列表中大于4且是偶数的值来构建新列表,那么既可以连用两个if,也可以只用一个if,下面两种写法效果相同:
1 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
在推导时,每一层的for子表达式都可以带有if条件。假如要根据原矩阵构建新的矩阵,把其中各元素之和大于等于10的那些行选出来,而且只保留其中能够被3整除的那些元素。这个逻辑用列表推导来写,并不需要太多的代码,但是这些代码理解起来会很困难:
1 | matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] |
1 | stock = { |
1 | result = {name: get_batches(stock.get(name, 0), 8) |
1 | result = {name: batches for name in order |
1 | result = {name: (tenth := count // 10) |
但是,如果把赋值表达式移动到if
条件里面,就可以解决这个问题:
1 | result = {name: tenth for name, count in stock.items() |
如果函数要返回的是个包含许多结果的序列,那么最简单的办法就是把这些结果放到列表中。例如,我们要返回字符串里每个单词的首字母在字符串中所对应的下标:
1 | def index_words(text): |
上面的index_words
函数也可以改用生成器来实现。生成器由包含yield表达式的函数创建。下面就定义一个生成器函数,实现与刚才那个函数相同的效果:
1 | def index_words_iter(the_text): |
1 | it = index_words_iter(the_text) |
如果确实要制作一份列表,那可以把生成器函数返回的迭代器传给内置的list函数:
1 | result = list(index_words_iter(the_text)) |
index_words_iter
相对于index_words
来说,不必一次性把所有结果都保存到列表中,在数据的数据较多的情况下,index_words
有可能因为耗尽内存而导致程序崩溃。
使用这些生成器函数时,只有一个地方需要注意,就是调用者无法重复使用函数所返回的迭代器,因为迭代器是有状态的(参见第31条)。
如果函数接受的参数是个可迭代对象,那么我们可能会在函数中对其迭代多次。例如,我们要分析美国德克萨斯州的游客数量。原始数据保存在一份列表中,其中的每个元素表示每年有多少游客(单位是百万)。我们要统计每个城市的游客数占游客总数的百分比。
1 | def normalize(numbers): |
在normalize
函数中会对numbers
参数进行两次迭代,一次是在sum
函数的调用中,一次是在for
循环中。
如果我们给nomalize
函数传入参数的是一个列表,我们可以的得到正确的结果:
1 | visits = [15, 35, 80] |
但是如果我们传给nomalize
函数的是个迭代器,例如在数据规模较大,需要从文件中读取数据时:
1 | def read_visits(data_path): |
奇怪的是,对read_visits
所返回的迭代器调用normalize
函数之后,并没有得到结果:
1 | it = read_visits('my_numbers.txt') |
出现这种状况的原因在于,迭代器只能进行一次迭代,并且迭代后不可重置。在sum
函数中,已经对迭代器进行过一次迭代了,所以在for
循环中由于没有数据可迭代,所以也就不会进行循环内部。
一种解决办法是让normalize
函数接受另外一个函数,使它每次要使用迭代器时,都要向那个函数去索要:
1 | def normalize_func(get_iter): |
这么做虽然可行,但是每次调用normalize_func
都需要传入一个函数,更好的方法是自定义一种容器类,并让其实现迭代器协议(iterator protocol)。
1 | class ReadVisits: |
1 | visits = ReadVisits(p[11.538, 26.924, 61.538]ath) |
1 | value = [len(x) for x in open('my_file.txt')] |
1 | it = (len(x) for x in open('my_file.txt')) |
生成器表达式还有个强大的特性,就是可以组合起来,例如,可以用刚才那条生成器表达式所形成的it迭代器作为输入,编写一条新的生成器表达式:
1 | roots = (x, x**0.5) for x in it) |
生成器(yield)有很多好处,能够解决很多常见的问题。生成器的用途很广,所以许多程序都会频繁使用它们,而且是一个连一个地用。
例如,我们要编写一个图形程序,让它在屏幕上面移动图像,从而形成动画效果。假设要实现这样一段动画:图片先快速移动一段时间,然后暂停,接下来慢速移动一段时间。我们用生成器来表示图片在当前时间段内应该保持的速度:
1 | def move(period, speed): |
为了把完整的动画制作出来,我们需要调用三次move:
1 | def animate(): |
上面这种写法的问题在于,animate函数里有很多重复的地方。比如它反复使用for结构来操作生成器,而且每个for结构都使用相同的yield表达式。为了解决这个问题,我们可以改用yield from
形式的表达式来实现。这种形式,会先从嵌套进去的小生成器里面取值,如果该生成器已经用完,那么程序的控制流程就会回到yield from
所在的这个函数之中:
1 | def animate(): |
上面使用yield from
的代码看上去更清晰、更直观,并且这种实现方式的运行效率要更快。
Python内置的itertools模块中有很多函数,可以用来对迭代器进行一些高级处理。下面分三大类,列出其中最重要的函数。
连接多个迭代器
chain
: 可以把多个迭代器从头连接到尾形成一个新的迭代器
1 | it1 = iter([1, 2, 3]) |
repeat
: 可以制作这样的一个迭代器,它会不停得输出某个值,或者通过第二个参数来控制最多能输出几次
1 | it = itertools.repeat('hello', 3) |
cycle
: 可以制作这样的一个迭代器,它会循环地输出某段内容之中的各个元素
1 | it = itertools.cycle([1, 2]) |
tee
: 可以让一个迭代器分裂成多个平行迭代器,具体个数由第二个参数指定。如果这些迭代器推进的速度不一样,那么程序可能要用大量内存做缓存,以存放进度落后的迭代器会用到的元素。
1 | it1, it2, it3 = itertools.tee([1, 2, 3], 3) |
zip_longest
: 它与内置的zip函数类似(参见第8条),但区别是,如果源迭代器的长度不同,那么它会用fillvalue
参数的值来填补提前耗尽的那些迭代器所留下的空缺。
1 | keys = ['one', 'two', 'three'] |
过滤迭代器中的元素
islice
: 可以在不拷贝数据的前提下,按照下标切割源迭代器,这种切割方式与标准的序列切片以及步进机制类似
1 | it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) |
takewhile
: 会一值从源迭代器里获取元素,直到某元素让测试函数返回False为止:
1 | it1 = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) |
dropwhile
: 与takewhile相反,dropwhile会一直跳过源序列里的元素,直到某元素让测试函数返回True为止,然后它会从这个地方开始逐个取值
1 | it1 = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) |
filterfalse
: 和内置的filter函数相反,它会逐个输出源迭代器里使得测试函数返回False的那些元素
1 | it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) |
用源迭代器中的元素合成新元素
accumulate
: accumulate 会从源代码迭代器取出一个元素,并把已经累计的结果与这个元素一起传给表示累加逻辑的函数,然后输出那个函数的计算结果,并把结果当成新的累计值。这与内置的functools模块中的reduce函数,实际上是一样的,只不过这个函数每次只给出一项累加值。如果调用者没有指定表示累加逻辑的函数,那么默认的逻辑就是两值相加。
1 | it = iter([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) |
product
: 会从一个或多个源迭代器里获取元素,并计算笛卡尔积,
1 | single = itertools.product([1, 2], repeat=2) |
product
: 会从源迭代器中能给出的全部元素,并逐个输出由其中N个元素组成的有序排列
1 | it = itertools.permutations([1, 2, 3], 2) |
combinations
: 会从源迭代器中能给出的全部元素,并逐个输出由其中N个元素组成的无序组合
1 | it = itertools.combinations([1, 2, 3], 2) |
combinations_with_replacement
: 和combination类似,但是它允许同一个元素在组合里多次出现:
1 | it = itertools.combinations_with_replacement([1, 2, 3], 2) |
python的unpacking机制允许python函数返回一个以上的值,函数返回一个以上的值的时候,实际上返回的是一个元组。
1 | def get_min_max(numbers): |
在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值(参考第13条)
1 | def get_avg_ratio(numbers): |
当我们用超过三个变量去接收函数的返回值时,会很容易出现将顺序弄错的情况。所以一般来时,一个元组最多只拆分到三个普通变量或者拆分到两个普通变量与一个万能变量(带星号的变量)。假如要拆分的值确实很多,那最好还是定义一个轻便的类或namedtuple(参见第37条),并让函数返回这样的实例。
编写工具函数(utility function)时,许多python程序员都爱用None这个返回值来表示特殊情况。对于某些函数来说,这或许有几分道理。例如,我们要编写一个辅助函数计算两数相除的结果,在除数是0的情况下,返回None似乎合理,因为这种除法的结果是没有意义的。
1 | def careful_devide(a, b): |
但是,如果传给careful_divide函数的被除数为0时,会怎么样呢?在这种情况下,只要除数不为0,函数返回的结果就应该是0。但是问题时,别人在使用这个工具函数时,在if表达式中不会明确判断返回值是否是None,而是去判断返回值是否相当于False:
1 | x, y = 0, 5 |
上面这种if语句,会把函数返回0的情况和返回None的情况一样处理。由于这种写法经常出现在python代码里,因此,像careful_divide这样,用None来表示特殊情况的函数是很容易出错的。有两种办法可以减少这样的错误。
第一种,利用二元组把计算结果分成两部分返回,元组的首个元素表示操作是否成功,第二个元素表示计算的实际值:
1 | def careful_divide(a, b): |
但是,有些调用方总喜欢忽略元组的第一个部分。第二种方法比刚才那种更好,就是不采用None表示特例,而是向调用方抛出异常,让他们自己去处理。
1 | def careful_divide(a, b): |
我们还可以利用类型注解指明函数返回float类型,这样就对外说明不会返回None了,但是,我们无法在函数的接口上说明函数可能抛出哪些异常,所以,我们只好把有可能抛出的异常写在文档里面,并希望调用方能够根据这份文档适当得捕获相关的异常(参见第84条)。
1 | def careful_divide(a: float, b:float) -> float: |
总结:用返回值None表示特殊情况是很容易出错的,因为这样的值在条件表达式里面没法与0、空字符串、空数组之类的值进行区分,这些值都相当于False。
个人觉得作者在此处使用的代码示例不是很好,这个抛出异常版本的careful_divide函数根据没啥实际用处,使用者还不如直接去捕获ZeroDivisionError,作者的目的可能只是为了简明得解释这条建议。
假设,现在有一个需求,我们要给列表中的元素排序,而且要优先把在另外一个群组的元素放在其他元素的前面。实现这种做法的一种常见方案,是把辅助函数通过key参数传给列表的sort方法,让这个方法根据辅助函数返回的值来决定元素在列表中的先后顺序,辅助函数先判断当前元素是否处在重要群组里,如果在,就把返回值的第一项写成0,让它能够排在不属于这个组的那些元素之前
1 | def sort_priority(values, group): |
在sort_priority函数中,引用了外部函数的group参数,在一个内部函数中,对外部作用域的变量进行引用,那么内部函数就被认为是闭包。
假设现在需求新增,sort_priority函数还需要告诉我们,列表里面是否有位于重要群组之中,那么第一个想法就是添加一个标志位:
1 | def sort_priority(values, group): |
虽然排序结果没有问题,但是却发现标志本应该为True,但是返回的确是False。
在表达式中引用某个变量时,Python解释器会按照下面的顺序,在各个作用域(scope)里面查找这个变量,以解析这次引用(变量出现在=
右边时)。
当前函数作用域
外围作用域(例如包含当前函数的其他函数所对应的作用域)
包含当前代码的那个模块所对应的作用域(也叫全局作用域,global scope)
内置作用域(built-in scope,也就是包含len与str等函数的那个作用域)
如果这些作用域中都没有定义名称相符的变量,那么程序就抛出NameError异常。
当对变量进行赋值时(变量出现在=
左边),需要分两种情况处理:如果变量已经定义在当前作用域中,那么直接把新值赋给它就行了。如果当前作用域中不存在这个变量,那么即使外围作用域里有同名的变量,Python也还是会把这次赋值操作当成变量的定义来处理。这会产生一个重要的效果,也就是说,Python会把包含赋值操作的这个函数当作新定义的这个变量的作用域。这也就解释了为什么found还是为False。
这种问题有时也称为作用域bug(scoping bug),Python新手可能认为这样的赋值规则很奇怪,但实际上Python是故意这么设计的,因为这样可以防止函数的局部变量污染外围模块,假设不这么做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这不仅混乱,而且会让全局变量之间彼此交互影响,从而导致更多难以探查的bug。
Python有一种特殊的写法,可以把闭包里面的数据赋给闭包外面的变量。用`nonlocal`描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。然而,nonlocal有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。1 | def sort_priority(values, group): |
nonlocal语句清楚地说明,我们要把数据赋给闭包之外的变量。有一种跟它互补的语句,叫做global,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域中。
1 | def to_global(): |
1 | def log(msg, values): |
1 | def log(msg, *values): |
1 | numbers = [1, 2, 3] |
1 | def my_generator(): |
1 | def log(sequence, msg, *values): |
1 | def remainder(number, divisor): |
1 | remiander(20, 7) |
1 | remainder(number=20, 7) |
1 | remainder(20, number=7) |
如果有一份字典,而且字典里面的内容能够用来调用remainder这样的函数,那么可以吧**运算符加在字典前面,这会让Python把字典里面的键值以关键字参数的形式传给函数。
1 | my_kwargs = { |
调用函数时,带**操作符的参数可以和位置参数或关键字参数混用,只要不重复指定就行。
1 | my_kwargs = { |
也可以对多个字典分别施加**操作,只要这些字典所提供的参数不重叠就好。
1 | my_kwargs = { |
1 | def print_parameters(**kwargs): |
1 | def calculate_flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2): |
1 | from time import sleep |
要想在Python里实现这种效果,惯用的办法是把参数的默认值设为None,同时在docstring文档里面写清楚,这个参数为None时,函数会怎么运作(参见第84条)。给函数写实现代码时,在内部对参数进行判断。
1 | def log(msg, when=None): |
把参数的默认值写成None还有个重要的意义,就是用来表示那种以后可能由调用者修改内容的默认值(例如某个可变容器)。例如,我们要写一个函数对采用JSON格式编码的数据进行解码。如果无法解码,那么就返回调用时所指定的默认结果:
1 | import json |
这样的写法与前面的datetime.now()的例子有同样的问题,系统只会计算一次default参数(在加载这个模块时),所有每次调用这个函数时,给调用者返回的都是一开始分配的那个字段,这就相当于凡是以默认值返回来调用这个函数的代码都共用的同一份字典。这会让程序出现奇怪的效果:
1 | foo = decode('bad data') |
我们的本意是让这两次操作得到两个不同的空白字典,但是实际上foo和bar是同一个字典。要解决这个问题,可以把默认值设置为None,而且在docstring文档里面说明,函数在这个值为None时会怎么做:
1 | def decode(data, default=None): |
按关键字传递参数是Python函数的一项强大特性,这种关键字参数特别灵活,在很多情况下,都能让我们写出一看就冬的函数代码。
例如,计算两数相除的结果时,可能需要仔细考虑各种特殊情况。例如在除数为0的情况下,时抛出异常还是返回无穷;在结果益处的情况下,是抛出异常还是返回0:
1 | def safe_division(number, divisor, |
调用者可以根据自己的需要对ignore_overflow和ignore_zero_division参数进行指定,而且调用者使用关键字形式进行传递会让代码显得更清晰。但是,按照上面的函数定义形式,我们没有办法要求调用者必须按照关键字形式来指定这两个参数。他们还是可以用传统的写法,按位置给safe_divison函数传递参数。
1 | save_division(number, divisor, False, True) |
对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数(keyword-only argument),这样的话,写出来的代码就能清楚地反映调用者的想法了。这种参数只能用关键字来指定,不能按位置传递。具体操作方式是使用*
符号把参数列表分成两组,左边是位置参数,右边是只能通过关键字指定的参数。
1 | def save_division(number, divisor, *, |
这时,如果按位置给只能用关键字指定的参数传值,那么程序就会出错。
1 | save_division(1.0, 0, True, False) |
但是,这样改依然还是有问题,因为在这个函数中,调用者在提供number和divisor参数时,既可以按位置提供,也可以按关键字提供,还可以把这两种方式混起来用:
1 | save_division(number=2, 5) |
在未来,也许因为扩展函数的需要,甚至是因为代码风格的变化,或许要修改这两个参数的名字。
1 | def save_division(numerator, denominator, *, |
这看起来只是字面上的微调,但之前所有通过关键字形式来指定这两个参数的调用代码,都会出错。其实最重要的问题在于,我们根本没有打算把number和divisor这两个名称纳入函数的接口;我们只是在编写函数时,随意挑了两个比较顺口的名称而已。
Python3.8引入了一项新特性,可以解决这个问题,这就是只能按位置传递的参数(positional-only argument)。这种参数与刚才的只能通过关键字指定的参数相反,它们必须按位置指定,绝不能通过关键字形式指定。具体操作方式是使用`/`符号表示左边的参数只能通过位置来指定:1 | def save_division(numerator, denominator, /, *, |
1 | def trace(func): |
1 | @trace |
1 | fibonacci(4) |
这样写确实能够满足要求,但是会带来一个我们不愿意看到的副作用。使用修饰器对fibonacci函数进行修饰后,fibonacci函数的名字本质上不再是fibonacci。
1 | print(fibonacci) |
这种现象解释起来并不困难。trace函数返回的,是它里面定义的wrapper函数,所以,当我们把这个返回值赋给fibonacci之后,fibonacci这个名称所表示的自然就是wrapper了。问题在于,这个可能会干扰需要利用反射机制来运作的工具。
例如,如果用内置的help函数来查看修饰后的fibonacci,那么打印出来的并不是我们想看的帮助文档,它本来应该打印前面定义时的那行’Return the n-th Fibonacci number文本才对’。
1 | help(fibonacci) |
对象序列化器也无法正常运作,因为它不能确定受修饰的那个原始函数的位置。
1 | import pickle |
想要解决这些问题,可以改用functool内置模块之中的wraps辅助函数来实现。wraps本身也是个修饰器,它可以帮助你编写自己的修饰器。把它运用到wrapper函数上面,它就会将重要的元数据全部从内部函数复制到外部函数。
1 | from functools import wraps |
现在我们就可以通过help函数看到正确的文档了,对象序列化器也可以正常使用,不会抛出异常了。
复制主要指通过互联网络在多台机器上保存相同数据的副本,通过数据复制方案,人们通常希望达到以下目的:
使数据在地理位置上更接近用户,从而降低访问延迟
当部分组件出现故障,系统依然可以继续工作,从而提高可用性
扩展至多台机器以同时提供数据访问服务,从而提高吞吐量
本章讨论的内容都是在假设数据规模比较小,集群的每一台机器都可以保存数据集的完整副本。在接下来的第6章中,我们讨论单台机器无法容纳整个数据集的情况(即必须分区)。在后面的章节中,我们还将讨论复制过程中可能出现的各种故障,以及该如何处理这些故障。
如果复制的数据一成不变,那么复制就非常容易:只需将数据复制到每个节点,一次即可搞定。然而所有的技术挑战都在于处理哪些持续更改的数据,而这正是本章讨论的核心。我们将讨论是那种流行的复制变化数据的方法:主从复制、多节点复制和无主节点复制。几乎所有的分布式数据库都使用上述方法中的某一种,而三种方法各有优缺点。
每个保存数据库完整数据集的节点称之为副本。当有了多个副本,不可避免地会引入一些问题:如何确保所有副本之间的数据是一致的?
对于每一笔数据写入,所有副本都需要随之更新,否则,某些副本将出现不一致。最常见的解决方案是基于主节点的复制,也即主从复制。主从复制的工作原理如下:
指定某一个副本为主副本(或主节点)。当客户写数据库时,必须将写请求发送给主副本
其他副本则全称为从副本(或从节点)。主副本把数据写入本地存储后,将数据更改为复制的日志或更改流发送给所有从副本。每个从副本获得更改日志后将其应用到本地,且严格保持与主副本相同的写入顺序。
客户端从数据库中读数据时,既可以在主副本也可以在从副本上执行查询。
//TODO: 贴图
许多关系型数据库都内置支持主从复制,例如PostgresSQL、Mysql、SQL Server。一些非关系型数据库如MongoDB、RethinkDB和Espresso也支持主从复制。另外,主从复制技术也不仅限于数据库,还广泛应用于分布式消息队列如Kafka和RabbitMQ,以及一些网络文件系统和复制块设备(如DRBD)
复制非常重要的一个设计选项是同步复制还是异步复制。对于关系数据库系统,同步或异步通常是一个可配置的选项;而其他系统则可能是硬性指定或者只能二选一。
结合一个例子,假设网站用户需要更新首页的头像图片。其基本流程是,客户将更新请求发送给主节点,主节点接收到请求,接下来将数据更新转发给从节点。最后,由主节点来通知客户端更新完成。
TODO://贴图
在上图中,从节点1的复制是同步的,即主节点需等待直到从节点1确认完成了写入,然后才会向用户报告完成,并且将最新的写入对其他客户端可见。而从节点2的复制是异步的:主节点发送完消息之后立即返回,不用等待从节点2完成确认。
从节点2在接收到复制日志并完成数据同步有一段延迟,通常情况下,复制速度会非常快,例如多数数据库系统可以在一秒之内完成所有从节点的更新,但是,系统其实并没有保证一定会在多长时间内完成复制。有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如,由于从节点刚从故障中恢复,或者系统已经接近最大设计上限,或者节点之间的网络出现问题。
同步复制的优点是,一旦向用户确认,从节点可以明确保证完成了与主节点的更新同步,数据已经处于最新版本。万一主节点发生故障,总是可以在从节点继续访问最新数据。缺点则是,如果同步的从节点无法完成确认(例如由于从节点发生崩溃,或者网络故障,或任何其他原因),写入就不能视为成功。节点会阻塞所有的写操作,直到同步副本确认完成。
因此,把所有的节点都配置为同步复制有些不切实际。因为这样的话,任何一个同步节点的中断都会导致整个系统更新停滞不前。实践中,如果数据库启用了同步复制,通常意味着其中某一个从节点是同步的,而其他节点则是异步模式。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模型。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。这种配置有时也称为半同步。
主从复制还经常会被配置为全异步模式。此时如果主节点发送失败且不可恢复,则所有尚未复制到从节点的写请求都会丢失。这意味着即使向客户端确认了写操作,却无法保证数据一定会持久化存储到。但全异步配置的优点则是,不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的吞吐性能更好。
全异步模式这种弱化的持久性听起来是一个非常不靠谱的折中设计,但是异步复制还是被广泛使用,特别是那些从节点数量巨大或者分布于广域地理环境。
如果出现一下情况时,如需要增加副本数以提高容错能力,或者替换失败的副本,就需要考虑增加新的从节点,但如何确保新的从节点和主节点保持数据一致呢?
简单地将数据文件从一个节点复制到另一个节点通常是不够的,主要是因为客户端仍在不断向数据库写入新数据,数据始终处于不断变化之中,因此常规的文件拷贝方式将会导致不同节点上呈现不同时间点的数据,这不是我们所期待的。
或许应该考虑锁定数据库(使其不可写)来使磁盘上的文件保持一致,但这会违反高可用的设计目标,好在我们可以做到不停机、数据服务不中断的前提下完成从节点的设置。逻辑上的主要操作步骤如下:
建立新的从副本具体操作步骤可能因数据库系统而异,某些系统中,这个过程是全自动化的,而在某些系统中由于所设计的步骤、流程可能会比较复杂,甚至需要管理员手动介入。
一个k8s集群(cluster)由一组被称为节点(node)的机器组成,这些节点上运行k8s所管理的容器化应用,一个集群至少拥有一个节点
控制面板组件对集群做出全局决策(比如调度),以及检测和响应集群事件(例如,当不满足部署的replicas时,启动新的pod)。
控制面板组件可以在集群中的任何节点上运行,然后,为了简单起见,通常会在同一个计算机上启动所有控制面板组件,并且不会在此计算机上运行用户容器。
api server组件实现了Kubernetes API供外部调用
etcd是兼具一致性和高可用性的键值数据库,可以作为保存kubernetes所有集群数据的后台数据库,这个数据库是给k8s自己使用的。
该组件负责监控新创建的、并未pods分配运行的节点。
对控制器进行管理的组件,kubernetes有如下控制器
节点控制器(Node Controller):负责节点出现故障时进行通知和响应
任务控制器(Job Controller):检测代表一次性任务的job对象,然后创建pods来运行这些任务直至这些任务运行完成
端点控制器(Endpoints Controller): 填充端点对象(即加入service和pod)
服务账号和令牌控制器(Service Account & Token Controller):为新的命名空间创建默认账户和API访问令牌
云控制管理器使得你可以将你的集群连接到云服务商提供的API之上,并将与该云平台交互的组件同与你的集群交互的组件分离开来。cloud-controller-manager仅运行于云平台的控制回路。如果你在自己的环境中运行kubernetes,或者在本地计算机运行学习环境,所部署的环境中不需要云控制器管理器。下面的控制器包含对云平台驱动的依赖:
节点控制器(Node Controller):用于在节点终止响应后检查云提供商以确定节点是否已被删除
路由控制器(Route Controller):用于在底层云基础架构中设置路由
服务控制器(Server Controller):用于创建、更新和删除云服务商提供负载均衡器
节点组件用于维护运行pod并提供kubernetes运行环境
一个在集群中每个节点(node)上运行的代理,它的主要任务有如下几点:
pod管理:kubelet定期从所监听的数据源获取节点上pod/container的期望状态(运行容器、运行的副本数量、网络或者存储如何配置等等),并调用对应的容器平台接口达到这个状态。
容器健康检查:kubelet创建了容器之后还要检查容器是否正常运行,如果容器运行出错,就要根据pod设置的重启策略进行处理
容器监控:kubelet会监控所在节点的资源使用情况,并是定时向master报告
kube-proxy是集群中每个节点(node)上运行的网络代理,kube-proxy维护节点上的网络规则(例如iptable和ipvs规则),这些网络规则允许从集群内部或外部的网络会话与pod进行网络通信。
容器运行环境时负责运行容器的软件,kubernetes支持多个容器允许环境:Docker、contrainerd、CRI-O以及任何实现Kubernetes CRI(容器运行环境接口)的容器。
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
集群内的虚拟概念,类似于资源池的概念,一个资源池里可以有各种资源类型,绝大多数的资源都必须属于某一个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。
用户准备一个配置文件,通过调用API向api server发起请求创建调用
api server写etcd,并将api response返回给用户
同时scheduler持续监听api server(轮训?),获取是否有需要进行pod调度,则通过调度算法,计算出最适合该pod运行的节点,同时调用api将信息更新到etcd中
kubelet同样持续监听api server,判断是否有新的pod需要创建到本节点,如果有新pod需要创建,创建pod,并调用api将相关信息写入etcd
一个程序通常使用两种不同的数据表示形式:
在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)
将数据写入文件或者通过网络发送时,必须将其编码为某种自包含的的字节序列(例如json文档)。由于一个进程的指针对于其他进程来说是没有意义的,所以这个字节序列会与内存中使用的数据结构不大一样
因此,在这两种表示之间需要进行类型的转化。从内存中的表示到字节序列的表示称为编码(或序列化),相反的过程称为解码(或反序列化)
许多语言都内置支持将内存中的对象序列化为字节序列的工具包,例如java有java.io.Serializable
,ruby有Marshal
,python有pickle
等。这些序列化反序列化库使用其他很方便,它们只需要很少的代码就可保存和恢复内存中的对象。然后也有一些问题:
它们通常和语言绑定在一起,而使用另外一种语言时访问数据就非常困难
效率,有些编程语言的序列化反序列化工具库性能非常差,例如java
由于这些原因,使用语言内置的编码方案通常不是一个好主意
JSON和XML是两种被广泛支持的,可有不同编程语言编写和读取的标准化编码,虽然XML经常被批评过于冗长与不必要的复杂。
JSON与XML都是文本格式,因此具有不错的可读性,但是它们也有一些小问题:
对数字的编码有很多模糊之处。在XML中,无法区分数字和数字组成的字符串。JSON区分字符串和数字,但是不区分整数和浮点数,并且不指定精度。
这在处理大数字时是一个问题,大于$2^{53}$的整数在IEEE 754标准中的双精度浮点数不能精确显示,所以这些数据在使用浮点数的语言(如JavaScript)中进行分析时,会得到不准确的结果。
JSON和XML对二进制数据支持得不是很好,通常的处理是将二进制数据用base64编码为文本来解决这个限制,虽然可行,但是会使数据变得混乱,而且会使二进制数据大小相对于原来增加33%左右
虽然JSON不像XML那样冗长,但是与二进制格式相比,两者仍然占用大量空间,虽然有很多JSON和XML的二进制变体(例如BSON),这些格式在一些细分领域被采用,但是没有一个像JSON和XML那样被广泛采用。另外,由于JSON和XML没有规定格式,所以需要在编码数据时包含所有的对象字段名称。
1 | { |
在上面的JSON文档中,它们必须在包含字符串userName,favoriteNumber,interest。
Apache Thrift和Protocol Buffers是目前使用得最广泛的两种二进制编码。Protocol Buffers最初是在Google开发的,Thrift最初是在Facebook开发的,并且都是在2007~2008年开源的。
Thrift和Protocol Buffers都需要模式来编码任意的数据,它们都使用接口描述语言来描述模式。Thrift的IDL示例:1 | struct Person { |
1 | message Person { |
可以添加新的字段到模式,只要给每个字段一个新的标记号码。如果旧的的代码试图解析新代码编码的数据,遇到它不能识别的字段标记号码时,则它可以简单忽略该字段。
新代码也可以加解析旧代码编码的数据,因为之前的标记号码仍有意义,唯一的要求是,如果添加一个新的字段,不能使其成为必须的字段。如果将添加的字段设置为required,当新代码读取旧代码写入的数据时,则会检测失败,因为旧代码不会写入添加的required字段。
删除字段和添加字段一样,只不过只能删除可选的字段,不能删除必须的字段,而且删除之后的字段号码,后面添加字段时,不要使用已经删除的字段号码,因为很有可能仍然有代码还在写入已经删除的字段。
另外一个问题,是否可以改变字段的数据类型呢?这是有可能的,但是会存在数据精度丢失或者数据被截断的风险。
avro 是另一种二进制编码格式,它与Protocol Buffers和Thrift有着一些有趣的差异。由于Thrift不适合Hadoop的用例,因此Avro在2009年作为Hadoop的子项目而启动。
Avro也使用模式来指定编码的数据结构,它有两种模式语音,一种Avro IDL易于人工编辑,另一种(基于JSON)更易于机器读取。
用Avro IDL示例:
1 | record Person { |
注意,模式中没有标签编号,其对应等价的JSON表示:
1 | { |
使用avro对之前的json数据进行编码其结果如下
从上图中的字节序列中可以看出,其中没有标识字段或数据类型,用于标记长度的字节中的最后一位用来标记数据是否为null,编码只是由连在一起的一些列值组成。一个字符串只是一个长度前缀,后紧跟UTF-8字节流。
为了解析二进制数据流,需要按照定义的模式顺序遍历这些字段,然后采用模式告诉的每个字段的类型,这意味着avro的编解码和模式是强相关的,那么Avro是如何支持模式的演化的呢?
avro在对某些数据进行编码时,它使用的模式成为写模式,反之,当arvo解码某些数据时使用的模式成为读模式。
avro的关键思想是,写模式和读模式不必是完全一模一样的,它们只需要保持兼容。只需要给出对应的写模式和读模式给avro,相关avro库内部会解决这种差异。
如果字段顺序不同,解析模式匹配字段名称即可
如果字段在写模式中有,但是在读模式中无,则忽略该字段
如果字段在读模式中有,但是在写模式中没有,则使用读模式中声明的默认值填充
对于在union类型中添加新分支也是向后兼容的,但不能向前兼容。删除union类型的分支是向前兼容的,但不能向后兼容。
由于avro的编辑码需要确切的知道使用的写模式和读模式是什么,如果在每个编码数据中都包含一份写模式用于reader去解码是不太现实的,因为模式有时甚至比编码数据还要大得多,这样使用avro二进制编码所节省的空间都变得没有意义。
但是在一些特点的使用场景下可以避免这个问题
有很多记录的大文件
在数据库中保存写模式
网络长连接
与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这样的编码格式非常适合。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服务非常困难。由于这些原因,尽管它在某些大型企业中仍有使用,但是已经不再收到大多数小公司的青睐。
远程过程调用的思想从20世纪70年代以来就一直存在。RPC模式试图使向远程网络发送请求看起来与在同一进程调用编程语言中的函数或方法相同。虽然RPC起初看起来很方便,但是这种方法在根本上是有缺陷的,网络请求与本地函数调用非常不同:
本地函数调用是可预测的,并且成功或失败仅取决于参数的控制。网络请求是不可预测的:请求或响应可能有由于网络问题而丢失,或者远程计算机可能速度慢或不可用,这些问题完全不在控制范围之内,网络问题很常见,因此必须有所准备,例如重试失败的请求。
本地函数调用要门返回一个结果,要么抛出一个异常,或者永远不会返回(因为进入无限循环或者进程崩溃)。网络请求有另外一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,根本不知道发生了什么:如果没有接收到来自远程服务的响应,无法直到请求是否成功
当你重试失败的网络请求时,可能发生请求实际上通过,但是只有响应丢失的情况,在这种情况下,重试将导致该操作被执行多次,除非操作是幂等的。
每次调用本地功能时,通常需要大致相同的时间来执行,网络请求比函数调用要慢很多,而且其延迟会随着网络环境和机器负载而波动
调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。
客户端和服务可以用不同的编程语音实现,所以RPC框架必须将数据从一种语音翻译成另外一种语音,这样可能会出问题,因为不是所有的语音都具有相同的数据类型(例如JavaScript数字大于$2^{53}$的问题)。
所有这些因素意味着尝试使远程服务看起来像编程语音中的本地函数调用一样是毫无意义的,因为这是两个根本不同的事情。REST的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实。
尽管有这样那样的问题,RPC不会消失:thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现。
新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,gPRC支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间的一系列请求和响应。 由于REST具有方便实验和调试(只需使用web浏览器或者命令行工具curl,无需任何代码生成或软件安装即可发送请求),能被所有主流的编程语音和平台所支持,还有大量可用的工具的生态系统(服务器、缓存、负载均衡、代理、防火墙、监控、调试和测试工具),REST已经成为公共API的主要风格,RPC的主要重点在于同一组织内部服务器之间的请求。对于RPC可演化性,关注的是可以独立更改和部署RPC客户端和服务器。与通过数据库流动的数据相比,一般来说都是所有的服务器先进行更新,其次再是客户端进行更新。所以RPC数据编码的演化需要考虑的是在请求上具有向后兼容性(服务器端对还未更新的客户端发来的请求可以识别并处理),并在响应上具有向前兼容(未更新的客户端对已经完成更新的服务端返回的响应也能够处理)。
进程间异步消息的传递通常是通过消息代理(消息队列)实现的,与直接RPC相比,使用消息代理有几个优点:
如果接受消息的进程不可用或过载,消息代理可以充当消息代理,从而提供系统的可靠性。
避免消息发送的进程需要知道接收进程的IP地址和端口号
它允许将一条消息广播给多个接收方
将消息发送方和接收方进行解耦