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

原文链接:https://huoding.com/2017/09/10/635?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io
大多数 PHP 程序员从来没有使用过连接池,主要原因是按照 PHP 本身的运行机制并不容易实现连接池,于是乎 PHP 程序员一方面不得不承受其它程序员的冷嘲热讽,另一方面还得面对频繁短链接导致的性能低下和 TIME_WAIT 等问题。

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

首先,按照 PHP 的运行机制,长连接在建立之后只能寄居在工作进程之上,也就是说有多少个工作进程,就有多少个长连接,打个比方,我们有 10 台 PHP 服务器,每台启动 1000 个 PHP-FPM 工作进程,它们连接同一个 Redis 实例,那么此 Redis 实例上最多将存在 10000 个长连接,数量完全失控了!
其次,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,如此一来既规避了短链接的种种弊端,也享受到了连接池带来的种种好处。

PHP Pool
PHP Pool

下面以 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 脚本内容如下: 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 即可:

pconnect(‘/tmp/redis.sock’);
?>
再次测试,结果发现在本例中,使用连接池前后,效率提升了 50% 左右。注意,此结论仅仅保证在我的测试里有效,如果你测试的话,视情况可能有差异。

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

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

阿里云用户隔离漏洞

之前文章提到了阿里云经典网络问题,这里操作一把。

1,首先扫端口

nmap -v -sT 10.161.91.0/16 -p 6379 –open

发现一些IP和端口

 

5269c54a87d498e1865e3a0c027cefd9

2,随便找一个登录

redis-cli -c -h 10.161.29.xx -p 6379

这里用-c的原因是redis采用的是集群,允许集群模式

scan 0看下数据:

38fd6ae9c98efba35b905cd3d1dbbd7b

发现很轻易就获得了数据!!

Redis的一些知识总结

