使用Cloudflare Worker与Durable Objects实现Backblaze B2流量代理和并发控制的一种方案

该话题被推 Web 实用技术Shionlib
浏览数 - 71发布于 - 2026-05-01 - 17:26

重新编辑于 - 2026-05-01 - 17:30

背景

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设计为:

javascript
{
  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 后,可以拿到:

javascript
{
  authorizationToken,
  apiInfo: {
    storageApi: {
      apiUrl,
      downloadUrl,
      allowed: {
        buckets: [
          {
            id,
            name,
          },
        ],
      },
    },
  },
}

我们需要保留这些:

javascript
{
  authorizationToken,
  bucketId,
  bucketName,
  apiUrl,
  downloadUrl,
}

然后通过 b2_get_download_authorization 为具体文件申请短期下载 token:

javascript
{
  bucketId,
  fileNamePrefix: fileKey,
  validDurationInSeconds: expiresIn,
}

最终提供给 ticket service 的信息是:

javascript
{
  bucketName,
  fileKey,
  authorizationToken: downloadAuthorizationToken,
  downloadUrl,
}

Worker 实现

客户端最终拿到的是类似这样的 Worker 下载地址:
https://dl.hikarifallback.uk/dl/<file-id>/<session-id>?ticket=<ticket-id>

Worker 收到请求后会做几件事:

  1. 解密 ticket
  2. 校验 ticket version
  3. 校验过期时间
  4. 校验 path 里的 fileId/sessionId 是否和 ticket 一致
  5. 根据 ticket 拼 B2 下载 URL
  6. 进入 Durable Object 并发控制
  7. fetch B2,并把响应流式返回给客户端

Worker 拼出的 B2 URL 使用 canonical /file// 形式:

javascript
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:

javascript
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。

处理流程大概是:

javascript
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。

image.png经过排查后确认:

  • 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们可以一起探索

Sticker

本文版权遵循 CC BY-NC 协议 本站版权政策

(。>︿<。) 已经一滴回复都不剩了哦~