Posted in

规则引擎响应延迟突增300ms?不是CPU也不是网络——Go runtime监控盲区与pprof+trace双维度诊断手册

第一章:规则引擎响应延迟突增300ms的典型现象与业务影响

规则引擎在实时风控、个性化推荐和订单履约等关键链路中承担决策中枢角色。当其平均响应延迟从常规的50–80ms突然跃升至350–400ms,将直接触发服务SLA告警,并引发下游系统级联超时。该现象并非偶发抖动,而常表现为持续10–30分钟的平台性延迟尖峰,伴随CPU使用率无显著升高、GC频率稳定、网络RTT正常等“反常”特征,易被误判为业务逻辑变更所致。

延迟突增的典型业务后果

  • 支付风控决策超时:用户提交支付请求后卡在“审核中”,3秒内未返回结果即触发降级策略,导致约12%的高风险交易被误放行;
  • 推荐流首屏加载延迟:APP端等待规则引擎打分超时后回退至冷启策略,点击率下降23%,次日留存率下滑1.8个百分点;
  • 订单履约路径阻塞:WMS系统依赖规则引擎动态计算分仓优先级,延迟导致3.7%的订单分配错误,需人工干预重调度。

根本原因定位方法

通过JVM字节码增强技术,在Drools KieSession.fireAllRules() 方法入口埋点,捕获每次规则匹配耗时分布。执行以下诊断脚本可快速识别瓶颈规则集:

# 启用Drools内置性能监控(需KIE 7.65+)
java -Dorg.drools.rule.engine.enableRuleProfiling=true \
     -Dorg.drools.rule.engine.profileThreshold=50 \
     -jar rules-service.jar

启动后,引擎自动输出rule-profiling.log,其中包含各规则组(RuleFlowGroup)的平均匹配耗时。常见问题模式包括:单条规则含嵌套循环遍历超5000条事实、accumulate函数未加from约束导致全量扫描、或@timestamp字段缺失导致时间窗口无法剪枝。

关键配置优化项

配置项 默认值 推荐值 效果说明
drools.sequential false true 强制顺序执行,避免并行匹配引发的锁竞争
kie.session.clock real-time pseudo 使用伪时钟替代系统时钟,规避纳秒级时间戳生成开销
drools.rule.compile.on.demand false true 按需编译规则,减少首次fire时的JIT编译延迟

定位到问题规则后,应立即通过kieContainer.newKieSession(kieBase, conf)传入定制化KieSessionConfiguration,启用setOption(SequentialOption.get())并禁用冗余日志,通常可在5分钟内恢复P95延迟至80ms以内。

第二章:Go runtime监控盲区深度解析

2.1 Goroutine泄漏与调度器饥饿:从GMP模型看隐性延迟源

Goroutine泄漏常源于未关闭的channel监听或遗忘的time.AfterFunc,导致G长期驻留于P的本地队列或全局队列中,无法被GC回收。

Goroutine泄漏典型模式

func leakyHandler(ch <-chan int) {
    go func() {
        for range ch { // 若ch永不关闭,此goroutine永不停止
            // 处理逻辑
        }
    }()
}

该goroutine一旦启动即脱离调用栈生命周期管理;range阻塞在已关闭/无发送者的channel时才会退出,否则持续占用G结构体和栈内存。

调度器饥饿的触发链

graph TD
    A[大量阻塞型G] --> B[抢占失效]
    B --> C[P本地队列积压]
    C --> D[其他P空闲但无法借G]
    D --> E[整体吞吐下降+尾部延迟飙升]
现象 根因 观测指标
P.mcache频繁GC G泄漏导致M绑定G数超限 runtime.NumGoroutine()持续增长
sched.latency >5ms 长时间未被调度的G排队等待 go tool trace中G状态热图

避免泄漏的关键是显式生命周期控制:使用context.Context取消、sync.WaitGroup同步、或select{case <-ctx.Done(): return}

2.2 内存分配路径中的Stop-the-World扰动:mcache/mcentral/mheap协同失衡实测分析

Go运行时内存分配依赖三层结构协同:mcache(线程本地)、mcentral(全局中心池)、mheap(堆主控)。当某类对象频繁分配且mcache耗尽时,需跨层申请,触发mcentral.lock争用;若mcentral亦空,则升级为mheap.lock持有,并可能触发scavenger或GC辅助回收——此过程在STW阶段被强制串行化。

