Skip to content

TCP 四次握手断开连接(图解)

26-2月-18

建立连接非常重要,它是数据正确传输的前提;断开连接同样重要,它让计算机释放不再使用的资源。如果连接不能正常断开,不仅会造成数据传输错误,还会导致套接字不能关闭,持续占用资源,如果并发量高,服务器压力堪忧。

建立连接需要三次握手,断开连接需要四次握手,可以形象的比喻为下面的对话:

  • [Shake 1] 套接字 A:“任务处理完毕,我希望断开连接。”
  • [Shake 2] 套接字 B:“哦,是吗?请稍等,我准备一下。”
  • 等待片刻后……
  • [Shake 3] 套接字 B:“我准备好了,可以断开连接了。”
  • [Shake 4] 套接字 A:“好的,谢谢合作。”

下图演示了客户端主动断开连接的场景:

TCP 断开连接图解

建立连接后,客户端和服务器都处于 ESTABLISED 状态。这时,客户端发起断开连接的请求:

1)客户端调用 close() 函数后,向服务器发送 FIN 数据包,进入 FIN_WAIT_1 状态。FIN 是 Finish 的缩写,表示完成任务需要断开连接。

2)服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入 CLOSE_WAIT 状态。

注意:服务器收到请求后并不是立即断开连接,而是先向客户端发送“确认包”,告诉它我知道了,我需要准备一下才能断开连接。

3)客户端收到“确认包”后进入 FIN_WAIT_2 状态,等待服务器准备完毕后再次发送数据包。

4)等待片刻后,服务器准备完毕,可以断开连接,于是再主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧。然后进入 LAST_ACK 状态。

5)客户端收到服务器的 FIN 包后,再向服务器发送 ACK 包,告诉它你断开连接吧。然后进入 TIME_WAIT 状态。

6)服务器收到客户端的 ACK 包后,就断开连接,关闭套接字,进入 CLOSED 状态。

关于 TIME_WAIT 状态的说明

客户端最后一次发送 ACK 包后进入 TIME_WAIT 状态,而不是直接进入 CLOSED 状态关闭连接,这是为什么呢?

TCP 是面向连接的传输方式,必须保证数据能够正确到达目标机器,不能丢失或出错,而网络是不稳定的,随时可能会毁坏数据,所以机器 A 每次向机器 B 发送数据包后,都要求机器 B ”确认“,回传 ACK 包,告诉机器 A 我收到了,这样机器 A 才能知道数据传送成功了。如果机器 B 没有回传 ACK 包,机器 A 会重新发送,直到机器 B 回传 ACK 包。

客户端最后一次向服务器回传 ACK 包时,有可能会因为网络问题导致服务器收不到,服务器会再次发送 FIN 包,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到 ACK 包了,所以客户端需要等待片刻、确认对方收到 ACK 包后才能进入 CLOSED 状态。那么,要等待多久呢?

数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这称为 报文最大生存时间(MSL,Maximum Segment Lifetime)TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。ACK 包到达服务器需要 MSL 时间,服务器重传 FIN 包也需要 MSL 时间,2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明服务器已经收到了 ACK 包。

TCP 三次握手(图解)

07-2月-18

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的通信协议,数据在传输前要建立连接,传输完毕后还要断开连接。

客户端在收发数据前要使用 connect() 函数和服务器建立连接。建立连接的目的是保证IP地址、端口、物理链路等正确无误,为数据的传输开辟通道。

TCP 建立连接时要传输三个数据包,俗称 三次握手(Three-way Handshaking)。可以形象的比喻为下面的对话:

  • [Shake 1] 套接字 A:“你好,套接字 B,我这里有数据要传送给你,建立连接吧。”
  • [Shake 2] 套接字 B:“好的,我这边已准备就绪。”
  • [Shake 3] 套接字 A:“谢谢你受理我的请求。”

TCP 数据包结构

我们先来看一下 TCP 数据包的结构:

TCP 数据包结构图

带阴影的几个字段需要重点说明一下:

1)序号:Seq(Sequence Number)序号占 32 位,用来标识从计算机 A 发送到计算机 B 的数据包的序号,计算机发送数据时对此进行标记。

2)确认号:Ack(Acknowledge Number)确认号占 32 位,客户端和服务器端都可以发送,Ack = Seq + 1。

3)标志位:每个标志位占用 1Bit,共有 6 个,分别为 URG、ACK、PSH、RST、SYN、FIN,具体含义如下:

  • URG:紧急指针(urgent pointer)有效。
  • ACK:确认序号有效。
  • PSH:接收方应该尽快将这个报文交给应用层。
  • RST:重置连接。
  • SYN:建立一个新连接。
  • FIN:断开一个连接。

对英文字母缩写的总结:Seq 是 Sequence 的缩写,表示序列;Ack(ACK) 是 Acknowledge 的缩写,表示确认;SYN 是 Synchronous 的缩写,原意是“同步的”,这里表示建立同步连接;FIN 是 Finish 的缩写,表示完成。

连接的建立(三次握手)

使用 connect() 建立连接时,客户端和服务器端会相互发送三个数据包,请看下图:

TCP 数据报结构图

