我的博客

  • 首页

  • 关于

  • 分类

  • 归档

  • 搜索

多线程与多进程

发表于 2019-12-04 分类于 python

gil锁

本文中的python都指cpython

gil全称为global interpreter lock,python中的一个线程分别对应c语言中的一个线程。GIL的存在使得同一时刻只能有一个线程在CPU上执行字节码,这就意味着不能像其他编程语言一样将多个线程映射到多个CPU上以实现真正的并发编程

所以GIL锁的存在使得python真正的并发编程成为一种奢望,但可以在一个CPU上快速的切换线程,已达到类并发的效果,以减少线程的等待时间。

GIL锁是可以释放的,满足以下任一条件都可以释放GIL锁

  • GIL会在执行的时间片满了或者字节数满了之后释放GIL锁
  • 在遇到IO操作时会释放锁

多线程演变的原因在于:进程对系统资源的消耗巨大,其次进程间相互隔离。但是对于文件IO操作,多线程和多进程性能差别不大,甚至多进程的速度会比多线程稍快

每个多线程启动时,都会存在一个主线程main thread,默认主线程退出时,子线程会被kill掉,所以threading模块中提供的join函数,手动将子线程挂起,待子线程执行完之后再执行主线程

Thread提供了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import threading
import time

def test1():
time.sleep(2)

def test2():
time.sleep(3)

if __name__=='__main__':
thread1 = threading.Thread(target=test1)
thread2 = threading.Thread(target=test2)
start = time.time()
thread1.start()
thread2.start()
print('total time spend: {}'.format(time.time() - start))
## output:total time spend: 0.0

如果加上join函数

1
2
3
4
5
6
7
...
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print('total time spend: {}'.format(time.time() - start))
## output:total time spend: 3.00200009346

线程间通信

线程间通信可通过以下两种方式

  1. 全局变量
  2. 队列通信

利用全局变量global可方便实现多线程之间通信,但是由于GIL锁的存在,可能会造成由于线程的时间片或者字节数满了,而造成线程间切换,而带来数据丢失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
a = 0
def add(num):
global a
for i in range(num):
a += i
def sub(num):
global a
for i in range(num):
a -= i
thread1 = threading.Thread(target=add, args=(1000000, ))
thread2 = threading.Thread(target=sub, args=(1000000, ))
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(a)
# output: -262517192310

当然,可以通过加锁的方式来保证线程的安全,随之带来的是线程性能的下降,所以可通过queue的方式来进行线程间的通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def add(num, queue):
for i in range(num):
queue.put(num)

def sub(num, queue):
for i in range(num):
queue.get(num)

queue = Queue(maxsize=100)
thread1 = threading.Thread(target=add, args=(1000000, queue))
thread2 = threading.Thread(target=sub, args=(1000000, queue))
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(queue.qsize())
# output: 0

不过需要注意的是,Queue的get和put方法中,还是使用的锁,都是使用的,通过查看put源码,可发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
with self.not_full:
if self.maxsize > 0:
if not block:
if self._qsize() >= self.maxsize:
raise Full
elif timeout is None:
while self._qsize() >= self.maxsize:
self.not_full.wait()
elif timeout < 0:
raise ValueError("'timeout' must be a non-negative number")
else:
endtime = time() + timeout
while self._qsize() >= self.maxsize:
remaining = endtime - time()
if remaining <= 0.0:
raise Full
self.not_full.wait(remaining)
self._put(item)
self.unfinished_tasks += 1
self.not_empty.notify()

当队列不为空,阻塞时,队列会等待放数据,然后通知下一个线程

与全局变量相比,队列安全,方便,开箱即用。当然,队列也存在性能方便的问题,加锁之后,性能下降是无法避免的。Queue可指定队列的maxsize参数,因为maxsize过大会占用过多内存,所以使用时最好指定合适的大小。

线程同步

线程同步我理解的是用来保证线程安全的方式,一般来说线程同步有三种方式,分别如下

  1. Lock,加锁
  2. RLock,也是加锁,只是可重入的锁
  3. Condition,复杂线程同步

还是回到之前说的全局变量的那个例子中,为了更好的展示python中字节码的接在过程,使用如下例子演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def add(a):
a +=1

def sub(a):
a -= 1

print(dis.dis(add))
print(dis.dis(sub))
# 7 0 LOAD_FAST 0 (a)
# 2 LOAD_CONST 1 (1)
# 4 INPLACE_ADD
# 6 STORE_FAST 0 (a)
# 8 LOAD_CONST 0 (None)
# 10 RETURN_VALUE
#None
# 10 0 LOAD_FAST 0 (a)
# 2 LOAD_CONST 1 (1)
# 4 INPLACE_SUBTRACT
# 6 STORE_FAST 0 (a)
# 8 LOAD_CONST 0 (None)
# 10 RETURN_VALUE
#None

由于GIL的存在,这两个函数在使用多线程的时候,有可能会存在以下的情况:先加载add中的a,在加载sub中的a;加载add中的1,再加载sub中的1;add中+运算,sub中-运算;add中赋值操作,sub中赋值操作。由此,得出来最后的结果为-1,而不是0。

为了解决以上问题,先试试Lock,执行部分代码如下

1
2
3
4
5
...
lock.acquire()
a += 1
lock.release()
...

acquire是为这个线程加上一把锁,release是释放这个线程的锁,需要注意的是,当前的线程必须要释放掉当前的这把锁之后才能进行下一步,于是就带来了Lock最大的弊端,容易死锁

于是,由于Lock的存在,带来了RLock,可重入的锁,这可以连续多次调用acquire,只要保证一个线程中的acquire和release个数是相同的,就不会造成死锁,这就为多线程之间相互调用提供了可能,比较方便的是,RLock和Lock的接口一致性,也是使用的acquire和release

但是对于复杂场景下的线程同步,这两种锁显然满足不了需求,比如,多个线程之间的对话。为了解决这种机制问题,可使用Condition模块,condition会先执行该线程之后,会使用notify方法通知另外一个线程,并且使用wait方法等待另外一个线程的通知,这种情况下线程启动的先后顺序就显得尤为重要了。由此种机制可以理解,这其实也是用的锁,由Condition的源码也可以看见,__init__构造函数中还是加了一把锁

1
2
3
if lock is None:
lock = RLock()
self._lock = lock

而由于condition中实现了__enter__和__exit__,而这两个方法底层调用的是RLock中的__enter__和__exit__,而RLock中的这两个方法是调用的acquire和lock方法,于是RLock中的加锁和取锁可以简化为

1
2
with lock:
a -= 1
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
def say1(cond):
with cond:
cond.wait()
print('say 1')
cond.notify()

cond.wait()
print('say 2')
cond.notify()


def say3(cond):
with cond:
print('say 3')
cond.notify()
cond.wait()

print('say 4')
cond.notify()
cond.wait()
cond = threading.Condition()
threading.Condition()
thread1 = threading.Thread(target=say1, args=(cond, ))
thread2 = threading.Thread(target=say3, args=(cond, ))
thread1.start()
thread2.start()
thread1.join()
thread2.join()
# say 3
# say 1
# say 4
# say 2

值得注意的是,wait方法调用时,会先释放底层锁,然后阻塞,直到另一个线程调用notify()或者notify_all()方法为止,或者timeout超时为止,当被唤醒或者超时,它将重新获得锁并返回

在web开发中,通常会使用限制同时爬取的线程个数来控制反爬,在多线程中,可通过Semaphore方法来实现,例子如下

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
class GetUrlList(threading.Thread):
def __init__(self, sem):
super().__init__()
self.sem = sem

def run(self):
for i in range(20):
self.sem.acquire()
detail_html = ParseDetail('https://www.baidu.com/{}'.format(i), self.sem)
detail_html.start()

