Форум программистов, компьютерный форум, киберфорум
ArchitectMsa
Войти
Регистрация
Восстановить пароль

Архитектура телеграм-ботов на Python и Феномен 15 миллиардов сообщений (2)

Запись от ArchitectMsa размещена 23.10.2025 в 13:11
Показов 6856 Комментарии 0

Нажмите на изображение для увеличения
Название: Шардинг и горизонтальное масштабирование ботов.jpg
Просмотров: 953
Размер:	179.6 Кб
ID:	11330
Архитектура телеграм-ботов на Python и Феномен 15 миллиардов сообщений (1)

Шардинг и горизонтальное масштабирование ботов



Вертикальное масштабирование упирается в физические границы железа довольно быстро. У меня был момент когда бот обрабатывал 300 тысяч пользователей на сервере с 32 гигами RAM и 16 ядрами. Казалось бы запаса море, но PostgreSQL сожрал всю память под connections и кеши, процессор горел на 90%, диск не успевал с I/O. Апгрейд до 64 гиг обошёлся в пять тысяч долларов, а через три месяца снова упёрлись в потолок. Дальше только шардинг - разбиение данных и нагрузки по нескольким независимым серверам.

Шардинг по user_id это классика для telegram-ботов. Берёшь ID пользователя, вычисляешь остаток от деления на количество шардов, определяешь на каком сервере хранятся его данные. Просто, предсказуемо, каждый юзер всегда попадает на свой шард. База разбита на десять инстансов - каждый держит 10% пользователей, нагрузка распределена равномерно. Теоретически.

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

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import hashlib
from typing import List, Optional
 
class ConsistentHashRing:
    def __init__(self, nodes: List[str], virtual_nodes: int = 150):
        self.virtual_nodes = virtual_nodes
        self.ring = {}
        self.sorted_keys = []
        
        for node in nodes:
            self.add_node(node)
    
    def _hash(self, key: str) -> int:
        # Хешируем в 32-битное число
        return int(hashlib.md5(key.encode()).hexdigest(), 16)
    
    def add_node(self, node: str):
        # Добавляем виртуальные ноды для равномерности
        for i in range(self.virtual_nodes):
            virtual_key = f"{node}:{i}"
            hash_val = self._hash(virtual_key)
            self.ring[hash_val] = node
        
        # Пересортировываем ключи
        self.sorted_keys = sorted(self.ring.keys())
    
    def get_node(self, user_id: int) -> str:
        # Находим шард для конкретного юзера
        if not self.ring:
            return None
        
        key_hash = self._hash(str(user_id))
        
        # Ищем первый шард по часовой стрелке
        for ring_key in self.sorted_keys:
            if key_hash <= ring_key:
                return self.ring[ring_key]
        
        # Если прошли весь круг - возвращаем первый
        return self.ring[self.sorted_keys[0]]
 
# Использование
hash_ring = ConsistentHashRing([
    'db-shard-01.example.com',
    'db-shard-02.example.com',
    'db-shard-03.example.com'
])
 
# Каждый юзер попадает на предсказуемый шард
shard = hash_ring.get_node(user_id=12345)
# shard = 'db-shard-02.example.com'
Cross-shard запросы это ад который пытаешься избежать любой ценой. Нужна статистика по всем пользователям? Придётся опросить все шарды, собрать результаты, агрегировать. Если один шард тормозит - весь запрос висит в ожидании. Я делал поиск пользователей по username через все шарды - десять параллельных SELECT'ов, timeout на каждом две секунды, результат за четыре секунды в лучшем случае. Пользователи жаловались на тормоза, пришлось делать отдельный search index в Elasticsearch.

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

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class ShardedDatabase:
    def __init__(self, shard_configs: dict):
        self.shards = {}
        self.rebalancing = {}  # Временные маппинги при миграции
        
        for shard_id, config in shard_configs.items():
            self.shards[shard_id] = asyncpg.create_pool(config['dsn'])
    
    def get_shard_id(self, user_id: int) -> str:
        # Проверяем идёт ли миграция для этого юзера
        if user_id in self.rebalancing:
            return self.rebalancing[user_id]
        
        # Стандартное распределение
        shard_count = len(self.shards)
        return f"shard_{user_id % shard_count}"
    
    async def get_user(self, user_id: int):
        shard_id = self.get_shard_id(user_id)
        pool = self.shards[shard_id]
        
        async with pool.acquire() as conn:
            # Пытаемся найти на целевом шарде
            user = await conn.fetchrow(
                "SELECT * FROM users WHERE id = $1",
                user_id
            )
            
            if not user and user_id in self.rebalancing:
                # Миграция в процессе - пробуем старый шард
                old_shard = self.rebalancing[user_id]
                async with self.shards[old_shard].acquire() as old_conn:
                    user = await old_conn.fetchrow(
                        "SELECT * FROM users WHERE id = $1",
                        user_id
                    )
            
            return user
    
    async def migrate_user(self, user_id: int, target_shard: str):
        source_shard = self.get_shard_id(user_id)
        
        if source_shard == target_shard:
            return  # Уже на нужном шарде
        
        # Помечаем что идёт миграция
        self.rebalancing[user_id] = source_shard
        
        try:
            # Копируем данные на новый шард
            async with self.shards[source_shard].acquire() as src_conn:
                user_data = await src_conn.fetchrow(
                    "SELECT * FROM users WHERE id = $1",
                    user_id
                )
                
                if user_data:
                    async with self.shards[target_shard].acquire() as dst_conn:
                        # Вставляем на новый шард
                        await dst_conn.execute(
                            """INSERT INTO users (id, username, data) 
                               VALUES ($1, $2, $3)""",
                            user_data['id'],
                            user_data['username'],
                            user_data['data']
                        )
                    
                    # Удаляем со старого
                    await src_conn.execute(
                        "DELETE FROM users WHERE id = $1",
                        user_id
                    )
            
            # Обновляем маппинг
            del self.rebalancing[user_id]
        except Exception as e:
            # При ошибке откатываем маркер миграции
            logger.error(f"Migration failed for user {user_id}: {e}")
            del self.rebalancing[user_id]
            raise
Hot shards возникают когда нагрузка неравномерна. У тебя миллион пользователей, но десять процентов генерят 80% запросов - активные юзеры, боты, автоматизация. Эти "горячие" аккаунты размазаны по шардам случайно, какой-то шард получил их больше и задыхается пока остальные простаивают. Видел кейс где из десяти шардов один обрабатывал половину запросов потому что туда попали все корпоративные аккаунты с ботами.

Решение через явное распределение VIP-пользователей на отдельные шарды. Выделяешь два "премиум" шарда с усиленным железом, туда складываешь top-1000 активных юзеров. Остальные на обычных шардах по модулю. Мониторишь активность, когда кто-то вырывается в топ - перемещаешь на премиум-шард. Усложняет логику но решает проблему горячих точек.

Географический шардинг по регионам имеет смысл для глобальных ботов. Европейские пользователи на европейских серверах, азиатские в Азии, американские в США. Латентность снижается драматически - вместо 300 миллисекунд до другого континента получаешь 20-30 внутри региона. Но тогда аналитика и админка должны уметь работать с множеством датацентров одновременно.

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

Как продавать телеграм-ботов?
Хотел бы узнать, среди тех, кто когда-либо делал ботов на заказ: как вы создаете бота для...

Как часто для Телеграм-ботов требуется сторонний API?
Здравствуйте! Я хочу писать Телеграм-ботов на непопулярном языке и у меня возникает вопрос: как...

Численность мировой интернет-аудитории достигла двух миллиардов
Международный союз телекоммуникаций (ITU) при ООН, объединяющий компании и правительственные...


Балансировка нагрузки между инстансами ботов



Load balancer это не просто "штука что раскидывает запросы по серверам". Это критический компонент который определяет надёжность всей системы, и я убедился в этом когда первый раз настроил его неправильно. Запустил три инстанса бота за nginx с дефолтной конфигурацией round-robin, через час начались жалобы что бот "забывает" диалоги. Пользователь вводил имя, следующее сообщение с возрастом попадало на другой инстанс, который ничего не знал о предыдущем шаге. FSM состояние размазалось по трём серверам беспорядочно.

Nginx это стандарт де-факто для HTTP load balancing в мире ботов. Лёгкий, быстрый, жрёт 10 мегабайт памяти и обрабатывает десятки тысяч соединений без проблем. Конфигурация для webhook выглядит обманчиво просто:

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
upstream bot_backends {
    server 10.0.1.10:8443;
    server 10.0.1.11:8443;
    server 10.0.1.12:8443;
}
 
