第一次做测速系统时,我踩了一个特别典型的坑:在云主机上部署了个测速页面,用户点一下"开始测速",结果返回的是云机房到目标站的速度。用户直接说"我家宽带没有这么慢,你测的不是我这条线"。测速的本质是测量一条网络路径的传输表现,路径两端的位置决定一切,单点测速只能代表单个机房,没有参考价值。后来我把整套调度逻辑搬到了 Cloudflare Worker 上,让测速任务从全球边缘节点发起,才算是真正解决了"测哪条路"的问题。但这套分布式测速调度系统最让我头疼的其实不是 Worker 脚本本身,而是数据层:任务状态放哪、测速结果怎么落库、多个边缘节点如何避免重复执行同一个任务。KV 和 D1 这两类存储,配合好了就是调度系统的地基,配合不好就是事故现场。这篇文章就把我在数据层设计上的完整思路、可复用的代码和踩过的坑都写出来。
1. 先搞明白:分布式测速调度到底在调度什么
1.1 测速不是测服务器,而是测"网络路径"
很多第一次做测速产品的同学会默认"我有一台带宽很大的服务器,用户来测,我给它喂数据就行"。实际上用户感知到的速度取决于用户本地网络、运营商互联、骨干网路由、目标服务器带宽,这一整条链路里任何一段出现瓶颈,速度就上不去。单点服务器测到的数据,只代表该服务器所在机房到目标站点的路径状况,完全不能代表普通用户的访问体验。
所以一个正经的测速系统,必须把探测点布到不同地理位置。Cloudflare Worker 恰好是个很顺手的载体:脚本不需要自己管机器,平台会自动运行在全球边缘节点上,用户请求落到哪个城市,测速就从哪个城市的边缘节点发起。再加上 Worker 可以用 fetch 发起 HTTP 请求,天然适合做文件下载测速、接口延迟探测这一类任务。
1.2 调度系统拆开看,是三个环节的联动
整个分布式测速调度系统可以拆成三段:
- 派单:生成一批测速任务,明确每个任务要测哪个目标 URL,期望从哪个区域发起。
- 执行:边缘 Worker 收到任务后,发起真实的 HTTP 请求,测量 DNS 耗时、TTFB、下载速度等指标。
- 回收:把各地节点的测速结果归集起来,做汇总、去重、出报表。
这三段单独做都不难,难的是放在分布式环境里。派单阶段要避免同一个任务被多个节点重复执行;执行阶段要允许节点失败、超时、重试;回收阶段要处理结果重复上报、数据乱序、聚合查询慢。这些问题的根源都在数据层,而不是计算逻辑。
1.3 为什么说数据层是这种系统的分水岭
如果把任务状态、节点状态、测速结果全放进程内存里,Worker 一重启就丢了。如果全部放到一个中心化 MySQL,边缘节点回写延迟高,写入多了还会拖垮数据库。这个时候 Cloudflare 自带的 KV 和 D1 提供了一个很务实的组合:KV 做全球复制的状态存储,D1 做关系型结果落库。但这个组合并不是开箱即用的,需要非常明确地划分边界。接下来我会用实际项目中的设计,把这条边界一条条画清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. KV 与 D1 的分工:先分清"状态"和"记录"
2.1 两种存储的定位完全不同
很多刚接触 Cloudflare 生态的人会把 KV 和 D1 放在一起比较,觉得都是"数据库"。实际上它们的性格差异很大:
| 维度 | Cloudflare KV | Cloudflare D1 |
|---|---|---|
| 底层模型 | 全局复制的键值存储 | 基于 SQLite 的关系型数据库 |
| 一致性 | 最终一致,写入后读可能读到旧值 | 写入为主区域的 SQLite,读取可通过副本扩展 |
| 延迟 | 读极快,边缘就近返回 | 读也不差,但涉及主区域写时有一定延迟 |
| 查询能力 | 只能按 Key 读写,支持前缀列举但能力有限 | 完整 SQL,支持聚合、JOIN、索引 |
| 典型用途 | 配置、状态、缓存、会话 | 结构化业务数据、报表、长期存储 |
一句话总结:KV 擅长"读多写少、Key 明确、允许短暂不一致"的数据;D1 擅长"需要结构化查询、需要聚合统计、需要数据长期可靠"的数据。
2.2 数据层整体切分:状态进 KV,结果进 D1
我在项目里把数据分成了两大块。
控制面数据,包括任务状态、任务锁、节点心跳、调度配置。这类数据的特点是:每个 Worker 在执行前都要查一次,更新频率高,但单条数据小,丢了一两秒影响不大。我放 KV。
记录面数据,包括每次测速的结果明细、按地区聚合的统计报表、用户测试历史。这类数据需要持久化,要支持按时间、区域、任务维度查询。我放 D1。
这个切分听起来简单,但真正执行时容易混。比如我一开始把"测速结果"也写进 KV,想着 KV 写读都快,结果发现要根据某个时间段做统计时,KV 完全使不上劲,只能把所有 Key 拉出来遍历,效率很低,而且 KV 有 finally consistent,列 Key 时可能漏掉刚写入的数据。从那时起我就定了一条原则:只要是"将来需要再查出来算一遍"的数据,必须进 D1。
2.3 一个判断标准:这个数据下一秒变了会怎样
如果拿不准一条数据放 KV 还是 D1,就问自己:这个数据下一秒变了,对系统影响大不大?
- 任务状态从 PENDING 变成 RUNNING,晚几秒被其他节点看到,影响不大,可以直接读 KV。
- 测速结果如果丢失或者重复,报表就会错,影响很大,必须写入 D1 并用唯一键约束。
- 一个热门报告页面被请求一千次,每次都查 D1 太贵,可以把聚合结果作为 kv 缓存放到 KV 里,设个 TTL。
这个"状态 vs 记录"的判断框架,帮我解决了很多模棱两可的设计问题。
3. KV 层设计:任务状态、缓存与自定义 Key 方案
3.1 别把 KV 当数据库表:Key 结构要精心设计
KV 的力大砖飞之处在于 O(1) 读,但它没有二级索引,没有条件查询。所以 Key 的结构本身就是你唯一的数据模型。我的拆分习惯是:
code复制speed:task:{taskId}:status
speed:task:{taskId}:context
speed:node:{nodeId}:lastSeen
speed:cache:report:daily:{yyyy-mm-dd}:{region}
每个部分要严格统一:前缀(speed)、实体(task/node/cache)、ID、字段。为了不在代码里反复出现字符串拼接错误,我封装了一个"生成自定义 KV" 的小函数:
js复制function buildKey(...parts) {
return parts.map(p => String(p).replace(/[:]/g, '_')).join(':');
}
// 使用
await env.TASK_KV.put(buildKey('speed', 'task', taskId, 'status'), 'PENDING', {
expirationTtl: 3600
});
Key 设计时要特别注意:KV 不支持真正高效的"扫描某前缀下所有 Key,再按值过滤"这类操作。前缀列举虽然可以用,但性能不可控。所以我只把 KV 当成"我明确知道 Key 是什么"的存储,需要按条件查的,直接用 D1。
3.2 TTL 是 KV 最好的隐形运维
KV 的 expirationTtl 可以给 Key 设置自动过期时间。这个机制对调度系统帮助很大,因为很多中间状态不需要永久保留,比如:
- 临时任务锁:设置 300 秒过期,防止 Worker 崩溃后锁永远存在。
- 任务上下文缓存:设置 3600 秒过期,任务完成后即使忘记清理,也会自动消失。
- 报表缓存:设置 600 秒过期,让数据定期刷新。
不过 TTL 不是万能的。如果任务实际执行时间可能超过 TTL,设置得太短会导致状态提前消失,后续任务判断"没有状态"而重新拉起。我的经验是把 TTL 设置为"预期最大执行时间的两倍",同时给 D1 里的任务记录保留最终状态,KV 丢了状态不是灾难,D1 没有记录才是。
3.3 踩坑实录:KV 不是 Redis,没有 setIfAbsent
很多后端工程师(包括我)第一反应是:KV 和 Redis 差不多,那我可以像用 RedisTemplate 那样,用 setIfAbsent 做分布式锁。这是个大坑。Cloudflare KV 的 API 只有 get、put、delete、list,put 是直接覆盖写入,没有"只有当 Key 不存在时才写入"的原子语义。即使你这么做:
js复制const existing = await env.TASK_KV.get(lockKey);
if (existing === null) {
await env.TASK_KV.put(lockKey, '1', { expirationTtl: 30 });
// 这里有并发竞态:两个 Worker 可能同时读到 null
}
在高并发调度下,两个边缘 Worker 可能同时读到 null,然后同时写入,导致同一个任务被执行两遍。KV 在这里帮不上忙。
我的解决方案是:KV 只存锁状态用于快速展示,真正决定任务归属的逻辑放到 D1 的唯一索引上。D1 是 SQLite 系,支持完整约束,可以用 INSERT OR IGNORE 实现"只有一个人能抢到任务"的语义。这一点我在后面调度闭环里会详细给出代码。
3.4 kv 缓存:把热点 Key 的命中和防护做对
这里说的 kv 缓存,不是 KV 这个产品本身,而是"存在 KV 里的缓存层"。我在项目里会把高频读的 D1 聚合结果缓存到 KV,因为 D1 的每次读都消耗配额,而 KV 读更便宜更快。
设计缓存时两个问题最容易踩:
- 缓存雪崩:所有 Key 在同一分钟过期,过期瞬间全部回源 D1,直接把数据库打满。解决很简单,TTL 加随机偏移。
- 热 Key 问题:所有用户都看全国汇总,那
report:daily:2025-06-01:all这个 Key 会非常热。虽然 KV 抗读能力很强,但回源 D1 时仍然有压力。我会拆分维度,把region、isp、node等拆分到不同 Key,再提供一个聚合 Key 作为兜底。
js复制const TTL_BASE = 600;
const ttl = TTL_BASE + Math.floor(Math.random() * 300);
await env.TASK_KV.put(
buildKey('speed', 'cache', 'report', dateStr, region),
JSON.stringify(report),
{ expirationTtl: ttl }
);
4. D1 层设计:测速结果落库、聚合查询与写入幂等
4.1 表结构:状态和结果一定要解耦
D1 是整个系统的"事实源"。我最早设计了一张大表,把任务状态、节点信息、测速结果全部塞进去,结果字段一多,每次更新都要考虑都要改多个列,查询维度还互相干扰。后来拆成了任务表和结果表。
sql复制CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
region_hint TEXT NOT NULL,
target_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS speed_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
node_id TEXT NOT NULL,
target_url TEXT NOT NULL,
task_region TEXT,
dns_ms REAL,
ttfb_ms REAL,
download_kbps REAL,
created_at INTEGER NOT NULL,
UNIQUE(task_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_results_time
ON speed_results(created_at);
CREATE INDEX IF NOT EXISTS idx_results_region_time
ON speed_results(task_region, created_at);
tasks 表存任务本身的状态流转,speed_results 表存每一条测速结果。UNIQUE(task_id, node_id) 是我故意加的约束:同一个任务,同一个节点,最多只有一条结果。这样就算 Worker 重试、网络超时后再次上报,也不会产生重复数据。
4.2 写入路径:批量执行 + 幂等更新
边缘节点测速完成后,要把结果写回 D1。最直接的做法是一条一条 INSERT,但任务多的时候效率不高。D1 提供了 batch 接口,可以把多条语句放在一次请求里执行。
js复制await env.DB.batch([
env.DB.prepare(
`INSERT INTO speed_results
(task_id, node_id, target_url, task_region, dns_ms, ttfb_ms, download_kbps, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(task_id, node_id) DO UPDATE SET
dns_ms = excluded.dns_ms,
ttfb_ms = excluded.ttfb_ms,
download_kbps = excluded.download_kbps`
).bind(taskId, nodeId, targetUrl, region, dnsMs, ttfbMs, downloadKbps, Date.now()),
env.DB.prepare(
`UPDATE tasks SET status = 'DONE', updated_at = ? WHERE task_id = ?`
).bind(Date.now(), taskId)
]);
ON CONFLICT ... DO UPDATE 保证了幂等:重复上报时不会报错,而是用新值覆盖旧值。这一点在分布式调度里非常重要,因为 Worker 可能因为网络闪断发起重试,重试不应该是灾难,而应该是无害的。
4.3 聚合查询:让报表不再全表扫描
测速结果一旦多了,最常做的操作就是按时间段、按地区算平均下载速度、P90、任务数量。这类聚合在 SQLite 里很顺手:
sql复制SELECT
task_region,
COUNT(*) AS sample_count,
AVG(download_kbps) AS avg_kbps
FROM speed_results
WHERE created_at >= ? AND created_at < ?
GROUP BY task_region
ORDER BY avg_kbps DESC;
配合 idx_results_region_time 索引,扫描范围会小很多。刚开始我没有建索引,结果量到几万行后查询明显变慢,加了复合索引后,性能问题立刻缓解。
这里也要提醒:SQLite 的 date() 函数可以直接把毫秒时间戳转成日期字符串,但如果你在 WHERE 里对 created_at 做函数转换,索引就失效了。所以我保存的是原始毫秒时间戳,分组时再转换,这样能用上索引。
4.4 D1 查询前的缓存策略
上一步的聚合结果,我不会每次都直接查 D1。因为报表页可能一分钟被刷几十次,每次都全量聚合不划算。我的做法是先查 KV 缓存,有就直接返回,没有才查 D1,然后把结果写回 KV。
这其实和 LLM 里的 "KV Cache" 思路很像:把活跃计算的关键状态缓存下来,避免每次都重新计算。对测速报表来说,最近一小时的数据变化快,缓存 TTL 设短点;更早的数据几乎不变,缓存 TTL 设长点。缓存框架用上一章的 buildKey 生成 Key,再把聚合 JSON 塞进去即可。
5. 调度闭环:从创建任务到结果回写的完整流程
5.1 状态机与五步闭环
整个调度系统的核心是一个状态机:
code复制PENDING -> RUNNING -> DONE
\--> TIMEOUT
\--> ERROR
一条任务从创建到完成,在数据层要经历五步:
- 调度入口创建任务,向 D1
tasks表插入一条PENDING记录,同时向 KV 写一份任务上下文。 - 边缘 Worker 通过 Cron Trigger 或 API 被唤醒,从 D1 查询一批
PENDING任务。 - Worker 尝试"领取"任务。这一步用
task_claims表配合唯一索引实现,只有抢到插入权的 Worker 才能把任务状态改为RUNNING。 - Worker 发起真实测速请求,测量 DNS、TTFB、下载速度。
- 测速完成后,结果写入
speed_results,tasks.status更新为DONE,同时删除 KV 里的临时状态。
第 3 步是整个设计的关键。因为同一时刻可能有多台 Worker 拉取同一个任务,如果不在数据层做拦截,就会重复执行。我使用 D1 唯一索引来实现所有 Worker 只有一个成功:
sql复制CREATE TABLE IF NOT EXISTS task_claims (
task_id TEXT NOT NULL,
node_id TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
PRIMARY KEY (task_id, node_id)
);
当 Worker 尝试领取时:
js复制const res = await env.DB.prepare(
`INSERT OR IGNORE INTO task_claims (task_id, node_id, claimed_at)
VALUES (?, ?, ?)`
).bind(taskId, nodeId, Date.now()).run();
const acquired = res.meta.changes > 0;
如果 INSERT OR IGNORE 影响的行数是 1,说明这是第一个抢到这个任务的节点,领取成功;如果影响行数是 0,说明已经被别的节点领取过,直接放弃。
5.2 能跑通的核心代码
下面我贴一个极简但能跑通的实现。scheduler.js 负责创建任务:
js复制export default {
async fetch(request, env) {
const taskId = crypto.randomUUID();
const targetUrl = "https://speed.cloudflare.com/__down?bytes=2000000";
await env.DB.prepare(
`INSERT INTO tasks (task_id, region_hint, target_url, status, created_at, updated_at)
VALUES (?, ?, ?, 'PENDING', ?, ?)`
).bind(taskId, "auto", targetUrl, Date.now(), Date.now()).run();
await env.TASK_KV.put(
buildKey('speed', 'task', taskId, 'status'),
'PENDING',
{ expirationTtl: 3600 }
);
return Response.json({ taskId, status: 'PENDING' });
}
}
executor.js 负责领取任务并测速:
js复制async function claimTask(env, taskId, nodeId) {
const res = await env.DB.prepare(
`INSERT OR IGNORE INTO task_claims (task_id, node_id, claimed_at)
VALUES (?, ?, ?)`
).bind(taskId, nodeId, Date.now()).run();
return res.meta.changes > 0;
}
async function measure(targetUrl) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const start = Date.now();
const res = await fetch(targetUrl, { signal: controller.signal });
const buf = await res.arrayBuffer();
clearTimeout(timer);
const durationMs = Date.now() - start;
const bytes = buf.byteLength;
return {
ttfbMs: 0, // 更准确需要单独测 header 到达时间,这里简化处理
downloadKbps: (bytes * 8) / (durationMs / 1000) / 1024
};
}
实际项目里我会把 TTFB 单独记一下,在 await fetch() 返回头部之后、读取 body 之前记一个时间点,这样能拆出连接时间和传输时间。上面的示例是为了保证代码短,把细节简化了。
5.3 边界情况:超时、重复触发、僵尸任务
分布式系统最容易在边界情况上翻车。我处理了三个:
- 超时:Worker 测速请求一定要用
AbortSignal.timeout()或AbortController,不然某个目标站一直不响应,会拖死 Worker 的执行时长。 - 重复触发:Cron Trigger 可能因为边缘节点重试导致同一分钟触发两次,但
task_claims的唯一索引能兜底。结果上报也做了ON CONFLICT DO UPDATE,重复上报也不会产生脏数据。 - 僵尸任务:如果 Worker 在领取任务后崩溃,任务状态会一直停留在
RUNNING。我在 D1 里记了updated_at,然后用一个定时任务把status='RUNNING'且updated_at超过 10 分钟的任务重新置为PENDING,让其他节点有机会接管。
6. 上线前必须知道的几个坑
6.1 KV 最终一致性对任务的"可见性"影响
KV 的最终一致性在大多数场景下没感觉,但在调度场景里很微妙。一个 Worker 刚 put 了一个任务状态,另一个节点立刻 get,可能还是旧值。如果你对这个状态做强判断,就可能做出错误决策。我的应对是:KV 只用于"读到了最好,读不到也能接受"的场景,比如展示任务状态、缓存报表;真正判断任务归属和最终一致性,永远用 D1。
这和我以前在 Java 项目里用 RedisTemplate 做分布式缓存的感觉完全不同。Redis 的 setIfAbsent、expire 能提供原子语义,但 Cloudflare KV 没有。所以不要用 KV 去实现严格锁,也不要指望它的 list 方法能给出实时全量数据。
6.2 D1 写入并发和免费额度
D1 不是无限制的。免费额度对每天的总写入行数、读行数都有约束。测速任务如果量大,每个任务三个节点,每个节点写一行结果,再更新任务状态,一天下来消耗不小。我的用量优化方案:
- 把多个结果先用 KV 暂存,攒到一定量后批量写入 D1。
- 结果上报尽量走
batch,一次请求里合并多条 SQL。 - 聚合报表不用每次都全量查询,用 KV 缓存扛住高频读。
预算很容易被忽略,但真正上线后才会发现,数据层设计直接影响账单。
6.3 冷热数据分离
测速结果有一个特点:新数据被查询的频率高,老数据几乎没人看。我建议 D1 里长期保留热数据,比如最近 90 天;更老的定期导出到对象存储或归档表。这样 D1 的表体积不会无限膨胀,查询性能也更稳定。同时热门的聚合结果永远放 KV 缓存,避免重复消耗 D1 读配额。
6.4 建议的验收清单
我在项目上线前做了一个简单的压力验证,供大家参考:
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 并发领取不重复 | 模拟 10 个节点并发领取 50 个任务 | 每个任务只被一个节点领取 |
| 结果重复上报不脏 | 同一结果连续上报 3 次 | 表中只有一行,值正确 |
| 任务状态不丢失 | 随机杀掉 Worker 进程 | 僵尸任务在可接受时间内被重置 |
| 缓存穿透可接受 | 清空 KV 缓存后并发访问报表 | D1 能抗住回源,页面无超时 |
| TTL 过期不误判 | 设置短 TTL 模拟任务超时 | 状态自动清理,不影响 D1 主数据 |
最后再分享一点个人体会
这套 KV + D1 的数据层方案,我落地之后最大的感受是:分布式测速调度系统真正难的不是让 Worker 跑起来,而是让数据在正确的时间出现在正确的地方。KV 适合做"快速看一眼"的缓存和临时状态,D1 适合做"必须算得清清楚楚"的结果存储和责任认定。设计数据层的时候,不要总想着"用一套存储解决所有问题",而是先回答"这条数据属于状态还是记录"。如果属于状态,大胆放 KV,接受短暂不一致;如果属于记录,老老实实进 D1,把唯一约束和索引建好。最后一个小技巧:每次新增一种 KV Key,都顺手在项目文档里登记一下格式、TTL、谁会读谁会写。我当初没这么做,后来排查一个状态丢失问题翻了好几天代码,把文档补齐之后,整个系统变得好维护得多。
