Architecture 2026-06-18·Ievgenii Svyryd

Побудова платформи для збору коштів у реальному часі: чому ми зробили ставку на Django, Wagtail, Rust і SSE

Як ми спроєктували мультитенантну платформу для фандрейзингових подій, що обробляє тисячі одночасних потоків пожертв, тематизацію для кожної організації та оновлення в реальному часі за менш ніж секунду — з Django, Wagtail CMS, мікросервісом SSE на Rust і нульовою складністю WebSocket.

Побудова платформи для збору коштів у реальному часі: чому ми зробили ставку на Django, Wagtail, Rust і SSE

Проблема: захід зі збору коштів — це 24-годинний спринт

Уявіть університет, який мобілізує 50 000 випускників пожертвувати за один день. Термометр на головній сторінці піднімається в реальному часі. Таблиці лідерів перемішуються. Амбасадори спостерігають, як їхні особисті сторінки збору коштів повзуть угору. За лаштунками тисячі вкладок браузера тримають відкриті з'єднання, чекаючи на наступну подію пожертви.

Це і є подія зі збору коштів - і коли ми взялися будувати платформу, що їх живить, ми знали, що технологічний стек має задовольняти три вимоги, які не підлягають обговоренню:

  • Реальний час у масштабі - тисячі одночасних SSE-з'єднань із затримкою менше секунди
  • Гнучкість контенту - кожній організації потрібен власний бренд, сторінки, кампанії та цілі, керовані нетехнічним персоналом
  • Операційна простота - невелика команда має швидко випускати функції, а не няньчити інфраструктуру

Ось як ми обирали - і обґрунтовували - кожен шар стеку.

Django + Wagtail: рушій контенту

Основний вебзастосунок працює на Django 5.x з Wagtail 7.x як рівнем CMS. Це ніколи не було складним вибором. Wagtail дає нам:

  • StreamField - сторінки кампаній складаються з багаторазово використовуваних блоків (банери-герої, термометри, таблиці лідерів, стіни донорів, сітки причин), які контент-редактори перетягують і скидають. Для зміни макета сторінки розробник не потрібен.
  • Конфігурація на основі сніпетів - виклики, правила таблиць лідерів, узгоджені пожертви та призначення причин - усе це сніпети Wagtail. Редактори створюють виклик, задають часове вікно та цільову суму в доларах, а платформа робить решту.
  • Попередній перегляд та історія ревізій - під час живої благодійної події редактори можуть підготувати чернетки оновлень сторінки, переглянути їх та миттєво опублікувати без розгортання.

Wagtail - це не просто "WordPress, але на Python". Це справді дружня до розробників CMS, яка не заважає, коли вам потрібна власна бізнес-логіка, але дає контент-командам справжню автономію.

# Campaign page built from composable StreamField blocks
class CampaignPage(WagtailCacheMixin, Page):
    body = StreamField([
        ("hero", HeroBannerBlock()),
        ("thermometer", ThermometerBlock()),
        ("leaderboard", LeaderboardBlock()),
        ("donor_wall", DonorWallBlock()),
        ("cause_grid", CauseGridBlock()),
        ("rich_text", blocks.RichTextBlock()),
        ("video_embed", EmbedBlock()),
    ])
    campaign = models.ForeignKey(
        "campaigns.Campaign", on_delete=models.PROTECT
    )

Мультитенантна архітектура: одна кодова база, ізольовані дані

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

Чому саме повна ізоляція на рівні бази даних, а не фільтрація на рівні рядків?

  • Суверенність даних - деякі організації вимагають, щоб дані їхніх донорів були фізично відокремлені. Ізольовані бази даних роблять аудити відповідності тривіальними.
  • Ізоляція продуктивності - захід зі збору коштів, який генерує 10 000 пожертв за годину, не сповільнює запити для інших організацій.
# Dynamic tenant database registration
def ensure_database_registered(org):
    db_alias = f"tenant_{org.sanitized_id}"
    if db_alias in connections.databases:
        return db_alias

    connections.databases[db_alias] = {
        "ENGINE": "django.db.backends...",
        "NAME": db_alias,
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
    }
    return db_alias

Пожертви в реальному часі: чому SSE тут кращий за WebSockets

Знакова функція заходу зі збору коштів - це живий стрічковий потік пожертв. Кожна вкладка браузера, що показує сторінку кампанії, утримує відкрите з'єднання й отримує події пожертв у ту саму мить, коли вони обробляються.