server {
    listen 443 ssl http2;
    server_name bot.example.com;
    
    ssl_certificate /etc/ssl/bot.crt;
    ssl_certificate_key /etc/ssl/bot.key;
    
    location /webhook {
        proxy_pass [url]http://bot_backends;[/url]
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
Round-robin по умолчанию просто чередует серверы - первый запрос на сервер 1, второй на сервер 2, третий на сервер 3, четвёртый снова на 1. Работает идеально для stateless приложений где каждый запрос независим. Для ботов с FSM это катастрофа потому что следующее сообщение от юзера может попасть куда угодно.

IP hash решает проблему через sticky sessions - хеширует IP адрес пользователя, всегда отправляет на один и тот же backend. Telegram шлёт webhook с нескольких IP блоками, но конкретный пользователь обычно привязан к одному датацентру. Добавляешь ip_hash; в upstream блок - большинство диалогов начинают работать корректно. Но не все, потому что Telegram иногда меняет IP источника для одного пользователя, особенно при переключении между Wi-Fi и мобильным интернетом.

Least connections умнее - отправляет на сервер с наименьшим количеством активных соединений. Если один backend тормозит, новые запросы пойдут на более быстрые. Для ботов где обработка занимает разное время в зависимости от команды это логичнее round-robin. Генерация PDF отчёта висит минуту на одном сервере, балансер отправляет лёгкие запросы на другие пока первый занят:

JSON
1
2
3
4
5
6
upstream bot_backends {
    least_conn;
    server 10.0.1.10:8443;
    server 10.0.1.11:8443;
    server 10.0.1.12:8443;
}
HAProxy мощнее nginx в плане алгоритмов балансировки и health checks. Поддерживает weighted round-robin где можно задать вес каждому серверу - мощный сервер получает 60% трафика, слабый 40%. Source IP hashing работает надёжнее чем в nginx, есть поддержка consistent hashing. Но требует отдельного процесса и конфигурацию на языке специфичном для HAProxy:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
frontend bot_frontend
    bind *:443 ssl crt /etc/ssl/bot.pem
    default_backend bot_backends
 
backend bot_backends
    balance source  # Sticky sessions по IP
    hash-type consistent
    
    option httpchk GET /health
    http-check expect status 200
    
    server bot1 10.0.1.10:8443 check inter 2000 rise 2 fall 3
    server bot2 10.0.1.11:8443 check inter 2000 rise 2 fall 3
    server bot3 10.0.1.12:8443 check inter 2000 rise 2 fall 3
Health checks автоматически исключают мёртвые инстансы из пула. HAProxy дёргает /health endpoint каждые две секунды, если получает не-200 ответ дважды подряд - помечает сервер как down, если три раза подряд получает 200 после падения - возвращает в пул. Nginx в бесплатной версии делает passive health checks - замечает ошибки только когда реальный трафик пытается достучаться до мёртвого сервера. Первые несколько пользователей получают ошибки пока nginx не поймёт что backend недоступен. Проблема с Telegram webhook в том что он не поддерживает редиректы. Балансер не может вернуть 307 и сказать "попробуй этот другой сервер". Либо 200 OK с обработкой, либо ошибка и retry через минуту. Поэтому балансер должен сам корректно проксировать запрос на живой backend, а не отправлять клиента по новому адресу.

Cloud load balancers типа AWS ELB или Google Cloud Load Balancer упрощают операционку - не надо настраивать nginx, мониторить его, обновлять SSL сертификаты. Всё из коробки с автоматическим масштабированием и health checks. Но добавляется cost - $20-50 в месяц за балансер против $0 за nginx на том же сервере что и боты. Для стартапа это может быть критично, для энтерпрайза не вопрос.

Graceful shutdown критичен чтобы не терять запросы при деплое. Останавливаешь инстанс бота - он должен сообщить балансеру "я больше не принимаю новые запросы" но дообработать текущие. Health check начинает возвращать 503, балансер перестаёт слать новый трафик, через 30 секунд инстанс завершает активные задачи и выключается. Без этого часть webhook'ов теряется на боте который убили посреди обработки.

CQRS и разделение чтения и записи в боте



Command Query Responsibility Segregation звучит академично, но в реальности это просто идея что операции записи и чтения данных должны идти разными путями. В классической архитектуре одна и та же модель User обслуживает и создание пользователя, и показ его профиля. Работает пока запросов немного, но масштабируется плохо - запись требует транзакций и блокировок, чтение хочет денормализованные быстрые выборки. Пытаешься оптимизировать одно, ломаешь другое.

Я столкнулся с этим на боте для маркетплейса где пользователи заказывали товары и смотрели свою историю покупок. Создание заказа - сложная операция с проверками, резервированием товара, списанием денег, обновлением инвентаря в трёх таблицах. Показ истории заказов - простая выборка с фильтрами и пагинацией. Но использовал одну модель Order для обоих сценариев. База тормозила потому что SELECT'ы блокировались на записях которые обновлялись при создании заказов, индексы оптимизированные для записи замедляли чтение.

Разделение начинается с двух отдельных моделей. Command model содержит бизнес-логику для изменения состояния - валидацию, правила, транзакции. Query model это плоская денормализованная структура оптимизированная для быстрого чтения. Пользователь создаёт заказ - идёшь через command handler который проверяет всё, сохраняет в нормализованные таблицы, публикует событие. Пользователь просматривает историю - читаешь из read model который уже содержит всё в удобном виде:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# Command model - нормализованная структура
class CreateOrderCommand:
    async def execute(self, user_id: int, items: list):
        async with db.transaction():
            # Проверяем остатки
            for item in items:
                stock = await db.fetchval(
                    "SELECT quantity FROM inventory WHERE product_id = $1 FOR UPDATE",
                    item['product_id']
                )
                if stock < item['quantity']:
                    raise InsufficientStock()
            
            # Создаём заказ
            order_id = await db.fetchval(
                "INSERT INTO orders (user_id, status, created_at) VALUES ($1, $2, NOW()) RETURNING id",
                user_id, 'pending'
            )
            
            # Записываем позиции
            for item in items:
                await db.execute(
                    "INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)",
                    order_id, item['product_id'], item['quantity'], item['price']
                )
                
                # Обновляем остатки
                await db.execute(
                    "UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2",
                    item['quantity'], item['product_id']
                )
            
            # Публикуем событие для обновления read model
            await event_bus.publish('order.created', {
                'order_id': order_id,
                'user_id': user_id,
                'items': items
            })
            
            return order_id
 
# Query model - денормализованная выборка
class OrderQueryService:
    async def get_user_orders(self, user_id: int, limit: int = 10):
        # Читаем из материализованного view или отдельной таблицы
        orders = await read_db.fetch("""
            SELECT 
                o.id,
                o.status,
                o.created_at,
                o.total_amount,
                json_agg(json_build_object(
                    'product_name', p.name,
                    'quantity', oi.quantity,
                    'price', oi.price
                )) as items
            FROM orders_read_model o
            JOIN order_items_read_model oi ON o.id = oi.order_id
            JOIN products_cache p ON oi.product_id = p.id
            WHERE o.user_id = $1
            GROUP BY o.id
            ORDER BY o.created_at DESC
            LIMIT $2
        """, user_id, limit)
        
        return orders
Read model обновляется асинхронно через события. Command handler опубликовал "order.created", отдельный worker подписан на это событие и обновляет денормализованную таблицу для быстрого чтения. Задержка между записью и появлением в read model - секунды максимум, пользователь создал заказ и сразу видит его в истории. Eventual consistency работает потому что точная синхронность тут не критична.

Отдельные базы данных для команд и запросов это следующий уровень. Запись идёт в PostgreSQL с транзакциями и ACID гарантиями. Чтение из read replicas или вообще другой БД оптимизированной под выборки - MongoDB, Elasticsearch, даже Redis если данных немного. У меня была схема где команды писались в Postgres, события реплицировались в Elasticsearch, все поисковые запросы шли туда. Латентность запросов упала с 500ms до 50ms, Postgres разгрузился на 80%. Проблема в том что система становится в разы сложнее. Вместо одной модели теперь две, плюс механизм синхронизации между ними, плюс обработка ситуаций когда синхронизация отстаёт или ломается. Пользователь создал заказ, событие потерялось по пути, read model не обновился - юзер не видит свой заказ хотя деньги списались. Нужен мониторинг задержки репликации, retry логика для событий, идемпотентность обработчиков.

Event sourcing доводит CQRS до логического предела - вместо хранения текущего состояния храним историю всех изменений. Каждая команда генерирует событие которое добавляется в append-only лог. Текущее состояние восстанавливается replay событий. Read model строится проигрыванием всех событий с начала времён или с последнего snapshot. Даёт полный audit trail и возможность временных запросов - "какой был баланс пользователя месяц назад?". Но сложность растёт экспоненциально, для большинства ботов это оверкилл.

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

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



Метрики нужны до того как что-то сломалось, а не после. Latency - время от получения Update до отправки ответа пользователю. Throughput - сколько запросов в секунду обрабатывается успешно. Error rate - процент упавших запросов. CPU utilization, memory usage, database connections, Redis operations per second. Без этих цифр летишь вслепую, не понимаешь где именно проблема и помогла ли оптимизация.

У меня был случай когда думал что база тормозит - смотрел на графики, видел что latency растёт синхронно с количеством database queries. Добавил индексы, кеширование, connection pool расширил. Стало лучше но ненамного. Включил профилировщик, оказалось 40% времени уходит на сериализацию JSON перед отправкой в Telegram API. Огромные объекты с вложенными структурами сериализовались в однопоточном режиме, блокируя event loop. Оптимизировал модели данных, убрал лишние поля - латентность упала ещё на 100 миллисекунд.

Нагрузочное тестирование показывает границы системы до того как реальные пользователи упрутся в них. Locust, JMeter, даже самописные скрипты на asyncio - инструмент не так важен как методика. Начинаешь с baseline - измеряешь производительность при минимальной нагрузке. Потом постепенно повышаешь RPS, смотришь когда начинается деградация. Находишь точку где error rate превышает 1% - это твой текущий предел. Профилируешь, оптимизируешь узкое место, повторяешь.

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

Capacity planning на основе метрик и нагрузочных тестов даёт представление когда упрёшься в лимиты. Текущая нагрузка 500 RPS, максимум системы 2000 RPS, рост пользователей 20% в месяц. Через три месяца достигнешь предела, надо масштабировать заранее. Или наоборот - запас в десять раз, можно сэкономить на железе. Без этих расчётов либо переплачиваешь за избыточные ресурсы, либо внезапно падаешь под наплывом пользователей.

Узкие места в python-коде



GIL это наверное самая обсуждаемая и одновременно непонятая фича CPython. Global Interpreter Lock разрешает выполняться только одному потоку Python-кода в один момент времени, даже если процессор имеет восемь ядер. Для IO-bound операций типа сетевых запросов это не проблема - пока один поток ждёт ответа, другие работают. Но попробуй обработать изображение или зашифровать данные в нескольких тредах - увидишь что все ядра простаивают, работает только одно.

Я наткнулся на это когда делал бота для обработки фотографий. Пользователь загружает картинку, бот применяет фильтры через Pillow, накладывает watermark, сжимает. На одной фотке занимает секунду, решил распараллелить через ThreadPoolExecutor - запустил четыре треда на сервере с четырьмя ядрами. Ожидал ускорение в три-четыре раза, получил замедление в полтора. Треды конкурировали за GIL, переключение контекста жрало больше времени чем экономилось. Перешёл на ProcessPoolExecutor - каждый процесс получил свой интерпретатор без общего GIL, обработка действительно ускорилась в четыре раза.

Проблема в том что multiprocessing добавляет overhead на IPC и сериализацию данных через pickle. Передать большой объект между процессами дороже чем обработать его в одном. Плюс каждый процесс держит полную копию памяти приложения - четыре процесса съедают гигабайт RAM вместо 250 мегабайт. Для ботов обрабатывающих тысячи мелких запросов это может быть неприемлемо.

Блокирующие операции в async коде убивают производительность мгновенно. Вызвал requests.get() внутри async функции - весь event loop встал, остальные корутины ждут. Видел бота где разработчик использовал синхронный psycopg2 драйвер в асинхронных handlers потому что "так проще". На пяти пользователях работало, на сотне бот превратился в черепаху. Каждый SELECT блокировал loop на 50-100 миллисекунд, запросы копились в очереди, латентность росла экспоненциально.

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Катастрофа для async кода
async def bad_handler(message: Message):
    # Блокирует весь event loop на секунды
    response = requests.get("https://api.example.com/data")
    result = heavy_cpu_function(response.json())
    
    await message.answer(str(result))
 
# Правильный подход
async def good_handler(message: Message):
    # Async HTTP-клиент не блокирует
    async with aiohttp.ClientSession() as session:
        async with session.get("https://api.example.com/data") as resp:
            data = await resp.json()
    
    # CPU-bound операция в executor
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, heavy_cpu_function, data)
    
    await message.answer(str(result))
Ленивая загрузка в ORM создаёт N+1 query problem который я вижу в каждом втором боте. Загрузил список из ста заказов, для каждого хочешь показать имя пользователя - ORM делает дополнительный SELECT на каждую итерацию. Сто один запрос к базе вместо одного с JOIN. Латентность умножается на количество объектов, при больших выборках это убийственно. SQLAlchemy решает через explicit eager loading - joinedload() или selectinload() грузят связанные объекты заранее одним или двумя запросами.

Утечки памяти в долгоживущих процессах Python реальны несмотря на сборщик мусора. Circular references между объектами, забытые глобальные кеши, незакрытые файловые дескрипторы - всё это копится. У меня бот работал неделю, потом начинал тормозить, через две недели падал с OutOfMemory. Оказалось я складывал все обработанные Update объекты в список "для отладки" и забыл его чистить. Список рос до миллионов элементов, сборщик мусора не мог их удалить потому что я держал ссылку.

JSON сериализация больших объектов блокирует на удивление долго. Встроенный json.dumps() не оптимизирован под большие структуры, на словаре с десятью тысячами ключей может тормозить десятки миллисекунд. ujson или orjson в разы быстрее за счёт реализации на C, но не поддерживают некоторые кастомные типы. Видел как бот отправлял огромный JSON с аналитикой пользователю, сериализация занимала полсекунды CPU времени, блокируя event loop намертво.

String concatenation в циклах это классическая ошибка новичков но встречается постоянно. result = ""; for item in items: result += str(item) создаёт новую строку на каждой итерации, копируя все предыдущие данные. O(n²) сложность вместо O(n). На тысяче элементов разница в миллисекундах, на миллионе - минуты. StringIO или join() делают это за линейное время, но многие не знают или забывают.

Профилирование реального бота



Профилировать код "на глаз" это как лечить болезнь не зная диагноза - можешь случайно угадать, но скорее всего потратишь время на оптимизацию не того места. У меня был бот который тормозил при отправке сообщений, я был уверен что проблема в базе данных - там же сложные JOIN'ы, наверняка она виновата. Потратил два дня на оптимизацию запросов, добавил индексы, переписал половину SQL. Стало чуть лучше но не радикально. Запустил профилировщик - оказалось 60% времени уходит на валидацию данных через pydantic перед отправкой в Telegram API. Переделал схемы валидации, убрал избыточные проверки - латентность упала втрое.

cProfile это встроенный профилировщик который показывает сколько времени каждая функция выполнялась и сколько раз вызывалась. Запускаешь бота с ним, нагружаешь трафиком, получаешь детальную статистику. Проблема в том что он замедляет выполнение процентов на 30-40, поэтому в production включать рискованно. Я использую на staging окружении с реплицированным трафиком:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cProfile
import pstats
from pstats import SortKey
 
def profile_bot_handler():
    profiler = cProfile.Profile()
    profiler.enable()
    
    # Здесь выполняется код бота
    # Например, обработка 1000 сообщений
    
    profiler.disable()
    
    # Сохраняем результаты
    profiler.dump_stats('bot_profile.prof')
    
    # Анализируем топ функций по времени
    stats = pstats.Stats(profiler)
    stats.sort_stats(SortKey.CUMULATIVE)
    stats.print_stats(20)  # Топ 20 самых медленных
Вывод выглядит пугающе - тысячи строк с названиями функций, временем, количеством вызовов. Важно смотреть не только на tottime (чистое время в функции), но и на cumtime (время включая вызовы других функций). Функция может сама работать быстро но вызывать медленные операции внутри. Обычно паттерн такой: находишь функцию с большим cumtime, смотришь что она вызывает через print_callers(), спускаешься по цепочке вызовов до реального узкого места.

line_profiler даёт построчную статистику - видишь сколько времени заняла каждая строка кода. Это золото когда нашёл медленную функцию через cProfile но не понимаешь какая именно её часть тормозит. Ставится отдельно через pip, требует декорирования функций для профилирования:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from line_profiler import LineProfiler
 
@profile  # Магический декоратор line_profiler
async def process_order(order_data: dict):
    # Валидация
    validated = OrderSchema(**order_data)
    
    # Проверка наличия
    available = await check_inventory(validated.items)
    
    # Создание заказа
    order_id = await db.create_order(validated)
    
    # Резервирование товаров
    await reserve_items(order_id, validated.items)
    
    # Отправка уведомления
    await notify_user(validated.user_id, order_id)
    
    return order_id
 
# Запуск с профилированием
# kernprof -l -v script.py
Вывод показывает точно - валидация заняла 120ms, проверка наличия 50ms, создание заказа 200ms, резервирование 30ms, уведомление 400ms. Сразу видно что notify_user съедает половину времени, копаешь туда.
memory_profiler отслеживает потребление памяти построчно. Критично для долгоживущих ботов где утечки накапливаются незаметно. Показывает не только текущее использование но и приращение на каждой строке - где именно память начинает расти. Тоже требует декорирования:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from memory_profiler import profile
 
@profile
def load_user_data(user_ids: list):
    # Загружаем данные всех юзеров
    users = []
    for uid in user_ids:
        user = fetch_user_from_db(uid)  # +50KB памяти
        user['cache'] = build_user_cache(user)  # +200KB
        users.append(user)
    
    return users
 
# Line #    Mem usage    Increment  Occurrences   Line Contents
# ===============================================================
#     2    125.5 MiB    125.5 MiB           1       users = []
#     3    125.5 MiB      0.0 MiB        1001       for uid in user_ids:
#     4    175.8 MiB     50.3 MiB        1000           user = fetch_user_from_db(uid)
#     5    375.8 MiB    200.0 MiB        1000           user['cache'] = build_user_cache(user)
tracemalloc встроенный в Python 3.4+ и показывает откуда растёт память без замедления кода. Делаю snapshot до и после подозрительной операции, сравниваю - вижу какие объекты добавились и где они были созданы:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import tracemalloc
 
# Начинаем отслеживание
tracemalloc.start()
 
# Делаем snapshot до операции
snapshot1 = tracemalloc.take_snapshot()
 
# Подозрительная операция
await process_thousand_messages()
 
# Snapshot после
snapshot2 = tracemalloc.take_snapshot()
 
# Сравниваем
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
 
for stat in top_stats[:10]:
    print(f"{stat.size_diff / 1024 / 1024:.1f} MB")
    print(f"  {stat.traceback}")
py-spy это sampling profiler который цепляется к работающему процессу снаружи, без изменения кода. Можешь профилировать production бота не перезапуская его. Делает snapshot call stack каждые несколько миллисекунд, строит flame graph показывающий где процессор проводит время. Я использую когда бот внезапно начал тормозить в проде и непонятно почему - цепляешь py-spy, через минуту видишь что 80% времени в одной функции:

Python
1
2
3
4
5
# Профилируем процесс бота
py-spy top --pid 12345
 
# Или записываем flamegraph
py-spy record -o profile.svg --pid 12345 --duration 60
Flame graph это визуализация где ширина блока показывает сколько времени функция выполнялась, высота - глубину call stack. Один взгляд и понятно какая функция жрёт ресурсы. Видел графы где 90% ширины занимал json.dumps() - сразу понятно что сериализация узкое место.

Distributed tracing через Jaeger или Zipkin показывает latency в микросервисной архитектуре. Запрос проходит через пять сервисов, где именно тормозит? Каждый сервис записывает span с временем входа/выхода, correlation ID связывает spans одного запроса. Смотришь trace в UI - видишь что payment service ждёт ответа от банка 2 секунды из 2.5 общего времени обработки. Не надо гадать, цифры перед глазами.

APM системы типа New Relic или Datadog собирают метрики автоматически без явного кода профилирования. Ставишь агент, он цепляется к интерпретатору, шлёт телеметрию на сервер. Получаешь красивые дашборды с латентностью по endpoints, slow queries к базе, exception rates. Плата за это - подписка от $100 в месяц и overhead агента в 5-10% CPU. Для небольших проектов избыточно, для серьёзных проектов окупается экономией времени на отладку.

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

Память и сборка мусора - проблемы CPython в долгоживущих процессах



Python скрывает управление памятью за удобным фасадом "всё само почистится", и для скриптов живущих пару секунд это работает идеально. Бот который крутится месяцами без перезапуска раскрывает все слабости этой модели. Reference counting съедает память на метаданные, циклические ссылки провоцируют утечки, fragmentation превращает 2 гига реального использования в 5 гигов потребления RAM. Видел боты которые стартовали с 200 мегабайт и через неделю жрали три гига - при том что реальных данных в памяти не добавлялось.

Reference counting это основной механизм сборки мусора в CPython. Каждый объект хранит счётчик сколько ссылок на него существует. Создал переменную - счётчик увеличился, удалил или вышли из scope - уменьшился. Достиг нуля - объект удаляется моментально. Звучит эффективно, работает детерминированно, но каждая операция со ссылками добавляет overhead. Присвоить значение переменной не просто записать адрес - надо увеличить refcount у нового объекта, уменьшить у старого, возможно запустить deallocation цепочки если что-то обнулилось.

Проблема номер один - циклические ссылки которые reference counting не видит. Два объекта ссылаются друг на друга, оба имеют refcount выше нуля, но снаружи на них никто не ссылается. Классическая утечка. У меня был бот где User объект хранил ссылку на Session, Session на User - создавал пару при каждом подключении, забыл явно разорвать связь при отключении. За сутки утекло полгига памяти на мёртвые пары висящие в limbo:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Утечка памяти гарантирована
class User:
    def __init__(self, user_id):
        self.user_id = user_id
        self.session = None
 
class Session:
    def __init__(self, user):
        self.user = user
        user.session = self  # Циклическая ссылка
 
# Создаём и забываем
def handle_user():
    user = User(123)
    session = Session(user)
    # Выходим из функции - user и session должны удалиться
    # Но refcount у обоих = 1 из-за взаимных ссылок
    # Reference counting не может их почистить
 
# После тысячи вызовов - тысяча пар висит в памяти
Generational garbage collector это второй слой защиты который CPython запускает периодически для поиска циклов. Разделяет объекты на три поколения - молодое, среднее, старое. Новые объекты попадают в gen0, выжившие несколько сборок переезжают в gen1, потом в gen2. Логика в том что большинство объектов умирают молодыми - создал временную переменную в функции, вышел из scope, всё. Сборка gen0 запускается часто (каждые 700 аллокаций по умолчанию), gen2 редко (после десятков сборок младших поколений). Экономит CPU на сканирование долгоживущих объектов.

Но сборка мусора это не бесплатная операция. GC сканирует объекты, ищет циклы, помечает недостижимые, освобождает память. В момент сборки весь Python код останавливается - stop-the-world pause. Для gen0 это микросекунды, для gen2 с миллионами объектов может быть десятки миллисекунд. Бот обрабатывает webhook, в самый неудачный момент срабатывает major GC collection - латентность скачет вверх, Telegram видит таймаут, retry попытки множатся. Я ловил спайки латентности до 150 миллисекунд коррелирующие с полными GC сборками в боте на пике нагрузки.

Memory fragmentation нарастает месяцами когда аллоцируешь и освобождаешь объекты разных размеров. CPython использует свой аллокатор pymalloc для небольших объектов, он выдаёт память блоками но не всегда возвращает их операционной системе при освобождении. Получается дыра - память формально свободна внутри интерпретатора, но процесс держит её зарезервированной от OS. RSS растёт независимо от реального использования. Видел процесс бота с 4 гига RSS где фактически использовалось полтора гига по подсчётам gc.get_stats(). Weakref модуль частично решает проблему циклов через слабые ссылки которые не увеличивают refcount. Удобно для кешей и временных связей - хранишь ссылку но не мешаешь GC удалить объект когда он больше не нужен. Проблема в том что надо явно проектировать какие ссылки сильные, какие слабые. Забыл в одном месте - утечка:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import weakref
 
class CacheManager:
    def __init__(self):
        # WeakValueDictionary автоматически чистит умершие объекты
        self._cache = weakref.WeakValueDictionary()
    
    def cache_user(self, user_id, user_obj):
        self._cache[user_id] = user_obj
    
    def get_user(self, user_id):
        return self._cache.get(user_id)
 
# Объект удалится когда никто кроме кеша не держит на него ссылку
manager = CacheManager()
user = User(123)
manager.cache_user(123, user)
 
# user жив потому что переменная существует
assert manager.get_user(123) is not None
 
del user  # Удалили последнюю сильную ссылку
import gc; gc.collect()
 
# Теперь объект исчез из кеша автоматически
assert manager.get_user(123) is None
Slots в классах экономят память драматически для объектов которых много. Обычный класс хранит атрибуты в словаре __dict__, это гибко но жрёт минимум 200 байт overhead на объект. __slots__ задаёт фиксированный набор атрибутов, убирает словарь, экономит до 50% памяти. На миллионе User объектов разница между 400 мегабайтами и 200:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Тяжёлый объект с __dict__
class User:
    def __init__(self, user_id, username):
        self.user_id = user_id
        self.username = username
        self.balance = 0
 
# Лёгкий объект со slots
class OptimizedUser:
    __slots__ = ('user_id', 'username', 'balance')
    
    def __init__(self, user_id, username):
        self.user_id = user_id
        self.username = username
        self.balance = 0
 
import sys
print(sys.getsizeof(User(1, "test")))  # ~400 байт
print(sys.getsizeof(OptimizedUser(1, "test")))  # ~200 байт
objgraph визуализирует граф объектов в памяти и помогает найти утечки. Показывает что держит объекты живыми - какие ссылки мешают GC их удалить. Делаешь snapshot до и после подозрительной операции, смотришь diff - видишь какие типы объектов размножились:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import objgraph
import gc
 
# Snapshot перед операцией  
gc.collect()
before = objgraph.typestats()
 
# Подозрительная операция
for i in range(1000):
    process_user_session()
 
# Snapshot после
gc.collect()
after = objgraph.typestats()
 
# Находим что утекло
objgraph.show_growth()
# dict         +5000
# list         +3000
[H2]User         +1000[/H2]
 
# Показываем кто держит User объекты живыми
objgraph.show_backrefs(objgraph.by_type('User')[0], max_depth=5)
Явный вызов gc.collect() после тяжёлых операций помогает контролировать когда происходит сборка. Вместо спонтанных пауз в середине webhook обработки, принудительно запускаешь GC в моменты простоя. Работает если трафик неравномерный - между пиками есть затишья когда можно почистить память без вреда латентности. Для ботов с постоянной нагрузкой бесполезно, паузы неизбежны.

Периодический рестарт процесса это грубое но эффективное решение fragmentation и медленных утечек. Ставишь systemd или supervisor на автоматический graceful restart раз в сутки или при достижении лимита памяти. Процесс завершает текущие запросы, отдаёт память системе полностью, стартует свежим. Пользователи не замечают если правильно настроен zero-downtime deployment с несколькими инстансами. Я делаю rolling restart - по одному инстансу каждые три часа, за сутки обновляются все восемь не создавая простоя.

Базы данных - выбор под задачу



Нажмите на изображение для увеличения
Название: Базы данных - выбор под задачу.jpg
Просмотров: 161
Размер:	146.1 Кб
ID:	11328

PostgreSQL стал дефолтным выбором для большинства моих проектов не потому что он универсален, а потому что я ленив искать альтернативы когда что-то работает. Это срабатывало пока не наткнулся на бота для real-time аналитики где нужно было писать миллион событий в час и строить агрегации по ним. Postgres задыхался на записи, индексы раздувались до гигабайт, vacuum блокировал таблицы на минуты. Попробовал ClickHouse для этой конкретной задачи - скорость записи выросла в пятьдесят раз, агрегации считались мгновенно. Но для юзерских профилей и транзакций оставил Postgres потому что там нужны были ACID гарантии. Реляционная база это правильный выбор когда структура данных известна заранее и редко меняется. Таблицы пользователей, заказов, платежей - чёткая схема, foreign keys связывают сущности, транзакции гарантируют что деньги не потеряются при переводе. Пытался сделать систему оплаты на MongoDB - получил race condition где два параллельных процесса списали деньги дважды потому что у Mongo нет изоляции транзакций между документами. Вернулся к Postgres, обернул операции в BEGIN/COMMIT, проблема исчезла.

Но жёсткая схема становится проклятием когда данные разнородные. Бот для CRM где каждый клиент хранит свои кастомные поля - имя, email, телефон у всех, но кто-то добавляет ИНН, кто-то номер паспорта, кто-то ID в своей системе. В Postgres пришлось бы либо делать EAV таблицы (ад для запросов), либо JSONB колонку (теряешь типизацию). MongoDB с гибкой схемой документов решила это элегантно - каждый клиент это документ со своим набором полей, индексы работают даже на вложенные атрибуты.

Redis как основная база звучит безумно но для определённых кейсов работает отлично. Делал бота для онлайн-игры где состояние матча обновлялось десятки раз в секунду, читалось сотни раз. Латентность критична - игрок видит позиции противников с задержкой, проигрывает. Redis с его in-memory storage давал односекундные операции, персистентность через AOF защищала от потери данных при падении. Размер датасета был небольшой - пара гигов активных игр, влезало в RAM без проблем. Попытка делать то же на Postgres давала латентность в 20-30 миллисекунд - для игры критично. Гибридные схемы используют сильные стороны каждой БД. Postgres для транзакционных данных где важна консистентность - заказы, платежи, инвентарь. MongoDB для гибкой аналитики и логов - события пользователей, метрики, история действий. Redis для кеширования и временных данных - сессии, rate limiting, pub/sub каналы. Elasticsearch для полнотекстового поиска который в реляционных БД работает отвратительно. Каждая делает своё, синхронизация через события или периодические джобы.

Ошибка номер один - выбирать БД по хайпу а не по требованиям. "Все используют MongoDB значит и мне надо" - получаешь проблемы с консистентностью там где она критична. "NoSQL не масштабируется" - упускаешь cases где гибкая схема экономит месяцы разработки. Я начинаю с вопросов: нужны ли транзакции между сущностями? Схема фиксирована или меняется? Какой объём данных и паттерн доступа - много записей или много чтений? Есть ли требования к латентности? Ответы дают направление выбора, остальное уже детали.

PostgreSQL против MongoDB - реальные сценарии для telegram-ботов



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

Бот для магазина с корзиной это классика где Postgres показывает себя идеально. Пользователь добавляет товар в корзину, резервируешь количество на складе, создаёшь заказ, списываешь деньги - всё должно произойти атомарно или откатиться полностью. Представь ситуацию: товар зарезервирован, деньги списаны, но запись заказа в базу упала с ошибкой. Без транзакции получаешь inconsistent state где деньги ушли а заказа нет. Postgres с ACID гарантиями оборачивает всю цепочку в BEGIN/COMMIT, любой сбой откатывает всё:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import asyncpg
 
async def create_order_atomic(user_id: int, items: list, total: float):
    async with db_pool.acquire() as conn:
        async with conn.transaction():
            # Проверяем и резервируем товары
            for item in items:
                stock = await conn.fetchval(
                    "SELECT quantity FROM inventory WHERE product_id = $1 FOR UPDATE",
                    item['product_id']
                )
                
                if stock < item['quantity']:
                    raise InsufficientStock(f"Not enough {item['name']}")
                
                await conn.execute(
                    "UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2",
                    item['quantity'], item['product_id']
                )
            
            # Списываем деньги
            balance = await conn.fetchval(
                "SELECT balance FROM users WHERE id = $1 FOR UPDATE",
                user_id
            )
            
            if balance < total:
                raise InsufficientFunds()
            
            await conn.execute(
                "UPDATE users SET balance = balance - $1 WHERE id = $2",
                total, user_id
            )
            
            # Создаём заказ
            order_id = await conn.fetchval(
                "INSERT INTO orders (user_id, total, status) VALUES ($1, $2, 'pending') RETURNING id",
                user_id, total
            )
            
            # Если хоть что-то упало - всё откатывается автоматически
            return order_id
MongoDB здесь справляется хуже даже с появлением multi-document transactions в версии 4.0. Транзакции между документами медленнее чем в Postgres, ограничения жёстче, а главное - это не native для Mongo паттерн использования. Архитектура документо-ориентированной базы предполагает что большая часть данных живёт в одном документе, транзакции между документами это исключение а не правило. Для магазина с inventory management и financial transactions Postgres естественнее.

Зато бот для блога или новостного агрегатора где каждая статья это независимая сущность со своим набором полей - тут MongoDB даёт гибкость которой в Postgres нет. У одной статьи есть автор, теги, текст. У другой добавляется галерея изображений, у третьей видео, у четвёртой опрос встроенный. В реляционной модели либо делаешь кучу NULL-able колонок которые почти всегда пустые, либо городишь EAV таблицы которые превращают простой SELECT в JOIN на пять таблиц:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from motor.motor_asyncio import AsyncIOMotorClient
 
# MongoDB - гибкая схема документа
mongo_client = AsyncIOMotorClient('mongodb://localhost:27017')
db = mongo_client.blog
 
async def create_article_flexible(article_data: dict):
    # Любая структура данных без миграций схемы
    result = await db.articles.insert_one({
        'title': article_data['title'],
        'content': article_data['content'],
        'author_id': article_data['author_id'],
        'published_at': datetime.now(),
        # Опциональные поля просто включаются если есть
        **{k: v for k, v in article_data.items() 
           if k not in ['title', 'content', 'author_id']}
    })
    return result.inserted_id
 
async def get_article(article_id):
    # Получаем документ целиком со всеми полями
    article = await db.articles.find_one({'_id': article_id})
    
    # Проверяем наличие опциональных полей в рантайме
    if 'gallery' in article:
        # Обрабатываем галерею
        pass
    
    if 'poll' in article:
        # Обрабатываем опрос
        pass
    
    return article
Проблема MongoDB в том что отсутствие схемы это палка о двух концах. Удобно что можно добавить поле не меняя структуру БД, неудобно что ошибки типизации вылезают в продакшене когда один документ содержит 'price' как число, другой как строку. Валидация через JSON Schema в MongoDB 3.6+ частично решает это, но всё равно слабее чем foreign keys и check constraints в Postgres. Я видел базу где половина документов имела поле 'created_at' как timestamp, половина как строку - мигрировать этот хаос было больно.

Аналитика и агрегации на больших объёмах данных показывают разные результаты. MongoDB с aggregation pipeline удобен для сложных группировок и трансформаций данных - можно делать map-reduce прямо в запросе без загрузки в память приложения. Но производительность на миллионах документов оставляет желать лучшего, индексы раздуваются быстрее чем в Postgres. У меня был бот собирающий статистику действий пользователей - 50 миллионов событий в MongoDB занимали 80 гигов с индексами, агрегация по дням выполнялась минуты. Перенёс в Postgres с partitioning по дате - тот же объём 40 гигов, запросы быстрее в три раза. Postgres умеет в параллельные запросы и лучше оптимизирует join'ы.

Полнотекстовый поиск это больная тема для обеих баз. Postgres имеет встроенный tsvector и GIN индексы, работают прилично для простых кейсов на латинице. MongoDB предлагает text indexes которые ещё более базовые. Реально для серьёзного поиска обе уступают Elasticsearch настолько что проще держать отдельный поисковый индекс и синхронизировать через события. Пытался делать поиск по названиям товаров на Postgres - морфология ломалась, ранжирование было странное, пришлось тащить Elastic отдельно.

Драйвер asyncpg для Postgres заметно быстрее чем motor для MongoDB в плане latency. Замерял на простых операциях - asyncpg стабильно даёт 2-3 миллисекунды на SELECT, motor 5-7 миллисекунд. Накладные расходы BSON против бинарного протокола Postgres ощутимы при тысячах запросов в секунду. Но это справедливо только для простых операций, на сложных агрегациях разница нивелируется потому что основное время уходит на вычисления.

Выбор между synchronous и async драйверами баз данных



Первый бот который я писал использовал psycopg2 потому что так быстрее - скопировал код из документации Django, заработало за десять минут. Работало до первых ста пользователей онлайн, потом началась деградация которую я не понимал. Латентность ползла вверх нелинейно - на пятидесяти юзерах было 100 миллисекунд на запрос, на сотне 500, на двухстах несколько секунд. Profiler показал что 80% времени уходит на ожидание базы данных, но сами запросы выполнялись быстро. Проблема была не в SQL а в том что синхронный драйвер блокировал весь event loop на время каждого запроса к PostgreSQL.

Синхронный драйвер типа psycopg2 делает блокирующий системный вызов при каждом обращении к базе. SELECT занимает 20 миллисекунд - весь Python процесс встаёт на эти 20 миллисекунд, никакие другие корутины не выполняются. При одном пользователе незаметно, при ста одновременных запросах образуется очередь где каждый ждёт пока предыдущие отработают последовательно. Итоговая латентность складывается - последний в очереди ждёт 100 * 20ms = 2 секунды даже если база отвечает моментально.

asyncpg это асинхронный драйвер который не блокирует event loop. Отправил запрос в базу, получил Future, event loop переключился на другие задачи пока ждём ответа. Сто параллельных SELECT'ов выполняются одновременно если база справляется с concurrency, результат приходит за те же 20-30 миллисекунд что и один запрос. Миграция с psycopg2 на asyncpg подняла throughput моего бота с жалких 50 RPS до 2000 на том же железе:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Синхронный код - блокирует event loop
import psycopg2
 
async def get_user_bad(user_id: int):
    conn = psycopg2.connect(DATABASE_URL)  # Блокирующее соединение
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))  # Блокирует на 20ms
    result = cursor.fetchone()
    conn.close()
    return result
    # Event loop заморожен всё это время
 
