PHP并发问题,多个并发请求;互斥锁?

2022-08-30 15:21:11

所以我刚刚意识到PHP可能会同时运行多个请求。昨晚的日志似乎显示,有两个请求是并行处理的;每个都触发了从另一台服务器导入数据;每个都尝试将记录插入到数据库中。一个请求在尝试插入另一个线程刚刚插入的记录时失败(导入的数据随 PK 一起提供;我没有使用递增的 ID):.SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '865020' for key 'PRIMARY' ...

  1. 我是否正确诊断了此问题?
  2. 我应该如何解决这个问题?

以下是一些代码。我已经剥离了其中的大部分(日志记录,从数据中创建患者以外的其他实体),但以下内容应包括相关片段。请求命中 import() 方法,该方法实质上为每个要导入的记录调用 importOne()。注意 importOne() 中的保存方法;这是一个 Eloquent 方法(使用 Laravel 和 Eloquent),它将生成 SQL 以根据需要插入/更新记录。

public function import()
{
        $now = Carbon::now();
        // Get data from the other server in the time range from last import to current import
        $calls = $this->getCalls($this->getLastImport(), $now);
        // For each call to import, insert it into the DB (or update if it already exists)
        foreach ($calls as $call) {
            $this->importOne($call);
        }
        // Update the last import time to now so that the next import uses the correct range
        $this->setLastImport($now);
}

private function importOne($call)
{
    // Get the existing patient for the call, or create a new one
    $patient = Patient::where('id', '=', $call['PatientID'])->first();
    $isNewPatient = $patient === null;
    if ($isNewPatient) {
        $patient = new Patient(array('id' => $call['PatientID']));
    }
    // Set the fields
    $patient->given_name = $call['PatientGivenName'];
    $patient->family_name = $call['PatientFamilyName'];
    // Save; will insert/update appropriately
    $patient->save();
}

我猜解决方案需要围绕整个导入块的互斥体?如果请求无法获得互斥体,它只需继续处理请求的其余部分即可。思潮?

编辑:只是要注意,这不是一个严重的故障。捕获并记录异常,然后像往常一样响应请求。导入在另一个请求上成功,然后像往常一样响应该请求。用户不是更明智的;他们甚至不知道导入,这不是请求的主要焦点。所以真的,我可以让它保持原样,除了偶尔的例外,没有什么不好的事情发生。但是,如果有一个修复程序可以防止完成其他工作/不必要地将多个请求发送到该其他服务器,那么这可能值得追求。

编辑2:好的,我已经尝试使用flock()实现锁定机制。思潮?以下方法是否有效?我该如何对这个添加进行单元测试?

public function import()
{
    try {
        $fp = fopen('/tmp/lock.txt', 'w+');
        if (flock($fp, LOCK_EX)) {
            $now = Carbon::now();
            $calls = $this->getCalls($this->getLastImport(), $now);
            foreach ($calls as $call) {
                $this->importOne($call);
            }
            $this->setLastImport($now);
            flock($fp, LOCK_UN);
            // Log success.
        } else {
            // Could not acquire file lock. Log this.
        }
        fclose($fp);
    } catch (Exception $ex) {
        // Log failure.
    }
}

编辑3:关于锁的以下替代实现的想法:

public function import()
{
    try {
        if ($this->lock()) {
            $now = Carbon::now();
            $calls = $this->getCalls($this->getLastImport(), $now);
            foreach ($calls as $call) {
                $this->importOne($call);
            }
            $this->setLastImport($now);
            $this->unlock();
            // Log success
        } else {
            // Could not acquire DB lock. Log this.
        }
    } catch (Exception $ex) {
        // Log failure
    }
}

/**
 * Get a DB lock, returns true if successful.
 *
 * @return boolean
 */
public function lock()
{
    return DB::SELECT("SELECT GET_LOCK('lock_name', 1) AS result")[0]->result === 1;
}

/**
 * Release a DB lock, returns true if successful.
 *
 * @return boolean
 */
public function unlock()
{
    return DB::select("SELECT RELEASE_LOCK('lock_name') AS result")[0]->result === 1;
}

答案 1

您似乎没有争用条件,因为ID来自导入文件,如果您的导入算法工作正常,那么每个线程都有自己的要完成的工作分片,并且不应该与其他线程冲突。现在似乎有2个线程正在收到一个请求,以创建相同的患者,并且由于算法不良而相互冲突。

conflictfree

确保每个生成的线程从导入文件中获取一个新行,并仅在失败时重复。

如果你不能做到这一点,并且想要坚持使用互斥体,使用文件锁似乎不是一个很好的解决方案,因为现在你已经解决了应用程序内的冲突,而它实际上发生在你的数据库中。数据库锁也应该快得多,总的来说是一个更体面的解决方案。

请求数据库锁,如下所示:

$db -> exec('LOCK TABLES WRITE, WRITE');table1table2

当您写入锁定的表时,您可能会遇到SQL错误,因此请用try catch包围您的Patient->save()。

更好的解决方案是使用条件原子查询。其中也包含条件的数据库查询。您可以使用如下查询:

INSERT INTO targetTable(field1) 
SELECT field1
FROM myTable
WHERE NOT(field1 IN (SELECT field1 FROM targetTable))

答案 2

您的示例代码将阻止第二个请求,直到第一个请求完成。您需要使用 选项,以便立即返回错误,而不是等待。LOCK_NBflock()

是的,您可以在文件系统级别或直接在数据库中使用锁定或信号量。

在您的情况下,当您只需要处理每个导入文件一次时,最好的解决方案是为每个导入文件创建一个带有行的 SQL 表。在导入开始时,插入导入正在进行中的信息,以便其他线程知道不再处理它。导入完成后,您可以将其标记为这样。(几个小时后,您可以检查表格以查看导入是否真正完成。

此外,最好做这种一次性的长期事情,例如导入单独的脚本,而不是在向访问者提供正常网页时。例如,您可以安排一个夜间 cron 作业,该作业将拾取导入文件并进行处理。


推荐