Posted in

Golang程序日志混乱难追溯?Zap+Sentry+TraceID全链路日志染色实施手册(含OpenTelemetry对接)

第一章:Golang程序的基本特性与日志痛点剖析

Go 语言以简洁语法、静态编译、原生并发(goroutine + channel)和内存安全著称。其二进制可执行文件不依赖外部运行时,部署轻量;但标准库 log 包功能高度精简——仅支持时间戳、级别前缀和简单输出,缺乏结构化日志、字段注入、上下文传递、采样、异步写入及多输出目标等现代可观测性必需能力。

Go 日志的典型短板

  • 无结构化输出:默认输出为纯文本,难以被 ELK 或 Loki 等系统自动解析;
  • 上下文缺失:HTTP 请求 ID、用户 UID、追踪 SpanID 等无法自然携带至深层调用栈;
  • 性能隐患:频繁日志 I/O(尤其同步写磁盘)易阻塞 goroutine,影响高并发吞吐;
  • 级别粒度粗放log.Printf 无内置 debug/info/warn/error 分级,需手动封装或依赖第三方。

标准日志 vs 生产就绪日志对比

特性 log(标准库) zap(推荐)
输出格式 文本 JSON / 非结构化(极速)
字段支持 ❌(需拼接字符串) ✅(zap.String("user_id", id)
上下文继承 ✅(logger.With(zap.String("req_id", r.Header.Get("X-Request-ID")))
写入性能(10k条/秒) ~3k ~150k(零分配模式)

快速启用结构化日志示例

// 使用 zap 初始化高性能日志器(需 go get -u go.uber.org/zap)
import "go.uber.org/zap"

func main() {
    // 开发模式:带颜色、人类可读(适合本地调试)
    logger, _ := zap.NewDevelopment()
    defer logger.Sync() // 刷写缓冲区,避免日志丢失

    logger.Info("user login succeeded",
        zap.String("user_id", "u_9a8b7c"),
        zap.Int("attempts", 1),
        zap.Bool("mfa_enabled", true),
    )
    // 输出:{"level":"info","ts":"2024-06-15T10:22:33.123Z","caller":"main.go:12","msg":"user login succeeded","user_id":"u_9a8b7c","attempts":1,"mfa_enabled":true}
}

该代码直接生成机器可解析的 JSON 日志,字段键名明确、类型保真,且无反射开销。

第二章:Zap日志库深度实践与结构化日志染色方案

2.1 Zap核心架构解析与高性能日志写入原理

Zap 的高性能源于其无反射、零内存分配的核心设计哲学,摒弃了 fmtreflect,全程使用结构化预分配。

核心组件协作流

graph TD
    A[Logger] --> B[Encoder]
    B --> C[WriteSyncer]
    C --> D[RingBuffer/OS File]
    A --> E[LevelEnabler]

日志写入关键路径

  • 日志结构体(Entry)直接序列化为字节流,避免字符串拼接
  • jsonEncoder 使用预分配 []byte 缓冲池,复用内存
  • WriteSyncer 抽象层支持异步刷盘(如 LockedFileWriter)与缓冲写入

同步写入性能对比(10k entries/sec)

写入方式 吞吐量 分配次数/entry
os.File.Write 42K 0
bufio.Writer 68K 0
zapcore.LockedWriteSyncer 51K 0
// 高效编码示例:跳过字段名拷贝,直接写入预计算偏移
func (e *jsonEncoder) AddString(key, val string) {
    e.addKey(key)                 // key 已预计算长度与转义
    e.WriteString(val)            // 直接 append 到 buffer.Bytes()
}

该方法规避 strconv.Quote 动态分配,keyfield.keyPool.Get() 复用,val 若为常量则走静态字节引用。缓冲区大小默认 4KB,满即 flush。

2.2 基于Context的字段动态注入与TraceID绑定实战

在分布式调用链中,TraceID需贯穿请求生命周期。Go标准库context.Context是天然载体,但需避免手动透传。

动态注入核心逻辑

通过context.WithValue()将TraceID注入上下文,并封装为可复用的中间件:

func WithTraceID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String() // fallback生成
        }
        ctx := context.WithValue(r.Context(), "trace_id", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

逻辑分析r.WithContext()创建新请求对象,确保TraceID随Context安全传递;键 "trace_id"建议使用私有类型避免冲突(生产环境应定义为type ctxKey string)。

关键参数说明

参数 类型 说明
r.Context() context.Context 原始请求上下文,含超时、取消信号等
"trace_id" any(推荐自定义类型) 上下文键,必须全局唯一且不可导出
traceID string 全局唯一标识,建议兼容W3C Trace Context格式

调用链传播流程

graph TD
    A[HTTP入口] --> B[WithTraceID中间件]
    B --> C[解析/生成TraceID]
    C --> D[注入context.WithValue]
    D --> E[下游服务调用]

2.3 自定义Encoder与日志格式标准化(JSON/Console/Protobuf)

日志输出的可读性、结构化与序列化效率,取决于底层 Encoder 的实现策略。

多格式Encoder选型对比

格式 适用场景 序列化开销 可检索性 调试友好度
Console 本地开发/调试 极低
JSON ELK/Splunk采集
Protobuf 高吞吐微服务间传输 极低 弱(需schema)

自定义JSON Encoder示例

type JSONEncoder struct {
    *zapcore.JSONEncoder
}

func (e *JSONEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
    // 强制添加 trace_id 字段(若上下文存在)
    if tid := getTraceIDFromContext(ent.Context); tid != "" {
        fields = append(fields, zap.String("trace_id", tid))
    }
    return e.JSONEncoder.EncodeEntry(ent, fields)
}

该实现扩展了 zapcore.JSONEncoder,在日志序列化前动态注入分布式追踪标识,确保全链路日志可关联;getTraceIDFromContextent.Context 提取 context.Context 中携带的 trace_id 值,要求调用方已通过 zap.AddCallerSkip()With 注入上下文。

编码器注册流程

graph TD
    A[初始化Logger] --> B[选择Encoder类型]
    B --> C{JSON? Console? Protobuf?}
    C -->|JSON| D[实例化JSONEncoder]
    C -->|Protobuf| E[注册ProtobufSchema]
    D --> F[绑定Core与WriteSyncer]

2.4 日志级别动态调控与采样策略在高并发场景下的落地

在QPS超5000的订单服务中,全量DEBUG日志将导致磁盘IO飙升300%,而盲目关闭又丧失排障能力。核心解法是运行时分级采样

动态日志级别热更新

// 基于Spring Boot Actuator + Logback JMX集成
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger logger = context.getLogger("com.example.order");
logger.setLevel(Level.valueOf(System.getProperty("log.level", "INFO"))); // 支持JVM参数实时覆盖

逻辑分析:通过JMX暴露LogLevelController端点,配合配置中心推送log.level=DEBUG,100ms内生效;System.getProperty提供降级兜底,避免配置中心不可用时日志失效。

分层采样策略

  • 关键路径(支付回调、库存扣减):100% TRACE采样
  • 非核心路径(用户浏览、静态资源):0.1%概率采样
  • 异常链路:自动提升至ERROR+STACKTRACE全量记录
采样维度 触发条件 采样率 存储位置
请求ID 包含X-Trace-ID: abc 100% ES hot节点
错误码 HTTP 5xx 或 bizCode=9999 100% S3归档桶
时间窗口 每分钟前10秒 5% 本地SSD缓存

流量自适应调节

graph TD
    A[QPS > 3000] --> B{CPU > 85%?}
    B -->|是| C[自动降级为INFO+5%采样]
    B -->|否| D[维持DEBUG+1%采样]
    C --> E[触发告警并推送钉钉]

2.5 Zap与Go原生log包、logrus迁移路径与兼容性适配

Zap 的高性能源于结构化日志与零分配设计,而 log(标准库)和 logrus 分别代表简易性与中间态扩展能力。

迁移核心挑战

  • 字段语义差异:log.Printf() 无字段,logrus.WithField() 类似 zap.String(),Zap 要求显式 zap.String("key", value)
  • Hook 机制缺失:Zap 无内置 Hook,需通过 zapcore.Corezap.WrapCore 实现

兼容性适配方案

// 封装 Zap 为 log.Logger 接口兼容层
func NewLogAdapter(z *zap.Logger) *log.Logger {
    return log.New(zapWriter{z: z}, "", 0)
}
type zapWriter struct{ z *zap.Logger }
func (w zapWriter) Write(p []byte) (n int, err error) {
    w.z.Info(strings.TrimSpace(string(p))) // 简单桥接,生产环境需解析格式
    return len(p), nil
}

该适配器使遗留 log.Print* 调用可无缝转向 Zap 输出,但丢失结构化能力——仅作过渡之用。

迁移优先级建议

  • ✅ 第一阶段:替换 logrusZap(保留 WithFields 风格封装)
  • ⚠️ 第二阶段:逐步消除字符串拼接日志,改用结构化字段
  • ❌ 避免长期依赖 log 标准库桥接层
特性 Go log logrus Zap
结构化日志
零内存分配(高频)
自定义 Encoder
graph TD
    A[现有 log/logrus 日志] --> B{迁移策略选择}
    B --> C[轻量适配:log.Writer 桥接]
    B --> D[渐进重构:字段提取+Zap封装]
    B --> E[一步到位:重写日志调用链]
    C --> F[临时兼容,性能无提升]
    D --> G[平衡成本与收益]
    E --> H[最优性能,需测试覆盖]

第三章:Sentry异常监控集成与全链路上下文透传

3.1 Sentry Go SDK初始化与性能指标采集配置

Sentry Go SDK 的初始化是可观测性的起点,需兼顾错误捕获与性能监控双路径。

初始化基础配置

import "github.com/getsentry/sentry-go"

err := sentry.Init(sentry.ClientOptions{
    DSN:         "https://xxx@o123.ingest.sentry.io/456",
    Environment: "production",
    Release:     "myapp@1.2.3",
    Debug:       false,
})
if err != nil {
    log.Fatal(err)
}

DSN 是唯一接入凭证;EnvironmentRelease 支持跨环境/版本归因;Debug: false 避免日志污染生产环境。

启用性能监控

需显式启用 TracesSampleRate 并注册 sentryhttp 中间件:

  • 设置采样率(如 0.1 表示 10% 请求上报)
  • 自动注入 trace_id 到 HTTP 响应头
  • 拦截 net/http 生命周期生成 span
选项 推荐值 说明
TracesSampleRate 0.1 平衡数据量与分析精度
EnableTracing true 必须开启才能采集性能数据
AttachStacktrace false 性能敏感场景建议关闭

数据同步机制

graph TD
    A[HTTP Request] --> B[sentryhttp.Middleware]
    B --> C[Start Transaction]
    C --> D[Auto-instrumented Spans]
    D --> E[Flush to Sentry]

3.2 将Zap日志自动上报至Sentry并关联Error事件与TraceID

日志钩子注入Trace上下文

通过 sentryzap.NewHook() 创建 Sentry 钩子,自动提取 trace_id 字段并注入 Sentry Event 的 fingerprintextra

hook := sentryzap.NewHook(sentry.ClientOptions{
    AttachStacktrace: true,
})
logger = zap.New(zapcore.NewCore(
    zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
    os.Stdout,
    zapcore.InfoLevel,
)).WithOptions(zap.Hooks(hook))

该钩子监听 error 级别日志,若结构化字段含 trace_id(如 OpenTelemetry 注入),则调用 sentry.ConfigureScope 绑定至当前事件,确保 Error 页面可跳转对应 Trace。

关联机制关键字段对照

Zap 字段名 Sentry 属性 作用
trace_id scope.SetTag() 建立日志与分布式追踪的显式纽带
error sentry.CaptureException() 触发 Error 事件上报
event_id extra.sentry_event_id 反向溯源日志来源

数据同步机制

使用 sentry.WithScope 包裹日志写入,确保每个 Error 事件携带 trace_id 标签,并在 Sentry UI 中点击 trace_id 即可联动 Jaeger/OTel 后端。

3.3 自定义Breadcrumb与Scope生命周期管理实现业务上下文还原

在复杂单页应用中,用户跨模块跳转后需精准还原操作上下文。核心在于将路由状态、表单数据、筛选条件等封装为可序列化的 breadcrumb 节点,并绑定至作用域(Scope)的声明周期。

Breadcrumb 节点结构设计

interface BreadcrumbNode {
  id: string;           // 唯一标识(如 order-edit-789)
  path: string;         // 关联路由路径
  state: Record<string, any>; // 序列化业务状态(支持 JSON.stringify)
  timestamp: number;    // 创建时间戳,用于 LRU 清理
}

该结构支持深度嵌套还原:state 可包含分页偏移、折叠面板状态、未提交表单快照等,id 保证节点幂等性。

Scope 生命周期协同机制

阶段 触发时机 操作
attach 路由进入/组件挂载 注册节点到全局 breadcrumb 栈
detach 路由离开/组件卸载 标记节点为待回收(非立即删除)
restore 用户点击历史节点 激活对应 Scope 并注入 state
graph TD
  A[用户跳转] --> B{是否启用上下文保留?}
  B -->|是| C[生成BreadcrumbNode]
  B -->|否| D[跳过记录]
  C --> E[push 到 Scope 管理器]
  E --> F[监听 detach 事件]
  F --> G[延迟清理或冻结状态]

此机制使“返回编辑页”不再丢失草稿,且避免内存泄漏——Scope 自动解绑时触发节点老化策略。

第四章:OpenTelemetry标准对接与分布式追踪日志协同

4.1 OpenTelemetry Go SDK集成与TracerProvider初始化最佳实践

初始化核心原则

避免全局单例滥用,优先使用依赖注入传递 TracerProvider;确保 Shutdown() 在应用退出前被显式调用。

推荐初始化模式

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func newTracerProvider() *sdktrace.TracerProvider {
    // 构建资源描述服务身份(必填,否则后端无法归类)
    res, _ := resource.Merge(
        resource.Default(),
        resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("order-service"),
            semconv.ServiceVersionKey.String("v1.2.0"),
        ),
    )

    // 配置采样器:生产环境推荐 TraceIDRatioBased(0.1)
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.01)), // 1% 采样率
        sdktrace.WithBatcher(otel.GetExporters().GetSpanExporter()), // 使用已注册导出器
    )
    return tp
}

