10 min čteníJohnny UnarJohnny Unar

Přestaňte rvát analytiku přes Django ORM

Reporting nacpaný do řetězených QuerySetů je pomalý, křehký a nečitelný. Těžkou analytiku vraťte do SQL, tam vám Postgres opravdu pomůže.

orm je na to špatný nástroj

Django ORM je skvělý pro transakční aplikační kód, CRUD obrazovky, permission filtry a business logiku, kde je důležitější čitelnost než ždímat z query planneru poslední procenta výkonu. Reporting je ale úplně jiná práce. Ve chvíli, kdy query potřebuje pět joinů, partitioned rank, klouzavou 30denní metriku, podmíněné agregace a pár chytře umístěných filtrů, aby se nic nezapočítalo dvakrát, je snaha vyjádřit to jako řetěz QuerySetů v podstatě malá forma sebepoškozování.

Viděli jsme codebase, kde měsíční revenue report seděl v manager metodě s dvanácti annotate() voláními, třemi zanořenými Subquery(), OuterRef() létal všude kolem a finální .values() potichu změnilo groupování, protože při refaktoru přibyl jeden sloupec navíc. Nikdo na to nechtěl sahat, všichni se toho báli a vygenerované SQL vypadalo, jako by se ORM pohádalo samo se sebou. Django přesně udělalo to, co jste po něm chtěli. Právě to je ten problém.

SQL už má pojmy pro věci, které reporting potřebuje. Common table expressions vám dovolí rozdělit query na kroky, které člověk přečte. Window functions spočítají ranky, klouzavé průměry nebo percentily bez toho, abyste tahali půl datasetu do Pythonu. FILTER (WHERE ...) je čistší než cpát Case a When do každé agregace. date_trunc, generate_series, lag, lead, dense_rank, sum(...) over (...), to všechno existuje z nějakého důvodu.

Spousta týmů bere raw SQL jako nebezpečný escape hatch, ale bez mrknutí oka udržuje 200 řádků ORM zaklínadel, kterým rozumí jeden jediný člověk. To máte obráceně. Pro reporting a analytiku je SQL hlavní jazyk. Django je vrstva kolem, která to napojí do aplikace.

pište query, které opravdu myslíte

Začněte v SQL souboru, ne v Pythonu, ne v model manageru a už vůbec ne reverzním inženýrstvím toho, co náhodou vypíše str(queryset.query). Napište query tak, jak ho chce databáze vykonat, a verzujte ho jako normální kód. Pro sales funnel report bývá něco takového skoro vždy čitelnější než jakýkoli ORM ekvivalent:

sql
1with base_orders as (
2 select
3 o.id,
4 o.customer_id,
5 o.created_at::date as order_date,
6 o.total_cents,
7 o.status
8 from orders o
9 where o.created_at >= current_date - interval '90 days'
10),
11paid_orders as (
12 select *
13 from base_orders
14 where status = 'paid'
15),
16daily_totals as (
17 select
18 order_date,
19 count(*) as paid_order_count,
20 sum(total_cents) as revenue_cents
21 from paid_orders
22 group by order_date
23),
24series as (
25 select generate_series(
26 current_date - interval '89 days',
27 current_date,
28 interval '1 day'
29 )::date as day
30)
31select
32 s.day,
33 coalesce(d.paid_order_count, 0) as paid_order_count,
34 coalesce(d.revenue_cents, 0) as revenue_cents,
35 sum(coalesce(d.revenue_cents, 0)) over (
36 order by s.day
37 rows between 6 preceding and current row
38 ) as rolling_7d_revenue_cents
39from series s
40left join daily_totals d on d.order_date = s.day
41order by s.day;

Tohle přečtete shora dolů, upravíte jednu část bez rozbití zbytku a můžete pustit EXPLAIN (ANALYZE, BUFFERS) přímo v psql nebo TablePlus. To je podstatné. Query, které neumíte pořádně zkontrolovat, neumíte ani ladit.

V Postgres 15 je tohle navíc příjemnější, protože planner se kolem CTE chová rozumněji než dřív, kdy se jim lidi vyhýbali ze strachu z vynucené materializace. Pořád musíte rozumět execution planům, pořád potřebujete správné indexy a pořád musíte sledovat row estimates. Jen už nemusíte za čitelnost automaticky platit.

Ve Steezru jsme tohle dělali na ERP dashboardech i zákaznických portálech, kde aplikace běží v Django, API je čisté, produktový tým dostane metriky, které chce, a nikdo nemusí během production incidentu debugovat řetěz annotate().

shipujte sql přes migrace

