【问题标题】:Laravel how to prevent concurrent handling of requests sent by same userLaravel如何防止并发处理同一用户发送的请求
【发布时间】:2020-04-18 12:49:06
【问题描述】:

我有以下控制器方法,当且仅当没有打开的订单时创建一个新订单(打开的订单有 status = 0,关闭的订单有 status = 1)。

public function createOrder(Request $req){
    // some validation stuff
    $last_active = Orders::where('user_id', $this->user->id)->where('status', 0)->orderBy('id', 'desc')->first();
    if ($last_active){
        return ['status' => 'error'];
    }
    $order= Orders::create([
        'status' => 0
                // some details
        ]);
    return ['status' => 'success'];
}

此方法绑定到特定路由

Route::post('/create', 'OrderController@create');

客户端向该路由发出 ajax 请求。 这背后的逻辑非常简单:我希望用户一次只有一个活动订单,因此用户必须在创建新订单之前执行一些操作来关闭之前的订单。 以下代码在普通用户的情况下可以完美运行,但在用户想要损害我的应用程序的情况下则不行。 所以这就是问题所在。当用户每秒发送大量此类请求时(我只是在 Google Chrome 开发控制台中使用以下脚本执行此操作)

for (var i = 0; i < 20; i++) 
    setTimeout(function(){
        $.ajax({
                url : '/create',
                type : 'post', 
            success: function(d){
                console.log(d)
            }
        })
}, 1);

当预期只插入一条而其他不应该插入时,它会导致将 status=0 的多条记录插入数据库。 国际海事组织,会发生什么:

  1. 许多请求都涉及网络服务器(在我的例子中是 nginx)
  2. Webserver 创建许多 PHP 进程(在我的例子中是通过 php-fpm)
  3. 多个PHP进程同时运行方法,在将一条记录插入另一个进程之前同时通过if ($last_active){...}检查,从而导致插入多条记录。

我试图解决这个问题:

  1. 在 nginx 端,我限制了请求速率(10 r/s)。这并没有太大帮助,因为它仍然允许非常快速地发送 10 个请求,它们之间的延迟非常小,然后再拒绝它们。我不能将速率限制值设置为低于 10 r/s,因为它会伤害普通用户
  2. 在 laravel 方面,我尝试进行交易
public function createOrder(Request $req){
    // some validation stuff
    DB::beginTransaction();
    try{
        $last_active = Orders::where('user_id', $this->user->id)->where('status', 0)->orderBy('id', 'desc')->first();
        if ($last_active){
            DB::rollBack();  // i dont think i even need this
            return ['status' => 'error'];
        }
        $order= Orders::create([
            'status' => 0
                    // some details
            ]);
        DB::commit();
    }
    catch (\Exception $e){
        DB::rollBack();
        return ['status' => 'error'];
    }
    return ['status' => 'success'];
}

使用事务可以显着减少插入的行数(甚至经常按预期工作 - 只允许插入 1 行,但并非总是如此)。

  1. 我创建了一个中间件,用于跟踪上次用户请求的发出时间并将此信息存储在会话中
   public function handle($request, Closure $next)
    {
        if ((session()->has('last_request_time') && (microtime(true) - session()->get('last_request_time')) > 1)
            || !session()->has('last_request_time')){
            session()->put('last_request_time', microtime(true));
            return $next($request);
        }
       return abort(429);
    }

它根本没有帮助,因为它只是将问题转移到中间件级别

  1. 我还尝试了一些奇怪的东西:
public function createOrder(Request $req){
    if (Cache::has('action.' . $this->user->id)) return ['status' => 'error'];
        Cache::put('action.' . $this->user->id, '', 0.5);
    // some validation stuff
    $last_active = Orders::where('user_id', $this->user->id)->where('status', 0)->orderBy('id', 'desc')->first();
    if ($last_active){
        Cache::forget('action.' . $this->user->id);
        return ['status' => 'error'];
    }
    $order= Orders::create([
        'status' => 0
                // some details
        ]);
    Cache::forget('action.' . $this->user->id);
    return ['status' => 'success'];
}

这种方法在很多情况下都有效,尤其是与事务结合使用,但有时它仍然允许最多插入 2 行(在 30 种情况下的 1-2 种情况下)。而且它对我来说确实很奇怪。 我考虑过队列,但正如 laravel 文档所述,它们旨在执行耗时的任务。我还考虑过表锁定,但对于普通用户来说似乎也很奇怪并且性能影响。 我相信这个问题存在干净简单的解决方案,但我在谷歌中找不到任何合理的东西,也许我错过了一些非常明显的东西?你能帮忙吗? 此外,在我的应用程序中有很多类似的情况,我真的很想找到一些通用的解决方案,以解决并发执行不仅会导致数据库出现此类错误,还会导致会话、缓存、redis 等错误的情况。

【问题讨论】:

  • 我认为$last_active = Orders::where('user_id', $this-&gt;user-&gt;id)-&gt;where('status', 0)-&gt;orderBy('id', 'desc')-&gt;first(); 不会准确返回最后一个用户的订单。我如何阅读它会返回最后一个状态为零的。它不一定是先前的顺序。如果你想检查最后一个订单的状态,它应该是$order = Order::where(['user_id' =&gt; $this-&gt;user-&gt;id])-&gt;latest()-&gt;first(); if ($order &amp;&amp; (1 === $order-&gt;status)) {/** Exception */}
  • 是的,我更想检查是否存在未平仓的订单并获取它,而不仅仅是检查最后一个订单是否已平仓。虽然我尝试了你的代码,但它也没有工作,因为在检查 if ($order &amp;&amp; (1 === $order-&gt;status)) 时,多个进程同时通过了此检查,并且它们都能够插入新记录
  • 之所以这么说,是因为您将“先前的订单”强调为单数。在这种情况下,您现在正在告诉反向搜索逻辑:$order = Order::where(['user' =&gt; $this-&gt;user-&gt;id, 'status' =&gt; 1])-&gt;first(); if ($order) {throw new \Exception("Case of existing order with status 1.")} /** rest of code */。我并没有试图解决问题,但更有可能是我试图将代码、描述、错误和意图联系起来。你试过@levi 的提议吗?
  • 感谢指点,改了说法。 @levi 完全拯救了我的一天,它只需要一点点改变就完美了。

标签: php database laravel concurrency


【解决方案1】:

您应该能够在user 模型上使用lockForUpdate(),以防止同一用户插入并发订单:

DB::beginTransaction();
User::where('id', $this->user->id)->lockForUpdate()->first();
// Create order if not exists etc...
DB::commit();

【讨论】:

  • 这是个好主意,谢谢。它不适用于 $this-&gt;user-&gt;lockForUpdate();,因为它似乎没有产生任何 sql 查询,但 $u = User::where('id', $this-&gt;user-&gt;id)-&gt;lockForUpdate()-&gt;first(); 确实起到了魅力。据我了解,lockForUpdate 会阻止选择用户行,直到整个事务提交,因此其他事务将不得不等待。我不是很喜欢数据库锁定,你能解释一下在哪些情况下这个技巧会导致死锁,如果我只锁定单个用户而不锁定数据库中的任何其他内容,是否有可能?
  • 鉴于只有一个资源锁与此事务相关联,它不应导致死锁。见这里 - stackoverflow.com/questions/20058542/…
猜你喜欢
  • 2019-09-26
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-09-27
相关资源
最近更新 更多