14 min čteníJohnny UnarJohnny Unar

Multi-tenant Postgres 16: berte RLS jako výchozí volbu, dokud se neprokáže opak

Většina SaaS týmů by měla v Postgresu 16 začít se sdílenými tabulkami, tenant_id a Row Level Security. Těžká část není SQL, ale pooling, migrace a provozní disciplína.

začněte tím, co dává smysl jako default

Většina B2B SaaS týmů by měla zvolit sdílené tabulky s column tenant_id a k tomu Row Level Security, ideálně hned na začátku. Udrží vám to jednoduchý datový model, aplikace zůstane nudná v tom dobrém slova smyslu a neotevřete si provozní peklo, které začne ve chvíli, kdy se z každého tenanta stane vlastní sněhová vločka se separátním DDL driftem, vlastními migracemi, vlastním backup příběhem a vlastními incidenty ve dvě ráno. Pořád vídám týmy, které sáhnou po schema-per-tenant, protože to působí bezpečněji. O půl roku později mají 1400 schémat, Prisma nebo Django migrace trvají věčnost, pg_dump joby potřebují custom filtrování a jeden enterprise zákazník, který chce hotfix, vám z toho udělá problém s migrací do stovek míst.

RLS má špatnou pověst hlavně u lidí, kteří ho nasadili napůl. Typicky nastaví tenant kontext na connection a před to dají PgBouncer v transaction mode. Přesně takhle si zaděláte na leak kontextu mezi requesty nebo na náhodné ERROR: unrecognized configuration parameter "app.tenant_id", protože session state nebyl to, co jste si mysleli. To není problém RLS. To je ops problém.

Ve Steezru jsme tenhle pattern použili v customer portálech, interních ERP systémech i AI backendech na zpracování dokumentů, kde nakonec tenant význam přiroste skoro ke každé tabulce, včetně těch, o kterých všichni na začátku tvrdili, že budou navždy globální. Shared-table multi-tenancy dovolí malému seniornímu týmu shipovat rychle, protože každý feature jde přes jednu code path, jeden migrační proud a jeden observability setup. Pořád to chce disciplínu, hlavně kolem composite indexů, cizích klíčů a connection managementu, ale ten payoff je obrovský. Fyzická izolace začne vyhrávat až ve chvíli, kdy máte smluvní požadavky, noisy neighbors, které nejdou zkrotit, tenant-specific rozšíření nebo data residency omezení, která se do jednoho clusteru nevejdou.

Moje výchozí rada je jednoduchá: začněte s RLS, vynucujte tenant_id všude a právo přesunout pár těžkých tenantů do izolovaných schémat nebo databází si zaslužte až později.

kde schémata opravdu dávají smysl

Schema-per-tenant má úzký, ale legitimní use case. Týmy, kterým to funguje, obvykle přesně vědí, proč to zvolily. Mají malý počet vysoce hodnotných tenantů, každý potřebuje vlastní objekty nebo vlastní release timing a provozní overhead je přijatelný, protože tenantů jsou desítky, ne tisíce. V takovém světě může být schéma rozumná hranice. Tenanta přesunete pomocí pg_dump --schema=tenant_acme nebo logické replikace, přístupy udělíte explicitněji a o storage přemýšlíte o něco víc fyzicky.

To ale neznamená, že jsou schémata příjemná. Růst katalogu je reálný problém. Tooling začne být divný. ORM často předpokládají jeden search_path nebo jednu historii migrací a pak vás potichu zradí. Bugy kolem search_path jsou nepříjemné hlavně proto, že často failují otevřeně, ne bezpečně. Někdo zapomene schema-qualify funkci nebo sekvenci, testy projdou, production pošle jednoho tenanta na špatný objekt a jste přesně v tom typu incidentu, kde každá odpověď začíná větou „věříme, že dopad byl omezený“. Takový mail nechce psát nikdo.

Spousta vyspělejších startupů nakonec skončí u hybridního modelu. Shared tables a RLS pro hlavní aplikaci, k tomu jeden nebo dva velcí tenanti přesunutí do dedikovaných schémat nebo samostatných databází, protože potřebují vlastní retention policy, regionální deployment nebo tak silně tlačí write throughput, že izolace začne dávat ekonomický smysl. To je rozumný cíl. Jako výchozí stav je to ale katastrofa. Hybrid násobí počet provozních větví a přesně v nich umírá rychlost.

