Posted in

Go语言SSE推送性能优化:揭秘10万连接下毫秒级延迟的5大核心技巧

第一章:SSE推送在Go语言中的核心原理与架构演进

Server-Sent Events(SSE)是一种基于 HTTP 的单向实时通信协议,专为服务端向客户端持续推送文本事件而设计。其核心在于复用长连接(Connection: keep-alive)、设置 Content-Type: text/event-stream 响应头,并遵循严格的消息格式(如 data:event:id:retry: 字段)。与 WebSocket 不同,SSE 天然支持自动重连、事件 ID 管理和流式解析,且可被 CDN 缓存(需禁用缓存控制)和代理服务器透明转发。

Go 语言凭借其轻量级 goroutine 和高效的 HTTP/1.1 流式响应能力,成为 SSE 服务端实现的理想选择。早期实践多依赖 http.ResponseWriter 手动刷新缓冲区;随着 net/http 包对 Flusher 接口的稳定支持(自 Go 1.0 起),开发者可显式调用 flusher.Flush() 触发 TCP 包发送,避免内核缓冲延迟。Go 1.21 引入的 http.NewResponseController() 进一步增强了流控能力,支持动态设置超时与中断。

SSE 响应的关键约束与配置

  • 必须禁用 Content-Length(由服务端流式生成,长度不可预知)
  • 建议设置 Cache-Control: no-cache 防止中间代理缓存事件流
  • 推荐添加 X-Accel-Buffering: no(Nginx)或 Proxy-Buffering off(Apache)绕过反向代理缓冲
  • 客户端默认 3 秒重连,可通过 retry: 5000 自定义毫秒级间隔

Go 中实现 SSE 的最小可靠示例

func sseHandler(w http.ResponseWriter, r *http.Request) {
    // 设置必需头信息
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    // 获取 Flusher 接口(确保底层支持)
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }

    // 每秒推送一个带时间戳的事件
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        fmt.Fprintf(w, "data: %s\n\n", time.Now().UTC().Format(time.RFC3339))
        flusher.Flush() // 强制刷出到客户端,避免缓冲积压
    }
}

该模式天然适配 Go 的并发模型:每个连接独占一个 goroutine,无状态事件广播可通过 channel + select 实现解耦;结合 context.WithTimeout 可优雅终止长连接。现代架构中,SSE 常作为 WebSocket 的轻量补充,用于日志流、指标监控、通知推送等场景,其简洁性与可观测性持续推动 Go 生态中流式 API 的标准化演进。

第二章:连接层性能优化:支撑10万并发的关键实践

2.1 基于net/http.Server的轻量化HTTP/1.1长连接管理

Go 标准库 net/http.Server 默认启用 HTTP/1.1 持久连接(Keep-Alive),无需额外配置即可复用 TCP 连接,显著降低握手与慢启动开销。

连接复用核心机制

Server 通过 keepAlive 状态机管理空闲连接,依赖底层 connreadLoopwriteLoop 协程协同维持生命周期。

关键配置参数

字段 默认值 说明
IdleTimeout 0(禁用) 控制空闲连接最大存活时间
ReadTimeout 0 从读取开始到请求头解析完成的上限
WriteTimeout 0 从响应写入开始到完成的上限
srv := &http.Server{
    Addr:        ":8080",
    IdleTimeout: 30 * time.Second, // 强制回收空闲 >30s 的连接
}

该配置使服务在高并发低频请求场景下自动清理僵尸连接,避免 TIME_WAIT 积压。IdleTimeout 触发时,server.closeIdleConns() 会向 conn 发送关闭信号,由 readLoop 检测并优雅退出。

graph TD
    A[新连接接入] --> B{是否为HTTP/1.1?}
    B -->|是| C[启动readLoop/writeLoop]
    C --> D[请求处理完毕]
    D --> E{连接空闲中}
    E -->|超时| F[server.closeIdleConns]
    E -->|新请求| C

2.2 连接复用与goroutine生命周期精准控制策略

连接池与goroutine协同模型