客户端调用 socket() 函数创建套接字后,因为没有建立连接,所以套接字处于 CLOSED 状态;服务器端调用 listen() 函数后,套接字进入 LISTEN 状态,开始监听客户端请求。

这个时候,客户端开始发起请求:

1)当客户端调用 connect() 函数后,TCP 协议会组建一个数据包,并设置 SYN 标志位,表示该数据包是用来建立同步连接的。同时生成一个随机数字 1000,填充“序号(Seq)”字段,表示该数据包的序号。完成这些工作,开始向服务器端发送数据包,客户端就进入了 SYN-SEND 状态。

2)服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道这是客户端发来的建立连接的“请求包”。服务器端也会组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。

服务器生成一个随机数 2000,填充“序号(Seq)”字段。2000 和客户端数据包没有关系。

服务器将客户端数据包序号(1000)加 1,得到 1001,并用这个数字填充“确认号(Ack)”字段。

服务器将数据包发出,进入 SYN-RECV 状态。

3)客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器发来的“确认包”。客户端会检测“确认号(Ack)”字段,看它的值是否为 1000+1,如果是就说明连接建立成功。

接下来,客户端会继续组建数据包,并设置 ACK 标志位,表示客户端正确接收了服务器发来的“确认包”。同时,将刚才服务器发来的数据包序号(2000)加 1,得到 2001,并用这个数字来填充“确认号(Ack)”字段。

客户端将数据包发出,进入 ESTABLISED 状态,表示连接已经成功建立。

4)服务器端收到数据包,检测到已经设置了 ACK 标志位,就知道这是客户端发来的“确认包”。服务器会检测“确认号(Ack)”字段,看它的值是否为 2000+1,如果是就说明连接建立成功,服务器进入 ESTABLISED 状态。

至此,客户端和服务器都进入了 ESTABLISED 状态,连接建立成功,接下来就可以收发数据了。

最后的说明

三次握手的关键是要确认对方收到了自己的数据包,这个目标就是通过“确认号(Ack)”字段实现的。计算机会记录下自己发送的数据包序号 Seq,待收到对方的数据包后,检测“确认号(Ack)”字段,看 Ack = Seq + 1是否成立,如果成立说明对方正确收到了自己的数据包。

通用 Linux 服务器安全配置指南

25-1月-18

本文是可参考的实际操作,不涉及如 IP 欺骗这样的原理,而且安全问题也不是几行命令就能预防的,这里只是 Linux 系统上基本的安全加固方法,后续有新的内容再添加进来。

注:所有文件在修改之前都要进行备份如

cp /etc/passwd{,.bak}

1. 禁用不使用的用户

可以使用命令 usermod -L usernamepasswd -l username 锁定。

2. 关闭不使用的服务

启动 Linux 系统时,可以进入不同的模式,这模式我们称为执行等级(run level)。不同的执行等级有不同的功能与服务,目前你先知道正常的执行等级有两个,一个是具有 X 窗口界面的 run level 5,另一个则是纯文本界面的 run level 3。

chkconfig --list | grep '3:on'      // 显示出目前在 run level 为 3 启动的服务
chkconfig --level 2345 postfix on   // 让 postfix 这个服务在 run level 为 2,3,4,5 时启动

// 关闭邮件服务
service postfix stop
chkconfig --level 2345 postfix off

3. 禁用 IPV6

IPV6 是为了解决 IPV4 地址耗尽的问题,但我们的服务器一般用不到它,反而禁用 IPV6 不仅仅会加快网络,还会有助于减少管理开销和提高安全级别。

4. iptables 规则

启用 linux 防火墙来禁止非法程序访问。使用 iptable 的规则来过滤入站、出站和转发的包。我们可以针对来源和目的地址进行特定 udp/tcp 端口的准许和拒绝访问。

// 只允许指定 ip 访问 6379 端口服务
# vim /etc/sysconfig/iptables
-I INPUT -p tcp --dport 6379 -j DROP
-I INPUT -s 127.0.0.1 -p tcp --dport 6379 -j ACCEPT
-I INPUT -s 183.14.11.* -p tcp --dport 6379 -j ACCEPT

5. SSH 安全

如果有可能,第一件事就是修改 ssh 的默认端口 22,改成如 20002 这样的较大端口会大幅提高安全系数,降低 ssh 破解登录的可能性。

// 修改 ssh 端口
# vim /etc/ssh/sshd_config
Port 22333

// 登录超时。用户在线 5 分钟无操作则超时断开连接,在 `/etc/profile`中添加
export TMOUT=300
readonly TMOUT

// 禁止 root 直接远程登录
# vim /etc/ssh/sshd_config
PermitRootLogin no

// 限制登录失败次数并锁定。在 `/etc/pam.d/login` 后添加
auth required pam_tally2.so deny=6 unlock_time=180 even_deny_root root_unlock_time=180 // 登录失败 5 次锁定 180 秒

// 登录 IP 限制。在 sshd_config 中定死允许 ssh 的用户和来源 ip
## allowed ssh users sysmgr
AllowUsers sysmgr@172.29.73.*

6. 配置只能使用密钥文件登录

使用密钥文件代替普通的简单密码认证也会极大的提高安全性。

占位图

将公钥重名为 authorized_key

# mv ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys
# chmod 600 ~/.ssh/authorized_keys

