先交代一个真实场景。我负责的一个系统需要把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重新分配,实现“至少