Když na query záleží, patří pod version control a stejnou deployment disciplínu jako změny schématu. Když raw SQL hodíte do helper stringu v Pythonu, nikdo neví, kdy se změnilo, nikdo ho pořádně nereviewuje a rollback se rychle zvrhne. Django migrace jsou úplně v pohodě místo pro views, materialized views, SQL funkce i podpůrné indexy.

Jednoduchý pattern je migrations.RunSQL plus SQL soubor v repu, načtený přes Path.read_text(). Třeba takhle:

python
1from pathlib import Path
2from django.db import migrations
3
4BASE_DIR = Path(__file__).resolve().parent
5
6class Migration(migrations.Migration):
7 dependencies = [
8 ("reporting", "0012_previous"),
9 ]
10
11 operations = [
12 migrations.RunSQL(
13 sql=(BASE_DIR / "sql" / "daily_revenue_mv.up.sql").read_text(),
14 reverse_sql=(BASE_DIR / "sql" / "daily_revenue_mv.down.sql").read_text(),
15 ),
16 ]

A SQL soubor:

sql
1create materialized view reporting_daily_revenue as
2with paid_orders as (
3 select
4 created_at::date as day,
5 total_cents
6 from orders
7 where status = 'paid'
8)
9select
10 day,
11 count(*) as order_count,
12 sum(total_cents) as revenue_cents
13from paid_orders
14group by day;
15
16create unique index reporting_daily_revenue_day_idx
17 on reporting_daily_revenue (day);

Ten unique index není volitelný, pokud chcete REFRESH MATERIALIZED VIEW CONCURRENTLY reporting_daily_revenue;. Bez něj na vás Postgres vyplivne ERROR: cannot refresh materialized view "reporting_daily_revenue" concurrently a řekne vám, že potřebuje použitelný unique index. Správně. Databáze vás chrání před provozně špatným rozhodnutím.

Pokud chcete podporu pro serializery a integraci do adminu, dejte nad view nebo materialized view unmanaged Django model:

python
1class DailyRevenue(models.Model):
2 day = models.DateField(primary_key=True)
3 order_count = models.IntegerField()
4 revenue_cents = models.BigIntegerField()
5
6 class Meta:
7 managed = False
8 db_table = "reporting_daily_revenue"

Tohle rozdělení funguje dobře. Django pořád řídí aplikaci. Postgres řídí report.

testujte sql jako kód

Týmy SQL testy vynechávají a pak jsou překvapené, když nenápadná změna schématu potichu rozbije revenue report na tři týdny. Jestli report řídí billing, finance, provize nebo čísla, která vidí zákazník, testujte ho přímo v databázi. pgTAP je dost dobrý, vyzrálý a o dost lepší než předstírat, že unit testy kolem ORM wrapperů pokrývají skutečné riziko.

pgTAP test umí ověřit počty řádků, přesné agregace, práci s null hodnotami i tvar view. I něco takhle jednoduchého chytí reálné chyby:

sql
1begin;
2select plan(4);
3
4insert into orders (id, created_at, total_cents, status)
5values
6 (1, '2025-01-01 10:00:00+00', 5000, 'paid'),
7 (2, '2025-01-01 12:00:00+00', 2500, 'paid'),
8 (3, '2025-01-01 14:00:00+00', 9000, 'failed');
9
10refresh materialized view reporting_daily_revenue;
11
12select is(
13 (select order_count::int from reporting_daily_revenue where day = '2025-01-01'),
14 2,
15 'counts only paid orders'
16);
17
18select is(
19 (select revenue_cents::bigint from reporting_daily_revenue where day = '2025-01-01'),
20 7500,
21 'sums paid revenue correctly'
22);
23
24select ok(
25 not exists(select 1 from reporting_daily_revenue where revenue_cents is null),
26 'revenue_cents is never null'
27);
28
29select has_materialized_view('public', 'reporting_daily_revenue');
30
31select * from finish();
32rollback;

Pouštějte to v CI proti Postgres 15, tedy stejné major verzi, jakou máte v produkci. Neodbývejte to test runem nad SQLite a netvařte se, že je hotovo. SQLite vám klidně nechá projít špatné předpoklady, protože se nechová jako Postgres v groupování, typech, JSON operátorech, časových zónách ani query planningu.

pgTAP snadno napojíte do GitHub Actions, stačí mít v test database imagi nainstalovaný pgtap a pouštět pg_prove. Týmy, které jsou zvyklé na Django test runner, si klidně můžou nechat Python integrační testy pro chování endpointů, permission logiku a serializaci, a správnost SQL vrstvy nechat na pgTAP. Je to čistší hranice než snažit se emulovat databázovou sémantiku v Python fixtures.

