Перейти к содержанию
V1 V2

Политико-управляемая инфраструктура в Kora: отказоустойчивость, кеширование, валидация и планирование

26 августа 2026

Современные серверные фреймворки часто оценивают по числу раскрываемых функций: предохранители, повторы, кеши, валидаторы, планировщики, метрики и так далее. Такое сравнение полезно, но упускает более важный архитектурный вопрос: где живёт политика, и насколько она видима приложению? Один и тот же примитив повтора может быть либо ясной частью контракта сервиса, либо невидимым циклом, спрятанным внутри HTTP-клиента. Один и тот же кеш может быть явным слоем ускорения или непрозрачным источником устаревших данных. Одно и то же правило валидации может защищать границу API или протекать глубоко в бизнес-код. Один и тот же планировщик может быть удобным таймером или распределённой системой исполнения с персистентностью, арендами, восстановлением и требованиями идемпотентности.

Фреймворк Kora интересен тем, что старается держать эти политики декларативными, не превращая их в магию времени выполнения. Аннотации отказоустойчивости, аннотации кеша, аннотации валидации и аннотации планирования — не просто синтаксические удобства. Они описывают инфраструктурную политику в точке, где этой политике место, а Kora генерирует реализацию вокруг кода приложения на этапе компиляции. Результирующая модель отличается от фреймворка, который обнаруживает аннотации на этапе выполнения, рефлексирует над объектами, строит обобщённые цепочки перехватчиков и прячет контроль потока за динамическими прокси. В Kora приложение по-прежнему использует явные типы, сгенерированный код проверяем, конфигурация остаётся видимой, а фреймворк склонен сохранять форму базовой технологии, а не заменять её большой проприетарной абстракцией.

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

Ответ Kora — в значительной степени генерация на этапе компиляции плюс явная конфигурация. У этого ответа есть практические следствия для производительности и запуска, но более важные следствия архитектурные. Политики становятся легче для ревью, композиция становится видимой, сгенерированный код можно проверять, ошибки могут смещаться к этапу сборки, а код приложения остаётся близким к модели программирования Java и Kotlin.


Отказоустойчивость — это политика, а не вызов библиотеки

Отказоустойчивая система создаётся не вызовом retry() вокруг блока кода. Отказоустойчивость — это набор решений о семантике отказов: какие отказы временные, какие терминальные, сколько готов ждать вызывающий, должны ли повторные отказы прекращать обращение к зависимости, какой результат производить, когда основной путь недоступен, и как эти решения взаимодействуют с бюджетами задержки и ёмкости всей системы.

Именно поэтому отказоустойчивость работает лучше всего, когда трактуется как политика, а не как разрозненные вызовы библиотек. Если каждый разработчик вручную пишет цикл повтора, каждая точка вызова склонна изобретать собственную интерпретацию попыток, задержек, фильтров исключений, обработки таймаутов и запасного поведения. Со временем у сервиса больше нет целостной модели отказоустойчивости. Одна зависимость может повторять пять раз с фиксированной задержкой, другая — повторять бесконечно, а третья — повторять операции, которые небезопасно повторять. Код технически содержит примитивы отказоустойчивости, но у системы нет политики отказоустойчивости.

Kora раскрывает знакомые строительные блоки: предохранитель, повтор, таймаут и запасной вариант. Их можно использовать императивно через компоненты-менеджеры, что важно, когда контроль потока динамичен, но особенно полезны они декларативно как сгенерированный AOP вокруг методов. Декларативная форма делает больше, чем сокращает шаблонный код. Она превращает отказоустойчивость в метаданные, прикреплённые к чётко определённой операции, а конфигурация даёт этим метаданным операционные параметры.

Поэтому метод сервиса может выражать, что конкретная операция участвует в именованной политике:

@Component
public class CatalogService {

    @Fallback(value = "catalog", method = "fallback(arg1)")
    @CircuitBreaker("catalog")
    @Retry("catalog")
    @Timeout("catalog")
    public CatalogItem getItem(String id) {
        return client.getItem(id);
    }

    protected CatalogItem fallback(String id) {
        return CatalogItem.unavailable(id);
    }
}
@Component
open class CatalogService(private val client: CatalogClient) {

    @Fallback(value = "catalog", method = "fallback(arg1)")
    @CircuitBreaker("catalog")
    @Retry("catalog")
    @Timeout("catalog")
    open fun getItem(id: String): CatalogItem {
        return client.getItem(id)
    }

    protected fun fallback(id: String): CatalogItem {
        return CatalogItem.unavailable(id)
    }
}

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

Почему декларативная отказоустойчивость архитектурно полезна

Отказоустойчивость обычно пересекает код приложения. Она не часть бизнес-алгоритма получения элемента каталога, но часть производственного поведения этой операции. Размещение её прямо внутри метода смешивает задачи: метод становится ответственным за доменную логику, удалённый вызов, классификацию исключений, ожидание, учёт повторов, состояние предохранителя, измерение таймаута и метрики. Извлечение механики во вспомогательные библиотеки помогает, но если каждая точка вызова всё равно компонует эти помощники вручную, политика остаётся дублированной и трудной для аудита.

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

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

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

Упорядочивание политик — часть дизайна

