Laravel 多進程數據庫隊列死鎖分析及解決方案

問題描述

最近項目線上環境,隊列服務器上一直頻繁地大量出現數據庫死鎖問題,這個問題最先能夠追溯到年前,19年的時候就出現了,當時一直頻於開發業務功能,因此一直未去處理這個問題,此次正好來探究一下死鎖的緣由和問題所在。php

首先,目前項目中使用的隊列驅動選用的是database,由於簡單、高效、無需擴展其餘第三方應用,就一直採用了mysql數據庫來做爲隊列驅動,線上隊列環境運行的是:Ubuntu 16.04 + Mysql5.7 + Laravel5.6,這樣的一個配置,目前總體使用supervisor在上面託管了16個隊列進程。
imagemysql

上圖顯示了17個,由於有一個匹配符,因此須要-1,就是16個。laravel

查看死鎖日誌

image

異常監控

image

這個死鎖問題,接近觸發了44萬次事件,幾乎每分每秒都有概率觸發死鎖,看了下隊列源碼發現是X鎖形成的,而後下面就嘗試模擬一下多進程隊列消費,是否會形成死鎖出現。sql

多進程消費隊列

生成一個測試Job

經過artisan命令來生成,而後咱們爲了模擬處理過程,每一個隊列暫停了500毫秒。數據庫

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class TestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        usleep(500000);
    }
}

生成10000個Job到數據庫隊列中

for($i=0;$i<10000;$i++){
    dispatch(new TestJob())->onQueue('test');
}

配置supervisor託管文件

咱們這裏使用supervisor來託管咱們的8個處理進程,使用配置以下:segmentfault

[program:laravel-worker-queue-test]
process_name=%(program_name)s_%(process_num)02d
command=php /data/sites/test/artisan queue:work --queue=test
autostart=true
autorestart=true
numprocs=8
user=root
redirect_stderr=true
stdout_logfile=/data/sites/test/storage/logs/worker.log

而後開始啓動8個進程,進行測試。
image服務器

而後發現,消費到1400+任務的時候,就產生了456次死鎖。
image併發

下面咱們就來分析一下死鎖過程和嘗試解決一些方案svg

求職機制(Get Job)

咱們運行了8個進程,就至關於8名工做人員,他們都會進行 "求職操做",來得到下一個Job進行工做,在Laravel的源碼中實現是這樣的:測試

public function pop($queue = null)
{
    $queue = $this->getQueue($queue);

    return $this->database->transaction(function () use ($queue) {
        if ($job = $this->getNextAvailableJob($queue)) {
            return $this->marshalJob($queue, $job);
        }

        return null;
    });
}

轉換成SQL語句就是以下操做:

BEGIN TRANSACTION;
SELECT * FROM `jobs` WHERE `queue` = ? AND ((`reserved_at` IS NULL and `available_at` <= NOW()) OR (`reserved_at` <= ?)) ORDER BY `id` ASC limit 1 FOR UPDATE; 
UPDATE `jobs` SET `reserved_at` = NOW(), `attempts` = `attempts` + 1 WHERE `id` = ?;
COMMIT;

第一個select查詢,主要在進行得到下一個可用的job,若是available_at < now,這表示該做業可用,而後選擇了for update增長了排它鎖,禁止其餘工做人員(worker進程),進行處理貨貨更新。

第二個update更新,工做人員(worker進程)將會更新reserved_at時間,進行保留,讓其餘工做進程沒法再查詢到,同時reserved_at字段將會保障,每一個job在刪除以前,至少將被執行一次(除了attempts太大,知足刪除條件)。

當執行完第二個update操做後,工做人員(worker進程)將會開始處理隊列做業,處理完成後,中途沒有異常後,工做人員就會開始刪除掉該做業。

laravel代碼以下:

public function deleteReserved($queue, $id)
{
    $this->database->transaction(function () use ($id) {
        if ($this->database->table($this->table)->lockForUpdate()->find($id)) {
            $this->database->table($this->table)->where('id', $id)->delete();
        }
    });
}

轉換成對應的SQL操做:

BEGIN TRANSACTION;
SELECT * from `jobs` WHERE `id` = ? FOR UPDATE;
DELETE FROM `jobs` WHERE `id` = ?;
COMMIT;

首先仍是會嘗試去使用X鎖,鎖住該記錄,而後進行刪除,再提交整個事務。

這樣問題就開始來了,經過以上結構,單個進程進行該操做應該沒有太大問題,可是多個進程同時操做執行2組SQL的時候,可能就會出現死鎖了。

當同時8個進程進行該操做時,同時線上又在頻繁的操做該表,這邊又在頻繁的刪改查,能夠算得上併發式的瘋狗操做。

問題緣由

當工做進程(1)正在查詢下一個可用工做進程時,他將會經過for update嘗試鎖住主鍵索引(id_index)

工廠進程(2)也剛處理完一個做業,而且正在嘗試執行刪除查詢,以便從該表中刪除做業,當能夠執行刪除時,已經拿到了主鍵鎖(index lock),可是刪除操做又會影響到queue_index,所以查詢就會請求該鎖。

這樣將會可能產生全局死鎖,每一個事務都在等待另外一個事務持有的鎖。

下面是用腳本模擬整個隊列的操做流程,依然產生了大量的死鎖:
image

解決方案

根據以上的問題,想到了一些解決方案,仍然能夠有效處理掉死鎖:

1.切換到隊列系統到Redis或Beanstalkd,減小Mysql層面的事務開銷,利用內存達到更快的處理速度。

2.刪除掉queue_index索引,爲了不死鎖,咱們能夠刪除這個條件,可是刪除後,處理速度會大大下降。

3.添加軟刪除:deleted_at,將數據變成更新操做,而不是刪除操做,因爲是更新,因此不會致使死鎖(無需鎖定該記錄)

4.嘗試使用第三方擴展包laravel-queue-database-ph4,使用S鎖實現的數據庫隊列,增長了version字段,消除掉了死鎖的問題。

相關文章
相關標籤/搜索