多集群路由架构详解
📚 目录结构
src/main/java/com/sankuai/meituan/
├── wmarch/es/
│ ├── KrsSearchManager.java ⭐ 核心!多集群管理器
│ ├── NameService.java 配置 Key 生成器
│ ├── RouteUtil.java 路由工具类
│ └── client/
│ ├── EsClient.java 单集群客户端
│ └── domain/
│ └── RouteTable.java ⭐ 核心!路由表(按比例分流)
└── krs/
├── service/
│ └── KrsRouteService.java Thrift 路由服务
├── cluster/
│ └── CustomThriftProxy.java Thrift 代理
└── util/
├── KrsClusterUtil.java 集群信息工具
├── KrsConfigUtil.java 配置中心工具
└── KrsIdcUtils.java IDC 信息工具
🌐 整体架构图
┌─────────────────────────────────────────────────────────────────┐
│ 业务应用层 │
│ (调用 esClient.getClient()) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ KrsSearchManager │
│ (多集群路由管理器) │
│ │
│ clients = { │
│ "appkey.mt" → EsClient1 (mt机房) │
│ "appkey.zf" → EsClient2 (zf机房) │
│ "appkey.yp" → EsClient3 (yp机房) │
│ } │
└─────────────────────────────────────────────────────────────────┘
↓
【路由决策层】
↓
┌─────────────────────┼─────────────────────┐
│ │ │
策略1:新版路由表 策略2:配置中心 策略3:降级兜底
(按比例分流) (动态切换) (Hash选择)
↓ ↓ ↓
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ RouteTable │ │ selectedKey │ │ IP Hash │
│ {"mt":50, │ │ (配置中心监听) │ │ (本地IP取模) │
│ "zf":50} │ │ │ │ │
│ 随机[0-100) │ │ 动态切换集群 │ │ 兜底策略 │└──────────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ └─────────────────────┼─────────────────────┘ ↓ 选择具体的 EsClient ↓┌─────────────────────────────────────────────────────────────────┐│ EsClient (具体集群) ││ serverIps: "10.1.1.1,10.1.1.2,10.1.1.3" ││ port: 8412 │└─────────────────────────────────────────────────────────────────┘ ↓ RestHighLevelClient ↓ ES 集群节点
🚀 完整流程串讲
第一步:应用启动,初始化多集群管理器
文件: KrsSearchManager.java
// 第 52-64 行
@PostConstruct
public void initClients() throws Exception {
// 1. 初始化降级配置
degradeConfigHolder = new DegradeConfigHolder(appkey);
// 2. 判断协议类型
if ("thrift".equals(protocol)) {
initThriftEnv(); // Thrift 协议
return;
}
// 3. HTTP 协议,初始化环境
initEnvRelated(); // ⭐ 核心方法
// 4. 监听配置变化,动态添加新集群
KrsConfigUtil.addListener(NameService.getMultiKey(appkey), ...);
}
说明:
- 这是 Spring 的
@PostConstruct注解,应用启动时自动执行 - 支持两种协议:HTTP 和 Thrift
- 我们重点看 HTTP 协议的
initEnvRelated()方法
第二步:初始化环境,创建多个 EsClient
文件: KrsSearchManager.java
// 第 113-197 行
private void initEnvRelated() throws Exception {
try {
// ========== 1. 获取本地 IDC 信息 ==========
if (this.idc == null) {
this.idc = KrsIdcUtils.getLocalIdc();
}
LOGGER.info("{} CURRENT IDC:{}", appkey, idc);
// 输出: CURRENT IDC: Idc(region:beijing, idc:MT, center:BJ1)
// ========== 2. 获取所有集群的 IP 信息 ==========
clusterIps = KrsClusterUtil.getKrsClusterInfos(appkey, true);
// clusterIps = {
// Idc(region:beijing, idc:MT) → ["10.1.1.1", "10.1.1.2"],
// Idc(region:beijing, idc:ZF) → ["10.2.1.1", "10.2.1.2"],
// Idc(region:shanghai, idc:SH) → ["10.3.1.1", "10.3.1.2"]
// }
if (MapUtils.isEmpty(clusterIps)) {
throw new IllegalStateException(appkey + " krs cluster info is empty");
}
// ========== 3. 过滤出同地域的 IDC ==========
// 获取可用的 region 列表
List<String> candidateRegions = KrsRouteService.getCandidateRegions(
idc.getRegion(), // "beijing"
isSupportAcrossRegionCall // false
);
// candidateRegions = ["beijing"]
// 获取同地域的 idc 列表
List<Idc> currentRegionIdcs = KrsRouteService.getSameRegionIdcs(
new ArrayList<>(clusterIps.keySet()),
candidateRegions
);
// currentRegionIdcs = [Idc(MT), Idc(ZF)] // 只保留北京的
if (currentRegionIdcs.isEmpty()) {
throw new IllegalStateException(appkey + " does not have same region idc");
}
// ========== 4. ⭐ 为每个 IDC 创建独立的 EsClient ==========
createClients(currentRegionIdcs, clusterIps);
// 创建完成后:
// clients = {
// "appkey.mt" → EsClient(["10.1.1.1", "10.1.1.2"], 8412),
// "appkey.zf" → EsClient(["10.2.1.1", "10.2.1.2"], 8412)
// }
try {
// ========== 5. 初始化路由配置(策略2) ==========
String listenKey = NameService.getReadKey(this.appkey, this.idc);
// listenKey = "appkey.read.mt"
String key = KrsConfigUtil.getValue(listenKey);
// key = "appkey.mt" (从配置中心读取)
if (key == null) {
// ⭐ 降级策略:使用 IP Hash 选择
String ip = ProcessInfoUtil.getLocalIpV4();
this.idc = currentRegionIdcs.get(
Math.abs(ip.hashCode()) % currentRegionIdcs.size()
);
listenKey = NameService.getReadKey(this.appkey, this.idc);
key = KrsConfigUtil.getValue(listenKey);
LOGGER.info("CANNOT FIND LISTEN KEY,FINAL FALLBACK IDC:{}", idc);
}
if (!clients.containsKey(key)) {
throw new IllegalStateException(appkey + " DOSE NOT HAVE " + key + " IDC");
}
// ⭐ 监听配置变化,动态切换集群
LOGGER.info("LISTEN :{}", listenKey);
KrsConfigUtil.addListener(listenKey, new IConfigChangeListener() {
@Override
public void changed(String key, String oldValue, String newValue) {
if (clients.containsKey(newValue)) {
LOGGER.info("SWITCH CURRENT CLUSTER FROM {} TO {},CURRENT IDC:{}",
oldValue, newValue, idc);
KrsSearchManager.this.setSelectedKey(newValue);
}
}
});
this.setSelectedKey(key);
// ========== 6. 初始化新版路由表(策略1) ==========
String routeInfoKey = NameService.getRouteKey(this.appkey, this.idc.getRegion());
// routeInfoKey = "appkey.route.beijing"
if (RouteUtil.newRouteEnable(this.appkey)) {
String routeInfoValue = KrsConfigUtil.getValue(routeInfoKey);
// routeInfoValue = "{"mt":50,"zf":50}"
RouteTable newRouteTable = RouteUtil.parseRouteInfo(
routeInfoValue, this.appkey, this.idc
);
if (newRouteTable == null) {
throw new RuntimeException("NEW ROUTE INFO INVALID");
}
LOGGER.info("HTTP NEW ROUTE TABLE INIT, appKey: {}, routeTable {}",
this.appkey, newRouteTable);
routeTable = newRouteTable;
}
// ⭐ 监听路由表变化
LOGGER.info("HTTP NEW ROUTE LISTEN :{}", routeInfoKey);
KrsConfigUtil.addListener(routeInfoKey, (key1, oldValue, newValue) -> {
try {
RouteTable tmp = RouteTable.parseRouteInfo(newValue, this.appkey, this.idc);
if (tmp != null) {
LOGGER.info("HTTP NEW ROUTE TABLE CHANGED, appKey: {}, routeTable {}",
this.appkey, tmp);
routeTable = tmp;
}
} catch (Exception e) {
LOGGER.error("HTTP NEW ROUTE TABLE CHANGED ERROR", e);
}
});
} catch (Exception e) {
LOGGER.error("{} ERROR INIT ENV", appkey, e);
this.selectedKey = "default";
}
} finally {
LOGGER.info("{} FINAL IDC:{},SELECTED KEY:{}", this.appkey, this.idc, this.selectedKey);
}
}
第三步:创建多个 EsClient
文件: KrsSearchManager.java
// 第 204-213 行
private void createClients(Collection<Idc> clusters, Map<Idc, List<String>> ips) throws Exception {
for (Idc idc : clusters) {
// 1. 为每个 IDC 创建独立的 EsClient
EsClient esClient = new EsClient(
ips.get(idc), // ["10.1.1.1", "10.1.1.2"]
defaultPort // 8412
);
esClient.setAppkey(getAppkey());
esClient.setDegradeConfigHolder(degradeConfigHolder);
// 2. 存入 clients Map
String clusterKey = NameService.getClusterKey(this.getAppkey(), idc);
// clusterKey = "appkey.mt"
clients.put(clusterKey, esClient);
LOGGER.info("{} CREATE CLIENT FOR KEY:{}", appkey, idc);
}
}
说明:
- 每个 IDC 有独立的
EsClient EsClient内部管理该 IDC 的所有节点- 使用
appkey.idc作为 key 存储
第四步:业务调用,选择合适的 EsClient
文件: KrsSearchManager.java
// 第 237-256 行
public EsClient getClient() {
// ========== 策略0:Thrift 协议 ==========
if (null != thriftWrapperClient) {
return thriftWrapperClient;
}
// ========== 策略1:新版路由表(按比例分流) ⭐ 优先级最高 ==========
if (RouteUtil.newRouteEnable(appkey) && null != routeTable) {
// 调用 RouteTable.getRouteIdc() 按比例随机选择 IDC
String clusterKey = NameService.getClusterKey(
this.appkey,
routeTable.getRouteIdc() // ⭐ 核心!随机选择
);
return clients.get(clusterKey);
}
// ========== 策略2:使用 selectedKey(配置中心) ==========
if (this.selectedKey == null ||
this.selectedKey.isEmpty() ||
"default".equals(this.selectedKey)) {
// ========== 策略3:降级兜底 ==========
if (ProcessInfoUtil.getHostEnv() == HostEnv.PROD ||
ProcessInfoUtil.getHostEnv() == HostEnv.STAGING) {
LOGGER.debug("CANNOT CHOOSE PROPER SEARCH CLIENT");
}
// 返回任意一个可用的
for (EsClient client : clients.values()) {
return client;
}
}
// 使用 selectedKey
return clients.get(this.selectedKey);
}
第五步:RouteTable 按比例分流(核心!)
文件: RouteTable.java
// 第 11-76 行
package com.sankuai.meituan.wmarch.es.client.domain;
public class RouteTable {
private final int length;
// 区间端点: [50, 100]
private final int[] weights;
// IDC列表: ["mt", "zf"]
private final String[] idcList;
// ========== 构造函数:解析配置 ==========
public RouteTable(Map<String, Integer> routeInfo) throws Exception {
// routeInfo = {"mt": 50, "zf": 50}
if (MapUtils.isEmpty(routeInfo)) {
throw new Exception("Empty route info.");
}
this.length = routeInfo.size(); // 2
this.weights = new int[this.length];
this.idcList = new String[this.length];
int cumulativeWeight = 0;
int index = 0;
for (Map.Entry<String, Integer> entry : routeInfo.entrySet()) {
// 第1次循环: entry = ("mt", 50)
// cumulativeWeight = 0 + 50 = 50
// weights[0] = 50
// idcList[0] = "mt"
// 第2次循环: entry = ("zf", 50)
// cumulativeWeight = 50 + 50 = 100
// weights[1] = 100
// idcList[1] = "zf"
cumulativeWeight += entry.getValue();
this.weights[index] = cumulativeWeight;
if (StringUtils.isBlank(entry.getKey())) {
throw new Exception("Invalid route info, idc name is blank.");
}
this.idcList[index] = entry.getKey().toLowerCase();
index++;
}
// 校验:权重总和必须是 100
if (this.weights[index - 1] != 100) {
throw new Exception("Invalid route info.");
}
// 最终结果:
// weights = [50, 100]
// idcList = ["mt", "zf"]
}
// ========== ⭐ 核心方法:按比例随机分流 ==========
public String getRouteIdc() {
// 生成 [0, 100) 的随机数
int randomValue = ThreadLocalRandom.current().nextInt(100);
// 例如:
// randomValue = 30 → 30 < 50 → 返回 idcList[0] = "mt"
// randomValue = 70 → 70 >= 50, 70 < 100 → 返回 idcList[1] = "zf"
for (int i = 0; i < this.length; i++) {
if (randomValue < weights[i]) {
return idcList[i];
}
}
// 理论上不会走到这里
return idcList[0];
}
@Override
public String toString() {
return "RouteTable{" +
"weights=" + Arrays.toString(weights) +
", idcList=" + Arrays.toString(idcList) +
'}';
}
}
图解:
配置: {"mt": 50, "zf": 50}
转换为:
weights = [50, 100]
idcList = ["mt", "zf"]
随机数分布:
[0 ─────────── 50 ─────────── 100)
└─── mt ───┘ └─── zf ───┘
50% 50%
示例:
randomValue = 0 → 0 < 50 → "mt"
randomValue = 30 → 30 < 50 → "mt"
randomValue = 49 → 49 < 50 → "mt"
randomValue = 50 → 50 < 100 → "zf"
randomValue = 70 → 70 < 100 → "zf"
randomValue = 99 → 99 < 100 → "zf"
第六步:解析路由配置
文件: RouteUtil.java
// 第 52-83 行
package com.sankuai.meituan.wmarch.es;
public class RouteUtil {
public static RouteTable parseRouteInfo(String value, String appKey, Idc localIdc) {
if (StringUtils.isBlank(value)) {
LOGGER.error("parseRouteInfo, value is empty! appKey: {}, localIdc: {},",
appKey, localIdc);
return null;
}
try {
// 1. 解析 JSON 配置
Map<String, Integer> routeInfo = JSON.parseObject(
value,
new TypeReference<Map<String, Integer>>() {}
);
// routeInfo = {"mt": 50, "zf": 50}
// 2. 获取当前 appkey 下的所有机房
Map<String, Idc> multiIdcMap = new HashMap<>();
for (Idc idc : KrsClusterUtil.getMultiConfigIdc(appKey)) {
multiIdcMap.put(idc.getIdc().toLowerCase(), idc);
}
// 3. 合法性校验
for (String idc : routeInfo.keySet()) {
// 校验1:路由信息内的机房必须在 multi 里
if (!multiIdcMap.containsKey(idc.toLowerCase())) {
LOGGER.error("parseRouteInfo error, {} not in multi", idc);
throw new Exception("invalid routeInfo config, not in multi");
}
// 校验2:路由信息内的机房必须属于同一个 region
if (!StringUtils.equalsIgnoreCase(
multiIdcMap.get(idc).getRegion(),
localIdc.getRegion())) {
LOGGER.error("parseRouteInfo error, {} not in this region", idc);
throw new Exception("invalid routeInfo config, not int this region");
}
}
// 4. 创建 RouteTable
return new RouteTable(routeInfo);
} catch (Exception e) {
LOGGER.error("parseRouteInfo error, value: {}. ", value, e);
}
return null;
}
}
第七步:配置 Key 生成
文件: NameService.java
// 第 10-65 行
package com.sankuai.meituan.wmarch.es;
public class NameService {
// 模板
private static final String KEY_TEMPLATE = "%s.read.%s";
private static final String CLUSTER_TEMPLATE = "%s.%s";
private static final String ROUTE_KEY_TEMPLATE = "%s.route.%s";
// ========== 生成集群 Key ==========
public static String getClusterKey(String appkey, Idc idc) {
return String.format(CLUSTER_TEMPLATE, appkey, idc.getIdc().toLowerCase());
// 例如: "appkey.mt"
}
public static String getClusterKey(String appkey, String idcName) {
return String.format(CLUSTER_TEMPLATE, appkey, idcName.toLowerCase());
// 例如: "appkey.zf"
}
// ========== 生成读配置 Key ==========
public static String getReadKey(String appkey, Idc idc) {
return String.format(KEY_TEMPLATE, appkey, idc.getIdc().toLowerCase());
// 例如: "appkey.read.mt"
}
public static String getReadKey(String appkey, String idcName) {
return String.format(KEY_TEMPLATE, appkey, idcName.toLowerCase());
// 例如: "appkey.read.zf"
}
// ========== 生成 Multi Key ==========
public static String getMultiKey(String appkey) {
return appkey + ".multi";
// 例如: "appkey.multi"
}
// ========== 生成路由表 Key ==========
public static String getRouteKey(String appKey, String region) {
return String.format(ROUTE_KEY_TEMPLATE, appKey, region.toLowerCase());
// 例如: "appkey.route.beijing"
}
}
🎬 完整调用链路图
┌─────────────────────────────────────────────────────────────────┐
│ 1. 应用启动 │
│ KrsSearchManager.initClients() │
│ 位置: KrsSearchManager.java:52 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 初始化环境 │
│ KrsSearchManager.initEnvRelated() │
│ 位置: KrsSearchManager.java:113 │
│ │
│ 2.1 获取本地 IDC │
│ KrsIdcUtils.getLocalIdc() │
│ → Idc(region:beijing, idc:MT) │
│ │
│ 2.2 获取所有集群 IP │
│ KrsClusterUtil.getKrsClusterInfos(appkey, true) │
│ → {Idc(MT) → ["10.1.1.1"], Idc(ZF) → ["10.2.1.1"]} │
│ │
│ 2.3 过滤同地域 IDC │
│ KrsRouteService.getCandidateRegions(...) │
│ KrsRouteService.getSameRegionIdcs(...) │
│ → [Idc(MT), Idc(ZF)] │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 创建多个 EsClient │
│ KrsSearchManager.createClients(...) │
│ 位置: KrsSearchManager.java:204 │
│ │
│ for (Idc idc : [MT, ZF]) { │
│ EsClient esClient = new EsClient(ips, port); │
│ clients.put("appkey.mt", esClient); │
│ } │
│ │
│ 结果: │
│ clients = { │
│ "appkey.mt" → EsClient(["10.1.1.1"], 8412), │
│ "appkey.zf" → EsClient(["10.2.1.1"], 8412) │
│ } │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 初始化路由配置 │
│ 位置: KrsSearchManager.java:155-195 │
│ │
│ 4.1 策略2:配置中心 │
│ listenKey = NameService.getReadKey(appkey, idc) │
│ → "appkey.read.mt" │
│ │
│ selectedKey = KrsConfigUtil.getValue(listenKey) │
│ → "appkey.mt" │
│ │
│ 监听配置变化: │
│ KrsConfigUtil.addListener(listenKey, ...) │
│ │
│ 4.2 策略1:路由表 │
│ routeInfoKey = NameService.getRouteKey(appkey, region) │
│ → "appkey.route.beijing" │
│ │
│ routeInfoValue = KrsConfigUtil.getValue(routeInfoKey) │
│ → "{"mt":50,"zf":50}" │
│ │
│ routeTable = RouteUtil.parseRouteInfo(...) │
│ → RouteTable(weights=[50,100], idcList=["mt","zf"]) │
│ │
│ 监听路由表变化: │
│ KrsConfigUtil.addListener(routeInfoKey, ...) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. 业务调用 │
│ EsClient client = krsSearchManager.getClient(); │
│ 位置: KrsSearchManager.java:237 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. 路由决策 │
│ 位置: KrsSearchManager.java:237-256 │
│ │
│ if (RouteUtil.newRouteEnable(appkey) && routeTable != null) {│
│ // ⭐ 策略1:按比例分流 │
│ String idc = routeTable.getRouteIdc(); │
│ 位置: RouteTable.java:57 │
│ │
│ int random = ThreadLocalRandom.nextInt(100); │
│ // random = 30 → "mt" │
│ // random = 70 → "zf" │
│ │
│ String key = NameService.getClusterKey(appkey, idc); │
│ // key = "appkey.mt" 或 "appkey.zf" │
│ │
│ return clients.get(key); │
│ } │
│ │
│ // ⭐ 策略2:使用 selectedKey │
│ return clients.get(selectedKey); │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 7. 获得具体的 EsClient │
│ EsClient esClient = clients.get("appkey.mt"); │
│ │
│ esClient 内部: │
│ - RestHighLevelClient │
│ - RestClient │
│ - 连接池: ["10.1.1.1:8412", "10.1.1.2:8412"] │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 8. 执行搜索 │
│ SearchResponse response = esClient.search(...); │
│ │
│ 内部流程: │
│ → RestHighLevelClient.search() │
│ → RestClient.performRequest() │
│ → 轮询选择节点: 10.1.1.1 │
│ → 发送 HTTP 请求: POST http://10.1.1.1:8412/_search │
│ → 返回结果 │
└─────────────────────────────────────────────────────────────────┘
📋 配置中心配置示例
# ========== Multi 配置(所有集群信息) ==========
appkey.multi = {
"beijing": ["MT", "ZF"],
"shanghai": ["SH"]
}
# ========== 集群 IP 配置 ==========
appkey.mt = 10.1.1.1,10.1.1.2
appkey.zf = 10.2.1.1,10.2.1.2
appkey.sh = 10.3.1.1,10.3.1.2
# ========== 读配置(策略2:配置中心动态切换) ==========
appkey.read.mt = appkey.mt # MT 机房读 MT 集群
appkey.read.zf = appkey.zf # ZF 机房读 ZF 集群
# ========== 路由表配置(策略1:按比例分流) ==========
appkey.route.beijing = {"mt":50,"zf":50} # 北京地域 50-50 分流
appkey.route.shanghai = {"sh":100} # 上海地域 100% 流量
# ========== 新版路由开关 ==========
appkey.newroute.enable = true
🎯 关键类和方法总结
| 类名 | 文件位置 | 核心方法 | 作用 |
|---|---|---|---|
| KrsSearchManager | wmarch/es/KrsSearchManager.java | initClients()initEnvRelated()createClients()getClient() | ⭐ 多集群管理器 创建和管理多个 EsClient |
| RouteTable | wmarch/es/client/domain/RouteTable.java | getRouteIdc() | ⭐ 按比例随机分流 |
| RouteUtil | wmarch/es/RouteUtil.java | parseRouteInfo() | 解析路由配置 |
| NameService | wmarch/es/NameService.java | getClusterKey()getReadKey()getRouteKey() | 生成配置 Key |
| EsClient | wmarch/es/client/EsClient.java | initClient()search() | 单集群客户端 |
| KrsRouteService | krs/service/KrsRouteService.java | getCandidateRegions()getSameRegionIdcs() | Region 路由服务 |
🎯 核心路由策略详解
🎯 实战案例:ZF 机房应用的跨机房路由
让我们通过一个完整的实例来理解跨机房路由是如何工作的:
┌─────────────────────────────────────────────────────────────────┐
│ ZF 机房的应用启动 │
│ 本地 IDC: Idc(region:beijing, idc:ZF) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: 获取所有集群信息 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ clusterIps = KrsClusterUtil.getKrsClusterInfos(appkey, true); │
│ │
│ 返回: │
│ { │
│ Idc(region:beijing, idc:MT) → ["10.1.1.1", "10.1.1.2"], │
│ Idc(region:beijing, idc:ZF) → ["10.2.1.1", "10.2.1.2"], │
│ Idc(region:shanghai, idc:SH) → ["10.3.1.1", "10.3.1.2"] │
│ } │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: 过滤同 Region 的集群 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ candidateRegions = ["beijing"] // ZF 在北京 │
│ │
│ currentRegionIdcs = [ │
│ Idc(region:beijing, idc:MT), // ⭐ 保留 MT │
│ Idc(region:beijing, idc:ZF) // ⭐ 保留 ZF │
│ ] │
│ │
│ // ⭐ 关键:虽然应用在 ZF 机房,但 MT 和 ZF 都在北京 Region, │
│ // 所以都会创建 Client! │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: ⭐ 为所有同 Region 的集群创建 Client │
├─────────────────────────────────────────────────────────────────┤
│ │
│ createClients([Idc(MT), Idc(ZF)], clusterIps) │
│ │
│ for (Idc idc : [MT, ZF]) { │
│ EsClient esClient = new EsClient(ips.get(idc), port); │
│ clients.put(NameService.getClusterKey(appkey, idc), esClient);│
│ } │
│ │
│ 结果: │
│ clients = { │
│ "appkey.mt" → EsClient(["10.1.1.1", "10.1.1.2"]), // MT集群 │
│ "appkey.zf" → EsClient(["10.2.1.1", "10.2.1.2"]) // ZF集群 │
│ } │
│ │
│ // ⭐ 关键:ZF 机房的应用同时创建了 MT 和 ZF 两个集群的 Client! │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: 初始化路由表 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ routeInfoKey = "appkey.route.beijing" // 按 Region 配置 │
│ routeInfoValue = "{"mt":50,"zf":50}" │
│ │
│ routeTable = RouteTable { │
│ weights = [50, 100] │
│ idcList = ["mt", "zf"] │
│ } │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Step 5: 业务调用时,RouteTable 决定连哪个集群 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ // 请求1 │
│ routeTable.getRouteIdc() │
│ → random = 30 │
│ → 返回 "mt" // ⭐ ZF 机房的应用路由到 MT 集群! │
│ → clients.get("appkey.mt") │
│ → 返回 EsClient(["10.1.1.1", "10.1.1.2"]) │
│ → 发送请求到 MT 机房的 ES 集群 │
│ │
│ // 请求2 │
│ routeTable.getRouteIdc() │
│ → random = 70 │
│ → 返回 "zf" // ⭐ ZF 机房的应用路由到 ZF 集群(本机房) │
│ → clients.get("appkey.zf") │
│ → 返回 EsClient(["10.2.1.1", "10.2.1.2"]) │
│ → 发送请求到 ZF 机房的 ES 集群 │
└─────────────────────────────────────────────────────────────────┘
核心要点:
- ✅ 预先建立所有连接: ZF 机房的应用启动时,为所有同 Region 的集群(MT、ZF)都创建了 Client
- ✅ 运行时动态选择: 通过 RouteTable 按比例随机选择,ZF 机房的应用可以路由到 MT 集群
- ✅ 配置按 Region 维度: 路由配置是
appkey.route.beijing,所有北京 Region 的机房共享 - ✅ 支持跨机房灰度: 可以实现 ZF 机房的应用 50% 流量到 MT,50% 流量到 ZF
📍 第一层:Region(地域)路由
// 1. 获取本地 IDC 信息
Idc localIdc = KrsIdcUtils.getLocalIdc();
// localIdc = Idc(region:beijing, idc:MT, center:BJ1)
// 2. 获取所有集群的 IDC 信息
Map<Idc, List<String>> clusterIps = KrsClusterUtil.getKrsClusterInfos(appkey, true);
// clusterIps = {
// Idc(region:beijing, idc:MT) → ["10.1.1.1", "10.1.1.2"],
// Idc(region:beijing, idc:ZF) → ["10.2.1.1", "10.2.1.2"],
// Idc(region:shanghai, idc:SH) → ["10.3.1.1", "10.3.1.2"]
// }
// 3. 获取可调用的 region 列表
List<String> candidateRegions = KrsRouteService.getCandidateRegions(
localIdc.getRegion(), // "beijing"
isSupportAcrossRegionCall // 是否支持跨地域调用
);
// 如果不支持跨地域:
// candidateRegions = ["beijing"]
// 如果支持跨地域:
// candidateRegions = ["beijing", "shanghai", "guangzhou"] // 亲和地域列表
// 4. 过滤出同 region 的 IDC
List<Idc> currentRegionIdcs = KrsRouteService.getSameRegionIdcs(
new ArrayList<>(clusterIps.keySet()),
candidateRegions
);
// currentRegionIdcs = [Idc(MT), Idc(ZF)] // 只保留北京的机房
关键点:
- ✅ 优先调用同地域的集群(降低延迟)
- ✅ 支持跨地域调用(容灾)
- ✅ 使用亲和地域列表(就近原则)
📍 第二层:IDC 路由决策
这是最核心的部分!有三种路由策略:
策略1:新版路由表(按比例分流) 🔥
// KrsSearchManager.java 第 237-250 行
public EsClient getClient() {
// ⭐ 策略1:新版路由表(优先级最高)
if (RouteUtil.newRouteEnable(appkey) && null != routeTable) {
String clusterKey = NameService.getClusterKey(
this.appkey,
routeTable.getRouteIdc() // ⭐ 按比例随机选择 IDC
);
return clients.get(clusterKey);
}
// ... 其他策略
}
配置示例:
// 配置中心: appkey.route.beijing
{
"mt": 50, // mt机房 50% 流量
"zf": 50 // zf机房 50% 流量
}
// 或者灰度发布:
{
"mt": 90, // 老集群 90% 流量
"zf": 10 // 新集群 10% 流量(灰度)
}
流量分布:
100 个请求:
├─ 50 个 → MT 机房 (10.1.1.1, 10.1.1.2)
└─ 50 个 → ZF 机房 (10.2.1.1, 10.2.1.2)
策略2:配置中心动态切换 🔄
// KrsSearchManager.java 第 140-165 行
// 1. 从配置中心读取路由配置
String listenKey = NameService.getReadKey(this.appkey, this.idc);
// listenKey = "appkey.read.mt"
String key = KrsConfigUtil.getValue(listenKey);
// key = "appkey.mt" (指向哪个集群)
// 2. 设置当前选中的集群
this.setSelectedKey(key);
// 3. 监听配置变化,动态切换集群
KrsConfigUtil.addListener(listenKey, new IConfigChangeListener() {
@Override
public void changed(String key, String oldValue, String newValue) {
if (clients.containsKey(newValue)) {
LOGGER.info("SWITCH CURRENT CLUSTER FROM {} TO {}", oldValue, newValue);
KrsSearchManager.this.setSelectedKey(newValue);
// ⭐ 动态切换!不需要重启应用
}
}
});
配置示例:
# 配置中心
appkey.read.mt = appkey.mt # MT 机房的应用读 MT 集群
appkey.read.zf = appkey.zf # ZF 机房的应用读 ZF 集群
# 动态切换(不需要重启):
appkey.read.mt = appkey.zf # MT 机房的应用切换到 ZF 集群
使用场景:
- ✅ 集群故障切换
- ✅ 集群维护
- ✅ 流量迁移
策略3:降级兜底(IP Hash) 🛡️
// KrsSearchManager.java 第 142-149 行
String key = KrsConfigUtil.getValue(listenKey);
if (key == null) {
// ⭐ 配置中心读取失败,使用降级策略
String ip = ProcessInfoUtil.getLocalIpV4();
// ip = "10.100.50.123"
// ⭐ 使用本地 IP 的 Hash 值选择一个 IDC
this.idc = currentRegionIdcs.get(
Math.abs(ip.hashCode()) % currentRegionIdcs.size()
);
// 例如: ip.hashCode() = 123456789
// 123456789 % 2 = 1
// 选择 currentRegionIdcs[1] = Idc(ZF)
listenKey = NameService.getReadKey(this.appkey, this.idc);
key = KrsConfigUtil.getValue(listenKey);
LOGGER.info("CANNOT FIND LISTEN KEY,FINAL FALLBACK IDC:{}", idc);
}
关键点:
- ✅ 配置中心不可用时的兜底策略
- ✅ 使用本地 IP Hash 保证同一台机器总是路由到同一个集群
- ✅ 避免单点故障
🔄 完整的路由决策流程
// KrsSearchManager.java 第 237-256 行
public EsClient getClient() {
// 1. 如果是 Thrift 协议,直接返回
if (null != thriftWrapperClient) {
return thriftWrapperClient;
}
// 2. ⭐ 策略1:新版路由表(按比例分流)
if (RouteUtil.newRouteEnable(appkey) && null != routeTable) {
String clusterKey = NameService.getClusterKey(
this.appkey,
routeTable.getRouteIdc() // 随机选择 IDC
);
return clients.get(clusterKey);
}
// 3. ⭐ 策略2:使用 selectedKey
if (this.selectedKey != null &&
!this.selectedKey.isEmpty() &&
!"default".equals(this.selectedKey)) {
return clients.get(this.selectedKey);
}
// 4. ⭐ 策略3:降级兜底(返回任意一个)
if (ProcessInfoUtil.getHostEnv() == HostEnv.PROD ||
ProcessInfoUtil.getHostEnv() == HostEnv.STAGING) {
LOGGER.debug("CANNOT CHOOSE PROPER SEARCH CLIENT");
}
for (EsClient client : clients.values()) {
return client; // 返回第一个可用的
}
return null;
}
📊 路由策略优先级
优先级从高到低:
1. Thrift 协议 (特殊场景)
↓
2. 新版路由表 (按比例分流)
- 支持灰度发布
- 支持流量调度
↓
3. 配置中心 selectedKey (动态切换)
- 支持故障切换
- 支持集群维护
↓
4. 降级兜底 (返回任意可用)
- 配置中心不可用时
- 保证服务可用性
🎯 实际应用场景
场景1:灰度发布新集群
// 初始配置: 100% 流量到老集群
{
"mt_old": 100,
"mt_new": 0
}
// 灰度 10%
{
"mt_old": 90,
"mt_new": 10
}
// 灰度 50%
{
"mt_old": 50,
"mt_new": 50
}
// 全量切换
{
"mt_old": 0,
"mt_new": 100
}
场景2:集群故障切换
# 正常情况
appkey.read.mt = appkey.mt
# MT 集群故障,切换到 ZF 集群
appkey.read.mt = appkey.zf
# 故障恢复,切回 MT 集群
appkey.read.mt = appkey.mt
场景3:跨地域容灾
// 北京机房故障
isSupportAcrossRegionCall = true;
// 自动路由到上海机房
candidateRegions = ["beijing", "shanghai"];
currentRegionIdcs = [Idc(SH1), Idc(SH2)];
💡 核心优势
| 特性 | 说明 | 好处 |
|---|---|---|
| 多级路由 | Region → IDC → Node | 灵活的流量调度 |
| 按比例分流 | RouteTable 随机分配 | 支持灰度发布 |
| 动态切换 | 配置中心监听 | 无需重启应用 |
| 降级兜底 | IP Hash 选择 | 保证高可用 |
| 跨地域容灾 | 亲和地域列表 | 自动故障转移 |
| 独立连接池 | 每个 IDC 独立 EsClient | 隔离故障影响 |
📝 总结
多集群路由策略是一个三层架构:
1. Region 路由层
- 根据地域选择可用的 IDC 列表
- 支持跨地域调用
2. IDC 路由层
- 按比例分流(RouteTable)
- 配置中心动态切换(selectedKey)
- IP Hash 降级兜底
3. Node 路由层
- RestClient 轮询负载均衡
- 节点嗅探自动发现
- 失败重试
这套架构实现了:
- ✅ 灵活的流量调度(灰度发布、A/B测试)
- ✅ 高可用(故障自动切换、跨地域容灾)
- ✅ 动态配置(无需重启应用)
- ✅ 性能优化(就近访问、连接池复用)
非常精妙的设计! 🎯
⚠️ 重要:路由粒度说明
🎯 路由是"请求级"的,不是"应用级"的!
这是一个非常重要的理解点,很多人容易误解:
✅ 正确的使用方式:每次请求都调用 getClient()
@Service
public class SearchService {
@Autowired
private SearchManagerProxy searchManagerProxy;
public SearchResponse search(String keyword) {
// ⭐ 每次搜索都重新获取 client
EsClient client = searchManagerProxy.getClient();
SearchBuilder builder = new SearchBuilder(client)
.addIndices("index_alias")
.setQuery(...)
.setPagination(0, 600);
return builder.action();
}
}
// 100 个请求:
// - 约 50 个 → IDC1 集群
// - 约 50 个 → IDC2 集群
// ⭐ 按比例分流生效!
❌ 错误的使用方式:缓存 client
@Service
public class SearchService {
@Autowired
private SearchManagerProxy searchManagerProxy;
// ❌ 错误:在类初始化时获取一次
private EsClient client;
@PostConstruct
public void init() {
this.client = searchManagerProxy.getClient();
}
public SearchResponse search(String keyword) {
// ❌ 总是用同一个 client
SearchBuilder builder = new SearchBuilder(this.client)
.addIndices("index_alias")
.setQuery(...);
return builder.action();
}
}
// 100 个请求:
// - 100 个 → 固定的某个集群
// ❌ 按比例分流失效!
📊 路由粒度对比
| 方式 | 代码 | 路由时机 | 路由效果 | 是否正确 |
|---|---|---|---|---|
| 请求级路由 | 每次都调用 getClient() | 每次请求 | ✅ 按比例分流 | ✅ 正确 |
| 应用级路由 | 初始化时调用一次 | 应用启动 | ❌ 固定集群 | ❌ 错误 |
💡 为什么性能不是问题?
public EsClient getClient() {
// 1. 生成随机数: < 100 纳秒
String idc = routeTable.getRouteIdc();
// 2. 拼接字符串: < 100 纳秒
String key = NameService.getClusterKey(appkey, idc);
// 3. Map.get(): < 100 纳秒
return clients.get(key);
// 总耗时: < 1 微秒
}
// ES 搜索耗时: 10-100 毫秒
// getClient() 耗时占比: < 0.001%
// ⭐ 性能影响可以忽略不计!
🎯 官方推荐用法
根据官方 Wiki 文档,推荐的使用方式是:
// ✅ 每次都通过 Manager 获取 Client
SearchBuilder builder = new SearchBuilder(
searchManager.getClient() // 每次都调用
)
.addIndices("index_alias")
.setQuery(...)
.action();
关键点:
- ✅ 每次搜索都调用
getClient() - ✅ 每次都会重新路由决策
- ✅ 按比例分流在请求级别生效
- ✅ 灰度发布实时生效,无需重启
🎓 小白总结
一句话概括:
多集群路由就是:先根据地域筛选出可用的集群,然后为每个集群创建独立的
EsClient,最后通过路由表按比例分流或配置中心动态切换来选择具体用哪个集群。
三个核心:
KrsSearchManager: 管理多个 EsClientRouteTable: 按比例随机分流- selectedKey: 配置中心动态切换
三种策略:
- 策略1(优先级最高):
RouteTable按比例分流 → 灰度发布 - 策略2: selectedKey 配置中心 → 故障切换
- 策略3: IP Hash 降级兜底 → 保证可用
最重要的一点:
- ⚠️ 必须每次请求都调用
getClient() - ⚠️ 不要缓存 client,否则路由失效