文章转自SHELL编程之执行环境

微博ID:orroz

微信公众号:Linux系统技术

前言

本文是shell编程系列的第三篇,主要介绍bash脚本的执行环境以及注意事项。通过本文,应该可以帮助您解决以下问题:

  • 执行bash和执行sh究竟有什么区别?
  • 如何调试bash脚本?
  • 如何记录用户在什么时候执行的某条命令?
  • 为什么有时ulimit命令的设置不生效或者报错?
  • 环境变量和一般变量有什么区别??

常用参数

交互式login shell

关于bash的编程环境,首先我们要先理解的就是bash的参数。不同方式启动的bash一般默认的参数是不一样的。一般在命令行中使用的bash,都是以login方式打开的,对应的参数是:-l或—login。还有-i参数,表示bash是以交互方式打开的,在默认情况下,不加任何参数的bash也是交互方式打开的。这两种方式都会在启动bash之前加载一些文件:

首先,bash去检查/etc/profile文件是否存在,如果存在就读取并执行这个文件中的命令。

之后,bash再按照以下列出的文件顺序依次查看是否存在这些文件,如果任何一个文件存在,就读取、执行文件中的命令:

  1. ~/.bash_profile
  2. ~/.bash_login
  3. ~/.profile

这里要注意的是,本步骤只会检查到一个文件并处理,即使同时存在2个或3个文件,本步骤也只会处理最优先的那个文件,而忽略其他文件。以上两个步骤的检查都可以用—noprofile参数进行关闭。

当bash是以login方式登录的时候,在bash退出时(exit),会额外读取并执行~/.bash_logout文件中的命令。

当bash是以交互方式登录时(-i参数),bash会读取并执行~/.bashrc中的命令。—norc参数可以关闭这个功能,另外还可以通过—rcfile参数指定一个文件替代默认的~/.bashrc文件。

以上就是bash以login方式和交互式方式登录的主要区别,根据这个过程,我们到RHEL7的环境上看看都需要加载哪些配置:

1.首先是加载/etc/profile。根据RHEL7上此文件内容,这个脚本还需要去检查/etc/profile.d/目录,将里面以.sh结尾的文件都加载一遍。具体细节可以自行查看本文件内容。
2.之后是检查~/.bash_profile。这个文件中会加载~/.bashrc文件。
3.之后是处理~/.bashrc文件。此文件主要功能是给bash环境添加一些alias,之后再加载/etc/bashrc文件。
4.最后处理/etc/bashrc文件。这个过程并不是bash自身带的过程,而是在RHEL7系统中通过脚本调用实现。

了解了这些之后,如果你的bash环境不是在RHEL7系统上,也应该可以确定在自己环境中启动的bash到底加载了哪些配置文件。

bash和sh

几乎所有人都知道bash有个别名叫sh,也就是说在一个脚本前面写!#/bin/bash和#!/bin/sh似乎没什么不同。但是下面我们要看看它们究竟有什么不同。

首先,第一个区别就是这两个文件并不是同样的类型。如果细心观察过这两个文件的话,大家会发现:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ ls -l /usr/bin/sh
lrwxrwxrwx 1 root root 4 11月 24 04:20 /usr/bin/sh -> bash
[zorro@zorrozou-pc0 bash]$ ls -l /usr/bin/bash
-rwxr-xr-x 1 root root 791304 11月 24 04:20 /usr/bin/bash

sh是指向bash的一个符号链接。符号链接就像是快捷方式,那么执行sh就是在执行bash。这说明什么?说明这两个执行方式是等同的么?实际上并不是。我们都知道在程序中是可以获得自己执行命令的进程名称的,这个方法在bash编程中可以使用$0变量来实现,参见如下脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[zorro@zorrozou-pc0 bash]$ cat name.sh
#!/bin/bash
echo $0
case $0 in
*name.sh)
echo "My name is name!"
;;
*na.sh)
echo "My name is na"
;;
*)
echo "Name error!"
;;
esac

这个脚本直接执行的结果是:

1
2
3
[zorro@zorrozou-pc0 bash]$ ./name.sh
./name.sh
My name is name!

大家也能看到脚本中有个逻辑是,如果进程名字是以na.sh结尾,那么打印的内容不一样。我们如何能让同一个程序触发这段不同的逻辑呢?其实很简单,就是给这个脚本创建一个叫na.sh的符号链接:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ ln -s name.sh na.sh
[zorro@zorrozou-pc0 bash]$ ./na.sh
./na.sh
My name is na

通过符号链接的方式改变进程名称是一种常见的编程技巧,我们可以利用这个办法让程序通过不同进程名触发不同处理逻辑。所以大家以后再遇到类似bash和sh这样的符号链接关系的进程时要格外注意它们的区别。在这里它们到底有什么区别呢?实际上bash的源代码中对以bash名称和sh名称执行的时候,会分别触发不同的逻辑,主要的逻辑区别是:以sh名称运行时,会相当于以—posix参数方式启动bash。这个方式跟一般方式的具体区别可以参见:http://tiswww.case.edu/php/chet/bash/POSIX。

我遇到过很多次因为不同文件名的处理逻辑不同而引发的问题。其中一次是因为posix模式和一般模式的ulimit -c设置不同导致的。ulimit -c参数可以设置进程出现coredump时产生的文件的大小限制。因为内存的页大多都是4k,所以一般core文件都是最小4k一个,当ulimit -c参数设置小于4k时,无法正常产生core文件。为了调试方便,我们的生产系统都开了ulimit -c限制单位为4。因为默认ulimit -c的限制单位是1k,ulimit -c 4就是4k,够用了。但是我们仍然发现部分服务器不能正常产生core文件,最后排查定位到,这些不能产生core文件的配置脚本只要将#!/bin/sh改为#!/bin/bash就可以正常产生core文件。于是郁闷之余,查阅了bash的处理代码,最终发现原来是这个坑导致的问题。原因是:在posix模式下,ulimit -c的参数单位不是1024,而是512。至于还有什么其他不同,在上述链接中都有说明。

脚本调试

程序员对程序的调试工作是必不可少的,bash本身对脚本程序提供的调试手段不多,printf大法是必要技能之一,当然在bash中就是echo大法。另外就是bash的-v参数、-x参数和-n参数。

-v参数就是可视模式,它会在执行bash程序的时候将要执行的内容也打印出来,除此之外,并不改变bash执行的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
[zorro@zorrozou-pc0 bash]$ cat arg.sh
#!/bin/bash -v
echo $0
echo $1
echo $2
ls /123
echo $3
echo $4
echo $#
echo $*
echo $?

执行结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[zorro@zorrozou-pc0 bash]$ ./arg.sh 111 222 333 444 555
#!/bin/bash -v
echo $0
./arg.sh
echo $1
111
echo $2
222
ls /123
ls: cannot access '/123': No such file or directory
echo $3
333
echo $4
444
echo $#
5
echo $*
111 222 333 444 555
echo $?
0

-n参数用来检查bash的语法错误,并且不会真正执行bash脚本。这个就不举例子了。另外,三种方式除了可以直接在bash后面加参数以外,还可以在程序中随时使用内建命令set打开和关闭,方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[zorro@zorrozou-pc0 bash]$ cat arg.sh
#!/bin/bash
set -v
#set -o verbose
echo $0
set +v
echo $1
set -x
#set -o xtrace
echo $2
ls /123
echo $3
set +x
echo $4
echo $#
set -n
#set -o noexec
echo $*
echo $?
set +n

执行结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[zorro@zorrozou-pc0 bash]$ ./arg.sh
#set -o verbose
echo $0
./arg.sh
set +v
+ echo
+ ls /123
ls: cannot access '/123': No such file or directory
+ echo
+ set +x
0

以上例子中顺便演示了1、3、#、?的意义,大家可以自行对比它们的区别以理解参数的意义。另外再补充一个-e参数,这个参数可以让bash脚本命令执行错误的时候直接退出,而不是继续执行。这个功能在某些调试的场景下非常有用!

本节只列出了几个常用的参数的意义和使用注意事项,希望可以起到抛砖引玉的作用。大家如果想要学习更多的bash参数,可以自行查看bash的man手册,并详细学习set和shopt命令的使用方法。

环境变量

我们目前已经知道有个PATH变量,bash会在查找外部命令的时候到PATH所记录的目录中进行查找,从这个例子我们可以先理解一下环境变量的作用。环境变量就类似PATH这种变量,是bash预设好的一些可能会对其状态和行为产生影响的变量。bash中实现的环境变量个数大概几十个,所有的帮助说明都可以在man bash中找到。我们还是拿一些会在bash编程中经常用到的来讲解一下。

我们可以使用env命令来查看当前bash已经定义的环境变量。set命令不加任何参数可以查看当前bash环境中的所有变量,包括环境变量和私有的一般变量。一般变量的定义方法:

1
2
3
4
5
6
[zorro@zorrozou-pc0 ~]$ aaa=1000
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ env|grep aaa
[zorro@zorrozou-pc0 ~]$ set|grep aaa
aaa=1000

上面我们定义了一个变量名字叫做aaa,我们能看到在set命令中可以显示出这个变量,但是env不显示。export命令可以将一个一般变量编程环境变量。

1
2
3
4
5
[zorro@zorrozou-pc0 ~]$ export aaa
[zorro@zorrozou-pc0 ~]$ env|grep aaa
aaa=1000
[zorro@zorrozou-pc0 ~]$ set|grep aaa
aaa=1000

export之后,env和set都能看到这个变量了。一般变量和环境变量的区别是:一般变量不能被子进程继承,而环境变量会被子进程继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[zorro@zorrozou-pc0 ~]$ env|grep aaa
aaa=1000
[zorro@zorrozou-pc0 ~]$ bbb=2000
[zorro@zorrozou-pc0 ~]$ echo $bbb
2000
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ env|grep bbb
[zorro@zorrozou-pc0 ~]$ bash
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ echo $bbb
[zorro@zorrozou-pc0 ~]$

上面测试中,我们的bash环境里有一个环境变量aaa=1000,又定义了一个一般变量bbb=2000。此时我们在用bash打开一个子进程,在子进程中我们发现,aaa变量仍然能取到值,但是bbb不可以。证明aaa可以被子进程继承,bbb不可以。

搞清楚了环境变量的基础知识之后,再来看一下bash中常用的环境变量:

进程自身信息相关

BASH:当前bash进程的进程名。

BASHOPTS:记录了shopt命令已经设置为打开的选项。

BASH_VERSINFO:bash的版本号信息,是一个数组。可以使用命令:echo ${BASH_VERSINFO[*]}查看数组的信息。有关数组的操作我们会在其它文章里详细说明。

BASH_VERSION:bash的版本号信息。比上一个信息更少一点。

HOSTNAME:系统主机名信息。

HOSTTYPE:系统类型信息。

OLDPWD:上一个当前工作目录。

PWD:当前工作目录。

HOME:主目录。一般指进程uid对应用户的主目录。

SHELL:bash程序所在路径。

常用数字

RANDOM:每次取这个变量的值都能得到一个0-32767的随机数。

SECONDS:当前bash已经开启了多少秒。

BASHPID:当前bash进程的PID。

EUID:进程的有效用户id。

GROUPS:进程组身份。

PPID:父进程PID。

UID:用户uid。

提示符

PS1:用户bash的交互提示符,主提示符。

PS2:第二提示符,主要用在一些除了PS1之外常见的提示符场景,比如输入了’之后回车,就能看到这个提示符。

PS3:用于select语句的交互提示符。

PS4:用于跟踪执行过程时的提示符,一般显示为”+”。比如我们在bash中使用set -x之后的跟踪提示就是这个提示符显示的。

命令历史

交互bash中提供一种方便追溯曾经使用的命令的功能,叫做命令历史功能。就是将曾经用过的命令纪录下来,以备以后查询或者重复调用。这个功能在交互方式的bash中默认打开,在bash编程环境中默认是没有开启的。可以使用set +H来关闭这个功能,set -H打开这个功能。在开启了history功能的bash中我们可以使用history内建命令查询当前的命令历史列表:

1
2
3
4
5
[zorro@zorrozou-pc0 bash]$ history
1 sudo bash
2 ps ax
3 ls
4 ip ad sh

命令历史的相关配置都是通过bash的环境变量来完成的:

HISTFILE:记录命令历史的文件路径。

HISTFILESIZE:命令历史文件的行数限制

HISTCONTROL:这个变量可以用来控制命令历史的一些特性。比如一般的命令历史会完全按照我们执行命令的顺序来完整记录,如果我们连续执行相同的命令,也会重复记录,如:

1
2
3
4
5
6
7
8
9
10
11
12
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ history
......
1173 pwd
1174 pwd
1175 pwd
1176 history

我们可以利用这个变量的配置来消除命令历史中的重复记录:

1
2
3
4
5
6
7
8
9
10
11
12
[zorro@zorrozou-pc0 bash]$ export HISTCONTROL=ignoredups
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ history
1177 export HISTCONTROL=ignoredups
1178 history
1179 pwd
1180 history

这个变量还有其它配置,ignorespace可以用来让history忽略以空格开头的命令,ignoreboth可以同时起到ignoredups和ignorespace的作用,

HISTIGNORE:可以控制history机制忽略某些命令,配置方法:

1
export HISTIGNORE=”pwd:ls:cd:”。

HISTSIZE:命令历史纪录的命令个数。

HISTTIMEFORMAT:可以用来定义命令历史纪录的时间格式.在命令历史中记录命令执行时间有时候很有用,配置方法:

1
export HISTTIMEFORMAT='%F %T '

相关时间格式的帮助可以查看man 3 strftime。

HISTCMD:当前命令历史的行数。

在交互式操作bash的时候,可以通过一些特殊符号对命令历史进行快速调用,这些符号基本都是以!开头的,除非!后面跟的是空格、换行、等号=或者小括号():

!n:表示引用命令历史中的第n条命令,如:!123,执行第123条命令。

!-n:表示引用命令历史中的倒数第n条命令,如:!-123,执行倒数第123条命令。

!!:重复执行上一条命令。

!string:在命令历史中找到最近的一条以string字符串开头的命令并执行。

!?string[?]:在命令历史中找到最近的一条包括string字符的命令并执行。如果最有一个?省略的话,就是找到以string结尾的命令。

^string1^string2^:将上一个命令中的string1字符串替换成string2字符串并执行。可以简写为:^string1^string2

!#:重复当前输入的命令。

以下符号可以作为某个命令的单词代号,如:

^:!^表示上一条命令中的第一个参数,$123^表示第123条命令的第一个参数。

$:!$表示上一条命令中的最后一个参数。!123$表示第123条命令的最后一个参数。

n(数字):!!0表示上一条命令的命令名,!!3上一条命令的第三个参数。!123:3第123条命令的第三个参数。

:表示所有参数,如:!123:\或!123*

