0%

最后一篇以前写的文章了,

左偏树是一种实现简单的可并堆,可以在 logn 的复杂度下面完成优先队列的插入,删除,合并操作。

这就是一颗左偏树啦 蓝色是该节点的 dist

左偏树

结构体定义

1
2
3
4
5
6
struct Node{
int l,r,d,v;//左右子树,距离和键值
Node(){
l=r=v=d=0;
}
}

我们定义左偏树的外界点为,一个左子树为空或者一个右子树为空的结点,结点的 dist 为它到它子树内外节点的最短距离。一个左偏树在满足堆的性质的前提下还应该满足它的右子树的 dist 不大于它的左子树的 dist。由此,我们可以得出其他一些性质。

当前结点的 dist 为右子树 dist 的长度+1
若左偏树根节点的 dist 为一定值,则节点数最少的左偏树是一颗完全二叉树,并且为满二叉树
由上一条性质,可以知道根节点 dist 为 n 的左偏树,最少含有 2n+1−12n+1−1 个结点
一个有 n 个结点的左偏树,根节点距离最大为|log2(n+1)|−1|log2(n+1)|−1
左偏树的操作(最大堆)

递归合并
如果有一颗树为空,返回另一棵树
否则,就将根节点键值较大树的右子树和根节点键值较小树合并
如果合并后破坏了左偏树的性质(左子树 dist<右子树 dist)
交换根节点的左右子树
更新根节点的 dist 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int merge(int x,int y){
if(!x){
return y;
}
if(!y){
return x;
}
if(node[x].v<node[y].v){
swap(x,y);
}
node[x].r=merge(node[x].r,y);
if(node[node[x].l].d<node[node[x].r].d){
swap(node[x].l,node[x].r);
}
node[x].d=node[node[x].r].d+1;
return x;
}

插入
相当于和只有一个结点的树合并

1
2
3
4
5
int insert(int x,int v){
cnt++;
node[cnt].v=v;
return merge(x,cnt);//返回新的根节点
}

堆操作

1
2
3
4
5
6
int top(int x){
return node[x].v;
}
int pop(int x){
return merge(node[x].l,node[x].r);//返回新的根节点
}

以上操作都以左偏树堆顶的编号代表这一刻左偏树
例题 hdu1512 左偏树加并查集的模版题,今年省赛的热身赛就是这题来着,当时乱搞搞出来了(数据太弱),重新用左偏树写一下。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=1e5+7;
int cnt=0;
int n,m;
int p[maxn],pqroot[maxn];
struct Node{
int l,r,d,v;
Node(){
l=r=v=d=0;
}
void fight(){
l=r=d=0;
v/=2;
}
}node[maxn];
int merge(int x,int y){
if(!x){
return y;
}
if(!y){
return x;
}
if(node[x].v<node[y].v){
swap(x,y);
}
node[x].r=merge(node[x].r,y);
if(node[node[x].l].d<node[node[x].r].d){
swap(node[x].l,node[x].r);
}
node[x].d=node[node[x].r].d+1;
return x;
}
int pop(int x){
return merge(node[x].l,node[x].r);
}
int find(int x){
return x==p[x]?x:x=find(p[x]);
}
int uset(int x,int y,int root){
p[x]=y;
pqroot[y]=root;
}
int main(){
//freopen("in.txt","r",stdin);
while(cin>>n){
memset(node,0,sizeof(node));
memset(p,0,sizeof(p));
memset(pqroot,0,sizeof(pqroot));
node[0].d=-1;
for(int i=1;i<=n;i++){
p[i]=i;
pqroot[i]=i;
scanf("%d",&node[i].v);
}
cin>>m;
for(int i=0;i<m;i++){
int p1,p2;
scanf("%d%d",&p1,&p2);
int s1=find(p1),s2=find(p2);
if(s1!=s2){
int pqroot1=pqroot[s1],pqroot2=pqroot[s2];
int newroot1=pop(pqroot1);
int newroot2=pop(pqroot2);
node[pqroot1].fight();
node[pqroot2].fight();
newroot1=merge(newroot1,pqroot1);
newroot2=merge(newroot2,pqroot2);
int newpqroot=merge(newroot1,newroot2);
uset(s1,s2,newpqroot);
printf("%d\n",node[newpqroot].v);
}else{
printf("-1\n");
}
}
}
}

并查集维护一个当前集合内优先队列的根节点 pqroot,合并的时候注意根的相关操作就 OK 了。

这还是一篇以前写的文章。

差分约束问题是一类出一些形如 x-y<=b 不等式的约束,问你是否满足有解的问题,这类问题竟然可以转换成图论里的最短路径问题,下面开始详细介绍下
比如给出三个不等式,b-a<=k1,c-b<=k2,c-a<=k3,求出 c-a 的最大值,我们可以把 a,b,c 转换成三个点,k1,k2,k3 是边上的权,如图
由题我们可以得知,这个有向图中,由题 b-a<=k1,c-b<=k2,得出 c-a<=k1+k2,因此比较 k1+k2 和 k3 的大小,求出最小的就是 c-a 的最大值了
根据以上的解法,我们可能会猜到求解过程实际就是求从 a 到 c 的最短路径,没错的….简单的说就是从 a 到 c 沿着某条路径后把所有权值和 k 求出就是 c -a<=k 的一个
推广的不等式约束,既然这样,满足题目的肯定是最小的 k,也就是从 a 到 c 最短距离
再看 a - b <= k1,b - c <= k2, c-1 <= k3, 那么有 k1+k2+k3>=0,因此若图中存在负环,就说明不存在满足条件的解
判断图中是否存在负环 可以使用 dfs 形式的 spfa 算法,使用一个 vis 数组 值为 1 表示当前正在递归栈中的点,因为点可能多次入栈,需要注意回溯时处理。
洛谷 P1993 使用了超级源点的思想,将图中的所有点与超级源点连一条权值为 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include<bits/stdc++.h>
using namespace std;
int n,m;
const int inf = 1e9+7;
struct node{
int to,w;
node(int to,int w){
this->to = to,this->w = w;
}
};
const int maxn = 1e4+7;
vector<node> gra[maxn];
int dist[maxn],vis[maxn];
void addedge(int a,int b,int c){
gra[a].push_back(node(b,c));
}
int spfa(int x){
for(int i = 0; i < gra[x].size(); i++){
int to = gra[x][i].to;
if(dist[to]>dist[x]+gra[x][i].w){
if(vis[to] == 1){
return 0;
}
vis[to] = 1;
dist[to]=dist[x]+gra[x][i].w;
int flag = spfa(to);
if(!flag){
return 0;
}
vis[to] = 0;// 回溯的处理
}
}
return 1;
}
int main(){
cin>>n>>m;
int flag = 1;
fill(dist,dist+maxn,inf);
for(int i = 0; i < m; i++){
int t,a,b,c;
cin>>t;
if(t == 1){
cin>>a>>b>>c;
addedge(b,a,-c);
}else if(t == 2){
cin>>a>>b>>c;
addedge(a,b,c);
}else{
cin>>a>>b;
addedge(a,b,0);
addedge(b,a,0);
}
}
memset(vis,0,sizeof(vis));
for(int i = 1; i <= n; i++){
addedge(0,i,0);
}
dist[0] = 0;
if(spfa(0)){
cout<<"Yes";
}else{
cout<<"No";
}
}

对于有向图而言 可以使用拓扑排序的方式找出图中的环

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
33
34
35
36
37
38
39
40
41
42
43
44
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e5+7;
int n;
int du[maxn];//入度
vector<int> gra[10];
vector<int> res;
void addedge(int a,int b){
du[b]++;
gra[a].push_back(b);
}
void topo(){
queue<int> q;
for(int i = 1; i <= n; i++){
if(du[i] == 0){
q.push(i);
}
}
while(!q.empty()){
int temp = q.front();
q.pop();
for(int i = 0; i < gra[temp].size(); i++){
int to = gra[temp][i];
du[to]--;
if(!du[to]){
q.push(i);
}
}
}
}
int main(){
cin>>n;
for(int i = 0; i < n; i++){
int a,b;
cin>>a>>b;
addedge(a,b);
}
topo();
for(int i = 1; i <= n; i++){
if(du[i]){
cout<<i<<' ';
}
}
}

对于无向图 可以采用并查集的方式,在读边的时候,如果两个顶点在同一个集合中,说明构成了环,这时令这两个顶点作为起点和终点,深搜一下输出路径即可

蓝桥杯 2017 国赛 c++b 组 t4 找环

题意:

编号为 1 到 n 的 n 个点,以及 n-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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e+7;
int n;
int p[maxn];
vector<int> gra[10];
vector<int> res;
int init(){
memset(p,0,sizeof(p));
for(int i = 1; i <= n; i++){
p[i] = i;
}
}
int find(int x){
return p[x]==x?x:p[x]=find(p[x]);
}
int unit(int x,int y){
p[x] = y;
}
void addedge(int x,int y){
gra[x].push_back(y);
gra[y].push_back(x);
}
int s,e;
int vis[maxn];
int path[maxn];
void dfs(int x){
cout<<x<<endl;
for(int i = 0; i < gra[x].size(); i++){
int to = gra[x][i];
if(!vis[to]){
path[to] = x;// 记录路径
vis[to] = 1;
if(to == e){
return;
}
dfs(to);
}
}
}
int main(){
freopen("in.txt","r",stdin);
cin>>n;
init();
memset(vis,0,sizeof(vis));
memset(path,0,sizeof(path));
for(int i = 0; i < n; i++){
int a,b;
cin>>a>>b;
int aa = find(a),bb = find(b);
if(aa!=bb){
unit(a,b);
}else{// 说明构成了环
s = a,e = b;
break;
}
addedge(a,b);
}
vis[s] = 1;
dfs(s);
int temp = e;
res.push_back(temp);
while(path[temp]){
res.push_back(path[temp]);
temp = path[temp];
}
sort(res.begin(),res.end());
for(int i = 0; i < res.size(); i++){
if(i>0){
cout<<' ';
}
cout<<res[i];
}
}

另外还可以采用深搜的方式,首先需要了解

在图的遍历中,往往设置了一个标记数组 vis 来记录顶点是否被访问过。但有些时候需要改变 vis 值的意义。令 vis 具有 3 种值并表示 3 种不同含义

vis = 0,表示该顶点没没有被访问
vis = 1,表示该顶点已经被访问,但其子孙后代还没被访问完,也就没从该点返回
vis = 2,,表示该顶点已经被访问,其子孙后代也已经访问完,也已经从该顶点返回
可以 vis 的 3 种值表示的是一种顺序关系和时间关系

《算法导论》334 页有这 4 种边的准确定义

边的定义

DFS 过程中,对于一条边 u->v
vis[v] = 0,说明 v 还没被访问,v 是首次被发现,u->v 是一条树边
vis[v] = 1,说明 v 已经被访问,但其子孙后代还没有被访问完(正在访问中),而 u 又指向 v,说明 u 就是 v 的子孙后代,u->v 是一条后向边,因此后向边又称返祖边,
vis[v] = 2,说明 v 已经被访问,其子孙后代也已经全部访问完,u->v 这条边可能是一条横叉边,或者前向边

注意:树边,后向边,前向边,都有祖先,后裔的关系,但横叉边没有,u->v 为横叉边,说明在这棵 DFS 树中,它们不是祖先后裔的关系它们可能是兄弟关系,堂兄弟关系,甚至更远的关系,如果是 dfs 森林的话,u 和 v 甚至可以在不同的树上

