背景
Shionlib 的游戏文件存储均在 Backblaze B2 上。 B2 本身提供两类常见下载入口:
-
友好URL,如:
https://f005.backblazeb2.com/file/<bucket>/<file-key> -
S3 URL,如:
https://shionlib-games.s3.us-east-005.backblazeb2.com/<file-key>
为了免除出口费用和隐藏 bucket name,我们一般会在业务侧套一层自己的 CDN 域名(在友好URL的基础上),作为 B2 下载入口的 CDN alias,通过 Cloudflare 将请求代理到 B2 并将PATH改写为 /file/<bucket>/<file-key> ,大部分使用Backblaze B2的资源站采取的都是这个做法。在Shionlib,这个CDN alias为:https://f.hikarifallback.uk/<file-key>
直接把下载 URL 返回给客户端当然可以工作,但对于一个需要长期维护的下载系统来说,还是有几个问题:
-
很难控制单个用户、单个文件或单次下载会话的并发连接数。
-
多线程下载器可能在短时间内占用大量连接。
-
下载统计、失败观测、后续风控都比较难做。
-
一旦以后需要切换下载策略,客户端已经拿到的直链基本没法收回或控制失效。
所以Shionlib引入了一层 Cloudflare Worker 作为下载代理,由后端签发短期 ticket,Worker 校验 ticket 后再去 B2 拉取文件,并通过 Durable Objects 做并发控制。在下文我们称这个模式为Worker模型。
整体思路
这套方案的核心思路是通过worker强大的能力把下载链路变成一个可控入口:
Client
-> Backend 申请下载链接
-> Backend 签发加密 ticket
-> Client 请求 Worker 下载地址
-> Worker 校验 ticket
-> Durable Object 控制并发
-> Worker fetch B2
-> Worker 流式返回文件
后端仍然负责和 B2 API 交互,包括申请 download authorization token。Worker 不持有 B2 application key,也不直接调用 B2 API,只消费后端签发的短期 ticket。
Ticket 设计
Worker 模式下,不应该把完整 direct URL (指包含flie-key和签名的完整下载链接) 当作核心数据传给 Worker。更清晰的方式是传结构化字段,让 Worker 自己拼出 B2 下载 URL。
在Shionlib,我们把ticket payload设计为:
{
sid: string, // 下载会话 ID
fid: number, // 文件 ID
gid: number, // 游戏 ID,用于统计
n: string, // 展示给用户的下载文件名
exp: number, // ticket 过期时间
mc: number, // 最大并发连接数
b: string, // B2 bucket name
k: string, // B2 file key
a: string, // B2 download authorization token
u: string, // B2 downloadUrl,例如 https://f005.backblazeb2.com
}
这里的关键是 u 不写死,也不直接放在 Worker 环境变量里,而是来自 B2 authorize response:
apiInfo.storageApi.downloadUrl
这样即使 B2 后续调整下载 host,后端也能从 B2 当前返回的信息里拿到正确值。
后端实现
后端调用 B2 authorize API 后,可以拿到:
{
authorizationToken,
apiInfo: {
storageApi: {
apiUrl,
downloadUrl,
allowed: {
buckets: [
{
id,
name,
},
],
},
},
},
}
我们需要保留这些:
{
authorizationToken,
bucketId,
bucketName,
apiUrl,
downloadUrl,
}
然后通过 b2_get_download_authorization 为具体文件申请短期下载 token:
{
bucketId,
fileNamePrefix: fileKey,
validDurationInSeconds: expiresIn,
}
最终提供给 ticket service 的信息是:
{
bucketName,
fileKey,
authorizationToken: downloadAuthorizationToken,
downloadUrl,
}
Worker 实现
客户端最终拿到的是类似这样的 Worker 下载地址:
https://dl.hikarifallback.uk/dl/<file-id>/<session-id>?ticket=<ticket-id>
Worker 收到请求后会做几件事:
- 解密 ticket
- 校验 ticket version
- 校验过期时间
- 校验 path 里的 fileId/sessionId 是否和 ticket 一致
- 根据 ticket 拼 B2 下载 URL
- 进入 Durable Object 并发控制
- fetch B2,并把响应流式返回给客户端
Worker 拼出的 B2 URL 使用 canonical /file// 形式:
const originUrl = `${downloadUrl.origin}/file/${encodeURIComponent(bucketName)}/${encodeURIComponent(fileKey)}` +
`?Authorization=${encodeURIComponent(authorizationToken)}`
也就是:
https://f005.backblazeb2.com/file/shionlib-games/games%2Fxxx.7z?Authorization=...
Durable Objects 并发控制
Cloudflare Worker 本身是无状态的,同一个用户的多个请求可能落在不同 isolate。要做可靠的“同一个下载会话最多 N 个连接”,需要一个集中状态点。
Durable Objects 很适合这个场景。
每个 ticket 都有一个 sid,Worker 用它拿到对应 Durable Object:
const limiterId = env.DOWNLOAD_LIMITER.idFromName(ticketPayload.sid)
const limiter = env.DOWNLOAD_LIMITER.get(limiterId)
一个 Durable Object 只负责一个下载会话的 lease 状态:
-
当前 active lease 数量
-
每个 lease 的过期时间
-
heartbeat 刷新
-
下载结束后的释放
下载开始前,Worker 申请 lease:
active leases < max connections -> allow
active leases >= max connections -> reject 429
如果超过限制,Worker 返回:
429 Too Many Concurrent Download Connections
下载过程中 Worker 定期 heartbeat,避免长时间下载被误清理。下载完成、失败或连接中断后释放 lease。
流式代理
Worker 肯定不应该把整个文件读进内存,我们要直接流式转发 B2 response body。
处理流程大概是:
const originResponse = await fetch(originRequest)
const contentLength = originResponse.headers.get('content-length')
const contentLengthBytes = contentLength ? parseInt(contentLength, 10) : 0
const outputStream = contentLengthBytes > 0 ?
new FixedLengthStream(contentLengthBytes) : new TransformStream()
ctx.waitUntil(
originResponse.body
.pipeTo(outputStream.writable)
.finally(() => releaseLeaseOnce()),
)
turn new Response(outputStream.readable, {
stus: originResponse.status,
heads: buildDownloadHeaders(originResponse.headers, fileName),
})
这样客户端看到的是 Worker 的响应,但数据主体仍然是从 B2 流式过来的。
同时 Worker 可以在这里统一设置下载文件名:
Content-Disposition: attachment; filename*=UTF-8''<encoded-file-name>
这让 B2 file key 和用户看到的实际下载文件名可以解耦。
Range 请求支持
大文件下载通常需要支持断点续传和多线程下载,因此 Worker 需要转发 Range 相关请求头,例如:
Range
If-Range
Accept
Accept-Encoding
User-Agent
B2 返回 206 Partial Content 时,Worker 应该原样保留状态码和关键响应头,让下载器可以正常工作。
并发控制也正是围绕这些 Range 请求展开:同一个 ticket 下,多线程下载器可以开多个连接,但最多不能超过 ticket 里的 mc。
安全性
这套方案里,安全边界主要在 ticket 和 B2 download authorization token 上:
-
Worker 不保存 B2 application key
-
后端签发的 ticket 是加密的,客户端不能篡改
-
ticket 有过期时间
-
ticket path 里带 fileId/sessionId,Worker 会和 payload 校验
-
B2 download authorization token 本身也是短期 token
-
B2 token 使用 fileNamePrefix 限定到指定 key
-
Worker 只接受 HTTPS download URL
-
Worker 只转发必要请求头,不把客户端所有 header 原样透传给 B2
一次实际问题
之前的实现里,后端会先拼出完整的direct URL,例如:https://f.hikarifallback.uk/````?Authorization=...
前文有提到,这个域名在 Cloudflare 侧有规则处理,会把请求转成 B2 需要的路径:
/file//<file-key>
普通用户直接访问这个 direct URL 是正常的。但今天凌晨开始,Worker 内部 fetch() 这个 same-zone URL 时,B2 上游开始稳定返回 404。
经过排查后确认:
-
B2 桶里的文件都还在
-
直接切回 direct 模式后下载恢复
-
直接访问 direct URL 正常
-
Worker 里 fetch direct URL 会 404
-
Worker 里 fetch B2 原生 URL 正常:
https://f005.backblazeb2.com/file/shionlib-games/````?Authorization=...
我们可以确定的问题点是:Worker 不应该依赖一个已经被 Cloudflare 规则处理过的 CDN host。Worker 应该直接面向 B2 原生下载 URL,自己拼出明确的 B2 下载路径。
至于 Cloudflare 内部为什么在那个时间点后表现发生变化,我们无法坐实。能坐实的是,我们之前的方案依赖了一个不够稳妥的路径:Worker subrequest 依赖 same-zone CDN/Transform 行为。
总结
这套方案的核心是:
-
后端负责 B2 授权
-
Worker 负责代理和校验
-
Durable Objects 负责会话级并发控制
-
ticket 传结构化下载信息,而不是完整 URL
-
Worker 使用 B2 canonical /file// 路径拉源
-
文件响应全程由 Worker 流式转发,轻松cover大文件
稳定性:经过 Shionlib 的接近两个月的生产环境实测,没有问题!(除了上面提到的那个bug)
费用:5刀的worker paid方案基本可以cover全部请求和观测费用(以 Shionlib 小小的体量来说)
Reference:懒得写了,所有源码均在 https://github.com/Ringyuki/shionlib
可以自己看捏
理论上来说,Cloudflare Worker + Durable Objects 的组合几乎可以配合你的业务实现任何功能,在这里我们只讨论了一个简单的使用方案,其他更加高级的用法uu们可以一起探索
