Другое

Другое #

1. Знакомы ли вы с книгой чистый код? #

Да


2. Какие недостатки у строгого следования практикам чистого кода? #

Главная проблема #

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

Цель разработки:
понятный, корректный и изменяемый код

Чистый код:
один из инструментов достижения цели,
а не сама цель

Строгое соблюдение правил может сделать код формально «чистым», но фактически более сложным.

1. Избыточное количество абстракций #

Разработчик пытается заранее предусмотреть любые изменения:

class UserRepositoryInterface(Protocol):
    async def get_by_id(self, user_id: int) -> User | None:
        ...


class AbstractUserRepository(ABC):
    @abstractmethod
    async def get_by_id(self, user_id: int) -> User | None:
        ...


class SQLUserRepository(AbstractUserRepository):
    ...

Хотя в проекте существует только одна реализация и её замена не планируется.

Вместо прямого вызова:

user = await User.get(id=user_id)

появляется цепочка:

endpoint
→ service
→ use case
→ repository interface
→ repository implementation
→ ORM

Каждый уровень может быть оправдан, но не каждый нужен автоматически.

Последствия:

  • больше файлов и классов;

  • сложнее навигация;

  • больше кода без новой функциональности;

  • изменение одной операции затрагивает несколько слоёв;

  • новичку труднее понять реальный поток выполнения.

2. Преждевременное проектирование #

Попытка сделать код готовым ко всем будущим требованиям приводит к созданию abstractions «на всякий случай».

class NotificationSenderFactory:
    def create(
        self,
        provider_type: NotificationProviderType,
    ) -> NotificationSender:
        ...

Хотя приложение пока отправляет только email и нет подтверждённых планов добавлять другие каналы.

Проблема:

предполагаемое будущее
≠ реальное будущее

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

Обычно безопаснее:

сначала простая реализация
→ появляются реальные варианты
→ выявляется стабильная общая структура
→ только тогда создаётся абстракция

3. Избыточное дробление функций #

Совет «функция должна делать только одну вещь» иногда понимают слишком буквально.

def process_order(order_id: int) -> None:
    order = get_order(order_id)
    validate_order(order)
    reserve_order_items(order)
    calculate_order_total(order)
    save_order(order)
    send_order_notification(order)

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

process_order
→ prepare_order
  → load_order
    → fetch_order_record
      → execute_order_query

то для понимания простой операции приходится постоянно переходить между файлами.

Слишком мелкие функции:

  • скрывают последовательность действий;

  • увеличивают количество имён;

  • заставляют держать в голове множество переходов;

  • затрудняют локальное чтение;

  • могут не иметь самостоятельного смысла.

Иногда блок из 15–20 последовательных строк понятнее, чем восемь функций по две строки.

4. Слишком много косвенности #

Абстракции добавляют indirection — чтобы понять, что происходит, нужно проследить несколько переходов.

await dispatcher.dispatch(
    command_factory.create(
        request_mapper.map(payload),
    ),
)

Чтобы узнать, что код создаёт пользователя, нужно открыть:

dispatcher
command factory
request mapper
command handler
service
repository

Прямой вариант может быть понятнее:

user = await user_service.create_user(
    username=payload.username,
    password=payload.password,
)

Косвенность полезна, когда она отделяет действительно независимые части. Без причины она только скрывает поведение.

5. Потеря контекста из-за DRY #

Принцип DRY часто понимают как запрет любого повторения.

Допустим, есть две проверки:

def validate_registration_email(email: str) -> None:
    ...

def validate_billing_email(email: str) -> None:
    ...

Сейчас правила совпадают, поэтому их объединяют:

def validate_email(email: str) -> None:
    ...

Позже оказывается:

регистрация:
разрешены только корпоративные адреса

billing:
разрешены любые корректные адреса

Общая функция начинает принимать флаги:

validate_email(
    email,
    require_corporate=True,
    allow_disposable=False,
    verify_domain=True,
)

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

Полезное правило:

дублирование логики
часто опасно

дублирование нескольких похожих строк
иногда безопаснее неправильной абстракции

6. Чрезмерное применение SOLID #

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

Например, ради Dependency Inversion создаётся интерфейс для каждого компонента:

PasswordHasherProtocol
ClockProtocol
UUIDGeneratorProtocol
UserRepositoryProtocol
TokenEncoderProtocol
EmailValidatorProtocol

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

Но интерфейс над стабильной функцией:

def normalize_email(email: str) -> str:
    return email.strip().lower()

обычно ничего не даёт.

Не каждая зависимость требует отдельной абстракции. Важнее абстрагировать:

  • нестабильные компоненты;

  • внешние системы;

  • инфраструктуру;

  • места, где реально существуют несколько реализаций;

  • сложное поведение, требующее изоляции.

7. Увеличение времени разработки #

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

DTO
entity
command
handler
service
repository interface
repository implementation
mapper
factory
exception hierarchy

Это увеличивает время:

  • написания;

  • code review;

  • тестирования;

  • рефакторинга;

  • знакомства с проектом.

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

8. Ухудшение производительности #

Большинство практик чистого кода почти не влияют на производительность. Но чрезмерная абстракция иногда создаёт реальные накладные расходы:

  • дополнительные объекты;

  • лишние копирования данных;

  • многочисленные преобразования DTO;

  • повторные запросы к БД;

  • невозможность выполнить batch-операцию;

  • цепочки декораторов и middleware;

  • виртуальные вызовы в критическом цикле.

Например, формально чистый код:

for user_id in user_ids:
    user = await repository.get_by_id(user_id)
    await notification_service.notify(user)

создаёт N запросов к базе.

Менее «объектно чистое», но эффективное решение:

users = await repository.get_many(user_ids)
await notification_service.notify_many(users)

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

9. Сложность от большого количества файлов #

Строгое правило «один класс — один файл» может превратить небольшой модуль в десятки файлов:

users/
├── create_user_command.py
├── create_user_handler.py
├── create_user_request.py
├── create_user_response.py
├── create_user_mapper.py
├── user_repository.py
├── user_repository_protocol.py
└── user_service.py

Поиск кода превращается в постоянное переключение контекста.

Иногда связанные небольшие сущности лучше держать рядом:

users/
├── schemas.py
├── service.py
├── repository.py
└── routes.py

Количество файлов само по себе не является показателем качества архитектуры.

10. Избыточные комментарии и документация #

Правило «код должен объяснять себя сам» иногда приводит к полному отказу от комментариев.

Но код хорошо показывает:

что он делает

И не всегда показывает:

почему принято именно такое решение
какое ограничение внешней системы учитывается
почему очевидный вариант не работает
какой бизнес-инвариант защищается

Полезный комментарий:

# Внешний провайдер может повторно прислать webhook,
# поэтому событие обрабатывается по уникальному provider_event_id.

Бесполезный комментарий:

# Получаем пользователя по ID
user = await get_user(user_id)

Строгий запрет комментариев так же вреден, как комментирование каждой строки.

11. Имена становятся слишком длинными #

Стремление сделать каждое имя максимально описательным приводит к конструкциям:

user_registration_email_address_validation_service

или:

get_active_users_with_unexpired_subscription_by_organization_id()

Длинное имя не всегда делает код понятнее. Контекст модуля уже передаёт часть информации:

# subscriptions/repository.py

async def get_active_by_org(
    organization_id: int,
) -> list[Subscription]:
    ...

Хорошее имя должно быть достаточно точным, а не максимально длинным.

12. Код становится удобным для тестов, но неудобным для использования #

Иногда архитектуру начинают строить исключительно вокруг unit-тестов:

каждая функция инжектируется
каждая зависимость имеет mock
каждый вызов проверяется через assert_called_once

В результате тесты привязываются к внутренней реализации:

repository.get_by_id.assert_called_once_with(42)
mapper.to_entity.assert_called_once()
event_bus.publish.assert_called_once()

После безопасного рефакторинга поведение не изменилось, но десятки тестов сломались.

Более устойчивые тесты проверяют результат и наблюдаемое поведение:

response = client.post("/users", json=payload)

assert response.status_code == 201
assert await user_exists(payload["username"])

Изолированные unit-тесты нужны, но не следует превращать каждый внутренний вызов в часть публичного контракта.

13. Игнорирование особенностей языка и фреймворка #

Универсальные правила могут конфликтовать с идиомами конкретного инструмента.

Например, в FastAPI dependency injection уже предоставляет механизм связывания зависимостей:

@app.post("/users")
async def create_user(
    data: UserCreate,
    service: UserService = Depends(get_user_service),
):
    return await service.create(data)

Добавление поверх него собственного:

controller factory
dependency container adapter
service locator
handler resolver

может не дать пользы.

То же относится к Django ORM, SQLAlchemy, Pydantic и другим фреймворкам. Хорошая архитектура учитывает их возможности, а не пытается полностью спрятать их за собственной платформой.

14. Догматизм в code review #

Строгие правила часто превращают review в обсуждение формы:

функция длиннее 20 строк
у класса две ответственности
название недостаточно clean
нужен repository pattern
здесь нарушен DRY

Вместо важных вопросов:

корректна ли бизнес-логика
есть ли race condition
сколько SQL-запросов выполняется
правильно ли проверяется авторизация
что произойдёт при повторной доставке
как система ведёт себя при частичном сбое

Формальное соответствие стилю может скрыть реальные дефекты.

15. Субъективность понятия «чистый код» #

Разные разработчики считают чистыми разные подходы:

один предпочитает маленькие функции
другой — линейный код

один предпочитает repository
другой — прямое использование ORM

один предпочитает исключения
другой — Result objects

Без общих критериев команда может бесконечно рефакторить код по вкусу отдельных участников.

Более объективные критерии:

  • легко ли найти нужную логику;

  • понятно ли поведение;

  • безопасно ли изменение;

  • достаточно ли тестов;

  • соблюдаются ли бизнес-инварианты;

  • приемлема ли производительность;

  • насколько часто происходят ошибки при изменениях.

Пример излишне «чистого» решения #

Для простого получения пользователя:

class GetUserQuery:
    def __init__(self, user_id: int):
        self.user_id = user_id


class GetUserHandler:
    def __init__(
        self,
        repository: UserRepositoryProtocol,
        mapper: UserResponseMapper,
    ):
        self.repository = repository
        self.mapper = mapper

    async def execute(
        self,
        query: GetUserQuery,
    ) -> UserResponse:
        user = await self.repository.get_by_id(query.user_id)

        if user is None:
            raise UserNotFoundError(query.user_id)

        return self.mapper.map(user)

Для небольшого приложения может быть достаточно:

async def get_user(
    user_id: int,
) -> UserResponse:
    user = await User.get_or_none(id=user_id)

    if user is None:
        raise UserNotFoundError(user_id)

    return UserResponse.model_validate(user)

Первый вариант не является автоматически плохим. Он оправдан, если:

  • приложение большое;

  • существуют отдельные use cases;

  • ORM нужно изолировать;

  • используются разные хранилища;

  • команда следует общей архитектуре;

  • логика будет расширяться.

Но применять его ко всем операциям только ради формального паттерна не нужно.

Как использовать практики разумно #

Вместо жёстких правил:

функция должна быть не длиннее 10 строк
каждый сервис должен иметь интерфейс
никакого дублирования
один класс — одна ответственность
всё должно быть покрыто unit-тестами

лучше задавать вопросы:

Понятен ли код без длительного изучения?

Можно ли изменить его локально?

У абстракции уже есть несколько реальных потребителей?

Скрывает ли функция значимую концепцию?

Уменьшает ли новый слой связанность?

Окупает ли сложность добавленную гибкость?

Защищены ли бизнес-инварианты?

Можно ли удалить часть архитектуры без потери пользы?

Итог #

Главные недостатки строгого следования Clean Code:

переусложнение
преждевременные абстракции
избыточное дробление
большое количество косвенности
неправильное устранение дублирования
замедление разработки
увеличение когнитивной нагрузки
ухудшение навигации
возможные потери производительности
догматичные code review

Хороший код — не тот, который соответствует максимальному количеству правил, а тот, который:

корректно решает задачу
понятен команде
безопасно изменяется
не содержит лишней сложности
соответствует масштабу и сроку жизни системы


3. В каких случаях следование принципам чистого кода ухудшает эффективность? #

Что понимать под эффективностью #

Следование Clean Code может ухудшать два разных вида эффективности:

1. Эффективность разработки
   → скорость реализации, изменения и поиска ошибок

2. Эффективность выполнения
   → latency, throughput, память, CPU, количество I/O

Чаще страдает именно эффективность разработки, а не скорость программы.

1. Когда абстракция дороже решаемой задачи #

Для простого CRUD создают много слоёв:

router
→ controller
→ command
→ handler
→ service
→ repository interface
→ repository implementation
→ ORM

Операция остаётся простой:

user = await User.get_or_none(id=user_id)

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

Это ухудшает эффективность, когда:

  • приложение небольшое;

  • реализация одна;

  • компоненты не планируется заменять;

  • бизнес-логика простая;

  • дополнительный слой не скрывает реальную сложность.

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

2. Когда функцию дробят слишком сильно #

Исходный линейный код:

async def create_order(data: OrderCreate) -> Order:
    product = await get_product(data.product_id)

    if product.stock < data.quantity:
        raise InsufficientStockError()

    total = product.price * data.quantity
    order = await save_order(data, total)
    await reserve_stock(product.id, data.quantity)

    return order

После чрезмерного дробления:

async def create_order(data: OrderCreate) -> Order:
    product = await load_order_product(data)
    ensure_product_can_be_ordered(product, data)
    total = calculate_order_total(product, data)
    order = await persist_new_order(data, total)
    await execute_stock_reservation(product, data)
    return order

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

Дробление ухудшает эффективность, когда функция:

  • вызывается только один раз;

  • состоит из одной очевидной строки;

  • не выражает отдельную предметную концепцию;

  • скрывает важную последовательность действий;

  • увеличивает количество имён сильнее, чем уменьшает сложность.

3. Когда преждевременно устраняют дублирование #

Две похожие операции объединяют слишком рано:

def validate_email(
    email: str,
    *,
    require_corporate: bool,
    allow_disposable: bool,
    verify_domain: bool,
) -> None:
    ...

В результате появляется универсальная функция с флагами и множеством ветвлений.

Иногда две отдельные функции эффективнее:

def validate_registration_email(email: str) -> None:
    ...


def validate_billing_email(email: str) -> None:
    ...

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

Устранение дублирования вредит, когда оно создаёт:

  • условные флаги;

  • сложную универсальную функцию;

  • зависимость между разными бизнес-процессами;

  • необходимость изменять общий код ради одного сценария.

4. Когда создают интерфейс для каждой зависимости #

Например:

class EmailNormalizer(Protocol):
    def normalize(self, email: str) -> str:
        ...

Реализация:

class DefaultEmailNormalizer:
    def normalize(self, email: str) -> str:
        return email.strip().lower()

Для стабильной чистой функции интерфейс обычно не нужен:

def normalize_email(email: str) -> str:
    return email.strip().lower()

Интерфейсы полезны для внешних или изменчивых зависимостей:

платёжный провайдер
хранилище файлов
почтовый сервис
очередь сообщений
часы и генераторы идентификаторов
репозиторий со значимой логикой

Они ухудшают эффективность, когда добавляют церемонию, но не дают заменяемости, изоляции или более ясного контракта.

5. Когда архитектуру проектируют под гипотетическое будущее #

Разработчик заранее поддерживает:

пять типов БД
несколько брокеров
разные ORM
несколько HTTP-фреймворков
замену PostgreSQL на MongoDB

Хотя реальных требований на это нет.

Появляются:

  • собственные универсальные ORM-обёртки;

  • фабрики фабрик;

  • абстрактные query builders;

  • лишние DTO и mapper-ы;

  • ограничения на использование возможностей конкретной технологии.

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

6. Когда скрывают возможности фреймворка #

Попытка полностью изолировать приложение от SQLAlchemy или Django ORM может привести к интерфейсу наименьшего общего знаменателя:

await repository.find(filters)

Но конкретной задаче нужен:

JOIN
SELECT FOR UPDATE
CTE
bulk insert
ON CONFLICT
window function
оптимизированная агрегация

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

Это ухудшает и скорость разработки, и runtime-производительность.

Иногда прямой запрос понятнее и эффективнее:

stmt = (
    select(User.id, func.count(Order.id))
    .join(Order)
    .group_by(User.id)
)

7. Когда чистая объектная модель создаёт N+1 #

Красивый код:

for order in orders:
    customer = await customer_repository.get_by_id(
        order.customer_id,
    )
    await send_notification(customer, order)

Может выполнить сотни запросов.

Эффективный вариант:

customer_ids = {
    order.customer_id
    for order in orders
}

customers = await customer_repository.get_many(customer_ids)
customers_by_id = {
    customer.id: customer
    for customer in customers
}

Или один JOIN.

Проблема возникает, когда локально красивый метод скрывает стоимость I/O:

один вызов метода
≠ одна дешёвая операция

Особенно опасны абстракции над:

  • базой данных;

  • сетью;

  • файловой системой;

  • брокером;

  • внешним API.

8. Когда DTO и mapper-ы многократно копируют данные #

Цепочка:

ORM model
→ repository DTO
→ domain entity
→ use-case DTO
→ response DTO
→ JSON

Каждый переход может создавать новый объект и копировать поля.

В крупной системе границы моделей могут быть полезны. Но для простого чтения данных такая цепочка:

  • увеличивает объём кода;

  • расходует CPU и память;

  • усложняет добавление нового поля;

  • создаёт риск забыть поле в одном из mapper-ов.

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

9. Когда неизменяемость создаёт лишние копирования #

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

Например, при каждом изменении создаётся новый большой объект:

new_state = replace(
    old_state,
    items=[*old_state.items, new_item],
)

Для небольшого бизнес-объекта это нормально. Для большого массива или высокочастотной обработки — может создавать много аллокаций и нагрузку на сборщик мусора.

В performance-critical коде контролируемая мутация иногда эффективнее:

items.append(new_item)

Но её границы должны быть хорошо определены.

10. Когда исключения используются для обычного потока #

Например:

try:
    user = await repository.get_by_email(email)
except UserNotFoundError:
    user = await repository.create(email)

Если отсутствие пользователя — нормальный ожидаемый результат, проще вернуть None:

user = await repository.get_by_email(email)

if user is None:
    user = await repository.create(email)

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

  • скрывать ожидаемый поток;

  • усложнять трассировку;

  • увеличивать runtime-расходы в горячем участке;

  • засорять мониторинг ожидаемыми событиями.

11. Когда универсальность мешает batch-операциям #

Абстракция рассчитана только на один объект:

async def save(self, user: User) -> None:
    ...

При обработке тысячи пользователей получается:

for user in users:
    await repository.save(user)

Это тысяча последовательных операций.

Эффективный API должен учитывать реальную модель нагрузки:

async def save_many(
    self,
    users: Sequence[User],
) -> None:
    ...

Clean API не обязательно должен быть минималистичным. Иногда отдельная batch-операция — это правильная абстракция.

12. Когда тестируемость достигается чрезмерным mocking #

Каждая зависимость подменяется mock-объектом:

repository.get.assert_called_once_with(user_id)
mapper.map.assert_called_once_with(user)
publisher.publish.assert_called_once()

Такие тесты фиксируют структуру реализации, а не результат.

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

Для бизнес-сценариев часто эффективнее проверять наблюдаемое поведение:

response = client.post("/orders", json=payload)

assert response.status_code == 201
assert await order_exists(response.json()["id"])

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

13. Когда производительность жертвуется ради локальной читаемости #

Например, несколько понятных проходов:

active = [user for user in users if user.active]
verified = [user for user in active if user.verified]
emails = [user.email for user in verified]

Для небольшого списка это хороший код.

В горячем участке с миллионами элементов один проход может быть эффективнее:

emails = [
    user.email
    for user in users
    if user.active and user.verified
]

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

14. Когда паттерны применяются без существующей проблемы #

Примеры:

Strategy с одной стратегией
Factory для одного конструктора
Observer для одного прямого вызова
CQRS для простого CRUD
Event bus внутри одного модуля
Repository поверх repository ORM

Паттерн эффективен, если решает конкретную проблему:

несколько алгоритмов
сложное создание объекта
независимые подписчики
разделение разных моделей чтения и записи
необходимость транзакционной границы

Без этой проблемы паттерн увеличивает стоимость понимания.

15. Когда требования быстро меняются #

На раннем этапе проекта ещё неизвестно:

  • какие границы домена стабильны;

  • какие сущности действительно нужны;

  • где будет изменчивость;

  • какая нагрузка появится;

  • какие интеграции сохранятся.

Слишком строгая архитектура в этот момент закрепляет неподтверждённые предположения.

В прототипе эффективнее:

простая реализация
→ проверка требований
→ выявление повторяющихся изменений
→ целевой рефакторинг

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

Когда отклонение от Clean Code оправдано #

Отклонение имеет смысл, когда одновременно выполняются условия:

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

Например:

# Один SQL-запрос намеренно содержит несколько JOIN:
# разбиение на repository-вызовы создаёт N+1 и увеличивает
# время ответа с 40 до 900 мс.

Это осознанный компромисс, а не оправдание хаотичного кода.

Практическое правило #

Перед добавлением «чистого» слоя стоит спросить:

Какую конкретную проблему он решает?

Есть ли эта проблема сейчас?

Уменьшится ли общая сложность?

Сколько переходов добавится при чтении?

Скрывает ли слой дорогие I/O-операции?

Нужна ли реально вторая реализация?

Можно ли добавить абстракцию позже?

Итог #

Следование Clean Code ухудшает эффективность, когда:

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

Оптимальная цель:

не максимальная чистота,
а минимальная общая стоимость
понимания, изменения и выполнения кода.


4. Читали ли вы материалы, оспаривающие общепринятые рекомендации по стилю программирования? #

Да, знаком с такими материалами. Наиболее полезны не те, которые просто объявляют Clean Code «вредным», а те, которые показывают, при каких условиях популярная рекомендация перестаёт работать.

John Ousterhout — A Philosophy of Software Design #

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

маленький и простой интерфейс
+
много скрытой внутри сложности

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

service
→ handler
→ adapter
→ repository
→ gateway

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

Casey Muratori — “Clean” Code, Horrible Performance #

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

Его основной аргумент:

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

Это может приводить к:

  • косвенным вызовам;

  • плохой локальности данных;

  • большому числу разрозненных объектов;

  • невозможности эффективно обрабатывать данные пакетно;

  • ухудшению использования кеша процессора;

  • затруднению SIMD-оптимизаций.

Например, вместо массива объектов:

for shape in shapes:
    area += shape.calculate_area()

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

При этом его вывод не следует превращать в противоположную догму. Аргумент особенно важен для game engines, обработки графики, физики, больших массивов данных и других горячих участков. Для обычного CRUD backend стоимость запроса к PostgreSQL обычно несравнимо выше стоимости дополнительного вызова Python-метода.

Sandi Metz — The Wrong Abstraction #

Метц оспаривает механическое применение DRY.

Классическая проблема:

два фрагмента кода выглядят одинаково
→ их немедленно объединяют
→ сценарии начинают развиваться по-разному
→ общая абстракция обрастает флагами и условиями

Например:

def validate_email(
    email: str,
    *,
    corporate_only: bool,
    check_domain: bool,
    allow_disposable: bool,
) -> None:
    ...

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

Главная мысль Метц: когда абстракция оказалась неправильной, полезно временно вернуть дублирование и посмотреть, какие реальные различия проявятся. То есть:

небольшое дублирование
часто дешевле неправильной зависимости

Это не отрицание DRY, а уточнение: устранять нужно повторение одного знания, а не просто похожие строки.

Rich Hickey — Simple Made Easy #

Хики разделяет понятия:

easy  → привычно, близко, быстро начать
simple → не переплетено с другими вещами

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

current_user = ...

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

С другой стороны, привычная enterprise-архитектура с большим количеством классов может выглядеть профессионально, но оставаться сложной из-за переплетения:

состояния
порядка выполнения
идентичности объектов
побочных эффектов
асинхронности

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

Out of the Tar Pit #

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

Особое внимание уделяется:

изменяемому состоянию
+
явному управлению порядком выполнения

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

Например:

создали заказ
→ изменили баланс
→ зарезервировали товар
→ получили ошибку
→ часть состояния уже изменилась
→ нужно определить, что откатывать

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

Какие рекомендации чаще всего оспаривают #

«Любая длинная функция плохая»
→ длинный линейный алгоритм иногда понятнее цепочки переходов.

«Функция должна делать только одну вещь»
→ понятие “одна вещь” субъективно и зависит от уровня абстракции.

«Нельзя допускать дублирование»
→ неправильная общая абстракция может быть дороже дублирования.

«Каждая зависимость должна иметь интерфейс»
→ интерфейс без реальной вариативности создаёт только косвенность.

«Комментарии — признак плохого кода»
→ код показывает что происходит, но не всегда объясняет почему.

«Объекты должны скрывать данные за методами»
→ для массовой обработки данных это может ухудшать производительность.

«Чем больше слоёв, тем лучше разделение ответственности»
→ слои могут просто размазывать одну операцию по проекту.

«Чистый код автоматически производителен»
→ читаемость и эффективность выполнения — разные свойства.

Мой вывод из этих материалов #

Они не доказывают, что Clean Code, SOLID или DRY бесполезны. Они показывают, что эти рекомендации являются эвристиками с областью применимости, а не законами.

Полезнее оценивать решение по конкретным результатам:

Насколько легко проследить выполнение?

Сколько понятий нужно удерживать одновременно?

Скрывает ли абстракция реальную сложность?

Локализуются ли изменения?

Видна ли стоимость I/O?

Сохраняются ли бизнес-инварианты?

Как решение ведёт себя под измеряемой нагрузкой?

Дешевле ли новая абстракция, чем проблема, которую она решает?

Хорошая инженерная позиция находится не между «всегда следовать Clean Code» и «писать всё одним полотном», а между догматизмом и измеряемым контекстом.


5. Программа может взаимодействовать с процессором напрямую? #

В каком смысле «напрямую» #

Да, программа в конечном счёте взаимодействует с процессором напрямую: процессор исполняет её машинные инструкции.

Например, исходный Python-код:

result = a + b

сам по себе процессор не понимает. В CPython его обработает интерпретатор, который уже состоит из машинных инструкций:

Python-код
→ байткод Python
→ интерпретатор CPython
→ машинные инструкции
→ процессор

Для скомпилированной программы путь короче:

C / C++ / Rust
→ компилятор
→ машинный код
→ процессор

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

Пользовательская программа #

Обычное приложение работает в непривилегированном режиме процессора — user mode:

Приложение
пользовательский режим CPU
операционная система
привилегированный режим CPU
оборудование

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

сложение и умножение
сравнение
переходы
чтение и запись своей памяти
вызов функций
SIMD-инструкции
атомарные операции

Например, условный машинный код:

mov rax, 10
add rax, 20

Процессор выполняет его непосредственно.

Но программа не может произвольно:

  • обращаться к физической памяти;

  • перенастраивать таблицы страниц;

  • управлять устройствами;

  • отключать прерывания;

  • менять привилегии;

  • обращаться к памяти другого процесса;

  • выполнять привилегированные инструкции.

Зачем нужна операционная система #

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

Программа
system call
ядро ОС
драйвер
устройство

Например:

with open("data.txt") as file:
    data = file.read()

Упрощённо происходит:

Python
→ стандартная библиотека
→ системный вызов read()
→ ядро ОС
→ файловая система
→ драйвер диска
→ устройство

Обычная программа не читает диск непосредственными командами контроллеру. Это делает ядро и драйвер.

Переход в режим ядра #

Программа вызывает специальную инструкцию системного вызова:

user mode
    ↓ syscall
kernel mode
    ↓ выполнение операции
user mode

На архитектуре x86-64 это может быть инструкция syscall.

Сама инструкция выполняется процессором напрямую, но передаёт управление ядру ОС.

Приложение выполняет syscall
CPU переключает уровень привилегий
запускается код ядра

Виртуальная и физическая память #

Программа обычно работает не с физическими адресами RAM, а с виртуальными адресами:

obj = SomeObject()

Программа видит условный адрес:

0x00007F1234567890

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

виртуальный адрес
→ MMU
→ таблица страниц
→ физический адрес

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

Может ли программа явно использовать инструкции процессора #

Да. Например, через:

  • ассемблер;

  • inline assembly;

  • compiler intrinsics;

  • библиотеки, использующие SIMD;

  • машинный код, сгенерированный JIT-компилятором.

Пример на C с использованием SIMD-инструкций:

#include <immintrin.h>

__m256 add_vectors(__m256 a, __m256 b) {
    return _mm256_add_ps(a, b);
}

Компилятор может превратить это в инструкцию вроде:

vaddps ymm0, ymm0, ymm1

Процессор выполняет её непосредственно.

Программа также может использовать атомарные инструкции для синхронизации потоков:

lock.acquire()

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

Интерпретируемая программа #

Даже интерпретируемый язык взаимодействует с процессором, но опосредованно:

Python-код
→ CPython
→ машинный код CPython
→ CPU

То есть процессор не исполняет напрямую строку:

print("Hello")

Он исполняет машинный код интерпретатора, который реализует смысл этой строки.

Для Java:

Java-код
→ JVM bytecode
→ интерпретация или JIT-компиляция
→ машинный код
→ CPU

JIT-компилятор во время работы создаёт машинные инструкции, которые затем исполняются процессором.

Когда доступ действительно почти прямой #

Код ядра ОС #

Ядро работает в привилегированном режиме и может:

  • настраивать MMU;

  • управлять прерываниями;

  • переключать процессы;

  • взаимодействовать с устройствами;

  • выполнять привилегированные инструкции.

Но даже ядро действует согласно архитектуре процессора и аппаратным протоколам.

Драйверы #

Драйвер может взаимодействовать с регистром устройства через memory-mapped I/O:

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

Такой доступ обычно разрешён только ядру или специально настроенному процессу.

Bare-metal программа #

На микроконтроллере программа может работать без полноценной ОС:

Программа
→ CPU
→ регистры устройства

Например:

GPIO_OUTPUT_REGISTER |= 1 << 5;

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

Это наиболее близко к прямому управлению оборудованием.

Привилегированные инструкции #

Процессор сам контролирует, какие инструкции разрешено выполнять программе.

Условно:

обычная инструкция
→ выполняется

привилегированная инструкция из user mode
→ исключение
→ управление получает ядро

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

На x86 традиционно говорят о кольцах привилегий:

Ring 3 → пользовательские приложения
Ring 0 → ядро ОС

Большинство современных ОС в основном используют эти два уровня.

Процессор не понимает понятия программы высокого уровня #

CPU не знает о:

Python-функциях
классах
HTTP
FastAPI
переменных Python
корутинах asyncio
SQLAlchemy

Он работает с более низкоуровневыми сущностями:

машинными инструкциями
регистрами
адресами памяти
переходами
прерываниями
уровнями привилегий

Например:

total = price * quantity

в конечном итоге превращается в последовательность вроде:

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

Итог #

Обычная программа
├── напрямую исполняется процессором как машинные инструкции
├── напрямую использует регистры и свою виртуальную память
├── может применять SIMD и атомарные инструкции
└── не имеет неограниченного доступа к оборудованию

Для файлов, сети и устройств
→ обращается к ядру через системные вызовы

Ядро и драйверы
→ имеют привилегированный доступ к процессору и оборудованию

Bare-metal программа
→ может взаимодействовать с аппаратурой почти напрямую

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


6. Как программа взаимодействует через операционную систему? #

Общая схема #

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

Программа
библиотека / runtime
системный вызов
ядро операционной системы
драйвер
устройство

Например, Python-код:

with open("data.txt", "rb") as file:
    data = file.read()

упрощённо проходит такой путь:

Python-код
→ CPython
→ системные вызовы open/read
→ ядро ОС
→ файловая система
→ драйвер накопителя
→ диск

User mode и kernel mode #

Процессор поддерживает уровни привилегий.

Обычная программа работает в пользовательском режиме:

user mode

В нём запрещено напрямую:

  • обращаться к физической памяти;

  • управлять устройствами;

  • менять таблицы страниц;

  • отключать прерывания;

  • читать память другого процесса;

  • выполнять привилегированные инструкции.

Ядро ОС работает в привилегированном режиме:

kernel mode

Оно может:

  • управлять процессами;

  • выделять память;

  • работать с устройствами;

  • обрабатывать сеть;

  • управлять файловыми системами;

  • переключать потоки;

  • проверять права доступа.

Системные вызовы #

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

Примеры операций:

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

На Linux это системные вызовы вроде:

openat
read
write
close
socket
connect
accept
mmap
fork
execve

Программа обычно не вызывает их вручную. Она использует стандартную библиотеку или runtime.

Например:

data = file.read()

CPython в итоге вызывает функцию ОС, которая приводит к системному вызову read.

Что происходит при системном вызове #

Упрощённо:

1. Программа подготавливает аргументы
2. Выполняет специальную инструкцию syscall
3. CPU переключается в kernel mode
4. Ядро проверяет параметры и права
5. Ядро выполняет операцию
6. Возвращает результат
7. CPU возвращается в user mode

Схема:

Приложение
   │ syscall
Ядро
   │ результат / ошибка
Приложение

Например, чтение файла:

read(fd, buffer, 4096)

Ядро проверяет:

  • существует ли файловый дескриптор;

  • разрешено ли чтение;

  • доступна ли память буфера;

  • есть ли данные;

  • нужно ли обращаться к диску.

После этого возвращает число прочитанных байтов или ошибку.

Файловые дескрипторы #

ОС обычно не отдаёт программе прямой объект устройства или файла. Вместо этого она выдаёт идентификатор — файловый дескриптор.

file = open("data.txt")

Упрощённо ОС возвращает:

fd = 3

Дальше программа говорит:

прочитай 4096 байт из fd=3

Ядро знает, что 3 в этом процессе относится к конкретному открытому файлу.

Файловыми дескрипторами в Unix-подобных ОС могут быть представлены:

файлы
сокеты
pipes
терминалы
устройства
eventfd

Работа с файлами #

Пример:

with open("report.txt", "w") as file:
    file.write("Hello")

Путь выполнения:

open("report.txt")
ядро ищет путь в файловой системе
проверяет права
создаёт файловый дескриптор
write(fd, "Hello")
данные попадают в page cache
позже записываются на диск

Важно: write() не всегда означает немедленную физическую запись на накопитель. ОС часто сначала помещает данные в кеш памяти.

Работа с сетью #

Пример:

import socket

sock = socket.socket()
sock.connect(("example.com", 443))

Упрощённая схема:

Программа
→ socket()
→ ядро создаёт сетевой сокет
→ connect()
→ TCP/IP stack ОС
→ драйвер сетевой карты
→ сетевой адаптер
→ сеть

При отправке:

sock.send(b"hello")

происходит:

данные программы
→ буфер сокета
→ TCP
→ IP
→ сетевой драйвер
→ сетевой адаптер

При получении данных ОС может уведомить программу через:

select
poll
epoll
kqueue
IOCP

Асинхронный код и ОС #

В asyncio event loop обычно ждёт события от операционной системы.

Например:

data = await reader.read(1024)

Это не обязательно означает, что Python-поток постоянно проверяет сокет.

Упрощённо:

1. Сокет пока не готов
2. Event loop регистрирует интерес в ОС
3. Корутина приостанавливается
4. ОС получает данные
5. ОС сообщает, что сокет готов
6. Event loop возобновляет корутину

На разных ОС используются разные механизмы:

Linux   → epoll
macOS   → kqueue
Windows → IOCP

Работа с памятью #

Программа видит виртуальную память, а не физическую RAM.

Процесс
→ виртуальный адрес
→ MMU
→ таблицы страниц ОС
→ физическая память

Каждый процесс получает собственное виртуальное адресное пространство.

Например, два процесса могут использовать одинаковый виртуальный адрес:

0x100000

но он будет указывать на разные физические страницы памяти.

Когда программе нужна память:

data = bytearray(100_000_000)

runtime может запросить память у ОС через механизмы вроде:

mmap
VirtualAlloc
brk

Но мелкие объекты Python обычно выдаются внутренним аллокатором CPython из уже полученных от ОС блоков.

Page fault #

Если процесс обращается к виртуальной странице, которая ещё не загружена, возникает page fault:

Программа обращается к адресу
страницы нет в RAM
CPU передаёт управление ядру
ядро загружает или создаёт страницу
инструкция повторяется

Page fault не обязательно является ошибкой приложения. Это нормальная часть работы виртуальной памяти.

Создание процесса #

Когда запускается программа:

python app.py

ОС:

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

На Unix запуск часто связан с:

fork
execve

На Windows используется другой API создания процессов.

Потоки и планировщик #

Программа может создать несколько потоков.

from threading import Thread

ОС управляет их выполнением:

поток A
поток B
поток C
планировщик ОС
ядра процессора

Планировщик решает:

  • какой поток запускать;

  • на каком ядре;

  • на сколько времени;

  • когда его приостановить;

  • когда возобновить.

Переключение может происходить из-за:

истечения кванта времени
ожидания I/O
системного вызова
блокировки
прерывания
более приоритетного потока

Ввод с клавиатуры и мыши #

Когда пользователь нажимает клавишу:

клавиатура
→ контроллер устройства
→ аппаратное прерывание
→ драйвер
→ ядро ОС
→ оконная система
→ очередь событий приложения

Программа затем получает событие:

KeyDown
MouseMove
ButtonClick

Она не опрашивает физические контакты клавиатуры напрямую.

Драйверы #

Драйвер — компонент, который знает, как работать с конкретным устройством.

Приложение
→ универсальный API ОС
→ драйвер конкретного устройства
→ устройство

Например, программа просит:

отправить сетевой пакет

