系统设计的核心是"选型"还是"落地"?给一个限流系统从设计到上线的完整过程
问题
系统设计的核心是选型还是落地?给一个限流系统从设计到上线的完整过程。
分析
很多面试者把系统设计等同于"选型"——Redis vs Memcached、Kafka vs RabbitMQ,然后罗列一堆优缺点。但面试官真正想听的,是你知不知道每个选项背后的代价。P7/P8 级别的系统设计,选型只占 30%,落地细节占 70%。
"我们选了 Redis 做分布式锁"这句话谁都会说,但"Redis 主从切换时锁丢了怎么办?续期机制怎么设计?超时后业务还没执行完怎么处理?"——这些细节才值 P7/P8 的价码。
下面用一个限流系统从设计到上线的完整过程来演示这个思路。
第一步:需求澄清
不是"做个限流",而是把问题问清楚:
- 限谁? 用户级别限流?API 级别?来源 IP?——不同粒度对应不同 Key 设计,用户级用
userId,API 级用method:path,IP 级要考虑 NAT 场景下会误杀 - 阈值? 单机 QPS 1000,还是集群总 QPS 10 万?——单机不需要中心化,集群必须考虑误差
- 超限后? 直接返回 429,排队等待,还是降级返回默认值?——业务场景不同,策略不同
- 动态调整? 上线后不重启能不能改限流规则?——能改则必须配中心化配置源
真实场景: 某电商大促时,核心下单接口单机 QPS 峰值 800,集群 20 台机器。需求是"单用户每秒最多 5 次请求,接口总 QPS 不超过 15000"。注意这里有两个维度——用户级和接口级,需要两层限流叠加。
第二步:方案设计
算法选型——带上精确参数
| 算法 | 核心机制 | 允许突发? | 内存占用 | 精度 | 适用场景 | 踩坑点 |
|---|---|---|---|---|---|---|
| 令牌桶 | 固定速率+桶容量 | 是(桶内积攒令牌) | 极低(2 个变量) | 秒级 | 大部分业务接口,允许短时脉冲 | 桶容量设置过大=限流失效,过小=正常突发被限 |
| 漏桶 | 固定速率出水 | 否 | 极低(2 个变量) | 毫秒级 | 数据库写入、MQ 消费、下游保护 | 下游扩容后还要按原速率放行,浪费容量 |
| 滑动窗口(ZSet) | 窗口内精确计数 | 否 | 高(存所有请求时间戳) | 毫秒级 | 资源受限、计费场景 | 窗口过大时 ZSet 内存爆炸,10 万 QPS × 60s = 600 万条记录 |
| 滑动窗口(Hash) | 细化分桶,近似计数 | 否 | 中等(固定分桶数) | 秒级 | 分布式场景的折中方案 | 精度不如 ZSet,但内存可控 |
我踩过的坑——令牌桶桶容量计算: 以前给一个接口配了令牌桶,rate=1000/s,burst=2000。结果线上每 10 秒就出现一次 2000 QPS 的突刺,桶吃满了直接放行,下游 DB 连接池被打满。根因:burst = 2 × rate 太大,客户端有批量重试逻辑,正好每 10 秒重试一次。正确做法:burst = rate × 1.2,只允许 20% 的突发余量。
分布式 vs 单机—两级限流
单机限流简单,但多实例扩容后合计会超额。中心化限流(Redis + Lua)精确,但每次请求多一次网络开销。
实际方案——两级限流:
请求入口
│
▼
┌──────────────────────┐
│ 第一级:本地令牌桶 │ 延迟 < 0.01ms,扛 80% 请求
│ Guava RateLimiter │
│ 单机 QPS 1000 │
└────────┬─────────────┘
│ 通过
▼
┌──────────────────────┐
│ 第二级:Redis 滑动窗口 │ 延迟 1-3ms,精确总量控制
│ Lua + ZSet │
│ 集群总 QPS 15000 │
└────────┬─────────────┘
│ 通过
▼
执行业务逻辑两级限流的核心思路:本地限流扛住大部分流量,Redis 中心限流兜底总量,两者取"与"关系。任何一个拒绝就返回 429。
// 第一级:本地令牌桶(低延迟,兜底 80% 流量)
public class LocalRateLimiter {
// Guava RateLimiter 底层是 SmoothBursty,基于 token bucket
private final RateLimiter guavaLimiter = RateLimiter.create(1000.0); // 单机 1000 QPS
public boolean tryAcquire() {
// tryAcquire() 默认超时 0 秒,拿不到立刻返回 false
return guavaLimiter.tryAcquire();
}
}
// 第二级:Redis 滑动窗口(精确控制总量)
public class RedisRateLimiter {
private final StringRedisTemplate redis;
private final String keyPrefix = "rate_limit:";
private final int maxRequests; // 窗口内最大请求数
private final int windowSeconds; // 窗口时长(秒)
public RedisRateLimiter(int maxRequests, int windowSeconds) {
this.maxRequests = maxRequests;
this.windowSeconds = windowSeconds;
}
public boolean tryAcquire(String userId) {
String key = keyPrefix + userId;
// Lua 脚本在 Redis 端原子执行,避免竞态
String luaScript = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
-- 清理窗口外的过期数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < max then
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, window)
return 1
end
return 0
""";
Long result = redis.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(key),
String.valueOf(System.currentTimeMillis()),
String.valueOf(windowSeconds),
String.valueOf(maxRequests)
);
return result != null && result == 1L;
}
}
// 最终判断:两者取"与",任何一个拒绝就拒绝
public boolean tryAcquire(String userId) {
if (!localLimiter.tryAcquire()) {
log.warn("local rate limit exceeded, userId={}", userId);
return false;
}
if (!redisLimiter.tryAcquire(userId)) {
log.warn("redis rate limit exceeded, userId={}", userId);
return false;
}
return true;
}关键参数配置: 本地限流的阈值 = 集群总阈值 / 实例数 × 1.1(留 10% 余量)。比如 20 台机器,总 QPS 15000,本地配 15000/20×1.1 = 825 QPS。这样即使某台机器下线,其他机器也能扛住溢出流量。
第三步:详细设计
限流规则配置
存配置中心(Nacos/Apollo),支持动态推送,不重启生效。数据结构:
{
"rules": [
{
"resource": "POST:/api/order/create",
"algorithm": "token_bucket",
"rate": 1000,
"burst": 1200,
"level": "local"
},
{
"resource": "POST:/api/order/create",
"algorithm": "sliding_window",
"maxRequests": 15000,
"windowSeconds": 1,
"level": "cluster"
}
]
}客户端监听配置变更,热加载到本地限流器。注意:热加载时 RateLimiter 重建需要做平滑过渡,直接 new 会导致旧 RateLimiter 积累的令牌丢失,瞬间流量冲垮。正确做法:双 Buffer 切换,先建新实例,等旧实例累计令牌耗尽再切换。
数据结构
Redis ZSet 做滑动窗口,每个请求的时间戳作为 score,每秒清理过期数据。内存水位监控: 假设单用户 QPS 5,窗口 1 秒,ZSet 中最多 5 条记录。但如果 windowSeconds 扩大到 60 秒,单用户 300 条记录,100 万用户 × 300 = 3 亿条,内存约 1.5GB。所以要限制窗口大小,超过 10 秒的窗口改用 Hash 分桶方案:
-- Hash 分桶方案(窗口 60 秒,每 1 秒一个桶)
local key = KEYS[1]
local bucket = math.floor(tonumber(ARGV[1]) / 1000)
local maxPerWindow = tonumber(ARGV[2])
local total = 0
-- 累计 60 个桶的计数
for i = 0, 59 do
total = total + tonumber(redis.call('HGET', key, bucket - i) or '0')
end
if total < maxPerWindow then
redis.call('HINCRBY', key, bucket, 1)
redis.call('EXPIRE', key, 120)
return 1
end
return 0Hash 分桶的内存比 ZSet 小一个数量级,但精度降为秒级——对于 60 秒窗口,误差在 1/60 以内,可接受。
返回格式
HTTP 429 + Retry-After Header,客户端按指示重试:
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<Map<String, Object>> handleRateLimit(RateLimitExceededException e) {
return ResponseEntity
.status(429)
.header("Retry-After", String.valueOf(e.getRetryAfterSeconds()))
.body(Map.of(
"code", 429,
"message", "Too Many Requests",
"retryAfter", e.getRetryAfterSeconds(),
"limit", e.getLimit(),
"remaining", 0
));
}限流与降级、熔断的联动
限流不能孤立存在。当限流触发率持续 > 50% 时,说明容量不足,应该触发降级:
- 降级策略:非核心功能直接返回缓存/默认值,释放资源给核心链路
- 熔断策略:当下游响应时间 > 500ms 或错误率 > 10% 时,熔断器打开,直接返回降级结果,避免对下游的无效请求进一步拖垮系统
- 联动时序:
正常 → 限流触发率 > 30% → 降级非核心功能 → 限流触发率 > 50% → 熔断下游
↓ ↓
恢复后逐步解除 恢复后逐步关闭限流日志
每次触发限流记录到 ES,用于后验分析和规则调优。日志格式:
{
"timestamp": "2026-07-23T16:30:00.000+08:00",
"userId": "u_123456",
"resource": "POST:/api/order/create",
"level": "local",
"currentQps": 825,
"limit": 825,
"clientIp": "10.0.1.100",
"userAgent": "okhttp/4.11.0"
}我踩过的坑——限流日志打崩 ES: 限流日志每秒写入几千条,ES 写入 QPS 不够,写入积压导致 ES 响应变慢,影响了 ES 上其他业务查询。正确做法:限流日志走本地环形缓冲区 + 异步批量写入,每 5 秒或每 1000 条批量刷一次,不要逐条 insert。
第四步:上线与灰度
这一步是很多人忽略的——限流系统上线本身就是有风险的。2020 年某公司上线限流系统时,因为限流阈值配置错误,误杀了 30% 的合法请求,导致大促订单量下跌 15%。
正确流程:
- 先上线不生效:只收集限流触发数据,不实际拒绝请求。观察 48 小时,确认限流规则合理(误杀率 < 0.1%)
- 灰度生效:先对 10% 流量生效,观察业务指标无异常(订单量、响应时间、错误率)
- 逐步放大:10% → 30% → 50% → 100%,每步观察 4-8 小时
- 回滚预案:配置中心推送"关闭限流"的开关,1 秒内生效,无需重启
// 限流开关 + 灰度开关
@Component
@RefreshScope
public class RateLimitSwitch {
@Value("${rate.limit.enabled:false}")
private boolean globalEnabled;
@Value("${rate.limit.gray.percent:0}")
private int grayPercent; // 0-100
public boolean isRateLimited(String userId) {
if (!globalEnabled) return false;
// 灰度:只有 hash 落在灰度百分比内的用户才生效
if (Math.abs(userId.hashCode()) % 100 >= grayPercent) return false;
// ... 执行限流逻辑
}
}第五步:监控与告警
- 限流触发次数(QPS 曲线 + 限流触发率)—— Prometheus Counter,按 resource 和 level 拆分
- 被限流用户的请求失败率——确保限流后用户不会无限重试导致雪崩。客户端重试策略:指数退避 + 随机抖动,最大重试 3 次
- 被限流后的业务正确性——防止限流导致数据不一致(比如创建订单时,限流后部分子调用未执行)
告警规则:
- 限流触发率 > 50% → P1 告警,说明系统容量不足,需要扩容
- 限流触发率 > 80% → P0 告警,系统濒临崩溃,立即扩容或降级
- 误杀率 > 0.5% → P2 告警,限流规则配置有误
- Redis 限流命令延迟 > 10ms → P2 告警,Redis 需要优化或扩容
面试追问——常见连环炮
面试官读完你的限流方案,通常会追问这几个问题,提前准备好:
Q1: Redis 限流指令的瓶颈在哪里? A: 10 万 QPS 时,ZSet 的 ZADD 和 ZREMRANGEBYSCORE 都是 O(log N) 操作,10 万次/秒下 CPU 单核 80%+。实测:10 万 QPS 时 Redis 单实例 CPU 90%,响应延迟从 1ms 飙升到 8ms。解决方案:批量 pipeline、本地限流前置拦截 80% 流量、或者用 Redis Cluster 拆分 Key。
Q2: 限流触发后用户重试导致雪崩怎么防? A: 三层防护:① 返回 Retry-After Header 让客户端等待;② 客户端指数退避(1s, 2s, 4s, 8s)+ 随机 ±0.5s 抖动;③ 服务端限流触发后,在接下来的 1 秒内对同一用户直接返回 429,不查 Redis(本地缓存黑名单)。
Q3: 限流规则能动态调整吗?怎么保证一致性? A: 配置中心推送,本地监听器重建 RateLimiter。一致性:最终一致即可,允许秒级延迟。某个实例晚几秒拿到新规则,刚好那几秒的流量偏差在整体灰度比例内,不会造成全局影响。
Q4: 如果 Redis 挂了怎么办? A: 两级限流的设计优势:Redis 挂了,本地限流还在,不会完全不限流。Redis 超时或报错时,降级为只走本地限流,同时告警通知运维。Redis 恢复后,滑动窗口数据丢失,不补——等窗口自然滑动到正常状态。
总结
系统设计面试的四个层次:
- L1 背方案:"秒杀用 Redis 预扣库存"
- L2 懂原理:"Redis DECR 是原子操作,但集群下需要 Lua 脚本"
- L3 会权衡:"Redis 预扣库存 + MQ 异步写 DB,但 Redis 挂了会丢库存,需要持久化 + 对账"
- L4 能落地:上面整个流程走一遍,带代码、灰度、监控、踩坑记录
面试官要的是 L3 和 L4。
避坑指南:
- 不要一上来就画架构图,先问清楚业务场景和约束条件
- 不要每个方案都做成"超级大统一"(Kafka + Redis + MySQL + ES + HBase 全上),能用 MySQL 解决的问题不要上 HBase
- 不要只讲优点不提缺点,面试官更想听你"选了 A 方案,因为 B 方案的 XX 缺点在这个场景中不可接受"
- 限流是系统设计的冰山一角,背后的思想是"trade-off 意识"——永远在性能、一致性、可用性之间做取舍
参考: 阿里巴巴《系统设计面试指南》、Google SRE 限流实践、Martin Fowler 的 Trade-Off 方法论、Redis 官网 Lua 脚本文档