Najlepsze podejście do wydajności podczas filtrowania uprawnień w Laravel

9

Pracuję nad aplikacją, w której użytkownik może mieć dostęp do wielu formularzy poprzez wiele różnych scenariuszy. Usiłuję zbudować to podejście z najlepszą wydajnością, zwracając użytkownikowi indeks formularzy.

Użytkownik może mieć dostęp do formularzy za pomocą następujących scenariuszy:

  • Forma właściciela
  • Zespół jest właścicielem formularza
  • Ma uprawnienia do grupy, która jest właścicielem formularza
  • Ma uprawnienia do zespołu, który jest właścicielem formularza
  • Ma pozwolenie na formularz

Jak widać, istnieje 5 możliwych sposobów dostępu do formularza przez użytkownika. Mój problem polega na tym, jak najbardziej wydajnie zwrócić użytkownikowi tablicę dostępnych formularzy.

Zasady dotyczące formularza:

Próbowałem pobrać wszystkie formularze z modelu, a następnie filtrować formularze według zasad formularzy. Wydaje się, że jest to problem z wydajnością, ponieważ przy każdej iteracji filtra formularz jest przepuszczany przez elokwentną metodę zawiera () 5 razy, jak pokazano poniżej. Im więcej formularzy w bazie danych, tym wolniej.

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);
}

Chociaż powyższa metoda działa, jest to szyjka butelki wydajności.

Z tego, co widzę, są następujące moje opcje:

  • Filtr FormPolicy (obecne podejście)
  • Zapytaj o wszystkie uprawnienia (5) i połącz w jedną kolekcję
  • Zapytanie wszystkie identyfikatory dla wszystkich uprawnień (5), a następnie kwerendy Wzór formularza przy użyciu identyfikatorów w IN () oświadczenie

Moje pytanie:

Która metoda zapewni najlepszą wydajność i czy jest jakaś inna opcja, która zapewni lepszą wydajność?

Tim
źródło
możesz również zastosować podejście Wiele do wielu, aby połączyć się z użytkownikiem, jeśli użytkownik ma dostęp do formularza
kod pieniędzy
Co powiesz na utworzenie tabeli specjalnie do zapytań o uprawnienia do formularza użytkownika? user_form_permissionTabela zawierająca tylko user_idi form_id. Sprawi to, że uprawnienia do odczytu będą proste, jednak uprawnienia do aktualizacji będą trudniejsze.
PtrTon,
Problem z tabelą user_form_permissions polega na tym, że chcemy rozszerzyć uprawnienia na inne podmioty, które wymagałyby osobnej tabeli dla każdej jednostki.
Tim
1
@Tim, ale to nadal 5 zapytań. Jeśli jest to tylko w obszarze chronionego członka, może to nie stanowić problemu. Ale jeśli jest to publiczny adres URL, który może odbierać wiele żądań na sekundę, wydaje mi się, że chcesz to nieco zoptymalizować. Ze względu na wydajność utrzymywałbym osobną tabelę (którą mogę buforować) za każdym razem, gdy formularz lub członek zespołu jest dodawany lub usuwany za pośrednictwem obserwatorów modeli. Następnie na każde żądanie otrzymywałem to z pamięci podręcznej. Uważam to pytanie i problem za bardzo interesujące i chciałbym wiedzieć, co myślą inni. To pytanie zasługuje na więcej głosów i odpowiedzi, rozpoczął nagrodę :)
Raul
1
Możesz rozważyć widok zmaterializowany, który możesz odświeżyć jako zaplanowane zadanie. W ten sposób zawsze możesz szybko uzyskać stosunkowo aktualne wyniki.
apokryfos

Odpowiedzi:

2

Chciałbym wykonać zapytanie SQL, ponieważ będzie ono działać znacznie lepiej niż php

Coś takiego:

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();

Z góry mojej głowy i niesprawdzone to powinno dostarczyć ci wszystkich formularzy, które są własnością użytkownika, jego grup i zespołów.

