Posted in

Golang开发4年,如何用可观测性工程能力撬动45K+年薪?一线大厂SRE团队真实JD拆解与能力映射

第一章:Golang开发4年,如何用可观测性工程能力撬动45K+年薪?一线大厂SRE团队真实JD拆解与能力映射

翻阅字节、腾讯、蚂蚁等头部厂商近半年SRE/平台研发岗JD,一个高频共性要求跃然纸上:“熟练使用Go构建高可靠可观测性系统”,而非泛泛而谈“了解Prometheus”。这背后是岗位价值坐标的迁移——从写业务API的开发者,升级为定义系统健康边界的可观测性工程师。

我们抽取某大厂2024年Q2发布的“云原生平台可观测性方向高级工程师”JD,核心能力项与Golang实战能力直接映射如下:

JD关键词 Go工程化落地方式 典型产出示例
自研Metrics采集器 使用prometheus/client_golang暴露自定义指标,结合expvar动态注册运行时指标 http_requests_total{service="auth",status="5xx"}
日志结构化与上下文透传 基于zap+context.WithValue实现trace_id全链路注入,避免日志割裂 logger.Info("db query failed", zap.String("trace_id", ctx.Value("trace_id").(string)))
分布式追踪采样优化 用go.opentelemetry.io/otel/sdk/trace编写自定义Sampler,按HTTP状态码动态调整采样率 对5xx错误100%采样,2xx仅0.1%采样

真正拉开薪资差距的,不是会调用SDK,而是能诊断并重构可观测性链路。例如当发现Jaeger UI中Span缺失时,需定位到Go服务中otelhttp.NewHandler未包裹健康检查端点(如/healthz),导致探针流量无追踪上下文:

// ❌ 错误:健康检查绕过OTel中间件
r.HandleFunc("/healthz", healthHandler).Methods("GET")

// ✅ 正确:统一纳入追踪链路
r.Handle("/healthz", otelhttp.NewHandler(
    http.HandlerFunc(healthHandler),
    "health-check",
    otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
        return "HEALTHZ"
    }),
)).Methods("GET")

4年Go经验者若仅停留于CRUD,年薪天花板约28K;但若能主导将公司核心服务的P99延迟归因时间从4小时压缩至15分钟(通过定制化指标聚合+火焰图+日志上下文反查三元联动),则45K+并非溢价,而是市场对可观测性工程交付力的即时定价。

第二章:可观测性三大支柱的Go原生实践体系

2.1 Go语言级指标采集:从expvar到OpenTelemetry Metrics SDK深度集成

Go原生expvar提供轻量运行时指标(如goroutines、memstats),但缺乏标签(labels)、聚合语义与标准传输协议。演进路径自然指向OpenTelemetry Metrics SDK——它支持同步/异步观测、多维度打标及Exporter可插拔架构。

数据同步机制

OpenTelemetry采用Meter注册指标,Int64Counter等仪器实例执行原子记录:

import "go.opentelemetry.io/otel/metric"

meter := otel.Meter("example.com/http")
reqCounter := meter.NewInt64Counter("http.requests.total")
reqCounter.Add(ctx, 1, metric.WithAttributes(
    attribute.String("method", "GET"),
    attribute.String("status_code", "200"),
))

Add()触发同步计数;WithAttributes()注入维度标签,替代expvar的扁平JSON结构;ctx承载trace关联上下文,实现指标-链路双向可追溯。

演进对比

维度 expvar OpenTelemetry Metrics SDK
标签支持 ❌(仅键值对) ✅(attribute.Key强类型)
聚合控制 ❌(服务端聚合) ✅(客户端预聚合+流式导出)
协议兼容性 自定义HTTP/JSON OTLP/gRPC、Prometheus等
graph TD
    A[Go应用] --> B[expvar HTTP handler]
    A --> C[OTel Meter]
    C --> D[Aggregator]
    D --> E[OTLP Exporter]
    E --> F[Collector]

2.2 结构化日志工程:Zap + Logrus进阶配置与上下文透传实战

日志库选型对比

特性 Zap Logrus
性能(QPS) ≈ 10× Logrus 基准参考
结构化支持 原生字段键值对 需显式 WithFields
上下文透传能力 支持 Logger.With() 链式继承 依赖 Entry.WithField(s)

Zap 上下文透传实战

// 创建带全局 traceID 的 logger 实例
logger := zap.New(zapcore.NewCore(
    zapcore.NewJSONEncoder(zapcore.EncoderConfig{
        TimeKey:        "ts",
        LevelKey:       "level",
        NameKey:        "logger",
        CallerKey:      "caller",
        MessageKey:     "msg",
        StacktraceKey:  "stacktrace",
        EncodeLevel:    zapcore.LowercaseLevelEncoder,
        EncodeTime:     zapcore.ISO8601TimeEncoder,
        EncodeDuration: zapcore.SecondsDurationEncoder,
    }),
    zapcore.Lock(os.Stdout),
    zapcore.InfoLevel,
)).Named("api")

// 透传请求上下文
reqLogger := logger.With(zap.String("trace_id", "abc123"), zap.String("user_id", "u789"))
reqLogger.Info("request received") // 输出含 trace_id & user_id 的结构化 JSON

逻辑分析:With() 返回新 *Logger,不修改原实例,实现无副作用的上下文快照;所有字段自动序列化为 JSON 键值对,无需手动拼接字符串。

Logrus 动态字段注入

entry := logrus.WithFields(logrus.Fields{
    "service": "auth",
    "version": "v2.1.0",
})
// 后续调用自动携带上述字段
entry.Info("token validated")

该方式通过 Entry 封装实现字段继承,适合 middleware 中统一注入请求元数据。

2.3 分布式追踪落地:Go net/http与gRPC链路注入、采样策略与Jaeger/Tempo对接

链路注入:HTTP 与 gRPC 双通道支持

使用 go.opentelemetry.io/otel 统一注入:

// HTTP 中间件注入 trace context
func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        // 从 HTTP header 提取 traceparent(W3C 标准)
        span := trace.SpanFromContext(otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(r.Header)))
        ctx = trace.ContextWithSpan(ctx, span)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

逻辑说明:propagation.HeaderCarrier 实现 W3C traceparent/tracestate 解析;otel.GetTextMapPropagator().Extract 恢复跨进程上下文;trace.ContextWithSpan 将 span 注入 request context,供后续 span 创建继承 parent。

采样策略对比

策略 适用场景 动态调整能力
AlwaysSample 调试期全量采集 ❌
TraceIDRatioBased(0.01) 生产降噪(1%采样) ✅(可热更新)
ParentBased(AlwaysSample) 保障关键链路不丢 ✅

Jaeger 与 Tempo 对接差异

graph TD
    A[Go Service] -->|OTLP/gRPC| B[OpenTelemetry Collector]
    B --> C{Exporters}
    C --> D[Jaeger: thrift_http]
    C --> E[Tempo: otlp_http]
  • Jaeger 依赖 thrift_http 协议兼容旧部署;
  • Tempo 原生推荐 otlp_http,更轻量且支持多后端路由。

2.4 事件与告警闭环:基于Prometheus Alertmanager的Go服务自愈逻辑编码

自愈触发入口设计

通过 Alertmanager Webhook 接收告警,解析 Alerts 数组并过滤 severity=critical 事件:

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    var payload struct {
        Alerts []struct {
            Labels    map[string]string `json:"labels"`
            Annotations map[string]string `json:"annotations"`
        } `json:"alerts"`
    }
    json.NewDecoder(r.Body).Decode(&payload)
    for _, a := range payload.Alerts {
        if a.Labels["severity"] == "critical" {
            triggerSelfHealing(a.Labels["service"], a.Annotations["runbook_url"])
        }
    }
}

该 handler 解析 JSON 告警载荷,提取关键标签(如 service)和运行手册链接,作为自愈上下文。runbook_url 用于动态加载修复策略脚本。

自愈策略执行流程

graph TD
    A[Webhook接收] --> B{severity==critical?}
    B -->|是| C[查服务注册中心]
    C --> D[执行预定义修复动作]
    D --> E[上报恢复结果至Metrics]

自愈动作映射表

服务名 动作类型 超时(s) 恢复验证方式
order-api restart-pod 30 HTTP 200 + /health
cache-svc flush-all 15 Redis KEYS * count

2.5 可观测性数据管道构建:Go编写的轻量Agent实现日志/指标/Trace统一采集与过滤

轻量级可观测性 Agent 的核心在于协议抽象层统一与插件化采集流水线。采用 Go 编写,兼顾并发安全与内存效率,通过 otelcol 兼容接口接入 OpenTelemetry SDK,同时支持原生 syslog、Prometheus Exporter 和 Jaeger Thrift 协议。

数据同步机制

采集器使用 sync.Map 缓存待处理 span/metric/log 批次,并通过带背压的 channel(容量 1024)投递至过滤器链:

// 初始化采集通道(带缓冲与超时控制)
ch := make(chan *otelcolexporter.ExportRequest, 1024)
go func() {
    for req := range ch {
        if !filterChain.Apply(req) { continue } // 预过滤丢弃
        exporter.Export(ctx, req)               // 异步导出
    }
}()

ExportRequest 封装原始 OTLP 数据;filterChain.Apply() 支持正则日志采样、标签白名单、Trace 采样率动态配置(如 sampling_rate=0.1)。

过滤能力对比

过滤类型 支持协议 动态热重载 示例配置
日志行级过滤 Syslog/JSON ✅ exclude: "healthz|DEBUG"
指标标签剪裁 Prometheus ✅ keep_labels: ["job","instance"]
Trace 采样 OTLP/Jaeger ✅ probabilistic: 0.05
graph TD
    A[日志文件/Stdout] --> B[Parser]
    C[Prometheus Scraper] --> B
    D[OTLP Receiver] --> B
    B --> E[Filter Chain]
    E --> F[Batcher]
    F --> G[Exporter]

第三章:从开发到SRE——Go工程师的可观测性能力跃迁路径

3.1 SLO驱动的Go服务可靠性设计:错误预算、熔断阈值与降级代码嵌入实践

SLO(Service Level Objective)是可靠性设计的北极星。以 99.5% 可用性 为例,每月允许约 216 分钟不可用,由此反推错误预算——每小时最多容忍 0.5 次 P99 超时错误。

错误预算实时跟踪

// 基于 Prometheus Counter 实时计算剩余错误预算
var (
    reqTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    }, []string{"status_code", "endpoint"})
    budgetSeconds = 2160 // 30天SLO对应秒数
)

// 每分钟计算:remaining = budgetSeconds - (errorRate × elapsedSeconds)

逻辑:通过 status_code 标签区分 5xx 错误,结合 rate(http_requests_total{status_code=~"5.."}[1m]) 计算错误率,动态扣减预算。

熔断与降级协同机制

组件 触发条件 降级动作
PaymentSvc 连续3次超时 > 800ms 返回缓存订单状态
Notification 错误率 > 5% 持续2分钟 切至邮件异步通道
graph TD
    A[HTTP Handler] --> B{Error Budget > 0?}
    B -->|Yes| C[调用上游]
    B -->|No| D[触发熔断器]
    D --> E[执行预注册降级函数]
    E --> F[返回兜底响应]

3.2 Go微服务可观测性基线建设:CI/CD中自动注入健康检查、pprof端点与诊断接口

在CI/CD流水线中,通过构建时代码注入(而非运行时配置)统一启用可观测性端点,确保每个服务实例默认具备基础诊断能力。

自动化注入机制

使用Go go:generate + 模板代码生成器,在main.go构建前自动插入标准诊断路由:

//go:generate go run ./internal/cmd/inject-observability
func init() {
    http.HandleFunc("/healthz", healthzHandler)      // 轻量级存活探针
    http.HandleFunc("/debug/pprof/", pprof.Index)     // 标准pprof入口
    http.HandleFunc("/diagnostics", diagnosticsHandler) // 自定义集群上下文诊断
}

该注入由CI脚本触发(如make inject-obs),避免人工遗漏;/debug/pprof/继承net/http/pprof默认行为,无需额外依赖;/diagnostics返回服务版本、启动时间、上游依赖连通性快照。

端点能力对比

端点 触发方式 数据粒度 安全约束
/healthz HTTP GET 二值(up/down) 无认证,公开可访问
/debug/pprof/ HTTP GET CPU/heap/block trace 默认仅绑定127.0.0.1,CI中自动加-tags=prod禁用(生产环境)
/diagnostics HTTP POST(需Bearer Token) JSON结构化元数据 JWT校验,限白名单IP
graph TD
    A[CI Pipeline] --> B[源码扫描]
    B --> C{含//go:generate 注释?}
    C -->|是| D[执行inject-observability]
    C -->|否| E[警告并标记为可观测性不合规]
    D --> F[生成诊断路由注册代码]
    F --> G[编译进二进制]

3.3 生产环境根因分析实战:结合Go runtime trace、GODEBUG与火焰图定位goroutine泄漏

数据同步机制

某服务在持续运行48小时后,runtime.NumGoroutine() 从200陡增至12,000+,GC pause显著延长。初步怀疑 goroutine 泄漏。

诊断工具组合拳

  • 启用 GODEBUG=gctrace=1,schedtrace=1000 观察调度器积压;
  • 采集 go tool trace:GODEBUG=schedtrace=1000 ./app & sleep 30 && kill -SIGUSR2 $!;
  • 生成火焰图:go tool pprof -http=:8080 cpu.pprof(需先 pprof --cpu 采样)。

关键代码片段

func startSyncWorker(ctx context.Context, ch <-chan Item) {
    for item := range ch { // ❌ 缺少 ctx.Done() 检查
        go func(i Item) {
            process(i) // 阻塞IO未设超时
        }(item)
    }
}

range ch 不感知 ctx 生命周期,channel 关闭后仍可能有 goroutine 在 process() 中阻塞;go func(i Item) 形成闭包变量捕获,加剧泄漏规模。

定位证据表

工具 关键指标 异常表现
runtime/trace Goroutine creation/sec >500/s 持续增长
GODEBUG=schedtrace GRQ(全局运行队列长度) 稳定 >3000
pprof 火焰图 runtime.gopark 占比 >65% 聚焦于 net/http.readLoop
graph TD
    A[HTTP请求] --> B[启动syncWorker]
    B --> C{ch是否关闭?}
    C -->|否| D[持续fork goroutine]
    C -->|是| E[已泄漏goroutine仍在park]
    D --> E

第四章:大厂SRE岗位JD深度解码与Go技能映射矩阵

4.1 JD关键词逆向解析:解读“eBPF辅助观测”“OpenTelemetry Collector定制”背后的Go扩展能力

JD内部工程实践中,“eBPF辅助观测”并非直接运行eBPF程序,而是通过Go封装libbpf-go调用链,实现低开销内核态指标采集;“OpenTelemetry Collector定制”则依托其component.Builder接口体系,以Go插件式扩展receiver/processor/exporter。

核心扩展机制

  • Go的plugin包(受限于CGO与版本兼容性)
  • otelcol.Config结构体的动态解码支持
  • eBPF Map与Go struct的零拷贝映射(如bpf.Map.Lookup()返回unsafe.Pointer)

eBPF事件采集示例

// 加载并关联perf event array
map, _ := bpfModule.Map("events")
reader, _ := perf.NewReader(map, 1024*1024)
// 每次Read()触发内核eBPF程序写入,Go侧反序列化为Go struct

perf.NewReader创建环形缓冲区,map需为BPF_MAP_TYPE_PERF_EVENT_ARRAY类型;1024*1024指定内核页缓冲大小,影响丢包率与内存占用。

OTel Collector扩展能力对比

扩展点 原生支持 Go定制难度 典型JD场景
Prometheus receiver ✅ ⚠️中 旧服务指标拉取适配
eBPF exporter ❌ 🔥高 容器网络延迟热力图生成
graph TD
    A[eBPF Probe] -->|perf_event_output| B[Ring Buffer]
    B --> C[Go perf.Reader]
    C --> D[JSON序列化]
    D --> E[OTel Collector Pipeline]
    E --> F[Jaeger/Zipkin Exporter]

4.2 简历竞争力强化:用Go编写可观测性工具库(如metrics-exporter、trace-validator)打造技术影响力

开源可观测性工具库是工程师技术影响力的放大器——它既是扎实工程能力的证明,也是与社区深度对话的接口。

为什么选择 Go?

  • 并发模型天然适配指标采集与 trace 校验场景
  • 静态编译产物便于嵌入各类基础设施(K8s DaemonSet、Sidecar)
  • 生态成熟:prometheus/client_golang、go.opentelemetry.io/otel 提供坚实基座

metrics-exporter 核心设计

// Exporter 暴露自定义指标:HTTP 请求延迟直方图
var httpLatency = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "Latency distribution of HTTP requests",
        Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, // 单位:秒
    },
    []string{"method", "path", "status"},
)

该代码注册带标签维度的直方图指标。Buckets 显式定义分位统计粒度;[]string{"method", "path", "status"} 支持多维下钻分析,为 SRE 提供精准根因定位能力。

trace-validator 工作流

graph TD
    A[接收 Jaeger/OTLP trace] --> B{Span 必填字段校验}
    B -->|通过| C[检查 parent_id 有效性]
    B -->|失败| D[返回结构错误码]
    C --> E[验证 trace_id 跨 Span 一致性]
    E --> F[输出合规性报告 JSON]
维度 合规要求
trace_id 全链路唯一且长度为 32 字符十六进制
span_id 非空、非零、长度 16 字符
start_time 早于 end_time,单位纳秒

4.3 面试高频场景还原:基于真实SRE故障复盘题,用Go模拟日志异常检测与自动聚合

某次线上服务突发5xx激增,SRE团队通过日志聚类发现/api/v2/payment路径下timeout=1500ms的错误集中爆发——这正是典型面试中要求现场建模的“异常模式识别+根因初筛”双重要求。

核心检测逻辑

使用滑动时间窗口(60s)统计各endpoint的错误率与P95延迟:

type LogEntry struct {
    Path     string `json:"path"`
    Status   int    `json:"status"`
    Duration int64  `json:"duration_ms"`
}

// 每30秒触发一次聚合:按Path分组计算 error_rate > 5% 且 p95 > 1200ms
func detectAnomaly(entries []LogEntry) []string {
    // ... 实现分组、百分位计算与阈值判定
    return []string{"/api/v2/payment"}
}

逻辑说明:Duration单位为毫秒,error_rate仅统计status >= 500;P95采用快速选择算法(O(n)),避免排序开销;阈值参数化便于压测调优。

异常传播路径示意

graph TD
A[原始日志流] --> B[解析与结构化]
B --> C[按Path+Status标签分桶]
C --> D[滑动窗口聚合]
D --> E{error_rate > 5% ∧ p95 > 1200ms?}
E -->|是| F[触发告警+自动聚合TOP3异常维度]

关键参数配置表

参数名 默认值 说明
windowSec 60 聚合时间窗口长度(秒)
minSample 10 触发P95计算的最小样本数
alertThresh 0.05 错误率告警阈值

4.4 薪资谈判锚点构建:将Go可观测性工程产出量化为MTTR降低率、告警准确率提升等业务指标

从日志到业务价值的映射链路

可观测性工程的价值需锚定在SRE与业务方共同认可的指标上:MTTR(平均故障恢复时间)、告警准确率(Precision = TP / (TP + FP))、P99延迟下降幅度。

关键指标采集示例(Prometheus + Go SDK)

// 在HTTP中间件中埋点,统计真实故障恢复耗时
var mttrHist = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "service_mttr_seconds",
        Help:    "MTTR per service, measured from alert trigger to 200 OK after recovery",
        Buckets: prometheus.ExponentialBuckets(10, 2, 8), // 10s ~ 1280s
    },
    []string{"service", "severity"},
)

逻辑说明:service_mttr_seconds 仅在确认故障已闭环(如健康检查连续3次通过+告警自动解除)后打点;severity 标签关联PagerDuty事件等级,确保MTTR统计与运维SLA对齐。

量化成效对照表

指标 上线前 上线后 提升幅度
平均MTTR 28.6 min 9.2 min ↓67.8%
告警准确率 53.1% 89.4% ↑36.3pp

根因归因流程

graph TD
    A[告警触发] --> B{是否匹配黄金信号?}
    B -->|否| C[降噪过滤]
    B -->|是| D[关联Trace/Log/Metric]
    D --> E[定位至Go服务goroutine阻塞]
    E --> F[自动注入pprof分析任务]
    F --> G[生成MTTR归因报告]

第五章:总结与展望

技术栈演进的实际影响

在某大型电商中台项目中,团队将微服务架构从 Spring Cloud Alibaba 迁移至基于 Kubernetes + Istio 的云原生体系后,API 平均响应延迟下降 37%,服务扩缩容时间从分钟级压缩至 12 秒内。关键指标变化如下表所示:

指标 迁移前(Spring Cloud) 迁移后(K8s+Istio) 变化率
部署成功率 92.4% 99.8% +7.4%
故障定位平均耗时 28 分钟 6.3 分钟 -77.5%
日均自动弹性伸缩次数 0 41 —

生产环境灰度发布的典型路径

某金融风控平台采用“代码分支 → GitTag 触发流水线 → Helm Chart 版本化部署 → Prometheus + Grafana 实时指标比对 → 自动回滚阈值触发”闭环流程。当 v2.3.1 版本在 5% 流量灰度中出现 Redis 连接池耗尽问题时,系统在 89 秒内完成指标异常识别、流量切回与告警推送,全程无人工干预。

# 示例:Istio VirtualService 中的渐进式流量切分配置
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: risk-service
spec:
  hosts:
  - risk-api.example.com
  http:
  - route:
    - destination:
        host: risk-service
        subset: v2.3.0
      weight: 95
    - destination:
        host: risk-service
        subset: v2.3.1
      weight: 5

多云灾备架构落地难点

某政务云项目在阿里云华东1与腾讯云华南2间构建双活集群,遭遇 DNS 解析缓存不一致、跨云存储网关吞吐瓶颈(实测峰值仅 18MB/s)、TLS 证书轮换不同步三大硬伤。最终通过自研 DNS 探针服务(每 3 秒探测 TTL 剩余值并强制刷新)、部署 RDMA 加速的跨云对象网关(吞吐提升至 126MB/s)、统一 ACME 客户端集群管理证书生命周期得以解决。

AI 辅助运维的规模化验证

在 2023 年 Q3 的 17 个省级电力调度系统中,接入基于 Llama-3-8B 微调的 AIOps 模型后,告警聚类准确率达 89.2%(对比传统规则引擎提升 41.6%),根因分析平均耗时由 42 分钟降至 9.7 分钟。模型输入为标准化后的 Zabbix 告警文本 + Prometheus 时间序列特征向量(共 137 维),输出为故障类型标签与 Top3 关联节点。

flowchart LR
A[原始告警日志] --> B[语义清洗与实体抽取]
B --> C[时序特征对齐]
C --> D[多模态编码器]
D --> E[故障分类头]
D --> F[拓扑关联图神经网络]
E --> G[告警归因报告]
F --> G

开源工具链的定制化改造成本

某车企智能座舱 OTA 平台评估了 Argo CD、Flux 和 Jenkins X 三套 GitOps 方案。最终选择 Flux 并投入 3 人月完成深度改造:增加车载固件签名验签插件、适配 UFS 存储的增量差分包生成器、集成 ISO 21434 网络安全合规检查模块。改造后发布审核通过率从 63% 提升至 99.1%,但 CI/CD 流水线维护复杂度上升 2.3 倍。

边缘计算场景下的资源约束突破

在 5G 工业质检边缘节点(ARM64 + 4GB RAM)上,通过 TensorRT-OSS 编译优化 + 内存池预分配 + 异步 DMA 数据搬运,将 ResNet-18 推理延迟压至 83ms(满足 100ms SLA),GPU 显存占用从 1.2GB 降至 386MB。该方案已在 127 条产线部署,单节点年节省云推理费用约 18.4 万元。

不张扬,只专注写好每一行 Go 代码。

发表回复

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