Posted in

Golang语音服务容器化部署血泪史:cgroup v2内存压力误判、k8s HPA指标失准、Pod QoS Class反模式

第一章:Golang语音服务容器化部署血泪史:cgroup v2内存压力误判、k8s HPA指标失准、Pod QoS Class反模式

某语音转写微服务(Go 1.21 + Gin + Whisper.cpp binding)在迁入 Kubernetes 1.28(默认启用 cgroup v2)后,频繁触发 OOMKilled,但 kubectl top pod 显示内存使用率仅 45%——根源在于 Go runtime 的 memory.stat 解析逻辑与 cgroup v2 的 memory.current/memory.low 语义错配。Go 程序依赖 runtime.ReadMemStats() 获取 Sys 字段,而该值在 cgroup v2 下被错误映射为 memory.max 而非实际用量,导致 GC 触发阈值漂移。

修复方案需双管齐下:

  • 在容器启动时显式配置 cgroup v2 兼容参数:

    # Dockerfile 片段
    ENV GODEBUG=madvdontneed=1  # 强制使用 MADV_DONTNEED 替代 MADV_FREE,规避 cgroup v2 的 page cache 统计偏差
  • Kubernetes Pod spec 中禁用内核内存 accounting(避免 memory.kmem 干扰):

    securityContext:
    sysctls:
    - name: kernel.memory_accounting
    value: "0"  # 需集群节点支持 sysctl 写入

HPA 失准源于 Prometheus metrics-server 默认采集 container_memory_working_set_bytes,该指标在 cgroup v2 下包含大量可回收的 page cache,无法反映 Go 堆真实压力。应改用 container_memory_usage_bytes - container_memory_cache(需启用 kubelet --enable-cadvisor-json-endpoints=true)。

Pod QoS Class 反模式表现为:为“保障语音低延迟”将所有 Pod 设为 Guaranteed,但未对齐 request/limit 均值。结果是节点 allocatable 内存被高估,调度器误判资源余量。正确实践如下:

QoS Class CPU Request/Limit Memory Request/Limit 适用场景
Guaranteed 必须相等 必须相等 实时语音编解码核心组件
Burstable request request 日志聚合、异步转写队列
BestEffort 全未设置 全未设置 临时调试 Pod(禁止生产)

最终通过 kubectl set resources deployment/voice-transcribe --limits=memory=1Gi,cpu=1 --requests=memory=900Mi,cpu=800m 强制收敛至 Guaranteed,并配合 kubectl describe node 验证 Allocatable 与 Capacity 差值回归合理区间(>15%)。

第二章:cgroup v2内存子系统深度解析与Golang运行时协同困境

2.1 cgroup v2 memory.stat与memory.pressure的语义差异及内核行为溯源

memory.stat累积快照型指标,记录自 cgroup 创建以来的内存事件总量(如 pgpgin, pgmajfault),由 mem_cgroup_stat_show() 定期读取 struct mem_cgroup_stat_cpu 中的 per-CPU 累加值并归并:

// kernel/mm/memcontrol.c: mem_cgroup_stat_show()
for_each_possible_cpu(cpu) {
    stat = per_cpu_ptr(memcg->stat_cpu, cpu);
    for (i = 0; i < NR_VM_NODE_STAT_ITEMS; i++)
        val += READ_ONCE(stat->count[i]); // 原子读,无锁聚合
}

→ 该路径不反映瞬时压力,仅支持历史趋势分析。

memory.pressure 则是事件驱动型信号,基于 psi(Pressure Stall Information)子系统实时计算内存等待比例(some, full),触发条件为 try_to_free_pages() 遇到直接回收延迟或 OOM killer 启动。

核心语义对比

维度 memory.stat memory.pressure
数据性质 累积计数器 滑动窗口平均率(% / 10s)
更新时机 读取时聚合(无自动刷新) 内核 PSI timer 每秒采样更新
典型用途 容量规划、事后归因 弹性扩缩容、SLA 监控告警

内核行为关键路径

graph TD
    A[page fault] --> B{need_reclaim?}
    B -->|yes| C[psi_memstall_enter]
    C --> D[psi_update_work]
    D --> E[memory.pressure: full/some]
    B -->|no| F[update memory.stat.pgpgin]

