0%

第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函数看到正确的文档了,对象序列化器也可以正常使用,不会抛出异常了。

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地址和端口号

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

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

第11条:学会对序列进行切片

python支持从序列里面切割出一部分内容,让我们能够放轻松地获取原序列地某个子集合,最简单的用法就是切割内置的list,str与bytes。其实,凡是实现了__getitem____setitem__这两个特殊方法的类都可以进行切割。

切片最基本的用法就是somelist[start:end]这一形式来切割,也就是从start开始一直取到end这个位置,但不包含end本身的元素。如果从头开始切割列表,那就应该省略start,如果一直取到末尾,那就应该省略end。用负数作下标表示从倒数第k个。

1
2
3
4
5
a[:]
a[:5]
a[2:]
a[-3:]
a[1:-1]
使用切片时,即使下标越界也不会有问题,python会自动忽略不存在的元素。使用切片切割处理的列表是一份新的列表,即使把某个元素替换掉,也不会影响原列表中的相应位置。

切片可以出现在赋值符号的左侧,表示用右侧那些元素把原列表中位于这个范围之内的元素换掉。与unpacking形式的赋值不同,这种赋值不要求等号两边所指定的元素个数必须相同,但是如果元素个数不同,列表的长度会发生变化。

1
2
3
a[2:5] = [2, 3, 4] # list's length not change
a[2:5] = [2, 3, 4, 4] # list's length will grow 1
a[2:5] = [2, 3] # list's length will desc 1

第12条:在切片中指定步进

python的切片还支持不仅切片形式,也就是somelist[start:end:stride]。这种形式从start开始取,每n个元素里面选取一个。

1
2
3
4
5
x = ['red', 'orange', 'yellow', 'green', 'blue', 'purple']
print(x[::2])

>>>
['red', 'yellow', 'blue']

当步进值设置为负数时,表示从start开始,从后往前取

1
2
3
4
print(x[-3: 2])

>>>
['green', 'orange']

设置步进值为负数的一个应用就是用于将列表进行反转

1
2
3
4
print(x[::-1])

>>>
['purple', 'blue', 'green', 'yellow', 'orange', 'red']

第13条:通过带星号的unpacking来捕获多个元素

python基本unpacking操作有一项限制,就是必须提前需要确定要拆解的序列的长度。但是如果不事先知道长度,而且想把一些元素仍然以list的形式保存,一种办法是通过获取长度,然后通过下标获取加切片的形式:

1
2
3
4
car_ages = [0, 9, 4, 8, 7, 20, 19, 1, 6, 15]
car_ages_desc = sorted(car_ages, reverse=True)
oldest = car_ages_desc[0]
others = car_ages_desc[1:]

更好的方式是使用带*的unpacking:

1
oldest, *others = car_ages_desc

这种带星号的表达式可以出现在任意位置,所以它能够获取序列中的任何一段元素:

1
oldest, *others, youngest = car_ages_desc
但是带星号的unpacking要求必须至少有一个普通的接受变量和它匹配,并且同一级unpacking里面至多只能有一个带星号的变量
1
2
*all = car_ages_desc # syntax error
first, *middle, *second_middle, last = [1, 2, 3, 4] # syntax error

另外,如果要拆分的列表里以及没有元素留给带*的变量,那么该变量会是一个长度为0的列表

1
2
3
4
5
6
short_list = [1, 2]
first, second, *rest = short_list

print(short_list)
>>>
[]

使用带星号的unpacking需要注意一点,带星号的这部分总是会形成一份列表,这有可能会耗尽计算机的全部内存并导致程序崩溃,尤其是在和生成器(yield方法)一起使用的时候。

第14条:用sort方法的key参数来表示复杂的排序逻辑

内置的列表类型提供了名叫sort的方法,可以按照多项指标给list实例中的元素进行排序。在默认情况下,sort方法总是按照自然升序排列列表内的元素。例如,如果列表中的元素都是整数,那么它就按数值从小到大排列

1
2
3
4
5
6
numbers = [93, 86, 11, 68, 70]
numbers.sort()
print(numbers)

>>>
[11, 68, 70. 86, 93]

凡是具备自然顺序的内置类型几乎都可以用sort方法进行排列,例如字符串、浮点数等。但是一般的对象又该如何排序呢?比如,假如这里定义了一个People类:

1
2
3
4
5
6
7
8
9
10
11
12
class People(object):
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender


peoples = [
People('Tom', 23, 'male'),
People('Jane', 22, 'female'),
People('henry', 24, 'male'),
]

如果仅仅这样写,那么这个由该类的对象所构成的列表是没办法用sort方法排序的,因为sort方法发现,排序所需要的特殊方法并没有在People类中实现

1
2
3
4
5
perples.sort()

>>>
Traceback ...
TypeError: '<' not supported between instances of 'People' and 'People'

虽然我们可以在People类中定义一些特殊的方法让我们在无须额外参数的情况下就能直接在这些类的实例所构成的列表上进行排序(参见第73条)。但是更为常见的情况是,很多对象需要在不同的情况下按照不同的标准排序,此时定义自然排序实际上没有意义。这些排序标准通常是针对对象中的某个属性,我们可以把这样的排序逻辑定义成函数,然后将这个函数传给sort方法的key参数。这个函数只有一个参数,用于指代列表中有待排序的对象,函数返回的应该是一个可比较的,具有自然顺序的值。

1
peoples.sort(key=lambda p:p.name)

有些时候我们可能需要用多个标准来排序。例如,在名字位首要标准的情况下,再按年龄进行排序,这种怎么实现呢?最简单的办法是利用元组类型来实现。两个元组是可以进行比较的,因为元组类型本身已经定义了自然顺序,也就是说,sort方法所要求的特殊方法(例如__it__方法),它都已经定义好了。元组在实现这些特殊方法时会依次比较每个位置的那两个对应元素,直到能够确定大小为止,注意,对应位置的元素要能够比较大小,否则也会报异常。

利用元组的特性,我们来对peoples数组先按名称排序,再按年龄排序:

1
peoples.sort(key=lambda p: (p.name, p.age))
但是,利用元组有一种功能不能实现,就是key函数所构造的这个元组只能按照同一个排序方向来对比它所表示的各项指标(要是升序,就都得是升序;要是降序,就都得是降序),所以不太好实现name按降序排序,而age按升序排序的效果。sort方法可以指定reverse参数,这个参数会同时影响元组中的各项指标。 一种解决方法是,如果其中一项是数字,那么可以在实现key函数时,利用取反操作符让该指标对应的值取反,以此达到按照不同方向排序的目的。
1
peoples.sort(key=lambda p: (p.name, -p.age))
但是,这个技巧并不适合所有的类型,例如,对字符串类型就无法应用取反操作符。 这时候,我们就应该考虑sort方法的一项特性,那就是这个方法是个稳定的排序算法。这意味着,如果key函数认定两个值相等,那么这两个值在排序结果中的先后顺序会与它们在排序前一致,于是,我们可以在同一个列表上多次调用sort方法,每次指定不同的排序指标,但是需要把次要指标放在第一轮排序,把首要指标放在第二轮。
1
2
peoples.sort(key=lambda p: p.age, reverse=True)
peoples.sort(key=lambda p: p.name)

无论有多少项排序指标都可以按照这种思路来实现,而且每项指标可以分别按照各自的方向来排,也就是越主要的那项排序指标放在越后一轮处理。

尽管两种思路都能实现两种的效果,但是只调用一次sort,还是要比多次调用sort更为简单,所以,在实现多个指标按不同方向排序时,应该优先考虑让key函数返回元组,并按需对元组中的相应指标进行取反,只有在万不得已的时候,才考虑多次调用sort方法

第15条:不要过分依赖给字典添加条目时所用的顺序

S在python3.5与之前的版本中,迭代字典(dict)时所看到的顺序是任意的,即不一定与当初把这些键值对添加到字典时的顺序相同,而且每次迭代的顺序也不固定。

1
2
3
4
5
6
7
8
9
# python 3.5
baby_names = {
'cat': 'kitten',
'dog': 'puppy'
}
print(baby_names)

>>>
{'dog': 'puppy', 'cat': 'kitten'}
之所以出现这种效果,是因为字典类型以前是使用哈希表算法来实现的,这个算法通过内置的hash函数与一个随机的种子数来运行,而该种子数会在每次启动python解释器时确定,所以,这样的机制导致这些键值对在字典中的存放顺序不一定会与添加时的顺序相同,而且每次运行程序的时候,存放的顺序可能都不一样。

从Python3.6开始,字典会保留这些键值对在添加时所用的顺序,而且python3.7版本的语言规范正式确立了这条规则。于是在新版的python里,总是能够按照当初创建字段时的那套顺序来遍历这些键值对。

1
2
3
4
5
6
7
8
9
# python 3.7
baby_names = {
'cat': 'kitten',
'dog': 'puppy'
}
print(baby_names)

>>>
{'cat': 'kitten', 'dog': 'puppy'}
在python3.5与之前的版本中,dict所提供的许多方法(包括keys,values,items与popitem等)都不保证固定的顺序。在3.6之后的python版本中,这些方法也已经可以按照当初添加键值对时的顺序来处理了。

这项变化对Python中那些依赖字典类型及其实现细节的特性产生了很多影响:

  • 函数的关键字参数(包括万能的**kwargs参数),以前是按照几乎随机的顺序出现的,现在,这些关键字参数总是能够保留嗲用函数时所指定的那套顺序

    1
    2
    3
    def my_func(**kwargs):
    for key, value in kwargs.items():
    print(f'{key} = {value}' % (key, value))

    my_func(goose=’gosling’, kangaroo=’joey’)

goose = gosling
kangraoo = joey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
* 类也会利用字典来保存这个类的实例所具备的一些数据,在早前版本的Python中,遍历对象(object)中的`__dict__`也是按随机顺序出现的,同样,在新版的Python中,我们可以认为这些字段在`__dict__`中出现的顺序应该与当初赋值的顺序一样。

