10万并发来袭:Go凭什么能扛住?我写了5年代码才敢说这3句话
凌晨三点,你的手机响了。生产告警:OOM Kill。
你揉着眼睛打开DashBoard,发现罪魁祸首是一个看似无害的接口——它负责处理用户导入的Excel文件,并发一上来, goroutine 直接爆了。
这时候你可能会想:Go不是号称轻量级并发吗?goroutine 不是才几KB吗?是的,但"便宜"不等于"免费"。当你的系统面临真实洪流时,不懂限流的后端工程师,迟早会被限流教做人。
今天聊三句话,每句都是我用线上故障换来的。
第一句话:Go的并发很便宜,但你不能无限挥霍
goroutine 确实便宜,初始栈只有 2KB,动态增长到 1GB 都没问题。但这不意味着你可以肆无忌惮地 spawn goroutine。
我见过最离谱的代码是这样的:
func ProcessUsers(userIDs []int64) {
for _, id := range userIDs {
go processOne(id) // 10万个用户 = 10万个goroutine
}
}
这段代码跑在测试环境美滋滋,一上生产就是灾难。10万并发进来,内存瞬间爆炸,GC 开始疯狂标记,整个服务卡成PPT。
限流不是限制你的能力,而是保护你的系统不被自己的"能力"杀死。
第二句话:信号量是最被低估的限流工具
很多人一提到限流就想到令牌桶、漏桶。但其实 golang.org/x/sync/semaphore 是最直观、最容易理解的并发控制工具。
它的本质就是一个带权重的信号量,帮你控制同时运行的任务数:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"golang.org/x/sync/semaphore"
)
// 最多同时处理100个请求
var sem = semaphore.NewWeighted(100)
func processWithSemaphore(ctx context.Context, id int) error {
// 尝试获取1个许可证,如果满了就阻塞等待
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
// 模拟实际工作
time.Sleep(100 * time.Millisecond)
fmt.Printf("处理完成: %d\n", id)
return nil
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := 1
// 带超时的获取,避免无限阻塞
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := processWithSemaphore(ctx, id); err != nil {
http.Error(w, "服务繁忙,请稍后重试", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
}
func main() {
log.Println("服务启动: :8080")
log.Fatalln(http.ListenAndServe(":8080", nil))
}
这段代码的核心逻辑:sem.Acquire 会阻塞,直到有空闲位置,最多等5秒。超时就返回错误,接口直接返回503。
有人会问:这和 sync.WaitGroup 有什么区别?区别大了:WaitGroup 只能控制"等全部完成",而信号量可以控制"同时进行多少个"。一个是计数器,一个是门槛。
第三句话:令牌桶才是生产级限流的标准答案
信号量解决了"同时多少个"的问题,但没有解决"每秒钟多少个"的问题。
现实场景更常见的是:我想限制QPS为5000,而不是同时5000个请求都在跑。这时候你需要令牌桶算法。
经典的实现有两种:
方案A:手写一个(适合学习)
package limiter
import (
"sync"
"time"
)
type TokenBucket struct {
rate int64 // 每秒产生的令牌数
capacity int64 // 桶的容量
tokens int64 // 当前令牌数
lastUpdate time.Time // 上次更新时间
mu sync.Mutex
}
func NewTokenBucket(rate, capacity int64) *TokenBucket {
return &TokenBucket{
rate: rate,
capacity: capacity,
tokens: capacity,
lastUpdate: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
return tb.AllowN(1)
}
func (tb *TokenBucket) AllowN(n int64) bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
// 根据时间流逝补充令牌
elapsed := now.Sub(tb.lastUpdate).Seconds()
tb.tokens += int64(elapsed * float64(tb.rate))
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
tb.lastUpdate = now
if tb.tokens >= n {
tb.tokens -= n
return true
}
return false
}
方案B:用现成库(适合生产)
package main
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/juju/ratelimit"
)
var (
bucket *ratelimit.Bucket
once sync.Once
)
func getBucket() *ratelimit.Bucket {
once.Do(func() {
// 每秒5000个令牌,桶容量10000
bucket = ratelimit.NewBucketWithRate(5000, 10000)
})
return bucket
}
func handler(w http.ResponseWriter, r *http.Request) {
// 尝试获取1个令牌,阻塞直到可用
tb := getBucket()
tb.Wait(1)
// 业务逻辑
fmt.Fprintf(w, "处理请求中...\n")
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
方案A让你理解原理,方案B让你快速上线。面试的时候手写算法,生产的时候用库。这不是双标,这是专业。
Bonus:滑动窗口限流,应对突发流量更优雅
令牌桶有个小问题:它允许突发流量一瞬间占满桶,这在某些场景下还是会打垮下游。
滑动窗口算法可以做到更平滑的限流:
package limiter
import (
"sync"
"time"
)
type SlideWindowLimiter struct {
windowSize time.Duration
maxReqs int64
requests []time.Time
mu sync.Mutex
}
func NewSlideWindow(windowSize time.Duration, maxReqs int64) *SlideWindowLimiter {
return &SlideWindowLimiter{
windowSize: windowSize,
maxReqs: maxReqs,
requests: make([]time.Time, 0),
}
}
func (sw *SlideWindowLimiter) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
cutoff := now.Add(-sw.windowSize)
// 清理窗口外的请求
idx := 0
for i, t := range sw.requests {
if t.After(cutoff) {
idx = i
break
}
}
sw.requests = sw.requests[idx:]
if int64(len(sw.requests)) < sw.maxReqs {
sw.requests = append(sw.requests, now)
return true
}
return false
}
滑动窗口的核心思想:统计当前时间窗口内的请求数,超过阈值就拒绝。相比固定窗口(Redis SETNX方案),它的精度更高,不会在窗口边界出现流量突刺。
说点得罪人的话
很多人觉得限流是个小技巧,其实它体现的是你对系统容量有没有清醒的认知。
那些不做限流就上线的服务,要么是初创阶段赌运气,要么是根本不知道自己的系统在临界点会变成什么样子。
限流做好只是第一步:你还需要监控、告警、熔断、降级、扩容。这是一个体系,不是一个配置。
下次半夜被报警吵醒的时候,你至少可以先确认一件事:你的限流有没有生效。
如果没有,那这篇文章就值了。