跳过正文
  1. 文章/

我当前的技术栈:用最少的计算资源支撑两个产品的扩缩容

· loading · loading ·
仁才德
作者
仁才德
居住在韩国首尔的领导者和软件工程师

我目前以独立开发者的身份运营着两个产品:一个是面向上门报价的手艺人的现场估价应用,另一个是带有招聘信息、二手交易和实时消息的社区市集平台

产品完全不同,用户完全不同,但技术栈几乎一模一样——而且两者都只跑在 Fly.io 最便宜的档位 shared-cpu-1x 机器上。这个约束是刻意为之的。与其按峰值负载来配置服务器,我选择把一台小机器的性能榨到极限,精确测出它在哪里崩溃,然后让 Fly 的自动扩缩容器围绕这个实测极限来增减机器。这篇文章讲的就是让这一切成为可能的优化、阈值背后的压测数据,以及 Fly.io 上扩容和缩容到底是怎么运作的。

理念:先测准一台机器,再做乘法
#

两个应用的扩缩容策略都是同样的三步:

  1. 把一台小机器在合理范围内做到尽可能快。
  2. 压测到它崩溃为止,记下延迟塌陷时的并发水平。
  3. 配置 Fly 的自动扩缩容器,在临界点之前启动第二台机器,并在流量回落时停掉空闲机器。

这样容量就随机器数量近似线性增长,而基线成本是每个应用一台小虚拟机。下面的所有内容,都是为这三步中的某一步服务的。

让一台小机器变快
#

Granian:API 走 WSGI,WebSocket 走 uvloop ASGI
#

两个后端都是 Django 6 + Django REST Framework。API 由基于 Rust 的服务器 Granian 提供服务,但跑的是 带阻塞线程池的 WSGI,而不是 ASGI:

granian --interface wsgi project.wsgi:application \
    --workers 1 --blocking-threads 16 \
    --workers-max-rss 1400 \
    --workers-kill-timeout 15 --respawn-failed-workers

我最初用的是 --interface asgi --loop uvloop,事后看是选错了。这些应用里的每个 HTTP 视图都是同步(sync)的,而 Django 的 ASGI 处理器会以 thread_sensitive 方式跑同步视图——每个 worker 一次只跑一个。于是 ASGI 把每个 worker 的并发请求实质压到约 1 个,却还把整套 asyncio/uvloop/Channels 栈白白装进内存。带 --blocking-threads 的 WSGI 则把这些同步视图放进一个真正的线程池:1 个 worker × 16 个线程 = 16 个并发的 I/O 密集型请求(数据库读取、S3 预签名),而常驻内存比异步栈更低。

为什么只用一个 worker?因为这台 VM 是 shared-cpu-1x——只有一个核。第二个 worker 在单核上没法并行跑 Python(GIL + 单核),却要白白多占一整个常驻的 Django 进程;Granian 会直接就此发出警告。一个 worker 配 16 个线程,就是单 vCPU 机器在 I/O 密集型工作上诚实的上限。--workers-max-rss 1400 会在 worker 越过内存上限时重启它,于是失控的 PDF 或图片任务会被回收,而不是把机器一路拖向 OOM。

WebSocket 被拆到了它自己的 Granian 进程里——一个用 uvloop 和 --wsASGI 应用,为长连接跑 1 个 worker。同步 API 完全不需要 ASGI 路径:当某个视图想往 socket 推送时,它会经由 Redis 调用 async_to_sync(channel_layer.group_send),消息照样会跨进程送到 WebSocket 应用。两台服务器,各自跑自己真正擅长的接口。本地 Docker 也跑同样的 WSGI 线程池,因此测试时我的笔记本约等于一台生产机器。

热路径交给 Rust
#

只要 Python 热路径上存在 Rust 实现的替代品,我就用它:

  • orjson(通过 drf-orjson-renderer)——所有 API 响应都经由 Rust 序列化,而不是标准库的 JSON 编码器。
  • blurhash-rs——在上传时计算图片占位符。
  • uuid-utils——Rust 实现的 UUID 生成。
  • psycopg 3 (binary)——C 加速的 Postgres 驱动。

这些都不改变架构,只是抬高了一颗 vCPU 在需要第二台机器之前所能承受的上限。

消灭隐藏的延迟税
#

