使用 grep 查找所有包含指定文本的文件

目标:本文提供一些关于如何搜索出指定目录或整个文件系统中那些包含指定单词或字符串的文件。

难度:容易

约定:

# – 需要使用 root 权限来执行指定命令,可以直接使用 root 用户来执行也可以使用 sudo 命令
$ – 可以使用普通用户来执行指定命令

案例

非递归搜索包含指定字符串的文件

第一个例子让我们来搜索 /etc/ 目录下所有包含 stretch 字符串的文件,但不去搜索其中的子目录:

# grep -s stretch /etc/*
/etc/os-release:PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
/etc/os-release:VERSION="9 (stretch)"

grep 的 -s 选项会在发现不存在或者不能读取的文件时隐藏报错信息。结果显示除了文件名之外,还有包含请求字符串的行也被一起输出了。

递归地搜索包含指定字符串的文件

上面案例中忽略了所有的子目录。所谓递归搜索就是指同时搜索所有的子目录。

下面的命令会在 /etc/ 及其子目录中搜索包含 stretch 字符串的文件:

# grep -R stretch /etc/*
/etc/apt/sources.list:# deb cdrom:[Debian GNU/Linux testing _Stretch_ - Official Snapshot amd64 NETINST Binary-1 20170109-05:56]/ stretch main
/etc/apt/sources.list:#deb cdrom:[Debian GNU/Linux testing _Stretch_ - Official Snapshot amd64 NETINST Binary-1 20170109-05:56]/ stretch main
/etc/apt/sources.list:deb http://ftp.au.debian.org/debian/ stretch main
/etc/apt/sources.list:deb-src http://ftp.au.debian.org/debian/ stretch main
/etc/apt/sources.list:deb http://security.debian.org/debian-security stretch/updates main
/etc/apt/sources.list:deb-src http://security.debian.org/debian-security stretch/updates main
/etc/dictionaries-common/words:backstretch
/etc/dictionaries-common/words:backstretch's
/etc/dictionaries-common/words:backstretches
/etc/dictionaries-common/words:homestretch
/etc/dictionaries-common/words:homestretch's
/etc/dictionaries-common/words:homestretches
/etc/dictionaries-common/words:outstretch
/etc/dictionaries-common/words:outstretched
/etc/dictionaries-common/words:outstretches
/etc/dictionaries-common/words:outstretching
/etc/dictionaries-common/words:stretch
/etc/dictionaries-common/words:stretch's
/etc/dictionaries-common/words:stretched
/etc/dictionaries-common/words:stretcher
/etc/dictionaries-common/words:stretcher's
/etc/dictionaries-common/words:stretchers
/etc/dictionaries-common/words:stretches
/etc/dictionaries-common/words:stretchier
/etc/dictionaries-common/words:stretchiest
/etc/dictionaries-common/words:stretching
/etc/dictionaries-common/words:stretchy
/etc/grub.d/00_header:background_image -m stretch `make_system_path_relative_to_its_root "$GRUB_BACKGROUND"`
/etc/os-release:PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
/etc/os-release:VERSION="9 (stretch)"

搜索所有包含特定单词的文件

上面 grep 命令的案例中列出的是所有包含字符串 stretch 的文件。也就是说包含 stretches , stretched 等内容的行也会被显示。 使用 grep 的 -w 选项会只显示包含特定单词的行:

# grep -Rw stretch /etc/*
/etc/apt/sources.list:# deb cdrom:[Debian GNU/Linux testing _Stretch_ - Official Snapshot amd64 NETINST Binary-1 20170109-05:56]/ stretch main
/etc/apt/sources.list:#deb cdrom:[Debian GNU/Linux testing _Stretch_ - Official Snapshot amd64 NETINST Binary-1 20170109-05:56]/ stretch main
/etc/apt/sources.list:deb http://ftp.au.debian.org/debian/ stretch main
/etc/apt/sources.list:deb-src http://ftp.au.debian.org/debian/ stretch main
/etc/apt/sources.list:deb http://security.debian.org/debian-security stretch/updates main
/etc/apt/sources.list:deb-src http://security.debian.org/debian-security stretch/updates main
/etc/dictionaries-common/words:stretch
/etc/dictionaries-common/words:stretch's
/etc/grub.d/00_header:background_image -m stretch `make_system_path_relative_to_its_root "$GRUB_BACKGROUND"`
/etc/os-release:PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
/etc/os-release:VERSION="9 (stretch)"

显示包含特定文本的文件名

上面的命令都会产生多余的输出。下一个案例则会递归地搜索 etc 目录中包含 stretch 的文件并只输出文件名:

# grep -Rl stretch /etc/*
/etc/apt/sources.list
/etc/dictionaries-common/words
/etc/grub.d/00_header
/etc/os-release

大小写不敏感的搜索

默认情况下搜索是大小写敏感的,也就是说当搜索字符串 stretch 时只会包含大小写一致内容的文件。

通过使用 grep 的 -i 选项,grep 命令还会列出所有包含 Stretch , STRETCH , StReTcH 等内容的文件,也就是说进行的是大小写不敏感的搜索。

# grep -Ril stretch /etc/*
/etc/apt/sources.list
/etc/dictionaries-common/default.hash
/etc/dictionaries-common/words
/etc/grub.d/00_header
/etc/os-release

搜索时包含/排除指定文件

grep 命令也可以只在指定文件中进行搜索。比如,我们可以只在配置文件(扩展名为.conf)中搜索指定的文本/字符串。 下面这个例子就会在 /etc 目录中搜索带字符串 bash 且所有扩展名为 .conf 的文件:

# grep -Ril bash /etc/*.conf
OR
# grep -Ril --include=*.conf bash /etc/*
/etc/adduser.conf

类似的,也可以使用 –exclude 来排除特定的文件:

# grep -Ril --exclude=*.conf bash /etc/*
/etc/alternatives/view
/etc/alternatives/vim
/etc/alternatives/vi
/etc/alternatives/vimdiff
/etc/alternatives/rvim
/etc/alternatives/ex
/etc/alternatives/rview
/etc/bash.bashrc
/etc/bash_completion.d/grub
/etc/cron.daily/apt-compat
/etc/cron.daily/exim4-base
/etc/dictionaries-common/default.hash
/etc/dictionaries-common/words
/etc/inputrc
/etc/passwd
/etc/passwd-
/etc/profile
/etc/shells
/etc/skel/.profile
/etc/skel/.bashrc
/etc/skel/.bash_logout

搜索时排除指定目录

跟文件一样,grep 也能在搜索时排除指定目录。 使用 –exclude-dir 选项就行。

下面这个例子会搜索 /etc 目录中搜有包含字符串 stretch 的文件,但不包括 /etc/grub.d 目录下的文件:

# grep --exclude-dir=/etc/grub.d -Rwl stretch /etc/*
/etc/apt/sources.list
/etc/dictionaries-common/words
/etc/os-release

显示包含搜索字符串的行号

-n 选项还会显示指定字符串所在行的行号:

# grep -Rni bash /etc/*.conf
/etc/adduser.conf:6:DSHELL=/bin/bash

寻找不包含指定字符串的文件

最后这个例子使用 -v 来列出所有不包含指定字符串的文件。

例如下面命令会搜索 /etc 目录中不包含 stretch 的所有文件:

# grep -Rlv stretch /etc/*

centos6.5 vsftpd 虚拟用户

环境

  • SERVER:CENTOS6.5

  • CLIENT:WINDOWS10

要求

用windows10 访问 centos的vsftpd 使用虚拟用户ftp 密码为abc.123 进行访问使其有删除,写入权限

安装vsftpd 服务

未分类

创建一个文件存放用户名和密码
格式大约为:

user1
password
user2
password

未分类

未分类

使用db_load 命令生成出虚拟用户口令认证文件(如没有此命令 yum install db4-utils 进行安装)
选项-T允许应用程序能够文本文件转译载入进数据库由于我们之虚拟用户信息文件方式存储文件里了让Vsftpd应用程序能够通过文本来载入用户数据必须要使用选项
指定了选项-T定要追加子选项-t;子选项-t追加-T选项用来指定转译载入数据库类型
-f 参数面接包含用户名和密码文本文件文件内容:奇数行用户名、偶数行密码

未分类

编辑/etc/pam.d/vsftpd 把所有内容都#注释掉 然后添加下面两行
这里的auth是指对用户的用户名口令进行验证。
这里的accout是指对用户的帐户有哪些权限哪些限制进行验证。

未分类

建立一个用户用来给我们的虚拟用户登陆

未分类

修改vsftpd配置文件(vi /etc/vsftpd/vsftpd.conf)指定虚拟用户 并开启虚拟用户权限

未分类

重启服务

未分类

连接成功

未分类

发现无法创建文件我们开启虚拟用户个人的配置文件

未分类

未分类

write_enable=YES #开放ftp的写权限
anon_world_readable_only=NO #开放ftp的下载权限
anon_upload_enable=YES #开放ftp的上传权限
anon_mkdir_write_enable=YES #开放ftp创建目录的权限
anon_other_write_enable=YES #开放ftp删除和重命名的权限

重启服务

创建

未分类

未分类

未分类

vsftpd 使用虚拟用户及单用户多目录的配置

一、配置vsftpd虚拟用户

1、安装vsftpd

yum -y install pam pam-devel db4 db4-tcl
yum -y install vsftpd

2、配置vsftpd.conf

cp /etc/vsftpd/vsftpd.conf /etc/vsftpd/vsftpd.conf.back
vim /etc/vsftpd/vsftpd.conf
anonymous_enable=NO
#设成YES,允许匿名用户登陆
local_enable=YES
#允许/禁止本地用户登陆 注意:主要是为虚拟宿主用户,如果该项目设定为NO那么所有虚拟用户将无法访问。
write_enable=YES
#设定可以进行写操作。
local_umask=022
#设定上传后文件的权限掩码,文件644,文件夹755
dirmessage_enable=YES
#设定开启目录标语功能
xferlog_enable=YES
#设定开启日志记录功能。
connect_from_port_20=YES
#设定端口20进行数据连接
xferlog_std_format=YES
#设定日志使用标准的记录格式
listen=YES
#开启独立进程vsftpd,不使用超级进程xinetd。设定该Vsftpd服务工作在StandAlone模式下。
pam_service_name=vsftpd
#设定,启用pam认证,并指定认证文件名/etc/pam.d/vsftpd
userlist_enable=YES
#设定userlist_file中的用户将不得使用FTP
tcp_wrappers=YES
#设定支持TCP Wrappers
chroot_local_user=YES
#限制所有用户在主目录
#限制所有用户在主目录
#↓↓↓↓↓↓↓↓↓↓↓
#↓↓↓↓↓↓↓↓↓↓↓
#以下这些是关于Vsftpd虚拟用户支持的重要配置项目。默认Vsftpd.conf中不包含这些设定项目,需要自己手动添加配置
guest_enable=YES
#设定启用虚拟用户功能
guest_username=www
#指定虚拟用户的宿主用户
virtual_use_local_privs=YES
#设定虚拟用户的权限符合他们的宿主用户 
user_config_dir=/etc/vsftpd/vconf
#设定虚拟用户个人Vsftp的配置文件存放路径。也就是说,这个被指定的目录里,将存放每个Vsftp虚拟用户个性的配置文件,
#一个需要注意的地方就是这些配置文件名必须和虚拟用户名相同。

3、配置虚拟用户

创建虚拟用户配置文件存放路径

mkdir /etc/vsftpd/vconf

创建虚拟用户名单文件,在其中加入用户的用户名和口令信息。格式很简单:“奇数行用户名,偶数行口令”。
virtusers文件格式如下:

vim /etc/vsftpd/virtusers
username1
password123

生成虚拟用户数据文件

db_load -T -t hash -f /etc/vsftpd/virtusers /etc/vsftpd/virtusers.db

