Posted in

Go gRPC流控压测实录:当QPS超12万时,明哥用x/net/flowrate替换默认流控器实现P99稳定<8ms

第一章:Go gRPC流控压测实录:当QPS超12万时,明哥用x/net/flowrate替换默认流控器实现P99稳定

在某高并发实时风控网关压测中,原基于 gRPC 默认 transport.Stream 级限流(依赖 runtime.NumGoroutine() 与简单令牌桶)在 QPS 达到 9.2 万时即出现 P99 延迟骤升至 47ms,连接复用率下降 35%,且偶发 stream reset 错误。根本原因在于默认流控器缺乏细粒度请求级速率感知能力,无法区分长尾请求与突发流量,导致缓冲区雪崩。

明哥引入 golang.org/x/net/flowrate 构建请求级自适应流控中间件,核心改造如下:

替换服务端流控策略

// 在 gRPC ServerOption 中注入自定义流控拦截器
func flowControlInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // 每个请求分配独立 flowrate.Limiter(基于请求路径动态配置)
    limiter := flowrate.NewLimiter(
        flowrate.Limit(getRPSByMethod(info.FullMethod)), // 如 /risk.Check: 150k/s
        flowrate.Burst(200),                             // 允许瞬时突增
    )
    if !limiter.AllowN(time.Now(), 1) {
        return nil, status.Error(codes.ResourceExhausted, "rate limited")
    }
    return handler(ctx, req)
}

关键参数调优对照表

维度 默认流控器 x/net/flowrate 方案
控制粒度 连接/Server 级 请求路径 + 动态 RPS
突发容忍 固定 buffer(≈50 req) 可配置 Burst(200+)
P99 延迟 ≥47ms(QPS=92k) 7.2ms(QPS=123k)
资源开销 无额外 goroutine 每请求 ~128B 内存

部署验证步骤

  1. 使用 ghz 启动阶梯压测:ghz --insecure -c 2000 -z 5m -q 100000 'https://api.example.com/risk.Check'
  2. 实时观测 grpc_server_handled_total{code="OK"}flowrate_rejected_total
  3. 通过 pprof 分析 runtime/pprof/profile?seconds=30 确认无流控相关锁竞争

最终在单节点 32C64G 环境下,稳定支撑 123,400 QPS,P99 延迟 7.2±0.4ms,错误率 x/net/flowrate 的纳秒级时间精度与无锁环形计数器设计。

第二章:gRPC流控机制深度解析与性能瓶颈定位

2.1 gRPC默认流控器(tcpConn、http2Server)的调度模型与局限性分析

gRPC底层依赖HTTP/2协议栈,其流控由tcpConn(连接层)与http2Server(应用层)两级协同实现:前者管理TCP窗口与读写缓冲区,后者基于HTTP/2流级WINDOW_UPDATE帧实施精细控制。

流控职责划分

  • tcpConn:响应内核TCP接收窗口变化,触发conn.read()阻塞/唤醒
  • http2Server:为每个Stream独立维护recvQuota,通过adjustWindow()动态分配流量配额

典型瓶颈场景

// http2_server.go 中关键配额分配逻辑
func (s *http2Server) adjustWindow(streamID uint32, n int32) {
    s.mu.Lock()
    stream := s.activeStreams[streamID]
    if stream != nil {
        stream.recvQuota += n // 累加配额(单位:字节)
        if stream.recvQuota > 0 && !stream.isReading {
            stream.isReading = true
            s.scheduleStream(stream) // 唤醒读协程
        }
    }
    s.mu.Unlock()
}

该逻辑未考虑跨Stream优先级调度,高吞吐流可能长期独占recvQuota,导致低优先级流饥饿。

维度 tcpConn 层 http2Server 层
控制粒度 连接级(全局) Stream级(细粒度)
更新时机 TCP ACK延迟触发 收到WINDOW_UPDATE帧后
调度公平性 无流感知 无优先级/权重机制
graph TD
    A[客户端发送DATA帧] --> B{http2Server检查recvQuota}
    B -->|quota > 0| C[接受数据并递减quota]
    B -->|quota == 0| D[暂存帧至buffer]
    C --> E[处理业务逻辑]
    D --> F[等待WINDOW_UPDATE]

2.2 QPS突破10万级时流控失稳的典型现象与内核态/用户态协同瓶颈验证

当QPS跃升至10万+/s,典型失稳表现为令牌桶突发耗尽后延迟毛刺陡增、限流阈值漂移超±15%,且/proc/sys/net/core/somaxconn与应用层滑动窗口周期性不同步。

数据同步机制

用户态限流器(如Sentinel)依赖定时采样上报QPS,但高负载下epoll_wait()返回延迟导致采样窗口偏移:

// 用户态采样伪代码:每100ms触发一次统计
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
uint64_t now_ms = ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
// ⚠️ 若内核调度延迟>50ms,两次采样实际间隔达180ms,造成QPS低估

该延迟源于CFS调度器在多核争抢下的rq->nr_switches突增,使用户态无法及时获取真实请求抵达节奏。

内核-用户协同瓶颈验证

指标 正常态( 失稳态(>100k QPS)
gettimeofday()抖动 >200μs
sendto()系统调用耗时 3–8μs 45–120μs(尾部放大)
graph TD
    A[请求抵达网卡] --> B[内核协议栈入队]
    B --> C{net.core.somaxconn是否溢出?}
    C -->|是| D[丢包+TCP重传]
    C -->|否| E[用户态accept唤醒]
    E --> F[限流器token校验]
    F --> G[上下文切换开销≥15μs]
    G --> H[QPS统计滞后→误放行]

2.3 x/net/flowrate设计哲学:令牌桶+滑动窗口双维度速率控制原理推演

x/net/flowrate 并非标准库组件,而是社区对流控模式的抽象共识——其核心是令牌桶保障瞬时突发容忍度滑动窗口提供长周期统计精度,二者正交协同。

双模协同动机

  • 单令牌桶:无法抑制周期性毛刺(如每秒整点批量请求)
  • 单滑动窗口:响应延迟高,缺乏实时令牌发放能力

核心结构示意

type FlowLimiter struct {
    tokenBucket *rate.Limiter // 每秒补充 r tokens,burst=100
    windowStats *sliding.Window // 60s 窗口,按毫秒分桶聚合
}

tokenBucket 控制单次请求准入(AllowN(time.Now(), n)),windowStats 异步采样并触发告警/降级策略,二者共享同一原始事件流但决策解耦。

决策流程

graph TD
    A[请求到达] --> B{令牌桶有足量token?}
    B -->|是| C[立即放行]
    B -->|否| D[进入等待队列或拒绝]
    C --> E[上报事件到滑动窗口]
    E --> F[窗口内QPS超阈值?]
    F -->|是| G[触发熔断回调]
维度 令牌桶 滑动窗口
时间粒度 微秒级瞬时判定 毫秒分桶,秒级聚合
主要职责 请求准入控制 统计观测与策略触发
参数敏感性 rate, burst 窗口长度、分桶数

2.4 基于pprof+trace+net/http/pprof的流控路径热区实测定位(含火焰图解读)

在真实服务中,流控逻辑常嵌套于 HTTP 中间件链与限流器调用栈深处。启用 net/http/pprof 后,通过 curl http://localhost:6060/debug/pprof/trace?seconds=10 可捕获 10 秒内全量执行轨迹。

import _ "net/http/pprof" // 自动注册 /debug/pprof 路由

该导入触发 init() 注册标准 pprof handler,无需显式启动 goroutine,但需确保 http.ListenAndServe(":6060", nil) 已运行。

火焰图生成流程

go tool trace -http=:8081 trace.out  # 启动交互式 trace UI  
go tool pprof -http=:8080 cpu.prof     # 生成火焰图(需先采集 CPU profile)
工具 采集目标 关键优势
pprof CPU/heap/block 精确定位函数耗时与内存分配热点
trace Goroutine 调度 揭示阻塞、抢占、GC 暂停影响

定位流控热区典型路径

  • middleware.RateLimit → limiter.Allow() → redis.Incr → net.Conn.Write
  • 高频 syscall.Syscall 出现在 redis 底层 write 调用处,表明 I/O 成为瓶颈
graph TD
    A[HTTP Request] --> B[MW: RateLimit]
    B --> C[TokenBucket.Take]
    C --> D{Redis INCR}
    D --> E[net.Conn.Write]
    E --> F[syscall.Syscall]

2.5 流控器替换前后goroutine调度延迟、GC停顿、conn write阻塞时长对比实验

为量化流控器重构效果,我们在相同压测场景(10k并发短连接、QPS=8k)下采集三类核心指标:

实验环境与采样方式

  • Go 版本:1.21.10(启用 GODEBUG=gctrace=1,madvdontneed=1
  • 采样工具:pprof + 自研 runtime.ReadMetrics() 定时快照(100ms间隔)

关键性能对比(单位:ms,P99)

指标 替换前(令牌桶) 替换后(滑动窗口+批处理) 变化
Goroutine调度延迟 12.7 3.4 ↓73%
GC STW停顿 8.2 1.9 ↓77%
conn.Write阻塞时长 41.6 6.3 ↓85%

核心优化代码片段

// 新流控器 Write 方法(非阻塞预检 + 批量缓冲)
func (c *SlidingWindowLimiter) Write(p []byte) (n int, err error) {
    if !c.tryAcquire(int64(len(p))) { // O(1) 时间复杂度检查
        return 0, ErrRateLimited
    }
    return c.writer.Write(p) // 底层使用 sync.Pool 复用 writeBuf
}

tryAcquire 基于原子计数与时间窗口滑动,避免锁竞争;sync.Pool 减少 writeBuf 分配,直接降低 GC 压力与调度争抢。

调度延迟下降归因

graph TD
A[旧流控器] -->|互斥锁阻塞| B[goroutine排队等待]
A -->|频繁堆分配| C[GC触发更频繁]
D[新流控器] -->|无锁CAS| E[立即返回或拒绝]
D -->|对象复用| F[减少STW触发]

第三章:x/net/flowrate在gRPC服务端的嵌入式集成实践

3.1 拦截器层流控注入:ServerStreamInterceptor中流量标记与速率决策点植入

在 gRPC ServerStream 场景下,需在流式响应生命周期早期注入轻量级流量标记与实时速率判定逻辑。

核心拦截点选择

ServerStreamInterceptor 是唯一可安全介入 ServerCall 初始化与 ServerCall.Listener 包装阶段的钩子,支持无侵入式标记注入。

流量标记注入示例

public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
    ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {

  // 注入请求指纹与上下文标签
  String traceId = headers.get(GrpcHeaderKeys.TRACE_ID);
  TrafficTag tag = TrafficTag.builder()
      .traceId(traceId)
      .method(call.getMethodDescriptor().getFullMethodName())
      .build();
  TrafficContext.set(tag); // 线程局部绑定

  return next.startCall(call, headers);
}

逻辑分析:TrafficContext.set() 将标记绑定至当前线程,供后续限流器(如 RateLimiter)按 traceId + method 维度聚合统计;headers.get() 安全提取元数据,避免空指针。

决策点嵌入位置对比

阶段 可用信息 是否支持速率决策
interceptCall 入口 call, headers ✅ 支持初始化标记与预判
onReady() 回调 流就绪状态 ❌ 无法前置拦截超限请求
onMessage() 单条消息体 ⚠️ 滞后,已产生资源开销

控制流示意

graph TD
  A[ServerStreamInterceptor.interceptCall] --> B[解析Metadata生成TrafficTag]
  B --> C[TrafficContext.set tag]
  C --> D[委托next.startCall]
  D --> E[RateLimiter.acquireAsync?]

3.2 流粒度动态配额分配:基于method、peer.IP、request_size的三级配额策略编码实现

流粒度配额需在毫秒级完成三重维度协同决策:HTTP 方法语义、客户端真实IP(绕过代理)、请求体大小。核心是避免粗粒度限流导致关键小请求被误拒。

配额计算逻辑

  • method 决定基础权重(GET=1, POST=3, PUT=4)
  • peer.IP 经 CIDR 归一化后查哈希环,绑定独立滑动窗口
  • request_size 按对数分桶(0–1KB→1分,1–16KB→2分,>16KB→5分)

核心策略代码

func calcQuota(method string, ip net.IP, size int64) int64 {
    base := methodWeights[method] // GET→1, POST→3...
    cidrKey := ip.To4().Mask(net.CIDRMask(24, 32)) // /24聚合
    ipBucket := hashRing.Get(cidrKey.String())      // 返回专属quota window
    sizeScore := sizeToScore(size)                  // log2(size/1024)+1, capped at 5
    return int64(base * ipBucket.Weight * sizeScore)
}

methodWeights 提供方法语义感知;hashRing 确保同网段IP共享配额池但隔离跨网段竞争;sizeToScore 将请求体积非线性映射为资源消耗系数,抑制大包洪泛。

配额维度权重表

维度 取值示例 权重范围 作用
method GET / POST 1–5 区分读写操作资源开销
peer.IP 192.168.1.0/24 0.8–1.2 抵御单IP扫描,保护同网段
request_size 512B / 12MB 1–5 惩罚大请求,保障小请求SLA
graph TD
    A[Request] --> B{method?}
    B -->|GET| C[base=1]
    B -->|POST| D[base=3]
    A --> E{peer.IP}
    E --> F[/24 Hash Ring/]
    F --> G[IP Bucket Weight]
    A --> H{request_size}
    H -->|≤1KB| I[score=1]
    H -->|>16KB| J[score=5]
    C & G & I --> K[Quota = base × weight × score]

3.3 流控指标透出:Prometheus exporter对接+自定义metric(flow_reject_count, flow_delay_ms)

为实现精细化流控可观测性,需将核心决策指标直接暴露给 Prometheus。我们基于 promhttp 构建轻量 exporter,并注册两个关键自定义 metric:

from prometheus_client import Counter, Histogram, make_wsgi_app
from werkzeug.serving import make_server

# 拒绝计数器:按原因标签区分
flow_reject_count = Counter(
    'flow_reject_count', 
    'Total number of flow control rejections',
    ['reason']  # e.g., 'qps_exceeded', 'concurrency_limit'
)

# 延迟直方图:毫秒级分桶,覆盖 1–500ms
flow_delay_ms = Histogram(
    'flow_delay_ms',
    'Flow queuing delay in milliseconds',
    buckets=(1, 5, 10, 25, 50, 100, 250, 500)
)

逻辑说明flow_reject_count 使用 reason 标签支持多维下钻分析;flow_delay_ms 采用非等距分桶,兼顾首屏敏感区间(100ms)。

数据同步机制

  • 每次流控拦截时调用 flow_reject_count.labels(reason='qps_exceeded').inc()
  • 请求入队前记录起始时间,出队时 flow_delay_ms.observe(elapsed_ms)

指标采集路径

路径 用途 认证
/metrics Prometheus pull endpoint Basic Auth(可选)
/healthz Exporter 可用性探针
graph TD
    A[流控中间件] -->|reject/delay event| B[Python metric SDK]
    B --> C[内存聚合]
    C --> D[/metrics HTTP handler]
    D --> E[Prometheus scrape]

第四章:高并发压测验证与P99稳定性调优闭环

4.1 Locust+grpcurl混合压测框架搭建:12万QPS下连接复用、metadata扰动、负载倾斜模拟

为支撑超大规模gRPC服务压测,我们构建了Locust(Python)驱动层 + grpcurl(Go)执行层的混合架构,实现连接池复用与细粒度流量控制。

核心设计要点

  • 连接复用:grpcurl通过-plaintext -rpc-timeout 5s复用底层HTTP/2连接,避免TLS握手开销
  • Metadata扰动:Locust任务动态注入tenant_id, region等键值对,模拟多租户请求特征
  • 负载倾斜:按权重路由至3个后端实例(70%→A, 20%→B, 10%→C)

grpcurl调用示例

# 带扰动metadata的单次调用(由Locust每秒并发触发)
grpcurl -plaintext \
  -rpc-timeout 5s \
  -H "tenant_id: t-$(shuf -i 1001-9999 -n 1)" \
  -H "region: $(shuf -e cn-east cn-west us-west | head -1)" \
  10.12.3.4:50051 proto.Service/Method

参数说明:-rpc-timeout保障连接复用生命周期;-H注入随机metadata模拟真实业务扰动;shuf实现轻量级熵源,规避缓存穿透。

性能对比(单节点)

连接模式 QPS 平均延迟 连接数
每请求新建连接 8,200 142ms 12,500
连接池复用 121,000 23ms 1,200
graph TD
  A[Locust Master] -->|分发任务| B[Worker进程]
  B --> C[grpcurl CLI]
  C --> D[HTTP/2连接池]
  D --> E[Service A 70%]
  D --> F[Service B 20%]
  D --> G[Service C 10%]

4.2 P99

为压测中稳定达成P99

初始token预热策略

冷启动时注入满额token,避免首请求排队:

// 初始化即预热:burst=200,初始tokens=200(非0)
TokenBucket bucket = new TokenBucket(200, Duration.ofMillis(10)); // refill every 10ms
bucket.refillNow(200); // 强制预热,跳过首次refill等待

逻辑分析:预热使首请求立即通过,消除初始化抖动;参数200需 ≥ 峰值QPS×refill周期(如20k QPS × 10ms = 200),确保首周期不欠容。

burst与refill周期黄金配比

burst refill interval 实测P99 稳定性
100 5ms 11.2ms ❌ 频繁耗尽
200 10ms 6.8ms ✅ 最优平衡

流量整形效果

graph TD
    A[请求抵达] --> B{桶中token≥1?}
    B -->|是| C[立即通行,-1 token]
    B -->|否| D[等待refill或拒绝]
    D --> E[refill触发:+20 tokens/10ms]

4.3 网络抖动与突发流量下的弹性表现:混沌工程注入(tc netem delay loss)后流控自愈验证

模拟真实网络异常

使用 tc netem 在服务端网卡注入可控的延迟与丢包,复现弱网场景:

# 向 eth0 注入 100ms ±20ms 抖动、5% 随机丢包、20ms 延迟相关性
tc qdisc add dev eth0 root netem delay 100ms 20ms 20% loss 5% correlation 20%

逻辑分析delay 100ms 20ms 20% 表示基础延迟100ms,抖动服从正态分布(±20ms),20%相关性使相邻包延迟相似;loss 5% correlation 20% 实现突发丢包模式,更贴近无线网络或拥塞链路。

自愈能力观测维度

指标 正常值 抖动注入后(30s) 恢复时间
请求成功率 99.98% 降至 92.3%
P99 响应延迟 142ms 峰值 867ms
令牌桶重填充速率 100rps 动态降为 45rps 自适应

数据同步机制

流控组件实时感知 RTT 波动与失败率跃升,触发三级响应:

  • ✅ 降级非核心校验逻辑
  • ✅ 动态收缩令牌桶速率(基于 EWMA 平滑延迟)
  • ✅ 启用客户端重试退避(Jittered Exponential Backoff)
graph TD
    A[netem 注入抖动/丢包] --> B{流控SDK 检测到 P99↑300% & 错误率↑5×}
    B --> C[冻结令牌桶速率]
    C --> D[启动滑动窗口延迟估算]
    D --> E[按 EWMA 更新 refill rate]
    E --> F[上报自愈事件至可观测平台]

4.4 生产灰度发布方案:双流控并行运行、AB测试分流、拒绝率熔断自动降级机制

双流控并行架构

核心服务同时接入 主流量通道(全量旧逻辑)与 灰度通道(新版本),通过统一网关路由分发,保障旧链路零扰动。

AB测试动态分流

基于用户ID哈希+业务标签实现精准分流:

# gateway-rules.yaml
ab_test:
  strategy: "hash_mod"
  modulo: 100
  groups:
    - name: "control"   # 旧版
      range: [0, 89]
    - name: "treatment" # 新版
      range: [90, 99]

逻辑说明:hash(user_id) % 100 决定归属组;modulo=100 提供精细调控粒度,支持秒级热更新分流比例。

拒绝率熔断自动降级

当灰度通道 5 分钟内平均拒绝率 > 15%,自动触发降级开关,将全部请求切回主通道。

指标 阈值 检测周期 动作
reject_rate 15% 300s 关闭灰度通道
error_rate 5% 60s 触发告警并人工介入
graph TD
  A[请求进入] --> B{灰度标识命中?}
  B -->|是| C[路由至灰度通道]
  B -->|否| D[路由至主通道]
  C --> E[实时采集 reject_rate]
  E --> F{>15%?}
  F -->|是| G[自动关闭灰度开关]
  F -->|否| H[持续监控]

第五章:总结与展望

核心技术栈落地成效

在某省级政务云迁移项目中,基于本系列实践构建的自动化CI/CD流水线已稳定运行14个月,累计支撑237个微服务模块的持续交付。平均构建耗时从原先的18.6分钟压缩至2.3分钟,部署失败率由12.4%降至0.37%。关键指标对比如下:

指标项 迁移前 迁移后 提升幅度
日均发布频次 4.2次 17.8次 +324%
配置变更回滚耗时 22分钟 48秒 -96.4%
安全漏洞平均修复周期 5.7天 9.3小时 -95.7%

生产环境典型故障复盘

2024年Q2发生的一起跨可用区数据库连接池雪崩事件,暴露出监控告警阈值静态配置的缺陷。团队立即采用动态基线算法重构Prometheus告警规则,将pg_connections_used_percent的触发阈值从固定85%改为基于7天滑动窗口的P95分位值+2σ。该方案上线后,同类误报率下降91%,且提前17分钟捕获到某核心交易库连接泄漏苗头。

# 动态告警规则片段(Prometheus Rule)
- alert: HighDBConnectionUsage
  expr: |
    (rate(pg_stat_database_blks_read_total[1h]) 
      / on(instance) group_left() 
      (pg_settings_max_connections * 0.01)) 
    > (quantile_over_time(0.95, pg_stat_database_blks_read_total[7d]) 
       + 2 * stddev_over_time(pg_stat_database_blks_read_total[7d]))
  for: 5m

开源组件升级路径图

通过mermaid流程图呈现Kubernetes集群组件演进路线,明确各版本兼容边界与灰度策略:

graph LR
    A[v1.22.17] -->|2023-Q3| B[v1.24.15]
    B -->|2024-Q1| C[v1.26.12]
    C -->|2024-Q3| D[v1.28.9]
    B --> E[CSI Driver v1.11]
    C --> F[NetworkPolicy v1.2]
    D --> G[Service Mesh v1.22]
    style A fill:#ffcccc,stroke:#ff6666
    style D fill:#ccffcc,stroke:#66cc66

运维知识沉淀机制

建立“故障驱动文档”制度,要求每次P1级事件闭环后24小时内完成三类产出:① 可执行的Ansible Playbook(含idempotent校验);② Grafana仪表盘JSON导出包;③ 基于OpenAPI 3.0规范的接口健康检查定义。目前已沉淀137套标准化诊断脚本,覆盖82%的高频故障场景。

边缘计算协同架构

在智慧工厂IoT平台中验证了云边协同新范式:中心云负责模型训练与策略下发,边缘节点采用轻量化K3s集群承载实时推理任务。实测显示,当网络分区持续超过93秒时,边缘侧自动切换至本地决策模式,产线停机时间从平均4.2分钟缩短至18秒,设备OEE提升6.8个百分点。

技术债治理实践

针对遗留系统中37个硬编码IP地址,设计自动化扫描-替换-验证流水线:先用grep -r "192\.168\|10\." ./src定位,再通过sed -i 's/192\.168\.1\.100/${DB_HOST}/g'注入环境变量,最后执行curl -s http://localhost:8080/health | jq '.db.status'验证连通性。整套流程已集成至GitLab CI,纳入MR准入检查。

安全合规强化措施

依据等保2.0三级要求,在容器镜像构建阶段强制嵌入SBOM(软件物料清单),使用Syft生成CycloneDX格式报告,并通过Grype扫描CVE漏洞。所有生产镜像必须满足:无CVSS≥7.0高危漏洞、基础镜像更新不超过90天、签名证书由内部PKI体系签发。当前镜像合格率达100%,审计整改周期缩短至平均3.2工作日。

未来演进方向

探索eBPF技术在内核层实现零侵入式服务网格数据平面,已在测试环境验证TCP连接追踪延迟降低至83μs(较Envoy Proxy提升4.7倍)。同时启动WebAssembly系统调用沙箱预研,目标使第三方扩展插件加载速度提升300%,内存占用减少62%。

从 Consensus 到容错,持续探索分布式系统的本质。

发表回复

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