Dezelfde Taak, Twee Engines: De Internals van InnoDB en Iceberg

OLTP

De drie garanties van InnoDB

  1. Rijen slaan alle kolommen samen op

Het ophalen van één staaf (bar) voor één symbool raakt één specifieke plek op de schijf, wat precies is wat nodig is bij data-ingest. Een indicator die slechts twee kolommen nodig heeft, haalt via de buffer pool nog steeds elke kolom op.

  1. Indexen versnellen reads en vertragen writes

Elke index is een eigen B-tree en elke write moet alle B-trees in de tabel bijwerken. B-trees zijn extreem snel bij het lezen. Het doel van elke index is om reads te versnellen ten koste van tragere writes. Dit is uitstekend voor selectieve reads, maar verschrikkelijk voor write-intensief ingest-werk. Dit was mijn reden waarom mijn Raw-tabellen alleen een primaire sleutel hebben, en waarom er zware indexering en partitionering is toegepast op de tabellen die het werk van de Raw-tabellen ontvangen.

  1. Undo log maakt gelijktijdige reads en writes mogelijk

Wanneer een rij wordt bijgewerkt of verwijderd, wordt de oude waarde opgeslagen in een zogenaamd undo log. Als een sessie de tabel leest tijdens de write, komt de oude waarde uit het undo log. Inserts krijgen ook een kleiner undo log; de tabel en de primaire sleutel die worden ingevoegd, worden opgeslagen, zodat InnoDB weet wat er moet worden teruggedraaid als de insert niet wordt gecommit. Bij een commit wordt het undo log van een insert onmiddellijk verwijderd. Het undo log van een update of delete voegt zich bij een globale lijst, de history list, waardoor sessies oudere versies van gewijzigde data kunnen lezen.

Voor databases met veel updates en deletes groeit de history list snel. Gelukkig is dit opgelost in InnoDB: het systeem markeert undo logs om te worden verwijderd zodra elke sessie die begon vóór of tijdens de commit is beëindigd. Dit systeem wordt Multi-Version Concurrency Control (MVCC) genoemd. In gewone mensentaal betekent MVCC dat inserts en updates kunnen plaatsvinden zonder reads te blokkeren.

Ontwerpfouten en Partitionering

Zonder het te weten, had ik mijn InnoDB-database ontworpen volgens een OLAP-fundament. De tabel financialdata bevatte de datumtijd, open, high, low en close. Oorspronkelijk was dit de enige tabel die de datumtijd bevatte. Elke technische indicator die ik wilde opvragen, vereiste een join op de financialdata-tabel om te weten voor welke datum de rij van de indicator was berekend. Dit was een van mijn slechtste data-ontwerpen, iets wat ik pas realiseerde toen de database meer dan 100 GB aan data bevatte. Het herstellen hiervan was een zwaar proces.

CREATE TABLE `FinancialData` (
`FinancialDataID` int NOT NULL AUTO_INCREMENT,
`StockID` int NOT NULL,
`StockDate` datetime DEFAULT NULL,
`Open` decimal(20,5) DEFAULT NULL,
`Low` decimal(20,5) DEFAULT NULL,
`High` decimal(20,5) DEFAULT NULL,
`Close` decimal(20,5) DEFAULT NULL,
`Volume` decimal(20,5) DEFAULT NULL,
`EMA` decimal(20,5) DEFAULT NULL,
`VWAP` decimal(20,5) DEFAULT NULL,
`RateOfChange` decimal(20,5) DEFAULT NULL,
`OnBalanceVolume` decimal(20,5) DEFAULT NULL,
PRIMARY KEY (`FinancialDataID`,`StockID`),
UNIQUE KEY `StockID_Date_Idx` (`StockID`,`StockDate`),
KEY `idx3` (`StockID`,`FinancialDataID`,`StockDate`,`Close`)
) ENGINE=InnoDB AUTO_INCREMENT=320075156 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
/*!50100 PARTITION BY HASH (`StockID`)
PARTITIONS 100 */;

