Привет, Хабр!

Я студент, изучаю backend-разработку на Python. Недавно в рамках учебного проекта столкнулся с задачей: нужно было сделать интеграцию с платёжным сервисом. Они присылают уведомление (вебхук), когда пользователь оплатил заказ, а я должен обновить статус в базе.

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

Как я сделал сначала (и почему это было плохо)

Первая версия моего кода выглядела примерно так:

@app.post("/webhook")
async def webhook(request: dict):
    # Обработка прямо здесь
    update_database(request)
    send_email_to_user(request)
    return {"status": "ok"}

Логично же? Пришло событие → обновил базу → отправил письмо. На локальном сервере всё работало идеально. Но когда я попробовал протестировать это под нагрузкой (и просто на реальном интернете), начались странности.

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

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

  3. Дубли. Иногда приходило два одинаковых уведомления. Моя база данных пыталась создать два одинаковых заказа, и вылезала ошибка уникальности.

Я понял, что так работать нельзя. Нужно разделять «приём» и «обработку».

Как я искал решение

Начал гуглить «как надёжно обрабатывать фоновые задачи». Наткнулся на понятие очередей задач (Task Queues).

Идея мне понравилась:

  1. Вебхук просто кладёт задачу в очередь и сразу отвечает «ОК».

  2. Отдельный процесс (воркер) в спокойном темпе забирает задачи из очереди и делает всю тяжёлую работу.

Для очереди я выбрал Redis. Почему? Потому что он простой, быстрый и его легко поднять через Docker. Для веб-сервера взял FastAPI — он современный и асинхронный.

Архитектура, которая у меня получилась

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

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

Чтобы было понятнее, как это выглядит в системе, вот общая схема компонентов:


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

Реализация: код, который у меня работает

Делюсь кодом. Он не идеален, но работает в моём проекте.

1. Приёмщик (API)

Самое важное здесь — не тормозить. Я только проверяю подпись и кидаю в Redis.

from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
import redis
import json
import hashlib
import os

app = FastAPI()
# Подключаемся к Redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

SECRET = os.getenv('WEBHOOK_SECRET', 'test_secret')

def check_signature(body: str, sign: str) -> bool:
    """Простая проверка подписи"""
    my_sign = hashlib.sha256(f"{body}{SECRET}".encode()).hexdigest()
    return my_sign == sign

@app.post("/api/payment")
async def payment_webhook(request: dict, x_sign: str = Header(None)):
    # 1. Безопасность
    if not check_signature(json.dumps(request), x_sign):
        raise HTTPException(status_code=403, detail="Bad sign")
    
    # 2. Идемпотентность (защита от дублей)
    # Используем ID транзакции как ключ
    task_id = request.get('transaction_id')
    if r.exists(f"processed:{task_id}"):
        return {"status": "duplicate"}
    
    # 3. В очередь
    task = {
        "id": task_id,
        "amount": request.get('amount'),
        "user_id": request.get('user_id')
    }
    r.lpush("payment_queue", json.dumps(task))
    
    # 4. Быстрый ответ
    return {"status": "accepted"}

2. Воркер (Обработчик)

Это отдельный скрипт, который я запускаю через терминал (или через systemd/Docker в продакшене). Он крутится в бесконечном цикле.

import redis
import json
import time
import logging

logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)

def process_task(data):
    """Тут бизнес-логика"""
    log.info(f"Обрабатываем платеж {data['id']}")
    # Имитация работы с БД
    time.sleep(1) 
    # Тут мог бы быть запрос к PostgreSQL
    return True

def main():
    r = redis.Redis(host='localhost', port=6379)
    log.info("Воркер запущен...")
    
    while True:
        # Ждем задачу из очереди (блокирующе, 5 секунд)
        task = r.brpop("payment_queue", timeout=5)
        
        if not task:
            continue
            
        _, raw_data = task
        data = json.loads(raw_data)
        
        try:
            success = process_task(data)
            if success:
                # Помечаем как обработанное (храним 1 день)
                r.setex(f"processed:{data['id']}", 86400, "1")
                log.info(f"Задача {data['id']} выполнена")
        except Exception as e:
            log.error(f"Ошибка: {e}")
            # Если ошибка - возвращаем задачу в очередь
            # (в реальном проекте лучше считать попытки)
            r.lpush("payment_queue", raw_data)
            time.sleep(5)

if __name__ == "__main__":
    main()

С какими проблемами я столкнулся

Не всё прошло гладко, хочу честно рассказать о багах.

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

  2. Redis пропадал. Когда я перезагружал компьютер, данные в Redis исчезали (он же в памяти). Для учебного проекта это ок, но для реального нужно включать persistence (сохранение на диск) или использовать базу данных как очередь.

  3. Бесконечный цикл ошибок. Если в коде воркера ошибка, задача возвращалась в очередь и сразу же забиралась снова. Воркер уходил в цикл и грел процессор. Сейчас я добавил задержку (time.sleep) и счётчик попыток.

Что я понял в итоге

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

