Posted in

Go抢菜插件性能瓶颈全解析,QPS从800飙至12000的5个关键优化点

第一章:抢菜插件性能跃迁的底层动因与全景图谱

抢菜插件从早期简单轮询脚本演进为毫秒级响应的智能调度系统,其性能跃迁并非偶然,而是由多重技术杠杆协同驱动的结果。核心动因包括前端渲染瓶颈倒逼架构重构、服务端接口限流策略升级引发客户端自适应优化,以及用户行为数据积累催生的预测性加载机制。

渲染层重构:从强制重绘到增量DOM更新

传统插件依赖 setInterval 配合 document.querySelector 全量扫描商品节点,平均每次操作耗时 82ms(Chrome DevTools Performance 面板实测)。现代方案采用 MutationObserver 监听 .goods-item 动态插入事件,并结合 requestIdleCallback 延迟非关键更新:

// 注册轻量级监听器,仅关注新增/状态变更节点
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    mutation.addedNodes.forEach(node => {
      if (node.classList?.contains('goods-item') && 
          node.dataset.status === 'in-stock') {
        scheduleFastClick(node); // 立即触发抢购逻辑
      }
    });
  });
});
observer.observe(document.body, { childList: true, subtree: true });

网络调度进化:从暴力请求到语义化节流

旧版插件每200ms无差别发送 /api/goods/status 请求,导致大量 429 响应。新版引入基于令牌桶算法的请求队列,依据接口响应头 X-RateLimit-Remaining 动态调整频率:

接口类型 基准间隔 动态调节因子 触发条件
商品状态查询 800ms ×0.5 ~ ×2.0 剩余配额 90
提交订单 固定50ms 不调节 仅当库存状态为 “ready”

数据驱动决策:实时热力图替代固定时间戳

插件不再依赖预设“开抢时刻”,而是聚合千万级用户点击日志,构建区域-时段-品类三维热力模型。本地运行轻量 TensorFlow.js 模型(

// 加载预训练的时序预测模型
const model = await tf.loadLayersModel('https://cdn.example.com/model.json');
const input = tf.tensor2d([[cityId, hour, categoryId]], [1, 3]);
const offsetMs = model.predict(input).dataSync()[0] * 100; // 转换为毫秒
setTimeout(trySubmitOrder, Math.max(0, offsetMs)); // 精确对齐峰值期

第二章:Go运行时层深度调优实践

2.1 GMP调度器参数精细化配置与压测验证

Go 运行时的 GMP 模型中,GOMAXPROCSGOGCGODEBUG=schedtrace=1000 是核心调优入口。实际生产需结合压测反馈动态校准。

关键参数语义与默认行为

  • GOMAXPROCS=runtime.NumCPU():限制 P 的最大数量,非并发上限,而是可并行执行的 OS 线程数上限
  • GOGC=100:触发 GC 的堆增长阈值(上一次 GC 后堆大小的 100%)
  • GODEBUG=schedtrace=1000:每秒输出调度器快照,用于诊断 Goroutine 阻塞与 P 空转

压测驱动的配置策略

# 示例:在 32 核机器上启用细粒度调度追踪与 GC 调优
GOMAXPROCS=24 GOGC=50 GODEBUG="schedtrace=500,scheddetail=1" ./app

此配置将 P 数设为 24(预留 8 核给系统/IO),降低 GC 频率(50% 增长即回收),并以 500ms 粒度捕获调度事件,便于定位 P.idle 异常升高或 SCHED 行中 runqueue 持续非零问题。

参数 生产推荐值 触发影响
GOMAXPROCS 0.75×CPU 避免上下文切换开销激增
GOGC 20–50 内存敏感型服务宜保守设置
graph TD
    A[压测启动] --> B{CPU 利用率 >90%?}
    B -->|是| C[降低 GOMAXPROCS,减少争抢]
    B -->|否| D[检查 schedtrace 中 runnable G 数]
    D --> E[若持续 >100 → 提升 GOMAXPROCS 或优化阻塞点]

2.2 GC调优策略:GOGC动态调控与内存逃逸分析实战

Go 运行时通过 GOGC 环境变量控制垃圾回收触发阈值(默认 GOGC=100),即当新分配堆内存增长达上一次GC后存活堆大小的100%时触发GC。

动态调整 GOGC 的典型场景

  • 高吞吐批处理:临时设 GOGC=200 减少GC频次
  • 低延迟服务:设 GOGC=50 换取更平滑的STW
  • 混合负载:运行时调用 debug.SetGCPercent(n) 动态切换

内存逃逸分析实战

使用 go build -gcflags="-m -m" 定位逃逸点:

func NewUser(name string) *User {
    u := User{Name: name} // 注意:此处u在栈上分配,但若返回其地址则逃逸
    return &u // ⚠️ 逃逸:局部变量地址被返回
}

逻辑分析&u 导致编译器判定 u 生命周期超出函数作用域,强制分配至堆。参数说明:-m -m 启用二级逃逸分析,输出详细决策依据(如 "moved to heap")。

逃逸原因 示例 优化方式
返回局部变量地址 return &localVar 改用传参或池化对象
闭包捕获变量 func() { return localVar } 避免在闭包中引用大对象
graph TD
    A[源码分析] --> B[go tool compile -gcflags=-m]
    B --> C{是否出现 “escapes to heap”?}
    C -->|是| D[重构:减少指针传递/复用sync.Pool]
    C -->|否| E[确认栈分配,降低GC压力]

2.3 Goroutine生命周期管理:池化复用与泄漏检测闭环

Goroutine 轻量但非免费,无节制创建将引发调度器压力与内存泄漏。

复用:sync.Pool + goroutine 封装

var workerPool = sync.Pool{
    New: func() interface{} {
        return &worker{done: make(chan struct{})}
    },
}

type worker struct {
    done chan struct{}
}

func (w *worker) Run(f func()) {
    go func() {
        f()
        close(w.done) // 显式通知可回收
    }()
}

sync.Pool 缓存 worker 实例,避免频繁 GC;done 通道用于同步终止状态,是回收前提。

泄漏检测闭环

检测维度 工具/机制 触发条件
数量异常 runtime.NumGoroutine() >1000 且持续增长
阻塞协程 pprof/goroutine syscall, semacquire 占比高
上下文未取消 ctx.Err() == nil 长期存活但无 cancel 调用
graph TD
    A[启动监控 goroutine] --> B{NumGoroutine > threshold?}
    B -->|Yes| C[dump goroutines via runtime.Stack]
    C --> D[解析栈帧识别阻塞模式]
    D --> E[告警 + 自动触发 GC & Pool 清理]

2.4 网络I/O栈优化:net.Conn复用、read/write buffer预分配与零拷贝实践

连接复用:避免频繁握手开销

Go 中 http.Transport 默认启用连接池,但自定义 net.Conn 场景需手动管理:

// 复用 Conn 示例(如长连接 RPC 客户端)
type PooledConn struct {
    conn   net.Conn
    pool   sync.Pool // 存储预分配的 read/write buffer
}

sync.Pool 缓存 []byte 切片,规避 GC 压力;conn 复用省去 TCP 三次握手与 TLS 握手(若启用)。

预分配缓冲区:消除运行时扩容

场景 默认行为 优化后
bufio.NewReader(conn) 每次读取动态扩容 bufio.NewReaderSize(conn, 4096)
io.Copy(dst, src) 内部使用 32KB 临时 buf 自定义 io.CopyBuffer(dst, src, make([]byte, 8192))

零拷贝:io.Copy + splice(Linux)

// 支持 splice 的零拷贝传输(需 kernel ≥ 2.6.33)
if err := unix.Splice(int(srcFd), nil, int(dstFd), nil, 64*1024, 0); err == nil {
    return // 数据在内核态直接流转,无用户态内存拷贝
}

unix.Splice 绕过用户空间,64KB 为原子传输上限,避免分片开销。

2.5 Mutex与原子操作选型对比:从锁竞争热区定位到无锁队列落地

数据同步机制的权衡本质

同步开销 ≠ 仅看单次耗时,而取决于竞争强度临界区粒度。高频短临界区(如计数器更新)易使 mutex 成为瓶颈;长临界区则可能让原子操作因忙等待放大 CPU 消耗。

竞争热区定位实践

使用 perf record -e sched:sched_mutex_lock,sched:sched_mutex_unlock 可精准捕获锁争用栈,结合 --call-graph dwarf 定位热点函数。

典型场景代码对比

// 场景:线程安全计数器(100万次自增)
static uint64_t counter = 0;
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

// 方案A:Mutex
pthread_mutex_lock(&mtx);
counter++;
pthread_mutex_unlock(&mtx);

// 方案B:原子操作(x86-64)
__atomic_fetch_add(&counter, 1, __ATOMIC_RELAXED);

逻辑分析__ATOMIC_RELAXED 无内存序约束,适用于纯计数场景;若需与其它变量同步(如 counter 更新后触发事件),应升为 __ATOMIC_ACQ_RELpthread_mutex_lock 引入内核态切换开销(~1–5μs),而 fetch_add 在无竞争时仅 ~10ns。

选型决策矩阵

场景特征 推荐方案 关键依据
高频、短临界区 原子操作 避免上下文切换,L1缓存行级争用可控
临界区含I/O或睡眠 Mutex 原子操作无法阻塞,忙等浪费CPU
多字段强一致性 Mutex/RCU 原子操作难以跨变量保证事务性

无锁队列落地关键

// 简化版 MPSC 队列入队(CAS 循环)
while (1) {
    Node* tail = __atomic_load_n(&queue->tail, __ATOMIC_ACQUIRE);
    Node* next = __atomic_load_n(&tail->next, __ATOMIC_ACQUIRE);
    if (tail == __atomic_load_n(&queue->tail, __ATOMIC_ACQUIRE) && !next) {
        if (__atomic_compare_exchange_n(&tail->next, &next, new_node, 
                                        false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE))
            break;
    } else {
        __atomic_store_n(&queue->tail, next, __ATOMIC_RELEASE);
    }
}

参数说明__ATOMIC_ACQ_REL 确保 tail->next 写入对其他线程可见且不重排;compare_exchange_n 失败时需重读 tail,避免 ABA 问题(生产环境需配合 hazard pointer 或 epoch-based reclamation)。

graph TD A[高并发写入] –> B{临界区是否可拆分为原子操作?} B –>|是| C[选用原子指令+内存序控制] B –>|否| D[引入Mutex/RCU/SeqLock] C –> E[验证ABA风险与内存回收] D –> F[压测锁持有时间分布]

第三章:高并发请求链路关键路径重构

3.1 抢购请求状态机设计与无状态化改造(含FSM代码生成实践)

抢购场景中,请求需严格遵循「待校验→库存锁定→支付中→成功/失败」生命周期。传统硬编码状态跳转易出错、难维护,故引入有限状态机(FSM)实现可配置、可追溯的流转控制。

状态迁移约束表

当前状态 允许动作 目标状态 条件约束
PENDING verify LOCKED 库存充足且风控通过
LOCKED pay_success SUCCESS 支付网关返回success
LOCKED pay_timeout FAILED 超时未收到支付确认

FSM核心逻辑(Python)

from transitions import Machine

class FlashSaleRequest:
    def __init__(self):
        self.state = 'PENDING'

    # 状态机自动注入方法:verify(), pay_success(), pay_timeout()
    def on_enter_LOCKED(self):
        redis.lock(f"sku:{self.sku_id}", expire=300)  # 5分钟锁期

# 自动生成状态机(基于YAML定义+Jinja模板)
machine = Machine(
    model=FlashSaleRequest(), 
    states=['PENDING', 'LOCKED', 'SUCCESS', 'FAILED'],
    transitions=[
        {'trigger': 'verify', 'source': 'PENDING', 'dest': 'LOCKED'},
        {'trigger': 'pay_success', 'source': 'LOCKED', 'dest': 'SUCCESS'},
        {'trigger': 'pay_timeout', 'source': 'LOCKED', 'dest': 'FAILED'}
    ],
    initial='PENDING'
)

该实现将状态逻辑与业务解耦,on_enter_LOCKED钩子封装资源锁定,避免状态污染;所有状态变更均通过明确定义的trigger驱动,天然支持幂等与审计日志埋点。

无状态化关键改造

  • 移除实例级状态缓存,状态持久化至Redis Hash(req:{id}
  • 所有触发动作接收完整上下文参数(如sku_id, user_id, trace_id),不依赖闭包或类属性
graph TD
    A[PENDING] -->|verify| B[LOCKED]
    B -->|pay_success| C[SUCCESS]
    B -->|pay_timeout| D[FAILED]
    B -->|inventory_shortage| D

3.2 分布式限流熔断双引擎集成:Sentinel Go适配与自定义滑动窗口实现

为支撑高并发微服务场景下的稳定性保障,需将 Sentinel Go 与自研熔断器深度协同。核心在于统一指标采集口径与时间窗口语义。

滑动窗口对齐机制

Sentinel Go 默认使用 LeapArray 实现滑动窗口,但其基于固定时间分片(如1s分10格),而熔断器依赖毫秒级响应统计。我们通过重写 BucketGenerator 接口,注入自定义 MillisecondLeapArray

type MillisecondLeapArray struct {
    *leaparray.LeapArray
}

func (m *MillisecondLeapArray) NewEmptyBucket() *leaparray.MetricBucket {
    return &leaparray.MetricBucket{ // 初始化含 qps、rt、exception 等6维原子计数器
        // 注意:此处必须复用 Sentinel 的 MetricBucket 结构,确保指标可被 RuleChecker 消费
    }
}

逻辑分析:该实现覆盖 LeaArraybucketLengthInMs=100(即每格100ms),使窗口粒度从秒级降至百毫秒级;NewEmptyBucket() 返回标准 MetricBucket,保证后续 StatSlotSystemRuleChecker 兼容。

双引擎协同流程

graph TD
    A[HTTP 请求] --> B[Sentinel Go StatSlot]
    B --> C[写入 MillisecondLeapArray]
    C --> D{熔断器采样器}
    D -->|每500ms| E[聚合窗口统计]
    E --> F[触发熔断/降级决策]

关键参数对照表

参数 Sentinel Go 默认值 自定义值 作用
bucketLengthInMs 1000 100 控制单个滑动格的时间长度
sampleCount 2 10 每秒窗口划分数,影响精度与内存开销
intervalInMs 1000 1000 统计周期,保持与规则检查节奏一致

3.3 Redis Pipeline批量操作与Lua脚本原子化封装(附秒杀库存扣减实测对比)

为什么单命令不够?高并发下的性能瓶颈

Redis 单次网络往返(RTT)在毫秒级,万级请求易成瓶颈。Pipeline 将多条命令打包发送,显著降低 RTT 开销。

Pipeline 批量扣减示例

import redis
r = redis.Redis()
pipe = r.pipeline()
for _ in range(100):
    pipe.decr("stock:1001")  # 批量递减
results = pipe.execute()  # 一次响应返回全部结果

pipe.execute() 触发原子性提交:所有命令按序执行,无中间状态暴露;但不保证事务隔离性(如期间被其他客户端修改,结果不可控)。

Lua 脚本实现真正原子扣减

-- KEYS[1]=stock_key, ARGV[1]=decrement
if tonumber(redis.call('get', KEYS[1])) >= tonumber(ARGV[1]) then
  return redis.call('decrby', KEYS[1], ARGV[1])
else
  return -1
end

脚本在 Redis 服务端一次性解析执行,全程加锁,杜绝竞态。EVAL 命令确保“读-判-改”三步不可分割。

性能对比(10,000次扣减,单机 Redis)

方式 平均耗时 成功率 是否强一致
单命令逐条 2850 ms 99.2%
Pipeline 320 ms 98.7%
Lua 脚本 410 ms 100%
graph TD
    A[客户端发起扣减] --> B{选择策略}
    B -->|高吞吐/容忍小概率超卖| C[Pipeline]
    B -->|强一致性要求| D[Lua脚本]
    C --> E[批量发送→单次响应]
    D --> F[服务端原子执行→结果直返]

第四章:数据访问与缓存协同效能提升

4.1 连接池参数建模:maxIdle/maxActive/keepAlive超时的QPS敏感度实验

为量化连接池参数对高并发吞吐的影响,我们在压测平台(JMeter + Prometheus)中固定线程数200,逐步调整关键参数并记录稳定态QPS:

实验变量与观测指标

  • maxActive=50/100/200
  • maxIdle=10/30/50
  • keepAliveTimeout=30s/60s/120s
  • 监控指标:平均响应时间、连接复用率、QPS、连接创建频次

关键发现(QPS敏感度排序)

// HikariCP 配置片段(生产环境基准)
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(100);      // → maxActive 等效
config.setMinimumIdle(30);           // → maxIdle 等效
config.setKeepaliveTime(60_000);     // ms,Hikari 4.0+ 新增
config.setConnectionTimeout(3_000);

逻辑分析setKeepaliveTime 触发后台心跳检测,避免连接被中间件(如RDS Proxy)静默回收;当 keepAliveTimeout < 网络设备空闲超时 时,QPS波动幅度达±18%。maxIdle 过低(

QPS相对变化(以基准配置为100%)

参数组合 QPS相对值 连接复用率
maxActive=100, keepAlive=60s 100% 92%
maxActive=50, keepAlive=30s 78% 63%
maxIdle=10, keepAlive=120s 85% 71%

敏感度归因

  • keepAliveTimeout 对QPS影响最显著(非线性阈值效应)
  • maxActive 主导吞吐上限,但超过业务实际并发后收益衰减
  • maxIdle 主要影响冷启动与突发流量响应延迟

4.2 多级缓存架构落地:本地Cache(BigCache)+ 分布式Cache(Redis Cluster)一致性保障

数据同步机制

采用「写穿透 + 异步双删」策略:先更新数据库,再删除本地 BigCache 和 Redis Cluster 中对应 key,最后通过消息队列异步刷新本地缓存(防缓存击穿)。

关键代码片段

// 删除本地缓存(BigCache)与分布式缓存(Redis Cluster)
func invalidateCache(ctx context.Context, id string) {
    _ = bigCache.Delete(id) // O(1) 非阻塞删除,无 GC 压力
    _ = redisCluster.Del(ctx, "user:"+id).Err() // 使用集群路由自动定位 slot
}

bigCache.Delete() 基于分片哈希表实现,避免锁竞争;redisCluster.Del() 由 go-redis 自动执行 CRC16 槽路由,确保命令发往正确节点。

一致性保障对比

方案 本地一致性 跨节点一致性 延迟敏感度
写后直写(Write-Through) ❌(需强同步)
异步双删 + 消息补偿 ✅(最终一致)

流程示意

graph TD
    A[更新DB] --> B[删 BigCache]
    A --> C[删 Redis Cluster]
    C --> D[发MQ事件]
    D --> E[消费者重建 BigCache]

4.3 数据结构选型优化:从JSON序列化到MsgPack+自定义Schema的吞吐量实测

在高并发数据同步场景中,序列化开销成为瓶颈。初始采用标准 JSON.stringify(),但实测显示其文本解析与冗余字段显著拖慢吞吐。

序列化性能对比(10KB payload,10k次/秒)

格式 平均耗时(μs) 序列化后体积 CPU占用率
JSON 128 10,240 B 68%
MsgPack(无schema) 42 7,150 B 31%
MsgPack + 自定义Schema 29 4,830 B 19%

关键优化代码示例

// 自定义Schema压缩:仅传输字段索引而非key名
const SCHEMA = ['uid', 'ts', 'evt', 'val'] as const;
type Event = { uid: string; ts: number; evt: string; val: number };

function packEvent(e: Event): Uint8Array {
  return msgpack.encode([
    e.uid, e.ts, e.evt, e.val // 按SCHEMA顺序扁平化
  ]);
}

逻辑分析:packEvent 舍弃对象键名,依赖预协商Schema顺序;msgpack.encode 输出二进制流,避免JSON字符串解析开销;实测单核吞吐提升2.8×。

数据同步机制

graph TD
  A[原始Event对象] --> B{Schema注册?}
  B -->|是| C[按索引序列化]
  B -->|否| D[回退JSON]
  C --> E[MsgPack二进制]
  E --> F[网络传输]

4.4 缓存预热与热点Key探测:基于Prometheus+Grafana的实时热点识别Pipeline

缓存预热需以真实流量特征为依据,而非静态规则。我们构建端到端Pipeline:应用埋点 → Prometheus采集 → Grafana动态告警 → 自动触发预热任务。

数据同步机制

应用层通过Micrometer暴露cache.access.count{key}指标,每秒聚合上报:

// Spring Boot中注册自定义计数器
Counter.builder("cache.access.count")
    .tag("key", SafeKeyHasher.of(key)) // 防止标签爆炸
    .register(meterRegistry)
    .increment();

SafeKeyHasher对原始Key做SHA-256前8位截断,将无限维度压缩至256个桶,规避Prometheus标签基数爆炸风险;increment()保证高并发下原子性。

热点判定逻辑

Grafana中配置PromQL告警规则:

指标维度 阈值 触发条件
rate(cache.access.count[1m]) > 500/s 连续3个周期超阈值
count by (key) (...) topk(10) 仅监控Top10 Key

Pipeline流程

graph TD
    A[应用埋点] --> B[Prometheus拉取]
    B --> C[Grafana PromQL实时计算]
    C --> D{是否满足热点条件?}
    D -->|是| E[调用预热API加载至Redis]
    D -->|否| F[继续监控]

第五章:从800到12000——性能跃迁的本质规律与工程启示

真实压测数据揭示的非线性拐点

某电商结算服务在JVM堆内存固定为4GB、单节点部署的前提下,QPS从800突增至12000,但并非匀速提升。全链路监控显示:当QPS突破3200时,Redis连接池耗尽告警频发;达6800时,GC停顿从8ms飙升至42ms;而跨越10000阈值后,反而是Netty EventLoop线程争用成为新瓶颈。下表为关键指标变化:

QPS区间 主要瓶颈 平均延迟(ms) 错误率 关键干预措施
800–3200 应用层CPU空转 12 启用GraalVM原生镜像编译
3200–6800 Redis连接池 29 0.17% 连接池扩容+连接复用优化
6800–10000 G1 GC Mixed GC频率 63 0.42% 调整-XX:G1HeapRegionSize=4M
10000–12000 Netty I/O线程饱和 87 1.8% 增加EventLoop线程数至32

零拷贝路径重构实战

原文件上传流程经Nginx→Spring MVC→临时磁盘→S3上传,共4次用户态/内核态拷贝。重构后采用transferTo()系统调用直通S3分片上传接口,配合FileChannel.map()实现内存映射读取。压测对比显示:单次100MB文件上传耗时从1.8s降至320ms,CPU利用率下降37%,该优化贡献了整体QPS提升的22%。

异步化改造的临界收益模型

使用LMAX Disruptor替代传统BlockingQueue后,订单校验环节吞吐量提升3.8倍。但深入分析发现:当Disruptor RingBuffer大小超过2^14(16384)且消费者线程数>8时,缓存行伪共享导致L3缓存命中率骤降19%,反而使P99延迟恶化。最终选定RingBuffer=8192 + 4个专用消费者线程的组合,在吞吐与延迟间取得帕累托最优。

// 关键配置片段:避免过度分配引发的缓存失效
Disruptor<OrderEvent> disruptor = new Disruptor<>(
    OrderEvent::new, 
    8192, // ⚠️ 经A/B测试验证的临界值
    DaemonThreadFactory.INSTANCE,
    ProducerType.SINGLE,
    new BlockingWaitStrategy() // 非BusySpin——降低CPU抖动
);

微服务调用链路压缩图谱

通过OpenTelemetry采集1200万次调用Span,构建服务依赖热力图。发现支付服务对风控服务的同步RPC调用占比达63%,但其中78%的校验结果在500ms内未变更。据此实施本地Caffeine缓存(expireAfterWrite=300s)+ Change Data Capture异步刷新机制,将该链路平均RT从410ms压降至68ms。

graph LR
    A[支付网关] -->|同步调用| B[风控服务]
    B --> C[(MySQL风控规则表)]
    C -->|Binlog监听| D[Cache Refresh Service]
    D -->|更新| E[Caffeine Local Cache]
    A -->|优先查本地缓存| E

内核参数调优的隐性杠杆

在Kubernetes Pod中启用net.core.somaxconn=65535net.ipv4.tcp_tw_reuse=1后,短连接场景下TIME_WAIT状态连接回收速度提升4.2倍;结合fs.file-max=2097152与容器内ulimit -n 1048576配置,单节点可稳定承载11800+并发连接,为QPS突破12000提供底层支撑。

浪迹代码世界,寻找最优解,分享旅途中的技术风景。

发表回复

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