本页目录 · 11 节
有个新用户在 Orbi Cloud 上连好了仓库,页面显示「正在准备工作环境」。他等了一会儿,走了。
他走的时候,环境其实已经准备好了。页面每 10 秒才刷新一次,还没来得及告诉他。
那天夜里我开始查这件事。我做规划用的 AI 助手 Claude Code 告诉我,准备环境「要 5 分钟左右」。我回它:5 分钟,一个操作系统都装好了。
查下来,这一步一般要一分钟左右,慢的时候两分多钟。一夜之后,测试环境上变成了 10 秒左右。改动拆成 31 张票,全是 Orbi 自己写、自己评审、自己合并的(Orbi 是我在做的产品,agent 时代的软件黑灯工厂:从 Issue 到发版,不用人守着)。中间还撞上了一个 bug:审查早就点过名,我们觉得没必要修,当天晚上就撞上了。
后面会反复出现两样东西:Cloud 是我们的网站,用户在这里登录、连仓库;主机是我们自己的一台机器,给每个连上的仓库建一个沙箱。为了安全,主机只能往外连,Cloud 没法直接叫它。
先拆开看,时间到底花在哪
我们从数据库里拉出时间戳,把这一分钟拆成几段:
| 环节 | 大概要多久 | 谁说了算 |
|---|---|---|
| 请求排队,等主机来接 | 平均二十多秒,最慢接近两分钟 | 主机上每分钟跑一次的定时任务 |
| 主机准备沙箱 | 半分钟到一分钟 | 开通脚本 |
| 页面发现已经好了 | 最多 10 秒 | 状态页每 10 秒刷新一次 |
Claude Code 把两件事混在了一起:准备环境大约一分钟,看到 Orbi 给仓库的建议要三分多钟,「5 分钟」说的其实是后者。这次先压前一件。
那天还定了一条规矩:用户在操作的时间,比如挑仓库,不算等待。要压的,是他什么都做不了、只能盯着屏幕的那段。
新用户排在一个刷新任务后面
主机上有个每分钟跑一次的任务。它先给所有老用户的沙箱刷新一遍凭据(GitHub 的令牌一小时就过期),刷完了,才去看有没有新用户在等。
一轮刷新要一分半钟左右,一轮接着一轮。新用户的请求要是刚好错过了这一轮的检查,就得等这一轮跑完,再等下一轮刷新完,才轮到他。用户越多,刷新越久,新用户等得越久。
最顺手的想法是把每分钟改成每 10 秒。我们差点就这么改了。读了脚本才发现,这么改一点用都没有:刷新本来就一轮接一轮在跑,新用户永远排在刷新后面。要改的是先后顺序。
从「问得更勤」到长轮询
先想到的是把「接新用户」单独拆出来,每 2 秒问一次 Cloud。能用,但还是在轮询。我问了一句:为什么不做成基于消息的,来了就知道?
把请求从 Cloud 送到主机,有三种做法:
| 做法 | 怎么做 | 结论 |
|---|---|---|
| 短轮询,问得更勤 | 每 2 秒问一次 | 简单,但总有延迟,没活也一直问 |
| 长轮询 | 主机发一个请求,Cloud 先不回答,有新活了立刻回,20 秒没有就回个空 | 一秒左右就能接到,主机还是只往外连 |
| 推送 | Cloud 主动连主机 | 主机得开一个对外的端口,我们的设计不允许 |
选了长轮询。GitHub 的自托管执行器也是这么干的:执行器向 GitHub 挂一个 50 秒的长轮询,超时就再挂一个,外面不需要能连进来。我们的主机处境一样:能往外连,外面连不进来。
Cloud 这边加了一个接口:请求最多挂 20 秒,每秒看一次有没有新用户。它只接新来的请求,失败了要重试的老请求还走原来每分钟那一轮,不然一次小故障就会让同一个请求几秒内把重试机会用光。这一条是动手写代码之前,独立审查查出来的。
给开通脚本加一个开关,不再新写程序
第一版设计是在主机上新写一个常驻的 Python 程序,循环挂长轮询、接请求、再去调开通脚本。部署脚本得学会第一次启用它、代码一更新就重启它,它还要配一套自己的测试。
Orbi 已经按这一版开工了,我问:能不能再薄一点?
回头看现有的开通脚本,发现根本不用新程序。它可以同时跑两份;刷新是一整段,可以整段跳过;新接口返回的东西和原来的清单一模一样。于是只给脚本加了一个开关 --claim-only:跳过刷新,改去挂长轮询,接到请求后,开通的代码全部照旧。跑一轮就退出。
循环交给 systemd。完整的服务文件就这么几行:
[Unit]
Description=Orbi Cloud provisioning fast claim (long-poll)
# 这个服务本来就是要不停重启的,关掉默认的启动限流
# (10 秒内启动 5 次就停掉),否则 Cloud 一出错它就被停了
StartLimitIntervalSec=0
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/orbi-cloud/provisioner.env
ExecStart=/opt/orbi-cloud/host-provisioner/provisioner.sh --claim-only
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target
脚本每跑完一轮就退出,systemd 隔 1 秒再把它拉起来。每一轮都从磁盘重新读脚本,所以发新版本时不用写任何「重启服务」的逻辑,下一轮自然就是新代码。前提是部署时先写临时文件、再改名换上去,别直接覆盖一个正在跑的脚本。
第一版那个新程序、它的重启逻辑和那套单独的测试,最后都没写。
审查早就提醒过的 bug
接新用户从每分钟那一轮里拆出来以后,就有两样东西同时在跑。审查在设计阶段就指出了两处会撞车的地方。
一处我们修了。另一处是这样的:每个用户只有一把模型密钥,签一把新的,旧的就作废。接新用户时会签一把交给开通脚本,每分钟那一轮也会给每个用户各签一把。两边要是撞在一起,开通脚本手里的密钥刚拿到就作废了,新用户第一次连仓库就看到「工作环境准备失败」。
审查给的修法很简单:刷新时跳过正在开通的用户。Claude Code 觉得现在接单只要一秒,撞不上,就没写进票里。我也没拦。
那天晚上,长轮询在测试环境上线。前四轮新用户测试都过了,第五轮就撞上了,一模一样。自动重试一分多钟后救了回来。我们开了最高优先级的票,Orbi 半小时后合并了修复。
记下的规矩:审查点名的竞态,要么把修法写进票里,要么写一个测试证明它撞不上。「现在应该够快了」只是猜。
剩下的时间也一点点抠出来
长轮询消掉了排队,之后每一轮,请求一两秒就被接走。同一批票还抠掉了准备环境本身的时间:Orbi 的程序从主机上备好的目录直接复制,不再每次下载;用户的仓库在沙箱还在建的时候就开始下载;页面每 2 秒原地更新,不再每 10 秒整页刷新。
下面是测试环境上的一轮,从点「连接仓库」开始,第 12 秒环境就绪,第 26 秒出现「Orbi 看懂了你的仓库」:
等的那十几秒,给人看什么
十几秒还是比人能专注的 10 秒长一点(Nielsen Norman Group 的经典说法)。所以页面上不放转圈,放真实的进度:建好独立的工作环境、拉取你的代码、安装 Orbi、开始读你的仓库,每完成一步打一个勾,写上用了几秒。
这么做有研究撑着。哈佛商学院 2011 年有个 Labor Illusion 实验:两个服务给的结果一模一样,一个立刻出结果,一个要等,但一直把自己在干什么展示给你看。等 60 秒的那个,有 63% 的人选它;如果只让人干等,选它的只剩 23%。
同一篇论文还有一个反面:过程展示得很卖力,结果却很差,评价最低。我们正好踩过:中文页面上,「Orbi 看懂了你的仓库」那段摘要是英文写的。现在 Orbi 会按页面语言来写。
结果
| 以前 | 现在(测试环境) | |
|---|---|---|
| 请求排队等主机来接 | 平均二十多秒,最慢接近两分钟 | 一两秒 |
| 环境准备好 | 一般一分钟左右,最慢两分多钟 | 10 秒左右 |
| 看到「Orbi 看懂了你的仓库」 | 没有这一步 | 25 秒左右 |
| 看到 3 条建议 | 3 分多钟 | 2 分钟左右 |
新代码已经上了生产,生产上的数字还要等真实用户来了再看。
活是谁干的
我是 Lawrence Liu,Orbi 的创始人。Orbi 接一张打了 ai-ready 标签的 GitHub Issue,用它的编程 agent Pi 写代码,另开一个会话评审,合并,再发版。这次的 31 张票都是这么走完的。Claude Code 和我一起写票,每个改动合并前它都读一遍。
我做的事情就三件:判断什么重要;问了两个问题,「为什么不基于消息?」和「能不能再薄一点?」;还有,放过了那个 bug。
如果你的用户也在等
- 先把等待拆成几段,搞清楚每段归谁管,再动手。
- 用户在操作的时间不算等待,只压他只能干看的时间。
- 想调快间隔之前,先看看前面挡着它的是什么。
- 机器不能被外面连进来,就用长轮询。快路径只接新活,重试留给慢路径。
- 让 systemd 当循环:
Restart=always、RestartSec=1、StartLimitIntervalSec=0,发版时用改名替换文件。 - 审查点名的竞态,没有测试证明之前,都当成一定会发生。
- 等的时候给人看真实进度;结果看起来不对,先修好再放上屏幕。
相关
继续阅读:Orbi 怎么把一张 Issue 做到发版、Orbi Cloud 里怎么写需求、和 Claude Code 的对比,以及 Cloud。