两个代码库里最大的一次性能提升不是算法,而是连接复用。在 Granian 的线程池下,每个新线程都会重新构建一个 boto3 的 S3 客户端,而每个返回照片或 PDF 的接口都在支付这笔开销。自定义的 SharedConnectionS3Storage 类改为按进程缓存客户端之后:

新线程上生成签名 URL 的耗时从 约 150ms 降到约 1ms

同样的教训也适用于数据库:Fly 的 Postgres 位于 PgBouncer 之后,所以应用设置 conn_max_age=0,把连接复用交给连接池,并用 5 秒的 connect_timeout 快速失败,避免 worker 挂在失效的 SSL 连接上。

把缓存放在真正的瓶颈前面
#

压测社区市集时发现了一个值得记住的事实:天花板是 Postgres 连接数,而不是 CPU。浏览流量绝大多数是匿名的、用户之间完全相同,而在高负载下,这些相同的列表查询会在 CPU 闲置的同时把连接池打满。

解决办法是给匿名列表页加一层 Redis 缓存,用按命名空间的版本计数器做失效——一次写操作只需把一个计数器加一(O(1),无需管理任何键),该资源的所有缓存页面立即失效。再用一个短 TTL 兜底。让浏览页面直接从 Redis 出去,真正的瓶颈就消失了,小机器可以把连接留给已登录的流量。

不要消息代理的后台任务
#

两个应用都用 django-tasks-db——Django 的数据库后端任务框架——而不是 Celery。一个独立的 db_worker 进程轮询数据库来处理任务:图片处理、PDF 生成、机器翻译、提醒推送。没有消息代理,没有需要额外估算容量和监控的服务,API 进程也永远不会被重活阻塞。这个 worker 甚至绕过 PgBouncer 直连 Postgres,因为长时间运行的任务和事务级连接池合不来。

内存:jemalloc 和交换空间缓冲
#

图片和 PDF 处理造成的内存尖峰,是按稳态估算内存时无法预测的。两个便宜的缓解手段:在 Docker 镜像里预加载 jemalloc 来减少内存碎片,以及给每台机器都配交换空间(API 机器 2GB,WebSocket 机器 1GB),这样尖峰来临时进程会把内存换出到磁盘,而不是被 OOM 杀掉。交换空间不是性能特性——它是一份保险,让我能把内存配置一直保持在小规格。

压力测试:找到拐点
#

阈值只有在被实测过之后才站得住脚。每个项目都有自己的压测工具:

  • 估价应用有一个自定义的 Python 压测框架(scripts/stress-test.py),扫描各个并发级别并报告 RPS、p50/p95/p99 和错误率,还有管理命令可以生成数百条真实形态的作业数据,让列表接口在真实数据量下被查询。
  • 社区市集用 Locust 以 30 秒为步长从 50 爬升到 500 用户来寻找饱和点,接口权重按真实的浏览为主的流量设定;另有一套独立的 k6 测试,以 p95 < 2 秒为阈值,对生产环境从 5 爬升到 50 个虚拟用户。

估价应用扫描的代表性数据(1 个 Granian worker × 16 个阻塞线程,开发机上的 Docker):

接口并发RPSp95错误率
health2560252ms0%
作业列表25309260ms0%
客户列表2535761ms0%
作业列表50309347ms0%

而决定其他一切的那个数字,来自在单台 shared-cpu-1x 机器上对社区市集的压测。在旧的 ASGI 构建里,这曾是一堵硬墙:同步视图被实质串行化到大约3 个可用槽位,于是 p95 在约 5 个并发请求以内保持在 300ms 以下,随后当一切都排在 thread_sensitive 锁后面时,超过约 8–10 个并发就塌陷。把拐点往外推的,正是把 API 迁到 WSGI 线程池这件事——每台机器的真实并发槽位从约 3 个变成了 16 个,而这个新的上限就是整个自动扩缩容配置的依据。

Fly.io 上的扩缩容是怎么运作的
#

Fly 的模型很简单:你的应用是代理后面的一组相同机器,代理统计每台机器的负载。你只需要声明两个数字:

[http_service.concurrency]
  type = 'requests'
  soft_limit = 12   # 超过就启动另一台机器
  hard_limit = 24   # 超过就直接卸载负载而不是排队

auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1

