史上最LOW的PHP连接池解决方案


大多数 PHP 程序员从来没有使用过连接池,主要原因是按照 PHP 本身的运行机制并不容易实现连接池,于是乎 PHP 程序员一方面不得不承受其它程序员的冷嘲热讽,另一方面还得面对频繁短链接导致的性能低下和 TIME_WAIT 等问题。


说到这,我猜一定会有 PHP 程序员跳出来说可以使用长连接啊,效果是一样一样的。比如以 PHP 中最流行的 Redis 模块 PhpRedis 为例,便有 pconnect 方法可用,通过它可以复用之前创建的连接,效果和使用连接池差不多。可惜实际情况是 PHP 中各个模块的长连接方法并不好用,基本上是鸡肋一样的存在,原因如下:


1)首先,按照 PHP 的运行机制,长连接在建立之后只能寄居在工作进程之上,也就是说有多少个工作进程,就有多少个长连接,打个比方,我们有 10 台 PHP 服务器,每台启动 1000 个 PHP-FPM 工作进程,它们连接同一个 Redis 实例,那么此 Redis 实例上最多将存在 10000 个长连接,数量完全失控了!


2)其次,PHP 的长连接本身并不健壮。一旦网络异常导致长连接失效,没有办法自动关闭重新连接,以至于后续请求全部失败,此时除了重启服务别无它法!


问题分析到这里似乎进入了死胡同:按常规做法是没法实现 PHP 连接池了。


别急着,如果问题比较棘手,我们不妨绕着走。让我们把目光聚焦到 Nginx 的身上,其 stream 模块实现了 TCP/UDP 服务的负载均衡,同时借助 stream-lua 模块,我们就可以实现可编程的 stream 服务,也就是用 Nginx 实现自定义的 TCP/UDP 服务!当然你可以自己从头写 TCP/UDP 服务,不过站在 Nginx 肩膀上无疑是更省时省力的选择。


不过 Nginx 和 PHP 连接池有什么关系?且听我慢慢道来:通常大部分 PHP 是搭配 Nginx 来使用的,而且 PHP 和 Nginx 多半是在同一台服务器上。有了这个客观条件,我们就可以利用 Nginx 来实现一个连接池,在 Nginx 上完成连接 Redis 等服务的工作,然后 PHP 通过本地的 Unix Domain Socket 来连接 Nginx,如此一来既规避了短链接的种种弊端,也享受到了连接池带来的种种好处。



下面以 Redis 为例来讲解一下实现过程,事先最好对 Redis 交互协议有一定的了解,推荐阅读官方文档或中文翻译,具体实现可以参考 lua-resty-redis 库,虽然它只是一个客户端库,但是 Redis 客户端请求和服务端响应实际上格式是差不多通用的。
首先在 nginx.conf 文件中加入如下配置:

stream {
    lua_code_cache on;
    lua_socket_log_errors on;
    lua_check_client_abort on;
    lua_package_path "/path/to/?.lua;;";
    server {
        listen unix:/tmp/redis.sock;
        content_by_lua_block {
             local redis = require "redis"
             pool = redis:new({ ip = "...", port = "..." })
             pool:run()
        }
    }
}


然后在 lua_package_path 配置的路径上创建 redis.lua 文件:

local redis = require "resty.redis"
local assert = assert
local print = print
local rawget = rawget
local setmetatable = setmetatable
local tonumber = tonumber
local byte = string.byte
local sub = string.sub
local function parse(sock)
    local line, err = sock:receive()
    if not line then
        if err == "timeout" then
            sock:close()
        end
        return nil, err
    end
    local result = line .. "\r\n"
    local prefix = byte(line)
    if prefix == 42 then -- char '*'
        local num = tonumber(sub(line, 2))
        if num <= 0 then
            return result
        end
        for i = 1, num do
            local res, err = parse(sock)
            if res == nil then
                return nil, err
            end
            result = result .. res
        end
    elseif prefix == 36 then -- char '$'
        local size = tonumber(sub(line, 2))
        if size <= 0 then
            return result
        end
        local res, err = sock:receive(size)
        if not res then
            return nil, err
        end
        local crlf, err = sock:receive(2)
        if not crlf then
            return nil, err
        end
        result = result .. res .. crlf
    end
    return result
end
local function exit(err)
    ngx.log(ngx.NOTICE, err)
    return ngx.exit(ngx.ERROR)
