Thrift 路由学习笔记
📚 目录
- 1. 核心概念
- 2. HTTP vs Thrift 路由对比
- 3. Thrift 路由完整流程
- 4. 框架层路由实现原理
- 5. CustomThriftProxy 设计解析
- 6. 初始化流程详解
- 7. 连接池初始化详解
- 8. 实战案例
- 9. 常见问题
- 10. KrsDirectlyCluster 直连模式详解
1. 核心概念
1.1 什么是 Thrift 路由?
Thrift 路由 是指在使用 Thrift RPC 协议时,框架自动选择目标机房和节点的机制。
核心特点:
- ✅ 框架层路由: 路由决策在 Thrift 框架内部完成
- ✅ 业务无感知: 业务代码拿到的永远是同一个客户端
- ✅ 动态路由: 每次 RPC 调用都重新决策
- ✅ 高性能: 二进制协议 + 压缩,比 HTTP 快 2-3 倍
1.2 关键组件
┌─────────────────────────────────────────────────┐
│ CustomThriftProxy (自定义 Thrift 代理) │
│ - 继承 ThriftClientProxy │
│ - 注入 KrsRouteService (路由服务) │
│ - 注入 KrsOCTOAgentCluster (集群管理器) │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ KrsRouteService (路由决策服务) │
│ - 从配置中心读取路由配置 │
│ - 支持按比例分流 (RouteTable) │
│ - 支持动态切换 (配置热更新) │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ KrsOCTOAgentCluster (集群管理器) │
│ - 获取路由目标 IDC │
│ - 按 IDC 过滤节点 │
│ - 返回可用节点列表 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ MTThriftMethodInterceptor (CGLIB 拦截器) │
│ - 拦截所有方法调用 │
│ - 调用 cluster.getServerConnList() │
│ - 负载均衡选择节点 │
│ - 发送 Thrift RPC 请求 │
└─────────────────────────────────────────────────┘
2. HTTP vs Thrift 路由对比
2.1 架构对比
HTTP 模式架构
┌─────────────────────────────────────────────────┐
│ KrsSearchManager (多集群管理器) │
│ │
│ clients = { │
│ "appkey.mt" → EsClient1 (MT机房), │
│ "appkey.zf" → EsClient2 (ZF机房), │
│ "appkey.sh" → EsClient3 (SH机房) │
│ } │
│ │
│ ⭐ getClient() { │
│ // 应用层路由决策 │
│ String idc = routeTable.getRouteIdc(); │
│ return clients.get("appkey." + idc); │
│ } │
└─────────────────────────────────────────────────┘
Thrift 模式架构
┌─────────────────────────────────────────────────┐
│ KrsSearchManager (多集群管理器) │
│ │
│ thriftWrapperClient = EsClient(thriftProxy) │
│ │
│ getClient() { │
│ return thriftWrapperClient; // 直接返回 │
│ } │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ CustomThriftProxy │
│ - krsRouteService (路由服务) │
│ - cluster = KrsOCTOAgentCluster │
│ - serviceProxy = CGLIB 动态代理 │
└─────────────────────────────────────────────────┘
2.2 核心差异
| 维度 | HTTP 模式 | Thrift 模式 |
|---|---|---|
| 路由决策位置 | 应用层 (getClient()) | 框架层 (getServerConnList()) |
| 客户端数量 | 多个 (每个机房一个) | 单个 (包装 Thrift 代理) |
| 路由时机 | 获取客户端时 | 每次 RPC 调用时 |
| 业务感知 | ✅ 能感知不同客户端 | ❌ 完全无感知 |
| 拦截机制 | 无 | CGLIB 动态代理 |
| 通信协议 | HTTP/REST (JSON) | Thrift RPC (Binary) |
| 端口 | 8412 | 9888 |
| 性能 | 基准 (1x) | 快 2-3 倍 |
3. Thrift 路由完整流程
3.1 调用链路图
【业务代码】
client = searchManagerProxy.getClient();
// 返回: thriftWrapperClient (固定的)
client.search(request, callback);
↓
【CGLIB 拦截】
MTThriftMethodInterceptor.intercept()
↓
【框架层路由决策】
cluster.getServerConnList() {
// 1. 获取路由目标 IDC
String idc = krsRouteService.getReadRouteIdcName();
// ⭐ 调用 routeTable.getRouteIdc()
// 生成随机数: random = 30 → "mt"
// random = 70 → "zf"
// 2. 按 IDC 过滤节点
return filterByIdc(idc);
}
↓
【返回过滤后的节点列表】
[ServerConn(10.1.1.1:9888, az="mt"), ServerConn(10.1.1.2:9888, az="mt")]
↓
【负载均衡】
loadBalancer.select(serverConnList)
选择: 10.1.1.1:9888
↓
【发送 Thrift RPC 请求】
thriftClient.send(10.1.1.1:9888, request)
↓
【返回结果】
3.2 关键代码位置
KrsRouteService.java (路由决策)
public String getReadRouteIdcName() {
if (RouteUtil.newRouteEnable(this.remoteAppKey)) {
return routeTable.getRouteIdc(); // ⭐ 按比例随机
}
return readRouteIdcName;
}
KrsOCTOAgentCluster.java (节点过滤)
private List<ServerConn> filterConnsByDegradedAndIdc(List<ServerConn> connList) {
// 1. 获取路由目标 IDC
String routeIdcName = this.krsRouteService.getReadRouteIdcName();
// 2. 按 IDC 过滤节点
List<ServerConn> validConnList = new ArrayList<>();
for (ServerConn conn : serverConnList) {
if (routeIdcName.equalsIgnoreCase(conn.getServer().getAz())) {
validConnList.add(conn);
}
}
return validConnList;
}
4. 框架层路由实现原理
4.1 三层拦截架构
┌─────────────────────────────────────────────────┐
│ 第1层: CGLIB 动态代理 │
│ 业务代码调用 thriftClient.search() 时被拦截 │
│ ↓ │
│ MTThriftMethodInterceptor.intercept() │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 第2层: 集群管理器 (Cluster) │
│ KrsOCTOAgentCluster.getServerConnList() │
│ ↓ │
│ 1. 调用 krsRouteService.getReadRouteIdcName() │
│ 2. 调用 filterConnsByDegradedAndIdc() │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 第3层: 负载均衡器 (LoadBalancer) │
│ 从过滤后的节点列表中选择一个具体节点 │
│ ↓ │
│ loadBalancer.select(serverConnList) │
└─────────────────────────────────────────────────┘
4.2 CGLIB 动态代理
代理创建 (ThriftClientProxy.java)
private void loadServiceProxy() {
// ⭐ 创建拦截器,传入 cluster
MTThriftMethodInterceptor clientInterceptor =
new MTThriftMethodInterceptor(this, cluster, getLoadBalancer());
// ↑
// 这里的 cluster 是子类注入的 KrsOCTOAgentCluster!
// ⭐ 创建 CGLIB 代理
ProxyFactory pf = new ProxyFactory(interfaceClazz, clientInterceptor);
serviceProxy = pf.getProxy();
}
方法拦截 (MTThriftMethodInterceptor.java - 真实代码)
🔍 为什么调用 client.search() 会被拦截?
因为 client 是 CGLIB 创建的代理对象,不是真实对象!
// ThriftClientProxy.java:679-684
ProxyFactory pf = new ProxyFactory(interfaceClazz, clientInterceptor);
// ↑
// 这里传入了 MTThriftMethodInterceptor
serviceProxy = pf.getProxy();
// ↑
// 返回的是 CGLIB 代理对象,不是真实的 KrsSearchThriftService!
业务代码拿到的 client 实际上是这样的:
// 业务代码
EsClient client = searchManagerProxy.getClient();
// client 的真实类型是: KrsSearchThriftService$AsyncIface$$EnhancerByCGLIB$$12345678
// 当调用 search() 方法时
client.search(request, callback);
// ↓
// CGLIB 会拦截这个调用,转发给 MTThriftMethodInterceptor.intercept()
MTThriftMethodInterceptor 的真实代码:
// MTThriftMethodInterceptor.java:81-89
public class MTThriftMethodInterceptor implements MethodInterceptor {
private ThriftClientProxy clientProxy;
private ICluster cluster; // KrsOCTOAgentCluster
private ILoadBalancer loadBalancer;
// ⭐ CGLIB 会调用这个方法拦截所有方法调用
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
// 构建 RPC 调用上下文
RpcInvocation invocation = buildRpcInvocation(method, args);
// 执行过滤器链(包含路由决策)
RpcResult result = filterChain.invoke(invocation);
return result.getValue();
}
}
doInvoke 方法(真实的路由逻辑):
// MTThriftMethodInterceptor.java:257-319
private Object doInvoke(RpcInvocation invocation) throws Throwable {
MethodInvocation methodInvocation = invocation.getMethodInvocation();
String methodName = methodInvocation.getMethod().getName();
// ⭐ 第1步: 获取可用节点列表(触发路由决策!)
// MTThriftMethodInterceptor.java:285
List<ServerConn> connList = cluster.getServerConnList(routerMetaData);
// ↑
// 这里会调用 KrsOCTOAgentCluster.getServerConnList()
// 触发路由决策: 获取目标 IDC → 按 IDC 过滤节点
if (null == connList || connList.isEmpty()) {
throw new TException("connection list is empty!");
}
// 第2步: 创建调用上下文
InvokerContext invokerContext = new InvokerContext(
methodInvocation, connList, timeline, methodName, invocation, timeout, ...);
// 第3步: 执行重试逻辑(包含负载均衡和发送请求)
return doInvokeWithRetry(invokerContext);
}
doInvokeWithRetry 方法(负载均衡和发送请求):
// MTThriftMethodInterceptor.java:332-428
private Object doInvokeWithRetry(InvokerContext invokerContext) throws Throwable {
List<ServerConn> connList = invokerContext.getConnList();
// 重试循环
for (int i = 0; connList.size() > 0 && i < this.retryTimes; i++) {
// ⭐ 第1步: 负载均衡选择一个节点
// MTThriftMethodInterceptor.java:348
ServerConn serverConn = loadBalancer.select(connList, methodInvocation);
// ↑
// 从过滤后的节点列表中选择一个
// 例如: 从 [10.1.1.1:9888, 10.1.1.2:9888] 中选择 10.1.1.1:9888
// ⭐ 第2步: 获取连接
Object socket = getConnection(invokerContext, serverConn);
if (socket == null) {
// 连接获取失败,移除这个节点,重试下一个
connList = removeConn(invokerContext, connList, serverConn);
continue;
}
// ⭐ 第3步: 发送 Thrift RPC 请求
Entry<Object, Throwable> rpcResult = getRpcResult(invokerContext, serverConn, socket);
if (rpcResult != null && rpcResult.getT1() != null) {
return rpcResult.getT1(); // 成功返回
}
// 如果是网络异常,重试下一个节点
if (shouldRetry(rpcResult)) {
connList = removeConn(invokerContext, connList, serverConn);
continue;
}
}
throw toThrow;
}
完整的拦截流程:
【业务代码】
client.search(request, callback);
↓
【CGLIB 检测到方法调用】
发现 client 是代理对象,不是真实对象
↓
【CGLIB 调用拦截器】
MTThriftMethodInterceptor.intercept(obj, search方法, [request, callback], proxy)
↓
【拦截器内部】
1. filterChain.invoke(invocation)
↓
2. doInvoke(invocation)
↓
3. cluster.getServerConnList(routerMetaData) // ⭐ 触发路由决策
↓
KrsOCTOAgentCluster.getServerConnList()
↓
krsRouteService.getReadRouteIdcName() // 获取目标 IDC: "mt"
↓
filterConnsByDegradedAndIdc(connList) // 按 IDC 过滤节点
↓
返回: [ServerConn(10.1.1.1:9888, az="mt"), ServerConn(10.1.1.2:9888, az="mt")]
↓
4. doInvokeWithRetry(invokerContext)
↓
5. loadBalancer.select(connList) // 负载均衡选择: 10.1.1.1:9888
↓
6. getConnection(serverConn) // 获取连接
↓
7. getRpcResult(serverConn, socket) // 发送 Thrift RPC 请求
↓
【返回结果】
4.3 为什么说是"框架层路由"?
| 对比维度 | HTTP 模式(应用层) | Thrift 模式(框架层) |
|---|---|---|
| 路由决策位置 | KrsSearchManager.getClient() | MTThriftMethodInterceptor.intercept() |
| 触发时机 | 业务代码调用 getClient() 时 | 业务代码调用 RPC 方法时 |
| 业务感知 | ✅ 能感知不同客户端 | ❌ 完全无感知 |
| 拦截机制 | 无拦截,直接返回 | CGLIB 动态代理拦截 |
5. CustomThriftProxy 设计解析
5.1 为什么要自定义?
问题: MTThrift 框架的 ThriftClientProxy 不支持自定义路由逻辑
解决方案: 继承 ThriftClientProxy,通过反射注入自定义组件
5.2 继承关系
public class CustomThriftProxy extends ThriftClientProxy {
private KrsRouteService krsRouteService; // 自定义路由服务
@Override
public void afterPropertiesSet() throws Exception {
// 1. 初始化自定义路由服务
this.krsRouteService = new KrsRouteService(this);
// 2. 配置自定义集群管理器
configClusterManager();
// 3. 调用父类的 loadServiceProxy()
this.execSuperMethodByReflect("loadServiceProxy", null, null);
}
private void configClusterManager() throws Exception {
// ⭐ 通过反射替换父类的 cluster 字段
this.updateSuperFieldByReflect("cluster", new KrsOCTOAgentCluster(this));
}
}
5.3 核心设计思想
1. 继承父类,复用功能
✅ 不需要重写 loadServiceProxy() (100+ 行)
✅ 不需要重写 initTrace() (50+ 行)
✅ 不需要重写 configureAuth() (80+ 行)
✅ 不需要重写其他 20+ 个方法
✅ 总共复用父类 1000+ 行代码
2. 反射注入,替换关键组件
// 通过反射修改父类的 cluster 字段
this.updateSuperFieldByReflect("cluster", new KrsOCTOAgentCluster(this));
3. 父类自动使用自定义组件
// 父类的 loadServiceProxy() 中
MTThriftMethodInterceptor clientInterceptor =
new MTThriftMethodInterceptor(this, cluster, ...);
// ↑
// 这里的 cluster 是子类注入的 KrsOCTOAgentCluster!
5.4 完整调用链路
【应用启动】
↓
CustomThriftProxy.afterPropertiesSet()
↓
【第1步】初始化自定义路由服务
this.krsRouteService = new KrsRouteService(this);
↓
【第2步】配置自定义集群管理器
configClusterManager() {
this.updateSuperFieldByReflect("cluster", new KrsOCTOAgentCluster(this));
}
↓
【第3步】调用父类的 loadServiceProxy()
this.execSuperMethodByReflect("loadServiceProxy", null, null);
↓
【父类方法】ThriftClientProxy.loadServiceProxy()
↓
创建拦截器:
MTThriftMethodInterceptor clientInterceptor =
new MTThriftMethodInterceptor(this, cluster, ...);
// ↑
// 这里的 cluster 是 KrsOCTOAgentCluster!
↓
创建 CGLIB 代理:
serviceProxy = pf.getProxy();
↓
【业务调用】
client.search(request, callback);
↓
【CGLIB 拦截】→【自定义路由】→【发送请求】
6. 初始化流程详解
6.1 完整初始化流程
@Override
public void afterPropertiesSet() throws Exception {
// ========== 第1阶段: 基础环境初始化 ==========
MTthriftDependencyVersionChecker.checkMtthriftDependencyVersion();
ContextInitializer.init(); // 初始化配置中心连接
// ========== 第2阶段: Thrift 客户端基础配置 ==========
this.execSuperMethodByReflect("portPreCheck", null, null);
this.execSuperMethodByReflect("loadAppkey", null, null);
this.execSuperMethodByReflect("loadServiceName", null, null);
this.execSuperMethodByReflect("loadClassLoader", null, null);
this.execSuperMethodByReflect("initFunctionProtocol", null, null);
this.execSuperMethodByReflect("configAnnotation", null, null);
this.execSuperMethodByReflect("loadLoadBalancer", ...);
// ========== 第3阶段: 网络和异步配置 ==========
this.updateSuperFieldByReflect("localEndpoint", ...);
if (super.isAsync() && !super.isNettyIO()) {
this.execSuperMethodByReflect("initAsyncThriftResourceIfNeeded", ...);
}
// ========== 第4阶段: 路由和服务发现(核心!) ==========
this.krsRouteService = new KrsRouteService(this); // ⭐ 路由服务
configClusterManager(); // ⭐ 集群管理器
// ========== 第5阶段: 高级功能配置 ==========
this.execSuperMethodByReflect("initMccConfig", null, null);
this.execSuperMethodByReflect("loadServiceProxy", null, null); // ⭐ CGLIB 代理
this.execSuperMethodByReflect("initTrace", null, null);
this.execSuperMethodByReflect("configureAuth", null, null);
this.execSuperMethodByReflect("initHttpServer", null, null);
this.execSuperMethodByReflect("initNettyChannel", null, null);
this.execSuperMethodByReflect("loadClientInfo", null, null);
this.execSuperMethodByReflect("loadMeshConfig", null, null);
this.execSuperMethodByReflect("loadServiceInterface", null, null);
CellConfigManager.getInstance();
TlsUtil.initLocalTlsAppkeys();
this.updateSuperFieldByReflect("started", true);
}
6.2 关键步骤说明
| 步骤 | 方法 | 作用 |
|---|---|---|
| 1 | checkMtthriftDependencyVersion() | 检查 MTThrift 框架版本 |
| 2 | ContextInitializer.init() | 初始化配置中心连接 |
| 3 | portPreCheck() | 检查端口配置 |
| 4 | loadAppkey() | 加载应用 AppKey |
| 5 | loadServiceName() | 加载服务名称 |
| 6 | initFunctionProtocol() | 初始化 Thrift 协议 |
| 7 | loadLoadBalancer() | 加载负载均衡器 |
| 8 | new KrsRouteService() | ⭐ 初始化路由服务 |
| 9 | configClusterManager() | ⭐ 创建集群管理器 |
| 10 | loadServiceProxy() | ⭐ 创建 CGLIB 代理 |
| 11 | initTrace() | 初始化链路追踪 |
| 12 | configureAuth() | 配置 TLS 鉴权 |
| 13 | initNettyChannel() | 初始化 Netty 连接池 |
6.3 为什么用反射?
// 父类的方法是 private 的
private void loadAppkey() { ... }
private void loadServiceName() { ... }
// 子类想自定义初始化顺序,但又要复用父类逻辑
// 所以用反射强制调用
private void execSuperMethodByReflect(String methodName, ...) throws Exception {
Method declaredMethod = SUPER_CLAZZ.getDeclaredMethod(methodName, ...);
declaredMethod.setAccessible(true); // 绕过访问权限检查
declaredMethod.invoke(this, args);
}
7. 连接池初始化详解
核心问题:
KrsOCTOAgentCluster.getServerConnList()是如何获取到所有连接的?
7.1 核心答案
简短回答:
KrsOCTOAgentCluster.getServerConnList() 获取连接的过程:
1. 应用启动时,从 MNS (服务发现) 获取服务节点列表
2. 为每个节点创建 ServerConn (包含连接池)
3. 存储到 serverConns 列表中
4. 定时更新服务列表 (每 3 秒)
5. getServerConnList() 直接返回 serverConns 列表
关键数据结构:
// OCTOAgentBaseCluster.java
public class OCTOAgentBaseCluster extends BaseCluster {
// ⭐ 核心数据结构1: 存储所有可用的服务连接
protected volatile List<ServerConn> serverConns = new ArrayList<ServerConn>();
// ⭐ 核心数据结构2: IP:Port → ServerConn 的映射
protected volatile Map<String, ServerConn> ipPortServerConnsMap =
new ConcurrentHashMap<>();
// ⭐ 核心数据结构3: Cell → Swimlane → Region → ServerConn 的索引
private volatile Table<String, String, Map<String, List<ServerConn>>>
serverConnListIndexTable;
}
7.2 完整初始化流程
【应用启动】
↓
【CustomThriftProxy.afterPropertiesSet()】
↓
【ThriftClientProxy.configClusterManager()】
↓
【创建 KrsOCTOAgentCluster】
new KrsOCTOAgentCluster(clientProxy)
↓
【调用父类构造函数】
super(clientProxy) // OctoAgentCluster
↓
【调用父类构造函数】
super(clientProxy) // OCTOAgentBaseCluster
↓
【第1步: 初始化定时任务】
scheduExec.scheduleWithFixedDelay(
() -> getServerListByAgent(), // 每3秒更新一次
1, 3, TimeUnit.SECONDS
)
↓
【第2步: 从 MNS 获取服务列表】
getServerListByAgent() {
List<SGService> sgServiceList = MnsInvoker.getServiceList(protocolRequest);
// ↑
// 调用 MNS (服务发现) 获取服务节点
initServerList(sgServiceList);
}
↓
【第3步: 初始化服务列表】
initServerList(sgServiceList) {
for (SGService sgService : sgServiceList) {
Server server = sgService2Server(sgService);
addServer(server); // 为每个节点创建连接
}
updateValidConns(); // 更新 serverConns 列表
}
↓
【第4步: 创建 ServerConn】
addServer(server) {
ServerConn serverConn = new ServerConn();
serverConn.setServer(server);
// 创建连接池
if (isNettyIO) {
serverConn.setChannelPool(new NettyChannelPool(...));
} else {
serverConn.setObjectPool(createPool(...));
}
// 存储到 Map
ipPortServerConnsMap.put(server.getIp() + ":" + server.getPort(), serverConn);
}
↓
【第5步: 更新可用连接列表】
updateValidConns() {
List<ServerConn> _serverConns = new ArrayList<>();
for (ServerConn conn : ipPortServerConnsMap.values()) {
if (ALIVE == conn.getServer().getStatus()) {
_serverConns.add(conn);
}
}
serverConns = _serverConns; // ⭐ 更新全局列表
}
↓
【完成初始化】
serverConns 列表已包含所有可用连接
7.3 服务发现机制 (MNS)
MNS 是企业内部的服务发现系统,类似于 Consul、Eureka。
┌─────────────────────────────────────────────────┐
│ MNS (服务注册中心) │
│ │
│ 服务列表: │
│ - com.sankuai.krs.search │
│ - 10.1.1.1:9888 (MT机房, ALIVE) │
│ - 10.1.1.2:9888 (MT机房, ALIVE) │
│ - 10.2.1.1:9888 (ZF机房, ALIVE) │
│ - 10.2.1.2:9888 (ZF机房, ALIVE) │
│ - 10.3.1.1:9888 (SH机房, ALIVE) │
└─────────────────────────────────────────────────┘
↑
│ getServiceList(protocolRequest)
│
┌─────────────────────────────────────────────────┐
│ OCTOAgentBaseCluster (客户端) │
│ │
│ 定时任务 (每3秒): │
│ 1. 调用 MnsInvoker.getServiceList() │
│ 2. 获取最新的服务节点列表 │
│ 3. 更新本地 serverConns 列表 │
└─────────────────────────────────────────────────┘
ProtocolRequest (请求参数):
ProtocolRequest protocolRequest = new ProtocolRequest();
protocolRequest.setRemoteAppkey("com.sankuai.krs.search"); // 远程服务的 AppKey
protocolRequest.setServiceName("KrsSearchThriftService"); // 服务接口名
protocolRequest.setProtocol("thrift"); // 协议类型
protocolRequest.setLocalAppkey("com.sankuai.krs.client"); // 本地 AppKey
SGService (服务节点信息):
// MNS 返回的服务节点信息
public class SGService {
private String ip; // "10.1.1.1"
private int port; // 9888
private int status; // 2 (ALIVE)
private double weight; // 100.0
private String cell; // "default_cell"
private String swimlane; // "default_swimlane"
private String az; // "mt" (机房标识)
private Map<String, ServiceDetail> serviceInfo; // 服务详情
}
7.4 关键代码解析
7.4.1 从 MNS 获取服务列表
// OCTOAgentBaseCluster.java:331-351
private void getServerListByAgent(ProtocolRequest protocolRequest, boolean isInit) {
List<SGService> sgServiceList = null;
try {
// ⭐ 调用 MNS (服务发现) 获取服务节点列表
sgServiceList = MnsInvoker.getServiceList(protocolRequest);
// ↑
// protocolRequest 包含:
// - remoteAppkey: "com.sankuai.krs.search"
// - serviceName: "KrsSearchThriftService"
// - protocol: "thrift"
valid = validateServiceList(sgServiceList);
} catch (Exception ex) {
LOG.warn("getServerList by Agent Exception", ex);
valid = false;
}
if (valid) {
if (isInit) {
initServerList(sgServiceList); // 初始化
} else {
updateServerList(sgServiceList); // 更新
}
}
}
7.4.2 初始化服务列表
// OCTOAgentBaseCluster.java:414-448
private void initServerList(List<SGService> sgServiceList) {
Map<Server, Boolean> nettyServers = new HashMap<>();
// ⭐ 遍历所有服务节点
for (SGService sgService : sgServiceList) {
// 过滤条件1: 端口匹配
boolean filterPortNotMatch = remoteServerPort > 0 &&
sgService.getPort() != remoteServerPort;
// 过滤条件2: 服务名匹配
Map<String, ServiceDetail> serviceDetailMap = sgService.getServiceInfo();
boolean filterServiceNotMatch = filterByServiceName &&
(serviceDetailMap == null ||
!serviceDetailMap.containsKey(serviceName));
// 过滤条件3: 状态必须是 ALIVE (2)
if (ALIVE != sgService.getStatus() ||
filterPortNotMatch ||
filterServiceNotMatch) {
continue;
}
// ⭐ 将 SGService 转换为 Server 对象
Server server = sgService2Server(sgService, serviceName);
// ⭐ 为每个节点创建连接
if (isNettyIO && server.isNettyIOSupported()) {
nettyServers.put(server, lazyInit);
} else {
addServer(server, true, lazyInit);
}
}
// 批量初始化 Netty 连接
for (Server server : nettyServers.keySet()) {
addServer(server, true, nettyServers.get(server));
}
// ⭐ 更新可用连接列表
updateValidConns();
updateServerConnListIndexTable();
}
7.4.3 创建 ServerConn
// OCTOAgentBaseCluster.java:641-685
protected boolean addServer(Server server, boolean asyncInit, boolean lazyInit) {
LOG.info("addServer:{}", server);
// ⭐ 第1步: 创建 ServerConn 对象
ServerConn serverConn = new ServerConn();
serverConn.setCluster(this);
serverConn.setServer(server);
serverConn.setSwimlane(server.getSwimlane());
serverConn.setCell(server.getCell());
serverConn.setServerConnStatus(ServerConn.ServerConnStatus.AVAILABLE);
serverConn.setConnPoolConf(poolConfig);
// ⭐ 第2步: 创建连接池
if (lazyInit) {
// 延迟初始化
serverConn.setLazyInitPool(true);
} else {
if (isNettyIO && server.isNettyIOSupported()) {
// Netty 连接池
serverConn.setChannelPool(
new NettyChannelPool(serverConn, poolConfig, clientProxy, asyncInit)
);
} else {
// Thrift 连接池
serverConn.setObjectPool(
createPool(server.getIp(), server.getPort(), timeOut, poolConfig, connTimeout)
);
}
}
// ⭐ 第3步: 存储到 Map
String serverIpPort = server.getIp() + ":" + server.getPort();
ServerConn oldServerConn = ipPortServerConnsMap.put(serverIpPort, serverConn);
// ↑
// 存储到全局 Map: "10.1.1.1:9888" → ServerConn
if (oldServerConn != null) {
destroyServerConn(oldServerConn, true);
}
return true;
}
7.4.4 更新可用连接列表
// OCTOAgentBaseCluster.java:575-588
protected void updateValidConns() {
List<ServerConn> _serverConns = new ArrayList<ServerConn>();
// ⭐ 从 Map 中筛选出所有可用的连接
for (ServerConn conn : ipPortServerConnsMap.values()) {
if (ALIVE == conn.getServer().getStatus()) {
_serverConns.add(conn);
}
}
// ⭐ 更新全局列表 (volatile 保证可见性)
serverConns = _serverConns;
// ↑
// 这就是 getServerConnList() 返回的列表!
}
7.5 动态更新机制
定时更新流程:
【定时任务 (每3秒)】
↓
getServerListByAgent(protocolRequest, false)
↓
【从 MNS 获取最新服务列表】
List<SGService> sgServiceList = MnsInvoker.getServiceList(protocolRequest);
↓
【更新服务列表】
updateServerList(sgServiceList) {
// 1. 处理下线节点
handleServerRemoved(ipHostSGServiceMap);
// 2. 处理新增节点
handleServerAdded(ipHostSGServiceMap);
// 3. 处理节点属性变更
handleServerAttributesChanged(ipHostSGServiceMap);
// 4. 更新可用连接列表
updateValidConns();
}
处理下线节点:
// OCTOAgentBaseCluster.java:477-495
private boolean handleServerRemoved(Map<String, SGService> ipHostSGServiceMap) {
boolean changed = false;
Set<String> ipHostSet = ipHostSGServiceMap.keySet();
// ⭐ 遍历本地所有连接
for (Iterator<String> it = ipPortServerConnsMap.keySet().iterator(); it.hasNext(); ) {
String serverIpPort = it.next();
// ⭐ 如果 MNS 返回的列表中没有这个节点,说明已下线
if (!ipHostSet.contains(serverIpPort)) {
ServerConn serverConn = ipPortServerConnsMap.get(serverIpPort);
it.remove(); // 从 Map 中移除
changed = true;
if (serverConn != null) {
LOG.info("removeServer:{}", serverIpPort);
destroyServerConn(serverConn, true); // 销毁连接池
}
}
}
return changed;
}
7.6 getServerConnList() 的实现
// KrsOCTOAgentCluster.java:71-80
@Override
public List<ServerConn> getServerConnList(RouterMetaData routerMetaData) {
if (getServersWithoutRegion) {
// ⭐ 模式1: 不考虑 Region,直接过滤
List<ServerConn> serversWithoutRegion =
filterConnsByDegradedAndIdc(serverConns);
// ↑
// 直接使用 serverConns 列表!
// 这个列表是在应用启动时从 MNS 获取并初始化的
if (CollectionUtil.isEmpty(serversWithoutRegion)) {
serversWithoutRegion.addAll(serverConns);
}
return serversWithoutRegion;
}
// ⭐ 模式2: 考虑 Region,使用索引表
return getServerConnListByCellRoute(routerMetaData);
}
7.7 完整调用链
【业务代码】
client.search(request, callback);
↓
【CGLIB 拦截】
MTThriftMethodInterceptor.intercept()
↓
【获取所有连接】
List<ServerConn> connList = cluster.getServerConnList(routerMetaData);
// ↑
// 返回 serverConns 列表 (从 MNS 获取并缓存)
// serverConns = [
// ServerConn(10.1.1.1:9888, az="mt"),
// ServerConn(10.1.1.2:9888, az="mt"),
// ServerConn(10.2.1.1:9888, az="zf"),
// ServerConn(10.2.1.2:9888, az="zf")
// ]
↓
【按 IDC 过滤】
List<ServerConn> filteredConns = filterConnsByDegradedAndIdc(connList);
// ↑
// 根据 krsRouteService.getReadRouteIdcName() 过滤
// 假设返回 "mt", 则过滤后:
// filteredConns = [
// ServerConn(10.1.1.1:9888, az="mt"),
// ServerConn(10.1.1.2:9888, az="mt")
// ]
↓
【负载均衡】
ServerConn selected = loadBalancer.select(filteredConns);
// ↑
// 选择: ServerConn(10.1.1.1:9888, az="mt")
↓
【获取连接】
Object socket = getConnection(selected);
// ↑
// 从连接池中获取一个可用连接
↓
【发送请求】
getRpcResult(selected, socket);
7.8 核心要点总结
-
serverConns 是全局共享的
- 所有 RPC 调用都使用同一个
serverConns列表 - 通过
volatile保证可见性
- 所有 RPC 调用都使用同一个
-
连接池是预先创建的
- 应用启动时就创建好所有连接池
- 不是每次调用时才创建
-
动态更新机制
- 定时任务每3秒从 MNS 获取最新服务列表
- 自动处理节点上下线和属性变更
-
路由过滤在 getServerConnList() 之后
getServerConnList()返回所有可用连接filterConnsByDegradedAndIdc()按 IDC 过滤loadBalancer.select()负载均衡选择一个
8. 实战案例
8.1 灰度发布新集群
配置示例
// 配置中心: appkey.route.beijing
{
"mt_old": 90, // 老集群 90% 流量
"mt_new": 10 // 新集群 10% 流量(灰度)
}
效果
100 个请求:
- 约 90 个 → MT_OLD 集群
- 约 10 个 → MT_NEW 集群
灰度步骤
// 第1步: 灰度 10%
{"mt_old": 90, "mt_new": 10}
// 第2步: 灰度 30%
{"mt_old": 70, "mt_new": 30}
// 第3步: 灰度 50%
{"mt_old": 50, "mt_new": 50}
// 第4步: 全量切换
{"mt_old": 0, "mt_new": 100}
8.2 集群故障切换
配置示例
# 正常情况
appkey.read.mt = mt
# MT 集群故障,切换到 ZF 集群
appkey.read.mt = zf
# 故障恢复,切回 MT 集群
appkey.read.mt = mt
效果
- ✅ 配置热更新,无需重启应用
- ✅ 秒级切换,快速止损
- ✅ 自动恢复,无需人工干预
9. 常见问题
Q1: 为什么 Thrift 性能更好?
A: 主要有以下几个原因:
1. 二进制协议 vs JSON 文本协议
HTTP/JSON 方式 (文本协议):
// 发送一个搜索请求
{
"query": "美食",
"from": 0,
"size": 10,
"filters": {
"city": "北京",
"price": 50
}
}
- 数据大小: 约 100 字节
- 需要解析: 字符串 → JSON 对象
- 可读性: ✅ 人类可读
- 效率: ❌ 占用空间大,解析慢
Thrift/Binary 方式 (二进制协议):
// 同样的数据,二进制表示 (用十六进制显示)
[0x01, 0x00, 0x01, 0x0B, 0x00, 0x00, 0x00, 0x06,
0xE7, 0xBE, 0x8E, 0xE9, 0xA3, 0x9F, 0x08, 0x00, ...]
- 数据大小: 约 40 字节 (减少 60%)
- 需要解析: 二进制 → 对象 (直接映射)
- 可读性: ❌ 人类不可读
- 效率: ✅ 占用空间小,解析快
🔍 你说得对!这里用的是十六进制表示法:
0x前缀表示这是十六进制数- 每个十六进制数代表 4 位二进制
- 两个十六进制数 = 1 字节 = 8 位二进制
详细转换过程见下方 👇
类比:
- JSON 就像写信,要写"我今年25岁",需要7个汉字
- Binary 就像发电报,只需要发"25",2个数字就够了
🔢 补充: 二进制、十六进制、数据转换详解
1. 为什么用十六进制显示?
二进制太长,不方便阅读:
二进制: 11100111 10111110 10001110 (太长!)
十六进制: E7 BE 8E (简洁!)
十六进制是二进制的简写:
- 1个十六进制位 = 4个二进制位
- 2个十六进制位 = 1个字节 = 8个二进制位
2. 进制转换对照表
| 十进制 | 二进制 | 十六进制 | 说明 |
|---|---|---|---|
| 0 | 0000 | 0 | |
| 1 | 0001 | 1 | |
| 2 | 0010 | 2 | |
| 3 | 0011 | 3 | |
| 4 | 0100 | 4 | |
| 5 | 0101 | 5 | |
| 6 | 0110 | 6 | |
| 7 | 0111 | 7 | |
| 8 | 1000 | 8 | |
| 9 | 1001 | 9 | |
| 10 | 1010 | A | 十六进制用A表示10 |
| 11 | 1011 | B | |
| 12 | 1100 | C | |
| 13 | 1101 | D | |
| 14 | 1110 | E | |
| 15 | 1111 | F | 十六进制用F表示15 |
3. 实际转换示例: "美食" 两个字
第1步: 文本 → UTF-8 编码
"美" → UTF-8: E7 BE 8E (3个字节)
"食" → UTF-8: E9 A3 9F (3个字节)
第2步: 十六进制 → 二进制
E7 = 1110 0111
BE = 1011 1110
8E = 1000 1110
E9 = 1110 1001
A3 = 1010 0011
9F = 1001 1111
第3步: 在网络上传输的实际数据
实际传输的是二进制:
11100111 10111110 10001110 11101001 10100011 10011111
但我们用十六进制显示:
E7 BE 8E E9 A3 9F
因为十六进制更简洁易读!
4. 完整的 Thrift 数据包结构
假设我们要发送: {"query": "美食", "size": 10}
JSON 方式 (文本):
{"query":"美食","size":10}
- 实际字节:
7B 22 71 75 65 72 79 22 3A 22 E7 BE 8E E9 A3 9F 22 2C 22 73 69 7A 65 22 3A 31 30 7D - 字节数: 29 字节
- 说明: 每个字符都要编码,包括
{、"、:等
Thrift Binary 方式 (二进制):
字段1: query (字符串类型)
0x0B → 字段类型: String (1字节)
0x00 0x01 → 字段ID: 1 (2字节)
0x00 0x06 → 字符串长度: 6字节 (2字节)
0xE7 0xBE 0x8E 0xE9 0xA3 0x9F → "美食" (6字节)
字段2: size (整数类型)
0x08 → 字段类型: i32 (1字节)
0x00 0x02 → 字段ID: 2 (2字节)
0x00 0x00 0x00 0x0A → 数值: 10 (4字节)
结束标记:
0x00 → 字段结束 (1字节)
- 总字节数: 1+2+2+6 + 1+2+4 + 1 = 19 字节
- 节省: (29-19)/29 = 34.5%
5. 为什么 Thrift 更小?
JSON 的开销:
{"query":"美食","size":10}
↑ ↑ ↑ ↑
这些符号都要占用字节!
{}:,"这些符号都要传输- 字段名 "query"、"size" 也要传输
- 数字 10 要转成字符串 "10"
Thrift 的优化:
0x0B 0x00 0x01 0x00 0x06 ...
↑ ↑ ↑ ↑ ↑
类型 字段ID 长度 数据
- 不需要传输字段名 (用字段ID代替)
- 不需要符号 (用类型标记代替)
- 数字直接用二进制表示 (不转字符串)
6. 实际代码示例
Java 中如何处理二进制数据:
// ========== JSON 方式 ==========
String json = "{"query":"美食","size":10}";
byte[] jsonBytes = json.getBytes("UTF-8");
System.out.println("JSON 字节数: " + jsonBytes.length); // 29
// 查看十六进制
for (byte b : jsonBytes) {
System.out.printf("%02X ", b);
}
// 输出: 7B 22 71 75 65 72 79 22 3A 22 E7 BE 8E E9 A3 9F 22 2C 22 73 69 7A 65 22 3A 31 30 7D
// ========== Thrift Binary 方式 ==========
// 手动构造 Thrift 二进制数据
ByteBuffer buffer = ByteBuffer.allocate(100);
// 字段1: query (String)
buffer.put((byte) 0x0B); // 类型: String
buffer.putShort((short) 1); // 字段ID: 1
buffer.putShort((short) 6); // 长度: 6
buffer.put("美食".getBytes("UTF-8")); // 数据
// 字段2: size (i32)
buffer.put((byte) 0x08); // 类型: i32
buffer.putShort((short) 2); // 字段ID: 2
buffer.putInt(10); // 数值: 10
// 结束标记
buffer.put((byte) 0x00);
byte[] thriftBytes = new byte[buffer.position()];
buffer.flip();
buffer.get(thriftBytes);
System.out.println("Thrift 字节数: " + thriftBytes.length); // 19
// 查看十六进制
for (byte b : thriftBytes) {
System.out.printf("%02X ", b);
}
// 输出: 0B 00 01 00 06 E7 BE 8E E9 A3 9F 08 00 02 00 00 00 0A 00
7. 十六进制 vs 二进制 vs 十进制对比
以数字 255 为例:
| 进制 | 表示 | 说明 |
|---|---|---|
| 十进制 | 255 | 人类习惯的表示 |
| 二进制 | 11111111 | 计算机实际存储 (8位) |
| 十六进制 | 0xFF | 简洁的表示 (2位) |
为什么程序员喜欢用十六进制?
- ✅ 比二进制短 (8位 → 2位)
- ✅ 和二进制转换简单 (每4位对应1位)
- ✅ 方便查看内存数据
8. 形象类比
十进制 (Decimal):
- 就像我们平时数数: 1, 2, 3, ..., 9, 10, 11, ...
- 逢十进一
二进制 (Binary):
- 就像开关: 开(1) 或 关(0)
- 逢二进一
- 计算机只认识这个!
十六进制 (Hexadecimal):
- 就像二进制的"简写"
- 0-9 用数字, 10-15 用 A-F
- 逢十六进一
- 程序员用来"翻译"二进制
类比:
二进制 = 摩斯密码 (点点划划,太长)
十六进制 = 简写版摩斯密码 (更短,更易读)
十进制 = 普通文字 (人类习惯)
9. 总结
| 特性 | JSON (文本) | Thrift (二进制) |
|---|---|---|
| 实际存储 | 文本字符 | 二进制数据 |
| 显示方式 | 直接显示 | 用十六进制显示 |
| 字段名 | 完整传输 | 用ID代替 |
| 符号 | 需要传输 | 不需要 |
| 数字 | 转成字符串 | 直接二进制 |
| 大小 | 大 | 小 (节省30-60%) |
关键点:
- 💡 二进制是计算机实际存储的方式
- 💡 十六进制是人类查看二进制的简便方式
- 💡
0x前缀表示这是十六进制数 - 💡 Thrift 传输的是二进制,但我们用十六进制显示
2. 数据压缩: Snappy/Gzip
未压缩的 JSON:
{
"results": [
{"id": 1, "name": "火锅店", "score": 4.5},
{"id": 2, "name": "烧烤店", "score": 4.3},
{"id": 3, "name": "川菜馆", "score": 4.8},
... // 重复 100 次
]
}
- 数据大小: 约 5000 字节
Snappy 压缩后:
[压缩后的二进制数据]
- 数据大小: 约 1000 字节 (减少 80%)
- 压缩时间: < 1ms (非常快)
- 解压时间: < 1ms
类比:
- 未压缩就像把100个苹果一个个装箱
- 压缩就像把苹果榨成汁,体积小了很多
3. 长连接 vs 短连接
HTTP 短连接 (每次请求都要建立连接):
第1次请求:
1. TCP 三次握手 (1.5ms)
2. 发送请求 (2ms)
3. 接收响应 (2ms)
4. TCP 四次挥手 (1.5ms)
总耗时: 7ms
第2次请求:
1. TCP 三次握手 (1.5ms) ← 又要握手!
2. 发送请求 (2ms)
3. 接收响应 (2ms)
4. TCP 四次挥手 (1.5ms)
总耗时: 7ms
100次请求总耗时: 700ms
Thrift 长连接 (连接复用):
第1次请求:
1. TCP 三次握手 (1.5ms) ← 只握手一次!
2. 发送请求 (2ms)
3. 接收响应 (2ms)
总耗时: 5.5ms
第2次请求:
1. 发送请求 (2ms) ← 直接发送,不用握手!
2. 接收响应 (2ms)
总耗时: 4ms
第3-100次请求:
每次只需要 4ms
100次请求总耗时: 5.5 + 99*4 = 401.5ms
节省时间: 700ms - 401.5ms = 298.5ms (节省 43%)
类比:
- HTTP 短连接就像每次打电话都要重新拨号
- Thrift 长连接就像保持通话,一直聊下去
4. 解析速度对比
JSON 解析 (文本 → 对象):
// HTTP/JSON 方式
String json = "{"name":"张三","age":25}";
// 第1步: 词法分析 (找到 {、"、:、} 等符号)
// 第2步: 语法分析 (构建 JSON 树)
// 第3步: 类型转换 ("25" → 25)
// 第4步: 创建对象
User user = JSON.parseObject(json, User.class);
// 耗时: 约 100 微秒
Binary 解析 (二进制 → 对象):
// Thrift/Binary 方式
byte[] bytes = [0x01, 0x00, 0x01, 0x0B, ...];
// 第1步: 直接读取字段类型 (0x01 = String)
// 第2步: 直接读取字段值 (0x06 = 长度6, 后面6字节是数据)
// 第3步: 直接映射到对象
User user = ThriftCodec.decode(bytes, User.class);
// 耗时: 约 20 微秒
速度提升: 100 / 20 = 5 倍
类比:
- JSON 解析就像读一篇文章,要理解每个字的意思
- Binary 解析就像看图表,一眼就能看懂
5. 完整性能对比实例
假设我们要搜索 100 次,每次返回 10 条结果:
HTTP/JSON 方式:
单次请求:
1. 建立连接: 1.5ms (TCP 握手)
2. 发送请求: 2ms (JSON 100字节)
3. 接收响应: 5ms (JSON 5000字节)
4. 解析响应: 0.1ms (JSON 解析)
5. 关闭连接: 1.5ms (TCP 挥手)
单次总耗时: 10.1ms
100次请求总耗时: 10.1 * 100 = 1010ms
Thrift/Binary 方式:
第1次请求:
1. 建立连接: 1.5ms (TCP 握手,只一次)
2. 发送请求: 0.5ms (Binary 40字节)
3. 接收响应: 1ms (Binary 1000字节,Snappy压缩)
4. 解压响应: 0.5ms (Snappy 解压)
5. 解析响应: 0.02ms (Binary 解析)
第1次总耗时: 3.52ms
第2-100次请求 (复用连接):
1. 发送请求: 0.5ms
2. 接收响应: 1ms
3. 解压响应: 0.5ms
4. 解析响应: 0.02ms
每次总耗时: 2.02ms
100次请求总耗时: 3.52 + 99*2.02 = 203.5ms
性能对比:
- HTTP/JSON: 1010ms
- Thrift/Binary: 203.5ms
- 速度提升: 1010 / 203.5 = 4.96 倍 ≈ 5 倍!
6. 真实场景举例
场景: 电商搜索,每秒 10000 次请求
HTTP/JSON 方式:
每次请求耗时: 10ms
需要的服务器数量: 10000 * 10ms / 1000ms = 100 台
每月服务器成本: 100台 * 5000元/台 = 50万元
Thrift/Binary 方式:
每次请求耗时: 2ms
需要的服务器数量: 10000 * 2ms / 1000ms = 20 台
每月服务器成本: 20台 * 5000元/台 = 10万元
节省成本: 50万 - 10万 = 40万元/月 = 480万元/年!
7. 为什么不都用 Thrift?
Thrift 的缺点:
- ❌ 不易调试 (二进制数据人类不可读)
- ❌ 需要定义 IDL 文件 (接口定义语言)
- ❌ 跨语言支持有限 (需要生成代码)
- ❌ 学习成本高
HTTP/JSON 的优点:
- ✅ 易于调试 (可以直接看懂)
- ✅ 通用性强 (浏览器、Postman 都能用)
- ✅ 跨语言简单 (任何语言都支持 JSON)
- ✅ 学习成本低
选择建议:
使用 HTTP/JSON:
- 对外 API (给第三方调用)
- 管理后台 (调用量小)
- 快速开发 (原型验证)
使用 Thrift/Binary:
- 内部服务 (微服务之间)
- 高并发场景 (QPS > 10000)
- 性能敏感 (延迟 < 10ms)
8. 总结对比表
| 维度 | HTTP/JSON | Thrift/Binary | 提升倍数 |
|---|---|---|---|
| 数据大小 | 100 字节 | 40 字节 | 2.5x |
| 压缩后 | 100 字节 | 10 字节 | 10x |
| 解析速度 | 100 微秒 | 20 微秒 | 5x |
| 连接开销 | 每次 3ms | 首次 3ms | N倍 |
| 单次请求 | 10ms | 2ms | 5x |
| 100次请求 | 1010ms | 203.5ms | 5x |
| 服务器成本 | 100台 | 20台 | 5x |
综合性能提升: 2-5 倍 (取决于场景)
9. 形象类比总结
HTTP/JSON 就像:
- 📝 写信: 要写很多字,邮递员要看懂
- 🚗 开车: 每次都要从家里出发
- 📚 读文章: 要理解每个字的意思
Thrift/Binary 就像:
- 📟 发电报: 只发关键信息,用代码表示
- 🚄 高铁: 一次出发,中途不停
- 📊 看图表: 一眼就能看懂数据
结论: Thrift 就像是给数据"瘦身"+"加速",让它跑得更快!
Q2: 业务代码如何使用?
A: 业务代码无需关心路由逻辑,正常调用即可:
@Service
public class SearchService {
@Autowired
private SearchManagerProxy searchManagerProxy;
public SearchResponse search(String keyword) {
// ⭐ 获取客户端(返回固定的 thriftWrapperClient)
EsClient client = searchManagerProxy.getClient();
// 调用搜索(框架层自动路由)
return client.search(request, callback);
}
}
Q3: 如何监控路由是否生效?
A: 可以通过以下方式监控:
// 1. 查看日志
LOGGER.info("THRIFT NEW ROUTE TABLE CHANGED, appKey: {}, routeTable {}",
remoteAppKey, routeTable);
// 2. 添加监控埋点
Cat.logEvent("ES.Route", idc); // 记录每次路由到哪个 IDC
// 3. 查看 CAT 监控
// 在 CAT 上查看 ES.Route 事件的分布
// 应该看到 50% mt, 50% zf
Q4: 路由配置错误会怎样?
A: 会抛出异常,应用启动失败:
// 错误配置1: 权重总和不是 100
{"mt": 50, "zf": 40} // 总和 90
// 异常: Invalid route info.
// 错误配置2: IDC 不在 multi 中
{"mt": 50, "unknown": 50}
// 异常: invalid routeInfo config, not in multi
// 错误配置3: IDC 不在同一个 Region
{"mt": 50, "sh": 50} // mt 在 beijing, sh 在 shanghai
// 异常: invalid routeInfo config, not in this region
10. KrsDirectlyCluster 直连模式详解
本章节详细解析
KrsDirectlyCluster和KrsOCTOAgentCluster两种集群管理器在整个 RPC 调用链路中的作用。
10.1 两种 Cluster 的对比
| 维度 | KrsOCTOAgentCluster | KrsDirectlyCluster |
|---|---|---|
| 服务发现方式 | MNS (服务发现) | 配置中心 (Lion) |
| 节点列表来源 | 动态从 MNS 获取 | 从配置中心读取 IP 列表 |
| 更新频率 | 每 3 秒 | 每 240 秒 (4分钟) |
| 适用场景 | 标准服务调用 | 直连特定节点 |
| 配置复杂度 | 低 (自动发现) | 高 (需手动配置 IP) |
| 灵活性 | 高 (自动感知节点变化) | 低 (需手动更新配置) |
10.2 完整的 RPC 调用链路
阶段1: 应用启动阶段
【Spring 容器启动】
↓
@Bean
public CustomThriftProxy customThriftProxy() {
return new CustomThriftProxy();
}
↓
【Spring 调用 afterPropertiesSet()】
CustomThriftProxy.afterPropertiesSet()
阶段2: 初始化阶段 (Cluster 的核心作用)
2.1 创建 Cluster
// CustomThriftProxy.java
@Override
public void afterPropertiesSet() throws Exception {
// ... 省略前面的步骤 ...
// ⭐ 第4阶段: 创建集群管理器
configClusterManager(); // 这里创建 Cluster!
}
private void configClusterManager() throws Exception {
if (直连模式) {
// ⭐ 创建 KrsDirectlyCluster
this.updateSuperFieldByReflect("cluster",
new KrsDirectlyCluster(this));
} else {
// ⭐ 创建 KrsOCTOAgentCluster
this.updateSuperFieldByReflect("cluster",
new KrsOCTOAgentCluster(this));
}
}
2.2 KrsDirectlyCluster 初始化
// KrsDirectlyCluster.java:42-64
public KrsDirectlyCluster(CustomThriftProxy clientProxy) throws Exception {
// ⭐ 环节1: 服务发现
List<String> sameRegionIpList = getSameRegionIpList();
// 从配置中心获取 IP 列表
// 返回: ["10.1.1.1", "10.1.1.2", "10.2.1.1"]
// ⭐ 环节2: 建立连接
this.serverConns = buildServerConnList(sameRegionIpList);
// 为每个 IP 创建连接池
// serverConns = [
// ServerConn(10.1.1.1:9888, pool=NettyChannelPool),
// ServerConn(10.1.1.2:9888, pool=NettyChannelPool),
// ServerConn(10.2.1.1:9888, pool=NettyChannelPool)
// ]
// ⭐ 环节3: 初始化路由服务
this.krsRouteService = clientProxy.getKrsRouteService();
// ⭐ 环节4: 启动定时任务
startScheduledTask();
// 每 240 秒更新一次节点列表
}
2.3 KrsOCTOAgentCluster 初始化
// KrsOCTOAgentCluster.java:20-24
public KrsOCTOAgentCluster(CustomThriftProxy clientProxy) throws Exception {
// ⭐ 调用父类构造函数
super(clientProxy); // OctoAgentCluster
// ⭐ 初始化路由服务
this.krsRouteService = clientProxy.getKrsRouteService();
}
// 父类 OCTOAgentBaseCluster 的构造函数
public OCTOAgentBaseCluster(ThriftClientProxy clientProxy) {
// ⭐ 环节1: 启动定时任务
scheduExec.scheduleWithFixedDelay(
() -> getServerListByAgent(), // 每 3 秒更新一次
1, 3, TimeUnit.SECONDS
);
// ⭐ 环节2: 首次获取服务列表
getServerListByAgent(protocolRequest, true);
// 从 MNS 获取节点列表
// ⭐ 环节3: 初始化服务列表
initServerList(sgServiceList);
// 为每个节点创建连接池
}
阶段3: 运行时阶段 (Cluster 的核心作用)
3.1 业务代码发起 RPC 调用
// 业务代码
EsClient client = searchManagerProxy.getClient();
SearchResponse response = client.search(request, callback);
3.2 完整调用链路
【第1步: CGLIB 拦截】
MTThriftMethodInterceptor.intercept()
↓
【第2步: 获取可用节点列表】⭐ Cluster 的第1个核心作用
List<ServerConn> connList = cluster.getServerConnList(routerMetaData);
↓
┌─────────────────────────────────────────┐
│ KrsDirectlyCluster.getServerConnList() │
│ 或 │
│ KrsOCTOAgentCluster.getServerConnList() │
└─────────────────────────────────────────┘
↓
【第3步: 路由过滤】⭐ Cluster 的第2个核心作用
filterConnsByDegradedAndIdc(connList)
↓
// 1. 过滤降级节点
// 2. 按 IDC 过滤节点
// 3. 返回过滤后的节点列表
↓
【第4步: 负载均衡】
ServerConn selected = loadBalancer.select(connList);
↓
【第5步: 获取连接】⭐ Cluster 的第3个核心作用
Object socket = getConnection(selected);
↓
// 从连接池中获取一个可用连接
// 如果是 Netty: channelPool.acquire()
// 如果是 Thrift: objectPool.borrowObject()
↓
【第6步: 发送 RPC 请求】
getRpcResult(selected, socket);
↓
【第7步: 返回结果】
10.3 Cluster 在各环节的详细作用
环节1: 获取可用节点列表
KrsDirectlyCluster
// KrsDirectlyCluster.java:140-164
@Override
public List<ServerConn> getServerConnList() {
// ⭐ 作用1: 过滤降级节点
List<ServerConn> serverConnExcludeDegradeNodeList =
super.filterServerConnListByDegradedWeight(this.serverConns);
// ⭐ 作用2: 按 IDC 路由过滤
String _routeIdcName = this.krsRouteService.getReadRouteIdcName();
// 从路由配置中获取目标 IDC: "mt" 或 "zf"
List<ServerConn> validConnList = new ArrayList<>();
for (ServerConn serverConn : serverConnExcludeDegradeNodeList) {
if (_routeIdcName.equalsIgnoreCase(serverConn.getServer().getAz())) {
validConnList.add(serverConn);
}
}
return validConnList;
}
输入:
this.serverConns: 所有节点 (3个)_routeIdcName: "mt"
输出:
[
ServerConn(10.1.1.1:9888, az="mt"),
ServerConn(10.1.1.2:9888, az="mt")
]
KrsOCTOAgentCluster
// KrsOCTOAgentCluster.java:71-80
@Override
public List<ServerConn> getServerConnList(RouterMetaData routerMetaData) {
if (getServersWithoutRegion) {
// ⭐ 作用1: 过滤降级节点
// ⭐ 作用2: 按 IDC 路由过滤
List<ServerConn> serversWithoutRegion =
filterConnsByDegradedAndIdc(serverConns);
return serversWithoutRegion;
}
// ⭐ 作用3: 按 Region 路由过滤
return getServerConnListByCellRoute(routerMetaData);
}
环节2: 路由过滤
// KrsOCTOAgentCluster.java:26-43
private List<ServerConn> filterConnsByDegradedAndIdc(List<ServerConn> connList) {
// ⭐ 第1步: 过滤降级节点
List<ServerConn> serverConnList =
super.filterServerConnListByDegradedWeight(connList);
// ⭐ 第2步: 获取路由目标 IDC
String routeIdcName = this.krsRouteService.getReadRouteIdcName();
// 调用 routeTable.getRouteIdc()
// 按比例随机: 50% → "mt", 50% → "zf"
// ⭐ 第3步: 按 IDC 过滤节点
List<ServerConn> validConnList = new ArrayList<ServerConn>();
for (ServerConn conn : serverConnList) {
if (routeIdcName.equalsIgnoreCase(conn.getServer().getAz())) {
validConnList.add(conn);
}
}
return validConnList;
}
作用:
- 从 5 个节点 → 过滤到 2 个节点
- 实现了机房级别的路由
环节3: 提供连接池
// MTThriftMethodInterceptor.java
private Object doInvokeWithRetry(InvokerContext invokerContext) {
// ⭐ 第1步: 负载均衡选择一个节点
ServerConn serverConn = loadBalancer.select(connList);
// ⭐ 第2步: 从连接池获取连接
Object socket = getConnection(invokerContext, serverConn);
// ↑
// 这里会调用 serverConn.getChannelPool().acquire()
// 或 serverConn.getObjectPool().borrowObject()
// ⭐ 第3步: 发送 RPC 请求
Entry<Object, Throwable> rpcResult =
getRpcResult(invokerContext, serverConn, socket);
return rpcResult.getT1();
}
Cluster 的作用:
- 在初始化时创建了连接池
- 在运行时提供连接池的引用
10.4 后台定时任务 (Cluster 的持续作用)
KrsDirectlyCluster 的定时任务
// KrsDirectlyCluster.java:202-213
private void startScheduledTask() {
serverListPollingTask = scheduExec.scheduleAtFixedRate(
() -> directUpdate(), // ⭐ 每 240 秒执行一次
60, 240, TimeUnit.SECONDS
);
}
private void directUpdate() throws Exception {
// ⭐ 环节1: 从配置中心获取最新 IP 列表
List<String> clusterLocalRegionIps = getSameRegionIpList();
// ⭐ 环节2: 检测下线节点
Set<String> needRemoved = handleServerRemoved(clusterLocalRegionIps);
// ⭐ 环节3: 检测新增节点
Set<String> needAdded = handleServerAdded(clusterLocalRegionIps);
// ⭐ 环节4: 更新 serverConns 列表
// 移除下线节点,添加新增节点
// ⭐ 环节5: 销毁下线节点的连接池
for (ServerConn serverConn : needDestroy) {
destroyServerConn(serverConn, true);
}
}
KrsOCTOAgentCluster 的定时任务
// OCTOAgentBaseCluster.java
scheduExec.scheduleWithFixedDelay(
() -> getServerListByAgent(), // ⭐ 每 3 秒执行一次
1, 3, TimeUnit.SECONDS
);
private void getServerListByAgent() {
// ⭐ 环节1: 从 MNS 获取最新服务列表
List<SGService> sgServiceList = MnsInvoker.getServiceList(protocolRequest);
// ⭐ 环节2: 更新服务列表
updateServerList(sgServiceList);
// ⭐ 环节3: 处理节点上下线
handleServerRemoved(ipHostSGServiceMap);
handleServerAdded(ipHostSGServiceMap);
// ⭐ 环节4: 更新 serverConns 列表
updateValidConns();
}
10.5 销毁阶段
// KrsDirectlyCluster.java:167-185
@Override
public void destroy() {
// ⭐ 环节1: 取消定时任务
if (null != serverListPollingTask) {
serverListPollingTask.cancel(true);
}
// ⭐ 环节2: 销毁所有连接池
for (ServerConn serverConn : serverConns) {
destroyServerConn(serverConn, false);
}
// ⭐ 环节3: 清空列表
serverConns.clear();
}
10.6 完整流程图
┌─────────────────────────────────────────────────────────────┐
│ 【应用启动阶段】 │
│ │
│ Spring 容器启动 → CustomThriftProxy.afterPropertiesSet() │
│ ↓ │
│ configClusterManager() │
│ ↓ │
│ ┌─────────────────┴─────────────────┐ │
│ ↓ ↓ │
│ KrsDirectlyCluster KrsOCTOAgentCluster │
│ - 从配置中心获取 IP - 从 MNS 获取节点 │
│ - 创建连接池 - 创建连接池 │
│ - 启动定时任务 (240秒) - 启动定时任务 (3秒) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 【运行时阶段】 │
│ │
│ 业务代码: client.search(request, callback) │
│ ↓ │
│ 【CGLIB 拦截】MTThriftMethodInterceptor.intercept() │
│ ↓ │
│ 【获取节点】cluster.getServerConnList() ⭐ Cluster 作用1 │
│ ↓ │
│ 【路由过滤】filterConnsByDegradedAndIdc() ⭐ Cluster 作用2 │
│ ↓ │
│ 【负载均衡】loadBalancer.select(connList) │
│ ↓ │
│ 【获取连接】getConnection(serverConn) ⭐ Cluster 作用3 │
│ ↓ │
│ 【发送请求】getRpcResult(serverConn, socket) │
│ ↓ │
│ 【返回结果】 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 【后台定时任务】 │
│ │
│ KrsDirectlyCluster (每 240 秒): │
│ - 从配置中心获取最新 IP 列表 │
│ - 检测节点上下线 │
│ - 更新 serverConns 列表 ⭐ Cluster 作用4 │
│ - 销毁下线节点的连接池 │
│ │
│ KrsOCTOAgentCluster (每 3 秒): │
│ - 从 MNS 获取最新服务列表 │
│ - 检测节点上下线 │
│ - 更新 serverConns 列表 ⭐ Cluster 作用4 │
│ - 销毁下线节点的连接池 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 【销毁阶段】 │
│ │
│ Spring 容器关闭 → cluster.destroy() │
│ - 取消定时任务 │
│ - 销毁所有连接池 ⭐ Cluster 作用5 │
│ - 清空列表 │
└─────────────────────────────────────────────────────────────┘
10.7 Cluster 的核心作用总结
| 阶段 | 环节 | Cluster 的作用 | 重要性 |
|---|---|---|---|
| 初始化 | 服务发现 | 获取节点列表 (配置中心/MNS) | ⭐⭐⭐⭐⭐ |
| 初始化 | 建立连接 | 为每个节点创建连接池 | ⭐⭐⭐⭐⭐ |
| 运行时 | 获取节点 | 返回可用节点列表 | ⭐⭐⭐⭐⭐ |
| 运行时 | 路由过滤 | 按 IDC/Region 过滤节点 | ⭐⭐⭐⭐⭐ |
| 运行时 | 提供连接 | 提供连接池引用 | ⭐⭐⭐⭐⭐ |
| 后台 | 动态更新 | 定时更新节点列表 | ⭐⭐⭐⭐ |
| 后台 | 节点管理 | 处理节点上下线 | ⭐⭐⭐⭐ |
| 销毁 | 资源释放 | 销毁连接池,释放资源 | ⭐⭐⭐ |
结论: Cluster 不是"边缘环节",而是整个 Thrift RPC 框架的核心组件,贯穿了应用的整个生命周期! 🎯
10.8 实战案例: 直连模式配置
配置示例
# Lion 配置中心
# 配置 key: com.sankuai.krs.search.cluster.mt
# 配置 value: 10.1.1.1,10.1.1.2,10.1.1.3
# 应用配置
krs.search.direct.mode=true
krs.search.remote.port=9888
使用场景
- 测试环境: 直连特定测试节点
- 灰度验证: 直连新版本节点进行验证
- 故障排查: 直连特定节点进行问题定位
- 性能测试: 直连高性能节点进行压测
📝 总结
🔑 关键概念速查表
| 概念 | 定义 | 核心作用 |
|---|---|---|
| Thrift 路由 | 框架层自动选择目标机房和节点的机制 | 实现多机房流量分配 |
| CustomThriftProxy | 自定义 Thrift 代理类 | 注入路由服务和集群管理器 |
| KrsRouteService | 路由决策服务 | 从配置中心读取路由规则,按比例分流 |
| KrsOCTOAgentCluster | 集群管理器 (MNS 模式) | 从 MNS 获取节点,按 IDC 过滤 |
| KrsDirectlyCluster | 集群管理器 (直连模式) | 从配置中心获取 IP,直连特定节点 |
| MTThriftMethodInterceptor | CGLIB 拦截器 | 拦截 RPC 调用,触发路由决策 |
| ServerConn | 服务连接对象 | 封装节点信息和连接池 |
| RouteTable | 路由表 | 存储 IDC 权重配置,按比例随机 |
核心要点
-
Thrift 路由的本质:
- 业务代码拿到的永远是同一个客户端
- 路由决策在 Thrift 框架内部完成
- 通过 CGLIB 动态代理拦截方法调用
-
三层架构:
- 第1层: CGLIB 动态代理 (拦截方法调用)
- 第2层: 集群管理器 (路由决策 + 节点过滤)
- 第3层: 负载均衡器 (选择具体节点)
-
CustomThriftProxy 的设计:
- 继承
ThriftClientProxy,复用父类功能 - 通过反射注入自定义组件 (
KrsRouteService,KrsOCTOAgentCluster) - 父类的
loadServiceProxy()自动使用自定义组件
- 继承
-
关键优势:
- ✅ 业务代码无感知
- ✅ 高性能 (比 HTTP 快 2-3 倍)
- ✅ 支持灰度发布
- ✅ 支持动态切换
- ✅ 配置热更新
学习路径
1. 理解 HTTP 路由 (应用层)
↓
2. 对比 Thrift 路由 (框架层)
↓
3. 学习 CGLIB 动态代理原理
↓
4. 分析 CustomThriftProxy 设计
↓
5. 掌握初始化流程
↓
6. 实战案例演练