x-y:x和y都是数字,表示从第x到第y个参数,如:!123:1-6表示第123条命令的第1个到第6个参数。只写成-y,取前y个,如:!123:-7表示0-7。

x:表示取从第x个参数之后的所有参数,相当于x-$。如:!123:2\

x-:表示取从第x个参数之后的所有参数,不包括最后一个。如:!123:2-

选择出相关命令或者参数之后,我们还可以通过一些命令对其进行操作:

h 删除所有后面的路径,只留下前面的

1
2
3
4
5
[zorro@zorrozou-pc0 bash]$ ls /etc/passwd
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:h
ls /etc
...

紧接着上面的命令执行,相当于运行passwd。

r 删除后缀.xxx, 留下文件名

1
2
3
4
[zorro@zorrozou-pc0 bash]$ ls 123.txt
ls: cannot access '123.txt': No such file or directory
[zorro@zorrozou-pc0 bash]$ !!:r
ls 123

e 删除文件名, 留下后缀

1
2
3
[zorro@zorrozou-pc0 bash]$ !-2:e
.txt
bash: .txt: command not found

p 只打印结果命令,但不执行

1
2
3
4
[zorro@zorrozou-pc0 bash]$ ls /etc/passwd
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:p
ls /etc/passwd

q 防止代换参数被再次替换,相当于给选择的参数加上了’’,以防止其被转义。

1
2
3
4
5
[zorro@zorrozou-pc0 bash]$ ls `echo /etc/passwd`
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:q
'ls `echo /etc/passwd`'
-bash: ls `echo /etc/passwd`: No such file or directory

x 作用同上,区别是每个参数都会分别给加上’’。如:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ !-2:x
'ls' '`echo' '/etc/passwd`'
ls: cannot access '`echo': No such file or directory
ls: cannot access '/etc/passwd`': No such file or directory

s/old/new/ 字符串替换,跟上面的^^类似,但是可以指定任意历史命令。只替换找到的第一个old字符串。
& 重复上次替换
g 在执行s或者&命令作为前缀使用,表示全局替换。

资源限制

每一个进程环境中都有对于资源的限制,bash脚本也不例外。我们可以使用ulimit内建命令查看和设置bash环境中的资源限制。

[zorro@zorrozou-pc0 ~]$ ulimit -a
core file size (blocks, -c) unlimited
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 63877
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 63877
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited

在上文讲述bash和sh之间的区别时,我们已经接触过这个命令中的-c参数了,用来限制core文件的大小。我们再来看看其它参数的含义:

data seg size:程序的数据段限制。

scheduling priority:优先级限制。相关概念的理解可以参考这篇:http://wp.me/p79Cit-S

file size:文件大小限制。

pending signals:未决信号个数限制。

max locked memory:最大可以锁内存的空间限制。

max memory size:最大物理内存使用限制。

open files:文件打开个数限制。

pipe size:管道空间限制。

POSIX message queues:POSIX消息队列空间限制。

real-time priority:实时优先级限制。相关概念的理解可以参考这篇:http://wp.me/p79Cit-S

stack size:程序栈空间限制。

cpu time:占用CPU时间限制。

max user processes:可以打开的的进程个数限制。

virtual memory:虚拟内存空间限制。

file locks:锁文件个数限制。

以上参数涉及各方面的相关知识,我们在此就不详细描述这些相关内容了。在此我们主要关注open files和max user processes参数,这两个参数是我们在优化系统时最常用的两个参数。

这里需要注意的是,使用ulimit命令配置完这些参数之后的bash产生的子进程都会继承父进程的相关资源配置。ulimit的资源配置的继承关系类似环境变量,父进程的配置变化可以影响子进程。所以,如果我们只是在某个登录shell或者交互式shell中修改了ulimit配置,那么在这个bash环境中执行的命令和产生的子进程都会受到影响,但是对整个系统的其它进程没有影响。如果我们想要让所有用户一登录就有相关的配置,可以考虑把ulimit命令写在bash启动的相关脚本中,如/etc/profile。如果只想影响某一个用户,可以写在这个用户的主目录的bash启动脚本中,如~/.bash_profile。系统的pam模块也给我们提供了配置ulimit相关限制的配置方法,在centos7中大家可以在以下目录和文件中找到相关配置:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ ls /etc/security/limits.d/
10-gcr.conf 99-audio.conf
[zorro@zorrozou-pc0 bash]$ ls /etc/security/limits.conf
/etc/security/limits.conf

即使是写在pam相关配置文件中的相关配置,也可能不是系统全局的。如果你想给某一个后台进程设置ulimit,最靠谱的办法还是在它的启动脚本中进行配置。无论如何,只要记得一点,如果相关进程的ulimit没生效,要想的是它的父进程是谁?它的父进程是不是生效了?

ulimit参数中绝大多数配置都是root才有权限改的更大,而非root身份只能在现有的配置基础上减小限制。如果你执行ulimit的时候报错了,请注意是不是这个原因。
```

最后

通过本文我们学习了bash编程的进程环境的相关内容,主要包括的知识点为:

  • bash的常用参数。
  • bash的环境变量。
  • 命令历史功能和相关变量配置。
  • bash脚本的资源限制ulimit的使用。

SHELL编程之执行过程

每次看这位大大的文章都会收获很多
文章转自SHELL编程之执行过程
作者相关:

1
2
3
微博ID:**orroz**
微信公众号:**Linux系统技术**

前言

本文是shell编程系列的第二篇,主要介绍bash脚本是如何执行命令的。通过本文,您应该可以解决以下问题:

  • 脚本开始的#!到底是怎么起作用的?

  • bash执行过程中的字符串判断顺序究竟是什么样?

  • 如果我们定义了一个函数叫ls,那么调用ls的时候,到底bash是执行ls函数还是ls命令?

  • 内建命令和外建命令到底有什么差别?

  • 程度退出的时候要注意什么?

以魔法#!开始

一个脚本程序的开始方式都比较统一,它们几乎都开始于一个#!符号。这个符号的作用大家似乎也都知道,叫做声明解释器。脚本语言跟编译型语言的不一样之处主要是脚本语言需要解释器。因为脚本语言主要是文本,而系统中能够执行的文件实际上都是可执行的二进制文件,就是编译好的文件。文本的好处是人看方便,但是操作系统并不能直接执行,所以就需要将文本内容传递给一个可执行的二进制文件进行解析,再由这个可执行的二进制文件根据脚本的内容所确定的行为进行执行。可以做这种解析执行的二进制可执行程序都可以叫做解释器。

脚本开头的#!就是用来声明本文件的文本内容是交给那个解释器进行解释的。比如我们写bash脚本,一般声明的方法是#!/bin/bash或#!/bin/sh。如果写的是一个python脚本,就用#!/usr/bin/python。当然,在不同环境的系统中,这个解释器放的路径可能不一样,所以固定写一个路径的方式就可能造成脚本在不同环境的系统中不通用的情况,于是就出现了这样的写法:

1
#!/usr/bin/env 脚本解释器名称

这就利用了env命令可以得到可执行程序执行路径的功能,让脚本自行找到在当前系统上到底解释器在什么路径。让脚本更具通用性。但是大家有没有想过一个问题,大多数脚本语言都是将#后面出现的字符当作是注释,在脚本中并不起作用。这个#!和这个注释的规则不冲突么?

这就要从#!符号起作用的原因说起,其实也很简单,这个功能是由操作系统的程序载入器做的。在Linux操作系统上,出了1号进程以外,我们可以认为其它所有进程都是由父进程fork出来的。所以对bash来说,所谓的载入一个脚本执行,无非就是父进程调用fork()、exec()来产生一个子进程。这#!就是在内核处理exec的时候进行解析的。

1
2
3
4
5
内核中整个调用过程如下(linux 4.4),内核处理exec族函数的主要实现在fs/exec.c文件的do_execveat_common()方法中,其中调用exec_binprm()方法处理执行逻辑,这函数中使用
search_binary_handler()对要加载的文件进行各种格式的判断,脚本(script)只是其中的一种。确定是script格式后,就会调用script格式对应的load_binary方法:
load_script()进行处理,#!就是在这个函数中解析的。解析到了#!以后,内核会取其后面的可执行程序路径,再传递给search_binary_handler()重新解析。
这样最终找到真正的可执行二进制文件进行相关执行操作。

因此,对脚本第一行的#!解析,其实是内核给我们变的魔术。#!后面的路径内容在起作用的时候还没有交给脚本解释器。很多人认为#!这一行是脚本解释器去解析的,然而并不是。了解了原理之后,也顺便明白了为什么#!一定要写在第一行的前两个字符,因为这是在内核里写死的,它就只检查前两个字符。当内核帮你选好了脚本解释器之后,后续的工作就都交给解释器做了。脚本的所有内容也都会原封不动的交给解释器再次解释,是的,包括#!。但是由于对于解释器来说,#开头的字符串都是注释,并不生效,所以解释器自然对#!后面所有的内容无感,继续解释对于它来说有意义的字符串去了。

我们可以用一个自显示脚本来观察一下这个事情,什么是自显示脚本?无非就是#!/bin/cat,这样文本的所有内容包括#!行都会交给cat进行显示:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 bash]$ cat cat.sh
#!/bin/cat
echo "hello world!"
[zorro@zorrozou-pc0 bash]$ ./cat.sh
#!/bin/cat
echo "hello world!"

或者自删除脚本:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 bash]$ cat rm.sh
#!/bin/rm
echo "hello world!"
[zorro@zorrozou-pc0 bash]$ chmod +x rm.sh
[zorro@zorrozou-pc0 bash]$ ./rm.sh
[zorro@zorrozou-pc0 bash]$ cat rm.sh
cat: rm.sh: No such file or directory

这就是#!的本质。

bash如何执行shell命令?

刚才我们从#!的作用原理讲解了一个bash脚本是如何被加载的。就是说当#!/bin/bash的时候,实际上内核给我们启动了一个bash进程,然后把脚本内容都传递给bash进行解析执行。实际上,无论在脚本里还是在命令行中,bash对文本的解析方法大致都是一样的。首先,bash会以一些特殊字符作为分隔符,将文本进行分段解析。最主要的分隔符无疑就是回车,类似功能的分隔符还有分号”;”。所以在bash脚本中是以回车或者分号作为一行命令结束的标志的。这基本上就是第一层级的解析,主要目的是将大段的命令行进行分段。

之后是第二层级解析,这一层级主要是区分所要执行的命令。这一层级主要解析的字符是管道”|”,&&、||这样的可以起到连接命令作用的特殊字符。这一层级解析完后,bash就能拿到最基本的一个个的要执行的命令了。

当然拿到命令之后还要继续第三层解析,这一层主要是区分出要执行的命令和其参数,主要解析的是空格和tab字符。这一层次解析完之后,bash才开始对最基本的字符串进行解释工作。当然,绝大多数解析完的字符串,bash都是在fork之后将其传递给exec进行执行,然后wait其执行完毕之后再解析下一行。这就是bash脚本也被叫做批处理脚本的原因,主要执行过程是一个一个指令串行执行的,上一个执行完才执行下一个。以上这个过程并不能涵盖bash解释字符串的全过程,实际情况要比这复杂。

bash在解释命令的时候为了方便一些操作和提高某些效率做了不少特性,包括alias功能和外部命令路径的hash功能。bash还因为某些功能不能做成外部命令,所以必须实现一些内建命令,比如cd、pwd等命令。当然除了内建命令以外,bash还要实现一些关键字,比如其编程语法结构的if或是while这样的功能。实际上作为一种编程语言,bash还要实现函数功能,我们可以理解为,bash的函数就是将一堆命令做成一个命令,然后调用执行这个名字,bash就是去执行事先封装好的那堆命令。

好吧,问题来了:我们已知有一个内建命令叫做cd,如果此时我们又建立一个alias也叫cd,那么当我在bash中敲入cd并回车之后,bash究竟是将它当成内建命令解释还是当成alias解释?同样,如果cd又是一个外部命令能?如果又是一个hash索引呢?如果又是一个关键字或函数呢?

实际上bash在做这些功能的时候已经安排好了它们在名字冲突的情况下究竟该先以什么方式解释。优先顺序是:

  1. 别名:alias

  2. 关键字:keyword

  3. 函数:function

  4. 内建命令:built in

  5. 哈西索引:hash

  6. 外部命令:command

这些bash要判断的字符串类型都可以用type命令进行判断,如:

1
2
3
4
5
6
7
8
9
[zorro@zorrozou-pc0 bash]$ type egrep
egrep is aliased to 'egrep --color=auto'
[zorro@zorrozou-pc0 bash]$ type if
if is a shell keyword
[zorro@zorrozou-pc0 bash]$ type pwd
pwd is a shell builtin
[zorro@zorrozou-pc0 bash]$ type passwd
passwd is /usr/bin/passwd

别名alias

bash提供了一种别名(alias)功能,可以将某一个字符串做成另一个字符串的别名,使用方法如下:

1
2
3
4
5
6
7
[zorro@zorrozou-pc0 bash]$ alias cat='cat -n'
[zorro@zorrozou-pc0 bash]$ cat /etc/passwd
1 root:x:0:0:root:/root:/bin/bash
2 bin:x:1:1:bin:/bin:/usr/bin/nologin
3 daemon:x:2:2:daemon:/:/usr/bin/nologin
4 mail:x:8:12:mail:/var/spool/mail:/usr/bin/nologin
......

于是我们再使用cat命令的时候,bash会将其解释为cat -n。

这个功能在交互方式进行bash操作的时候可以提高不少效率。如果我们发现我们常用到某命令的某个参数的时候,就可以将其做成alias,以后就可以方便使用了。交互bash中,我们可以用alias命令查看目前已经有的alias列表。可以用unalias取消这个别名设置:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ alias
alias cat='cat -n'
[zorro@zorrozou-pc0 bash]$ unalias cat

alias功能在交互打开的bash中是默认开启的,但是在bash脚本中是默认关闭的。

1
2
3
4
5
6
#!/bin/bash
#shopt -s expand_aliases
alias ls='ls -l'
ls /etc

此时本程序输出:

1
2
3
[zorro@zorrozou-pc0 bash]$ ./alias.sh
adjtime cgconfig.conf docker group ifplugd libao.conf mail.rc netconfig passwd request-key.conf shells udisks2
adobe cgrules.conf drirc ...

使用注释行中的shopt -s expand_aliases命令可以打开alias功能支持,我们将这行注释取消掉之后的执行结果为:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 bash]$ ./alias.sh
total 1544
-rw-r--r-- 1 root root 44 11月 13 19:53 adjtime
drwxr-xr-x 2 root root 4096 4月 20 09:34 adobe
-rw-r--r-- 1 root root 389 4月 18 22:19 appstream.conf
-rw-r--r-- 1 root root 0 10月 1 2015 arch-release
-rw-r--r-- 1 root root 260 7月 1 2014 asound.conf
drwxr-xr-x 3 root root 4096 3月 11 10:09 avahi

这就是bash的alias功能。

关键字:keyword

关键字的概念很简单,主要就是bash提供的语法。比如if,while,function等等。对这些关键字使用type命令会显示:

1
2
[zorro@zorrozou-pc0 bash]$ type function
function is a shell keyword

说明这是一个keyword。我想这个概念没什么可以解释的了,无非就是bash提供的一种语法而已。只是要注意,bash会在判断alias之后才来判断字符串是不是个keyword。就是说,我们还是可以创建一个叫if的alias,并且在执行的时候,bash只把它当成alias看。

1
2
3
4
[zorro@zorrozou-pc0 bash]$ alias if='echo zorro'
[zorro@zorrozou-pc0 bash]$ if
zorro
[zorro@zorrozou-pc0 bash]$ unalias if

函数:function

bash在判断完字符串不是一个关键字之后,将会检查其是不是一个函数。在bash编程中,我们可以使用关键字function来定义一个函数,当然这个关键字其实也可以省略:

1
2
name () compound-command [redirection]
function name [()] compound-command [redirection]

语法结构中的compound-command一般是放在{}里的一个命令列表(list)。定义好的函数其实就是一系列shell命令的封装,并且它还具有很多bash程序的特征,比如在函数内部可以使用$1,$2等这样的变量来判断函数的参数,也可以对函数使用重定向功能。

关于函数的更细节讨论我们会在后续的文章中展开说明,再这里我们只需要知道它对于bash来说是第几个被解释的即可。

内建命令:built in

在判断完函数之后,bash将查看给的字符串是不是一个内建命令。内建命令是相对于外建命令来说的。其实我们在bash中执行的命令最常见的是外建(外部)命令。比如常见的ls,find,passwd等。这些外建命令的特点是,它们是作为一个可执行程序放在$PATH变量所包含的目录中的。bash在执行这些命令的时候,都会进行fork(),exec()并且wait()。就是用标准的打开子进程的方式处理外部命令。但是内建命令不同,这些命令都是bash自身实现的命令,它们不依靠外部的可执行文件存在。只要有bash,这些命令就可以执行。典型的内建命令有cd、pwd等。大家可以直接help cd或者任何一个内建命令来查看它们的帮助。大家还可以man bash来查看bash相关的帮助,当然也包括所有的内建命令。

其实内建命令的个数并不会很多,一共大概就这些:

:, ., [, alias, bg, bind, break, builtin, caller, cd, command, compgen, complete, compopt, continue, declare, dirs, disown, echo, enable, eval, exec, exit, export, false, fc,
fg, getopts, hash, help, history, jobs, kill, let, local, logout, mapfile, popd, printf, pushd, pwd, read, readonly, return, set, shift, shopt, source, suspend, test, times, trap,
true, type, typeset, ulimit, umask, unalias, unset, wait

我们在后续的文章中会展开讲解这些命令的功能。

哈西索引:hash

hash功能实际上是针对外部命令做的一个功能。刚才我们已经知道了,外部命令都是放在$PATH变量对应的路径中的可执行文件。bash在执行一个外部命令时所需要做的操作是:如果发现这个命令是个外部命令就按照$PATH变量中按照目录路径的顺序,在每个目录中都遍历一遍,看看有没有对应的文件名。如果有,就fork、exec、wait。我们系统上一般的$PATH内容如下:

1
2
3
[zorro@zorrozou-pc0 bash]$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/lib/jvm/default/bin:/usr/bin/site_perl:
/usr/bin/vendor_perl:/usr/bin/core_perl:/home/zorro/.local/bin:/home/zorro/bin

当然,很多系统上的$PATH变量包含的路径可能更多,目录中的文件数量也可能会很多。于是,遍历这些目录去查询文件名的行为就可能比较耗时。于是bash提供了一种功能,就是建立一个bash表,在第一次找到一个命令的路径之后,对其命令名和对应的路径建立一个hash索引。这样下次再执行这个命令的时候,就不用去遍历所有的目录了,只要查询索引就可以更快的找到命令路径,以加快执行程序的速度。

我们可以使用内建命令hash来查看当前已经建立缓存关系的命令和其命中次数:

1
2
3
4
5
6
7
8
9
10
[zorro@zorrozou-pc0 bash]$ hash
hits command
1 /usr/bin/flock
4 /usr/bin/chmod
20 /usr/bin/vim
4 /usr/bin/cat
1 /usr/bin/cp
1 /usr/bin/mkdir
16 /usr/bin/man
27 /usr/bin/ls

这个命令也可以对当前的hash表进行操作,-r参数用来清空当前hash表。手工创建一个hash:

1
2
3
4
[root@zorrozou-pc0 bash]# hash -p /usr/sbin/passwd psw
[root@zorrozou-pc0 bash]# psw
Enter new UNIX password:
Retype new UNIX password:

此时我们就可以通过执行psw来执行passwd命令了。查看更详细的hash对应关系:

1
2
3
4
5
6
7
8
9
10
11
12
[root@zorrozou-pc0 bash]# hash -l
builtin hash -p /usr/bin/netdata netdata
builtin hash -p /usr/bin/df df
builtin hash -p /usr/bin/chmod chmod
builtin hash -p /usr/bin/vim vim
builtin hash -p /usr/bin/ps ps
builtin hash -p /usr/bin/man man
builtin hash -p /usr/bin/pacman pacman
builtin hash -p /usr/sbin/passwd psw
builtin hash -p /usr/bin/ls ls
builtin hash -p /usr/bin/ss ss
builtin hash -p /usr/bin/ip ip

删除某一个hash对应:

1
2
3
4
5
6
7
8
9
10
11
12
[root@zorrozou-pc0 bash]# hash -d psw
[root@zorrozou-pc0 bash]# hash -l
builtin hash -p /usr/bin/netdata netdata
builtin hash -p /usr/bin/df df
builtin hash -p /usr/bin/chmod chmod
builtin hash -p /usr/bin/vim vim
builtin hash -p /usr/bin/ps ps
builtin hash -p /usr/bin/man man
builtin hash -p /usr/bin/pacman pacman
builtin hash -p /usr/bin/ls ls
builtin hash -p /usr/bin/ss ss
builtin hash -p /usr/bin/ip ip

显示某一个hash对应的路径:

1
2
[root@zorrozou-pc0 bash]# hash -t chmod
/usr/bin/chmod

在交互式bash操作和bash编程中,hash功能总是打开的,我们可以用set +h关闭hash功能。

1
2
3
4
5
6
7
8
9
10
11
12
[zorro@zorrozou-pc0 bash]$ cat hash.sh
#!/bin/bash
#set +h
hash
hash -p /usr/bin/useradd uad
hash -t uad
uad

默认打开hash的脚本输出:

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
[zorro@zorrozou-pc0 bash]$ ./hash.sh
hash: hash table empty
/usr/bin/useradd
Usage: uad [options] LOGIN
uad -D
uad -D [options]
Options:
-b, --base-dir BASE_DIR base directory for the home directory of the
new account
-c, --comment COMMENT GECOS field of the new account
-d, --home-dir HOME_DIR home directory of the new account
-D, --defaults print or change default useradd configuration
-e, --expiredate EXPIRE_DATE expiration date of the new account
-f, --inactive INACTIVE password inactivity period of the new account
-g, --gid GROUP name or ID of the primary group of the new
account
-G, --groups GROUPS list of supplementary groups of the new
account
-h, --help display this help message and exit
-k, --skel SKEL_DIR use this alternative skeleton directory
-K, --key KEY=VALUE override /etc/login.defs defaults
-l, --no-log-init do not add the user to the lastlog and
faillog databases
-m, --create-home create the user's home directory
-M, --no-create-home do not create the user's home directory
-N, --no-user-group do not create a group with the same name as
the user
-o, --non-unique allow to create users with duplicate
(non-unique) UID
-p, --password PASSWORD encrypted password of the new account
-r, --system create a system account
-R, --root CHROOT_DIR directory to chroot into
-s, --shell SHELL login shell of the new account
-u, --uid UID user ID of the new account
-U, --user-group create a group with the same name as the user

关闭hash之后的输出:

1
2
3
4
5
[zorro@zorrozou-pc0 bash]$ ./hash.sh
./hash.sh: line 5: hash: hashing disabled
./hash.sh: line 7: hash: hashing disabled
./hash.sh: line 9: hash: hashing disabled
./hash.sh: line 11: uad: command not found

外部命令:command

除了以上说明之外的命令都会当作外部命令处理。执行外部命令的固定动作就是在$PATH路径下找命令,找到之后fork、exec、wait。如果没有这个可执行文件名,就报告命令不存在。这也是bash最后去判断的字符串类型。

外建命令都是通过fork调用打开子进程执行的,所以bash单纯只用外建命令是不能实现部分功能的。比如大家都知道cd命令是用来修改当前进程的工作目录的,如果这个功能使用外部命令实现,那么进程将fork打开一个子进程,子进程通过chdir()进行当前工作目录的修改时,实际上只改变了子进程本身的当前工作目录,而父进程bash的工作目录没变。之后子进程退出,返回到父进程的交互操作环境之后,用户会发现,当前的bash的pwd还在原来的目录下。所以大家应该可以理解,虽然我们的原则是尽量将所有命令都外部实现,但是还是有一些功能不能以创建子进程的方式达到目的,那么这些功能就必须内部实现。这就是内建命令必须存在的原因。另外要注意:bash在正常调用内部命令的时候并不会像外部命令一样产生一个子进程。

脚本的退出

一个bash脚本的退出一般有多种方式,比如使用exit退出或者所有脚本命令执行完之后退出。无论怎么样退出,脚本都会有个返回码,而且返回码可能不同。

任何命令执行完之后都有返回码,主要用来判断这个命令是否执行成功。在交互中bash中,我们可以使用$?来查看上一个命令的返回码:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 bash]$ ls /123
ls: cannot access '/123': No such file or directory
[zorro@zorrozou-pc0 bash]$ echo $?
2
[zorro@zorrozou-pc0 bash]$ ls /
bin boot cgroup data dev etc home lib lib64 lost+found mnt opt proc root run sbin srv sys tmp usr var
[zorro@zorrozou-pc0 bash]$ echo $?
0

返回码逻辑上有两类,0为真,非零为假。就是说,返回为0表示命令执行成功,非零表示执行失败。返回码的取值范围为0-255。其中错误返回码为1-255。bash为我们提供了一个内建命令exit,通过中这个命令可以人为指定退出的返回码是多少。这个命令的使用是一般进行bash编程的运维人员所不太注意的。我们在上一篇的bash编程语法结构的讲解中说过,if、while语句的条件判断实际上就是判断命令的返回值,如果我们自己写的bash脚本不注意规范的使用脚本退出时的返回码的话,那么这样的bash脚本将可能不可以在别人编写脚本的时候,直接使用if将其作为条件判断,这可能会对程序的兼容性造成影响。因此,请大家注意自己写的bash程序的返回码状态。如果我们的bash程序没有显示的以一个exit指定返回码退出的话,那么其最后执行命令的返回码将成为整个bash脚本退出的返回码。

当然,一个bash程序的退出还可能因为被中间打断而发生,这一般是因为进程接收到了一个需要程序退出的信号。比如我们日常使用的ctrl+c操作,就是给进程发送了一个2号SIGINT信号。考虑到程序退出可能性的各种可能,系统将错误返回码设计成1-255,这其中还分成两类:

  • 程序退出的返回码:1-127。这部分返回码一般用来作为给程序员自行设定错误退出用的返回码,比如:如果一个文件不存在,ls将返回2。如果要执行的命令不存在,则bash统一返回127
    。返回码125和126有特殊用处,一个是程序命令不存在的返回码,另一个是命令的文件在,但是不可执行的返回码。
  • 程序被信号打断的返回码:128-255。这部分系统习惯上是用来表示进程被信号打断的退出返回码的。一个进程如果被信号打断了,其退出返回码一般是128+信号编号的数字。

比如说,如果一个进程被2号信号打断的话,其返回码一般是128+2=130。如:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ sleep 1000
^C
[zorro@zorrozou-pc0 bash]$ echo $?
130

在执行sleep命令的过程中,我使用ctrl+c中断了进程的执行。此时返回值为130。可以用内建命令kill -l查看所有信号和其对应的编号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[zorro@zorrozou-pc0 bash]$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX

在我们编写bash脚本的时候,一般可以指定的返回码范围是1-124。建议大家养成编写返回码的编程习惯,但是系统并不对这做出限制,作为程序员你依然可以使用0-255的所有返回码。但是如果你滥用这些返回码,很可能会给未来程序的扩展造成不必要的麻烦。

最后

本文中我们描述了一个脚本的执行过程,从#!开始,到中间的解析过程,再到最后的退出返回码。希望这些对大家深入理解bash的执行过程和编写更高质量的脚本有帮助。通过本文我们明确了以下知识点:

  • 脚本开始的#!的作用原理。
  • bash的字符串解析过程。
  • 什么是alias。
  • 什么是关键字。
  • 什么是function。
  • 什么是内建命令,hash和外建命令以及它们的执行方法。
  • 如何退出一个bash脚本以及返回码的含义。

ansible使用

ansible可以作为自动化运维工具,一直以来都是知道有这东西,却没有尝试过,现在是时候把吹过的牛逼补回来了

1、简介

ansible是自动化运维工具,基于Python开发,集合了众多运维工具(puppet、cfengine、chef、func、fabric)的优点,实现了批量系统配置、批量程序部署、批量运行命令等功能。ansible是基于模块工作的,本身没有批量部署的能力。真正具有批量部署的是ansible所运行的模块,ansible只是提供一种框架。主要包括:

  • 连接插件connection plugins:负责和被监控端实现通信;

  • host inventory:指定操作的主机,是一个配置文件里面定义监控的主机;

  • 各种模块核心模块、command模块、自定义模块;

  • 借助于插件完成记录日志邮件等功能;

  • playbook:剧本执行多个任务时,非必需可以让节点一次性运行多个任务。

安装

略过了,使用pip,安装很简单

总体架构

特性

  • no agents:不需要在被管控主机上安装任何客户端;

  • no server:无服务器端,使用时直接运行命令即可;

  • modules in any languages:基于模块工作,可使用任意语言开发模块;

  • yaml,not code:使用yaml语言定制剧本playbook;

  • ssh by default:基于SSH工作;

  • strong multi-tier solution:可实现多级指挥。

优点

  • 轻量级,无需在客户端安装agent,更新时,只需在操作机上进行一次更新即可;
  • 批量任务执行可以写成脚本,而且不用分发到远程就可以执行;
  • 使用python编写,维护更简单,ruby语法过于复杂;
  • 支持sudo。

任务执行流程

使用

搭建多个可操作的host

没有机器,暂时用docker替代了
一个简单的sshd的Dockerfile

1
2
3
4
5
6
7
8
9
10
11
FROM ubuntu:14.04
MAINTAINER ericwang 123048591@qq.com
RUN apt-get update && apt-get install -y openssh-server \
&& sed -i 's/UsePAM yes/UsePAM no/g' /etc/ssh/sshd_config \
&& useradd ansible \
&& echo ansible:ansible |chpasswd \
&& echo "admin ALL=(ALL) ALL" >> /etc/sudoers \
&& mkdir /var/run/sshd
RUN ssh-keygen -t rsa -f /root/.ssh/id_rsa -P ""
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]

build脚本

1
2
#!/bin/bash
docker build --tag sshd-server ./

run脚本

1
2
3
4
5
#!/bin/bash
set -x
container_id=$(docker run -itd -P sshd-server)
cat ~/.ssh/id_rsa.pub | docker exec --user=root -i $container_id sh -c 'cat >> /root/.ssh/authorized_keys'
docker exec $container_id chmod 600 /root/.ssh/authorized_keys

练习的时候才发现docker exec使用流的时候有些问题,google看到这篇文章,直接使用exec command >> file这种形式是不行的,会把流输出到宿主系统,完全就是错误的。期待那位linux 大神继续更新shell相关的博文,更深入地理解linux

./build.sh 创建image,然后,./run.sh 这就创建了一个容器,也就是用来测试的ansible host。多次run后,就有了多个host机器了

1
2
3
4
➜ ~ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a57b8c69e513 sshd-server "/usr/sbin/sshd -D" 35 seconds ago Up 34 seconds 0.0.0.0:32784->22/tcp serene_saha

每一次run后都会产生一个容器,将端口22映射到主机的一个端口上,可以通过ssh登陆到docker容器中

1
2
3
4
5
6
➜ ~ ssh root@127.0.0.1 -p 32784
The authenticity of host '[127.0.0.1]:32784 ([127.0.0.1]:32784)' can't be established.
ECDSA key fingerprint is 6a:d1:27:e4:89:93:98:96:8f:02:f7:bf:6e:d7:eb:7b.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[127.0.0.1]:32784' (ECDSA) to the list of known hosts.
root@a57b8c69e513:~#

第一次登陆需要add到know hosts,以后就是免密码登陆了

启动了2个docker容器后,接下来是配置ansible了
默认hosts是在/etc/ansible/hosts,添加一组远程主机地址

1
2
3
4
➜ ~ cat /etc/ansible/hosts
[docker]
[docker]
127.0.0.1:32787 ansible_connection=ssh ansible_ssh_user=root

关于inventory的配置,这个博客写的很详尽

接下来尝试写playbook,这篇介绍的也不错。
写了一个简单的目录传输的playbook,很简单,功能很简单(其实就是发布系统的目录推送部分,其他功能待完善)
ansible目录下的两个文件,push_code.yml,update_git_code.sh

1
2
3
4
5
6
7
8
➜ ansible cat push_code.yml
---
- hosts: docker
remote_user: root
tasks:
- name: push data
copy: src=/data/git_code dest=/data/code/
owner=root group=root mode=0644

然后写一个简单的主机发送脚本吧

1
2
3
4
5
6
7
➜ ansible cat update_git_code.sh
#!/bin/bash
set -x
cd dirname $0
git_dir=/data/git_code/
git --git-dir=$git_dir.git/ --work-tree=$git_dir pull --rebase origin master:master \
&& ansible-playbook -check push_code.yml

/data/git_code是预先建立好的git代码仓库。这样就实现了一个简单的发布系统。后面有时间结合下之前做的docker-laravel,搞一个自动化的吧

ansible在github上也有关于lamp的例子ansible-examples

就先这样吧 以后有机会再继续接触

参考

自动化运维工具Ansible详细部署
ansible 官方文档

文章转自SHELL编程之语法基础

微博ID:orroz

微信公众号:Linux系统技术

前言

在此需要特别注明一下,本文叫做shell编程其实并不准确,更准确的说法是bash编程。考虑到bash的流行程度,姑且将bash编程硬说成shell编程也应没什么不可,但是请大家一定要清楚,shell编程绝不仅仅只是bash编程。

通过本文可以帮你解决以下问题:

  • if后面的中括号[]是语法必须的么?
  • 为什么bash编程中有时[]里面要加空格,有时不用加?如if [ -e /etc/passwd ]或ls [abc].sh。
  • 为什么有人写的脚本这样写:if [ x$test = x”string” ]?
  • 如何用*号作为通配符对目录树递归匹配?
  • 为什么在for循环中引用ls命令的输出是可能有问题的?就是说:for i in $(ls /)这样用有问题?

除了以上知识点以外,本文还试图帮助大家用一个全新的角度对bash编程的知识进行体系化。介绍shell编程传统的做法一般是要先说明什么是shell?什么是bash?这是种脚本语言,那么什么是脚本语言?不过这些内容真的太无聊了,我们快速掠过,此处省略3万字……作为一个实践性非常强的内容,我们直接开始讲语法。所以,这并不是一个入门内容,我们的要求是在看本文之前,大家至少要学会Linux的基本操作,并知道bash的一些基础知识。

if分支结构

组成一个语言的必要两种语法结构之一就是分支结构,另一种是循环结构。作为一个编程语言,bash也给我们提供了分支结构,其中最常用的就是if。用来进行程序的分支逻辑判断。其原型声明为:

1
if list; then list; elif list; then list; ... else list; fi

bash在解析字符的时候,对待“;”跟看见回车是一样的行为,所以这个语法也可以写成:

1
2
3
4
5
6
7
8
9
10
if list
then
list
elif list
then
list
...
else
list
fi

对于这个语法结构,需要重点说明的是list。对于绝大多数其他语言,if关键字后面一般跟的是一个表达式,比如C语言或类似语言的语法,if后面都是跟一个括号将表达式括起来,如:if (a > 0)。这种认识会对学习bash编程造成一些误会,很多初学者都认为bash编程的if语法结构是:if [ ];then…,但实际上这里的中括号[]并不是C语言中小括号()语法结构的类似的关键字。这里的中括号其实就是个shell命令,是test命令的另一种写法。严谨的叙述,if后面跟的就应该是个list。那么什么是bash中的list呢?根据bash的定义,list就是若干个使用管道,;,&,&&,||这些符号串联起来的shell命令序列,结尾可以;,&或换行结束。这个定义可能比较复杂,如果暂时不能理解,大家直接可以认为,if后面跟的就是个shell命令。换个角度说,bash编程仍然贯彻了C程序的设计哲学,即:一切皆表达式。

一切皆表达式这个设计原则,确定了shell在执行任何东西(注意是任何东西,不仅是命令)的时候都会有一个返回值,因为根据表达式的定义,任何表达式都必须有一个值。在bash编程中,这个返回值也限定了取值范围:0-255。跟C语言含义相反,bash中0为真(true),非0为假(false)。这就意味着,任何给bash之行的东西,都会反回一个值,在bash中,我们可以使用关键字$?来查看上一个执行命令的返回值:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 ~]$ ls /tmp/
plugtmp systemd-private-bfcfdcf97a4142e58da7d823b7015a1f-colord.service-312yQe systemd-private-bfcfdcf97a4142e58da7d823b7015a1f-systemd-timesyncd.service-zWuWs0 tracker-extract-files.1000
[zorro@zorrozou-pc0 ~]$ echo $?
0
[zorro@zorrozou-pc0 ~]$ ls /123
ls: cannot access '/123': No such file or directory
[zorro@zorrozou-pc0 ~]$ echo $?
2

可以看到,ls /tmp命令执行的返回值为0,即为真,说明命令执行成功,而ls /123时文件不存在,反回值为2,命令执行失败。我们再来看个更极端的例子:

1
2
3
4
[zorro@zorrozou-pc0 ~]$ abcdef
bash: abcdef: command not found
[zorro@zorrozou-pc0 ~]$ echo $?
127

我们让bash执行一个根本不存在的命令abcdef。反回值为127,依然为假,命令执行失败。复杂一点:

1
2
3
4
5
[zorro@zorrozou-pc0 ~]$ ls /123|wc -l
ls: cannot access '/123': No such file or directory
0
[zorro@zorrozou-pc0 ~]$ echo $?
0

这是一个list的执行,其实就是两个命令简单的用管道串起来。我们发现,这时shell会将整个list看作一个执行体,所以整个list就是一个表达式,那么最后只返回一个值0,这个值是整个list中最后一个命令的返回值,第一个命令执行失败并不影响后面的wc统计行数,所以逻辑上这个list执行成功,返回值为真。

理解清楚这一层意思,我们才能真正理解bash的语法结构中if后面到底可以判断什么?事实是,判断什么都可以,因为bash无非就是把if后面的无论什么当成命令去执行,并判断其起返回值是真还是假?如果是真则进入一个分支,为假则进入另一个。基于这个认识,我们可以来思考以下这个程序两种写法的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
DIR="/etc"
#第一种写法
ls -l $DIR &> /dev/null
ret=$?
if [ $ret -eq 0 ]
then
echo "$DIR is exist!"
else
echo "$DIR is not exist!"
fi
#第二种写法
if ls -l $DIR &> /dev/null
then
echo "$DIR is exist!"
else
echo "$DIR is not exist!"
fi

我曾经在无数的脚本中看到这里的第一种写法,先执行某个命令,然后记录其返回值,再使用[]进行分支判断。我想,这样写的人应该都是没有真正理解if语法的语义,导致做出了很多脱了裤子再放屁的事情。当然,if语法中后面最常用的命令就是[]。请注意我的描述中就是说[]是一个命令,而不是别的。实际上这也是bash编程新手容易犯错的地方之一,尤其是有其他编程经验的人,在一开始接触bash编程的时候都是将[]当成if语句的语法结构,于是经常在写[]的时候里面不写空格,即:

1
2
3
4
#正确的写法
if [ $ret -eq 0 ]
#错误的写法
if [$ret -eq 0]

同样的,当我们理解清楚了[]本质上是一个shell命令的时候,大家就知道这个错误是为什么了:命令加参数要用空格分隔。我们可以用type命令去检查一个命令:

1
2
[zorro@zorrozou-pc0 bash]$ type [
[ is a shell builtin

所以,实际上[]是一个内建命令,等同于test命令。所以上面的if语句也可以写成:

1
if test $ret -eq 0

这样看,形式上就跟第二种写法类似了。至于if分支怎么使用的其它例子就不再这废话了。重要的再强调一遍:if后面是一个命令(严格来说是list),并且记住一个原则:一切皆表达式。

“当”、“直到”循环结构

一般角度的讲解都会在讲完if分支结构之后讲其它分支结构,但是从执行特性和快速上手的角度来看,我认为先把跟if特性类似的while和until交代清楚更加合理。从字面上可以理解,while就是“当”型循环,指的是当条件成立时执行循环。,而until是直到型循环,其实跟while并没有实质上的区别,只是条件取非,逻辑变成循环到条件成立,或者说条件不成立时执行循环体。他们的语法结构是:

1
2
while list-1; do list-2; done
until list-1; do list-2; done

同样,分号可以理解为回车,于是常见写法是:

1
2
3
4
5
6
7
8
9
while list-1
do
list-2
done
until list-1
do
list-2
done

还是跟if语句一样,我们应该明白对与while和until的条件的含义,仍然是list。其判断条件是list,其执行结构也是list。理解了上面if的讲解,我想这里应该不用复述了。我们用while和unitl来产生一个0-99的数字序列:
while版:

1
2
3
4
5
6
7
8
9
#!/bin/bash
count=0
while [ $count -le 100 ]
do
echo $count
count=$[$count+1]
done

until版:

1
2
3
4
5
6
7
8
9
#!/bin/bash
count=0
until ! [ $count -le 100 ]
do
echo $count
count=$[$count+1]
done

我们通过这两个程序可以再次对比一下while和until到底有什么不一样?其实它们从形式上完全一样。这里另外说明两个知识点:

  • 在bash中,叹号(!)代表对命令(表达式)的返回值取反。就是说如果一个命令或list或其它什么东西如果返回值为真,
    加了叹号之后就是假,如果是假,加了叹号就是真。

  • 在bash中,使用$[]可以得到一个算数运算的值。可以支持常用的5则运算(+-*/%)。
    用法就是$[3+7]类似这样,而且要注意,这里的$[]里面没有空格分隔,因为它并不是个shell命令,而是特殊字符。

常见运算例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[zorro@zorrozou-pc0 bash]$ echo $[213+456]
669
[zorro@zorrozou-pc0 bash]$ echo $[213+456+789]
1458
[zorro@zorrozou-pc0 bash]$ echo $[213*456]
97128
[zorro@zorrozou-pc0 bash]$ echo $[213/456]
0
[zorro@zorrozou-pc0 bash]$ echo $[9/3]
3
[zorro@zorrozou-pc0 bash]$ echo $[9/2]
4
[zorro@zorrozou-pc0 bash]$ echo $[9%2]
1
[zorro@zorrozou-pc0 bash]$ echo $[144%7]
4
[zorro@zorrozou-pc0 bash]$ echo $[7-10]
-3

注意这个运算只支持整数,并且对与小数只娶其整数部分(没有四舍五入,小数全舍)。这个计算方法是bash提供的基础计算方法,如果想要实现更高级的计算可以使用let命令。如果想要实现浮点数运算,我一般使用awk来处理。

上面的例子中仍然使用[]命令(test)来作为检查条件,我们再试一个别的。假设我们想写一个脚本检查一台服务器是否能ping通?如果能ping通,则每隔一秒再看一次,如果发现ping不通了,就报警。如果什么时候恢复了,就再报告恢复。就是说这个脚本会一直检查服务器状态,ping失败则触发报警,ping恢复则通告恢复。脚本内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
IPADDR='10.0.0.1'
INTERVAL=1
while true
do
while ping -c 1 $IPADDR &> /dev/null
do
sleep $INTERVAL
done
echo "$IPADDR ping error! " 1>&2
until ping -c 1 $IPADDR &> /dev/null
do
sleep $INTERVAL
done
echo "$IPADDR ping ok!"
done

这里关于输出重定向的知识我就先不讲解了,后续会有别的文章专门针对这个主题做出说明。以上就是if分支结构和while、until循环结构。掌握了这两种结构之后,我们就可以写出几乎所有功能的bash脚本程序了。这两种语法结构的共同特点是,使用list作为“判断条件”,这种“风味”的语法特点是“一切皆表达式”。bash为了使用方便,还给我们提供了另外一些“风味”的语法。下面我们继续看:

case分支结构和for循环结构

我们之所以把case分支和for循环放在一起讨论,主要是因为它们所判断的不再是“表达式”是否为真,而是去匹配字符串。我们还是通过其语法和例子来理解一下。case分支的语法结构:

1
case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac

与if语句是以fi标记结束思路相仿,case语句是以esac标记结束。其常见的换行版本是:

1
2
3
4
5
6
7
8
9
10
11
case $1 in
pattern)
list
;;
pattern)
list
;;
pattern)
list
;;
esac

举几个几个简单的例子,并且它们实际上是一样的:

例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
case $1 in
(zorro)
echo "hello zorro!"
;;
(jerry)
echo "hello jerry!"
;;
(*)
echo "get out!"
;;
esac

例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
case $1 in
zorro)
echo "hello zorro!"
;;
jerry)
echo "hello jerry!"
;;
*)
echo "get out!"
;;
esac

例3:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
case $1 in
zorro|jerry)
echo "hello $1!"
;;
*)
echo "get out!"
;;
esac

这些程序的执行结果都是一样的:

1
2
3
4
5
6
[zorro@zorrozou-pc0 bash]$ ./case.sh zorro
hello zorro!
[zorro@zorrozou-pc0 bash]$ ./case.sh jerry
hello jerry!
[zorro@zorrozou-pc0 bash]$ ./case.sh xxxxxx
get out!

这些程序应该不难理解,无非就是几个语法的不一样之处,大家自己可以看到哪些可以省略,哪些不能省略。这里需要介绍一下的有两个概念:

  • $1在脚本中表示传递给脚本命令的第一个参数。关于这个变量以及其相关系列变量的使用,我们会在后续其它文章中介绍。
  • pattern就是bash中“通配符”的概念。常用的bash通配符包括星号(*)、问号(?)和其它一些字符。相信如果对bash有一定了解的话,对这些符号并不陌生,我们在此简单说明一下。

最常见的通配符有三个:

? 表示任意一个字符。这个没什么可说的。

  • 表示任意长度任意字符,包括空字符。在bash4.0以上版本中,如果bash环境开启了globstar设置,那么两个连续的**可以用来递归匹配某目录下所有的文件名。我们通过一个实验测试一下:

一个目录的结构如下:

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
[zorro@zorrozou-pc0 bash]$ tree test/
test/
├── 1
├── 2
├── 3
├── 4
├── a
│ ├── 1
│ ├── 2
│ ├── 3
│ └── 4
├── a.conf
├── b
│ ├── 1
│ ├── 2
│ ├── 3
│ └── 4
├── b.conf
├── c
│ ├── 5
│ ├── 6
│ ├── 7
│ └── 8
└── d
├── 1.conf
└── 2.conf
4 directories, 20 files

使用通配符进行文件名匹配:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ echo test/*
test/1 test/2 test/3 test/4 test/a test/a.conf test/b test/b.conf test/c test/d
[zorro@zorrozou-pc0 bash]$ echo test/*.conf
test/a.conf test/b.conf

这个结果大家应该都熟悉。我们再来看看下面:
查看当前globstar状态:

1
2
[zorro@zorrozou-pc0 bash]$ shopt globstar
globstar off

打开globstar:

1
2
3
[zorro@zorrozou-pc0 bash]$ shopt -s globstar
[zorro@zorrozou-pc0 bash]$ shopt globstar
globstar on

使用**匹配:

1
2
3
4
[zorro@zorrozou-pc0 bash]$ echo test/**
test/ test/1 test/2 test/3 test/4 test/a test/a/1 test/a/2 test/a/3 test/a/4 test/a.conf test/b test/b/1 test/b/2 test/b/3 test/b/4 test/b.conf test/c test/c/5 test/c/6 test/c/7 test/c/8 test/d test/d/1.conf test/d/2.conf
[zorro@zorrozou-pc0 bash]$ echo test/**/*.conf
test/a.conf test/b.conf test/d/1.conf test/d/2.conf

关闭globstart并再次测试**:

1
2
3
4
5
6
7
8
9
[zorro@zorrozou-pc0 bash]$ shopt -u globstar
[zorro@zorrozou-pc0 bash]$ shopt globstar
globstar off
[zorro@zorrozou-pc0 bash]$ echo test/**/*.conf
test/d/1.conf test/d/2.conf
[zorro@zorrozou-pc0 bash]$
[zorro@zorrozou-pc0 bash]$ echo test/**
test/1 test/2 test/3 test/4 test/a test/a.conf test/b test/b.conf test/c test/d

[…] 表示这个范围中的任意一个字符。比如[abcd],表示a或b或c或d。当然这也可以写成[a-d]。[a-z]表示任意一个小些字母。还是刚才的test目录,我们再来试试:

1
2
3
4
5
6
7
8
9
10
11
[zorro@zorrozou-pc0 bash]$ ls test/[123]
test/1 test/2 test/3
[zorro@zorrozou-pc0 bash]$ ls test/[abc]
test/a:
1 2 3 4
test/b:
1 2 3 4
test/c:
5 6 7 8

以上就是简单的三个通配符的说明。当然,关于通配符以及shopt命令还有很多相关知识。我们还是会在后续的文章中单独把相关知识点拿出来讲,再这里大家先理解这几个。另外需要强调一点,千万不要把bash的通配符和正则表达式搞混了,它们完全没有关系!

简要理解了pattern的概念之后,我们就可以更加灵活的使用case了,它不仅仅可以匹配一个固定的字符串,还可以利用pattern做到一定程度的模糊匹配。但是无论怎样,case都是去比较字符串是否一样,这跟使用if语句有本质的不同,if是判断表达式。当然,我们在if中使用test命令同样可以做到case的效果,区别仅仅是程序代码多少的区别。还是举个例子说明一下,我们想写一个身份验证程序,大家都知道,一个身份验证程序要判断用户名及其密码是否都匹配某一个字符串,如果两个都匹配,就通过验证,如果有一个不匹配就不能通过验证。分别用if和case来实现这两个验证程序内容如下:

if版:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
if [ $1 = "zorro" ] && [ $2 = "zorro" ]
then
echo "ok"
elif [ $1$2 = "jerryjerry" ]
then
echo "ok"
else
echo "auth failed!"
fi

case版:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
case $1$2 in
zorrozorro|jerryjerry)
echo "ok!"
;;
*)
echo "auth failed!"
;;
esac

两个程序一对比,直观看起来case版的要少些代码,表达力也更强一些。但是,这两个程序都有bug,如果case版程序给的两个参数是zorro zorro可以报ok。如果是zorroz orro是不是也可以报ok?如果只给一个参数zorrozorro,另一个参数为空,是不是也可以报ok?同样,if版的jerry判断也有类似问题。当你的程序要跟用户或其它程序交互的时候,一定要谨慎仔细的检查输入,一般写程序很大工作量都在做各种异常检查上,尤其是需要跟人交互的时候。我们看似用一个合并字符串变量的技巧,将两个判断给合并成了一个,但是这个技巧却使程序编写出了错误。对于这个现象,我的意见是,如果不是必要,请不要在编程中玩什么“技巧”,重剑无锋,大巧不工。当然,这个bug可以通过如下方式解决:

if版:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
if [ $1 = "zorro" ] && [ $2 = "zorro" ]
then
echo "ok"
elif [ $1:$2 = "jerry:jerry" ]
then
echo "ok"
else
echo "auth failed!"
fi

case版:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
case $1x$2 in
zorro:zorro|jerry:jerry)
echo "ok!"
;;
*)
echo "auth failed!"
;;
esac

我加的是个:字符,当然,也可以加其他字符,原则是这个字符不要再输入中能出现。我们在其他人写的程序里也经常能看到类似这样的判断处理:

if [ x$1 = x”zorro” ] && [ x$2 = x”zorro” ]

相信你也能明白为什么要这么处理了。仅对某一个判断来说这似乎没什么必要,但是如果你养成了这样的习惯,那么就能让你避免很多可能出问题的环节。这就是编程经验和编程习惯的重要性。当然,很多人只有“经验”,却也不知道这个经验是怎么来的,那也并不可取。

for循环结构

bash提供了两种for循环,一种是类似C语言的for循环,另一种是让某变量在一系列字符串中做循环。在此,我们先说后者。其语法结构是:

1
for name [ [ in [ word ... ] ] ; ] do list ; done

其中name一般是一个变量名,后面的word …是我们要让这个变量分别赋值的字符串列表。这个循环将分别将name变量每次赋值一个word,并执行循环体,直到所有word被遍历之后退出循环。这是一个非常有用的循环结构,其使用频率可能远高于while、until循环。我们来看看例子:

1
2
3
4
5
6
[zorro@zorrozou-pc0 bash]$ for i in 1 2 3 4 5;do echo $i;done
1
2
3
4
5

再看另外一个例子:

1
2
3
4
5
6
[zorro@zorrozou-pc0 bash]$ for i in aaa bbb ccc ddd eee;do echo $i;done
aaa
bbb
ccc
ddd
eee

再看一个:

1
2
3
4
5
6
7
8
[zorro@zorrozou-pc0 bash]$ for i in /etc/* ;do echo $i;done
/etc/adjtime
/etc/adobe
/etc/appstream.conf
/etc/arch-release
/etc/asound.conf
/etc/avahi
......

这种例子举不胜举,可以用for遍历的东西真的很多,大家可以自己发挥想象力。这里要提醒大家注意的是当你学会了“或$()这个符号之后,for的范围就更大了。于是很多然喜欢这样搞:

1
2
3
4
5
6
7
8
9
[zorro@zorrozou-pc0 bash]$ for i in `ls`;do echo $i;done
auth_case.sh
auth_if.sh
case.sh
if_1.sh
ping.sh
test
until.sh
while.sh

乍看起来这好像跟使用*没啥区别:

1
2
3
4
5
6
7
8
9
[zorro@zorrozou-pc0 bash]$ for i in *;do echo $i;done
auth_case.sh
auth_if.sh
case.sh
if_1.sh
ping.sh
test
until.sh
while.sh

但可惜的是并不总是这样,请对比如下两个测试:

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
[zorro@zorrozou-pc0 bash]$ for i in `ls /etc`;do echo $i;done
adjtime
adobe
appstream.conf
arch-release
asound.conf
avahi
bash.bash_logout
bash.bashrc
bind.keys
binfmt.d
......
[zorro@zorrozou-pc0 bash]$ for i in /etc/*;do echo $i;done
/etc/adjtime
/etc/adobe
/etc/appstream.conf
/etc/arch-release
/etc/asound.conf
/etc/avahi
/etc/bash.bash_logout
/etc/bash.bashrc
/etc/bind.keys
/etc/binfmt.d
......

看到差别了么?

其实这里还会隐含很多其它问题,像ls这样的命令很多时候是设计给人用的,它的很多显示是有特殊设定的,可能并不是纯文本。比如可能包含一些格式化字符,也可能包含可以让终端显示出颜色的标记字符等等。当我们在程序里面使用类似这样的命令的时候要格外小心,说不定什么时候在什么不同环境配置的系统上,你的程序就会有意想不到的异常出现,到时候排查起来非常麻烦。所以这里我们应该尽量避免使用ls这样的命令来做类似的行为,用通配符可能更好。当然,如果你要操作的是多层目录文件的话,那么ls就更不能帮你的忙了,它遇到目录之后显示成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[zorro@zorrozou-pc0 bash]$ ls /etc/*
/etc/adobe:
mms.cfg
/etc/avahi:
avahi-autoipd.action avahi-daemon.conf avahi-dnsconfd.action hosts services
/etc/binfmt.d:
/etc/bluetooth:
main.conf
/etc/ca-certificates:
extracted trust-source

所以遍历一个目录还是要用刚才说到的**,如果不是bash 4.0之后的版本的话,可以使用find。我推荐用find,因为它更通用。有时候你会发现,使用find之后,绝大多数原来需要写脚本解决的问题可能都用不着了,一个find命令解决很多问题。

select和第二种for循环

我之所以把这两种语法放到一起讲,主要是这两种语法结构在bash编程中使用的几率可能较小。这里的第二种for循环是相对于上面讲的第一种for循环来说的。实际上这种for循环就是C语言中for循环的翻版,其语义基本一致,区别是括号()变成了双括号(()),循环标记开始和结束也是bash风味的do和done,其语法结构为:

1
for (( expr1 ; expr2 ; expr3 )) ; do list ; done

看一个产生0-99数字的循环例子:

1
2
3
4
5
6
#!/bin/bash
for ((count=0;count<100;count++))
do
echo $count
done

我们可以理解为,bash为了对数学运算作为条件的循环方便我们使用,专门扩展了一个for循环来给我们使用。跟C语言一样,这个循环本质上也只是一个while循环,只是把变量初始化,变量比较和循环体中的变量操作给放到了同一个(())语句中。这里不再废话。

最后是select循环,实际上select提供给了我们一个构建交互式菜单程序的方式,如果没有select的话,我们在shell中写交互的菜单程序是比较麻烦的。它的语法结构是:

1
select name [ in word ] ; do list ; done

还是来看看例子:

1
2
3
4
5
6
#!/bin/bash
select i in a b c d
do
echo $i
done

这个程序执行的效果是:

1
2
3
4
5
6
[zorro@zorrozou-pc0 bash]$ ./select.sh
1) a
2) b
3) c
4) d
#?

你会发现select给你构造了一个交互菜单,索引为1,2,3,4。对应的名字就是程序中的a,b,c,d。之后我们就可以在后面输入相应的数字索引,选择要echo的内容:

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
[zorro@zorrozou-pc0 bash]$ ./select.sh
1) a
2) b
3) c
4) d
#? 1
a
#? 2
b
#? 3
c
#? 4
d
#? 6
#?
1) a
2) b
3) c
4) d
#?
1) a
2) b
3) c
4) d
#?

如果输入的不是菜单描述的范围就会echo一个空行,如果直接输入回车,就会再显示一遍菜单本身。当然我们会发现这样一个菜单程序似乎没有什么意义,实际程序中,select大多数情况是跟case配合使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
select i in a b c d
do
case $i in
a)
echo "Your choice is a"
;;
b)
echo "Your choice is b"
;;
c)
echo "Your choice is c"
;;
d)
echo "Your choice is d"
;;
*)
echo "Wrong choice! exit!"
exit
;;
esac
done

执行结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[zorro@zorrozou-pc0 bash]$ ./select.sh
1) a
2) b
3) c
4) d
#? 1
Your choice is a
#? 2
Your choice is b
#? 3
Your choice is c
#? 4
Your choice is d
#? 5
Wrong choice! exit!

这就是select的常见用法。

continue和break

对于bash的实现来说,continue和break实际上并不是语法的关键字,而是被作为内建命令来实现的。不过我们从习惯上依然把它们看作是bash的语法。在bash中,break和continue可以用来跳出和金星下一次for,while,until和select循环。
最后

我们在本文中介绍了bash编程的常用语法结构:if、while、until、case、两种for和select。我们在详细分析它们语法的特点的过程中,也简单说明了使用时需要注意的问题。希望这些知识和经验对大家以后在bash编程上有帮助。

通过bash编程语法的入门,我们也能发现,bash编程是一个上手容易,但是精通困难的编程语言。任何人想要写个简单的脚本,掌握几个语法结构和几个shell命令基本就可以干活了,但是想写出高质量的代码确没那么容易。通过语法的入门,我们可以管中窥豹的发现,讲述的过程中有无数个可以深入探讨的细节知识点,比如通配符、正则表达式、bash的特殊字符、bash的特殊属性和很多shell命令的使用。我们的后续文章会给大家分块整理这些知识点,如果你有兴趣,请持续关注。

php查找符合某些规则的文件

通常实现查找目录下某些规则的文件,用shell实现起来很容易,但是php呢,个人想到的是遍历目录下所有文件并再用正则筛选.
然而php有现成的函数已经为我们做好了

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
说明
array glob ( string $pattern [, int $flags = 0 ] )
glob() 函数依照 libc glob() 函数使用的规则寻找所有与 pattern 匹配的文件路径,类似于一般 shells 所用的规则一样。不进行缩写扩展或参数替代。
参数
pattern
The pattern. No tilde expansion or parameter substitution is done.
flags
有效标记有:
GLOB_MARK - 在每个返回的项目中加一个斜线
GLOB_NOSORT - 按照文件在目录中出现的原始顺序返回(不排序)
GLOB_NOCHECK - 如果没有文件匹配则返回用于搜索的模式
GLOB_NOESCAPE - 反斜线不转义元字符
GLOB_BRACE - 扩充 {a,b,c} 来匹配 'a','b' 或 'c'
GLOB_ONLYDIR - 仅返回与模式匹配的目录项
GLOB_ERR - 停止并读取错误信息(比如说不可读的目录),默认的情况下忽略所有错误
返回值
返回一个包含有匹配文件/目录的数组。如果出错返回 FALSE。
Note:
On some systems it is impossible to distinguish between empty match and an error.

比如查找某目录下的txt文件并显示其大小

1
2
3
4
5
6
<?php
foreach (glob("*.txt") as $filename) {
echo "$filename size " . filesize($filename) . "\n";
}
?>

问题

最近发布系统出现了一些速度问题,而且时好时坏,网络非常不稳定,查了一下,发现代码里出现很多file_get_contents函数

比较

写两个简单的例子吧
curl

1
2
3
4
5
6
7
8
$i= 0;
do{
$t1 = microtime();
$url = "http://www.anjuke.com";
file_get_contents($url);
$t2 = microtime();
echo (($t2-$t1)<0?($t2-$t1)+1:($t2-$t1)).PHP_EOL;
}while($i++<2);

file_get_contents

1
2
3
4
5
6
7
8
$i= 0;
do{
$t1 = microtime();
$url = "http://www.anjuke.com";
file_get_contents($url);
$t2 = microtime();
echo (($t2-$t1)<0?($t2-$t1)+1:($t2-$t1)).PHP_EOL;
}while($i++<20);

测试了一下,速度相差一个数量级,为何相差如此悬殊,查了些资料,原来file_get_contents会刷新dns缓存。
使用strace 追踪,发现其会读取/etc/hosts 和/etc/resolv.conf 文件,找到dns server,而curl不会。

此外,curl相对自由一些,file_get_contents不支持post请求等等

相关参考 :

fsockopen/curl/file_get_contents效率比较

文章转自MySQL索引背后的数据结构及算法原理
一些代码和图片没有原文做的好,如果原文没有失效的话请选择访问原文链接

摘要

本文以MySQL数据库为研究对象,讨论与数据库索引相关的一些话题。特别需要说明的是,MySQL支持诸多存储引擎,而各种存储引擎对索引的支持也各不相同,因此MySQL数据库支持多种索引类型,如BTree索引,哈希索引,全文索引等等。为了避免混乱,本文将只关注于BTree索引,因为这是平常使用MySQL时主要打交道的索引,至于哈希索引和全文索引本文暂不讨论。

文章主要内容分为三个部分。

第一部分主要从数据结构及算法理论层面讨论MySQL数据库索引的数理基础。

第二部分结合MySQL数据库中MyISAM和InnoDB数据存储引擎中索引的架构实现讨论聚集索引、非聚集索引及覆盖索引等话题。

第三部分根据上面的理论基础,讨论MySQL中高性能使用索引的策略。

数据结构及算法基础

索引的本质

MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构。提取句子主干,就可以得到索引的本质:索引是数据结构。

我们知道,数据库查询是数据库的最主要功能之一。我们都希望查询数据的速度能尽可能的快,因此数据库系统的设计者会从查询算法的角度进行优化。最基本的查询算法当然是顺序查找(linear search),这种复杂度为O(n)的算法在数据量很大时显然是糟糕的,好在计算机科学的发展提供了很多更优秀的查找算法,例如二分查找(binary search)、二叉树查找(binary tree search)等。如果稍微分析一下会发现,每种查找算法都只能应用于特定的数据结构之上,例如二分查找要求被检索数据有序,而二叉树查找只能应用于二叉查找树上,但是数据本身的组织结构不可能完全满足各种数据结构(例如,理论上不可能同时将两列都按顺序进行组织),所以,在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法。这种数据结构,就是索引。
看一个例子:
图一
图1展示了一种可能的索引方式。左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。为了加快Col2的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据记录物理地址的指针,这样就可以运用二叉查找在O(log2n)
Olog2n
的复杂度内获取到相应数据。

虽然这是一个货真价实的索引,但是实际的数据库系统几乎没有使用二叉查找树或其进化品种红黑树(red-black tree)实现的,原因会在下文介绍。

B-Tree和B+Tree

目前大部分数据库系统及文件系统都采用B-Tree或其变种B+Tree作为索引结构,在本文的下一节会结合存储器原理及计算机存取原理讨论为什么B-Tree和B+Tree在被如此广泛用于索引,这一节先单纯从数据结构角度描述它们。

B-Tree

为了描述B-Tree,首先定义一条数据记录为一个二元组[key, data],key为记录的键值,对于不同数据记录,key是互不相同的;data为数据记录除key外的数据。那么B-Tree是满足下列条件的数据结构:

  • d为大于1的一个正整数,称为B-Tree的度。

  • h为一个正整数,称为B-Tree的高度。

  • 每个非叶子节点由n-1个key和n个指针组成,其中d<=n<=2d。

  • 每个叶子节点最少包含一个key和两个指针,最多包含2d-1个key和2d个指针,叶节点的指针均为null 。

  • 所有叶节点具有相同的深度,等于树高h。

  • key和指针互相间隔,节点两端是指针。

  • 一个节点中的key从左到右非递减排列。

  • 所有节点组成树结构。

  • 每个指针要么为null,要么指向另外一个节点。

  • 如果某个指针在节点node最左边且不为null,则其指向节点的所有key小于v(key1),其中v(key1)为node的第一个key的值。

  • 如果某个指针在节点node最右边且不为null,则其指向节点的所有key大于v(keym),其中v(keym)为node的最后一个key的值。

  • 如果某个指针在节点node的左右相邻key分别是keyi和keyi+1且不为null,则其指向节点的所有key小于v(keyi+1)且大于v(keyi)

图2是一个d=2的B-Tree示意图。
图2

由于B-Tree的特性,在B-Tree中按key检索数据的算法非常直观:首先从根节点进行二分查找,如果找到则返回对应节点的data,否则对相应区间的指针指向的节点递归进行查找,直到找到节点或找到null指针,前者查找成功,后者查找失败。B-Tree上查找算法的伪代码如下:

1
2
3
4
5
6
7
8
9
10
BTree_Search(node, key) {
if(node == null) return null;
foreach(node.key)
{
if(node.key[i] == key) return node.data[i];
if(node.key[i] > key) return BTree_Search(point[i]->node);
}
return BTree_Search(point[i+1]->node);
}
data = BTree_Search(root, my_key);

关于B-Tree有一系列有趣的性质,例如一个度为d的B-Tree,设其索引N个key,则其树高h的上限为logd((N+1)/2),检索一个key,其查找节点个数的渐进复杂度为O(logdN)。从这点可以看出,B-Tree是一个非常有效率的索引数据结构。
另外,由于插入删除新的数据记录会破坏B-Tree的性质,因此在插入删除时,需要对树进行一个分裂、合并、转移等操作以保持B-Tree性质,本文不打算完整讨论B-Tree这些内容,因为已经有许多资料详细说明了B-Tree的数学性质及插入删除算法,有兴趣的朋友可以在本文末的参考文献一栏找到相应的资料进行阅读。

B+Tree

B-Tree有许多变种,其中最常见的是B+Tree,例如MySQL就普遍使用B+Tree实现其索引结构。

与B-Tree相比,B+Tree有以下不同点:

每个节点的指针上限为2d而不是2d+1。

内节点不存储data,只存储key;叶子节点不存储指针。

图3是一个简单的B+Tree示意。

图三

由于并不是所有节点都具有相同的域,因此B+Tree中叶节点和内节点一般大小不同。这点与B-Tree不同,虽然B-Tree中不同节点存放的key和指针可能数量不一致,但是每个节点的域和上限是一致的,所以在实现中B-Tree往往对每个节点申请同等大小的空间。

一般来说,B+Tree比B-Tree更适合实现外存储索引结构,具体原因与外存储器原理及计算机存取原理有关,将在下面讨论。

带有顺序访问指针的B+Tree

一般在数据库系统或文件系统中使用的B+Tree结构都在经典B+Tree的基础上进行了优化,增加了顺序访问指针。

图4

如图4所示,在B+Tree的每个叶子节点增加一个指向相邻叶子节点的指针,就形成了带有顺序访问指针的B+Tree。做这个优化的目的是为了提高区间访问的性能,例如图4中如果要查询key为从18到49的所有数据记录,当找到18后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提到了区间查询效率。
这一节对B-Tree和B+Tree进行了一个简单的介绍,下一节结合存储器存取原理介绍为什么目前B+Tree是数据库系统实现索引的首选数据结构。

为什么使用B-Tree(B+Tree)

上文说过,红黑树等数据结构也可以用来实现索引,但是文件系统及数据库系统普遍采用B-/+Tree作为索引结构,这一节将结合计算机组成原理相关知识讨论B-/+Tree作为索引的理论基础。

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。下面先介绍内存和磁盘存取原理,然后再结合这些原理分析B-/+Tree作为索引的效率。

主存存取原理

目前计算机使用的主存基本都是随机读写存储器(RAM),现代RAM的结构和存取原理比较复杂,这里本文抛却具体差别,抽象出一个十分简单的存取模型来说明RAM的工作原理。

图5

从抽象角度看,主存是一系列的存储单元组成的矩阵,每个存储单元存储固定大小的数据。每个存储单元有唯一的地址,现代主存的编址规则比较复杂,这里将其简化成一个二维地址:通过一个行地址和一个列地址可以唯一定位到一个存储单元。图5展示了一个4 x 4的主存模型。

主存的存取过程如下:

当系统需要读取主存时,则将地址信号放到地址总线上传给主存,主存读到地址信号后,解析信号并定位到指定存储单元,然后将此存储单元数据放到数据总线上,供其它部件读取。

写主存的过程类似,系统将要写入单元地址和数据分别放在地址总线和数据总线上,主存读取两个总线的内容,做相应的写操作。

这里可以看出,主存存取的时间仅与存取次数呈线性关系,因为不存在机械操作,两次存取的数据的“距离”不会对时间有任何影响,例如,先取A0再取A1和先取A0再取D3的时间消耗是一样的。

磁盘存取原理

上文说过,索引一般以文件形式存储在磁盘上,索引检索需要磁盘I/O操作。与主存不同,磁盘I/O存在机械运动耗费,因此磁盘I/O的时间消耗是巨大的。

图6是磁盘的整体结构示意图。

图6

一个磁盘由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘必须同步转动)。在磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不能转动,但是可以沿磁盘半径方向运动(实际是斜切向运动),每个磁头同一时刻也必须是同轴的,即从正上方向下看,所有磁头任何时候都是重叠的(不过目前已经有多磁头独立技术,可不受此限制)。

图7是磁盘结构的示意图。

图7

盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道,所有半径相同的磁道组成一个柱面。磁道被沿半径线划分成一个个小的段,每个段叫做一个扇区,每个扇区是磁盘的最小存储单元。为了简单起见,我们下面假设磁盘只有一个盘片和一个磁头。

当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。

局部性原理与磁盘预读

由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,磁盘的存取速度往往是主存的几百分分之一,因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:

当一个数据被用到时,其附近的数据也通常会马上被使用。

程序运行期间所需要的数据通常比较集中。

由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。

预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。

B-/+Tree索引的性能分析

到这里终于可以分析B-/+Tree索引的性能了。

上文说过一般使用磁盘I/O次数评价索引结构的优劣。先从B-Tree分析,根据B-Tree的定义,可知检索一次最多需要访问h个节点。数据库系统的设计者巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:

每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。

B-Tree中一次检索最多需要h-1次I/O(根节点常驻内存),渐进复杂度为O(h)=O(logdN)。一般实际应用中,出度d是非常大的数字,通常超过100,因此h非常小(通常不超过3)。

综上所述,用B-Tree作为索引结构效率是非常高的。

而红黑树这种结构,h明显要深的多。由于逻辑上很近的节点(父子)物理上可能很远,无法利用局部性,所以红黑树的I/O渐进复杂度也为O(h),效率明显比B-Tree差很多。

上文还说过,B+Tree更适合外存索引,原因和内节点出度d有关。从上面分析可以看到,d越大索引的性能越好,而出度的上限取决于节点内key和data的大小:
dmax=floor(pagesize/(keysize+datasize+pointsize))

floor表示向下取整。由于B+Tree内节点去掉了data域,因此可以拥有更大的出度,拥有更好的性能。

这一章从理论角度讨论了与索引相关的数据结构与算法问题,下一章将讨论B+Tree是如何具体实现为MySQL中索引,同时将结合MyISAM和InnDB存储引擎介绍非聚集索引和聚集索引两种不同的索引实现形式。

MySQL索引实现

在MySQL中,索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的,本文主要讨论MyISAM和InnoDB两个存储引擎的索引实现方式。

MyISAM索引实现

MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址。下图是MyISAM索引的原理图:

图8

这里设表一共有三列,假设我们以Col1为主键,则图8是一个MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果我们在Col2上建立一个辅助索引,则此索引的结构如下图所示:

图9

同样也是一颗B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。

MyISAM的索引方式也叫做“非聚集”的,之所以这么称呼是为了与InnoDB的聚集索引区分。

InnoDB索引实现

虽然InnoDB也使用B+Tree作为索引结构,但具体实现方式却与MyISAM截然不同。

第一个重大区别是InnoDB的数据文件本身就是索引文件。从上文知道,MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。

图10

图10是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录。这种索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整形。

第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。换句话说,InnoDB的所有辅助索引都引用主键作为data域。例如,图11为定义在Col3上的一个辅助索引:

图11

这里以英文字符的ASCII码作为比较准则。聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。

了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助,例如知道了InnoDB的索引实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。

下一章将具体讨论这些与索引有关的优化策略。

索引使用策略及优化

MySQL的优化主要分为结构优化(Scheme optimization)和查询优化(Query optimization)。本章讨论的高性能索引策略主要属于结构优化范畴。本章的内容完全基于上文的理论基础,实际上一旦理解了索引背后的机制,那么选择高性能的策略就变成了纯粹的推理,并且可以理解这些策略背后的逻辑

示例数据库

为了讨论索引策略,需要一个数据量不算小的数据库作为示例。本文选用MySQL官方文档中提供的示例数据库之一:employees。这个数据库关系复杂度适中,且数据量较大。下图是这个数据库的E-R关系图(引用自MySQL官方手册):

MySQL官方文档中关于此数据库的页面为http://dev.mysql.com/doc/employee/en/employee.html。
里面详细介绍了此数据库,并提供了下载地址和导入方法,如果有兴趣导入此数据库到自己的MySQL可以参考文中内容。

最左前缀原理与相关优化

高效使用索引的首要条件是知道什么样的查询会使用到索引,这个问题和B+Tree中的“最左前缀原理”有关,下面通过例子说明最左前缀原理。

这里先说一下联合索引的概念。在上文中,我们都是假设索引只引用了单个的列,实际上,MySQL中的索引可以以一定顺序引用多个列,这种索引叫做联合索引,一般的,一个联合索引是一个有序元组,其中各个元素均为数据表的一列,实际上要严格定义索引需要用到关系代数,但是这里我不想讨论太多关系代数的话题,因为那样会显得很枯燥,所以这里就不再做严格定义。另外,单列索引可以看成联合索引元素数为1的特例。

以employees.titles表为例,下面先查看其上都有哪些索引:

1
2
3
4
5
6
7
8
9
SHOW INDEX FROM employees.titles;
+--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Null | Index_type |
+--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
| titles | 0 | PRIMARY | 1 | emp_no | A | NULL | | BTREE |
| titles | 0 | PRIMARY | 2 | title | A | NULL | | BTREE |
| titles | 0 | PRIMARY | 3 | from_date | A | 443308 | | BTREE |
| titles | 1 | emp_no | 1 | emp_no | A | 443308 | | BTREE |
+--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+

从结果中可以到titles表的主索引为,还有一个辅助索引。为了避免多个索引使事情变复杂(MySQL的SQL优化器在多索引时行为比较复杂),这里我们将辅助索引drop掉:

1
2
ALTER TABLE employees.titles DROP INDEX emp_no;
这样就可以专心分析索引PRIMARY的行为了。

情况一:全列匹配。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26';
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
| 1 | SIMPLE | titles | const | PRIMARY | PRIMARY | 59 | const,const,const | 1 | |
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

很明显,当按照索引中所有列进行精确匹配(这里精确匹配指“=”或“IN”匹配)时,索引可以被用到。这里有一点需要注意,理论上索引对顺序是敏感的,但是由于MySQL的查询优化器会自动调整where子句的条件顺序以使用适合的索引,例如我们将where中的条件顺序颠倒:

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26' AND emp_no='10001' AND title='Senior Engineer';
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
| 1 | SIMPLE | titles | const | PRIMARY | PRIMARY | 59 | const,const,const | 1 | |
+----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

效果是一样的。

情况二:最左前缀匹配。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001';
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
| 1 | SIMPLE | titles | ref | PRIMARY | PRIMARY | 4 | const | 1 | |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------+

当查询条件精确匹配索引的左边连续一个或几个列时,如,所以可以被用到,但是只能用到一部分,即条件所组成的最左前缀。上面的查询从分析结果看用到了PRIMARY索引,但是key_len为4,说明只用到了索引的第一列前缀。

情况三:查询条件用到了索引中列的精确匹配,但是中间某个条件未提供。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26';
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
| 1 | SIMPLE | titles | ref | PRIMARY | PRIMARY | 4 | const | 1 | Using where |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

此时索引使用情况和情况二相同,因为title未提供,所以查询只用到了索引的第一列,而后面的from_date虽然也在索引中,但是由于title不存在而无法和左前缀连接,因此需要对结果进行扫描过滤from_date(这里由于emp_no唯一,所以不存在扫描)。如果想让from_date也使用索引而不是where过滤,可以增加一个辅助索引,此时上面的查询会使用这个索引。除此之外,还可以使用一种称之为“隔离列”的优化方法,将emp_no与from_date之间的“坑”填上。

首先我们看下title一共有几种不同的值:

1
2
3
4
5
6
7
8
9
10
11
12
SELECT DISTINCT(title) FROM employees.titles;
+--------------------+
| title |
+--------------------+
| Senior Engineer |
| Staff |
| Engineer |
| Senior Staff |
| Assistant Engineer |
| Technique Leader |
| Manager |
+--------------------+

只有7种。在这种成为“坑”的列值比较少的情况下,可以考虑用“IN”来填补这个“坑”从而形成最左前缀:

1
2
3
4
5
6
7
8
9
EXPLAIN SELECT * FROM employees.titles
WHERE emp_no='10001'
AND title IN ('Senior Engineer', 'Staff', 'Engineer', 'Senior Staff', 'Assistant Engineer', 'Technique Leader', 'Manager')
AND from_date='1986-06-26';
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | titles | range | PRIMARY | PRIMARY | 59 | NULL | 7 | Using where |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

这次key_len为59,说明索引被用全了,但是从type和rows看出IN实际上执行了一个range查询,这里检查了7个key。看下两种查询的性能比较:

1
2
3
4
5
6
7
SHOW PROFILES;
+----------+------------+-------------------------------------------------------------------------------+
| Query_ID | Duration | Query |
+----------+------------+-------------------------------------------------------------------------------+
| 10 | 0.00058000 | SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26'|
| 11 | 0.00052500 | SELECT * FROM employees.titles WHERE emp_no='10001' AND title IN ... |
+----------+------------+-------------------------------------------------------------------------------+

“填坑”后性能提升了一点。如果经过emp_no筛选后余下很多数据,则后者性能优势会更加明显。当然,如果title的值很多,用填坑就不合适了,必须建立辅助索引。

情况四:查询条件没有指定索引第一列。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26';
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
| 1 | SIMPLE | titles | ALL | NULL | NULL | NULL | NULL | 443308 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+

由于不是最左前缀,索引这样的查询显然用不到索引。

情况五:匹配某列的前缀字符串。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title LIKE 'Senior%';
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | titles | range | PRIMARY | PRIMARY | 56 | NULL | 1 | Using where |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

此时可以用到索引,但是如果通配符不是只出现在末尾,则无法使用索引。(原文表述有误,如果通配符%不出现在开头,则可以用到索引,但根据具体情况不同可能只会用其中一个前缀)

情况六:范围查询。

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no < '10010' and title='Senior Engineer';
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | titles | range | PRIMARY | PRIMARY | 4 | NULL | 16 | Using where |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

范围列可以用到索引(必须是最左前缀),但是范围列后面的列无法用到索引。同时,索引最多用于一个范围列,因此如果查询条件中有两个范围列则无法全用到索引。

1
2
3
4
5
6
7
8
9
10
EXPLAIN SELECT * FROM employees.titles
WHERE emp_no < '10010'
AND title='Senior Engineer'
AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | titles | range | PRIMARY | PRIMARY | 4 | NULL | 16 | Using where |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

可以看到索引对第二个范围索引无能为力。这里特别要说明MySQL一个有意思的地方,那就是仅用explain可能无法区分范围索引和多值匹配,因为在type中这两者都显示为range。同时,用了“between”并不意味着就是范围查询,例如下面的查询:

1
2
3
4
5
6
7
8
9
EXPLAIN SELECT * FROM employees.titles
WHERE emp_no BETWEEN '10001' AND '10010'
AND title='Senior Engineer'
AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | titles | range | PRIMARY | PRIMARY | 59 | NULL | 16 | Using where |
+----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

看起来是用了两个范围查询,但作用于emp_no上的“BETWEEN”实际上相当于“IN”,也就是说emp_no实际是多值精确匹配。可以看到这个查询用到了索引全部三个列。因此在MySQL中要谨慎地区分多值匹配和范围匹配,否则会对MySQL的行为产生困惑。

情况七:查询条件中含有函数或表达式。

很不幸,如果查询条件中含有函数或表达式,则MySQL不会为这列使用索引(虽然某些在数学意义上可以使用)。例如:

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND left(title, 6)='Senior';
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
| 1 | SIMPLE | titles | ref | PRIMARY | PRIMARY | 4 | const | 1 | Using where |
+----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

虽然这个查询和情况五中功能相同,但是由于使用了函数left,则无法为title列应用索引,而情况五中用LIKE则可以。再如:

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.titles WHERE emp_no - 1='10000';
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
| 1 | SIMPLE | titles | ALL | NULL | NULL | NULL | NULL | 443308 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+--------+-------------+

显然这个查询等价于查询emp_no为10001的函数,但是由于查询条件是一个表达式,MySQL无法为其使用索引。看来MySQL还没有智能到自动优化常量表达式的程度,因此在写查询语句时尽量避免表达式出现在查询中,而是先手工私下代数运算,转换为无表达式的查询语句。

索引选择性与前缀索引

既然索引可以加快查询速度,那么是不是只要是查询语句需要,就建上索引?答案是否定的。因为索引虽然加快了查询速度,但索引也是有代价的:索引文件本身要消耗存储空间,同时索引会加重插入、删除和修改记录时的负担,另外,MySQL在运行时也要消耗资源维护索引,因此索引并不是越多越好。一般两种情况下不建议建索引。

第一种情况是表记录比较少,例如一两千条甚至只有几百条记录的表,没必要建索引,让查询做全表扫描就好了。至于多少条记录才算多,这个个人有个人的看法,我个人的经验是以2000作为分界线,记录数不超过 2000可以考虑不建索引,超过2000条可以酌情考虑索引。

另一种不建议建索引的情况是索引的选择性较低。所谓索引的选择性(Selectivity),是指不重复的索引值(也叫基数,Cardinality)与表记录数(#T)的比值:

1
Index Selectivity = Cardinality / #T

显然选择性的取值范围为(0, 1],选择性越高的索引价值越大,这是由B+Tree的性质决定的。例如,上文用到的employees.titles表,如果title字段经常被单独查询,是否需要建索引,我们看一下它的选择性:

1
2
3
4
5
6
SELECT count(DISTINCT(title))/count(*) AS Selectivity FROM employees.titles;
+-------------+
| Selectivity |
+-------------+
| 0.0000 |
+-------------+

title的选择性不足0.0001(精确值为0.00001579),所以实在没有什么必要为其单独建索引。

有一种与索引选择性有关的索引优化策略叫做前缀索引,就是用列的前缀代替整个列作为索引key,当前缀长度合适时,可以做到既使得前缀索引的选择性接近全列索引,同时因为索引key变短而减少了索引文件的大小和维护开销。下面以employees.employees表为例介绍前缀索引的选择和使用。

从图12可以看到employees表只有一个索引,那么如果我们想按名字搜索一个人,就只能全表扫描了:

1
2
3
4
5
6
EXPLAIN SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido';
+----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+
| 1 | SIMPLE | employees | ALL | NULL | NULL | NULL | NULL | 300024 | Using where |
+----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+

如果频繁按名字搜索员工,这样显然效率很低,因此我们可以考虑建索引。有两种选择,建,看下两个索引的选择性:

1
2
3
4
5
6
7
8
9
10
11
12
SELECT count(DISTINCT(first_name))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
| 0.0042 |
+-------------+
SELECT count(DISTINCT(concat(first_name, last_name)))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
| 0.9313 |
+-------------+

显然选择性太低,选择性很好,但是first_name和last_name加起来长度为30,有没有兼顾长度和选择性的办法?可以考虑用first_name和last_name的前几个字符建立索引,例如,看看其选择性:

1
2
3
4
5
6
SELECT count(DISTINCT(concat(first_name, left(last_name, 3))))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
| 0.7879 |
+-------------+

选择性还不错,但离0.9313还是有点距离,那么把last_name前缀加到4:

1
2
3
4
5
6
SELECT count(DISTINCT(concat(first_name, left(last_name, 4))))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
| 0.9007 |
+-------------+

这时选择性已经很理想了,而这个索引的长度只有18,比短了接近一半,我们把这个前缀索引 建上:

1
2
3
4
5
6
7
8
9
10
11
ALTER TABLE employees.employees
ADD INDEX `first_name_last_name4` (first_name, last_name(4));
此时再执行一遍按名字查询,比较分析一下与建索引前的结果:
SHOW PROFILES;
+----------+------------+---------------------------------------------------------------------------------+
| Query_ID | Duration | Query |
+----------+------------+---------------------------------------------------------------------------------+
| 87 | 0.11941700 | SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido' |
| 90 | 0.00092400 | SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido' |
+----------+------------+---------------------------------------------------------------------------------+

性能的提升是显著的,查询速度提高了120多倍。

前缀索引兼顾索引大小和查询速度,但是其缺点是不能用于ORDER BY和GROUP BY操作,也不能用于Covering index(即当索引本身包含查询所需全部数据时,不再访问数据文件本身)。

InnoDB的主键选择与插入优化

在使用InnoDB存储引擎时,如果没有特别的需要,请永远使用一个与业务无关的自增字段作为主键。

经常看到有帖子或博客讨论主键选择问题,有人建议使用业务无关的自增主键,有人觉得没有必要,完全可以使用如学号或身份证号这种唯一字段作为主键。不论支持哪种论点,大多数论据都是业务层面的。如果从数据库索引优化角度看,使用InnoDB引擎而不使用自增主键绝对是一个糟糕的主意。

上文讨论过InnoDB的索引实现,InnoDB使用聚集索引,数据记录本身被存于主索引(一颗B+Tree)的叶子节点上。这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放,因此每当有一条新的记录插入时,MySQL会根据其主键将其插入适当的节点和位置,如果页面达到装载因子(InnoDB默认为15/16),则开辟一个新的页(节点)。

如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。如下图所示:

图13

这样就会形成一个紧凑的索引结构,近似顺序填满。由于每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。

如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置:

图14

此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。

因此,只要可以,请尽量在InnoDB上采用自增字段做主键。

后记

这篇文章断断续续写了半个月,主要内容就是上面这些了。不可否认,这篇文章在一定程度上有纸上谈兵之嫌,因为我本人对MySQL的使用属于菜鸟级别,更没有太多数据库调优的经验,在这里大谈数据库索引调优有点大言不惭。就当是我个人的一篇学习笔记了。

其实数据库索引调优是一项技术活,不能仅仅靠理论,因为实际情况千变万化,而且MySQL本身存在很复杂的机制,如查询优化策略和各种引擎的实现差异等都会使情况变得更加复杂。但同时这些理论是索引调优的基础,只有在明白理论的基础上,才能对调优策略进行合理推断并了解其背后的机制,然后结合实践中不断的实验和摸索,从而真正达到高效使用MySQL索引的目的。

另外,MySQL索引及其优化涵盖范围非常广,本文只是涉及到其中一部分。如与排序(ORDER BY)相关的索引优化及覆盖索引(Covering index)的话题本文并未涉及,同时除B-Tree索引外MySQL还根据不同引擎支持的哈希索引、全文索引等等本文也并未涉及。如果有机会,希望再对本文未涉及的部分进行补充吧。

参考文献

[1] Baron Scbwartz等 著,王小东等 译;高性能MySQL(High Performance MySQL);电子工业出版社,2010

[2] Michael Kofler 著,杨晓云等 译;MySQL5权威指南(The Definitive Guide to MySQL5);人民邮电出版社,2006

[3] 姜承尧 著;MySQL技术内幕-InnoDB存储引擎;机械工业出版社,2011

[4] D Comer, Ubiquitous B-tree; ACM Computing Surveys (CSUR), 1979

[5] Codd, E. F. (1970). “A relational model of data for large shared data banks”. Communications of the ACM, , Vol. 13, No. 6, pp. 377-387

[6] MySQL5.1参考手册 - http://dev.mysql.com/doc/refman/5.1/zh/index.html

1.有时候查看某些运行中的进程时常常这样(比如nginx)

1
2
3
4
➜ ~ ps aux |grep nginx
root 13617 0.0 0.0 2463900 448 ?? Ss 11:48下午 0:00.00 nginx: master process nginx
eric 13624 0.0 0.0 2434840 744 s001 R+ 11:48下午 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn nginx
eric 13618 0.0 0.0 2464120 1000 ?? S 11:48下午 0:00.00 nginx: worker process

问题就是,我想对这些pid做些操作,但是会多出来一个ps进程产生的一行,一直以来也不知道如何去掉这行,反正也不碍事。前些天看到一篇微博里给出了方法

1
2
3
4
➜ ~ ps aux |grep \[n]ginx
eric 13618 0.0 0.0 2464120 1000 ?? S 11:48下午 0:00.00 nginx: worker process
root 13617 0.0 0.0 2463900 448 ?? Ss 11:48下午 0:00.00 nginx: master process nginx
➜ ~

2.有时候需要展示进程之间的父子关系,虽然有父进程的pid,但是并不直观,可以用ps aux --forest
3.批量杀掉进程,可以借助xargs

1
ps aux |grep \[n]ginx |awk '{print $2}' |xargs sudo kill -9

Memcache

简介

MemCache是什么

MemCache是一个自由、源码开放、高性能、分布式的分布式内存对象缓存系统,用于动态Web应用以减轻数据库的负载。它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高了网站访问的速度。MemCaChe是一个存储键值对的HashMap,在内存中对任意的数据(比如字符串、对象等)所使用的key-value存储,数据可以来自数据库调用、API调用,或者页面渲染的结果。MemCache设计理念就是小而强大,它简单的设计促进了快速部署、易于开发并解决面对大规模的数据缓存的许多难题,而所开放的API使得MemCache能用于Java、C/C++/C#、Perl、Python、PHP、Ruby等大部分流行的程序语言。

Memcache 和Memcached

  • MemCache是项目的名称

  • MemCached是MemCache服务器端可以执行文件的名称

特点

  • 无容灾考虑,纯内存缓存,重启后所有数据丢失。存取数据比硬盘快,当内存达到上限后,通过LRU算法自动删除缓存。
  • 基于libevent开发。将Linux的epoll、BSD类操作系统的kqueue(多路复用io模型)等事件处理功能封装成统一的接口,因此memcached在多种操作系统上都可以发挥较好的性能,即使服务器的连接数增加,也能发挥O(1)的性能(C10k问题)
  • 协议基于文本行,直接通过telnet在memcached服务器上可进行存取数据操作
  • 分布式,memcache算不上是一个真正的分布式系统,因为各个memcached之间并不会“感知“,不会互相通信。分布式部署取决于memcache客户端。

原理

路由算法

前面说到memcached服务之间并不能互相通信,所以对服务的“选择”落在了客户端上,所以路由算法决定了客户端对哪台memcached进行数据存取。
先来看一个简单的路由算法

余数算法

比方说,字符串str对应的HashCode是50、服务器的数目是3,取余数得到2,str对应节点Node2,所以路由算法把str路由到Node2服务器上。由于HashCode随机性比较强,所以使用余数Hash路由算法就可以保证缓存数据在整个MemCache服务器集群中有比较均衡的分布。

如果不考虑服务器集群的伸缩性(集群中新加入节点或者某节点down掉),那么余数Hash算法几乎可以满足绝大多数的缓存路由需求。

就假设MemCache服务器集群由3台变为4台,更改服务器列表,仍然使用余数Hash,50对4的余数是2,对应Node2,但是str原来是存在Node1上的,这就导致了缓存没有命中。如果这么说不够明白,那么不妨举个例子,原来有HashCode为0~19的20个数据,那么:

原始hash

现在我扩容到4台,加粗标红的表示命中:

扩容后

如果我扩容到20+的台数,只有前三个HashCode对应的Key是命中的,也就是15%。当然这只是个简单例子,现实情况肯定比这个复杂得多,不过足以说明,使用余数Hash的路由算法,在扩容的时候会造成大量的数据无法正确命中(其实不仅仅是无法命中,那些大量的无法命中的数据还在原缓存中在被移除前占据着内存)。这个结果显然是无法接受的,在网站业务中,大部分的业务数据度操作请求上事实上是通过缓存获取的,只有少量读操作会访问数据库,因此数据库的负载能力是以有缓存为前提而设计的。当大部分被缓存了的数据因为服务器扩容而不能正确读取时,这些数据访问的压力就落在了数据库的身上,这将大大超过数据库的负载能力,严重的可能会导致数据库宕机。

一致性Hash算法

一致性Hash算法通过一个叫做一致性Hash环的数据结构实现Key到缓存服务器的Hash映射

一致性hash

具体算法过程为:先构造一个长度为2^32 的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2^32 -1])将缓存服务器节点放置在这个Hash环上,然后根据需要缓存的数据的Key值计算得到其Hash值(其分布也为[0, 2^32 -1]),然后在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。

就如同图上所示,三个Node点分别位于Hash环上的三个位置,然后Key值根据其HashCode,在Hash环上有一个固定位置,位置固定下之后,Key就会顺时针去寻找离它最近的一个Node,把数据存储在这个Node的MemCache服务器中。使用Hash环如果加了一个节点会怎么样,看一下:

新增节点后

加了一个Node4节点,只影响到了一个Key值的数据,本来这个Key值应该是在Node1服务器上的,现在要去Node4了。采用一致性Hash算法,的确也会影响到整个集群,但是影响的只是加粗的那一段而已,相比余数Hash算法影响了远超一半的影响率,这种影响要小得多。更重要的是,集群中缓存服务器节点越多,增加节点带来的影响越小,很好理解。换句话说,随着集群规模的增大,继续命中原有缓存数据的概率会越来越大,虽然仍然有小部分数据缓存在服务器中不能被读到,但是这个比例足够小,即使访问数据库,也不会对数据库造成致命的负载压力。

至于具体应用,这个长度为2^32 的一致性Hash环通常使用二叉查找树实现。

内存管理

前面说到memcache是内存缓存,不会持久化数据,而且也会受到机器位数的限制,32位最多只有2G,64位可以认为没有上限。

内存分配

下面说下内存分配,传统的内存管理方式是,使用完通过malloc分配的内存后通过free来回收内存,这种方式容易产生内存碎片并降低操作系统对内存的管理效率,所以memcache采用的是固定空间分配,MemCache的这种内存分配的方式称为allocator。

内存分配方式

这张图片里面涉及了slab_class、slab、page、chunk四个概念,它们之间的关系是:

* MemCache将内存空间分为一组slab

* 每个slab下又有若干个page,每个page默认是1M,如果一个slab占用100M内存的话,那么这个slab下应该有100个page,page一旦分配,就不会被回收

* 每个page里面包含一组chunk,chunk是真正存放数据的地方,同一个slab里面的chunk的大小是固定的

* 有相同大小chunk的slab被组织在一起,称为slab_class

slab的数量是有限的,几个、十几个或者几十个,这个和启动参数的配置相关。

MemCache中的item存放的地方是由item的大小决定的,item总是会被存放到与chunk大小最接近的一个slab中,比如slab[1]的chunk大小为80字节、slab[2]的chunk大小为100字节、slab[3]的chunk大小为128字节(相邻slab内的chunk基本以1.25为比例进行增长,MemCache启动时可以用-f指定这个比例),那么过来一个88字节的item,这个item将被放到2号slab中。放slab的时候,首先slab要申请内存,申请内存是以page为单位的,所以在放入第一个数据的时候,无论大小为多少,都会有1M大小的page被分配给该slab。申请到page后,slab会将这个page的内存按chunk的大小进行切分,这样就变成了一个chunk数组,最后从这个chunk数组中选择一个用于存储数据。

如果这个slab中没有chunk可以分配了怎么办,如果MemCache启动没有追加-M(禁止LRU,这种情况下内存不够会报Out Of Memory错误),那么MemCache会把这个slab中最近最少使用的chunk中的数据清理掉,然后放上最新的数据。
这种内存分配方式的特点避免了管理内存碎片问题,同时也带来了内存浪费的问题,如88字节的item分配在128字节(紧接着大的用)的chunk中,就损失了40字节。

缓存策略

Memcached的缓存策略是LRU(最近最少使用)加上延迟删除策略

延迟删除是指memcache并不会监视和清理过期数据,而是在客户端get时检查。比如设置某个key存活30s,到了30s后,memcache并不会主动清理它,而是有get请求发现它后才会设置为过期方便以后优先利用,还有一种情况就是lru。
删除操作只会将chunk置为删除状态,这样下次申请的时候可以优先利用。
flush操作只会使所有的item失效。
这种删除方式,可以提高memcache的效率,因为不必每时每刻检查过期item,从而提高CPU工作效率。
LRU只是针对slab内的,并不是全局的,因为不同的slab的chunk不同。

应用

使用telnet操作memcache

  • set 和 get
1
2
3
4
5
6
7
8
9
10
11
➜ ~ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
set foo 0 0 6
123456
STORED
get foo
VALUE foo 0 6
123456
END
  • 查看memcache状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜ ~ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
stats
STAT pid 1
STAT uptime 275
STAT time 1463557642
STAT version 1.4.25
STAT libevent 2.0.21-stable
STAT pointer_size 64
...
STAT evictions 0
STAT reclaimed 0
STAT crawler_reclaimed 0
STAT crawler_items_checked 0
STAT lrutail_reflocked 0
END
  • 查看各个slab使用状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜ ~ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
stats slabs
STAT 1:chunk_size 96
STAT 1:chunks_per_page 10922
STAT 1:total_pages 1
STAT 1:total_chunks 10922
STAT 1:used_chunks 1
STAT 1:free_chunks 10921
STAT 1:free_chunks_end 0
STAT 1:mem_requested 74
STAT 1:get_hits 1
STAT 1:cmd_set 1
STAT 1:delete_hits 0
STAT 1:incr_hits 0
STAT 1:decr_hits 0
STAT 1:cas_hits 0
STAT 1:cas_badval 0
STAT 1:touch_hits 0
STAT active_slabs 1
STAT total_malloced 1048512
END
  • 简易监控
    telnet是一个交互的工具,并不适合在监控脚本中使用,可以用netcat
1
2
3
4
5
6
7
8
9
10
➜ ~ (echo stats;sleep 1)|nc 127.0.0.1 11211
STAT pid 1
STAT uptime 1995
STAT time 1463559362
STAT version 1.4.25
STAT libevent 2.0.21-stable
STAT pointer_size 64
STAT rusage_user 0.048000
STAT rusage_system 0.048000
....

php中的一些应用

memcache常常作为数据缓存,来缓解mysql的压力。
当item不存在时,memcache会返回false,可以根据memcache的返回结果。
注意判断只能使用 ===
当设置一个key为false时,memcache会返回一个空字符串。

  • 一个简单的缓存
1
2
3
4
5
6
7
8
9
10
11
12
function getFoo($id,$cleanCache){
$memcache = CacheFactory:getInstance()->getMemcache("");
$prefix = "foo_";
$key = $prefix.$id;
$value = $memcache->get($key);
if($value === false || $cleanCache){
$value = DaoFactory::get();
...
memcache->set($key,$value,0,self::$expireTime);
}
return $value;
}
  • 分布式会话管理

登陆信息等。

  • memcache cas
    memcache的每一个命令都是原子操作的,比如get,set,但是针对同一个key的多次get,set请求并不是原子的,是串行化的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$m = new Memcached();
$m->addServer('localhost', 11211);
do {
/* 获取ip列表以及它的标记 */
$ips = $m->get('ip_block', null, $cas);
/* 如果列表不存在, 创建并进行一个原子添加(如果其他客户端已经添加, 这里就返回false)*/
if ($m->getResultCode() == Memcached::RES_NOTFOUND) {
$ips = array($_SERVER['REMOTE_ADDR']);
$m->add('ip_block', $ips);
/* 其他情况下,添加ip到列表中, 并以cas方式去存储, 这样当其他客户端修改过, 则返回false */
} else {
$ips[] = $_SERVER['REMOTE_ADDR'];
$m->cas($cas, 'ip_block', $ips);
}
} while ($m->getResultCode() != Memcached::RES_SUCCESS);
?>

有兴趣的可以看一下这篇文章:memcached 原子性操作 CAS模式

  • socket 操作memcache
    一般都不会这么用,除非没有安装memcache扩展
1
2
3
4
5
6
7
8
9
10
11
//connect
$socket = fsockopen('10.207.26.234',11211);
//set
fwrite($socket,"set a 0 0 1\r\n1\r\n");
$response = fgets($socket);
echo "<pre>";print_r($response);
//get
fwrite($socket,"get a\r\n");
$response = fread($socket,1024);
echo "<pre>";print_r($response);

应用场景

适用的场景

  • 对于mysql读>>写的场景,memcached可以显著地提高运行效率
  • 一些很小但是频繁访问的文件
  • session数据
  • 计算量很大,可以用memcache缓存结果

不适用的场景

  • 需要获取所有key
  • key的长度超过250字符
  • item过大
  • 实时性要求高的场景

其他

  • 安全
  • 其他缓存系统,redis,mongodb,hbase等等
  • 缓存一致性问题
  • 缓存雪崩
  • 缓存预热
  • 缓存无底洞
  • (twemproxy)nutcraker

参考

年后没怎么写博客,老大不小了,得考虑未来的去处了,择一城终老,上海的限购政策也是坑死单身狗,身边也有朋友渐渐离开,一方面也是因为离家远。最终做了个决定,离开上海,3月离职,国内转了半个月,一路吃到北京。开始找工作,面了5家拿了3家offer,拿到了链家offer,很想去链家和牛人们一起交流学习,但是经济原因和路程原因,放弃了链家。也不想去什么创业公司,图个稳定,好好的呆满5年