end
local _M = {}
_M._VERSION = "1.0"
function _M.new(self, config)
    local t = {
        _ip = config.ip or "127.0.0.1",
        _port = config.port or 6379,
        _timeout = config.timeout or 100,
        _size = config.size or 10,
        _auth = config.auth,
    }
    return setmetatable(t, { __index = _M })
end
function _M.run(self)
    local ip = self._ip
    local port = self._port
    local timeout = self._timeout
    local size = self._size
    local auth = self._auth
    local downstream_sock = assert(ngx.req.socket(true))
    while true do
        local res, err = parse(downstream_sock)
        if not res then
            return exit(err)
        end
        local red = redis:new()
        local ok, err = red:connect(ip, port)
        if not ok then
            return exit(err)
        end
        if auth then
            local times = assert(red:get_reused_times())
            if times == 0 then
                local ok, err = red:auth(auth)
                if not ok then
                    return exit(err)
                end
            end
        end
        local upstream_sock = rawget(red, "_sock")
        upstream_sock:send(res)
        local res, err = parse(upstream_sock)
        if not res then
            return exit(err)
        end
        red:set_keepalive(timeout, size)
        downstream_sock:send(res)
    end
end
return _M


见证奇迹的时候到了,测试的 PHP 脚本内容如下:

<?php 
$num = 1000;
$redis = new Redis();
for ($i = 0; $i < $num; $i++) {
    $redis->connect('/tmp/redis.sock');
    // $redis->connect('ip', 'port');
    $redis->set("foo", bar);
    $foo = $redis->get("foo");
    var_dump($foo);
}
?>


可惜测试的时候,不管是通过 /tmp/redis.sock 连接,还是通过 ip 和 port 连接,结果效率都差不多,完全不符合我们开始的分析,挂上 strace 看看发生了什么:

shell> strace -f ...
[pid  ...] recvfrom(..., "QUIT\r\n", 4096, 0, NULL, NULL) = 6
[pid  ...] sendto(..., "QUIT\r\n", 6, 0, NULL, 0) = 6


原来是因为 PhpRedis 发送了 QUIT,结果我们连接池里的连接都被关闭了。不过这个问题好解决,不要使用 connect,换成 pconnect 即可:

<?php
$redis->pconnect('/tmp/redis.sock');
?>


再次测试,结果发现在本例中,使用连接池前后,效率提升了 50% 左右。注意,此结论仅仅保证在我的测试里有效,如果你测试的话,视情况可能有差异。


说到这里,没搞清楚原委的读者可能会质疑:你不是说 PHP 里的长连接是鸡肋么,怎么自己在测试里又用了长连接!本文使用 pconnect,只是为了屏蔽 QUIT 请求,而且仅仅在本地使用,没有数量和网络异常的隐忧,此时完全没有问题,并且实际上我们也可以在 redis.lua 里过滤 QUIT 请求,篇幅所限,我就不做这个实现了。


注意:如果你只是使用 Redis 简单的功能,那么可以直接套用本文代码,如果使用 Redis 复杂的功能,比如 Multi … Exec 事务功能,暂时还不支持。想支持的话也不难,只要在接收到请求的时候,从 Raw 数据解析出具体的命令即可,一旦发现请求是 Multi 事务,那么在 Exec 之前,不要把连接放回连接池就行了。


鲁迅说:真的猛士,敢于直面惨淡的人生,敢于正视淋漓的鲜血。从这个角度看,本文的做法实在是 LOW,不过换个角度看,二战中德军对付马其诺防线也干过类似的勾当,所以管它 LOW 不 LOW,能解决问题的方法就是好方法。



免责声明:
杰微刊遵循行业规范,任何转载的稿件都会明确标注来源和链接。
转载目的在于传递更多信息,并不代表杰微刊赞同其观点和对其真实性负责。如涉及作品内容、版权和其它问题,请在30日内与本网联系,我们将在第一时间删除内容。
杰微刊的原创文章,请转载时务必注明文章作者、链接和"来源:杰微刊"。

分享到:
赚钱
喜欢
精品汇 精品汇总

手机应用大起底:APP如何让用户习惯成瘾?

随着手机APP应用的全面开花,如何让越来越多的用户上钩、让用户形成一种习惯,已经成为科技公司们最痴迷的一件事情。而手机的小小屏幕,用户的注意力只放在几个常用的APP上,在排队、喝咖啡、吃饭时,情不自禁地打开这些APP,究竟这些APP应该具有怎样的魔力?让我们从开发者的角度来一一探究

评论
*

还可以输入140个字符

提交
全部评论(条)

点击这里,查看赚钱机会