Memcached读写Session数据失败的原因分析及解决办法

最近虾皮路在监测自己的站点的时候发现,页面偶尔会打开很慢,读取数据卡顿,找到网站日志,发现一堆错误。经检查是无法从Memcached读写Session数据失败导致的。为什么会出现这类问题呢?虾皮路找了很久的解决办法,最终解决成功,现在分享一下Memcached读写Session数据失败的原因分析及解决办法。

一、错误提示内容

因为虾皮路是将PHP的Session数据保存路径是设置为Memcached保存的,这样可以让网站响应速度更快,但是同时也会带来一些其他问题。比如经常会出现Memcached读写Session数据失败。

注意:PHP的Session数据可以保存在file文件中,也可以保存在Memcached、Redis甚至数据库里,本文的问题只限于Memcached,如果是Redis貌似不会出现这类问题。

一般Memcached读写Session数据失败后,日志里将有大量提示如下:

session_start(): Failed to read session data: memcached (path: 127.0.0.1:11211)
Unknown: Failed to write session data (memcached). Please verify that the current setting of session.save_path is correct.

监测结果如下图

Memcached读写Session数据失败的原因分析及解决办法插图

有大量的Memcached读写Session数据失败的错误提示如下

Memcached读写Session数据失败的原因分析及解决办法插图1

比如很多时候不少小伙伴在WordPress的后台站点健康里会出现提示:

PHP 会话是由 session_start() 函数调用创建的。这会干扰 REST API 和环回请求。在发出任何 HTTP 请求之前,会话应该由 session_write_close() 关闭。

其实用本文的方法也是可以解决的。

二、问题分析

为什么会无法读取呢,虾皮路经过了大量的了解及实践后,发现是因为PHP的Session会话锁导致的。因为其中问题的代码中为session_start()相关设置。即受影响的代码为

if (!session_id()) session_start();

什么是PHP的Session会话锁?

在PHP中,当同一个客户端执行两个包含session_start()的页面,如果先执行的脚步执行时间较长,就会导致session文件阻塞。具体表现为:一个PHP页面的执行时间比较长,而只要这个页面没有执行完毕,其他的页面访问都是长时间加载状态,只有那个页面执行完毕了,剩下的页面才能打开。 

原因:PHP 默认是用文件格式存储session,对于每一个请求执行包含有session_start()的页面后,就会默认创建一个包含session_id的文件名,且会对文件进行锁定,如果用户在这个客户端又访问了一个包含了session_start()的页面,由于session_id一样,这个页面也要读取该用户存放的session文件,如果第一个页面没有执行完成,这个文件就一直被锁定,第二个页面就无法获取,只能一直处于等待状态。 

可能造成的后果:如果网站有大量的用户访问,就好导致session读取文件一直堵塞等待,用户浏览器一直保持和服务器的连接,从而会消耗掉大量的服务器资源。而且,随着web服务器活跃连接数的增大,可能会耗费完连接资源,造成出现拒绝服务现象。

当你调用 session_start() 时,都发生了什么

我们使用一个基本的 PHP 配置为例:当你开始一次 PHP 会话时,PHP会在 session.save_path 路径下创建一个普通的文件,默认路径为 /var/lib/php/session 。所有的 session 数据都保存在这个地方。

如果你的用户还没有一个 session cookie ,那么 PHP 将产生一个新的 ID,并设置到用户机器的 cookie 中。如果是一个已访问过的用户,那么他会将 cookie 发送给你的 web 服务器,PHP 则会解析它,并且从 session.save_path 路径下加载到相应的 session 数据。
简而言之,这就是 session_start() 的所做的工作。

会话锁与并发

接下来我们举一个稍微完整一点的例子,来我们说明PHP初始化session后,各个场景下所发生的事情。

TimingPHP CodeLinux/Server
0mssession_start();创建文件锁:/var/lib/php/session/sess_$identifier
15msSQL查询,for循环,第三方API调用持有session文件锁
350msPHP脚本执行结束session文件锁被移除

当你调用session_start()(或者PHP的session.auto_start被设置为true时,该方法会被自动调用),操作系统会锁住session文件。大多数文件锁的实现都是flock,在Linux上,它也用于防止定时任务的重复执行或者其它文件锁定工作。
在Linux机器上,一个session文件锁看起来就像这样子。