materialized views jsou ops rozhodnutí

Materialized views řeší jeden problém, drahé opakované čtení, a přidávají několik dalších, zastaralá data, cenu refreshe, chování locků, růst storage a údržbu, kterou někdo musí vlastnit. Používejte je tehdy, když ten trade-off dává smysl, ne proto, že to zní chytře.

Obyčejné view stačí, pokud podkladová query běží za 40 ms a access pattern je rozumný. Materialized view začne dávat smysl ve chvíli, kdy zdrojová query pálí CPU, joinuje velké tabulky nebo krmí dashboard, na který každých pár sekund sahá sales, podpora a nějaký wallboard, který si někdo pověsil v kanceláři. U jednoho klienta jsme nahradili hluboce zanořený ORM report, který na PostgreSQL 15.4 trval 6 až 9 sekund, materialized view refreshem každých pět minut. API spadlo zhruba na 30 ms a nikomu nevadilo, že čísla mají malé zpoždění, protože šlo o provozní trendy, ne burzovní obchodování.

Refresh strategie je důležitá. REFRESH MATERIALIZED VIEW vezme na view exkluzivní lock pro čtení. REFRESH MATERIALIZED VIEW CONCURRENTLY neblokuje čtenáře, trvá déle a vyžaduje ten unique index. Když refresh čas začne růst, možná budete muset agregovat dřív, partitionovat zdrojové tabulky nebo přestat předstírat, že jedno obří materialized view má odpovědět na každou reportingovou otázku.

Důležitý je i vacuum. Analytické workloady vytvářejí dead tuples na často updatovaných zdrojových tabulkách a zastaralé statistiky ničí rozhodování planneru. Výchozí autovacuum nastavení bývá pro vytížené SaaS produkty moc opatrné. Sledujte pg_stat_user_tables, n_dead_tup, last_autovacuum i reálný bloat. Když se report po růstové špičce najednou zpomalí, neobviňujte hned tvar SQL, nejdřív se podívejte, jestli planner nerozhoduje podle odpadních statistik.

Nad tím vším je ještě cache. Když se stejný report pálí pořád dokola, cacheujte API response v Redis. U multi-tenant aplikace klidně cache per tenant, pokud cardinalita dává smysl. TTL držte v souladu s tím, jak často refreshujete materialized view. Cacheovat query, které je při každém requestu jiné, je cargo cult. Cacheovat stabilní dashboard summary je prostě praktické.

udržte tu hranici čistou

Pragmatické rozdělení je jednoduché. Transakční business logika zůstává v Django modelech, service vrstvách a request handlerech. Těžká analytika žije v SQL artefaktech, které můžete reviewovat, testovat a ladit přímo v databázi. Problémy začínají ve chvíli, kdy týmy tuhle hranici rozmažou a nutí jednu vrstvu předstírat, že je tou druhou.

Dobré pravidlo je jednoduché: pokud kód existuje hlavně proto, aby poskládal řádky do reportu, zvlášť když v tom jsou časové buckety, cohort logika, ranking, deduplikace nebo agregace, pište SQL. Pokud kód existuje hlavně proto, aby vynucoval chování aplikace, skládal doménová pravidla, validoval změny stavu nebo orchestruje workflow, nechte ho v Pythonu. Tahle hranice stárne dobře, protože každá strana používá to, v čem je přirozeně silná.

Nic z toho neznamená, že máte z Django aplikace udělat data warehouse projekt. Nepotřebujete dbt kvůli každému dashboardu ve startupu, nepotřebujete Kafka jen proto, že je jeden report pomalý, a už vůbec nemusíte mučit ORM, aby vyjádřilo sum(total_cents) filter (where status = 'paid') over (partition by customer_id order by created_at), jen protože vám raw SQL ideologicky nevoní. Ideologii si nechte na conference talky.

Pište SQL do souborů, deployujte ho přes migrace, vystavte ho přes unmanaged modely nebo cursor() tam, kde to dává smysl, dejte kolem kritických částí pgTAP a refresh sémantiku popište normálně lidsky, aby produkt i finance věděly, jestli je graf real time, po pěti minutách, nebo podle včerejší uzávěrky. Inženýři tohle zvládnou. CTO by to měli vyžadovat. Reporting stack, který nikdo neumí přečíst, je problém dávno předtím, než se z něj stane scaling problém.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Reporting nacpaný do řetězených QuerySetů je pomalý, křehký a nečitelný. Těžkou analytiku vraťte do SQL, tam vám Postgres opravdu pomůže.