Qual è il "problema delle selezioni N+1" in ORM (Object-Relational Mapping)?
-
01-07-2019 - |
Domanda
Il "problema delle selezioni N+1" viene generalmente indicato come un problema nelle discussioni sulla mappatura relazionale degli oggetti (ORM) e capisco che ha qualcosa a che fare con la necessità di effettuare molte query sul database per qualcosa che sembra semplice nell'oggetto mondo.
Qualcuno ha una spiegazione più dettagliata del problema?
Soluzione
Diciamo che hai una collezione di Car
oggetti (righe del database) e ciascuno Car
ha una collezione di Wheel
oggetti (anche righe).In altre parole, Car
-> Wheel
è una relazione 1-a-molti.
Ora, diciamo che devi scorrere tutte le auto e, per ciascuna, stampare un elenco delle ruote.L'implementazione ingenua dell'O/R farebbe quanto segue:
SELECT * FROM Cars;
Poi per ciascuno Car
:
SELECT * FROM Wheel WHERE CarId = ?
In altre parole, hai una selezione per le auto, e poi N selezioni aggiuntive, dove N è il numero totale di auto.
In alternativa, è possibile ottenere tutte le ruote ed eseguire le ricerche in memoria:
SELECT * FROM Wheel
Ciò riduce il numero di viaggi di andata e ritorno al database da N+1 a 2.La maggior parte degli strumenti ORM offre diversi modi per impedire le selezioni N+1.
Riferimento: Persistenza Java con ibernazione, capitolo 13.
Altri suggerimenti
SELECT
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId
Ciò ti dà un set di risultati in cui le righe secondarie in table2 causano la duplicazione restituendo i risultati table1 per ogni riga secondaria in table2.I mappatori O/R devono differenziare le istanze di table1 in base a un campo chiave univoco, quindi utilizzare tutte le colonne di table2 per popolare le istanze figlio.
SELECT table1.*
SELECT table2.* WHERE SomeFkId = #
N+1 è il punto in cui la prima query popola l'oggetto primario e la seconda query popola tutti gli oggetti secondari per ciascuno degli oggetti primari univoci restituiti.
Prendere in considerazione:
class House
{
int Id { get; set; }
string Address { get; set; }
Person[] Inhabitants { get; set; }
}
class Person
{
string Name { get; set; }
int HouseId { get; set; }
}
e tabelle con struttura simile.Una singola query per l'indirizzo "22 Valley St" può restituire:
Id Address Name HouseId
1 22 Valley St Dave 1
1 22 Valley St John 1
1 22 Valley St Mike 1
L'O/RM dovrebbe riempire un'istanza di Home con ID=1, Indirizzo="22 Valley St" e quindi popolare l'array Inhabitants con istanze People per Dave, John e Mike con una sola query.
Una query N+1 per lo stesso indirizzo utilizzato sopra comporterebbe:
Id Address
1 22 Valley St
con una query separata come
SELECT * FROM Person WHERE HouseId = 1
e risultando in un set di dati separato come
Name HouseId
Dave 1
John 1
Mike 1
e il risultato finale è lo stesso di sopra con la singola query.
Il vantaggio della selezione singola è che ottieni tutti i dati in anticipo, il che potrebbe essere ciò che alla fine desideri.Il vantaggio di N+1 è che la complessità delle query è ridotta e puoi utilizzare il caricamento lento in cui i set di risultati secondari vengono caricati solo alla prima richiesta.
Fornitore con una relazione uno-a-molti con il Prodotto.Un Fornitore ha (fornisce) molti Prodotti.
***** Table: Supplier *****
+-----+-------------------+
| ID | NAME |
+-----+-------------------+
| 1 | Supplier Name 1 |
| 2 | Supplier Name 2 |
| 3 | Supplier Name 3 |
| 4 | Supplier Name 4 |
+-----+-------------------+
***** Table: Product *****
+-----+-----------+--------------------+-------+------------+
| ID | NAME | DESCRIPTION | PRICE | SUPPLIERID |
+-----+-----------+--------------------+-------+------------+
|1 | Product 1 | Name for Product 1 | 2.0 | 1 |
|2 | Product 2 | Name for Product 2 | 22.0 | 1 |
|3 | Product 3 | Name for Product 3 | 30.0 | 2 |
|4 | Product 4 | Name for Product 4 | 7.0 | 3 |
+-----+-----------+--------------------+-------+------------+
Fattori:
Modalità lazy per il fornitore impostata su "true" (impostazione predefinita)
La modalità di recupero utilizzata per eseguire query sul prodotto è Seleziona
Modalità di recupero (predefinita):È possibile accedere alle informazioni sul fornitore
La memorizzazione nella cache non svolge un ruolo per la prima volta
Si accede al fornitore
La modalità di recupero è Seleziona Recupero (impostazione predefinita)
// It takes Select fetch mode as a default
Query query = session.createQuery( "from Product p");
List list = query.list();
// Supplier is being accessed
displayProductsListWithSupplierName(results);
select ... various field names ... from PRODUCT
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
Risultato:
- 1 dichiarazione di selezione per Prodotto
- N selezionare dichiarazioni per Fornitore
Questo è il problema di selezione N+1!
Non posso commentare direttamente altre risposte, perché non ho abbastanza reputazione.Ma vale la pena notare che il problema si pone essenzialmente solo perché, storicamente, molti dbm sono stati piuttosto scadenti quando si tratta di gestire i join (MySQL è un esempio particolarmente degno di nota).Quindi n+1 è stato spesso notevolmente più veloce di un join.E poi ci sono modi per migliorare n+1 ma comunque senza bisogno di un join, che è ciò a cui si riferisce il problema originale.
Tuttavia, MySQL ora è molto migliore di prima per quanto riguarda i join.Quando ho imparato MySQL per la prima volta, ho utilizzato molto i join.Poi ho scoperto quanto sono lenti e sono passato invece a n+1 nel codice.Ma recentemente sono tornato ai join, perché MySQL ora è molto più bravo a gestirli rispetto a quando ho iniziato a usarlo.
Al giorno d'oggi, un semplice join su un insieme di tabelle indicizzate correttamente rappresenta raramente un problema, in termini di prestazioni.E se dà un calo delle prestazioni, l'uso dei suggerimenti sull'indice spesso li risolve.
Questo è discusso qui da uno dei team di sviluppo MySQL:
http://jorgenloland.blogspot.co.uk/2013/02/dbt-3-q3-6-x-performance-in-mysql-5610.html
Quindi il riassunto è:Se in passato hai evitato i join a causa delle pessime prestazioni di MySQL, riprova con le versioni più recenti.Probabilmente rimarrai piacevolmente sorpreso.
Ci siamo allontanati dall'ORM di Django a causa di questo problema.Fondamentalmente, se provi a farlo
for p in person:
print p.car.colour
L'ORM restituirà volentieri tutte le persone (tipicamente come istanze di un oggetto Persona), ma poi dovrà interrogare la tabella dell'auto per ogni Persona.
Un approccio semplice e molto efficace a questo è quello che io chiamo "piegatura a ventaglio", che evita l'idea insensata che i risultati della query da un database relazionale debbano corrispondere alle tabelle originali da cui è composta la query.
Passo 1:Ampia scelta
select * from people_car_colour; # this is a view or sql function
Questo restituirà qualcosa di simile
p.id | p.name | p.telno | car.id | car.type | car.colour
-----+--------+---------+--------+----------+-----------
2 | jones | 2145 | 77 | ford | red
2 | jones | 2145 | 1012 | toyota | blue
16 | ashby | 124 | 99 | bmw | yellow
Passo 2:Oggettivare
Risucchia i risultati in un creatore di oggetti generici con un argomento da dividere dopo il terzo elemento.Ciò significa che l'oggetto "jones" non verrà creato più di una volta.
Passaggio 3:Render
for p in people:
print p.car.colour # no more car queries
Vedere questa pagina web per un'implementazione di piegatura a ventaglio per pitone.
Supponiamo di avere AZIENDA e DIPENDENTE.L'AZIENDA ha molti DIPENDENTI (es.DIPENDENTE ha un campo COMPANY_ID).
In alcune configurazioni O/R, quando si ha un oggetto Azienda mappato e si accede ai suoi oggetti Dipendente, lo strumento O/R eseguirà una selezione per ogni dipendente, mentre se si stesse semplicemente facendo le cose in SQL diretto, si potrebbero select * from employees where company_id = XX
.Quindi N (n. di dipendenti) più 1 (azienda)
Ecco come funzionavano le versioni iniziali di EJB Entity Beans.Credo che cose come Hibernate abbiano eliminato questo, ma non ne sono troppo sicuro.La maggior parte degli strumenti solitamente include informazioni sulla strategia di mappatura adottata.
Ecco una buona descrizione del problema: https://web.archive.org/web/20160310145416/http://www.realsolve.co.uk/site/tech/hib-tip-pitfall.php?name=why-lazy
Ora che hai compreso il problema, in genere è possibile evitarlo eseguendo un recupero del join nella query.Ciò forza sostanzialmente il recupero dell'oggetto caricato in modo lento in modo che i dati vengano recuperati in una query anziché in n+1 query.Spero che questo ti aiuti.
Secondo me l'articolo scritto in Insidia dell'ibernazione:Perché le relazioni dovrebbero essere pigre è esattamente l'opposto del vero problema N+1.
Se hai bisogno di una spiegazione corretta, fai riferimento Ibernazione - Capitolo 19:Miglioramento delle prestazioni: strategie di recupero
Seleziona il recupero (il valore predefinito) è estremamente vulnerabile a N+1 seleziona i problemi, quindi potremmo voler abilitare il recupero
Controlla il post di Ayende sull'argomento: Combattere il problema della selezione N + 1 in NHibernate
Fondamentalmente, quando si utilizza un ORM come NHibernate o EntityFramework, se si dispone di una relazione uno-a-molti (master-dettaglio) e si desidera elencare tutti i dettagli per ciascun record principale, è necessario effettuare N + 1 chiamate di query al database, dove "N" è il numero di record anagrafici:1 query per ottenere tutti i record anagrafici e N query, una per record anagrafico, per ottenere tutti i dettagli per record anagrafico.
Più chiamate di query al database --> più tempo di latenza --> prestazioni dell'applicazione/database ridotte.
Tuttavia, gli ORM hanno opzioni per evitare questo problema, principalmente utilizzando i "join".
Il problema delle query N+1 si verifica quando ci si dimentica di recuperare un'associazione e quindi è necessario accedervi:
List<PostComment> comments = entityManager.createQuery(
"select pc " +
"from PostComment pc " +
"where pc.review = :review", PostComment.class)
.setParameter("review", review)
.getResultList();
LOGGER.info("Loaded {} comments", comments.size());
for(PostComment comment : comments) {
LOGGER.info("The post title is '{}'", comment.getPost().getTitle());
}
Che genera le seguenti istruzioni SQL:
SELECT pc.id AS id1_1_, pc.post_id AS post_id3_1_, pc.review AS review2_1_
FROM post_comment pc
WHERE pc.review = 'Excellent!'
INFO - Loaded 3 comments
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 1
INFO - The post title is 'Post nr. 1'
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 2
INFO - The post title is 'Post nr. 2'
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 3
INFO - The post title is 'Post nr. 3'
Innanzitutto, Hibernate esegue la query JPQL e un elenco di PostComment
le entità vengono recuperate.
Quindi, per ciascuno PostComment
, gli associati post
viene utilizzata per generare un messaggio di registro contenente il file Post
titolo.
Perché il post
l'associazione non è inizializzata, Hibernate deve recuperare il file Post
entità con una query secondaria e per n PostComment
entità, verranno eseguite altre N query (da qui il problema delle query N+1).
Innanzitutto, hai bisogno registrazione e monitoraggio SQL adeguati in modo che tu possa individuare questo problema.
In secondo luogo, questo tipo di problema è meglio essere individuato dai test di integrazione.Puoi usare un asserzione JUnit automatica per convalidare il conteggio previsto delle istruzioni SQL generate.IL progetto db-unit fornisce già questa funzionalità ed è open source.
Una volta identificato il problema relativo alla query N+1, è necessario utilizzare JOIN FETCH in modo che le associazioni secondarie vengano recuperate in una query, anziché in N.Se è necessario recuperare più associazioni secondarie, è preferibile recuperare una raccolta nella query iniziale e la seconda con una query SQL secondaria.
Il collegamento fornito presenta un esempio molto semplice del problema n + 1.Se lo applichi a Hibernate, sostanzialmente si parla della stessa cosa.Quando esegui una query per un oggetto, l'entità viene caricata ma qualsiasi associazione (a meno che non sia configurata diversamente) verrà caricata in modo lento.Quindi una query per gli oggetti radice e un'altra query per caricare le associazioni per ciascuno di questi.100 oggetti restituiti indicano una query iniziale e quindi 100 query aggiuntive per ottenere l'associazione per ciascuno, n + 1.
Un milionario possiede N automobili.Vuoi ottenere tutte (4) le ruote.
Una (1) query carica tutte le auto, ma per ciascuna (N) auto viene inviata una query separata per il caricamento delle ruote.
Costi:
Supponiamo che gli indici si adattino alla ram.
1 + N analisi e pianificazione delle query + ricerca dell'indice E 1 + N + (N * 4) accesso alla piastra per il caricamento del carico utile.
Supponiamo che gli indici non si adattino a ram.
Costi aggiuntivi nel caso peggiore 1 + N accessi piastra per indice di carico.
Riepilogo
Il collo della bottiglia è l'accesso alla piastra (ca.70 volte al secondo accesso casuale su HDD) Una selezione di join ansiosa accederebbe anche alla piastra 1 + N + (n * 4) per il payload.Quindi, se gli indici si adattano alla ram, nessun problema, è abbastanza veloce perché sono coinvolte solo le operazioni ram.
È molto più veloce emettere 1 query che restituisce 100 risultati piuttosto che emettere 100 query che restituiscono ciascuna 1 risultato.
Il problema della selezione N+1 è una seccatura ed è opportuno rilevare tali casi nei test unitari.Ho sviluppato una piccola libreria per verificare il numero di query eseguite da un determinato metodo di test o semplicemente da un blocco di codice arbitrario - Sniffer JDBC
Aggiungi semplicemente una regola JUnit speciale alla tua classe di test e inserisci un'annotazione con il numero previsto di query sui tuoi metodi di test:
@Rule
public final QueryCounter queryCounter = new QueryCounter();
@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
// your JDBC or JPA code
}
Il problema, come altri hanno affermato in modo più elegante, è che hai un prodotto cartesiano delle colonne OneToMany o stai eseguendo selezioni N+1.O possibile risultato gigantesco o chiacchierato con il database, rispettivamente.
Sono sorpreso che questo non sia menzionato, ma è così che ho risolto questo problema... Creo una tabella ID semi-temporanea. Lo faccio anche quando hai il IN ()
clausola limitativa.
Questo non funziona per tutti i casi (probabilmente nemmeno per la maggioranza) ma funziona particolarmente bene se si hanno molti oggetti figli tali che il prodotto cartesiano sfuggirà di mano (cioè molti OneToMany
colonne, il numero di risultati sarà una moltiplicazione delle colonne) ed è più un lavoro batch.
Per prima cosa inserisci gli ID degli oggetti principali come batch in una tabella ID.Questo batch_id è qualcosa che generiamo nella nostra app e che manteniamo.
INSERT INTO temp_ids
(product_id, batch_id)
(SELECT p.product_id, ?
FROM product p ORDER BY p.product_id
LIMIT ? OFFSET ?);
Ora per ciascuno OneToMany
colonna fai semplicemente a SELECT
sulla tabella ID INNER JOIN
ing la tabella figlio con a WHERE batch_id=
(o vice versa).Vuoi solo assicurarti di ordinare in base alla colonna id in quanto renderà più semplice l'unione delle colonne dei risultati (altrimenti avrai bisogno di una HashMap/Table per l'intero set di risultati che potrebbe non essere poi così male).
Quindi pulisci periodicamente la tabella degli ID.
Funziona particolarmente bene anche se l'utente seleziona circa 100 elementi distinti per una sorta di elaborazione in blocco.Inserisci i 100 ID distinti nella tabella temporanea.
Ora il numero di query che stai eseguendo dipende dal numero di colonne OneToMany.
Prendi l'esempio di Matt Solnit, immagina di definire un'associazione tra Car e Wheels come LAZY e di aver bisogno di alcuni campi Wheels.Ciò significa che dopo la prima selezione, l'ibernazione eseguirà "Seleziona * da Wheels dove car_id =:id" PER OGNI macchina.
Questo rende la prima selezione e le successive 1 selezione per ciascuna N auto, ecco perché si chiama problema n+1.
Per evitare ciò, rendere l'associazione recuperata come desiderosa, in modo che l'ibernazione carichi i dati con un join.
Ma attenzione, se molte volte non accedi alle Ruote associate, è meglio mantenerlo PIGRO o cambiare tipo di recupero con Criteria.