限流(Rate Limiting)是保护后端服务免受过载攻击或突发流量冲击的关键技术。当请求速率超过系统处理能力时,限流服务能够拒绝多余请求或将其排队,从而避免服务雪崩。常见的限流位置包括网关层、应用层或独立中间件。独立限流服务的好处是与业务解耦,可以横向扩展,并且能统一管理不同接口的限流规则。本文将以 Docker 为基础,使用 Redis 存储令牌桶状态,结合 Lua 脚本保证操作原子性,构建一个简单但生产可用的限流服务。

在开始之前,需要明确限流算法的选择。固定窗口计数器实现简单,但存在临界突发问题;滑动窗口更平滑但存储开销较大;漏桶算法强制恒定输出速率,可能浪费系统处理能力;令牌桶算法允许一定程度的突发流量,同时保持平均速率可控,是实践中应用最广泛的方案。令牌桶的核心思想是:系统以固定速率向桶中放入令牌,桶有最大容量,请求到达时需要先获取令牌,获取成功则处理请求,否则拒绝或等待。使用 Redis 实现令牌桶时,需要注意并发访问的原子性,否则会出现超发或令牌丢失。通过 Redis 的 Lua 脚本可以一次性完成读取、判断和更新操作,天然避免竞态条件。
一、令牌桶算法的核心原理与 Redis 实现
令牌桶算法维护两个关键参数:令牌生成速率(rate)和桶容量(capacity)。速率决定了长期平均允许的请求频率,容量决定了允许的瞬时突发大小。例如 rate=10 表示每秒生成 10 个令牌,capacity=20 表示最多积累 20 个令牌。当请求到来时,先计算当前时间与上次填充时间的间隔,按照速率补充令牌,但不超过容量上限;然后判断桶中是否有令牌,有则扣减一个并放行,无则拒绝。在 Redis 中,我们可以使用一个哈希表存储每个限流键(例如用户 ID 或接口路径)的上次填充时间和当前令牌数。为了避免多客户端并发修改导致的不一致,需要将整个逻辑封装在 Lua 脚本中执行,因为 Redis 执行 Lua 脚本是原子性的。
下面给出一个完整的 Lua 脚本示例,它接受四个参数:限流键、速率、容量、当前时间戳(由客户端传入以保证可测试性)。脚本返回一个数组,第一个元素为 1 表示允许,0 表示拒绝;第二个元素为当前令牌数。注意脚本中使用了 Redis 的 redis.call 命令,所有操作都在一个原子上下文中完成。
-- token_bucket.lua
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'last_time', 'tokens')
local last_time = tonumber(data[1]) or now
local tokens = tonumber(data[2]) or capacity
-- 计算经过的时间(秒),并补充令牌
local delta = math.max(0, now - last_time)
local filled_tokens = math.floor(delta * rate)
if filled_tokens > 0 then
tokens = math.min(capacity, tokens + filled_tokens)
last_time = now
end
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'last_time', last_time, 'tokens', tokens)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
return {allowed, tokens}
上面的脚本中,last_time 和 tokens 存储在同一个哈希键下。每次请求先更新令牌数量,再判断是否可扣减。为了避免过期键积累,设置了过期时间,大约为桶容量所需时间再加一秒。在实际 Java、Python 或 Go 服务中,我们只需通过 Redis 客户端调用 EVAL 命令执行该脚本即可。为了减少脚本传输开销,也可以使用 SCRIPT LOAD 和 EVALSHA 方式。
二、限流服务的应用层封装
接下来我们使用 Python 的 Flask 框架编写一个简单的 HTTP 限流服务,该服务暴露一个检查接口,业务方调用该接口并传入 service、user_id、rate、capacity 等参数,服务返回是否允许通过。当然,更通用的做法是将限流逻辑嵌入中间件,但独立服务便于演示和 Docker 化。这里选择 Flask 是为了代码简洁,实际生产环境可以使用 FastAPI 或 Go 提升性能。我们使用 redis-py 客户端,并预加载 Lua 脚本。
import time
import redis
from flask import Flask, request, jsonify
app = Flask(__name__)
r = redis.Redis(host='redis', port=6379, decode_responses=True)
# 加载 Lua 脚本
with open('token_bucket.lua', 'r') as f:
lua_script = f.read()
token_bucket = r.register_script(lua_script)
@app.route('/check', methods=['POST'])
def check():
data = request.get_json()
service = data.get('service', 'default')
user_id = data.get('user_id', 'anonymous')
rate = float(data.get('rate', 10))
capacity = int(data.get('capacity', 20))
key = f'rate_limit:{service}:{user_id}'
now = time.time()
# 调用 Lua 脚本
allowed, tokens = token_bucket(keys=[key], args=[rate, capacity, now])
return jsonify({
'allowed': bool(allowed),
'tokens': int(tokens),
'service': service,
'user_id': user_id
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
代码中,register_script 方法会先尝试 EVALSHA,如果脚本未缓存则回退到 EVAL 并缓存,提高了后续调用的效率。通过 redis.Redis(host='redis') 连接到 Docker 网络中的 Redis 容器,这是容器化部署的关键点,后文会进行配置。需要注意的是,时间戳由客户端传入可能会导致不同业务服务器时钟不一致的问题,但在单机或者统一授时环境下可以接受;如果要求严格,可以使用 Redis 的 TIME 命令获取服务器时间,但会增加一次往返。这里为了简单,采用客户端时间。
三、使用 Docker 构建与运行限流服务
现在将限流服务容器化。首先编写 Dockerfile,基于官方 Python 镜像,安装依赖并复制代码。由于脚本需要 token_bucket.lua 文件,需一并复制进去。
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "app.py"]
requirements.txt 内容很简单,包含 Flask 和 redis:
Flask==3.0.0 redis==5.0.1
然后编写 docker-compose.yml,同时启动 Redis 和限流服务,并让它们处于同一个自定义网络中,这样限流服务可以通过主机名 redis 访问 Redis 容器。
version: '3.8'
services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
rate-limiter:
build: .
restart: unless-stopped
ports:
- "8000:8000"
depends_on:
- redis
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
volumes:
redis_data:
在终端中执行 docker-compose up -d 即可构建并启动两个容器。启动后,限流服务监听宿主机的 8000 端口。可以使用 docker-compose logs -f rate-limiter 查看日志。如果不想使用 Compose,也可以手动运行两个容器:先启动 Redis,再通过 --link 或自定义网络连接。Compose 方式更简洁,且易于管理。
四、测试与参数调优
服务启动后,使用 curl 发送 POST 请求进行测试。例如设置每秒允许 2 个请求,容量为 3,连续发送 5 个请求,观察返回结果。前 3 个请求会立即通过,第 4、5 个请求返回 allowed: false。注意 capacity 为 3 意味着初始桶中有 3 个令牌,加上生成速率,短时间内可能允许更多请求,这是令牌桶的正常行为。
curl -X POST http://localhost:8000/check
-H "Content-Type: application/json"
-d '{"service":"api","user_id":"user123","rate":2,"capacity":3}'
为了更直观地观察限流效果,可以使用 ApacheBench(ab)进行并发测试。例如模拟 20 个并发请求,总共 100 个请求,查看有多少被放行。由于限流服务的响应非常快,ab 的输出中 Failed requests 并不准确,我们需要观察服务返回的 JSON 中的 allowed 字段。可以编写一个简单的脚本来统计。实际调优时,应根据业务需求调整 rate 和 capacity:增大 rate 提高吞吐,增大 capacity 容忍更大的突发流量。另外,Redis 的内存和网络延迟也会影响限流精度,建议使用本地或同机房的 Redis 实例。
生产环境中,限流服务自身也可能成为瓶颈。可以通过以下方式优化:使用连接池复用 Redis 连接;将限流逻辑改为异步非阻塞(如 FastAPI + aioredis);在多实例部署时,使用一致性哈希或共享 Redis 确保同一用户的请求路由到同一限流键;还可以对 Lua 脚本进行 SCRIPT LOAD 预加载,减少网络开销。监控方面,可以导出 Prometheus 指标,观察被拒绝的请求数、令牌桶填充延迟等。
本文构建的限流服务虽然简单,但完整展示了独立限流组件的核心设计思路:算法选型、原子性保证、容器化部署和测试调优。你可以基于此扩展更多功能,如多维度限流(IP、接口、租户)、动态规则加载、分布式限流等。Docker 的引入让部署和迁移变得极其方便,无论是本地开发还是生产环境,都能快速拉起一套限流基础设施。