Ми обрали Server-Sent Events (SSE) замість WebSockets зі свідомої причини: потік даних односпрямований. Сервер надсилає події пожертв клієнтам. Клієнти ніколи не надсилають дані назад через потік подій. SSE дає нам:

  • Автоматичне перепідключення - нативний API браузера EventSource перепідключається при розриві з експоненційним відкладенням. Нуль логіки перепідключення на боці клієнта.
  • Мультиплексування HTTP/2 - потоки SSE поділяють те саме TCP-з'єднання, що й інші HTTP-запити. Жодного окремого протоколу, жодного танцю з CORS preflight, жодної спеціальної конфігурації проксі.
  • Прозорість для балансувальника навантаження - SSE - це просто довготривала HTTP-відповідь. Кожен зворотний проксі, CDN та балансувальник навантаження вже його розуміють.
  • Простота - жодних кадрів ping/pong, жодного рукостискання для оновлення з'єднання, жодного бінарного фреймінгу. Просто текст UTF-8 поверх HTTP.

WebSockets був би правильним вибором, якби нам була потрібна двоспрямована комунікація - функція чату, спільне редагування чи інтерактивні аукціони. Для стрічки пожертв SSE - це правильний інструмент.

Мікросервіс SSE на Rust: чому б просто не використати Django?

Django синхронний. Воркери Gunicorn обробляють один запит за раз. Утримання 5000 відкритих SSE-з'єднань вимагало б 5000 воркерів Gunicorn - неприпустимо.

Ми побудували окремий SSE-мікросервіс, і побудували його двічі: спершу на Python із FastAPI + Hypercorn (асинхронний ASGI), а потім на Rust із Axum + Tokio. Обидва дотримуються однакової архітектури:

  1. Клієнт відкриває EventSource до SSE-сервісу
  2. SSE-сервіс підписується на канал pub/sub у Redis: campaign:{id}:updates
  3. Коли Django обробляє пожертву, задача Celery публікує оновлену статистику в Redis
  4. SSE-сервіс отримує повідомлення та розсилає його всім підключеним клієнтам цієї кампанії

Версія на Rust існує тому, що ми хотіли підняти стелю конкурентності ще вище. Один процес Rust може комфортно утримувати десятки тисяч одночасних з'єднань із мінімальними витратами пам'яті завдяки асинхронному рантайму Tokio з нульовими накладними витратами. На практиці версія на FastAPI ідеально справляється з нашим поточним навантаженням - сервіс на Rust є страховкою на той день, коли кампанія стане по-справжньому вірусною.

// Обробник SSE на Rust (Axum + Tokio)
pub async fn sse_handler(
    State(state): State<AppState>,
    Path(campaign_id): Path<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = async_stream::stream! {
        // Атомарний підрахунок з'єднань через Lua-скрипт
        let count = state.redis
            .increment_connection(campaign_id)
            .await;

        if count > MAX_CONNECTIONS {
            yield Ok(Event::default()
                .data("rate_limited"));
            return;
        }

        // Підписка на канал pub/sub у Redis
        let mut sub = state.redis
            .subscribe(campaign_id).await;

        while let Some(msg) = sub.next().await {
            yield Ok(Event::default()
                .data(msg.get_payload::<String>()?));
        }
    };
    Sse::new(stream)
        .keep_alive(KeepAlive::default())
}

Запобігання ефекту громового стада за допомогою Lua-скриптів

Одна тонка проблема з SSE у великому масштабі: коли завантажується популярна сторінка кампанії, сотні браузерів одночасно відкривають SSE-з'єднання. Якщо ми перевіряємо кількість з'єднань у Python, збільшуємо її, а потім вирішуємо, чи приймати нове - виникає стан гонитви. Двісті клієнтів можуть усі прочитати "199 connections" і всі пройти далі.

Ми вирішили це за допомогою атомарного Lua-скрипта, який виконується всередині Redis. Скрипт зчитує поточну кількість, звіряє її з лімітом і збільшує в одній атомарній операції. Жодної гонитви TOCTOU, жодного розподіленого блокування не потрібно.

-- Atomic connection limiting (runs inside Redis)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
    return -1  -- rejected
end

local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count  -- accepted