对于无向图而言,只存在树边和返祖边,当发现一条返祖边时,说明找到了环,这时输出所有 vis[i] = 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e5+7;
int vis[maxn];
int n;
vector<int> gra[10];
vector<int> res;
void addedge(int a,int b){
gra[a].push_back(b);
gra[b].push_back(a);
}
int flag = 0;
void findp(){ // 找环
for(int i = 1; i <= n; i++){
if(vis[i] == 1){
res.push_back(i);
}
}
}
void dfs(int x,int p){
for(int i = 0; i < gra[x].size(); i++){
int to = gra[x][i];
if(!vis[to]){
vis[to] = 1;
dfs(to,x);
}else if(to!= p && vis[to]){
if(!flag){
flag = 1;
findp();
}
return;
}
}
vis[x] = 2;
}
int main(){
cin>>n;
memset(vis,0,sizeof(vis));
for(int i = 0; i < n; i++){
int a,b;
cin>>a>>b;
addedge(a,b);
}
vis[1] = 1;
dfs(1,0);
sort(res.begin(),res.end());
for(int i = 0; i < res.size(); i++){
if(i>0){
cout<<' ';
}
cout<<res[i];
}
}

昨天新买的笔记本到了,折腾了一下午安装 windows 下的软件,尝试了把自己装了 archlinux 移动固态接到笔电上,提示找不到分区 UUID。因为之前一直接在台式用,所以 fstab 上有台式机硬盘的记录,于是换上台式进 arch,把 fstab 里台式的分区都注释了(伏笔)。折腾一番可以进系统了,输完账号密码后,黑屏,是的它黑屏了。
开始猜测是显卡驱动的问题,切到字符 tty,安装上 amd 的显卡驱动,重启,依然不行。之后就是漫长的百度与谷歌时间。gnome 桌面是跟着教程安装的,一直以来都是 gdm 启动,所以我对于 X11 的启动过程基本是一窍不通。尝试使用 startx 手动启动,发现 root 可以进入,自己的用户进去就是黑屏。网上的一些方案有删除 gnome 插件,检查 PATH 设置,检查.Xauthority 文件权限,我都尝试了。更令我不能理解的是我换回台式机,它也黑屏了,我只好把自己对系统作的更改一条一条还原。这时看到网上一条评论说 X11 启动会读取用户目录下.x 目录下配置文件,既然 root 能进说明驱动啥的都没问题啊,可能就是配置文件的问题。我看了一下,arch 下没有.x 文件夹,不过在.config 文件下有很多 gnome 的文件夹。我突然想到自己之前因为空间不足,把用户目录下的.cache 和.config 都挂载硬盘的另一个分区上了。cd 到挂载目录,ls 显示目录为空。果然人一傻逼起来是没有救的,看了一下 fstab,果然那个分区的挂载注释掉了。修改之后重启,一切正常,此时已经是夜里 2 点钟。
谨以此记录一下我因为傻逼而浪费掉的 4 个小时的人生。
另如果想要在移动存储设备上安装 linux,务必参考官方 wiki在 USB 设备上安装 Arch Linux

这道题以前做过,但是忘记怎么做的了。第一次交了一个双重循环的暴力。查看题解一下就明白了正确的贪心算法。

题目描述

给你 N 个非负的正整数 a1,a2,a3,…an,每个代表了坐标轴上的一个点(i,ai)。n 条垂直的线连接点和 x 轴,要求找出两条线,让它们和 x 轴组成的容器的容积最大。

解题思路

正解是用两点逼近的算法。首先容器的高度是由最短的那条线决定的。
维护一个变量记录最大容积。记录所取的两端的端点位置,每次计算当前情况的容积,并更新最大容积。之后更新较短的那条线的端点位置(一定是更新较短的端点位置,否则容积一定小于当前容积,因为只有宽度减小),更新方法为向另一端的方向遍历直到遇到一条比当前线高度高的线(比当前线高度低的线组成的容积一定小于当前情况的容积)。

以下是代码

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
func maxArea(height []int) int {
left, right := 0, len(height)-1
res := 0
for left < right {
minHeight := 0
if height[left] < height[right] {
minHeight = height[left]
} else {
minHeight = height[right]
}

newArea := (right - left) * minHeight
if newArea > res {
res = newArea
}

if height[left] < height[right] {
newLeft := left + 1
for newLeft < right && height[newLeft] < height[left] {
newLeft = newLeft + 1
}
left = newLeft
} else {
newRight := right - 1
for newRight > left && height[newRight] < height[right] {
newRight = newRight - 1
}
right = newRight
}
}
return res
}

强烈推荐这个博主的系列文章 docker 基础

基础命令

docker images 显示出所有的镜像
docker tag 给镜像打 tag eg docker tag unbuntu myubuntu:last
docker inspect 查看镜像信息
docker history 显示 layer 基础信息
docker search —filter=is-automated=true nginx
docker rmi [-f 强行删除] 镜像 tag/镜像 ID 删除镜像 当镜像有容器在运行时,会提示无法删除。当不加 tag 时默认为 latest
docker commit [OPTIONS]

  • -a —author 作者信息
  • -c —change = [] 提交的时候执行 dockerfile 指令
  • -m —message 提交信息
  • -p —pause=true 提交时暂停容器运行

docker commit -a “xxx” -m “xxx” [容器名称 容器 id] 镜像名称 基于已有镜像的容器创建
docekr create [options] 镜像名 新建一个容器 此时容器处于停止状态
docker run [option] 镜像名 新建一个容器 并且容器处于运行状态

  • -P 暴露在 Dockerfile 中 Expose 的端口
  • -idt -i 让容器标准输入打开 -d 创建后台守护进程 -t 给 docker 分配一个伪终端
  • —name 命名容器
  • docker run -d -P —name web -v /src/webapp:/opt/webapp:ro [镜像 id 镜像名称] 容器名称 运行程序
  • docker run -it -v /dbdata —name dbdata ubuntu 创建数据卷容器
  • docker run -it —volumes-from dbdata —name db1 ubuntu 共享数据卷
  • docker run -d -P —name web —link db:db 镜像名 程序名 —link name:alias

docker start 容器名或容器 ID
docker restart 容器名或者容器 ID 先将一个运行态的容器先停止,然后重新启动它
docker attach 容器名称或容器 ID 所有窗口会同步显示,当有一个窗口阻塞的时候,其他窗口也会阻塞
docker exec -it 容器名或容器 id /bin/bash
docker rm 删除处于终止或者退出状态的容器
docker top 镜像名 查看镜像内的进程
docker logs 镜像名 获取容器的日志

Dockerfile 指令

可以查看这本 Gitbook Docker 从入门到实践 以下记录一些我不熟悉的知识点,便于复习

VOLUME 定义匿名卷

格式为:
VOLUME [“<路径 1>”, “<路径 2>”…]
VOLUME <路径>
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据
VOLUME /data
这里的 /data 目录就会在运行时自动挂载为匿名卷,任何向 /data 中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:

1
docker run -d -v mydata:/data xxxx

在这行命令中,就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置。
如果需要在删除容器的同时移除数据卷。可以在删除容器的时候使用 docker rm -v 这个命令

docker volume prune 清理无主的数据卷

如何修改已存在容器的配置

直接修改 /var/lib/docker/contains/[container_hash]/ 目录下的 config.v2.json 和 hostconfig.json 文件,修改之后需要重启 docker,再重新启动容器,就生效啦

docker 架构

docker 采用 C/S 架构,包含客户端和服务端,通过镜像仓库来存储镜像。客户端和服务端可以不在一个机器上,通过 socket 或者 restful api 来进行通信。

什么是镜像

镜像是文件和 meta data 的集合 root filesystem

如一个 centos 的镜像,里面包含一些最精简版的 centos 文件系统,还有其他软件包等。

镜像是分层的,每层都可以添加删除改变文件,变成一个新的 image

不同的 image 可以共享同一层 layer

镜像本身是只读的

什么是容器

容器通过 image 创建

在 image layer 之上创建了一个 container layer,这个容器层可以读写操作

image 负责 app 的存储和分发,container 负责运行 app

容器的 created 状态

docker create 只创建容器,但是不启动,docker run 是启动并且创建容器

docker start 可以启动容器

ADD 和 COPY

ADD 功能更强大,会拷贝文件并且解压,COPY 只会拷贝文件

服务端

一般在宿主机后台运行, dockerd 作为服务端接受来自客户的请求,通过 containerd 具体处理与容器相关的请求。包括创建,运行,删除容器等。

dockerd 为客户端提供 rest api,响应来自客户端的请求,采用模块化的解构,通过专门的 Engine 模块来分发管理各个来自客户端的任务。

docker-proxy 是 dockerd 的子进程,当需要进行容器端口映射时,docker-proxy 完成网络映射配置。

containerd 是 docker 的子进程,提供 gRPC 接口响应来自 dockerd 的请求,对下管理 runC 镜像和容器环境

containerd——shim, containerd 的子进程,作为容器内进程的根进程。

runC 是从 Docker 公司开源的 libcontainer 项目演化而来,。runC 支持 linux 系统中容器相关技术栈。

dockerd 默认监听本地 unix:///var/run/docker.sock 套接字,只允许本地 root 用户或者 docker 用户组成员能访问。

可以通过 -H 选项修改监听方式。

Docker 还支持通过 TLS 认证方式来验证访问。

docker-proxy 只有当启动容器并且开启端口映射才会执行,负责配置容器的端口映射规则。

客户端

默认通过本地的 unix:///var/run/docker.sock 套接字向服务端发出命令。如果服务端没有监听默认地址,需要手动指定。

命名空间

namespace 是 linux 内核一个强大特性,利用这一特性,每个容器都可以拥有自己的单独命名空间。运行在其中的应用都像在独立的操作系统环境中一样。命名空间机制保证了容器之间彼此不受影响。

进程命名空间

Linux 通过进程命名空间管理进程号。对于同一个进程在不同的命名空间中,看到的进程号不同。每个进程命名空间有一套自己的进程号管理方法。进程命名空间是一个父子关系的结构,子空间的进程对于父空间是可见的。新 fork 出来的一个进程,在父命名空间和子命名空间将分别对应不同的进程号。

一般运行一个容器,以 docker run —name test -d ubuntu:18.04 sleep 9999 为例,相关进程关系如下:

docker 服务主进程 dockerd 作为父进程启动了 docker-containerd 进程,docker-containerd 作为父进程启动了 docker-containerd-shim,其作为容器内所有进程的根进程。

IPC 命名空间

容器内进程交互还是采用了 Linux 常见的进程间交互方法,包括信号量,消息队列和共享内存等方式.PID 命名空间和 IPC 命名空间可以组合起来使用,同一个 IPC 命名空间的进程可以交互,不同空间的进程则无法交互。

网络命名空间

有了进程命名空间,不同命名空间中的进程号可以相互隔离,但是网络端口还是共享本地系统的端口。通过网络命名空间,可以实现网络隔离。一个网络命名空间为进程提供了一个完全独立的网络协议栈的视图。包括网络设备接口,IPV4 和 IPV6 协议栈,IP 路由表,防火墙规则,sockets 等。

