传统 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 ); } const slug = customSlug || generateSlug (6 ); try { const result = await env.DB .prepare ( 'INSERT OR IGNORE INTO short_urls (slug, target_url) VALUES (?, ?)' ).bind (slug, targetUrl).run (); 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 ) { 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 ); } 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 (); } function ctxWaitUntil (promise ) { } 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 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/"}' 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 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.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 行,但架构提升是量级的。