
• Оглавление
Мультиарендность — это когда несколько клиентов (арендаторов, «тенантов») используют один и тот же инстанс приложения и базу, но их данные строго изолированы. Бизнесу это даёт:
Главный риск — смешение данных. В этой статье разберём, как избежать этого в PostgreSQL с помощью Row Level Security (RLS) и продуманной схемы.
Есть три частых варианта:
Мы сосредоточимся на варианте (1) и покажем, как добиться жёсткой изоляции политиками RLS.
Основные принципы:
Пример:
-- UUID удобен для безопасности и простоты внешних ключей
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Заказы с составным ключом: внутри арендатора айди может быть последовательностью,
-- а глобально уникальность даёт пара (tenant_id, id)
CREATE TABLE orders (
tenant_id uuid NOT NULL REFERENCES tenants(id),
id bigint NOT NULL,
number text NOT NULL,
status text NOT NULL CHECK (status IN ('new','paid','canceled')),
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id),
UNIQUE (tenant_id, number)
);
-- Индексы “с tenant_id слева” ускоряют выборки под RLS
CREATE INDEX ON orders (tenant_id, created_at);
CREATE INDEX ON orders (tenant_id, status);
RLS позволяет ограничить видимость строк на уровне таблицы. Мы привяжем арендатора к пользовательской настройке сеанса app.tenant_id и напишем политики.
-- Включаем RLS на таблицу и принуждаем её даже для владельца таблицы
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Политика чтения: видны только строки своего арендатора.
-- current_setting(..., true) вернёт NULL, если переменная не задана.
CREATE POLICY r_orders_tenant_read ON orders
FOR SELECT
USING (
current_setting('app.tenant_id', true) IS NOT NULL
AND tenant_id = current_setting('app.tenant_id', true)::uuid
);
-- Политика записи: вставлять/обновлять можно только в рамках своего арендатора
CREATE POLICY r_orders_tenant_write ON orders
FOR INSERT TO PUBLIC
WITH CHECK (
current_setting('app.tenant_id', true) IS NOT NULL
AND tenant_id = current_setting('app.tenant_id', true)::uuid
);
CREATE POLICY r_orders_tenant_update ON orders
FOR UPDATE
USING (
current_setting('app.tenant_id', true) IS NOT NULL
AND tenant_id = current_setting('app.tenant_id', true)::uuid
)
WITH CHECK (
current_setting('app.tenant_id', true) IS NOT NULL
AND tenant_id = current_setting('app.tenant_id', true)::uuid
);
Политики «по умолчанию» — запретительные: если ничего не задано, запрос не увидит ни одной строки и не сможет их менять.
Дополнительно можно задать дефолт для tenant_id, берущийся из GUC‑переменной, чтобы снизить риск забыть подставить колонку при вставке:
ALTER TABLE orders
ALTER COLUMN tenant_id SET DEFAULT current_setting('app.tenant_id', true)::uuid;
Важно: tenant_id должен задаваться как параметр сеанса/транзакции, а не подмешиваться в SQL вручную.
Паттерн:
Пример на Go с pgxpool:
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
tenantID := "1f7e0b6a-3e4a-4a7d-b2a7-0ad50d0299aa"
// Обычная транзакция под арендатора
tx, err := pool.Begin(ctx)
if err != nil {
log.Fatal(err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET LOCAL app.tenant_id = $1", tenantID); err != nil {
log.Fatal(err)
}
// Вставка: tenant_id подставится по DEFAULT либо проверится политикой
_, err = tx.Exec(ctx, `
INSERT INTO orders (tenant_id, id, number, status, amount)
VALUES (DEFAULT, $1, $2, 'new', $3)
`, 1001, "ORD-1001", 19.99)
if err != nil {
log.Fatal(err)
}
// Чтение: увидим только свои строки
var count int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM orders WHERE status='new'`).Scan(&count); err != nil {
log.Fatal(err)
}
fmt.Println("new orders visible for tenant:", count)
if err := tx.Commit(ctx); err != nil {
log.Fatal(err)
}
// Проверка: без tenant_id ничего не видно
var zero int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM orders`).Scan(&zero); err != nil {
log.Fatal(err)
}
fmt.Println("without tenant set, visible rows:", zero)
_ = time.Second // просто, чтобы импорт time не ругался если не нужен
}
Почему SET LOCAL? Потому что при пуллинге соединений важно не «залипать» с tenant_id на сессию. После коммита переменная исчезает, и следующее использование соединения безопасно.
Если вы используете pgbouncer в режиме transaction pooling, следите, чтобы SET выполнялся внутри каждой транзакции. Для session pooling — очищайте состояние соединения (server_reset_query) или используйте SET LOCAL в каждой транзакции, как выше.
Пример партиционирования:
CREATE TABLE events (
tenant_id uuid NOT NULL,
id bigserial NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
) PARTITION BY HASH (tenant_id);
-- 16 партиций
DO $$
DECLARE i int;
BEGIN
FOR i IN 0..15 LOOP
EXECUTE format(
'CREATE TABLE events_p%1$s PARTITION OF events FOR VALUES WITH (MODULUS 16, REMAINDER %1$s);', i);
END LOOP;
END $$;
-- Индексы создавайте на родительской таблице — они раскатятся на все партиции
CREATE INDEX ON events (tenant_id, created_at);
Проверяйте планы, чтобы убеждаться, что фильтр по tenant_id применяется рано:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status='new' LIMIT 50;
Если на таблице включён RLS, планировщик учитывает политики как дополнительные условия. Но иногда полезно явно добавлять tenant_id в WHERE (не нарушая инкапсуляцию), если вы пишете особо тяжёлые отчёты для админ‑ролей без BYPASSRLS.
Варианты:
Важно: если функция SECURITY DEFINER принадлежит владельцу таблицы, по умолчанию она может обойти RLS. Поэтому:
Пример «безопасной» отчётной функции по конкретному арендатору:
CREATE OR REPLACE FUNCTION tenant_sales_sum(p_tenant uuid)
RETURNS numeric LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE s numeric;
BEGIN
PERFORM set_config('app.tenant_id', p_tenant::text, true);
SELECT COALESCE(sum(amount),0) INTO s FROM orders WHERE status='paid';
RETURN s;
END;$$;
REVOKE ALL ON FUNCTION tenant_sales_sum(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION tenant_sales_sum(uuid) TO reporting_role;
План миграции без простоя:
Простая проверка через SQL:
-- Задаём двух арендаторов
INSERT INTO tenants (id, name) VALUES
('11111111-1111-1111-1111-111111111111', 'alpha'),
('22222222-2222-2222-2222-222222222222', 'beta');
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO orders (id, number, status, amount) VALUES (1, 'A-1', 'new', 10.00);
RESET ALL; -- сбросили переменные сеанса
SET LOCAL app.tenant_id = '22222222-2222-2222-2222-222222222222';
-- Попробуем увидеть чужой заказ
SELECT * FROM orders WHERE number='A-1'; -- ожидаем 0 строк
С pgTAP удобно автоматизировать сценарии: выставили tenant_id, вставили/прочитали, проверили изоляцию и ошибки при неверном tenant_id.
Пример Lua‑скрипта для Redis, который обновляет кэш заказа только внутри нужного арендатора:
-- file: upsert_order_cache.lua
-- KEYS[1] = key, ARGV[1] = tenant_id, ARGV[2] = payload, ARGV[3] = ttl
local meta = redis.call('HGET', KEYS[1], 'tenant')
if meta ~= false and meta ~= ARGV[1] then
return {err = 'tenant_mismatch'}
end
redis.call('HSET', KEYS[1], 'tenant', ARGV[1])
redis.call('HSET', KEYS[1], 'data', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 'ok'
RLS в PostgreSQL позволяет держать данные нескольких клиентов в одной базе без компромисса по изоляции. С правильными политиками, индексами и дисциплиной в прокидывании tenant_id вы получите:
Эта стратегия хорошо масштабируется и по скорости, и по организационным усилиям — при условии, что придерживаться описанных практик с первого дня внедрения.