Budowa platformy do zbiórek w czasie rzeczywistym: dlaczego postawiliśmy na Django, Wagtail, Rust i SSE
Jak zaprojektowaliśmy wielodostępną platformę do wydarzeń fundraisingowych, która obsługuje tysiące równoczesnych strumieni darowizn, motywy dostosowane do każdej organizacji i aktualizacje na żywo w czasie poniżej sekundy — z użyciem Django, Wagtail CMS, mikroserwisu SSE w Rust i bez żadnej złożoności WebSocket.
Problem: zbiórka charytatywna to 24-godzinny sprint
Wyobraź sobie uniwersytet mobilizujący 50 000 absolwentów, aby przekazali darowiznę w ciągu jednego dnia. Termometr na stronie głównej wspina się w czasie rzeczywistym. Rankingi tasują się na nowo. Ambasadorzy obserwują, jak ich osobiste strony zbiórkowe tykają w górę. Za kulisami tysiące kart przeglądarki utrzymują otwarte połączenia, czekając na kolejne zdarzenie darowizny.
To jest wydarzenie fundraisingowe - a kiedy zabraliśmy się za budowę platformy, która je napędza, wiedzieliśmy, że stos technologiczny musi spełnić trzy niepodlegające negocjacjom wymagania:
- Czas rzeczywisty na dużą skalę - tysiące jednoczesnych połączeń SSE z opóźnieniem poniżej sekundy
- Elastyczność treści - każda organizacja potrzebuje własnej marki, stron, kampanii i celów, zarządzanych przez nietechniczny personel
- Prostota operacyjna - mały zespół musi szybko dostarczać funkcje, a nie niańczyć infrastrukturę
Oto jak wybraliśmy - i uzasadniliśmy - każdą warstwę stosu.
Django + Wagtail: potęga zarządzania treścią
Główna aplikacja webowa działa na Django 5.x z Wagtail 7.x jako warstwą CMS. Nigdy nie był to trudny wybór. Wagtail daje nam:
- StreamField - strony kampanii są komponowane z reużywalnych bloków (banery hero, termometry, tablice liderów, ściany darczyńców, siatki celów), które redaktorzy treści przeciągają i upuszczają. Do zmian układu strony nie jest potrzebny programista.
- Konfiguracja oparta na snippetach - wyzwania, reguły tablic liderów, darowizny łączone (matching gifts) oraz przeznaczenia celów to wszystko snippety Wagtail. Redaktorzy tworzą wyzwanie, ustawiają okno czasowe i cel kwotowy, a platforma zajmuje się resztą.
- Podgląd i historia rewizji - podczas trwającego wydarzenia fundraisingowego redaktorzy mogą przygotować wersje robocze aktualizacji stron, wyświetlić ich podgląd i publikować je natychmiast, bez wdrożenia.
Wagtail to nie tylko "WordPress, ale w Pythonie." To naprawdę przyjazny programistom CMS, który schodzi ci z drogi, gdy potrzebujesz własnej logiki biznesowej, a jednocześnie daje zespołom treści prawdziwą autonomię.
# Strona kampanii zbudowana z komponowalnych bloków StreamField
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
)
Architektura wielodostępowa: jedna baza kodu, izolowane dane
Platforma izoluje dane każdej organizacji na poziomie bazy danych. Niestandardowy router bazy danych kieruje zapytania do właściwego backendu na podstawie bieżącego kontekstu tenanta, podczas gdy współdzielone zasoby, takie jak treść stron i konfiguracja kampanii, znajdują się we wspólnym magazynie.
Dlaczego pełna izolacja na poziomie bazy danych zamiast filtrowania na poziomie wierszy?
- Suwerenność danych - niektóre organizacje wymagają, aby ich dane darczyńców były fizycznie oddzielone. Izolowane bazy danych sprawiają, że audyty zgodności są trywialne.
- Izolacja wydajności - wydarzenie fundraisingowe generujące 10 000 darowizn w ciągu godziny nie spowalnia zapytań innych organizacji.
# Dynamiczna rejestracja bazy danych najemcy
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
Darowizny w czasie rzeczywistym: dlaczego SSE bije tu WebSockety
Sztandarową funkcją wydarzenia fundraisingowego jest strumień darowizn na żywo. Każda karta przeglądarki wyświetlająca stronę kampanii utrzymuje otwarte połączenie i otrzymuje zdarzenia darowizn w chwili, gdy zostają przetworzone.
Wybraliśmy Server-Sent Events (SSE) zamiast WebSockets z rozmysłem: przepływ danych jest jednokierunkowy. Serwer wypycha zdarzenia darowizn do klientów. Klienci nigdy nie odsyłają danych z powrotem przez strumień zdarzeń. SSE daje nam:
- Automatyczne ponowne łączenie - natywne API przeglądarki
EventSourceponownie łączy się po rozłączeniu z wykładniczym backoffem. Zero logiki ponownego łączenia po stronie klienta. - Multipleksowanie HTTP/2 - strumienie SSE współdzielą to samo połączenie TCP co inne żądania HTTP. Żadnego osobnego protokołu, żadnego tańca z preflightem CORS, żadnej specjalnej konfiguracji proxy.
- Przezroczystość dla load balancera - SSE to po prostu długo żyjąca odpowiedź HTTP. Każde reverse proxy, CDN i load balancer już to rozumie.
- Prostota - żadnych ramek ping/pong, żadnego handshake'u upgrade'u połączenia, żadnego binarnego framingu. Po prostu tekst UTF-8 przez HTTP.
WebSockets byłyby właściwym wyborem, gdybyśmy potrzebowali komunikacji dwukierunkowej - funkcji czatu, wspólnej edycji czy interaktywnych aukcji. Dla strumienia darowizn SSE to właściwe narzędzie.
Mikroserwis SSE w Rust: dlaczego nie po prostu Django?
Django jest synchroniczne. Workery Gunicorn obsługują jedno żądanie naraz. Utrzymywanie 5000 otwartych połączeń SSE wymagałoby 5000 workerów Gunicorn - co jest niewykonalne.
Zbudowaliśmy dedykowany mikroserwis SSE, i to dwukrotnie: najpierw w Pythonie z FastAPI + Hypercorn (asynchroniczne ASGI), a następnie w Ruście z Axum + Tokio. Oba trzymają się tej samej architektury:
- Klient otwiera
EventSourcedo usługi SSE - Usługa SSE subskrybuje kanał pub/sub Redis:
campaign:{id}:updates - Gdy Django przetwarza darowiznę, zadanie Celery publikuje zaktualizowane statystyki do Redis
- Usługa SSE odbiera wiadomość i rozsyła ją do wszystkich połączonych klientów danej kampanii
Wersja w Ruście istnieje, ponieważ chcieliśmy podnieść pułap współbieżności jeszcze wyżej. Pojedynczy proces Rust może komfortowo utrzymać dziesiątki tysięcy jednoczesnych połączeń przy minimalnym narzucie pamięciowym, dzięki bezkosztowemu środowisku asynchronicznemu Tokio. W praktyce wersja FastAPI doskonale radzi sobie z naszym obecnym obciążeniem - usługa w Ruście to ubezpieczenie na dzień, w którym kampania stanie się naprawdę wirusowa.
// Rust SSE handler (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! {
// Atomic connection count via Lua script
let count = state.redis
.increment_connection(campaign_id)
.await;
if count > MAX_CONNECTIONS {
yield Ok(Event::default()
.data("rate_limited"));
return;
}
// Subscribe to Redis pub/sub channel
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())
}
Zapobieganie efektowi stada (Thundering Herd) za pomocą skryptów Lua
Jeden subtelny problem z SSE przy dużej skali: gdy ładuje się popularna strona kampanii, setki przeglądarek jednocześnie otwierają połączenia SSE. Jeśli sprawdzimy liczbę połączeń w Pythonie, zwiększymy ją, a następnie zdecydujemy, czy zaakceptować - powstaje sytuacja wyścigu. Dwustu klientów może odczytać „199 połączeń” i wszyscy przejdą dalej.
Rozwiązaliśmy to za pomocą atomowego skryptu Lua wykonywanego wewnątrz Redis. Skrypt odczytuje bieżącą liczbę, sprawdza ją względem limitu i zwiększa w ramach jednej atomowej operacji. Żadnego wyścigu TOCTOU, żadnego rozproszonego locka.
-- Atomowe ograniczanie połączeń (działa wewnątrz 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 -- odrzucone
end
local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count -- zaakceptowane
Motywy per organizacja: zmienne CSS na dużą skalę
Każda organizacja na platformie ma własną markę - kolory, logotypy, fonty. Potrzebowaliśmy systemu motywów, który jest:
- Konfigurowalny przez osoby niebędące programistami za pośrednictwem CMS-a
- Wydajny - bez obliczania kolorów w JavaScripcie w czasie działania
- Świadomy trybu ciemnego od pierwszego dnia
Rozwiązanie: model Wagtaila BrandColor, który generuje niestandardowe właściwości CSS po stronie serwera. Każda organizacja definiuje swoją paletę (podstawowy, drugorzędny, akcent, tło) z opcjonalnymi nadpisaniami dla trybu ciemnego. Platforma renderuje je jako blok <style> w nagłówku strony.
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} }} }}"
)
To trójwarstwowe podejście obejmuje każdy scenariusz: jawne przełączenie przez użytkownika (atrybut data-theme), preferencja systemowa poprzez prefers-color-scheme oraz sensowna wartość domyślna. Klasy Tailwind w całych szablonach odwołują się do tych zmiennych zamiast do zakodowanych na sztywno kolorów, więc jedna organizacja może być głęboko granatowa, a inna jaskrawoszkarłatna - ten sam HTML, inna farba.
HTMX: anty-SPA
Frontend to renderowane po stronie serwera szablony Django wzbogacone o HTMX i Alpine.js. Nie ma tu Reacta, nie ma Vue, nie ma kroku budowania dla JavaScriptu. To celowa decyzja architektoniczna.
Strony wydarzeń fundraisingowych są mocno buforowane. HTML strony kampanii jest generowany raz, buforowany w Redis i serwowany tysiącom odwiedzających. React SPA wymagałby osobnej warstwy API, wprowadziłby złożoność buforowania po stronie klienta i podwoił powierzchnię pojawiania się błędów - a wszystko to, aby osiągnąć ten sam rezultat co buforowana strona renderowana po stronie serwera.
HTMX daje nam interaktywność, której potrzebujemy, z chirurgiczną precyzją:
- Paginacja listy darczyńców -
hx-getpobiera kolejną stronę darczyńców i podmienia ją w DOM. Serwer zwraca częściowy fragment HTML, a nie JSON. - Sekcje ładowane leniwie - ciężkie obliczenia tablicy liderów są odraczane. Strona ładuje się natychmiast z placeholderem, a następnie HTMX wysyła żądanie, aby ją wypełnić.
- Wzorzec Shell + Partial - pełne załadowania strony zwracają kompletną powłokę HTML. Żądania HTMX (wykrywane przez nagłówek
HX-Request) zwracają tylko fragment. Ten sam widok, ten sam szablon, zero duplikacji.
<!-- Donor list with HTMX pagination -->
<div id="donors-list"
hx-get="?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Skeleton placeholder -->
</div>
<!-- Server detects HX-Request header, returns partial -->
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#donors-list"
hx-swap="innerHTML"
class="btn-outline">Load more</a>
{% endif %}
Wydajność: od ponad 70 zapytań do 9
Największy zysk wydajnościowy przyszedł nie z buforowania, lecz z wyeliminowania zapytań N+1. Strony kampanii obejmują głęboko zagnieżdżone relacje: kampanie mają cele, cele mają tabele wyników, tabele wyników mają zespoły, zespoły mają członków. Naiwne przechodzenie po ORM generowało ponad 70 zapytań SQL na jedno załadowanie strony.
Zastosowaliśmy trzy strategie, aby zredukować to do 9 zapytań:
select_relateddla jednowartościowych kluczy obcych (kampania → organizacja, darowizna → cel)prefetch_relateddla relacji odwrotnych i wiele-do-wielu (kampania → cele, tabela wyników → zespoły)- Ręczne wstrzykiwanie pamięci podręcznej prefetch dla obiektów ClusterableModel z Wagtail, które omijają standardowy mechanizm prefetch w Django. Wypełniamy
_prefetched_objects_cachebezpośrednio po zapytaniu zbiorczym.
# Ręczny cache prefetch dla ClusterableModel
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in leaderboards]
# Jedno zbiorcze zapytanie o wszystkie zespoły ze wszystkich tablic wyników
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)
# Wstrzyknij do cache prefetch - unika N+1
for lb in leaderboards:
lb._prefetched_objects_cache = {
"leaderboard_team_links": teams_by_lb.get(lb.pk, []),
}
Rozgrzewanie cache'u: nie czekaj na pierwsze żądanie
Buforowanie to tylko połowa historii - pierwsze żądanie po chybieniu cache może być boleśnie wolne, jeśli renderowanie strony jest kosztowne. Rozwiązujemy to poprzez proaktywne rozgrzewanie cache: gdy nadchodzi darowizna i statystyki są przeliczane, zadanie Celery renderuje stronę kampanii przy użyciu testowego Client Django i przygotowuje cache, zanim trafi na nią jakikolwiek prawdziwy użytkownik.
Rozgrzewanie CDN jest trudniejsze. Wysyłamy żądania HTTP do brzegu CDN z ograniczaniem tempa (2 równoległe, 200 ms między żądaniami), aby nie przeciążyć origin. Klucz deduplikacji SETNX w Redis zapobiega temu, by wielu workerów Celery jednocześnie rozgrzewało tę samą stronę.
Wdrożenie: Kubernetes + Helm
Produkcyjny stos działa na Kubernetes orkiestrowanym za pomocą Helm charts. Każda usługa skaluje się niezależnie:
- Django (Gunicorn) - 2-4 repliki za service mesh
- Usługa SSE (Rust/FastAPI) - skaluje się poziomo w zależności od liczby połączeń
- Workery Celery - osobne kolejki do obliczania statystyk, rozgrzewania cache i przetwarzania darowizn
- Celery Beat - pojedyncza replika do zadań okresowych
- Nginx - reverse proxy z HTTP/2 i terminacją TLS
- Redis Sentinel - wysokodostępny klaster Redis
Obserwowalnością zajmuje się OpenTelemetry + Jaeger dla śledzenia rozproszonego, Sentry dla śledzenia błędów oraz Prometheus + Grafana dla metryk i alertów. Każda darowizna ma identyfikator śladu (trace ID), który towarzyszy jej od endpointu API, przez zadania Celery, aż po broadcast SSE.
Stos technologiczny, uzasadniony
Każda technologia w tym stosie zasługuje na swoje miejsce:
- Django - sprawdzony w boju ORM, warstwa pośrednicząca, uwierzytelnianie, panel administracyjny. Ekosystem jest niezrównany dla platformy nasyconej treścią.
- Wagtail - daje zespołom contentowym realną moc, nie poświęcając ergonomii pracy programisty. Samo StreamField to uzasadnia.
- SSE zamiast WebSocketów - prostsze, bardziej odporne, a przepływ danych i tak jest jednokierunkowy.
- Rust do SSE - utrzymuje dziesiątki tysięcy bezczynnych połączeń przy niemal zerowym narzucie pamięci. Właściwe narzędzie do właściwego zadania.
- Redis - pub/sub do rozgłaszania w czasie rzeczywistym, buforowanie dla wydajności stron, skrypty Lua do operacji atomowych. Jedna usługa, trzy krytyczne role.
- HTMX zamiast Reacta - buforowany, renderowany na serwerze HTML jest szybszy, prostszy i bardziej dostępny niż renderowane po stronie klienta SPA w tym przypadku użycia.
- Bazy danych per organizacja - izolacja danych bez złożoności wielodostępności na poziomie wierszy.
- Kubernetes + Helm - niezależne skalowanie bezstanowych usług podczas szczytowego ruchu w wydarzeniach fundraisingowych.
W architekturze oprogramowania nie ma srebrnej kuli. Ale są stosy, które pasują do kształtu problemu, a ten pasuje jak ulał. Platforma z powodzeniem obsłużyła wydarzenia fundraisingowe, które zebrały miliony dolarów, z feedami działającymi płynnie w czasie rzeczywistym na tysiącach jednoczesnych połączeń - a zespół contentowy wdraża zmiany na stronach bez ani jednej linijki kodu.
Najlepsza architektura to taka, w której każdy komponent potrafi wyjaśnić, dlaczego istnieje. Jeśli nie potrafisz uzasadnić obecności danej technologii w dwóch zdaniach, prawdopodobnie nie powinno jej tam być.