Why and how you should rate-limit your API

2025-06-10

为什么以及如何限制 API 的速率

打开大门

就是这样!你的闪亮新产品即将发布!你为此付出了辛勤的努力,它一定会大受欢迎!一大批用户正迫不及待地想要试用它。

让我进去!

你自信地按下了按钮。服务上线了!你的数据分析显示,越来越多的用户正在涌入。你的广告和营销活动非常有效。

随着数据的增长,你会感到不堪重负,就像你的云基础设施一样。它们实在是太多了。你的错误追踪器开始吐出成千上万份报告。你的监控状态从绿色变成黄色,再变成红色……你还没有准备好应对如此巨大的流量。你以为你已经准备好了,但事实并非如此。

解决问题

刻不容缓,你必须解决这个问题。你不能让你的首次用户体验如此糟糕。你决定暂时关闭服务,然后再处理这个问题。但现在,没人能用了。这感觉不对劲。

打开门一点 meme

💡 没错!你只需要稍微打开一点门。这样就能让基础设施能够控制的流量进入。

剩下的人至少会被告知目前没有空位了。这总比体验极其缓慢和故障要好。


速率限制

你刚刚设置的就是速率限制。它是每个暴露在互联网上的系统的关键组件。

这个想法很简单:通过限制一定时间内的请求数量来管理流量。例如,可以限制为“每分钟 100 个请求”。

用例

速率限制解决了几个问题。

  • 稳定性:通过限制负载,可以减轻基础设施的压力。
  • 成本控制:永远不要无限制地自动扩展。否则,你必然会收到云提供商开出的账单,这笔账单足以让你破产。当你对服务使用量设置明确的限制时,你就知道会发生什么。
  • 用户体验和安全:如果配置正确,只有滥用的用户才会达到速率限制。这样,诚实的用户就不必为少数恶意用户所苦。
  • 数据控制:任何服务被机器人或恶意攻击者访问并试图窃取其可访问的所有数据的情况并不少见。速率限制是阻止爬虫攻击的有效方法。

缺点

没有完美的解决方案。速率限制也存在不少缺点。

  • 复杂性:这个看似简单的想法背后隐藏着巨大的复杂性。正确设置它并非易事。您可以使用多种策略。您必须仔细计算和调整速率。某些应用程序还需要正确处理请求突发。
  • 用户体验:这是一把双刃剑。如果用户真的达到了极限,他们就会感到沮丧。当你效率很高的时候,停下来等待绝对不是什么愉快的事。
  • 扩展:随着规模的扩大,速率限制需要持续监控和调整。新功能推出时,可能需要提高限制。您是倾向于扩展基础架构,还是试图尽可能多地压缩用户,直到其性能下降?

话虽如此,我还是想强调一下,没有完美的限流方法。您必须根据自己的服务和业务情况做出自己的选择。


走进兔子洞

事情变得越来越有趣,但也越来越复杂。让我们深入探索这个“兔子洞”,希望找到最适合你的方案。

有人跳进洞里

代理与应用程序速率限制

首先,你应该问问自己需要在哪个级别设置速率限制。这里有两个选项:

  • 代理级别允许您在用户实际访问您的服务之前对其进行速率限制。就性能和安全性而言,这是最有效的方法。大多数云提供商都提供内置解决方案来为您处理此问题。
  • 应用级别允许对配额进行更细粒度的控制。您可以根据用户是否经过身份验证或是否拥有特殊权限来调整配额。这甚至可以让您通过允许付费客户使用更高的限额来潜在地将您的 API 货币化。

为什么不能两者兼而有之?

同时选择两种解决方案可能会很有趣。您可以使用代理级别进行 DDoS 防护,并避免服务过载。同时,您还可以使用应用程序级别,在其中执行一些业务逻辑。

政策

有许多不同的策略可用于计算用户配额。每种策略都有其适用场景,因此您需要选择最适合自己的策略。我们不会深入探讨细节,但会介绍四种最常见的速率限制策略。

固定窗口

固定窗口图

此策略最为简单:速率限制在固定的时间窗口内应用。每个人都有一个请求计数器,每隔几分钟就会重置一次。如果计数器超出允许的配额,则请求将被拒绝。

它的主要缺点是根本无法处理突发流量。假设你的请求配额是每分钟 100 个。当计数器重置后,所有用户都可能同时发送 100 个请求。

我也可以非常严格。如果窗口太长,用户可能需要等待很长时间才能再次发送请求。如果窗口太短,速率限制的效果就会降低。

滑动窗口

滑动窗口图

此策略是对固定窗口的改进。简而言之,它会跟踪某个用户在最后时刻发出的请求,而不是一次性重置所有计数器。

