Posted in

【Golang匹配引擎避坑手册】:生产环境踩过的12个致命错误,第8个90%团队仍在复现

第一章:Golang游戏匹配引擎的核心架构与设计哲学

Go 语言凭借其轻量级协程、高效并发模型与静态编译能力,天然适配实时性要求高、连接密集型的游戏匹配场景。匹配引擎并非单纯算法容器,而是融合状态管理、策略调度与弹性伸缩的系统级组件——其设计哲学根植于“可预测性优先、延迟可控、演进友好”三大原则。

并发模型与连接抽象

引擎采用 net/http 的长连接(WebSocket)与自定义 TCP 协议双通道支持,所有玩家连接被封装为 PlayerSession 结构体,内嵌 sync.Mutex 保护状态,并通过 chan *MatchRequest 统一接入匹配队列。关键设计在于:每个匹配周期由独立 goroutine 启动,避免阻塞主线程;超时控制使用 time.AfterFunc 而非 select 配合 time.Timer,防止 Timer 泄漏。

匹配策略的插件化组织

核心匹配逻辑解耦为可替换策略接口:

type MatchStrategy interface {
    Name() string
    Match(players []*PlayerSession) ([]*MatchGroup, error)
}

默认实现 RankBasedStrategy 按 MMR 分段滑动窗口匹配,支持运行时热加载新策略(通过 plugin.Open() 加载 .so 文件)。启动时通过环境变量 MATCH_STRATEGY=ranked 动态绑定,无需重启服务。

状态一致性保障机制

匹配过程涉及多阶段状态跃迁(Waiting → Matching → Paired → Ready),采用乐观锁 + 版本号(version uint64 字段)校验更新:

// 更新前检查版本是否一致
if !atomic.CompareAndSwapUint64(&p.version, expected, expected+1) {
    return errors.New("concurrent update rejected")
}

所有状态变更均记录至 WAL(Write-Ahead Log)文件,故障恢复时按序重放日志,确保跨节点会话状态最终一致。

弹性扩缩容边界设计

引擎不依赖外部服务发现,而通过 etcd 监听 /match/nodes 路径下的健康节点列表。每个实例仅处理哈希槽 playerID % N 对应的匹配请求,N 为当前活跃节点数。扩容时新节点注册后自动参与哈希分片,旧节点平滑退出——无状态匹配器本身即为水平扩展单元。

组件 关键约束 容错手段
Session 管理 单节点承载 ≤50k 连接 心跳超时自动驱逐
匹配调度器 单次匹配耗时 ≤80ms(P99) 超时丢弃并触发降级策略
日志持久化 WAL 写入延迟 双写 + fsync 强制刷盘

第二章:匹配算法实现中的典型陷阱与优化实践

2.1 基于权重的实时匹配算法:理论边界与goroutine泄漏实测分析

核心匹配逻辑与权重衰减模型

匹配器为每个候选节点动态维护 weight = base × e^(-λ×t),其中 λ 控制时效敏感度,t 为距最近心跳的时间戳(秒级)。

goroutine泄漏复现关键路径

func (m *Matcher) watchNode(ctx context.Context, nodeID string) {
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop() // ❌ 缺失:若ctx.Done()先触发,ticker未stop
    for {
        select {
        case <-ctx.Done():
            return // goroutine 永久悬挂
        case <-ticker.C:
            m.updateWeight(nodeID)
        }
    }
}

逻辑分析:defer ticker.Stop() 在函数退出时才执行,但 ctx.Done() 触发后 return 跳过 defer;应改用 defer func(){ if !ticker.Stop() { <-ticker.C } }() 或显式 stop。

实测泄漏规模(压测 10 分钟)

并发连接数 泄漏 goroutine 数 内存增长
100 1,240 +86 MB
500 6,890 +420 MB

数据同步机制

  • 权重更新通过无锁环形缓冲区批量提交
  • 每次匹配仅读取快照,避免读写竞争

2.2 时间窗口滑动匹配的精度偏差:时钟漂移补偿与单调时钟实践

数据同步机制

分布式系统中,事件时间(event time)匹配依赖本地时钟。但物理时钟存在时钟漂移(clock drift),导致滑动窗口边界错位,引发重复或漏处理。

