静态站表单 5 条免费链路实测 13 天:3 条全死,接口还在返回 ok:true
一个静态站最脆的地方不是页面,是表单。页面挂了你能看见,表单挂了你看不见——访客点了提交,转圈两秒,出现「提交成功」,然后那条询盘进了黑洞。
我给自己的站做了 5 条并行链路(业内叫冗余),每天开工第一条动作就是真发一条探针,逐腿读返回。连续 13 天,得到一张很难看的表:5 条腿里 3 条 13/13 天全失败,而接口顶层从第一天到最后一天都返回 ok:true。这篇文章把原始读数、错误码成因、和一套能真正判死活的验收清单全部摊开。
一、被测的 5 条腿长什么样
表单是 Cloudflare Pages Functions 写的一个 /api/lead,收到 POST 后往 5 个方向各发一份,最后聚合成一个 JSON 返回:
| 腿 | 实现 | 设计意图 | 成本 |
|---|---|---|---|
| 腿1 | webhook.site 临时桶 ×5 | 落库(能回读原始 JSON) | 免费,50 条 / 7 天 |
| 腿2 | Amazon SES | 发到自己的邮箱 | 按封计费,几分钱 |
| 腿3 | ntfy.sh | 手机推送 | 免费,有日额度 |
| 腿4 | FormSubmit | 第三方转发到邮箱 | 免费,有日额度 |
| 腿5 | 自建 SMTP 中继 | 备用发信 | 自有服务器 |
这个结构看起来很稳:任何一条挂了还有其他四条。实际结果正好相反——它把「全死」伪装成了「全活」。
二、13 天逐日读数(原始值,未加工)
下表是每天 06:30 班第一条动作的实测返回,sent 为成功,其余为原始错误码:
| 日期 | 腿1 桶 | 腿2 SES | 腿3 ntfy | 腿4 FormSubmit | 腿5 SMTP |
|---|---|---|---|---|---|
| 10-20 | sent×5 | sent | 522 | 429 | 401 |
| 10-21 | sent×5 | sent | 429 | 429 | 401 |
| 10-22 | sent×5 | sent | 429 | 429 | 401 |
| 10-23 | sent×5 | sent | 522 | 429 | 401 |
| 10-24 | sent×5 | sent | 429 | 429 | 401 |
| 10-25 | sent×5 | sent | 429 | 429(当日实收 1 封) | 401 |
| 10-26 | sent×5 | sent | 429 | 429 | 401 |
| 10-27 | sent×5 | sent | 429 | 429 | 401 |
| 10-28 | sent×5 | sent | 522 | 429 | 401 |
| 10-29 | sent×5 | sent | 522 | 429 | 401 |
| 10-30 | sent×5 | sent | 429 | 521 | 401 |
| 10-31 | sent×5 | sent | 522 | 429 | 401 |
| 11-01 | sent×5 | sent | 429 | 429 | 401 |
| 11-02 | sent×5 | sent | 522 | 429 | 401 |
统计:腿1 13/13 成功、腿2 13/13 成功、腿3 0/13、腿4 0/13、腿5 0/13。所谓「5 条冗余」,有效冗余是 2 条。
三、三条腿为什么全死:错误码各自的含义
1) ntfy.sh:429 和 522 轮流出现
429 是免费日额度用尽,522 是 Cloudflare 侧连不上源站(ntfy 自己的边缘超时)。这两个码在 13 天里交替出现,说明这条腿不是「被墙」也不是「配置错」,而是免费额度 + 服务端不稳定。同一个 URL、同一个请求体,隔天结果不同——这种腿你永远无法用「上次是好的」来推断今天。
2) FormSubmit:429 是日额度,521 是源站挂了
FormSubmit 免费层按域名维度限流,共享同一出口 IP 的请求很容易互相踩。521 出现在 10-30,是 Cloudflare 标记「源站拒绝连接」,与你的表单无关,等它自己恢复。
3) 自建 SMTP:401 不是密码错,是 nginx 挡在前面
这条最有教育意义。返回体是一整页 HTML:<title>401 Authorization Required</title> ... nginx/1.27.1。也就是说请求在 HTTP 层就被 nginx 的 basic auth 拦住了,根本没走到应用层的账号密码校验。看到 401 就去改 SMTP 密码,是典型的层级误判——先看是谁返回的(Server 头),再决定改哪一层。
四、最贵的一条教训:ok:true 只证明函数跑完了
这是整件事的核心。我的接口返回长这样:
{
"ok": true,
"results": {
"sink": "sent | sent | sent | sent | sent",
"ses": "sent",
"ntfy": "failed:522",
"formsubmit": "failed:429",
"mail": "failed:401"
},
"intent": "normal"
}
ok:true 的含义是「函数执行完毕、没有抛异常」。它不代表任何一条腿送达。如果你只监控 ok,你的监控会连续 13 天报「全部正常」,同时三条腿一次都没成功过。
判活的最小单位不是「请求」,是「回执」。请求返回 200 只说明对方接收了这个 HTTP 包;只有对方能在自己的存储里被读回来,才算送达。
我自己的判据后来改成三条,缺一条就不算健康:
- 逐腿读:解析
results的每个字段,不看ok; - 回读验证:往桶里发一条带唯一标记的探针,再用 GET 把桶内容读回来,确认标记在里面;
- 至少一条腿真的进人工视野:邮箱里能看到、或手机能收到推送。落库成功但没人看,等于没收到。
五、webhook.site 免费桶的三个硬限(9 次爆桶换来的)
腿1 虽然 13/13 成功,但它有另一类死法——不报错地死。免费桶的规则:
| 限制 | 实测值 | 失效表现 |
|---|---|---|
| 条数上限 | 50 条 / 桶 | 满了之后 total 冻在 50,新请求仍返回 200,但不再入库 |
| 有效期 | 7 天 | 过期后桶 404,之前的数据一起消失 |
| 建桶限流 | 约 52 秒 / 次 | 连建两个报 try again in ~52s |
第 2 条最阴:过期前没有任何提醒。所以我的做法是把「再造新桶」写进每 2~3 天的固定动作,并且新桶建好立刻发一条探针确认能收——建桶成功不等于能收数据,这两件事要分别验。
条数上限这一条我踩了 9 次。前 8 次都是「桶满了 → 换桶 → 又满了」,直到第 8 次我才意识到问题不在桶,在流量估算。
六、用桶的容量反算真实流量:我的估算错了 70 倍
遥测(页面访问计数)也写在这个桶里,为了不爆桶我做了抽样。抽样率的调整史,本身就是一部误判史:
| 时间 | 抽样率 | 依据 | 结果 |
|---|---|---|---|
| 10-30 | 1/6 | 凭感觉「日访问很少」 | 当天爆桶 |
| 10-30 晚 | 1/10 | 同上,保守一档 | 24 小时内积 48/50 |
| 10-31 | 1/60 | 按 48 条 × 10 反算 ≈ 480 次/天 | 勉强稳住 |
| 11-01 | 1/150 | 留 3 倍余量(480 ÷ 150 ≈ 3 条/天) | 24 小时仅 1 条 |
关键推理:1/10 抽样下 24 小时积了 48 条,反推真实命中 = 480 次/天。而我此前一直按「一天七八次访问」在做容量假设——差了 70 倍。这意味着在那之前所有基于「流量很小、不用管容量」的判断全部不可信。
七、静态站表单的最小可靠架构
踩完这些坑,如果重做一次,我会这样搭(按重要性排序,前三项占 90% 的可靠性):
- 自己可控的落库(必须有):Cloudflare KV / D1 / 或任何你能直接查询的存储。这是唯一不依赖第三方额度的环节,也是唯一能事后审计的环节。
- 一条事务性邮件(必须有):SES / 或你自己的 SMTP。要能查发送回执,不能只看接口返回。
- 一个「人一定能看到」的提醒(必须有):推到手机或微信。邮箱容易淹没在通知里。
- 第三方免费转发(可选,作冷备):FormSubmit 这类可以留着,但别把它算进可靠性分子——它有日额度、会 521、会 429。
- webhook 临时桶(可选,作取证):适合当「原始请求体存档」,别当主存储。50 条 / 7 天不够任何真实业务。
反面模式很明确:把 5 条免费腿当冗余。它们的失效模式高度相关——都是免费额度、都是同一类限流、都是没有 SLA 的公益服务。相关性高的冗余,等于没有冗余。真正的冗余应该是「一条付费 + 一条自建」,而不是「五条免费」。
八、可直接抄走的验收清单
| # | 检查项 | 通过判据 |
|---|---|---|
| 1 | 逐腿状态 | 能打印每一条腿的独立结果,不聚合 |
| 2 | 回读 | 发唯一标记 → GET 读回 → 标记在里面 |
| 3 | 人工可达 | 邮箱/手机上真的看到那条探针 |
| 4 | 存储余量 | 当前条数 / 上限 < 50%,且知道过期日 |
| 5 | 失败可解释 | 每个错误码能说清是「限流 / 源站挂 / 鉴权 / 配置」哪一类 |
这五条做完,大概 20 分钟。它挡住的不是技术问题,是「以为自己在收询盘、其实一条都没收到」这种最贵的沉默失败。
看这套巡检工具箱(含 9 项验收脚本)常见问题
接口返回 200 就代表表单提交成功了吗?
不代表。200 只说明服务端接收了请求。要确认「消息到了」,必须能在下游存储里读回这条数据,或在下游收件箱里看到它。
FormSubmit 一直 429 是封号了吗?
多数是日额度或共享出口 IP 的限流,隔天会恢复。我实测过「返回 429 但当天仍有一封实际送达」,所以单次 429 不能判定链路死亡,要按天统计成功率。
webhook.site 免费桶能当询盘数据库用吗?
不建议。50 条上限 + 7 天过期 + 建桶限流,任一条都会让你在某天静默丢数据。它适合临时调试和原始请求取证,不适合当业务存储。
为什么我的多通道冗余没起作用?
如果几条通道都是免费第三方服务,它们的失效原因高度相关(额度、限流、源站稳定性),会同时死。冗余要选失效原因不同的通道,比如一条自建 + 一条付费。
静态站没有后端,怎么低成本做到「一定收得到」?
最小组合是:Pages Functions 收请求 → 写入 KV/D1(自己可控)→ 调一条事务性邮件 API。第三方转发只当冷备,不进可靠性分子。成本大约是一天几分钱。