用Go编写的一个可组合、事务性开源速率限制器


这是Go 的可组合速率限制器开源项目,下面是作者开发这个项目的来龙去脉:

深夜,城市早已沉入梦乡,写字楼的灯却还亮着。某位程序员,代号o“Clipper”,正坐在屏幕前,眼神呆滞地盯着一段Go语言代码,嘴里喃喃自语:“我要让这个限流器,既优雅又接地气……还得带点烟火气。”

你没听错,烟火气。不是厨房里的油烟,而是代码里那种“活人写的、会呼吸的、偶尔还骂两句脏话”的真实感。他不想写那种教科书式的冷冰冰的限流器,而是想做出一个能理解人类需求、懂得“通融”、甚至能在高并发下还保持风度的“老江湖”。

他想做的,是一个可组合的、带事务语义的、还能自动重试的限流器。听起来像不像一个会做饭、会吵架、还会帮你挡酒的兄弟?



第二章:限流器的“江湖规矩”——令牌桶与时间的哲学

Clipper点了一根烟(其实是电子烟,毕竟办公楼禁明火),开始构思他的“江湖规矩”。

限流,说白了就是控制流量。比如,每秒最多放行10个请求。超过?抱歉,排队,或者直接滚蛋。

他决定用令牌桶算法。这名字听着像武侠小说里的暗器——“令牌”是通行证,“桶”是库存。每过一段时间,系统就往桶里扔一个令牌。请求来了,先掏令牌,有就放行,没有就等着。

但Clipper是个懒人,他不想维护什么“令牌数量”字段。他灵机一动:“时间本身就是令牌。”

什么意思?比如每秒10个令牌,那每个令牌就值100毫秒。桶里存的不是令牌数,而是上次取令牌的时间。现在的时间减去上次时间,再除以100毫秒,就是还能取几个令牌。

于是,他的bucket结构体只有一行代码

go
type bucket struct {
    time time.Time
}

“这叫极简主义,”他得意地对自己说,“别人存令牌,我存时间。别人算数,我算命——哦不,算时间。”

他还写了个小函数判断有没有令牌:

go
func (b *bucket) hasToken(now time.Time, limit Limit) bool {
    threshold := now.Add(-limit.durationPerToken)
    return b.time.Before(threshold)
}

意思是:如果当前时间往前推一个令牌的时间,还比桶里的时间晚,说明桶里还有“时间余额”,也就是还有令牌。

“这不就是‘你还有没有时间陪我’的代码版吗?”他自嘲道,“桶说:我上次陪你的时间是昨天,你现在要陪我?你配吗?”



第三章:第一个Bug——老桶不能装新酒

正当他准备收工时,突然发现一个惊天漏洞。

假设一个桶,已经一小时没更新了。按算法,现在的时间减去桶的时间,除以每个令牌的时间,算出来可能是几万个令牌

“这不就炸了?”他惊出一身冷汗,“一个沉寂一小时的IP,突然复活,就能一口气发几万请求?那还限个屁流!”

他赶紧补救:加个“过期时间”。如果桶的时间太老,比如超过限流周期(比如1分钟),那就只给最大令牌数,不多给。

go
func (b *bucket) remainingTokens(now time.Time, limit Limit) int64 {
    cutoff := now.Add(-limit.period)
    if b.time.Before(cutoff) {
        return limit.count
    }
    return now.Sub(b.time) / limit.durationPerToken
}

“这叫江湖规矩——老账不能翻新篇。”他总结道,“你一小时没来,不代表你有资格预支未来一小时的额度。做人,要守时,也要守本分。”



第四章:Limiter——限流界的“居委会大妈”

接下来是主角登场:Limiter

它不像其他限流器那样冷冰冰地说“拒绝”,而是像个居委会大妈,既讲原则,又通人情。

它的API长这样:

go
perSecond := Limit{count: 10, period: time.Second}

func byIP(req *http.Request) string {
    return req.RemoteAddr
}

limiter := rate.NewLimiter(byIP, perSecond)

你看,byIP是个函数,告诉Limiter:“按IP分桶。” 你想按用户ID分?行。按国家分?也行。甚至按用户心情分——只要你能写出来。

Limiter内部用一个sync.Map存所有桶,线程安全,不怕高并发。

最关键的Allow方法,逻辑如下:

1. 根据请求,算出key(比如IP)。
2. 找到对应的桶。
3. 看看有没有令牌。
4. 有就扣一个,返回true;没有就返回false

“这不就是‘先查后扣’吗?”他说,“跟银行转账一个道理。不能先扣钱再查余额,那会透支。”

他还加了个细节:整个操作用一个时间戳,而不是反复调time.Now()

“为什么?”他自问自答,“因为时间在高并发下是奢侈品。你前一秒查有令牌,后一秒可能就没——因为别人刚扣了。所以,我用一个‘事务时间’,假装整个操作发生在同一瞬间。这叫‘时间冻结’,是限流器的浪漫。”



第五章:多重限流——既要防秒杀,又要防持久战

Clipper的野心不止于此。他想要多重限流

比如:每秒最多10次,但每分钟最多100次。这样既能防短时爆发,又能防长期骚扰。

go
perSecond := Limit{count: 10, period: time.Second}
perMinute := Limit{count: 100, period: time.Minute}

limiter := rate.NewLimiter(byIP, perSecond, perMinute)