class ParseDetail(threading.Thread):
def __init__(self, url, sem):
super().__init__()
self.sem = sem
self.url = url

def run(self):
time.sleep(2)
print('parse html success')
self.sem.release()
if __name__ == '__main__':
sem = threading.Semaphore(3)
url_list = GetUrlList(sem)
url_list.start()

Semaphore默认的线程个数为1个,可自行指定个数,需要注意的是,acquire和release方法中还是用的condition的锁

线程池

对于多线程的线程池,Semaphore其实可以作为是一个简易的线程池,但是遇到复杂场景下,它不能很好的处理,比如:主线程获取某一个线程或者任务的状态和返回值;当一个线程执行完之后能马上通知主线程并返回。

这时,就需要通过另外的方法了,由此引申出concurrent模块的futures方法,其中的ThreadPoolExecutor方法封装了对于多线程的各种复杂方法,且与多进程的接口一致性,用来保证可复用性。

1
2
3
4
5
6
7
8
def sleep(sec):
print('i sleep {} sec'.format(sec))
time.sleep(sec)
return sec

executor = ThreadPoolExecutor(max_workers=2)
task1 = executor.submit(sleep, 2)
task2 = executor.submit(sleep, 3)

submit方法会在线程执的时候立即返回,如上例,会先打印字符串,再等待几秒

done方法会返回当前线程是否已经执行完成,完成返回True,否则为False

1
2
3
4
task1 = executor.submit(sleep, 2)
task2 = executor.submit(sleep, 3)
print(task1.done())
#output: False
1
2
3
4
5
task1 = executor.submit(sleep, 2)
task2 = executor.submit(sleep, 3)
time.sleep(2)
print(task1.done())
#output: True

result方法可以获取线程的返回值

1
print(task1.result())

如果想要获取已经成功完成的返回结果,可以通过futures模块下的as_completed方法获取,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from concurrent.futures import ThreadPoolExecutor, as_completed
...
executor = ThreadPoolExecutor(max_workers=2)
secs = [3, 2, 4]
tasks = [executor.submit(sleep, sec) for sec in secs]
for future in as_completed(tasks):
data = future.result()
print('this is {} sec'.format(data))
# i sleep 3 sec
# i sleep 2 sec
# i sleep 4 sec
# this is 2 sec
# this is 3 sec
# this is 4 sec

它的特点是先完成先返回,比如上例中,先返回2秒的线程,最后返回4秒的线程

此外,还提供了一种更为简便的方法,如下

1
2
3
4
5
6
7
8
for data in executor.map(sleep, secs):
print('this is {} sec'.format(data))
#i sleep 3 sec
#i sleep 2 sec
#i sleep 4 sec
#this is 3 sec
#this is 2 sec
#this is 4 sec

它与as_completed方法存在些许不同,map方法的执行顺序是按照可迭代对象的顺序执行,as_completed执行顺序是按照线程完成的先后顺序执行,在使用的时候需注意这点。

多进程编程

由于GIL锁的存在,多线程不能有效的利用多核的CPU,从而没办法达到真正的并发操作,python中所谓的多线程其实是快速的切换同一个CPU而已,只是在多个线程可能存在的网络延迟,程序处理中,还是很有必要的。相比之下,多进程能实现真正的并发编程,能使用多个CPU同时工作,但是,进程间资源不共享,进程切换带来的巨大消耗,使得多进程编程无法成为大多数并发编程的主流。于是,综合以上,得出以下经验

  1. 对于消耗CPU资源大的操作,可尽量使用多进程编程,例如,算术操作
  2. 对于IO频繁的操作,尽量使用多线程编程

在多进程中,通常使用futures模块中的ProcessPoolExecutor方法,因为该方法具有与ThreadPoolExecutor的多线程编程的一致接口,换个名字即可,在此不做过多示例,但是必须注意的是,在使用该方法时,必须将多进程相关操作放在if __name__ == '__main__':之后(windows平台)。

以下多进程相关操作,都是用multiprocessing模块演示

1
2
3
4
5
6
7
8
9
10
11
12
def sleep(sec):
time.sleep(sec)
print('i sleep {} sec'.format(sec))
return sec

if __name__ == '__main__':
p1 = multiprocessing.Process(target=sleep, args=(2, ))
p2 = multiprocessing.Process(target=sleep, args=(3, ))
p1.start()
p2.start()
p1.join()
p2.join()

操作与threading模块中多线程的操作类似

可使用multiprocessing模块下的Pool方法来设定进程池,且进程池默认为当前CPU的个数

1
2
3
4
5
pool = multiprocessing.Pool(multiprocessing.cpu_count())
result1 = pool.apply_async(sleep, args=(2, ))
result2 = pool.apply_async(sleep, args=(3, ))
pool.close()
pool.join()

tips:必须先关闭pool,再挂起pool

如果想要获取进程执行结果,可使用get方法

1
print(result1.get())

进程池还提供了imap方法,类似于executor.map()的功能,执行的顺序与传递的可迭代对象的顺序相同

1
2
3
4
5
6
7
8
9
pool = multiprocessing.Pool(multiprocessing.cpu_count())
for data in pool.imap(sleep, [3, 2, 4]):
print(data)
#i sleep 2 sec
#i sleep 3 sec
#3
#2
#i sleep 4 sec
#4

此外,还提供了map方法,类似于as_completed方法,执行的顺序按照时间先后顺序,在此不再赘述

多进程间通信

由于进程间资源是不共享的,所以多进程通信无法使用全局变量,可使用以下方式来进行通信

  • 使用multiprocessing模块下的Queue方法来进行通信,却无法使用之前使用的原生Queue来进行通信,但是此方法无法在进程池中使用
  • 进程池下的通信可使用multiprocessing模块下的Manager方法来进行通信(Manager().Queue())
  • 使用pipe管道进行通信
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def add(queue):
queue.put('a')

def sub(queue):
data = queue.get('a')
print(data)

if __name__ == '__main__':
queue = Queue(100)
p1 = multiprocessing.Process(target=add, args=(queue, ))
p2 = multiprocessing.Process(target=sub, args=(queue, ))
p1.start()
p2.start()
p1.join()
p2.join()
#output: a

进程池通信

1
2
3
4
5
6
7
queue = Manager().Queue(100)
pool = multiprocessing.Pool(multiprocessing.cpu_count())
pool.apply_async(add, args=(queue, ))
pool.apply_async(sub, args=(queue, ))
pool.close()
pool.join()
#output: a

管道通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def add(pipe):
pipe.send('jack')

def sub(pipe):
data = pipe.recv()
print(data)

if __name__ == '__main__':
recevie_pipe, send_pipe = Pipe()
p1 = multiprocessing.Process(target=add, args=(send_pipe, ))
p2 = multiprocessing.Process(target=sub, args=(recevie_pipe, ))
p1.start()
p2.start()
p1.join()
p2.join()
#output: jack

管道通信只能应用于两个进程之间,并且它的性能要高于使用Queue,原因在于队列使用了很多锁,这会带来性能的下降

进程间内存共享

有些时候我们需要使用多个进程对同一个内存空间进行操作,比如,多个进程修改同一个字典。multiprocessing模块中的Manager方法提供了丰富的数据结构,以供内存共享来使用,比如常见的dict,list,array以及Lock,RLock,Condition等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
def add_list(p_list, new):
p_list.append(new)

if __name__ == '__main__':
process_list = Manager().list()
p1 = multiprocessing.Process(target=add_list, args=(process_list, 'tang', ))
p2 = multiprocessing.Process(target=add_list, args=(process_list, 'jack', ))
p1.start()
p2.start()
p1.join()
p2.join()
print(process_list)
#output: ['tang', 'jack']