该session的文件锁会保持到脚本执行结束或者被主动移除(后面会讲到)。这是一个读写锁:任何对session读取都必须等到锁被释放之后。
锁本身并不是问题。它保护session文件中的数据,防止多个同时写入损毁数据或者覆盖之前的数据。
但是当第二个并发的PHP执行想要获取同一个PHP会话的时候,就会造成问题了。

Timingscript 1Linux/Serverscript 2
0mssession_start();script1锁定(flock)文件/var/lib/php/session/sess_$identifiersession_start();被调用,但是被锁阻塞。PHP等待锁被移除。
15msSQL查询,for循环,第三方API调用文件锁保持不变。脚本仍然在等待,啥都不做。
350msscript1执行结束。script1持有的文件锁被移除。script2仍然在等待。
360msscript2得到新的文件锁。script2现在可以执行它的SQL查询,for循环…
700msscript2持有的文件锁被移除。script2执行结束。

解释一下上面的表格:

  • 当2个PHP文件同时想要开始一个会话时,只有一个能赢且获得锁。另一个则需要等待。
  • 当它等待的时候,不会做任何事情:session_start()阻塞了之后动作的执行。
  • 一旦第一个脚本的锁被移除,第二个脚本在获得锁的同时就可以向后继续执行了。

在绝大多数场景下,这都使得PHP对于同一个用户来说,表现得像是一系列同步脚本:一个执行完成后执行下一个,没有平行的请求。即使你使用AJAX调用这些PHP脚本也无济于事。
所以,刚才两个脚本没能同时在350ms左右的时间执行完毕,第一个脚本350ms执行完毕,而第一个脚本则消耗两倍的时长执行了700ms,因为它得等第一个脚本先执行完。

有的小伙伴说,哪有这么麻烦,如果是Memcached保存session的话,直接将memcached.sess_locking设置为“off”,来避免session锁就行了,其实然并卵。这个方法虾皮路就试过,没啥用。

PHP session锁有可能出现的问题

锁的存在也它好的一面。想象以下没有“session锁”的场景,当两个脚本同时处理同一个session数据时,可能引发错误:

Timingscript 1script 2
0mssession_start();session数据被读入到$_SESSION变量中session_start();session数据被读入到$_SESSION变量中
15ms脚本1写入session数据:$_SESSION['payment_id'] = 1;脚本2写入session数据:$_SESSION['payment_id'] = 5;
350mssleep(1);脚本结束,保存session数据
450ms脚本结束,保存session数据

session中的数据值应该是多少?
应当是脚本1的所保存的值。因为脚本2所保存的值被脚本1最后所保存的值覆盖了。

这是一个非常尴尬,而且又很难排查的并发问题。session锁可以防止这种情况发生。
绝大多数情况下,这是写session数据时才会碰到的问题。如果你有一个PHP脚本只是读取session数据(大多数ajax请求都是),你可以安全地对数据进行多次读取。另一方面,如果你有一个长时间运行的脚本,它读取了session数据并且还会修改session数据,而另一个脚本开始执行并且读取到了旧的过时数据 — 这也可能使你的应用出错。

三、解决办法

以上给大家普及了一下PHP session锁的原理知识,接下来就介绍解决办法。

PHP中有一个方法叫做session_write_close()。它的功能如其名:写入session数据,关闭session文件,从而解除了session锁。你在PHP代码中,可以这样使用。

<?php
// This works in PHP 5.x and PHP 7
session_start();
$_SESSION['something'] = 'foo';
$_SESSION['yolo'] = 'swag';
session_write_close();
// Do the rest of your PHP execution below

因为虾皮路用的是PHP 7.4,因此这里的解决办法是直接修改受影响的文件代码,如下,原文件中的受影响代码如下

if (!session_id()) session_start();

修改后为

if (!session_id()) session_start(['read_and_close' => true]);

保存后重启PHP,问题解决。

解决后的网络波动也停止了,如下图。

Memcached读写Session数据失败的原因分析及解决办法插图2
 收藏 (0) 打赏

您可以选择一种方式赞助本站

支付宝扫一扫赞助

微信钱包扫描赞助

虾皮路版权所有,未经允许不得转载:虾皮路 » Memcached读写Session数据失败的原因分析及解决办法
分享到: 生成海报

热门文章

评论 抢沙发

  • QQ号
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
切换注册

登录

点击按钮进行验证

忘记密码 ?

您也可以使用第三方帐号快捷登录

切换登录

注册

我们将发送一封验证邮件至你的邮箱, 请正确填写以完成账号注册和激活