下载私钥文件 id_rsa 到本地(为了更加容易识别,可重命名为 hostname_username_id_rsa),保存到安全的地方。以后 username 用户登录这台主机就必须使用这个私钥,配合密码短语来登录(不再使用 username 用户自身的密码)

另外还要修改 /etc/ssh/sshd_conf 文件,打开注释

RSAAuthentication yes
PubkeyAuthentication yes
AuthorizedKeysFile      .ssh/authorized_keys

PasswordAuthentication no

使用命令 service sshd restart 重启 sshd 服务。

7. 减少 history 命令记录

执行过的历史命令记录越多,从一定程度上讲会给维护带来简便,但同样会伴随安全问题。

# vim /etc/profile
HISTSIZE=50

或每次退出时,使用命令 history -c 清理 history。

8. 增强特殊文件权限

给下面的文件加上不可更改属性,从而防止非授权用户获取权限

chattr +i /etc/passwd
chattr +i /etc/shadow
chattr +i /etc/group
chattr +i /etc/gshadow
chattr +i /etc/services         // 给系统服务端口列表文件加锁,防止未经许可的删除或添加服务
chattr +i /etc/pam.d/su
chattr +i /etc/ssh/sshd_config
chattr +i /var/spool/cron/root  // root 用户定时任务

9. 防止一般网络攻击

网络攻击不是几行设置就能避免的,以下都只是些简单的将可能性降到最低,增大攻击的难度但并不能完全阻止。

// 禁 ping
# echo 1 > /proc/sys/net/ipv4/icmp_echo_ignore_all

// 或使用 iptable 禁 ping
# iptables -A INPUT -p icmp --icmp-type 0 -s 0/0 -j DROP
# iptables -A OUTPUT -p icmp --icmp-type 8 -j DROP          // 不允许ping其他主机

// 防止 IP 欺骗。编辑 `/etc/host.conf` 文件并增加如下几行
order hosts,bind
multi on
nospoof on

10. 定期做日志安全检查

将日志移动到专用的日志服务器里,这可避免入侵者轻易的改动本地日志。

大型网站架构演化

19-1月-18

很久以前读过一本书,书名 《大型网站技术架构》。因为架构是个很大的命题,此书篇幅有限,写的内容比较泛,比较适合刚接触网站架构的童鞋,读完此书对网站架构体系能有一个整体的认知。现将书中主要内容整理如下:

1. 初始阶段的网站架构

应用程序、数据库、文件等所有的资源都放在一台服务器上。使用 LNAMP,汇集各种免费开源软件及一台廉价服务器就可以开始网站的发展之路了。

占位图

2. 应用服务和数据服务分离

随着网站业务的发展,一台服务器逐渐不能满足需求,这时就需要将应用和数据分离。应用和数据分离后整个网站使用三台服务器:应用服务器、文件服务器、和数据库服务器。

这三台服务器对硬件资源的要求各不相同:

  1. 应用服务器需要处理大量的业务逻辑,因此需要更快更强大的 CPU;
  2. 数据库服务器需要快速磁盘检索和数据缓存,因此需要更快的硬盘和更大的内存;
  3. 文件服务器需要存储大量用户上传的文件,因此需要更大的硬盘。

占位图

3. 使用缓存改善网站性能

随着用户逐渐增多,网站又一次面临挑战:数据库压力太大导致访问延迟,进而影响整网站的性能。

网站访问特点和现实世界的财富分配一样遵循二八定律:80% 的业务访问集中在 20% 的数据上。

既然大部分的业务访问集中在一小部分数据上,把这一小部分数据缓存在内存中,减少数据库的访问压力,提高整个网站的数据访问速度。

网站使用的缓存可以分为两种:缓存在应用服务器上的本地缓存和缓存在专门的分布式缓存服务器上的远程缓存。本地缓存的访问速度更快一些,但是受应用服务器内存限制,其缓存数据量有限,而且会出现和应用程序争用内存的情况。远程分布式缓存可以使用集群的方式,部署大内存的服务器作为专门的缓存服务器。

占位图

4. 使用应用服务器集群改善网站的并发处理能力

单一应用服务器能够处理的请求连接有限,在网站访问高峰期,应用服务器成为整个网站的瓶颈。

对网站架构而言,只要能通过增加一台服务器的方式改善负载压力,就可以以同样的方式持续增加服务器不断改善系统性能,从而实现系统的可伸缩性

通过负载均衡调度服务器,可将来自用户浏览器的访问请求分发到应用服务器集群中的任何一台服务器上。

占位图

5. 数据库读写分离

使用缓存后,仍有一部分缓存访问不命中、缓存过期和全部的写操作需要访问数据库。

主从复制,实现数据库读写分离,从而改善数据库负载压力。

占位图

6. 使用反向代理和 CDN 加速网站响应

随着网站业务不断发展,用户规模越来越大,由于中国复杂的网络环境,不同地区的用户访问网站时,速度差别也极大。使用 CDN 和反向代理的目的都是尽早返回数据给用户。

CDN 和反向代理的基本原理都是缓存,区别在于 CDN 部署在网络提供商的机房,使用户在请求网站服务时,可以从距离自己最近的网络提供商机房获取数据;而反向代理则部署在网站的中心机房。

