Posted in

【Go面试算法压轴题库】:11道Google级难度题(含context取消传播+io.MultiReader组合应用)

第一章:Go面试算法压轴题库总览与能力图谱

Go语言面试中的算法压轴题并非单纯考察编码速度,而是系统性检验候选人对语言特性、数据结构本质、并发模型与工程边界的综合把握。题库覆盖五大核心维度:基础数据结构变形(如带删除的LFU缓存)、高并发场景建模(如限流器与分布式ID生成器)、边界鲁棒性设计(如大数加法与溢出安全的整数反转)、系统级抽象能力(如实现简易RPC序列化协议)以及Go原生机制深度运用(如利用unsafe优化内存布局或runtime接口调试goroutine泄漏)。

常见压轴题类型分布

题型类别 典型题目示例 Go特异性考点
并发控制 实现无锁环形缓冲区(RingBuffer) sync/atomic + 内存序语义
接口抽象 设计可插拔的Metrics上报模块 空接口泛型约束与io.Writer组合
性能敏感实现 零拷贝JSON路径查询(gjson简化版) unsafe.String() + 字节切片视图

关键能力映射关系

  • 内存管理能力:需能手写对象池复用结构体(避免GC压力),例如在高频日志采样中复用LogEntry实例;
  • 错误处理范式:拒绝if err != nil { return err }链式堆叠,应使用errors.Join聚合多点失败,或通过fmt.Errorf("xxx: %w", err)保留原始调用栈;
  • 测试驱动验证:压轴题必须附带Benchmark函数与Test断言,例如:
func BenchmarkLRUCache_Get(b *testing.B) {
    c := NewLRUCache(1024)
    for i := 0; i < b.N; i++ {
        c.Get(i % 1024) // 触发热点访问模式
    }
}

该基准测试强制暴露缓存哈希冲突与锁竞争问题,是评估实现质量的硬性门槛。

第二章:高并发场景下的Context取消传播深度剖析

2.1 Context取消机制的底层原理与状态机模型

Context取消机制本质是基于原子状态跃迁的协作式中断模型,其核心由done通道、err字段和mu互斥锁共同维护。

状态机关键状态

  • active:初始态,可被取消
  • canceled:已收到取消信号,done已关闭
  • closed:资源已清理完毕(非标准状态,由用户扩展)

状态迁移约束

当前状态 触发动作 目标状态 是否允许
active cancel() canceled
canceled cancel() canceled ✅(幂等)
canceled close() closed
// cancel unlocks & closes done channel atomically
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil { // 已取消,直接返回
        c.mu.Unlock()
        return
    }
    c.err = err
    close(c.done) // 原子广播:所有 <-c.Done() 立即返回
    c.mu.Unlock()
}

该函数确保err写入与done关闭的原子性;c.erratomic.Value语义,供下游安全读取错误原因;close(c.done)触发所有监听协程退出阻塞。

graph TD
    A[active] -->|cancel()| B[canceled]
    B -->|close()| C[closed]
    B -->|cancel() again| B

2.2 cancelCtx、timerCtx与valueCtx的协同取消路径实践

三类 Context 的职责边界

  • cancelCtx:提供显式取消能力,维护 children 集合与 done channel
  • timerCtx:封装 cancelCtx,支持超时自动触发 cancel()
  • valueCtx:不参与取消,仅透传键值对,但可嵌套于任意取消链中

协同取消流程(mermaid)

graph TD
    A[Root cancelCtx] --> B[timerCtx with 3s]
    B --> C[valueCtx with requestID]
    C --> D[cancelCtx for DB query]
    D --> E[done channel closed on timeout]

嵌套取消示例

root, cancel := context.WithCancel(context.Background())
defer cancel()

timerCtx, timerCancel := context.WithTimeout(root, 3*time.Second)
valCtx := context.WithValue(timerCtx, "traceID", "req-789")
dbCtx, _ := context.WithCancel(valCtx) // 可被 timerCtx 自动取消

// 当 timerCtx 超时,valCtx 和 dbCtx 的 done channel 同步关闭

逻辑分析:timerCtx 内部持有 cancelCtx 实例;超时触发其 cancel(),递归通知所有 children(含 valCtx 的子 cancelCtx)。valueCtx 本身无 cancel 方法,但 Done() 方法委托给父 Context,实现零开销透传取消信号。