Schémata používejte tehdy, když potřebujete něco jako fyzickou izolaci uvnitř jedné databáze. Samostatné databáze používejte tehdy, když si tenant opravdu zaslouží vlastní blast radius. Pro běžnou cestu použijte RLS. V praxi to funguje dobře.

migrace, která opravdu funguje

Čistá migrační cesta ze single-tenant tabulek na multi-tenant RLS je nudná, postupná a plná backfillů. To je dobře. Nudné migrace většinou doběhnou do konce.

Předpokládejme, že jste začínali s něčím jako accounts, projects, invoices, bez skutečné tenant hranice, maximálně s nějakým account_id, který tak nějak naznačuje vlastnictví. První krok je přidat reálnou tenant tabulku a dostat tenant_id na každý řádek, který tenantovi patří. Nejdřív nullable, potom backfill po dávkách a teprve pak utažení pravidel.

sql
1create table tenant (
2 id uuid primary key default gen_random_uuid(),
3 slug text not null unique,
4 created_at timestamptz not null default now()
5);
6
7alter table project add column tenant_id uuid;
8alter table invoice add column tenant_id uuid;
9
10update project p
11set tenant_id = a.tenant_id
12from account a
13where p.account_id = a.id
14 and p.tenant_id is null;

Velké backfilly chunkujte. Pokud má tabulka 80 milionů řádků, nepouštějte jeden obří update a nedoufejte. Použijte rozsahy primárních klíčů nebo batching přes ctid z aplikace nebo maintenance skriptu, commitujte každých pár tisíc řádků a během běhu sledujte pg_stat_activity, pg_locks a replica lag. Dlouhé transakce vás doběhnou později, protože drží mrtvé tuple a natahují vacuum práci klidně do dalšího týdne.

Jakmile je backfill hotový, přidejte constrainty způsobem, který pokud možno neblokuje všechno kolem.

sql
1alter table project
2 add constraint project_tenant_fk
3 foreign key (tenant_id) references tenant(id) not valid;
4
5alter table project validate constraint project_tenant_fk;
6
7alter table project
8 alter column tenant_id set not null;

Pak opravte unikátnost. Globální unique indexy jsou v multi-tenant světě klasická past. users(email) bude nejspíš potřeba změnit na (tenant_id, email), pokud váš produkt skutečně nevyžaduje globální unikátnost.

sql
1create unique index concurrently user_tenant_email_uniq
2 on app_user (tenant_id, lower(email));

Další krok je upravit všechny cizí klíče mezi tenant tabulkami tak, aby tam, kde to dává smysl, zahrnovaly i tenant_id. Jednosloupcové FK dovolí cross-tenant reference, pokud někdo špatně odhadne nebo zkopíruje id. Bezpečnější pattern jsou composite klíče.

sql
1alter table project add constraint project_tenant_id_id_uniq
2 unique (tenant_id, id);
3
4alter table invoice
5 add constraint invoice_project_fk
6 foreign key (tenant_id, project_id)
7 references project (tenant_id, id)
8 not valid;
9
10alter table invoice validate constraint invoice_project_fk;

V tu chvíli můžete zapnout RLS, napsat policy a vynutit, aby přes ně aplikace skutečně chodila. Jednu admin roli s BYPASSRLS si nechte pro maintenance joby, držte ji dál od request trafficu a projděte každé místo, které ji může převzít.

rls bez sabotáže z vlastní strany

RLS samo o sobě je jednoduché. O tom, jestli jednoduché i zůstane, rozhodují pravidla kolem.

Mně dává smysl dedikovaný setting pro tenant kontext, protože SQL zůstane explicitní a aplikace nemusí sypat tenant predikát do každého query. V Postgresu 16 se pořád můžete spolehnout na current_setting(..., true) uvnitř policy, které vrátí null místo exploze, když setting není nastavený. Během migrace se s tím chyby chápou výrazně líp.

sql
1alter table project enable row level security;
2alter table invoice enable row level security;
3alter table project force row level security;
4alter table invoice force row level security;
5
6create policy tenant_isolation_project on project
7 using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
8 with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
9
10create policy tenant_isolation_invoice on invoice
11 using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
12 with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

V aplikaci pak každý request začne transakci a nastaví kontext přes SET LOCAL, ne SET, protože SET LOCAL umře s transakcí a nepřeteče do dalšího requestu na znovu použité connection.

