В рабочей системе на .NET права на изменение отдельных данных зависели от роли пользователя и состояния записи. Для этого использовался механизм атрибутного контроля доступа (ABAC).

Я исследовал его работу и обосновал переход от заранее рассчитанных результатов к вычислению на лету. Это позволило отказаться от хранения и синхронизации готовых привязок. К этому решению привела проверка предположения о производительности, на котором строилась первая реализация.

Почему выбрали предварительный расчёт

Первую версию механизма разрабатывали в спешке. Онлайн-расчёт тоже обсуждался, но его считали потенциально слишком затратным. Выбрали предварительный расчёт с хранением результатов в PostgreSQL и Redis. Сравнительных замеров, которые подтвердили бы этот выбор, тогда не проводили.

Сложность синхронизации и пересчётов стала очевидна позже, при развитии бизнес-функционала.

Проблемы прежнего механизма

Система заранее определяла подходящий набор правил для записи и сохраняла привязку в PostgreSQL и Redis. При чтении оставалось получить готовый результат.

Объём хранения зависел от числа записей. Привязки к правилам сохранялись для каждой бизнес-записи, поэтому небольшой справочник правил порождал большой набор производных данных. Планировалось несколько сотен активных правил на сотни тысяч записей уже в первые месяцы. По мере накопления данных рос бы и объём хранения, необходимый для работы механизма.

Пересчёты усложняли бизнес-операции. Изменение записи требовало обновления её привязок. Местами бизнес-логика требовала обновления нескольких записей по цепочке. И каждый раз требовалось вызвать пересчёт привязок и их пересохранение, повышая нагрузку на БД и на сеть.

Результаты нужно было синхронизировать в двух хранилищах. Для каждого изменения привязки требовалось обновить PostgreSQL и Redis. Это добавляло отдельную задачу поддержания согласованности данных.

Пропущенные пересчёты требовали отдельного восстановления. Периодически пересчёт не срабатывал, и привязки устаревали. Приходилось запускать операцию синхронизации данных, стоимость которой росла бы по мере накопления записей.

Каждое чтение данных требовало дополнительных сетевых вызовов. При получении данных система запрашивала в Redis рассчитанную привязку для каждой записи. И в обработке запроса приходилось ожидать пула сетевых ответов от Redis, добавляя нагрузку на сеть и увеличивая время обработки HTTP-запроса.

Redis был точкой отказа системы. При его недоступности сервис не мог проверить права доступа и не отдавал данные. Это блокировало запросы данных из UI и межсервисные запросы.

Проверка альтернативы

Когда появилось время на исследование, я решил проверить онлайн-расчёт. Я предложил заранее готовить правила, используя скомпилированные деревья выражений (System.Linq.Expressions), и хранить их в памяти для повторного использования. Я ожидал, что при небольшом числе правил это позволит рассчитывать права без существенных затрат: разбор условий и компиляция не будут повторяться для каждой записи.

Вариант, который при каждом обращении заново разбирал бы правила из строк, я не проверял. Предполагал, что его стоимость могла быть заметной на фоне всей обработки HTTP-запроса.

Я поставил AI-агенту задачу реализовать расчёт по подготовленным правилам с переключателем между старым и новым алгоритмами. Мы обсудили, какие сценарии сравнивать и что измерять. Агент подготовил код и бенчмарк, я вносил правки и разбирал результаты.

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

Понимание механизмов .NET и работы с памятью помогло мне выбрать устранение лишних преобразований и аллокаций. Я предположил, что это заметно снизит затраты, и поручил агенту доработать реализацию.

После устранения лишних аллокаций агент повторил измерения. Выделения памяти сравнялись с используемым вариантом, а время выполнения онлайн-расчёта заметно сократилось. По масштабу вычислительных затрат оба варианта стали сопоставимыми. Это окончательно убедило меня в необходимости перехода на доработанный механизм онлайн-расчёта.

Что показали измерения

В синтетическом стенде сравнивали локальную работу алгоритмов: прежнее чтение моделировалось словарём, без настоящего Redis и ожидания его ответа. Для выбранного сценария в отчёте сохранились следующие значения (условия измерений):

  • модель прежнего чтения — около 5 мкс и 304 байта выделений;
  • онлайн-расчёт — около 11 мкс и 304 байта выделений.

HTTP-запросы в сервисе обрабатывались десятки миллисекунд. Даже за вычетом сетевых вызовов оставшаяся обработка занимала миллисекунды. На этом фоне стоимость расчёта порядка десяти микросекунд при целевых нагрузках и объёмах данных я счёл несущественной. При этом новый вариант больше не обращался за готовой привязкой в Redis.

Замеры проводились для сценариев разной сложности. Для представления результатов я выбрал вариант с большим запасом: по грубой оценке числа потенциальных проверок он был в 15 раз тяжелее целевого.

Условия измерений

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

Параметр Целевой сценарий Выбранный сценарий в стенде
Записей в ответе 25 50
Правил на модель и роль 2 10
Атрибутов записи в условии 2 3
Потенциальных проверок 25 × 2 × 2 = 100 50 × 10 × 3 = 1500

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

Прогоны повторялись, в отчётах фиксировались средние значения. Точное число повторов и способ усреднения сейчас не восстановлены.

Результат переработки

Я представил результаты команде, и она поддержала переход на онлайн-расчёт. Замеры показали, что после оптимизации его вычислительная стоимость позволяет отказаться от поддержки материализованных привязок.

+ Готовые привязки больше не требовалось хранить. Подходящий набор правил теперь определялся в памяти при обработке записи. Обращения к Redis за заранее рассчитанным результатом исчезли.

+ Пересчёты убрали из бизнес-сценариев. Изменения записей, включая каскадные, больше не требовали дополнительных обращений к БД и сетевых вызовов для обновления привязок.

+ Отпала необходимость восстанавливать актуальность привязок. Набор правил подбирался для текущего состояния записи при обращении к ней. Справочник правил и обновление его кеша сохранились.

− В каждом сервисе появился локальный кеш правил. По оценке на момент исследования он занимал до мегабайта на сервис. На фоне сотен мегабайт, которые местами занимали сами сервисы, дополнительный расход был несущественным. Правила можно было обновлять раз в несколько минут; по моей оценке, даже обновление раз в минуту не создавало заметной нагрузки.

Разгрузку бизнес-сценариев отдельно не измеряли, поэтому я не привожу процент ускорения или снижения нагрузки.

Проверять предположения до выбора архитектуры

Предположение о производительности стоит проверить до того, как строить на нём архитектуру. При сравнении нужно учитывать и стоимость вычислений, и последующую работу по хранению, пересчётам и синхронизации результатов.

Мы успели переработать механизм в самом начале промышленной эксплуатации, пока данные ещё накапливались. Такая возможность будет не всегда. Сэкономленное на замерах время может обернуться гораздо большей работой по сопровождению и переделке.