1,Redis
  • 丰富的数据结构(Data Structures)
    • 字符串(String)
      • Redis字符串能包含任意类型的数据
      • 一个字符串类型的值最多能存储512M字节的内容
      • 利用INCR命令簇INCR, DECR, INCRBY)来把字符串当作原子计数器使用
      • 使用APPEND命令在字符串后添加内容
    • 列表(List)
      • Redis列表是简单的字符串列表,按照插入顺序排序
      • 你可以添加一个元素到列表的头部(左边:LPUSH)或者尾部(右边:RPUSH)
      • 一个列表最多可以包含232-1个元素(4294967295,每个表超过40亿个元素
      • 在社交网络中建立一个时间线模型,使用LPUSH去添加新的元素用户时间线中,使用LRANGE去检索一些最近插入的条目
      • 你可以同时使用LPUSHLTRIM去创建一个永远不会超过指定元素数目列表并同时记住最后的N个元素
      • 列表可以用来当作消息传递基元(primitive),例如,众所周知的用来创建后台任务的Resque Ruby库
    • 集合(Set)
      • Redis集合是一个无序的,不允许相同成员存在的字符串合集(Uniq操作,获取某段时间所有数据排重值
      • 支持一些服务端的命令从现有的集合出发去进行集合运算,如合并(并集:union),求交(交集:intersection),差集, 找出不同元素的操作(共同好友、二度好友)
      • 用集合跟踪一个独特的事。想要知道所有访问某个博客文章的独立IP?只要每次都用SADD来处理一个页面访问。那么你可以肯定重复的IP是不会插入的( 利用唯一性,可以统计访问网站的所有独立IP
      • Redis集合能很好的表示关系。你可以创建一个tagging系统,然后用集合来代表单个tag。接下来你可以用SADD命令把所有拥有tag的对象的所有ID添加进集合,这样来表示这个特定的tag。如果你想要同时有3个不同tag的所有对象的所有ID,那么你需要使用SINTER
      • 使用SPOP或者SRANDMEMBER命令随机地获取元素
    • 哈希(Hashes)
      • Redis Hashes是字符串字段和字符串值之间的映射
      • 尽管Hashes主要用来表示对象,但它们也能够存储许多元素
    • 有序集合(Sorted Sets)
      • Redis有序集合和Redis集合类似,是不包含相同字符串的合集
      • 每个有序集合的成员都关联着一个评分,这个评分用于把有序集合中的成员按最低分到最高分排列(排行榜应用,取TOP N操作
      • 使用有序集合,你可以非常快地(O(log(N)))完成添加,删除和更新元素的操作
      • 元素是在插入时就排好序的,所以很快地通过评分(score)或者位次(position)获得一个范围的元素(需要精准设定过期时间的应用)
      • 轻易地访问任何你需要的东西: 有序的元素快速的存在性测试快速访问集合中间元素
      • 在一个巨型在线游戏中建立一个排行榜,每当有新的记录产生时,使用ZADD 来更新它。你可以用ZRANGE轻松地获取排名靠前的用户, 你也可以提供一个用户名,然后用ZRANK获取他在排行榜中的名次。 同时使用ZRANKZRANGE你可以获得与指定用户有相同分数的用户名单。 所有这些操作都非常迅速
      • 有序集合通常用来索引存储在Redis中的数据。 例如:如果你有很多的hash来表示用户,那么你可以使用一个有序集合,这个集合的年龄字段用来当作评分,用户ID当作值。用ZRANGEBYSCORE可以简单快速地检索到给定年龄段的所有用户
  • 复制(Replication, Redis复制很简单易用,它通过配置允许slave Redis Servers或者Master Servers的复制品)
    • 一个Master可以有多个Slaves
    • Slaves能通过接口其他slave的链接,除了可以接受同一个master下面slaves的链接以外,还可以接受同一个结构图中的其他slaves的链接
    • redis复制是在master段非阻塞的,这就意味着master在同一个或多个slave端执行同步的时候还可以接受查询
    • 复制slave端也是非阻塞的,假设你在redis.conf中配置redis这个功能,当slave在执行的新的同步时,它仍可以用旧的数据信息来提供查询,否则,你可以配置当redis slaves去master失去联系是,slave会给发送一个客户端错误
    • 为了有多个slaves可以做只读查询,复制可以重复2次,甚至多次,具有可扩展性(例如:slaves对话与重复的排序操作,有多份数据冗余就相对简单了)
    • 他可以利用复制去避免在master端保存数据,只要对master端redis.conf进行配置,就可以避免保存(所有的保存操作),然后通过slave的链接,来实时的保存在slave端
  • LRU过期处理(Eviction)
    • EVAL 和 EVALSHA 命令是从 Redis 2.6.0 版本开始的,使用内置的 Lua 解释器,可以对 Lua 脚本进行求值
    • Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI / EXEC包围的事务很类似。 在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)
    • LRU过期处理(Eviction)
      • Redis允许为每一个key设置不同的过期时间,当它们到期时将自动从服务器上删除(EXPIRE)
  • 事务
    • MULTI 、 EXEC 、 DISCARDWATCH 是 Redis 事务的基础
    • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中不会被其他客户端发送来的命令请求所打断
    • 事务中的命令要么全部被执行,要么全部都不执行EXEC 命令负责触发并执行事务中的所有命令
    • Redis 的 Transactions 提供的并不是严格的 ACID 的事务
    • Transactions 还是提供了基本的命令打包执行的功能: 可以保证一连串的命令是顺序在一起执行的,中间有会有其它客户端命令插进来执行
    • Redis 还提供了一个 Watch 功能,你可以对一个 key 进行 Watch,然后再执行 Transactions,在这过程中,如果这个 Watched 的值进行了修改,那么这个 Transactions 会发现并拒绝执行
  • 数据持久化
    • RDB
      • 特点
        • RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储
      • 优点
        • RDB是一个非常紧凑的文件,它保存了某个时间点得数据集,非常适用于数据集的备份
        • RDB是一个紧凑的单一文件, 非常适用于灾难恢复
        • RDB在保存RDB文件时父进程唯一需要做的就是fork出一个子进程,接下来的工作全部由子进程来做,父进程不需要再做其他IO操作,所以RDB持久化方式可以最大化redis的性能
        • 与AOF相比,在恢复大的数据集的时候,RDB方式会更快一些
      • 缺点
        • 如果你希望在redis意外停止工作(例如电源中断)的情况下丢失的数据最少的话,那么RDB不适合,Redis要完整的保存整个数据集是一个比较繁重的工作
        • RDB 需要经常fork子进程来保存数据集到硬盘上,当数据集比较大的时候,fork的过程是非常耗时的,可能会导致Redis在一些毫秒级内不能响应客户端的请求.如果数据集巨大并且CPU性能不是很好的情况下,这种情况会持续1秒,AOF也需要fork,但是你可以调节重写日志文件的频率来提高数据集的耐久度
    • AOF
      • 特点
        • AOF持久化方式记录每次对服务器写的操作
        • redis重启的时候会优先载入AOF文件恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整
      • 优点
        • 使用AOF 会让你的Redis更加耐久: 你可以使用不同的fsync策略无fsync,每秒fsync,每次写的时候fsync
        • AOF文件是一个只进行追加的日志文件,所以不需要写入seek
        • Redis 可以在 AOF 文件体积变得过大时,自动地在后台对 AOF 进行重写
        • AOF 文件有序地保存了对数据库执行的所有写入操作, 这些写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松导出(export) AOF 文件也非常简单
      • 缺点
        • 对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积
        • 根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB
    • 选择
      • 同时使用两种持久化功能
  • 分布式
    • Redis Cluster (Redis 3版本)
    • Keepalived
      • Master挂了后VIP漂移到SlaveSlavekeepalived 通知redis 执行:slaveof no one ,开始提供业务
      • Master起来后VIP 地址不变Masterkeepalived 通知redis 执行slaveof slave IP host ,开始作为从同步数据
      • 依次类推
    • Twemproxy
      • 快、轻量级、减少后端Cache Server连接数、易配置、支持ketama、modula、random、常用hash 分片算法
      • 对于客户端而言,redis集群是透明的,客户端简单,遍于动态扩容
      • Proxy为单点、处理一致性hash时,集群节点可用性检测不存在脑裂问题
      • 高性能,CPU密集型,而redis节点集群多CPU资源冗余,可部署在redis节点集群上,不需要额外设备
  • 高可用(HA)
    • Redis Sentinel(redis自带的集群管理工具 )
      • 监控(Monitoring): Redis Sentinel实时监控主服务器和从服务器运行状态
      • 提醒(Notification):当被监控的某个 Redis 服务器出现问题时, Redis Sentinel 可以向系统管理员发送通知, 也可以通过 API 向其他程序发送通知
      • 自动故障转移(Automatic failover): 当一个主服务器不能正常工作时,Redis Sentinel 可以将一个从服务器升级为主服务器, 并对其他从服务器进行配置,让它们使用新的主服务器。当应用程序连接到 Redis 服务器时, Redis Sentinel会告之新的主服务器地址和端口
    • 单M-S结构
      • 单M-S结构特点是在Master服务器中配置Master Redis(Redis-1M)和Master Sentinel(Sentinel-1M)
      • Slave服务器中配置Slave Redis(Redis-1S)和Slave Sentinel(Sentinel-1S)
      • 其中 Master Redis可以提供读写服务,但是Slave Redis只能提供只读服务。因此,在业务压力比较大的情况下,可以选择将只读业务放在Slave Redis中进行
    • 双M-S结构
      • 双M-S结构的特点是在每台服务器上配置一个Master Redis,同时部署一个Slave Redis。由两个Redis Sentinel同时对4个Redis进行监控两个Master Redis可以同时对应用程序提供读写服务,即便其中一个服务器出现故障,另一个服务器也可以同时运行两个Master Redis提供读写服务
      • 缺点是两个Master redis之间无法实现数据共享,不适合存在大量用户数据关联的应用使用
    • 单M-S结构和双M-S结构比较
      • 单M-S结构适用于不同用户数据存在关联,但应用可以实现读写分离的业务模式Master主要提供写操作,Slave主要提供读操作,充分利用硬件资源
      • 双(多)M-S结构适用于用户间不存在或者存在较少的数据关联的业务模式读写效率是单M-S的两(多)倍,但要求故障时单台服务器能够承担两个Mater Redis的资源需求
  • 发布/订阅(Pub/Sub)
  • 监控:Redis-Monitor
    • 历史redis运行查询:CPU、内存、命中率、请求量、主从切换
    • 实时监控曲线
2,数据类型Redis使用场景
  • String
    • 计数器应用
  • List
    • 最新N个数据的操作
    • 消息队列
    • 删除过滤
    • 实时分析正在发生的情况,用于数据统计防止垃圾邮件(结合Set)
  • Set
    • Uniqe操作,获取某段时间所有数据排重值
    • 实时系统,反垃圾系统
    • 共同好友、二度好友
    • 利用唯一性,可以统计访问网站的所有独立 IP
    • 好友推荐的时候,根据 tag 求交集,大于某个 threshold 就可以推荐
  • Hashes
    • 存储、读取、修改用户属性
  • Sorted Set
    • 排行榜应用,取TOP N操作
    • 需要精准设定过期时间的应用(时间戳作为Score)
    • 带有权重的元素,比如一个游戏的用户得分排行榜
    • 过期项目处理,按照时间排序
3,Redis解决秒杀/抢红包等高并发事务活动
  • 秒杀开始前30分钟把秒杀库存从数据库同步到Redis Sorted Set
  • 用户秒杀库存放入秒杀限制数长度的Sorted Set
  • 秒杀到指定秒杀数后,Sorted Set不在接受秒杀请求,并显示返回标识
  • 秒杀活动完全结束后,同步Redis数据到数据库,秒杀正式结束

[openresty]第三节:操作redis数据

同样的看redis组件

https://github.com/openresty/lua-resty-redis

按照实例添加redis.conf


worker_processes 1;
error_log logs/error.log;
events {
worker_connections 1024;
}
http {
server {
listen 8091;
location / {
default_type text/html;
content_by_lua_block {
local redis = require "resty.redis"
local red = redis:new()

red:set_timeout(1000)
local ok,err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("fail to connent", err)
return
end

local res,err = red:get("lua")
if not res then
ngx.say("failed to get lua", err)
return
end
ngx.say(res)
}
}
}
}

启动redis.conf配置

../nginx/sbin/nginx -p `pwd`/ -c conf/redis.conf

访问localhost:8091 获取了key为lua的数据,使用起来非常简单。

[转]谈谈Redis的SETNX

在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果,不过很多人没有意识到 SETNX 有陷阱!

比如说:某个查询数据库的接口,因为调用量比较大,所以加了缓存,并设定缓存过期后刷新,问题是当并发量比较大的时候,如果没有锁机制,那么缓存过期的瞬间,大量并发请求会穿透缓存直接查询数据库,造成雪崩效应,如果有锁机制,那么就可以控制只有一个请求去更新缓存,其它的请求视情况要么等待,要么使用过期的缓存。

下面以目前 PHP 社区里最流行的 PHPRedis 扩展为例,实现一段演示代码:

<?php

$ok = $redis->setNX($key, $value);

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

缓存过期时,通过 SetNX  获取锁,如果成功了,那么更新缓存,然后删除锁。看上去逻辑非常简单,可惜有问题:如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测:

<?php

$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();

?>

因为 SetNX 不具备设置过期时间的功能,所以我们需要借助 Expire 来设置,同时我们需要把两者用 Multi/Exec 包裹起来以确保请求的原子性,以免 SetNX 成功了 Expire 却失败了。 可惜还有问题:当多个请求到达时,虽然只有一个请求的 SetNX 可以成功,但是任何一个请求的 Expire 却都可以成功,如此就意味着即便获取不到锁,也可以刷新过期时间,如果请求比较密集的话,那么过期时间会一直被刷新,导致锁一直有效。于是乎我们需要在保证原子性的同时,有条件的执行 Expire,接着便有了如下 Lua 代码:

local key   = KEYS[1]
local value = KEYS[2]
local ttl   = KEYS[3]

local ok = redis.call('setnx', key, value)
 
if ok == 1 then
  redis.call('expire', key, ttl)
end
 
return ok

没想到实现一个看起来很简单的功能还要用到 Lua 脚本,着实有些麻烦。其实 Redis 已经考虑到了大家的疾苦,从 2.6.12 起,SET 涵盖了 SETEX 的功能,并且 SET 本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用 SET 就可以实现。

<?php

$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

如上代码是完美的吗?答案是还差一点!设想一下,如果一个请求更新缓存的时间比较长,甚至比锁的有效期还要长,导致在缓存更新过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在缓存更新完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值:

<?php

$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();

    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}

?>

如此基本实现了单机锁,假如要实现分布锁,请参考:Distributed locks with Redis,这里就不深入讨论了,总结:避免掉入 SETNX 陷阱的最好方法就是永远不要使用它!

原文链接:http://huoding.com/2015/09/14/463