PHP 7.4 / Laravel 8 / PostgreSQL 13
Проблема: импорт 50 тысяч строк занимал около 45 секунд.
Первое подозрение: база данных, INSERT, UPSERT, индексы, размер batch и количество запросов.
Реальность: бОльшая часть времени уходит на валидацию.
Мы начали разбираться, почему проверка нескольких десятков обычных полей на большом массиве данных оказалась значительно дороже самой записи в PostgreSQL. В итоге удалось сократить время валидации с 42,5 секунды до 0,65 секунды, а длительность всего job — примерно с 45 до 4 секунд.
В статье покажу, что именно оказалось узким местом, почему стандартный Laravel Validator оказался не самым подходящим инструментом для этого конкретного сценария и почему мы не стали от него отказываться.
Контекст: большой импорт
Так сложилось, что наша система регулярно получает json‑ами большие наборы данных из 1С и обрабатывает их очередями. Это и справочники, и регистры, и документы, и другие всевозможные сущности. Это не пользовательские формы, где человек отправляет несколько десятков полей, в нашем случае за один импорт могут прийти от нескольких штук до десятков тысяч записей.
Для обработки данные разбиваются на чанки. В рассматриваемом сценарии один chunk содержал примерно 2500 строк и 26 колонок. Перед сохранением данных в базу, мы валидируем полученную информацию и задаем обычные правила валидации полей Laravel:
$rules = [ 'guid' => 'required|string|max:36', 'name' => 'required|string|max:255', 'price' => 'nullable|numeric', 'quantity' => 'required|integer', // и т.д. ];
Затем данные записываются в базу через upsert. С точки зрения архитектуры не было ничего подозрительного, пока мы не посмотрели на время выполнения каждого этапа в отдельности.

Метрики
Весь job занимал около 45 секунд. Разложили его на основные этапы:
Этап | Продолжительность |
Валидация | 42,5 с |
Upsert | ~2 с |
Остальное | ~0,5 с |
Итого | ~45 с |
Для нас получилась довольно удивительная картина. Около 95% времени job уходило не на базу, а на валидацию. Поэтому чтобы эффект оптимизации становится заметен на уровне всего процесса дальше смотрели в сторону ускорения валидации хотя бы в несколько раз.
Проблема
Для batch validation использовались wildcard rules. Условно:
$batchRules = [ 'items' => 'required|array', ]; foreach ($rules as $field => $rule) { $batchRules['items.*.' . $field] = $rule; } $validator = Validator::make( ['items' => $itemsList], $batchRules );
С точки зрения разработчика здесь всего несколько десятков правил.
Например:
items.*.guid
items.*.name
items.*.price
...
Но это означает, что правило применяется к каждому элементу массива.
Для одного поля items..guid получаем набор конкретных атрибутов:
items.0.guid
items.1.guid
items.2.guid
...
items.2499.guid
Получается один chunk превращается примерно в 65 000 комбинаций (2500 строк × 26 полей = 65 000). А это уже совсем другой объем работы, чем те 26 правил, которые мы видим в исходном коде. Но ведь правила у нас простые, почему проверка 65 000 значений занимает так много времени?
Laravel Validator — это не набор из нескольких условий. Он сделан универсальным и помимо непосредственно проверки значения, Validator работает с: вложенными именами атрибутов, правилами с подстановкой (wildcard rules), различными типами правил, обязательными и неявными правилами, структурой ошибок, атрибутами, сообщениями об ошибках, локализацией, сложными и пользовательскими правилами.
Для обычного HTTP‑запроса этот подход именно то, что нужно — удобный API и полноценная инфраструктура валидации. Но в нашем случае источником данных был не пользовательский запрос, а контролируемый импорт. И вместо десятков полей одной формы мы обрабатывали десятки тысяч значений. Получилась интересная ситуация: преимущества универсального механизма превратились в накладные расходы.
Проверили гипотезу benchmark'ом
Для проверки гипотезы сделали отдельный benchmark. Условия максимально приблизили к реальному импорту: PHP 7.4, Laravel 8, 26 полей, те же типы правил, размер chunk 2500 строк. Для чистоты эксперимента делали 5 прогонов для каждого сценария по количеству строк (1000, 5000, 10000, 25000, 50000), в итоговую таблицу брали медиану.
Сравнивали три варианта валидации:
Laravel Validator с wildcard rules;
Laravel Validator отдельно для каждой строки;
специализированный fast path.
Разброс между отдельными прогонами оказался небольшим, поэтому медиана хорошо подходит для сравнения. Результат получился следующий.
Время валидации
Строки (шт.) | Wildcard Laravel (с.) | Laravel по строке (с.) | Fast path © |
1 000 | 12,7 | 0,46 | 0,1 |
5 000 | 243 | 2,27 | 0,47 |
10 000 | 514 | 4,54 | 0,96 |
25 000 | 1346 | 11,4 | 2,4 |
50 000 | >1800, timeout | 23 | 4,82 |
Последняя строка особенно показательна: на 50 тысячах строк wildcard‑вариант не завершился за установленный таймаут в 30 минут.
Добавочная память относительно самого набора данных.
Строки (шт.) | Wildcard Laravel (Mb) | Laravel по строке (Mb) | Fast path (Mb) |
1 000 | +24 | 0 | 0 |
5000–25000 | +64…68 | +2…4 | 0…+2 |
Сам PHP‑процесс на 1000 строк при этом занимал примерно 56 MB, на 50 000 — 214 MB.
На небольших объемах разница может показаться несущественной, но с ростом количества строк становится хорошо видно, как ведут себя разные подходы. Fast path и построчный Validator::make() масштабируются практически линейно. На 25 000 строк fast path примерно в 4,7 раза быстрее Validator::make(). Здесь кроется причина, почему мы не выбрали этот подход.
С wildcard rules ситуация совсем другая для 25 000 строк 1346 с (~22 мин). Также здесь важно учитывать размер для chunk — 2500 строк. Каждый следующий chunk запускает тяжёлый validation pipeline заново, поэтому при массовом импорте его стоимость быстро становится критичной.
При этом проблема проявляется не только во времени, но и в памяти. Сам набор данных занимает большую часть памяти процесса: примерно 52 MB на 1000 строк и 210 MB на 50000. Но wildcard rules добавляют ещё около 64–68 MB на один chunk. Для Laravel Validator по одной строке и fast path эта добавка составляет всего до 2–4 MB.
При увеличении количества чанков дополнительная память wildcard rules практически не растет, поскольку одновременно обрабатывается только один chunk. Но каждый chunk снова требует построения и обработки большого количества validation‑структур.
В итоге картина достаточно наглядная: fast path масштабируется практически линейно и почти не добавляет памяти, а wildcard с ростом объема данных становится всё дороже из‑за повторного запуска тяжёлого механизма валидации для каждого chunk.
Что нам действительно было нужно
Если посмотреть на правила конкретного импорта, большая часть из них была очень простой: required, nullable, string, integer, numeric, boolean, date, max, min, in.
Например: «required|string|max:36».
Смысл этой проверки достаточно прямолинеен: обязательное наличие значения строки не длиннее 36 символов.
Для такого сценария нам не нужен весь универсальный validation pipeline. Нам нужен результат: валидно или невалидно в таком‑то поле. Это послужило отправной точкой для решения.
Мы не стали делать свой Laravel Validator, который умеет всё. Мы оставили стандартный Validator для сложных правил и добавили быстрый путь для ограниченного набора простых правил через специализированный сервис:

Шаг первый — разобрать правила заранее
Правила приходят строками:
[ 'guid' => 'required|string|max:36', 'name' => 'required|string|max:255', ]
Вместо того чтобы повторно разбирать их в горячем цикле, мы предварительно превращаем их в массив:
public function parseRules(array $rules): array { $parsedRules = []; foreach ($rules as $field => $ruleString) { $parsedRules[$field] = explode('|', $ruleString); } return $parsedRules; }
После этого правила можно использовать повторно для всех строк. При 50 000 записей это уже не тот случай, когда повторяется одна и та же подготовительная работа.
Шаг второй — идти непосредственно по данным
Вместо wildcard‑пути «items.1847.guid» мы работаем непосредственно с текущей строкой $item['guid']. Получается простой цикл:
foreach ($items as $item) { foreach ($parsedRules as $field => $ruleParts) { // проверка значения по правилам следующего шага } }
То есть вместо универсального обхода вложенной структуры появляется прямой путь и benchmark показывает, что такой подход действительно масштабируется практически линейно.
Шаг третий — простые проверки выполняет PHP
Для поддерживаемого набора правил достаточно обычных функций PHP.
Пример для целочисленного и строчного типа данных:
if ($rulePart === 'integer') { if (filter_var($value, FILTER_VALIDATE_INT) === false) { return "Поле {$field} должно быть целым числом."; } continue; } if ($rule_part === 'string') { if (!is_string($value) && !is_numeric($value)) { return "Поле {$field} должно быть строкой."; } continue; }
Здесь нет попытки повторить внутреннее устройство Laravel. Мы просто выполняем те проверки, которые действительно нужны этому импорту.
Что показал этот кейс
Наша задача не состояла в том, чтобы реализовать полную совместимость с Laravel Validation API. Fast path реализует ограниченный контракт конкретного импортного сценария. Поэтому прежде чем использовать такой подход в другом проекте, необходимо определить:
Какие именно правила поддерживаются?
Как интерпретируются входные данные?
Должны ли результаты полностью совпадать с Laravel?
Если требуется полная идентичность Laravel Validator, нужно использовать Laravel Validator.
Если требуется быстро проверить ограниченный набор хорошо известных правил на большом массиве данных, специализированное решение может оказаться оправданным.
Для меня здесь есть несколько выводов.
1. Не всегда стоит искать узкое горлышко там, где оно кажется очевидным
Импорт данных почти автоматически заставляет думать о базе. Но измерения показали, что PostgreSQL в данном случае не был проблемой.
2. Универсальная абстракция имеет стоимость
Laravel Validator удобен именно потому, что умеет очень много. Но при массовой обработке цена этой универсальности становится заметной.
3. Wildcard — не бесплатная синтаксическая конструкция
items.*.field выглядит компактно в коде, но за этой записью скрывается работа с каждым элементом массива. При 2500 строках и 26 полях это уже около 65 тысяч комбинаций.
4. Не обязательно выбирать между «Laravel» и «полностью своим решением»
Можно использовать оба подхода: для простых правила Fast path, для сложных Laravel Validator.
5. Оптимизация должна начинаться с измерений
Если бы мы сразу начали переписывать upsert, мы бы оптимизировали участок, который занимал около двух секунд, в то время как реальная проблема занимала 42,5 секунды.
Самым полезным результатом этой оптимизации для меня стала даже не цифра ускорения, а то что мы нашли реальное узкое горлышко там, где не ожидали. Для команды это стало хорошим примером практической оптимизации кода: сначала измерить, потом понять стоимость абстракции, затем оптимизировать только тот участок, который действительно ограничивает систему. А после оптимизации — снова измерить и понять куда переехало узкое горлышко на этот раз и, вохможно, принять еще какие‑то решения.

