Posted in

Go语言HIS实时检验结果推送中断?WebSocket+断线续推+ACK幂等机制一文打通

第一章:Go语言HIS实时检验结果推送中断?WebSocket+断线续推+ACK幂等机制一文打通

医院信息系统(HIS)中检验结果的实时性直接关乎临床决策效率。当基于Go构建的检验结果推送服务遭遇网络抖动、客户端闪退或服务重启时,传统单向WebSocket推送极易丢失关键消息,导致医生端长期未收到危急值告警。

WebSocket连接生命周期管理

采用gorilla/websocket库实现健壮连接:启用SetReadDeadlineSetWriteDeadline防止阻塞;在OnClose回调中主动触发会话清理,并将未确认消息标记为pending状态存入Redis哈希表(key: pending:conn_id, field: msg_id, value: JSON序列化的原始消息)。

断线续推的触发与范围控制

客户端重连成功后,需携带上次接收的last_ack_id发起握手。服务端比对Redis中该连接的pending消息ID集合,仅推送ID > last_ack_id且未超时(TTL=30min)的消息。示例逻辑:

// 查询待续推消息(按ID升序)
msgs, _ := redisClient.ZRangeByScore(ctx, "pending:"+connID, &redis.ZRangeBy{
    Min: strconv.FormatInt(lastAckID+1, 10),
    Max: "+inf",
}).Result()
for _, msgJSON := range msgs {
    conn.WriteMessage(websocket.TextMessage, []byte(msgJSON))
}

ACK确认与幂等去重

客户端收到消息后立即发送ACK帧(格式:{"type":"ack","msg_id":12345})。服务端通过redisClient.ZRem("pending:"+connID, msgID)原子删除已确认消息;同时利用Redis SETNX指令以ack:connID:msgID为键做去重登记,确保同一消息重复ACK不引发二次处理。

机制 技术选型 关键保障点
连接可靠性 gorilla/websocket 心跳保活 + 错误重连指数退避
消息持久化 Redis Sorted Set 按ID有序存储,支持范围查询
幂等控制 Redis SETNX + TTL=24h 防止ACK乱序/重发导致重复消费
超时清理 Redis TTL + 定时任务 自动清除72小时未确认的陈旧消息

第二章:WebSocket在HIS检验结果实时推送中的核心实现与优化

2.1 WebSocket连接生命周期管理与HIS业务场景适配

在医院信息系统(HIS)中,WebSocket需精准匹配临床业务的实时性与可靠性需求,如医嘱执行状态推送、检验结果秒级下发、手术室设备心跳保活等场景。

连接建立阶段的业务校验

// HIS接入时强制携带机构编码+工号+终端类型
const handshakeParams = new URLSearchParams({
  orgId: "SZYY-001",     // 医院唯一编码(用于路由隔离)
  staffId: "DOC20230876", // 绑定CA认证身份
  terminal: "EMR_WEB"     // 区分移动端/护士站/医生站
});

该参数由前端登录态注入,服务端通过/ws?...解析后完成RBAC权限预检与科室会话分组,避免未授权终端接入核心业务通道。

生命周期关键状态映射表

WebSocket状态 HIS业务含义 超时阈值 自动响应动作
connecting 登录验证中 5s 拒绝重复连接请求
open 实时监护流已就绪 推送最新生命体征快照
closing 护士站切屏至其他系统 30s 缓存未确认医嘱并标记“待同步”

断线重连策略流程

graph TD
  A[连接异常] --> B{是否在手术中?}
  B -->|是| C[立即启用本地缓存+降级为轮询]
  B -->|否| D[指数退避重连:1s→2s→4s→max16s]
  C --> E[术中结束→恢复WebSocket]
  D --> F[重连成功→同步离线消息]

2.2 基于gorilla/websocket的高性能双向通道构建实践

连接生命周期管理

使用 websocket.Upgrader 安全升级 HTTP 连接,禁用默认 CORS 并设置心跳超时:

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true }, // 生产需校验来源
    HandshakeTimeout: 5 * time.Second,
}

CheckOrigin 允许跨域(开发阶段),HandshakeTimeout 防止恶意客户端阻塞握手;生产环境应结合 JWT 或 Referer 校验。

