redis使用总结

1.redis的使用场景

项目中存在多种redis的使用场景

1.1 场景一:缓存(key,value)

缓存是一个非常常见的场景,在项目中,可以将mysql中的一部分数据查询的结果缓存到redis中,以此来获取更快的查询速度。

以用户信息缓存为例,我的做法如下:

  • 1.设定用户信息缓存的规则,比如userinfo128代表用户id为128的用户信息缓存。
  • 2.在query语句执行时,先检查userinfo128是否存在,如果存在,直接取出结果返回,否则执行sql语句进行查询,并将查询结果缓存。
  • 3.在update和delete语句执行时,删除userinfo128的缓存,这样下一次query时就会自动更新缓存了。

1.2 timeline(sorted set)

timeline非常经典的场景就是微博,朋友圈这种,每个用户都能在自己的timeline上获取到按时间排序的其他用户发布的动态。这里有以前总结过的一篇文章:朋友圈式的TIMELINE设计方案

其实这个 timeline 我一开始的实现是用 list 去做的,但是用list会存在一个问题:因为推送动态可能同时发生,导致不是严格的按照时间排序。

1.3 推送用户集合(set)

这个功能其实就相当于维持一个用户的粉丝列表。这个列表是一个会经常发生变化的集合,并且在项目中是根据用户的关系链计算出来的,单次的查询会消耗很多的时间,因此做成一个集合,在需要推送动态之类的内容时,直接从redis的集合中查询,会节约很多的时间。

1.4 任务队列(list)

整个项目中很多的操作都是异步的,比如发短信,发邮件,推送用户动态等等,使用redis作为任务队列是很简单的,使用它的list结构,然后使用lpush/rpop对,一边push进任务,另一边有一个单独的后台进程pop出任务进行执行。

当然lpush/rpop并不是很好的一个选择,更好的选择是lpush/brpop,使用阻塞版本的pop指令,可以减少很多不必要的轮询。

在使用这样的任务队列时,还需要考虑到一个问题,如果在取出一个任务时进程崩溃,那么这个任务就彻底的丢失了。因此还可以使用 rpoplpush 或者阻塞版本的 brpoplpush ,取出一个任务的同时备份到另一个队列。如果执行成功的话就再lrem掉这个备份即可。关于队列的更详细的使用在第7大点有更详细的说明。

当然, Redis其实并不推荐作为任务队列的实现,如果需要的话,可以尝试使用Redis作者的另一个项目:disque,或者是kafka。

 

1.5 计数器(hash)

计数器我认为也算是 redis 一个常用的功能了,我认为原因有以下:

  • 1.很多场景下的计数功能都是一个非常高频的操作,使用 redis 会拥有极高的性能。
  • 2.redis支持原子性的自增(incre)操作,不用担心CAS(check and set)的问题。
  • 3.传统数据库,如mysql,如果是MyISAM,单次的更新会带来表锁,如果是InnoDB,则带来行锁,影响并发度。

计数器的使用很简单,直接对某个key做 incr 操作,或者对某个 hash 的 key 做 hincrby 操作即可。

 

2.php在使用redis时,多个数据库切换的困扰

项目中使用的是phpredis这个扩展,在使用pconnect保持redis长连接时,所有对redis的操作会共用同一个redis连接。这就导致:多个进程同时使用一个redis连接,并且多个进程使用的数据库不同时导致错误。比如下方的操作:

// 进程1做以下操作
redis->select(0);redis->set("key1", "val1");

// 进程2做以下操作
redis->select(1);redis-set("key2", "val2");

但是在redis的server端,所做的操作可能如下:

select(0)
select(1)
set("key2", "val2")
set("key1", "val1")

这样就导致key1的存储错误

所以我必须在所有这样的操作中,使用MULTI/EXEC对去解决这个问题。以上代码变成:

// 进程1做以下操作
redis->multi();redis->select(0);
redis->set("key1", "val1");result->exec();

// 进程2做以下操作
redis->multi();redis->select(1);
redis-set("key2", "val2");result->exec();

事实上,使用 redis 时,同时使用多个数据库并不推荐。因为在 redis 集群中是不支持 select 命令的。

3.redis多个数据库之间的切换,对性能有影响吗?

在探讨这个问题之前,摘录官网上对 select 命令的说明:

Since the currently selected database is a property of the connection, clients should track the currently selected database and re-select it on reconnection. While there is no command in order to query the selected database in the current connection, the CLIENT LIST output shows, for each client, the currently selected database.

大致意思可以翻译为:

因为当前选中的数据库是连接的一个属性,每个客户端连接都跟踪记录了当前选中的数据库,在重新连接时会重新选择数据库。虽然没有命令是为了查询当前连接选中的数据库,但是 CLIENT LIST 的输出会显示,每个客户端当前选中的是哪个数据库。

因此 select 操作只是修改了当前连接的属性。

4.redis 有 16 个数据库,目的是什么,最佳的使用方式是什么?为什么 redis 集群不支持 select?

同样的摘录一段官网的介绍

Redis different selectable databases are a form of namespacing: all the databases are anyway persisted together in the same RDB / AOF file. However different databases can have keys having the same name, and there are commands available like FLUSHDB, SWAPDB or RANDOMKEY that work on specific databases.

  In practical terms, Redis databases should mainly used in order to, if needed, separate different keys belonging to the same application, and not in order to use a single Redis instance for multiple unrelated applications.

When using Redis Cluster, the SELECT command cannot be used, since Redis Cluster only supports database zero. In the case of Redis Cluster, having multiple databases would be useless, and a worthless source of complexity, because anyway commands operating atomically on a single database would not be possible with the Redis Cluster design and goals.

大意如下:

Redis 多个不同的可选择的数据库是命名空间的一个表现形式:所有的数据库都会在同一个 RDB/AOF 文件中进行持久化。当然不同的数据库可以拥有同样的名字的键,同样的也有一些类似 FLUSHDB, SWAPDB 或 RANDOMKEY 这样的命名专门在数据库上工作的。

实际上,Redis 数据库应该主要用来分离属于一个应用的不同的键,而不是为了使用一个单独的 Redis 实例服务于多个不相关的应用。

当使用 Redis 集群时,SELECT 命名就不能使用了,因为 Redis 集群仅仅支持数据库0。在 Redis 集群的案例中,拥有多个数据库是无用的,是一种毫无价值的复杂性的来源,因为 Redis 集群的设计和目标是不可能支持 SELECT 命令的。

这里解释了为什么 Redis 被设计为有多个数据库,是为了分离同一个应用中不同的键而设计的,但是不能在多个不相关的应用中使用同一个 Redis 实例。并且还要注意在 Redis 集群中无法使用 SELECT ,因此在项目中还是不用为好。

5.redis是单进程的,如何理解?

我在第一次看到这句话时,是很不理解的。对于这种应用,不可能只有一个进程在工作啊。但是在深入了解之后,明白了这里的单进程指的是:处理 Redis 命令是单进程的。

也就是说,同一时间,在并发和并行的层面上来说,都只有一个 Redis 命令被执行。这样设计的理由我的理解有以下:

  • Redis是内存数据库,所有的操作耗时都视CPU的运行速度而定,IO不可能是瓶颈,并行/并发处理带来的意义不大。
  • 并行/并发会增加应用的复杂度

6.redis中使用队列的问题

在1.4小节中我提到了使用Redis作为任务队列的场景。在使用时遇到了程序运行一段时间之后,无法使用brpop获取数据的问题,并且这个程序的连接依然是存活的。

于是我用 CLIENT LIST 查看当前的连接客户端。发现服务器中有大量连接,但是很多连接的 idle 特别长,明显是很久以前的连接,这些连接我可以肯定是已经断开的。经过检查之后,发现 Redis.conf 中的 tcp-keepalive 项我设置为0了,设置为0就不会检查连接是否存活,从而导致连接一直存在。以前将 tcp-keepalive 设置为60,

那这跟 brpop 无法从 Redis 中获取数据有什么关系呢?以下是个人的猜想时间。

这要从Redis的block模型说起。Redis的网络连接是epoll模型的,所以是一个异步的io,肯定不会block一个连接。那么Redis server为了实现这样的block操作,会维持一个内部的哈希表,这个哈希表保存了哪个key上阻塞了哪些客户端。如下图所示:
此处输入图片的描述