sql
1begin;
2set local app.tenant_id = '6f0b7f9d-6a1f-4c2c-b2ae-0d7b8a2b98ab';
3select id, name from project order by created_at desc limit 20;
4commit;

Tenhle detail je s PgBouncerem zásadní. V transaction pooling mode session state po commitu není vaše. Lidi tohle přehlédnou a pak několik dní loví fantomové autorizační bugy. Pokud váš framework neumí jednoduše per-request transakce, opravte to dřív, než začnete rolloutovat RLS. Django 6.x to zvládne čistě přes middleware a transaction.atomic(). Ve stacku s FastAPI nebo async SQLAlchemy obalte request unit of work a pošlete SET LOCAL na stejnou connection svázanou s transakcí ještě před tím, než odpálíte první ORM query.

Ještě jedna věc: testujte negativní cestu. Udělejte fixture pro tenanta A a tenanta B, pusťte stejné repository metody pod oběma a ověřte, že přes hranici neproleze ani jeden řádek. Do CI přidejte kontrolu, že každá tenant tabulka má zapnuté RLS a nastavené force row level security. Lidi zapomínají. Databáze ne.

pgbouncer a pooling

Většina hororových historek o RLS jsou ve skutečnosti historky o PgBounceru. Transaction pooling je pořád správná volba pro hodně webového trafficu, protože dává výrazně lepší connection density, hlavně na menších Postgres serverech, kde by max_connections=500 jen proměnilo RAM v zoufalství. Jen tomu musíte přizpůsobit aplikaci. To znamená explicitní transakce kolem práce requestu, SET LOCAL pro tenant kontext a žádné předpoklady, že prepared statements přežijí věčně, pokud na to nemáte správně nastavený driver a pooling mode.

Minimální PgBouncer konfigurace pro tenhle pattern vypadá takhle:

ini
1[databases]
2app = host=127.0.0.1 port=5432 dbname=app
3
4[pgbouncer]
5pool_mode = transaction
6max_client_conn = 2000
7default_pool_size = 50
8reserve_pool_size = 20
9reserve_pool_timeout = 3
10server_reset_query = DISCARD ALL
11server_reset_query_always = 0
12ignore_startup_parameters = extra_float_digits,options

DISCARD ALL pomáhá při vracení connection uklidit session bordel, ale nezachrání špatné chování aplikace uvnitř transakce. Něco to stojí, ale u request-response workloadů je ta cena obvykle v pohodě. Pokud přes drivery, které si s transaction poolingem nerozumí, posíláte hodně prepared statements, uvidíte chyby jako prepared statement "__asyncpg_stmt_a__" does not exist nebo podivné výkonové propady kvůli opakovanému parse/plan churnu. Řešení je několik: vypnout server-side prepares v driveru, doladit statement cache nebo si nechat oddělenou session-pooled cestu pro migrace, admin úlohy a workloady, které sticky sessions opravdu potřebují.

My traffic typicky dělíme. Requesty aplikace jdou přes transaction pooling. Background workeři s těžší transakční logikou můžou mít vlastní PgBouncer database entry, někdy session pooled, někdy direct. Migrace jdou přímo do Postgresu nebo do přísně kontrolovaného session poolu, protože schema změny a transaction pooling je kombinace, kde ostré hrany rychle přibývají.

Sledujte taky idle in transaction. RLS spolu s SET LOCAL svádí k tomu obalit všechno transakcí, což je v pořádku do chvíle, než někdo otevře transakci a pak čeká na HTTP call nebo upload do S3. V tu chvíli autovacuum nemůže uklidit tuple změněné po daném snapshotu, locky visí déle, než čekáte, a máte incident, který začal jednou línou service metodou.

indexy, vacuum a ošklivé failure modes

Postgres 16 zvládá multi-tenant shared tables velmi dobře, pokud si přestanete nalhávat, že tenant predikát je volitelný. Skoro každý hot query v aplikaci by měl mít tenant_id na začátku indexu, protože skutečný access pattern málokdy vypadá jako where status = 'open'. Ve skutečnosti spíš řešíte where tenant_id = $1 and status = 'open' order by created_at desc limit 50. Stavte indexy podle reálného workloadu.