2.3 多goroutine嵌套调用中cancel信号的精准传播与泄漏规避

核心挑战:Cancel链断裂与goroutine泄漏

ctx.WithCancel(parent) 创建子上下文后,若父上下文未被显式取消,或子goroutine未监听 ctx.Done(),则 cancel 信号无法穿透多层嵌套,导致 goroutine 永久阻塞。

正确传播模式

必须确保每个嵌套层级均接收并转发 ctx,且所有 I/O 或循环操作都以 select { case <-ctx.Done(): ... } 统一退出:

func worker(ctx context.Context, id int) {
    childCtx, cancel := context.WithCancel(ctx) // 继承取消链
    defer cancel() // 防止子ctx泄漏
    go func() {
        select {
        case <-childCtx.Done():
            log.Printf("worker %d exited cleanly", id)
        }
    }()
}

逻辑分析childCtx 绑定父 ctx.Done()cancel() 调用触发级联关闭;defer cancel() 避免子上下文资源滞留。若省略 defer cancel(),子 ctx 的 done channel 将永不释放,造成内存泄漏。

常见泄漏场景对比

场景 是否监听 ctx.Done() 是否调用 defer cancel() 是否泄漏
✅ 正确嵌套
❌ 忘记 defer cancel 是(子 ctx channel)
❌ 仅父 ctx 取消 是(子 goroutine 卡死)

Cancel传播拓扑

graph TD
    A[main goroutine] -->|ctx.WithCancel| B[serviceA]
    B -->|pass ctx| C[worker1]
    B -->|pass ctx| D[worker2]
    C -->|ctx.WithTimeout| E[http call]
    D -->|ctx.WithDeadline| F[db query]
    A -.->|Cancel invoked| B
    B -.->|propagates| C & D
    C -.->|cascades| E
    D -.->|cascades| F

2.4 基于context.WithCancel/WithTimeout的算法题重构实战(如超时终止DFS搜索)

超时控制的必要性

在大规模图遍历(如网格中找最长路径)中,DFS可能陷入深递归或环路,导致响应不可控。硬编码maxDepth无法应对动态负载,需引入上下文取消机制。

使用 context.WithTimeout 重构 DFS

func dfsWithTimeout(ctx context.Context, grid [][]int, i, j, steps int) (int, error) {
    select {
    case <-ctx.Done():
        return 0, ctx.Err() // 超时或取消时立即退出
    default:
    }

    if i < 0 || i >= len(grid) || j < 0 || j >= len(grid[0]) || grid[i][j] == 0 {
        return steps, nil
    }

    grid[i][j] = 0 // 标记已访问
    maxSteps := steps
    for _, d := range [][]int{{-1,0},{1,0},{0,-1},{0,1}} {
        nextI, nextJ := i+d[0], j+d[1]
        if res, err := dfsWithTimeout(ctx, grid, nextI, nextJ, steps+1); err == nil {
            if res > maxSteps { maxSteps = res }
        }
    }
    grid[i][j] = 1 // 回溯(若需复用grid)
    return maxSteps, nil
}

逻辑分析ctx.Done() 非阻塞监听取消信号;每次递归前检查,确保毫秒级响应。WithTimeout(parent, 500*time.Millisecond) 可在主调用处创建带截止时间的上下文。参数 ctx 是传播取消信号的载体,steps 为当前路径长度,无共享状态,线程安全。

对比策略一览

方式 可中断性 状态清理能力 适用场景
time.AfterFunc 简单定时任务
select{case <-time.C} ⚠️(需手动传递 channel) 中等 单层循环
context.WithTimeout 强(自动传播) 递归/多 goroutine 协作

2.5 生产级Cancel传播测试:使用Goroutine泄露检测+pprof验证取消完整性

场景复现:未正确传播 cancel 的典型泄漏

以下代码模拟一个未响应 context 取消的 goroutine:

func leakyWorker(ctx context.Context) {
    go func() {
        select {
        case <-time.After(10 * time.Second): // ❌ 忽略 ctx.Done()
            fmt.Println("work done")
        }
    }()
}

逻辑分析:time.After 不受 ctx 控制,即使父 context 被 cancel,该 goroutine 仍运行满 10 秒,造成泄漏。关键参数:time.After 返回独立 timer channel,与 ctx.Done() 无关联。

验证手段组合拳

  • 使用 runtime.NumGoroutine() 基线比对(启动前/取消后)
  • pprof/goroutine?debug=2 抓取阻塞栈快照
  • go tool pprof -http=:8080 cpu.pprof 可视化活跃协程

检测流程(mermaid)

graph TD
    A[启动服务并记录goroutine数] --> B[触发cancel]
    B --> C[等待2s稳定期]
    C --> D[采集pprof/goroutine]
    D --> E[过滤含“select”且无ctx.Done的栈帧]
指标 正常值 泄漏信号
NumGoroutine() delta ≤ 2 ≥ 5 持续增长
pprof/goroutineselect 栈深度 ≤ 3 深度 ≥ 5 + time.Sleep

第三章:IO组合范式在算法解题中的工程化应用

3.1 io.MultiReader的接口契约与流式数据拼接原理

io.MultiReader 是 Go 标准库中实现 io.Reader 接口的组合型读取器,其核心契约是:按顺序串联多个 io.Reader,前一个读尽后自动切换至下一个,直至全部耗尽或遇错

数据同步机制

它不缓冲、不预读、不并发,仅维护当前 reader 索引与偏移,每次 Read(p []byte) 调用均委托给当前活跃 reader。

接口实现要点

  • 必须满足 io.Reader 签名:func (r *MultiReader) Read(p []byte) (n int, err error)
  • 切换逻辑隐含在返回 io.EOF 后的内部索引递增
// 构造 MultiReader 示例
r := io.MultiReader(
    strings.NewReader("Hello"),
    strings.NewReader(" "),
    strings.NewReader("World!"),
)

此代码创建一个逻辑上连续的 "Hello World!" 流;Read 调用将依次从三个 strings.Reader 中拉取数据,无拷贝、无中间分配。

特性 行为
错误传播 首个非 io.EOF 错误立即返回,不尝试后续 reader
EOF 处理 单个 reader 返回 io.EOF 时自动切换,仅当所有 reader 均 EOF 才返回全局 EOF
并发安全 ❌ 不保证,需外部同步
graph TD
    A[Read call] --> B{Current reader available?}
    B -->|Yes| C[Delegate to current reader]
    C --> D{Returns EOF?}
    D -->|Yes| E[Advance to next reader]
    D -->|No| F[Return n, err]
    E --> G{Next exists?}
    G -->|Yes| B
    G -->|No| H[Return EOF]

3.2 利用MultiReader实现“多源输入合并”的算法题解法(如归并K个有序流)

核心思想

MultiReader 封装多个有序输入源(如 Iterator<Integer>),通过最小堆动态维护各流首元素,实现 O(log K) 时间复杂度的逐个取最小值。

关键数据结构对比

组件 作用 时间复杂度
PriorityQueue<ReaderNode> 维护K个流当前最小候选 插入/弹出:O(log K)
ReaderNode 包含值、所属流索引、迭代器引用

示例代码(Java)

class MultiReader {
    private PriorityQueue<ReaderNode> heap;
    private List<Iterator<Integer>> readers;

    public MultiReader(List<Iterator<Integer>> readers) {
        this.readers = readers;
        this.heap = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
        // 初始化:每个流推入首个有效元素
        for (int i = 0; i < readers.size(); i++) {
            if (readers.get(i).hasNext()) {
                heap.offer(new ReaderNode(readers.get(i).next(), i, readers.get(i)));
            }
        }
    }

    public Integer next() {
        if (heap.isEmpty()) return null;
        ReaderNode top = heap.poll();
        // 若该流仍有后续元素,推入下一个
        if (top.iterator.hasNext()) {
            heap.offer(new ReaderNode(top.iterator.next(), top.idx, top.iterator));
        }
        return top.val;
    }
}

逻辑分析:构造时预热堆,确保每流至少贡献一个候选;next() 每次取出全局最小,并立即补充同源下一元素,维持堆规模 ≤ K。参数 idx 用于调试溯源,iterator 复用避免重复创建。

数据同步机制

  • 所有流独立推进,无锁协作
  • 堆中节点生命周期与单次 next() 绑定,内存友好
graph TD
    A[初始化K个流首元素] --> B[建最小堆]
    B --> C{调用next?}
    C -->|是| D[弹出堆顶→输出]
    D --> E[对应流取下值→入堆]
    E --> C

3.3 MultiReader + io.TeeReader + bytes.Reader 构建可回溯输入流的实战演练

在处理动态拼接、审计与重放需求时,单一 io.Reader 往往无法兼顾“多源读取”“边读边存”和“反复读取”三重能力。此时需组合标准库组件构建复合流。

核心组件职责

  • bytes.Reader: 提供可重复读取的底层字节源(支持 Seek(0, 0)
  • io.TeeReader: 在读取时同步写入 io.Writer(如 bytes.Buffer),实现流量镜像
  • io.MultiReader: 按序串联多个 Reader,模拟分段输入流

实战代码示例

var buf bytes.Buffer
src1 := bytes.NewReader([]byte("hello"))
src2 := bytes.NewReader([]byte(" world"))
multi := io.MultiReader(src1, src2)
tee := io.TeeReader(multi, &buf) // 读取时自动追加到 buf

data, _ := io.ReadAll(tee)
fmt.Printf("read: %s, mirrored: %s\n", data, buf.String())
// 输出:read: hello world, mirrored: hello world

逻辑分析MultiReader 将两个 bytes.Reader 串成单一流;TeeReader 在每次 Read() 时,先从 multi 读取,再将字节写入 buf;最终 buf 完整保存原始内容,支持后续 bytes.NewReader(buf.Bytes()) 回溯。

组件能力对比表

组件 可Seek 可重复读 边读边存 适用场景
bytes.Reader 静态数据、需回溯
io.TeeReader 审计、日志、调试镜像
io.MultiReader 多源拼接、协议分段解析
graph TD
    A[bytes.Reader] -->|提供底层可Seek数据| B[io.MultiReader]
    B -->|按序聚合| C[io.TeeReader]
    C -->|读取+镜像| D[bytes.Buffer]
    D -->|构造新Reader| E[回溯读取]

第四章:Google级综合算法题精解(含Context+IO双范式融合)

4.1 题目01:带上下文取消的分布式任务调度器模拟(含deadline传播与worker优雅退出)

核心设计原则

  • context.WithDeadline 实现跨节点 deadline 逐跳衰减传播
  • Worker 监听 ctx.Done() 并完成当前任务后退出,避免中断中状态
  • 调度器主动向 worker 发送 CANCEL 协议帧,触发协作式终止

关键代码片段

// 构建带传播延迟的子上下文(预留100ms网络抖动余量)
childCtx, cancel := context.WithDeadline(parentCtx, time.Now().Add(deadline.Sub(time.Now()).Add(-100*time.Millisecond)))
defer cancel()

// 启动任务并监听退出信号
go func() {
    select {
    case <-childCtx.Done():
        log.Printf("task cancelled: %v", childCtx.Err()) // 可能为 DeadlineExceeded 或 Canceled
        return
    case <-taskCompleteCh:
        log.Println("task finished gracefully")
    }
}()

逻辑分析WithDeadline 确保子任务截止时间早于父任务,预留网络传输与处理开销;select 非阻塞响应取消信号,保障 worker 不丢任务。

状态迁移表

当前状态 触发事件 下一状态 动作
Running ctx.Done() Draining 停止接收新任务,完成队列中任务
Draining taskCompleteCh Idle 通知调度器可安全下线

流程示意

graph TD
    S[Scheduler] -->|WithDeadline| W[Worker]
    W -->|taskStart| T[Task Execution]
    S -->|CANCEL signal| W
    W -->|on Done| G[Graceful Exit]

4.2 题目04:基于MultiReader的多协议日志流合并与实时Top-K统计

核心架构设计

MultiReader 抽象层统一接入 Syslog、JSON-HTTP、Protobuf gRPC 三类日志源,通过协议适配器完成字段对齐(如 timestamp, level, service_id, trace_id)。

数据同步机制

  • 每个协议 Reader 独立线程拉取,共享环形缓冲区(RingBuffer)避免锁竞争
  • 时间戳归一化至毫秒级 UTC,触发下游 Flink Watermark 生成

实时 Top-K 统计逻辑

// 使用 KeyedProcessFunction 实现滑动窗口 Top-K(K=10)
public class TopKCounter extends KeyedProcessFunction<String, LogEvent, List<RankItem>> {
    private final int k;
    private ValueState<Map<String, Long>> countState; // service_id → count
    private ValueState<Long> lastEmitTs;

    @Override
    public void processElement(LogEvent value, Context ctx, Collector<List<RankItem>> out) {
        Map<String, Long> counts = countState.value();
        if (counts == null) counts = new HashMap<>();
        counts.merge(value.serviceId, 1L, Long::sum);
        countState.update(counts);

        // 每5秒触发一次 Top-K 排序(非精确,但低延迟)
        long now = ctx.timerService().currentProcessingTime();
        if (lastEmitTs.value() == null || now - lastEmitTs.value() > 5000) {
            lastEmitTs.update(now);
            ctx.timerService().registerProcessingTimeTimer(now + 5000);
            List<RankItem> topK = counts.entrySet().stream()
                .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
                .limit(k).map(e -> new RankItem(e.getKey(), e.getValue()))
                .collect(Collectors.toList());
            out.collect(topK);
        }
    }
}

逻辑分析:该实现规避了状态全量排序开销,采用“懒更新+定时快照”策略;countState 存储服务维度计数,lastEmitTs 控制输出频率;registerProcessingTimeTimer 保证严格周期性触发,适用于监控告警等场景。

协议兼容性对比

协议类型 解析延迟(P95) 字段对齐成本 流控支持
Syslog 中(正则提取) ✅(TCP背压)
JSON-HTTP 低(Jackson) ✅(HTTP/2流)
Protobuf gRPC 高(需IDL绑定) ✅(内置流控)

执行流程示意

graph TD
    A[Syslog Reader] --> D[MultiReader Merge]
    B[JSON-HTTP Reader] --> D
    C[gRPC Reader] --> D
    D --> E[Schema Normalization]
    E --> F[Time-based Windowing]
    F --> G[TopKCounter]
    G --> H[Result Sink]

4.3 题目07:Context感知的异步BFS最短路径求解(支持中途取消+结果流式返回)

核心设计思想

将传统BFS改造为 IAsyncEnumerable<(int distance, Node node)> 流式生成器,结合 CancellationToken 实现毫秒级响应式取消。

关键实现片段

public async IAsyncEnumerable<(int dist, Node n)> StreamBfsAsync(
    Node start, 
    CancellationToken ct = default)
{
    var queue = new Queue<(Node, int)>();
    var visited = new HashSet<Node>();
    queue.Enqueue((start, 0));
    visited.Add(start);

    while (queue.Count > 0 && !ct.IsCancellationRequested)
    {
        var (node, dist) = queue.Dequeue();
        yield return (dist, node); // 流式推送当前层节点

        foreach (var neighbor in node.Neighbors)
        {
            if (visited.Add(neighbor))
                queue.Enqueue((neighbor, dist + 1));
        }
        await Task.Yield(); // 让出控制权,支持取消检测
    }
}

逻辑分析yield return 实现逐层结果推送;await Task.Yield() 确保每次循环后检查 ctvisited.Add() 原子性保障线程安全。参数 ct 由调用方注入,支持 UI 交互或超时中断。

取消响应性能对比

场景 平均响应延迟 资源释放及时性
无取消检测 ❌ 挂起至完成
ct.IsCancellationRequested 检查点 2.1 ms ✅ 即时终止

4.4 题目11:全链路Cancel+MultiReader+io.Pipe构建的管道化图计算引擎

图计算引擎需在动态拓扑中实时中断冗余计算。核心是三重协同机制:

  • 全链路 Cancelcontext.WithCancel 向下游传播终止信号,避免 goroutine 泄漏
  • MultiReader:复用 io.MultiReader 聚合多个子图计算结果流
  • io.Pipe:零拷贝连接生产者(图遍历)与消费者(聚合器)
pr, pw := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
go func() {
    defer pw.Close()
    traverseGraph(ctx, pw, graph) // 遇 ctx.Done() 自动退出
}()

逻辑分析:pr/pw 构成内存管道;traverseGraph 持续写入边数据,一旦 cancel() 调用,ctx.Err() 触发提前返回,pw.Close() 通知 pr EOF。

数据流协同示意

graph TD
    A[图遍历协程] -->|io.Pipe.Writer| B[Pipe]
    B -->|io.Pipe.Reader| C[MultiReader]
    C --> D[并行聚合器]
    E[Cancel信号] --> A
    E --> C
组件 关键作用
io.Pipe 内存级流式桥接,无缓冲区拷贝
MultiReader 合并多子图结果流,统一读取接口
context 跨 goroutine、跨阶段信号广播

第五章:从面试题到生产代码的思维跃迁

面试题里的“完美解法”往往缺三样东西

LeetCode 上双指针反转链表只需 12 行,但真实服务中你要处理:空指针异常(上游协议未约定 null 安全)、内存泄漏(Golang defer 未配对)、并发修改(多个 goroutine 同时调用该方法)。某电商订单服务曾因直接复用算法题代码,在大促期间出现 37% 的 panic: runtime error: invalid memory address 错误——根源是面试代码里那行 head.Next = reverse(head.Next) 在高并发下触发了竞态读写。

日志不是装饰品,而是调试契约

生产环境必须强制植入结构化日志边界。对比以下两段代码:

// ❌ 面试风格(无上下文)
func findUser(id int) *User {
    return db.Query("SELECT * FROM users WHERE id = ?", id)
}

// ✅ 生产风格(带可观测性契约)
func findUser(ctx context.Context, id int) (*User, error) {
    log := zerolog.Ctx(ctx).With().Int("user_id", id).Logger()
    log.Info().Msg("findUser start")
    defer log.Info().Msg("findUser end")

    if id <= 0 {
        log.Warn().Msg("invalid user_id")
        return nil, errors.New("invalid id")
    }

    user, err := db.QueryRowContext(ctx, "SELECT id,name,email FROM users WHERE id = $1", id)
    if err != nil {
        log.Error().Err(err).Msg("db query failed")
        return nil, err
    }
    // ... 
}

配置驱动而非硬编码的防御边界

某支付网关曾将超时阈值 3s 写死在代码里,导致灰度发布时无法动态调整。改造后采用配置中心驱动:

配置项 开发环境 预发环境 生产环境
http_timeout_ms 5000 3000 1500
retry_max_attempts 3 2 1
circuit_breaker_threshold 0.8 0.95 0.99

配合 Apollo 配置监听器,变更 200ms 内生效,避免每次发版重启服务。

错误分类决定恢复策略

面试题只返回 nilerror,而生产系统需区分三类错误:

  • Transient Errors(网络抖动)→ 指数退避重试(最多 3 次)
  • Business Errors(余额不足)→ 返回明确业务码 ERR_INSUFFICIENT_BALANCE
  • Fatal Errors(数据库连接池耗尽)→ 熔断 + 上报 Prometheus alert{severity="critical"}

单元测试必须覆盖边界爆炸点

针对 calculateDiscount(amount, coupon) 方法,面试代码只测 amount=100, coupon="SUMMER20",而生产测试需覆盖:

  • amount=0.0001(浮点精度陷阱)
  • coupon="SUMMER20;EXPIRED"(注入式恶意输入)
  • amount=math.MaxFloat64(溢出导致负折扣)
flowchart TD
    A[用户提交订单] --> B{是否启用熔断?}
    B -->|是| C[返回降级价格]
    B -->|否| D[调用优惠计算服务]
    D --> E{响应延迟 > 800ms?}
    E -->|是| F[记录 SLO 违规事件]
    E -->|否| G[执行折扣逻辑]
    G --> H[验证最终价格 ≥ 0.01]

某外卖平台在春节流量高峰前,通过将面试题代码重构为带熔断、指标埋点、配置化超时的生产模块,使订单创建接口 P99 延迟从 2.4s 降至 312ms,错误率下降 98.7%。
服务上线后第 3 天,配置中心自动将预发环境重试次数从 2 次降为 1 次,暴露了下游支付 SDK 的幂等缺陷,推动对方在 48 小时内发布修复版本。

关注异构系统集成,打通服务之间的最后一公里。

发表回复

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