限流是一份关于获准工作量的预算,需要明确单位、身份、作用范围和时间规则。只说“五次请求”还不够:要说明是谁的请求、覆盖哪些入口与工作进程、按什么区间计数,以及耗尽后如何处理。即使用了共享计数器,也只有所有相关请求都经过同一个权威准入决定,才能落实共享预算。
先定义政策,再开始计数
一次请求可以消耗一个单位,也可以按预计工作量或字节数加权。按准入尝试、成功执行或退还额度分别记账,必须由契约明确规定,不能在实现中悄悄混用。获得预算只意味着允许继续执行,不保证后续业务操作一定成功,也不等于为成功结果预留了全部资源。
窗口算法并不相同。固定窗口可能在边界附近允许突发,滚动窗口衡量的是另一段区间;令牌桶允许迅速花掉已经积累的额度,并不会自动把请求均匀发出。这些算法本身也不限制仍未结束的慢操作数量,准入速率不能替代执行并发上限。
账号、路由、地域和整个服务的预算分别回答不同问题。剩余额度只是某个时刻的快照,并不是为调用方预留的位置:其他工作进程可能先把它用掉。各进程各自维护同样大小的本地计数器,会放大实际额度,除非存在明确的分配规则,将总预算拆给不同参与者。
把准入决定做成原子操作
把数据放进同一个 Redis 键,不会让客户端的一串命令自动变成原子操作。两个调用方可能都读到尚有额度,各自决定继续,再分别递增。每个 INCR 都是原子的,但之前的比较发生在它外面,所以最后总量仍可能超过承诺,不能只检查单条命令的原子性。
下面的示例将比较、首次创建计数器并附加过期时间、后续递增放进一个很短的 Lua 执行。窗口从首次获准请求开始;拒绝既不增加计数,也不延长截止时间。所有调用方传入同一个键和固定的正参数,实验独占这个数值计数器,不与其他业务共享它的写入权限。
这是从首次到达开始计时的窗口,不是持续滑动的配额。脚本执行不被其他命令交错,也不等于自动回滚、持久预留,或与应用业务操作构成同一个事务。Redis 执行脚本时会阻塞其他服务活动,因此应保持脚本简短;连接丢失应视为结果未知,不能直接断言额度从未被扣除。
准确解释拒绝结果
RFC 6585 用 HTTP 429 表示请求速率过高,但把身份选择和计数政策留给服务器决定。响应应解释条件,可以附带 Retry-After;429 响应不得被缓存保存。整个服务暂时不可用,与某个调用方超过自身额度,是两种不同情况,不应仅为了统一返回格式就抹掉区别。
Retry-After 可以表达建议等待时间,却不保证届时一定有额度。如果公开剩余量或重置时间等元数据,接口契约必须定义单位和范围。历史上不同 API 使用的 X-RateLimit 字段,并不由 RFC 6585 赋予一套通用解释;不能因为名字相似,就把某个平台的字段规则当成统一标准。
坏掉的样子
拆开的检查与更新会在竞争中超额放行。把创建计数器和设置过期时间分成两个命令,还可能因为客户端在中间停止,留下不会过期的计数器。键淘汰、重启或异步故障转移也可能丢失预算状态;一台正常服务器上的原子执行,不能证明这些故障中仍有严格硬上限。
应明确预算存储不可用时,请求是否还能获准。静默把错误当成空计数器,就会凭空创造额度。也要保护限流器本身的容量与响应时间,一起观察允许和拒绝的工作量,并区分必要的资源保护,与没有契约依据地提前拒绝仍有权使用预算的请求。
快速验证
准备 POSIX 环境、C 编译器、make、curl、tar 和 Python 3.10 以上版本。本实验已在 macOS、Redis 8.10.1、redis-py 8.1.0、Python 3.14.4 上运行。安装步骤只构建 Redis 核心,并把客户端装入临时环境,不修改系统级安装。
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
在同一个 shell 中,用 "$redis_lab/venv/bin/python" 执行代码。它只启动自己创建的 Redis 进程,通过私有 Unix socket 核对进程身份,关闭 TCP 和持久化,结束后完成清理。十六条独立客户端连接竞争五次准入机会;第一条路径故意展示有错误的检查后更新方式,不应用于真实限流。
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
from threading import Barrier
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
def connection(endpoint):
return redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-budget-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0", "--unixsocket", endpoint,
"--unixsocketperm", "700", "--dir", directory,
"--save", "", "--appendonly", "no",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = connection(endpoint)
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
assert time.monotonic() < deadline, "Redis did not start"
time.sleep(0.02)
assert client.info("server")["process_id"] == process.pid
yield client, endpoint
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
acquire = """
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return {0, current, redis.call('PTTL', KEYS[1])}
end
if current == 0 then
redis.call('SET', KEYS[1], '1', 'PX', ARGV[2])
else
redis.call('INCR', KEYS[1])
end
return {1, current + 1, redis.call('PTTL', KEYS[1])}
"""
LIMIT, WINDOW_MS, WORKERS = 5, 60000, 16
with instance() as (control, endpoint):
def concurrent(operation):
barrier = Barrier(WORKERS)
def run(_):
with connection(endpoint) as client:
return operation(client, barrier)
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
return list(pool.map(run, range(WORKERS)))
control.set("budget:unsafe", 0)
def split_check(client, barrier):
observed = int(client.get("budget:unsafe"))
barrier.wait(timeout=5) # All callers read before any caller increments.
if observed >= LIMIT:
return False
client.incr("budget:unsafe")
return True
unsafe = concurrent(split_check)
assert sum(unsafe) == WORKERS
assert int(control.get("budget:unsafe")) == WORKERS
print("split GET/INCR admits 16 against a budget of 5")
def atomic_check(client, barrier):
barrier.wait(timeout=5)
return client.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)
results = concurrent(atomic_check)
assert sum(reply[0] for reply in results) == LIMIT
assert sorted(reply[1] for reply in results if reply[0]) == list(range(1, LIMIT + 1))
assert all(reply[1] == LIMIT for reply in results if not reply[0])
assert all(0 < reply[2] <= WINDOW_MS for reply in results)
assert control.get("budget:shared") == b"5"
print("one atomic script admits 5 and rejects 11 across independent client connections")
ttl_before = control.pttl("budget:shared")
assert control.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)[:2] == [0, LIMIT]
assert 0 < control.pttl("budget:shared") <= ttl_before
assert control.get("budget:shared") == b"5"
print("a rejection neither spends more budget nor refreshes the window")
assert control.pexpire("budget:shared", 50)
deadline = time.monotonic() + 3
while control.exists("budget:shared"):
assert time.monotonic() < deadline, "test deadline did not expire"
time.sleep(0.01)
first = control.eval(acquire, 1, "budget:shared", LIMIT, WINDOW_MS)
assert first[:2] == [1, 1] and 0 < first[2] <= WINDOW_MS
print("after deliberately shortening and expiring the test window, the next admission starts at 1")
同步屏障保证所有不安全调用者都在任何递增发生之前完成读取,因此可以稳定复现十六次获准。原子脚本则恰好放行五次、拒绝十一次,最终计数为五;五个成功回复中的计数从一到五各出现一次。额外拒绝既没有继续花掉预算,也没有刷新原来的过期截止时间。
实验随后主动把自己的截止时间缩短为五十毫秒,等待真实过期,再确认下一次准入从一开始,并获得新窗口。这避免等待整整一分钟,但没有声称测量了一分钟的真实工作负载。集群、故障转移、持久化、HTTP 中间件、生产延迟,以及准入与业务副作用的联动都未在本例中测试。
延伸阅读
来源
- RFC 6585,第四节的 429 语义、可选 Retry-After 与禁止缓存要求,已核验 2026-09-08。
- RFC 9110,Retry-After 与服务不可用语义,已核验 2026-09-08。
- Redis 脚本介绍,原子执行和阻塞行为,已核验 2026-09-08。
- Redis EVAL,显式传入键与参数,已核验 2026-09-08。
- Redis INCR,计数器操作和限流竞争条件,已核验 2026-09-08。
- Redis PEXPIRE,隔离实验所用的毫秒过期时间,已核验 2026-09-08。
- Redis 8.10.1 构建说明,核心构建与分配器选择,已核验 2026-09-08。