平台工程

B站Toy同步事故复盘:版本指纹先定性,外部cron做兜底

一句话:分发平台的线上产物自带版本指纹(URL 后缀 -v1、外壳 meta),先抓线上实际版本定性”管道坏了”还是”从未走通”,再回头拆管道;调度、分发、更新三环,各自都需要一条 GitHub 之外的兜底。

事实记录(不可修改区)

排障过程

  1. 拉 Actions 运行记录:数据管道(抓取/Pages)全绿,唯独 toy-preview 两次失败,全部死在 Validate 步——问题不在数据侧。
  2. 复现校验器判定(本机无 Python,直接读 check_ref 源码走查):href="./" 被按相对资产拼成 .,不在文件集合 → ERROR。index.html 全文唯一 ERROR 即此误报,改 href="index.html" 一行过检。
  3. 抓线上外壳页:B 站 Toy URL 只是壳,真实内容在沙箱 iframe(bebox.net/toy/<slug>/<id>-v1/)。-v1 一锤定音”首版后从未更新”;且壳里 window.__TOY_META__ 直接暴露了 update 流程缺失的 toy_id
  4. 走网页端更新:CLI update 需要 cookie secret(未配置且不宜经手);发布平台网页端在已登录会话下走”发布记录 → 更新”,slug/标题/可见性全保留,不会误建新 Toy。
  5. 修完复发再排查:次日用户仍见旧数据——Actions 记录证明是 GitHub schedule 整点丢班(非应用问题)→ 上外部 cron 兜底,首跑即真实补救。

结果分析

方法论沉淀

⭐⭐ GitHub schedule 必须配 GitHub 之外的触发兜底(二次验证)

核心:GitHub Actions 的 cron 是尽力而为,高峰期丢班/长时间不触发无 SLA,且状态页不反映——不能作为生产级定时任务的唯一触发源。

来源:两个仓库、两种表现——快讯 workflow 的 schedule 自上线起零自然触发(见 云端定时内容生产连环坑复盘_全绿不等于已发_v1 坑①);本次 typhoon-eye 每小时 4 班仅放行约 1 班、连丢 4 班致数据滞后 69 分钟。

验证状态:二次验证 ⭐⭐(跨仓库、跨月份两例)

操作规则:

  1. 外部定时源(cron-job.org / Cloudflare Worker Cron)按业务周期 workflow_dispatch 兜底;
  2. 外部触发的目标选”过期才动手”的 watchdog 任务而非主任务——幂等,GitHub 自身正常时空转零成本,不产生重复提交;
  3. PAT 用细粒度、单仓库、仅 actions: write;
  4. 兜底线路自身要可观测:开外部服务的失败邮件告警。

反例/边界:分钟级实时性要求不适用(此方案最坏仍有 20–35 分钟滞后),应改常驻服务;外部服务与 GitHub 同时故障无解,属可接受残余风险。

⚠️ 版本指纹先定性,再拆管道(首次发现)

核心:分发平台的线上产物往往自带版本指纹(URL 版本后缀、外壳页 meta、页脚版本号)。排”不更新”类故障,第一步抓线上实际版本——一步区分”管道坏了”和”从未走通过”,避免在正常环节空转;外壳页还可能直接暴露你缺失的元数据(本次 __TOY_META__ 给出 update 必需的 toy_id)。

来源:iframe -v1 后缀直接证明首版后从未更新;三环断裂里两环由此反推锁定。

验证状态:首次发现 ⚠️

⚠️ 校验器 ERROR 先复现其解析逻辑(首次发现)

核心:静态校验器(doctor/linter)报 ERROR ≠ 代码真有缺陷。toy_doctor 把合法的 href="./" 按相对资产解析成 . 判 missing——改代码前先读校验器源码复现判定路径,分清”真缺陷”与”解析盲区”,再选成本最低的一侧改(本次改代码一行,不动校验器)。

来源:preview 工作流两次全挂的唯一 ERROR 即此误报。

验证状态:首次发现 ⚠️

⚠️ 沙箱 iframe 内外部协议链接会静默失效(首次发现,未真机验证)

核心:托管平台把第三方内容装进 sandbox iframe(B站 Toy → bebox.net)时,tel: / mailto: 等外部协议的本框架导航可能被静默吞掉,用户只觉得”点了没反应”。

操作规则:检测 window.self !== window.top 后走三级唤起(top 导航 → window.open → 本框架导航)+ 始终自动复制号码 + toast 提示;顶层环境保持原生行为不动。

反例/边界:B站 App 真机唤起成功率未实测,此条整体仍是待验证假设;保底体验(“号码已复制,可粘贴拨打”)必须存在。

验证状态:首次发现 ⚠️

真机追记(2026-07-12,勘误不覆盖原文):B站 App 真机实测,上面的”三级唤起”第一级(顶层导航)是有害的——App 的 webview 未注册 tel: 处理器,顶层导航把整个 Toy 页跳到 net::ERR_UNKNOWN_URL_SCHEME 全屏错误页,体验比”点了没反应”更糟。修正后的规则:沙箱内禁止一切页面级导航(顶层 / window.open / 本框架都不行),改用隐藏 iframe src = tel:... 触发——支持 tel: 的浏览器静默唤起拨号,不支持的静默失败、页面不动;复制号码 + toast 保底不变。教训:webview 对未注册 scheme 的处置(静默吞 / 全屏报错)因宿主 App 而异,“尝试拨号”的任何路径都必须保证失败时页面无感

⚠️ 单源静态分发升级为多镜像取最新(首次发现)

核心:*.github.io 在大陆不稳,单源在线数据一失败就静默降级旧快照,用户观感 = “从不更新”。并行请求 Pages + jsDelivr×2 + raw 四源,以数据内时间戳取最新(而非先到先得);镜像有缓存 TTL,数据每次更新后必须主动 purge(实测 purge 后 jsDelivr 1 分钟内跟上)。

来源:v1/v2 时期在线取数逻辑存在但用户仍见旧数据的成因之一。

反例/边界:首个成功源之后留 800ms 收集窗口,是首屏延迟与数据新鲜度的折中;jsDelivr 大陆可达性未多地实测。

验证状态:首次发现 ⚠️

下次改进

附录:发布平台网页端更新 Toy 的正确姿势(实测 SOP,2026-07-12 回填)

CLI 更新需要 cookie secret,网页端(bilibili.com/toy/publish)是已登录会话下的首选路径。三次实战(v2/v3/v4)踩出的坑与固定动作:

  1. “更新”按钮有概率把你带进新建表单——实测 3 轮里 2 轮首次点击后落到”上传发布”页:页面地址栏是预生成的随机 slug、标题为空、底部按钮是”发布”。此时上传等于创建第二个 Toy,不是更新。成因疑似 SPA 状态未就绪;窗口较窄时记录行按钮还会从”更新/删除”文字变成 ↻/× 图标,更易误判。
  2. 进入更新模式的三重判据(缺一不可,建议程序化断言而不是目测):① 顶部出现”正在更新 <原标题>“横幅(带”取消”);② 页面地址锁定为原 slug;③ 底部按钮文案是”更新”而非”发布”。首点失效就回”发布记录”等列表渲染完,悬停目标行重点一次。改良(第 4 轮验证):不用坐标点击,直接在页面 JS 上下文定位记录行的 button.update-btn.click(),再原子断言 ①②——实测首次即中,绕开了坐标点击对悬停态/渲染时序的依赖。
  3. 自动化上传的可行注入法:浏览器扩展的文件上传白名单不含任意本地路径;绕法是在页面 JS 上下文里 fetch 仓库 raw 地址的 zip(按 commit 固定 URL),构造 File + DataTransfer 赋给 input[type=file](accept=“.zip,.html,.htm” 的那个)并派发 change 事件。注入前后都校验字节数与预期一致。
  4. 点”更新”前验预览包:上传成功后页面会生成 toy/preview/preview_xxx/ 预览地址,先 fetch 预览包内的关键文件(如本次的 assets/toy-runtime.js)确认确实是新代码,再提交——防止传错包白走一轮审核。
  5. 过审判据:外壳 iframe 的版本后缀 +1(-v3-v4)即上线;两次实测审核耗时约 10 分钟。更新不改 slug、标题、封面与可见性(全部保留原值)。

关联文档

类型/平台工程主题/踩坑主题/自动化生产