Оптимизация Laravel-приложения

Хочу поделиться небольшим кейсом оптимизации Laravel-приложения. Этот кейс служит хорошей иллюстрацией алгоритма оптимизации в целом, в процессе пришлось столкнуться с типичными проблемами, плюс он содержит несколько решений, относящихся именно к Laravel.

Исходно имеем микросервис авторизации, владеющий информацией о правах пользователей, реализованный на Laravel. Правами пользователи наделяются через роли (RBAC), система прав выглядит следующим образом: — трёхуровневое дерево; — на верхнем уровне — сервисы; — посередине — страницы; — на нижнем уровне — элементы страниц.

Для страниц и элементов страниц есть набор действий, которые с ними можно осуществлять. В тот момент времени, когда было решено заняться оптимизацией, сервис тратил на ответ о правах пользователя в рамках сервиса ~140 мс, что позволяло выдерживать только стандартную нагрузку и совсем не позволяло пиковую. Для удержания пиковой нагрузки без апгрейда сервера необходимо было снизить время ответа хотя бы до 50 мс. Целевым показателем было выбрано 20 мс, чтобы иметь запас по производительности.

Выполненные шаги по оптимизации

  1. Анализ запросов к БД (отказ от Eloquent и доменной модели прав). Поскольку данные о правах представлены иерархической структурой, но хранятся в реляционной БД (PostgreSQL), то первое предположение было о том, что основные «тормоза» происходят на этапе работы с БД. Анализ показал, что Eloquent выполнял O(N) запросов, где N – количество сервисов. Для получения набора прав было решено отказаться от Eloquent и доменной модели прав. Запросы переписаны на «сырой» SQL, в результате их количество сокращено до 3 (по одному для каждого уровня), плюс проводится дополнительная обработка результатов в коде. В результате время подготовки ответа удалось сократить до ~100 мс.
  2. Денормализация данных. Принято решение денормализовать данные и хранить в БД уже подготовленный ответ с полным набором прав пользователя по всем сервисам уже в JSON-формате, что позволит не тратить время на обработку и сократит время выборки. Как результат, удалось сократить ещё 2 запроса к БД и время подготовки ответа стало ~70 мс.
  3. Проблема обновления данных. После денормализации было установлено, что существует юзеркейс, в котором пользователям несколько раз меняется набор прав, после чего они тут же запрашиваются. Денормализация привела к тому, что время обновления прав существенно возросло, и в этом кейсе время подготовки ответа пользователю вернулось почти к исходному показателю (стало ~130 мс). Кейс не был очень существенным, однако подвёл к мысли, что решение пока неоптимально. В качестве дополнительной оптимизации изменили алгоритм денормализации и стали хранить данные по правам на каждый сервис в отдельном поле и отдавать информацию о правах на каждый сервис по отдельности (бизнес-логику это не нарушило, т. к. каждому сервису интересны права только про его страницы и элементы). Это позволило обновлять данные частично и немного уменьшило трафик, в итоге время подготовки типичного ответа стало ~60 мс, а в рассмотренном юзеркейсе ~90 мс.
  4. Выносим данные в кэш. Денормализованные данные стало возможно вынести в кэш, т. к. теперь обновление прав затрагивало конкретные кэшированные данные, которые можно было легко инвалидировать. Это позволило сократить время подготовки типичного ответа до ~50 мс. Пиковую нагрузку уже стало можно выдерживать, но без какого-либо запаса прочности.
  5. Оптимизируем работу IoC-контейнера (отказ от автоматического DI). Дальнейшая оптимизация со стороны источников данных уже не могла дать значительного прироста производительности, поэтому начали оптимизировать уже работу кода. В процессе анализа выяснилось, что автоматический подбор классов, реализующих требуемые интерфейсы в IoC-контейнере, достаточно медленный. Было решено отказаться от этого механизма и подсказывать контейнеру явно конкретные классы без привязки к интерфейсам. Это позволило сократить время до ~45 мс.
  6. Делаем часть работы IoC-контейнера вручную. Следующим шагом стал полный отказ от использования контейнера для инстанцирования отдельных сущностей, которые использовались для подготовки ответа с набором прав пользователя. Т. е. в провайдере служб эти сущности собирались вручную с lazy-инициализацией. Смогли выиграть ещё немного и получили результат ~37 мс.
  7. Оптимизация роутинга. Сервис поддерживал порядка 60 различных запросов, что приводило к довольно медленному роутингу. Часть запросов была удалена (в них возвращалась частично информация о правах, теперь стал использоваться полный вариант), часть объединена с переходом на дополнительные параметры в теле запроса. В результате удалось сократить количество запросов до ~20. Эта оптимизация плюс включение кэширования в провайдере роутинга позволило сократить время до ~30 мс (включение кэширования без оптимизации давало результат ~33 мс).

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