Skip to content

11 白板手写题与系统设计题(字节现场版) ​

本册解决的是另一类问题:04 篇那些深挖问答考的是你对自己系统的理解,而白板题考的是你能不能把那份判断力迁移到一个陌生的小问题上。手写题看"思路组织 + 边界意识 + 代码质量",设计题看"澄清能力 + 取舍能力 + 抗挑战能力"。 所有题目都往 shatangAI 上贴——不是为了炫耀项目,而是"用自己跑过两年的真实系统作答"是唯一能让回答有细节、有事故、有取舍的方式。 建议顺序:先读最后那节「白板答题的通用套路」,再按 Q1→Q17 过一遍;每题的 【衔接自己项目】 是这册的灵魂,务必背下来。 可信度说明:所有 JS/TS 代码都在 Node 上实跑过,复杂度与边界的结论来自实跑;SQL 在 PostgreSQL 上实跑过,EXPLAIN 计划是真实输出;Redis 题用 ioredis API + Lua,不依赖第三方库。

〇、一页速览:8 道手写题 ↔ 项目里的真实落点 ​

手写题一句话算法shatangAI 里的落点
Q1 并发上限任务池N 个 worker 抢同一个下标游标BullMQ 的 concurrency(video-poll=10 / video-generate=2 / transcode=1)
Q2 退避 + 抖动指数增长 + 封顶 + 随机打散video-poll 的 backoff: exponential/5000;上传重试 1/2/4/8s;两处都没有 jitter(待改进)
Q3 Redis 限流INCR+EXPIRE / ZSET / 令牌桶middleware/rateLimiter.ts(固定窗口,IP+完整 path)+ USER_ACTION_RULES(按用户名双窗口)
Q4 Redis 分布式锁SET NX EX + Lua 比对释放services/lock/creditLock.ts(TTL 10s,抢不到不阻断,退化成 PG 行锁)
Q5 LRU 缓存Map 的插入顺序当双向链表历史页缓存是"固定 TTL + 写失效",不是 LRU(取舍要讲清)
Q6 收尾抢占SET NX EX,成功不释放enhance-claim / canvas:pp 抢占,防重复付费超分
Q7 守卫式状态更新守卫写进 WHERE,看影响行数taskRecovery.ts 全篇的 WHERE status NOT IN ('done','failed')
Q8 分页OFFSET vs 游标video_history 8 万行走 OFFSET;改无限滚动时要换游标

A. 手写代码题 ​

Q1. 手写带并发上限的异步任务池 asyncPool(tasks, limit) ​

思路 ​

  1. 先划 API 契约:tasks 必须是「返回 Promise 的函数」数组,不是 Promise 数组——传 Promise 数组时,调用方构造数组的那一刻就全启动了,池子从根上失去意义。这句要在写码之前说。
  2. 模型:起 limit 个 worker 抢同一个下标游标,不是把任务切成 limit 份分片(分片会让快的 worker 等慢的,长尾一多并发利用率就掉)。
  3. 默认 allSettled 语义(一个失败不中断其它),"要不要中断"做成选项而不是改结构。
  4. 结果按 index 落位,返回顺序与传入一致,与完成顺序无关。

代码 ​

js
/**
 * 带并发上限的异步任务池。
 * @param {Array<(i:number)=>Promise<any>|any>} tasks 任务工厂(不是 Promise!)
 * @param {number} limit 并发上限
 * @param {{timeoutMs?:number, failFast?:boolean}} [options]
 * @returns 长度与 tasks 相同的数组;failFast 中断时未领取的位置是 undefined
 */
async function asyncPool(tasks, limit, options = {}) {
  const { timeoutMs = 0, failFast = false } = options;
  if (!Array.isArray(tasks)) throw new TypeError('tasks 必须是数组');
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit 必须是 >= 1 的整数');
  if (tasks.length === 0) return [];

  const results = new Array(tasks.length).fill(undefined);
  let nextIndex = 0;   // 全局取号器,所有 worker 共享
  let aborted = false; // failFast 命中后置位:还在跑的 worker 不再领新任务
  let firstError = null;

  // 超时不能只用 Promise.race:原 Promise 仍在跑。这里只做"我不等了" + 清定时器防泄漏。
  function runOne(task, index) {
    const p = Promise.resolve().then(() => task(index)); // 包一层:同步抛也变成 rejected
    if (!timeoutMs) return p;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error(`task[${index}] 超时 ${timeoutMs}ms`)), timeoutMs);
      p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
    });
  }

  async function worker() {
    while (!aborted) {
      const index = nextIndex++;         // 同步自增:中间没有 await,不会重复取号
      if (index >= tasks.length) return; // 领完就退出
      try {
        results[index] = { status: 'fulfilled', value: await runOne(tasks[index], index) };
      } catch (reason) {
        results[index] = { status: 'rejected', reason };
        if (failFast) { aborted = true; if (!firstError) firstError = reason; return; }
      }
    }
  }

  const workerCount = Math.min(limit, tasks.length); // n < limit 时别起空转 worker
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
  if (failFast && firstError) throw firstError;
  return results;
}

// ── 自测(面试时至少口述这两个)──
// 1) 峰值并发:任务里用 running/peak 计数,asyncPool([mk(30),mk(10),mk(50),mk(5)], 2) → peak === 2
// 2) 顺序与失败隔离:第 3 个抛错时结果仍是 [fulfilled, fulfilled, rejected],下标不串位

复杂度与边界 ​

  • 峰值并发恒为 limit(不是 n),内存峰值由 limit 决定——这就是"池子"与 Promise.all 的本质区别。
  • limit <= 0 / 非整数 → 抛 RangeError(别静默改成 1,静默纠正会把调用方的 bug 藏起来);limit > n → 实际并发降为 n;空数组 → 直接 []。
  • failFast 时结果数组里会出现 undefined,调用方必须判空——最常见的"部分成功"踩坑点。
  • 超时后原 Promise 仍在后台跑完(JS 无法真取消);若它带副作用(写库/回调上游),必须另外用 AbortSignal 传进任务。

面试官追问 ​

  1. 为什么参数是任务工厂而不是 Promise 数组? [fetch(a), fetch(b)] 在数组字面量求值时就全发起了,池子只能限制"等待"、限制不了"发起"。BullMQ 的 video-generate 就是这个原理的进程级版本:job 先进 Redis,Worker 领到才去调供应商。
  2. 结果乱序怎么办? 不乱。results[index] 按取号时的下标落位,返回顺序与 tasks 严格一致。想要"完成即回调"的流式语义,那是另一个 API(加 onSettle),别混进一个函数。
  3. 超时算失败还是算重试? 分开。池子只负责"标记这条 rejected",重试是外层 retry() 的事(Q2)。混在一起会出现"超时→重试→原请求还在跑→两个结果都写库"。
  4. 一个失败要不要中断整批? 默认不中断——批量复刻 5 个镜头挂一个,不该让另外 4 个白跑。要中断就给 failFast,但要说明代价:已发出的请求收不回,只是不再领新任务,返回的是部分结果。
  5. 它和 BullMQ 的 concurrency 什么关系? 同一概念的两种实现:手写版是进程内单次调用的闸门,BullMQ 是跨进程、可持久化、可崩溃恢复的闸门。用队列是为了拿到手写版给不了的三件事:任务不丢、能跨进程、能被外部观测。

扣分点 ​

  1. 用分片(chunk)实现:for (i=0; i<n; i+=limit) await Promise.all(slice)。看着对,实际每批都要等最慢的——一批里有个 30 秒任务,另外 3 个槽位就空等 30 秒。
  2. 用 tasks.shift() 取任务:O(n) 让循环变成 O(n²);若写成 await 之后再 shift,还会重复领取。
  3. 超时忘了 clearTimeout:长跑服务里定时器越积越多。

【衔接自己项目】 BullMQ 的 concurrency 就是它的进程级实现——video-poll 给 10(轮询是轻量状态查询)、video-generate 只给 2(打上游、要省槽位)、transcode/retouch 给 1(CPU 密集,跟 Node 主线程抢资源)。主动补一句关键差异:BullMQ 的 concurrency 是每个 Worker 实例的上限,多实例部署时真实并发 = 实例数 × concurrency。我们现在单进程跑,数字还准;一旦水平扩展,这个数必须重新分配——这正是 Q16 里第一个要处理的事。


Q2. 手写"指数退避 + 抖动"的重试函数 ​

思路 ​

  1. 拆成两个函数:computeDelay(attempt, opts)(纯函数、可单测)+ retry(fn, opts)(调度)。能拆成纯函数的策略一定要拆——项目里 pollTimeoutPolicy.ts 的 isPollTimedOut() 就是为此拆的。
  2. 退避序列:min(max, base * factor^(attempt-1))。max 必须有,否则第 10 次就是 100 秒。
  3. 抖动三档:none(不抖)/ equal(raw/2 + rand*raw/2)/ full(rand*raw)。哪档是哪档必须说准,把 equal 说成 full 是概念性扣分。
  4. shouldRetry 用白名单(只重试认识的瞬时错误),不用黑名单。
  5. sleep、random 做成可注入参数:测试才能瞬间跑完 5 次重试并断言确切延迟序列。

代码 ​

js
/** 纯函数:第 attempt 次失败后等多久(attempt 从 1 开始)。 */
function computeDelay(attempt, { base = 200, factor = 2, max = 5000, jitter = 'full', random = Math.random } = {}) {
  const raw = Math.min(max, base * factor ** (attempt - 1)); // 指数增长 + 封顶
  if (jitter === 'none') return raw;
  if (jitter === 'equal') return raw / 2 + random() * (raw / 2); // 半抖:保住下限,不会太早重试
  return random() * raw;                                         // full jitter:0 ~ raw
}

async function retry(fn, options = {}) {
  const {
    attempts = 3, base = 200, factor = 2, max = 5000, jitter = 'full', timeoutMs = 0,
    shouldRetry = () => true, onRetry = () => {},
    sleep = (ms) => new Promise((r) => setTimeout(r, ms)), random = Math.random,
  } = options;
  if (!Number.isInteger(attempts) || attempts < 1) throw new RangeError('attempts 必须是 >= 1 的整数');

  let lastError;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const p = Promise.resolve().then(() => fn(attempt)); // 包一层:fn 同步抛也能被 catch
      if (!timeoutMs) return await p;
      return await new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error(`第 ${attempt} 次尝试超时 ${timeoutMs}ms`)), timeoutMs);
        p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
      });
    } catch (err) {
      lastError = err;
      const isLast = attempt === attempts;
      if (isLast || !shouldRetry(err, attempt)) break;   // 用尽 或 不可重试 → 抛出
      const delay = computeDelay(attempt, { base, factor, max, jitter, random });
      onRetry(err, attempt, delay);                       // 打点挂回调里,便于单测断言
      await sleep(delay);
    }
  }
  throw lastError;
}

// ── 自测(实测值)──
// computeDelay 序列 (base=1000,max=5000,jitter='none') → 1000, 2000, 4000, 5000
// retry(第3次才成功, {attempts:5, base:1, max:4, jitter:'none', sleep:记录}) → 'ok@3',延迟 [1,2]
// shouldRetry 白名单:code='INVALID_ARGUMENT' 时只调用 1 次

复杂度与边界 ​

  • 时间上界 Σ min(max, base·factor^i) + attempts × timeoutMs,空间 O(1)。
  • attempts = 1 → 等价不重试;fn 同步抛 → 必须 Promise.resolve().then(fn) 包住,否则 .catch 直接 TypeError。
  • jitter='full' 时首次延迟可能是 0(立刻重试)→ 生产上一般加一个 floor,否则下游还没缓过来又被打一次。
  • 总耗时必须能脱口而出:attempts=3 + timeoutMs=30s + max=5s 最坏 100 秒,否则用户端会先超时。
  • random 可注入:否则"抖动"让断言变成范围断言,测试会脆。

面试官追问 ​

  1. 为什么一定要 jitter?不抖会怎样? 会形成同步重试风暴:我们这种"一批任务同时打同一个下游"的场景,上游抖一下,几百个任务在同一秒全部失败,退避序列又完全一样(1s/2s/4s),于是它们在 1 秒、2 秒、4 秒这三个时刻重新聚集成三波洪峰,把刚恢复的下游再打挂一次——惊群的经典形态。jitter 的唯一作用就是把这批客户端摊平成一个斜坡。
  2. full / equal / decorrelated 怎么选? full 是默认(打散最好,代价是期望延迟只有 raw/2);equal 保住下限,适合"不希望过早重试";decorrelated(random(base, prev*3),有状态)在长重试链上更均匀但实现复杂。我默认 full + onRetry 打点观察实际分布。
  3. 抖动会不会让最坏延迟更差? 单次上界还是 raw,但期望延迟从 raw 降到 raw/2。要让总预算不变,一般得把 base/max 调大一点,让平均延迟落在原量级。
  4. 哪些错误不该重试? 参数非法、素材不合规、余额不足、模型被关、审核拦截——重试 100 次结果一样,只会重复烧日志甚至重复计费。所以必须是白名单式("只重试我认识的瞬时错误")。
  5. 视频生成"每次调用都烧钱",重试要注意什么? 重试成本是钱不是时间。① 只重试幂等操作(查状态可以重试,提交任务不能无脑重试);② 框架的自动重试不认识钱,video-generate 我特意把 attempts 设成 1,改成业务自己判断;③ 重试次数要和退赔策略对齐——重试 3 次都失败就该走"标失败 + 退款"。

扣分点 ​

  1. 只有 2^n * base,没有 max:第 10 次是 102 秒,第 20 次是 29 小时。
  2. 抖动档位说不清:写成 delay * (0.5 + rand*0.5) 却声称是 full jitter——那是 equal jitter。概念说错比代码写错更致命,面试官会怀疑你所有"最佳实践"都是背来的。
  3. 对非幂等操作重试:比如"提交订单"直接重试会重复下单。正确做法是加幂等键(我们用 jobId = taskId、credit_holds 的部分唯一索引)或者不重试。

