Posted in

【Go可观测性实战体系】:Prometheus指标建模+OpenTelemetry trace注入+Loki日志关联,实现1秒根因定位

第一章:Go可观测性实战体系概览

可观测性不是监控的升级版,而是从“系统是否在运行”转向“系统为何如此运行”的范式转变。在 Go 生态中,这一能力由三根支柱协同支撑:指标(Metrics)、日志(Logs)和链路追踪(Traces),三者需统一语义、共享上下文、共用生命周期管理。

核心组件选型原则

  • 轻量嵌入:优先选用原生支持 context.Context 传递的库,避免 goroutine 泄漏;
  • 零配置启动:基础采集应默认启用(如 HTTP 请求延迟、GC 次数),无需显式初始化;
  • OpenTelemetry 原生兼容:确保所有 SDK 输出符合 OTLP 协议,便于对接 Jaeger、Prometheus、Loki 等后端。

快速启用基础可观测性

以下代码片段可在 5 行内为任意 Go HTTP 服务注入指标与追踪能力:

import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel/exporters/prometheus"
    "go.opentelemetry.io/otel/sdk/metric"
)

// 启动 Prometheus 指标 exporter(自动注册到 /metrics)
exporter, _ := prometheus.New()
provider := metric.NewMeterProvider(metric.WithExporter(exporter))
otel.SetMeterProvider(provider)

// 包装 HTTP handler,自动记录请求延迟、状态码、方法等
http.Handle("/api/", otelhttp.NewHandler(http.HandlerFunc(yourHandler), "api"))

执行后,服务将暴露 /metrics 端点(文本格式),并为每个 HTTP 请求生成带 trace_id 的结构化日志(若集成 zap + otelpzap)。

关键实践约束

维度 推荐做法 禁止行为
日志字段 固定字段:trace_id, span_id, service.name 使用 fmt.Sprintf 拼接上下文
指标命名 遵循 Prometheus 命名规范(小写下划线,如 http_request_duration_seconds 包含大写或空格
追踪采样 生产环境启用自适应采样(如 ParentBased(TraceIDRatioBased(0.01)) 全量采样或完全关闭

真正的可观测性始于开发阶段——每一个 log.Info() 调用、每一处 http.HandleFunc 注册,都应默认携带 trace 上下文。

第二章:Prometheus指标建模与Go应用深度集成

2.1 Prometheus数据模型与Go指标语义建模原理

Prometheus 的核心是时间序列数据模型:每个样本由 (metric_name{label1="v1",...}, value, timestamp) 三元组构成,标签(labels)赋予多维语义能力。

Go 客户端库的指标抽象层级

  • Counter:单调递增计数器(如请求总数)
  • Gauge:可增可减瞬时值(如内存使用量)
  • Histogram:分桶统计观测值分布(如 HTTP 延迟)
  • Summary:客户端计算分位数(已不推荐用于新场景)

标签绑定与语义建模示例

// 创建带业务维度的延迟直方图
httpLatency := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "Latency distribution of HTTP requests",
        Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1},
    },
    []string{"method", "endpoint", "status_code"}, // 语义化维度
)

NewHistogramVec 动态生成笛卡尔积时间序列;[]string 参数定义标签键名,运行时通过 .WithLabelValues("GET", "/api/users", "200") 绑定具体语义上下文。标签组合爆炸需谨慎设计——高基数标签(如 user_id)将导致存储与查询性能劣化。

指标类型 是否支持标签 典型用途 客户端聚合
Counter 总请求数、错误数
Histogram 延迟、大小分布 ✅(分桶)
Gauge 当前并发数、队列长
graph TD
    A[Go应用埋点] --> B[metrics包注册指标]
    B --> C[HTTP handler中Observe/Inc]
    C --> D[Prometheus scrape endpoint]
    D --> E[TSDB按label+name索引存储]

2.2 使用prometheus/client_golang定义业务黄金指标(QPS、延迟、错误率)

黄金指标的采集需贴合业务语义,而非仅依赖基础设施层。prometheus/client_golang 提供了 CounterHistogramGauge 三类核心指标类型,分别适配 QPS、延迟与错误率场景。

指标类型选型依据

指标类型 适用场景 示例用途
Counter 累计事件数 请求总量、错误总数
Histogram 观测值分布与分位 HTTP 延迟(p50/p99)
Gauge 可增可减瞬时值 当前活跃连接数(非黄金指标主用)

定义延迟 Histogram 示例

import "github.com/prometheus/client_golang/prometheus"

httpLatency := prometheus.NewHistogram(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Help:    "Latency of HTTP requests in seconds",
    Buckets: prometheus.DefBuckets, // [0.005, 0.01, ..., 10]
})
prometheus.MustRegister(httpLatency)

Histogram 自动记录观测值并聚合为分位数(如 http_request_duration_seconds_bucket{le="0.1"}),配合 rate()histogram_quantile() 函数可在 PromQL 中计算 P99 延迟。DefBuckets 覆盖典型 Web 延迟范围,避免自定义失当导致直方图失真。

错误率计算逻辑

错误率需由 Counter 比率推导:
rate(http_requests_total{code=~"5.."}[5m]) / rate(http_requests_total[5m])

2.3 自定义Collector实现复杂指标采集(如goroutine泄漏检测)

核心思路

通过 prometheus.Collector 接口实现自定义指标逻辑,捕获运行时 goroutine 数量变化趋势,并识别异常增长。

实现关键结构

  • GoroutineLeakCollector 持有历史快照(lastCount, lastTime
  • 每次 Collect() 调用触发 runtime.NumGoroutine() 采样
type GoroutineLeakCollector struct {
    lastCount int
    lastTime  time.Time
    metric    *prometheus.GaugeVec
}

func (c *GoroutineLeakCollector) Collect(ch chan<- prometheus.Metric) {
    now := runtime.NumGoroutine()
    elapsed := time.Since(c.lastTime).Seconds()
    if elapsed > 30 && float64(now-c.lastCount)/elapsed > 5 { // 每秒新增 >5 个视为可疑
        c.metric.WithLabelValues("leak_warning").Set(1)
    } else {
        c.metric.WithLabelValues("leak_warning").Set(0)
    }
    c.metric.WithLabelValues("current").Set(float64(now))
    c.lastCount = now
    c.lastTime = time.Now()
    ch <- c.metric.MustCurryWith(prometheus.Labels{"type": "current"}).WithLabelValues("current")
}

逻辑说明:该 Collector 不仅上报当前 goroutine 数,还基于时间窗口计算增长率;阈值 5 goroutines/sec 可配置,避免瞬时抖动误报。

指标语义对照表

标签值 含义 告警建议
current 当前活跃 goroutine 总数 持续 >5000 需排查
leak_warning 是否触发泄漏疑似信号(0/1) 结合 rate() 聚合判断

数据同步机制

Collector 在 Register() 后由 Prometheus 定期调用 Collect(),全程无锁,依赖 time.Now() 与原子采样保证一致性。

2.4 指标生命周期管理与动态注册/注销实践

指标并非静态存在,而需随业务模块启停、配置变更或灰度发布实时响应。核心在于将指标对象与运行时上下文(如 Spring Bean 生命周期、HTTP 请求作用域)解耦绑定。

动态注册示例(Spring Boot)

@Component
public class MetricRegistryManager {
    private final MeterRegistry registry;

    public MetricRegistryManager(MeterRegistry registry) {
        this.registry = registry;
    }

    public Counter registerCounter(String name, String... tags) {
        return Counter.builder(name)
                .tags(tags) // 如 "service", "order-api"
                .register(registry); // 自动加入全局注册表
    }
}

Counter.builder() 构建未注册指标;.register(registry) 触发实际注册并返回可写入实例;标签数组支持动态拼接,避免硬编码。

注销限制与替代方案

  • Prometheus 不支持运行时注销指标(违反其不可变设计原则)
  • 实践中采用“标记废弃 + TTL 清理”策略:
策略 是否真正删除 适用场景
Meter.remove() 否(仅断开引用) JVM 内存回收友好
标签标记 status=deprecated 是(服务端过滤) Grafana 查询隔离

生命周期协同流程

graph TD
    A[模块启动] --> B[调用 registerCounter]
    B --> C[指标注入 MeterRegistry]
    C --> D[Prometheus Scraping]
    E[模块销毁] --> F[调用 close() 或 GC]
    F --> G[自动解除引用]

2.5 指标命名规范、标签设计与高基数风险规避

命名应遵循 namespace_subsystem_metric_type 模式

例如:http_server_request_duration_seconds_bucket —— 清晰表达领域、组件、指标语义与单位。

标签设计原则

  • 必选标签:jobinstance(保障服务级可聚合)
  • 可选业务标签:endpointstatus_code(需评估基数)
  • 禁止使用高变度字段:user_idrequest_idtrace_id

高基数陷阱示例与规避

# ❌ 危险:user_id 标签导致百万级时间序列
http_requests_total{job="api", user_id="u_8a7f2c1e"}  

# ✅ 改造:降维为用户等级分桶
http_requests_total{job="api", user_tier="premium"}

逻辑分析:原始 user_id 标签每新增用户即生成新时间序列,突破 Prometheus 存储与查询性能阈值(通常建议单 job user_tier 后,基数稳定在个位数级别。

标签类型 示例 安全基数上限 风险等级
静态维度 env="prod"
业务枚举 payment_method="alipay"
唯一标识 order_id="ORD-..." > 10⁵
graph TD
    A[原始指标] --> B{含高基数标签?}
    B -->|是| C[剥离/哈希/分桶]
    B -->|否| D[直接采集]
    C --> E[降维后指标]
    E --> F[可控序列数]

第三章:OpenTelemetry trace注入与Go分布式追踪落地

3.1 OpenTelemetry Go SDK架构解析与上下文传播机制

OpenTelemetry Go SDK 的核心是 sdk/trace 包,其以 TracerProvider 为入口,通过 Tracer 创建 span,并依赖 context.Context 实现跨 goroutine 的透传。

上下文传播关键组件

  • propagation.TextMapPropagator:负责在 HTTP header 等载体中注入/提取 trace context
  • otel.GetTextMapPropagator().Inject() / .Extract():标准传播接口
  • oteltrace.SpanContextFromContext():从 context 中安全获取 span 上下文

Span 上下文注入示例

func injectTraceHeader(ctx context.Context, carrier http.Header) {
    // 使用全局 propagator 注入 traceparent 和 tracestate
    otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(carrier))
}

该函数将当前 span 的 trace-idspan-id、采样标志等序列化为 W3C traceparent 字段(如 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),并写入 carrierHeaderCarrier 是适配器模式实现,屏蔽底层传输细节。

传播字段对照表

字段名 含义 格式示例
traceparent W3C 标准 trace 元数据 00-<trace-id>-<span-id>-<flags>
tracestate 跨厂商上下文扩展信息 rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
graph TD
    A[HTTP Handler] --> B[Extract from headers]
    B --> C[ctx = ContextWithSpanContext]
    C --> D[Tracer.StartSpan]
    D --> E[Inject into downstream headers]

3.2 HTTP/gRPC中间件自动注入trace与span语义标准化

在微服务链路追踪中,手动埋点易遗漏且语义不一致。通过框架级中间件实现自动化注入,是保障 trace 全局唯一、span 层级清晰的关键。

自动注入原理

HTTP 中间件拦截 http.Handler,gRPC 拦截器封装 grpc.UnaryServerInterceptor,从请求头提取 traceparent,或生成新 trace ID;自动创建带标准语义的 span。

标准化 Span 名称映射

协议 Span Name 模式 示例
HTTP HTTP {METHOD} {PATH} HTTP GET /api/users
gRPC GRPC {Service}/{Method} GRPC user.v1.UserService/GetUser
func TraceUnaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // 从 metadata 提取 traceparent 或生成新 trace
    md, _ := metadata.FromIncomingContext(ctx)
    span := tracer.StartSpan(info.FullMethod, 
        ext.SpanKindRPCServer,
        ext.RPCSystemGRPC,
        ext.GRPCFullMethodKey.String(info.FullMethod))
    defer span.Finish()

    ctx = opentracing.ContextWithSpan(ctx, span)
    return handler(ctx, req)
}

该拦截器自动绑定 span 生命周期:info.FullMethod 提供标准化命名依据;ext.SpanKindRPCServer 确保 span 类型语义统一;defer span.Finish() 保障异常路径下资源释放。

graph TD A[请求进入] –> B{协议识别} B –>|HTTP| C[HTTP Middleware] B –>|gRPC| D[gRPC Interceptor] C & D –> E[提取/生成 traceparent] E –> F[创建标准化 Span] F –> G[注入 Context 并透传]

3.3 异步任务(goroutine、channel、worker pool)的trace延续实践

在分布式追踪中,跨 goroutine 的 span 上下文传递是关键挑战。Go 原生不自动传播 context.Context 中的 trace 信息,需显式注入与提取。

trace 上下文透传机制

使用 oteltrace.ContextWithSpan 将当前 span 注入 context,并通过 channel 发送携带 context 的任务:

// 任务结构体需嵌入 context.Context
type Task struct {
    Ctx context.Context
    ID  string
}

// 发送前注入 span
span := trace.SpanFromContext(parentCtx)
task := Task{
    Ctx: oteltrace.ContextWithSpan(context.Background(), span), // ✅ 显式继承父 span
    ID:  "job-123",
}
taskCh <- task

逻辑分析context.Background() 创建新根上下文,ContextWithSpan 将活跃 span 绑定其中;接收端调用 trace.SpanFromContext(task.Ctx) 即可恢复 trace 链路。参数 parentCtx 应来自 HTTP handler 或上游 goroutine 的原始 trace 上下文。

Worker Pool 中的 span 复用策略

场景 是否复用 span 说明
同一请求内子任务 ✅ 是 共享父 span,支持 nested view
跨请求异步补偿任务 ❌ 否 应创建独立 span 并标注 span.kind=internal
graph TD
    A[HTTP Handler] -->|ctx with span| B[Producer Goroutine]
    B -->|Task{Ctx, ID}| C[Channel]
    C --> D[Worker 1]
    C --> E[Worker N]
    D -->|span from ctx| F[DB Query]
    E -->|span from ctx| G[Cache Write]

第四章:Loki日志关联与全链路可观测性闭环构建

4.1 Loki日志结构化设计与Go零分配日志注入(zerolog/logrus适配)

Loki 要求日志必须为结构化文本(如 JSON),且推荐通过 labels 实现维度索引,而非全文解析。零分配日志注入的核心在于避免 []byte 临时分配与 fmt.Sprintf 字符串拼接。

零分配写入关键路径

  • 使用 zerolog.NewConsoleWriter() 配合预分配缓冲池
  • log.With().Str("service", s).Int("code", c).Msg("") 生成无 GC 日志
  • 通过 log.Hook 将日志条目直接序列化为 Loki 兼容的 json.RawMessage

标签映射策略

字段名 Loki Label 键 注入方式
service job 静态注入(启动时设定)
trace_id traceID 动态上下文提取
cluster cluster 环境变量自动绑定
func NewLokiWriter() io.Writer {
    buf := make([]byte, 0, 1024) // 预分配缓冲区
    return &labeledWriter{buf: buf}
}

// labeledWriter.Write 避免额外切片分配,复用 buf 并直接写入 socket

该实现跳过 bytes.Buffer 的动态扩容逻辑,buf 在每次 Write 前重置,全程无堆分配。labeledWriter 内部直接构造含 streamsvalues 的 Loki push 请求体,省去中间 JSON 字符串生成步骤。

4.2 trace_id、span_id、request_id多维日志标记与自动注入

在分布式链路追踪中,trace_id 标识一次完整请求调用链,span_id 标识当前操作节点,request_id 则常用于业务层唯一标识单次 HTTP 请求。三者协同可实现日志跨服务精准归因。

日志上下文自动注入机制

主流框架(如 Spring Cloud Sleuth、OpenTelemetry Java Agent)通过 ThreadLocal + MDC 实现透传:

// 自动将 trace_id/span_id 注入 MDC,供 logback 使用
MDC.put("trace_id", tracer.currentSpan().context().traceId());
MDC.put("span_id", tracer.currentSpan().context().spanId());
MDC.put("request_id", RequestContextHolder.getRequestAttributes()
    .getAttribute("X-Request-ID", RequestAttributes.SCOPE_REQUEST));

逻辑说明tracer.currentSpan() 获取当前活跃 Span;traceId() 返回 16/32 位十六进制字符串;MDC.put() 将字段绑定至当前线程,确保异步线程(如 @Async)需显式 MDC.getCopyOfContextMap() 传递。

三类 ID 的语义与协作关系

字段 来源 生命周期 典型用途
trace_id 首入口生成(如网关) 全链路贯穿 链路聚合、拓扑绘制
span_id 每个服务自动生成 单次方法调用内有效 耗时分析、依赖定位
request_id Nginx/网关注入 header 单次 HTTP 请求周期 运维排查、审计溯源

跨线程传递保障(简略流程)

graph TD
    A[HTTP 请求] --> B[Gateway 注入 X-Trace-ID/X-Span-ID/X-Request-ID]
    B --> C[Feign/OkHttp 拦截器透传]
    C --> D[下游服务 MDC 自动填充]
    D --> E[Logback pattern 引用 %X{trace_id}]

4.3 PromQL + LogQL联合查询实现指标-日志交叉验证

在 Grafana 9+ 与 Loki/Prometheus 联动场景中,可通过 Explore 视图直接发起跨数据源关联查询。

数据同步机制

Loki 通过 | json| __error__ == "" 过滤器提取结构化字段,Prometheus 则暴露 http_request_duration_seconds_sum{job="api"} 等指标。二者通过 clusterpodinstance 等共用标签对齐。

关联查询示例

# PromQL:定位高延迟 Pod(过去5分钟 P95 > 2s)
histogram_quantile(0.95, sum by (le, pod) (rate(http_request_duration_seconds_bucket{job="api"}[5m])))
> 2

该查询返回异常 pod="api-7f8c9d";随后在 LogQL 中复用该标签精准下钻:

{job="api", pod="api-7f8c9d"} | json | status >= 400 | line_format "{{.method}} {{.path}} {{.status}}"

逻辑说明:{job="api", pod="api-7f8c9d"} 实现服务级路由匹配;| json 解析日志为键值对;line_format 定制输出便于人工快速识别错误模式。

联合分析能力对比