Nie uwzględnia jednak uprawnień formularzy przeglądania użytkowników w grupach i zespołach.

Nie jestem pewien, jak skonfigurowałeś do tego autoryzację, więc musisz zmodyfikować zapytanie o to i jakąkolwiek różnicę w strukturze DB.

Josh
źródło
Dziękuję za odpowiedź. Problemem nie było jednak pytanie, w jaki sposób uzyskać dane z bazy danych. Problem polega na tym, jak uzyskać go efektywnie za każdym razem, na każde żądanie, gdy aplikacja ma setki tysięcy formularzy i wiele zespołów i członków. Wasze dołączenia zawierają ORklauzule, które, jak podejrzewam, będą wolne. Wierzę, że trafienie w to na każde żądanie będzie szalone.
Raul
Możesz być w stanie uzyskać większą szybkość dzięki surowemu zapytaniu MySQL lub użyciu czegoś takiego jak widoki lub procedury, ale będziesz musiał wykonywać takie połączenia za każdym razem, gdy chcesz danych. Pomocne może być również buforowanie wyników.
Josh
Chociaż myślę, że jedynym sposobem na sprawienie, by ten wykonawca jest buforowanie, wiąże się to z kosztem utrzymania tej mapy przy każdej zmianie. Wyobraź sobie, że tworzę nowy formularz, który, jeśli zespół zostanie przypisany do mojego konta, oznacza, że ​​tysiące użytkowników może uzyskać do niego dostęp. Co dalej? Ponownie buforować kilka tysięcy zasad członków?
Raul
Istnieją rozwiązania pamięci podręcznej na całe życie (takie jak abstrakcje pamięci podręcznej Lararela), a także można usunąć zmienione indeksy pamięci podręcznej zaraz po wprowadzeniu jakichkolwiek zmian. Pamięć podręczna zmienia rzeczywistą grę, jeśli używasz jej poprawnie. Sposób konfiguracji pamięci podręcznej zależy od odczytów i aktualizacji danych.
Gonzalo
2

Krótka odpowiedź

Trzecia opcja: 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();

Długa odpowiedź

Z jednej strony (prawie) wszystko, co można zrobić w kodzie, jest lepsze pod względem wydajności niż w przypadku zapytań.

Z drugiej strony, pobieranie większej ilości danych z bazy danych, niż to konieczne, byłoby już zbyt dużą ilością danych (użycie pamięci RAM i tak dalej).

Z mojej perspektywy potrzebujesz czegoś pomiędzy, a tylko będziesz wiedział, gdzie będzie równowaga, w zależności od liczb.

Sugerowałbym uruchomienie kilku zapytań, ostatnia zaproponowana opcja ( Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement):

  1. Zapytaj o wszystkie identyfikatory dla wszystkich uprawnień (5 zapytań)
  2. Scal wszystkie wyniki formularzy w pamięci i uzyskaj unikalne wartości array_unique($ids)
  3. Zapytaj o model Form, używając identyfikatorów w instrukcji IN ().

Możesz wypróbować trzy zaproponowane opcje i monitorować wydajność, używając narzędzia do wielokrotnego uruchamiania zapytania, ale jestem 99% pewien, że ostatnia zapewni najlepszą wydajność.

To również może się wiele zmienić, w zależności od używanej bazy danych, ale jeśli mówimy na przykład o MySQL; W bardzo dużym zapytaniu zużyłoby więcej zasobów bazy danych, co nie tylko poświęci więcej czasu niż proste zapytania, ale także zablokuje tabelę przed zapisem, co może powodować błędy zakleszczenia (chyba że użyjesz serwera podrzędnego).

Z drugiej strony, jeśli liczba identyfikatorów formularzy jest bardzo duża, możesz mieć błędy dla zbyt wielu symboli zastępczych, więc możesz chcieć podzielić zapytania na grupy, powiedzmy, 500 identyfikatorów (zależy to bardzo od limitu ma rozmiar, a nie liczbę powiązań) i scal wyniki w pamięci. Nawet jeśli nie dostaniesz błędu bazy danych, możesz również zauważyć dużą różnicę w wydajności (wciąż mówię o MySQL).


