0%

第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数据库中直接进行查询分析。

另外使用单独的数据仓库而不是i直接查询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: 在实际使用了图数据之后在补充一下,目前只做基础了解,知道哪些场景适用图模型即可。

小结

文档模型、关系模型和图模型如今都有广泛的应用,而且在各自的目标领域都足够优秀。我们观察到,一个模型可以用另外一个模型来模拟,但是处理起来很笨拙。这就是为什么不同的系统用于不同的目的,而不是一个万能的解决方法。

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

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

可靠性

什么叫作系统的可靠性

原文:

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

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

硬件故障

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

软件错误

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

人为错误

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

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

我对可靠性的理解

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

可扩展性

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

描述负载

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

描述性能

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

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

响应时间

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

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

我们经常考察的是服务请求的平均响应时间(算数平均值)。但是,如果想知道更典型的响应时间,平均值并不是合适的指标。因为它掩盖了一些信息,无法告诉有多少用户实际经历了多少延迟,因此最好使用百分位数</font>。中位数指标非常适合描述多少用户需要等待多长的时间,一半的用户请求的服务时间少于中位数响应时间,另一半则多用于中位数的时间。因此中位数也称为50百分位数,简写为p50。有些时候还有需要关注更大的百分位数,例如常见的p95、p99甚至p999(99.9百分位数)。采用较高的响应时间百分位数很重要,因为它们直接影响用户的总体服务体验(长尾效应)。

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

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

应对负载增加的方法

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

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

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

可维护性

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

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

Padding

一般来说,如果我们对一个$n_h \times n_w$的图片用一个$k_h \times k_w$的卷积核进行卷积操作,那么输出图片的大小为$(n_h - k_h + 1) \times (n_w - k_w + 1)$。经过卷积操作之后,图片的大小会减小,同时,图片的边缘的信息也会被抹去。通常,一个卷积神经网络有好几层卷积层,如果不进行任何处理的话,那么经过多层卷积层之后,图片携带的信息可能就非常少了。

对输入图片进行Padding是一个常用的解决上面问题的方法。Padding即在图片周围填充额外的像素,通常,这些像素的值设置为0。如果我们对图像加入$p_h$行填充和$p_w$列填充,那么输出的图片的大小为$(n_h - k_h + p_h + 1) \times (n_w - k_w + p_w + 1)$

padding.png
padding example

在许多情况下,通常会将$p_h$设置为$k_h - 1$,将$p_w$设置为$k_w - 1$,这样经过卷积之后的输出图片就和输入图片的大小一致。如果$k_h$为奇数(则$k_h - 1$为偶数),那么一般就在输入图片的上面和下面都加上$\frac{k_h - 1}{2}$行填充,如果$k_h$为偶数(则$k_h - 1$为奇数),那么可以在图片上方添加$\frac{k_h}{2}$行填充,在图片下方添加$(\frac{k_h}{2} - 1)$行填充,或者反过来。对于列填充来说也是这样的。

CNN中通常使用长和宽都为奇数的卷积核,使用奇数卷积核心可以保障在图片上下和左右添加的填充是均匀的。另外还有一个好处就是对于输出图片中的像素$Y[i, j]$来说,它正好是以输入图片中$X[i, j]$(注意输入图片的坐标以未添加padding的原始图片的左上角为原点)为中心周围像素的卷积运算的结果。

下面以一个8 x 8的输入图片,一个3 x 3的卷积核来做padding处理下的卷积代码示例:

1
2
3
4
5
6
7
import tensorflow as tf

conv2d = tf.keras.layers.Conv2D(1, kernel_size=3, padding='same')
# Here (1, 1) indicates that the batch size and the number of channels
input_tensor = tf.random.uniform(shape=(1, 8, 8, 1))
output_tensor = conv2d(input_tensor)
print(output_tensor.shape)

Stride

在之前的对图片进行卷积计算时,都是从图片左上角开始,每次向右移动一个像素,当一行计算完后,再往下移动一个像素。但是有些时候,为了计算效率和缩减采样次数,卷积窗口可以每次多向右或向下移动几个像素,跳过中间几个像素的计算。如下图:

padding.png
stride example

上图中的示例中,使用了水平2像素的stride,垂直3像素的stride。

假设输入图片的尺寸为$n_h \times n_w$,padding的尺寸为$p_h \times p_w$,卷积核的尺寸为$k_h \times k_w$,stride的尺寸为$s_h \times s_w$,则最终输出的结果的尺寸为$(n_h + p_h -k_h + s_h / s_h, \space n_w + p_w - k_w + s_w / s_w)$。

使用stride的代码示例:

1
2
3
conv2d = tf.keras.layers.Conv2D(1, kernel_size=3, padding='same', strides=2)
#or
conv2d = tf.keras.layers.Conv2D(1, kernel_size=(3, 5), padding="valid", strides=(3, 4))

通常来说,我们会将padding的长宽设置为相同的,stride的长宽也设置为相同的,即使用正方形的padding和stride。