# Асинхронный код - не блокирует
import asyncpg
 
async def get_user_good(user_id: int):
    async with pool.acquire() as conn:  # Async получение соединения
        result = await conn.fetchrow(
            "SELECT * FROM users WHERE id = $1", 
            user_id
        )  # Event loop свободен пока ждём
        return result
Но переписать все запросы с psycopg2 API на asyncpg это не просто замена импорта. Параметры передаются по-разному - %s против $1, методы другие - fetchone() против fetchrow(), транзакции оборачиваются в async context managers. У меня ушло три дня на миграцию кодовой базы в 5 тысяч строк, плюс неделя на вылавливание багов где забыл поставить await или перепутал номера параметров.

SQLAlchemy с версии 1.4 поддерживает asyncio через расширение но с болью. Создаёшь AsyncEngine вместо Engine, используешь AsyncSession вместо Session, перед каждым запросом await. ORM методы работают но требуют явного await session.execute(select(User)) вместо привычного session.query(User).all(). Миграция существующего синхронного кода на async SQLAlchemy это рефакторинг половины кодовой базы. Я предпочитаю писать сырые запросы через asyncpg и не тащить ORM overhead если приложение не сложное.

motor для MongoDB асинхронный из коробки но API идентичен pymongo с добавлением await. Миграция тривиальная - заменил клиент на AsyncIOMotorClient, расставил await перед операциями с базой, готово. Ни одной строчки логики не тронул, в отличие от psycopg2→asyncpg где пришлось переписывать синтаксис запросов.