Она не обязана знать модель сетевой карты. Эти детали знает драйвер.

Прерывания #

Устройства могут сообщать процессору о событиях через аппаратные прерывания.

Например:

сетевой адаптер получил пакет
генерирует прерывание
CPU временно переключается на обработчик ядра
драйвер обрабатывает пакет
данные помещаются в буфер сокета
ожидающий процесс пробуждается

Так ОС узнаёт, что операция завершилась.

IPC — взаимодействие процессов #

Процессы изолированы друг от друга, поэтому для обмена они используют механизмы ОС:

pipes
named pipes
сокеты
shared memory
message queues
signals
semaphores

Например:

FastAPI process
→ Unix socket
→ Nginx process

Или:

parent process
→ pipe
→ child process

ОС контролирует права и передачу данных.

Что делает стандартная библиотека #

Обычно код работает не напрямую с системными вызовами, а через несколько уровней:

Ваш код
→ библиотека языка
→ системная библиотека
→ syscall
→ ядро

Например:

print("Hello")

может пройти путь:

print
→ CPython
→ write(stdout_fd, ...)
→ ядро
→ терминал

Что происходит при ошибке #

Если операция невозможна, ядро возвращает код ошибки.

Например:

open("/root/secret.txt")

Ядро может вернуть:

EACCES

Runtime языка превращает это в исключение:

PermissionError

Схема:

ядро: EACCES
→ libc/runtime
→ Python exception
→ PermissionError

Пример полного пути HTTP-запроса #

Код:

response = await client.get("https://example.com")

Упрощённо:

Python
→ HTTP-клиент
→ DNS API ОС
→ socket()
→ connect()
→ TCP stack ядра
→ TLS-библиотека
→ send()
→ драйвер сети
→ сетевой адаптер
→ интернет

Ответ идёт обратно:

сетевой адаптер
→ прерывание
→ драйвер
→ TCP stack
→ буфер сокета
→ event loop
→ HTTP-клиент
→ Python-код

Итог #

Программа
├── выполняет обычные инструкции процессора
├── работает в user mode
├── использует виртуальную память
├── обращается к ОС через системные вызовы
└── получает ошибки и события от ОС

Операционная система
├── управляет памятью
├── планирует потоки
├── работает с файлами
├── реализует сетевой стек
├── управляет устройствами через драйверы
├── проверяет права доступа
└── изолирует процессы

То есть ОС выступает одновременно как:

посредник
менеджер ресурсов
защитный слой
унифицированный интерфейс к оборудованию


7. Знакомы ли вы с архитектурой аппаратной части компьютера? #

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

Основные компоненты #

Компьютер
├── CPU
│   ├── ядра и аппаратные потоки
│   ├── регистры
│   ├── ALU/FPU
│   ├── блок управления
│   ├── конвейер
│   ├── предсказатель переходов
│   ├── декодирование и исполнение инструкций
│   └── кеши L1/L2/L3
├── Оперативная память
│   ├── DRAM
│   ├── контроллер памяти
│   ├── каналы памяти
│   └── виртуальная память и MMU
├── Материнская плата
│   ├── chipset
│   ├── PCI Express
│   ├── системные контроллеры
│   └── UEFI/BIOS
├── Накопители
│   ├── SATA
│   └── NVMe через PCIe
├── Устройства
│   ├── GPU
│   ├── сетевой адаптер
│   ├── USB-контроллер
│   └── звуковое устройство
└── Питание и охлаждение

На уровне процессора #

Могу разобрать:

  • цикл fetch → decode → execute;

  • ISA и микроархитектуру;

  • x86-64 и ARM;

  • out-of-order execution;

  • superscalar execution;

  • speculative execution;

  • branch prediction;

  • конвейер процессора;

  • SIMD: SSE, AVX;

  • атомарные инструкции;

  • ring levels и привилегированные инструкции;

  • прерывания и исключения CPU.

Упрощённо выполнение инструкции выглядит так:

Оперативная память
Кеш процессора
Fetch инструкции
Decode
Планирование микроопераций
Исполнительные блоки
Запись результата

На уровне памяти #

Могу объяснить всю иерархию:

Регистры
L1 cache
L2 cache
L3 cache
RAM
SSD

Чем ниже уровень, тем обычно:

больше объём
ниже цена хранения
выше задержка доступа

Также знаком с:

  • cache lines;

  • cache misses;

  • spatial и temporal locality;

  • TLB;

  • page tables;

  • MMU;

  • page faults;

  • NUMA;

  • false sharing;

  • когерентностью кешей;

  • memory ordering.

На уровне взаимодействия устройств #

Устройство обычно не «общается с приложением» напрямую:

Программа
→ системный вызов
→ ядро ОС
→ драйвер
→ контроллер устройства
→ устройство

При этом используются:

  • MMIO — регистры устройства отображаются в адресное пространство;

  • DMA — устройство передаёт данные в RAM без копирования каждого байта процессором;

  • аппаратные прерывания — устройство сообщает CPU о завершении операции;

  • PCI Express — основная высокоскоростная шина современных компьютеров.

Например, получение сетевого пакета:

Сетевая карта
→ DMA записывает пакет в RAM
→ аппаратное прерывание
→ сетевой драйвер
→ сетевой стек ОС
→ сокет процесса
→ приложение


8. Что такое сигналы в операционных системах и для чего они используются? #

Что такое сигналы #

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

Упрощённо:

Источник события
ядро ОС формирует сигнал
сигнал доставляется процессу
процесс выполняет обработчик
или стандартное действие

Сигнал не передаёт произвольное большое сообщение. Обычно он сообщает только:

«произошло событие определённого типа»

Например:

пользователь нажал Ctrl+C
процесс нужно завершить
дочерний процесс завершился
истёк таймер
произошло недопустимое обращение к памяти

Где используются сигналы #

Сигналы характерны прежде всего для Unix-подобных ОС:

Linux
macOS
BSD

В Windows есть другие механизмы, хотя некоторые среды частично эмулируют Unix-сигналы.

Пример с Ctrl+C #

Когда пользователь нажимает:

Ctrl+C

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

SIGINT

Дальше процесс:

получает SIGINT
→ выполняет обработчик
или
→ завершается по стандартному поведению

В Python это часто превращается в:

KeyboardInterrupt

Основные источники сигналов #

Сигнал может отправить:

Ядро ОС #

Например, при ошибке выполнения:

деление на ноль
недопустимый доступ к памяти
обращение к закрытому каналу

Пользователь через терминал #

Ctrl+C  → SIGINT
Ctrl+Z  → SIGTSTP
Ctrl+\  → SIGQUIT

Другой процесс #

Например:

kill -TERM 1234

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

Сам процесс #

Процесс может послать сигнал самому себе или настроить таймер:

import os
import signal

os.kill(os.getpid(), signal.SIGTERM)

Что происходит при доставке сигнала #

У каждого сигнала есть стандартное действие:

завершить процесс
завершить с core dump
проигнорировать
остановить процесс
продолжить выполнение

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

Упрощённо:

сигнал поступил
сигнал заблокирован?
    ├── да → остаётся pending
    └── нет
есть пользовательский обработчик?
    ├── да → выполняется обработчик
    └── нет → стандартное действие

Пример обработчика в Python #

import signal
import sys
import time


def handle_sigterm(
    signum: int,
    frame,
) -> None:
    print("Получен SIGTERM, завершаемся корректно")
    sys.exit(0)


signal.signal(signal.SIGTERM, handle_sigterm)

while True:
    time.sleep(1)

Другой процесс отправляет:

kill -TERM <pid>

Программа не завершается мгновенно, а сначала выполняет обработчик.

Это позволяет:

закрыть соединения
дождаться активных задач
записать данные
освободить ресурсы
завершить worker

Часто используемые сигналы #

SIGINT #

Обычно приходит при Ctrl+C.

назначение:
прервать выполнение программы

Процесс может обработать его и выполнить корректное завершение.

SIGTERM #

Стандартная просьба завершиться.

SIGTERM
→ «пожалуйста, завершись корректно»

Именно его обычно сначала отправляют сервису, контейнеру или worker-процессу.

SIGKILL #

Принудительное завершение:

SIGKILL
→ процесс немедленно уничтожается ядром

Его нельзя:

перехватить
проигнорировать
заблокировать

Поэтому процесс не успевает выполнить cleanup.

SIGHUP #

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

SIGHUP
→ перечитать конфигурацию

Например, сервис может обновить настройки без полного перезапуска.

SIGSTOP #

Безусловно останавливает процесс.

Как и SIGKILL, его нельзя перехватить или проигнорировать.

SIGCONT #

Продолжает выполнение остановленного процесса.

SIGCHLD #

Родитель получает его, когда дочерний процесс:

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

Родитель затем может вызвать wait() или аналогичный механизм и получить статус дочернего процесса.

SIGSEGV #

Возникает при недопустимом обращении к памяти:

Segmentation Fault

Например, процесс попытался обратиться к памяти, которой у него нет.

SIGABRT #

Обычно используется для аварийного завершения через abort().

SIGALRM #

Приходит после срабатывания таймера.

SIGPIPE #

Может возникнуть, если процесс пишет в pipe или socket, у которого больше нет читателя.

SIGTERM и SIGKILL #

Ключевое различие:

SIGTERM
→ процесс может обработать
→ можно корректно завершиться

SIGKILL
→ обрабатывает только ядро
→ процесс завершается немедленно

Типичная стратегия:

1. отправить SIGTERM
2. подождать grace period
3. если процесс не завершился — отправить SIGKILL

Так работают многие системы управления процессами и контейнерами.

Сигналы в Docker и Kubernetes #

При остановке контейнера обычно сначала отправляется сигнал завершения главному процессу контейнера:

SIGTERM

Процесс должен:

перестать принимать новые запросы
дождаться текущих операций
закрыть соединения
завершиться

Если он не завершился за отведённое время, система может применить SIGKILL.

Поэтому backend-приложению важно корректно обрабатывать graceful shutdown.

Сигналы и процессы #

Сигнал адресуется процессу, но в многопоточной программе есть нюансы.

Ядро может доставить сигнал:

конкретному потоку
или
одному из потоков процесса,
который не блокирует этот сигнал

Некоторые сигналы привязаны к конкретному потоку, например ошибка памяти во время выполнения его инструкции.

В Python обработчики сигналов обычно выполняются в главном потоке интерпретатора.

Блокировка сигналов #

Процесс или поток может временно заблокировать некоторые сигналы.

сигнал пришёл
→ сейчас заблокирован
→ становится pending
→ будет доставлен после разблокировки

Это полезно для защиты критических участков:

начало изменения общего состояния
→ заблокировать сигнал
→ завершить изменение
→ разблокировать

Но SIGKILL и SIGSTOP блокировать нельзя.

Ожидающие сигналы #

Если сигнал пришёл, но пока не может быть обработан, он становится pending.

Для обычных Unix-сигналов несколько одинаковых сигналов могут объединиться:

SIGUSR1
SIGUSR1
SIGUSR1

Процесс может фактически увидеть только один pending-сигнал этого типа.

Поэтому обычные сигналы не подходят для подсчёта событий:

не следует считать:
один сигнал = одно гарантированно сохранённое событие

Real-time signals #

Unix-подобные системы также могут поддерживать realtime signals.

Они отличаются тем, что:

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

Но для сложного обмена данными обычно всё равно используют другие механизмы IPC.

Сигнал — не полноценный канал передачи данных #

Сигналы плохо подходят для передачи бизнес-данных.

Нельзя удобно отправить:

{
  "order_id": 42,
  "status": "paid"
}

Для этого используют:

pipe
socket
message queue
shared memory
файл
брокер сообщений

Сигнал скорее сообщает:

«проверь состояние»
«заверши работу»
«перечитай конфигурацию»
«дочерний процесс изменил состояние»

Сигналы и системные вызовы #

Сигнал может прервать выполнение системного вызова.

Например, процесс ожидает чтения:

read()

В этот момент приходит сигнал.

В зависимости от ОС, настроек и конкретного системного вызова:

обработчик выполнится
системный вызов будет продолжен
или
вернётся ошибка EINTR

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

Сигналы и исключения языка #

Сигнал ОС и исключение Python — разные уровни.

SIGINT
→ механизм ОС

KeyboardInterrupt
→ исключение Python

CPython получает сигнал и преобразует его в поведение, понятное Python-программе.

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

Сигналы и аппаратные прерывания #

Их не нужно путать.

аппаратное прерывание
→ устройство уведомляет процессор и ядро

сигнал
→ ядро уведомляет процесс или поток

Например:

клавиатура
→ аппаратное прерывание
→ драйвер и ядро
→ терминал определяет Ctrl+C
→ ядро отправляет процессу SIGINT

Практические сценарии #

Сигналы используются для:

корректного завершения сервиса
аварийного завершения процесса
приостановки и продолжения процесса
перезагрузки конфигурации
уведомления о дочерних процессах
таймеров
сообщения об ошибках CPU и памяти
управления worker-процессами

Итог #

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

Самый практический пример:

SIGTERM
→ попросить сервис корректно завершиться

SIGKILL
→ немедленно уничтожить процесс

SIGINT
→ прерывание через Ctrl+C

SIGHUP
→ часто перечитать конфигурацию

SIGCHLD
→ дочерний процесс изменил состояние


9. Какое у вас направление (специализация/профиль) высшего образования? #

Кандидат отвечает сам


10. Как выполняется программа на уровне процессора и памяти? #

Общая схема #

На уровне процессора и памяти выполнение программы выглядит примерно так:

Исходный код
Компилятор или интерпретатор
Машинные инструкции и данные
ОС создаёт процесс
Код и данные отображаются в виртуальную память
CPU выбирает, декодирует и исполняет инструкции
Результаты сохраняются в регистрах, кеше и памяти

Процессор не понимает Python, классы, HTTP или функции высокого уровня. Он исполняет только инструкции своей архитектуры — например, x86-64 или ARM.


1. Как программа превращается в исполняемый код #

Для компилируемого языка:

C / C++ / Rust
→ компилятор
→ объектные файлы
→ линкер
→ исполняемый файл
→ машинный код

Например:

int result = a + b;

может превратиться в инструкции, условно похожие на:

mov eax, [a]
add eax, [b]
mov [result], eax

Для Python путь другой:

Python-код
→ байткод Python
→ интерпретатор CPython
→ машинный код CPython
→ CPU

Например:

result = a + b

процессор не исполняет напрямую. Он исполняет машинные инструкции интерпретатора, который:

  1. получает очередную инструкцию Python-байткода;

  2. определяет операцию сложения;

  3. проверяет типы объектов;

  4. вызывает подходящую реализацию;

  5. создаёт или получает объект результата.


2. Что делает ОС при запуске программы #

Когда запускается исполняемый файл, ОС создаёт процесс.

Упрощённо она:

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

Процесс получает собственное виртуальное адресное пространство.

Примерная структура:

Высокие адреса
┌────────────────────────────┐
│ Stack                      │
│ локальные переменные       │
│ адреса возврата            │
│ кадры вызовов              │
├────────────────────────────┤
│ Memory mappings            │
│ библиотеки, mmap, файлы    │
├────────────────────────────┤
│ Heap                       │
│ динамические объекты       │
│ растёт по мере выделений   │
├────────────────────────────┤
│ BSS                        │
│ нулевые глобальные данные  │
├────────────────────────────┤
│ Data                       │
│ глобальные переменные      │
├────────────────────────────┤
│ Text                       │
│ машинный код               │
└────────────────────────────┘
Низкие адреса

Это упрощённая модель. Реальное расположение зависит от ОС, формата исполняемого файла и ASLR.


3. Виртуальная память #

Программа обычно работает не с физическими адресами RAM, а с виртуальными адресами.

Например, инструкция обращается к адресу:

0x00007F20A1001000

Это ещё не физический адрес памяти.

Перевод выполняется так:

виртуальный адрес
TLB
      ↓ если нет записи
таблицы страниц
физический адрес RAM

Компоненты:

MMU
→ аппаратный блок перевода адресов

Page table
→ таблица соответствия виртуальных страниц физическим

TLB
→ небольшой быстрый кеш переводов адресов

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

Процесс A: 0x1000 → физическая страница 50
Процесс B: 0x1000 → физическая страница 900

Так обеспечивается изоляция процессов.


4. Страницы памяти #

Память делится на страницы, часто размером 4 КиБ, хотя существуют и большие страницы.

виртуальная память
├── страница 0
├── страница 1
├── страница 2
└── ...

Каждая страница имеет права:

read
write
execute

Например:

код программы
→ read + execute

обычные данные
→ read + write

stack
→ read + write

Защита памяти может запрещать выполнение кода из страниц данных:

NX / XD bit

Если программа обращается к отсутствующей или запрещённой странице, CPU генерирует исключение page fault.


5. Page fault #

Page fault не всегда является ошибкой.

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

CPU выполняет загрузку из памяти
страница ещё не находится в RAM
CPU передаёт управление ядру
ядро находит нужные данные
загружает страницу
обновляет page table
инструкция повторяется

Но если адрес недопустим:

у процесса нет такой страницы
или
нет нужного права доступа

ОС может завершить процесс. В Unix-подобной системе это часто проявляется как:

SIGSEGV
Segmentation fault

6. Регистры процессора #

Регистры — самая быстрая память, расположенная внутри CPU.

Они хранят:

операнды
результаты вычислений
адреса
состояние выполнения
указатель стека
адрес следующей инструкции

Условные примеры x86-64:

RAX, RBX, RCX
→ регистры общего назначения

RSP
→ указатель стека

RIP
→ адрес следующей инструкции

RFLAGS
→ флаги результата операций

Например:

mov rax, 10
mov rbx, 20
add rax, rbx

После выполнения:

RAX = 30

7. Цикл выполнения инструкции #

Классическая упрощённая модель:

Fetch
→ Decode
→ Execute
→ Write back

Fetch #

Процессор получает инструкцию по адресу, содержащемуся в instruction pointer:

RIP → адрес следующей инструкции

Инструкция обычно уже находится в кеше инструкций.

Decode #

Процессор определяет:

какая это операция
какие нужны операнды
какие регистры используются
нужен ли доступ к памяти

Сложные x86-инструкции могут разбиваться на внутренние микрооперации.

Execute #

Микрооперации отправляются в исполнительные блоки:

ALU
→ целочисленная арифметика

FPU
→ числа с плавающей точкой

Load/Store units
→ чтение и запись памяти

Branch units
→ переходы

SIMD units
→ векторные операции

Write back #

Результат записывается:

в регистр
в кеш
в память
в специальные флаги

8. Современный процессор не исполняет инструкции строго по одной #

Современный CPU использует:

конвейер
суперскалярное исполнение
out-of-order execution
speculative execution
branch prediction
register renaming

Поэтому реальная схема сложнее:

Fetch
→ Decode
→ Rename
→ Dispatch
→ Schedule
→ Execute
→ Retire

Несколько инструкций могут находиться на разных стадиях одновременно.

Инструкция 1 → Execute
Инструкция 2 → Decode
Инструкция 3 → Fetch

Это и есть идея конвейера.


9. Out-of-order execution #

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

Исходный код:

load rax, [slow_memory]
add  rbx, rcx
mul  rdx, r8
add  r9, rax

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

add rbx, rcx
mul rdx, r8

Но последняя инструкция зависит от rax, поэтому должна ждать.

Внутри CPU порядок исполнения может быть изменён, но результаты фиксируются архитектурно в правильном порядке на стадии retire.


10. Предсказание переходов #

Условие:

if user.is_active:
    process(user)

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

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

условие будет истинным
или
условие будет ложным

При правильном прогнозе конвейер продолжает работать.

При ошибке:

неправильно выполненная спекулятивная работа отменяется
→ конвейер частично очищается
→ выполнение продолжается по правильной ветке

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


11. Иерархия памяти #

CPU намного быстрее оперативной памяти, поэтому используется несколько уровней кеша:

Регистры
L1 cache
L2 cache
L3 cache
RAM
SSD

Чем дальше уровень от CPU:

больше объём
выше задержка
ниже стоимость хранения

Условно:

регистры → единицы тактов
L1       → несколько тактов
L2       → больше
L3       → десятки тактов
RAM      → сотни тактов и более
SSD      → на порядки медленнее

Точные значения сильно зависят от архитектуры и конкретного процессора.


12. Cache line #

Кеш обычно загружает не один байт, а целую cache line, часто 64 байта.

Если программа читает:

array[0]

CPU может загрузить в кеш сразу:

array[0]
array[1]
array[2]
...

в пределах одной линии.

Поэтому последовательный доступ эффективен:

for item in items:
    process(item)

А хаотический доступ по разрозненным указателям может приводить к cache misses:

объект A → объект X → объект Q → объект M

Отсюда важность locality:

temporal locality
→ скоро повторно используется тот же адрес

spatial locality
→ скоро используются соседние адреса

13. Cache hit и cache miss #

При чтении данных:

CPU проверяет L1
    ↓ нет
проверяет L2
    ↓ нет
проверяет L3
    ↓ нет
обращается к RAM

Если данные найдены в кеше:

cache hit

Если нет:

cache miss

Cache miss может остановить зависимые инструкции на значительное число тактов, хотя CPU постарается выполнять другие независимые операции.


14. Stack #

Stack используется для организации вызовов функций.

Например:

def add(a, b):
    result = a + b
    return result

При вызове функции логически создаётся stack frame, который может содержать:

аргументы
локальные данные
адрес возврата
сохранённые регистры
служебную информацию

Упрощённо:

main()
  ↓ вызывает process()
Stack:
┌─────────────────┐
│ frame process   │
├─────────────────┤
│ frame main      │
└─────────────────┘

При возврате frame удаляется, а выполнение продолжается с адреса возврата.

Для Python ситуация сложнее: Python frame — это объект runtime, а не только нативный стек CPU.

Python call stack
+
нативный C stack CPython

15. Heap #

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

Например:

users = [User() for _ in range(1000)]

В CPython объекты User и список обычно размещаются в управляемой runtime памяти.

Путь может быть таким:

Python object allocator
→ allocator CPython
→ системный allocator
→ mmap / VirtualAlloc
→ виртуальные страницы
→ физическая RAM по мере обращения

То есть каждый Python-объект не обязательно вызывает отдельный системный вызов. Runtime обычно получает крупные блоки и распределяет их самостоятельно.


16. Чтение переменной из памяти #

Рассмотрим условную операцию:

result = a + b;

Может происходить:

1. CPU вычисляет адрес a
2. Load unit запрашивает данные
3. Проверяется TLB
4. Выполняется перевод виртуального адреса
5. Проверяется кеш L1
6. Значение загружается в регистр
7. Аналогично загружается b
8. ALU выполняет сложение
9. Результат записывается в регистр
10. При необходимости сохраняется в память

Условный машинный код:

mov eax, [a]
add eax, [b]
mov [result], eax

Но компилятор может оставить всё в регистрах и не обращаться к памяти лишний раз.


17. Запись в память #

Когда CPU выполняет запись:

mov [address], rax

данные обычно не отправляются сразу непосредственно в RAM.

Возможный путь:

register
→ store buffer
→ L1 cache
→ затем другие уровни кеша
→ RAM

Запись может происходить отложенно.

Из-за этого в многопоточной программе существуют вопросы:

порядка видимости записей
memory barriers
атомарности
когерентности кешей

18. Когерентность кешей #

У каждого ядра могут быть собственные L1 и L2 кеши.

Core 1 → L1 cache
Core 2 → L1 cache

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

Core 1 изменил значение
→ копия у Core 2 должна быть обновлена или признана недействительной

Для этого применяются протоколы когерентности кеша.

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

locks
atomic operations
memory ordering
barriers

19. Атомарные операции #

Обычная операция:

counter += 1

логически состоит из нескольких действий:

прочитать counter
прибавить 1
записать counter

Два потока могут потерять обновление:

Поток A читает 10
Поток B читает 10
A записывает 11
B записывает 11

Вместо ожидаемого 12 получается 11.

Атомарные инструкции позволяют выполнять некоторые операции неделимо с точки зрения других ядер:

compare-and-swap
atomic exchange
fetch-and-add

На их основе строятся:

mutex
semaphore
spinlock
lock-free структуры

20. Системный вызов #

Когда программе нужен файл, сеть или другой системный ресурс, она выполняет системный вызов.

Например:

data = socket.recv(4096)

Упрощённо:

пользовательский код
→ runtime
→ системная библиотека
→ syscall
→ переход в kernel mode
→ ядро проверяет дескриптор и буфер
→ выполняет операцию
→ возврат в user mode

CPU при этом:

  1. сохраняет часть состояния текущего потока;

  2. переключает уровень привилегий;

  3. переходит к обработчику системного вызова;

  4. исполняет код ядра;

  5. возвращается в пользовательский режим.


21. Что происходит при ожидании I/O #

Предположим, поток вызывает блокирующий read(), но данных нет.

поток вызывает read
ядро видит, что данных нет
поток переводится в состояние ожидания
CPU выполняет другой поток
устройство или сеть присылают данные
ядро пробуждает поток

Ожидающий поток не должен постоянно занимать процессор.

Для асинхронного Python:

data = await reader.read(4096)

происходит примерно:

корутина регистрирует ожидание сокета
→ приостанавливается
→ event loop запускает другие корутины
→ ОС сообщает о готовности сокета
→ event loop возобновляет корутину

22. Прерывания #

Устройство может уведомить CPU о событии аппаратным прерыванием.

Например, пришёл сетевой пакет:

сетевая карта
→ записывает данные в RAM через DMA
→ отправляет прерывание
→ CPU запускает обработчик ядра
→ драйвер обрабатывает событие
→ пакет передаётся сетевому стеку
→ ожидающий сокет становится готов

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


23. DMA #

DMA позволяет устройству передавать данные в RAM без участия CPU в копировании каждого байта.

Например, при чтении с NVMe:

CPU настраивает операцию
→ NVMe-контроллер читает данные
→ DMA записывает их в RAM
→ устройство сообщает о завершении

CPU участвует в управлении и обработке результата, но не переносит каждый байт отдельной инструкцией.


24. Планирование потоков #

Программа может иметь несколько потоков, но число одновременно выполняемых потоков ограничено аппаратными ядрами и аппаратными потоками CPU.

потоки программы
планировщик ОС
логические CPU
физические ядра

Планировщик выбирает:

какой поток выполнять
на каком ядре
с каким приоритетом
как долго

При context switch ОС сохраняет состояние одного потока:

регистры
instruction pointer
stack pointer
служебное состояние

и восстанавливает состояние другого.


25. Context switch #

Схема переключения:

Поток A выполняется
таймер / блокировка / более приоритетная задача
ядро сохраняет регистры A
загружает регистры B
Поток B выполняется

Переключение имеет стоимость:

  • работа планировщика;

  • сохранение и восстановление состояния;

  • ухудшение локальности кеша;

  • возможные TLB/cache misses;

  • переходы между режимами.

Поэтому создание огромного числа потоков не означает автоматического ускорения.


26. Полный пример #

Рассмотрим:

def calculate_total(prices: list[int]) -> int:
    return sum(prices)

Упрощённый путь:

1. ОС уже запустила процесс CPython
2. Python-код преобразован в байткод
3. CPython получает инструкцию вызова sum()
4. CPU исполняет машинный код функции CPython
5. Из Python-списка читаются ссылки на объекты
6. Виртуальные адреса переводятся через MMU
7. Данные ищутся в кеше
8. Python проверяет тип каждого объекта
9. Значения складываются
10. Результат оформляется как Python-объект
11. Ссылка на объект возвращается вызывающему коду

Для Python целочисленное сложение заметно сложнее машинного add, потому что Python integer — это объект произвольной точности.

Python int
→ объект с типом, счётчиком ссылок и массивом цифр

А не просто 64-битное значение в регистре.


27. Почему одинаковый алгоритм может работать с разной скоростью #

Даже при одинаковой алгоритмической сложности производительность зависит от:

cache locality
числа обращений к RAM
предсказуемости ветвлений
количества системных вызовов
аллокаций памяти
числа context switches
векторизации
параллелизма
структуры данных

Например:

sum(numbers)

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


28. Итоговая схема #

Программа запускается
ОС создаёт процесс и виртуальную память
Код программы отображается в память
Планировщик назначает поток ядру CPU
CPU получает адрес следующей инструкции
Fetch → Decode → Execute → Retire
Данные берутся из:
регистров → кешей → RAM
MMU переводит виртуальные адреса
Результаты записываются в регистры и память
Для файлов и сети выполняются системные вызовы
При ожидании поток приостанавливается
ОС запускает другой поток

Главное различие уровней:

Исходный код
→ описывает намерение программиста

Runtime или компилятор
→ превращает его в исполняемые операции

ОС
→ создаёт процесс, память и доступ к ресурсам

CPU
→ исполняет машинные инструкции

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


11. Какими методами можно кратно ускорить выполнение программы? #

Главное правило #

Кратно ускорить программу обычно можно не «микрооптимизацией», а изменением одного из фундаментальных факторов:

алгоритм
структура данных
количество I/O
объём обрабатываемых данных
параллелизм
уровень абстракции

Замена:

value = x * 2

на:

value = x << 1

почти никогда не даёт значимого выигрыша.

А замена алгоритма O(n²) на O(n log n) может ускорить программу в сотни или тысячи раз.

1. Сначала определить узкое место #

Без измерений оптимизация часто направлена не туда.

Нужно выяснить, где программа тратит время:

CPU
база данных
сеть
диск
сериализация
блокировки
аллокации памяти
ожидание внешнего API

Для Python можно использовать:

cProfile
py-spy
scalene
line_profiler

Для backend дополнительно важны:

время SQL-запросов
количество SQL-запросов
время внешних HTTP-вызовов
event loop lag
размер connection pool
p95/p99 latency

Оптимизировать нужно не самый некрасивый код, а самый дорогой участок.

2. Улучшить алгоритм #

Это обычно самый сильный способ ускорения.

Например, поиск элемента в списке:

users = [1, 2, 3, 4, 5]

if user_id in users:
    ...

Сложность:

O(n)

Если проверка выполняется много раз, лучше использовать set:

user_ids = {1, 2, 3, 4, 5}

if user_id in user_ids:
    ...

Средняя сложность:

O(1)

Ещё пример:

for user in users:
    for blocked_user in blocked_users:
        if user.id == blocked_user.id:
            ...

Это:

O(n × m)

Лучше:

blocked_ids = {
    user.id
    for user in blocked_users
}

for user in users:
    if user.id in blocked_ids:
        ...

Это примерно:

O(n + m)

На больших объёмах разница может быть кратной.

3. Выбрать правильную структуру данных #

Структура данных определяет стоимость операций.

list
→ быстрый последовательный обход
→ медленный поиск по значению

set
→ быстрый поиск и проверка принадлежности

dict
→ быстрый доступ по ключу

deque
→ эффективное добавление и удаление с обоих концов

heap
→ быстрый доступ к минимальному или максимальному элементу

Например, очередь через список:

item = items.pop(0)

дорога, потому что остальные элементы сдвигаются.

Лучше:

from collections import deque

items = deque()
item = items.popleft()

4. Уменьшить количество операций с базой данных #

В backend именно БД часто является главным узким местом.

Плохой вариант:

for user_id in user_ids:
    user = await User.get(id=user_id)

Для 1000 пользователей:

1000 SQL-запросов

Лучше:

users = await User.filter(
    id__in=user_ids,
)

Получается один или несколько batch-запросов.

Особенно важны:

устранение N+1
SELECT только нужных полей
bulk insert/update
правильные JOIN
индексы
пагинация
агрегация на стороне БД

Например:

SELECT *
FROM users
WHERE email = $1;

Без индекса может потребовать полного обхода таблицы.

С индексом:

CREATE INDEX idx_users_email
ON users (email);

поиск может стать на порядки быстрее.

Но индекс ускоряет чтение ценой:

дополнительной памяти
замедления INSERT/UPDATE/DELETE

5. Делать batch-операции #

Часто основная стоимость находится не в самой операции, а в её вызове:

сетевой round trip
системный вызов
транзакция
сериализация
переключение контекста

Вместо:

for notification in notifications:
    await send(notification)

лучше, если API позволяет:

await send_many(notifications)

Вместо тысячи отдельных INSERT:

INSERT INTO users (...) VALUES (...);
INSERT INTO users (...) VALUES (...);

использовать batch:

INSERT INTO users (username, email)
VALUES
    (...),
    (...),
    (...);

Batching особенно эффективен для:

БД
Redis
HTTP API
брокеров сообщений
файловых операций

6. Уменьшить объём обрабатываемых данных #

Самые быстрые данные — те, которые не пришлось читать и обрабатывать.

Плохо:

SELECT *
FROM payments;

если нужны только:

id
status
amount

Лучше:

SELECT id, status, amount
FROM payments;

Также полезно:

фильтровать данные раньше
использовать LIMIT
не загружать весь файл в память
обрабатывать данные потоково
не сериализовать ненужные поля

Пример генератора:

def read_large_file(path: str):
    with open(path) as file:
        for line in file:
            yield line

Это не обязательно ускорит CPU, но резко уменьшит потребление памяти и позволит начать обработку раньше.

7. Использовать кеширование #

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

запрос
→ дорогой расчёт или SQL
→ сохранить результат
→ последующие запросы читают кеш

Пример:

cached = await redis.get(f"user:{user_id}")

if cached is not None:
    return cached

Кешировать можно:

результаты SQL-запросов
HTTP-ответы
конфигурацию
справочники
результаты вычислений
скомпилированные шаблоны

Но кеш создаёт проблемы:

устаревшие данные
инвалидация
cache stampede
дублирование состояния
расход памяти

Кеш полезен, когда:

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

8. Использовать конкурентность для I/O-bound задач #

Если программа большую часть времени ждёт:

сеть
БД
файлы
внешние API

операции можно выполнять конкурентно.

Последовательно:

user = await get_user()
orders = await get_orders()
settings = await get_settings()

Если вызовы независимы:

import asyncio

user, orders, settings = await asyncio.gather(
    get_user(),
    get_orders(),
    get_settings(),
)

Условно:

3 запроса × 300 мс последовательно
→ около 900 мс

3 запроса одновременно
→ около 300–400 мс

Но конкурентность не должна быть неограниченной:

semaphore = asyncio.Semaphore(20)

Иначе можно перегрузить:

БД
внешний API
connection pool
оперативную память

9. Использовать параллелизм для CPU-bound задач #

asyncio почти не ускоряет тяжёлые CPU-вычисления.

Для CPU-bound задач нужны:

multiprocessing
ProcessPoolExecutor
нативные расширения
распараллеливание на несколько ядер

Пример:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as executor:
    results = list(
        executor.map(calculate, items)
    )

Подходит для:

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

Но параллелизм имеет накладные расходы:

создание процессов
IPC
сериализация
копирование данных
синхронизация

Для маленьких задач он может оказаться медленнее.

10. Перенести горячий код из Python #

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

Вместо Python-цикла:

result = []

for value in values:
    result.append(value * 2)

можно использовать библиотеку, реализованную на C:

import numpy as np

result = np.asarray(values) * 2

Варианты ускорения:

NumPy
Numba
Cython
Rust extension
C/C++ extension

Это может дать ускорение в десятки или сотни раз, если основная нагрузка — вычисления, а не I/O.

11. Использовать векторизацию и SIMD #

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

Обычная обработка:

a[0] + b[0]
a[1] + b[1]
a[2] + b[2]
a[3] + b[3]

SIMD условно делает:

[a0, a1, a2, a3]
+
[b0, b1, b2, b3]

одной векторной инструкцией.

Обычно в Python SIMD используется через:

NumPy
PyTorch
TensorFlow
нативные библиотеки
компиляторы

12. Улучшить локальность данных #

Процессор работает быстрее, когда данные расположены последовательно.

Эффективно:

массив структурированных данных
последовательный обход

Менее эффективно:

цепочка объектов
→ указатель
→ другой объект
→ ещё один указатель

Причина:

cache hit
vs
cache miss

Например, последовательный обход массива обычно быстрее обхода связного списка даже при одинаковой сложности O(n).

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

13. Сократить аллокации и копирования #

Создание большого числа объектов создаёт нагрузку на:

allocator
память
кеш процессора
garbage collector
reference counting

Плохо:

data = original[:]
result = data[:]
final = result[:]

Если копии не нужны, лучше использовать один объект или представление.

Также полезно:

использовать генераторы
не создавать временные списки
повторно использовать буферы
не копировать request/response DTO без необходимости

14. Уменьшить сериализацию #

В web-приложении значительное время может уходить на:

ORM model
→ domain entity
→ DTO
→ Pydantic model
→ dict
→ JSON

Если объект большой или таких объектов тысячи, это становится заметно.

Можно:

возвращать только нужные поля
избегать лишних преобразований
использовать быструю JSON-библиотеку
не сериализовать большие вложенные объекты
применять пагинацию

Но заменять JSON-библиотеку имеет смысл только после профилирования.

15. Сократить системные и сетевые вызовы #

Каждый системный вызов и сетевой round trip имеют стоимость.

Вместо множества маленьких записей:

for chunk in chunks:
    file.write(chunk)

можно объединять данные в более крупные блоки.

Вместо:

HTTP-запрос на каждый объект

использовать:

batch endpoint

Вместо частого открытия соединений:

создать соединение
отправить запрос
закрыть

использовать connection pooling и keep-alive.

16. Использовать компиляцию и JIT #

Интерпретируемый код можно ускорить через:

JIT-компиляцию
ahead-of-time compilation
специализацию типов

В Python возможны:

PyPy
Numba
Cython
mypyc

Результат зависит от кода. PyPy может ускорять долгоживущие чисто Python-вычисления, но не всегда хорошо сочетается с C-расширениями и конкретными библиотеками.

