indirection - layer
08. 间接层:反向代理、网关与所有中间人
0. 本章先解决什么问题
计算机科学有句流传很广的老话(出自 David Wheeler):
计算机科学里的任何问题,都可以靠增加一个间接层来解决。
后半句常被省略: 除了”间接层太多”这个问题本身。
反向代理、网关、负载均衡的网络层基础在 代理、网关、负载均衡与 CDN 已经讲过。
本章把视野拉开:这个结构在从硬件到框架的每个尺度上反复出现,页表、DNS、VIP、NGINX、注册中心、槽位表、Spring 的动态代理、DispatcherServlet、消息队列,全是同一句话的化身。
本章把每个化身单独拆开讲清机制,最后收拢它们共享的收益和代价。
这张图怎么读
中间是抽象结构:调用方拿着一个稳定的名字,间接层把名字翻译成真实位置,真实位置可以随时变。四周是这个结构在各层的化身,从硬件的页表到业务的网关。
1. 抽象结构:名字和位置解耦
所有间接层做的是同一件事:
- 没有间接层:调用方 -> 真实目标
地址写死,目标一动,全体调用方改代码 - 有间接层:调用方 -> 稳定的名字 -> [翻译] -> 真实目标
目标搬家只改翻译表,调用方无感
由此白送三种能力,后面每个化身都能对号入座:
- 位置透明: 目标可以搬家、扩容、替换、故障切换
- 集中控制: 所有流量过一个咽喉,鉴权、限流、观测、路由策略在这一点做
- 平滑变更: 翻译规则可以逐步改,灰度、迁移、AB 有了拨流量的支点
2. 硬件和 OS 里的间接层
2.1 页表:地址的间接层
上上章拆过分页机制,这里换间接层视角再看一眼它买到的三样东西,和第 1 节的三种能力逐条对应:
位置透明: 物理页可以换出、搬迁、整理,程序里的指针值不变
集中控制: 权限位在翻译表上(只读/可执行/内核态),
越权访问在翻译这一步被拦下(段错误的来源)
平滑变更: 写时复制、按需加载全靠”先改表、访问时再兑现”
它的翻译表查询太频繁,于是配了专用缓存 TLB。
记住这个组合: 间接层 + 它的翻译表缓存,后面反复出现
2.2 文件描述符与 FTL
- 文件描述符
fd:进程里看到的整数3先映射到内核打开文件表,再指向 inode。重定向2>&1本质上只是改这张映射表中的一个格子,应用程序无需感知。 - SSD 的 FTL:把逻辑块地址映射到闪存物理位置。磨损均衡、坏块屏蔽和 GC 搬运都在翻译层内部完成,上层始终以为自己在读写固定地址;第 2 章关于擦除块的伏笔也在这里归位。
3. 网络入口的间接层:DNS 和 VIP
3.1 DNS:最早的名字服务
域名到 IP 的翻译链在 DNS 讲过,间接层视角下补两个工程结论:
- 它顺便是最粗的负载均衡:一个域名挂多条 A 记录轮流答,
或按地理位置返回就近机房(CDN 调度的第一跳) - 它的失效收敛极慢:翻译结果被浏览器、OS、运营商逐级缓存,
TTL 说 60 秒,总有不守规矩的缓存拖到几小时。
所以 DNS 适合做”机房级”的粗切换,
秒级的故障转移要靠下面更快的层
3.2 VIP + keepalived:IP 层的可漂移名字
对外只暴露一个虚拟 IP(VIP),当前活着的主机持有它
keepalived 跑 VRRP 协议: 主机周期组播心跳,
备机收不到心跳就按优先级接管 VIP,
并广播免费 ARP 让交换机更新”这个 IP 在哪个网口”
效果: 故障转移对客户端完全透明,连接串永远是那个 VIP
局限: 上一章说过,两台机器没有多数派,脑裂要靠额外 fencing
DNS 管”名字到机房”,VIP 管”机房里哪台活着”,两层间接各管一段收敛速度。
4. 流量层的间接层:反向代理、网关、注册中心
4.1 NGINX 反向代理:翻译表叫 upstream
代码块收起展开
upstream backend {
least_conn; # 翻译策略
server 10.0.0.1:8080 weight=3;
server 10.0.0.2:8080;
server 10.0.0.3:8080 backup; # 平时不用,全挂才上
}
server { location /api/ { proxy_pass http://backend; } }调度策略逐个点名,各有归属场景:
轮询(默认)/ 加权轮询: 机器同构/异构时的均分
least_conn: 请求耗时方差大时,避免长请求堆在同一台
ip_hash: 按来源 IP 固定后端,会话粘性的粗方案
(移动网络下 IP 会变,正经会话还是外置到 Redis)
hash $key consistent: 一致性哈希(上一章的环在这里上岗),
按业务键路由,本地缓存亲和
健康检查: 开源版是被动式(转发失败计数,超阈值临时摘除),
探活式主动检查是商业版/第三方模块的活
代理会吃掉客户端来源 IP,标准补丁是逐跳追加 X-Forwarded-For、传递 X-Real-IP。安全细节:这两个头客户端可以伪造,取”真实 IP”必须从可信代理层数起,别直接信第一个值。
4.2 网关:带业务语义的代理
Spring Cloud Gateway 的模型三件套,本质是把翻译规则做成了可编程的:
- Route:一条翻译规则(id + 目标 + 谓词 + 过滤器链)
- Predicate:什么请求匹配这条路(按路径/方法/Header/权重…)
- Filter:匹配后过路要交的税(改写路径、鉴权、限流、日志)
放在网关做的事,全是第 1 节”集中控制”的兑现:
- 统一鉴权:解 JWT、查会话,合法才放行,
后端服务从此不用各自实现一遍登录校验 - 限流:令牌桶(常配 Redis + Lua 保证集群一致,
板子在 Lua脚本) - 灰度:按 Header/用户尾号/权重把 5% 流量拨给新版本
(“平滑变更”能力的直接兑现) - 和 NGINX 的分工:NGINX 站最外层管接入
(TLS 终结、静态资源、粗限流),
网关站微服务门口管业务策略,两层各司其职
4.3 注册中心:翻译表本身活起来了
前面的翻译表都靠人改配置,微服务实例动态扩缩后,表必须自动维护:
服务启动 -> 向 Nacos 注册”订单服务 @ 10.0.0.5:8080”
存活 -> 周期心跳续约;停止心跳 -> 摘除
调用方 -> 订阅”订单服务”的实例列表,
变更由推送 + 定时拉取兜底,本地缓存一份做路由
注意结构:调用方本地缓存的实例列表,就是翻译表的缓存,注册中心挂了一阵,调用方靠缓存照常路由(牺牲新鲜度保可用)。“间接层 + 翻译表缓存”的组合第三次出现(页表配 TLB、DNS 配逐级缓存、注册中心配本地列表)。
5. 数据层的间接层
上一章已经拆过机制的,这里只按间接层视角归位:
Redis Cluster 槽位表: key 到节点之间垫了 16384 个槽,
迁移只改”槽到节点”这半张表。smart client 的本地槽位表
又是翻译表缓存,MOVED 就是缓存失效通知
分库分表中间件: 逻辑表名到真实库表的翻译。
两种部署位置(JDBC 内嵌 / 独立代理)上一章已对比,
那道选择题的通用形态见本章第 8 节
Redis 集群方案演化史刚好把间接层的三个位置走了一遍:
客户端分片(翻译逻辑内嵌调用方)
-> twemproxy/Codis(独立代理层)
-> Cluster(服务端重定向 + 客户端缓存,两头分摊)
6. 框架里的间接层:Spring 全家桶的老底
6.1 动态代理:方法调用级的间接层
Spring AOP 给 Bean 包一层代理对象,调用先过代理再到真身,事务、日志、鉴权切面全垫在这层:
- JDK 动态代理:目标实现了接口时用。运行时生成一个
实现同接口的代理类,方法体统一进 InvocationHandler - CGLIB:无接口时用。生成目标类的子类,覆写方法织入逻辑
(所以 final 类和 final 方法切不进去)
著名的”事务自调用失效”用间接层视角一句话就通了:
代码块收起展开
@Service
public class OrderService {
public void a() {
this.b(); // this 是真身,不是代理!
} // 调用没经过代理层,@Transactional 的翻译逻辑没机会垫入
@Transactional
public void b() { ... }
}
// 修法本质都是"把调用重新引导回代理":
// 注入自己的代理引用再调、AopContext.currentProxy()、或拆到另一个 Bean同理还有 @Async、@Cacheable 的自调用失效,一个病根。机制展开在 事务 - Transactional。
6.2 DispatcherServlet:应用内部的网关
所有请求 -> DispatcherServlet(唯一入口)
-> HandlerMapping: URL 到 Controller 方法的翻译表
-> HandlerAdapter: 参数绑定、执行
-> 拦截器链: 过路的税(登录检查、日志)
结构和 4.2 的网关逐格同构:一个咽喉入口、一张路由表、一串过滤器。前端控制器模式就是”应用进程内的网关”。
6.3 MyBatis Mapper:接口到 SQL 的翻译
Mapper 接口没有实现类,运行时 MapperProxy 动态代理把方法调用翻译成”namespace.methodId 对应的 SQL”再交给 SqlSession 执行。你写的 orderMapper.selectById(1) 全程是一次查表翻译。
7. 消息队列:时间维度上的间接层
有一个化身容易被漏掉,因为它间接的维度不同。MQ 把”调用方直连被调方”改成”调用方 -> 队列 -> 被调方”:
空间解耦: 生产者不知道消费者是谁、有几个、在哪(位置透明)
时间解耦: 消费者可以暂时不在线,消息先躺在队列里
速率解耦: 生产洪峰被队列吸收,消费者按自己的节奏拉
(第三条是缓冲的老本行,MQ 同时占了间接层和缓冲两个思想的便宜,
这解释了它为什么无处不在)
RabbitMQ 的 exchange 到 queue 的 binding 就是它的翻译表:
direct 按 routing key 精确翻、topic 按通配翻、fanout 广播,
改 binding 就是改路由,生产者一行代码不动
展开见 MQ基础。
8. 位置三选:间接层放哪,一道反复出现的选择题
同一个间接层,物理上放哪,是从 Redis 到分库分表到 RPC 都要答的题:
- 内嵌调用方(smart client / JDBC 中间件 / 客户端 LB 如 Ribbon)
优点: 零额外网络跳数
缺点: 翻译逻辑复制在每个调用方里,升级要全体动,跨语言要重写 - 独立中间层(twemproxy / MyCat / NGINX / 网关)
优点: 调用方零改造、集中管控、语言无关
缺点: 多一跳延迟;代理自身成为要做高可用的对象 - 下沉服务端(Redis Cluster 的 MOVED + 客户端缓存)
优点: 服务端权威,客户端只是缓存,两头分摊
缺点: 协议要客户端配合(收到 MOVED 会刷新表的才叫 smart client)
做选型时认出”这是间接层位置问题”,讨论直接跳到这张利弊表,不用从头吵。
9. 代价:后半句名言的具体形状
每加一层间接,固定支付四种成本:
- 延迟: 每层至少多一跳网络或一次查表,链路延迟是逐层累加的
- 新单点: 咽喉挂了全体遭殃。
所以 NGINX 前面要 keepalived,网关要集群部署
(为高可用加的层,自己又成了要做高可用的对象,
层就是这么一层层多起来的) - 排障变长: 来源 IP 要靠 X-Forwarded-For 逐跳传,
一次请求跨五层,没有全链路 trace 就是盲人摸象;
502/504 要先回答”这个错误码是哪一层返回的” - 翻译表不一致: 槽位表过期、DNS 缓存没跟上、注册中心推送延迟,
全是”翻译表的缓存”和现实脱节。
第 3 章的缓存一致性问题原样适用: 思想之间是嵌套的,不是并列的
10. 联系实际:什么时候该加层,什么时候该删层
该加层的信号:
调用方硬编码目标地址,目标一变全体改配置发版
同一段横切逻辑(鉴权/限流/日志)在每个服务里各写一份
想灰度、想迁移,找不到能拨流量的支点
该删层的信号:
一次请求过五层代理,每层只做了转发
排障时没人说得清这一跳为什么存在
某层的翻译表从上线起就没变过(静态得不需要翻译)
判断标准一句话: 这层的翻译规则会变吗?有人利用它集中做事吗?
两个都答否,它就只剩延迟贡献。
11. 学完本章你能解决什么问题
- 间接层白送的三种能力是什么,页表怎么逐条兑现它们?
- DNS 和 VIP 各管哪一段收敛速度,为什么秒级切换轮不到 DNS?
- NGINX 五种调度策略各配什么场景,X-Forwarded-For 为什么不能直接信?
- 网关的 Route/Predicate/Filter 对应翻译结构的哪三件,和 NGINX 怎么分工?
- “间接层 + 翻译表缓存”的组合出现在哪四个地方?
- 事务自调用失效的病根一句话是什么,三种修法共同在做什么?
- DispatcherServlet 和网关的同构点在哪?
- MQ 同时占了哪两个思想的便宜,它的翻译表是什么?
- 间接层位置三选的利弊表怎么背,Redis 方案史怎么走完三格的?
- 四种固定成本是什么,第四种和第 3 章什么关系?
核心一句话:间接层用一次翻译换位置透明、集中控制和平滑变更;它的翻译表本身又是缓存、它自己又可能成为新单点,模式互相嵌套,层就是这样长出来的;判断一层存废,看翻译规则变不变、有没有人在这层集中做事。