需要特别注意的是,以后再要添加虚拟用户的时候,只需要按照“一行用户名,一行口令”的格式将新用户名和口令添加进虚拟用户名单文件。
但是光这样做还不够,这样是不会生效的!还要再执行一遍“ db_load -T -t hash -f 虚拟用户名单文件 虚拟用户数据库文件.db ”的命令使其生效才可以!

4、设置认证文件PAM

编辑Vsftpd的PAM验证配置文件,把原来的配置文件全部注释掉(不注释掉虚拟用户会登录不上),添加如下行

cp /etc/pam.d/vsftpd /etc/pam.d/vsftpd.backup

#vim /etc/pam.d/vsftpd
auth    sufficient      /lib64/security/pam_userdb.so    db=/etc/vsftpd/virtusers
account sufficient      /lib64/security/pam_userdb.so    db=/etc/vsftpd/virtusers

#以上两条是手动添加的,内容是对虚拟用户的安全和帐户权限进行验证。
这里的auth是指对用户的用户名口令进行验证。
这里的accout是指对用户的帐户有哪些权限哪些限制进行验证。
其后的sufficient表示充分条件,也就是说,一旦在这里通过了验证,那么也就不用经过下面剩下的验证步骤了。相反,如果没有通过的话,也不会被系统立即挡之门外,因为sufficient的失败不决定整个验证的失败,意味着用户还必须将经历剩下来的验证审核。
再后面的/lib/security/pam_userdb.so表示该条审核将调用pam_userdb.so这个库函数进行。
最后的db=/etc/vsftpd/virtusers则指定了验证库函数将到这个指定的数据库中调用数据进行验证。

5、建立虚拟用户配置文件模版

创建ftp的目录,设置属组属主为www,应为我们上面指定虚拟用户的宿主用户是www,这样ftp用户才有权限对目录进行操作。

mkdir -p /data/www/virtual/

chown www.www -R /data/www/virtual/

vim /etc/vsftpd/vconf/vconf.tmp
local_root=/data/www/virtual/
#指定虚拟用户的具体主路径
anonymous_enable=NO
#设定不允许匿名用户访问
write_enable=YES
#设定允许写操作
local_umask=022
#设定上传文件权限掩码
anon_upload_enable=NO
#设定不允许匿名用户上传
anon_mkdir_write_enable=NO
#设定不允许匿名用户建立目录
idle_session_timeout=600
#设定空闲连接超时时间
data_connection_timeout=120
#设定单次连续传输最大时间
max_clients=10
#设定并发客户端访问个数
max_per_ip=5
#设定单个客户端的最大线程数,这个配置主要来照顾Flashget、迅雷等多线程下载软件
local_max_rate=50000
#设定该用户的最大传输速率,单位b/s
cp /etc/vsftpd/vconf/vconf.tmp /etc/vsftpd/vconf/username1

6、测试配置

未分类

二、vsftpd单用户多目录的配置

在使用vsftpd过程中,我们会经常发现vsftpd在默认情况下一个用户(无论是系统用户还是虚拟用户)只能拥有一个目录,一般是根目录。
如果此时再要向该用户添加其它目录的话,比如系统的其他目录也需要此用户访
问,那么就无法直接添加了。

所以我们只能借助其他方式实现这个功能

一开始我想到了软连接的方式,结果是vsftpd不支持软连接,硬链接又不允许将硬链接指向目录。

在此我们使用的是 mount –bind 命令,很多人将这个命令理解为针对目录的硬连接,但这种想法是错的。
所以我需要注意一下两点:

1. mount –bind连接的两个目录的inode号码并不一样,只是目标目录的block被屏蔽掉,inode被重定向到原目录的inode (目标目录的inode和block依然没变,就是说目标目录只是隐藏不是删除,数据都没有改变,只是访问不到了)

2. 两个目录的对应关系存在于内存里,一旦重启挂载关系就不存在了,所以我们想要服务器重启之后还有效的话就需要写到/etc/rc.local

创建几个测试目录

mkdir /data/www/virtual/test{1,2}
mkdir /home/test{1,2}
chown -R www.www /home/test{1,2}
vim /etc/rc.local

#可读写挂载
mount --bind /home/test1/ /data/www/virtual/test1/
#只读挂载
mount --bind /home/test2/ /data/www/virtual/test2/
mount -o remount,ro /data/www/virtual/test2/

未分类

使用 source加载一下/etc/rc.local, 让它立即生效。

source /etc/rc.local

然使用 mount 查看挂载的结果。

未分类

Redis实现数据库读写分离

Redis是一种NoSQL的文档数据库,通过key-value的结构存储在内存中,Redis读的速度是110000次/s,写的速度是81000次/s,性能很高,使用范围也很广。

下面用一个实例实现redis的读写分离,步骤如下:

第一步:下载redis

官网下载地址: https://redis.io/download

未分类

下载最新的稳定版,解压拷贝到 ~/redis 中

未分类

编译代码:

$ make
$ test

第二步:配置redis

编辑redis.conf文件

bind 127.0.0.1
port 6379

拷贝redis.conf文件,改名为slave.conf,打开编辑

bind 127.0.0.1
port 6380
slaveof 127.0.0.1 6379  

第三步:运行服务

开启【主服务】

$ src/redis-server

开启【从服务】

$ src/redis-server slave.conf

运行【主服务】的客户端

$ src/redis-cli

运行【从服务】的客户端

$ src/redis-cli -h 127.0.0.1 -p 6380

查看主从服务的关系

$ src/redis-cli info replication  

未分类

第四步:测试服务器

下面实例演示:在主服务器中存储一些数据,然后在从服务器中查询出来

未分类

未分类

可以看出,从服务器成功的获取到了主服务器的备份数据。

假如我们在从服务器中保存数据,看结果如何?

未分类

提示错误:

(error) READONLY You can't write against a read only slave.

说明从服务器只能读数据,而不能写入数据。

数据在【从服务器】里【读】,在【主服务器】里【写】。

这样就实现了redis数据库的读写分离功能。

Redis性能优化tips

读完了Redis实战,感觉收获还是蛮多的。像往常那样,读完就想将书束之高阁。这几天总感觉差点什么,于是又翻了一下这本书,打算记录书上和自己知道的关于Redis优化的小知识点。

