前言

在后端系统开发中,接口限流是一个非常常见但容易被忽视的基础能力。

很多接口在功能测试阶段看起来没有问题,但一旦进入真实线上环境,就可能遇到以下情况:

  • 某个用户频繁刷新页面,导致接口被大量请求;

  • 某个爬虫短时间内高频访问接口;

  • 某个客户端出现死循环,不断重复调用服务;

  • 秒杀、报名、查询等高并发场景下,请求量瞬间暴涨;

  • 第三方调用方没有做好重试控制,导致接口压力被放大。

如果系统没有限流机制,很容易出现数据库压力过大、缓存被打穿、服务响应变慢,甚至整个应用被拖垮。

本文将使用 FastAPI + Redis 实现一个轻量级接口限流方案,重点讲清楚以下内容:

  1. 什么是接口限流;

  2. 常见限流算法有哪些;

  3. 如何用 Redis 实现固定窗口限流;

  4. 固定窗口算法有什么问题;

  5. 如何进一步实现滑动窗口限流;

  6. 如何在 FastAPI 中封装成通用依赖;

  7. 真实项目中需要注意哪些细节。

本文不依赖复杂框架,重点放在底层逻辑和工程实现。


一、什么是接口限流?

接口限流的核心目标是:

限制某个用户、某个 IP、某个接口或某类资源在单位时间内的访问次数。

例如:

同一个 IP 每分钟最多访问登录接口 10 次
同一个用户每秒最多提交订单 3 次
同一个接口每分钟最多允许 1000 次请求

当请求超过限制后,系统可以直接返回错误,例如:

{
  "code": 429,
  "message": "Too Many Requests"
}

HTTP 协议中,429 Too Many Requests 就是专门用于表示请求过于频繁的状态码。


二、常见限流算法

常见接口限流算法主要有以下几种。

1. 固定窗口算法

固定窗口算法是最简单的限流方式。

例如限制:

每个 IP 每 60 秒最多请求 100 次

系统会以 60 秒为一个窗口,记录当前窗口内的请求次数。

如果请求次数小于 100,则允许访问。

如果请求次数超过 100,则拒绝访问。

优点是实现简单,性能好。

缺点是存在窗口边界问题。

例如:

12:00:59 请求 100 次
12:01:00 请求 100 次

从每分钟限制来看,这两批请求分别落在两个窗口内,都没有超限。

但从真实时间来看,用户在 1 秒左右发送了 200 次请求。

这就会造成瞬时流量突刺。


2. 滑动窗口算法

滑动窗口算法会记录一段时间内的真实请求时间戳。

例如限制:

任意 60 秒内最多请求 100 次

每次请求进来时,系统会先删除 60 秒之前的请求记录,然后统计最近 60 秒内的请求数量。

如果数量小于 100,则允许访问,并记录当前请求时间。

如果数量已经达到 100,则拒绝访问。

滑动窗口比固定窗口更精确,但实现成本和 Redis 操作成本也更高。


3. 令牌桶算法

令牌桶算法会以固定速率向桶中放入令牌。

每次请求都需要消耗一个令牌。

如果桶中有令牌,则允许请求。

如果桶中没有令牌,则拒绝请求或等待。

令牌桶适合允许一定程度的突发流量。


4. 漏桶算法

漏桶算法把请求看作进入桶中的水,请求会以固定速率流出。

如果请求进入速度太快,桶满后新请求就会被丢弃。

漏桶更强调平滑流量,适合控制下游系统的处理速率。


三、环境准备

本文使用以下技术栈:

Python 3.10+
FastAPI
Redis
redis-py
uvicorn

安装依赖:

pip install fastapi uvicorn redis

如果本地没有 Redis,可以使用 Docker 快速启动:

docker run -d \
  --name redis-limiter \
  -p 6379:6379 \
  redis:7

创建项目目录:

rate_limit_demo/
├── main.py
├── redis_client.py
├── limiter.py
└── requirements.txt

四、连接 Redis

先创建 redis_client.py

import redis


redis_client = redis.Redis(
    host="localhost",
    port=6379,
    db=0,
    decode_responses=True
)

这里使用同步 Redis 客户端,方便演示。

在高并发异步项目中,也可以使用 redis.asyncio


五、实现固定窗口限流

固定窗口限流的核心思路是:

每个限流对象生成一个 Redis key
每次请求让 key 对应的计数器加 1
如果是第一次访问,设置过期时间
如果计数超过阈值,则拒绝请求

例如 key 可以设计为:

rate_limit:login:127.0.0.1

表示某个 IP 对登录接口的访问次数。

创建 limiter.py

import time
from fastapi import HTTPException, Request
from redis_client import redis_client