假设我们有一个 1 分钟的窗口,配额为 100 个请求。如果用户在过去 60 秒内执行的请求少于 100 个,则该请求被接受。因此,窗口相对于当前时间不断滑动。

这项政策仍然非常严格,但可以确保交通畅通

令牌桶

令牌桶图

令牌桶一种完全不同的方法。其理念是,每个用户都有一个装满指定数量令牌的桶。执行请求时,用户会从桶中取出一个令牌。如果桶为空,则拒绝该请求。桶会按照预先定义的速率重新填充,直到再次装满为止。

此策略非常适合处理短时间突发流量和峰值,并具有长期平滑的速率限制。

漏水桶

漏桶图

漏桶的工作原理有点像漏斗。每个用户一开始都有一个底部有孔的空桶。这个孔的宽度或多或少,在单位时间内只允许固定数量的请求通过。随着请求的涌入,桶的填充速度会比排水速度更快。最终,桶满了,溢出了:所有新请求都会被拒绝。

打个比方,底部开口的宽度代表速率桶的深度代表爆破力

此策略是四种策略中最灵活的。它可以根据流量轻松调整,并平滑流量。

HTTP 标准

在撰写本文时,我们最接近的 HTTP 速率限制标准是这个已过期的IETF 草案

简而言之,该文档定义了一组HTTP 标头,可用于通知客户端其配额和所使用的策略。

不幸的是,很难知道它会走向何方,或者它是否会被彻底放弃。这是我们目前最好的结果,所以让我们继续吧。


执行

让我们开始吧

在我们的示例中,我们将在应用程序级别进行操作。我们将使用GoRedis漏桶策略。为了避免自行实现算法,我们将使用go-redis/redis_rate库。

为什么我们需要Redis?

Redis是一个键/值存储系统,我们将用它来存储用户计数器。在一个可扩展到一定数量实例的分布式系统中,您肯定不希望每个实例都单独保存计数器。这意味着速率限制是针对实例进行的,而不是针对您的服务的,这基本上毫无意义。

速率限制服务

让我们从实现一个不可知服务开始。这样,我们可以轻松地将它与任何框架或库一起使用。

让我们创建一个新ratelimit包,导入我们的库,并为我们的服务设置基础:

// service/ratelimit/ratelimit.go
package ratelimit

import (
    //...
    rate "github.com/go-redis/redis_rate/v10"
    "github.com/redis/go-redis/v9"
)

type Service struct {
    limiter *rate.Limiter
    limit   rate.Limit
}

func NewService(redisClient redis.UniversalClient, limit rate.Limit) *Service {
    return &Service{
        limiter: rate.NewLimiter(redisClient),
        limit:   limit,
    }
}
Enter fullscreen mode Exit fullscreen mode

接下来让我们介绍一个简单的Allow()方法。

// Allow checks if the given client ID should be rate limited.
// If an error is returned, it should not prevent the user from accessing the service
// (fail-open principle).
func (s *Service) Allow(ctx context.Context, clientID string) (*rate.Result, error) {
    return s.limiter.Allow(ctx, fmt.Sprintf("client-id:%s", clientID), s.limit)
}
Enter fullscreen mode Exit fullscreen mode

我们采用故障开放原则。它非常适合高可用性服务,因为在这种情况下,阻止所有流量比让流量过大更有害。

对于资源密集型的操作,即使速率限制失败,使用故障关闭方法来确保稳定性也是更明智的。

现在,我们还可以实现一个简单的方法,根据前面提到的 IETF 草案更新响应头。

// UpdateHeaders of the HTTP response according to the given result.
// The headers are set following this IETF draft (not yet standard):
// https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers
func (s *Service) UpdateHeaders(headers http.Header, result *rate.Result) {
    headers.Set(
        "RateLimit-Limit",
        strconv.Itoa(result.Limit.Rate),
    )

    headers.Set(
        "RateLimit-Policy",
        fmt.Sprintf(`%d;w=%.f;burst=%d;policy="leaky bucket"`, result.Limit.Rate, math.Ceil(result.Limit.Period.Seconds()), result.Limit.Burst),
    )

    headers.Set(
        "RateLimit-Remaining",
        strconv.Itoa(result.Remaining),
    )

    headers.Set(
        "RateLimit-Reset",
        fmt.Sprintf("%.f", math.Ceil(result.ResetAfter.Seconds())),
    )
}
Enter fullscreen mode Exit fullscreen mode

最后,我们需要识别用户。对于未经身份验证的用户,这可能比较棘手。通常,您需要依赖客户端的 IP 地址。这种方法虽然不完美,但大多数情况下足够了。

// GetDefaultClientID returns the client IP retrieved from the X-Forwarded-For header.
func (s *Service) GetDefaultClientID(headers http.Header) string {
    // X-Forwarded-For: <client-ip>,<load-balancer-ip>
    // or
    // X-Forwarded-For: <supplied-value>,<client-ip>,<load-balancer-ip>
    // We only keep the client-ip.
    parts := strings.Split(headers.Get("X-Forwarded-For"), ",")
    clientIP := parts[0]

    if len(parts) > 2 {
        clientIP = parts[len(parts)-2]
    }

    return strings.TrimSpace(clientIP)
}
Enter fullscreen mode Exit fullscreen mode

⚠️此标头格式是 Google Cloud 负载均衡器使用的格式。根据云提供商的不同,可能会有所不同。

我们现在可以像这样创建我们的服务实例:

opts := &redis.Options{
    Addr:       "127.0.0.1:6379",
    Password:   "",
    DB:         0,
    MaxRetries: -1, // Disable retry
}
redisClient := redis.NewClient(opts)
ratelimitService := ratelimit.NewService(redisClient, rate.PerMinute(200))
Enter fullscreen mode Exit fullscreen mode

当然,理想情况下,我们不应该硬编码所有这些设置。最好使用配置文件或环境变量来配置它们。但这超出了本文的讨论范围。

中间件

现在我们已经完成了速率限制服务,我们需要在新的中间件中使用它

在这个例子中,我们将使用Goyave框架。这个 REST API 框架提供了大量实用的包,并鼓励使用强大的分层架构。我们将以博客示例项目作为起点。

注册我们的服务

第一步是为我们的速率限制服务添加一个名称。

// service/ratelimit/ratelimit.go
import "example-project/service"

func (*Service) Name() string {
    return service.Ratelimit
}
Enter fullscreen mode Exit fullscreen mode
// service/service.go
package service

const (
    //...
    Ratelimit = "ratelimit"
)
Enter fullscreen mode Exit fullscreen mode

然后让我们在我们的服务器中注册它:

// main.go
func registerServices(server *goyave.Server) {
    server.Logger.Info("Registering services")

    opts := &redis.Options{
        Addr:       "127.0.0.1:6379",
        Password:   "",
        DB:         0,
        MaxRetries: -1, // Disable retry
    }
    redisClient := redis.NewClient(opts)
    ratelimitService := ratelimit.NewService(redisClient, rate.PerMinute(200))

    server.RegisterService(ratelimitService)
    //...
}

Enter fullscreen mode Exit fullscreen mode

ℹ️ 您可以在此处找到解释 Goyave 中的服务如何运作的文档

实现中间件

让我们为我们的中间件建立基础。首先,我们将创建一个与我们的速率限制服务兼容的新接口,并将其用作中间件的依赖项。

// http/middleware/ratelimit.go
package middleware

import (
    "context"
    "net/http"

    "goyave.dev/goyave/v5"

    "github.com/go-goyave/goyave-blog-example/service"
    rate "github.com/go-redis/redis_rate/v10"
)

type RatelimitService interface {
    Allow(ctx context.Context, clientID string) (*rate.Result, error)
    GetDefaultClientID(headers http.Header) string
    UpdateHeaders(headers http.Header, result *rate.Result)
}

type Ratelimit struct {
    goyave.Component
    RatelimitService RatelimitService
}

func NewRatelimit() *Ratelimit {
    return &Ratelimit{}
}

func (m *Ratelimit) Init(server *goyave.Server) {
    m.Component.Init(server)
    ratelimitService := server.Service(service.Ratelimit).(RatelimitService)
    m.RatelimitService = ratelimitService
}
Enter fullscreen mode Exit fullscreen mode

现在让我们实现中间件的实际逻辑。我们希望经过身份验证的用户拥有自己的配额,并且访客用户可以通过其 IP 进行识别。

func (m *Ratelimit) getClientID(request *goyave.Request) string {
    if u, ok := request.User.(*dto.InternalUser); ok && u != nil {
        return  strconv.FormatUint(uint64(u.ID), 10)
    }

    return m.RatelimitService.GetDefaultClientID(request.Header())
}
Enter fullscreen mode Exit fullscreen mode

我们只需Handle()实现以下方法:

import (
    //...
    "goyave.dev/goyave/v5/util/errors"
)

func (m *Ratelimit) Handle(next goyave.Handler) goyave.Handler {
    return func(response *goyave.Response, request *goyave.Request) {
        res, err := m.RatelimitService.Allow(request.Context(), m.getClientID(request))
        if err != nil {
            m.Logger().Error(errors.New(err))
            next(response, request)
            return // Fail-open
        }

        m.RatelimitService.UpdateHeaders(response.Header(), res)

        if res.Allowed == 0 {
            response.Status(http.StatusTooManyRequests)
            return
        }
        next(response, request)
    }
}