17. Устранить блокировки и конкуренцию за ресурсы #

Многопоточная программа может быть медленной не из-за вычислений, а из-за ожидания lock:

поток A держит mutex
потоки B, C, D ждут

Полезно:

уменьшить критическую секцию
не выполнять I/O под lock
разделить одну глобальную блокировку
использовать lock-free чтение, где оправдано
уменьшить shared mutable state

Для БД аналогичная проблема:

долгие транзакции
SELECT FOR UPDATE
конкуренция за одну строку
deadlock

Иногда ускорение достигается не увеличением CPU, а уменьшением ожидания блокировок.

18. Масштабировать программу #

Если один процесс уже оптимизирован, throughput можно увеличить горизонтально:

Load Balancer
├── Backend 1
├── Backend 2
├── Backend 3
└── Backend 4

Но это помогает только если нет общего bottleneck:

одна БД
одна блокировка
один внешний API
одна очередь

Для CPU-bound обработки можно увеличивать количество worker-процессов.

Для async web backend количество workers обычно подбирают по измерениям, а не по формуле «чем больше, тем лучше».

19. Перенести работу из request-response потока #

Некоторые операции не нужно выполнять до ответа клиенту:

отправка email
генерация отчёта
обработка изображения
индексация
уведомления

Вместо:

HTTP-запрос
→ 30 секунд работы
→ ответ

можно:

HTTP-запрос
→ поставить задачу в очередь
→ 202 Accepted
→ worker выполняет работу

Это не обязательно уменьшает суммарное CPU-время, но сильно улучшает latency и устойчивость HTTP-сервиса.

20. Использовать специализированный инструмент #

Иногда кратное ускорение достигается переносом работы туда, где она выполняется эффективнее:

Python-цикл
→ SQL-запрос

ручной поиск
→ индекс БД

Python aggregation
→ GROUP BY

локальная очередь
→ RabbitMQ/Kafka

ручные численные вычисления
→ NumPy

обработка изображения в Python
→ OpenCV

Например, плохо:

payments = await Payment.all()

total = sum(
    payment.amount
    for payment in payments
)

Лучше:

SELECT SUM(amount)
FROM payments;

БД выполнит агрегацию ближе к данным и не передаст все строки приложению.

Что обычно даёт самый большой эффект #

Приоритет можно представить так:

1. Не делать ненужную работу
2. Улучшить алгоритм
3. Сократить I/O и round trips
4. Использовать batch
5. Добавить правильные индексы
6. Использовать кеш
7. Добавить конкурентность или параллелизм
8. Перенести горячий код в нативную реализацию
9. Применять микрооптимизации

Пример для backend #

Допустим, endpoint обрабатывает 1000 платежей:

for payment in payments:
    user = await get_user(payment.user_id)
    blocked = await check_blocked(payment.account_id)
    await save_payment(payment)

Получается:

1000 запросов пользователей
1000 проверок блокировки
1000 INSERT

Ускоренная схема:

1 запрос пользователей через IN
1 запрос заблокированных счетов через IN
1 bulk insert платежей

Плюс:

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

Такое изменение может ускорить обработку не на 10–20%, а в десятки раз.

Итог #

Кратное ускорение чаще всего дают:

изменение алгоритма
правильные структуры данных
устранение N+1
индексы БД
batch-операции
уменьшение объёма данных
кеширование
конкурентное I/O
параллельные CPU-вычисления
нативный или векторизованный код

Правильная последовательность:

измерить
→ найти bottleneck
→ устранить ненужную работу
→ изменить архитектуру или алгоритм
→ снова измерить

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


12. Какими способами обеспечивают идемпотентность операций в веб-сервисах и API? #

Основной принцип #

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

Запрос №1 → операция выполнена
Запрос №2 → обнаружен повтор
Запрос №3 → обнаружен повтор

Итог: одна бизнес-операция

Обычно применяют не один механизм, а комбинацию:

идемпотентный дизайн операции
+ идентификатор операции
+ ограничения БД
+ транзакция
+ сохранение результата

1. Проектировать операцию как установку состояния #

Операция «установить значение» обычно идемпотентна:

PUT /users/42/status

{
  "status": "blocked"
}

Повторение не меняет результат:

status = blocked
status = blocked
status = blocked

Операция «изменить относительно текущего значения» обычно неидемпотентна:

POST /users/42/increase-balance

{
  "amount": 100
}

Каждый повтор добавит ещё 100.

Поэтому там, где возможно, лучше передавать целевое состояние:

Установить статус = paid

а не команду:

Переключить статус

2. Использовать подходящие HTTP-методы #

По HTTP-семантике идемпотентны:

GET
HEAD
PUT
DELETE
OPTIONS
TRACE

Обычно не гарантируют идемпотентность:

POST
PATCH

Например, клиент сам создаёт идентификатор ресурса:

PUT /orders/550e8400-e29b-41d4-a716-446655440000

{
  "product_id": 42,
  "quantity": 2
}

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

Но сам HTTP-метод не защищает плохую реализацию. Такой PUT формально используется неправильно:

@app.put("/counter")
async def update_counter():
    counter.value += 1

Повтор изменяет состояние повторно, поэтому операция фактически неидемпотентна.

3. Idempotency-Key #

Для критичных POST клиент генерирует уникальный ключ операции:

POST /payments
Idempotency-Key: 8c2d7b6e-2fde-4e17-a9f7-245a44c21f07
Content-Type: application/json

{
  "order_id": 1001,
  "amount": 50
}

Backend хранит запись:

client_id
idempotency_key
request_hash
status
response_status
response_body
resource_id
expires_at

Логика:

Ключ отсутствует:
→ зарегистрировать операцию
→ выполнить её
→ сохранить результат

Ключ уже completed:
→ повторно не выполнять
→ вернуть сохранённый результат

Ключ processing:
→ сообщить, что операция выполняется

Тот же ключ, другое тело:
→ отклонить запрос

Для новой бизнес-операции клиент обязан использовать новый ключ. При retry используется тот же самый ключ.

4. Уникальное ограничение в базе данных #

Проверка только на уровне Python небезопасна:

existing = await find_by_key(key)

if existing is None:
    await create_payment(...)

Два параллельных запроса могут одновременно увидеть отсутствие записи:

Запрос A → записи нет
Запрос B → записи нет
Запрос A → создаёт платёж
Запрос B → создаёт платёж

Нужна гарантия базы:

CREATE UNIQUE INDEX uq_idempotency_key
ON idempotency_requests (client_id, idempotency_key);

Или уникальность бизнес-идентификатора:

ALTER TABLE payments
ADD CONSTRAINT uq_payment_external_id
UNIQUE (merchant_id, external_payment_id);

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

5. INSERT ... ON CONFLICT #

В PostgreSQL дедупликацию можно выполнить атомарно:

INSERT INTO payments (
    merchant_id,
    external_payment_id,
    amount
)
VALUES (
    :merchant_id,
    :external_payment_id,
    :amount
)
ON CONFLICT (
    merchant_id,
    external_payment_id
)
DO NOTHING;

Или вернуть существующий ресурс после конфликта.

Это надёжнее схемы:

SELECT
→ если не найдено, INSERT

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

6. Естественный бизнес-ключ #

Не всегда нужен отдельный случайный Idempotency-Key. Иногда операция уже имеет стабильный уникальный идентификатор:

external_payment_id
order_number
provider_event_id
invoice_id
request_id внешней системы

Например, webhook платёжного провайдера:

{
  "event_id": "evt_84592",
  "payment_id": "pay_1001",
  "status": "succeeded"
}

Backend сохраняет event_id с уникальным ограничением:

CREATE TABLE processed_events (
    event_id text PRIMARY KEY,
    processed_at timestamptz NOT NULL
);

Повторно доставленный webhook обнаруживается по тому же event_id.

7. Таблица обработанных сообщений — Inbox #

Для consumer брокера часто используется Inbox Pattern:

получить сообщение
→ начать транзакцию
→ зарегистрировать message_id
→ изменить бизнес-данные
→ подтвердить транзакцию
→ ack сообщения

Таблица:

CREATE TABLE inbox_messages (
    consumer_name text NOT NULL,
    message_id uuid NOT NULL,
    processed_at timestamptz NOT NULL,
    PRIMARY KEY (consumer_name, message_id)
);

В одной транзакции:

INSERT INTO inbox_messages (...)
VALUES (...)
ON CONFLICT DO NOTHING;

Если вставка не произошла, сообщение уже было обработано.

Важно записывать message_id и бизнес-эффект в одной транзакции. Иначе возможны два плохих сценария:

Эффект выполнен, message_id не сохранён
→ повтор применит эффект ещё раз

message_id сохранён, эффект не выполнен
→ повтор будет ошибочно пропущен

8. Транзакция #

Регистрация ключа и изменение бизнес-данных должны быть согласованы.

BEGIN

создать idempotency record
создать платёж
изменить состояние заказа
сохранить результат операции

COMMIT

При ошибке:

ROLLBACK

Без общей транзакции может сохраниться только часть операции.

Но транзакция одной БД не охватывает автоматически:

внешний HTTP API
RabbitMQ
Kafka
email
S3
другую независимую БД

Для этого нужны дополнительные паттерны.

9. Transactional Outbox #

Допустим, нужно одновременно:

  1. создать заказ в PostgreSQL;

  2. отправить событие в RabbitMQ.

Нельзя атомарно сделать обычную транзакцию между БД и брокером:

заказ сохранён
→ публикация упала
→ события нет

Либо:

событие опубликовано
→ транзакция БД откатилась
→ событие описывает несуществующий заказ

Outbox решает это так:

Одна транзакция БД:
├── сохранить заказ
└── сохранить событие в outbox

Отдельный publisher:
→ читает outbox
→ публикует событие
→ отмечает его отправленным

Publisher может отправить одно событие повторно, поэтому consumer всё равно должен быть идемпотентным через Inbox или уникальный event_id.

10. Условные переходы состояния #

Для операций над состояниями полезна конечная машина состояний.

Например, заказ:

created → paid → shipped → delivered

Повторное событие payment_succeeded не должно повторно проводить платёж или начислять бонусы.

Атомарное обновление:

UPDATE orders
SET status = 'paid'
WHERE id = :order_id
  AND status = 'created';

После запроса проверяется количество изменённых строк:

1 строка → переход выполнен
0 строк → заказ уже paid или находится в другом состоянии

Такое условное обновление защищает и от повторов, и от части race condition.

11. Оптимистическая блокировка #

К ресурсу добавляют версию:

id = 42
status = pending
version = 7

Обновление:

UPDATE orders
SET
    status = 'paid',
    version = version + 1
WHERE id = :id
  AND version = :expected_version;

Если другой запрос уже изменил ресурс, обновится 0 строк.

В HTTP для этого можно использовать ETag и If-Match:

PATCH /documents/42
If-Match: "version-7"

Если версия изменилась:

412 Precondition Failed

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

12. Пессимистическая блокировка #

Для критического ресурса можно заблокировать строку:

SELECT *
FROM accounts
WHERE id = :account_id
FOR UPDATE;

Параллельные транзакции будут ждать.

Это помогает последовательно проверить и изменить состояние:

заблокировать счёт
→ проверить операцию
→ применить изменение
→ зафиксировать транзакцию

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

13. Сохранение исходного ответа #

Для idempotency key часто сохраняют не только факт обработки, но и ответ:

{
  "status_code": 201,
  "body": {
    "payment_id": 845,
    "status": "succeeded"
  }
}

При повторе сервер возвращает тот же логический результат:

не создавать новый платёж
→ вернуть payment_id=845

Это особенно важно, когда первый запрос был успешно обработан, но HTTP-ответ потерялся.

14. Проверка хеша запроса #

Нельзя разрешать такое:

Idempotency-Key: abc
amount: 50

повтор:
Idempotency-Key: abc
amount: 5000

Поэтому backend сохраняет нормализованный хеш значимых параметров:

hash(method + route + normalized_body + principal)

При повторе:

тот же ключ + тот же hash
→ вернуть предыдущий результат

тот же ключ + другой hash
→ 409 Conflict или 422

Важно нормализовать данные, чтобы порядок JSON-полей не менял смысловой хеш.

15. Distributed lock #

Иногда используют Redis:

SET operation:<key> processing NX EX 30

Это позволяет только одному процессу начать выполнение.

Но distributed lock не должен быть единственной гарантией:

  • lock может истечь раньше завершения операции;

  • процесс может зависнуть;

  • Redis может быть временно недоступен;

  • после освобождения lock повтор снова сможет выполнить операцию;

  • БД и Redis не находятся в одной транзакции.

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

UNIQUE constraint
условное UPDATE
таблица операций
бизнес-инвариант

16. Дедупликационное окно #

Иногда ключи хранят не навсегда, а ограниченное время:

24 часа
7 дней
срок возможных retry

После окончания TTL ключ удаляется.

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

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

17. Идемпотентный consumer #

Обработчик сообщения должен уметь безопасно получить одно сообщение несколько раз:

async def handle_payment_event(event: PaymentEvent) -> None:
    async with transaction():
        inserted = await register_event(event.id)

        if not inserted:
            return

        await mark_order_as_paid(event.order_id)

Не следует рассчитывать, что брокер физически доставит сообщение ровно один раз.

Практическая модель:

at-least-once delivery
+
idempotent consumer
=
один бизнес-эффект

18. Компенсация не равна идемпотентности #

Иногда дубликат не предотвращают, а потом отменяют:

списать 100
→ обнаружить повтор
→ вернуть 100

Это компенсация, а не настоящая идемпотентность.

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

Практическая схема для POST /payments #

Запрос:

POST /payments
Idempotency-Key: 8c2d7b6e-2fde-4e17-a9f7-245a44c21f07

{
  "order_id": 1001,
  "amount": 50
}

Таблица:

CREATE TABLE idempotency_requests (
    user_id bigint NOT NULL,
    key uuid NOT NULL,
    request_hash text NOT NULL,
    status text NOT NULL,
    response_status integer,
    response_body jsonb,
    created_at timestamptz NOT NULL DEFAULT now(),

    PRIMARY KEY (user_id, key)
);

Алгоритм:

1. Начать транзакцию
2. Попытаться вставить ключ со status=processing
3. Если ключ уже существует:
   ├── сравнить request_hash
   ├── completed → вернуть сохранённый ответ
   ├── processing → вернуть конфликт/статус операции
   └── другой hash → отклонить
4. Создать платёж с уникальным business ID
5. Сохранить status=completed и ответ
6. Commit

Что выбирать на практике #

СценарийОсновная защита
Установить состояние ресурсаPUT и присваивание состояния
Создать ресурс с ID клиентаPUT /resource/{client_id}
Критичный POSTIdempotency-Key
Платёж или заказКлюч + уникальный бизнес-ID + транзакция
WebhookУникальный event_id
Consumer брокераInbox / processed messages
БД + публикация событияTransactional Outbox
Конкурентное изменениеУсловный UPDATE, version или lock
Защита от параллельного запускаLock как дополнительный механизм

Главное #

Надёжная идемпотентность обычно строится так:

Стабильный идентификатор операции
        +
UNIQUE constraint в БД
        +
атомарная транзакция
        +
проверка содержимого запроса
        +
сохранение результата

При этом:

идемпотентность
≠ запрос физически выполняется один раз

идемпотентность
= повторный запрос не создаёт повторный бизнес-эффект


13. Что такое API и какую роль он выполняет во взаимодействии между различными программными компонентами или системами? #

Что такое API #

API (Application Programming Interface) — это программный интерфейс, который определяет, каким образом один программный компонент может обращаться к другому.

API описывает:

  • какие операции доступны;

  • какие параметры нужно передать;

  • в каком формате передаются данные;

  • что будет возвращено;

  • какие ошибки возможны;

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

Упрощённо:

Компонент A
    ↓ запрос по правилам API
Компонент B
    ↓ результат или ошибка
Компонент A

API можно рассматривать как контракт между поставщиком функциональности и её потребителем.

Пример #

Допустим, есть сервис пользователей. Он предоставляет API:

GET /users/42

Ответ:

{
  "id": 42,
  "username": "alex"
}

Клиенту не нужно знать:

  • в какой таблице хранится пользователь;

  • используется PostgreSQL или MongoDB;

  • как устроен код сервиса;

  • применяется ли кеш Redis;

  • на каком языке написан backend.

Клиенту достаточно знать контракт:

GET /users/{id}
→ возвращает данные пользователя

API — это не обязательно HTTP #

API существует на разных уровнях.

API функции #

result = calculate_total(items)

Контракт функции:

вход:
последовательность товаров

выход:
общая стоимость

Внутренняя реализация функции скрыта от вызывающего кода.

API класса #

repository.get_by_id(user_id)
repository.save(user)

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

API библиотеки #

import requests

response = requests.get(url)

Библиотека предоставляет набор функций, классов и правил их использования.

API операционной системы #

Программа обращается к ОС через системные API:

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

Например:

file = open("data.txt")

В конечном итоге runtime обращается к API операционной системы.

Web API #

Компоненты взаимодействуют через сеть:

Frontend
→ HTTP API
→ Backend

Backend
→ HTTP API
→ платёжный сервис

Мобильное приложение
→ HTTP API
→ сервер

Именно этот тип чаще всего подразумевают под словом API в веб-разработке.

Какую роль выполняет API #

1. Определяет контракт взаимодействия #

API задаёт, что может запросить клиент и что обязан вернуть сервер.

Например:

POST /payments
Content-Type: application/json

{
  "account_id": 42,
  "amount": 100
}

Контракт может определять:

обязательные поля
типы данных
правила валидации
формат успешного ответа
коды ошибок
требования авторизации

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

2. Скрывает внутреннюю реализацию #

Клиент вызывает:

user = await user_service.get_user(42)

Но не знает, что внутри происходит:

проверка Redis
→ запрос PostgreSQL
→ преобразование ORM-модели
→ формирование ответа

API отделяет:

что компонент умеет делать

от:

как именно он это делает

Благодаря этому внутреннюю реализацию можно менять без изменения клиентского кода.

Например:

PostgreSQL → другая схема PostgreSQL
локальный расчёт → внешний сервис
один алгоритм → оптимизированный алгоритм

Публичный API при этом может остаться прежним.

3. Уменьшает связанность компонентов #

Без API компонент может зависеть от внутренних деталей другого компонента:

Сервис заказов
→ напрямую читает таблицы сервиса пользователей

Тогда изменение схемы БД пользователей ломает сервис заказов.

Через API:

Сервис заказов
→ GET /users/42
→ сервис пользователей

Сервис заказов зависит от стабильного контракта, а не от внутренней структуры чужой системы.

Но сетевой API также создаёт новые сложности:

  • сетевые ошибки;

  • задержки;

  • тайм-ауты;

  • частичную недоступность;

  • необходимость версионирования;

  • распределённые транзакции.

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

4. Ограничивает доступ #

API предоставляет только разрешённые операции.

Например, клиент может:

получить профиль
изменить имя
загрузить аватар

Но не может:

произвольно выполнить SQL
прочитать пароль
изменить чужой баланс
получить доступ к внутренней памяти сервиса

API выступает контролируемой границей, на которой выполняются:

  • аутентификация;

  • авторизация;

  • валидация;

  • ограничение частоты запросов;

  • аудит;

  • фильтрация данных.

5. Обеспечивает повторное использование #

Одно API могут использовать разные клиенты:

Web-приложение ─────┐
Мобильное приложение ├→ API → Backend
Административная панель ┤
Другой сервис ──────┘

Бизнес-логика не дублируется в каждом клиенте.

Например, операция создания платежа реализуется на сервере один раз, а вызывается:

  • браузером;

  • мобильным приложением;

  • внутренним сервисом;

  • административной системой.

6. Позволяет независимо развивать системы #

Frontend и backend могут разрабатываться разными командами.

Команда frontend
→ реализует клиент API

Команда backend
→ реализует сервер API

Пока контракт согласован, frontend может использовать mock-сервер, а backend — автоматические тесты контракта.

Аналогично в микросервисной системе:

Order Service
Payment Service
Notification Service
User Service

Каждый сервис развивается отдельно и предоставляет API другим компонентам.

Из чего состоит Web API #

Обычно контракт Web API включает несколько элементов.

Адрес ресурса #

/users/42

HTTP-метод #

GET    → получить
POST   → создать или запустить операцию
PUT    → установить состояние ресурса
PATCH  → частично изменить
DELETE → удалить

Заголовки #

Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <key>

Тело запроса #

{
  "username": "alex",
  "email": "alex@example.com"
}

Статус ответа #

200 OK
201 Created
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
500 Internal Server Error

Тело ответа #

{
  "id": 42,
  "username": "alex"
}

Внутреннее и внешнее API #

Публичное API #

Доступно внешним клиентам или партнёрам:

API платёжного провайдера
API облачного хранилища
API карт

Оно требует:

  • стабильности;

  • документации;

  • версионирования;

  • строгой безопасности;

  • ограничений запросов.

Внутреннее API #

Используется внутри компании или приложения:

Order Service → Payment Service

Оно может быть недоступно из интернета, но всё равно нуждается в ясном контракте.

Приватное API компонента #

Например, методы класса или функции модуля:

class UserService:
    async def create(self, data: UserCreate) -> User:
        ...

Это тоже API, хотя взаимодействие происходит внутри одного процесса.

API и реализация #

Рассмотрим интерфейс:

class PaymentGateway(Protocol):
    async def charge(
        self,
        payment_id: str,
        amount: int,
    ) -> PaymentResult:
        ...

Бизнес-код зависит от API:

result = await gateway.charge(
    payment_id=payment_id,
    amount=amount,
)

Реализация может быть разной:

StripePaymentGateway
TestPaymentGateway
InternalBankGateway

Потребителю не требуется знать внутренний HTTP-запрос или протокол конкретного провайдера.

API и протокол — не одно и то же #

API определяет доступные операции и правила их использования.

Протокол определяет технические правила передачи сообщений.

Например:

API:
GET /users/42 возвращает пользователя

Протокол:
HTTP передаёт запрос и ответ по сети

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

API и пользовательский интерфейс #

Не следует путать:

UI  → интерфейс для человека
API → интерфейс для программы

Пример UI:

кнопка «Создать заказ»

После нажатия frontend вызывает API:

POST /orders

То есть:

Пользователь
→ UI
→ клиентский код
→ API
→ backend

Основные виды сетевых API #

REST API #

Оперирует ресурсами и обычно использует HTTP:

GET /users/42
POST /orders
DELETE /files/10

RPC #

Клиент вызывает удалённую операцию:

createUser()
calculatePrice()
sendPayment()

Примеры подходов:

gRPC
JSON-RPC
часть HTTP API командного типа

GraphQL #

Клиент описывает, какие данные ему нужны:

query {
  user(id: 42) {
    username
    orders {
      id
      status
    }
  }
}

SOAP #

Использует формализованные XML-сообщения и строгие контракты, часто описанные через WSDL.

Хороший API #

Качественный API обычно обладает следующими свойствами:

понятный контракт
предсказуемое поведение
стабильные форматы данных
однозначная семантика операций
корректные коды ошибок
авторизация
идемпотентность критичных операций
версионирование
документация
наблюдаемость

Например, операция:

POST /orders/42/toggle-status

менее предсказуема и неидемпотентна.

Более явный вариант:

PUT /orders/42/status

{
  "status": "cancelled"
}

Здесь клиент передаёт целевое состояние.

Итог #

API
├── определяет доступные операции
├── задаёт формат запросов и ответов
├── выступает контрактом между компонентами
├── скрывает внутреннюю реализацию
├── уменьшает прямую связанность
├── контролирует доступ
├── позволяет повторно использовать функциональность
└── обеспечивает независимую разработку систем

То есть API — это формально определённая граница взаимодействия, через которую один компонент пользуется возможностями другого, не зная всех деталей его реализации.


14. Как зависит число одновременно выполняемых потоков от количества доступных ядер? #

Основная зависимость #

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

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

4 ядра / 4 логических CPU
→ до 4 потоков одновременно исполняют инструкции

4 ядра / 8 логических CPU с SMT
→ до 8 аппаратных потоков одновременно

Остальные готовые к выполнению потоки ждут своей очереди, а планировщик ОС быстро переключает их.

Потоки процесса и аппаратные потоки #

Нужно различать три понятия:

Программный поток
→ объект, созданный программой или runtime

Физическое ядро
→ самостоятельный вычислительный блок CPU

Логический процессор
→ аппаратный контекст выполнения,
  который видит операционная система

Например, процессор может иметь:

4 физических ядра
8 логических процессоров

ОС видит восемь логических CPU и теоретически может одновременно назначить на них восемь программных потоков.

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

Если потоков меньше, чем ядер #

Допустим, есть восемь логических CPU, но программа создала два CPU-bound потока:

CPU 0 → поток A
CPU 1 → поток B
CPU 2–7 → свободны или выполняют другие процессы

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

1 CPU-bound поток
→ обычно загружает максимум один логический CPU

Исключение — когда вызываемая библиотека сама создаёт потоки, например NumPy, BLAS, видеокодек или библиотека машинного обучения.

Если потоков столько же, сколько логических CPU #

Например:

8 runnable CPU-bound потоков
8 логических CPU

Планировщик может назначить по одному потоку на каждый логический CPU:

CPU 0 → поток 1
CPU 1 → поток 2
...
CPU 7 → поток 8

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

Но ускорение редко бывает строго восьмикратным из-за:

  • последовательной части алгоритма;

  • конкуренции за память и кеш;

  • синхронизации;

  • блокировок;

  • общего L3-кеша;

  • ограниченной пропускной способности RAM;

  • переключений контекста;

  • SMT вместо отдельных физических ядер.

Если потоков больше, чем ядер #

Допустим:

4 логических CPU
100 готовых CPU-bound потоков

В каждый момент исполняются только примерно четыре:

CPU 0 → поток A
CPU 1 → поток B
CPU 2 → поток C
CPU 3 → поток D

остальные 96 → runnable, но ждут CPU

Планировщик периодически переключает потоки:

A выполняется
→ квант времени закончился
→ сохраняются регистры A
→ загружаются регистры E
→ выполняется E

Пользователю кажется, что работают все 100 потоков, но физически они исполняются порциями.

Конкурентность и параллелизм #

Конкурентность
→ несколько задач находятся в процессе выполнения,
  но могут чередоваться на одном ядре

Параллелизм
→ несколько задач физически выполняются одновременно
  на разных логических CPU

На одном ядре:

поток A: ███       ███
поток B:    ███ ███

Они выполняются конкурентно, но не параллельно.

На двух ядрах:

CPU 1: поток A ███████
CPU 2: поток B ███████

Они выполняются параллельно.

Почему I/O-потоков может быть намного больше ядер #

Большинство потоков сервера могут не вычислять, а ждать:

ответа БД
сетевого пакета
файла
блокировки
таймера

Ожидающий поток не занимает ядро:

поток A → ждёт PostgreSQL
поток B → ждёт HTTP-ответ
поток C → ждёт файл
поток D → выполняется на CPU

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

Для I/O-bound нагрузки число потоков иногда делают больше числа ядер:

пока одни потоки ждут I/O,
другие используют CPU

Но слишком большое количество потоков создаёт расходы:

  • память под стеки;

  • переключения контекста;

  • работу планировщика;

  • конкуренцию за блокировки;

  • ухудшение кеш-локальности.

CPU-bound задачи #

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

Примерная отправная точка:

число CPU-bound workers
≈ число доступных физических или логических CPU

Какое именно число лучше — физические или логические ядра — зависит от задачи.

Вычисления хорошо используют все блоки ядра #

SMT может дать умеренный прирост:

8 физических / 16 логических CPU
→ оптимум может оказаться около 12–16 потоков

Потоки полностью нагружают ядра и память #

Дополнительные SMT-потоки могут почти не помогать:

8 физических / 16 логических CPU
→ оптимум может оказаться около 8 потоков

Поэтому число CPU-bound workers лучше подбирать измерениями.

I/O-bound задачи #

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

количество потоков
≈ количество CPU × (1 + время ожидания / время вычисления)

Например, задача:

10 мс вычисляется
90 мс ждёт сеть

Она использует CPU только около 10% времени. Поэтому потоков может быть значительно больше числа ядер.

Однако на практике лимит определяют также:

  • connection pool БД;

  • ограничения внешнего API;

  • память;

  • допустимая нагрузка;

  • rate limits;

  • latency;

  • число файловых дескрипторов.

Что происходит с заблокированным потоком #

Если поток вызывает блокирующий read() и данных нет:

поток выполнялся
→ вошёл в ядро через системный вызов
→ данных нет
→ ОС переводит поток в waiting
→ ядро отдаётся другому runnable-потоку

Когда данные приходят:

устройство или сеть сообщает ядру
→ поток становится runnable
→ планировщик позже назначает его на CPU

Поэтому количество существующих потоков не равно количеству потоков, потребляющих процессор.

Влияние SMT / Hyper-Threading #

При SMT одно физическое ядро предоставляет несколько логических CPU:

Физическое ядро
├── логический CPU 0
└── логический CPU 1

Они могут одновременно держать состояние двух потоков, но делят:

  • исполнительные блоки;

  • кеши;

  • пропускную способность;

  • часть внутренней инфраструктуры ядра.

Поэтому:

8 физических ядер / 16 потоков
≠ производительность 16 физических ядер

В зависимости от нагрузки выигрыш может быть заметным, небольшим или отсутствовать.

Процессорное сродство #

ОС может переносить поток между ядрами:

сначала поток выполнялся на CPU 1
→ затем на CPU 4

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

Можно задать affinity — ограничить поток или процесс определёнными CPU:

процесс может выполняться только на CPU 0–3

Тогда даже при наличии 16 логических CPU он сможет одновременно использовать максимум четыре из них.

Ограничения также могут задаваться контейнерами и cgroups.

Python и GIL #

В обычной сборке CPython наличие множества потоков не означает параллельное выполнение Python-кода.

from threading import Thread

При наличии GIL только один поток конкретного интерпретатора одновременно исполняет Python-байткод:

8 ядер
8 Python CPU-bound threads
→ обычно Python-байткод выполняет один поток за раз

Потоки периодически получают GIL по очереди, поэтому CPU-bound Python-код обычно не ускоряется и может даже замедлиться.

При этом потоки полезны для I/O:

поток вызывает сетевое или файловое ожидание
→ CPython освобождает GIL
→ другой поток может исполнять Python-код

Для настоящего параллелизма CPU-bound Python-кода традиционно используют:

multiprocessing
ProcessPoolExecutor
нативные библиотеки, освобождающие GIL
NumPy / C / Rust / Cython

У разных процессов собственные интерпретаторы и собственные GIL:

8 процессов
→ могут выполняться на 8 ядрах параллельно

Asyncio #

Корутины asyncio не являются потоками ОС:

1000 корутин
→ могут выполняться в одном потоке
→ на одном логическом CPU

Во время await одна корутина приостанавливается, и event loop запускает другую:

корутина A → ждёт сеть
корутина B → выполняется
корутина C → ждёт БД

Это обеспечивает высокую конкурентность для I/O, но не параллелизм CPU-bound вычислений.

Практический пример #

Компьютер:

4 физических ядра
8 логических CPU

Один CPU-bound поток #

одновременно выполняется: 1
задействовано логических CPU: примерно 1

Восемь CPU-bound нативных потоков #

одновременно может выполняться: до 8

Но они делят четыре физических ядра через SMT.

Сто CPU-bound потоков #

одновременно: до 8
остальные: ждут планировщика

Сто потоков, большинство ждут сеть #

существуют: 100
одновременно вычисляются: обычно значительно меньше 8
остальные: waiting

Сто корутин asyncio в одном потоке #

конкурентно обрабатываются: 100
Python-код одновременно исполняется: одной корутиной
используется: обычно один логический CPU

Итог #

Максимальное аппаратное параллельное выполнение
≈ число доступных логических CPU

Программных потоков
может быть намного больше

Лишние runnable CPU-bound потоки
→ делят процессорное время
→ не увеличивают число одновременно исполняемых инструкций
→ создают расходы на переключения

I/O-bound потоков
может быть больше числа ядер,
потому что большинство из них ждёт

В CPython с GIL
CPU-bound Python-потоки обычно не выполняют байткод параллельно

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


15. Какие меры защиты от DoS- и DDoS-атак обычно применяются в backend-системах? #

Общий принцип #

Защита от DoS/DDoS строится в несколько уровней, потому что разные атаки исчерпывают разные ресурсы:

L3/L4:
канал связи, пакеты, TCP-соединения

L7:
HTTP-запросы, CPU, память, worker-ы, БД

Бизнес-уровень:
дорогие отчёты, поиск, SMS, email, внешние API

Главное различие:

DoS
→ атака может идти от одного или нескольких источников

DDoS
→ атака распределена между множеством источников

Простой rate limiting внутри FastAPI не спасёт от крупной объёмной DDoS-атаки: входящий канал или инфраструктура перед приложением могут быть перегружены ещё до того, как запрос достигнет backend. Поэтому объёмный трафик нужно отбрасывать на стороне провайдера, CDN или специализированной Anti-DDoS-сети.

1. CDN и специализированная Anti-DDoS-защита #

Публичный сервис обычно помещают за:

пользователь
→ Anycast/CDN/Anti-DDoS
→ WAF
→ Load Balancer
→ backend

Такая инфраструктура принимает трафик на распределённой сети, фильтрует вредоносные пакеты и пропускает к origin только разрешённый трафик. Anycast позволяет распределять входящий трафик между географически разными узлами, а специализированные сервисы защищают уровни L3, L4 и L7.

Для крупных систем применяются:

  • CDN и reverse proxy;

  • облачная DDoS-защита;

  • scrubbing centers;

  • защита BGP-анонсов и сетевой инфраструктуры;

  • автоматическое обнаружение аномалий;

  • фильтрация UDP-, SYN- и HTTP-flood атак.

На AWS эту роль выполняют, например, Shield, CloudFront, Global Accelerator и WAF; аналогичные возможности есть у других крупных edge-провайдеров.

2. Не раскрывать реальный адрес origin #

Если backend спрятан за CDN, но его реальный IP доступен напрямую, атакующий может обойти CDN:

атака
→ напрямую на origin IP
→ защита edge не участвует

Поэтому origin обычно принимает входящие подключения только:

  • от адресов CDN или reverse proxy;

  • через закрытую сеть;

  • через защищённый load balancer;

  • через VPN или private connectivity.

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

3. Rate limiting #

Rate limiting ограничивает количество запросов за период:

не более 100 запросов в минуту
на пользователя / токен / IP / endpoint

Пример алгоритмов:

fixed window
sliding window
token bucket
leaky bucket

Ограничивать можно по:

  • IP;

  • API-ключу;

  • пользователю;

  • организации;

  • сессии;

  • endpoint;

  • сочетанию нескольких признаков.

На превышение API обычно отвечает:

429 Too Many Requests
Retry-After: 30

OWASP отдельно относит отсутствие ограничений на потребление ресурсов к рискам API, а NGINX и WAF-системы поддерживают ограничения частоты запросов и числа соединений.

При этом правило:

100 запросов в минуту на IP

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

Поэтому применяют несколько уровней:

общий лимит сервиса
+ лимит клиента
+ лимит endpoint
+ лимит дорогих операций
+ адаптивное обнаружение аномалий

4. Разные лимиты для разных операций #

Запросы имеют разную стоимость:

GET /health
→ почти бесплатный

GET /users/42
→ дешёвый запрос БД

POST /reports
→ тяжёлая агрегация

POST /images/resize
→ CPU и память

POST /sms/send
→ внешний платный ресурс

Поэтому одинаковый лимит для всех endpoint обычно неэффективен.

Пример:

GET /products       → 300 запросов/мин
POST /login         → 10 запросов/мин
POST /reports       → 2 запроса/мин
POST /sms/send      → 3 запроса/час

Кроме частоты, полезно учитывать стоимость запроса:

простой запрос       → 1 unit
сложный поиск        → 5 units
генерация отчёта     → 50 units

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

5. Ограничение числа одновременных запросов #

Помимо запросов в секунду ограничивают concurrency:

не более 20 одновременных тяжёлых операций
на пользователя

Например:

import asyncio

report_semaphore = asyncio.Semaphore(20)


async def generate_report(data):
    async with report_semaphore:
        return await perform_expensive_calculation(data)

Но локальный Semaphore действует только внутри одного процесса. В многопроцессной или распределённой системе лимит нужно реализовывать на уровне:

  • API gateway;

  • ingress;

  • общей очереди;

  • Redis;

  • внешнего rate limiter;

  • базы данных или централизованного coordinator.

На reverse proxy также ограничивают число одновременных соединений с одного клиента. NGINX предоставляет отдельный механизм limit_conn для подобных ограничений.

6. Тайм-ауты #

Атакующий может не отправлять много запросов, а удерживать соединения открытыми:

открыть соединение
→ медленно отправлять заголовки
→ медленно отправлять тело
→ занять worker или socket

Это семейство low-and-slow атак.

Защита:

connect timeout
read timeout
write timeout
idle timeout
header timeout
request body timeout
upstream timeout

Также ограничивают:

  • максимальную продолжительность запроса;

  • время выполнения SQL;

  • время ответа внешнего API;

  • время удержания транзакции;

  • время обработки background-задачи.

HTTP reverse proxy перед приложением особенно важен для защиты от медленных соединений.

7. Ограничение размеров входных данных #

Backend должен устанавливать верхние границы:

максимальный размер HTTP body
максимальный размер файла
максимальное число элементов массива
максимальная длина строки
максимальная глубина JSON
максимальное число multipart-частей

Например:

аватар       → максимум 10 МБ
CSV-платежи  → максимум 1000 строк
batch API    → максимум 100 объектов
page_size    → максимум 100

