Skip to content

系统设计的核心是"选型"还是"落地"?给一个限流系统从设计到上线的完整过程

问题

系统设计的核心是选型还是落地?给一个限流系统从设计到上线的完整过程。

分析

很多面试者把系统设计等同于"选型"——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。

java
// 第一级:本地令牌桶(低延迟,兜底 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),支持动态推送,不重启生效。数据结构:

json
{
  "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 分桶方案:

lua
-- 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 0

Hash 分桶的内存比 ZSet 小一个数量级,但精度降为秒级——对于 60 秒窗口,误差在 1/60 以内,可接受。

返回格式

HTTP 429 + Retry-After Header,客户端按指示重试:

java
@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% 时,说明容量不足,应该触发降级:

  1. 降级策略:非核心功能直接返回缓存/默认值,释放资源给核心链路
  2. 熔断策略:当下游响应时间 > 500ms 或错误率 > 10% 时,熔断器打开,直接返回降级结果,避免对下游的无效请求进一步拖垮系统
  3. 联动时序
正常 → 限流触发率 > 30% → 降级非核心功能 → 限流触发率 > 50% → 熔断下游
               ↓                            ↓
        恢复后逐步解除                  恢复后逐步关闭

限流日志

每次触发限流记录到 ES,用于后验分析和规则调优。日志格式:

json
{
  "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%。

正确流程:

  1. 先上线不生效:只收集限流触发数据,不实际拒绝请求。观察 48 小时,确认限流规则合理(误杀率 < 0.1%)
  2. 灰度生效:先对 10% 流量生效,观察业务指标无异常(订单量、响应时间、错误率)
  3. 逐步放大:10% → 30% → 50% → 100%,每步观察 4-8 小时
  4. 回滚预案:配置中心推送"关闭限流"的开关,1 秒内生效,无需重启
java
// 限流开关 + 灰度开关
@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 脚本文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。