Posted in

Golang系统设计避坑手册(23个血泪教训总结):K8s环境下内存泄漏、goroutine泄露、context误用真实案例复盘

第一章:Golang系统设计避坑总览与K8s环境特性认知

在云原生场景下,Golang服务若未经针对性设计,极易在Kubernetes环境中暴露稳定性、可观测性与资源治理短板。K8s不是“运行容器的黑盒”,而是具备强声明式语义、生命周期管理、网络抽象与弹性调度能力的分布式操作系统——其核心特性直接决定Golang程序的健壮边界。

容器化运行时的本质约束

K8s Pod生命周期由kubelet严格管控:启动阶段依赖 readinessProbe 探针确认就绪,终止前发送 SIGTERM 并等待 terminationGracePeriodSeconds(默认30秒)后强制 SIGKILL。Golang服务必须优雅处理信号,例如:

// 捕获SIGTERM并完成HTTP Server graceful shutdown
server := &http.Server{Addr: ":8080", Handler: mux}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
    <-sigChan
    log.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    server.Shutdown(ctx) // 阻塞至所有连接关闭或超时
}()

网络与服务发现模型差异

K8s Service提供集群内稳定的DNS名称(如 my-service.default.svc.cluster.local),但默认不支持客户端负载均衡。Golang调用应避免硬编码IP,改用Service DNS,并配置合理重试与超时:

场景 推荐实践
HTTP客户端 使用 http.DefaultClient + context.WithTimeout
gRPC客户端 启用 WithBlock() + WithTimeout 避免阻塞初始化
数据库连接池 SetMaxOpenConns(10) 防止Pod重启时连接风暴

资源感知与健康检查对齐

LivenessProbe 若仅检查端口连通性,将无法捕获goroutine泄漏或内存OOM;ReadinessProbe 应真实反映业务就绪状态(如检查数据库连接、依赖服务可用性)。建议在 /healthz 中集成关键依赖探活逻辑,而非简单返回200。

第二章:内存泄漏的识别、定位与根治

2.1 Go内存模型与GC机制在K8s容器中的行为偏差分析

Go运行时的GC(如三色标记-清除)默认基于堆内存压力触发,但在K8s容器中受限于cgroup v1/v2内存限制(memory.limit_in_bytes),导致GC时机严重偏移。

GC触发阈值失配

当容器内存上限设为512MiB,而Go程序仅分配300MiB时,Go runtime仍可能因GOGC=100(默认)延迟GC——但cgroup OOM Killer可能在480MiB时直接杀进程。

关键参数对比表

参数 宿主机行为 K8s容器内表现 风险
GOGC 基于实时堆增长比例 同,但堆上限被cgroup截断 提前OOM
GOMEMLIMIT 无影响 强制GC上限(推荐设为limit * 0.9 避免OOM-Killer介入

推荐启动配置

# 在容器entrypoint中显式约束
GOMEMLIMIT=480MiB GOGC=50 ./myapp

该配置使Go runtime将480MiB视作“软上限”,当堆达约240MiB即触发GC,显著降低OOM概率。GOGC=50进一步缩短标记周期,适配容器短生命周期特性。

内存同步机制

cgroup memory.stat 中的 total_inactive_file 与Go heap profile无直接映射,需通过 /sys/fs/cgroup/memory/.../memory.usage_in_bytes 实时对齐观测点。

2.2 pprof实战:从heap profile到goroutine stack trace的全链路追踪

启动带pprof的HTTP服务

import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // 应用主逻辑...
}

_ "net/http/pprof" 自动注册 /debug/pprof/ 路由;端口 6060 避免与主服务冲突;goroutine 方式启动确保不阻塞主线程。

关键profile类型对比

Profile 触发路径 典型用途
heap /debug/pprof/heap 定位内存泄漏与对象堆积
goroutine /debug/pprof/goroutines?debug=2 查看所有 goroutine 栈帧快照

全链路抓取流程

# 1. 抓取堆快照(30秒内内存分配峰值)
curl -s http://localhost:6060/debug/pprof/heap > heap.pb.gz
# 2. 抓取阻塞型 goroutine 栈(含锁等待)
curl -s "http://localhost:6060/debug/pprof/goroutines?debug=2" > stacks.txt

graph TD
A[启动pprof HTTP服务] –> B[采集heap profile]
B –> C[分析内存热点]
A –> D[采集goroutine stack trace]
D –> E[识别死锁/阻塞点]
C & E –> F[交叉验证泄漏根源]

2.3 常见泄漏模式复盘:sync.Pool误用、全局map未清理、cgo引用未释放

sync.Pool 的生命周期陷阱

sync.Pool 不保证对象复用,更不自动回收。若 Put 过期对象(如含闭包或 goroutine 引用),将导致内存滞留:

var pool = sync.Pool{
    New: func() interface{} { return &bytes.Buffer{} },
}
func leakyHandler() {
    buf := pool.Get().(*bytes.Buffer)
    buf.Reset()
    // 错误:启动 goroutine 持有 buf,但未确保其结束前 Put
    go func() { time.Sleep(time.Second); buf.WriteString("done") }()
    pool.Put(buf) // buf 仍被 goroutine 引用 → 泄漏
}

bufpool.Put 后仍被活跃 goroutine 持有,Pool 无法感知,导致后续 GC 无法回收该实例。

全局 map 与 cgo 引用的协同泄漏

三类泄漏常交织发生:

泄漏类型 触发条件 检测难点
sync.Pool 误用 Put 前存在外部强引用 pprof heap 无明显根路径
全局 map 未清理 key 持久化但 value 未删除 map 长期增长,GC 无效
cgo 引用未释放 C 分配内存由 Go 指针持有 cgo 检查器(-gcflags=-gcshrinkstack=0)易遗漏

数据同步机制

sync.Pool 清理仅在 GC 时触发,且仅清理未被任何 goroutine 引用的对象;全局 map 需配合 sync.Map.Delete 或 TTL 清理;cgo 场景必须显式调用 C.free 或使用 runtime.SetFinalizer 补救。

2.4 K8s资源限制下OOMKilled的真实日志解读与反向验证方法

当容器因内存超限被内核 OOM Killer 终止时,kubectl describe pod 中的 Last State: Terminated (OOMKilled) 是表象,真实线索藏于节点日志与 cgroup 指标中。

关键日志定位路径

  • 节点侧:journalctl -u kubelet --since "1 hour ago" | grep -A5 -B5 "oom\|memory"
  • 容器侧:kubectl logs <pod> --previous(仅当容器曾输出过日志)

反向验证三步法

  1. 查看 Pod 内存限制与请求:kubectl get pod <p> -o jsonpath='{.spec.containers[0].resources.limits.memory}'
  2. 获取实际内存使用峰值(cgroup v1):
    # 进入对应容器的 cgroup 目录(需 node ssh 权限)
    cat /sys/fs/cgroup/memory/kubepods/burstable/pod<uid>/container<hash>/memory.max_usage_in_bytes
    # 输出示例:2147483648 → 2GiB,若接近或等于 limits.memory,则证实 OOMKilled 合理

    该值为自容器启动以来内存使用峰值(字节),对比 limits.memory(如 2Gi = 2147483648)可确认是否触限。注意单位需统一换算(1Gi = 1024³ 字节)。

常见误判对照表

现象 实际原因 验证命令
OOMKilledmax_usage_in_bytes 仅占 limit 60% 应用存在内存泄漏+GC 延迟,OOM 发生在 cgroup 统计更新间隙 cat /sys/fs/cgroup/memory/kubepods/.../memory.failcnt > 0
Exit Code 137OOMKilled 标签 Pod 未设 memory limit,被节点级 OOM Killer 杀死 kubectl get pod -o wide + 检查 limits.memory 是否为空
graph TD
    A[Pod OOMKilled] --> B{检查 memory.limit_in_bytes}
    B -->|等于 0 或未设| C[节点级 OOM]
    B -->|非零| D[读取 max_usage_in_bytes]
    D --> E[比较 usage ≥ limit?]
    E -->|是| F[确认资源限制触发]
    E -->|否| G[检查 memory.failcnt 或 kernel log 中 oom_kill_process]

2.5 内存压测自动化方案:基于k6+Prometheus+pprof的CI级泄漏拦截流水线

核心架构设计

graph TD
A[k6压测脚本] –>|HTTP指标流| B(Prometheus)
B –>|/debug/pprof/heap| C[pprof分析服务]
C –> D[CI失败门禁]

关键检测逻辑

// k6内存监控断言(每30s采样一次)
export default function () {
  http.get('http://api.example.com/data');
  if (__ENV.CI && (memory.allocated > 150*1024*1024)) { // 超150MB触发告警
    throw new Error('Memory leak detected in CI');
  }
}

该脚本在CI环境中启用内存阈值熔断,memory.allocated为k6内置指标,单位字节;150MB阈值经历史基线校准,兼顾灵敏性与误报率。

工具链协同能力对比

组件 实时性 堆栈深度 CI集成难度
k6 毫秒级 进程级 ⭐⭐⭐⭐
Prometheus 15s间隔 指标聚合 ⭐⭐⭐
pprof 按需触发 函数级 ⭐⭐

第三章:goroutine泄露的系统性防控

3.1 Goroutine生命周期管理:从启动到退出的可观测性断点设计

Goroutine 的可观测性需在关键状态节点注入钩子,而非依赖事后分析。

启动阶段断点

通过 runtime.SetTraceCallback 注册启动事件回调,捕获 GoCreate 事件:

runtime.SetTraceCallback(func(event *trace.Event) {
    if event.Type == trace.GoCreate {
        log.Printf("goroutine %d spawned at %s", event.G, event.Stk[0])
    }
})

该回调在调度器创建 goroutine 瞬间触发;event.G 为唯一 goroutine ID,event.Stk[0] 提供起始调用栈帧,用于溯源启动位置。

生命周期状态映射

状态 对应 trace 事件 可观测性价值
启动 GoCreate 定位并发源头
阻塞(网络) GoBlockNet 识别 I/O 瓶颈点
退出 GoEnd 统计存活时长与泄漏风险

退出监控机制

使用 defer 结合 runtime.GoID()(需 Go 1.22+)实现轻量级退出日志:

func worker() {
    id := runtime.GoID()
    defer log.Printf("goroutine %d exited", id)
    // ... work
}

runtime.GoID() 返回当前 goroutine 唯一标识;defer 确保无论何种路径退出均执行,避免遗漏。

3.2 泄露高发场景还原:无缓冲channel阻塞、WaitGroup误用、defer中启动goroutine

无缓冲 channel 阻塞导致 goroutine 永久挂起

以下代码中,ch 为无缓冲 channel,send 协程在无接收者时永久阻塞:

func leakByUnbufferedChan() {
    ch := make(chan int) // 无缓冲,需配对收发
    go func() { ch <- 42 }() // 永不返回:无人接收
}

逻辑分析:make(chan int) 创建同步 channel,发送操作 ch <- 42 在无 goroutine 执行 <-ch 前永不返回,该 goroutine 被调度器标记为 Gwaiting 并持续驻留内存。

WaitGroup 误用引发等待死锁

常见错误:Add() 调用晚于 Go 启动,或漏调 Done()

错误模式 后果
wg.Add(1)go f() 之后 wg.Wait() 立即返回,f 可能未执行完
忘记 defer wg.Done() wg.Wait() 永不返回,goroutine 泄露

defer 中启动 goroutine 的隐蔽陷阱

func dangerousDefer() {
    wg := &sync.WaitGroup{}
    wg.Add(1)
    defer wg.Wait()           // defer 在函数返回前执行
    defer go func() {         // 此 goroutine 在 defer 链中启动,但 wg.Done() 不在其内!
        time.Sleep(100 * time.Millisecond)
        // wg.Done() 缺失 → Wait 永不返回
    }()
}

逻辑分析:defer go 仅延迟 goroutine 启动时机,不绑定其生命周期;wg.Done() 未被调用,主 goroutine 在 wg.Wait() 处永久阻塞。

3.3 生产环境goroutine数突增的SRE响应手册与自愈脚本模板

快速诊断三步法

  • 执行 go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2 获取全量栈快照
  • 检查 /debug/pprof/goroutine?debug=1 输出中高频阻塞模式(如 select, semacquire, netpoll
  • 关联 Prometheus 指标:go_goroutines{job="api"} + rate(go_gc_duration_seconds_count[5m])

自愈脚本核心逻辑(Bash + Go)

#!/bin/bash
# 检测阈值:连续2次采样 > 5000 goroutines 且增幅 >30%
THRESHOLD=5000; INCREASE_RATE=30
CURRENT=$(curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | wc -l)
PREV=$(cat /tmp/goroutines.prev 2>/dev/null || echo "0")

if [[ $CURRENT -gt $THRESHOLD ]] && \
   [[ $(echo "($CURRENT - $PREV) * 100 / $PREV" | bc -l 2>/dev/null | cut -d. -f1) -gt $INCREASE_RATE ]]; then
  echo "$(date): High goroutine alert ($CURRENT)" >> /var/log/goroutine-alert.log
  curl -X POST http://localhost:8080/api/v1/self-heal --data '{"action":"dump_and_restart"}'
fi
echo $CURRENT > /tmp/goroutines.prev

逻辑分析:脚本通过 /debug/pprof/goroutine?debug=1 返回文本行数估算 goroutine 数量(每 goroutine 至少占 1 行),避免解析 JSON 开销;bc 计算相对增长率确保精度;状态持久化至 /tmp/goroutines.prev 支持跨周期对比。参数 THRESHOLDINCREASE_RATE 需按服务基线调优。

常见根因与处置优先级

类型 典型表现 推荐动作
泄漏 runtime.gopark 占比 >70%,无对应 runtime.goready 滚动重启 + 分析 pprof/goroutine?debug=2 栈帧
阻塞IO 大量 net.(*pollDesc).wait 检查下游超时配置与连接池耗尽
误用 select select {} 或无 default 的 channel 操作 静态扫描 select 语句并注入超时
graph TD
    A[告警触发] --> B{goroutine > THRESHOLD?}
    B -->|Yes| C[计算环比增长率]
    B -->|No| D[忽略]
    C -->|>30%| E[记录日志 & 触发自愈API]
    C -->|≤30%| F[更新历史值,等待下次采样]
    E --> G[生成 pprof 快照]
    G --> H[优雅重启或熔断非关键协程]

第四章:context深度误用与分布式超时治理

4.1 Context取消传播的隐式失效:WithCancel/WithTimeout在K8s滚动更新中的断裂现象

数据同步机制

Kubernetes滚动更新时,旧Pod被SIGTERM终止,但其内部context.WithCancel()创建的子Context可能未及时收到父Context取消信号——因控制器与Pod间无强引用路径。

典型失效场景

  • 新Pod已就绪并接管流量
  • 旧Pod的http.Server.Shutdown()阻塞于长连接处理
  • ctx.Done()通道未关闭,导致select { case <-ctx.Done(): ... }永不触发
// 旧Pod中常见错误模式
func handleRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) {
    // WithTimeout 基于父ctx(如request.Context()),但父ctx未随Pod终止而取消
    childCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel() // ❌ cancel() 不会被调用,若上层未显式传播取消
    // ...
}

该代码依赖ctx本身可取消;但在K8s中,r.Context()继承自http.Server,而Server未监听Pod生命周期事件,故ctx保持活跃直至超时或连接自然关闭。

关键参数说明

  • ctx:来自http.Request,生命周期绑定net.Conn而非Pod
  • 30*time.Second:硬编码超时易掩盖传播断裂,应动态对齐terminationGracePeriodSeconds
组件 是否参与Cancel传播 原因
kubelet 发送SIGTERM并等待grace period
http.Server 默认不监听外部信号,ctx不继承Pod状态
context.WithCancel ⚠️ 仅当显式调用cancel()才生效,无自动级联
graph TD
    A[Pod Terminating] --> B[kubelet sends SIGTERM]
    B --> C[http.Server begins Shutdown]
    C --> D{Does ctx.Done() close?}
    D -->|No| E[Child contexts remain active]
    D -->|Yes| F[Graceful cleanup]

4.2 HTTP服务中context.Context与net/http.Request.Context的耦合陷阱与解耦实践

陷阱根源:隐式生命周期绑定

http.Request.Context() 返回的 context.Context 与请求生命周期强绑定——一旦响应写出或连接关闭,其 Done() channel 立即关闭,所有衍生 context 均不可再用于异步任务

典型误用示例

func handler(w http.ResponseWriter, r *http.Request) {
    // ❌ 危险:ctx 随请求结束而 cancel,goroutine 可能 panic
    go func() {
        time.Sleep(5 * time.Second)
        log.Println("Async job done") // 若此时 ctx 已 cancel,log 可能被 context.Err() 中断
    }()
}

此处 r.Context()*http.contextCtx,底层封装了 net.Conn 的关闭信号。任何基于它派生的 WithTimeout/WithValue 在响应后失效,导致超时控制失准、value 丢失或 select 永久阻塞。

安全解耦方案

  • ✅ 使用 context.WithCancel(context.Background()) 启动独立生命周期
  • ✅ 通过 sync.WaitGrouperrgroup.Group 管理异步任务边界
方案 生命周期控制者 适用场景
r.Context() HTTP Server 请求内同步处理
context.Background() + 显式 cancel 业务逻辑层 跨请求长时任务(如消息投递)
graph TD
    A[HTTP Request] --> B[r.Context()]
    B --> C[自动 Cancel on Close]
    D[Business Task] --> E[context.WithCancel<br>context.Background()]
    E --> F[Manual Cancel on Success/Failure]

4.3 跨微服务调用链中context.Value滥用导致的内存膨胀与调试盲区

问题根源:隐式透传与生命周期错配

context.WithValue() 本为传递短暂、只读、低频的请求元数据(如用户ID、traceID),但实践中常被用于透传结构体、数据库连接、HTTP客户端甚至日志字段,导致 context 携带不可回收对象。

典型滥用代码示例

// ❌ 危险:将 *sql.DB 塞入 context —— DB 实例永不释放!
ctx = context.WithValue(ctx, "db", dbInstance) // dbInstance 是全局复用连接池,但 context 生命周期远长于单次请求

// ✅ 正确:仅传轻量标识符,DB 由依赖注入或中间件统一管理
ctx = context.WithValue(ctx, "tenant_id", "t-789")

逻辑分析:context.Value 底层是 map[interface{}]interface{},键值对随 context 树传播;若值为大对象或含闭包/指针,GC 无法回收其引用链,尤其在长生命周期 context(如 context.Background() 衍生出的超时 context)中持续驻留。

影响对比表

场景 内存增长趋势 调试可见性 是否可追踪
仅传 string/int 稳定 ✅ traceID 可关联
传 struct{} + slice 线性膨胀 ❌ pprof 仅显 context 大小,无键名语义
传 *http.Client 泄漏式增长 极低 ❌ 逃逸分析难定位

调试盲区形成路径

graph TD
    A[HTTP Gateway] -->|ctx.WithValue<br>含 []byte{1MB}| B[Auth Service]
    B -->|原样透传| C[Payment Service]
    C -->|未清理| D[Metrics Collector]
    D --> E[OOM 报警]

4.4 基于OpenTelemetry的context传播可视化与超时预算(Timeout Budget)建模

OpenTelemetry 不仅采集遥测数据,更通过 TraceContext 实现跨服务的 context 精确传递,为超时预算建模提供关键时序锚点。

数据同步机制

OTel SDK 自动注入 traceparenttracestate HTTP 头,保障 context 在 HTTP/gRPC 调用链中无损流转:

from opentelemetry.propagate import inject
from opentelemetry.trace import get_current_span

headers = {}
inject(headers)  # 注入 W3C traceparent 格式:'00-<trace_id>-<span_id>-01'
# trace_id 和 span_id 构成唯一调用路径标识,支撑后续延迟聚合与 SLO 计算

超时预算建模核心维度

维度 说明 OTel 关联字段
P95 服务延迟 用于设定 budget baseline http.server.duration
重试次数 影响实际耗时与 budget 消耗速率 http.client.attempt
上游超时值 决定下游可分配的 budget 余量 http.request.header.timeout

调用链超时传导示意

graph TD
  A[API Gateway] -- timeout=2s --> B[Auth Service]
  B -- timeout=800ms --> C[User DB]
  C -- timeout=300ms --> D[Cache]
  style A fill:#4CAF50,stroke:#388E3C
  style B fill:#2196F3,stroke:#0D47A1

第五章:结语:构建可持续演进的Golang云原生系统能力

在字节跳动广告中台的实时竞价(RTB)系统重构中,团队将原有Java单体服务逐步迁移至Golang微服务架构,历时14个月完成全链路替换。该系统日均处理请求超28亿次,P99延迟从320ms压降至86ms,关键指标提升背后并非仅靠语言切换,而是依托一套可验证、可度量、可回滚的可持续演进机制。

工程效能闭环体系

团队落地了基于GitOps的自动化发布流水线,所有服务变更必须通过go vet + staticcheck + golangci-lint三级静态检查,并强制执行覆盖率≥82%的单元测试门禁。CI阶段嵌入go test -race检测数据竞争,CD阶段自动注入OpenTelemetry trace ID并关联Prometheus指标,当http_request_duration_seconds_bucket{le="0.1",job="bidder"}连续5分钟占比低于95%,自动触发灰度暂停。下表为2023年Q3核心服务发布质量对比:

指标 旧流程(手动部署) 新流程(GitOps)
平均发布耗时 47分钟 6.2分钟
回滚平均耗时 18分钟 23秒
生产环境P0故障率 0.37次/周 0.02次/周

可观测性驱动的演进决策

在Kubernetes集群中部署eBPF探针(使用cilium/ebpf库),实时采集Go runtime指标:runtime/gc/heap/allocs:bytesruntime/forcedgc/latency:seconds。当GC Pause P99超过50ms时,自动触发内存分析快照并推送至Grafana看板。某次升级gRPC v1.52后,发现runtime/metrics/gc/heap/allocs:bytes突增300%,经pprof火焰图定位到proto.Unmarshal未复用proto.Buffer实例,修复后内存分配下降至基线水平。

// 修复前:每次调用创建新Buffer
func badUnmarshal(data []byte) (*pb.BidRequest, error) {
    buf := proto.NewBuffer(nil) // ❌ 频繁分配
    if err := buf.Unmarshal(data); err != nil {
        return nil, err
    }
    return buf.Message().(*pb.BidRequest), nil
}

// 修复后:Buffer池化复用
var bufferPool = sync.Pool{
    New: func() interface{} { return proto.NewBuffer(nil) },
}
func goodUnmarshal(data []byte) (*pb.BidRequest, error) {
    buf := bufferPool.Get().(*proto.Buffer)
    defer bufferPool.Put(buf)
    buf.Reset() // ✅ 复用底层[]byte
    if err := buf.Unmarshal(data); err != nil {
        return nil, err
    }
    return buf.Message().(*pb.BidRequest), nil
}

架构防腐层实践

为应对上游广告主API频繁变更,在服务间引入Protocol Buffer Schema Registry,所有gRPC接口定义经Conformance Test验证后方可提交。当ad_service.proto新增revenue_share_rate字段时,自动生成向后兼容的JSON映射规则,并通过protoc-gen-validate生成校验逻辑,避免因空值导致下游panic。该机制使跨团队接口变更交付周期从平均5.8天缩短至1.3天。

graph LR
    A[上游API变更] --> B{Schema Registry校验}
    B -->|通过| C[生成gRPC stub + JSON mapping]
    B -->|失败| D[阻断CI并返回错误码SCHEMA_MISMATCH_422]
    C --> E[注入Envoy Filter进行字段级熔断]
    E --> F[生产流量灰度验证]

组织协同机制

建立“SRE-Gopher双轨制”值班体系:每个微服务由开发团队(Gopher)负责功能迭代,SRE团队通过go tool pprof远程诊断工具包提供性能支持。当bidder-service出现goroutine泄漏时,SRE直接执行curl http://localhost:6060/debug/pprof/goroutine?debug=2获取栈快照,结合go tool trace分析调度延迟,2小时内定位到context.WithTimeout未被defer cancel()释放的问题。

持续演进不是追求技术新鲜度,而是让每一次代码提交都成为系统韧性增强的原子操作。

专治系统慢、卡、耗资源,让服务飞起来。

发表回复

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