编码面试:实现按键请求合并(Singleflight)
题目与场景
请实现一个并发安全的异步助手 coalesce(key, task)。同一个 key 在同一时刻只能有一个 task 执行,其他调用者等待并共享完全相同的结果或错误;不同 key 必须可以并行执行。
约束:task 可能同步抛错,也可能异步 reject;调用者可以设置自己的等待超时;任务成功或失败后都必须清理条目,使下一次调用可以重新执行。请说明取消、错误传播和测试策略。
面试官在考察什么
核心是把“去重”落实为可证明的并发不变量:先安装共享 promise,再开始异步工作;清理时确认 map 仍指向当前 entry;每个 key 独立;失败不会永久污染状态。面试官还会观察你是否区分“某个调用者停止等待”和“取消共享任务”,以及是否能防止内存泄漏。
澄清问题
- key 是否要求非空、是否需要规范化?我会拒绝空 key,避免无意把所有请求合成一组。
- 调用者超时是否应取消上游任务?默认只取消该调用者的等待,不影响其他等待者。
- 任务失败后是否缓存错误?本题不缓存,settled 后删除,下一次调用重试。
- 是否需要跨进程合并?本题限定单进程内存;跨进程需要共享存储或网关协调,属于另一层设计。
30 秒回答
我用 Map<key, Entry> 保存进行中的任务。进入函数后先查 map;命中就返回已有 promise。未命中时创建 entry,把 promise 放入 map,再启动 task。finally 中只有当 map 仍然指向这个 entry 才删除它。这样同 key 共享一次执行,不同 key 互不阻塞,成功和失败都会释放状态。调用者超时通过 Promise.race 停止等待,不取消共享任务;测试覆盖重复调用、不同 key、同步抛错、reject 后重试和清理竞态。
逐步拆解
先定义 Entry,保存共享 promise 和内部控制器(若产品需要真正取消)。关键顺序如下:
const inFlight = new Map<string, Promise<unknown>>();
function coalesce<T>(key: string, task: () => Promise<T>): Promise<T> {
if (!key) return Promise.reject(new Error("key must not be empty"));
const existing = inFlight.get(key);
if (existing) return existing as Promise<T>;
let shared: Promise<T>;
try {
shared = Promise.resolve().then(task);
} catch (error) {
shared = Promise.reject(error);
}
inFlight.set(key, shared);
shared.finally(() => {
if (inFlight.get(key) === shared) inFlight.delete(key);
}).catch(() => undefined);
return shared;
}实际实现可把 entry 对象放入 map,便于记录开始时间、等待者数量和 AbortController。Promise.resolve().then(task) 统一同步抛错与异步 reject。必须在第一次 await 之前写入 map,否则两个事件循环任务都可能看到空值并重复执行。清理使用 identity check,避免旧任务 finally 删除新一轮任务。
调用者超时属于外围策略:
function waitWithTimeout<T>(shared: Promise<T>, ms: number): Promise<T> {
return Promise.race([
shared,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("wait timeout")), ms),
),
]);
}共享任务仍会完成并服务其他等待者;若所有等待者都离开,可再设计引用计数后取消,但必须把该语义写进契约并测试。
复杂度:map 操作期望 O(1),每个 key 只有一个进行中任务;K 个不同 key 的内存占用为 O(K),一次结果通知的成本与等待者数量有关。生产环境还要限制 key 基数、记录执行时长和超时率,避免把 coalescing map 变成无界缓存。
高质量示范回答
我会先确认边界:这是单进程、只合并 in-flight 工作、不缓存最终结果。实现上使用 Map 保存 entry,并在发现缺失时立即创建 promise、立即登记,再执行用户 task。所有调用者拿到同一 promise,因此值和错误保持一致。finally 删除 entry 时比较引用,保证旧任务不会删掉新任务。
取消采用“取消等待、不取消共享工作”:一个调用者超时不会让其他调用者收到 AbortError,也不会中断唯一的上游任务。若业务必须取消,我会增加共享 AbortController 和等待者引用计数,只有引用归零才取消,并明确竞态规则。
测试方面,我会用 barrier 让多个调用同时进入,断言 task 只执行一次、所有调用收到同一值;再测试不同 key 并行、同步 throw、异步 reject、失败后下一次可重试、成功后再次调用会新建任务、旧任务 finally 不删除新 entry,以及单个调用超时不影响其他调用。最后加入高基数 key 的容量和观测指标。
常见错误
- 先
await task()再写入 map,竞态下会重复执行。 - finally 无条件
delete(key),旧任务可能删除新一轮 entry。 - 把一个调用者的 AbortSignal 直接传给共享任务,导致其他等待者被连带取消。
- 失败后不清理,后续请求永久复用 rejected promise。
- 用全局锁保护所有 key,让互不相关的请求串行化。
- 只测顺序调用,不测同时到达、同步抛错和清理竞态。
追问与回答
如何支持真正的取消?
何时需要跨进程合并?
如何防止高基数 key 泄漏?
真正取消需要共享控制器、引用计数和明确的零等待者策略。跨进程场景要把 in-flight 状态放到 Redis、网关或专用协调器,并处理租约、主节点故障和重复执行。高基数 key 可设置容量、TTL、拒绝策略和指标;这些机制不能改变“只保存进行中任务”的核心语义。