**扩容:**当一台机器上的在途(in-flight)请求超过 soft limit,代理会启动一台停止中的机器,并把新流量路由过去。WSGI 线程池让每台机器有 16 个真实并发槽位,所以 soft limit 设为 12——约为池子的 75%,于是第二台机器会在线程还有余量时启动,而不是等用户已经在承受数秒延迟之后。hard limit 24(池子的 1.5 倍)是熔断器:短促的突发会在线程上排队,超过它,代理宁可卸载负载,也不让一台机器被压垮。(旧的 ASGI 构建只有约 3 个真实槽位,用的是 8/20;正是把池子拓宽,才让这两个数字都能往上调。)

**缩容:**流量回落时,Fly 自动停掉空闲机器,最低降到 min_machines_running = 1。停止的机器不产生计算费用(只收 rootfs 存储费),当代理再次需要它时,冷启动不到一秒。每个应用的基线成本就是字面意义上的一台小虚拟机,而突发容量随时待命。

一个微妙之处:并发类型是 requests 而不是 connections。现代客户端会一直保持 keep-alive 连接,所以按连接数统计时数字在真实负载下几乎不动,自动扩缩容永远不会触发。统计在途请求,才能追踪真实的工作量。

WebSocket 在另一条轴上扩缩
#

社区市集把 WebSocket 服务器拆成了第二个 Fly 应用,与 API 共享同一个 Docker 镜像。API 应用用 WSGI 跑 1 个 worker × 16 个阻塞线程,按请求计数(soft limit 12);WebSocket 应用在一台 512MB 的机器上用 ASGI 配 uvloop 跑 1 个 worker,按连接计数:

[http_service.concurrency]
  type = 'connections'
  soft_limit = 1500
  hard_limit = 2000

一条长连接维持起来几乎零成本,却会占用一个槽位好几个小时;而一个 HTTP 请求只占用槽位几毫秒——同一个扩缩容信号无法同时服务两者。拆开之后,每个机群都按对自己诚实的指标扩缩,而空闲的 WebSocket 容量只花 512MB,不是 2GB。在 Channels 这一侧,Redis 层以 capacity: 1500、消息 60 秒过期运行,并每 30 秒发送 socket keepalive——因为 Fly 的 Redis 代理会把 Channels 赖以工作的长时间 BRPOP 连接当作空闲连接掐掉。

把计算卸载到边缘和客户端
#

最便宜的服务器工作,是服务器根本不用做的工作:

  • 静态文件和媒体放在 Fly 的 S3 兼容存储 Tigris 上,私有媒体通过签名 URL 走 CDN 分发——图片的每一个字节都不经过 Django 机器。
  • **AI 推理跑在用户的手机上。**两个应用都通过 llama.rn 内嵌本地 LLM(社区市集流式运行一个约 2.7GB 的量化 4B 模型;估价应用则把工具调用接进数据层,助手可以直接创建和更新作业)。服务端推理成本为零,而且离线也能用。
  • 离线优先的移动端(Expo SDK 57,设备上用 SQLite,网页端用 Dexie,带重试的同步队列)意味着应用会把写操作攒批、容忍信号盲区,而不是每敲一个键就向 API 发一次琐碎请求。

其余的技术栈,简单说
#

两个应用都用 Firebase Auth 加 WebAuthn 通行密钥(passkey),并有一个自定义中间件在协议升级完成前认证 WebSocket 连接。生产用 Postgres,开发用 SQLite。后台管理用 django-unfold,审计追踪用 django-simple-history。工具链是 uv 和 ruff。移动端是 React Native 0.86 / React 19 / TypeScript,配 Expo Router、Gluestack UI v3 和 Tailwind 4,OTA 更新走自托管的 Expo Updates,订阅由 RevenueCat 处理。质量关卡:后端 pytest + mypy + bandit,客户端 Jest + Detox + Playwright。

值得你拿走的东西
#

可迁移的想法不是某个具体的库,而是这个循环本身:把一台便宜机器做到真正的快(Rust 热路径、连接复用、在真正的瓶颈前面加缓存、能卸给 CDN 和客户端的都卸掉);压测到找出它到底在哪个并发数崩溃;把自动扩缩容的 soft limit 设在那个拐点上,让空闲机器停机,把最小机群保持在 1。两个生产应用、实时功能、AI 助手——而平时的账单,只是这个平台上最便宜的几台虚拟机。