docker应用原理

发表于 2019-11-12 分类于 容器与容器技术

使用docker部署一个python应用

使用到的代码如下所示,是一个典型的flask项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
html = "<h3>Hello {name}!</h3>" \
"<b>Hostname:</b> {hostname}<br/>"
return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())

if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)

同时将依赖放在同目录下的requirement.txt的目录中

1
2
$ cat requirements.txt
Flask

接下来,制作Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用官方提供的Python开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为/app
WORKDIR /app

# 将当前目录下的所有内容复制到/app下
ADD . /app

# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的80端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个Python应用的启动命令
CMD ["python", "app.py"]

在Dockerfile中,使用一些原语来处理逻辑流程,且执行顺序是由上往下执行

值得一提的是,通常还会看到一个叫ENTRYPOINT的原语,实际上,它和CMD都是docker进程启动时所必需的的参数,完整的格式是ENTRYPOINT CMD,但是,在默认情况下,docker会提供一个默认的ENTRYPOINT参数,即/bin/bash -c,所以在这个例子中,实际运行的是/bin/bash -c python app.py

加下来,就可以制作镜像了

1
$ docker build -t helloworld .

-t是指加一个tag,即取一个名字,docker build会依次执行dockerfile文件,并且每一句原语会生成一层镜像。即使原语本身没有明显的修改文件操作,它回应的层也是会存在的,只不过在外界看来,这个层是空的。

exec命令进入容器内部

通过docker exec命令可以进入一个正在运行的docker容器中。

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

通过如下指令,你可以看到当前正在运行的 Docker 容器的进程号(PID)是 25686:

1
2
$ docker inspect --format '{{ .State.Pid }}' 
4ddf4638572d25686

这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

1
2
3
4
5
6
7
8
9
10
$ ls -l  /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。这就相当于’HODL’住了所有的Namespace,那么就可以实现进入一个已经存在的Namespace中了。

这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

简单说下所依赖的原理,其实是依赖于setns()的Linux系统调用,具体用法可以百度或Google。它的作用就是加入一个进程的Namespace中。

docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

由于使用了联合文件系统,容器对镜像的任何操作,都会被操作系统先复制到可读写层,然后再修改,这就是所谓的Copy On Write。当然,docker commit不会提交Init层的内容。

Volume机制

volume的存在在于解决容器中文件与数据在宿主机上的备份。而volume的存在,允许将宿主机上指定的文件或目录挂载到容器中进行读取和修改。

在docker中,有两种申明方式,如下所示:

1
2
$ docker run -v /test ...
$ docker run -v /home:/test ...

这两种命令的本质是相同的,都是把一个宿主机的目录挂载到容器的/test目录中。只是在第一种情况下,由于没有申明宿主机目录,docker会默认在宿主机创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,然后将它挂载到容器的/test目录下。而第二种情况,是直接将宿主机的/home目录挂载到容器的/test目录下。

由于当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff目录下,在容器进程启动后,它们会被联合挂载在/var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

注意:这里提到的 “ 容器进程 “,是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

同时,/test目录中的内容,也不会被docker commit所提交。原因在于,容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

以上,就是docker volume的核心原理了。

总结一下,将rootfs分层所示如下图

docker分层

参考

  1. https://time.geekbang.org/column/article/18119

  2. https://docs.docker.com/engine/reference/builder/

容器技术的实现

发表于 2019-11-07 分类于 容器与容器技术

容器技术的兴起

  • 容器技术的兴起源于PaSS技术的普及
  • Docker公司发布的Docker项目具有里程碑式的意义
  • Docker项目通过“容器镜像”,解决了应用打包的根本性难题

NameSpace隔离

我们经常看见一张对比虚拟机和Docker的图虚拟机 VS Docker

这幅图的左边,画出了虚拟机的工作原理。其中,名为 Hypervisor的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。

而这幅图的右边,则用一个名为 Docker Engine 的软件替换了 Hypervisor。这也是为什么,很多人会把 Docker 项目称为“轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。

可是这样的说法并不严谨。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace技术则是用来修改进程视图的主要方法。

假如运行一个容器,并进入其交互界面

1
2
3

$ docker run -it busybox /bin/sh
/ #

再执行ps指令

1
2
3
4
5

/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps

可以看见,容器中的/bin/bash的pid为1,以及命令本身包含的ps,这说明Docker已经被隔离在了一个完全隔离的世界中了。

而Docker采用的隔离机制其实就是Linux的NameSpace技术。而 Namespace 的使用方式也非常有意思:它其实只是 Linux 创建新进程的一个可选参数。我们知道,在 Linux 系统中创建线程的系统调用是 clone(),比如:

1
2

int pid = clone(main_function, stack_size, SIGCHLD, NULL);

这个系统调用就会为我们创建一个新的进程,并且返回它的进程号 pid。而当我们用 clone() 系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数,比如:

1
2

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

这时,新创建的这个进程将会“看到”一个全新的进程空间,在这个进程空间里,它的 PID 是 1。之所以说“看到”,是因为这只是一个“障眼法”,在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100。

除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

比如,Mount Namespace,用于让被隔离进程只看到当前 Namespace 里的挂载点信息;Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。

这,就是 Linux 容器最基本的实现原理了。

由此可见,虚拟机和Docker的比对图应该如下所示

docker VS 虚拟机修正版

使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。相比之下,容器技术的应用并不需要单独的GuestOS,共享宿主机的内核。

但是,有利也有弊,Linux NameSpace在隔离上也存在隔离不彻底的问题。

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。

这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

这意味着,在容器中修改了时间,宿主机的时间也会被修改,这显然不符合期望的要求。

所以总结来说,容器,就是一种特殊的进程。

Cgroups限制

由于容器是一个特殊的进程,这意味着,它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

好在Linux Cgroups就是Linux内核中用来设置资源限制的一个重要功能。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。可以通过mount指令显示

1
2
3
4
5
6
7
8

$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

可以看到,目录下有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件,这个指令是:

1
2
3
4

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks

其中cfs_period 和 cfs_quota 这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间,tasks可以用来指定进程的PID。

对于配置文件的使用,需要到对应的子系统下创建一个目录,例如:

1
2
3
4
5

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks

这个目录被称为控制组,操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。

假如在后台执行一个死循环脚本,让脚本将操作系统CPU吃满

1
2
3

$ while : ; do : ; done &
[1] 226

可以通过top指令来确认CPU实时变化

1
2
3

$ top
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

而此时,可以通过查看container目录下的文件,可以看到container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us)

1
2
3
4
5

$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

接下来可以通过修改配置文件的方式来对CPU占用率进行限制

1
2
3
4
5
6
7

$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

$ echo 226 > /sys/fs/cgroup/cpu/container/tasks

$ top
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,CPU占用率立马降为20%。除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:

  • blkio:为块设备设定I/O 限制,一般用于磁盘等设备;
  • cpuset:为进程分配单独的 CPU 核和对应的内存节点;
  • memory:为进程设定内存使用的限制。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。当然,至于控制组下面的资源文件限制值,可以在运行image的时候通过参数来指定

1
2
3
4
5
6
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
20000

总结来说,容器是一个单进程模型,这意味着,一个容器没法运行两个不同的应用,因为没法找到一个公共的PID=1来充当两个不同应用的父进程。这是因为容器本身的设计就是希望应用和容器同生命周期。

同样,Cgroups也是有利有弊,最明显的就是/proc文件系统问题。

/proc是用来存放当前内核的运行状态的一些特殊文件集合,比如CPU使用情况,内存使用情况,top指令的主要来源值就是来自于这里。但是,在容器中,proc显示的是宿主机的数据,这是绝对不允许的。