Realizacja

Zakładam, że jest to schemat bazy danych:

users
  - id
  - team_id

forms
  - id
  - user_id
  - team_id
  - group_id

permissible
  - user_id
  - permissible_id
  - permissible_type

Tak więc dopuszczalna byłaby już skonfigurowana relacja polimorficzna .

Dlatego relacje byłyby następujące:

  • Forma właściciela: users.id <-> form.user_id
  • Zespół jest właścicielem formularza: users.team_id <-> form.team_id
  • Ma uprawnienia do grupy, która jest właścicielem formularza: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
  • Ma uprawnienia do zespołu, który jest właścicielem formularza: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
  • Ma pozwolenie na formularz: permissible.user_id <-> users.id && permissible.permissible_type = 'App\From'

Uprość wersję:

$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();

Wersja szczegółowa:

// 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();

Wykorzystane zasoby:

Wydajność bazy danych:

  • Zapytania do bazy danych (z wyłączeniem użytkownika): 2 ; jeden, aby uzyskać dopuszczalny, a drugi, aby uzyskać formularze.
  • Brak dołączeń !!
  • Możliwe minimalne OR ( user_id = ? OR id IN (?..) OR team_id IN (?...) OR group_id IN (?...).

PHP, w pamięci, wydajność:

  • przewidzieć pętlę dopuszczalną z przełącznikiem w środku.
  • array_values(array_unique()) aby uniknąć powtarzania identyfikatorów.
  • W pamięci, 3 tablice identyfikatorów ( $teamIds, $groupIds, $formIds)
  • W pamięci odpowiednie elokwentne zbieranie uprawnień (w razie potrzeby można to zoptymalizować).

Plusy i minusy

Plusy:

  • Czas : Suma czasów pojedynczych zapytań jest krótsza niż czas dużego zapytania z połączeniami i OR.
  • Zasoby DB : zasoby MySQL używane przez zapytanie z łączeniem i / lub instrukcjami są większe niż zużyte przez sumę jego oddzielnych zapytań.
  • Pieniądze : mniej zasobów bazy danych (procesor, pamięć RAM, odczyt dysku itp.), Które są droższe niż zasoby PHP.
  • Blokady : W przypadku, gdy nie wysyłasz zapytania do serwera podrzędnego tylko do odczytu, twoje zapytania spowodują mniej blokad odczytu blokad (blokada odczytu jest wspólna w MySQL, więc nie zablokuje kolejnego odczytu, ale zablokuje jakikolwiek zapis).
  • Skalowalne : to podejście pozwala na większą optymalizację wydajności, na przykład na częściowe zapytania.

CONS:

  • Zasoby kodu : Wykonywanie obliczeń w kodzie, a nie w bazie danych, z pewnością zużyje więcej zasobów w instancji kodu, ale szczególnie w pamięci RAM, przechowując środkową informację. W naszym przypadku byłby to tylko szereg identyfikatorów, co nie powinno stanowić problemu.
  • Konserwacja : Jeśli użyjesz właściwości i metod Laravela i dokonasz jakichkolwiek zmian w bazie danych, łatwiej będzie zaktualizować kod niż w przypadku bardziej wyraźnych zapytań i przetwarzania.
  • Overkilling? : W niektórych przypadkach, jeśli dane nie są tak duże, optymalizacja wydajności może być nadmierna.

Jak mierzyć wydajność

Kilka wskazówek na temat pomiaru wydajności?

  1. Wolne dzienniki zapytań
  2. TABELA ANALIZOWA
  3. POKAŻ STATUS TABELI JAK
  4. WYJAŚNIĆ ; Rozszerzony format wyjściowy EXPLAIN ; używając wyjaśnienia ; wyjaśnić wyjście
  5. POKAŻ OSTRZEŻENIA

Kilka interesujących narzędzi profilujących:

Gonzalo
źródło
Co to za pierwsza linia? Używanie zapytania jest prawie zawsze lepsze pod względem wydajności, ponieważ uruchamianie różnych pętli lub manipulowanie tablicą w PHP jest wolniejsze.
Płomień
Jeśli masz małą bazę danych lub komputer z bazą danych jest znacznie potężniejszy niż instancja kodu lub opóźnienie bazy danych jest bardzo złe, to tak, MySQL jest szybszy, ale zwykle tak nie jest.
Gonzalo
Podczas optymalizacji zapytania do bazy danych należy wziąć pod uwagę czas wykonania, liczbę zwróconych wierszy, a co najważniejsze, liczbę zbadanych wierszy. Jeśli Tim mówi, że zapytania stają się wolne, to zakładam, że dane rosną, a zatem liczba badanych wierszy. Poza tym baza danych nie jest zoptymalizowana pod kątem przetwarzania, tak jak język programowania.
Gonzalo
Ale nie musisz mi ufać, możesz uruchomić EXPLAIN dla swojego rozwiązania, następnie możesz uruchomić go dla mojego rozwiązania prostych zapytań i zobaczyć różnicę, a następnie pomyśleć, czy prosty array_merge()i array_unique()zbiór identyfikatorów naprawdę spowalniają twój proces.
Gonzalo
W 9 na 10 przypadków baza danych mysql działa na tym samym komputerze, na którym działa kod. Warstwa danych ma służyć do wyszukiwania danych i jest zoptymalizowana do wybierania fragmentów danych z dużych zestawów. Nie widziałem jeszcze sytuacji, w której a array_unique()jest szybsze niż instrukcja GROUP BY/ SELECT DISTINCT.
Płomień
0

Dlaczego nie możesz po prostu zapytać o Formularze, których potrzebujesz, zamiast robić, Form::all()a następnie tworzyć łańcuchyfilter() funkcję po niej?

Tak jak:

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

Tak, to robi kilka zapytań:

  • Zapytanie dla $user
  • Jeden dla $user->team
  • Jeden dla $user->team->forms
  • Jeden dla $user->permissible
  • Jeden dla $user->permissible->groups
  • Jeden dla $user->permissible->groups->forms

Jednak zaletą jest to, że nie musisz już korzystać z zasad , ponieważ wiesz, że wszystkie formularze w $formsparametrze są dozwolone dla użytkownika.

To rozwiązanie będzie działać dla dowolnej liczby formularzy w bazie danych.

Uwaga dotycząca używania merge()

merge()scala kolekcje i odrzuci zduplikowane identyfikatory formularzy, które już znalazł. Jeśli więc z jakiegoś powodu formularz z teamrelacji jest również bezpośrednią relacją z user, wyświetli się tylko raz w scalonej kolekcji.

To dlatego, że w rzeczywistości jest to Illuminate\Database\Eloquent\Collection, które ma swoją własną merge()funkcję, która sprawdza modelowych identyfikatorów wymowny. Tak więc nie możesz użyć tej sztuczki podczas łączenia 2 różnych treści kolekcji, takich jak Postsi Users, ponieważ użytkownik o identyfikatorze 3i post o identyfikatorze 3będą w tym przypadku sprzeczne, a tylko ta ostatnia (post) zostanie znaleziona w scalonej kolekcji.


Jeśli chcesz, aby był jeszcze szybszy, powinieneś utworzyć niestandardowe zapytanie przy użyciu fasady DB, coś w stylu:

// 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();

Twoje aktualne zapytanie jest znacznie większe, ponieważ masz tak wiele relacji.

Główna poprawa wydajności wynika tutaj z faktu, że ciężka praca (podzapytanie) całkowicie omija logikę modelu Eloquent. Następnie wystarczy przekazać listę identyfikatorów do whereInfunkcji, aby pobrać listę Formobiektów.

Płomień
źródło
0

Wierzę, że możesz do tego użyć Lazy Kolekcje (Laravel 6.x) i chętnie załadować relacje, zanim zostaną one udostępnione.

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
źródło