占位图

7. 使用分布式文件系统和分布式数据库系统

任何强大的单一服务器都满足不了大型网站持续增长的业务需求。数据库经过读写分离后,从一台服务器拆分成两台服务器,但是随着网站业务的发展依然不能满足需求,这时需要使用分布式数据库。文件系统也一样,需要使用分布式文件系统。

分布式数据库是网站数据库拆分的最后手段,只有在单表数据规模非常庞大的时候才使用。不到不得已时,网站更常用的数据库拆分手段是业务分库,将不同业务的数据部署在不同的物理服务器上。

占位图

8. 使用 NoSQL 和搜索引擎

随着网站业务越来越复杂,对数据存储和检索的需求也越来越复杂,网站需要采用一些非关系型数据库技术如 NoSQL 和非数据库查询技术如搜索引擎。

NoSQL 和搜索引擎对可伸缩的分布式特性具有更好的支持。应用服务器则通过一个统一数据访问模块访问各种数据,减轻应用程序管理诸多数据源的麻烦。

占位图

9. 业务拆分

大型网站为了应对日益复杂的业务场景,通过使用分而治之的手段将整个网站业务分成不同的产品线,如大型购物交易网站就会将首页、商铺、订单、买家、卖家等拆分成不同的产品线,分归不同的业务团队负责。

具体到技术上,也会根据产品线划分,将一个网站拆分成许多不同的应用,每个应用独立部署维护。

占位图

10. 分布式服务

随着业务拆分越来越小,存储系统越来越庞大,应用系统的整体复杂度呈指数级增加,部署维护越来越困难。由于所有应用要和所有数据库系统连接,在数万台服务器规模的网站中,这些连接的数目是服务器规模的平方,导致数据库连接资源不足,拒绝服务。

既然每一个应用系统都需要执行许多相同的业务操作,比如用户管理、商品管理等,那么可以将这些共用的业务提取出来,独立部署。由这些可复用的业务连接数据库,提供共用业务服务,而应用系统只需要管理用户界面,通过分布式服务调用共用业务服务完成具体业务操作。

占位图

常用命令笔记(一)

26-12月-17

工作中常用命令汇总。持续更新 ing ……

// 去除字段内容中的换行和回车
UPDATE `table` SET `position`= REPLACE(REPLACE(`position`, CHAR(10), ''), CHAR(13), '') WHERE 条件;

// svn 更新冲突选项说明
tc 以svn服务器上为准
mc 以本地代码为准

// git 提交
git add .
git commit -m 'My first file'
git push origin master

// find 命令删除文件  
find . -type f -size 768c -delete // c 代表 bytes
find . -name 'COCKIE*' -delete

// 统计文件数量
ls -l|grep 'error_log'|wc -l

// 动态修改 Redis 配置参数
CONFIG SET requirepass password

// 打包并以 gzip 压缩
tar -zcvf abc.tar.gz test 123.txt // 目录 test 文件 123.txt

// scp 命令
scp -P22 -r ./backupdatabase.sh root@120.24.**.**:/root/

// 设置临时 IP 地址
ifconfig eth0 ip 地址 

// 创建软链接
ln -s ./release_v4.2.1.20171102/api.xxx.com ./api.xxx.com

// chattr 命令
chattr +i api.xxx.com               // 设定文件不能被删除、改名、设定链接关系,同时不能写入或新增内容
chattr -R +a uploadFiles            // 只能向文件中附加数据

// 查看磁盘使用情况
df -h

// 查看当前目录下所有一级子目录文件夹大小
du -h --max-depth=1 ./

// 批量杀死进程
kill -9 $(lsof -i tcp:6379 -t)
ps -ef|grep root.sh|grep -v grep|awk '{print "kill -9 " $2}'|sh

// 查看某一端口的占用情况
lsof -i:端口号
netstat -tunlp|grep 端口号

// 查看所有端口
netstat -tunlp  // t 代表 tcp,u 代表 udp

// 查看不同状态的连接数量
netstat -an | awk '/^tcp/ {++y[$NF]} END {for(w in y) print w, y[w]}'

Iptables 配置
// 屏蔽网址
-A OUTPUT -m string --string "lnk0.com" --algo bm -j DROP 
// 只允许指定 ip 访问 redis 服务
-I INPUT -p tcp --dport 6379 -j DROP
-I INPUT -s 127.0.0.1 -p tcp --dport 6379 -j ACCEPT
-I INPUT -s 183.14.28.173 -p tcp --dport 6379 -j ACCEPT

PHP 加解密函数

26-12月-17

超好用的一对 PHP 加解密函数。

/**
 * 加密函数
 * @author zhuhz
 * @param string $string 需要加密的字符串
 * @param string $key 密钥
 * @return string 返回加密结果
 */