Elk .ibd-bestand is een InnoDB tablespace-bestand met ongeveer 1/100ste van de data van de tabel (bestanden variëren van FinancialData#p#p0.ibd tot FinancialData#p#p99.ibd).

Het idee achter het ontwerp was in theorie correct. Ik dacht aanvankelijk dat ik al deze kolommen voor elke query nodig zou hebben. Toen ik begon, wist ik nog niet eens dat de slotkoers de belangrijkste prijs is. Dit ontwerp betekende dat al mijn indicatortabellen FinancialDataID en StockID bevatten, wat diende als efficiënte kolommen voor joins. Het idee om financialdata als basistabel te gebruiken en kolommen te "plukken" op basis van de indicators, lijkt sterk op het bevragen van alleen de gewenste kolommen in een tabel en het negeren van de rest.

Toen ik uiteindelijk StockDate aan mijn indicatortabellen toevoegde, deed ik dit via een stored procedure die door verschillende geplande events werd uitgevoerd.

CREATE TEMPORARY TABLE IF NOT EXISTS t
(StockDate DATETIME, FinancialDataID INT PRIMARY KEY);

INSERT INTO t(StockDate, FinancialDataID)
SELECT StockDate, FinancialDataID FROM FinancialData WHERE StockID = iStockID;

UPDATE sar sr INNER JOIN t on sr.FinancialDataID = t.FinancialDataID SET sr.StockDate = t.StockDate;
UPDATE macd md INNER JOIN t on md.FinancialDataID = t.FinancialDataID SET md.StockDate = t.StockDate;
UPDATE directionalmovement dm INNER JOIN t on dm.FinancialDataID = t.FinancialDataID SET dm.StockDate = t.StockDate;
UPDATE boilerband bb INNER JOIN t on bb.FinancialDataID = t.FinancialDataID SET bb.StockDate = t.StockDate;
UPDATE chaikinoscillator co INNER JOIN t on co.FinancialDataID = t.FinancialDataID SET co.StockDate = t.StockDate;

DROP TEMPORARY TABLE IF EXISTS t;

Na vijf jaar met v1 realiseerde ik me dat mijn analytische werk slechts 2 of 3 kolommen per tabel gebruikte, terwijl ik gedwongen werd om elke kolom te lezen. Omdat optimalisatie voor analytisch werk mij een voordeel in de markt zou kunnen geven, besloot ik dit aan te pakken. Na onderzoek kwam ik tot de conclusie dat de keuze voor de engine onjuist was.

OLAP

De analytische tabellen van versie 2 maken gebruik van Iceberg op MinIO, met Parquet-bestanden achter een Polaris-catalogus, waarbij Spark de reads afhandelt. De volledige partitioneringsstrategie is vastgelegd in één DDL-clausule:

CREATE TABLE IF NOT EXISTS raw_bars (
`symbol` string,
`time_stamp` timestamp,
`open` double,
`high` double,
`low` double,
`close` double,
`volume` bigint,
`vwap` double,
`trade_count` bigint,
`is_valid` boolean
)
USING iceberg PARTITIONED BY (bucket(16,`symbol`));

Een van de voordelen van Iceberg is dat elke partitie in de metadata wordt berekend en opgeslagen. Data wordt gegroepeerd per partitieversie, onafhankelijk gepruned per versie, en de prunes worden daarna samengevoegd. Deze stap wordt split planning genoemd. In v1 duurde het partitioneren van een tabel dagen tot een week; als ik een partitie wilde wijzigen, moest ik de hele tabel opnieuw partitioneren. Daarom koos ik voor een bovengemiddelde hash van 100. Door ~13.000 StockIDs te splitsen in groepen van 130, zou het jaren duren voordat herpartitionering nodig zou zijn.

Het leespad (The Read Path)

Het lakehouse bestaat uit drie componenten: Apache Polaris, MinIO en Apache Iceberg.

  • Polaris dient als mijn catalogus. Dit is een Postgres-database met een API. De hoofdfunctie is het opslaan van een pointer naar een metadata.json-bestand per Iceberg-tabel. Elke query begint met het opvragen van die pointer, en elke commit eindigt met het bijwerken ervan. Polaris accepteert slechts één update tegelijk, wat tabelupdates atomair maakt.
  • MinIO is de fysieke opslag. Ik gebruik MinIO omdat ik mijn lakehouse in mijn eigen homelab host. MinIO slaat de Parquet-databestanden, de manifests, manifest-lijsten en de metadata.json-bestanden op. De bestandsstructuur is identiek aan een S3-bucket.
  • Iceberg is het brein. Iceberg biedt twee lagen van query-optimalisatie, aangevuld met een extra laag op het niveau van het databestand.

Ik zie deze optimalisatielagen als Russische matroesjka-poppetjes, waarbij we beginnen bij de kleinste pop en elke pop in de volgende, grotere pop plaatsen:

  1. De kleinste pop: Databestanden

Dit zijn Parquet-bestanden. Parquet is geoptimaliseerd voor kolomgebaseerde systemen, waardoor we alleen de specifieke kolommen kunnen lezen die nodig zijn. Elk Parquet-bestand bevat groepen rijen (Row Groups) met een samenvatting per groep, specifiek de min/max-waarden en het aantal nulls per kolom.

  1. De middelste pop: Manifests

Databestanden worden gegroepeerd door een Manifest. Net als Row Groups houdt een Manifest de min/max- en null-counts per kolom bij, evenals de partitiewaarde, bestandsgrootte en locatie van elk databestand. Manifests beheren ook Delete Files (bestanden die bijhouden welke rijen zijn verwijderd of bijgewerkt). Iceberg verwijdert deze rijen voordat de resultaten worden gegeven. Wanneer dit bij elke read gebeurt, noemen we dat merge on read. Dit is bijzonder nuttig voor streaming.

  1. De grootste pop: De Manifest List

De Manifest List is een samenvatting van de Manifests. Het geeft de berekende min/max partitiewaarde per Manifest en de partitieversie. Eén Manifest List hoort bij één snapshot. De pointer naar de Manifest List en het snapshotID staan in het metadata.json-bestand.

Hoe een query wordt uitgevoerd

Wanneer een executor een write (zoals een MERGE) wil uitvoeren, moet hij eerst de matchende rijen vinden. Dit begint dus als een read:

  1. De executor vraagt aan Polaris om de pointer naar het huidige metadata.json-bestand.
  2. Het metadata-bestand wijst naar de huidige snapshot.
  3. De snapshot wijst naar de Manifest List.
  4. De planner berekent de bucket voor het symbool en betreedt alleen die specifieke bucket.
  5. Er wordt alleen gekeken naar de Manifests waarvan het partitbereik het berekende symbool bevat.
  6. De overgebleven databestanden controleren de statistieken van elke Row Group om onnodige groepen te verwijderen (pruning).
  7. Alleen de overgebleven Row Groups worden gedecodeerd.

Mijn indicatortabel heeft 36 kolommen. Wanneer een indicator alleen de slotkoers (close) en het volume nodig heeft, worden nu alleen die twee kolommen gelezen; de overige 34 blijven op de schijf.

Het commit-pad (The Commit Path)

Databestanden zijn onveranderlijk (immutable). Een write creëert nieuwe bestanden. Mijn ingest verloopt via een MERGE-statement:

MERGE INTO stockalgo.market.raw_bars AS rb
USING new_raw_bars AS nrb ON rb.symbol = nrb.symbol AND rb.time_stamp = nrb.time_stamp
WHEN MATCHED THEN UPDATE SET rb.time_stamp = nrb.time_stamp, rb.open = nrb.open, rb.close = nrb.close,
rb.high = nrb.high, rb.low = nrb.low, rb.volume= nrb.volume, rb.vwap=nrb.vwap, rb.trade_count = nrb.trade_count,
rb.is_valid = nrb.is_valid
WHEN NOT MATCHED THEN INSERT (symbol, time_stamp, open, close, high, low, volume, vwap, trade_count, is_valid)
VALUES(nrb.symbol, nrb.time_stamp, nrb.open, nrb.close, nrb.high, nrb.low, nrb.volume, nrb.vwap, nrb.trade_count, nrb.is_valid);

Rijen die overeenkomen worden herschreven als nieuwe bestanden (copy on write). Rijen die niet overeenkomen, worden toegevoegd (appends).

De writer bouwt nieuwe manifests, een nieuwe manifest-lijst voor de nieuwe snapshot, en een nieuw metadata-bestand. Vervolgens vraagt hij Polaris om de pointer van het oude metadata-bestand naar het nieuwe te verplaatsen. Deze swap is atomair en vormt de commit.

Bij gelijktijdige schrijvers (race conditions) wint de snelste schrijver. De langzamere schrijver ziet de mismatch en controleert of de gewijzigde databestanden in de nieuwe versie dezelfde zijn als waar hij aan werkte. Zo niet, dan past hij zijn wijzigingen toe op de nieuwe snapshot en probeert hij de swap opnieuw. In v1 losten advisory locks dit op door de tweede schrijver te laten wachten; in v2 wordt dit gedaan via optimistic pointer swapping.

Reads hoeven niet te wachten. Een reader houdt vast aan de snapshot waarmee hij begon. Omdat bestanden immutable zijn, kunnen ze niet onder de reader veranderen. Waar InnoDB een undo log nodig heeft voor deze garantie, slaat Iceberg de oude versie simpelweg op als het oude bestand.

Vergelijking

VraagInnoDB (v1)Iceberg + Spark + Polaris (v2)
Wat maakt een read snel?Secundaire B-tree indexen, betaald bij elke writeMetadata bij write-tijd, drie lagen van pruning
Wat kost een write?Rij wordt ter plaatse bijgewerkt + elke indexNieuwe immutable bestanden + één snapshot commit
Kosten van wijziging partitieneringVolledige rebuild van de tabel (dagen tot een week)Metadata-wijziging, split planning dekt oude bestanden
Hoe leest een scan kolommen?Volledige rijen via de buffer poolAlleen de kolommen die in de query worden genoemd
Gelijktijdige schrijversDoor mij beheerde advisory locksOptimistische pointer swap, verliezer probeert opnieuw
Wat ziet een reader?MVCC rij-versiesEén immutable snapshot
Write die halverwege faaltTemp tables, anti joins, deletes na commitWees-bestanden, geen snapshot, retry is idempotent
Rij-aantal bepalenIndex scan over elke rijSnapshot samenvatting, metadata read
OnderhoudslastOnderhoud van indexen en partitiesCompaction en snapshot expiration