2.2 Go runtime.MemStats与cgroup v2内存指标的映射断层实测分析

数据同步机制

Go 的 runtime.ReadMemStats 采集的是 GC 周期驱动的采样快照,而 cgroup v2 /sys/fs/cgroup/memory.current 是内核实时原子计数器——二者无同步协议,存在固有延迟(通常 10–200ms)。

关键指标映射断层示例

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Sys: %v KB, HeapAlloc: %v KB\n", 
    m.Sys/1024, m.HeapAlloc/1024) // Sys 包含 mmap/madvise 内存,但不反映 cgroup limit 超限状态

逻辑分析m.Sys 统计进程所有虚拟内存映射(含未驻留页),而 cgroup v2 memory.current 仅统计实际驻留物理页(RSS + cache)。当进程调用 mmap(MAP_ANONYMOUS) 但未写入时,Sys 上升但 memory.current 不变,造成可观测偏差。

映射关系对照表

Go MemStats 字段 cgroup v2 文件 语义一致性 偏差原因
HeapAlloc memory.statfile Go 不统计 page cache
Sys memory.current ⚠️(弱相关) Sys 含虚拟地址空间开销

内存超限检测路径差异

graph TD
    A[Go GC 触发] --> B[读取 /proc/self/status]
    C[cgroup v2 OOM Killer] --> D[内核 memcg charge path]
    B -.->|无感知| D

2.3 GOGC动态调优在cgroup v2受限环境下的失效路径复现

当 Go 程序运行于 cgroup v2 的 memory.max 严格限制下,GOGC 的自动调节机制可能持续误判可用内存,导致 GC 频繁触发甚至 OOMKilled。

失效关键条件

  • cgroup v2 启用 memory.pressure 但 Go 1.21+ 仍未读取该接口
  • runtime.ReadMemStats 报告的 Sys 内存包含未被 cgroup 识别的 page cache
  • GOGC=off 时仍会因 heap_live ≥ memory.max × 0.95 强制触发 GC

复现实例(Docker + cgroup v2)

# 启动受限容器(cgroup v2 模式)
docker run --cgroup-version 2 --memory 128m -it golang:1.22-alpine sh -c \
  'echo "GOGC=100" > /etc/environment && go run -gcflags="-m" main.go'

此命令强制进程在 128 MiB 限额内运行;Go 运行时通过 /sys/fs/cgroup/memory.max 读取上限,但计算 heap_trigger 时错误将 MemStats.Alloccgroup.limit_in_bytes 直接比值化,忽略 memory.current 的瞬时水位及 memory.low 的缓冲策略,造成触发阈值漂移。

典型 GC 行为偏差对比

指标 cgroup v1(有效) cgroup v2(失效)
触发依据 MemStats.Alloc > limit × GOGC/100 MemStats.Sys > limit × 0.95(误用 Sys)
压力感知 memory.pressure 但未集成
graph TD
    A[Go runtime 启动] --> B[读取 /sys/fs/cgroup/memory.max]
    B --> C[调用 getMemoryLimit syscall]
    C --> D[计算 heap_trigger = limit * 0.95]
    D --> E[但 MemStats.Sys 包含 mmap 匿名页+page cache]
    E --> F[实际 heap_live << trigger → 提前 GC]

2.4 基于memcg eventfd的实时内存压力捕获与Go信号量联动实践

Linux cgroup v2 的 memory.events 文件支持通过 eventfd 注册内存压力事件(如 highlowoom),实现零轮询实时通知。

核心联动机制

  • 内核在内存压力触发时,向绑定的 eventfd 写入 8 字节计数;
  • Go 程序用 epoll 监听该 fd,结合 semaphore.Weighted 动态限流。
// 创建 eventfd 并绑定到 memory.events
efd := unix.Eventfd(0, unix.EFD_CLOEXEC)
unix.Write(int(efd), []byte{0,0,0,0,0,0,0,1}) // 初始化
unix.EpollCtl(epollfd, unix.EPOLL_CTL_ADD, int(efd), &unix.EpollEvent{
    Events: unix.EPOLLIN,
    Fd:     int32(efd),
})

此处 eventfd 作为内核→用户态的轻量信号通道;EPOLLIN 表示事件就绪,避免 busy-wait。

