Posted in

为什么米哈游不用Kubernetes原生Service?——自研服务发现中间件GoDNS架构解析(含核心调度算法Go实现)

第一章:米哈游服务发现演进的底层动因与GoDNS诞生背景

在《原神》《崩坏:星穹铁道》等全球级游戏的高并发、多区域、跨云架构实践中,米哈游早期基于 Consul + DNS SRV 的服务发现方案逐渐暴露出三大结构性瓶颈:DNS 缓存导致服务上下线延迟高达 30–60 秒;跨 AZ/Region 场景下健康检查链路复杂,故障收敛慢;Kubernetes 与裸金属混合集群中,传统 DNS 插件难以统一纳管非容器化游戏网关与状态服务器。

为应对毫秒级服务感知需求,团队启动轻量级 DNS 代理层重构——GoDNS 应运而生。它并非通用 DNS 服务器,而是专为服务发现优化的“智能解析中间件”:直接监听 etcd 中 /services/{name}/instances 路径变更,将服务实例列表实时映射为 A/AAAA 记录,并支持 TTL 动态降级(健康实例 TTL=5s,异常实例 TTL=1s)以加速客户端重试。

核心设计原则包括:

  • 零配置启动:仅需 --etcd-endpoints=http://etcd-cluster:2379 --domain=svc.mihoyo.com
  • 协议兼容:完全遵循 RFC 1035,可无缝替换 CoreDNS 或 dnsmasq 作为上游 DNS
  • 健康闭环:内置 TCP 端口探测 + 自定义 HTTP /healthz 接口校验,失败实例自动剔除

典型部署流程如下:

# 1. 启动 GoDNS(监听 etcd 并暴露 53 端口)
go run cmd/godns/main.go \
  --etcd-endpoints="http://etcd-01:2379,http://etcd-02:2379" \
  --domain="game.svc.mihoyo.com" \
  --upstream="8.8.8.8:53"  # 将非服务域名转发至公共 DNS

# 2. 在 Kubernetes ConfigMap 中注入 DNS 配置
kubectl set env deploy game-server \
  DNS_SERVER=10.244.1.100  # GoDNS Pod IP

相较传统方案,GoDNS 将服务发现平均延迟从 22s 降至 1.3s(P99),且内存占用稳定在 12MB 以内,支撑了单集群超 8000 个服务实例的实时解析。其诞生本质是业务规模倒逼基础设施下沉——当游戏世界需要“零感迁移”,DNS 必须从静态目录进化为动态拓扑反射器。

第二章:GoDNS核心架构设计原理与Golang实现细节

2.1 基于一致性哈希的节点注册与去中心化路由表构建

在 P2P 网络中,节点动态加入/退出频繁,传统中心化路由易成瓶颈。一致性哈希将节点 ID 和键(如资源标识)映射至同一环形哈希空间(如 2^160 - 1),实现负载均衡与最小化重分布。

节点注册流程

  • 新节点计算自身 ID(如 SHA-1(IP:Port))
  • 在哈希环上定位前驱节点(predecessor),发起 Join 请求
  • 前驱节点移交其负责的部分 key 区间及对应数据

路由表结构(Finger Table 示例)

Index Start Successor Node
0 n+1 succ(n+1)
1 n+2 succ(n+2)
k n+2^k mod 2^m succ(...)
def find_successor(id: int) -> Node:
    # id: 目标哈希值(如资源key)
    if self.in_range(id, self.id, self.successor.id):
        return self.successor
    # 沿 finger table 跳跃查找,O(log N) 复杂度
    n = self.closest_preceding_node(id)
    return n.find_successor(id)

逻辑说明:find_successor 递归调用,利用 finger table 实现指数级跳跃;closest_preceding_node 遍历 finger 表项,返回离 id 最近且小于它的节点;参数 id 是目标资源哈希值,非节点 ID。

graph TD A[新节点N] –>|1. 计算hash N| B[定位前驱P] B –>|2. 发起Join| C[P移交区间 & 数据] C –>|3. 更新finger表| D[N完成注册]

2.2 零依赖轻量级健康探测机制:TCP心跳+HTTP探针混合调度模型

传统健康检查常依赖复杂中间件或定制Agent,而本机制仅利用内核TCP栈与标准HTTP客户端能力,实现毫秒级响应、零外部依赖的存活判定。

混合探测策略设计

  • TCP心跳:每3s发起一次无payload SYN连接尝试,超时阈值设为1.5s
  • HTTP探针:每15s向/healthz发送HEAD请求,要求200 OK且响应头含X-Ready: true

探测状态融合逻辑

def fused_health_score(tcp_ok: bool, http_ok: bool, http_latency_ms: int) -> float:
    # 权重分配:TCP实时性(0.7) + HTTP语义可靠性(0.3)
    tcp_score = 1.0 if tcp_ok else 0.0
    http_score = 0.9 if http_ok and http_latency_ms < 200 else 0.0
    return 0.7 * tcp_score + 0.3 * http_score

该函数输出[0.0, 1.0]连续健康分,支持灰度流量调度——当分值

调度决策流程

graph TD
    A[定时触发] --> B{TCP连接成功?}
    B -->|是| C[发起HTTP探针]
    B -->|否| D[立即标记不健康]
    C --> E{HTTP状态码=200<br>且X-Ready:true?}
    E -->|是| F[计算融合分]
    E -->|否| G[降权并记录异常]
探测类型 频率 超时 成功条件
TCP心跳 3s 1.5s SYN-ACK握手完成
HTTP探针 15s 2s HEAD返回200+正确Header

2.3 动态权重感知的负载均衡算法:QPS+延迟+连接数三因子加权调度

传统轮询或静态权重策略难以应对瞬时流量突变与服务异构性。本算法实时融合三个动态指标:每秒查询数(QPS)、平均响应延迟(ms)、当前活跃连接数(Conn),构建自适应权重公式:

def compute_weight(qps, latency_ms, conn_count, base_weight=100):
    # 归一化各因子(0~1区间),避免量纲差异主导结果
    norm_qps = min(qps / 1000.0, 1.0)        # 假设峰值QPS为1000
    norm_latency = max(0.0, 1.0 - latency_ms / 500.0)  # 延迟≤500ms时线性增益
    norm_conn = max(0.0, 1.0 - conn_count / 2000.0)    # 连接数上限2000
    return int(base_weight * (0.4 * norm_qps + 0.4 * norm_latency + 0.2 * norm_conn))

逻辑说明norm_qps反映服务能力利用率;norm_latency对高延迟节点施加惩罚(延迟超500ms权重趋零);norm_conn抑制过载节点;系数[0.4, 0.4, 0.2]体现业务对吞吐与响应的双敏感特性。

权重计算示例(单位:QPS/ms/conn)

实例 QPS Latency Conn 计算权重
A 850 42 620 91
B 320 185 310 67

调度决策流程

graph TD
    A[采集指标] --> B{是否超阈值?}
    B -->|是| C[触发权重重计算]
    B -->|否| D[维持当前权重]
    C --> E[广播新权重至调度器]
    E --> F[按加权轮询分发请求]

2.4 增量式服务变更通知:基于etcd Watch + Go Channel的事件驱动同步

数据同步机制

传统轮询拉取配置存在延迟与资源浪费;而 etcd 的 Watch API 提供长连接+增量事件流能力,配合 Go Channel 实现轻量级、非阻塞的事件分发。

核心实现逻辑

watchCh := client.Watch(ctx, "/services/", client.WithPrefix())
for resp := range watchCh {
    for _, ev := range resp.Events {
        // ev.Type: PUT/DELETE;ev.Kv.Key/Value 携带变更数据
        select {
        case eventChan <- ServiceEvent{Type: ev.Type, Key: string(ev.Kv.Key), Data: ev.Kv.Value}:
        default: // 防止 channel 阻塞,丢弃瞬时积压(可按需替换为带缓冲channel)
        }
    }
}

该代码建立前缀监听,将 etcd 的 mvcc 版本化事件实时转为结构化 ServiceEvent,通过无缓冲 channel 向业务层投递。WithPrefix() 支持服务目录批量监听,select+default 保障高吞吐下的稳定性。

事件处理对比

方式 延迟 CPU 开销 一致性保障
HTTP 轮询 秒级 弱(可能丢失中间状态)
etcd Watch 毫秒级 极低 强(基于 revision 有序交付)
graph TD
    A[etcd Server] -->|Watch Stream| B[Client Watcher]
    B --> C[Go Channel]
    C --> D[Service Registry]
    C --> E[Config Reloader]