```python
class MyClass(object):
def __init__(self):
self.alligator = 'hatchling'
self.elephant = 'calf'

a = MyClass()
for key, value in a.__dict__.items():
print(f'{key} = {value}')

>>>
alligator = hatchling
elephant = calf
但是,我们在写代码时,不能假设所有的字典类型的参数都能保证键值对插入时的顺序,因为,我们很容易就自己可以构造一个与标准dict相似的类型(拥有标准dict支持的所有方法,但是方法的行为可能与标准dict不同)。

例如:

1
2
def get_first(the_dict):
return the_dict.items[0]

在实际调用get_first函数时,我们不知道传入的是标准的dict类型,还是一个实现了items方法的类。解决这个问题有以下几种方法:

  • 在函数开头判断是否是标准dict

    1
    2
    3
    4
    def get_first(the_dict):
    if not isinstance(the_dict, dict):
    raise TypeError('must provide a dict instance')
    return the_dict.items[0]
  • 通过类型注解,在程序时,使用mypy模块进行静态分析

    1
    2
    3
    from typing import Dict
    def get_first(the_dict: Dict[str, int]):
    return the_dict.items[0]
    1
    python3 -m mypy --strict xxx.py

第16条:用get处理键不再字典中的情况,不要使用in与KeyError

字典有三种基本的交互操作:访问、赋值以及删除键值对。字典的内容经常变动,所以完全由可能会出现你想访问或删除的键以及不在字段中了,所以大多数情况我们访问字段都要先判断一下key是否还在dict中

1
2
3
4
5
6
7
8
9
10
11
counter = {
'sourdough': 1
}

key = 'wheat'
if key not in counter:
count = 0
else:
count = counter[key]

counter[key] = count + 1

使用if表达式需要访问key两次,并且进行赋值操作一次,还有一种方法也可以实现相同的功能,就是利用KeyError异常:

1
2
3
4
5
try:
count = counter[key]
except KeyError:
count = 0
counter[key] = count + 1

这种方式比用if表达要稍稍高效一点,因为只需要一次访问和一次赋值操作。更好的方法是使用dict的get方法,get方法第一参数指定自己想要查的键,第二个参数指定这个键不存在时返回的值:

1
2
count = counter.get(key, 0)
counter[key] = count + 1

虽然这种方法也需要一次访问和一次赋值操作,但是这比捕获KeyError的方式代码更简洁。

如果字典里的数据属于比较简单的类型,那么代码最简单、表达最清晰的方案就是使用dict内置的get方法。

假设dict里面的value不是简单类型,而是例如列表list这样的复杂类型时,修改可能存在的key时应该怎么处理呢:

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
votes = {
'baguette': ['Bob', 'Alice'],
'ciabatta': ['Coco', 'Deb']
}

key = 'brioche'
who = 'Elmer'

# use if expressin
if key in votes:
names = votes[key]
else:
votes[key] = names = []
names.append(who)

# use try catch
try:
names = votes[key]
except KeyError:
votes[key] = name = []
names.append(who)

# use get
if (names := votes.get(key)) is None:
votes[key] = names = []
names.append(who)

在采用if表达式的实现方案里,如果键名已经存在,那么需要访问两次(一次是在if语句里,另外一次是在获取列表的语句里);如果键名不存在,那么就只需要在if语句中访问一次,然后再else语句中赋值一次。

在再采用捕获KeyError的方案里,如果键已经再字典中,那么只需要在try块里访问一次键名;如果不在字典中,那么要先在try块里访问一次键名,然后在except块中做一次赋值。

在使用get方法的方案里,由于get方法在key不存时,虽然会返回设置的默认返回值,但是不会将对应的值和字典关联起来,所以在操作复杂类型时,为了减少赋值操作,更好的方式是先将key和value关联起来,再对value进行操作

dict类型还提供了setdefault方法,能够继续优化代码。这个方法会查询字典里有没有这个键,如果有,就返回对应的值,如果没有,就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。总之,这个方法所返回的值肯定已经跟键关联起来。

1
2
names = votes.setdefault(key, [])
names.append(who)

使用setdefault方法可以达到预期的效果,并且代码也很简洁。但是代码读起来会有歧义,setdefault的表现和它的名称似乎有点不相符:它实际上是在获取value,但是却叫做set。另外,当key不存在时,默认值会直接简单赋值给对应的key,而不是进行深拷贝,这样就可能存在问题。

1
2
3
4
5
6
7
8
9
10
data = {}
value = []
data.setdefault('foo', value)
print('Before:', data)
value.append('hello')
print('After:', data)

>>>
Before: {'foo', []}
After: {'foo': ['hello']}

由于这个问题存在,就意味着必须保证每次调用setdefault时,默认值参数都必须重新构造,这也导致不论key是否存在,都会进行一次默认值构造的开销。

一般来说,只有在少数几种情况下才用setdefault处理缺失的键才是最简短的方式,例如这种情况:与键相关联的默认值构造起来开销很低且是属于可变对象类型,而且不用担心异常问题。

但其实更好的解决方法是使用defaultdict类,见下面的第17条。

第17条:用defaultdict处理内部状态中缺失的元素

deafultdict类是collections包中内置的模块,相比于setdefault要求提供默认值,它需要提供的是一个函数,注意,该函数不能有任何必填参数。

1
2
3
4
5
6
7
8
from collections import defaultdict
data = defaultdict(list) # list as a construct function
data["foo"].append('hello')

print(data)

>>>
defaultdict(<class 'list'>, {'foo': ['hello']})

第18条:学会利用__missing__构造依赖键的默认值

前面介绍了dict的setdefault方法和内置的defaultdict类来解决key缺失的情况,但是还有些情况是这两个方法也不好解决的。

例如,有一个key为文件路径,value文件句柄的dict,用于文件的重复读写,当key在dict不存在时,需要打开文件并将句柄添加到dict中

1
2
3
4
5
6
7
8
9
10
11
12
13
pictures = {}
path = 'profile_1234.png'

if (handle := pictures.get(path)) is None:
try:
handle = open(path, 'a+b')
except OSError:
print(f'failed to open path {path}')
else:
pictures[path] = handle

handle.seek(0)
data = handle.read()

使用get方法,如果字典中已经有这个handle了,那么这种写法只需要进行一次字典访问。如果没有,那么它会通过get方法访问一次字典,然后在try/except/else结构的else分支中做一次赋值。

这套逻辑也能用in表达式或KeyError实现,但那两种方案的字典访问次数与代码嵌套层数都比较多。有人可能觉得,既然这套逻辑能用getinKeyError这三种方案实现,那么也应该可以用第四种方案,也就是setdefault方法来是实现:

1
2
3
4
try:
handle = pictures.setdefault(path, open(path, 'a+b'))
except OSError:
print(f'failed to open path {path}')

这样写有很多问题,因为即使图片的路径名已经在字典中了,程序还是得调用内置得open函数创建文件句柄,并且这个handle也没有显示地close。

如果考虑使用defaultdict来实现,由于defaultdict要求传入的构造函数不能有任何必填参数,所以在这种情况下,使用defaultdict也是不太好的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from collections import defaultdict

def open_pictures(profile_path):
try:
return open(profile_path, 'a+b')
except OSError:
print(f'failed to open path {profile_path}')
raise

prictures = defaultdict(open_picture)
handle = pictures[path]
>>>
Traceback ...
TypeError: open_picture() missing 1 required positional

幸运的是,python还提供了一个内置的解决方法,那就是我们可以自定义一个类并继承自dict类型,并重写__missing__方法来自定义key缺失的情况怎么处理。

1
2
3
4
5
6
7
class Picture(dict):
def __missing__(self, key):
value = open_picture(key)
self[key] = value
return value
pictures = Pictures()
handle = pictures[path]

__missing__方法必须给key创建一个default值,并插入到自身中,在调用self[key]时是不会再次触发__missing__方法的。

总结一下,目前有以下几种方法去处理访问dict key缺失的情况: 1. 使用dict内置的`get`方法,提供key缺失时返回默认值,该方法不会修改dict本身 2. 使用`setdefault`方法,在key缺失时修改dict并返回对应的值 3. 使用`defaultdict`类型,需要提供一个没有任何必填参数的函数作为key缺失时,用于构造对象的构造函数 4. 自定义类继承自dict类型,并重写`__mising__`方法 考虑使用的方法的优先级为1>3>2>4

第1条:获取Python版本

在代码中,可以使用内置的sys模块来查询当前python的版本

1
2
3
import sys
print(sys.version_info)
print(sys.version)

python2从2020.01.01号之后就不再有新版本发布了,所以尽量使用python3进行开发

第2条:PEP 8 风格指南

PEP 8的全称为Python Enhencement Proposal #8,他是一份针对python代码格式而编订的风格指南。PEP 8非常详细的描述了如果编写清晰的python代码,而且随着python语言的发展,这份指南也在不断更新。这里只简单说明几种

空格与空行

  • 用空格表示缩进(一般四个空格),而不使用tab表示缩进,现代的IDE都有将tab转换为空格的设置
  • 同一份文件中,函数与类之间用两个空行隔开
  • 同一个类中,方法与方法之间用一个空行隔开

现代IDE都有控制空格与空行规范的format工具,可以借用IDE的format工具来实现空格与空行的规范,而不必手动进行改动。

变量命名相关建议

  • 函数、变量及属性用小写+下划线命名
  • 受保护的实例属性,用一个下划线开头,例如:_leading_underscore
  • 私有的实力属性,用两个下划线开头,例如__double_leading_underscore
  • 类(包括异常)命名时,使用首字母大写+驼峰命名法,例如:CaptializedWord
  • 模块级别的常量,所有字母都大写,各单词之间用下划线相连,例如:ALL_CAPS
  • 类中的实例方法,第一个参数命名为self,用来表示该对象本身
  • 类方法的第一个参数,应该命名为cls,用来表示这个类本身

表达式相关建议

  • 否定表达式将否定词直接写在要否定的内容前面,而不要放在整个表达式的前面,例如应该写if a is not b 而不是if not a is b

与导入包相关的建议

  • 引入模块时,尽量使用绝对名称,而不应该根据当前模块路径来使用相对名称。如果一定要用相对名称来导入,也应明确的写出from .xxx import xxxfrom ..xxx import xxx
  • 文件中import语句按顺序划分为三个部分:首先引入标准库里的模块,然后引入第三方模块,最后引入自己编写的模块。属于同一个部分的import语句尽量按照字母顺序排列(老实说个人理解做到按字母顺序排列有点难,除非靠专门的format工具)

Pylint是一款流行的Python源码静态分析工具。它可以自动检测代码是否符合PEP 8风格指南,很多IDE都包含这样的linting工具

第3条:了解bytes与str的区别

python有两种类型可以表示字符序列,一种是bytes,一种是str,bytes实例包含的是原始数据,即8位的无符号值(通常按照ASCII编码标准来显示)。str实例包含的是unicode码点,这些码点与人类语音之中的文本字符相对应。要把Unicode数据转换成二进制数据,必须调用str的encode方法,要把二进制数据转换为Unicode数据,必须调用bytes的decode方法。

编写python程序的时候,一般把字符的解码和编码放在最外层来做,让程序的核心使用Unicode数据来运作(在程序的核心部分,用str类型来表示Unicode数据)。

  • string和string之间,bytes和bytes之间可以使用+号连接,但是string和bytes之间不可以

  • string和string之间,bytes和bytes之间可以使用><比较大小,但是string和bytes之间不行

  • string 和 bytes之间使用==比较大小总是会得到false,即使两个实例表示的字符完全相同

  • string和string之间,bytes和bytes之间,可以使用%s操作符来进行格式替换。但是如果格式字符串是bytes类型,那么不能使用str实例来替换其中的%s,因为python不知道这个str应该按照什么方式来编码。但是反过来,如果格式字符串时str类型,虽然可以用str实例来替换其中的%s,但是最终的结果可能和你想要的不一样。

    1
    2
    3
    4
    print('red %s' % b'blue')

    >>>
    red b'blue'

另外一个需要注意的就是使用open函数对文本文件进行读写的问题,如果在打开一个文本文件使用'r'模式,则系统会采用默认的编码格式对二进制数据进行处理。如果要以二进制方式读取的话需要使用rb模式。

第4条:用f-string 取代 C 风格的格式化字符串和str.format方法

python 中对字符串进行格式化有多种方法,下面分别对这几种方法进行介绍和对比。

第一种:

采用%格式化操作符,这是python中最常用的字符串格式化方式。这个操作符左边的文字模板叫做格式字符串,%操作符右边是一个值或者多个值构成的元组,例如:

1
2
3
4
5
6
a = 0b101111011
b = 0xc5f
print('binary is %d, hex is %d' % (a, b))

>>>
binary is 187, hex is 3167
格式说明符的写法来自C语言的printf函数,所以常见的printf选项都可以当成python的格式说明符来用,例如%s, %x, %f,此外还可以控制小数点的位值,并指定填充与对其方式。

C风格的格式化字符串,在python里有三个缺点:

  1. 如果%右侧元组里的值在类型或顺序上有变化,那么程序可能会因为类型转换时发生不兼容问题而出现错误。
1
2
3
4
5
6
key = 'my_var' 
value = 1.234
print('%-10s = %.2f' % (value, key))

>>>
TypeError: must be real number, not str
如果%右侧的写法不变,但是左侧的格式字符串里说明符调换了位置,程序同样会发生这个错误。
  1. 在填充模板之前,经常要先对准备填写进去的这个值稍稍做一些处理,但这样以来,整个表达式可能就会写得很长。

  2. 如果想用一个值来填充格式字符串里的多个位置,那么必须在%操作符右侧的元组中相应地多次重复该值。如果需要修改,那么必须同时修改多次

虽然%操作符允许我们使用dict来取代tuple,让格式字符串里面的说明符与dict里面的键以相应的名称对应起来以解决第三个问题,但是这样会让第二个问题更严重,每个键至少需要写两次

1
2
3
4
format_string = '%(key)-10 = %(value).2f' % {
'key': 'my_var',
'value': 1.234
}

第二种:

python3 添加了高级字符串格式化机制,它的表达能力比老式的格式字符串要强,且不再使用%操作符,而是用str的format方法,format方法不使用%d这样的C风格说明符。而是把格式有待调整的那些位置在字符串里面先用{}代替,然后按从左至右的顺序,依次填写到format方法的参数中

1
format_string = '{} = {}'.format('my_var', 1.234)

你也可以在{}中写个冒号,然后把格式说明符写在冒号的右边,用以规定format方法所接受的这个值应该按照怎样的格式来调整。

1
format_string = '{:<10} = {.2f}'.format('my_var', 1.234)

C风格的格式字符串采用%操作符来引导格式说明符,所以如果要将这个符号按照原样输出,就必须进行转义,也就是连写两个%。同理,在调用str.format的时候,如果想把str里面的{}按原样输出,那么也得转义

1
2
3
4
5
6
print('%.2f%%' % 12.5)
print('{} replaces {{}}'.fromat(1.23))

>>>
12.50%
1.23 replaces {}
使用format函数可以避免使用`%`操作符带来的第一个问题:格式字符串中的此项发生变动后,程序也不会有问题。另外format还支持为`{}`指定索引,这样就不需要把多个值重复地传给format方法,于是就解决了前面的第三个缺点。
1
2
3
4
5
6
format_string = '{} loves food, see {0} cook.'.format('tom')
print(format_string)
format_string = '{name} loves food, see {name} cook.'.format(name='tom')
print(format_string)
>>>
tom loves food, see tom cook.
但是format 方法也没有解决上面的第二个问题。 第三种: python 3.6之后增加了一种新的特性,叫做插值格式字符串,简称f-string,可以解决上面的所有问题。新语法特性要求在格式字符串的前面加字母`f`作为前缀,这跟字符`b`和`r`(分别表示字节形式的字符串与原始未经转义的字符串)的用法类似。
1
2
3
4
key = 'my_var'
value = 1.234

print(f'{key} = {value}')
同时f-string也支持在`{}`加冒号用于指定格式
1
2
3
key = 'my_var'
value = 1.234
print(f'{key:<10}')
另外python表达式也可以出现在f-string的格式说明符中
1
2
3
4
5
6
places = 3
number = 1.23456
print(f'my number is {number:{places}f}')

>>>
my number is 1.235
f-string可以简洁而清晰地表达出许多种逻辑,这使它成为程序员的最佳选择。

第5条:用辅助函数取代复杂的表达式

python拥有很强大的语法,有时候一条表达式就可以实现比较复杂的逻辑,但是有时候这种表达式会不利于代码阅读,编写同样功能的辅助函数反而是一个不错的选择。

当你发现表达式越写越复杂,那就应该考虑把它拆分成多个部分,并把这套逻辑写道辅助函数中。

第6条:将数据结构直接拆分到多个变量里,不要专门通过下标访问

该条实际上建议多使用python中的unpacking机制,例如对于有两个元素的元组,可以通过下标来访问,也可以直接unpacking到两个变量中

1
2
3
4
5
6
item = ('Peanut butter', 'Jelly')
# unrecommended
first = item[0]
second = item[1]
# recommend
first, second = item #unpacking

并且unpacking还支持快速交换连个变量的值

1
2
3
item = ('Peanut butter', 'Jelly')
first, second = item
first, second = second, first

本质上python是先将=号右边的值放入一个临时元组内,然后对这个临时元组再做unpacking

第7条:尽量使用enumerate取代range

python内置的range函数比较适合来迭代一系列整数:

1
2
for i in range(32):
do something

如果要迭代的是某种数据结构,例如字符串列表,则可以直接在这个序列上进行迭代:

1
2
3
fruit_list = ['vanilla', 'chocolate', 'pecan', 'starwberry']
for fruit in fruit_list:
print(fruit)

如果即需要知道index,也想要知道元素的值,使用range可以如下实现:

1
2
3
fruit_list = ['vanilla', 'chocolate', 'pecan', 'starwberry']
for i in range(len(fruit_list)):
print(i, fruit_list[i])

python 还有一个内置函数,叫做enumerate,它可以方便地获取到元素的下标和元素值。enumerate本质上是将任何一种迭代器(例如list,dict)封装成惰性生成器,这样的话,每次循环的时候,它只需要从iterator里面获取下一个值就可以了。每一次取出的是一个包括元素下标和对应的值的元组:

1
2
3
4
5
6
fruit_list = ['vanilla', 'chocolate', 'pecan', 'starwberry']
it = enumerate(fruit_list)
print(next(it))

>>>
(0, 'vanilla')

在for循环中使用,加上unpacking:

1
2
for i, fruit in enumerate(fruit_list):
print(i, fruit)

另外enumerate还可以指定第二个参数,用于指定启始的排序序号,注意这不是表示从下标为1的数据开始遍历

1
2
3
4
5
6
7
8
for i, fruit in enumerate(fruit_list, 1):
print(i, fruit)

>>>
1 vanilla
2 chocolate
3 pecan
4 starwberry

第8条:用zip函数同时遍历多个迭代器

python的内置zip函数,能够两个或更多的迭代器封装成惰性生成器,每次循环时,它分别从这些迭代器里获取下一个元素,并把这些值放在一个元组里,可以利用unpacking将这个元组拆分到for语句里的那些变量之中。

1
2
3
4
5
names = ['name1', 'name2', 'name3']
addresses = ['address1', 'address2', 'address3']
for name, address in zip(name, address):
print(name)
print(address)

但是,如果输入zip的那些列表的长度不一致,在这种情况下,zip表现的行为如下:如果其中任何一个迭代器处理完毕,则完成迭代。如果需要使最长的列表迭代,可以用itertools模块中的zip_longest。

1
2
3
4
from itertools import zip_longest
for name, address in zip_longest(name, address):
print(name)
print(address)

第9条:不要再for与while循环后面紧跟else代码块

python的循环支持一项特性,即可以把else代码块紧跟在整个循环结构的后面

1
2
3
4
5
for in range(3):
print(f''loop {i})

else:
print('else block')

但是,整个else语句的意思不是如果for循环没有执行完,就执行else语句。恰恰相反,else语句在for循环完成后接着执行,如果循环中途退出,是不会执行else语句的。另外,如果循环一次没有执行,也会执行else代码块中的内容。

所以这种语句是一种不利于代码阅读的语法,不要使用这种语法。

第10条:用赋值表达式减少重复代码

赋值表达式是python3.8新引入的语法,它会用到海象操作符。a = b是一条普通的赋值语句,而a := b则是赋值表达式。这个符号为什么叫海象操作符呢,因为把:=瞬时间旋转90度之后,冒号就是海象的一双眼睛,等号就是它的獠牙,(感觉有点牵强…,完全不像好吧)。

这种表达式很有用,可以在赋值语句无法应用的场合实现赋值,例如可以用在if语句中。赋值表达式的值,就是赋给海象操作符左侧的那个标识符的值。例如,如果有一筐水果要给果汁店做食材,那我们就可以定义其中的内容:

1
2
3
4
5
fruit = {
'apple': 10,
'banana': 8,
'lemon': 5
}

顾客点柠檬汁之前,我们先得确认现在还有柠檬:

1
2
3
4
5
6
7
8
9
10
11
def make_lemonade(count):
pass

def out_of_stock():
pass

count = fruit.get('lemon', 0)
if count:
make_lemonade(count)
else:
out_of_stock()

count 只会在if语句中使用,把count写在外面,会给人一种后面还会使用到count变量的感觉,这时候就可以使用赋值表达式

1
2
3
4
if count := fruit.get('lemon', 0):
make_lemonade(count)
else:
out_of_stock()

新代码虽然只节省了一行,但是读起来却清晰很多,因为这种写法明确体现出count变量只与if块有关。这个赋值表达式先把:=右边的值赋给左边的count变量,由于然后判断是否为0来决定是否要执行if代码块。如果不是和0比较,则需要将赋值表达式用括号扩起来:

1
2
3
4
if (count := fruit.get('lemon', 0)) >= 4:
make_lemonade(count)
else:
out_of_stock()

另外,赋值表达语句还可以解决if/else语句嵌套的问题

假设现在客户的要求是点柠檬汁,但是如果柠檬不够就做香蕉冰沙,如果香蕉不够就做苹果汁,现在做柠檬汁需要3个柠檬,做香蕉冰沙需要2个香蕉,做苹果汁需要1一个苹果,不适用赋值表达式的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def make_bananas(count):
pass

def make_apple(count):
pass


count = fruit.get('lemon', 0)
if count >= 3:
make_lemonade(count)
else:
count = fruit.get('banana', 0)
if count >= 2:
make_banana(count)
else:
count = fruit.get('apple', 0)
if count >= 1:
make_apple(count)

使用赋值表达式:

1
2
3
4
5
6
if (count := fruit.get('lemon', 0)) >= 3:
make_lemonade(count)
elif (count := fruit.get('banana', 0)) >= 2:
make_banana(count)
elif (count := fruit.get('apple', 0)) >= 1:
make_apple(count)

另外赋值表达式也可以用在while循环的条件判断中,甚至来实现do/while的效果(python中不支持do/while 语法)。例如,当前需要随机选一种水果来做果汁,pick_fruit函数实现随机选水果的操作,店员一直做果汁,直到没有水果,不使用赋值表达式来实现:

1
2
3
4
5
while True:
fruit = pick_fruit()
if fruit is None:
break
make_juice(fruit)

使用赋值表达式来实现:

1
2
while (fruit := pick_fruit()):
make_juice(fruit)

Texture

为了给渲染的物体添加更多的细节,我们可以使用更多的vertex和颜色,但是实际情况是,如果我们要渲染对象比较复杂,那么我们需要定义很多的vertex数据和颜色数据。这时候就可以利用texture来简化。

texture通常来说是一个二维图片(虽然也有一维texture和三维texture存在)。

为了将texture绘制到vertex组成的物体上,我们需要告诉每个vertex分别对应texture的那个部分,因此每个顶点就需要一个texture coordinate用于指定vertex对应纹理图片的那个部分,中间区域最后交给fragment interpolation去完成。

texture coordinate一般来说每个轴的曲直范围为[0, 1]闭区间,对于二维texture来说,坐标原点为左下角,右上角的坐标为(1, 1)

texture coordinate

Texture Wrapping

Texture coordinate的取值范围通常来说通常是(0, 0) 到 (1, 1),但是如果我们将坐标设置为大于1的值时,OpenGL的默认做法是重复texture图片,但是OpenGL也提供了其他的处理方式:

  • GL_REPEATE,默认的处理方式,重复texture图片
  • GL_MIRORED_REPEATE,重复texture图片,但是是镜像重复
  • GL_CLAMP_TO_EDGE,将坐标压缩到0到1之间,最终结果是高坐标区域的图片被拉伸
  • GL_CLAMP_TO_BORDER,超出范围的坐标区域会被赋予用户指定的边框颜色

下图分别是四种选项对应的效果

texture wrapping

上面的选项可以针对每一个texture coordinate的坐标轴进行设定
1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

如果我们使用GL_CLAMP_TO_BORDER选项,我们还必须指定border的颜色:

1
2
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

如果坐标设置为小于1的值,会对texture图片进行裁剪。

Texture filtering

texture coordinate和texture图片的分辨率没有关系,因此OpenGL必须决定如何将texture coordinate映射到texture图片上的具体像素去,这就叫Texture filtering。和Texture wrapping 一样,OpenGL也提供了多种 texture filtering的选项:

  • GL_NEAREST,OpenGL默认的Texture filtering方法,使用这个选项时,OpenGL会选择中心最接近Texture coordinate点的像素点颜色最为最终的texture coordinate点颜色

    nearest texture filtering

  • GL_LINEAR,使用这个选项时,texture coordinate对应颜色为周围像素的线形插值的和,像素点到texture coordinate中心距离越小,贡献的颜色值占比就越多

    linear texture filtering

下图分别是两种选项在实际使用中最终的渲染效果:

texture filtering result

可以看到使用GL_LINEAR选项时,最终效果图更光滑,但是GL_LINEAR需要的计算量更多,GL_NEAREST效果更像8bit像素风。

修改Texture filtreing选项的值仍然可以使用glTexParameteri函数

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Mipmaps

通常来说,一个场景会有很多物体对象,很多对象可能使用的是一个texture图片,但是有些对象离视窗很近,这些对象看着比较大一些,有些对象离得比较远,看着会小一些,这些小的物体最终可能只会生成几个fragment,相对于比较大的物体而言,OpenGL针对这些小的物体去获取texture上的颜色会比较困难,因为它必须遍历纹理图片的大部分区域来决定一个fragment的颜色,这不仅会导致内存浪费,同时也会导致小物体渲染效果很差。

为了解决这个问题,OpenGL引入了Mipmaps的概念。所谓Mipmap就是一组texture图片,只不过后一个图片的长宽是前一个图片长宽的一半。在从视窗经过一定距离后,OpenGL在mipmap中会使用一个适合当前距离的texture图片,这样对于较远的,最终比较小的物体来说就有了合适的texture。

一个mipmaps看起来一般是这样的:

mipmaps example

如果我们自己手动来制作一个mipmap贴图的话会很麻烦,但是幸运的是OpenGL为我们提供了根据已有texture对象创建mipmaps的方法。

在渲染是,可能会遇到切换mipmaps级别的情况,这时为了决定最终的颜色值,和原来的texture filtering一样,OpenGL也为mipmaps提供了filtering的方法。我们可以使用下面四种选项:

  • GL_NEAREST_MIPMAP_NEAREST,针对mipmaps使用nearest采样,针对texture filtering使用nearest采样
  • GL_LINEAR_MIPMAP_NEAREST,针对mipmaps使用linear采样,针对texture filtering使用nearest采样
  • GL_NEAREST_MIPMPAP_LINEAR,针对mipmaps使用nearest采样,针对texture filtering使用linear采样
  • GL_LINEAR_MIPMAP_LINEAR,针对mipmaps使用linear采样,针对texture filtering使用linear采样
1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
开发者一个经常犯的错误是将mipmaps filtering的选项设置到放大filter(magnification filter)的选项上,由于mipmaps解决的场景是texture需要缩小的场景,texture放大的场景不需要使用到mipmaps,在magnification filter上设置一个mipmaps的选项会导致 GL_INVALID_ENUM错误。

作为一名应用系统开发人员,为什么要关注数据库内部的存储和检索呢?首先,你不太可能从头开始实现一套自己的存储引擎,往往需要从众多现有的存储引擎中选择一个适合自己应用的存储引擎。因此,为了针对你特定的工作负载而对数据库调优时,最好对存储引擎的底层机制有一个大概的了解。

索引

索引是基于原始数据派生来的额外数据结构,很多数据库允许单独添加或删除索引,从而不影响数据库的内容,它只会影响查询性能。它们背后的基本想法是保留一些额外的元数据,这些数据作为路标,帮助定位想要的数据。索引作为额外引入的结构,在维护时势必会引入开销,特别是在新数据写入时。对于写入,它很难超过简单地追加文件方式的性能。对于每次写入数据时,需要更新索引,因此任何类型的索引通常都会降低写的速度

这里涉及存储系统中重要的权衡设计:适当的索引可以加速读取查询,但每个索引都会减慢写速度。为此,默认情况下,数据库通常不会对所有内容进行索引,它需要应用开发人员或数据库管理员,基于对应用程序典型查询模式的了解,来手动选择索引。目的是为应用程序提供最有利加速的同时,避免引入过多不必要的开销。

哈希索引

首先我们以key-value数据的索引开始,key-value类型数据并不是唯一可以索引的数据,但是它随处可见,而且是其他复杂索引的基础构造模块。

key-valuy存储与大多数编程语言所内置的字典结构非常相似,通常采用hash map来实现,那么既然已经有了内存数据结构的hash map,为什么不用它们在磁盘上直接索引数据呢。

假设数据存储全部采用追加式文件组成,那么最简单的索引策略就是:保存内存中的hash map,把每个键意义映射到数据文件中特定的字节偏移量,这就就可以找到每个值的位置。 如果所有的key可以放进内存,只需要进行一次磁盘寻址,就可以将value从磁盘加载到内存。如果那部分的数据文件已经在文件系统的缓存中,则读取根本不需要任何的磁盘I/O。 但是,如果数据只追加到一个文件,那么如果避免最终用尽磁盘空间?一个好的解决方法是将日志分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中。同时,对于已经关闭的段文件,可以在每个段文件内部执行压缩操作,丢弃重复的键,只保留每个键在在段文件中最新的值,一个段文件压缩之后通常会使得段文件变得更小。在多个已经关闭的段文件直接可以执行合并操作,将多个压缩过的段文件合并成一个段文件。 这些冻结段的合并和压缩过程可以在后台线程中完成,而且运行时,仍可以使用未合并和压缩的段文件继续正常的读取。当合并过程完成后,再将读取请求切换到新的压缩合并完成的段文件上,旧的段文件则可以安全的删除。 每个段文件都有自己的内存hash map,用于将键映射到段文件中的偏移量。为了找到键的值,需要将所有段文件的hash表加载进内存,然后首先检查最新段的hash map,如果键不存在,检查第二新的段,以此类推。由于合并后可以维持较少的段数量,因此查询通常不需要检查很多的hash map。 还有很多的细节方面的考虑才能使得这个简单的想法在实际中行之有效。简而言之,在真正地实现中有以下重要问题: * 如果要删除键和它关联地值,则必须在数据文件中追加一个特殊的删除记录。当合并日志段时,一旦发现删除记录,则会丢弃这个已删除键的所有值。 * 由于写入以严格的先后顺序追加到文件中,通常的实现方式是只有一个写线程。由于数据文件段是追加的,而且已经写入的数据是不可变的,所以它们可以被多个线程同时读取。 一个追加式的存储看起来似乎很浪费空间:为什么不原地更新文件,用新值覆盖旧值?但是,事实上,追加式的设计是一个不错的设计,原因有以下几点: * 追加主要是顺序写,它通常比随机写快很多,特别是在旋转式磁性硬盘上。在某种程度上,属性写入在基于闪存的固态硬盘上也是合适的。 * 如果段文件是追加或不可变的,则并发和崩溃恢复要简单得多。里如果不必担心在重写值时发生崩溃的情况,留下一个包含部分旧值和部分新值混杂在一起的文件。 * 合并旧段可以避免随着时间推移数据文件出现碎片化的问题。 但是,哈希表索引也有其局限性: * hash map必须全部放入内存,如果有大量的键,那么很可能超内存。原则上,可以在磁盘上维护hash map,但是,很难使磁盘上的hash map表现良好。它需要大量的随机访问I/O,并且哈希冲突时需要复杂的处理逻辑 ## SSTable 对于单纯的将key-value按顺序添加到文件尾部的方式,如果在写入时对key进行排序,索引结构还是hash map。这样的存储形式就被成为SSTable(排序字符串表)。SSTable相对于单纯顺序添加的形式有以下几个优点: 1、在合并冻结段时更高效,由于每个段中的key都是有序的,合并时方法类似与归并排序merge sort的方式 2、不需要把段文件中每个key的偏移量都存进内存的hash map中,例如,如果当前查找的键handiwork,且不知道该键在段文件中的确切偏移量,但是如果知道键handbag和键handsome的偏移量,由于键在段文件中时有序的,可以跳到handbag的偏移,从那里开始顺序扫描,直到找到handiwork。所以,虽然仍然需要内存的hash map做索引,但是它可以是稀疏的,而且由于顺序扫描可以很快的扫描几千个字节,所以通常对于一个段文件,只需要每隔几千个字节选一个key添加到内存中的hash map做索引即可。

但是,如何保证在写入段文件的key-value保持有序呢。虽然在磁盘上维护排序结构是可行的,例如B-trees,但是在内存中做排序更容易和高效,内存排序有很多广为人知的树状数据结构,例如红黑树或AVL树,可以在插入键时完成排序。

所以SSTable结构的基本构建流程如下:

1、写入key-value键值对时,将其添加到内存中的平衡树数据结构中

2、当内存中的平衡树结构大于某个阈值时(通常为几M字节),将其作为段文件写入到磁盘,由于平衡树已经维护了排序过的键值对,所以写磁盘也很高效。在将平衡树写入磁盘的时候,后续的写入则添加到到另外一个新的内存中的平衡树中

3、在处理读请求时,首先从内存的平衡树中查找,接着是最新的段文件,然后是次新的段文件,以此类推(相对于之前增加了一个内存查找的过程)

4、后台进程周期性地执行段合并与压缩地过程。

但是,如果内存中的平衡树还未写入到段文件时,此时系统崩溃了,怎么办呢,内存中的数据无法再恢复。所以,通常SSTable还需要配置日志使用,每个写内存平衡树的记录同时也要写到一个顺序添加的日志文件中。如果系统崩溃,已经写入段文件的可以直接读磁盘恢复,内存中的数据则从日志文件恢复。当内存中的平衡树写入到段文件后,该日志文件就可以丢弃了。

LSM-Tree

像SSTable这样类似日志不断在文件末尾进行添加的索引结构称为日志结构的合并树——LSM-Tree(Log-Structured Merge-Tree)

B-trees

B-tree是一种常见和被广泛使用的索引类型,它几乎是所有关系数据库中的标准索引实现。

B-tree本质上也是一种排序的树形数据结构,它在数据库系统中的使用形式一般如下:

  1. 将数据库划分为固定大小的块或页,传统上大小为4KB,页是内存读/写的最小单元。这种设计更接近底层硬件

  2. 每个页都可以使用地址或者位置进行标记,这样可以让一个页面引用另一个页面,类似指针,只不过是指向磁盘地址,而不是内存,这样可以使用这些页面引用来构造一个树状页面

  3. 每一个页包含若干个键和对子页的引用。某一页被指定为B-tree的根,每当查找索引中的一个键时,总是从这里开始。B-tree中一个页包所包含的子页引用数量成为分支因子。在实际中,分支因子取决于存储页面引用和范围边界所需的空间总量,通常为几百个。

  4. 如果要更新B-tree中现有键的值,首先搜索包含该键的叶子页,更改该页的值,并将页写回到磁盘(B-tree中对该页的引用仍有效)。如果要添加键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后新的键的范围。

B-tree底层的基本写操作时使用新数据覆盖磁盘上的旧页。它假设覆盖不会改变页的磁盘存储位置,也就是说,当页被覆盖时,对该页的所有引用保持不变。这与LSM-Tree引形成鲜明对比,LSM-Tree仅追加更新文件。

但是,覆盖操作是危险的,在硬件层面,意味着磁头首先移动到正确的位置,然后旋转盘面,对于SSD,由于SSD必须一次擦除并重写非常大的存储芯片块,情况会更复杂。此外,某些操作需要覆盖多个不同的页,例如,如果插入导致页溢出,因而需要对页进行分裂,那么需要修改三个页(分裂后的两个页和父页),这是个比较危险的操作,因为如果数据库在完成部分页写入之后发生崩溃,最终会导致索引破坏,例如分裂后新形成的页的引用未更新到父页中,成为一个孤儿页。

为了使数据库能从崩溃中恢复,常见B-tree的实现需要磁盘上的额外的数据结构支持:预写日志(write-ahead log,WAL),也称为重做日志。这是一个仅支持追加修改的文件,每个B-tree的修改必须先更新WAL然后再修改树本身的页。当数据库在崩溃后需要恢复时,该日志用于将B-tree恢复到最近一致的状态。

对比B-tree和LSM-tree

尽管B-Tree的实现比LSM-tree的实现更为成熟,然而由于LSM-tree的性能特点,LSM-tree目前仍很有吸引力。根据经验,LSM-tree通常对于写入更快,而B-tree被认为对于读取更快。读取通常在LSM-tree上较慢,因为它们必须在不同的压缩阶段检查多个不同的数据结构和SSTable

LSM-tree相对于B-tree的优点

B-tree索引必须至少写两次数据:一次写入预写日志,一次写入树的页本身(还可能发生页分裂)。即使该页中只有几个字节更改,也必须承受写整个页的开销。一些存储引擎甚至覆盖相同的页两次,以避免在电故障的情况下最终出现部分更新的页。像这种由于一次数据库写入请求导致多次磁盘写的情况成为写放大,对于SSD来说,由于只能承受有限次地擦除覆盖,因此尤为关注写放大指标。

对于大量写密集地应用程序,性能瓶颈很可能在于数据库写入磁盘地速率。在这种情况下,写放大具有直接地的性能成本。LSM-tree通常能够承受比B-tree更高的写入吞吐量,部分是因为它们具有较低的写放大,还有部分原因是因为它们以顺序方式写入紧凑的文件,而不必重写多个页(这种差异对于磁盘驱动器尤为重要,原因是磁盘的顺序写比随机写得要快得多)。

由于B-tree以页为单位,每个页中很有可能有不能利用的磁盘空间,即磁盘碎片,而LSM-tree不是面向页的,而且定期对数据进行合并,所以它们具有较低的存储开销。

在许多SSD上,硬件底层使用日志结构化算法会将随机写入转换为底层存储芯片上的顺序写入,所以对SSD来说,顺序写和随机写的影响差异不那么明显。然而,更低的写放大和更少的磁盘碎片在SSD上仍然有益,以更紧凑的方式表示数据,从而在可用的I/O带宽中支持更多的读写请求。 **LMS-tree相对于B-tree的缺点** LMS-tree的缺点就是压缩过程有时会干扰正在进行的读写操作,压缩和合并操作在后台进程中进行,由于磁盘的并发资源有限,所以当磁盘执行昂贵的压缩操作时,很容易发生读写请求等待的情况(B-tree的响应延迟更具有确定性)。随着数据库数据的增长,合并和压缩操作消耗磁盘的带宽会越来越多。 另外B-tree的一个优点就是每个键都恰好唯一对应与索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本。如果数据库希望提供强大的事务语义,这方面B-tree显得更具有吸引力:在许多关系数据库中,事务隔离是通过键范围上的锁来实现的,并且在B-tree索引中 ,这些锁还可以直接定义到树中。

总结

不考虑使用场景,空谈哪种存储引擎更适合是没有意义的,实地测试总是需要的。

其他索引结构

之前讨论的key-value索引,它们就像关系模型中的主键索引。主键唯一标识关系表中的一行或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的一行/文档/顶点通过主键来引用。

在索引中存储值(聚集索引)

索引中的key是需要查询的对象,索引中的value可以是以下两类之一:第一种,是实际行数据(或者文档,顶点),也可以是对其他地方存储的行的引用。在后一种情况下,存储行数据的具体位置被称为堆文件,而且它不以特定的顺序存储数据(它可以是追加的,也可以是覆盖的)。堆文件方式比较常见,这样当存在多个二级索引时,它可以避免复制数据,即每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。

当更新值而不更新键时,堆文件方式会非常高效:只要新值的字节数不大于旧值,记录就可以直接覆盖,如果新值比较大,则情况比较复杂,它可能需要移动数据到一个最够大的新位置。在这种情况下,所有索引都需要更新以指向记录的新的堆位置,或者在旧堆位置保留一个间接指针。

在某些情况下,从索引到堆文件的额外跳转对于读取来说意味着太多的性能损失,因此可能希望将行数据直接存储在索引中,这被称为聚集索引。例如,在mysql的InnoDB存储引擎中,表的主键始终是聚集索引,二级索引引用主键(而不是堆文件位置)。 在聚集索引(在索引中直接保存行数据)和非聚集索引(索引仅存储数据的引用)之间有一种折中设计称为覆盖索引或包含列的索引,它在索引中包含部分列值。它支持通过索引来回答某些简单查询。 与任何类型的数据冗余一样,聚集和覆盖索引可以加快读取速度,但是它们需要额外的存储,并且会增加写入的开销。此外,数据库还需要更多的工作来保证事务性,这样应用程序不会因为数据冗余而得到不一致的结果。

多列索引

到目前为止,讨论的索引都只有一个key,如果需要对多个key进行查询,就需要多列索引。但是多列索引是高维的,标准的B-tree和LSM-tree索引无法高效地应对这种查询,它们只能针对一个维度进行排序和查询。一种处理方式是将高维数据转换为一维度数据,但是,更常见的做法是使用专门的空间索引,如R树。

在内存中保存所有的内容

到目前位置,讨论的索引都是为了适应磁盘限制。与内存相比,磁盘更难处理。使用磁盘SSD,如果要获得良好的读写性能,需要精心地安排磁盘上地数据布局。然而这些工作是值得的,因为磁盘有两个显著的优点:数据持久化和低成本。

随着内存变得更便宜,而许多数据集不是那么大的情况下,可以将它们全部保存在内存中,或者分布在多台机器上。这推动了内存数据库的发展。

与直觉相反,内存数据库的性能优势并不是因为它们不需要从磁盘中读取。如果有足够的内存,即使是基于磁盘的存储引擎,也可能不需要从磁盘读取,因为操作系统将最近使用的磁盘块缓存在内存中。相反,内存数据库可以更快,是因为它们避免了使用写磁盘的格式对内存数据结构编码的开销。

另外,内存数据库提供了基于磁盘索引难以实现的某些数据模型。而且,内存数据库架构可以扩展到支持远大于可用内存的数据集,即当没有足够的内存时,通过将最近最少使用的数据从内存写到磁盘,并在将来再次访问时将其加载到内存。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以在记录级别而不是内存页的级别上工作。不过这种方法仍需要索引完全放入到内存。

如果将来非易失性存储(non-volaitle memory,NVM)技术得到更广泛的普及,可能还会颠覆目前的存储引擎设计,目前这还是一个新的研究领域,但值得密切关注。 # 事务处理与分析处理 数据库中的数据一般会用于两种用途,一种是用于在线事务处理(OLTP,online transaction processing),例如用户数据的增删改查,这种情况下一般只需要查找少量记录。另外一种是用于在线数据分析,例如获取一个月每个店铺的总收入是多少,这种情况一般会查找大量数据,这种叫做OLAP(online analytic processing)。 # 数据仓库 一个企业可能有十几种不同的交易系统,例如面向客户网站提供支持的系统、供应商管理系统、员工管理系统等。这些系统中的每一个都足够复杂,往往需要一个专门团队来维护,最终导致这些系统彼此独立运行。 由于这些OLTP系统对于业务的运行至关重要,所以往往期望它们高度可用,处理事务时延迟足够低,并且数据库管理员要密切关注OLTP数据库的运行状态。数据库管理员通常不愿意让业务分析人员在OLTP数据库上直接运行临时分析查询,这些查询通常代价很高,要扫描大量数据集,这可能会损害并发执行事务的能力。

相比之下,数据仓库则是单独的数据库,分析人员可以在不影响OLTP操作的情况下尽情地使用。数据仓库包含公司所有各种OLTP系统的只读副本。从OLTP数据库(使用周期性数据转储或连续更新流)中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。

几乎所有的大型企业都有数据仓库,但是在小型企业中却几乎闻所未闻。这可能是因为大多数小公司没有那么多不同的OLTP系统,大多数小公司只拥有少量的数据,完全可以在传统的SQL数据库中直接进行查询分析。

另外使用单独的数据仓库而不是直接查询OLTP系统来进行分析,很大的优势在于数据仓库可以针对分析访问模式进行优化。事实证明,前半部分讨论的索引算法适合OLTP,但不擅长应对分析查询。接下来将会讨论一些针对分析型而优化的存储引擎。

星型与雪花型分析模式

在事务处理领域有多种不同的数据模型(文档模型,关系模型,图模型),但是在分析型业务中,用到的数据模型则比较少,其中最主要的数据模型就是星型模型。该模型的中心是一个事实表,事实表的每一个行表示在特定时间发生的事件(例如代表客户购买了某个商品),在事实表周围,是许多个维度表,维度表通常记录时间发生的对象、时间、地点、原定等信息。事实表中通过外键指向这些维度表。

星型模型

通常,事实被捕获为单独的事件,这样之后的分析具有很大的灵活性,但同时这意味着事实表会变得非常庞大,在大公司中,其数据仓库可能有数十PB的交易历史,其中大多数都保存在事实表中。

星型模型中如果对维度表进行进一步细分,将部分数据使用另外一个表格进行存储,然后再维度表中使用外键执行这些表格,这种变体就称为雪花模型。雪花模型比星型模型更规范,但是星型模型通常是首选,主要是对于分析人员,星型模型使用起来更简单。 在典型的数据仓库中,表通常非常宽:事实表通常超过100列,有时候有几百列。维度表也可能非常宽。

列式存储

如果事实表有数万亿行,则高效地存储和查询这些数据将成为一个具有挑战性的问题。虽然维度表通常超过100列,但典型得数据仓库查询往往一次只访问其中的几列(select * 查询很少用于分析)。

在大多数OLTP数据库中,存储以面向行的方式布局:来自表的一行的所有值彼此相邻存储,文档数据库也是类似,整个文档通常被存储为一个连续的序列。为了处理数据仓库中在超过100列的数据中查询其中的几列的这种查询,如果仍使用面向行的存储引擎,则需要将所有一行中用不到的列数据从磁盘中加载到内存中。而面向列的存储,不需要将一行中的所有值存储在一起,而是将每列中的所有数据存储在连续的空间中,这很适合数据仓库中的查询场景。如果每个列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列即可,这可以节省大量磁盘数据的加载。

列存储

面向列的存储布局依赖一组列文件,每个文件以相同的顺序保存着数据行,因此,如果需要重新组装整行,可以从每个单独的列文件中获取对应的条目,然后将它们放在一起构成表的一行。

列压缩

除了仅从磁盘中加载查询所需的列之外,还可以通过压缩数据来进一步降低磁盘数据的加载。幸运的是,面向列的存储恰好非常适合压缩,因为通常列中不同值的数量会小于行数(例如,零售商可能拥有数十亿个销售交易,但只有十万个不同的商品)。

一种常见的列压缩方法是使用位图编码+游程编码:一个列中有n个不同的值,则可以创建一个长度位n的位图,位图中的每一位对应一个不同的值,每一行都会有一个位图。但是,如果n很大,那边位图中就会有很多0,这时就可以使用游程编码对位图进一步压缩。

内存带宽和矢量化处理

对于需要扫描数百行的数据仓库查询,将数据从磁盘磁盘加载到内存时,带宽是一个瓶颈,然而,这不是唯一的瓶颈。分析数据库的开发人员还要关心如何高效节省CPU访问内存的带宽,甚至是尽量减少CPU分支预测错误和CPU指令处理流水线中的气泡,并利用现代CPU中的单指令多数据(SIMD)指令(向量计算单元)。

面向列的存储布局也有利于高效利用CPU的缓存,例如,查询引擎可以将一大块压缩后的列数据放入CPU的L1缓存中,并以紧凑循环(即循环中没有函数调用和条件判断分支)进行迭代,这种技术被成为矢量化处理。

列存储中的排序

列存储中的排序并不是简单的单独对某列进行排序,这种排序是没有意义的,因为这样的话就无法知道列中的某一项实际属于哪一行。即使数据是按列存储的,也需要以行位单位进行排序。

数据库管理员可以基于经常查询的列来进行排序,例如,假如查询经常以日期范围作为目标,那么就可以对数据按照日期这一列作为第一个排序的key对数据进行排序。然后可以对第二经常访问的列作为第二排序key进行排序,以此类推。

排序的另外一个优点就是它可以帮助进一步压缩列,排序之后该列值相同的行会挨在一起,这时候一个简单的游程编码就可以表示多行。通常来说,第一个排序键的压缩效果最好,后面的排序键对压缩的提升效果就没有那么明显了。

列式存储排序

由于不同的查询会从不同的排序中获益,同时数据需要被复制到多台机器上防止单台机器出现故障时不会丢失数据,不妨存储不同方式排序的冗余数据到不同的机器上,在查询时可以选择最适合的排序版本进行查询。

面向列的存储具有多个排列顺序,有点类似面向行的存储中有多个不同的二级索引,但最大的区别是,面向行的存储将每一行都保存在一个位置(在堆文件或者聚集索引中),而二级索引只包含指向对应行的指针。而对于列存储,通常没有指向别处数据的指针,只有包含列的值。

列存储的写操作

面向列的存储、压缩和排序都非常有助于加速读取操作,但是,它们的缺点是让写入更加困难。

像B-tree使用原地更新的方式,对于压缩的列是不可能的,如果在排序表的中间插入一行,那么很可能不得不重写所有的列文件。

幸运的是,LSM-Tree可以比较好地解决这个问题:所有的写入首先写入内存,并在内存中进行排序,内存中的存储是面向行还是面向列的都无关紧要,当积累了足够的写入时,将它们和磁盘中的列文件合并,并批量写入新文件。执行查询时,就需要检测磁盘上的数据和内存中最近的写入。

聚合:数据立方体与物化视图

基本思想是由于数据仓库的查询通常涉及聚合函数,例如计算总量、平均值,最大值,最小值,每次都处理原始数据将非常浪费,可以缓存查询最常使用的一些计数信息。具体细节不进行讲解。

decoder

入口函数:

1
2
3
4
5
6
7
8
9
10
func Unmarshal(data []byte, v interface{}) error {
var d decodeState
err := checkValid(data, &d.scan) //使用一个状态机来判断是否是合法的json字符串
if err != nil {
return err
}

d.init(data)
return d.unmarshal(v)//真正的解析赋值步骤
}

golang 的 json decoder从Unmarshal进入,该函数创建一个decodeState结构体,该结构体用于存储解析json数据时当前的解析状态信息,例如下个需要解析的字符串的截止index,在解析中遇到的错误,以及一个存储了一个json解析的状态机。该函数首先会遍历一遍json字符串,用状态机来判断一个是否是一个合法的json字符串。如果是一个合法的字符串,则再变遍历变解析。所以一次decode操作会遍历两次json字符串。

状态机解析

首先看第一步用状态机判断是否是一个合法的json字符串是如何实现的。

状态机结构体的定义如下:

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
31
32
// A scanner is a JSON scanning state machine.
// Callers call scan.reset and then pass bytes in one at a time
// by calling scan.step(&scan, c) for each byte.
// The return value, referred to as an opcode, tells the
// caller about significant parsing events like beginning
// and ending literals, objects, and arrays, so that the
// caller can follow along if it wishes.
// The return value scanEnd indicates that a single top-level
// JSON value has been completed, *before* the byte that
// just got passed in. (The indication must be delayed in order
// to recognize the end of numbers: is 123 a whole value or
// the beginning of 12345e+6?).
type scanner struct {
// The step is a func to be called to execute the next transition.
// Also tried using an integer constant and a single func
// with a switch, but using the func directly was 10% faster
// on a 64-bit Mac Mini, and it's nicer to read.
step func(*scanner, byte) int

// Reached end of top-level value.
endTop bool

// Stack of what we're in the middle of - array values, object keys, object values.
parseState []int

// Error that happened, if any.
err error

// total bytes consumed, updated by decoder.Decode (and deliberately
// not set to zero by scan.reset)
bytes int64
}

scanner的定义很简单,只有5个字段。第一个字段step,它是一个函数类型,注释的解释是它用于根据状体机当前的状态,以及输入的字符来前往下一个状态(即改变状态机结构体内部的数据),并返回一个int类型的code,用来告诉当前是否有一些事件发生,例如是字符串序列的结束字符(第二个引号)、对象类型的结束字符(”}”)、字符串的的结束字符(”]”)、解析出错等等。

第二个字段endTop,用于标记是否当前是否已经结束最上层的对象解析,即整个json字符串对象的解析。第三个字段parseState,一个用数组简单实现的栈类型数据结构,每进入到数组类型的解析或者进入到嵌套对象类型的解析时,都会往这个栈上压入一个int数据(这个int数据是个简单的标志位,会用到数组类型的解析和嵌套对象类型的解析中,在后面的源码解析中可以看到)。在完成数组类型或者嵌套对象类型后,会弹出这个int数据。

第四个字段err,用于记录在当前解析遇到的错误。第五个字段bytes,用于记录已经解析的字符串长度,这个字段状态机本身是不会更新这个字段的,只会由外部来更新(这里目前我也有点不太清楚为什么)

总体简介

曝光三要素分别为:光圈大小、快门速度、感光度。这三个要素一起作用共同决定了一张照片的亮暗程度。

光圈大小(Aperture)

对于不同的镜头,在不同焦距下,有的镜头的最大光圈可以做到恒定,有些则不行。

光圈的参数用f表示,参数越大,光圈越小。

光圈可以影响虚化的效果,光圈越大,虚化效果越好,一般在拍摄人物的时候可以突出主体。具体为什么光圈越大,虚化效果越好涉及到凸透镜成像的原理,可以参考这篇文章

快门速度(Shutter)

快门速度决定了曝光时长。高快门速度一般用来拍运动的物体。慢快门可以来拍摄一些特殊效果照片(通常需要借助三脚架),例如马路上的光流

安全快门:在未使用三脚架和防抖镜头时,保证成像图片不会因为手抖而导致糊掉的快门速度。通常经验值为 —— 1/焦距(秒)

感光度(ISO)

感光度是感光元件的感光程度,ISO越高,在光圈大小和快门速度相同的情况下,图片亮度越高。一般来说观光度越低,最后拍出来的图像画质越好,图片越细腻,噪点越少,所以一般在拍风景的时候用的观光度参数都是比较小的

相机各档位介绍

自动档(auto)

在自动档下,相机会根据算法,依据当前光学元件感知到的图像进行自动调节相关测试。而一般来说根据检查算法的不同有以下几种测光模式

测光模式

  • 评价测光

    • 评价测光会针对整个镜头下的图片进行检测来调节曝光度,一般使用评价测光就ok了
  • 局部测光

    • 局部测光只根据一个固定小框内的区间进行测光,可以自己移动测光的框
  • 点测光

    • 只根据一个点进行测光

曝光补偿

曝光补偿是一种在简单控制曝光的方式,它本质上是通过调整光圈大小和快门速度来实现的,在相机中它用EV表示。曝光补偿值越大,进光量越大,图片越亮。但是在实际使用的时候,在拍摄亮的物体的时候,需要把曝光值调高,在拍摄暗的物体时,需要把曝光值调低。原因是在拍摄亮的物体时,在相机进行测光后,判断出当前的画面比较亮,会自动调整以减少进光量,所以为了减少相机自动判断出来的效果,可能就需要增加曝光补偿值。同理,拍摄暗的物体时就可能需要减少曝光补偿值。

光圈优先模式(AV档)

快门速度、感光度根据设置的光圈大小进行自动调节。一般在拍人像的时候用,因为大光圈做到可以虚化背景。

快门优先模式(TV档)

光圈大小,感光度根据设置的快门的速度自动调节。一般在拍摄运动题材的时候使用。

全手动档(M档)

光圈大小,快门速度,观光度全都可以手动调节。

相机的分类

按取景方式分

单反相机:单镜头反光式取景相机,相机的镜头内部有一片反光镜,用于将镜头前的图像反射到取景窗,反光镜后面时感光元件当相机进行拍摄时,反光镜会先弹下(避免挡住感光元件),所以拍摄时镜头一般会黑一下。由于单反是通过物理的方法将镜头前的图像反射到取景窗中的,所以在没有开启相机电源的情况下,也能从取景窗中看到镜头中的画面。

单反相机原理

微单相机:单镜头无反相机,无反相机,没有反光镜。无反相机直接通过感光原件将镜头前的画面输出到感光器和屏幕中,所以当微单相机未开机时,无法从取景窗中看到镜头前的画面。

双反相机:双镜头反光式取景相机,一个镜头负责取景,一个镜头负责拍摄。双镜头相机一般在用于拍摄人文照片时比较方便(因为看取景窗时要从上往下看,别人一般觉察不到你在进行拍摄)

双镜头相机

旁轴相机:一般在相机右上角单独有一个取景窗,从取景窗中看到的和镜头中实际的图片有一些微小的差距(非常下,一般没有太大影响)

按成像介质分

数码相机:利用感光元件将影像转换成电子数据的相机,都称为数码相机

胶片相机:以化学方法,将影像记录在卤化银胶片上的相机

全画幅和APS-C画幅的区别

全画幅和APS-C画幅的主要区别是在于感光元件的大小不同,全画幅的感光元件大小一般在36mm 24mm,APS-C画幅的感光元件的大小一般在23mm 15mm。全画幅的感光元件比较大,所以在其他参数一致的条件下,全画幅拍到的画面会比较大一些。

如何清洁相机

对相机的显示屏幕和镜头的前镜头可以使用清洁布清理。对相机的内部镜头和观光元件最多使用气吹清理,必要时可以去专业的店进行清洗。

镜头的分类及镜头参数

定焦镜头和变焦镜头

定焦镜头只有一个固定的焦距,也只有一个固定的视野,没有变焦的功能。

变焦镜头,在一定范围内,可以变换焦距,从而得到不同的视野。

变焦镜头的好处就是焦段可动态调节。定焦镜头的好处是在同样焦段下,拍出来的图片细节保留更好。原因是变焦镜头为了可以变焦一般是多个镜片叠加起来的,而每通过一层镜片,由于折射和反射,就损失了一些细节。

最近对焦距离

对焦距离是感光元件到被摄对象之间的距离,而最近对焦距离就是:每一种镜头都有一个能使相机对上焦的最小对焦距离,如果小于这个距离,相机是无法对上焦的。如果需要非常近距离的拍摄,例如拍摄昆虫,可以使用微距镜头(最近对焦距离非常小)。

广角镜头简介

35mm以下焦段的镜头称为广角镜头。

中焦段镜头简介

35——70mm焦段的镜头称为中焦段镜头。

长焦镜头简介

70mm以上的镜头称为长焦镜头

动态范围(宽容度)

从亮到暗,相机只能选择其中的一部分范围来记录

动态范围越大的相机,能记录的明暗范围越广,这在拍摄一些明暗差别比较大的场景(通常是一些山水风光)比较有用,否则就需要靠后期修图。

掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。

关系模型与文档模型

支持文档数据模型的主要论点是模式灵活,对于某些应用来说,它更接近与应用程序所使用的数据结构。关系模型则强在联结操作、多对一和多对多关系更简介的表达上,与文档模型抗衡。

文档数据库在处理一对多的关系有很大优势,可以直接把记录记在一个文档中,但是在处理多对一,多对多的关系时相比于关系数据库则稍逊一些,因为在多对一、多对多的查询下,通常需要联结(join)多张数据表来查询,而一般文档数据库对联结操作的支持很差。有些文档数据库甚至本身都不支持联结,必须在应用程序代码中,通过对数据的多次查询来模拟联结。

如果应用数据具有类似文档的结构(即一对多关系,通常一次加载整个树),那么使用文档数据库更为合适。但是如果应用程序中经常使用多对多关系,那么关系系数据库是一个很好的选择。通常,我们无法一概而论那种数据模型的应用代码更简单。这主要取决于数据项之间的关系模型。有时候使用文档模型是最合适的,有时候用关系模型会更好,有些时候还可以使用图模型。

对象关系不匹配

目前大多数应用开发都采用面向对象的编程语言,如果数据存储在关系表中,那么应用层代码中的对象与表、行和列的数据库模型之间需要一个笨拙的转换层。一般在开发中会使用对象-关系映射(ORM)框架来减少此层转换之间的代码量,但是ORM框架并不能完全隐藏两个模型之间的差异。

文档数据库的数据局部性

文档通常存储编码为JSON、XML或其二进制变体(如MongoDB的BSON)的连续字符串。如果应用程序需要频繁访问整个文档,则存储局部性具有性能优势。局部性优势仅适用需要同时访问文档的大部分内容的场景,如果应用只是访问其中的一小部分,那对于大型文档数据来讲就有些浪费。对文档进行更新时,通常会重写整个文档,而只有修改量不改变原文档大小时,原地覆盖更新才更有效。因此,通常建议文档应该尽量小且避免写入时增加文档大小。这些性能方面的不利因此大大限制了文档数据库的适用场景。

数据查询语言

在关系模型被提出的最初时期,就出现两总查询数据的方法:声明式查询和命令式查询。命令式查询需要告诉计算机如何以特定的顺序来执行查询操作,一次查询基本上对应一段查询代码。而对于声明式查询语言(如SQL或关系代数),则只需要指定所需的数据模式,结果需满足什么条件,以及如何转换数据(例如,排序、分组和聚合),而不需要指明如何实现这一目标。数据库系统的查询优化器会决定采用那种索引和联结,以及用何种顺序来执行查询的各个语句。另外声明式查询语言对外隐藏了数据引擎的很多实现细节,这样数据库能够在不改变查询语句的情况下提高性能。

MapReduce

MapReduce是一种编程模型,用于在许多机器上批量处理海量数据。MapReduce既不是声明式查询语言,也不是一个完全命令式的查询API,而是介于两者之间:查询的逻辑用代码片段表示,这些代码片段可以被处理框架重复地调用。它主要基于许多函数式编程中的map和reduce函数,map函数用于对每一条数据进行过滤筛选,reduce用于对过滤出来的记录进行相关操作。不同数据库对MapReduce的执行实现都不同。

图数据模型

在处理多对多关系时,关系模型能够处理简单的多对多关系,但是随着数据之间的关联越来越复杂,将数据建模转换为图模型会更加自然

图由两种对象组成:顶点(也称为实体)和边(也称为关系),很多著名的算法都可以在图数据上运行,例如PageRank算法。

图的不同顶点存储的数据可以是相同类型对象,也可以是不同类型对象,同理对于边来说,不同边可以表示相同的关系,也可以表示不同的关系。这就是图数据模型强大的地方。

例如,Facebook维护了一个包含许多不同类型的顶点与边的大图:顶点包括人、地点、事件、签到和用户的评论;边表示哪些人是彼此的朋友,签到发生在哪些位置,谁评论了哪个帖子,谁参与了哪个事件等

属性图

在属性图模型中,每个顶点包括:

  • 唯一的标识符
  • 出边的集合
  • 入边的集合
  • 属性的集合(键-值对)

每个边包括

  • 唯一的标识符
  • 边开始的节点
  • 边结束的节点
  • 描述两个顶点间关系的标签
  • 属性的集合(键-值对)

// TODO: 在实际使用了图数据之后在补充一下,目前只做基础了解,知道哪些场景适用图模型即可。

小结

文档模型、关系模型和图模型如今都有广泛的应用,而且在各自的目标领域都足够优秀。我们观察到,一个模型可以用另外一个模型来模拟,但是处理起来很笨拙。这就是为什么不同的系统用于不同的目的,而不是一个万能的解决方法。有些场景下甚至可能需要定制化开发新的数据系统,比如粒子对撞数据的存储和分析,其通常需要处理数百PB的数据。

文档数据库和图数据库有一个共同点,那就是它们通常不会对存储的数据强加某个模式,这可以使应用程序更容易适应不断变化的需求。但是,应用程序很可能仍然假定数据具有一定的结构,只不过模式是显示(写时强制)还是隐式(读时处理)的问题。

对于每个数据模型,都有自己的查询语言或框架。

影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等

可靠性

什么叫作系统的可靠性

原文:

可靠性大致意味着:即使发生了某种错误,系统仍可以继续正常工作

一般,用于测试目的,可以故意提高故障发生概率,例如通过随机杀死某个进程,来确保系统仍保持健壮。

硬件故障

例如:磁盘崩溃、内存故障、网络故障、机房停电等等。通常对于硬件故障,都会为硬件添加冗余来减少系统故障率。例如,对磁盘配置RAID,服务器配备双电源,热拔插CPU,数据中心添加备用电源、发电机等。当一个组件发生故障,冗余组件可以快速接管,之后再更换失效的组件。

软件错误

软件故障一般很难事先预料,因为导致软件故障的bug通常会长时间处于引而不发的状态,直到碰到特定的触发条件。软件问题有时没有快速解决办法,而只能仔细考虑很多细节,包括认真检查依赖的假设条件与系统之间交互,进行全面的测试,进行隔离,运行进程崩溃并自动重启。

人为错误

有一项针对大型互联网服务的调查发现,运维者的配置错误居然是系统下线的首要原因,而硬件问题(服务器或网络)仅在10%~25%的故障中有影响。对于人为故障,一般来说可以通过结合以下多种方法:

  • 想办法分离最容易出错的地方、容易引发故障的接口,对重要接口进行多重条件检查
  • 充分的测试:从单元测试到全系统的集成测试到手动测试
  • 提供快速的恢复机制,例如快速回滚配置改动
  • 设置详细而清晰的监控子系统,包括性能指标和错误率。
  • 推行管理流程并加以培训

我对可靠性的理解

系统的可靠性不是指让系统的任何一个部分在任何时刻都不发生故障,这是不可能的,随机事件的发生不可控的,系统的可靠性是指在系统的某个部分发生了故障时,能够尽快的从故障中恢复过来,同时尽量把由于故障导致的损失降到最小。并且在故障期间不会由于一个故障,像多米诺骨牌一样,引发一连串的其他故障。

可扩展性

对于可扩展性,一个重要讨论的内容是:如果系统以某种方式增长,我们应对增长的措施有哪些,我们该如何添加计算资源来处理额外的负载。

描述负载

首先,我们需要简洁地描述系统当前的负载,这样才能更好地讨论后续增长的问题。负载可以用称为负载参数的若干数字来描述,参数的最佳选择取决于系统的体系架构,它可能是web服务器的每秒请求处理次数,数据库中写入的比例,缓存命中率等。

描述性能

描述系统负载之后,接下来设想如果负载增加将会发生什么。一般有有两种考虑方式:

  • 负载增加,但系统资源(如CPU、内存、网络带宽等)保存不变,系统性能会发生什么变化?
  • 负载增加,如果要保持性能不变,需要增加多少资源?
下面对一个描述性能的重要指标进行额外讨论——响应时间

响应时间

一般来说,对服务进行请求,每次的响应时间都是不同的,而且响应时间可能变化很大。因此。最好不要将响应时间视为一个固定的数字,而应该视为一种数值分布。

平均响应时间与相关百分位数

我们经常考察的是服务请求的平均响应时间(算数平均值)。但是,如果想知道更典型的响应时间,平均值并不是合适的指标。因为它掩盖了一些信息,无法告诉有多少用户实际经历了多少延迟,因此最好使用百分位数</font>。中位数指标非常适合描述多少用户需要等待多长的时间,一半的用户请求的服务时间少于中位数响应时间,另一半则多用于中位数的时间。因此中位数也称为50百分位数,简写为p50。有些时候还有需要关注更大的百分位数,例如常见的p95、p99甚至p999(99.9百分位数)。一方面采用较高的响应时间百分位数很重要,因为它们有时直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时是以 99.9 百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了。另一方面,优化99.99百分位点被认为太昂贵了,所以需要根据业务的具体需求来确认需要的百分位数响应时间。

排队延迟往往在高百分数响应时间中影响很大,由于服务器并行处理的请求优先(例如,CPU内核数的限制),正在处理的少数请求可能会阻挡后续请求,这种情况有时被称为队头阻塞(head-of-line-blocking)。即使后续请求可能处理很简单,但它阻塞在等待先前请求的完成,客户端将会观察到极慢的响应时间。因此,很重要的一点是要在客户端来测量响应时间。

因此,当测试系统的能支持的负载时,负载生成端要独立于响应时间来持续发送请求。如果客户端在发送请求之前总是等待先前请求的完成,就会在测试中人为地缩短了服务器端的累计排队深度,这就带来了测试偏差。

如果你想将响应时间百分点添加到你的服务的监视仪表板,则需要持续有效地计算它们。例如,你可以使用滑动窗口来跟踪连续10分钟内的请求响应时间。每一分钟,你都会计算出该窗口中的响应时间中值和各种百分数,并将这些度量值绘制在图上

应对负载增加的方法

一般来说,针对特定级别负载而设计的架构不太可能应付超出目标10倍的实际负载。如果目标服务处于快速增长阶段,那么需要认真考虑每增加一个数量级的负载,架构应该如何设计。

对于未发生数量级变化的负载,更多的是在垂直拓展(升级到更强大的机器)和水平拓展(增加更多的机器实例)进行选择。通常,在单台机器上运行的系统通常更简单,然而高端机器可能非常昂贵,且扩展水平有限,最终往往还是无法避免需要水平扩展。另外,把无状态服务分布然后扩展至多台机器相对比较容易,而有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加。

对于特定应用来说,扩展能力好的架构通常会做出某些假设,然后有针对性地优化设计,如哪些操作是最频繁的,那些负载是少数情况。如果这些假设最终发现是错误的,那么在可扩展性上做的努力就白费了,甚至会出现与设计预期完全相反的情况。对于早期的初创公司或者尚未定型的产品,快速迭代产品功能往往比投入精力来应对不可知的扩展性更为重要。

可维护性

软件的大部分成本并不在最初的开发阶段,而在于整个生命周期内持续的投入,包括缺陷修复、增加新功能、适配新需求等等。但是,许多开发人员根本不喜欢维护这些遗留系统,例如修复他人埋下的错误,或者使用过时的开发平台。所以,换个角度,我们应该从软件设计时开始考虑,尽可能减少维护期间的麻烦,一般我们可以考虑以下几点:

  • 简单性:简化系统复杂性,使新工程师能够轻松理解系统
  • 可演化性:后续工程师能够轻松地对系统进行改进,并根据需求变化将其适配其他场景