面试知识库

面试 Q&A — 限流与并发控制#

后端面试高频考点。本文档从算法原理讲到工程实现,再到真实压测数据验证, 覆盖固定窗口 / 滑动窗口 / 令牌桶的对比、Lua 原子性、分布式并发槽、面试追问链。


一、算法选型#

Q1: 举个具体例子,运维配错了 Alertmanager 导致 300 QPS 打过来,限流怎么挡住的?#

场景:某团队的 Alertmanager 配了 group_interval: 0s(正常应该 5m),同一批告警每秒重推一次,叠加多条规则同时触发,瞬时 300 请求/秒 打到 webhook 接口。

从一个请求的视角走一遍限流链路:

  1. 取 identityX-API-Key 头有值用 API Key,没有取 X-Forwarded-For 第一段 IP。假设是 10.0.1.50

  2. 第一道: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 头。

  3. 第二道:receiver 维度(500/min)——只有第一道通过了才到这里。每秒放过 50 个 × 60 秒 = 理论 3000/min,但这道限额 500/min → 第 501 个请求被拦。

  4. Alertmanager 收到 429 → 读 Retry-After 头,退避后重推。被 429 的大多是重复告警,新告警在下一个窗口(1 秒后)就能通过。

300 QPS 持续 8 秒的压测结果:

阶段QPS成功率P99 延迟被拦数
预热(正常流量)10100%43ms0
突发(配错)30019.3%30ms1,936
回落(修复后)10100%43ms0

三个关键结论:零误杀(正常流量全过)、快速拒绝(被拦请求 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 脚本怎么写?#

比固定窗口多了 ZREMRANGEBYSCORE + ZADD + 随机 member 生成,但都在一个 Lua 里原子执行。代价是每个请求都是一个 ZSET member(20 req/min = 20 个 member),内存略高。

Q6: 令牌桶你知道怎么实现吗?#

令牌桶的优势是允许突发——桶里攒了令牌时可以一次消耗多个。适合”正常流量平稳但偶尔有合理突发”的场景(比如用户打开页面瞬间发多个请求)。我的场景不需要这个特性。


二、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  # 不多不少
python

Q9: 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 因为:

  1. 脚本只有 4 行,带宽节省可以忽略
  2. EVALSHA 需要处理 NOSCRIPT 错误(Redis 重启后 SHA 失效),增加了代码复杂度
  3. redis-pyeval() 内部已经做了 EVALSHA + fallback EVAL 的优化

三、双层限流设计#

Q11: 你的系统有几层限流?分别限什么?#

两层,限不同维度:

第 1 层:接口级限流(固定窗口 + Lua)

接口scopeidentity限额窗口
/diagnose/submitmanualClient IP20/min60s
/diagnose (SSE)manualClient IP20/min60s
/webhook/alertmanagerwebhook_keyAPI Key 或 IP50/sec1s
/webhook/alertmanagerwebhook_srcReceiver 名500/min60s

第 2 层:执行级限流(分布式并发槽 + Lua)

资源上限控制对象
manual_diagnosis16SSE 同步诊断并发数
worker_diagnosis32Worker 异步诊断并发数

两层的关系:

请求 → [第 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/min
python

场景举例:

  • 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 维度
python
  • scope 区分限流规则 (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,跑了 64
python

再加 3 个 diagnosis worker 进程,总共 7 个进程 × 16 = 112。配了 16 等于没限。

Redis ZSET 是全局的——所有进程看同一个 ZSET,ZCARD 返回的是全局占用数。

Q18: Pause/Resume 这个设计很独特,能详细说说吗?#

场景:Agent 诊断过程中遇到高危操作(如重启服务),需要人工审批。审批可能等几分钟到几小时。

Worker 抢到槽 → 诊断中 → 遇到高危操作 → 等审批 (可能 30 分钟)

                                    如果一直占着槽,其他 31 个任务只能用 31 个槽
plaintext

Pause/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

三个关键点:

  1. HTTP 标准头 Retry-After:很多 HTTP 客户端 (包括 Alertmanager) 会读这个头自动重试
  2. body 里也带 retry_after:前端 JS 读 body 做倒计时 UI 更方便
  3. 不暴露限额值:不告诉调用方 “你的限额是 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/s
plaintext

20 不是约数,是精确值——500 个请求里恰好 20 个通过,480 个被拦。因为 Lua 原子脚本的 INCR 是准确的,不存在 “两个请求同时 INCR 到 20 都通过” 的竞态。

Q22: 突发流量下限流的表现如何?#

burst 模式压测——10 QPS 平稳 → 300 QPS 突发 8 秒 → 10 QPS 回落:

阶段QPS成功率P99429
平稳期10100%43ms0
突发期30019.3%30ms1,936
回落期10100%43ms0

三个结论:

  1. 零误杀:平稳期和回落期 100% 通过,只有超额的被拦
  2. 快速拒绝:被拦请求 P99 仅 30ms(比通过的还快——不做业务逻辑直接返回)
  3. 无亚健康:突发结束后立即恢复 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=50
plaintext

目前不支持运行时热更新。如果需要:

  • 短期方案:把限额存 Redis,hit() 每次从 Redis 读(多一次往返,但可热更)
  • 长期方案:接入配置中心(Nacos / Apollo),watch 变更事件更新 settings 对象

八、生产级考量#

Q26: 如果要上生产,限流这块还需要补什么?#

优先级改进理由
P0Redis Sentinel / Cluster消除单点
P1滑动窗口替代固定窗口消除边界突刺,改动极小(换 Lua 脚本)
P1租户级限流(不只是 IP)多租户场景需要按用户/组织维度限流
P2动态限额根据队列深度自动调限额(队列满了收紧,空了放宽)
P2限流监控Prometheus 暴露 rate_limited_total{scope, identity}
P3熔断集成后端 LLM 连续超时时,主动降低限额保护系统

Q27: 面试终极追问:如果让你从零设计一个限流中间件,架构怎么画?#

关键设计原则:

  1. 分层:网络层粗过滤 → 应用层细限流 → 业务层并发控制
  2. 标准化:HTTP 429 + Retry-After + X-RateLimit-Limit/Remaining/Reset 标准头
  3. 可观测:每次限流都打日志 + metric,可以回答 “上个月被限了多少次”
  4. 降级:Redis 挂了不能让整个 API 挂,必须有本地降级策略