Без этих ограничений один запрос может потребовать огромный объём:

  • памяти;

  • CPU;

  • диска;

  • времени сериализации;

  • запросов в БД.

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

8. Защита от вычислительно дорогих входных данных #

Некоторые небольшие запросы способны вызвать непропорционально дорогую обработку:

сложное регулярное выражение
глубоко вложенный JSON/XML
архив с огромным содержимым
дорогой GraphQL-запрос
изображение с огромным разрешением
сложный фильтр БД

Нужны ограничения на:

  • глубину вложенности;

  • сложность выражений;

  • число распакованных файлов;

  • итоговый размер распаковки;

  • разрешение изображения;

  • количество JOIN и фильтров;

  • время исполнения;

  • объём промежуточных данных.

Для XML обычно отключают DTD и внешние сущности: помимо XXE это защищает от атак вроде Billion Laughs, вызывающих чрезмерное потребление памяти и CPU.

9. Пагинация и ограничения выборок #

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

GET /payments?page_size=10000000

Backend должен устанавливать предел:

page_size = min(requested_page_size, 100)

Также нужны:

  • обязательная пагинация;

  • максимальный диапазон дат;

  • ограничение числа экспортируемых строк;

  • асинхронная генерация крупных отчётов;

  • ограничение полей и вложенных связей.

Отсутствие пагинации может привести к чрезмерному потреблению памяти, процессора и ресурсов БД.

10. WAF и bot management #

WAF анализирует HTTP-запросы и может:

  • блокировать известные вредоносные шаблоны;

  • ограничивать запросы к отдельным endpoint;

  • применять managed rules;

  • выполнять challenge;

  • блокировать аномальные User-Agent или сигнатуры;

  • учитывать threat intelligence;

  • отличать автоматизированный трафик от нормального.

Для L7 DDoS обычно используют сочетание:

WAF
+ rate limiting
+ bot management
+ поведенческий анализ
+ масштабирование приложения

AWS прямо рекомендует совместно использовать WAF и масштабирование для application-layer DDoS, а edge-провайдеры могут автоматически добавлять правила в ответ на обнаруженную аномалию.

11. CAPTCHA и challenge #

Для действий, которые обычно выполняет человек, можно включать challenge:

регистрация
восстановление пароля
отправка формы
поиск билетов
голосование
покупка дефицитного товара

Challenge обычно применяют не ко всем пользователям, а при повышенном риске:

обычный пользователь
→ запрос разрешён

подозрительный клиент
→ JavaScript challenge / CAPTCHA / MFA

явный бот
→ блокировка

Это снижает автоматизированную нагрузку, но не защищает сетевой канал от объёмной DDoS-атаки.

12. Кеширование #

Кеш позволяет обслужить запрос без обращения к backend или БД:

клиент
→ CDN cache
→ ответ

вместо:

клиент
→ backend
→ PostgreSQL
→ сериализация
→ ответ

Особенно полезно кешировать:

  • статические файлы;

  • публичные страницы;

  • справочники;

  • редко изменяемые GET-ответы;

  • результаты дорогих вычислений.

Edge-узлы CDN могут возвращать кешированную копию, не передавая каждый запрос origin-серверу.

При этом атакующий может намеренно обходить кеш:

GET /products?random=12345

Поэтому важны нормализация cache key, игнорирование ненужных параметров и rate limiting cache-miss запросов.

13. Очереди и backpressure #

Тяжёлые операции лучше не выполнять неограниченно внутри HTTP-запроса:

HTTP
→ поставить задачу в очередь
→ вернуть 202
→ worker обработает позже

Очередь даёт контролируемый буфер:

100 HTTP-запросов
→ очередь
→ 10 worker-ов обрабатывают с фиксированной скоростью

Но очередь тоже должна иметь:

  • максимальный размер;

  • TTL сообщений;

  • ограничение числа задач от одного пользователя;

  • приоритеты;

  • dead-letter queue;

  • стратегию отказа при заполнении.

Без ограничений очередь просто переносит DoS из HTTP-сервера в брокер или worker-ы.

14. Изоляция ресурсов #

Одна тяжёлая функция не должна уничтожать весь сервис.

Применяются:

отдельные worker pool
отдельные очереди
bulkhead pattern
CPU/memory limits
лимиты файловых дескрипторов
ограничения числа процессов
отдельные connection pool

Например:

обычные API-запросы
→ отдельный pool

генерация PDF
→ отдельный pool

обработка изображений
→ отдельные worker-ы

В контейнерах задают ограничения CPU и памяти, чтобы один компонент не поглотил все ресурсы узла. Kubernetes поддерживает resource requests, limits и namespace quotas.

15. Защита базы данных #

Часто HTTP-сервер выдерживает атаку, но падает БД.

Меры:

  • ограниченный connection pool;

  • statement_timeout;

  • lock_timeout;

  • лимит тяжёлых запросов;

  • индексы;

  • запрет неограниченных выборок;

  • read replicas;

  • кеширование;

  • circuit breaker;

  • отдельный пользователь БД с ограниченными ресурсами;

  • закрытие долгих idle-транзакций.

Важно не делать:

каждый HTTP-запрос
→ новое соединение с PostgreSQL

При атаке это быстро исчерпает max_connections.

Лучше:

1000 входящих запросов
→ очередь ожидания
→ pool из 20–50 соединений
→ PostgreSQL

Когда pool переполнен, сервис должен ограниченно ждать или быстро отклонять запросы, а не создавать неограниченное количество соединений.

16. Circuit breaker и отказ от необязательных функций #

Во время перегрузки сервис может временно отключить дорогие возможности:

рекомендации
поиск по истории
полные отчёты
изображения высокого качества
внешние интеграции

И сохранить критичные:

авторизация
платежи
просмотр статуса заказа
health checks

Это называется graceful degradation.

Circuit breaker временно прекращает вызовы в уже перегруженную зависимость:

Payment API отвечает ошибками
→ circuit открывается
→ новые вызовы быстро отклоняются
→ backend не накапливает тысячи зависших запросов

17. Load balancing и autoscaling #

Балансировщик распределяет запросы между экземплярами:

Load Balancer
├── backend 1
├── backend 2
├── backend 3
└── backend 4

Autoscaling добавляет экземпляры при росте нагрузки.

Это повышает устойчивость, но не является полной DDoS-защитой:

  • атака может быть больше максимальной мощности;

  • может быть перегружена общая БД;

  • масштабирование происходит не мгновенно;

  • атакующий способен вызвать огромные облачные расходы;

  • L3/L4-атака может не дойти до уровня приложения.

Поэтому масштабирование должно работать вместе с edge-фильтрацией, WAF, лимитами и бюджетными ограничениями. Для L7 AWS рекомендует именно сочетание масштабирования и WAF.

18. Не полагаться только на блокировку IP #

IP blacklist полезен против отдельных источников, но слаб против DDoS:

100 000 разных IP
→ блокировка по одному слишком медленная

Кроме того, один IP может представлять:

  • корпоративную сеть;

  • мобильного оператора;

  • NAT;

  • прокси;

  • множество легитимных пользователей.

Поэтому IP — только один из сигналов:

IP
+ ASN
+ страна
+ API key
+ пользователь
+ fingerprint
+ поведение
+ частота
+ репутация

19. Мониторинг и обнаружение аномалий #

Для реакции нужны метрики:

requests per second
новые TCP-соединения
ошибки 429/502/503/504
CPU и память
event loop lag
число занятых worker-ов
длина очереди
connection pool БД
cache hit ratio
p95/p99 latency
сетевой трафик
стоимость запросов

Необходимо знать нормальный baseline:

обычно: 1 000 RPS
сейчас: 30 000 RPS

обычно: 70% cache hit
сейчас: 5%

Современные managed DDoS-сервисы также анализируют исторические профили трафика и ищут отклонения от базовой линии.

20. План реагирования #

До атаки должны быть определены:

кто принимает решение
как связаться с провайдером
какие endpoint отключать
как включить жёсткие WAF-правила
как скрыть origin
как ограничить трафик по регионам
как проверить состояние БД
как уведомить пользователей

Также полезно заранее согласовать возможности Anti-DDoS с хостингом или ISP. OWASP и CISA рекомендуют готовить защиту и процедуру реагирования до инцидента, а не во время уже идущей атаки.

Практическая схема backend-защиты #

Internet
Anycast / CDN / Anti-DDoS
WAF + Bot Management
Rate Limiting
Load Balancer / Reverse Proxy
   ├── connection limits
   ├── body-size limits
   ├── timeouts
   └── concurrency limits
Backend
   ├── validation
   ├── pagination
   ├── query-cost limits
   ├── semaphores
   ├── circuit breakers
   └── graceful degradation
Redis / Queue / Database
   ├── connection pools
   ├── resource quotas
   ├── statement timeout
   └── bounded queues

Что наиболее важно #

Для обычного публичного backend минимальный разумный набор:

CDN или Anti-DDoS перед origin
WAF
rate limiting по пользователю и endpoint
лимиты соединений и параллельности
тайм-ауты
ограничение body/file/page size
пагинация
ограниченный DB connection pool
кеширование
метрики и алерты
скрытый origin

Главное правило:

крупную DDoS нужно отбрасывать далеко от backend;

дорогие L7-запросы нужно ограничивать
как можно раньше в цепочке;

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


16. Какие библиотеки используют для работы с API #

Сначала нужно разделить две задачи #

Под «работой с API» могут иметь в виду:

1. Вызывать чужое API
   → HTTP-клиент

2. Создавать собственное API
   → web-фреймворк

3. Тестировать и документировать API
   → тестовые клиенты, OpenAPI-инструменты

Для вызова API из Python #

requests #

Популярная синхронная HTTP-библиотека:

import requests

response = requests.get(
    "https://api.example.com/users/42",
    timeout=5,
)

response.raise_for_status()
user = response.json()

Подходит для:

  • обычных скриптов;

  • синхронных приложений;

  • небольшого числа последовательных запросов;

  • CLI и интеграций.

requests предоставляет методы get, post, put, patch, delete, сессии, cookies, заголовки, авторизацию и работу с тайм-аутами.

Недостаток для async-backend: обычный вызов requests блокирует поток.

httpx #

Современный HTTP-клиент с синхронным и асинхронным API:

import httpx


async def get_user(user_id: int) -> dict:
    async with httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=5,
    ) as client:
        response = await client.get(f"/users/{user_id}")
        response.raise_for_status()
        return response.json()

Подходит для:

  • FastAPI и другого асинхронного кода;

  • параллельных HTTP-запросов;

  • HTTP/1.1 и HTTP/2;

  • тестирования ASGI-приложений.

HTTPX официально предоставляет как Client, так и AsyncClient; для повторного использования соединений клиент рекомендуется держать дольше одного запроса, а не создавать в каждой итерации. ( Httpx)

Для современного FastAPI-backend это обычно основной выбор.

aiohttp #

Асинхронная библиотека, которая содержит и HTTP-клиент, и серверную часть:

import aiohttp


async def get_user(user_id: int) -> dict:
    timeout = aiohttp.ClientTimeout(total=5)

    async with aiohttp.ClientSession(
        base_url="https://api.example.com",
        timeout=timeout,
    ) as session:
        async with session.get(f"/users/{user_id}") as response:
            response.raise_for_status()
            return await response.json()

Подходит для:

  • большого количества конкурентных соединений;

  • потокового чтения и загрузки данных;

  • WebSocket;

  • проектов, уже построенных вокруг aiohttp.

aiohttp официально позиционируется как асинхронный HTTP client/server для asyncio.

urllib.request #

Входит в стандартную библиотеку Python:

from urllib.request import urlopen

with urlopen(
    "https://api.example.com/users/42",
    timeout=5,
) as response:
    data = response.read()

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

Быстрое сравнение HTTP-клиентов #

БиблиотекаSyncAsyncКогда применять
requestsДаНетСинхронные скрипты и приложения
httpxДаДаFastAPI, современные sync/async-проекты
aiohttpНетДаВысокая async-конкурентность, streaming, WebSocket
urllibДаНетБез сторонних зависимостей

Для создания API на Python #

FastAPI #

Фреймворк для построения HTTP API с использованием аннотаций типов:

from fastapi import FastAPI

app = FastAPI()


@app.get("/users/{user_id}")
async def get_user(user_id: int) -> dict:
    return {
        "id": user_id,
        "username": "alex",
    }

FastAPI автоматически формирует OpenAPI-схему и интерактивную документацию, а валидация входных и выходных данных строится вокруг типизации и моделей данных.

Подходит для:

  • REST API;

  • асинхронных backend-сервисов;

  • микросервисов;

  • хорошо типизированных контрактов;

  • автоматической документации.

Django REST Framework #

Используется для API поверх Django:

Django
+ ORM
+ authentication
+ admin
+ Django REST Framework

Подходит, когда проекту одновременно нужны:

  • Django ORM;

  • административная панель;

  • развитая система пользователей;

  • permissions;

  • serializers;

  • ViewSet и router.

Flask #

Минималистичный web-фреймворк. Часто выбирается для небольших HTTP API, когда разработчик хочет самостоятельно подобрать валидацию, ORM, DI и остальные компоненты.

aiohttp.web #

Помимо клиента, aiohttp позволяет создавать HTTP-серверы и приложения.

Используется реже FastAPI для типичных REST API, но подходит для низкоуровневых асинхронных сервисов и проектов, уже использующих экосистему aiohttp.

Для WebSocket API #

Часто применяют:

FastAPI / Starlette WebSocket
aiohttp
websockets

Пример FastAPI:

from fastapi import FastAPI, WebSocket

app = FastAPI()


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
    await websocket.accept()

    while True:
        message = await websocket.receive_text()
        await websocket.send_text(f"Received: {message}")

Для GraphQL API #

На Python используют, например:

Strawberry
Ariadne
Graphene

FastAPI не реализует GraphQL самостоятельно, но позволяет подключать сторонние GraphQL-библиотеки.

Для gRPC #

Для взаимодействия через Protocol Buffers и RPC используют:

grpcio
grpcio-tools
protobuf

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

  • строгий контракт;

  • генерация клиентов;

  • бинарная сериализация;

  • streaming;

  • высокая производительность.

Это уже не обычный REST/JSON API.

Для авторизации #

К HTTP-клиентам обычно добавляют инструменты для конкретной схемы:

OAuth 2.0 / OpenID Connect
JWT
Basic Auth
API Key

Для requests существуют интеграции вроде requests-oauthlib; сам Requests также поддерживает базовые механизмы аутентификации.

Но часто Bearer-токен передают обычным заголовком:

headers = {
    "Authorization": f"Bearer {access_token}",
}

response = httpx.get(
    "https://api.example.com/profile",
    headers=headers,
)

Для повторных запросов и устойчивости #

В production обычно требуются:

timeouts
retry с backoff
connection pooling
лимиты соединений
проверка HTTP-статусов
circuit breaker

Пример с HTTPX:

import httpx

timeout = httpx.Timeout(
    connect=2,
    read=5,
    write=5,
    pool=2,
)

limits = httpx.Limits(
    max_connections=100,
    max_keepalive_connections=20,
)

client = httpx.AsyncClient(
    timeout=timeout,
    limits=limits,
)

Важно: библиотеки обычно не должны бездумно повторять любой POST. Retry безопаснее для идемпотентных операций либо при использовании Idempotency-Key.

Для тестирования API #

httpx #

Удобен для тестов FastAPI и ASGI-приложений:

from httpx import ASGITransport, AsyncClient

transport = ASGITransport(app=app)

async with AsyncClient(
    transport=transport,
    base_url="http://test",
) as client:
    response = await client.get("/users/42")

HTTPX поддерживает специальный ASGI transport, позволяющий вызывать приложение напрямую без реального сетевого сервера. ( Httpx)

pytest #

Используется как тестовый фреймворк:

def test_get_user(client):
    response = client.get("/users/42")

    assert response.status_code == 200
    assert response.json()["id"] == 42

Postman и Insomnia #

Это не Python-библиотеки, а отдельные инструменты для ручного выполнения и сохранения API-запросов.

Практический выбор для Python-backend #

Синхронный скрипт:
→ requests или httpx.Client

FastAPI вызывает внешнее API:
→ httpx.AsyncClient

Много streaming/WebSocket-соединений:
→ aiohttp

Создание REST API:
→ FastAPI

Большой Django-проект:
→ Django REST Framework

Тестирование FastAPI:
→ pytest + HTTPX

Внутренний строго типизированный RPC:
→ grpcio


17. Что такое reflection (Рефлексия) в программировании? #

Что такое рефлексия #

Рефлексия (reflection) — это способность программы во время выполнения:

  • исследовать собственную структуру;

  • получать информацию о типах, классах, методах и полях;

  • находить элементы по имени;

  • в некоторых языках — динамически вызывать методы, создавать объекты и изменять структуру программы.

Упрощённо:

обычный код:
программа работает с данными

рефлексия:
программа работает с описанием самого кода

Например, программа может спросить:

Какого типа этот объект?
Какие у него есть методы?
Есть ли у него атрибут "username"?
Как вызвать метод, имя которого пришло строкой?
Какие параметры принимает функция?

Простой пример в Python #

class User:
    def __init__(self, username: str) -> None:
        self.username = username

    def greet(self) -> str:
        return f"Hello, {self.username}"


user = User("alex")

Получение типа объекта:

print(type(user))

Результат:

<class '__main__.User'>

Проверка наличия атрибута:

print(hasattr(user, "username"))
print(hasattr(user, "email"))

Получение значения по имени:

username = getattr(user, "username")
print(username)

Динамический вызов метода:

method_name = "greet"

method = getattr(user, method_name)
result = method()

print(result)

Здесь имя метода известно только во время выполнения.

Основные средства рефлексии в Python #

type() #

Возвращает тип объекта:

value = 42

print(type(value))

isinstance() #

Проверяет принадлежность типу:

if isinstance(value, int):
    print("Это целое число")

dir() #

Возвращает имена доступных атрибутов:

print(dir(user))

getattr() #

Получает атрибут по строковому имени:

value = getattr(user, "username")

Можно указать значение по умолчанию:

email = getattr(user, "email", None)

setattr() #

Устанавливает атрибут по имени:

setattr(user, "email", "alex@example.com")

Эквивалентно:

user.email = "alex@example.com"

hasattr() #

Проверяет наличие атрибута:

if hasattr(user, "greet"):
    ...

delattr() #

Удаляет атрибут:

delattr(user, "email")

__dict__ #

Многие объекты хранят свои атрибуты в словаре:

print(user.__dict__)

Результат:

{
    "username": "alex"
}

Однако не у всех объектов есть __dict__, например при использовании __slots__.

Инспекция функций #

Модуль inspect позволяет анализировать функции и классы.

import inspect


def create_user(
    username: str,
    active: bool = True,
) -> dict:
    return {
        "username": username,
        "active": active,
    }


signature = inspect.signature(create_user)

print(signature)

Результат:

(username: str, active: bool = True) -> dict

Можно получить параметры:

for name, parameter in signature.parameters.items():
    print(name, parameter.annotation, parameter.default)

И аннотацию результата:

print(signature.return_annotation)

Интроспекция и рефлексия #

Эти понятия часто используют как синонимы, но иногда различают.

Интроспекция — исследование структуры программы:

получить тип
найти методы
прочитать аннотации
изучить параметры функции

Рефлексия в более широком смысле включает ещё и воздействие:

динамически вызвать метод
создать объект
изменить атрибут
зарегистрировать обработчик
сгенерировать новый класс

То есть:

интроспекция:
посмотреть

рефлексия:
посмотреть и использовать полученную информацию

Но строгая граница зависит от языка и терминологии конкретного сообщества.

Пример динамического диспетчера #

Допустим, имя операции приходит из запроса:

class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

    def multiply(self, a: int, b: int) -> int:
        return a * b

Можно выбрать метод динамически:

calculator = Calculator()

operation_name = "multiply"

operation = getattr(calculator, operation_name)
result = operation(4, 5)

print(result)

Результат:

20

Но напрямую передавать пользовательский ввод в getattr() опасно. Следует использовать whitelist:

allowed_operations = {
    "add": calculator.add,
    "multiply": calculator.multiply,
}

operation = allowed_operations.get(operation_name)

if operation is None:
    raise ValueError("Unknown operation")

result = operation(4, 5)

Это безопаснее и понятнее.

Где используется рефлексия #

1. Dependency Injection #

FastAPI анализирует сигнатуру endpoint и зависимостей:

from fastapi import Depends, FastAPI

app = FastAPI()


def get_current_user() -> str:
    return "alex"


@app.get("/profile")
def get_profile(
    user: str = Depends(get_current_user),
) -> dict:
    return {"username": user}

Фреймворк исследует:

  • параметры функции;

  • аннотации типов;

  • значения Depends;

  • вложенные зависимости.

На основе этого он строит граф зависимостей и вызывает нужные функции.

2. Валидация и сериализация #

Pydantic анализирует аннотации класса:

from pydantic import BaseModel


class UserCreate(BaseModel):
    username: str
    age: int

По этим данным библиотека понимает:

какие поля существуют
какие у них типы
какие значения допустимы
как сериализовать объект

3. ORM #

ORM исследует объявление модели:

class User(Model):
    id = fields.IntField(primary_key=True)
    username = fields.CharField(max_length=100)

Она собирает метаданные:

имя таблицы
колонки
типы
ограничения
связи

После этого строит SQL-запросы.

4. Автоматическое тестирование #

pytest находит тестовые функции по имени и анализирует их параметры:

def test_create_user(client, database):
    ...

По именам client и database он находит соответствующие fixtures и передаёт их в функцию.

5. Плагины #

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

import importlib

module = importlib.import_module("plugins.payment")

plugin_class = getattr(module, "PaymentPlugin")
plugin = plugin_class()

Так строятся расширяемые системы.

6. Сериализация #

Объект можно преобразовать в словарь на основе его полей:

def serialize_object(obj: object) -> dict:
    return dict(vars(obj))
print(serialize_object(user))

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

7. Документация API #

FastAPI анализирует:

маршруты
параметры
аннотации
Pydantic-модели
типы ответов

И на основе этого генерирует OpenAPI-схему.

8. Декораторы и метапрограммирование #

Декоратор может изучать функцию и менять её поведение:

import inspect
from functools import wraps


def log_call(func):
    signature = inspect.signature(func)

    @wraps(func)
    def wrapper(*args, **kwargs):
        bound = signature.bind(*args, **kwargs)
        print(f"Calling {func.__name__}: {bound.arguments}")
        return func(*args, **kwargs)

    return wrapper

Рефлексия в статически типизированных языках #

В Java рефлексия явно представлена API java.lang.reflect.

Условный пример:

Class<?> clazz = User.class;

Method method = clazz.getMethod("getUsername");
Object result = method.invoke(user);

Программа может:

  • получить класс объекта;

  • перечислить методы;

  • исследовать поля;

  • читать аннотации;

  • вызвать метод;

  • создать объект через конструктор.

В C# используются:

System.Type
System.Reflection
Attributes
Activator

Например:

Type type = typeof(User);
MethodInfo method = type.GetMethod("GetUsername");
object result = method.Invoke(user, null);

В Rust и C++ полноценная runtime-рефлексия ограничена сильнее. Там чаще используют:

  • шаблоны;

  • макросы;

  • traits;

  • кодогенерацию;

  • compile-time reflection;

  • RTTI в ограниченном виде.

Рефлексия и метапрограммирование #

Метапрограммирование — более широкое понятие: программа создаёт, анализирует или изменяет другой код либо собственную структуру.

Рефлексия — один из способов метапрограммирования.

Метапрограммирование
├── runtime reflection
├── декораторы
├── метаклассы
├── макросы
├── шаблоны
└── генерация кода

В Python метакласс может изменять создание класса:

class ModelMeta(type):
    def __new__(mcls, name, bases, namespace):
        namespace["model_name"] = name.lower()
        return super().__new__(mcls, name, bases, namespace)


class User(metaclass=ModelMeta):
    pass


print(User.model_name)

Преимущества рефлексии #

Она позволяет создавать:

  • универсальные фреймворки;

  • dependency injection;

  • ORM;

  • сериализаторы;

  • системы плагинов;

  • автоматическую документацию;

  • тестовые фреймворки;

  • динамические адаптеры.

Без неё разработчику пришлось бы вручную описывать множество метаданных:

fields = {
    "username": str,
    "age": int,
}

Хотя эта информация уже существует в аннотациях класса.

Недостатки #

Потеря статической проверяемости #

Такой код:

method = getattr(obj, method_name)
method()

труднее проверить статическим анализатором, потому что имя метода известно только во время исполнения.

Ошибка обнаружится поздно:

AttributeError

Усложнение навигации #

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

getattr(service, "create_user")()

Поиск usages может не показать этот вызов.

Ошибки при переименовании #

Если метод переименован:

"create_user"

строковое имя может не обновиться автоматически.

Производительность #

Рефлексивный поиск и динамический вызов обычно дороже прямого вызова:

obj.method()

по сравнению с:

getattr(obj, "method")()

В обычном backend-коде это редко главный bottleneck, но в горячих циклах может быть заметно.

Нарушение инкапсуляции #

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

Риски безопасности #

Особенно опасно использовать пользовательский ввод для выбора:

  • метода;

  • класса;

  • модуля;

  • поля;

  • импортируемого пути.

Плохо:

method_name = request.json()["method"]
method = getattr(admin_service, method_name)
return method()

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

Безопаснее:

allowed_methods = {
    "get_profile": admin_service.get_profile,
    "list_orders": admin_service.list_orders,
}

Когда рефлексия оправдана #

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

фреймворк
ORM
DI-контейнер
serializer
plugin system
test runner
code generator

В обычной бизнес-логике часто проще и безопаснее прямой вызов:

service.create_user(data)

чем динамический:

getattr(service, action_name)(data)

Итог #

Рефлексия
├── позволяет исследовать структуру программы во время выполнения
├── получает типы, методы, поля и сигнатуры
├── позволяет динамически вызывать и изменять элементы
├── используется во фреймворках, ORM, DI и сериализации
└── увеличивает динамичность ценой проверяемости и прозрачности

Для Python типичный набор средств рефлексии:

type
isinstance
dir
getattr
setattr
hasattr
vars
__dict__
inspect
importlib

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


18. Что такое асимптотическая сложность (вычислительная сложность)? #

Что такое асимптотическая сложность #

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

Обычно оценивают:

временную сложность
→ сколько операций требуется

пространственную сложность
→ сколько дополнительной памяти требуется

Например, если алгоритм обрабатывает список из n элементов, сложность показывает, как изменится объём работы при росте n.

n = 10
n = 1 000
n = 1 000 000

Речь идёт не о точном времени в секундах, а о характере роста.

Простой пример #

def print_items(items: list[int]) -> None:
    for item in items:
        print(item)

Если элементов:

10     → примерно 10 итераций
100    → примерно 100 итераций
1000   → примерно 1000 итераций

Количество работы растёт пропорционально размеру списка:

O(n)

Это линейная сложность.

Что означает n #

n — размер входных данных.

В зависимости от задачи это может быть:

количество элементов списка
количество символов строки
количество вершин графа
количество строк таблицы
размер матрицы
количество записей в файле

Например:

def contains(items: list[int], target: int) -> bool:
    for item in items:
        if item == target:
            return True

    return False

Здесь:

n = количество элементов items

В худшем случае нужно просмотреть весь список, поэтому сложность — O(n).

Зачем используется асимптотическая оценка #

Фактическое время зависит от:

  • процессора;

  • языка;

  • компилятора;

  • реализации;

  • кеша процессора;

  • операционной системы;

  • нагрузки машины.

Но рост алгоритма сохраняется.

Например, один алгоритм выполняет:

100n

операций, а другой:

При небольшом n второй может быть быстрее. Но при больших данных квадратичный рост начинает доминировать.

n = 10:
100n = 1000
n²   = 100

n = 10 000:
100n = 1 000 000
n²   = 100 000 000

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

Big O — верхняя оценка #

На практике чаще всего используют обозначение:

O(...)

Оно описывает верхнюю границу роста алгоритма.

Например:

O(1)
O(log n)
O(n)
O(n log n)
O(n²)
O(2ⁿ)
O(n!)

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

Основные классы сложности #

O(1) — постоянная #

Количество операций не зависит от размера коллекции.

def get_first(items: list[int]) -> int:
    return items[0]

Для списка из 10 или миллиона элементов выполняется примерно одинаковый объём работы.

O(1)

Другие примеры:

user = users_by_id[user_id]
value = items[index]

Доступ по индексу списка и средний доступ по ключу словаря обычно рассматривают как O(1).

O(log n) — логарифмическая #

На каждом шаге размер рассматриваемой области значительно сокращается, обычно вдвое.

Классический пример — бинарный поиск:

def binary_search(
    items: list[int],
    target: int,
) -> int | None:
    left = 0
    right = len(items) - 1

    while left <= right:
        middle = (left + right) // 2
        value = items[middle]

        if value == target:
            return middle

        if value < target:
            left = middle + 1
        else:
            right = middle - 1

    return None

Для миллиона элементов требуется примерно:

log₂(1 000 000) ≈ 20 шагов

Вместо просмотра миллиона значений.

O(n) — линейная #

Алгоритм выполняет работу для каждого элемента:

def calculate_total(prices: list[int]) -> int:
    total = 0

    for price in prices:
        total += price

    return total

Количество операций пропорционально n.

O(n log n) — линейно-логарифмическая #

Часто встречается у эффективных алгоритмов сортировки:

merge sort
heap sort
средний случай quicksort

Примерно такую сложность имеют многие встроенные сортировки:

sorted_items = sorted(items)

Для произвольных сравниваемых элементов сортировка обычно требует порядка n log n сравнений.

O(n²) — квадратичная #

Часто возникает при вложенных циклах по одной коллекции:

def print_pairs(items: list[int]) -> None:
    for first in items:
        for second in items:
            print(first, second)

Если элементов n, внутренний цикл выполняется n раз для каждой из n итераций внешнего:

n × n = n²

При увеличении входа в 10 раз работа увеличивается примерно в 100 раз.

O(2ⁿ) — экспоненциальная #

Количество операций удваивается при добавлении одного элемента.

Так может работать наивный перебор всех подмножеств:

для n элементов существует 2ⁿ подмножеств

Значения быстро становятся огромными:

n = 10 → 1 024
n = 20 → 1 048 576
n = 40 → более триллиона

O(n!) — факториальная #

Возникает при переборе всех перестановок:

n элементов
→ n! возможных порядков

Например:

10! = 3 628 800
20! ≈ 2,43 × 10¹⁸

Такие алгоритмы применимы только для очень небольших входов или требуют эвристик и сокращения пространства поиска.

Сравнение роста #

От более масштабируемого к менее масштабируемому:

O(1)
O(log n)
O(n)
O(n log n)
O(n²)
O(n³)
O(2ⁿ)
O(n!)

При этом O(n²) не означает автоматически «плохой алгоритм». Для n = 20 он может быть совершенно приемлемым.

Контекст имеет значение:

размер входа
частота вызова
стоимость одной операции
ограничения по времени и памяти

Почему отбрасываются коэффициенты #

Допустим, алгоритм выполняет:

3n + 10

операций.

При больших n основную роль играет n, поэтому:

O(3n + 10) → O(n)

Аналогично:

5n² + 100n + 200
→ O(n²)

Отбрасываются:

  • постоянные коэффициенты;

  • слагаемые меньшего порядка.

Потому что при больших значениях доминирует наиболее быстро растущая часть.

Однако это не означает, что коэффициенты не важны на практике:

O(n) с дорогим сетевым запросом на каждой итерации

может быть намного медленнее:

O(n²) из простых операций в памяти

на реальных ограниченных объёмах данных.

Последовательные операции #

for item in items:
    process(item)

for item in items:
    save(item)

Получается:

O(n) + O(n) = O(2n) = O(n)

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

Например:

O(n²) + O(n)
→ O(n²)

Вложенные операции #

for first in items:
    for second in items:
        process(first, second)

Сложности перемножаются:

O(n) × O(n)
→ O(n²)

Но не каждый вложенный цикл автоматически означает O(n²).

Например:

for group in groups:
    for item in group:
        process(item)

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

O(n)

Несколько независимых размеров #

Рассмотрим:

def find_matches(
    users: list[User],
    orders: list[Order],
) -> None:
    for user in users:
        for order in orders:
            if order.user_id == user.id:
                ...

Здесь два разных размера:

n = количество пользователей
m = количество заказов

Сложность:

O(n × m)

Не всегда правильно обозначать её как O(n²), потому что размеры коллекций могут сильно различаться.

Лучший, средний и худший случаи #

Рассмотрим линейный поиск:

def find(items: list[int], target: int) -> bool:
    for item in items:
        if item == target:
            return True

    return False

Лучший случай #

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

O(1)

Худший случай #

Элемент находится последним или отсутствует:

O(n)

Средний случай #

Обычно требуется просмотреть значительную часть списка:

O(n)

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

Амортизированная сложность #

Некоторые операции обычно дешёвые, но иногда требуют дорогого перераспределения.

Например:

items.append(value)

Добавление в Python-список обычно выполняется за O(1). Иногда внутренний массив переполняется, и Python выделяет больший блок памяти и переносит ссылки.

Одна конкретная операция может стоить O(n), но в последовательности большого числа добавлений средняя стоимость одной операции остаётся:

амортизированно O(1)

Пространственная сложность #

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

Например:

def double_values(items: list[int]) -> list[int]:
    return [item * 2 for item in items]

Создаётся новый список из n элементов:

время:  O(n)
память: O(n)

Вариант с генератором:

def double_values(items: list[int]):
    for item in items:
        yield item * 2

Дополнительная память обычно:

O(1)

если результаты потребляются постепенно и не сохраняются целиком.

Пример оптимизации сложности #

Неэффективный поиск пересечений:

def get_common(
    first: list[int],
    second: list[int],
) -> list[int]:
    result = []

    for item in first:
        if item in second:
            result.append(item)

    return result

Проверка item in second для списка — O(m).

Общая сложность:

O(n × m)

Можно построить множество:

def get_common(
    first: list[int],
    second: list[int],
) -> list[int]:
    second_set = set(second)

    return [
        item
        for item in first
        if item in second_set
    ]

Построение множества:

O(m)

Обход первого списка:

O(n)

Проверка в множестве в среднем:

O(1)

Итого:

O(n + m)

Цена оптимизации — дополнительная память O(m).

Сложность типичных операций Python #

Приблизительно:

ОперацияСредняя сложность
list[index]O(1)
list.append()амортизированно O(1)
list.pop() с концаO(1)
list.pop(0)O(n)
value in listO(n)
dict[key]O(1)
key in dictO(1)
value in setO(1)
sorted(items)O(n log n)
Копирование спискаO(n)

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

Big O, Big Omega и Big Theta #

O(f(n)) #

Верхняя асимптотическая граница:

алгоритм растёт не быстрее указанного порядка

Ω(f(n)) #

Нижняя граница:

алгоритм растёт не медленнее указанного порядка

Θ(f(n)) #

Точная асимптотическая граница:

рост ограничен одним порядком и сверху, и снизу

Например, полный обход всех элементов всегда требует линейного количества действий:

Θ(n)

В повседневной разработке обычно используют O(...), даже когда формально подразумевают Θ(...).

Асимптотическая сложность не показывает всё #

Два алгоритма с одинаковым O(n) могут сильно отличаться из-за:

  • коэффициентов;

  • количества аллокаций;

  • обращений к сети;

  • работы с БД;

  • кеш-локальности;

  • системных вызовов;

  • параллелизма;

  • особенностей реализации языка.

Например:

for user_id in user_ids:
    await database.get_user(user_id)

Формально цикл линейный:

O(n)

Но он выполняет n сетевых запросов к БД.

Batch-вариант:

users = await database.get_users(user_ids)

тоже может иметь линейную внутреннюю работу, но использует один сетевой round trip и на практике будет намного быстрее.

Поэтому нужно учитывать и вычислительную сложность, и реальную стоимость операций.

Итог #

Асимптотическая сложность
→ показывает, как растут затраты алгоритма
  при увеличении размера входных данных

Временная сложность
→ рост количества работы

Пространственная сложность
→ рост потребления дополнительной памяти

Основные классы:
O(1), O(log n), O(n), O(n log n),
O(n²), O(2ⁿ), O(n!)

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


19. Что такое цикломатическая сложность? #

Что такое цикломатическая сложность #

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

Она показывает, насколько сильно управление программой разветвляется из-за:

  • if и elif;

  • циклов;

  • case в match/switch;

  • обработчиков исключений;

  • иногда логических операторов and и or.

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

Простейшее правило подсчёта #

Для одной функции цикломатическую сложность часто считают так:

M = количество точек ветвления + 1

Функция без условий:

def add(a: int, b: int) -> int:
    return a + b

Здесь только один путь выполнения:

M = 1

Пример с одним условием #

def get_access_message(is_admin: bool) -> str:
    if is_admin:
        return "Full access"

    return "Limited access"

Есть одна точка ветвления:

M = 1 + 1 = 2

Два независимых пути:

is_admin = True
is_admin = False

Несколько условий #

def get_discount(
    is_vip: bool,
    total: int,
) -> int:
    if is_vip:
        return 20

    if total >= 1000:
        return 10

    return 0

Две точки ветвления:

M = 2 + 1 = 3

Основные пути:

1. Пользователь VIP
2. Не VIP, но сумма >= 1000
3. Не VIP и сумма < 1000

if, elif, else #

def classify_age(age: int) -> str:
    if age < 18:
        return "minor"
    elif age < 65:
        return "adult"
    else:
        return "senior"

Точки принятия решения:

if
elif

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

M = 2 + 1 = 3

Циклы тоже увеличивают сложность #

def find_even(numbers: list[int]) -> int | None:
    for number in numbers:
        if number % 2 == 0:
            return number

    return None

