系统设计面试:分布式限流系统设计(算法原理与工程实现)

限流是保障系统稳定性的最后一道防线。本文系统梳理限流系统的四大算法、单机 vs 分布式限流、滑动窗口精确实现,以及如何在面试中设计一个完整的分布式限流服务。


1. 为什么需要限流?

1
2
3
4
5
6
不限流的后果:
突发流量 → 服务资源耗尽 → 响应变慢 → 超时堆积 → 雪崩

限流的本质:
用"拒绝部分请求"换取"剩余请求的服务质量"
宁可少服务一些用户,也不让所有用户都得到差服务

限流 vs 熔断 vs 降级的区别

触发条件 作用方向 目标
限流 请求量超阈值 在入口拒绝请求 保护自身
熔断 下游错误率超阈值 不调用下游 保护下游
降级 资源不足或超时 走简化逻辑 保证主路径可用

2. 四大限流算法

2.1 固定窗口计数器(Fixed Window)

1
2
3
4
5
6
7
8
9
时间轴: |---窗口1(0-60s)---|---窗口2(60-120s)---|
计数: 100请求 100请求

实现:
counter = redis.incr(f"rate:{user_id}:{current_minute}")
if counter > 100:
reject()
if counter == 1:
redis.expire(f"rate:{user_id}:{current_minute}", 60)

致命缺陷:临界突破

1
2
3
59秒:100个请求(窗口1打满)
60秒:100个请求(窗口2重置,又允许100个)
→ 2秒内实际通过200个请求,是限额的2倍!

2.2 滑动窗口计数器(Sliding Window Log)

用时间戳日志精确统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def is_allowed(user_id, limit=100, window=60):
now = time.time()
key = f"rate:{user_id}"
pipe = redis.pipeline()

# 删除窗口外的请求记录
pipe.zremrangebyscore(key, 0, now - window)
# 统计窗口内的请求数
pipe.zcard(key)
# 记录当前请求
pipe.zadd(key, {now: now})
pipe.expire(key, window)

_, count, _, _ = pipe.execute()
return count < limit

优点:精确,无临界问题
缺点:每个请求都存一条记录,大 QPS 下内存消耗大

面试优化:滑动窗口计数(不存日志,分格子)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 将 60s 窗口分成 60 个格子(每格 1s)
def is_allowed_sliding_count(user_id, limit=100, window=60, precision=60):
now = time.time()
slot = int(now * precision / window) # 当前格子编号

# 统计过去 60 个格子的总量
total = 0
for i in range(precision):
key = f"rate:{user_id}:{slot - i}"
count = redis.get(key) or 0
total += int(count)

if total >= limit:
return False

redis.incr(f"rate:{user_id}:{slot}")
redis.expire(f"rate:{user_id}:{slot}", window + 1)
return True

2.3 漏桶(Leaky Bucket)

1
2
3
4
5
6
7
请求 → 桶(队列)→ 以固定速率流出

┌──────────┐
请求 →→→ │ 桶(队列) │ → 以 r req/s 匀速处理
└──────────┘

桶满则丢弃新请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class LeakyBucket:
def __init__(self, capacity=100, leak_rate=10): # 10 req/s
self.capacity = capacity
self.water = 0
self.last_time = time.time()
self.leak_rate = leak_rate

def allow(self):
now = time.time()
elapsed = now - self.last_time
# 漏掉已流出的水
self.water = max(0, self.water - elapsed * self.leak_rate)
self.last_time = now

if self.water < self.capacity:
self.water += 1
return True
return False # 桶满,拒绝

特点:输出速率恒定,适合对下游的保护(防止突发打垮下游)。

2.4 令牌桶(Token Bucket)— 最常用

1
2
3
4
5
6
7
令牌以固定速率生成,放入桶中
请求来了必须先取令牌,取到才能通过

┌──────────┐
令牌 →→→ │ 桶(令牌) │ → 请求取走令牌后通过
└──────────┘
每秒生成 r 个令牌,桶容量 b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TokenBucket:
def __init__(self, rate=10, capacity=100):
self.rate = rate # 每秒生成的令牌数
self.capacity = capacity # 桶最大容量
self.tokens = capacity # 当前令牌数(初始满桶)
self.last_time = time.time()

def allow(self, tokens_required=1):
now = time.time()
elapsed = now - self.last_time
# 补充令牌
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_time = now

if self.tokens >= tokens_required:
self.tokens -= tokens_required
return True
return False # 令牌不足,拒绝

令牌桶 vs 漏桶核心差异

  • 令牌桶允许突发(桶满时积累了令牌,可以瞬间消耗)
  • 漏桶输出匀速(无论请求多密集,处理速度恒定)

3. 分布式限流

3.1 为什么单机限流不够?

1
2
3
4
5
假设限额:100 QPS/用户
部署 10 个实例,每个实例限流 100 QPS

用户实际可以:向不同实例发请求,达到 10 * 100 = 1000 QPS
→ 单机限流在分布式环境下形同虚设(除非请求总是打到同一实例)

3.2 基于 Redis 的集中式限流

所有实例共享 Redis 计数器:

方案一:Redis + Lua 脚本(原子操作)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0 -- 拒绝
else
return 1 -- 允许
end
1
2
3
4
5
6
script = redis.register_script(open('rate_limit.lua').read())

