Posted in

【限时公开】某S级漫画AppGo后端故障复盘文档(2023.11.17雪崩事件:etcd租约失效→服务注册坍塌→全站503)

第一章:【限时公开】某S级漫画AppGo后端故障复盘文档(2023.11.17雪崩事件:etcd租约失效→服务注册坍塌→全站503)

2023年11月17日14:22起,某S级漫画App后端集群突发大规模503响应,持续时长18分钟,核心API成功率从99.99%骤降至12.3%,影响全量用户阅读、支付及登录链路。根因定位为etcd集群租约续期失败引发的级联雪崩。

故障时间线与关键现象

  • 14:22:07 — etcd leader节点CPU突增至98%,/health端点返回503;
  • 14:22:41 — 所有Go微服务实例的etcd租约(TTL=30s)批量过期,服务发现注册表清空;
  • 14:23:15 — 网关层持续收到no healthy upstream错误,DNS缓存未生效(SRV记录依赖etcd);
  • 14:40:22 — 全链路恢复,SLA达标率回升至99.97%。

etcd租约失效的直接诱因

经排查,etcd v3.5.4集群中存在租约续期请求积压:

  • etcdctl --endpoints=localhost:2379 endpoint status --write-out=table 显示leader节点Failed requests达12,486次/分钟;
  • 日志中高频出现lease keepalive failed: context deadline exceeded;
  • 根本原因为运维误操作:在滚动升级期间,将--heartbeat-interval=100ms错误配置为--heartbeat-interval=1000ms,导致租约心跳超时阈值翻倍,客户端无法及时续租。

修复与临时规避步骤

立即执行以下操作恢复服务:

# 1. 强制刷新所有服务实例租约(需在每台Go服务节点执行)
curl -X POST http://localhost:8080/actuator/refresh-etcd-lease \
  -H "Content-Type: application/json" \
  -d '{"lease-id":"694d4a7f7e1b2c3d","ttl":30}'

# 2. 重置etcd心跳间隔(重启前必须)
etcd --heartbeat-interval=100ms --election-timeout=1000ms \
  --name infra-0 --initial-advertise-peer-urls http://10.0.1.10:2380 \
  --listen-peer-urls http://0.0.0.0:2380

关键改进项清单

  • ✅ 引入租约存活探针:Go服务启动后每5秒向etcd写入/health/{service}/{id}带TTL的key;
  • ✅ 网关层降级策略:当etcd不可用时,自动切换至本地内存缓存的服务列表(TTL=60s);
  • ✅ 运维配置校验:CI流水线增加etcd参数合法性检查(如heartbeat-interval < election-timeout/2)。

第二章:Go微服务注册中心机制深度解析与etcd租约模型实践

2.1 etcd v3 Lease机制原理与Go clientv3租约生命周期管理

etcd v3 的 Lease 是一个带TTL的分布式租约抽象,用于实现键值对的自动过期与会话保活。

核心设计思想

  • 租约独立于键存在,多个 key 可绑定同一 lease ID
  • 服务端维护 lease TTL 计时器,客户端需定期 KeepAlive 续期
  • 租约过期后,关联 key 被原子性删除(由 mvcc backend 触发)

Go clientv3 生命周期管理示例

cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
// 创建 10s 租约
resp, _ := cli.Grant(context.TODO(), 10)
leaseID := resp.ID

// 关联 key 到 lease
_, _ = cli.Put(context.TODO(), "foo", "bar", clientv3.WithLease(leaseID))

// 启动 KeepAlive 流(自动续期)
kaCh, _ := cli.KeepAlive(context.TODO(), leaseID)
go func() {
    for range kaCh { /* 心跳成功 */ }
}()

Grant() 返回 lease ID 与初始 TTL;WithLease() 将写操作绑定租约;KeepAlive() 返回 channel 并在后台发起长连接续期请求,失败时 channel 关闭。

租约状态流转(mermaid)

graph TD
    A[Created] -->|Grant| B[Active]
    B -->|KeepAlive OK| B
    B -->|TTL expires| C[Expired]
    B -->|Revoke| D[Revoked]
    C --> E[Auto-deletion of bound keys]
    D --> E
