Posted in

【20年Go性能调优经验】:如何用3行代码+1个HTTP endpoint实现自动化内存泄漏预警?

第一章:内存泄漏的本质与Go运行时特征

内存泄漏并非Go语言独有的现象,而是指程序在运行过程中持续分配堆内存却未能及时释放,导致可用内存不可逆地减少。在Go中,由于存在自动垃圾回收(GC),开发者常误以为“不会发生内存泄漏”——但事实是,只要对象仍被活跃的引用链可达,GC就无法回收它们,从而形成逻辑泄漏。

Go运行时的内存管理模型

Go使用三色标记-清除GC算法,配合写屏障和并发标记机制,在低延迟前提下保障内存安全。其堆内存按span组织,每个span管理固定大小的对象块;而逃逸分析决定变量分配在栈还是堆——若变量地址被函数外引用,则强制逃逸至堆,成为潜在泄漏源头。

常见泄漏模式识别

  • 长生命周期容器持续追加未清理元素(如全局map、缓存无淘汰策略)
  • Goroutine泄漏:启动后因通道阻塞或条件等待永久挂起,携带闭包引用的变量无法释放
  • Finalizer滥用:注册的finalizer未执行或执行失败,导致关联对象长期驻留

实例:隐式引用导致的泄漏

以下代码将*http.Request存入全局map,但请求体未读取,导致底层buffer和相关上下文无法释放:

var requests = make(map[string]*http.Request)

func leakHandler(w http.ResponseWriter, r *http.Request) {
    // ❌ 未消费r.Body,r及其内部buffer持续被requests引用
    requests[r.URL.Path] = r
    fmt.Fprint(w, "leaked")
}

修复方式:显式读取并丢弃body,或仅存储必要字段(如URL、Header副本):

func safeHandler(w http.ResponseWriter, r *http.Request) {
    // ✅ 消费body解除引用链
    io.Copy(io.Discard, r.Body)
    r.Body.Close()
    requests[r.URL.Path] = &http.Request{URL: r.URL, Header: cloneHeader(r.Header)}
    fmt.Fprint(w, "safe")
}

运行时诊断工具链

工具 用途 启动方式
runtime.ReadMemStats 获取实时堆内存统计 在关键路径调用并打印MemStats.Alloc, TotalAlloc
pprof heap profile 定位高分配量类型 curl http://localhost:6060/debug/pprof/heap?debug=1
go tool pprof -alloc_space 分析内存分配热点 go tool pprof http://localhost:6060/debug/pprof/heap

GC触发并非完全自动——当新分配内存超过当前堆大小的特定比例(默认为100%)时,才会启动。因此,即使无明显OOM,缓慢增长的MemStats.TotalAlloc也可能是泄漏信号。

第二章:Go内存监控的核心机制剖析

2.1 runtime.MemStats的底层结构与关键字段语义解析

runtime.MemStats 是 Go 运行时内存状态的快照结构,定义于 runtime/mstats.go,由 runtime.ReadMemStats() 原子读取。

数据同步机制

MemStats 本身不保证并发安全,其字段通过运行时 GC 每次标记-清扫周期末尾的 updateMemStats() 批量更新,并借助 atomic.StoreUint64 写入关键计数器(如 HeapAlloc),避免锁开销。

关键字段语义表

字段名 单位 语义说明
HeapAlloc bytes 当前已分配且仍在使用的堆内存总量
TotalAlloc bytes 程序启动至今累计分配的堆内存总量
Sys bytes 向操作系统申请的总内存(含堆、栈、MSpan等)

核心字段读取示例

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Live heap: %v KB\n", m.HeapAlloc/1024)

此调用触发一次 stop-the-world 轻量级快照:GC 将当前各内存区域统计值原子复制到 m,HeapAlloc 反映的是 GC 标记后存活对象的精确大小,而非实时 malloc 分配量——它滞后于瞬时分配行为,但具备强一致性语义。

2.2 pprof heap profile的采样原理与GC周期耦合关系

pprof 的 heap profile 并非连续采样,而是仅在垃圾回收(GC)完成时触发一次快照。其核心机制依赖于 Go 运行时的 runtime.ReadMemStats 与 runtime.GC() 后的堆状态捕获。

GC 触发是采样的隐式门控

  • 每次 runtime.GC() 返回后,若启用了 --memprofile 或 runtime.SetBlockProfileRate(0) 等配置,pprof 会调用 runtime.writeHeapProfile
  • 采样点严格绑定于 GC 周期末尾,无 GC 则无新 heap profile 数据

采样粒度由分配事件驱动

// runtime/mfinal.go 中实际采样逻辑简化示意
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ... 分配逻辑
    if shouldRecordHeapSample() { // 仅当 memstats.next_sample <= memstats.heap_alloc 时触发
        recordHeapSample()
    }
    return x
}

shouldRecordHeapSample() 依据指数随机采样(平均每分配 512KB 触发一次),但该采样仅记录分配栈,不写入 profile 文件——真正落盘仍需 GC 完成后批量归并。

采样阶段 触发条件 数据完整性
分配采样 指数随机(~512KB) 仅暂存栈帧,不持久化
Profile 落盘 GC 结束回调 合并所有待写样本,生成完整 heap profile
graph TD
    A[内存分配] --> B{是否达采样阈值?}
    B -->|是| C[记录分配栈至 runtime.heapProfile]
    B -->|否| A
    D[GC 开始] --> E[暂停世界]
    E --> F[标记-清除]
    F --> G[GC 结束]
    G --> H[调用 writeHeapProfile]
    H --> I[合并 runtime.heapProfile 中所有栈帧]
    I --> J[生成 .heap 文件]

2.3 GC触发条件与内存增长模式的可观测性建模

JVM 的 GC 触发并非仅依赖堆满,而是由多维可观测指标协同决策:

关键触发信号

  • Eden 区分配失败(Allocation Failure)
  • 元空间容量逼近阈值(MetaspaceSize / MaxMetaspaceSize)
  • CMS/ ZGC 的并发周期启动条件(如老年代占用率达 initiatingOccupancyFraction)

内存增长模式建模示例

// 基于 JFR 事件流实时计算内存增长率(单位:MB/s)
public double calculateGrowthRate(List<MemoryPoolUsage> samples) {
  long durationMs = samples.get(samples.size()-1).timestamp() 
                  - samples.get(0).timestamp();
  long deltaBytes = samples.get(samples.size()-1).used() 
                  - samples.get(0).used();
  return (double) deltaBytes / durationMs / 1024 / 1024 * 1000; // MB/s
}

该方法将时间序列内存采样转化为瞬时增长速率,timestamp() 单位为纳秒,used() 返回字节数;除以 1024² 转 MB,乘 1000 补偿毫秒→秒换算。

GC触发决策因子权重表

因子 权重 观测方式
Eden 分配失败频次 0.4 -XX:+PrintGCDetails
老年代使用率增速 0.35 JMX MemoryUsage.used
GC 吞吐量下降率 0.25 jstat -gc 持续采集
graph TD
  A[内存采样流] --> B[滑动窗口聚合]
  B --> C{增长率 > 阈值?}
  C -->|是| D[触发预测性GC]
  C -->|否| E[维持常规回收策略]

2.4 Go 1.21+ 新增memstats delta指标在泄漏检测中的实践验证

Go 1.21 引入 runtime/metrics 中的 memstats/heap_alloc:delta 等增量指标,支持低开销、高频率的内存变化观测。

Delta 指标核心优势

  • 避免 runtime.ReadMemStats 的全局 stop-the-world 开销
  • 原生支持纳秒级采样间隔(如每 100ms)
  • 自动累积差值,天然适配泄漏趋势建模

实时泄漏检测代码示例

import "runtime/metrics"

func monitorHeapDelta() {
    last := metrics.Read[metrics.Sample]{
        {Name: "memstats/heap_alloc:bytes"},
        {Name: "memstats/heap_alloc:delta"},
    }
    metrics.Read(&last) // 初始化快照

    ticker := time.NewTicker(100 * time.Millisecond)
    for range ticker.C {
        var now metrics.Read[metrics.Sample]
        metrics.Read(&now)
        delta := now[1].Value.(uint64) // 单位:bytes
        if delta > 1024*1024 { // 持续 >1MB/s 增长
            log.Printf("suspected leak: %d B/s", delta)
        }
    }
}

该代码利用 :delta 指标直接获取自上次读取以来的堆分配增量,无需手动差分计算;metrics.Read 非阻塞且无 GC 暂停影响,适合长期驻留监控。

关键指标对照表

指标名 类型 含义
memstats/heap_alloc:bytes gauge 当前已分配堆字节数
memstats/heap_alloc:delta counter 自上次读取后新增分配字节数

检测流程逻辑

graph TD
    A[每100ms触发Read] --> B{获取heap_alloc:delta}
    B --> C[>1MB/s?]
    C -->|Yes| D[记录告警并dump goroutine]
    C -->|No| A

2.5 基于GODEBUG=gctrace=1的实时内存行为日志反向推演泄漏路径

启用 GODEBUG=gctrace=1 后,Go 运行时每完成一次 GC 即输出结构化日志,例如:

gc 1 @0.021s 0%: 0.024+0.27+0.011 ms clock, 0.098+0.15/0.26+0.044 ms cpu, 4->4->2 MB, 5 MB goal, 4 P

日志字段语义解析

  • gc 1:第 1 次 GC
  • 4->4->2 MB:堆大小(上一轮结束→本轮开始→本轮结束),若 heap_alloc 持续增长且 heap_idle 不释放,则存在泄漏嫌疑
  • 4 MB goal:目标堆大小,长期偏离表明 GC 未能回收预期对象

反向推演关键步骤

  • 捕获连续 5+ 次 GC 日志,提取 heap_alloc 序列
  • 定位 heap_alloc 突增点,结合 pprof 的 allocs profile 回溯分配栈
  • 关注 runtime.mallocgc 调用链中高频出现的业务包路径
字段 示例值 含义
0.024+0.27+0.011 ms clock GC 三阶段耗时(标记/扫描/清理) 标记时间过长常指向环状引用
4->4->2 MB 堆内存变化 若 ->2 MB 长期不下降,说明对象未被回收
// 在疑似泄漏模块添加临时观测点
func processUser(u *User) {
    // 记录分配前后的 runtime.MemStats
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    log.Printf("Alloc = %v MB", bToMb(m.Alloc))
    defer func() { runtime.ReadMemStats(&m); log.Printf("Post-Alloc = %v MB", bToMb(m.Alloc)) }()
}

该代码通过前后内存快照差值辅助定位泄漏上下文;bToMb 将字节转 MB,便于日志比对。需配合 GODEBUG=gctrace=1 输出的时间戳对齐分析。

graph TD
    A[GC Log] --> B{heap_alloc 持续↑?}
    B -->|Yes| C[提取 allocs profile]
    B -->|No| D[排除内存泄漏]
    C --> E[按调用栈聚合分配点]
    E --> F[定位高频 pkg.func]

第三章:轻量级自动化预警系统设计

3.1 三行核心代码实现内存增量趋势判定(delta > threshold && rising)

核心判定逻辑

内存趋势判定依赖实时变化量与方向性双重约束:

delta = current_mem - prev_mem          # 当前与前一采样点差值
is_rising = delta > 0                   # 增长方向性标识
alarm = delta > THRESHOLD_MB and is_rising  # 增量超阈值且持续上升

THRESHOLD_MB 为预设敏感阈值(如50MB),delta 必须严格大于0才能触发 is_rising,避免噪声误判;双条件合取确保仅捕获显著且持续增长的异常模式。

判定状态组合表

delta 符号 delta > THRESHOLD alarm 结果 含义
+ True True 高风险增长
+ False False 正常波动
− — False 下降或稳定

执行流程

graph TD
    A[获取current_mem, prev_mem] --> B[计算delta]
    B --> C{delta > 0?}
    C -->|否| D[alarm = False]
    C -->|是| E{delta > THRESHOLD_MB?}
    E -->|否| D
    E -->|是| F[alarm = True]

3.2 HTTP endpoint暴露/metrics/leak-alert的REST语义与幂等性保障

REST语义对监控端点的约束

/metrics 和 /leak-alert 必须严格遵循 HTTP 方法语义:

  • GET /metrics:只读、无副作用,返回当前指标快照(200 OK);
  • POST /leak-alert:触发告警检查,但必须幂等——重复请求不产生新告警事件。

幂等性实现机制

采用 Idempotency-Key 请求头 + 服务端去重缓存(TTL 5min):

POST /leak-alert HTTP/1.1
Content-Type: application/json
Idempotency-Key: a1b2c3-d4e5-f6g7-h8i9-j0k1l2m3n4o5

逻辑分析:网关层解析 Idempotency-Key,查 Redis 缓存键 idemp:${key}。若存在且状态为 processed,直接返回 202 Accepted 及原始响应体;否则执行业务逻辑并写入缓存。参数 Idempotency-Key 由客户端生成 UUID,确保跨请求唯一性。

端点语义对照表

端点 方法 幂等性 响应示例
/metrics GET ✅ 天然幂等 200 OK, text/plain
/leak-alert POST ✅ 需显式保障 202 Accepted, Location: /alerts/123

数据同步机制

graph TD
    A[Client] -->|POST with Idempotency-Key| B[API Gateway]
    B --> C{Key exists?}
    C -->|Yes| D[Return cached 202]
    C -->|No| E[Execute leak check]
    E --> F[Store result & key in Redis]
    F --> D

3.3 预警阈值动态校准策略:基于历史百分位数与容器内存限制的自适应计算

传统静态阈值易误报或漏报。本策略融合过去7天内存使用历史的P95分位数与容器memory.limit_in_bytes,实现双约束动态校准。

核心计算逻辑

def compute_dynamic_threshold(mem_limit_bytes: int, p95_history_mb: float) -> int:
    # 取二者较小值,再叠加10%安全缓冲
    base = min(mem_limit_bytes // 1024 // 1024, p95_history_mb)
    return int(base * 1.1)  # 单位:MB

逻辑说明:mem_limit_bytes来自cgroup接口;p95_history_mb由Prometheus聚合计算;乘数1.1防止瞬时毛刺触发告警。

决策权重对照表

来源 权重 触发条件
容器内存上限 高 p95 > limit × 0.9
历史P95分位数 中 p95 < limit × 0.7

自适应流程

graph TD
A[采集内存使用序列] --> B[计算7d P95]
B --> C{P95 vs limit}
C -->|P95 ≥ 90% limit| D[以limit为基准]
C -->|P95 < 70% limit| E[以P95为基准]
D & E --> F[×1.1 → 动态阈值]

第四章:生产环境落地与深度调优实践

4.1 在Kubernetes Pod中注入内存泄漏探测Sidecar的Operator化部署方案

核心设计思路

Operator通过MutatingWebhookConfiguration拦截Pod创建请求,依据标签(如 monitoring/mem-leak: "true")动态注入轻量级memleak-probe Sidecar容器,并挂载共享内存卷用于实时堆快照采集。

自定义资源定义(CRD)关键字段

字段 类型 说明
spec.thresholdMB integer 触发告警的RSS内存阈值(默认256)
spec.sampleIntervalSeconds integer pprof采样间隔(最小5秒)

注入逻辑示例(Webhook响应片段)

# webhook response patch
- op: add
  path: /spec/containers/- 
  value:
    name: memleak-probe
    image: registry.example.com/memleak-probe:v1.2.0
    resources:
      limits: {memory: "128Mi"}
    volumeMounts:
    - name: shared-mem
      mountPath: /dev/shm

该补丁在Pod spec末尾追加Sidecar容器;shared-mem卷用于与主容器共享/dev/shm,使memleak-probe可通过/proc/<pid>/maps与gcore协同分析匿名映射区异常增长。

生命周期协同机制

  • Sidecar启动后等待主容器就绪(通过readinessProbe路径 /healthz)
  • 主容器终止时,Sidecar自动触发最终堆转储并上报至Metrics Server
graph TD
  A[Pod Create Request] --> B{Label Match?}
  B -->|Yes| C[Inject Sidecar + Shared Volume]
  B -->|No| D[Pass Through]
  C --> E[Start Probe with PID Discovery]
  E --> F[Periodic RSS Check & pprof Capture]

4.2 结合Prometheus+Alertmanager构建SLO驱动的分级告警管道

SLO指标建模示例

定义服务可用性SLO(99.9%)对应的错误预算消耗率:

# prometheus_rules.yml
groups:
- name: slo-alerts
  rules:
  - alert: ErrorBudgetBurnRateHigh
    expr: (1 - sum(rate(http_request_duration_seconds_count{code=~"2.."}[1h])) 
           / sum(rate(http_requests_total[1h]))) > 0.001
    labels:
      severity: warning
      slo_target: "99.9%"
    annotations:
      summary: "SLO burn rate exceeds 0.1%/h for 1h"

该表达式计算每小时错误预算消耗率:分子为错误请求占比,分母为总请求数;0.001对应0.1%/h燃烧速率阈值,触发warning级告警。

分级路由策略

Alertmanager依据severity与service标签实现告警分流:

severity 路由目标 响应时效
critical PagerDuty + SMS ≤5分钟
warning Slack #sre-slo ≤30分钟
info Email digest 每日汇总

告警生命周期流程

graph TD
A[Prometheus采集指标] --> B[SLO规则评估]
B --> C{是否触发?}
C -->|是| D[打标 severity/service]
D --> E[Alertmanager分组/抑制/路由]
E --> F[分级通知通道]

关键配置要点

  • 使用group_by: [alertname, service]避免告警风暴
  • 配置repeat_interval: 4h防止重复低优先级通知
  • inhibit_rules屏蔽warning级告警在critical发生时的冗余推送

4.3 利用go tool pprof -http与火焰图定位goroutine/heap泄漏源点

启动实时分析服务

go tool pprof -http=:8080 ./myapp http://localhost:6060/debug/pprof/goroutine?debug=2

该命令启动 Web UI,连接运行中程序的 /debug/pprof 端点;?debug=2 强制展开所有 goroutine 栈帧,避免折叠隐藏泄漏线索。

火焰图识别泄漏模式

视图类型 适用场景 关键特征
top 快速定位高数量 goroutine 显示 runtime.gopark 占比异常高
graph 分析调用链阻塞点 循环调用或未关闭 channel 显著凸起
flame(火焰图) 可视化栈深度与频率 持续宽底座表示长期存活 goroutine

定位 heap 泄漏典型路径

func leakyHandler(w http.ResponseWriter, r *http.Request) {
    data := make([]byte, 1<<20) // 1MB 每次请求
    // ❌ 忘记释放:data 逃逸至堆且无引用回收
    time.Sleep(10 * time.Second) // 模拟长生命周期
}

make([]byte, 1<<20) 在逃逸分析中必然分配到堆;若 handler 并发激增且 data 被意外闭包捕获,pprof heap 将显示 runtime.mallocgc 下持续增长的 []uint8 分配热点。

graph TD
A[pprof HTTP server] –> B[采集 goroutine/heap profile]
B –> C[生成火焰图]
C –> D[识别宽底座/高频栈帧]
D –> E[回溯源码中未关闭 channel/未释放 slice]

4.4 避免误报:区分正常缓存增长、sync.Pool复用抖动与真实泄漏的信号指纹识别

三类行为的内存时序特征对比

行为类型 GC 周期内存增量趋势 对象存活率(GC 后) Pool Get/Free 频次波动
正常缓存增长 缓慢上升,渐趋平稳 >90% 低频、稳定
sync.Pool 抖动 周期性尖峰(~3–5 GC) 30%–70%(动态回收) 高频、相位同步于 GC
真实泄漏 持续单向增长 ≈100% 无关联或持续递增

关键诊断代码片段

// 采集连续5次GC后的堆对象统计
var stats runtime.MemStats
for i := 0; i < 5; i++ {
    runtime.GC()
    runtime.ReadMemStats(&stats)
    fmt.Printf("GC[%d]: Alloc=%v, HeapObjects=%v\n", 
        i, stats.Alloc, stats.HeapObjects)
}

逻辑分析:HeapObjects 持续增长且 Alloc 与之强正相关,排除 Pool 复用抖动(后者在 GC 后显著回落);若 HeapObjects 增量 > Alloc/avg_obj_size,则指向未释放引用。

内存行为决策流

graph TD
    A[观测 HeapObjects 趋势] --> B{是否周期性回落?}
    B -->|是| C[sync.Pool 抖动]
    B -->|否| D{GC 后存活率 ≈100%?}
    D -->|是| E[真实泄漏]
    D -->|否| F[正常缓存增长]

第五章:从预警到根治——Go内存治理的终局思考

预警不是终点,而是根因挖掘的起点

某电商大促期间,订单服务P99延迟突增300ms,pprof heap profile显示runtime.mallocgc调用频次飙升至每秒12万次。但仅靠go tool pprof -alloc_objects无法定位问题——真正元凶是日志模块中一个被反复调用的fmt.Sprintf("%s:%d", key, value),在高频订单ID拼接场景下每秒生成27万临时字符串对象。我们通过go tool trace捕获GC pause事件与goroutine阻塞链,最终在trace timeline中发现log.WithField调用栈与runtime.gcAssistAlloc强相关,证实逃逸分析失效导致堆分配激增。

从逃逸分析到编译器级优化

执行go build -gcflags="-m -m"后,关键函数输出如下:

// pkg/log/logger.go:42:6: &msg does not escape
// pkg/log/logger.go:43:18: leaking param: msg to heap
// pkg/log/logger.go:45:22: newLogEntry(...) escapes to heap

这揭示了结构体字段msg string被嵌入logEntry后强制逃逸。我们重构为logEntry{msg: unsafe.String(unsafe.Slice(...))},配合-gcflags="-l"禁用内联(避免编译器误判),使该路径对象分配量下降92%。

生产环境内存治理双轨机制

治理阶段 工具链 触发阈值 响应动作
实时预警 Prometheus + go_memstats_heap_alloc_bytes 连续3分钟 > 800MB 自动触发curl http://localhost:6060/debug/pprof/heap?debug=1 > heap.pprof
根因闭环 eBPF + bcc-tools malloc调用栈TOP3占比超65% 注入bpftrace -e 'kprobe:__libc_malloc { @stack = ustack(); }'采集用户态调用链

持续验证的黄金指标

  • GC Pause P99 ≤ 1.2ms(实测从4.7ms降至0.8ms)
  • Heap Objects Allocated/sec
  • runtime.ReadMemStats().Mallocs - prev.Mallocs delta

真实案例:支付网关的零停机内存手术

2023年Q4,某支付网关因http.Request.Body未关闭导致io.Copy持续持有bytes.Buffer引用。通过go tool pprof -inuse_space发现net/http.(*body).readLocked持有1.2GB内存。我们采用defer req.Body.Close()+io.Copy(ioutil.Discard, req.Body)双保险,并在HTTP中间件注入httptrace.ClientTrace监听GotConn事件,当连接复用率net/http.DefaultTransport.IdleConnTimeout = 30s动态调整。

构建可审计的内存契约

在go.mod中声明//go:build memsafe约束,并为每个核心包添加memcontract.go:

//go:build memsafe
package order

// MemoryContract ensures no heap allocation in critical path
// ContractID: ORDER_PROCESS_2024_Q1
// MaxAllocPerCall: 0
// VerifiedBy: pprof -alloc_space -seconds=60
func ProcessOrder(ctx context.Context, o *Order) error {
    // ... zero-allocation logic using sync.Pool & pre-allocated slices
}

CI流水线强制执行go test -gcflags="-m" ./... | grep -E "(escape|heap)" | grep -v "no escape",任何新引入的逃逸均阻断合并。

治理效果量化看板

flowchart LR
    A[每秒GC次数] -->|下降76%| B[平均STW时间]
    C[堆对象数] -->|下降89%| D[young generation存活率]
    E[goroutine峰值] -->|下降41%| F[调度器延迟]
    B --> G[API P99延迟]
    D --> G
    F --> G

工程化落地的三个硬性标准

  • 所有HTTP handler必须通过go tool vet -printfuncs="log.Printf,log.Errorf"校验格式化字符串安全性
  • 内存敏感模块需提供BenchmarkAllocsPerOperation基准测试,且benchstat对比结果ΔAllocs ≤ 0
  • pprof采集脚本嵌入K8s liveness probe,当/debug/pprof/heap?debug=1响应体>5MB时触发Pod重启

跨团队协同治理协议

运维团队每月提供container_memory_working_set_bytes{pod=~"order.*"}的P95分位趋势图;SRE团队按季度发布go_gc_duration_seconds历史波动热力图;开发团队在每次CR中必须附带go tool pprof -top10截屏,标注本次变更对Top3分配热点的影响系数。

扎根云原生,用代码构建可伸缩的云上系统。

发表回复

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