flock -n, PHP CLI и MySQL

flock -n, PHP CLI и MySQL

 

flock -n, PHP CLI и MySQL: настройка для тяжёлых импортов


Что такое flock -n и зачем он нужен

flock — это утилита Linux из пакета util-linux, которая управляет рекомендательными файловыми блокировками (advisory locks) из shell-скриптов. Блокировка накладывается на файловый дескриптор, а ядро автоматически снимает её, когда процесс завершается — даже если он упал или был убит.

Флаг -n (--nonblock, --nb) делает блокировку неблокирующей: если файл уже заблокирован другим процессом, flock не ждёт, а немедленно завершается с кодом 1. Это принципиально отличается от поведения по умолчанию, когда flock будет ждать освобождения блокировки бесконечно.

Два режима вызова

1. Обёртка вокруг команды: 

flock -n /var/lock/import.lock -c '/usr/bin/php /opt/import.php' 

flock открывает файл /var/lock/import.lock, захватывает эксклюзивную блокировку, запускает команду и снимает блокировку при выходе. Файл создаётся автоматически, если не существует.

2. Блокировка через файловый дескриптор внутри скрипта:

#!/bin/bash
LOCKFILE=/var/lock/import.lock

exec 200>"$LOCKFILE"
flock -n 200 || {
    echo "Импорт уже запущен, выходим" >&2
    exit 0
}

# Здесь — тело скрипта, защищённое блокировкой
/usr/bin/php /opt/import.php

Файловый дескриптор 200 остаётся открытым всё время работы скрипта. Ядро снимает блокировку, когда дескриптор закрывается — то есть когда процесс завершается любым способом, включая kill -9.

Почему flock лучше, чем PID-файлы

Классический подход — писать PID процесса в файл и проверять его при запуске. Проблемы: если процесс упал без очистки (OOM-killer, kill -9), PID-файл остаётся, и следующий запуск думает, что процесс ещё работает. С flock ядро само следит за блокировкой: процесс умер — дескриптор закрылся — блокировка снялась. Никаких зомби-PID.

Применение в cron

Главная задача flock -n в cron — не дать двум копиям одного скрипта работать одновременно. Например, импорт из внешнего API по расписанию каждые 5 минут:

*/5 * * * * flock -n /var/lock/api-import.lock /usr/bin/php /opt/import.php

Если предыдущий импорт ещё не закончился, flock -n тихо завершается, и cron не запускает второй процесс. Никаких гонок, дублирования данных или двойной нагрузки на БД.


Почему для тяжёлых импортов нужен CLI, а не curl

Когда говорят «запустить импорт через curl», имеют в виду следующее: дёрнуть URL веб-скрипта (curl https://example.com/import.php), который выполнит всю работу внутри веб-сервера (Apache/Nginx + PHP-FPM). Для маленьких задач это работает. Для больших — ломается на нескольких уровнях.

1. max_execution_time — главная проблема

В веб-окружении (SAPI) max_execution_time по умолчанию равен 30 секундам. В CLI — 0 (без лимита), и это сделано намеренно.

CLI жёстко переопределяет max_execution_time на 0, и это значение нельзя изменить через php.ini для CLI — только через set_time_limit() или php -d в командной строке.

При запуске через curl скрипт работает в контексте веб-сервера. Даже если вы увеличите max_execution_time в php.ini, веб-сервер порвёт соединение раньше: у Nginx — fastcgi_read_timeout (по умолчанию 60 секунд), у Apache — Timeout (по умолчанию 120 секунд). Получается многослойный тайм-аут, и слабейшее звено убивает процесс на середине импорта. 

2. Память

Веб-окружение обычно ограничивает memory_limit до 128–256 МБ. Импорт больших файлов (XML, CSV на сотни мегабайт) через загрузку в память мгновенно упирается в этот лимит. В CLI можно задать нужный лимит через параметр: 

php -d memory_limit=2G /opt/import.php

или в самом скрипте: 

ini_set('memory_limit', '2G');

3. Веб-сервер как лишнее звено

При вызове через curl запрос проходит через цепочку: curl → Nginx/Apache → PHP-FPM/CGI → скрипт. Каждое звено добавляет накладные расходы, буферизацию, тайм-ауты. CLI запускает PHP-скрипт напрямую, без веб-сервера, без HTTP-заголовков, без буферизации вывода.

4. ignore_user_abort

В веб-режиме, если клиент (curl) обрывает соединение, PHP может продолжить или остановить выполнение в зависимости от ignore_user_abort. В CLI этой проблемы нет — нет клиента, который мог бы «отвалиться». 

5. Разные php.ini

У CLI обычно отдельный php.ini (/etc/php/8.x/cli/php.ini), не зависящий от настроек веб-сервера. Это значит, что вы можете свободно настраивать CLI-окружение под тяжёлые задачи, не рискуя сломать веб-приложение. 

Итого: CLI vs curl

Параметр curl (веб-SAPI) CLI
max_execution_time 30 сек (по умолчанию) 0 (без лимита)
Тайм-аут веб-сервера Да, рвёт соединение Нет веб-сервера
memory_limit 128–256 МБ (типично) Произвольно
HTTP-накладные расходы Заголовки, буферизация Нет
Отдельный php.ini Нет (общий с веб) Да
Обрыв клиентом Риск остановки скрипта Нет клиента

Настройки PHP для тяжёлых CLI-скриптов

У CLI свой php.ini — обычно /etc/php/8.x/cli/php.ini. Проверить, какой файл используется: 

php --ini

 

Ключевые директивы

; Нет лимита на время выполнения (значение по умолчанию для CLI)
max_execution_time = 0

; Память — под объём данных. Для импорта больших XML/CSV — от 512M до 4G
memory_limit = 2G

; Вывод в реальном времени, без буферизации
implicit_flush = true
output_buffering = Off

; Ошибки — в виде plain text, без HTML
html_errors = Off
display_errors = On
error_log = /var/log/php/import_errors.log

; Игнорировать обрыв соединения (для CLI неактуально, но на всякий случай)
ignore_user_abort = On

Если не хочется менять php.ini глобально, можно передать параметры прямо в команде:

php -d memory_limit=4G -d max_execution_time=0 -d error_reporting=E_ALL /opt/import.php

Или внутри скрипта:

<?php
set_time_limit(0);              // без лимита времени
ini_set('memory_limit', '2G');  // 2 ГБ памяти
ini_set('display_errors', '1');
error_reporting(E_ALL);

Важно: max_execution_time в CLI не учитывает время, проведённое в системных вызовах — sleep(), запросы к БД, обращения к API. То есть скрипт, который 99% времени ждёт ответа от MySQL, не упрёт в лимит, даже если он задан. Это работает и в веб-SAPI, но в CLI лимит и так 0


Настройки MySQL для тяжёлых импортов

Все настройки применяются в my.cnf (обычно /etc/mysql/my.cnf или /etc/my.cnf) в секции [mysqld]. Часть параметров можно менять на лету через SET GLOBAL — это удобно для временной настройки на время импорта без перезапуска сервера.

InnoDB — основной движок

Если таблицы на InnoDB (а по умолчанию с MySQL 5.5 это так), главные параметры:

[mysqld]
; Буферный пул — 50–80% доступной RAM. Главное хранилище данных в памяти.
innodb_buffer_pool_size = 4G

; Размер лог-файла. Больше = реже flush на диск = быстрее запись.
; Рекомендуется ~25% от buffer_pool_size.
innodb_log_file_size = 1G

; Буфер лога транзакций. Для крупных транзакций — от 64M до 256M.
innodb_log_buffer_size = 128M

; Метод flush. O_DIRECT обходит OS cache — быстрее при импорте.
innodb_flush_method = O_DIRECT

; Отдельный файл на таблицу — меньше раздувание ibdata1.
innodb_file_per_table = 1

Почему это важно: innodb_buffer_pool_size — параметр номер один. Чем больше данных помещается в память, тем меньше дискового I/O, и тем быстрее вставки. innodb_log_file_size определяет, сколько redo-лога может накопиться до flush в табличное пространство. Маленький лог — частые flush'и — медленный импорт.

Временные настройки на время импорта (через SET GLOBAL)

Это настройки, которые ускоряют импорт, но снижают надёжность. Применяйте только на время импорта и возвращайте обратно:

-- Отключить проверку внешних ключей
SET GLOBAL foreign_key_checks = 0;

-- Отключить проверку уникальности (убедитесь, что данные чистые!)
SET GLOBAL unique_checks = 0;

-- Flush лога раз в секунду вместо каждой транзакции
-- 0 = не flush, 2 = flush в OS cache (не на диск)
-- ВНИМАНИЕ: риск потери последней секунды данных при краше
SET GLOBAL innodb_flush_log_at_trx_commit = 0;

-- Отключить doublewrite buffer (ускоряет запись, риск при краше)
SET GLOBAL innodb_doublewrite = 0;

-- Максимальный размер пакета — для крупных INSERT
SET GLOBAL max_allowed_packet = 256M;

-- Размер буфера для bulk-insert (для MyISAM; на InnoDB не влияет,
-- но не повредит, если есть MyISAM-таблицы)
SET GLOBAL bulk_insert_buffer_size = 256M;

После импорта — обязательно вернуть обратно:

SET GLOBAL foreign_key_checks = 1;
SET GLOBAL unique_checks = 1;
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL innodb_doublewrite = 1;
SET GLOBAL max_allowed_packet = 64M;

Оставить innodb_flush_log_at_trx_commit = 0 в продакшене — значит жертвовать ACID-гарантиями: при сбое сервера вы потеряете данные за последнюю секунду.

Сессионные настройки внутри скрипта

Ещё эффективнее — задавать эти параметры на уровне сессии внутри скрипта, а не глобально:

$pdo->exec("SET foreign_key_checks = 0");
$pdo->exec("SET unique_checks = 0");
$pdo->exec("SET autocommit = 0");

// ... импорт данных ...

$pdo->exec("COMMIT");
$pdo->exec("SET foreign_key_checks = 1");
$pdo->exec("SET unique_checks = 1");
$pdo->exec("SET autocommit = 1");

autocommit = 0 критически важен: при autocommit = 1 каждая вставка — отдельная транзакция с отдельным flush лога. Это замедляет импорт в десятки раз. 

Индексы: импорт в пустую таблицу

Если вы загружаете данные в новую пустую таблицу, создайте таблицу без вторичных индексов, загрузите данные, а затем добавьте индексы:

-- 1. Создаём таблицу только с PRIMARY KEY
CREATE TABLE import_data (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    data JSON,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 2. Загружаем данные (без overhead на индексы)
LOAD DATA INFILE '/var/lib/mysql-files/data.csv'
INTO TABLE import_data
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY 'n';

-- 3. Добавляем вторичные индексы
ALTER TABLE import_data ADD INDEX idx_data ((CAST(data AS CHAR(255))));

ALTER TABLE ... DISABLE KEYS работает только для MyISAM и не влияет на InnoDB. Для InnoDB нужно физически создавать и удалять индексы. 

LOAD DATA INFILE вместо INSERT

Самый быстрый способ загрузки данных в MySQL — LOAD DATA INFILE. Он читает файл bulk'ом, минует накладные расходы на парсинг отдельных INSERT-запросов и сетевые round-trips. На 20 млн строк: GUI-мастер MySQL Workbench — 4–8 часов, LOAD DATA INFILE — 5–15 минут. 

Если LOAD DATA INFILE недоступен (например, файл на клиенте, а не на сервере MySQL), используйте LOAD DATA LOCAL INFILE или многострочные INSERT:

INSERT INTO table_name (col1, col2) VALUES
    (1, 'a'), (2, 'b'), (3, 'c'), ...;

Оптимальный размер батча — 1000–10 000 строк на один INSERT.

Буферы и кэши (общие)

[mysqld]
; Размер временных таблиц в памяти. Если превышен — уходит на диск.
tmp_table_size = 256M
max_heap_table_size = 256M

; Кэш потоков — уменьшает overhead на создание новых потоков.
thread_cache_size = 16

; Размер буфера сортировки (per-thread). Осторожно:
; memory = sort_buffer_size * max_connections
sort_buffer_size = 4M

; Размер read-буфера для table scan (per-thread)
read_buffer_size = 2M
read_rnd_buffer_size = 4M

Осторожность с per-thread буферами: sort_buffer_size, read_buffer_size, read_rnd_buffer_size выделяются на каждый поток. Формула потребления: key_buffer + (sort_buffer_size + read_buffer_size + read_rnd_buffer_size) * max_connections. Не задирайте их без необходимости — лучше увеличьте конкретный буфер на уровне сессии (SET SESSION sort_buffer_size = 16M) для тяжёлого запроса.


Сводная таблица: что настраивать и зачем

Слой Параметр Назначение Типичное значение
flock -n Неблокирующая блокировка Флаг, без значения
flock lock-файл Файл-мьютекс /var/lock/import.lock
PHP CLI max_execution_time Лимит времени 0 (без лимита)
PHP CLI memory_limit Лимит памяти 2G (под задачу)
PHP CLI implicit_flush Вывод без буфера true
MySQL innodb_buffer_pool_size Кэш данных в RAM 50–80% RAM
MySQL innodb_log_file_size Размер redo-лога ~25% от buffer pool
MySQL innodb_log_buffer_size Буфер транзакций 128M
MySQL innodb_flush_log_at_trx_commit Flush на коммите 0 (временно), 1 (прод)
MySQL innodb_doublewrite Doublewrite buffer 0 (временно), 1 (прод)
MySQL foreign_key_checks Проверка FK 0 (временно), 1 (прод)
MySQL unique_checks Проверка уникальности 0 (временно), 1 (прод)
MySQL autocommit Автокоммит 0 на время импорта
MySQL max_allowed_packet Макс. размер пакета 256M (на время импорта)

Шаблон cron-задачи с полной защитой

Собирая всё вместе, типичная cron-строка для тяжёлого импорта выглядит так:

*/10 * * * * flock -n /var/lock/import.lock /usr/bin/php -d memory_limit=2G -d max_execution_time=0 /opt/import.php >> /var/log/import.log 2>&1

Что здесь происходит: flock -n не даст запуститься второй копии, если предыдущая ещё работает. PHP запускается в CLI-режиме с нужным лимитом памяти и без ограничения по времени. Вывод и ошибки пишутся в лог. Если процесс упадёт — блокировка снимется автоматически, и следующий тик cron запустит новый импорт без проблем.

Комментарии
Комментариев к записи нет. Вы можете стать первым!

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

Ваше имя
Ваш email
Защита от роботов