Go信号量协同策略

压力等级 信号量权重调整 触发条件
low +10 内存充裕
high -5 回收压力上升
oom -20(瞬时阻塞) OOM Killer待触发
graph TD
    A[memcg memory.events] -->|write 8B| B(eventfd)
    B --> C{epoll_wait}
    C -->|EPOLLIN| D[Go goroutine]
    D --> E[semaphore.Acquire/Release]

该机制将内核内存状态毫秒级映射为应用层并发控制信号。

2.5 容器内Golang服务OOMKilled前兆识别与优雅降级兜底方案

内存使用监控信号捕获

Go 运行时提供 runtime.ReadMemStats,可高频采样 RSS 与堆内存趋势:

func checkOOMPremonition() bool {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    // 触发降级阈值:RSS > 容器限制的 85%
    return uint64(m.Sys) > atomic.LoadUint64(&containerLimitBytes)*85/100
}

逻辑分析:m.Sys 包含堆、栈、全局变量及 OS 映射内存总和,比 m.Alloc 更贴近容器 RSS;containerLimitBytes 需通过 /sys/fs/cgroup/memory/memory.limit_in_bytes 初始化。

降级策略分级响应

级别 触发条件 动作
L1 RSS > 75% 关闭非核心定时任务
L2 RSS > 85% 拒绝新 HTTP 连接(HTTP 503)
L3 RSS > 92% 主动 GC + 释放缓存池

自适应降级流程

graph TD
    A[每秒采样 MemStats] --> B{RSS > 85%?}
    B -->|是| C[触发L2:返回503]
    B -->|否| A
    C --> D{持续30s?}
    D -->|是| E[执行runtime.GC()]

第三章:Kubernetes HPA在IM语音场景下的指标可信度重构

3.1 自定义指标Adapter对接/metrics/resource(而非/prometheus)的协议适配实践

Kubernetes Metrics Server 的 metrics.k8s.io API 要求 Adapter 实现 /metrics/resource 端点,返回结构化资源指标(如 cpu, memory),而非 Prometheus 原生格式。

数据同步机制

Adapter 需周期性拉取底层监控系统(如 OpenTelemetry Collector)的资源维度指标,并转换为 Kubernetes ResourceMetric 格式:

# 示例响应体(/metrics/resource/nodes)
- timestamp: "2024-06-15T08:30:00Z"
  window: "30s"
  usage:
    cpu: "123m"
    memory: "456Mi"

逻辑分析:timestamp 必须严格对齐采集窗口起始时间;window 表示指标统计时长,影响 HPA 计算精度;usage 中单位必须符合 K8s Quantity 规范(如 m 表示 millicores,Mi 表示 mebibytes)。

关键字段映射表

Prometheus 标签 Resource Metric 字段 说明
node="ip-10-0-1-5" name: "ip-10-0-1-5" 节点名需与 Node 对象 name 一致
container_memory_working_set_bytes memory 需除以 1024² 并四舍五入为 Mi

协议校验流程

graph TD
  A[HTTP GET /metrics/resource/nodes] --> B{鉴权通过?}
  B -->|否| C[401 Unauthorized]
  B -->|是| D[查询 OTel backend]
  D --> E[转换为 ResourceMetricList]
  E --> F[序列化 JSON + 设置 Content-Type: application/json]

3.2 面向语音流的QPS+并发连接数+端到端P99延迟三维HPA策略建模

传统基于CPU或内存的HPA在实时语音场景下失效:突发性语音流导致QPS尖峰、长连接维持高并发、P99延迟敏感度远超平均值。需融合三维度指标构建动态扩缩容决策面。

核心指标协同建模逻辑

  • QPS:反映请求吞吐压力(单位:req/s),触发快速扩容阈值;
  • 并发连接数:表征资源驻留开销(WebSocket/RTCP连接数),决定最小副本基线;
  • 端到端P99延迟:从ASR请求注入到文本返回的第99百分位耗时(ms),作为熔断与紧急缩容依据。

HPA自定义指标配置示例

# metrics.yaml —— Kubernetes Custom Metrics Adapter 配置片段
- type: Pods
  pods:
    metric:
      name: voice_qps
    target:
      type: AverageValue
      averageValue: 120 # 每Pod目标QPS上限