【衔接自己项目】 要主动交底现状,并说清为什么现在还没炸:

  • BullMQ 侧 video-poll 用 backoff: { type:'exponential', delay: 5000 },而 BullMQ 的 exponential 不带 jitter,同一批 job 会同时重试;上传侧是 min(1000 * 2^(attempt-1), 8000) 即 1/2/4/8 秒,也没有 jitter(dbInit 同形态,ossAdapter 12 秒封顶)。
  • 主动澄清一处容易被读错的地方:retryStrategy: Math.min(times * 200, 5000) 是 ioredis 的连接重连退避,不是 job 重试,两者很容易混为一谈。
  • 为什么现在还没炸:同时刻在重试的请求数被两件事压住了——video-generate 的 concurrency = 2,以及上游账号级并发槽(腾讯 AIGC 槽位是 ZSET 租约 + 全局 100/本服务 50 两级配额)。所以"同一秒失败又同一秒重试"的规模只有个位数到十几。量涨上来之后,加 jitter 就是重试策略里第一个要改的东西——既承认不足,又给出"现在还安全"的定量理由,比只说"我们没做"强得多。

Q3. 手写 Redis 限流器:固定窗口 → 滑动窗口(ZSET)→ 令牌桶 ​

思路 ​

  1. 先问限流目的,它决定选型:防爆破(硬窗口、宁可错杀)、省自己的 Redis/DB(省命令数)、保护上游稳态 QPS(要平滑)、还是防"某账号把积分烧光"(要按账号而非 IP 计数)。
  2. 固定窗口(INCR + EXPIRE)三行就够,致命缺陷是窗口交界最多 2 倍瞬时量。
  3. 滑动窗口用 ZSET:清过期(ZREMRANGEBYSCORE)→ 计数(ZCARD)→ 记一笔(ZADD)。必须 Lua 打包,否则三步之间有竞态、高并发下超发。
  4. 令牌桶用于"保护上游稳态 QPS + 允许小突发":hash 存 tokens/ts,按经过时间补充,桶容量为突发上限。
  5. 三套实现都要回答同一个问题:Redis 不可用时放行还是拒绝。

代码 ​

js
// ── ① 固定窗口:INCR + PEXPIRE(与项目 middleware/rateLimiter.ts 同形态,含"补 TTL 自愈")──
async function fixedWindowConsume(redis, key, limit, windowMs) {
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.pexpire(key, windowMs);
  } else {
    // 自愈:若首次 pexpire 漏设/因重启丢失,key 会永不过期、计数只增不减 → 永久 429
    const ttl = await redis.pttl(key);
    if (ttl < 0) await redis.pexpire(key, windowMs);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count),
           retryAfterMs: count <= limit ? 0 : await redis.pttl(key) };
}

// ── ② 滑动窗口:ZSET + Lua(原子)── KEYS[1]=key  ARGV=[nowMs, windowMs, limit, member]
const SLIDING_WINDOW_LUA = `
local key, now, window, limit, member = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)   -- 1) 清掉窗口外的记录
local count = redis.call('ZCARD', key)                      -- 2) 数窗口内的量
if count < limit then
  redis.call('ZADD', key, now, member)                      -- 3) 记一笔,score = 请求时刻 ms
  redis.call('PEXPIRE', key, window)                        --    TTL 给窗口长度即可
  return {1, limit - count - 1, 0}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') -- 4) 拒绝:算出最早那条何时过期
local retryAfter = window
if oldest[2] then retryAfter = (tonumber(oldest[2]) + window) - now end
if retryAfter < 0 then retryAfter = 0 end
return {0, 0, retryAfter}`;

async function slidingWindowAllow(redis, key, limit, windowMs) {
  const member = `${Date.now()}-${Math.random().toString(36).slice(2)}`; // 必须唯一,否则同毫秒互相覆盖
  const [allowed, remaining, retryAfterMs] =
    await redis.eval(SLIDING_WINDOW_LUA, 1, key, String(Date.now()), String(windowMs), String(limit), member);
  return { allowed: allowed === 1, remaining: Number(remaining), retryAfterMs: Number(retryAfterMs) };
}

// ── ③ 令牌桶:速率平滑 + 允许突发(Lua)── KEYS[1]=key  ARGV=[nowMs, ratePerSec, burst, need]
const TOKEN_BUCKET_LUA = `
local key, now, rate, burst, need = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens, ts = tonumber(data[1]), tonumber(data[2])
if tokens == nil then tokens, ts = burst, now end              -- 首次访问:满桶
tokens = math.min(burst, tokens + (now - ts) / 1000.0 * rate)  -- 按经过时间补令牌
local allowed = 0
if tokens >= need then tokens, allowed = tokens - need, 1 end
redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(now))
redis.call('PEXPIRE', key, math.ceil(burst / rate * 1000) + 1000) -- 最长寿命 = 空桶填满所需时间
return {allowed, math.floor(tokens)}`;

async function tokenBucketAllow(redis, key, ratePerSec, burst, need = 1) {
  const [allowed, tokens] = await redis.eval(TOKEN_BUCKET_LUA, 1, key,
    String(Date.now()), String(ratePerSec), String(burst), String(need));
  return { allowed: allowed === 1, tokens: Number(tokens) };
}

// 时间戳由【应用传入】Date.now(),不用 Redis 的 TIME:
// 好处是窗口与业务时钟/日志时间一致(排查不用做时钟换算),代价是多实例依赖 NTP。

复杂度与边界 ​

每请求命令数内存精度
固定窗口1~3(INCR + 首次 PEXPIRE,命中时多一次 PTTL)O(1)/key交界处最多 2 倍瞬时量
滑动窗口 ZSET1 次 EVAL(内含 3 条命令)O(窗口内请求数)精确
令牌桶1 次 EVAL(HMGET+HMSET)O(1)/key速率精确,允许 burst 突发
  • 无 TTL 的残留 key 必须自愈补 TTL,否则计数只增不减 = 永久 429(项目代码里专门写过这个坑)。
  • Redis 不可用:项目里一律放行,理由是这条路径上 Redis 挂了本来也入不了队(BullMQ 用同一个 Redis),拦不拦都不会真扣到钱,不值得把正常用户挡在门外。但这是业务决策——防爆破场景应该反过来选 fail-close。
  • 窗口交界:0:59 打满 5 次、1:01 再打满 5 次 = 1 秒内 10 次。是"瞬时 2 倍",不是"平均 2 倍"。
  • member 必须唯一,否则同毫秒的两次请求互相覆盖,实际放行数多于 limit。
  • Lua 里不要用 TIME,时间戳从应用传。

面试官追问 ​

  1. 固定窗口的 2 倍怎么量化? 窗口 60 秒上限 N,最坏是窗口末尾打 N 次、窗口一翻立刻再打 N 次 → 任意 60 秒滑动窗内最多 2N 次。平均速率并没超标,但下游承受的是 2N 的瞬时尖峰。防爆破能接受,保护上游 QPS 不能接受。
  2. 滑动窗口有更省内存的做法吗? 有,分桶:60 秒切成 60 个 1 秒桶,hash 存 {秒: 计数},查询时把当前桶和上一分钟的桶按权重加权求和。内存是 O(桶数) 常数而非 O(请求数),误差降到 1/60;代价是实现更绕、"加权"是个近似值。
  3. 限流的 key 怎么选?这是最重要的一问。 key = 限流主体 + 动作 + 窗口。主体选错,限流本身就是摆设——项目里真有这个案例:通用限流按 IP + 完整 path 计数,而脚本库生成的 path 带 uuid(/api/video/script/library/<uuid>/generate),换一个 uuid 就是另一个 key,等于每个脚本各有一份 60 次/分钟的额度;而那个接口单次最高能花 150 积分,额度形同虚设。所以后来加了一套按登录用户名的配额——要保护的是账号的积分,与 IP 无关(那个接口只信明文 X-Login-User 头,攻击者换 IP 是零成本的)。
  4. 被拒绝的请求要不要计数? 要。项目里两套限流都是"调用即计数(含最终被拒的那次)",所以狂点的人会把自己的小时额度一并烧掉。被拒的不计数,攻击者就能一直挂在边界上试探、永远不"付费"。
  5. 多个窗口同时超限,Retry-After 报哪个? 报最长的。分钟窗过去了、小时窗还卡着,只报 60 秒会让客户端到点重试再吃一个 429。项目里这是个纯函数 evaluateUserActionQuota,专门为了可单测。

扣分点 ​

  1. INCR 后忘 EXPIRE(key 永不过期 = 永久 429),或把 EXPIRE 写在 INCR 之前(每次请求都重置窗口 = 没限流)。正确位置是 count === 1 时设。
  2. 滑动窗口不用 Lua:三条独立命令之间会被其他请求插入,高并发下实际放行数明显超限。Redis 单命令原子 ≠ 多命令原子。
  3. 选型答错:给"登录爆破防护"上令牌桶(桶允许突发,正好给了爆破者空间),给"保护上游 QPS"用固定窗口(2 倍尖峰打上去)。

【衔接自己项目】 按这个顺序讲:

  1. middleware/rateLimiter.ts 是固定窗口,key 是 shatang:rate:${ip}:${path},path 是完整路径——上面那个"每个 uuid 一份额度"的漏洞就出自这里。
  2. 所以又加了一套 USER_ACTION_RULES,按登录用户名计数 + 分钟窗/小时窗双窗口。数值是算出来的,能直接背:脚本库一键生成单次最高 5 条 × 30 秒 × 1 积分/秒 = 150 积分,30 次/小时的额度 ⇒ 单账号每小时最多被烧掉约 4500 积分——这就是"这个账号一小时的损失上限"。报得出这个数字比说"我们限流了"强十倍。
  3. 主动指出一处注释与实现不符:文件头注释写的是 "using Redis INCR + EXPIRE (sliding window)",实现却是固定窗口。主动说明这一点,面试官会据此判断你读不读自己的代码。
  4. 再补一个"我在别处真用了 ZSET"的证据:腾讯 AIGC 的并发槽租约(slotLease.ts)就是 Sorted Set,member = '<owner>:<id>'、score = 占用时刻,过期回收用 ZREMRANGEBYSCORE 而不是 key TTL——因为要按"单个成员"过期(某个任务崩了只回收它占的那个槽),而不是整个集合一起过期。这正好是滑动窗口用 ZSET 的同一个理由。

Q4. 手写 Redis 分布式锁(SET NX EX + Lua 释放),讲清三个坑 ​

思路 ​

  1. 加锁只允许一条命令:SET key token EX ttl NX。绝不允许 SETNX 之后再 EXPIRE——两条命令之间进程挂掉就死锁。
  2. value 必须是每次唯一的持有者标识(crypto.randomUUID()),否则无法区分"我的锁"和"别人的锁"。
  3. 解锁必须用 Lua:if get(key) == token then del(key) end。写成"先 GET 判断再 DEL"是 TOCTOU:判断和删除之间锁可能已过期并被别人拿走,你就删了别人的锁。
  4. 三个坑要主动说:① 锁过期了业务还在跑;② 释放了别人的锁;③ Redis 主从异步复制丢锁。① 的正解是续期或让临界区幂等;③ 的正解是别把锁当正确性保证,正确性要落回数据库约束(或 fencing token)。
  5. 最关键的判断:先问自己这把锁是"性能优化"还是"正确性保证"。是前者,"拿不到锁"的正确反应就是降级继续而不是拒绝服务。

代码 ​

js
const crypto = require('crypto');

class RedisLock {
  constructor(redis, key, opts = {}) {
    this.redis = redis; this.key = key;
    this.ttlSeconds = opts.ttlSeconds ?? 10;      // 必须 > 临界区 P99 耗时
    this.retryMs = opts.retryMs ?? 1500;
    this.retryIntervalMs = opts.retryIntervalMs ?? 60;
    this.token = null; this.renewTimer = null;
  }

  /** 返回 token = 抢到;null = 重试窗口内没抢到 */
  async acquire() {
    const token = crypto.randomUUID();            // 每次唯一:解锁时靠它认领
    const deadline = Date.now() + this.retryMs;
    for (;;) {
      // 一条命令完成"不存在才设 + 带过期":不存在 SETNX 与 EXPIRE 之间的死锁窗口
      const ok = await this.redis.set(this.key, token, 'EX', this.ttlSeconds, 'NX');
      if (ok === 'OK') { this.token = token; this.startRenew(); return token; }
      if (Date.now() >= deadline) return null;    // 抢不到 → 由调用方决定阻断还是降级
      await new Promise((r) => setTimeout(r, this.retryIntervalMs));
    }
  }

  /** 续期:只有 token 还是自己的才续(判断+续期用 Lua 保证原子) */
  startRenew() {
    const interval = Math.max(500, (this.ttlSeconds * 1000) / 3);
    this.renewTimer = setInterval(async () => {
      const script = `if redis.call('get', KEYS[1]) == ARGV[1] then
                        return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end`;
      try {
        const r = await this.redis.eval(script, 1, this.key, this.token, String(this.ttlSeconds));
        if (r === 0) this.stopRenew();  // 锁已不是自己的:停止续期,让调用方感知丢锁
      } catch { /* 网络抖动:下一次 tick 再试 */ }
    }, interval);
    if (this.renewTimer.unref) this.renewTimer.unref(); // 否则进程被定时器挂住,pm2 restart 停不干净
  }

  stopRenew() { if (this.renewTimer) { clearInterval(this.renewTimer); this.renewTimer = null; } }

  /** 释放:Lua 比对 token,绝不删别人的锁 */
  async release() {
    this.stopRenew();
    if (!this.token) return false;                // 没抢到过 → no-op
    const script = `if redis.call('get', KEYS[1]) == ARGV[1] then
                      return redis.call('del', KEYS[1]) else return 0 end`;
    try { return (await this.redis.eval(script, 1, this.key, this.token)) === 1; }
    catch { return false; }
    finally { this.token = null; }
  }
}

/** 用法:临界区里必须再做一次数据库层的强保证(见 onLockUnavailable 的语义) */
async function withLock(redis, key, criticalSection, { onLockUnavailable } = {}) {
  const lock = new RedisLock(redis, key);
  const token = await lock.acquire().catch(() => null);  // Redis 异常按"没抢到"处理,不向上抛
  if (!token) {
    if (onLockUnavailable) return onLockUnavailable();   // 生产语义:降级继续
    throw new Error(`lock unavailable: ${key}`);
  }
  try { return await criticalSection(); } finally { await lock.release(); }
}

