PHP中防止竞态条件最可靠和安全的方法是什么?

26

我需要在PHP中使用互斥锁或信号量,但这让我感到害怕。澄清一下,我不怕编写能正确同步且无死锁的代码,也不惧并发编程的危险,而是担心PHP处理边缘情况的能力。

快速背景:编写一个信用卡处理接口,位于用户和第三方信用卡网关之间。需要防止重复请求,并已经有一个可行的系统,但如果用户在毫秒级别同时点击提交(没有启用JS,因此我无法为其禁用按钮),会出现竞争条件,导致我的PHP脚本没有意识到已经发生了重复请求。需要互斥锁/信号量,以确保每个唯一事务只通过一个成功请求。

我在多核Linux机器上通过PHP-FPM在nginx后运行PHP。我想确保:

  1. 信号量在所有php-fpm进程之间共享,并跨越所有核心(i686内核)。
  2. php-fpm在持有互斥锁/信号量时处理PHP进程崩溃,并相应地释放它们。
  3. php-fpm在持有互斥锁/信号量时处理会话中止,并相应地释放它们。

是的,我知道。这些都是非常基本的问题,并且愚蠢地认为任何其他软件都没有一个正确的解决方案。但这是PHP,并不一定考虑并发性,它经常崩溃(取决于您加载了哪些扩展程序),而且处于不稳定的环境中(在PHP-FPM上以及在Web上)。

关于(1),我假设如果PHP使用POSIX函数,这两个条件在SMP i686机器上都成立。至于(2),我从简要浏览文档中看到有一个参数决定此行为(尽管我不明白为什么会希望PHP在会话终止时不释放互斥锁)。但是(3)是我的主要关注点,我不知道是否可以安全地假设php-fpm可以为我处理所有边界情况。我(显然)永远不想出现死锁,但我不确定是否可以相信PHP永远不会使我的代码处于无法获得互斥锁的状态,因为抓住它的会话已经被优雅或不优雅地终止。

我考虑使用MySQL的LOCK TABLES方法,但是在那里我甚至更加怀疑,因为虽然我相信MySQL锁比PHP锁更可靠,但我担心如果PHP在持有MySQL会话锁的同时中止请求(而*没有*崩溃),MySQL可能会保持表格锁定(特别是因为我可以轻易想象导致这种情况发生的代码)。

老实说,我最舒适的选择是使用非常基本的C扩展,以便我可以看到正在进行的POSIX调用以及使用哪些参数来确保我想要的精确行为.. 但我不希望写那些代码。

有人有关于PHP并发方面的最佳实践想分享吗?


1
我不熟悉你的设置,但当我看到你需要阻止重复请求被处理时,我想知道为什么你不能检查存储在用户会话中的表单密钥 - nickb
这基本上就是我现有的重复请求预防措施,似乎在某些具有多个PHP进程的设置中,存在一定时间点的竞争条件,两个PHP处理后端都将令牌/随机数/任何东西视为尚未处理。Nonce通常用于防止重放攻击(或一般的XSS),但在我的情况下,已经受到保护 - 除非它在毫秒后作为竞争条件被重放。 - Mahmoud Al-Qudsi
也许对您来说,这篇关于PHP和会话锁定/竞争条件的(有点过时的)文章很有趣。但是,至少PHP 5.3.2似乎已经在使用一致的flock()来进行会话:http://thwartedefforts.org/2006/11/11/race-conditions-with-ajax-and-php-sessions/ - Kaii
说句实话,PHP的posix扩展可以让你精确控制信号量和系统调用。但是你不需要它来解决你的问题。https://www.php.net/sem - Kaii
5个回答

12

实际上,我认为不需要复杂的互斥/信号量等解决方案。

在PHP中,存储在$_SESSION中的表单键就是你所需要的。这种方法的一个好处是可以保护你的表单免受CSRF攻击。

在PHP中,会通过获取POSIX的flock()来锁定会话,并且PHP的session_start()会等待用户会话被释放。你只需要在第一个有效请求上使用unset()删除表单密钥。第二个请求必须等到第一个请求释放会话。

但是,在涉及多个主机的(非会话或源IP基础的)负载均衡情况下,情况变得更加复杂。对于这种情况,我相信你会在这篇很棒的论文中找到有价值的解决方案:http://thwartedefforts.org/2006/11/11/race-conditions-with-ajax-and-php-sessions/

我使用以下演示重现了您的用例。只需将此文件放到您的Web服务器上并进行测试:

<?php
session_start();
if (isset($_REQUEST['do_stuff'])) {
  // do stuff
  if ($_REQUEST['uniquehash'] == $_SESSION['uniquehash']) {
    echo "valid, doing stuff now ... "; flush();
    // delete formkey from session
    unset($_SESSION['uniquehash']);
    // release session early - after committing the session data is read-only
    session_write_close();
    sleep(20);  
    echo "stuff done!";
  }
  else {
    echo "nope, {$_REQUEST['uniquehash']} is invalid.";
  }     
}
else {
  // show form with formkey
  $_SESSION['uniquehash'] = md5("foo".microtime().rand(1,999999));
?>
<html>
<head><title>session race condition example</title></head>
<body>
  <form method="POST">
    <input type="hidden" name="PHPSESSID" value="<?=session_id()?>">
    <input type="text" name="uniquehash" 
      value="<?= $_SESSION['uniquehash'] ?>">
    <input type="submit" name="do_stuff" value="Do stuff!">
  </form>
</body>
</html>
<?php } ?>

谢谢你提供的链接,它给了我所需的信息。但是PHP真的需要加强他们的游戏——默认使用flock来保护会话?真的吗?我希望使用信号量/互斥锁/MySQL等来避免出于性能原因自己实现这个! - Mahmoud Al-Qudsi
文档的后半部分提供了我需要释放MySQL锁的所有信息(特别是MySQL会在会话结束时释放锁,而不一定是在中止时释放,假设持久连接),这对于我来说很有用,因为我需要编写自己的会话处理程序。顺便说一下,你上面的代码示例仍然容易受到竞态条件的影响,如果两个请求在达到第5行的比较之前都到达,则存在竞争条件。 - Mahmoud Al-Qudsi
3
注意,仅当您在文件系统上使用默认实现时,会锁定会话。如果您使用自定义会话处理程序,则它将负责实现锁定。老实说,flock 没有任何问题。 - Charles
1
@MahmoudAl-Qudsi “仍然容易受到竞争条件的影响” - 为什么?session_start会代表您进行锁定。 - zaf
1
结果证明这还不够好。没有 cookie 的用户无法受到保护。叹气 PHP。 - Mahmoud Al-Qudsi
1
@MahmoudAl-Qudsi,您可以在不需要cookies的情况下通过表单提交会话ID! <input type ="hidden" name ="PHPSESSID" value ="<?=session_id()?>"> [请参阅文档](http://php.net/manual/zh/session.idpassing.php)...我已将其包含在我的更新答案中...希望这可以帮助。 - Kaii

3
您提出了一个有趣的问题,但是您没有任何数据或代码可以展示。
对于80%的情况,如果您遵循关于防止用户多次提交表单的标准程序和惯例,PHP本身引起任何不良后果的机率几乎为零。这适用于几乎所有其他设置,而不仅仅是PHP。
如果您是20%,并且您的环境要求这样做,则一种选择是使用消息队列,我相信您对此很熟悉。同样,这个想法与语言无关。这完全是关于数据如何移动。

1

你可以将一个随机哈希值存储在会话数据的数组中,并将该哈希值作为隐藏表单输入值打印出来。当请求到达时,如果隐藏的哈希值存在于会话数组中,则可以从会话中删除哈希并处理表单,否则不进行处理。

这样可以防止重复提交表单,同时有助于防止 CSRF 攻击。


请查看我在问题本身上的评论。我已经有了nonce来防止重放和CSRF / XSS攻击。它们不能防止竞态条件,因为会话或mysql访问不是原子的。 - Mahmoud Al-Qudsi
会话写通过 flock() 是原子的。 - Kaii

0

如果问题只在毫秒内连续按下按钮时出现,那么软件去抖动器是否可行呢?比如将按钮按下的时间保存在会话变量中,在一秒钟内不允许再次按下。这只是我喝咖啡前的一个想法。干杯。


这基本上就是我现有的防止重复请求的方式,似乎在某些多个PHP进程的设置中,存在一种竞争条件(race condition),导致两个PHP处理后端都将令牌/随机数/其他标识视为尚未被处理。 - Mahmoud Al-Qudsi

0
为了防止代码中的会话竞争条件,我所做的是在最后一个将数据存储在会话中的操作之后使用PHP函数session_write_close()。请注意,如果您正在使用PHP 7,则需要在php.ini中禁用默认输出缓冲。如果您有耗时的操作,最好在调用session_write_close()之后执行它们。
希望这能帮助到某些人,对我来说,它拯救了我的生命 :)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接