Posted in

【Go聊天室监控黄金指标】:从连接数、消息延迟、goroutine堆积到P99响应时间看板搭建

第一章:Go聊天室核心架构设计与实现

Go聊天室采用经典的“客户端-服务器”模型,以高并发、低延迟和强可维护性为目标。核心架构由三部分组成:连接管理器(ConnManager)、消息分发中心(Broker)和用户会话层(Session),三者通过接口解耦,支持横向扩展与热插拔。

连接生命周期管理

使用 net/httpUpgrade 方法实现 WebSocket 协议升级,避免 HTTP 轮询开销。每个连接封装为 *Session 实例,包含唯一 ID、用户名、读写通道及心跳超时控制。连接建立后自动注册至 ConnManagersync.Map 中,键为连接 ID,值为 Session 指针;断开时触发 Close() 方法,清理资源并广播离线事件。

消息路由与广播机制

所有客户端消息统一经由 Broker 中央队列中转。Broker 维护一个 map[string]chan *Message 记录各频道(room)的订阅通道,并通过 goroutine 池异步分发。关键代码如下:

// Message 定义含类型、发送者、目标房间、内容和时间戳
type Message struct {
    Type    string    `json:"type"`    // "broadcast", "private", "system"
    From    string    `json:"from"`
    Room    string    `json:"room"`
    Content string    `json:"content"`
    Time    time.Time `json:"time"`
}

// Broker.Publish 将消息写入对应房间的 channel(带缓冲,防阻塞)
func (b *Broker) Publish(msg *Message) {
    if ch, ok := b.rooms[msg.Room]; ok {
        select {
        case ch <- msg:
        default: // 队列满时丢弃,避免 goroutine 泄漏
            log.Printf("room %s channel full, dropped message", msg.Room)
        }
    }
}

用户状态与权限控制

用户登录需携带 JWT token,服务端校验签名与有效期后绑定 session。权限分级通过 Role 字段区分:"guest"(只读)、"member"(发言)、"admin"(踢人/封禁)。角色策略以结构体切片形式配置,支持运行时动态加载:

角色 允许操作 限制说明
guest 加入公共房间、接收消息 禁止发送、禁止创建房间
member 发送消息、创建私聊 无法管理房间成员
admin 踢出用户、设置房间密码、审计日志 需二次验证

所有连接均启用 context.WithTimeout 控制握手与心跳超时,防止慢连接耗尽资源。

第二章:连接数监控体系构建

2.1 连接生命周期管理与实时计数器设计(理论+atomic+sync.Map实践)

核心挑战

高并发场景下,连接的创建、活跃、关闭需原子化追踪,避免竞态导致计数漂移或 panic。

数据同步机制

  • atomic.Int64:适用于单值高频更新(如总连接数)
  • sync.Map:适合键值分离管理(如按客户端 ID 缓存连接元信息)

实时计数器实现

var activeConnCount atomic.Int64

// 增加连接
func onConnOpen() {
    activeConnCount.Add(1)
}

// 关闭连接
func onConnClose() {
    activeConnCount.Add(-1)
}

Add() 是无锁原子操作,参数为 int64 增量;activeConnCount.Load() 可安全读取当前值,无需 mutex。

连接元数据映射表

Key(ClientID) Value(*ConnMeta) 线程安全
“cli-001” {Created: time.Now()} ✅(sync.Map 内置)
graph TD
    A[新连接建立] --> B[atomic.Add +1]
    A --> C[sync.Map.Store clientID → meta]
    D[连接关闭] --> E[atomic.Add -1]
    D --> F[sync.Map.Delete clientID]

2.2 WebSocket连接状态埋点与Prometheus指标暴露(理论+gorilla/websocket+promhttp实践)

WebSocket长连接的健康度直接影响实时系统可用性。需对连接生命周期关键节点(open/close/error)进行细粒度埋点,并将状态聚合为可观测指标。

核心指标设计

  • websocket_connections_total{state="open",app="chat"}:当前活跃连接数(Gauge)
  • websocket_connection_duration_seconds_sum{app="chat"}:连接持续时间累加(Counter)
  • websocket_errors_total{reason="timeout",app="chat"}:错误事件计数(Counter)

