Why DuckDB 2.0 is faster

原始链接: https://motherduck.com/blog/why-duckdb-20-is-faster/

Hacker News new | past | comments | ask | show | jobs | submit login Why DuckDB 2.0 is faster ( motherduck.com ) 8 points by tosh 1 hour ago | hide | past | favorite | 1 comment help stacktraceyo 8 minutes ago [–] Great visualization. Side note their new c++ extension api is also gonna be faster from the perspective of development / distribution of those extensions reply Consider applying for YC's Winter 2027 batch! Applications are open till November 2. Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact Search:
相关文章

原文

Everybody loves JSON. VARIANT is now a first-class data type in DuckDB, and the word to remember is shredding.

Not the guitar kind. Shredding in our VARIANT context means: when DuckDB writes a row group to disk, it looks at your JSON column and finds the fields that show up in most rows with the same kind of value every time.

Here is a common example, one event out of five million:

{"type": "purchase", "user": {"id": 42, "country": "FR", "premium": true},
 "props": {"amount": 12.5, "currency": "EUR", "items": 2}, "tags": ["a", "b"]}

The event kind is always text, user.id is always a number, props.amount is always a decimal. Those fields get pulled out into their own real columns under the hood. The rare fields, and the fields that are a number in one row and text in the next, stay together in a binary remainder. So the consistent part of your JSON is stored like a normal table, and only the messy part is stored as a blob.

JSON vs VARIANT SELECT count(*) FROM ev WHERE type = 'purchase' AND user.country = 'FR'; DuckDB 1.5.5 JSON string, parsed at query time payload · stored as text {"type":"click", "user":{"id":51054,"country":"BR"},"amount":null} {"type":"click","user":{"id":51054,"country":"BR"},"amount":null} {"type":"purchase", "user":{"id":42,"country":"FR"},"amount":12.5} {"type":"purchase","user":{"id":42,"country":"FR"},"amount":12.5} {"type":"view", "user":{"id":11,"country":"FR"},"amount":null} {"type":"view","user":{"id":11,"country":"FR"},"amount":null} {"type":"purchase", "user":{"id":8890,"country":"US"},"amount":301.2} {"type":"purchase","user":{"id":8890,"country":"US"},"amount":301.2} type · after parsing country · after parsing click BR purchase FR view FR purchase US characters read 0 18 32 43 51 57 61 63 64 65 74 90 103 112 119 124 127 129 130 147 160 170 178 183 187 189 191 201 217 230 240 248 253 256 258 259 DuckDB 2.0 alpha VARIANT, shredded at checkpoint payload · stored as VARIANT {"type":"click", "user":{"id":51054,"country":"BR"},"amount":null} {"type":"click","user":{"id":51054,"country":"BR"},"amount":null} {"type":"purchase", "user":{"id":42,"country":"FR"},"amount":12.5} {"type":"purchase","user":{"id":42,"country":"FR"},"amount":12.5} {"type":"view", "user":{"id":11,"country":"FR"},"amount":null} {"type":"view","user":{"id":11,"country":"FR"},"amount":null} {"type":"purchase", "user":{"id":8890,"country":"US"},"amount":301.2} {"type":"purchase","user":{"id":8890,"country":"US"},"amount":301.2} type user.id country amount click 51054 BR null purchase 42 FR 12.5 view 11 FR null purchase 8890 US 301.2 values read 0 2 4 6 8

The perfect case is structured logs. level, service, latency_ms, trace_id are in every line and always the same kind of value, so they all shred. The odd extra object stays in the remainder, still queryable, just slower. The trap: a latency_ms that is 231 in one line and "231ms" in the next falls into the remainder too. Keep value kinds consistent.

VARIANT is not only about speed. Text is greedy on storage as much as on CPU. I took the five million events and stored the same data three ways:


CREATE TABLE ev AS SELECT json AS payload FROM read_ndjson_objects('events.ndjson');

CREATE TABLE ev AS SELECT json::VARIANT AS payload FROM read_ndjson_objects('events.ndjson');

CREATE TABLE ev AS SELECT * FROM read_json('events.ndjson');

Then three queries on each: a filter on two fields, a sum of a numeric field grouped by country, and a list lookup.


SELECT count(*) FROM ev
WHERE payload.type::VARCHAR = 'purchase' AND payload.user.country::VARCHAR = 'FR';


SELECT payload.user.country::VARCHAR AS country, sum(payload.props.amount::DOUBLE) AS amount
FROM ev WHERE payload.type::VARCHAR = 'purchase' GROUP BY ALL ORDER BY 1;


SELECT count(*) FROM ev WHERE list_contains(payload.tags::VARCHAR[], 'c');
JSON stringVARIANT (2.0 alpha)VARIANT (1.5.5)One typed column per field
On disk224 MB85 MB81 MB45 MB
Q1 filter366 ms63 ms4.96 s52 ms
Q2 sum by country408 ms61 ms5.28 s51 ms
Q3 list contains357 ms1.96 s4.65 s67 ms

What can we see here?

  • VARIANT is 2.7 times smaller than the JSON string, and you can see why with pragma_storage_info('ev'): the object is split into sub-columns, the event kind is stored as a dictionary. EXPLAIN shows the filter pushed into the scan, so a query on payload.type reads one sub-column.
  • On the queries that touch shredded fields, a filter or a sum on a numeric sub-field, VARIANT is about 6 times faster than parsing the JSON text and within 20 percent of the typed columns. Compared to VARIANT in 1.5.5 it is 78 times faster, because 1.5.5 had the type but not the shredding.
  • The list query is the exception. Casting a VARIANT list to VARCHAR[] costs two seconds in this alpha, slower than the JSON path. Field access is where shredding pays today; lists are not there yet.

So the golden rule of modeling is still valid: model what you know. The fields every query touches deserve real columns, and promoting one is two statements:

ALTER TABLE ev ADD COLUMN type VARCHAR;
UPDATE ev SET type = payload.type::VARCHAR;

TL;DR: if your events share a consistent set of fields with consistent value kinds, store them as VARIANT rather than a JSON string. You get a third of the storage and field queries that run like real columns. Promote the fields every query touches to real columns, keep the long tail in the VARIANT, and avoid list casts in hot queries for now.

联系我们 contact @ memedata.com