逻辑分析WithResource 确保 span 携带语义化服务元数据;TraceIDRatioBased(0.01) 平衡可观测性与性能开销;WithBatcher 复用导出器实例,避免重复连接。

关键配置对比

配置项 开发环境 生产环境
采样率 AlwaysSample() TraceIDRatioBased(0.01)
批处理大小 1 512
导出超时 1s 10s

生命周期管理

graph TD
    A[应用启动] --> B[初始化 TracerProvider]
    B --> C[注入至 HTTP/GRPC 中间件]
    C --> D[业务逻辑使用 Tracer]
    D --> E[应用优雅关闭]
    E --> F[调用 tp.Shutdown context.WithTimeout]

4.2 使用otelzap桥接器实现SpanContext到Zap字段的零侵入染色

otelzap 是 OpenTelemetry Go SDK 提供的官方 Zap 日志桥接器,它自动将当前 context.Context 中的 SpanContext(含 TraceID、SpanID、TraceFlags)注入 Zap 日志字段,无需修改业务日志调用。

自动字段注入机制

import (
    "go.opentelemetry.io/otel/sdk/trace"
    "go.uber.org/zap"
    "go.opentelemetry.io/contrib/bridge/opentelemetry/zap/otelzap"
)

logger := zap.New(otelzap.NewWithConfig(zap.NewDevelopmentConfig()))
// 当前 context 若含有效 span,log 自动含 trace_id、span_id 等字段
logger.Info("request processed", zap.String("path", "/api/user"))