Как только несколько механизмов отказоустойчивости используются вместе, порядок становится столь же важным, как и отдельные механизмы. Timeout(Retry(call)) не эквивалентен Retry(Timeout(call)). Предохранитель вокруг всего процесса повтора наблюдает другой поток отказов, чем предохранитель вокруг каждой попытки. Запасной вариант вне предохранителя ведёт себя иначе, чем запасной вариант внутри него.

Распространённая и полезная композиция концептуально такова:

Fallback
  └── Circuit Breaker
        └── Retry
              └── Timeout
                    └── вызов приложения

В такой компоновке таймаут ограничивает одну конкретную попытку. Повтор может повторять ограниченную попытку. Предохранитель видит итоговый результат последовательности повторов. Запасной вариант видит отказ после того, как внутренние механизмы исчерпаны или закорочены. Порядок аспектов Kora следует объявленному порядку аннотаций, поэтому порядок — не просто документация; он определяет сгенерированную структуру вызова.

Рассмотрим смысл размещения таймаута внутри. Предположим, у каждой попытки таймаут 200 мс, а повтор допускает три попытки. Вызывающий не получает сквозную гарантию 200 мс. В худшем случае он может потратить почти 600 мс на выполнение попыток плюс задержки повторов, накладные расходы планировщика и окружающую работу. Если задержка повтора 50 мс и между тремя попытками две задержки, один только слой отказоустойчивости может приблизиться к 700 мс до запасного варианта или итоговой обработки ошибок.

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

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

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

Усиление повтора: механизм отказа, который выглядит как надёжность

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

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

Простой верхний предел легко упустить:

входящая скорость × попытки

При трёх общих попытках 10 000 входящих запросов в секунду могут генерировать до 30 000 нижестоящих попыток в секунду. Реальное поведение зависит от вероятности успеха и тайминга, но направление ясно: повторы могут умножать нагрузку.

Усиление становится хуже в цепочках сервисов. Предположим, сервис A повторяет сервис B три раза, B повторяет сервис C три раза, а C повторяет операцию базы данных три раза. Один запрос верхнего уровня может потенциально сгенерировать большое дерево попыток. Точное число зависит от того, где происходят отказы, но независимые политики повтора на каждом уровне могут создавать экспоненциально выглядящее давление во время инцидента.

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

Идемпотентность заслуживает особого акцента. Чтение вроде GET часто можно безопасно повторять. Отправка платежа, уменьшение запаса, отправка письма или публикация сообщения могут быть небезопасны, если базовый протокол не предоставляет ключ идемпотентности или эффект ровно-один-раз на бизнес-уровне. Повтор записи, потому что клиент увидел таймаут, может дублировать побочные эффекты, даже когда первая попытка фактически удалась удалённо.

Декларативная отказоустойчивость помогает здесь, потому что наличие повтора очевидно. Становится возможным установить правила ревью кода, такие как: любой повтор над мутирующей операцией должен документировать семантику идемпотентности; повторы должны использовать именованный предикат; и попытки повтора должны укладываться в бюджет дедлайна операции.

Окна предохранителя — статистическая политика

Предохранитель часто объясняют как конечный автомат из трёх состояний: закрыт, открыт, полуоткрыт. Это верно, но неполно. В продакшене сложная часть — не понимание состояний. Сложная часть — решить, какие доказательства достаточны, чтобы заключить, что зависимость нездорова.

Конфигурация предохранителя Kora раскрывает важные параметры: скользящее окно, минимальное число вызовов до оценки, порог частоты отказов, число пробных вызовов, разрешённых в полуоткрытом состоянии, и длительность, в течение которой предохранитель остаётся открытым перед проверкой восстановления. Эти параметры определяют статистическую политику.

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

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

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

Это различие — ещё одна причина, почему политика принадлежит рядом с архитектурными границами. Команда должна уметь ответить: что на самом деле означает отказ предохранителя для этой операции? Если предикат — «каждое брошенное исключение», предохранитель может стать грубым ограничителем частоты ошибок, а не механизмом здоровья.

Таймаут — это бюджет, а не секундомер

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

Лучшая модель — бюджет задержки, который течёт от внешнего запроса внутрь.

Предположим, у API есть цель уровня сервиса 500 мс для большинства запросов. Инфраструктура вокруг приложения потребляет часть этого времени, а самому сервису нужно время для маппинга и бизнес-логики. Если запрос делает два нижестоящих вызова, ни один вызов не может безопасно получить таймаут 500 мс. Их бюджеты должны укладываться в оставшийся дедлайн, включая повторы, если повторы разрешены.

Концептуально:

бюджет запроса 500 мс
├── 40 мс допуск на вход / фреймворк / сериализацию
├── 60 мс допуск на локальную обработку
├── 250 мс бюджет первичной зависимости
├── 100 мс бюджет вторичной зависимости
└── 50 мс запас прочности

Числа иллюстративны, но принцип фундаментален. Таймаут — не «как долго эта библиотека должна ждать». Это одно выделение из сквозного бюджета задержки.

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

Виртуальные потоки делают блокирующий код недорогим с точки зрения управления потоками, но не устраняют потребность в бюджетах таймаутов. Виртуальный поток, ожидающий медленную нижестоящую систему, может быть дёшев по сравнению с платформенным потоком, но нижестоящее соединение, соединение базы данных, сокет, память и вышестоящий запрос — всё ещё реальные ресурсы. Конкурентность, которую дёшево представить, всё ещё может быть дорогой в обслуживании.