埋点实现(gorilla/websocket)

var (
    connections = promauto.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "websocket_connections_total",
            Help: "Current number of active WebSocket connections",
        },
        []string{"state", "app"},
    )
)

// 在 Upgrade 后调用
func onOpen(conn *websocket.Conn, app string) {
    connections.WithLabelValues("open", app).Inc()
    // 记录连接建立时间戳用于 duration 计算
}

该代码注册带标签的 Prometheus Gauge 指标,WithLabelValues("open", app) 动态绑定连接状态与业务标识,Inc() 原子递增——确保高并发下连接数统计精确。promauto 自动注册指标至默认 Registry,避免手动 prometheus.MustRegister()

指标暴露端点

http.Handle("/metrics", promhttp.Handler())

promhttp.Handler() 提供标准 /metrics 端点,返回文本格式指标(如 # TYPE websocket_connections_total gauge),兼容所有 Prometheus 抓取器。

指标名 类型 标签示例 用途
websocket_connections_total Gauge state="closed" 实时连接快照
websocket_errors_total Counter reason="protocol_error" 错误归因分析
graph TD
    A[Client Connect] --> B[Upgrade HTTP to WS]
    B --> C{Success?}
    C -->|Yes| D[onOpen: Inc open counter]
    C -->|No| E[onError: Inc error counter]
    D --> F[Heartbeat or Data Flow]
    F --> G[onClose: Dec open counter]

2.3 突发连接洪峰检测与熔断机制实现(理论+令牌桶限流+动态阈值告警实践)

核心设计思想

以实时性与自适应为双驱动:先用滑动窗口统计连接速率,再通过动态基线(如 EWMA 平滑均值 ± 2σ)生成浮动阈值,避免静态阈值误触发。

令牌桶限流实现(Go)

type TokenBucket struct {
    capacity  int64
    tokens    int64
    rate      float64 // tokens/sec
    lastTick  time.Time
    mu        sync.RWMutex
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()
    now := time.Now()
    elapsed := now.Sub(tb.lastTick).Seconds()
    tb.tokens = min(tb.capacity, tb.tokens+int64(elapsed*tb.rate))
    tb.lastTick = now
    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    return false
}

逻辑分析:elapsed * rate 计算新增令牌数,min() 防溢出;tokens-- 原子消耗,Allow() 返回是否放行。关键参数:capacity 控制突发容量,rate 决定恢复速度。

动态阈值告警流程

graph TD
    A[每秒采集连接数] --> B[EWMA平滑计算]
    B --> C[滚动标准差σ]
    C --> D[阈值 = μ + 2σ]
    D --> E[超阈值 → 触发熔断]

关键参数对照表

参数 典型值 作用
EWMA α 0.2 控制历史权重衰减速度
滑动窗口 60s 保障基线反映近期负载趋势
熔断持续时间 30s 避免雪崩,留出恢复窗口

2.4 分布式连接数聚合与跨节点一致性校验(理论+Redis HyperLogLog+Raft共识模拟实践)

在高并发网关场景中,单节点连接数统计易受分区影响,需跨节点去重聚合并保障最终一致。

核心设计思路

  • 使用 Redis HyperLogLog 实现轻量级、误差率
  • 搭配 Raft 模拟层协调各节点提交顺序,避免统计竞态

HyperLogLog 聚合示例

# 各节点本地记录连接ID(如 client_ip:port)
redis_client.pfadd("hll:conn:node1", "192.168.1.10:56789")
redis_client.pfadd("hll:conn:node2", "192.168.1.10:56789")  # 重复ID自动去重

# 全局合并(支持最多16个key)
total = redis_client.pfmerge("hll:conn:global", "hll:conn:node1", "hll:conn:node2")
approx_count = redis_client.pfcount("hll:conn:global")  # 返回≈2(若两节点各1唯一连接)

pfadd 单次插入 O(1),pfmerge 时间复杂度 O(N),pfcount 返回基数估计值;内存恒定约12KB,远低于Set。

Raft 角色状态同步示意

graph TD
    A[Leader] -->|AppendEntries| B[Follower-1]
    A -->|AppendEntries| C[Follower-2]
    B -->|VoteRequest| A
    C -->|VoteRequest| A
    A -->|Commit Index Sync| D[(Shared HLL Key)]

一致性校验策略

  • 每 30s 触发一次 Raft 日志提交,驱动 hll:conn:global 更新
  • 各节点比对本地 pfcount 与全局 hll:conn:global 差值 >5% 时触发补偿同步
统计维度 单节点 HLL 全局 HLL 误差容忍
内存占用 ~12 KB ~12 KB 固定
插入吞吐 >100k/s 线性可扩展
基数误差 ≤0.81% ≤0.81% 可配置

2.5 连接健康度画像:空闲/活跃/僵尸连接智能分类(理论+心跳超时建模+goroutine泄漏检测实践)

连接健康度画像本质是基于多维时序行为特征对 TCP 连接进行动态聚类:空闲连接(有心跳无业务流量)、活跃连接(双向数据包持续交互)、僵尸连接(TCP 状态为 ESTABLISHED,但无任何收发行为且心跳失效)。

心跳超时建模

采用指数加权移动平均(EWMA)动态计算连接心跳响应延迟基线:

// alpha = 0.25,平滑历史RTT,适应网络抖动
conn.heartbeatRTT = alpha*observedRTT + (1-alpha)*conn.heartbeatRTT
timeout := conn.heartbeatRTT * 3 // 3倍动态基线作为超时阈值

逻辑分析:避免固定超时(如30s)误杀高延迟链路;alpha越小,基线越稳定,抗突发抖动能力越强。

goroutine 泄漏检测实践

通过 runtime.NumGoroutine() 结合连接池监控实现关联预警:

指标 阈值 触发动作
单连接 goroutine 数 > 5 标记可疑连接
全局 goroutine 增速 Δ>200/s 启动 pprof 分析
graph TD
    A[每5s采样连接状态] --> B{心跳是否超时?}
    B -->|是| C[标记为僵尸连接]
    B -->|否| D{最近10s有读写事件?}
    D -->|是| E[归类为活跃]
    D -->|否| F[归类为空闲]

第三章:消息端到端延迟精准测量

3.1 消息路径全链路打点与TraceID透传机制(理论+context.WithValue+OpenTelemetry实践)

为什么需要TraceID透传?

微服务调用中,一次用户请求横跨多个服务,若无唯一标识,日志与指标将无法关联。TraceID是分布式追踪的基石,贯穿RPC、HTTP、消息队列等所有环节。

核心实现三要素

  • 生成时机:入口服务(如API网关)首次生成全局唯一TraceID
  • 传递载体:通过context.Context携带,避免参数污染业务逻辑
  • 标准化注入:遵循W3C Trace Context规范(traceparent header)

context.WithValue 实践(需谨慎使用)

// 创建带TraceID的上下文
ctx := context.WithValue(context.Background(), "trace_id", "0xabcdef1234567890")
// ⚠️ 注意:应使用自定义key类型避免冲突
type ctxKey string
const traceIDKey ctxKey = "trace_id"
ctx = context.WithValue(ctx, traceIDKey, "0xabcdef1234567890")

context.WithValue 仅适用于不可变、轻量级元数据;生产环境推荐 context.WithValue(ctx, key, val) + 类型安全key,避免字符串key哈希碰撞或误覆盖。

OpenTelemetry自动注入示例

组件 注入方式 自动传播支持
HTTP Server otelhttp.NewHandler ✅(header解析)
gRPC Client otelgrpc.UnaryClientInterceptor ✅(metadata)
Kafka Consumer otelkafka.Consumer ✅(headers)

全链路打点流程

graph TD
    A[Client Request] --> B[Gateway: 生成TraceID]
    B --> C[HTTP Header: traceparent]
    C --> D[Service A: otelhttp handler]
    D --> E[gRPC Call to Service B]
    E --> F[Service B: otelgrpc interceptor]
    F --> G[DB Query: otelmysql]

3.2 P99/P999延迟分位计算与滑动窗口优化(理论+golang.org/x/exp/slices+time.Ticker实践)

延迟分位数的工程意义

P99(99%请求耗时 ≤ 该值)和P999(99.9%)是SLO保障的关键指标,比平均值更能反映尾部延迟风险。

滑动窗口 vs 全局聚合

  • 全局统计:内存无限增长,无法反映近期趋势
  • 固定窗口(如1分钟):边界效应导致抖动
  • 滑动时间窗口:以 time.Ticker 驱动增量更新,兼顾实时性与内存可控性

Go 实现核心逻辑

// 使用 golang.org/x/exp/slices.Sort 支持高效分位计算
func computeP999(latencies []int64) int64 {
    slices.Sort(latencies) // 注意:原地排序,需复制原始切片
    n := len(latencies)
    if n == 0 {
        return 0
    }
    idx := int(float64(n) * 0.999)
    return latencies[min(idx, n-1)]
}

slices.Sort 是 Go 1.21+ 推荐的零分配排序工具;min(idx, n-1) 防止越界;0.999 对应 P999 位置索引。

窗口管理策略对比

策略 内存占用 时间精度 实现复杂度
全量存储 O(N)
环形缓冲区 O(W)
分桶直方图 O(1)
graph TD
    A[time.Ticker 每100ms触发] --> B[采集新延迟样本]
    B --> C[加入滑动窗口双端队列]
    C --> D{窗口超时?}
    D -- 是 --> E[移除过期样本]
    D -- 否 --> F[调用computeP999]

3.3 消息队列积压与Broker延迟注入模拟(理论+channel缓冲区监控+chaos-mesh集成实践)

消息队列积压本质是生产者速率持续高于消费者处理能力,导致Broker内存/磁盘缓冲区持续增长。关键瓶颈常位于网络层net.Conn写缓冲区或Kafka LogSegment刷盘延迟。

channel缓冲区监控示例

// 监控Go runtime中MQ consumer goroutine的channel阻塞状态
ch := make(chan string, 100) // 缓冲区容量100
select {
case ch <- msg:
    // 成功入队
default:
    metrics.Counter("ch_full").Inc() // 缓冲区满指标
}

该逻辑通过非阻塞select探测channel饱和度,default分支触发即表明缓冲区已满,需联动告警与限流。

Chaos Mesh延迟注入配置

字段 说明
action delay 注入网络延迟
latency "2s" 固定延迟时长
target broker-pod 作用目标
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
  name: broker-delay
spec:
  action: delay
  latency: "2s"
  mode: one
  selector:
    namespaces: ["mq-system"]

graph TD A[Producer] –>|高吞吐| B[Broker Buffer] B –> C{buffer > 80%?} C –>|Yes| D[触发metric告警] C –>|No| E[Normal Flow] D –> F[Chaos Mesh注入延迟] F –> G[暴露消费滞后根因]

第四章:goroutine堆积根因分析与治理

4.1 Goroutine泄漏模式识别与pprof火焰图解读(理论+runtime/pprof+go tool pprof实践)

Goroutine泄漏常表现为持续增长的goroutine数量,最终耗尽内存或调度器压力激增。

常见泄漏模式

  • 未关闭的channel导致range阻塞
  • time.AfterFunc/time.Ticker未显式停止
  • HTTP handler中启动goroutine但未绑定请求生命周期

快速诊断三步法

  1. go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2(查看全量栈)
  2. go tool pprof -http=:8080 cpu.pprof(启动交互式火焰图)
  3. 在火焰图中聚焦宽而深的调用路径——即高频、长生命周期的goroutine分支
func leakyHandler(w http.ResponseWriter, r *http.Request) {
    go func() { // ❌ 无取消机制,请求结束仍运行
        time.Sleep(10 * time.Second)
        fmt.Fprint(w, "done") // w 已失效!panic风险
    }()
}

该goroutine脱离HTTP上下文,无法响应r.Context().Done(),且http.ResponseWriter在handler返回后不可用,触发资源泄漏与panic双重风险。

检测指标 健康阈值 风险信号
runtime.NumGoroutine() > 5000 持续上升
goroutine pprof采样深度 ≥ 3层调用栈 大量runtime.gopark顶层
graph TD
    A[HTTP Handler] --> B[启动goroutine]
    B --> C{是否监听ctx.Done?}
    C -->|否| D[泄漏:永不退出]
    C -->|是| E[受控退出]

4.2 长连接协程池化与复用策略设计(理论+worker pool模式+sync.Pool定制实践)

长连接资源(如 gRPC ClientConn、数据库连接)创建开销大,频繁新建/销毁引发 GC 压力与 TLS 握手延迟。需在协程粒度实现连接复用生命周期托管

核心设计三层次

  • 理论层:基于连接亲和性(connection affinity)与请求局部性,避免跨 goroutine 竞争锁
  • 模式层:Worker Pool 负责任务分发,每个 worker 持有专属长连接,规避并发读写冲突
  • 优化层sync.Pool 定制 *grpc.ClientConn 对象池,重写 NewPut 实现懒加载与安全回收

sync.Pool 定制示例

var connPool = sync.Pool{
    New: func() interface{} {
        // New 返回新连接(含健康检查)
        conn, _ := grpc.Dial("backend:8080", 
            grpc.WithTransportCredentials(insecure.NewCredentials()),
            grpc.WithBlock())
        return conn
    },
    Put: func(v interface{}) {
        if conn, ok := v.(*grpc.ClientConn); ok && conn != nil {
            // Put 前主动关闭连接(避免泄漏),或标记为可复用
            conn.Close() // 实际中应结合连接健康状态判断
        }
    },
}

New 在池空时触发,确保按需初始化;Put 不直接复用连接(因 gRPC Conn 非线程安全),而是统一释放,由下一次 Get 触发重建——兼顾安全性与可控性。

复用维度 方案 适用场景
连接级 单 worker 持有 高吞吐、低频重连
协程级 sync.Pool + New 中低频、连接成本敏感
请求级 context.Context 绑定 需要超时/取消传播的场景
graph TD
    A[Task Request] --> B{Worker Pool}
    B --> C[Idle Worker]
    C --> D[Get conn from sync.Pool]
    D --> E[Use conn to call]
    E --> F[Put conn back or discard]

4.3 Channel阻塞检测与死锁预防机制(理论+select超时+channel inspection工具实践)

死锁成因与典型模式

Go 中死锁常源于 goroutine 间 channel 单向等待:A 等待从 ch 读,B 等待向 ch 写,且无其他协程调度。runtime 在主 goroutine 退出且无活跃 goroutine 时触发 fatal error: all goroutines are asleep - deadlock

select 超时防御式编程

ch := make(chan int, 1)
select {
case ch <- 42:
    fmt.Println("sent")
default:
    fmt.Println("channel full or blocked — safe fallback")
}

逻辑分析:default 分支提供非阻塞兜底;若 channel 缓冲满或接收端未就绪,立即执行 fallback,避免 goroutine 挂起。参数 ch 需为已声明 channel,default 必须存在才能实现超时/降级语义。

实用检测工具链

工具 用途 启动方式
go tool trace 可视化 goroutine 阻塞点 go tool trace trace.out
godebug 运行时 channel 状态快照 godebug core dump --channels
graph TD
    A[goroutine 尝试 send] --> B{channel ready?}
    B -->|yes| C[成功写入]
    B -->|no| D[进入 sendq 队列]
    D --> E[若 recvq 为空且无其他 goroutine 唤醒 → 潜在死锁]

4.4 并发模型演进:从goroutine per connection到event-loop优化(理论+net.Conn非阻塞封装+epoll/kqueue模拟实践)

早期 Go 服务常为每个连接启动独立 goroutine,简洁但高并发下调度开销显著。现代高性能网络库转向事件驱动模型,复用少量 goroutine 处理海量连接。

非阻塞 net.Conn 封装关键点

  • 调用 conn.SetNonblock(true) 禁用阻塞
  • 使用 syscall.EAGAIN / syscall.EWOULDBLOCK 判断需重试
  • 结合 runtime.Gosched() 避免忙等
func (c *NIOConn) Read(b []byte) (n int, err error) {
    n, err = c.conn.Read(b)
    if err != nil {
        if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK) {
            return 0, io.ErrNoProgress // 触发事件循环轮询
        }
    }
    return
}

该封装将底层系统调用错误语义标准化,使上层事件循环能统一处理就绪/未就绪状态。

epoll/kqueue 模拟核心逻辑

组件 Linux (epoll) BSD/macOS (kqueue)
注册事件 epoll_ctl(ADD) kevent(EV_ADD)
等待就绪 epoll_wait() kevent()
事件类型 EPOLLIN/EPOLLOUT EVFILT_READ/EVFILT_WRITE
graph TD
    A[Accept 新连接] --> B[注册到EventLoop]
    B --> C{fd 是否就绪?}
    C -->|是| D[Read/Write 处理]
    C -->|否| E[继续轮询/挂起]

第五章:P99响应时间看板落地与SLO驱动运维

数据采集与指标标准化

在某电商核心下单服务(Java Spring Boot 3.2)中,我们统一接入OpenTelemetry Agent v1.32,将HTTP请求路径、状态码、上游服务名作为关键标签。P99响应时间定义为:histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{app="order-service", uri!~".*/health|/actuator.*"}[5m])) by (le, uri, status))。所有服务强制启用/metrics端点暴露Prometheus格式指标,并通过Relabel规则过滤内部探针流量,确保P99计算基线纯净。

看板构建与分层告警

使用Grafana v10.4构建三级看板:

  • 全局视图:按服务维度聚合P99热力图(7×24小时滚动)
  • 链路下钻:点击异常URI后自动跳转至Jaeger追踪ID列表(按耗时倒序)
  • 根因辅助:并列展示对应时段CPU利用率、GC Pause Time及DB慢查询TOP5
