# 接口在裸奔:限流和熔断你真的懂了吗?
> "你永远不知道下一个流量洪峰什么时候来,但你可以准备好迎接它。"
## 开篇:一场事故带来的思考
那是一个普通的下午,监控突然开始报警——服务响应时间从正常的 200ms 飙升到 15 秒,错误率从 0.1% 冲到 40%。紧接着,数据库连接池耗尽,缓存雪崩,所有服务开始连锁宕机。
后来复盘,发现原因特别简单:一个接口没有限流,被外部爬虫盯上了。
这就是我今天想聊的话题:**限流和熔断**。
很多人觉得这是"基础设施部门的事",或者"加个计数器就行"。但我想说,你真的分得清限流和熔断的区别吗?你知道固定窗口和滑动窗口的区别吗?你知道为什么你的限流有时候形同虚设吗?
这篇文章,不讲概念罗列,只讲实战。
---
## 第一章:限流和熔断,傻傻分不清?
先问个问题:限流和熔断有什么区别?
如果你说"都是控制请求的",那恭喜你,你只答对了一半。
**限流**,是**主动防御**——在流量进来之前就把它拦住,说"兄弟,慢点"。
**熔断**,是**被动自救**——当你的下游服务已经挂了,快速失败,说"算了,别再试了"。
举个例子:
- 限流:商场门口限流,每秒放 100 人进去,满了就排队
- 熔断:商场里面某个店铺着火了,赶紧拉闸断电,把人疏散出来,保住整个商场
一个是防患于未然,一个是壮士断腕。
很多人只做了限流,没做熔断。结果下游服务已经挂了,你的服务还在疯狂重试,最后一起挂。
这就是典型的**没有边界意识**。
---
## 第二章:限流算法,那些你踩过的坑
### 2.1 固定窗口:最简单的坑
最常见的限流实现:
```go
// 伪代码
var counter int64
var windowStart int64
func Allow() bool {
now := time.Now().Unix()
if now != windowStart {
counter = 0
windowStart = now
}
atomic.AddInt64(&counter, 1)
return counter <= limit
}
```
这段代码眼熟吗?眼熟就对了,这是 90% 的人写的限流代码。
**问题在哪?**
假设限制是每秒 100 个请求。在 1.0s 到 1.5s 这个区间内,实际通过的可能不是 100 个,而是 200 个。
因为窗口是按秒切的,1.0s 放 100 个,1.5s 又放 100 个,看起来都"合法",但实际流量翻倍了。
这就是**固定窗口的边界突变问题**。
### 2.2 滑动窗口:进阶一点
滑动窗口解决这个问题:
```go
// 滑动窗口限流
type SlidingWindowLimiter struct {
limit int
windowSize int64 // 窗口大小,毫秒
requests []int64 // 时间戳队列
mu sync.Mutex
}
func (l *SlidingWindowLimiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now().UnixMilli()
cutoff := now - l.windowSize
// 清理过期的请求
var validRequests []int64
for _, ts := range l.requests {
if ts > cutoff {
validRequests = append(validRequests, ts)
}
}
l.requests = validRequests
if len(l.requests) >= l.limit {
return false
}
l.requests = append(l.requests, now)
return true
}
```
滑动窗口比固定窗口精细很多,但它的问题是:**内存占用高**。如果限流阈值很大,或者并发很高,这个队列会很长。
### 2.3 令牌桶:最实用
令牌桶是 Google Guava 推荐的方式,也是生产环境最常用的:
```go
type TokenBucket struct {
rate int64 // 每秒产生多少令牌
capacity int64 // 桶的容量
tokens int64 // 当前令牌数
lastFill time.Time // 上次填充时间
mu sync.Mutex
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
// 计算应该补充多少令牌
elapsed := now.Sub(tb.lastFill).Seconds()
tb.tokens = min(tb.capacity, tb.tokens + int64(float64(tb.rate) * elapsed))
tb.lastFill = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
```
令牌桶的好处是:**允许一定程度的突发流量**。
比如你的限流是每秒 100 个令牌,但桶容量是 200。那突然来了 150 个请求,前 100 个立刻拿到令牌,后 50 个等令牌补充。
这就是**预热**的效果——平时慢慢积累,关键时刻能用。
### 2.4 漏桶:另一种思路
漏桶和令牌桶相反,它是**匀速消费**:
```go
type LeakyBucket struct {
rate int64 // 漏出速率
capacity int64 // 桶容量
water int64 // 当前水量
lastLeak time.Time
mu sync.Mutex
}
func (lb *LeakyBucket) Allow() bool {
lb.mu.Lock()
defer lb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(lb.lastLeak).Seconds()
lb.water = max(0, lb.water - int64(float64(lb.rate) * elapsed))
lb.lastLeak = now
if lb.water < lb.capacity {
lb.water++
return true
}
return false
}
```
漏桶的问题是:**没有突发能力**。无论你之前积累了多少请求,它都是匀速放行。
所以我的建议是:**大多数场景用令牌桶**,它最符合实际流量特征。
---
## 第三章:熔断,不是你想的那样
限流是防外敌,熔断是**救自己**。
当你的下游服务(数据库、第三方 API、其他微服务)开始超时、报错,你有两个选择:
1. 继续请求,等超时,报错
2. 直接返回降级结果
选项 2 就是熔断。
### 3.1 熔断器原理:三状态
熔断器有三个状态:
- **Closed(关闭)**:正常请求通过,统计错误率
- **Open(打开)**:直接返回降级,不发请求
- **Half-Open(半开)**:尝试放行少量请求,看看下游活了没
```go
type CircuitBreaker struct {
failureThreshold int // 连续失败多少次触发熔断
successThreshold int // 连续成功多少次关闭熔断
timeout time.Duration // 熔断超时时间
state int // 0=closed, 1=open, 2=half-open
failureCount int
successCount int
lastStateChange time.Time
mu sync.Mutex
}
func (cb *CircuitBreaker) Execute(fn func() error) error {
cb.mu.Lock()
// 检查是否应该从 Open 转为 Half-Open
if cb.state == 1 && time.Since(cb.lastStateChange) > cb.timeout {
cb.state = 2
cb.lastStateChange = time.Now()
}
// 如果是 Open 状态,直接返回降级
if cb.state == 1 {
cb.mu.Unlock()
return ErrCircuitOpen
}
cb.mu.Unlock()
// 执行请求
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failureCount++
cb.successCount = 0
if cb.failureCount >= cb.failureThreshold {
cb.state = 1
cb.lastStateChange = time.Now()
}
} else {
cb.successCount++
cb.failureCount = 0
if cb.state == 2 && cb.successCount >= cb.successThreshold {
cb.state = 0
cb.successCount = 0
}
}
return err
}
```
### 3.2 熔断的坑
熔断器最常见的坑有两个:
**1. 阈值拍脑袋**
我见过太多人把失败阈值设为 5 次,熔断时间设为 1 秒。结果正常抖动就触发熔断,业务反而受损。
正确的做法是:根据你的业务容错能力来定。如果是核心链路,阈值可以高一点;如果是边缘服务,可以低一点。
**2. 没有降级方案**
熔断之后返回什么?这是个问题。
常见做法:
- 返回缓存数据
- 返回默认值
- 返回友好提示
如果你熔断之后抛异常,那熔断的意义在哪?
---
## 第四章:实战:怎么设计一个好的限流方案?
说了这么多算法和原理,怎么落地?
我的建议是**分层限流**:
### 4.1 接入层限流(Nginx/网关)
在流量入口就限掉大部分请求,别让它们进入你的服务。
```nginx
# Nginx 限流配置
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
limit_req zone=api_limit burst=200 nodelay;
```
### 4.2 应用层限流
网关之后的限流,要更精细:
- 按用户 ID 限流
- 按接口限流
- 按业务规则限流
```go
// 按用户限流
userLimiter := NewTokenBucketLimiter(100, 200) // 每秒100令牌,桶容量200
if !userLimiter.Allow() {
return ErrUserRateLimit
}
// 按接口限流
interfaceLimiter := NewTokenBucketLimiter(1000, 2000)
if !interfaceLimiter.Allow() {
return ErrInterfaceRateLimit
}
```
### 4.3 熔断配置
```go
breaker := &CircuitBreaker{
failureThreshold: 10, // 连续10次失败
successThreshold: 3, // 连续3次成功恢复
timeout: 30 * time.Second, // 30秒后尝试恢复
}
```
---
## 第五章:常见的反模式
最后聊聊我见过的**反面教材**:
### 1. 限流值设成常量
```go
const RateLimit = 100 // 这是认真的吗?
```
不同环境、不同服务,限流值能一样吗?
### 2. 只在核心服务限流
边缘服务也要限流!边缘服务挂了,可能导致连锁反应。
### 3. 限流和熔断二选一
这两个是互补关系,不是替代关系。
### 4. 限流逻辑放在业务代码里
限流应该是**基础设施**,不是业务逻辑。把它抽离出来,统一管理。
---
## 结尾:没有银弹
限流和熔断是**防御性编程**的重要组成部分,但它们不是万能的。
真正的稳定性,来自于:
- 合理的架构设计
- 充分的容量规划
- 完善的监控告警
- 可靠的故障演练
限流和熔断,只是最后一道防线。
但如果你不做这道防线,可能连第一道都守不住。
> "与其事后补救,不如事前防御。"