问题来了:怎么保证“全通过才扣,一个失败全不扣”?

这就像结婚登记:必须双方都同意,才能领证。不能一个人签字就生效。

他设计了一套“全有或全无”机制:

1. 给所有相关桶加锁(用sync.Mutex)。
2. 检查每个限流规则是否允许。
3. 如果都允许,才扣所有桶的令牌。
4. 任何一个失败,就全部回滚,一个都不扣。

“这叫事务性限流,”他得意地说,“比银行还严谨。你不能说我每秒没超,就让我发1000次——因为每分钟超了。江湖规矩,得一条条守。”

他还调侃:“这就像你去夜店,保安要看三样东西:身份证、健康码、有没有钱。少一个,今晚就别想进去。限流器也一样——少一个条件,就滚。”



第六章:动态限流——你的身份,决定你的额度

但现实世界哪有这么简单?Clipper知道,不同用户,不同待遇

比如:免费用户每秒5次,VIP用户每秒50次。

他引入了LimitFunc——一个能根据请求动态返回限流规则的函数。

go
func byCustomerID(r *http.Request) int {
    return db.GetCustomerID(r.Cookies["CustomerID"])
}

limitFunc := func(r *http.Request) Limit {
    if r.Method == "GET" {
        return readLimit
    }
    return writeLimit
}

limiter := rate.NewLimiterFunc(byCustomerID, limitFunc)

现在,限流器不仅能看你是谁,还能看你想干啥。GET请求?宽松点。POST?严一点。

更狠的是,他支持多个LimitFunc叠加

go
limiter := rate.NewLimiterFunc(byPlan, free, enterprise)

意思是:先看用户套餐,再分别应用免费版和企业版的规则。哪个更严格,就按哪个来。

“这叫策略叠加,”他说,“就像你出国,既要过海关,又要过安检,还得过边防。一层层来,谁也别想蒙混过关。”



第七章:Wait方法——自动重试,做个贴心的限流器

最让他自豪的,是Wait方法。

传统限流器说“不行”就完了。他的限流器会说:“等会儿,马上就有令牌了,我帮你盯着。

go
limiter.Wait(r)

这个方法会自动重试,直到拿到令牌为止。用户不用自己写循环,不用自己算sleep时间。

“这多像一个贴心的助理?”他说,“你不用说‘等令牌有了叫我’,它自己就守着,一有空就喊你。”

实现也不复杂:算出下一个令牌大概什么时候来,然后time.Sleep,醒来再试。但关键是——它在锁里睡

“什么意思?”他解释,“如果我不加锁就睡,醒来时可能令牌又被别人拿走了。所以我得一直抱着桶睡觉,等令牌一到,立刻扣下,不给别人机会。”

“这叫占有式等待,”他总结,“就像你去食堂,让朋友帮你占座。你不能说‘我去买饭,回来再占’——那座位早没了。”



第八章:未来的野心——LimiterStack,限流器的“联盟”

Clipper的野心还没完。他想搞一个LimiterStack——把多个限流器组合成一个。

比如:一个管API调用,一个管数据库写入,一个管支付接口。调用时,必须所有限流器都放行,才算通过。

go
stack := NewLimiterStack(apiLimiter, dbLimiter, paymentLimiter)
stack.Allow(req) // 全通过才true

这就像“多因素认证”:密码对了不行,还得短信验证码、指纹、人脸识别全过。

“这叫防御纵深,”他说,“不能一个漏洞就全崩。限流也得层层设防。”

他还开玩笑:“以后可以搞个‘限流器联盟’,就像复仇者联盟。API限流器是钢铁侠,数据库的是雷神,支付的是美国队长——谁都不能单干。”



第九章:设计哲学——让复杂的事,变得简单

Clipper靠在椅子上,总结他的设计哲学:

1. 可组合:每个功能都是积木,想搭什么搭什么。
2. 事务性:要么全成功,要么全失败,不搞半吊子。
3. 动态性:规则可以变,用户可以分层。
4. 人性化Wait方法自动重试,Allow方法清晰明了。
5. 高效:内存省,锁少,性能高。

“我不是在写代码,”他说,“我是在写一套江湖规矩。它要公平,要灵活,还要能应付各种刁钻的请求。”

他还强调:“用户不需要懂令牌桶算法,但他们得懂自己的业务逻辑。我的工作是把复杂的并发、锁、时间计算藏起来,让他们专注‘我想怎么限流’。”



安装
go get github.com/clipperhouse/rate

例子

// Define a getter for the rate limiter “bucket”
func byIP(req *http.Request) string {
    // You can put arbitrary logic in here. In this case, we’ll just use IP address.
    return req.RemoteAddr
}

// 10 requests per second
limit := rate.NewLimit(10, time.Second)

// 10 requests per second per IP
limiter := rate.NewLimiter(byIP, limit)

// In your HTTP handler, where r is the http.Request
if limiter.Allow(r) {
    w.WriteHeader(http.StatusOK)
    w.Write(byte("Success"))
} else {
    w.WriteHeader(http.StatusTooManyRequests)
    w.Write(byte("Too many requests"))
}

Go 团队提供了golang.org/x/time/rate。他们所说的 alimiter相当于我们bucket上面的类型。
这个包建立在原始概念之上,按键查找存储桶,并适应动态限制。