function encrypt($string, $key = '') {
    if (empty($string)) return $string;
    if (empty($key)) $key = config_item('encryption_key');
    $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
    $ikey ="vxGx2BkOm0qIr9s36-Rgp6Z.r6ogZm_W";
    $nh1 = rand(0,64);
    $nh2 = rand(0,64);
    $nh3 = rand(0,64);
    $ch1 = $chars{$nh1};
    $ch2 = $chars{$nh2};
    $ch3 = $chars{$nh3};
    $nhnum = $nh1 + $nh2 + $nh3;
    $knum = 0;$i = 0;
    while(isset($key{$i})) {
        $knum +=ord($key{$i++});
    }
    $mdKey = substr(md5(md5(md5($key.$ch1).$ch2.$ikey).$ch3),$nhnum%8,$knum%8 + 16);
    $string = base64_encode(time().'_'.$string);
    $string = str_replace(array('+','/','='),array('-','_','.'),$string);
    $tmp = '';
    $j=0;$k = 0;
    $tlen = strlen($string);
    $klen = strlen($mdKey);
    for ($i=0; $i<$tlen; $i++) {
        $k = ($k == $klen) ? 0 : $k;
        $j = ($nhnum+strpos($chars,$string{$i})+ord($mdKey{$k++}))%64;
        $tmp .= $chars{$j};
    }
    $tmplen = strlen($tmp);
    $tmp = substr_replace($tmp,$ch3,$nh2 % ++$tmplen,0);
    $tmp = substr_replace($tmp,$ch2,$nh1 % ++$tmplen,0);
    $tmp = substr_replace($tmp,$ch1,$knum % ++$tmplen,0);

    return $tmp;
}

/**
 * 解密函数
 * @author zhuhz
 * @param string $string 需要解密的字符串
 * @param string $key 密匙
 * @return string 字符串类型的返回结果
 */
function decrypt($string, $key = '', $ttl = 0) {
    if (empty($string)) return $string;
    if (empty($key)) $key = config_item('encryption_key');
    $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
    $ikey ="vxGx2BkOm0qIr9s36-Rgp6Z.r6ogZm_W";
    $knum = 0;$i = 0;
    $tlen = strlen($string);
    while(isset($key{$i})) {
        $knum +=ord($key{$i++});
    }
    $ch1 = $string{$knum % $tlen};
    $nh1 = strpos($chars,$ch1);
    $string = substr_replace($string,'',$knum % $tlen--,1);
    $ch2 = $string{$nh1 % $tlen};
    $nh2 = strpos($chars,$ch2);
    $string = substr_replace($string,'',$nh1 % $tlen--,1);
    $ch3 = $string{$nh2 % $tlen};
    $nh3 = strpos($chars,$ch3);
    $string = substr_replace($string,'',$nh2 % $tlen--,1);
    $nhnum = $nh1 + $nh2 + $nh3;
    $mdKey = substr(md5(md5(md5($key.$ch1).$ch2.$ikey).$ch3),$nhnum % 8,$knum % 8 + 16);
    $tmp = '';
    $j=0; $k = 0;
    $tlen = strlen($string);
    $klen = strlen($mdKey);
    for ($i=0; $i<$tlen; $i++) {
        $k = ($k == $klen) ? 0 : $k;
        $j = strpos($chars,$string{$i})-$nhnum - ord($mdKey{$k++});
        while ($j<0) $j+=64;
        $tmp .= $chars{$j};
    }
    $tmp = str_replace(array('-','_','.'),array('+','/','='),$tmp);
    $tmp = trim(base64_decode($tmp));

    if (preg_match("/\d{10}_/s",substr($tmp,0,11))){
        if ($ttl > 0 && (time() - substr($tmp,0,11) > $ttl)){
            $tmp = null;
        }else{
            $tmp = substr($tmp,11);
        }
    }

    return $tmp;
}

PHP 实现 Hash 表

09-12月-17

Hash 表(HashTable)又称散列表,通过把关键字 Key 映射到数组中的一个位置来访问记录,以加快查找的速度。这个映射函数称为 Hash 函数,存放记录的数组称为 Hash 表。

Hash 表结构

Hash 表结构

Hash 表的实现步骤如下:

  1. 创建一个固定大小的数组用于存放数据。
  2. 设计 Hash 函数。
  3. 通过 Hash 函数把关键字 Key 映射到数组的某个位置,并在此位置上进行数据存取。

PHP 具体实现

首先创建一个 HashTable 类。

<?php
class HashTable 
{
    private $buckets;   // 用于存储数据的数组
    private $size = 10; // $buckets 数组大小

    public function __construct()
    {
        $this->buckets = new SplFixedArray($this->size);    
    }

    /**
     * Hash 函数
     */
    private function hashfunc($key)
    {
        $strlen = strlen($key);
        $hashval = 0;
        for ($i = 0; $i < $strlen; $i++) {
            $hashval += ord($key[$i]);
        }
        return $hashval % $this->size;
    }

    /**
     * 插入数据
     */
    public function insert($key, $value)
    {
        $index = $this->hashfunc($key);
        // 新建一个节点
        if (isset($this->buckets[$index])) {
            $newNode = new HashNode($key, $value, $this->buckets[$index]);
        } else {
            $newNode = new HashNode($key, $value, null);
        }
        $this->buckets[$index] = $newNode;
    }

    /**
     * 查找数据
     */
    public function find($key)
    {
        $index = $this->hashfunc($key);
        $current = $this->buckets[$index];
        while (isset($current)) {           // 遍历当前链表
            if ($current->key == $key) {    // 比较当前节点的关键字
                return $current->value;     // 查找成功
            }
            $current = $current->nextNode;  // 比较下一个节点
        }
        return null; // 查找失败
    }
}

