Posted in

【Golang系统上线前必检清单】:12项生产级Checklist,规避87%的首发故障

第一章:Golang系统上线前的全局认知与风险图谱

Go语言构建的系统虽以简洁、高效和强并发著称,但上线前若缺乏对运行环境、依赖链路与隐性约束的全景审视,极易在生产环境暴露“编译通过却线上崩塌”的典型反模式。全局认知并非仅关注代码逻辑正确性,更需穿透至操作系统层、网络拓扑、资源调度与可观测性基建的交汇点。

核心风险维度识别

  • 静态链接与动态依赖冲突:CGO_ENABLED=1 时,若混用系统级动态库(如 libssl.so),不同发行版 ABI 不兼容将导致 panic;建议统一启用 CGO_ENABLED=0 并使用纯 Go 实现(如 crypto/tls)或 vendoring 静态链接版本
  • goroutine 泄漏的静默积累:未关闭的 HTTP 连接、未回收的 timer、channel 阻塞写入均会持续占用内存与 OS 线程;可通过 pprof 实时采样验证:
    # 启动时注册 pprof 路由(net/http/pprof)
    go tool pprof http://localhost:8080/debug/pprof/goroutine?debug=2
  • 时区与时间精度陷阱time.Now() 在容器中若未挂载 /etc/localtime,默认使用 UTC;高精度定时任务应显式指定 time.Local 或使用 time.Now().In(loc)

关键检查清单

检查项 验证方式 风险等级
内存泄漏趋势 go tool pprof -http=:8081 http://srv/debug/pprof/heap ⚠️⚠️⚠️
文件描述符耗尽 lsof -p $(pidof myapp) \| wc -l 对比 ulimit -n ⚠️⚠️
DNS 解析超时 GODEBUG=netdns=1 ./myapp 2>&1 \| grep 'lookup' ⚠️

环境一致性保障

Docker 构建必须锁定基础镜像 SHA256(而非 latest 标签),并显式声明 Go 版本与构建参数:

FROM golang:1.22.4-alpine3.19@sha256:7a1b... AS builder
# 编译阶段禁用 CGO,确保二进制纯净
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -ldflags="-s -w" -o /app/main .

该配置规避了 Alpine libc 与 glibc 兼容性问题,并生成无调试符号、体积更小的可执行文件。上线前务必在目标环境(相同内核版本、cgroup v2 启用状态)执行 strace -e trace=clone,openat,connect ./main 验证系统调用行为是否符合预期。

第二章:代码质量与构建可靠性保障

2.1 静态分析与Go Vet/Staticcheck实战配置与误报治理

Go Vet 和 Staticcheck 是 Go 生态中不可或缺的静态分析双引擎:前者由 Go 工具链原生提供,后者以高精度和可扩展性见长。

配置统一分析流水线

# 在 .golangci.yml 中启用关键检查器并抑制已知误报
linters-settings:
  staticcheck:
    checks: ["all", "-SA1019"]  # 禁用过时API警告(需业务评估)
  govet:
    check-shadowing: true
    check-unreachable: true

该配置启用全部 Staticcheck 规则但排除 SA1019(避免因兼容性保留的旧 API 被误标),同时激活 govet 的变量遮蔽与不可达代码检测,兼顾安全性与可维护性。

误报治理策略对比

方法 适用场景 维护成本
//nolint:xxx 单行临时豁免
//lint:ignore xxx 模块级规则覆盖(Staticcheck)
配置 exclude-rules 全局模式化过滤(如测试文件)

误报根因识别流程

graph TD
  A[告警触发] --> B{是否在测试/生成代码中?}
  B -->|是| C[加入 exclude-rules]
  B -->|否| D{是否符合业务语义?}
  D -->|是| E[添加 //nolint 注释+原因]
  D -->|否| F[升级规则或提交 issue]

2.2 Go Module依赖收敛与语义化版本锁定策略(含replace与retract实践)

Go Module 通过 go.mod 实现依赖图的确定性构建,语义化版本(SemVer)是收敛的核心契约。

依赖收敛的本质

当多个模块间接依赖同一包的不同次要版本(如 v1.2.0v1.3.1),Go 自动选择最高兼容版本v1.3.1),前提是主版本一致(v1.x.y)。此即最小版本选择(MVS)算法。

replace:本地调试与私有仓库适配

// go.mod 片段
replace github.com/example/lib => ./local-fix
  • => 左侧为原始模块路径,右侧为本地文件系统路径或 Git URL;
  • 仅作用于当前 module 构建,不改变上游依赖声明;
  • 常用于快速验证补丁,但不可提交至生产分支

retract:主动撤回问题版本

// go.mod 中声明
retract [v1.5.0, v1.5.3]
  • 显式标记存在严重缺陷的版本区间;
  • go list -m -versions 将隐藏被 retract 的版本;
  • Go 1.17+ 支持,需配合 go get 自动规避。
场景 replace retract
目的 临时重定向依赖 永久废弃不良版本
生效范围 本 module 构建期 所有下游 module 解析期
graph TD
    A[go build] --> B{解析 go.mod}
    B --> C[应用 replace 规则]
    B --> D[过滤 retract 版本]
    C & D --> E[执行 MVS 算法]
    E --> F[生成 vendor/ 或下载缓存]

2.3 构建可重现性验证:-trimpath、-ldflags -s -w与BuildInfo嵌入校验

Go 构建可重现性(Reproducible Builds)的核心在于消除构建路径、调试符号与时间戳等非确定性因子。

关键编译标志协同作用

  • -trimpath:剥离源码绝对路径,使 runtime.Caller 和 panic 栈迹不泄露构建环境;
  • -ldflags "-s -w"-s 移除符号表,-w 移除 DWARF 调试信息,显著减小体积并消除元数据差异;
  • go build -buildmode=exe -ldflags="-X main.BuildHash=$(git rev-parse HEAD)":注入 Git 提交哈希实现构建溯源。

BuildInfo 嵌入与校验流程

# 构建时嵌入版本与哈希
go build -ldflags="-X 'main.Version=1.2.0' -X 'main.Commit=$(git rev-parse --short HEAD)'" main.go

此命令将字符串字面量注入 main.Versionmain.Commit 变量。-X 要求目标变量为 string 类型且包级导出;若未定义,链接器静默忽略——需在代码中预先声明 var Version, Commit string

验证机制对比

方法 是否校验路径 是否校验符号 是否支持 Git 追溯
sha256sum binary ✅(间接)
go version -m binary ✅(含 BuildInfo)
自定义 main.Commit 检查 ✅(运行时) ✅(运行时)
graph TD
    A[源码] --> B[go build -trimpath -ldflags=\"-s -w -X ...\"]
    B --> C[二进制含纯净 BuildInfo]
    C --> D[CI 环境重构建]
    D --> E[二进制哈希比对]
    E --> F[一致则验证通过]

2.4 单元测试覆盖率深度达标(行覆盖+分支覆盖)与CI门禁阈值设定

单元测试覆盖率需同时保障行覆盖(Line Coverage)分支覆盖(Branch Coverage),二者缺一不可。仅高行覆盖可能掩盖逻辑缺陷(如 if (x > 0 && y < 10) 中短路导致的分支未执行)。

覆盖率双维度校验示例

public boolean isEligible(int age, boolean hasLicense) {
    return age >= 18 && hasLicense; // 分支:T/T、T/F、F/?
}

该方法含 1 行代码、2 个逻辑分支(age>=18 真/假路径,hasLicense 在前者为假时被跳过)。JUnit + Jacoco 需生成 100% 行覆盖 + 100% 分支覆盖 报告,否则 CI 拒绝合入。

CI 门禁阈值建议(企业级实践)

指标 基线阈值 严格模式 说明
行覆盖 ≥ 85% ≥ 95% 核心模块强制 95%
分支覆盖 ≥ 75% ≥ 90% 分支覆盖权重应 ≥ 行覆盖

自动化门禁流程

graph TD
    A[Git Push] --> B[CI Pipeline 启动]
    B --> C[执行 mvn test -Pcoverage]
    C --> D{Jacoco 报告校验}
    D -->|行≥95% ∧ 分支≥90%| E[允许合并]
    D -->|任一不达标| F[阻断并返回详细缺失分支]

2.5 Panic路径全覆盖与recover边界测试:从defer链到goroutine泄漏模拟

defer链的panic传播机制

当panic触发时,当前goroutine中已注册但未执行的defer按后进先出(LIFO)顺序逐个执行,若某defer内调用recover()且位于同一goroutine,则捕获panic并终止传播;否则panic继续向上冒泡直至goroutine崩溃。

func risky() {
    defer func() {
        if r := recover(); r != nil { // ✅ 同goroutine内有效
            log.Println("Recovered:", r)
        }
    }()
    panic("timeout")
}

此处recover()必须在defer函数体内直接调用,且不能跨goroutine——recover()仅对当前goroutine的panic生效,返回nil表示无待恢复panic。

goroutine泄漏模拟场景

以下代码因未处理panic导致子goroutine静默退出,主goroutine无法感知,形成“幽灵goroutine”:

场景 是否可recover 是否泄漏 原因
主goroutine panic + defer recover panic被拦截,资源正常释放
子goroutine panic + 无defer/recover panic终止goroutine,但无通知、无清理
子goroutine panic + defer recover 可主动记录日志或通知
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Worker panicked: %v", r) // 关键:避免goroutine静默消失
        }
    }()
    // ...业务逻辑可能panic
}()

此模式是构建韧性服务的基础——每个长期运行的goroutine都应包裹recover兜底,否则panic将导致不可观测的资源泄漏。

流程示意

graph TD
    A[panic发生] --> B{是否在defer中?}
    B -->|是| C[执行defer链]
    B -->|否| D[goroutine立即终止]
    C --> E{recover()被调用?}
    E -->|是| F[panic终止,继续执行]
    E -->|否| G[继续执行下一个defer]
    G --> H[所有defer执行完 → goroutine终止]

第三章:运行时稳定性核心检查

3.1 Goroutine泄漏检测:pprof/goroutines快照比对与runtime.Stack诊断脚本

Goroutine泄漏常表现为持续增长的协程数,却无对应业务逻辑回收。核心检测路径有二:实时快照比对与堆栈现场诊断。

pprof快照比对法

通过 /debug/pprof/goroutines?debug=2 获取全量 goroutine 栈信息,定时采集两次快照后 diff:

# 采集基线(t0)
curl -s "http://localhost:6060/debug/pprof/goroutines?debug=2" > goroutines_t0.txt
sleep 30
# 采集观察点(t1)
curl -s "http://localhost:6060/debug/pprof/goroutines?debug=2" > goroutines_t1.txt
# 提取 goroutine ID 并统计新增
diff <(grep -o 'goroutine [0-9]*' goroutines_t0.txt | sort) \
     <(grep -o 'goroutine [0-9]*' goroutines_t1.txt | sort) | grep '^>' | wc -l

逻辑说明:debug=2 输出含完整调用栈;grep -o 'goroutine [0-9]*' 提取所有 goroutine ID;diff 比对集合差异,^> 行即为 t1 新增协程。该方法轻量、无侵入,适用于生产环境周期巡检。

runtime.Stack 实时诊断脚本

func dumpLeakingGoroutines() {
    buf := make([]byte, 2<<20) // 2MB buffer
    n := runtime.Stack(buf, true) // true: all goroutines
    fmt.Printf("Active goroutines: %d\n", bytes.Count(buf[:n], []byte("goroutine ")))
    os.Stdout.Write(buf[:n])
}

参数说明:runtime.Stack(buf, true) 将所有 goroutine 栈写入 buftrue 表示包含系统 goroutine(可设为 false 聚焦用户态);bytes.Count 快速估算活跃数,避免正则开销。

方法 适用场景 开销 是否含阻塞状态
pprof 快照 定期巡检、CI 集成 极低 是(含 IO wait 等)
runtime.Stack 紧急诊断、自检触发 中等

graph TD A[发现CPU/内存缓慢上涨] –> B{是否协程数持续上升?} B –>|是| C[采集pprof/goroutines快照] B –>|否| D[排查其他资源泄漏] C –> E[diff快照识别新增栈模式] E –> F[定位阻塞点:select无default、channel未关闭等]

3.2 内存逃逸分析与sync.Pool精准复用场景验证(含对象池生命周期管理)

逃逸分析实证:从栈分配到堆分配的临界点

运行 go build -gcflags="-m -l" 可观察变量是否逃逸。例如:

func NewBuffer() *bytes.Buffer {
    b := bytes.Buffer{} // 逃逸:返回局部变量地址
    return &b
}

逻辑分析&b 将栈上变量地址暴露给调用方,编译器强制将其分配至堆,触发GC压力。-l 禁用内联可更清晰定位逃逸源。

sync.Pool 的黄金复用边界

适用场景需同时满足:

  • 对象构造开销高(如 regexp.Compile
  • 生命周期短且频率高(HTTP中间件中的 bytes.Buffer
  • 无跨goroutine强状态依赖
场景 推荐使用 Pool 原因
每请求1次的JSON解码缓冲区 避免高频小对象GC
全局配置缓存对象 生命周期长,Pool可能过早清理

对象池生命周期管理关键点

var bufPool = sync.Pool{
    New: func() interface{} { return new(bytes.Buffer) },
}
// Get/Reuse/Reset需配合:buf.Reset() 清空内容,而非新建

参数说明New 仅在 Get() 返回 nil 时调用;Reset() 是安全复用前提,否则残留数据引发竞态。

graph TD
    A[Get] --> B{Pool非空?}
    B -->|是| C[返回并重置对象]
    B -->|否| D[调用New创建新实例]
    C --> E[业务使用]
    E --> F[Put回Pool]

3.3 Context传播完整性审计:超时传递、取消链路、WithValue滥用识别

超时传递的隐式失效风险

context.WithTimeout 若未在调用链每层显式传递,父级超时将无法向下传导。常见于中间件透传缺失:

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ❌ 错误:丢弃原始 r.Context(),新建无超时的 context.Background()
        ctx := context.Background() // 超时信息彻底丢失
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

逻辑分析:r.WithContext(ctx) 替换了携带超时的原始 r.Context();参数 ctx 应为 r.Context() 或其派生(如 r.Context().WithDeadline(...))。

取消链路断裂检测

场景 是否中断取消传播 原因
ctx, cancel := context.WithCancel(parent)cancel() 显式触发
ctx := context.WithValue(parent, key, val) WithValue 不继承取消能力

WithValue滥用识别

  • ✅ 合法:传递请求范围元数据(如用户ID、追踪ID)
  • ❌ 禁止:传递配置、函数、通道等非只读状态
graph TD
    A[HTTP Request] --> B[Middleware A]
    B --> C[Service Logic]
    C --> D[DB Query]
    B -.->|WithContext| C
    C -.->|WithContext| D
    style B stroke:#f00
    style C stroke:#0a0

第四章:生产环境适配与可观测性就绪

4.1 HTTP/GRPC服务健康端点标准化实现(liveness/readiness/probes语义对齐K8s)

Kubernetes 依赖 livenessreadiness 探针准确判断服务状态,但 HTTP 与 gRPC 协议语义差异易导致误判。需统一抽象健康模型。

健康状态语义映射

Probe 类型 HTTP 状态码 gRPC 状态码 语义含义
liveness 200 OK 进程存活、未卡死
readiness 200 OK + metadata ready:true 可接收流量、依赖就绪

gRPC 健康检查服务实现(Go)

func (s *healthServer) Check(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
    status := grpc_health_v1.HealthCheckResponse_SERVING
    if !s.isDBReady() { // 自定义依赖检查
        status = grpc_health_v1.HealthCheckResponse_NOT_SERVING
    }
    return &grpc_health_v1.HealthCheckResponse{Status: status}, nil
}

逻辑分析:Check() 方法返回 SERVING 表示 readiness;K8s gRPC probe 仅识别 OK 状态码 + Status 字段值,NOT_SERVING 触发 readiness 下线。isDBReady() 需幂等、低耗时(

探针配置对齐要点

  • liveness:检测进程级存活(如内存泄漏、goroutine 泄露),不检查外部依赖;
  • readiness:必须包含关键依赖(DB、Cache、下游gRPC服务)连通性验证;
  • 所有健康端点响应时间须 timeoutSeconds(推荐 ≤1s)。

4.2 结构化日志接入与采样策略:Zap字段规范、traceID注入与日志上下文透传

Zap 字段标准化实践

遵循 OpenTelemetry 日志语义约定,关键字段需统一命名:trace_id(字符串)、span_id(字符串)、service.name(常量)、level(小写)、event(动词短语)。避免自定义 request_id 等歧义字段。

traceID 自动注入示例

// 使用 zapcore.AddSync 包装 logger,并在 Write() 前注入上下文
func (l *contextLogger) Write(fields []zapcore.Field, enc zapcore.ObjectEncoder) error {
    if tid := trace.SpanFromContext(l.ctx).SpanContext().TraceID(); tid.IsValid() {
        enc.AddString("trace_id", tid.String()) // 标准字段名,非 "traceId" 或 "TraceID"
    }
    return l.logger.Write(fields, enc)
}

逻辑分析:trace.SpanFromContext() 安全提取 span 上下文;tid.String() 输出 32 位小写十六进制字符串(如 432a1f9b...),符合 Zipkin/B3 兼容格式;字段名严格小写加下划线,确保 ELK/OTLP 解析一致性。

日志采样控制矩阵

场景 采样率 触发条件
ERROR 级别日志 100% level == "error"
DEBUG 级别日志 1% service.name == "payment"
高频 info 日志 0.1% event == "http_request"

上下文透传流程

graph TD
    A[HTTP Handler] --> B[Extract traceID from header]
    B --> C[Attach to context.Context]
    C --> D[Pass to service layer]
    D --> E[Zap logger writes trace_id + fields]

4.3 指标埋点合规性检查:Prometheus命名约定、Histogram分位数预设与Cardinality控制

命名规范:语义清晰 + 单位明确

Prometheus 推荐使用 snake_case,前缀标识组件,后缀标明单位与类型:

# ✅ 合规示例
http_request_duration_seconds_bucket{le="0.1"}  # Histogram,单位秒  
redis_connected_clients  # Gauge,无单位  
jvm_memory_bytes_used{area="heap"}  # Counter-like Gauge,单位字节

逻辑分析:_seconds 显式声明时间单位,避免 http_request_latency_ms(单位隐含易歧义);_bucket 后缀强制标识 Histogram 子指标,防止与原始观测值混淆。

Histogram 分位数预设策略

必须显式配置 buckets,禁用动态分桶:

# Prometheus client config (Go)
prometheus.NewHistogram(prometheus.HistogramOpts{
  Name:    "rpc_durations_seconds",
  Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, // 覆盖 P90–P99 典型延迟区间
})

参数说明:8 个 bucket 覆盖 10ms–5s 主流响应区间,兼顾精度与 Cardinality —— 过密(如每 10ms 一个)将导致 label 组合爆炸。

Cardinality 控制三原则

  • ❌ 禁止将用户 ID、URL 路径、UUID 作为 label
  • ✅ 优先聚合为有限枚举(如 status_code="2xx" 而非 "200"
  • ✅ 使用 metric_relabel_configs 在采集端丢弃高基数 label
风险 label 类型 替代方案 潜在 series 数量
user_id="u12345" user_tier="premium" ↓ 从 10⁶ → 3
path="/api/v1/order/123" path_template="/api/v1/order/{id}" ↓ 10⁴ → 20
graph TD
  A[原始埋点] --> B{含高基数 label?}
  B -->|是| C[采集端 relabel 过滤]
  B -->|否| D[保留并打标]
  C --> E[写入 TSDB]
  D --> E

4.4 分布式追踪初始化验证:OpenTelemetry SDK自动注入与Span上下文跨goroutine延续测试

自动注入验证要点

OpenTelemetry Go SDK 通过 otelhttp.NewHandlerotelhttp.NewClient 实现零侵入注入,但需显式启用 otel.SetTextMapPropagator 配置 B3 或 W3C 传播器。

跨 goroutine Span 延续测试

ctx, span := otel.Tracer("test").Start(context.Background(), "parent")
defer span.End()

// 使用 context.WithValue 无法传递 Span —— 必须用 otel.GetTextMapPropagator().Inject()
prop := otel.GetTextMapPropagator()
carrier := propagation.MapCarrier{}
prop.Inject(ctx, carrier) // 注入 traceparent/tracestate

go func() {
    ctx := prop.Extract(context.Background(), carrier) // 正确还原上下文
    _, childSpan := otel.Tracer("test").Start(ctx, "child") // 关联 parent
    defer childSpan.End()
}()

逻辑分析prop.Inject() 将当前 SpanContext 编码为 traceparent HTTP header 格式;prop.Extract() 反向解析并重建 context.Context,确保 childSpanparentSpanID 指向 parent。若直接传 ctx(非 prop.Extract),子 goroutine 将生成无关联的独立 Span。

常见失败场景对比

场景 是否继承 ParentSpanID 原因
直接 go f(ctx) Context 不含 SpanContext 传播元数据
prop.Inject + prop.Extract 显式序列化/反序列化 tracestate
使用 context.WithValue(ctx, key, val) OpenTelemetry 不监听自定义 key
graph TD
    A[main goroutine] -->|prop.Inject| B[(traceparent header)]
    B --> C[spawn goroutine]
    C -->|prop.Extract| D[reconstructed ctx]
    D --> E[child Span with correct parent]

第五章:上线决策与灰度发布协同机制

核心决策三角模型

上线不是技术单点动作,而是由业务影响评估、系统稳定性基线、可观测性就绪度构成的动态决策三角。某电商大促前,风控团队基于实时流量预测模型(QPS峰值预估±12%误差带)与历史熔断阈值(CPU >85%持续90s触发降级)交叉校验,否决了原定凌晨2点全量上线方案,转而启动分城市渐进式灰度。

灰度策略矩阵表

灰度维度 切流比例 监控粒度 回滚触发条件 执行周期
地域(华东) 5% → 20% → 50% 接口P99延迟、DB慢查率 P99 > 800ms且持续3分钟 每阶段≥15分钟
用户分群(VIP) 100%固定白名单 订单创建成功率、支付链路耗时 成功率 实时生效

自动化协同流水线

通过GitLab CI与Prometheus告警联动构建闭环:代码合并至release/v2.3分支后,自动部署至灰度集群→调用/health/ready探针验证→触发curl -X POST http://metrics-api/trigger?metric=order_success_rate&threshold=99.2注册监控项→若3分钟内告警未清除,则自动执行kubectl rollout undo deployment/order-service --to-revision=12回滚。

flowchart LR
    A[上线评审会] --> B{业务影响评估通过?}
    B -->|是| C[灰度配置中心下发规则]
    B -->|否| D[终止流程并归档风险报告]
    C --> E[流量切分至灰度节点]
    E --> F[实时比对主干/灰度双链路指标]
    F --> G{P99延迟差值<50ms且错误率差值<0.1%?}
    G -->|是| H[自动提升灰度比例]
    G -->|否| I[触发人工介入看板]

真实故障拦截案例

2024年3月某金融系统灰度期间,监控发现灰度集群MySQL从库延迟突增至120s(主干集群稳定在800ms内),经分析为新引入的审计日志写入逻辑未适配读写分离架构。通过灰度配置中心秒级关闭华东区域5%流量,同时保留其余区域继续验证,避免了全量数据库连接池耗尽风险。该问题在2小时内定位到AuditLogWriter组件未启用异步批量提交模式。

多维验证清单

  • [x] 全链路压测报告(含灰度节点独立压测数据)
  • [ ] 配置中心灰度规则版本号与K8s ConfigMap哈希值一致性校验
  • [x] 前端资源CDN缓存过期时间设置为灰度周期的1/3(当前灰度周期30分钟→CDN TTL=10分钟)
  • [ ] 客服系统知识库更新完成(含灰度功能FAQ及报错码映射表)

协同工具链集成

Jira需求卡片关联灰度任务ID(如GRAY-782),当该卡片状态变为“In QA”时,自动向企业微信机器人推送灰度环境访问凭证及测试用例链接;当灰度阶段结束时,自动将Prometheus性能对比快照、日志采样片段、链路追踪Top5慢请求截图打包上传至Confluence文档空间,路径为/Release/2024Q2/v2.3/gray-report-20240322

在 Kubernetes 和微服务中成长,每天进步一点点。

发表回复

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