VoiceDrop · 存储系统排查

数据现状盘点与 D1 迁移评估

jianshuo.dev Pages Functions、voicedrop-agent Worker、reco Worker、EdgeOne 边缘层与东京 VPS 中继的全量数据面排查,含线上真实规模数据。

2026-07-20 · 排查范围:~/code/jianshuo.dev + ~/code/voicedrop · 线上数据取自 Cloudflare API 实测

✅ 实施状态(2026-07-20 当天完成 P0–P3,全部上线)

P0–P3 已部署生产并线上验证:voicedrop-core 库 10 张表——P1/P2 的 refhits·invites·share_stats·prompt_shares·articles·recordings,加 P3 的 identities·user_profiles·push_tokens·community_reports。全部走双写 + D1 优先 + R2 兜底 + 自愈;存量 backfill 全部对齐源计数(identities 57 / profiles 53 / push_tokens 120 / reports 1)。上传→双写→D1 直出→挖矿标记→销号双删全链路实测通过;1232 个测试全绿。生产已观测到自愈生效:articles/recordings 由真实用户打开列表自动回填至 347/335 行。提交 8c5e8ff(P1) · bd048a3(P2) · 3908919(P3)。仅剩「D1 跑稳后移除逐次对账」按计划观察后再做。

18,407R2 对象总数桶 jianshuo-dev-files
2.06 GBR2 总体积桶位于北美西部 WNAM
589用户目录users/ 前缀,1.84 GB
8 张表已在 D1 运行usage 3.1MB + reco 0.9MB
11+R2 list() 扫描点最大一处扫全桶
10+读-改-写竞态点R2 无原子操作

一句话结论

该迁,且只迁一半。慢和不稳的根源不是「用了 R2」,而是「把 R2 当数据库用」:所有列表、索引、计数、指针都是 R2 里的小 JSON 文件,靠读-改-写维护、靠全前缀 list() 扫描兜底自愈。音频、照片、文章正文这些 blob 放 R2 是对的;需要迁走的是那一层「手工索引 + 计数器 + 指针」,它们天然是数据库的活。币记账本(6 张表)和社区索引(2 张表)已经在 D1 上验证过这条路能走通。

01存储全景:数据现在都在哪

三种后端并存。颜色约定贯穿全文:R2 对象存储 · D1 SQLite 数据库 · DO Durable Object 实例态。

R2 jianshuo-dev-files(主力,问题所在)

18,407 对象 / 2.06 GB。文章、文风、录音、照片、身份绑定、分享、社区指针、归因指纹、配置、日志——所有用户可见数据都在这里,包括本应是数据库行的索引与计数。

D1 两个库(已跑通)

voicedrop-usage:account·ledger·bucket·mint·iap_txn·iap_sub,币记/订阅全部资金态(537 账户、8,728 条流水)。
voicedrop-reco:community_posts(156)·engagement(4,069),社区展示索引与互动。

DO 7 个类(各司其职,不用动)

ArticleEditor / LibraryAgent 内嵌 SQLite 存编辑队列与历史;StatusHub / LinkBroker 存设备配对态;Miner 存告警计数与闹钟;两个 Relay 无状态。均为实例协调态,不是 D1 候选

东京 VPS 中继(无关迁移)

wechat-relay 只有一个本地文件 imgcache.json(微信图片 CDN URL 缓存,30 天 TTL),不碰 R2。EdgeOne 边缘层纯反代 + 缓存规则,无持久数据。

R2 前缀实测规模(2026-07-20)

前缀内容对象数分布体积
users/589 个用户的全部数据(音频占大头)6,500
1,836 MB
llmlogs/每次 LLM 调用一个 JSON,按日分目录8,289
172 MB
minelogs/每次挖矿一个日志2,700
1.5 MB
shares/分享码 → 文章 key / 提示词 JSON(一名两制)364
0.1 MB
refhits/归因指纹命中(127 个指纹,2 天生命周期)290
<0.1 MB
community/社区帖指针 + 举报记录162
0.1 MB
links/Apple/微信身份 → 数据箱映射57
<0.1 MB
invites/ · config/ · assets/ · debug/邀请码 29 · 配置 4 · 公众号封面 20(50.8MB)· 调试 356
51 MB

桶位置 WNAM(北美西部):中国用户的每一次对象读写都跨太平洋往返。这放大了下文所有 N+1 问题。

02数据结构:每类数据长什么样

抽样自线上真实对象 + 代码 schema 定义(functions/lib/article-store.js 等)。

单个用户目录(users/<sub>/)