docker 采用虚拟网络设备 Virtual Network Device VND 的方式,将不同命名空间的网络设备连接到一起。默认情况下,Docker 在宿主机上创建多个虚拟网桥,如默认的 docker0 网桥,容器中的虚拟网卡通过网桥进行桥接。

docker network ls 可以查看当前系统中的网桥。

docker网桥

使用 brctl 工具,可以看到连接到网桥上的虚拟网口信息。每个容器默认分配一个网桥上的虚拟网口,并将 docker0 的 IP 地址设置为默认的网关。容器发起的网络流量通过 宿主机的 iptables 规则进行转发。

虚拟网卡 veth

这里参考了这篇文章 Linux 网络命名空间,下面简述一下其中的内容。

虚拟网卡成对出现,像一个管道的两端,从这个管道一端的 veth 进去的数会从另一端的 veth 出来,可以用 veth 接口把一个网络命名空间连接到外部的默认命名空间或者 global 命名空间,而物理网卡就存在这些命名空间里。

创建一个网络命名空间,默认包含 lo 回环网络。然后创建一对虚拟网卡,将其中一端 veth1 加入命名空间中,设置该命名空间中的路由,使得找不到目的地址的数据包都通过 veth1 转发。这时就可以 ping 通 host 上的 veth0 网卡了。但是依然不能连接外部网络。

Linux 中 IP 转发的意思是 Linux 主机存在多个网卡的时候,允许一个网卡的数据包转发到另外一张网卡。

1
iptables -t nat -A POSTROUTING -s 10.1.1.0/255.255.255.0 -o ens160 -j MASQUERADE

添加了一条规则到 NAT 表的 POSTROUTING 链中,对于源 IP 地址为 10.1.1.0 网段的数据包,用 ens160 网口的 IP 地址替换并发送。这样在容器中就可以访问外部网络了。

整个流程

两个容器为什么能通信

只要有容器运行,docker0 接口的状态就从 down 变成了 up。

主机上的 veth 只能和 docker0 通信。容器内的 veth 可以和主机上的配对的 veth 通信。

veth 成对出现,docker0 是多个容器之间能通信的关键。

docker容器间通信

1
docker run -d --name=test2 --link test1 busybox /bin/sh -c "while true; do sleep 3600; done"

当于添加了 DNS 解析,同时 link 是单向的。只能在 test2 通过 test1 ping 通 test1。

none 和 host 网络

连接到 none 网络

1
docker run -d test1 --network none busybox /bin/sh

使用 none 模式,没有物理地址和 ip 地址,容器内只有 lo 回环网络,意味着容器不能被其他容器访问。

连接到 host 网络

1
docker run -d test1 --network host busybox /bin/sh

容器和外层 linux 共享一套网络接口

数据持久化 data volume

在 docker 中,默认数据存储在 container layer,也就是容器层中,因为只有这一层是可读可写的。但这样如果容器关闭了,并且被删除了,数据也就被删除了。docker 为了解决这种问题,出现一种机制。就是把数据转移到外挂或者 mount 的磁盘上。例如把容器里数据,同步到外层 linux 主机磁盘上。这样即使容器被删除了,容器里面数据还是保留在 linux 主机上,下次我们再启动一个容器,配置读取外层 linux 这个外挂的文件系统中数据,这个容器恢复正常服务功能。

VOLUME 的类型

有两种,第一种是受管理的 data Volume,由 docker 后台自动创建。第二种是绑定挂载的 Volume,具体挂载位置可以由用户指定。

安全防护与配置

Docker 是基于 Linux 操作系统实现的应用虚拟化。运行在容器内的进程,与运行在本地系统的进程在本质上没有区别。Docker 容器的安全性,很大程度上依赖于 Linux 系统自身。在评估 Linux 安全性上,主要考虑一下几个方面

  • Linux 内核的命名空间机制提供的容器隔离安全
  • Linux 控制组机制对容器资源的控制能力安全
  • Linux 内核的能力机制所带来的操作权限安全
  • Docker 程序本身的抗攻击性
  • 其他安全增强机制对容器安全性的影响

从网络架构上来看,所有的容器实际上是通过本地主机的网络接口 docker0 来进行相互通信,就像物理机器通过物理交换机通信一样。

与虚拟机方式相比,通过命名空间来实现的隔离并不那个绝对,运行在容器中的应用可以直接访问系统内核和部分系统文件。因此用户必须保证容器中的应用是安全可信的。

控制组是 Linux 容器机制的另一个关键组件,它负责实现资源的审计和限制。当用户执行 docker run 启动一个 容器的时候,docker 将通过 Linux 相关的调用,在后台为容器创建一个独立的控制组策略集合,该集合将限制容器内应用对资源的消耗。

控制组提供了很多有用的特性,他可以确保各个容器公平地分享主机的内存,cpu,磁盘 IO 等资源,当然,更重要的是,通过控制组可以限制容器对资源的占用,确保了当某个容器对资源消耗过大时,不会影响到本地主机系统和其他容器。

Linux 内核自 2.2 版本起支持能力机制,将权限划分为更加细粒度的操作能力,默认情况下,Docker 启动的容器有严格限制,只允许使用内核的一部分能力。通常,在服务器上会运行一堆特权进程,包括 ssh,cron,syslogd 等,容器与这些进程是不同的,而容器大部分情况下不需要真正的 root 权限,为了加强安全,容器可以禁用一些没必要的权限,包括:

  • 完全禁止任何文件挂载操作
  • 禁止直接访问本地主机的套接字
  • 禁止访问一些文件系统的操作
  • 禁止模块加载

高级网络功能

容器内 dns

docker 服务启动后悔默认启用一个内嵌的 DNS 服务,来自动解析同一个网络中的容器主机名和地址,如果无法解析,则通过容器内的 DNS 相关配置来进行解析。

容器运行时,可以再运行中的容器直接编辑 /etc/hosts /etc/hostname /etc/resolve.conf 文件,但这种改动连 docker commit 也无法保存。

容器访问外部的实现

假设容器内部的网络地址为 172.17.0.2,本地网络地址为 10.0.2.2。容器要能访问外部网络,需要进行源地址映射(Source NAT),修改为本地系统的 IP 地址 10.0.2.2。

映射是通过 iptables 的源地址伪装操作实现的。 主机 nat 表上的 POSTROUTING 链规则负责网包离开主机前,改写其源地址:

外部访问容器实现

容器允许外部访问,可以再 docker run 时候通过 -p 或者 -P 参数来启用。其实也是在本地的 iptable 的 nat 表 中添加相应的规则,将访问外部 IP 地址的包进行目标地址 DNAT,将目标地址修改为容器的 IP 地址。

基本概念

数据链路层属于计算机网络的底层,使用的信道主要有以下两种类型:

  • 点对点信道。这种信道使用一对一的点对点通信方式。
  • 广播信道。这种信道使用一对多的广播通信方式,因此过程比较复杂。广播信道上连接的主机很多,因此必须使用专用的共享信道协议来协调这些主机的数据发送。
    本章主要内容是
  • 数据链路层点对点和广播信道的特点,以及这两种信道使用的协议(PPP,CSMA/CD)的特点
  • 数据链路层的三个基本问题:封装成帧,透明传输和差错检测
  • 以太网 mac 层的硬件地址
  • 适配器,转发器,集线器,王巧,以太网交换机的作用和使用场合

点对点信道的数据链路层

  • 链路(link)是一条无源的点到点的物理线路段,中间没有任何其他的交换结点。一条链路只是一条通路的一个组成部分。
  • 数据链路(data link) 除了物理线路外,还必须有通信协议来控制这些数据的传输。若把实现这些协议的硬件和软件加到链路上,就构成了数据链路。现在最常用的方法是使用适配器(即网卡)来实现这些协议的硬件和软件。
    传送帧
  • 点对点通信在数据链路层的主要步骤
    结点 A 的数据链路层把网络层交下来的 IP 数据报添加首部和尾部封装成帧 结点 A 把封装好的帧发给结点 B 的数据链路层 * 若结点 B 的数据链路层收到的帧无差错,则从收到的帧中提取出 IP 数据报交给上面的网络层,否则丢弃该帧
    数据链路层不考虑物理层之间如何实现比特传输的细节

封装成帧

  • 封装成帧(framing)就是在一段数据的前后分别添加首部和尾部,然后就构成了一个帧。确定帧的界限。
  • 首部和尾部的一个重要作用就是进行帧定界。
  • MTU Maximum Transfer Unit 最大传输单元
  • 当数据是可打印的 ASCII 码组成的文本文件时,可以用特殊的帧定界符。控制字符 SOH(start of header)和 EOT (end of transmission),十六进制编码分别为 01 和 04

透明传输

  • 由于帧开始和结束使用了专门制定的控制字符,因此,所传输的数据中任何 8 比特的组合一定不允许和帧定界的控制字符的比特编码一样当传输的帧是用文本文件组成的帧时,这种方式没有问题,不用管传送的是什么字符,这种传输就是透明传输。但是当传送的是非 ASCII 的文本文件时,数据中可能会出现恰好和 SOH 和 EOT 一样的二进制数据。
  • 如何解决
    • 字节填充法
      发送端的数据链路层在数据中出现控制字符“SOH”或“EOT”的前面插入一个转义字符“ESC”(其十六进制编码是 1B)。字节填充(byte stuffing)或字符填充(character stuffing)——接收端的数据链路层在将数据送往网络层之前删除插入的转义字符。如果转义字符也出现数据当中,那么应在转义字符前面插入一个转义字符。当接收端收到连续的两个转义字符时,就删除其中前面的一个。
    • 字符计数法
      这种方法是在帧头部中使用一个字符计数字段来标明帧内字符数。例如,发送序列“5 A B C D E 4 U V W X 7 1 2 3 4 4 5 8”表示一共有三个帧,三个帧的长度分别为 5 字节、4 字节和 7 字节。但是这种方法很容易出现定界错误。假如计数值出现传输差错,接收端收到的序列为“5 A B C D E 6 U V W X 7 1 2 3 4 4 5 8”时,则接收端会将第二帧解释为“6 U V W X 7 1”,从而导致因发收双方对帧大小和内容理解不一致而出错。
    • 带字符填充的首尾界符法
      这种方法是在每一帧的开头加上 ASCII 字符“DLESTX” ,在帧末尾加上 ASCII 字符“DLE ETX” 。例如,假设待发送的数据是 ADLECB ,则在数据链路层封装的帧为:DLE STX ADLECB DLE ETX 如果发送方在数据帧中遇到帧头或者帧尾字符,就采用字符填充法来处理。例如,数据帧有 DLE 字符,就在其前面加一个 DLE。
      DLE STX A DLE DLE CB DLE ETX
    • 带位填充的首尾标志法(零比特填充法)
      这种方法是用一个特殊的位模式“01111110”作为帧边界。数据中可能包含“01111110”数据,如何判断?采用零比特填充法使一帧中两个边界字段之间的数据不会出现 6 个连续 1。在发送端,当一串比特流数据中有 5 个连续 1 时,就立即填入一个 0。如此保证数据部分不会出现 6 个连续的 1 在接收帧时,先找到边界字段以确定帧的边界。接着再对比特流进行扫描。每当发现 5 个连续 1 时,就将其后的一个 0 删除,以还原成原来的比特流。
    • 物理层编码违例法
      物理层编码违例法就是利用物理层信息编码中未用的电信号来作为帧的边界。例如,用曼彻斯特编码,在传输之前,将数据位 1 编码成高-低电平对,数据位 0 编码成低-高电平对。那么高-高电平、低-低电平就可以用作帧的边界。

