Posted in

线上事故复盘:因误信“map读操作安全”,我们在K8s operator中用map存Pod状态,导致23个集群配置漂移

第一章:线上事故复盘:因误信“map读操作安全”,我们在K8s operator中用map存Pod状态,导致23个集群配置漂移

在构建一个用于统一管理多租户工作负载的 Kubernetes Operator 时,我们采用 sync.Map 存储各 Pod 的实时状态(如 phase、ready condition、lastTransitionTime),以避免频繁调用 API Server。设计初衷是利用其并发读取安全特性提升性能——但忽略了写操作仍需显式同步,且零值写入、delete 后的 read 不再线程安全这一关键边界。

事故触发路径如下:

  • 多个 goroutine 并发处理同一 Pod 的 status update 事件;
  • 某次 reconcile 中,先执行 delete(m, podUID) 清理旧状态,随后另一 goroutine 执行 m.Load(podUID) 返回 nil,再调用 m.Store(podUID, newStatus);
  • 由于 sync.Map 的 delete 后 Load 行为不保证原子性,部分 goroutine 在 delete 和 Store 间隙读到 stale 指针或 panic,导致状态写入错乱;
  • 最终 Operator 将错误状态回写至 CRD 的 .status.podStates 字段,引发跨集群配置漂移——23 个生产集群中,17 个出现 Pod 状态与实际不符,3 个触发误扩缩容。

核心修复代码如下:

// ❌ 错误用法:依赖 sync.Map 自动同步,忽略 delete-load-store 竞态
var podStateMap sync.Map // 危险!

// ✅ 正确方案:改用带锁的结构体,明确控制读写边界
type PodStateManager struct {
    mu    sync.RWMutex
    state map[string]v1.PodStatus
}
func (p *PodStateManager) Get(uid string) (v1.PodStatus, bool) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    s, ok := p.state[uid]
    return s, ok
}
func (p *PodStateManager) Set(uid string, s v1.PodStatus) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.state[uid] = s
}

关键改进点包括:

  • 将 sync.Map 替换为带 RWMutex 的 map[string]v1.PodStatus;
  • 所有读操作统一走 Get(),写操作强制走 Set();
  • 在 Operator 的 Reconcile 方法入口处增加 defer func() { recover() }() 防止 panic 波及主循环;
  • 新增 e2e 测试覆盖 delete → concurrent Load/Store 场景,使用 ginkgo + gomega 断言状态一致性。

事后验证表明,该修改将状态同步错误率从 0.87% 降至 0,且平均 reconcile 耗时仅增加 12μs(可接受范围)。

第二章:go map为什么并发不安全

2.1 Go map底层哈希表结构与桶分裂机制剖析

Go 的 map 是基于开放寻址哈希表(实际为分离链表 + 线性探测混合变体)实现的,核心由 hmap 结构体和 bmap(桶)组成。

桶结构与数据布局

每个桶(bmap)固定容纳 8 个键值对,采用紧凑数组布局:

  • 前 8 字节为 tophash 数组(存储哈希高 8 位,用于快速失败判断)
  • 后续连续存放 key、value、overflow 指针(若发生溢出)
// runtime/map.go 中简化示意(非真实内存布局,仅逻辑示意)
type bmap struct {
    tophash [8]uint8     // 哈希高位,加速查找
    keys    [8]keyType  // 键数组
    values  [8]valueType // 值数组
    overflow *bmap       // 溢出桶指针(链表延伸)
}

tophash[i] == 0 表示该槽位为空;== emptyRest 表示后续全空;== evacuatedX 表示已迁移。overflow 指针支持动态扩容时的渐进式搬迁。

负载因子与分裂触发

当装载因子(count / (2^B * 8))≥ 6.5 或溢出桶过多时,触发扩容:

条件 动作
count > 6.5 × 2^B × 8 双倍扩容(B++)
溢出桶数 ≥ 2^B 等量扩容(B 不变)
graph TD
    A[插入新键值对] --> B{负载超限?}
    B -->|是| C[申请新 hmap,B 更新]
    B -->|否| D[直接插入或线性探测]
    C --> E[渐进式搬迁:每次写/读/遍历搬一个桶]

桶分裂非原子操作,通过 oldbuckets 和 neighboring 标志位协同实现无锁迁移。

2.2 读写竞态下迭代器panic的汇编级触发路径复现

当并发读取 sync.Map 迭代器与写入操作(如 Store)发生时,底层 readOnly.m 与 dirty 映射切换可能使迭代器访问已释放的 entry 指针,触发空指针解引用 panic。

数据同步机制

sync.Map 的 Range 使用原子读取 read 字段,但不阻塞 dirty 提升过程;若提升中 read 被替换为新只读映射,原迭代器仍持有旧 readOnly.m 中失效的 *entry。

关键汇编片段(amd64)

MOVQ    (AX), DX     // 加载 entry.ptr(可能为 nil)
TESTQ   DX, DX
JE      panic_cleanup // 若 DX == 0 → 触发 runtime.panicnil

此指令序列在 runtime.mapiternext 内联展开中出现:AX 指向迭代器 hiter.key 所属的 entry 结构首地址,MOVQ (AX), DX 直接解引用未加锁校验的指针。

触发条件归纳

  • 迭代器初始化后,sync.Map.Store 触发 dirty 提升;
  • readOnly.m 被原子替换,但旧 map 中 entry 已被 GC 标记为可回收;
  • 下一次 mapiternext 执行上述汇编片段,解引用悬垂指针。
状态阶段 read.m 地址 entry 有效性 是否 panic
迭代开始 0x7f1a…c00 有效 否
dirty 提升 0x7f1a…e80(新) 旧地址 entry 已释放 是(下次 next)

2.3 race detector无法捕获的“伪安全”读场景实测验证

数据同步机制

Go 的 sync/atomic 提供无锁原子操作,但仅对同一变量的读写具备内存序保障。若读操作依赖多个非原子关联变量(如标志位+数据缓冲),race detector 因无共享地址冲突而静默通过。

实测代码示例

var (
    ready uint32
    data  int64
)

func writer() {
    data = 42                    // 非原子写入
    atomic.StoreUint32(&ready, 1) // 原子写入标志
}

func reader() {
    if atomic.LoadUint32(&ready) == 1 {
        _ = data // race detector 不报错!但 data 可能未刷新到当前 goroutine 缓存
    }
}

逻辑分析:data 与 ready 地址不重叠,race detector 无法识别跨变量重排序风险;data 缺乏 atomic 或 sync.Mutex 保护,CPU/编译器可能重排或缓存失效,导致读到陈旧值。

关键约束对比

保障类型 覆盖变量数 race detector 检测能力
单变量原子操作 1 ✅ 显式标记
多变量顺序依赖 ≥2 ❌ 静默漏报

修复路径

  • 使用 sync.Mutex 统一封装 data 与 ready
  • 改用 atomic.StoreInt64 + atomic.LoadInt64 全量保护
  • 引入 runtime.KeepAlive 防止编译器过度优化

2.4 单goroutine写+多goroutine读仍触发fatal error的边界案例构造

数据同步机制

Go 运行时对 sync.Map 和原生 map 的并发访问有不同检查策略:原生 map 在写后未同步即被并发读(即使仅一个写 goroutine)可能触发 fatal error: concurrent map read and map write。

关键边界条件

  • 写操作未完成(如 m[key] = val 正在扩容哈希桶)
  • 多个读 goroutine 恰在写操作中间状态访问底层 buckets 或 oldbuckets
  • GC 扫描与 map 状态不一致(如 dirty 未刷入 read 且 amended=true)

复现代码

var m = make(map[int]int)
func writer() {
    for i := 0; i < 1000; i++ {
        m[i] = i // 非原子写,可能触发 growWork
    }
}
func reader() {
    for range m { // 触发迭代器,直接读 buckets
    }
}
// 启动 1 writer + 8 readers → 高概率 panic

逻辑分析:for range m 底层调用 mapiterinit,直接访问 h.buckets。若 writer 正执行 hashGrow(复制 oldbucket、清空 dirty),而 reader 此时读取已释放的 oldbuckets 地址,触发内存非法访问,runtime 直接 fatal。

条件 是否触发 panic 原因
写后立即 sync.RWMutex.Unlock 否 读被阻塞,状态一致
写中 GC 并发扫描 是 bucket 指针悬空
sync.Map.Load 读 否 封装了原子状态检查

2.5 从Go 1.22 runtime源码看mapaccess1/mapassign函数的原子性缺失

Go 1.22 的 runtime/map.go 中,mapaccess1 与 mapassign 均不加锁直接操作 hmap.buckets 和 bucket.tophash,依赖调用方保证并发安全。

数据同步机制

  • mapaccess1 仅读取 bucket.tophash 和 bucket.keys,无内存屏障;
  • mapassign 在写入前未执行 atomic.LoadUintptr(&h.buckets) 验证桶指针有效性;
  • 扩容期间若另一 goroutine 并发读写,可能观察到部分初始化的 bucket(如 tophash 为 0 但 key 非零)。

关键代码片段(简化自 src/runtime/map.go)

// mapaccess1: 无 sync/atomic 保护的桶指针解引用
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + uintptr(bi)*uintptr(h.bucketsize)))
// ▶ h.buckets 可能被 growWork 中的 write barrier 更新,此处读取非原子

参数说明:h.buckets 是 unsafe.Pointer 类型,其更新由 hashGrow 中的 atomic.StorePointer 完成,但 mapaccess1 未配对使用 atomic.LoadPointer,导致数据竞争。

场景 是否原子 风险
读 bucket.tophash 否 读到 0xff 或未初始化值
写 bucket.keys 否 脏写、ABA 问题、越界访问
graph TD
    A[goroutine A: mapassign] -->|写入新 bucket| B[h.buckets = newBuckets]
    C[goroutine B: mapaccess1] -->|竞态读| B
    B --> D[可能读到 half-initialized bucket]

第三章:Operator场景中map误用的典型模式与危害放大效应

3.1 Informer事件驱动下非线程安全状态缓存的雪崩式扩散

数据同步机制

Informer 通过 Reflector 拉取全量资源后,将对象写入 DeltaFIFO 队列,再由 sharedIndexInformer 的 processorListener 并发分发事件。若缓存层(如 map[string]*v1.Pod)未加锁直接更新,多个 goroutine 同时 delete(cache, key) 或 cache[key] = obj 将触发 panic 或数据丢失。

并发写冲突示例

// ❌ 危险:非线程安全缓存更新
var podCache = make(map[string]*v1.Pod)
func onUpdate(obj interface{}) {
    pod := obj.(*v1.Pod)
    podCache[pod.Name] = pod // 竞态点:无 mutex 保护
}

逻辑分析:podCache 是原生 map,Go 运行时在并发写时直接 panic(”fatal error: concurrent map writes”)。该 panic 会终止 handler goroutine,导致后续事件积压,触发 informer 重试风暴——即“雪崩式扩散”。

雪崩传播路径

阶段 表现
初始竞态 单个 onUpdate panic
Goroutine 崩溃 processorListener 停摆
事件积压 DeltaFIFO 持续堆积
重试放大 ListWatch 周期性全量重同步
graph TD
    A[Informer 事件分发] --> B[onUpdate 并发写 map]
    B --> C{panic?}
    C -->|是| D[goroutine crash]
    D --> E[事件处理停滞]
    E --> F[DeltaFIFO 积压 → 触发 resync]
    F --> G[全量 List → 加剧竞争]

3.2 控制循环中map读写交织引发的Pod状态“幽灵更新”现象

数据同步机制

Kubernetes 控制器在 Reconcile 循环中常使用 map[string]*corev1.Pod 缓存 Pod 状态,但若未加锁并发读写,会触发竞态:

// ❌ 危险:无同步的 map 操作
podCache[pod.Name] = pod.DeepCopy() // 写
_, exists := podCache[req.NamespacedName.Name] // 读

该操作在 Go 运行时会 panic(fatal error: concurrent map read and map write),但更隐蔽的是——部分 goroutine 观察到中间态,导致控制器误判 Pod 状态变更。

幽灵更新的触发链

  • 控制器 A 在 List() 后遍历更新 podCache
  • 控制器 B 同时调用 Get() 并基于旧缓存生成 Patch
  • etcd 中实际状态未变,但 status.conditions 被重复 patch → 出现时间戳漂移的“幽灵更新”

解决方案对比

方案 安全性 性能开销 适用场景
sync.RWMutex 包裹 map ✅ 强一致 中等 高频读、低频写
sync.Map ⚠️ 仅支持接口类型 低 简单键值,无需遍历
替换为 cache.Indexer ✅ Kubernetes 原生推荐 最低 生产控制器
graph TD
    A[Controller Reconcile Loop] --> B{并发读写 podCache map?}
    B -->|Yes| C[读到 stale value]
    B -->|No| D[使用 cache.Indexer]
    C --> E[Status patch 时间戳异常]
    D --> F[Watch+Index 保证一致性]

3.3 多reconciler并发调用导致etcd写入序列错乱的链路追踪

数据同步机制

Kubernetes Controller Manager 启动时可配置多个 --concurrent-*-syncs,导致同一资源类型被多个 Reconciler 实例并发处理。每个 Reconciler 独立执行 Get → Modify → Update 流程,无跨实例序列协调。

关键竞态路径

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    obj := &appsv1.Deployment{}
    if err := r.Get(ctx, req.NamespacedName, obj); err != nil { /* ... */ }
    obj.Spec.Replicas = pointer.Int32(3) // 修改字段
    return ctrl.Result{}, r.Update(ctx, obj) // 直接写入 etcd
}

⚠️ r.Get 与 r.Update 之间无乐观锁校验(ResourceVersion 未比对),并发更新将覆盖彼此变更,破坏期望状态序列。

etcd 写入时序对比

场景 Reconciler A 写入顺序 Reconciler B 写入顺序 最终 etcd 状态
无锁并发 RV=100 → RV=102 RV=100 → RV=101 RV=101(B 覆盖 A)

核心修复策略

  • 强制启用 UpdateWithAdmissionControl + ServerSideApply
  • 在 Reconcile 中注入 PatchType: types.ApplyPatchType 与 FieldManager
  • 使用 controller-runtime v0.16+ 的 Client.SubResource("status").Patch() 分离状态写入
graph TD
    A[Reconciler A] -->|Read RV=100| E[etcd]
    B[Reconciler B] -->|Read RV=100| E
    A -->|Update RV=102| E
    B -->|Update RV=101| E
    E -->|最终保留| C[RV=101]

第四章:高可靠状态管理的工程化替代方案

4.1 sync.Map在低频更新高频读场景下的性能陷阱与压测对比

数据同步机制

sync.Map 采用读写分离 + 懒惰迁移策略:读操作优先访问 read(无锁原子映射),写操作若命中只读映射则尝试 CAS 更新;否则升级至 dirty(带互斥锁的 map),并触发后续 misses 计数驱动的提升迁移。

压测关键发现

  • 高并发只读下,sync.Map.Load 比 map[interface{}]interface{} + RWMutex 快约 3.2×;
  • 但仅每 1000 次读插入 1 次写(即 0.1% 更新率)时,sync.Map 因 misses 累积触发频繁 dirty 提升,吞吐下降 27%。

对比基准(16 线程,10M 操作/轮)

实现方式 QPS 平均延迟 (μs) GC 次数
map + RWMutex 9.8M 1.62 12
sync.Map 7.1M 2.25 41
// 压测中触发迁移的关键路径
func (m *Map) missLocked() {
    m.misses++ // 无原子操作,依赖锁保护
    if m.misses < len(m.dirty) { // 阈值非固定,依赖 dirty 大小
        return
    }
    m.read.Store(&readOnly{m: m.dirty}) // 全量复制,O(n)
    m.dirty = make(map[interface{}]*entry)
    m.misses = 0
}

该函数在每次未命中且锁持有期间执行,len(m.dirty) 动态变化导致迁移时机不可预测;全量复制 dirty 到 read 在大 map 下引发显著停顿与内存分配压力。

4.2 基于RWMutex+结构体封装的细粒度状态同步实践

数据同步机制

传统全局互斥锁(sync.Mutex)在高读低写场景下易成性能瓶颈。改用 sync.RWMutex 可允许多读并发,仅写操作独占,显著提升吞吐。

封装设计要点

  • 将状态字段与读写锁内聚封装为结构体
  • 暴露 Get() / Update() 等语义化方法,隐藏同步细节
  • 避免外部直接访问原始字段或锁

示例:用户会话状态管理

type SessionState struct {
    mu sync.RWMutex
    onlineUsers map[string]int64 // 用户ID → 登录时间戳
    totalViews  uint64
}

func (s *SessionState) GetOnlineCount() int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return len(s.onlineUsers) // 仅读,无写竞争
}

func (s *SessionState) RecordView() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.totalViews++
}

逻辑分析:GetOnlineCount() 使用 RLock() 支持任意数量 goroutine 并发调用;RecordView() 使用 Lock() 保证计数原子性。onlineUsers 未暴露指针,杜绝外部误修改。

方法 锁类型 并发安全 典型调用频次
GetOnlineCount 读锁 ✅ 高(每秒千级)
RecordView 写锁 ✅ 中(每秒百级)
graph TD
    A[goroutine A] -->|Read| B(RWMutex.RLock)
    C[goroutine B] -->|Read| B
    D[goroutine C] -->|Write| E(RWMutex.Lock)
    B -->|共享进入| F[返回在线数]
    E -->|独占进入| G[更新totalViews]

4.3 使用k8s.io/client-go/tools/cache.Store实现声明式状态抽象

Store 是 client-go 中核心的内存状态抽象层,提供对 Kubernetes 资源对象的增删改查与事件通知能力,是 Informer 底层状态同步的基础。

数据同步机制

Store 接口定义了线程安全的 CRUD 操作,不依赖网络,仅管理本地缓存快照:

type Store interface {
    Add(obj interface{}) error
    Update(obj interface{}) error
    Delete(obj interface{}) error
    List() []interface{}
    Get(obj interface{}) (interface{}, bool, error)
    // ...
}

Add/Update/Delete 接收任意 runtime.Object(需实现 Meta() 方法);List() 返回浅拷贝切片,调用方不可修改内部状态。

核心实现:cache.Store

实际常用 cache.NewStore(cache.MetaNamespaceKeyFunc) 创建实例。键生成函数决定索引粒度(如按 namespace/name 唯一标识)。

方法 并发安全 作用
Add ✅ 插入新对象,触发 OnAdd 回调
Replace ✅ 全量刷新,用于初始同步
Index ✅ 支持自定义索引器(如按 label 查询)
graph TD
    A[API Server] -->|List/Watch| B(Informer)
    B --> C[DeltaFIFO]
    C --> D[Pop → Process]
    D --> E[Store.Add/Update/Delete]
    E --> F[Local Cache]

4.4 Operator SDK v2中Controller-runtime Manager内置状态管理机制迁移指南

Operator SDK v2 将 Manager 的状态生命周期完全交由 controller-runtime 统一托管,废弃了 v1 中手动调用 Start()/Stop() 的显式控制模式。

状态自动协调机制

Manager 启动后自动注册 Runnable 并按依赖顺序启动:

  • Webhook server
  • Cache(带事件过滤器)
  • Controllers(含 Reconciler)
  • Health probes

迁移关键变更

  • ✅ 移除 mgr.Start(ctx) 手动调用,改用 mgr.ElectedRun(ctx) 或直接 mgr.Start(ctx)(内置信号监听)
  • ✅ 使用 mgr.Add(Runnable) 注册自定义状态组件(如指标收集器)
  • ❌ 不再支持 mgr.SetFields(...) 多次覆盖;需在 NewManager 时一次性配置

核心代码迁移示例

// v1(已弃用)
mgr := manager.New(...)
mgr.Add(&myRunnable{})
mgr.Start(ctx) // 显式启动,易遗漏信号处理

// v2(推荐)
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme:                 scheme,
    MetricsBindAddress:     ":8080",
    LeaderElection:         true,
    LeaderElectionID:       "example-operator",
    HealthProbeBindAddress: ":8081",
})
if err != nil {
    panic(err)
}
if err := mgr.Add(&myRunnable{}); err != nil { // 自动纳入生命周期
    panic(err)
}
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { // 内置 SIGTERM/SIGINT 处理
    panic(err)
}

逻辑分析:ctrl.SetupSignalHandler() 返回一个监听 os.Interrupt 和 syscall.SIGTERM 的 context.Context;mgr.Start() 内部按拓扑顺序启动所有 Runnable,并在收到信号时调用各组件的 Stop() 方法。Add() 注册的组件必须实现 controller.Runnable 接口(含 Start(ctx) 和可选 NeedLeaderElection())。

生命周期状态流转(mermaid)

graph TD
    A[NewManager] --> B[Ready]
    B --> C{LeaderElection?}
    C -->|Yes| D[LeaseAcquired → Running]
    C -->|No| E[Running]
    D --> F[SignalReceived]
    E --> F
    F --> G[GracefulShutdown]
    G --> H[All Runnables Stop]

第五章:总结与展望

核心技术栈的落地验证

在某省级政务云迁移项目中,我们基于本系列实践方案完成了 127 个遗留 Java Web 应用的容器化改造。所有服务均采用 Spring Boot 2.7 + OpenJDK 17 运行时,通过 Helm Chart 统一编排部署至 Kubernetes v1.25 集群。实测数据显示:平均启动耗时从传统虚拟机模式的 83 秒降至 4.2 秒;资源利用率提升 3.6 倍(CPU 平均占用率由 12% 升至 43%,内存碎片率下降至 5.8%);滚动更新成功率稳定在 99.97%(连续 30 天监控数据)。关键指标对比如下:

指标项 改造前(VM) 改造后(K8s) 提升幅度
单实例部署耗时 83.2 s 4.2 s ↓94.9%
故障自愈平均耗时 142 s 8.7 s ↓93.9%
日志采集延迟 3.2 s 127 ms ↓96.0%

生产环境灰度策略实施细节

采用 Istio 1.18 的流量切分能力构建三级灰度通道:canary-v1(5% 流量)、canary-v2(20% 流量)、stable(75% 流量)。每个版本绑定独立 Prometheus 监控看板,当 v2 版本的 5xx 错误率突破 0.3% 或 P99 延迟超过 850ms 时,自动触发 Argo Rollouts 的回滚流程。2023 年 Q3 共执行 47 次灰度发布,其中 3 次因熔断机制介入实现零用户感知回退。

多云异构基础设施适配实践

在混合云场景中,同一套 Terraform 代码库通过变量注入实现跨平台部署:Azure 上启用 azurerm_kubernetes_cluster 模块并配置 AAD 集成;AWS 环境切换为 aws_eks_cluster 并启用 IRSA;本地测试环境则调用 kind_cluster 模块生成 KinD 集群。所有模块共享统一的 cluster_config.yaml 配置文件,通过 yamldecode(file("cluster_config.yaml")) 动态加载参数,使基础设施即代码(IaC)复用率达 91.3%。

graph LR
    A[GitLab CI Pipeline] --> B{环境标识}
    B -->|prod-aws| C[AWS EKS Cluster]
    B -->|prod-azure| D[Azure AKS Cluster]
    B -->|staging-kind| E[KinD Cluster]
    C --> F[Argo CD Sync]
    D --> F
    E --> F
    F --> G[应用健康检查]
    G --> H[Prometheus AlertManager]

安全合规性强化措施

在金融客户项目中,通过 OPA Gatekeeper 实现 Kubernetes 准入控制:禁止使用 hostNetwork: true 的 Pod、强制镜像签名验证(Cosign)、限制特权容器创建。结合 Falco 实时检测异常行为,成功拦截 17 起横向移动尝试(如 /proc/self/environ 文件读取、非标准端口连接)。所有审计日志经 Fluent Bit 过滤后推送至 ELK,满足等保 2.0 三级日志留存 180 天要求。

开发者体验持续优化路径

内部 DevOps 平台已集成 VS Code Remote-Containers 插件,开发者提交代码后自动生成开发沙箱环境(含预装 JDK 17/Node 18/Maven 3.9 及私有 Nexus 代理),平均环境准备时间压缩至 92 秒。下一阶段将接入 GitHub Codespaces 实现浏览器端 IDE,目标将新成员上手周期从 3.2 人日缩短至 0.7 人日。

守护服务器稳定运行,自动化是喵的最爱。

发表回复

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