Быстрая обработка частых задач в Crontab и PHP

В Linux есть универсальный инструмент для обработки длительных задач в фоновом режиме в заданное время, он называется crontab. Этот демон отлично подходит для выполнения повторяющихся задач, однако у него есть одно ограничение: минимальный интервал выполнения задачи – 1 минута.

Но современные приложения должны выполнять некоторые задачи чаще, чтобы избежать неудобств в работе пользователей. Например, если для планирования задач обработки файлов на своем веб-сайте вы используете модель очереди заданий, длительное ожидание отрицательно скажется на конечных пользователях.

Другой сценарий – это применение очереди заданий для отправки текстовых сообщений или электронных писем клиентам после того, как они выполнили определенную задачу в приложении (например, перевели деньги получателю). Если пользователю приходится ждать подтверждения целую минуту, он может подумать, что транзакция не удалась, и попытается повторить ее.

Чтобы устранить эти проблемы, вы можете написать сценарий PHP, который циклически повторяет и обрабатывает задачи в течение 60 секунд, после чего демон crontab вызовет его снова (через минуту). Когда скрипт PHP вызывается демоном crontab в первый раз, он может выполнять задачи в тот период времени, который соответствует логике вашего приложения, не заставляя пользователя ждать.

В этом мануале мы создадим на сервере Ubuntu 20.04 тестовую базу данных cron_jobs, а в ней – таблицу tasks, после чего напишем скрипт, который будет выполнять задачи из таблицы с интервалом в 5 секунд с помощью цикла while(…){…} и функции sleep().

Требования

  • Сервер Ubuntu 20.04, настроенный согласно этому руководству по начальной настройке.
  • Установленный на вашем сервере стек LAMP. Инструкции по установке вы найдете здесь (можно пропустить раздел 4, посвященный виртуальным хостам).

1: Создание базы данных

Прежде всего мы создадим базу данных и таблицу. Подключитесь к серверу по SSH и войдите в MySQL как root:

sudo mysql -u root -p

Введите root-пароль сервера MySQL и нажмите Enter, чтобы продолжить. Затем выполните следующую команду, чтобы создать базу данных по имени cron_jobs.

CREATE DATABASE cron_jobs;

Создайте для базы данных пользователя без привилегий root. Учетные данные этого пользователя потребуются вам позже для подключения к базе данных cron_jobs из PHP. Не забудьте заменить EXAMPLE_PASSWORD надежным паролем:

CREATE USER 'cron_jobs_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'EXAMPLE_PASSWORD';
GRANT ALL PRIVILEGES ON cron_jobs.* TO 'cron_jobs_user'@'localhost';
FLUSH PRIVILEGES;

Затем перейдите в эту БД:

USE cron_jobs;
Database changed

После этого создайте таблицу tasks. В эту таблицу мы вставим несколько задач, которые будут автоматически выполняться демоном cron. Поскольку минимальный интервал между двумя задачами cron составляет 1 минуту, позже мы создадим сценарий PHP, который позволит нам преодолеть это ограничение и будет выполнять задачи с интервалом в 5 секунд.

А пока создайте таблицу:

CREATE TABLE tasks (
task_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_name VARCHAR(50),
queued_at DATETIME,
completed_at DATETIME,
is_processed CHAR(1)
) ENGINE = InnoDB;

Вставьте в таблицу три записи. Используйте функцию MySQL NOW() в столбце queued_at, чтобы записать текущую дату и время помещения задачи в очередь. Для столбца completed_at используйте функцию CURDATE(), чтобы установить время по умолчанию 00:00:00. Позже, – по мере выполнения задач, – ваш скрипт обновит этот столбец:

INSERT INTO tasks (task_name, queued_at, completed_at, is_processed) VALUES ('TASK 1', NOW(), CURDATE(), 'N');
INSERT INTO tasks (task_name, queued_at, completed_at, is_processed) VALUES ('TASK 2', NOW(), CURDATE(), 'N');
INSERT INTO tasks (task_name, queued_at, completed_at, is_processed) VALUES ('TASK 3', NOW(), CURDATE(), 'N');

После выполнения каждой команды INSERT вы получите:

Query OK, 1 row affected (0.00 sec)
...

Убедитесь, что данные на своем месте, выполнив оператор SELECT для таблицы tasks:

SELECT task_id, task_name, queued_at, completed_at, is_processed FROM tasks;

Вы найдете список всех задач:

+---------+-----------+---------------------+---------------------+--------------+
| task_id | task_name | queued_at           | completed_at        | is_processed |
+---------+-----------+---------------------+---------------------+--------------+
|       1 | TASK 1    | 2021-03-06 06:27:19 | 2021-03-06 00:00:00 | N            |
|       2 | TASK 2    | 2021-03-06 06:27:28 | 2021-03-06 00:00:00 | N            |
|       3 | TASK 3    | 2021-03-06 06:27:36 | 2021-03-06 00:00:00 | N            |
+---------+-----------+---------------------+---------------------+--------------+
3 rows in set (0.00 sec)

В столбце completed_at установлено время 00:00:00, и далее этот столбец будет обновляться – после обработки задач сценарием PHP, который мы создадим вскоре.

Выйдите из командной строки MySQL:

QUIT;
Bye

Теперь у вас есть база данных cron_jobs и таблица tasks. Приступим к написанию сценария PHP, который обрабатывает задачи.

2: Создание PHP-скрипта

На этом шаге мы напишем сценарий, который комбинирует цикл PHP while(…){…} и функцию sleep, что позволяет ему выполнять задачи через каждые 5 секунд.

Откройте новый файл /var/www/html/tasks.php в корневом каталоге вашего веб-сервера:

sudo nano /var/www/html/tasks.php