差错检测

  • 误码率 传输错误比特占传输总比特数的比率称为误码率
循环冗余检测 CRC
  • 在数据链路层传送的帧中,广泛使用了循环冗余检验 CRC 的检错技术。 在发送端,先把数据划分为组。假定每组 k 个比特。假设待传送的一组数据 M = 101001(现在 k = 6)。我们在 M 的后面再添加供差错检测用的 n 位冗余码一起发送。
  • 用二进制的模 2 运算进行 2n 乘 M 的运算,这相当于在 M 后面添加 n 个 0。
  • 得到的 (k + n) 位的数除以事先选定好的长度为 (n + 1) 位的除数 P,得出商是 Q 而余数是 R,余数 R 比除数 P 少 1 位,即 R 是 n 位。
  • 模 2 运算是不进位也不借位的运算,在这里等同于异或运算,即有当前位就可以得到该位运算的结果
  • 在数据后面添加上的冗余码称为帧检验序列 FCS (Frame Check Sequence)。循环冗余检验 CRC 和帧检验序列 FCS 并不等同。CRC 是一种常用的检错方法,而 FCS 是添加在数据后面的冗余码。FCS 可以用 CRC 这种方法得出,但 CRC 并非用来获得 FCS 的唯一方法。
  • 若得出的余数 R = 0,则判定这个帧没有差错,就接受(accept)。 若余数 R != 0,则判定这个帧有差错,就丢弃。但这种检测方法并不能确定究竟是哪一个或哪几个比特出现了差错。只要经过严格的挑选,并使用位数足够多的除数 P,那么出现检测不到的差错的概率就很小很小。
汉明码

汉明码原理
汉明码(Hamming Code)编码的关键是使用多余的奇偶校验位来识别一位错误。假设信息码共有 n 位,海明码共有 h 位,那么总共的码长为 n + h 位。为能检测出 n + h 位编码,中其中一位的错误,海明码必须能够表示至少 n + h + 1 种状态,其中 n + h 种表示 n + h 位编码中有一位错误,另外还需要 1 种来表示整个编码正确无误。则海明码的长度需要满足下列关系:

把所有 2 的幂次方的数据位标记为奇偶校验位(编号为 1, 2, 4, 8, 16, 32, 64 等的位置)
其他数据位用于待编码数据. (编号为 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 17 等的位置)
每个奇偶校验位的值代表了码字中部分数据位的奇偶性,其所在位置决定了要校验的比特位。
位置 1,校验所有二进制最后一位是 1 的位置
位置 2,校验所有二进制倒数第二位是 1 的位置
位置 4,校验所有二进制倒数第三位是 1 的位置
配偶是该位置的数值加上是其要效验的位置和为偶数,可以用异或运算计算。
配奇则正好相反。
如何得到是哪一位出错了呢?以配偶方式为例,求冗余码的效验和,假设 分别为 1 1 0 0 。正常效验和应当为 0,为 1 说明该位置效验的比特位有一位出现了错误。因此倒数第一位和倒数第二位为 1 的都为 1 的位置出现了错误,这可以唯一确定一个位置,即数据的第三位。

可靠传输

在不可靠的信道上实现可靠的数据传输为上层提供一条可靠的逻辑通道。CRC 循环冗余检测只能保证无比特差错,但是可能会出现帧丢失,帧失序等,无法做到无传输差错。

停止等待协议
  • 在发送完一个帧后,必须暂时保留已发送的帧的副本。数据帧和确认帧都必须进行编号。只要超过了一段时间还没有收到确认,就认为已发送的帧出错或丢失了,因而重传已发送过的帧。这就叫做超时重传。
    超时计时器的重传时间应当比数据在分组传输的平均往返时间更长一些。
  • 使用上述的确认和重传机制,我们就可以在不可靠的传输网络上实现可靠的通信。这种可靠传输协议常称为自动重传请求 ARQ (Automatic Repeat reQuest)。ARQ 表明重传的请求是自动进行的。接收方不需要请求发送方重传某个出错的分组 。
  • RTT (Round Trip)
  • 停止等待协议不适合发送时延远远小于往返时延的情况
  • 停止等待协议的优点是简单,但缺点是信道利用率低。发送方可连续发送多个分组,不必每发完一个分组就停顿下来等待对方的确认。由于信道上一直有数据不间断地传送,这种传输方式可获得很高的信道利用率,
    这种方式是流水线传输
回退 N 帧
  • 如果发送方发送了前 5 个分组,而中间的第 3 个分组丢失了。这时接收方只能对前两个分组发出确认。发送方无法知道后面三个分组的下落,而只好把后面的三个分组都再重传一次。这就叫做 Go-back-N(回退 N),表示需要再退回来重传已发送过的 N 个分组。
  • GBN 协议的接受窗口大小为 1,当收到序号错误的分组,接收方除了丢弃他们,还对最近按序接受的分组进行确认
  • 接收方采用累积确认的方式,对分组 n 的确认,表明接收方已经正确收到分组 n 和之前所有的分组
  • GBN 协议存在一个缺点:一个分组的差错可能引起大量分组的重传,这些分组可能已经被接收方正确接收了,但由于未按序到达而被丢弃。可设法只重传出现差错的分组。但必须加大接收窗口,以便先收下
    失序到达但仍然处在接收窗口中的哪些分组,等到所缺分组收齐后再一并送交上层。这就是选择重传 SR(Selective Repeat)协议。
数据链路层的可靠传输
  • 实现可靠传输需要付出代价(例如会降低传输效率)。因此,应当根据链路的具体情况来决定是否需要让链路层向上提供可靠传输服务。当链路误码率非常低时,在数据链路层可不实现可靠传输,而是由上层协议(例如,
    运输层的 TCP 协议)来完成。但是在使用无线信道传输数据时,由于信道质量较差,在数据链路层仍需要实现可靠传输(例如使用停止等待协议)。

点对点协议 PPP

  • 现在全世界使用得最多的点对点数据链路层协议是点对点协议 PPP (Point-to-Point Protocol)。用户使用拨号电话线接入因特网时,一般都是使用 PPP 协议。
  • PPP 协议的特点
    • 简单——这是首要的要求
    • 封装成帧
    • 透明性
    • 多种网络层协议
    • 多种类型链路
    • 差错检测
    • 检测连接状态
  • PPP 协议的组成
    • 一个将 IP 数据报封装到串行链路的方法。
    • 链路控制协议 LCP (Link Control Protocol)。
    • 网络控制协议 NCP (Network Control Protocol)。
  • PPP 协议的帧格式
    标志字段 F = 0x7E (符号“0x”表示后面的字符是用十六进制表示。十六进制的 7E 的二进制表示是 01111110)。地址字段 A 只置为 0xFF。地址字段实际上并不起作用。控制字段 C 通常置为 0x03。PPP 是面向字节的,所有的 PPP 帧的长度都是整数字节。
  • PPP 协议字段
    当协议字段为 0x0021 时,PPP 帧的信息字段就是 IP 数据报。若为 0xC021, 则信息字段是 PPP 链路控制数据。若为 0x8021,则表示这是网络控制数据。
  • 透明传输问题
    当 PPP 用在同步传输链路时,协议规定采用硬件来完成比特填充(和 HDLC 的做法一样)。当 PPP 用在异步传输时,就使用一种特殊的字符填充法。
  • PPP 的工作状态
    当用户拨号接入 ISP 时,路由器的调制解调器对拨号做出确认,并建立一条物理连接。PC 机向路由器发送一系列的 LCP 分组(封装成多个 PPP 帧)。这些分组及其响应选择一些 PPP 参数,和进行网络层配置,NCP 给新接入的 PC 机分配一个临时的 IP 地址,使 PC 机成为因特网上的一个主机。通信完毕时,NCP 释放网络层连接,收回原来分配出去的 IP 地址。接着,LCP 释放数据链路层连接。最后释放的是物理层的连接。

使用广播信道的数据链路层

  • 广播信道可以进行一对多的通信,能很方便且廉价地连接多个邻近的计算机,因此曾经被广泛应用于局域网之中。由于用广播信道连接的计算机共享同一传输媒体,因此使用广播信道的局域网被称为共享式局域网。
    虽然交换式局域网在有线领域已完全取代了共享式局域网,但无线局域网仍然使用的是共享媒体技术。

媒体接入控制

媒体访问/接入控制(MAC) Medium Access Control 多点接入、多址访问(Multiple Access)

  • 静态划分信道
    频分多址,码分多址和时分多址,这种固定划分信道的方法很不灵活,对于突发性信道的传输利用率很低,通常在无线网络的物理层使用,而不是在数据链路层中使用。
  • 动态接入控制
    各站点动态占用信道发送数据,而不是使用预先固定分配好的信道
    • 随机接入
      通过所有站点之间的竞争,随机地在信道上发送数据,如果恰巧有两个或者更多的站点在同意时刻发送数据,那么信号在共享媒体上就要发生碰撞,使得这些站点的发送都失败,因此这类协议要解决的关键问题是如何避免冲突以及在发生冲突后如何尽快恢复通信。共享式以太网采用的就是随机接入。
    • 受控接入
      不能随机的发送信息而必须服从一定的控制,典型代表有集中控制的多点轮询协议和分散的令牌传递协议。集中控制的多点轮训协议有一个主站以循环方式轮询每个站点有无数据发送,只有被轮询到的站点才能发送数据,分散控制的令牌传递协议中各站点是平等的,并连接成一个唤醒网络,令牌(一个特殊的控制帧)沿环逐站传递,接收到令牌的站点才有权发送数据。

局域网

  • 局域网的拓扑有星形网,环形网,总线网,树形网。总线网和树形网用总线两端的匹配电阻消除总线上传播的电磁波信号的能量
局域网体系结构
  • IEEE 802 委员会将局域网的数据链路层拆成了两个子层,即逻辑链路控制 (Logical Link Control) 和 媒体接入控制 (Medium Access Control MAC) 子层。与接入到传输媒体有关的内容都放在 MAC 子层,而 LLC 子层与传输媒体无关,不管采用何种传输媒体和 MAC 子层的局域网,对 LLC 子层来说都是透明的。然而由于以太网在局域网中已经取得了垄断地位,LLC(802.2 标准)已经不再重要。
网络适配器
  • 网络接口板又称为通信适配器(adapter)或网络接口卡 NIC (NetworkInterface Card),或“网卡”。
  • 适配器的重要功能:
    • 进行串行/并行转换。
    • 对数据进行缓存。
    • 在计算机的操作系统安装设备驱动程序。
    • 实现以太网协议。
MAC 地址
  • 在局域网中,硬件地址又称为物理地址,或 MAC 地址。802 标准所说的“地址”严格地讲应当是每一个站的“名字”或标识符。但鉴于大家都早已习惯了将这种 48 位的“名字”称为“地址”,所以本书也采用这种习惯用法,尽管这种说法并不太严格。
  • IEEE 的注册管理机构 RA 负责向厂家分配地址字段的前三个字节(即高位 24 位)。地址字段中的后三个字节(即低位 24 位)由厂家自行指派,称为扩展标识符,必须保证生产出的适配器没有重复地址。一个地址块可以生成$2^24$个不同的地址。这种 48 位地址称为 MAC-48,它的通用名称是 EUI-48。“MAC 地址”实际上就是适配器地址或适配器标识符 EUI-48。
  • 适配器从网络上每收到一个 MAC 帧就首先用硬件检查 MAC 帧中的 MAC 地址.
    如果是发往本站的帧则收下,然后再进行其他的处理。
    否则就将此帧丢弃,不再进行其他的处理。
  • “发往本站的帧”包括以下三种帧:
    单播(unicast)帧(一对一)
    广播(broadcast)帧(一对全体)
    多播(multicast)帧(一对多)