Connection pooling критичнее для async драйверов чем для sync. Создание соединения с базой занимает 50-100 миллисекунд, делать это на каждый запрос убивает смысл асинхронности. asyncpg.create_pool() создаёт пул заранее, переиспользует соединения, экономит время и ресурсы. Размер пула зависит от нагрузки - слишком маленький создаёт очередь на соединения, слишком большой убивает базу чрезмерным concurrency. Я начинаю с 20 соединений на инстанс бота, мониторю метрики, подкручиваю если вижу проблемы. Гибридные схемы где часть операций через sync драйвер в отдельных тредах работают но усложняют архитектуру. Видел проект где тяжёлые аналитические запросы выполнялись в ThreadPoolExecutor с psycopg2, лёгкие OLTP через asyncpg в main event loop. Код выглядел как франкенштейн с двумя типами соединений, двумя пулами, проблемами синхронизации транзакций между ними. Рефакторинг на полный asyncpg занял месяц но упростил код в разы.

Connection pooling и оптимизация запросов



Создание нового подключения к PostgreSQL это дорогая операция которую новички делают в каждом обработчике сообщения. У меня был момент когда бот открывал соединение, делал один SELECT, закрывал - и так тысячу раз в минуту. Каждое подключение это TCP handshake, аутентификация, выделение серверных ресурсов. На localhost занимает 50 миллисекунд, через интернет 150-200. При ста параллельных пользователях половина времени уходила просто на установку соединений, база показывала в логах непрерывный поток CONNECT/DISCONNECT. Поставил connection pool - латентность упала с 300 до 30 миллисекунд моментально. Connection pool держит несколько открытых соединений готовыми к использованию. Нужен запрос - берёшь свободное соединение из пула, выполняешь, возвращаешь обратно. Никаких затрат на установку, соединение уже живое. asyncpg делает это элегантно через create_pool:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import asyncpg
 
class DatabaseManager:
    def __init__(self, dsn: str, min_size: int = 10, max_size: int = 20):
        self.dsn = dsn
        self.min_size = min_size
        self.max_size = max_size
        self.pool = None
    
    async def connect(self):
        # Создаём пул с минимальным и максимальным размером
        self.pool = await asyncpg.create_pool(
            self.dsn,
            min_size=self.min_size,  # Всегда держим минимум соединений
            max_size=self.max_size,  # Не создаём больше максимума
            command_timeout=60,      # Таймаут на выполнение команды
            max_queries=50000,       # Пересоздаём соединение после N запросов
            max_inactive_connection_lifetime=300  # Закрываем простаивающие
        )
    
    async def fetch_user(self, user_id: int):
        # Автоматически берёт соединение из пула
        async with self.pool.acquire() as conn:
            return await conn.fetchrow(
                "SELECT * FROM users WHERE id = $1",
                user_id
            )
        # Соединение вернулось в пул автоматически
 
    async def close(self):
        await self.pool.close()
Размер пула это балансирование между производительностью и нагрузкой на базу. Слишком маленький - запросы встают в очередь ожидая свободного соединения, latency растёт. Слишком большой - база захлёбывается от чрезмерного concurrency, начинаются locks и context switching overhead. У PostgreSQL есть параметр max_connections (обычно 100-200), и все твои боты + другие сервисы делят этот лимит между собой.

Формула грубая но работает: количество воркеров бота умножить на 2-3. Четыре инстанса бота по пять воркеров каждый = 20 воркеров, ставишь pool_size 50-60. Мониторишь метрику pool utilization - если постоянно на 90%+ значит мало, если висит на 20% значит можно урезать. Я видел проекты где ставили pool в 200 соединений "чтоб наверняка" и убивали базу которая не могла обслужить столько параллельных клиентов эффективно.

N+1 проблема это классика которую ловят все кто переходит с синхронного на асинхронный код. Загрузил список из ста заказов, для каждого нужно имя пользователя - делаешь сто дополнительных SELECT'ов. В синхронном коде это медленно, в асинхронном все сто запросов улетают параллельно но это всё равно расточительство ресурсов:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# N+1 антипаттерн - катастрофически неэффективно
async def get_orders_with_users_bad():
    async with pool.acquire() as conn:
        # Один запрос получения заказов
        orders = await conn.fetch("SELECT * FROM orders LIMIT 100")
        
        # Плюс N запросов для каждого юзера - итого 101 запрос
        result = []
        for order in orders:
            user = await conn.fetchrow(
                "SELECT username FROM users WHERE id = $1",
                order['user_id']
            )
            result.append({**order, 'username': user['username']})
        
        return result
 
# Правильно - один запрос с JOIN
async def get_orders_with_users_good():
    async with pool.acquire() as conn:
        # Один запрос получает всё сразу
        return await conn.fetch("""
            SELECT 
                o.*,
                u.username
            FROM orders o
            JOIN users u ON o.user_id = u.id
            LIMIT 100
        """)