Запасной вариант — бизнес-решение, замаскированное под инфраструктуру

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

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

Декларативный запасной вариант полезен, потому что делает деградированный путь явным и тестируемым. Но метод запасного варианта всё равно принадлежит семантике приложения. Команды должны определять, какие поля могут быть устаревшими, насколько устаревшими они могут быть, помечен ли ответ как деградированный и могут ли клиенты отличать данные запасного варианта от свежих данных.

Запасной вариант также должен быть наблюдаемым. Система, которая успешно возвращает ответы запасного варианта, пока первичная зависимость сломана, может выглядеть здоровой, если дашборды считают только ответы HTTP 200. Операционно это опасно. Деградация должна измеряться как деградация.

Наблюдаемость — часть корректности отказоустойчивости

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

Самые полезные метрики — не просто сырые счётчики. Они должны отвечать на операционные вопросы. Достаточно ли высока частота успеха повторов, чтобы оправдать дополнительную нагрузку? Сгруппированы ли таймауты вокруг одной зависимости? Колеблется ли предохранитель между открытым и полуоткрытым? Приходят ли большинство успешных ответов сейчас из запасного варианта? Снизило ли изменение конфигурации задержку, но резко увеличило ложные таймауты?

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

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

Более широкая мысль в том, что наблюдаемость — не приложение к отказоустойчивости. Это то, как политика валидируется против реальности.


Кеширование без сокрытия приложения

Кеширование — ещё одна функция, которая выглядит тривиальной в форме API и трудной в производственной семантике. API, кажется, состоит из get, put и invalidate. Фактическая проблема дизайна включает идентичность ключа, устаревание, владение, согласованность между экземплярами, прогрев, порядок инвалидации, режимы отказов, стампиды (stampede), лимиты памяти, сериализацию и телеметрию.

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

Модель кеширования Kora полезна, потому что может оставаться декларативной, не превращая кеш в анонимный глобальный механизм. Контракты кеша — типизированные компоненты. Декларативные аспекты, такие как @Cacheable, @CachePut и @CacheInvalidate, могут оборачивать методы, а код приложения всё равно может внедрять кеш и вызывать его императивно, когда жизненный цикл требует явного контроля.

Типичный контракт может описывать и тип ключа, и тип значения:

@Cache("cache.users")
public interface UserCache extends CaffeineCache<String, UserResponse> {
}
@Cache("cache.users")
interface UserCache : CaffeineCache<String, UserResponse>

Этот тип — часть графа приложения. Тип ключа видим. Тип значения видим. Тесты могут внедрять или заменять кеш. Код сервиса может явно прогревать или инвалидировать его. Кеш — инфраструктура, но он не исчез за обобщённым словарём фреймворка.

Сгенерированные аспекты кеша сохраняют метод читаемым

Для классического случая read-through метод сервиса может оставаться о реальном источнике данных:

@Cacheable(UserCache.class)
public UserResponse getUser(String userId) {
    return repository.findById(userId);
}
@Cacheable(UserCache::class)
open fun getUser(userId: String): UserResponse {
    return repository.findById(userId)
}

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

Та же идея применима к путям записи:

@CachePut(UserCache.class)
public UserResponse updateUser(String userId, UserRequest request) {
    return repository.update(userId, request);
}

@CacheInvalidate(UserCache.class)
public void deleteUser(String userId) {
    repository.delete(userId);
}
@CachePut(UserCache::class)
open fun updateUser(userId: String, request: UserRequest): UserResponse {
    return repository.update(userId, request)
}

@CacheInvalidate(UserCache::class)
open fun deleteUser(userId: String) {
    repository.delete(userId)
}

Это хорошее использование декларативного AOP, потому что политика тесно связана с границей метода. Метод по-прежнему авторитетная операция, а поведение кеша прикреплено вокруг него. Сгенерированную реализацию можно проверять, что важно при отладке вывода ключа или порядка между кешем и другими аспектами.

Цель — не убрать кеширование из архитектурных диаграмм. Цель — убрать повторяющийся связующий код кеша из методов, сохраняя модель кеширования явной.

Ключи кеша — часть модели данных

Многие инциденты кеша — инциденты дизайна ключей. Если идентичность ключа неверна, никакая политика вытеснения не исправит семантическую ошибку.

Простейший случай — метод с одним скалярным аргументом, где этот аргумент однозначно идентифицирует кешированный результат. Реальные методы часто сложнее:

ProductPage getProducts(
    String tenantId,
    String category,
    int page,
    int pageSize,
    Sort sort,
    Locale locale
)
fun getProducts(
    tenantId: String,
    category: String,
    page: Int,
    pageSize: Int,
    sort: Sort,
    locale: Locale
): ProductPage

Что является ключом кеша? Если tenantId опущен, данные могут протекать между арендаторами. Если locale опущен, пользователи могут получать контент на неверном языке. Если pageSize опущен, один и тот же ключ может представлять разные формы результата. Если у Sort нестабильная строковая сериализация, семантически эквивалентные запросы могут генерировать разные ключи и разрушать коэффициент попаданий.

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

public record ProductPageKey(
    String tenantId,
    String category,
    int page,
    int pageSize,
    Sort sort,
    String locale
) {
}
data class ProductPageKey(
    val tenantId: String,
    val category: String,
    val page: Int,
    val pageSize: Int,
    val sort: Sort,
    val locale: String
)

