字节笔记本
2026年8月28日
Cloudflare R2 + D1 做 OTA 更新
做桌面端或移动端壳应用时,发一版安装包成本不低。Electron、React Native(尤其是走 CodePush / 热更新那一类流程)都希望「改完 JS 或静态资源,用户下次启动就能拿到」,不必再走一遍商店审核。Cloudflare 的 R2 存文件、D1 存版本元数据,再加一个 Worker 当检查更新的 API,整条链路可以压到几乎零固定成本,延迟也还行。
下面按「存什么、查什么、客户端怎么接」拆一遍,方便你直接落到项目里。
这条链路各自干什么
| 组件 | 角色 |
|---|---|
| R2 | 放更新包(zip、jsbundle、asar 差量包等),对象存储,按量计费,出站走 Cloudflare 网络 |
| D1 | 存版本表:渠道、版本号、文件 key、校验和、是否强制更新、发布时间 |
| Workers | 对外暴露 GET /update/check,读 D1、比对客户端版本,返回下载 URL 或「已是最新」 |
R2 本身没有「按版本查询」的语义,所以版本真相放在 D1;Worker 只做只读查询和签名/鉴权(如果需要),别把写库权限散到客户端。
D1 里建议怎么建表
一个够用的最小表大致是:
CREATE TABLE app_releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL, -- 例如 com.example.app
channel TEXT NOT NULL DEFAULT 'stable', -- stable / beta
platform TEXT NOT NULL, -- electron | ios | android
version TEXT NOT NULL, -- semver,如 1.4.2
build INTEGER NOT NULL, -- 单调递增的内部号,便于比较
object_key TEXT NOT NULL, -- R2 里的 key
sha256 TEXT NOT NULL,
force_update INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_releases_lookup
ON app_releases (app_id, channel, platform, build DESC);客户端上报:app_id、channel、platform、当前 build(或 version)。Worker 查该渠道下 build 更大的最新一条;没有就返回 upToDate: true。
R2 的对象命名
建议 key 带版本,避免覆盖历史包:
releases/{app_id}/{platform}/{channel}/{version}/update.zip
上传可以用 Wrangler:
npx wrangler r2 object put my-ota-bucket \
--file ./dist/update.zip \
--key releases/com.example.app/electron/stable/1.4.2/update.zip然后在 D1 插入对应行(object_key、sha256、build)。发版脚本里「上传 → 写库」做成原子步骤:上传成功后再 insert;失败不要写库。
Worker:检查更新 API 示例
绑定 R2 与 D1 后,核心逻辑可以长这样(示意):
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== "/update/check") {
return new Response("not found", { status: 404 });
}
const appId = url.searchParams.get("app_id");
const channel = url.searchParams.get("channel") || "stable";
const platform = url.searchParams.get("platform");
const build = Number(url.searchParams.get("build") || 0);
if (!appId || !platform) {
return Response.json({ error: "missing params" }, { status: 400 });
}
const row = await env.DB.prepare(
`SELECT version, build, object_key, sha256, force_update, notes
FROM app_releases
WHERE app_id = ? AND channel = ? AND platform = ? AND build > ?
ORDER BY build DESC LIMIT 1`
)
.bind(appId, channel, platform, build)
.first();
if (!row) {
return Response.json({ upToDate: true });
}
// 公开读:用自定义域名或 R2 公开桶;私有桶则签发短时 URL
const downloadUrl = `${env.R2_PUBLIC_BASE}/${row.object_key}`;
return Response.json({
upToDate: false,
version: row.version,
build: row.build,
sha256: row.sha256,
forceUpdate: !!row.force_update,
notes: row.notes,
url: downloadUrl,
});
},
};wrangler.toml 里挂上:
[[d1_databases]]
binding = "DB"
database_name = "ota-meta"
database_id = "<your-d1-id>"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-ota-bucket"如果更新包不能公开,用 R2 预签名 URL 或 Worker 里 env.ASSETS.get() 再流式返回,并限制 Referer / 带一次性 token。
客户端怎么接(Electron / RN)
两端套路一样:启动或定时调 GET /update/check,有新版本就下载 → 校验 sha256 → 替换本地资源 → 重启或热切换。
Electron 常见做法:
- 主进程在
ready后请求检查接口。 - 下载到
userData临时目录,校验哈希。 - 解压到
app.getPath('userData')/updates/<build>/,下次启动从该目录加载渲染资源;或配合electron-updater只把「元数据源」换成你的 Worker(完整安装包仍可走 GitHub Releases,热更新走 R2)。
React Native:
- 若只更 JS bundle:把 jsbundle 放 R2,检查接口返回 URL,用自家小模块下载后
CodePush-式替换(注意 iOS/Android 对热更新的政策差异)。 - 若更原生层:这条链只适合资源/JS;完整 IPA/APK 仍走商店或企业分发。
无论哪端,强制更新时客户端应阻塞进入主界面,只留「去更新」;非强制可提示后继续用旧版。
发版流水线建议
- CI 打出
update.zip,计算 sha256,build取 CI 流水号或 git 计数。 - 上传 R2 →
INSERTD1(同一 job,失败回滚:删掉刚传的 object)。 - 用 curl 打一遍检查接口,确认新 build 能返回。
- 灰度:先写
channel=beta,内部验完再插一条stable(或改 channel)。
curl -s "https://ota.example.com/update/check?app_id=com.example.app&platform=electron&channel=stable&build=100"成本与注意点
- R2 存储和 A 类操作很便宜,适合放历史包;记得定期清掉极旧 build,避免桶无限涨。
- D1 按行读写,版本表很小,几乎可忽略。
- Worker 免费额度对「启动时检查一次」通常够用;高频轮询就加缓存头(
Cache-Control)或 Durable Object 限流。 - 安全:检查接口可以匿名,但写 D1 / 上传 R2 必须只在 CI(API Token),不要把账号密钥打进客户端。
- 完整性:客户端务必校验
sha256,不要只信 HTTPS。 - Electron 若更新主进程二进制,热更新解决不了,仍需整包安装程序。
小结
R2 管文件、D1 管「哪个版本该推给谁」、Worker 管查询。对独立开发者做 Electron 壳或 RN 热更新资源,这条组合足够简单,也容易和现有 CI 接上。先跑通「一个 app、一个渠道、手动上传 + 手写一条 D1」,再把上传和 insert 丢进 CI,比一上来上完整差量协议要稳。