Posted in

接口限流在Go微服务中的5大致命误用(90%工程师踩过第3个坑)

第一章:接口限流在Go微服务中的核心价值与演进脉络

在高并发、多租户、云原生演进的背景下,接口限流已从“可选防御机制”跃升为微服务稳定性的基础设施能力。它不仅是防止雪崩的熔断前哨,更是资源公平分配、SLA契约履行与成本精细化管控的关键支点。

为什么限流是Go微服务的刚需

Go凭借轻量协程与高效调度天然适合构建高吞吐API网关与业务服务,但其非阻塞特性也放大了突发流量冲击风险——一个未限流的/search接口可能因爬虫或误配客户端,在数秒内耗尽连接池与CPU,拖垮整个服务实例。限流在此承担三重角色:保护后端依赖(如数据库、第三方API)、保障核心路径QoS(如支付优先于日志上报)、支撑多租户配额隔离(如免费版用户RPS≤100,企业版≤5000)。

从令牌桶到自适应限流的范式演进

早期常采用固定窗口计数器(简单但存在临界突变问题),随后主流转向平滑的令牌桶(golang.org/x/time/rate标准库实现)与漏桶算法。如今,基于实时指标的自适应限流成为新趋势:例如通过Sentinel-Golang采集P99延迟、错误率与系统Load,动态调整阈值;或利用eBPF在内核层捕获连接建立速率,实现毫秒级响应。

在Go服务中快速集成令牌桶限流

以下代码片段为HTTP Handler添加每秒100请求的平滑限流:

import (
    "net/http"
    "time"
    "golang.org/x/time/rate"
)

var limiter = rate.NewLimiter(rate.Every(time.Second/100), 100) // 100 QPS,允许100个初始令牌

func limitedHandler(w http.ResponseWriter, r *http.Request) {
    if !limiter.Allow() { // 非阻塞检查,返回bool
        http.StatusTooManyRequests
        http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
        return
    }
    // 正常业务逻辑
    w.WriteHeader(http.StatusOK)
}

该实现零依赖、低开销(单goroutine维护令牌),适用于大多数API场景。生产环境建议配合Prometheus暴露rate_limiter_rejected_total指标,形成可观测闭环。

第二章:限流算法选型的五大认知陷阱

2.1 误将令牌桶等同于“平滑限流”——Go标准库time.Ticker的精度幻觉与实测偏差

time.Ticker 常被误用为令牌桶的底层节拍器,但其实际调度受 OS 时间片、GMP 调度及系统负载影响,并非恒定周期触发

实测偏差示例

ticker := time.NewTicker(100 * time.Millisecond)
start := time.Now()
for i := 0; i < 10; i++ {
    <-ticker.C
    fmt.Printf("Tick %d: %.2fms\n", i, time.Since(start).Seconds()*1000)
    start = time.Now() // 重置测量起点
}

该代码测量相邻 ticker.C 触发间隔。实测在高负载 Linux 容器中,10次间隔标准差达 ±18ms(预期应趋近 0),证明其非硬实时

关键偏差来源

  • Go runtime 的 sysmon 线程每 20ms 扫描一次定时器,引入延迟毛刺;
  • ticker.C 本质是 channel 发送,受 goroutine 抢占与调度队列深度影响;
  • 内核 CLOCK_MONOTONIC 高精度 ≠ Go 运行时能精确投递。
环境 平均误差 最大偏差 是否适合令牌桶节奏
本地 macOS +3.2ms +12ms ❌(抖动不可控)
云服务器(4c8g) +7.8ms +29ms
RT-Linux + GOMAXPROCS=1 +0.4ms +1.1ms ✅(需严苛配置)
graph TD
    A[NewTicker 100ms] --> B[加入 runtime timer heap]
    B --> C{sysmon 每20ms扫描}
    C --> D[找到到期定时器]
    D --> E[唤醒 goroutine 向 ticker.C 发送]
    E --> F[接收方 goroutine 可能被抢占/阻塞]
    F --> G[观测到的间隔 ≠ 100ms]

2.2 滑动窗口计数器的并发安全陷阱——sync.Map vs atomic.Value在高频请求下的性能撕裂

数据同步机制

滑动窗口需原子更新时间桶与计数,sync.Map 适用于键动态增长场景,但其内部锁分段带来额外调度开销;atomic.Value 要求值类型不可变,适合整体替换窗口快照(如 map[int]intstruct{ buckets [60]int })。

性能对比关键维度

维度 sync.Map atomic.Value
写吞吐(QPS) ~120k ~480k
内存分配/操作 每次写分配新节点 零分配(仅复制结构体)
读路径延迟 ~85ns(含哈希+锁) ~9ns(纯内存加载)
// 使用 atomic.Value 实现无锁窗口快照
type Window struct {
    data atomic.Value // 存储 *windowData
}
type windowData struct {
    buckets [60]int // 每秒桶,固定大小
    offset  int     // 当前窗口起始秒戳(Unix 时间)
}

逻辑分析:atomic.Value 将整个 windowData 视为不可变单元;每次窗口滑动时,新建结构体并 Store() 替换指针,避免临界区竞争。offset 保证时间对齐,[60]int 数组使 CPU 缓存行友好,消除 false sharing。

graph TD A[请求抵达] –> B{是否跨秒?} B –>|是| C[构造新 windowData] B –>|否| D[原子读取当前 data] C –> E[Store 新快照] D –> F[unsafe.Add 索引后 CAS 更新]

2.3 分布式场景下Redis Lua脚本的原子性失效——pipeline乱序、网络分区与watch超时的真实案例复盘

看似原子,实则脆弱

Redis 单实例中 Lua 脚本具备原子性,但分布式环境下(如 Redis Cluster 或主从+客户端分片),EVAL 不再天然隔离。Pipeline 中混入 EVALWATCH/MULTI/EXEC,极易因命令重排导致预期外的并发行为。

典型失效链路

-- 案例:库存扣减 Lua 脚本(含 WATCH)
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) >= tonumber(ARGV[1]) then
  redis.call('DECRBY', KEYS[1], ARGV[1])
  return 1
else
  return 0
end

⚠️ 问题:该脚本未使用 WATCH(Lua 内部无 watch 语义),且在集群中若 KEY 跨槽,直接报错 CROSSSLOT;若强制打到同一节点,仍可能因网络分区导致主从间状态不一致。

关键失效维度对比

失效诱因 表现 是否可被 Lua 规避
Pipeline 乱序 客户端批量发送,服务端执行顺序与逻辑依赖错位 否(pipeline 本身无事务边界)
网络分区 主节点写入成功,从节点未同步,读取陈旧值 否(Lua 不解决复制延迟)
WATCH 超时 WATCH + MULTI/EXEC 组合在高延迟链路中频繁失败 是(但需放弃 Lua 原子封装)

根本约束

graph TD
  A[Lua 脚本] --> B[单 Redis 进程内原子]
  B --> C{分布式环境?}
  C -->|是| D[跨节点:不可用<br>跨主从:不一致<br>跨 pipeline:无序]
  C -->|否| E[严格原子]

2.4 漏桶算法在突发流量下的响应延迟误判——基于Go net/http middleware的压测对比实验(wrk + pprof)

漏桶算法天然平滑请求速率,却在突发流量场景下将排队等待时间隐式计入P99延迟,导致可观测性失真。