共享式以太网

  • 以太网的两个标准
    DIX Ethernet V2。
    IEEE 的 802.3 标准。
    DIX Ethernet V2 标准与 IEEE 的 802.3 标准只有很小的差别,因此可以将 802.3 局域网简称为“以太网”。严格说来,“以太网”应当是指符合 DIX Ethernet V2 标准的局域网

CSMA CD 协议

  • 总线上的每一个工作的计算机都能检测到 B 发送的数据信号。 由于只有计算机 D 的地址与数据帧首部写入的地址一致,因此只有 D 才接收这个数据帧。其他所有的计算机(A, C 和 E)都检测到不是发送给它们的数据帧,因此就丢弃这个数据帧而不能够收下来。具有广播特性的总线上实现了一对一的通信。
  • 采用较为灵活的无连接的工作方式,即不必先建立连接就可以直接发送数据。以太网对发送的数据帧不进行编号,也不要求对方发回确认。 这样做的理由是局域网信道的质量很好,因信道质量产生差错的概率是很
    小的。
  • 以太网提供的服务是不可靠的交付,即尽最大努力的交付。当目的站收到有差错的数据帧时就丢弃此帧,其他什么也不做。差错的纠正由高层来决定。如果高层发现丢失了一些数据而进行重传,但以太网并不知道这是一个重传的帧,而是当作一个新的数据帧来发送。
  • 以太网发送数据都使用曼彻斯特编码
  • CSMA/CD 表示 Carrier Sense Multiple Access with Collision Detection。
    • “多点接入”表示许多计算机以多点接入的方式连接在一根总线上。“载波监听”是指每一个站在发送数据之前先要检测一下总线上是否有其他计算机在发送数据,如果有,则暂时不要发送数据,以免发生碰撞。
    • 总线上并没有什么“载波”。因此, “载波监听”就是用电子技术检测总线上有没有其他计算机发送的数据信号。
    • “碰撞检测”就是计算机边发送数据边检测信道上的信号电压大小。当几个站同时在总线上发送数据时,总线上的信号电压摆动值将会增大(互相叠加)。当一个站检测到的信号电压摆动值超过一定的门限值时,就认为总线上至少有两个站同时在发送数据,表明产生了碰撞。所谓“碰撞”就是发生了冲突。因此“碰撞检测”也称为“冲突检测”。在发生碰撞时,总线上传输的信号产生了严重的失真,无法从中恢复出有用的信息来。每一个正在发送数据的站,一旦发现总线上出现了碰撞,就要立即停止发送,免得继续浪费网络资源,然后等待一段随机时间后再次发送。
    • 关于如何检测信道空闲,站点会持续监听到达该站的信号,如果检测到信号包含数据,则说明在这一段时间内信道不是空闲的
    • 当某个站监听到总线是空闲时,也可能总线并非真正是空闲的。A 向 B 发出的信息,要经过一定的时间后才能传送到 B。B 若在 A 发送的信息到达 B 之前发送自己的帧(因为这时 B 的载波监听检测不到 A 所发送的信息),则必然要在某个时间和 A 发送的帧发生碰撞。碰撞的结果是两个帧都变得无用。
    • 最先发送数据帧的站,在发送数据帧后至多经过时间 2r (两倍的端到端往返时延)就可知道发送的数据帧是否遭受了碰撞。以太网的端到端往返时延 2r 称为争用期,或碰撞窗口。经过争用期这段时间还没有检测到碰撞,才能肯定这次发送不会发生碰撞。
  • 以太网取 51.2 us 为争用期的长度。对于 10 Mb/s 以太网,在争用期内可发送 512 bit,即 64 字节。以太网在发送数据时,若前 64 字节没有发生冲突,则后续的数据就不会发生冲突。
  • 为保证发送方能检测到所有碰撞,以太网规定了最短有效帧长为 64 字节。如果发生冲突,就一定是在发送的前 64 字节之内,立即中止发送,这时已经发送出去的数据一定小于 64 字节。因此将长度小于 64 字节的帧都视为是由于冲突而异常中止的无效帧。
  • 当发送数据的站一旦发现发生了碰撞时: 立即停止发送数据;再继续发送若干比特的人为干扰信号(jamming signal),以便让所有站点都知道现在已经发生了碰撞。
  • 帧间最小间隔 96 比特时间,站点在发送数据帧之前要等待信道空闲 96 比特时间,这样用于接收方检测一个帧的结束,同时也使得其他所有站点都能有机会争用信道并发送数据
  • CSMA/CD 的要点如下
    • 适配器从网络层获得一个分组,加上以太网的首部和尾部,组成以太网帧,放入适配器的缓存中,准备发送
    • 若适配器检测到信道空闲 96 比特时间,就发送这个帧。若检测到信道忙,则继续检测并等待信道转换为空闲 96 比特时间,然后发送这个帧
    • 在发送过程中继续检测,若一直未检测到碰撞,就顺利把这个帧成功发送完毕。若检测到碰撞,则终止数据的发送,并认为发送干扰信号
    • 在终止发送后,适配器就执行指数退避算法,随机等待 r 倍的 512 比特时间后,返回到步骤 2
二进制指数退避算法

go 语言的实现

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"fmt"
"math/rand"
"time"
)

func getRandomRate() int {
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
return random.Intn(100)
}

func resend(k int) {
index := k
if k > 10 {
index = 10
}
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
upperBound := 1 << uint(index)
r := random.Intn(upperBound)
fmt.Printf("第%d次重传 本次退避 51.2 * %d us\n", k, r)
}

func tbeb() {

var failRate int
k := 0
fmt.Println("请输入碰撞几率 0-100")
fmt.Scanf("%d", &failRate)

for {
if getRandomRate() < failRate {
k++
if k > 16 {
fmt.Println("重传已经达到16次,丢弃该帧,向上层报告")
break
}
fmt.Println("发生碰撞,准备重传")
resend(k)
} else {
fmt.Printf("传送成功,共传输%d次\n", k)
break
}
}
}

func main() {
tbeb()
}

对于第k次重传,选择 $[0,2^k-1]$中的一个随机数作为倍数,将 2r 乘以该倍数作为退避时间

信道利用率

其中 2r 是争用期长,即端到端传播时延的两倍,帧长为 L,数据发送速率为 C,$t_0=\frac{L}{C}$是帧发送时间,当 a 趋近于 0 时,表示一发生碰撞就可以检测出来,a 越大,信道利用率越低。

  • 当网络覆盖范围越大,既端到端时延越大,信道极限利用率越低,即网络性能越差。另外,端到端时延越大或连接的站点越多,都会导致发生冲突的概率变大,网络性能还会进一步降低。可见,共享式以太网只能作为一种局域网技术。

使用集线器的星形拓扑

集线器
  • 集线器是使用电子器件来模拟实际电缆线的工作,因此整个系统仍然像一个传统的以太网那样运行。
  • 使用集线器的以太网在逻辑上仍是一个总线网,各工作站使用的还是 CSMA/CD 协议,并共享逻辑上的总线。
  • 集线器很像一个多接口的转发器,工作在物理层。

以太网的帧格式

  • 常用的以太网 MAC 帧格式有两种标准 :
    • DIX Ethernet V2 标准
    • IEEE 的 802.3 标准
  • 最常用的 MAC 帧是以太网 V2 的格式。
    以太网的帧格式
    源地址和目的地址的地址指的是 MAC 地址 类型字段用来表示上层协议的类型
    FCS 是帧检验学列 如果数据字段的长度小于 46 字节,MAC 子层就会在数据字段的最后加入一个整数字节的填充字段
  • 因为以太网在传输帧的时候,各个帧之间必须有一定的间隙,因此不需要使用帧结束定界符
  • 无效的 MAC 帧
    • 数据字段的长度与长度字段的值不一致;
    • 帧的长度不是整数个字节;
    • 用收到的帧检验序列 FCS 查出有差错;
    • 数据字段的长度不在 46 ~ 1500 字节之间。
    • 有效的 MAC 帧长度为 64 ~ 1518 字节之间。
    • 对于检查出的无效 MAC 帧就简单地丢弃。以太网不负责重传丢弃的帧。

网桥和以太网交换机

在物理层扩展以太网

  • 使原来属于不同碰撞域的局域网上的计算机能够进行跨碰撞域的通信。
  • 扩大了局域网覆盖的地理范围。
  • 碰撞域增大了,但总的吞吐量并未提高。
  • 如果不同的碰撞域使用不同的数据率,那么就不能用集线器将它们互连起
    来。
  • 由于争用期的限制,并不能无限扩大地理覆盖范围

在数据链路层扩展以太网

  • 在数据链路层扩展以太网要使用网桥。
  • 网桥工作在数据链路层,它根据 MAC 帧的目的地址对收到的帧进行转发。
  • 网桥具有过滤帧的功能。当网桥收到一个帧时,并不是向所有的接口转发此帧,而是先检查此帧的目的 MAC 地址,然后再确定将该帧转发到哪一个接口
使用网桥的好处
  • 过滤通信量。
  • 扩大了物理范围。
  • 提高了可靠性。
  • (由于采用存储转发方式)可互连不同物理层、不同 MAC 子层和不同速率(如 10 Mb/s 和
    100 Mb/s 以太网)的局域网。
使用网桥的缺点
  • 存储转发增加了时延。
  • 在 MAC 子层并没有流量控制功能。
  • 具有不同 MAC 子层的网段桥接在一起时时延更大。
  • 网桥只适合于用户数不太多(不超过几百个)和通信量不太大的局域网,
  • 否则有时还会因传播过多的广播信息而产生网络拥塞。这就是所谓的
  • 广播风暴。
网桥和集线器(或转发器)不同
  • 集线器在转发帧时,不对传输媒体进行检测。
  • 网桥在转发帧之前必须执行 CSMA/CD 算法。若在发送过程中出现碰撞,就必须停止发送和进行退避。
透明网桥
  • 目前使用得最多的网桥是透明网桥(transparent bridge)。“透明”是指局域网上的站点并不知道所发送的帧将经过哪几个网桥,因为网
    桥对各站来说是看不见的。
  • 透明网桥是一种即插即用设备,其标准是 IEEE 802.1D。
自学习算法

go 语言实现

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import (
"errors"
"fmt"
)

type Record struct {
port int
address string
}
type Iptable struct {
records []Record
}

var iptable Iptable

func findTable(address string) (int, error) {
for _, record := range iptable.records {
if record.address == address {
return record.port, nil
}
}
err := errors.New("没找到记录")
return 0, err
}

func storeRecord(address string, port int) {
iptable.records = append(iptable.records, Record{port, address})
}

func printTable() {
fmt.Printf("**********iptable**********\n")
fmt.Printf("id address port\n")
for index, record := range iptable.records {
fmt.Printf("%-3d %-20s %-5d\n", index, record.address, record.port)
}
fmt.Printf("**********iptable**********\n")
}

