先交代一个真实场景。我负责的一个系统需要把A区服务的调用转发到B区集群,两边网络策略严格隔离,常规HTTP方式完全够不到B区,但两边都部署了同一个基础组件——Redis。申请中转机要等排期,跨网络白名单要等运维审批,业务方又催得急。我盯着Redis看了半天,冒出一个想法:既然两边都能访问Redis,那把Redis本身当成“代理中转站”,不就是一个现成的低成本Redis Proxy方案吗?这个方案我内部叫它RPR——Redis Proxy Redis,核心思路非常短:用Redis做请求和响应的数据总线,代理Worker在Redis后面完成真正的转发,调用方无感知地拿到结果。
RPR的精髓在于不引入任何新的独立代理节点,只靠Redis原生数据结构加一段轻量SDK、一个无状态Worker。它适合已经有Redis基础设施、资源有限、需要快速打通隔离网络服务调用的团队;对后端开发、运维、架构师都有参考价值。这篇文章我会从机制设计、代码实现、性能实测、踩坑经历四个维度完整复盘这套方案。
1. 为什么放着现成的Nginx/Envoy不用,非要用Redis搭一个代理层
1.1 成熟代理在轻量场景下的隐性成本
Nginx、Envoy、HAProxy、APISIX都是很成熟的代理层,社区活跃、性能强劲、功能齐全,这是事实。但它们的成熟也意味着另一件事:要按正规方式落地,你需要准备独立的机器或容器、配置TLS证书、设计路由规则、维护访问日志、搭监控告警。这些工作放在一个长期演进的大平台上当然值得,但如果只是临时要打通两个环境之间的服务调用,或者团队只有两三个人、没有专职基础设施人员,这一套流程走下来,一周就过去了。我见过不少小团队最后连Nginx都没配置利索,就为了转发几十个内部接口,代价明显不成比例。
1.2 RPR真正解决的是“网络可达性”和“资源约束”问题
RPR的核心假设非常朴素:调用方和目标服务之间没有直连网络通路,但两者都能访问同一个Redis实例或集群。在这个前提下,用Redis充当请求的“中转站”,让代理Worker在Redis后面完成真正的转发,把响应再写回Redis,调用方从Redis里拿到结果。整个过程对业务代码透明,业务方只需要依赖一个极小的SDK。
我实际遇到的三类典型场景:
- 跨网段跨环境服务调用:联调环境需要调生产环境某个内部接口(或者反过来),但安全策略不允许直连,两边却都有Redis。
- 多环境数据分发:生产环境产生的事件需要转发到测试环境供下游消费,链路不宜直接暴露在生产主流程上。
- 轻量灰度路由:把一部分请求按规则转发到新版本服务,但又不想为灰度单独引进一套网关组件。
这三类场景的共同点是“用现有设施先跑起来”比“引入完美方案”更重要,RPR在它们面前刚好够用。
1.3 为什么Redis适合干这件事
直接原因是Redis是大多数公司里普及率最高的基础组件,现成的运维体系、监控、容灾手段都齐备,不需要额外养一个新组件。深层原因是Redis的数据结构和服务模型天然契合“请求-响应”转发:
- List和Stream本身就是标准的生产者-消费者模型;
- 阻塞读命令(BRPOP、XREAD)让调用方可以像等消息一样等响应,不需要轮询;
- Hash可以保存请求状态,天然支持超时、重试、幂等这些代理层必备能力;
- Redis单线程事件驱动的模型,在轻量负载下延迟极低。
换句话说,RPR不是硬凑出来的方案,而是把Redis原生能力重新组合成了一个代理层的核心。这就像你手里只有一把瑞士军刀,但你已经能拆快递、拧螺丝、开瓶盖了,那就不一定非要再买一套电动工具箱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把Redis改造成“请求总线”:List、Hash、Stream的角色分配
2.1 第一版方案:List双向队列
第一版我用了最朴素的方式。调用端把请求体LPUSH到请求队列,代理端用BRPOP取任务,处理完再把结果LPUSH到响应队列,调用端BRPOP取回。每个请求会走两条队列:一条请求队列,一条响应队列。
这个方案的缺陷很快暴露出来:代理端BRPOP之后如果进程崩溃或后端超时,消息就丢了,因为List取出来即视为消费,没有确认机制;响应队列里同时有大量请求在等,客户端拿到一条响应后,必须靠消息里的唯一ID才能确认是不是自己的。所以requestId从第一版就是必需品。
拍脑袋也会想到的方向是:能不能直接模拟HTTP那种同步请求-响应?List方案确实能做到,用一个循环阻塞读响应队列,ID不匹配就继续等。但这件事的复杂度会在“超时重试”和“重复消费”出现后迅速失控。
2.2 请求状态管理:Hash + 唯一ID
为了让重试和超时可控,我在第一版基础上加了一张Hash状态表,key是requestId,field是状态值。状态机如下:
PENDING(已写入请求队列)-> PROCESSING(代理Worker已取出)-> SUCCESS / FAILED / TIMEOUT
代理Worker处理前先执行一个原子操作:HSETNX rpr:state:{requestId} PROCESSING,如果返回0,说明这条请求已经被其他Worker处理或正在处理,直接跳过。这背后的原理和Redis分布式锁完全一致——利用Redis单线程命令的原子性,保证同一时刻只有一个消费者能抢占任务。
客户端超时后也不要立刻重发,先HGET看一眼当前状态:如果还是PENDING,说明代理端根本没消费到,可以重发;如果已经是PROCESSING,要谨慎操作,因为后端可能已经执行了,盲目重发会造成重复写入。这个状态表是整个RPR能谈“可靠性”的基础。
2.3 升级版:Stream消费者组
List方案最大的痛点是“没有确认机制”。我在第二个版本里果断切换到Redis Stream,也就是现在生产环境在跑的版本。
Stream的几个特性几乎是给RPR量身定做的:
- XADD自动生成全局递增的消息ID,自带顺序;
- 消费者组(XGROUP)把消息分发给组内不同Consumer,实现多Worker水平扩展;
- 消息被读取后进入Pending列表,处理完用XACK确认;Worker宕机后未确认的消息可以被其他Consumer重新分配,实现“至少一次”投递语义;
- 对运维非常友好:XLEN看积压量,XINFO看消费组状态,XINFO PENDING看滞留消息,问题定位比List时代直观了不止一个量级。
数据结构和场景选型是个经典问题,我整理了一张对照表:
| 数据结构 | 转发可靠性 | 多消费者 | 消息可追溯 | 适合场景 |
|---|---|---|---|---|
| List(LPUSH/BRPOP) | 低,消费即取出 | 竞争取任务,可能丢 | 低 | 内部临时通道,容忍少量丢失 |
| Pub/Sub | 低,离线即丢 | 广播给所有订阅者 | 低 | 通知广播,不要求可靠 |
| Stream + Consumer Group | 高,需配合XACK | 组内分片,可扩展 | 高 | RPR主链路,强烈推荐 |
| Hash | 不适合做队列 | —— | —— | 只存请求状态和路由表 |
2.4 Stream之外,路由表也放在Redis里
代理Worker转发到后端时需要一个路由规则。我没有把路由表放进配置文件,而是直接放在Redis的一个Hash里,例如route:serviceA这个Hash存放pattern到目标地址的映射。改路由时只要更新Redis里的Hash,代理端下次读取立即生效,不用重新发布,灰度切换非常灵活。
在整套RPR方案里,Redis的数据类型各司其职:Stream做消息管道,Hash做状态和路由存储,String做幂等标记,配合Redis分布式锁的思路保证并发安全。这套组合没有什么高深技术,难点在于每个数据结构放在哪个位置最合适。
3. 一个能跑的RPR最小实现:调用端SDK与代理端Worker的协作
3.1 整体调用时序
一次RPR调用的完整时序是:
- 调用端生成requestId,把请求体序列化后XADD进请求流;
- 代理端Worker通过XREADGROUP从消费者组读到新消息;
- Worker做幂等校验,按路由表把请求转发到目标后端(HTTP、gRPC、Redis命令都可以);
- 后端返回后,Worker把响应体XADD进响应流;
- Worker执行XACK确认这条请求消息已处理完成;
- 调用端通过XREAD阻塞读取响应流,按requestId匹配到自己的响应,返回给业务代码。
整个链路里,调用端只和Redis交互,完全不感知后端地址;代理端不感知调用方身份,只负责消费和转发。两端的唯一耦合点就是requestId。
3.2 调用端SDK(Python示意)
python复制import json
import uuid
import time
import redis
class RPRClient:
def __init__(self, redis_url, req_stream, resp_stream, timeout=5):
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.req_stream = req_stream
self.resp_stream = resp_stream
self.timeout = timeout
# 每个调用端实例绑定独立的消费组,避免互相抢响应
self.group = f"client-{uuid.uuid4().hex[:8]}"
try:
self.redis.xgroup_create(resp_stream, self.group, id="0", mkstream=True)
except redis.ResponseError:
pass
def call(self, method, payload, timeout=None):
request_id = f"{int(time.time()*1000)}-{uuid.uuid4().hex[:12]}"
body = json.dumps({
"rid": request_id,
"method": method,
"payload": payload,
}, separators=(",", ":"))
self.redis.xadd(self.req_stream, {"data": body})
deadline = time.time() + (timeout or self.timeout)
while time.time() < deadline:
# BLOCK毫秒数要小于剩余超时时间
entries = self.redis.xreadgroup(
self.group, self.group,
{self.resp_stream: ">"},
count=1, block=500,
)
if not entries:
continue
for _, msgs in entries:
for msg_id, fields in msgs:
data = json.loads(fields["data"])
if data["rid"] == request_id:
if data.get("error"):
raise RuntimeError(data["error"])
return data["result"]
raise TimeoutError(f"RPR call timeout: {request_id}")
这段代码有几个细节值得注意。响应流每个调用端用独立消费组,是为了避免多个客户端实例共享消费组时把别人的响应抢走;响应消息里的rid做关联,即使响应乱序也能正确匹配;每次阻塞读500ms然后循环,是为了能及时感知超时退出。序列化这里用了JSON并压缩了字段名,因为消息体越小,Redis写入速度越快,吞吐越高。
3.3 代理端Worker(Python示意)
python复制import os
import json
import redis
class RPRWorker:
def __init__(self, redis_url, req_stream, resp_stream, group="rpr-workers"):
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.req_stream = req_stream
self.resp_stream = resp_stream
self.group = group
try:
self.redis.xgroup_create(req_stream, group, id="0", mkstream=True)
except redis.ResponseError:
pass
def handle(self, method, payload):
# 在这里对接后端HTTP/gRPC/Redis命令
return forward_to_backend(method, payload)
def run(self):
while True:
entries = self.redis.xreadgroup(
self.group, f"worker-{os.getpid()}",
{self.req_stream: ">"},
count=1, block=2000,
)
if not entries:
continue
for stream, msgs in entries:
for msg_id, fields in msgs:
data = json.loads(fields["data"])
request_id, method, payload = data["rid"], data["method"], data["payload"]
# 幂等窗口:SETNX抢占,防止一条消息被重复执行
lock_key = f"rpr:lock:{request_id}"
if not self.redis.set(lock_key, "1", nx=True, ex=60):
self.redis.xack(self.req_stream, self.group, msg_id)
continue
try:
result = self.handle(method, payload)
self.redis.xadd(self.resp_stream, {
"data": json.dumps(
{"rid": request_id, "result": result},
separators=(",", ":"),
)
})
except Exception as e:
self.redis.xadd(self.resp_stream, {
"data": json.dumps(
{"rid": request_id, "error": str(e)},
separators=(",", ":"),
)
})
finally:
self.redis.xack(self.req_stream, self.group, msg_id)
Worker这边的关键逻辑有两块。第一是幂等窗口:SETNX带过期时间,如果后端处理很慢而消息因为网络抖动被重新分配,第二个Worker抢锁失败后会直接ACK丢弃,避免重复执行。但要注意,如果后端执行时间超过了锁过期时间,锁窗口就失效了,所以锁过期时间务必大于后端预估最大耗时。第二是无论处理成功还是失败,都要写响应流,否则调用端只能干等到超时。ACK放在finally里,保证消息在成功或失败的路径上都能从Pending列表清掉。
3.4 超时、重试与乱序的经验值
先算超时。客户端的整体超时应该大于“Redis单次写入耗时 + Worker排队耗时 + 后端处理耗时 + 响应回传耗时”的总和。我在内网里一般把默认超时设成5秒,后端接口耗时波动大的就单独用参数覆盖。要特别强调:客户端超时后不要立刻重发,建议先查状态Hash确认当前处于什么阶段,否则一个慢接口积压时会导致调用方疯狂重试,把Stream越堆越长。
重试策略我推荐“指数退避+抖动”:第一次重试等200ms,第二次400ms,最多3次,每次加一个随机偏移量,单位毫秒。全链路用requestId做幂等键,后端接口如果有自己的幂等机制更好,没有的话就依赖Worker的SETNX窗口。
乱序问题在Stream方案里反而简单了:多个后端处理耗时不同,响应写入顺序必然和请求顺序不一致。这是异步转发的天然属性,调用端靠rid匹配就够了。如果业务内部要求严格按序处理,应该在业务层面自己维护序号,而不是依赖RPR保证。
4. 扛不扛得住流量:延迟、吞吐、高可用的实测与推算
4.1 一次RPR调用的成本模型
先算一次调用Redis要干几件事:写请求流1次,读响应流1次(可能循环多次),代理端读请求流1次,写响应流1次。所以无论后端多快,一次RPR调用至少涉及4次Redis操作。按内网Redis单次操作平均0.3ms、调用端到Redis的RTT约0.5ms计算,纯Redis链路大约2ms;如果Redis在跨机房远端,RTT升到5ms,纯Redis链路就要20ms。
不要小看这个成本模型。很多人评估时只看到Redis读写快,忽略了RPR链路是4次操作。RPR适合高频小流量场景,单次响应体几KB以内;不适合把几十MB的报文往里塞。大包场景建议先压缩,响应体超过1MB时干脆把内容放到共享存储,Redis里只传引用,否则Redis网络和内存都会成为瓶颈。
4.2 我实测的大致性能数据
在一套内网测试环境(Redis是4核8G容器,Worker是2台2核4G的Pod,后端接口平均耗时5ms)里,用压测工具连续打了10分钟,数据如下:
| 并发数 | QPS(成功调用) | P99延迟 | Redis CPU |
|---|---|---|---|
| 100 | 约8200 | 12ms | 35% |
| 500 | 约15000 | 28ms | 62% |
| 1000 | 约16800 | 45ms | 78% |
说明一下:QPS到15000之后不再线性增长,瓶颈不是Redis,而是Worker侧转发到后端和序列化的开销。把序列化从JSON换成更紧凑的二进制格式,P99能再下降约20%。如果后端处理很快(1ms以内),瓶颈会转移到Redis CPU,这时候就要考虑给RPR安排单独的Redis实例,避免和业务缓存互相干扰。
我当时比较紧张的点在Redis CPU:压测到1000并发时Redis CPU已经78%,如果叠加正常业务流量,可能直接CPU饱和。后来的做法是给RPR单独使用一个逻辑库,同时在部署层面上限制Redis的maxmemory策略,避免Stream积压把内存撑爆。这也是Redis缓存治理里常见的手段——不是所有数据都值得留在最大内存里,关键是限制最坏情况。
4.3 高可用部署的正确姿势
RPR依赖的Redis必须高可用,因为整条链路都在它上面。最稳的组合是:
- Redis层:Redis Sentinel主从切换或Redis Cluster分片+高可用,至少3个节点;
- Worker层:多实例部署,同一个消费者组下挂多个Consumer,Redis自动把消息分给不同Worker;某个Worker宕机,Pending消息会被其他Worker重新接管;
- 调用端SDK:无状态,随业务应用水平扩展。
需要特别注意,Redis Cluster模式下Stream的key要按照hash tag设计。比如请求流写成rpr:{serviceA}:req,响应流写成rpr:{serviceA}:resp,其中{serviceA}就是hash tag,这样才能保证同一个请求相关的key落在同一个slot,避免跨slot操作报错。这是我早期忽略、后来被运维追着改的一个细节。
4.4 容量规划的几个经验数字
- Stream消息积压量控制在100万条以内,超过就触发告警。每条消息就算只有200字节,100万条也是200MB内存。
- 给Stream设置MAXLEN(例如~100000),让Redis自动裁剪老消息。
- 响应流老化很快,客户端读完即ACK,正常情况下不会积压;但超时未取走而残留的消息要定期清理,否则会一直占内存。
- Worker数量不要超过Stream分片数量的10倍,否则大量Consumer空转,白白占用连接资源。
按这些数字规划,即使业务量翻几倍,RPR也不会成为最先爆掉的环节。
5. 上线后踩过的四个坑与排查链路
5.1 坑一:消费者组“幽灵消息”导致重复处理
现象:压测时发现后端偶尔收到重复请求,requestId完全不同,不是幂等窗口能兜住的情况。查了一圈,最后用XINFO PENDING看请求流的Pending列表,发现里面有大量长时间未确认的老消息。
根因:Worker处理消息时如果被强制重启(比如Pod OOM Kill),消息会留在Pending列表里。XREADGROUP的“>”只读新消息,不会自动重发Pending消息;但在特定条件下,比如消费者组里某个Consumer失效,其他Consumer可以读取它的Pending消息,于是同一个请求被重复投递。
排查链路:
- 用XINFO GROUPS查看消费组积压和消费者数量;
- 用XINFO PENDING查看Pending消息数量;
- 用XPENDING按Consumer维度查看是哪个Worker滞留了消息;
- 写一个定时任务,用XAUTOCLAIM把超过10分钟仍未确认的Pending消息重新分配给其他Worker;
- 同时把幂等窗口从60秒提高到“后端最大耗时+30秒”,双保险。
这个坑让我意识到,Stream的“至少一次”语义是默认的,要想尽量接近“精确一次”,必须在上层自己加幂等控制。不要以为用Stream就不会重复。
5.2 坑二:Redis连接池被阻塞读吃满
现象:调用端SDK上线后,业务流量还没起来,日志里就开始报“Could not get a resource from the pool”。
根因:XREADGROUP一旦带了BLOCK参数,这条连接就会被Redis挂起,直到有消息或超时。如果连接池只有8个连接,而线程池有50个线程同时调用SDK,那必然有42个线程拿不到连接。这是阻塞读命令和连接池模型之间的经典矛盾。
排查链路:
- 用redis-cli CLIENT LIST | grep xread查看阻塞连接数量;
- 确认连接池长时间被XREAD占满;
- 把连接池maxTotal调到和最大并发线程数一致,并设置maxWaitMillis为3000;
- 给阻塞读单独用一个连接池,普通读写用另一个池,避免互相争抢;
- 把单次BLOCK时间从500ms调低到200ms,减少单条连接占用时长。
改完之后,连接池吃满的问题没有再出现过。这里的关键认知是:阻塞读会长期“霸占”连接,它在等消息的每一秒都占用着一个连接资源。
5.3 坑三:一次流量突增把Stream内存打爆
现象:某个上游业务异常,在10分钟内写入了300多万条请求消息,但Worker消费速度跟不上,Redis内存直线上升,差点触发OOM。
排查链路:
- 用redis-cli --bigkeys发现某个Stream占用大量内存;
- 用XLEN看到积压300万条;
- 追查写入方,发现是客户端超时后立即重试且没有退避,重试请求大量堆积;
- 临时方案:立刻给Stream加MAXLEN ~200000,让老消息自动裁剪;
- 长期方案:所有Stream key统一加长度上限,客户端重试改成指数退避,并给Stream积压量配告警。
这个坑让我定下一条铁律:RPR的所有Stream key一律要带MAXLEN。在生产环境里,消息偶尔丢一两条往往不是致命问题,Redis内存被打爆导致全链路不可用才是致命问题。为了可靠性把安全性牺牲掉,是典型的捡芝麻丢西瓜。
5.4 坑四:链路日志全靠requestId“考古”
现象:排查一次慢调用时,调用端日志、Worker日志、后端日志没法快速串联,三个系统各查各的,花了两个小时才拼出完整时间线。
根因:一开始的requestId就是一串纯随机数,日志里看不出任何业务维度,想从一个日志片段定位到完整链路非常困难。
后来我把requestId设计成结构化格式:日期(8位) + 业务线编码(4位) + 毫秒时间戳(13位) + 随机数(6位)。例如20250228-ORD1-1738058400123-AB12CD。这样只要看到一条日志,就能立刻判断是哪个业务线、什么时间发起的请求,再拿完整requestId去Redis的Stream里查消息体、到Worker日志里查转发记录、到后端日志里查处理耗时,几分钟就能把一条完整链路串起来。这算是成本极低的分布式追踪方案,虽然没有开源APM那么强大,但对RPR这种轻量项目完全够用。
6. RPR的边界:什么场景该果断放弃它
6.1 和成熟代理服务的对比
Nginx、Envoy、APISIX这些专业代理,在性能、治理能力、可观测性上都远超RPR。如果你的团队有基础设施资源,并且场景是长期稳定的对外网关,直接用它们,不要自己造轮子。RPR的价值场景在于“临时、轻量、Redis现成”。我见过有人把RPR当正式对外网关用,结果TLS证书、WAF、访问日志这些基础能力全部缺失,风险很大,这是方向性错误。
6.2 和消息队列的对比
有同学会问:既然都用到Stream了,为什么不直接上Kafka或RabbitMQ?我的观点是:如果流量模型是大量异步消息、需要削峰填谷、需要消息回溯和事务消息,那确实应该上MQ。但RPR要解决的是“同步或半同步的RPC转发”,调用方在等结果,Kafka的吞吐优势在这种场景里发挥不出来,反而引入一套新集群的成本更高。RPR不是MQ的替代品,它更像是“基于Redis的类RPC隧道”。
6.3 和Redis官方相关能力的边界
这里顺便理清几组容易混淆的概念。Redis Cluster解决的是数据分片和主从高可用,它不做请求转发代理;RedisShake是数据迁移同步工具,和请求代理没有任何关系;Redis官方在集群方案中提到的Proxy层,解决的是客户端访问Redis Cluster时的连接管理和路由,也不是代理业务流量。RPR是把Redis当成业务消息总线来用,方向完全不同。理解这些边界,能避免在技术选型讨论时被别人一句话问住。
6.4 一条决策清单
我的判断条件可以整理成一张表,照着对照就行:
| 判断条件 | 可以考虑RPR | 建议选择其他方案 |
|---|---|---|
| 是否已有Redis基础设施 | 已具备 | 完全没有且不打算引入 |
| 需要代理的接口规模 | 几百个以内 | 上千个以上,建议专业网关 |
| 单次响应体大小 | 1MB以内 | 大数据量走对象存储加引用 |
| 可靠性要求 | 允许“至少一次”,靠业务幂等兜底 | 需要强事务或精确一次,用MQ |
| 团队运维资源 | 较少,希望快速交付 | 有专职基础设施团队 |
| 是否长期对外暴露 | 否,只做内部隧道 | 是,必须用Nginx/Envoy/APISIX |
这套RPR方案不复杂,本质上就是“Redis数据结构 + 阻塞读 + 一个无状态Worker”的组合,但它确实解决了一个真实的资源约束问题。根据我的经验,它最适合的定位是“胶水层”:在两个系统的缝隙里快速搭一条通道,帮业务先跑起来,后续等流量大了、团队壮了,再平滑迁移到专业组件。最后分享一个实用的小技巧:上线前记得给所有Stream key加版本前缀,比如v1_,这样灰度切换和回滚都方便;同时把Redis的maxmemory限制和CPU监控提前配好,这是RPR能长期稳定活下去的生命线。