2.5 多集群跨AZ服务寻址:拓扑感知路由与Region-Aware DNS解析协议扩展

在超大规模云原生架构中,服务实例跨可用区(AZ)与多集群部署已成为常态。传统DNS轮询或全局负载均衡无法感知底层拓扑,易引发跨AZ高延迟与带宽浪费。

拓扑标签驱动的路由决策

Kubernetes Node与Service均注入拓扑标签(如 topology.kubernetes.io/zone: us-west-2a),Ingress控制器据此优先将流量路由至同AZ后端:

# 示例:Envoy RDS 配置片段(拓扑加权集群)
clusters:
- name: svc-auth
  lb_policy: MAGLEV
  locality_lb_setting:
    enabled: true
    failover:
    - from: us-west-2a
      to: us-west-2b

逻辑分析locality_lb_setting.enabled=true 启用区域亲和;failover 定义降级路径,避免跨Region回退。MAGLEV 确保一致性哈希在局部拓扑内稳定。

Region-Aware DNS 协议扩展字段

字段名 类型 说明
X-Region-Hint string 客户端声明期望Region(如 cn-hangzhou
X-AZ-Priority list 优先AZ列表(["cn-hangzhou-a", "cn-hangzhou-b"]

请求调度流程

graph TD
  A[客户端发起DNS查询] --> B{DNS Resolver检查X-Region-Hint}
  B -->|存在| C[查询Region-Aware Zone DNS视图]
  B -->|缺失| D[返回全局默认视图]
  C --> E[返回同Region内最低延迟AZ的A记录]

该机制使服务发现延迟降低42%,跨AZ流量下降76%(实测于阿里云ACK三集群环境)。

第三章:GoDNS在《原神》全球服中的真实落地实践

3.1 千万级并发下服务实例注册吞吐优化:sync.Pool + ring buffer批处理实战

在千万级实例高频注册场景中,频繁堆分配 Instance 结构体与切片导致 GC 压力陡增、延迟毛刺明显。我们采用 对象复用 + 批量缓冲 双策略协同优化。

核心设计

  • sync.Pool 复用 RegistrationBatch 对象,规避 GC 开销
  • 无锁 ring buffer(固定容量 1024)暂存待注册请求,攒批后异步刷入注册中心

ring buffer 批处理核心逻辑

type RingBuffer struct {
    data   [1024]*Instance
    head, tail uint64
}

func (rb *RingBuffer) Push(inst *Instance) bool {
    next := (rb.tail + 1) & 1023 // 位运算替代取模,零开销
    if next == rb.head {         // 已满,丢弃最老项(注册可容忍轻量丢失)
        rb.head = (rb.head + 1) & 1023
    }
    rb.data[rb.tail&1023] = inst
    rb.tail = next
    return true
}

& 1023 等价于 % 1024,但编译器可直接优化为位操作;head/tail 使用 uint64 避免 ABA 问题;满时主动推进 head 实现有界丢弃,保障写入恒定 O(1)。

性能对比(单节点压测)

方案 吞吐(req/s) P99延迟(ms) GC 次数/秒
原生 slice 分配 82,000 42 18
sync.Pool + ring buffer 315,000 9 1
graph TD
    A[实例注册请求] --> B{RingBuffer.Push}
    B -->|成功| C[缓冲区未满]
    B -->|丢弃最老项| D[缓冲区已满]
    C --> E[定时器触发 Flush]
    D --> E
    E --> F[批量调用 RegisterAPI]

3.2 游戏网关层无缝集成:gRPC Resolver插件开发与TLS上下文透传实现

gRPC Resolver插件核心逻辑

为支持动态服务发现,自研Resolver插件继承grpc.Resolver接口,重写ResolveNowClose方法:

func (r *GameResolver) ResolveNow(o resolver.ResolveNowOptions) {
    // 基于玩家区域ID查询最新游戏服列表,并注入TLS元数据
    servers := r.discovery.GetServers(r.regionID)
    state := resolver.State{
        Addresses: make([]resolver.Address, len(servers)),
    }
    for i, s := range servers {
        state.Addresses[i] = resolver.Address{
            Addr:     fmt.Sprintf("%s:%d", s.IP, s.Port),
            Metadata: map[string]interface{}{"tls_context": s.TLSContext}, // 关键:透传TLS上下文
        }
    }
    r.cc.UpdateState(state) // 触发连接重建
}

该实现将服务发现结果与TLS配置(如客户端证书路径、SNI主机名)绑定至每个Address.Metadata,供后续TransportCredentials构建时消费。

TLS上下文透传机制

Metadata中携带的tls_contextClientConn拦截器捕获,用于动态构造credentials.TransportCredentials

  • ✅ 支持多租户SNI隔离(按玩家归属分区)
  • ✅ 自动加载分区专属mTLS证书链
  • ❌ 不依赖全局证书池,避免跨区污染

插件注册与生效流程

graph TD
    A[Gateway启动] --> B[注册GameResolverBuilder]
    B --> C[gRPC.Dial时触发Resolve]
    C --> D[Discovery服务拉取带TLS元数据的Endpoint]
    D --> E[UpdateState注入Metadata]
    E --> F[ConnPicker选择并应用对应TLS配置]
字段 类型 说明
tls_context.sni string SNI主机名,如 shanghai.game.example.com
tls_context.cert_path string 客户端证书文件路径(挂载卷内)
tls_context.ca_path string 分区CA根证书路径

3.3 灰度发布支持:基于标签的Service Version路由与流量染色追踪

灰度发布依赖于细粒度的流量分发能力,核心在于将请求“染色”并按标签匹配目标服务版本。

流量染色机制

客户端在 HTTP Header 中注入 x-envoy-version: v2.1x-user-group: beta,网关据此注入 Envoy 路由元数据。

Service Mesh 路由配置示例

# Istio VirtualService 片段(带标签路由)
spec:
  http:
  - route:
    - destination:
        host: user-service
        subset: v2-rc  # 对应 DestinationRule 中的 subset 标签
      weight: 30
    - destination:
        host: user-service
        subset: v1-stable
      weight: 70

该配置通过 subset 关联 DestinationRule 中定义的 labels: {version: v2-rc},实现标签驱动的权重分流;weight 表示百分比流量分配,支持动态热更新。

流量追踪链路示意

graph TD
  A[Client] -->|x-envoy-version:v2.1| B[Ingress Gateway]
  B --> C{Router}
  C -->|match label| D[user-service-v2-rc]
  C -->|default| E[user-service-v1-stable]

标签策略对照表

标签键 示例值 用途 生效层级
version v2.1 主版本灰度 Pod/Deployment
canary true 实验性功能开关 Service Subset
user-id-range 1000-1999 用户ID区间路由 Envoy Filter

第四章:GoDNS调度算法深度解析与Golang工程化实现

4.1 自适应最小连接数算法(Adaptive Least Connections):动态权重收敛控制与Go泛型封装

该算法在传统最小连接数基础上引入实时负载反馈,通过滑动窗口统计后端节点的响应延迟与错误率,动态调整其有效权重。

核心收敛机制

采用指数加权移动平均(EWMA)更新权重:

// weight = α × currentLoad + (1−α) × oldWeight,α ∈ (0,1)
func (a *ALC) updateWeight(node *Node, latencyMS float64, errRate float64) {
    load := latencyMS*0.7 + errRate*100*0.3 // 归一化复合负载
    node.weight = a.alpha*load + (1-a.alpha)*node.weight
}

alpha=0.2 控制收敛速度:值越小,历史权重影响越大,抗瞬时抖动能力越强。

泛型调度器封装

type LoadBalancer[T NodeInterface] struct {
    nodes []T
    mu    sync.RWMutex
}
func (lb *LoadBalancer[T]) Next() T { /* 基于weight排序取最小 */ }
参数 含义 典型值
alpha 权重更新衰减系数 0.15–0.25
windowSize 延迟采样窗口(秒) 30

graph TD
A[请求到达] –> B{计算实时负载}
B –> C[EWMA更新权重]
C –> D[按weight升序排序]
D –> E[选取最小weight节点]

4.2 拓扑亲和性调度(Topology-Aware Scheduling):Zone/Region维度距离建模与Golang MapReduce式聚合

拓扑感知调度需将物理位置(如可用区、地域)编码为可计算的“距离”,而非仅依赖标签匹配。

距离建模策略

  • Zone内距离 = 0
  • 同Region跨Zone距离 = 1
  • 跨Region距离 = 10(惩罚权重)

Golang MapReduce聚合示例

// Map: 提取节点拓扑键并计数
func mapNode(node *Node) (string, int) {
    return fmt.Sprintf("%s/%s", node.Region, node.Zone), 1 // key: "us-west-1/zone-a"
}

// Reduce: 汇总各拓扑单元节点数
func reduceCount(_ string, counts []int) int {
    sum := 0
    for _, c := range counts { sum += c }
    return sum
}

该MapReduce逻辑将节点按Region/Zone分组聚合,输出形如 {"us-west-1/zone-a": 12, "us-west-1/zone-b": 8} 的拓扑热度图,供调度器加权选择。

拓扑距离矩阵(简化示意)

From → To zone-a zone-b us-east-1
zone-a 0 1 10
zone-b 1 0 10
us-east-1 10 10 0
graph TD
    A[Pod调度请求] --> B{查询拓扑热度图}
    B --> C[计算候选节点距离得分]
    C --> D[加权排序:距离×负载×资源余量]
    D --> E[选择Top1节点绑定]

4.3 故障熔断与快速恢复:基于滑动窗口统计的失败率阈值判定与goroutine池化重试机制

滑动窗口失败率统计

采用环形缓冲区实现固定长度(如60秒)滑动窗口,实时聚合请求成功/失败计数:

type SlidingWindow struct {
    window   []int64 // 1: success, -1: failure
    cursor   int
    size     int
    total    int64
    failures int64
}

func (sw *SlidingWindow) Record(success bool) {
    if success {
        sw.total++
        sw.window[sw.cursor] = 1
    } else {
        sw.total++
        sw.failures++
        sw.window[sw.cursor] = -1
    }
    sw.cursor = (sw.cursor + 1) % sw.size
}

window 存储原子标记,totalfailures 支持 O(1) 失败率计算(failures/total > 0.5 触发熔断)。

goroutine 池化重试

避免重试风暴,使用带限流的 worker pool:

参数 说明
MaxWorkers 16 并发重试上限
QueueSize 128 等待重试任务队列容量
BackoffBase 100ms 指数退避起始间隔
graph TD
    A[请求失败] --> B{是否熔断?}
    B -- 是 --> C[返回降级响应]
    B -- 否 --> D[提交至重试池]
    D --> E[Worker按Backoff调度]
    E --> F[最多3次指数退避重试]

重试任务由固定 goroutine 池消费,配合 jitter 避免同步重试尖峰。

4.4 调度决策可观测性增强:Prometheus指标埋点设计与pprof火焰图性能归因分析

为精准定位调度器性能瓶颈,需同步构建多维指标采集低开销运行时剖析双通道。

Prometheus指标埋点设计

ScheduleOne关键路径注入以下核心指标:

// 定义调度延迟直方图(单位:毫秒)
scheduler_latency_seconds := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "scheduler_latency_seconds",
        Help:    "Latency of scheduling one pod, in seconds",
        Buckets: prometheus.ExponentialBuckets(0.001, 2, 12), // 1ms~2s
    },
    []string{"phase", "result"}, // phase: predicate/priority/bind;result: success/fail
)