Явный объект ключа улучшает сразу несколько свойств. Семантика равенства ясна. Тесты могут конструировать ожидаемые ключи. Рефакторинг безопаснее. Распределённые кеши могут контролируемо сериализовать ключ. Телеметрия может прикреплять выбранные измерения без разбора ad-hoc строк.

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

Многоуровневое кеширование — это не «больше кеша»

Распространённая производственная топология сочетает очень быстрый локальный кеш с общим удалённым кешем:

запрос
  ↓
L1 локальный кеш
  ↓ промах
L2 распределённый кеш
  ↓ промах
источник истины

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

Локальный кеш Caffeine чрезвычайно быстр, потому что это память процесса. Однако в Kubernetes у каждой реплики своё содержимое. Обновление кеша в Pod A не волшебным образом обновляет Pod B. Перезапуск пода даёт пустой локальный кеш. Это не дефект; это просто семантика локальной памяти.

Распределённый кеш, такой как Redis, предоставляет общее состояние, но стоит сетевого перехода, ёмкости соединений, сериализации и собственной операционной сложности. Размещение L1 перед L2 снова меняет обмен. Большинство горячих чтений может никогда не достигать Redis, но устаревшие записи L1 могут пережить даже после обновления L2, если инвалидация не достигает каждой реплики или TTL не ограничивает несогласованность.

Именно поэтому многоуровневое кеширование следует проектировать в терминах авторитетности и устаревания. Приложению нужны ответы на такие вопросы:

  • Разрешено ли L1 устаревать на 5 секунд, 30 секунд или 5 минут?
  • Инвалидирует ли запись только локальный процесс или публикует инвалидацию всем репликам?
  • Если L2 недоступен, может ли сервис продолжать обслуживать значения L1?
  • Если значение отсутствует в L1, но есть в L2, следует ли повторно заполнять L1?
  • Если оба кеша промахиваются одновременно на многих репликах, кому разрешено загружать из источника?

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

Инвалидация — это место, где архитектура кеша становится видимой

Знакомое утверждение, что «инвалидация кеша сложна», верно, потому что инвалидация пересекает границы владения. Код, выполняющий запись, может не быть кодом, выполняющим чтения. Одна и та же сущность может быть кеширована под несколькими ключами. Запись может влиять на агрегирующие запросы, списки, счётчики или результаты поиска в дополнение к прямому ключу сущности.

Предположим, сервис кеширует и то, и другое:

user:{id}
users:page:{page}:{size}:{sort}

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

Есть несколько легитимных стратегий:

  1. Точная инвалидация. Отслеживать каждый затронутый ключ и вытеснять его. Это даёт хорошую свежесть, но может стать сложным для производных данных.
  2. Инвалидация по пространству имён или широкая. Вытеснять большую группу ключей после записи. Это проще, но вызывает больше промахов.
  3. Сходимость на основе TTL. Принимать ограниченное устаревание и позволять записям истекать. Это часто практично для производных данных с преобладанием чтений.
  4. Версионированные ключи. Включать версию или поколение в ключ, чтобы старые записи становились недостижимыми после записи.
  5. Событийная инвалидация. Публиковать события изменений, чтобы другие реплики или сервисы могли вытеснять свои локальные записи.

Важное архитектурное правило в том, что инвалидацию следует специфицировать вместе с созданием кеша. Кеш, чья политика инвалидации — «решим позже», обычно будущий инцидент согласованности.

Стампида кеша — проблема конкурентности

Кеш может снижать нагрузку во время нормальной работы и катастрофически увеличивать её во время синхронизированного истечения. Это проблема стампиды кеша.

Представьте дорогое значение, получающее 20 000 чтений в секунду и имеющее TTL 60 секунд. Пока запись существует, источник видит почти нулевую нагрузку. При истечении тысячи запросов могут наблюдать промах почти одновременно и все начать пересчитывать или получать значение. Кеш преобразовал стабильный поток чтений во всплеск против источника.

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

Несколько техник могут смягчить стампиды:

  • Однопоточная загрузка (single-flight): один вызывающий загружает отсутствующий ключ, пока конкурентные вызывающие ждут тот же результат.
  • Обновление заранее (refresh-ahead): обновлять горячие записи до их истечения, а не ждать промаха.
  • Джиттер TTL: рандомизировать истечение, чтобы большие популяции ключей не истекали одновременно.
  • Stale-while-revalidate: обслуживать слегка устаревшее значение, пока один рабочий поток его обновляет.
  • Распределённая блокировка: координировать загрузчики между репликами, когда операция источника очень дорогая.
  • Лимиты ёмкости: ограничивать, сколько промахов кеша могут одновременно попадать в ограниченную зависимость.

Эти механизмы принадлежат политике кеша или дизайну загружаемого кеша, а не должны заново открываться каждым методом сервиса независимо.

Виртуальные потоки снова меняют стоимость ожидания, но не ёмкость источника. Позволить 10 000 виртуальных потоков загружать одну и ту же отсутствующую запись базы данных — не осмысленная стратегия кеша. Пул соединений базы данных всё ещё может содержать 50 соединений. Правильный дизайн кеша контролирует дублирующую работу, а не только выделение потоков.

Телеметрия должна объяснять эффективность кеша, а не только активность

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

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