func main() {
var srcAddress, destAddress string
var srcPort int
for {
fmt.Println("请输入源地址和端口号")
fmt.Scanf("%s %d", &srcAddress, &srcPort)
fmt.Println("请输入目的地址")
fmt.Scanf("%s", &destAddress)
destPort, err := findTable(destAddress)
if err != nil {
fmt.Println("转发表中未找到记录")
fmt.Println("从其他端口将此帧转发给别的网桥")
if _, err := findTable(srcAddress); err != nil {
storeRecord(srcAddress, srcPort)
}

} else {
fmt.Println("转发表中找到记录")
if srcPort == destPort {
fmt.Println("目的地址和源地址在同一网段,目的主机已经收到")
} else {
fmt.Printf("将此帧从查找到的端口发出 %d\n", destPort)
}
if _, err := findTable(srcAddress); err != nil {
storeRecord(srcAddress, srcPort)
}
}
printTable()
}
}
  • 网桥收到一帧后先进行自学习。查找转发表中与收到帧的源地址有无相匹配的项目。如没有,就在转发表中增加一个项目(源地址、进入的接口和时间)。如有,则把原有的项目进行更新。
  • 转发帧。查找转发表中与收到帧的目的地址有无相匹配的项目。
    • 如没有,则通过所有其他接口(但进入网桥的接口除外)按进行转发。
    • 如有,则按转发表中给出的接口进行转发。
    • 若转发表中给出的接口就是该帧进入网桥的接口,则应丢弃这个帧(因为这时不需要经过网桥进行转发)。
生成树协议
  • 互连在一起的网桥在进行彼此通信后,就能找出原来的网络拓扑的一个子集。在这个子集里,整个连通的网络中不存在回路,即在任何两个站之间只有一条路径。
  • 网桥会关闭不在生成树上的那些接口,以确保不存在环路。
  • 为了得出能够反映网络拓扑发生变化时的生成树,在生成树上的根网桥每隔一段时间还要对生成树的拓扑进行更新。
源路由网桥
  • 透明网桥容易安装,但网络资源的利用不充分。
  • 源路由(source route)网桥在发送帧时将详细的路由信息放在帧的首部中。源站以广播方式向欲通信的目的站发送一个发现帧,每个发现帧都记录所经过的路由。
  • 发现帧到达目的站时就沿各自的路由返回源站。源站在得知这些路由后,从所有可能的路由中选择出一个最佳路由。凡从该源站向该目的站发送的帧的首部,都必须携带源站所确定的这一路由信息。
以太网交换机
  • 1990 年问世的交换式集线器(switching hub),可明显地提高局域网的性能。
  • 交换式集线器常称为以太网交换机(switch)或第二层交换机(表明此交换机工作在数据链路层)。
  • 以太网交换机通常都有十几个接口。因此,以太网交换机实质上就是一个多接口的网桥,可见交换机工作在数据链路层。
以太网交换机的特点
  • 以太网交换机的每个接口都直接与主机相连,并且一般都工作在全双工方式。
  • 交换机能同时连通许多对的接口,使每一对相互通信的主机都能像独占通信媒体那样,进行无碰撞地传输数据。
  • 以太网交换机由于使用了专用的交换结构芯片,其交换速率较高。

无线局域网

CSMA/CA 协议

  • 无线局域网不能简单地搬用 CSMA/CD 协议。这里主要有两个原因。对于无线信道,接收信号强度往往会远远小于发送信号强度。如要在无线局域网的适配器上实现碰撞检测,对硬件的要求非常高。。
  • 即使我们能够实现碰撞检测的功能,并且当我们在发送数据时检测到信道是空闲的,在接收端仍然有可能发生碰撞(隐蔽站问题)。
  • 无线局域网不能使用 CSMA/CD,而只能使用改进的 CSMA 协议。
  • 改进的办法是把 CSMA 增加一个碰撞避免(Collision Avoidance)功能。
  • 802.11 就使用 CSMA/CA 协议。而在使用 CSMA/CA 的同时,还增加使用停止等待协议。
  • 下面先介绍 802.11 的 MAC 层。
  • 所有的站在完成发送后,必须再等待一段很短的时间(继续监听)才能发送下一帧。这段时间的通称是帧间间隔 IFS (InterFrame Space)。
  • 帧间间隔长度取决于该站欲发送的帧的类型。高优先级帧需要等待的时间较短,因此可优先获得发送权。
  • 若低优先级帧还没来得及发送而其他站的高优先级帧已发送到媒体,则媒体变为忙态因而低优先级帧就只能再推迟发送了。这样就减少了发生碰撞的机会。
三种帧间间隔
  • SIFS,即短(Short)帧间间隔,是最短的帧间间隔,用来分隔开属于一次对话的各帧。一个站应当能够在这段时间内从发送方式切换到接收方式。
  • 使用 SIFS 的帧类型有:ACK 帧、CTS 帧、由过长的 MAC 帧分片后的数据帧,以及所有回答 AP 探询的帧
    和在 PCF 方式中接入点 AP 发送出的任何帧。
  • PIFS,即点协调功能帧间间隔,它比 SIFS 长,是为了在开始使用 PCF 方式时(在 PCF 方式下使用,没有争用)优先获得接入到媒体中。PIFS 的长度是 SIFS 加一个时隙(slot)长度。
  • 时隙的长度是这样确定的:在一个基本服务集 BSS 内当某个站在一个时隙开始时接入到媒体时,那么在下一个时隙开始时,其他站就都能检测出信道已转变为忙态。
  • DIFS,即分布协调功能帧间间隔(最长的 IFS),在 DCF 方式中用来发送数据帧和管理帧。DIFS 的长度比 PIFS 再增加一个时隙长度。
  • 欲发送数据的站先检测信道。在 802.11 标准中规定了在物理层的空中接口进行物理层的载波监听。
  • 通过收到的相对信号强度是否超过一定的门限数值就可判定是否有其他的移动站在信道上发送数据。
  • 当源站发送它的第一个 MAC 帧时,若检测到信道空闲,则在等待一段时间 DIFS 后就可发送。
  • 为什么信道空闲还要再等待?这是考虑到可能有其他的站有高优先级的帧要发送。如有,就要让高优先级帧先发送。
高优先级帧发送
  • 源站发送了自己的数据帧。
  • 目的站若正确收到此帧,则经过时间间隔 SIFS 后,向源站发送确认帧 ACK。
  • 若源站在规定时间内没有收到确认帧 ACK(可能是发生碰撞),就必须重传此帧,直到收到确认为止,或者经过若干次的重传失败后放弃发送。
  • 确认机制可以认为是一种间接碰撞检测 。
退避算法
  • 为避免碰撞,如果要发送数据的站发现信道忙, 在信道恢复空闲时并不是立即发送数据,而是要退避一段随机的时间(大于 DIFS)若信道仍然空闲才能发送数据
  • 若发送方接收到确认要立即发送下一帧时,为公平竞争,也要执行退避
  • 当发送方没有接收到确认,重传帧时,要将随机选择退避时间的范围扩大一倍。
退避计时器
  • 站点每经历一个时隙的时间就检测一次信道。这可能发生两种情况。
  • 若检测到信道空闲,退避计时器就继续倒计时。
  • 若检测到信道忙,就冻结退避计时器的剩余时间,重新等待信道变为空闲并再经过时间 DIFS 后,从剩余时间开始继续倒计时。如果退避计时器的时间减小到零时,就开始发送整个数据帧。
退避算法的使用
  • 仅在下面的情况下才不使用退避算法:检测到信道是空闲的,并且这个数据帧是要发送的第一个数据帧。
    除此以外的所有情况,都必须使用退避算法。即:
    • 在发送第一个帧之前检测到信道处于忙态。
    • 在每一次的重传后。
    • 在每一次的成功发送后。
      802.11退避机制

这一题最开始想到模拟,也想到了匹配的贪婪问题,感觉题目可能不要求那么多,结果是 WA,看到讨论区 PO 出的一份代码,使用了动态规划,想了一下确实改用 DP,此题确有一定难度,在此记录一下解法。

题目描述

给你一个字符串 s 和字符串 p,s 只包含英文小写字母,p 只包含英文小写字母和 ‘.’ 或 ‘*‘,实现一个支持元字符 ‘.’ 和 ‘*‘的正则匹配

解题思路

设 dp[x][y]表示取字符串 s 前 x 个字符和模式串 p 前 y 个字符是否能够完全匹配,为一个 bool 值
初始状态 dp[0][0] = true,若 p 串以”a*b*c*“的形式开头,则 dp[0][2],dp[0][4],dp[0][6]也为 true,显示这种状态是匹配的
dp 的转移过程为
若 s[i-1] == p[j-1] || p[j-1] == ‘.’,dp[i][j] = dp[i][j] || dp[i-1][j-1]
若 p[j-1] == ‘*‘ ,方程至多可从一下三种状态转移

  • dp[i][j] = dp[i][j] || dp[i][j-2],*可以匹配 0 个多个,因此总可以从该状态转移
  • 若 s[i-1] == p[j-2] || int(p[j-2]) == ‘.’,即匹配 “?*“形式,可采用贪婪与非贪婪策略
    • dp[i][j] = dp[i][j] || dp[i-1][j] ,采用非贪婪策略,*不匹配字符
    • dp[i][j] = dp[i][j] || dp[i][j-1],采用贪婪策略,*匹配字符

以下是代码,参考了 leetcode 上的题解

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
func isMatch(s string, p string) bool {
lens := len(s)
lenp := len(p)
var dp [][]bool

for i := 0; i <= lens; i++ {
d := make([]bool, lenp+1)
dp = append(dp, d)
}
dp[0][0] = true
for j := 1; j <= lenp; j++ {
if p[j-1] == '*' {
dp[0][j] = dp[0][j-2]
}
}

for i := 1; i <= lens; i++ {
for j := 1; j <= lenp; j++ {
if s[i-1] == p[j-1] || p[j-1] == '.' {
dp[i][j] = dp[i][j] || dp[i-1][j-1]
} else if p[j-1] == '*' {
dp[i][j] = dp[i][j] || dp[i][j-2]
if s[i-1] == p[j-2] || int(p[j-2]) == '.' {
dp[i][j] = dp[i][j] || dp[i-1][j]
dp[i][j] = dp[i][j] || dp[i][j-1]
}
}
}
}
return dp[lens][lenp]
}

跟着官方文档学习

Getting Started

db 显示当前数据库

插入文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
db.inventory.insertMany([
{
item: "journal",
qty: 25,
status: "A",
size: { h: 14, w: 21, uom: "cm" },
tags: ["blank", "red"],
},
{
item: "notebook",
qty: 50,
status: "A",
size: { h: 8.5, w: 11, uom: "in" },
tags: ["red", "blank"],
},
]);

查询所有文档

1
db.inventory.find({});

美化查询结果

1
db.inventory.find({}).pretty();

相等匹配

1
db.inventory.find({ status: "D" });

对于数组,严格相等,数组项的顺序也要一致

1
2
// 不匹配 { tags: ["blank", "red"] }
db.inventory.find({ tags: ["red", "blank"] });

定义返回结构 Projection

1
db.inventory.find( { }, { item: 1, status: 1 } );

默认全是 0 或者全是 1,不能既有 0 又有 1

database and collections

MongoDB 在集合中存储 BSON 文档,即数据记录;数据库中的集合。

数据库

可以 使用 use 去切换到一个不存在的数据库

