如何在go中优雅的热升级服务

一、概述

在日常业务中,服务会经常升级,但是因为某些原因不希望断开和客户端的连接。因此就需要服务的热升级技术。

在研究这个问题之前,可以先看一下nginx是如何做到不间断服务热重启的。

  • 将新的nginx可执行文件替换掉旧的可执行文件。
  • master进程发送USR2信号,master进程在接收到信号后会将pid文件命名为.oldbin后缀。之后启动新的可执行文件,并启动新的worker进程。这个时候会有两个master进程
  • 向第一个master进程发送WINCH信号。第一个master进程会通知旧的worker进程优雅(处理完当前请求)地退出。
  • 如果此时新的可执行文件有问题。可以做以下措施:
    • 向旧的master进程发送HUP信号,旧的master进程会启动新的worker进程,并且不会重新读取配置文件。然后会向新的master进程发送QUIT信号来要求其退出。
    • 发送TERM信号到新的master进程,新的master进程和其派生的worker进程都会立刻退出。旧的master进程会自动启动新的worker进程。
  • 如果升级成功,QUIT信号会发送给旧的master进程,并退出。

完整的文档可以看: http://nginx.org/en/docs/control.html#upgrade

在go中处理这个问题也是这个思路。

二、具体实现

2.1 定义服务

type Server struct {
l         net.Listener  // 监听端口
conns     map[int]net.Conn  // 当前服务的所有连接
rw        sync.RWMutex      // 读写锁,用来保证conns在并发情况下的正常工作
idLock    sync.Mutex        // 锁,用来保证idCursor在并发情况下的递增没有问题
idCursor  int               // 用来标记当前连接的id
isChild   bool // 是否是子进程
status    int  // 当前服务的状态
relaxTime int  // 在退出时允许协程处理请求的时间
}

2.2 处理信号

func (s *Server) handleSignal() {
sc := make(chan os.Signal)

signal.Notify(sc, syscall.SIGHUP, syscall.SIGTERM)

for {
sig := <-sc

switch sig {
case syscall.SIGHUP:
log.Println("signal sighup")
// reload
go func() {
s.fork()
}()
case syscall.SIGTERM:
log.Println("signal sigterm")
// stop
s.shutdown()
}
}
}

这里只处理了两个信号,HUP表示要热升级服务,此时会fork一个新的服务。TERM表示要终止服务。

2.3 如何fork新的服务

