diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 85f3332..0e69019 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -185,14 +185,6 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) return h.repo.ListForwardPorts(forwardID) } -func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) { - protocol, err := h.repo.GetTunnelOutProtocol(tunnelID) - if err != nil { - return false, err - } - return isTLSTunnelProtocol(protocol), nil -} - func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) { n, err := h.repo.GetNodeRecord(nodeID) if err != nil { @@ -272,10 +264,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method } serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID) - tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID) - if err != nil { - return nil, err - } for _, fp := range ports { if limiterID != nil && speed != nil { @@ -298,7 +286,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method if err != nil { return nil, err } - services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol) + services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID) _, err = h.sendNodeCommand(node.ID, method, services, true, false) if err != nil && allowFallbackAdd && method == "UpdateService" { if isNotFoundError(err) { @@ -313,7 +301,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method } if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) { var warning string - warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol) + warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID) if err == nil && warning != "" { warnings = append(warnings, warning) } @@ -339,7 +327,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method return warnings, nil } -func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) { +func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) { if h == nil || forward == nil || tunnel == nil || node == nil { return "", errors.New("invalid bind fallback context") } @@ -356,7 +344,7 @@ func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunne } time.Sleep(150 * time.Millisecond) - defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol) + defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID) if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil { return "", err } @@ -1561,7 +1549,7 @@ func compactErrorMessage(msg string) string { return strings.Join(strings.Fields(strings.ToLower(msg)), "") } -func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} { +func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} { protocols := []string{"tcp", "udp"} services := make([]map[string]interface{}, 0, 2) targets := splitRemoteTargets(forward.RemoteAddr) @@ -1605,9 +1593,9 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel }, } if protocol == "udp" { - listenerMetadata := map[string]interface{}{"keepAlive": true} - if tunnelTLSProtocol { - listenerMetadata["ttl"] = "10s" + listenerMetadata := map[string]interface{}{ + "keepAlive": true, + "ttl": "30s", } service["listener"].(map[string]interface{})["metadata"] = listenerMetadata } diff --git a/go-backend/internal/http/handler/control_plane_test.go b/go-backend/internal/http/handler/control_plane_test.go index 3d1128b..81d79bc 100644 --- a/go-backend/internal/http/handler/control_plane_test.go +++ b/go-backend/internal/http/handler/control_plane_test.go @@ -378,7 +378,7 @@ func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) { func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) { forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} - services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false) + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil) if len(services) != 2 { t.Fatalf("expected 2 services, got %d", len(services)) } @@ -393,7 +393,7 @@ func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) { func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) { forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"} - services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false) + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil) if len(services) != 2 { t.Fatalf("expected 2 services, got %d", len(services)) } @@ -409,7 +409,7 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing. func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) { forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} - services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false) + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil) if len(services) != 2 { t.Fatalf("expected 2 services, got %d", len(services)) } @@ -464,7 +464,7 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) { t.Run(tt.name, func(t *testing.T) { forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} - services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false) + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil) if len(services) != 2 { t.Fatalf("expected 2 services, got %d", len(services)) } diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index 429a640..5562604 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -144,17 +144,24 @@ type remoteUsageNodeItem struct { func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} { service := map[string]interface{}{ - "name": serviceName, - "addr": addr, + "name": serviceName, + "addr": addr, "handler": map[string]interface{}{ "type": "relay", }, - "listener": map[string]interface{}{ - "type": protocol, - }, + "listener": buildTunnelListenerConfig(protocol), } - if isTLSTunnelProtocol(protocol) { - service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true} + if isTCPTunnelProtocol(protocol) { + service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{ + "nodelay": true, + "mux.keepaliveInterval": "15s", + "mux.keepaliveTimeout": "45s", + } + } + if isKCPTunnelProtocol(protocol) { + service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{ + "connectTimeout": "30s", + } } if role == "middle" { service["handler"].(map[string]interface{})["chain"] = chainName @@ -1095,16 +1102,23 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ connector := map[string]interface{}{ "type": "relay", } - if isTLSTunnelProtocol(targetProtocol) { - connector["metadata"] = map[string]interface{}{"nodelay": true} + if isTCPTunnelProtocol(targetProtocol) { + connector["metadata"] = map[string]interface{}{ + "nodelay": true, + "mux.keepaliveInterval": "15s", + "mux.keepaliveTimeout": "45s", + } + } + if isKCPTunnelProtocol(targetProtocol) { + connector["metadata"] = map[string]interface{}{ + "connectTimeout": "30s", + } } nodeItems = append(nodeItems, map[string]interface{}{ "name": fmt.Sprintf("node_%d", i+1), "addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)), "connector": connector, - "dialer": map[string]interface{}{ - "type": targetProtocol, - }, + "dialer": buildTunnelDialerConfig(targetProtocol), }) } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index a4dba82..42fb10c 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -3452,16 +3452,23 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu connector := map[string]interface{}{ "type": "relay", } - if isTLSTunnelProtocol(protocol) { - connector["metadata"] = map[string]interface{}{"nodelay": true} + connectorMetadata := map[string]interface{}{} + if isTCPTunnelProtocol(protocol) { + connectorMetadata["nodelay"] = true + connectorMetadata["mux.keepaliveInterval"] = "15s" + connectorMetadata["mux.keepaliveTimeout"] = "45s" + } + if isKCPTunnelProtocol(protocol) { + connectorMetadata["connectTimeout"] = "30s" + } + if len(connectorMetadata) > 0 { + connector["metadata"] = connectorMetadata } nodeItems = append(nodeItems, map[string]interface{}{ "name": fmt.Sprintf("node_%d", idx+1), "addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)), "connector": connector, - "dialer": map[string]interface{}{ - "type": protocol, - }, + "dialer": buildTunnelDialerConfig(protocol), }) } @@ -3493,19 +3500,26 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, handlerCfg := map[string]interface{}{ "type": "relay", } - if isTLSTunnelProtocol(protocol) { - handlerCfg["metadata"] = map[string]interface{}{"nodelay": true} + handlerMetadata := map[string]interface{}{} + if isTCPTunnelProtocol(protocol) { + handlerMetadata["nodelay"] = true + handlerMetadata["mux.keepaliveInterval"] = "15s" + handlerMetadata["mux.keepaliveTimeout"] = "45s" + } + if isKCPTunnelProtocol(protocol) { + handlerMetadata["connectTimeout"] = "30s" + } + if len(handlerMetadata) > 0 { + handlerCfg["metadata"] = handlerMetadata } if nextHopCandidateCount > 1 { handlerCfg["retries"] = nextHopCandidateCount - 1 } service := map[string]interface{}{ - "name": fmt.Sprintf("%d_tls", tunnelID), - "addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)), - "handler": handlerCfg, - "listener": map[string]interface{}{ - "type": protocol, - }, + "name": fmt.Sprintf("%d_%s", tunnelID, protocol), + "addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)), + "handler": handlerCfg, + "listener": buildTunnelListenerConfig(protocol), } if chainNode.ChainType == 2 { service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID) @@ -3588,10 +3602,45 @@ func nodeDisplayName(node *nodeRecord) string { return fmt.Sprintf("node_%d", node.ID) } +func isTCPTunnelProtocol(protocol string) bool { + p := strings.ToLower(strings.TrimSpace(defaultString(protocol, "tls"))) + return p == "tls" || p == "mtls" || p == "mtcp" +} + +func isKCPTunnelProtocol(protocol string) bool { + return strings.EqualFold(strings.TrimSpace(protocol), "kcp") +} + func isTLSTunnelProtocol(protocol string) bool { return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls") } +func buildTunnelDialerConfig(protocol string) map[string]interface{} { + dialer := map[string]interface{}{ + "type": protocol, + } + if isKCPTunnelProtocol(protocol) { + dialer["metadata"] = map[string]interface{}{ + "kcp.keepalive": 10, + "kcp.tcp": false, + } + } + return dialer +} + +func buildTunnelListenerConfig(protocol string) map[string]interface{} { + listener := map[string]interface{}{ + "type": protocol, + } + if isKCPTunnelProtocol(protocol) { + listener["metadata"] = map[string]interface{}{ + "kcp.keepalive": 10, + "kcp.tcp": false, + } + } + return listener +} + func nodeSupportsV4(node *nodeRecord) bool { if node == nil { return false diff --git a/go-gost/x/dialer/kcp/metadata.go b/go-gost/x/dialer/kcp/metadata.go index 16053d2..a5cf570 100644 --- a/go-gost/x/dialer/kcp/metadata.go +++ b/go-gost/x/dialer/kcp/metadata.go @@ -42,7 +42,7 @@ func (d *kcpDialer) parseMetadata(md mdata.Metadata) (err error) { if d.md.config == nil { d.md.config = kcp_util.DefaultConfig } - d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp") + d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp") d.md.config.Key = mdutil.GetString(md, "kcp.key") d.md.config.Crypt = mdutil.GetString(md, "kcp.crypt") d.md.config.Mode = mdutil.GetString(md, "kcp.mode") diff --git a/go-gost/x/internal/util/kcp/config.go b/go-gost/x/internal/util/kcp/config.go index eef72fd..985d2eb 100644 --- a/go-gost/x/internal/util/kcp/config.go +++ b/go-gost/x/internal/util/kcp/config.go @@ -40,7 +40,7 @@ var ( SnmpLog: "", SnmpPeriod: 60, Signal: false, - TCP: false, + TCP: false, // default UDP mode (use tcpraw when true) } ) diff --git a/go-gost/x/listener/kcp/metadata.go b/go-gost/x/listener/kcp/metadata.go index 539d2ad..c66a44f 100644 --- a/go-gost/x/listener/kcp/metadata.go +++ b/go-gost/x/listener/kcp/metadata.go @@ -45,7 +45,7 @@ func (l *kcpListener) parseMetadata(md mdata.Metadata) (err error) { if l.md.config == nil { l.md.config = kcp_util.DefaultConfig } - l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp") + l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp") l.md.config.Key = mdutil.GetString(md, "kcp.key") l.md.config.Crypt = mdutil.GetString(md, "kcp.crypt") l.md.config.Mode = mdutil.GetString(md, "kcp.mode") diff --git a/go-gost/x/listener/rudp/metadata.go b/go-gost/x/listener/rudp/metadata.go index 94da252..f60b33e 100644 --- a/go-gost/x/listener/rudp/metadata.go +++ b/go-gost/x/listener/rudp/metadata.go @@ -8,7 +8,7 @@ import ( ) const ( - defaultTTL = 5 * time.Second + defaultTTL = 30 * time.Second defaultReadBufferSize = 8192 defaultReadQueueSize = 1024 defaultBacklog = 128 diff --git a/go-gost/x/listener/udp/metadata.go b/go-gost/x/listener/udp/metadata.go index db8a5e1..95ec8d8 100644 --- a/go-gost/x/listener/udp/metadata.go +++ b/go-gost/x/listener/udp/metadata.go @@ -8,7 +8,7 @@ import ( ) const ( - defaultTTL = 5 * time.Second + defaultTTL = 30 * time.Second defaultReadBufferSize = 8192 defaultReadQueueSize = 128 defaultBacklog = 128 diff --git a/plans/069-udp-tunnel-fix.md b/plans/069-udp-tunnel-fix.md new file mode 100644 index 0000000..d2c28b5 --- /dev/null +++ b/plans/069-udp-tunnel-fix.md @@ -0,0 +1,87 @@ +# 069: UDP 隧道转发断流修复 + KCP 协议支持 + +## 问题分析 + +GOST 隧道转发 UDP 断流的根因涉及三个超时层: + +### 1. UDP 虚拟连接 TTL 过短(5秒)— 主因 +- `go-gost/x/listener/udp/metadata.go:11`: `defaultTTL = 5s` +- `go-gost/x/internal/net/udp/pool.go:79-115`: `idleCheck()` 每 TTL 周期标记空闲连接,下一周期关闭 +- **影响**: UDP 流无数据包超过 ~10 秒即被静默关闭 +- **后端配置**: `control_plane.go:1607-1613` 设置了 `keepAlive: true`,但仅在 `tunnelTLSProtocol=true` 时设 `ttl: "10s"`,常规隧道转发不设 TTL 覆盖 + +### 2. Smux Keepalive 未显式配置 +- smux `DefaultConfig()`: `KeepAliveInterval=10s`, `KeepAliveTimeout=30s` +- 后端 `buildTunnelChainServiceConfig` 生成 relay handler 时不设置 mux 参数 +- 依赖 smux 默认值,但中间 NAT/防火墙可能先于 30s 断开 TCP 连接 + +### 3. 隧道仅依赖 TCP 传输 — 无 UDP 备选 +- 隧道链节点间仅支持 TLS/TCP 传输 +- TCP 队头阻塞 + 单连接承载所有流 + 无 UDP 级别的容错 + +## 修复方案 + +### Part A: 修复现有问题(低风险,即时生效) + +#### A1. 增大 UDP listener 默认 TTL +- **文件**: `go-gost/x/listener/udp/metadata.go` +- **改动**: `defaultTTL = 30s`(从 5s) +- **原因**: 5s 对实际场景太激进,DNS 等长间隔 UDP 应用会被误杀 + +#### A2. 后端生成 UDP forward 配置时强制设置 TTL +- **文件**: `go-backend/internal/http/handler/control_plane.go` +- **改动**: `buildForwardServiceConfigs` 中始终设置 `listener.metadata.ttl = "30s"` +- **原因**: 确保配置显式覆盖,不依赖 go-gost 默认值 + +#### A3. 后端隧道链服务配置加入 mux keepalive +- **文件**: `go-backend/internal/http/handler/mutations.go` +- **改动**: `buildTunnelChainServiceConfig` 中的 relay handler 加入 mux metadata: + ```go + "mux.keepaliveInterval": "15s", + "mux.keepaliveTimeout": "45s", + ``` + +### Part B: 新增 KCP 协议支持(新功能) + +#### B1. 后端支持 `kcp` 作为隧道链传输协议 +- **文件**: `go-backend/internal/http/handler/mutations.go` +- **改动**: + - `buildTunnelChainConfig`: 识别 `kcp` 协议,生成对应的 connector/dialer + - `buildTunnelChainServiceConfig`: 识别 `kcp` 协议,生成 KCP listener + - `isTLSTunnelProtocol` → `isTCPTunnelProtocol`(包含 TLS) + - KCP dialer 设置 `kcp.keepalive` 参数 + - KCP 配置无需 `nodelay`(那是 TLS 特有) + +#### B2. 前端支持选择 `kcp` 协议 +- **文件**: `vite-frontend/src/pages/tunnel/` 相关表单 +- **改动**: 协议选择器中加入 KCP 选项 + +#### B3. 确认 go-gost KCP 组件正常 +- KCP dialer: `go-gost/x/dialer/kcp/` — 已存在 +- KCP listener: `go-gost/x/listener/kcp/` — 已存在 +- KCP connector: 使用 `relay` connector 通过 KCP dialer 连接 +- 验证 KCP 配置的 metadata 参数传递正确 + +## 任务清单 + +- [x] A1: 增大 UDP listener 默认 TTL (5s→30s) +- [x] A2: 后端 forward 配置强制设置 UDP TTL=30s +- [x] A3: 后端隧道链 relay handler 加入 mux keepalive +- [x] B1: 后端支持 kcp 作为隧道链协议 +- [x] B2: 前端协议选择器加入 KCP +- [x] B3: 验证 go-gost KCP 组件配置正确 +- [x] C1: go-backend 合约测试 (187 passed) +- [x] C2: go-gost 编译验证 (passed) +- [x] C3: vite-frontend 编译验证 (passed) + +## 文件变更清单 + +| 文件 | 变更类型 | +|------|----------| +| `go-gost/x/listener/udp/metadata.go` | 修改 defaultTTL: 5s→30s | +| `go-gost/x/listener/rudp/metadata.go` | 修改 defaultTTL: 5s→30s | +| `go-backend/internal/http/handler/control_plane.go` | buildForwardServiceConfigs: 强制设置 UDP TTL=30s, 移除 tunnelTLSProtocol 参数 | +| `go-backend/internal/http/handler/control_plane_test.go` | 更新测试调用签名 | +| `go-backend/internal/http/handler/mutations.go` | 新增 isTCPTunnelProtocol/isKCPTunnelProtocol/buildTunnelDialerConfig/buildTunnelListenerConfig; 隧道链配置加入 mux keepalive + KCP 支持 | +| `go-backend/internal/http/handler/federation.go` | federation 配置同步加入 mux keepalive + KCP 支持 | +| `vite-frontend/src/pages/tunnel.tsx` | 协议选择器新增 KCP 选项 (3 处) diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 2cb43e1..bf207f7 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -90,7 +90,7 @@ import { interface ChainTunnel { nodeId: number; - protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' - 转发链协议 + protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' | 'kcp' - 转发链协议 strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要 chainType?: number; // 1: 入口, 2: 转发链, 3: 出口 inx?: number; // 转发链序号 @@ -2456,6 +2456,7 @@ export default function TunnelPage() { MTLS MWSS MTCP + KCP {/* 负载策略 - 25% */} @@ -2756,6 +2757,7 @@ export default function TunnelPage() { MTLS MWSS MTCP + KCP {/* 负载策略 - 25% */}