消息收发优化

采用带缓冲 channel 解耦读写 goroutine,避免 WriteMessage 阻塞:

组件 推荐缓冲大小 作用
send channel 64 平滑突发消息
ping ticker 30s 维持连接活跃状态

数据同步机制

func (c *Client) writePump() {
    ticker := time.NewTicker(pingPeriod)
    defer ticker.Stop()
    for {
        select {
        case message, ok := <-c.send:
            if !ok { c.conn.Close(); return }
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
                return
            }
        case <-ticker.C:
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

writePump 独立协程保障写操作非阻塞;SetWriteDeadline 防止挂起连接;PingMessage 触发自动 Pong 响应,维持长连接健康度。

2.3 检验结果消息序列化策略:Protocol Buffers vs JSON性能实测对比

在高吞吐检验系统中,检验结果需毫秒级序列化传输。我们对比 Protobuf(v3.21)与 JSON(Jackson 2.15)在 1KB 典型检验报告(含嵌套 TestItem、时间戳、状态枚举)上的实测表现:

序列化耗时(百万次平均,单位:μs)

格式 序列化 反序列化 序列化后体积
Protocol Buffers 82 117 324 B
JSON 296 443 872 B
// Protobuf 定义(test_result.proto)
message TestResult {
  int64 timestamp = 1;
  Status status = 2; // enum
  repeated TestItem items = 3;
}

该定义通过二进制紧凑编码规避 JSON 字段名重复开销,且 repeated 自动优化变长数组内存布局。

// 等效 JSON 片段(含冗余键名与字符串引号)
{
  "timestamp": 1717023456000,
  "status": "PASSED",
  "items": [{"id":"T001","value":98.5}]
}

JSON 解析需反复字节匹配引号与冒号,而 Protobuf 基于字段编号的定长/变长编码(如 zigzag)显著降低解析分支预测失败率。

性能瓶颈归因

  • JSON:UTF-8 解码 + 动态字段名哈希 + 浮点数字符串转换
  • Protobuf:零拷贝 ByteBuffer 直接读写 + 预编译 Parser 实例复用

graph TD A[原始Java对象] –>|Protobuf| B[二进制流
字段编号+长度前缀] A –>|JSON| C[UTF-8文本流
键值对+结构符号] B –> D[无反射反序列化
直接内存映射] C –> E[动态字段绑定
JSON树构建]

2.4 并发连接池设计与HIS高并发检验报告推送压测验证

为支撑日均50万+检验报告实时推送至HIS系统,我们基于Netty构建了可动态伸缩的连接池,支持长连接复用与自动故障熔断。

连接池核心配置

// 初始化HIS连接池(每个HIS院区独立池)
ConnectionPool pool = ConnectionPool.builder()
    .maxConnections(200)        // 单院区最大并发连接数
    .minIdle(20)                 // 最小空闲连接,保障低延迟响应
    .idleTimeout(30, TimeUnit.MINUTES)  // 空闲超时回收
    .healthCheckInterval(10, TimeUnit.SECONDS) // 主动心跳探测
    .build();

该配置在保持资源利用率的同时,将连接建立耗时从平均380ms降至12ms(冷启→热复用),显著降低TCP握手开销。

压测关键指标(单节点,JMeter 1000线程持续60分钟)

指标 数值 达标线
TPS(报告/秒) 1,842 ≥1,500
99%响应延迟 87ms ≤120ms
连接池拒绝率 0.00% ≤0.1%

数据同步机制

  • 采用“内存队列 + 批量异步刷写”双缓冲策略
  • 失败消息自动进入Redis重试队列(TTL=2h,指数退避重试)
  • 每条报告携带trace_id,全链路日志可追溯
graph TD
    A[检验LIS系统] -->|HTTP POST| B(本地内存队列)
    B --> C{连接池获取可用Channel}
    C -->|成功| D[序列化+加密+推送]
    C -->|失败| E[降级至Redis延迟队列]
    D --> F[HIS接收确认]
    E --> C

2.5 TLS安全加固与医院内网穿透方案(反向代理+证书双向认证)

医院内网需兼顾合规性与远程协诊需求,传统端口映射存在明文传输与身份泛化风险。采用 Nginx 反向代理 + mTLS(双向 TLS)构建零信任接入层。

核心配置要点

  • 强制客户端证书校验,禁用弱密码套件(TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 为最低要求)
  • 证书生命周期由内部 CA 统一签发,有效期≤90天,自动轮换集成至 DevOps 流水线

Nginx mTLS 配置片段

server {
    listen 443 ssl;
    ssl_certificate /etc/nginx/certs/his-gateway.crt;
    ssl_certificate_key /etc/nginx/certs/his-gateway.key;
    ssl_client_certificate /etc/nginx/certs/internal-ca.crt;  # 根CA用于验证客户端证书
    ssl_verify_client on;                                      # 启用双向认证
    ssl_protocols TLSv1.3;                                     # 禁用 TLS 1.0/1.1
}

逻辑分析:ssl_client_certificate 指定受信根CA证书链,Nginx 用其验证客户端证书签名有效性;ssl_verify_client on 强制校验,未提供有效证书则返回 400;TLSv1.3 确保前向保密与 AEAD 加密。

访问控制流程

graph TD
    A[外部终端] -->|携带客户端证书| B(Nginx 反向代理)
    B --> C{证书有效性校验}
    C -->|通过| D[转发至HIS内网服务]
    C -->|失败| E[拒绝连接,返回400]

安全策略对比表

策略维度 单向 TLS 双向 TLS(本方案)
身份认证粒度 仅服务端认证 终端+服务端双向绑定
中间人攻击防护 弱(依赖DNS/证书吊销) 强(私钥不离终端,证书绑定工号)
合规适配 满足等保2.0基础要求 满足等保2.0三级增强要求

第三章:断线续推机制的设计哲学与工程落地

3.1 基于Redis Streams的断线状态追踪与未确认消息持久化

Redis Streams 天然支持消息追加、消费者组(Consumer Group)与待处理队列(Pending Entries List),是实现断线感知与消息可靠投递的理想载体。

核心机制设计

  • 每个客户端以唯一 client_id 加入消费者组 consumer-group:orders
  • 断线时,其未确认(XCLAIM 未执行)消息保留在 PEL 中,超时自动标记为 idle
  • 心跳检测通过 XINFO CONSUMERS 实时获取各消费者最后活动时间戳

消息写入与消费示例

# 写入订单事件(含时间戳与来源标识)
XADD orders * event_type "order_created" order_id "ORD-789" client_id "web-03" ts "1717024560"

# 创建消费者组(初始从头读取)
XGROUP CREATE orders consumer-group:orders 0 MKSTREAM

# 客户端消费(自动记录到PEL)
XREADGROUP GROUP consumer-group:orders web-03 COUNT 1 STREAMS orders >

逻辑说明:XREADGROUP 触发后,消息立即进入该消费者 PEL;若客户端崩溃,XPENDING orders consumer-group:orders - + 10 可查出所有 idle > 60s 的待处理项,供恢复服务主动 XCLAIM

消费者活跃度状态表

client_id pending_count idle_ms last_delivered_id
web-01 0 1200 1717024560-0
web-03 1 86420000 1717024560-0
graph TD
    A[Producer 发送消息] --> B[Streams 追加并分配ID]
    B --> C{Consumer Group 拉取}
    C --> D[消息进入消费者PEL]
    D --> E[ACK前断线?]
    E -->|是| F[PEL中idle超时 → 触发重分配]
    E -->|否| G[XACK 清除PEL条目]

3.2 客户端重连时的会话上下文恢复与增量消息拉取协议设计

数据同步机制

客户端重连时,需在无状态服务端快速重建会话视图。核心依赖两个关键元数据:session_id(全局唯一)与 last_seen_seq(客户端最后确认的序列号)。

协议交互流程

graph TD
    A[客户端发起重连] --> B{携带 session_id + last_seen_seq}
    B --> C[服务端查询会话快照缓存]
    C --> D[比对 seq 差值 Δ = current_max_seq - last_seen_seq]
    D -->|Δ ≤ 1000| E[直接返回增量消息列表]
    D -->|Δ > 1000| F[触发快照+增量双阶段同步]

增量拉取请求示例

GET /v1/messages?session=abc123&since=456789&limit=200 HTTP/1.1
Authorization: Bearer eyJhbGci...
  • since:客户端上次成功处理的全局单调递增序列号(非时间戳),保障严格有序;
  • limit:硬性约束防止长轮询阻塞,服务端强制截断并返回 has_more: true

消息元数据表

字段 类型 说明
seq int64 全局唯一、严格递增的消息序号
ts int64 服务端写入毫秒时间戳(仅用于调试)
type string MSG, ACK, HEARTBEAT 等语义类型

服务端依据 seq 范围索引 LSM-Tree,毫秒级定位增量区间。

3.3 HIS检验LIS系统时间戳对齐与服务端消息水位线(Watermark)控制

数据同步机制

HIS与LIS系统间检验结果推送依赖强一致性时间基准。采用NTP校时+逻辑时钟补偿双机制,确保毫秒级时间戳对齐。

水位线控制策略

服务端为每条检验消息注入单调递增的watermark_ts(单位:ms),消费者按水位线提交偏移量:

// Kafka消费者水位线校验逻辑
if (record.timestamp() > watermarkTs.get()) {
    watermarkTs.set(record.timestamp()); // 更新当前水位
    commitOffset(record.offset());        // 仅当越过水位才提交
}

watermarkTs为原子长整型,保障多线程安全;commitOffset()需幂等实现,避免重复消费导致检验结果错乱。

关键参数对照表

参数名 含义 推荐值 约束条件
max_watermark_lag_ms 允许最大水位滞后 5000 ≥ 网络RTT + 处理延迟
clock_skew_tolerance_ms 时间偏差容忍阈值 100 需 ≤ NTP同步周期

流程协同示意

graph TD
    A[HIS生成检验请求] --> B[打上NTP校准时间戳]
    B --> C[LIS响应并携带watermark_ts]
    C --> D[服务端聚合校验水位线]
    D --> E[下游按watermark提交消费位点]

第四章:ACK幂等机制保障检验结果“一次且仅一次”送达

4.1 基于UUID+业务键(LabOrderID+ResultTime)的双重幂等令牌生成

在高并发检验结果上报场景中,单靠 UUID 易导致重复提交;引入业务语义键可精准锚定唯一事实。

核心生成逻辑

import uuid
from hashlib import sha256

def generate_idempotent_token(lab_order_id: str, result_time: str) -> str:
    # 拼接业务键并哈希,确保长度固定、无特殊字符
    raw = f"{lab_order_id}|{result_time}".encode()
    return str(uuid.uuid5(uuid.NAMESPACE_OID, sha256(raw).hexdigest()))[:16]

uuid5 基于 SHA-1 哈希确定性生成,sha256(raw) 提升抗碰撞能力;截取前 16 位兼顾唯一性与存储效率。

优势对比

方案 冲突风险 业务可追溯性 存储开销
纯 UUID 高(无业务上下文) 36 字符
LabOrderID+ResultTime 拼接 中(时钟精度不足) 可变长
本方案(UUID5 + SHA256) 极低 高(含完整业务键) 16 字符

数据同步机制

graph TD
A[检验系统] –>|携带 token 上报| B(网关幂等过滤器)
B –> C{token 是否已存在?}
C –>|是| D[返回 409 Conflict]
C –>|否| E[写入 Redis + 转发至业务服务]

4.2 ACK消息的异步确认链路与超时自动补偿(Backoff重试+死信隔离)

数据同步机制

ACK确认采用事件驱动异步链路:生产者发送消息后不阻塞,由独立消费者监听确认事件,通过 Redis Stream 持久化待确认状态。

超时补偿策略

  • 初始重试间隔 100ms,指数退避(×1.8),最多 5 次
  • 第 3 次失败后触发告警并标记为“可疑”
  • 第 5 次失败自动路由至死信队列(DLQ)
def schedule_ack_retry(msg_id: str, attempt: int):
    delay = int(100 * (1.8 ** (attempt - 1)))  # 单位:毫秒
    redis.zadd("ack_retry_zset", {msg_id: time.time() + delay / 1000})

delay 基于指数退避公式计算;zadd 利用有序集合实现延迟调度;时间戳单位为秒,需将毫秒转为秒。

死信隔离流程

阶段 动作 监控指标
重试中 写入 ZSET + 记录 attempt retry_count
超时失败 MOVE 到 dlq:ack_failed dlq_enqueue_time
人工介入 从 DLQ 拉取并诊断 manual_resolve
graph TD
    A[发送ACK请求] --> B{超时?}
    B -- 是 --> C[计算Backoff延迟]
    C --> D[写入重试ZSET]
    B -- 否 --> E[标记SUCCESS]
    D --> F[延迟任务触发]
    F --> G{第5次?}
    G -- 是 --> H[转入DLQ]
    G -- 否 --> A

4.3 分布式环境下Redis Lua原子脚本实现ACK去重与状态机更新

在高并发分布式任务系统中,多个Worker可能重复处理同一任务并提交ACK。为保障“恰好一次”语义,需在Redis中以Lua脚本实现原子化的ACK校验与状态跃迁。

核心设计原则

  • ACK ID幂等写入(SETNX + 过期时间)
  • 状态机仅允许合法跃迁(如 processing → success,禁止 pending → success
  • 所有操作包裹于单次Lua执行,规避网络往返与竞态

Lua脚本实现

-- KEYS[1]: task_key, ARGV[1]: ack_id, ARGV[2]: new_state, ARGV[3]: ttl_sec
local current_state = redis.call('HGET', KEYS[1], 'state')
if not current_state then return {0, "task_not_found"} end

-- 检查状态跃迁合法性
local valid_transitions = {
    ["pending"] = {"processing"},
    ["processing"] = {"success", "failed", "timeout"},
    ["success"] = {},
    ["failed"] = {}
}
local allowed = false
for _, s in ipairs(valid_transitions[current_state] or {}) do
    if s == ARGV[2] then allowed = true; break end
end
if not allowed then return {0, "invalid_transition"} end

-- 去重:仅当ACK未存在时才记录并更新状态
local ack_key = KEYS[1] .. ":acks:" .. ARGV[1]
if redis.call('SETNX', ack_key, '1') == 1 then
    redis.call('EXPIRE', ack_key, tonumber(ARGV[3]))
    redis.call('HSET', KEYS[1], 'state', ARGV[2], 'updated_at', ARGV[4])
    return {1, "ack_accepted"}
else
    return {0, "ack_duplicate"}
end

逻辑分析:脚本接收任务键、ACK ID、目标状态、TTL及时间戳;先校验当前状态合法性,再通过SETNX实现ACK去重,成功则同步更新状态与时间戳。全程无中间状态暴露,确保强一致性。参数ARGV[4]为毫秒级时间戳,用于追踪最后更新时间。

状态跃迁约束表

当前状态 允许跃迁至 说明
pending processing 任务刚被领取
processing success, failed, timeout 处理完成或异常终止
success 终态,不可再变更

执行流程示意

graph TD
    A[收到ACK请求] --> B{任务是否存在?}
    B -- 否 --> C[返回 task_not_found]
    B -- 是 --> D{状态跃迁是否合法?}
    D -- 否 --> E[返回 invalid_transition]
    D -- 是 --> F[SETNX 写入 ACK ID]
    F -- 已存在 --> G[返回 ack_duplicate]
    F -- 新写入 --> H[更新 state & updated_at<br/>设置 ACK 过期]
    H --> I[返回 ack_accepted]

4.4 全链路幂等日志审计与HIS质控报表可视化(Prometheus+Grafana集成)

数据同步机制

HIS系统通过唯一业务ID(biz_id)与操作指纹(md5(req_body+timestamp+operator))双重校验保障幂等。每次质控事件触发后,自动写入审计日志并推送至Prometheus Pushgateway。

# pushgateway.yml 示例(服务端配置)
global:
  external_labels:
    env: "prod"
    system: "HIS"
scrape_configs:
- job_name: 'hmis-idempotent-audit'
  static_configs:
  - targets: ['pushgateway:9091']

该配置使Prometheus主动拉取Pushgateway中带hmis_audit_success_total{biz_id="20240501001", op_type="lab_review"}标签的计数器,实现按业务维度聚合。

可视化看板设计

Grafana中构建三层下钻面板:

  • 顶层:日均幂等拦截率趋势(rate(hmis_idempotent_rejected_total[1d])
  • 中层:TOP5高频重复操作类型(topk(5, sum by (op_type) (hmis_audit_success_total))
  • 底层:单次biz_id全链路耗时热力图(基于Loki日志关联TraceID)
指标名称 Prometheus指标 业务含义 告警阈值
幂等拦截率 hmis_idempotent_rejected_total 单日被拦截的重复请求占比 >15%
质控超时率 hmis_qc_duration_seconds_count{quantile="0.95"} 质控流程P95响应超时次数 >50次/小时

审计日志采集链路

graph TD
  A[HIS应用] -->|HTTP POST /api/v1/audit| B{Idempotent Filter}
  B -->|通过| C[业务逻辑执行]
  B -->|拦截| D[写入审计日志+Prometheus Counter++]
  C --> E[异步推送至Loki+Jaeger]
  D & E --> F[Grafana统一看板]

第五章:总结与展望

技术栈演进的实际影响

在某大型电商平台的微服务重构项目中,团队将原有单体架构迁移至基于 Kubernetes 的云原生体系。迁移后,平均部署耗时从 47 分钟压缩至 92 秒,CI/CD 流水线成功率由 63% 提升至 99.2%。关键指标变化如下表所示:

指标 迁移前 迁移后 变化幅度
服务平均启动时间 8.4s 1.2s ↓85.7%
日均故障恢复时长 28.6min 47s ↓97.3%
配置变更灰度覆盖率 0% 100% ↑∞
开发环境资源复用率 31% 89% ↑187%

生产环境可观测性落地细节

团队在生产集群中统一接入 OpenTelemetry SDK,并通过自研 Collector 插件实现日志、指标、链路三态数据的语义对齐。例如,在一次支付超时告警中,系统自动关联了 Nginx 访问日志中的 X-Request-ID、Prometheus 中的 payment_service_latency_seconds_bucket 指标分位值,以及 Jaeger 中对应 trace 的 db.query.duration span。整个根因定位耗时从人工排查的 3 小时缩短至 4 分钟。

# 实际部署中启用的 OTel 环境变量片段
OTEL_RESOURCE_ATTRIBUTES="service.name=order-service,env=prod,version=v2.4.1"
OTEL_TRACES_SAMPLER="parentbased_traceidratio"
OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.internal:4317"

多云策略下的成本优化实践

为应对公有云突发计费波动,团队构建跨 AWS 和阿里云的混合调度层。通过自定义 Kubernetes Scheduler Extender,依据实时 Spot 实例价格(每 5 分钟同步一次)、节点 GPU 利用率(>75% 触发迁移)、SLA 合约剩余时长(

工程效能工具链协同图谱

以下 mermaid 流程图展示了研发流程中各工具的实际集成路径:

flowchart LR
    A[GitLab MR] -->|Webhook| B(Jenkins Pipeline)
    B --> C{SonarQube 扫描}
    C -->|失败| D[阻断合并]
    C -->|通过| E[Argo CD Sync]
    E --> F[K8s 集群]
    F --> G[Datadog 健康检查]
    G -->|异常| H[自动回滚 + 企业微信告警]
    G -->|正常| I[New Relic 用户行为埋点验证]

安全左移的实证效果

在金融客户合规审计中,SAST 工具集成至 PR 阶段后,高危漏洞(CWE-79、CWE-89)平均修复周期从 17.3 天缩短至 2.1 天;容器镜像扫描覆盖率达 100%,累计拦截含 Log4j 2.14.1 的恶意基础镜像 37 次,其中 12 次来自第三方 Helm Chart 仓库。

未来技术债管理机制

团队已建立基于 Git Blame + Jira Issue Link 的自动化技术债追踪看板,每日解析最近 30 天 commit 中标记 #techdebt 的代码行,关联对应 Jira ticket 的优先级、负责人及预计解决周期,并在周会中滚动更新 Top 5 债务项。当前最高风险项为遗留的 Oracle 数据库连接池硬编码配置,已在 Q3 路线图中排期替换为 Vault 动态凭据方案。

从 Consensus 到容错,持续探索分布式系统的本质。

发表回复

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