实验设计关键点

  • 使用 wrk -t4 -c100 -d30s http://localhost:8080/api 模拟脉冲式并发
  • 对比:朴素限流 vs golang.org/x/time/rate.Limiter(漏桶)vs 自适应令牌桶(uber-go/ratelimit

核心问题代码片段

func leakyBucketMiddleware(next http.Handler) http.Handler {
    limiter := rate.NewLimiter(rate.Every(100*time.Millisecond), 1) // 容量1,填充间隔100ms
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() { // 阻塞式检查 → 等待时间计入HTTP延迟
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Allow() 不阻塞但返回false;若改用 Wait(ctx) 则显式阻塞,此时pprof火焰图中runtime.gopark占比飙升,而http.Server.ServeHTTP耗时被错误归因于业务逻辑。

压测延迟分布对比(单位:ms)

算法类型 P50 P90 P99 P99.9
无限流 1.2 2.8 5.1 18.3
漏桶(rate.Limiter) 1.3 3.1 127.6 412.9
自适应令牌桶 1.4 3.5 6.9 22.1

P99跃升超25倍,本质是漏桶队列积压延迟被HTTP中间件透传暴露。

2.5 自适应限流的指标污染问题——QPS、P99、CPU负载三维度耦合导致的限流阈值震荡

当QPS突增时,P99延迟往往滞后上升,而CPU负载又因JVM GC或线程争用产生尖峰,三者非线性耦合导致限流器误判“过载”,频繁下调阈值;随后流量回落,各指标异步恢复,又触发阈值快速上抬——形成毫秒级震荡。

指标采集不同步示例

# 采样窗口错位加剧污染(单位:ms)
qps_window = time.time() - 1000    # 基于最近1s请求计数
p99_window = time.time() - 2000    # 延迟统计滞后2s(为规避GC干扰)
cpu_window = time.time() - 500     # CPU采样周期仅500ms,高敏但噪声大

该错位设计本意是平衡实时性与稳定性,却使限流决策依据的“同一时刻状态”实际来自三个时间切片,放大指标漂移。

震荡根因归类

  • ✅ QPS与P99存在天然相位差(流量激增→延迟升高需缓冲填充)
  • ✅ CPU负载对短时GC事件过度敏感(如G1 Mixed GC暂停引发瞬时100%)
  • ❌ 三指标未加权融合,直接取min()作为限流阈值基线
维度 响应延迟 噪声源 权重建议
QPS 网络抖动 0.4
P99 800–1200ms GC pause 0.35
CPU 采样抖动 0.25
graph TD
    A[原始指标流] --> B{时间对齐模块}
    B --> C[QPS@t-1s]
    B --> D[P99@t-2s]
    B --> E[CPU@t-0.5s]
    C & D & E --> F[加权滑动融合]
    F --> G[平滑限流阈值]

第三章:Go限流中间件的三大集成反模式

3.1 在HTTP Handler链中错误放置限流中间件——goroutine泄漏与context取消未传播的调试实录

问题现场还原

某服务在高并发压测后内存持续增长,pprof 显示大量 runtime.gopark 阻塞在 semacquire,且 net/httpServeHTTP 调用栈中 ctx.Done() 始终未触发。

错误中间件位置示例

func RateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ❌ 错误:在 context.WithTimeout 前就调用限流器(可能阻塞)
        if !limiter.Allow() {
            http.Error(w, "rate limited", http.StatusTooManyRequests)
            return
        }
        // ✅ 正确顺序:先派生带超时的 ctx,再执行业务逻辑
        ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
        defer cancel() // 若限流后 handler panic,cancel 不会被调用!
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

分析limiter.Allow() 若基于 channel 或 mutex 实现,在限流拒绝前可能已启动 goroutine 等待令牌;而 r.Context() 未被提前包装,导致下游 handler 无法感知父请求取消,cancel() 也因 return 提前跳过,引发 goroutine 泄漏。

中间件正确链式顺序对比

位置 是否传播 cancel 是否保护 goroutine 生命周期 风险
限流 → 超时 → 日志 ❌ 否(超时 ctx 未覆盖原 ctx) ❌ 否(cancel 可能不执行) context 泄漏 + goroutine 悬挂
超时 → 限流 → 日志 ✅ 是(所有 handler 共享同一 cancelable ctx) ✅ 是(defer cancel 在作用域末尾强制执行) 安全

根本修复流程

graph TD
    A[HTTP Server] --> B[Timeout Middleware]
    B --> C[Rate Limit Middleware]
    C --> D[Auth Middleware]
    D --> E[Handler]
    B -.-> F[defer cancel on exit]
    C -->|调用 limiter.Wait(ctx)| G[阻塞等待令牌]
    G -->|ctx.Done() 触发| H[立即返回 ErrLimitExhausted]

3.2 使用全局限流器实例导致多租户资源争抢——基于go.uber.org/fx依赖注入的隔离方案重构

当多个租户共享单例 golimiter.Limiter 时,请求洪峰下易触发跨租户速率抢占,造成高优先级租户被低优先级租户“饿死”。

核心问题定位

  • 全局单例限流器无租户上下文感知能力
  • fx.Provide(limiter.New) 默认绑定为 Singleton scope
  • 租户标识(如 tenantID)无法参与限流策略构造

基于 FX 的租户级限流器注入

// 按租户 ID 动态提供独立限流器实例
fx.Provide(func(lc fx.Lifecycle, cfg Config) fx.Out {
    limiter := limiter.New(
        limiter.WithRate(cfg.Rate),     // 每秒请求数(租户专属配额)
        limiter.WithBurst(cfg.Burst),   // 突发容量(防抖阈值)
    )
    return fx.Out{Value: limiter, Scope: fx.Transient} // 关键:改用 Transient 作用域
})

此处 fx.Transient 确保每次 fx.In 注入时生成新实例;cfg.Rate 需由租户配置中心动态加载,实现配额隔离。

限流器作用域对比

作用域 实例生命周期 多租户适用性
fx.Singleton 进程启动时创建一次 ❌ 共享争抢
fx.Transient 每次注入新建 ✅ 完全隔离
graph TD
    A[HTTP Request] --> B{Extract tenantID}
    B --> C[Resolve Limiter via FX]
    C --> D[Transient Limiter per tenant]
    D --> E[Apply rate limit]

3.3 忽略gRPC拦截器中的流式RPC特殊处理——Unary与Streaming限流策略混用引发的连接雪崩

流式RPC的生命周期本质

与 Unary RPC 的“请求-响应”原子性不同,Streaming RPC(如 ServerStreaming)建立长连接后持续推送多条消息,其上下文生命周期跨越多次 Send() 调用,不能被单次限流令牌消耗覆盖

混用限流策略的典型误用

// ❌ 错误:对所有 RPC 类型统一调用 unary 限流器
func rateLimitInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    if !limiter.Allow() { // 此处未区分 streaming,导致流式连接被错误拒绝或令牌透支
        return nil, status.Error(codes.ResourceExhausted, "rate limited")
    }
    return handler(ctx, req)
}

逻辑分析Allow() 在流式 RPC 中被反复调用(每次 Send() 可能触发新拦截),但 limiter 无连接粒度状态跟踪,导致同一 stream 被多次扣减令牌,快速耗尽配额,触发下游服务过载。

正确分流策略对比

RPC 类型 适用限流维度 是否支持连接复用 风险点
Unary 请求级 低延迟、易建模
ServerStream 连接级 + 消息频次 令牌泄漏、雪崩传导

关键修复路径

  • 使用 grpc.StreamServerInterceptor 单独处理流式 RPC;
  • 基于 stream.Context() 绑定连接级限流器(如 tokenbucket.NewBucketPerConn);
  • 通过 metadata.FromIncomingContext() 提取 rpc-type 标识实现路由分发。

第四章:生产环境限流可观测性的四大断层

4.1 Prometheus指标命名不规范导致告警失效——counter/gauge/histogram的语义混淆与Grafana看板重建

语义误用的典型场景

将请求成功率定义为 http_requests_total{status="2xx"}(counter)却直接用于 rate() 计算后做阈值告警,忽略其单调递增本质——若服务重启导致 counter 重置,rate() 自动处理,但若错误地用 delta()changes() 则触发误告。

正确指标定义示例

# ✅ 推荐:使用 counter 表达累积事件
http_requests_total{method="GET", status="200"} 123456

# ❌ 错误:用 gauge 模拟计数器(破坏语义)
http_requests_gauge{method="GET"} 123456  # 导致 rate() 无意义

http_requests_total 是 counter,必须配合 rate()/increase() 使用;http_requests_gauge 是 gauge,适用于瞬时值(如内存使用率),不可用于求速率。

指标类型决策表

类型 适用场景 告警推荐函数 Grafana 查询示例
Counter 累积请求数、错误总数 rate(x[5m]) < 0.1 rate(http_requests_total{code="500"}[5m])
Gauge CPU使用率、在线用户数 x > 90 node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100
Histogram 请求延迟分布(P90/P99) histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

Grafana重建关键步骤

  • 删除所有硬编码 sum(delta(...)) 类查询;
  • 将原看板中 http_errors_per_second 替换为 rate(http_requests_total{status=~"5.."}[5m])
  • 对延迟指标统一改用 histogram_quantile() 而非 avg_over_time()

4.2 限流日志缺乏trace_id与request_id关联——OpenTelemetry SDK在gin/echo框架中的埋点补全实践

限流中间件(如 governorxrate)通常独立于 HTTP 请求生命周期,导致其日志缺失 OpenTelemetry 上下文,无法与 trace_idrequest_id 关联。

关键问题定位

  • Gin/Echo 的 context.Context 未透传至限流器回调
  • otel.GetTextMapPropagator().Inject() 未在限流触发时调用

补全方案:注入 trace_id 到限流日志

// 在 Gin 中间件中将 context 注入限流器配置
limiter := xrate.NewRateLimiter(xrate.WithContextInjector(func(ctx context.Context) context.Context {
    // 将当前 span context 注入子 context
    return otel.GetTextMapPropagator().Inject(
        ctx, propagation.HeaderCarrier(request.Header))
}))

该代码确保限流器内部调用 log.Printf 时可通过 ctx.Value(oteltrace.SpanKey) 提取 trace_idrequest_id 可通过 gin.Context.Request.Header.Get("X-Request-ID") 获取并显式写入日志字段。

日志结构增强对比

字段 原始限流日志 补全后日志
trace_id 0123456789abcdef
request_id req-abc123
limit_key ip:192.168.1.1 同左
graph TD
    A[HTTP Request] --> B[Gin Context with Span]
    B --> C[限流中间件]
    C --> D{调用 Inject<br>注入 trace_id}
    D --> E[限流日志含 trace_id/request_id]

4.3 熔断-限流-降级策略边界模糊引发的决策冲突——基于go-resilience的策略编排DSL设计与验证

当熔断器判定服务不可用、限流器拒绝新请求、降级逻辑主动返回兜底响应时,三者常因共享同一错误信号(如 503 Service Unavailable)而触发竞态,导致策略叠加或相互覆盖。

策略执行优先级语义冲突

  • 熔断:面向稳定性保障,依据失败率/持续时间决策;
  • 限流:面向容量约束,依据QPS/并发数实时拦截;
  • 降级:面向业务韧性,依据SLA阈值或人工开关触发。

go-resilience DSL 核心抽象

Policy("payment-flow").
  When(StatusCode(503)). // 统一触发源
  Then(
    Fuse().Timeout(30*time.Second), // 熔断:30s冷却
    RateLimit(100).Window(1*time.Minute), // 限流:1分钟100次
    Fallback(StaticResponse("pay_unavailable")) // 降级:静态兜底
  ).WithOrchestration(FirstWin) // 关键:声明编排语义

此DSL将策略视为可组合的“行为契约”,WithOrchestration(FirstWin) 明确指定:首个满足条件的策略立即生效,其余跳过,消除叠加歧义。

策略决策矩阵(按请求上下文)

上下文特征 熔断生效 限流生效 降级生效 最终动作
连续失败率 > 80% 拒绝并返回熔断态
QPS > 100(无熔断) 拒绝并返回429
SLA延迟 > 2s 返回兜底响应
graph TD
  A[请求进入] --> B{熔断器检查}
  B -->|OPEN| C[直接返回熔断态]
  B -->|CLOSED| D{限流器检查}
  D -->|超限| E[返回429]
  D -->|未超限| F{降级规则匹配}
  F -->|匹配| G[执行Fallback]
  F -->|不匹配| H[正常转发]

4.4 限流拒绝响应体未遵循API契约——RFC 7807 Problem Details标准在Go echo-gin中的统一实现

当限流中间件直接返回 {"error": "rate limited"} 时,客户端无法可靠解析错误类型、状态码或可恢复建议,违背 RFC 7807 对标准化问题描述(application/problem+json)的强制要求。

统一问题响应结构

RFC 7807 要求响应体包含以下核心字段:

字段 类型 必填 说明
type string 问题类型URI(如 /problems/rate-limited
title string 简明英文标题(如 "Rate Limit Exceeded"
status integer HTTP 状态码(如 429
detail string 补充说明(支持i18n占位符)
retry-after integer 秒级重试建议(HTTP头与body双写)

Gin/Echo 共享 Problem 结构体

type Problem struct {
    Type     string `json:"type"`
    Title    string `json:"title"`
    Status   int    `json:"status"`
    Detail   string `json:"detail,omitempty"`
    RetryAfter int  `json:"retry-after,omitempty"`
}

该结构体被封装为 ProblemJSON() 方法,自动设置 Content-Type: application/problem+json 并写入 status 到响应头,确保与 HTTP 状态语义严格对齐。

中间件注入流程

graph TD
    A[请求到达] --> B{是否触发限流?}
    B -->|是| C[构造Problem实例]
    C --> D[设置Retry-After Header]
    D --> E[Write JSON with status]
    B -->|否| F[继续处理]

第五章:通往弹性架构的限流演进路线图

在真实生产环境中,限流不是一蹴而就的配置开关,而是伴随业务增长、技术栈迭代与故障认知深化持续演进的过程。某头部电商中台团队在三年间完成了从单点硬编码到全链路自适应限流的完整跃迁,其路径具备典型参考价值。

早期阶段:Nginx层静态阈值限流

初期采用 limit_req zone=api burst=10 nodelay 在边缘网关统一拦截突发流量。该方案部署快、无侵入,但无法区分用户等级(如VIP/普通)、无法感知下游DB连接池水位,2021年“618”大促期间因商品详情页缓存击穿导致大量请求穿透至数据库,触发了误杀式限流——高优先级订单查询也被同等限制。

中期阶段:应用层注解驱动限流

引入Sentinel 1.8后,在Spring Boot服务中通过 @SentinelResource(value = "orderCreate", blockHandler = "handleBlock") 实现接口粒度控制。配合动态规则推送中心,运营可实时调整秒杀接口QPS阈值(如从500→2000)。关键改进在于支持多维度上下文限流:基于userId做单用户QPS限制,避免羊毛党刷单;基于regionId实现地域分级熔断。下表为某次灰度发布期间的限流效果对比:

阶段 平均响应延迟 5xx错误率 限流命中率(订单创建)
Nginx静态限流 320ms 4.7% 12.3%(误伤率高)
Sentinel注解限流 89ms 0.2% 3.1%(精准拦截恶意流量)

近期阶段:服务网格+AI预测式限流

升级至Istio 1.20后,在Envoy Filter中集成轻量级时序模型(Prophet简化版),每30秒分析上游调用模式与下游Pod CPU/内存指标。当检测到“支付回调接口调用量突增且下游Redis延迟上升150%”时,自动将该链路权重降为0.3,并向SRE推送根因建议:“建议扩容redis-cluster-2分片”。以下Mermaid流程图展示该决策闭环:

flowchart LR
A[Envoy Access Log] --> B[Metrics Collector]
B --> C{时序异常检测}
C -- 异常 --> D[限流策略引擎]
C -- 正常 --> E[透传请求]
D --> F[动态更新Cluster Weight]
F --> G[Envoy xDS下发]
G --> H[实时生效]

关键演进原则

必须建立限流可观测性基线:所有限流动作需记录rule_idtrigger_reasonaffected_service三元组,并接入ELK实现分钟级聚合分析;禁止使用try-catch包裹限流逻辑——某次因未捕获BlockException导致降级方法未执行,订单状态机卡在“待支付”达17小时。

生产验证案例

2023年双十二前夜,营销系统因AB测试配置错误引发裂变分享接口雪崩。基于服务网格的限流体系在23秒内完成三级响应:① 自动识别/share/v2路径调用量超基线300%;② 将该路径下游依赖的用户中心服务调用权重降至0.1;③ 同步触发告警并生成回滚建议(Git commit ID: a7f3b9c)。最终核心下单链路P99延迟稳定在112ms,未触发任何业务降级预案。

记录 Go 学习与使用中的点滴,温故而知新。

发表回复

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