对 jianshuo.dev Pages Functions、voicedrop-agent Worker、reco Worker、EdgeOne 边缘层与东京 VPS 中继的全量数据面排查,含线上真实规模数据。
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 跑稳后移除逐次对账」按计划观察后再做。
该迁,且只迁一半。慢和不稳的根源不是「用了 R2」,而是「把 R2 当数据库用」:所有列表、索引、计数、指针都是 R2 里的小 JSON 文件,靠读-改-写维护、靠全前缀 list() 扫描兜底自愈。音频、照片、文章正文这些 blob 放 R2 是对的;需要迁走的是那一层「手工索引 + 计数器 + 指针」,它们天然是数据库的活。币记账本(6 张表)和社区索引(2 张表)已经在 D1 上验证过这条路能走通。
三种后端并存。颜色约定贯穿全文:R2 对象存储 · D1 SQLite 数据库 · DO Durable Object 实例态。
18,407 对象 / 2.06 GB。文章、文风、录音、照片、身份绑定、分享、社区指针、归因指纹、配置、日志——所有用户可见数据都在这里,包括本应是数据库行的索引与计数。
voicedrop-usage:account·ledger·bucket·mint·iap_txn·iap_sub,币记/订阅全部资金态(537 账户、8,728 条流水)。voicedrop-reco:community_posts(156)·engagement(4,069),社区展示索引与互动。
ArticleEditor / LibraryAgent 内嵌 SQLite 存编辑队列与历史;StatusHub / LinkBroker 存设备配对态;Miner 存告警计数与闹钟;两个 Relay 无状态。均为实例协调态,不是 D1 候选。
wechat-relay 只有一个本地文件 imgcache.json(微信图片 CDN URL 缓存,30 天 TTL),不碰 R2。EdgeOne 边缘层纯反代 + 缓存规则,无持久数据。
| 前缀 | 内容 | 对象数 | 分布 | 体积 |
|---|---|---|---|---|
| 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)· 调试 3 | 56 | 51 MB |
桶位置 WNAM(北美西部):中国用户的每一次对象读写都跨太平洋往返。这放大了下文所有 N+1 问题。
抽样自线上真实对象 + 代码 schema 定义(functions/lib/article-store.js 等)。
.empty/.blocked/.tags 等 sidecar 用文件存在与否表达状态位,再由 RMW 把状态镜像进 articles-index——一个布尔字段被拆成了两次对象写。community/*.json 是真源,D1 community_posts 是展示索引,靠每次列表后的 waitUntil 全量对账收敛。全部有代码注释或事故记录佐证,file:line 可直接跳转。
scanUnprocessed 的 sweep 模式对整个桶 18,407 个对象分页 list(),在内存里做集合运算推导「哪些音频还没挖」。对象越多越慢,且随全站增长线性恶化——为此才被迫拆出 per-user 分片。
articles-index / recordings-index 两个 JSON 是为绕开 list() 慢而造的加速层:每次写文章都 RMW 更新索引(try/catch 吞错),再靠全前缀 list() 对账兜底。代码注释实测:~1,500 个 key 扫一遍 1.0–1.6 秒,「不达标」才挪进后台;首次调用仍同步对账 ~1.3s。
refhits 按 IP 指纹分目录存对象,查一次归因要 list 整个前缀再逐条 GET(上限 80 个),每个 GET 都是跨洋往返。这是典型的「该用一条 SELECT 的地方用了 81 次对象操作」。
R2 没有 CAS、没有原子自增,最后写者赢。已知竞态点:prompt-shares.json(byItem + mintLog + 每日上限三件事夹在一次 RMW 里,真实丢更新风险)、shares/<code> 的 importCount(代码注释自认「并发导入偶尔丢计数」)、两个 index JSON、ACCOUNT.json、举报 reporters[]、config/prompts.json。
「打开分享 10 秒」= 15 个串行 R2 操作,靠 Promise.all + waitUntil 缓解;删账户扫全前缀逐 key 删,913 个对象的账号曾打爆 subrequest 预算,改成 1000/轮批删。这些补丁都在对抗同一件事:把数据库工作负载放在了对象存储上。
WNAM 桶位置使上述每一次对象操作都自带跨太平洋 RTT。迁 D1 后写路径同样要去主区域,但请求次数从 N 次降到 1 次,且 D1 读复制可把读就近化——这是数量级差异的来源。
原则一句话:blob 留 R2,状态进 D1,协调留 DO。R2 保持真源直到切换完成,随时可回退。
| 表 | 关键列 | 取代的 R2 结构 | 消灭的问题 |
|---|---|---|---|
| articles | user_sub, stem PK, title, head, status, tags, flag_empty, flag_blocked, created_at, updated_at | articles-index.json + .empty/.blocked/.tags 镜像 | 索引 RMW、对账扫描、状态位双写 |
| recordings | user_sub, key PK, uploaded_at, mine_state, fail_count | recordings-index.json + .minefail + 全桶扫描 | miner 扫全桶 → WHERE mine_state='pending' |
| shares | code PK, type, owner, target_key, payload, import_count, created_at | shares/<code> + prompt-shares.json | 一名两制、importCount 丢计数(原子 UPDATE)、丢更新 |
| invites | code PK, owner, name, ts | invites/<CODE> | —(顺手,量小) |
| refhits | fingerprint, ts, owner, token · 索引(fingerprint, ts) | refhits/<ip>/<ts> 对象树 | list+80 GET → 1 条 SELECT;cron DELETE 取代生命周期 |
| identities | provider, external_id PK, user_sub, linked_at | links/apple-*.json · links/wechat-*.json | 登录多一次跨洋 GET;first-write-wins 改唯一约束 |
| user_profile | user_sub PK, email, name, avatar, push_token, push_env, last_seen_at | ACCOUNT.json + push-token.json | RMW 合并竞态 |
| community_reports | share_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 对象分页 list | 1 条 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 每表 |
建库 voicedrop-core + migrations;写 backfill 脚本(walk R2 → INSERT,幂等可重跑);共享 lib 里加 D1 访问层。所有新读路径挂 feature flag,R2 写照旧。
refhits、shares、invites、prompt-shares 切 D1。这四类量小(合计 <740 对象)、调用方集中、不碰移动端接口形状,却直接消灭 N+1 归因查询和两处真实丢更新。双写一周对账无差异后停写 R2。
articles / recordings 两表落地:写文章时 UPSERT,GET /articles、/recordings 直接 SELECT(entry 原样存取,接口 JSON 形状逐字节不变,iOS 无感)。实施中的现场修正:挖矿扫描不动——全桶扫描只发生在 6 小时 cron 的兜底 sweep(非热路径),每用户前缀扫描量级有界;老用户无需全量 backfill,第一次打开列表时后台对账自动把 D1 拉齐(自愈式迁移)。reconcile 暂保留为每次列表后的后台对账,等 D1 跑稳后降频/改手动。
identities / user_profiles / push_tokens / community_reports 四表迁入:Apple/微信登录的 scope 解析与社区写门槛 hasVerifiedBinding 改查 D1;push token 上传即双写、sendPush D1 优先、410 双删;举报的隐藏集/详情/对账都变成一条 SELECT(取代 community/reports/ 前缀扫描)。文章正文与版本史继续留 R2(拆版本表是另一个话题)。「取消每次列表后的全量对账」暂缓——留作 D1 稳定运行观察后的收尾,现阶段 reconcile 仍作后台自愈兜底。
DEBUG_PLAINTEXT_IP = true(refhits.js:14)仍开着——归因指纹和 PostHog distinct_id 目前存明文 IP,备忘录里写的是「确认后翻回 false」。数据来源:三路并行代码全扫(Pages Functions 1,920 行主文件 + agent worker 44 个源文件 + reco/EdgeOne/VPS)交叉印证;线上规模取自 Cloudflare API 与 wrangler d1 实测。文中 file:line 均可在仓库直接定位。