面试 Q&A — 限流与并发控制#
后端面试高频考点。本文档从算法原理讲到工程实现,再到真实压测数据验证, 覆盖固定窗口 / 滑动窗口 / 令牌桶的对比、Lua 原子性、分布式并发槽、面试追问链。
一、算法选型#
Q1: 举个具体例子,运维配错了 Alertmanager 导致 300 QPS 打过来,限流怎么挡住的?#
场景:某团队的 Alertmanager 配了 group_interval: 0s(正常应该 5m),同一批告警每秒重推一次,叠加多条规则同时触发,瞬时 300 请求/秒 打到 webhook 接口。
从一个请求的视角走一遍限流链路:
-
取 identity:
X-API-Key头有值用 API Key,没有取X-Forwarded-For第一段 IP。假设是10.0.1.50。 -
第一道:IP 维度(50/s)——Redis key
rate_limit:webhook_key:10.0.1.50:{时间桶},Lua 脚本原子INCR + EXPIRE。第 1-50 个请求 count ≤ 50 放行;第 51 个 count=51 > 50 → 返回(False, retry_after),API 返回 HTTP 429 +Retry-After头。 -
第二道:receiver 维度(500/min)——只有第一道通过了才到这里。每秒放过 50 个 × 60 秒 = 理论 3000/min,但这道限额 500/min → 第 501 个请求被拦。
-
Alertmanager 收到 429 → 读
Retry-After头,退避后重推。被 429 的大多是重复告警,新告警在下一个窗口(1 秒后)就能通过。
300 QPS 持续 8 秒的压测结果:
| 阶段 | QPS | 成功率 | P99 延迟 | 被拦数 |
|---|---|---|---|---|
| 预热(正常流量) | 10 | 100% | 43ms | 0 |
| 突发(配错) | 300 | 19.3% | 30ms | 1,936 |
| 回落(修复后) | 10 | 100% | 43ms | 0 |
三个关键结论:零误杀(正常流量全过)、快速拒绝(被拦请求 P99 仅 30ms)、无亚健康(突发结束立即恢复 100%)。
而且限流只是第一层——即使被绕过,后面还有告警去重(75 条 → 1 行 DB)、任务去重(1 个事件组只建 1 个任务)、并发槽(最多 32 个同时执行),层层收敛保护 LLM 配额。
Q2: 常见的限流算法有哪些?各有什么优缺点?#
四种主流算法:
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口 | 按时间窗计数 (如每分钟 20 次) | 实现最简单,1 次 Redis 往返 | 窗口边界突刺 (交界处瞬时 2x) |
| 滑动窗口 | 统计过去 N 秒内的请求数 | 无边界突刺,平滑限流 | Redis ZSET 操作多,内存占用大 |
| 令牌桶 | 匀速产生令牌,请求消耗令牌 | 允许突发 (桶里攒了令牌),最平滑 | 实现复杂,两个变量 (桶+速率) |
| 漏桶 | 请求入桶,匀速流出 | 输出绝对匀速 | 突发无法加速,延迟高 |
Q3: 你选了固定窗口,为什么不用更精确的滑动窗口?#
三个考虑:
1. 场景适配:这是运维诊断平台,不是交易系统。窗口边界突刺理论上最大 2 倍 (窗口末 20 条 + 窗口初 20 条 = 40 条/瞬间),40 条请求对后端毫无压力。
2. 实现极简:整个限流器只有 7 行 Lua + 50 行 Python,一次 Redis 往返搞定。滑动窗口要 ZADD + ZREMRANGEBYSCORE + ZCARD,至少 3 次操作(即使放在 Lua 里也更复杂)。
3. 真正的保护不靠限流:限流只是第一道门,后面还有队列削峰 + 分布式并发槽。即使窗口边界突刺 2 倍进来了,进到队列后也被均匀消费。
如果面试官问”需要更精确怎么办”:接口设计是 hit(scope, identity, limit, window_sec) → (bool, retry_after),换 Lua 脚本就行,调用方一行不改。
Q4: 面试追问:固定窗口的边界突刺具体是什么?画个图?#
窗口 1 (0:00 - 0:59) 窗口 2 (1:00 - 1:59)
│ │
...............▓▓▓│▓▓▓..............│
0:58│1:01 │
│ │
↑
这里可能出现 2x 的瞬时流量plaintext假设限额 20/min:
- 用户在 0:58-0:59 发了 20 个请求 → 窗口 1 计数 20,全放行
- 用户在 1:00-1:01 又发 20 个请求 → 窗口 2 计数 20,全放行
- 在 0:58 到 1:01 这 3 秒内实际通过了 40 个请求 → 2 倍突刺
滑动窗口怎么避免:不按整分钟划窗口,而是看”过去 60 秒内有多少请求”。任何时刻向前推 60 秒,计数都不超过 20。
我为什么能接受:40 个请求只是进入了队列,真正执行诊断还要过并发槽 (最多 32 个)。即使瞬时进来 40 个,Worker 也是一个一个消费的。
Q5: 如果让你实现滑动窗口,Lua 脚本怎么写?#
-- 滑动窗口 Lua (对比用, 项目中未使用)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local now = redis.call('TIME')
local now_ms = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
-- 1. 清掉窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
-- 2. 数窗口内有多少
local count = redis.call('ZCARD', key)
-- 3. 没超限就加一条
if count < limit then
redis.call('ZADD', key, now_ms, now_ms .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window_ms)
return 1 -- 放行
end
return 0 -- 拒绝lua比固定窗口多了 ZREMRANGEBYSCORE + ZADD + 随机 member 生成,但都在一个 Lua 里原子执行。代价是每个请求都是一个 ZSET member(20 req/min = 20 个 member),内存略高。
Q6: 令牌桶你知道怎么实现吗?#
-- 令牌桶 Lua (对比用, 项目中未使用)
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒产生多少令牌
local capacity = tonumber(ARGV[2]) -- 桶最大容量
local now = tonumber(ARGV[3]) -- 当前时间戳
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last = tonumber(data[2]) or now
-- 补充令牌
local elapsed = now - last
local refill = elapsed * rate
tokens = math.min(capacity, tokens + refill)
-- 尝试消费 1 个令牌
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
return 1 -- 放行
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
return 0 -- 拒绝lua令牌桶的优势是允许突发——桶里攒了令牌时可以一次消耗多个。适合”正常流量平稳但偶尔有合理突发”的场景(比如用户打开页面瞬间发多个请求)。我的场景不需要这个特性。
二、Lua 原子性#
Q7: 你的限流 Lua 脚本具体做了什么?为什么要用 Lua?#
-- rate_limiter.py — _HIT_LUA (7 行)
local n = redis.call('INCR', KEYS[1]) -- 计数 +1
if n == 1 then -- 如果是这个窗口的第一个请求
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) -- 设过期时间
end
return n -- 返回当前计数lua为什么必须用 Lua 而不是两步操作:
如果拆成 INCR + 单独 EXPIRE:
请求 A: INCR → n=1 → 准备 EXPIRE
请求 B: INCR → n=1 (A 的 EXPIRE 还没执行!) → EXPIRE 60s
请求 A: EXPIRE 60s (覆盖了 B 的)plaintext两个请求都看到 n=1,都去 EXPIRE,窗口被反复重置。极端情况下 EXPIRE 丢失(网络闪断),key 永不过期,这个 IP 被永久封禁。
Lua 保证原子性:Redis 执行 Lua 脚本时不会被其他命令打断,INCR 和 EXPIRE 在同一个原子操作里完成。
Q8: 这个 bug 你真的遇到过吗?#
是的。早期用两步操作时,写了并发测试:5 个协程同时打 limit=5 的窗口。偶尔会出现 6 个通过的情况。改成 Lua 后再跑,100 次测试零超限。
测试代码在 tests/test_rate_limiter.py:
# 测试: 5 个并发请求打 limit=5, 必须恰好 5 个通过
results = await asyncio.gather(*[
rate_limiter.hit("test", "user1", limit=5, window_sec=60)
for _ in range(5)
])
allowed = sum(1 for ok, _ in results if ok)
assert allowed == 5 # 不多不少pythonQ9: Redis Lua 脚本的执行会阻塞其他命令吗?#
会。Redis 是单线程的,Lua 执行期间其他命令排队等。但这个脚本只有 3 个 Redis 操作(INCR + EXPIRE + return),执行时间在微秒级,对 Redis 吞吐的影响可以忽略。
如果 Lua 脚本很复杂(比如遍历大量 key),确实会阻塞 Redis。所以:
- 限流脚本:3 个操作,安全
- 并发槽脚本:4 个操作 (ZREMRANGEBYSCORE + ZCARD + ZADD + PEXPIRE),安全
- 千万不要在 Lua 里做
KEYS *或大范围 SCAN
Q10: 面试追问:EVALSHA vs EVAL 的区别?你用了哪个?#
用了 EVAL(每次发送完整脚本)。EVALSHA 是先 SCRIPT LOAD 注册脚本得到 SHA,之后只传 SHA 不传脚本体,省带宽。
我没用 EVALSHA 因为:
- 脚本只有 4 行,带宽节省可以忽略
- EVALSHA 需要处理
NOSCRIPT错误(Redis 重启后 SHA 失效),增加了代码复杂度 redis-py的eval()内部已经做了 EVALSHA + fallback EVAL 的优化
三、双层限流设计#
Q11: 你的系统有几层限流?分别限什么?#
两层,限不同维度:
第 1 层:接口级限流(固定窗口 + Lua)
| 接口 | scope | identity | 限额 | 窗口 |
|---|---|---|---|---|
/diagnose/submit | manual | Client IP | 20/min | 60s |
/diagnose (SSE) | manual | Client IP | 20/min | 60s |
/webhook/alertmanager | webhook_key | API Key 或 IP | 50/sec | 1s |
/webhook/alertmanager | webhook_src | Receiver 名 | 500/min | 60s |
第 2 层:执行级限流(分布式并发槽 + Lua)
| 资源 | 上限 | 控制对象 |
|---|---|---|
manual_diagnosis | 16 | SSE 同步诊断并发数 |
worker_diagnosis | 32 | Worker 异步诊断并发数 |
两层的关系:
请求 → [第 1 层: 挡住异常频率] → 入队 → [第 2 层: 限制真正的 LLM 调用并发]plaintext第 1 层保护 API 层,第 2 层保护 LLM 配额。
Q12: Webhook 为什么有两道限流 (webhook_key + webhook_src)?#
两道卡不同维度:
# webhook.py:112-121
# 道 1: 防单个来源 IP/Key 打太快 (配置错误 or 恶意)
identity = request.headers.get("x-api-key") or rate_limiter.client_ip(request)
await rate_limiter.enforce("webhook_key", identity, 50, 1) # 50/sec
# 道 2: 防单个 receiver 来源堆积 (多个 Alertmanager 共享一个 receiver)
source = str(payload.receiver or "default")
await rate_limiter.enforce("webhook_src", source, 500, 60) # 500/minpython场景举例:
- 3 台 Alertmanager 各自每秒推 20 条 → 道 1 各过(每台 < 50/sec),道 2 合计 60 × 3 = 3600/min → 超 500 → 被拦
- 1 台 Alertmanager 疯狂重推 → 道 1 就挡住了(> 50/sec → 429)
Q13: 限流 key 的格式是什么?怎么保证不同维度不冲突?#
key = f"rate_limit:{scope}:{identity}:{bucket}"
# 例如:
# rate_limit:manual:192.168.1.100:28401560 ← IP 维度
# rate_limit:webhook_key:ak-abc123:1719705600 ← API Key 维度
# rate_limit:webhook_src:infra-team:28401560 ← Receiver 维度pythonscope区分限流规则 (manual / webhook_key / webhook_src)identity区分限流对象 (IP / API Key / Receiver)bucket=now // window_sec,固定窗口的时间桶号
三个字段拼接保证全局唯一,不同接口、不同用户、不同时间窗的计数互不干扰。
四、分布式并发槽#
Q14: 你说接口限流和并发槽是两层,它们的本质区别是什么?#
| 维度 | 接口限流 | 分布式并发槽 |
|---|---|---|
| 限的是什么 | 请求频率 (次/秒, 次/分) | 同时在执行的任务数 |
| 数据结构 | String (INCR 计数器) | ZSET (member=进程 token, score=过期时间) |
| 生命周期 | 窗口结束自动归零 | 任务结束显式释放 (ZREM) |
| 崩溃处理 | 无需 (窗口过期即可) | TTL + 心跳续期 (防永久泄漏) |
| fail 模式 | 429 拒绝 | 排队等待 (Worker) 或 “请稍后重试” (SSE) |
| 为什么需要 | 防刷接口 | 保护 LLM RPM/TPM 配额 |
一个类比:接口限流像高速公路入口的红绿灯(控制上路速度),并发槽像停车场的车位数(控制同时在里面的数量)。
Q15: 并发槽的 Lua 脚本比限流的复杂在哪?#
-- distributed_limiter.py — _ACQUIRE_LUA (6 行)
local t = redis.call('TIME') -- 1. 取服务端时间 (防客户端时钟漂移)
local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now_ms) -- 2. 清掉已过期的槽
local count = redis.call('ZCARD', KEYS[1]) -- 3. 数当前占用数
if count < tonumber(ARGV[1]) then -- 4. 没满就占一个
redis.call('ZADD', KEYS[1], now_ms + tonumber(ARGV[2]), ARGV[3])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]) * 2) -- 5. 整个 ZSET 也设过期 (兜底)
return 1 -- 抢到了
end
return 0 -- 满了lua比限流多了:
- 服务端时间:
redis.call('TIME')而不是客户端传时间戳,避免各进程时钟不同步导致误判 - 过期清理:每次 acquire 前先清掉过期 member,实现 “TTL 自动回收”
- PEXPIRE 兜底:如果所有持有者都崩溃了、没人再 acquire 来触发清理,整个 ZSET 也会过期
Q16: 心跳续期是怎么做的?为什么需要?#
诊断任务可能跑 2-5 分钟,远超 TTL (90 秒)。不续期 → 任务还在跑但槽被回收 → 别人抢走 → 并发超限。
-- _REFRESH_LUA — 心跳续期 (每 30 秒调一次)
local t = redis.call('TIME')
local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
if redis.call('ZSCORE', KEYS[1], ARGV[2]) then -- token 还在 ZSET 里
redis.call('ZADD', KEYS[1], now_ms + tonumber(ARGV[1]), ARGV[2]) -- 把 score 往后推
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1]) * 2)
return 1
end
return 0 -- token 不在了 (被别人清掉了), 续期失败lua时间关系:TTL 90s > 心跳间隔 30s × 2 + 安全余量。即使一次心跳丢失,下一次在 60 秒时还能续上(TTL 还没到 90 秒)。
Q17: asyncio.Semaphore 为什么不够?#
# 如果用本地 Semaphore:
uvicorn --workers 4
├── worker-1: Semaphore(16) ← 各有各的
├── worker-2: Semaphore(16)
├── worker-3: Semaphore(16)
└── worker-4: Semaphore(16)
实际全局并发 = 16 × 4 = 64 ← 配的是 16,跑了 64python再加 3 个 diagnosis worker 进程,总共 7 个进程 × 16 = 112。配了 16 等于没限。
Redis ZSET 是全局的——所有进程看同一个 ZSET,ZCARD 返回的是全局占用数。
Q18: Pause/Resume 这个设计很独特,能详细说说吗?#
场景:Agent 诊断过程中遇到高危操作(如重启服务),需要人工审批。审批可能等几分钟到几小时。
Worker 抢到槽 → 诊断中 → 遇到高危操作 → 等审批 (可能 30 分钟)
↑
如果一直占着槽,其他 31 个任务只能用 31 个槽plaintextPause/Resume 的解决方案:
# distributed_limiter.py — SlotHandle
async def pause(self):
"""让出槽位: 停心跳 + ZREM 释放"""
await self._stop_heartbeat()
await release_slot(self.resource, self.token)
async def resume(self):
"""审批结束: 重新抢一个槽位 (满了会阻塞等)"""
while True:
tok = await try_acquire_slot(self.resource, self.limit, self.ttl_seconds)
if tok is not None:
self.token = tok
break
await asyncio.sleep(0.5)
self.start_heartbeat()python通过 ContextVar 暴露 current_slot,tool_runner 在检测到需要审批时:
slot = current_slot.get()
if slot:
await slot.pause() # 让出槽
# ... 等审批 ...
if slot:
await slot.resume() # 抢回槽python五、429 响应设计#
Q19: 429 响应里应该包含什么信息?#
# rate_limiter.py — _raise_429()
raise HTTPException(
status_code=429,
detail={
"error": "rate_limited",
"message": "请求过于频繁,请稍后再试",
"retry_after": retry_after, # 还要等多少秒
},
headers={"Retry-After": str(retry_after)}, # HTTP 标准头
)python三个关键点:
- HTTP 标准头
Retry-After:很多 HTTP 客户端 (包括 Alertmanager) 会读这个头自动重试 - body 里也带 retry_after:前端 JS 读 body 做倒计时 UI 更方便
- 不暴露限额值:不告诉调用方 “你的限额是 20/min”,避免被精确绕过
Q20: Retry-After 的值怎么算的?#
retry_after = window_sec - (now % window_sec)python当前窗口的剩余秒数。比如窗口 60 秒,当前时间是第 45 秒 → retry_after = 60 - 45 = 15 秒。
客户端等 15 秒后重试,新窗口计数器归零,请求可以通过。
六、压测数据验证#
Q21: 怎么证明你的限流是精确的?#
submit 接口压测:500 并发 → 单 IP → 限额 20/min:
总请求: 500
成功(200): 20 ← 精确 = 限额值
限流(429): 480
其它/失败: 0
吞吐: 738.8 req/splaintext20 不是约数,是精确值——500 个请求里恰好 20 个通过,480 个被拦。因为 Lua 原子脚本的 INCR 是准确的,不存在 “两个请求同时 INCR 到 20 都通过” 的竞态。
Q22: 突发流量下限流的表现如何?#
burst 模式压测——10 QPS 平稳 → 300 QPS 突发 8 秒 → 10 QPS 回落:
| 阶段 | QPS | 成功率 | P99 | 429 |
|---|---|---|---|---|
| 平稳期 | 10 | 100% | 43ms | 0 |
| 突发期 | 300 | 19.3% | 30ms | 1,936 |
| 回落期 | 10 | 100% | 43ms | 0 |
三个结论:
- 零误杀:平稳期和回落期 100% 通过,只有超额的被拦
- 快速拒绝:被拦请求 P99 仅 30ms(比通过的还快——不做业务逻辑直接返回)
- 无亚健康:突发结束后立即恢复 100%,系统没被打出异常状态
Q23: 限流被绕过的风险?怎么防?#
| 绕过手段 | 风险 | 防御 |
|---|---|---|
| 换 IP | 每个 IP 各 20/min | 队列 + 并发槽兜底(进来也只能排队) |
| 伪造 X-Forwarded-For | 绕过真实 IP 提取 | 只信第一个 XFF(最外层代理写的) |
| 分布式刷 | N 个 IP × 20 = N×20 | 加第二道 webhook_src 限流(按来源名称) |
| 利用窗口边界 | 瞬时 2x | 可接受;后面有并发槽保底 |
最根本的保护不在限流层——是分布式并发槽。即使限流被绕过,进入队列的任务最多也只有 32 个同时被执行。
七、Fail-open 与容错#
Q24: 限流器 Redis 挂了怎么办?#
# rate_limiter.py:40-48 — fail-open
async def _redis():
if not settings.rate_limit_enabled:
return None
try:
return await incident_queue.client()
except Exception as exc:
logger.warning(f"[ratelimit] Redis 不可达, 限流降级放行")
return None
# hit() 里: client 为 None 就放行
if client is None or limit <= 0:
return True, 0 # 放行python为什么 fail-open 而不是 fail-closed:
限流和队列用同一个 Redis。Redis 挂了 → 限流放行 → 但入队 (XADD) 也失败 → API 返回 500 → 请求根本到不了 Worker → LLM 不会被打爆。
如果 fail-closed:Redis 挂了 → 所有请求 429 → 正常用户也被拦 → 系统完全不可用。反而更糟。
Q25: 限流配置是运行时可调的吗?#
通过环境变量读取,重启生效:
RATE_LIMIT_ENABLED=true
RATE_LIMIT_MANUAL_PER_IP_PER_MIN=20
RATE_LIMIT_WEBHOOK_PER_SOURCE_PER_MIN=500
RATE_LIMIT_WEBHOOK_PER_IP_PER_SEC=50plaintext目前不支持运行时热更新。如果需要:
- 短期方案:把限额存 Redis,hit() 每次从 Redis 读(多一次往返,但可热更)
- 长期方案:接入配置中心(Nacos / Apollo),watch 变更事件更新 settings 对象
八、生产级考量#
Q26: 如果要上生产,限流这块还需要补什么?#
| 优先级 | 改进 | 理由 |
|---|---|---|
| P0 | Redis Sentinel / Cluster | 消除单点 |
| P1 | 滑动窗口替代固定窗口 | 消除边界突刺,改动极小(换 Lua 脚本) |
| P1 | 租户级限流(不只是 IP) | 多租户场景需要按用户/组织维度限流 |
| P2 | 动态限额 | 根据队列深度自动调限额(队列满了收紧,空了放宽) |
| P2 | 限流监控 | Prometheus 暴露 rate_limited_total{scope, identity} |
| P3 | 熔断集成 | 后端 LLM 连续超时时,主动降低限额保护系统 |
Q27: 面试终极追问:如果让你从零设计一个限流中间件,架构怎么画?#
客户端 → API Gateway
│
├─ [L1] IP 黑白名单 (内存 Set, 无网络开销)
│
├─ [L2] 全局限流 (Redis 滑动窗口, 按路由+租户)
│ ├─ Lua 脚本原子操作
│ ├─ Fail-open + 降级到本地限流
│ └─ 429 + Retry-After + X-RateLimit-* 标准头
│
├─ [L3] 自适应限流 (可选)
│ ├─ 监控后端 P99 / 错误率
│ ├─ 动态调整限额 (AIMD 算法)
│ └─ 触发熔断时直接 503
│
└─ [L4] 业务级并发控制 (进入业务后)
├─ 分布式信号量 (Redis ZSET)
└─ 保护昂贵资源 (LLM / 数据库)plaintext关键设计原则:
- 分层:网络层粗过滤 → 应用层细限流 → 业务层并发控制
- 标准化:HTTP 429 +
Retry-After+X-RateLimit-Limit/Remaining/Reset标准头 - 可观测:每次限流都打日志 + metric,可以回答 “上个月被限了多少次”
- 降级:Redis 挂了不能让整个 API 挂,必须有本地降级策略