Что это дало:

  • API стал отвечать за 50 мс вместо 2 секунд.

  • Я перестал бояться, что сервер упадёт и данные потеряются.

  • Я научился работать с Redis не только как с кэшем, но и как с очередью.

Заключение

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

Комментарии (15)


  1. gtosss
    28.03.2026 08:39

    Отличная статья! Именно так и нужно делиться опытом и проводить ретроспективный анализ своей работы — это дает на много больше опыта, чем бездумный кодинг “на авось”, который сейчас особенно популярен из-за LLM.


    1. gtosss
      28.03.2026 08:39

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


  1. sergey2212
    28.03.2026 08:39

    Молодец. Да это хороший вектор. А почему именно redis а не rabbitMQ? Redis кажется больше для разгрузки основной базы. Первый раз сталкиваюсь что бы её использовать в качестве брокера сообщений


    1. gtosss
      28.03.2026 08:39

      Redis дает функционал который подходит для работы с очередью. С помощью него и можно реализовать паттерн Producer-Consumer.

      Если в двух словах, мы можем релазиовать через него FIFO (first in, first out) так же как в RabbitMQ.

      PS

      Это не совсем моя зона экспертизы, если что-то не так сказал, буду рад если поправят более опытные


  1. Exited
    28.03.2026 08:39

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


    1. gtosss
      28.03.2026 08:39

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


    1. Pubert
      28.03.2026 08:39

      Лол, чел, в БД дубли не появлялись. Ошибка просто выдавалась в эндпоинтах. А почему внезапно приходили дубли? Почти наверняка из-за таймаута. Хоть POST запрос неидемпотентен (его НЕЛЬЗЯ ретраить), некоторые "хорошие" сервисы всё равно делают это. Отсюда и дубли при таймауте. Так что Ваше замечание некорректно. Просто автор, скорее всего, забыл это упомянуть, или даже не задумался о том, куда исчезли дубли)


  1. kirillrudnikov
    28.03.2026 08:39

    Стоит посмотреть в сторону Redis Streams, если в приоритете остаться на том стеке, что используется сейчас

    Сейчас события потребляются из очереди и после просто из неё пропадают (но будут в базе, возможно), но, а если воркер упадет сразу же после взятия события, не выполнив никакой работы, событие просто испарится?

    Стоит рассмотреть вариант историчности событий, ну и их запись сразу на диск (а Redis Streams это все как раз умеет), сможешь правильно обрабатывать события из очередей, еще и перечитывать их, если понадобится


  1. svi0105
    28.03.2026 08:39

    # 4. Быстрый ответ

    Что произойдет, если на этапе записи в БД не будет найден клиент с переданным в запросе user_id?

    (Вы уже ответили "ОК", сразу)


    1. theindiefly
      28.03.2026 08:39

      Пишем в отдельную сущность Error со всеми параметрами , шлем письмо команде поддержки, ну и в Rollbar ещё складируем на всякий случай.

      А там уже разбираются -)


  1. serafims
    28.03.2026 08:39

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


  1. anaxita
    28.03.2026 08:39

    А зачем в этой схеме редис? По идее вы можете писать таску сразу в БД и джобой ее брать, при параллельной обработки брать лок

    либо делаете аутбокс а воркеры это отдельные сервисы которые из брокера высчитывают


  1. Vorono4ka
    28.03.2026 08:39

    У вас здесь возможен некоторый race condition, если его так вообще можно назвать, я бы даже назвал это просто ошибкой логики.

    Вы добавляете новый запрос в payments_queue только если он ещё не был обработан. Проверяете это через запись, которая появляется только после успешной обработки.

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

    Рассмотрим гипотетическую ситуацию: от платёжного сервиса вам приходит два одинаковых уведомления подряд.

    Допустим, все ваши воркеры выключены, тогда никто гарантированно не добавит в Redis запись processed для таски, а значит в очередь гарантированно попадут две одинаковые таски.

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

    При этом если в базе данных есть индексы на уникальность (а судя по вашему описанию проблемы — они есть), то process_task упадет с ошибкой и ваш except всегда будет возвращать эту дублированную таску в очередь.

    Я не работал с очередями в Redis, может быть там всё проще, чем я думаю, но хочу предложить такое решение: перед добавлением таски в очередь добавлять запись, например, f"received:{payment_id}". И проверять именно её наличие, а не наличие f"processed:{payment_id}". В таком случае проблема с дублированием запросов решится.

    То есть нужно проверять на дубликаты не по статусу успешной обработки в воркере-обработчике, а по статусу добавления непосредственно в очередь. Вроде бы это вполне логично.

    Потенциально еще может возникнуть проблема с тем, что два одинаковых запроса попадут в разные воркеры-приемники. В таком случае не исключаю возможности уже настоящего race condition, когда оба воркера успеют проверить наличие received записи и оба получат False. Было бы интересно узнать как с этим бороться. И может быть я выдумал себе эту проблему.


  1. Shkn
    28.03.2026 08:39

    Что за курс или где обучаетесь?


  1. korob93
    28.03.2026 08:39

    Насчёт пункта 3 не уверен, но вроде бы распространённая практика - держать отдельную очередь для ошибочных тасков