时钟漂移补偿策略

  • 使用 NTP/PTP 同步,但网络延迟引入残余误差(±10–100ms);
  • 更可靠的是采用单调时钟CLOCK_MONOTONIC),规避系统时间被手动/自动回拨风险。

单调时钟实践示例

#include <time.h>
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts); // 安全获取单调纳秒级时间戳
uint64_t now_ns = ts.tv_sec * 1e9 + ts.tv_nsec;

CLOCK_MONOTONIC 不受系统时间调整影响,仅随真实流逝递增。tv_sec+tv_nsec 组合提供高精度(通常纳秒级),适用于窗口起止计算。注意:该值不可映射到绝对时间,需与事件时间戳对齐时引入偏移校准因子。

滑动窗口精度对比

时钟源 漂移典型值 回拨敏感 适用场景
CLOCK_REALTIME ±500 ppm 日志时间标记
CLOCK_MONOTONIC 窗口边界、超时控制
graph TD
    A[事件到达] --> B{使用 CLOCK_MONOTONIC 计算窗口ID}
    B --> C[窗口ID = floor(now_ns / window_size_ns)]
    C --> D[按ID聚合,避免因NTP跳变导致ID倒流]

2.3 多维度匹配条件组合爆炸:位图索引压缩与预筛选剪枝实战

当用户查询同时涉及 status IN ('active','pending') AND region IN ('CN','US','JP') AND is_premium = true,原始笛卡尔组合达 $2 \times 3 \times 1 = 6$ 路,随维度增加呈指数增长。

位图压缩实现

# 使用 RoaringBitmap 压缩稀疏位图(比传统 bitmap 节省 80%+ 内存)
from roaringbitmap import RoaringBitmap
active_bm = RoaringBitmap([1, 5, 12, 99])  # 用户ID集合
pending_bm = RoaringBitmap([2, 7, 15])
cn_us_jp_bm = RoaringBitmap([1,2,5,7,12,15,99])  # region联合位图
result = (active_bm | pending_bm) & cn_us_jp_bm & RoaringBitmap([1,5,12])  # is_premium=true的ID

RoaringBitmap 将整数集分块为 16-bit key + container(array/run/bs),对稀疏场景自动选用高效容器;|& 为O(n)并行位运算,避免全量扫描。

预筛选剪枝流程

graph TD
    A[原始查询条件] --> B{是否启用位图预筛?}
    B -->|是| C[加载各维度位图]
    C --> D[执行 AND/OR 位运算]
    D --> E[仅对非空交集ID查主表]
    B -->|否| F[全表扫描]

关键参数对照表

参数 推荐值 说明
bitmap_cache_ttl 300s 避免频繁重建冷热混合位图
min_cardinality_for_bitmap 1000 低于该基数不建位图,防小集合开销反超

2.4 ELO/TrueSkill等评分模型集成:浮点精度丢失与并发更新竞态修复

在高并发对战匹配场景中,ELO/TrueSkill 更新常因浮点累加误差和无锁写入引发评分漂移与不一致。

浮点精度补偿策略

采用 decimal.Decimal 替代 float 进行 Δrating 累加,关键路径示例:

from decimal import Decimal, getcontext
getcontext().prec = 28  # 保障28位精度

def update_elo(a_rating: Decimal, b_rating: Decimal, result: int) -> tuple[Decimal, Decimal]:
    exp_a = 1 / (1 + 10 ** ((b_rating - a_rating) / 400))
    delta = Decimal('32') * (Decimal(str(result)) - exp_a)  # 避免float中间量
    return a_rating + delta, b_rating - delta

result 为0/0.5/1;Decimal(str(result)) 防止整数→float隐式转换;10 ** (...) 中的指数仍为Decimal,全程无float介入。

并发安全更新机制

使用数据库行级乐观锁 + 原子CAS(Compare-and-Swap):

字段 类型 说明
rating DECIMAL(28,12) 高精度评分值
version BIGINT 乐观锁版本号
updated_at TIMESTAMP 最后更新时间

数据同步机制

graph TD
    A[客户端提交对局结果] --> B{校验version匹配?}
    B -- 是 --> C[执行UPDATE ... SET rating=..., version=version+1 WHERE id=? AND version=?]
    B -- 否 --> D[重读最新rating+version,重算Δ]
    C --> E[返回新rating与version]

