golang

PostgreSQL RLS в Go, Часть 2: Архитектура Highload. Паника, гонки и 10 000 партиций

  • среда, 28 января 2026 г. в 00:00:13
https://habr.com/ru/articles/988744/

В первой части было разобрано, как настроить RLS в Go, почему is_local=true спасает от утечек в PgBouncer, и как покрыть это интеграционными тестами. Если вы еще не настроили базовую изоляцию, начните оттуда.

Сегодня пойдем глубже. Не будем говорить о синтаксисе. Поговорим об архитектуре. О том, почему RLS - это не просто "удобный фильтр", а нативный механизм инкапсуляции, который решает проблемы распределенных систем и рисков безопасности прямо в слое данных, не раздувая Ops-сложность до сотен схем и баз.

Кому и зачем читать?

Junior/Middle: Поймете, почему RLS - это архитектурный выбор, а не просто WHERE tenant_id = ?. Узнаете про TOCTOU и как его избежать.

Senior/Architect: Увидите паттерны для highload (партиционирование + RLS) и production-грабли (матвьюхи, репликация).

TL;DR ключевых идей:

  1. RLS = встроенный в БД security layer, а не фильтр.

  2. Паниковать нужно только для чувствительных данных.

  3. 10k партиций - можно, но сложно; Hash partitioning - золотая середина.

  4. RLS защищает от межтенантных утечек при SQL-инъекциях, но не от инъекций в самой политике.

1. Reliability: Философия Panic и Fail Fast

В первой части мы установили жесткое правило, нет контекста - нет транзакции. Но почему так радикально?

В мире разработки есть два подхода к ошибкам:

  1. Graceful Degradation (Изящная деградация): "Сервис авторизации тупит? Ну, покажем пользователю кешированный профиль или пустой список, лишь бы не 500-ка".

  2. Fail Secure (Отказ в безопасное состояние): "Не уверен в правах пользователя. Останавливаю работу немедленно".

Для UI "изящная деградация" - это хорошо. Для слоя данных - катастрофа.

Почему был выбран Panic?

Представьте, что миддлварь из-за бага пропустил запрос без tenant_id.

Если "сгладим" углы и вернем NULL, бизнес-логика может интерпретировать это, как "Новый пользователь без заказов" и начать создавать дубликаты записей или, хуже того, перезаписывать глобальные настройки.

RLS позволяет реализовать принцип Transaction Suicide. Если база данных видит, что контекст безопасности нарушен (переменная не задана или имеет неверный формат), она убивает транзакцию.

-- Пример "параноидальной" функции контекста

CREATE OR REPLACE FUNCTION current_tenant_strict() RETURNS uuid AS $$

BEGIN

    -- Не возвращаем NULL. Стреляем на поражение.

    RETURN current_setting('app.tenant_id')::uuid;

EXCEPTION

    WHEN OTHERS THEN

        RAISE EXCEPTION 'RLS Policy Violation: Security Context Missing';

END;

$$ LANGUAGE plpgsql STABLE;

/*

Практический совет: Не применяйте панику слепо. 
Используйте градацию:

    1. PANIC (current_tenant_strict()): для платежей, персональных данных или любых других важных данных

    2. STRICT (возвращаем NULL UUID): для обычных бизнес-данных  

    3. LAX (возвращаем NULL): для публичных каталогов, аналитики

Пример для публичных данных:

CREATE FUNCTION current_tenant_or_public() RETURNS uuid AS $$

BEGIN

    RETURN COALESCE(

        NULLIF(current_setting('app.tenant_id', true), '')::uuid,

        '00000000-0000-0000-0000-000000000000'::uuid

    );

END;

$$ LANGUAGE plpgsql;

*/

Это переводит ошибки безопасности из разряда "тихих багов" в разряд "громких инцидентов", которые чинятся в первые минуты после деплоя.

Напоминание из первой части: Чтобы эта защита работала в пулере соединений (PgBouncer), критически важно использовать set_config(..., is_local => true). Без этого переменная "утечет" к соседу. Подробнее механика расписана в прошлой статье.

2. Consistency: Убийца Race Conditions

Вот где RLS сияет по-настоящему. Большинство разработчиков даже не осознают, что у них есть дыры класса TOCTOU (Time-of-Check to Time-of-Use).

Сценарий атаки

У вас есть документ. Юзер Алиса хочет его удалить. Админ одновременно отзывает у Алисы права.

Типичный код приложения:

// 1. Time of Check

