Posted in

Go语言实现企业级远程投屏系统:3大核心模块拆解(信令/编解码/渲染)及性能压测实测数据

第一章:Go语言远程投屏系统架构全景与设计哲学

远程投屏系统在现代协同办公、远程教育和IoT运维中扮演着关键角色。Go语言凭借其轻量级协程、内置并发模型、静态编译能力及跨平台支持,天然适配低延迟、高并发、易部署的投屏场景。本系统摒弃传统C/S紧耦合设计,采用分层解耦、关注点分离的设计哲学:将采集、编码、传输、渲染、控制五大职责划分为独立可插拔模块,各模块通过标准化接口通信,避免隐式依赖。

核心架构分层

  • 采集层:基于golang.org/x/exp/shiny/screen(Linux/macOS)或github.com/moutend/go-w32(Windows)实现多平台屏幕捕获,支持区域裁剪与帧率动态调节;
  • 编码层:集成github.com/pion/webrtc/v3的VP8/VP9软编码流水线,兼顾CPU占用与压缩效率;启用--tags=avx2编译标志加速SIMD优化;
  • 传输层:采用WebRTC DataChannel承载控制指令(如鼠标坐标、键盘事件),MediaTrack流式推送视频帧;信令服务基于HTTP+JSON实现轻量握手;
  • 渲染层:客户端使用HTML5 <canvas> + requestAnimationFrame实现60fps平滑渲染,支持双缓冲防撕裂;
  • 控制层:服务端暴露RESTful API(如POST /control/mouse),接收JSON格式指令并广播至所有连接客户端。

关键设计决策

  • 零依赖部署:全部二进制静态链接,单文件即可运行:
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o remote-screen-server .
  • 自适应带宽协商:客户端启动时上报网络类型(navigator.connection.effectiveType),服务端动态调整H.264 profile与码率上限;
  • 安全边界隔离:所有输入事件经白名单校验(仅允许MouseMoveKeyDown等12类动作),拒绝任意代码执行。
模块 启动方式 进程模型 热重载支持
信令服务 内置HTTP Server 单进程
编码协程池 runtime.GOMAXPROCS(2) 多goroutine ✅(配置热更)
WebRTC连接 按需创建 每连接1 goroutine ❌(连接级隔离)

该架构拒绝“大而全”的中间件堆砌,以Go原生能力为基石,用组合代替继承,用接口代替实现,使系统既可嵌入树莓派等边缘设备,亦能横向扩展至千级并发节点。

第二章:信令模块深度实现与高并发治理

2.1 基于WebSocket+Protobuf的双向信令协议设计与Go泛型封装

为支撑高并发、低延迟的实时信令交互,协议采用 WebSocket 作为传输层,Protobuf 作为序列化格式,并通过 Go 泛型实现类型安全的编解码抽象。

核心设计原则

  • 信令帧结构统一为 Header + Payload
  • 所有消息类型注册至 proto.Register(),确保反射可用;
  • 使用 type Signal[T any] struct { Type string; Data T; Timestamp int64 } 实现泛型信令容器。

泛型编码器示例

func Encode[T proto.Message](msg T) ([]byte, error) {
  buf := &bytes.Buffer{}
  if err := binary.Write(buf, binary.BigEndian, uint32(0)); err != nil {
    return nil, err // 预留4字节长度头(后续填充)
  }
  data, err := proto.Marshal(msg)
  if err != nil {
    return nil, err
  }
  // 写入真实长度并拼接 payload
  binary.BigEndian.PutUint32(buf.Bytes(), uint32(len(data)))
  buf.Write(data)
  return buf.Bytes(), nil
}

逻辑说明:先预留长度字段位置,再序列化 Protobuf 消息体,最后回填长度值。T proto.Message 约束确保仅接受合法 Protobuf 结构体,避免运行时 panic。

协议帧格式对照表

字段 长度(字节) 说明
Length 4 Payload 总长度(大端)
Type 1 信令类型枚举(如 0x01=JOIN)
Version 1 协议版本(当前 v1)
Payload N Protobuf 序列化二进制数据
graph TD
  A[Client Send Signal] --> B[Encode[T] 泛型序列化]
  B --> C[WebSocket Write]
  C --> D[Server Read Raw Bytes]
  D --> E[Decode[T] 反序列化]
  E --> F[Type-Safe Dispatch]

2.2 分布式信令网关的连接管理与心跳保活实战(含goroutine泄漏防护)

连接生命周期管理

信令网关需维护海量长连接(SCTP/HTTP/QUIC),采用连接池+状态机双控模型:Idle → Handshaking → Active → Closing → Closed

心跳保活机制

func startHeartbeat(conn net.Conn, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop() // 防止goroutine泄漏的关键!

    for {
        select {
        case <-ticker.C:
            if err := sendPing(conn); err != nil {
                log.Warn("heartbeat failed", "err", err)
                return // 主动退出,避免泄漏
            }
        case <-conn.CloseNotify(): // 连接关闭信号
            return
        }
    }
}

逻辑分析:defer ticker.Stop() 确保资源释放;select 中监听连接关闭事件,避免 goroutine 在连接断开后持续运行。interval 建议设为 30s(RFC 5060 推荐值)。

goroutine 泄漏防护策略

  • ✅ 使用 context.WithCancel 统一控制生命周期
  • ✅ 启动前注册 sync.WaitGroupdefer wg.Done()
  • ❌ 禁止无超时的 time.Sleep 或裸 for {}
风险点 防护手段
心跳协程未退出 conn.CloseNotify() 监听
连接复用失败 连接池 MaxIdleTime 限流
并发启动失控 semaphore.Acquire(1) 控制并发数

2.3 多端协同状态同步机制:基于CRDT的端侧状态收敛实现

数据同步机制

传统最终一致性依赖中心时钟,易引发冲突。CRDT(Conflict-Free Replicated Data Type)通过数学可证明的合并函数,实现无协调的多端并发更新。

LWW-Element-Set 实现示例

class LwwElementSet<T> {
  private addTimes = new Map<T, number>(); // 元素 → 最后添加时间戳
  private removeTimes = new Map<T, number>(); // 元素 → 最后删除时间戳

  add(element: T, timestamp: number): void {
    if (!this.removeTimes.has(element) || timestamp > this.removeTimes.get(element)!) {
      this.addTimes.set(element, timestamp);
    }
  }

  remove(element: T, timestamp: number): void {
    if (!this.addTimes.has(element) || timestamp > this.addTimes.get(element)!) {
      this.removeTimes.set(element, timestamp);
    }
  }

  contains(element: T): boolean {
    const addT = this.addTimes.get(element) ?? -1;
    const removeT = this.removeTimes.get(element) ?? -1;
    return addT > removeT; // LWW 冲突解决:取时间戳更大者
  }

  merge(other: LwwElementSet<T>): LwwElementSet<T> {
    const merged = new LwwElementSet<T>();
    // 合并 addTimes:取各元素最大时间戳
    for (const [e, t] of this.addTimes) merged.addTimes.set(e, Math.max(t, other.addTimes.get(e) ?? -1));
    for (const [e, t] of other.addTimes) merged.addTimes.set(e, Math.max(t, merged.addTimes.get(e) ?? -1));
    // 同理合并 removeTimes
    for (const [e, t] of this.removeTimes) merged.removeTimes.set(e, Math.max(t, other.removeTimes.get(e) ?? -1));
    for (const [e, t] of other.removeTimes) merged.removeTimes.set(e, Math.max(t, merged.removeTimes.get(e) ?? -1));
    return merged;
  }
}

逻辑分析LwwElementSet 是一种可交换、结合、幂等的 CRDT。add/remove 操作仅写本地,merge 函数保证任意顺序合并结果一致。关键参数 timestamp 需由高精度单调时钟(如 performance.now() + 设备ID哈希)生成,避免时钟漂移导致误判。

CRDT 类型对比

类型 支持操作 网络开销 冲突解决方式
LWW-Element-Set Add/Remove 时间戳最大值
OR-Set Add/Remove 唯一标签(tag)集合
G-Counter Increment 极低 各副本计数求和

状态收敛流程

graph TD
  A[设备A本地变更] --> B[生成带时间戳操作]
  C[设备B本地变更] --> B
  B --> D[广播操作至P2P网络]
  D --> E[各端接收并merge本地CRDT实例]
  E --> F[最终所有端状态数学等价]

2.4 信令安全加固:mTLS双向认证与JWT动态权限鉴权集成

在信令服务中,仅依赖单向 TLS 已无法抵御中间人劫持与仿冒节点接入。mTLS 强制客户端与服务端双向证书校验,结合 JWT 携带的细粒度权限声明,实现连接建立与操作授权的双重防护。

mTLS 连接初始化流程

graph TD
    A[客户端加载证书+私钥] --> B[发起TLS握手]
    B --> C[服务端验证客户端证书链]
    C --> D[服务端返回自身证书]
    D --> E[客户端验证服务端证书]
    E --> F[协商密钥,建立加密通道]

JWT 权限注入示例(Go)

// 生成含信令权限的JWT
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
    "sub": "client-789", 
    "iss": "auth-service",
    "exp": time.Now().Add(10 * time.Minute).Unix(),
    "permissions": []string{"join:room:123", "publish:audio"}, // 动态权限列表
})
signedToken, _ := token.SignedString(privateKey) // 使用ECDSA私钥签名

逻辑分析:permissions 字段为字符串切片,由授权中心基于用户角色实时生成;exp 严格限制令牌生命周期,避免长期凭证泄露风险;sub 与设备指纹绑定,防止令牌横向越权。

安全策略对比表

方案 连接认证强度 权限更新时效 证书轮换成本
单向 TLS 低(仅服务端) 不支持
mTLS + 静态ACL 分钟级 高(需重签)
mTLS + JWT动态鉴权 秒级

2.5 信令链路可观测性:OpenTelemetry埋点与实时拓扑追踪

在核心信令网元(如S-CSCF、IBCF)中,需在SIP消息处理关键路径注入轻量级OpenTelemetry SDK埋点:

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

该代码初始化全局TracerProvider,配置HTTP协议的OTLP导出器指向采集器;BatchSpanProcessor保障高吞吐下异步批量上报,避免信令延迟抖动。endpoint需与K8s Service对齐,确保跨AZ链路可达。

关键埋点位置

  • SIP INVITE/ACK/BYE 处理入口
  • Diameter Cx/Dx接口调用前后
  • TLS握手完成回调钩子

实时拓扑生成逻辑

graph TD
    A[SIP Proxy] -->|trace_id+span_id| B(OTel Collector)
    B --> C[Jaeger UI]
    B --> D[Prometheus + Grafana]
    C --> E[自动构建服务依赖图]
字段 作用 示例值
net.peer.name 对端信令节点主机名 ibcf-prod-02.svc.cluster.local
sip.method SIP方法标识 INVITE
http.status_code 下游响应状态 200

第三章:音视频编解码模块的Go原生优化实践

3.1 硬件加速抽象层设计:VAAPI/Videotoolbox/NVENC统一接口封装

现代视频处理需屏蔽底层硬件差异。统一接口的核心是抽象编码上下文与操作语义:

接口核心抽象

  • EncoderDevice: 封装设备初始化(VA_DISPLAY、CVPixelBufferPoolRef、CUcontext)
  • EncoderSession: 统一生命周期管理(create/flush/destroy)
  • EncodeTask: 跨平台帧描述结构(含YUV指针、PTS、metadata)

关键同步机制

struct EncodeTask {
    void* raw_surface;      // VASurfaceID / CVPixelBufferRef / NV_ENC_PIC_PARAMS::inputBuffer
    uint64_t pts;           // 统一时基(microseconds)
    bool sync_required;     // true → 阻塞等待GPU完成
};

raw_surface 类型由运行时 device_type(VA, VT, NV)动态解析;sync_required 控制 vaSyncSurface/CVOpenGLTextureCacheFlush/nvEncLockBitstream 调用路径。

性能特征对比

后端 初始化延迟 内存拷贝开销 多实例支持
VAAPI 低(DMA-BUF)
VideoToolbox 零(IOSurface) 有限
NVENC 中(P2P memcpy) 最优
graph TD
    A[EncodeTask] --> B{device_type}
    B -->|VA| C[VAAPI: vaCreateSurfaces]
    B -->|VT| D[VideoToolbox: VTCompressionSessionCreate]
    B -->|NV| E[NVENC: nvEncCreateEncoder]

3.2 Go协程池驱动的帧级并行编码流水线(H.264/H.265/AV1)

传统单goroutine串行编码严重受限于B帧依赖与CABAC上下文同步。本方案采用固定大小协程池解耦帧调度与编码执行,实现I/P/B帧的乱序提交与有序输出。

核心调度模型

type FrameEncoder struct {
    pool *ants.Pool
    out  chan *EncodedFrame // 保序输出通道
}
// 每帧独立携带seqNum、pts、依赖帧ID,由pool.Submit异步执行

逻辑分析:ants.Pool 提供复用goroutine能力,避免高频启停开销;seqNum用于后续归并排序,pts保障解码时间轴正确性;依赖帧ID触发前置帧等待机制。

编码器兼容性对比

编码标准 帧级并行支持 依赖粒度 协程池推荐大小
H.264 需Slice级拆分 宏块行 8–12
HEVC 内建Tile/WPP CTU行 16–24
AV1 Superblock级 SB单元 20–32

数据同步机制

  • 使用sync.Map缓存已编码帧元数据
  • atomic.Value原子更新GOP头部状态
  • 输出阶段按seqNum归并排序,确保PTS单调递增

3.3 低延迟自适应码率控制算法:基于网络QoE反馈的ABR策略Go实现

核心设计思想

以实时网络吞吐量、缓冲区水位、卡顿事件为三维QoE信号,动态加权决策下一码率档位,兼顾平滑性与响应性。

Go核心结构体

type ABRController struct {
    ThroughputEstimator *ewma.Throughput // 指数加权移动平均吞吐量估算
    BufferLevelMs       int               // 当前缓冲时长(毫秒)
    StallCount          uint            // 近10s内卡顿次数
    BitrateCandidates   []int           // 可选码率列表(bps),升序
}