def fixed_window_limiter(
    key: str,
    limit: int,
    window_seconds: int
):
    """
    固定窗口限流

    key: 限流 key
    limit: 窗口内最大请求次数
    window_seconds: 窗口长度,单位秒
    """
    current = redis_client.incr(key)

    if current == 1:
        redis_client.expire(key, window_seconds)

    if current > limit:
        raise HTTPException(
            status_code=429,
            detail="Too Many Requests"
        )

    return True

这个实现非常简单,但有一个细节需要注意:

current = redis_client.incr(key)

INCR 是 Redis 原子操作,因此并发场景下不会出现多个请求同时读取相同旧值的问题。


六、在 FastAPI 接口中使用固定窗口限流

创建 main.py

from fastapi import FastAPI, Request
from limiter import fixed_window_limiter

app = FastAPI()


@app.get("/api/search")
def search(request: Request):
    client_ip = request.client.host

    key = f"rate_limit:search:{client_ip}"

    fixed_window_limiter(
        key=key,
        limit=5,
        window_seconds=60
    )

    return {
        "message": "search success"
    }

启动项目:

uvicorn main:app --reload

访问接口:

curl http://127.0.0.1:8000/api/search

如果同一个 IP 在 60 秒内访问超过 5 次,就会返回:

{
  "detail": "Too Many Requests"
}

状态码为 429


七、固定窗口算法的问题

固定窗口虽然实现简单,但存在明显的边界问题。

假设限制规则是:

每分钟最多请求 5 次

用户在第 59 秒请求 5 次,然后在第 60 秒之后马上又请求 5 次。

从 Redis 计数窗口来看,这 10 次请求分别属于两个周期,因此都可能被允许。

但从实际情况看,用户在极短时间内完成了 10 次请求,仍然可能对服务造成压力。

这就是固定窗口算法的典型缺陷。

为了解决这个问题,可以使用滑动窗口算法。


八、用 Redis ZSET 实现滑动窗口限流

滑动窗口需要记录每次请求的时间戳。

Redis 的有序集合 ZSET 很适合这个场景。

可以把请求时间戳作为 score,把请求唯一标识作为 member。

每次请求时,执行以下步骤:

1. 删除窗口之前的请求记录
2. 统计当前窗口内的请求数量
3. 如果数量达到限制,则拒绝请求
4. 如果没有达到限制,则写入当前请求记录
5. 设置 key 过期时间

例如限制:

任意 60 秒内最多请求 5 次

当前时间是 now,则需要保留:

now - 60 秒 到 now

这段时间内的请求记录。

代码如下:

import time
import uuid
from fastapi import HTTPException
from redis_client import redis_client


def sliding_window_limiter(
    key: str,
    limit: int,
    window_seconds: int
):
    """
    滑动窗口限流

    key: 限流 key
    limit: 窗口内最大请求次数
    window_seconds: 窗口长度,单位秒
    """
    now = time.time()
    window_start = now - window_seconds

    redis_client.zremrangebyscore(key, 0, window_start)

    current_count = redis_client.zcard(key)

    if current_count >= limit:
        raise HTTPException(
            status_code=429,
            detail="Too Many Requests"
        )

    member = f"{now}:{uuid.uuid4()}"

    redis_client.zadd(key, {member: now})
    redis_client.expire(key, window_seconds)

    return True

这里使用了几个 Redis 命令:

ZREMRANGEBYSCORE:删除指定分数范围内的元素
ZCARD:统计集合中元素数量
ZADD:添加元素
EXPIRE:设置过期时间

每一次请求都会清理过期数据,然后检查最近窗口内的请求数量。


九、滑动窗口的并发问题

上面的滑动窗口实现逻辑是正确的,但在极高并发场景下仍然存在一个问题:

删除旧数据
统计数量
写入新请求

这三个步骤不是一个原子操作。

如果多个请求同时进来,可能都在 ZCARD 时看到未超限,然后一起写入,导致短时间内超出限制。

为了让操作原子化,可以使用 Redis Lua 脚本。


十、使用 Lua 脚本保证原子性

Redis 执行 Lua 脚本时,会把脚本作为一个整体执行。

在脚本执行期间,不会被其他命令插入。

因此适合处理限流这种多步骤操作。

Lua 脚本如下:

SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

local window_start = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

local current = redis.call('ZCARD', key)

if current >= limit then
    return 0
end

redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)

return 1
"""

封装成 Python 函数:

import time
import uuid
from fastapi import HTTPException
from redis_client import redis_client


SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

local window_start = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

local current = redis.call('ZCARD', key)

if current >= limit then
    return 0
end

redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)

return 1
"""


def sliding_window_limiter_lua(
    key: str,
    limit: int,
    window_seconds: int
):
    now = time.time()
    member = f"{now}:{uuid.uuid4()}"

    allowed = redis_client.eval(
        SLIDING_WINDOW_LUA,
        1,
        key,
        now,
        window_seconds,
        limit,
        member
    )

    if allowed != 1:
        raise HTTPException(
            status_code=429,
            detail="Too Many Requests"
        )

    return True

这样,清理、统计、判断、写入就变成了一个原子操作。


十一、封装成 FastAPI 依赖

在实际项目中,不建议每个接口都手动写限流逻辑。

可以将限流器封装成 FastAPI 依赖。

from fastapi import Request


def rate_limit(
    limit: int,
    window_seconds: int,
    prefix: str
):
    def dependency(request: Request):
        client_ip = request.client.host
        path = request.url.path

        key = f"rate_limit:{prefix}:{path}:{client_ip}"

        sliding_window_limiter_lua(
            key=key,
            limit=limit,
            window_seconds=window_seconds
        )

        return True

    return dependency

使用方式如下:

from fastapi import FastAPI, Depends

app = FastAPI()


@app.get("/api/search", dependencies=[Depends(rate_limit(
    limit=5,
    window_seconds=60,
    prefix="search"
))])
def search():
    return {
        "message": "search success"
    }


@app.post("/api/login", dependencies=[Depends(rate_limit(
    limit=3,
    window_seconds=60,
    prefix="login"
))])
def login():
    return {
        "message": "login success"
    }

这样不同接口就可以设置不同的限流规则。

例如:

搜索接口:每分钟 5 次
登录接口:每分钟 3 次
提交接口:每分钟 10 次

十二、按用户 ID 限流

上面的代码是按 IP 限流。

但在登录后的业务系统中,更常见的是按用户 ID 限流。

例如:

同一个用户每分钟最多提交 10 次订单

可以从请求头或认证信息中获取用户 ID。

示例:

def user_rate_limit(
    limit: int,
    window_seconds: int,
    prefix: str
):
    def dependency(request: Request):
        user_id = request.headers.get("X-User-Id")

        if not user_id:
            raise HTTPException(
                status_code=401,
                detail="Unauthorized"
            )

        path = request.url.path
        key = f"rate_limit:{prefix}:{path}:user:{user_id}"

        sliding_window_limiter_lua(
            key=key,
            limit=limit,
            window_seconds=window_seconds
        )

        return True

    return dependency

接口中使用:

@app.post("/api/order", dependencies=[Depends(user_rate_limit(
    limit=10,
    window_seconds=60,
    prefix="order"
))])
def create_order():
    return {
        "message": "order created"
    }

需要注意的是,真实项目中用户 ID 不应该直接相信请求头,而应该从认证系统、JWT、Session 或网关传递的可信身份中获取。


十三、返回更友好的错误信息

默认返回:

{
  "detail": "Too Many Requests"
}

对于前端来说可能不够友好。

可以统一返回结构:

from fastapi import HTTPException


def raise_rate_limit_error():
    raise HTTPException(
        status_code=429,
        detail={
            "code": "RATE_LIMITED",
            "message": "请求过于频繁,请稍后再试"
        }
    )

在限流失败时调用:

if allowed != 1:
    raise_rate_limit_error()

这样前端可以根据 code 做统一处理。


十四、增加 Retry-After 响应头

HTTP 中可以使用 Retry-After 告诉客户端多久后可以重试。

示例:

from fastapi import HTTPException


def raise_rate_limit_error(retry_after: int):
    raise HTTPException(
        status_code=429,
        detail={
            "code": "RATE_LIMITED",
            "message": "请求过于频繁,请稍后再试"
        },
        headers={
            "Retry-After": str(retry_after)
        }
    )

例如:

Retry-After: 60

表示客户端可以在 60 秒后重试。

在更精细的滑动窗口实现中,可以根据最早请求时间计算剩余等待时间。


十五、限流 Key 的设计

限流效果很大程度上取决于 key 的设计。

常见 key 维度包括:

按 IP 限流:
rate_limit:ip:{ip}

按用户限流:
rate_limit:user:{user_id}

按接口限流:
rate_limit:path:{path}

按用户 + 接口限流:
rate_limit:user:{user_id}:path:{path}

按 IP + 接口限流:
rate_limit:ip:{ip}:path:{path}

不同场景适合不同粒度。

登录接口

适合按 IP + 账号共同限流:

rate_limit:login:ip:{ip}
rate_limit:login:account:{account}

这样可以防止同一个 IP 爆破多个账号,也可以防止多个 IP 爆破同一个账号。

下单接口

适合按用户 ID 限流:

rate_limit:order:user:{user_id}

公开查询接口

适合按 IP 限流:

rate_limit:query:ip:{ip}

第三方开放接口

适合按 API Key 限流:

rate_limit:openapi:apikey:{api_key}

十六、真实项目中的注意事项

