3. Go 标准库实现计数器限流:基于 sync/atomic 与 time.Ticker 的固定窗口计数器实现
固定窗口计数器是限流算法中最直观、最容易实现的一种。其核心思想是:将时间划分为固定的窗口(例如 1 秒),每个窗口内维护一个计数器,当请求到达时计数器加 1,若计数器超过预设阈值则拒绝请求,窗口结束时计数器归零。
本章将使用 Go 标准库中的 sync/atomic 和 time.Ticker,从零构建一个线程安全、高性能的固定窗口计数器限流器。这种实现方式不依赖第三方库,适合对依赖管理有严格要求的项目。
3.1 固定窗口计数器原理
固定窗口计数器的工作流程可以概括为以下步骤:
- 设定一个时间窗口长度
window(如 1 秒)和一个最大请求数limit(如 100)。 - 在每个窗口开始时,将计数器重置为 0。
- 每次请求到达时,原子性地将计数器加 1。
- 如果加 1 后的值超过
limit,则拒绝该请求;否则放行。 - 窗口结束时,重复步骤 2。
关键特性: 固定窗口算法实现简单、性能极高(仅需一次原子操作),但存在“窗口临界突发”问题——在窗口切换的瞬间,可能允许两倍于阈值的请求通过。后续章节会介绍滑动窗口算法来解决此问题。
3.2 架构设计
下图展示了固定窗口计数器的核心组件与数据流:
3.3 核心数据结构
我们定义 FixedWindowLimiter 结构体,包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
limit |
int64 |
窗口内允许的最大请求数 |
counter |
atomic.Int64 |
当前窗口的请求计数器(Go 1.19+ 推荐) |
ticker |
*time.Ticker |
定时器,每个窗口到期触发重置 |
stopCh |
chan struct{} |
停止信号,用于优雅关闭 |
版本提示: Go 1.19 引入了 atomic.Int64 等类型安全原子操作,推荐在新项目中使用。若兼容旧版本,可使用 sync/atomic 包中的 LoadInt64/AddInt64 函数。
3.4 完整实现代码
以下为固定窗口计数器的完整实现,包含构造函数、请求判断方法和优雅关闭:
package limiter
import (
"sync/atomic"
"time"
)
// FixedWindowLimiter 固定窗口计数器限流器
type FixedWindowLimiter struct {
limit int64 // 窗口内最大请求数
counter atomic.Int64 // 当前窗口计数器
ticker *time.Ticker // 窗口定时器
stopCh chan struct{} // 停止信号
}
// NewFixedWindowLimiter 创建固定窗口限流器
// window: 时间窗口长度,如 time.Second
// limit: 窗口内允许的最大请求数
func NewFixedWindowLimiter(window time.Duration, limit int64) *FixedWindowLimiter {
l := &FixedWindowLimiter{
limit: limit,
ticker: time.NewTicker(window),
stopCh: make(chan struct{}),
}
// 启动后台 goroutine,每个窗口到期重置计数器
go func() {
for {
select {
case <-l.ticker.C:
// 窗口到期,原子重置计数器为 0
l.counter.Store(0)
case <-l.stopCh:
l.ticker.Stop()
return
}
}
}()
return l
}
// Allow 判断当前请求是否允许通过
// 返回值 true 表示放行,false 表示拒绝
func (l *FixedWindowLimiter) Allow() bool {
// 原子递增计数器,并获取递增后的值
current := l.counter.Add(1)
// 如果超过限制,则拒绝
if current > l.limit {
return false
}
return true
}
// Stop 停止限流器,释放资源
func (l *FixedWindowLimiter) Stop() {
close(l.stopCh)
}
3.5 使用示例
以下代码演示了如何在 HTTP 处理器中使用该限流器:
package main
import (
"fmt"
"net/http"
"time"
"yourproject/limiter"
)
func main() {
// 创建限流器:每秒最多允许 10 个请求
l := limiter.NewFixedWindowLimiter(time.Second, 10)
defer l.Stop()
http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
if !l.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
fmt.Fprintln(w, "请求成功")
})
http.ListenAndServe(":8080", nil)
}
3.6 关键实现细节
- 原子操作保证线程安全:
counter.Add(1)是原子操作,多个 goroutine 并发调用时不会出现数据竞争,无需加锁。 - 后台 goroutine 重置计数器: 定时器到期后通过
Store(0)原子重置,避免在请求路径上执行重置逻辑。 - 优雅关闭: 通过
stopCh通知后台 goroutine 退出,防止 goroutine 泄漏。 - 边界判断: 使用
> limit而非>= limit,确保第limit个请求能够通过。
3.7 性能与局限性分析
| 维度 | 评价 |
|---|---|
| 性能 | 极高。每次判断仅需一次原子加法操作,无锁、无内存分配,单机可达百万 QPS |
| 内存开销 | 极低。仅需几个 int64 和 channel,几乎可以忽略 |
| 实现复杂度 | 低。核心代码不到 50 行,易于理解和维护 |
| 临界突发问题 | 存在。窗口切换瞬间可能允许 2 倍流量通过,对突发敏感的业务不适用 |
| 适用场景 | 对突发不敏感、追求极致性能的场景,如日志上报、非关键统计接口 |
避坑指南: 不要在生产环境对核心业务接口直接使用固定窗口计数器。临界突发问题可能导致瞬间流量尖峰击穿下游服务。后续章节将介绍滑动窗口和令牌桶算法来弥补这一缺陷。
3.8 与 sync.Mutex 实现的对比
如果不使用 sync/atomic,也可以使用 sync.Mutex 加锁实现计数器。以下是两种方式的对比:
| 对比项 | sync/atomic 实现 | sync.Mutex 实现 |
|---|---|---|
| 性能 | 纳秒级,无锁竞争 | 微秒级,存在锁竞争和上下文切换 |
| 代码复杂度 | 低,无需显式加解锁 | 中等,需要小心 Lock/Unlock 配对 |
| 可扩展性 | 高,原子操作在大量并发下表现稳定 | 低,高并发下锁成为瓶颈 |
| 推荐度 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
在高并发限流场景下,sync/atomic 是更优的选择。它利用 CPU 提供的原子指令,避免了操作系统级别的锁调度开销。