告警策略采用阶梯式触发: P99阈值 持续时长 通知渠道
>800ms ≥3分钟 企业微信(值班组)
>1200ms ≥1分钟 电话+短信(SRE on-call)

SLO目标设定与错误预算消耗

基于业务SLA协议,为下单服务定义SLO:P99 ≤ 600ms(99.5%季度可用性)。错误预算初始值=100%−99.5%=0.5%,即每季度允许21.6分钟P99超标。实时计算公式:

1 - sum(increase(http_server_requests_seconds_count{status=~"5.."}[7d])) 
  / sum(increase(http_server_requests_seconds_count[7d]))

运维决策闭环机制

当错误预算剩余量低于15%时,自动冻结非紧急发布窗口,并触发SLO健康度评审会。2024年Q2真实案例:支付回调服务因Redis连接池泄漏导致P99突增至1.4s,错误预算单日消耗达8.2%。团队立即执行以下动作:

  1. 通过kubectl exec -it order-pod -- jstack -l定位线程阻塞点
  2. 回滚上周发布的连接池配置变更(maxIdle从200→500)
  3. 在CI流水线中插入redis-benchmark -t set,get -n 100000性能验证关卡

可观测性与开发协同

前端团队接入SLO Dashboard后,将P99趋势图嵌入Jira需求卡片。当新功能“优惠券叠加计算”上线后,其专属URI /api/v1/order/calculate的P99从320ms升至710ms,触发自动关联代码变更(Git commit: feat: coupon-combo-logic)。开发人员据此重构了优惠券校验逻辑,改用本地缓存预加载替代实时DB查询,最终P99回落至410ms。

成本优化联动分析

对比P99与云资源消耗发现:当订单服务P99连续2小时>500ms时,EC2实例CPU平均利用率仅62%,但内存交换率飙升至18%。进一步排查确认JVM堆外内存泄漏,通过jcmd <pid> VM.native_memory summary定位Netty DirectBuffer未释放问题,升级Netty至4.1.100.Final后P99稳定性提升40%。

跨团队对齐实践

每月SLO复盘会议采用“三色报告”:绿色(达标)、黄色(误差预算消耗>30%)、红色(连续3天超标)。2024年6月报表显示物流服务P99超标源于第三方WMS接口超时,推动建立熔断降级策略——当WMS调用失败率>5%时,自动切换至本地模拟库存数据,保障主流程P99不受影响。

flowchart LR
    A[Prometheus采集P99] --> B[Grafana看板渲染]
    B --> C{错误预算剩余量<15%?}
    C -->|Yes| D[冻结发布+启动根因分析]
    C -->|No| E[常规监控]
    D --> F[代码回滚/配置修复]
    F --> G[验证P99回归基线]
    G --> H[更新错误预算仪表盘]

传播技术价值,连接开发者与最佳实践。

发表回复

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