数据同步机制

mcachemcentral归还span时采用无锁队列+原子计数,但mcentralmheap申请新span需加锁:

// src/runtime/mcentral.go:132
func (c *mcentral) cacheSpan() *mspan {
    c.lock()           // ⚠️ 全局锁,高并发下成为瓶颈
    s := c.nonempty.pop()
    if s == nil {
        c.grow()       // → 触发mheap.allocMSpan,可能阻塞
    }
    c.unlock()
    return s
}

逻辑分析:c.lock()为互斥锁,非可重入;grow()内部调用mheap_.allocSpanLocked(),若需向OS申请内存(sysAlloc),将触发stopTheWorld级同步。参数cruntime.mcentral实例,其nonempty链表长度在压测中下降超70%时,锁等待时间跃升3.8×。

实测关键指标(16核/64GB,50K goroutines/s)

指标 正常态 失衡态 变化率
mcentral.lock持有均值 42ns 1.2μs +2800%
STW中分配延迟占比 1.3% 37.6% +2792%

协同失衡传播路径

graph TD
    A[mcache miss] --> B{mcentral nonempty empty?}
    B -->|Yes| C[mcentral.lock acquired]
    C --> D{mcentral has free span?}
    D -->|No| E[mheap.allocSpanLocked → stopTheWorld]
    E --> F[GC mark assist delay ↑]

2.3 GC标记阶段并发度不足与辅助GC触发异常:基于GOGC与gcPercent的调优验证

Go 运行时在高吞吐场景下易因标记阶段 Goroutine 并发度受限(runtime.markrootSpans 单线程扫描)导致 STW 延长,同时 GOGC=100(即 gcPercent=100)在内存突增时可能过早触发辅助 GC,加剧 CPU 竞争。

根本诱因分析

  • 标记阶段默认仅使用 GOMAXPROCS 的 25% 并发 worker(受 gcMarkWorkerModeDedicated 调度策略限制)
  • gcPercent=100 意味着堆增长达上次 GC 后存活对象大小的 100% 即触发 GC,小对象高频分配易误判为“内存泄漏”

调优验证代码

// 启动时显式配置:提升标记并发性 + 延迟辅助GC触发阈值
func init() {
    debug.SetGCPercent(150) // 提高到150%,降低GC频率
    runtime.GC()             // 强制一次初始GC,使新gcPercent生效
}

此配置将辅助 GC 触发阈值从 heap_live × 2 放宽至 heap_live × 2.5,减少非必要标记压力;SetGCPercent 在首次 GC 后生效,需主动触发初始化 GC。

关键参数对照表

参数 默认值 调优值 影响
GOGC 100 150 延迟GC触发,降低频率
GOMAXPROCS CPU核数 保持不变 保障标记worker最大并发基数

GC调度行为变化流程

graph TD
    A[内存分配] --> B{heap_live ≥ heap_last_gc × 1.5?}
    B -->|否| C[继续分配]
    B -->|是| D[启动辅助GC]
    D --> E[分配goroutine执行mark assist]
    E --> F[worker并发度 = min(4, GOMAXPROCS/4)]

2.4 网络轮询器(netpoll)阻塞与epoll_wait虚假就绪:syscall.Syscall与runtime.netpoll对比实验

核心差异定位

Go 运行时的 runtime.netpoll 并非直接封装 epoll_wait,而是通过 非阻塞轮询 + 信号协作 + 事件聚合 机制规避内核虚假就绪(spurious wakeup);而裸调 syscall.Syscall(SYS_epoll_wait, ...) 易受边缘条件干扰。

对比实验关键代码

// 方式1:原始 syscall.epoll_wait(易触发虚假就绪)
n, _, _ := syscall.Syscall(syscall.SYS_epoll_wait, uintptr(epfd), uintptr(unsafe.Pointer(&events[0])), uintptr(len(events)), 1000)

// 方式2:runtime.netpoll(自动过滤重复/过期事件)
gp := acquireg()
netpoll(true) // block = true,内部已做事件去重与时间戳校验

syscall.Syscall 参数说明:epfd 为 epoll 实例 fd;events 是用户分配的事件数组指针;1000 为超时毫秒值。该调用无上下文感知,无法识别内核因 EPOLLONESHOT 重置或 EPOLLET 边沿触发丢失导致的误报。

性能与可靠性对比

维度 syscall.Syscall(epoll_wait) runtime.netpoll
虚假就绪处理 自动过滤
阻塞语义 纯内核级阻塞 可被 GC/调度中断
事件准确性 依赖用户逻辑二次校验 内建 fd 状态快照
graph TD
    A[epoll_wait 返回] --> B{事件是否仍有效?}
    B -->|是| C[交付 Goroutine]
    B -->|否| D[丢弃并继续轮询]
    D --> A
    C --> E[netpoll 保证原子性交付]

2.5 锁竞争与sync.Pool误用:在规则匹配高频场景下的原子操作替代实践

数据同步机制

在规则引擎中,每秒数万次的正则匹配常伴随共享状态更新(如命中计数、缓存淘汰),传统 sync.Mutex 易引发锁争用。

sync.Pool 的陷阱

  • 规则对象生命周期短但构造开销大(含编译正则、预分配缓冲);
  • sync.Pool 在高并发下因本地池窃取(victim cache)导致 GC 压力与内存抖动;
  • 实测显示:Pool 复用率仅 32%,反增 18% 分配延迟。

原子操作重构方案

// 使用 atomic.Value 存储不可变规则快照,避免锁
var ruleSnapshot atomic.Value // 类型为 *RuleSet

type RuleSet struct {
    Rules []compiledRule
    Version uint64 // 用于乐观并发控制
}

// 更新时生成新实例,CAS 替换
func updateRules(newRules []compiledRule) {
    rs := &RuleSet{Rules: newRules, Version: atomic.AddUint64(&globalVer, 1)}
    ruleSnapshot.Store(rs) // 无锁发布
}

逻辑分析:atomic.Value.Store() 是线程安全的指针替换,零拷贝;RuleSet 设计为不可变结构,使读侧(匹配函数)完全无锁调用 ruleSnapshot.Load().(*RuleSet)。参数 Version 供外部做 ABA 检测,避免脏读。

性能对比(10K QPS)

方案 P99 延迟 CPU 占用 GC 次数/秒
Mutex + struct 42ms 78% 120
sync.Pool 38ms 85% 210
atomic.Value 11ms 41%
graph TD
    A[匹配请求] --> B{读取 ruleSnapshot.Load}
    B --> C[获取 *RuleSet]
    C --> D[遍历 Rules 执行匹配]
    D --> E[返回结果]
    F[规则热更新] --> G[构造新 RuleSet]
    G --> H[atomic.Store]
    H --> B

第三章:pprof多维采样策略构建

3.1 cpu profile精准捕获规则执行热点:–block-profile-rate=0与–mutex-profile-fraction=1的组合配置

该组合专为排除阻塞干扰、聚焦锁竞争热点而设:--block-profile-rate=0禁用 goroutine 阻塞采样,避免 I/O 等待噪声;--mutex-profile-fraction=1则强制对每次 sync.Mutex 加锁/解锁事件全量记录。

mutex 热点定位示例

// 启动时添加如下 flag
func main() {
    flag.Set("mutexprofilefraction", "1") // 等价于 --mutex-profile-fraction=1
    flag.Set("blockprofilerate", "0")      // 等价于 --block-profile-rate=0
    // ...业务逻辑
}

逻辑分析:blockprofilerate=0 关闭阻塞分析(默认 1,即每 1 次阻塞采样 1 次),彻底消除网络/磁盘等待伪热点;mutexprofilefraction=1 表示 100% 采样率(值为 1 时启用全量记录),确保细粒度锁争用路径可追溯。

典型锁竞争场景对比

配置组合 CPU Profile 可信度 Mutex 热点完整性 Block Profile 噪声
默认 中等 低(采样率=0)
本节组合 完整
graph TD
    A[Go 程序启动] --> B{--block-profile-rate=0?}
    B -->|是| C[跳过 runtime.blockEvent 记录]
    B -->|否| D[按 rate 采样阻塞事件]
    A --> E{--mutex-profile-fraction=1?}
    E -->|是| F[每次 Lock/Unlock 写入 profile]
    E -->|否| G[按 fraction 概率采样]

