Posted in

为什么你的Go程序总在凌晨OOM?——基于pprof+trace+runtime/metrics的12小时故障根因溯源实录

第一章:为什么你的Go程序总在凌晨OOM?——基于pprof+trace+runtime/metrics的12小时故障根因溯源实录

凌晨3:17,告警突响:Kubernetes Pod因OOMKilled被强制终止。这不是偶发事件——过去7天内,同一服务在02:45–04:00区间共崩溃19次。我们放弃日志盲猜,启动三维度实时观测闭环:pprof抓取内存快照、runtime/trace回溯调度行为、runtime/metrics采集细粒度指标流。

快速定位内存泄漏点

在Pod存活窗口内执行:

# 获取实时堆快照(需提前启用 pprof HTTP handler)
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" > heap-before.log
sleep 300  # 等待内存增长窗口
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" > heap-after.log
# 对比差异(聚焦 top 10 alloc_space 增量)
go tool pprof -top10 heap-before.log heap-after.log

结果直指 vendor/github.com/segmentio/kafka-go.(*Conn).readLoop 中未释放的 []byte 缓冲区——每次心跳响应后残留 2.4MB,5小时累积达 4.3GB。

追踪goroutine生命周期异常

启用 trace 并采样关键时段:

curl -s "http://localhost:6060/debug/pprof/trace?seconds=120" > trace.out
go tool trace trace.out

在可视化界面中筛选 GC pausegoroutine creation 时间轴,发现每分钟新增 800+ goroutine,但仅 12% 被 runtime 正常回收——大量 kafka.(*Conn).readLoop goroutine 卡在 select{ case <-conn.done: } 阻塞,而 conn.done channel 永远未关闭。

实时验证指标漂移

直接读取 runtime 指标流:

import "runtime/metrics"
// 在健康检查端点中输出关键指标
m := metrics.Read(
    []metrics.Description{
        {Name: "/gc/heap/allocs:bytes"},
        {Name: "/gc/heap/objects:objects"},
        {Name: "/sched/goroutines:goroutines"},
    }...)
// 输出示例(凌晨03:00采集):
// /gc/heap/allocs:bytes → 3.2 GiB (2h +140%)
// /sched/goroutines:goroutines → 12,486 (峰值持续>90min)
指标 正常阈值 故障时段实测 偏差
/gc/heap/allocs:bytes 3.2 GiB +300%
/sched/goroutines 12,486 +2400%
GC pause avg 287ms +1813%

根本原因锁定:Kafka连接池未设置 MaxConnsPerHost,且连接异常关闭时 conn.done channel 泄漏,导致 readLoop goroutine 无限堆积并持续申请堆内存。修复方案:升级 kafka-go 至 v0.4.27+,显式调用 conn.Close() 并注入 context.Done 监听。

第二章:Go内存模型与OOM发生机理深度解析

2.1 Go runtime内存分配器(mheap/mcache/mspan)运行时行为图谱

Go 的内存分配器采用三级结构协同工作:mcache(每P私有缓存)、mspan(页级内存块)与 mheap(全局堆管理器)。

核心协作流程

// 简化版分配路径示意(源自runtime/malloc.go)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 1. 尝试从 mcache.alloc[cls] 获取已预切分的 span
    // 2. 若失败,从 mcentral 获取新 span 并缓存到 mcache
    // 3. 若 mcentral 空,向 mheap 申请新页并切分为 span
}

该路径体现局部性优先→中心协调→全局兜底的层级调度逻辑;size 决定 size class(cls),影响 span 大小与对齐方式。

关键组件职责对比

组件 作用域 生命周期 同步机制
mcache per-P P 存活期 无锁(仅本P访问)
mspan 跨P共享 动态复用 atomic + mutex(mcentral)
mheap 全局 进程级 全局锁 + 原子操作

数据同步机制

mcentral 通过 spinlock 保护 nonempty/empty span 链表,避免多P争抢;mheap 使用 heapLock 控制页级映射(如 sysAlloc)。

graph TD
    A[goroutine malloc] --> B[mcache.alloc]
    B -->|hit| C[返回对象指针]
    B -->|miss| D[mcentral.get]
    D -->|span available| E[迁入 mcache]
    D -->|span exhausted| F[mheap.allocSpan]
    F --> G[映射物理页 → 切分 → 返回]

2.2 GC触发阈值、GOGC波动与凌晨低负载场景下的隐式内存膨胀实验

GOGC动态性与阈值漂移

Go 的 GOGC 并非固定阈值,而是基于上一次 GC 后的堆存活对象大小动态计算下次触发点:
next_gc = heap_live × (1 + GOGC/100)。当凌晨流量骤降,分配速率降低,但若存在长周期缓存或未释放的 sync.Pool 对象,heap_live 被“锚定”在高位,导致 GC 触发延迟。

隐式膨胀复现实验

以下代码模拟低负载下内存滞留:

var cache = make(map[string][]byte)
func init() {
    // 预热:注入 50MB 内存(GC 后仍存活)
    for i := 0; i < 1000; i++ {
        cache[fmt.Sprintf("key-%d", i)] = make([]byte, 50*1024)
    }
}
func handler(w http.ResponseWriter, r *http.Request) {
    // 凌晨仅偶发请求,无新分配,但 cache 持有引用 → heap_live 不降
    w.WriteHeader(200)
}

▶️ 逻辑分析cache 全局变量持续持有内存引用,heap_live 维持在 ~50MB。若 GOGC=100,则下次 GC 触发点为 50MB × 2 = 100MB;但因无新分配,实际堆增长极慢,GC 延迟数小时——期间 RSS 持续高于预期。

关键观测指标对比

场景 平均 GC 间隔 RSS 峰值 heap_live 稳态
高负载(1k QPS) 3s 120MB 60MB
凌晨低负载 47min 185MB 58MB

GC 触发延迟链路

graph TD
    A[凌晨请求锐减] --> B[分配速率↓]
    B --> C[heap_live 变化趋近于0]
    C --> D[GOGC 计算式失效:next_gc ≈ heap_live × 2]
    D --> E[无新分配 → next_gc 永不达标]
    E --> F[OS RSS 不回收 → 隐式膨胀]

2.3 goroutine泄漏、finalizer堆积与sync.Pool误用导致的不可回收内存实测复现

goroutine泄漏:阻塞通道未关闭

func leakyWorker(ch <-chan int) {
    for range ch { /* 永不退出 */ } // ch 无缓冲且永不关闭 → goroutine 永驻
}
// 启动后无法被GC回收,持续占用栈内存(默认2KB起)

该goroutine因等待已无发送者的通道而永久阻塞,运行时无法标记为可终止,形成泄漏。

finalizer堆积:注册未清理

for i := 0; i < 10000; i++ {
    obj := &struct{ x [1024]byte }{}
    runtime.SetFinalizer(obj, func(*struct{ x [1024]byte }) { /* do nothing */ })
}
// finalizer队列持续增长,对象无法入堆free list,触发STW延迟

sync.Pool误用对比

场景 是否触发泄漏 原因
存储含闭包/指针的临时对象 Pool 持有引用,阻止GC
复用后未清空字段 隐式延长底层对象生命周期
纯值类型(如[]byte切片) 安全,Pool自动管理
graph TD
    A[对象分配] --> B{sync.Pool.Put?}
    B -->|是| C[加入私有/共享池]
    B -->|否| D[直接等待GC]
    C --> E[下次Get可能复用]
    E --> F[若含未置零指针→强引用残留]

2.4 mmap/vmmap内存映射碎片化分析:从/proc/pid/smaps到go tool pprof –alloc_space的交叉验证

内存映射碎片化常表现为大量小块 mmap 区域分散在虚拟地址空间,难以被内核合并回收。

/proc/pid/smaps 关键字段解析

# 查看某 Go 进程的映射碎片特征(单位:kB)
grep -A 1 "mmapped" /proc/12345/smaps | tail -n +2
# 输出示例:
# Size:                4 kB
# MMUPageSize:         4 kB
# Mapped:              4 kB
# MMUPageSize:         2048 kB  # 大页未启用 → 碎片风险高

MMUPageSize 持续为 4 kB 表明未启用透明大页(THP),小块 mmap 易堆积;MappedSize 高度一致说明无共享或预留冗余。

交叉验证:pprof 分配热点定位

go tool pprof --alloc_space binary http://localhost:6060/debug/pprof/heap

该命令捕获累计分配字节数,可定位高频小对象分配路径(如 runtime.mmap 调用栈),与 smapsmmapped 区域数量形成强关联。

工具 视角 碎片敏感度
/proc/pid/smaps 虚拟内存布局 ⭐⭐⭐⭐☆(直接可见区域数量/大小)
go tool pprof --alloc_space 分配行为溯源 ⭐⭐⭐☆☆(间接推断 mmap 触发点)

碎片化影响链

graph TD
    A[频繁小对象分配] --> B[触发 runtime.sysAlloc → mmap]
    B --> C[生成多个 4kB VMA]
    C --> D[TLB miss 增加 & 缺页开销上升]

2.5 runtime.MemStats关键字段语义辨析与生产环境采样陷阱(如Stats不原子、LastGC漂移)

数据同步机制

runtime.ReadMemStats() 采集的是快照式非原子视图:各字段在不同时间点读取,可能跨多个 GC 周期。例如 AllocTotalAlloc 可能来自不同内存屏障时刻。

关键字段语义陷阱

  • LastGC 是纳秒级时间戳,但不保证单调递增;若系统时钟回拨或 GC 暂停(如 STW 被抢占),该值可能漂移甚至倒退;
  • PauseNs 数组仅保留最近 256 次 GC 暂停时长,索引循环覆盖,NumGC 并不严格对应数组长度。
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Alloc: %v, LastGC: %v\n", stats.Alloc, time.Unix(0, int64(stats.LastGC)))

此代码输出的 LastGC 若直接用于计算 GC 间隔(如 now.Sub(lastGC)),在时钟跳变或高负载下会得出负值或异常大值,需结合 NumGC 与上一次采样比对验证有效性。

生产采样建议

  • 避免单次 ReadMemStats 做趋势判断;
  • 使用 expvarpprof/debug/pprof/heap?debug=1 获取带上下文的聚合视图;
  • 监控应基于滑动窗口内 NumGC 差值 + PauseNs 最大值双维度告警。
字段 是否实时更新 是否跨 GC 原子一致 典型误用场景
Alloc ❌(采样瞬时值) 误判内存泄漏
LastGC ❌(无锁读取) 计算间隔出现负数
NumGC ✅(自增计数器) 唯一可靠 GC 次数指标

第三章:三位一体观测体系构建:pprof + trace + runtime/metrics协同诊断

3.1 pprof heap/profile/cpu采样策略调优:采样率动态适配与SIGPROF干扰规避实践

采样率动态适配原理

Go 运行时通过 runtime.SetMutexProfileFractionruntime.SetBlockProfileRate 控制竞争/阻塞采样,而 CPU/heap 采样由 GODEBUG 环境变量或 pprof.StartCPUProfile 参数驱动。关键在于避免静态高采样率导致性能扰动。

SIGPROF 干扰规避实践

Linux 中 SIGPROFsetitimer(ITIMER_PROF) 触发,但频繁信号中断会加剧调度抖动。推荐在高负载服务中禁用默认 CPU profile,改用基于 perf_event_open 的低开销替代方案(如 go-perf)。

动态采样配置示例

// 启用自适应 heap 采样:仅当堆增长 >5% 时提升采样率
if delta := heapEnd - heapStart; float64(delta)/float64(heapStart) > 0.05 {
    runtime.MemProfileRate = 512 // 降低采样粒度(默认为 512KB/样本)
}

MemProfileRate=512 表示每分配 512 字节记录一次堆分配栈;值越小采样越密,但 GC 开销上升约 8–12%。

场景 推荐采样率 影响说明
生产稳态监控 4096 低开销,覆盖内存泄漏
内存暴涨诊断 64 高精度定位分配热点
CPU 密集型服务 禁用原生 profile,启用 perf-based 采集 避免 SIGPROF 抖动
graph TD
    A[启动时] --> B{QPS > 1k?}
    B -->|是| C[启用低频 heap 采样]
    B -->|否| D[启用中频 CPU profile]
    C --> E[检测到 alloc/s 增长 >30%]
    E --> F[临时提升 MemProfileRate 至 128]

3.2 trace可视化深度解读:goroutine生命周期异常、block/pprof阻塞链路与调度器延迟热力图定位

goroutine生命周期异常识别

go tool trace 中的 Goroutines 视图可直观暴露 Runnable → Running → GoSleep 异常跃迁。常见模式:

  • 长时间处于 Runnable 状态(等待调度器分配 P)
  • Running 后立即 GoBlock, 无用户逻辑执行

block/pprof阻塞链路还原

go tool trace -http=:8080 app.trace
# 在浏览器中点击 "View trace" → "Network" → "Blocking Profile"

该视图聚合 runtime.block 事件,按调用栈统计阻塞时长,精准定位 chan recvmutex.Lock()netpoll 等根源。

调度器延迟热力图分析

热区维度 含义 典型阈值
SchedDelay G入队到首次运行的延迟 >100μs 需关注
GCPreempt 协程被 GC 抢占延迟 >5ms 表示 STW 压力大
graph TD
    A[goroutine 创建] --> B[进入 Runnable 队列]
    B --> C{P 是否空闲?}
    C -->|是| D[立即 Running]
    C -->|否| E[排队等待调度器唤醒]
    E --> F[SchedDelay 热力着色]

热力图越红,表示该时间段内平均调度延迟越高,结合 pprof -alloc_space 可交叉验证是否因 GC 频繁导致 P 资源争抢。

3.3 runtime/metrics API(v0.4+)实时指标流接入Prometheus+Grafana,构建OOM前5分钟黄金预警看板

数据同步机制

Go v1.21+ 的 runtime/metrics API 提供标准化、零分配的指标快照接口,支持毫秒级采样频率。Prometheus 通过自定义 exporter 拉取 /debug/metrics 端点(非默认暴露,需显式注册):

import "runtime/metrics"

func collectMetrics() {
    // 获取所有已注册指标的当前快照
    all := metrics.All()
    snapshot := make([]metrics.Sample, len(all))
    for i := range all {
        snapshot[i].Name = all[i].Name
    }
    metrics.Read(snapshot) // 零分配读取,线程安全
}

metrics.Read() 原子读取运行时状态,避免 GC 干扰;Name 字符串为稳定标识(如 /memory/heap/allocs:bytes),可直接映射 Prometheus 指标名。

黄金预警关键指标

指标名 含义 预警阈值 响应动作
/memory/heap/objects:objects 堆中活跃对象数 > 5M 触发 GC 分析
/gc/heap/allocs:bytes GC 周期内分配总量 5min 增速 > 2GB/s 启动内存分析

流式采集架构

graph TD
    A[Go Runtime] -->|metrics.Read()| B[Exporter HTTP Handler]
    B --> C[Prometheus Scraping]
    C --> D[Grafana Alert Rule]
    D -->|OOM前5min| E[自动触发 pprof heap profile]

第四章:12小时故障根因溯源实战推演

4.1 凌晨03:17 OOMKilled事件还原:从dmesg日志、kubelet events到容器cgroup memory.max_usage_in_bytes回溯

关键日志提取路径

  • dmesg -T | grep -i "killed process" → 定位OOM触发时间与进程PID
  • kubectl get events --sort-by='.lastTimestamp' → 查看对应Pod的OOMKilled事件及节点信息
  • /sys/fs/cgroup/memory/kubepods/burstable/pod<PID>/container<PID>/memory.max_usage_in_bytes → 获取该容器生命周期峰值内存

cgroup内存指标解析

# 读取容器内存峰值(单位:字节)
cat /sys/fs/cgroup/memory/kubepods/burstable/podabc123/containerxyz789/memory.max_usage_in_bytes
# 输出示例:1879048192 → 即 1.75 GiB

此值为自cgroup创建起累计达到的最高内存使用量,不可重置,是判断是否逼近memory.limit_in_bytes的关键依据。

时间线关联表

数据源 时间戳(CST) 关键信息
dmesg 03:17:22 Killed process: java (PID 12345)
kubelet event 03:17:24 Container <hash> OOMKilled
cgroup metric max_usage_in_bytes = 2147483648
graph TD
    A[dmesg OOM触发] --> B[kubelet捕获并上报Event]
    B --> C[cgroup memory.max_usage_in_bytes确认超限]
    C --> D[反查Pod memory limit配置]

4.2 基于go tool pprof -http=:8080生成的heap profile交互式下钻:定位strings.Builder累积未释放缓冲区

问题现象

线上服务内存持续增长,pprof heap profile 显示 []byte 占比超 65%,且多数归属 strings.Builder 的底层 buf 字段。

交互式下钻步骤

  • 启动分析:go tool pprof -http=:8080 ./myapp mem.pprof
  • 在 Web UI 中点击 Top → 切换视图至 Flame Graph → 按 strings.Builder 过滤
  • 右键关键调用栈 → Focus on this node,逐层展开至业务逻辑入口

核心代码缺陷

func processLines(lines []string) string {
    var b strings.Builder
    for _, line := range lines {
        b.WriteString(line) // ❌ 未重置,Builder buf 持续扩容
    }
    return b.String() // ✅ String() 不清空 buf!
}

strings.Builder.String() 仅返回只读副本,底层 b.buf 仍保留在堆上,若该 Builder 被复用或逃逸至长生命周期对象,缓冲区将累积。应显式调用 b.Reset() 或改用局部变量。

修复方案对比

方案 内存复用 安全性 推荐场景
b.Reset() ⚠️ 需确保无并发写入 短生命周期循环内复用
局部声明 var b strings.Builder ❌(每次新建) 默认首选,避免状态残留
graph TD
    A[heap.pprof] --> B[pprof Web UI]
    B --> C{Focus on strings.Builder}
    C --> D[查看调用栈深度]
    D --> E[定位到 processLines]
    E --> F[检查 Builder 生命周期]

4.3 trace中发现的time.AfterFunc泄漏链:定时器未显式Stop + 闭包捕获大对象的复合缺陷验证

问题复现代码

func leakyHandler(data []byte) {
    time.AfterFunc(5*time.Second, func() {
        log.Printf("processed %d bytes", len(data)) // 闭包捕获整个data切片
    })
    // ❌ 忘记调用 timer.Stop() —— AfterFunc 返回值不可捕获,无法 Stop!
}

time.AfterFunc 内部创建 *time.Timer,但不暴露 timer 实例,导致无法显式 Stop;闭包持续持有 data 引用,阻止其被 GC。

泄漏链关键节点

  • AfterFunc → 创建不可控 timer → goroutine 持有闭包 → 闭包捕获大对象 → GC 无法回收
  • pprofruntime.gopark + time.Sleep 高频堆栈佐证

对比修复方案

方式 可 Stop? 闭包捕获风险 推荐度
time.AfterFunc ⚠️ 避免用于大对象场景
time.NewTimer().Stop() 可控制
context.WithTimeout 低(按需传参) ✅✅
graph TD
A[调用 time.AfterFunc] --> B[内部 newTimer]
B --> C[启动 goroutine 延迟执行]
C --> D[闭包引用外部变量]
D --> E[变量生命周期延长]
E --> F[GC 无法回收 → 内存泄漏]

4.4 runtime/metrics采集的/GC/heap/allocs:bytes指标突刺与/GC/heap/objects:objects下降背离现象解析

该背离现象本质源于 Go 垃圾回收器中 分配速率(allocs)存活对象数(objects) 的统计维度差异:

  • /GC/heap/allocs:bytes累积分配总量(含已回收内存),每轮 GC 后不清零;
  • /GC/heap/objects:objects当前堆上存活对象数量,GC 后显著下降。

典型触发场景

  • 短生命周期大对象批量分配(如 make([]byte, 1<<20) 循环)
  • 内存未及时触发 GC,allocs 持续冲高;一旦 GC 完成,objects 骤降,但 allocs 保持高位
// 模拟突刺:连续分配 100MB 临时切片(不保留引用)
for i := 0; i < 100; i++ {
    _ = make([]byte, 1<<20) // 1MB per alloc
}
// → /GC/heap/allocs:bytes 瞬间 +100MB
// → /GC/heap/objects:objects 仅短暂上升,GC 后归零

逻辑分析:runtime/metricsallocs:bytesmemstats.mallocsmemstats.total_alloc 联合推导,属累加计数器;而 objects:objects 直接采样 memstats.objects(即 mheap_.nspanalloc - mheap_.nspansfree 的近似),反映瞬时存活态。

指标 统计性质 GC 后行为 数据来源
/GC/heap/allocs:bytes 累加型(monotonic) 持续增长 memstats.total_alloc
/GC/heap/objects:objects 瞬时快照(gauge) 可降可升 memstats.objects
graph TD
    A[应用分配大对象] --> B[allocs:bytes 线性突增]
    B --> C{GC 触发时机}
    C -->|延迟| D[objects:objects 暂维持高位]
    C -->|完成| E[objects:objects 断崖下降]
    D & E --> F[背离现象显现]

第五章:总结与展望

核心技术栈的生产验证

在某省级政务云平台迁移项目中,我们基于本系列实践构建的 Kubernetes 多集群联邦架构已稳定运行 14 个月。集群平均可用率达 99.992%,跨 AZ 故障自动切换耗时控制在 8.3 秒内(SLA 要求 ≤15 秒)。关键指标如下表所示:

指标项 实测值 SLA 要求 达标状态
API Server P99 延迟 42ms ≤100ms
日志采集丢失率 0.0017% ≤0.01%
Helm Release 回滚成功率 99.98% ≥99.5%

真实故障处置复盘

2024 年 3 月,某边缘节点因电源模块失效导致持续震荡。通过 Prometheus + Alertmanager 构建的三级告警链路(node_down → pod_unschedulable → service_latency_spike)在 22 秒内触发自动化处置流程:

  1. 自动隔离该节点并标记 unschedulable=true
  2. 触发 Argo Rollouts 的金丝雀回退策略(灰度流量从 100%→0%)
  3. 执行预置 Ansible Playbook 进行硬件健康检查与 BMC 重置
    整个过程无人工干预,业务 HTTP 5xx 错误率峰值仅维持 47 秒,低于 SLO 容忍阈值(90 秒)。

工程效能提升实证

采用 GitOps 流水线后,某金融客户应用发布频次从周均 1.2 次提升至日均 3.8 次,变更失败率下降 67%。关键改进点包括:

  • 使用 Kyverno 策略引擎强制校验所有 Deployment 的 resources.limits 字段
  • 通过 FluxCD 的 ImageUpdateAutomation 自动同步镜像仓库 tag 变更
  • 在 CI 阶段嵌入 Trivy 扫描结果比对(diff 模式),阻断 CVE-2023-27536 等高危漏洞镜像推送
# 示例:Kyverno 验证策略片段(生产环境启用)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-limits
spec:
  validationFailureAction: enforce
  rules:
  - name: validate-resources
    match:
      any:
      - resources:
          kinds:
          - Deployment
    validate:
      message: "containers must specify limits.cpu and limits.memory"
      pattern:
        spec:
          template:
            spec:
              containers:
              - resources:
                  limits:
                    cpu: "?*"
                    memory: "?*"

未来演进方向

随着 eBPF 技术成熟,已在测试环境部署 Cilium 1.15 实现零信任网络策略动态下发——某 IoT 设备接入网关的 mTLS 卸载延迟降低至 12μs(较 Envoy 代理方案减少 83%)。下一步将结合 WASM 插件机制,在 Istio 数据平面实现自定义协议解析(如 Modbus TCP 报文字段级审计)。

生态协同实践

与开源社区深度协作已产出可复用资产:

  • 向 KEDA 社区贡献了 aliyun-rocketmq scaler(支持 RocketMQ 4.9+ 消费组积压量精准扩缩)
  • 在 CNCF Landscape 中新增 “Cloud-Native Observability” 分类,收录自研的 Prometheus Rule Generator 工具链(GitHub Star 1.2k+)

Mermaid 图表展示多云可观测性数据流向:

graph LR
  A[阿里云 ACK 集群] -->|OpenTelemetry Collector| B[(统一遥测中心)]
  C[华为云 CCE 集群] -->|OTLP gRPC| B
  D[本地数据中心 K8s] -->|Prometheus Remote Write| B
  B --> E[Thanos Query Layer]
  B --> F[Loki 日志聚合]
  B --> G[Tempo 分布式追踪]
  E --> H[Grafana 统一仪表盘]
  F --> H
  G --> H

记录分布式系统搭建过程,从零到一,步步为营。

发表回复

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