复杂度与边界 ​

  • O(1) 条 Redis 命令;争用时最坏 retryMs / retryIntervalMs 次尝试(项目里 1500/60 = 25 次)。
  • release() 必须在 finally,且对 token === null 是 no-op(不能因为没抢到就删掉别人的键)。
  • TTL 必须大于临界区 P99:项目给 10 秒,因为锁里只有一次毫秒级 PG 事务;若临界区是"调一次上游 HTTP",10 秒就危险了。
  • 续期失败必须视为丢锁并让调用方感知。
  • Redis 抛异常时 acquire 返回 null 而不是抛出——要的是可控降级,不是 500。
  • setInterval 记得 unref()。

面试官追问 ​

  1. 为什么 SETNX + EXPIRE 不行? 两条命令之间进程被杀或连接断,就留下一个永不过期的锁,全站该用户的资金操作从此永久失败。SET key val EX ttl NX 是一条命令、原子生效,这个窗口不存在。
  2. 锁过期了业务还在跑怎么办? 两条路:① 续期(watchdog 定时 EXPIRE);② 接受它会发生,并让重复执行无害。项目走后一条——锁里那句 SELECT ... FOR UPDATE 已经把同账号并发串行化了,即使锁过期、两个请求同时进临界区,行锁让它们排队,第二个人看到的余额已经扣过了。这是"用数据库约束兜住分布式锁失效"的标准姿势。
  3. Redlock 能解决主从切换丢锁吗? 不能真正解决。Kleppmann 的经典反驳是:Redlock 依赖各节点时钟准确,而时钟漂移 + GC 暂停完全可能让客户端在锁"已过期"的情况下继续执行;多数派只降低概率、没消除。业界更接受的结论是:要强正确性就用 fencing token(单调递增序号,让底层资源自己拒绝旧持有者)或直接用数据库。 我们就是直接用数据库——Redis 锁只减少无谓的行锁等待。
  4. 拿不到锁报错还是继续? 取决于锁的角色。性能优化 → 继续,让下层约束保证正确性;正确性保证(如"全站只有一个进程在对账")→ 必须阻断。项目里两种都有:creditLock 是前者(拿不到就打 warn 继续走事务),收尾抢占(Q6)是后者(抢不到必须 return)。能分清这两者并说清判据,是这题的满分点。
  5. 既然有行锁,为什么还要 Redis 锁? 行锁是悲观等待:并发请求会一直占着连接和事务,而 PG 连接池只有 20,同账号并发生成时很容易把连接池占满,把影响面从"一个用户慢"扩大到"全站慢"。Redis 锁在数据库之前挡掉大部分重复请求,事务持有时长从"等待时长"降到"执行时长"。本质是把压力从贵资源(PG 连接)前移到便宜资源(Redis 命令)。

扣分点 ​

  1. SETNX 之后单独 EXPIRE:分布式锁最经典的一处错误,能主动说出"为什么不用这条"就体现了字段熟练度。
  2. 释放时不校验 token(直接 DEL):重复收尾场景会删掉别人的锁,让第三个人也进来,锁彻底失效。
  3. TTL 拍脑袋:给长任务配 30 秒 TTL,锁早过期了业务还在跑 = 没锁;给短任务配 1 小时 TTL,进程一崩就锁死 1 小时。TTL 必须由临界区 P99 推出来。
  4. 把锁当唯一正确性保证:说"我们有 Redis 锁所以不会超卖"基本就结束了。正确表述:"Redis 锁降低争用,正确性由 FOR UPDATE 行锁和 credit_holds 的部分唯一索引保证,即使锁完全失效也不会超卖,只会变慢。"

【衔接自己项目】 打开 backend/services/lock/creditLock.ts 讲:

  • 形态就是上面这段:SET key token EX 10 NX,token 是 crypto.randomUUID(),释放用 redis.eval 比对 token。
  • 最值得讲的是那个取舍:拿不到锁时不阻断业务,只打一条 warn(acquireCreditLock failed ...; DB row-lock serialization will guard the transaction),然后继续开事务。因为临界区里真正保证正确性的是 SELECT credits, gift_credits, paid_credits FROM users WHERE id = $1 FOR UPDATE 这句行锁。所以 Redis 挂了、或锁因主从切换丢了,结果只是"慢一点",不会超卖——这就是"锁是优化不是保证"的落地实例。
  • 重试窗口 1.5 秒、每 60ms 一次,注释写明了依据:"锁持有者的事务毫秒级完成(内部 SELECT ... FOR UPDATE 已串行化),短重试即可把虚假的『操作过于频繁』消掉,正常路径首次 SET NX 成功,不引入额外延迟。"

Q5. 手写 LRU 缓存(Map 实现,O(1) get/put) ​

思路 ​

  1. 要 O(1) 的 get/put,需要"哈希表 + 有序结构"。JS 里 Map 一个结构就够:迭代顺序就是插入顺序,delete + set 都是 O(1),等价于双向链表里"摘下来挂到队尾"。
  2. 约定方向:队首 = 最久未使用(淘汰端),队尾 = 最近使用。方向先说清,代码里搞反是很常见的错。
  3. get 命中:delete 再 set——不能只 set,Map.set 对已存在的 key 不改变位置。
  4. put:已存在 → 先删;不存在且已满 → 删掉 map.keys().next().value(队首)。
  5. 为什么不用数组:findIndex 是 O(n)、splice 也是 O(n),get 一次就是 O(n)。

代码 ​

js
class LRUCache {
  constructor(capacity) {
    if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('capacity 必须是 >= 1 的整数');
    this.capacity = capacity;
    this.map = new Map(); // 迭代顺序 = 插入顺序:队首 = 最久未使用
  }

  get(key) {
    if (!this.map.has(key)) return undefined;
    const value = this.map.get(key);
    // 先删后插 = 把这个键移到队尾(最近使用)。少了这一步就退化成 FIFO。
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  has(key) { return this.map.has(key); } // 值可能是 undefined,必须另给一个"在不在"的判据

  put(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);                              // 覆盖已存在的键也要刷新位置
    } else if (this.map.size >= this.capacity) {
      this.map.delete(this.map.keys().next().value);      // 淘汰队首(最久未使用)
    }
    this.map.set(key, value);
    return this;
  }

  get size() { return this.map.size; }
  keys() { return [...this.map.keys()]; } // 仅用于自测/调试
}

// ── 自测(实测输出)── put a,b,c → keys a,b,c;get('a') → keys b,c,a(a 被移到队尾)
// put('d') → keys c,a,d(淘汰队首 b);put('c',33) → keys a,d,c(覆盖也刷位置);get('a')===1

复杂度与边界 ​

  • get/put 均摊 O(1)(Map 的 delete/set/keys().next()),空间 O(capacity)——与总数据量无关,这是缓存和"存全部再排序"的分水岭。
  • capacity <= 0 → 抛错(不要静默改成 1)。
  • value 恰好是 undefined 时 get 与"未命中"无法区分,必须提供 has()——这是本题最容易被追问的语义陷阱。
  • put 已存在的 key 必须刷新位置,否则退化成 FIFO。
  • 淘汰方向别搞反:淘汰的是队首(最久未使用),不是队尾。
  • 并发:JS 单线程下只要函数体内没有 await,这段逻辑天然原子;一旦有人在 get 里加 await(异步回源),临界区就被切开,需要另加保护。

面试官追问 ​

  1. LFU 和 LRU 的差异?各适合什么? LRU 淘汰"最久没访问的",LFU 淘汰"访问次数最少的"。差别在抗扫描:一次只读一遍的批量扫描会把 LRU 的热数据全冲出去(cache pollution),LFU 不会;但纯 LFU 有新数据饥饿(新内容计数为 1,永远排不过老热点)。工程上多是折中:LRU-K、TinyLFU(Caffeine)、LFU 带衰减。而我们历史页缓存的需求其实是第三种:写时失效——LRU/LFU 都解决不了。
  2. 怎么给 LRU 加 TTL? entry 存 {value, expireAt},get 时先判过期(过期当未命中并删)。注意惰性删除会残留内存,需要后台清理或淘汰时顺手清。生产上别手写,直接用 lru-cache。
  3. Redis 的 LRU 是精确的吗? 不是。Redis 用近似 LRU:allkeys-lru 是"采样 N 个 key(默认 5),淘汰其中最久未访问的"。原因是精确 LRU 要给每个 key 维护链表指针,内存开销太大。这个取舍本身就是好话题:精确性与资源开销的权衡,工程上通常选近似。
  4. Map 和 WeakMap 该用哪个? WeakMap 键是弱引用、不可枚举、无法知道有多少条目,因此做不到"按容量淘汰最久未使用"——它只适合"跟着对象生命周期自动清理"。缓存要主动淘汰,必须用 Map。
  5. 手写和用库的边界? 面试手写是为了证明你懂"哈希表 + 有序结构"这个组合;生产上手写 LRU 是负债(缓存最需要成熟实现:并发、统计、TTL、淘汰策略)。我会明说:"这题我手写,但真上生产我用 lru-cache。"

扣分点 ​

  1. 用数组却声称 O(1):findIndex 找位置 O(n)、splice 删除 O(n)。
  2. get 里忘提升顺序:唯一改动的就是那一行,漏掉就变成 FIFO,而且测试很容易过(读写都正常,只是命中率低),上线后才体现为"缓存好像没啥用"。
  3. put 覆盖已存在 key 时没先 delete:同样是 FIFO 化。
  4. 淘汰方向写反:keys().next().value 是队首 = 最久未使用,要与上面的约定一致。

【衔接自己项目】 这题要主动说"我们这里刻意没用 LRU",比硬套一个 LRU 更高级。 视频历史分页有一层 Redis 缓存(services/cache/historyCache.ts),策略是固定 TTL + 写时失效:列表缓存 300 秒、分页缓存只有 10 秒,用户一生成新视频就 invalidateHistory() 把整个作用域的键打掉。理由:分页缓存里"最近被访问的一页"根本不是"最可能被再访问的一页"(用户按时间翻页、越翻越旧),LRU 的前提在这条访问序列上不成立;而用户真正的诉求是"我新生成了一条,历史列表必须马上能看到"——那是失效策略要解决的,LRU 解决不了。 还有一句可以直接背:分页缓存那个 10 秒 TTL 的注释写的是*"无效化失败时的兜底,避免用户等 60s+ 才看到新记录"*——10 秒是"失效机制失灵时的最坏感知延迟",不是命中率调优的结果。这句话会让面试官知道你能区分"TTL 作为性能参数"和"TTL 作为正确性兜底"。


Q6. 手写"只允许一个任务活跃"的收尾抢占(SET NX EX + 成功后不释放) ​

思路 ​

  1. 先说清问题:同一任务的 finalize 会被多条路径同时触发——恢复逻辑对 processing 任务做"立刻重轮一次"(delay=0 时不做 jobId 去重)、队列里已有的延迟 job、多实例部署时的另一个实例。而收尾里有一个花钱的动作(画质增强=真实转码计费),并发收尾 = 双份钱。
  2. 抢占用 SET key val EX ttl NX,抢不到直接 return:不报错、不重试——"别人在做"是正确结果,不是异常。
  3. 成功后不主动释放(这题的核心):后续 job 会被函数开头的终态卫语句拦住,锁不再承担防重职责;而主动释放会打开一个危险窗口——释放发生在"付费动作做完但状态还没落库"之间时,后来者会抢到锁并再做一遍付费动作。
  4. 失败要释放:落库前出错就 DEL,把重试能力留给队列和巡检。这与成功路径有意不一致,必须主动说明。
  5. TTL 定法:远大于一次收尾的 P99(项目给 1800 秒,实测单次增强约 52 秒)。

代码 ​

js
/** 收尾抢占:保证同一任务的收尾(含付费动作)在整个系统里只发生一次 */
async function finalizeOnce({
  redis, taskId, keyPrefix = 'shatang:enhance-claim', ttlSeconds = 1800,
  isFinished, runFinalize, markTerminal,
}) {
  // ① 终态卫语句:已经收过尾的任务,后面所有 job 都在这里被拦住
  if (await isFinished(taskId)) return { skipped: true, reason: 'already-terminal' };

  // ② 抢占:一条命令拿到"我是唯一收尾者"这个身份
  const key = `${keyPrefix}:${taskId}`;
  const claimed = await redis
    .set(key, String(Date.now()), 'EX', ttlSeconds, 'NX')
    .catch(() => null);   // Redis 异常按"没抢到"处理:宁可漏收尾,不可重复花钱
  if (claimed !== 'OK') return { skipped: true, reason: 'claimed-by-peer' };

  // ③ 成功路径【不释放】:后续 job 会被 ① 拦住,锁不再承担防重职责;
  //    主动释放会打开"业务做完但状态没落库"的窗口,让后来者再做一遍付费动作。
  //    锁靠 TTL 自然过期,TTL 远大于单次收尾耗时。
  try {
    const result = await runFinalize(taskId);
    await markTerminal(taskId, result);
    return { ok: true, result };
  } catch (err) {
    // ④ 失败路径【要释放】:把重试能力留给队列(attempts)和巡检(taskRecovery)。
    //    与成功路径的不一致是有意的,注释必须写清,否则后人会"顺手统一"掉。
    await redis.del(key).catch(() => {});
    throw err;
  }
}

// ── 自测要点(实测行为)──
// 1) Promise.all 并发两次:一次 {ok:true},一次 {skipped:'claimed-by-peer'},runFinalize 调用次数必须是 1
// 2) 第三次调用:走 ① 终态卫语句 → {skipped:'already-terminal'}
// 3) runFinalize 首次抛错:抛出去,且锁已被删掉 → 下一次调用能重新抢到并成功

复杂度与边界 ​

  • 正常路径 1 次 SET;失败路径额外 1 次 DEL。零额外存储。
  • TTL 必须大于收尾 P99:1800 秒 vs 实测 ~52 秒,30 倍余量。
  • 如果收尾真跑超 TTL:后来者会抢到并重复一次。所以卫语句(①)是兜底,而它只在"落库成功"后有效——"落库"与"释放/超时"之间的顺序是这套逻辑的正确性核心。
  • Redis 异常的策略要分场合:项目里有两处相反写法——canvas 链路读 pp 状态时 Redis 异常按"无状态处理,走抢占"(宁可多抢一次也不漏收尾);长视频的 lease 判定里 Redis 异常必须 continue 跳过("不确定的时候什么都不做")。同一类异常在两条链路上策略相反,因为一边的代价是"漏收尾"、另一边是"误杀在跑的任务并退款",这个对比答出来很加分。
  • 脏数据:canvas 的 pp 键存在但状态字段不认识(旧协议/脏写)时要 DEL 重抢,不能原地空等到 TTL 过期。
  • 跨链路必须共用同一把钥匙(见追问 4)。