逻辑分析:otelzap.NewWithConfig 包装原始 Zap logger,每次日志写入前调用 trace.SpanFromContext(ctx) 提取上下文中的 span,并通过 AddFields() 注入标准字段。ctx 默认来自 zap.Logger.WithOptions(zap.AddCaller()) 不影响,但需确保日志调用时传入含 span 的 context(如 HTTP middleware 注入)。

关键字段映射表

OpenTelemetry 字段 Zap 字段名 类型 示例值
TraceID trace_id string a1b2c3d4e5f67890...
SpanID span_id string 1234567890abcdef
TraceFlags trace_flags uint32 1(采样启用)

集成流程

graph TD
    A[HTTP Handler] --> B[otelsdk.Tracer.Start]
    B --> C[context.WithValue(spanCtx)]
    C --> D[otelzap logger.Info]
    D --> E[自动提取 SpanContext]
    E --> F[注入 trace_id/span_id]

4.3 TraceID/SpanID在HTTP/gRPC中间件中的自动注入与透传

核心设计原则

  • 无侵入性:业务代码无需手动读写 trace 上下文
  • 协议一致性:HTTP 使用 traceparent(W3C 标准),gRPC 使用 grpc-trace-bintraceparent metadata
  • 生命周期对齐:Span 生命周期与请求生命周期严格绑定

