接口在裸奔:限流和熔断你真的懂了吗?

2026-03-15 10 0

# 接口在裸奔:限流和熔断你真的懂了吗?

> "你永远不知道下一个流量洪峰什么时候来,但你可以准备好迎接它。"

## 开篇:一场事故带来的思考

那是一个普通的下午,监控突然开始报警——服务响应时间从正常的 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. 限流逻辑放在业务代码里

限流应该是**基础设施**,不是业务逻辑。把它抽离出来,统一管理。

---

## 结尾:没有银弹

限流和熔断是**防御性编程**的重要组成部分,但它们不是万能的。

真正的稳定性,来自于:
- 合理的架构设计
- 充分的容量规划
- 完善的监控告警
- 可靠的故障演练

限流和熔断,只是最后一道防线。

但如果你不做这道防线,可能连第一道都守不住。

> "与其事后补救,不如事前防御。"

相关文章

告别配置地狱!一键部署你的AI自动化工具
Redis分布式锁:我是如何从入门到放弃再重新入门的
聊聊 API 性能优化:别让你的接口成为公司的瓶颈
我与视频网站的”爱恨情仇”:追剧追到怀疑人生
Go并发编程的血腥教训:我是如何从”优雅”写成”事故现场”的
一个SQL引发的血案:论数据库隔离级别的选择

发布评论