当然,可采用lxcfs来隔离宿主机和容器的文件系统。

容器文件系统的深入理解

由于NameSpace的作用是隔离,它的应用让容器只能看到该NameSpace内的世界;Cgroups的作用是限制,,它的作用是限制容器对于资源的使用率。对于理想的容器文件系统应该是看到一份完全独立的文件系统,且不受宿主机的影响。关于Mount NameSpace的小实验可以参考DOCKER基础技术,可以得出结论是:

Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

对于刚进入容器的文件系统来说,容器使用的是Linux名为chroot的指令,即改变进程根目录到指定的目录下。用法如下所示:

首先,创建一个test目录和几个lib文件夹,再把bash命令拷贝到test目录下对应的bin目录下,接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件可以用 ldd 命令

1
2
3
4
5
6
7
8
$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T
$ cp -v /bin/{bash,ls} $HOME/test/bin

$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done

最后,执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录

1
$ chroot $HOME/test /bin/bash

这时,你如果执行 “ls /“,就会看到,它返回的都是 $HOME/test 目录下面的内容,而不是宿主机的内容。实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

现在,对于Docker项目来说,最核心的原理已经完成了,即:

  • 启用 Linux Namespace 配置;
  • 设置指定的 Cgroups 参数;
  • 切换进程的根目录(Change Root)。

不过需要注意的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

也就是说,同一台机器上的所有容器,都共享一个操作系统内核,这也是容器相比于虚拟机的主要区别:毕竟后者有模拟出来的硬件机器充当沙盒,每个沙盒中还有一个完整的GuestOS。

当然,在有了rootfs之后,还必须面对的一个问题就是,是否需要每次改动一下应用都需要重新制作一次rootfs。

Docker的做法是,在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

这个做法主要依赖Linux的联合文件系统(Union File System),最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录 A 和 B,它们分别有两个文件:

1
2
3
4
5
6
7
8
$ tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x

然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上:

1
2
$ mkdir C
$ mount -t aufs -o dirs=./A:./B none ./C

这时,我再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:

1
2
3
4
5
$ tree ./C
./C
├── a
├── b
└── x

Docker采用AuFS这个联合文件系统来实现联合挂载,对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:/var/lib/docker/aufs/diff/<layer_id>,而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上/var/lib/docker/aufs/mnt/<layer_id>。

而且,从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

docker分层

第一部分:只读层

它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。

1
2
3
4
5
6
$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
etc sbin usr var
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
run
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

第二部分:可读写层

在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

而对于删除来说,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。

第三部分:Init层

它是一个以-init结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

参考

  1. https://coolshell.cn/articles/17010.html
  2. https://time.geekbang.org/column/intro/116

Python的垃圾回收机制

发表于 2019-08-16 分类于 python

引用计数

引用计数是指当对象的引用计数(指针数)为0时,表示这个对象不可达,需要被回收

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
import os
import psutil

# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)

info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))

def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')

func()
show_memory_info('finished')

########## 输出 ##########

initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB

这个例子说明的是,a是局部变量,当包含a的函数执行完成之后,a的引用就变为0,也就被回收了,想要改变这种方式的话,只需要将a设置为global变量即可

这里存在的问题是,如果a是被返回的值,那么列表a的生命周期就没有消失,仍然会继续占用内存

1
2
3
4
5
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
return a

查看对象的引用次数可用sys模块中的getrefcount函数

1
2
3
4
5
a = []
print(sys.getrefcount(a)) #output:2(一次来自于a,一次来自于getrefcount自身)
def get_count(a):
print(sys.getrefcount(a))
#output:4(a,python函数调用,函数参数,getrefcount)

需要注意的是,函数调用时,函数本身和函数参数都会产生调用,sys.getrefcount()并不是统计的指针数,而是统计指针指向的变量数量

1
2
3
4
5
6
7
8
9
10
11
12
>>> a = []
>>> import sys
>>> print(sys.getrefcount(a))
2
>>> b = a
>>> print(sys.getrefcount(a))
3
>>> c = b
>>> d = b
>>> f = c
>>> print(sys.getrefcount(a))
6

而回收内存的方法也很简单,先调用del语句,在强制调用gc.collect()手动启动垃圾回收即可

循环引用

python中绝大部分都采用的引用计数的垃圾回收机制,但是,当多个计数存在相互调用的情况下时,可能为内存带来很大的负担

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)

func()
show_memory_info('finished')
#output
"""
initial memory used: 8.2421875 MB
after a, b created memory used: 783.05078125 MB
finished memory used: 783.05078125 MB
"""

由上例可以看出,当func函数执行完之后,按理说a, b的生命周期已经结束,但是可以看出内存并没有释放。因为他们还有相互引用,导致内存并没有释放。

如果想要回收内存,可以显示调用gc.collect()手动启动垃圾回收

1
2
3
func()
gc.collect()
show_memory_info('finished')

python采用标记清除算法和分代收集,来启用针对循环引用的自动垃圾回收。

标记清除大致的意思是,python会想一个有向图一样去遍历每个节点,如果存在没有被标记的节点就会被回收,当然,不可能每次都去遍历全部节点,python采用的是双向链表维护了一个数据结构。

分代收集是指,python将所有对象分为三代。刚刚创建的对象为第0代,经过一次垃圾回收之后,依然存在的对象便会从上一代挪到下一代。而每一代都会有自动垃圾回收的阈值,值是可以单独指定的,当垃圾回收时,到达阈值时,对象就会被回收。

深入理解迭代器和生成器

发表于 2019-08-09 分类于 python

对于生成器来说,最大的作用可能是对于内存的节省,因为每次调用next()函数才会去取一次数据,直到抛出StopIteration异常。但这并不是本次笔记的重点,本次笔记的重点在于一定要牢记每次调用生成器之后,都会消耗生成器中的值

1
2
3
4
5
>>> a = (i for i in range(3))
>>> next(a)
0
>>> list(a)
[1, 2]

对于经常使用的(i in a)来说,其实它底层调用的还是生成器原理

1
2
3
4
while True:
val = next(a)
if val == i:
yield True

所以,先看以下示例

1
2
3
4
5
6
7
8
9
10
11
b = (i for i in range(5))

print(2 in b)
print(4 in b)
print(3 in b)

########## 输出 ##########

True
True
False

原因在于每次使用print函数时,都会消耗掉b中的值,而每次使用in函数时,比如2 in b就会消耗掉0, 1, 2这三个值,剩下3, 4这两个值,而4 in b时,其中存在4,就返回为True,此时b中已消耗完所有元素,在此调用就会失败。

在看一个leetcode示例,给定字符串 s 和 t ,判断 s 是否为 t 的子序列。

1
2
3
4
5
6
7
8
9
10
11
def is_subsequence(a, b):
b = iter(b)
return all(i in b for i in a)

print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))

########## 输出 ##########

True
False

Python的参数传递机制

发表于 2019-08-09 分类于 python

首先看以下示例:

1
2
3
4
5
6
7
8
>>> a = 1
>>> b = 1
>>> a is b
True
>>> a = [1, 2]
>>> b = [1, 2]
>>> a is b
False
  • 变量的赋值,表示让变量指向某个对象,并不是拷贝对象给变量;而一个对象是可以由多个对象指向
  • 可变对象(列表,字典,集合等)的改变,会影响所有指向该对象的变量
  • 对于不可变对象(字符串,整型,元祖)的改变,所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+,=等等)更新不可变对象时,会返回一个新的对象
  • 变量可以被删除,但对象无法被删除。这也和python的垃圾回收机制相符合,只有当该对象的指向变量为0个时,才会回收该对象。

通过以下示例:

1
2
3
4
5
6
7
>>> a = 1
>>> b = a
>>> a += 1
>>> a
2
>>> b
1

由于1是不可变对象,a, b都是指向1这个变量,a的值变化时,相当于a的指向又会重新改变,而b的指向是没有变化的

1565256260196

而对于可变对象来说,a, b会同时指向一个内存地址,改变可变对象的值,会改变所有指向该对象的变量

1
2
3
4
5
6
7
>>> a = [1, 2]
>>> b = a
>>> a.append(3)
>>> a
[1, 2, 3]
>>> b
[1, 2, 3]

1565312225472

1
2
3
4
5
6
7
>>> def func(a):
... a = 2
...
>>> b = 1
>>> func(b)
>>> b
1

在上述例子中,变量a和b同时指向1这个对象,当执行到a = 2时,是将a重新指向了2这个对象,而b仍然还是指向的1这个对象

如果想要改变上述过程

1
2
3
4
5
6
7
>>> def func(a):
... a = 2
... return a
>>> b = 1
>>> b = func(b)
>>> b
2

可将b变量重新指向2这个对象,这样就能改变b的值

而对于可变参数来说,改变对象的值,会改变所有指向它的值

1
2
3
4
5
6
>>> def func(a):
... a.append(4)
>>> b = [1, 2, 3]
>>> func(b)
>>> b
[1, 2, 3, 4]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> def func(a):
... a += [4]
...
>>> b = [1, 2, 3]
>>> func(b)
>>> b
[1, 2, 3, 4]
>>> def func(a):
... a = a + [4]
...
>>> b = [1, 2, 3]
>>> func(b)
>>> b
[1, 2, 3]

需要注意的是,a += [4]和a = a + [4]是不相同的操作,a = a + [4]是表示新创建一个新的列表,然后让a重新指向它,而a += [4]是一个自增的过程

1
2
3
4
5
6
7
8
9
>>> a = [1]
>>> id(a)
2610677016776
>>> a += [2]
>>> id(a)
2610677016776
>>> a = a + [3]
>>> id(a)
2610680008648

总结来说:

  • 如果对象是可变的,对象改变时,指向这个对象的所有变量都会改变
  • 如果对象是不可变的,对象改变时,指向这个对象的变量不会受到影响
  • 为了安全,函数末尾尽量使用return返回

装饰器

发表于 2019-08-09 分类于 python

之前对于装饰器的理解感觉比较片面,没成体系,在此记录一下装饰器的学习之路

Decorators is to modify the behavior of the function through a wrapper so we don’t have to actually modify the function.

也就是说,所谓装饰器,就是通过装饰器函数,来修改原函数的一些功能,使原函数在不需要修改的情况下达到某些目的。通常广泛应用于日志,身份认证,缓存等方面。

函数装饰器

先看一个简单例子

1
2
3
4
5
6
7
8
9
10
11
def my_decorator(func):
def wrapper():
print('wrapper of decorator')
func()
return wrapper

@my_decorator
def greet():
print('hello world')

greet()

@被称为语法糖,@my_decorator就相当于my_decorator(greet),当然,my_decorator和greet函数也能带参数

1
2
3
4
5
def my_decorator(func):
def wrapper(*args, **kwargs):
print('wrapper of decorator')
func(*args, **kwargs)
return wrapper

这样也不是万无一失的,我们发现greet函数的元信息发生了变化

1
2
3
4
5
6
7
8
9
greet.__name__
## 输出
'wrapper'

help(greet)
# 输出
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

为了解决这个问题,通常使用内置装饰器@functools.wrap,它会帮助我们保留函数的元信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import functools

def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print('wrapper of decorator')
func(*args, **kwargs)
return wrapper

@my_decorator
def greet(message):
print(message)

greet.__name__

# 输出
'greet'

类装饰器

不仅不可以使用函数装饰器,类也可以使用装饰器。类装饰器主要依赖于__call__函数

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
class Count:
def __init__(self, func):
self.func = func
self.num_calls = 0

def __call__(self, *args, **kwargs):
self.num_calls += 1
print('num of calls is: {}'.format(self.num_calls))
return self.func(*args, **kwargs)

@Count
def example():
print("hello world")

example()

# 输出
num of calls is: 1
hello world

example()

# 输出
num of calls is: 2
hello world

...

装饰器也可以是嵌套使用

1
2
3
4
5
@decorator1
@decorator2
@decorator3
def func():
...

相当于

1
decorator1(decorator2(decorator3(func)))

实际用法

最常用的应该还是日志记录。如果想要测试某些函数的耗时时长,装饰器就是一种常用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import time
import functools

def log_execution_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
res = func(*args, **kwargs)
end = time.perf_counter()
print('{} took {} ms'.format(func.__name__, (end - start) * 1000))
return res
return wrapper

@log_execution_time
def calculate_similarity(items):
...

对于身份认证来说,往往使用需要登录之后才能使用的功能,例如评论,在校验这种身份的时候也经常使用装饰器来校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import functools

def authenticate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
request = args[0]
if check_user_logged_in(request): # 如果用户处于登录状态
return func(*args, **kwargs) # 执行函数 post_comment()
else:
raise Exception('Authentication failed')
return wrapper

@authenticate
def post_comment(request, ...)
...

另外,对于测试来说,往往需要传入某种格式固定的参数,例如传入值的先后顺序时候合法等,也常用装饰器来进行校验

1
2
3
4
5
6
7
8
9
10
import functools

def validation_check(input):
@functools.wraps(func)
def wrapper(*args, **kwargs):
... # 检查输入是否合法

@validation_check
def neural_network_training(param1, param2, ...):
...

Python比较符与拷贝问题

发表于 2019-08-07 分类于 python

is VS ==

简单来说,==比较的是值大小,is比较的是内存地址,总的来说,is运算符的速度大于==运算符,这是在于is运算符没办法重载,而运行==运算符时,程序会先去搜索__eq__函数,如果没有重载,就会直接比较大小,由于python很多内置的函数都重载了__eq__函数

值得注意的是,对于整型数字来说,a is b为True的结论,只适用于-5到256之间,比如:

1
2
3
4
5
6
7
8
>>> a = 257
>>> b = 257
>>> id(a)
2314450519952
>>> id(b)
2314450941424
>>> a is b
False

原因在于,出于性能考虑,python内部会维持一个-5到256的数组,起到一个缓存的作用。每次你去创建一个-5到256范围的整型数字时,python会去这个数组中取值,而不是去创建一个新的内存。

浅拷贝与深拷贝

浅拷贝:是指重新分配一块内存,创建一个新的对象,里面的元素是对原对象中子对象的引用。如果原对象中元素是不可变类型,不会产生影响,如果是可变类型,会带来新对象的改变。

深拷贝:指重新分配一块新的内存地址,创建一个新的对象和新的元素,和原对象没有任何关联。

注意,浅拷贝带来的变化只针对原对象的子对象是可变类型数据,例如嵌套列表

1
2
3
4
5
6
7
8
>>> a = [[1, 2], 3]
>>> b = a[:]
>>> a[0].append(4)
>>> b
[[1, 2, 4], 3]
>>> a.append(5)
>>> b
[[1, 2, 4], 3]
1
2
3
4
5
6
7
8
9
10
>>> a = [[1, 2], (3, 4)]
>>> a[1] += (5, )
>>> a
[[1, 2], (3, 4, 5)]
>>> b = a[:]
>>> a[1] += (5, )
>>> b
[[1, 2], (3, 4, 5)]
>>> a
[[1, 2], (3, 4, 5, 5)]

而深拷贝不会受到任何影响