Создайте новый блок try { после тега <?php и объявите переменные базы данных, которую вы создали в разделе 1. Не забудьте заменить EXAMPLE_PASSWORD настоящим паролем вашего пользователя базы данных:

<?php

try {

    $db_name     = 'cron_jobs';

    $db_user     = 'cron_jobs_user';

    $db_password = 'EXAMPLE_PASSWORD';

    $db_host     = 'localhost';

Объявите новый класс PDO (что значит PHP Data Object) и установите атрибут ERRMODE_EXCEPTION для перехвата ошибок PDO. Кроме того, нужно установить значение false для параметра ATTR_EMULATE_PREPARES, чтобы позволить собственному ядру базы данных MySQL обрабатывать эмуляцию. Эти операторы позволяют отправлять SQL-запросы и данные отдельно – для повышения безопасности и снижения вероятности SQL-инъекций:

$pdo = new PDO('mysql:host=' . $db_host . '; dbname=' . $db_name, $db_user, $db_password);

$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

Затем создайте переменную $loop_expiry_time и установите в качестве ее значения текущее время плюс 60 секунд. Затем откройте новый оператор PHP while(time() < $loop_expiry_time). Идея этого фрагмента состоит в том, чтобы создать цикл, который выполняется до тех пор, пока текущее время (time()) не совпадет с переменной $loop_expiry_time:

$loop_expiry_time = time() + 60;

while (time() < $loop_expiry_time) {

Затем объявите подготовленный SQL-оператор, который извлекает необработанные задачи из таблицы:

$data = [];
$sql  = "select
task_id
from tasks
where is_processed = :is_processed
";

Выполните команду SQL и выберите из таблицы tasks все строки, в которых для столбца is_processed установлено значение N – это означает, что строки не обрабатываются.

$data['is_processed'] = 'N';

$stmt = $pdo->prepare($sql);

$stmt->execute($data);

Затем выполните цикл по извлеченным строкам с помощью оператора while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {…} и создайте еще один SQL оператор. На этот раз команда будет обновлять столбцы is_processed и completed_at для каждой обработанной задачи. Это гарантирует, что скрипт не будет обрабатывать задачи более одного раза:

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {

    $data_update   = [];

    $sql_update    = "update tasks set

                      is_processed  = :is_processed,

                      completed_at  = :completed_at

                      where task_id = :task_id

                      ";

    $data_update   = [

                     'is_processed' => 'Y',

                     'completed_at' => date("Y-m-d H:i:s"),

                     'task_id'      => $row['task_id']

                     ];

    $stmt = $pdo->prepare($sql_update);

    $stmt->execute($data_update);

}

Примечание: Если вам нужно обработать большую очередь (например, 100 000 записей в секунду), вы можете рассмотреть возможность создания очереди на сервере Redis, поскольку он быстрее, чем MySQL, и больше подходит для обработки подобных объемов.

Прежде чем закрыть первый оператор while (time() < $loop_expiry_time) {, включите оператор sleep(5), чтобы приостановить выполнение задач на 5 секунд и освободить ресурсы сервера.

Вы можете изменить этот интервал в зависимости от вашей бизнес-логики и того, насколько быстро вы хотите выполнять задачи. Например, если вы хотите, чтобы задачи обрабатывались 3 раза в минуту, установите в sleep значение 20.

Не забудьте добавить catch, чтобы перехватить сообщения об ошибках PDO внутри блока } catch (PDOException $ex) { echo $ex->getMessage(); }:

sleep(5);

        }

} catch (PDOException $ex) {

    echo $ex->getMessage();

}

Готовый скрипт tasks.php будет иметь следующий вид:

<?php

try {

    $db_name     = 'cron_jobs';

    $db_user     = 'cron_jobs_user';

    $db_password = 'EXAMPLE_PASSWORD';

    $db_host     = 'localhost';

    $pdo = new PDO('mysql:host=' . $db_host . '; dbname=' . $db_name, $db_user, $db_password);

    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

    $loop_expiry_time = time() + 60;

    while (time() < $loop_expiry_time) {

        $data = [];

        $sql  = "select

                 task_id

                 from tasks

                 where is_processed = :is_processed

                 ";

        $data['is_processed'] = 'N';

        $stmt = $pdo->prepare($sql);

        $stmt->execute($data);

        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {

            $data_update   = [];

            $sql_update    = "update tasks set

                              is_processed  = :is_processed,

                              completed_at  = :completed_at

                              where task_id = :task_id

                              ";

            $data_update   = [

                             'is_processed' => 'Y',

                             'completed_at' => date("Y-m-d H:i:s"),

                             'task_id'      => $row['task_id']

                             ];

            $stmt = $pdo->prepare($sql_update);

            $stmt->execute($data_update);

        }

        sleep(5);

        }

} catch (PDOException $ex) {

    echo $ex->getMessage();

}

Сохраните файл, нажав Ctrl + X, Y, затем Enter.

Завершив написание скрипта в файле /var/www/html/tasks.php, мы можем автоматизировать запуск этого скрипта с помощью демона cron. Демон будет запускать скрипт через 1 минуту на следующем шаге.

3: Планирование запуска PHP-скрипта в cron

В Linux вы можете запланировать автоматический запуск задач по истечении установленного времени. Для этого нужно добавить команду в файл crontab. На этом этапе мы настроим crontab для запуска сценария /var/www/html/tasks.php каждую минуту. Итак, откройте файл /etc/crontab с помощью nano:

sudo nano /etc/crontab

Затем добавьте в конец файла следующую строку, чтобы перезапускать http://localhost/tasks.php каждую минуту:

...
* * * * * root /usr/bin/wget -O - http://localhost/tasks.php

Сохраните и закройте файл.

В этом руководстве предполагается, что у вас есть базовые знания о том, как работают задачи cron.

Читайте также: Автоматизация задач с помощью Cron

Как говорилось ранее, демон cron запускает файл tasks.php только раз в 1 минуту, но после первого выполнения файла он будет перебирать открытые задачи в цикле еще 60 секунд. По истечении времени цикла демон cron снова запустит файл, и процесс продолжится.

Обновив /etc/crontab, демон crontab должен немедленно начать выполнение задач MySQL, которые вы вставили в таблицу tasks. Чтобы убедиться, что все работает должным образом, запросите свою базу данных cron_jobs.

4: Тестирование настройки

На этом этапе мы снова откроем свою базу данных и проверим, обрабатывает ли файл tasks.php поставленные в очередь задачи при автоматическом запуске через crontab.

Войдите на свой сервер MySQL как root:

sudo mysql -u root -p

Затем введите root пароль MySQL и нажмите Enter, чтобы продолжить. Затем перейдите в базу данных:

USE cron_jobs;
Database changed

Запустите оператор SELECT для таблицы tasks:

SELECT task_id, task_name, queued_at, completed_at, is_processed FROM tasks;

Вы получите примерно следующий результат. Задачи в столбце completed_at были обработаны с интервалом в 5 секунд. Кроме того, задачи отмечены как выполненные – в столбце is_processed теперь установлено значение Y, что означает YES.

+---------+-----------+---------------------+---------------------+--------------+
| task_id | task_name | queued_at           | completed_at        | is_processed |
+---------+-----------+---------------------+---------------------+--------------+
|       1 | TASK 1    | 2021-03-06 06:27:19 | 2021-03-06 06:30:01 | Y            |
|       2 | TASK 2    | 2021-03-06 06:27:28 | 2021-03-06 06:30:06 | Y            |
|       3 | TASK 3    | 2021-03-06 06:27:36 | 2021-03-06 06:30:11 | Y            |
+---------+-----------+---------------------+---------------------+--------------+
3 rows in set (0.00 sec)

Это значит, что PHP-скрипт работает правильно; задачи были запущены в более короткий интервал времени, переопределив ограничение crontab.

Заключение

В этом руководстве вы создали БД, а затем составили список задач и поместили его в таблицу. С помощью сценария PHP задачи запускаются с интервалом в 5 секунд. Используя логику из этого мануала, вы можете реализовать и более сложное приложение на основе очереди заданий, в котором задачи должны выполняться несколько раз в течение 1 минуты.

Tags: , , ,

Добавить комментарий