小Cの已经记不起来的博客

限流 的十种打开方式:工程实践笔记

前段时间一个导出接口被人拿脚本刷了,QPS 瞬间冲到平时的几十倍,数据库连接池直接被占满,连带着一堆不相干的接口也跟着趴窝。事后复盘的时候发现,这服务裸奔快两年了,什么限流都没有...趁着修 bug 的功夫,把限流这摊子事从头捋了一遍,能想到的打开方式都记在这儿,省得下次再抓瞎。

一、固定窗口计数器

最直觉的做法就是数数:一分钟最多放 100 个请求,超了直接打回 429。两行 Redis 命令就能跑起来:

INCR rate:api:202506011233
EXPIRE rate:api:202506011233 60

简单粗暴,但有个经典的坑:窗口边界。假如第 59 秒涌进来 100 个,第 61 秒又来 100 个,这两秒内系统实际承受了 200 个,限流漏了个大洞。流量均匀的时候凑合,一抖就不行了。

二、滑动窗口

为了补上面的洞,把 1 分钟切成 60 个 1 秒的小格子,只统计最近 60 个格子的总和。用 Redis 的 zset 存每个请求的时间戳就行:

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - 60000)
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[2]) then
    return 0
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
return 1

精度上去了,代价是每个请求都要在 zset 里留一条记录,QPS 一高内存吃得厉害,只适合量不大的场景,或者把格子切粗点、按秒聚合再统计。

三、漏桶

把请求想成往桶里倒水,桶底有个洞匀速往外漏,桶满了就直接拒绝。好处是出口流量绝对平滑,坏处是请求得排队,对延迟敏感的接口不太合适。一般没人自己手写这个,Nginx 的 limit_req 底层就是它,第七条再说。

四、令牌桶

和漏桶反着来:系统匀速往桶里放令牌,请求拿到令牌才放行,桶空了就拒。比漏桶友好的地方在于允许一定的突发,桶里攒下的令牌可以一口气用掉。单机场景不用自己造轮子,Guava 现成的:

RateLimiter limiter = RateLimiter.create(100); // 每秒 100 个
if (!limiter.tryAcquire()) {
    throw new TooManyRequestsException();
}

注意带超时参数的 tryAcquire(1, TimeUnit.SECONDS) 是会阻塞等待的,不想等就用无参版本,拿不到立刻返回 false。

五、并发数限制(信号量)

严格说这不是限“速率”,是限“同时正在处理的人数”。有的接口慢不是因为流量大,而是单个请求本身就慢,比如导出、跑报表,这种限 QPS 没意义,限并发才有用:

Semaphore semaphore = new Semaphore(50);
if (!semaphore.tryAcquire()) {
    throw new TooManyRequestsException();
}
try {
    return doBusiness();
} finally {
    semaphore.release();
}

或者更省事,单独建个线程池,上限设好,拒绝策略用 AbortPolicy,效果差不多。

六、Redis + Lua 分布式限流

单机限流有个绕不过去的问题:部署 10 个实例,每台限 100,全局就是 1000,但流量经过负载均衡不一定分得匀。要全局精确,就把计数挪到 Redis,原子性靠 Lua 保证:

local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1

两个坑记一下:一是 INCR 和 EXPIRE 必须写在同一段 Lua 里,分开发的话,第一次请求如果恰好赶上服务重启,就会留下一个没有过期时间的 key,然后这个 key 就被永久限流了;二是 Redis 挂了怎么办,要么降级放行要么直接拒绝,我们选的放行,毕竟限流的初衷是保系统,不是为难用户。

七、网关层:Nginx

能在网关拦的就别放进应用,毕竟这时候请求还没消耗你应用的线程和连接。Nginx 自带的 limit_req 就够用:

limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;

location /api/ {
    limit_req zone=api burst=200 nodelay;
    limit_req_status 429;
}

burst 相当于桶的容量,nodelay 表示突发请求立刻放行、不排队干等。不配 burst 的话超出 rate 的请求直接报错,排查的时候记得翻 nginx 的 error log,别像我一样盯着应用日志看了半天啥也没看出来。

八、中间件:Sentinel

自己写算法终究是玩具,真要管一大堆规则、看监控、随时调阈值,还是上中间件省心。Sentinel 声明个资源、加载条规则就能跑,控制台里改个阈值秒级生效,不用发版:

FlowRule rule = new FlowRule();
rule.setResource("exportApi");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100);
FlowRuleManager.loadRules(Collections.singletonList(rule));

熔断降级、热点参数限流它也都能干,展开说又是一篇文章了,先挖个坑。

九、线程池 / 连接池隔离

换个思路:不去拒绝请求,而是把资源隔开。导出这种慢接口单独丢进一个 5 线程的小线程池,就算被刷爆,遭殃的也只有这 5 个线程,主业务一点事没有。数据库连接池同理,给不同业务分不同的 HikariCP 池。所谓舱壁模式说的就是这个。这次事故之所以殃及全站,就是因为所有接口共用一个 32 线程的池子,一个导出接口把别人全拖下水了...

十、队列削峰 + 兜底降级

最后的兜底姿势:同步改异步。请求进来先扔进 MQ,后端按自己的消化速度慢慢处理,前端轮询或者推送拿结果。导出、发短信、对账这类业务天生就该这么干,流量再猛也只是队列长一点。再配个最外层的降级开关,Redis 挂了、MQ 堆积了,直接返回一句“当前繁忙,稍后再试”,好歹留个体面。

最后

实际项目里这几招往往是叠着用的:网关拦明显异常的流量,应用内令牌桶控

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放