Тематизація для кожної організації: CSS-змінні у великому масштабі

Кожна організація на платформі має власний бренд - кольори, логотипи, шрифти. Нам була потрібна система тематизації, яка є:

  • Налаштовуваною не-розробниками через CMS
  • Продуктивною - без обчислень кольорів на JavaScript під час виконання
  • Обізнаною про темний режим від першого дня

Рішення: модель BrandColor для Wagtail, яка генерує кастомні CSS-властивості на боці сервера. Кожна організація визначає свою палітру (основний, вторинний, акцентний, фоновий кольори) з опціональними перевизначеннями для темного режиму. Платформа рендерить їх як блок <style> у head сторінки.

class BrandColor(models.Model):
    color_value = models.CharField()       # light mode
    color_value_dark = models.CharField()  # dark mode override
    is_dark = models.BooleanField()        # text contrast hint

    @classmethod
    def get_all_css_for_organization(cls, org) -> str:
        """Generates complete CSS variable blocks."""
        colors = cls.objects.filter(organization=org)
        light = "; ".join(
            f"{c.css_var_name}: {c.color_value}" for c in colors
        )
        dark = "; ".join(
            f"{c.css_var_name}: {c.color_value_dark}"
            for c in colors if c.color_value_dark
        )
        return (
            f":root {{ {light} }}\n"
            f'[data-theme="dark"] {{ {dark} }}\n'
            f"@media (prefers-color-scheme: dark) "
            f"{{ :root:not([data-theme]) {{ {dark} }} }}"
        )

Цей трирівневий підхід покриває кожен сценарій: явне перемикання користувачем (атрибут data-theme), системна перевага через prefers-color-scheme і розумне значення за замовчуванням. Класи Tailwind по всіх шаблонах посилаються на ці змінні замість жорстко закодованих кольорів, тож одна організація може бути темно-синьою (navy), а інша - яскраво-багряною (crimson): той самий HTML, різна фарба.

HTMX: анти-SPA

Фронтенд - це шаблони Django, що рендеряться на сервері, доповнені HTMX та Alpine.js. Немає ані React, ані Vue, ані кроку збірки для JavaScript. Це навмисний архітектурний вибір.

Сторінки благодійних подій сильно кешуються. HTML сторінки кампанії генерується один раз, кешується в Redis і подається тисячам відвідувачів. React SPA вимагав би окремого рівня API, вносив би складність клієнтського кешування та подвоював би площу поверхні для помилок - і все це заради того самого результату, що й кешована сторінка, відрендерена на сервері.

HTMX дає нам потрібну інтерактивність з хірургічною точністю:

  • Пагінація списку донорів - hx-get отримує наступну сторінку донорів і підставляє її в DOM. Сервер повертає частковий HTML-фрагмент, а не JSON.
  • Секції з відкладеним завантаженням - важкі обчислення таблиць лідерів відкладаються. Сторінка завантажується миттєво з заповнювачем, а потім HTMX надсилає запит, щоб заповнити її.
  • Патерн Shell + Partial - повні завантаження сторінки повертають повну HTML-оболонку. Запити HTMX (виявлені через заголовок HX-Request) повертають лише фрагмент. Те саме представлення, той самий шаблон, нуль дублювання.
<!-- Список донорів із пагінацією через HTMX -->
<div id="donors-list"
     hx-get="?page=1"
     hx-trigger="load"
     hx-swap="innerHTML">
    <!-- Заповнювач-скелетон -->
</div>

<!-- Сервер визначає заголовок HX-Request і повертає частковий фрагмент -->
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}"
   hx-target="#donors-list"
   hx-swap="innerHTML"
   class="btn-outline">Завантажити ще</a>
{% endif %}

Продуктивність: від 70+ запитів до 9

Найбільший виграш у продуктивності дало не кешування, а усунення N+1 запитів. Сторінки кампаній залучають глибоко вкладені звʼязки: кампанії мають цілі, цілі мають таблиці лідерів, таблиці лідерів мають команди, команди мають учасників. Наївний обхід через ORM генерував понад 70 SQL-запитів на завантаження сторінки.

