Один сервис, тысячи клиентов, у каждого свои данные: границу проводят в строке (tenant_id и политика RLS), в схеме, в базе или на уровне шардов Citus. Каждая граница умножает на число клиентов своё: условие в каждом плане, строки каталога и память каждого backend, пул соединений, число миграций. На PostgreSQL 18.6 это измерено; Citus разобран по коду и документации.
Четыре границы между клиентами: что общее, что раздельное и сколько стоит
Опыт 1: 10 000 схем в одной базе
Стенд и база saas: на каждого клиента схема tN и таблица orders с первичным ключом. Одна такая таблица добавляет в pg_class четыре строки: таблицу, её TOAST-таблицу и два индекса.
docker run -d --name dba-multitenancy-patterns --memory 4g -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-multitenancy-patterns psql -U postgres -c 'CREATE DATABASE saas'
SELECT format('CREATE SCHEMA t%s', i) FROM generate_series(1,10000) i \gexec
SELECT format('CREATE TABLE t%s.orders (id bigint PRIMARY KEY, customer_id int NOT NULL,
status text, created_at timestamptz DEFAULT now())', i) FROM generate_series(1,10000) i \gexec
Создание 10 000 схем и 10 000 таблиц (20 000 команд) заняло 40,2 с (time вокруг docker exec). Размер каталога до и после:
SELECT count(*) AS pg_class_rows, pg_size_pretty(pg_relation_size('pg_class')) AS pg_class_size,
pg_size_pretty(pg_total_relation_size('pg_attribute')) AS pg_attribute_size,
(SELECT pg_size_pretty(sum(pg_total_relation_size(c.oid))) FROM pg_class c
WHERE relnamespace='pg_catalog'::regnamespace AND relkind='r') AS catalog_total
FROM pg_class;
до: pg_class_rows 415 | pg_class_size 112 kB | pg_attribute_size 688 kB | catalog_total 7784 kB
после: pg_class_rows 40415 | pg_class_size 7360 kB | pg_attribute_size 42 MB | catalog_total 100 MB
Каталог вырос в 13 раз. Запрос, который \dt *.* отправляет серверу (список таблиц всех схем), сравнён с базой small, где создана одна такая схема:
\timing on
\o /dev/null
SELECT n.nspname, c.relname, c.relkind, pg_catalog.pg_get_userbyid(c.relowner) FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r','p','t','s','') ORDER BY 1,2;
small (1 схема): Time: 0.913 ms Time: 0.882 ms Time: 1.068 ms
saas (10 000): Time: 28.212 ms Time: 29.042 ms Time: 28.284 ms
Сам \dt *.* вернул 20 107 строк. Миграция ALTER TABLE tN.orders ADD COLUMN note text в цикле по всем схемам заняла 1 мин 33 с (time, 10 000 команд), а та же команда на общей таблице из опыта 3 (2 млн строк) — Time: 15.557 ms. Очередь блокировок у каждой из 10 000 таблиц устроена так, как в статье про миграции без простоя.
Опыт 2: что запоминает backend
Соединение держит в CacheMemoryContext описание каждой таблицы и индекса, к которым обращалось (pg_backend_memory_contexts). Новая сессия обращается по первичному ключу к первым n схемам и считает память кэша и всех его потомков:
SELECT pg_backend_pid() AS pid \gset
\o /dev/null
SELECT format('SELECT 1 FROM t%s.orders WHERE id = 1', i) FROM generate_series(1,:n) i \gexec
\o
WITH cm AS (SELECT path FROM pg_backend_memory_contexts WHERE name='CacheMemoryContext')
SELECT :n AS schemas_touched, sum(c.total_bytes) AS cache_bytes, count(*) AS contexts
FROM pg_backend_memory_contexts c, cm WHERE c.path[1:array_length(cm.path,1)] = cm.path;
\setenv PID :pid
\! grep VmRSS /proc/$PID/status
schemas_touched | cache_bytes | contexts | VmRSS
1 | 1284800 | 94 | 20616 kB
100 | 2536128 | 193 | 29016 kB
1000 | 10670784 | 1093 | 69600 kB
10000 | 80221152 | 10093 | 242692 kB
На 10 000 схем кэш одного соединения — 80 221 152 байта, в среднем 7 894 байта на таблицу вместе с её индексом: (80 221 152 − 1 284 800) / 9 999. Каждый backend набирает кэш отдельно: 100 соединений пула, прошедших по всем клиентам, держали бы около 100 × 80 221 152 ≈ 8,0 ГБ (арифметика по измерению, не запуск). Тот же кэш описаний делает первый запрос сессии дорогим при 1 200 секциях в статье про партиционирование.
Второе ограничение — блокировки: транзакция, обходящая схемы, берёт блокировку на таблицу и на индекс каждой. Цикл в одной транзакции при max_locks_per_transaction = 64 и max_connections = 100:
BEGIN;
DO $$ BEGIN FOR i IN 1..10000 LOOP EXECUTE format('SELECT 1 FROM t%s.orders WHERE id = 1', i); END LOOP; END $$;
ERROR: out of shared memory
HINT: You might need to increase "max_locks_per_transaction".
В CONTEXT стояла схема t7357; на 5 000 схем та же транзакция прошла. По документации, общая таблица блокировок рассчитана на max_locks_per_transaction объектов на серверный процесс, отдельная транзакция может взять больше, пока все помещаются.
Опыт 3: RLS и общая таблица
Одна таблица на 1 000 клиентов, 2 000 строк на клиента, у клиента 42 — по 500 new и shipped и 1 000 paid. Роль app не владеет таблицей и не суперпользователь: владелец и роли с BYPASSRLS обходят политики, а владелец включает их для себя только через FORCE ROW LEVEL SECURITY (документация).
CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id int NOT NULL, email text NOT NULL,
status text NOT NULL, amount numeric(10,2) NOT NULL);
INSERT INTO orders SELECT g, g % 1000, 'user' || g || '@example.com',
(ARRAY['new','paid','shipped','paid'])[1 + (g / 1000) % 4], (g % 5000) / 10.0
FROM generate_series(1, 2000000) g;
CREATE INDEX orders_tenant_idx ON orders (tenant_id);
CREATE INDEX orders_email_idx ON orders (email text_pattern_ops);
ANALYZE orders;
CREATE ROLE app LOGIN PASSWORD 'app'; GRANT SELECT ON orders TO app;
Один запрос, два режима. Без политики условие пишет приложение; с политикой запрос его не содержит:
SET max_parallel_workers_per_gather = 0;
SET ROLE app; SET app.tenant_id = '42';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) SELECT count(*), sum(amount) FROM orders WHERE tenant_id = 42 AND status = 'paid';
RESET ROLE;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.tenant_id')::int);
SET ROLE app; SET app.tenant_id = '42';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) SELECT count(*), sum(amount) FROM orders WHERE status = 'paid';
Оставлены строки с условиями и буферами (остальное в обоих планах одинаково: Bitmap Heap Scan, Heap Blocks: exact=2000, Rows Removed by Filter: 1000):
-- без политики, tenant_id = 42 в запросе
Buffers: shared hit=2004 read=1
Recheck Cond: (tenant_id = 42)
Filter: (status = 'paid'::text)
Index Cond: (tenant_id = 42)
-- с политикой, в запросе только status
Buffers: shared hit=2005
Recheck Cond: (tenant_id = (current_setting('app.tenant_id'::text))::integer)
Filter: (status = 'paid'::text)
Index Cond: (tenant_id = (current_setting('app.tenant_id'::text))::integer)
Условие политики стало условием индекса, как ручное. Оценка строк у скана по tenant_id под политикой — rows=1993 при фактических 2 000 (EXPLAIN без ANALYZE), то есть по обычной статистике, как в статье про модель стоимости. Политику добавляет get_row_security_policies: по комментарию в rowsecurity.c её выражения становятся началом списка securityQuals таблицы, и пользовательское условие не должно выполниться раньше них, иначе его функция увидит чужие строки. Исключение — leakproof-функции, которые по документации не раскрывают данные. Класс хранится в pg_proc.proleakproof, и проверка идёт по каждому условию запроса (contain_leaked_vars). Цена видна при выборе индекса. Если у таблицы есть условия политики, а оператор не leakproof, условие не станет условием индекса (indxpath.c):
/*
* If clause can't be used as an indexqual because it must wait till after
* some lower-security-level restriction clause, reject it.
*/
if (!restriction_is_securely_promotable(rinfo, index->rel))
return;
Запрос SELECT proname, proleakproof FROM pg_proc WHERE proname IN ('texteq','textlike','numeric_eq') вернул texteq|t, textlike|f, numeric_eq|f.
Три запроса к email под ролью app для клиента 42 (клиенту принадлежит строка user1042@example.com):
ALTER TABLE orders DISABLE ROW LEVEL SECURITY; -- как app: LIKE без политики
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) SELECT id FROM orders WHERE email LIKE 'user1042@%';
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; -- как app: те же условия под политикой
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) SELECT id FROM orders WHERE email = 'user1042@example.com';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) SELECT id FROM orders WHERE email LIKE 'user1042@%';
-- RLS выключена, LIKE
Index Scan using orders_email_idx on orders (actual time=0.137..0.137 rows=1.00 loops=1)
Index Cond: ((email ~>=~ 'user1042@'::text) AND (email ~<~ 'user1042A'::text))
Filter: (email ~~ 'user1042@%'::text)
Buffers: shared hit=1 read=3
-- RLS включена, =
Index Scan using orders_email_idx on orders (actual time=0.008..0.009 rows=1.00 loops=1)
Index Cond: (email = 'user1042@example.com'::text)
Filter: (tenant_id = (current_setting('app.tenant_id'::text))::integer)
Buffers: shared hit=4
-- RLS включена, LIKE
Bitmap Heap Scan on orders (actual time=0.433..3.872 rows=1.00 loops=1)
Recheck Cond: (tenant_id = (current_setting('app.tenant_id'::text))::integer)
Filter: (email ~~ 'user1042@%'::text)
Rows Removed by Filter: 1999
Heap Blocks: exact=2000
Buffers: shared hit=2005
С = (leakproof) условие клиента осталось условием индекса, а политика стала Filter: 4 буфера. С LIKE (textlike, не leakproof) индекс по email недоступен, план идёт по индексу tenant_id и читает все 2 000 строк клиента ради одной: 2 005 буферов, 3,9 мс против 0,1 мс (как читать Buffers). numeric_eq тоже помечен f; запрос с numeric не запускался.
Переменная app.tenant_id ставится командой SET LOCAL внутри транзакции. Обычный SET остаётся в соединении после её конца:
SET app.tenant_id = '42' (без LOCAL), после запроса: current_setting = 42
BEGIN; SET LOCAL ...; COMMIT; затем SELECT count(*): ERROR: invalid input syntax for type integer: ""
Новая сессия, переменная не задана: ERROR: unrecognized configuration parameter "app.tenant_id"
Без LOCAL значение остаётся в серверном соединении, а PgBouncer в режиме transaction возвращает его в пул после транзакции и отдаёт другому клиенту. С LOCAL забытая переменная даёт ошибку, а не чужие строки.
Расчёт: база на клиента и пул соединений
Соединение привязано к одной базе, поэтому пул PgBouncer ведётся отдельно для каждой пары пользователь-база: default_pool_size — «максимум серверных соединений на пару user/database», по умолчанию 20, а max_db_connections по умолчанию 0 (не ограничен), см. документацию. Сервер из опыта: SHOW max_connections вернул 100. Арифметика: при пуле 20 пять баз с полной нагрузкой занимают все 100 соединений; если пул каждой из 1 000 баз держит хотя бы одно соединение, это 1 000 серверных соединений, в 10 раз больше лимита. Это расчёт по документации, PgBouncer в опыте не запускался.
Citus: распределение по tenant_id
Citus (citusdata/citus, последний тег v14.3.0 на 28.10.2026) делит таблицу на шарды по значению ключа распределения (tenant_id) и держит связанные таблицы рядом: create_distributed_table('page', 'tenant_id', colocate_with => 'event') (документация). Запрос, где ключ стоит в WHERE равенством, планировщик отправляет в один шард, не обращаясь к standard_planner(). Условия описаны в комментарии FastPathRouterQuery (fast_path_router_planner.c):
* - SELECT/UPDATE/DELETE query without CTES, sublinks-subqueries, set operations
* - The query should touch only a single hash distributed or reference table
* - The distribution with equality operator should be in the WHERE clause
* and it should be ANDed with any other filters. Also, the distribution
* key should only exist once in the WHERE clause. So basically,
* SELECT ... FROM dist_table WHERE dist_key = X
Документация Citus (сравнение подходов, проверена 28.10.2026) называет для шардирования по схемам от 1 до 10 тыс. клиентов, для шардирования по строкам — от 1 до миллиона и больше; для строк требуется столбец tenant_id в каждой таблице и в ключах, а WHERE и соединения обязаны его содержать. Расширение не запускалось: в статье — только код и документация.
Что из этого следует
- Клиентов сотни и тысячи, схема у всех одна — общая таблица с
tenant_idи RLS. ПроверяйEXPLAINна запросах сLIKEи другими не leakproof условиями: они уходят вFilter(2 005 буферов против 4). - Схема на клиента подходит, когда клиентов десятки или сотни, а схемы различаются. На 10 000 схем каталог вырос в 13 раз,
\dt *.*занимал 28 мс, backend копил по 7 894 байта на таблицу, миграция шла 93 с вместо 15,6 мс. - База на клиента даёт свой пул и каталог, но число активных баз ограничено
max_connections/pool_size. SET LOCAL app.tenant_idв транзакции, а неSETв сессии, особенно за PgBouncer в режимеtransaction.- Рост за пределы одного сервера при общей схеме — Citus с шардированием по
tenant_id.
Чек-лист
- Посчитай
SELECT count(*) FROM pg_classи размер каталога: если схем и таблиц тысячи, замерь\dt *.*и миграции по всем схемам. - Для общей таблицы поставь
tenant_idпервым столбцом нужных индексов и проверь вEXPLAIN, что условие клиента остаётся условием индекса при реальных операторах приложения. - Найди в запросах
LIKEи функции над столбцами и сверь их сpg_proc.proleakproof. - Для базы на клиента умножь число баз на
pool_sizeи сравни соSHOW max_connections. - Убери стенд командой
docker rm -f -v dba-multitenancy-patterns: без-vтом с данными остаётся на диске.