sql
1create index concurrently invoice_tenant_created_idx
2 on invoice (tenant_id, created_at desc);
3
4create index concurrently invoice_tenant_status_created_idx
5 on invoice (tenant_id, status, created_at desc)
6 where deleted_at is null;

Tenhle partial index má na soft-delete tabulce často větší hodnotu než nějaký obecný index. Počet indexů ale držte na uzdě. Shared tables s velkým množstvím secondary indexů násobí cenu write operací pro všechny tenanty a jeden přerostlý enterprise tenant může trestat všechny ostatní.

Autovacuum tuning začíná být důležitý dřív než v jiných modelech, protože bloat od jednoho tenanta s vysokým churnem se hromadí ve stejném heapu a indexech, na které sahají všichni. U nejvytíženějších tabulek často nastavujeme per-table parametry místo globální paniky.

sql
1alter table invoice set (
2 autovacuum_vacuum_scale_factor = 0.02,
3 autovacuum_analyze_scale_factor = 0.01,
4 autovacuum_vacuum_threshold = 5000,
5 autovacuum_analyze_threshold = 5000
6);

Sledujte pg_stat_user_tables.n_dead_tup, last_autovacuum, vacuum_count a růst velikosti indexů. Když se bloat utrhne, reindex index concurrently nebo pg_repack vás umí zachránit bez dlouhého výpadku, i když pg_repack má vlastní provozní rituály. Další past jsou velké delete operace. Když offboardujete tenanta a smažete 200 milionů řádků ze shared tables, necháte za sebou vlnu bloatu a WAL. Pokud potřebujete, nejdřív archivujte, mažte po dávkách a počítejte s následným vacuum.

Migrační průšvihy jsou bolestivě předvídatelné. Když přidáte not null moc brzy, zablokujete write operace. Když na hot tabulce vytvoříte index bez concurrent režimu, zablokujete je ještě víc. Validace cizích klíčů během špičky stáhne latenci dolů. Dlouhé read transakce na replikách brzdí cleanup. Jedna zapomenutá code path, která vkládá řádky bez tenant_id, začne házet new row violates row-level security policy for table "invoice". Je to otravné, ale aspoň to failuje bezpečně. Tenhle typ bolesti beru.

kdy jít dál než k rls

RLS většině startupů vydrží hodně dlouho. Přestává stačit většinou dřív z organizačních nebo smluvních důvodů než z technických. Jeden tenant začne vyžadovat oddělené encryption keys a přísné hranice mezi operátory. Regulovaný zákazník potřebuje data připíchnutá do regionu, který dnes nepoužíváte. Jeden tenant generuje 70 % write trafficu a jeho měsíční import promění autovacuum v permanentní požár na pozadí. To jsou dobré důvody pro izolaci.

Dobrá zpráva je, že čistý RLS design vám nechává únikovou cestu. Každý řádek už má tenant_id. Composite klíče už nesou vlastnictví. Query už s tenant scope počítají. Přesun jednoho tenanta do dedikovaného schématu nebo databáze je pak extrakční projekt, ne redesign.

Nejjednodušší cesta bývá logický export plus dual-write nebo krátký write-freeze při cutoveru. Vytvoříte cílové schéma nebo databázi, přehrajete schema migrace, zkopírujete tenant data v pořadí podle závislostí, ověříte počty a checksumy, na chvíli zablokujete zápisy, dosynchronizujete zbytek, přepnete routing a necháte si read-only fallback okno. Při přesunu do samostatné databáze může pomoct logická replikace, pokud tomu nahrávají primární klíče a update patterny. Spousta týmů to ale zbytečně překombinuje, i když by pečlivě naplánované maintenance okno bylo levnější a bezpečnější.

Můj názor se po letech v produkci nemění. Začněte se shared tables, vynucujte tenant disciplínu v databázi, použijte RLS, provozujte PgBouncer v transaction mode s explicitními transakcemi a investujte do indexů a vacuum dřív, než začne bolet. Těžké tenanty vyvádějte bokem teprve tehdy, až prokážou, že si speciální zacházení opravdu zaslouží. Většina z nich si ho nezaslouží. A s těmi, které ano, se vám bude pracovat snáz, protože jste nezačali architekturou optimalizovanou na edge cases, která zdaní každý normální případ.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Většina SaaS týmů by měla v Postgresu 16 začít se sdílenými tabulkami, tenant_id a Row Level Security. Těžká část není SQL, ale pooling, migrace a provozní disciplína.