if !user.CanEdit(docID) {

    return Error("Нет прав") // (A)

}

// ... в эту наносекунду админ коммитит транзакцию по отзыву прав ...

// 2. Time of Use

db.Delete(docID) // Упс, документ удален, хотя прав уже нет

Между точкой А и точкой Б есть временное окно. В распределенной системе (где проверка прав может быть вообще в микросервисе Auth) это окно может достигать миллисекунд.

Чтобы закрыть его в коде, нужны распределенные локи (Redis Redlock) или уровень изоляции SERIALIZABLE для всей БД (привет, дедлоки).

Решение RLS

RLS переносит проверку (Check) внутрь действия (Use).

Когда вы делаете:

DELETE FROM documents WHERE id = 1;

Postgres выполняет это атомарно:

  1. Блокирует строку (Row Lock) для изменения

  2. В той же транзакции, в том же кванте времени проверяет политики RLS (USING)

  3. Если политика теперь возвращает false, строка для операции DELETE перестает существовать

  4. Счетчик удаленных строк: 0

Вы получаете гарантии консистентности на уровне БД без единого лока в приложении.

До RLS:

Запрос → Проверка прав в коде → Запрос к БД → Race condition возможен

После RLS:

Запрос → Установка контекста → Запрос к БД → Проверка прав в БД (атомарно)

Проблема

Без RLS

С RLS

Race conditions

Требует локов

Решено на уровне БД

SQL-инъекции

Полная утечка

Только свои данные

Сложность кода

Высокая

Низкая

Решение RLS (с оговорками)

RLS переносит проверку (Check) внутрь действия (Use), что закрывает базовый класс TOCTOU-уязвимостей.

Но важно:

  • Защита работает в пределах одной БД (не поможет в микросервисной архитектуре)

  • Зависит от уровня изоляции транзакций (лучше всего с READ COMMITTED)

  • Не заменяет блокировки для сложных бизнес-правил

Для 99% случаев RLS достаточно. Для финансовых транзакций требуется SERIALIZABLE изоляция или FOR UPDATE с соблюдением порядка локирования.

Defense in Depth: SQL Injection

И еще один бонус. Даже если (не дай бог) у вас есть SQL-инъекция. Злоумышленник пишет: ' OR '1'='1. Приложение выполняет: SELECT * FROM orders WHERE id = '' OR '1'='1'.

В обычной системе это "Game Over", вытекли все заказы. В системе с RLS хакер увидит... все свои заказы. Потому что неявный AND tenant_id = current_setting(...) добавляется базой данных поверх любого WHERE в запросе.

Edge Case (Гонки в политиках): Если ваша политика содержит подзапросы (например, tenant_id IN (SELECT ...)), вы снова в опасности из-за MVCC.

Решение: Держите политики простыми (id = $1). Если нужны подзапросы, используйте FOR UPDATE внутри них (чтобы залочить строки прав) или уровень изоляции SERIALIZABLE (дорого, но надежно).

3. Performance: Как выжить с RLS на объемах

В первой статье было упомянуто, что RLS добавляет оверхед. Если у вас таблица orders на 100 млн. строк, Postgres должен проверить права для каждой строки. Это больно.

Решение - Partitioning. Но не простое, а "золотое".

Шаг 0: Индексы (до партиционирования)

-- Прежде чем партиционировать, убедитесь, что есть индекс:

CREATE INDEX CONCURRENTLY idx_orders_tenant ON orders(tenant_id);

-- Для 90% проектов этого хватит на годы

Шаг 1: List partitioning (когда клиентов < 100)

Если у вас SaaS для корпораций (50-100 крупных клиентов), идеально подходит PARTITION BY LIST (tenant_id).

Каждый клиент живет в своей физической таблице. Изоляция идеальная.

Когда пора переезжать на Hash?

  • Количество партиций > 100

  • Время планирования запроса > 100ms

  • Размер каталога (pg_class) > 500MB

  • VACUUM одной таблицы длится часами

Шаг 2: Hash partitioning (когда клиентов > 1000)

Если у вас 100,000 пользователей, создавать 100,000 партиций (List) - это убить базу. Заголовок статьи провокационный: 10,000 партиций значительно увеличат время планирования запросов, а VACUUM будет работать вечно.

Золотая середина - Hash Partitioning.

Вы создаете 128 (или 256) buckets.

CREATE TABLE orders (

    id uuid,

    tenant_id uuid,

    payload jsonb

) PARTITION BY HASH (tenant_id);

-- Создаем 128 партиций