数据持久化

  • 选择恰当的持久化方式。Redis提供RDB和AOF两种持久化方式。用户需要根据实际场景对两种持久化方式进行考量和选择。 RDB会在一定时间间隔内一次性将内存中的所有数据刷到磁盘上,非增量。它的一个主要缺点是如果Redis宕机了,那么可能会造成部分数据丢失,丢失的数据两和RDB持久化的时间间隔有关。此外,如果数据集非常大,Redis在创建子进程时可能会消耗相对较长的时间,这期间客户端请求都会被阻塞。这种情况下我们可以关闭自动保存,通过手动发送SAVE或者BGSAVE命令来控制停顿出现的时间。由于SAVE命令不需要创建子进程,从而避免了与子进程的资源竞争,因此它比BGSAVE速度更快。手动生成快照可以选择在线用户很少的情况下执行,比如使用脚本在凌晨三点执行SAVE命令。 从上述内容可以看出,RDB适用于即使丢失部分数据也不会造成问题的场景。同时我们需要注意快照是否生成得过于频繁或者稀少。 AOF持久化会将被执行的命令追加到AOF文件末尾。在redis.conf中该功能默认是关闭的,设置appendonly yes以开启该功能。这种方式会对磁盘进行大量写入,因此Redis处理命令的速度会受到硬盘性能的限制。并且AOF文件通常比RDB文件更大,数据恢复速度比RDB慢,性能消耗也比RDB高。由于它记录的是实际执行的命令,所以也易读。为了兼顾写入性能和数据安全,可以在配置文件设置appendfsysnc everysec。并不推荐appendfsync no选项,因为这种方式是由操作系统决定何时对AOF文件进行写入。在缓冲区被待写入硬盘的数据填满时,可能造成Redis的写入操作被阻塞,严重影响性能。

  • 重写AOF文件。如果用户开启了AOF功能,Redis运行时间越长,`AOF文件也会越来越大。用户可以发送BGREWRITEAOF重写AOF文件,它会移除AOF文件中的冗余命令以此来减小AOF文件的体积。由于AOF文件重写会用到子进程,因此也存在BGSAVE`命令持久化快照时因为创建子进程而导致的性能问题和内存占用问题。除了使用命令重写AOF文件,也可以在配置文件中配置,以让Redis自动执行重写命令。
    #当aof文件体积大于64mb且比上次重写之后的体积增大了至少一倍

auto-aof-rewrite-percentage 100 
auto-aof-rewrite-min-size 64mb

内存优化

  • 设置maxmemory。设置Redis使用的最大物理内存,即Redis在占用maxmemory大小的内存之后就开始拒绝后续的写入请求,该参数可以确保Redis因为使用了大量内存严重影响速度或者发生OOM。此外,可以使用info命令查看Redis占用的内存及其它信息。

  • 让键名保持简短。键的长度越长,Redis需要存储的数据也就越多

  • 使用短结构。这节主要谈谈Redis的list、hash、set、zset这四种数据结构的存储优化。 在Redis3.2之前,如果列表、散列或者有序集合的长度或者体积较小,Redis会选择一种名为ziplist的数据结构来存储它们。该结构是列表、散列和有序集合三种不同类型的对象的一种非结构化表示,与Redis在通常情况下使用双向链表来表示列表、使用散列表示散列、使用散列加跳跃表表示有序集合相比,它更加紧凑,避免了存储额外的指针和元数据(比如字符串值的剩余可用空间和结束符”″)。但是压缩列表需要在存储的时候进行序列化,读取的时候进行反序列化。以散列为例,在redis.conf中,可以进行如下设置

hash-max-ziplist-entries 512 hash-max-ziplist-value 64

entries选项说明允许被编码为ziplist的最大元素数量,value表示压缩列表每个节点的最大体积是多少个字节。如果任意一个条件不满足,则压缩列表会退化成相应的常规结构。这样做的原因是,当压缩列表的体积越来越大时,操作这些数据结构的速度也会越来越慢,特别是当需要扫描整个列表的时候,因为Redis需要解码很多单独的节点。那么上述值各取多少合适呢?合理的做法是将压缩列表长度限制在500~2000个元素之内,并且每个元素体积在128字节之内。Redis实战推荐的做法是将压缩列表长度限制在1024个元素之内,并且每个元素体积不超过64字节。这类参数可能还得由应用的实际场景来定。此外,我们可以使用DEBUG OBJECT命令来查看某个存储的数据使用了何种数据结构及其它一些重要信息。 在Redis3.2及以后,列表的内部实现变成了quicklist而非ziplist或者传统的双端链表。官方定义是A doubly linked list of ziplists,即由ziplist组成的双向链表。quicklist这样设计的原因大概是一个空间和时间的折中:(1)双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。(2)ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能。那么到底一个quicklist节点包含多长的ziplist合适呢?我们从存储效率来分析:(1)每个quicklist节点上的ziplist越短,则内存碎片越多。内存碎片多了,有可能在内存中产生很多无法被利用的小碎片,从而降低存储效率。这种情况的极端是每个quicklist节点上的ziplist只包含一个数据项,这就退化成一个普通的双向链表了。(2)每个quicklist节点上的ziplist越长,则为ziplist分配大块连续内存空间的难度就越大。有可能出现内存里有很多小块的空闲空间(它们加起来很多),但却找不到一块足够大的空闲空间分配给ziplist的情况。这同样会降低存储效率。这种情况的极端是整个quicklist只有一个节点,所有的数据项都分配在这仅有的一个节点的ziplist里面。这其实退化成一个ziplist了。 redis.conf提供了以下参数来设置quicklist的相关属性

list-max-ziplist-size -2
list-compress-depth 0