节点需要保存关键字 Key 和 Value,同时还要记录具有相同 Hash 值的节点。所以创建一个 HashNode 类存储这些信息。

<?php
class HashNode
{
    public $key;        // 节点关键字
    public $value;      // 节点值
    public $nextNode;   // 指向具有相同 Hash 值节点的指针

    public function __construct($key, $value, $nextNode = null)
    {
        $this->key = $key;
        $this->value = $value;
        $this->nextNode = $nextNode;
    }
}

测试代码如下:

<?php
$ht = new HashTable();
$ht->insert('key1', 'value1');
$ht->insert('key12', 'value12');
echo $ht->find('key1');
echo $ht->find('key12');

/** 
*   相同 Hash 值的节点会被连接在同一个链表。
*   
*   HashNode Object
*   (
*       [key] => key12
*       [value] => value12
*       [nextNode] => HashNode Object
*           (
*               [key] => key1
*               [value] => value1
*               [nextNode] => 
*           )
*   
*   )
*/

Hash 表冲突

冲突的原因是:不同的关键字通过 Hash 函数计算出来的 Hash 值相同。通过打印 “key1” 和 “key12” 的 Hash 值,可以发现它们都是 8。也就是说 “value1” 和 “value12” 同时被存储在 Hash 表的第 9 个位置上(索引从 0 开始)。

修改后的插入算法流程如下:

  1. 使用 Hash 函数计算关键字的 Hash 值,通过 Hash 值定位到 Hash 表的指定位置。
  2. 如果此位置已经被其他节点占用,把新节点的 $nextNode 指向此节点,否则把新节点的 $nextNode 设置为 NULL。
  3. 把新节点保存到 Hash 表的当前位置。

修改后的查找算法流程如下:

  1. 使用 Hash 函数计算关键字的 Hash 值,通过 Hash 值定位到 Hash 表的指定位置。
  2. 遍历当前链表,比较链表中每个节点的关键字与查找关键字是否相等。如果相等,查找成功。
  3. 如果整个链表都没有要查找的关键字,查找失败。

Redis 使用与实践

25-11月-17

RedisKey-Value 类型的内存数据库,支持 String、List、Set、Sorted Set、Hash 等数据类型,支持 Snapshottiong(快照)和 Append-Only file(追加)两种数据持久化方式,支持主从复制。

1. Redis 安装步骤

// 下载 Redis 的稳定版本
$ wget http://download.redis.io/releases/redis-4.0.2.tar.gz 
// 解压缩
$ tar zvxf redis-4.0.2.tar.gz
// 编译 Redis
$ make
// 安装 Redis,Redis 没有提供 make install 命令,需要手动安装
$ sudo cp redis.conf /etc/
$ sudo cp redis-benchmark redis-cli redis-server /usr/bin/

2. 运行 Redis 服务器

// 启动 Redis 服务器,并指定配置文件 
$ /usr/bin/redis-server /etc/redis.conf
// 启动一个客户端
$ /usr/bin/redis-cli -a 'password'

3. Redis 支持的数据类型

3.1 String 类型

占位图

String 类型支持 incr 操作,可以用做统计计算,如统计网站访问次数、博客访问次数等。

3.2 List 类型

占位图

List 数据类型 key 对应的 value 是一个双向链表结构。

3.3 Set 类型

占位图

Set 数据类型是一种无序集合,在 Redis 内部通过 HashTable 实现。优点是快速查找元素是否存在,用于记录一些不能重复的数据。

Set 类型通常用于记录做过某些事情。例如在某些投票系统中,每个用户一天只能投票一次,那么可以使用 Set 来记录某个用户的投票情况,只需要以日期作为 Set 的 key,则将用户 ID 作为 member 即可。要查看某个用户今天是否投过票,只需要以今天的日期作为 key 去查询此用户 ID 是否存在。

3.4 Sorted Set 类型

// 添加元素 member 到集合,元素在集合中存在则更新对应 score
zadd key score member
// 增加对应 member 的 score 值,并且重新排序,返回更新后的 score 值
zincrby key incr member

占位图

Sorted Set 类型是排序的 Set 类型,属于有序集合。

Sorted Set 类型在 Web 应用中非常有用。例如排行榜应用中的按 “顶贴” 次数排序,方法是:将排序的值设置成 Sorted Set 的 score 值,将具体数据设置成相应的 value,用户每次按 “顶贴” 按钮时,只需执行 zadd 命令修改 score 值。

3.5 Hash 类型

占位图

Hash 类型是每个 key 对应一个 HashTable。Hash 类型适合应用于存储对象,例如用户信息对象。把用户 ID 作为 key,可把用户信息保存到 Hash 类型中。

4. 持久化

Redis 是基于内存的数据库,内存数据库有一个严重的弊端:突然宕机或者断电时,内存的数据不会保存。为了解决这个问题,Redis 提供两种持久化方式:内存快照(Snapshotting)和 日志追加(Append-only file)。

4.1 内存快照

内存快照方式是将内存中的数据以快照方式写入二进制文件中,默认文件名为 dump.rdb。

