Ik ben een enorme fan van databases. Ik wilde zelfs mijn eigen DBMS maken toen ik op de universiteit zat. Nu werk ik zowel met RDBMS als NoSQL oplossingen, en daar ben ik erg enthousiast over. Weet je, er is geen Golden Hammer, elk probleem heeft zijn eigen oplossing. Of een subset van oplossingen.
In de serie blog posts The SQL I Love <3 loop ik met je door een aantal met SQL opgeloste problemen die ik bijzonder interessant vond. De oplossingen zijn getest met een tabel met meer dan 100 miljoen records. Alle voorbeelden maken gebruik van MySQL, maar de ideeën zijn ook toepasbaar op andere relationele data stores zoals PostgreSQL, Oracle en SQL Server.
Dit hoofdstuk is gericht op het efficiënt scannen van een grote tabel met behulp van paginering met offset
op de primaire sleutel. Dit is ook bekend als keyset pagination.
Achtergrond
In het hoofdstuk gebruiken we de volgende database structuur als voorbeeld. Het canonieke voorbeeld over gebruikers zou op elk domein moeten passen.
CREATE TABLE `users` ( `user_id` int(11) unsigned NOT NULL AUTO_INCREMENT, `external_id` varchar(32) NOT NULL, `name` varchar(100) COLLATE utf8_unicode_ci NOT NULL, `metadata` text COLLATE utf8_unicode_ci, `date_created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uf_uniq_external_id` (`external_id`), UNIQUE KEY `uf_uniq_name` (`name`), KEY `date_created` (`date_created`)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
Een paar opmerkingen over de structuur:
-
external_id
kolom slaat verwijzing op naar dezelfde gebruiker in ander systeem in UUID formaat -
name
vertegenwoordigtFirstname Lastname
-
metadata
kolom bevat JSON blob met allerlei ongestructureerde gegevens
De tabel is relatief groot en bevat ongeveer 100 000 000 records.
Scanning a Large Table
Probleem: je moet door de tabel lopen, elk record extraheren, transformeren in de code van je toepassing en invoegen op een andere plaats. We richten ons op de eerste fase in de post – het scannen van de tabel.
Voor de hand liggende en verkeerde oplossing
SELECT user_id, external_id, name, metadata, date_createdFROM users;
In mijn geval met 100 000 000 records, wordt de query nooit afgemaakt. Het DBMS stopt hem gewoon. Waarom? Waarschijnlijk, omdat het leidde tot de poging om de hele tabel in RAM te laden. Alvorens gegevens terug te sturen naar de cliënt. Een andere veronderstelling – het kostte te veel tijd om de gegevens te laden voor het verzenden en de query werd uitgetimed. Hoe dan ook, onze poging om alle records op tijd te krijgen mislukte. We moeten een andere oplossing vinden.
Oplossing #2
We kunnen proberen om de gegevens in pagina’s te krijgen. Omdat records niet gegarandeerd geordend zijn in een tabel op fysiek of logisch niveau – moeten we ze aan de DBMS-kant sorteren met ORDER BY
-clausule.
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 0, 10 000;10 000 rows in set (0.03 sec)
Zoet. Het werkte. We hebben de eerste pagina met 10.000 records opgevraagd, en het duurde slechts 0.03
sec om die terug te sturen. Maar hoe zou het werken voor de 5000e pagina?
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 50 000 000, 10 000; --- 5 000th page * 10 000 page size10 000 rows in set (40.81 sec)
Dit is inderdaad erg traag. Laten we eens kijken hoeveel tijd er nodig is om de gegevens voor de laatste pagina op te halen.
SELECT user_id, external_id, name, metadata, date_createdFROM usersORDER BY user_id ASCLIMIT 99 990 000, 10 000; --- 9999th page * 10 000 page size10 000 rows in set (1 min 20.61 sec)
Dit is krankzinnig. Het kan echter OK zijn voor oplossingen die op de achtergrond draaien. Nog een verborgen probleem met de aanpak kan worden onthuld als u probeert om een record te verwijderen uit de tabel in het midden van het scannen van het. Stel, je bent klaar met de 10e pagina (100 000 records zijn al bezocht), en je gaat de records tussen 100 001 en 110 000 scannen. Maar de records 99 998 en 99 999 worden verwijderd vóór de volgende SELECT
uitvoering. In dat geval geeft de volgende query het onverwachte resultaat:
SELECT user_id, external_id, name, metadata, date_created FROM users ORDER BY user_id ASC LIMIT 100 000, 10 000; N, id, ... 1, 100 003, ... 2, 100 004, ...
Zoals u kunt zien, heeft de query de records met de id’s 100 001 en 100 002 overgeslagen. Zij worden met deze aanpak niet door de applicatiecode verwerkt omdat zij na de twee verwijderbewerkingen in de eerste 100 000 records voorkomen. Daarom is de methode onbetrouwbaar als de dataset muteerbaar is.
Oplossing #3 – de laatste voor vandaag
De aanpak lijkt erg op de vorige omdat het nog steeds paging gebruikt, maar nu in plaats van te vertrouwen op het aantal gescande records, gebruiken we de user_id
van het laatst bezochte record als de offset
.
Simplified algorithm:
- We krijgen
PAGE_SIZE
aantal records uit de tabel. Start offset waarde is 0. - Gebruik de max geretourneerde waarde voor
user_id
in de batch als de offset voor de volgende pagina. - Gebruik de volgende batch van de records die
user_id
waarde hoger dan huidigeoffset
hebben.
De query in actie voor de 5000e pagina, elke pagina bevat gegevens over 10 000 gebruikers:
SELECT user_id, external_id, name, metadata, date_createdFROM usersWHERE user_id > 51 234 123 --- value of user_id for 50 000 000th recordORDER BY user_id ASCLIMIT 10 000;10 000 rows in set (0.03 sec)
Merk op, dat de waarden van user_id
niet opeenvolgend zijn en hiaten kunnen vertonen, zoals 25 348 vlak na 25 345 is. De oplossing werkt ook als records van toekomstige pagina’s worden verwijderd – zelfs in dat geval slaat de query geen records over. Sweet, right?
Explaining performance
Om verder te leren, raad ik aan de resultaten van EXPLAIN EXTENDED
te onderzoeken voor elke versie van de query om de volgende 10 000 records na 50 000 000 te krijgen.
Solution | Time | Type | Keys | Rows | Filtered | Extra |
---|---|---|---|---|---|---|
1. Obvious | Never | ALL | NULL | 100M | 100.00 | NULL |
2. Paginering met aantal records als offset | 40.81 sec | index | NULL / PRIMARY | 50M | 200.00 | NULL |
3. Keyset paginering met user_id als offset | 0.03 sec | range | PRIMARY / PRIMARY | 50M | 100.00 | Gebruik makend van where |
Laten we ons concentreren op het belangrijkste verschil tussen uitvoeringsplannen voor 2e en 3e oplossingen, aangezien de 1e niet praktisch bruikbaar is voor grote tabellen.
- Join type:
index
vsrange
. De eerste betekent dat de hele indexboom wordt gescand om de records te vinden.range
type vertelt ons dat de index alleen wordt gebruikt om overeenkomende rijen binnen een gespecificeerd bereik te vinden. Dus,range
type is sneller danindex
. - Mogelijke sleutels:
NULL
vsPRIMARY
. De kolom toont de sleutels die kunnen worden gebruikt door MySQL. BTW, kijken naar sleutels kolom, kunnen we zien dat uiteindelijkPRIMARY
sleutel wordt gebruikt voor de beide queries. - Rijen:
50 010 000
vs50 000 000
. De waarde geeft het aantal geanalyseerde records weer alvorens het resultaat terug te geven. Voor de 2e query, de waarde hangt af van hoe diep is onze scroll. Bijvoorbeeld, als we proberen om de volgende10 000
records te krijgen na de 9999e pagina dan worden99 990 000
records onderzocht. Daarentegen heeft de 3e query een constante waarde; het maakt niet uit of we gegevens laden voor de 1e pagina of de allerlaatste. Het is altijd de halve grootte van de tabel. - Gefilterd:
200.00
vs100.00
. De kolom geeft het geschatte percentage van de tabel aan dat moet worden gefilterd vóór verwerking. Een hogere waarde is beter. De waarde van100.00
betekent dat de query de hele tabel doorzoekt. Voor de tweede query is de waarde niet constant en afhankelijk van het paginanummer: als we naar de eerste pagina vragen, zou de waarde van de gefilterde kolom1000000.00
zijn. Voor de allerlaatste pagina, zou het zijn100.00
. - Extra:
NULL
vsUsing where
. Geeft extra informatie over hoe MySQL de query omzet. Het gebruik vanWHERE
opPRIMARY
sleutel maakt de uitvoering van de query sneller.
Ik vermoed dat join type de parameter van de query is die de grootste bijdrage heeft geleverd aan de prestaties om de 3e query sneller te maken. Een ander belangrijk ding is dat de 2e query is zeer afhankelijk van het aantal van de pagina’s te scrollen. Meer diepe paginering is langzamer in dat geval.
Meer richtlijnen over het begrijpen van de uitvoer voor EXPLAIN
commando kan worden gevonden in de officiële documentatie voor uw RDBMS.
Samenvatting
Het hoofdonderwerp voor de blog post was gerelateerd aan het scannen van een grote tabel met 100 000 000 records met behulp van offset
met een primaire sleutel (keyset paginering). In totaal werden 3 verschillende benaderingen bekeken en getest op de overeenkomstige dataset. Ik raad slechts een van hen aan als je een mutabele grote tabel moet scannen.
Ook hebben we het gebruik van EXPLAIN EXTENDED
commando herzien om het uitvoeringsplan van MySQL queries te analyseren. Ik ben er zeker van dat andere RDBMS hebben analogen voor de functionaliteit.
In het volgende hoofdstuk, zullen we aandacht besteden aan data aggregatie en opslag optimalisatie. Blijf op de hoogte!
Wat is uw methode om grote tabellen te scannen?
Weet u nog een ander doel van het gebruik van keyset paginering zoals in oplossing #3?