def is_allowed(user_id, limit=100, window=60):
key = f"rate:{user_id}:{int(time.time() / window)}"
result = script(keys=[key], args=[limit, window])
return result == 1

方案二:Redis + Sliding Window(精确)

使用 ZSET 实现精确滑动窗口(见上文 2.2),适合对精度要求高的场景。

方案三:Nginx + lua-resty-limit(网关层限流)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
http {
lua_shared_dict rate_limit 10m;

server {
location /api/ {
access_by_lua_block {
local limit = require "resty.limit.req"
local lim, err = limit.new("rate_limit", 100, 200)
local key = ngx.var.remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(429)
end
end
}
}
}
}

3.3 本地计数器 + 定期同步(高性能方案)

当 Redis 限流成为瓶颈时(网络 RTT 叠加),用本地计数器 + 定期同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DistributedRateLimiter:
def __init__(self, global_limit=10000, sync_interval=100ms):
self.local_count = 0
self.local_quota = global_limit / num_instances # 本地分配的配额
self.sync_interval = sync_interval

def allow(self):
self.local_count += 1
if self.local_count <= self.local_quota:
return True
return False

def sync_to_redis(self):
# 定期将本地计数同步到 Redis
# 并从 Redis 获取全局计数,动态调整本地配额
global_count = redis.incrby("global_counter", self.local_count)
self.local_count = 0
# 动态调整配额(例如某些实例闲置时,将配额让渡给繁忙实例)
self.local_quota = recalculate_quota(global_count)

适用场景:QPS 极高(10万+),允许轻微超量(配额分配有误差)。


4. 完整系统设计:API 限流服务

面试题:设计一个支持亿级用户的 API 限流系统

4.1 需求澄清

1
2
3
4
5
- 限流维度:用户级、IP级、API 级?→ 多维度
- 限流策略:固定 QPS?还是动态配置?→ 动态,运行时可修改
- 精度要求:允许 ±5% 误差?→ 允许小误差
- 响应时间:限流判断增加的延迟 → < 1ms
- 规模:DAU 1亿,峰值 1000万 QPS

4.2 整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                 ┌─────────────────────────────────┐
│ API Gateway │
│ ┌─────────────────────────┐ │
Client ────────► │ │ Rate Limit Middleware │ │
│ │ (本地令牌桶 + 异步同步) │ │
│ └────────────┬────────────┘ │
└───────────────┼─────────────────┘
│ 定期同步 (100ms)
┌───────────────▼─────────────────┐
│ Rate Limit Service │
│ (全局计数 + 配额分配算法) │
└───────────────┬─────────────────┘

┌───────────────▼─────────────────┐
│ Redis Cluster │
│ (全局计数器 + 限流规则存储) │
└─────────────────────────────────┘

┌───────────────▼─────────────────┐
│ Config Service │
│ (动态限流规则,运行时修改) │
└─────────────────────────────────┘

4.3 限流规则设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 规则存储结构(Redis Hash)
rate_limit_rules:
user:{user_id}:api:/search:
limit: 100
window: 60s
algorithm: sliding_window
ip:{ip_address}:
limit: 1000
window: 60s
algorithm: token_bucket
api:/checkout:global:
limit: 50000
window: 1s
algorithm: token_bucket

4.4 限流响应设计

被限流时,返回标准 HTTP 429:

1
2
3
4
5
6
7
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718300400

{"error": "rate_limit_exceeded", "message": "Too many requests, retry after 30 seconds"}

5. 常见限流场景与对应策略

场景 推荐算法 理由
API 接口保护(允许突发) 令牌桶 允许短时突发,日常匀速
下游 DB/服务保护 漏桶 匀速输出,保护下游
精确用量计费 滑动窗口 无临界问题,精确计量
简单 QPS 限制(允许误差) 固定窗口 实现简单,够用
极高 QPS + 允许小误差 本地计数器 + 定期同步 减少 Redis RTT

6. 面试常见追问

Q: 限流时如何公平对待不同用户?

A: 实现优先级队列:VIP 用户拥有更高配额(10x);在接近限额时,优先拒绝低优先级请求;可结合令牌桶为不同等级用户分配不同的桶容量和填充速率。

Q: 如何防止限流服务本身成为单点故障?

A: Redis Cluster 保证存储层高可用;Rate Limit Service 本身无状态,可水平扩展;网关本地有降级策略(Redis 不可达时,降级为本地限流;甚至临时关闭限流,优先保证业务可用)。

Q: 分布式环境下如何保证”全局精确限流”?

A: 严格意义上的全局精确限流(无任何误差)代价极高,需要每个请求都做跨实例的 Redis 原子操作,延迟显著上升。实际工程中接受 1-5% 的超量(本地计数器 + 定期同步方案),用精度换性能,这是合理的工程权衡。


总结

1
2
3
4
5
6
7
8
9
10
算法选型:令牌桶(允许突发 API 限流) vs 漏桶(保护下游)
固定窗口(简单) vs 滑动窗口(精确)

实现层次:
网关层(Nginx/Kong)→ 最早拦截,保护后端
应用层(SDK/Middleware)→ 细粒度,可按业务维度限流
服务层(Sentinel/Hystrix)→ 熔断+限流一体

分布式策略:
Redis 集中计数(精确) vs 本地计数+同步(高性能)

面试核心:限流不只是”计数器”,要展示你对突发流量处理、分布式一致性权衡、降级兜底的系统性思考。