Redis 每隔一段时间进行一次内存快照操作,客户端使用 save 或者 bgsave 命令告诉 Redis 需要做一次内存快照操作。save 命令在主线程中保存内存快照,Redis 由单线程处理所有请求,执行 save 命令可能阻塞其他客户端请求,从而导致不能快速响应请求,所以建议不要使用 save 命令。另外要注意,内存快照每次都把内存数据完整地写入硬盘,而不是只写入增量数据。所以如果数据量大,写入操作比较频繁,从而严重影响性能。

与内存快照相关的配置选项如下:

// 经过 900 秒或数据更改 1 次就进行一次内存快照操作
save 900 1
// 设置多个这样的条件实现不同内存快照方案,这样其中一个条件成立,Redis 都进行一次内存快照操作
save 900 1
save 300 10
save 60 10000

4.2 日志追加

日志追加(aof)方式是把增加、修改数据的命令通过 write 函数追加到文件尾部(默认是 appendonly.aof)。Redis 重启时读取 appendonly.aof 文件中的所有命令并且执行,从而把数据写入内存中。

另外,操作系统内核的 I/O 接口可能存在缓存,所以日志追加方式不可能立即写入文件中,这样就有可能丢失部分数据。幸运的是 Redis 提供了解决方法,通过修改配置文件告诉 Redis 应该在什么时候用 fsync 函数强制操作系统把缓存写入磁盘。有以下三种写法:

appendonly yes           # 启动日志追加持久化方式
# appendfsync always     # 每次收到增加或者修改命令就立刻强制写入磁盘
appendfsync everysec     # 每秒强制写入磁盘一次
# appendfsync no         # 是否写入磁盘完全依赖操作系统

日志追加方式有效降低数据丢失的风险,同时也带来另一个问题,即持久化文件(appendonly.aof)不断膨胀。例如调用 incr nums 命令 100 次,文件就会保存 100 条 incr nums 命令,其实 99 条都是多余的,因为要恢复数据只需要 set nums 100

为了压缩日志文件,Redis 提供 bgrewriteaof 命令。当 Redis 收到此命令,就使用类似于内存快照方式将内存的数据以命令的方式保存到临时文件中,最好替换原来的日志文件。

5. 扩展库 phpredis 安装及使用

// 下载 phpredis 源码
$ wget https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz
// 解压
$ tar -xzvf 3.1.4.tar.gz
// 编译安装
$ cd phpredis-3.1.4
$ /usr/local/php/bin/phpize
$ ./configure -with -php -config = /usr/local/php/bin/php -config
$ make
$ make install
// 修改 php.ini
添加 extension = redis.so,重启 Web 服务器

扩展资料

  1. Redis 未授权访问漏洞

MySQL 主从复制

20-11月-17

主从复制功能通过在主服务器和从服务器之间切分处理客户查询的负荷,可以得到更好的客户响应时间, SELECT 查询可以发送到从服务器,以降低主服务器的查询处理负荷。修改数据的语句仍然发送到主服务器,以使主、从服务器保持同步。

1. 主从复制原理

主从复制通过 3 个过程实现,其中一个过程发生在主服务器上,另外两个过程发生在从服务器上。具体如下:

  • 主服务器将用户对数据库更新的操作以二进制格式保存到 Binary Log 日志文件中,然后由 Binlog Dump 线程将 Binary Log 日志文件传输给从服务器。

  • 从服务器通过一个 I/O 线程将主服务器的 Binary Log 日志文件中的更新操作复制到一个叫 Relay Log 的中继日志文件中。

  • 从服务器通过另一个 SQL 线程将 Relay Log 中继日志文件中的操作依次在本地执行,从而实现主从之间数据的同步。

主从复制详细过程如图:

主从复制

BinLog Dump 线程:

BinLog Dump 线程运行在主服务器上,主要工作是把 Binary Log 二进制日志文件的数据发送给从服务器。

I/O 线程:

从服务器执行 START SLAVE 语句后,创建一个 I/O 线程。此线程运行在从服务器上,与主服务器建立连接,然后向主服务器发出更新请求。之后,I/O 线程将主服务器发送的更新操作复制到本地 Relay Log 日志文件中。

SQL 线程:

SQL 线程运行在从服务器上,主要工作是读取 Relay Log 日志文件中的更新操作,并将操作依次执行,从而使主从服务器数据得到同步。

2. 主从复制配置

  1. 确认主从服务器的 MySQL 版本。

    MySQL 不同版本的 BinLog 格式可能不一样,最好采用相同版本。

  2. 在主服务器上为从服务器设置一个连接账户,授予 REPLICATION SLAVE 权限。

    mysql> create user 'backup'@'%' identified by '123456';
    mysql> grant replication slave on *.* to 'backup'@'%' identified by '123456';       
    
  3. 配置主服务器。

    打开二进制日志,指定唯一 Server ID。例如,在 my.cnf 配置文件中加入如下值:

    [mysqld]        
    log-bin = mysql-bin
    server-id = 1
    
  4. 重启主服务器。

    运行 show master status 语句,输出如图所示。

    占位图

    File 表示主服务器正在使用的 binlog 文件; Position 的值与 binlog 文件的大小相同,表示下一个被记录事件的位置; Binlog_Do_DBBinlog_lgnore_DB 是主服务器控制写入 binlog 文件内容的过滤选项,默认为空,表示不做任何过滤。

    FilePosition 两个字段指明从服务器将从哪个 binlog 文件中复制,以及复制的开始位置,它们也是 CHANGE MASTER TO 语句的参数。

  5. 配置从服务器。

    从服务器的配置与主服务器类似,必须提供一个唯一 Server ID (不能跟主服务器 ID 相同),配置完成后重启。

    [mysqld]
    log-bin = mysql-bin
    server-id = 2
    
  6. 指定主服务器信息。

    mysql> change master to 
    -> master_host='主服务器ip',
    -> master_user='backup',
    -> master_password='123456',
    -> master_port=3306,
    -> master_log_file='mysql-bin.000001',
    -> master_log_pos=0;
    

    此处指定 master_log_pos 的值为 0,因为要从日志的开始位置开始读。