操作 是否阻塞 是否可重入 说明
Grant 否 否 创建新租约,返回唯一 ID
KeepAlive 否 是 返回流式 channel,支持并发调用
Revoke 否 是 立即终止租约并清理关联 key

2.2 Go服务注册/注销的原子性保障:Watch+Lease+TTL协同实现

服务注册与注销若非原子操作,极易引发“幽灵节点”或“服务瞬断”。Go客户端通过三机制协同破局:

Lease驱动生命周期

leaseID, err := cli.Grant(context.TODO(), 10) // TTL=10s,Lease ID唯一标识租约
if err != nil { panic(err) }
// 注册时绑定Lease,注销即自动释放
_, err = cli.Put(context.TODO(), "/services/api-01", "10.0.0.1:8080", clientv3.WithLease(leaseID))

Grant()生成带TTL的Lease;WithLease()使key依赖租约存活——租约过期则key自动删除,避免人工清理遗漏。

Watch监听状态跃迁

watchChan := cli.Watch(context.TODO(), "/services/", clientv3.WithPrefix())
for wresp := range watchChan {
    for _, ev := range wresp.Events {
        if ev.Type == clientv3.EventTypeDelete {
            log.Printf("服务 %s 已注销(Lease到期或主动撤销)", ev.Kv.Key)
        }
    }
}

Watch监听前缀路径,实时捕获PUT/DELETE事件,实现状态变更零延迟感知。

协同时序保障(mermaid)

graph TD
    A[客户端申请Lease] --> B[Put服务节点+Lease绑定]
    B --> C[后台TTL续期或到期]
    C --> D{Lease有效?}
    D -->|是| E[节点持续可见]
    D -->|否| F[etcd自动删除key]
    F --> G[Watch触发Delete事件]
机制 作用域 原子性贡献
Lease 数据生存周期 消除手动注销失败导致的残留
Watch 状态变更通知 保障下游及时感知终态
TTL 自动续约/过期 防止网络分区引发的假在线

2.3 租约续期失败的典型场景建模:网络抖动、GC停顿与goroutine阻塞实测分析

租约续期失败并非孤立异常,而是系统性压力在不同维度的外显。我们通过可控注入模拟三类典型扰动:

网络抖动模拟

# 使用 tc 模拟 100ms ±30ms 抖动,丢包率 2%
tc qdisc add dev eth0 root netem delay 100ms 30ms loss 2%

该配置使 etcd 客户端 Lease.KeepAlive() RPC 延迟方差扩大,触发默认 6s 续期超时(LeaseTTL=10s,renewDeadline=6s)。

GC停顿影响观测

GC Pause (ms) 续期成功率 触发失败阈值
99.98% —
12–18 73.2% 超过 renewDeadline
>25 12.6% 连续丢失 2+ 心跳

goroutine 阻塞链路

select {
case <-ctx.Done(): // 可能因 channel full 或锁竞争阻塞
    return errors.New("context canceled")
case <-leaseRespChan: // 阻塞在此处将跳过续期逻辑
    // 处理响应...
}

当 leaseRespChan 缓冲区满且无 goroutine 消费时,续期协程永久挂起,租约自然过期。

graph TD A[Lease Renew Goroutine] –> B{Send to leaseRespChan} B –>|Channel Full| C[Block Indefinitely] B –>|Normal| D[Process Response] C –> E[Lease Expiry]

2.4 基于go.etcd.io/etcd/client/v3的租约健康探测与自动降级代码模板

核心设计思路

利用 etcd v3 的 Lease 机制实现服务心跳续约,结合 Watch 通道监听租约过期事件,触发服务自动降级。

健康探测与降级流程

// 初始化带租约的客户端注册
leaseResp, err := cli.Grant(ctx, 10) // 10秒TTL租约
if err != nil { panic(err) }

_, err = cli.Put(ctx, "/services/api", "alive", clientv3.WithLease(leaseResp.ID))
if err != nil { panic(err) }

// 启动后台续租协程
go func() {
    for range time.Tick(3 * time.Second) {
        if _, err := cli.KeepAliveOnce(ctx, leaseResp.ID); err != nil {
            log.Warn("lease keepalive failed, triggering fallback")
            setFallbackMode(true) // 降级开关
            return
        }
    }
}()

逻辑分析:Grant() 创建初始租约;WithLease() 绑定键值生命周期;KeepAliveOnce() 主动续租失败即判定节点失联。setFallbackMode() 应对接口熔断或本地缓存兜底策略。

关键参数对照表

参数 推荐值 说明
TTL 10s 避免频繁续约,兼顾故障发现时效
KeepAlive Interval 3s 确保至少3次续租机会(>1个TTL周期)
Fallback Trigger 续租返回 ErrLeaseNotFound 或 context timeout 明确区分网络抖动与真实失联
graph TD
    A[启动服务] --> B[申请Lease]
    B --> C[注册服务键]
    C --> D[启动KeepAlive协程]
    D --> E{续租成功?}
    E -->|是| D
    E -->|否| F[激活降级逻辑]
    F --> G[关闭写入/切换只读模式]

2.5 漫画业务场景下服务实例元数据(章节缓存策略、CDN节点标签)与租约绑定最佳实践

在高并发漫画分发场景中,章节缓存策略需与 CDN 节点地理标签、服务实例租约生命周期强协同。

缓存粒度与租约对齐

漫画章节(如 comic/1024/chapter/87/v2)应绑定实例级元数据:

  • cache-ttl: 3600s(匹配租约默认续期周期)
  • cdn-region: shanghai-2a(由实例启动时注入的节点标签)
  • lease-id: le-9f3a1b(与注册中心租约唯一关联)

元数据注入示例(Spring Cloud Alibaba)

# application.yml
spring:
  cloud:
    nacos:
      discovery:
        metadata:
          cache-strategy: "chapter-aware"
          cdn-tag: "${CDN_NODE_TAG:beijing-1c}"
          lease-grace-period: 90 # 秒,须 ≤ 注册中心心跳超时

该配置确保服务重启时自动携带地域化 CDN 标签,并将租约宽限期设为 90s,避免因网络抖动触发误摘除;cdn-tag 支持环境变量覆盖,适配灰度发布。

关键绑定关系表

元数据字段 来源 绑定目标 生效时机
cdn-tag 容器启动环境变量 CDN 路由调度器 实例注册瞬间
lease-id Nacos 服务端分配 缓存失效广播通道 首次心跳成功后
cache-strategy 应用配置中心推送 网关缓存策略引擎 配置监听触发

租约驱动的缓存刷新流程

graph TD
  A[实例心跳上报] --> B{租约有效?}
  B -->|是| C[维持 CDN 节点标签绑定]
  B -->|否| D[触发 /cache/invalidate?lease=le-9f3a1b]
  C --> E[网关按 cdn-tag 路由并复用本地缓存]

第三章:Go服务发现链路雪崩根因定位与可观测性加固

3.1 使用pprof+trace+metrics构建Go服务注册链路全景监控视图

Go服务注册链路涉及服务发现、健康检查、心跳上报与元数据同步,需多维可观测性协同分析。

三类观测能力协同定位瓶颈

  • pprof:采集CPU/heap/block/profile,定位注册逻辑阻塞点
  • trace(如go.opentelemetry.io/otel):追踪Register→Deregister→Watch跨节点调用链
  • metrics(Prometheus client):暴露service_registry_requests_total等指标

集成示例(启动时注入)

import (
    "net/http"
    "runtime/pprof"
    "go.opentelemetry.io/otel/sdk/trace"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func initTracing() {
    // OpenTelemetry trace exporter(简化版)
    tp := trace.NewProvider(trace.WithSyncer(...))
    otel.SetTracerProvider(tp)
}

func main() {
    // pprof HTTP handler
    http.HandleFunc("/debug/pprof/", pprof.Index)

    // metrics endpoint
    http.Handle("/metrics", promhttp.Handler())

    // trace propagation middleware for registry calls
    http.ListenAndServe(":8080", nil)
}

该代码启用标准pprof端点、Prometheus指标导出,并为注册请求注入trace上下文;/debug/pprof/默认启用所有profile类型,promhttp.Handler()暴露registry_up{job="svc"}等标签化指标。

监控维度对齐表

维度 工具 关键信号
时延热点 pprof runtime.gopark, net/http阻塞调用栈
跨程路径 trace registry.register.duration span duration
频次趋势 metrics registry_operations{op="register",status="success"}
graph TD
    A[Service Start] --> B[pprof.Init]
    A --> C[OTel Tracer Init]
    A --> D[Prometheus Registry]
    B --> E[/debug/pprof/]
    C --> F[Trace Context Propagation]
    D --> G[/metrics]
    E & F & G --> H[全景视图:时延+路径+频次聚合]

3.2 基于OpenTelemetry的etcd Watch事件延迟与lease-grant耗时追踪实战

数据同步机制

etcd 的 Watch 机制采用 long polling + event buffer 模式,客户端订阅 key 变更后,服务端在 lease 到期、key 修改或网络抖动时可能引入可观测延迟。

OpenTelemetry 集成要点

  • 使用 otelgrpc 拦截器注入 span context
  • 自定义 WatchOption 注入 trace ID 到 watch 请求元数据
  • 对 LeaseGrant RPC 单独打点,标注 lease-ttl 和 granted-ttl
// 在 etcd client 初始化时注入 tracing 拦截器
client, _ := clientv3.New(clientv3.Config{
    Endpoints:   []string{"localhost:2379"},
    DialOptions: []grpc.DialOption{
        otelgrpc.WithTracerProvider(tp),
        grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
        grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()),
    },
})

该配置使所有 gRPC 调用(含 Watch 和 LeaseGrant)自动创建 span;otelgrpc 会提取 traceparent 并透传至 etcd server,实现跨进程链路对齐。

指标名称 语义说明 典型 P95 值
etcd.watch.delay.ms 客户端收到事件距变更实际发生时间 120 ms
etcd.lease.grant.ms Lease 分配全流程耗时 8 ms
graph TD
    A[Client Watch Request] --> B[OpenTelemetry Context Inject]
    B --> C[etcd Server Receive & Propose]
    C --> D[Apply to Raft Log]
    D --> E[Notify Watcher Queue]
    E --> F[Send Event via Stream]
    F --> G[Client Receive & Span Finish]

3.3 Go goroutine泄漏导致etcd连接池枯竭的内存dump分析与修复验证

问题现象定位

通过 pprof 抓取 heap profile,发现 runtime.gopark 占用堆内 78% 的 goroutine,且多数阻塞在 clientv3.(*retryClusterClient).Get 调用链中。

关键泄漏点代码

func watchConfig(client *clientv3.Client, key string) {
    rch := client.Watch(context.Background(), key) // ❌ 缺少 cancel context
    for wresp := range rch { // goroutine 永不退出
        process(wresp)
    }
}

分析:context.Background() 无超时/取消机制,Watch 返回的 channel 持续阻塞;若 etcd 网络抖动或节点失联,goroutine 将永久挂起,复用连接池中的底层 TCP 连接,最终耗尽 clientv3.Config.DialTimeout 所约束的连接数。

修复后对比(连接池状态)

指标 修复前 修复后
平均活跃 goroutine 1240 42
etcd 连接数(max) 512 32

修复方案流程

graph TD
    A[注入 context.WithTimeout] --> B[defer cancel()]
    B --> C[Watch 配置超时重试]
    C --> D[错误时关闭 rch]

第四章:Go高可用服务注册治理方案设计与落地

4.1 多级租约保活策略:客户端心跳+服务端兜底续期+离线缓存注册信息

在分布式服务注册场景中,单一心跳机制易受网络抖动或客户端崩溃影响导致误摘除。本策略构建三层防护体系:

客户端主动心跳(最小粒度保活)

# 心跳间隔与超时需满足:interval < lease_ttl / 3
requests.post("http://registry/heartbeat", 
              json={"service_id": "svc-01", "ttl": 30},  # ttl单位:秒
              timeout=2)

逻辑分析:客户端每10秒发送一次心跳,服务端将租约TTL重置为30秒;若连续3次未收到(即30秒内无响应),触发自动剔除。

服务端兜底续期(防客户端失联)

续期触发条件 执行动作 最大续期次数
租约剩余 自动延长TTL至20秒 2次
检测到客户端IP变更 保留租约但标记“待验证” —

离线缓存注册信息(终端断网保障)