3.2 heap profile识别规则DSL对象生命周期异常:pprof –inuse_space vs –alloc_space双视角比对

Go 规则引擎中,DSL 对象常因误用 new() 或未及时释放引用导致内存滞留。--inuse_space 反映当前存活对象占用堆空间,而 --alloc_space 统计全量分配总量(含已 GC 对象)。

双视角差异本质

  • --inuse_space:仅包含仍可达的对象(如缓存未清理、闭包捕获长生命周期变量)
  • --alloc_space:包含所有 mallocgc 调用,暴露高频短命对象(如循环内重复构建 AST 节点)

典型异常模式

# 捕获分配热点(含已释放对象)
go tool pprof --alloc_space http://localhost:6060/debug/pprof/heap

# 对比当前驻留对象
go tool pprof --inuse_space http://localhost:6060/debug/pprof/heap

--alloc_space 高但 --inuse_space 低 → 短生命周期对象爆炸;反之则存在内存泄漏。

指标 含义 异常信号
--alloc_space 累计分配字节数 持续增长且无衰减趋势
--inuse_space 当前存活对象总大小 缓慢爬升不回落
// 错误示例:每次 evaluate 都新建 RuleSet 实例,但被全局 map 意外持有
func (e *Engine) evaluate(ruleStr string) {
    rs := NewRuleSet(ruleStr) // alloc_space 激增
    e.cache[ruleStr] = rs      // inuse_space 持续累积
}

NewRuleSetevaluate 中高频调用,若 e.cache 未设 TTL 或驱逐策略,--inuse_space 将线性增长,而 --alloc_space 更剧烈——揭示“分配即泄漏”的 DSL 对象生命周期失控。

3.3 goroutine profile定位规则链式调用阻塞点:runtime.GoroutineProfile与自定义trace.Tag注入联动

在高并发规则引擎中,链式调用(如 Validate → Enrich → Score → Notify)易因某环节阻塞导致 goroutine 积压。runtime.GoroutineProfile 可捕获全量 goroutine 状态,但原始堆栈缺乏业务上下文。

数据同步机制

通过 trace.Tag 注入调用链标识,实现 profile 与业务 trace 关联:

// 在每条规则链入口注入唯一 trace tag
ctx = trace.WithTag(ctx, "rule_id", "credit_score_v2")
ctx = trace.WithTag(ctx, "stage", "enrich")
runtime.GoroutineProfile(goroutines) // 此时 runtime.Stack() 中隐含 trace 标签

逻辑分析runtime.GoroutineProfile 返回 []*runtime.GoroutineStackRecord,其中 Stack0 字段含完整调用帧;结合 trace.FromContext(ctx) 提取的 tag,可反向映射阻塞 goroutine 所属规则阶段。rule_id 用于归类,stage 标识链路位置,二者构成二维定位坐标。

阻塞根因分类表

阶段 常见阻塞源 Profile 中典型状态
Validate 外部 HTTP 超时 net/http.(*persistConn).readLoop
Enrich Redis pipeline 阻塞 github.com/go-redis/redis/v9.(*Pipeline).Exec
Score 同步锁竞争 sync.runtime_SemacquireMutex

定位流程图

graph TD
    A[触发 GoroutineProfile] --> B[解析 Stack0 帧]
    B --> C{匹配 trace.Tag 注入点?}
    C -->|是| D[关联 rule_id + stage]
    C -->|否| E[标记为未知链路]
    D --> F[聚合同 stage 阻塞数]
    F --> G[定位最高频阻塞 stage]

第四章:trace可视化诊断实战体系

4.1 trace事件埋点规范:在RuleEngine.Execute、Matcher.Match、Action.Evaluate关键路径注入trace.WithRegion

为实现端到端可观测性,需在规则引擎核心执行链路精准注入结构化 trace 区域。

埋点位置与语义对齐

  • RuleEngine.Execute:标记整条规则流水线的生命周期
  • Matcher.Match:标识条件匹配阶段(支持多 matcher 并行追踪)
  • Action.Evaluate:捕获动作表达式求值上下文

典型埋点代码示例

func (e *RuleEngine) Execute(ctx context.Context, facts map[string]interface{}) error {
    ctx, span := trace.WithRegion(ctx, "RuleEngine.Execute") // 自动绑定spanID、采样策略、parentSpanID
    defer span.End()
    // ... 执行逻辑
}

trace.WithRegion 自动继承父上下文中的 traceID 和 spanID,"RuleEngine.Execute" 作为 operation name 被写入 span 标签,用于服务拓扑聚合。

关键参数对照表

参数 类型 说明
ctx context.Context 携带 trace 上下文的传播载体
"RuleEngine.Execute" string 语义化区域名称,影响 APM 分组与告警策略

执行链路可视化

graph TD
    A[RuleEngine.Execute] --> B[Matcher.Match]
    B --> C[Action.Evaluate]
    C --> D[Result.Commit]

4.2 Go trace文件时序图解读:识别GC Pause、Scheduler Delay、Network Block三类非CPU延迟峰

Go trace 工具生成的时序图(go tool trace)以垂直时间轴呈现 Goroutine、OS 线程与系统调用的调度状态,其中非CPU延迟峰直观反映性能瓶颈。

GC Pause 峰特征

表现为 Goroutine 全局停顿(所有 P 进入 GCSTW 状态),在 Proc 行中出现宽幅空白,持续时间通常为毫秒级。可通过以下命令提取关键事件:

go tool trace -pprof=heap ./trace.out  # 定位GC触发点

该命令导出堆快照,结合 runtime.ReadMemStats 可验证是否因堆增长过快触发 STW。

Scheduler Delay 与 Network Block 对比

类型 触发场景 时序图表现
Scheduler Delay Goroutine 就绪但无空闲 P G 处于 Runnable,P 长时间 RunningIdle
Network Block read/write 等阻塞系统调用 G 进入 Syscall,M 脱离 P,P 继续调度其他 G

诊断流程

graph TD
    A[加载 trace.out] --> B{是否存在长时空白?}
    B -->|是| C[检查 GCSTW 标记]
    B -->|否| D[筛选 Syscall/Runnable 状态跃迁]
    C --> E[确认 GC 次数与 heap_alloc 增长率]
    D --> F[定位 netpoll_wait 或 epoll_wait 调用栈]

4.3 pprof+trace交叉验证法:将trace中高耗时span映射至pprof火焰图函数栈

当发现 trace 中某 span 耗时异常(如 db.Query 占 850ms),需精确定位其在 CPU/heap 上的调用栈归属。

核心思路

通过统一 trace ID 关联分布式追踪与采样数据:

  • 启动服务时注入 GODEBUG=gctrace=1net/http/pprof
  • 在 trace span 创建处手动注入 pprof.SetGoroutineLabels 绑定 traceID。

关键代码示例

// 在 span 开始处绑定 traceID 到 goroutine label
ctx, span := tracer.Start(ctx, "db.Query")
defer span.End()

// 将 traceID 注入 pprof 标签,使后续 CPU profile 可关联
pprof.SetGoroutineLabels(
    pprof.Labels("trace_id", span.SpanContext().TraceID().String()),
)

此代码使 go tool pprof 在解析 CPU profile 时能按 trace_id 过滤栈帧;SetGoroutineLabels 仅影响当前 goroutine,避免跨协程污染。

验证流程

步骤 工具 输出目标
1. 采集 trace Jaeger/OTLP 获取 trace_id 与高耗时 span
2. 采集 profile curl :6060/debug/pprof/profile?seconds=30 生成 cpu.pprof
3. 交叉过滤 go tool pprof -http=:8080 -tagfocus trace_id=abcd1234 cpu.pprof 火焰图聚焦该 trace 的函数栈
graph TD
    A[Span in Trace UI] -->|copy trace_id| B[Filter pprof by label]
    B --> C[Flame Graph with matched stacks]
    C --> D[定位到 runtime.mallocgc / database/sql.rows.Next]

4.4 生产环境低开销trace采集方案:基于runtime/trace的采样率动态降频与ring buffer持久化

在高吞吐服务中,全量 runtime/trace 会引发显著 GC 压力与 I/O 阻塞。核心优化路径是采样率动态调控内存内环形缓冲写入

动态采样控制器

type TraceSampler struct {
    baseRate  uint64 // 初始采样分母(如100 → 1%)
    loadFloor uint64 // CPU >85%时触发降频
    mu        sync.RWMutex
}

func (s *TraceSampler) Sample() bool {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return rand.Uint64()%s.baseRate == 0
}

逻辑:基于实时 runtime.ReadMemStats/proc/stat CPU 负载反馈,通过原子更新 baseRate 实现毫秒级采样率升降(如从 1001000 表示采样率从 1% 降至 0.1%)。

Ring Buffer 持久化设计

字段 类型 说明
buffer []byte 预分配 4MB lock-free 环形区
writePos uint64 原子递增写位置
flushTrigger chan int 达 80% 容量时触发异步刷盘

数据流转流程

graph TD
    A[goroutine trace event] --> B{Sampler.Decide()}
    B -->|true| C[Encode to binary]
    C --> D[RingBuffer.WriteAtomic]
    D --> E[FlushWorker: batch write to file]
    B -->|false| F[Drop silently]

第五章:从诊断到治理——风控规则引擎Go性能治理闭环

在某大型互联网金融平台的实时风控系统中,规则引擎日均处理超2亿次决策请求,峰值QPS达12,000。上线初期,P99延迟突增至850ms(SLA要求≤200ms),触发多次告警。团队立即启动性能治理闭环,覆盖诊断、定位、优化、验证、沉淀五个关键阶段。

火热的火焰图诊断现场

通过pprof采集生产环境30秒CPU profile,生成火焰图发现rule.Evaluate()调用栈中regexp.Compile被高频重复执行——每条规则解析时动态编译正则表达式,单次耗时平均47ms。进一步检查代码确认:规则配置未预编译,且无缓存机制。

规则加载阶段的预编译改造

将正则编译逻辑从运行时移至规则加载阶段,并引入sync.Map缓存已编译的*regexp.Regexp实例:

var compiledRegexps sync.Map // key: pattern string, value: *regexp.Regexp

func compilePattern(pattern string) (*regexp.Regexp, error) {
    if v, ok := compiledRegexps.Load(pattern); ok {
        return v.(*regexp.Regexp), nil
    }
    re, err := regexp.Compile(pattern)
    if err != nil {
        return nil, err
    }
    compiledRegexps.Store(pattern, re)
    return re, nil
}

治理前后的核心指标对比

指标 治理前 治理后 降幅
P99延迟 850ms 162ms ↓81%
CPU使用率(峰值) 92% 41% ↓55%
GC Pause(avg) 18ms 2.3ms ↓87%
规则匹配吞吐量 9.2k/s 13.8k/s ↑50%

基于eBPF的持续观测看板

部署bpftrace脚本实时捕获runtime.mallocgcsyscall.read事件,结合Prometheus暴露自定义指标rule_engine_regex_compile_totalrule_eval_duration_seconds_bucket,构建Grafana看板实现毫秒级异常检测。当regex_compile_total 5分钟内增长超300次,自动触发告警并关联规则ID。

治理闭环的自动化卡点

在CI/CD流水线中嵌入性能门禁:每次规则引擎PR合并前,必须通过本地基准测试(go test -bench=RuleEval -benchmem)与混沌测试(注入time.Sleep(50ms)模拟慢规则)。若P95延迟超过180ms或内存分配增长超15%,流水线强制阻断。

规则DSL语法树的内存复用优化

分析pprof heap profile发现ast.Node结构体频繁创建。重构AST解析器,采用对象池复用ConditionNodeExpressionNode实例:

var nodePool = sync.Pool{
    New: func() interface{} {
        return &ConditionNode{Ops: make([]Op, 0, 4)}
    },
}

该优化使每万次规则解析减少堆分配21,400次,GC压力显著下降。

多维度回归验证矩阵

上线后连续72小时采集全链路Trace(Jaeger),对TOP 50高频规则分别验证:① 单规则压测(wrk -t4 -c100 -d30s);② 混合规则并发(模拟真实策略组合);③ 故障注入场景(模拟DB延迟、Redis超时);④ 内存泄漏扫描(go tool pprof -http=:8080 mem.pprof)。所有维度均满足SLO阈值。

治理知识资产沉淀机制

将本次优化的regexp预编译方案、AST对象池实践、eBPF观测脚本封装为内部Go SDK模块github.com/fintech/rulesdk/v3,版本号语义化管理,并同步更新《风控引擎性能治理Checklist》第12版。

深入 goroutine 与 channel 的世界,探索并发的无限可能。

发表回复

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