- type: Pods
  pods:
    metric:
      name: voice_p99_latency_ms
    target:
      type: Value
      value: 800 # P99 >800ms 触发紧急扩容

逻辑分析:voice_qps采用AverageValue确保负载均衡,而voice_p99_latency_ms使用绝对Value实现低延迟硬约束;二者通过HPA v2 API加权融合,权重由在线A/B测试动态校准。

三维决策空间示意

QPS区间 并发连接数 P99延迟 动作
缩容至minReplicas
100–140 600–1200 700–900ms +1 replica
>150 >1300 >950ms 熔断+告警+扩容
graph TD
    A[语音流接入] --> B{QPS >120?}
    B -->|是| C{并发连接 >1000?}
    B -->|否| D[维持当前副本]
    C -->|是| E{P99 >800ms?}
    E -->|是| F[扩容+延迟根因分析]
    E -->|否| G[仅扩容]

3.3 kube-state-metrics无法反映Go GC暂停导致的瞬时CPU spike盲区修复

kube-state-metrics 仅采集 Kubernetes API 对象状态(如 Pod phase、ReplicaSet ready replicas),不采集容器运行时指标,因此对 Go runtime 级别瞬态事件(如 STW GC 暂停引发的 CPU 利用率尖刺)完全不可见。

数据同步机制

其指标采集基于 client-go 的 Informer 缓存,周期性 List/Watch API Server,无实时 profiling 能力,与 cAdvisor 或 runtime/metrics 包无集成。

修复路径对比

方案 是否覆盖 GC STW 延迟 部署复杂度
扩展 kube-state-metrics ❌(架构隔离) 高(需 fork + runtime 注入)
Sidecar + go:linkname 采集 runtime.ReadMemStats
Prometheus process_cpu_seconds_total + go_gc_duration_seconds ✅(间接推断) ~15s
// 通过 runtime/metrics 在应用侧暴露 GC 暂停直方图
import "runtime/metrics"
func init() {
    // 注册指标:/gc/pause:seconds
    metrics.Register("gc/pause:seconds", metrics.KindFloat64Histogram)
}

该代码启用 Go 1.21+ 内置指标导出,go_gc_pause_seconds 直方图可精准捕获每次 STW 时长(含 sub-ms 精度),配合 Prometheus rate() 计算单位时间暂停频次,填补 kube-state-metrics 的语义盲区。

graph TD A[Pod 启动] –> B[Go runtime 启动] B –> C{GC 触发 STW} C –> D[记录 go_gc_pause_seconds] D –> E[Prometheus scrape] E –> F[Alert on histogram_quantile>10ms]

第四章:Pod QoS Class在高实时性IM语音服务中的反模式破局

4.1 Guaranteed类Pod因CPU硬限引发的gRPC流式语音抖动实证分析

现象复现配置

# pod.yaml 关键资源约束
resources:
  limits:
    cpu: "1000m"   # 硬性上限,触发CFS bandwidth throttling
    memory: "2Gi"
  requests:
    cpu: "1000m"   # request=limit → Guaranteed QoS
    memory: "2Gi"

该配置使Kubernetes将Pod划为Guaranteed类,但cpu: 1000m在高负载下触发内核CFS throttled事件,导致gRPC流式语音帧调度延迟突增。

核心根因链

  • CPU硬限 → CFS周期性配额耗尽 → 进程被throttle → gRPC Server端Write()阻塞 → 音频缓冲区抖动(Jitter > 40ms)

监控佐证数据

指标 正常值 抖动发生时
container_cpu_cfs_throttled_periods_total 0 ↑ 127/s
gRPC stream latency p95 18ms 63ms
graph TD
  A[gRPC Audio Stream] --> B[Write to HTTP/2 Frame]
  B --> C{CPU Quota Available?}
  C -->|Yes| D[Low-latency Write]
  C -->|No| E[CFS Throttle → Kernel Sleep]
  E --> F[Buffer Underrun → Jitter]

4.2 Burstable类Pod在突发语音编解码负载下被kubelet OOMKill的优先级陷阱

当语音实时编解码服务(如WebRTC SFU)以Burstable QoS运行时,内存使用呈现强脉冲特性:静音期低至150Mi,VAD激活后瞬时飙升至1.2Gi。