Точки ветвления:

for
if

Получается:

M = 3

Почему цикл считается ветвлением:

цикл не выполняется ни разу
цикл выполняется один или несколько раз

Обработка исключений #

В зависимости от инструмента except обычно увеличивает сложность:

def parse_number(value: str) -> int | None:
    try:
        return int(value)
    except ValueError:
        return None

Есть два пути:

преобразование успешно
возникает ValueError

Условно:

M = 2

Правила подсчёта могут незначительно отличаться между анализаторами.

Формальное определение через граф управления #

Код можно представить как граф потока управления:

  • вершины — блоки инструкций;

  • рёбра — возможные переходы;

  • связные компоненты — независимые участки графа.

Формула Маккейба:

M = E - N + 2P

Где:

E — количество рёбер графа
N — количество вершин
P — количество связных компонентов

Для одной связной функции обычно:

P = 1

и формула превращается в:

M = E - N + 2

В повседневной разработке обычно проще считать точки принятия решений плюс один.

Логические операторы and и or #

Рассмотрим:

def can_access(
    is_active: bool,
    is_admin: bool,
) -> bool:
    if is_active and is_admin:
        return True

    return False

На уровне выполнения and содержит короткое замыкание:

is_active = False
→ is_admin даже не проверяется

Поэтому некоторые инструменты считают:

if  → +1
and → +1

Тогда сложность будет 3.

Другие инструменты могут считать всё условие одной точкой решения и вернуть 2. Поэтому числовые значения разных анализаторов иногда отличаются.

Цикломатическая сложность и количество тестов #

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

Для функции:

def calculate_fee(
    is_vip: bool,
    amount: int,
) -> int:
    if amount <= 0:
        raise ValueError("Invalid amount")

    if is_vip:
        return 0

    return 100

Сложность:

две точки ветвления + 1
→ M = 3

Полезны как минимум три сценария:

def test_invalid_amount():
    ...

def test_vip_customer():
    ...

def test_regular_customer():
    ...

Но важно:

цикломатическая сложность
≠ точное необходимое количество всех тестов

Одному пути могут потребоваться несколько тестов из-за:

  • граничных значений;

  • различных входных типов;

  • ошибок внешних зависимостей;

  • состояний базы данных;

  • конкурентного выполнения;

  • бизнес-инвариантов.

Высокая сложность #

def process_payment(payment, user):
    if payment.amount <= 0:
        return "invalid"

    if user.is_blocked:
        return "blocked"

    if payment.currency == "USD":
        if user.country == "US":
            fee = 1
        else:
            fee = 3
    elif payment.currency == "EUR":
        if user.is_vip:
            fee = 0
        else:
            fee = 2
    else:
        fee = 5

    if payment.is_refund:
        if payment.original_id is None:
            return "invalid_refund"

    return fee

Такая функция имеет много путей выполнения. Разработчику приходится одновременно учитывать:

валидность суммы
блокировку пользователя
валюту
страну
VIP-статус
возврат
наличие исходной операции

Проблема не только в количестве строк, а в количестве сочетаний условий.

Как уменьшать цикломатическую сложность #

Ранние возвраты #

Глубокая вложенность:

def process(user):
    if user is not None:
        if user.active:
            if user.has_permission:
                return perform_action(user)

    return None

Более линейный вариант:

def process(user):
    if user is None:
        return None

    if not user.active:
        return None

    if not user.has_permission:
        return None

    return perform_action(user)

Числовая цикломатическая сложность может остаться той же, но когнитивная сложность снижается: меньше уровней вложенности.

Выделение отдельных концепций #

def can_process(user) -> bool:
    return (
        user is not None
        and user.active
        and user.has_permission
    )


def process(user):
    if not can_process(user):
        return None

    return perform_action(user)

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

Полиморфизм или таблица диспетчеризации #

Вместо длинного if/elif:

def calculate_fee(currency: str) -> int:
    if currency == "USD":
        return 1
    elif currency == "EUR":
        return 2
    elif currency == "GBP":
        return 3

    return 5

Можно использовать таблицу:

FEES = {
    "USD": 1,
    "EUR": 2,
    "GBP": 3,
}


def calculate_fee(currency: str) -> int:
    return FEES.get(currency, 5)

Это особенно полезно, когда ветки просто сопоставляют ключ со значением или обработчиком.

Какие значения считаются допустимыми #

Часто используют приблизительную шкалу:

1–5
→ простая функция

6–10
→ умеренная сложность

11–20
→ сложная функция, стоит изучить

больше 20
→ высокий риск ошибок и трудного тестирования

Это не универсальный стандарт. Значение 12 не означает автоматически плохой код, а значение 3 — хороший.

Например, сложный, но линейный алгоритм может иметь:

200 строк
цикломатическая сложность = 2

А короткое булево выражение может иметь высокую логическую сложность.

Ограничения метрики #

Цикломатическая сложность не учитывает:

  • понятность имён;

  • длину функции;

  • качество архитектуры;

  • связанность компонентов;

  • количество внешних зависимостей;

  • сложность SQL-запросов;

  • асинхронность;

  • изменяемое состояние;

  • распределённые ошибки;

  • сложность самих условий.

Например:

if user.is_active:
    ...

и:

if (
    account.balance - reserved_amount >= requested_amount
    and not account.is_frozen
    and limits.allow(transaction)
):
    ...

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

Цикломатическая и когнитивная сложность #

Цикломатическая сложность считает независимые пути.

Когнитивная сложность пытается оценить, насколько трудно человеку понимать код. Она сильнее учитывает:

  • вложенность;

  • прерывание линейного потока;

  • сложные логические выражения;

  • рекурсию.

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

Инструменты для Python #

Часто используют:

radon
ruff
flake8 + mccabe
pylint
SonarQube

Например, ruff может контролировать сложность через правило McCabe:

[tool.ruff.lint.mccabe]
max-complexity = 10

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

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

Итог #

Цикломатическая сложность
→ количество независимых путей выполнения кода

Приблизительный подсчёт:
точки ветвления + 1

Увеличивают сложность:
if, elif, циклы, case, except
и иногда and/or

Высокое значение обычно означает:
больше сценариев
больше тестов
выше риск ошибок
сложнее изменение кода

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


20. Чем отличаются временная и пространственная сложность? #

Основное различие #

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

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

Временная сложность
→ сколько операций выполняется

Пространственная сложность
→ сколько памяти используется

Обе обычно выражаются через размер входа n:

O(1)
O(log n)
O(n)
O(n²)
...

Но описывают разные ресурсы.

Пример: сумма элементов #

def calculate_sum(numbers: list[int]) -> int:
    total = 0

    for number in numbers:
        total += number

    return total

Для списка из n элементов функция проходит по каждому элементу:

Время: O(n)

Дополнительно она хранит только переменную total и текущий number:

Дополнительная память: O(1)

Итого:

временная сложность:     O(n)
пространственная:        O(1)

Что именно считается памятью #

Алгоритм может использовать память для:

  • новых списков, словарей и множеств;

  • временных переменных;

  • стека рекурсивных вызовов;

  • кеша и мемоизации;

  • вспомогательных буферов;

  • копий входных данных.

Часто отдельно оценивают дополнительную память — память, созданную алгоритмом сверх уже существующих входных данных.

Например, сам список numbers занимает O(n), но функция выше его не создаёт. Поэтому её дополнительная пространственная сложность — O(1).

Пример с созданием нового списка #

def double_values(numbers: list[int]) -> list[int]:
    result = []

    for number in numbers:
        result.append(number * 2)

    return result

Функция проходит по всем элементам:

Время: O(n)

И создаёт новый список из n элементов:

Дополнительная память: O(n)

Итого:

временная сложность:     O(n)
пространственная:        O(n)

Генератор вместо списка #

from collections.abc import Iterator


def double_values(numbers: list[int]) -> Iterator[int]:
    for number in numbers:
        yield number * 2

Если результаты обрабатываются постепенно и не собираются затем в список:

Время: O(n)
Память: O(1)

Генератор не хранит сразу все n результатов.

Но это не уменьшает общий объём вычислений: каждый элемент всё равно нужно обработать.

Пример поиска дубликатов #

Вариант без дополнительной структуры:

def contains_duplicate(numbers: list[int]) -> bool:
    for i in range(len(numbers)):
        for j in range(i + 1, len(numbers)):
            if numbers[i] == numbers[j]:
                return True

    return False

Сравниваются пары элементов:

Время: O(n²)
Память: O(1)

Быстрый вариант с множеством:

def contains_duplicate(numbers: list[int]) -> bool:
    seen: set[int] = set()

    for number in numbers:
        if number in seen:
            return True

        seen.add(number)

    return False

Теперь:

Время: O(n) в среднем
Память: O(n)

Это пример компромисса:

использовали больше памяти
→ получили меньше времени выполнения

Time-space trade-off #

Часто один ресурс можно сэкономить за счёт другого.

Больше памяти, меньше времени #

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

Например:

user_by_id = {
    user.id: user
    for user in users
}

Построение словаря требует O(n) памяти, зато последующий поиск пользователя выполняется в среднем за O(1), а не O(n).

Меньше памяти, больше времени #

Можно не хранить промежуточные результаты, а вычислять их заново:

нет кеша
→ меньше памяти
→ повторные вычисления

Или использовать потоковую обработку вместо загрузки всего файла:

with open("payments.csv") as file:
    for line in file:
        process(line)

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

Рекурсия и память #

Рассмотрим рекурсивный факториал:

def factorial(n: int) -> int:
    if n <= 1:
        return 1

    return n * factorial(n - 1)

Количество вызовов:

Время: O(n)

Одновременно в стеке сохраняется до n вызовов:

Память: O(n)

Итеративный вариант:

def factorial(n: int) -> int:
    result = 1

    for value in range(2, n + 1):
        result *= value

    return result

Теперь:

Время: O(n)
Память: O(1)

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

Сортировка #

Разные алгоритмы могут иметь одинаковую временную, но разную пространственную сложность.

Например:

Merge sort:
время  → O(n log n)
память → обычно O(n)

Heap sort:
время  → O(n log n)
память → O(1) дополнительной памяти

При этом на практике важны также константы, кеш-локальность и свойства конкретной реализации.

Пространственная сложность не равна размеру результата #

Иногда результат сам по себе требует O(n) памяти:

def copy_positive(numbers: list[int]) -> list[int]:
    return [
        number
        for number in numbers
        if number > 0
    ]

В худшем случае возвращается список из n элементов.

Можно говорить:

общая память результата: O(n)

Но некоторые анализы разделяют:

память результата
и
вспомогательную память алгоритма

Вспомогательная память здесь может считаться O(1) сверх создаваемого результата. Поэтому при обсуждении желательно уточнять, учитывается ли выходная структура.

Сложность и фактические ресурсы #

O(n) не означает конкретное число секунд или мегабайт.

Два алгоритма с пространственной сложностью O(n) могут хранить:

первый  → один байт на элемент
второй  → большой Python-объект на элемент

А два алгоритма с временной сложностью O(n) могут выполнять на каждой итерации:

первый  → одно сложение
второй  → HTTP-запрос

Асимптотика показывает характер роста, но не точную стоимость.

Краткое сравнение #

ХарактеристикаВременная сложностьПространственная сложность
Что измеряетРост количества работыРост используемой памяти
Основной ресурсCPU-время, операцииRAM, stack, структуры данных
Типичный вопросСколько элементов обработаем?Сколько данных храним одновременно?
ПримерОдин проход — O(n)Новый список — O(n)
ОптимизацияМеньше вычисленийМеньше копий и промежуточных данных

Итог #

Временная сложность:
как растёт количество выполняемых операций

Пространственная сложность:
как растёт объём необходимой памяти

Один алгоритм может иметь:

O(n) времени и O(1) памяти
O(n) времени и O(n) памяти
O(n²) времени и O(1) памяти

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


21. Какая асимптотическая сложность бинарного поиска? #

Сложность бинарного поиска #

Для массива из n элементов:

Лучший случай:   O(1)
Средний случай:  O(log n)
Худший случай:   O(log n)

На каждом шаге бинарный поиск уменьшает область поиска примерно вдвое:

n → n/2 → n/4 → n/8 → ... → 1

Количество таких делений равно примерно:

log₂(n)

Например:

1 000 элементов     → около 10 шагов
1 000 000 элементов → около 20 шагов
1 000 000 000       → около 30 шагов

Лучший случай — O(1), когда искомое значение сразу находится посередине.

Пространственная сложность #

Итеративная реализация:

def binary_search(items: list[int], target: int) -> int | None:
    left = 0
    right = len(items) - 1

    while left <= right:
        middle = (left + right) // 2

        if items[middle] == target:
            return middle

        if items[middle] < target:
            left = middle + 1
        else:
            right = middle - 1

    return None

Использует несколько переменных:

Память: O(1)

Рекурсивная реализация создаёт стек вызовов:

Память: O(log n)

Важное условие #

Бинарный поиск требует, чтобы данные были отсортированы и поддерживали быстрый доступ к среднему элементу.

Если массив сначала нужно отсортировать:

сортировка: O(n log n)
поиск:      O(log n)

итого:      O(n log n)

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


22. Какая асимптотическая сложность тернарного поиска? #

Сложность тернарного поиска #

Зависит от того, какой именно вариант тернарного поиска рассматривается, но в обоих основных случаях временная сложность:

O(log n)

Поиск элемента в отсортированном массиве #

Диапазон делится на три части с помощью двух точек:

[left ... mid1 ... mid2 ... right]

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

n → n/3 → n/9 → n/27 → ... → 1

Рекуррентная оценка:

T(n) = T(n / 3) + O(1)

Количество шагов:

log₃(n)

Поэтому:

лучший случай:  O(1)
средний случай: O(log n)
худший случай:  O(log n)

Основание логарифма в Big O не имеет значения:

O(log₃ n) = O(log₂ n) = O(log n)

Сравнение с бинарным поиском #

Бинарный поиск:

n → n/2

Тернарный поиск:

n → n/3

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

Бинарный поиск:
→ одна средняя точка
→ обычно одно-два сравнения

Тернарный поиск:
→ две средние точки
→ больше сравнений

Поэтому при поиске элемента в отсортированном массиве бинарный поиск обычно эффективнее на практике, хотя асимптотика у них одинаковая:

бинарный поиск:  O(log n)
тернарный поиск: O(log n)

Поиск экстремума унимодальной функции #

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

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

n → 2n/3 → 4n/9 → ...

Рекуррентная оценка:

T(n) = T(2n / 3) + O(1)

Она также даёт:

O(log n)

Для непрерывного диапазона поиск выполняют до достижения точности ε:

O(log((right - left) / ε))

Пространственная сложность #

Итеративная реализация:

O(1)

Рекурсивная реализация:

O(log n)

из-за стека рекурсивных вызовов.

Итог #

Тернарный поиск элемента:
время  → O(log n)
память → O(1) итеративно

Тернарный поиск экстремума:
время  → O(log n)
или O(log((r - l) / ε)) для вещественного диапазона

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


23. Как определить Big On (Сложность) функции? #

Что именно нужно определить #

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

Например:

def calculate_sum(numbers: list[int]) -> int:
    total = 0

    for number in numbers:
        total += number

    return total

Здесь:

n = количество элементов numbers

Цикл выполняется n раз, поэтому временная сложность:

O(n)

Общий алгоритм анализа #

Обычно действуют так:

1. Определить размер входа
2. Найти операции, зависящие от размера входа
3. Определить число их повторений
4. Учесть сложность вложенных операций
5. Сложить последовательные участки
6. Для ветвлений взять наиболее дорогой путь
7. Убрать константы и члены меньшего порядка

1. Определить, что такое n #

Размер входа зависит от функции.

def process_users(users: list[User]) -> None:
    ...

Здесь:

n = количество пользователей

Для двух коллекций могут быть разные размеры:

def match_users_and_orders(
    users: list[User],
    orders: list[Order],
) -> None:
    ...

Здесь:

n = количество пользователей
m = количество заказов

Не нужно автоматически называть оба размера n.

2. Определить стоимость отдельных операций #

Простые операции обычно считают O(1):

value = numbers[5]
total += value
x = a + b
return result

Но вызов функции или операция над коллекцией могут иметь другую сложность:

value in some_list    # O(n)
value in some_set     # O(1) в среднем
sorted(items)         # O(n log n)
items.copy()          # O(n)

Поэтому нельзя анализировать только количество строк.

Константная сложность #

def get_first(numbers: list[int]) -> int:
    return numbers[0]

Размер списка не влияет на число операций:

T(n) = 1
O(1)

Один цикл #

def print_numbers(numbers: list[int]) -> None:
    for number in numbers:
        print(number)

Тело цикла выполняется n раз:

T(n) = n
O(n)

Если внутри цикла выполняется несколько постоянных операций:

def process(numbers: list[int]) -> None:
    for number in numbers:
        x = number * 2
        y = x + 10
        print(y)

Получается примерно:

T(n) = 3n

Но коэффициент отбрасывается:

O(3n) = O(n)

Последовательные циклы #

def process(numbers: list[int]) -> None:
    for number in numbers:
        print(number)

    for number in numbers:
        save(number)

Первый цикл — O(n), второй — O(n):

O(n) + O(n)
= O(2n)
= O(n)

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

Вложенные циклы #

def print_pairs(numbers: list[int]) -> None:
    for first in numbers:
        for second in numbers:
            print(first, second)

Внешний цикл выполняется n раз. Для каждой его итерации внутренний также выполняется n раз:

n × n = n²

Итого:

O(n²)

Для трёх полных вложенных циклов:

O(n³)

Вложенные циклы по разным коллекциям #

def match(
    users: list[User],
    orders: list[Order],
) -> None:
    for user in users:
        for order in orders:
            if user.id == order.user_id:
                ...

Если пользователей n, а заказов m:

O(n × m)

Это не обязательно O(n²).

Не каждый вложенный цикл означает O(n²) #

def process_groups(groups: list[list[int]]) -> None:
    for group in groups:
        for item in group:
            print(item)

Если суммарно во всех группах находится n элементов, каждый элемент обрабатывается один раз:

O(n)

Нужно считать фактическое число итераций, а не просто уровни отступов.

Цикл, уменьшающий данные в несколько раз #

def divide_until_one(n: int) -> None:
    while n > 1:
        n //= 2

Значение изменяется так:

n → n/2 → n/4 → n/8 → ... → 1

Количество итераций:

log₂ n

Следовательно:

O(log n)

Подобная логика используется в бинарном поиске.

Линейный цикл с логарифмической операцией #

def process(numbers: list[int]) -> None:
    for number in numbers:
        binary_search(sorted_values, number)

Цикл выполняется n раз, бинарный поиск — O(log m):

O(n × log m)

Если размер обеих коллекций обозначается одним n:

O(n log n)

Условия #

def process(numbers: list[int], full: bool) -> None:
    if full:
        for first in numbers:
            for second in numbers:
                print(first, second)
    else:
        for number in numbers:
            print(number)

Ветки имеют сложности:

if:   O(n²)
else: O(n)

Для худшего случая берётся наиболее дорогая ветка:

O(n²)

Само условие if обычно стоит O(1), но код внутри веток может иметь любую сложность.

Ранний return #

def contains(
    numbers: list[int],
    target: int,
) -> bool:
    for number in numbers:
        if number == target:
            return True

    return False

Сложность зависит от случая:

Лучший случай:
элемент первый → O(1)

Худший случай:
элемент последний или отсутствует → O(n)

Средний случай:
O(n)

Когда просто спрашивают Big O, обычно называют худший случай:

O(n)

Вызовы других функций #

def process(numbers: list[int]) -> list[int]:
    result = normalize(numbers)
    return sorted(result)

Нужно знать сложность вызываемых операций.

Допустим:

normalize(numbers) → O(n)
sorted(result)     → O(n log n)

Тогда:

O(n) + O(n log n)
= O(n log n)

Доминирует более быстро растущий член.

Встроенные операции Python #

При анализе важно учитывать их стоимость.

ОперацияСредняя сложность
items[index]O(1)
items.append(x)амортизированно O(1)
items.pop()O(1)
items.pop(0)O(n)
x in listO(n)
x in setO(1)
key in dictO(1)
list.copy()O(n)
items[a:b]O(k)
sorted(items)O(n log n)
min(items)O(n)
sum(items)O(n)

Здесь k — размер среза.

Распространённая ошибка с in #

def common_items(
    first: list[int],
    second: list[int],
) -> list[int]:
    result = []

    for item in first:
        if item in second:
            result.append(item)

    return result

Пусть:

len(first) = n
len(second) = m

Цикл выполняется n раз. Поиск в списке second стоит O(m):

O(n × m)

Если размеры одинаковые:

O(n²)

Вариант с множеством:

def common_items(
    first: list[int],
    second: list[int],
) -> list[int]:
    second_set = set(second)

    return [
        item
        for item in first
        if item in second_set
    ]

Построение множества:

O(m)

Обход первого списка:

O(n)

Проверка в множестве:

O(1) в среднем

Итого:

O(n + m)

Срезы тоже создают работу #

def recursive_process(items: list[int]) -> None:
    if not items:
        return

    recursive_process(items[1:])

Количество рекурсивных вызовов — n, но на каждом шаге создаётся новый срез:

n + (n - 1) + (n - 2) + ... + 1

Получается:

O(n²)

Хотя визуально функция кажется линейно-рекурсивной.

Рекурсивные функции #

Для рекурсии составляют рекуррентное соотношение.

Линейная рекурсия #

def factorial(n: int) -> int:
    if n <= 1:
        return 1

    return n * factorial(n - 1)

На каждом шаге выполняется один рекурсивный вызов:

T(n) = T(n - 1) + O(1)

Итого:

O(n)

Наивный Фибоначчи #

def fibonacci(n: int) -> int:
    if n <= 1:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)

Один вызов порождает два других:

T(n) = T(n - 1) + T(n - 2) + O(1)

Сложность экспоненциальная:

O(2ⁿ)

Более точная оценка связана с золотым сечением, но обычно достаточно O(2ⁿ).

Разделение задачи пополам #

T(n) = T(n/2) + O(1)

Получается:

O(log n)

Если создаются две подзадачи половинного размера:

T(n) = 2T(n/2) + O(n)

Получается:

O(n log n)

Так устроена сортировка слиянием.

Как упрощать итоговое выражение #

Отбрасывать константы #

O(5n) → O(n)
O(100) → O(1)

Оставлять самый быстро растущий член #

O(n² + n + 100)
→ O(n²)
O(n log n + n)
→ O(n log n)

Не объединять независимые размеры без необходимости #

O(n + m)

не следует превращать в O(n), если n и m — размеры разных входов.

Пример полного анализа #

def analyze(
    users: list[User],
    blocked_ids: list[int],
) -> list[User]:
    blocked_set = set(blocked_ids)
    result = []

    for user in users:
        if user.id not in blocked_set:
            result.append(user)

    result.sort(key=lambda user: user.name)

    return result

Пусть:

n = количество users
m = количество blocked_ids

Разбираем:

set(blocked_ids)  → O(m)
обход users       → O(n)
поиск в set       → O(1) в среднем на итерацию
append            → O(1) амортизированно
сортировка        → O(n log n) в худшем случае по размеру результата

Суммируем:

O(m) + O(n) + O(n log n)

Итог:

O(m + n log n)

Если m имеет тот же порядок, что и n:

O(n log n)

Дополнительная память:

blocked_set → O(m)
result      → O(n)

Итого: O(n + m)

Практическая памятка #

Одна операция                     → O(1)
Один полный проход                → O(n)
Два последовательных прохода     → O(n)
Два полных вложенных прохода     → O(n²)
Деление диапазона пополам        → O(log n)
Проход + деление пополам         → O(n log n)
Все подмножества                  → O(2ⁿ)
Все перестановки                  → O(n!)

Главное #

Чтобы определить сложность функции, не нужно просто считать строки или циклы. Нужно посчитать, сколько раз выполняется каждая значимая операция в зависимости от размера входа:

последовательные участки → складываются
вложенные операции       → перемножаются
ветвления                 → берётся дорогой путь
рекурсия                  → составляется рекуррентная формула
константы                 → отбрасываются
младшие члены             → отбрасываются

Финальный вопрос всегда один:

Как растёт количество работы,
когда размер входа n становится больше?


24. Что такое Big On (Сложность) функции #

Что такое Big O #

Big O — это обозначение, которое показывает, как растут затраты функции или алгоритма при увеличении размера входных данных.

Обычно оценивают:

время выполнения
→ временная сложность

объём дополнительной памяти
→ пространственная сложность

Big O не показывает точное количество секунд или операций. Оно описывает темп роста.

Что означает O(n) #

Запись:

O(n)

читается как «сложность порядка n».

Она означает, что количество работы растёт примерно пропорционально размеру входа.

def calculate_sum(numbers: list[int]) -> int:
    total = 0

    for number in numbers:
        total += number

    return total

Здесь n — количество элементов списка.

10 элементов    → примерно 10 итераций
100 элементов   → примерно 100 итераций
1000 элементов  → примерно 1000 итераций

Поэтому временная сложность функции:

O(n)

Big O и O(n) — не одно и то же #

Big O — это общее название обозначения.

O(n) — один конкретный класс сложности.

Другие классы:

СложностьХарактер ростаПример
O(1)постоянныйдоступ к элементу списка по индексу
O(log n)логарифмическийбинарный поиск
O(n)линейныйполный обход списка
O(n log n)линейно-логарифмическийэффективная сортировка
O(n²)квадратичныйдва вложенных прохода
O(2ⁿ)экспоненциальныйперебор всех подмножеств
O(n!)факториальныйперебор всех перестановок

Почему отбрасываются константы #

Допустим, функция выполняет:

3n + 10

операций.

При больших значениях n главным остаётся линейный рост:

O(3n + 10) = O(n)

Аналогично:

O(5n² + 100n + 20) = O(n²)

Оставляют наиболее быстро растущий член, а коэффициенты и константы отбрасывают.

Примеры #

Постоянная сложность:

def get_first(numbers: list[int]) -> int:
    return numbers[0]
O(1)

Линейная:

def contains(numbers: list[int], target: int) -> bool:
    for number in numbers:
        if number == target:
            return True

    return False

Худший случай:

O(n)

Квадратичная:

def print_pairs(numbers: list[int]) -> None:
    for first in numbers:
        for second in numbers:
            print(first, second)
O(n × n) = O(n²)

Что Big O не показывает #

Два алгоритма с O(n) могут сильно отличаться по реальной скорости:

for item in items:
    total += item

и:

for item in items:
    await send_http_request(item)

Оба выполняют n итераций, но HTTP-запрос намного дороже сложения.

Big O не учитывает напрямую:

  • скорость процессора;
  • сетевые задержки;
  • стоимость одной операции;
  • константные коэффициенты;
  • особенности языка;
  • кеш процессора;
  • нагрузку системы.

Итог #

Big O
→ показывает характер роста затрат алгоритма

O(n)
→ затраты растут линейно с размером входа

n
→ размер входных данных

Таким образом, выражение «Big O функции» означает асимптотическую оценку того, как увеличивается время работы или потребление памяти функции при росте входных данных.


25. Как вы оцениваете свои знания в базах данных? #

Hello World


26. Что такое сериализация и десереализация? #

Что такое сериализация #

Сериализация — преобразование объекта или структуры данных в формат, который можно:

  • передать по сети;

  • записать в файл;

  • сохранить в кеш или БД;

  • передать через брокер сообщений;

  • восстановить позже.

Например, в программе есть Python-словарь:

user = {
    ""id"": 42,
    ""username"": ""alex"",
    ""active"": True,
}

Для передачи через HTTP его можно сериализовать в JSON:

{
  ""id"": 42,
  ""username"": ""alex"",
  ""active"": true
}

В памяти программы это объект dict, а после сериализации — последовательность байтов или текстовое представление.

Python-объект
→ сериализация
→ JSON / XML / MessagePack / Protobuf / байты

Что такое десериализация #

Десериализация — обратное преобразование: из переданного или сохранённого формата создаётся объект, с которым может работать программа.

JSON / XML / байты
→ десериализация
→ объект программы

Например:

import json

raw_data = '{""id"": 42, ""username"": ""alex"", ""active"": true}'

user = json.loads(raw_data)

print(type(user))
print(user[""username""])

Результат:

<class 'dict'>
alex

Пример в Python #

Сериализация:

import json

user = {
    ""id"": 42,
    ""username"": ""alex"",
    ""active"": True,
}

serialized = json.dumps(user)

print(serialized)
print(type(serialized))

Результат:

{""id"": 42, ""username"": ""alex"", ""active"": true}
<class 'str'>

Десериализация:

deserialized = json.loads(serialized)

print(deserialized)
print(type(deserialized))

Результат:

{'id': 42, 'username': 'alex', 'active': True}
<class 'dict'>

dump и load #

Для файлов используются функции dump() и load():

import json

user = {
    ""id"": 42,
    ""username"": ""alex"",
}

Запись:

with open(""user.json"", ""w"", encoding=""utf-8"") as file:
    json.dump(user, file)

Чтение:

with open(""user.json"", encoding=""utf-8"") as file:
    user = json.load(file)

Разница:

dumps → сериализует в строку
loads → десериализует из строки

dump  → сериализует в файл
load  → десериализует из файла

Зачем это нужно в API #

HTTP не передаёт Python-объекты напрямую.

Например, backend работает с объектом:

user = User(
    id=42,
    username=""alex"",
)

Перед отправкой ответа объект преобразуется:

User
→ dict
→ JSON
→ bytes
→ HTTP response

На стороне клиента происходит обратный процесс:

HTTP response
→ bytes
→ JSON
→ объект или структура данных клиента

Пример ответа API:

HTTP/1.1 200 OK
Content-Type: application/json

{
  ""id"": 42,
  ""username"": ""alex""
}

Сериализация в FastAPI и Pydantic #

Pydantic-модель:

from pydantic import BaseModel


class UserResponse(BaseModel):
    id: int
    username: str
    active: bool

Создание объекта:

user = UserResponse(
    id=42,
    username=""alex"",
    active=True,
)

Преобразование в словарь:

data = user.model_dump()

Результат:

{
    ""id"": 42,
    ""username"": ""alex"",
    ""active"": True,
}

Преобразование в JSON:

json_data = user.model_dump_json()

При получении запроса FastAPI выполняет обратную работу:

JSON запроса
→ десериализация
→ валидация
→ объект Pydantic

То есть Pydantic занимается не только десериализацией, но и проверкой типов и ограничений.

Не все типы поддерживаются JSON #

JSON поддерживает ограниченный набор типов:

объект
массив
строка
число
true / false
null

Но в Python существуют:

datetime
date
UUID
Decimal
set
bytes
Enum
пользовательские классы

Поэтому их нужно преобразовывать.

Например:

from datetime import datetime

data = {
    ""created_at"": datetime.now(),
}

Обычный json.dumps() выдаст ошибку:

TypeError: Object of type datetime is not JSON serializable

Можно преобразовать дату вручную:

data = {
    ""created_at"": datetime.now().isoformat(),
}

Либо использовать сериализатор, который знает правила для datetime, например Pydantic.

Текстовые и бинарные форматы #

JSON #

текстовый
читаемый человеком
широко используется в HTTP API

Пример:

{
  ""id"": 42,
  ""active"": true
}

XML #

текстовый
поддерживает сложную структуру и схемы
часто встречается в SOAP и старых интеграциях

CSV #

текстовый табличный формат
подходит для строк и колонок
неудобен для вложенных структур

MessagePack #

бинарный
компактнее JSON
быстро сериализуется

Protocol Buffers #

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

Avro #

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

Сериализация и кодирование #

Эти понятия связаны, но не полностью одинаковы.

Сериализация:
объект → формат данных

Кодирование:
представление данных в определённой форме

Например:

Python dict
→ JSON-сериализация
→ строка Unicode
→ UTF-8-кодирование
→ bytes

Код:

import json

data = {""username"": ""Алекс""}

json_string = json.dumps(data, ensure_ascii=False)
json_bytes = json_string.encode(""utf-8"")

Обратный процесс:

json_string = json_bytes.decode(""utf-8"")
data = json.loads(json_string)

Сериализация не равна шифрованию #

Сериализация не скрывает данные.

{
  ""password"": ""secret123""
}

Если это сериализовать в JSON, пароль останется читаемым.

сериализация ≠ шифрование
сериализация ≠ хеширование
сериализация ≠ сжатие

Это разные операции:

Сериализация
→ преобразование структуры данных

Шифрование
→ защита конфиденциальности

Хеширование
→ получение необратимого отпечатка

Сжатие
→ уменьшение размера

Их можно комбинировать:

объект
→ сериализация
→ сжатие
→ шифрование
→ передача

Что происходит с классами #

Допустим, есть объект:

class User:
    def __init__(self, user_id: int, username: str) -> None:
        self.id = user_id
        self.username = username
user = User(42, ""alex"")

JSON не знает, что такое User. Поэтому объект сначала преобразуют в поддерживаемую структуру:

data = {
    ""id"": user.id,
    ""username"": user.username,
}

После десериализации JSON получится словарь, а не объект User:

data = json.loads(serialized)

user = User(
    user_id=data[""id""],
    username=data[""username""],
)

То есть десериализация формата и создание доменного объекта могут быть отдельными этапами.

Безопасность десериализации #

Десериализация недоверенных данных может быть опасна.

Особенно это касается форматов, способных восстанавливать произвольные объекты.

Например, Python pickle:

import pickle

data = pickle.loads(untrusted_bytes)

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

Для внешних API безопаснее использовать форматы данных:

JSON
MessagePack с контролем типов
Protocol Buffers

Но даже JSON необходимо валидировать:

  • типы;

  • обязательные поля;

  • длины строк;

  • диапазоны чисел;

  • глубину вложенности;

  • размер тела запроса.

Схема данных #

Сериализация отвечает на вопрос:

Как представить данные?

Схема отвечает:

Какие поля и типы допустимы?

Например:

class PaymentRequest(BaseModel):
    account_id: int
    amount: int
    currency: str

JSON:

{
  ""account_id"": 42,
  ""amount"": 100,
  ""currency"": ""AZN""
}

Pydantic:

  1. десериализует JSON;

  2. проверяет наличие полей;

  3. проверяет типы;

  4. создаёт объект PaymentRequest.

Где применяется сериализация #

HTTP API
WebSocket
RabbitMQ и Kafka
Redis
файлы конфигурации
кеширование
межпроцессное взаимодействие
хранение сессий
логирование
RPC и gRPC

Например, сообщение в RabbitMQ:

message = {
    ""event_id"": ""evt-1001"",
    ""order_id"": 42,
    ""status"": ""paid"",
}

Перед отправкой:

body = json.dumps(message).encode(""utf-8"")

Consumer получает байты:

message = json.loads(body.decode(""utf-8""))

Итог #

Сериализация:
объект программы
→ переносимый или сохраняемый формат

Десериализация:
переносимый формат
→ объект или структура программы

Пример полного пути в API:

Pydantic-модель
→ словарь
→ JSON
→ UTF-8 bytes
→ HTTP
→ UTF-8 строка
→ JSON
→ объект клиента

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


27. С помощью каких инструментов проверяются аннотации типов #

Главное #

В Python аннотации типов сами по себе обычно не проверяются интерпретатором во время выполнения:

def add(a: int, b: int) -> int:
    return a + b


result = add("10", "20")
print(result)  # "1020"

Хотя функция ожидает int, Python разрешает передать строки. Проверку выполняют отдельные инструменты.

Статические анализаторы типов #

Они проверяют код до запуска, анализируя аннотации.

mypy #

Один из наиболее распространённых type checker:

def add(a: int, b: int) -> int:
    return a + b


result = add("10", "20")

Проверка:

mypy app.py

Сообщит примерно:

Argument 1 to "add" has incompatible type "str"; expected "int"
Argument 2 to "add" has incompatible type "str"; expected "int"

Pyright #

Статический анализатор типов, используемый также расширением Pylance в VS Code:

pyright

Обычно отличается высокой скоростью и хорошей поддержкой современной системы типов Python.

BasedPyright #

Расширенный вариант Pyright с более строгими проверками и дополнительными диагностическими правилами.

Pyre #

Статический анализатор типов для крупных Python-проектов.

pytype #

Анализатор, который умеет не только проверять явно написанные аннотации, но и выводить некоторые типы из кода.

Проверка в IDE #

IDE обычно запускает собственный анализатор или интегрируется с внешним:

VS Code
→ Pylance / Pyright

PyCharm
→ встроенный анализатор типов

Neovim
→ Pyright через Language Server Protocol

IDE подчёркивает ошибку непосредственно в редакторе:

user_id: int = "42"

Но проверки IDE могут отличаться от результатов mypy или Pyright. Поэтому в проекте обычно выбирают один основной type checker и запускают его в CI.

Проверка во время выполнения #

Статические анализаторы не влияют на выполнение программы. Для runtime-проверки используются другие инструменты.

Pydantic #

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

from pydantic import BaseModel


class User(BaseModel):
    id: int
    username: str


user = User(
    id="42",
    username="alex",
)

print(user.id)        # 42
print(type(user.id))  # int

Pydantic может преобразовать строку "42" в int. В строгом режиме такое преобразование можно запретить.

Он применяется прежде всего для:

  • входных данных API;

  • конфигурации;

  • сериализации;

  • валидации структур данных.

Pydantic не является полной заменой mypy или Pyright: он валидирует конкретные значения во время выполнения, а статический анализатор проверяет связи типов во всём коде.

typeguard #

Проверяет аргументы и возвращаемые значения функций во время выполнения:

from typeguard import typechecked


@typechecked
def add(a: int, b: int) -> int:
    return a + b


add("10", "20")  # ошибка типов во время выполнения

