传统 API 架构有一个绕不开的延迟瓶颈:用户请求要跨越半个地球到达你的源站机房,再从源站访问集中式数据库。一个东京用户访问部署在美东的 API,光网络往返就要 150ms 起步。CDN 能缓存静态资源,但带数据库的动态请求呢?

Cloudflare Workers 给了一个不同寻常的答案:把代码运行在全球 300+ 个边缘节点上,数据库也做成全球分布式。今天我们从零构建一个全球可用的短链接服务,把这套边缘计算架构跑通。

一、为什么是 Workers + D1

先搞清楚这套组合解决了什么问题。

传统架构的延迟链路:

1
2
用户 → CDN(静态缓存) → 源站 API → 集中式数据库(MySQL/PG)
↑ 命中则到此为止 ↑ 未命中走这里,跨地域延迟

边缘架构:

1
2
用户 → 最近的 Cloudflare 节点(运行 Workers + 读取 D1 副本)
↑ 全程不回源,延迟 < 50ms
  • Workers:基于 V8 isolates 的边缘运行时,不是 Docker 容器,启动开销接近零,请求到达即执行。全球 300+ 节点自动就近调度。
  • D1:Cloudflare 的边缘 SQLite 数据库。写入走主库,读取走全球分布的只读副本——你的查询自动路由到离用户最近的副本。

适用场景很明确:读多写少、对延迟敏感的 API,比如短链接、计数器、配置中心、Feature Flag。不适合重度写入或复杂事务。

二、实战:构建全球短链接服务

我们做一个真实能用的短链服务,包含三个核心接口:

  • GET /:slug — 短链跳转(读,高频)
  • POST /api/shorten — 创建短链(写,低频)
  • GET /api/stats/:slug — 点击统计(读)

2.1 创建 D1 数据库

先全局安装 Wrangler CLI 并登录:

1
2
npm install -g wrangler
wrangler login

创建数据库:

1
wrangler d1 create shorturl_db

命令会输出一段配置,记下 database_id,后面要写进 wrangler.toml

2.2 建表

创建 schema.sql

1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS short_urls (
slug TEXT PRIMARY KEY,
target_url TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
clicks INTEGER DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_created ON short_urls(created_at);

推到远程 D1:

1
wrangler d1 execute shorturl_db --remote --file=schema.sql

2.3 项目配置

wrangler.toml

1
2
3
4
5
6
7
8
name = "shorturl-api"
main = "src/index.js"
compatibility_date = "2024-09-01"

[[d1_databases]]
binding = "DB"
database_name = "shorturl_db"
database_id = "你的-database-id"

2.4 核心代码

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;

// 路由分发
if (method === 'POST' && path === '/api/shorten') {
return handleShorten(request, env);
}
if (method === 'GET' && path.startsWith('/api/stats/')) {
const slug = path.replace('/api/stats/', '');
return handleStats(slug, env);
}
if (method === 'GET' && path.length > 1) {
const slug = path.slice(1);
return handleRedirect(slug, env);
}

return json({ error: 'Not Found' }, 404);
}
};

// 创建短链
async function handleShorten(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON' }, 400);
}

const { url: targetUrl, slug: customSlug } = body;
if (!targetUrl || !/^https?:\/\//.test(targetUrl)) {
return json({ error: 'Invalid URL' }, 400);
}

// 自定义 slug 或生成 6 位随机串
const slug = customSlug || generateSlug(6);

try {
// 用 INSERT OR IGNORE 防止 slug 冲突
const result = await env.DB.prepare(
'INSERT OR IGNORE INTO short_urls (slug, target_url) VALUES (?, ?)'
).bind(slug, targetUrl).run();

// changes === 0 说明 slug 已存在
if (result.meta.changes === 0) {
return json({ error: 'Slug already exists' }, 409);
}

return json({ slug, short_url: `https://${new URL(request.url).host}/${slug}` }, 201);
} catch (e) {
return json({ error: 'Database error', detail: e.message }, 500);
}
}

// 短链跳转(核心高频接口)
async function handleRedirect(slug, env) {
// 边缘 KV 缓存,见第三节
const cached = await env.LINKS?.get(slug, 'json');
if (cached) {
// 异步累加点击数,不阻塞跳转
ctxWaitUntil(incrementClick(slug, env));
return Response.redirect(cached.target_url, 302);
}

const row = await env.DB.prepare(
'SELECT target_url FROM short_urls WHERE slug = ?'
).bind(slug).first();

if (!row) {
return json({ error: 'Short link not found' }, 404);
}

// 回填缓存,TTL 1 小时
await env.LINKS?.put(slug, JSON.stringify({ target_url: row.target_url }), { expirationTtl: 3600 });
ctxWaitUntil(incrementClick(slug, env));

return Response.redirect(row.target_url, 302);
}

// 点击统计
async function handleStats(slug, env) {
const row = await env.DB.prepare(
'SELECT target_url, clicks, created_at FROM short_urls WHERE slug = ?'
).bind(slug).first();

if (!row) return json({ error: 'Not found' }, 404);

return json({
slug,
target_url: row.target_url,
clicks: row.clicks,
created_at: row.created_at,
age_hours: Math.floor((Date.now() / 1000 - row.created_at) / 3600)
});
}

// 点击数自增 —— 切到写库路径
function incrementClick(slug, env) {
return env.DB.prepare(
'UPDATE short_urls SET clicks = clicks + 1 WHERE slug = ?'
).bind(slug).run();
}

