Posted in

Go区块链升级不中断方案:Hot-Swap State Migration + 双版本共识兼容机制(某Layer1主网灰度升级实录)

第一章:Go区块链升级不中断方案:Hot-Swap State Migration + 双版本共识兼容机制(某Layer1主网灰度升级实录)

在2024年Q2某高TPS Layer1主网的v1.8→v1.9灰度升级中,团队实现了零区块停机、无状态回滚风险的在线升级。核心突破在于将状态迁移与共识演进解耦:状态层通过热交换(Hot-Swap)完成增量迁移,共识层则依托双版本兼容协议维持跨节点语义一致性。

状态热交换实现原理

State Migration 不依赖全量快照导出/导入,而是基于可序列化、幂等的 StateDelta 结构体:

  • 每个模块(如账户、质押、IBC通道)注册独立的 MigrateFunc
  • 升级时仅广播轻量级 delta 包(含键前缀、操作类型、新旧值哈希),由各节点本地按序应用;
  • 所有 delta 操作均包裹在 state.MigrationTx() 中,确保原子性与可验证性。
// 示例:账户模块迁移片段(v1.8 → v1.9 地址格式标准化)
func MigrateAccounts(ctx sdk.Context, store sdk.KVStore) error {
    iter := sdk.KVStorePrefixIterator(store, types.AccountKeyPrefix)
    defer iter.Close()
    for ; iter.Valid(); iter.Next() {
        addr := sdk.AccAddress(iter.Key()[len(types.AccountKeyPrefix):])
        oldAcc := types.MustUnmarshalAccount(iter.Value())
        // 新版要求地址强制小写+校验和(兼容旧地址读取逻辑不变)
        newAddr := strings.ToLower(addr.String()) // 实际含 checksum 生成逻辑
        newAcc := oldAcc.WithAddress(newAddr)
        store.Set(types.AddressKey(newAddr), types.MustMarshalAccount(newAcc))
    }
    return nil
}

双版本共识兼容设计

v1.9 节点启动后自动进入“兼容模式”:

  • 接收 v1.8 和 v1.9 的 Msgs,对旧消息执行透明转换(如字段映射、默认值注入);
  • 共识投票中携带 ConsensusVersion 字段,BFT 层依据最低共同支持版本决定区块验证规则;
  • 配置表驱动兼容策略,支持运行时热加载:
版本组合 块验证规则 消息解析方式 超时阈值调整
v1.8 ↔ v1.8 原生规则 直接反序列化
v1.8 ↔ v1.9 v1.8 规则降级执行 自动字段填充+校验 +200ms

升级全程耗时17分钟,峰值延迟增加123ms,未产生任何分叉或交易丢弃。

第二章:Hot-Swap状态热迁移的核心原理与Go实现

2.1 基于Go反射与unsafe的运行时状态快照捕获机制

为实现低开销、高保真的运行时状态捕获,本机制融合 reflect 动态类型探查能力与 unsafe.Pointer 的内存直访能力,绕过 GC 安全检查,直接读取活跃 goroutine 栈帧与变量值。

核心设计原则

  • 零分配:避免在快照路径中触发堆分配
  • 瞬时性:单次扫描耗时控制在微秒级
  • 可逆性:所有 unsafe 操作均经 go:linkname//go:noescape 显式约束

关键代码片段

func captureStackVars(frame *runtime.Frame) map[string]interface{} {
    vars := make(map[string]interface{})
    ptr := unsafe.Pointer(frame.PC) // 获取指令指针定位栈帧
    // ...(省略栈偏移计算逻辑)
    v := reflect.NewAt(typ, ptr).Elem() // 用类型信息+地址构造反射值
    vars[frame.Function] = v.Interface()
    return vars
}

逻辑分析reflect.NewAt 将原始内存地址 ptr 绑定到已知类型 typ,生成可安全读取的 reflect.Valueframe.PC 提供栈帧入口,配合 DWARF 符号表可反推局部变量布局。参数 frame 来自 runtime.CallersFrames,确保上下文一致性。

组件 作用 安全边界
reflect 类型驱动的字段遍历 仅读取,不修改内存
unsafe.Pointer 跨 GC 边界的地址映射 严格限定生命周期 ≤ 单次调用
graph TD
    A[触发快照] --> B[获取当前goroutine栈帧]
    B --> C[解析DWARF调试信息定位变量偏移]
    C --> D[用unsafe.Pointer+reflect构造只读视图]
    D --> E[序列化为JSON/Protobuf]

2.2 持久化层抽象与StateDB双写/影子写入的原子性保障

数据同步机制

StateDB 采用双写(主库 + 影子库)+ WAL 日志协同保障原子性:先写影子库与日志,再原子提交至主库。

// 影子写入流程(伪代码)
func ShadowWrite(key, value []byte) error {
    if err := shadowDB.Put(key, value); err != nil { // 1. 写入影子库(非阻塞)
        return err
    }
    if err := wal.Write(&WALEntry{Key: key, Value: value, Op: "PUT"}); err != nil { // 2. 持久化日志
        return err
    }
    return primaryDB.CommitShadow(); // 3. 原子切换(基于CAS或事务快照)
}

shadowDB.Put() 隔离写入避免脏读;wal.Write() 确保崩溃可恢复;CommitShadow() 通过底层 LSM 树 snapshot 切换实现无锁原子提交。

关键保障维度对比

维度 双写模式 影子写入模式
崩溃一致性 弱(需补偿) 强(WAL+快照)
读写隔离性 依赖应用层 自动快照隔离
graph TD
    A[客户端写请求] --> B[写入影子DB]
    B --> C[追加WAL日志]
    C --> D{日志落盘成功?}
    D -->|是| E[原子切换主库视图]
    D -->|否| F[回滚影子DB]

2.3 Go协程安全的状态迁移校验器设计与增量一致性验证

核心设计原则

  • 基于 sync/atomic 实现无锁状态跃迁
  • 所有状态变更必须经 ValidateTransition(from, to) 预检
  • 每次校验附带版本戳(uint64),支持幂等重试

状态迁移校验器实现

type StateValidator struct {
    currentState atomic.Uint64
    transitions  map[uint64]map[uint64]bool // from → {to: allowed}
}

func (v *StateValidator) TryTransition(from, to uint64) bool {
    if !v.transitions[from][to] {
        return false // 非法迁移路径
    }
    return v.currentState.CompareAndSwap(from, to) // 原子提交
}

CompareAndSwap 确保仅当当前状态匹配 from 时才更新为 to,避免竞态导致的中间态丢失;transitions 预置合法图谱,防止运行时动态修改引入不一致。

增量一致性验证流程

graph TD
    A[读取当前版本v1] --> B[执行业务逻辑]
    B --> C[生成增量diff]
    C --> D[校验v1→v2是否在迁移图中]
    D --> E[原子提交v2并返回新版本]

支持的迁移规则示例

From To 合法 说明
0 1 初始化 → 待处理
1 2 处理中 → 已完成
1 0 禁止回退

2.4 面向区块高度的迁移断点续传与回滚恢复实践

数据同步机制

基于区块高度(block height)的迁移需严格保证状态一致性。核心在于将迁移任务锚定至不可变的链上坐标,而非时间戳或随机ID。

断点续传实现

def resume_from_height(target_height: int, db_conn):
    # 查询已成功写入的最高区块高度
    last_committed = db_conn.execute(
        "SELECT MAX(height) FROM migration_log WHERE status = 'success'"
    ).fetchone()[0] or 0

    # 仅同步 [last_committed + 1, target_height] 区间
    for h in range(last_committed + 1, target_height + 1):
        block = fetch_block_by_height(h)  # 从节点RPC拉取
        apply_block_to_db(block, db_conn)
        log_migration(h, "success", db_conn)

逻辑分析:last_committed 作为断点游标,避免重复处理;fetch_block_by_height 要求节点支持 eth_getBlockByNumber 等确定性接口;日志表 migration_log 必须启用事务级原子写入。

回滚策略对比

方式 触发条件 恢复粒度 适用场景
高度快照回滚 迁移校验失败 全量区块高度 金融级强一致性
WAL日志回退 DB写入异常 行级变更 高吞吐低延迟场景

状态验证流程

graph TD
    A[启动迁移] --> B{当前height ≤ target?}
    B -->|否| C[完成]
    B -->|是| D[拉取height区块]
    D --> E[执行业务转换]
    E --> F[写入DB + 写日志]
    F --> G{校验通过?}
    G -->|否| H[触发回滚至height-1]
    G -->|是| I[height++]
    I --> B

2.5 生产环境迁移性能压测:从本地测试到主网千节点集群实测

压测阶段演进路径

  • 本地单机模拟:基于 Docker Compose 启动 5 节点轻量集群,验证基础同步逻辑
  • IDC 混合集群:200 节点(物理机 + KVM),引入网络抖动与磁盘限速
  • 主网灰度实测:接入真实千节点 PoS 共识网络,启用链上指标埋点

关键数据同步机制

# 同步速率自适应控制脚本(部署于每个验证节点)
curl -X POST http://localhost:8545 \
  -H "Content-Type: application/json" \
  --data '{
    "jsonrpc":"2.0","method":"admin_setSyncRate","params":[85], "id":1
  }'

params[0] 表示带宽配额百分比(0–100),85 表示允许占用 85% 可用上行带宽;该值由上游监控服务动态下发,避免拥塞传播。

主网压测核心指标对比

阶段 平均区块同步延迟 P99 状态同步耗时 网络重传率
本地测试 120 ms 380 ms 0.2%
千节点实测 410 ms 2.1 s 8.7%

流量调度决策流

graph TD
  A[监控采集延迟/丢包/队列深度] --> B{是否触发降级?}
  B -->|是| C[切换至压缩同步模式]
  B -->|否| D[维持全量状态同步]
  C --> E[启用 Snappy+Delta 编码]

第三章:双版本共识兼容机制的Go协议栈重构

3.1 共识消息序列化兼容层:proto v2/v3混合编解码与字段默认值语义对齐

在跨版本共识节点共存场景中,v2 生成的 optional 字段(无显式赋值)与 v3 的 field_presence 模式存在语义鸿沟:v2 将未设字段视为“未设置”(has_xxx() == false),而 v3 默认启用 optional 时将其视为“显式缺省”(xxx == default_value)。

字段语义对齐策略

  • 在反序列化入口注入 ProtoVersionAwareParser
  • 对 v2 消息强制补全缺失字段为 nulldefault(依 schema 标记)
  • v3 消息保留原生语义,通过 FieldMask 区分 unsetexplicit_default

编解码桥接核心逻辑

// v2/v3 兼容反序列化器(简化版)
public static ConsensusMsg parse(byte[] data, ProtoVersion version) {
  if (version == V2) {
    return V2Parser.parse(data)              // 返回 raw v2 obj
        .withDefaultValuesApplied(SCHEMA);   // 关键:按 v3 schema 补默认值
  }
  return V3Parser.parse(data);               // 原生 v3 解析
}

withDefaultValuesApplied() 遍历 .protooptional 字段定义,对 hasXxx()==false 的字段注入 schema.getDefaultValue("xxx"),确保下游统一按“显式缺省”语义处理。

字段类型 v2 行为 v3 行为(启用 optional) 对齐后语义
int32 id hasId()==false id == 0(且 hasId() 为 true) 统一为 id == 0hasId() 恒真
graph TD
  A[原始字节流] --> B{ProtoVersion}
  B -->|V2| C[V2Parser → RawMsg]
  B -->|V3| D[V3Parser → NativeMsg]
  C --> E[ApplySchemaDefaults]
  E --> F[ConsensusMsg]
  D --> F

3.2 Go接口契约演进策略:VersionedConsensusEngine接口的零依赖扩展设计

VersionedConsensusEngine 接口通过泛型约束与版本标记解耦实现逻辑,避免引入 github.com/.../v2 等路径依赖:

type VersionedConsensusEngine[T ConstraintsV1 | ConstraintsV2] interface {
    Validate(block T) error
    Commit(ctx context.Context) error
}

逻辑分析ConstraintsV1ConstraintsV2 是空接口别名(如 type ConstraintsV1 interface{ V1() }),不携带运行时行为,仅作编译期契约标识;T 类型参数使同一接口可适配多版本区块结构,无需接口继承或包升级。

零依赖扩展核心原则

  • ✅ 类型参数替代 import 版本分支
  • ✅ 接口方法签名保持稳定(无新增/删减)
  • ❌ 禁止在接口中嵌入 io.Closer 等非领域相关接口

版本兼容性对比

版本策略 依赖污染 编译期检查 运行时反射开销
路径分版本(v1/v2)
接口类型参数化
graph TD
    A[Client调用] --> B{Type Param T}
    B --> C[ConstraintsV1]
    B --> D[ConstraintsV2]
    C --> E[ValidateV1Impl]
    D --> F[ValidateV2Impl]

3.3 混合共识窗口期的动态分片路由:基于Go context超时与区块时间戳的智能转发逻辑

在跨分片交易高频场景下,传统静态路由易导致窗口期错配与共识延迟。本机制融合 context.WithTimeout 的纳秒级精度与区块头 Timestamp 的链上时序锚点,实现路由决策的双重时效校验。

路由决策核心逻辑

func selectShard(ctx context.Context, tx *Transaction, latestBlock *Block) (uint64, error) {
    // 1. 上下文超时剩余时间(防御性兜底)
    deadline, ok := ctx.Deadline()
    if !ok {
        return 0, errors.New("no timeout set")
    }
    timeoutLeft := time.Until(deadline)

    // 2. 基于区块时间戳计算共识窗口余量(主逻辑)
    windowEnd := latestBlock.Timestamp.Add(consensusWindow) // 如 5s
    timeLeftInWindow := time.Until(windowEnd)

    // 3. 取更严格的约束(min)
    effectiveTimeout := min(timeoutLeft, timeLeftInWindow)
    if effectiveTimeout <= 0 {
        return 0, errors.New("outside consensus window")
    }

    return shardHash(tx.Sender, uint64(effectiveTimeout.Nanoseconds())), nil
}

逻辑分析ctx.Deadline() 提供客户端侧最大容忍延迟(如 RPC 调用超时),latestBlock.Timestamp 提供链上全局时序基准;二者取交集确保路由既满足本地 SLA,又不越界当前共识周期。shardHash 输入含动态超时值,使相同交易在不同窗口期可能路由至不同分片,提升负载均衡弹性。

窗口期参数对照表

参数 类型 典型值 作用
consensusWindow time.Duration 5s 分片内共识有效周期
ctx.Timeout() time.Duration 3s 客户端请求最大等待时长
effectiveTimeout time.Duration min(3s, 2.1s) = 2.1s 实际路由决策依据

路由状态流转

graph TD
    A[接收交易] --> B{Context Deadline valid?}
    B -->|No| C[Reject: No timeout]
    B -->|Yes| D[Fetch latest block]
    D --> E[Compute timeLeftInWindow]
    E --> F[Min of two timeouts]
    F --> G{> 0?}
    G -->|Yes| H[Hash with dynamic timeout → Shard ID]
    G -->|No| I[Reject: Outside window]

第四章:灰度升级全链路工程实践与可观测性建设

4.1 Go模块化升级控制器:支持按节点标签、地理区域、算力权重的渐进式发布

核心调度策略设计

控制器采用三级过滤+加权打分机制:

  • 第一级:nodeSelector 匹配标签(如 env=prod, zone=cn-east-2
  • 第二级:地理围栏校验(基于 region/zone 拓扑层级)
  • 第三级:动态算力评分(CPU空闲率 × 0.4 + 内存余量 × 0.3 + GPU可用数 × 0.3)

渐进式发布配置示例

// rolloutStrategy.go
RolloutPolicy: &v1alpha1.RolloutPolicy{
  Steps: []v1alpha1.RolloutStep{
    {Percent: 5,  Match: "labels.env==staging && region==us-west"}, // 首批5%灰度
    {Percent: 30, Match: "zone==cn-north-1 || zone==cn-east-2"},     // 扩至30%,覆盖双AZ
    {Percent: 100, WeightField: "computeScore"},                      // 全量,按算力权重排序下发
  },
}

Match 使用 CEL 表达式引擎实时求值;WeightField 触发节点池内 Top-K 排序,避免低负载节点过载。

节点权重计算维度对比

维度 数据源 更新频率 权重系数
CPU空闲率 cAdvisor metrics 10s 0.4
内存余量(GB) kubelet summary 15s 0.3
可用GPU数 device plugin 30s 0.3

发布流程时序

graph TD
  A[接收升级请求] --> B{解析RolloutPolicy}
  B --> C[标签过滤候选节点]
  C --> D[地理区域裁剪]
  D --> E[算力加权排序]
  E --> F[按Step百分比切片]
  F --> G[并发下发PodSpec]

4.2 Prometheus+OpenTelemetry深度集成:共识状态迁移延迟、版本混合率、消息分歧率三维度监控看板

数据同步机制

OpenTelemetry SDK 通过 ResourceInstrumentationScope 标准化链路标签,将共识节点角色(validator/observer)、链路ID、区块高度注入指标上下文:

# otel-collector-config.yaml 片段:指标重标签规则
processors:
  resource:
    attributes:
      - key: "consensus.role"
        action: insert
        value: "validator"

该配置确保所有 otelcol_exporter_prometheus 输出的指标自动携带 consensus_role="validator" 标签,供 Prometheus 按角色切片聚合。

三大核心指标建模

指标名 类型 标签维度 业务含义
consensus_state_migration_latency_seconds Histogram role, target_state 状态迁移耗时分布(P95
consensus_version_mix_ratio Gauge cluster_id, epoch 同一区块高度下多版本共存比例(>0.1 触发降级告警)
consensus_message_divergence_rate Counter topic, source_node_id 跨节点消息哈希不一致频次(持续上升表明分叉风险)

监控看板逻辑流

graph TD
  A[OTel SDK采集共识事件] --> B[otel-collector聚合+重标]
  B --> C[Prometheus scrape /metrics]
  C --> D[PromQL计算:rate(divergence_total[5m]) / sum(messages_total)]
  D --> E[Grafana三维度联动看板]

4.3 基于Go testbench的升级路径混沌测试框架:注入网络分区、状态篡改、版本错配等故障场景

该框架以 go test 为执行基座,通过 testbench.RunChaosScenario() 启动可插拔故障注入器:

// 注入网络分区:隔离节点A与B,保留A-C、B-C通路
err := netpart.Inject("A", "B", testbench.WithPartitionTimeout(30*time.Second))
if err != nil {
    t.Fatal("failed to inject network partition:", err)
}

逻辑分析netpart.Inject 利用 iptables 规则动态丢弃 A→B 和 B→A 的 TCP/UDP 包;WithPartitionTimeout 控制故障持续窗口,避免测试挂起。底层调用 exec.Command("iptables", ...) 实现无代理轻量隔离。

核心故障类型支持能力

故障类型 注入方式 可观测性钩子
网络分区 iptables + tc netpart.Metrics()
状态篡改 直接写入 etcd backend statehook.Watch()
版本错配 动态替换 binary path version.MockLoader

数据同步机制

故障恢复后,框架自动触发三阶段校验:

  1. 元数据一致性比对(raft term / commit index
  2. 用户态数据快照 diff(基于 merkle tree root
  3. 业务语义验证(调用预注册 validator.Func
graph TD
    A[Start Test] --> B{Inject Fault}
    B --> C[Observe Degradation]
    C --> D[Trigger Recovery]
    D --> E[Validate Consistency]
    E --> F[Report SLI/SLO Drift]

4.4 主网灰度升级SOP文档与Go自动化巡检脚本集(含链上状态比对、RPC兼容性探针、轻节点同步验证)

核心设计原则

灰度升级以“可回滚、可观测、可中断”为铁律,覆盖全链路验证:链上状态一致性 → RPC接口契约合规性 → 轻节点同步完整性。

巡检脚本架构

// rpc_probe.go:轻量级HTTP/JSON-RPC探针
func ProbeRPC(endpoint string, method string, params []interface{}) (bool, error) {
    client := rpc.DialHTTP(endpoint)
    defer client.Close()
    var result interface{}
    err := client.Call(method, params, &result)
    return err == nil && result != nil, err // 非空响应+无错误视为通过
}

逻辑分析:ProbeRPC 采用阻塞式调用,校验方法存在性、序列化兼容性及基础返回结构;params 支持动态传入(如 ["0x1", true]),适配 eth_blockNumbernet_version 等关键接口。

验证维度对比

维度 检查项 失败阈值
链上状态比对 最新区块哈希、时间戳、难度 偏差 >3s 或哈希不一致
RPC兼容性 eth_getBlockByNumber 返回字段完整性 缺失 miner, totalDifficulty 等核心字段
轻节点同步 eth_syncing 返回 false 或区块高度 ≥ 主网-2 同步延迟 >10区块

自动化执行流程

graph TD
    A[启动灰度节点] --> B[并发执行三类探针]
    B --> C{全部通过?}
    C -->|是| D[标记该批次升级成功]
    C -->|否| E[触发告警+自动回滚至前一稳定版本]

第五章:总结与展望

核心技术栈落地成效

在某省级政务云迁移项目中,基于本系列实践构建的自动化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分位值+15%浮动带。该方案上线后,同类误报率下降91%,且在后续三次突发流量高峰中均提前4.2分钟触发精准预警。

# 动态阈值计算脚本核心逻辑(生产环境已验证)
curl -s "http://prometheus:9090/api/v1/query?query=avg_over_time(pg_connections_used_percent[7d])" \
  | jq -r '.data.result[0].value[1]' \
  | awk '{printf "%.0f\n", $1 * 1.15}'

多云协同架构演进路径

当前已实现AWS中国区与阿里云华东2节点的双活流量调度,但存在跨云日志检索延迟高的问题。下一步将部署基于OpenTelemetry Collector的统一采集层,通过以下拓扑结构消除数据孤岛:

graph LR
  A[应用Pod] -->|OTLP gRPC| B[边缘Collector]
  B --> C{路由决策}
  C -->|业务标签匹配| D[AWS CloudWatch Logs]
  C -->|安全合规策略| E[阿里云SLS]
  C -->|审计日志| F[本地ES集群]
  D & E & F --> G[统一Grafana Loki界面]

开发者体验优化成果

内部开发者调研显示,新入职工程师首次提交代码到生产环境的平均耗时从11.3天缩短至3.6天。关键改进包括:自动生成符合CIS标准的Kubernetes资源配置模板、集成SonarQube的PR预检门禁、提供可交互式调试沙箱环境。某金融客户使用该沙箱完成支付网关灰度验证,将线上问题发现阶段从发布后前移至代码合并前。

技术债治理机制

建立季度技术债评审会制度,采用加权评分卡评估债务项。2024年已清理37项高风险债务,包括替换Elasticsearch 6.x集群(存在CVE-2023-25194)、迁移遗留Python 2.7脚本至3.11、重构单体认证服务为Keycloak联邦模式。当前技术债存量较年初下降42%,其中基础设施类债务占比从63%降至29%。

行业合规适配进展

在医疗健康领域落地实践中,系统已通过等保2.0三级认证与GDPR数据主权要求。所有患者影像数据在传输层强制启用TLS 1.3,在存储层采用AES-256-GCM加密,并通过硬件安全模块(HSM)管理密钥生命周期。审计日志保留策略严格遵循《医疗卫生机构网络安全管理办法》第18条,原始日志留存期达180天且不可篡改。

社区共建生态建设

开源项目k8s-resource-validator已被12家金融机构采用,贡献者数量从初始3人增长至47人。最新v2.4版本新增FHIR标准资源校验器,支持HL7 v4.0.1规范,已在华西医院电子病历系统中验证通过。社区每月举办线上故障演练,最近一次模拟医保结算接口超时场景,平均响应时间缩短至8.3秒。

下一代可观测性规划

2025年Q1将启动eBPF深度探针部署,覆盖内核级网络丢包分析与内存泄漏追踪。试点集群已验证bpftrace脚本对gRPC流控异常的实时检测能力,可在毫秒级捕获HTTP/2 GOAWAY帧异常发送行为。配套建设的Trace-Span关联图谱将打通从浏览器FID指标到后端数据库锁等待的全链路因果推理。

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

发表回复

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