2.5 匹配池动态扩缩容:基于QPS与延迟双指标的弹性伸缩控制器实现

匹配池作为实时匹配系统的核心资源池,其容量需在高并发与低延迟间取得动态平衡。传统单指标(如CPU利用率)扩缩容易导致“过载抖动”或“冷启动延迟”。

双指标协同决策模型

控制器同时采集每秒查询数(QPS)与P95延迟(ms),采用加权滑动窗口计算综合压力分:
score = 0.6 × norm(QPS) + 0.4 × norm(latency)

扩缩容策略逻辑

  • score ≥ 0.85:触发扩容,新增实例数 = ceil((score − 0.85) × current_size × 2)
  • score ≤ 0.3 且持续 3 个周期:缩容至 max(2, floor(current_size × 0.7))

核心控制器代码片段

def calculate_scale_action(qps, p95_ms, window=60):
    # 归一化:QPS基于历史峰值,延迟基于SLA阈值(如200ms)
    qps_norm = min(1.0, qps / max(1, self.peak_qps_history[-window:]))
    lat_norm = min(1.0, p95_ms / 200.0)  # SLA=200ms
    score = 0.6 * qps_norm + 0.4 * lat_norm

    if score >= 0.85:
        return "scale_up", int((score - 0.85) * self.current_size * 2)
    elif score <= 0.3 and self.stable_under_threshold >= 3:
        return "scale_down", max(2, int(self.current_size * 0.7))
    return "no_op", 0

该逻辑避免了单一指标误判:高QPS但低延迟时抑制激进扩容;低QPS但延迟飙升时仍可能触发扩容(如GC风暴),保障SLA。

指标 权重 归一化基准 敏感场景
QPS 0.6 近60s峰值 流量突增
P95延迟 0.4 SLA阈值(200ms) 后端依赖抖动
graph TD
    A[采集QPS/P95] --> B[滑动窗口归一化]
    B --> C[加权融合score]
    C --> D{score ≥ 0.85?}
    D -->|是| E[扩容:按比例+上限保护]
    D -->|否| F{score ≤ 0.3 & 稳定≥3周期?}
    F -->|是| G[缩容:保底2实例]
    F -->|否| H[维持当前规模]

第三章:高并发匹配场景下的资源管控误区

3.1 连接池与匹配上下文泄漏:net.Conn生命周期与context.WithTimeout误用对照

问题根源:上下文生命周期 ≠ 连接生命周期

http.Client 默认复用 net.Conn,但若对单次请求使用 context.WithTimeout 并传递给 http.Do(),该 context 仅约束本次请求,不终止底层连接的复用。连接可能被归还至 http.Transport 连接池,却仍持有已取消的 context 引用(如通过 http.Request.Context() 间接捕获),导致后续请求意外继承过期/取消状态。

典型误用代码

func badRequest() {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel() // ✅ 及时释放本层资源
    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
    // ❌ req.Context() 被绑定到短命 ctx,若 conn 复用,其内部 goroutine 可能持续监听已取消的 ctx
    http.DefaultClient.Do(req)
}

逻辑分析req.Context() 仅控制本次请求的 I/O 阻塞与取消信号;但 net.Conn 的读写、Keep-Alive 状态由 http.Transport 独立管理。此处 cancel() 触发后,连接池中的 conn 仍可能被后续请求复用,而其关联的 ctx.Done() 已关闭,引发 select 永久阻塞或提前退出。

正确实践对比

维度 误用方式 推荐方式
Context 作用域 绑定到 *http.Request 仅用于 http.Do() 调用时的超时控制
连接管理 依赖 request context 控制连接 通过 http.Transport.IdleConnTimeout 等参数显式配置
graph TD
    A[发起 HTTP 请求] --> B{req.Context() 是否含 timeout?}
    B -->|是| C[本次请求受控退出]
    B -->|否| D[连接池按 Transport 配置复用 conn]
    C --> E[conn 归还池中,但可能残留 ctx.Done() 引用]
    D --> F[conn 复用安全,无 context 泄漏]

3.2 Redis分布式锁在匹配仲裁中的失效模式:Redlock退化与本地缓存一致性破绽