// 用 executionCtx 实现“不等写入就返回”
function ctxWaitUntil(promise) {
// 由 Workers 运行时注入的 ctx,生产环境可直接用
// 详见文末踩坑记录
}

function generateSlug(len) {
const chars = 'abcdefghijkmnpqrstuvwxyz23456789'; // 去掉易混字符
let s = '';
const arr = new Uint8Array(len);
crypto.getRandomValues(arr);
for (let i = 0; i < len; i++) s += chars[arr[i] % chars.length];
return s;
}

function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
}

2.5 本地开发与部署

1
2
3
4
5
6
7
8
# 在本地 D1 上执行 schema(--local 走本地 SQLite)
wrangler d1 execute shorturl_db --local --file=schema.sql

# 本地起服务
wrangler dev

# 部署到全球边缘
wrangler deploy

部署完你会拿到一个 https://shorturl-api.<你的子域>.workers.dev 的地址。用 curl 测一下:

1
2
3
4
5
6
7
8
9
10
11
12
# 创建短链
curl -X POST https://shorturl-api.xxx.workers.dev/api/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://ccwork.nyc.mn/2026/06/08/redis-cache-problems-solutions/"}'

# 返回 {"slug":"a3kp9m","short_url":"https://.../a3kp9m"}

# 访问短链 → 302 跳转
curl -I https://shorturl-api.xxx.workers.dev/a3kp9m

# 查看统计
curl https://shorturl-api.xxx.workers.dev/api/stats/a3kp9m

三、进阶:KV 热点缓存

短链跳转是典型读多写少。D1 读副本虽快,但极端高并发下(某条短链被热搜带飞)仍可能把同一行数据查爆。加一层 KV 缓存:

wrangler.toml 追加:

1
2
3
[[kv_namespaces]]
binding = "LINKS"
id = "你的-kv-namespace-id"
1
wrangler kv namespace create LINKS

代码里的 env.LINKS.get() / env.LINKS.put() 就是缓存层。读取优先级:KV → D1 副本。KV 全球最终一致,读取延迟个位数毫秒,命中后整条跳转链路能压到 20ms 以内。

缓存击穿处理:短链 404 也要缓存个空值,否则恶意请求不存在的 slug 会穿透到 D1。这和 Redis 那套逻辑一样:

1
2
3
4
5
// 缓存空值防止穿透
if (!row) {
await env.LINKS.put(slug, JSON.stringify({ not_found: true }), { expirationTtl: 300 });
return json({ error: 'Short link not found' }, 404);
}

四、踩坑记录

这部分是我在实际部署中踩的坑,文档里不会明说。

坑 1:D1 写入后立即读取可能读不到

D1 的读副本是最终一致的。你刚 INSERT 一条短链,紧接着去查,有概率查不到——因为写还没同步到最近的读副本。如果业务有“写后立即读”的需求,强制读主库:

1
2
3
4
// 指定读主库
const row = await env.DB.prepare('SELECT * FROM short_urls WHERE slug = ?')
.bind(slug)
.first();

或者在写入后短暂等待。短链创建后立即跳转的场景不存在这个问题(创建是用户主动行为,跳转有延迟),但如果是“提交表单后立即展示列表”就要注意。

坑 2:必须用 .bind() 传参,不能字符串拼接

1
2
3
4
5
// ❌ SQL 注入漏洞
env.DB.prepare(`SELECT * FROM short_urls WHERE slug = '${slug}'`).first();

// ✅ 参数绑定
env.DB.prepare('SELECT * FROM short_urls WHERE slug = ?').bind(slug).first();

D1 的 prepare 返回的是 PreparedStatement,必须走 bind。拼接字符串不仅危险,还会触发 D1 的查询计划缓存失效,性能下降。

坑 3:ctx.waitUntil 没有自动注入

上面代码里 incrementClick 用了 ctxWaitUntil,目的是让点击数写入在后台进行、不阻塞 302 跳转。但 Workers 的 ctx 参数需要从 fetch handler 第二参往后拿。正确写法:

1
2
3
4
5
6
export default {
async fetch(request, env, ctx) { // 第三参数 ctx
// ...
ctx.waitUntil(incrementClick(slug, env));
}
};

忘了写第三个参数,ctx 就是 undefined,跳转接口会偶发报错。

坑 4:本地 –local 和 –remote 数据不互通

wrangler dev 默认连本地 D1(一个本地 SQLite 文件),和线上 --remote 数据库完全独立。我早期部署时在本地建了一堆短链,上线后发现全没了——因为线上是空的。开发时养成习惯:schema 同时推 local 和 remote。

五、成本与小结

资源 免费额度 短链场景用量
Workers 请求 10 万次/天 轻松够用
D1 行读取 500 万行/天 够用
D1 存储 5 GB 短链数据微乎其微
KV 读取 10 万次/天 配合缓存够用

像短链接这种读密集型服务,基本能在免费额度内跑起来。一旦量起来,Workers 付费版 $5/月起,带 1000 万次请求,成本远低于自建服务器。

架构总结:

  • 动态请求不再回源,在离用户最近的节点处理
  • D1 读副本 + KV 缓存,读取延迟压到 20ms 内
  • 写入走主库保证一致性,异步累加统计不阻塞跳转
  • 冷启动为零,伸缩完全交给平台

边缘计算不是银弹,但非常适合“读多写少 + 全球低延迟”这类场景。如果你的服务也卡在源站延迟上,这套方案值得试一把——代码量也就 100 行,但架构提升是量级的。