Разница драматическая. Первый вариант делает 101 roundtrip к базе даже если они параллельны, латентность сети умножается. Второй - один roundtrip, одна передача данных, результат в разы быстрее. Видел случай где миграция всех N+1 паттернов на JOIN'ы ускорила API бота в десять раз без изменения железа.
Индексы это то что забывают добавить пока база маленькая, а потом мучаются когда она вырастает. Таблица users с миллионом записей, делаешь WHERE username = 'john' без индекса - база сканирует все миллион строк линейно. С индексом находит за микросекунды через B-tree. Разница между 200 миллисекундами и 2:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Создание индексов при инициализации базы
async def setup_indexes(conn):
    # Индекс для поиска по username
    await conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_users_username 
        ON users(username)
    """)
    
    # Составной индекс для фильтрации заказов
    await conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_orders_user_status 
        ON orders(user_id, status, created_at DESC)
    """)
    
    # Частичный индекс только для активных заказов
    await conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_orders_active 
        ON orders(user_id, created_at DESC)
        WHERE status IN ('pending', 'processing')
    """)
Но индексы это не халява - каждый жрёт дисковое пространство и замедляет INSERT/UPDATE потому что база должна обновлять индекс. Видел таблицу с двадцатью индексами "на всякий случай", она весила в пять раз больше самих данных и вставка тормозила секундами. Индексируй только те колонки которые реально используются в WHERE, JOIN, ORDER BY условиях. EXPLAIN ANALYZE покажет использует ли запрос индекс или делает sequential scan.
Батчинг операций экономит roundtrips когда нужно вставить или обновить много записей. Вместо ста отдельных INSERT делаешь один с массивом значений:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Неэффективно - N отдельных INSERT'ов
async def save_events_slow(events: list):
    async with pool.acquire() as conn:
        for event in events:
            await conn.execute(
                "INSERT INTO events (user_id, action, data) VALUES ($1, $2, $3)",
                event['user_id'], event['action'], event['data']
            )
 
# Эффективно - один batch INSERT
async def save_events_fast(events: list):
    async with pool.acquire() as conn:
        # executemany оптимизирован для батчей
        await conn.executemany(
            "INSERT INTO events (user_id, action, data) VALUES ($1, $2, $3)",
            [(e['user_id'], e['action'], e['data']) for e in events]
        )
Slow query log в PostgreSQL показывает запросы превышающие заданный threshold. Ставишь log_min_duration_statement = 100 (100ms) в postgresql.conf, все медленные запросы пишутся в лог с планом выполнения. Парсишь логи, находишь что тормозит, оптимизируешь. У меня была ситуация где один запрос с подзапросом съедал 40% CPU базы, перписал через CTE - стал в двадцать раз быстрее. Без slow query log никогда бы не нашёл этот bottleneck среди тысяч других запросов.

Батчинг операций и оптимизация сетевых запросов



Отправка сообщений пользователям это не просто вызов метода send_message() - каждый вызов это полноценный HTTPS-запрос к серверам Telegram с латентностью 50-150 миллисекунд в зависимости от географии. Когда я делал первую массовую рассылку на пять тысяч подписчиков, наивно запустил цикл который отправлял сообщения последовательно одно за другим. Математика простая: 5000 сообщений * 100ms средней латентности = 500 секунд чистого времени на сетевые запросы. Восемь с половиной минут на то чтобы просто передать данные по сети, не считая обработки. И это при идеальных условиях без rate limiting от Telegram.

Параллельная отправка через asyncio.gather() кажется очевидным решением но упирается в лимиты API мгновенно. Telegram ограничивает тридцатью сообщениями в секунду на один чат, и есть глобальный throttle на бота около 20-30 запросов в секунду для методов отправки. Попробовал запустить тысячу send_message одновременно - получил град ошибок 429 Too Many Requests, плюс половина сообщений потерялась потому что не обработал retry правильно. Пришлось городить семафор ограничивающий concurrency и очередь с rate limiting:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import asyncio
from datetime import datetime, timedelta
 
class TelegramRateLimiter:
    def __init__(self, rate_per_second: int = 25):
        self.rate = rate_per_second
        self.tokens = rate_per_second
        self.updated_at = datetime.now()
        self.lock = asyncio.Lock()
    
    async def acquire(self):
        async with self.lock:
            now = datetime.now()
            elapsed = (now - self.updated_at).total_seconds()
            
            # Добавляем токены за прошедшее время
            self.tokens = min(
                self.rate,
                self.tokens + elapsed * self.rate
            )
            self.updated_at = now
            
            # Если нет токенов - ждём
            if self.tokens < 1:
                sleep_time = (1 - self.tokens) / self.rate
                await asyncio.sleep(sleep_time)
                self.tokens = 0
            else:
                self.tokens -= 1
 
limiter = TelegramRateLimiter(rate_per_second=25)
 
async def send_with_rate_limit(bot, chat_id: int, text: str):
    await limiter.acquire()
    return await bot.send_message(chat_id, text)
 
# Массовая рассылка без перегрузки API
async def broadcast_message(bot, user_ids: list, text: str):
    tasks = [
        send_with_rate_limit(bot, uid, text) 
        for uid in user_ids
    ]
    # Ограничиваем concurrency для экономии памяти
    results = []
    for chunk in chunks(tasks, 100):
        batch_results = await asyncio.gather(*chunk, return_exceptions=True)
        results.extend(batch_results)
    
    return results
sendMediaGroup это единственный легитимный способ батчинга в Telegram API для отправки альбомов фотографий или видео. Вместо десяти отдельных вызовов sendPhoto делаешь один sendMediaGroup с массивом медиа - экономишь девять сетевых запросов. Ограничение в десять элементов на альбом, но для большинства кейсов достаточно. Я использовал это в боте для фотоотчётов где пользователь загружал серию снимков - вместо отправки каждого отдельно группировал в альбомы по десять штук, латентность упала с десяти секунд на пятьдесят фото до полутора.

Переиспользование file_id это хитрость которую не все знают. Загрузил файл в Telegram один раз, получил file_id типа "AgACAgIAAxkBAAI..." - теперь можешь отправлять этот файл тысячам пользователей передавая только ID вместо самого файла. Экономия траффика огромная плюс скорость - отправка по file_id занимает 50ms против 500-1000ms на upload нового файла каждый раз. У меня бот рассылал стикеры и картинки подписчикам, хранил file_id в базе после первой отправки, все последующие использовали ID. Единственный нюанс - file_id привязан к боту, между разными ботами не работает.

HTTP keep-alive соединения режут латентность заметно когда делаешь много последовательных запросов. По умолчанию aiohttp переиспользует соединения в рамках одной ClientSession, не создаёт новый TCP handshake на каждый запрос. Но видел код где на каждый вызов API создавалась новая сессия - получалось что каждый запрос устанавливал новое соединение с Telegram. Добавил persistent session - средняя латентность упала на 20 миллисекунд просто за счёт экономии на handshake. Debouncing помогает когда пользователь спамит одинаковыми запросами быстро. Нажимает кнопку обновления балланса пять раз подряд за секунду - вместо пяти запросов к базе делаешь один с задержкой 300 миллисекунд после последнего нажатия. Реализовал через словарь pending tasks которые отменяются если приходит новый запрос от того же юзера в течение таймаута. Снизило нагрузку на базу на 40% для особо активных пользователей которые любят кликать много раз. Retry логика с exponential backoff критична для надёжности при временных сбоях сети или rate limiting. Первая попытка упала - ждём секунду, вторая - две секунды, третья - четыре. Максимум три-пять попыток перед окончательной ошибкой. Telegram в ответе 429 возвращает заголовок Retry-After показывающий сколько секунд ждать - надо уважать это значение иначе продолжишь долбить API и получишь бан. Я встраиваю это в обёртку над всеми вызовами Telegram API чтобы не дублировать логику в каждом методе.

Оптимизация работы с API Telegram - батчинг и лимиты



Telegram Bot API живёт по своим правилам которые нигде полностью не задокументированы и меняются без предупреждения. Официально написано "30 сообщений в секунду на чат", на практике есть глобальный лимит на бота который зависит от фазы луны и настроения серверов. Я ловил бан при 25 RPS, коллега спокойно долбил 40 без проблем. Единственный способ узнать свой лимит - методично повышать нагрузку в продакшене и смотреть когда прилетают 429 ошибки.

editMessageText это скрытый пожиратель rate limit. Обновляешь прогресс-бар в сообщении каждую секунду - выглядит как один апдейт для пользователя, считается как двадцать запросов к API за двадцать секунд. У меня был бот показывающий real-time статус обработки файла, обновлял процент выполнения каждые 500 миллисекунд. Через минуту словил throttling, пользователи начали жаловаться на зависания. Сделал debouncing - обновления не чаще раза в три секунды, плюс кешировал последнее значение чтобы не слать одинаковые проценты. API calls упали в десять раз, никто из юзеров разницы не заметил.

deleteMessage жрёт лимиты наравне с отправкой хотя интуитивно кажется лёгкой операцией. Захотел реализовать самоуничтожающиеся сообщения - отправил текст, через десять секунд удалил. При ста активных пользователях это сотни delete запросов в минуту поверх обычной нагрузки. Быстро упёрся в потолок когда пики трафика совпадали. Решение оказалось в группировке удалений - вместо немедленного delete после таймаута складывал ID сообщений в очередь, батчил по десять штук с задержкой в пару секунд между батчами. Визуально пользователь всё равно видел что сообщение исчезает "примерно через десять секунд", точность не критична.

answerCallbackQuery имеет коварную особенность - его нужно вызвать в течение 30 секунд после получения callback_query иначе у пользователя крутится спиннер на кнопке бесконечно. Но засчитывается в общий rate limit как обычный метод. При активном боте с инлайн-кнопками на каждое нажатие приходится отвечать, запросов столько же сколько кликов. Оптимизация тут только через уменьшение числа кнопок требующих ответа или использование пустых ответов await query.answer() без текста - такие выполняются быстрее чем с notification.

sendChatAction это дешёвый способ показать активность без отправки сообщения. "Печатает...", "загружает фото..." - не считается в лимиты жёстко, можно слать чаще. Использую когда операция занимает несколько секунд - пользователь видит что бот работает, не думает что завис. Но действие исчезает через пять секунд, надо обновлять если обработка затягивается. Делал генерацию PDF отчёта которая занимала минуту - слал sendChatAction('upload_document') каждые четыре секунды в фоне пока генерация шла, юзер видел что процесс идёт.

Telegram хранит последние 100 Updates в очереди для каждого бота. Бот был оффлайн час, накопилось 150 сообщений - получишь только последние сто, остальные потеряны навсегда. При polling через getUpdates ты сам контролируешь offset и можешь восстановить пропущенные если они ещё в окне. При webhook если не ответил 200 OK вовремя - Telegram пробует повторить несколько раз, потом дропает Update. Я мониторю размер очереди через специальный health check эндпоинт который проверяет не отстаём ли мы в обработке. Если backlog больше пятидесяти - алерт, пора масштабироваться.

getFile для скачивания медиа от пользователей ограничен размером 20 МБ через Bot API. Больше - только через прямые ссылки которые Telegram не предоставляет ботам. Обходил это прося пользователей заливать большие файлы на Google Drive или Dropbox и кидать ссылку, но UX ужасный. В итоге добавил поддержку разбиения больших файлов на части - юзер шлёт пятьдесят мегабайт как пять архивов по десять, бот склеивает на своей стороне. Костыль но работает.

Кеширование ответов API помогает при повторяющихся запросах. getChat или getChatMember возвращают относительно статичную информацию - кешировал на пять минут в Redis. При проверке прав доступа не дёргал API каждый раз, читал из кеша. Снизило нагрузку на треть для админ-команд где проверяешь роль пользователя в группе перед выполнением действия. Главное не забыть инвалидировать кеш когда права реально меняются через события о добавлении/удалении администраторов.

Telegram Bot API limits - как обойти ограничения архитектурно



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

Множественные боты с одним token это violation правил но технически возможен. Однако лимиты привязаны к токену, не к процессу - запустишь три инстанса с одним токеном, получишь те же 30 сообщений в секунду суммарно на всех троих. Толку ноль, только headache от координации между процессами кто сколько может отправить. Видел схему где разработчик думал что масштабирование решит проблему лимитов, поднял десять серверов, все долбили одним ботом - получил ещё больше 429 ошибок потому что координация отсутствовала напрочь. Архитектурный обход через отдельных ботов для разных функций работает если это оправдано логикой. Основной бот общается с пользователями, отдельный notification bot шлёт массовые рассылки, третий admin bot для служебных задач. Каждый имеет свой токен и свой rate limit независимо. У меня такая схема в боте для магазина - клиентский бот обрабатывает заказы, notification бот шлёт уведомления о статусах, analytics bot собирает метрики. Клиентский никогда не упирается в лимиты потому что трафик предсказуемый, notification справляется с рассылками параллельно.

Очередь с приоритетами разделяет критичные и некритичные операции. Ответ пользователю на команду важнее чем отправка статистики администратору - первый должен улететь немедленно даже если rate limit близок к исчерпанию, второй может подождать. Реализовал через две отдельных очереди в Redis: high_priority со временем ожидания максимум 500 миллисекунд и low_priority откуда берём задачи только когда high пустая и есть запас по лимитам:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import asyncio
from enum import Enum
from dataclasses import dataclass
from typing import Optional
 
class Priority(Enum):
HIGH = 1
NORMAL = 2
LOW = 3
 
@dataclass
class QueuedMessage:
priority: Priority
chat_id: int
text: str
timestamp: float
 
class PriorityQueue:
def __init__(self, rate_limiter):
    self.high = asyncio.Queue()
    self.normal = asyncio.Queue()
    self.low = asyncio.Queue()
    self.limiter = rate_limiter
 
async def put(self, msg: QueuedMessage):
    # Кладём в нужную очередь по приоритету
    if msg.priority == Priority.HIGH:
        await self.high.put(msg)
    elif msg.priority == Priority.NORMAL:
        await self.normal.put(msg)
    else:
        await self.low.put(msg)
 
async def get(self) -> Optional[QueuedMessage]:
    # Берём из высокоприоритетной если есть
    if not self.high.empty():
        return await self.high.get()
    
    # Проверяем не близки ли к лимиту
    if self.limiter.tokens < 5:
        # Близко к исчерпанию - только high priority
        return None
    
    if not self.normal.empty():
        return await self.normal.get()
    
    # Low только когда запас большой
    if self.limiter.tokens > 10 and not self.low.empty():
        return await self.low.get()
    
    return None
 
# Worker который обрабатывает очередь
async def queue_worker(bot, queue: PriorityQueue):
while True:
    msg = await queue.get()
    if msg:
        await limiter.acquire()
        try:
            await bot.send_message(msg.chat_id, msg.text)
        except Exception as e:
            # При ошибке возвращаем в очередь
            if "retry" in str(e).lower():
                await asyncio.sleep(5)
                await queue.put(msg)
    else:
        await asyncio.sleep(0.1)
Token bucket с запасом защищает от burst трафика когда сто пользователей одновременно жмут одну кнопку. Без буфера первые тридцать получат ответ моментально, остальные встанут в очередь на секунды. С запасом накапливаешь неиспользованные токены до определённого максимума - пришёл всплеск, быстро отработал из резерва, потом спокойно восстанавливаешь баланс. Capacity ставлю обычно в два-три раза больше чем rate per second - rate 25, capacity 60 означает можем отправить шестьдесят сообщений моментально если накопили запас.

Кеширование агрессивное на read операциях минимизирует calls к API. getChat возвращает информацию о группе которая меняется раз в неделю - зачем дёргать каждый раз? Кеш на час вполне достаточен, обновляем только если пришло событие об изменении настроек. getChatMember для проверки прав - кеш на десять минут, инвалидация при событиях о смене администраторов. За счёт этого снизил количество API calls на сорок процентов без потери функциональности.

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

Мониторинг и отладка



Баги в продакшене обнаруживаются не через тесты а через жалобы пользователей. "Бот не отвечает", "команда не работает", "деньги списались дважды" - эти сообщения прилетают в техподдержку когда уже поздно, проблема случилась, репутация пострадала. У меня был момент когда бот молча терял заказы из-за необработанного исключения в payment handler. Пользователи оформляли заказ, деньги уходили, а запись в базу не создавалась - откат транзакции срабатывал но уведомление администратору не отправлялось. Узнал об этом через неделю когда накопилось двадцать потерянных заказов и разъярённые клиенты написали напрямую владельцу. Логов не было - exception съедался где-то в недрах try/except без записи.

Структурированное логирование через structlog спасает от хаоса plain text логов. Каждое событие это JSON с полями timestamp, level, message, плюс контекстные данные - user_id, order_id, correlation_id для трейсинга запроса через всю систему. Парсить такие логи легко через любую систему аналитики, grep по JSON работает предсказуемо, фильтрация по полям не требует regex магии:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import structlog
 
logger = structlog.get_logger()
 
async def process_order(user_id: int, order_id: int):
    logger.info(
        "order_processing_started",
        user_id=user_id,
        order_id=order_id,
        timestamp=time.time()
    )
    
    try:
        result = await create_order(order_id)
        logger.info(
            "order_created_successfully",
            user_id=user_id,
            order_id=order_id,
            amount=result.total
        )
    except PaymentError as e:
        logger.error(
            "payment_failed",
            user_id=user_id,
            order_id=order_id,
            error=str(e),
            exc_info=True
        )
        raise
Метрики важнее красивых дашбордов. Request rate, error rate, latency percentiles (p50, p95, p99), database query time, Redis operations per second - эти цифры показывают здоровье системы объективно. Prometheus с Grafana стали стандартом не потому что модно а потому что работают - экспортируешь метрики через /metrics endpoint, Prometheus скрейпит каждые пятнадцать секунд, Grafana рисует графики. У меня дашборд показывает throughput по типам команд, латентность по перцентилям, количество активных соединений к базе, размер очереди задач. Один взгляд и понятно всё ли нормально или где-то затык.

Алерты настраиваю на критичные метрики с разумными thresholds. Error rate выше одного процента - тревога в Telegram админской группы. P95 latency больше секунды - warning. Database connections близки к максимуму - предупреждение что скоро упрёмся в лимит. Главное не перегнуть с чувствительностью - если алерты срабатывают каждый час по мелочам, их начинаешь игнорировать и пропускаешь реальную проблему. Я держу баланс где алерт означает "надо смотреть прямо сейчас", не "может быть посмотри когда-нибудь". Sentry ловит необработанные исключения автоматически и группирует одинаковые ошибки вместе. Вместо тысяч записей в логах "KeyError: user_id" видишь одно событие с частотой возникновения, stack trace, список affected пользователей. Context enrichment добавляет данные о пользователе, его последних действиях, состоянии FSM - воспроизвести баг становится в разы проще. Бесплатный тариф Sentry ограничен десятью тысячами событий в месяц, для небольшого бота хватает, для крупного придётся платить или self-host.

Логирование



Уровни логирования это не просто DEBUG/INFO/ERROR для галочки. DEBUG содержит все детали выполнения - каждый SQL-запрос, каждое состояние FSM, промежуточные вычисления. Полезно при разработке, в продакшене сожрёт диск за сутки и замедлит всё на 20-30% от объёма записи. INFO для нормального flow - пользователь отправил команду, заказ создан, платёж обработан. WARNING когда что-то пошло не так но не критично - retry сработал, fallback использован, внешний API недоступен но есть кеш. ERROR для реальных проблем требующих внимания - упало исключение, транзакция откатилась, данные потеряны.

Проблема в том что граница между WARNING и ERROR размыта. Внешний API не ответил - это warning если retry поможет, или error если мы потеряли критичные данные? У меня правило простое: ERROR это когда пользователь увидел ошибку или система в inconsistent state. WARNING когда обработали gracefully без последствий для юзера. DEBUG только в development окружении, в production вообще отключен.

Контекст критически важен для понимания что происходило. Лог "Error processing message" ничего не говорит - какой пользователь, какое сообщение, в каком состоянии был бот. Добавь user_id, message_text, current_state, timestamp - картина ясна моментально. Я использую context manager который автоматически добавляет user_id ко всем логам внутри обработки запроса от этого пользователя:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import contextvars
import structlog
 
user_context = contextvars.ContextVar('user_id', default=None)
 
def add_user_context(logger, method_name, event_dict):
    # Добавляем user_id из контекста если есть
    uid = user_context.get()
    if uid:
        event_dict['user_id'] = uid
    return event_dict
 
structlog.configure(
    processors=[
        add_user_context,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer()
    ]
)
 
logger = structlog.get_logger()
 
async def handle_message(message: Message):
    # Устанавливаем контекст для всех логов ниже
    user_context.set(message.from_user.id)
    
    # Все эти логи автоматически содержат user_id
    logger.info("message_received", text=message.text)
    await process_command(message.text)
    logger.info("message_processed")
Чувствительные данные нельзя логировать даже в encrypted storage. Пароли, токены, номера карт, API keys - всё это должно маскироваться автоматически. Видел проекты где bot token лежал в plain text логах потому что кто-то залогировал полный response от API включая credentials. Реализовал sanitizer который автоматически заменяет подозрительные паттерны на звёздочки перед записью:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import re
 
class SensitiveDataFilter:
    PATTERNS = [
        (r'\b\d{16}\b', '[B][/B]-[B][/B]-[B][/B]-[B][/B]'),  # Номера карт
        (r'token["\s:=]+([^\s,"]+)', r'token: [REDACTED]'),  # Токены
        (r'password["\s:=]+([^\s,"]+)', r'password: [REDACTED]'),
        (r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[EMAIL]')
    ]
    
    def __call__(self, logger, method_name, event_dict):
        # Маскируем чувствительные данные в message
        if 'message' in event_dict:
            msg = str(event_dict['message'])
            for pattern, replacement in self.PATTERNS:
                msg = re.sub(pattern, replacement, msg)
            event_dict['message'] = msg
        return event_dict
 
structlog.configure(
    processors=[
        SensitiveDataFilter(),
        structlog.processors.JSONRenderer()
    ]
)
Ротация логов обязательна иначе диск забьётся за неделю. logrotate на уровне системы или TimedRotatingFileHandler в Python создают новый файл каждый день/час, старые архивируют и удаляют через N дней. У меня настроено хранение семи дней подробных логов, потом ещё месяц сжатых архивов для расследования инцидентов если что-то всплывёт постфактум. Старше месяца удаляется безвозвратно - хранить годами дорого и бесполезно.

Production логи отличаются от development кардинально. В dev пишу всё включая DEBUG, вывод в консоль цветной и readable. В prod только INFO и выше, формат JSON для парсинга, запись в файл и centralised logging system типа Elasticsearch. Переключение через переменную окружения LOG_LEVEL:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import os
import logging.config
 
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
 
LOGGING = {
    'version': 1,
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': LOG_LEVEL,
        },
        'file': {
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': 'logs/bot.log',
            'when': 'midnight',
            'interval': 1,
            'backupCount': 7,
            'level': 'INFO',
        }
    },
    'root': {
        'level': LOG_LEVEL,
        'handlers': ['console', 'file']
    }
}
 
logging.config.dictConfig(LOGGING)
Correlation ID связывает логи одного запроса через всю систему. Webhook пришёл, обработался через пять сервисов, каждый писал логи - как понять что это всё один запрос пользователя? Генерируешь UUID при получении Update, передаёшь через все слои, логируешь везде. Потом grep по этому ID показывает полную картину обработки. Я храню correlation_id в контексте и добавляю автоматически ко всем логам аналогично user_id.

Производительность логирования может быть неожиданно плохой. Запись на диск это I/O операция которая блокирует если делать синхронно. При тысячах запросов в секунду logging становится bottleneck. Решение через async logging - логи пишутся в очередь в памяти, отдельный поток сливает их на диск батчами. Теряешь последние несколько записей если процесс убьют без graceful shutdown, но производительность растёт на порядок.

Метрики и алерты



Красивые графики в Grafana это хорошо для презентаций руководству, но реальная ценность метрик проявляется когда в три часа ночи прилетает алерт "latency p95 превысила 2 секунды" и ты за пять минут находишь что конкретно сломалось. У меня был период когда я собирал метрики просто потому что "так надо", смотрел на графики раз в неделю, радовался что всё зелёное. Пока однажды пользователи не начали жаловаться что бот тормозит уже второй день подряд. Открыл дашборд - действительно, latency выросла в три раза, но я этого не заметил потому что алертов не настроил. С тех пор правило простое: любая метрика которую собираешь должна иметь алерт на критичные значения, иначе она бесполезна.

Prometheus с его pull-model стал стандартом не случайно. Экспортируешь метрики через /metrics endpoint в формате который он понимает, настраиваешь scrape каждые пятнадцать секунд, получаешь time series базу из коробки. Counter для событий которые только растут - количество обработанных сообщений, отправленных уведомлений, ошибок. Gauge для значений которые колеблются - активные соединения к базе, размер очереди задач, использование памяти. Histogram для распределения latency - не просто среднее время ответа которое ничего не говорит, а перцентили показывающие реальный user experience:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from prometheus_client import Counter, Gauge, Histogram, start_http_server
import time
 
# Счетчики событий
messages_total = Counter(
    'bot_messages_total',
    'Total messages processed',
    ['command', 'status']  # Лейблы для группировки
)
 
# Текущие значения
active_users = Gauge('bot_active_users', 'Currently active users')
queue_size = Gauge('bot_queue_size', 'Messages waiting in queue')
 
# Распределение времени обработки
request_duration = Histogram(
    'bot_request_duration_seconds',
    'Time spent processing request',
    buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0]  # Бакеты в секундах
)
 
# Декоратор для автоматического измерения времени
def track_time(func):
    async def wrapper(*args, **kwargs):
        start = time.time()
        try:
            result = await func(*args, **kwargs)
            messages_total.labels(command=func.__name__, status='success').inc()
            return result
        except Exception as e:
            messages_total.labels(command=func.__name__, status='error').inc()
            raise
        finally:
            duration = time.time() - start
            request_duration.observe(duration)
    return wrapper
 
@track_time
async def handle_start_command(message):
    active_users.inc()
    try:
        await process_message(message)
    finally:
        active_users.dec()
 
# Запускаем HTTP сервер для Prometheus
start_http_server(8000)  # Метрики доступны на :8000/metrics
Но собирать всё подряд это путь к аду неструктурированных данных. На одном проекте я экспортировал сорок метрик включая количество символов в каждом сообщении, температуру сервера и фазу луны. Дашборд превратился в мозаику из восьмидесяти графиков где невозможно было найти нужное. Оставил только то что реально использую для принятия решений - throughput по типам команд, error rate, latency (p50/p95/p99), database query time, cache hit rate, queue backlog. Семь метрик говорят больше чем сорок случайных.

Алерты настраиваются через Alertmanager в экосистеме Prometheus или через встроенные механизмы Grafana. Правило простое - если метрика превысила порог больше минуты, шлём уведомление. Не моментально потому что кратковременные спайки случаются постоянно - GC сработала, кто-то запустил тяжёлый запрос, сеть дёрнулась. Но если latency держится высокой минуту подряд - это уже паттерн требующий внимания:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
groups:
name: bot_alerts
  interval: 30s
  rules:
  - alert: HighLatency
    expr: histogram_quantile(0.95, rate(bot_request_duration_seconds_bucket[1m])) > 2
    for: 1m
    labels:
      severity: warning
    annotations:
      summary: "Высокая задержка обработки запросов"
      description: "P95 latency {{ $value }} секунд"
 
  - alert: ErrorRateHigh  
    expr: rate(bot_messages_total{status="error"}[5m]) / rate(bot_messages_total[5m]) > 0.05
    for: 2m
    labels:
      severity: critical
    annotations:
      summary: "Высокий процент ошибок"
      description: "{{ $value | humanizePercentage }} запросов падают с ошибкой"
Проблема ложных срабатываний решается через правильные thresholds и временные окна. Если алерт орёт каждый час по пустякам - его отключат и пропустят реальную проблему. Я подбираю пороги глядя на исторические данные за месяц - смотрю какие значения были при нормальной работе, добавляю запас 50-100%, это становится warning threshold. Ещё плюс сто процентов - critical. Для latency например p95 обычно держится на 200-300 миллисекундах, warning ставлю на секунду, critical на две. Для error rate нормально меньше 0.1%, warning на 1%, critical на 5%.

Custom метрики для специфики ботов показывают то что стандартные не видят. Бот для игры трекает количество активных матчей, среднюю длительность партии, distribution игроков по уровням. Бот для магазина - conversion rate от просмотра к покупке, средний чек, abandoned carts. Экспортирую через те же prometheus_client метрики, только semantic specific для домена. Видел график abandoned carts который показал что половина пользователей бросает оформление на этапе ввода адреса - переделали форму на более простую, conversion вырос на треть.

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



Когда весь бот крутится в одном процессе, отладка выглядит просто - поставил breakpoint, прошёлся по стеку вызовов, нашёл где упало. Разбил на пять микросервисов - и внезапно понять где конкретно сломалось становится квестом. Запрос пользователя прошёл через gateway, попал в order service, тот дёрнул payment service, payment обратился к inventory service, где-то на этом пути всё упало с timeout. Логи каждого сервиса показывают свою часть истории, но связать их в единую картину без специальных инструментов невозможно. Я потратил три часа на поиск бага который оказался в четвёртом по цепочке сервисе - перебирал логи вручную, сопоставлял timestamps, пытался угадать какие записи относятся к одному запросу.

Distributed tracing решает это через correlation ID который проходит через всю цепочку вызовов. Каждый запрос получает уникальный trace_id при входе в систему, этот ID передаётся всем сервисам которые обрабатывают запрос, логируется на каждом этапе. Потом берёшь этот trace_id и видишь полный путь - когда запрос пришёл в gateway, сколько времени провёл в каждом сервисе, где именно упал. Вместо трёх часов поиска - тридцать секунд взглянуть на trace timeline и увидеть что payment service ждал ответа от банка две с половиной секунды из трёх общего времени обработки.

Простейшая реализация через HTTP headers работает для базовых кейсов. Генерируешь UUID при получении webhook от Telegram, засовываешь в заголовок X-Trace-ID, каждый сервис читает этот заголовок из входящего запроса и передаёт дальше при вызове других сервисов:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import uuid
import aiohttp
from contextvars import ContextVar
 
trace_context = ContextVar('trace_id', default=None)
 
async def handle_webhook(request):
    # Генерим trace_id для нового запроса
    trace_id = str(uuid.uuid4())
    trace_context.set(trace_id)
    
    # Логируем с trace_id
    logger.info("webhook_received", trace_id=trace_id)
    
    # Обрабатываем запрос
    update = await request.json()
    await process_update(update)
 
async def call_payment_service(order_data):
    # Получаем trace_id из контекста
    trace_id = trace_context.get()
    
    async with aiohttp.ClientSession() as session:
        # Передаём trace_id следующему сервису
        headers = {'X-Trace-ID': trace_id}
        async with session.post(
            'http://payment-service/process',
            json=order_data,
            headers=headers
        ) as resp:
            logger.info(
                "payment_service_called",
                trace_id=trace_id,
                status=resp.status
            )
            return await resp.json()
 
# В payment service читаем заголовок
async def payment_handler(request):
    # Извлекаем trace_id из headers
    trace_id = request.headers.get('X-Trace-ID')
    trace_context.set(trace_id)
    
    logger.info("payment_processing", trace_id=trace_id)
    # Дальше обрабатываем с тем же trace_id
Проблема в том что это даёт только correlation но не показывает timing и dependencies визуально. Приходится парсить логи, сортировать по trace_id, вручную вычислять длительность каждого этапа. Jaeger или Zipkin автоматизируют это через spans - каждый сервис создаёт span при начале операции, закрывает при завершении, шлёт данные в collector. UI показывает красивый timeline где видно какие сервисы вызывались параллельно, какие последовательно, где были задержки.

Но интеграция Jaeger требует инструментирования всего кода. Каждая точка входа должна создавать span, каждый вызов другого сервиса должен быть обёрнут в child span, а это десятки строк boilerplate кода размазанные по всей кодовой базе. Я видел проекты где треть кода было instrumentation для tracing, реальная бизнес-логика терялась среди создания и закрытия spans. OpenTelemetry упрощает это через автоматическое инструментирование популярных библиотек - aiohttp, asyncpg, Redis clients оборачиваются автоматически, spans создаются без явного кода.

Sampling критичен для продакшена когда трафик большой. Трейсить каждый запрос при десятках тысяч RPS создаёт overhead на сеть и хранилище - Jaeger collector захлёбывается, база раздувается терабайтами за неделю. Probability sampling трейсит только процент запросов - один из ста, один из тысячи. Достаточно чтобы поймать паттерны и аномалии, не убивает производительность. Адаптивный sampling умнее - увеличивает частоту для медленных или ошибочных запросов, снижает для быстрых успешных. Если запрос выполнился за двадцать миллисекунд без ошибок - скорее всего не трейсим, если за три секунды с ретраями - обязательно сохраняем trace для анализа.

Отлов багов в продакшене



Баги в разработке находятся за минуты через debugger и print-statements. В продакшене они прячутся неделями пока не накопится критическая масса жалоб пользователей. У меня был случай когда бот работал полгода без нареканий, потом вдруг начались жалобы что команды не выполняются. Попытался воспроизвести локально - всё работало идеально. Оказалось проблема проявлялась только при определённой последовательности действий от конкретной группы пользователей с кириллицей в username. В development окружении я тестировал только с латинскими именами, в продакшене двадцать процентов юзеров были с кириллицей и именно они ловили exception на encoding.

Exception tracking через Sentry это первая линия обороны против невидимых багов. Любое необработанное исключение автоматически уходит в Sentry с полным stack trace, переменными в момент падения, breadcrumbs показывающими что происходило перед ошибкой. Grouping одинаковых ошибок показывает частоту - если KeyError появился один раз это может быть случайность, если сотня раз за час то систематическая проблема требующая фикса:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import sentry_sdk
from sentry_sdk.integrations.aiohttp import AioHttpIntegration
 
# Инициализация при старте бота
sentry_sdk.init(
    dsn="https://xxx@sentry.io/yyy",
    traces_sample_rate=0.1,  # 10% запросов для performance monitoring
    environment="production",
    release=f"bot@{VERSION}"
)
 
# Автоматический захват исключений
async def process_order(order_data):
    try:
        # Бизнес-логика
        result = await create_order(order_data)
    except Exception as e:
        # Exception автоматически уйдёт в Sentry
        # Добавляем контекст для более полной картины
        sentry_sdk.set_context("order", {
            "user_id": order_data.get('user_id'),
            "items_count": len(order_data.get('items', [])),
            "total": order_data.get('total')
        })
        
        # Добавляем breadcrumb для истории действий
        sentry_sdk.add_breadcrumb(
            category='order',
            message='Order creation failed',
            level='error'
        )
        
        raise  # Прокидываем дальше после логирования
 
# Для критичных мест явно шлём в Sentry даже обработанные ошибки
async def charge_payment(amount, user_id):
    try:
        result = await payment_gateway.charge(amount)
        return result
    except PaymentGatewayTimeout as e:
        # Обработали но хотим знать что таймауты случаются
        sentry_sdk.capture_exception(e)
        # Используем fallback
        return await retry_with_alternative_gateway(amount)
Проблема в том что Sentry показывает только exceptions которые дошли до top level или были явно захвачены. Silent failures где код проглатывает ошибку без re-raise остаются невидимыми. Видел бота где try/except блок молча ловил все исключения при сохранении в базу и просто возвращал False - пользователь видел "операция выполнена" хотя ничего не сохранилось, а в Sentry тишина потому что exception обработан. Пришлось добавить явный capture_exception внутри except блока.

Feature flags помогают изолировать новый код и быстро откатывать если что-то сломалось. Раскатываешь новую фичу только пяти процентам пользователей, мониторишь error rate специфично для этой группы. Видишь рост ошибок - выключаешь флаг одной кнопкой, откат мгновенный без deployment. У меня была ситуация когда новый алгоритм рекомендаций работал отлично на тестах но в продакшене начал падать с OutOfMemory на определённых паттернах данных. Feature flag позволил отключить его за минуту пока разбирался в чём дело, основной функционал продолжал работать.

Воспроизведение production багов локально это квест потому что окружения отличаются радикально. База в продакшене содержит миллионы записей с edge cases которых нет в dev данных. Сетевые задержки другие, concurrency уровень несопоставим. Записываю production traffic через middleware который логирует все входящие Updates от Telegram со всеми параметрами. Когда прилетает баг репорт от юзера, нахожу его Updates в логах за нужное время, replay их локально через тестовый скрипт имитирующий Telegram API - часто этого достаточно чтобы воспроизвести проблему.

User feedback loop критически важен для обнаружения багов которые не вызывают exceptions. Бот выдал неправильный результат, посчитал сумму неверно, показал не те данные - exception не будет, в Sentry тишина, но пользователь недоволен. Добавил кнопку "Сообщить о проблеме" которая моментально шлёт мне в админский чат детали последних десяти действий пользователя, его состояние FSM, последний результат операции. Половина багов которые нашёл за последний квартал пришли через этот feedback механизм, не через мониторинг.

Canary deployments раскатывают новую версию постепенно начиная с одного сервера из десяти. Десять процентов трафика идёт на новую версию, девяносто на старую проверенную. Мониторишь error rate, latency, business метрики специфично для canary инстанса. Если всё чисто после часа работы - раскатываешь на следующие серверы волнами. Поймал рост ошибок на canary - откатываешь только этот один сервер, остальные девять продолжают стабильно работать на старой версии. Потерял один процент пользователей вместо ста при сломанном full deploy.

Конфигурация через переменные окружения и secrets management



Хардкод токенов прямо в коде это классика которую делает каждый на первом проекте. Я не исключение - скопировал bot token из BotFather, вставил в строку BOT_TOKEN = "1234567890:ABC...", закоммитил в git, запушил в публичный репозиторий на GitHub. Через два часа бот перестал отвечать - кто-то нашёл токен через поиск по GitHub, использовал для спама, Telegram забанил бота. Пришлось создавать нового через BotFather, менять токен везде, чистить историю коммитов чтобы старый токен не светился. Урок усвоил жёстко - секреты не должны касаться системы контроля версий вообще никогда.

Переменные окружения это минимальный уровень защиты который работает из коробки. Вместо хардкода читаешь значение через os.getenv(), токен живёт вне кода в окружении процесса. Локально экспортируешь через shell, на сервере прописываешь в systemd unit или docker-compose. Просто, понятно, не требует дополнительных зависимостей:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
import os
 
# Никогда так
BOT_TOKEN = "1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
 
# Всегда так
BOT_TOKEN = os.getenv('BOT_TOKEN')
if not BOT_TOKEN:
    raise ValueError("BOT_TOKEN environment variable is required")
 
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://localhost/bot_db')
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
Проблема в том что переменные окружения видны любому процессу в системе через /proc/PID/environ на Linux. Запустил ps aux | grep python - видишь аргументы командной строки где могут быть токены. Логируешь environment при старте для отладки - секреты попадают в plain text файлы. У меня был incident когда системный администратор запустил скрипт мониторинга который собирал переменные окружения всех процессов и складывал в центральный лог. Через неделю обнаружили что все bot tokens, database credentials, API keys лежат в открытом виде в этом логе доступном десятку человек.

.env файлы с python-dotenv упрощают управление локально но добавляют риски. Создаёшь .env файл с парами ключ-значение, библиотека загружает их как переменные окружения при старте. Удобно для разработки - не надо экспортировать руками, просто создал файл и готово. Но .env это обычный текстовый файл который легко случайно закоммитить в git если не добавил в .gitignore:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# .env файл в корне проекта
BOT_TOKEN=1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
DATABASE_URL=postgresql://user:password@localhost/bot_db
REDIS_URL=redis://localhost:6379/0
SENTRY_DSN=https://xxx@sentry.io/yyy
 
# Загрузка в Python коде
from dotenv import load_dotenv
import os
 
# Грузим переменные из .env перед использованием
load_dotenv()
 
BOT_TOKEN = os.getenv('BOT_TOKEN')
Я добавляю .env в .gitignore сразу при создании проекта, плюс создаю .env.example с пустыми значениями как шаблон для других разработчиков. Видел проекты где забыли .gitignore, залили .env в репозиторий, потом пытались удалить через новый коммит - но история git сохраняет всё, токены остались видны в старых коммитах навсегда. Единственное спасение - сменить все скомпрометированные секреты и force push с переписанной историей, что ломает репозиторий для всей команды.

Secrets managers решают проблему хранения и ротации централизованно. HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Cloud Secret Manager - все работают по схожему принципу: секреты живут в encrypted storage, доступ контролируется через политики, аудит встроенный, ротация автоматическая. Приложение обращается к API secrets manager'а с аутентификацией, получает секрет в runtime, использует. Секреты никогда не касаются файловой системы или git репозитория.

AWS Secrets Manager я использовал на одном проекте размещённом в EC2. Instance profile даёт автоматическую аутентификацию через IAM роль, код просто вызывает API без явных credentials. Секреты кешируются на пять минут в памяти приложения чтобы не долбить API на каждый запрос:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import boto3
from botocore.exceptions import ClientError
import json
from datetime import datetime, timedelta
 
class AWSSecretsManager:
    def __init__(self, region_name='us-east-1'):
        self.client = boto3.client('secretsmanager', region_name=region_name)
        self.cache = {}
        self.cache_ttl = timedelta(minutes=5)
    
    def get_secret(self, secret_name: str) -> dict:
        # Проверяем кеш
        if secret_name in self.cache:
            cached_at, value = self.cache[secret_name]
            if datetime.now() - cached_at < self.cache_ttl:
                return value
        
        try:
            # Получаем секрет из AWS
            response = self.client.get_secret_value(SecretId=secret_name)
            secret_value = json.loads(response['SecretString'])
            
            # Кешируем
            self.cache[secret_name] = (datetime.now(), secret_value)
            return secret_value
        except ClientError as e:
            if e.response['Error']['Code'] == 'ResourceNotFoundException':
                raise ValueError(f"Secret {secret_name} not found")
            raise
 
# Использование
secrets = AWSSecretsManager()
bot_config = secrets.get_secret('production/telegram-bot')
 
BOT_TOKEN = bot_config['bot_token']
DATABASE_URL = bot_config['database_url']
Ротация секретов это то что все знают что надо делать но мало кто реально делает. Поменять bot token значит обновить код на всех серверах одновременно иначе часть инстансов перестанет работать. Database password ещё хуже - надо координировать смену на стороне PostgreSQL и во всех приложениях использующих эту базу. AWS Secrets Manager поддерживает автоматическую ротацию через Lambda функции которые меняют секрет и обновляют зависимые сервисы, но настроить это нетривиально.

Я делаю ротацию критичных секретов раз в квартал вручную в scheduled maintenance window. Некритичные типа API keys сторонних сервисов - раз в год или при подозрении на компрометацию. Bot token меняю только если есть реальные признаки утечки потому что это больно - надо обновлять везде включая документацию, скрипты деплоя, резервные системы. За три года поменял токен трижды - один раз после утечки в git, дважды превентивно после смены членов команды с доступом.

Разные окружения требуют разных секретов. Development использует тестовый бот с отдельным токеном, staging окружение имеет копию production данных но другие credentials, production содержит реальные секреты. Храню их в отдельных префиксах secrets manager - dev/telegram-bot, staging/telegram-bot, production/telegram-bot. Код определяет окружение через переменную ENVIRONMENT и подтягивает нужный набор секретов:

Python
1
2
3
4
5
6
7
8
9
import os
 
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development')
 
def get_secret_name(key: str) -> str:
    return f"{ENVIRONMENT}/telegram-bot/{key}"
 
# В зависимости от ENVIRONMENT подтянутся разные секреты
bot_token = secrets.get_secret(get_secret_name('bot_token'))
Проблема когда secrets manager недоступен при старте приложения. AWS Secrets Manager может лежать, сеть может глючить, rate limits исчерпаны - а бот пытается запуститься и падает потому что не может получить токен. Делаю fallback на переменные окружения для критичных секретов - сначала пробую загрузить из secrets manager, не получилось читаю из env vars. Не идеально с точки зрения безопасности но лучше чем полный downtime:

Python
1
2
3
4
5
6
7
8
9
10
11
12
def get_bot_token():
    try:
        # Пробуем secrets manager сначала
        config = secrets.get_secret('production/telegram-bot')
        return config['bot_token']
    except Exception as e:
        logger.warning(f"Failed to load from secrets manager: {e}")
        # Fallback на environment variable
        token = os.getenv('BOT_TOKEN')
        if not token:
            raise RuntimeError("Cannot load bot token from any source")
        return token
Audit logging в secrets managers показывает кто и когда получал доступ к секретам. AWS CloudTrail логирует каждый вызов GetSecretValue с user identity, timestamp, IP адресом. Мониторю эти логи на аномалии - внезапный доступ к секрету с нового IP адреса или в необычное время может означать компрометацию credentials. Один раз поймал так что кто-то пытался достучаться к production секретам с development окружения - оказалось разработчик случайно прописал production secret name в dev конфиге, но IAM политика запретила доступ.

Тестирование и CI/CD для production-ready решения



Нажмите на изображение для увеличения
Название: Тестирование и CICD для production-ready решения.jpg
Просмотров: 148
Размер:	129.7 Кб
ID:	11329

Тестирование telegram-ботов это квест уровня hard потому что половину функционала невозможно протестировать без живого Telegram API. Мокать каждый вызов send_message руками превращается в boilerplate который занимает больше строк чем сам тест. Первый бот я вообще не тестировал автоматически - запускал локально, руками тыкал команды, смотрел работает ли. Казалось быстрее чем писать тесты. Работало пока кодовая база не выросла до пяти тысяч строк - тогда каждое изменение ломало что-то неожиданное, регресс-баги вылезали через неделю после деплоя, пользователи находили проблемы раньше меня.

Unit-тесты для бизнес-логики пишутся нормально если правильно отделил её от Telegram API. Функция которая валидирует промокод, вычисляет скидку, проверяет остатки на складе - всё это можно тестировать без моков telegram-специфики. Проблема когда логика размазана по handlers и завязана на Message объекты напрямую. Тогда каждый тест должен создавать фейковый Message со всеми вложенными полями, получается монстр на пятьдесят строк setup кода ради проверки одной функции.

Я выношу всю логику в отдельные сервисы которые не знают про aiogram или python-telegram-bot вообще. Принимают простые типы - user_id, promo_code, items список - возвращают результат. Тестировать такое одно удовольствие, моки не нужны:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pytest
from services.promo import PromoService, PromoNotFound
 
@pytest.fixture
def promo_service():
    # Простой сервис без зависимостей от Telegram
    return PromoService()
 
def test_valid_promo_returns_discount(promo_service):
    result = promo_service.validate("SUMMER2024")
    assert result.is_valid
    assert result.discount == 0.20
 
def test_expired_promo_returns_error(promo_service):
    result = promo_service.validate("WINTER2023")
    assert not result.is_valid
    assert "expired" in result.error
 
def test_invalid_promo_raises_exception(promo_service):
    with pytest.raises(PromoNotFound):
        promo_service.validate("FAKE999")
Моки Telegram API всё равно нужны для integration тестов где проверяешь что handler правильно вызывает send_message с нужными параметрами. pytest-aiohttp и aiogram.utils.testing помогают, но это всё равно куча setup кода. Делаю фабрики для частоиспользуемых объектов чтобы не дублировать создание фейковых Message:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from aiogram.types import User, Chat, Message
 
class MessageFactory:
    @staticmethod
    def create_message(
        text="/start",
        user_id=12345,
        chat_id=12345,
        username="testuser"
    ):
        user = User(
            id=user_id,
            is_bot=False,
            first_name="Test",
            username=username
        )
        chat = Chat(id=chat_id, type="private")
        
        return Message(
            message_id=1,
            date=datetime.now(),
            chat=chat,
            from_user=user,
            text=text
        )
 
# В тестах использование проще
def test_start_command():
    msg = MessageFactory.create_message("/start")
    # Дальше тест...
Проблема с асинхронными тестами что pytest требует отдельного плагина pytest-asyncio и маркировки каждого теста как async. Забыл поставить декоратор - тест просто скипается молча, думаешь всё зелёное а на самом деле не запустилось. Плюс async fixtures требуют специального синтаксиса, обычные не работают если внутри await.

Database fixtures для тестов создаю через отдельную тестовую базу которая поднимается перед тестами и чистится после. Docker-compose с PostgreSQL контейнером удобен - один command создаёт чистое окружение, тесты заливают fixtures через alembic миграции или прямыми INSERT'ами, после прогона контейнер убивается. Никаких следов на production базе, полная изоляция:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pytest
import asyncpg
from alembic import command
from alembic.config import Config
 
@pytest.fixture(scope="session")
async def test_db():
    # Поднимаем тестовую БД
    pool = await asyncpg.create_pool(
        "postgresql://test:test@localhost:5433/test_db"
    )
    
    # Накатываем миграции
    alembic_cfg = Config("alembic.ini")
    command.upgrade(alembic_cfg, "head")
    
    yield pool
    
    # Чистим после тестов
    await pool.close()
 
@pytest.fixture
async def clean_db(test_db):
    # Перед каждым тестом чистим таблицы
    async with test_db.acquire() as conn:
        await conn.execute("TRUNCATE users, orders, products CASCADE")
    yield test_db
CI/CD пайплайн я настраиваю через GitHub Actions потому что бесплатно для публичных репозиториев и интегрировано прямо в GitHub. Каждый push запускает линтеры, тесты, если всё зелёное - автоматический деплой на staging. На master ветке после мержа pull request - деплой в production но с manual approval через GitHub Environments. Видел проекты на GitLab CI и Jenkins, работают похоже но требуют больше настройки инфраструктуры.

Секрет в том что CI должен быть быстрым иначе его начинают скипать. Если тесты идут десять минут, разработчики перестанут ждать результатов перед мержом. Оптимизирую через параллельный запуск тестов, кеширование зависимостей, использование matrix builds для разных Python версий одновременно. Мои тесты выполняются за две минуты - unit за тридцать секунд, integration за минуту с копейками. Приемлемо, никто не жалуется.

Blue-green deployment использую для zero-downtime релизов. Две группы серверов - blue активные, green неактивные. Деплоишь новую версию на green, прогоняешь smoke tests, переключаешь load balancer - теперь green обрабатывает трафик, blue в standby. Что-то пошло не так - откат одной кнопкой обратно на blue. За секунды вместо раскатывания изменений по всем серверам с возможным downtime.

Главное что понял - тесты пишутся не для галочки а потому что экономят время на отладку багов в продакшене. Час потраченный на написание тестов сейчас сэкономит три часа поиска бага через неделю. Не тестирую абсолютно всё - покрытие 80-90% критичной логики достаточно, остальное проверяется в staging перед релизом.

Демонстрационная архитектура: модульный бот-агрегатор



Выбрал эту задачу потому что она покрывает все паттерны о которых писал выше - событийная архитектура для синхронизации между модулями, очереди для отложенных публикаций, кеширование статистики, FSM для multi-step форм создания контента, rate limiting потому что публикация в пять каналов одновременно это сто пятьдесят запросов к API за минуту. Плюс реальная необходимость в модульности - новый канал добавляется как plugin без изменения core кода.

Строгое разделение слоёв где handlers не знают про базу данных, services не знают про Telegram API, repositories инкапсулируют всю работу с PostgreSQL. Dependency injection через простой контейнер без магии автоматического связывания - явно регистрируешь зависимости при старте, явно инжектишь в нужные места. Event bus для развязки модулей - публикация контента генерирует событие, analytics модуль подписан и обновляет метрики, notification модуль шлёт алерты администраторам. Всё это без прямых вызовов между модулями.

Код занимает около двух тысяч строк чистого Python не считая тестов и конфигурационных файлов. Достаточно чтобы показать все важные паттерны, но не настолько много чтобы теряться в деталях. Каждый компонент делает одну вещь хорошо - router маршрутизирует команды, service выполняет бизнес-логику, repository работает с данными, event bus координирует взаимодействие. Дальше разберу каждую часть детально с обоснованием почему именно так а не иначе.

Общая структура проекта



Структура директорий это то с чего начинается порядок или хаос в проекте. Видел боты где всё лежит в одной папке - handlers.py на пять тысяч строк рядом с models.py, utils.py, services.py и main.py. Найти нужный код превращается в ctrl+F марафон по всей кодовой базе. Рефакторить страшно потому что непонятно что от чего зависит и что сломается при изменении.

Я выстраиваю иерархию вокруг bounded contexts из DDD - каждый логический модуль живёт в своей директории со всем необходимым внутри. Content модуль содержит handlers для создания публикаций, services бизнес-логики, repositories для работы с базой. Analytics модуль полностью отдельно - свои handlers, services, repositories. Хочешь понять как работает аналитика - открываешь одну папку, всё там. Не надо прыгать между десятком файлов размазанных по проекту:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
bot-aggregator/
├── src/
│   ├── core/                    # Ядро системы
│   │   ├── __init__.py
│   │   ├── config.py            # Конфигурация из env
│   │   ├── container.py         # DI контейнер
│   │   ├── database.py          # Пулы соединений
│   │   ├── event_bus.py         # Шина событий
│   │   └── exceptions.py        # Базовые исключения
│   │
│   ├── modules/                 # Бизнес-модули
│   │   ├── content/            # Управление контентом
│   │   │   ├── handlers.py     # Telegram handlers
│   │   │   ├── services.py     # Бизнес-логика
│   │   │   ├── repositories.py # Работа с БД
│   │   │   ├── models.py       # Domain модели
│   │   │   └── events.py       # События модуля
│   │   │
│   │   ├── channels/           # Управление каналами
│   │   │   ├── handlers.py
│   │   │   ├── services.py
│   │   │   ├── repositories.py
│   │   │   └── plugin.py       # Plugin interface
│   │   │
│   │   ├── analytics/          # Статистика и метрики
│   │   │   ├── handlers.py
│   │   │   ├── services.py
│   │   │   ├── collectors.py   # Сбор метрик
│   │   │   └── aggregators.py  # Агрегация данных
│   │   │
│   │   └── scheduler/          # Отложенные публикации
│   │       ├── tasks.py        # Celery задачи
│   │       ├── services.py
│   │       └── queue.py        # Priority queue
│   │
│   ├── infrastructure/          # Инфраструктурный слой
│   │   ├── telegram/           # Telegram API клиент
│   │   │   ├── client.py
│   │   │   └── rate_limiter.py
│   │   │
│   │   ├── cache/              # Кеширование
│   │   │   ├── redis_cache.py
│   │   │   └── strategies.py
│   │   │
│   │   └── monitoring/         # Метрики и логи
│   │       ├── metrics.py
│   │       └── logger.py
│   │
│   └── main.py                  # Точка входа
│
├── migrations/                  # Alembic миграции
│   └── versions/
│
├── tests/                       # Тесты
│   ├── unit/
│   ├── integration/
│   └── fixtures/
│
├── docker-compose.yml          # Локальное окружение
├── Dockerfile                  # Production образ
├── requirements.txt            # Зависимости
└── alembic.ini                # Конфиг миграций
Core директория это фундамент над которым строится всё остальное. Config загружает переменные окружения и валидирует их через pydantic - если забыл BOT_TOKEN программа упадёт при старте с понятной ошибкой, а не через час работы где-то в недрах кода. Container регистрирует все зависимости которые нужны модулям - database pools, Redis клиент, event bus, rate limiter. Database создаёт asyncpg pool с правильными параметрами connection pooling. Event bus координирует асинхронную коммуникацию между модулями без прямых зависимостей.

Modules это где живёт вся бизнес-логика приложения. Каждый модуль независим от других в плане файловой структуры - можешь переименовать, удалить, добавить новый не трогая остальные. Handlers содержат только маршрутизацию команд и минимальную валидацию входящих данных. Services реализуют use cases - создать публикацию, запланировать пост, собрать статистику. Repositories инкапсулируют SQL запросы чтобы services не знали про asyncpg или структуру таблиц.

Проблема когда слои размываются и services начинают импортить repositories напрямую создавая coupling. У меня было так что ContentService импортил ChannelRepository прямым импортом from modules.channels.repositories import ChannelRepository. Работало но при попытке переместить channels модуль в отдельный микросервис обнаружил сотню прямых импортов которые надо было рефакторить. Теперь все зависимости inject через конструктор service - передаю repository как параметр при создании, service не знает откуда он пришёл и где находится физически.

Infrastructure слой изолирует внешние зависимости от бизнес-логики. Telegram клиент обёрнут в интерфейс чтобы модули вызывали методы типа send_message() не зная что под капотом aiohttp и rate limiter. Завтра захочу заменить aiohttp на httpx - меняю только в infrastructure, модули не заметят. Redis cache тоже за интерфейсом - сегодня используем Redis, завтра можем переключиться на Memcached изменив одну имплементацию.

Tests структурированы по тем же модулям что и код. Unit тесты лежат в tests/unit/content/ и тестируют services изолированно через моки репозиториев. Integration тесты в tests/integration/ проверяют взаимодействие между слоями с реальной тестовой базой в Docker. Fixtures содержат фабрики для создания тестовых данных - вместо дублирования setup кода в каждом тесте использую готовые функции типа create_test_channel() или create_scheduled_post().

Архитектура слоёв - разделение ответственности



Первый бот который я написал был одним гигантским файлом где handlers напрямую писали SQL запросы, тут же форматировали ответы пользователю, заодно обновляли кеш и слали метрики в Prometheus. Работало пока функций было десять, потом начался кошмар - изменить формат ответа означало перелопатить пятнадцать мест в коде, переключить базу с PostgreSQL на MongoDB потребовало бы переписать половину handlers, добавить unit-тест было невозможно потому что всё завязано на реальную базу и Telegram API.

Разделение на слои это не академическая абстракция а практическая необходимость для кода который живёт дольше месяца. Presentation layer - это handlers которые принимают Update от Telegram, валидируют входящие данные, вызывают нужный service, форматируют результат обратно в Message. Всё. Никакой бизнес-логики, никаких запросов к базе, только маршрутизация и преобразование данных между форматами Telegram и внутренними структурами приложения.

Application layer содержит use cases - что можно сделать в системе. CreatePostService реализует создание публикации со всей логикой: проверка прав доступа, валидация контента, сохранение в базу через repository, публикация события в event bus. Service ничего не знает про Telegram - принимает обычные типы Python, возвращает domain objects или raise исключения. Можешь вызывать тот же service из REST API, GraphQL endpoint, Celery задачи - работает одинаково:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# Presentation - handler знает только про Telegram типы
@router.message(Command("create_post"))
async def handle_create_post(
    message: Message,
    state: FSMContext,
    post_service: PostService  # Инжектится через DI
):
    # Только валидация и маршрутизация
    if not message.text or len(message.text) < 10:
        await message.answer("Пост слишком короткий")
        return
    
    try:
        # Вызываем service с простыми типами
        post = await post_service.create_post(
            author_id=message.from_user.id,
            content=message.text,
            channel_ids=[123, 456]  # Из FSM context
        )
        
        # Форматируем ответ для Telegram
        await message.answer(
            f"Пост #{post.id} создан и запланирован"
        )
    except InsufficientPermissions:
        await message.answer("У вас нет прав для публикации")
    except ValidationError as e:
        await message.answer(f"Ошибка валидации: {e}")
 
# Application - service содержит бизнес-логику
class PostService:
    def __init__(
        self,
        post_repo: PostRepository,
        channel_repo: ChannelRepository,
        event_bus: EventBus
    ):
        self._posts = post_repo
        self._channels = channel_repo
        self._events = event_bus
    
    async def create_post(
        self,
        author_id: int,
        content: str,
        channel_ids: list[int]
    ) -> Post:
        # Проверяем права - это бизнес-логика
        if not await self._has_permissions(author_id, channel_ids):
            raise InsufficientPermissions()
        
        # Валидация контента
        if len(content) > 4096:  # Telegram limit
            raise ValidationError("Content too long")
        
        # Создаём domain объект
        post = Post(
            author_id=author_id,
            content=content,
            status=PostStatus.DRAFT,
            created_at=datetime.now()
        )
        
        # Сохраняем через repository
        saved_post = await self._posts.create(post)
        
        # Связываем с каналами
        for channel_id in channel_ids:
            await self._posts.add_channel(saved_post.id, channel_id)
        
        # Публикуем событие для других модулей
        await self._events.publish(PostCreatedEvent(
            post_id=saved_post.id,
            author_id=author_id,
            channel_ids=channel_ids
        ))
        
        return saved_post
Domain layer это центр системы где живут бизнес-модели и правила. Post это не просто dataclass с полями, это entity со своим поведением - можешь вызвать post.schedule(publish_at) и он сам проверит что дата в будущем, обновит статус, сгенерирует domain event. Правила бизнеса инкапсулированы внутри domain объектов, не размазаны по services. Видел проекты где Post был тупым контейнером данных, а вся логика лежала в сервисе - получалось procedural programming обёрнутое в классы для вида.

Infrastructure layer изолирует внешний мир. PostRepository знает про asyncpg, SQL, структуру таблиц - но предоставляет методы типа create(), find_by_id(), list_scheduled() которые возвращают domain objects. Завтра захочешь заменить PostgreSQL на MongoDB - переписываешь только repository имплементацию, services даже не узнают о переменах. TelegramClient скрывает детали HTTP вызовов к Bot API, rate limiting, retry логику - снаружи просто `await client.send_message(chat_id, text)`.

Dependency Rule критично соблюдать - зависимости всегда направлены внутрь к domain слою. Handlers зависят от services, services от repositories и domain models, но domain ни от чего не зависит. Это значит что presentation импортит application, application импортит domain и infrastructure, но никогда наоборот. Нарушил правило - получил циклические зависимости и coupling который делает невозможным изменение одной части без поломки всего остального.

Реальная проблема возникает с событиями между модулями. Analytics модуль хочет реагировать на PostCreatedEvent из content модуля - если он импортит event class напрямую создаётся зависимость между модулями. Решение через event bus как посредник - content публикует событие как словарь с type field, analytics подписывается на этот type и получает данные. Модули не знают друг про друга, связаны только через контракт событий который описан в документации или через shared contracts пакет.

Феномен Уилла Роджерса
Условие: Пользователь вводит два натуральных числа m и n. Генерируются случайными действительными...

Феномен Уилла Роджерса
Нужна полная реализация программы(Python), помогите пожалуйста. Задание: Пользователь вводит...

Феномен
Здравствуйте, помогите пожалуйста с решением задачи! Ограничение времени 10 секунд Ограничение...

Архитектура Python (Java vs Python)
Встретил совет учить Java, а не Python, потому что у Java всё с архитектурой правильно, хотя...

Создание ботов на Python
Я хочу написать бота, который бы ловил рыбу в Minecraft, он будет кидать, когда определенные...

Что нужно знать программисту Python, который смотрит в сторону ботов?
Привет! Мне интересна тема ботов, смотрю на них в аспекте применения в бизнес-задачах: краткие...

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

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

Телеграм бот в python
Привет всем) Очень нужна ваша помощь в создании бота. Идея бота в поиске слов из заданных...

Телеграм бот на python
Здравствуйте, при создании бота на языке Python выдает следующую ошибку: AttributeError: module...

Бот для телеграм на python
Есть задание (ниже кода) # -*- coding: utf-8 -*- import mysql.connector cnx =...

делаю бота телеграм на python, Traceback (most recent call last): File "D:/bott/bot.py", line 3, in <module> impor
делаю бота телеграм на python вот ошибки Traceback (most recent call last): File...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
GitHub Copilot в JetBrains: модель по умолчанию, Fix и контроль MCP
and_y87 12.10.2026
10 октября 2026 года GitHub обновил Copilot для JetBrains: добавил настройки стартовой модели, исправления из диагностики и управление автозапуском MCP. Поддержка IDE 2025. 1 прекращена — требуется. . .
День 5 из 28: Codex подсказывает следующий запрос
and_y87 12.10.2026
9 октября 2026 года OpenAI запустила бету Composer predictions — подсказок следующего сообщения в текущем диалоге Codex. Это помощь с формулировкой запроса, а не автоматическое продолжение задачи. . . .
Запустил конкурс "тем и промптов для текстовых квестов созданных почти чисто ИИ"
Adler 06.10.2026
Всем привет! За последние три-четыре дня я создал более 16 текстовых квестовых игр используя преимущественно по одному запросу к ИИ на игру. Мне так понравилось смотреть все ветки/ сцены во всех. . .
ИИ не может найти нужный язык в списке
Supersumestria 05.10.2026
Я ему даю вот такое изображение и прошу найти и подчеркнуть немецкий язык. Возвращает он вот это: https:/ / i. **********/ vqBWLe2. png Нужную строчку в 3й колонке просто выдумал. . Это. . .
Новая последняя моя музыка в SUNO
zorxor 05.10.2026
Здравствуйте, дорогие мои друзья! С большой радостью я хотел бы представить вам свою новую последнею музыку, которую сгенерировала мне по моей просьбе нейросеть SUNO. С уважением, zorxor. Это. . .
Программный домашний кинотеатр
russiannick 27.09.2026
Сподобился на программный домашний кинотеатр. В качестве ЯВУ по традиции выбрал js. В помощники взял Яндекс-Алису. Было создано три зала на разные интересы. исторические и ретро сериал Хичкок. . .
Беседа с ИИ о программистах, недопускающих к созданию и правке кода генеративные ИИ и причины этого
zorxor 21.09.2026
Раньше я радовался или получал некоторые эмоции, пусть небольшие, но всё же, от самого процесса написания кода, рекомпиляции и запуска, видя постепенное развитие программы и прочее. А теперь лень. . .
Мобильное приложение ColorStep
pavlinmavlin 17.09.2026
Реализовал приложение Красный, Зеленый, Синий в Unity3d + c#. Название изменил на ColorStep. Приложение прошло модерацию и теперь доступно для скачивания. Делал его сам, шаг за шагом — и вот,. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru