PostgreSQL RLS в Go, Часть 2: Архитектура Highload. Паника, гонки и 10 000 партиций
- среда, 28 января 2026 г. в 00:00:13
В первой части было разобрано, как настроить RLS в Go, почему is_local=true спасает от утечек в PgBouncer, и как покрыть это интеграционными тестами. Если вы еще не настроили базовую изоляцию, начните оттуда.
Сегодня пойдем глубже. Не будем говорить о синтаксисе. Поговорим об архитектуре. О том, почему RLS - это не просто "удобный фильтр", а нативный механизм инкапсуляции, который решает проблемы распределенных систем и рисков безопасности прямо в слое данных, не раздувая Ops-сложность до сотен схем и баз.
Кому и зачем читать?
Junior/Middle: Поймете, почему RLS - это архитектурный выбор, а не просто WHERE tenant_id = ?. Узнаете про TOCTOU и как его избежать.
Senior/Architect: Увидите паттерны для highload (партиционирование + RLS) и production-грабли (матвьюхи, репликация).
TL;DR ключевых идей:
RLS = встроенный в БД security layer, а не фильтр.
Паниковать нужно только для чувствительных данных.
10k партиций - можно, но сложно; Hash partitioning - золотая середина.
RLS защищает от межтенантных утечек при SQL-инъекциях, но не от инъекций в самой политике.
1. Reliability: Философия Panic и Fail Fast
В первой части мы установили жесткое правило, нет контекста - нет транзакции. Но почему так радикально?
В мире разработки есть два подхода к ошибкам:
Graceful Degradation (Изящная деградация): "Сервис авторизации тупит? Ну, покажем пользователю кешированный профиль или пустой список, лишь бы не 500-ка".
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 выполняет это атомарно:
Блокирует строку (Row Lock) для изменения
В той же транзакции, в том же кванте времени проверяет политики RLS (USING)
Если политика теперь возвращает false, строка для операции DELETE перестает существовать
Счетчик удаленных строк: 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 минут):
Включен ли RLS на продовых таблицах? \dt+
Есть ли у сервисных аккаунтов BYPASSRLS? \du
Используете ли вы is_local=true с PgBouncer?
Есть ли индекс на tenant_id?
Тестируете ли вы запросы без контекста?
Куда двигаться дальше?
Для практики: Возьмите свой проект, включите RLS на одной тестовой таблице
Для изучения: Почитайте про security_invoker в PG15+ и pg_audit
Для мониторинга: Настройте алерты на запросы с tenant_id = NULL
RLS - это не серебряная пуля, но мощный инструмент в арсенале. Начните с малого, добавляйте постепенно, и ваша архитектура станет прочнее с каждым релизом. Пишите безопасный код, используйте Panic с умом и не бойтесь партицирования.