内存压力触发路径

# pod.yaml 片段:典型Burstable配置
resources:
  requests:
    memory: "256Mi"   # kubelet仅据此计算节点allocatable占比
  limits:
    memory: "2Gi"

requests.memory 是kubelet OOM评分(oom_score_adj)的核心输入——值越小,OOM优先级越高。该Pod在节点内存压测中实际占用1.8Gi时,仍因request偏低被优先kill。

OOM优先级关键参数对照表

指标 影响
memory.request / node.allocatable 0.8% 触发oom_score_adj = 1000 - (1000 × ratio) → 得分992(极高风险)
memory.usage / memory.limit 90% 不影响评分,仅触发cgroup memory.max enforcement

负载突增时的调度失配

graph TD
  A[语音VAD检测到人声] --> B[FFmpeg线程池扩容]
  B --> C[堆内存瞬时增长300%]
  C --> D[kubelet周期性采样未捕获峰值]
  D --> E[OOMKilled时usage已超limit但request未变]

4.3 BestEffort类Pod在eBPF观测链路中缺失cgroup path导致的监控断点修复

BestEffort QoS 类 Pod 默认不绑定到 kubepods/besteffort/ 下的 cgroup v2 路径,eBPF 程序通过 bpf_get_cgroup_id() 获取的 cgroup ID 无法映射到有效路径,造成容器元数据关联失败。

根因定位

  • eBPF 程序依赖 /proc/<pid>/cgroupbpf_iter_cgroup 枚举路径
  • BestEffort Pod 的 cgroup.procs 存在于根级 cgroup2(如 /sys/fs/cgroup/kubepods.slice/...),但路径未被 kubelet 显式标注

修复策略:动态回溯补全

// 在 eBPF tracepoint 程序中增强 cgroup path 解析
u64 cgid = bpf_get_cgroup_id(0);
struct cgroup_path_key key = {.id = cgid};
char *path = bpf_map_lookup_elem(&cgroup_path_cache, &key);
if (!path) {
    // 回退:遍历祖先 cgroup 寻找含 "kubepods" 的路径
    bpf_override_cgroup_path(cgid, CGROUP_PATH_FALLBACK);
}

逻辑说明:当直接查表失败时,调用 bpf_override_cgroup_path() 触发内核级路径推导,参数 CGROUP_PATH_FALLBACK 指示遍历 cgroup.subtree_control 链并匹配 kubepods.*besteffort 正则模式。

补丁效果对比

指标 修复前 修复后
BestEffort Pod 监控覆盖率 32% 99.8%
cgroup path 解析延迟 N/A(超时丢弃) ≤12μs
graph TD
    A[tracepoint: sched:sched_process_exec] --> B{cgroup_id → path?}
    B -->|Yes| C[关联Pod元数据]
    B -->|No| D[触发祖先路径回溯]
    D --> E[匹配kubepods.*besteffort]
    E --> C

4.4 基于runtime.GC()触发时机与QoS Class联动的主动内存释放调度机制

核心设计思想

将 Pod 的 QoS Class(Guaranteed/Burstable/BestEffort)作为 GC 触发优先级信号,结合 runtime.ReadMemStats() 实时监控堆增长速率,在内存压力上升早期主动调用 runtime.GC(),避免 OOMKilled。

调度策略映射表

QoS Class GC 触发阈值(HeapAlloc / HeapSys) 是否允许并发标记
Guaranteed 85%
Burstable 70% ✅(限速)
BestEffort 50% ❌(STW 模式)

主动释放示例代码

func triggerGCByQoS(qos string, memStats *runtime.MemStats) {
    ratio := float64(memStats.HeapAlloc) / float64(memStats.HeapSys)
    switch qos {
    case "Guaranteed":
        if ratio > 0.85 {
            runtime.GC() // 强制全量GC,降低碎片率
        }
    case "Burstable":
        if ratio > 0.70 {
            debug.SetGCPercent(50) // 降低GC频次但提升回收深度
            runtime.GC()
        }
    }
}

逻辑分析HeapAlloc/HeapSys 比值反映实际使用率;debug.SetGCPercent(50) 在 Burstable 场景下压缩触发阈值,平衡延迟与内存驻留;runtime.GC() 是阻塞调用,需在低负载协程中异步执行。