beartype #

Также добавляет runtime-проверку аннотаций:

from beartype import beartype


@beartype
def add(a: int, b: int) -> int:
    return a + b

isinstance() для ручной проверки #

Можно проверять типы вручную:

def calculate_total(amount: int) -> int:
    if not isinstance(amount, int):
        raise TypeError("amount must be int")

    return amount * 2

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

Кроме того, isinstance() не работает напрямую со многими параметризованными типами:

isinstance([1, 2], list[int])  # TypeError

Можно проверить только сам контейнер:

isinstance([1, 2], list)

Проверку типов элементов придётся реализовать отдельно или использовать специализированную библиотеку.

Линтеры и type checker — не одно и то же #

Ruff, Flake8 и Pylint преимущественно проверяют:

  • стиль;

  • потенциальные ошибки;

  • неиспользуемые импорты;

  • подозрительные конструкции;

  • соблюдение правил проекта.

Например:

unused_variable = 10

Type checker проверяет совместимость типов:

user_id: int = "42"

Некоторые линтеры имеют отдельные правила, связанные с аннотациями, но обычно они не выполняют полноценный анализ типов вместо mypy или Pyright.

Типичная конфигурация проекта #

Для FastAPI-проекта часто используют:

Ruff
→ стиль, импорты и потенциальные ошибки

mypy или Pyright
→ статическая проверка типов

Pydantic
→ runtime-валидация входных и выходных данных

pytest
→ проверка фактического поведения

Например:

from pydantic import BaseModel


class PaymentRequest(BaseModel):
    account_id: int
    amount: int


def calculate_commission(payment: PaymentRequest) -> int:
    return payment.amount // 100

Здесь:

Pydantic
→ проверит данные, из которых создаётся PaymentRequest

mypy / Pyright
→ проверит использование PaymentRequest в коде

pytest
→ проверит, правильно ли рассчитывается комиссия

Что выбрать #

Для большинства Python-проектов:
→ mypy или Pyright

Для VS Code:
→ Pylance, основанный на Pyright

Для проверки API-данных:
→ Pydantic

Для runtime-проверки обычных функций:
→ typeguard или beartype

Для стиля и общих ошибок:
→ Ruff

Итог #

Аннотации Python
→ сами по себе обычно не запрещают передавать неправильные значения

Статическая проверка:
→ mypy, Pyright, Pyre, pytype

Runtime-проверка:
→ Pydantic, typeguard, beartype, ручной isinstance

Проверка в редакторе:
→ Pylance, PyCharm и другие IDE

На практике основной контроль типов обычно обеспечивает связка Pyright или mypy + IDE + проверка в CI, а Pydantic используется на границах системы для проверки реальных входных данных.


28. Какие возможности появились в OpenAPI 3.0? #

Что изменилось в OpenAPI 3.0 #

OpenAPI 3.0 существенно переработал формат OpenAPI 2.0, ранее называвшийся Swagger 2.0. Основные улучшения связаны с описанием тел запросов, форматов данных, серверов, авторизации и асинхронных callback-вызовов.

1. Объект requestBody #

В OpenAPI 2.0 тело запроса описывалось как параметр:

parameters:
  - in: body
    name: user
    schema:
      $ref: "#/definitions/User"

В OpenAPI 3.0 для тела появился отдельный объект requestBody:

requestBody:
  required: true
  content:
    application/json:
      schema:
        $ref: "#/components/schemas/User"

Тело запроса теперь явно отделено от path-, query-, header- и cookie-параметров. body и formData из OpenAPI 2.0 были заменены на requestBody.

2. Несколько форматов тела запроса #

В OpenAPI 2.0 использовался общий список:

consumes:
  - application/json
  - application/xml

В OpenAPI 3.0 форматы указываются через content, и каждому формату можно назначить отдельную схему:

requestBody:
  content:
    application/json:
      schema:
        $ref: "#/components/schemas/UserJson"

    application/xml:
      schema:
        $ref: "#/components/schemas/UserXml"

То есть один endpoint может принимать JSON, XML, форму или бинарные данные, причём их структуры могут различаться.

3. Аналогичный content для ответов #

В OpenAPI 2.0 тип ответа задавался через глобальный или операционный produces.

В OpenAPI 3.0 каждый HTTP-ответ может иметь собственный набор media types:

responses:
  "200":
    description: User information
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/User"

      application/xml:
        schema:
          $ref: "#/components/schemas/UserXml"

Это позволяет точнее описывать разные представления одного ответа.

4. Новый раздел components #

В OpenAPI 2.0 переиспользуемые объекты были разбросаны по отдельным разделам:

definitions
parameters
responses
securityDefinitions

В OpenAPI 3.0 они объединены внутри components:

components:
  schemas:
  responses:
  parameters:
  examples:
  requestBodies:
  headers:
  securitySchemes:
  links:
  callbacks:

Объекты из components затем подключаются через $ref.

Например:

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string

Использование:

schema:
  $ref: "#/components/schemas/User"

5. Несколько серверов через servers #

В OpenAPI 2.0 адрес API собирался из:

schemes:
  - https
host: api.example.com
basePath: /v1

В OpenAPI 3.0 появился массив servers:

servers:
  - url: https://api.example.com/v1
    description: Production

  - url: https://staging.example.com/v1
    description: Staging

Можно описывать несколько окружений и использовать переменные:

servers:
  - url: https://{environment}.example.com/{version}
    variables:
      environment:
        default: api
        enum:
          - api
          - staging
      version:
        default: v1

Серверы можно задавать глобально, для отдельного path или даже отдельной операции.

6. oneOf, anyOf и not #

OpenAPI 3.0 расширил возможности составления схем.

oneOf #

Значение должно соответствовать ровно одному из вариантов:

Pet:
  oneOf:
    - $ref: "#/components/schemas/Cat"
    - $ref: "#/components/schemas/Dog"

anyOf #

Значение может соответствовать одному или нескольким вариантам:

PaymentMethod:
  anyOf:
    - $ref: "#/components/schemas/Card"
    - $ref: "#/components/schemas/BankAccount"

not #

Значение не должно соответствовать указанной схеме:

not:
  type: string

Это позволило точнее моделировать альтернативные структуры данных и полиморфные модели.

7. nullable #

Появилась возможность явно разрешить null:

middle_name:
  type: string
  nullable: true

Без nullable: true значение должно быть строкой, а не null.

Важно: OpenAPI 3.0 использует собственный вариант схем, основанный на подмножестве JSON Schema, поэтому синтаксис отличается от полного JSON Schema. Более полная совместимость с JSON Schema появилась уже в OpenAPI 3.1.

8. readOnly и writeOnly #

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

User:
  type: object
  properties:
    id:
      type: integer
      readOnly: true

    password:
      type: string
      writeOnly: true

Семантика:

readOnly
→ поле присутствует в ответе,
  но не должно требоваться в запросе

writeOnly
→ поле принимается в запросе,
  но не должно возвращаться в ответе

Например, id формируется сервером, а пароль принимается при регистрации, но не возвращается клиенту.

9. Улучшенное описание файлов #

В OpenAPI 2.0 использовался отдельный тип file.

В OpenAPI 3.0 файл описывается как строка с бинарным форматом:

requestBody:
  content:
    application/octet-stream:
      schema:
        type: string
        format: binary

Загрузка через multipart/form-data:

requestBody:
  content:
    multipart/form-data:
      schema:
        type: object
        properties:
          avatar:
            type: string
            format: binary

Можно описать и массив файлов:

files:
  type: array
  items:
    type: string
    format: binary

OpenAPI 3.0 также позволяет описывать сериализацию отдельных частей multipart-запроса через encoding.

10. Параметры в cookies #

OpenAPI 3.0 позволяет описывать cookie-параметры:

parameters:
  - name: session_id
    in: cookie
    required: true
    schema:
      type: string

Это отдельное расположение параметра наряду с:

path
query
header
cookie

11. Более гибкая сериализация параметров #

Появились свойства style и explode, которые описывают преобразование массивов и объектов в URL, query string, headers и cookies.

Например:

parameters:
  - name: filters
    in: query
    style: deepObject
    explode: true
    schema:
      type: object
      properties:
        status:
          type: string
        limit:
          type: integer

Такой параметр может выглядеть примерно так:

?filters[status]=active&filters[limit]=10

OpenAPI 3.0 поддерживает несколько стилей сериализации, включая form, simple, matrix, label, spaceDelimited, pipeDelimited и deepObject.

12. Несколько примеров #

В OpenAPI 2.0 обычно задавался один example.

В OpenAPI 3.0 можно описать несколько именованных примеров:

content:
  application/json:
    schema:
      $ref: "#/components/schemas/User"
    examples:
      activeUser:
        summary: Active user
        value:
          id: 1
          status: active

      blockedUser:
        summary: Blocked user
        value:
          id: 2
          status: blocked

Примеры можно хранить в components/examples, ссылаться на внешние примеры и задавать отдельно для параметров, запросов и ответов.

13. Callbacks #

OpenAPI 3.0 позволяет описывать запросы, которые сервер позже отправит клиенту.

Например, клиент создаёт подписку:

POST /subscriptions

и передаёт адрес callback:

{
  "callback_url": "https://client.example.com/events"
}

Спецификация:

callbacks:
  eventCallback:
    "{$request.body#/callback_url}":
      post:
        requestBody:
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
        responses:
          "200":
            description: Callback accepted

Runtime-выражение:

$request.body#/callback_url

извлекает URL из исходного запроса.

Callbacks предназначены для описания связанных с операцией исходящих вызовов API-провайдера — например, webhook-подобных уведомлений.

Важно различать:

OpenAPI 3.0
→ callbacks внутри операции

OpenAPI 3.1
→ дополнительно появился верхнеуровневый webhooks

Объект links позволяет описывать связь между ответом одной операции и вызовом другой.

Например:

responses:
  "201":
    description: User created
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/User"
    links:
      GetCreatedUser:
        operationId: getUser
        parameters:
          user_id: "$response.body#/id"

Здесь значение id из ответа POST /users можно использовать для вызова:

GET /users/{user_id}

Links описывают не просто гиперссылку, а способ передать данные из одного вызова в следующий.

15. Улучшенная авторизация #

OpenAPI 3.0 переработал описание security schemes.

Раздел:

securityDefinitions

был заменён на:

components.securitySchemes

Появились и были улучшены:

  • общий тип http;

  • Bearer-аутентификация;

  • API key в cookies;

  • OpenID Connect Discovery;

  • несколько OAuth 2.0 flows в одной схеме;

  • более корректные названия OAuth-потоков.

Bearer JWT:

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

OpenID Connect:

components:
  securitySchemes:
    openId:
      type: openIdConnect
      openIdConnectUrl: https://example.com/.well-known/openid-configuration

16. Диапазоны кодов ответов #

Можно описывать сразу диапазон HTTP-статусов:

responses:
  "2XX":
    description: Successful response

  "4XX":
    description: Client error

  "5XX":
    description: Server error

Конкретный статус имеет приоритет над диапазоном:

responses:
  "200":
    description: Exact successful response

  "2XX":
    description: Other successful responses

Такие диапазоны поддерживаются как 1XX, 2XX, 3XX, 4XX и 5XX.

Основные изменения в одной схеме #

OpenAPI 2.0                 OpenAPI 3.0

host/basePath/schemes   →   servers
definitions             →   components.schemas
securityDefinitions     →   components.securitySchemes
body/formData            →   requestBody
consumes/produces        →   content
одна схема тела          →   схемы для разных media types
file                     →   string + binary
ограниченные модели      →   oneOf / anyOf / not
один example             →   именованные examples
нет callbacks            →   callbacks
нет links                →   links
ограниченная security    →   bearer, OIDC, OAuth flows

Итог #

Наиболее значимые возможности OpenAPI 3.0:

отдельный requestBody
несколько media types и схем для них
централизованный components
несколько серверов и server variables
oneOf / anyOf / not
nullable, readOnly и writeOnly
улучшенная работа с multipart и файлами
cookie-параметры
style и explode
несколько примеров
callbacks
links между операциями
Bearer и OpenID Connect

Главное архитектурное изменение — OpenAPI 3.0 стал значительно точнее описывать реальное содержимое HTTP-запросов и ответов, а также отношения между операциями API.


29. Какие линтеры для Python вы знаете? #

Основные линтеры Python #

Ruff #

Современный быстрый линтер, написанный на Rust. Поддерживает сотни правил и заменяет значительную часть функциональности Flake8 и его плагинов, Pyflakes, pycodestyle, isort, pyupgrade, pydocstyle и других инструментов. Умеет автоматически исправлять многие нарушения через --fix.

ruff check .
ruff check . --fix

Конфигурация в pyproject.toml:

[tool.ruff.lint]
select = [
    "E",    # pycodestyle errors
    "F",    # Pyflakes
    "I",    # сортировка импортов
    "B",    # flake8-bugbear
    "UP",   # pyupgrade
]

ignore = ["E501"]

Для большинства новых проектов Ruff — разумный основной выбор:

быстро работает
много правил
автоисправления
одна конфигурация
может заменить несколько отдельных инструментов

Сам Ruff также имеет отдельный форматтер:

ruff format .

Но линтинг и форматирование — разные операции.

Pylint #

Более глубокий и строгий анализатор:

pylint src/

Проверяет:

  • потенциальные ошибки;

  • неправильное использование объектов;

  • соглашения об именовании;

  • слишком сложные функции;

  • слишком большое количество аргументов;

  • недоступные атрибуты;

  • дублирование кода;

  • архитектурные признаки плохой структуры.

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

Пример:

def calculate(a, b, c, d, e, f, g, h):
    ...

Pylint может сообщить о слишком большом количестве аргументов.

Настройка:

[tool.pylint.main]
py-version = "3.13"

[tool.pylint.design]
max-args = 7
max-branches = 12

Flake8 #

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

flake8 src tests

Традиционно он объединяет проверки:

Pyflakes
→ потенциальные логические ошибки

pycodestyle
→ стиль PEP 8

McCabe
→ цикломатическая сложность

Его часто расширяют плагинами:

flake8-bugbear
flake8-builtins
flake8-comprehensions
flake8-simplify
flake8-annotations
pep8-naming

Пример конфигурации:

[flake8]
max-line-length = 88
max-complexity = 10
exclude =
    .git,
    .venv,
    migrations

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

Pyflakes #

Pyflakes ищет вероятные ошибки, почти не занимаясь оформлением кода:

import os  # импорт не используется

result = unknown_variable + 1

Типичные проверки:

  • неиспользуемые импорты;

  • неопределённые переменные;

  • повторное определение имён;

  • неправильное использование global;

  • переменные, назначенные, но не использованные.

Pyflakes входит в основу Flake8, а его правила также реализованы в Ruff под кодами F....

pycodestyle #

Проверяет часть соглашений PEP 8:

pycodestyle src/

Например:

  • отступы;

  • пробелы;

  • пустые строки;

  • длину строки;

  • расположение импортов.

Официальная документация подчёркивает, что pycodestyle проверяет только алгоритмически формализуемую часть рекомендаций PEP 8, а не весь стиль Python-кода.

Пример нарушения:

x=10

Предпочтительный вариант:

x = 10

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

Специализированные анализаторы #

Bandit #

Ищет потенциальные проблемы безопасности в Python-коде:

bandit -r src/

Например:

import subprocess

subprocess.run(user_input, shell=True)

Может обнаруживать:

  • shell=True;

  • небезопасные вызовы eval;

  • слабые криптографические алгоритмы;

  • небезопасное использование pickle;

  • захардкоженные пароли;

  • проблемные настройки SSL/TLS.

Bandit предназначен именно для security-oriented статического анализа и может подключаться к CI/CD.

pydocstyle #

Проверяет docstring по соглашениям PEP 257:

def create_user():
    pass

Может сообщить об отсутствии документации функции или неправильном формате docstring.

Многие его правила доступны в Ruff под префиксом:

D

Vulture #

Ищет предположительно мёртвый код:

неиспользуемые функции
неиспользуемые классы
неиспользуемые переменные
недостижимые участки

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

McCabe #

Измеряет цикломатическую сложность:

if
elif
for
while
except

Часто используется не отдельно, а через Flake8:

flake8 --max-complexity 10 src/

Или через Ruff:

[tool.ruff.lint.mccabe]
max-complexity = 10

Инструменты, которые часто ошибочно называют линтерами #

Black #

Black — прежде всего форматтер, а не линтер:

black .

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

isort #

Сортирует импорты:

isort .
import os
import sys

from fastapi import FastAPI

from app.models import User

Сейчас его функциональность также можно получить через Ruff:

ruff check . --select I --fix

mypy и Pyright #

Это прежде всего статические анализаторы типов:

mypy src/
pyright

Они проверяют:

user_id: int = "42"

Но не заменяют линтер общих ошибок и стиля.

pytest #

pytest запускает тесты, а не анализирует код как линтер.

Практические комбинации #

Для современного небольшого или среднего проекта:

Ruff
→ ошибки, стиль, импорты, упрощения

Pyright или mypy
→ типы

pytest
→ поведение

Bandit
→ дополнительные security-проверки

Команды:

ruff check .
ruff format --check .
pyright
pytest
bandit -r src/

Для проекта, где требуется более строгий анализ структуры:

Ruff
+ Pylint
+ Pyright/mypy
+ pytest

При этом правила Ruff и Pylint частично пересекаются, поэтому их нужно настроить так, чтобы CI не выдавал одинаковые предупреждения дважды.

Для старого проекта:

Flake8 + плагины
Black
isort
mypy

Переходить на Ruff необязательно, если текущая цепочка стабильна и уже настроена.

Пример настройки Ruff #

[tool.ruff]
target-version = "py313"
line-length = 88

[tool.ruff.lint]
select = [
    "E",
    "F",
    "W",
    "I",
    "B",
    "UP",
    "SIM",
    "C90",
]

ignore = [
    "E501",
]

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]

Запуск:

ruff check .
ruff check . --fix
ruff format .

Итог #

Ruff
→ основной универсальный современный линтер

Pylint
→ более глубокий и строгий анализ

Flake8
→ классическая расширяемая экосистема

Pyflakes
→ вероятные ошибки и неиспользуемые имена

pycodestyle
→ соблюдение части PEP 8

Bandit
→ безопасность

pydocstyle
→ docstring

Vulture
→ мёртвый код

McCabe
→ цикломатическая сложность

Для нового Python-backend чаще всего достаточно начать со связки:

Ruff + Pyright/mypy + pytest

и добавить Pylint или Bandit, когда их проверки действительно нужны проекту.


30. Какие библиотеки | инструменты используют для логирования в Python #

Сначала разделим уровни #

Под инструментами логирования могут подразумеваться разные вещи:

Python-приложение
→ создаёт log records

Форматтер
→ превращает их в текст или JSON

Коллектор
→ забирает логи с серверов и контейнеров

Хранилище
→ сохраняет и индексирует

Интерфейс
→ позволяет искать, фильтровать и строить графики

Поэтому logging, Fluent Bit и Grafana Loki решают разные части одной задачи.

logging — стандартная библиотека Python #

Основной встроенный инструмент:

import logging

logger = logging.getLogger(__name__)


def create_payment(payment_id: int) -> None:
    logger.info(
        "Payment created",
        extra={"payment_id": payment_id},
    )

В logging есть четыре основных компонента:

Logger
→ создаёт запись

Handler
→ отправляет запись в консоль, файл, syslog и т. д.

Formatter
→ определяет формат вывода

Filter
→ решает, какие записи пропускать

Стандартный модуль поддерживает иерархические логгеры и хорошо интегрируется с логами сторонних Python-библиотек. Для модулей рекомендуется создавать логгер через logging.getLogger(__name__).

Простая настройка:

import logging

logging.basicConfig(
    level=logging.INFO,
    format=(
        "%(asctime)s "
        "%(levelname)s "
        "%(name)s "
        "%(message)s"
    ),
)

logger = logging.getLogger(__name__)

logger.info("Application started")

Для большого приложения обычно применяют logging.config.dictConfig(), а не разбросанные по коду вызовы basicConfig().

Обработчики logging.handlers #

Стандартная библиотека содержит готовые способы доставки логов:

StreamHandler
→ stdout или stderr

FileHandler
→ файл

RotatingFileHandler
→ файл с ротацией по размеру

TimedRotatingFileHandler
→ ротация по времени

SysLogHandler
→ локальный или удалённый syslog

HTTPHandler
→ отправка по HTTP

QueueHandler
→ запись через очередь

SMTPHandler
→ отправка критичных записей по email

RotatingFileHandler и TimedRotatingFileHandler поддерживают ротацию файлов, а QueueHandler позволяет вынести непосредственную запись логов из рабочего потока приложения.

Пример ротации:

import logging
from logging.handlers import RotatingFileHandler

logger = logging.getLogger("app")
logger.setLevel(logging.INFO)

handler = RotatingFileHandler(
    "app.log",
    maxBytes=10 * 1024 * 1024,
    backupCount=5,
)

logger.addHandler(handler)

structlog #

structlog предназначен прежде всего для структурированного логирования.

Вместо неструктурированной строки:

User 42 created order 1001

записываются отдельные поля:

{
  "event": "order_created",
  "user_id": 42,
  "order_id": 1001
}

Пример:

import structlog

logger = structlog.get_logger()

logger.info(
    "order_created",
    user_id=42,
    order_id=1001,
    amount=150,
)

structlog поддерживает JSON, logfmt, удобный консольный вывод, contextual values и может работать поверх стандартного logging. ( structlog)

Он особенно удобен для:

  • FastAPI и микросервисов;

  • JSON-логов;

  • добавления request_id, user_id, trace_id;

  • централизованной обработки событий;

  • приложений с asyncio и contextvars.

Пример контекста:

logger = structlog.get_logger().bind(
    request_id="req-123",
    user_id=42,
)

logger.info("request_started")
logger.info("payment_created", payment_id=1001)

Все записи получат связанный контекст.

Loguru #

Loguru предоставляет готовый глобальный логгер и упрощает настройку:

from loguru import logger

logger.info("Application started")
logger.warning("Low disk space")
logger.exception("Request failed")

Добавление файла:

from loguru import logger

logger.add(
    "logs/app.log",
    rotation="100 MB",
    retention="14 days",
    compression="zip",
)

logger.info("Server started")

Loguru поддерживает sinks, ротацию, retention, сжатие, contextual values, красивый вывод исключений и совместимость со стандартным logging.

Он удобен для:

небольших приложений
CLI
скриптов
быстрого старта
проектов, где хочется меньше конфигурации

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

python-json-logger #

Это JSON-форматтер для стандартного logging.

import logging

from pythonjsonlogger.json import JsonFormatter

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())

logger.addHandler(handler)

logger.info(
    "payment_created",
    extra={
        "payment_id": 1001,
        "user_id": 42,
    },
)

Результат:

{
  "message": "payment_created",
  "payment_id": 1001,
  "user_id": 42
}

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

Это хороший вариант, когда не нужны все возможности structlog:

logging
+ python-json-logger
→ структурированные JSON-логи

OpenTelemetry #

OpenTelemetry используется, когда логи нужно связать с другими сигналами наблюдаемости:

logs
metrics
traces

В запись можно добавить:

trace_id
span_id
service.name
service.version
deployment.environment

Тогда ошибка в логе связывается с конкретным распределённым trace:

HTTP request
→ Order Service
→ Payment Service
→ PostgreSQL

OpenTelemetry Python предоставляет API, SDK, инструментацию и exporters для сбора и передачи логов, метрик и трассировок.

OpenTelemetry не обязательно заменяет logging или structlog. Частая схема:

logging / structlog
→ OpenTelemetry integration
→ OTLP
→ OpenTelemetry Collector
→ Loki / Elastic / облачная система

Sentry #

Sentry используют главным образом для:

  • сбора необработанных исключений;

  • группировки одинаковых ошибок;

  • stack trace;

  • контекста пользователя и запроса;

  • уведомлений об ошибках;

  • связи ошибок с производительностью и логами.

Sentry SDK может интегрироваться со стандартным logging и перехватывать записи заданных уровней.

import logging

import sentry_sdk

sentry_sdk.init(
    dsn="...",
    enable_logs=True,
)

logger = logging.getLogger(__name__)

logger.error(
    "Payment provider unavailable",
    extra={"provider": "bank"},
)

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

Коллекторы логов #

Python-приложение обычно не должно самостоятельно отправлять каждую запись напрямую в Loki или Elasticsearch.

Чаще применяется:

Python
→ stdout или файл
→ collector
→ централизованное хранилище

Fluent Bit #

Лёгкий коллектор и процессор логов:

читает stdout, файлы, journald
→ парсит и обогащает записи
→ отправляет их в Loki, Elastic, OpenTelemetry и другие системы

Fluent Bit может собирать, обрабатывать и перенаправлять логи, включая отправку через OTLP.

Filebeat #

Агент экосистемы Elastic. Он следит за файлами и другими источниками логов, собирает события и отправляет их в Elasticsearch или Logstash.

Logstash #

Более тяжёлый процессор данных с pipeline:

input
→ filters
→ output

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

Хранилища и интерфейсы #

Grafana Loki + Grafana #

Fluent Bit / Alloy / OpenTelemetry Collector
→ Loki
→ Grafana

Loki хранит логовые потоки и индексирует главным образом их labels. Для запросов используется LogQL, а Grafana предоставляет поиск, Explore и dashboards.

Часто выбирается, если уже используется:

Prometheus
Grafana
OpenTelemetry

Elastic Stack #

Типичная цепочка:

Filebeat
→ Logstash
→ Elasticsearch
→ Kibana

Elasticsearch хранит и индексирует события, а Kibana позволяет искать и анализировать логи. Filebeat может отправлять записи непосредственно в Elasticsearch либо через Logstash.

Что выбрать для FastAPI #

Разумная production-схема:

FastAPI
→ logging или structlog
→ JSON в stdout
→ Fluent Bit / OpenTelemetry Collector
→ Loki + Grafana

Дополнительно:

Sentry
→ исключения и error tracking

OpenTelemetry
→ trace_id и связь логов с трассировкой

Практический выбор:

СценарийИнструменты
Небольшой скриптlogging или Loguru
Обычный Python-backendlogging
JSON без сложной инфраструктурыlogging + python-json-logger
Структурированные логи с контекстомstructlog
Простая удобная настройка файловLoguru
Ошибки и stack tracesSentry
Связь логов с tracesOpenTelemetry
Сбор контейнерных логовFluent Bit
Elastic-инфраструктураFilebeat + Logstash + Elasticsearch + Kibana
Grafana-инфраструктураFluent Bit/Alloy + Loki + Grafana

Итог #

На уровне Python чаще всего используют:

logging
structlog
Loguru
python-json-logger
OpenTelemetry SDK
Sentry SDK

На уровне инфраструктуры:

Fluent Bit
Filebeat
Logstash
OpenTelemetry Collector
Loki + Grafana
Elasticsearch + Kibana

Для большинства backend-проектов хорошая начальная архитектура:

стандартный logging
+ структурированный JSON
+ stdout
+ централизованный collector
+ Loki или Elastic
+ Sentry для исключений


31. Как организовать сбор и передачу логов приложения? #

Общая архитектура #

Для backend-приложения логирование обычно организуют как отдельный pipeline:

Приложение
→ формирует структурированные события
→ пишет их в stdout/stderr
→ агент собирает логи
→ парсит, дополняет и буферизует
→ передаёт в централизованное хранилище
→ Grafana/Kibana предоставляет поиск и визуализацию

Например:

FastAPI
→ JSON в stdout
→ Fluent Bit
→ Loki
→ Grafana

или:

FastAPI
→ logging / OpenTelemetry
→ OpenTelemetry Collector
→ Elasticsearch, Loki или облачный backend

OpenTelemetry Collector предназначен именно для приёма, обработки и экспорта логов, метрик и трассировок, а Fluent Bit строит pipeline из входов, фильтров, буферизации и выходов.

1. Формирование логов в приложении #

В Python основой обычно служит стандартный logging:

import logging

logger = logging.getLogger(__name__)


def create_payment(payment_id: int, user_id: int) -> None:
    logger.info(
        "payment_created",
        extra={
            "payment_id": payment_id,
            "user_id": user_id,
        },
    )

Каждый модуль получает логгер с именем модуля:

logger = logging.getLogger(__name__)

Централизованная конфигурация выполняется один раз при запуске приложения, обычно через logging.config.dictConfig().

2. Использовать структурированные логи #

Для production лучше писать не произвольные строки:

Payment 145 created by user 42

а JSON:

{
  "timestamp": "2026-07-31T17:45:10.128Z",
  "level": "INFO",
  "service": "payment-service",
  "environment": "production",
  "event": "payment_created",
  "payment_id": 145,
  "user_id": 42,
  "request_id": "req-9f12",
  "trace_id": "b9f270..."
}

Такой лог проще:

  • фильтровать по полям;

  • агрегировать;

  • передавать между системами;

  • использовать для алертов;

  • связывать с трассировкой.

Формировать JSON можно через:

logging + python-json-logger
structlog
OpenTelemetry logging integration

3. Определить общую схему события #

Все сервисы должны использовать согласованный набор основных полей:

timestamp
level
service
service_version
environment
event
message
request_id
trace_id
span_id
user_id
duration_ms
http_method
http_route
http_status_code
error.type
error.message

При этом не каждое поле присутствует в каждом событии.

Пример успешного HTTP-запроса:

{
  "event": "http_request_completed",
  "service": "notification-service",
  "request_id": "req-123",
  "method": "POST",
  "route": "/notifications",
  "status_code": 201,
  "duration_ms": 34
}

Пример ошибки:

{
  "event": "notification_creation_failed",
  "level": "ERROR",
  "request_id": "req-123",
  "user_id": 42,
  "error_type": "DatabaseError",
  "message": "Could not create notification"
}

4. Добавлять контекст запроса #

Все записи, созданные при обработке одного запроса, желательно связывать через:

request_id
trace_id
user_id
HTTP-запрос
request_id=req-123
    ├── проверка пользователя
    ├── SQL-запрос
    ├── публикация события
    └── HTTP-ответ

В стандартном logging контекст можно добавлять через LoggerAdapter, фильтр или фабрику LogRecord. Python Logging Cookbook отдельно описывает LoggerAdapter как средство добавления контекстных полей к записям.

Для асинхронного FastAPI-приложения удобно хранить контекст запроса в contextvars.

Упрощённый middleware:

from contextvars import ContextVar
from uuid import uuid4

from fastapi import FastAPI, Request

request_id_var: ContextVar[str | None] = ContextVar(
    "request_id",
    default=None,
)

app = FastAPI()


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = request.headers.get(
        "X-Request-ID",
        str(uuid4()),
    )

    token = request_id_var.set(request_id)

    try:
        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        return response
    finally:
        request_id_var.reset(token)

Далее formatter или filter автоматически добавляет request_id ко всем записям текущего запроса.

5. Куда писать логи #

Приложение в Docker или Kubernetes #

Предпочтительная схема:

приложение
→ stdout/stderr
→ контейнерная платформа
→ агент логирования

Docker автоматически захватывает вывод контейнера в stdout и stderr; команда docker logs читает именно эти потоки. Docker также поддерживает разные logging drivers.

Пример handler:

"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "formatter": "json",
        "stream": "ext://sys.stdout",
    },
}

Не стоит внутри контейнера писать каждый сервис в собственный файл без явной причины:

/app/logs/service.log

Это усложняет:

  • ротацию;

  • доступ из нескольких реплик;

  • сбор после пересоздания контейнера;

  • конфигурацию volume;

  • работу в Kubernetes.

Обычный сервер без контейнеров #

Можно писать в файл:

/var/log/my-service/app.log

Но необходимо настроить:

  • ротацию;

  • ограничение размера;

  • число резервных файлов;

  • сжатие;

  • права доступа;

  • сбор агентом.

В Python доступны RotatingFileHandler и TimedRotatingFileHandler, а для неблокирующей обработки записей — QueueHandler и QueueListener.

6. Не отправлять каждый лог непосредственно из бизнес-кода #

Плохая схема:

def create_payment():
    requests.post(
        "https://logging.example.com/logs",
        json={"event": "payment_created"},
    )

Проблемы:

  • логирование увеличивает задержку запроса;

  • недоступность хранилища может сломать бизнес-операцию;

  • на каждый лог создаётся сетевой запрос;

  • появляется сильная зависимость приложения от конкретного backend;

  • сложно организовать batch, retries и buffering.

Лучше:

бизнес-код
→ локальный logging
→ stdout или локальная очередь
→ внешний collector
→ backend

7. Исключить блокирующую запись из рабочего потока #

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

рабочий поток
→ QueueHandler
→ очередь
→ QueueListener
→ реальный handler
import logging
import queue
from logging.handlers import QueueHandler, QueueListener

log_queue: queue.Queue[logging.LogRecord] = queue.Queue(
    maxsize=10_000,
)

console_handler = logging.StreamHandler()

queue_handler = QueueHandler(log_queue)
listener = QueueListener(
    log_queue,
    console_handler,
    respect_handler_level=True,
)

root_logger = logging.getLogger()
root_logger.addHandler(queue_handler)
root_logger.setLevel(logging.INFO)

listener.start()

QueueHandler помещает LogRecord в очередь, а QueueListener обрабатывает записи в отдельном потоке.

При этом нужно заранее определить политику переполнения очереди:

ждать
отбрасывать DEBUG/INFO
сохранять ERROR отдельно
увеличивать локальный буфер

Бесконечная очередь опасна неконтролируемым потреблением памяти.

8. Сбор логов агентом #

Агент запускается рядом с приложением:

на каждой VM
в каждом Kubernetes node
как sidecar
как DaemonSet

Он выполняет:

input
→ parsing
→ filtering
→ enrichment
→ buffering
→ routing
→ output

Например, Fluent Bit может читать контейнерные логи, разбирать JSON, добавлять метаданные и отправлять результат в Loki, Elasticsearch или другой backend. Его официальная модель pipeline состоит из inputs, filters и outputs, а processors позволяют преобразовывать и дополнять события.

Условная конфигурация Fluent Bit:

service:
  flush: 1
  log_level: info