Ми використали три стратегії, щоб знизити це до 9 запитів:

  1. select_related для одинарних зовнішніх ключів (campaign → organisation, donation → cause)
  2. prefetch_related для зворотних та багато-до-багатьох звʼязків (campaign → causes, leaderboard → teams)
  3. Ручне впровадження кешу попереднього завантаження для обʼєктів ClusterableModel у Wagtail, які обходять стандартний механізм попереднього завантаження Django. Ми заповнюємо _prefetched_objects_cache безпосередньо після масового запиту.
# Manual prefetch cache for ClusterableModel
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in leaderboards]

# Single bulk query for all teams across all leaderboards
teams = FlexiLeaderboardTeam.objects.filter(
    leaderboard_id__in=leaderboard_ids
).select_related("team_profile")

teams_by_lb = defaultdict(list)
for t in teams:
    teams_by_lb[t.leaderboard_id].append(t)

# Inject into prefetch cache - avoids N+1
for lb in leaderboards:
    lb._prefetched_objects_cache = {
        "leaderboard_team_links": teams_by_lb.get(lb.pk, []),
    }

Прогрів кешу: не чекайте на перший запит

Кешування - це лише половина історії: перший запит після промаху кешу може бути болісно повільним, якщо сторінка дорога у рендерингу. Ми вирішуємо це проактивним прогрівом кешу: коли надходить пожертва і статистика перераховується, задача Celery рендерить сторінку кампанії за допомогою тестового Client Django та наповнює кеш ще до того, як на неї потрапить хоч один реальний користувач.

Прогрів CDN складніший. Ми надсилаємо HTTP-запити до крайових вузлів CDN з обмеженням частоти (2 одночасних, 200 мс між запитами), щоб не перевантажити джерело. Ключ дедуплікації SETNX у Redis не дає кільком воркерам Celery одночасно прогрівати одну й ту саму сторінку.

Розгортання: Kubernetes + Helm

Продакшн-стек працює на Kubernetes, оркестрованому за допомогою Helm charts. Кожен сервіс масштабується незалежно:

  • Django (Gunicorn) - 2-4 репліки за сервісною сіткою
  • SSE-сервіс (Rust/FastAPI) - масштабується горизонтально залежно від кількості з'єднань
  • Celery workers - окремі черги для розрахунку статистики, прогріву кешу та обробки пожертв
  • Celery Beat - одна репліка для періодичних задач
  • Nginx - зворотний проксі з HTTP/2 та термінацією TLS
  • Redis Sentinel - високодоступний кластер Redis

Спостережуваність забезпечують OpenTelemetry + Jaeger для розподіленого трасування, Sentry для відстеження помилок та Prometheus + Grafana для метрик і сповіщень. Кожна пожертва має ідентифікатор трасування, який супроводжує її від кінцевої точки API через задачі Celery до трансляції SSE.

Стек, обґрунтований

Кожна технологія в цьому стеку заслуговує свого місця:

  • Django - перевірена в боях ORM, middleware, автентифікація, адмінка. Екосистема неперевершена для платформи з великим обсягом контенту.
  • Wagtail - дає командам контенту справжню потужність без жертвування ергономікою розробки. Сам лише StreamField це виправдовує.
  • SSE замість WebSockets - простіше, стійкіше, і потік даних усе одно односпрямований.
  • Rust для SSE - тримає десятки тисяч простоюючих з'єднань з майже нульовими витратами пам'яті. Правильний інструмент для правильної задачі.
  • Redis - pub/sub для реального часу, кешування для продуктивності сторінок, Lua-скрипти для атомарних операцій. Один сервіс, три критичні ролі.
  • HTMX замість React - кешований HTML, відрендерений на сервері, швидший, простіший і доступніший, ніж SPA з рендерингом на клієнті для цього випадку.
  • Окремі бази даних на організацію - ізоляція даних без складності tenancy на рівні рядків.
  • Kubernetes + Helm - незалежне масштабування stateless-сервісів під час пікового трафіку фандрайзингових подій.

В архітектурі програмного забезпечення немає срібної кулі. Але є стеки, які пасують під форму задачі, і цей пасує як рукавичка. Платформа успішно забезпечила фандрайзингові події, що зібрали мільйони доларів, з фідами реального часу, які плавно працювали через тисячі одночасних з'єднань - а команда контенту випускає зміни сторінок без жодного рядка коду.

Найкраща архітектура — це та, де кожен компонент може пояснити, чому він існує. Якщо ви не можете обґрунтувати присутність технології двома реченнями, її, ймовірно, там бути не повинно.

Зв'язатися →