3. 连接主从服务器。

  1. 执行 start slave 语句开始复制。

    mysql> start slave;
    
  2. 使用 show slave status \G 查看输出结果:

    占位图

    Slave_IO_RunningSlave_SQL_Running 值为 Yes 表明复制过程正常。

  3. 使用 show processlist 语句查看主、从服务器的线程状态。

    在主服务器上

    占位图

    第 1 行就是处理从服务器的 I/O 线程的连接。

    在从服务器上

    占位图

    第 1 行为 I/O 线程状态,图中没有 SQL 线程状态,因为 SQL 线程未开始运行。



扩展

// 刷新权限
mysql> flush privileges;
// 查看用户权限
mysql> show grants for backup;
// 可赋予的权限
mysql> grant select,insert,update,delete,alter,create,drop,lock tables on dbname.* to 'backup'@'%';
// 取消所有权限
mysql> revoke all on dbname.* from 'backup'@'%';
// 获取全局读锁
mysql> flush tables with read lock;
// 释放锁
mysql> unlock tables;

PHP cURL 扩展库

19-11月-17

cURL 是一个通用的库,并非 PHP 独有。PHP cURL 扩展库 是一个封装的函数库。可以用来模拟浏览器和服务器进行交互,功能比较强大。

1. 建立 cURL 请求

<?php
// 1. 初始化
$ch = curl_init();
// 2. 设置选项,包括 URL
curl_setopt($ch, CURLOPT_URL, "http://www.163100.com");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); // 将 curl_exec() 获取的信息以文件流的形式返回,而不是直接输出
curl_setopt($ch, CURLOPT_HEADER, 1);         // 启动时会将头文件的信息作为数据流输出
curl_setopt($ch, CURLOPT_TIMEOUT, 60);       // 设置 cURL 允许执行的最长秒数
// 3. 执行并获取 HTML 文档内容
$output = curl_exec($ch);
if ($output === false) {
    echo 'cURL Error:' . curl_error($ch);
}
// 4. 释放 cURL 句柄
curl_close($ch);
echo $output; 

很多时候并不需要 header 头,把 CURLOPT_HEADER 设为 0 或者不设置(默认为 0)。

2. 获取请求相关的信息

通过 curl_getinfo() 函数可以返回 cURL 执行后这一请求相关的信息,如下所示:

<?php 
// ...
curl_exec($ch);
$info = curl_getinfo($ch);
echo '获取' . $info['url'] . '耗时' . $info['total_time'] . '秒';

// 返回 $info 数组如下
Array
(
    [url] => http://www.163100.com
    [content_type] => text/html; charset=UTF-8  // 内容编码
    [http_code] => 200
    [header_size] => 252                        // header 的大小
    [request_size] => 53                        // 请求的大小
    [filetime] => -1                            // 文件创建时间
    [ssl_verify_result] => 0                    // SSL 验证结果
    [redirect_count] => 0                       // 跳转次数
    [total_time] => 0.109                       // 耗时
    [namelookup_time] => 0                      // DNS 查询时间
    [connect_time] => 0.016                     // 连接时间
    [pretransfer_time] => 0.016                 // 准备传输耗时
    [size_upload] => 0                          // 上传数据大小
    [size_download] => 15903                    // 下载数据大小
    [speed_download] => 145899                  // 下载速度
    [speed_upload] => 0                         // 上传速度
    [download_content_length] => -1
    [upload_content_length] => 0
    [starttransfer_time] => 0.094               // 开始传输耗时
    [redirect_time] => 0                        // 重定向耗时
)

3. 在 cURL 中用 POST 方法发送数据

<?php
$url = "http://localhost/post.php";
$post_data = array(
    'foo'    =>  'bar',
    'query'  =>  'php',
    'action' => 'submit'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// 设置为 POST
curl_setopt($ch, CURLOPT_POST, 1);
// 把 POST 的变量加上
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
$output = curl_exec($ch);
if ($output === false) {
    echo 'cURL Error:' . curl_error($ch);
}
curl_close($ch);
echo $output;

4. 使用 cURL 上传文件

上传文件和 POST 类似,只需要把文件路径当作一个 POST 变量传过去,不过记得在前面加上 @ 符号。

<?php
// ...
$post_data = array(
    'foo'    => 'bar',
    'upload' => '@test.zip'
);
// ....


参考资料

  1. Techniques for Mastering cURL