使用PHP更新redis失败的一个问题
前几天同事遇到一个更新 redis 失败的问题,我觉得很有意思,背后的技术细节也可以深入挖掘,所以通过本文做个记录。我本人对这个问题也有很多的疑点,希望阅读此文的读者能够解答。
代码功能和实现也非常简单:
$ip = "localhost";
$port = 24362;
$socket=fsockopen($ip,$port,$errno, $errstr);
$t1 = microtime(true);
$line="";
$arr = range(1,1000);
foreach($arr as $K=>$t){
$line="lpush send_mass_mailx {$row}\r\n";
$bool = fwrite($socket,$line);
if ($bool === false) {
echo $t . "_error\n" ;
exit;
}
}
fclose($socket);
$t2 = microtime(true);
echo '耗时'.round($t2-$t1,4).'秒';
通过上述代码可以看出,功能就是循环向 reids 队列插入数据,遇到的问题就是:通过 llen send_mass_mailx
命令发现大概 990 ~ 1000 之间的数据总是插入不成功。
自己当时并不知道背后的原因,但是从代码严谨性的角度(没有对 redis 服务器的响应进行判断,所以代码无法知晓数据是否成功插入 redis 队列)考虑,我修改了下代码:
$ip = "localhost";
$port = 24362;
$socket=fsockopen($ip,$port,$errno, $errstr);
$t1 = microtime(true);
$line="";
$arr = range(1,1000);
foreach($arr as $K=>$t){
$line="lpush send_mass_mailx {$row}\r\n";
$bool = fwrite($socket,$line);
if ($bool === false) {
echo $t . "_error\n" ;
exit;
}
$m = fgets($socket,200);
echo $m "\n"
}
fclose($socket);
$t2 = microtime(true);
echo '耗时'.round($t2-$t1,4).'秒';
两个代码片段之间差异很小(第二个代码增加了 fgets 操作),但带来的结果却完全不同:第二个代码不但成功运行,而且向 redis 队列成功插入了 1000 个值。
背后的原因在哪儿?
可能很多人问,为什么不用一些 PHP redis 封装库呢,不管从那个角度看使用封装库是一个正确的决定,但是、本文是为了分析问题,从另外个角度了解事情的背后原理。
我首先针对第二个代码片段进行 tcpdump 抓包,然后使用 wireshark 进行分析,首先看一张图:
图1:wireshark 抓包图-代码片段2可以看出,每循环一次,代码都会及时读取 redis 的响应,也就是完成一次 redis 的更新,客户端(运行代码的机器)到服务器(redis)要经过一次 RTT 来回(客户端和服务器一次发送和请求)。
那看看每一次请求,客户端发送的具体信息,如图:
图2:wireshark 抓包图-代码片段2然后看看服务器的响应,如图:
图3:wireshark 抓包图-代码片段2通过上面两张图可以看到客户端和服务器做了些什么,进一步验证一次请求和更新之间的交互。
然后我针对第一个代码片段进行 tcpdump 抓包,发现以下的一些信息,如图:
图4:wireshark 抓包图-代码片段1通过示例图可以看出,客户端是不断的向服务器发送数据,无需等待服务器的响应。不像第一个代码片段一样,完成 1000 个值的更新,并不需要 1000 个 RTT。
那么每一次更新操作发送了什么,看最后一次客户端的操作,如图:
图5:wireshark 抓包图-代码片段1可见,最后一次客户端的 lpush 操作的值是第 991 个值,也就是说造成本次问题的原因在于客户端,其没有发送所有的数据更新操作。
结论和猜想:
(1)fwrite 背后的行为
PHP 中的 fwrite 和 socket(比如 socket_connect 函数)背后是不一样的,write 做了进一步的抽象,可以向本地文件和网络句柄发送数据。
每一次 fwrite 返回的值并不代表数据更新操作,而是代表向本地缓存区发送成功(此时可能并没有向本地文件或者网络句柄发送数据),比如运行下面代码:
$line="lpushx send_mass_mailx {$row}\r\n";
$bool = fwrite($socket,$line);
即使故意写错 lpushx,函数返回仍然是成功的。
(2)背后的网络请求
针对第一个代码片段来说,PHP 解析器接收到一个 fwrite 命令,并不是马上发送的,而是向将请求放入到缓存区,然后合并多次更新操作,再向 redis 发出请求。
而对第二个代码片段来说,PHP 解析器每接收到一个 fwrite 命令,就会立刻发出网络请求,这样客户端代码能够详细知道数据是否更新成功。
(3)非阻塞操作和阻塞操作
针对第一个代码片段来说,相当于一个非阻塞操作,客户端代码每发送一次请求不用等待 redis 服务器的响应,所以执行非常块,基本上运行时间不超过 0.003 秒。
而对第二个代码片段来说,相当于一个阻塞操作,客户端代码每发送一次请求就需要等待 redis 服务器的响应,所以执行相对较慢,大概需要 0.5 秒才能运行完成。
至于客户端为什么少发了一些 reids 更新操作,我还没有完全弄清,可能是本地缓存区已满(每 fgets 一次就会清空),但是无法证明这一点,由于 fwrite 做了高度封装,所以这个机制并不是 PHP 控制的,而是其背后的操作系统和 TCP/IP 控制。
如果读者能够知晓背后原因,还请告知。
欢迎关注我的公众号(yudadanwx),了解我最新的博文。 yudadanwx