Невелике відкриття


Читав про рівні ізоля́цій транза́кцій і відкрив для себе SELECT … FOR UPDATE.

І аж соромно, що я раніше цього не знав. Наче ж я працював з транза́кціями, але чомусь для тих задач завжди якось обходився без locking reads, тільки стандартний режим repeatable read. Ох, яким же чайником я себе відчуваю.

А воно ж, дідько, було ще навіть у фундамента́льному SQL92.

Приклад

Уявімо, що у нас є отака от табличка. (Тут і далі я використовую варіант синтаксису для MySQL/MariaDB).

CREATE TABLE IF NOT EXISTS `sample_job` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `status` enum('new','working','done','failed') NOT NULL DEFAULT 'new',
  `command` varchar(255) NOT NULL,
  `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `started` TIMESTAMP NULL DEFAULT NULL,
  `finished` TIMESTAMP NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `sample_job` (`command`) VALUES
('echo "it is a command"'),
('echo "an other command"'),
('echo "more and more"'),
('echo "plenty of commands"');

Типу, тут у нас є якісь команди, які треба виконати. Час від часу до бази даних під’єднуються програми-виконавці і хочуть взяти собі якусь зада́чку на виконання. Як нам це зробити?

SELECT @JOB_ID:=`id`
FROM `sample_job`
WHERE `status`='new'
ORDER BY `created` ASC, `id` ASC
LIMIT 1;

UPDATE `sample_job`
SET `status`='working', `started`=NOW()
WHERE `id`=@JOB_ID;

Але це два окремих запити, між якими є проміжок часу. Чисто теоретично може бути стан гонитви (race condition), коли два паралельних виконавці намагаються взяти собі одну і ту саму задачу.

Як же я викручувався раніше? Та бувало, що взагалі без транзакцій, через додаткову умову у WHERE та перевірку кількості змінених рядків.

function obtainJobId(\PDO $dbh): int|false
{
    $sth = $dbh->prepare('SELECT `id` FROM `sample_job` '
        . 'WHERE `status`=\'new\' '
        . 'ORDER BY `created` ASC, `id` ASC LIMIT 1');
    $sth->execute();
    $result = $sth->fetchAll(PDO::FETCH_ASSOC);

    if (!count($result)) {
        // No pending jobs
        return false;
    }

    $id = $result[0]['id'];

    $sth = $dbh->prepare('UPDATE `sample_job` '
      . 'SET `status`=\'working\', `started`=CURRENT_TIMESTAMP '
      . 'WHERE `status`=\'new\' AND id=:id');
    $sth->bindValue('id', $id, PDO::PARAM_INT);
    $sth->execute();

    if (empty($sth->rowCount())) {
        // That $id is already used
        return false;
    }

    return $id;
}

Наче непогане рішення, чи не так?

Але.

Але це дуже простий приклад — одна таблиця, один первинний ключ, один SELECT та один UPDATE. Якби умови потребували кілька різних UPDATE, то цей фокус не спрацював би. Треба було б загорнути все в транза́кцію і робити ROLLBACK, якщо щось не спрацювало.

Тож ось воно, правильне рішення — злюще, потенційно небезпечне, воно блокує таблиці на читання, але воно і є пра-правда.

START TRANSACTION;

SELECT @JOB_ID:=`id`
FROM `sample_job`
WHERE `status`='new'
ORDER BY `created` ASC, `id` ASC
LIMIT 1
FOR UPDATE;

UPDATE `sample_job`
SET `status`='working', `started`=CURRENT_TIMESTAMP
WHERE `id`=@JOB_ID;

COMMIT;