Go 的 http.Transport 默认启用连接复用(keep-alive),但若 goroutine 泄漏,空闲连接将长期滞留,拖慢资源回收。需同步管控连接生命周期与协程存活期。

精准终止机制

使用 context.WithCancel 关联连接与 goroutine,确保任一端退出时双向清理:

ctx, cancel := context.WithCancel(parentCtx)
defer cancel() // 触发所有关联 I/O 中断

go func() {
    select {
    case <-ctx.Done():
        http.DefaultTransport.CloseIdleConnections() // 主动关闭空闲连接
    }
}()

逻辑分析cancel() 调用传播至 ctx.Done(),触发 CloseIdleConnections();参数 parentCtx 应来自请求上下文或带超时的派生上下文,避免孤儿 goroutine。

控制策略对比

策略 连接复用率 goroutine 安全性 适用场景
无 context 管控 ❌ 易泄漏 一次性短任务
WithTimeout + 池 中高 ✅ 可预测终止 API 网关
WithCancel + 监听 ✅ 精确联动 长连接流式服务
graph TD
    A[HTTP 请求发起] --> B{是否启用 keep-alive?}
    B -->|是| C[从连接池获取复用连接]
    B -->|否| D[新建 TCP 连接]
    C --> E[启动 goroutine 处理响应]
    E --> F[绑定 context.CancelFunc]
    F --> G[响应结束/超时/取消 → 同步释放连接+退出 goroutine]

2.3 客户端心跳保活与异常连接自动驱逐机制

心跳协议设计原则

采用双向轻量心跳:客户端主动上报 PING,服务端响应 PONG,避免单向探测导致的“假在线”。

心跳检测逻辑(Go 示例)

// 每30秒发送一次心跳,超时阈值设为90秒
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
if _, err := conn.Write([]byte("PING")); err != nil {
    log.Printf("心跳写入失败: %v", err)
    return // 触发驱逐
}

逻辑分析:SetReadDeadline 作用于整个连接读操作,若90秒内未收到任何数据(含PONG或业务帧),Read() 将返回 i/o timeout 错误;参数 90s 是3倍心跳周期,兼顾网络抖动与故障识别时效性。

驱逐策略对比

策略 响应延迟 误杀率 实现复杂度
单次超时驱逐
连续3次失败 ~90s
指数退避探测 ~180s 极低

自动驱逐流程

graph TD
    A[心跳超时] --> B{连续失败≥3次?}
    B -->|是| C[关闭TCP连接]
    B -->|否| D[重置失败计数器]
    C --> E[清理Session/订阅关系]

2.4 TLS握手优化与ALPN协商加速实战

ALPN协议优先级配置

现代服务端应显式声明ALPN首选协议,避免客户端试探性协商:

# nginx.conf 片段
ssl_protocols TLSv1.3 TLSv1.2;
ssl_alpn_prefer_server on;  # 服务端主导ALPN选择
ssl_alpn_protocols h2,http/1.1;  # 严格按优先级排序

ssl_alpn_prefer_server on 启用后,Nginx在ServerHello中直接返回首个匹配的ALPN协议(如h2),跳过客户端冗余列表比对;h2前置确保HTTP/2优先启用,减少1-RTT回退。

关键参数对比

参数 默认值 推荐值 效果
ssl_buffer_size 4KB 16KB 减少TLS记录分片,提升首屏加载
ssl_early_data off on(配合TLS 1.3) 允许0-RTT数据,降低首请求延迟

握手流程精简示意

graph TD
    A[ClientHello] --> B{含ALPN extension?}
    B -->|Yes| C[ServerHello + ALPN selected]
    B -->|No| D[协商失败→降级重试]
    C --> E[Application Data via h2]

2.5 内存友好的连接元数据存储:sync.Map vs 并发安全RingBuffer

在高并发长连接场景(如 WebSocket 网关)中,连接元数据(如 clientID → connState、最后活跃时间、限流令牌)需高频读写且生命周期不一。sync.Map 提供无锁读、延迟初始化的并发映射,但存在内存持续增长与 GC 压力;而定制化并发安全 RingBuffer 可实现固定内存占用与 O(1) 时间复杂度的滑动窗口元数据管理。

数据同步机制

RingBuffer 采用原子索引+双缓冲写入策略,避免写竞争:

type ConnMetaRing struct {
    data     [1024]ConnMeta
    head     atomic.Uint64 // 读位置(只增)
    tail     atomic.Uint64 // 写位置(只增)
}
// 写入时通过 (tail.Load() & mask) 定位槽位,CAS 更新 tail

mask = len(data)-1(2 的幂),保证位运算取模;head/tail 使用 atomic 避免锁,读写分离天然支持无锁遍历。

性能对比(10K 连接/秒写入)

方案 内存增长 GC 次数(1min) 平均写延迟
sync.Map 持续上升 182 124μs
RingBuffer(固定容量) 恒定 0 23μs
graph TD
    A[新连接元数据] --> B{RingBuffer 是否满?}
    B -->|否| C[原子写入 tail 槽位]
    B -->|是| D[覆盖最老项 head 槽位]
    C & D --> E[更新 tail]

第三章:事件分发层低延迟设计

3.1 基于channel扇出+批处理的事件广播模型

在高并发事件分发场景中,单一 goroutine 直接广播易成瓶颈。该模型将“扇出(fan-out)”与“批处理(batching)”解耦协同:先由一个 producer 将事件写入中心 channel,再由多个 consumer goroutine 并行读取并本地缓存,达到阈值或超时后批量推送。

扇出调度机制

  • 每个 consumer 独立监听同一 eventCh chan *Event
  • 利用 sync.Pool 复用 batch slice,降低 GC 压力
  • 批大小(batchSize=64)与超时(flushInterval=50ms)需按吞吐/延迟权衡

核心广播逻辑

func (b *Broadcaster) startConsumer(id int) {
    ticker := time.NewTicker(b.flushInterval)
    defer ticker.Stop()
    batch := make([]*Event, 0, b.batchSize)

    for {
        select {
        case evt := <-b.eventCh:
            batch = append(batch, evt)
            if len(batch) >= b.batchSize {
                b.dispatchBatch(batch)
                batch = batch[:0] // 复用底层数组
            }
        case <-ticker.C:
            if len(batch) > 0 {
                b.dispatchBatch(batch)
                batch = batch[:0]
            }
        }
    }
}

逻辑说明:batch[:0] 清空切片但保留底层数组,避免频繁内存分配;dispatchBatch 负责异步投递至下游(如 Kafka、WebSocket 连接池),支持失败重试与背压感知。

性能参数对比

配置项 小批量(16) 中批量(64) 大批量(256)
吞吐量(evt/s) 42k 89k 102k
P99 延迟(ms) 12 28 67
graph TD
    A[Producer] -->|写入| B[Central eventCh]
    B --> C[Consumer-1]
    B --> D[Consumer-2]
    B --> E[Consumer-N]
    C --> F[Batch Buffer]
    D --> G[Batch Buffer]
    E --> H[Batch Buffer]
    F & G & H --> I[Batch Dispatch]

3.2 无锁环形缓冲区(RingBuffer)在事件队列中的落地实现

无锁 RingBuffer 是高性能事件队列的核心载体,通过原子操作与内存序约束规避锁竞争,实现生产者-消费者线程间零阻塞协作。

核心设计契约

  • 固定容量、幂次长度(便于位运算取模)
  • 单一生产者 / 多消费者模型(LMAX Disruptor 风格)
  • 使用 std::atomic<uint64_t> 管理 publishSeqconsumerCursor

生产者提交逻辑(C++17)

bool tryPublish(Event* ev) {
    const uint64_t next = producerCursor_.load(std::memory_order_acquire) + 1;
    const uint64_t wrapPoint = next - capacity_;
    if (wrapPoint <= consumerCursor_.load(std::memory_order_acquire)) {
        return false; // 缓冲区满
    }
    entries_[next & indexMask_] = ev;
    producerCursor_.store(next, std::memory_order_release); // 发布可见性
    return true;
}

indexMask_ = capacity_ - 1(要求 capacity 为 2ⁿ),memory_order_release 保证写入 entries_ 对消费者可见;wrapPoint 判断基于最慢消费者位置,避免覆盖未消费事件。

性能对比(1M 事件/秒,单核)

实现方式 平均延迟(ns) GC 压力 线程争用
有锁 Queue 850 严重
无锁 RingBuffer 42
graph TD
    A[生产者调用 tryPublish] --> B{是否可写?}
    B -->|是| C[写入 entries_[idx]]
    B -->|否| D[返回 false]
    C --> E[原子更新 producerCursor]
    E --> F[消费者轮询 cursor 差值]

3.3 按客户端优先级动态调度的事件分发权重算法

传统轮询或固定权重策略难以应对高并发下移动端、IoT设备与管理后台对事件响应时效性的差异化需求。本算法引入实时优先级因子 $P_i$ 与负载衰减系数 $\alpha$,动态计算客户端 $i$ 的分发权重:

def calculate_weight(client):
    # client: {priority: int, recent_latency_ms: float, active_sessions: int}
    base = max(1, client["priority"])  # 基础优先级(1–10)
    decay = 1.0 / (1 + 0.01 * client["recent_latency_ms"])  # 延迟惩罚
    load_penalty = 1.0 / (1 + 0.1 * client["active_sessions"])  # 负载抑制
    return round(base * decay * load_penalty, 3)

逻辑分析:base 保障高优先级客户端最低调度保障;decay 对延迟 >500ms 的客户端权重衰减超40%;load_penalty 防止单客户端过载导致雪崩。三因子相乘实现非线性自适应。

权重调节效果对比(典型场景)

客户端类型 初始优先级 平均延迟 当前会话数 计算权重
管理后台 8 42 ms 3 7.62
移动App 6 186 ms 12 2.91
IoT传感器 4 890 ms 45 0.38

调度决策流程

graph TD
    A[新事件入队] --> B{获取客户端实时指标}
    B --> C[并行计算各客户端权重]
    C --> D[归一化为概率分布]
    D --> E[按权重采样目标客户端]
    E --> F[投递并更新延迟/负载统计]

第四章:内核与运行时协同调优

4.1 Go runtime GOMAXPROCS与网络轮询器(netpoll)深度调参

Go 的并发调度高度依赖 GOMAXPROCS 与底层 netpoll 的协同。默认值为逻辑 CPU 数,但高吞吐网络服务常需精细调优。

GOMAXPROCS 的实际影响

  • 过低:P 不足,M 频繁阻塞/唤醒,goroutine 调度延迟升高
  • 过高:P 间缓存行竞争加剧,runtime.lock 争用上升
  • 最佳实践:GOMAXPROCS=runtime.NumCPU() * 0.8(实测 HTTP/1.1 长连接场景)

netpoll 与 epoll/kqueue 绑定机制

// 启动时 runtime 初始化 netpoll 实例
func netpollinit() {
    epfd = epollcreate1(0) // Linux only
    if epfd == -1 { panic("epollcreate1 failed") }
}

该调用绑定单个 epoll fd 到全局 netpoll 实例,所有 goroutine 的 I/O 等待均经此复用——不随 GOMAXPROCS 变化而复制

关键参数对照表

参数 默认值 推荐范围 影响维度
GOMAXPROCS NumCPU() 4–32(非 NUMA 服务器) P 数量、调度粒度
netpoll 并发度 单实例 无法配置(硬编码) I/O 复用瓶颈点

调优验证流程

  1. GODEBUG=schedtrace=1000 观察 sched trace 中 idleprocs 波动
  2. strace -e epoll_wait,writev 定位 netpoll 唤醒频次
  3. 对比 ab -n 100000 -c 1000 下 QPS 与 99% 延迟变化
graph TD
    A[goroutine 发起 Read] --> B{是否就绪?}
    B -->|是| C[直接返回数据]
    B -->|否| D[注册到 netpoll]
    D --> E[epoll_wait 阻塞等待]
    E --> F[内核事件触发]
    F --> G[唤醒对应 goroutine]

4.2 TCP栈参数调优:SO_KEEPALIVE、TCP_USER_TIMEOUT与quick ACK启用

为什么默认保活机制不够用

Linux 默认 SO_KEEPALIVE 每 2 小时探测一次(tcp_keepalive_time=7200),对微服务或移动网络场景完全失效。需结合应用层语义缩短探测周期。

关键参数协同调优

// 启用并定制保活行为(单位:秒)
int enable = 1;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &enable, sizeof(enable));
int idle = 60, interval = 10, probes = 3;
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));     // 首次探测延迟
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); // 重试间隔
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &probes, sizeof(probes)); // 最大失败次数

逻辑分析:TCP_KEEPIDLE=60 触发首探,TCP_KEEPINTVL=10 连续 3 次无响应后内核标记连接为 ESTABLISHED → CLOSE_WAIT,避免僵尸连接堆积。

更激进的故障判定:TCP_USER_TIMEOUT

参数 作用
TCP_USER_TIMEOUT 5000 ms 应用层指定“最后容忍的未确认数据等待时间”,超时直接 RST

快速 ACK 启用(降低延迟)

# 启用延迟 ACK 抑制(即快速 ACK)
echo 1 > /proc/sys/net/ipv4/tcp_low_latency

该设置使内核在收到乱序包或立即有数据待发时跳过 tcp_delack_min 延迟,提升实时性。

graph TD
    A[应用写入数据] --> B{TCP_USER_TIMEOUT 是否超时?}
    B -- 是 --> C[RST 连接]
    B -- 否 --> D[触发 SO_KEEPALIVE 探测]
    D --> E{ACK 回复?}
    E -- 否且达 TCP_KEEPCNT --> F[关闭 socket]

4.3 Linux内核级优化:epoll边缘触发模式适配与socket选项精细化配置

边缘触发(ET)模式核心约束

ET模式要求应用必须一次性读/写完所有就绪数据,否则会丢失后续通知。需配合O_NONBLOCK使用,避免阻塞导致事件饥饿。

关键socket选项配置

  • SO_REUSEPORT:允许多进程绑定同一端口,提升负载均衡能力
  • TCP_NODELAY:禁用Nagle算法,降低小包延迟
  • SO_RCVBUF/SO_SNDBUF:显式调优缓冲区,避免内核自动缩放干扰ET语义

epoll ET模式典型代码片段

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 必须非阻塞

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 启用边缘触发
ev.data.fd = sockfd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &ev);

逻辑分析EPOLLET启用ET后,仅当socket状态从“不可读”变为“可读”时触发一次通知;若未读尽recv()返回EAGAIN,需持续循环直至返回0或EAGAIN,否则该fd将不再被唤醒。

常见ET误用对比表

场景 LT模式行为 ET模式行为
半包未读完 持续触发EPOLLIN 仅首次触发,后续静默
recv()返回EAGAIN 可安全忽略 必须作为读取结束标志
graph TD
    A[epoll_wait返回就绪] --> B{EPOLLET启用?}
    B -->|是| C[循环read/write直到EAGAIN]
    B -->|否| D[单次处理即可]
    C --> E[确保数据边界完整性]

4.4 GC停顿抑制:对象池复用、零拷贝序列化与预分配缓冲区实践

在高吞吐低延迟场景中,频繁对象创建会触发Young GC,加剧STW风险。核心优化路径有三:

  • 对象池复用:避免短生命周期对象反复分配/回收
  • 零拷贝序列化:跳过中间字节数组,直接读写堆外内存
  • 预分配缓冲区:固定大小缓冲池+线程局部存储(TLS)

对象池实践(Apache Commons Pool)

GenericObjectPool<ByteBuffer> pool = new GenericObjectPool<>(
    new BasePooledObjectFactory<ByteBuffer>() {
        public ByteBuffer create() { return ByteBuffer.allocateDirect(8192); }
        public PooledObject<ByteBuffer> wrap(ByteBuffer b) { return new DefaultPooledObject<>(b); }
    }
);

allocateDirect 创建堆外缓冲,规避堆内GC压力;BasePooledObjectFactory 定制初始化逻辑,DefaultPooledObject 封装状态管理。

零拷贝序列化对比

方式 内存拷贝次数 GC压力 典型库
Jackson(堆内) 2+ jackson-databind
Protobuf(零拷贝) 0 极低 protobuf-java
graph TD
    A[业务对象] -->|序列化| B[DirectByteBuf]
    B --> C[SocketChannel.write]
    C --> D[网卡DMA]

第五章:面向生产环境的SSE高可用演进路径

架构瓶颈暴露于真实流量洪峰

某金融级实时行情系统在日均120万连接、峰值38万并发SSE长连接场景下,单节点Nginx+Node.js架构出现连接抖动——监控显示每小时平均断连率突增至7.2%,且重连耗时中位数达4.8秒。根本原因在于单点EventSource代理层缺乏连接状态同步能力,当Worker进程重启时,未持久化的客户端游标(Last-Event-ID)丢失,导致重复推送与消息空洞并存。

会话状态解耦与分布式游标管理

引入Redis Streams作为游标存储中枢,每个客户端连接绑定唯一client_id,其消费偏移量以哈希结构存于stream:offsets:{service}键下。关键代码片段如下:

// 消费端获取游标
const offset = await redis.hget(`stream:offsets:quote`, clientId) || '$';
// 推送后更新游标
await redis.xadd('stream:quote', 'MAXLEN', '~', '10000', '*', 'data', JSON.stringify(event));
await redis.hset(`stream:offsets:quote`, clientId, '$');

实测将游标恢复时间从秒级压缩至毫秒级,断连后重连消息补全率提升至99.998%。

多活网关层的连接亲和性调度

部署三地四中心Nginx集群,通过GeoIP+客户端IP哈希实现连接路由一致性。配置核心策略:

upstream sse_backend {
    hash $remote_addr consistent;
    server 10.1.2.10:3001 max_fails=2 fail_timeout=30s;
    server 10.1.2.11:3001 max_fails=2 fail_timeout=30s;
}

配合Kubernetes Pod反亲和性规则,确保同一地域内SSE服务实例分散于不同可用区,单AZ故障时连接保持率稳定在92%以上。

故障注入验证下的熔断机制

使用Chaos Mesh对SSE服务注入500ms网络延迟及30%丢包,触发自适应熔断器:当连续5次HTTP 503响应或X-RateLimit-Remaining低于阈值时,自动切换至降级通道——将实时行情转为15秒聚合快照,并通过独立轻量级WebSocket通道推送。该机制在2023年Q3华东机房光缆中断事件中成功拦截87%异常请求。

组件 原始SLA 高可用改造后SLA 关键改进点
连接建立成功率 99.2% 99.995% TLS握手缓存+OCSP Stapling
消息端到端延迟 P95: 820ms P95: 210ms 内核TCP_BBRv2+零拷贝发送
故障恢复MTTR 4.7min 22s etcd健康检查+自动剔除

流量染色与全链路追踪

在SSE响应头注入X-Trace-ID: ${uuid},结合OpenTelemetry Collector采集event_typeclient_regionconnection_duration等12个维度标签。通过Grafana看板实时下钻分析:发现新加坡节点因CDN缓存策略误启导致Cache-Control: no-cache被覆盖,经调整Cloudflare Page Rule后,该区域首字节时间(TTFB)下降63%。

容器化部署的内存泄漏防护

Node.js应用启用--max-old-space-size=2048并配置cgroup v2内存限制,同时集成clinic-doctor进行周期性堆快照比对。在v2.4.1版本中定位到EventSource流未正确销毁导致的IncomingMessage对象滞留问题,通过req.socket.on('close')钩子显式调用res.destroy()修复,单Pod内存占用从3.2GB降至1.1GB。

灰度发布中的双写验证

新版本SSE服务上线采用金丝雀发布:10%流量同时写入旧版Kafka Topic与新版Pulsar Topic,通过Flink SQL实时比对两条链路的消息序列号、payload CRC32及到达时间戳。当检测到偏差率>0.001%时自动回滚,保障2024年全年零数据不一致事故。

用代码写诗,用逻辑构建美,追求优雅与简洁的极致平衡。

发表回复

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