Redlock在时钟漂移下的退化路径

当多个Redis节点间存在显著时钟漂移(>300ms),Redlock的quorum + TTL机制将无法保证互斥性。客户端A在Node1–3获取锁后,因Node4时钟快进导致其认为锁已过期而拒绝续租,但Node1–3仍视锁有效——形成“双主写入”。

# Redlock加锁伪代码(含关键缺陷点)
def redlock_acquire(resources, ttl_ms=5000):
    quorum = len(resources) // 2 + 1
    start_time = time.time() * 1000  # 依赖本地时钟!
    for node in random.shuffle(resources):
        if node.set(key, uuid, nx=True, px=ttl_ms):  # px: 毫秒级TTL
            acquired.append(node)
        if len(acquired) >= quorum:
            elapsed = (time.time() * 1000) - start_time
            # ⚠️ 若elapsed > ttl_ms/2,剩余有效期不足,锁实际不可靠
            return True if (ttl_ms - elapsed) > 1000 else False

逻辑分析:start_time取自客户端本地时钟,未与NTP对齐;elapsed计算结果受网络延迟+时钟漂移双重污染;当ttl_ms - elapsed < 1000ms时,锁在多数节点上剩余有效期不足1秒,仲裁系统可能误判为“已释放”,触发并发匹配。

本地缓存与分布式锁的隐式冲突

匹配引擎常采用「本地LRU缓存+Redis锁」混合架构,但缓存更新未与锁生命周期绑定:

缓存操作 是否同步锁状态 风险示例
写缓存 锁已释放,缓存仍保留脏数据
读缓存命中 返回过期锁保护下的陈旧匹配结果
graph TD
    A[匹配请求到达] --> B{本地缓存命中?}
    B -->|是| C[直接返回缓存结果]
    B -->|否| D[尝试Redlock加锁]
    D --> E[执行DB查询 & 写缓存]
    E --> F[释放锁]
    C --> G[跳过锁校验 → 可能返回锁已失效时的旧结果]

3.3 内存碎片化导致GC停顿飙升:对象复用池(sync.Pool)在匹配请求结构体中的精准应用

高并发匹配服务中,每秒创建数万 MatchRequest 结构体,频繁堆分配加剧内存碎片,触发 STW 时间从 0.2ms 涨至 12ms。

问题定位

  • Go runtime pprof 显示 runtime.mallocgc 占 CPU 热点 68%
  • go tool trace 发现 GC pause 呈周期性尖峰,与请求波峰严格同步

sync.Pool 集成方案

var requestPool = sync.Pool{
    New: func() interface{} {
        return &MatchRequest{} // 预分配零值结构体
    },
}

// 获取:复用或新建
req := requestPool.Get().(*MatchRequest)
req.Reset() // 清理上次残留字段(关键!)

// 归还:仅当未被重用时调用
defer func() { requestPool.Put(req) }()

Reset() 方法需手动清空指针/切片字段(如 req.Players = req.Players[:0]),避免悬挂引用延长对象生命周期;New 函数不执行初始化逻辑,交由 Reset() 统一控制。

效果对比

指标 优化前 优化后
GC pause avg 8.3ms 0.4ms
heap allocs/s 42k 1.1k
graph TD
    A[HTTP Handler] --> B{Get from Pool}
    B -->|Hit| C[Reset & Use]
    B -->|Miss| D[New struct]
    C --> E[Process Match]
    D --> E
    E --> F[Put back to Pool]

第四章:分布式匹配系统协同失败的关键路径

4.1 跨Region匹配状态同步:最终一致性下“重复匹配”与“漏匹配”的因果链溯源

数据同步机制

跨Region匹配依赖异步消息队列(如Kafka)传递状态变更事件,但网络分区或消费者重平衡可能导致事件乱序或重复投递。

因果链关键节点

  • Region A 发起匹配并写入本地状态 → 触发 MatchCreated 事件
  • 事件经跨Region网关投递至 Region B
  • Region B 消费延迟 > 状态TTL,导致二次匹配
# 匹配事件幂等校验逻辑(Region B)
def handle_match_event(event: dict):
    idempotency_key = f"{event['match_id']}_{event['version']}"
    if redis.exists(idempotency_key):  # 防重放
        return "DUPLICATED"
    redis.setex(idempotency_key, 3600, "processed")  # TTL=1h,覆盖窗口期
    apply_match_state(event)

该逻辑依赖version字段实现严格单调性;若上游未生成/透传version,则idempotency_key失效,引发重复匹配。

典型错误传播路径

阶段 失效点 后果
生产端 未绑定全局唯一trace_id 漏匹配溯源断裂
传输层 Kafka分区键设计不当 同一match事件乱序
消费端 无状态去重缓存过期 重复匹配
graph TD
    A[Region A 创建匹配] -->|事件含version| B[Kafka Topic]
    B --> C{Region B 消费}
    C -->|redis.exists==false| D[执行匹配]
    C -->|redis.exists==true| E[丢弃]

4.2 消息队列重试机制与匹配幂等性冲突:基于匹配ID+版本号的双校验落地方案

数据同步机制痛点

消息重试导致重复消费,而仅依赖match_id做幂等判重时,若同一匹配请求因业务逻辑更新(如报价变更)需多次触发,旧版本消息覆盖新版本结果,引发数据不一致。

双校验设计原理

引入 match_id + version 联合唯一键,既保障同一匹配流程的原子性,又支持版本演进:

// 幂等写入SQL(MySQL)
INSERT INTO match_result (match_id, version, price, status) 
VALUES (?, ?, ?, 'SUCCESS') 
ON DUPLICATE KEY UPDATE 
  price = CASE WHEN VALUES(version) > version THEN VALUES(price) ELSE price END,
  version = GREATEST(version, VALUES(version)),
  updated_at = NOW();

逻辑分析ON DUPLICATE KEY UPDATE 依赖 (match_id, version) 联合唯一索引;GREATEST() 确保高版本优先生效,避免降级覆盖;CASE 表达式实现“仅升版更新”语义。

校验维度对比

维度 仅 match_id match_id + version
防重放
支持版本演进
存储开销 略增(+8B INT)

执行流程

graph TD
  A[消费者拉取消息] --> B{查 match_result<br>WHERE match_id=? AND version >= ?}
  B -->|存在且version更高| C[丢弃]
  B -->|不存在或version更低| D[执行INSERT ON DUPLICATE]

4.3 服务发现抖动引发的匹配分区:etcd租约续期中断与gRPC连接保活协同策略

当 etcd 租约续期因网络延迟或 leader 切换中断时,客户端服务实例可能被过早剔除,而 gRPC 连接仍处于 READY 状态,导致流量持续打向已下线节点——即“匹配分区”。

核心协同机制

  • 双心跳对齐:gRPC 的 keepalive 参数需与 etcd 租约 TTL/TTL 续期窗口对齐
  • 状态感知熔断:监听 LeaseKeepAliveResponse 错误事件触发连接主动关闭

关键参数配置表

参数 推荐值 说明
etcd lease TTL 10s 服务注册租约总时长
lease keepalive interval 3s 续期间隔,≤ TTL/3
grpc.keepalive_time_ms 6000 首次探测前空闲时间
grpc.keepalive_timeout_ms 1000 探测失败判定超时
// 启动租约续期并绑定 gRPC 连接生命周期
leaseResp, err := cli.KeepAlive(ctx, leaseID)
if err != nil {
    log.Warn("lease keepalive failed, closing gRPC conn")
    conn.Close() // 主动终止不一致连接
    return
}

该代码在租约续期失败时立即关闭 gRPC 连接,避免状态错位。conn.Close() 触发客户端负载均衡器实时剔除该 endpoint,保障服务发现一致性。

graph TD
    A[etcd Lease Renewal] -->|Success| B[gRPC Conn: KEEPALIVE]
    A -->|Failure| C[Trigger conn.Close()]
    C --> D[LB 移除 endpoint]
    D --> E[流量零转发至下线实例]

4.4 灰度发布期间匹配规则不一致:配置中心热加载与规则引擎版本快照隔离实践

问题根源:热更新与执行态的时序错位

灰度发布中,配置中心推送新规则后,部分节点立即生效,而规则引擎仍在执行旧版本快照,导致流量路由结果不一致。

解决方案:双快照隔离机制

  • 规则引擎启动时加载 rule_snapshot_v1 并锁定执行上下文
  • 配置中心仅触发 pending_snapshot_v2 缓存,不直接覆盖运行时
  • 全量节点就绪后,原子切换 current_snapshot ← pending_snapshot_v2

数据同步机制

// RuleEngineContext.java
public void commitSnapshot(String snapshotId) {
    // 原子替换:CAS保障线程安全
    if (CURRENT_SNAPSHOT.compareAndSet(
            currentVersion, snapshotId)) { // ✅ 旧版ID → 新版ID
        logger.info("Rule snapshot switched to: {}", snapshotId);
    }
}

CURRENT_SNAPSHOTAtomicReference<String>,确保多线程下快照切换无竞态;snapshotId 为带时间戳的唯一标识(如 v2_20240520_143022),支持回溯审计。

版本兼容性对照表

规则引擎版本 支持快照格式 热加载延迟 回滚能力
v3.2+ JSON Schema v2 ✅ 原子回退
v3.1 JSON Schema v1 ~800ms ❌ 仅重启
graph TD
    A[配置中心推送新规则] --> B[写入 pending_snapshot_v2]
    B --> C{全节点健康检查}
    C -->|通过| D[原子切换 CURRENT_SNAPSHOT]
    C -->|失败| E[告警并保留 v1]
    D --> F[所有节点执行统一快照]

第五章:从踩坑到加固:构建可演进的匹配稳定性体系

在某大型电商实时推荐系统升级过程中,匹配服务在大促峰值期间出现 32% 的漏匹配率,日志显示大量请求卡在用户画像向量检索阶段。根因分析发现:原架构采用单体 Redis 缓存用户兴趣标签,当新增 17 类行为特征后,缓存键膨胀至平均 4.2KB,触发 Redis 单 key 内存碎片率超 68%,GET 操作 P99 延迟从 8ms 飙升至 210ms。

服务降级策略失效的现场复盘

上线前未覆盖“缓存穿透+下游超时”的组合故障场景。当某类新商品类目无历史曝光数据时,匹配引擎持续发起空查询,触发 Hystrix 熔断器误判——因熔断阈值按 QPS 设定(500/s),而实际空查询流量达 1200/s 且响应时间稳定在 150ms,熔断器始终未触发,最终压垮下游 Elasticsearch 节点。

动态权重漂移监控看板

我们部署了基于滑动窗口的特征权重健康度仪表盘,实时追踪各匹配维度(如地域相似度、品类偏好分、时效衰减因子)的贡献方差。当“直播热度分”在晚间时段方差突增至 0.43(基线为 0.07),系统自动告警并触发权重回滚流程,避免因算法迭代引发的匹配结果震荡。

监控指标 基线值 当前值 异常判定条件
向量检索超时率 0.3% 12.7% >5% 持续5分钟
匹配结果空返回率 1.2% 31.4% Δ>25pp 且环比+300%
特征加载延迟 42ms 189ms P95 >150ms

多级缓存穿透防护链

// 在 FeignClient 层注入防御逻辑
@MatchGuard(cacheType = CacheType.REDIS, 
            fallbackStrategy = FallbackStrategy.BLOOM_FILTER)
public MatchResult queryUserProfile(Long userId) {
    // 自动校验布隆过滤器 + 空值缓存双保险
}

演进式灰度发布机制

通过 Envoy Proxy 实现匹配服务的渐进式流量切分:先将 0.1% 流量导向新版向量索引服务(Faiss-GPU),同时采集两套结果的 Jaccard 相似度;当相似度连续 10 分钟 ≥0.92 且错误率差值

可观测性增强实践

在匹配决策链路埋入 OpenTelemetry Span 标签,关键字段包括 match_stage=vector_retrievalfeature_version=v3.2.1fallback_triggered=true。通过 Grafana 关联查询发现:当 fallback_triggered 标签出现时,match_stage=rule_fusion 的耗时中位数上升 4.7 倍,直接定位到规则引擎中正则表达式回溯漏洞。

该方案已在 3 个核心业务线落地,匹配服务全年可用性从 99.23% 提升至 99.995%,平均修复 MTTR 由 47 分钟压缩至 8 分钟。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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