执行流程

graph TD
    A[读取QoS Class] --> B[采集MemStats]
    B --> C{HeapAlloc/HeapSys > 阈值?}
    C -->|是| D[动态调整GCPercent]
    C -->|否| E[跳过]
    D --> F[runtime.GC()]

第五章:从血泪史到生产就绪:Golang IM语音服务容器化黄金配置清单

容器镜像瘦身:多阶段构建+静态链接实战

早期我们使用 golang:1.21-alpine 作为基础镜像,但因 CGO_ENABLED=1 导致 libc 依赖混乱,语音编解码模块(基于 Opus 1.4)在 Alpine 上崩溃。最终采用 golang:1.21-bullseye 构建 + scratch 运行的双阶段方案,并显式设置 CGO_ENABLED=0-ldflags="-s -w -buildmode=pie"。镜像体积从 892MB 压缩至 14.3MB,启动耗时降低 67%。

CPU 与内存硬限:避免语音抖动的临界值设定

语音服务对延迟极度敏感,K8s 中曾因未设 resources.limits.cpu 导致节点 CPU Throttling,端到端 PTT(Push-to-Talk)延迟峰值达 420ms。经 72 小时压测验证,确定黄金配比: 服务组件 requests.cpu limits.cpu requests.memory limits.memory
voice-gateway 800m 1200m 512Mi 768Mi
opus-processor 1500m 2000m 1Gi 1.5Gi

网络栈优化:UDP socket 复用与 ConnTrack 调优

语音流走 UDP,需启用 SO_REUSEPORT 并在 Go 代码中显式调用 syscall.SetsockoptInt32(fd, syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)。同时在宿主机执行:

echo 65536 > /proc/sys/net/netfilter/nf_conntrack_max
echo 30 > /proc/sys/net/netfilter/nf_conntrack_udp_timeout_stream

避免 ConnTrack 表溢出导致语音包被静默丢弃。

健康探针设计:区分 Liveness 与 Readiness 的语义边界

Liveness 探针仅检查进程存活(GET /healthz),而 Readiness 探针必须验证 Opus 编解码器加载状态、Redis 连接池健康度及 STUN 服务器连通性:

func (h *HealthzHandler) readinessCheck() error {
    if !opus.IsLoaded() { return errors.New("opus not loaded") }
    if err := redisPool.Ping(context.Background()).Err(); err != nil { return err }
    if !stunClient.IsReachable() { return errors.New("stun unreachable") }
    return nil
}

日志与追踪:结构化日志注入 trace_id 与 call_id

所有语音会话日志强制携带 call_id(UUIDv4)和 trace_id(OpenTelemetry 生成),并通过 logfmt 格式输出:

ts=2024-06-15T08:23:41.221Z level=info call_id=3a7f1b2c-8d5e-4f1a-9b0c-2d8e7f1a9b0c trace_id=4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d event=opus_encode_start duration_ms=12.4 codec=opus/48000/1

持久化配置热更新:etcd 监听 + atomic config reload

语音服务关键参数(如 Opus bitrates、JitterBuffer ms、DTLS 超时)全部托管于 etcd /im/voice/config 路径。使用 go.etcd.io/etcd/client/v3Watch() 接口监听变更,并通过 sync.RWMutex + atomic.StorePointer 实现零停机配置切换,实测热更新耗时

安全加固:非 root 运行 + capabilities 最小化

Dockerfile 显式声明:

RUN addgroup -g 6101 -f voice && adduser -S voice -u 6101
USER voice:voice
DROP --all-capabilities
ADD CAP_NET_BIND_SERVICE

规避 CVE-2022-29154 类漏洞,且允许绑定 80/443 端口而无需 root。

故障注入验证:Chaos Mesh 模拟网络分区与 CPU 扰动

在预发环境部署 Chaos Mesh 实验:持续 5 分钟模拟 200ms 网络延迟 + 30% 丢包率,同时对 voice-gateway Pod 注入 stress-ng --cpu 4 --timeout 300s。服务自动触发降级策略(切至 SILK 编码 + 增大 JitterBuffer 至 200ms),语音可懂度保持 ≥ 89%(MOS 测试结果)。

热爱算法,相信代码可以改变世界。

发表回复

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