构建实时筹款平台:我们为何押注 Django、Wagtail、Rust 和 SSE
我们如何架构一个多租户筹款活动平台,使其能够处理数千条并发的捐赠流、按组织定制的主题,以及亚秒级的实时更新——全部基于 Django、Wagtail CMS、一个 Rust SSE 微服务,且完全不涉及 WebSocket 的复杂性。
问题所在:一场募捐活动就是一次 24 小时的冲刺
想象一所大学动员 5 万名校友在同一天捐款。主页上的温度计实时攀升。排行榜不断重新洗牌。大使们看着自己的个人筹款页面数字一路上涨。在幕后,成千上万个浏览器标签页保持着连接,等待着下一个捐款事件。
这就是一场筹款活动 —— 当我们着手构建支撑这些活动的平台时,我们知道技术栈必须满足三条不可妥协的要求:
- 大规模实时 —— 数千个并发 SSE 连接,延迟低于一秒
- 内容灵活性 —— 每个组织都需要自己的品牌、页面、活动和公益项目,且由非技术人员管理
- 运维简单 —— 一支小团队必须快速交付功能,而不是当基础设施的保姆
下面就是我们如何为技术栈的每一层做出选择 —— 并给出理由的过程。
Django + Wagtail:内容强力引擎
核心 Web 应用运行在 Django 5.x 之上,并以 Wagtail 7.x 作为 CMS 层。这从来都不是一个艰难的抉择。Wagtail 为我们带来了:
- StreamField——活动页面由可复用的区块组合而成(英雄横幅、募捐温度计、排行榜、捐赠者墙、公益项目网格),内容编辑可以拖放这些区块。页面布局的调整无需开发人员介入。
- 基于 Snippet 的配置——挑战、排行榜规则、配捐以及公益项目指定,全都是 Wagtail 的 snippet。编辑创建一个挑战、设定时间窗口和金额目标,平台便会处理好其余的一切。
- 预览与修订历史——在募捐活动进行期间,编辑可以起草页面更新、预览效果,并即时发布,无需任何部署。
Wagtail 并不只是“用 Python 写的 WordPress”。它是一个真正对开发者友好的 CMS——在你需要编写定制业务逻辑时它不会碍事,同时又能给内容团队真正的自主权。
# 由可组合的 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
)
多租户架构:单一代码库,数据隔离
该平台在数据库层面隔离每个组织的数据。一个自定义的数据库路由器会根据当前的租户上下文将查询导向正确的后端,而像页面内容和活动配置这样的共享资源则存放在公共存储中。
为什么采用完全的数据库级隔离,而不是行级过滤?
- 数据主权——一些组织要求其捐赠者数据在物理上被分离。隔离的数据库让合规审计变得轻而易举。
- 性能隔离——一场在一小时内产生 10,000 笔捐款的筹款活动不会拖慢其他组织的查询。
# 动态注册租户数据库
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 给了我们:
- 自动重连——浏览器原生的
EventSourceAPI 会在断开后以指数退避方式重连。零客户端重连逻辑。 - HTTP/2 多路复用——SSE 流与其他 HTTP 请求共享同一个 TCP 连接。没有单独的协议、没有 CORS 预检的繁琐流程、没有特殊的代理配置。
- 负载均衡器透明性——SSE 只是一个长生命周期的 HTTP 响应。每一个反向代理、CDN 和负载均衡器都已经能理解它。
- 简单性——没有 ping/pong 帧、没有连接升级握手、没有二进制分帧。只是通过 HTTP 传输的 UTF-8 文本。
如果我们需要双向通信——一个聊天功能、协同编辑或交互式拍卖——那么 WebSockets 会是正确的选择。但对于捐款动态流而言,SSE 才是合适的工具。
Rust SSE 微服务:为什么不直接用 Django?
Django 是同步的。Gunicorn worker 一次只处理一个请求。要维持 5,000 个打开的 SSE 连接,就需要 5,000 个 Gunicorn worker——这根本行不通。
我们构建了一个专门的 SSE 微服务,而且构建了两次:先是用 Python,搭配 FastAPI + Hypercorn(异步 ASGI),后来又用 Rust,搭配 Axum + Tokio。两者遵循相同的架构:
- 客户端向 SSE 服务打开一个
EventSource - SSE 服务订阅一个 Redis 发布/订阅频道:
campaign:{id}:updates - 当 Django 处理一笔捐款时,一个 Celery 任务把更新后的统计数据发布到 Redis
- SSE 服务收到消息,并将其扇出给该活动所有已连接的客户端
之所以还有 Rust 版本,是因为我们想把并发上限推得更高。得益于 Tokio 的零成本异步运行时,单个 Rust 进程可以从容地维持数万个并发连接,而内存开销极小。实际上,FastAPI 版本能完美应对我们当前的负载——Rust 服务只是为某天某个活动真正爆红时准备的保险。
// 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())
}
使用 Lua 脚本防止缓存击穿
大规模使用 SSE 时有一个微妙的问题:当一个热门的活动页面加载时,成百上千的浏览器会同时打开 SSE 连接。如果我们在 Python 中检查连接数、递增它,然后再决定是否接受——就会出现竞态条件。两百个客户端可能全都读到"199 个连接",于是全都通过了。
我们用一个在 Redis 内部执行的原子 Lua 脚本解决了这个问题。该脚本读取当前计数、与上限进行比较,并在单个原子操作中完成递增。没有 TOCTOU 竞态,也不需要分布式锁。
-- 原子化的连接数限制(在 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 -- 被拒绝
end
local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count -- 已接受
按组织定制主题:规模化的 CSS 变量方案
平台上的每个组织都有自己的品牌——颜色、logo、字体。我们需要一套主题系统,它应当:
- 非开发人员可以通过 CMS 进行配置
- 高性能——不在运行时用 JavaScript 计算颜色
- 从第一天起就支持暗色模式
解决方案:一个在服务端生成 CSS 自定义属性 的 BrandColor Wagtail 模型。每个组织定义自己的调色板(primary、secondary、accent、background),并可选地为暗色模式提供覆盖值。平台会将这些渲染为页面头部中的一个 <style> 块。
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 类引用的是这些变量,而非硬编码的颜色,因此一个组织可以是深海军蓝,另一个可以是亮猩红——同样的 HTML,不同的配色。
HTMX:反 SPA 之道
前端是服务端渲染的 Django 模板,并用 HTMX 和 Alpine.js 加以增强。没有 React,没有 Vue,也没有针对 JavaScript 的构建步骤。这是一个深思熟虑的架构选择。
募捐活动页面被大量缓存。活动页面的 HTML 只生成一次,缓存在 Redis 中,然后提供给成千上万的访客。而 React SPA 则需要一个独立的 API 层,会引入客户端缓存的复杂性,并使 bug 的暴露面翻倍——而这一切换来的,只不过是与一个已缓存的服务端渲染页面相同的结果。
HTMX 以外科手术般的精准为我们提供了所需的交互能力:
- 捐赠者列表分页——
hx-get获取下一页捐赠者并将其换入 DOM。服务器返回的是一段 HTML 片段,而不是 JSON。 - 懒加载区块——沉重的排行榜计算被延后处理。页面带着占位符即时加载,随后 HTMX 发起请求将其填充。
- 外壳 + 片段模式——完整的页面加载返回完整的 HTML 外壳。HTMX 请求(通过
HX-Request头检测)只返回片段。同一个视图、同一个模板、零重复。
<!-- 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 %}
性能优化:从 70 多次查询降到 9 次
最大的性能提升并非来自缓存,而是来自消除 N+1 查询。活动页面涉及层层嵌套的关系:活动有事业目标,事业目标有排行榜,排行榜有团队,团队有成员。一次朴素的 ORM 遍历会为每次页面加载生成 70 多条 SQL 查询。
我们用三种策略把它降到了 9 条查询:
- 对单值外键使用
select_related(活动 → 组织,捐赠 → 事业目标) - 对反向关系和多对多关系使用
prefetch_related(活动 → 事业目标,排行榜 → 团队) - 对绕过 Django 标准预取机制的 Wagtail ClusterableModel 对象,采用手动预取缓存注入。我们在一次批量查询之后直接填充
_prefetched_objects_cache。
# 为 ClusterableModel 手动构建预取缓存
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in 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)
# 注入预取缓存——避免 N+1 查询
for lb in leaderboards:
lb._prefetched_objects_cache = {
"leaderboard_team_links": teams_by_lb.get(lb.pk, []),
}
缓存预热:不要等到第一个请求
缓存只是故事的一半——如果页面渲染开销很大,缓存未命中后的第一次请求可能会慢得令人痛苦。我们用主动缓存预热来解决这个问题:当一笔捐款到账、统计数据被重新计算时,一个 Celery 任务会用 Django 的测试 Client 渲染活动页面,在任何真实用户访问之前就把缓存预热好。
CDN 预热则更为棘手。我们向 CDN 边缘节点发起 HTTP 请求,并施加限流(2 个并发、请求间隔 200ms),以避免压垮源站。Redis 中的一个 SETNX 去重键可以防止多个 Celery worker 同时预热同一个页面。
部署: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 来处理。每一笔捐款都有一个 trace ID,从 API 端点一路跟随它经过 Celery 任务直到 SSE 广播。
技术栈的取舍论证
这套技术栈中的每一项技术都物有所值:
- Django——久经考验的 ORM、中间件、认证、管理后台。对于内容密集型平台,其生态系统无出其右。
- Wagtail——在不牺牲开发者使用体验的前提下,赋予内容团队真正的能力。仅 StreamField 一项就足以证明其价值。
- SSE 而非 WebSockets——更简单、更有韧性,而且数据流本来就是单向的。
- 用 Rust 实现 SSE——能以近乎零的内存开销支撑数以万计的空闲连接。合适的工具用在合适的地方。
- Redis——用于实时扇出的发布/订阅、用于页面性能的缓存、用于原子操作的 Lua 脚本。一个服务,三个关键角色。
- HTMX 而非 React——对于这个使用场景,缓存的服务端渲染 HTML 比客户端渲染的 SPA 更快、更简单、也更易于访问。
- 按组织独立的数据库——在不引入行级多租户复杂度的情况下实现数据隔离。
- Kubernetes + Helm——在筹款活动高峰流量期间对无状态服务进行独立扩缩容。
软件架构中没有银弹。但确实存在与问题形态相契合的技术栈,而这一套契合得严丝合缝。该平台已成功支撑了募集数百万美元的筹款活动,实时信息流在数千个并发连接上平稳运行——而内容团队无需写一行代码即可发布页面变更。
最好的架构是每个组件都能说清自己为何存在的架构。如果你无法用两句话为一项技术的存在辩护,那它很可能就不该出现在那里。