pipeline:
  inputs:
    - name: tail
      path: /var/lib/docker/containers/*/*.log
      tag: app.*

  filters:
    - name: parser
      match: app.*
      key_name: log
      parser: json
      reserve_data: true

    - name: modify
      match: app.*
      add:
        collector: fluent-bit

  outputs:
    - name: loki
      match: app.*
      host: loki
      port: 3100

Конкретный input зависит от среды: Docker, systemd, Kubernetes или обычные файлы.

9. Буферизация и повторная отправка #

Центральное хранилище может временно быть недоступно:

приложение работает
Loki/Elasticsearch недоступен
сеть нестабильна

Поэтому collector должен поддерживать:

  • batching;

  • retries;

  • ограниченную очередь;

  • backpressure;

  • локальный disk buffer;

  • лимиты памяти;

  • dead-letter или отдельный маршрут для проблемных данных.

Fluent Bit поддерживает memory- и filesystem-буферизацию; файловый буфер помогает пережить backpressure и ограничить использование оперативной памяти.

Принцип:

backend недоступен
→ события временно сохраняются на диске
→ collector повторяет отправку
→ после восстановления связи backlog передаётся

Буфер должен быть ограниченным. Иначе при долгом отказе хранилища collector может заполнить весь диск.

10. Agent и gateway #

Для небольшой системы достаточно:

приложения
→ один collector
→ backend

Для нескольких серверов и кластеров применяется двухуровневая схема:

Приложение
→ локальный agent
→ центральный gateway
→ одно или несколько хранилищ
Node 1: приложения → Agent ┐
Node 2: приложения → Agent ├→ Gateway → Loki
Node 3: приложения → Agent ┘          → Elastic
                                      → S3/archive

В модели OpenTelemetry локальные agents находятся рядом с приложениями, а gateways централизуют более тяжёлую обработку и могут масштабироваться независимо.

Gateway может:

  • фильтровать события;

  • удалять чувствительные поля;

  • выполнять batching;

  • маршрутизировать разные сервисы;

  • отправлять копии в несколько систем;

  • централизованно применять правила.

11. Выбор хранилища #

Распространённые варианты:

Loki + Grafana
Elasticsearch + Kibana
OpenSearch + OpenSearch Dashboards
облачные системы логирования

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

Но структуру логов нужно проектировать под выбранное хранилище.

Особенность Loki #

В labels стоит помещать только поля с относительно низкой кардинальностью:

service
environment
cluster
namespace

Не следует использовать как labels:

request_id
trace_id
user_id
order_id
IP
timestamp

Каждое уникальное сочетание labels создаёт отдельный log stream; высокая кардинальность ухудшает ingestion и запросы. Grafana рекомендует оставлять labels низкокардинальными, а идентификаторы запроса, пользователя и trace хранить в теле события или structured metadata.

Например:

{
  "service": "payment-service",
  "environment": "production",
  "request_id": "req-928134",
  "user_id": 837261,
  "event": "payment_created"
}

В Loki labels:

service=payment-service
environment=production

А request_id и user_id остаются полями записи.

12. Уровни логирования #

Нужно определить единые правила:

DEBUG
→ детали для диагностики и разработки

INFO
→ нормальные значимые бизнес- и технические события

WARNING
→ подозрительная ситуация, но операция продолжается

ERROR
→ операция завершилась ошибкой

CRITICAL
→ сервис или важная подсистема не может работать

Не стоит писать ERROR для ожидаемого ответа:

пользователь ввёл неправильный пароль
запрашиваемый объект не найден
валидация запроса вернула 422

Иначе алерты и статистика ошибок превратятся в шум.

13. Что именно логировать #

Полезно логировать:

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

Например:

logger.info(
    "payment_status_changed",
    extra={
        "payment_id": payment.id,
        "old_status": old_status,
        "new_status": payment.status,
    },
)

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

14. Что нельзя логировать #

Нельзя записывать в обычные логи:

пароли
JWT и refresh tokens
session cookies
Authorization header
приватные ключи
полные данные банковских карт
секреты API
строки подключения с паролями
полные тела чувствительных запросов

Поля должны маскироваться до передачи collector:

{
  "email": "a***@example.com",
  "card_number": "**** **** **** 1234",
  "authorization": "[REDACTED]"
}

Редактирование лучше выполнять в двух местах:

приложение
→ не создаёт опасные поля

collector
→ дополнительная защитная фильтрация

15. Логи HTTP-запросов #

Разумная запись доступа:

{
  "event": "http_request_completed",
  "method": "POST",
  "route": "/payments/{payment_id}",
  "status_code": 200,
  "duration_ms": 48,
  "request_id": "req-123"
}

Лучше хранить шаблон маршрута:

/payments/{payment_id}

а не каждый фактический URL:

/payments/874512

Это упрощает агрегацию и уменьшает количество уникальных значений.

Не нужно без необходимости логировать полное тело каждого запроса и ответа. Это создаёт:

  • большие объёмы данных;

  • риск утечки персональных данных;

  • дополнительную нагрузку;

  • сложность соблюдения retention policy.

16. Исключения #

Для исключения нужен stack trace:

try:
    await payment_gateway.charge(payment)
except Exception:
    logger.exception(
        "payment_charge_failed",
        extra={"payment_id": payment.id},
    )
    raise

logger.exception() автоматически добавляет информацию о текущем исключении.

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

repository записал ERROR
service записал ERROR
endpoint записал ERROR
global handler записал ERROR

Иначе одно исключение превращается в четыре одинаковых события.

Обычно ошибка логируется там, где:

  • она была обработана;

  • к ней добавляется значимый контекст;

  • либо в глобальном обработчике, если осталась необработанной.

17. Retention и управление объёмом #

Для логов нужно заранее определить:

сколько дней они хранятся
какие уровни сохраняются
какие сервисы требуют более долгого хранения
что архивируется
когда данные удаляются

Например:

DEBUG → не собирается в production
INFO  → 14 дней
ERROR → 90 дней
audit → 1 год

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

Объём можно уменьшать через:

  • отключение DEBUG;

  • sampling частых однотипных событий;

  • агрегацию;

  • исключение health-check запросов;

  • ограничение stack trace;

  • ограничение размера одного события;

  • удаление ненужных полей.

18. Мониторинг самого pipeline #

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

размер очереди
число retry
число отброшенных событий
объём буфера на диске
ошибки парсинга
задержка доставки
заполнение диска
доступность backend

Иначе может возникнуть ситуация:

приложение работает
логи не поступают уже два часа
никто этого не заметил

Для collector следует создать отдельные алерты:

dropped_logs > 0
buffer_usage > 80%
export_errors растут
нет логов от критичного сервиса

Практическая схема для FastAPI и Docker Compose #

FastAPI
  │ JSON stdout
Docker logging driver
Fluent Bit
  │ parsing, metadata, buffering, retries
Loki
Grafana

В приложении:

logging или structlog
JSON formatter
request_id
trace_id
service
environment

В Fluent Bit:

чтение контейнерных логов
парсинг JSON
добавление container/service metadata
фильтрация секретов
filesystem buffering
отправка в Loki

В Loki:

labels:
service
environment
host

поля внутри JSON:
request_id
trace_id
user_id
payment_id
event
duration_ms

Итоговая рекомендация #

Для обычного Python-backend разумно начать с такой архитектуры:

1. logging или structlog
2. единый JSON-формат
3. request_id и trace_id
4. вывод в stdout
5. Fluent Bit или OpenTelemetry Collector
6. локальная буферизация и retries
7. Loki + Grafana либо Elastic
8. отдельный Sentry для исключений
9. маскирование секретов
10. retention и мониторинг доставки

Ключевой принцип:

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

Сбор, буферизацию, повторную передачу и маршрутизацию
выполняет отдельный collector.


32. Какие инструменты мониторинга применяют для Python-приложений? #

Основные направления мониторинга #

Для Python-приложения обычно собирают несколько типов данных:

Метрики
→ сколько запросов, ошибок, задач, подключений

Логи
→ что конкретно произошло

Трассировки
→ через какие сервисы и операции прошёл запрос

Ошибки
→ исключения, stack trace, контекст

Профилирование
→ где расходуются CPU и память

Инфраструктурные показатели
→ CPU, RAM, диск, сеть, контейнеры, БД

Один инструмент редко закрывает всё. Обычно используют несколько взаимосвязанных компонентов.

Prometheus #

Prometheus применяется для сбора и хранения числовых метрик во временных рядах.

Python-приложение инструментируется клиентской библиотекой prometheus-client и предоставляет endpoint наподобие:

GET /metrics

Prometheus периодически опрашивает этот endpoint. Клиентские библиотеки поддерживают основные типы метрик: Counter, Gauge, Histogram и Summary.

Пример:

from prometheus_client import Counter, Histogram

REQUEST_COUNT = Counter(
    "http_requests_total",
    "Total number of HTTP requests",
    ["method", "route", "status"],
)

REQUEST_DURATION = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    ["method", "route"],
)

Использование:

from time import perf_counter


def process_request() -> None:
    started_at = perf_counter()

    try:
        # Обработка запроса
        REQUEST_COUNT.labels(
            method="POST",
            route="/payments",
            status="201",
        ).inc()
    finally:
        REQUEST_DURATION.labels(
            method="POST",
            route="/payments",
        ).observe(perf_counter() - started_at)

Обычно отслеживают:

http_requests_total
http_request_duration_seconds
http_requests_in_progress
http_errors_total

database_connections_active
database_query_duration_seconds

celery_tasks_total
celery_task_duration_seconds
celery_queue_size

business_operations_total
payments_failed_total

Grafana #

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

Типичная схема:

Python
→ Prometheus
→ Grafana

На dashboard можно вывести:

RPS
p50 / p95 / p99 latency
долю ответов 5xx
количество активных запросов
размер очереди Celery
использование пула соединений
CPU и память

Alertmanager #

Alertmanager принимает срабатывания правил от Prometheus, группирует их, подавляет дубликаты и направляет уведомления соответствующим получателям.

Например:

groups:
  - name: backend
    rules:
      - alert: HighErrorRate
        expr: |
          rate(http_requests_total{status=~"5.."}[5m])
          /
          rate(http_requests_total[5m])
          > 0.05
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "High HTTP error rate"

Pipeline:

Prometheus обнаружил проблему
→ создал alert
→ Alertmanager получил alert
→ сгруппировал уведомления
→ отправил их ответственному получателю

OpenTelemetry #

OpenTelemetry предоставляет API, SDK и инструментацию для формирования:

traces
metrics
logs

Python SDK можно использовать для ручной или автоматической инструментации приложения. Телеметрия обычно отправляется по OTLP в OpenTelemetry Collector, который обрабатывает и экспортирует её в выбранный backend.

Архитектура:

FastAPI
PostgreSQL
Redis
HTTPX
Celery
OpenTelemetry instrumentation
    │ OTLP
OpenTelemetry Collector
    ├── Prometheus / Mimir
    ├── Tempo / Jaeger
    ├── Loki
    └── облачный APM

Особенно полезны распределённые трассировки:

POST /orders
    ├── Order Service: 20 ms
    ├── PostgreSQL: 15 ms
    ├── Payment Service: 450 ms
    │       └── внешний банк: 420 ms
    └── RabbitMQ publish: 8 ms

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

Jaeger и Grafana Tempo #

Для хранения и анализа распределённых трассировок используют:

Jaeger
Grafana Tempo
Zipkin

OpenTelemetry может экспортировать данные в эти и другие совместимые backends. Grafana Tempo представляет собой специализированный backend распределённой трассировки и интегрируется с Grafana.

Типичная Grafana-система:

Prometheus или Mimir
→ метрики

Loki
→ логи

Tempo
→ трассировки

Grafana
→ общий интерфейс

Связь через trace_id позволяет перейти из ошибки на графике к трассировке, а затем к связанным логам.

Sentry #

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

Он собирает:

необработанные исключения
stack trace
тип и текст ошибки
контекст запроса
версию приложения
информацию о пользователе
breadcrumbs
performance traces

Sentry группирует похожие ошибки, чтобы тысяча одинаковых исключений не выглядела как тысяча независимых проблем. Python SDK поддерживает error monitoring и performance monitoring.

Пример:

import sentry_sdk

sentry_sdk.init(
    dsn="...",
    traces_sample_rate=0.1,
)

Sentry не обязательно заменяет Prometheus:

Prometheus
→ агрегированные числовые показатели

Sentry
→ конкретные исключения и проблемные транзакции

Они часто используются вместе.

Готовые APM-системы #

APM — Application Performance Monitoring. Такие системы обычно объединяют:

метрики
трассировки
ошибки
логи
service map
алерты
dashboards

Распространённые варианты:

Elastic APM #

Python Agent собирает данные о производительности приложения и ошибках и отправляет их в Elastic APM. Поддерживается автоматическая инструментация распространённых Python-фреймворков и клиентских библиотек. (

Схема:

Python Agent
→ APM Server
→ Elasticsearch
→ Kibana

Datadog APM #

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

Python + ddtrace
→ Datadog Agent
→ Datadog APM

New Relic #

Python Agent New Relic отслеживает производительность приложения и поддерживает автоматическую и ручную инструментацию Python-кода и распространённых библиотек.

Готовый APM удобен, когда не хочется самостоятельно собирать стек из Prometheus, Grafana, Tempo, Loki и Alertmanager. Недостатки — стоимость, зависимость от поставщика и меньший контроль над хранением данных.

Мониторинг логов #

Для централизованной работы с логами применяют:

Grafana Loki + Grafana
Elasticsearch + Kibana
OpenSearch

В случае Loki индексируются главным образом метаданные и labels логовых потоков, а сами записи хранятся сжатым потоком.

Типичная схема:

Python JSON logs
→ stdout
→ Fluent Bit / Grafana Alloy / OpenTelemetry Collector
→ Loki
→ Grafana

Логи желательно связывать с остальными сигналами:

{
  "event": "payment_failed",
  "service": "payment-service",
  "request_id": "req-123",
  "trace_id": "abc923...",
  "payment_id": 145
}

Мониторинг сервера и инфраструктуры #

Python-метрики показывают состояние приложения, но не показывают полностью состояние машины.

Для Linux-сервера обычно используется Node Exporter, который предоставляет метрики CPU, памяти, файловых систем, дисков и сети.

Node Exporter
→ сервер

Prometheus Python client
→ приложение

PostgreSQL exporter
→ PostgreSQL

Redis exporter
→ Redis

RabbitMQ metrics
→ RabbitMQ

Prometheus поддерживает модель exporters для систем, которые сами не предоставляют метрики в нужном формате.

Для внешней проверки доступности endpoint применяют Blackbox Exporter:

можно ли установить TCP-соединение
отвечает ли HTTPS endpoint
не истёк ли сертификат
сколько занимает внешний запрос

Blackbox Exporter работает как отдельный probe-сервис, который проверяет заданные targets.

Профилирование #

Мониторинг отвечает:

Когда сервис стал медленным?

Профилировщик отвечает:

На выполнении каких функций тратится время?

cProfile #

Встроенный детерминированный профилировщик Python:

python -m cProfile -o profile.data app.py

Он показывает число вызовов функций и время, затраченное на их выполнение.

py-spy #

Sampling-профилировщик, который может подключаться к уже работающему Python-процессу без изменения его кода и строить flame graph.

py-spy top --pid 1234
py-spy record --pid 1234 -o profile.svg

Scalene #

Профилировщик CPU, памяти и GPU для Python. Он помогает отличить время, проведённое непосредственно в Python-коде, от работы нативного кода и анализировать выделение памяти.

Профилировщики не стоит путать с постоянным мониторингом: их обычно применяют для расследования уже обнаруженной проблемы или как часть continuous profiling.

Health checks #

Приложение обычно предоставляет несколько проверок:

/health/live
→ процесс запущен и event loop работает

/health/ready
→ приложение готово принимать запросы

/health/startup
→ инициализация завершена

В readiness-проверке могут учитываться критичные зависимости:

загружена конфигурация
создан пул БД
запущены необходимые фоновые компоненты

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

Практический стек для FastAPI #

Для самостоятельной инфраструктуры:

FastAPI
├── prometheus-client
├── OpenTelemetry SDK
├── JSON logging
└── Sentry SDK
OpenTelemetry Collector / Fluent Bit
        ├── Prometheus → Grafana
        ├── Tempo → Grafana
        ├── Loki → Grafana
        └── Sentry

Отдельно:

Node Exporter
PostgreSQL exporter
Redis exporter
RabbitMQ metrics
Blackbox Exporter
Alertmanager

Минимальный набор для небольшого проекта:

Prometheus
+ prometheus-client
+ Grafana
+ Alertmanager

Для распределённой системы:

Prometheus + Grafana
→ метрики

OpenTelemetry + Tempo/Jaeger
→ трассировки

Loki
→ логи

Sentry
→ исключения

Node Exporter и exporters
→ инфраструктура

Какие метрики важнее всего #

Для HTTP-сервиса полезна модель RED:

Rate
→ количество запросов

Errors
→ количество и доля ошибок

Duration
→ время выполнения

Для ресурсов — модель USE:

Utilization
→ насколько ресурс загружен

Saturation
→ есть ли очередь или нехватка ресурса

Errors
→ ошибки ресурса

Практически стоит начать с:

RPS
p50 / p95 / p99 latency
доля 5xx
число запросов в работе
размер пула БД
длительность SQL-запросов
размер очереди задач
число неуспешных задач
CPU
RAM
диск
число перезапусков процесса

Главная рабочая связка для Python-backend — Prometheus для метрик, Grafana для визуализации, Alertmanager для уведомлений и OpenTelemetry для распределённых трассировок. Sentry добавляется для удобного расследования конкретных исключений, а exporters — для наблюдения за инфраструктурой.


33. Как добавлять метки к метрикам в Prometheus? #

Что такое метки #

Метки (labels) — это дополнительные измерения метрики. Они позволяют хранить одну метрику для разных методов, маршрутов, статусов и других категорий.

Без меток:

http_requests_total 1532

С метками:

http_requests_total{method="GET",route="/users",status="200"} 1200
http_requests_total{method="POST",route="/users",status="201"} 250
http_requests_total{method="GET",route="/users",status="500"} 82

Каждое уникальное сочетание имени метрики и значений меток создаёт отдельный временной ряд.

Добавление меток в Python #

При создании метрики передаётся список названий меток:

from prometheus_client import Counter

HTTP_REQUESTS = Counter(
    "http_requests_total",
    "Total number of HTTP requests",
    ["method", "route", "status"],
)

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

HTTP_REQUESTS.labels(
    method="GET",
    route="/users",
    status="200",
).inc()

Можно передавать значения позиционно:

HTTP_REQUESTS.labels(
    "GET",
    "/users",
    "200",
).inc()

Но именованные аргументы обычно понятнее и безопаснее: порядок меток не приходится держать в голове. Python-клиент Prometheus поддерживает оба варианта.

Метки у разных типов метрик #

Метки одинаково добавляются к Counter, Gauge, Histogram и Summary.

Counter #

from prometheus_client import Counter

PAYMENTS = Counter(
    "payments_total",
    "Number of processed payments",
    ["status", "currency"],
)

PAYMENTS.labels(
    status="success",
    currency="AZN",
).inc()

PAYMENTS.labels(
    status="failed",
    currency="AZN",
).inc()

Gauge #

from prometheus_client import Gauge

ACTIVE_TASKS = Gauge(
    "active_tasks",
    "Number of currently active tasks",
    ["queue"],
)

ACTIVE_TASKS.labels(queue="email").inc()

try:
    process_email()
finally:
    ACTIVE_TASKS.labels(queue="email").dec()

Histogram #

from prometheus_client import Histogram

REQUEST_DURATION = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    ["method", "route"],
)

with REQUEST_DURATION.labels(
    method="POST",
    route="/payments",
).time():
    create_payment()

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

Использование в FastAPI #

Упрощённый middleware:

from time import perf_counter

from fastapi import FastAPI, Request
from prometheus_client import Counter, Histogram

app = FastAPI()

HTTP_REQUESTS = Counter(
    "http_requests_total",
    "Total number of HTTP requests",
    ["method", "route", "status"],
)

HTTP_DURATION = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    ["method", "route"],
)


@app.middleware("http")
async def collect_metrics(request: Request, call_next):
    started_at = perf_counter()
    status_code = 500

    try:
        response = await call_next(request)
        status_code = response.status_code
        return response
    finally:
        route_object = request.scope.get("route")
        route = getattr(route_object, "path", "unmatched")

        labels = {
            "method": request.method,
            "route": route,
        }

        HTTP_REQUESTS.labels(
            **labels,
            status=str(status_code),
        ).inc()

        HTTP_DURATION.labels(**labels).observe(
            perf_counter() - started_at
        )

Важно использовать шаблон маршрута:

/payments/{payment_id}

а не фактический URL:

/payments/938271
/payments/938272
/payments/938273

Иначе каждый уникальный payment_id создаст новое значение метки и отдельный временной ряд.

Предварительная инициализация меток #

Метрика с метками не создаёт ряд до первого вызова .labels(), потому что библиотека заранее не знает возможные значения меток. Можно проинициализировать ожидаемые комбинации без изменения значения:

HTTP_REQUESTS.labels(
    method="GET",
    route="/users",
    status="200",
)

HTTP_REQUESTS.labels(
    method="POST",
    route="/users",
    status="201",
)

Для счётчика это позволит экспортировать нулевые значения ещё до первого запроса:

http_requests_total{method="GET",route="/users",status="200"} 0

Это бывает полезно, чтобы запросы и панели Grafana не получали отсутствующий ряд до первого события.

Подходящие метки #

Хорошие метки имеют ограниченное и предсказуемое количество значений:

method:
GET, POST, PUT, DELETE

status:
200, 201, 400, 404, 500

route:
/users
/users/{user_id}
/payments/{payment_id}

environment:
development, staging, production

result:
success, failed, rejected

payment_provider:
bank_a, bank_b, bank_c

Например:

PAYMENT_OPERATIONS = Counter(
    "payment_operations_total",
    "Number of payment operations",
    ["operation", "result", "provider"],
)

PAYMENT_OPERATIONS.labels(
    operation="withdrawal",
    result="success",
    provider="bank_a",
).inc()

Опасные метки #

Не стоит использовать значения с практически неограниченным количеством вариантов:

user_id
request_id
trace_id
payment_id
order_id
email
IP-адрес
полный URL
текст исключения
timestamp

Плохой пример:

REQUESTS.labels(
    user_id=str(user.id),
    request_id=request_id,
).inc()

При миллионе пользователей потенциально появится миллион временных рядов только для одной комбинации меток.

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

Детальные идентификаторы лучше помещать в:

логи
распределённые трассировки
exemplars
аналитическое хранилище

Как рассчитывается кардинальность #

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

Пусть метрика содержит:

method: 5 значений
route: 50 значений
status: 10 значений
instance: 4 значения

Потенциальное число рядов:

5 × 50 × 10 × 4 = 10 000

Для Histogram с десятью buckets число внутренних рядов будет ещё больше, поскольку для каждой комбинации меток создаются bucket-ряды, _sum и _count.

Запросы по меткам в PromQL #

Выбрать только успешные GET-запросы:

http_requests_total{
  method="GET",
  status="200"
}

Посчитать RPS по маршрутам:

sum by (route) (
  rate(http_requests_total[5m])
)

Доля ответов 5xx:

sum(
  rate(http_requests_total{status=~"5.."}[5m])
)
/
sum(
  rate(http_requests_total[5m])
)

Группировка по методу и маршруту:

sum by (method, route) (
  rate(http_requests_total[5m])
)

PromQL позволяет фильтровать временные ряды по точным значениям и регулярным выражениям меток.

Метки приложения и метки Prometheus #

Часть меток добавляет само приложение:

method
route
status
result

Другие могут добавляться конфигурацией сбора Prometheus:

scrape_configs:
  - job_name: backend
    static_configs:
      - targets:
          - app:8000
        labels:
          environment: production
          region: baku

Prometheus также добавляет метки job и instance при сборе target.

Поэтому необязательно прописывать в каждой Python-метрике данные, которые уже известны инфраструктуре:

instance
hostname
cluster
region
environment

Их часто удобнее добавлять на уровне scrape-конфигурации, Kubernetes service discovery или collector.

Изменение набора меток #

Набор названий меток фиксируется при создании метрики:

REQUESTS = Counter(
    "http_requests_total",
    "Total requests",
    ["method", "route"],
)

После этого для каждого вызова необходимо передавать значения обеих меток:

REQUESTS.labels(
    method="GET",
    route="/users",
).inc()

Такой вызов некорректен:

REQUESTS.labels(
    method="GET",
).inc()

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

Counter(
    "http_requests_total",
    "Requests",
    ["method"],
)

Counter(
    "http_requests_total",
    "Requests",
    ["method", "route"],
)

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

Практическое правило #

Для HTTP-метрик обычно достаточно:

["method", "route", "status"]

Для бизнес-операций:

["operation", "result"]

Для Celery:

["task_name", "status", "queue"]

Для внешних запросов:

["service", "operation", "status"]

При выборе каждой метки полезно задать вопрос:

Сколько уникальных значений может появиться
у этой метки за неделю или месяц?

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


34. Что такое шифрование и ключ шифрования? #

Что такое шифрование #

Шифрование — это преобразование исходных данных в нечитаемый вид с помощью алгоритма и ключа.

Открытый текст
→ алгоритм шифрования + ключ
→ шифротекст

Например:

Исходные данные:
"Перевести 100 AZN"

После шифрования:
"8F A2 91 3C ..."

Чтобы восстановить данные, выполняется расшифрование:

Шифротекст
→ алгоритм расшифрования + правильный ключ
→ исходные данные

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

Что такое ключ шифрования #

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

Ключ можно представить как очень большое случайное число:

algorithm(data, key) → encrypted_data

Сам алгоритм обычно не является секретным. Безопасность должна зависеть от секретности ключа, а не от сокрытия алгоритма.

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

Данные + ключ A → один шифротекст
Данные + ключ B → другой шифротекст

Почему нельзя расшифровать без ключа #

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

Например, ключ длиной 256 бит имеет:

2²⁵⁶ возможных значений

Поэтому важны:

  • достаточная длина ключа;

  • криптографически безопасная случайная генерация;

  • отсутствие утечек;

  • правильное хранение;

  • периодическая ротация ключей.

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

Симметричное шифрование #

При симметричном шифровании один и тот же секретный ключ используется для шифрования и расшифрования:

Данные
→ секретный ключ
→ шифротекст

Шифротекст
→ тот же секретный ключ
→ данные

Пример алгоритма:

AES

Симметричное шифрование:

  • быстро работает;

  • подходит для больших объёмов данных;

  • используется для файлов, дисков, резервных копий, трафика и данных в БД;

  • требует безопасно передать секретный ключ второй стороне.

Пример на уровне идеи:

encrypted = encrypt(data, secret_key)
decrypted = decrypt(encrypted, secret_key)

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

Асимметричное шифрование #

При асимметричной криптографии используется пара связанных ключей:

публичный ключ
приватный ключ

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

Упрощённо:

Данные шифруются публичным ключом
→ расшифровать можно приватным ключом

Например, Алиса публикует свой открытый ключ:

Боб
→ шифрует сообщение публичным ключом Алисы
→ Алиса расшифровывает своим приватным ключом

К асимметричным алгоритмам относятся, например:

RSA
эллиптическая криптография

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

Гибридное шифрование #

В реальных системах часто комбинируют оба подхода:

1. Создаётся случайный симметричный ключ
2. Данные шифруются этим ключом
3. Сам ключ защищается асимметричной криптографией

Так работает большая часть защищённых сетевых протоколов:

асимметричная криптография
→ безопасное согласование секрета

симметричная криптография
→ быстрое шифрование трафика

Например, HTTPS использует TLS, где стороны договариваются о сеансовых ключах, а затем шифруют трафик быстрыми симметричными алгоритмами.

Шифрование не гарантирует целостность автоматически #

Шифрование отвечает на вопрос:

Может ли посторонний прочитать данные?

Но отдельно нужно контролировать:

Не были ли данные изменены?
Кто их отправил?

Для этого используют аутентифицированное шифрование, например:

AES-GCM
ChaCha20-Poly1305

Оно одновременно обеспечивает:

конфиденциальность
+ проверку целостности
+ проверку подлинности шифротекста

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

Что такое nonce и IV #

Кроме ключа, алгоритму часто требуется nonce или IV — дополнительное значение, используемое при шифровании.

шифротекст = encrypt(data, key, nonce)

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

encrypt("hello", key, nonce_1) → шифротекст A
encrypt("hello", key, nonce_2) → шифротекст B

Nonce обычно не является секретным и может храниться рядом с шифротекстом. Но для многих режимов критически важно не использовать одно и то же значение повторно с одним ключом.

ключ
→ секретный

nonce / IV
→ обычно публичный, но должен использоваться правильно

Пароль и ключ — не одно и то же #

Пароль обычно имеет низкую энтропию:

my_password_123

Криптографический ключ должен выглядеть как случайный набор байтов.

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

пароль
+ salt
+ KDF
→ криптографический ключ

Примеры KDF:

Argon2
scrypt
PBKDF2

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

Шифрование и хеширование #

Это разные операции.

Шифрование #

Обратимо при наличии ключа:

данные → шифротекст → исходные данные

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

документы
токены
резервные копии
сетевой трафик

Хеширование #

Обычно необратимо:

данные → хеш

Используется для проверки целостности и хранения паролей.

Пароли пользователей обычно не шифруют, а хешируют специальными алгоритмами:

Argon2
bcrypt
scrypt

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

Шифрование и кодирование #

Кодирование не обеспечивает секретность:

Base64
URL encoding
UTF-8

Например:

secret → c2VjcmV0

Это Base64, но любой может сразу декодировать значение обратно.

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

шифрование
→ защита данных с помощью ключа

Где хранить ключи #

Ключ нельзя хранить:

  • прямо в исходном коде;

  • в Git-репозитории;

  • в Docker-образе;

  • в логах;

  • рядом с зашифрованными данными без дополнительной защиты.

Обычно используют:

переменные окружения
Docker/Kubernetes Secrets
HashiCorp Vault
AWS KMS
Google Cloud KMS
Azure Key Vault
аппаратные HSM

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

Система управления ключами обычно предоставляет:

  • генерацию;

  • хранение;

  • разграничение доступа;

  • аудит;

  • ротацию;

  • отзыв;

  • уничтожение ключей.

Компрометация ключа #

Если злоумышленник получил ключ, он может расшифровать все данные, защищённые этим ключом.

Поэтому применяют:

разные ключи для разных окружений
разные ключи для разных задач
ограничение прав доступа
ротацию
версионирование
аудит использования

Например:

key_version = 3

При ротации новые данные шифруются новым ключом, а старые ключи некоторое время сохраняются только для расшифрования существующих данных.

Пример практической структуры #

Обычно рядом с шифротекстом хранят:

{
  "ciphertext": "...",
  "nonce": "...",
  "key_version": 3,
  "algorithm": "AES-256-GCM"
}

При этом сам секретный ключ хранится отдельно в KMS или Vault.

Процесс расшифрования:

1. Прочитать key_version
2. Получить нужный ключ из защищённого хранилища
3. Передать ключ, nonce и шифротекст алгоритму
4. Проверить аутентификационный тег
5. Получить исходные данные

Итог #

Шифрование
→ преобразует читаемые данные в защищённый шифротекст

Ключ шифрования
→ значение, от которого зависит возможность зашифровать
  или расшифровать данные

Основные варианты:

Симметричное:
один секретный ключ
быстрое шифрование данных

Асимметричное:
публичный и приватный ключи
обмен секретами и цифровые подписи

Гибридное:
асимметричная защита ключа
+ симметричное шифрование данных

Безопасность зависит не только от алгоритма, но и от правильной генерации, хранения, применения и ротации ключей.


35. Отличия симметричного и ассиметричного шифрования #

Основное отличие #

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

Данные + секретный ключ
→ шифротекст

Шифротекст + тот же ключ
→ исходные данные

Асимметричное шифрование использует пару математически связанных ключей:

публичный ключ
приватный ключ

Данные, зашифрованные публичным ключом, расшифровываются соответствующим приватным ключом.

Сравнение #

ХарактеристикаСимметричноеАсимметричное
Количество ключейОдин общий ключПубличный и приватный ключи
СекретностьОбщий ключ должен быть секретнымСекретным является только приватный ключ
СкоростьВысокаяЗначительно медленнее
Большие объёмы данныхПодходитОбычно не используется напрямую
Передача ключаНужно безопасно передать общий ключПубличный ключ можно распространять открыто
Основное применениеШифрование файлов, дисков, трафикаОбмен ключами, подписи, аутентификация
ПримерыAES, ChaCha20RSA, криптография на эллиптических кривых

Симметричное шифрование #

Алиса и Боб заранее имеют общий секретный ключ:

Секретный ключ: K

Алиса шифрует сообщение:

encrypt(message, K) → ciphertext

Боб расшифровывает его тем же ключом:

decrypt(ciphertext, K) → message

Преимущества:

  • высокая скорость;

  • подходит для больших файлов и сетевого трафика;

  • сравнительно небольшая вычислительная нагрузка.

Главная проблема — безопасная передача общего ключа.

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

Асимметричное шифрование #

Получатель создаёт пару ключей:

Public key
→ можно публиковать

Private key
→ должен оставаться секретным

Боб шифрует сообщение публичным ключом Алисы:

encrypt(message, AlicePublicKey)
→ ciphertext

Расшифровать его может только Алиса:

decrypt(ciphertext, AlicePrivateKey)
→ message

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

Преимущества:

  • не требуется заранее передавать общий секрет;

  • публичный ключ можно открыто распространять;

  • поддерживает цифровые подписи и аутентификацию.

Недостатки:

  • медленнее симметричного шифрования;

  • требует больше вычислительных ресурсов;

  • не подходит для прямого шифрования больших объёмов данных;

  • подлинность публичного ключа всё равно необходимо проверять.

Цифровая подпись #

Асимметричная криптография используется не только для шифрования.

При цифровой подписи направление применения ключей другое:

Владелец подписывает приватным ключом
→ остальные проверяют публичным ключом

Подпись подтверждает:

  • кто создал подпись;

  • что данные не были изменены;

  • что подписывающая сторона владела приватным ключом.

Шифрование
→ обеспечивает конфиденциальность

Цифровая подпись
→ обеспечивает подлинность и целостность

Почему HTTPS использует оба подхода #

В реальных системах применяется гибридная криптография:

1. Асимметричная криптография
   → аутентификация и согласование общего секрета

2. Симметричная криптография
   → шифрование основного трафика

Упрощённо для TLS:

Клиент ↔ сервер
→ безопасно согласовали сеансовые ключи
→ дальше шифруют HTTP-трафик быстрым симметричным алгоритмом

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

Пример с файлом #

Предположим, нужно передать большой архив.

Неэффективный подход:

Зашифровать весь архив RSA

Практический подход:

1. Сгенерировать случайный AES-ключ
2. Зашифровать архив через AES
3. Защитить AES-ключ публичным ключом получателя
4. Передать зашифрованный архив и защищённый ключ

Получатель:

1. Расшифровывает AES-ключ приватным ключом
2. Расшифровывает архив полученным AES-ключом

Так сочетаются безопасность асимметричного подхода и скорость симметричного.

Что произойдёт при утечке ключей #

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

При асимметричном:

Утечка публичного ключа
→ нормальна, он предназначен для распространения

Утечка приватного ключа
→ критическая компрометация

Компрометация приватного ключа может позволить злоумышленнику:

  • расшифровывать предназначенные владельцу данные;

  • выдавать себя за владельца;

  • создавать действительные цифровые подписи.

Конкретные последствия зависят от назначения ключа и используемого протокола.

Важный нюанс про эллиптические кривые #

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

обмена ключами
→ ECDH

цифровой подписи
→ ECDSA, EdDSA

После согласования общего секрета данные обычно шифруются симметричным алгоритмом.

Итог #

Симметричное шифрование:
один общий секретный ключ
быстрое
подходит для больших данных
сложно безопасно передать ключ

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

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


36. Какой ключ хранится в TLS/SSL-сертификате? #

Какой ключ находится в TLS-сертификате #

В TLS/SSL-сертификате хранится публичный ключ сервера.

TLS-сертификат:
→ публичный ключ
→ доменное имя
→ данные владельца
→ срок действия
→ данные центра сертификации
→ цифровая подпись CA

Соответствующий приватный ключ в сертификат не входит и должен храниться отдельно на сервере.

Сертификат — можно передавать клиентам
Приватный ключ — нельзя раскрывать

Как это работает #

У сервера есть пара ключей:

Публичный ключ
→ находится в сертификате

Приватный ключ
→ хранится на сервере

Когда клиент подключается по HTTPS:

  1. Сервер отправляет сертификат клиенту.

  2. Клиент извлекает публичный ключ.

  3. Проверяет подпись центра сертификации, домен и срок действия.

  4. Сервер доказывает владение соответствующим приватным ключом.

  5. Стороны согласовывают симметричные сеансовые ключи.

  6. Дальнейший трафик шифруется симметрично.

Что делает публичный ключ в современном TLS #

В TLS 1.3 публичный ключ сертификата обычно используется для проверки цифровой подписи сервера.

Сервер подписывает часть TLS-handshake приватным ключом:

данные handshake
→ подпись приватным ключом сервера

Клиент проверяет подпись публичным ключом из сертификата:

подпись + публичный ключ
→ подтверждение подлинности сервера

Сам HTTP-трафик публичным ключом сертификата не шифруется. Для трафика создаются временные симметричные ключи.

Как было в старых версиях TLS #

В старых наборах шифров с RSA key exchange клиент мог шифровать общий секрет публичным RSA-ключом из сертификата.

premaster secret
→ шифрование публичным RSA-ключом
→ расшифрование приватным ключом сервера

В TLS 1.3 такой механизм удалён. Современные соединения обычно используют временный обмен ключами ECDHE, обеспечивающий forward secrecy.

Сертификат и приватный ключ в файлах #

Типичная конфигурация сервера:

certificate.pem
→ сертификат с публичным ключом

private-key.pem
→ приватный ключ

Например, в Nginx:

ssl_certificate     /etc/ssl/certificate.pem;
ssl_certificate_key /etc/ssl/private-key.pem;

Файл сертификата можно передавать другим сторонам. Файл приватного ключа должен быть доступен только серверу.

Итог #

В TLS/SSL-сертификате:
→ публичный ключ

Отдельно на сервере:
→ приватный ключ

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


37. Какие метрики обычно собирают в backend-приложении? #

Основные группы метрик #

В backend-приложении обычно собирают метрики нескольких уровней:

HTTP / API
бизнес-операции
база данных
кеш
очереди и фоновые задачи
внешние сервисы
процесс приложения
инфраструктура

Главное — собирать не всё подряд, а метрики, которые помогают отвечать на вопросы:

Сервис доступен?
Он отвечает быстро?
Ошибок стало больше?
Где возникло узкое место?
Бизнес-операции выполняются?

HTTP-метрики #

Для API чаще всего используют модель RED:

Rate
→ количество запросов

Errors
→ количество и доля ошибок

Duration
→ время обработки

Количество запросов #

http_requests_total

Полезные метки:

method
route
status

Пример:

http_requests_total{
    method="POST",
    route="/payments",
    status="201"
}

Важно использовать шаблон маршрута:

/payments/{payment_id}

а не фактический URL:

/payments/817263

Иначе появится слишком много временных рядов.

Время ответа #

http_request_duration_seconds

Обычно используется Histogram, чтобы рассчитывать:

p50
p90
p95
p99

Среднее время ответа само по себе недостаточно. Например, среднее может быть 100 мс, хотя 1% запросов выполняются по 10 секунд.

Активные запросы #

http_requests_in_progress

Показывает, сколько запросов сейчас обрабатывается.

Резкий рост может означать:

  • замедление БД;

  • зависание внешнего API;

  • нехватку worker-ов;

  • исчерпание пула соединений.

Размер запросов и ответов #

http_request_size_bytes
http_response_size_bytes

Полезно для endpoint-ов загрузки файлов, экспорта данных и больших JSON-ответов.

Метрики ошибок #

Общий счётчик:

application_errors_total

Метки:

error_type
operation
component

Например:

application_errors_total{
    error_type="TimeoutError",
    component="payment_provider"
}

Но нельзя помещать в метку полный текст исключения:

error_message="Connection failed for user 18372..."

Тексты ошибок имеют высокую кардинальность. Их лучше хранить в логах.

Полезно отдельно считать:

http_5xx_total
validation_errors_total
database_errors_total
external_service_errors_total
unhandled_exceptions_total

Бизнес-метрики #

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

Например:

payments_total
orders_created_total
notifications_sent_total
registrations_total
files_uploaded_total

Метки должны описывать ограниченные категории:

status="success|failed|rejected"
provider="bank_a|bank_b"
type="email|sms|push"

Пример:

payments_total{
    status="success",
    currency="AZN"
}

Также полезны метрики длительности:

payment_processing_duration_seconds
order_processing_duration_seconds
notification_delivery_duration_seconds

И текущего состояния:

pending_payments
active_subscriptions
unprocessed_notifications

Бизнес-метрики часто быстрее технических показывают реальную проблему:

HTTP 200 продолжает возвращаться,
но количество успешных платежей упало до нуля.

Метрики базы данных #

Пул соединений #

db_pool_connections_active
db_pool_connections_idle
db_pool_connections_max
db_pool_waiting_requests

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

Длительность запросов #

db_query_duration_seconds

Полезные метки:

operation="select|insert|update|delete"
table="payments"

Не стоит помещать в метки полный SQL-запрос.

Количество запросов #

db_queries_total
db_query_errors_total
db_transactions_total
db_transaction_rollbacks_total

Прикладные признаки проблем #

db_connection_timeouts_total
db_deadlocks_total
db_lock_wait_duration_seconds

Часть таких метрик собирает приложение, часть — PostgreSQL exporter или сама СУБД.

Метрики Redis и кеша #

Основные метрики кеша:

cache_requests_total
cache_hits_total
cache_misses_total
cache_errors_total
cache_operation_duration_seconds

Ключевой показатель:

cache_hit_ratio =
cache_hits / (cache_hits + cache_misses)

Дополнительно:

redis_connections_active
redis_memory_usage_bytes
redis_evicted_keys_total
redis_expired_keys_total

Если hit rate резко падает, нагрузка может перейти на базу данных.

Метрики очередей и брокеров #

Для RabbitMQ, Kafka, Celery и других систем важны:

queue_size
messages_published_total
messages_consumed_total
messages_failed_total
messages_retried_total
dead_letter_messages_total

Особенно важен возраст самого старого сообщения:

oldest_message_age_seconds

Размер очереди может быть большим из-за обычного всплеска. Но если старое сообщение лежит часами, consumer явно не справляется.

Также собирают:

message_processing_duration_seconds
consumer_lag
active_consumers
unacknowledged_messages

Метрики Celery-задач #

celery_tasks_started_total
celery_tasks_succeeded_total
celery_tasks_failed_total
celery_tasks_retried_total
celery_task_duration_seconds

Метки:

task_name
queue
status

Полезные Gauge:

celery_tasks_active
celery_tasks_pending
celery_workers_online

Не стоит использовать task_id как label — почти каждое значение будет уникальным.

Метрики внешних сервисов #

Для каждого внешнего API полезно собирать:

external_requests_total
external_request_duration_seconds
external_request_errors_total
external_request_timeouts_total
external_request_retries_total

Метки:

service="payment_gateway"
operation="charge"
status="success|error|timeout"

Пример:

external_request_duration_seconds{
    service="bank_api",
    operation="create_payment"
}

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

Метрики retry и circuit breaker #

retry_attempts_total
retry_exhausted_total
circuit_breaker_state
circuit_breaker_open_total

Состояние circuit breaker можно представить как Gauge:

0 → closed
1 → open
2 → half-open

Или использовать отдельную метку состояния.

Метрики процесса Python #

Обычно собираются автоматически:

process_cpu_seconds_total
process_resident_memory_bytes
process_virtual_memory_bytes
process_open_fds
process_start_time_seconds

Полезно также отслеживать:

process_threads
gc_collections_total
gc_collected_objects_total

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

Метрики event loop #

Для асинхронного приложения важны:

event_loop_lag_seconds
active_async_tasks
thread_pool_queue_size

event_loop_lag показывает, насколько поздно event loop смог выполнить запланированную операцию.

Большой lag может означать:

  • блокирующий код внутри async def;

  • CPU-bound вычисления;

  • синхронный файловый или сетевой I/O;

  • слишком долгую обработку callback;

  • перегрузку процесса.

Метрики worker-ов #

Для Gunicorn/Uvicorn:

workers_total
workers_alive
worker_restarts_total
worker_request_count
worker_busy

Если число активных запросов близко к суммарной вместимости worker-ов, новые запросы начинают ждать.

Инфраструктурные метрики #

Эти метрики обычно собирает не Python-приложение, а exporter или контейнерная платформа:

CPU utilization
RAM usage
disk usage
disk I/O
network traffic
network errors
container restarts
OOM kills
filesystem inode usage

Для контейнеров:

container_cpu_usage_seconds_total
container_memory_working_set_bytes
container_network_receive_bytes_total
container_network_transmit_bytes_total

Для сервера:

node_cpu_seconds_total
node_memory_available_bytes
node_filesystem_avail_bytes
node_load1

Health-метрики #

Проверки доступности можно выражать через:

service_up
dependency_up

Пример:

dependency_up{dependency="postgres"} 1
dependency_up{dependency="redis"} 0

Где:

1 → доступно
0 → недоступно

Но не нужно делать тяжёлый запрос к каждой зависимости на каждом /metrics scrape.

Метрики безопасности #

В зависимости от проекта:

authentication_failures_total
authorization_denied_total
rate_limit_rejections_total
suspicious_requests_total
invalid_tokens_total

Метки должны быть категориальными:

reason="expired|invalid_signature|missing"

Нельзя добавлять:

token
email
user_id
IP

если это создаёт высокую кардинальность или раскрывает чувствительные данные.

Метрики лимитов и насыщения #

Нужно наблюдать не только за нагрузкой, но и за приближением к пределу:

db_pool_usage_ratio
worker_utilization
queue_capacity_usage
thread_pool_usage
disk_usage_ratio

Пример:

db_pool_usage_ratio =
active_connections / max_connections

Такие метрики помогают обнаружить проблему до полного отказа.

Counter, Gauge и Histogram #

Counter #

Значение только увеличивается:

http_requests_total
payments_failed_total
tasks_completed_total

После перезапуска процесса обнуляется.

Gauge #

Может увеличиваться и уменьшаться:

active_requests
queue_size
memory_usage_bytes
db_connections_active

Histogram #

Хранит распределение наблюдений:

http_request_duration_seconds
db_query_duration_seconds
response_size_bytes

Используется для процентилей и анализа распределения.

Минимальный набор для HTTP-backend #

Для начала обычно достаточно:

http_requests_total
http_request_duration_seconds
http_requests_in_progress
http_errors_total

process_cpu_seconds_total
process_resident_memory_bytes

db_pool_connections_active
db_pool_waiting_requests
db_query_duration_seconds

external_request_duration_seconds
external_request_errors_total

business_operations_total

Для Celery или брокера добавляются:

queue_size
oldest_message_age_seconds
tasks_succeeded_total
tasks_failed_total
task_duration_seconds

Какие метки использовать #

Для HTTP:

method
route
status

Для бизнес-операций:

operation
result
type
provider

Для БД:

operation
table
result

Для внешнего API:

service
operation
status

Не использовать как labels:

request_id
trace_id
user_id
payment_id
order_id
email
полный URL
текст ошибки
timestamp

Такие данные должны идти в логи или трассировки.

Практический итог #

Для backend-приложения основные метрики можно свести к пяти вопросам:

1. Сколько операций выполняется?
   → counters

2. Сколько операций завершается ошибкой?
   → error counters и error rate

3. Сколько они выполняются?
   → histograms и percentiles

4. Сколько работы сейчас ожидает обработки?
   → queue size, active requests, pool waiting

5. Не заканчиваются ли ресурсы?
   → CPU, RAM, соединения, worker-ы, диск

Хороший базовый набор — RPS, error rate, p95/p99 latency, активные запросы, состояние пула БД, очереди, внешние зависимости и ключевые бизнес-операции.


38. Что такое Apache Airflow #

Что такое Apache Airflow #

Apache Airflow — платформа для описания, планирования и контроля выполнения связанных задач.

Проще говоря, Airflow позволяет задать workflow:

получить данные
→ проверить их
→ преобразовать
→ загрузить в хранилище
→ сформировать отчёт

После этого Airflow:

  • запускает задачи по расписанию или вручную;

  • учитывает зависимости между ними;

  • повторяет неудачные задачи;

  • хранит историю запусков;

  • показывает состояние workflow через веб-интерфейс;

  • отправляет уведомления об ошибках.

Airflow особенно часто применяется для ETL/ELT, аналитических pipeline, периодических расчётов и оркестрации инфраструктурных операций.

DAG #

Workflow в Airflow называется DAG — Directed Acyclic Graph, направленный ациклический граф.

extract
transform
load

Направленный означает, что зависимости имеют направление:

extract → transform

Ациклический означает, что нельзя создать замкнутую зависимость:

A → B → C → A

Иначе невозможно определить, какая задача должна запускаться первой.

Пример DAG:

from datetime import datetime

from airflow import DAG
from airflow.operators.python import PythonOperator


def extract() -> None:
    print("Extracting data")


def transform() -> None:
    print("Transforming data")


def load() -> None:
    print("Loading data")


with DAG(
    dag_id="example_pipeline",
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:
    extract_task = PythonOperator(
        task_id="extract",
        python_callable=extract,
    )

    transform_task = PythonOperator(
        task_id="transform",
        python_callable=transform,
    )

    load_task = PythonOperator(
        task_id="load",
        python_callable=load,
    )

    extract_task >> transform_task >> load_task

Зависимость:

extract_task >> transform_task >> load_task

означает:

сначала extract
потом transform
потом load

Основные сущности #

DAG #

Описание всего workflow:

какие задачи существуют
как они связаны
по какому расписанию запускаются

Task #

Конкретный шаг workflow:

запустить Python-функцию
выполнить SQL
отправить HTTP-запрос
запустить контейнер
выполнить Bash-команду

Operator #

Шаблон того, какую работу выполняет задача.

Например:

PythonOperator
→ вызывает Python-функцию

BashOperator
→ запускает shell-команду

SQL-операторы
→ выполняют запросы к БД

DockerOperator
→ запускает контейнер

Task instance #

Конкретный запуск задачи в рамках определённого запуска DAG.

Например:

DAG: daily_report
дата запуска: 2026-07-31
задача: load_data

Это отдельный task instance со своим состоянием, логами и числом попыток.

DAG run #

Один полный запуск DAG.

Один и тот же DAG может иметь множество запусков:

daily_report — 29 июля
daily_report — 30 июля
daily_report — 31 июля

Основные компоненты Airflow #

DAG-файлы
Scheduler
Executor
Workers

Scheduler #

Проверяет:

  • какие DAG нужно запустить;

  • какие зависимости уже выполнены;

  • какие задачи готовы к запуску;

  • какие задачи нужно повторить.

Scheduler не обязательно сам выполняет бизнес-код задачи. Он в основном принимает решения о запуске.

Executor #

Определяет, каким способом задачи будут выполняться.

Например:

локально
в отдельных процессах
на Celery workers
в Kubernetes pods

Worker #

Непосредственно выполняет задачу.

Например:

worker получил задачу
→ запустил Python-код
→ записал результат и статус

Metadata Database #

Airflow хранит служебное состояние в реляционной БД:

  • DAG runs;

  • task instances;

  • статусы задач;

  • расписания;

  • retry;

  • служебные настройки.

Это не основное хранилище данных вашего pipeline. Большие наборы данных обычно хранятся во внешних системах.

Webserver #

Предоставляет веб-интерфейс, где можно:

  • посмотреть DAG;

  • запустить его вручную;

  • увидеть граф зависимостей;

  • проверить статусы задач;

  • открыть логи;

  • повторно запустить отдельную задачу;

  • посмотреть историю запусков.

Состояния задачи #

Задача может находиться в состояниях:

scheduled
queued
running
success
failed
up_for_retry
skipped
upstream_failed

Например:

extract → success
transform → failed
load → upstream_failed

load не запускалась, потому что зависела от неуспешной transform.

Повторные попытки #

Airflow умеет автоматически повторять неудачные задачи:

from datetime import timedelta

with DAG(
    ...,
    default_args={
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
    },
):
    ...

Логика:

задача упала
→ подождать 5 минут
→ повторить
→ максимум 3 попытки

Задачи желательно делать идемпотентными, чтобы повторный запуск не создавал дубликаты и не повреждал данные.

Расписание #

DAG можно запускать:

каждый час
каждый день
по cron-расписанию
вручную
через API
после появления определённых данных

Примеры:

schedule="@daily"
schedule="0 3 * * *"

Второй вариант означает запуск ежедневно около 03:00 в соответствии с настроенной временной зоной.

Что такое backfill и catchup #

Предположим, DAG должен выполняться ежедневно, но был выключен пять дней.

При включённом catchup Airflow может создать пропущенные запуски:

27 июля
28 июля
29 июля
30 июля
31 июля

Это полезно для исторической обработки данных.

catchup=False

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

Передача данных между задачами #

Airflow поддерживает XCom — механизм обмена небольшими значениями между задачами.

Например:

extract
→ передал ID созданного файла
→ transform получил этот ID

XCom подходит для:

  • идентификаторов;

  • путей к файлам;

  • небольших метаданных;

  • статусов;

  • коротких результатов.

Не следует передавать через XCom большие DataFrame, файлы или массивы на сотни мегабайт.

Лучше:

задача сохранила данные в S3 / MinIO / БД
→ через XCom передала только путь или ключ объекта

TaskFlow API #

Airflow позволяет описывать Python-задачи через декораторы:

from airflow.decorators import dag, task
from datetime import datetime


@dag(
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,
)
def payment_report():
    @task
    def extract() -> list[int]:
        return [100, 200, 300]

    @task
    def calculate_total(values: list[int]) -> int:
        return sum(values)

    @task
    def save(total: int) -> None:
        print(f"Total: {total}")

    values = extract()
    total = calculate_total(values)
    save(total)


payment_report()

Здесь зависимости формируются из вызовов задач:

extract
→ calculate_total
→ save

Это удобнее для небольших Python-oriented pipeline.

Где применяют Airflow #

ETL/ELT #

PostgreSQL
→ извлечение
→ очистка
→ преобразование
→ ClickHouse / BigQuery / Data Warehouse

Периодические отчёты #

получить данные
→ рассчитать показатели
→ сформировать файл
→ отправить отчёт

Машинное обучение #

получить датасет
→ подготовить признаки
→ обучить модель
→ проверить метрики
→ опубликовать модель

Data quality #

проверить наличие данных
→ проверить количество строк
→ проверить ограничения
→ отправить уведомление

Оркестрация сервисов #

запустить job
→ дождаться завершения
→ проверить результат
→ запустить следующий этап

Airflow и Celery #

Airflow и Celery решают пересекающиеся, но разные задачи.

Celery #

Очередь фоновых задач:

приложение отправило задачу
→ broker
→ worker выполнил её

Подходит для:

  • отправки email;

  • генерации файлов;

  • обработки пользовательских запросов;

  • коротких фоновых операций;

  • событийных задач.

Airflow #

Оркестратор workflow:

задача A
→ после успеха задача B и C
→ после них задача D
→ по расписанию
→ с историей и повторными запусками

Подходит для:

  • сложных зависимостей;

  • длительных pipeline;

  • расписаний;

  • backfill;

  • контроля каждого этапа.

Airflow может использовать Celery как механизм выполнения задач, но это не делает их одним и тем же инструментом.

Airflow и брокер сообщений #

Airflow не является заменой RabbitMQ или Kafka.

RabbitMQ / Kafka
→ транспорт и доставка сообщений

Airflow
→ управление порядком и расписанием выполнения workflow

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

Для чего Airflow подходит плохо #

Airflow обычно не используют как основной механизм для:

  • обработки каждого HTTP-запроса;

  • real-time pipeline с миллисекундной задержкой;

  • бесконечно работающих consumer-ов;

  • передачи больших данных между задачами;

  • простого запуска одной фоновой функции;

  • хранения бизнес-данных.

Плохой пример:

пользователь отправил HTTP-запрос
→ создать отдельный DAG
→ ждать его завершения

Для этого обычно подходят Celery, брокер сообщений или обычный backend worker.

Важный принцип #

Airflow оркестрирует работу, но тяжёлые вычисления желательно выполнять вне самого Airflow-процесса:

Airflow task
→ запускает Spark job
→ запускает Kubernetes pod
→ вызывает внешний сервис
→ выполняет SQL в хранилище

То есть Airflow отвечает за:

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

а специализированная система выполняет тяжёлую работу.

Итог #

Apache Airflow
→ платформа оркестрации workflow

DAG
→ граф зависимых задач

Scheduler
→ решает, что и когда запускать

Executor и workers
→ выполняют задачи

Metadata DB
→ хранит состояния и историю

Web UI
→ показывает и позволяет управлять pipeline

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


39. Где хранятся DAG-и (схемы) в Airflow? #

Где физически хранятся DAG-и #

DAG-и в Airflow обычно хранятся как Python-файлы:

dags/
├── payments_pipeline.py
├── daily_report.py
└── cleanup.py

Путь к каталогу задаётся конфигурацией Airflow. В классической установке это параметр dags_folder, например:

[core]
dags_folder = /opt/airflow/dags

Airflow периодически читает Python-файлы из этого источника, выполняет их и извлекает созданные объекты DAG.

Что хранится в Metadata Database #

После разбора Airflow сериализует структуру DAG в JSON-представление и сохраняет её в Metadata Database.

Python-файл DAG
→ DAG Processor
→ разбор Python-кода
→ сериализованная структура DAG
→ Metadata Database

В базе хранятся:

  • сериализованное представление DAG;

  • версии DAG;

  • состояния задач;

  • DAG Run;

  • Task Instance;

  • расписания;

  • история запусков;

  • retry и служебные данные.

Но база данных не является основным хранилищем исходного Python-кода DAG. Источником определения остаётся файл или другой настроенный источник DAG.

Airflow 3: DAG Bundles #

В Airflow 3 используется более общее понятие DAG Bundle — набор DAG-файлов и связанных ресурсов.

Источником bundle может быть:

локальная директория
Git-репозиторий
внешняя система
пользовательский источник

По умолчанию используется локальная директория, но можно настроить несколько bundle и получать DAG-и, например, из Git.

В Docker #

Часто каталог с DAG подключают как volume:

services:
  airflow-dag-processor:
    volumes:
      - ./dags:/opt/airflow/dags

Структура проекта:

project/
├── dags/
│   ├── payments.py
│   └── reports.py
├── plugins/
├── logs/
└── docker-compose.yml

В production DAG-и часто доставляются через:

Git repository
→ CI/CD
→ общий volume / контейнерный образ / DAG Bundle
→ Airflow DAG Processor

В распределённой установке #

Компоненты, которым необходимо исполнить конкретную версию DAG, должны иметь доступ к соответствующим файлам или bundle:

DAG Processor
→ читает и разбирает DAG

Metadata DB
→ хранит сериализованную структуру и состояние

Workers
→ получают доступ к нужной версии DAG-кода для выполнения задачи

Поэтому в Kubernetes или Celery-инсталляции DAG-и доставляют через:

  • общий сетевой volume;

  • синхронизацию с Git;

  • включение DAG-файлов в Docker-образ;

  • DAG Bundles.

Итог #

Исходный DAG:
→ Python-файл в dags_folder или DAG Bundle

Разобранная структура DAG:
→ Metadata Database

Состояния и история выполнения:
→ Metadata Database

Логи задач:
→ файлы, object storage или система логирования

То есть DAG в Airflow одновременно существует в двух формах: исходный Python-код хранится в каталоге или bundle, а сериализованное представление и состояние выполнения — в Metadata Database.


40. Какие шаблонизаторы вы использовали? #

Что такое шаблонизатор #

Шаблонизатор берёт текстовый шаблон и данные, после чего формирует готовый документ:

Шаблон + контекст → готовый HTML / XML / текст / конфигурация

Например:

<h1>Здравствуйте, {{ username }}</h1>

При контексте:

{"username": "Alex"}

получится:

<h1>Здравствуйте, Alex</h1>

В Python чаще всего используются следующие шаблонизаторы.

Jinja #

Jinja — наиболее универсальный и часто применяемый Python-шаблонизатор. Он может генерировать не только HTML, но и XML, CSV, LaTeX, конфигурационные файлы и любой другой текстовый формат. Поддерживает наследование шаблонов, включения, макросы, фильтры, автоматическое экранирование HTML и асинхронный рендеринг.

Пример:

<!-- templates/users.html -->

{% extends "base.html" %}

{% block content %}
    <h1>Пользователи</h1>

    <ul>
        {% for user in users %}
            <li>{{ user.username }}</li>
        {% else %}
            <li>Пользователей нет</li>
        {% endfor %}
    </ul>
{% endblock %}

Python:

from jinja2 import Environment, FileSystemLoader, select_autoescape

environment = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=select_autoescape(["html", "xml"]),
)

template = environment.get_template("users.html")

html = template.render(
    users=[
        {"username": "alex"},
        {"username": "maria"},
    ],
)

Используется с:

FastAPI
Flask
Starlette
Django при отдельной настройке
CLI-утилитами
генераторами конфигураций
email-шаблонами

Для FastAPI и Flask обычно выбирают именно Jinja.

Django Template Language #

Django Template Language, или DTL, — встроенный шаблонизатор Django.

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

Пример:

{% extends "base.html" %}

{% block content %}
    <h1>{{ title }}</h1>

    {% for user in users %}
        <p>{{ user.username|upper }}</p>
    {% empty %}
        <p>Пользователей нет</p>
    {% endfor %}
{% endblock %}

Во view:

from django.shortcuts import render


def users_page(request):
    return render(
        request,
        "users.html",
        {
            "title": "Пользователи",
            "users": users,
        },
    )

DTL обычно выбирают, когда приложение уже построено на Django, поскольку он тесно интегрирован с:

Django views
формами
локализацией
static files
CSRF
template context processors
Django Admin

Jinja и Django Templates #

Синтаксис похож:

{{ variable }}

{% if condition %}
{% endif %}

{% for item in items %}
{% endfor %}

Но возможности различаются.

ХарактеристикаJinjaDjango Templates
Привязка к фреймворкуНезависимыйВстроен в Django
Выражения в шаблонеБолее гибкиеНамеренно ограниченные
FastAPI/FlaskЧасто используетсяОбычно нет
DjangoМожно подключитьИспользуется по умолчанию
МакросыЕстьОбычно используются inclusion tags
Генерация произвольного текстаУдобнаГлавным образом web-представления

Для нового FastAPI-проекта обычно выбирают Jinja. Для стандартного Django-проекта чаще оставляют DTL.

Mako #

Mako — Python-шаблонизатор с более свободным использованием Python-кода непосредственно внутри шаблона. Он может генерировать HTML, XML, email-текст и другие текстовые форматы.

Пример:

<%
    active_users = [
        user for user in users
        if user.active
    ]
%>

<ul>
% for user in active_users:
    <li>${user.username}</li>
% endfor
</ul>

Также поддерживает блоки и функции шаблонов:

 <%def name="render_user(user)">
    <li>${user.username}</li>
 </%def>

 <ul>
 % for user in users:
    ${render_user(user)}
 % endfor
 </ul>

Преимущество Mako — гибкость. Недостаток — в шаблон легко перенести слишком много бизнес-логики:

HTML + сложный Python-код
→ труднее читать
→ труднее тестировать
→ сильнее смешиваются представление и логика

Mako можно встретить в проектах на SQLAlchemy/Alembic, Pyramid и в системах генерации кода или конфигураций.

Chameleon #

Chameleon — шаблонизатор для HTML и XML, основанный на концепции Zope Page Templates. Управляющие конструкции обычно записываются в атрибутах HTML/XML, а шаблоны компилируются в Python bytecode. ( Chameleon)

Пример:

<ul>
    <li tal:repeat="user users">
        ${user.username}
    </li>
</ul>

Условие:

<p tal:condition="user.is_active">
    Активный пользователь
</p>

В отличие от Jinja, исходный шаблон Chameleon старается оставаться похожим на обычный валидный HTML/XML-документ.

Он чаще встречается в экосистемах:

Pyramid
Zope
Plone

Chameleon также имеет интеграции с другими Python-фреймворками. ( Chameleon)

Mustache-подобные шаблонизаторы #

Mustache — семейство минималистичных «logic-less» шаблонизаторов.

Общий синтаксис:

<h1>{{ title }}</h1>

{{#users}}
    <p>{{ username }}</p>
{{/users}}

{{^users}}
    <p>Пользователей нет</p>
{{/users}}

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

В Python существуют реализации вроде:

Pystache
Chevron

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

Шаблонизаторы на frontend #

В JavaScript-экосистеме часто встречаются:

ИнструментОсновное применение
HandlebarsHTML-шаблоны с ограниченной логикой
MustacheМинималистичные межъязыковые шаблоны
EJSHTML со вставками JavaScript
PugHTML через сокращённый синтаксис с отступами
NunjucksСинтаксис, похожий на Jinja

Но в современных React, Vue и Svelte отдельный классический шаблонизатор часто не требуется: шаблонизация встроена в сам frontend-фреймворк.

Что выбрать в Python #

FastAPI / Starlette / Flask
→ Jinja

Django
→ Django Template Language

Генерация сложного текста или кода
→ Jinja или Mako

Pyramid / Zope / Plone
→ Chameleon или Jinja

Максимально простой межъязыковой синтаксис
→ Mustache-совместимый движок

Практический выбор #

Для большинства Python-backend проектов достаточно знать два основных варианта:

Jinja
→ универсальный независимый шаблонизатор

Django Templates
→ стандартный шаблонизатор экосистемы Django

Mako стоит выбирать, когда действительно нужна возможность использовать больше Python-логики в шаблонах. Chameleon в основном актуален для проектов, связанных с Pyramid, Zope или Plone.


41. Что такое AJAX? #

Что такое AJAX #

AJAX — подход, при котором браузер обменивается данными с сервером без полной перезагрузки страницы.

Расшифровывается как:

Asynchronous JavaScript and XML

Но сейчас вместо XML чаще передают JSON.

JavaScript в браузере
→ отправляет HTTP-запрос
→ сервер возвращает данные
→ JavaScript обновляет часть страницы

Пример без AJAX #

Пользователь нажимает кнопку «Следующая страница»:

браузер отправляет запрос
→ сервер формирует новую HTML-страницу
→ браузер полностью перезагружает документ

Пример с AJAX #

Пользователь нажимает «Загрузить ещё»:

JavaScript отправляет запрос
→ сервер возвращает новые записи в JSON
→ JavaScript добавляет их в существующую страницу

Остальная часть страницы не перезагружается.

Как это выглядит #

Frontend:

async function loadUser(userId) {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
    }

    const user = await response.json();

    document.querySelector("#username").textContent = user.username;
}

Backend возвращает:

{
  "id": 42,
  "username": "alex"
}

После этого JavaScript изменяет нужный элемент страницы:

<span id="username">alex</span>

Чем отправляют AJAX-запросы #

Исторически использовался объект:

XMLHttpRequest

Современный вариант:

fetch()

Также применяются библиотеки:

Axios
jQuery.ajax()

Пример через fetch:

const response = await fetch("/api/payments", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
    },
    body: JSON.stringify({
        account_id: 42,
        amount: 100,
    }),
});

const payment = await response.json();

Почему AJAX называется асинхронным #

Запрос выполняется не блокируя всю страницу.

Пока сервер обрабатывает запрос, пользователь может:

  • прокручивать страницу;

  • нажимать другие кнопки;

  • заполнять форму;

  • взаимодействовать с интерфейсом.

Когда ответ приходит, JavaScript обрабатывает его.

console.log("До запроса");

fetch("/api/users")
    .then(response => response.json())
    .then(users => console.log(users));

console.log("После запуска запроса");

Сначала обычно будет выведено:

До запроса
После запуска запроса

А результат HTTP-запроса появится позже.

Где применяется AJAX #

Поиск с подсказками
Бесконечная прокрутка
Фильтрация каталога
Отправка формы без перезагрузки
Чат
Уведомления
Добавление товара в корзину
Лайки и комментарии
Обновление таблицы
Пагинация

Например, при вводе текста в поиск:

"pyt"
→ запрос к серверу
→ ["python", "pytest", "pytorch"]
→ отображение подсказок

AJAX — это не отдельный протокол #

AJAX не заменяет HTTP и не является библиотекой.

Это сочетание технологий:

JavaScript
HTTP
Fetch или XMLHttpRequest
JSON / XML / HTML / text
DOM

То есть AJAX описывает способ взаимодействия браузера с backend.

Какие данные можно получать #

Сервер может вернуть:

JSON
HTML-фрагмент
XML
обычный текст
файл
Blob

Чаще всего API возвращает JSON:

const response = await fetch("/api/users");
const users = await response.json();

Но можно получить и HTML:

const response = await fetch("/users/table");
const html = await response.text();

document.querySelector("#users").innerHTML = html;

AJAX и REST API #

AJAX — способ отправить запрос из браузера.

REST API — подход к проектированию HTTP-интерфейса.

Они часто используются вместе:

Frontend
→ AJAX-запрос
→ REST API
→ JSON-ответ

Например:

GET /api/users/42

Ответ:

{
  "id": 42,
  "username": "alex"
}

AJAX и WebSocket #

AJAX работает по модели запрос-ответ:

клиент отправил запрос
→ сервер ответил

WebSocket создаёт постоянное двустороннее соединение:

клиент ⇄ сервер

AJAX подходит для:

  • загрузки данных;

  • отправки форм;

  • периодических обновлений.

WebSocket лучше подходит для:

  • чатов;

  • игровых событий;

  • биржевых котировок;

  • моментальных уведомлений.

AJAX и polling #

Чтобы регулярно проверять новые данные, браузер может отправлять AJAX-запросы по таймеру:

setInterval(async () => {
    const response = await fetch("/api/notifications");
    const notifications = await response.json();

    renderNotifications(notifications);
}, 5000);

Это называется polling:

каждые 5 секунд
→ есть ли новые данные?

Недостаток — запросы отправляются даже тогда, когда новых данных нет.

Для более оперативной доставки могут использоваться:

WebSocket
Server-Sent Events
long polling

Обработка ошибок #

AJAX-запрос может завершиться:

  • HTTP-ошибкой;

  • сетевой ошибкой;

  • таймаутом;

  • некорректным JSON;

  • отменой запроса.

async function loadPayments() {
    try {
        const response = await fetch("/api/payments");

        if (!response.ok) {
            throw new Error(`Server returned ${response.status}`);
        }

        return await response.json();
    } catch (error) {
        console.error("Could not load payments", error);
    }
}

Важно: fetch() обычно не выбрасывает исключение только из-за ответа 404 или 500. Поэтому нужно проверять:

response.ok

Безопасность #

AJAX-запросы подчиняются обычным правилам веб-безопасности.

Нужно учитывать:

CORS
CSRF
XSS
аутентификацию
авторизацию
валидацию входных данных

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

При токене в заголовке:

fetch("/api/profile", {
    headers: {
        Authorization: `Bearer ${accessToken}`,
    },
});

Backend всё равно обязан проверять права пользователя. Скрытая кнопка на frontend не является защитой.

Итог #

AJAX
→ способ выполнять HTTP-запросы из JavaScript
  без полной перезагрузки страницы

Типичная схема:

Действие пользователя
→ JavaScript
→ fetch()
→ backend API
→ JSON
→ обновление части DOM

Название сохранилось исторически, но современный AJAX чаще означает асинхронные HTTP-запросы из браузера с использованием JavaScript и JSON.


42. Библиотеки httpx vs requests #

Основное различие #

Обе библиотеки используются для отправки HTTP-запросов из Python, и их базовый API похож:

response = client.get(url)
response = client.post(url, json=data)
response.raise_for_status()
data = response.json()

Но requests ориентирован на синхронную работу, а HTTPX предоставляет и синхронный Client, и асинхронный AsyncClient. Кроме того, HTTPX поддерживает HTTP/1.1 и HTTP/2, тогда как основной сценарий Requests — синхронные HTTP/1.1-запросы.

ХарактеристикаRequestsHTTPX
Синхронный APIДаДа
Асинхронный APIНетAsyncClient
HTTP/2НетДа, включается отдельно
Пул соединенийrequests.Sessionhttpx.Client / AsyncClient
Таймаут по умолчаниюОтсутствует5 секунд сетевой неактивности
ASGI/WSGI transportНет встроенного аналогаЕсть
Совместимость APIИсходный привычный APIВо многом похож на Requests
Типичный сценарийСкрипты и синхронные приложенияAsync backend, конкурентные запросы

Requests #

Пример:

import requests


def get_user(user_id: int) -> dict:
    with requests.Session() as client:
        response = client.get(
            f"https://api.example.com/users/{user_id}",
            timeout=(3, 10),
        )
        response.raise_for_status()

        return response.json()

Session сохраняет cookies и общую конфигурацию, а также использует пул соединений: несколько запросов к одному серверу могут повторно использовать существующее TCP-соединение.

Requests удобен, когда:

  • приложение полностью синхронное;

  • требуется простой HTTP-клиент;

  • выполняется CLI-скрипт или периодическая задача;

  • проект уже построен вокруг requests.Session;

  • асинхронность и HTTP/2 не нужны.

Главная опасность — отсутствие таймаута по умолчанию:

requests.get("https://api.example.com/users")

Такой вызов может ожидать ответ неопределённо долго. Поэтому таймаут нужно указывать явно:

requests.get(
    "https://api.example.com/users",
    timeout=(3, 10),
)

Здесь первое число относится к установлению соединения, второе — к чтению ответа. HTTPX, в отличие от Requests, устанавливает таймауты по умолчанию.

HTTPX в синхронном режиме #

HTTPX можно использовать почти так же:

import httpx


def get_user(user_id: int) -> dict:
    timeout = httpx.Timeout(
        timeout=10,
        connect=3,
    )

    with httpx.Client(
        base_url="https://api.example.com",
        timeout=timeout,
    ) as client:
        response = client.get(f"/users/{user_id}")
        response.raise_for_status()

        return response.json()

Аналогом requests.Session является httpx.Client. Он предоставляет пул соединений, сохранение cookies и общую конфигурацию запросов. Документация HTTPX рекомендует использовать клиент для всего, что сложнее одноразового экспериментального запроса.

HTTPX в асинхронном режиме #

Основное преимущество HTTPX проявляется в асинхронных приложениях:

import httpx


async def get_user(user_id: int) -> dict:
    async with httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=10,
    ) as client:
        response = await client.get(f"/users/{user_id}")
        response.raise_for_status()

        return response.json()

AsyncClient поддерживает asyncio и Trio. Пока клиент ожидает сетевой ответ, event loop может выполнять другие корутины.

В FastAPI не следует создавать новый AsyncClient внутри каждого запроса:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    async with httpx.AsyncClient() as client:
        ...

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

from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http_client = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(10, connect=3),
    )

    try:
        yield
    finally:
        await app.state.http_client.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    response = await app.state.http_client.get(
        f"/users/{user_id}"
    )
    response.raise_for_status()

    return response.json()

Постоянный клиент использует connection pooling и повторно использует TCP-соединения.

Таймауты #

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

HTTPX разделяет четыре вида таймаутов:

timeout = httpx.Timeout(
    connect=3,
    read=10,
    write=10,
    pool=2,
)
connect
→ ожидание установки соединения

read
→ ожидание очередной части ответа

write
→ ожидание отправки очередной части запроса

pool
→ ожидание свободного соединения из пула

Requests обычно использует connect и read через одно число или кортеж:

timeout=(3, 10)

HTTPX предоставляет более детальную настройку, включая ожидание записи и свободного соединения.

HTTP/2 #

HTTPX поддерживает HTTP/2, но он не включён по умолчанию:

pip install "httpx[http2]"
async with httpx.AsyncClient(http2=True) as client:
    response = await client.get("https://example.com")

HTTP/2 позволяет нескольким конкурентным запросам использовать одно TCP-соединение через мультиплексирование. Фактическое соединение перейдёт на HTTP/2 только тогда, когда его поддерживает и сервер.

Тестирование ASGI-приложений #

HTTPX может обращаться непосредственно к ASGI-приложению без запуска реального HTTP-сервера:

import httpx


async def test_health(app):
    transport = httpx.ASGITransport(app=app)

    async with httpx.AsyncClient(
        transport=transport,
        base_url="http://testserver",
    ) as client:
        response = await client.get("/health")

    assert response.status_code == 200

Также существует WSGITransport для синхронных WSGI-приложений. Эти транспорты полезны для тестирования и подмены внешних сервисов.

Отличия API, о которых важно помнить #

HTTPX стремится быть совместимым с Requests, но полной совместимости нет.

Редиректы #

Requests обычно следует редиректам автоматически, а HTTPX требует явно включить это поведение:

client = httpx.Client(
    follow_redirects=True,
)

или:

response = httpx.get(
    url,
    follow_redirects=True,
)

Потоковое чтение #

Requests:

with requests.get(url, stream=True) as response:
    for chunk in response.iter_content(8192):
        process(chunk)

HTTPX:

with httpx.stream("GET", url) as response:
    for chunk in response.iter_bytes():
        process(chunk)

HTTPX выделяет streaming в отдельный контекстный интерфейс, чтобы соединение было явно закрыто.

Тело запроса #

В HTTPX разделены:

httpx.post(url, json={"name": "Alex"})   # JSON
httpx.post(url, data={"name": "Alex"})   # HTML-форма
httpx.post(url, content=b"raw bytes")    # сырые данные

Для сырых данных HTTPX предпочитает content, тогда как data предназначен для form data.

Что использовать #

Для синхронного скрипта:

Requests

Он прост, привычен и отлично подходит для нескольких последовательных запросов.

Для синхронного приложения также можно использовать HTTPX:

httpx.Client

Особенно когда хочется единый API с асинхронной частью проекта, более детальные таймауты или возможность HTTP/2.

Для FastAPI и другого асинхронного backend:

httpx.AsyncClient

Прямой вызов Requests внутри async def будет блокировать поток event loop на время сетевого запроса. Его пришлось бы выносить в thread pool, что обычно менее естественно, чем использование асинхронного HTTP-клиента.

Итог #

Requests
→ простой зрелый синхронный HTTP-клиент
→ подходит для скриптов и sync-приложений

HTTPX
→ синхронный и асинхронный API
→ HTTP/2
→ детальные таймауты
→ ASGI/WSGI transports
→ удобнее для FastAPI и конкурентных запросов