Архитектура телеграм-ботов на Python и Феномен 15 миллиардов сообщений (2)
Архитектура телеграм-ботов на Python и Феномен 15 миллиардов сообщений (1)Шардинг и горизонтальное масштабирование ботовВертикальное масштабирование упирается в физические границы железа довольно быстро. У меня был момент когда бот обрабатывал 300 тысяч пользователей на сервере с 32 гигами RAM и 16 ядрами. Казалось бы запаса море, но PostgreSQL сожрал всю память под connections и кеши, процессор горел на 90%, диск не успевал с I/O. Апгрейд до 64 гиг обошёлся в пять тысяч долларов, а через три месяца снова упёрлись в потолок. Дальше только шардинг - разбиение данных и нагрузки по нескольким независимым серверам. Шардинг по user_id это классика для telegram-ботов. Берёшь ID пользователя, вычисляешь остаток от деления на количество шардов, определяешь на каком сервере хранятся его данные. Просто, предсказуемо, каждый юзер всегда попадает на свой шард. База разбита на десять инстансов - каждый держит 10% пользователей, нагрузка распределена равномерно. Теоретически. На практике распределение получается неравномерным из-за паттернов регистрации. Все новые пользователи регистрируются сейчас, их ID подряд, модуль от соседних чисел часто даёт один шард. Первые месяцы один шард перегружен новичками, остальные простаивают. Consistent hashing решает это элегантнее - хеш от user_id распределяется по кольцу, каждый сервер занимает несколько сегментов. Добавление нового шарда перемещает только часть данных, не весь датасет:
Rebalancing при добавлении шарда переносит данные между серверами. Было три шарда, добавил четвёртый - четверть данных с каждого из трёх переезжает на новый. Миграция терабайта занимает часы, в это время часть запросов идёт на старый шард, часть на новый. Надо поддерживать два пути чтения одновременно, плюс обеспечить что записи не теряются:
Решение через явное распределение VIP-пользователей на отдельные шарды. Выделяешь два "премиум" шарда с усиленным железом, туда складываешь top-1000 активных юзеров. Остальные на обычных шардах по модулю. Мониторишь активность, когда кто-то вырывается в топ - перемещаешь на премиум-шард. Усложняет логику но решает проблему горячих точек. Географический шардинг по регионам имеет смысл для глобальных ботов. Европейские пользователи на европейских серверах, азиатские в Азии, американские в США. Латентность снижается драматически - вместо 300 миллисекунд до другого континента получаешь 20-30 внутри региона. Но тогда аналитика и админка должны уметь работать с множеством датацентров одновременно. Нужна консультация по созданию телеграм ботов Как продавать телеграм-ботов? Как часто для Телеграм-ботов требуется сторонний API? Численность мировой интернет-аудитории достигла двух миллиардов Балансировка нагрузки между инстансами ботовLoad balancer это не просто "штука что раскидывает запросы по серверам". Это критический компонент который определяет надёжность всей системы, и я убедился в этом когда первый раз настроил его неправильно. Запустил три инстанса бота за nginx с дефолтной конфигурацией round-robin, через час начались жалобы что бот "забывает" диалоги. Пользователь вводил имя, следующее сообщение с возрастом попадало на другой инстанс, который ничего не знал о предыдущем шаге. FSM состояние размазалось по трём серверам беспорядочно. Nginx это стандарт де-факто для HTTP load balancing в мире ботов. Лёгкий, быстрый, жрёт 10 мегабайт памяти и обрабатывает десятки тысяч соединений без проблем. Конфигурация для webhook выглядит обманчиво просто:
IP hash решает проблему через sticky sessions - хеширует IP адрес пользователя, всегда отправляет на один и тот же backend. Telegram шлёт webhook с нескольких IP блоками, но конкретный пользователь обычно привязан к одному датацентру. Добавляешь ip_hash; в upstream блок - большинство диалогов начинают работать корректно. Но не все, потому что Telegram иногда меняет IP источника для одного пользователя, особенно при переключении между Wi-Fi и мобильным интернетом.Least connections умнее - отправляет на сервер с наименьшим количеством активных соединений. Если один backend тормозит, новые запросы пойдут на более быстрые. Для ботов где обработка занимает разное время в зависимости от команды это логичнее round-robin. Генерация PDF отчёта висит минуту на одном сервере, балансер отправляет лёгкие запросы на другие пока первый занят:
/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 который уже содержит всё в удобном виде:
Отдельные базы данных для команд и запросов это следующий уровень. Запись идёт в 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 реальны несмотря на сборщик мусора. 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 окружении с реплицированным трафиком:
line_profiler даёт построчную статистику - видишь сколько времени заняла каждая строка кода. Это золото когда нашёл медленную функцию через cProfile но не понимаешь какая именно её часть тормозит. Ставится отдельно через pip, требует декорирования функций для профилирования:
memory_profiler отслеживает потребление памяти построчно. Критично для долгоживущих ботов где утечки накапливаются незаметно. Показывает не только текущее использование но и приращение на каждой строке - где именно память начинает расти. Тоже требует декорирования:
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:
Но сборка мусора это не бесплатная операция. 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 удалить объект когда он больше не нужен. Проблема в том что надо явно проектировать какие ссылки сильные, какие слабые. Забыл в одном месте - утечка:
__dict__, это гибко но жрёт минимум 200 байт overhead на объект. __slots__ задаёт фиксированный набор атрибутов, убирает словарь, экономит до 50% памяти. На миллионе User объектов разница между 400 мегабайтами и 200:
gc.collect() после тяжёлых операций помогает контролировать когда происходит сборка. Вместо спонтанных пауз в середине webhook обработки, принудительно запускаешь GC в моменты простоя. Работает если трафик неравномерный - между пиками есть затишья когда можно почистить память без вреда латентности. Для ботов с постоянной нагрузкой бесполезно, паузы неизбежны.Периодический рестарт процесса это грубое но эффективное решение fragmentation и медленных утечек. Ставишь systemd или supervisor на автоматический graceful restart раз в сутки или при достижении лимита памяти. Процесс завершает текущие запросы, отдаёт память системе полностью, стартует свежим. Пользователи не замечают если правильно настроен zero-downtime deployment с несколькими инстансами. Я делаю rolling restart - по одному инстансу каждые три часа, за сутки обновляются все восемь не создавая простоя. Базы данных - выбор под задачу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, любой сбой откатывает всё:
Зато бот для блога или новостного агрегатора где каждая статья это независимая сущность со своим набором полей - тут MongoDB даёт гибкость которой в Postgres нет. У одной статьи есть автор, теги, текст. У другой добавляется галерея изображений, у третьей видео, у четвёртой опрос встроенный. В реляционной модели либо делаешь кучу NULL-able колонок которые почти всегда пустые, либо городишь EAV таблицы которые превращают простой SELECT в JOIN на пять таблиц:
Аналитика и агрегации на больших объёмах данных показывают разные результаты. 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 на том же железе:
%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:
Формула грубая но работает: количество воркеров бота умножить на 2-3. Четыре инстанса бота по пять воркеров каждый = 20 воркеров, ставишь pool_size 50-60. Мониторишь метрику pool utilization - если постоянно на 90%+ значит мало, если висит на 20% значит можно урезать. Я видел проекты где ставили pool в 200 соединений "чтоб наверняка" и убивали базу которая не могла обслужить столько параллельных клиентов эффективно. N+1 проблема это классика которую ловят все кто переходит с синхронного на асинхронный код. Загрузил список из ста заказов, для каждого нужно имя пользователя - делаешь сто дополнительных SELECT'ов. В синхронном коде это медленно, в асинхронном все сто запросов улетают параллельно но это всё равно расточительство ресурсов:
Индексы это то что забывают добавить пока база маленькая, а потом мучаются когда она вырастает. Таблица users с миллионом записей, делаешь WHERE username = 'john' без индекса - база сканирует все миллион строк линейно. С индексом находит за микросекунды через B-tree. Разница между 200 миллисекундами и 2:
Батчинг операций экономит roundtrips когда нужно вставить или обновить много записей. Вместо ста отдельных INSERT делаешь один с массивом значений:
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:
Переиспользование 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 пустая и есть запас по лимитам:
Кеширование агрессивное на 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 магии:
Алерты настраиваю на критичные метрики с разумными 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 ко всем логам внутри обработки запроса от этого пользователя:
Production логи отличаются от development кардинально. В dev пишу всё включая DEBUG, вывод в консоль цветной и readable. В prod только INFO и выше, формат JSON для парсинга, запись в файл и centralised logging system типа Elasticsearch. Переключение через переменную окружения LOG_LEVEL:
Производительность логирования может быть неожиданно плохой. Запись на диск это 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:
Алерты настраиваются через Alertmanager в экосистеме Prometheus или через встроенные механизмы Grafana. Правило простое - если метрика превысила порог больше минуты, шлём уведомление. Не моментально потому что кратковременные спайки случаются постоянно - GC сработала, кто-то запустил тяжёлый запрос, сеть дёрнулась. Но если latency держится высокой минуту подряд - это уже паттерн требующий внимания:
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, каждый сервис читает этот заголовок из входящего запроса и передаёт дальше при вызове других сервисов:
Но интеграция 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 появился один раз это может быть случайность, если сотня раз за час то систематическая проблема требующая фикса:
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. Просто, понятно, не требует дополнительных зависимостей:
/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:
.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 на каждый запрос:
Я делаю ротацию критичных секретов раз в квартал вручную в 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 и подтягивает нужный набор секретов:
Тестирование и CI/CD для production-ready решенияТестирование telegram-ботов это квест уровня hard потому что половину функционала невозможно протестировать без живого Telegram API. Мокать каждый вызов send_message руками превращается в boilerplate который занимает больше строк чем сам тест. Первый бот я вообще не тестировал автоматически - запускал локально, руками тыкал команды, смотрел работает ли. Казалось быстрее чем писать тесты. Работало пока кодовая база не выросла до пяти тысяч строк - тогда каждое изменение ломало что-то неожиданное, регресс-баги вылезали через неделю после деплоя, пользователи находили проблемы раньше меня. Unit-тесты для бизнес-логики пишутся нормально если правильно отделил её от Telegram API. Функция которая валидирует промокод, вычисляет скидку, проверяет остатки на складе - всё это можно тестировать без моков telegram-специфики. Проблема когда логика размазана по handlers и завязана на Message объекты напрямую. Тогда каждый тест должен создавать фейковый Message со всеми вложенными полями, получается монстр на пятьдесят строк setup кода ради проверки одной функции. Я выношу всю логику в отдельные сервисы которые не знают про aiogram или python-telegram-bot вообще. Принимают простые типы - user_id, promo_code, items список - возвращают результат. Тестировать такое одно удовольствие, моки не нужны:
Database fixtures для тестов создаю через отдельную тестовую базу которая поднимается перед тестами и чистится после. Docker-compose с PostgreSQL контейнером удобен - один command создаёт чистое окружение, тесты заливают fixtures через alembic миграции или прямыми INSERT'ами, после прогона контейнер убивается. Никаких следов на production базе, полная изоляция:
Секрет в том что 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. Хочешь понять как работает аналитика - открываешь одну папку, всё там. Не надо прыгать между десятком файлов размазанных по проекту:
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 задачи - работает одинаково:
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 пакет. Феномен Уилла Роджерса Феномен Уилла Роджерса Феномен Архитектура Python (Java vs Python) Создание ботов на Python Что нужно знать программисту Python, который смотрит в сторону ботов? Отправка ботом сообщений из телеграм канала Пересылка сообщений Телеграм ботом с удалением изображений Телеграм бот в python Телеграм бот на python Бот для телеграм на python делаю бота телеграм на python, Traceback (most recent call last): File "D:/bott/bot.py", line 3, in <module> impor | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||