能力 PromQL LogQL 联合优势
实时聚合 指标触发 → 日志定界
原始上下文追溯 日志反查 → 根因定位
标签一致性校验 ✅(label_values) ✅(| label_values(pod) 自动对齐维度,避免手工映射
graph TD
    A[Prometheus 报警] --> B{P95延迟突增?}
    B -->|是| C[提取异常pod标签]
    C --> D[Loki按pod+时间窗口检索原始日志]
    D --> E[解析JSON字段定位错误链路]

4.4 Grafana中构建1秒根因定位看板(指标下钻→trace展开→日志聚焦)

实现毫秒级根因闭环,需打通指标、链路、日志三域关联。核心依赖Grafana 9.5+的Explore LinkingData Links能力。

关联跳转配置示例

{
  "links": [{
    "title": "🔍 查看Trace",
    "url": "/explore?left={\"datasource\":\"tempo\",\"queries\":[{\"refId\":\"A\",\"expr\":\"{service_name=\\\"$service\\\", traceID=\\\"$__value.raw\\\"}\"}]}",
    "internal": true
  }]
}

$__value.raw 自动注入当前指标点原始traceID;$service 为面板变量,确保上下文继承。

三域联动路径

  • 指标面板 → 点击异常时间点 → 自动带参跳转Tempo Trace视图
  • Trace详情页 → 点击Span → 下钻至Loki日志(通过span_id + timestamp精准锚定)
  • 日志行内高亮error字段,支持一键反向跳回对应Span

关键参数映射表

字段名 来源 用途
traceID Prometheus标签 关联Tempo链路
span_id Tempo元数据 定位Loki日志上下文
ts Loki日志时间戳 对齐Trace时间轴
graph TD
  A[指标异常点] -->|携带traceID| B[Tempo Trace视图]
  B -->|点击Span| C[Loki日志聚焦]
  C -->|高亮error| D[反向定位Span]

第五章:可观测性能力演进与工程化治理

从日志堆砌到信号融合的范式迁移

某大型电商在双十一大促前遭遇P99延迟突增,传统ELK栈仅能检索到“HTTP 504”日志,却无法定位是下游库存服务超时还是网关重试风暴。团队将OpenTelemetry SDK嵌入Spring Cloud微服务,统一采集Trace(含Span上下文)、Metrics(JVM GC耗时、HTTP状态码分布)与结构化日志(带trace_id字段),通过Jaeger UI下钻发现87%的504请求均关联至同一Redis连接池耗尽Span——该信号仅在三者关联分析后才可确认。此案例验证了可观测性本质是多维信号的因果推理能力,而非单点数据收集。

可观测性即代码的工程实践

某金融科技公司建立可观测性即代码(Observability as Code)流水线:

  • 使用Prometheus Operator CRD定义ServiceMonitor,声明式配置Kubernetes中各服务指标抓取规则;
  • 通过Grafana Terraform Provider生成Dashboard JSON,版本化管理217个核心看板;
  • 在CI/CD阶段执行otelcol-contrib --config ./otel-config.yaml --validate校验OpenTelemetry Collector配置语法与端口冲突。
    该流程使新业务接入可观测性能力的平均耗时从3.2人日压缩至17分钟。

黄金信号的动态基线建模

指标类型 基线算法 生产环境误报率 典型响应时间
QPS Prophet季节性预测 0.8% 42s
错误率 动态阈值(滚动窗口P95+3σ) 2.1% 18s
延迟P99 LSTM时序异常检测 1.3% 6.3s

某支付网关将上述模型集成至Alertmanager,当P99延迟突破基线时自动触发Runbook:先执行kubectl exec -it payment-gateway-xxxx -- curl -s http://localhost:9090/debug/pprof/goroutine?debug=2采集协程快照,再调用内部API触发熔断开关。过去3个月拦截了12次潜在雪崩,平均MTTD(平均故障检测时间)降至8.7秒。

跨云环境的统一信号治理

混合云架构下,AWS EKS集群的Pod指标通过Prometheus Remote Write推送至GCP托管的Thanos,而Azure VM上的.NET Core应用则通过OpenTelemetry Collector Exporter直连同一Thanos Querier。关键治理动作包括:

  • 在Collector配置中强制注入cloud_provider="aws"等标签,解决跨云维度对齐问题;
  • 使用Thanos Ruler对全局指标计算SLI(如rate(http_requests_total{job="api"}[5m]) / rate(http_requests_total[5m]) > 0.999),避免各云厂商监控口径差异导致SLO漂移。

成本与精度的持续博弈

某视频平台为降低Tracing存储成本,实施采样策略分级:用户关键路径(登录→播放→付费)100%采样,后台任务流采用头部采样(Head-based Sampling)+动态速率限制(每秒不超过5000个Span)。通过对比全量采样数据集,验证该策略在保持P99延迟归因准确率92.4%的前提下,将Jaeger后端存储成本降低67%。

# otel-collector-config.yaml 片段:动态采样配置
processors:
  probabilistic_sampler:
    hash_seed: 42
    sampling_percentage: 10.0
    decision_wait: 30s
    num_traces: 10000

组织协同的可观测性契约

前端团队与后端团队签署《可观测性SLA协议》:前端埋点必须携带x-trace-id并遵循W3C Trace Context标准;后端接口需在HTTP Header中返回X-Response-Time-MsX-Cache-Status。当某次AB测试发现iOS端转化率下降12%,通过Trace ID跨系统串联发现,后端缓存中间件未正确透传X-Cache-Status: HIT,导致前端错误触发冗余请求。该契约使跨职能根因分析平均耗时下降58%。

flowchart LR
    A[用户点击支付按钮] --> B[前端SDK注入trace_id]
    B --> C[调用后端支付API]
    C --> D[OpenTelemetry Collector注入span]
    D --> E[Jaeger UI展示完整链路]
    E --> F[自动关联Prometheus延迟指标]
    F --> G[触发Grafana告警看板高亮]

守护数据安全,深耕加密算法与零信任架构。

发表回复

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