size参数可取正值和负值,取正的时候表示按照数据项个数来限定每个quicklist节点上的ziplist长度。取负的时候表示按照数据项大小来限制每个quicklist上的ziplist长度。计算方式是2^(abs(n)+1),比如这里-2表示每个quicklist节点上的ziplist大小不能超过2^(2+1)即8kb。 当列表很长的时候,最容易被访问的很可能是两端的数据,中间的数据被访问的频率比较低(访问起来性能也很低)。如果应用场景符合这个特点,那么list还提供了一个选项,能够把中间的数据节点进行压缩,从而进一步节省内存空间。Redis的配置参数list-compress-depth就是用来完成这个设置的。它表示两端不被压缩的元素个数。这里节点个数指quicklist双向链表的节点个数,如果一个quicklist节点上的ziplist被压缩,就是整体被压缩。如果值为0,则表示两端数据都不被压缩,为n,则表示两端各n个数据不被压缩。 关于ziplist和quicklist细节可以阅读参考链接中的相关文章。
集合(set)也有自己的紧凑表示形式。如果集合元素全是整数,而这些整数处于平台的有符号范围之内,并且它们的数量又在一定范围内,那么Redis会以有序整数数组的方式存储集合,这种方式被成为整数集合(intset)。redis.conf中可以通过

set-max-intset-entries 512

设置该范围。当存储数据个数大于512的时候或者存储了其它类型的数据时,它会退化为hashtable。在数据量较大的时候,与ziplist由于编码解码数据(如果有对数据移动的操作也会有影响)主要造成性能瓶颈的原因不同,主要影响intset性能的原因是它在执行插入或者删除操作的时候都需要对数据进行移动。因此,需要根据实际情况设置intset最大的元素个数。

  • 对数据进行分片。比如当单个散列比较大的时候,可以按一定规则(key+id%shard_num)对数据进行分片,然后ziplist便更不容易退化为hashtable,且不会出现编码解码引起的性能问题。

扩展读写能力

  • 扩展读性能。在redis.conf中添加slaveof host port即可将其配置为另一台Redis服务器的从服务器。注意,在从服务器连接主服务器的时候,从服务器之前的数据会被清空。可以用这种方式建立从服务器树,扩展其读能力。但这种方式并未做故障转移,高可用Redis部署方案可以参考Redis Sentinel,Redis Cluster和Codis。

  • 扩展写性能。(1)使用集群分片技术,比如Redis Cluster;(2)单机上运行多个Redis实例。由于Redis是单线程设计,在涉及到cpu bound的操作的时候,可能速度会大大降低。如果服务器的cpu、io资源充足,可以在同一台机器上运行多个Redis服务器。

应用程序优化

应用程序优化部分主要是客户端和Redis交互的一些建议。主要思想是尽可能减少操作Redis往返的通信次数。

  • 使用流水线操作。Redis支持流水线(pipeline)操作,其中包括了事务流水线和非事务流水线。Redis提供了WATCH命令与事务搭配使用,实现CAS乐观锁的机制。WATCH的机制是:在事务EXEC命令执行时,Redis会检查被WATCH的key,只有被WATCH的key从WATCH起始时至今没有发生过变更,EXEC才会被执行。如果WATCH的key在WATCH命令到EXEC命令之间发生过变化,则EXEC命令会返回失败。使用事务的一个好处是被MULTI和EXEC包裹的命令在执行时不会被其它客户端打断。但是事务会消耗资源,随着负载不断增加,由WATCH、MULTI、EXEC组成的事务(CAS)可能会进行大量重试,严重影响程序性能。 如果用户需要向Redis发送多个命令,且一个命令的执行结果不会影响另一个命令的输入,那么我们可以使用非事务流水线来代替事务性流水线。非事务流水线主要作用是将待执行的命令一次性全部发送给Redis,减少来回通信的次数,以此来提升性能。

  • 使用mset、lpush、zadd等批量操作数据。它的原理同非事务性流行线操作。

  • 使用lua脚本。Lua脚本跟单个Redis命令及MULTI/EXEC组成的事务一样,都是原子操作。Redis采用单线程设计,每次只能执行一个命令,每个单独的命令都是原子的。Lua脚本有两个好处:(1)减少多个操作通信往返带来的开销(2)无需担心由于事务竞争导致的性能开销。

  • 尽可能使用时间复杂度为O(1)的操作,避免使用复杂度为O(N)的操作。避免使用这些O(N)命令主要有几个办法:(1)不要把List当做列表使用,仅当做队列来使用;(2)通过机制严格控制Hash、Set、Sorted Set的大小;(3)可能的话,将排序、并集、交集等操作放在客户端执行;(4)绝对禁止使用KEYS命令;(5)避免一次性遍历集合类型的所有成员,而应使用SCAN类的命令进行分批的,游标式的遍历

Redis提供了Slow Log功能,可以自动记录耗时较长的命令,redis.conf中的配置如下

#执行时间慢于10000毫秒的命令计入Slow Log
slowlog-log-slower-than 10000  
#最大纪录多少条Slow Log
slowlog-max-len 128

使用SLOWLOG GET n命令,可以输出最近n条慢查询日志。使用SLOWLOG RESET命令,可以重置Slow Log。

Redis命令行遍历所有key方法

最常见的指令是:

keys 前缀*

后面的参数跟通配符来列出所有符合的key。

由于KEYS命令一次性返回所有匹配的key,所以,当redis中的key非常多时,对于内存的消耗和redis服务器都是一个隐患,
对于Redis 2.8以上版本给我们提供了一个更好的遍历key的命令 SCAN 该命令的基本格式:

SCAN cursor [MATCH pattern] [COUNT count]  

SCAN 每次执行都只会返回少量元素,所以可以用于生产环境,而不会出现像 KEYS 或者 SMEMBERS 命令带来的可能会阻塞服务器的问题。

SCAN命令是一个基于游标的迭代器。这意味着命令每次被调用都需要使用上一次这个调用返回的游标作为该次调用的游标参数,以此来延续之前的迭代过程

当SCAN命令的游标参数(即cursor)被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束。

简单的迭代演示:

redis 127.0.0.1:6379> scan 0
1) "17"
2)  1) "key:12"
    2) "key:8"
    3) "key:4"
    4) "key:14"
    5) "key:16"
    6) "key:17"
    7) "key:15"
    8) "key:10"
    9) "key:3"
   10) "key:7"
   11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
   2) "key:18"
   3) "key:0"
   4) "key:2"
   5) "key:19"
   6) "key:13"
   7) "key:6"
   8) "key:9"
   9) "key:11"