如果此时list key1中被push进了一个值,key1就被置为ready状态,然后从链表头部取出client2,将值传给它。可能在我自己的测试中,有大量的已经断开连接客户端阻塞在key1上,但是因为tcp-keepalive为0,没有被及时清除。导致以上的结果。(目前的水平只能这么解释了,虽然还有很多地方说不通)

ABNF格式说明

一、简介

ABNF全称是Augmented Backus-Naur Form,广泛用于很多的互联网文档说明中。主要作用就是以简洁的字符串来描述某些规范。使用了ABNF的标准说明有:电子邮件的标准说明[RFC733]和之后的[RFC822],HTTP1.1协议的[RFC7230]。因此,要想阅读这些文档,必须了解ABNF的格式。ABNF在RFC5234中进行了详细的说明。

二、规则定义

2.1 规则命名

ABNF中规则的命名是大小写不敏感的,由字母开头,后面跟上字母、数字或连字符

2.2 规则格式

一个规则是如下格式定义的:

name = elements crlf

name指的是规则名,elements是一个或多个规则名,或者是终端字符,crlf也就是我们常说的\r\n

2.3 终端值

一个规则被解释成一个字符串。每个字符都是一个非负的数字(比如ASCII码中a对应十进制的97)。终端值就是这些数字。目前定义了以下几种进制:

b     = binary          ;二进制
d     = decimal         ;十进制
x     = hexadecimal     ;十六进制

因此:

CR = %d13
CR = %x0D

使用”.”号来分割字符

CRLF = %d13.10

2.4 额外的编码

根据编码不同,所显示的值可能也不同。比如7-bit的US-ASCII和16-bit的unicode编码,结果是截然不同的。目前7-bit的US-ASCII编码是最常用的。

三、 运算符

3.1 连接: Rule1 Rule2

连接的意思就是值一个规则可能由其他规则连接而成。比如

foo = %x61 ; a
bar = %x62 ; b
mumble = foo bar foo

因此规则mumnle = aba

3.2 选择: Rule1 / Rule2

选择就是多选一的意思。比如

rule = foo / bar

那么rule是foo或者bar都接受的

3.3 扩展的选择: Rule1 =/ Rule2

ruleset = rule1 / rule2
ruleset =/ rule3
ruleset =/ rule4 / rule5

那么ruleset最终为

ruleset = rule1 / rule2 / rule3 / rule4 / rule5

3.4 范围选择: %c##-##

DIGIT = %x30-39

等价于

DIGIT = "0" / "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9"

3.5 序列组: (Rule1 Rule2)

序列组主要是为了阅读上的方便

elem (foo / bar) blat

等价于

(elem foo blat) or (elem bar blat)

elem foo / bar blat

等价于

(elem foo) or (bar blat)

3.6 变量重复: *Rule

完整的格式为:<a>*<b>element
<a><b>是可选的数字值,代表最少a个,最多b个

因此:

*<element> 0到任意多个
1*<element> 至少1个
3*3<element> 只能是3个
1*2<element> 1到2个

3.7 指定的重复: nRule

n<element>等价于n*n<element>

3.8 可选的序列: [Rule]

[Rule]代表这个规则可有可无。因此[foo bar]等价于*1[foo bar]

3.9 注释: ;Comment

使用;来表示注释

3.10 运算符优先级

运算符优先级从上往下排序如下:

规则名, 单值, 终端值
注释
范围取值
重复
组, 可选
连接
选择

四、使用ABNF定义ABNF

rulelist = 1*( rule / (*c-wsp c-nl) )

rule = rulename defined-as elements c-nl
; continues if next line starts
; with white space

rulename = ALPHA *(ALPHA / DIGIT / "-")

defined-as = *c-wsp ("=" / "=/") *c-wsp
; basic rules definition and
; incremental alternatives

elements = alternation *c-wsp

c-wsp = WSP / (c-nl WSP)

c-nl = comment / CRLF
; comment or newline

comment = ";" *(WSP / VCHAR) CRLF

alternation = concatenation
*(*c-wsp "/" *c-wsp concatenation)

concatenation = repetition *(1*c-wsp repetition)

repetition = [repeat] element

repeat = 1*DIGIT / (*DIGIT "*" *DIGIT)

element = rulename / group / option /
char-val / num-val / prose-val

group = "(" *c-wsp alternation *c-wsp ")"

option = "[" *c-wsp alternation *c-wsp "]"

char-val = DQUOTE *(%x20-21 / %x23-7E) DQUOTE
; quoted string of SP and VCHAR
; without DQUOTE

num-val = "%" (bin-val / dec-val / hex-val)

bin-val = "b" 1*BIT
[ 1*("." 1*BIT) / ("-" 1*BIT) ]
; series of concatenated bit values
; or single ONEOF range

dec-val = "d" 1*DIGIT
[ 1*("." 1*DIGIT) / ("-" 1*DIGIT) ]

hex-val = "x" 1*HEXDIG
[ 1*("." 1*HEXDIG) / ("-" 1*HEXDIG) ]

prose-val = "<" *(%x20-3D / %x3F-7E) ">"
; bracketed string of SP and VCHAR
; without angles
; prose description, to be used as
; last resort

附录:核心规则

ALPHA = %x41-5A / %x61-7A ; A-Z / a-z

BIT = "0" / "1"

CHAR = %x01-7F
; any 7-bit US-ASCII character,
; excluding NUL

CR = %x0D
; carriage return

CRLF = CR LF
; Internet standard newline

CTL = %x00-1F / %x7F
; controls

DIGIT = %x30-39
; 0-9

DQUOTE = %x22
; " (Double Quote)

HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"

HTAB = %x09
; horizontal tab

LF = %x0A
; linefeed

LWSP = *(WSP / CRLF WSP)
; Use of this linear-white-space rule
; permits lines containing only white
; space that are no longer legal in
; mail headers and have caused
; interoperability problems in other
; contexts.
; Do not use when defining mail
; headers and use with caution in
; other contexts.

OCTET = %x00-FF
; 8 bits of data

SP = %x20

VCHAR = %x21-7E
; visible (printing) characters

WSP = SP / HTAB
; white space

RFC5234地址:https://tools.ietf.org/html/rfc5234

Canvas性能优化小结

简介

H5中引入了对canvas的支持,使得网页的表达能力更加丰富了。程序员可以通过canvas来绘制复杂的图形,甚至是游戏。因为工作中的需求,需要使用canvas在网页上绘制家谱。具体的算法可以看之前的一篇总结:树的可视化以及家谱绘制的算法
。当家谱中的数据量变大之后,整个绘制过程会变的很卡(800左右人物的家谱1000次绘制居然需要25s左右)。但是在做完优化后,1000次绘制只需要0.15s左右。

家谱示例

目前的家谱绘制共有两部分。一个是家谱的主体,用户可以通过鼠标、滚轮去任意的浏览;一个是左上角的小地图功能,帮助用户了解到当前浏览的位置,小地图中的有色方块就是用户整个屏幕显示的内容。

优化措施一:缓存

缓存是在绝大多数系统中经常用到的提升性能的方法,典型的以空间换时间。在canvas中当然也可以使用缓存。在家谱的每一次绘制中,都要遍历所有的人物,然后计算他们的位置大小,然后绘制到界面上,然后还要遍历所有的路径再次进行绘制。而使用缓存的目的就是避免这一部分的计算。因此,在任何复杂计算后的绘制,都能使用缓存来提升性能。

在canvas中有这样一个方法,CanvasRenderingContext2D.drawImage(image, dx, dy)image参数代表要绘制的图片源,不仅仅可以是一个image对象,还可以是一个canvas对象。所以如果我们将一个复杂的图形作为canvas对象缓存起来,然后直接使用drawImage方法去绘制到画面上,就能减少很多的重复计算,来提升性能。

例如以下的伪代码

function paintPerson() {
    // paint 
}

function paintFamilyTree() {
    for(var i = 0; i < 10000; i++) {
        paintPerson()
    }
}