1
2
3
4
5
6
7
>>> from copy import deepcopy
>>> a
[[1, 2], (3, 4, 5, 5)]
>>> b = deepcopy(a)
>>> a[0].append(3)
>>> b
[[1, 2], (3, 4, 5, 5)]

深拷贝也不是完美的,如果被拷贝对象是自身,程序有可能会陷入无限循环

1
2
3
4
5
6
7
>>> x = [1]
>>> x.append(x)
>>> x
[1, [...]]
>>> y = deepcopy(x)
>>> y
[1, [...]]

x为一个无限嵌套的列表,深拷贝的时候,程序并没有报stack overflow的现象,这是在于深拷贝函数内部维护这一个字典,记录已经拷贝对象和ID,拷贝过程中,如果字典里存储了将要拷贝对象,会直接将字典中内容返回

而在使用==比较深拷贝之后的无限嵌套列表时,会抛出异常RecursionError: maximum recursion depth exceeded in comparison,这是在于列表中==会去遍历每个值的大小再去比较,而列表时无限嵌套的,所以会抛出异常

1
2
3
4
5
import copy
x = [1]
x.append(x)
y = copy.deepcopy(x)
print(x == y) #RecursionError: maximum recursion depth exceeded in comparison

sql范式

发表于 2019-08-07 分类于 sql

数据库范式

我们在设计数据库模型的时候,需要对关系内部各个属性之间联系的合理化程度进行定义,这就有了不同等级的规范要求,这些规范要求就被称为范式(NF)。也可以理解为,一张数据表的设计结构需要满足的某种设计标准级别。

目前关系型数据库一共有6种范式,,由低到高分别为:

  1. 1NF(第一范式)
  2. 2NF(第二范式)
  3. 3NF(第三范式)
  4. BCNF(巴斯-科德范式)
  5. 4NF(第四范式)
  6. 5NF(第五范式,也叫完美范式)

数据库范式的设计越高阶,冗余度就越低,同时高阶的范式一定会满足低阶的要求。

数据库表中键的定义:

  • 超键:能唯一标识的属性集

  • 候选键:如果超键中不包含多余的属性,那么这个键就是超键

  • 主键:用户可以从候选键中选择一个键作为主键

  • 外键:如果R1数据表中某属性集不是R1的主键,而是另一个表R2的主键,那么这个键就是R1的主键

  • 主属性:包含任一候选键的属性成为主属性

  • 非主属性:与主属性相对,不包含任何一个候选键的属性

第一范式:指的是数据表中任何属性都是原子性的,不可再分。几乎所有关系型数据库都满足第一范式的要求。

第二范式:指的是数据表的非主属性都要和这个数据表的候选键有完全依赖。以球员表player_game表为例,包含球员编号,姓名,年龄,比赛编号,比赛时间和比赛场地等属性,这里的候选键和主键分别为(球员编号和比赛编号)。但是这个数据表设计并不满足第二范式,因为还存在以下对应关系:

1
2
(球员编号)--> (姓名,年龄)
(比赛编号) --> (比赛时间,比赛场地)

这样会产生以下问题:

  1. 数据冗余:如果一个球员可以参加n场比赛,那么球员的姓名和年龄就重复了n-1次。一个比赛可能有m个球员参加,比赛时间和场地就重复了m-1次
  2. 插入异常:我们想要添加一场新的比赛,但是这时还没确定参加的球员都有谁,那么久没法插入
  3. 删除异常:比如想要删除某个球员编号,如果没有单独保存比赛表时,就会把比赛信息也删除掉
  4. 更新异常:如果想要调整某个比赛时间,那么数据表中所有时间都要调整

为避免以上情况,可以将一张表拆分为3张表。球员player表包含球员编号,年龄,姓名;比赛game表包含比赛编号,比赛时间和比赛场地等属性;球员关系player_game表包含球员编号,比赛编号,比赛得分等属性

第三范式:对任何非主属性都不传递依赖于候选键。

以球员player表为例,这张表包含球员编号,姓名,球队名称,球队主教练。球员编号决定了球队名称,球队名称决定了球队主教练,那么非主属性球队主教练就依赖于候选键球员编号。需要将player表拆分为下面这样:

球员表包含球员编号,姓名和球队名称;球队表包含球队名称和球队主教练。

当然,也不一定是范式越高就越好,越高阶以为这冗余越少,同时数据表也越多,搜索的时间也越大。实际工作中往往根据实际情况适当采用反范式,以时间换取空间的做法,容忍适当的冗余。

仓库名 管理员 物品名 数量
北京仓 张三 iPhone XR 10
北京仓 张三 iPhone 7 20
上海仓 李四 iPhone 8 30
上海仓 李四 iPhone X 40

在上表中,一个仓库只有一个管理员,同时一个管理员也管理者一个仓库。这样候选键为(仓库名,物品名)和(管理员,物品名),然后我们从中选取一个候选键作为主键。按照以上理论梳理,此表满足了1NF,2NF,3NF规范,但是同样存在以下问题:

  1. 增加一个仓库,但是没有存放任何物品,根据数据完整性的要求,主键不能有空值,因此会出现插入异常
  2. 仓库管理员更换之后,会修改多条记录
  3. 仓库物品卖空后,仓库名称和管理员都会随之删除掉

由此引入BCNF(巴斯-科德范式):它在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系

按照BCNF要求,我们需要将上表拆分为两个表:

  • 仓库表:(仓库名,管理员)
  • 库存表:(仓库名,物品名,数量)

sql基础

发表于 2019-08-05 更新于 2019-08-08 分类于 sql

Oracle执行流程

oracle执行流程

  1. 语法检查:检查 SQL 拼写是否正确,如果不正确,Oracle 会报语法错误。

  2. 语义检查:检查 SQL 中的访问对象是否存在。比如我们在写SELECT 语句的时候,列名写错了,系统就会提示错误。语法检查和语义检查的作用是保证 SQL 语句没有错误。

  3. 权限检查:看用户是否具备访问该数据的权限。

  4. 共享池检查:共享池(Shared Pool)是一块内存池,最主要的作用是缓存SQL语句和该语句的执行计划。Oracle通过检查共享池是否存在SQL语句的执行计划,来判断进行软解析

    • 软解析:在共享池中,Oracle首先对SQL语句进行Hash运算,然后根据Hash值在库缓存(Library Cache)中查找,如果存在SQL语句的执行计划,就直接拿来用,直接进入’执行器’环节
    • 硬解析:如果没找到SQL语句和执行计划,Oracle就需要创建解析树进行解析,生成执行计划,进入’优化器’这个环节
  5. 优化器:优化器中就需要进行硬解析,也就是决定怎么做,比如创建执行树,生成执行计划

  6. 执行器:执行SQL语句

MYSQL架构

MYSQL架构

MYSQL是典型的的C/S架构,服务端程序应用的是mysqld

  1. 连接层:客户端和服务器建立连接,客户端发送SQL至服务端
  2. SQL层:对SQL语句进行查询处理
  3. 存储引擎层:与数据文件打交道,负责数据存储和读取

MYSQL执行流程

mysql执行流程

  1. 缓存查询:Server在缓存中查询到了这条语句,会直接将结果返回给客户端,如果没有,就会进入解析器阶段。需要说明的是,因为查询效率不高,所以在MySQL8.0之后就抛弃掉这个功能
  2. 解析器:在解析器中对SQL语句进行语法分析,语句分析
  3. 优化器:在优化器中会确定SQL语句的执行路径
  4. 执行器:在执行之前需要判断用户是否具备权限,如果具备权限就执行SQL查询并返回结果。在MySQL8.0以下版本,会将查询结果缓存。