HTTP 中间件示例(Go/Chi)

func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 从 header 提取或生成 TraceID/SpanID
        traceID := r.Header.Get("traceparent")
        if traceID == "" {
            traceID = "00-" + uuid.New().String() + "-" + uuid.New().String() + "-01"
        }
        ctx := context.WithValue(r.Context(), "trace_id", traceID)
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

逻辑说明:traceparent 格式为 00-{trace-id}-{span-id}-{flags};若缺失则生成新链路根 Span;context.WithValue 实现跨中间件透传,但生产环境建议使用 context.WithValue 的替代方案(如 otel.GetTextMapPropagator().Inject())以兼容 OpenTelemetry。

gRPC Server 拦截器关键流程

graph TD
    A[Client Request] --> B{Has traceparent?}
    B -->|Yes| C[Parse & Resume Trace]
    B -->|No| D[Start New Trace]
    C & D --> E[Attach Span to Context]
    E --> F[Call Handler]

透传字段对照表

协议 Header/Metadata Key 值格式 标准
HTTP traceparent 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 W3C Trace Context
gRPC traceparent 同上(推荐) 兼容 OTel SDK

4.4 日志-指标-追踪(Logs-Metrics-Traces)三位一体可观测性闭环构建

可观测性不是三类数据的简单堆砌,而是通过语义关联与上下文注入实现动态闭环。