// 用户移动鼠标就需要重绘族谱
dom.addEventListener("mousemove", function() {
    paintFamilyTree()
}

用户每一次移动鼠标,都需要10000次的循环去画。所以我们使用下面的方法,使得只需要计算一次

function paintPerson() {
    // paint 
}

function paintFamilyTree() {
    for(var i = 0; i < 10000; i++) {
        paintPerson()
    }
}

var canvasObj = cache(paintFamilyTree) // 缓存这次绘制的结果。

dom.addEventListener("mousemove", function() {
    ctx.drawImage(canvasObj,0,0)
}

那么如何去缓存这个 canvas 对象呢?可以使用document.createElement('canvas')来创建一个不在页面上显示的 canvas 对象,一般也称它为离屏画布,这里用变量 offScreenCanvas 来称呼。然后将绘制的图形画在这个 canvas 上,之后调用 ctx.drawImage(offScreenCanvas,0,0) 即可。

在创建这个离屏画布的时候,我们也要设置合适的宽高,这样也有助于提升性能。

可以参考这个例子来感受一下性能差距:
离屏画布缓存来提升页面性能
例子代码如下:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>离屏canvas实例</title>
        <style>
            .main-wrapper {
                width: 800px;
                margin: 10px auto;
            }
        </style>
    </head>

    <body onload="init()">
        <div class="main-wrapper">
            <canvas width="800" height="600" style="border: 1px solid #ccc" id="canvas">
                你的浏览器不支持canvas
            </canvas>
            <br><br><br><br><br><br><br><br><br>
            <div id="result"></div>
            <br>
            渲染次数:<input type="number" id="times" value="1000">,共耗时(ms)<span id="time_used">0</span><br>
            <button onclick="doTest(false,false)">不使用离屏canvas</button>
            <button onclick="doTest(true,false)">使用离屏canvas</button>
            <button onclick="doTest(true,true)">使用离屏canvas并设置正确的高度</button>
        </div>
        <script>
            /**
             * 离屏缓存,num为缓存canvas的数量
             */
            var OffScreenCache = function (num) {
                this.canvases = [];
                for (i = 0; i < num; i++) {
                    this.canvases.push(document.createElement("canvas"));
                }
            }
            OffScreenCache.prototype = {
                pop: function() {
                    return this.canvases.pop();
                },
                push: function(canvas) {
                    this.canvases.push(canvas);
                },
                destroy: function() {
                    this.canvases = null;
                }
            }
            var Ball = function (color) {
                this.radius = 50;
                this.lineWidth = 4;
                this.cache = null;
                this.color = color;
            }
            Ball.prototype = {
                paint: function(ctx, x, y) {
                    ctx.save();
                    ctx.lineWidth = this.lineWidth;
                    ctx.strokeStyle = this.color;
                    for (i = 1; i < this.radius; i+= this.lineWidth) {
                        ctx.beginPath();
                        ctx.arc(x, y, i, 0, Math.PI*2,true); // 绘制
                        ctx.stroke();
                    }
                    ctx.restore();
                },
                useCache: function (cacheCanvas, autoSet) {
                    if (autoSet) {
                        cacheCanvas.width = this.radius * 2;
                        cacheCanvas.height = this.radius * 2;
                    }
                    cacheCtx = cacheCanvas.getContext('2d')
                    this.paint(cacheCtx, cacheCanvas.width / 2, cacheCanvas.height / 2)
                    this.cache = cacheCanvas
                }
            }
            /******************** 下面是执行代码 **************************/
            var g_canvas, g_ctx;
            var g_width = 800;
            var g_height = 600;
            function init() {
                g_canvas = document.getElementById("canvas");
                g_ctx = g_canvas.getContext('2d');
            }
            function getRandomPos() {
                var x = Math.random() * g_width
                var y = Math.random() * g_height
                return {x:x, y:y}
            }
            function showResult(ballCount) {
                var dom = document.getElementById("result");
                dom.innerHTML = "";
                var str = "";
                for (var key of Object.keys(ballCount)) {
                    str += "<span>" + key + "ball:" + ballCount[key] + "</span>    "
                }
                dom.innerHTML = str;
            }
            function doTest(useCache, autoSet) {
                g_ctx.clearRect(0, 0, g_width, g_height)
                var startTime = Date.now();
                var times = document.getElementById("times").value
                var colorArray = ['red', 'blue', 'green', 'black', 'pink']   // 共绘制5种颜色
                var ballCount = {};
                colorArray.forEach(function(color) {
                    ballCount[color] = 0;
                })
                if (useCache) {
                    var colorBall = [];
                    var cacheCanvases = new OffScreenCache(colorArray.length);
                    for (var i = 0; i < colorArray.length; i++) {
                        var ball = new Ball(colorArray[i]);
                        var cacheCanvas = cacheCanvases.pop();
                        ball.useCache(cacheCanvas, autoSet)
                        colorBall.push(ball)
                    }
                    for (var i = 0; i < times; i++) {
                        ball = colorBall[i % 5]
                        ballCount[ball.color]++;
                        var pos = getRandomPos()
                        g_ctx.drawImage(ball.cache, pos.x, pos.y)       // 开始画
                    }
                } else {
                    for (var i = 0; i < times; i++) {
                        var color = colorArray[i % 5];
                        var ball = new Ball(color)
                        var pos = getRandomPos()
                        ball.paint(g_ctx, pos.x, pos.y)               // 开始画
                        ballCount[color]++;
                    }
                }
                var endTime = Date.now();
                document.getElementById("time_used").innerText = endTime - startTime;
                showResult(ballCount)
            }
        </script>
    </body>
</html>

优化措施二:分层

在上述的族谱图片中,左上角的小地图其实是有两个canvas重叠而成。底层的canvas绘制族谱的缩略图,上层的canvas绘制小方框。这样在小方框移动时,只需清空上层canvas的局部画布重绘小方框即可,不需要重绘底层的缩略图。

接口请求速率(接口防刷)限制方案

1.场景

当前业务中接口防刷的场景有:

  • 1.短信/邮件接口。这个接口不做好防刷策略,损失是很大的。
  • 2.涉及到安全问题的接口调用。比如注册接口,登录接口等等。过于频繁的请求可能代表着用户的批量注册,用户密码的暴力破解(需要考虑到可能是用户忘记密码了)等等。

2.要求

  • 1.与当前业务系统解耦合
  • 2.可以很方便的对不同的接口设置不同的请求频率限制
  • 3.具有较好的性能。在大并发的情况下,需要有正确的结果

3.方案提出

3.1 token bucket(令牌桶)

token bucket算法的如下图:

token_bucket

描述如下:

1.假设有一个桶,不断的向桶中投放token,并且我们也可以从桶中取出token。
2.投放token的速率是恒定的r1,桶满了token数量则不会再增长。
3.取出的速率是随机的r2。如果桶是空的,则无法取出token。
4.所有的请求都必须从桶中取出token才是有效的。否则该请求会被拒绝。

举个例子,假设桶的容量是10,每秒放2个token进去。

  • 如果现在每秒2个请求,那么所有这样的请求都是没有问题的,都会拿到token。
  • 如果现在每秒4个请求,10 + 2x = 4x。也就是x=5秒后请求就没法及时拿到token,被丢弃。

总结出以下特点:
– 如果请求速率过快,到一段时间后会受到限制
– 允许突发请求

3.2 redis数据库

使用redis数据库完全是因为方便以及速度。只要注意这其中存在的CAS问题即可

4.具体实现

首先,为了解决redis中检查和存储数据存在的CAS问题,我们需要使用lua脚本。因此,对于token bucket的主要实现是用lua脚本实现,然后通过php调用。

local function mysplit(inputstr, sep)
    if sep == nil then
            sep = "%s"
    end
    local t={} ; 
    local i=1
    for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
            t[i] = str
            i = i + 1
    end
    return t
end

local res = redis.pcall('get',KEYS[1])

if (res == false) then
    -- bucket不存在,初始化为capacity - 1
    local str = string.format("%d %d",ARGV[1] - 1,ARGV[2])  
    return redis.pcall('set',KEYS[1],str)
end

local arr_str = mysplit(res, " ")

local num = arr_str[1]
local timestamp = arr_str[2]

-- 根据时间戳来计算num,这里一秒2个令牌
num = num + (ARGV[2] - timestamp) * ARGV[3]
-- 不超过10个令牌
if num > 10 then
    num = 10
end

if (tonumber(num) > 0) then
    -- 拿到token并减一
    local str = string.format("%d %d",num - 1,ARGV[2])
    return redis.pcall('set', KEYS[1], str)
else 
    -- 没有拿到token
    return false
end
<?php

sha = '2ff7ad2d1b49da8430e5adc8675e';

/**
 * 初始化lua脚本
 */
function initScript(redis) {
    global sha;
    //不存在脚本,load进去script = file_get_contents('check_and_set.lua');
    sha =redis->script('load', script);
    if(redis->getLastError() !== NULL){
        echo "出错了:".redis->getLastError().'\n';
    }
    echo "初始化的脚本sha:".sha.PHP_EOL;
}

function getTokenFromBucket(redis,bucket) {
    global sha;capacity = 10;
    time = time();inputRate = 2;     //一秒2个token
    params = array(bucket, capacity,time, inputRate);result = redis->evalSha(sha, params, 1);
    if(redis->getLastError() !== NULL){
        echo "出错了:".redis->getLastError().'\n';
    }
    returnresult;
}

redis = new Redis();redis->pconnect("127.0.0.1");

initScript(redis);start = microtime(true) * 1000;

while(true) {
    result =  getTokenFromBucket(redis, 'bucket1');
    if (!result) {
        break;
    } else {
        echo time()."--拿到令牌".PHP_EOL;
        usleep(250000);    //每秒请求4次,10 + 2x = 4x, x = 5。5秒左右无法拿到令牌
    }
}end = microtime(true) * 1000;

echo "共耗时:".(end -start)."毫秒".PHP_EOL.PHP_EOL;

start = microtime(true) * 1000;

while(true) {result =  getTokenFromBucket(redis, 'bucket2');
    if (!result) {
        break;
    } else {
        echo "拿到令牌".PHP_EOL;
        usleep(500000);    //每秒请求2次,永远可以拿到令牌
    }
}

end = microtime(true) * 1000;

echo "共耗时:".(end - $start)."毫秒".PHP_EOL.PHP_EOL;

代码中主要部分概括如下:

  • 检查redis中是否存在桶bucket1
  • 如果不存在,那么默认的容量是10,将bucket1设为9 timestamp(timestamp为当前的时间戳)。返回true
  • 如果存在,则获取该bucket1的值,解析出实际token数量为num,以及上一次存储时时间戳为pre。如果rate为token的生成速率,那么现在的token数量应该是:num = num + (time() – pre) * rate。num最大为默认容量10。如果num大于0,则减一后重新设置,返回true。否则返回false。

代码的运行结果如下:

运行结果演示

5.扩展知识

与token bucket相似的算法还有leaky bucket(漏桶)。leaky bucket 就像是 token bucket的镜像一样。leaky bucket 一般用于Traffic shaping和Traffic policing等问题,与下面的两张图对应。

leaky bucket

leaky bucket

可以描述如下:

  • 1.有一个桶,其上方水龙头以变化的速率r1向里面滴水,桶底的水龙头则以恒定的速率向下漏水
  • 2.水桶装满后,上方水龙头的滴水就会溢出
  • 3.水桶空后,下方水龙头则不会滴水

这里举一个异步任务处理(任务队列的容量是有限的)的例子。

  • 上方水龙头相当于任务投递系统,下方水龙头相当于任务处理系统。
  • 任务投递的速率是变化的,因为不同时间段的系统负载可能不同。
  • 任务处理系统的速率是恒定的,因为机器的处理能力是恒定的。
  • 当任务投递的速率大于处理速率,填充满任务队列后,后面的任务都会被丢弃。

可以看出leaky bucket可以将不规则的抖动的请求序列变成规则的平滑的请求序列。leaky bucket通常有两个版本:作为一个评判的仪器(meter)或者是生成一个合法请求队列(queue)。

虽然在使用中leaky bucket和token bucket是不同的,但实际上他们是同一种思路。leaky bucket的变化的输入相当于token bucket的变化的输出,leaky bucket的恒定的输出相当于token bucket的恒定的输出。

参考文献

token bucket
leaky bucket

redis中的事务与锁

这篇文章是我在查找如何对redis中的值做原子操作时的一系列笔记,虽然最初的目的只是研究有哪些方式可以实现事务(transaction)操作,但是后来的延伸很多,所以我认为有必要做一些笔记防止忘记。

1.redis中的事务

redis中的事务其实并不满足数据库事务的四个特性:原子性(Atomicity)、一致性(Consistency)、隔离型(Isolation)、持久性(Durability),简称ACID。redis只能在一致性和隔离性上提供保证,而原子性和持久性是无法保证的。因为redis的事务操作如果中断,无法回滚,满足不了原子性,并且redis的持久化策略有很多中,比如在纯内存模式下是无法持久化数据库更改的,满足不了持久性。

所以下面针对与redis事务的讨论都是有局限性的。仅仅是指对redis数据进行操作时,不会受到其他客户端的干扰。举例来说:客户端A读取keyA,修改keyA中间,不会受到客户端B的影响。

    //客户端A执行以下代码
    //获取user1的性别,如果性别是男,头像设置为男性角色
    //否则头像设置为女性角色
    gender =redis->get('user1_gender');
    if(gender === 'male'){redis->set('user1_photo','male.png');
    }else{
        $redis->set('user1_photo','female.png');
    }
    //客户端B执行以下代码
    //更改user1的性别,如果是男则改为女,如果是女则改为男
    //并根据性别设置头像
    gender =redis->get('user1_gender');
    if(gender === 'male'){redis->set('user1_gender','female');
        redis->set('user1_photo','female.png');
    }else{redis->set('user1_gender','male');
        $redis->set('user1_photo','male.png');
    }

如果客户端A、B同时只有一个执行,那么性别和头像一定是对应的。但是A、B同时执行,并且执行顺序如下:
时序图

那么执行结果就是user1性别为女,但头像为男性角色。

所以针对于这种情况,我们需要一定的措施去预防。

redis的事务实现方式有多种。据我所知有三种方式:

  • 1.MULTI/EXEC
  • 2.WATCH/UNWATCH
  • 3.lua脚本

1.1 MULTI/EXEC

MULTI/EXEC是成对出现的指令。大家都知道,redis执行指令是单线程的,也就是说所有指令都处于一个队列,一个一个执行。MULTI/EXEC可以保证所有的指令都会被同时放松到redis中,中间不会掺杂来自其他客户端的指令。

但是这个针对于上述情况并不适用。因为我们需要在GET到数据之后,才能做下面的更新操作。

1.2 WATCH/UNWATCH

在redis中我们可以使用watch来监视一个值。

redis> WATCH name
OK

redis> MULTI
OK

redis> SET name peter
QUEUED

redis> EXEC
(nil)

比如这段代码,在我们WATCH name后,如果另外一个客户端修改了name的值,那么这个客户端再次修改name则无法成功。这其实就是乐观锁的一种实现。它保证了代码的执行结果不会错乱。用一开始的例子来说,就是不会出现性别和头像不对应的情况。

1.3 lua脚本
redis中可以执行lua脚本来完成事务操作。lua脚本和MULTI/EXEC很像,也就是说在lua脚本执行的过程中,redis是不执行其他客户端的指令的。

但是lua脚本的不同之处在于,你可以使用GET操作来获取数据并判断,再执行后来的操作。

2.在redis操作中使用锁

在多线程环境中,为了防止资源出现race condition,需要借助锁来互斥的访问资源。在这里也是一样的,user1_gender就是我们要互斥访问的资源。

还是上述那个例子,如果我们使用悲观锁,只有获得锁的客户端才能读取和修改user1的的值,也可以很好的解决这个问题。

伪代码如下:

$can_lock = lock('user1_gender'); //得到锁
if($can_lock){
    do_something();
    $release_lock('user1_gender');  //释放锁
}

不同于多线程环境下的是,我们这里的锁的范围是针对于不同的客户端。因此没法使用基于系统的、或者基于语言的锁,而是得使用分布式的锁。这样的分布式锁我们同样可以借助于redis来实现。

整个分布式锁的实现可以概括为以下几个步骤:

    1. 获得锁。得到要锁的资源的唯一hash:lockname,以及一个随机字符串:identifier,设置expire:20s,这个expire就是锁的有效期,在有效期后锁会自动释放,防止出现死锁。在redis中使用setnx(lockname,identifier,expire)。这句代码的意思是:如果redis中不存在lockname,则存入lockname,值为identifier,过期时间是expire。
    1. 第一步我们就得到了一个锁,这一步我们开始执行获得锁之后的代码
    1. 释放锁。我们根据lockname来从redis中查找。如果get(lockname) identifier,则表示我们仍然持有这把锁,使用delete(lockname)来释放锁。如果不等于,说明我们已经不持有这把锁了,则什么也不做。

那么lock函数可以用以下代码来描述:

function lock(lockname,identifier,expire = 20){acquire_timeout = 10;      //花费10秒去获得锁,否则就放弃
    end = time() +acquire_timeout
    while (time() < end){can_lock = redis->setnx(lockname,identifier,expire);
        if($can_lock){
            return true;
        }
        sleep(0.1);
    }

    return false;
}
    function release_lock(lockname,identifier){
        redis->watch(lockname);
        try{
            if(redis->get(lockname) === identifier){
                //锁仍然持有,释放锁redis->delete(lockname);
                return true;
            }redis->unwatch();
        }catch(Exception $e){

        }

        return false;
    }

这样我们就很轻松的得到了借助于redis实现的分布式锁。但是这样的实现方式依然是有问题的。

问题1:如果在某个客户端获得锁后,redis主服务器宕机了,那么即使我们使用了主从备份,从属服务器被提升为主服务器,因为redis备份是异步的原因,这里的锁是没法及时同步到从属服务器的。

问题2:如果一个客户端在获得锁后,执行的操作超过了锁的有效期,锁被自动释放了。那么后续的操作是没法受到锁的保护的。

问题2的解决方案可以是watch,在获取锁后,可以立刻watch资源,然后再执行余下操作。

问题1的解决方案则是接下来要介绍的redlock算法

3.redlock

redlock算法是Redis的作者antirez提出来的。
可以被概述为以下几个步骤:

  • 1.获取当前时间(毫秒数):start_time。

  • 2.按顺序获得N个Redis节点的锁(使用相同的key和identifier,并设置初始有效时间:init_validity_time)。在获得每个redis结点的锁的时候,都要设置一个timeout参数,这个timeout要远小于锁的自动释放时间。例如:如果锁的自动释放时间是10s,timeout应该为~5-55ms(还得视网络情况决定)。这样可以防止在获取锁时,节点宕机,导致耗时过长锁被释放了。如果获取锁失败则立刻获取下一个redis节点的锁

  • 3.client计算为了获取锁花了多长时间:used_time = current_time – start_time。当且仅当client可以获取大多数实例的时候(至少N / 2 + 1个),所花费的时间小于锁的有效时间,才认为获得了锁。

  • 4.如果获得了锁,重新计算锁的有效时间:validity_time = init_validity_time – used_time

  • 5.如果锁获取失败(无法获取N/2 + 1个节点的锁,或者有效时间validity_time是负数),则释放所有实例的锁(即使获得锁的时候失败了,这主要是考虑到有的时候锁获得成功了,但是告知客户端时网络异常)。

这很好的解决了上述的问题1,通过多个redis节点了来保证分布式锁服务的可靠性。

参考文章

Distributed locks with Redis

基于Redis的分布式锁到底安全吗(上)?

基于Redis的分布式锁到底安全吗(下)?

redis事务

6.2.3 Building a lock in Redis

系统权限设计中RBAC模型的使用

什么是RBAC

RBAC,全称是Role-Based-Access-Control,可以译为基于角色的权限控制,是一种应用非常广泛的权限控制模型。

什么是角色(Role)

角色不是一个用户实体,而是代表了一组行为能力或责任的命名实体,以我们常用的QQ群为例,有以下角色:超级管理员、群主、管理员、普通成员、非群成员。

每个角色都有不同的权限:

  • 超级管理员可以管理任何的QQ群,但是一般只能禁言、删除群,不能管理具体的某个群成员,也不能在群里聊天;
  • 群主可以管理自己的QQ群,可以在群里聊天;
  • 管理员可以管理自己的QQ群,可以聊天,但是不能解散群,也不能任命或撤销其他管理员;
  • 普通成员则没有管理权限,只有在群里聊天的权限
  • 非群成员无管理权限,也无法在群里聊天。

所以可以知道,每个角色都对应着一组行为能力或责任。

隐式的基于角色的权限控制

对于QQ群的例子来说。如果我们要做一个删除某个群成员的动作,伪代码可能如下:

if( user.hasRole('Group Owner') || user.hasRole('Group Manager') ){
    // delete the Group Member
} else {
    // Permission Denied
}

那么现在,如果腾讯的权限策略发生了改变,超级管理员也可以删除某个群的群成员来防止某些不当言论的传播,那么代码就要改为

if( user.hasRole('Group Owner') || user.hasRole('Group Manager') || user.hasRole('Super Manager') ){
    // delete the Group Member
} else {
    // Permission Denied
}

上面这种权限的管理方式,就可以称为隐式的基于角色的权限控制。因为Group OwnerGroup ManagerSuper Manager并不能显式的表达出:它们的角色具有删除群成员的权限。没有任何的代码显式的定义了这些角色的权限。我们只能从代码中隐式的推测出:这三个角色拥有删除群成员的权限。所以程序员们使用if/else语句来反映这些假设。

显式的基于角色的权限控制

了解了隐式的基于角色的权限控制,那么我们就可以知道,显式的基于角色的权限控制要有能力直接表达出:当前用户有权限去删除群成员。这样我们的代码可以调整如下:

if( user.isPermitted('GroupMember:delete:478') ){
    // delete the Group Member
} else {
    // Permission Denied
}

这样从代码中可以看到,如果当前用户被允许删除ID为478的群成员,那么就去删除,否则报权限不足的错误。至于isPermitted()中如何去判断权限的,可能依然是回到了哪些角色有哪些权限的问题。但是不同之处在于,应对上面的需求变更问题时,我们只需要更改user的isPermitted的判断规则,而不用去更改散布在代码中各个地方的if语句。可以做到以最小的变更来应对复杂的需求变化。

隐式 vs 显式

隐式和显式在我看来,其内在的权限控制仍然是一样的,都是基于角色在做权限判断。但对外的抽象则是截然不同的:隐式侧重于某个用户是否有某些角色,显式则直接将问题聚焦于某个用户是否有对某个资源的某个操作的权限。这在写代码中给程序员带来的影响是不一样的。截取一段项目中的代码来佐证:

//检查操作的权限
if(
    !familyDB->isUserForFamily(familyId,userId)&&
    !familyDB->isAdminForFamily(familyId,userId)&&
    !familyDB->isOriginatorForFamily(familyId,userId)
){
    Util::printResult(GLOBALS['ERROR_PERMISSION'], "操作权限错误");
    exit;
}

这段代码判断了用户是否对家族有读取权限。因为我们是隐式的基于角色的权限控制,很直观的想法就是:家族成员、家族管理员、家族创始人这三个角色都有对家族的读取权限。所以这里判断了三个权限。事实上,家族创始人的判断是多余的,因为家族创始人肯定属于家族成员。但是真正在写代码的时候,很有可能考虑不到这个问题。

但如果是显式的基于角色的权限控制,这个if语句就是:

//检查操作的权限
if(
    !familyDB->isUserHasReadPermission(familyId,userId))
){
    Util::printResult(GLOBALS['ERROR_PERMISSION'], "操作权限错误");
    exit;
}

程序员写代码时就不会做出多余的判断。可以得出,显式的抽象表达能力是明显更强的。

新的RBAC:Resource-Based-Access-Control

通过对比隐式显式的区别,我们知道显式是直接检查某个用户对某个资源(Resource)是否有某个权限。所以不如直接抛弃角色(Role)的概念,将资源这个概念引入。这样就有了基于资源的权限控制(Resource-Based-Access-Control)

xdebug的简易使用教程

1.ubuntu下的安装

通过 pecl 安装

pecl install xdebug

然后将xdebug.so加入php.ini中,注意如果使用的是fpm,则需要加入到fpm下的php.ini,同理cli环境下则需要向cli下的php.ini添加

zend_extension=”/usr/local/php/modules/xdebug.so”

注意:xdebug是zend的拓展,不需要添加extension=xdebug.so

通过编译安装

git clone git://github.com/xdebug/xdebug.git
cd xdebug
phpize
./configure –enable-xdebug
make
make install

2.配置php使用xdebug

xdebug有许多特性。

2.1 通过设置来影响var_dump()

影响var_dump()的属性有:

  • xdebug.var_display_max_children,
  • xdebug.var_display_max_data
  • xdebug.var_display_max_depth

这三个属性的值都是数字类型的。会影响var_dump()函数显示的变量的内容长度和深度。

可以在php.ini做以下设置:

xdebug.var_display_max_depth = 2
xdebug.var_display_max_data = 8
xdebug.var_display_max_children = 3

具体的值可以自己手动调整

另外还有xdebug.cli_colorxdebug.overload_var_dump会影响到显示的效果

2.2 堆栈跟踪

演示脚本如下:

<?php
//这个脚本会超时
function foo( a ) {
    for (i = 1; i<a['foo']; i++) {
        if (i == 500000) xdebug_break();
    }
}

set_time_limit(1);
c = new stdClass;c->bar = 100;
a = array(
    42 => false, 'foo' => 9121240000000000,c, new stdClass, fopen( '/etc/passwd', 'r' )
);
foo( $a );
?>

我们使用设置

xdebug.collect_params = 1

结果如下:
params为1

修改一下设置:

xdebug.collect_params = 3

params为3

可以看到浏览器中的报错信息更多了,体现在foo()函数中的参数数量

同样的,我们还可以设置

xdebug.dump_globals = On
xdebug.dump.SERVER = ‘REQUEST_URI’

这样则可以展示一些超全局变量。这里指定了请求的URI

超全局变量

还可以设置

xdebug.show_local_vars = On

来展示程序运行期间的本地变量
本地变量

2.3 函数跟踪

使用xdebug可以记录所有的函数调用。

测试脚本如下:

<?php

ini_set('xdebug.trace_format','0');

xdebug_start_trace();
str = "Xdebug";
function ret_ord(c )
{
    return ord( c );
}

foreach ( str_split(str ) as char )
{
    echochar, ": ", ret_ord( $char ), "\n";
}
xdebug_stop_trace();
?>

使用的设置如下:

;代码跟踪日志文件位置,注意要先新建这个/tmp/php_traces/fpm目录,并设置777
xdebug.auto_trace = Off
xdebug.trace_output_dir = /tmp/php_traces/fpm
;代码跟踪日志文件格式 
xdebug.trace_output_name = trace.%c.%p
;trace中显示函数的参数值,这个很有用,待会细说
xdebug.collect_params = 3
xdebug.collect_includes = On
xdebug.collect_return = On
xdebug.show_mem_delta = On
xdebug.var_display_max_depth = 2

结果如下:
此处输入图片的描述

通过调整xdebug.trace_format的值可以更改记录的格式。0是人类可读,1是机器可读,2是html

2.4远程调试

这里xdebug中非常好用的一个功能。通过设置,我们可以在IDE中单步调试。下面我会使用vscode来演示一遍。

2.4.1 环境准备

1.首先我们需要为vscode安装xdebug的插件。

2.配置好调试环境

此处输入图片的描述

这一步是在vscode左侧调试栏新增配置,然后选择php即可。

3.打上断点,启动调试

此处输入图片的描述

4.在浏览器中访问这个页面即可

此处输入图片的描述

参考链接

https://xdebug.org/docs/all#default

ubuntu服务器部署ipv6访问

整个部署过程分为:
1.启用服务器的ipv6支持
2.申请ipv6通道
3.开启nginx的ipv6地址监听

1.ubuntu上开启ipv6支持,需要修改几个地方

1.开启ipv6支持,/etc/sysctl.conf
确保有以下设置:

net.ipv6.conf.all.disable_ipv6 = 0
net.ipv6.conf.default.disable_ipv6 = 0
net.ipv6.conf.lo.disable_ipv6 = 0

设置完毕后使用sysctl -p使设置生效

2.设置服务器的ipv6 dns服务器,/etc/network/interfaces
添加以下设置:

dns-nameserver 2001:4860:4860::8888
dns-nameserver 2001:4860:4860::8844

设置完毕后使用sudo resolvconf -u使设置生效

这样能够保证服务器在连接ipv6地址时有合适的dns服务器

测试:ping6 ipv6.google.com
如果能够ping通则表示已经设置成功

2.申请ipv6通道

因为我的服务器是没有分配ipv6地址的,所以需要去http://tunnelbroker.net申请ipv6的通道。
1.注册账号
2.邮箱激活
3.登录
4.create regular tunnel
5.输入你的服务器的ipv4地址,创建通道
6.在example configurations处选择操作系统,获取配置
7.在 /etc/network/interfaces中拷贝6中的配置
8.sudo resolvconf -u使配置生效
9.通过ifconfig查看是否设置成功,如果出现了ipv6的地址则表示成功

3.开启nginx的ipv6地址监听

在所有的站点配置文件中加上:

listen [::]:80
listen [::]:443

分别监听80端口和443端口(如果有https)
重启nginx即可。
通过http://ipv6-test.com/validate.php测试域名能否在ipv6下访问成功。如果是阿里云,那么有可能在IPv6 DNS server这一项上失败。因为阿里云的DNS服务器没有IPv6 DNS server。这种情况下,如果用户只有ipv6的环境,则会因为无法访问DNS服务器而失败

ubuntu下使用GDB调试segment fault错误

注意:下面的使用仅在ubuntu实验过。

1.打开ubuntu的core dump,这样程序出错后会生成corefile以供我们分析

echo '/tmp/corefile/core.%e.%p.%t' | sudo tee /proc/sys/kernel/core_pattern
ulimit -c unlimited

然后创建/tmp/corefile文件夹

mkdir /tmp/corefile/

2.运行程序,如果出现段错误(核心已转储),则说明corefile已经生成了。检查/tmp/corefile下是否有新生成的错误文件。

如果没有,执行cat /proc/sys/kernel/core_pattern,检查/tmp/corefile/core.%e.%p.%t是否写入了

3.使用gdb调试错误

tcpdump分析php curl

所用工具

  • tcpdump:linux下分析网络的一个好用的终端工具
  • psysh:php的一个终端执行工具,可以很方便的在终端执行php代码
  • curl:php的curl库

tcpdump报文中Flags代表报文类型:
S (SYN), F (FIN), P (PUSH), R (RST), U (URG), W (ECN CWR), E (ECN-Echo) or ‘.’ (ACK), or ‘none’ if no flags are set.

分析背景

最近在了解swoole,因为公司的技术栈是php,所以之前用的java那一套在普通的php开发上完全用不了。为了让php有更好的性能,更广泛的使用场景,找了找相关资料,了解到swoole这个库。决定使用之后,项目中有个地方需要使用到http连接池,所以需要使用curl,但是curl虽然强大,但是参数实在是太多了,里面的实现细节也只能查看c的源代码才知道。所以只能曲线救国,通过tcpdump了解curl在http请求上的工作机制

tcpdump的使用方法是:

案例分析

案例一

只执行一次exec,等待一段时间,强制关闭终端进程

<?php
context = curl_init();
curl_setopt(context,CURLOPT_URL,"http://www.izuqun.com");
curl_exec($context);

整个过程的报文如下

    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [S], cksum 0x4bdf (correct), seq 3687066847, win 29200, options [mss 1460,sackOK,TS val 5931640 ecr 0,nop,wscale 7], length 0
16:28:54.014280 IP (tos 0x20, ttl 52, id 0, offset 0, flags [DF], proto TCP (6), length 60)

    `116.62.25.128.http > 192.168.1.107.44664`: Flags [S.], cksum 0xbcb5 (correct), seq 4174849842, ack 3687066848, win 28960, options [mss 1460,sackOK,TS val 3844377305 ecr 5931640,nop,wscale 7], length 0
16:28:54.014304 IP (tos 0x0, ttl 64, id 44712, offset 0, flags [DF], proto TCP (6), length 52)

    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [.], cksum 0x5bba (correct), ack 1, win 229, options [nop,nop,TS val 5931643 ecr 3844377305], length 0
16:28:54.014334 IP (tos 0x0, ttl 64, id 44713, offset 0, flags [DF], proto TCP (6), length 105)

    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [P.], cksum 0xee60 (correct), seq 1:54, ack 1, win 229, options [nop,nop,TS val 5931643 ecr 3844377305], length 53: HTTP, length: 53
        GET / HTTP/1.1
        Host: www.izuqun.com
        Accept: *\* 
16:28:54.023036 IP (tos 0x20, ttl 52, id 44520, offset 0, flags [DF], proto TCP (6), length 52)

    `116.62.25.128.http > 192.168.1.107.44664`: Flags [.], cksum 0x5b85 (correct), ack 54, win 227, options [nop,nop,TS val 3844377307 ecr 5931643], length 0
16:28:54.023061 IP (tos 0x20, ttl 52, id 44521, offset 0, flags [DF], proto TCP (6), length 314)
    `116.62.25.128.http > 192.168.1.107.44664`: Flags [P.], cksum 0xba4d (correct), seq 1:263, ack 54, win 227, options [nop,nop,TS val 3844377307 ecr 5931643], length 262: HTTP, length: 262
        HTTP/1.1 200 OK
        Server: nginx/1.12.2
        Date: Mon, 11 Dec 2017 08:28:54 GMT
        Content-Type: text/html
        Content-Length: 1243
        Last-Modified: Mon, 11 Dec 2017 02:35:33 GMT
        Connection: keep-alive
        Vary: Accept-Encoding
        ETag: "5a2deef5-4db"
        Accept-Ranges: bytes
16:28:54.023076 IP (tos 0x0, ttl 64, id 44714, offset 0, flags [DF], proto TCP (6), length 52)

    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [.], cksum 0x5a73 (correct), ack 263, win 237, options [nop,nop,TS val 5931645 ecr 3844377307], length 0
16:28:54.024214 IP (tos 0x20, ttl 52, id 44522, offset 0, flags [DF], proto TCP (6), length 1295)

    `116.62.25.128.http > 192.168.1.107.44664`: Flags [P.], cksum 0x870b (correct), seq 263:1506, ack 54, win 227, options [nop,nop,TS val 3844377307 ecr 5931643], length 1243: HTTP
16:28:54.024236 IP (tos 0x0, ttl 64, id 44715, offset 0, flags [DF], proto TCP (6), length 52)

    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [.], cksum 0x5585 (correct), ack 1506, win 256, options [nop,nop,TS val 5931645 ecr 3844377307], length 0

1~3报文:tcp的三次握手,
4~5报文:curl发起的get请求、服务器的ack回应
6~9报文:服务器对get请求的返回、我的机器的内核的ack,因为报文长度是超出了最大的限制,所以分成两次发送

稍等一会儿,又有新的报文

    `116.62.25.128.http > 192.168.1.107.44664`: Flags [F.], cksum 0x161d (correct), seq 1506, ack 54, win 227, options [nop,nop,TS val 3844393567 ecr 5931645], length 0
16:29:59.097967 IP (tos 0x0, ttl 64, id 44716, offset 0, flags [DF], proto TCP (6), length 52)
    `192.168.1.107.44664 > 116.62.25.128.http`: Flags [.], cksum 0xd672 (correct), ack 1507, win 256, options [nop,nop,TS val 5947914 ecr 3844393567], length 0

这是服务器发起的tcp连接关闭的报文,我的机器响应了,但是没有主动再次发送Fin

主动关闭psysh进程,出现大量报文

    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x8765 (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968150 ecr 3844393567], length 0
16:31:20.253999 IP (tos 0x0, ttl 64, id 44718, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x8730 (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968203 ecr 3844393567], length 0
16:31:20.465940 IP (tos 0x0, ttl 64, id 44719, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x86fb (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968256 ecr 3844393567], length 0
16:31:20.890008 IP (tos 0x0, ttl 64, id 44720, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x8691 (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968362 ecr 3844393567], length 0
16:31:21.737935 IP (tos 0x0, ttl 64, id 44721, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x85bd (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968574 ecr 3844393567], length 0
16:31:23.437992 IP (tos 0x0, ttl 64, id 44722, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x8414 (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5968999 ecr 3844393567], length 0
16:31:26.833967 IP (tos 0x0, ttl 64, id 44723, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x80c3 (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5969848 ecr 3844393567], length 0
16:31:33.633977 IP (tos 0x0, ttl 64, id 44724, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x7a1f (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5971548 ecr 3844393567], length 0
16:31:47.218007 IP (tos 0x0, ttl 64, id 44725, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x6cdb (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5974944 ecr 3844393567], length 0
16:32:14.417998 IP (tos 0x0, ttl 64, id 44726, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44664 > 116.62.25.128.http: Flags [F.], cksum 0x524b (correct), seq 54, ack 1507, win 256, options [nop,nop,TS val 5981744 ecr 3844393567], length 0

这些报文都是我的机器主动发送的关闭报文,但是服务器也不再响应

案例二

完整的使用curl_init和curl_close,显式关闭连接

<?php
context = curl_init();
curl_setopt(context,CURLOPT_URL,"http://www.izuqun.com");
curl_exec(context);
curl_close(context);

报文如下

    192.168.1.107.44684 > 116.62.25.128.http: Flags [S], cksum 0x469f (correct), seq 2378041097, win 29200, options [mss 1460,sackOK,TS val 6028159 ecr 0,nop,wscale 7], length 0
16:35:20.090006 IP (tos 0x20, ttl 52, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    116.62.25.128.http > 192.168.1.107.44684: Flags [S.], cksum 0xd6a7 (correct), seq 3548165201, ack 2378041098, win 28960, options [mss 1460,sackOK,TS val 3844473825 ecr 6028159,nop,wscale 7], length 0
16:35:20.090050 IP (tos 0x0, ttl 64, id 64631, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44684 > 116.62.25.128.http: Flags [.], cksum 0x75ac (correct), ack 1, win 229, options [nop,nop,TS val 6028162 ecr 3844473825], length 0
16:35:20.090105 IP (tos 0x0, ttl 64, id 64632, offset 0, flags [DF], proto TCP (6), length 105)
    192.168.1.107.44684 > 116.62.25.128.http: Flags [P.], cksum 0x0853 (correct), seq 1:54, ack 1, win 229, options [nop,nop,TS val 6028162 ecr 3844473825], length 53: HTTP, length: 53
        GET / HTTP/1.1
        Host: www.izuqun.com
        Accept: */*

16:35:20.099626 IP (tos 0x20, ttl 52, id 47030, offset 0, flags [DF], proto TCP (6), length 52)
    116.62.25.128.http > 192.168.1.107.44684: Flags [.], cksum 0x7576 (correct), ack 54, win 227, options [nop,nop,TS val 3844473828 ecr 6028162], length 0
16:35:20.099655 IP (tos 0x20, ttl 52, id 47031, offset 0, flags [DF], proto TCP (6), length 314)
    116.62.25.128.http > 192.168.1.107.44684: Flags [P.], cksum 0xda41 (correct), seq 1:263, ack 54, win 227, options [nop,nop,TS val 3844473828 ecr 6028162], length 262: HTTP, length: 262
        HTTP/1.1 200 OK
        Server: nginx/1.12.2
        Date: Mon, 11 Dec 2017 08:35:20 GMT
        Content-Type: text/html
        Content-Length: 1243
        Last-Modified: Mon, 11 Dec 2017 02:35:33 GMT
        Connection: keep-alive
        Vary: Accept-Encoding
        ETag: "5a2deef5-4db"
        Accept-Ranges: bytes

16:35:20.099670 IP (tos 0x0, ttl 64, id 64633, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44684 > 116.62.25.128.http: Flags [.], cksum 0x7464 (correct), ack 263, win 237, options [nop,nop,TS val 6028164 ecr 3844473828], length 0
16:35:20.099680 IP (tos 0x20, ttl 52, id 47032, offset 0, flags [DF], proto TCP (6), length 1295)
    116.62.25.128.http > 192.168.1.107.44684: Flags [P.], cksum 0xa0fc (correct), seq 263:1506, ack 54, win 227, options [nop,nop,TS val 3844473828 ecr 6028162], length 1243: HTTP
16:35:20.099686 IP (tos 0x0, ttl 64, id 64634, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44684 > 116.62.25.128.http: Flags [.], cksum 0x6f76 (correct), ack 1506, win 256, options [nop,nop,TS val 6028164 ecr 3844473828], length 0

这边的报文一开始和案例一完全一致,但是在我使用curl_close()显式关闭curl后,并没有发送任何关闭tcp连接的报文。查了一下资料,在php7.0中,如果$context任何有全局引用,即使使用curl_close()显式关闭,也不会关闭连接。

案例3

完整的使用curl_init和curl_close,显式关闭连接,并且多次执行exec

16:38:26.590098 IP (tos 0x0, ttl 64, id 18826, offset 0, flags [DF], proto TCP (6), length 60)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [S], cksum 0x520f (correct), seq 3672832041, win 29200, options [mss 1460,sackOK,TS val 6074787 ecr 0,nop,wscale 7], length 0
16:38:26.604823 IP (tos 0x20, ttl 52, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    116.62.25.128.http > 192.168.1.107.44714: Flags [S.], cksum 0xf795 (correct), seq 3884635295, ack 3672832042, win 28960, options [mss 1460,sackOK,TS val 3844520454 ecr 6074787,nop,wscale 7], length 0
16:38:26.604870 IP (tos 0x0, ttl 64, id 18827, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [.], cksum 0x969a (correct), ack 1, win 229, options [nop,nop,TS val 6074790 ecr 3844520454], length 0
16:38:26.604904 IP (tos 0x0, ttl 64, id 18828, offset 0, flags [DF], proto TCP (6), length 105)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [P.], cksum 0x2941 (correct), seq 1:54, ack 1, win 229, options [nop,nop,TS val 6074790 ecr 3844520454], length 53: HTTP, length: 53
        GET / HTTP/1.1
        Host: www.izuqun.com
        Accept: */*

16:38:26.616651 IP (tos 0x20, ttl 52, id 28329, offset 0, flags [DF], proto TCP (6), length 52)
    116.62.25.128.http > 192.168.1.107.44714: Flags [.], cksum 0x9664 (correct), ack 54, win 227, options [nop,nop,TS val 3844520457 ecr 6074790], length 0
16:38:26.616674 IP (tos 0x20, ttl 52, id 28330, offset 0, flags [DF], proto TCP (6), length 314)
    116.62.25.128.http > 192.168.1.107.44714: Flags [P.], cksum 0xf829 (correct), seq 1:263, ack 54, win 227, options [nop,nop,TS val 3844520457 ecr 6074790], length 262: HTTP, length: 262
        HTTP/1.1 200 OK
        Server: nginx/1.12.2
        Date: Mon, 11 Dec 2017 08:38:26 GMT
        Content-Type: text/html
        Content-Length: 1243
        Last-Modified: Mon, 11 Dec 2017 02:35:33 GMT
        Connection: keep-alive
        Vary: Accept-Encoding
        ETag: "5a2deef5-4db"
        Accept-Ranges: bytes

16:38:26.616687 IP (tos 0x0, ttl 64, id 18829, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [.], cksum 0x9551 (correct), ack 263, win 237, options [nop,nop,TS val 6074793 ecr 3844520457], length 0
16:38:26.616701 IP (tos 0x20, ttl 52, id 28331, offset 0, flags [DF], proto TCP (6), length 1295)
    116.62.25.128.http > 192.168.1.107.44714: Flags [P.], cksum 0xc1ea (correct), seq 263:1506, ack 54, win 227, options [nop,nop,TS val 3844520457 ecr 6074790], length 1243: HTTP
16:38:26.616708 IP (tos 0x0, ttl 64, id 18830, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [.], cksum 0x9063 (correct), ack 1506, win 256, options [nop,nop,TS val 6074793 ecr 3844520457], length 0
16:38:28.241841 IP (tos 0x0, ttl 64, id 18831, offset 0, flags [DF], proto TCP (6), length 105)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [P.], cksum 0x2174 (correct), seq 54:107, ack 1506, win 256, options [nop,nop,TS val 6075199 ecr 3844520457], length 53: HTTP, length: 53
        GET / HTTP/1.1
        Host: www.izuqun.com
        Accept: */*

16:38:28.253636 IP (tos 0x20, ttl 52, id 28332, offset 0, flags [DF], proto TCP (6), length 314)
    116.62.25.128.http > 192.168.1.107.44714: Flags [P.], cksum 0xeede (correct), seq 1506:1768, ack 107, win 227, options [nop,nop,TS val 3844520867 ecr 6075199], length 262: HTTP, length: 262
        HTTP/1.1 200 OK
        Server: nginx/1.12.2
        Date: Mon, 11 Dec 2017 08:38:28 GMT
        Content-Type: text/html
        Content-Length: 1243
        Last-Modified: Mon, 11 Dec 2017 02:35:33 GMT
        Connection: keep-alive
        Vary: Accept-Encoding
        ETag: "5a2deef5-4db"
        Accept-Ranges: bytes

16:38:28.253689 IP (tos 0x0, ttl 64, id 18832, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [.], cksum 0x8be1 (correct), ack 1768, win 276, options [nop,nop,TS val 6075202 ecr 3844520867], length 0
16:38:28.253704 IP (tos 0x20, ttl 52, id 28333, offset 0, flags [DF], proto TCP (6), length 1295)
    116.62.25.128.http > 192.168.1.107.44714: Flags [P.], cksum 0xb8a1 (correct), seq 1768:3011, ack 107, win 227, options [nop,nop,TS val 3844520867 ecr 6075199], length 1243: HTTP
16:38:28.253713 IP (tos 0x0, ttl 64, id 18833, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.1.107.44714 > 116.62.25.128.http: Flags [.], cksum 0x86f3 (correct), ack 3011, win 295, options [nop,nop,TS val 6075202 ecr 3844520867], length 0

多次curl_exec()会复用之前的连接,但是curl_close()依然是不能关闭连接的。但当我使用unset($context)时,观察到了以下报文:

    192.168.1.107.49938 > 116.62.25.128.http: Flags [F.], cksum 0x8407 (correct), seq 54, ack 1506, win 256, options [nop,nop,TS val 274723 ecr 3860448314], length 0                                                                                                      
10:20:58.408173 IP (tos 0x20, ttl 52, id 25537, offset 0, flags [DF], proto TCP (6), length 52)                                      
    116.62.25.128.http > 192.168.1.107.49938: Flags [F.], cksum 0x5ca5 (correct), seq 1506, ack 55, win 227, options [nop,nop,TS val 3860458424 ecr 274723], length 0                                                                                                      
10:20:58.408218 IP (tos 0x0, ttl 64, id 34603, offset 0, flags [DF], proto TCP (6), length 52)                                       
    192.168.1.107.49938 > 116.62.25.128.http: Flags [.], cksum 0x5c85 (correct), ack 1507, win 256, options [nop,nop,TS val 274726 ecr 3860458424], length 0 

我的机器主动关闭连接发出Fin,服务器ack并发送Fin,我的机器也ack。两边顺利的完成了连接的关闭。

所以当前可以得出以下几点:

1.$context = curl_init(), $context是可以复用的,复用$context后,多次执行curl_exec()可以避免tcp的3次握手和4次断开的过程。

2.curl_close()在上面的试验中,并不能正常的关闭连接,这是因为在psysh的执行环境中,$context一直是在当前的作用域,会一直保持着变量的引用。而php7.0中,$context如果保持引用,curl_close则不会关闭连接,而unset($context)则会关闭。

3.服务器主动关闭连接后发出Fin,我的机器虽然响应了ack,但是没有主动发送Fin,这会不会导致服务器大量的fin_wait2?

第三点是很重要的,如果不解决会大量占用服务器资源。

所以再做一个实验,实验过程如下:

  • 我的机器初始化大量的curl_init(),然后不释放,等待服务器关闭。使用netstat查看服务器是否会出现大量的fin_wait2。

使用netstat -an|awk '/tcp/ {print $6}'|sort|uniq -c可以快速查看各种tcp连接状态的统计。
初始情况为:

20 CLOSE_WAIT
63 ESTABLISHED                                                                              
10 LISTEN

psysh中执行以下脚本

$contextArr = array();
for($i = 0; $i < 1000; $i++){
    $contextArr[$i] = curl_init();
    curl_setopt($contextArr[$i],CURLOPT_URL,"http://www.izuqun.com");
    curl_exec($contextArr[$i]);
}

脚本执行完后,服务器状态变化如下:

20 CLOSE_WAIT
1058 ESTABLISHED
5 FIN_WAIT2
10 LISTEN
2 TIME_WAIT
20 CLOSE_WAIT
58 ESTABLISHED
1001 FIN_WAIT2
10 LISTEN

20 CLOSE_WAIT
63 ESTABLISHED
10 LISTEN

可以看到,中间有1000个FIN_WAIT2状态,说明我们的猜想是正确的。