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)
思路
- 先划 API 契约:
tasks必须是「返回 Promise 的函数」数组,不是 Promise 数组——传 Promise 数组时,调用方构造数组的那一刻就全启动了,池子从根上失去意义。这句要在写码之前说。 - 模型:起
limit个 worker 抢同一个下标游标,不是把任务切成limit份分片(分片会让快的 worker 等慢的,长尾一多并发利用率就掉)。 - 默认
allSettled语义(一个失败不中断其它),"要不要中断"做成选项而不是改结构。 - 结果按
index落位,返回顺序与传入一致,与完成顺序无关。
代码
/**
* 带并发上限的异步任务池。
* @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传进任务。
面试官追问
- 为什么参数是任务工厂而不是 Promise 数组?
[fetch(a), fetch(b)]在数组字面量求值时就全发起了,池子只能限制"等待"、限制不了"发起"。BullMQ 的video-generate就是这个原理的进程级版本:job 先进 Redis,Worker 领到才去调供应商。 - 结果乱序怎么办? 不乱。
results[index]按取号时的下标落位,返回顺序与tasks严格一致。想要"完成即回调"的流式语义,那是另一个 API(加onSettle),别混进一个函数。 - 超时算失败还是算重试? 分开。池子只负责"标记这条 rejected",重试是外层
retry()的事(Q2)。混在一起会出现"超时→重试→原请求还在跑→两个结果都写库"。 - 一个失败要不要中断整批? 默认不中断——批量复刻 5 个镜头挂一个,不该让另外 4 个白跑。要中断就给
failFast,但要说明代价:已发出的请求收不回,只是不再领新任务,返回的是部分结果。 - 它和 BullMQ 的
concurrency什么关系? 同一概念的两种实现:手写版是进程内单次调用的闸门,BullMQ 是跨进程、可持久化、可崩溃恢复的闸门。用队列是为了拿到手写版给不了的三件事:任务不丢、能跨进程、能被外部观测。
扣分点
- 用分片(chunk)实现:
for (i=0; i<n; i+=limit) await Promise.all(slice)。看着对,实际每批都要等最慢的——一批里有个 30 秒任务,另外 3 个槽位就空等 30 秒。 - 用
tasks.shift()取任务:O(n) 让循环变成 O(n²);若写成await之后再 shift,还会重复领取。 - 超时忘了
clearTimeout:长跑服务里定时器越积越多。
【衔接自己项目】 BullMQ 的
concurrency就是它的进程级实现——video-poll给 10(轮询是轻量状态查询)、video-generate只给 2(打上游、要省槽位)、transcode/retouch给 1(CPU 密集,跟 Node 主线程抢资源)。主动补一句关键差异:BullMQ 的concurrency是每个 Worker 实例的上限,多实例部署时真实并发 = 实例数 × concurrency。我们现在单进程跑,数字还准;一旦水平扩展,这个数必须重新分配——这正是 Q16 里第一个要处理的事。
Q2. 手写"指数退避 + 抖动"的重试函数
思路
- 拆成两个函数:
computeDelay(attempt, opts)(纯函数、可单测)+retry(fn, opts)(调度)。能拆成纯函数的策略一定要拆——项目里pollTimeoutPolicy.ts的isPollTimedOut()就是为此拆的。 - 退避序列:
min(max, base * factor^(attempt-1))。max必须有,否则第 10 次就是 100 秒。 - 抖动三档:
none(不抖)/equal(raw/2 + rand*raw/2)/full(rand*raw)。哪档是哪档必须说准,把 equal 说成 full 是概念性扣分。 shouldRetry用白名单(只重试认识的瞬时错误),不用黑名单。sleep、random做成可注入参数:测试才能瞬间跑完 5 次重试并断言确切延迟序列。
代码
/** 纯函数:第 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可注入:否则"抖动"让断言变成范围断言,测试会脆。
面试官追问
- 为什么一定要 jitter?不抖会怎样? 会形成同步重试风暴:我们这种"一批任务同时打同一个下游"的场景,上游抖一下,几百个任务在同一秒全部失败,退避序列又完全一样(1s/2s/4s),于是它们在 1 秒、2 秒、4 秒这三个时刻重新聚集成三波洪峰,把刚恢复的下游再打挂一次——惊群的经典形态。jitter 的唯一作用就是把这批客户端摊平成一个斜坡。
- full / equal / decorrelated 怎么选?
full是默认(打散最好,代价是期望延迟只有raw/2);equal保住下限,适合"不希望过早重试";decorrelated(random(base, prev*3),有状态)在长重试链上更均匀但实现复杂。我默认full+onRetry打点观察实际分布。 - 抖动会不会让最坏延迟更差? 单次上界还是
raw,但期望延迟从raw降到raw/2。要让总预算不变,一般得把base/max调大一点,让平均延迟落在原量级。 - 哪些错误不该重试? 参数非法、素材不合规、余额不足、模型被关、审核拦截——重试 100 次结果一样,只会重复烧日志甚至重复计费。所以必须是白名单式("只重试我认识的瞬时错误")。
- 视频生成"每次调用都烧钱",重试要注意什么? 重试成本是钱不是时间。① 只重试幂等操作(查状态可以重试,提交任务不能无脑重试);② 框架的自动重试不认识钱,
video-generate我特意把attempts设成 1,改成业务自己判断;③ 重试次数要和退赔策略对齐——重试 3 次都失败就该走"标失败 + 退款"。
扣分点
- 只有
2^n * base,没有max:第 10 次是 102 秒,第 20 次是 29 小时。 - 抖动档位说不清:写成
delay * (0.5 + rand*0.5)却声称是 full jitter——那是 equal jitter。概念说错比代码写错更致命,面试官会怀疑你所有"最佳实践"都是背来的。 - 对非幂等操作重试:比如"提交订单"直接重试会重复下单。正确做法是加幂等键(我们用
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同形态,ossAdapter12 秒封顶)。- 主动澄清一处容易被读错的地方:
retryStrategy: Math.min(times * 200, 5000)是 ioredis 的连接重连退避,不是 job 重试,两者很容易混为一谈。- 为什么现在还没炸:同时刻在重试的请求数被两件事压住了——
video-generate的concurrency = 2,以及上游账号级并发槽(腾讯 AIGC 槽位是 ZSET 租约 + 全局 100/本服务 50 两级配额)。所以"同一秒失败又同一秒重试"的规模只有个位数到十几。量涨上来之后,加 jitter 就是重试策略里第一个要改的东西——既承认不足,又给出"现在还安全"的定量理由,比只说"我们没做"强得多。
Q3. 手写 Redis 限流器:固定窗口 → 滑动窗口(ZSET)→ 令牌桶
思路
- 先问限流目的,它决定选型:防爆破(硬窗口、宁可错杀)、省自己的 Redis/DB(省命令数)、保护上游稳态 QPS(要平滑)、还是防"某账号把积分烧光"(要按账号而非 IP 计数)。
- 固定窗口(
INCR + EXPIRE)三行就够,致命缺陷是窗口交界最多 2 倍瞬时量。 - 滑动窗口用 ZSET:清过期(
ZREMRANGEBYSCORE)→ 计数(ZCARD)→ 记一笔(ZADD)。必须 Lua 打包,否则三步之间有竞态、高并发下超发。 - 令牌桶用于"保护上游稳态 QPS + 允许小突发":hash 存
tokens/ts,按经过时间补充,桶容量为突发上限。 - 三套实现都要回答同一个问题:Redis 不可用时放行还是拒绝。
代码
// ── ① 固定窗口: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 倍瞬时量 |
| 滑动窗口 ZSET | 1 次 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,时间戳从应用传。
面试官追问
- 固定窗口的 2 倍怎么量化? 窗口 60 秒上限 N,最坏是窗口末尾打 N 次、窗口一翻立刻再打 N 次 → 任意 60 秒滑动窗内最多 2N 次。平均速率并没超标,但下游承受的是 2N 的瞬时尖峰。防爆破能接受,保护上游 QPS 不能接受。
- 滑动窗口有更省内存的做法吗? 有,分桶:60 秒切成 60 个 1 秒桶,hash 存
{秒: 计数},查询时把当前桶和上一分钟的桶按权重加权求和。内存是 O(桶数) 常数而非 O(请求数),误差降到 1/60;代价是实现更绕、"加权"是个近似值。 - 限流的 key 怎么选?这是最重要的一问。
key = 限流主体 + 动作 + 窗口。主体选错,限流本身就是摆设——项目里真有这个案例:通用限流按IP + 完整 path计数,而脚本库生成的 path 带 uuid(/api/video/script/library/<uuid>/generate),换一个 uuid 就是另一个 key,等于每个脚本各有一份 60 次/分钟的额度;而那个接口单次最高能花 150 积分,额度形同虚设。所以后来加了一套按登录用户名的配额——要保护的是账号的积分,与 IP 无关(那个接口只信明文X-Login-User头,攻击者换 IP 是零成本的)。 - 被拒绝的请求要不要计数? 要。项目里两套限流都是"调用即计数(含最终被拒的那次)",所以狂点的人会把自己的小时额度一并烧掉。被拒的不计数,攻击者就能一直挂在边界上试探、永远不"付费"。
- 多个窗口同时超限,
Retry-After报哪个? 报最长的。分钟窗过去了、小时窗还卡着,只报 60 秒会让客户端到点重试再吃一个 429。项目里这是个纯函数evaluateUserActionQuota,专门为了可单测。
扣分点
INCR后忘EXPIRE(key 永不过期 = 永久 429),或把EXPIRE写在INCR之前(每次请求都重置窗口 = 没限流)。正确位置是count === 1时设。- 滑动窗口不用 Lua:三条独立命令之间会被其他请求插入,高并发下实际放行数明显超限。Redis 单命令原子 ≠ 多命令原子。
- 选型答错:给"登录爆破防护"上令牌桶(桶允许突发,正好给了爆破者空间),给"保护上游 QPS"用固定窗口(2 倍尖峰打上去)。
【衔接自己项目】 按这个顺序讲:
middleware/rateLimiter.ts是固定窗口,key 是shatang:rate:${ip}:${path},path 是完整路径——上面那个"每个 uuid 一份额度"的漏洞就出自这里。- 所以又加了一套
USER_ACTION_RULES,按登录用户名计数 + 分钟窗/小时窗双窗口。数值是算出来的,能直接背:脚本库一键生成单次最高 5 条 × 30 秒 × 1 积分/秒 = 150 积分,30 次/小时的额度 ⇒ 单账号每小时最多被烧掉约 4500 积分——这就是"这个账号一小时的损失上限"。报得出这个数字比说"我们限流了"强十倍。- 主动指出一处注释与实现不符:文件头注释写的是 "using Redis INCR + EXPIRE (sliding window)",实现却是固定窗口。主动说明这一点,面试官会据此判断你读不读自己的代码。
- 再补一个"我在别处真用了 ZSET"的证据:腾讯 AIGC 的并发槽租约(
slotLease.ts)就是 Sorted Set,member ='<owner>:<id>'、score = 占用时刻,过期回收用ZREMRANGEBYSCORE而不是 key TTL——因为要按"单个成员"过期(某个任务崩了只回收它占的那个槽),而不是整个集合一起过期。这正好是滑动窗口用 ZSET 的同一个理由。
Q4. 手写 Redis 分布式锁(SET NX EX + Lua 释放),讲清三个坑
思路
- 加锁只允许一条命令:
SET key token EX ttl NX。绝不允许SETNX之后再EXPIRE——两条命令之间进程挂掉就死锁。 - value 必须是每次唯一的持有者标识(
crypto.randomUUID()),否则无法区分"我的锁"和"别人的锁"。 - 解锁必须用 Lua:
if get(key) == token then del(key) end。写成"先GET判断再DEL"是 TOCTOU:判断和删除之间锁可能已过期并被别人拿走,你就删了别人的锁。 - 三个坑要主动说:① 锁过期了业务还在跑;② 释放了别人的锁;③ Redis 主从异步复制丢锁。① 的正解是续期或让临界区幂等;③ 的正解是别把锁当正确性保证,正确性要落回数据库约束(或 fencing token)。
- 最关键的判断:先问自己这把锁是"性能优化"还是"正确性保证"。是前者,"拿不到锁"的正确反应就是降级继续而不是拒绝服务。
代码
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()。
面试官追问
- 为什么
SETNX+EXPIRE不行? 两条命令之间进程被杀或连接断,就留下一个永不过期的锁,全站该用户的资金操作从此永久失败。SET key val EX ttl NX是一条命令、原子生效,这个窗口不存在。 - 锁过期了业务还在跑怎么办? 两条路:① 续期(watchdog 定时
EXPIRE);② 接受它会发生,并让重复执行无害。项目走后一条——锁里那句SELECT ... FOR UPDATE已经把同账号并发串行化了,即使锁过期、两个请求同时进临界区,行锁让它们排队,第二个人看到的余额已经扣过了。这是"用数据库约束兜住分布式锁失效"的标准姿势。 - Redlock 能解决主从切换丢锁吗? 不能真正解决。Kleppmann 的经典反驳是:Redlock 依赖各节点时钟准确,而时钟漂移 + GC 暂停完全可能让客户端在锁"已过期"的情况下继续执行;多数派只降低概率、没消除。业界更接受的结论是:要强正确性就用 fencing token(单调递增序号,让底层资源自己拒绝旧持有者)或直接用数据库。 我们就是直接用数据库——Redis 锁只减少无谓的行锁等待。
- 拿不到锁报错还是继续? 取决于锁的角色。性能优化 → 继续,让下层约束保证正确性;正确性保证(如"全站只有一个进程在对账")→ 必须阻断。项目里两种都有:
creditLock是前者(拿不到就打 warn 继续走事务),收尾抢占(Q6)是后者(抢不到必须 return)。能分清这两者并说清判据,是这题的满分点。 - 既然有行锁,为什么还要 Redis 锁? 行锁是悲观等待:并发请求会一直占着连接和事务,而 PG 连接池只有 20,同账号并发生成时很容易把连接池占满,把影响面从"一个用户慢"扩大到"全站慢"。Redis 锁在数据库之前挡掉大部分重复请求,事务持有时长从"等待时长"降到"执行时长"。本质是把压力从贵资源(PG 连接)前移到便宜资源(Redis 命令)。
扣分点
SETNX之后单独EXPIRE:分布式锁最经典的一处错误,能主动说出"为什么不用这条"就体现了字段熟练度。- 释放时不校验 token(直接
DEL):重复收尾场景会删掉别人的锁,让第三个人也进来,锁彻底失效。 - TTL 拍脑袋:给长任务配 30 秒 TTL,锁早过期了业务还在跑 = 没锁;给短任务配 1 小时 TTL,进程一崩就锁死 1 小时。TTL 必须由临界区 P99 推出来。
- 把锁当唯一正确性保证:说"我们有 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)
思路
- 要 O(1) 的
get/put,需要"哈希表 + 有序结构"。JS 里Map一个结构就够:迭代顺序就是插入顺序,delete+set都是 O(1),等价于双向链表里"摘下来挂到队尾"。 - 约定方向:队首 = 最久未使用(淘汰端),队尾 = 最近使用。方向先说清,代码里搞反是很常见的错。
get命中:delete再set——不能只set,Map.set对已存在的 key 不改变位置。put:已存在 → 先删;不存在且已满 → 删掉map.keys().next().value(队首)。- 为什么不用数组:
findIndex是 O(n)、splice也是 O(n),get 一次就是 O(n)。
代码
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(异步回源),临界区就被切开,需要另加保护。
面试官追问
- LFU 和 LRU 的差异?各适合什么? LRU 淘汰"最久没访问的",LFU 淘汰"访问次数最少的"。差别在抗扫描:一次只读一遍的批量扫描会把 LRU 的热数据全冲出去(cache pollution),LFU 不会;但纯 LFU 有新数据饥饿(新内容计数为 1,永远排不过老热点)。工程上多是折中:LRU-K、TinyLFU(Caffeine)、LFU 带衰减。而我们历史页缓存的需求其实是第三种:写时失效——LRU/LFU 都解决不了。
- 怎么给 LRU 加 TTL? entry 存
{value, expireAt},get时先判过期(过期当未命中并删)。注意惰性删除会残留内存,需要后台清理或淘汰时顺手清。生产上别手写,直接用lru-cache。 - Redis 的 LRU 是精确的吗? 不是。Redis 用近似 LRU:
allkeys-lru是"采样 N 个 key(默认 5),淘汰其中最久未访问的"。原因是精确 LRU 要给每个 key 维护链表指针,内存开销太大。这个取舍本身就是好话题:精确性与资源开销的权衡,工程上通常选近似。 Map和WeakMap该用哪个?WeakMap键是弱引用、不可枚举、无法知道有多少条目,因此做不到"按容量淘汰最久未使用"——它只适合"跟着对象生命周期自动清理"。缓存要主动淘汰,必须用Map。- 手写和用库的边界? 面试手写是为了证明你懂"哈希表 + 有序结构"这个组合;生产上手写 LRU 是负债(缓存最需要成熟实现:并发、统计、TTL、淘汰策略)。我会明说:"这题我手写,但真上生产我用
lru-cache。"
扣分点
- 用数组却声称 O(1):
findIndex找位置 O(n)、splice删除 O(n)。 get里忘提升顺序:唯一改动的就是那一行,漏掉就变成 FIFO,而且测试很容易过(读写都正常,只是命中率低),上线后才体现为"缓存好像没啥用"。put覆盖已存在 key 时没先 delete:同样是 FIFO 化。- 淘汰方向写反:
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 + 成功后不释放)
思路
- 先说清问题:同一任务的 finalize 会被多条路径同时触发——恢复逻辑对
processing任务做"立刻重轮一次"(delay=0时不做 jobId 去重)、队列里已有的延迟 job、多实例部署时的另一个实例。而收尾里有一个花钱的动作(画质增强=真实转码计费),并发收尾 = 双份钱。 - 抢占用
SET key val EX ttl NX,抢不到直接return:不报错、不重试——"别人在做"是正确结果,不是异常。 - 成功后不主动释放(这题的核心):后续 job 会被函数开头的终态卫语句拦住,锁不再承担防重职责;而主动释放会打开一个危险窗口——释放发生在"付费动作做完但状态还没落库"之间时,后来者会抢到锁并再做一遍付费动作。
- 失败要释放:落库前出错就
DEL,把重试能力留给队列和巡检。这与成功路径有意不一致,必须主动说明。 - TTL 定法:远大于一次收尾的 P99(项目给 1800 秒,实测单次增强约 52 秒)。
代码
/** 收尾抢占:保证同一任务的收尾(含付费动作)在整个系统里只发生一次 */
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)。
面试官追问
- 为什么成功后不释放? 因为"锁"和"终态"承担的职责不同。终态卫语句防的是"已经收完尾了",只在落库成功后生效;而在"付费动作已提交、状态还没落库"那个瞬间,只有锁能防重。在这一瞬间释放锁,恰好过来的第二个 job 既看不到终态又抢到锁,就会再做一遍付费动作。
- TTL 怎么定? 单次收尾 P99 × 安全系数,且要能报依据:项目里单次增强实测约 52 秒(生产日志里真实测到的两条重复增强记录之一),TTL 给 1800 秒。给太小 → 长尾任务被重复收尾;给太大 → 一个任务卡住后它在 TTL 内无法被重试收尾,用户要等 30 分钟。两个方向的代价都要说。
- 如果收尾跑了 40 分钟、超过 TTL? 两层补救:① 收尾函数在写库前会再检查一次终态(不只入口检查),所以"慢到超时但先落库"的那个仍能拦住后来者;② 若两边都落库了,靠落库的幂等——
UPDATE ... WHERE status NOT IN ('done','failed')天然只有一个赢家(Q7)。最终正确性不靠锁、靠状态守卫的原子性,锁只是降低重复做付费动作的概率。 - 两条链路各自加锁会怎样? 这是生产真实踩过的坑:画布/首页对话类任务有第二条收尾链路——前端自己也会轮询触发一次画质增强,用
shatang:canvas:pp:<taskId>去重。worker 侧原来用另一个键shatang:enhance-claim,两条链路的锁互相不可见:用户开着页面时恢复逻辑又把任务塞进队列,同一任务被腾讯超分了两遍(生产实测同一任务增强两次、各约 52 秒的双份转码费)。修法是 canvas 类任务统一去抢前端那条链路的键,并按同一份 JSON 状态机协议(processing/done/failed)推进。教训一句话:"分布式锁只有在所有参与者用同一把钥匙时才有意义。系统里存在两条独立演化的链路时,最容易出现的就是『两边都做了去重、但去的是不同的重』。" - 这和 Q4 的
creditLock有什么区别? 三点:① 角色不同(一个是性能优化、抢不到就降级继续;一个是正确性保证、抢不到必须放弃);② TTL 量级不同(10 秒 vs 1800 秒,因为临界区一个是毫秒级 PG 事务、一个是分钟级付费转码);③ 释放策略不同(前者无论成败都在finally释放,后者只失败时释放)。能列出这三点,说明你不是记住了一个模板,而是理解了每个参数背后的依据。
扣分点
- 成功路径写
finally { await redis.del(key) }:锁退化成"只防同时、防不住先后",而收尾的重复往往是先后的(恢复逻辑的立即重轮和残留 delayed job 之间隔了几十秒)。这是本题最大的扣分点。 - 用
GET判断再SET:TOCTOU,两个 job 可以同时判断"没有锁"然后同时写。必须一条SET NX。 - 抢不到就抛异常:"别人正在做"是预期的正常分支。抛异常会让 BullMQ 把这次 job 记为失败、消耗重试次数,最后真变成一个告警。
- 忘了终态卫语句:只靠锁的话,一个任务的第 4、第 5 次收尾会一直抢失败刷日志;更严重的是锁过期后(30 分钟)它还会被抢到并重复花钱。
【衔接自己项目】 把上面第 4 个追问(双链路各用一把锁、生产实测双份转码费)讲出来——这是这册里最有说服力的素材,它同时证明了"我知道为什么这么做""我踩过它的反面""我能用一句话抽象出通用教训"。 再补一个细节增加可信度:canvas 链路抢到锁后会写一个 JSON 状态
{state:'processing', rawUrl, startedAt}(EX 1800),前端链路和 worker 链路读同一份状态机;读到processing就scheduleNextPoll稍后再来,读到failed就按它的结论终止任务并退款。所以这把锁不只占了位,还承载了"另一条链路进展到哪了"的语义——这是锁与状态机合并的一个实用变体。
Q7. 手写状态机的守卫式更新(只允许从非终态推进到终态)
思路
- 先写错误示范:
SELECT status判断 → 业务逻辑 → 无条件UPDATE。它在并发下必然出错,因为SELECT和UPDATE之间有窗口(TOCTOU)。 - 正确做法:把状态守卫写进
UPDATE ... WHERE,用影响行数(或RETURNING)判断"我有没有赢得这次迁移"。 - 为什么安全:PostgreSQL 的
UPDATE会锁行并对最新版本重新求值WHERE,所以"并发时别人刚把它改成终态"这件事会被WHERE看见,后到者影响 0 行。 - 语义:返回 0 行 ≠ 错误,而是"别人已经推到终态了,我什么都不该做"。这个分支必须静默,不记 warning、不抛异常、更不能退款。
代码
-- 反例(先查后改,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 行 = 我没赢,静默放弃/** 守卫式状态迁移:唯一入口,所有"把任务推到终态"的地方都走它 */
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。
面试官追问
- 为什么这比"先 SELECT 再 UPDATE"安全? 因为
SELECT读到的是快照,不是"一票否决权"。两个执行者可以都读到processing、都通过判断、都执行UPDATE,最后一个覆盖前一个,两边副作用都跑了。把守卫放进WHERE之后,UPDATE对最新版本的行重新求值条件,后到者看到的是已变成终态的行,影响 0 行——判断和执行变成同一条语句,中间没有窗口。 - 为什么不用版本号乐观锁? 因为这里的并发冲突不是"两个人都想改同一个字段"(那才需要乐观锁保证不丢更新),而是"多个执行者同时想终结同一个任务,只允许一个人赢"。条件更新恰好就是这个语义,而且不需要额外字段、不需要重试循环。用对的工具,不是更复杂的工具。
RETURNING和rowCount用哪个? 本质一样,RETURNING更不容易被驱动/连接池的语义差异影响,还能顺带拿到更新后的行做日志。我们用RETURNING id。UPDATE ... WHERE会不会锁表? 不会,PostgreSQL 是行级锁。但要注意:并发更新同一行时后到的事务会阻塞等待前者提交,然后重新求值WHERE。所以不会丢更新,但会引入等待——临界区长的话就该像 Q4 那样在前面加一层 Redis 锁把重复请求挡掉。- 怎么保证这套模式不被绕过? 老实说,目前靠约定不是靠机制:状态迁移散在
taskRecovery、videoWorker、videoPollWorker等多个文件里各自写WHERE,正确但不好审查。要改就把所有迁移收敛到一张显式状态机表(当前态 × 事件 → 目标态 + 副作用),由一个函数统一执行——这一条放在"如果重新设计"里主动说。
扣分点
UPDATE不带状态条件:这是"任务已完成后又被标失败、用户被退款但成片照常交付(白嫖)"的直接原因。- 拿到 0 行却当异常:记 error 日志、抛异常、甚至触发重试。并发终结是预期路径,当异常处理会淹没真实问题、浪费重试次数。
- 副作用写在判断之外:
if (status !== 'done') { ... }之后紧跟无条件refundCredits()——退款是唯一不能重复/不能误发的动作,必须在"赢得迁移"的分支里。 - 顺序颠倒:先退款再写终态。这不是风格问题,是事故换来的不变量。
【衔接自己项目】 这套写法在
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)分页
思路
- 先问口径,这题一半的分在需求澄清:是"第 N 页"(后台管理/报表,需要总数、要能跳页)还是"下滑加载"(用户端历史,只要"下一页"、不要总数)?两者最优解不同。
- OFFSET 的代价:
LIMIT 20 OFFSET 79000不是"跳过 79000 行",而是老老实实扫过并丢弃 79020 行。它随页码线性变慢,且并发插入/删除时会出现"重复或漏记录"。 - keyset:把上一页最后一行当游标写进
WHERE。必须用行比较器(created_at, id) < (?, ?),不能用created_at <= ? AND id < ?(语义错,见追问 1)。 - 排序键必须唯一且稳定:游标是
(created_at, id)复合的,created_at主序、id做 tiebreaker。 - 索引必须与排序键完全一致,
(user_id, created_at DESC, id DESC)才能让"按用户过滤 + 倒序翻页"走纯索引扫描。
代码
-- ① 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);/** 游标编解码: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 <> '失败'这类过滤,索引要一起设计(部分索引),否则退化成"扫很多再过滤"。
面试官追问
(created_at, id) < (?, ?)和created_at <= ? AND id < ?有什么区别? 前者是行比较器,等价于"created_at < t或(created_at = t且id < i)",即字典序;后者是AND,会把所有created_at < t但id >= i的行全部排除——只要 id 不与时间同序就会漏记录。这是 keyset 分页最高频的实现错误。- 用户端历史要不要返回总数? 不要。总数要一次全量扫描,而这个数字对用户没有价值(用户只关心"还能不能往下滑")。
nextCursor === null就是到底了。项目现在带COUNT(*),属于可以优化掉的开销。 - 深分页还有别的解法吗? 有,延迟关联(deferred join):先在覆盖索引上
OFFSET拿主键,再回表。它降低的是回表代价(只回 20 行),不降低扫描行数,所以是工程折中而非根治。根治只有游标。 - 后台导出几万行怎么办? 不要"翻页导出"(客户端循环翻页会被 OFFSET 越翻越慢拖死)。要么服务端用游标流式分批拉、边拉边写文件;要么直接扔进异步任务(我们本来就有队列),导出完给下载链接。
- 只建
ORDER BY created_at DESC的索引够吗? 不够。复合顺序要与ORDER BY完整一致(created_at DESC, id DESC),等值过滤列(user_id)放最左。只建单列时规划器可能选"位图扫描后排序"(就是上面 C 那段),行数一大就退化成外部排序。
扣分点
- 用 OFFSET 做无限滚动:这就是"翻到后面越来越慢"的根因。滑到第 4000 页时后端要扫 8 万行才返回 20 条。
- 复合游标漏了 tiebreaker:时间戳有并列(批量生成的任务时间戳高度接近)时会跳过同毫秒内的后续记录——表现为用户翻页"少了几条视频",而且很难复现。
- 游标用自增 id、排序用 created_at:两者顺序不一致时游标会乱跳。游标必须精确对应排序键。
- 不校验客户端传来的游标:至少要做到"解不出来就降级成第一页"。
【衔接自己项目】
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 共用,先写在白板左上角)
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 分钟仍未结算/退款"的悬空记录
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 可读性好但不便于二次加工。两个都写一遍并说取舍是加分项。
面试官追问
- 能走索引吗? 能,前提是部分索引
ON credit_holds (status) WHERE status='held'(项目里就有)。它体积只与在途冻结数成正比,而held是少数派(绝大多数很快变 settled/refunded),所以索引很小、常驻内存。 - 固定 30 分钟会不会误报长任务? 会——长视频、批量复刻本来就跑几十分钟。这也是为什么自动修复逻辑必须按任务类型分流(视频编辑、CR 批次各有自己的对账函数)。更彻底的做法是引入
expected_duration按服务类型分档。 - 和
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. 找出"退款总额大于扣款总额"(多退/白嫖)——按金额而不是按笔数
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。
面试官追问
- 容差为什么是 0.01 而不是 0? 积分是
NUMERIC(10,2),历史上出现过浮点漂移(迁移注释写过"积分保留 2 位小数,避免浮点漂移后落库")。+0.01等于"多退超过 1 分钱才算异常"。容差是对账系统的可信度核心参数:太严格会告警失聪,太松会吞掉真问题。 GREATEST会不会盖住真实的"少扣"? 会——若两条链路都记了扣款且金额不同,取 max 看起来就"平了"。所以严格做法是先确认"同一个 task 不会同时出现在两张扣款表里";我们这个业务里确实不会(一条链路只走一种扣费方式),所以取 max 安全。能主动指出这个前提,比记住GREATEST更有价值。- 一条退款流水没有
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. 找出"余额与流水累计不一致"的用户(漂移检测)
-- ① 余额漂移: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列、不写流水"。前提说错,整个查询全错。
面试官追问
- 为什么"多退"(Q10)抓不到漂移? 因为多退时余额和流水是同步增加的(退款既加
credits又写流水),两边一致。所以两类异常必须分成两个查询:一个抓"金额对不上",一个抓"账实不符"。不同的漏钱形态需要不同的探测器。 - ① 和 ② 什么关系? ① 抓"余额 vs 流水",② 抓"总余额 vs 两个池子"。② 更细一层:
credits对了不代表池子对,可能"扣了赠送池却记成扣充值池"。项目注释写明了 ② 的定位:"上线第一次发布到 CHECK 约束生效之间的观察期里,这是唯一的守卫 —— 持续新增就说明还有写 credits 却没写池子的路径没改到。" - 成本会随时间爆炸吗? 主体是一次全表
GROUP BY user_id,正确性没问题,但流水表会一直长,全量聚合越来越慢。工程解法:① 增量对账(只看最近 N 小时流水);② 维护user_credit_snapshot日切快照表,只算"快照 + 快照之后的流水"。能主动说出"成本会随时间爆炸"是很好的工程意识。 - 跑出异常怎么办? 只打日志告警,不自动改数据。自动改会掩盖根因(把"某条路径没记账"变成"余额被悄悄修正了"),而且万一修正逻辑自己有 bug,就会把对的数据改成错的。只读巡检 + 人工/脚本修正是资金系统的保守选择。
扣分点:① 用 JOIN 不用 LEFT JOIN——没有流水的用户被整行丢掉,恰好漏掉最该看的那批;② 忘了排除 is_unlimited——无限号被长期误报,告警失聪(面试官一看就知道你没在真实系统里跑过对账);③ 忘了说"初始积分不写流水"这个前提;④ 把巡检写成会改数据的语句——巡检必须只读。
【衔接自己项目】 项目里有一套五类目的只读对账巡检
runCreditAudit():balanceDrift、splitDrift、duplicateRefund、stuckHolds、negativeBalance,跑完打一行汇总日志并返回结构化结果。可以直接背这句:"这五类各抓不同的漏钱形态,互补——一个查询抓不全,这就是为什么要有巡检这个模块而不是一条 SQL。"
Q12. "同一任务最多一条 held 记录"的部分唯一索引
-- 部分唯一索引:只对满足谓词的行做唯一性约束
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';行为验证(两个方向都要能说)
-- 方向一:同 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——这是容易被忽略的代码契约。
面试官追问
- 为什么不用"应用层先查再插"? 那是 TOCTOU(同 Q7):两个并发请求都查到"没有 held 记录",然后都插入,于是同一任务冻结两笔、扣两次余额。唯一索引是唯一能在并发下保证这件事的机制。
CREATE UNIQUE INDEX和ADD CONSTRAINT UNIQUE有区别吗? 有:约束不支持WHERE谓词,所以部分唯一索引只能用CREATE UNIQUE INDEX建。答对说明你真建过。- 为什么用
INSERT而不是ON CONFLICT DO NOTHING?DO NOTHING会把冲突静默吞掉,调用方无法区分"我插入了"和"已经存在了",而这两者后续动作完全不同。资金路径上我倾向于让冲突抛出、由调用方显式 catch,因为"静默成功"比"报错"危险(BullMQ 的 jobId 去重就是静默丢弃,我们为此踩过坑)。 - 这个约束的局限? 只防"同一 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. 用窗口函数找出每个用户最近一次消费记录
-- ① 每个用户最近一次消费
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只能排序。
面试官追问
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会在同一秒多笔时返回多行。- CTE 会不会拖慢性能? PostgreSQL 12+ 对 CTE 做内联优化,与子查询几乎无差别;PG 11 及以前 CTE 是优化屏障(强制物化),这个历史坑值得提一句。
- 百万行流水上会怎样?
PARTITION BY user_id要扫描全部 debit 行并排序,会成慢查询。解法:改成按用户查询(LATERAL+(user_id, created_at DESC)索引,每用户 O(log n)),或维护user_last_consume汇总表在写流水时同步更新。"什么时候该从实时聚合切到预聚合"是这道题的延伸考点。 - "只要最近一次消费在一个月内的用户"该怎么写? 过滤位置很关键:
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 视频生成平台的任务调度系统
一、需求澄清问题清单(先问,不要一上来就画框图)
- 生成耗时量级?秒级还是分钟级?(决定同步/异步——我们动辄几分钟,所以"下单即返回"是硬约束)
- 上游是谁?自研模型还是第三方供应商?有没有并发额度、有没有计费、会不会限流?(决定了要治理、要重试、要退赔)
- 结果怎么拿?上游支持 webhook 回调还是只能轮询?(决定轮询成本)
- 要不要收钱?扣费时机是提交前还是成功后?(决定要不要冻结/结算这套账本)
- 失败怎么算?谁承担成本——用户还是平台?(决定失败时"退款"还是"重试")
- 一致性要求?同一个任务能不能被生成两次(重复收费)?能不能接受"任务成功但用户看不到"?
- 规模?日任务量、峰值并发、任务平均与 P99 时长。(决定要不要分片、要不要拆队列)
- 能不能接受部分失败?批量任务(一次 5 个镜头)里坏一个怎么办?
- 任务的可见性?用户能不能取消、能不能重试、重试是不是新任务?(决定状态机里有没有 cancelled、重试是新建行还是复用 taskId)
- 运维约束?单机还是多实例、有没有 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 层 应用层判断 查余额 → 够则扣。**并发下必错**(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 倍"来讲。
二、按"崩的顺序"排(这个顺序本身就是答案)
- 单进程 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 密集任务会把事件循环的响应时间拉长,所有队列一起变慢——这不是"哪个队列满了",而是"整个进程变钝了"。 - "内存自管"的任务无法水平扩展:长视频、批量镜头复刻、脚本库一键生成这些链路的状态在进程内存里(
video_tasks里只有一行占位行),taskRecovery只能靠 Redis lease 猜。现在的恢复逻辑能跑,是因为只有一个进程。一旦起第二个副本,两边都会去"恢复"对方的任务,直接互相误杀。 - PG 连接池
max = 20:上限是进程级的。轮询队列 concurrency 10、加上提交/图片/转码/HTTP 请求,20 个连接很容易被打满;表现是"整个 API 变慢",而根因在某个后台批处理任务上——这是最难排查的一类故障(连接池是全局共享的隐藏耦合点)。 - 轮询与前端 3 秒轮询的读放大:我们主动轮询供应商(每 30 秒一次/任务),前端在"生成中"时每 3 秒轮询一次历史列表。100 倍任务量 = 100 倍轮询 + 100 倍前端查询,而其中绝大多数查询的答案没变。2026-08-04 那次"假 DDoS"就是自己的巡检和轮询把 Redis/DB 打满。
- Redis 是公网单点:连接要
keepAlive: 10000防 NAT 掐连接,enableOfflineQueue兜抖动。它是缓存、限流、分布式锁、BullMQ 后端四合一,一旦抖动或打满,全军覆没。100 倍量级下它是最先需要"拆分 + 内网化 + 独立实例"的组件。 - 没有 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. 如何设计一个能扛住上游抖动的重试与降级策略
一、需求澄清问题清单
- 上游的失败率与失败形态(超时/5xx/限流/业务拒绝)各占多少?
- 重试的代价是什么——是时间、是钱,还是可能产生副作用(重复下单)?
- 能不能判断"上游到底做没做"?有没有幂等键和状态查询接口?
- 用户侧能等多久?超时后要告诉他什么?
- 哪些功能可以降级(接旧模型、降分辨率、走另一家供应商),哪些必须 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),因为这时候继续只会产生无效任务和无效扣费。
- 画质增强失败 → 回落原片(fail-open 保交付),但必须吼一声
- 降级要能被观测:
enhancementOutcome这种字段要落库(executed/failed/skipped),否则"我们降级了多少次"永远没人知道。
四、如果面试官挑战 X 怎么答
- "fail-open 会不会亏钱?" → 会,所以要按"交付价值 vs 成本"逐个决策:增强失败回落原片,用户至少拿到成片(不交付才是更大的损失,还要退款);但音轨校验不过就不交付(交付一个没声音的成片,用户会投诉且要退款,成本更高)。能用"不交付的代价 vs 降级交付的代价"把每个决策讲一遍,这题就满分了。
- "你怎么知道重试策略是对的?" → 靠数据:
task_events表记着每次重试的原因,error_message存上游原文(我们曾因为写死一句"供应商返回任务失败",让它成了近 60 天里出现最多的一条错误信息,把真实原因全丢了)。没有这些数据,重试策略就是拍脑袋。 - "上游整体挂 30 分钟呢?" → 三条:① 队列积压 + 上游槽位租约会自然限流,不会把上游打死;② 提交阶段的失败要区分"确定没提交成功"(可安全重试)和"不确定"(转
SUBMISSION_UNCERTAIN挂起等对账);③ 对用户的诚实:与其让他等 2 小时后超时退款,不如让状态显示"排队中/上游繁忙",并在超时前主动退款。 - "重试会不会把上游打挂?" → 这正是 jitter 要解决的(Q2)。我们现在两个地方都没有 jitter(BullMQ 的 exponential 不带、上传重试不带),只是因为并发被上游槽位和
concurrency压住了才没事。这是我最清楚的一处技术债。
白板答题的通用套路
这七条比任何一道题的答案都重要——面试官记不住你写对了哪个函数,但一定记得住你"是不是一个有条理的人"。
- 先复述需求,再动手(30 秒)。用自己的话把题目念一遍,并明确说出输入/输出/边界:"
tasks是函数数组,limit是并发上限,结果按传入顺序返回——对吗?" 复述对了,后面写歪的概率降一半;复述时发现歧义,当场问。 - 先讲思路再写码,且明确说"我打算这样组织"。例如"我拆成两个函数:一个算延迟的纯函数、一个调度;这样抖动策略可以单独测"。面试官要看到的是结构感,不是打字速度。
- 写码时同步解释关键行,但不要逐行念。只在"这里有个坑"的地方停一下:"这一步必须用
SET NX一条命令,不能SETNX再EXPIRE,否则中间挂了就死锁。" - 主动说边界,宁可多说不写。边界意识是白板题最大的区分度:
limit=0、空数组、value 是undefined、同一毫秒的并列行、TTL 小于临界区耗时。每题说出 3 个边界,比多写 10 行代码值钱。 - 写完自测两个用例(而且是主动做,不要等面试官问):一个正常路径、一个边界或失败路径。"我用
[30ms, 10ms, 50ms, 5ms]跑一下,峰值并发应该是 2……对,是 2。再试一个抛错的任务,结果应该是 rejected 而不是整体崩。" - 主动接回项目:"这个模式在我们项目里的对应实现是
creditLock.ts,而且我们那里做了一个取舍——拿不到锁不阻断,因为……"。这一步把"会做题"变成"能落地",是最有效的加分动作。 - 收尾时主动交底不足:"这个实现没考虑 X,如果要上生产我会先补 Y。" 主动说出局限比被追问出来强得多;但只交底"有改进方向"的不足,不要交底"我写错了"。
现场时间分配建议:手写题 15~20 分钟/题(复述 1 分钟、思路 2 分钟、写码 8~10 分钟、自测与边界 3 分钟、接项目 2 分钟);系统设计题 30~40 分钟(澄清 5 分钟、分层 5 分钟、关键设计 15 分钟、留 10 分钟给面试官挑战)。面试官打断你时不要慌,那通常意味着他想往某个方向深挖——顺着他的方向走,但记得把没说完的那句"我给这个设计留的不变量是……"补回去。