Справочник DBA: базы данных изнутри читается по порядку

Статья 7.8 · раздел 7 «Проектирование схемы и данных» · 54 из 109

Мультитенантность в PostgreSQL: tenant_id и RLS, схема или база на клиента, Citus и цена каждого

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 с --memory 4g, настройки по умолчанию (max_connections = 100, max_locks_per_transaction = 64); опыт 1 и 2 — 10 000 схем по одной таблице, опыт 3 — 2 млн строк (284 МБ) и 1 000 клиентов; PgBouncer и Citus не запускались: пул посчитан по документации, Citus разобран по коду v14.3.0 и документации; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Один сервис, тысячи клиентов, у каждого свои данные: границу проводят в строке (tenant_id и политика RLS), в схеме, в базе или на уровне шардов Citus. Каждая граница умножает на число клиентов своё: условие в каждом плане, строки каталога и память каждого backend, пул соединений, число миграций. На PostgreSQL 18.6 это измерено; Citus разобран по коду и документации.

DBA / 01

Четыре границы между клиентами: что общее, что раздельное и сколько стоит

Четыре схемы изоляции клиентов и измеренная цена каждой Четыре колонки. Первая: tenant_id и RLS, общие таблицы, индексы, каталог и пул, раздельные строки; запрос с LIKE под политикой читает 2 005 буферов против 4. Вторая: схема на клиента, общие база, роли, пул и WAL, раздельные таблицы и индексы; при 10 000 схем pg_class занимает 7,2 МБ против 112 кБ. Третья: база на клиента, общие сервер, WAL и диск, раздельные каталог, пул и кэши; при max_connections 100 и пуле 20 хватает пяти баз. Четвёртая: Citus, общие координатор и reference-таблицы, раздельные шарды по tenant_id; запрос с tenant_id = X уходит на один шард по коду планировщика, без запуска. tenant_id + RLS схема на клиента база на клиента Citus общее таблицы, индексы, каталог, пул раздельное строки по tenant_id общее база, роли, пул, WAL раздельное таблицы и индексы общее сервер, WAL, диск раздельное каталог, пул, кэши общее координатор, reference-таблицы раздельное шарды по tenant_id 2 005 против 4 буферов: LIKE под RLS и = под RLS 7,2 МБ pg_class, 10 000 схем (было 112 кБ) 100 / 20 = 5 баз заполнят все max_connections 1 шард если в WHERE есть tenant_id (код, док.)
Чем глубже граница, тем больше служебной работы умножается на число клиентов: каталог и память backend в схемах, пул в базах. Общая таблица с RLS дешевле, но граница держится условием в плане, и LIKE под политикой читает 2 005 буферов вместо 4. 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.

Чек-лист

  1. Посчитай SELECT count(*) FROM pg_class и размер каталога: если схем и таблиц тысячи, замерь \dt *.* и миграции по всем схемам.
  2. Для общей таблицы поставь tenant_id первым столбцом нужных индексов и проверь в EXPLAIN, что условие клиента остаётся условием индекса при реальных операторах приложения.
  3. Найди в запросах LIKE и функции над столбцами и сверь их с pg_proc.proleakproof.
  4. Для базы на клиента умножь число баз на pool_size и сравни со SHOW max_connections.
  5. Убери стенд командой docker rm -f -v dba-multitenancy-patterns: без -v том с данными остаётся на диске.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru