Posted in

广告归因分析Go服务OOM频发?深度剖析pprof+trace+heapdump三维度定位法(附可复用诊断脚本)

第一章:广告归因分析Go服务OOM频发现象与背景洞察

近期,广告归因分析平台的核心Go服务在高并发流量时段频繁触发OOM(Out of Memory)信号,导致容器被Linux OOM Killer强制终止。该服务承担实时点击/曝光事件匹配、设备指纹解析及多渠道归因路径计算,日均处理超80亿事件,峰值QPS达12万。内存使用曲线呈现周期性陡升——每15分钟增长约1.2GB,直至达到Kubernetes配置的3GB内存limit后崩溃重启。

现象特征分析

  • 每次OOM前,runtime.ReadMemStats() 显示 HeapInuse 持续攀升,但 HeapReleased 基本为0,表明Go运行时未主动向OS归还内存;
  • pprof heap profile 显示 []bytemap[string]interface{} 占用堆内存TOP2,合计占比达67%;
  • GC pause时间从常规的200μs飙升至12ms以上,且GC cycle间隔从2min延长至8min,说明内存压力已干扰调度器。

关键代码隐患定位

以下片段在事件解析流程中被高频调用,存在隐式内存泄漏风险:

func parseEvent(raw []byte) *AttributionEvent {
    // ❌ 错误:直接将原始字节切片赋值给结构体字段
    // 导致底层底层数组无法被GC回收(即使raw来自复用的bytes.Pool)
    return &AttributionEvent{
        Payload: raw, // 引用原始缓冲区,延长其生命周期
        Timestamp: time.Now(),
    }
}

根本原因归纳

因素类别 具体表现
数据结构滥用 使用map[string]interface{}承载动态JSON,引发大量逃逸分配和interface{}头开销
缓冲区管理失当 未对bytes.Buffer[]byte做显式cap限制,扩容导致内存碎片化
并发控制缺失 sync.Map被误用于高频写场景,实际应采用分片map+读写锁降低锁竞争

紧急缓解措施

  1. 在Kubernetes Deployment中添加内存软限:resources.limits.memory=3Giresources.requests.memory=2.5Gi,提升调度器内存预留精度;
  2. 启用Go 1.21+的GODEBUG=madvdontneed=1环境变量,强制启用MADV_DONTNEED策略加速内存归还;
  3. 对所有json.Unmarshal调用点插入浅拷贝逻辑:payloadCopy := append([]byte(nil), raw...),切断原始缓冲区引用链。

第二章:pprof深度剖析——内存泄漏与热点函数精准定位

2.1 pprof基础原理与Go运行时内存模型解析

pprof 通过 Go 运行时暴露的采样接口(如 runtime.SetMutexProfileFractionruntime.ReadMemStats)获取底层指标,其本质是事件驱动的轻量级采样器,而非全量追踪。

Go 内存模型核心分层

  • Stack:goroutine 私有,按需增长,不参与 GC
  • Heap:全局共享,由 mspan/mcache/mcentral/mheap 组织,受 GC 管理
  • Globals & Stack roots:作为 GC 根对象参与可达性分析

内存采样关键路径

// 启用堆分配采样(每 512KB 分配触发一次 stack trace 记录)
runtime.MemProfileRate = 512 * 1024

MemProfileRate=0 关闭采样;=1 全量记录(性能损毁);默认 (即仅统计总量,无调用栈)。该值控制 mheap.allocSpan 中是否调用 profilealloc 记录调用帧。

采样类型 触发机制 数据粒度
heap 内存分配/释放事件 按 span 分配栈 + size
goroutine debug.ReadGoroutines 当前所有 goroutine 状态快照
mutex 锁竞争时 mutex.profile 阻塞栈 + 等待时长
graph TD
    A[pprof HTTP handler] --> B[Runtime API]
    B --> C{MemProfileRate > 0?}
    C -->|Yes| D[recordStack → mProf]
    C -->|No| E[只更新 ms.mallocs/ms.total_alloc]

2.2 实战:在K8s环境中动态启用HTTP pprof并规避安全风险

安全启用策略

默认暴露 pprof(如 /debug/pprof/)存在信息泄露与拒绝服务风险。推荐通过 条件式 ServiceMesh 注入 + 鉴权代理 实现按需启用。

动态注入示例(Envoy Filter)

# envoy-filter-pprof.yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: enable-pprof-on-demand
spec:
  workloadSelector:
    labels:
      app: backend
  configPatches:
  - applyTo: HTTP_ROUTE
    match:
      context: SIDECAR_INBOUND
      routeConfiguration:
        vhost:
          name: "inbound|http|8080"
          route:
            name: "pprof-route"
    patch:
      operation: INSERT_FIRST
      value:
        name: pprof-route
        match: { prefix: "/debug/pprof/" }
        route: { cluster: "backend" }
        typed_per_filter_config:
          envoy.filters.http.ext_authz: # 启用RBAC校验
            "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
            check: { disabled: false }

此配置仅当 Pod 标签含 app: backend 且请求路径匹配 /debug/pprof/ 时,才触发外部授权服务(如 OPA)鉴权;未通过则 403 拒绝。避免全局暴露。

推荐访问控制矩阵

访问方式 是否允许 条件说明
kubectl port-forward 限管理员本地调试
ClusterIP 直连 默认禁用,需显式加注解启用
Ingress 公网 网关层自动拦截

流程控制逻辑

graph TD
  A[HTTP 请求 /debug/pprof/] --> B{Envoy Route Match?}
  B -->|Yes| C[Ext Authz 调用 OPA 策略]
  C -->|Allow| D[转发至应用容器]
  C -->|Deny| E[返回 403]
  B -->|No| F[404 Not Found]

2.3 从alloc_objects到inuse_space:四类关键profile的语义辨析与选择策略

Go 运行时 runtime/pprof 提供四类核心内存 profile,语义差异显著:

  • alloc_objects:累计分配对象数(含已回收),反映分配频度
  • alloc_space:累计分配字节数,衡量吞吐压力
  • inuse_objects:当前存活对象数,揭示内存驻留规模
  • inuse_space:当前堆上实际占用字节数,表征真实内存水位
// 启用 inuse_space profile 示例
pprof.WriteHeapProfile(os.Stdout) // 默认输出 inuse_space

该调用触发运行时快照,仅采集当前 GC 周期后仍可达的对象内存布局;inuse_space 不含元数据开销,是评估 OOM 风险最直接指标。

Profile 采样时机 GC 敏感性 典型用途
alloc_objects 每次 malloc 发现高频小对象分配热点
inuse_space GC 后快照 定位内存泄漏主因
graph TD
    A[New Object] --> B{GC 是否可达?}
    B -->|是| C[inuse_objects/inuse_space]
    B -->|否| D[计入 alloc_objects/alloc_space 后丢弃]

2.4 基于pprof火焰图识别广告归因链路中的goroutine堆积与sync.Pool误用

在高并发广告归因服务中,/debug/pprof/goroutine?debug=2 暴露大量阻塞在 sync.(*Pool).Get 的 goroutine,火焰图显示 http.HandlerFuncattributer.Processpool.Get() 路径持续占宽。

火焰图关键特征

  • 顶层 runtime.gopark 占比超65%,集中于 sync.poolChain.pushHead
  • attributer.Process 调用频次与 QPS 不匹配,存在池对象“只取不放”

典型误用代码

func (a *Attributer) Process(ctx context.Context, req *Request) *Response {
    buf := a.pool.Get().(*bytes.Buffer) // ✅ 正确获取
    defer a.pool.Put(buf)               // ❌ 缺失!实际未执行(panic前已return)
    buf.Reset()
    // ... 处理逻辑(可能panic)
    return &Response{Data: buf.String()}
}

逻辑分析defer a.pool.Put(buf) 在 panic 时不会执行,导致 *bytes.Buffer 永久泄漏;sync.Pool 无引用计数,对象无法回收,新请求不断新建 goroutine 等待 Get()

修复方案对比

方案 是否解决泄漏 是否引入锁开销 适用场景
defer pool.Put() + recover() 高频 panic 链路
buf := bytes.NewBuffer(nil) ✅(alloc) 低延迟敏感场景
sync.Pool + init() 预热 ⚠️(需严格配对) 稳定生命周期对象
graph TD
    A[HTTP Request] --> B[attributer.Process]
    B --> C{panic?}
    C -->|Yes| D[buf leaked in Pool]
    C -->|No| E[defer Put executed]
    D --> F[goroutine堆积等待Get]

2.5 自动化pprof采样脚本开发:支持按OOM触发条件智能抓取(附可复用Go CLI工具)

当进程内存持续飙升逼近系统限制时,被动轮询式采样往往错过关键窗口。我们设计了一个轻量级 Go CLI 工具 oomprof,通过 /sys/fs/cgroup/memory//proc/[pid]/status 实时监控 RSSVMS,触发阈值后自动调用 curl http://localhost:6060/debug/pprof/heap?debug=1 抓取快照。

核心监控策略

  • 基于滑动窗口计算 RSS 增长速率(MB/s)
  • 支持绝对阈值(如 --oom-threshold-mb=4096)与相对阈值(如 --oom-ratio=0.85
  • 采样后自动归档为 heap_20240521_142301.pb.gz

关键逻辑片段

// 检查是否满足OOM前采样条件
func shouldSample(pid int, thresholdMB, ratio float64) bool {
    rssMB := getProcRSSMB(pid)          // 从 /proc/[pid]/statm 解析 RSS 页数 × 4KB
    limitMB := getMemLimitMB(pid)       // 读取 cgroup memory.limit_in_bytes 或 fallback 到 total RAM
    return rssMB > thresholdMB || (limitMB > 0 && float64(rssMB)/limitMB > ratio)
}

该函数每200ms执行一次,避免高频系统调用;getProcRSSMB 使用 os.Open 直接解析,零依赖;getMemLimitMB 自动适配 systemd cgroup v1/v2 路径。

输出格式对照表

输出类型 文件名示例 内容说明
堆快照 heap_20240521.pb runtime.MemStats + 对象分布
元信息 meta.json PID、时间戳、RSS、触发条件
graph TD
    A[启动监控] --> B{RSS增长速率 > 50MB/s?}
    B -->|是| C[触发采样]
    B -->|否| D[检查绝对/相对阈值]
    D -->|满足| C
    C --> E[保存heap+meta]
    C --> F[发送告警Webhook]

第三章:trace协同诊断——协程生命周期与调度阻塞根因挖掘

3.1 Go trace机制底层实现与广告业务场景下的trace事件语义映射

Go 的 runtime/trace 通过轻量级的 proc 状态切换与 g(goroutine)生命周期钩子,在调度器关键路径插入 traceEvent 调用,底层基于环形缓冲区(traceBuf)和原子写指针实现零锁日志采集。

核心事件注入点

  • schedule() 中记录 goroutine 阻塞/就绪
  • newproc1() 记录 goroutine 创建
  • goexit() 记录退出

广告业务语义映射示例

// 在广告召回服务中注入业务语义事件
trace.WithRegion(ctx, "ad-recall", func() {
    trace.Log(ctx, "ad-slot", fmt.Sprintf("pos=%s", slotID)) // 自定义标签
    trace.Log(ctx, "bid-request-id", bidReqID)
})

此代码将 ad-recall 区域绑定到 Go 原生 trace event,使 GoroutineCreateGoBlockSync 等底层事件自动携带 slotIDbidReqID 上下文,实现跨调度器与业务逻辑的语义对齐。

事件类型 底层 trace 类型 广告语义含义
GoCreate traceEvGoCreate 新建召回协程
GoBlockNet traceEvGoBlockNet 等待 RTB 出价响应
UserRegionBegin traceEvUserRegion 进入定向人群过滤阶段
graph TD
    A[goroutine start] --> B[traceEvGoCreate]
    B --> C{ad-recall region}
    C --> D[traceEvUserRegion]
    D --> E[traceEvGoBlockNet]
    E --> F[RTB HTTP roundtrip]

3.2 从trace视图定位广告ID分片逻辑中的channel死锁与context超时传播失效

trace上下文断链现象

在Jaeger trace中观察到 ad_id_shard span 的 duration 异常延长(>15s),但下游 fetch_channel_config span 缺失,且 span.kind=client 标签中断——表明 context 超时未向下传递。

死锁关键路径

// channel 阻塞写入:无缓冲 channel + 单 goroutine 消费
configCh := make(chan *ChannelConfig) // ❌ 无缓冲
go func() {
    configCh <- loadConfig() // 阻塞直至消费,但消费者尚未启动
}()
cfg := <-configCh // 死锁在此

逻辑分析configCh 为无缓冲 channel,生产者在消费者 <-configCh 执行前即阻塞;而消费者依赖 ctx.WithTimeout() 初始化,因 context 超时未传播,导致 ctx.Done() 永不触发,形成环形依赖。

超时传播失效根因

组件 是否继承 parent ctx 超时是否生效 原因
shardRouter context.WithTimeout(parentCtx, 0) 错误传入 0s
channelLoader 直接使用 context.Background()

修复方案

  • make(chan *ChannelConfig) 改为带缓冲 make(chan *ChannelConfig, 1)
  • 统一使用 ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) 并 defer cancel
graph TD
    A[ad_id_shard span] -->|ctx.WithTimeout| B[shardRouter]
    B -->|错误创建 background ctx| C[channelLoader]
    C -->|阻塞写 configCh| D[deadlock]

3.3 trace+pprof交叉验证:识别“伪高内存占用”背后的GC STW异常延长问题

pprof 显示堆内存持续高位(如 heap_inuse > 1.2GB),但实际业务负载平稳,需警惕“伪高内存占用”——本质可能是 GC STW 时间异常拉长,导致对象堆积未及时回收。

关键诊断路径

  • go tool trace 提取 Goroutine execution tracer,聚焦 GC pause 事件时序;
  • 同步采集 runtime/pprofgoroutineheap profile;
  • 交叉比对:若 trace 中 STW 耗时突增至 80ms+,而 pprof heap 中 mallocs - frees 差值未同步激增 → 指向 STW 卡顿,非真实内存泄漏。

典型 trace 分析代码

// 启动 trace 并强制触发 GC 以捕获 STW 样本
f, _ := os.Create("trace.out")
trace.Start(f)
runtime.GC() // 强制一次 GC,确保 trace 包含 STW 事件
time.Sleep(100 * time.Millisecond)
trace.Stop()
f.Close()

此代码显式触发 GC,使 trace.out 包含可分析的 STW 区段;time.Sleep 确保 trace 收集完整周期。参数 100ms 需 ≥ 最大预期 STW 时长,避免截断。

STW 异常根因对照表

现象 可能原因 验证方式
STW > 50ms 且波动剧烈 大量 finalizer 未及时执行 go tool trace → View trace → Finalizer goroutine 排队
STW 稳定在 30–40ms 辅助 GC goroutine 资源争抢 pprof -http=:8080 查看 goroutinegcBgMarkWorker 状态
graph TD
    A[pprof heap: 高 inuse] --> B{STW 是否异常?}
    B -->|是| C[trace: GC pause > 50ms]
    B -->|否| D[真实内存泄漏]
    C --> E[检查 finalizer 队列/Mark Assist 延迟]
    E --> F[优化 finalizer 或减少大对象分配]

第四章:heapdump三阶比对法——堆内存快照的时序化建模与差异归因

4.1 heapdump生成原理与go tool pprof -dumpheap局限性深度剖析

Go 运行时通过 runtime.GC() 触发标记-清除后,自动将堆快照写入 runtime/debug.WriteHeapDump() 所指定的文件(需启用 GODEBUG=gctrace=1)。该机制依赖 mcentralmcache 的 span 元数据遍历,而非简单内存拷贝。

核心限制根源

  • -dumpheap 仅支持 Go 1.16+,且不捕获 goroutine 栈帧中的临时对象引用
  • 无法区分 finalizer 关联对象与普通存活对象
  • 输出格式为二进制 heapdump,非标准 pprof 协议,go tool pprof 仅作兼容解析,丢失类型元信息

典型误用示例

# ❌ 错误:-dumpheap 不接受 --alloc_space 等采样参数
go tool pprof -dumpheap -http=:8080 ./mybin heap.dump

此命令忽略所有采样标志,强制全量导出;-dumpheap 是独立子命令,不参与 pprof 的 profile 解析流水线。

特性 runtime/debug.WriteHeapDump pprof.Lookup("heap").WriteTo
是否含 goroutine 栈 是(间接)
是否可增量采样
输出格式 自定义二进制 protocol buffer
graph TD
    A[GC 结束] --> B[扫描 allgs + mheap.allspans]
    B --> C[序列化 span.base, size, spanclass]
    C --> D[写入 heap.dump 文件]
    D --> E[go tool pprof -dumpheap 解码]
    E --> F[重建 object → symbol 映射]
    F --> G[缺失类型字段/接口动态分派信息]

4.2 基于runtime/debug.WriteHeapDump的定制化快照采集策略(含goroutine上下文标记)

runtime/debug.WriteHeapDump 是 Go 1.19+ 引入的低开销堆快照接口,支持在运行时捕获精确的堆内存状态,且不触发 STW

核心增强:goroutine 上下文注入

通过 debug.SetGoroutineLabels 预设标签,并在 dump 前调用 debug.WriteHeapDump

// 在关键 goroutine 中注入业务上下文
debug.SetGoroutineLabels(map[string]string{
    "handler": "payment_timeout",
    "trace_id": "tr-7f8a2b1c",
})
debug.WriteHeapDump("/tmp/heap_payment_timeout.gz") // 自动关联当前 goroutine 标签

逻辑分析:WriteHeapDump 内部会读取当前 goroutine 的 labels 字段并写入快照元数据区;参数 /tmp/heap_payment_timeout.gz 支持 .gz 后缀自动压缩,路径需确保写入权限。

采集策略对比

策略 触发条件 标签可见性 文件大小增幅
全量定时采集 cron 每5分钟 +0%
异常指标触发 p99 > 2s + OOM +3–5%
手动标记采集 运维指令/API 调用 ✅✅(全链路) +8–12%

流程示意

graph TD
    A[触发采集] --> B{是否已标记 goroutine?}
    B -->|是| C[注入 labels 到 heapdump header]
    B -->|否| D[生成无上下文快照]
    C --> E[写入带 trace_id 的压缩文件]

4.3 三时刻堆比对:OOM前5min/前30s/触发瞬间的object growth趋势建模

为精准定位内存泄漏根因,需在OOM临界点采样三个关键时间窗口的堆快照并建模对象增长速率:

  • 前5分钟:基线期,捕获缓慢泄漏(如静态Map缓存未清理)
  • 前30秒:加速期,识别突发分配(如批量导入未分页)
  • 触发瞬间:快照冻结点,保留GC Roots强引用链
// 基于JDK jmap + jstat实现三时刻自动快照
Runtime.getRuntime().exec(
  "jmap -dump:format=b,file=/tmp/heap_pre5m.hprof " + pid // 前5min
);
// 注:实际生产需配合-XX:+HeapDumpBeforeFullGC与自定义Signal Handler

该命令触发堆转储,format=b指定二进制HPROF格式,pid为Java进程ID;需提前验证jmap权限及磁盘空间。

数据同步机制

三时刻快照通过jstat -gc高频轮询(1s间隔)对齐时间戳,避免时钟漂移。

时刻 GC次数 Eden使用率 对象增长率(/s)
前5min 12 68% +12.4K
前30s 8 92% +87.3K
触发瞬间 99.9% +214K
graph TD
  A[前5min快照] -->|Diff分析| B[对象类型TOP10]
  B --> C[增长斜率拟合]
  C --> D[识别异常斜率:com.example.CacheEntry]
  D --> E[关联代码行:CacheService.java:142]

4.4 广告归因典型内存反模式识别:UDF闭包捕获、ProtoBuf重复反序列化、Metrics标签爆炸

UDF闭包捕获导致对象泄漏

Spark SQL中误将Broadcast变量或大对象直接闭包进UDF,触发全量序列化:

val userCache = spark.sparkContext.broadcast(loadUserMap()) // 10MB HashMap
val riskyUdf = udf((id: Long) => userCache.value.get(id)) // ❌ 闭包捕获整个Broadcast

分析:userCache被序列化进每个Task的UDF闭包,而非按需反查;正确做法应显式调用userCache.value并在UDF外完成广播变量解引用。

ProtoBuf重复反序列化

单条日志被多次parseFrom(),GC压力陡增: 场景 调用频次/记录 内存开销
正确(一次解析+复用) 1 ~2KB
反模式(每字段调用) 5+ ~10KB+

Metrics标签爆炸

// ❌ 动态拼接高基数标签
counter.labels("campaign_id", campaignId.toString()).inc();

高基数campaignId导致Micrometer无限注册新Meter实例——内存持续增长。

第五章:构建可持续演进的Go服务内存健康体系

内存指标分层采集策略

在高并发订单履约服务(QPS 12k+)中,我们采用三层指标采集机制:基础层(runtime.MemStats每5s快照)、语义层(pprof堆采样标记关键业务路径如/v2/checkout)、业务层(自定义memtag标签统计各微服务模块内存归属)。通过expvar暴露结构化指标,并经Prometheus remote_write同步至统一监控平台。关键代码片段如下:

func recordMemUsage() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    memGauge.WithLabelValues("heap_alloc").Set(float64(m.HeapAlloc))
    memGauge.WithLabelValues("total_alloc").Set(float64(m.TotalAlloc))
}

基于GC周期的动态告警阈值

传统静态阈值在流量峰谷期误报率超37%。我们引入GC周期特征建模:以gcPauseQuantile99nextGC为输入,训练轻量级XGBoost模型(仅23个特征),动态生成heap_inuse告警基线。过去三个月线上数据显示,告警准确率从61%提升至92%,平均MTTD缩短至47秒。

生产环境内存泄漏根因定位流程

heap_inuse持续增长且gcPauseQuantile99 > 100ms时,自动触发诊断流水线:

  1. 采集pprof/heap?debug=1原始数据(保留最近3次快照)
  2. 执行go tool pprof -http=:8081 heap.pprof生成火焰图
  3. 调用memleak-detector工具比对两次快照差异(过滤runtime.mallocgc调用栈)
  4. 输出可疑对象持有链:*redis.Client → *redis.Pool → []*redis.conn → []byte

持续演进的内存治理看板

指标类型 数据源 更新频率 告警通道
GC暂停时间 godebug.gc.pauses 实时 PagerDuty
对象分配速率 runtime.MemStats 5s Slack群
大对象分布 pprof/heap?alloc_space 每小时 邮件日报
Goroutine阻塞 runtime.NumGoroutine 30s 企业微信机器人

自动化内存压测框架

基于go-fuzz改造的mem-fuzz工具,可注入内存压力场景:

  • 模拟突发大文件上传(构造[]byte{10MB}切片)
  • 注入goroutine泄漏模式(未关闭channel导致select阻塞)
  • 强制触发GC并测量HeapInuse回收率
    在支付网关服务上线前,该框架发现3处sync.Pool误用导致对象逃逸问题,避免了生产环境OOM事故。
flowchart LR
    A[内存指标采集] --> B[异常检测引擎]
    B --> C{HeapAlloc增长率 >15%/min?}
    C -->|Yes| D[触发pprof快照]
    C -->|No| E[进入常规监控队列]
    D --> F[生成diff报告]
    F --> G[推送至SRE工单系统]
    G --> H[关联Git提交记录]

线上服务内存画像实践

对某电商搜索服务进行为期两周的内存画像分析,发现map[string]*Product结构体在缓存预热阶段产生12GB临时对象,经重构为sync.Map+unsafe.Pointer对象池后,HeapAlloc峰值下降68%,GC频次由每32秒1次降至每117秒1次。所有优化均通过go test -benchmem验证,确保无性能回退。

内存健康度评分模型

定义内存健康度MHS(Memory Health Score)公式:
MHS = 100 − (0.4×GC_Pause_99 + 0.3×Heap_Alloc_Rate + 0.2×Goroutine_Count + 0.1×Object_Alloc_Frequency)
其中各项指标归一化至0-100区间。服务MHS低于70分时自动触发容量评估流程,生成扩容建议与代码优化清单。

可观测性埋点规范

要求所有核心业务模块必须实现MemObserver接口:

type MemObserver interface {
    ObserveAlloc(tag string, size int64) // 标记内存分配来源
    ObserveRelease(tag string)            // 标记资源释放
    GetSnapshot() map[string]int64        // 返回当前内存标签快照
}

该规范已集成至CI流水线,未实现接口的PR将被自动拒绝合并。

专治系统慢、卡、耗资源,让服务飞起来。

发表回复

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