在上面这个例子中, 第一次迭代使用 0 作为游标, 表示开始一次新的迭代。第二次迭代使用的是第一次迭代时返回的游标 17 ,作为新的迭代参数 。

显而易见,SCAN命令的返回值 是一个包含两个元素的数组, 第一个数组元素是用于进行下一次迭代的新游标, 而第二个数组元素则又是一个数组, 这个数组中包含了所有被迭代的元素。

注意:返回的游标不一定是递增的,可能后一次返回的游标比前一次的小。

在第二次调用 SCAN 命令时, 命令返回了游标 0 , 这表示迭代已经结束, 整个数据集已经被完整遍历过了。

full iteration :以 0 作为游标开始一次新的迭代, 一直调用 SCAN 命令, 直到命令返回游标 0 , 我们称这个过程为一次完整遍历。

SCAN增量式迭代命令并不保证每次执行都返回某个给定数量的元素,甚至可能会返回零个元素, 但只要命令返回的游标不是 0 , 应用程序就不应该将迭代视作结束。

不过命令返回的元素数量总是符合一定规则的, 对于一个大数据集来说, 增量式迭代命令每次最多可能会返回数十个元素;而对于一个足够小的数据集来说,可能会一次迭代返回所有的key。

Django中Mysql Redis连接池

MySQL 连接

对Django服务进行压测,DB报错数据库连接数过多,如果设置MySQL的最大连接数为1000,很快连接数就会达到上限,调整到2000,也很快连接数达到上限。

xuetangx DB最大连接数2048

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 2048   |
+-----------------+-------+
1 row in set (0.00 sec)

通过Django文档可以发现Django其实提供了一个连接池的另一种时间方式

Django的默认数据库连接

Django程序接受到请求之后,在第一次访问数据库的时候会创建一个数据库连接,直到请求结束,关闭数据库连接。(Django opens a connection to the database when it first makes a database query. It keeps this connection open and reuses it in subsequent requests.)下次请求也是如此。因此,这种情况下,随着访问的并发数越来越高,就会产生大量的数据库连接。也就是我们在压测时出现的情况。

使用CONN_MAX_AGE减少数据库请求(连接池)

每次请求都会创建新的数据库连接,这对于高并发的应用来说是不能接受的。因此在Django1.6时,提供了持久的数据库连接,通过DATABASE配置CONN_MAX_AGE来控制每个连接的最大存活时间。

The default value is 0, preserving the historical behavior of closing the database connection at the end of each request. To enable persistent connections, set CONN_MAX_AGE to a positive number of seconds. For unlimited persistent connections, set it to None.

这个参数的原理就是在每次创建完数据库连接之后,把连接放到一个Theard.local的实例中。在request请求开始结束的时候,打算关闭连接时会判断是否超过CONN_MAX_AGE设置这个有效期。超过则关闭。每次进行数据库请求的时候其实只是判断local中有没有已存在的连接,有则复用。

基于上述原因,Django中对于CONN_MAX_AGE的使用是有些限制的,使用不当,会适得其反。因为保存的连接是基于线程局部变量的,因此如果你部署方式采用多线程,必须要注意保证你的最大线程数不会多余数据库能支持的最大连接数(一个线程一个连接)。另外,如果使用开发模式运行程序(直接runserver的方式),建议不要设置CONN_MAX_AGE,因为这种情况下,每次请求都会创建一个Thread。同时如果你设置了CONN_MAX_AGE,将会导致你创建大量的不可复用的持久的连接。

CONN_MAX_AGE设置

CONN_MAX_AGE的时间怎么设置主要取决于数据库对空闲连接的管理,比如你的MySQL设置了空闲1分钟就关闭连接,那你的CONN_MAX_AGE就不能大于一分钟,不过DBA已经习惯了程序中的线程池的概念,会在数据库中设置一个较大的值。

Redis 连接

在做一个直播活动时,所有评论数据保存Redis,收到Redis服务报错:max number of clients reached

redis 127.0.0.1:6379> CONFIG GET maxclients
1) "maxclients"
2) "200"

发现设置的这个最大连接数太小了,因为每次request都会创建redis连接,几百个人同时使用就达到最大上线了。

Django-redis可以设置Redis连接池,并设置最大连接数,这样就能保证连接的复用和连接数的控制。

the default connection pool is simple. You can only customize the maximum number of connections in the pool, by setting CONNECTION_POOL_KWARGS in the CACHES setting。

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        ...
        "OPTIONS": {
            "CONNECTION_POOL_KWARGS": {"max_connections": 100}
        }
    }
}

END

redis 突然大量逐出导致读写请求block

现象

redis作为缓存场景使用,内存耗尽时,突然出现大量的逐出,在这个逐出的过程中阻塞正常的读写请求,导致 redis 短时间不可用;

背景

redis 中的LRU是如何实现的?

  • 当mem_used内存已经超过maxmemory的设定,对于所有的读写请求,都会触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存。

  • 这个清理过程是阻塞的,直到清理出足够的内存空间。

  • 这里的LRU或TTL策略并不是针对redis的所有key,而是以配置文件中的maxmemory-samples个key作为样本池进行抽样清理。
    maxmemory-samples在redis-3.0.0中的默认配置为5,如果增加,会提高LRU或TTL的精准度,redis作者测试的结果是当这个配置为10时已经非常接近全量LRU的精准度.

原因

逐出qps突增非常大的原因:一次需要逐出释放太多的空间会导致阻塞;具体的原因是 mem_tofree 的计算逻辑有问题;
mem_tofree 统计的是:实际已分配的内存总量 – AOF 缓冲区相关的内存;
如果这时候有rehash,会临时分配一个桶来做rehash,这部分内存未排除,所以在rehash阶段,算出来的mem_tofree 就会很大,造成一个时刻需要逐出大量的key,逐出的loop是阻塞的,这个阶段会block redis的请求;

逐出qps的计算:

freeMemoryIfNeeded(...)
    // 计算出 Redis 目前占用的内存总数,但有两个方面的内存不会计算在内:
    // 1)从服务器的输出缓冲区的内存
    // 2)AOF 缓冲区的内存
    // 3)AOF 重写缓冲区中的内存
    mem_used = zmalloc_used_memory();
    if (slaves) {
        listIter li;
        listNode *ln;

        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
            redisClient *slave = listNodeValue(ln);
            unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
            if (obuf_bytes > mem_used)
                mem_used = 0;
            else
                mem_used -= obuf_bytes;
        }
    }
    if (server.aof_state != REDIS_AOF_OFF) {
        mem_used -= sdslen(server.aof_buf);
        mem_used -= aofRewriteBufferSize();
    }
    // 计算需要释放多少字节的内存
    mem_tofree = mem_used - server.maxmemory;
    propagateExpire(db,keyobj);
    // 计算删除键所释放的内存数量
    delta = (long long) zmalloc_used_memory();
    dbDelete(db,keyobj);
    delta -= (long long) zmalloc_used_memory();
    mem_freed += delta;
    // 对淘汰键的计数器增一
    server.stat_evictedkeys++;

解决方案

github上 @Rosanta 给出的解决方案:释放内存的循环逻辑中最多执行一定次数,达到阈值了就不再逐出,到下个请求来时再释放一点空间;这个方案的好处是不会 block 整个进程,正常的业务读写请求无影响;潜在问题是可能单次写入的数据比释放的空间还大,导致总的内存是一直上升,而不是下降;

@antirez 给的方案:同样是迭代删除,但会加个标志,保证在迭代删除的逻辑下内存是逐渐下降的,而如果是上升的,还是会block住正常的请求(要控制主总的内存大小);
详见:
https://github.com/antirez/redis/pull/4583

ref

关于 redis 4.0的逐出算法优化
http://antirez.com/news/109

缓存重构 – 减少Redis Key的数量

未分类

前不久重构系统的时候,发现redis的key已经超过5000万个了,已经没法用keys做遍历了,即使用迭代器*scan做遍历,开销也大到无法接受了。对业务我是相当熟悉的,我很确定我们不需要这么多的key,于是着手开始清理。

首先我跑了个脚本,统计出最常见的key的前缀,发现有两类最多,都超过1000万,分别是

  • carbrand_udid_,缓存的是每个用户的绑定了车牌的车型

  • 1100_341_,缓存的是某个URL对应的图片是否允许被展示

这些key都没有过期时间,也没有清理机制。而业务本身是要求carbrand_udid_永不过期的,1100_341_的时效则比较短,通常在半个月以内,最长也不会超过三个月。

针对第一种情况,原先一个carbrand_udid_$userId对应一个车型列表,通常这个列表长度不超过10,毕竟拥有10个以上车牌且在我们平台上绑定过的用户是极其稀有的。而且每次对这个列表都是整体存取的,不需要单独访问其中一个车型。这种情况其实很好改造,我们放弃每个用户一个key的做法,改用hashtable,table.carbrand,用$userId做hashtable的field,然后把JSON序列化后的车型ID作为value,Redis的hashtable可以存储40亿个键值对,我们用户数才3亿,绑定了牌照的用户不过几千万,在可以预见的未来,不存在超过40亿的可能性。第一种情况的改造,把数千万个key塞到了hashtable内,变成了一个key加这个key内部的数千万个field。

本质上这种改造,是把二维的数据压缩成一维的,data[][]变成data[],消失的那个维度是通过JSON编码成字符串完成的。

针对第二种情况,原先是1100_341_$URL,值是0或1,用来存储一个广告图片是否能展示,0是不能展示,1是可以展示。直接用上面那种方法也能大大减少key的数量,但是我需要做的更好,增加一个过期的功能。由于redis只能对key设置过期时间,不能对二维数据结构内部元素做过期设置,只能想别的办法了。能不能好好利用有效期最长也不会超过三个月这个特性呢?当然可以,而且很简单。我们把日期编码进key里面就行了,比如这样

  • image.audit.white.2018.Q1

  • image.audit.black.2018.Q2

  • image.audit.white.2018.Q1

  • image.audit.black.2018.Q2

每个季度一个白名单set和黑名单set,把URL存储在set里面,再通过判断集合中是否存在某个元素决定URL是否能访问。我们每次最多需要查4个名单,可以确定URL在哪个集合,利用redis的pipeline特性,可以在一个报文里批量查询,一次查完。4条O(1)复杂度的命令执行起来是很快的。还能不能继续优化一些呢?考虑到大部分URL都很长,在空间利用上有些浪费,所以我选择用sha1把URL做个签名,只存储这个签名就行了。第二种改造,成功把数千万个key,减少到每个季度2个,然后定期删除上上季度的key,就能完成清理工作了。

Redis有丰富的数据结构,需要因地制宜好好使用。如若不然,还不如用memcached算了。

基于Redis的任务调度设计方案

一个网关服务器就跟快餐店一样,总是希望客人来得快、去得也快,这样在相同时间内才可以服务更多的客人。如果快餐店的服务员在一个顾客点餐、等餐和结账时都全程跟陪的话,那么这个服务员大部分时间都是在空闲的等待。应该有专门的服务员负责点餐,专门的服务员负责送餐,专门的服务员负责结账,这样才能提高效率。同样道理,网关服务器中也需要分工明确。举个例子:

假设有一个申请发送重置密码邮件的网关接口,须知道发送一封邮件可能会花费上好几秒钟,如果网关服务器直接在线上给用户发送重置密码邮件,高并发的情况下就很容易造成网络拥挤。但实际上,网关服务器并非一定要等待邮件发送成功后才能响应用户,完全可以先告知用户邮件会发送的,而后再在线下把邮件发送出去(就像快餐店里点餐的服务员跟顾客说先去找位置坐,饭菜做好后会有人给他送过去)。

那么是谁来把邮件发送出去呢?

任务队列

为了网关接口能够尽快响应用户请求,无需即时知道结果的耗时操作可以交由任务队列机制来处理。
任务队列机制中包含两种角色,一个是任务生产者,一个是任务消费者,而任务队列是两者之间的纽带:

  • 生产者往队列里放入任务;

  • 消费者从队列里取出任务。

