Posted in

【Go Web可观测性基建第一课】:从零接入OpenTelemetry的5个强制规范(TraceID透传、Span命名、Error标注、Metric维度、Log关联)

第一章:Go Web可观测性基建概览与OpenTelemetry核心理念

现代Go Web服务在云原生环境中运行时,单靠日志已无法满足故障定位、性能分析与依赖拓扑理解的需求。可观测性(Observability)作为系统可理解性的工程实践,其三大支柱——指标(Metrics)、追踪(Tracing)和日志(Logs)——需统一采集、关联与导出。OpenTelemetry(OTel)正是为此而生的开源标准,它提供语言无关的API、SDK与协议,解耦观测逻辑与后端实现,使Go应用无需绑定特定厂商即可接入Prometheus、Jaeger、Zipkin或云服务商(如AWS X-Ray、GCP Cloud Trace)。

OpenTelemetry的核心设计哲学

  • 供应商中立:通过otel.Exporter接口抽象数据导出行为,切换后端仅需替换导出器实例;
  • 语义约定(Semantic Conventions):定义HTTP、gRPC、数据库等通用属性命名规范(如http.method, db.system),确保跨语言数据一致可比;
  • 上下文传播(Context Propagation):基于context.Context传递TraceID与SpanID,天然适配Go生态的并发模型。

在Go Web服务中启用基础追踪

以下代码为Gin框架注入OTel追踪中间件,使用otlphttp导出器发送至本地OTel Collector(默认监听localhost:4318):

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlphttp"
    "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)

func initTracer() {
    client := otlphttp.NewClient(
        otlphttp.WithEndpoint("localhost:4318"), // OTel Collector地址
        otlphttp.WithInsecure(),                 // 开发环境跳过TLS
    )
    exp, _ := trace.NewExporter(client)
    tp := trace.NewProvider(trace.WithBatcher(exp))
    otel.SetTracerProvider(tp)
}

// Gin路由注册时添加中间件
r := gin.Default()
r.Use(otelgin.Middleware("my-web-service")) // 自动创建HTTP请求Span

关键组件协作关系

组件 职责 Go SDK对应包
API 定义Tracer、Meter等抽象接口 go.opentelemetry.io/otel
SDK 实现采样、批处理、上下文注入等逻辑 go.opentelemetry.io/otel/sdk
Exporter 将数据序列化并发送至后端(如OTLP/HTTP) go.opentelemetry.io/otel/exporters/otlp/*

启用后,每个HTTP请求将自动生成包含http.status_code、net.peer.ip等语义属性的Span,并通过TraceID串联下游调用,为后续分布式链路分析奠定基础。

第二章:TraceID全链路透传的强制规范与工程实践

2.1 HTTP中间件中TraceID注入与提取的RFC标准实现

HTTP追踪上下文传播需遵循 RFC 9113 与 W3C Trace Context 联合规范,核心字段为 traceparent(必需)与 tracestate(可选)。

标准字段结构

  • traceparent: 00-{trace-id}-{parent-id}-{flags}(如 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)
  • tracestate: 键值对链表,支持多供应商上下文传递(如 rojo=00f067aa0ba902b7,congo=t61rcWkgMzE)

中间件注入逻辑(Go 示例)

func injectTraceID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := uuid.New().String()
        parentID := "0000000000000000" // root span
        flags := "01" // sampled
        traceParent := fmt.Sprintf("00-%s-%s-%s", traceID, parentID, flags)
        r.Header.Set("traceparent", traceParent)
        next.ServeHTTP(w, r)
    })
}

逻辑说明:traceparent 必须严格按 52 字符格式构造;trace-id 为 32 位小写十六进制;flags 第二位为 1 表示采样启用。中间件在请求进入时生成并注入,确保下游服务可无损解析。

规范兼容性对照表

字段 RFC 9113 W3C Trace Context 是否强制
traceparent ✗ ✓ 是
tracestate ✗ ✓ 否
X-Request-ID ✗ ✗ 否(非标准)
graph TD
    A[Incoming Request] --> B{Has traceparent?}
    B -->|Yes| C[Parse & Validate Format]
    B -->|No| D[Generate New Root Span]
    C --> E[Propagate to Downstream]
    D --> E

2.2 gRPC元数据透传与context.Context跨协程安全传递

gRPC 的 metadata.MD 与 context.Context 共同构成请求上下文的“双轨制”传递机制:前者承载可序列化的键值对(如认证令牌、追踪ID),后者管理生命周期与取消信号。

元数据透传实践

// 客户端注入元数据
md := metadata.Pairs("trace-id", "abc123", "user-role", "admin")
ctx := metadata.AppendToOutgoingContext(context.Background(), md...)

// 服务端提取
md, ok := metadata.FromIncomingContext(ctx)
if ok {
    role := md.Get("user-role") // ["admin"]
}

metadata.Pairs 自动处理二进制/ASCII 键后缀(-bin);FromIncomingContext 仅从 context.Context 的底层 grpc.mdata 值中安全解包,线程安全。

context.Context 跨协程安全原理

特性 说明
不可变性 WithCancel/WithValue/WithTimeout 返回新 context,原 context 不变
协程安全 所有字段(done, err, value)均通过 atomic 或 mutex 保护
取消传播 Done() channel 由父 context 广播,子 goroutine 无需额外同步
graph TD
    A[main goroutine] -->|ctx.WithCancel| B[child goroutine 1]
    A -->|ctx.WithValue| C[child goroutine 2]
    B -->|<-ctx.Done()| D[自动关闭]
    C -->|<-ctx.Done()| D

2.3 异步任务(goroutine/channel/worker pool)中的TraceID延续策略

在 Go 的并发模型中,TraceID 的跨 goroutine 传递不能依赖线程局部存储,必须显式携带上下文。

上下文传递是唯一可靠路径

  • context.Context 是官方推荐的传播载体
  • trace.WithSpanContext() 封装 TraceID 到 context 中
  • 新 goroutine 必须接收并继承该 context,而非使用 context.Background()

Worker Pool 中的典型实践

func processTask(ctx context.Context, task Task) {
    // 从 ctx 提取并绑定当前 span
    span := trace.SpanFromContext(ctx)
    defer span.End()

    // 启动子 goroutine 时透传 ctx
    go func(ctx context.Context) {
        // ✅ 正确:TraceID 延续
        childSpan := tracer.Start(ctx, "subtask")
        defer childSpan.End()
    }(ctx) // ← 关键:显式传入原始 ctx
}

逻辑分析:ctx 携带 spanContext,tracer.Start(ctx, ...) 自动提取并关联父 Span。若传入 context.Background(),则生成孤立 TraceID,链路断裂。

场景 是否延续 TraceID 原因
go f(ctx) ✅ 显式继承上下文
go f(context.Background()) ❌ 上下文重置,丢失 spanCtx
graph TD
    A[main goroutine] -->|ctx with TraceID| B[worker goroutine]
    B -->|ctx passed via channel| C[sub-task goroutine]
    C --> D[log/metrics with same TraceID]

2.4 第三方SDK(如database/sql、redis-go、kafka-go)的TraceID自动挂载机制

核心原理:Context透传与驱动拦截

Go 生态中主流 SDK 均支持 context.Context 参数,为 TraceID 注入提供统一入口。database/sql 通过 driver.Conn 的 PrepareContext/QueryContext 等方法透传;redis-go(如 github.com/redis/go-redis/v9)所有命令均接收 ctx;kafka-go 的 Writer.WriteMessages 亦同理。

自动挂载实现方式

  • ✅ 依赖中间件包装原始 driver 或 client(如 sqltrace、redisotel、kafka-go/otlp)
  • ✅ 在调用前从 ctx.Value(trace.TracerKey) 提取 Span,并将 TraceID 注入请求元数据(如 SQL comment、Redis X-Trace-ID header、Kafka headers)

示例:Redis 客户端自动注入

import "github.com/redis/go-redis/v9"

// 使用 otelredis 包自动注入
rdb := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
})
rdb.AddHook(otelredis.NewTracingHook()) // 自动从 ctx 提取 traceID 并写入 command

ctx := trace.ContextWithSpan(context.Background(), span)
rdb.Get(ctx, "user:1001").Val() // TraceID 自动附加至命令上下文

逻辑分析:otelredis.NewTracingHook() 实现 redis.Hook 接口,在 Process 阶段读取 ctx 中的 SpanContext,将 TraceID.String() 写入 redis.Cmdable 的 Args 或自定义 header(若协议支持),供服务端解析。参数 ctx 是唯一注入通道,span 必须已通过 otel.GetTextMapPropagator().Inject() 注入 carrier。

主流 SDK 支持对比

SDK Context 支持 OpenTelemetry 官方插件 TraceID 透传位置
database/sql ✅ 全面 go.opentelemetry.io/contrib/instrumentation/database/sql SQL 注释(/* trace_id=... */)
redis-go/v9 ✅ 全面 go.opentelemetry.io/contrib/instrumentation/github.com/redis/go-redis/redis/v9 命令参数或自定义 metadata
kafka-go ✅ Write/Read github.com/segmentio/kafka-go/otlp Kafka message headers
graph TD
    A[业务代码调用 rdb.Get(ctx, key)] --> B{Hook.Process}
    B --> C[从 ctx 提取 SpanContext]
    C --> D[生成 trace_id 字符串]
    D --> E[注入到 Redis 命令元数据]
    E --> F[服务端解析并续传链路]

2.5 多语言服务边界(HTTP Header标准化、W3C Trace Context兼容性验证)

微服务跨语言调用时,请求头语义不一致常导致链路断裂。需统一注入 traceparent 与 tracestate,并兼容 OpenTracing 的 uber-trace-id。

标准化 Header 注入示例(Go)

func injectTraceHeaders(req *http.Request, span opentracing.Span) {
    carrier := opentracing.HTTPHeadersCarrier(req.Header)
    opentracing.GlobalTracer().Inject(span.Context(), opentracing.HTTPHeaders, carrier)
    // 自动写入 traceparent(W3C)、tracestate、uber-trace-id(向后兼容)
}

逻辑分析:Inject 会依据 span 上下文生成符合 W3C Trace Context 规范的 traceparent(格式:00-<trace-id>-<span-id>-01),同时保留 uber-trace-id 供旧 Java 服务解析;tracestate 支持多供应商上下文透传。

兼容性验证要点

  • ✅ 所有语言 SDK 必须支持 traceparent 解析优先级高于自定义 header
  • ✅ tracestate 值需满足 key=value 对逗号分隔,且 key 不含空格或特殊字符
  • ❌ 禁止修改 trace-id 或 parent-id 字段大小写或分隔符
Header 键 标准来源 是否强制
traceparent W3C Trace Context 是
tracestate W3C Trace Context 否(推荐)
uber-trace-id Jaeger 否(兼容)
graph TD
    A[客户端发起请求] --> B{SDK自动注入}
    B --> C[traceparent: 00-123...-456...-01]
    B --> D[tracestate: rojo=00f067aa0ba902b7]
    B --> E[uber-trace-id: 123...:456...:0:1]
    C & D & E --> F[Go/Java/Python 服务均正确提取 SpanContext]

第三章:Span生命周期管理与语义化命名规范

3.1 基于Go HTTP Handler与Gin/Echo路由的Span自动命名策略

在 OpenTelemetry Go SDK 中,HTTP Span 的名称默认为 "HTTP GET" 等通用方法名,缺乏路由语义。为提升可观测性,需结合框架路由结构动态生成 span.Name。

Gin 框架自动命名实现

func TracingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        span := trace.SpanFromContext(c.Request.Context())
        // 利用 Gin 的 fullPath(含参数占位符)避免 cardinality 爆炸
        span.SetName("http.server" + c.FullPath()) // e.g., "/api/users/:id"
        c.Next()
    }
}

c.FullPath() 返回注册路由模式(如 /users/:id),而非实际路径(如 /users/123),保障 Span 名称稳定性与可聚合性。

Echo 与原生 Handler 对比

框架 可获取的路由标识 是否需中间件注入
Gin c.FullPath() 否
Echo c.Route().Path 否
net/http 无内置路由信息 是(需自定义 Router)

自动命名关键原则

  • ✅ 使用路由模板而非原始 URL
  • ❌ 避免 c.Request.URL.Path(导致高基数)
  • 🔄 统一前缀 "http.server" 符合 OTel 语义约定

3.2 数据库查询Span的SQL摘要脱敏与操作类型标注(SELECT/INSERT/UPDATE/DELETE)

SQL操作类型自动识别

基于正则前缀匹配与词法分析双校验机制,精准识别首关键字:

import re
def classify_sql(sql: str) -> str:
    sql_upper = sql.strip().upper()
    # 忽略注释和换行,取首有效token
    first_word = re.split(r'[\s;(/\n]+', sql_upper)[0]
    return {
        'SELECT': 'READ',
        'INSERT': 'WRITE',
        'UPDATE': 'WRITE',
        'DELETE': 'WRITE',
        'REPLACE': 'WRITE'
    }.get(first_word, 'UNKNOWN')

逻辑说明:re.split 提取首个非空白/分隔符token;字典映射兼顾语义层级(READ/WRITE),为后续权限策略提供基础标签。

敏感字段脱敏规则表

字段类型 脱敏方式 示例输入 输出示意
手机号 ***-****-**** 13812345678 138-****-5678
身份证号 前6后4保留 1101011990... 110101******9012

流程协同示意

graph TD
    A[原始SQL] --> B{提取首关键字}
    B -->|SELECT| C[标记READ + 字段级脱敏]
    B -->|INSERT/UPDATE/DELETE| D[标记WRITE + 值脱敏]
    C & D --> E[生成标准化Span摘要]

3.3 外部API调用Span的service.name与http.url安全截断规则

在分布式追踪中,service.name 和 http.url 的原始值可能包含敏感路径参数或过长域名,直接上报易引发隐私泄露与存储溢出。

截断策略优先级

  • 首先按 / 分割 URL,保留协议、主机、端口及首两级路径(如 https://api.pay.example.com/v1/charge → https://api.pay.example.com/v1/)
  • service.name 仅允许字母、数字、连字符、下划线,长度上限 50 字符,超长时取 SHA256 前 8 字节哈希后 Base32 编码

安全截断示例

from urllib.parse import urlparse
import re

def safe_url_truncate(url: str) -> str:
    parsed = urlparse(url)
    # 仅保留 scheme + netloc + 最多前两级 path 段
    path_parts = [p for p in parsed.path.strip('/').split('/') if p][:2]
    safe_path = '/' + '/'.join(path_parts) + '/' if path_parts else '/'
    return f"{parsed.scheme}://{parsed.netloc}{safe_path}"

该函数剥离查询参数与片段,避免 token、id 等敏感信息外泄;path_parts[:2] 保证语义可识别性(如 /v1/charge),同时规避 /v1/users/123456789/token?auth=xxx 类风险。

字段 截断方式 最大长度 示例输出
http.url 协议+主机+前两级路径 256 https://api.pay.example.com/v1/
service.name 正则清洗 + 哈希截断 50 pay-service-2a7f9b1c
graph TD
    A[原始URL] --> B{解析URL}
    B --> C[提取scheme/netloc/path]
    C --> D[路径分段取前2]
    D --> E[拼接安全URL]
    E --> F[上报Tracing系统]

第四章:可观测三支柱协同落地的关键实践

4.1 Error标注:panic恢复、error wrapping与otel-codes映射的统一拦截器

统一错误拦截器需同时处理三类信号:panic的捕获与转化、error链的语义增强(wrapping)、以及OpenTelemetry标准错误码(otel-codes)的自动映射。

核心拦截逻辑

func UnifiedErrorInterceptor(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                err := fmt.Errorf("panic recovered: %v", rec)
                // → 转为 wrapped error with otel-code Unknown
                wrapped := errors.Wrap(err, "http.recovery")
                logAndTraceError(wrapped, codes.Unknown)
                http.Error(w, "Internal Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

该中间件在defer中捕获panic,强制包装为带上下文的error,并调用logAndTraceError完成OTel错误码注入与日志关联。codes.Unknown是默认兜底码,后续由业务层显式覆盖。

映射策略表

Go error 类型 otel/codes.Code 触发条件
*url.Error codes.Unavailable 网络连接失败
sql.ErrNoRows codes.NotFound 查询无结果
自定义 AppError codes.InvalidArgument 实现 Code() codes.Code 方法

错误流转示意

graph TD
    A[HTTP Handler] --> B{panic?}
    B -->|Yes| C[recover → wrap → codes.Unknown]
    B -->|No| D[return error]
    D --> E{error implements Codeer?}
    E -->|Yes| F[Use e.Code()]
    E -->|No| G[Map via type switch]

4.2 Metric维度设计:按handler路径、HTTP状态码、method、client_region打标的最佳实践

为实现精细化可观测性,需在指标采集阶段注入高区分度标签。核心维度应正交且低基数:

  • handler_path:归一化路由(如 /api/v1/users/{id}),避免参数值污染基数
  • status_code:使用整型(200, 404, 503),禁用字符串形式
  • method:全大写标准化(GET, POST)
  • client_region:采用 ISO 3166-1 alpha-2 编码(us-east-1, cn-shanghai)
# Prometheus client Python 示例:动态打标
from prometheus_client import Counter

http_requests_total = Counter(
    'http_requests_total',
    'Total HTTP Requests',
    ['handler_path', 'status_code', 'method', 'client_region']
)

# 打标调用(生产环境需从请求上下文提取)
http_requests_total.labels(
    handler_path="/api/v1/users/{id}",
    status_code=str(resp.status_code),  # 确保为字符串类型
    method=request.method.upper(),
    client_region=geo_ip_to_region(request.remote_addr)
).inc()

逻辑分析:labels() 动态注入四维组合,status_code 强制转为字符串以匹配 Prometheus 标签类型要求;geo_ip_to_region 需预加载区域映射表,避免实时 DNS 查询引入延迟。

维度 推荐基数上限 风险提示
handler_path 路径含 UUID 将爆炸增长
client_region ≤ 20 按云厂商可用区粒度聚合
graph TD
    A[HTTP Request] --> B{Extract Context}
    B --> C[Normalize handler_path]
    B --> D[Parse status_code]
    B --> E[Upper method]
    B --> F[GeoIP → client_region]
    C & D & E & F --> G[Label Metric]

4.3 Log关联:结构化日志(zerolog/logrus)与trace_id/span_id的上下文注入方案

在分布式追踪中,日志需与 trace_id、span_id 对齐,才能实现链路级问题定位。

日志库选型对比

特性 zerolog logrus
性能(零分配) ✅ 原生支持 ❌ 需配合 logrus.WithContext
上下文注入方式 ctx.With().Str("trace_id", id).Logger() log.WithFields(log.Fields{"trace_id": id})

zerolog 注入示例

import "github.com/rs/zerolog"

func handler(ctx context.Context, log *zerolog.Logger) {
    // 从 context 提取 trace_id(如来自 OpenTelemetry)
    traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String()
    spanID := trace.SpanFromContext(ctx).SpanContext().SpanID().String()

    // 注入上下文字段,生成新 logger 实例
    ctxLog := log.With().
        Str("trace_id", traceID).
        Str("span_id", spanID).
        Logger()

    ctxLog.Info().Msg("request processed") // 输出含 trace_id/span_id 的 JSON
}

逻辑分析:log.With() 返回 zerolog.Context,调用 .Logger() 构建新 logger;Str() 是类型安全字段注入,避免 fmt.Sprintf 开销;所有字段被序列化为结构化 JSON,供 ELK 或 Loki 关联检索。

关键原则

  • trace_id/span_id 必须从 context 统一提取,而非手动传参
  • 每次 HTTP middleware 或 RPC handler 入口完成一次注入,避免跨层丢失
graph TD
    A[HTTP Request] --> B[OTel Middleware]
    B --> C[Extract trace_id/span_id]
    C --> D[Inject into logger]
    D --> E[Structured log output]

4.4 三者联动验证:基于OpenTelemetry Collector的Trace-Metric-Log关联性端到端测试

为实现可观测性三大支柱的语义对齐,需在 OpenTelemetry Collector 中启用 batch、memory_limiter 与 correlation 扩展协同工作。

关联性注入机制

通过 servicegraphprocessor 聚合 span 间的调用关系,并利用 resource 和 span attributes 统一注入 trace_id、span_id 与 service.name:

processors:
  servicegraph:
    metrics_exporter: prometheus
  batch:
  memory_limiter:
    check_interval: 5s
    limit_mib: 256

此配置确保 trace 上下文在 metric/log 采集中被透传;servicegraph 自动提取 trace_id 并绑定至指标标签 service.name 与 client_service.name。

关联字段映射表

数据类型 关键关联字段 来源示例
Trace trace_id, span_id HTTP header traceparent
Metric service.name, trace_id servicegraph 输出指标标签
Log trace_id, span_id OTLP log record attributes

端到端验证流程

graph TD
  A[Instrumented App] -->|OTLP/gRPC| B[OTel Collector]
  B --> C[servicegraphprocessor]
  C --> D[Prometheus Exporter]
  C --> E[Logging Pipeline]
  D & E --> F[Jaeger + Grafana + Loki]

验证时需确认同一 trace_id 在 Jaeger 中可跳转至对应日志与指标面板。

第五章:从Demo到生产:可观测性基建的演进路线图

初期验证:单体应用+本地Prometheus+Grafana看板

在电商促销活动预演阶段,团队将Spring Boot应用暴露/actuator/prometheus端点,使用Docker Compose一键拉起Prometheus v2.35与Grafana v9.1。通过以下配置实现5秒采集周期:

# prometheus.yml 片段
scrape_configs:
- job_name: 'spring-boot-app'
  static_configs:
  - targets: ['host.docker.internal:8080']

此时告警仅依赖Grafana内置阈值着色,无通知通道,但成功捕获了JVM内存泄漏导致GC频率突增300%的问题。

中期扩展:微服务化后的指标治理挑战

当订单、库存、支付拆分为6个K8s Deployment后,原方案暴露出三大瓶颈:

  • 指标命名不统一(http_request_duration_seconds vs api_latency_ms)
  • Label爆炸(env=prod,region=shanghai,version=v2.1.0,instance=pod-7f3a,job=order-service 导致TSDB存储膨胀47%)
  • 缺乏上下文关联(无法将慢查询Span与对应Pod CPU飙升关联)

团队引入OpenTelemetry Collector作为统一接收层,通过Processor标准化标签:

processors:
  resource:
    attributes:
    - action: insert
      key: service.namespace
      value: "ecommerce"

生产就绪:多维度可观测性融合架构

上线前完成三重加固: 维度 技术选型 关键能力
指标 VictoriaMetrics集群 支持10亿时间序列/秒写入
日志 Loki+Promtail+LogQL 基于指标的动态日志采样
链路追踪 Jaeger+OTLP+ServiceGraph 自动生成依赖拓扑与SLA热力图

通过Mermaid流程图呈现关键数据流:

graph LR
A[应用注入OTel SDK] --> B[OpenTelemetry Collector]
B --> C{分流处理器}
C --> D[VictoriaMetrics]
C --> E[Loki]
C --> F[Jaeger]
D --> G[Grafana Metrics Panel]
E --> G
F --> G

稳定性保障:SLO驱动的闭环机制

在双十一大促中,基于orders_created_total和payment_success_rate定义SLO:

  • 错误预算消耗率>80%时自动触发降级预案(关闭推荐模块)
  • 连续3分钟P99延迟>800ms则扩容API网关实例
    该机制使核心链路可用性从99.2%提升至99.99%,故障平均定位时间从47分钟缩短至6分钟。

持续演进:AI辅助根因分析实践

接入TimescaleDB时序数据库后,训练LSTM模型识别CPU使用率异常模式。当检测到k8s-node-cpu-usage出现周期性尖峰(间隔14分32秒),模型自动关联出同一节点上etcd心跳超时事件,并推送至运维群——该模式在三次灰度发布中提前12分钟发现etcd版本兼容性问题。

擅长定位疑难杂症,用日志和 pprof 找出问题根源。

发表回复

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