graph TD
    A[客户端启动] --> B{本地磁盘存在有效cache?}
    B -->|是| C[加载缓存注册信息]
    B -->|否| D[发起首次注册]
    C --> E[后台异步校验服务端一致性]

该设计确保网络中断≤60秒时服务持续可见,同时避免脑裂风险。

4.2 基于go-micro或Kratos的注册中心熔断器设计:etcd不可用时的本地服务发现Fallback

当 etcd 集群不可用时,服务注册/发现链路中断,需启用本地缓存 fallback 机制保障核心调用连续性。

熔断触发与降级策略

  • 监控 etcd 连接健康状态(clientv3.Get(ctx, "health") 超时 ≥3次)
  • 自动切换至内存缓存 sync.Map 中的最近有效服务实例列表
  • 同步启动后台 goroutine 尝试恢复 etcd 连接

本地缓存同步机制

// 初始化带 TTL 的本地服务缓存(Kratos 示例)
var localCache = cache.New(cache.WithTTL(30 * time.Second))

func fallbackDiscovery(serviceName string) ([]*registry.ServiceInstance, error) {
    if instances, ok := localCache.Get(serviceName); ok {
        return instances.([]*registry.ServiceInstance), nil // 类型安全断言
    }
    return nil, errors.New("no cached instances")
}

cache.WithTTL 控制本地缓存有效期,避免 stale 实例;sync.Map 替代 map 保证并发安全;errors.New 显式标识 fallback 失败场景。

熔断状态流转(mermaid)

graph TD
    A[etcd Healthy] -->|心跳失败| B[Open 状态]
    B --> C[尝试本地缓存]
    C -->|命中| D[返回缓存实例]
    C -->|未命中| E[返回 ErrNoInstances]
    D --> F[异步刷新 etcd]
组件 触发条件 fallback 行为
go-micro v4 registry.ErrRegistry 调用 fallback.Registry
Kratos v2.7+ registry.ErrUnreachable 使用 cache.Registry

4.3 Go服务启动阶段注册超时控制与幂等重试机制(含context.WithTimeout与backoff实现)

服务启动时向注册中心(如etcd或Consul)注册常因网络抖动或依赖未就绪而失败。直接重试易引发雪崩,需结合超时控制与退避策略。

超时封装:启动注册的兜底防线

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := registerToEtcd(ctx, serviceInfo)

WithTimeout 创建带截止时间的子上下文,5s 是注册操作最大容忍耗时;超时后自动触发 cancel(),终止阻塞调用并释放资源。

幂等重试:指数退避 + 错误分类

backoff := backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)
err := backoff.Retry(func() error {
    return registerToEtcd(ctx, serviceInfo)
}, ctx)
  • NewExponentialBackOff() 初始间隔 250ms,每次×1.6,上限 1s
  • WithMaxRetries(_, 3) 限制最多尝试3次(含首次)
  • Retry 自动注入 ctx,支持超时/取消中断
策略要素 值域/说明
最大重试次数 3(平衡可靠性与启动延迟)
初始退避间隔 250ms
上下文超时 5s(覆盖3次退避总和)
graph TD
    A[启动注册] --> B{成功?}
    B -->|是| C[完成]
    B -->|否| D[应用退避]
    D --> E[等待间隔]
    E --> F[重试]
    F --> B

4.4 漫画App灰度发布场景下的租约隔离:按业务域(阅读/支付/评论)划分etcd命名空间与配额

在灰度发布中,需保障阅读、支付、评论三大业务域的配置与状态互不干扰。etcd 的租约(Lease)机制成为关键隔离手段。

命名空间分层设计

  • /app/comic/reading/ → 阅读域(含分页策略、缓存开关)
  • /app/comic/payment/ → 支付域(含渠道白名单、风控阈值)
  • /app/comic/comment/ → 评论域(含审核开关、敏感词版本)

租约配额控制示例

# 为阅读域创建带配额的租约(TTL=60s,自动续期)
etcdctl lease grant 60 --lease-id=0x1234567890abcdef
etcdctl put --lease=0x1234567890abcdef /app/comic/reading/feature/enable_v2 true