ThroughputEstimator采用0.85衰减因子,平衡突变敏感性与噪声抑制;BufferLevelMs驱动保守降码率(StallCount用于紧急回退(≥2次/10s强制切最低档)。

决策权重表

QoE指标 权重 触发条件
吞吐量波动率 0.4 >25%环比变化
缓冲水位 0.35 3000ms
卡顿频次 0.25 ≥1次/10s

策略执行流程

graph TD
    A[接收新分片元数据] --> B{计算QoE三维度}
    B --> C[加权归一化评分]
    C --> D[候选码率排序]
    D --> E[选择最高安全档位]
    E --> F[输出ABR决策]

第四章:跨平台渲染引擎与端侧性能调优

4.1 基于OpenGL ES/WebGL/Vulkan的统一渲染上下文抽象与Go绑定

为屏蔽底层图形API差异,设计RendererContext接口抽象:

  • 统一生命周期(Init()/Destroy()
  • 封装资源创建(NewShader()/NewBuffer()
  • 隐藏平台特异性(EGL/WGL/WSI)

核心绑定策略

使用cgo桥接C/C++原生层,Go侧仅暴露安全、零拷贝的句柄(uintptr):

// Cgo导出函数示例(简化)
/*
#include "glad/glad.h"
#include "vk_context.h"
*/
import "C"

func (r *VulkanContext) CreateBuffer(size uint32) uintptr {
    var buf C.VkBuffer
    C.vkCreateBuffer(r.device, &C.VkBufferCreateInfo{...}, nil, &buf)
    return uintptr(unsafe.Pointer(&buf))
}

逻辑分析:uintptr避免GC干扰,unsafe.Pointer转译Vulkan句柄;参数size直接映射GPU内存请求量,由驱动完成对齐与分配。

API能力对比

特性 OpenGL ES WebGL Vulkan
多线程命令录制
显式同步控制 ✅(GLsync) ⚠️(WebGL2有限) ✅(VkSemaphore)
graph TD
    A[Go应用层] -->|RendererContext接口| B[抽象适配层]
    B --> C[OpenGL ES实现]
    B --> D[WebGL实现]
    B --> E[Vulkan实现]

4.2 零拷贝帧传输:共享内存+DMA-BUF在Linux嵌入式端的落地实践

传统视频帧传输依赖多次CPU拷贝(用户态→内核态→设备),在资源受限的嵌入式平台易成性能瓶颈。零拷贝方案通过DMA-BUF统一管理物理连续缓冲区,结合ioncma分配器,在V4L2 capture、GPU渲染与NIC发送间建立跨驱动共享通道。

核心数据流

// 获取DMA-BUF fd并映射到用户空间(以V4L2为例)
struct v4l2_buffer buf;
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
ioctl(fd, VIDIOC_DQBUF, &buf); // 返回含dma_buf_fd的plane[0].m.fd
int dmafd = buf.m.planes[0].m.fd; // 内核自动关联同一DMA-BUF对象

dma_buf_fd由内核dma_buf_export()生成,持有struct dma_buf *引用;用户态调用dma_buf_get(dmafd)可复用同一底层缓冲区,避免重分配和拷贝。

同步机制关键点

  • 使用sync_file + fence实现生产者/消费者时序控制
  • dma_buf_begin_cpu_access() / end_cpu_access()确保cache一致性(ARM64需dmb sy
组件 作用
dma-buf 跨子系统缓冲区句柄抽象
dma-heap 统一内存分配接口(替代ION)
dma-fence 异步操作完成信号,支持timeline
graph TD
    A[V4L2 Capture] -->|export dma_buf| B(DMA-BUF Core)
    B --> C[GPU Render]
    B --> D[Network TX]
    C -->|fence signal| B
    D -->|fence wait| B

4.3 iOS/macOS Metal渲染管线的CGO桥接与内存生命周期管理

Metal 渲染需在 Go(CGO)与 Objective-C++ 间安全传递 MTLDeviceMTLCommandQueue 等原生对象指针,同时确保其生命周期不早于 Go 对象释放。

CGO 指针桥接规范

  • 使用 C.CString/C.free 管理字符串;
  • Metal 对象指针通过 uintptr 转换,在 Go 中封装为 unsafe.Pointer
  • 禁止直接 free() Objective-C 对象,必须调用 objc_release()

内存生命周期关键约束

阶段 Go 侧责任 Objective-C++ 侧责任
创建 保存 uintptr 并注册 runtime.SetFinalizer retain 后返回裸指针
使用中 确保 runtime.KeepAlive() 防止过早回收
销毁 Finalizer 中调用 C.objc_release 不再持有引用
// metal_bridge.h(C 头)
void objc_release_mtl_device(uintptr_t device_ptr);
// bridge.go
func (r *Renderer) destroyDevice() {
    C.objc_release_mtl_device(uintptr(unsafe.Pointer(r.device)))
    r.device = nil
}

objc_release_mtl_device 在 Objective-C++ 中执行 [(__bridge id)device release],确保 ARC 正确降级引用计数。uintptr 转换绕过 Go 类型系统,但由 finalizer 和显式销毁双重保障生命周期安全。

4.4 渲染卡顿根因分析:VSync对齐、掉帧检测与GPU时间戳采集

VSync对齐机制

Android系统通过Choreographer调度渲染帧,严格对齐硬件VSync信号。若doFrame()执行超时(>16.67ms),将跳过当前VSync周期,直接进入下一帧——这是掉帧的物理起点。

掉帧检测实践

// 注册帧回调,捕获实际渲染延迟
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
    @Override
    public void doFrame(long frameTimeNanos) {
        long vsyncTime = frameTimeNanos; // 当前VSync触发时刻(纳秒)
        long renderStart = System.nanoTime();
        // ... 执行UI绘制 ...
        long renderEnd = System.nanoTime();
        long gpuDuration = getGpuTimestamp(); // 见下文采集逻辑
        if (renderEnd - vsyncTime > 16_666_666L) { // 超过1帧时长(μs)
            Log.w("Jank", "Dropped frame at VSync: " + vsyncTime);
        }
        Choreographer.getInstance().postFrameCallback(this);
    }
});

该回调在VSync信号到达后立即触发,frameTimeNanos是系统级高精度VSync时间戳(非System.nanoTime()),用于计算相对延迟;超时阈值16_666_666L对应16.67ms,是60Hz刷新率下的硬性边界。

GPU时间戳采集

需通过EGL_EXT_timestamp扩展获取GPU管线真实耗时: 时间点 获取方式 说明
GPU提交时刻 eglGetTimestampEXT(display) 渲染命令入队GPU时戳
GPU完成时刻 glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0) + glGetSynciv() 需配合同步对象轮询

卡顿归因流程

graph TD
    A[VSync信号到达] --> B[Choreographer触发doFrame]
    B --> C{CPU渲染≤16.67ms?}
    C -->|否| D[掉帧计数+1]
    C -->|是| E[GPU命令提交]
    E --> F[采集GPU起止时间戳]
    F --> G[计算GPU耗时>16.67ms?]
    G -->|是| H[判定GPU瓶颈]

第五章:压测结论、生产问题复盘与演进路线图

压测核心指标达成情况

在针对订单履约服务集群开展的全链路压测中,我们以 12000 TPS 为目标持续施压 30 分钟。实际峰值达 11860 TPS,P99 响应时间稳定在 342ms(SLA 要求 ≤ 400ms),错误率 0.017%(阈值为 0.1%)。数据库 CPU 使用率在主库达到 92%,触发自动熔断策略;而 Redis 集群因热点 Key(order:status:{orderId})未做逻辑分片,单节点负载超 85%,出现 3 次连接超时抖动。以下为关键组件性能对比:

组件 压测前基线 压测峰值 是否达标 主要瓶颈点
订单写入服务 3200 TPS 11860 线程池耗尽(max=200)
MySQL 主库 65% CPU 92% 大字段 extra_info JSON 解析开销高
Kafka 消费组 Lag Lag=1280 消费者并发度不足(仅 8 个 partition)

生产环境真实故障复盘(2024-03-17 10:23)

当日早高峰突发订单创建失败率跃升至 8.3%,持续 11 分钟。根因定位为支付回调服务在处理微信异步通知时,未对 out_trade_no 字段做长度校验(实测最长达 48 位),导致 MySQL VARCHAR(32) 字段截断后主键冲突,引发事务回滚并阻塞整个回调队列。日志中高频出现 Duplicate entry 'xxx...' for key 'PRIMARY' 错误。修复方案已上线:增加前置校验 + 改用 CHAR(64) 存储,并同步补充单元测试覆盖边界值(32/48/64 字符输入)。

架构演进优先级矩阵

采用 RICE 评分模型(Reach, Impact, Confidence, Effort)对 7 项改进项排序,Top3 如下:

graph LR
    A[订单服务无状态化] --> B[剥离本地缓存 Guava Cache]
    B --> C[接入统一分布式缓存层 Redis Cluster]
    C --> D[按商户 ID 哈希分片,消除热点]
    D --> E[灰度发布验证分片路由正确性]

关键落地节奏

  • Q2 内完成:MySQL 大字段 extra_info 拆分为独立宽表 order_ext,并通过应用层双写+数据一致性校验脚本保障迁移安全;
  • Q3 上线:Kafka 消费端重构为 Spring Kafka + BatchListener + 自定义重试策略(指数退避 + DLQ 分离),partition 数由 8 扩容至 24;
  • 长期机制:建立压测黄金指标看板(含 DB 连接池活跃数、GC Pause 时间、Netty EventLoop 队列积压),接入 Prometheus + Grafana 实现阈值告警联动预案执行。

团队协作机制升级

推行“压测即上线”流程:每次发版前必须运行对应场景的自动化压测脚本(JMeter + InfluxDB 数据采集),结果未达标则 CI 流水线自动阻断。SRE 团队已将 12 类典型异常模式(如 TooManyConnections, RedisTimeoutException)编排为自愈剧本,平均故障恢复时间从 8.7 分钟降至 1.4 分钟。

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

发表回复

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