A Northstar orders és order_items tábláit
az előző alfejezetben megtisztítottuk, és a sémát Parquet-ben is
rögzítettük. Most ugyanazokat a rendelésszintű mutatókat számoljuk ki
DuckDB-vel, amelyeket a 7. fejezetben SQLite-tal is használtunk:
rendelések száma, tételek száma, kosárérték és szállítási díj összege. A
megfigyelési egység a rendelés, illetve a rendelési tétel; a cél, hogy
Pythonból és közvetlen fájlból azonos eredményt kapjunk, explicit
DuckDB-kapcsolattal, majd SQLite kontrollértékekkel ellenőrizzünk.
Előzetes becslés: a tiszta adatban 2400 rendelés és 4796 tétel
található, ezért a belső join sorszáma 4796.
connect. A DuckDB beágyazott adatbázis:
nincs külön szerverfolyamat, a motor a hívó Python-folyamatban fut. A
duckdb.connect(":memory:") egy átmeneti, csak az adott
kapcsolatban élő adatbázist nyit; a
duckdb.connect("northstar.duckdb") fájlba írja az adatot.
Az explicit kapcsolat azért fontos, mert így nem a DuckDB rejtett
alapértelmezett kapcsolatát használjuk, és mi irányítjuk, mely
Python-objektumok melyik kapcsolatba regisztrálódnak.
CSV/Parquet scan. A read_parquet és
read_csv_auto táblafüggvények a fájlt közvetlenül, pandas
köztes DataFrame nélkül olvassák be a lekérdezésbe. A Parquet a fájlban
tárolt sémát használja; CSV-nél a DuckDB mintavételezéssel becsli a
típusokat. Az orders_raw.csv hiányzó
distance_km értékei például NULL-ként jelennek
meg, nem nullává alakított távolságként. A scan nem tölti be az egész
fájlt a Python heapbe: a motor a saját pufferében dolgozik, és csak a
kért oszlopokat, illetve a szükséges sorokat olvassa.
Relation. A con.sql("SELECT ...") hívás
nem feltétlenül anyagiasítja azonnal az eredményt: egy
DuckDBPyRelation objektumot ad vissza. Ez a reláció a
lekérdezés terve, amely a relációs algebra kifejezéseként írja le, mit
szeretnénk számolni. A tényleges végrehajtás akkor történik, amikor az
eredményt lekérjük, például .fetchall(),
.df(), .arrow() vagy .show()
hívással. A Relation láncolható is: szűrés, vetítés és aggregálás
építhető rá, hasonlóan ahhoz, ahogy a Polars lazy tervét fogjuk a
következő alfejezetben használni.
Eredménykonverzió. A leggyakoribb kimenetek:
.df(): pandas DataFrame-et ad; ez az adatot a Python
heapbe másolja..arrow(): PyArrow táblát ad, amely a CSV-től Parquet-ig: séma és partíció alfejezetben
tanult oszlopalapú ábrázolást használja..fetchall(): Python tuple-listát ad, ami kis eredmények
debugolásához kényelmes.Ha csak a séma érdekel, a DESCRIBE SELECT ... lekérdezés
vagy a Relation oszlopneveinek vizsgálata nem anyagiasítja a teljes
eredményt.
Szál- és memóriakeret. A DuckDB a folyamaton belül
több szálat használhat egy lekérdezés párhuzamos végrehajtására. A
SET threads TO 4 felső korlátot ad a szálhasználatra, a
SET memory_limit='2GB' pedig a motor puffermemóriáját
korlátozza. Ez a korlát nem foglalja magában a Python oldali
DataFrame-eket: ha rel.df()-et hívunk, a pandas másolat
további heapmemóriát fogyaszt. A fájlalapú .duckdb állomány
egyszerre jellemzően egy író folyamatnak való; többfelhasználós írási
tranzakciókra nem ez a megfelelő eszköz.
Két apró kézi táblával ellenőrizzük a connect, a
regisztráció, a Relation és a konverzió együttműködését. Futtatás a
projektgyökérből: uv run python mini_duckdb.py (Linux/macOS
terminál; PowerShellben ugyanez a uv parancs, csak az
útvonalírás térhet).
import pandas as pd
import duckdb
mini_orders = pd.DataFrame({
"order_id": ["A", "B"],
"shipping_fee_minor": [300, 0],
})
mini_items = pd.DataFrame({
"order_id": ["A", "A", "B"],
"line_total_minor": [1000, 2000, 500],
})
con = duckdb.connect(":memory:")
con.register("mini_orders", mini_orders)
con.register("mini_items", mini_items)
rel = con.sql("""
SELECT
(SELECT count(*) FROM mini_orders) AS n_orders,
(SELECT count(*) FROM mini_items) AS n_items,
(SELECT sum(shipping_fee_minor)
FROM mini_orders) AS shipping_ok,
(SELECT sum(o.shipping_fee_minor)
FROM mini_items i
JOIN mini_orders o USING (order_id)) AS shipping_dup
""")
print(type(rel).__name__)
df = rel.df()
print(df.to_string(index=False))
print("df.shape =", df.shape)A kézi táblázatból elméletileg várt kimenet:
DuckDBPyRelation
n_orders n_items shipping_ok shipping_dup
2 3 300 600
df.shape = (1, 4)
Az utolsó sort a szövegíró nem futtatta; a kézi számításból
következik. A shipping_dup azért 600, mert a három tételsor
közül az A rendelés kétszer szerepel, és a 300 centes szállítási díj
kétszer adódik az összeghez.
A kis példa a Join-kardinalitás és duplázódó célváltozó join-kardinalitási leckéjét ismétli: a belső join sorszáma a tételek számával egyezik, ezért a rendelésszintű oszlopok ismétlődnek. A mutatók definíciója skalár eredményt ad egy sorban:
A joined sorból számolt
nem ugyanaz a mutató. Az IDE, típusinformáció és
hibakeresés alfejezetben tanult debuggeres shape-ellenőrzés itt is
hasznos: a rel.df().shape legyen (1, 4), mert
egy aggregált sor négy skalár oszlopot tartalmaz. Mielőtt nagy fájlra
váltunk, ezt a kézi kontrollt érdemes megismételni.
A laborban a Northstar fájlokkal dolgozunk. A DuckDB-függőséget először a projektbe kell venni:
# Linux/macOS terminál, a lab projekt gyökérkönyvtárából
uv add duckdb
uv lock
uv sync --lockedEzután a DuckDB közvetlen Parquet-scanjeit összehasonlítjuk egy SQLite kontrollal. A kód a szövegírás során nem futott; a nyomtatott pénzösszegek a helyi manifestből adódnak, ezért itt nem adunk előre konkrét számot.
import sqlite3
import time
import pandas as pd
import duckdb
t0 = time.perf_counter()
# DuckDB: közvetlen fájlból, explicit kapcsolattal.
con = duckdb.connect(":memory:")
con.execute("SET threads TO 4")
con.execute("SET memory_limit='2GB'")
duck_sql = """
SELECT
(SELECT count(*) FROM read_parquet('data/orders.parquet')) AS n_orders,
(SELECT count(*) FROM read_parquet('data/order_items.parquet')) AS n_items,
(SELECT sum(basket_minor) FROM read_parquet('data/orders.parquet')) AS basket_sum,
(SELECT sum(shipping_fee_minor) FROM read_parquet('data/orders.parquet')) AS shipping_sum,
(SELECT sum(i.line_total_minor)
FROM read_parquet('data/order_items.parquet') i
JOIN read_parquet('data/orders.parquet') o
ON i.order_id = o.order_id) AS line_total_joined
"""
duck_rel = con.sql(duck_sql)
duck_result = duck_rel.df().iloc[0].to_dict()
t_duck = time.perf_counter() - t0
# SQLite kontroll: a CSV-ket pandas betölti, az stdlib sqlite3 végrehajtja.
sqlite_con = sqlite3.connect(":memory:")
orders = pd.read_csv("data/orders.csv", parse_dates=["order_ts"])
items = pd.read_csv("data/order_items.csv")
orders.to_sql("orders", sqlite_con, index=False)
items.to_sql("order_items", sqlite_con, index=False)
sqlite_sql = """
SELECT
(SELECT count(*) FROM orders) AS n_orders,
(SELECT count(*) FROM order_items) AS n_items,
(SELECT sum(basket_minor) FROM orders) AS basket_sum,
(SELECT sum(shipping_fee_minor) FROM orders) AS shipping_sum,
(SELECT sum(i.line_total_minor)
FROM order_items i
JOIN orders o ON i.order_id = o.order_id) AS line_total_joined
"""
sqlite_row = sqlite_con.execute(sqlite_sql).fetchone()
sqlite_result = dict(zip(
["n_orders", "n_items", "basket_sum", "shipping_sum", "line_total_joined"],
sqlite_row))
t_total = time.perf_counter() - t0
print(duck_result)
print(sqlite_result)
print(f"duckdb_wall_s={t_duck:.3f}, total_wall_s={t_total:.3f}")
for key in sqlite_result:
duck_val = duck_result[key]
sqlite_val = sqlite_result[key]
if sqlite_val is None:
assert duck_val is None, key
else:
assert int(duck_val) == int(sqlite_val), key
print("kontroll: a DuckDB és SQLite mutatók egyeznek")Ha még nincs Parquet-állomány, a CSV-től
Parquet-ig: séma és partíció alapján készítsük el, vagy ideiglenesen
cseréljük a read_parquet('data/orders.parquet')
kifejezéseket read_csv_auto('data/orders.csv')-re. A
n_orders=2400 és n_items=4796 a szerződésből
várható; a basket_sum és a line_total_joined
egyezése a tételszintű kosárösszeg invariant, egész minor unitban. A
duckdb_wall_s egyszeri, a saját gépen mért tájékoztató idő;
teljesítmény-összehasonlításhoz gép, adatméret, ismétlésszám és azonos
input kellene.
Értelmezés. A mini példában a rel
típusa DuckDBPyRelation. Mit jelent az, hogy a
con.sql(...) hívás nem azonnal anyagiasít? Melyik hívás
kényszeríti ki a végrehajtást? Adj egy módot, amellyel a reláció sémáját
ellenőrizni tudod anélkül, hogy minden sort a Python heapbe
másolnál.
Implementálás. Futtasd a fenti laborpéldát a
data/orders.parquet és
data/order_items.parquet állományokra. Írasd ki a
n_orders, n_items, basket_sum,
shipping_sum értékeket, és ellenőrizd, hogy a DuckDB és a
SQLite eredmény mind az öt mutatóra egyezik. A megoldásban add meg a
használt DuckDB-verziót is, például a duckdb.__version__
kiíratásával.
Hibakeresés. Egy kolléga a következő kódot futtatja:
con.register("orders", pd.read_csv("data/orders.csv"))
con.register("order_items", pd.read_csv("data/order_items.csv"))
bad = con.sql("""
SELECT sum(o.shipping_fee_minor) AS shipping
FROM order_items i JOIN orders o ON i.order_id = o.order_id
""").df()A kapott shipping nagyobb, mint a
SELECT sum(shipping_fee_minor) FROM orders eredménye. A Join-kardinalitás és duplázódó célváltozó
join-kardinalitási szabályai alapján magyarázd meg, miért, és adj egy
javított lekérdezést.
priority_flag = 1 rendelésekhez tartozó
n_orders, n_items és shipping_sum
mutatókat úgy, hogy a rendelésszintű szűrés a tételekre is
érvényesüljön. Ellenőrizd az invariánst: n_items egyenlő a
priority rendelésekhez tartozó tételek számával, és a szűrt
shipping_sum nem tartalmazza a
priority_flag = 0 rendelések díját.A con.sql(...) Relation objektumot ad: a lekérdezés
tervét, nem az eredmény Python-másolatát. Az anyagiasítást a
.df(), .fetchall(), .fetchone(),
.arrow() vagy .show() hívás váltja ki. A séma
ellenőrzéséhez a Relation oszlopneveit (rel.columns) vagy
egy DESCRIBE SELECT ... lekérdezést használhatunk; a
rel.limit(5).df() pedig biztonságos kis előnézetet ad
anélkül, hogy a teljes eredményt a Python heapbe töltenénk.
A uv add duckdb után a fenti kód futtatható. A
szerződésből n_orders=2400 és n_items=4796
várható; a basket_sum és a line_total_joined
egész minor unitban egyezik, a két végrehajtó értékei mind az öt
mutatóra azonosak. A DuckDB-verziót a duckdb.__version__
rögzíti; a lockfájl a pontos csomagverziót is tartalmazza. Ha Parquet
nincs, a read_csv_auto útváltozat a CSV-n is ugyanazt a
mutatót adja.
A belső join a tételek szerint 4796 sort ad; minden rendelés
annyiszor szerepel, ahány tétele van. A shipping_fee_minor
rendelésszintű oszlop minden tételsorban megismétlődik, ezért a naiv
sum a rendelésenkénti tételszámmal súlyozza a szállítási
díjat. A mini példában a 300 centes A rendelés kétszer szerepel, így 600
lesz. A javítás: a szállítási díjat a rendelés táblából, join nélkül
kell összegezni:
SELECT sum(shipping_fee_minor) AS shipping FROM ordersVagy előaggregálással egy rendelésenként egy reprezentáns sort kell
venni, például (order_id, shipping_fee_minor) egyedi
párokból.
SELECT
count(*) AS n_orders,
(SELECT count(*)
FROM order_items i
JOIN orders o ON i.order_id = o.order_id
WHERE o.priority_flag = 1) AS n_items,
sum(shipping_fee_minor) AS shipping_sum
FROM orders
WHERE priority_flag = 1Az invariáns teljesül, mert a belső select a priority rendelésekhez
tartozó tételeket számolja, a külső WHERE pedig a
rendelésszintű shipping_fee_minor összeget szűkíti
ugyanarra a halmazra. A szűrt shipping_sum így nem
tartalmazza a priority_flag = 0 rendelések díját.
A beágyazott analitikus adatbázis nem minden többfelhasználós
tranzakciós szerver helyettesítője. Ha két párhuzamos folyamat ugyanarra
a .duckdb fájlra ír, az egyik zárolási hibát vagy sérült
állapotot kaphat; egy szerveres OLTP-rendszer, például egy PostgreSQL, a
tranzakciókat, a felhasználókezelést és az egyidejű írásokat más
mechanizmussal kezeli. A DuckDB-t ezért kutatói folyamaton belüli,
fájlok feletti analitikus lekérdezésre használjuk, nem
alkalmazásszervernek. A Join-kardinalitás és
duplázódó célváltozó alfejezetből ismert, hogy a join előtti
kulcsegyediség és a join utáni sorváltozás ellenőrzése akkor is
kötelező, ha a motor gyors. Önellenőrzés: mielőtt új adatforrásra
váltanál, futtasd a mini példát, és győződj meg róla, hogy a
shipping_dup pontosan kétszerese a
shipping_ok-nak.
Rendelésszintű aggregációnál a rendeléshez tartozó
shipping_fee_minor összege megsokszorozódhat a
tételsorokkal való összekapcsolás után. A tételsorokat előbb
order_id szerint aggregáljuk, és csak azután kapcsoljuk az
egyedi rendelésekhez.
A DuckDB beágyazott analitikus SQL-motort ad: explicit
duckdb.connect kapcsolattal, szerver nélkül olvasunk CSV-t
és Parquet-et. A con.sql Relation objektumot ad, amely a
lekérdezési terv; a tényleges anyagiasítást a .df(),
.fetchall() vagy .arrow() hívás váltja ki. A
fájlscan elkerüli a pandas köztes DataFrame-et, de a
.df()-felé konvertálás a Python heapben további memóriát
fogyaszt. A Northstar mutatók DuckDB-vel és SQLite-tal azonos
SQL-definícióval számolhatók; a kontroll a join-kardinalitás miatt a
rendelésszintű oszlopokra külön figyelmet igényel. A következő
alkalmazás: a Polars lazy tervének és a DuckDB Relation viselkedésének
összehasonlítása.