逻辑说明:ExponentialBuckets覆盖毫秒级判定到秒级绑定的全量延时分布;phase标签支持按调度阶段下钻,result标签区分成功/失败路径,避免指标混淆。

pprof火焰图归因分析

启动时启用运行时采样:

# 每秒采集CPU样本(开销<3%)
curl "http://localhost:10251/debug/pprof/profile?seconds=30" > cpu.pb.gz
go tool pprof -http=:8080 cpu.pb.gz

关键指标语义对齐表

指标名 类型 标签维度 归因目标
scheduler_pending_pods Gauge queue 队列积压水位
scheduler_preemption_attempts_total Counter result 抢占决策有效性
graph TD
    A[调度循环入口] --> B{Predicate过滤}
    B -->|慢节点筛选| C[NodeInfo.Cache miss]
    B -->|高并发竞争| D[SharedInformer lock contention]
    C --> E[火焰图热点:nodeLister.Get]
    D --> F[pprof mutex profile]

第五章:从GoDNS到下一代游戏基础设施的演进思考

GoDNS在《星穹突围》中的实战落地

2023年Q3,某中型MMO手游《星穹突围》遭遇全球节点解析延迟突增问题——东南亚玩家平均DNS解析耗时达387ms,导致登录排队超时率飙升至12.6%。团队紧急将原Cloudflare DNS切换为自研GoDNS服务,通过协程池+LRU缓存+EDNS Client Subnet(ECS)支持,在阿里云新加坡、AWS东京、GCP洛杉矶三地部署轻量级解析集群。实测数据显示:解析P95延迟压缩至42ms,TTL动态调控使热点服区缓存命中率达91.3%,且单节点承载QPS突破12万。关键代码片段如下:

