Posted in

游戏服务器开发者的Go迁移实录:从Erlang到Go的连接数承载能力对比(百万级压测数据)

第一章:什么人适合学习go语言

Go 语言以其简洁的语法、卓越的并发模型和开箱即用的工具链,成为现代云原生开发的核心选择之一。它并非为所有人而生,但对以下几类开发者具有显著适配性。

后端服务开发者

长期使用 Python、Java 或 Node.js 构建高并发 Web 服务的工程师,常面临性能调优复杂、部署包体积大、启动慢等问题。Go 提供静态编译、单二进制分发能力,可一键构建零依赖可执行文件:

# 编译生成独立可执行文件(默认针对当前系统架构)
go build -o myapi main.go

# 跨平台编译 Linux 二进制(无需 Linux 环境)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myapi-linux main.go

该特性大幅简化容器化部署流程,配合 DockerfileFROM scratch 基础镜像,可将镜像体积压缩至 5MB 以内。

云原生与基础设施工程师

Kubernetes、Docker、Terraform 等主流基础设施项目均采用 Go 开发。熟悉 Go 可直接阅读源码、编写 Operator、定制 CRD 控制器或开发 CLI 工具。其标准库 net/httpencoding/jsonflag 模块开箱即用,无需引入第三方依赖即可快速搭建 REST API 或配置驱动型工具。

初学者与转行者

Go 语言刻意限制特性数量(无类继承、无泛型(旧版)、无异常机制),强制使用显式错误处理(if err != nil)和组合优于继承的设计哲学。这种“少即是多”的设计降低了认知负荷,避免初学者陷入过度抽象陷阱。对比 Java 的 Spring Boot “约定大于配置”,Go 更强调“代码即文档”,函数签名清晰、包结构扁平、go fmt 统一风格,利于建立扎实的工程直觉。

对比维度 Go 语言 典型替代语言(如 Python/Java)
编译产物 静态链接单二进制文件 需运行时环境 + 依赖包管理
并发模型 goroutine + channel 线程/协程 + 锁/回调/async-await
学习曲线起点 语法 2 小时可写完整 API 依赖生态理解周期长

系统管理员、DevOps 工程师及嵌入式领域中需轻量 CLI 工具的实践者,也普遍受益于 Go 的快速迭代与跨平台能力。

第二章:高并发系统开发者转型Go的实战路径

2.1 Erlang与Go并发模型的本质差异与迁移映射

核心范式对比

Erlang 基于 Actor 模型:轻量进程(pid)完全隔离,仅通过异步消息传递通信;Go 采用 CSP(Communicating Sequential Processes),goroutine 共享内存但通过 channel 同步通信。

并发单元与生命周期

  • Erlang 进程:毫秒级启动/销毁,无栈大小限制,崩溃不波及其他进程
  • Go goroutine:纳秒级调度,初始栈 2KB,由 runtime 自动扩容,panic 默认终止当前 goroutine

消息传递语义映射

特性 Erlang Go
发送操作 Pid ! Msg(异步、无阻塞) ch <- msg(可能阻塞,取决于缓冲)
接收模式 receive ... end(模式匹配+超时) select { case v := <-ch: ... }
% Erlang: mailbox-based pattern matching
loop(State) ->
    receive
        {inc, N} -> loop(State + N);
        {get, From} -> From ! {value, State}, loop(State);
        stop -> ok
    after 5000 -> timeout
    end.

该函数体现 Erlang 的“被动接收”与结构化消息匹配:{inc, N}{get, From} 是原子消息元组,From ! {...} 实现双向通信;after 提供超时保护,避免永久挂起。

// Go: channel-driven active polling
func loop(ch <-chan Msg, replyCh chan<- Result, state int) {
    for {
        select {
        case msg := <-ch:
            switch m := msg.(type) {
            case IncMsg:
                state += m.N
            case GetMsg:
                replyCh <- Result{Value: state}
            case StopMsg:
                return
            }
        case <-time.After(5 * time.Second):
            return // timeout exit
        }
    }
}

Go 版本需显式 select 轮询 channel,类型断言替代模式匹配;replyCh 单独通道实现响应,体现 CSP 的“同步握手”本质——发送方必须等待接收方就绪(或缓冲可用),而 Erlang 发送始终成功。

graph TD A[Erlang Process] –>|! Msg| B[Mailbox Queue] B –> C{receive pattern match} C –> D[Handle inc] C –> E[Handle get] F[Go Goroutine] –>|ch H[select case] H –> I[switch type] H –> J[timeout]

2.2 百万级连接场景下Go netpoll机制的底层实践调优

在百万级并发连接下,Go runtime 的 netpoll(基于 epoll/kqueue/iocp)成为性能瓶颈关键点。需从系统层与 Go 运行时双维度协同调优。

关键调优方向

  • 调整 GOMAXPROCS 匹配 NUMA 节点数,避免跨节点调度开销
  • 禁用 GODEBUG=netdns=go 防止阻塞式 DNS 查询拖慢 netpoll 循环
  • 通过 runtime.LockOSThread() 绑定关键 goroutine 到专用 M,减少上下文切换

epoll 参数优化示例

// 修改 netFD 创建逻辑(需 patch syscall 或使用替代 net 库如 gnet)
fd, _ := unix.Socket(unix.AF_INET, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, unix.IPPROTO_TCP)
_ = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEPORT, 1) // 启用 SO_REUSEPORT 分载

SO_REUSEPORT 允许多进程/多线程监听同一端口,内核按流哈希分发连接,缓解单个 epoll 实例的事件堆积压力。

性能对比(单机 100w 连接,QPS 峰值)

配置项 QPS 平均延迟 epoll_wait 平均耗时
默认配置 86k 42ms 1.8ms
SO_REUSEPORT + GOMAXPROCS=32 135k 21ms 0.3ms

2.3 从OTP行为模式到Go Worker Pool的架构重构案例

某实时日志聚合服务最初基于 Erlang OTP 的 gen_server 实现,每个连接独占一个进程,峰值并发下内存激增且调度开销显著。

核心瓶颈识别

  • 进程创建/销毁频繁(平均生命周期
  • 消息邮箱堆积导致 GC 压力陡增
  • 无统一资源配额机制,突发流量易触发 OOM

Go Worker Pool 设计要点

  • 固定容量 goroutine 池(默认 50,可热更新)
  • 任务队列采用无锁环形缓冲区(ringbuf.Channel
  • 超时任务自动丢弃并上报 metrics
// 初始化工作池(带健康检查与优雅关闭)
pool := worker.NewPool(
    worker.WithWorkers(50),
    worker.WithQueueSize(1024),
    worker.WithTimeout(5 * time.Second), // 单任务最大执行时长
)

该初始化配置将并发控制在确定性范围内;WithTimeout 防止长尾任务阻塞 worker,保障整体吞吐稳定性。

维度 OTP 原方案 Go Worker Pool
内存占用 ~3.2MB/连接 ~128KB/worker
P99 延迟 420ms 86ms
扩容响应时间 > 90s(VM重启)
graph TD
    A[HTTP Handler] --> B{Task Queue}
    B --> C[Worker-1]
    B --> D[Worker-2]
    B --> E[...]
    C --> F[Parse & Enrich]
    D --> F
    E --> F
    F --> G[Async Kafka Producer]

2.4 连接数压测数据驱动的GC策略定制(含pprof火焰图分析)

在高并发连接场景下,GC频次与堆对象生命周期强相关。我们基于 go tool pprof 采集压测期间的 CPU 和 heap profile:

# 压测中持续采样(每30s抓取一次堆快照)
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" > heap_$(date +%s).txt

该命令触发 Go runtime 的堆快照,debug=1 返回人类可读的统计摘要,便于快速识别大对象来源。

关键指标提取逻辑

  • 解析 heap_*.txtinuse_spaceobjects 增长斜率;
  • 关联连接数 QPS 曲线,定位 GC 触发拐点(如:连接数达 8k 时 gc pause 从 150μs 跃升至 1.2ms)。

GC 参数动态调优依据

指标 压测值(8k 连接) 优化动作
GOGC 默认100 降至65,减少触发频次
GOMEMLIMIT 未设置 设为 3.2GiB(80% 容器内存)
// 启动时按压测基线预设(需在 main.init 中早于 runtime.GC 调用)
import "runtime/debug"
func init() {
    debug.SetGCPercent(65)                 // 降低触发阈值,避免突增分配
    debug.SetMemoryLimit(3_221_225_472)    // ≈3.2GiB,配合 cgroup 限界
}

SetGCPercent(65) 表示当新分配堆内存达上次 GC 后存活堆的65%时触发GC,相比默认100%,更早回收短生命周期连接对象(如 net.Conn 附属 buffer);SetMemoryLimit 防止 OOM Kill,强制 runtime 在逼近上限前激进回收。

pprof 火焰图归因路径

graph TD
    A[HTTP handler] --> B[json.Unmarshal]
    B --> C[[]byte alloc]
    C --> D[large temp slice]
    D --> E[GC pressure]

2.5 热更新与平滑重启:基于文件监听+原子切换的Go服务演进方案

传统重启导致请求中断,而优雅重启需兼顾连接保持与配置/二进制无缝切换。核心路径为:监听变更 → 预加载新实例 → 原子切换监听句柄 → 安全退出旧进程。

文件监听与触发机制

使用 fsnotify 监控配置文件或新二进制哈希变化:

watcher, _ := fsnotify.NewWatcher()
watcher.Add("config.yaml")
for {
    select {
    case event := <-watcher.Events:
        if event.Op&fsnotify.Write == fsnotify.Write {
            reloadConfig() // 触发热重载流程
        }
    }
}

event.Op&fsnotify.Write 精确过滤写入事件,避免 chmod 等干扰;reloadConfig() 应为幂等函数,支持并发安全。

原子切换关键步骤

步骤 操作 安全性保障
1 新进程绑定相同端口(SO_REUSEPORT) 内核负载分发,避免端口冲突
2 旧进程停止接收新连接(ln.Close() 已建立连接继续处理
3 等待活跃连接超时或主动关闭 http.Server.Shutdown() 控制 grace period

进程生命周期协同

graph TD
    A[主进程启动] --> B[启动监听器]
    B --> C[fork新进程并传递listener FD]
    C --> D[新进程接管连接]
    D --> E[旧进程Shutdown等待完成]

第三章:云原生基础设施工程师的Go能力跃迁

3.1 Kubernetes Operator开发中的Go类型系统与Clientset深度应用

Operator的核心在于将领域逻辑嵌入Kubernetes原生API,而Go的强类型系统与client-goClientset共同构成这一能力的基石。

类型安全的CRD交互

定义CustomResource时,必须实现runtime.Object接口,并通过Scheme注册。Clientset据此生成类型化客户端:

// myappv1.MyApp 是自定义资源结构体,已注册到Scheme
client := myappv1client.NewForConfigOrDie(cfg)
list, err := client.MyApps("default").List(ctx, metav1.ListOptions{})
// 参数说明:
// - cfg:已配置Bearer Token与API Server地址的rest.Config
// - "default":命名空间,类型安全校验在编译期完成
// - ListOptions:支持labelSelector、fieldSelector等原生过滤能力

Clientset分层抽象对比

层级 用途 类型安全性 典型场景
DynamicClient 通用资源操作(无结构体) ❌ 运行时 跨版本适配、调试工具
Typed Clientset CRD/内置资源类型化访问 ✅ 编译期 Operator核心业务逻辑

数据同步机制

Informer结合SharedIndexInformer提供事件驱动的本地缓存,其AddEventHandler接收cache.ResourceEventHandler,所有回调参数均经类型断言保障安全。

3.2 eBPF + Go实现网络层可观测性增强(XDP程序联动示例)

核心联动架构

XDP程序在网卡驱动层高速过滤/标记数据包,Go用户态服务通过libbpf-go读取perf event ring buffer,实时聚合统计并暴露Prometheus指标。

数据同步机制

// 初始化perf event reader,绑定到XDP程序的map
reader, err := perf.NewReader(objs.XdpEvents, 1024*1024)
if err != nil {
    log.Fatal("failed to create perf reader:", err)
}
  • objs.XdpEvents:eBPF中定义的BPF_MAP_TYPE_PERF_EVENT_ARRAY,用于从内核向用户态推送事件
  • 1024*1024:环形缓冲区大小(字节),需与eBPF侧bpf_perf_event_output()预留空间对齐

事件结构定义(eBPF侧)

字段 类型 说明
timestamp __u64 纳秒级时间戳
protocol __u8 IP协议号(如6=TCP, 17=UDP)
packet_len __u16 原始包长(含L2头)

处理流程

graph TD
    A[XDP_PASS/XDP_DROP] --> B[bpf_perf_event_output]
    B --> C[Perf Ring Buffer]
    C --> D[Go perf.NewReader]
    D --> E[反序列化为Event struct]
    E --> F[指标更新 + 日志采样]

3.3 多租户资源隔离:Go中cgroups v2与namespace的协同控制实践

在Kubernetes多租户场景下,单节点需保障租户间CPU、内存硬隔离。cgroups v2 提供统一层级(/sys/fs/cgroup/tenant-a)与memory.maxcpu.weight等原子接口,配合pid, user, mnt namespace实现进程视图与资源配额双重隔离。

核心协同机制

  • Namespace 划分进程/用户/挂载视图,避免PID冲突与路径越权
  • cgroups v2 控制实际资源消耗上限,无v1的子系统分裂问题
  • Go通过github.com/containerd/cgroups/v3库安全操作BPF-based控制器

创建受限租户cgroup示例

// 创建tenant-b的v2 cgroup并设内存上限512MB
mgr, _ := cgroups.New(cgroups.V2, "/tenant-b", &cgroups.Spec{
    Memory: &cgroups.Memory{Max: uint64(512 * 1024 * 1024)},
    CPU:    &cgroups.CPU{Weight: uint16(50)}, // 相对权重,基准为100
})
defer mgr.Close()

逻辑说明:cgroups.New(V2, ...)自动挂载cgroup2文件系统(若未挂载),Memory.Max启用内存硬限制(OOM优先杀本组进程),CPU.Weight在CPU争用时按比例分配时间片,不设max则不限制峰值。

隔离效果对比表

维度 仅Namespace 仅cgroups v1 Namespace + cgroups v2
内存硬限 ⚠️(需memory controller启用) ✅(memory.max直设)
CPU公平调度 ⚠️(cpu.shares仅软限) ✅(cpu.weight+cpu.max
文件系统隔离 ✅(mnt ns) ✅(mnt ns + cgroup path绑定)
graph TD
    A[租户Pod启动] --> B[clone(CLONE_NEWPID \| CLONE_NEWUSER)]
    B --> C[setns to tenant-specific mount ns]
    C --> D[cgroups.New V2 /tenant-x]
    D --> E[Apply memory.max & cpu.weight]
    E --> F[execve in isolated context]

第四章:后端业务架构师重构微服务的技术决策依据

4.1 Go模块化分层设计:DDD边界划分与wire依赖注入实战

DDD强调限界上下文(Bounded Context)驱动的模块切分,Go中天然通过包(package)实现物理边界。internal/ 下按领域分层:domain(实体、值对象、领域服务)、application(用例、DTO、端口接口)、infrastructure(数据库、HTTP、第三方适配器)。

依赖方向约束

  • domain 无外部依赖
  • application 仅依赖 domain
  • infrastructure 可依赖 applicationdomain

wire 注入图示意

graph TD
    A[cmd/main.go] --> B[wire.NewApp]
    B --> C[application.NewOrderService]
    C --> D[domain.NewOrder]
    C --> E[infrastructure.NewOrderRepository]

wire 注入代码示例

// wire.go
func InitializeApp() *App {
    panic(wire.Build(
        NewApp,
        application.NewOrderService,
        infrastructure.NewOrderRepository,
        database.NewGORMClient,
    ))
}

wire.Build 声明依赖拓扑;NewApp 是构造函数,接收 *application.OrderService 等参数;wire 在编译期生成 wire_gen.go,零反射、类型安全。

层级 职责 典型包路径
domain 业务核心逻辑 internal/domain/order
application 用例编排与端口定义 internal/application/order
infrastructure 具体实现与外部交互 internal/infrastructure/persistence

4.2 跨语言服务互通:gRPC-Go与Protobuf Schema治理的落地陷阱

Schema版本漂移的隐性风险

当Go服务升级.proto文件但未同步更新Java客户端时,optional int32 timeout = 3;字段在旧客户端中被忽略,却在新服务端触发非空校验失败——字段语义未变,行为已崩

Go服务端关键校验逻辑

// server.go:显式校验可选字段存在性(违反Protobuf默认语义)
if !req.Timeout.IsSet() { // 注意:需启用experimental_features
    return status.Error(codes.InvalidArgument, "timeout required")
}

IsSet()是Go protobuf v2.10+引入的实验性API,依赖protoc-gen-go生成代码时启用--experimental_allow_proto3_optional;未开启则该方法不可用,且nil指针解引用将panic。

常见陷阱对照表

陷阱类型 Go表现 跨语言影响
字段重命名 编译通过,运行时零值传递 Python客户端收到空字符串
oneof默认值缺失 生成struct无零值初始化逻辑 Rust客户端无法识别分支类型

治理建议

  • 强制启用--require_syntax = proto3并禁用optional(除非全栈统一升级);
  • 使用buf lint + 自定义规则拦截field_presence = EXPLICIT声明。

4.3 分布式事务一致性:Saga模式在Go微服务链路中的状态机实现

Saga 模式通过一连串本地事务 + 对应补偿操作保障跨服务最终一致性,适用于高并发、长生命周期的微服务场景。

核心状态机设计

Saga 实例需维护 Pending → Executing → Succeeded | Failed → Compensating → Compensated 等关键状态,支持幂等重入与中断恢复。

Go 状态机核心结构(精简版)

type SagaState int

const (
    Pending SagaState = iota
    Executing
    Succeeded
    Failed
    Compensating
    Compensated
)

type Saga struct {
    ID        string
    State     SagaState
    Steps     []SagaStep // 正向操作与补偿函数对
    Current   int        // 当前执行步序(-1 表示未开始)
}

Steps 切片按顺序封装每个服务的 Do()Undo() 函数;Current 为有状态游标,驱动原子推进与回滚定位。

Saga 执行流程(mermaid)

graph TD
    A[Start] --> B{Step i executed?}
    B -->|Yes| C[Next Step]
    B -->|No| D[Run Do i]
    D --> E{Success?}
    E -->|Yes| F[Update Current++]
    E -->|No| G[Trigger Undo i..0]
    F --> H{All done?}
    H -->|Yes| I[Succeeded]
    H -->|No| B
    G --> J[Compensating → Compensated]

关键保障机制

  • 补偿操作必须幂等且可重试
  • 状态变更需原子写入持久化存储(如 PostgreSQL INSERT ... ON CONFLICT DO UPDATE
  • 超时任务由独立 Saga Coordinator 定期扫描 Executing 状态并触发恢复

4.4 性能基线建设:基于go-benchstat的多版本延迟/吞吐对比方法论

构建可复现、可回溯的性能基线,是服务迭代中识别回归与优化收益的关键前提。go-benchstat 提供了统计显著性驱动的基准对比能力,避免仅依赖单次 go test -bench 的偶然偏差。

核心工作流

  • 对每个版本(如 v1.2, v1.3)独立运行 go test -bench=. -benchmem -count=10 > bench-v1.2.txt
  • 使用 benchstat 聚合多轮结果并执行 Mann-Whitney U 检验
# 比较两个版本的 p95 延迟与吞吐变化(需提前生成 bench-*.txt)
benchstat -delta-test=p -geomean bench-v1.2.txt bench-v1.3.txt

--delta-test=p 启用非参数显著性检验;-geomean 输出几何均值比,更适配响应时间分布;默认阈值 p<0.05 判定差异显著。

典型输出解读

Metric v1.2 (ns/op) v1.3 (ns/op) Δ p-value
BenchmarkHandle-8 42100 38900 -7.61% 0.003

自动化集成示意

graph TD
  A[Git Tag Detect] --> B[Run 10x Benchmarks]
  B --> C[Save to S3/FS]
  C --> D[benchstat Compare]
  D --> E[Post to Dashboard if Δ>5% || p<0.05]

第五章:总结与展望

核心技术栈的协同演进

在实际交付的三个中型微服务项目中,Spring Boot 3.2 + Jakarta EE 9.1 + GraalVM Native Image 的组合显著缩短了容器冷启动时间——平均从 2.8 秒降至 0.37 秒。某电商订单履约系统上线后,通过 @Transactional@RetryableTopic 的嵌套使用,在 Kafka 消息重试场景下将最终一致性保障成功率从 99.42% 提升至 99.997%。以下为生产环境 A/B 测试对比数据:

指标 传统 JVM 模式 Native Image 模式 提升幅度
内存占用(单实例) 512 MB 186 MB ↓63.7%
启动耗时(P95) 2840 ms 368 ms ↓87.0%
HTTP 接口 P99 延迟 142 ms 118 ms ↓16.9%

生产故障的逆向驱动优化

2024 年 Q2 某金融对账服务因 LocalDateTime.now() 在容器时区未显式配置,导致跨 AZ 部署节点生成不一致的时间戳,引发日终对账失败。团队紧急上线补丁后,建立自动化检测流水线:

# CI 阶段强制校验 JVM 时区配置
docker run --rm -e TZ=Asia/Shanghai openjdk:17-jdk-slim \
  java -XshowSettings:properties -version 2>&1 | grep "user.timezone"

该机制已集成至 12 个核心服务的 GitLab CI,拦截潜在时区风险配置 37 次。

架构治理的度量实践

采用 OpenTelemetry Collector 自定义 exporter,将链路追踪数据实时写入 TimescaleDB,并构建 Grafana 看板监控「跨服务调用超时率突增」事件。当 service-a → service-bhttp.status_code=504 出现 5 分钟内增长 300%,自动触发 Slack 告警并推送根因分析建议(如检查 service-b 的 HikariCP 连接池活跃数是否达上限)。该策略使平均故障定位时间(MTTD)从 18.4 分钟压缩至 4.2 分钟。

开源组件的定制化改造

针对 Apache ShardingSphere-JDBC 5.3.2 在分库分表场景下无法动态路由到新扩容分片的问题,团队基于其 ShardingSphereDataSource 扩展了 DynamicShardingAlgorithm 接口实现,支持运行时热加载分片规则 JSON 文件。该方案已在物流轨迹查询系统落地,支撑单日 2.3 亿条轨迹数据按 trace_id % 128 动态扩容至 256 个物理分片,无需停服。

技术债偿还的渐进路径

遗留的 PHP 5.6 单体应用迁移过程中,采用 Strangler Fig Pattern 分阶段剥离:先以 Envoy Proxy 截获 /api/v1/orders/* 路由至新 Spring Cloud Gateway,再逐步将订单创建、支付回调等高价值模块迁出。截至 2024 年 9 月,核心交易链路已 100% 迁移,PHP 代码库体积减少 68%,而用户侧无感知变更。

云原生可观测性的深度整合

在阿里云 ACK 集群中部署 eBPF-based 的 Pixie 工具链,捕获 Pod 级别 TCP 重传率、TLS 握手失败等底层指标,与 Prometheus 的 JVM GC 指标进行关联分析。发现某支付网关在 GC Pause 达 1.2s 时,eBPF 数据显示其上游 Nginx 的 tcp_retrans_segs 突增 400%,从而定位到 JVM 参数 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 设置过激导致网络层抖动。

安全合规的自动化闭环

基于 Open Policy Agent(OPA)构建 Kubernetes Admission Controller,强制校验所有 Deployment 的 securityContext.runAsNonRoot=trueimagePullPolicy: Always。当某开发误提交含 runAsRoot: true 的 YAML 时,OPA 策略立即拒绝创建并返回结构化错误码 SEC-007,同时自动创建 Jira Issue 并关联安全基线文档链接。该机制已拦截高危配置 152 次。

多云环境的服务网格统一治理

在混合云架构中,使用 Istio 1.21 的 VirtualServiceDestinationRule 统一管理 AWS EKS 和 Azure AKS 上的 payment-service 实例。通过 trafficPolicy.loadBalancer.simple: LEAST_CONN 实现跨云流量智能调度,并利用 Sidecar 资源限制 Envoy 仅可访问同命名空间内的服务,避免跨租户服务发现泄露。实际压测显示,当 AWS 区域网络延迟升高 40ms 时,流量自动倾斜 62% 至 Azure 集群,业务 P99 延迟波动控制在 ±3ms 内。

对 Go 语言充满热情,坚信它是未来的主流语言之一。

发表回复

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