关键协同机制

  • TraceID 注入日志与指标:确保同一请求在三端可交叉定位
  • 指标异常触发日志采样增强:如 http_server_requests_seconds_count{status=~"5.."} > 10 触发 TRACE 级日志捕获
  • 日志结构化字段反哺追踪标签:如 error_code="DB_TIMEOUT" 自动注入 span tag

数据同步机制

# OpenTelemetry Collector 配置片段(log → trace 关联)
processors:
  resource:
    attributes:
      - action: insert
        key: service.name
        value: "payment-service"
  batch: {}
exporters:
  otlp:
    endpoint: "otel-collector:4317"
    logs:
      include_metadata: true  # 携带 trace_id, span_id

此配置强制日志携带 trace context,使 Loki 可通过 {|traceID="xxx"} 查询关联所有 span 与 metrics;include_metadata: true 启用 OTLP 日志元数据透传,是闭环建立的前提。

闭环验证路径

输入源 关联字段 查询示例
Metrics trace_id rate(http_request_duration_seconds_count{job="api"}[5m]) → 提取高频 trace_id
Traces span.kind=server 查找对应 trace_id 的根 span 耗时与错误标记
Logs trace_id + error 在 Datadog/Loki 中搜索 traceID:"abc123" AND error
graph TD
    A[HTTP 请求] --> B[Metrics:计数/延迟采集]
    A --> C[Trace:Span 生成与传播]
    A --> D[Log:结构化输出含 trace_id]
    B -.->|阈值告警| E[触发 Trace 深度分析]
    C -->|span.status=ERROR| F[自动 enrich 日志采样策略]
    D -->|trace_id 关联| C & B

第五章:总结与展望

核心技术栈的生产验证结果

在2023年Q3至2024年Q2的12个关键业务系统迁移项目中,基于Kubernetes+Istio+Prometheus的技术栈实现平均故障恢复时间(MTTR)从47分钟降至6.3分钟,服务可用率从99.23%提升至99.992%。下表为某电商大促场景下的压测对比数据:

指标 旧架构(VM+NGINX) 新架构(K8s+eBPF Service Mesh) 提升幅度
请求延迟P99(ms) 328 89 ↓72.9%
配置热更新耗时(s) 42 1.8 ↓95.7%
日志采集延迟(s) 15.6 0.32 ↓97.9%

真实故障复盘中的关键发现

2024年3月某支付网关突发流量激增事件中,通过eBPF实时追踪发现:上游SDK未正确释放gRPC连接池,导致TIME_WAIT套接字堆积至67,842个。团队立即上线连接复用策略补丁,并通过OpenTelemetry自定义指标grpc_client_conn_reuse_ratio持续监控,该指标在后续3个月稳定维持在≥0.98。

# 生产环境快速诊断命令(已集成至SRE巡检脚本)
kubectl exec -n istio-system deploy/istiod -- \
  istioctl proxy-config listeners payment-gateway-7f9c5d8b4-2xkqj \
  --port 8080 --json | jq '.[0].filter_chains[0].filters[0].typed_config.http_filters[] | select(.name=="envoy.filters.http.ext_authz")'

多云治理落地挑战

在混合部署于阿里云ACK、腾讯云TKE及自建OpenShift集群的场景中,发现跨云服务发现存在3.2秒级DNS解析抖动。解决方案采用CoreDNS插件k8s_external配合Consul Federation,在三个集群间同步ServiceEntry,将服务发现延迟收敛至≤86ms(P95)。Mermaid流程图展示其请求路径:

graph LR
    A[客户端Pod] --> B{CoreDNS}
    B -->|本地集群| C[ClusterIP Service]
    B -->|跨云服务| D[Consul Federation]
    D --> E[阿里云集群Endpoint]
    D --> F[腾讯云集群Endpoint]
    D --> G[自建集群Endpoint]

开发者体验改进实效

通过GitOps流水线接入Argo CD v2.9后,前端团队发布频率从每周1次提升至日均2.7次,配置变更错误率下降89%。关键改进包括:

  • 使用Kustomize Base叠加层管理多环境ConfigMap,避免YAML复制粘贴
  • 在CI阶段强制执行OPA策略检查(如禁止hostNetwork: true
  • 为每个微服务生成Swagger UI自动注入到Istio Gateway路由

边缘计算协同演进

在智能工厂IoT平台中,将KubeEdge边缘节点与中心集群联动:当车间温控设备离线超5分钟,边缘AI推理模块自动启用本地模型(TensorFlow Lite量化版),同时向中心集群推送edge-fallback-active事件。该机制已在17个制造基地上线,设备异常响应时效从平均93秒缩短至11秒。

安全合规实践沉淀

完成等保2.0三级认证过程中,通过Falco规则引擎实现容器运行时威胁检测:

  • 实时阻断/bin/sh进程启动(规则ID: shell-in-container
  • 检测敏感挂载路径读取(如/etc/shadow)并触发Slack告警
  • 对K8s API Server审计日志进行SIEM关联分析,发现横向移动尝试准确率达99.4%

技术债偿还路线图

当前遗留的3个单体Java应用(合计217万行代码)已按季度拆分计划推进:Q3完成订单核心模块容器化,Q4上线Saga分布式事务框架,2025年Q1前实现全链路OpenTracing埋点覆盖率≥98%。每次拆分均伴随对应压测报告与混沌工程演练记录存档。

对 Go 语言充满热情,坚信它是未来的主流语言之一。

发表回复

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