Posted in

【国家级项目脱敏版】某智慧高速项目中Golang行人检测模块的9次架构迭代(从单机10路到万路集群调度)

第一章:Golang行人检测模块的演进全景与项目背景

行人检测作为智能视频分析的核心能力,长期依赖 Python 生态(如 OpenCV + YOLO/SSD),但在高并发边缘设备部署中面临 GC 延迟高、内存占用大、交叉编译复杂等瓶颈。为支撑某城市级交通感知平台对低功耗网关(ARM64, 2GB RAM)的实时性要求(≥15 FPS @ 720p),团队启动 Golang 原生行人检测模块的研发演进。

早期采用 cgo 封装 libtorch C++ API,虽保留模型精度,但引入动态链接依赖、构建链脆弱,且 Go runtime 与 C++ 异步线程调度冲突导致偶发 panic。第二阶段转向纯 Go 实现轻量级推理引擎——基于 gorgonia 构建计算图,并适配 ONNX Runtime 的 Go binding(github.com/microsoft/onnxruntime-go),显著提升可移植性。当前稳定版本已集成优化后的 MobileNetV3+YOLOv5s 蒸馏模型(ONNX 格式,仅 4.2MB),支持 TensorRT 加速(需 CUDA 环境)与 CPU fallback 双模式。

关键构建步骤如下:

# 1. 获取预编译 ONNX Runtime for Linux ARM64
wget https://github.com/microsoft/onnxruntime/releases/download/v1.18.0/onnxruntime-linux-aarch64-1.18.0.tgz
tar -xzf onnxruntime-linux-aarch64-1.18.0.tgz

# 2. 设置 CGO 环境并构建(需指定 ORT 路径)
export CGO_CPPFLAGS="-I$(pwd)/onnxruntime-linux-aarch64-1.18.0/include"
export CGO_LDFLAGS="-L$(pwd)/onnxruntime-linux-aarch64-1.18.0/lib -lonnxruntime"
go build -o peddet-server ./cmd/server

该模块已接入生产环境 237 个路口摄像头,平均端到端延迟 68ms(含解码、预处理、推理、后处理),较原 Python 方案降低 41%。演进路径清晰体现从“胶水层封装”到“原生协同设计”的技术跃迁。

核心演进阶段对比

阶段 技术选型 启动时间 主要约束
cgo 封装 libtorch C++ + Go 2022.Q2 动态库版本锁定、调试困难
ONNX 绑定 onnxruntime-go 2023.Q1 CGO 依赖、CUDA 兼容性碎片化
原生推理内核 gorgonia + 自研算子 2024.Q3 模型表达力受限,持续迭代中

当前核心能力

  • 支持 ONNX 模型热加载(无需重启服务)
  • 内置 ROI 区域裁剪与多尺度金字塔输入适配
  • 提供 Prometheus 指标接口(/metrics),暴露推理耗时、FPS、GPU 显存使用率
  • 通过 config.yaml 动态切换 CPU/TensorRT 执行提供者

第二章:单机轻量级检测架构设计与工程实践

2.1 Go协程模型与YOLOv5推理流水线的深度适配

Go 的轻量级协程(goroutine)天然契合 YOLOv5 推理中“预处理→推理→后处理→结果聚合”的阶段解耦特性,避免传统线程模型的上下文切换开销。

数据同步机制

采用 chan *InferenceTask 构建无锁任务队列,配合 sync.WaitGroup 控制流水线启停:

// 定义任务通道(缓冲区大小=GPU批处理容量)
taskCh := make(chan *InferenceTask, 32)
for i := 0; i < runtime.NumCPU(); i++ {
    go func() {
        for task := range taskCh {
            task.RunYOLOv5Inference() // 调用cgo封装的libtorch C API
        }
    }()
}

逻辑分析:taskCh 缓冲区设为32,匹配典型TensorRT引擎的batch=8×4并发吞吐;runtime.NumCPU() 启动协程数,避免GPU计算单元争抢;RunYOLOv5Inference() 内部通过 cgo 调用已序列化的 .engine 模型,规避 Python GIL 限制。

协程调度与资源绑定

阶段 协程策略 GPU内存复用方式
预处理 固定4个goroutine 预分配 cudaMalloc 显存池
推理 绑定至单个GPU device 异步stream+pin memory
后处理 每任务独占goroutine CPU侧零拷贝共享内存
graph TD
    A[HTTP请求] --> B[Preproc goroutine]
    B --> C[taskCh]
    C --> D{Inference goroutine pool}
    D --> E[Postproc goroutine]
    E --> F[Result channel]

2.2 内存池+对象复用机制在高频视频帧处理中的落地验证

在 60fps+ 视频流场景下,单帧频繁 new/delete 导致 GC 压力陡增,内存分配延迟波动达 12–47μs。引入固定大小内存池(每块 1920×1080×3 字节)后,分配耗时稳定在 0.8±0.1μs

对象生命周期管理

  • 帧对象从池中 acquire() 获取,处理完毕调用 release() 归还;
  • 线程局部缓存(TLB)避免锁竞争;
  • 超时未归还对象触发异步健康检查并告警。

性能对比(1080p@120fps,持续5分钟)

指标 原生 new/delete 内存池+复用
平均分配延迟 28.3 μs 0.82 μs
GC 暂停次数 1,842 0
内存碎片率 31.7%
class FramePool {
private:
    static constexpr size_t FRAME_SIZE = 1920 * 1080 * 3;
    std::vector<std::unique_ptr<uint8_t[]>> pool_;
    std::stack<uint8_t*> available_;
    std::mutex mtx_;

public:
    uint8_t* acquire() {
        std::lock_guard<std::mutex> lk(mtx_);
        if (!available_.empty()) {
            auto ptr = available_.top(); available_.pop();
            return ptr; // 零拷贝复用,无构造开销
        }
        // 池满时扩容(预设上限防OOM)
        pool_.emplace_back(std::make_unique<uint8_t[]>(FRAME_SIZE));
        return pool_.back().get();
    }
};

逻辑分析:acquire() 优先从空闲栈取块,避免 mallocFRAME_SIZE 严格对齐视频原始 YUV420 平面总尺寸,消除运行时计算开销;std::stack 提供 O(1) 复用路径,std::vector 承载预分配块集合,兼顾线程安全与吞吐。

graph TD
    A[新帧到达] --> B{池中有空闲块?}
    B -->|是| C[快速pop返回指针]
    B -->|否| D[分配新块并加入pool_]
    C --> E[填充YUV数据]
    D --> E
    E --> F[处理管线执行]
    F --> G[release归还至available_]

2.3 基于channel的异步预处理-推理-后处理三级解耦实现

通过 Go channel 构建无锁流水线,实现三阶段天然解耦:

数据同步机制

预处理、推理、后处理各自运行在独立 goroutine 中,通过有缓冲 channel 传递 *DataBatch

// 预处理 → 推理 → 后处理通道链
preToInfer := make(chan *DataBatch, 16)
inferToPost := make(chan *ResultBatch, 16)

// 预处理协程(示例)
go func() {
    for raw := range inputChan {
        batch := preprocess(raw) // CPU密集型
        preToInfer <- batch      // 非阻塞写入
    }
}()

逻辑分析:preToInfer 缓冲区设为 16,平衡预处理吞吐与内存开销;*DataBatch 指针传递避免深拷贝,batch 生命周期由 channel 所有权转移管理。

性能对比(典型场景)

阶段 同步调用延迟 Channel 流水线延迟 吞吐提升
预处理 82 ms
推理 145 ms
端到端 227 ms 163 ms +39%
graph TD
    A[原始数据] --> B[预处理 goroutine]
    B -->|preToInfer| C[推理 goroutine]
    C -->|inferToPost| D[后处理 goroutine]
    D --> E[最终结果]

2.4 GPU显存零拷贝共享与CUDA Context跨goroutine安全复用

Go语言中直接管理CUDA资源面临两大挑战:显存跨进程/线程拷贝开销大,以及CUcontext非goroutine-safe导致竞态风险。

零拷贝共享机制

利用cuMemHostRegister将宿主内存页锁定并映射至GPU地址空间,实现CPU/GPU直访:

// 注册页锁定内存,启用零拷贝访问
CUresult res = cuMemHostRegister(host_ptr, size, CU_MEMHOSTREGISTER_DEVICEMAP);
// host_ptr: 可被GPU直接读写的对齐内存(如posix_memalign分配)
// size: 必须为系统页大小整数倍(通常4KB)
// CU_MEMHOSTREGISTER_DEVICEMAP: 启用设备地址映射

该调用使host_ptr在GPU侧可通过cuMemHostGetDevicePointer获取设备指针,规避cudaMemcpy开销。

Context安全复用策略

CUDA Context不支持并发复用,需结合Go sync.Pool与TLS(线程局部存储)隔离:

方案 线程安全 复用粒度 上下文切换开销
全局单例Context ❌(严重竞态) 进程级 极低但不可用
goroutine本地Context goroutine级 中等(首次创建+绑定)
sync.Pool缓存Context 池级复用 低(避免重复创建)
graph TD
    A[goroutine启动] --> B{Context in Pool?}
    B -- 是 --> C[Pop & cuCtxSetCurrent]
    B -- 否 --> D[Create new CUcontext]
    C --> E[执行Kernel]
    D --> E
    E --> F[Push back to Pool]

核心原则:每个goroutine独占Context绑定,通过cuCtxSetCurrent切换上下文,禁止跨goroutine传递CUcontext句柄。

2.5 单机10路并发下的延迟压测、CPU/GPU利用率热力图分析

为验证单节点服务在中等并发压力下的稳定性,我们采用 wrk 对 REST API 接口发起 10 路持久连接压测,持续 60 秒:

wrk -t10 -c10 -d60s http://localhost:8080/infer

参数说明:-t10 启动 10 个线程模拟并发;-c10 每线程维持 10 个连接(共 100 连接),精准匹配“10 路并发”场景;-d60s 避免瞬时抖动干扰长期趋势。

同步采集指标:

  • CPU 使用率(/proc/stat + mpstat -P ALL 1
  • GPU 显存与利用率(nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,memory.used --format=csv -l 1
  • 端到端 P99 延迟(wrk 输出 + 自研日志采样)

热力图生成逻辑

使用 Python seaborn.heatmap() 将每秒采样数据映射为时间-设备维度矩阵,横轴为时间戳(秒),纵轴为 CPU 核心 ID / GPU ID,颜色深浅表征负载强度。

设备类型 采样频率 关键指标
CPU 1 Hz user%, sys%, iowait%
GPU 1 Hz utilization.gpu [%]
graph TD
    A[wrk压测启动] --> B[并行采集CPU/GPU指标]
    B --> C[聚合为time-series矩阵]
    C --> D[归一化+插值]
    D --> E[seaborn.heatmap渲染]

第三章:分布式推理服务化转型的关键突破

3.1 gRPC流式接口设计与行人轨迹ID一致性保障协议

流式接口定义(ServerStreaming)

service TrajectoryService {
  // 按行人ID持续推送轨迹点,保证单ID单流语义
  rpc StreamTrajectoryByPID(TrajectoryRequest) 
    returns (stream TrajectoryPoint);
}

message TrajectoryRequest {
  string person_id = 1;  // 必填:全局唯一行人标识(UUIDv4)
  int64 start_timestamp = 2; // 微秒级起始时间戳
}

person_id 是一致性锚点:服务端严格按此字段路由至专属处理协程,避免跨ID混流;start_timestamp 支持断点续传,配合客户端重连ID实现幂等订阅。

ID一致性保障机制

  • 服务端强绑定:每个 person_id 对应独立的 sync.Map[string]*streamState
  • 客户端重连令牌:携带 reconnect_token = SHA256(person_id + session_salt) 防重放
  • 流生命周期约束:单 person_id 同时仅允许1个活跃流,新请求自动终止旧流

状态同步关键字段对照表

字段名 类型 作用 一致性要求
person_id string 行人全局唯一标识 全链路透传,禁止修改
track_seq uint32 单ID内单调递增序列号 服务端原子递增,检测跳变
server_ts int64 服务端生成时间戳(纳秒) 用于校准客户端时钟偏移

数据同步机制

graph TD
  A[客户端发起StreamTrajectoryByPID] --> B{服务端校验person_id格式}
  B -->|合法| C[分配专属goroutine+seq计数器]
  B -->|非法| D[返回INVALID_ARGUMENT]
  C --> E[从时序数据库按person_id+timestamp范围拉取轨迹点]
  E --> F[注入track_seq并写入gRPC流]

序列号 track_seq 在 goroutine 内部通过 atomic.AddUint32 维护,确保多线程安全且严格保序;任意轨迹点丢失将导致 track_seq 不连续,触发客户端告警与重同步。

3.2 模型版本灰度发布与推理结果AB对比验证框架

为保障模型迭代上线的可靠性,需在真实流量中同步运行新旧版本(A/B),并量化比对输出差异。

核心流程设计

# AB对比中间件:拦截请求,分发至双模型实例
def ab_inference(request, model_a, model_b, traffic_ratio=0.1):
    if random.random() < traffic_ratio:  # 10%灰度流量走B
        pred_a = model_a.predict(request)
        pred_b = model_b.predict(request)
        log_ab_pair(request.id, pred_a, pred_b)  # 持久化对比样本
        return pred_b
    return model_a.predict(request)  # 默认走稳定版A

traffic_ratio 控制灰度比例;log_ab_pair 写入结构化日志供后续统计分析,确保可追溯性。

关键指标看板

指标 计算方式 阈值告警
输出一致性率 pred_a == pred_b 的占比
置信度偏移均值 abs(conf_a - conf_b) 均值 >0.15
响应延迟增幅 (lat_b - lat_a) / lat_a >20%

数据同步机制

graph TD
A[线上请求] –> B{灰度分流器}
B –>|10%| C[新模型B]
B –>|90%| D[旧模型A]
C & D –> E[AB对比日志中心]
E –> F[实时指标计算引擎]

3.3 分布式队列(NATS JetStream)驱动的负载感知任务分发策略

传统轮询或随机分发无法应对异构工作节点的实时资源波动。NATS JetStream 通过流式消息持久化与消费者组语义,为负载感知分发提供底层支撑。

动态权重注册机制

Worker 启动时向 $JS.API.CONSUMER.CREATE 提交带标签的消费者配置,并定期发布心跳消息至 worker.heartbeat.<id> 主题,含 CPU 使用率、内存余量、待处理任务数等指标。

负载感知路由逻辑

// 基于加权轮询的消费者选择(伪代码)
func selectConsumer(workers []Worker) string {
    totalWeight := 0
    for _, w := range workers {
        // 权重 = 基础能力 × (1 - 归一化负载率)
        w.Weight = w.BaseCapacity * (1.0 - w.LoadRatio)
        totalWeight += int(w.Weight)
    }
    // 随机采样实现概率性调度
    r := rand.Intn(totalWeight)
    for _, w := range workers {
        if r < int(w.Weight) {
            return w.ID
        }
        r -= int(w.Weight)
    }
    return workers[0].ID
}

该逻辑将 JetStream 的 pull-based 消费模型与实时指标融合:BaseCapacity 表示节点理论吞吐上限(如 100 QPS),LoadRatio 由心跳消息动态计算,确保高负载节点被自动降权。

调度效果对比(单位:ms 平均延迟)

策略 P50 P95 任务积压率
纯轮询 82 210 12.4%
负载感知(JetStream) 47 98 1.8%
graph TD
    A[Producer] -->|Publish task<br>with affinity hint| B(JetStream Stream)
    B --> C{Consumer Group}
    C --> D[Worker-A<br>CPU: 32%]
    C --> E[Worker-B<br>CPU: 87%]
    C --> F[Worker-C<br>CPU: 41%]
    D -.->|权重 +0.68| G[Router]
    E -.->|权重 +0.13| G
    F -.->|权重 +0.59| G
    G -->|Route to max-weight| D & F

第四章:万路级集群调度体系的构建与稳定性治理

4.1 基于etcd的动态节点注册/健康探活与拓扑感知路由算法

服务节点启动时,通过 PUT /v3/kv/put 向 etcd 注册带 TTL 的临时键(如 /nodes/worker-001),值为 JSON 序列化的元数据:

# 注册节点(TTL=30s,自动续租)
curl -L http://localhost:2379/v3/kv/put \
  -X POST -H "Content-Type: application/json" \
  -d '{
        "key": "L25vZGVzL3dvcmtlci0wMDE=",
        "value": "ewogICAicG9ydCI6IDI4MDAsCiAgICJyZWdpb24iOiAiYmVpamluZyIsCiAgICJ6b25lIjogInppLTAxIgp9",
        "lease": 1234567890
      }'

逻辑分析key 为 Base64 编码路径,value 含端口、地域(region)、可用区(zone)等拓扑标签;lease 关联租约 ID,由客户端后台 goroutine 每 10s 调用 KeepAlive() 续期。超时未续则键自动删除,触发监听事件。

健康状态同步机制

  • 客户端监听 /nodes/ 前缀变更,实时更新本地节点视图
  • 每个节点上报心跳时携带 last_heartbeat_tsload_score(CPU+网络延迟加权)

拓扑优先级路由策略

优先级 匹配条件 权重
1 同 zone & 同 region 100
2 同 region & 不同 zone 80
3 不同 region 30
graph TD
  A[Client发起请求] --> B{查询本地节点缓存}
  B --> C[按拓扑权重排序候选节点]
  C --> D[剔除无 lease 或 load_score > 0.9 的节点]
  D --> E[轮询选取首个健康节点]

4.2 行人检测SLA分级保障:关键路段QoS优先级抢占式调度

在城市智能路口边缘节点中,行人检测任务需按SLA等级动态调度计算资源。核心机制是基于实时交通流密度与事件紧急度的双因子优先级仲裁。

QoS等级映射规则

  • L1(黄金级):学校/医院周边,检测延迟 ≤80ms,置信度阈值 ≥0.92
  • L2(白银级):主干道交叉口,延迟 ≤150ms,阈值 ≥0.85
  • L3(青铜级):次干道,延迟 ≤300ms,阈值 ≥0.75

抢占式调度决策逻辑

def preemptive_schedule(task, current_load):
    # task: {id, slaclass, deadline_ms, gpu_mem_mb}
    if task["slaclass"] == "L1" and current_load > 0.7:
        evict_lowest_l3_task()  # 强制驱逐L3任务释放GPU显存
        return "GRANT_IMMEDIATE"
    return "QUEUE_NORMAL"

该函数在检测到L1任务抵达且GPU负载超70%时,触发L3任务的显存回收流程,确保高优先级推理零排队。deadline_ms用于硬实时校验,gpu_mem_mb决定驱逐粒度。

SLA等级 允许丢帧率 最大调度延迟 对应GPU切片占比
L1 80ms 45%
L2 150ms 35%
L3 300ms 20%
graph TD
    A[新任务入队] --> B{SLA等级判定}
    B -->|L1| C[检查GPU负载]
    B -->|L2/L3| D[加入对应优先级队列]
    C -->|>70%| E[驱逐最低L3任务]
    C -->|≤70%| F[立即分配资源]
    E --> F

4.3 全链路TraceID贯通的异常检测定位系统(OpenTelemetry集成)

核心集成模式

OpenTelemetry SDK 自动注入 trace_idspan_id,并通过 HTTP 头(traceparent)跨服务透传,确保全链路唯一标识可追溯。

数据同步机制

  • 所有微服务统一使用 OTLP/gRPC 协议上报遥测数据
  • 异常检测引擎订阅 otel-collectormetricstraces
  • 基于 trace_id 聚合跨服务 Span,构建调用拓扑

关键代码片段

from opentelemetry import trace
from opentelemetry.propagate import inject

def call_downstream(url: str):
    headers = {}
    inject(headers)  # 自动写入 traceparent、tracestate
    requests.get(url, headers=headers)  # 透传至下游

逻辑分析:inject() 将当前活跃 Span 的上下文序列化为 W3C Trace Context 格式;traceparent 包含 version-traceid-spanid-traceflags 四元组,是跨进程传播的核心载体。

异常判定规则表

指标类型 阈值条件 触发动作
http.status_code ≥500 关联同 trace_id 的所有 Span 并标记为异常链
duration > P99(10s) 启动 Flame Graph 分析
graph TD
    A[Service A] -->|traceparent| B[Service B]
    B -->|traceparent| C[Service C]
    C --> D{异常检测引擎}
    D --> E[聚合 trace_id]
    E --> F[定位慢 Span + 错误 Span]

4.4 自适应批处理窗口(Adaptive Batch Windowing)与吞吐-延迟帕累托最优调控

传统固定窗口在流量突增时易引发高延迟或低吞吐,自适应批处理窗口通过实时反馈动态调节 window_sizetrigger_threshold,逼近吞吐-延迟的帕累托前沿。

核心调控机制

  • 基于滑动窗口延迟百分位(P95
  • 每5秒采集指标,触发梯度下降式窗口缩放

动态窗口计算示例

# 当前窗口参数与自适应更新逻辑
current_window_ms = 200
latency_p95_ms = monitor.get_p95()  # 实时延迟观测值
throughput_tps = monitor.get_tps()   # 当前吞吐率

if latency_p95_ms > 120 and throughput_tps > 0.8 * peak_tps:
    current_window_ms = max(50, int(current_window_ms * 0.7))  # 降窗减延迟
elif throughput_tps < 0.5 * peak_tps:
    current_window_ms = min(500, int(current_window_ms * 1.3))  # 扩窗提吞吐

该逻辑以延迟为硬约束、吞吐为优化目标,在资源受限场景下实现帕累托改进;0.7/1.3 为经验衰减/增长因子,经A/B测试验证收敛稳定。

调控效果对比(典型负载下)

窗口策略 平均延迟 (ms) 吞吐 (KTPS) P95延迟超标率
固定 200ms 142 18.3 23%
自适应窗口 89 21.7 1.2%
graph TD
    A[实时指标采集] --> B{P95 ≤ 100ms?}
    B -->|否| C[收缩窗口]
    B -->|是| D{吞吐 ≥ 90%峰值?}
    D -->|否| E[适度扩张窗口]
    D -->|是| F[维持当前窗口]

第五章:架构演进方法论总结与行业迁移建议

核心方法论三角模型

架构演进不是线性升级,而是由业务驱动力、技术就绪度、组织成熟度构成的动态三角。某华东区域性银行在2022年启动核心系统云原生改造时,首先绘制三维度雷达图:业务侧明确“跨境支付T+0清算”为刚性需求(驱动力得分9.2/10);技术侧评估容器化率仅37%、服务网格无生产经验(就绪度5.1/10);组织侧发现SRE岗位空缺率达64%(成熟度4.8/10)。决策层据此将演进拆解为三阶段:先用Service Mesh Pilot项目补足技术短板,同步开展SRE能力认证计划,最后才切入核心账务模块重构。

行业迁移优先级矩阵

行业类型 推荐首迁系统 关键约束条件 典型落地周期
证券期货 行情分发中间件 时延 4–6个月
医疗健康 检查影像归档系统(PACS) HIPAA/GDPR合规审计链路完整 8–12个月
制造业 设备IoT接入平台 支持OPC UA/Modbus多协议混接 5–7个月
零售电商 订单履约引擎 需对接WMS/TMS/物流API超23个 3–5个月

某头部新能源车企在建设电池BMS数据中台时,依据该矩阵跳过传统ERP迁移,直接将车载边缘计算节点接入Kubernetes集群,通过eBPF实现毫秒级故障注入测试,使OTA升级回滚时间从47分钟压缩至11秒。

反模式清单与熔断机制

  • ❌ “大爆炸式替换”:某保险公司在2021年用6个月重写全部承保引擎,导致新旧系统并行期超18个月,理赔差错率上升3.7倍
  • ✅ 熔断实践:采用Chaos Engineering定义三级熔断阈值——当API P99延迟>800ms持续3分钟,自动降级至本地缓存;>2秒持续1分钟,触发流量切至灾备集群;>5秒立即熔断所有非核心链路
flowchart LR
    A[监控指标采集] --> B{P99延迟>800ms?}
    B -- 是 --> C[启用本地LRU缓存]
    B -- 否 --> D[正常路由]
    C --> E{持续3分钟?}
    E -- 是 --> F[切换至灾备集群]
    F --> G{延迟>2秒?}
    G -- 是 --> H[熔断非核心服务]

组织能力建设路线图

某省级广电集团在推进4K/8K内容分发架构升级时,建立“双轨制”团队:传统运维组负责CDN节点物理维护,新成立的Platform Team专注构建GitOps流水线。关键动作包括:每月强制20%运维工程师参与Feature Flag配置演练;将Prometheus告警规则库纳入Git版本管理;要求所有基础设施即代码(IaC)必须通过Terraform Validator静态检查。半年后,内容发布平均耗时从17分钟降至2.3分钟,配置错误率下降92%。

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

发表回复

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