Быстродействие коллекций Laravel

Photo by Bruno Guerrero on Unsplash

Привет. Это небольшой пост-шпаргалка. В нём речь пойдёт о классах Illuminate\Support\{Collection, LazyCollection}.

Я обожаю коллекции Laravel. Они очень гибки и комфортны в использовании при обработке массивов данных. Однако это балует и расслабляет разработчика. Более того, вся философия Laravel и good practices вертятся вокруг гибкости и простоты написания кода. Всё это может плохо сказаться (и в итоге сказывается) на производительности бекенда.

Ниже рассмотрим несколько конкретных случаев, на которые следует обратить внимание.

Отбор данных

Например, в коллекции нужно найти один или несколько элементов по (не)конкретным значениям их полей. Обычно для этого используют метод where() или его вариации (firstWhere()whereIn() и т.п.).

Бенчмарки показывают, что все они в разы (и на порядки) медленнее, чем filter().

Методы семейства where() разработчик вынужден вызывать каждый раз на каждое условие. При чейнинге, каждая новая отфильтрованная коллекция целиком итерирутся сызнова, вызывая внутри рекурсивный data_get() для парсинга ключей с точкой.

Метод filter() итерирует коллекцию лишь однажды, проверяя результат аргумента-замыкания. Оно должно вернуть булев тип, т. о. при итерации элементов можно описать сразу несколько условий.

Пример бенчмарка
// допустим, $data->count() === 2651
Illuminate\Support\Benchmark::dd([
    // конкретные тайминги могут отличаться, но порядки те же
    // время указано для Collection / LazyCollection
 
    /* ~49.083ms / ~0.013ms */
    static fn () => $data
        ->where('myfield', 1),
     
    /* ~2.847ms / ~0.005ms */
    static fn () => $data
        ->filter(static fn ($row) => $row['myfield'] === 1),
     
    /* ~50.883ms / ~0.017ms */
    static fn () => $data
        ->where('myfield1', 1)
        ->where('myfield2', '!=', null),
     
    /* ~2.964ms / ~0.005ms */
    static fn () => $data
        ->filter(
            static fn ($row) => $row['myfield1'] === 1
                && $row['myfield2'] !== null
        ),
], 10);

Ассоциация (индексирование)

Пусть существует некоторая коллекция, содержимое коей нужно ассоциировать по какому-то полю каждого из элементов, например, по id. Для того, чтобы в коллекции массив оказался ассоциированным, существуют эти два метода.

Метод keyBy() принимает только имя поля, которое нужно взять из элемента массива и сделать его ключом для этого элемента в массиве.

Метод mapWithKeys() принимает замыкание, которое должно вернуть ассоциированный массив с готовым ключом и его значением.

На практике, скорость mapWithKeys() во много раз выше, чем keyBy()

Пример бенчмарка
// допустим, $data->count() === 2651
Illuminate\Support\Benchmark::dd([
    // конкретные тайминги могут отличаться, но порядки те же
    // время указано для Collection / LazyCollection
 
    /* ~20.359ms / ~0.006ms */
    static fn () => $data->keyBy('id'),
 
    /* ~3.740ms / ~0.005ms */
    static fn () => $data->mapWithKeys(
        static fn ($row) => [$row['id'] => $row]
    ),
], 10);

Однако метод keyBy() также принимает на вход и замыкание, которое должно вернуть только значение ключа:

$data->keyBy(static fn ($row) => $row['id']));

Сортировка

Ситуация совершенно аналогична описанным выше. Если передать имя ключа строкой, то это будет сильно медленнее, чем передать сразу замыкание. И ленивые коллекции, вполне ожидаемо, на порядки быстрее.

Пример бенчмарка
// допустим, $data->count() === 2651
Illuminate\Support\Benchmark::dd([
    // конкретные тайминги могут отличаться, но порядки те же
    // время указано для Collection / LazyCollection
 
    /* ~23.509ms / ~0.007ms */
    static fn() => $data->sortBy('id'),
 
    /* ~4.220ms / ~0.008ms */
    static fn() => $data->sortBy(
        static fn ($row) => $row['id']
    ),
], 10);

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

static fn ($row) => [$row['hid'], $row['id']]

Данные по ключу

Коллекции могут возвращать все значения какого-то конкретного поля вложенных элементов.

Проще всего вызвать $data->pluck('id'), чтобы получить все айдишники из коллекции. Но, как ты догадываешься, есть альтернатива:

$data->map(static fn ($row) => $row['id'])

— и это будет быстрее при идентичном результате. Замыкание должно вернуть значение того поля из элемента, массив которых нужно получить на выходе.

Приведение к массиву

Нередко бывает необходимость собрать список однотипных полей. Например, из всей коллекции объектов нам нужны только ID только активных записей:

$ids = $collection
    ->filter(static fn ($row) => $row->is_active)
    ->map(static fn ($row) => $row->id);

Мапа возвращает новую коллекцию, а нам нужен массив. Здесь у нас два варианта:

  • ->all() — вернёт всё содержимое коллекции как есть;
  • ->toArray() — тоже вернёт всё содержимое коллекции, но прежде — рекурсивно приведёт всё её содержимое к массивам, если есть нескалярные вложенные элементы.

Поскольку мы сформировали список интов, то нам не нужно бежать по нему ещё 1 раз, поэтому следует вызвать именно all().

Это та вещь, которая может сэкономить пару спичек.

Причины

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

И в итоге оказывается, что для коллекции отработать твоё замыкание тупо проще.

Выводы

  1. В те методы коллекций, которые могут принимать замыкания, лучше передавать именно замыкания. Даже если это кажется глупым и многословным.
  2. LazyCollection использует генераторы под капотом, поэтому для них п1 можно пренебречь (разница по быстродействию на уровне погрешности в наносекундах). Ленивые предпочтительнее обычных, но не имеют части функционала (например, сериализация).
  3. Заглядывай внутрь методов, которые ты используешь, и используй возможности отладки.

Оговорки

  1. В примерах выше я допустил, что внутри коллекции хранится список массивов, но там может быть и список объектов, и ресурсов и чего угодно ещё. Тебе лучше знать, что там хранится.
  2. Методы, которые рассмотрены здесь, не являются мутаторами. Однако сами классы Collection и LazyCollection не являются иммутабельными, ибо часть их методов всё же мутируют состояние объекта. Это важно помнить, когда, например, используешь методы transform(), add() или др.
  3. Да, обработка данных через генераторы быстрее, чем классическая обработка массивов. Но это не значит, что бекенд быстрее отдаст ответ клиенту.

    В общем случае, всеми переданными замыканиями (map(fn() => ..), keyBy(fn() => ...), …) мы лишь откладываем фактическое выполнение операций над данными на этап сериализации (json-изации) данных, буквально перед ответом. Использование LazyCollection даёт преимущество в том, что подготовка операций над данными происходит быстрее. Однако перед ответом все или часть этих операции уже непосредственно выполняются в замыканиях, а поскольку количество данных одинаково, существенное ускорение ответа бекенда не гарантируется.

Если что-нибудь ещё припомню — дополню пост. Пока всё.

Опубликовано
В рубрике blog Отмечено ,

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *