Posted in

【Go后端SLO达标作战地图】:基于SLI(Latency/Error/Availability)反推代码层必须植入的5类埋点与告警阈值公式

第一章:SLO作战地图与Go后端可观测性全景概览

SLO(Service Level Objective)不是抽象指标,而是连接业务目标与工程实践的作战地图——它定义了系统“可被容忍的失败边界”,并驱动监控、告警、诊断与优化的全链路决策。在Go后端服务中,可观测性并非仅靠日志堆砌,而是由指标(Metrics)、链路追踪(Traces)、日志(Logs)与运行时事件(Events)四者协同构成的立体感知网络。

SLO核心三要素

  • 服务水平指标(SLI):可测量的请求成功率、延迟P95、错误率等,需基于真实请求流计算;
  • 服务水平目标(SLO):如“99.9% 的 /api/order 请求在 200ms 内完成”;
  • 错误预算(Error Budget):100% − SLO 值,例如 99.9% SLO 对应每日约 86.4 秒不可用时间,是发布、压测与故障响应的决策依据。

Go可观测性技术栈选型建议

组件类型 推荐方案 关键优势
指标采集 Prometheus + promhttp 原生支持Go HTTP中间件,低侵入暴露/metrics端点
分布式追踪 OpenTelemetry SDK + Jaeger/Tempo 自动注入context,支持Span生命周期透传
日志结构化 zerolog + JSON输出 零分配日志写入,天然兼容ELK/Loki

快速启用基础可观测性(Go示例)

import (
    "net/http"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/jaeger"
    "go.opentelemetry.io/otel/sdk/trace"
)

func initTracing() {
    exp, _ := jaeger.New(jaeger.WithCollectorEndpoint("http://localhost:14268/api/traces"))
    tp := trace.NewTracerProvider(trace.WithBatcher(exp))
    otel.SetTracerProvider(tp)
}

func main() {
    initTracing()
    http.Handle("/metrics", promhttp.Handler()) // 暴露Prometheus指标端点
    http.HandleFunc("/api/data", instrumentedHandler) // 业务路由需注入tracer与metric计数器
    http.ListenAndServe(":8080", nil)
}

该初始化代码使服务自动上报HTTP请求延迟、状态码分布、Span链路,并为后续SLO计算提供SLI数据源。所有观测信号必须与SLO声明对齐——例如若SLO定义“P95延迟 ≤ 200ms”,则指标采集必须保留原始请求耗时直方图(而非仅平均值)。

第二章:Latency SLI驱动的代码层埋点体系构建

2.1 基于http.Handler中间件的全链路延迟采样理论与gorilla/mux实战封装

全链路延迟采样需在请求生命周期关键节点埋点,以毫秒级精度捕获处理耗时。核心在于不侵入业务逻辑的前提下,通过 http.Handler 装饰器模式注入采样逻辑。

中间件设计原则

  • 零配置默认采样率(如 1%)
  • 支持 X-Request-ID 透传与 Span ID 衍生
  • 延迟数据结构化为 {"path":"/api/user","latency_ms":42.3,"status":200}

gorilla/mux 封装示例

func LatencySampler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rr := &responseWriter{ResponseWriter: w, statusCode: 200}
        next.ServeHTTP(rr, r)
        latency := time.Since(start).Milliseconds()
        if rand.Float64() < 0.01 { // 1% 采样率
            log.Printf("SAMPLED: %s %s %.2fms %d", r.Method, r.URL.Path, latency, rr.statusCode)
        }
    })
}

逻辑分析:该中间件包裹原始 handler,用自定义 responseWriter 拦截状态码;time.Since 确保高精度计时;rand.Float64() < 0.01 实现概率采样,避免日志洪峰。参数 next 为下游 handler,r 包含完整上下文,rr.statusCode 在 WriteHeader 后生效。

组件 作用
responseWriter 代理响应,捕获真实 status
X-Request-ID 用于跨服务 trace 关联
log.Printf 可替换为 OpenTelemetry exporter
graph TD
    A[Client Request] --> B[gorilla/mux Router]
    B --> C[LatencySampler Middleware]
    C --> D[Business Handler]
    D --> C
    C --> E[Log/Export Sample]

2.2 gRPC拦截器中request/response耗时埋点:proto反射+context.Value双路径实践

在高精度可观测性场景下,仅依赖 time.Since() 记录拦截器起止时间存在局限——无法关联具体 RPC 方法名、请求体大小及业务语义标签。为此,我们采用 proto反射 + context.Value 双路径协同 实现结构化耗时采集。

双路径设计动机

  • 反射路径:动态解析 *descriptor.MethodDescriptor,提取 service/method 名,避免硬编码;
  • Context 路径:将 startTimeproto.Message 实例注入 context.WithValue(),保障跨 goroutine 时序一致性。

核心拦截器实现

func latencyInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    start := time.Now()
    // 注入 startTime 与原始 req(支持后续反射序列化分析)
    ctx = context.WithValue(ctx, startTimeKey{}, start)
    ctx = context.WithValue(ctx, requestKey{}, req)

    resp, err := handler(ctx, req)
    duration := time.Since(start)

    // 利用 proto 反射获取方法全名
    method := info.FullMethod // "/helloworld.Greeter/SayHello"
    if msg, ok := req.(proto.Message); ok {
        service, method := grpcutil.ServiceAndMethodFromFullMethod(info.FullMethod)
        log.Printf("method=%s, service=%s, duration=%v, req_size=%d", 
            method, service, duration, proto.Size(msg))
    }
    return resp, err
}

逻辑分析startTimeKey{} 是未导出空 struct,避免 context key 冲突;proto.Size(msg) 依赖 google.golang.org/protobuf/proto,需确保 req 已完成 unmarshal;grpcutil 为自封装工具,从 /pkg.Svc/Method 提取结构化元信息。

耗时数据维度对比

维度 反射路径支持 Context 路径支持 说明
方法全名 依赖 info.FullMethod
请求体大小 ✅(需 req) ✅(req 存于 ctx) proto.Message 实现
响应状态码 ✅(err 分析) err == nil 表示成功
graph TD
    A[UnaryInterceptor] --> B[Inject startTime & req into ctx]
    B --> C[Call Handler]
    C --> D[Calculate duration]
    D --> E[Reflect proto descriptor]
    E --> F[Log structured metrics]

2.3 数据库调用延迟埋点:sql/driver接口劫持与pgx/v5 hook注入详解

Go 生态中实现数据库延迟可观测性,主流路径分为两类:底层驱动劫持与高层客户端钩子。

sql/driver 接口劫持原理

通过包装 sql.Driver 实现 Open() 方法,在返回的 *sql.Conn 前注入拦截器,捕获 ExecContext/QueryContext 调用前后的纳秒级时间戳:

type tracedDriver struct {
    driver driver.Driver
}
func (d *tracedDriver) Open(name string) (driver.Conn, error) {
    conn, err := d.driver.Open(name)
    if err != nil {
        return nil, err
    }
    return &tracedConn{Conn: conn}, nil // 包装原始连接
}

逻辑分析:tracedDriver 不修改原驱动行为,仅在连接建立环节注入代理层;name 是 DSN 字符串(如 "host=... port=5432"),用于关联数据源标签。

pgx/v5 Hook 注入优势

pgx v5 原生支持 pgconn.ConnectHookpgx.BatchHooks,支持细粒度事件捕获:

Hook 类型 触发时机 典型用途
Connect 连接建立后 记录连接耗时、TLS信息
Query 每条 SQL 执行前后 统计 P95 延迟、SQL指纹
Prepare 预编译语句时 检测高频未复用预编译
graph TD
    A[App Call db.Query] --> B[pgx.QueryHook.BeforeQuery]
    B --> C[Execute on PostgreSQL]
    C --> D[pgx.QueryHook.AfterQuery]
    D --> E[上报 latency + context]

2.4 异步任务(如go-workers)执行延迟埋点:结构化task ID透传与time.Since原子记录

在分布式异步任务系统中,精准归因延迟需穿透任务生命周期。核心在于两点:结构化 task ID 的全链路透传执行耗时的无竞态原子记录

数据同步机制

使用 context.WithValue 将带命名空间的 task ID(如 order:12345:retry-2)注入 worker 执行上下文,避免字符串拼接导致的解析歧义。

原子计时实践

start := time.Now()
defer func() {
    metrics.Histogram("worker.task.duration_ms").Observe(float64(time.Since(start).Milliseconds()))
}()

time.Since(start) 返回 time.Duration,毫秒级精度;Observe() 线程安全,无需额外锁;defer 确保无论 panic 或正常返回均记录。

延迟归因维度对比

维度 传统方式 结构化透传方式
Task ID 可读性 12345 payment:refund:20240521:78901
追踪完整性 仅 worker 日志 context → DB → MQ → Monitor 全链路
graph TD
    A[Producer] -->|task_id=“batch:sync:20240521:001”| B[Redis Queue]
    B --> C[Worker Pool]
    C -->|ctx.WithValue| D[Handler]
    D --> E[time.Since → Prometheus]

2.5 P95/P99延迟阈值动态计算公式:滑动时间窗口+分位数估算(TDigest)在Go中的轻量实现

实时延迟监控需兼顾精度与内存开销。传统直方图固定桶导致稀疏数据浪费,而完整排序求分位数无法流式处理。TDigest 以质心聚类思想,在 O(log n) 时间内完成高精度分位估算(误差

核心设计约束

  • 滑动窗口:60秒滚动,每秒切片聚合,避免全量重算
  • 内存上限:≤ 2MB(千级服务实例可部署)
  • 更新吞吐:≥ 10k ops/sec(单核)

TDigest 轻量 Go 实现关键逻辑

type TDigest struct {
    centroids []centroid
    compression float64 // 默认 100,控制质心数量上限
}

func (t *TDigest) Add(value float64) {
    c := centroid{mean: value, weight: 1}
    t.centroids = append(t.centroids, c)
    if len(t.centroids) > int(2*t.compression*math.Log(float64(len(t.centroids)))) {
        t.compress() // 合并邻近质心,保持误差界
    }
}

compression 参数决定精度-内存权衡:值越大,质心越多、P99误差越小,但内存线性增长;默认 100 可保证 99.9% 场景下相对误差 compress() 使用贪心合并策略,按累积权重比例融合相邻质心,严格满足 δ(q) ≤ 0.5·compression⁻¹ 理论误差上界。

动态阈值计算流程

graph TD
    A[新延迟样本] --> B{是否超60s?}
    B -->|是| C[淘汰最老1s切片]
    B -->|否| D[追加至当前切片TDigest]
    C --> D
    D --> E[每秒聚合所有切片→全局TDigest]
    E --> F[P95 = Query 0.95, P99 = Query 0.99]
组件 时间复杂度 典型内存 说明
单切片 TDigest O(log k) 插入 ~12KB k≈500 质心
全局合并 O(K log K) K=60 切片
分位查询 O(log K) 二分搜索累积权重

第三章:Error SLI驱动的错误分类与标准化捕获

3.1 Go错误链(errors.Is/As)与SLO错误码映射规范:定义业务错误、系统错误、临时错误三级分类

在高可用服务中,错误需可识别、可归因、可度量。Go 1.13+ 的错误链机制为分层诊断奠定基础:

var (
    ErrInsufficientBalance = errors.New("insufficient balance")
    ErrPaymentTimeout      = &temporaryError{"payment gateway timeout"}
)

type temporaryError struct{ msg string }
func (e *temporaryError) Error() string { return e.msg }
func (e *temporaryError) Is(target error) bool { 
    return errors.Is(target, ErrPaymentTimeout) || 
           target == ErrPaymentTimeout 
}

该实现使 errors.Is(err, ErrPaymentTimeout) 可穿透包装,支持 SLO 中“临时错误”自动重试判定。

三级错误分类与 SLO 映射关系如下:

错误类型 示例 SLO 影响 检测方式
业务错误 ErrInsufficientBalance 不计入错误率 errors.Is(err, ErrXXX)
系统错误 io.EOF, sql.ErrNoRows 计入P99错误率 errors.As(err, &target)
临时错误 &temporaryError{} 触发重试/降级 自定义 Is() 实现

错误链诊断流程

graph TD
    A[原始error] --> B{errors.Is?}
    B -->|匹配业务码| C[业务拦截]
    B -->|不匹配| D{errors.As?}
    D -->|可转为*temporaryError| E[加入重试队列]
    D -->|否则| F[上报监控并告警]

3.2 HTTP/gRPC错误响应统一拦截:status code→SLO error type自动归因逻辑与middleware实现

核心归因映射原则

HTTP 4xx → user_error(客户端误用),5xx → system_error(服务端异常);gRPC Code.Unavailable/DeadlineExceededavailability_errorCode.Internalsystem_error

归因映射表

Status Source Code / Status SLO Error Type
HTTP 400, 401, 403, 404 user_error
HTTP 500, 502, 503, 504 system_error
gRPC UNAVAILABLE, DEADLINE_EXCEEDED availability_error
gRPC INTERNAL, UNKNOWN system_error

Middleware 实现(Go)

func SLOErrorMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rw := &statusResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(rw, r)
        sloType := mapStatusCodeToSLO(rw.statusCode) // ← 关键归因函数
        metrics.SLOErrorCount.WithLabelValues(sloType).Inc()
    })
}

// statusResponseWriter 拦截 WriteHeader 调用以捕获真实状态码

该中间件通过包装 ResponseWriter 拦截原始 status code,调用 mapStatusCodeToSLO() 查表完成语义升维,将协议层错误精准映射至可观测性友好的 SLO 错误分类。

3.3 panic recover兜底埋点:runtime.Stack + trace.Frame符号化解析与error_rate告警公式推导

recover() 捕获 panic 后,需精准定位异常调用链。runtime.Stack 获取原始栈字节流,但不可读;需结合 runtime.Callers + runtime.CallersFrames 进行符号化解析:

func captureStack() []trace.Frame {
    pc := make([]uintptr, 64)
    n := runtime.Callers(2, pc) // 跳过 captureStack 和 defer wrapper
    frames := runtime.CallersFrames(pc[:n])
    var fs []trace.Frame
    for {
        f, more := frames.Next()
        if f.Function != "" && !strings.HasPrefix(f.Function, "runtime.") {
            fs = append(fs, f)
        }
        if !more {
            break
        }
    }
    return fs
}

逻辑说明Callers(2) 从调用栈第2层开始采集(跳过当前函数及 defer 包装层);CallersFrames 将 PC 地址映射为含 FunctionFileLine 的结构化帧,过滤 runtime. 内部函数提升可读性。

error_rate 告警公式定义为:
$$ \text{error_rate} = \frac{\text{panic_count}{[t-1m,t]}}{\text{total_request}{[t-1m,t]}} \times 100\% $$
触发阈值:error_rate > 0.5% 且持续 ≥2 个采样周期。

维度 说明
采样窗口 60s 滑动时间窗口
最小请求量 ≥1000 避免低流量下误告
告警抑制周期 300s 触发后冷却期

栈帧解析关键字段语义

  • f.Function: 完整包路径函数名(如 myapp/service.Process
  • f.File: 绝对路径源文件(生产环境建议裁剪为相对路径)
  • f.Line: panic 发生行号(精度依赖 -gcflags="-l" 关闭内联)

第四章:Availability SLI落地的健康度量化与熔断联动

4.1 /healthz端点语义升级:依赖服务探活状态聚合 + availability = uptime / (uptime + downtime) 实时计算

传统/healthz仅返回自身进程存活状态,新版本将依赖服务(DB、Redis、AuthSvc)的探活结果进行加权聚合,并实时计算可用性指标。

可用性动态计算逻辑

// 基于滑动窗口(5分钟)统计各依赖服务的up/down事件
func calculateAvailability(upTime, downTime float64) float64 {
    if upTime+downTime == 0 {
        return 1.0 // 初始无数据时视为完全可用
    }
    return math.Round(upTime/(upTime+downTime)*1000) / 1000 // 保留3位小数
}

该函数以毫秒级精度累加各依赖的uptimedowntime,避免整点重置导致的抖动;分母为零保护确保服务启动初期返回合理默认值。

依赖状态聚合策略

依赖服务 权重 探活频率 故障传播系数
PostgreSQL 0.4 2s 1.0
Redis 0.3 3s 0.8
AuthSvc 0.3 5s 0.9

状态决策流程

graph TD
    A[/healthz 请求] --> B{所有依赖 healthy?}
    B -->|是| C[返回 200 OK, availability=1.0]
    B -->|否| D[聚合 weighted availability]
    D --> E[availability ≥ 0.95 → 200 OK<br>else → 503 Service Unavailable]

4.2 基于go.uber.org/ratelimit的请求准入率埋点:QPS级可用性衰减建模与告警阈值公式(Avail% = 1 − (rejected_req / total_req))

埋点集成模式

使用 ratelimitTake() 方法拦截请求,结合原子计数器统计 rejected_reqtotal_req

import "go.uber.org/ratelimit"

var (
    limiter = ratelimit.New(100) // 100 QPS
    total   = atomic.Int64{}
    rejected = atomic.Int64{}
)

func handleRequest() bool {
    total.Add(1)
    if !limiter.Take() {
        rejected.Add(1)
        return false // 拒绝准入
    }
    return true
}

Take() 非阻塞返回布尔值:true 表示放行,false 即刻拒绝。100 表示每秒最多允许100次调用,底层采用“漏桶”平滑限流。

可用性动态计算

每10秒采样一次,按公式 Avail% = 1 − (rejected_req / total_req) 计算瞬时可用率:

时间窗 total_req rejected_req Avail%
T₀–T₁ 1000 8 99.2%
T₁–T₂ 1000 42 95.8%

告警阈值推导

当连续3个周期 Avail% < 98%rejected_req > 50,触发 P2 级告警——反映QPS容量已逼近临界衰减拐点。

4.3 Circuit Breaker状态变更埋点:hystrix-go或gobreaker事件监听 + open→half-open→closed跃迁指标打点

状态跃迁可观测性设计原则

Circuit Breaker 的核心价值在于故障隔离,而状态跃迁(open → half-open → closed)是熔断器健康度的关键信号。需在状态变更瞬间打点,而非轮询检测。

事件监听实现对比

事件钩子方式 是否支持自定义上下文 状态变更回调粒度
hystrix-go hystrix.SetLogger(仅日志) 粗粒度(无原生事件)
gobreaker cb.OnStateChange ✅(闭包捕获) 精确到 State 枚举值

gobreaker 状态监听代码示例

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "payment-service",
    OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
        metrics.Counter(fmt.Sprintf("circuit_breaker.state_change.%s.%s_to_%s", name, from, to)).Inc(1)
        log.Printf("[CB] %s: %v → %v", name, from, to)
    },
})

逻辑分析OnStateChange 是同步回调,在状态写入内存后立即触发;from/togobreaker.State 类型(StateClosed=0, StateHalfOpen=1, StateOpen=2),确保跃迁路径可精确归因。打点使用 name + from_to 组合标签,便于 Prometheus 多维聚合。

状态跃迁时序图

graph TD
    A[open] -->|timeout or failure threshold| B[half-open]
    B -->|success < maxRequests| C[closed]
    B -->|failure ≥ 1| A
    C -->|failure ≥ threshold| A

4.4 Availability告警联动策略:连续N个周期Avail%

核心判定逻辑

采用滑动窗口计数器,维护最近 N=5 个采样周期(每分钟1次)的可用率数据,仅当连续5次 Avail% < 99.95 时进入告警状态。

告警分级与动作映射

级别 触发条件 自动动作
P3 连续5次 企业微信通知运维组
P2 连续10次 调用降级开关 api/v1/switch?name=feature_x&state=off
P1 连续15次 同步触发熔断 + 工单自动创建
# 滑动窗口判定(伪代码)
window = deque(maxlen=5)
if all(x < 99.95 for x in window):  # 连续5次命中
    trigger_alert(level="P3")
    if len(window) >= 10:  # 实际通过计数器扩展支持长周期
        call_degrade_api("feature_x", "off")

逻辑说明:deque(maxlen=5) 实现O(1)窗口维护;all() 确保严格连续性;降级调用前需校验开关当前状态防重复操作。

降级开关调用流程

graph TD
    A[Avail%持续低于阈值] --> B{连续计数≥5?}
    B -->|是| C[触发P3告警]
    B -->|否| D[继续采集]
    C --> E{连续计数≥10?}
    E -->|是| F[HTTP PUT调用降级API]
    F --> G[返回200且status==\"degraded\"]

第五章:SLO达标闭环:从埋点数据到自动化修复的Go工程实践

埋点数据采集层:基于OpenTelemetry的轻量级SDK封装

我们在核心HTTP服务中嵌入自研otel-slo-agent,该SDK以无侵入方式注入关键SLO指标埋点:request_latency_p95_mserror_rate_5msuccess_ratio_1h。所有指标通过gRPC Exporter直连Prometheus Remote Write Endpoint,并启用采样率动态调节(默认100%,当QPS > 5k时自动降为20%)。代码示例如下:

// 初始化SLO埋点器,绑定服务标识与SLI定义
sloMeter := slo.NewMeter("payment-service", 
    slo.WithSLIDefinitions(map[string]slo.SLIDef{
        "latency": {Window: 5 * time.Minute, Threshold: 200},
        "availability": {Window: 1 * time.Hour, Threshold: 0.9995},
    }),
)
http.HandleFunc("/pay", func(w http.ResponseWriter, r *http.Request) {
    ctx, span := sloMeter.StartSLITrack(r.Context(), "latency")
    defer span.End()
    // ...业务逻辑
})

SLO实时计算引擎:Prometheus + Thanos Rule联动架构

我们部署了双层规则评估体系:Prometheus本地Rule评估高频指标(如5分钟错误率),Thanos Ruler集群负责长周期SLO聚合(如28天滚动可用性)。关键SLO表达式已固化为可版本化YAML配置:

SLO名称 Prometheus表达式 触发阈值 告警等级
payment_latency_slo histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="payment"}[5m])) by (le)) * 1000 > 300ms P1
payment_availability_slo 1 - sum(rate(http_requests_total{job="payment",status=~"5.."}[1h])) / sum(rate(http_requests_total{job="payment"}[1h])) P2

自动化修复工作流:GitOps驱动的弹性响应链

当SLO持续2个评估周期未达标时,系统自动触发修复流水线:

  1. 调用/api/v1/slo/incident创建带上下文的Incident记录(含TraceID、Pod列表、最近3次GC Pause);
  2. 根据预设策略匹配修复模板——若latency_slo告警且cpu_usage_percent > 85%,则执行HorizontalPodAutoscaler扩缩容;
  3. 执行前生成repair-plan.yaml并经Argo CD比对确认;
  4. 修复后自动注入canary-check任务,验证SLO恢复情况。

生产环境落地效果

在2024年Q2大促压测中,支付服务遭遇突发流量导致P95延迟升至412ms(SLO目标300ms)。系统在1分17秒内完成自动诊断(识别出数据库连接池耗尽),并于2分03秒完成连接池参数热更新(maxOpenConns=150→250),SLO于3分48秒回归达标。全链路日志、trace与修复操作均留存至Loki+Jaeger+Git仓库,支持审计回溯。

flowchart LR
A[埋点数据] --> B[Prometheus实时指标]
B --> C{SLO达标?}
C -- 否 --> D[触发Incident]
D --> E[根因分析引擎]
E --> F[匹配修复策略]
F --> G[生成GitOps PR]
G --> H[Argo CD自动部署]
H --> I[Canary验证]
I --> J[SLO状态更新]
C -- 是 --> K[维持当前配置]

监控看板与工程师反馈闭环

我们构建了SLO健康度驾驶舱,每个服务页签包含:实时SLO水位环形图、近7天达标趋势折线、TOP3影响因子词云(如“DB慢查询”、“GC STW”、“网络抖动”)。工程师可通过Slack Slash Command /slo repair payment-service latency 直接调起修复流程,并收到含trace链接的执行报告卡片。所有修复动作被标记为git commit --signoff,确保责任可追溯。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

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