评论系统上线第一周,我收到了 47 条"评论"。点开之前以为是友好交流,点开之后全是博彩链接、英文药房广告和俄文乱码——唯一一条真人评论还是我自己测试时留的。这篇文章复盘渡劫博客评论系统接入 Cloudflare Turnstile 的完整过程:为什么不用 reCAPTCHA、灰度开关怎么设计、以及最关键的 fail-open 策略——人机校验挂了,评论功能不能跟着陪葬。
为什么是 Turnstile,不是 reCAPTCHA
市面上的方案其实就四类,先过一遍排除法:
| 方案 | 问题 |
|---|---|
| reCAPTCHA v2 | 九宫格图片点到怀疑人生,体验劝退 |
| reCAPTCHA v3 | 无感但依赖 Google 服务,国内访问不稳,隐私叙事也难看 |
| hCaptcha | 免费档可用,但国内节点同样不稳 |
| 自建(算术题/蜜罐) | 防不住脚本,只防得住最懒的脚本 |
Turnstile 的优势总结成三条:免费额度对个人站等于无限、国内可稳定访问 challenges.cloudflare.com、隐私叙事干净——用户不需要背着 Google 生态的包袱。它有三种模式:Managed(自动决定是否交互)、Non-interactive(几乎无感,跑一轮浏览器挑战)、Invisible(完全无感)。我们从 Non-interactive 起步,实测大部分真实用户完全感知不到校验的存在。
接入:三行前端加一次后端验证
前端部分就一个 script 加一个容器,Cloudflare 侧创建一个 widget 拿到 sitekey:
创建 widget 时有两个配置值得说:一是 Mode 的选择——Managed 还是 Non-interactive。我们先用 Managed 灰度观察了几天,后台统计里交互挑战的出现频率不低,果断切到 Non-interactive,误伤率没有可感知的变化;二是 Domains 白名单要精确到具体域名,别图省事填顶级域,否则别人把你的 sitekey 抠走用在别的站,你的风控数据全乱。
<div id="cf-turnstile" data-sitekey="0x4AAAAAAA..." data-theme="auto"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
提交时从 widget 里取 token,随评论表单一起 POST 给 Worker:
const token = turnstile.getResponse(); // 用户停留过久会是空串,见踩坑
const body = { nickname, email, content, "cf-turnstile-response": token };
await fetch("/api/comments", { method: "POST", body: JSON.stringify(body) });
后端在写入 D1 之前调用 siteverify,这是唯一必须的服务端环节:
const form = new FormData();
form.append("secret", env.TURNSTILE_SECRET);
form.append("response", token);
if (request.ip) form.append("remoteip", request.ip);
const ac = new AbortController();
setTimeout(() = ac.abort(), 3000); // 3 秒超时,见 fail-open
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", body: form, signal: ac.signal,
}).then(r => r.json());
if (!verify.success) return rejected(verify["error-codes"]);
secret key 只存在于 Worker 环境变量,这一点没有任何商量余地——把 secret 打进前端 bundle 的事故,每年都有人在犯。
灰度开关:先让 10% 流量试水
评论是站的命脉功能之一,最怕的就是校验本身把真人挡在门外。我们设计了一个三级灰度开关,由 Workers 环境变量控制,改配置不需要重新部署:
// wrangler.toml
// [vars] TURNSTILE_ROLLOUT = "10" → 只对 10% 请求启用
// TURNSTILE_ROLLOUT = "100" → 全量
// TURNSTILE_ROLLOUT = "0" → 紧急关闭(fail-open 态)
const rollout = Number(env.TURNSTILE_ROLLOUT ?? "0");
const enabled = rollout >= 100 || (rollout > 0 && hash(request.ip) % 100 < rollout);
分流用请求 IP 哈希取模,好处是同一用户多次访问结果一致,不会这次让填下次不让。灰度节奏是三天一批:
- 第 1-3 天:10%——灰度组 0 垃圾,对照组照旧每天中招,说明 widget 本身在工作;
- 第 4-6 天:50%——盯失败率日志,确认没有误伤真实提交;
- 第 7 天起:100% 全量,对照组消失,全站受益。
这里有个细节:灰度期内对照组的存在非常重要。没有对照组,你无法区分"垃圾变少是因为 Turnstile"还是"垃圾本来这周就少"。数据不会撒谎,但没设计过的数据会。
fail-open:校验挂了,评论不能陪葬
这是整个方案里最容易被忽略、宕机时最救命的设计。Turnstile 的 siteverify 是外部依赖,它超时、502、区域性故障,都不该让评论功能整体不可用。我们的策略分三层:
- 验证接口 3 秒超时:用 AbortController 硬切,超时即视为"未知";
- 超时或故障时 fail-open:评论照常写入 D1,但 status 强制为
pending,进人工审核队列,不直接外显; - 故障必须留痕:fail-open 事件写日志并计数,超过阈值触发告警——否则一次持续几小时的静默故障,能给你攒出几百条待审。
try {
const verify = await siteverify(token, ip);
if (!verify.success) return reject(verify["error-codes"]);
status = "approved";
} catch (err) {
// Turnstile 不可用:放行但进审核,绝不让用户看到 500
status = "pending";
metrics.incr("turnstile_fail_open");
console.warn("turnstile unavailable, fail-open:", err.message);
}
fail-open 与 fail-closed 的取舍没有标准答案,取决于评论的公开风险。我们的语境是技术博客、评论先审后发,所以放行进审核队列的代价远低于"全员无法评论"。换成金融、政务类场景,结论可能反过来。
告警走的是邮件:计数器每 5 分钟聚合一次,超过阈值(我们设的是 10 次)发一封邮件,标题带站点名和触发次数。8 周里这封邮件只响了 2 次,都是验证接口短时超时、几分钟后自愈的情况。知道它响过,比它没响更让人安心。
灰度之后的数据
| 指标 | 接入前 | 全量后 |
|---|---|---|
| 周垃圾评论(进 D1 的) | 47 条 | 0 条 |
| 真人评论周提交失败次数 | — | 1 次(token 过期,见下) |
| fail-open 触发次数(8 周) | — | 2 次,均为验证接口超时 |
| admin 后台 pending 队列 | — | 日常为空,故障时短暂堆积 |
8 周零垃圾入库,这个结果连我自己都有一点意外。需要坦白的是:关键词黑名单仍在工作("博彩""casino"等词直接拒),Turnstile 是主力但不是唯一防线。纵深防御的意思就是,任何单层失效都不至于崩盘。
踩坑清单
- token 五分钟过期:用户打开评论区去吃了顿饭,回来提交报
timeout-or-duplicate。解法是监听cf-turnstile的 expired 回调,自动turnstile.reset()并提示用户重试一次; - token 一次性:提交失败后用户点"重试",旧 token 直接失效,报 duplicate。所有失败路径必须先 reset 再允许二次提交;
- remoteip 别忘传:不传也能验证成功,但传了能让 Cloudflare 侧的风控信号更准,属于免费的情报;
- 错误码要翻译成人话:
invalid-input-response对用户展示应该是"校验已过期,请重试",而不是把原始错误码糊到界面上; - 灰度时对照组别撤太早:我们原定第 7 天全量,实际拖到第 10 天,就因为对照组数据还不够稳。
结论
Turnstile 接入本身的工程量小得惊人——真正花时间的是周边的设计:灰度分流保证不放任不管也不冒进,fail-open 保证外部故障不演变成功能事故,日志与计数保证故障"发生过"这件事可见。评论系统是静态站上少有的有状态组件,值得用有状态系统的标准去要求它。下一篇会聊聊这套评论系统的数据模型演化与三级状态机,看看 D1 里的表是怎么从一张裸表长成现在这样的。