Skip to content

09 生产事故与踩坑故事集(面试最好的弹药) ​

为什么单独一篇:字节面试官几乎必问"讲一个你解决过的最难的问题"和"你踩过什么坑"。 空谈架构不如讲一个真实事故:现象 → 定位 → 根因 → 修复 → 教训。这一篇里的事故全部有代码注释/迁移文件作为证据,可以直接引用。 每则统一 STAR 结构(Situation 背景 / Task 我面对什么 / Action 我做了什么 / Result 结果与沉淀)+ 【追问】 + 【备注讲解】。


〇、按"面试价值"排序的清单(先挑 3 个背熟) ​

排名故事一句话卖点适合证明什么
🥇 1「假 DDoS」:轮询没有终止条件我们自己把 Redis/DB 打满,看起来像被攻击系统性思维、把"事件放大器"找出来
🥈 268 小时悬挂 + 57.6 积分没退把权威状态从前端浏览器搬回服务端架构判断、资损意识
🥉 3重复兜底扣费 37 单 / 370 积分幂等判断漏了另一半凭证心细程度、对账意识
42.6 秒视频按 30 秒收 75 积分同类坑第四次,最后用"角色白名单"从根上封死从"修 bug"到"改架构"的升级
5OSS 文件被真删(四次)每次根因都不同,最后收敛成"存在性判定 + 触发器 + 唯一约束"数据生命周期设计
6字幕擦除任务永久"生成中"一个不在注册表里的 provider 让巡检集体瞎掉保守归一 / 失败可见性
7画质增强被做了两遍,双份转码费两条链路各锁各的键,锁互相不可见分布式锁的正确用法
8脚本库占位行被恢复逻辑重投 → 空 prompt 烧钱三重错位串成一条完整的资损链链路推演能力
9日期口径错了 8 小时生产库是 UTC,流水页和仪表盘算进不同的天时区/数据口径细节
10前端进度条是伪随机的主动交底:体验兜底 ≠ 真实进度诚实度(这是加分题,不是减分题)

故事 1 🥇「假 DDoS」——我们自己把系统打满了 ​

【Situation】 2026-08-04 前后,监控上看到 Redis 和数据库的压力异常升高,请求量像被攻击一样。但查下来没有外部攻击源——流量全是我们自己产生的。

【Task】 找出"谁在打我们",并让这种事不可能再发生。

【Action】 定位到两条链路叠加放大:

  1. 轮询队列没有终止条件。 videoPollWorker 的续排函数 scheduleNextPoll 以及创意复刻/长视频/复杂复刻三条分段轮询分支,都只负责"再排下一次",没有任何"什么时候停"的判断。轮询间隔表到第 8 次就封顶在 30 秒,于是从那之后就是永远每 30 秒一次。上游卡死或回调丢失时,这些 job 会一直空转到有人手工清理。
  2. 前端是放大器。 只要历史记录的状态不是终态,前端就每 3 秒继续轮询一次。所以服务端"没能把任务推到终态"和"客户端看见非终态就一直重试"这两件事互相放大。

修复做了三件事:

  • 抽出纯函数 isPollTimedOut() + 一张间隔表推导的2 小时硬超时(MAX_POLL_ATTEMPTS 由间隔表反推,不写死次数——避免"两份清单只改一份",这个仓库在那个坑上反复出过事);
  • 超时后强制走"最终失败"分支(显式把 retry_count 置成 max_retries),因为可重试失败分支不写 video_history,前端会继续转圈——这正是放大器那一环;
  • 超时判定收口在 scheduleNextPoll 一个函数里,因为有多个调用点,"三个调用点只封了一个"等于没封。

【Result】 轮询 job 有了确定的寿命上限(2 小时),悬挂任务能被自动终结并退款;同时留下一个手工清理脚本 clean-zombie-polls.ts 处理历史遗留(默认 dry-run,加 --write 才真删,脚本头警告"调低阈值会把正常轮询任务删成永久生成中")。

【追问】

  • "你为什么不直接限制队列速率?" → 因为瓶颈不是速率而是寿命:这些 job 永远不会结束,限速只会让它们排队更久。要解决的是终止条件。
  • "2 小时怎么定的?" → 与恢复逻辑里单次复刻的 SR_TIMEOUT_MS 同口径,否则会出现"巡检说超时了、轮询还在转"的错位;并且由间隔表推导,改间隔表超时依然精确是 2 小时。
  • "为什么放在 scheduleNextPoll 里而不是各调用点?" → 调用点多,漏一个等于没封。

【备注讲解】 这题的核心叙事是"事件放大器":服务端没能推进状态 + 客户端看见非终态就重试 = 自激。修的时候两边都要看,只修一边还会打满。这个抽象可以直接迁移到任何"客户端轮询 + 服务端状态机"的系统,说出来很加分。

【代码依据】 backend/services/queue/pollTimeoutPolicy.ts:1-75(间隔表、2 小时、由表推导次数、"2026-08-04 假 DDoS"注释)、backend/services/queue/videoPollWorker.ts:1893-1924(收口 + 强制最终失败 + "3 秒轮询放大器"注释)、backend/scripts/clean-zombie-polls.ts。


故事 2 🥈 一条批次卡了 68 小时,57.6 积分没退 ​

【Situation】 生产上有一条标准复刻批次在"生成中"停留了 68 小时,用户看不到成片,也拿不回 57.6 积分。

【Task】 我的巡检本该发现它——为什么它每 10 分钟跑一次却"眼睁睁看着"?

【Action】 查下来根因是一句话:结算动作被绑在了用户的浏览器上。 标准复刻这条链路的收尾(把原声回贴、画质增强、写历史、结算积分)原来只由一条 HTTP 路由触发——也就是"用户还开着页面、前端在轮询"的时候才会被调用。当时的恢复逻辑对这条链路是无条件 continue,理由是"由 status 路由独占收尾",注释还写着"自管,跳过"。于是用户一关页面:没人收尾 → 任务永远停在"生成中" → 积分永远 held → 巡检每轮只打一行"跳过"日志。 我做了三件事:

  1. 把巡检改成真正对账:按任务年龄 >30 秒/分段两类分流,用和路由完全相同的那一份收尾实现(standardRemakeSettle),不复制第二份 SQL——因为复制一份就意味着两处的顺序会各自漂移;
  2. 优先去问供应商真实状态:生产上抓到的卡住任务里,有相当一部分在供应商那边早就终态了(抓到过 21 小时前就 failed、而前端只轮询过 1 次甚至 0 次的任务);
  3. 加"卡在收尾"的专门兜底:如果所有段都出片了、只是收尾(下载 + 拼接 + 上传 + 增强 + 结算)没跑完,重跑收尾而不是退款——因为段视频都在手上、钱也已经付给供应商了,直接退款等于把已生成的成品扔掉。收尾重跑上限 3 次,超过才退款给用户一个交代。

【Result】 悬挂任务从"靠用户开着页面"变成"服务端 10 分钟内必然收敛";同时留下一句话写进注释:"先写业务终态、最后才动钱,不要顺手调换顺序"——因为一旦退款成功而写库失败,hold 已经 refunded、巡检不再扫描它,两张业务表却永远停在处理中。

【追问】

  • "为什么不干脆让前端一直轮询?" → 那就等于把结算和退款绑定在用户标签页上,这是设计缺陷不是兜底方案。
  • "重跑收尾会不会重复扣钱?" → 不会。收尾是幂等的(重跑拼接/上传/结算),并发安全由 batchFinalizeClaim 锁负责,我们不自己发明锁。
  • "怎么判断是真断了还是刚好很慢?" → 用心跳 + 2 倍宽限:段轮询最长 2 小时必出终态,所以超过 2 倍时长还没心跳才算真断了。宽限取 2 倍而不是 1 倍,因为"最后一段刚好卡到超时上限"是合法状态,误杀代价是把正常任务标失败。

【备注讲解】 这题的杀手句是:"这条链路的结算原来依赖用户的浏览器还开着——这不是兜底,这是把可用性绑在客户端上。"凡是"权威状态判断发生在客户端"的设计,都值得怀疑。

【代码依据】 backend/services/taskRecovery.ts:95-106(68 小时 + 57.6 积分的原始注释)、:370-490(对账分流、收尾重跑、2 倍宽限、Redis 异常时保守跳过)、backend/services/queue/videoPollWorker.ts:104-116("抓过 21 小时前就 failed 的任务")、backend/services/standardRemake/standardRemakeBatchService.ts:9-37。


故事 3 🥉 重复兜底扣费:37 单 / 14 个用户 / 多扣 370 分 ​

【Situation】 从 2026-08-14 起,陆续有 37 单、14 个用户被多扣了积分,合计 370 分。

【Task】 找到"同一笔任务被扣两次"的路径。

【Action】 根因在兜底扣费的幂等判断只看了一半凭证。 videoPollWorker 里有一个兜底逻辑:收尾时如果发现这个任务"似乎没扣过钱",就补扣一笔固定 10 分。它的幂等判断原来是:

sql
SELECT id FROM user_credit_transactions WHERE ref_task_id = $1 LIMIT 1

但我们的业务里有两种扣费顺序:

  • /generate 这类是"先扣费、后建任务"——扣费发生时还没有 taskId,所以流水上的 ref_task_id 是空的;
  • 另一批是"先建任务、再扣费",ref_task_id 有值。 于是对那些"先扣费后建任务"的单子,兜底逻辑看不见任何流水,判断成"没扣过",再扣一遍 10 分。 修法是同时检查另一半凭证:credit_holds 里有没有这个 taskId 的记录。因为"先扣费后建任务"的链路,建任务后会用 creditBilling.hold 补写一条 credit_holds 行——那一行才是"钱已经扣过"的证据。

【Result】 兜底扣费现在要同时查流水和冻结表,缺一不可;这个 case 的因果被写进了代码注释(含具体数字),防止后人简化回去。

【追问】

  • "为什么会有兜底扣费这种奇怪逻辑?" → 历史遗留的保底,防止"完全没扣费就出片"的白嫖。正确方向是把它删掉(因为真冻结链路已经覆盖了),但在迁移完成前必须先修正确。
  • "怎么发现这 37 单的?" → 对账巡检的 balanceDrift 和人工核对流水。这也是为什么我把对账做成一等公民。

【备注讲解】 这题的通用教训非常漂亮:"幂等判断必须覆盖所有产生该事实的路径。只查一半凭证,等于给另一半路径开了后门。"

【代码依据】 backend/services/queue/videoPollWorker.ts:1926-1972(双重幂等判断 + "生产 2026-08-14 起 37 单 / 14 用户 / 多扣 370 分"注释)。


故事 4 2.6 秒的演示视频,按 30 秒收了 75 积分 ​

【Situation】 用户上传了一条 2.6 秒的产品使用演示视频 + 一堆参考图,选择 30 秒时长做编辑类生成。结果按 30 秒收了 75 积分,交付了 2.625 秒。

【Task】 这是同类问题的第四次了,这次必须从根上封死。

【Action】 根因是素材角色的判定错误。 编辑类任务(operation: 'edit')的时长语义是 Duration = -1:跟随原片时长。而我们把那条 2.6 秒的"产品使用演示视频"当成了待编辑的原片——于是成片就是 2.6 秒,但计费按用户选的 30 秒走。用户付的是 30 秒的钱,拿到的是 2.6 秒的片子。 前三次都是"打补丁":在某个分支加一个判断。第四次我改成收敛到一张角色白名单:只有特定角色的视频才算"待编辑原片"(NON_EDIT_SOURCE_VIDEO_ROLES 之外的都排除),角色判定只在一处,不再散落在各个分支里。同时把这条链路的因果关系写进了注释(含"2026-09-02 生产事故就是这么来的")。 类似口径我后来也用在了另一条链路上:有一批"选择秒数作废、计费照旧"的单子,全平台 12 单收了 158 分却只交付 23 秒,最后也是靠统一口径的结算模块修掉的。

【Result】 同类问题的第五次没有发生;而且衍生出一条结算口径:按实际交付时长核算时,只退不补、向上取整、结算失败回落原结算。

【追问】

  • "为什么只退不补?" → 因为供应商交付普遍比下单多 0.1~0.4 秒(实测 4 秒单交付 4.096 秒),双向补差会让几乎每一单都被多刮一点零头,用户投诉量远大于收益。这是"数学更精确、业务更糟"的典型。
  • "结算失败为什么不重试?" → 卡住会让 hold 悬空,超过时限后被兜底整笔退款,等于成品白送。所以宁可少退一点钱,也不能卡住结算。

【备注讲解】 这题最值钱的部分是**"第四次才从根上封死"。你可以坦率地说:前三次都是打补丁,第四次我才意识到问题的根因不是某个判断错了,而是"角色判定散落在多个分支里"**——修 bug 和改架构的分界线就在这里。

【代码依据】 backend/services/video/providers/tencentVod.ts:94-102(2026-09-02 事故 + 角色白名单)、:563("照它剪就是把 30 秒剪成 2.6 秒")、backend/services/billing/deliveredDurationSettlement.ts:3-16(12 单 158 分 / 23 秒 + 三条口径)。


故事 5 OSS 文件被真删了四次(外加一次标签残留) ​

【Situation】 我们的素材库里,用户上传的图片和视频在 30 天后被物理删除——但它们还在被使用。这个问题前后发生了四次,每次根因都不一样:

  1. 写入模式问题:"重建素材"的 updateLibraryItem 是"先全删再全插",执行期间文件短暂失去所有引用,于是被判定为可删、30 天后被真删;
  2. 中文文件名编码不一致:URL 里的中文是 percent-encoding,而 oss_extract_key() 拿到的是编码后的 key,两条杀链同时成立——摘标签脚本拿编码 key 去删报 NoSuchKey,这个异常被 catch 成"成功",真实对象 15 天后被桶的生命周期规则删掉;同时临时表里按编码 key 也删不掉那行,到期后被真删。这是用户反馈"图片/音色集中消失"的直接死因;
  3. 复制粘贴绕过守卫:清空 try_ons 的操作误伤了跨素材共享的定妆照——实测有一条 front.jpg 仍被活跃模特引用,却照样进了删除队列;
  4. 覆盖面遗漏(最严重):对话里上传的图只存在 conversations.snapshot 字段里,而 oss_key_in_use() 没覆盖这张表,7 天临时清理把图删了,用户 @图片1 时下载 404、整批任务中止。普查近 60 天有 1638 处引用、约 1136 处已失联(抽样 40 个实测 31 个 404,涉及 61 个账号 / 459 个会话)。 另有一次标签残留:三个还在使用的定妆照带着 pending-delete 标签活了 51 天。

【Task】 让"文件能不能删"这件事有一个不可能被绕过的判据。

【Action】 我参与做的是这套生命周期机制:不建冗余的全局对象表,而是把业务表当作文件的注册表,只跟踪两类特殊状态(temp_oss_uploads 临时上传 = 7 天 TTL;oss_delete_queue 软删后待物理删除 = 30 天)。核心是一个 PostgreSQL 函数 oss_key_in_use(key),它跨 12+ 张业务表判断这个对象是否还被引用(包括 JSONB 展开和 jsonb_path_query 深扫),然后:

  • 入库时由 DB 触发器自动把文件从临时追踪和删除队列里撤销;
  • 出库时必须"不被任何业务表引用"才允许进删除队列;
  • 删除队列上有一条唯一约束保证 ON CONFLICT 真正生效(清掉了历史重复行,否则"同一个仍在使用的 key 被反复入队 → 一条到期被物理删除 → 后续报 key 不存在");
  • 物理删除前还有四处复检(注释写着"物理删除不可逆,落刀前最后确认");
  • 撤销路径要先标 revoked_at、再摘掉 OSS 对象标签(起因:有三个在用的定妆照带着 pending-delete 标签活了 51 天)。

【Result】 约束被下沉到数据库触发器和函数,所以"漏调一个 lifecycle 函数"这种情况从结构上不可能发生。

【追问】

  • 🚩 "你简历写的'引用计数保护'是怎么实现的?" → 要立刻纠正:"准确说不是计数,是引用存在性判定。代码里 refCount 只用于'参考图数量',跟文件保护无关。我们用的是 oss_key_in_use() 存在性判定 + 触发器 + 唯一约束 + 30 天延迟删除。"
  • "为什么不直接做引用计数?" → 计数需要每一次 +1/-1 都不漏,而漏一次就是永久性错误(要么永不删除、要么误删)。存在性判定是每次删除前重新计算事实,天然没有"计数漂移"这种失败模式。这是把"状态一致性"换成"实时计算"的取舍。
  • "中文文件名的问题是什么?" → URL 里的中文是 percent-encoding 的,decode 之后的 object_key 跟存储里对不上,所以匹配改用 ASCII 前缀 uploads/<epoch>-<rand8>。

【备注讲解】 "计数 vs 实时判定"这个取舍是本题的内核,值得单独记住:计数的失败模式是静默漂移,实时判定的代价是每次要查 12 张表。对"删除"这种不可逆操作,后者明显更划算。

【代码依据】 backend/migrations/20260709120000_oss_delete_queue_guard.up.sql:20(oss_key_in_use 首次定义 + "先全删再全插"事故)、backend/migrations/20260709160000_oss_key_encoding_fix.up.sql(中文文件名 percent-encoding 双杀链)、backend/migrations/20260709170000_fix_tryons_enqueue_guard.up.sql(try_ons 跨素材共享误伤)、backend/migrations/20260909120000_oss_key_in_use_conversations.up.sql(对话快照事故 + 1638/1136 的数据)、backend/services/storage/ossLifecycleService.ts:197/210/318/371(物理删除前四处复检)、:221-269(撤销标签路径 + 51 天事故)。

【收尾时可以说的一句总结】 四次事故可以被归成五类根因:写入模式(先删后插)、编码不一致、复制粘贴绕过守卫、覆盖面遗漏、以及"副作用不对称"(删除会触发清理,但撤销不会自动摘标签)。这五类在任何一个"带引用关系的资源生命周期系统"里都会重现——所以我们的收敛方向是把判据下沉到数据库函数和触发器,而不是在业务代码里补判断。


故事 6 一个字幕擦除任务永久停在"生成中" ​

【Situation】 2026-09-02,火山侧的擦除任务 lb:820d66… 在 11:08:03 就 Success 了(耗时 5 分 6 秒),但我们的库里25 分钟没有动,用户的任务永远停在"生成中",积分永久 held。

【Task】 为什么每 10 分钟跑一次的巡检看不见它?

【Action】 根因是一条被吞掉的异常:字幕擦除任务落库时写的 provider 是 'volcengine',而它不在视频供应商注册表里。巡检走到通用分支时会 getProvider(task.provider),直接抛"未知视频生成供应商: volcengine"——但这句异常被 catch 吞掉后保守返回了 processing。 于是形成了一个非常隐蔽的死亡循环:巡检每轮都以为"它还在跑",于是什么都不做,眼睁睁看着任务永久悬挂。 修法是在巡检和轮询 Worker 的最前面按 sourceMode 拦截这类任务,走各自的专用收尾。同时不能反过来给它硬塞一个 provider 实现——那样会走通用收尾,把供应商直链当 video_url 写进去,而这条链路的播放/下载必须走自己的代理接口。 同一天还发现换脸任务(provider='tencent-mps')是一模一样的洞,同一个修法。

【Result】 这类"provider 不在注册表"的自管链路全部前置拦截,并且每条分支的注释都写明了"落到下面会被怎么误杀"。

【追问】

  • "为什么 catch 之后返回 processing 而不是 failed?" → 那是保守归一的正确选择(不确定时当作"还在跑",因为误判失败的代价是标失败 + 退款),但它掩盖了"这个 provider 我根本不认识"这个事实。正确的修法是:拦截在更前面,并且让"未知 provider"这类结构性问题直接告警,而不是降级进保守分支里。
  • "这说明什么?" → 保守策略本身没问题,问题是没有区分"网络抖动导致的不确定"和"我根本不认识这个东西"。前者可以保守等待,后者必须立刻暴露。

【备注讲解】 这题是"过度保守也会造成故障"的绝佳案例。大多数人只会讲"我们做了保守降级",而你能讲出"保守降级用错地方会变成永久悬挂",层次立刻不一样。

【代码依据】 backend/services/taskRecovery.ts:108-126(字幕擦除/换脸拦截 + 2026-09-02 lb:820d66… 实锤)、backend/services/queue/videoPollWorker.ts:118-141(Worker 侧同样的拦截 + "不能硬塞 provider 实现"的理由)、backend/services/subtitleErase/subtitleEraseSettle.ts:1-10。


故事 7 同一个任务被超分了两遍,双份转码费 ​

【Situation】 生产实测发现同一个任务的画质增强被做了两次,各约 52 秒——也就是双份转码费,而且两个结果会互相覆盖落库。

【Task】 谁在重复提交增强?

【Action】 触发路径很清楚:任务恢复时对 processing 任务做了"立刻重轮一次"(delay=0 时不做 jobId 去重),叠加已有的延迟轮询 job 还在队列里,再叠加多实例部署,同一个任务就会被并发 finalize。 我用了 Redis 的 SET key value EX 1800 NX 做收尾抢占:抢不到的那个直接 return,不报错、不重试;成功后不主动释放锁(后续 job 会被开头的终态卫语句拦下,让锁自然过期)。 但真正麻烦的是第二条链路:画布/首页对话这类任务,前端自己也会触发一次增强,用它自己的 Redis 键做去重;而 worker 侧用的是另一个键(shatang:enhance-claim)。两条链路的锁互相不可见——两边都做了去重,去的是不同的重。 修法是:画布类任务统一去抢前端那条链路的键,并按同一份 JSON 状态机协议(processing / done / failed)推进状态。

【Result】 重复增强消失;更重要的是留下一条结论:分布式锁只有在所有参与者用同一把钥匙时才有意义。

【追问】

  • "为什么抢不到锁是静默 return,而不是抛错重试?" → 因为抢不到意味着"别人正在正确地做这件事",这是正常情况不是异常;抛错会让队列重试,反而制造更多并发。
  • "TTL 1800 秒会不会太长?" → 它必须大于一次最慢的合法收尾(增强 + 转存 + 落库),我们按最慢路径留了余量;成功率上,早释放锁比晚释放危险得多。

【备注讲解】 这题非常适合回答"你在团队协作/多链路系统里踩过什么坑"。两条链路各自演化、都做了正确的事、合起来就是错的——这是大型系统里最难查的一类问题。

【代码依据】 backend/services/queue/videoPollWorker.ts:229-291(抢占逻辑 + 双链路注释 + "同一任务增强两次、各 ~52s 双份转码费")、backend/services/lock/batchFinalizeClaim.ts:3-42(批次锁用 token + Lua 只删自有锁,起因是旧 hsetnx 无 TTL 导致进程被杀后永久卡 concatenating)。


故事 8 空 prompt 烧钱:三重错位串成的资损链 ​

【Situation】 如果恢复逻辑把"脚本库一键生成"的占位行当成标准任务重投,会发生一件非常贵的事。

【Task】 我需要在恢复逻辑、Worker、轮询三处都把它拦下来——只拦一处就够出事故。

【Action】 完整的事故链是这样的(现在被写进了 videoWorker 的注释):

  1. 脚本库任务在 video_tasks 里只有一行占位行(provider='ark'、external_task_id 恒为 NULL),它存在的唯一目的是满足 video_history.task_id 的外键;
  2. 恢复逻辑的"无 external_task_id 且超过 5 分钟"这个判据正好命中它 → 重新入队;
  3. 提交 Worker 拿一个空 prompt 去调 Ark → 烧真钱,出一条 4 秒废片;
  4. 轮询 Worker 的 ON CONFLICT DO UPDATE 把用户的占位行覆盖成废片,并把冻结结算掉;
  5. 等真正的成片跑完,selfServeSettlement 的"生成中"守卫又让它静默 no-op;
  6. 最终结果:用户付了钱、历史里是废片、真成片永远看不到。

修法是三处都加前置 skip(恢复逻辑、videoWorker、videoPollWorker),并且每条注释都写清了"落到下面会被怎么误杀"。

【Result】 这条链路被封死;同时形成了一个判断清单——凡是在 video_tasks 里只有占位行、状态由进程内存或另一套队列自管的链路,都必须在通用逻辑的最前面被排除(我们一共识别出 9 条这样的链路)。

【追问】

  • "为什么会有占位行这种设计?" → 因为 video_history.task_id 对 video_tasks.id 有外键 + 唯一约束,每条历史必须对应一个任务行。这是外键设计的副作用,也是我们最大的技术债来源之一。
  • "正确的修法是什么?" → 给占位行一个显式标识(比如 provider='internal' + self_managed 字段),让通用逻辑一眼排除,而不是靠三处各自维护字符串清单。

【备注讲解】 这题能讲出"三重错位串成一条完整资损链",说明你有把分散的判据串起来推演后果的能力。这是高级工程师和中级工程师最明显的分水岭之一。

【代码依据】 backend/services/queue/videoWorker.ts:82-94(完整资损链注释)、backend/services/taskRecovery.ts:128-145、backend/services/queue/videoPollWorker.ts:87-141。


故事 9 流水页和仪表盘算进了"不同的天" ​

【Situation】 用户投诉:积分消费记录页看到的消耗,和后台仪表盘统计的对不上。

【Task】 数据没有错,为什么统计不一致?

【Action】 根因是时区口径:生产库是 UTC,而我们的业务日界是北京时间。查询如果直接用 created_at::date 分组,日界就落在 UTC 零点 = 北京时间早上 8 点——于是同一笔消费会落进不同的"天":流水页(按本地时区展示)和仪表盘(按 UTC date 分组)自然对不上。 修法是在查询里强制转换:

sql
(created_at AT TIME ZONE 'Asia/Shanghai')::date

并且把这个口径固化进一个专门的查询模块,避免各处自己写。

【Result】 两处统计口径统一;这个事故被写进了代码注释("生产库是 UTC,日界早 8 小时,流水页与仪表盘算进不同的天")。

【追问】

  • "为什么不把数据库时区改成本地?" → 因为存储用 UTC 是更正确的选择(跨时区、夏令时、夏令时切换都不会有歧义),展示和业务日界才该用本地时区。要统一的是"计算口径",不是"存储时区"。
  • "怎么防止再犯?" → 把日界转换收敛到一个函数/模块,并且在测试里用跨日界的时刻做用例。

【备注讲解】 这是所有涉及时间统计的系统都会踩的坑,讲出来很能说明你处理过"数据口径"这类问题(而不只是功能开发)。

【代码依据】 backend/services/billing/creditTransactionQuery.ts:55(强制 AT TIME ZONE 'Asia/Shanghai' + 事故注释)。


故事 10 前端进度条其实是"伪随机动画"(诚实题) ​

【Situation】 面试官如果问"你们的生成进度是怎么做的",真实答案是:

【口述答案】要诚实说:进度条大部分时候不是真实进度。 我们的实现是取 max(后端 progress, 本地持久化进度, 模拟进度, 阶段下限) 再 min 到一个上限值,其中"模拟进度"是一个用 mulberry32(hash(taskType:taskId:startedAt)) 播种的 S 曲线,上限值在 94~99 之间随机。 为什么这么做:上游供应商不返回细粒度进度——它只能告诉你 processing,没法给"完成了 63%"。而在分钟级的等待里,一个完全静止的进度条会让用户以为卡死了,甚至会重复提交(2026-09-06 就发生过用户连点 8 次全失败的事故)。 用 hash(taskId) 播种而不是纯随机,是为了让同一个任务的进度在同一用户的不同页面/刷新之间保持稳定——否则刷新一次进度跳回去,体验更差。 它的定位是"等待体验兜底",不是"进度展示"。 改进方向已经留了口子(阶段下限 phaseFloor),真正要做的是让上游产出真实的阶段码(提交中/排队中/生成中/后处理中),用阶段驱动而不是百分比驱动。

【备注讲解】 这是主动性最强的一题——大多数人会含混地说"我们做了进度展示",你直接交底"它是伪随机的、为什么伪随机、怎么改进",面试官会记住你。但要把它讲成工程取舍,不是道歉。

【代码依据】 src/features/**/useDisplayProgress、getDisplayProgress(max(...) + cap + mulberry32);相关事故在 backend/routes/tools.ts:2439("2026-09-06 生产事故,用户连点 8 次全失败")。


附:还有哪些"小坑"可以顺手拿出来用 ​

坑一句话依据
首尾帧 + 参考媒体混用被腾讯直接 400判定只查"有没有参考视频",导致只有参考图的多段作业段 2 起 100% 失败tencentVod.ts:269-289(2026-08-17 起生产 4 例)
免审任务重启后被误退款账号提示存在进程内 Map,重启即丢,任务被主账号查成"任务不存在"→ 误退款;改成按 TaskId 前缀反推账号tencentVod.ts:14-24
高清档"按 1080P 收费、按 720P 交付"调用收尾函数时省略第 4 个参数,吃到了默认模型值productionWorker.ts:396-401
参考图归一化漏挂路由归一化曾只挂 4 个路由,漏挂的路由"同一张图连挂 9 次"tencentVod.ts:628-633
素材注册连挂 7 次、日志为零2026-08-30 标准复刻因素材注册失败连挂 7 次,而那个文件当时一条日志都没有backend/services/tencent/vodMaterial.ts:82
缓存污染被全量回写persistUserHistory 是全量回写,会把缓存里的污染值整批写回数据库,导致历史记录串味backend/routes/video.ts:334-345
失败原因全是套话固定文案"供应商返回任务失败"是 error_message 里出现最多的一条(近 60 天 21 次),真实原因全丢videoPollWorker.ts:163-170
一笔 8 小时前就该失败的视频恢复巡检抓到21 小时前就 failed、而前端只轮询过 1 次的任务videoPollWorker.ts:106-110
两个 React 实例导致白屏后台曾未声明 lucide-react,从根 node_modules 借依赖后解析到第二个 React,ReactSharedInternals.H 为 null;修法是补依赖 + resolve.dedupeadmin/(前端分册详述)
CI 其实半年没跑GitHub Actions 自 2026-08-08 起因账户欠费从未启动,pre-push hook 又因 core.hooksPath 未设置未安装.githooks/pre-push 注释;见 08 分册

面试时怎么用这一篇(三条建议) ​

  1. 准备 3 个,讲 1.5 个。 主故事讲透(现象→定位→根因→修复→教训),第二个故事只在被追问时打开。不要一口气讲三个。
  2. 每个故事都留一个"钩子"给面试官。 比如故事 1 留"前端为什么要 3 秒轮询"、故事 2 留"为什么不能只写一份收尾实现"——让面试官问,你才好展示深度。
  3. 结局一定要落到"沉淀":要么是把约束下沉到数据库、要么是抽成纯函数可单测、要么是把判据收敛到一处。只讲"我修好了"是初级,讲"我让这类问题不可能再发生"才是高级。

持续学习,持续构建。