面试官追问 ​

  1. 为什么成功后不释放? 因为"锁"和"终态"承担的职责不同。终态卫语句防的是"已经收完尾了",只在落库成功后生效;而在"付费动作已提交、状态还没落库"那个瞬间,只有锁能防重。在这一瞬间释放锁,恰好过来的第二个 job 既看不到终态又抢到锁,就会再做一遍付费动作。
  2. TTL 怎么定? 单次收尾 P99 × 安全系数,且要能报依据:项目里单次增强实测约 52 秒(生产日志里真实测到的两条重复增强记录之一),TTL 给 1800 秒。给太小 → 长尾任务被重复收尾;给太大 → 一个任务卡住后它在 TTL 内无法被重试收尾,用户要等 30 分钟。两个方向的代价都要说。
  3. 如果收尾跑了 40 分钟、超过 TTL? 两层补救:① 收尾函数在写库前会再检查一次终态(不只入口检查),所以"慢到超时但先落库"的那个仍能拦住后来者;② 若两边都落库了,靠落库的幂等——UPDATE ... WHERE status NOT IN ('done','failed') 天然只有一个赢家(Q7)。最终正确性不靠锁、靠状态守卫的原子性,锁只是降低重复做付费动作的概率。
  4. 两条链路各自加锁会怎样? 这是生产真实踩过的坑:画布/首页对话类任务有第二条收尾链路——前端自己也会轮询触发一次画质增强,用 shatang:canvas:pp:<taskId> 去重。worker 侧原来用另一个键 shatang:enhance-claim,两条链路的锁互相不可见:用户开着页面时恢复逻辑又把任务塞进队列,同一任务被腾讯超分了两遍(生产实测同一任务增强两次、各约 52 秒的双份转码费)。修法是 canvas 类任务统一去抢前端那条链路的键,并按同一份 JSON 状态机协议(processing/done/failed)推进。教训一句话:"分布式锁只有在所有参与者用同一把钥匙时才有意义。系统里存在两条独立演化的链路时,最容易出现的就是『两边都做了去重、但去的是不同的重』。"
  5. 这和 Q4 的 creditLock 有什么区别? 三点:① 角色不同(一个是性能优化、抢不到就降级继续;一个是正确性保证、抢不到必须放弃);② TTL 量级不同(10 秒 vs 1800 秒,因为临界区一个是毫秒级 PG 事务、一个是分钟级付费转码);③ 释放策略不同(前者无论成败都在 finally 释放,后者只失败时释放)。能列出这三点,说明你不是记住了一个模板,而是理解了每个参数背后的依据。

扣分点 ​

  1. 成功路径写 finally { await redis.del(key) }:锁退化成"只防同时、防不住先后",而收尾的重复往往是先后的(恢复逻辑的立即重轮和残留 delayed job 之间隔了几十秒)。这是本题最大的扣分点。
  2. 用 GET 判断再 SET:TOCTOU,两个 job 可以同时判断"没有锁"然后同时写。必须一条 SET NX。
  3. 抢不到就抛异常:"别人正在做"是预期的正常分支。抛异常会让 BullMQ 把这次 job 记为失败、消耗重试次数,最后真变成一个告警。
  4. 忘了终态卫语句:只靠锁的话,一个任务的第 4、第 5 次收尾会一直抢失败刷日志;更严重的是锁过期后(30 分钟)它还会被抢到并重复花钱。

【衔接自己项目】 把上面第 4 个追问(双链路各用一把锁、生产实测双份转码费)讲出来——这是这册里最有说服力的素材,它同时证明了"我知道为什么这么做""我踩过它的反面""我能用一句话抽象出通用教训"。 再补一个细节增加可信度:canvas 链路抢到锁后会写一个 JSON 状态 {state:'processing', rawUrl, startedAt}(EX 1800),前端链路和 worker 链路读同一份状态机;读到 processing 就 scheduleNextPoll 稍后再来,读到 failed 就按它的结论终止任务并退款。所以这把锁不只占了位,还承载了"另一条链路进展到哪了"的语义——这是锁与状态机合并的一个实用变体。


Q7. 手写状态机的守卫式更新(只允许从非终态推进到终态) ​

思路 ​

  1. 先写错误示范:SELECT status 判断 → 业务逻辑 → 无条件 UPDATE。它在并发下必然出错,因为 SELECT 和 UPDATE 之间有窗口(TOCTOU)。
  2. 正确做法:把状态守卫写进 UPDATE ... WHERE,用影响行数(或 RETURNING)判断"我有没有赢得这次迁移"。
  3. 为什么安全:PostgreSQL 的 UPDATE 会锁行并对最新版本重新求值 WHERE,所以"并发时别人刚把它改成终态"这件事会被 WHERE 看见,后到者影响 0 行。
  4. 语义:返回 0 行 ≠ 错误,而是"别人已经推到终态了,我什么都不该做"。这个分支必须静默,不记 warning、不抛异常、更不能退款。

代码 ​

sql
-- 反例(先查后改,TOCTOU):并发下两个执行者都认为自己赢了
SELECT status FROM video_tasks WHERE id = $1;       -- 都读到 'processing'
-- ... 中间做了一堆判断 ...
UPDATE video_tasks SET status = 'failed', error_message = $2, completed_at = now()
 WHERE id = $1;                                     -- 无条件更新:后到的覆盖先到的

-- 正例(守卫式):把状态机写进 WHERE,靠影响行数认领
UPDATE video_tasks
   SET status = 'failed', error_message = $2, updated_at = now(), completed_at = now()
 WHERE id = $1
   AND status IN ('pending', 'queued', 'processing')  -- ★ 只有非终态才能推进到终态
RETURNING id;                                         -- 0 行 = 我没赢,静默放弃
js
/** 守卫式状态迁移:唯一入口,所有"把任务推到终态"的地方都走它 */
async function transitionToTerminal(db, taskId, status, errorMessage) {
  const { rows } = await db.query(
    `UPDATE video_tasks
        SET status = $2, error_message = $3,
            retry_count = CASE WHEN $2 = 'failed' THEN max_retries ELSE retry_count END,
            updated_at = now(), completed_at = now()
      WHERE id = $1 AND status IN ('pending','queued','processing')
     RETURNING id`,
    [taskId, status, errorMessage ?? null],
  );
  return rows.length === 1;        // ★ 唯一判据是影响行数,不是"查出来是什么状态"
}

// 调用侧:只有赢家才做后续副作用(写历史、退款),输家立刻返回
async function failTaskOnce(db, taskId, reason) {
  const won = await transitionToTerminal(db, taskId, 'failed', reason);
  if (!won) return { skipped: true, reason: 'already-terminal' };      // 静默,不记 warning
  await db.query(`UPDATE video_history SET status = '失败', updated_at = now()
                   WHERE task_id = $1 AND status = '生成中'`, [taskId]);
  await refundCredits(taskId);                                          // ★ 先写业务终态,最后才动钱
  return { ok: true };
}

PostgreSQL 实跑验证(两个会话跑的,结论就是这么来的):

会话 A: BEGIN; SELECT status ...                                  → 读到 'processing'
会话 B:      UPDATE ... WHERE status IN ('pending','queued','processing') → 更新 1 行,置 'done'
会话 A:      UPDATE ... WHERE id='t1'                             → 无条件更新,覆盖成 'failed'
  反例结果:最终 status='failed',B 的终态被 A 覆盖,两边副作用都执行了

会话 A(守卫式): UPDATE ... WHERE id='t1' AND status IN ('pending','queued','processing') RETURNING id
                                                                 → 0 rows(A 读到过 processing,但没赢得迁移)
  正例结果:最终 status='done',B 的终态被保住,A 什么都不做

复杂度与边界 ​

  • 一次索引点查 + 一次行更新,O(1);不需要额外版本号字段,也不需要重试循环。
  • 影响行数为 0 不一定是"别人赢了",也可能是"这个 id 不存在"。项目里两者都是"不做任何事",所以不区分;若要区分就得额外查一次。
  • status NOT IN ('done','failed') 与 status IN ('pending','queued','processing') 语义不同:前者会把未来新增的状态(cancelled、partial)自动放进来,后者不会。新代码建议用显式白名单(仓库里两种都有,可以主动交底)。
  • 副作用必须在赢得迁移之后:写 video_history、退款都要放在 won === true 分支里,否则"输家"也会去退款。
  • 顺序不变量:先写业务终态、最后才动钱。反过来的话,一旦退款成功而写库失败,冻结记录已 refunded、巡检不再扫它,用户就永久看不到成片也没人补记录。
  • 前提:状态迁移是单向的。若将来需要回退(失败后重跑),条件更新就不够了,得引入显式版本号做 CAS。

面试官追问 ​

  1. 为什么这比"先 SELECT 再 UPDATE"安全? 因为 SELECT 读到的是快照,不是"一票否决权"。两个执行者可以都读到 processing、都通过判断、都执行 UPDATE,最后一个覆盖前一个,两边副作用都跑了。把守卫放进 WHERE 之后,UPDATE 对最新版本的行重新求值条件,后到者看到的是已变成终态的行,影响 0 行——判断和执行变成同一条语句,中间没有窗口。
  2. 为什么不用版本号乐观锁? 因为这里的并发冲突不是"两个人都想改同一个字段"(那才需要乐观锁保证不丢更新),而是"多个执行者同时想终结同一个任务,只允许一个人赢"。条件更新恰好就是这个语义,而且不需要额外字段、不需要重试循环。用对的工具,不是更复杂的工具。
  3. RETURNING 和 rowCount 用哪个? 本质一样,RETURNING 更不容易被驱动/连接池的语义差异影响,还能顺带拿到更新后的行做日志。我们用 RETURNING id。
  4. UPDATE ... WHERE 会不会锁表? 不会,PostgreSQL 是行级锁。但要注意:并发更新同一行时后到的事务会阻塞等待前者提交,然后重新求值 WHERE。所以不会丢更新,但会引入等待——临界区长的话就该像 Q4 那样在前面加一层 Redis 锁把重复请求挡掉。
  5. 怎么保证这套模式不被绕过? 老实说,目前靠约定不是靠机制:状态迁移散在 taskRecovery、videoWorker、videoPollWorker 等多个文件里各自写 WHERE,正确但不好审查。要改就把所有迁移收敛到一张显式状态机表(当前态 × 事件 → 目标态 + 副作用),由一个函数统一执行——这一条放在"如果重新设计"里主动说。

扣分点 ​

  1. UPDATE 不带状态条件:这是"任务已完成后又被标失败、用户被退款但成片照常交付(白嫖)"的直接原因。
  2. 拿到 0 行却当异常:记 error 日志、抛异常、甚至触发重试。并发终结是预期路径,当异常处理会淹没真实问题、浪费重试次数。
  3. 副作用写在判断之外:if (status !== 'done') { ... } 之后紧跟无条件 refundCredits()——退款是唯一不能重复/不能误发的动作,必须在"赢得迁移"的分支里。
  4. 顺序颠倒:先退款再写终态。这不是风格问题,是事故换来的不变量。

【衔接自己项目】 这套写法在 taskRecovery.ts 里是全篇骨架,可以直接报三处原文:

  • 长视频孤儿判定:UPDATE video_tasks SET status='failed', ... WHERE id=$2 AND status IN ('pending','queued','processing') RETURNING id;
  • 单次复刻/批量任务超时:WHERE id = $1 AND status NOT IN ('done','failed');
  • Worker 里的历史纠正:UPDATE video_tasks SET status='done' WHERE id=$1 AND status NOT IN ('done','failed')——注意它叫"纠正"不叫"更新",语义是"权威历史表说这个任务已完成,我把任务表对齐过去",而守卫保证它不会把已失败的记录改成成功。 再主动加一句风险认知:"这个模式的前提是状态迁移单向(非终态→终态);将来若需要回退的迁移,条件更新就不够了,得换成显式版本号。"

Q8. 手写分页:LIMIT/OFFSET vs 游标(keyset)分页 ​

思路 ​

  1. 先问口径,这题一半的分在需求澄清:是"第 N 页"(后台管理/报表,需要总数、要能跳页)还是"下滑加载"(用户端历史,只要"下一页"、不要总数)?两者最优解不同。
  2. OFFSET 的代价:LIMIT 20 OFFSET 79000 不是"跳过 79000 行",而是老老实实扫过并丢弃 79020 行。它随页码线性变慢,且并发插入/删除时会出现"重复或漏记录"。
  3. keyset:把上一页最后一行当游标写进 WHERE。必须用行比较器 (created_at, id) < (?, ?),不能用 created_at <= ? AND id < ?(语义错,见追问 1)。
  4. 排序键必须唯一且稳定:游标是 (created_at, id) 复合的,created_at 主序、id 做 tiebreaker。
  5. 索引必须与排序键完全一致,(user_id, created_at DESC, id DESC) 才能让"按用户过滤 + 倒序翻页"走纯索引扫描。

代码 ​

sql
-- ① OFFSET 分页(用户端历史现状)
SELECT vh.id, vh.title, vh.status, vh.created_at
  FROM video_history vh
 WHERE vh.user_id = $1
 ORDER BY vh.created_at DESC, vh.id DESC
 LIMIT $2 OFFSET $3;

-- ② keyset(游标)分页:游标来自上一页返回的最后一行
SELECT vh.id, vh.title, vh.status, vh.created_at
  FROM video_history vh
 WHERE vh.user_id = $1
   AND (vh.created_at, vh.id) < ($2::timestamptz, $3::bigint)  -- ★ 行比较器 = 字典序
 ORDER BY vh.created_at DESC, vh.id DESC
 LIMIT $4;