任务队列的整体运行流程是:任务生产者把当前操作的关键信息(后续可以根据这些信息还原出当前操作)抽象出来,比如发送重置密码的邮件,我们只需要当前用户邮箱和用户名就可以了;任务生产者把任务放进队列,实际就是把任务的关键信息存储起来,这里会用到MySQL、Redis之类数据存储工具,常用的是Redis;而任务消费者就不断地从数据库中取出任务信息,逐一执行。

任务生产者的工作是任务分发,一般由线上的网关服务程序执行;任务消费者的工作是任务调度,一般由线下的程序执行,这样即使任务耗时再多,也不阻塞网关服务。

这里主要讨论的是任务调度(任务消费者)的程序设计。

简单直接

假设我们用Redis列表List存储任务信息,列表键名是queues:default,任务发布就是往列表queues:default后追加数据:

<?php
// PHP伪代码
    Redis::rpush('queues:default', serialize($task));

那么任务调度可以这样简单直接的实现:

<?php
// PHP伪代码
Class Worker {

    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default');
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                continue;
            }
            sleep(1);
        }
    }

    public function handle($task) {
        // do something time-consuming
    }
}

$worker = new Worker;
$worker->schedule();

意外保险

上面代码是直接从queues:default列表中移出第一个任务(lpop),因为handle($task)函数是一个耗时的操作,过程中若是遇到什么意外导致了整个程序退出,这个任务可能还没执行完成,可是任务信息已经完全丢失了。保险起见,对schedule()函数进行以下修改:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            sleep(1);
        }
    }
...

即在任务完成后才将任务信息从列表中移除。

延时执行

queues:default列表中的任务都是需要即时执行的,但是有些任务是需要间隔一段时间后或者在某个时间点上执行,那么可以引入一个有序集合,命名为queues:default:delayed,来存放这些任务。任务发布时需要指明执行的时间点$time:

<?php
// PHP伪代码
    Redis::zadd('queues:default:delayed', $time, serialize($task));

任务调度时,如果queues:default列表已经空了,就从queues:default:delayed集合中取出到达执行时间的任务放入queues:default列表中:

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            sleep(1);
        }
    }
...

任务超时

预估任务正常执行所需的最大时间值,若是任务执行超过了这个时间,可能是过程中遇到一些意外,如果任由它继续卡着,那么后面的任务就会无法被执行了。

首先我们给任务设定一个时限属性timeout,然后在执行任务前先给进程本身设置一个闹钟信号,timeout后收到信号说明任务执行超时,需要退出当前进程(用supervisor守护进程时,进程自身退出,supervisor会自动再拉起)。

注意:pcntl_alarm($timeout)会覆盖之前闹钟信号,而pcntl_alarm(0)会取消闹钟信号;任务超时后,当前任务放入queues:default:delayed集合中延时执行,以免再次阻塞队列。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lindex('queues:default', 0);
            if($seri) {
                $task = unserialize($seri);
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::lpop('queues:default');
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            pcntl_alarm(0);
            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = $task->timeout;
        pcntl_signal(SIGALRM, function () {
            $seri = Redis::lpop('queues:default');
            Redis::zadd('queues:default:delayed', time() + ($timeout > 0 ? $timeout * 2 : 10), $seri_arr);
            posix_kill(getmypid(), SIGKILL);
        });
        pcntl_alarm($timeout > 0 ? $timeout : 0);
    }
...

并发执行

上面代码,直观上没什么问题,但是在多进程并发执行的时候,有些任务可能会被重复执行,是因为没能及时将当前执行的任务从queues:default列表中移出,其他进程也可以读取到。为了避免重复执行的问题,我们需要引入一个有序集合SortedSet存放正在执行的任务,命名为queues:default:reserved。
首先任务是从queues:default列表中直接移出,然后开始执行任务前先把任务放进queues:default:reserved集合中,任务完成了再从queues:default:reserved集合中移出。
假设一个任务执行时间不可能超过6060秒(可以按需调整),在queues:default列表为空的时候,queues:default:reserved集合中有任务已经存放超过了6060秒,那么有可能是某些进程在执行任务是意外退出了,所以把这些任务放到queues:default:delayed集合中稍后执行。

<?php
...
    public function schedule() {
        while(1) {
            $seri = Redis::lpop('queues:default', 0);
            if($seri) {
                Redis::zadd('queues:default:reserved', time()+10, $seri);
                $task = unserialize($seri);                
                $this->timeoutHanle($task);
                $this->handle($task);
                Redis::zrem('queues:default:reserved', $seri);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:delayed', 0, time());
            if($seri_arr) {
                Redis::rpush('queues:default', $seri_arr);
                continue;
            }
            $seri_arr = Redis::zremrangebyscore('queues:default:reserved', 0, time()-60*60);
            if($seri_arr) {
                foreach($seri_arr as $seri) {
                    Redis::zadd('queues:default:delayed', time()+10, $seri);
                }
            }

            sleep(1);
        }
    }

    public function timeoutHanle($task) {
        $timeout = $task->timeout;
        pcntl_signal(SIGALRM, function () {
            posix_kill(getmypid(), SIGKILL);
        });
        pcntl_alarm($timeout > 0 ? $timeout : 0);
    }
...

其他

失败重试

以上代码没有检验任务是否执行成功,应该有任务失败的处理机制:比如给任务设定一个最多重试次数属性retry_times,任务每执行一次retry_times,任务执行失败时,若是retry_times等于0,则将任务放入queues:default:failed列表中不在执行;否则放入放到queues:default:delayed集合中稍后执行。

休眠时间

以上代码是进程忙时连续执行,闲时休眠一秒,可以按需调整优化。

事件监听

若是需要在任务执行成功或失败时进行某些操作,可以给任务设定成功操作方法afterSucceeded()或失败操作方法afterFailed(),在相应的时候回调。

最后

以上讲述了一个任务调度程序的逐步演变,设计方案很大程度上参考了Laravel Queue。
用工具,知其然,知其所以然。