Posted in

为什么你的Go服务RSS持续增长?揭秘runtime.mspan、mcache与arena未释放的5个隐蔽路径

第一章:Go服务RSS持续增长的本质与观测基石

RSS(Resident Set Size)是衡量Go服务实际物理内存占用的关键指标,其持续增长往往并非简单的内存泄漏,而是由运行时内存管理机制、对象生命周期与GC行为共同作用的结果。理解这一现象的本质,需回归Go内存模型:堆内存由mcache、mcentral、mheap三级结构管理,而大对象直接分配至mheap,小对象经mcache快速分配;当对象存活时间超过两轮GC周期,可能被晋升至老年代,若未被及时回收,将导致RSS缓慢爬升。

内存观测的黄金三角

要准确定位RSS增长动因,必须协同采集三类信号:

  • 运行时指标runtime.MemStats.SysHeapInuseNextGC 反映内存分配总量与GC触发水位;
  • OS级视图/proc/<pid>/statm 中的 rss 字段(单位为页)提供内核视角的物理内存快照;
  • pprof堆快照:通过 http://localhost:6060/debug/pprof/heap?debug=1 获取实时堆分布,重点关注 inuse_spacealloc_space 的差值趋势。

实时诊断操作指南

在生产环境快速验证RSS是否由内存碎片或未释放对象驱动,执行以下命令:

# 1. 获取当前RSS(KB)及关键运行时统计
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" | grep -E "(Sys|HeapInuse|NextGC)"
awk '{print $2*4}' /proc/$(pgrep mygoservice)/statm  # 转换为KB

# 2. 生成带时间戳的堆快照用于对比分析
curl -s "http://localhost:6060/debug/pprof/heap?gc=1" > heap_$(date +%s).pb.gz

# 3. 使用pprof工具对比两次快照差异(需提前安装go tool pprof)
go tool pprof --base heap_1712345678.pb.gz heap_1712349012.pb.gz

上述步骤中,?gc=1 强制触发一次GC后再采样,可排除短期对象干扰;--base 模式能高亮新增分配热点,如发现 runtime.malgnet/http.(*conn).readLoop 持续增长,则指向goroutine泄漏或连接池未复用。

观测维度 健康阈值 风险信号
RSS / HeapInuse > 1.8 表明严重内存碎片
GC Pause Avg > 50ms 且频率上升提示GC压力
Alloc Rate 稳定波动 ±15% 持续单边增长暗示对象创建失控

真正的观测基石不在于单一指标,而在于建立从Go运行时→OS内存子系统→应用逻辑层的全链路映射能力。

第二章:runtime.mspan未释放的5个隐蔽路径

2.1 mspan分配链表残留:从mheap_.central到mcache的跨GMP生命周期泄漏

当 Goroutine 在 M 上执行并触发内存分配时,mcache 会优先从 mheap_.central 的 span 类别链表中获取已缓存的 mspan。若该 mspan 被绑定至某个 mcache 后,对应 G 被调度销毁、M 被复用但未清理 mcache.spanclass 引用,即形成跨 GMP 生命周期的链表残留。

数据同步机制

mcache 的 span 缓存不主动参与 GC 标记,仅依赖 runtime.mgcycle 检查与 mcentral.uncacheSpan() 协同清理——但该清理仅在 mcache.next_sample 触发或 stopTheWorld 阶段执行,存在可观测窗口。

关键代码路径

// src/runtime/mcache.go:127
func (c *mcache) refill(spc spanClass) {
    s := mheap_.central[spc].mcentral.cacheSpan() // ← 此处获取 span 后未绑定生命周期所有权语义
    c.alloc[s.class] = s // ← 直接赋值,无引用计数或 epoch 校验
}

cacheSpan() 返回的 mspan 仍保留在 mcentral.nonempty 链表中(除非显式 uncache),导致 mcache 持有已“逻辑释放”但物理未归还的 span。

状态阶段 mcentral.nonempty mcache.alloc 是否可被 GC 回收
刚分配后 ✅(仍挂载) ❌(强引用)
G 退出/M 复用 ✅(滞留)
next_sample 触发 ❌(已移至 empty) ❌(清空)
graph TD
    A[mheap_.central] -->|cacheSpan| B(mcache.alloc)
    B -->|G exit, no flush| C[span remains in nonempty]
    C --> D[跨 M 复用泄漏]

2.2 mspan被goroutine栈帧长期持有:逃逸分析盲区与stackguard0误判实践复现

当 goroutine 栈帧中持有指向 mspan 的指针(如通过 runtime.mspan 类型字段),且该指针未显式逃逸,Go 编译器的逃逸分析可能误判为“不逃逸”,导致 mspan 被错误地分配在栈上——但 mspan 是运行时堆管理核心结构,必须驻留堆中且由 mheap 全局管理

关键误判场景

  • stackguard0 在栈增长检查时依赖 g.stack.lomspanlimit 字段;
  • mspan 被栈帧间接持有(如嵌套结构体字段),GC 无法追踪其生命周期,触发悬垂指针或提前回收。
type GuardedSpan struct {
    span *mSpan // ❗无显式逃逸标记,逃逸分析漏判
}
func newGuarded() GuardedSpan {
    return GuardedSpan{span: acquireMspan()} // acquireMspan 返回 *mSpan,但未强制逃逸
}

逻辑分析:acquireMspan() 返回堆分配的 *mSpan,但结构体字面量初始化未触发 & 或传参逃逸路径;编译器认为 span 仅存活于函数栈帧内。实际该指针被 g.stackguard0 引用并长期缓存,造成 mspan 被 GC 过早回收。

复现验证要点

  • 使用 -gcflags="-m -l" 观察 span does not escape 提示;
  • runtime/stack.go 中注入日志,捕获 stackguard0 绑定时的 mspan 地址;
  • 对比 mspannelems 与实际 g.stack 状态,确认不一致。
检查项 正常行为 误判表现
mspan.nelems ≥ 128(标准栈 span) 突变为 0(已回收)
g.stackguard0 指向有效 mspan.limit 指向非法地址(SIGSEGV)
graph TD
    A[goroutine 创建] --> B[调用 newGuarded]
    B --> C[逃逸分析判定 span 不逃逸]
    C --> D[mspan 地址写入栈帧]
    D --> E[stackguard0 被设为 mspan.limit]
    E --> F[goroutine 长时间运行]
    F --> G[GC 扫描栈:未发现堆引用]
    G --> H[mspan 被 mheap.free]
    H --> I[SIGSEGV on stack growth]

2.3 finalizer关联mspan导致的循环引用:unsafe.Pointer+runtime.SetFinalizer组合陷阱

Go 运行时中,mspan 是内存管理的核心结构,若通过 unsafe.Pointer 将其与用户对象绑定,并调用 runtime.SetFinalizer,极易触发循环引用。

关键陷阱链路

  • 用户对象持 *mspan(via unsafe.Pointer 转换)
  • SetFinalizer(obj, f) 使 runtime 持有 obj 的强引用
  • mspan 又被 mheap 全局持有,且其 specials 链表反向引用 finalizer 所在对象
type spanWrapper struct {
    sp unsafe.Pointer // 指向 runtime.mspan
}
w := &spanWrapper{sp: unsafe.Pointer(someMspan)}
runtime.SetFinalizer(w, func(_ *spanWrapper) { /* cleanup */ })

此处 w 无法被 GC 回收:w → mspan → mheap → w(因 finalizer 注册后 runtime 内部保留 w 的指针),形成闭环。mspan 本身永不释放,导致整块 span 内存泄漏。

循环引用生命周期示意

graph TD
    A[用户对象 w] -->|unsafe.Pointer| B[mspan]
    B --> C[mheap]
    C -->|finalizer registry| A
风险维度 表现
GC 可达性 对象始终被 runtime.finalizer goroutine 引用
内存泄漏 关联的 span 及其 spanClass 分配页长期驻留
  • ✅ 推荐替代方案:使用 runtime.KeepAlive + 显式资源回收
  • ❌ 禁止将 runtime 内部结构(如 mspan, mcache)暴露给 finalizer

2.4 GC标记阶段遗漏的mspan:自定义内存池中未调用runtime.ReadMemStats的观测断层

数据同步机制

Go 运行时依赖 runtime.ReadMemStats 触发内部 GC 状态快照与 mheap.allspans 的遍历同步。若自定义内存池(如对象复用池)长期不调用该函数,GC 标记器将无法感知其持有的 mspan,导致这些 span 被错误判定为“未分配”而跳过扫描。

关键观测断层

  • ReadMemStats 是唯一触发 mheap.updateSpanStats() 的公开入口
  • 自定义池绕过 mallocgc 分配路径 → 不注册到 mheap.allspans → GC 标记阶段不可见
// 示例:危险的池化模式(缺失 ReadMemStats 同步)
var pool sync.Pool
pool.New = func() interface{} {
    return make([]byte, 1024)
}
// ❌ 此处未触发 memstats 读取,mspan 不进入 GC 可达图

逻辑分析:ReadMemStats 内部调用 memstats.clear()mheap.updateSpanStats(),后者遍历 allspans 并更新 span.inuse 标志位;缺失该调用,则 span 的 markBits 永远不被初始化,GC 标记器直接跳过。

场景 是否触发 span 同步 GC 可见性
mallocgc 分配
sync.Pool 复用(无 ReadMemStats)
手动 runtime.GC() ✅(间接)
graph TD
    A[自定义内存池分配] --> B{调用 runtime.ReadMemStats?}
    B -- 否 --> C[mspan 不入 allspans 快照]
    B -- 是 --> D[updateSpanStats 更新 inuse 标志]
    C --> E[GC 标记阶段遗漏该 mspan]

2.5 mmap系统调用未munmap:arena边界外独立mspan分配(如cgo调用触发)的strace验证方案

当 Go 程序通过 cgo 调用 C 函数(如 mallocmmap(MAP_ANONYMOUS)),运行时可能绕过 Go 的 mheap arena,在其边界外直接分配独立 mspan,且未配对 munmap

验证方法:strace 捕获裸内存映射

strace -e trace=mmap,munmap,mprotect -f ./mygoapp 2>&1 | grep -E "(mmap|munmap).*MAP_ANONYMOUS"
  • -f 跟踪子线程/CGO 线程;
  • MAP_ANONYMOUS 标识非文件映射的独立页;
  • 缺失对应 munmap 行即为泄漏线索。

关键观察维度

字段 说明
addr 若非 且不在 runtime.memstats.heap_sys 区间,则属 arena 外分配
length 常为 8192(1 个页)或 64K(cgo 默认 malloc chunk)
prot/flags PROT_READ\|PROT_WRITE + MAP_PRIVATE\|MAP_ANONYMOUS 是典型特征

内存生命周期示意

graph TD
    A[cgo malloc/mmap] --> B[分配独立 vma]
    B --> C[Go runtime 不感知]
    C --> D[无 GC 清理路径]
    D --> E[仅依赖 C 层 free/munmap]

第三章:mcache内存驻留的三大隐性根源

3.1 P本地mcache未随P销毁而清空:GOMAXPROCS动态调整后mcache dangling指针实测分析

GOMAXPROCS 动态下调时,运行时会销毁多余 P(Processor),但其绑定的 mcache 结构体未被显式归零或释放,仅解除与 P 的指针关联,导致残留 dangling 指针。

内存布局关键字段

// src/runtime/mcache.go
type mcache struct {
    alloc [numSpanClasses]*mspan // 指向mspan的指针数组(含已释放但未置nil的项)
    next_sample int64            // 采样计数器,不随P销毁重置
}

该结构体由 persistentalloc 分配,生命周期独立于 P;alloc 数组中部分 *mspan 可能指向已被 scavenger 回收的 span,形成悬垂引用。

复现路径示意

graph TD
    A[调用 runtime.GOMAXPROCS(8)] --> B[创建8个P及对应mcache]
    B --> C[执行大量小对象分配]
    C --> D[调用 GOMAXPROCS(2)]
    D --> E[销毁6个P,但mcache内存未清零]
    E --> F[残留alloc[i]仍指向已unmap的span]

风险验证要点

  • mcache.alloc[i] != nilmspan.nelems == 0 时即为可疑悬垂;
  • GC 扫描时若误读该指针,可能触发非法内存访问(SIGSEGV);
  • 实测显示 runtime·gcDrain 在标记阶段偶发 panic,堆栈指向 mspan.refill

3.2 mcache.spanclass索引错位:自定义allocSize导致spanclass哈希冲突与缓存污染复现实验

当用户调用 runtime.MemStats 或手动触发 mcache.refill() 时,若传入非标准 allocSize(如 128B 而非 128B 对齐的 spanclass 17),spanclass 索引计算将发生错位:

// src/runtime/mcache.go: spanClass := size_to_class8[divRoundUp(size, _PageSize>>_PageShift)]
// 错误示例:allocSize=128 → size_to_class8[128] = 17,但实际应映射到 class 16(128B span)
// 导致 mcache.alloc[17] 被写入非预期 span,污染后续 small-alloc 缓存

该逻辑绕过 size_to_class128 查表路径,直接使用低精度 size_to_class8,引发哈希桶碰撞。

复现关键路径

  • 自定义 allocSize 不满足 allocSize == _PageSize >> (n - 1) 形式
  • mcache.refill() 未校验 spanclass 与 size 的双向一致性
  • 同一 mcache.alloc[i] 被多个 size 类别交叉写入

冲突影响对比

场景 spanclass 索引 实际分配 size 是否污染
标准 128B 16 128B
自定义 128B(误查 class8) 17 128B 是(写入 class17 slot)
graph TD
    A[allocSize=128] --> B{size_to_class8[128]}
    B -->|返回17| C[mcache.alloc[17] ← new span]
    C --> D[后续 class16 分配命中失败]
    D --> E[强制 sysAlloc → 缓存抖动]

3.3 mcache.freeList非原子清空:高并发场景下sync.Pool误用引发的mcache stale freeList残留

数据同步机制

mcache.freeList 是每个 P(Processor)私有的空闲对象链表,由 sync.Pool 归还时写入。但 sync.Pool.PutpoolLocal.private 赋值非原子,且未对 mcache 中已存在的 freeList 做显式清零。

关键问题复现

// 错误用法:跨 goroutine 复用 mcache 对象
p := &myStruct{}
sync.Pool.Put(p) // 可能写入 A-P 的 mcache.freeList
// 若此时该 P 被调度器迁移,B-P 重用同一 mcache 结构体
// 则 B-P 的 freeList 残留 A-P 的 stale 指针

此处 mcache 结构体被 runtime.mcache 全局复用,但 freeList 字段未在 releaseMCachepoolCleanup 中置为 nil,导致 dangling 链表。

修复策略对比

方案 原子性 安全性 开销
atomic.StorePointer(&mc.freeList, nil)
sync.Pool.New 返回零值结构体 ❌(New 不保证调用) ⚠️ 极低
强制 runtime.GC() 触发 mcache 重置
graph TD
    A[Put obj to sync.Pool] --> B{Pool 找到 local pool}
    B --> C[写入 poolLocal.private]
    C --> D[mcache.freeList 未清空]
    D --> E[新 P 复用 stale mcache]
    E --> F[use-after-free panic]

第四章:arena未释放的四类底层机制

4.1 heapArena已映射但未归还:从mheap_.arenas二维数组到pageAlloc位图的内存状态一致性校验

内存视图的双重表示

Go运行时通过 mheap_.arenas[1<<PAGEMASK][1<<PAGEMASK] 管理64MB arena切片,而 pageAlloc 使用紧凑位图标记每页(8KB)是否已分配。二者状态不同步将导致“幽灵内存”——arena仍被标记为活跃,但pageAlloc中对应页位为0。

一致性校验关键断点

// src/runtime/mheap.go: checkArenaConsistency()
for i := range mheap_.arenas {
    for j := range mheap_.arenas[i] {
        if a := mheap_.arenas[i][j]; a != nil {
            base := uintptr(i)<<40 | uintptr(j)<<20 // 计算arena起始地址
            pbase := pageNo(base >> pageShift)       // 转为pageAlloc索引
            if !mheap_.pageAlloc.inUse(pbase) {      // 位图未置位 → 不一致!
                throw("arena mapped but pageAlloc says free")
            }
        }
    }
}

逻辑说明:i<<40 | j<<20 还原arena虚拟地址(PAGEMASK=20,arena大小=1heapArenaShift);pageNo() 将地址转为pageAlloc内部页号;inUse() 查询位图对应bit是否为1。

校验失败的典型路径

  • mmap成功但pageAlloc未更新(如sysAlloc后遗漏pageAlloc.allocRange调用)
  • GC清扫阶段pageAlloc批量释放,但arena元数据未同步清零
检查项 预期状态 失败含义
arena指针非nil true arena已映射
pageAlloc.inUse() true 对应页被标记为已使用
spanClass有效性 != spanDead span结构未被回收
graph TD
    A[遍历mheap_.arenas二维数组] --> B{arena指针非nil?}
    B -->|Yes| C[计算对应pageAlloc页号]
    B -->|No| D[跳过]
    C --> E[查询pageAlloc位图]
    E -->|bit==0| F[panic:已映射但未登记]
    E -->|bit==1| G[通过]

4.2 大对象直接分配绕过mcache:>32KB对象在heapArena中形成不可回收碎片的pprof+gdb联合定位法

当分配超过32KB的对象时,Go运行时跳过mcache/mcentral路径,直连heapArena,导致大页(64KB/128KB)被整块占用且无法被复用——即使仅使用其中一小部分。

pprof定位内存热点

go tool pprof -http=:8080 mem.pprof  # 关注alloc_space中>32KB的top allocators

该命令触发runtime.MemStats.AllocBytes采样,聚焦runtime.mallocgcsize > _MaxSmallSize分支调用栈。

gdb断点验证分配路径

(gdb) b runtime.mallocgc
(gdb) cond 1 $arg0 > 32768
(gdb) r

命中后检查span.class == 0(表示无size class映射),确认已绕过mcache。

指标 >32KB
分配路径 mcache → mcentral → mheap 直达mheap.allocSpan
碎片粒度 span内可复用 整个arena页锁定

联合分析流程

graph TD
    A[pprof发现持续增长的32KB+ alloc] --> B[gdb断点验证class==0]
    B --> C[查看runtime.heapArena.free.remove]
    C --> D[确认free bitmap未覆盖已分配页]

4.3 堆内元数据(gcBits、heapBits)长期驻留:bitvector未随对象回收而重置的runtime/debug.FreeOSMemory失效场景

数据同步机制

Go 运行时使用 gcBits(标记位图)和 heapBits(堆地址映射位图)跟踪对象生命周期。二者以 page 粒度缓存于全局 bitmap 区,不随单个对象回收自动清零

失效根源

debug.FreeOSMemory() 仅触发 GC 并归还未使用的 span 给 OS,但:

  • gcBits 中已标记为“扫描过”的位保持置位;
  • heapBits 对应页的 alloc/scan 标志未重置;
  • 下次分配同页内存时复用旧 bitvector → 误判存活对象。
// 示例:强制复用已回收页,触发 bitvector 残留问题
var ptrs []*int
for i := 0; i < 1e4; i++ {
    x := new(int)
    *x = i
    ptrs = append(ptrs, x)
}
ptrs = nil // 对象可回收,但 gcBits 未清零
runtime.GC()
debug.FreeOSMemory() // OS 内存释放成功,但 heapBits 仍记录该页为"已扫描"

逻辑分析FreeOSMemory() 不调用 mheap.reclaim() 的 full-reset 流程;gcBits.clear() 仅在 STW 阶段的 gcResetMarkState() 中批量执行,而该函数不被 FreeOSMemory 触发。参数 mheap.allspans 中 span 的 needzero 标志仍为 false,导致位图复用。

场景 gcBits 是否清零 heapBits 是否重置 FreeOSMemory 是否生效
正常 GC 后 ✅(STW 中) ✅(span 重初始化)
FreeOSMemory 单独调用 ❌(仅释放 OS 层内存)
graph TD
    A[FreeOSMemory] --> B{触发 GC?}
    B -->|是| C[mark termination]
    B -->|否| D[仅 munmap unused spans]
    C --> E[gcResetMarkState → 清 gcBits/heapBits]
    D --> F[跳过位图重置 → 残留标记]

4.4 arena页未触发scavenger回收:GODEBUG=madvdontneed=1与runtime/debug.SetGCPercent协同调优实验

Go 1.22+ 引入 arena 内存管理后,scavenger 对 arena 页的回收行为受 madvise(MADV_DONTNEED) 策略直接影响。

关键调试组合

  • GODEBUG=madvdontneed=1:强制使用 MADV_DONTNEED(而非默认 MADV_FREE),立即归还物理页给 OS
  • debug.SetGCPercent(10):激进触发 GC,加速 arena 页标记为可回收

实验对比数据(10MB arena 分配后空闲 5s)

配置 物理内存释放延迟 scavenge 调用次数 RSS 下降幅度
默认 ~30s 1 12%
madvdontneed=1 + GCPercent=10 4 98%
import (
    "runtime/debug"
    _ "unsafe" // for go:linkname
)

func tuneArenaScavenging() {
    debug.SetGCPercent(10) // 提高 GC 频率,促发 arena 页清扫
    // 注意:madvdontneed=1 需通过环境变量启动时设置,运行时不可变
}

此代码不改变 madvise 行为,仅协同提升 GC 触发密度;GODEBUG 必须在进程启动前注入,否则 arena scavenger 仍沿用 MADV_FREE 的延迟回收语义。

第五章:构建可持续的Go内存健康治理闭环

内存指标采集标准化实践

在字节跳动某核心推荐服务中,团队将 runtime.ReadMemStatspprof 运行时指标统一接入 Prometheus,定义了 7 个黄金内存指标:go_memstats_heap_alloc_bytes(实时堆分配)、go_memstats_gc_cpu_fraction(GC CPU 占比)、go_goroutines(协程数)、go_memstats_next_gc_bytes(下一次 GC 触发阈值)、go_memstats_heap_objects(活跃对象数)、container_memory_working_set_bytes(容器实际驻留内存)、go_memstats_pause_ns_total(累计 STW 时间)。所有指标通过 OpenTelemetry Collector 统一打标,附加 service_nameenvpod_id 三重维度,确保跨集群可比性。

自动化内存异常检测规则

以下为生产环境部署的 PromQL 异常检测规则示例:

# 连续3分钟堆分配速率突增200%
rate(go_memstats_heap_alloc_bytes[3m]) / rate(go_memstats_heap_alloc_bytes[15m]) > 2.0

# GC 频率异常(每分钟 GC 次数 > 8)
sum(rate(go_memstats_gc_count_total[1m])) by (job, instance) > 8

# Goroutine 泄漏迹象(10分钟内增长超5000)
delta(go_goroutines[10m]) > 5000

内存问题根因定位工作流

使用 Mermaid 流程图描述典型响应路径:

flowchart TD
    A[告警触发] --> B{HeapAlloc 增速 >200%?}
    B -->|是| C[自动抓取 pprof heap profile]
    B -->|否| D[检查 Goroutine profile]
    C --> E[调用 go tool pprof -http=:8080 profile]
    D --> F[分析 goroutine stack trace]
    E --> G[定位高频 NewObject 调用栈]
    F --> H[识别阻塞/未关闭 channel]
    G --> I[关联代码变更记录]
    H --> I
    I --> J[推送 PR 到对应微服务仓库]

线上内存压测验证机制

在 CI/CD 流水线中嵌入内存基线测试:每次合并前执行 go test -bench=. -memprofile=mem.out -run=^$ ./...,对比主干分支基准值。若 BenchmarkProcessItems-8Allocs/op 增幅超 15% 或 Bytes/op 超 20%,流水线自动阻断并生成 diff 报告。某次发现 json.Unmarshal 替换为 easyjson 后,Allocs/op 从 1242 降至 89,但 Bytes/op 反升 37%,经分析系预分配缓冲区过大,最终采用混合策略:高频小结构体用 easyjson,大 payload 回退标准库。

治理效果量化看板

指标 Q1 2023 Q4 2023 变化 改进措施
平均 GC 间隔 28.4s 62.1s +118% 对象池复用 HTTP body buffer
P99 分配延迟 4.7ms 1.2ms -74% 替换 sync.Pool 为无锁 ring buffer
内存泄漏工单数 17件/月 2件/月 -88% 新增 goroutine 生命周期审计插件

持续反馈机制设计

在每个服务启动时注入 memory.Guardian,监听 runtime.MemStats 变化,当 HeapAlloc 在 60 秒内增长超过 GOGC*1.5 阈值时,自动向内部 Slack 频道发送结构化告警,并附带 curl -s http://localhost:6060/debug/pprof/heap?debug=1 下载链接与火焰图生成命令。该机制已在 12 个核心服务上线,平均故障发现时间从 23 分钟缩短至 92 秒。

Docker 与 Kubernetes 的忠实守护者,保障容器稳定运行。

发表回复

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