func (s *Server) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
    clientIP := getClientIP(r)
    region := geoip.Lookup(clientIP).Region // 基于MaxMind DB实时定位
    key := fmt.Sprintf("%s:%s", r.Question[0].Name, region)
    if cached, ok := s.cache.Get(key); ok {
        w.WriteMsg(cached.(*dns.Msg))
        return
    }
    // 转发至权威DNS并注入地域策略
    upstream := s.getUpstream(region)
    resp, _ := dns.Exchange(r, upstream)
    s.cache.Set(key, resp, cache.DefaultExpiration)
    w.WriteMsg(resp)
}

游戏服务网格(Game Mesh)的渐进式演进路径

传统DNS仅解决地址发现,而现代游戏需融合路由、熔断、灰度发布与实时指标采集。我们基于GoDNS内核扩展出Game Mesh控制平面,集成OpenTelemetry SDK采集每毫秒级延迟分布,并通过eBPF探针捕获UDP丢包特征。下表对比了三个迭代阶段的核心能力:

阶段 服务发现粒度 故障隔离能力 动态权重依据 典型延迟波动容忍
GoDNS v1.0 区域级(APAC/NA/EU) 全局DNS TTL回退 静态配置 ±150ms
Game Mesh v2.3 机房级(SG-SH-DC1) 实时UDP丢包率熔断 Prometheus指标 ±35ms
Game Mesh v3.1 实例级(Pod UID) 基于QUIC连接健康度 eBPF网络层特征 ±8ms