func (s *Server) fork() (err error) {

log.Println("start forking")
serverLock.Lock()
defer serverLock.Unlock()

if isForked {
return errors.New("Another process already forked. Ignoring this one")
}

isForked = true

files := make([]*os.File, 1+len(s.conns))
files[0], err = s.l.(*net.TCPListener).File() // 将监听带入到子进程中
if err != nil {
log.Println(err)
return
}

i := 1
for _, conn := range s.conns {
files[i], err = conn.(*net.TCPConn).File()

if err != nil {
log.Println(err)
return
}

i++
}

env := append(os.Environ(), CHILD_PROCESS+"=1")
env = append(env, fmt.Sprintf("%s=%s", SERVER_CONN, strconv.Itoa(len(s.conns))))

path := os.Args[0] // 当前可执行程序的路径
var args []string
if len(os.Args) > 1 {
args = os.Args[1:]
}

cmd := exec.Command(path, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = files
cmd.Env = env

err = cmd.Start()
if err != nil {
log.Println(err)
return
}

return
}

这里会将监听的文件描述符以及所有连接的文件描述符都带到新的服务中。这里只需要在新的服务中重新使用这些文件描述符即可保证不断开连接。

2.4 服务的启动流程

func (s *Server) Start(addr string) {
var err error

log.Printf("pid: %v \n", os.Getpid())

s.setState(StateInit)

if s.isChild {
log.Println("进入子进程")
// 通知父进程停止
ppid := os.Getppid()

err := syscall.Kill(ppid, syscall.SIGTERM)

if err != nil {
log.Fatal(err)
}

// 子进程, 重新监听之前的连接
connN, err := strconv.Atoi(os.Getenv(SERVER_CONN))
if err != nil {
log.Fatal(err)
}

for i := 0; i < connN; i++ {
f := os.NewFile(uintptr(4+i), "")
c, err := net.FileConn(f)
if err != nil {
log.Print(err)
} else {
id := s.add(c)
go s.handleConn(c, id)
}
}
}

s.l, err = s.getListener(addr)
if err != nil {
log.Fatal(err)
}
defer s.l.Close()

log.Println("listen on ", addr)

go s.handleSignal()

s.setState(StateRunning)

for {
log.Println("start accept")
conn, err := s.l.Accept()
if err != nil {
log.Fatal(err)
return
}

log.Println("accept new conn")

id := s.add(conn)
go s.handleConn(conn, id)
}

}

func (s *Server) getListener(addr string) (l net.Listener, err error) {
if s.isChild {
f := os.NewFile(3, "")
l, err = net.FileListener(f)
return
}

l, err = net.Listen("tcp", addr)

return
}

启动时,会判断是否是fork出的新的进程。如果是,则继承从父进程传递过来的文件描述符,并重新监听或作为连接处理。

完整的代码参考github: https://github.com/joyme123/graceful_restart_server_in_golang_demo

go WebAssembly初体验

WebAssembly是一门新的浏览器技术。可以取代一部分js的角色,并且从性能上来说,要比js好很多。WebAssembly目前还处于早期的发展阶段,仍然不够成熟。go语言对WebAssembly的支持也是在1.11版本上刚刚加入。虽然不能投入生产环境,但是可以用来做一些很有意思的事情。

官方WebAssembly的文档:https://github.com/golang/go/wiki/WebAssembly。详细的介绍可以看官方的文档。

一个go WebAssembly的例子

main.go

package main

import "fmt"

func main() {
    fmt.Println("hello WebAssembly")
}

编译这个go文件: GOOS=js GOARCH=wasm go build -o main.wasm main.go

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Go wasm</title>
</head>
<body>
    <script src="wasm_exec.js"></script>
    <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(
            fetch("main.wasm"),go.importObject).then((result) => {
            go.run(result.instance)
        });

    </script>
</body>
</html>

这里还需要一个wasm_exec.js。这个文件是官方提供的,可以理解为是go编译出来的二进制文件和js间的连接桥梁。这个时候打开浏览器,可以看到控制台下有:hello WebAssembly

需要说明的是,在go里面的所有STDOUT,都会在浏览器的控制台打印出来。

wasm的初始化过程

WebAssembly.instantiateStreaming(fetch("main.wasm"),go.importObject)。浏览器执行这样一段代码来加载main.wasm,并返回一个Promise对象。在加载完毕之后,调用go.run(result.instance)来执行wasm中的代码。

加载一个wasm文件就是这样的简单。

go WebAssembly如何和js交互

上面的例子非常简单。但是WebAssembly技术绝非这样简单。我们可以在go中轻松的调用js中的方法。比如下面这句话:

js.Global().Get("document").Call("getElementById", "maxCubes").Set("value", 256)

相当于js中的

document.getElementById("maxCubes).setAttribute('value', 256)

同样的,我们也可以在go中定义js方法,这样就可以在js中直接调用了。

api.onMemInitCb = js.NewCallback(func(args []js.Value) {
        length := args[0].Int()
        api.console.Call("log", "length", length) // 调用js的console.log("length", length)
        api.inBuf = make([]uint8, length)
        // 拿到这个slice的SliceHeader
        hdr := (*reflect.SliceHeader)(unsafe.Pointer(&api.inBuf))
        ptr := uintptr(unsafe.Pointer(hdr.Data))

        api.console.Call("log", "ptr:", ptr)
        js.Global().Call("gotMem", ptr)

        fmt.Println("初始化Mem成功")
    })

js.Global().Set("initMem", api.onMemInitCb)

这样,我们就可以在js中直接使用initMem方法了。这样子,就相当于打通了go和js之间的通道,使得go WebAssembly几乎无所不能。

go和js之间如何通过内存传值。

这个部分是我在看go WebAssembly部分时最关注的。因为大多数时候,我们传参都不仅仅是256这样的字面值。比如我们在做图片处理的时候,浏览器加载图片后传给wasm去处理,这种时候传递的肯定是一个指向一段内存的指针。

go代码中:

api.onMemInitCb = js.NewCallback(func(args []js.Value) {
    length := args[0].Int()
    api.console.Call("log", "length", length) // 调用js的console.log("length", length)
    api.inBuf = make([]uint8, length)
    // 拿到这个slice的SliceHeader
    hdr := (*reflect.SliceHeader)(unsafe.Pointer(&api.inBuf))
    ptr := uintptr(unsafe.Pointer(hdr.Data))

    api.console.Call("log", "ptr:", ptr)
    js.Global().Call("gotMem", ptr)

    fmt.Println("初始化Mem成功")
})

js代码中:


function gotMem(pointer) { console.log("pointer", pointer) memoryBytes.set(bytes, pointer); // Now the image can be loaded from the slice. console.log("load image") loadImage(); } ...... let reader = new FileReader(); reader.onload = (ev) => { bytes = new Uint8Array(ev.target.result); initMem(bytes.length); let blob = new Blob([bytes], {'type': imageType}); document.getElementById("sourceImg").src = URL.createObjectURL(blob); }; imageType = this.files[0].type; reader.readAsArrayBuffer(this.files[0]);

上面有两段代码,js部分代码是在加载一张图片,然后转换为Uint8Array数组bytes,然后调用initMem方法,传递一个数组bytes的长度作为参数。而initMem是在go代码中定义的,initMem负责调用make([]uint8, length)去初始化需要的内存。然后通过一系列的转换获得申请的内存区域的指针ptr。然后调用js的gotMem将这个ptr传递给js代码。在gotMem中,memoryBytes.set(bytes, pointer)这句代码初始化了这块内存。

这里值得指出的是,memoryBytes是一段在wasm初始化时申请的内存,保存在一个Uint8Array数组中。如果我们打印出来的话,可以发现这段内存有1G。然后我们的go代码中调用make([]uint8, length)申请一块内存时,其实是在这段内存中申请的。比如说申请的区域为201883648~(201883648+107003)。我们只要在js中向memoryBytes的数组中给这块区域赋值,就把值传递给go的对象了。

再考虑一个问题。1G的初始化内存是不是太大了?这个内存是在编译go代码时由编译工具指定的。但是如果我们使用浏览器(比如chrome)的任务管理器查看这个窗口的占用内存时就会发现,实际占用并不会这么大。

在我的理解中(并不一定正确),这和C语言的malloc类似。malloc可以申请大于物理内存的虚拟内存,但是只要你不实际占用这么大内存,是不会有问题的。所以虽然go WebAssembly打印出来有1G的初始化内存,但是如果不是真的会使用,是不会占用这么大物理内存的。

关于初始化内存过大的问题,可以参考这个issues:cmd/compile: wasm code causes out of memory error on Chrome and Firefox for Android

一些关于WebAssembly内存的设计:Finer-grained control over memory

一个用go WebAssembly实现中位切分法处理图片的例子

关于中切分法可以参考我之前的这篇文章:中位切分法颜色量化

可以在这个地址进行预览: 预览地址

在浏览器中运行的效果(这里只展示了FastMap的效果,BestMap会好很多):

fastmap

代码的github地址是:WebAssembly-MedianCut

里面大多数的代码都是用的我之前的代码。可见go WebAssembly还可以非常舒服的复用之前的代码。

中位切分法颜色量化

首先举两个例子。

有一种视频接口叫VGA,这种视频接口有一个最大的缺点,就是同一时刻无法显示超过256种颜色。而对于一张true-color的图片,R、G、B都是有1个byte标示,因此一张true-color的图片可能有2^24种颜色。这远远超过了VGA可以同时展示的颜色数量。

我们都知道有一种图片格式叫PNG(
PNG格式分析与压缩原理
)。它的编码方案中,有一种使用调色板的编码方案。调色板上最多有256种颜色。这样每一种颜色都可以用一个byte的索引值代替。如果将一副超过256种颜色的png图使用调色板的编码方式,那么就可以明显的减少图片的体积。

那么如何将2^24种颜色用256种颜色表示呢?或者说,如何将m种颜色使用n种颜色来代替表示(m>n)。这就是这篇文章主要讨论的问题。

首先,如下图所示,RGB可以映射到三维空间中,R代表X轴,G代表Y轴,B代表Z轴。这样,任意一个RGB的组合都可以在空间中以一个点表示。

rgb-cube

对于一个24-bit的图片来说,通常来说颜色空间是连续的,因为颜色之间的最小差异是几乎不可能察觉的。现在,这个连续的颜色空间要被映射到256种离散的颜色上。将一个连续的变量映射到离散的集合上,就叫做量化

有了上面这些说明后,现在有一张颜色很多的图片。图片上所有的点都被映射到一个空间中的立方体上。因为图片上点的分布一般不是均匀的,那么就可能有很多个点的聚集块。一般来说,聚集的越密,代表这些点的颜色越接近,那么如果我们将这些聚集块的点用聚集块中心点的颜色来表示,这样就可以将很多接近的颜色用一种颜色来代替。其实,这个过程跟聚类是很相似的。只不过一般的聚类算法都是无监督的机器学习,效率要较差。而中位切分法则可以用很快的速度完成这样的聚类。

中位切分法

中位切分法用简短的话描述:将一个图片对应的RGB立方体切割成目标数量的紧凑的RGB立方体,然后用立方体的质心值代表立方体内所有点的值。重复这个过程直到得到想要的颜色数量。

这里假设我们要获取256个颜色。详细的流程如下:

  • 将整张图片转换成一个RGB立方体
  • 找到立方体的最长边,从中位数的地方开始切割。得到两个包含相同数量点的立方体。
  • 对分割成的立方体重复上一步的切割过程直到得到256个立方体。
  • 256个立方体的质心就是要计算的256个颜色值。

用一个例子来说明(为了简单,这里没有B颜色空间),这里有6种颜色,共14个点。想要得到4中颜色输出。

  1. 初始情况
Color (r,g)-coordinates Count
C0 (20,40) 3
C1 (40,20) 2
C2 (5,60) 4
C3 (50,80) 2
C4 (60,30) 1
C5 (80,50) 2
  1. 3次切割后的情况
Cube HistPtr.lower HistPtr.upper Colors Enclosed Cube Centroid
A3 0 0 C0 (20,40)
B3 1 1 C2 (5,60)
A2 2 3 C1,C4 (46.7,23.3)
B2 4 5 C5,C3 (65,65)

具体流程图如下:

第一次:最长边是R,中位数是(20 + 40) / 2 = 30。从30处切割。得到收缩后的矩形。左边的矩形为A1,右边为B1。
第二次: 切割B1的R边,得到A2,B2
第三次: 切割A1的R边,得到A3,B3

这个时候我们已经有4个矩形了。

这里我们有两个颜色映射方式。第一种是快速映射,以矩形的质心作为映射后的颜色值。第二种是最佳映射,得到和其他点的距离和最短的点作为映射后的颜色值。

切割过程

中位切分法的实现

  1. 递归方式。在描述的时候,我们RGB块描述成递归的方式。如下
Split(Cube){
  if (ncubes == 4) return;
  find longest axis of Cube;
  cut Cube at median to form CubeA, CubeB;
  Split(CubeA);
  Split(CubeB);
}

但是这种递归切割的方式是有问题的。结果如下图
递归切割

在递归情况下,B1将一直不会被处理。

2.为了解决1中的问题。我们可以设定一个最大深度(level)。比如我们要得到4个输出颜色。那么log 2 4=2。最大深度应该是2。当切割到最大深度时,我们就不再往下切割,转而切割那些还未被处理的更大的区域。

maxlevel = 2;
Split(Cube,level){
  if (ncubes == 4) return;
  if (Cube's level == maxlevel) return;
  find longest axis of Cube;
  cut Cube at median to form CubeA, CubeB;
  Split(CubeA, level+1);
  Split(CubeB, level+1);
}
  1. 但是这样又会有新的问题出现。如果一个立方体中只有一种颜色(实际上此时只有一个点),不能再往下切割。因此我们需要一种方案去解决这个问题。这里可以维持一个包含所有立方体的数组,然后根据优先级排序切割。优先级可以按照level从小到大排序,但是所有颜色数量为1的立方体都会被忽略。这样每次切割前对这个数组做一次排序,取出优先级最高的立方体进行切割。直到切割出指定数量的立方体或者所有的立方体颜色都为1。
build initial cube from histogram;
set initial cube's level to 0;
insert initial cube in list of cubes;
ncubes = 1;
while (ncubes < maxcubes){
  search for Cube with smallest level;
  find the longest axis of Cube;
  find the median along this axis;
  cut Cube at median to form CubeA, CubeB;
  set CubeA's level = Cube's level + 1;
  set CubeB's level = Cube's level + 1;
  insert CubeA in Cube's slot;
  add CubeB to end of list of cubes;
  ncubes = ncubes + 1;
}

实践

  1. 使用中位切分法提取图片的主题色

  2. 使用中位切分法压缩图片

中位切分法的实现的go语言版本:joyme123/MedianCut点击这里进行效果预览

参考文献

Median-Cut Color Quantization

前端图片主题色提取

virtualbox 网络桥接

virtualbox的默认方式是NAT,用宿主机对虚拟机做端口转发。在组建本地的集群环境时,使用这种方式是不行的。可以使用桥接网卡的方式,使得虚拟机分配到一个宿主机局域网内的ip地址。

首先,修改/etc/network/interfaces文件。

这个文件原来的内容如下:

# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
auto enp0s3
iface enp0s3 inet dhcp

修改成如下:

# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
auto enp0s3
#iface enp0s3 inet dhcp
iface enp0s3 inet static
address 192.168.0.100
gateway 192.168.0.1
netmask 255.255.255.0

注意这里的addressgateway的网段要和宿主机网段一致。比如我的宿主机ip为192.168.0.8

注意这里还要修改一下dns,不然会出现无法解析域名的问题,编辑/etc/resolvconf/resolv.conf.d/base

添加一下的nameserver:

nameserver 8.8.8.8
nameserver 1.1.1.1

执行sudo resolvconf -u使dns的配置生效。

然后修改虚拟机的设置,在VirtualBox的菜单栏中:设备->网络->网络->连接方式:桥接网卡。

然后重启网络

sudo /etc/init.d/networking restart

如果网络还是有问题,可以尝试重启虚拟机。

之后在终端之中输入ifconfig,网络信息如下:

enp0s3    Link encap:Ethernet  HWaddr 08:00:27:f1:6d:fd  
          inet addr:192.168.0.100  Bcast:192.168.0.255  Mask:255.255.255.0
          inet6 addr: fe80::a00:27ff:fef1:6dfd/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:304 errors:0 dropped:0 overruns:0 frame:0
          TX packets:173 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:28254 (28.2 KB)  TX bytes:28063 (28.0 KB)

虚拟机的ip已经变成192.168.0.100。使用ping 192.168.0.8检查和宿主机的通信是否正常。

TLS1.2 RFC5426中一些术语解释

最近想为我的cats服务器加上https的支持。因为最近有一点点的忙,这个项目已经很久没有提交新的代码了。之所以没有用一些开源的库去做,因为这个项目的目的就是锻炼我的代码能力,以及英文RFC的阅读能力。但是在看RFC5426时遇到了一些挫折,里面有大量的密码学上的专业词汇。因此买了一本《图解密码技术》,这里将RFC5426中的专业词汇和概念单独拿出来做一次笔记。

PRF( pseudorandom function ) algorithm: 伪随机函数算法。随机数有三类性质:1.随机性。2.不可预测性。3.不可重现性。这三个性质要求越来越严格。满足1,称为弱伪随机数,满足1、2,称为强伪随机数,满足1、2、3,称为真伪随机数。在密码学的体系中,要求至少达到强伪随机数才能保证安全。

public key encryption:公开密钥加密(英语:Public-key cryptography),也称为非对称加密(英语:asymmetric cryptography),是密码学的一种算法,它需要两个密钥,一个是公开密钥,另一个是私有密钥;一个用作加密的时候,另一个则用作解密。使用其中一个密钥把明文加密后所得的密文,只能用相对应的另一个密钥才能解密得到原本的明文;甚至连最初用来加密的密钥也不能用作解密。由于加密和解密需要两个不同的密钥,故被称为非对称加密;不同于加密和解密都使用同一个密钥的对称加密。虽然两个密钥在数学上相关,但如果知道了其中一个,并不能凭此计算出另外一个;因此其中一个可以公开,称为公钥,任意向外发布;不公开的密钥为私钥,必须由用户自行严格秘密保管,绝不透过任何途径向任何人提供,也不会透露给要通信的另一方,即使他被信任。参考链接: 公开密钥加密

RSA: 名称不是什么缩写,而是发明人首字母的结合。是一种非对称加密的方法。它的速度比起DES等对称加密算法要慢的多。因为非对称加密中,有一个公钥和一个私钥,那么如果分配公钥则是一个问题。如果通过网络传输公钥,则可能被中间人替换掉公钥进行攻击.因此一般的做法是用可靠的第三方机构签发证书来防止这样的攻击。参考链接:RSA加密演算法

MAC (Message Authentication Code) algorithm: 消息验证码的算法.是经过特定算法后产生的一小段信息,检查某段消息的完整性,以及作身份验证 。它可以用来检查在消息传递过程中,其内容是否被更改过,不管更改的原因是来自意外或是蓄意攻击。同时可以作为消息来源的身份验证,确认消息的来源。在我的理解中,MAC是一种与密钥相关联的单向散列函数。参考链接:消息认证码

HMAC (Keyed-Hashing for Message Authentication): 它通过一个标准算法,在计算哈希的过程中,把key混入计算过程中。其实就是常用的加salt的方式,使得相同的原值生成不同的哈希值。HMAC是用来生成MAC值的。参考链接:密钥散列消息认证码

DSA (digital signing algorithm):DSA是一种更高级的验证方式。一般用于数字签名和认证。DSA 不单单只有公钥、私钥,还有数字签名。私钥加密生成数字签名,公钥验证数据及签名。在DSA数字签名和认证中,发送者使用自己的私钥对文件或消息进行签名,接受者收到消息后使用发送者的公钥来验证签名的真实性。如果数据和签名不匹配则认为验证失败!数字签名,不仅能验证数据的完整性,真实性,还能“对第三方证明”和“防止否认”。参考链接:常见的加密算法之DSA 算法

CBC (Cipher Block Chaining):分组密码的一种工作模式,允许使用同一个分组密码密钥对多于一块的数据进行加密,并保证其安全性。其他的工作模式还有:ECB,PCBC,CFB,OFB,CTR等。参考链接:分组密码工作模式

SHA256, SHA1, MD5: 常见的几种信息摘要算法(有时候也称为哈希算法,单向散列函数等)。

SSL (Secure Socket Layer):安全套接字层

TLS (Transport Layer Security Protocol):安全传输层协议,TLS的后续工作是在SSL的基础上进行的。

stream cipher encryption:在密码学中,流密码(英语:Stream cipher),又译为流加密、数据流加密,是一种对称加密算法,加密和解密双方使用相同伪随机加密数据流(pseudo-random stream)作为密钥,明文数据每次与密钥数据流顺次对应加密,得到密文数据流。实践中数据通常是一个位(bit)并用异或(xor)操作加密。参考链接: 流密码

block cipher encryption:在密码学中,分组加密(英语:Block cipher),又称分块加密或块密码,是一种对称密钥算法。它将明文分成多个等长的模块(block),使用确定的算法和对称密钥对每组分别加密解密。分组加密是极其重要的加密协议组成,其中典型的如DES和AES作为美国政府核定的标准加密算法,应用领域从电子邮件加密到银行交易转帐,非常广泛。参考链接: 分组加密

authenticated encryption with additional data (AEAD) encryption:认证加密(英语:Authenticated encryption,AE)和用于关联数据的认证加密(authenticated encryption with associated data,AEAD,AE的变种)是一种能够同时保证数据的保密性、 完整性和真实性的一种加密模式。参考链接: 用于关联数据的认证加密

compression algorithm:数据压缩算法

master secret:主密码用来生成对称密码的秘钥,消息认证码的秘钥以及对称密码CBC模式所使用的初始化向量(IV)

rabbitmq遇到的一次tcp半打开的问题

问题描述

业务中有一个测试服务器,里面运行了好几个任务队列。但是自从将任务队列从redis迁移到rabbitmq上后,一直会在运行一段时间后停止运行。一般这种情况下,要么是进程退出了,要么是连接断开了。但是检查后发现,进程是正常运行的,并且通过netstat发现,连接也一直存在。如果是连接断开,我的代码中也做了断线重连的机制。

问题排查

一开始以为是php-amqplib这个库的问题,就去找它的issue,看看有没有类似的问题。这里没有找到类似的bug。

然后去复现了一个最小的demo,这个问题一直都是出现在运行相当长的时间之后。于是决定对测试服务器上已经出现问题的代码进行抓包。新push的任务,任务队列的进程就是接收不到,但是netstat结果,任务队列到rabbit服务的连接又确实是存在的。

没有办法就去随便翻翻《UNIX网络编程》的TCP章节,然后想到TCP的全双工特性。全双工特性就是说在一个给定的连接上应用可以在任何时候在进出两个方向上既发送数据又接收数据。建立一个全双工连接后,需要的话可以把它转换成一个单工连接。于是用netstat检查rabbit服务的连接,发现是没有到任务队列的连接的。所以问题就是出现在这里。**但是仔细思考一下,这里的问题并不能用全双工特性去解释,因为全双工转成单工是tcp的一个特性,但是在这个问题中,应该是一个异常情况。全双工是需要双端协商的,而我这里的问题应该是:

服务器关闭了连接,但是任务队列却没有收到关闭的Fin报文,很多时候被称为半打开

问题解决

解决这个问题很简单,启用php-amqplib的心跳包机制即可。

更多的思考

1.半连接,半打开,半关闭(以下用A,B代表连接的两端)

半连接:出现在tcp的三次握手阶段。A发送syn,B响应ack,syn后,此时处于半连接状态。如果A不发送ack,B将会一直为这个半连接分配一段内存空间。因此可以使用这个特点对B进行半连接攻击

半打开(half-open):A断开连接但是却没有发送Fin报文,导致B不知道。在维基百科上半打开和半连接是相同的。

半关闭:在关闭的4次挥手阶段,A端发送Fin,B端ack但是不发送Fin。

半连接、半关闭都是正常出现的情况。半打开则是不正常的状态,一个Unix进程无论自愿地(调用exit或是从main函数中返回)还是非自愿地(收到一个终止本进程的信号)终止时,所有打开的描述符都被关闭,这也导致仍然打开的任何TCP连接上也发出一个FIN。也就是说,只有当服务器断电等这种非正常关闭的情况下才会出现半连接,否则对端都应该收到Fin报文,然后关闭连接。

1.什么情况导致了半打开?

服务器断电这类情况肯定是没有出现的,所以一定是其他地方有问题导致了这个情况。因为这个问题只在测试服务器上出现,生产服务器上并没有。所以有点难推测。

2.双向连接(bidirectional)和全双工(full-duplex)

在我的理解中,双向连接指的是A端确认了到B端的连接,B端也确认了到A端的连接。全双工则指可以同时发送和接收,互不干扰。

3.心跳机制是如何避免这种情况的?

心跳机制一般都是隔一段时间主动发送一个消息给对端来确认连接是否存活。如果连接丢失,则必然不会收到对端的响应。这样在响应超时后重新发起连接即可。

其实tcp也有一个keep-alive机制。与心跳包作用类似,但是一是检查的周期长,二是一旦启用,机器上所有的连接都会启用这个机制,导致资源浪费。

参考

TCP half-open
半连接、半打开、半关闭

go 内存模型

简介

go的内存模型旨在说明:一个协程中对变量v的写入产生的值可以保证被另一个协程中的对变量v的读取观察到。

Happens Before

在一个协程内,读写操作必须按照程序指定的顺序进行。在一个协程内,编译器和处理器可能对读写操作重写排序,但是这个排序的前提是:在当前协程内,不会改变程序的执行行为。但是这个重新排序是不保证其他协程观测到执行顺序是不改变的。比如在协程1中a=1;b=2,但在其他协程的感知中,可能b比a先更新值。

我们这里定义Happens Before(在...之前发生),如果事件e1在事件e2之前发生,那么我们就可以说e2在e1之后发生。如果e1既不在e2之前发生,也不在e2之后发生。那么e1和e2就是同时发生的(并发)。

在一个协程内,Happens Before的顺序就是程序表达的那样。

如果下面两点可以保证,就说明对变量v的读取r允许观察到对变量v的写入w:

  • r不是在w之前发生
  • 在w之后并且r之前没有其他的对v的写入w’

为了保证变量v的读取r观察到v的特定写入w,并且保证w是唯一允许被r观察到的。也就是说,r保证能观察到w。需要做到下面两点:

  • w在r之前发生
  • 其他的对v的写入w’要么发生在w之前,要么发生在w之后

下面两点比上面两点要求更为严格。它保证了没有其他的写入w’和w、r同时发生。

在一个协程内,因为没有并发,所以这两种定义是一致的:读取r可以观察到写入w对变量v最近一次的写入。但是当多个协程同时访问同一个共享变量时,就必须使用同步事件来建立Happens Before语义来保证读取r可以观察到指定的写入w。

在内存模型中,对变量v以0值初始化是一次写入。

对于大于单机器字节的读取和写入,可以看做是对多个单机器字节的乱序操作。

同步

初始化

程序初始化是在单协程内运行的,但是这个协程可能创建其他的协程。它们是并发的。

如果包p引入了包q,则q的初始化函数会在p的初始化函数之前运行。

main.main函数在所有的初始化函数之后运行。

协程创建

go关键字创建协程发生在协程运行之前。

协程销毁

协程的销毁不保证在程序中的任何事件发生之前。比如:

var a string

func hello() {
    go func() { a = "hello" }()
    print(a)
}

这个赋值没有跟随任何同步事件,所以它不保证被其他协程观察到。事实上,激进的编译器会删除整个go语句。

如果需要,可以使用同步原语比如管道通信来建立一个相关的执行顺序。

管道通信

在go的协程中,管道通信是非常重要的一个同步方法。通常发送方和接受方在两个不同的协程中,利用发送和接收这两个有序的动作来进行同步。

1.在有缓冲的管道中,发送一定发生在接收完成前。(A send on a channel happens before the corresponding receive from that channel completes.

例如:

var c = make(chan int, 10)
var a string

func f() {
    a = "hello, world"
    c <- 0
}

func main() {
    go f()
    <-c
    print(a)
}

a = "hello, world"一定在c<-0之前发生,c<-0一定在<-c之前发生,<-c一定在print(a)之前发生。这样就能保证a = "hello, world"print(a)之前发生。则保证可以打印出hello, world

2.管道的关闭一定发生在从管道中接收值之前。

因此上面的例子将<-c替换成close(c)也是可以的。

3.在无缓冲管道中,接收一定发生在发送完成前。(The closing of a channel happens before a receive that returns a zero value because the channel is closed.)

例如下面的例子将发送和接收语句互换了位置。

var c = make(chan int)
var a string

func f() {
    a = "hello, world"
    <-c
}
func main() {
    go f()
    c <- 0
    print(a)
}

如果上述例子中管道是有缓冲的(e.g., c = make(chan int, 1)) ,就无法保证一定能打印出hello,world

4.在容量为C的管道中,第k个接收发生在k+C个发送完成之前。(The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.)

第4点推广了第一点的规则。这里其实有一点绕,举个例子:第1个接收发生在1+C个发送完成之前。首先思考:第一个接收能否保证在0+C个发送完成之前?答案是不能。因为管道有C个容量的缓冲,C个发送语句发送完成前,完全可以不调用接收语句。那第1个接收发生在1+C个发送完成之前如何保证,我们知道,当管道缓冲满了之后,就无法向管道中发送,发送语句会阻塞。因此必须在接收之后发送语句才能继续执行。

第四点规则使得计数信号量可以由缓冲管道建模:管道中的数量对应了当前并发量,管道的容量对应了最大并发量。发送语句占用一个信用量,接收语句释放一个信号量。这是限制并发量的一个惯用手段。

下面的例子限制了最大并发量为3:

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

同样的,我们也可以用lock和once来实现happens before语义

不正确的同步方式

即使读r可以观察到同时发生的写w的值,但这并不意味这在r之后发生的读r’可以观察到在w之前发生的写w’。例如:

var a, b int

func f() {
    a = 1
    b = 2
}

func g() {
    print(b)
    print(a)
}

func main() {
    go f()
    g()
}

这段程序可能打印出2、0

这种现象使得一些常见的方式失效。比如双重锁定检查

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func doprint() {
    if !done {
        once.Do(setup)
    }
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}

这段程序不能保证print(a)时能够观察到a的值一定是hello, world

同样的,还有一种循环等待的写法也可能有问题,例如:

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done {
    }
    print(a)
}

这段程序也不保证print(a)一定能打印出内容,甚至更坏的情况下无法观察到done发生
了改变,因此程序会死循环下去。

还有一种衍生版本的写法也会有问题

type T struct {
    msg string
}

var g *T

func setup() {
    t := new(T)
    t.msg = "hello, world"
    g = t
}

func main() {
    go setup()
    for g == nil {
    }
    print(g.msg)
}

即使main协程观察到了g被赋值,也不一定能观察到g.msg有值。

参考

The Go Memory Model(原文)

用c写php扩展的笔记

编写php扩展的步骤:

1.使用php-src中ext文件夹中的ext_skel生成项目框架
2.编辑config.m4,将其中三句话前面的dnl删除,改成下面这样。

PHP_ARG_WITH(md2pic, for md2pic support,
Make sure that the comment is aligned:
[  --with-md2pic             Include md2pic support])

3.执行phpize
4.执行./configure
5.使用make编译
6.使用make install安装扩展
7.将扩展加入php.ini中
8.使用php -m检查扩展是否正常加载

关于config.m4

config.m4相当于一个构建系统,在php扩展的开发中,我的理解就是它可以用来配置lib,include,flags等编译时的属性以及其他的一些功能。这里给出一个配置了其他的lib和include信息的config.m4文件

dnl $Id$
dnl config.m4 for extension md2pic

dnl Comments in this file start with the string 'dnl'.
dnl Remove where necessary. This file will not work
dnl without editing.

dnl If your extension references something external, use with:

PHP_ARG_WITH(md2pic, for md2pic support,
Make sure that the comment is aligned:
[  --with-md2pic             Include md2pic support])

dnl Otherwise use enable:

dnl PHP_ARG_ENABLE(md2pic, whether to enable md2pic support,
dnl Make sure that the comment is aligned:
dnl [  --enable-md2pic           Enable md2pic support])

if test "$PHP_MD2PIC" != "no"; then
  dnl Write more examples of tests here...

  dnl # --with-md2pic -> check with-path
  dnl SEARCH_PATH="/usr/local /usr"     # you might want to change this
  dnl SEARCH_FOR="/include/md2pic.h"  # you most likely want to change this
  dnl if test -r $PHP_MD2PIC/$SEARCH_FOR; then # path given as parameter
  dnl   MD2PIC_DIR=$PHP_MD2PIC
  dnl else # search default path list
  dnl   AC_MSG_CHECKING([for md2pic files in default path])
  dnl   for i in $SEARCH_PATH ; do
  dnl     if test -r $i/$SEARCH_FOR; then
  dnl       MD2PIC_DIR=$i
  dnl       AC_MSG_RESULT(found in $i)
  dnl     fi
  dnl   done
  dnl fi
  dnl
  dnl if test -z "$MD2PIC_DIR"; then
  dnl   AC_MSG_RESULT([not found])
  dnl   AC_MSG_ERROR([Please reinstall the md2pic distribution])
  dnl fi

  dnl # --with-md2pic -> add include path

  PHP_ADD_INCLUDE(src/libMultiMarkdown/include)

  LIBNAME=gd # you may want to change this
  LIBSYMBOL=gdImageCreate # you most likely want to change this 

  PHP_CHECK_LIBRARY($LIBNAME,$LIBSYMBOL,
  [
    PHP_ADD_LIBRARY_WITH_PATH(gd,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(curl,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(png,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(z,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(jpeg,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(freetype,"/usr/lib", MD2PIC_SHARED_LIBADD)
    PHP_ADD_LIBRARY_WITH_PATH(m,"/usr/lib", MD2PIC_SHARED_LIBADD)
    AC_DEFINE(HAVE_MD2PICLIB,1,[ ])
  ],[
    AC_MSG_ERROR([wrong md2pic lib version or lib not found])
  ],[

  ])


  dnl
  PHP_SUBST(MD2PIC_SHARED_LIBADD)
  PHP_NEW_EXTENSION(md2pic, [md2pic.c \
  src/libMultiMarkdown/aho-corasick.c \
  src/libMultiMarkdown/beamer.c \
  src/libMultiMarkdown/char.c \
  src/libMultiMarkdown/critic_markup.c \
  src/libMultiMarkdown/d_string.c \
  src/libMultiMarkdown/epub.c \
  src/libMultiMarkdown/file.c \
  src/libMultiMarkdown/html.c \
  src/libMultiMarkdown/latex.c \
  src/libMultiMarkdown/lexer.c \
  src/libMultiMarkdown/memoir.c \
  src/libMultiMarkdown/miniz.c \
  src/libMultiMarkdown/mmd.c \
  src/libMultiMarkdown/object_pool.c \
  src/libMultiMarkdown/opendocument-content.c \
  src/libMultiMarkdown/opendocument.c \
  src/libMultiMarkdown/scanners.c \
  src/libMultiMarkdown/stack.c \
  src/libMultiMarkdown/textbundle.c \
  src/libMultiMarkdown/token_pairs.c \
  src/libMultiMarkdown/token.c \
  src/libMultiMarkdown/transclude.c \
  src/libMultiMarkdown/rng.c \
  src/libMultiMarkdown/uuid.c \
  src/libMultiMarkdown/writer.c \
  src/libMultiMarkdown/zip.c \
  src/libMultiMarkdown/parser.c \
  src/libMultiMarkdown/pic.c], $ext_shared,, [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 ] )
fi

编写php扩展的资料

我这里主要参考的是 php内核剖析这本书。

php的扩展其实也可以用c++开发。这里有一个很好的项目php-x,并且开发扩展也要容易很多。

HTTP协议中的缓存控制

一、总览

HTTP协议中有以下的头部字段和缓存相关(很多内容都是复制的MDN的文档)

字段名 请求头包含 响应头包含 含义 出现的协议版本
Cache-Control 是否缓存、缓存时间、缓存验证等 HTTP/1.1
Pragma 只有一种用法:Pragma: no-cache。在响应头中没有规定 HTTP/1.0
Vary 它决定了对于未来的一个请求头,应该用一个缓存的回复(response)还是向源服务器请求一个新的回复 HTTP/1.1
If-Match 在请求方法为 GET 和 HEAD 的情况下,服务器仅在请求的资源满足此首部列出的 ETag 之一时才会返回资源。而对于 PUT 或其他非安全方法来说,只有在满足条件的情况下才可以将资源上传 HTTP/1.1
If-None-Match 对于 GET 和 HEAD 请求方法来说,当且仅当服务器上没有任何资源的 ETag 属性值与这个首部中列出的相匹配的时候,服务器端会才返回所请求的资源,响应码为 200 。对于其他方法来说,当且仅当最终确认没有已存在的资源的 ETag 属性值与这个首部中所列出的相匹配的时候,才会对请求进行相应的处理。 HTTP/1.1
If-Modified-Since 服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为200。如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的304响应,而在 Last-Modified 首部中会带有上次修改时间。不同于If-Unmodified-Since, If-Modified-Since 只可以用在 GET 或 HEAD 请求中。 HTTP/1.1
If-Unmodified-Since 只有当资源在指定的时间之后没有进行过修改的情况下,服务器才会返回请求的资源,或是接受 POST 或其他 non-safe 方法的请求。如果所请求的资源在指定的时间之后发生了修改,那么会返回 412 (Precondition Failed) 错误。 HTTP/1.1
ETag TagHTTP响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web服务器不需要发送完整的响应。而如果内容发生了变化,使用ETag有助于防止资源的同时更新相互覆盖(“空中碰撞”) HTTP/1.1
Expires Expires 响应头包含日期/时间, 即在此时候之后,响应过期 HTTP/1.1
Last-Modified 包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。包含有 If-Modified-Since 或 If-Unmodified-Since 首部的条件请求会使用这个字段。 HTTP/1.1
Date 消息生成的时间 HTTP/1.1
If-Range If-Range HTTP 请求头字段用来使得 Range 头字段在一定条件下起作用:当字段值中的条件得到满足时,Range 头字段才会起作用,同时服务器回复206 部分内容状态码,以及Range 头字段请求的相应部分;如果字段值中的条件没有得到满足,服务器将会返回 200 OK 状态码,并返回完整的请求资源。 HTTP/1.1

二、详细说明

一眼看上去,缓存相关的字段确实有很多。但是实际上,稍微理一理思路即可。

上面所有的字段都在围绕着3个点:

  • 1.是否要缓存
  • 2.缓存多久
  • 3.缓存是否有效

2.1 是否要缓存

一个HTTP的客户端(包括浏览器,以及CDN等缓存代理)如何知道当前的请求是否要缓存呢?

Cache-Control中,有下列取值来决定是否缓存:

public:表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存。
private:表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它),可以缓存响应内容。
no-store:缓存不应存储有关客户端请求或服务器响应的任何内容。

2.2 缓存多久

源服务器上的内容可能随时发生变化,那么如何知道什么时候去检查缓存是否更新了呢?HTTP协议中有以下字段规定了一个缓存的有效期。

Cache-Control

max-age={seconds}:设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。
s-maxage={seconds}:覆盖max-age 或者 Expires 头,但是仅适用于共享缓存(比如各个代理),并且私有缓存中它被忽略。
max-stale[={seconds}]:表明客户端愿意接收一个已经过期的资源。 可选的设置一个时间(单位秒),表示响应不能超过的过时时间。
min-fresh={seconds}:表示客户端希望在指定的时间内获取最新的响应。

Expire

Expires: {http-date}: 表示在http-date之后,这个缓存就过期了。如果http-date是一个无效的时间值,则代表已过期。如果在Cache-Control响应头设置了 "max-age" 或者 "s-max-age" 指令,那么 Expires 头会被忽略。

Date和Last-Modified

如果在`Cache-Control`和`Expire`都没有返回的情况下,也可以通过`Date`头和`Last-Modified`头去计算缓存的有效期。缓存的寿命就等于头里面Date的值减去Last-Modified的值除以10。

2.3 缓存是否有效

源服务器上的内容可能随时发生变化, 那么HTTP客户端如果知道自己缓存的内容是否有效呢?这里要分几种情况:

  • 服务器的响应中有Cache-Control:must-revalidate头:当前缓存在有效时间内,此时缓存默认就是有效的。当前缓存过了有效时间,则会向服务器验证缓存是否过期。如果服务返回304,则代表缓存没有过期。
  • 服务器的响应中有Cache-Control: no-cachePragma:no-cache头:则表示每一次都要从服务器验证缓存是否过期。no-cache的优先级是要大于Pragma的

注:no-cache和must-revalidate的区别

在RFC7234中说到:

“must-revalidate” 响应头指令表示一旦该响应过期,这个缓存在向源服务器成功验证之前禁止使用。在任何情况下,一个缓存都必须遵循”must-revalidate”指令;特殊情况下,如果源服务器无法连接,必须生成504(Getway Timeout)响应。

“no-cache”响应头指令表示缓存在向源服务器成功验证之前禁止使用(注:不论缓存是否过期)。如果”no-cache”指令指明了一或多个字段,缓存可以被用来响应之后的请求。但是,在没有和源服务器进行验证的情况下,任何”no-cache”中列出的字段都禁止在之后的响应中被发送。这使得源服务器可以阻止某些字段被重复使用,但是仍然可以缓存响应的其他部分。

“no-cache”中的字段不仅仅限于http1.1协议中列举出来的字段。字段名是大小写不敏感的。使用双引号包围。

因此个人认为,在某些时候max-age=0;must-revalidate 可以等同于 no-cache。

那么这个验证机制是什么样的?也分几种情况

  • 根据文件指纹ETag:在服务器返回了一个文件的ETag的情况下,HTTP客户端可以根据If-None-MatchIf-Match来向服务器验证当前缓存是否过期。
  • 根据文件修改时间:在服务器返回了Last-Modified的情况下,HTTP客户端可以根据If-Unmodified-SinceIf-Modified-Since来向服务器验证当前缓存是否过期。
  • 一个比较特殊的If-RangeIf-Range通常出现在分段请求当中,用来分段请求的资源主体是否发生了变化。它的值既可以是etag,也可以是GMT时间戳。

注:因为Last-Modified精确到秒,在精确度上比ETag低,所以应该以ETag为主。

2.4 关于vary字段

上面说了三个点,但是没有涉及到vary字段。vary和缓存并不是直接相关的。取一段MDN的说明:

Vary 是一个HTTP响应头部信息,它决定了对于未来的一个请求头,应该用一个缓存的回复(response)还是向源服务器请求一个新的回复。它被服务器用来表明在 content negotiation algorithm(内容协商算法)中选择一个资源代表的时候应该使用哪些头部信息(headers).

在响应状态码为 304 Not Modified 的响应中,也要设置 Vary 首部,而且要与相应的 200 OK 响应设置得一模一样。

举个例子,如果服务器返回的网页是分手机版和电脑版的,一般我们会根据user-agent来判断浏览器是手机浏览器还是电脑上的浏览器。假设有一个中间代理的请求:

手机用户1请求index.html---------->中间代理------------>源服务器
电脑用户1请求index.html---------->中间代理------------>源服务器

在电脑用户1请求index.html时,中间代理会向原服务器请求还是直接使用本地缓存的副本呢?

如果原服务器在第一次请求时响应头中有vary:user-agent则会重新请求。因为两次请求的user-agent是不同的,因此缓存不能被重复使用。但是如果没有指定则使用本地缓存作为响应。


参考文档

HTTP缓存控制小结
HTTP 协议中 Vary 的一些研究
http://www.cnblogs.com/chyingp/p/no-cache-vs-must-revalidate.html
HTTP缓存

HTTP Caching | MDN

Cache-Control | MDN
Pragma | MDN
Vary | MDN
If-Match | MDN
If-None-Match | MDN
If-Modified-Since | MDN
If-Unmodified-Since | MDN
ETag | MDN
Expires | MDN
Last-Modified | MDN
If-Range | MDN

从php-fpm解析FastCGI协议

这是一篇类似于开发笔记的文章,从php-fpm与nginx的tcp请求中,去理解FastCGI协议,因此不会详细的阐述FastCGI协议到底是什么样的。

从一段抓包说起

"No.","Time","Source","Destination","Protocol","Length","Info"
"431","22.975896289","127.0.0.1","127.0.0.1","TCP","76","55928  >  9000 [SYN] Seq=0 Win=43690 Len=0 MSS=65495 SACK_PERM=1 TSval=1732184618 TSecr=0 WS=128"
"432","22.975910047","127.0.0.1","127.0.0.1","TCP","76","9000  >  55928 [SYN, ACK] Seq=0 Ack=1 Win=43690 Len=0 MSS=65495 SACK_PERM=1 TSval=1732184618 TSecr=1732184618 WS=128"
"433","22.975920352","127.0.0.1","127.0.0.1","TCP","68","55928  >  9000 [ACK] Seq=1 Ack=1 Win=43776 Len=0 TSval=1732184618 TSecr=1732184618"
"434","22.975948796","127.0.0.1","127.0.0.1","TCP","1356","55928  >  9000 [PSH, ACK] Seq=1 Ack=1 Win=43776 Len=1288 TSval=1732184618 TSecr=1732184618"
"435","22.975953739","127.0.0.1","127.0.0.1","TCP","68","9000  >  55928 [ACK] Seq=1 Ack=1289 Win=174720 Len=0 TSval=1732184618 TSecr=1732184618"
"452","23.068068706","127.0.0.1","127.0.0.1","TCP","660","9000  >  55928 [PSH, ACK] Seq=1 Ack=1289 Win=174720 Len=592 TSval=1732184710 TSecr=1732184618"
"453","23.068076923","127.0.0.1","127.0.0.1","TCP","68","55928  >  9000 [ACK] Seq=1289 Ack=593 Win=44928 Len=0 TSval=1732184710 TSecr=1732184710"
"454","23.068097717","127.0.0.1","127.0.0.1","TCP","68","9000  >  55928 [FIN, ACK] Seq=593 Ack=1289 Win=174720 Len=0 TSval=1732184710 TSecr=1732184710"
"455","23.068153021","127.0.0.1","127.0.0.1","TCP","68","55928  >  9000 [FIN, ACK] Seq=1289 Ack=594 Win=44928 Len=0 TSval=1732184710 TSecr=1732184710"
"456","23.068163150","127.0.0.1","127.0.0.1","TCP","68","9000  >  55928 [ACK] Seq=594 Ack=1290 Win=174720 Len=0 TSval=1732184710 TSecr=1732184710"

为了抓这段包,需要将php-fpm中的监听地址改成tcp socket。注意:tcp socket的性能远远低于unix socket。

可以看到,这里面除了tcp的握手和断开以及应答部分,有PSH标志的是FastCGI的具体协议内容。可以看到nginx给php-fpm发送了一段数据,之后php-fpm进行响应。FastCGI协议就是这样简单的使用tcp协议,使得Web Server可以转发HTTP请求到FastCGI应用程序上,具体的协议内容可以参考FastCGI规范中文翻译

php-fpm在单次请求结束后,会主动断开连接,而在FastCGI协议中,明确说明单次连接是可以复用的。

https://stackoverflow.com/questions/43280573/whether-the-connection-between-php-fpm-and-nginx-by-fast-cgi-are-persistent-kee

这个链接中有关于nginx和php-fpm连接释放的相关说明。

web server 可以将关闭权限委托给php-fpm,这样php-fpm在每次请求结束后就会关闭。

将关闭权限委托给php-fpm的好处就是不会因为连接的占用导致子进程不释放。但是不断的建立和断开连接也会影响性能。

当前php-fpm和nginx的主动断开连接是否会影响性能

会影响性能,但是并不推荐保持连接。

如果希望php-fpm不主动关闭连接,可以使用以下设置:

Syntax: fastcgi_keep_conn on | off;
Default:    
fastcgi_keep_conn off;
Context:    http, server, location
This directive appeared in version 1.1.4.

记得在upstream中使用keepalive选项

upstream backend {
    server 127.0.0.1:9000
    keepalive 20
}

但是缺点也很明显,如果用户请求一直和nginx保持连接,那么nginx也不会释放该与php-fpm的连接。这样会一直占用php-fpm的子进程不释放。当达到php-fpm的最大子进程时,就会拒绝其他的请求。

同时需要注意的是,如果nginx和php-fpm都在本地,不断的重新建立连接的影响是很小的。因此并不推荐将fastcgi_keep_conn选项打开。

这里是一些压测数据(pm.max_children 设置为20,这里只使用20个并发):

测试命令

ab -k -n 100000 -c 20 http://localhost/php/index.php

在主动断开FastCGI连接的情况下:

Server Software:        nginx/1.13.3
Server Hostname:        localhost
Server Port:            80

Document Path:          /php/index.php
Document Length:        60 bytes

Concurrency Level:      20
Time taken for tests:   28.334 seconds
Complete requests:      100000
Failed requests:        0
Keep-Alive requests:    0
Total transferred:      22900000 bytes
HTML transferred:       6000000 bytes
Requests per second:    3529.39 [#/sec] (mean)
Time per request:       5.667 [ms] (mean)
Time per request:       0.283 [ms] (mean, across all concurrent requests)
Transfer rate:          789.29 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0      14
Processing:     1    5   2.4      5     212
Waiting:        0    5   2.4      5     212
Total:          1    6   2.5      5     212

在不断开连接的情况下:

测试一直没法正常完成,部分请求会超时。

因此FastCGI是没有必要保持连接的,这会大大降低并发度。

benchmark 压测,请求直接超时退出

ab -k -c 100 -n 10000 "http://localhost/php/index.php"

php-fpm有一个子进程数量的限制,在并发过高时,没有办法为每一个请求分配一个子进程,导致请求一直在等待,直至超时退出。

unix socket和tcp socket的区别

unix socket相对于tcp socket来说,性能会提升很多。

unix socket虽然也有个socket,但是和网络一点关系都没有。unix socket是进程间的通信。

unix socket不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。

实现FastCGI协议时,tcp连接中读到EOF代表了什么

在写代码过程中,tcp连接读到了EOF。从表面上来看,是读到了流的结束。但也意味着对端至少关闭了写通道。这是因为php-fpm读到了它无法理解的请求,因此直接关闭了连接。

在开发过程中,遇到了php-fpm进程不释放的问题

在BeginRequestRecord中,将flags置为1,这样与fastcgi的连接会一直保持。但是我在tcp连接中读到EOF时,却没有释放这个连接。因此这个连接会占用一个php-fpm子进程不会释放。只要手动释放这个连接即可,或者将flags设为0。

参考

FastCGI协议分析

Nginx支持PHP的PATHINFO模式配置分析

Linux下的IPC-UNIX Domain Socket