工业网关通常不是单租户、单进程的简单程序:北向 API 会被多个应用调用,南向可能接入大量设备,本地还要承载数据缓存、历史导出、协议任务和云端上报。如果资源使用没有边界,一个异常应用、一个失控采集任务或一次历史导出,就可能挤占其他租户的连接、存储和上行带宽。
Quota 的价值不是简单“把请求拒绝掉”,而是把资源使用显式化:谁能用、能用多少、在什么周期内用、超出后走什么策略。本文从Zenova EdgeOS工程实战角度梳理工业网关中的配额类型、限流算法、Redis 原子实现、数据模型、降级策略和监测体系。
一、为什么工业网关需要 Quota
没有配额治理时,常见问题包括:
| 问题 | 现场表现 | Quota 带来的约束 |
|---|---|---|
| 相互影响 | 一个应用高频轮询,其他应用请求延迟上升 | 按租户 / 应用限制 QPS |
| 成本失控 | 云端 API、上行流量、存储持续增长 | 为流量和存储设置周期额度 |
| 资源不公平 | 高频任务抢占连接、线程和 CPU | 为任务、连接和并发设置上限 |
| 故障定位难 | 只知道网关慢,不知道谁在消耗资源 | 配额维度天然提供用量画像 |
| 下游被打穿 | 网关正常,但数据库或云 API 先过载 | 让网关成为第一层背压点 |
配额设计的第一原则是:先定义保护对象,再定义限额。保护对象可以是网关自身,也可以是数据库、设备通道、云端 API、存储空间或上行带宽。
二、Quota 的核心类型
1. 速率配额
速率配额限制单位时间内的请求数、指令数或消息数,常见指标包括 QPS、RPS、每秒写入条数、每分钟调用次数。
适用场景:
- 北向 OpenAPI 按租户限流;
- 南侧采集任务限制扫描频率;
- 云端上报限制批次速率;
- 告警上报限制风暴扩散。
2. 容量配额
容量配额限制“最多有多少对象”,例如设备数、点位数、租户数、应用数、规则数、报表数。
容量配额通常是长周期或永久限制,适合控制授权边界和内存中的元数据规模。
3. 存储配额
存储配额限制磁盘空间、对象存储容量、历史数据保留量、日志保留量或数据库表大小。
工业现场尤其要注意:
- 历史导出文件必须有配额和过期清理;
- 日志、Trace、指标不能无限保留;
- 离线缓存要设置上限和淘汰策略;
- 磁盘使用率达到软阈值时必须先降级写入,而不是等磁盘满后整体故障。
4. 并发配额
并发配额限制同一时刻的连接数、任务数、线程数、设备会话数或慢查询数。
它和速率配额不同:速率关心“一段时间内来了多少”,并发关心“同一时刻占着多少”。例如 QPS 不高但每个请求耗时很长,仍可能耗尽连接池。
5. 流量配额
流量配额限制上行 / 下行字节数,常见于 4G / 5G 场景。可以按日 / 月设置总额度,也可以按消息类型设置优先级,例如控制指令和告警优先于历史数据补传。
三、配额维度设计
单维 Quota 很容易失效。工程上通常组合多个维度:
| 维度 | 示例 | 说明 |
|---|---|---|
| 租户 | tenant_id | 多租户 SaaS 或多项目隔离 |
| 应用 | app_id | 防止同一租户内应用互相影响 |
| 资源 | api.read、device.command、storage.local | 每类资源单独限额 |
| 实例 | gateway_id | 本地资源边界 |
| 周期 | 秒、分钟、日、月 | 突发控制和总量控制分别设计 |
| 优先级 | control、alarm、telemetry、export | 资源紧张时决定谁先被限制 |
推荐的最小键结构:
quota:{tenant_id}:{app_id}:{resource}:{period}
例如:
quota:tenant-a:scada:api.read:1m
quota:tenant-a:collector:device.command:1s
quota:tenant-a:edge:storage.local:total
不要把所有请求都塞进一个全局 key。全局 key 只能保护网关整体,不能提供租户间的公平性;只有租户 key 而没有全局 key,又可能在大量租户同时超限时打穿下游。
四、限流算法选型
1. 令牌桶:允许突发,控制平均速率
令牌桶按固定速率生成令牌,桶容量决定最大突发量。请求只有拿到令牌才允许通过。
import threading
import time
class TokenBucket:
def __init__(self, rate: float, capacity: float):
if rate <= 0 or capacity <= 0:
raise ValueError("rate and capacity must be positive")
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.updated_at = time.monotonic()
self.lock = threading.Lock()
def acquire(self, tokens: float = 1.0) -> bool:
if tokens <= 0 or tokens > self.capacity:
raise ValueError("tokens must be in (0, capacity]")
with self.lock:
now = time.monotonic()
elapsed = now - self.updated_at
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate,
)
self.updated_at = now
if self.tokens < tokens:
return False
self.tokens -= tokens
return True
适用场景:
- 允许短时间内批量读取;
- 控制平均上报速率;
- 云端 API 有突发额度;
- 下游能承受有限突发。
注意:这个实现只适合单进程。如果网关有多个 worker 或多实例,本地令牌桶会造成实际限流值为 单实例限额 × 实例数,需要 Redis 等中心化实现。
2. 漏桶:平滑输出,保护慢速下游
漏桶把不均匀的输入排队后按固定速率输出,适合保护处理能力稳定的下游。
import asyncio
class LeakyBucket:
def __init__(self, rate: float, capacity: int):
if rate <= 0 or capacity <= 0:
raise ValueError("rate and capacity must be positive")
self.queue: asyncio.Queue[None] = asyncio.Queue(maxsize=capacity)
self.rate = rate
self._task: asyncio.Task | None = None
async def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._leak())
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def acquire(self) -> None:
if self._task is None:
raise RuntimeError("bucket is not started")
await self.queue.put(None)
async def _leak(self) -> None:
interval = 1.0 / self.rate
while True:
await asyncio.sleep(interval)
try:
self.queue.get_nowait()
self.queue.task_done()
except asyncio.QueueEmpty:
pass
使用方式:
async def send_report(bucket: LeakyBucket, payload: dict):
await bucket.acquire()
await upload(payload)
适用场景:
- 控制指令按固定速率下发;
- 历史数据补传不抢占实时数据;
- 下游协议栈处理速度稳定;
- 允许请求排队而不是立即失败。
漏桶要显式设置队列上限、等待超时和取消策略,否则突发流量会把内存队列撑高,或者让请求等待过久。
3. 固定窗口:实现简单,但存在边界突刺
import time
class FixedWindow:
def __init__(self, limit: int, window: float):
self.limit = limit
self.window = window
self.count = 0
self.window_start = time.monotonic()
def acquire(self) -> bool:
now = time.monotonic()
if now - self.window_start >= self.window:
self.count = 0
self.window_start = now
if self.count >= self.limit:
return False
self.count += 1
return True
固定窗口的问题是边界效应:限制每分钟 600 次,前一个窗口最后 10 秒来 600 次,后一个窗口前 10 秒又来 600 次,则 20 秒内实际通过 1200 次。
因此固定窗口更适合粗粒度统计和低风险业务配额,不适合保护连接数敏感或容量敏感的下游。
4. 滑动窗口:更平滑,成本略高
滑动窗口只统计最近一个时间周期内的请求,可以消除固定窗口的边界突变。常见实现有:
- Redis
ZSET保存窗口内唯一请求; - Redis
INCR分桶后加权统计; - 数据库按分钟聚合统计;
- 本地环形队列。
其中 Redis ZSET 逻辑直观,但要注意成员唯一性、窗口清理和原子执行。
5. 并发配额:限制同时占用
import asyncio
class ConcurrencyLimiter:
def __init__(self, limit: int):
self.semaphore = asyncio.Semaphore(limit)
self.active = 0
async def __aenter__(self):
await self.semaphore.acquire()
self.active += 1
return self
async def __aexit__(self, exc_type, exc, tb):
self.active -= 1
self.semaphore.release()
async def read_device(limiter: ConcurrencyLimiter, device_id: str):
async with limiter:
return await query_device(device_id)
并发配额必须配置排队等待时间。否则请求虽然不会同时占用资源,但可能大量堆积在信号量队列中,最终表现为超时和内存增长。
算法选择表
| 算法 | 优势 | 代价 | 适合场景 |
|---|---|---|---|
| 令牌桶 | 平均速率稳定,允许可控突发 | 需要处理桶容量和时钟 | API 调用、数据上报 |
| 漏桶 | 输出平滑,保护慢速下游 | 排队会引入延迟 | 指令下发、历史补传 |
| 固定窗口 | 实现和存储简单 | 窗口边界可能双倍突发 | 日配额、月配额 |
| 滑动窗口 | 控制更精确 | 存储和计算成本更高 | 租户限流、公平调度 |
| 信号量 | 限制同时占用 | 需要超时和排队策略 | 连接、任务、慢查询 |
五、Redis 分布式限流
多实例网关不能只依赖进程内计数。假设每个实例本地限制 100 QPS,部署 5 个实例后,全局可能达到 500 QPS。若要全局公平,需要中心化计数。
1. 原子滑动窗口 Lua 脚本
下面的脚本用 Redis 服务端时间避免多网关本机时钟不一致,用唯一请求 ID 作为 ZSET member,避免同一毫秒内的请求互相覆盖:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local request_id = ARGV[3]
local clock = redis.call('TIME')
local now_ms = clock[1] * 1000 + math.floor(clock[2] / 1000)
local cutoff_ms = now_ms - window_ms
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff_ms)
local count = redis.call('ZCARD', key)
if count >= limit then
redis.call('PEXPIRE', key, window_ms)
return 0
end
redis.call('ZADD', key, now_ms, request_id)
redis.call('PEXPIRE', key, window_ms)
return 1
Python 调用示例:
import uuid
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local request_id = ARGV[3]
local clock = redis.call('TIME')
local now_ms = clock[1] * 1000 + math.floor(clock[2] / 1000)
local cutoff_ms = now_ms - window_ms
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff_ms)
local count = redis.call('ZCARD', key)
if count >= limit then
redis.call('PEXPIRE', key, window_ms)
return 0
end
redis.call('ZADD', key, now_ms, request_id)
redis.call('PEXPIRE', key, window_ms)
return 1
"""
async def allow_request(
redis,
tenant_id: str,
resource: str,
limit: int,
window_ms: int,
) -> bool:
key = f"quota:{tenant_id}:{resource}:sliding"
request_id = str(uuid.uuid4())
allowed = await redis.eval(
SLIDING_WINDOW_LUA,
1,
key,
limit,
window_ms,
request_id,
)
return bool(allowed)
这里不能用普通 pipeline 替代 Lua:pipeline 只保证命令打包发送,不保证其他客户端不会在 ZCARD 和 ZADD 之间插入请求。分布式限流的核心是检查和写入必须原子执行。
原文示例里还有一个隐蔽问题:在 pipeline 中先执行 ZADD,再根据旧 count 判断是否允许。这会导致被拒绝的请求也被写入窗口,统计被拒绝流量污染。正确逻辑必须是“达到上限直接返回,未达到才写入”。
2. 固定窗口原子计数
日配额、月配额不需要滑动窗口,可以用 INCR:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl_ms = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, ttl_ms)
end
if current > limit then
return 0
end
return 1
适合月流量、日报表数、导出次数等粗粒度配额。若业务要求按自然日 / 自然月重置,TTL 应该设置为距离下一个重置点的时间,而不是固定 30 天。
3. Redis 故障时的策略
Quota 中心不可用时,不能只有一个“全部拒绝”或“全部放行”的答案,应按资源风险选择:
| 资源类型 | 建议策略 | 原因 |
|---|---|---|
| 控制指令 | 本地保守限额后放行 | 不能因限流中心故障阻断关键控制 |
| 实时告警 | 放行并记录超额 | 告警比精确限流更重要 |
| 历史数据补传 | 延迟或降级 | 可等待恢复 |
| 历史导出 | 拒绝新任务 | 避免存储和内存被占用 |
| 北向开放 API | 本地配额 + 短 TTL 缓存 | 保持基本公平 |
| 第三方付费 API | fail-closed | 避免成本和配额失控 |
无论选择哪种策略,都必须暴露 quota_backend_error 指标,并在恢复后补齐审计记录。
六、配额数据模型
Redis 适合实时判断,数据库适合保存规则、审计和长期统计。不要只建一张 quotas 表,把规则和当前用量混在一起;否则并发更新和周期重置都容易产生竞态。
CREATE TABLE quota_rules (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
app_id VARCHAR(64) NOT NULL DEFAULT 'ALL',
resource VARCHAR(128) NOT NULL,
period VARCHAR(16) NOT NULL CHECK (period IN ('second', 'minute', 'hour', 'day', 'month', 'total')),
quota_limit BIGINT NOT NULL CHECK (quota_limit > 0),
burst_limit BIGINT,
soft_threshold INTEGER CHECK (soft_threshold BETWEEN 1 AND 100),
action VARCHAR(24) NOT NULL DEFAULT 'reject',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, app_id, resource, period)
);
CREATE TABLE quota_usage (
tenant_id UUID NOT NULL,
app_id VARCHAR(64) NOT NULL DEFAULT 'ALL',
resource VARCHAR(128) NOT NULL,
period VARCHAR(16) NOT NULL,
period_start TIMESTAMP NOT NULL,
used_amount BIGINT NOT NULL DEFAULT 0,
rejected_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, app_id, resource, period, period_start)
);
app_id 固定使用 ALL 表示租户级全局配额,避免 NULL 参与唯一键后出现多条同类规则。
规则变更要带版本号或更新时间。网关侧定期拉取规则,并在 Redis 故障时使用最近一次有效配置。
七、HTTP 入口集成
配额判断应该放在认证和授权之后,这样 key 才能绑定真实租户和应用,而不是匿名流量。
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.middleware("http")
async def quota_middleware(request: Request, call_next):
tenant = request.headers.get("X-Tenant-ID")
if not tenant:
return JSONResponse(status_code=400, content={"message": "tenant is required"})
allowed = await allow_request(
redis=request.app.state.redis,
tenant_id=tenant,
resource="api.read",
limit=600,
window_ms=60_000,
)
if not allowed:
raise HTTPException(
status_code=429,
detail="quota exceeded",
headers={"Retry-After": "1"},
)
return await call_next(request)
接口返回时建议补充:
429 Too Many Requests;Retry-After,提示等待秒数;- 稳定的业务错误码,例如
QUOTA_EXCEEDED; - 响应头中的剩余额度和重置时间,必要时只暴露给内部调用方。
不要把 429 当成 500,也不要让客户端无限立即重试。SDK 应采用指数退避加抖动。
八、边缘场景的降级策略
工业边缘不能简单套用互联网 API 的“超限即拒绝”。不同数据的价值和时效完全不同。
| 数据 / 请求类型 | 超限策略 | 说明 |
|---|---|---|
| 控制指令 | 预留额度,优先通过 | 关键操作不应被历史数据挤占 |
| 实时告警 | 放行、聚合、去重 | 限流目标是防风暴,不是丢关键告警 |
| 实时遥测 | 采样、聚合、降频 | 可用聚合值替代原始高频点 |
| 历史补传 | 排队、暂停、分时补传 | 网络空闲时再执行 |
| 历史导出 | 拒绝或排队 | 防止磁盘、内存和 CPU 被长任务占用 |
| 第三方 API | 背压或降级缓存 | 保护付费额度和外部依赖 |
一个实用的做法是预留额度:总配额的 80% 给普通流量,20% 保留给控制指令和告警。普通流量达到 80% 即触发降级,关键流量仍有一定余量。
九、配额观测与告警
Quota 上线后必须能看到“额度是否合理”,否则很容易从资源失控变成误伤业务。
1. 核心指标
| 指标 | 说明 |
|---|---|
quota_used_ratio | 已用额度 / 总额度 |
quota_rejected_total | 被拒绝请求,按租户和资源聚合 |
quota_check_duration_seconds | 配额判断耗时 |
quota_backend_error_total | Redis / 数据库错误 |
quota_wait_duration_seconds | 排队类任务等待时间 |
quota_active_count | 当前并发占用 |
2. 告警建议
- 使用率达到 80%:提示容量风险和规则评审;
- 使用率达到 95%:准备降级或扩容;
- 拒绝率突增:判断是异常调用、业务增长还是配额过小;
- Redis 错误率上升:确认故障策略生效;
- 配额判断 P99 延迟过高:限流组件正在拖慢主链路;
- 单租户持续接近上限:评估是否调整套餐或隔离热点。
3. 审计事件
关键动作要记录:
- 配额规则创建、修改、禁用;
- 配额被触发时的动作;
- 降级策略切换;
- 手动放行或临时扩额;
- 周期重置。
审计日志建议包含 tenant_id、app_id、resource、quota_limit、used、action、request_id 和操作者。
十、工程实践清单
- 按保护对象设计配额,而不是只按接口路径限制;
- 速率、容量、存储、并发、流量分别建模;
- 令牌桶允许突发,漏桶平滑输出,固定窗口注意边界突刺;
- 多实例场景使用 Redis Lua 保证检查和写入原子;
- Redis ZSET 使用唯一 request ID,避免同毫秒请求覆盖;
- 规则存数据库,实时判断走 Redis,网关本地保留最近有效配置;
- 为控制指令和告警预留额度;
- 为历史补传、导出、报表等低优先级任务设计排队和暂停策略;
- 429 响应携带稳定错误码和
Retry-After; - 监控使用率、拒绝率、后端错误率和判断延迟;
- 配额规则变更进入审计日志;
- 定期回顾误伤率和资源利用率,避免拍脑袋限额。
十一、常见坑与应对
| 常见坑 | 现象 | 应对 |
|---|---|---|
| 本地限流当全局限流 | 实例越多,实际限额越大 | Redis 或中心服务统一判断 |
| 检查和写入不原子 | 并发下超额通过 | Lua、事务或数据库原子更新 |
| 被拒绝请求也计数 | 拒绝流量污染配额窗口 | 只把成功占用的请求写入使用量 |
| ZSET member 用时间戳 | 同一秒请求互相覆盖 | 使用 UUID 或请求 ID |
| 忽略窗口边界 | 固定窗口边界突发翻倍 | 使用滑动窗口或分桶加权 |
| 只有硬限制 | 一个导出任务导致关键数据被拒 | 软阈值 + 优先级 + 预留额度 |
| 队列无上限 | 请求延迟和内存持续增长 | 设置排队上限、等待超时和取消 |
| Redis 故障无预案 | 全部放行或全部拒绝 | 按资源风险选择降级策略 |
| 无观测 | 不知道限额是否合理 | 使用率、拒绝率、错误率、延迟 |
| 规则不可追溯 | 临时调额无审计 | 规则版本化和审计事件 |
十二、TL;DR
- Quota 是资源治理机制,不只是接口限流;
- 类型包括速率、容量、存储、并发和流量;
- 令牌桶适合可控突发,漏桶适合平滑输出,滑动窗口适合精确公平限流;
- 多实例必须中心化,Redis 检查和写入要用 Lua 原子执行;
- 数据库保存规则与审计,Redis 保存实时窗口;
- 工业边缘要按业务优先级降级,为控制指令和告警预留额度;
- 所有配额都要接观测,否则无法判断是保护系统还是误伤业务。
下一步建议
- 盘点网关的北向 API、南向设备任务、存储和上行流量保护对象;
- 设计租户、应用、资源、周期和优先级维度;
- 选择令牌桶、漏桶或滑动窗口,并明确突发与排队策略;
- 实现 Redis 原子限流和本地故障降级;
- 建立配额规则表、审计事件和使用率监控。
2335



被折叠的 条评论
为什么被折叠?