CREATE TABLE orders_000 PARTITION OF orders FOR VALUES WITH (MODULUS 128, REMAINDER 0);

...

CREATE TABLE orders_127 PARTITION OF orders FOR VALUES WITH (MODULUS 128, REMAINDER 127);

Пользователи "размазываются" по этим 128 таблицам.

Шаг 3: Альтернативы (когда выросли)

  • TimescaleDB для временных данных

  • Citus для горизонтального масштабирования

  • BRIN-индексы если данные упорядочены

4. Магия Run-Time Partition Pruning: Развенчать "магию"

Как оптимизатор понимает, какую партицию читать?

Обычно Pruning работает на этапе планирования. Здесь Postgres использует Run-Time Pruning (на этапе Executor Start). Считая хеш от current_setting, он исключает все остальные 127 таблиц.

Как проверить, работает ли pruning?

EXPLAIN (ANALYZE, VERBOSE) 

SELECT * FROM orders 

WHERE tenant_id = current_setting('app.tenant_id')::uuid;

-- В выводе ищите:

--   Runtime Pruning: true работает!

--   Partitions Selected: 1 of 128 отлично!

--   Важно: pruning НЕ работает, если:

--      1. Используете подзапросы в WHERE

--      2. Вызываете VOLATILE-функции

--      3. Делаете JOIN между партиционированными таблицами

4. Грабли реализации (Senior Checklist)

В завершение, краткий чеклист "граблей" (подробнее о некоторых было в первой части):

Superuser Blindness: Помните, что postgres и роли с BYPASSRLS игнорируют политики. Всегда тестируйте под ролью app_user.

Materialized Views: Критично. RLS не работает при чтении из мат-вьюхи! Это физический снимок, созданный от имени владельца.

Решение: Не давайте прямой доступ к MatView. Оберните её в обычную View с security_invoker=true (PG 15+) и явным фильтром.

Leakproof @ Managed Postgres: В облаках (Yandex Cloud, AWS RDS) у вас часто нет прав суперюзера, чтобы пометить функцию как LEAKPROOF.

Решение: Используйте стандартные операторы или расширения (типа pg_trgm), которые уже помечены как безопасные вендором.

Logical Replication (DWH): WAL-логи содержат "сырые" данные. Если вы реплицируете базу в DWH/BigQuery, RLS там не работает магическим образом. Настраивайте безопасность на приемнике.

Hard Migrations: На таблицах 1TB+ ALTER TABLE с включенным RLS может вызвать пересчет политик. Лайфхак: BEGIN; DISABLE RLS; ALTER ...; ENABLE RLS; COMMIT;. Делайте это строго внутри транзакции, иначе в момент отключения RLS ваши данные будут голыми для всех!

Заключение

Row Level Security - это отличный инструмент. Выбирайте его, исходя из своих задач и бизнес-требований. Он требует дисциплины (не забывать is_local), понимания кишок базы (Pruning, Transaction Isolation) и смелости (Fail Fast). Взамен же вы получаете архитектуру, в которой безопасность данных гарантирована фундаментальными свойствами базы данных.

Чего НЕ делать с RLS (чтобы не выстрелить себе в ногу)?

1. Не используйте подзапросы в политиках (риск TOCTOU)

-- ПЛОХО:

CREATE POLICY bad_policy ON documents

FOR SELECT USING (

  EXISTS (SELECT 1 FROM permissions WHERE user_id = current_user_id())

);

-- ХОРОШО:

CREATE POLICY good_policy ON documents

FOR SELECT USING (owner_id = current_user_id());

2. Не давайте прямой доступ к материализованным представлениям

3. Не забывайте про VACUUM на партициях

Проверь свой проект (5 минут):

  1. Включен ли RLS на продовых таблицах? \dt+

  2. Есть ли у сервисных аккаунтов BYPASSRLS? \du

  3. Используете ли вы is_local=true с PgBouncer?

  4. Есть ли индекс на tenant_id?

  5. Тестируете ли вы запросы без контекста?

Куда двигаться дальше?

  1. Для практики: Возьмите свой проект, включите RLS на одной тестовой таблице

  2. Для изучения: Почитайте про security_invoker в PG15+ и pg_audit

  3. Для мониторинга: Настройте алерты на запросы с tenant_id = NULL

RLS - это не серебряная пуля, но мощный инструмент в арсенале. Начните с малого, добавляйте постепенно, и ваша архитектура станет прочнее с каждым релизом. Пишите безопасный код, используйте Panic с умом и не бойтесь партицирования.