Лучший подход к производительности при фильтрации разрешений в Laravel

9

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

Пользователь может иметь доступ к формам по следующим сценариям:

  • Форма собственности
  • Команда владеет формой
  • Имеет разрешения для группы, которая владеет формой
  • Имеет разрешения для команды, которая владеет формой
  • Имеет разрешение на форму

Как вы можете видеть, существует 5 возможных способов доступа пользователя к форме. Моя проблема заключается в том, как наиболее эффективно вернуть пользователю массив доступных форм.

Политика формы:

Я попытался получить все формы из модели, а затем отфильтровать формы по политике формы. Похоже, что это проблема производительности, так как на каждой итерации фильтра форма передается через eloquent метод 5 (5), как показано ниже. Чем больше форм в базе данных, тем медленнее становится.

FormController@index

public function index(Request $request)
{
   $forms = Form::all()
      ->filter(function($form) use ($request) {
         return $request->user()->can('view',$form);
   });
}
FormPolicy@view

public function view(User $user, Form $form)
{
   return $user->forms->contains($form) ||
      $user->team->forms->contains($form) ||
      $user->permissible->groups->forms($contains);
}

Хотя вышеописанный метод работает, это - горлышко бутылки производительности.

Из того, что я вижу, мои следующие варианты:

  • Фильтр FormPolicy (текущий подход)
  • Запросить все разрешения (5) и объединить в одну коллекцию
  • Запрос все идентификаторы для всех разрешений (5), а затем запросить модель формы с использованием идентификаторов в IN () заявление

Мой вопрос:

Какой метод обеспечит лучшую производительность, и есть ли другой вариант, который обеспечит лучшую производительность?

Тим
источник
Вы также можете использовать подход « многие ко многим» для ссылки, если пользователь может получить доступ к форме
код для денег
А как насчет создания таблицы специально для запроса прав пользователей? user_form_permissionТаблица , содержащая только user_idи form_id. Это сделает разрешение на чтение быстрым, однако обновление разрешений будет сложнее.
PtrTon
Проблема с таблицей user_form_permissions заключается в том, что мы хотим расширить разрешения для других объектов, которые затем потребуют отдельную таблицу для каждого объекта.
Тим
1
@Tim, но это все еще 5 запросов. Если это только внутри зоны защищенного члена, это может не быть проблемой. Но если он находится на общедоступном URL-адресе, который может получать много запросов в секунду, я считаю, что вы захотите немного его оптимизировать. По соображениям производительности, я бы поддерживал отдельную таблицу (которую я могу кэшировать) каждый раз, когда форма или член команды добавляется или удаляется через наблюдателей модели. Затем при каждом запросе я получал это из кеша. Я нахожу этот вопрос и проблему очень интересными и хотел бы знать, что думают и другие. Этот вопрос заслуживает большего количества голосов и ответов, начатый за вознаграждение :)
Рауль
1
Вы можете рассмотреть материализованное представление, которое вы можете обновить как запланированное задание. Таким образом, вы всегда можете быстро получить относительно свежие результаты.
Апокрыфос

Ответы:

2

Я хотел бы сделать SQL-запрос, поскольку он будет работать намного лучше, чем PHP

Что-то вроде этого:

User::where('id', $request->user()->id)
    ->join('group_users', 'user.id', 'group_users.user_id')
    ->join('team_users', 'user.id', 'team_users.user_id',)
    ->join('form_owners as user_form_owners', function ($join) {
        $join->on('users.id', 'form_owners.owner_id')
            ->where('form_owners.owner_type', User::class);
    })
    ->join('form_owners as group_form_owners', function ($join) {
        $join->on('group_users.group_id', 'form_owners.owner_id')
            ->where('form_owners.owner_type', Group::class);
    })
    ->join('form_owners as team_form_owners', function ($join) {
        $join->on('team_users.team_id', 'form_owners.owner_id')
           ->where('form_owners.owner_type', Team::class);
    })
    ->join('forms', function($join) {
        $join->on('forms.id', 'user_form_owners.form_id')
            ->orOn('forms.id', 'group_form_owners.form_id')
            ->orOn('forms.id', 'team_form_owners.form_id');
    })
    ->selectRaw('forms.*')
    ->get();

Из головы и непроверенных это должно получить все формы, которые принадлежат пользователю, его группам и этим командам.

Однако он не рассматривает разрешения пользовательских форм просмотра в группах и командах.

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