SELECT执行顺序

  1. 查询的关键字顺序

    1
    SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...
  2. SELECT的执行顺序(在MYSQL和Oracle中SELECT的执行顺序基本相同)

    1
    FROM > WHERE > GROUP BY > HAVING > SELECT 的字段 > DISTINCT > ORDER BY > LIMIT

    详细顺序如下所示

    1
    2
    3
    4
    5
    6
    7
    SELECT DISTINCT player_id, player_name, count(*) as num # 顺序 5
    FROM player JOIN team ON player.team_id = team.team_id # 顺序 1
    WHERE height > 1.80 # 顺序 2
    GROUP BY player.team_id # 顺序 3
    HAVING num > 2 # 顺序 4
    ORDER BY num DESC # 顺序 6
    LIMIT 2 # 顺序 7

SQL内置函数

通常将内置函数分为四类:

  1. 算术函数
  2. 字符串函数
  3. 日期函数
  4. 转换函数

算术函数

函数名 定义
ABS() 去绝对值
MOD() 取余
ROUND() 四舍五入为指定小数位数,需要有两个参数,分别为字段名称和小数位数
1
2
3
SELECT ABS(-2);		#output:2
SELECT MOD(10, 3); #output:1
SELECT ROUND(1.123456, 2) #output:1.12

字符串函数

函数名 定义
CONCAT() 将多个字段拼接起来
LENGTH() 计算字段长度,一个汉字算3个字符
CHAR_LENGTH() 计算字段长度,汉字,字母,数字都算1个字符
LOWER() 将字符串转换为小写
UPPER() 将字符串转换为大写
REPLACE() 替换函数,有3个参数,分别为:要替换的表达式或字段名,old,new
SUBSTRING() 截取字符串,有3个参数,分别为:字符串,开始位置,截取长度(下边从1开始)
1
2
3
4
5
6
7
8
-- SELECT CONCAT('aa','bb','cc') as concat;
-- SELECT LENGTH('qwerty') as len;
-- SELECT LENGTH('唐肖') as han_len;
-- SELECT CHAR_LENGTH('唐肖') as char_len;
-- SELECT LOWER('PYTHON') as low;
-- SELECT UPPER('python') as up;
-- SELECT REPLACE('weekday', 'k','ken') as re;
SELECT SUBSTRING('python', 1, 3) as sub;

日期函数

函数名 定义
CURRENT_DATE() 系统当前日期
CURRENT_TIME() 系统当前时间,没有具体日期
CURRENT_TIMESTAMP() 系统当前时间,包含日期和时间
EXTRACT() 抽取具体的年,月,日
DATE() 返回时间的日期部分
YEAR() 返回时间的年
MONTH() 返回时间的月
DAY() 返回时间的天数
HOUR() 返回时间的小时
MINUTE() 返回时间的分钟
SECOND() 返回时间的秒

转换函数

函数名 定义
CAST() 数据类型转换,参数是一个表达式,表达式通过AS关键词分割了2个参数,分别是原始数据和目标类型数据
COALESCE() 返回第一个非空数值

子查询

参数 含义
EXISTS 判断条件是否存在,存在未True,否则为False
IN 判断是否在结果集中
ANY 需要与比较操作符一起使用,与子查询返回的任何值做比较
ALL 需要与比较操作符一起使用,与子查询返回的所有值做比较
  1. 查看出场过的球员都有哪些
1
SELECT player_id, team_id, player_name FROM player WHERE player_id in (SELECT player_id FROM player_score WHERE player.player_id = player_score.player_id)
  1. 查询球员表中,比印第安纳步行者(对应的 team_id 为1002)中任何一个球员身高高的球员的信息
1
SQL: SELECT player_id, player_name, height FROM player WHERE height > ANY (SELECT height FROM player WHERE team_id = 1002)
  1. 查询球员表中,比印第安纳步行者(对应的 team_id 为1002)中所有球员身高高的球员的信息
1
SQL: SELECT player_id, player_name, height FROM player WHERE height > ALL (SELECT height FROM player WHERE team_id = 1002)
  1. 查询场均得分大于 20 的球员。场均得分从player_score 表中获取
1
SELECT player_id,team_id,player_name FROM player WHERE player_id IN(SELECT player_id FROM player_score GROUP BY player_id HAVING AVG(score) > 20);

联表查询

由于主流关系型数据库对SQL92的支持更好,在此以SQL92作为示例。

交叉查询

1
SELECT * FROM player CROSS JOIN team

通过CROSS JOIN关键字可以得到两张表的笛卡尔积查询结果,当然也可以多次使用该关键字来连接多张表

自然连接

使用NATURAL JOIN关键字可以自动连接两张表相同的关键字,然后进行等值连接

1
SELECT player_id, team_id, player_name, height, team_name FROM player NATURAL JOIN team

ON连接

ON用来指定连接条件,ON player.team_id = team.team_id相当于是指定team_id字段的等值连接

1
SELECT player_id, player.team_id, player_name, height, team_name FROM player JOIN team ON player.team_id = team.team_id

当然,也可以进行非等值连接

1
2
3
SELECT p.player_name, p.height, h.height_level
FROM player as p JOIN height_grades as h
ON height BETWEEN h.height_lowest AND h.height_highest

USING

使用USING关键字指定数据库中相同字段名进行等值连接

1
SELECT player_id, team_id, player_name, height, team_name FROM player JOIN team USING(team_id)

外连接

包含三种连接方式:

  1. 左连接:LEFT JOIN
  2. 右连接:RIGHT JOIN
  3. 全连接:FULL JOIN

值得注意的是,MYSQL并不支持全连接

例子:根据不同身高等级查询球员的个数,输出身高等级和个数

1
SELECT height_level, count(*) FROM height_grades as h JOIN player as p ON p.height BETWEEN h.height_lowest AND h.height_highest GROUP BY height_level;

视图

视图作为一张虚拟表,只是帮助我们封装底层与数据库接口,相当于一张表或多张表的结果集,视图一般在数据量比较大的情况下使用,它具有以下特点,

  • 安全性:虚拟表是基于底层数据库的,我们在使用视图时一般不会通过视图对底层数据进行修改,在一定程度上保证了数据的安全性,同时,还可以针对不同用户开放不同的数据查询权限
  • 简单清晰:视图是对SQL语句的封装,将原来复杂的语句简单化,类似于函数的作用

创建视图

1
2
3
4
CREATE VIEW view_name AS
SELECT column1, column2
FROM table
WHERE condition

例:

1
CREATE VIEW avg_height AS SELECT AVG(height) FROM player;

同时,视图还支持视图嵌套

修改视图

1
2
3
4
ALTER VIEW view_name AS
SELECT column1, column2
FROM table
WHERE condition

删除视图

1
DROP VIEW view_name

关于视图的应用

查询球员中的身高介于1.90m到2.08m之间的名字,身高,以及对应的身高等级

  1. 创建身高等级的视图

    1
    2
    3
    4
    CREATE VIEW player_height_grades AS
    SELECT p.player_name, p.height, h.height_level
    FROM player as p JOIN height_grades as h
    ON height BETWEEN h.height_lowest AND h.height_highest
  2. 查询身高介于1.90m到2.08m之间的名字,身高,以及对应的身高等级

    1
    SELECT * FROM player_height_grades WHERE height >= 1.90 AND height <= 2.08

事务

事务的特性(ACID)是:要么全部成功,要么全部失败。这保证了数据的一致性和可恢复性,它保证了我们在增加,删除,修改的时候某一个环节出错也能回滚还原。

Oracle是支持事务的,在MYSQL中,InnoDB才支持事务,可以通过SHOW ENGINES查看哪些存储引擎支持事务

事务的流程控制语句:

  1. START TRANSACTION或BEGIN:显式开启一个事务
  2. COMMIT:提交事务。提交事务之后,对数据库的修改是永久性的。
  3. ROLLBACK或ROLLBACK TO [SAVEPOINT]:回滚事务,表示撤销当前所有没有提交的修改或回滚到某个保存点
  4. SAVEPOINT:在事务中创建保存点,一个事务可以有多个保存点
  5. RELEASE SAVEPOINT:删除某个保存点
  6. SET TRANSACTION:设置事务的隔离级别

需要注意的是,使用事务有两种,分别为隐式事务和显式事务。隐式事务实际上就是自动提交,Oracle不自动提交,需要手写COMMIT命令,而MYSQL自动提交

1
2
mysql> set autocommit =0;  // 关闭自动提交
mysql> set autocommit =1; // 开启自动提交

在MYSQL默认情况下

1
2
3
4
5
6
7
8
9
CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;
BEGIN;
INSERT INTO test SELECT '关羽';
COMMIT;
BEGIN;
INSERT INTO test SELECT '张飞';
INSERT INTO test SELECT '张飞';
ROLLBACK;
SELECT * FROM test;

表中存在一条数据关羽,原因在于name为主键,插入第二条数据的name字段为张飞时,抛出异常,回滚到上一次事务提交点

MYSQL中completion_type参数:

1
SET @@completion_type = 1;
1
2
3
4
5
6
7
8
9
CREATE TABLE test(name varchar(255), PRIMARY KEY (name)) ENGINE=InnoDB;
SET @@completion_type = 1;
BEGIN;
INSERT INTO test SELECT '关羽';
COMMIT;
INSERT INTO test SELECT '张飞';
INSERT INTO test SELECT '张飞';
ROLLBACK;
SELECT * FROM test;

表中存在一条数据,原因在于completion=1相当于在提交之后,在下一行写下BEGIN

  1. completion=0,默认情况,在我们执行COMMIT的时候提交事务,在执行下一个事务时,还需要START TRANSACTION或BEGIN来开启
  2. completion=1,提交事务之后,相当于是执行了COMMIT AND CHAIN,也就是开启了一个链式事务,即当我们提交了事务之后会开启一个相同隔离级别的事务
  3. completion=2,这种情况下COMMIT=COMMIT AND RELEASE,在我们提交之后会自动断开与服务器连接

事务隔离级别

严格来讲,我们可以使用串行化的方式来执行每个事务,这就意味着每个事务相互独立,不存在并发的情况。在生产中,往往存在高并发情况,这时需要降低数据库的隔离标准来换取事务之间的并发数

三种异常情况

  1. 脏读(DIRTY READ):读到了其他用户还没提交的事务
  2. 不可重复读(Nnrepeatable Read):对某数据进行读取,发现两次结果不同。这时由于有其他事务对这个数据进行了修改
  3. 幻读(Phantom Read):事务A根据条件查询到了N条事务,但此时B事务更改了符合事务A查询条件的数据,事务A再次查询发现数据不一致

针对不同的异常情况,SQL92设置了4中隔离级别

脏读 不可重复读 幻读
读未提交(READ UNCOMMITTED) 允许 允许 允许
读已提交(READ COMMITTED) 禁止 允许 允许
可重复读(REPEATABLE READ) 禁止 禁止 允许
可串行化(SERIALIZABLE) 禁止 禁止 禁止

读已提交属于RDBMS中常见的默认隔离级别(比如Oracle和SQL Server),如果想要避免不可重复读和幻读,需要在SQL查询时编写带锁的SQL语句

可重复读,是MYSQL默认的隔离级别

PYTHON操作MYSQL接口

在此使用pymysql模块来操作mysql接口

connection可以对当前数据库的连接进行管理,它提供以下接口

  1. 指定host,user,passwd,port,database等参数连接数据库
  2. db.cursor():创建游标
  3. db.close():关闭连接
  4. db.begin():开启事务
  5. db.commit()和db.rollback():事务提交和回滚

游标提供的接口:

  1. cursor.execute():执行sql语句

  2. cursor.fetchone():读取查询结果一条数据

  3. cursor.fetchall():读取查询结果全部数据,以元祖类型返回

  4. cursor.fetchmany(n):读取查询结果多条数据,以元祖类型返回

  5. cursor.rowcount:返回查询的行数

  6. cursor.close():关闭游标。

为了保证数据修改的正确,可用try..except…模式捕获异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import traceback
try:
sql = "INSERT INTO player (team_id, player_name, height) VALUES (%s, %s, %s)"
val = (1003, " 约翰 - 科林斯 ", 2.08)
cursor.execute(sql, val)
db.commit()
print(cursor.rowcount, " 记录插入成功。")
except Exception as e:
# 打印异常信息
traceback.print_exc()
# 回滚
db.rollback()
finally:
# 关闭数据库连接
db.close()

Python ORM框架操作MYSQL

ORM(Object Relation Mapping),使用ORM框架的原因在于随着项目的增加,降低维护成本,且不用关系底层的SQL语句是如何写的,就可以像类或者函数一样使用。以下示例都基于sqlalchemy

初始化表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from sqlalchemy import Column, String, create_engine, Integer, Float
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import func
# 创建对象的基类:Base = declarative_base()
# 定义User对象:
class Player(Base):
# 表的名字:
__tablename__ = 'player'
# 表的结构:
player_id = Column(Integer, primary_key=True, autoincrement=True)
team_id = Column(Integer)
player_name = Column(String(255))
height = Column(Float(3, 2))
# 初始化数据库连接:
engine = create_engine('mysql+pymysql://root:123456@localhost:3306/heros_data')
# 创建DBSession类型:
DBSession = sessionmaker(bind=engine)
session = DBSession()

连接格式为数据库类型+数据库连接框架://用户名:密码@host:port/数据库名

__tablename指明了对应的数据库表名称

在 SQLAlchemy 中,我们采用 Column 对字段进行定义,常用的数据类型如下:

Integer 整数型
Float 浮点型
Decimal 定点类型
Boolean 布尔类型
Date datetime.date日期类型
Time datetime.date时间类型
String 字符类型,使用时需指明长度
Text 文本类型

除了数据类型之外,也可以指定Column参数

default 默认值
primary_key 是否为主键
unique 是否唯一
autoincrement 是否自增

增加数据

1
2
3
4
# 新增一行数据
new_player = Player(team_id=1002, player_name='唐潇唐', height=1.71)
session.add(new_player)
session.commit()

修改数据

1
2
3
4
5
# 将球员身高为2.08的全部改为2.09
rows = session.query(Player).filter(Player.height == 2.08).all()
for i in rows:
i.height = 2.09
session.commit()session.close()

删除数据

1
2
3
4
row = session.query(Player).filter(Player.player_name=='约翰 - 科林斯').first()
session.delete(row)
session.commit()
session.close()

查询数据

query(Player)相当于是select *,这时可以对player表中所有字段进行打印

filter()函数相当于是WHERE条件查询

多条件查询时,比如查询身高大于等于2.08,小于等于2.10的球员

1
rows = session.query(Player).filter(Player.height >=2.08, Player.height <=2.10).all()

使用or查询时,需要引入or_方法

1
2
from sqlalchemy import or_
rows = session.query(Player).filter(or_(Player.height >=2.08, Player.height <=2.10)).all()

分组查询,排序,统计等需要引入func方法

1
2
3
from sqlalchemy import func
rows = session.query(Player.team_id, func.count(Player.player_id)).group_by(Player.team_id).having(func.count(Player.player_id)>5).order_by(func.count(Player.player_id).asc()).all()
print(rows)

关于条件查询更多的接口,可以查看filter方法和func方法的源码目录

12
唐潇唐

唐潇唐

进击的测试
17 日志
5 分类
RSS
E-Mail
0%
© 2019 唐潇唐