Enter fullscreen mode Exit fullscreen mode

最后,让我们将其添加为全局中间件,紧接着身份验证中间件。

// http/route/route.go

func Register(server *goyave.Server, router *goyave.Router) {
    //...
    router.GlobalMiddleware(authMiddleware)

    router.GlobalMiddleware(middleware.NewRatelimit())
    //...
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ 您可以在此处找到解释中间件在 Goyave 中如何工作的文档

最后一件事

等等!这里还有一个问题。如果身份验证失败,速率限制中间件将不会被执行。让我们扩展 来auth.JWTAuthenticator处理这种情况。我们只需要让它实现auth.Unauthorizer。此接口允许自定义身份验证器在身份验证失败时定义自定义行为。这样做的目的是,即使身份验证中间件阻止了请求,速率限制中间件也会执行。

让我们创建一个新的自定义身份验证器,它将使用以下组合auth.JWTAuthenticator

// http/auth/jwt.go
package auth

import (
    "net/http"

    "goyave.dev/goyave/v5"
    "goyave.dev/goyave/v5/auth"
)

type JWTAuthenticator[T any] struct {
    *auth.JWTAuthenticator[T]
    ratelimiter goyave.Middleware
}

func NewJWTAuthenticator[T any](userService auth.UserService[T], ratelimiter goyave.Middleware) *JWTAuthenticator[T] {
    return &JWTAuthenticator[T]{
        JWTAuthenticator: auth.NewJWTAuthenticator(userService),
        ratelimiter:      ratelimiter,
    }
}

func (a *JWTAuthenticator[T]) OnUnauthorized(response *goyave.Response, request *goyave.Request, err error) {
    a.ratelimiter.Handle(a.handleFailed(err))(response, request)
}

func (a *JWTAuthenticator[T]) handleFailed(err error) goyave.Handler {
    return func(response *goyave.Response, _ *goyave.Request) {
        response.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()})
    }
}
Enter fullscreen mode Exit fullscreen mode

我们现在需要更新我们的路线:

// http/route/route.go

import (
    //...
    customauth "github.com/go-goyave/goyave-blog-example/http/auth"
)

func Register(server *goyave.Server, router *goyave.Router) {
    //...
    ratelimiter := middleware.NewRatelimit()

    authenticator := customauth.NewJWTAuthenticator(userService, ratelimiter)
    authMiddleware := auth.Middleware(authenticator)
    router.GlobalMiddleware(authMiddleware)

    router.GlobalMiddleware(ratelimiter)
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ 您可以在此处找到解释身份验证器在 Goyave 中如何工作的文档

速率限制实际作用

大功告成!来测试一下吧

全部完成!

在此之前,我们需要在中添加一个redis容器docker-compose.yml

services:
  #...
  redis:
    image: redis:7
    ports:
      - '127.0.0.1:6379:6379'
Enter fullscreen mode Exit fullscreen mode

按照 README 中的说明启动应用程序:

docker compose up -d
dbmate -u postgres://dbuser:secret@127.0.0.1:5432/blog?sslmode=disable -d ./database/migrations --no-dump-schema migrate
go run main.go -seed
Enter fullscreen mode Exit fullscreen mode

让我们用我们信任的朋友来查询我们的服务器curl

curl -v http://localhost:8080/articles
Enter fullscreen mode Exit fullscreen mode

结果:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Ratelimit-Limit: 200
Ratelimit-Policy: 200;w=60;burst=200;policy="leaky bucket"
Ratelimit-Remaining: 198
Ratelimit-Reset: 1
Date: Thu, 27 Jun 2024 12:47:08 GMT
Transfer-Encoding: chunked

{"records":[...
Enter fullscreen mode Exit fullscreen mode

我们可以看到RateLimit标题了。成功了!


结论

您最终可以再次稍微)打开大门,让每个人都可以毫无问题或减速地享受您出色的新产品。

在此过程中,您了解了开始使用速率限制所需的所有知识。尽管并非完美,但此解决方案非常有效!从现在开始,请不要忘记密切监控您的服务,并相应地调整限制。

看看Goyave框架吧!它拥有路由、验证、本地化、模型映射等诸多功能,可以帮助你以多种方式更快地构建更好的 API。

让我们聊聊吧!这篇文章对你有帮助吗?你有什么补充或修改的吗?或者你有什么有趣的经历想和我们分享。我们评论区见!感谢阅读!

鏂囩珷鏉ユ簮锛�https://dev.to/systemglitch/why-and-how-you-should-rate-limit-your-api-2o7d
PREV
和我一起体验 100 天纯 CSS
NEXT
将 dev.to 放到你的 LinkedIn 上?