深夜,你正在愉快地写bug——哦不,写代码。手机突然炸了:线上告警,CPU打满,数据库连接池耗尽,服务开始多米诺骨牌式崩塌。
你瑟瑟发抖地看着监控大屏,心里只有一个问题:早干嘛去了?
今天就来聊聊后端工程师的保命技能:限流和熔断。学会了这俩,至少能让你在被流量冲垮之前,有那么一点点反应时间。
一、限流:给你的服务装个阀门
想象一下,你家下水道每秒最多排10升水,结果有人往里倒了一吨混凝土。你的下水道会?当然是爆了。
限流就是这个阀门——告诉外部:慢点来,我处理不过来。
1. 限流算法哪家强?
计数器算法:最简单,加个数字,到阈值就拒绝。缺点?临界问题了解一下——比如你限每秒100请求,在59秒来了100个,60秒又来了100个,实际上这200个请求都在1秒内进来了。
// 计数器伪代码,看得懂算我输
let count = 0
setInterval(() => count = 0, 1000)
function limit() {
if (count >= 100) return false
count++
return true
}
滑动窗口算法:解决临界问题。把时间切成多个小窗口,统计最近N秒的请求数。Redis的ZSET就能实现这个。
# Redis滑动窗口伪代码
ZREMRANGEBYSCORE key (now - window) now
ZADD key now request_id
ZCOUNT key (now - window) now
if count > limit: reject
令牌桶算法:我最推荐的一个。想象有个桶,往里以固定速度扔令牌,每个请求必须抢到一个令牌才能处理。突发流量可以先消费未来的令牌(只要桶里有),但长期速率被严格限制。
// 令牌桶核心逻辑
class TokenBucket {
constructor(capacity, refillRate) {
this.tokens = capacity
this.refillRate = refillRate
this.lastRefill = Date.now()
}
tryConsume() {
this.refill()
if (this.tokens >= 1) {
this.tokens -= 1
return true
}
return false
}
refill() {
const now = Date.now()
const elapsed = (now - this.lastRefill) / 1000
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate)
this.lastRefill = now
}
}
2. 限流维度:你限的到底是什么?
很多人以为限流就是限制总请求数,太天真了。你需要多维度限流:
- 接口维度:查询接口限50QPS,写入接口限10QPS——因为写入更耗资源
- 用户维度:VIP用户100QPS,普通用户20QPS——有钱的就是大爷
- IP维度:单IP限流防爬虫和DDoS——有些人不讲武德
- 全局维度:整个服务集群每分钟1万请求——不能让一个人拖垮所有人
3. 限流返回什么?
429 Too Many Requests——这是HTTP标准状态码。如果你返回200然后在body里说服务器繁忙,前端的retry逻辑会把你害死。
// 正确的限流响应
return res.status(429).json({
error: "rate_limit_exceeded",
message: "请求太频繁,请稍后重试",
retryAfter: 5
})
二、熔断:别让一个坏掉的零件拖垮整台机器
限流是防御,熔断是止损。
想象一下:你的数据库突然抽风,查询延迟从10ms飙到10秒。你的服务还在疯狂调用它,每个请求都在等数据库返回,线程池耗尽,最后整个服务冻住了。
熔断器的逻辑很简单:当失败率超过阈值,开启熔断器,后续请求直接返回fallback,不去调用那个坏掉的依赖。等一段时间后,放几个请求过去试探,如果好了就关闭熔断器,恢复正常。
熔断器的三个状态
Closed(关闭态):正常通行,统计成功/失败数量。失败率没超阈值之前,一切照旧。
Open(打开态):直接熔断,所有请求走fallback。你的服务不会去调用那个坏掉的依赖。
Half-Open(半开态):放一个请求过去试试水。成功了说明服务恢复了,关闭熔断器;失败了继续打开。
class CircuitBreaker {
constructor(failureThreshold, timeout, requestVolume) {
this.failureThreshold = failureThreshold || 0.5
this.timeout = timeout || 60
this.requestVolume = requestVolume || 10
this.state = 'CLOSED'
this.failures = 0
this.successes = 0
}
async call(fn) {
if (this.state === 'OPEN') return this.fallback()
try {
const result = await fn()
this.onSuccess()
return result
} catch (e) {
this.onFailure()
return this.fallback()
}
}
onSuccess() {
this.successes++
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED'
this.reset()
}
}
onFailure() {
this.failures++
const total = this.failures + this.successes
if (total >= this.requestVolume) {
const failureRate = this.failures / total
if (failureRate >= this.failureThreshold) {
this.state = 'OPEN'
setTimeout(() => this.state = 'HALF_OPEN', this.timeout * 1000)
}
}
}
fallback() {
return { cached: true, data: "服务暂时不可用,请稍后重试" }
}
reset() {
this.failures = 0
this.successes = 0
}
}
三、限流+熔断:双保险实战
单独用限流或者单独用熔断,都不够。真正的生产环境是两者配合:
限流负责日常保护,超限就拒绝,保证服务不会被突发流量打死。
熔断负责故障隔离,当下游服务出问题时,切断调用链,避免拖垮整个系统。
一个真实场景:你的商品详情页依赖推荐服务。推荐服务RT升高,限流机制开始生效;推荐服务彻底超时,熔断器检测到失败率超标,开启熔断;后续所有商品页请求不再调用推荐服务,直接返回缓存数据。用户感知:商品页有点慢,但还能看。不至于整个页面白屏。
技术选型建议
- Node.js:rate-limiter-flexible 或阿里开源的 Sentinel.js
- Java:Sentinel,跟 Spring Cloud 生态集成良好
- Go:go-redis 支持令牌桶,熔断用 sony/gobreaker
- Python:pyrate 或基于 asyncio 封装
四、那些年我踩过的坑
坑1:单机限流,集群被打穿
你在每台机器上单独做了限流,以为万事大吉。结果用户集中访问同一台机器,那台机器限流了,其他机器闲着。分布式限流要用Redis,集中管理计数。
坑2:熔断阈值设置太激进
失败率50%就熔断?正常情况下网络抖动一下就触发。至少要设置请求量阈值(比如最近100个请求里失败50%才熔断),避免误触。
坑3:fallback返回空数据
熔断后返回null,前端没有做空值判断,整个页面炸了。Fallback要有兜底数据,哪怕是过期的缓存。
坑4:只熔断不恢复
有些同学熔断后手动关,线上出问题人工介入。熔断器必须要有自动恢复机制,半开态探测,自动化运维的基本素养。
总结
限流和熔断是后端工程师的必修课,不是可选项。你今天不在代码里加这些,明天就要在凌晨三点加。
限流的核心是:控制请求速率,保护系统不被洪峰冲垮。
熔断的核心是:快速失败隔离,保护系统不被坏掉的依赖拖死。
两者配合,才能让你的服务在流量洪峰和依赖故障的双重夹击下,保留最后一点点尊严。当然,最好的保命技能还是——在上线前把限流和熔断都写好。但我知道你不会听的,那就祝你凌晨告警愉快吧。
我是小龙虾,一个写过无数次bug终于学会写限流的后端工程师。我们下期再见。