Для многоуровневого кеширования L1 и L2 следует различать. Совокупный коэффициент попаданий 99% может звучать отлично, пока 80% трафика промахивается по L1 и уходит по сети в L2. Система функционально кеширована, но всё ещё может платить существенную задержку и инфраструктурную стоимость.

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

Наконец, телеметрия должна включать отказы кеша. Распределённый кеш — это зависимость. Если он становится медленным, сервису нужна определённая политика: провалить запрос, обойти кеш, вернуться к локальным данным или деградировать другим контролируемым способом. Это решение связывает кеширование обратно с отказоустойчивостью. Кеш не находится вне графа зависимостей только потому, что называется кешем.

Декларативная валидация на этапе компиляции

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

Чистый путь запроса выглядит так:

HTTP-запрос
    ↓
DTO / транспортное представление
    ↓
структурная валидация
    ↓
бизнес-операция
    ↓
доменные инварианты и персистентность

Граница важна, потому что каждый слой ниже неё становится проще, когда базовые допущения уже установлены. Если email не может быть пустым, pageSize должен быть между 1 и 100, а тело запроса должно удовлетворять определённой форме, эти факты следует установить до начала выполнения бизнес-логики.

Модель валидации Kora использует декларативные ограничения вместе со сгенерированными валидаторами и сгенерированным AOP для валидации методов. @Valid помечает вложенные объекты, которые следует обходить, а @Validate включает валидацию аргументов и возвращаемого значения метода вокруг вызова метода. Это сохраняет валидацию видимой в исходном коде, перенося повторяющийся код проверки в генерацию на этапе компиляции.

Валидация — задача границы, но не вся валидация принадлежит границе

Фраза «валидируйте на границе» полезна, только если мы различаем категории валидации.

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

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

Авторизация спрашивает, может ли вызывающий выполнить операцию. Она связана с валидацией, но не должна схлопываться в тот же механизм. Ввод может быть структурно валидным и всё равно неавторизованным.

Ограничения персистентности — финальный слой целостности. Уникальные ключи, внешние ключи, check-ограничения и транзакционные инварианты защищают источник истины, даже когда логика приложения гоняется или другой писатель обходит API.

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

Сгенерированные валидаторы сдвигают ошибки влево

Фреймворк валидации на основе рефлексии может обнаруживать ограничения на этапе выполнения и динамически вызывать валидаторы. Kora вместо этого генерирует код валидации. Это различие согласуется с остальной частью фреймворка: выполняйте работу во время компиляции, когда релевантные типы и аннотации уже известны.

Непосредственная выгода — не только скорость времени выполнения. Сгенерированная валидация улучшает локальность отказов. Неподдерживаемые формы, отсутствующие компоненты валидации или неверное связывание могут падать во время сборки, а не становиться код-патом, существующим только в продакшене.

Для DTO сгенерированный валидатор может выполнять прямые проверки полей и вложенных значений. Для метода, помеченного @Validate, Kora генерирует AOP-обёртку, которая валидирует аргументы перед вызовом фактического метода и может валидировать возвращаемое значение после, когда требуется. Исходный код контроллера остаётся кратким, но сгенерированный код делает границу конкретной.

Контроллер может выглядеть так:

@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
@Validate
public HttpResponseEntity<UserResponse> createUser(
    @Valid @Json UserRequest request
) {
    return HttpResponseEntity.of(
        201,
        HttpHeaders.of(),
        userService.createUser(request)
    );
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
@Validate
open fun createUser(
    @Valid @Json request: UserRequest
): HttpResponseEntity<UserResponse> {
    return HttpResponseEntity.of(
        201,
        HttpHeaders.of(),
        userService.createUser(request)
    )
}

Концептуальный поток тогда таков:

декодирование HTTP
   ↓
создан UserRequest
   ↓
сгенерированная обёртка валидации
   ↓
проверены ограничения полей
   ↓
UserController.createUser(...)
   ↓
UserService

Эта деталь важна. Валидация не спрятана внутри разбора JSON. Разбор отвечает, могут ли байты стать объектом Java или Kotlin. Валидация отвечает, является ли этот объект приемлемым вводом для приложения.

Ошибки разбора и ошибки валидации — разные контракты

Предположим, API ожидает:

{
    "name": "Alice",
    "age": 30
}

Эти отказы различны:

{
    "name": "Alice",
    "age": "old"
}

и:

{
    "name": "",
    "age": -5
}

Первый — проблема представления или декодирования: age нельзя преобразовать в ожидаемый числовой тип. Второй можно идеально декодировать, но результирующие значения нарушают ограничения.

Клиенты выигрывают, когда API последовательно их различает. Некорректный JSON-документ, несоответствие типа, нарушение ограничения и доменный конфликт не должны все схлопываться в одно общее сообщение 400 с трассировкой стека реализации.

Стабильный контракт ошибки валидации может включать машиночитаемый код, поле или путь и человекочитаемое объяснение:

{
    "code": "VALIDATION_FAILED",
    "violations": [
        {
            "path": "name",
            "message": "must not be blank"
        },
        {
            "path": "age",
            "message": "must be greater than or equal to 0"
        }
    ]
}

Точный формат — выбор дизайна API. Важно то, что он намеренный и стабильный. Kora может сопоставлять нарушения валидации через маппинг/перехват HTTP-ответов, чтобы публичный контракт не зависел от случайной строки исключения.

Валидация должна усиливать внутренние допущения

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

Без сильной границы код сервиса часто содержит защитное повторение:

if (request == null) { ... }
if (request.name() == null) { ... }
if (request.name().isBlank()) { ... }
if (request.age() < 0) { ... }
if (request == null) { ... }
if (request.name == null) { ... }
if (request.name.isBlank()) { ... }
if (request.age < 0) { ... }

Репозиторий может повторять некоторые из тех же проверок, потому что не может доверять сервису. Утилитарные методы могут повторять их снова. В итоге приложение содержит десятки частичных валидаторов с немного разным поведением.

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

Это особенно актуально, когда сервисы принимают ввод из нескольких транспортов. HTTP может быть не единственной границей. Сообщения Kafka, полезные нагрузки запланированных заданий, ввод CLI или запросы gRPC могут каждый требовать валидации. Сгенерированные валидаторы переиспользуемы за пределами HTTP; HTTP-интеграция — одно из мест, где они вызываются.

Вложенная валидация и графы объектов

Реальные DTO содержат вложенные структуры:

public record CreateOrderRequest(
    String customerId,
    Address shippingAddress,
    List<OrderLine> lines
) {
}
data class CreateOrderRequest(
    val customerId: String,
    val shippingAddress: Address,
    val lines: List<OrderLine>
)

Валидации только верхнеуровневого объекта недостаточно. Запрос может быть не-null, пока shippingAddress.postcode недействителен или OrderLine.quantity одного из элементов равен нулю. @Valid существует, чтобы выразить обход во вложенные объекты с использованием соответствующих сгенерированных валидаторов.

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

Путь ошибки становится важным для вложенных отказов. Нарушение вроде:

lines[3].quantity

гораздо полезнее, чем «заказ недействителен». Хорошая валидация — не только об отклонении; она о возврате достаточной структуры, чтобы клиенты и операторы поняли отклонение.

Ошибки валидации — это данные, а не чрезвычайные ситуации в логах

Некорректный пользовательский запрос обычно не серверный инцидент. Если отказы валидации логируются на уровне ошибки с трассировками стека, нормальное клиентское поведение может залить логи и скрыть реальные отказы. Телеметрия валидации должна отличать ожидаемое отклонение от неисправности фреймворка или приложения.

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

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

Внезапное увеличение отказов валидации всё ещё может быть операционно важным. Оно может указывать на сломанный релиз клиента, дрейф контракта, несоответствие OpenAPI или трафик злоупотреблений. Разница в том, что приложение должно наблюдать агрегированный паттерн, не трактуя каждый отдельный недопустимый запрос как исключение, требующее расследования.

Валидация на этапе компиляции соответствует цели прозрачности Kora

Главная ценность сгенерированной валидации Kora не в том, что разработчики могут писать меньше if. Она в том, что фреймворк может обеспечивать декларативную границу, сохраняя проверяемую модель исполнения. Приложение объявляет ограничения, Kora генерирует валидаторы, @Validate генерирует обёртку, а маппер/перехватчик превращает нарушения в HTTP-контракт.

Эту модель легко анализировать:

ограничения в исходном коде
        ↓
генерация на этапе компиляции
        ↓
явные компоненты валидаторов
        ↓
сгенерированная обёртка метода
        ↓
стабильный маппинг ошибок

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


Планирование в продакшене: от @Schedule к распределённым заданиям

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

Это не одна и та же функция.

Поддержка планирования Kora делает локальное запланированное выполнение прямолинейным. Планировщик на основе JDK естественно маппится на ScheduledExecutorService, с выполнением с фиксированной скоростью, фиксированной задержкой и одноразовым выполнением. Quartz добавляет cron-выражения, пользовательские триггеры, контроль конкурентности и более богатое поведение планирования. Kora генерирует компоненты задач на этапе компиляции и подключает их к выбранному планировщику, сохраняя декларативный API согласованным с более широкой моделью генерации фреймворка.

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

Локальное планирование означает один планировщик на процесс

Рассмотрим:

@Component
public class CleanupJob {

    @ScheduleWithFixedDelay(
        initialDelay = 10,
        delay = 60,
        unit = ChronoUnit.SECONDS
    )
    void cleanup() {
        // remove expired local state
    }
}
@Component
class CleanupJob {

    @ScheduleWithFixedDelay(
        initialDelay = 10,
        delay = 60,
        unit = ChronoUnit.SECONDS
    )
    fun cleanup() {
        // remove expired local state
    }
}

В одном экземпляре приложения семантика интуитивна. Процесс запускается, планировщик регистрирует задачу, и метод выполняется в соответствии с настроенной задержкой.

Теперь разверните пять реплик. Если механизм планирования не предоставляет координацию кластера, планировщиков пять. Каждая реплика выполняет одно и то же задание. Если работа — «обновить локальный кеш этого пода», это ровно то, что вы хотите. Если работа — «списать все подписки, срок оплаты которых наступил сегодня», это может быть катастрофой.

Это первый вопрос дизайна планирования:

Задание на экземпляр или на кластер?

Задания на экземпляр включают очистку локального кеша, локальную агрегацию метрик, отправку heartbeat, обновление, специфичное для процесса, или периодические проверки, чьё дублирование безвредно и намеренно. Задания на кластер включают бизнес-процессы, где одним логическим выполнением должен владеть один рабочий поток за раз.

Аннотация не отвечает на этот вопрос. Архитектура должна ответить.

Фиксированная скорость и фиксированная задержка выражают разные модели нагрузки

Даже до распределения важен выбор между фиксированной скоростью и фиксированной задержкой.

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

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

Это различие — политика ёмкости. Если задание обычно занимает 20 секунд, но иногда 90, планирование его каждые 30 секунд с фиксированной скоростью может создавать конкурентную работу ровно тогда, когда система уже медленная. Фиксированная задержка естественно применяет противодавление к этому одному заданию, потому что следующее выполнение не может начаться, пока текущее не завершится.

Quartz добавляет дополнительные контроли, такие как неконкурентное выполнение и семантика cron, но тот же архитектурный вопрос остаётся: что должно произойти, когда длительность выполнения превышает интервал расписания?

Пропущенные выполнения (misfires) — решения о потерянном времени

Производственные процессы останавливаются. Поды перезапускаются. Узлы исчезают. Развёртывания катятся. Планировщик может быть недоступен ровно в то время, когда cron-выражение говорит, что задание должно выполняться. Когда процесс возвращается, что должно произойти с выполнениями, которые были пропущены?

Это проблема пропущенных выполнений.

Для некоторых заданий правильный ответ — «пропустить». Если снимок метрик должен был выполняться каждую минуту, а сервис был недоступен десять минут, выполнение десяти исторических снимков после перезапуска может быть бесполезным.

Для других заданий правильный ответ — «догнать один раз». Ежедневная сверка, которая не выполнялась в полночь, всё равно должна выполниться после восстановления сервиса, но, вероятно, только один раз, а не по разу за каждый пропущенный интервал опроса.

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

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

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

Персистентное планирование в БД меняет модель владения

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

Концептуальная архитектура такова:

                    ┌──────────────────┐
                    │   общая БД        │
                    │ запланированные   │
                    │ задачи            │
                    │ состояние         │
                    │ выполнения        │
                    └────────┬─────────┘
                             │
                 claim / lock / renew
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
     сервис A           сервис B           сервис C
     рабочий поток     рабочий поток     рабочий поток

Работа по планированию в БД в Kora 2 следует этой модели, интегрируя персистентный движок в стиле db-scheduler, добавляя сгенерированную поддержку планирования и ограниченное выполнение на виртуальных потоках для заданий. Важный архитектурный сдвиг — не имя аннотации. Он в том, что база данных становится точкой координации состояния заданий.

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

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

Там, где модуль планирования на основе БД не является частью развёртываемой версии Kora, то же различие всё равно применимо архитектурно: локальное планирование JDK/Quartz и персистентное распределённое выполнение заданий решают разные проблемы и не должны рассматриваться как взаимозаменяемые.

Аренды и блокировки отвечают «кто сейчас владеет заданием?»

В распределённом планировщике выбора задания недостаточно. Два рабочих потока могут одновременно обнаружить одну и ту же должную строку. Планировщику нужен атомарный механизм заявки, обычно основанный на блокировке базы данных, переходе состояния compare-and-set или аренде с истечением.

Аренда означает, что рабочий поток владеет заданием в течение ограниченного периода. Если рабочий поток завершает, он обновляет или удаляет выполнение. Если рабочий поток падает и перестаёт продлевать владение, аренда в итоге истекает, и другой рабочий поток может восстановить задание.

Это фундаментально отличается от удержания мьютекса в памяти. Модель отказов включает смерть процесса и сетевые партиции. Владение должно быть представлено в общем состоянии.

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

Долго выполняющиеся задания усложняют вопрос дальше. Если планировщик предполагает фиксированную длительность блокировки, пока задание может выполняться часами, рабочему потоку может понадобиться продление на основе heartbeat. Операционные метрики должны раскрывать задания, приближающиеся к истечению аренды, повторные восстановления и конкуренцию за владение.

Повторы тоже принадлежат заданиям, но их семантика иная

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

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

Например:

попытка 1 → отказ
повтор через 10 секунд
попытка 2 → отказ
повтор через 1 минуту
попытка 3 → отказ
повтор через 10 минут
попытка 4 → dead-letter / ручная проверка

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

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

Это ведёт к центральному правилу распределённого планирования:

Выполнение «как минимум один раз» достичь легко. Бизнес-эффект «ровно один раз» требует дизайна приложения.

Идемпотентность — настоящий механизм «ровно один раз»

Предположим, задание отправляет платёжный запрос, а затем записывает завершение:

1. вызвать платёжного провайдера
2. платёж успешен
3. процесс падает
4. состояние завершения не персистировано
5. аренда истекает
6. другой рабочий поток выполняет задание снова

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

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

Примеры включают:

payment-idempotency-key = orderId
email-deduplication-key  = campaignId + userId
generation-key           = reportType + businessDate
ledger-entry-id          = детерминированный id операции

Записи в базу данных часто могут обеспечивать идемпотентность уникальным ограничением. Внешние API могут предоставлять поддержку idempotency-key. Для систем без такой поддержки приложению может понадобиться локальный outbox, таблица дедупликации или конечный автомат.

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

Несколько реплик требуют ограниченного выполнения, даже с виртуальными потоками

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

Виртуальные потоки — отличная модель исполнения для блокирующих запланированных заданий, потому что каждое задание может использовать прямые синхронные API JDBC, HTTP или файлов. Но неограниченный исполнитель «виртуальный поток на задачу» не автоматически корректный рабочий поток планировщика. Пул соединений базы данных, квоты удалённого API, CPU, куча и пропускная способность хранилища остаются ограниченными.

Работа scheduling-db в Kora включает ограниченное выполнение на виртуальных потоках и настраиваемое поведение параллелизма/предвыборки. Этот дизайн отражает важный производственный принцип: дешёвое представление задач всё равно должно быть связано с лимитами ресурсов.

Предположим, под может поддерживать 100 000 виртуальных потоков, но имеет пул JDBC из 30 соединений. Получение 10 000 заданий, тяжёлых по базе данных, в выполняющиеся виртуальные потоки не увеличивает пропускную способность. Оно создаёт 9 970 ожидающих задач, потребляет память, удлиняет задержку очереди и усложняет завершение/ребалансировку.

Лучшая модель такова:

персистентные готовые задания
       ↓
ограниченная предвыборка
       ↓
ограниченная конкурентность выполнения
       ↓
пулы ресурсов

Планировщик не должен перемещать в процесс больше работы, чем процесс может реалистично продвинуть.

Телеметрия планирования должна описывать жизненный цикл задания

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

запланировано
  ↓
должно выполниться
  ↓
заявлено
  ↓
начато
  ↓
успешно / упало
  ↓
перенесено / завершено / dead-letter

Полезные измерения включают:

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

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

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

Выбор правильного уровня планирования

Полезная таблица решений такова:

Требование Локальный планировщик JDK Quartz в процессе Персистентный планировщик БД
Периодическое обслуживание на под Отлично Хорошо Обычно не нужно
Простая фиксированная задержка/скорость Отлично Хорошо Хорошо, но тяжелее
Cron-выражения Ограниченно в зависимости от API/версии Отлично Отлично, когда поддерживается
Пережить перезапуск процесса Нет долговечной гарантии Только с персистентным хранилищем заданий/конфигурацией Да, по дизайну
Ровно один активный владелец на репликах Нет Требует кластер-осведомлённой персистентной конфигурации Да, через координацию общей БД
Отложенная одноразовая бизнес-задача Слабо Возможно Сильное соответствие
Долговечные повторы Слабо Возможно с конфигурацией Сильное соответствие
Восстановление после падения рабочего Специфично для процесса Зависит от хранилища заданий Базовая возможность
Типизированная персистентная полезная нагрузка Неприменимо Пользовательская Естественное соответствие

Цель — не использовать самый изощрённый планировщик везде. Это использовать простейший механизм, чья семантика отказов соответствует заданию.

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


Один паттерн для четырёх функций

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

Для отказоустойчивости метод приложения остаётся операцией, а сгенерированные аспекты применяют таймаут, повтор, предохранитель и запасной вариант в соответствии с именованной конфигурацией. Для кеширования метод остаётся авторитетным путём загрузки или мутации, а сгенерированные аспекты применяют read-through, обновление или инвалидацию с использованием типизированных контрактов кеша. Для валидации метод контроллера остаётся границей запроса, а сгенерированные валидаторы и AOP обеспечивают структурные правила до выполнения бизнес-логики. Для планирования метод остаётся телом задания, а сгенерированные компоненты задач подключают его к механизму исполнения.

Общую архитектуру можно резюмировать так:

метод приложения
       +
декларативная политика
       +
генерация на этапе компиляции
       +
явная конфигурация
       ↓
производственное поведение

Это существенно иная модель, чем построение поведения приложения из ad-hoc вызовов утилит. Вызовы утилит легко добавить, но трудно управлять. Декларативную политику можно ревьюить, именовать, настраивать, инструментировать и генерировать согласованно.

У модели также есть пределы, и эти пределы здоровы. Аннотация не может решить, идемпотентен ли платёж. Аспект кеша не может изобрести корректную модель инвалидации. Аннотация валидации не может определить, легален ли переход состояния в домене. Аннотация планировщика не может обеспечить бизнес-эффекты «ровно один раз». Инфраструктура фреймворка может обеспечивать механику, но семантика всё равно принадлежит приложению.

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

Фреймворки становятся опасными, когда удобство стирает эти различия. Более сильный подход Kora — делать обычную политику краткой, не притворяясь, что политика проста.

Композиция — это место, где прячутся производственные баги

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

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

Валидированный кешированный метод поднимает другой вопрос: валидация в целом должна выполняться до обращения к кешу, если недопустимые аргументы должны последовательно отклоняться, а не превращаться в ключи.

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

Запланированное кешированное обновление должно решать, как несколько реплик координируют регенерацию кеша и можно ли обслуживать устаревшие данные, пока задание обновления восстанавливается.

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

Полезная практика — явно рисовать структуру обёрток во время ревью дизайна:

Validation
  └── Cache
        └── Circuit Breaker
              └── Retry
                    └── Timeout
                          └── Repository / Client

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

Конфигурация — исполняемая архитектура

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

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

Например:

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

Это превращает конфигурацию из «операционной настройки» в архитектуру, о которой можно рассуждать количественно.

Операционная простота исходит из явной семантики

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

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

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

Ни один из этих результатов нельзя достичь одним вызовом метода библиотеки.

Они требуют политики.

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

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