users/anon-01298f…/
├─ CLAUDE.json 文风文档,schema-3 版本化 {head, versions[≤20], profile}
├─ CONFIG.json 用户设置 {autoShareCommunity,…}
├─ ACCOUNT.json 身份记录 {appleSub, wechatOpenid, email,…} — RMW 合并
├─ WECHAT.json 公众号发布凭据 {appid, secret, coverMediaIds}
├─ push-token.json APNs 设备令牌
├─ prompts.json 提示词树(整树覆盖写)
├─ prompt-shares.json 分享码索引 {byItem, mintLog[]} — 真实丢更新风险
├─ articles-index.json 文章摘要索引 — RMW 加速层 + list() 全量对账
├─ recordings-index.json 录音索引 — 同上,对账实测 1.0–1.6s
├─ VoiceDrop-*.m4a 原始音频(体积大头,该留 R2)
├─ photos/<session>/*.jpg 照片/AI 图,key 写后不变(该留 R2)
├─ style/<id>.json 文风语料样本 — 读取时 list+逐个 GET(N+1)
└─ articles/
   ├─ <stem>.json 文章文档:{head, versions[≤10] 整史内嵌, transcript, srt, photos, questions,…}
   ├─ <stem>.srt 字幕
   └─ <stem>.{empty|blocked|tags|minefail|asr.json|asrdone.json} 6 种 sidecar 标记(存在性即状态位)

值得注意的结构决策

03诊断:慢和不稳的六个根源

全部有代码注释或事故记录佐证,file:line 可直接跳转。

① 挖矿调度靠扫全桶

scanUnprocessed 的 sweep 模式对整个桶 18,407 个对象分页 list(),在内存里做集合运算推导「哪些音频还没挖」。对象越多越慢,且随全站增长线性恶化——为此才被迫拆出 per-user 分片。

agent/src/miner.js:1562 · index.js:559(分片注释)

② 列表页靠「手工索引 + 全量对账」自愈

articles-index / recordings-index 两个 JSON 是为绕开 list() 慢而造的加速层:每次写文章都 RMW 更新索引(try/catch 吞错),再靠全前缀 list() 对账兜底。代码注释实测:~1,500 个 key 扫一遍 1.0–1.6 秒,「不达标」才挪进后台;首次调用仍同步对账 ~1.3s。

functions/lib/article-store.js:42-158 · [[path]].js:1644-1660, 652

③ 归因查询 N+1:一次 list + 最多 80 个 GET

refhits 按 IP 指纹分目录存对象,查一次归因要 list 整个前缀再逐条 GET(上限 80 个),每个 GET 都是跨洋往返。这是典型的「该用一条 SELECT 的地方用了 81 次对象操作」。

agent/src/referral.js:55-75 · functions/lib/refhits.js:31-49

④ R2 无原子操作 → 读-改-写丢更新

R2 没有 CAS、没有原子自增,最后写者赢。已知竞态点:prompt-shares.json(byItem + mintLog + 每日上限三件事夹在一次 RMW 里,真实丢更新风险)、shares/<code> 的 importCount(代码注释自认「并发导入偶尔丢计数」)、两个 index JSON、ACCOUNT.json、举报 reporters[]、config/prompts.json。

agent/src/prompt-share.js:339,424 · prompt-routes.js:317 · [[path]].js:243-317, 1258

⑤ 串行对象操作堆出来的延迟事故

「打开分享 10 秒」= 15 个串行 R2 操作,靠 Promise.all + waitUntil 缓解;删账户扫全前缀逐 key 删,913 个对象的账号曾打爆 subrequest 预算,改成 1000/轮批删。这些补丁都在对抗同一件事:把数据库工作负载放在了对象存储上。

agent/src/prompt-share.js:79 注释 · [[path]].js:503-567 注释

⑥ 桶在北美西部,用户在中国

WNAM 桶位置使上述每一次对象操作都自带跨太平洋 RTT。迁 D1 后写路径同样要去主区域,但请求次数从 N 次降到 1 次,且 D1 读复制可把读就近化——这是数量级差异的来源。

wrangler r2 bucket info 实测 location: WNAM

04方案:迁什么、不迁什么

原则一句话:blob 留 R2,状态进 D1,协调留 DO。R2 保持真源直到切换完成,随时可回退。

R2 留下(是它的本职)

  • 音频 .m4a、照片 photos/(1.8 GB 大头)
  • 文章/文风正文 JSON(版本体可后置再议)
  • llmlogs/ minelogs/ 原始日志(建议加生命周期)
  • config/*.json 零部署调参(单写者、无竞态)
  • assets/ 公众号封面

D1 迁入(新库 voicedrop-core)

  • articles 元数据表 ← articles-index.json + sidecar 状态位
  • recordings 表(含 mine_state 工作队列列)← recordings-index.json + 全桶扫描
  • shares / invites / prompt_shares ← 三类指针与计数
  • refhits ← 归因指纹(一条 SELECT 取代 81 次操作)
  • identities / push_tokens ← links/*、ACCOUNT.json、push-token.json
  • community_reports ← reporters[] RMW

DO 不动(已是正确形态)

  • 编辑/指令队列(DO 内嵌 SQLite,强一致)
  • 设备配对、状态推送、告警计数、挖矿闹钟
  • ASR 断点续传 checkpoint 留 R2(与音频同生命周期)

目标表设计(voicedrop-core)

关键列取代的 R2 结构消灭的问题
articlesuser_sub, stem PK, title, head, status, tags, flag_empty, flag_blocked, created_at, updated_atarticles-index.json + .empty/.blocked/.tags 镜像索引 RMW、对账扫描、状态位双写
recordingsuser_sub, key PK, uploaded_at, mine_state, fail_countrecordings-index.json + .minefail + 全桶扫描miner 扫全桶 → WHERE mine_state='pending'
sharescode PK, type, owner, target_key, payload, import_count, created_atshares/<code> + prompt-shares.json一名两制、importCount 丢计数(原子 UPDATE)、丢更新
invitescode PK, owner, name, tsinvites/<CODE>—(顺手,量小)
refhitsfingerprint, ts, owner, token · 索引(fingerprint, ts)refhits/<ip>/<ts> 对象树list+80 GET → 1 条 SELECT;cron DELETE 取代生命周期
identitiesprovider, external_id PK, user_sub, linked_atlinks/apple-*.json · links/wechat-*.json登录多一次跨洋 GET;first-write-wins 改唯一约束
user_profileuser_sub PK, email, name, avatar, push_token, push_env, last_seen_atACCOUNT.json + push-token.jsonRMW 合并竞态
community_reportsshare_id, reporter, at, reason · PK(share_id, reporter)community/reports/*.json 的 reporters[]举报并发 RMW;顺带精确去重

规模评估:全部候选数据现约几 MB、行数万级,距 D1 单库 10 GB 上限有三个数量级余量。注意已知坑:绑定参数上限 100(reco 已按 90 分块);跨库无 JOIN——core 与 usage/reco 本就不需要 JOIN。

热路径收益预估

路径现在迁移后
打开文章列表读索引 JSON + 后台对账 list() 1.0–1.6s(首次同步 ~1.3s)1 条 SELECT,无对账
挖矿找活sweep 全桶 18k 对象分页 list1 条 WHERE mine_state='pending'
归因判定1 list + ≤80 GET(跨洋)1 条 SELECT
分享导入计数RMW,并发丢计数(代码自认)UPDATE … SET import_count=import_count+1
每次写文章正文 PUT + 索引 RMW(GET+PUT)+ 状态位镜像正文 PUT + 1 条 UPSERT
删账户3 个前缀全扫 + 千级批删blob 批删不变,状态一条 DELETE 每表

05实施:三期走,每期独立回退

P0 ✅

准备(半天)

建库 voicedrop-core + migrations;写 backfill 脚本(walk R2 → INSERT,幂等可重跑);共享 lib 里加 D1 访问层。所有新读路径挂 feature flag,R2 写照旧。

P1 ✅

高收益低风险:指针与计数(1–2 天)

refhits、shares、invites、prompt-shares 切 D1。这四类量小(合计 <740 对象)、调用方集中、不碰移动端接口形状,却直接消灭 N+1 归因查询和两处真实丢更新。双写一周对账无差异后停写 R2。

P2 ✅

核心收益:索引层(3–5 天 → 实际当天完成)

articles / recordings 两表落地:写文章时 UPSERT,GET /articles、/recordings 直接 SELECT(entry 原样存取,接口 JSON 形状逐字节不变,iOS 无感)。实施中的现场修正:挖矿扫描不动——全桶扫描只发生在 6 小时 cron 的兜底 sweep(非热路径),每用户前缀扫描量级有界;老用户无需全量 backfill,第一次打开列表时后台对账自动把 D1 拉齐(自愈式迁移)。reconcile 暂保留为每次列表后的后台对账,等 D1 跑稳后降频/改手动。

P3 ✅

收尾:身份与举报(当天完成)

identities / user_profiles / push_tokens / community_reports 四表迁入:Apple/微信登录的 scope 解析与社区写门槛 hasVerifiedBinding 改查 D1;push token 上传即双写、sendPush D1 优先、410 双删;举报的隐藏集/详情/对账都变成一条 SELECT(取代 community/reports/ 前缀扫描)。文章正文与版本史继续留 R2(拆版本表是另一个话题)。「取消每次列表后的全量对账」暂缓——留作 D1 稳定运行观察后的收尾,现阶段 reconcile 仍作后台自愈兜底。

风险与对策

06顺手发现的独立隐患(与迁移无关,建议单独处理)

数据来源:三路并行代码全扫(Pages Functions 1,920 行主文件 + agent worker 44 个源文件 + reco/EdgeOne/VPS)交叉印证;线上规模取自 Cloudflare API 与 wrangler d1 实测。文中 file:line 均可在仓库直接定位。