如果数据库不存在,MongoDB 将在您第一次为该数据库存储数据时创建数据库。

MongoDB 将文档存储在集合中。集合类似于关系数据库中的表。

如果一个集合不存在,MongoDB 会在您第一次存储该集合的数据时创建该集合。

ObjectId

ObjectId 是一个 12 字节的 BSON 类型字符串。按照字节顺序,依次代表:

  • 4 字节 UNIX 时间戳
  • 3 字节 运行 MongoDB 的机器
  • 2 字节 代表生成次 id 的进程
  • 3 字节 由一个随机数开始的的计数器生成的值

使用这种机制可以保证唯一性

1
2
db.myNewCollection2.insertOne({ x: 1 });
db.myNewCollection3.createIndex({ y: 1 });

如果 insertOne()和 createIndex()操作还不存在,那么它们将创建各自的集合。确保集合名称遵循 MongoDB 命名限制。

db.createCollection() 可以显式以指定的选项创建一个数据库

view

view 是一个可查询的对象,由其他集合或者视图聚合产生。

MongoDB 并不在硬盘中保存 view,而是在需要的时候进行计算。view 不支持写操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
db.createCollection(
"<viewName>",
{
"viewOn" : "<source>",
"pipeline" : [<pipeline>],
"collation" : { <collation> }
}
);
db.createView(
"<viewName>",
"<source>",
[<pipeline>],
{
"collation" : { <collation> }
}
)

view 支持以下方法

1
2
3
4
5
6
7
db.collection.find();
db.collection.findOne();
db.collection.aggregate();
db.collection.countDocuments();
db.collection.estimatedDocumentCount();
db.collection.count();
db.collection.distinct();

更新文档

1
2
3
4
5
6
7
8
db.collsetion.update(
<query>,
<updateObj>,
{
upsert:<boolean>,
multi:<boolean>
}
)

update 更新后的对象或指定一些更新的操作符

  • $set 直接指定更新后的值
  • $inc 在原基础上累加
1
2
db.students.update({ name: "zf" }, { $set: { age: 44 } });
db.students.update({ name: "zf" }, { $inc: { age: 1 } });
  • $unset 删除字段,如果字段不存在不做处理
  • $push 向数组添加数据
  • $ne
1
db.students.update({ hobby: { $ne: "smoking" } }, {});
  • $each
1
2
3
4
db.students.update(
{ name: "zf" },
{ $push: { hobby: { $each: ["smok", "dringing"] } } }
);
  • $addToSet 插入到集合,可以保证唯一性,如果项是重复的,将不会被插入
1
db.students.update({ name: "zfpx" }, { $addToSet: { hobby: "drink" } });

upsert 可选,不过不存在记录是否插入,默认不插入
muitl 可选,默认只更新找到的第一条记录,如果为 true,更新所有符合的记录

操作符

$eq:匹配字段值等于指定值的文档

$gt:匹配字段值大于指定值的文档

$gte:匹配字段值大于等于指定值的文档

$lt:匹配字段值小于指定值的文档

$lte:匹配字段值小于等于指定值的文档

$ne:匹配字段值不等于指定值的文档,包括没有这个字段的文档

$in:匹配字段值等于指定数组中的任何值

$nin:字段值不在指定数组或者不存在

$or:文档至少满足其中的一个表达式

$not:字段值不匹配表达式或者字段值不存在

$nor:字段值不匹配所有的表达式的文档,包括那些不包含这些字段的文档

$exists:boolean 等于 true 时,字段存在,包括字段值为 null 的文档

$type:匹配字段值为指定数据类型的文档

$mod:匹配字段值被除有指定的余数的文档

$regex:正则表达式可以匹配到的文档

$text:针对创建了全文索引的字段进行文本搜索

$where:可以通过 js 表达式或 js 函数来查询文档
$all: 字段值是包含所有指定元素的数组的文档
$elemMatch:数组字段至少一个元素满足所有指定查询条件的文档
$size:匹配数组字段元素个数等于指定数量的文档
$ (projection):限定查询结果中指定数组字段返回满足条件的第一个元素
$elemMatch (projection):限定查询结果中指定数组字段返回满足条件的第一个元素
$slice (projection):控制指定数组字段返回元素个数
$inc:给一个字段增加指定值
$setOnInsert :upsert 为 true 时,有插入文档操作时插入指定字段值
$unset:删除指定字段

$min:指定值小于当前值则更新为指定值
$max: 指定值大于当前值则更新为指定值
$currentDate:设置字段值为当前日期

$: 更新指定数组的第一个元素
$addToSet:数组字段增加一个值
$pop: 删除数组字段中的第一个或最后一个元素
$pullAll: 删除数组字段中所有指定值,如果指定值为数组,则删除匹配数组内的元素
$pull: 符合条件的值将被删除
$pushAll:向数组中追加多个指定值
$push:向数组中追加值
$each:用于 $addToSet 添加多个值到数组中

高级知识

通过配置项启动数据库

—dbpath 数据库文件的目录
—port 默认是 27017 28017
—fork 以后台守护方式运行
—logpath 日志目录
—config 指定一个配置文件
—auth 以安全参数启动

导入导出

mongoimport 导出数据
mongoexport 导入数据

mongodb 使用 mongorestore 来恢复备份的数据

Mongodump 可以 backup 整个数据库,而 mongoexport 要对每个 conllection 进行操作。恢复使用 mongostore

mongoexport 输出的 json 比 mongodump 的 bson 可读性高,进而可以直接对 json 文件进行操作然后还原数据(bson 转换 json 存在潜在兼容问题)

安全措施

物理隔离
网络隔离
防火墙
用户名和密码验证

用户管理

1
2
show roles 查看角色
db.createUser({user:'zf',pwd:'123',roles:[{db:'school'},role:'read']})

索引

ensure 表示升序

1
db.students.ensureIndex({ age: 1 });

分析索引的执行过程

mongodb 提供了 explain 命令来分析查询过程

注意事项

1 为正序 -1 为倒序
索引可以提升查询速度,但会降低插入速度
数据量不大时不需要使用索引,性能的提升不明显,反而大大增加了内存和硬盘的消耗
查询数据超过表数据量 30%时,不要使用索引字段查询
排序工作的时候可以建立索引提高排序速度
数字索引要比字符串索引快得多

集群技术

主从复制

数据库集群中明确知道谁是主服务器,主服务器只有一台
从服务器要知道自己的数据源也就是主服务器是谁
—master 用来确定主服务器,—slave 和—source 来控制从服务器

其他选项

-only 从节点 指定复制某个数据库
-slavedelay 从节点 设置主数据库同步数据的延迟
-fastsync 从节点 以主数据库的节点快照为节点启动从数据库
-autoresync 从节点 如果不同步则重新同步数据库
-oplogSize 主节点 设置 oplog 的大小

db.sources

提供了 api 设置从服务器的 source

副本集

需要一台活跃服务器和两个备份服务器
当活跃服务器故障,集群根据权重算法推选出活跃服务器
当原来的主服务器恢复后又会变成从服务器

1
2
3
dbpath=...
prot=...
replSet=group // 每个副本集需要有一个名字

mongoose

Schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var personSchema = new Schema({
name: String,
binary: Buffer,
living: Boolean,
birthday: Date,
age: Number,
_id: Schema.Types.ObjectId,
_fk: Schema.Types.ObjectId,
array: [],
arrOfString: [String],
arrOfNumber: [Number],
// 内嵌文档
nested: {
name: String,
},
});

SchemaType

以下是 mongoose 的所有合法 SchemaTypes:

  • String
  • Number
  • Date
  • Buffer
  • Boolean
  • Mixed
  • ObjectId
  • Array
  • Decimal128
    直接声明 schema type 为某一种 type,或者赋值一个含有 type 属性的对象。
全部可用
  • required: 布尔值或函数 如果值为真,为此属性添加 required 验证器
  • default: 任何值或函数 设置此路径默认值。如果是函数,函数返回值为默认值
  • select: 布尔值 指定 query 的默认 projections 为 false 默认不返回该字段
  • validate: 函数 adds a validator function for this property
  • get: 函数 使用 Object.defineProperty() 定义自定义 getter 这个 getter 是在访问该属性时生效的 consolelog 打印的还是初始值
  • set: 函数 使用 Object.defineProperty() 定义自定义 setter
  • alias: 字符串 仅 mongoose >= 4.10.0。 为该字段路径定义虚拟值 gets/sets
索引相关
  • index: 布尔值 是否对这个属性创建索引
  • unique: 布尔值 是否对这个属性创建唯一索引
  • sparse: 布尔值 是否对这个属性创建稀疏索引
String
  • lowercase: 布尔值 是否在保存前对此值调用 .toLowerCase()
  • uppercase: 布尔值 是否在保存前对此值调用 .toUpperCase()
  • trim: 布尔值 是否在保存前对此值调用 .trim()
  • match: 正则表达式 创建验证器检查这个值是否匹配给定正则表达式
  • enum: 数组 创建验证器检查这个值是否包含于给定数组
Number
  • min: 数值 创建验证器检查属性是否大于或等于该值
  • max: 数值 创建验证器检查属性是否小于或等于该值
Date
  • min: Date
  • max: Date
注意

Dates
内建 Date 方法 不会触发 mongoose 修改跟踪逻辑, 如果你对使用 setMonth() 修改文档里的 Date, mongoose 在 doc.save() 的时候是察觉不到这个文档发生了变化的,因此保存不到数据库。 如果你一定要用内建 Date 方法, 请手动调用 doc.markModified(‘pathToYourDate’) 告诉 mongoose 你修改了数据。

1
2
3
4
5
6
7
8
var Assignment = mongoose.model("Assignment", { dueDate: Date });
Assignment.findOne(function (err, doc) {
doc.dueDate.setMonth(3);
doc.save(callback); // THIS DOES NOT SAVE YOUR CHANGE

doc.markModified("dueDate");
doc.save(callback); // works
});

Mixed
一个啥都可以放的 SchemaType , 虽然便利,但也会让数据难以维护。 Mixed 可以通过 Schema.Types.Mixed 或 传入一个空对象定义。以下三种方法效果一致:

1
2
3
var Any = new Schema({ any: {} });
var Any = new Schema({ any: Object });
var Any = new Schema({ any: Schema.Types.Mixed });

ObjectIds

创造 SchemaTypes 或子文档数组。

1
2
3
4
5
6
7
8
var ToySchema = new Schema({ name: String });
var ToyBox = new Schema({
toys: [ToySchema],
buffers: [Buffer],
string: [String],
numbers: [Number],
// ... etc
});

指定空数组相当于 Mixed

1
2
3
4
var Empty1 = new Schema({ any: [] });
var Empty2 = new Schema({ any: Array });
var Empty3 = new Schema({ any: [Schema.Types.Mixed] });
var Empty4 = new Schema({ any: [{}] });

Connections

连接

