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 запустит новый импорт без проблем.
Комментарии
Написать автору