1. 不要只在应用层限流

应用层限流很灵活,但在高流量场景下,请求已经打到了应用服务。

如果流量特别大,应该在更靠前的位置限流,例如:

网关层
负载均衡层
Nginx
API Gateway
服务网格

应用层限流更适合做精细化业务控制。


2. 注意代理场景下的真实 IP

如果服务部署在反向代理后面,直接使用:

request.client.host

拿到的可能是代理服务器 IP,而不是真实客户端 IP。

这种情况下需要读取:

X-Forwarded-For
X-Real-IP

但这些请求头也可能被伪造,因此必须由可信网关统一注入和清洗。


3. Redis 故障时如何处理

限流依赖 Redis。

如果 Redis 不可用,系统需要决定采用哪种策略:

fail-open:Redis 故障时放行请求
fail-closed:Redis 故障时拒绝请求

大多数普通业务接口更适合 fail-open,避免因为 Redis 故障导致主业务全部不可用。

但对于登录、支付、风控等敏感接口,可以根据业务风险选择更严格策略。


4. 避免 Redis key 爆炸

如果 key 维度过细,例如:

用户 ID + IP + 接口 + 设备 + 参数

可能会产生大量 Redis key。

因此 key 设计需要平衡精度和资源消耗。

建议:

  • 设置合理过期时间;

  • 避免把长参数直接拼进 key;

  • 必要时对参数做 hash;

  • 定期监控 Redis key 数量和内存占用。


5. 限流不是权限控制

限流只能限制访问频率,不能替代认证和权限校验。

例如:

限流解决的是“请求太多”的问题
认证解决的是“你是谁”的问题
权限解决的是“你能不能访问”的问题

三者不能混为一谈。


十七、完整示例代码

下面给出一个简化版完整示例。

import time
import uuid
import redis
from fastapi import FastAPI, Request, Depends, HTTPException


redis_client = redis.Redis(
    host="localhost",
    port=6379,
    db=0,
    decode_responses=True
)


SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

local window_start = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

local current = redis.call('ZCARD', key)

if current >= limit then
    return 0
end

redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)

return 1
"""


def raise_rate_limit_error():
    raise HTTPException(
        status_code=429,
        detail={
            "code": "RATE_LIMITED",
            "message": "请求过于频繁,请稍后再试"
        }
    )


def sliding_window_limiter_lua(
    key: str,
    limit: int,
    window_seconds: int
):
    now = time.time()
    member = f"{now}:{uuid.uuid4()}"

    allowed = redis_client.eval(
        SLIDING_WINDOW_LUA,
        1,
        key,
        now,
        window_seconds,
        limit,
        member
    )

    if allowed != 1:
        raise_rate_limit_error()

    return True


def rate_limit(
    limit: int,
    window_seconds: int,
    prefix: str
):
    def dependency(request: Request):
        client_ip = request.client.host
        path = request.url.path

        key = f"rate_limit:{prefix}:{path}:{client_ip}"

        sliding_window_limiter_lua(
            key=key,
            limit=limit,
            window_seconds=window_seconds
        )

        return True

    return dependency


app = FastAPI()


@app.get("/api/search", dependencies=[Depends(rate_limit(
    limit=5,
    window_seconds=60,
    prefix="search"
))])
def search():
    return {
        "message": "search success"
    }


@app.post("/api/submit", dependencies=[Depends(rate_limit(
    limit=3,
    window_seconds=60,
    prefix="submit"
))])
def submit():
    return {
        "message": "submit success"
    }

启动服务:

uvicorn main:app --reload

测试接口:

curl http://127.0.0.1:8000/api/search

连续请求超过限制后,会返回 429。


十八、总结

本文从零实现了一个基于 FastAPI 和 Redis 的接口限流方案。

核心流程可以概括为:

请求进入接口
  ↓
根据 IP、用户 ID 或 API Key 生成限流 key
  ↓
在 Redis 中记录请求时间或请求次数
  ↓
判断是否超过限制
  ↓
允许请求或返回 429

固定窗口算法实现简单,适合基础场景,但存在窗口边界问题。

滑动窗口算法更加精确,可以限制“任意时间段内”的请求次数,更适合对流量控制要求较高的接口。

在工程实践中,限流方案需要结合业务场景选择合适粒度:

  • 公开接口可以按 IP 限流;

  • 用户接口可以按用户 ID 限流;

  • 登录接口可以按 IP 和账号共同限流;

  • 开放平台接口可以按 API Key 限流;

  • 高并发核心接口可以放在网关层提前限流。

接口限流不是一个复杂功能,但它是后端系统稳定性建设中非常重要的一环。

一个好的限流设计,既能保护系统不被异常流量拖垮,也能让正常用户在高峰期获得更稳定的访问体验。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