-- 索引(复合顺序必须与 ORDER BY 一致)
CREATE INDEX IF NOT EXISTS idx_vh_user_created_id ON video_history (user_id, created_at DESC, id DESC);
js
/** 游标编解码:base64(JSON),对客户端不透明,服务端必须校验 */
function encodeCursor(row) {
  return Buffer.from(JSON.stringify({ t: row.created_at.toISOString(), i: String(row.id) })).toString('base64url');
}
function decodeCursor(cursor) {
  if (!cursor) return null;                       // 第一页:不带游标
  try {
    const { t, i } = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
    const ts = new Date(t);
    if (Number.isNaN(ts.getTime()) || !/^\d+$/.test(String(i))) throw new Error('bad cursor');
    return { ts, id: String(i) };
  } catch { return null; }                        // 脏游标一律当"第一页",不要把 500 抛给用户
}

/** 分页查询:返回 rows 与下一页游标(不足一页则 nextCursor = null) */
async function listHistory(db, { userId, cursor, limit = 20 }) {
  const cur = decodeCursor(cursor);
  const { rows } = cur
    ? await db.query(`SELECT id, title, status, created_at FROM video_history
                       WHERE user_id = $1 AND (created_at, id) < ($2, $3)
                       ORDER BY created_at DESC, id DESC LIMIT $4`, [userId, cur.ts, cur.id, limit])
    : await db.query(`SELECT id, title, status, created_at FROM video_history
                       WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, [userId, limit]);
  return { rows, nextCursor: rows.length === limit ? encodeCursor(rows[rows.length - 1]) : null };
}

8 万行实测(本机 PostgreSQL,EXPLAIN (ANALYZE) 真实输出):

A) OFFSET 79000
   Limit (actual rows=20) → Index Scan ... (actual rows=79020)          ← 扫了 79020 行,4.051 ms
B) keyset(游标已知,直接来自上一页)
   Limit (actual rows=20) → Index Scan ... (actual rows=20)             ← 扫了 20 行,0.020 ms
     Index Cond: (ROW(created_at, id) < ROW('2026-09-18 07:48:56.6789+00','79001'))
C) 按 user 过滤 + OFFSET(user_id=7 有 200 行)
   Sort (quicksort Memory: 39kB) → Bitmap Heap Scan (actual rows=200)   ← 多了一次排序

数字会随机器和缓存状态变,但行数的比例不会变:OFFSET 是 O(offset+limit),keyset 是 O(limit)。这就是"讲清原理"与"只背结论"的分界。

复杂度与边界 ​

  • OFFSET = O(offset + limit);keyset = O(limit)。keyset 与页码无关——翻到第 4000 页还是扫 20 行。
  • tiebreaker 不能省:只用 created_at 当游标,同一毫秒的多条记录会被跳过或重复。
  • 游标是不可信输入:解码失败要降级成第一页;进 SQL 必须参数化、绝不能拼字符串。
  • keyset 无法跳页:要"直接跳到第 50 页"只能用 OFFSET。所以真实系统常两者共存:用户端滚动用 keyset,后台搜索用 OFFSET(并加"最多翻到第 N 页"的硬限制)。
  • 不能为了总数算 COUNT(*):用户端不需要总数,为它做一次全索引扫描不划算。
  • 软删除/状态过滤:若 WHERE 里还有 status <> '失败' 这类过滤,索引要一起设计(部分索引),否则退化成"扫很多再过滤"。

面试官追问 ​

  1. (created_at, id) < (?, ?) 和 created_at <= ? AND id < ? 有什么区别? 前者是行比较器,等价于"created_at < t 或(created_at = t 且 id < i)",即字典序;后者是 AND,会把所有 created_at < t 但 id >= i 的行全部排除——只要 id 不与时间同序就会漏记录。这是 keyset 分页最高频的实现错误。
  2. 用户端历史要不要返回总数? 不要。总数要一次全量扫描,而这个数字对用户没有价值(用户只关心"还能不能往下滑")。nextCursor === null 就是到底了。项目现在带 COUNT(*),属于可以优化掉的开销。
  3. 深分页还有别的解法吗? 有,延迟关联(deferred join):先在覆盖索引上 OFFSET 拿主键,再回表。它降低的是回表代价(只回 20 行),不降低扫描行数,所以是工程折中而非根治。根治只有游标。
  4. 后台导出几万行怎么办? 不要"翻页导出"(客户端循环翻页会被 OFFSET 越翻越慢拖死)。要么服务端用游标流式分批拉、边拉边写文件;要么直接扔进异步任务(我们本来就有队列),导出完给下载链接。
  5. 只建 ORDER BY created_at DESC 的索引够吗? 不够。复合顺序要与 ORDER BY 完整一致(created_at DESC, id DESC),等值过滤列(user_id)放最左。只建单列时规划器可能选"位图扫描后排序"(就是上面 C 那段),行数一大就退化成外部排序。

扣分点 ​

  1. 用 OFFSET 做无限滚动:这就是"翻到后面越来越慢"的根因。滑到第 4000 页时后端要扫 8 万行才返回 20 条。
  2. 复合游标漏了 tiebreaker:时间戳有并列(批量生成的任务时间戳高度接近)时会跳过同毫秒内的后续记录——表现为用户翻页"少了几条视频",而且很难复现。
  3. 游标用自增 id、排序用 created_at:两者顺序不一致时游标会乱跳。游标必须精确对应排序键。
  4. 不校验客户端传来的游标:至少要做到"解不出来就降级成第一页"。

【衔接自己项目】 video_history 现在是 LIMIT/OFFSET(videoHistoryStore.ts 与 adminHistoryQuery.ts 都是 ORDER BY vh.created_at DESC LIMIT $n OFFSET $m),外面套了一层 Redis 页缓存吃掉第一页的压力。现在表大约 8 万行,OFFSET 还撑得住,但我能说清它会在什么时候坏、坏在哪:

  • 用户端历史若改成无限滚动,第一件事就是加 (created_at, id) 游标(配索引 (user_id, created_at DESC, id DESC)),并去掉 COUNT(*);
  • 后台搜索仍需要总数和跳页,可以保留 OFFSET,但加"最多翻到第 N 页"的硬限制,超出引导用户加筛选(这也是 ES 用 from + size 卡 10000 的同一个取舍);
  • 导出类需求走队列 + 流式拉,不靠前端翻页。 这样回答的额外好处:它把 Q8(分页实现)和 Q16(100 倍扩容)在面试官脑子里连起来了,你会显得在讲一个系统而不是一个算法题。

B. SQL 题 ​

全部基于项目真实表结构。先默写建表片段再写查询——面试官通过你写不写得出约束,判断你是"会用数据库"还是"会设计数据库"。 每道 SQL 都在 PostgreSQL 上实跑验证过;示例数据是刻意设计的,为了让"错的口径"必然误报(这正是 Q10 的核心)。

公共建表片段(Q9~Q13 共用,先写在白板左上角) ​

sql
CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username VARCHAR(64) NOT NULL UNIQUE,
    credits NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (credits >= 0),  -- 总余额
    gift_credits NUMERIC(10,2) NOT NULL DEFAULT 0,                  -- 赠送池
    paid_credits NUMERIC(10,2) NOT NULL DEFAULT 0,                  -- 充值池
    initial_credits INT NOT NULL DEFAULT 0,        -- ★ 注册赠送,不写流水
    is_unlimited BOOLEAN NOT NULL DEFAULT FALSE);

CREATE TABLE user_credit_transactions (            -- 流水:每次余额变动必须成对写一行
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC(10,2) NOT NULL,
    balance_before NUMERIC(10,2) NOT NULL, balance_after NUMERIC(10,2) NOT NULL,
    reason VARCHAR(128) NOT NULL,
    ref_task_id VARCHAR(64),                       -- 关联任务号,可能为空(先扣钱后建任务)
    direction VARCHAR(8) NOT NULL DEFAULT 'debit'
              CHECK (direction IN ('debit','credit','refund')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now());

CREATE TABLE credit_holds (                        -- 冻结账本:先冻结,后结算/退款
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    task_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC(10,2) NOT NULL CHECK (amount > 0),
    status VARCHAR(16) NOT NULL DEFAULT 'held'
           CHECK (status IN ('held','settled','refunded')),   -- held = 在途
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    settled_at TIMESTAMPTZ, refunded_at TIMESTAMPTZ);

CREATE TABLE video_tasks (id VARCHAR(36) PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id),
    status VARCHAR(16) NOT NULL DEFAULT 'pending',  -- pending/queued/processing/done/failed/cancelled
    external_task_id VARCHAR(128), created_at TIMESTAMPTZ NOT NULL DEFAULT now());

CREATE TABLE video_history (id VARCHAR(36) PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id),
    task_id VARCHAR(36) NOT NULL REFERENCES video_tasks(id), title VARCHAR(256) NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT '生成中',    -- 生成中 / 已完成 / 失败
    credits_used NUMERIC(10,2), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_video_history_task_id UNIQUE (task_id));

Q9. 找出"冻结超过 30 分钟仍未结算/退款"的悬空记录 ​

sql
SELECT ch.id, ch.task_id, u.username, ch.amount::float8 AS amount, ch.created_at,
       ROUND(EXTRACT(EPOCH FROM (now() - ch.created_at)) / 60)::int AS age_minutes
  FROM credit_holds ch
  JOIN users u ON u.id = ch.user_id
 WHERE ch.status = 'held'                        -- ★ 判据是"在途",时间只是第二维
   AND ch.created_at < now() - interval '30 minutes'
 ORDER BY ch.created_at ASC                      -- 最老的排最前:按资损敞口排序
 LIMIT 200;                                      -- 巡检必须有上限,防异常时日志爆量

讲解

  • "悬空"= 既没结算也没退款,不是"存在了很久"。它属于资损排查(用户的钱被冻着、任务可能已经死了),不是慢查询排查。
  • 30 分钟是"用户可感知的最坏悬挂时间"。项目里两个阈值的链条要一起讲:自动修复(creditRecovery)是 10 分钟,告警巡检(creditAudit)是 30 分钟,必须是"先自动修复、后人工告警"——告警阈值低于自愈阈值,就会在每次自愈成功前先告警,慢慢没人看了。通用原则:告警阈值必须晚于自愈阈值。
  • 用 EXTRACT(EPOCH FROM …) 而非 AGE():前者给秒数便于计算排序,后者给 interval 可读性好但不便于二次加工。两个都写一遍并说取舍是加分项。

面试官追问

  1. 能走索引吗? 能,前提是部分索引 ON credit_holds (status) WHERE status='held'(项目里就有)。它体积只与在途冻结数成正比,而 held 是少数派(绝大多数很快变 settled/refunded),所以索引很小、常驻内存。
  2. 固定 30 分钟会不会误报长任务? 会——长视频、批量复刻本来就跑几十分钟。这也是为什么自动修复逻辑必须按任务类型分流(视频编辑、CR 批次各有自己的对账函数)。更彻底的做法是引入 expected_duration 按服务类型分档。
  3. 和 creditRecovery 什么关系? "修"与"报"的关系:recoverOrphanedCreditHolds() 负责修(10 分钟起扫,按 video_tasks/video_history 真实状态决定结算还是退款),runCreditAudit() 负责报(纯只读、小时级跑一次)。让巡检也去改数据,两个自动化动作就会互相干扰、无法判断是谁改的。

扣分点:① 漏了 status='held',把已结算/已退款的记录全捞出来,99% 是噪音;② now() - created_at > 30(interval 与数字不能直接比);③ 不 JOIN users——巡检输出的第一要求是人能直接看懂、能直接行动,只有 user_id 就得去另一个系统翻译。

【衔接自己项目】 这条几乎就是 backend/services/billing/creditAudit.ts 里 findStuckHolds() 的原文,连"算年龄给人看"的 age_minutes 写法都一样。它是那份巡检抓的五类异常之一,另外四类是余额漂移、双余额拆分漂移、多退(白嫖)、负余额——一口气报出这五类,比答对一道 SQL 更有价值,因为它证明你对"钱这条线会怎么坏"有成体系的认知。跑完会打一行 [creditAudit] ⚠️ 发现积分异常 N 项:余额漂移=… 拆分漂移=… 多退=… 悬空冻结=… 负余额=…,后台还能用 GET /api/video/admin/credit-audit 手动触发。


Q10. 找出"退款总额大于扣款总额"(多退/白嫖)——按金额而不是按笔数 ​

sql
WITH refunds AS (                                   -- 退款:按 task 汇总【金额】,不是数笔数
  SELECT ref_task_id AS task_id, user_id, COUNT(*) AS refund_count, SUM(amount) AS total_refunded
    FROM user_credit_transactions
   WHERE direction = 'refund' AND ref_task_id IS NOT NULL
   GROUP BY ref_task_id, user_id),
holds AS (                                          -- 扣款源之一:冻结账本(先扣钱后建任务的链路)
  SELECT task_id, user_id, SUM(amount) AS held_total FROM credit_holds GROUP BY task_id, user_id),
debits AS (                                         -- 扣款源之二:带 ref_task_id 的扣款流水
  SELECT ref_task_id AS task_id, user_id, -SUM(amount) AS debited_total
    FROM user_credit_transactions
   WHERE direction = 'debit' AND ref_task_id IS NOT NULL GROUP BY ref_task_id, user_id)
SELECT r.task_id, u.username, r.refund_count, r.total_refunded::float8 AS total_refunded,
       GREATEST(COALESCE(h.held_total,0), COALESCE(d.debited_total,0))::float8 AS total_charged,
       (r.total_refunded - GREATEST(COALESCE(h.held_total,0), COALESCE(d.debited_total,0)))::float8
         AS over_refunded
  FROM refunds r JOIN users u ON u.id = r.user_id
  LEFT JOIN holds  h ON h.task_id = r.task_id AND h.user_id = r.user_id
  LEFT JOIN debits d ON d.task_id = r.task_id AND d.user_id = r.user_id
 WHERE r.total_refunded > GREATEST(COALESCE(h.held_total,0), COALESCE(d.debited_total,0)) + 0.01
 ORDER BY over_refunded DESC LIMIT 200;

讲解

  • 为什么必须按金额、不能按笔数(本题核心,生产数据换来的教训):旧口径是"同一 ref_task_id 出现 >1 条退款流水就告警"。它错在把笔数当成了金额的代理指标,而两者在我们业务里根本不相关——视频编辑失败后会「退款 + 用同一个 taskId 重扣」再跑一次(videoEdit.ts 注释写得很直白:"重新提交一个失败任务前先退掉旧冻结,否则 credit_holds 上 status='held' 的部分唯一索引会让新的 holdCredits 撞车")。所以一个 taskId 上 N 笔扣款 + N 笔退款是正常的,收支完全平,却被旧口径报成"多退"。生产上那条告警长期挂着 11 条,逐笔核过收支全是平的——真正的多退反而混在里面看不出来。这是"指标选错导致告警失聪"的典型案例。
  • 为什么扣款取两边的大者:历史演进留下两条扣款链路,各缺一半——/generate 先扣钱后建任务,扣款流水的 ref_task_id 是空的,只有 credit_holds 记着;image2 / 模特库那批走 restoreUserCredits(refTaskId),从不写 credit_holds。只看一边必然误报,取 GREATEST 对每条链路都取到自己那份真值。
  • 判定放 WHERE 而不是 HAVING:这条算术有业务含义,要能被单测钉死(项目里有 creditAuditOverRefund.test.ts)。SQL 只出聚合(一个有退款的 task 一行),判定留在 JS 里,改规则不动 SQL。

面试官追问

  1. 容差为什么是 0.01 而不是 0? 积分是 NUMERIC(10,2),历史上出现过浮点漂移(迁移注释写过"积分保留 2 位小数,避免浮点漂移后落库")。+0.01 等于"多退超过 1 分钱才算异常"。容差是对账系统的可信度核心参数:太严格会告警失聪,太松会吞掉真问题。
  2. GREATEST 会不会盖住真实的"少扣"? 会——若两条链路都记了扣款且金额不同,取 max 看起来就"平了"。所以严格做法是先确认"同一个 task 不会同时出现在两张扣款表里";我们这个业务里确实不会(一条链路只走一种扣费方式),所以取 max 安全。能主动指出这个前提,比记住 GREATEST 更有价值。
  3. 一条退款流水没有 ref_task_id 呢? 被 IS NOT NULL 过滤掉了——有意的保守选择:没有任务号的退款无法归集,硬归集会造假的多退;代价是"按用户整体对账"这条线漏了,所以 Q11 的余额漂移才是那条线的守卫。两个查询互补,各守一边。

扣分点:① 按笔数判定(HAVING COUNT(*) > 1)——旧口径原文。示例数据里 ve_1 有 2 扣 2 退、金额完全相等,按笔数误报、按金额不报,面试时可以直接把这个例子写在白板上;② 只从 credit_holds 取扣款额——image2 那批全被误报成白嫖;③ 忘了 ref_task_id IS NOT NULL——所有"先扣钱后建任务"的扣款流水归成一个 NULL 组,算出莫名其妙的大额;④ SUM(amount) 符号搞错——refund 是正数、debit 是负数(迁移专门修过一次"该记 debit 却记了正数"的数据),所以要 -SUM(amount),符号错一次结论整个反过来。

【衔接自己项目】 这段就是 creditAudit.ts 里 findOverRefunds() 的逻辑,函数上面那段注释几乎在替面试官提问:"别改回「同一 ref_task_id 出现 >1 条退款流水」那个旧口径 —— 它是错的……生产上那条告警长期挂着 11 条,逐笔核过收支全是平的,真正的多退反而混在里面看不出来。" 把这段直接复述出来,它同时展示了"我踩过这个坑、我知道为什么错、我用生产数据验证了修正"。


Q11. 找出"余额与流水累计不一致"的用户(漂移检测) ​

sql
-- ① 余额漂移:credits ≠ initial_credits + SUM(流水)
SELECT u.username, u.credits::float8 AS credits,
       (u.initial_credits::numeric + COALESCE(t.ledger_sum,0))::float8 AS expected,
       (u.credits - (u.initial_credits::numeric + COALESCE(t.ledger_sum,0)))::float8 AS drift
  FROM users u
  LEFT JOIN (SELECT user_id, SUM(amount) AS ledger_sum
               FROM user_credit_transactions GROUP BY user_id) t ON t.user_id = u.id
 WHERE u.is_unlimited = FALSE        -- 无限号不参与(它从没被扣过钱,credits 是装饰值)
   AND ABS(u.credits - (u.initial_credits::numeric + COALESCE(t.ledger_sum,0))) > 0.01
 ORDER BY ABS(u.credits - (u.initial_credits::numeric + COALESCE(t.ledger_sum,0))) DESC
 LIMIT 200;

-- ② 双余额拆分漂移:gift_credits + paid_credits ≠ credits
SELECT u.username, (u.credits - u.gift_credits - u.paid_credits)::float8 AS split_drift
  FROM users u WHERE u.is_unlimited = FALSE
   AND ABS(u.credits - u.gift_credits - u.paid_credits) > 0.01 ORDER BY split_drift DESC LIMIT 200;

-- ③ 负余额(理论不该出现,出现即扣减越界)
SELECT username, credits::float8 FROM users WHERE is_unlimited = FALSE AND credits < 0 LIMIT 200;

讲解

  • 漂移检测是"隐藏 bug 的探测器":正常路径每次都成对写流水,所以任何漂移都说明存在一条"改了余额却没记账"的路径。它不告诉你路径在哪,但把"用户投诉才发现"变成"主动发现"。
  • 必须 LEFT JOIN + COALESCE:从来没有流水的用户(新注册、只拿过初始积分)在子查询里没有行,用 JOIN 会把他们整行丢掉——而他们恰好是最可能漂移的一批(迁移/赠送逻辑改过)。
  • 必须排除 is_unlimited:无限号从没被扣过钱(consumeUserCredits/holdCredits 对它直接返回 consumed=0)。项目里每一类异常查询都带这个过滤——不参与资金流的账号混进对账会让信噪比崩掉。
  • 前提必须说出口:这条不变式成立的前提是"初始积分走 initial_credits 列、不写流水"。前提说错,整个查询全错。

面试官追问

  1. 为什么"多退"(Q10)抓不到漂移? 因为多退时余额和流水是同步增加的(退款既加 credits 又写流水),两边一致。所以两类异常必须分成两个查询:一个抓"金额对不上",一个抓"账实不符"。不同的漏钱形态需要不同的探测器。
  2. ① 和 ② 什么关系? ① 抓"余额 vs 流水",② 抓"总余额 vs 两个池子"。② 更细一层:credits 对了不代表池子对,可能"扣了赠送池却记成扣充值池"。项目注释写明了 ② 的定位:"上线第一次发布到 CHECK 约束生效之间的观察期里,这是唯一的守卫 —— 持续新增就说明还有写 credits 却没写池子的路径没改到。"
  3. 成本会随时间爆炸吗? 主体是一次全表 GROUP BY user_id,正确性没问题,但流水表会一直长,全量聚合越来越慢。工程解法:① 增量对账(只看最近 N 小时流水);② 维护 user_credit_snapshot 日切快照表,只算"快照 + 快照之后的流水"。能主动说出"成本会随时间爆炸"是很好的工程意识。
  4. 跑出异常怎么办? 只打日志告警,不自动改数据。自动改会掩盖根因(把"某条路径没记账"变成"余额被悄悄修正了"),而且万一修正逻辑自己有 bug,就会把对的数据改成错的。只读巡检 + 人工/脚本修正是资金系统的保守选择。

扣分点:① 用 JOIN 不用 LEFT JOIN——没有流水的用户被整行丢掉,恰好漏掉最该看的那批;② 忘了排除 is_unlimited——无限号被长期误报,告警失聪(面试官一看就知道你没在真实系统里跑过对账);③ 忘了说"初始积分不写流水"这个前提;④ 把巡检写成会改数据的语句——巡检必须只读。

【衔接自己项目】 项目里有一套五类目的只读对账巡检 runCreditAudit():balanceDrift、splitDrift、duplicateRefund、stuckHolds、negativeBalance,跑完打一行汇总日志并返回结构化结果。可以直接背这句:"这五类各抓不同的漏钱形态,互补——一个查询抓不全,这就是为什么要有巡检这个模块而不是一条 SQL。"


Q12. "同一任务最多一条 held 记录"的部分唯一索引 ​

sql
-- 部分唯一索引:只对满足谓词的行做唯一性约束
CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_holds_task_held
    ON credit_holds (task_id) WHERE status = 'held';

-- 生产大表上要避免长锁:用 CONCURRENTLY(注意它不能在事务块里执行)
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_credit_holds_task_held
    ON credit_holds (task_id) WHERE status = 'held';

-- 配套的普通部分索引:按状态扫悬空冻结时用得上
CREATE INDEX IF NOT EXISTS idx_credit_holds_status ON credit_holds (status) WHERE status = 'held';

行为验证(两个方向都要能说)

sql
-- 方向一:同 task 再来一条 held → 被拒绝(幂等)
INSERT INTO credit_holds (task_id, user_id, amount, status) VALUES ('fresh_1', 4, 10.00, 'held');
-- ERROR: duplicate key value violates unique constraint "idx_credit_holds_task_held"
-- DETAIL: Key (task_id)=(fresh_1) already exists.

-- 方向二:老 hold 已退款后,同 task 可以再 held(重试链路必须被允许)
UPDATE credit_holds SET status='refunded', refunded_at=now() WHERE task_id='fresh_1' AND status='held';
INSERT INTO credit_holds (task_id, user_id, amount, status) VALUES ('fresh_1', 4, 12.00, 'held');
-- 结果:同一 task_id 下有两条记录,一条 refunded、一条 held —— 这正是我们要的
普通唯一索引 UNIQUE (task_id)部分唯一索引 UNIQUE (task_id) WHERE status='held'
约束范围表里所有行只有满足谓词的行
同一 task 的 settled + refunded不允许共存允许(谓词外,完全不参与约束)
同一 task 两条 held不允许不允许
索引体积与表行数成正比只与 held 行数成正比(少数派)
表达的语义"这个 task 在账本里只能出现一次""这个 task 最多只有一份在途冻结"

讲解

  • 业务含义比技术含义重要:普通唯一索引会禁止重试。视频编辑失败后要"先退旧的、再用同一 taskId 冻结新的",若约束是"一个 task_id 只能有一行",退款和重冻就无法共存,功能直接做不了。部分唯一索引允许"历史记录累加、在途冻结唯一",正好匹配业务。
  • 它同时表达幂等与状态机不变式:幂等——重复调用 holdCredits 撞唯一冲突,第二次不会真扣钱,这是"用数据库约束做幂等",并发下天然正确(不需要先查后插的 TOCTOU),而且是最后一道防线;状态机不变式——status 从 held 只单向走到 settled/refunded,而"最多一条 held"把单向性物化成了索引谓词:状态推进让行离开谓词范围,从而释放出"允许新 held"的资格。索引谓词就是状态机"活跃态"的定义。
  • 两者是同一句话的两种读法:"一个任务在任意时刻最多只有一次未结算的资金攥在手里。"
  • 还有性能红利:部分索引体积只与在途冻结数成正比,而需要它的查询恰好也按 status='held' 过滤(Q9 的悬空冻结、creditRecovery 的扫描),一物两用。
  • 代价要主动说:① 大表建索引会锁写 → 用 CONCURRENTLY(更慢、失败会留 INVALID 索引、不能在事务里跑,而很多迁移框架默认包事务);② 上线前必须清理历史脏数据,否则索引创建直接失败;③ 唯一冲突成了"正常业务分支",调用方必须 catch 唯一冲突(PostgreSQL 错误码 23505)并当作"已经扣过了",而不是当成 500——这是容易被忽略的代码契约。

面试官追问

  1. 为什么不用"应用层先查再插"? 那是 TOCTOU(同 Q7):两个并发请求都查到"没有 held 记录",然后都插入,于是同一任务冻结两笔、扣两次余额。唯一索引是唯一能在并发下保证这件事的机制。
  2. CREATE UNIQUE INDEX 和 ADD CONSTRAINT UNIQUE 有区别吗? 有:约束不支持 WHERE 谓词,所以部分唯一索引只能用 CREATE UNIQUE INDEX 建。答对说明你真建过。
  3. 为什么用 INSERT 而不是 ON CONFLICT DO NOTHING? DO NOTHING 会把冲突静默吞掉,调用方无法区分"我插入了"和"已经存在了",而这两者后续动作完全不同。资金路径上我倾向于让冲突抛出、由调用方显式 catch,因为"静默成功"比"报错"危险(BullMQ 的 jobId 去重就是静默丢弃,我们为此踩过坑)。
  4. 这个约束的局限? 只防"同一 task 的两笔在途冻结",防不了"一个用户多笔冻结总额超过余额"——后者靠 users.credits >= 0 的 CHECK + 事务内 FOR UPDATE 扣减。分清哪个约束守哪条不变式是设计资金系统的核心能力:唯一索引守幂等、CHECK 守非负、行锁守原子性、对账巡检守一切漏网的。

扣分点:① 写成普通唯一索引 UNIQUE (task_id)——直接禁止重试,而且上线时正常路径全通,只有用户点"重试"才 500,非常隐蔽;② 忘了 WHERE status='held' 却说自己在做部分唯一索引;③ 说"有约束就不用应用层校验了"——约束是最后防线不是第一防线,应用层仍要先做"余额够不够""是不是重复提交"才能返回明确的 402,两道都要有。

【衔接自己项目】 这条索引就在 backend/migrations/009_credit_holds.up.sql,注释一句话概括目的:"同一任务最多持有一个 held 态冻结,防止重复扣减"。再讲两个与它直接耦合的真实细节:

  • 约束逼着业务流程写对了:videoEdit.ts 重新提交失败任务前会先 refundCredits(current.id),注释写明的理由就是*"否则 credit_holds 上 status='held' 的部分唯一索引会让新的 holdCredits 撞车"*——这是"约束即文档"的体现。
  • 它还是对账查询的锚点:adminHistoryQuery.ts 显示"实际扣了多少"时会回落到 SELECT ch.amount FROM credit_holds ch WHERE ch.task_id=vh.task_id AND ch.status='settled' ORDER BY ch.settled_at DESC NULLS LAST LIMIT 1,并专门注释*"必须写成标量子查询:同一 task_id 在 credit_holds 里可能留下多行(重绑/重试后的 settled + refunded),JOIN 会让历史列表凭空多出重复行"*——"同一 task 多行"正是因为有了部分唯一索引才成立。两处细节放在一起讲,能证明你不是背了个索引语法,而是理解它在系统里的位置。

Q13. 用窗口函数找出每个用户最近一次消费记录 ​

sql
-- ① 每个用户最近一次消费
WITH ranked AS (
  SELECT t.user_id, u.username, t.amount, t.reason, t.ref_task_id, t.balance_after, t.created_at,
         ROW_NUMBER() OVER (
           PARTITION BY t.user_id
           ORDER BY t.created_at DESC, t.id DESC      -- ★ id 做 tiebreaker,保证结果确定
         ) AS rn
    FROM user_credit_transactions t JOIN users u ON u.id = t.user_id
   WHERE t.direction = 'debit')                       -- 消费 = debit
SELECT username, reason, ref_task_id, amount, balance_after, created_at
  FROM ranked WHERE rn = 1 ORDER BY created_at DESC;  -- 最近 3 次就把 rn=1 改成 rn<=3

-- 对照写法 B:DISTINCT ON(PostgreSQL 专有,每用户一条时通常最快)
SELECT DISTINCT ON (t.user_id) t.user_id, t.amount, t.reason, t.created_at
  FROM user_credit_transactions t WHERE t.direction = 'debit'
 ORDER BY t.user_id, t.created_at DESC, t.id DESC;

-- 对照写法 C:LATERAL(语义最直白,用户数少时能精准走索引)
SELECT u.id, u.username, x.amount, x.created_at FROM users u
  LEFT JOIN LATERAL (SELECT amount, created_at FROM user_credit_transactions t
                      WHERE t.user_id = u.id AND t.direction = 'debit'
                      ORDER BY t.created_at DESC, t.id DESC LIMIT 1) x ON TRUE;

讲解

  • 关键是"先分区、再排序、再编号",最后在外层用 rn = 1 过滤。不能在同一个 SELECT 的 WHERE 里用 rn——窗口函数在 WHERE 之后才计算(FROM → WHERE → GROUP BY → HAVING → 窗口函数 → ORDER BY → LIMIT)。
  • ORDER BY 必须带 tiebreaker (t.id DESC):同一秒可能有多笔流水(批量生成/批量退款),只按 created_at 排序时 ROW_NUMBER() 的分配是不确定的——同一查询跑两次可能返回不同的行。这种不确定性在生产排查里是灾难("上次看到的最近一笔不是这个")。
  • 三种写法的取舍:要 Top-N 就用窗口函数(rn <= N,不可替代);只要每用户一条且是 PostgreSQL,DISTINCT ON 通常更快;用户数少而流水表巨大时 LATERAL 能精准利用 (user_id, created_at DESC) 索引。
  • 索引配合:理想索引 (user_id, created_at DESC, id DESC)(部分索引再加 WHERE direction='debit')。没有它,PARTITION BY user_id ORDER BY created_at DESC 只能排序。

面试官追问

  1. ROW_NUMBER / RANK / DENSE_RANK 的区别? ROW_NUMBER 严格递增(1,2,3,4),并列也强行分序;RANK 并列同名、后续跳号(1,2,2,4);DENSE_RANK 并列同名、不跳号(1,2,2,3)。取"最近一条"必须用 ROW_NUMBER,因为并列行只能留一条——用 RANK 会在同一秒多笔时返回多行。
  2. CTE 会不会拖慢性能? PostgreSQL 12+ 对 CTE 做内联优化,与子查询几乎无差别;PG 11 及以前 CTE 是优化屏障(强制物化),这个历史坑值得提一句。
  3. 百万行流水上会怎样? PARTITION BY user_id 要扫描全部 debit 行并排序,会成慢查询。解法:改成按用户查询(LATERAL + (user_id, created_at DESC) 索引,每用户 O(log n)),或维护 user_last_consume 汇总表在写流水时同步更新。"什么时候该从实时聚合切到预聚合"是这道题的延伸考点。
  4. "只要最近一次消费在一个月内的用户"该怎么写? 过滤位置很关键:created_at > now() - interval '1 month' 放 WHERE 等于"只在这个范围里找最近一次";若语义是"有过消费、且最近一次在一个月内",就得先在 CTE 里算 rn=1 再在外层过滤时间。"过滤应在窗口之前还是之后"是隐藏考点。

扣分点:① ORDER BY created_at DESC 不带 tiebreaker——结果不确定;② 在 WHERE 里直接用 rn——报 column "rn" does not exist;③ 用 GROUP BY user_id, MAX(created_at) 取"最近时间"再 JOIN 回原表——同一时间多笔时会返回多行,而且要两次扫描;能说出这个反面写法为什么错是很强的加分点,因为它证明你理解"取最近一条"与"取最近时间"是两件事。

【衔接自己项目】 窗口函数在我们仓库里主要用于后台统计与榜单(按用户/按商品汇总消费);"每个用户最近一次"这类查询在在线路径上更常写成按索引点查——历史列表是"按用户 + 时间倒序分页",用 ORDER BY created_at DESC LIMIT 20(Q8 那个索引)对单个用户来说比窗口函数更便宜。 主动补一句判断:"窗口函数适合『一次算所有用户』的报表场景,不适合『一个用户查自己』的在线场景——在线路径要尽量避免 PARTITION BY 这种需要全量排序的算子。" 这句话把 Q13 和 Q8 串起来了:同一类需求,在线和离线的正确解法可以完全不同。


C. 系统设计题 ​

系统设计题的评分点只有三个:澄清(你有没有先问清需求)、取舍(你为什么这么选、放弃了什么)、抗挑战(面试官否定你时你怎么守或怎么改)。 每题都按 需求澄清 → 方案分层 → 关键决策取舍 → 挑战应答 四段写。回答时始终用自己项目的语言:讲"我们怎么做的、为什么这么做、踩过什么坑",比讲教科书架构有说服力得多。

Q14. 设计一个 AI 视频生成平台的任务调度系统 ​

一、需求澄清问题清单(先问,不要一上来就画框图) ​

  1. 生成耗时量级?秒级还是分钟级?(决定同步/异步——我们动辄几分钟,所以"下单即返回"是硬约束)
  2. 上游是谁?自研模型还是第三方供应商?有没有并发额度、有没有计费、会不会限流?(决定了要治理、要重试、要退赔)
  3. 结果怎么拿?上游支持 webhook 回调还是只能轮询?(决定轮询成本)
  4. 要不要收钱?扣费时机是提交前还是成功后?(决定要不要冻结/结算这套账本)
  5. 失败怎么算?谁承担成本——用户还是平台?(决定失败时"退款"还是"重试")
  6. 一致性要求?同一个任务能不能被生成两次(重复收费)?能不能接受"任务成功但用户看不到"?
  7. 规模?日任务量、峰值并发、任务平均与 P99 时长。(决定要不要分片、要不要拆队列)
  8. 能不能接受部分失败?批量任务(一次 5 个镜头)里坏一个怎么办?
  9. 任务的可见性?用户能不能取消、能不能重试、重试是不是新任务?(决定状态机里有没有 cancelled、重试是新建行还是复用 taskId)
  10. 运维约束?单机还是多实例、有没有 metrics、谁半夜被叫起来?

二、方案分层 ​

接入层   ① 限流(按账号/动作/双窗口)② 参数与模型开关校验 ③ 幂等键
资金层   ④ 冻结积分(事务内 FOR UPDATE 扣减 + credit_holds 部分唯一索引 + 写流水)
编排层   ⑤ 生成外部任务号 → INSERT video_tasks(pending) ⑥ 入队(jobId = taskId 去重)
执行层   ⑦ 提交队列(concurrency 小、只做提交,拿到外部任务号立刻转轮询)
         ⑧ 轮询队列(concurrency 大、退避间隔表、2 小时超时)
         ⑨ 收尾(Redis SET NX EX 抢占 → 付费增强 → 转存 → 落终态 → 结算/退款)
状态层   ⑩ 权威状态在服务端 DB;Redis 只做热状态缓存(TTL 1h,丢了不影响主流程)
恢复层   ⑪ 启动恢复 + 每 10 分钟巡检(按"有没有外部任务号"分两条路)
观测层   ⑫ task_events 审计表 + [ALERT] 结构化日志 + 资金对账巡检

三、关键设计决策与取舍 ​

决策选择放弃的理由
同步还是异步下单即返回 + 后台队列请求内等待生成几分钟,HTTP 撑不住;这是硬约束不是优化
提交与轮询拆两条队列一条队列串起来槽位占用时长要和真实耗时解耦:提交几秒、结果几分钟,混在一起 concurrency 就是全站并发上限
状态存哪PostgreSQL 是权威只存 Redis/内存要能崩溃恢复、要能被运维和客服查到
并发终结Redis SET NX EX 抢占靠 DB 行锁收尾里有付费动作(超分转码),重复做就是双份钱;且抢不到必须是"放弃"而不是"排队"
状态迁移条件更新(守卫写进 WHERE)版本号乐观锁冲突语义是"只允许一个人终结",条件更新天然匹配且不需要重试循环
收尾顺序先写业务终态,最后才动钱先退款反过来会出现"钱退了、记录没写、巡检不再扫它、用户永久看不到成片"
重试归属业务显式决定,框架不自动重试提交attempts: 3 无脑重试框架的重试不认识钱;提交失败多半是参数/合规问题,重试只会重复计费
上游并发队列 concurrency + 账号级槽位租约(ZSET)只靠队列并发额度是账号级的,两个服务共用一个账号就必须跨进程协调
幂等队列 jobId + 业务终态卫语句 + DB 唯一约束只靠队列去重队列只防"同一个 job 投两次",防不了巡检重捞、多实例、人工补单

四、如果面试官挑战 X 怎么答 ​

  • "为什么不直接用 Kafka/RabbitMQ?" → 用量级与语义回答:我们是"每天几千到几万条长耗时任务",不是百万 QPS 日志流;而我们要的是延迟任务(2 秒/30 秒后再轮询)和固定 jobId 去重,这两个在 BullMQ 是一等公民,在 Kafka 要靠时间轮自建、在 RabbitMQ 要靠 TTL+死信队列拼。用 Kafka 是拿最重的基础设施解决最轻的问题。
  • "为什么不用状态机框架/Temporal?" → 承认它更对(Temporal 的 workflow 语义天然解决"崩溃后从哪里继续"),但当时的技术债约束是"团队只有我一个人 + 已有 Express/Redis 栈"。可以说:"如果重来一次,我会把状态迁移收敛成一张显式的状态机表(当前态 × 事件 → 目标态 + 副作用),由一个函数统一执行;但不会把'先写业务终态再动钱'这条顺序不变量交给框架。"
  • "你说服务端状态权威,那前端轮询有什么用?" → 前端轮询只做展示,不参与决策;我们有一条硬教训:前端只要看见"生成中"就每 3 秒继续轮询,所以服务端必须保证任何终止路径都把 video_history 落成终态,否则前端的轮询会变成"事件放大器"(2026-08-04 那次"假 DDoS"的成因之一)。
  • "怎么保证不重复收尾?" → 三层:Redis SET NX EX 抢占 → 落库前的终态再检查 → UPDATE ... WHERE status NOT IN ('done','failed') 的条件更新。锁只降低概率,最终正确性靠状态守卫的原子性(详见 Q6/Q7)。
  • "如果上游根本没有状态查询接口呢?" → 那就必须让上游回调 + 超时兜底;如果都没有,只能靠"提交时的幂等键 + 本地超时判定",并明确接受"可能重复生成"的代价——这时候要在需求澄清阶段就把这个约束挑明,而不是实现完再说。

Q15. 设计一个"防止重复扣费"的积分系统 ​

一、需求澄清问题清单 ​

  1. 扣费时机:提交前扣、成功后扣,还是先冻结后结算?
  2. 一次操作可能产生几笔扣费(一次生成多个视频)?失败部分成功怎么算?
  3. 能不能接受"扣了钱但没出片"?(不能 → 需要冻结 + 退款)
  4. 有没有多种资金来源(充值/赠送/活动)?退款退到哪个池子?
  5. 幂等键是什么?同一个用户重复点击算一次还是两次?
  6. 对账的容忍度?多久发现一次异常可接受?
  7. 余额能不能为负?有没有无限账号、企业账号这类特殊主体?

二、方案分层(四层递进,这是本题的标准答法) ​

第 1 层  应用层判断      查余额 → 够则扣。**并发下必错**(TOCTOU),只能做用户提示
第 2 层  分布式锁        SET NX EX 把同账号并发提前挡住。**减少争用,不保证正确性**
第 3 层  数据库约束      FOR UPDATE 行锁串行化 + credit_holds 部分唯一索引防重复冻结
                        + users.credits >= 0 的 CHECK + 每次变动成对写流水
第 4 层  对账巡检        每小时只读巡检,抓五类异常(漂移/多退/悬空/负余额/拆分漂移)

核心心法:越往下越可靠。 Redis 锁会因为主从切换、TTL 过期而失效;应用层判断会被并发穿透;只有数据库约束是"即使前面全错也仍然正确"的那一层,而巡检负责抓"前四层都没抓到的漏网"。

三、关键设计决策与取舍 ​

  • 冻结(hold)而不是直接扣:直接扣的话,任务失败要"反向加钱",而"加钱"这一步可能被重复执行(多退 = 白嫖)。冻结把"资金状态"和"任务状态"解耦:held 是在途,任务终态才决定它变 settled 还是 refunded,每次状态迁移只能发生一次(部分唯一索引 + 行锁保证)。
  • 流水表必须成对写:这是对账能成立的前提。credits ≠ initial_credits + SUM(流水) 这个不变式一旦被打破,就说明有路径改了余额却没记账——漂移检测的价值就在于此。
  • 锁的角色是"性能优化":临界区里真正保证正确性的是 SELECT ... FOR UPDATE;Redis 锁把重复请求挡在数据库之前,避免同账号并发把 PG 连接池(max 20)占满、把影响面从一个用户扩大到全站。
  • 双余额(赠送/充值):不是为了好看,是因为"退款退到哪个池子"必须可判定(涉及收入确认和合规)。代价是所有扣减/退款都要按池子拆分,多了一层不变式 gift + paid = credits(所以巡检里多了一类 splitDrift)。
  • 退款的幂等:refundCredits 在"没有 held 记录"时是 no-op,所以超时路径和巡检路径同时退款也不会重复退。幂等函数要能安全地被重复调用,这是异步系统的基本要求。
  • 无限账号必须显式排除:它从没被扣过钱,退款给它就是凭空造钱(生产上真发生过:无限额账号身上躺着 6 笔"自动退款"共 12.98 分)。

四、如果面试官挑战 X 怎么答 ​

  • "用 Redis 锁不就行了?" → "锁只解决'同时',不解决'先后'。TTL 到期、主从切换、进程 GC 暂停都会让锁失效;而我们真正怕的是先后两次扣费(重试、巡检重捞、人工补单)。所以正确性我放在数据库:行锁保证原子性、部分唯一索引保证幂等。"
  • "为什么不用乐观锁版本号?" → 冲突语义不同:版本号解决"不丢更新",我们要的是"只允许一个赢家"。条件更新/唯一索引恰好就是后者,且不需要重试循环。
  • "对账发现异常你怎么修?" → 先不修。只读巡检只告警,先定位"哪条路径没记账",修完路径再洗数据。自动修数据会把根因变成"余额被悄悄修正了",而且修正逻辑自己有 bug 时会把对的数据改错。
  • "怎么防白嫖(多退)?" → 四道:① 退款以 credit_holds 的行状态迁移为唯一入口(UPDATE credit_holds SET status='refunded' WHERE id=$1);② refundCredits 天然幂等(无 held 就 no-op);③ 部分唯一索引保证不会有两笔在途冻结;④ 按金额对账的多退巡检兜底(Q10)。
  • "如果一个用户并发点 10 次生成呢?" → 限流(账号维度的分钟/小时双窗口)+ 冻结失败即 402 分开处理:前 2 次成功冻结,后面 8 次因为余额不足返回 402,或者因为限流返回 429。这两个错误码要区分开——一个是"没钱",一个是"太频繁",用户看到的话术和后续动作完全不同。

Q16. 如果任务量涨 100 倍,这套系统哪里先崩、怎么改 ​

一、需求澄清问题清单 ​

先问清"涨 100 倍"具体指什么:是日任务量、是并发任务数、是用户数,还是上游调用量? 四者的瓶颈完全不同。默认按"并发任务数和日任务量都涨 100 倍"来讲。

二、按"崩的顺序"排(这个顺序本身就是答案) ​

  1. 单进程 Node:我们全部 9 个 Worker(video-generate / video-poll / image / retouch / transcode / payment-poll / scriptWriter / production / video-edit,都在 backend/index.ts 的 startup() 里逐个 createWorker())跑在同一个进程里,pm2 也没开 cluster。一个进程只有一个 event loop:transcode/retouch 这类 CPU 密集任务会把事件循环的响应时间拉长,所有队列一起变慢——这不是"哪个队列满了",而是"整个进程变钝了"。
  2. "内存自管"的任务无法水平扩展:长视频、批量镜头复刻、脚本库一键生成这些链路的状态在进程内存里(video_tasks 里只有一行占位行),taskRecovery 只能靠 Redis lease 猜。现在的恢复逻辑能跑,是因为只有一个进程。一旦起第二个副本,两边都会去"恢复"对方的任务,直接互相误杀。
  3. PG 连接池 max = 20:上限是进程级的。轮询队列 concurrency 10、加上提交/图片/转码/HTTP 请求,20 个连接很容易被打满;表现是"整个 API 变慢",而根因在某个后台批处理任务上——这是最难排查的一类故障(连接池是全局共享的隐藏耦合点)。
  4. 轮询与前端 3 秒轮询的读放大:我们主动轮询供应商(每 30 秒一次/任务),前端在"生成中"时每 3 秒轮询一次历史列表。100 倍任务量 = 100 倍轮询 + 100 倍前端查询,而其中绝大多数查询的答案没变。2026-08-04 那次"假 DDoS"就是自己的巡检和轮询把 Redis/DB 打满。
  5. Redis 是公网单点:连接要 keepAlive: 10000 防 NAT 掐连接,enableOfflineQueue 兜抖动。它是缓存、限流、分布式锁、BullMQ 后端四合一,一旦抖动或打满,全军覆没。100 倍量级下它是最先需要"拆分 + 内网化 + 独立实例"的组件。
  6. 没有 metrics,先瞎了:没有 Prometheus/OpenTelemetry,队列积压量、轮询次数分布、上游 P99 都看不到。扩容时最大的风险不是容量不够,而是不知道自己不够在哪——这条要第一个修。

三、分阶段改造路线(按投入产出排序) ​

阶段动作解决什么代价
0 先看得见接入 metrics:队列积压/延迟、任务各阶段耗时、上游错误率与 P99、连接池与 Redis 命中率让后面每一步都有依据小,纯增量
1 不动架构压成本连接池按 Worker 用途拆(后台与 API 分开)、轮询间隔表重调、历史列表去掉 COUNT(*)、前端轮询加 ETag/长轮询、把"生成中"的轮询改成 SSE 推送通常能扛 3~10 倍小到中
2 拆进程Worker 独立部署(API 进程不再跑队列)、transcode 拆成独立服务(CPU 隔离);同时把"内存自管"链路的状态外置到 DB/Redis解决 event loop 争抢 + 打开水平扩展的门大(自管链路改造是真正的硬骨头)
3 治理上游回调优先(供应商支持 webhook 就用回调,轮询只做超时兜底);账号级槽位租约做多账号分池;提交队列按供应商/账号分片把上游调用量降一到两个数量级中
4 数据层video_history/video_tasks 按时间冷热分离、历史归档、流水表分区;游标分页替换深 OFFSET让查询成本与总量解耦中到大

顺序为什么是这样:阶段 0 和 1 不改架构、风险最低、收益立刻可见;阶段 2 是真正的分水岭,但它依赖阶段 0 给的证据(否则你不知道该拆哪个 Worker);阶段 4 放最后,因为在数据量真正成为瓶颈之前做分区,只是给自己增加复杂度。

四、如果面试官挑战 X 怎么答 ​

  • "为什么不直接上 K8s + 微服务?" → "我们的瓶颈里有一半不在'部署形态'上:内存自管的链路不改成状态外置,上 K8s 只会让多副本互相误杀,问题更严重。先改状态,再改部署形态。" 这句话是本题最亮的答案。
  • "你说 conncurrency 要重算,怎么算?" → 三个约束联立:① 上游账号额度(腾讯 AIGC 全局 100 / 本服务 50);② PG 连接池 max 20(每个在跑的 Worker 至少占 1 个连接);③ 单进程 event loop 的 CPU 预算。真实的并发上限 = min(上游额度, 连接池, CPU 预算),而不是随便调大 concurrency。多实例时还要除以实例数。
  • "加了实例之后有什么新问题?" → 至少三个:① taskRecovery 的 forceActiveJobs 语义要重审(启动时"上一进程的僵尸 active job"这个假设在多实例下不成立);② 内存自管链路必须先外置状态;③ 所有"每分钟跑一次"的巡检要加分布式选主,否则 N 个副本会跑 N 份对账。
  • "Redis 打满了怎么办?" → 拆用途(缓存/BullMQ/限流/锁分实例)、队列与业务缓存分开、把页缓存从"写时失效 + 短 TTL"改成"版本号 + 更短 TTL"、给 Redis 加 maxmemory-policy 与哨兵/集群。但现在最该做的其实是先把可观测性补上——没有指标的情况下你连"是内存打满还是命令数打满"都不知道。

Q17. 如何设计一个能扛住上游抖动的重试与降级策略 ​

一、需求澄清问题清单 ​

  1. 上游的失败率与失败形态(超时/5xx/限流/业务拒绝)各占多少?
  2. 重试的代价是什么——是时间、是钱,还是可能产生副作用(重复下单)?
  3. 能不能判断"上游到底做没做"?有没有幂等键和状态查询接口?
  4. 用户侧能等多久?超时后要告诉他什么?
  5. 哪些功能可以降级(接旧模型、降分辨率、走另一家供应商),哪些必须 fail-close?

二、核心:把错误分成三类 ​

类别例子动作
可重试网络超时、连接重置、上游 5xx、限流 429指数退避 + jitter 重试,有次数上限
不可重试参数非法、素材不合规、内容审核拦截、余额不足、模型被关立即失败,给出可读原因,不做任何重试
未知提交时连接断了、响应读了一半、上游返回了没见过的状态保守归一到"还在跑",交给查询/对账去确认

关键判据:不确定时归一到保守的那一侧。 在这套系统里,"误判为失败"的代价是标失败 + 退款(钱出去了、片子还在跑),而"误判为还在跑"的代价只是多查几次。两者的代价不对称,所以必须往"还在跑"归。

三、落到具体设计 ​

  • 归一化层:把上游五花八门的状态收敛成四个语义态——done / failed / processing / not_found。not_found 单独存在的理由是它对应完全不同的动作(清空外部任务号后重新提交,因为这一单实际上从没提交成功过),而 processing 只是继续等。识别不出来的异常一律向上抛让队列重试,绝不猜。
  • 一个状态值承载语义:SUBMISSION_UNCERTAIN(提交可能已成功、需要人工/对账确认)——结算策略里它是 keep:不结算也不退款。因为退了是白嫖(片子可能真出了)、扣了是错收(片子可能真没出)。
  • 重试策略:video-generate(提交,非幂等)attempts = 1,业务自己判断;video-poll(查询,幂等)attempts = 3 + 指数退避。"幂等 + 失败是瞬时的"是交给框架自动重试的两个前提。
  • 降级而不是失败(fail-open 与 fail-close 要分开定):
    • 画质增强失败 → 回落原片(fail-open 保交付),但必须吼一声 [ALERT][enhance],而且落库的分辨率必须是实际交付的那一档而不是目标档——否则会出现"用户按 1080P 付了钱、拿到的是 720P 原片、历史里却写着 1080P"(这个 bug 我们真出过)。
    • Redis 不可用 → 限流放行(fail-open),理由是这条路径上 Redis 挂了本来也入不了队。
    • 模型开关关闭 / 上游通道不可用 → 403/503(fail-close),因为这时候继续只会产生无效任务和无效扣费。
  • 降级要能被观测:enhancementOutcome 这种字段要落库(executed / failed / skipped),否则"我们降级了多少次"永远没人知道。

四、如果面试官挑战 X 怎么答 ​

  • "fail-open 会不会亏钱?" → 会,所以要按"交付价值 vs 成本"逐个决策:增强失败回落原片,用户至少拿到成片(不交付才是更大的损失,还要退款);但音轨校验不过就不交付(交付一个没声音的成片,用户会投诉且要退款,成本更高)。能用"不交付的代价 vs 降级交付的代价"把每个决策讲一遍,这题就满分了。
  • "你怎么知道重试策略是对的?" → 靠数据:task_events 表记着每次重试的原因,error_message 存上游原文(我们曾因为写死一句"供应商返回任务失败",让它成了近 60 天里出现最多的一条错误信息,把真实原因全丢了)。没有这些数据,重试策略就是拍脑袋。
  • "上游整体挂 30 分钟呢?" → 三条:① 队列积压 + 上游槽位租约会自然限流,不会把上游打死;② 提交阶段的失败要区分"确定没提交成功"(可安全重试)和"不确定"(转 SUBMISSION_UNCERTAIN 挂起等对账);③ 对用户的诚实:与其让他等 2 小时后超时退款,不如让状态显示"排队中/上游繁忙",并在超时前主动退款。
  • "重试会不会把上游打挂?" → 这正是 jitter 要解决的(Q2)。我们现在两个地方都没有 jitter(BullMQ 的 exponential 不带、上传重试不带),只是因为并发被上游槽位和 concurrency 压住了才没事。这是我最清楚的一处技术债。

白板答题的通用套路 ​

这七条比任何一道题的答案都重要——面试官记不住你写对了哪个函数,但一定记得住你"是不是一个有条理的人"。

  1. 先复述需求,再动手(30 秒)。用自己的话把题目念一遍,并明确说出输入/输出/边界:"tasks 是函数数组,limit 是并发上限,结果按传入顺序返回——对吗?" 复述对了,后面写歪的概率降一半;复述时发现歧义,当场问。
  2. 先讲思路再写码,且明确说"我打算这样组织"。例如"我拆成两个函数:一个算延迟的纯函数、一个调度;这样抖动策略可以单独测"。面试官要看到的是结构感,不是打字速度。
  3. 写码时同步解释关键行,但不要逐行念。只在"这里有个坑"的地方停一下:"这一步必须用 SET NX 一条命令,不能 SETNX 再 EXPIRE,否则中间挂了就死锁。"
  4. 主动说边界,宁可多说不写。边界意识是白板题最大的区分度:limit=0、空数组、value 是 undefined、同一毫秒的并列行、TTL 小于临界区耗时。每题说出 3 个边界,比多写 10 行代码值钱。
  5. 写完自测两个用例(而且是主动做,不要等面试官问):一个正常路径、一个边界或失败路径。"我用 [30ms, 10ms, 50ms, 5ms] 跑一下,峰值并发应该是 2……对,是 2。再试一个抛错的任务,结果应该是 rejected 而不是整体崩。"
  6. 主动接回项目:"这个模式在我们项目里的对应实现是 creditLock.ts,而且我们那里做了一个取舍——拿不到锁不阻断,因为……"。这一步把"会做题"变成"能落地",是最有效的加分动作。
  7. 收尾时主动交底不足:"这个实现没考虑 X,如果要上生产我会先补 Y。" 主动说出局限比被追问出来强得多;但只交底"有改进方向"的不足,不要交底"我写错了"。

现场时间分配建议:手写题 15~20 分钟/题(复述 1 分钟、思路 2 分钟、写码 8~10 分钟、自测与边界 3 分钟、接项目 2 分钟);系统设计题 30~40 分钟(澄清 5 分钟、分层 5 分钟、关键设计 15 分钟、留 10 分钟给面试官挑战)。面试官打断你时不要慌,那通常意味着他想往某个方向深挖——顺着他的方向走,但记得把没说完的那句"我给这个设计留的不变量是……"补回去。

持续学习,持续构建。