мистифицировать
источник
Спасибо за ответ. Однако проблема заключалась не в том, как получить данные из базы данных. Проблема в том, как получить его эффективно каждый раз, при каждом запросе, когда в приложении сотни тысяч форм и множество команд и участников. У ваших объединений есть ORпункты, которые, я подозреваю, будут медленными. Так что удар по этому запросу будет безумным, я верю.
Рауль
Возможно, вам удастся добиться большей скорости с помощью необработанного MySQL-запроса или с помощью чего-то вроде представлений или процедур, но вам придется выполнять такие вызовы каждый раз, когда вам нужны данные. Кэширование результатов также может помочь здесь.
Джош
Хотя я думаю, что единственный способ сделать этот исполнитель - кэширование, это происходит за счет постоянного поддержания этой карты каждый раз, когда вносятся изменения. Представьте, что я создаю новую форму, которая, если команда назначена моей учетной записи, означает, что тысячи пользователей могут получить к ней доступ. Что дальше? Перепишите несколько тысяч членов политики?
Рауль
Существуют решения для кеша со временем жизни (например, абстракции кеша laravel), и вы также можете удалить затронутые индексы кеша сразу после внесения любых изменений. Кэш действительно изменит правила игры, если вы используете его правильно. Как настроить кеш зависит от чтения и обновления данных.
Гонсало
2

Короткий ответ

Третий вариант: Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement

$teamMorphType  = Relation::getMorphedModel('team');
$groupMorphType = Relation::getMorphedModel('group');
$formMorphType  = Relation::getMorphedModel('form');

$permissible = [
    $teamMorphType  => [$user->team_id],
    $groupMorphType => [],
    $formMorphType  => [],
];

foreach ($user->permissible as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
        case $groupMorphType:
        case $formMorphType:
            $permissible[$permissible->permissible_type][] = $permissible->permissible_id;
            break;
    }
}

$forms = Form::query()
             ->where('user_id', '=', $user->id)
             ->orWhereIn('id', $permissible[$fromMorphType])
             ->orWhereIn('team_id', $permissible[$teamMorphType])
             ->orWhereIn('group_id', $permissible[$groupMorphType])
             ->get();

Длинный ответ

С одной стороны, (почти) все, что вы можете сделать в коде, лучше с точки зрения производительности, чем в запросах.

С другой стороны, получение большего количества данных из базы данных, чем необходимо, уже было бы слишком большим объемом данных (использование ОЗУ и т. Д.).

С моей точки зрения, вам нужно что-то промежуточное, и только вы будете знать, где будет баланс, в зависимости от чисел.

Я бы предложил выполнить несколько запросов, последний вариант, который вы предложили ( Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement):

  1. Запрос всех идентификаторов для всех разрешений (5 запросов)
  2. Объединить все результаты форм в памяти и получить уникальные значения array_unique($ids)
  3. Запросите модель Form, используя идентификаторы в выражении IN ().

Вы можете попробовать три предложенных вами варианта и отслеживать производительность, используя какой-либо инструмент для многократного выполнения запроса, но я на 99% уверен, что последний из них даст вам наилучшую производительность.

Это также может сильно измениться, в зависимости от того, какую базу данных вы используете, но если мы говорим, например, о MySQL; В очень большом запросе будет использоваться больше ресурсов базы данных, который не только будет тратить больше времени, чем простые запросы, но также будет блокировать таблицу от записи, и это может привести к ошибкам взаимоблокировки (если вы не используете подчиненный сервер).

С другой стороны, если количество идентификаторов форм очень велико, вы можете иметь ошибки для слишком большого количества заполнителей, поэтому вы можете разделить запросы на группы, скажем, 500 идентификаторов (это зависит от предела по размеру, а не по количеству привязок) и объединить результаты в памяти. Даже если вы не получите ошибку базы данных, вы также можете увидеть большую разницу в производительности (я все еще говорю о MySQL).


Реализация

Я предполагаю, что это схема базы данных:

users
  - id
  - team_id

forms
  - id
  - user_id
  - team_id
  - group_id

permissible
  - user_id
  - permissible_id
  - permissible_type

Так допустимо было бы уже настроенные полиморфные отношения .

Следовательно, отношения будут:

  • Форма собственности: users.id <-> form.user_id
  • Команда владеет формой: users.team_id <-> form.team_id
  • Имеет разрешения для группы, которая владеет формой: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
  • Имеет разрешения для команды, которая владеет формой: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
  • Имеет разрешение на Форму: permissible.user_id <-> users.id && permissible.permissible_type = 'App\From'

Упрощенная версия:

$teamMorphType  = Relation::getMorphedModel('team');
$groupMorphType = Relation::getMorphedModel('group');
$formMorphType  = Relation::getMorphedModel('form');

$permissible = [
    $teamMorphType  => [$user->team_id],
    $groupMorphType => [],
    $formMorphType  => [],
];

foreach ($user->permissible as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
        case $groupMorphType:
        case $formMorphType:
            $permissible[$permissible->permissible_type][] = $permissible->permissible_id;
            break;
    }
}

$forms = Form::query()
             ->where('user_id', '=', $user->id)
             ->orWhereIn('id', $permissible[$fromMorphType])
             ->orWhereIn('team_id', $permissible[$teamMorphType])
             ->orWhereIn('group_id', $permissible[$groupMorphType])
             ->get();

Подробная версия:

// Owns Form
// users.id <-> forms.user_id
$userId = $user->id;

// Team owns Form
// users.team_id <-> forms.team_id
// Initialise the array with a first value.
// The permissions polymorphic relationship will have other teams ids to look at
$teamIds = [$user->team_id];

// Groups owns Form was not mention, so I assume there is not such a relation in user.
// Just initialise the array without a first value.
$groupIds = [];

// Also initialise forms for permissions:
$formIds = [];

// Has permissions to a group that owns a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
$teamMorphType = Relation::getMorphedModel('team');
// Has permissions to a team that owns a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
$groupMorphType = Relation::getMorphedModel('group');
// Has permission to a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Form'
$formMorphType = Relation::getMorphedModel('form');

// Get permissions
$permissibles = $user->permissible()->whereIn(
    'permissible_type',
    [$teamMorphType, $groupMorphType, $formMorphType]
)->get();

// If you don't have more permissible types other than those, then you can just:
// $permissibles = $user->permissible;

// Group the ids per type
foreach ($permissibles as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
            $teamIds[] = $permissible->permissible_id;
            break;
        case $groupMorphType:
            $groupIds[] = $permissible->permissible_id;
            break;
        case $formMorphType:
            $formIds[] = $permissible->permissible_id;
            break;
    }
}

// In case the user and the team ids are repeated:
$teamIds = array_values(array_unique($teamIds));
// We assume that the rest of the values will not be repeated.

$forms = Form::query()
             ->where('user_id', '=', $userId)
             ->orWhereIn('id', $formIds)
             ->orWhereIn('team_id', $teamIds)
             ->orWhereIn('group_id', $groupIds)
             ->get();

Использованные ресурсы:

Производительность базы данных:

  • Запросы к базе данных (исключая пользователя): 2 ; один, чтобы получить допустимое, и другой, чтобы получить формы.
  • Нет присоединений!
  • Минимальное возможное ИЛИ ( user_id = ? OR id IN (?..) OR team_id IN (?...) OR group_id IN (?...).

PHP, в памяти, производительность:

  • цикл foreach допускается с помощью переключателя внутри.
  • array_values(array_unique()) чтобы не повторять идентификаторы.
  • В памяти, 3 массивы идентификаторов ( $teamIds, $groupIds, $formIds)
  • В памяти соответствующих разрешений красноречивый сбор (это можно оптимизировать при необходимости).

Плюсы и минусы

ПЛЮСЫ:

  • Время : сумма раз одиночных запросов меньше времени большого запроса с объединениями и ИЛИ.
  • Ресурсы БД. Ресурсы MySQL, используемые запросом с операторами join или or, больше, чем сумма, используемая отдельными запросами.
  • Деньги : меньше ресурсов базы данных (процессор, оперативная память, чтение дисков и т. Д.), Которые стоят дороже ресурсов PHP.
  • Блокировки . Если вы не запрашиваете подчиненный сервер, доступный только для чтения, ваши запросы будут делать меньше блокировок чтения строк (блокировка чтения используется в MySQL, поэтому она не блокирует другое чтение, но блокирует любую запись).
  • Масштабируемость . Этот подход позволяет оптимизировать производительность, например, разделять запросы на части.

МИНУСЫ:

  • Ресурсы кода : выполнение вычислений в коде, а не в базе данных, очевидно, потребляет больше ресурсов в экземпляре кода, но особенно в оперативной памяти, хранящей промежуточную информацию. В нашем случае это будет просто массив идентификаторов, что не должно быть проблемой на самом деле.
  • Обслуживание : если вы используете свойства и методы Laravel и вносите какие-либо изменения в базу данных, обновление кода будет проще, чем если вы будете делать более явные запросы и обработку.
  • Overkilling? В некоторых случаях, если данные не так велики, оптимизация производительности может оказаться излишней.

Как измерить производительность

Некоторые подсказки о том, как измерить производительность?

  1. Медленные журналы запросов
  2. АНАЛИЗ ТАБЛИЦА
  3. ПОКАЗАТЬ СТАТУС, КАК
  4. ОБЪЯСНИТЬ ; Расширенный EXPLAIN Формат вывода ; используя объяснение ; объяснить вывод
  5. ПОКАЗАТЬ ПРЕДУПРЕЖДЕНИЯ

Некоторые интересные инструменты профилирования:

Гонсало
источник
Что это за первая строка? Использование запроса почти всегда лучше с точки зрения производительности, поскольку выполнение различных циклов или манипулирование массивами в PHP выполняется медленнее.
Пламя
Если у вас небольшая база данных или ваш компьютер базы данных намного мощнее, чем ваш экземпляр кода, или задержка базы данных очень плохая, тогда да, MySQL быстрее, но обычно это не так.
Гонсало
При оптимизации запроса к базе данных необходимо учитывать время выполнения, количество возвращаемых строк и, что наиболее важно, количество проверенных строк. Если Тим говорит, что запросы становятся медленными, то я предполагаю, что данные растут, и, следовательно, количество проверенных строк. Кроме того, база данных не оптимизирована для обработки, как язык программирования.
Гонсало
Но вам не нужно доверять мне, вы можете запустить EXPLAIN для своего решения, затем вы можете запустить его для моего решения простых запросов и увидеть разницу, а затем подумать, будет ли простой array_merge()и array_unique()куча идентификаторов действительно замедлить ваш процесс.
Гонсало
В 9 из 10 случаев база данных mysql работает на той же машине, на которой выполняется код. Слой данных предназначен для использования в поиске данных и оптимизирован для выбора фрагментов данных из больших наборов. Я еще не видел ситуацию, когда a array_unique()быстрее, чем GROUP BY/ SELECT DISTINCTоператор.
Пламя
0

Почему вы не можете просто запросить нужные вам формы, вместо того, чтобы выполнять Form::all()и затем связывать filter()функцию после нее?

Вот так:

public function index() {
    $forms = $user->forms->merge($user->team->forms)->merge($user->permissible->groups->forms);
}

Так что да, это делает несколько запросов:

  • Запрос для $user
  • Один для $user->team
  • Один для $user->team->forms
  • Один для $user->permissible
  • Один для $user->permissible->groups
  • Один для $user->permissible->groups->forms

Однако плюсом является то, что вам больше не нужно использовать политику , поскольку вы знаете, что все формы в $formsпараметре разрешены для пользователя.

Таким образом, это решение будет работать для любого количества форм в базе данных.

Примечание по использованию merge()

merge()объединяет коллекции и удаляет дубликаты идентификаторов форм, которые он уже нашел. Таким образом, если по какой-либо причине форма из teamотношения также является прямым отношением к user, она будет отображаться в объединенной коллекции только один раз.

Это потому, что на самом деле он Illuminate\Database\Eloquent\Collectionимеет свою собственную merge()функцию, которая проверяет идентификаторы модели Eloquent. Таким образом, вы не можете использовать этот трюк при объединении двух разных коллекций, таких как Postsи Users, потому что пользователь с идентификатором 3и публикацией с идентификатором 3будет конфликтовать в этом случае, и только последняя (публикация) будет найдена в объединенной коллекции.


Если вы хотите, чтобы это было еще быстрее, вы должны создать собственный запрос, используя фасад БД, что-то вроде:

// Select forms based on a subquery that returns a list of id's.
$forms = Form::whereIn(
    'id',
    DB::select('id')->from('users')->where('users.id', $user->id)
        ->join('teams', 'users.id', '=', 'teams.user_id')
        ...
)->get();

Ваш реальный запрос намного больше, так как у вас так много отношений.

Основное улучшение производительности здесь связано с тем, что тяжелая работа (подзапрос) полностью обходит логику модели Eloquent. Затем все, что осталось сделать, это передать список идентификаторов в whereInфункцию для получения списка Formобъектов.

пламя
источник
0

Я полагаю, что вы можете использовать Lazy Collections для этого (Laravel 6.x) и стремиться загрузить отношения до того, как они будут доступны.

public function index(Request $request)
{
   // Eager Load relationships
   $request->user()->load(['forms', 'team.forms', 'permissible.group']);
   // Use cursor instead of all to return a LazyCollection instance
   $forms = Form::cursor()->filter(function($form) use ($request) {
         return $request->user()->can('view', $form);
   });
}
public function view(User $user, Form $form)
{
   return $user->forms->contains($form) ||
      $user->team->forms->contains($form) ||
      // $user->permissible->groups->forms($contains); // Assuming this line is a typo
      $user->permissible->groups->contains($form);
}
IGP
источник