多协议协同架构设计

在《星穹突围》跨平台版本中,iOS客户端使用HTTP/3获取资源清单,PC端依赖gRPC流式同步战斗状态,主机平台则采用自定义二进制协议传输帧数据。GoDNS不再仅返回A记录,而是根据User-Agent Header和TLS ALPN协商结果,返回对应协议网关的SRV记录与TLS证书指纹哈希值。Mermaid流程图展示该决策链路:

graph TD
    A[DNS Query] --> B{ALPN Negotiation}
    B -->|h3| C[Return HTTP/3 Gateway SRV]
    B -->|grpc| D[Return gRPC Endpoint + Cert Hash]
    B -->|gameproto| E[Return UDP Endpoint + Session Key Seed]
    C --> F[客户端验证证书指纹]
    D --> F
    E --> G[启动QUIC握手]

边缘计算节点的动态注册机制

为支撑全球200+边缘节点自动纳管,GoDNS引入基于WebAssembly的轻量注册器:每个边缘节点运行wasmtime实例,加载Rust编译的注册模块,每30秒上报CPU负载、UDP丢包率、内存水位及本地NTP偏差值。注册数据经签名后写入Etcd,GoDNS控制面据此生成区域拓扑图谱,并在DNS响应中嵌入ADDITIONAL节携带节点健康评分(0~100)。2024年春节活动期间,该机制自动将北京联通节点权重从80提升至95,成功规避骨干网拥塞导致的登录雪崩。

协议栈下沉带来的运维范式转变

当DNS层开始承载QUIC连接状态、gRPC健康检查、甚至部分业务逻辑(如防外挂token校验),SRE团队需重构监控体系:Prometheus新增go_dns_upstream_health_score指标,Grafana看板集成eBPF丢包热力图,告警规则从“DNS响应超时”升级为“区域健康分低于阈值且持续3个采样周期”。某次上海电信骨干网抖动事件中,系统在17秒内完成节点降权,玩家无感切换至杭州移动节点。

专攻高并发场景,挑战百万连接与低延迟极限。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注