做网关这类中间层,最怕的不是流量大,而是下游慢。流量大无非是机器扛不扛得住,水平扩展就能解决;下游慢则是顺着调用链往上传染的,线程池、连接池、TCP缓冲区一层层被拖死。我见过不止一次类似场景:核心服务本身没毛病,只是某个第三方接口或者数据库抖动了一下,网关线程就全卡在等待上,客户端等不到响应开始重试,重试又压回来,最后整条链路被拖到雪崩。今天想聊的就是我怎么用 Redis Stream 在网关这一层做“抗背压边缘缓冲”,把这种连锁反应在门口拦住。这套方案我已经在几个异步写入型的链路上落地过,实测下来很稳,而且 Python 实现并不复杂。如果你也在处理网关稳定性、削峰填谷、异步化改造之类的问题,这篇应该能直接给你一套可抄的作业。
1. 背压问题:网关不是被流量压垮的,是被“慢下游”拖垮的
1.1 一次典型事故是怎么发生的
网关被压垮,表象经常是CPU打满、线程池拒绝、接口大面积超时,但根因很少是流量真的到了天文数字,而是某个下游变慢了。
整个过程其实是一连串连锁反应:
- 某个核心下游接口的响应时间从 20ms 涨到 2s;
- 网关的 worker 线程在等待 HTTP response,线程驻留时间变长;
- 线程池很快被占满,新请求开始在队列里排队;
- 排队时间超过客户端超时,客户端直接断连重试;
- 重试流量再次打进网关,线程池更没有空位;
- 不相关的接口也被拖垮,事故范围从一条链路扩散到整个网关。
你可以把它想象成银行柜台:柜员(下游)处理一个客户突然要花10分钟,外面排队(线程池等待)就越排越长;排队的人等烦了就去按铃、去别的窗口插队(重试),整个营业厅秩序就崩了。
这里有个关键词叫“背压”。在数据管道里,当下游消费速度跟不上上游生产速度时,压力会沿着调用链反向传导,最后体现在线程池占满、连接池等待、TCP缓冲堆积上。做网关稳定性的本质工作之一,就是处理这种反向压力——要么挡住它,要么在边缘把它卸掉。
1.2 抗背压的两种姿势:限流和缓冲
处理背压,业内基本两条路线。
一条是限流。流量超过阈值直接拒绝,返回503或429。好处是干净利落,代价是用户体验受损,高峰期的请求直接流失。对一次性、短促的流量尖峰来说,限流其实有点冤——明明下游恢复之后很快就能处理完,你却在尖峰那一刻把请求拒了。
另一条是缓冲。入口先收下请求,放进一个缓冲区,下游按照自己的节奏慢慢消费。这样瞬时尖峰被削平,请求不会立刻失败,下游也不会被突变流量冲垮。缺点也很明显:响应不再是同步的,调用方拿不到即时结果,只能拿到一个“已受理”的回执。
二者的关系不是互斥,而是配合。缓冲解决“短时间尖峰”,限流解决“缓冲也扛不住”的最坏情况。很多网关事故的根源不是限流没做,而是把限流当成了唯一的保命手段,忽略了缓冲这个削峰手段。
1.3 边缘缓冲到底“边缘”在哪
边缘缓冲,指的是把缓冲区放在系统最外侧,也就是接入层或者网关这一层。流量刚进系统就被缓存下来,根本不需要深入到核心业务内部去排队,下游即使抖动,影响范围也被限制在缓冲区和下游之间。
我常用的实现姿势特别简单:网关收到请求后,只做一件事——把请求内容写入 Redis Stream,然后立刻返回成功。后台独立的消费者进程从 Stream 里读消息,再去调用下游。网关入口的请求耗时从几百毫秒变成几毫秒,线程池不会再被慢下游占着不放。
代价是这个方案只适合“响应可以晚一点,但数据最好不要丢”的写入型请求,比如日志采集、埋点上报、订单事件同步、通知推送、索引更新这一类。
如果业务必须同步拿结果,那这套方案不能直接套。你需要在入口之外再搞一个结果回执通道(比如请求 ID 轮询 Redis 里的结果表、或者通过 WebSocket/SSE 推送),复杂度会上去不少。所以动手之前,先想清楚你的业务到底能不能接受异步语义。
2. 工欲善其事:为什么偏偏是 Redis Stream
2.1 先排除几个传统选手
做缓冲的方案其实不少,我先说为什么把其他几个都排除了。
内存队列是大家最容易想到的。Python 里queue.Queue、Java 里ArrayBlockingQueue,写起来很爽,但它有两个硬伤:一是进程内存,多个网关实例之间不共享,流量打进来,有的实例排队有的实例空闲;二是进程重启数据全丢,这在网关场景里是不可接受的。
Redis List 是另一个常被提到的方案。用LPUSH+BRPOP能做一个简单的队列,但 List 没有消费者组的概念,多个消费者同时BRPOP会各自拿到不同的消息,可一旦消费者处理失败,消息就找不回来了。有人会用BRPOPLPUSH或者LMOVE做“安全队列”,把处理中的消息放到另一个列表里,但这相当于自己实现一套消息确认机制,复杂度并不比 Stream 低。
数据库表就更不用说了。用一张表当消息队列表,轮询或者SELECT FOR UPDATE SKIP LOCKED去取待处理数据,低并发勉强能跑,每秒几千上万的消息量会把数据库拖得很惨,而且消费延迟也高。如果消息本身最终要落库,直接异步写表是可以的;如果只是想临时缓冲一下请求,别让数据库干这活儿。
Kafka、RocketMQ 这类消息队列当然能解决所有问题,但对“网关边缘缓冲”这个场景来说,属于杀鸡用牛刀。引入一套 MQ 意味着多一个高可用组件要运维,多一层网络延迟,还要处理分区、副本、积压监控。如果你的需求跨度很大,比如消息要保留好几天、多个团队同时消费、需要分区有序,那直接用 Kafka 是对的;但如果你只是想在网关后面加一道缓冲池,Kafka 的运维成本完全不划算。
2.2 Redis Stream 为什么适合这个位置
Redis Stream 其实是 Redis 5.0 引入的一个原生数据结构,很多人容易忽略它,因为平时 Redis 用得太顺手,反而忘了它有多适合这种场景。
Stream 本质上是一个有序的消息日志,每条消息都有唯一的自增 ID。它最核心的能力是消费者组:同一个 Stream 可以被多个消费者组独立消费,组与组之间互不影响;组内的消息只会被一个消费者读到,天然实现负载均衡。这一点直接解决了 List 做不了的问题。
第二个核心能力是消息确认机制。消费者从 Stream 中读走消息后,消息并不会立即删除,而是进入这个消费者私有的 Pending Entries List(PEL)。只有消费者处理完业务、调用XACK确认之后,消息才会从 PEL 里移除。如果消费者处理到一半崩溃了,消息会一直留在 PEL 里,可以由其他消费者通过XAUTOCLAIM重新认领。这就是一种“至少一次”的投递语义。
第三个优点是 Redis 本身就是内存数据结构,写入和读取都是微秒级。网关入口写一条 Stream 消息,耗时可以忽略不计,不会因为加了缓冲反而拖慢入口性能。
我用表格快速对比一下几个方案:
| 方案 | 数据可靠性 | 多消费者互斥 | 确认/重投机制 | 运维成本 | 适用场景 |
|---|---|---|---|---|---|
| 进程内队列 | 进程重启即丢 | 不支持 | 无 | 低 | 本地削峰 |
| Redis List | AOF/RDB可持久化 | 无原生支持 | 需自研 | 低 | 简单先进先出 |
| Redis Stream | 持久化+PEL | 原生消费者组 | 原生XACK/XAUTOCLAIM | 低 | 边缘缓冲、短时积压 |
| Kafka | 高可靠 | 原生 | 原生 | 高 | 大数据量、多团队、长时间重放 |
| 数据库表 | 取决于DB | 需锁或轮询 | 需自研 | 中 | 低频、必须落库 |
2.3 必须认清的边界:Stream 不是通用消息队列
上面夸了 Redis Stream 一堆,但它也有非常明确的边界。
Redis 是单机内存型架构,Stream 的一切读写都发生在一个 Redis 实例上(或者说一个分片)。它没有 Kafka 那种多分区水平扩展能力,想要在 Stream 上做分区,你得自己在业务层按 key 拆成多个 Stream。它也没有服务端内置的自动清理策略(虽然 7.0 之后 Stream 支持按最大长度修剪,但那只是容量控制,不算消息生命周期管理),不会像 Kafka 那样按时间帮你保留。
更关键的是内存成本。Stream 的数据毕竟在 Redis 内存里,不像 Kafka 可以堆在磁盘上随便存几天。所以 Stream 天然适合“短时缓冲 + 削峰”的场景,不适合“消息囤积几小时甚至几天”的场景。
如果消息量特别大、堆积时间又特别久,我建议老老实实上 MQ,别硬用 Redis Stream。Redis Stream 最舒服的位置,是“下游抖动几十分钟,缓冲足够撑过去并消化掉”这个区间。
3. Python 实战:把边缘缓冲接到网关里
3.1 总体结构和依赖
下面给出一套可以跑的 Python 实现。组件分两部分:
- 生产端:FastAPI 应用作为网关入口,接收请求写入 Redis Stream;
- 消费端:独立的 asyncio 进程,里面有多个消费者 worker 负责调用下游,以及一个 reclaimer 负责回收失败消息。
依赖就三个:fastapi、uvicorn、redis(连接 Redis 用redis.asyncio),下游请求用httpx。
目录结构很简单:
gateway_buffer/ ├── gateway.py # FastAPI 网关入口,XADD 写入 ├── consumer.py # 消费进程,worker + reclaimer ├── docker-compose.yml # Redis 本地环境3.2 生产端:网关入口写入 Stream
网关入口这段代码很短,核心就一个XADD。
import os import time import uuid from fastapi import FastAPI, Request import redis.asyncio as aioredis STREAM = "gw:edge:buffer" GROUP = "gw:consumers" REDIS_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0") app = FastAPI() @app.on_event("startup") async def startup(): app.state.redis = aioredis.from_url(REDIS_URL, decode_responses=True) # 消费者组必须在写入前建好,否则 XREADGROUP 会报 NOGROUP try: await app.state.redis.xgroup_create( STREAM, GROUP, id="0", mkstream=True ) except Exception: # BUSYGROUP 说明已经建过,忽略即可 pass @app.post("/gateway/{target}") async def gateway_forward(target: str, request: Request): body = await request.body() request_id = uuid.uuid4().hex await app.state.redis.xadd( STREAM, { "target": target, "request_id": request_id, "payload": body.decode("utf-8", errors="ignore"), "arrived_at": time.time(), }, maxlen=200_000, approximate=True, ) return {"accepted": True, "request_id": request_id}几个关键点:
xgroup_create创建消费者组时,id="0"表示从 Stream 的第一条消息开始消费;如果传"$",则只消费创建之后的新消息。集群刚上线时两者没区别,但如果 Stream 里已经积压了消息,你要想清楚要不要消费历史。我是倾向于"0",因为缓冲的目的就是不丢请求,历史积压应该在恢复后处理掉,而不是跳过。
maxlen=200_000表示这个 Stream 最多保留 20 万条消息,超出后自动淘汰最老的。approximate=True表示允许 Redis 用近似裁剪,性能更好,代价是最老消息的裁剪边界不是精确的。这个值怎么定,我放到后面第 3.5 节详细算。
写入的每条消息里我带了request_id。这个 ID 有两个用处:一是给调用方回执时对照,二是后面做幂等去重的时候用。
3.3 消费端:worker 循环与 ACK 时机
消费端进程是核心,先看单个 worker 的实现。
import asyncio import os import httpx import redis.asyncio as aioredis STREAM = "gw:edge:buffer" GROUP = "gw:consumers" DLQ = "gw:edge:dlq" MAX_RETRIES = 3 BLOCK_MS = 5000 COUNT = 100 DOWNSTREAM_BASE = os.getenv("DOWNSTREAM_BASE", "http://127.0.0.1:8081") async def consumer_worker(redis, client, worker_id): while True: result = await redis.xreadgroup( GROUP, worker_id, {STREAM: ">"}, count=COUNT, block=BLOCK_MS, ) if not result: continue for _, messages in result: for msg_id, fields in messages: await handle_message(redis, client, msg_id, fields) async def handle_message(redis, client, msg_id, fields): request_id = fields.get("request_id", msg_id) done_key = f"done:{request_id}" # 幂等:同一请求绝不重复打下游 if await redis.get(done_key): await redis.xack(STREAM, GROUP, msg_id) return try: resp = await client.post( f"{DOWNSTREAM_BASE}/process", content=fields.get("payload", ""), headers={"Content-Type": "application/json"}, timeout=10.0, ) resp.raise_for_status() except Exception as exc: # 失败不 ACK,消息留在 PEL,等待 reclaimer 重新认领 retry_key = f"retry:{msg_id}" retries = int(await redis.get(retry_key) or "0") + 1 await redis.setex(retry_key, 86400, retries) if retries >= MAX_RETRIES: await redis.xadd( DLQ, {**fields, "_reason": str(exc), "_retries": retries}, ) await redis.xack(STREAM, GROUP, msg_id) await redis.delete(retry_key) return # 业务成功才标记幂等键、确认消息 await redis.setex(done_key, 3600, "1") await redis.xack(STREAM, GROUP, msg_id)这里最重要的设计是 ACK 时机:业务成功之后才XACK,失败一律不 ACK。
为什么?因为XACK代表“这条消息我已经处理完了,可以从 PEL 里删掉”。如果你先 ACK 再做业务,这中间一旦宕机,消息就彻底丢了。如果业务成功但 ACK 前崩溃,消息还在 PEL,会被重新投递一次,但因为有幂等键兜底,重复调用下游的问题也可以挡掉。所以“先业务后 ACK”是最安全的姿势。
失败消息的处理也值得说。我并没有在except里直接XACK,而是让它留在 PEL,同时用retry:{msg_id}这个 key 记录重试次数。如果重试次数超过 3 次,就把原始消息复制到单独的DLQStream,再 ACK 原消息。这样既不会无限重试,也不会让失败的消息悄悄消失。
3.4 故障恢复:XAUTOCLAIM 与死信队列
只靠 worker 自己失败不 ACK,消息会永远悬在 PEL 里——因为同一个消费者已经“读走”了它,正常情况下不会再拿到它。所以需要另一个角色去认领这些滞留的消息,Redis 6.2+ 提供的XAUTOCLAIM就是干这个的。
async def reclaimer(redis, client): while True: await asyncio.sleep(30) try: pending = await redis.xpending(STREAM, GROUP) total = pending[0] if pending else 0 if not total: continue # 只认领停留在 PEL 超过 30 秒的消息 claimed = await redis.xautoclaim( STREAM, GROUP, "reclaimer", min_idle_time=30000, start_id="0-0", count=200, ) # 返回值: (next_cursor, [(msg_id, fields)], deleted_ids) for msg_id, fields in claimed[1]: retry_key = f"retry:{msg_id}" retries = int(await redis.get(retry_key) or "0") if retries >= MAX_RETRIES: await redis.xadd(DLQ, {**fields, "_retries": retries}) await redis.xack(STREAM, GROUP, msg_id) await redis.delete(retry_key) else: # 交给 reclaimer 再处理一次 await handle_message(redis, client, msg_id, fields) except Exception: await asyncio.sleep(5)XAUTOCLAIM的工作方式是把超过min_idle_time毫秒的 pending 消息,从原来的消费者名下转给 reclaimer 这个消费者,然后 reclaimer 再执行一遍处理逻辑。
min_idle_time设 30 秒是一个比较合理的值。设得太短,可能把正在正常处理的消息(比如下游接口偶尔慢,处理耗时超过几秒)误认领了,导致重复并发调用;设得太长,故障消息恢复的时间就太慢。
要注意一个细节:XAUTOCLAIM的第三个返回项deleted_ids是 Redis 自动清理的孤儿消息 ID。这种情况出现在 Stream 已经被MAXLEN裁剪掉了旧消息,但 PEL 里还留着对应 ID 的时候。Redis 6.2+ 会在XAUTOCLAIM时识别到这些“幽灵 ID”并自动从 PEL 中清除,这也是我推荐用 6.2 以上版本的原因。
最后是主函数,启动多个 worker 和一个 reclaimer:
async def main(): redis = aioredis.from_url(REDIS_URL, decode_responses=True) async with httpx.AsyncClient() as client: consumers = [ consumer_worker(redis, client, f"w-{i}") for i in range(8) ] await asyncio.gather(*consumers, reclaimer(redis, client)) if __name__ == "__main__": asyncio.run(main())3.5 关键参数怎么定:MAXLEN、COUNT、BLOCK、消费者数
代码能跑只是第一步,参数才是生产环境里真正要命的东西。
MAXLEN决定缓冲容量。我建议用这个公式估算:最坏情况下下游需要多久恢复(比如 30 分钟),乘以上游峰值流入速率,得出的消息条数就是最低容量。比如峰值 5000 TPS,恢复时间 30 分钟,那就是 9 万条,MAXLEN 至少要设到 20 万,留一倍余量。不要随手填个数字,要让运维能一眼看懂你的内存占用。
COUNT是每次读取的批量条数。我常用 100。太小会增加网络往返次数;太大(比如一次读 5000 条)会让 Redis 单次命令构造大列表,增加阻塞风险。100 是一个平衡点。
BLOCK决定 XREADGROUP 在没有新消息时的等待时间。我设 5000ms(5 秒),5 秒内没有新消息就返回空,循环继续。你也可以设block=0永久阻塞,但要特别注意网络断连时可能一直卡着,我宁可用 5 秒兜底,让循环有机会做连接检测和其他收尾。
消费者数量是整个方案里最容易算错的。每个 worker 是单协程串行处理消息,处理一条消息的耗时就是它的吞吐上限。假设下游接口平均响应 100ms,单个 worker 每秒最多处理约 10 条;你要支撑 1000 TPS,理论上就需要 100 个 worker。这个数字听起来吓人,但 asyncio 协程很轻,几十上百个 worker 完全扛得住。实际我一般是按照下游可用并发上限的 1.2 到 1.5 倍来开 worker,跑完压测再调。
还有一个我吃过亏的点:消息体大小。Stream 每条消息的大小直接决定 Redis 内存和网络带宽。如果业务消息体有几百 KB,别直接往 Redis 里塞。正确做法是消息体放到对象存储或数据库,Stream 里只存引用 ID、目标地址等元数据。边缘缓冲不是存文件的地方。
4. 生产避坑:那些文档没写透的细节
4.1 消费者组要先于第一条消息创建
很多人第一次写 XREADGROUP 会碰到NOGROUP错误,就是没建消费者组。我在启动代码里用xgroup_create(..., mkstream=True)来兜底:Stream 不存在就创建,存在就用 MKSTREAM 参数不影响现有数据。
但这个 try-except 有个隐患:多个网关实例同时启动时,可能同时执行xgroup_create,第二个会报BUSYGROUP异常。所以这里的 except 是必要的,但别把其他真实错误(比如 Redis 连接失败)也吞掉。我一般是先在日志里打印异常信息,再判断是不是BUSYGROUP。
另外,生产环境上线前一定要确认消费端已经部署并且组已建好,再开放流量。否则网关开始写入 Stream,但消费者组不存在,后面想从头消费就会漏掉之前的数据。
4.2 不 ACK 就不会丢,但也不会自动重投
这是 Stream 最容易误解的一点。消费者从 Stream 里读走消息后,消息并没有消失,而是进入 PEL。对调用方来说,这条消息“已经投递了”;但对系统来说,它还在等 ACK。如果消费者一直不 ACK 也不崩溃,消息就永远躺在 PEL 里,不会被别人拿到——因为 Stream 不会给消息设置超时时间。
所以你要么保证每个消息最终都走到 ACK 或死信,要么靠 reclaimer 的超时认领机制强制接管。我在第 3 节的实现里两者都有:正常处理成功立即 ACK,失败的消息进入 pending,由 reclaimer 在超过 30 秒后接管。这样不会有消息无限滞留,除非 reclaimer 自己挂了。
如果 reclaimer 也挂了,你会看到 PEL 数量不断上涨,积压告警会把这个异常暴露出来。这就是为什么监控里 PEL 数量比 Stream length 更值得盯。
4.3 至少一次语义:业务侧必须幂等
Redis Stream 给不了“精确一次”,它给的是“至少一次”。原因很直接:消费者可能在调用下游成功后、ACK 前崩溃,消息会被重新投递,你的下游会被同一请求打两次。
解决靠幂等。我在代码里用的是done:{request_id}这个 Redis key,处理前先查,处理成功就写入,这样重复消息直接 ACK 掉,不会再调下游。
这里有两个坑。第一,不要把幂等键的 TTL 设得太短。消息可能会在缓冲区里待很久,如果 TTL 短于消息在缓冲里的最长滞留时间,重复消息就会穿透幂等检查。我一般按“最坏恢复时间 + 24 小时”来设 TTL。第二,如果你的下游接口本身支持幂等键(比如支付链路里的商户订单号),那更稳妥的做法是把 request_id 透传给下游,让下游自己判重。Redis 幂等键只是兜底,不能当作业务幂等的唯一依据。
4.4 trim 会让消息从“屁股”后面悄悄消失
这是最容易踩的隐形坑。MAXLEN 的裁剪是从最老的消息开始的,它不管这条消息是不是已经被消费者读走了,也不管是不是还在 PEL 里等待 ACK。换句话说:如果下游长时间不可用,已经投递给消费者但迟迟没有确认的消息,可能在被读取之后又被 MAXLEN 悄悄删除。
这时 PEL 里会留下一个消息 ID,但消息内容已经不存在了。Redis 6.2+ 的 XAUTOCLAIM 会把这些孤儿 ID 放进返回的 deleted_ids,你至少能感知到;旧版本可能就会出现“认领到一条空消息体”的诡异现象。
防护思路只有一个:MAXLEN 一定要大于“下游最坏恢复时间内能产生的积压消息量”。如果下游挂了 1 小时才恢复,峰值流量是 8000 TPS,那缓冲容量至少要 30 万条,MAXLEN 设 40 万以上才安全。不要指望 MAXLEN 能帮你自动处理故障,它只是在内存有限的前提下兜个底。
4.5 Stream 消息字段不可变:重试计数放哪里
Redis Stream 的 entry 是不可变的,没有 update 命令。你没法在原来的消息上累加一个 retry_count 字段。
所以我在实现里把重试次数放到了外部 key:retry:{msg_id},处理失败时INCR,reclaimer 认领时先查这个 key 再决定是重试还是进 DLQ。这样做简单干净,不会破坏 Stream 本身的不可变性。
消息 ID 是唯一锚点,整个排障过程都靠它。日志里一定要把msg_id打出来,后面追查“这条消息到底重试了几次、为什么进了死信”时,没有 ID 根本无从下手。
顺带提一下 DLQ 的设计:我建议单独建一个DLQStream,把原始消息字段和_reason、_retries一起写入。这个 Stream 可以保留更长的 MAXLEN,比如 100 万条,作为事后人工排查和重放的依据。DLQ 不消费不代表没用,它保证每条失败消息都“有迹可循”。
5. 可观测性:让别人能看见积压,而不是等它爆
5.1 用 XINFO 三件套盯住状态
Redis 本身给了几个相当好用的观测命令,做监控的时候别从业务日志里硬挖,直接用它们。
XINFO STREAM返回 Stream 的基础信息,重点看length(当前消息条数)、last-generated-id(最新生成的消息 ID)、max-deleted-entry-id(删除过的最大 ID)。XINFO GROUPS返回每个消费者组的状态,重点看pending(未确认消息数)、lag(组内最新投递与最新写入的差距)、consumers(当前活跃消费者数)。
在代码里暴露一个监控接口很容易:
@app.get("/metrics/stream") async def stream_metrics(): r = app.state.redis stream_info = await r.xinfo_stream(STREAM) groups = await r.xinfo_groups(STREAM) return { "stream_length": stream_info["length"], "groups": groups, }生产上把这个接口接到 Prometheus 里,然后用 Grafana 画线。除了基础的 length,我强烈建议把每个 consumer group 的lag和pending单独拉出来看。lag持续上升说明消费速度跟不上生产速度,pending突然增多说明有消费者卡死或者 reclaimer 出了问题。
5.2 容量估算与告警水位
容量估算有个简单推演。假设峰值流量 5000 TPS,单条消息平均 800 字节,MAXLEN 设为 20 万条,那这 20 万条能扛的峰值积压时间就是 200000 ÷ 5000 = 40 秒。内存占用约 20 万 × 800B = 160MB,再加上 Redis 的 overhead 和编码损耗,实际占用大约 200MB 到 250MB。如果你给 Redis 预留了 1GB 给这个 Stream,这个配置就是合理的。
如果下游要恢复 10 分钟,5000 TPS × 600 秒 = 300 万条,那 MAXLEN 至少要 300 万,内存就要 2.5GB 左右。这时候你就得想清楚:是不是该考虑上 MQ 了,还是该在下游前面再加一层缓存,减少实际打到下游的流量。
告警水位我建议分两级:
- 警告级:Stream length 达到 MAXLEN 的 70%,说明缓冲压力偏大,需要关注下游健康状态;
- 严重级:达到 90%,说明缓冲区快满,随时可能开始丢消息,必须立即介入。
- 另外 pending 中最老消息的年龄单独告警。这个值超过 1 分钟,说明有消费者处理卡住了;超过 10 分钟,说明 reclaimer 可能也出了问题。
5.3 缓冲都不是无限的:最后一道防线仍然是限流
边缘缓冲解决的是削峰填谷,不是无限吸收。当 Stream 长度接近 MAXLEN 水位时,你再往里写消息,最老的消息就会被裁剪掉——这时候就不是“缓冲”,而是“丢数据”了。
所以一个完整的抗背压方案,缓冲是主力,限流是后备。当监测到 Stream 长度超过 85% 的水位时,网关应该主动开始拒绝新请求,返回 503 并带上Retry-After头,让客户端过几秒再试。
因为 Stream 长度是 Redis 里的全局状态,多个网关实例看到的是同一个值,不会出现某台实例已经超水位、另一台还在大量放行的问题。这比每台实例各自统计本地指标再来协调要简单得多。
这部分的实现可以在 FastAPI 里加中间件:每次请求先查一下 Stream 长度,超过阈值就返回 503。怕 Redis 的额外查询拖慢入口的话,可以在内存里缓存 1 秒内的长度快照,反正水位告警不需要秒级精确。
我在实际使用中最强烈的体感是:这套方案把“网关被拖垮”这个系统级问题,变成了一个可以量化、可以观测、可以告警的局部问题。入口写入 Stream 只要 3 毫秒,下游慢 10 分钟也不会传染给其他实例,你可以坐在 Grafana 前面看着积压曲线一点点降下去,这在之前的同步架构里是不可想象的。如果你是第一次做这个改造,建议先拿低峰期流量跑半天,把 length 曲线和 lag 曲线摸清楚,再决定 MAXLEN 要不要调、消费者开几个。盲拍参数十有八九要返工。最后提醒一句:边缘缓冲不是银弹,凡是必须同步返回结果的接口,不要硬套进去;先做业务拆解,把可以异步化的链路挑出来,这个方案才能发挥它真正的价值。