弱项补强 + 编码热身:rate limiter、LRU、并发控制、流式 JSON 解析
针对 D28 列出的弱项做定向补强,同时用四个高频编码题(限流器、LRU、并发控制、流式 JSON 解析)热身。
今日目标
- 能针对 D28 的弱项清单逐条完成补强并记录改进情况
- 能独立实现一个限流器(rate limiter)和一个 LRU 缓存
- 能实现一个简单的并发控制器和一个流式 JSON 解析器
昨天那两场模拟面试的产出不是"感觉还行",是一张四列的弱项清单。今天不新学东西,只做两件事:照着那张清单一条条补,再把四道最可能在白板上遇到的编码题练到能默写。读完回来把上面三条勾掉。
小白版讲解
弹错的永远是那两小节:分段慢练,不是从头再来
学过乐器的人都熟悉这个场景:一首曲子练了两周,每次弹错的都是第 17 到 18 小节那个转调。于是你从头再弹十遍——曲子越听越顺,那两小节还是错。
原因很朴素:从头到尾弹十遍,等于把已经对的部分又练熟了十遍,把错的部分又错了十遍。 肌肉记忆不区分对错,只记录重复次数。有效的做法是把那两小节单独拎出来、速度降到一半、盯着手指练 20 遍,再合回整曲。这叫分段慢练。
"泛泛复习"就是从头到尾再弹十遍。 把 30 天的笔记从 D1 翻到 D28,你会获得一种"都学过"的踏实感,而明天卡住的仍然是昨天卡住的那几处。所以今天的补强必须落在昨天那张清单上。
清单还是四列,列名不要改——现象 / 根因 / 最小练习动作 / 怎么验证。举一行你昨天很可能真写下来过的:
| 现象 | 根因 | 最小练习动作 | 怎么验证 |
|---|---|---|---|
| 讲 mini-koda 讲到第 8 分钟还没说到"我做了什么" | 按时间顺序讲开发过程,没有先给结论 | 写一段 90 秒的项目开场:一句定位、一句最难的点、一句结果,念熟 | 掐表录音,90 秒内讲完且不看稿 |
这一行为什么合格:现象可观察(8 分钟这个数字),根因指向一个具体习惯,动作足够小(90 秒的一段话,不是"练习表达"),验证能当场给出是或否。四列里最容易注水的是第三列——"多练几遍项目讲解"不是最小动作,"写一段 90 秒开场并念熟"才是。降速与分段,都体现在这一列里。
顺序也有讲究:先补"练一次就能提高"的,再补需要时间积累的。 自我介绍卡壳、STAR 里缺 R、系统设计不问需求就开画,都是当天能改掉的;而"算法手生"只能靠今天下午真的手敲几遍。
那为什么偏偏是限流器、LRU、并发控制、流式 JSON 解析这四道?因为它们不是随机抽的 LeetCode,这四道恰好是你这 30 天写过的东西的原型题,每一道背后都连着一个你已经理解的生产场景。更关键的是,它们网上到处都有标准答案,能拉开差距的从来不是代码本身。所以下面四节只讲四件事:面试官真正在考什么、你要主动说出的复杂度、三个边界用例、你放弃的另一种实现。
限流器:四种算法,实现选令牌桶
面试官真正在考什么:不是你能不能写出一个计数器,是你知不知道"限流"底下藏着四种语义不同的算法,以及会不会主动说出它们各自在什么时候出丑。
四种从简到繁:
| 算法 | 一句话 | 内存 | 致命弱点 |
|---|---|---|---|
| 固定窗口计数 | 每分钟一个计数器,跨分钟清零 | 一个整数 | 边界双倍 |
| 滑动窗口日志 | 存下每次请求的时间戳,判断最近一分钟有几条 | 与请求数同阶 | 高频下内存吃不消 |
| 滑动窗口计数 | 用上一窗口的计数按比例加权,近似滑动 | 两个整数 | 是近似值,突发时略有偏差 |
| 令牌桶 | 桶里匀速加令牌,来一个请求取一个 | 两个数 | 需要一个时间戳 |
边界双倍必须讲,它是这题最经典的追问。限每分钟 100 次,用户在 12:00:59 打满 100 次,时钟一跳到 12:01:00 计数器清零,他又能立刻打 100 次——跨越边界的这 2 秒实际放行了 200 次,下游是数据库或模型 API 的话足够打穿。滑动窗口计数就是为修这个而生的。
落地选令牌桶,因为它同时约束长期速率和瞬时突发:桶容量 100、每秒补 10 个,平均每秒 10 次,攒够了能一次爆发 100 次——这正是真实流量的样子,两个旋钮对应两个业务约束。
关键是惰性补充:不要起定时器每秒加令牌,十万用户就是十万个定时器,光调度开销就压垮机器。正确做法是取的时候现算,距上次操作多久就补多少。
// 令牌桶:容量 capacity,每秒匀速补 refillPerSec 个。
// 惰性补充——不起定时器,取的时候按「距上次补充过了多久」一次性补上。
// now 由调用方传入(毫秒),测试才能把时间快进,不用真的 sleep。
class TokenBucket {
constructor(capacity, refillPerSec, nowMs) {
this.capacity = capacity
this.refillPerSec = refillPerSec
this.tokens = capacity // 冷启动是满桶:允许一次 capacity 大小的突发
this.lastRefillMs = nowMs
}
tryAcquire(cost, nowMs) {
const elapsedMs = Math.max(0, nowMs - this.lastRefillMs) // 时钟回拨不倒扣也不白送
this.tokens = Math.min(this.capacity, this.tokens + (elapsedMs / 1000) * this.refillPerSec)
this.lastRefillMs = nowMs
if (this.tokens < cost) return false
this.tokens -= cost
return true
}
}import time
from dataclasses import dataclass, field
# 时间单位是秒,用 time.monotonic 的单调时钟——
# 它不受系统改时间影响,比拿 time.time 当基准省心。
@dataclass
class TokenBucket:
capacity: float
refill_per_sec: float
last_refill: float = field(default_factory=time.monotonic)
tokens: float = field(init=False)
def __post_init__(self) -> None:
self.tokens = self.capacity # 冷启动是满桶
def try_acquire(self, cost: float, now: float) -> bool:
elapsed = max(0.0, now - self.last_refill)
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_sec)
self.last_refill = now
if self.tokens < cost:
return False
self.tokens -= cost
return True// synchronized 是单机版必须加的:补充和扣减要在同一个临界区里,
// 否则两个线程都读到「还剩 1 个」,双双放行。时间单位是毫秒。
final class TokenBucket {
private final double capacity;
private final double refillPerSec;
private double tokens;
private long lastRefillMs;
TokenBucket(double capacity, double refillPerSec, long nowMs) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity; // 冷启动是满桶
this.lastRefillMs = nowMs;
}
synchronized boolean tryAcquire(double cost, long nowMs) {
long elapsedMs = Math.max(0L, nowMs - lastRefillMs);
tokens = Math.min(capacity, tokens + elapsedMs / 1000.0 * refillPerSec);
lastRefillMs = nowMs;
if (tokens < cost) return false;
tokens -= cost;
return true;
}
}// 时间单位是秒。跨任务共享时把它包成 actor 或加锁——
// 「补充 + 扣减」必须是一个不可分割的动作,这一点四种语言里都一样。
final class TokenBucket {
private let capacity: Double
private let refillPerSec: Double
private var tokens: Double
private var lastRefill: Double
init(capacity: Double, refillPerSec: Double, now: Double) {
self.capacity = capacity
self.refillPerSec = refillPerSec
self.tokens = capacity // 冷启动是满桶
self.lastRefill = now
}
func tryAcquire(cost: Double, now: Double) -> Bool {
let elapsed = max(0, now - lastRefill)
tokens = min(capacity, tokens + elapsed * refillPerSec)
lastRefill = now
guard tokens >= cost else { return false }
tokens -= cost
return true
}
}主动说出的复杂度:单次判断是常数时间,每个 key 只存两个数(令牌余额 + 上次补充时刻),空间是常数——这正是它相对滑动窗口日志最大的优势。
三个边界用例:冷启动桶是满的,第一波能放行 capacity 个、第 capacity 加一个被拒;同一毫秒内连续调用时间差是零,不能凭空补出令牌;空闲十分钟后回来,补充量必须被容量封顶,不能攒出六千个令牌把下游打死。第三条最常被漏掉,Math.min 那一行就是干这个的。第四条是时钟回拨,Math.max(0, ...) 保证既不倒扣也不白送。
放弃的另一种实现:滑动窗口日志。它最精确,放弃它是因为内存——限每分钟 1000 次、十万活跃用户,最坏要存一亿个时间戳。"更精确但内存与请求量同阶"这句话说出来,比闷头写令牌桶有价值得多。
-- token_bucket.lua:补充与扣减必须在同一次 EVAL 里完成
-- KEYS[1] = 桶的 key;ARGV = 容量、每秒补充数、当前毫秒时间戳、本次要几个
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local last = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - last) / 1000 * refill)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- 桶从空到满需要的时间,加一点余量,让长期不活跃的 key 自己过期
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) + 1000)
return allowed时间戳由调用方传进来而不是在脚本里取,是为了让脚本保持确定性,代价是各实例的时钟要大致对齐。这个取舍值得主动说,它是分布式限流第二常见的追问。
LRU 缓存:这题真正在考"你写的是哪门语言"
面试官真正在考什么:表面是哈希表加双向链表,实际考两层。第一层是你知不知道为什么必须是这两个结构的组合——哈希表给按键常数时间定位,双向链表给常数时间的"摘出来再挂到尾部",少任何一个都会退化成线性扫描。第二层更微妙:你在这门语言里写的是不是这门语言该有的样子。
这题恰好是四种语言差异最大的一道:JavaScript 的 Map 天生保插入顺序,delete 再 set 就把键提到最新,链表可以不写;Python 的 OrderedDict 有 move_to_end 和 popitem(last=False),两行搞定;Java 更狠,LinkedHashMap 构造器第三个参数 accessOrder 传 true 就是访问顺序,再覆写 removeEldestEntry,淘汰逻辑一行;Swift 标准库没有保顺序的字典,只能老实手写。
// JS 的 Map 保插入顺序,所以「提到最新」= delete 再 set,链表可以不写。
class LRUCache {
constructor(capacity) {
if (capacity <= 0) throw new Error('capacity 必须大于 0')
this.capacity = capacity
this.map = new Map()
}
get(key) {
if (!this.map.has(key)) return undefined
const value = this.map.get(key)
this.map.delete(key)
this.map.set(key, value) // 重新插到尾部 = 最近使用
return value
}
put(key, value) {
if (this.map.has(key)) this.map.delete(key) // 更新已有键同样要刷新顺序
this.map.set(key, value)
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value // 头部 = 最久未使用
this.map.delete(oldest)
}
}
}from collections import OrderedDict
class LRUCache:
"""Python 有 OrderedDict.move_to_end,比手写链表短一半。
真写业务缓存直接上 functools.lru_cache;这里手写是因为面试要看淘汰顺序。"""
def __init__(self, capacity: int) -> None:
if capacity <= 0:
raise ValueError("capacity 必须大于 0")
self.capacity = capacity
self.store: OrderedDict[str, int] = OrderedDict()
def get(self, key: str) -> int | None:
if key not in self.store:
return None
self.store.move_to_end(key) # 移到尾部 = 最近使用
return self.store[key]
def put(self, key: str, value: int) -> None:
if key in self.store:
self.store.move_to_end(key) # 更新已有键同样要刷新顺序
self.store[key] = value
if len(self.store) > self.capacity:
self.store.popitem(last=False) # 弹出头部 = 最久未使用import java.util.LinkedHashMap;
import java.util.Map;
// JDK 把这题内置了:accessOrder 传 true 就是 LRU 顺序,
// removeEldestEntry 每次插入后回调一次,返回 true 就淘汰头部。
final class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LRUCache(int capacity) {
super(16, 0.75f, true); // true = 按访问顺序,get 也会把条目挪到尾部
if (capacity <= 0) throw new IllegalArgumentException("capacity 必须大于 0");
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}// Swift 标准库没有「保顺序的字典」,只能哈希表 + 双向链表手写。
// 所以这份反而最接近面试官说「不许用内置有序容器」时想看到的答案。
final class LRUCache {
private final class Node {
let key: String
var value: Int
var next: Node?
weak var prev: Node? // prev 必须 weak,否则前后互指形成循环引用、永不释放
init(key: String, value: Int) {
self.key = key
self.value = value
}
}
private let capacity: Int
private var map: [String: Node] = [:]
private let head = Node(key: "", value: 0) // 哨兵,省掉一半空判断
private let tail = Node(key: "", value: 0)
init(capacity: Int) {
precondition(capacity > 0, "capacity 必须大于 0")
self.capacity = capacity
head.next = tail
tail.prev = head
}
func get(_ key: String) -> Int? {
guard let node = map[key] else { return nil }
moveToTail(node) // get 也算一次使用
return node.value
}
func put(_ key: String, _ value: Int) {
if let node = map[key] {
node.value = value
moveToTail(node)
return
}
let node = Node(key: key, value: value)
map[key] = node
insertBeforeTail(node)
// 先插入再淘汰,顺序反了会在容量为 1 时把刚放进来的自己淘汰掉
if map.count > capacity, let oldest = head.next, oldest !== tail {
unlink(oldest)
map.removeValue(forKey: oldest.key)
}
}
private func unlink(_ node: Node) {
node.prev?.next = node.next
node.next?.prev = node.prev
node.next = nil
node.prev = nil
}
private func insertBeforeTail(_ node: Node) {
let last = tail.prev
last?.next = node
node.prev = last
node.next = tail
tail.prev = node
}
private func moveToTail(_ node: Node) {
unlink(node)
insertBeforeTail(node)
}
}看出差别了吗:同一道题,JS 和 Python 写 20 行,Java 写 15 行,Swift 写 60 行。 这不是谁强谁弱,是标准库的边界划在了不同位置。面试时用哪门语言就写哪门语言的惯用法,把 Swift 那套手写链表原样搬进 JS,面试官会认为你不熟悉 Map。
但要准备一个反手:面试官很可能追一句"不许用内置的有序容器"。这时上面那份 Swift 版就是标准答案的形状——哨兵头尾节点、unlink 与 insertBeforeTail 两个私有方法、moveToTail 由这两个拼出来。四种语言的手写版结构一样,只是内存管理不同:Swift 要把 prev 声明成弱引用防循环引用,Java 和 JS 交给垃圾回收。
主动说出的复杂度:get 与 put 都是常数时间(哈希定位 + 链表指针改写),空间与容量同阶。要点明"常数时间"是均摊的——哈希表扩容那一次是线性的。这一句会让面试官知道你不是在背结论。
三个边界用例:get 命中也必须刷新顺序(最常见的错是只在 put 里刷新,热点数据反被淘汰);put 已存在的键除了改值也要刷新顺序,不能当新键插入导致容量算错;容量为 1 时连续放两个键必须先插后淘汰,顺序反了会把刚放进来的立刻淘汰掉。
放弃的另一种实现:只用一个哈希表,每个值挂一个"最后访问时间戳",淘汰时扫全表找最小的。五分钟能交卷,但淘汰是线性的——缓存越大越慢,而缓存本来就是为了快。说出"我知道有这种写法,因为淘汰是线性的才放弃",比直接写对更能证明你在做选择。
并发控制:Promise.all 根本不控并发
面试官真正在考什么:这题几乎是为 Agent 岗定做的。你的 Agent 要并行调 20 次工具、批量给 500 个文档做 embedding、同时开 8 路子任务——一次全放出去,要么把下游打到限流,要么把内存吃穿。所以面试官要确认的是:你分不分得清"等待一批任务"和"限制同时运行的任务数"这两件完全不同的事。
最典型的错误答案:把 500 个任务全 map 成 Promise 再 Promise.all 一下。这段代码的并发度就是 500——Promise.all 只负责等它们结束,而 Promise 一被创建,里面的请求就已经发出去了。同样的误解在 Python 里叫"用 asyncio.gather 控并发",在 Java 里叫"用 CompletableFuture.allOf 控并发"。
正确的形状只有两种:固定数量的工人从同一个任务队列里取活(JS 的惯用法),或用信号量挡在任务启动之前(Python、Java 的惯用法);Swift 要用 TaskGroup 自己开一个滑动窗口。
// 限制同时运行的异步任务数为 limit。
async function mapWithLimit(items, limit, worker) {
const results = new Array(items.length)
let cursor = 0
// 一个 runner 就是一个并发槽位,槽位数固定为 limit
async function runner() {
while (cursor < items.length) {
const index = cursor++
try {
results[index] = { ok: true, value: await worker(items[index]) }
} catch (error) {
// 失败也必须把槽位还回去:这里不接住,runner 直接退出,
// 这个槽位就永久空着,并发度从 limit 悄悄掉下去。
results[index] = { ok: false, reason: String(error) }
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner))
return results
}import asyncio
from typing import Awaitable, Callable, TypeVar
T = TypeVar("T")
async def map_with_limit(
items: list[T], limit: int, worker: Callable[[T], Awaitable[object]]
) -> list[dict[str, object]]:
sem = asyncio.Semaphore(limit)
async def run(item: T) -> dict[str, object]:
# async with 离开作用域一定 release,等价于 try/finally,
# 任务抛异常也漏不掉槽位——这是 Python 版最省心的地方。
async with sem:
try:
return {"ok": True, "value": await worker(item)}
except Exception as exc:
return {"ok": False, "reason": str(exc)}
# gather 只负责收结果,控并发的是上面那个信号量,不是 gather
return list(await asyncio.gather(*(run(item) for item in items)))// 依赖:java.util.concurrent(JDK 17+)
// acquire 放在提交之前:主线程先被挡住,任务队列就不会无限堆积,天然带背压。
static <T, R> List<String> mapWithLimit(List<T> items, int limit, Function<T, R> worker)
throws InterruptedException {
var sem = new Semaphore(limit);
var pool = Executors.newCachedThreadPool();
var futures = new ArrayList<CompletableFuture<String>>();
try {
for (T item : items) {
sem.acquire();
futures.add(CompletableFuture.supplyAsync(() -> {
try {
return "ok:" + worker.apply(item);
} catch (RuntimeException e) {
return "err:" + e.getMessage();
} finally {
sem.release(); // 成功失败都要还,这一行就是本题的题眼
}
}, pool));
}
return futures.stream().map(CompletableFuture::join).toList();
} finally {
pool.shutdown();
}
}// withTaskGroup 会把 addTask 进去的任务全部并发跑,本身不限流。
// 控并发要自己开窗口:先塞满 limit 个,之后每收一个结果才补一个。
func mapWithLimit<T: Sendable>(
_ items: [T],
limit: Int,
worker: @escaping @Sendable (T) async throws -> String
) async -> [String] {
var results = [String](repeating: "", count: items.count)
await withTaskGroup(of: (Int, String).self) { group in
var next = 0
func submit(_ index: Int) {
let item = items[index]
group.addTask {
// 错误必须在任务内部收敛成结果值:让它抛出去,
// 整个 group 会被取消,剩下的任务连槽位带结果一起没了。
do { return (index, "ok:" + (try await worker(item))) } catch {
return (index, "err:\(error)")
}
}
}
while next < min(limit, items.count) {
submit(next)
next += 1
}
for await (index, value) in group {
results[index] = value
if next < items.count {
submit(next)
next += 1
}
}
}
return results
}四份代码的"释放槽位"写在不同地方,说的是同一件事:槽位的归还必须发生在任何退出路径上。Java 写在 finally 里,Python 靠 async with 自动做,Swift 靠在任务内部把错误收敛成结果值(否则整个 group 会被取消),JS 靠 while 循环里的 try 与 catch——异常一旦穿透出 runner,这个 runner 就退出了,等于永久丢掉一个槽位。忘掉这一步的代码在 happy path 上完全正常,只有下游开始报错时才会越来越慢直到彻底卡死,是最难查的那类 bug。
主动说出的复杂度:总耗时约等于任务数除以并发度再乘单任务耗时;同时驻留的内存与并发度同阶而不是与任务数同阶——后半句才是真正的收益。
三个边界用例:任务列表为空时不能死等;并发度大于任务数时不该开出多余的 runner(Math.min(limit, items.length) 那一行两条都顺带处理了);某个任务抛错时其余任务必须跑完,且结果里能区分成功和失败。
放弃的另一种实现:直接用现成的库(JS 的 p-limit、Python 的 aiometer)。生产里当然该用库,但面试要能手写,并主动说"生产我用库,手写是为了说明我知道它在做什么"。
流式 JSON 解析:先问清楚是哪一种
面试官真正在考什么:这题的区分度几乎全在你开口的第一句话。听到"流式 JSON 解析"就动手写状态机的人,会花 25 分钟写一个大概率有 bug 的东西;先反问一句"是一行一个完整 JSON,还是一个大对象被切成很多片"的人,已经赢了一半。
两种情况的工程量差一个数量级:
- (a) 一行一个完整 JSON。SSE 就是这样:每条事件是一行
data:开头的文本,行内是完整 JSON。LLM 场景 99% 是这一种,解法只是行缓冲加逐行解析,二十行代码。 - (b) 单个大 JSON 对象跨分片到达,比如模型返回一个巨大的结构化结果、你想边传边渲染。这才需要真正的增量解析。
所以答题顺序是:先说清这个区分,再给 (a) 的完整实现,最后说明 (b) 什么条件下才值得做。上来就写状态机是过度设计,而过度设计在面试里的扣分和写不出来是一样的。
// (a) SSE 那种「一行一个完整 JSON」:行缓冲 + 逐行 parse。
function createLineParser(onEvent) {
let buffer = ''
return function feed(chunk) {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // 最后一段可能是半行,留到下一轮(D1 那个坑)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue // 空行、注释行、event 行都跳过
const payload = trimmed.slice(5).trim()
if (payload === '[DONE]') return // 它不是 JSON,拿去 parse 必炸
onEvent(JSON.parse(payload))
}
}
}import json
from typing import Any, Callable
def make_line_parser(on_event: Callable[[dict[str, Any]], None]) -> Callable[[str], None]:
buffer = ""
def feed(chunk: str) -> None:
nonlocal buffer
buffer += chunk
# 星号解包一步到位:前面的都是完整行,最后一段是半行,留到下一轮
*lines, buffer = buffer.split("\n")
for line in lines:
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
return
on_event(json.loads(payload))
return feed// 依赖:Jackson(com.fasterxml.jackson.databind)
// Java 没有「按分隔符切并保留余数」的现成 API,用 StringBuilder 手写行缓冲。
final class LineParser {
private final StringBuilder buffer = new StringBuilder();
private final ObjectMapper mapper = new ObjectMapper();
private final Consumer<JsonNode> onEvent;
LineParser(Consumer<JsonNode> onEvent) {
this.onEvent = onEvent;
}
void feed(String chunk) throws JsonProcessingException {
buffer.append(chunk);
int nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
String line = buffer.substring(0, nl).strip();
buffer.delete(0, nl + 1); // 剩下的半行留在 buffer 里,等下一轮
if (!line.startsWith("data:")) continue;
String payload = line.substring(5).strip();
if (payload.equals("[DONE]")) return;
onEvent.accept(mapper.readTree(payload));
}
}
}// 真实代码里 URLSession 的 bytes(for:).lines 已经替你做完了分包与半行拼接(D1 讲过),
// 这里手写一份是为了在白板上写得出来。Swift 有 Codable,直接解成具体类型。
struct StreamEvent: Decodable {
let delta: String
}
final class LineParser {
private var buffer = ""
private let decoder = JSONDecoder()
private let onEvent: (StreamEvent) -> Void
init(onEvent: @escaping (StreamEvent) -> Void) {
self.onEvent = onEvent
}
func feed(_ chunk: String) {
buffer += chunk
var parts = buffer.components(separatedBy: "\n")
buffer = parts.removeLast() // 最后一段可能是半行,留到下一轮
for line in parts {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("data:") else { continue }
let payload = trimmed.dropFirst(5).trimmingCharacters(in: .whitespaces)
guard payload != "[DONE]" else { return }
guard let data = payload.data(using: .utf8),
let event = try? decoder.decode(StreamEvent.self, from: data)
else { continue }
onEvent(event)
}
}
}这四份代码你在 D1 和 D25 都见过近亲——D1 是命令行里的打字机,D25 是浏览器里的逐字渲染,今天是白板上的十五分钟版。同一个行缓冲,第三次出现了,所以它值得练到默写。
主动说出的复杂度:每个字符只被扫描常数次,时间与字节总数同阶;内存只驻留一个未完成的行,是常数级的——"不需要把整个响应攒在内存里"正是流式解析存在的理由。
三个边界用例:分片切在 JSON 中间(靠缓冲区留半行,四份代码里那一行 pop 或 removeLast 就是干这个的);分片切在 data: 前缀中间(同样靠行缓冲兜住,没遇到换行就不处理);收尾的 [DONE] 不是合法 JSON,拿去解析必然抛异常。还有隐藏的第四条,见下面的提醒。
那 (b) 呢?真需要增量解析一个大对象时,核心是三个状态变量:括号深度(深度回到零说明一个完整对象结束)、是否在字符串内部(字符串里的括号不能计入深度)、前一个字符是不是反斜杠(转义中的引号不切换字符串状态)。三者缺一不可,少一个,遇到含括号的文本就会算错深度。
状态:depth=0 inString=false escaped=false
读到 '{' 且 inString=false → depth+1
读到 '}' 且 inString=false → depth-1;depth 归零则吐出一个完整对象
读到 '"' 且 escaped=false → inString 取反
读到 '\' 且 inString=true → escaped=true(只对下一个字符生效)
其余字符 → escaped=false放弃的另一种实现:等全部数据到齐再一次性解析。功能上永远正确,放弃它的唯一理由是延迟——用户要等模型把 800 个字生成完才看到第一个字,首字延迟从 300 毫秒变成 12 秒。"我知道最简单的做法是等齐了再解析,因为首字延迟才不这么做",这句话把技术选择和用户体验连了起来,是这题最好的收尾。
源码导读
动手实验
今天的产出不是一个新项目,是四段你能默写出来的代码,外加一份写进弱项清单第四列的验证记录。所以别开自动补全,也别复制上面的代码——白板上没有这两样东西。
热身的判据只有一条:闭卷、限时、能跑。写完立刻用正文列出的边界用例跑一遍,跑挂了就地改,改完记下错在哪——你今天错的那一处,明天大概率还会错在同一处。
- 先花 60 分钟补弱项:摊开 D28 那张四列清单,挑出「练一次就能改掉」的两到三条,逐条执行第三列的最小动作,按第四列的方式当场验证,结果写回清单。
- 闭卷 15 分钟手写令牌桶,跑通四个边界用例:冷启动突发、同一时刻重复取、空闲后被容量封顶、时钟回拨。写完再花 5 分钟把那段 Lua 脚本默一遍。
- 闭卷 15 分钟手写 LRU:先用主语言的内置有序容器写一版,再假装面试官说「不许用」,用哈希表加双向链表重写一版。两版都要通过「get 也刷新顺序」。
- 闭卷 15 分钟手写并发控制器,用一个会随机抛错的假任务验证:并发峰值不超过 limit,某个任务失败后其余任务仍然跑完,空列表不死等。
- 闭卷 20 分钟手写流式解析器:先说出「是哪一种」,再写行缓冲版,喂给它三段故意切在 JSON 中间的分片,确认拼回来的内容完整无缺。
- 最后 10 分钟做记录:四道题各自卡在哪一步、超时几分钟、错了几次,写进补强记录,作为明天复盘的输入。
面试题
今天 3 道题在下方题库区,全是编码题的口头版——面试官经常先问思路再让你写,答不好思路,代码写得再对也拿不到高分。LRU 的考点已经在正文里讲透了,题库里换上区分度更高的三道。展开后先看"分析过程"再看要点,重点看每道题最后那条追问,它们是这四道题最常见的第二问。
检查清单与明日预告
- 能针对 D28 的弱项清单逐条完成补强并记录改进情况
- 能独立实现一个限流器(rate limiter)和一个 LRU 缓存
- 能实现一个简单的并发控制器和一个流式 JSON 解析器
- 能说清固定窗口的边界双倍问题,以及为什么分布式限流要用 Lua 脚本
- 能在被要求"不许用内置有序容器"时,手写出哈希表加双向链表版的 LRU
- 四道题闭卷手写都在限时内跑通了三个边界用例,卡壳的地方已写进补强记录
- 3 道面试题不看要点也能答出至少 2 道
明天(D30)是最后一天,我们做三件事:把 30 天的题库全量过一遍、用三色标记挑出真正的薄弱项,把四周内容收成一张标出前置关系的知识地图,再定下第二个月的投递节奏并把这个网站正式上线。今天练的是单点动作,明天要把这些单点连成一张你自己讲得出来的图——先有能力,再有结构,最后才是讲给别人听,顺序反过来就成了空谈。
面试题库
限流器有哪几种常见算法?各自的优缺点是什么?如果只能落地一种,你选哪个?What are the common rate limiting algorithms, what are their trade-offs, and which one would you actually ship?
国内高频海外高频基础#rate-limiting#concurrency分析过程 · 先想清楚再作答
- 这题在考「你知不知道限流有多种语义」,而不是「你会不会写计数器」。只答出一种算法的人,会被默认没做过真正的流量治理。
- 先把四种按复杂度排开再逐个给弱点:固定窗口最省内存但有边界双倍;滑动窗口日志最精确但内存和请求数同阶;滑动窗口计数是近似解、内存回到常数;令牌桶允许突发、内存常数。这个排列顺序本身就是答案的骨架。
- 边界双倍要用具体数字讲,它是本题最常见的追问:限每分钟 100 次,用户在 12:00:59 打满 100 次,12:01:00 计数器清零又能打 100 次,跨边界的这 2 秒实际放行了 200 次。说不出这个例子,等于没答第一问。
- 结论选令牌桶,理由要落在业务形状上:真实流量本来就是突发的,令牌桶同时约束了长期速率(补充速度)和瞬时突发(桶容量),两个旋钮分别对应两个业务问题。实现上必须是惰性补充——取的时候按时间差现算,不要给每个用户起一个定时器,十万用户就是十万个定时器。
- 生产视角:单机内存版只在单实例下成立。多个网关实例共享配额时,「读余额 → 算补充 → 写回」三步之间一定有竞态,两个实例都读到「还剩 1 个」就会双双放行。修法是把三步塞进一段 Redis Lua 脚本,靠单线程执行整段脚本拿到原子性——用 Lua 不是为了快,是为了把三条命令粘成一条。
- 可以预期的追问:脚本里为什么不直接取当前时间?因为那会让脚本变得不确定,时间戳应该由调用方传进来;代价是各实例的时钟要大致对齐,这个取舍要主动说出口。
How to reason about it · think before answering
- This question tests whether you know rate limiting has several distinct semantics, not whether you can write a counter. Naming only one algorithm reads as never having run real traffic.
- Lay the four out by complexity and attach a weakness to each: fixed window is cheapest but has the boundary burst; sliding window log is exact but its memory grows with request count; sliding window counter is an approximation with constant memory; token bucket allows bursts with constant memory. That ordering is the skeleton of a good answer.
- Make the boundary burst concrete, because it is the standard follow-up: with a 100-per-minute limit, a client can spend 100 at 12:00:59 and another 100 the instant the counter resets at 12:01:00 — 200 requests inside two seconds, double the quota.
- Pick the token bucket and justify it by traffic shape: real traffic is bursty, and the bucket gives you two independent knobs — refill rate caps the long-run rate, capacity caps the burst. Implement it with lazy refill: compute the top-up from the elapsed time when a token is requested, never run a timer per user.
- Production angle: the in-memory version only holds for a single instance. Across gateway replicas, read-compute-write has a race and two replicas can both see 'one token left' and both allow. Fix it with a Redis Lua script so refill and deduction happen in one atomic step — Lua is not for speed here, it is for gluing three commands into one.
- Expect the follow-up: why not read the clock inside the script? Because that makes the script non-deterministic. Pass the timestamp in from the caller, and say the cost out loud — replica clocks now have to be roughly aligned.
答题要点
- 四种算法:固定窗口(省内存但边界双倍)、滑动窗口日志(精确但内存与请求数同阶)、滑动窗口计数(近似、常数内存)、令牌桶(允许突发、常数内存)
- 固定窗口的边界双倍:跨窗口交界的 2 秒内可以放行两倍配额,下游是数据库或模型 API 时足以打穿
- 落地选令牌桶:补充速度管长期速率、桶容量管瞬时突发,两个旋钮对应两个真实业务约束
- 必须用惰性补充:取令牌时按时间差现算,不要为每个 key 起定时器
- 分布式版把补充与扣减写进一段 Redis Lua 脚本,先 GET 再 SET 一定有竞态;时间戳由调用方传入以保持脚本确定性
Key points
- Four algorithms: fixed window (cheap, boundary burst), sliding window log (exact, memory grows with requests), sliding window counter (approximate, constant memory), token bucket (bursty, constant memory)
- The fixed-window boundary burst lets twice the quota through in the two seconds around a window edge, which is enough to overload a database or model API
- Ship the token bucket: refill rate bounds the long-run rate and capacity bounds the burst, two knobs for two real constraints
- Use lazy refill — top up from elapsed time on access instead of running one timer per key
- For the distributed version, put refill and deduction in one Redis Lua script; a GET followed by a SET always races. Pass the timestamp in to keep the script deterministic
怎么实现一个限制并发数的调度器?为什么不能直接用 Promise.all 或者 asyncio.gather?How would you build a scheduler that caps in-flight async tasks, and why is Promise.all or asyncio.gather not enough?
国内高频海外高频进阶#concurrency#async分析过程 · 先想清楚再作答
- 题眼在后半句。面试官在确认你分不分得清「等待一批任务」和「限制同时运行的任务数」——这两件事在 API 名字上很像,在语义上毫无关系。
- 先说破错误答案为什么错:把 500 个任务全部映射成 Promise 再一起 await,这段代码的并发度是 500。Promise 一被创建,它内部的请求就已经发出去了,await 只是在等结果;gather 和 CompletableFuture.allOf 是同一个坑的另外两种口音。
- 再给正确形状的两条路:固定数量的工人从同一个游标取任务(JS 的惯用法,槽位就是工人本身),或者用信号量挡在任务启动之前(Python 的 asyncio.Semaphore、Java 的 Semaphore)。Swift 要用 TaskGroup 自己开滑动窗口,先塞满 limit 个、每收一个结果补一个。
- 本题真正的失分点是槽位泄漏:acquire 之后必须在 finally 里 release,或者把错误在任务内部收敛成结果值。忘了这一步的代码在 happy path 上完全正常,只有下游开始报错时才会一点点变慢直到彻底卡死——这是最难查的那类 bug,因为症状出现在故障之后而不是之中。
- 落到 Agent 场景说收益:批量 embedding、并行工具调用、多路子任务都靠它。收益不只是「不打爆下游」,还有同时驻留的内存与并发度同阶而不是与任务数同阶。
- 可以预期的追问:如果任务本身还要重试呢?答案是重试要在槽位内部完成(占着槽位退避重试),否则重试风暴会绕过限流;再追一层就是给重试加抖动,避免所有失败任务在同一时刻一起回来。
How to reason about it · think before answering
- The hinge is the second half. They are checking whether you separate 'await a batch' from 'cap how many run at once' — similar API names, unrelated semantics.
- Name the wrong answer first: mapping 500 items to promises and awaiting them together runs at concurrency 500. Creating the promise already fired the request; awaiting only collects results. gather and CompletableFuture.allOf are the same trap in other accents.
- Then give the two correct shapes: a fixed set of workers pulling from a shared cursor (the JS idiom, where a worker is the slot), or a semaphore gating task start (asyncio.Semaphore, java.util.concurrent.Semaphore). Swift needs a manual window over a TaskGroup — fill limit slots, then add one task per result received.
- The real failure mode is slot leakage: release must happen in a finally, or the error must be collapsed into a result value inside the task. Code that misses this looks perfect on the happy path and only degrades once the downstream starts failing, which makes it one of the hardest bugs to trace.
- Tie it to agents: batch embedding, parallel tool calls, fan-out subtasks. The benefit is not only sparing the downstream — peak memory now scales with the concurrency limit instead of the task count.
- Expect the follow-up: what if tasks retry? Retries must happen inside the slot, otherwise a retry storm bypasses the limiter entirely. One level deeper: add jitter so failed tasks do not all come back at the same instant.
答题要点
- Promise.all 与 asyncio.gather 只负责等待,任务在被创建的那一刻就已经启动了,并发度等于任务总数
- 两种正确形状:固定数量的工人从共享游标取任务,或者用信号量挡在任务启动之前
- 槽位必须在任何退出路径上归还:Java 写在 finally 里,Python 用 async with,Swift 把错误收敛成结果值,JS 在循环里 try 与 catch
- 槽位泄漏的症状是「下游一开始报错就越来越慢直到卡死」,happy path 完全看不出来
- 收益是同时驻留的内存与并发度同阶,而不是与任务总数同阶;重试要占着槽位做,并加抖动
Key points
- Promise.all and asyncio.gather only wait; the work started when each promise was created, so concurrency equals the task count
- Two correct shapes: a fixed worker set pulling from a shared cursor, or a semaphore gating task start
- The slot must be returned on every exit path — finally in Java, async with in Python, error-to-value inside a Swift task, try/catch inside the JS loop
- A leaked slot shows up as gradual slowdown to a full stall once the downstream starts erroring, and is invisible on the happy path
- The payoff is peak memory scaling with the concurrency limit rather than the task count; retries must stay inside the slot and carry jitter
为什么流式场景下不能直接用 JSON.parse?你会怎么做增量解析?Why can't you just call JSON.parse in a streaming response, and how would you parse incrementally?
国内高频海外高频深入#streaming#json-parsing分析过程 · 先想清楚再作答
- 这题的区分度几乎全在你开口的第一句话。听到「流式 JSON 解析」就动手写状态机的人,会花二十多分钟写一个大概率有 bug 的东西;先反问一句「是一行一个完整 JSON,还是一个大对象被切成很多片」的人,已经赢了一半。
- 先回答为什么不能直接解析:网络分包不认语法边界,一次读取拿到的很可能是半个 JSON。直接扔给解析器只会抛异常,而且这个异常没有任何可恢复的信息。
- 然后做那个关键区分。情况 a 是 SSE:每条事件是一行以 data 开头的文本,行内是完整 JSON,LLM 场景 99% 是这一种,解法是行缓冲加逐行解析,二十行代码——把切分出来的最后一段(可能是半行)留在缓冲区里,等下一次读到更多数据再拼。情况 b 是单个大对象跨分片到达,才需要真正的增量解析。
- 情况 b 的核心是三个状态变量:括号深度(深度归零说明一个完整对象结束)、是否在字符串内部(字符串里的括号不能计入深度)、前一个字符是不是反斜杠(转义中的引号不切换字符串状态)。三者缺一不可,少一个遇到含括号的文本就算错深度。
- 还有一条几乎没人主动说、但一说就加分的坑:分片是按字节切的,一个汉字在 UTF-8 里占三个字节,边界可能落在中间。必须用流式解码器(TextDecoder 的 stream 选项、Python 的增量解码器、Java 的 InputStreamReader),否则会拿到一个永远补不回来的乱码字符。这是「半行缓冲」在字节层的同款问题。
- 可以预期的追问:那结尾那个终止标记怎么办?答案是它不是 JSON,必须在解析前先判断并直接返回,拿它去解析必然抛异常——这是这道题里最常见的一行 bug。
How to reason about it · think before answering
- Almost all the signal is in your first sentence. Whoever starts writing a state machine will spend twenty-plus minutes on something probably buggy; whoever first asks 'is it one complete JSON per line, or one big object split across chunks?' has already won half the question.
- Answer the why first: network chunking ignores syntax boundaries, so a single read often holds half a JSON document. Handing that to a parser only throws, and the exception carries nothing you can recover from.
- Then draw the distinction. Case (a) is SSE: each event is one line prefixed with data, holding one complete JSON object. This covers 99% of LLM work, and the fix is line buffering plus per-line parsing — keep the trailing fragment in the buffer and stitch it onto the next chunk.
- Case (b) — one large object arriving in pieces — is the only case needing real incremental parsing, and it rests on three state variables: bracket depth (back to zero means a complete object), whether you are inside a string (brackets in text must not count), and whether the previous character was a backslash (an escaped quote must not toggle string state). Drop any one and text containing brackets breaks the depth count.
- One trap almost nobody volunteers: chunks are split on bytes, and a CJK character takes three bytes in UTF-8, so a boundary can land mid-character. Use a streaming decoder — TextDecoder with the stream option, an incremental decoder in Python, InputStreamReader in Java — or you get a replacement character you can never recover. It is the half-line problem one layer down.
- Expect the follow-up: what about the terminator line? It is not JSON, so check for it and return before parsing. Feeding it to the parser is the single most common one-line bug in this question.
答题要点
- 网络分包不认语法边界,一次读取可能拿到半个 JSON,直接解析必然抛异常且不可恢复
- 先问清是哪一种:一行一个完整 JSON(SSE,占 LLM 场景的绝大多数)还是一个大对象跨分片到达
- 前者只需行缓冲加逐行解析:把最后一段可能的半行留在缓冲区,等下一次读到更多数据再拼
- 后者才需要状态机,核心是括号深度、是否在字符串内部、前一个字符是否为转义反斜杠三个状态
- 字节层还有一个同款坑:UTF-8 多字节字符可能被分片切开,必须用流式解码器;结尾的终止标记不是 JSON,解析前要先判断
Key points
- Network chunking ignores syntax boundaries, so a read can hold half a document; parsing it throws an unrecoverable error
- Ask which case it is first: one complete JSON per line (SSE, the overwhelming majority of LLM work) or one large object split across chunks
- The first case only needs line buffering plus per-line parsing, keeping the trailing partial line for the next chunk
- Only the second case needs a state machine, tracking bracket depth, inside-string, and escaped-previous-character
- One layer down, a multi-byte UTF-8 character can be split across chunks, so use a streaming decoder; and the terminator line is not JSON, so check for it before parsing
评论
登录后即可参与讨论
还没有评论,来说第一句。