逻辑分析:--lease-id 绑定键生命周期;灰度服务仅在租约有效期内读取该路径配置,超时即自动失效,避免脏配置残留。60s TTL兼顾稳定性与快速回滚能力。

业务域 默认租约TTL QPS配额 关键配置路径示例
阅读 60s 500 /app/comic/reading/feature/
支付 30s 200 /app/comic/payment/risk/
评论 90s 300 /app/comic/comment/filter/

配置生效流程

graph TD
  A[灰度实例启动] --> B{请求租约ID}
  B --> C[etcd分配业务专属Lease]
  C --> D[写入对应命名空间配置]
  D --> E[Watch监听本域路径变更]
  E --> F[动态加载配置并上报健康状态]

第五章:总结与展望

核心成果回顾

在前四章的实践中,我们完成了基于 Kubernetes 的微服务可观测性平台落地:接入 12 个生产级服务(含订单、支付、库存三大核心系统),日均采集指标数据超 8.6 亿条,Prometheus 实例内存占用稳定控制在 14GB 以内;通过 OpenTelemetry Collector 统一采集链路与日志,Trace 采样率动态调节至 5%–15%,平均端到端延迟下降 37%。某电商大促期间,平台成功提前 11 分钟识别出 Redis 连接池耗尽异常,并自动触发告警与预案脚本,避免了订单创建失败。

技术债与优化瓶颈

当前架构仍存在两处关键约束:其一,日志解析规则硬编码于 Fluentd 配置中,新增服务需人工修改 YAML 并重启 DaemonSet,平均交付周期达 4.2 小时;其二,Grafana 告警策略依赖静态阈值(如 CPU > 90% 持续 5 分钟),在流量突增场景下误报率达 23%。下表对比了三类典型异常的检测效果:

异常类型 当前准确率 改进目标 实施路径
数据库慢查询 68% ≥92% 集成 SQL Plan 分析 + 异常模式聚类
JVM 内存泄漏 74% ≥95% 接入 JFR 流式分析 + GC 日志时序建模
网络抖动 51% ≥88% 部署 eBPF 实时网络指标采集器

下一代可观测性演进路径

我们将分阶段推进技术升级:第一阶段(Q3-Q4 2024)完成 OpenTelemetry 自动化插桩 SDK 接入,覆盖 Spring Boot 3.x 和 Node.js 20+ 运行时,消除手动埋点;第二阶段(2025 Q1)构建 AIOps 分析引擎,基于 LSTM 模型对 15 类核心指标进行多维度关联预测,已在测试环境验证对服务雪崩的提前预警时间达 8.3 分钟。

graph LR
A[原始指标/日志/Trace] --> B[OpenTelemetry Collector]
B --> C{智能路由模块}
C -->|结构化数据| D[Prometheus TSDB]
C -->|非结构化日志| E[Loki]
C -->|分布式追踪| F[Jaeger]
D & E & F --> G[AIOps 异常检测引擎]
G --> H[动态基线生成]
G --> I[根因图谱推理]
H --> J[自适应告警策略]
I --> K[故障影响范围拓扑]

生产环境灰度验证计划

已选定物流调度服务作为首批灰度对象(日均调用量 240 万次),部署双栈采集:旧链路维持原 Prometheus + ELK,新链路启用 OTel Collector + Tempo + Grafana Loki;通过对比分析 7 天内相同异常事件的发现时效、定位步骤数及修复耗时,量化评估 ROI。初步数据显示,新链路将平均 MTTR 从 22.6 分钟压缩至 9.1 分钟。

跨团队协同机制建设

建立“可观测性 SLO 共建小组”,由运维、开发、测试三方轮值负责人组成,每月发布《SLO 健康度报告》,强制要求所有新上线服务必须定义 3 项可测量 SLO(如 “API P99 延迟 ≤ 300ms”、“错误率

开源社区贡献规划

计划向 CNCF OpenTelemetry 项目提交两项 PR:一是增强 Java Agent 对 Dubbo 3.2.x 的 Span 注入兼容性(已通过单元测试覆盖 92% 场景);二是为 Prometheus Remote Write 协议增加 TLS 证书轮换支持(PoC 已在阿里云 ACK 集群验证)。相关代码仓库已开源至 github.com/infra-observability/otel-dubbo-plugin。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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