我目前以独立开发者的身份运营着两个产品:一个是面向上门报价的手艺人的现场估价应用,另一个是带有招聘信息、二手交易和实时消息的社区市集平台。
产品完全不同,用户完全不同,但技术栈几乎一模一样——而且两者都只跑在 Fly.io 最便宜的档位 shared-cpu-1x 机器上。这个约束是刻意为之的。与其按峰值负载来配置服务器,我选择把一台小机器的性能榨到极限,精确测出它在哪里崩溃,然后让 Fly 的自动扩缩容器围绕这个实测极限来增减机器。这篇文章讲的就是让这一切成为可能的优化、阈值背后的压测数据,以及 Fly.io 上扩容和缩容到底是怎么运作的。
理念:先测准一台机器,再做乘法#
两个应用的扩缩容策略都是同样的三步:
- 把一台小机器在合理范围内做到尽可能快。
- 压测到它崩溃为止,记下延迟塌陷时的并发水平。
- 配置 Fly 的自动扩缩容器,在临界点之前启动第二台机器,并在流量回落时停掉空闲机器。
这样容量就随机器数量近似线性增长,而基线成本是每个应用一台小虚拟机。下面的所有内容,都是为这三步中的某一步服务的。
让一台小机器变快#
Granian 加 uvloop#
两个后端都是 Django 6 + Django REST Framework,由基于 Rust 的 ASGI 服务器 Granian 配合 uvloop 事件循环来提供服务:
granian --interface asgi project.asgi:application \
--workers 3 --loop uvloop --ws \
--workers-kill-timeout 15 --respawn-failed-workers对单 vCPU 的共享机器来说,3 个 worker 是甜点位——足以让 I/O 等待相互重叠,又不至于把 CPU 压到抖动。Granian 在一个进程里同时支持 HTTP/1.1、HTTP/2 和 WebSocket,所以没有额外吃内存的代理层。本地 Docker 也跑同样的 3 个 worker,因此测试时我的笔记本约等于一台生产机器。
热路径交给 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 个虚拟用户。
估价应用扫描的代表性数据(3 个 Granian worker,开发机上的 Docker):
| 接口 | 并发 | RPS | p95 | 错误率 |
|---|---|---|---|---|
| health | 25 | 602 | 52ms | 0% |
| 作业列表 | 25 | 309 | 260ms | 0% |
| 客户列表 | 25 | 357 | 61ms | 0% |
| 作业列表 | 50 | 309 | 347ms | 0% |
而决定其他一切的那个数字,来自在单台 shared-cpu-1x 机器上对社区市集的压测:p95 在约 5 个并发请求以内保持在 300ms 以下,超过约 8–10 个并发就会塌陷——吞吐量下降,延迟飙升到数秒级。这个拐点就是整个自动扩缩容配置的依据。
Fly.io 上的扩缩容是怎么运作的#
Fly 的模型很简单:你的应用是代理后面的一组相同机器,代理统计每台机器的负载。你只需要声明两个数字:
[http_service.concurrency]
type = 'requests'
soft_limit = 8 # 超过就启动另一台机器
hard_limit = 20 # 超过就直接卸载负载而不是排队
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1**扩容:**当一台机器上的在途(in-flight)请求超过 soft limit,代理会启动一台停止中的机器,并把新流量路由过去。既然压测显示在 8–10 个并发请求处塌陷,soft limit 就设为 8——第二台机器恰好在拐点处启动,而不是等用户已经在承受数秒延迟之后。hard limit 20 是熔断器:超过它,代理宁可卸载负载,也不让一台机器被压垮。
**缩容:**流量回落时,Fly 自动停掉空闲机器,最低降到 min_machines_running = 1。停止的机器不产生计算费用(只收 rootfs 存储费),当代理再次需要它时,冷启动不到一秒。每个应用的基线成本就是字面意义上的一台小虚拟机,而突发容量随时待命。
一个微妙之处:并发类型是 requests 而不是 connections。现代客户端会一直保持 keep-alive 连接,所以按连接数统计时数字在真实负载下几乎不动,自动扩缩容永远不会触发。统计在途请求,才能追踪真实的工作量。
WebSocket 在另一条轴上扩缩#
社区市集把 WebSocket 服务器拆成了第二个 Fly 应用,与 API 共享同一个 Docker 镜像。API 应用跑 3 个 worker,按请求计数(soft limit 8);WebSocket 应用在一台 512MB 的机器上跑 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 助手——而平时的账单,只是这个平台上最便宜的几台虚拟机。