mongoose.connect(‘mongodb://username:password@host:port/database?options…’);

操作缓存

不必等待连接建立成功就可以使用 mongoose models

1
2
3
4
5
6
mongoose.connect("mongodb://localhost/myapp");
var MyModel = mongoose.model("Test", new Schema({ name: String }));
// 可行
MyModel.findOne(function (error, result) {
/* ... */
});

mongoose 会缓存操作,这样很方便,但有时也会造成疑惑,因为如果你没连上 ,Mongoose 不会 抛错。
要禁用缓存,请修改 bufferCommands 配置。 如果你打开了 bufferCommands 连接被挂起,尝试关闭 bufferCommands 检查你是否正确打开连接。 你也可以全局禁用 bufferCommands :

1
mongoose.set("bufferCommands", false);

Model

Model 是通过 Schema 构造而成的,除了具有 Schema 定义的数据库骨架以外,还可以操作数据库

第一个参数是跟 model 对应的集合( collection )名字的 单数 形式。 Mongoose 会自动找到名称是 model 名字 复数 形式的 collection 。 对于上例,Tank 这个 model 就对应数据库中 tanks 这个 collection。.model() 这个函数是对 schema 做了拷贝(生成了 model)。 你要确保在调用 .model() 之前把所有需要的东西都加进 schema 里了!

1
2
var schema = new mongoose.Schema({ name: "string", size: "string" });
var Tank = mongoose.model("Tank", schema);

查询

可以用 model 的 find,findById,findOne,where 这些静态方法

删除

remove 方法

更新

update 方法

Documents

Mongoose document 代表着 MongoDB 文档的一对一映射。 每个 document 都是他的 Model 的实例。

更新

有很多方法可以更新文档

1
2
3
4
5
6
7
8
9
Tank.findById(id, function (err, tank) {
if (err) return handleError(err);

tank.size = "large";
tank.save(function (err, updatedTank) {
if (err) return handleError(err);
res.send(updatedTank);
});
});

用 .set() 修改 document,在底层 tank.size = ‘large’; 用 tank.set({ size: ‘large’ })实现。

1
2
3
4
5
6
7
8
9
Tank.findById(id, function (err, tank) {
if (err) return handleError(err);

tank.set({ size: "large" });
tank.save(function (err, updatedTank) {
if (err) return handleError(err);
res.send(updatedTank);
});
});

如果我们仅仅需要更新而不需要获取该数据, Model#update 就很适合我们:

1
Tank.update({ _id: id }, { $set: { size: "large" } }, callback);

如果我们确实需要返回文档

1
2
3
4
5
6
7
8
9
Tank.findByIdAndUpdate(
id,
{ $set: { size: "large" } },
{ new: true },
function (err, tank) {
if (err) return handleError(err);
res.send(tank);
}
);

验证

document 在被保存前会被验证

覆盖

可以用 .set() 覆盖整个文档,如果要修改在中间件中被保存的文档,这样很方便

子文档 subdocument

Mongoose 中子文档有两种不同的概念 子文档数组和单个嵌套子文档。

子文档与普通 document 类似。嵌套 schema 可以有自己的 中间件、自定义检验逻辑、 虚拟值以及其他顶层 schemas 可用的特性。两者主要的不同点是 子文档不能单独保存,他们会在他们的顶级文档保存时保存。

子文档跟普通 document 一样有 save 和 validate 中间件。 调用父文档的 save() 会触发其所有子文档的 save() 中间件, validate() 中间件同理。

子文档的 pre(‘save’) 和 pre(‘validate’) 中间件执行于 顶层 document 的 pre(‘save’) 之前, 顶层 document 的 pre(‘validate’) 之后。 因为 save() 前的验证就是一个内置中间件。(待修改)

查找子文档

每个子文档都有一个默认的 _id 。Mongoose document 数组有一个特别的 id 方法, 这个方法只要传入 _id 就能返回文档数组中特定文档。

1
var doc = parent.children.id(_id);

添加子文档到数组

Mongoose 数组方法有 push、 unshift、 addToSet、 及其他:

1
2
3
4
5
6
7
8
9
10
11
12
13
var Parent = mongoose.model("Parent");
var parent = new Parent();

// create a comment
parent.children.push({ name: "Liesl" });
var subdoc = parent.children[0];
console.log(subdoc); // { _id: '501d86090d371bab2c0341c5', name: 'Liesl' }
subdoc.isNew; // true

parent.save(function (err) {
if (err) return handleError(err);
console.log("Success!");
});

代替声明语法的写法

如果你用对象的数组创建 schema ,mongoose 会自动 为你把对象转换成 schema:

1
2
3
4
5
6
7
var parentSchema = new Schema({
children: [{ name: "string" }],
});
// Equivalent
var parentSchema = new Schema({
children: [new Schema({ name: "string" })],
});

这几天研究了一下用把上学期写的 oj 部署在 docker 容器中,自己编写了 Dockerfile 构建主容器,并将数据库容器分离,使用了 docker-compose 对容器进行管理,实现了自动化的部署,感觉收获挺多的。写下这篇博客记录一下自己新 get 到的技能。

重构原因

其实我接触到 docker 这门技术已经很久了。在学习 Laravel 的时候使用了 laradock 快速构建开发环境,那时候感觉 docker 真是太方便了。不过长久以来,我对于 docker 的了解,仅仅停留在基础的使用上,也没有尝试过自己编写 Dockerfile 构建镜像,对于 docker 中一些基础的概念都很模糊,像什么端口映射啊,数据持久化啊,都不了解,结果在部署应用的时候,白白费了很多功夫,实在是得不偿失。这就不得不说之前部署 OJ 的血泪史(菜啊(⊙﹏⊙)b)。我校 OJ 是基于 HUSTOJ 进行二次开发的,在本地开发的时候使用了作者的一键部署脚本,没有使用容器构建。之前学长在学校的服务器上用 docker 跑了一个 hustoj 的镜像。本地开发倒是没有什么问题,但是怎么部署到服务器的 docker 上?我的做法现在看来非常的傻逼,把项目改动部分的代码上传到 github 仓库,然后进入到学校 oj 的 hustoj 容器中,把其中 web 部分的代码删除,再从 github 上 pull 下我的代码。我想的是很简单,实际操作起来。先是部署上去就花了将近一整天,而之后像是 OJ 判不了 JAVA,文件权限不对,配置文件不对等各种问题等前前后后修复了好几次才搞定,一点都不优雅。之后过了一个寒假,倒是一直稳定运行到现在。但是我觉得不能就这样放着,因为这系统除了我自己。趁开学事情少,我学习了一下怎么编写 Dockerfile,以及编写 docker-compose 的配置脚本,实现了 OJ 的自动化部署。

编写 Dockerfile

之前看 docker 的书的时候,是有看过怎么编写 Dockerfile 的,但是没有自己写过。所以一上来就陷入了僵局。基础镜像选择我比较熟悉的 ubuntu,但之后该干嘛啊。百度学习一番后,我了解到 docker 有 history 这个命令,其加上—no-trunc 可以完整显示镜像构建每个步骤执行的命令(但是后来我才意识到 hustoj 的项目中就有 Dockerfile 文件,僵)。先复制过来,然后看着基础镜像 ubuntu:Trusty,不明所以,百度了一下才知道这个 ubuntu14 的代号,这也太老了吧。直接换成 ubuntu18.04,把里面的软件都换成了比较新的版本,把 php5 换成了 php7,去掉了 mysql,因为要放到单独的容器中。就这样小改了一下,感觉没啥问题,那就构建一下试试吧。
进行构建,显示命令报错,很多软件包都不存在。果断开一个 ubuntu 的容器,一个个测试能不能下载,确认没问题再次构建。然后 apt 能下载了,但是官方源的速度实在是太慢了。因此添加了COPY sources.list /etc/apt/sources.list命令到最前面。这下下载速度没问题了,但是在安装某个依赖 tzdata 时,需要手动选择时区,但是我按照指令选择并没有相应,重新构建了几次,都是如此。百度解决办法,需要这样来安装

1
2
3
4
5
6
7
RUN set -ex \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get install -y tzdata
RUN set -ex \
&& ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& dpkg-reconfigure -f noninteractive tzdata

我把它放在安装其他软件之前。docker 的构建步骤是有缓存的,之前成功构建的步骤会缓存起来,每一个步骤对应一个镜像层,下次不需要再次构建。一条 RUN 语句的执行就创建了一个镜像层。如果构建步骤发生了改变,就从开始改变的那一步开始重新构建。开始构建的时候,往往少安装很多软件包,可以把新添加的包放在新的 RUN 语句下安装,避免把原先安装的包再下载一遍。
hustoj 的构建镜像的项目代码是从 github 上 clone 下来的,我最开始也是这么写的,后来发现有更好的方法,这个后面再说。

使用 docker-compose

因为打算把 mysql 容器分离,所以要使用 docker-compose 进行容器的管理。laradock 就是基于 docker-compose 的一个项目。在配置文件中,我们需要定义服务,每个服务对应一个容器,服务的选项对应了 docekr 启动是的各种启动选项。将其写在配置文件中,我们只要简单的两行命令就可以了,非常高效。

1
2
docker-compose build
docker-compose up -d

下面记录几个我认为重要的点

  • tty 选项,主容器应该设置为 true,否则会因为确实控制终端的配置导致启动失败
  • ports 选项,设置容器的端口映射,按照 主机端口:容器端口 的顺序映射
  • expose,说明容器暴露的端口,但没有实际作用,仍需要设置 ports 选项才能让映射生效
  • links,容器间数据互联,容器的中会解析服务名为对应的 ip 地址,我让主容器连接到了 mysql 容器
  • depends_on 依赖,被依赖的服务会现行构建
  • enviroment 环境变量,对于 mysql 可以通过环境变量设置管理员密码,新建用户和新建数据库
  • volumes,数据卷,像/var/lib/mysql 这样存放数据的目录应该放在可持久化的数据卷中,也可以映射本机目录到容器
  • .env 文件可以定义当前目录中的环境变量,这是 linux 系统的特性,会自动读取.env 中定义的变量到环境变量
  • entrypoint,容器启动时自动运行的脚本,一般用于启动后台服务进程。是启动而不是构建时,每次docker-compose start时都会运行,Dockerfile 中也有 ENTRYPOINT 选项,也是在每次容器启动的时候运行。

总结一下,Dockerfile 中 RUN 命令只在构建的时候运行,用于安装软件包,和简单的一些配置。两个 entrypoint 区别不大,都是容器启动时运行,一般用于启动后台服务。而对于只需要进行一次的操作,比如运行 sql 建库,修改文件权限,可以编写一个 shell 脚本,在容器创建之后手动执行一次

整合到项目

这样构建起来的项目,代码是保存在镜像中的,没办法用 vscode 打开。回想起来之前用 laradock 的经历,似乎可以将本地项目目录映射到容器,看了 laradock 的配置文件,果然如此。于是把项目代码文件目录也整合了进来,在 volumes 选项中设置映射代码目录。目录结构是这样的

1
2
3
4
5
6
7
8
├── docker
│   ├── ahpuoj
│   └── docker-compose.yml
├── README.md
└── src
├── core
├── install
└── web

这样只要 clone 到本地,简单构建一下容器,就可以进行开发了。
对于不要提交的配置文件,我将其放在 install 文件夹中,并且在创建后执行的安装脚本中设置了软连接指向本该所在的目录。个人认为是个不错的方法。

一些问题

  • depends_on 虽然决定了容器创建的顺序,但是容器中的服务启动仍然需要时间。因此在 entrypoint.sh 中访问数据库会失败,因为这时候数据库容器中的服务可能还没有启动完成
  • sed 修改软连接文件会将其变成真正的文件,需要添加--follow-symlinks选项
  • entrypoint.sh 文件的最后应加上/bin/bash,运行 bash 前台应用,否则也会造成容器启动后就停止

项目地址

最后是项目的地址