Posted in

若依Go版WebSocket在线用户统计不准?用gorilla/websocket+Redis Streams重构实时会话中心

第一章:若依Go版WebSocket在线用户统计不准的根源剖析

WebSocket在线用户统计失准并非孤立现象,而是由连接生命周期管理、状态同步机制与并发控制三者耦合失效共同导致。核心问题在于:服务端未严格区分“连接建立”“心跳续期”“异常断连”三种状态变更事件,导致用户计数器在连接抖动或网络延迟场景下出现重复累加或漏减。

连接注册时机存在竞态条件

若依Go版默认在ws.Upgrader.Upgrade成功后立即执行userManager.Add(userID),但此时客户端可能尚未完成首次心跳上报。当多个Tab页快速打开同一账号页面时,多个goroutine并发调用Add(),而userManager底层采用非线程安全的map[string]bool存储,引发计数虚高。修复需引入读写锁并校验心跳标识:

// 修正后的连接注册逻辑(需在user_manager.go中替换原Add方法)
func (m *UserManager) Add(userID string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    // 仅当用户尚未注册且心跳标志为false时才添加(等待首次心跳确认)
    if _, exists := m.users[userID]; !exists {
        m.users[userID] = false // false表示未通过心跳验证
    }
}

心跳超时判定逻辑不一致

当前代码使用time.AfterFunc设置单次超时,但未在每次pong回调中重置定时器,导致实际超时窗口固定为初始值(如30秒),无法响应动态网络波动。应改用time.Timer.Reset()实现滑动窗口检测。

客户端主动断连未触发清理

当浏览器标签页关闭时,部分Chrome版本不会立即发送FIN包,服务端依赖TCP Keepalive(默认2小时)才能感知断连,期间用户仍被计入在线列表。必须结合websocket.OnClose事件与defer conn.Close()的显式清理:

触发场景 当前处理方式 正确处理方式
客户端正常关闭 依赖TCP FIN 捕获websocket.CloseMessage并立即调用Remove()
网络中断 等待Keepalive超时 启用SetReadDeadline配合心跳超时主动驱逐
服务端重启 全量丢失在线状态 持久化连接ID至Redis并设置TTL

统计口径未统一

前端调用/api/user/online接口返回的是内存map长度,而后台定时任务每5分钟将该值写入Redis。若统计页面在定时任务间隙请求,将获取到过期快照。应改为实时聚合:GET user:online:count失败时回退至内存计数,并记录告警日志。

第二章:gorilla/websocket核心机制与会话建模实践

2.1 WebSocket握手流程与连接生命周期管理

WebSocket 连接始于 HTTP 升级请求,客户端发起带 Upgrade: websocketSec-WebSocket-Key 的请求,服务端校验后返回 101 Switching Protocols 响应,并附上 Sec-WebSocket-Accept(基于密钥的 SHA-1 Base64 签名)。

握手关键字段对照表

字段 客户端发送 服务端响应 作用
Upgrade websocket websocket 标识协议升级意图
Connection Upgrade Upgrade 协同升级语义
Sec-WebSocket-Key 随机 Base64 字符串 防止缓存与代理干扰
Sec-WebSocket-Accept base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) 服务端合法性证明
// 客户端握手请求片段(含关键头)
const ws = new WebSocket("wss://api.example.com/chat");
// 浏览器自动构造:Sec-WebSocket-Key、Origin、Protocol 等

浏览器自动注入 Sec-WebSocket-Key 并隐藏签名计算;服务端必须严格验证该值,否则握手失败。

生命周期状态流转

graph TD
    A[CONNECTING] -->|握手成功| B[OPEN]
    B -->|send/close| C[CLOSING]
    C -->|ACK received| D[CLOSED]
    B -->|网络中断/错误| D

连接建立后,心跳保活、异常重连、优雅关闭均依赖对 onopen/onmessage/onclose/onerror 四类事件的精准响应。

2.2 gorilla/websocket并发安全连接池设计

WebSocket长连接在高并发场景下易因频繁建连/断连引发资源耗尽。gorilla/websocket原生不提供连接池,需手动构建线程安全的复用机制。

核心设计原则

  • 连接生命周期由池统一管理
  • 使用 sync.Pool 复用 *websocket.Conn 包装结构体(非 Conn 本身,因其非线程安全)
  • 每连接绑定唯一 context.Context 实现超时与取消

连接获取流程

func (p *Pool) Get(ctx context.Context) (*PooledConn, error) {
    select {
    case conn := <-p.ch:
        if conn.IsAlive() { // 心跳检测
            return conn, nil
        }
        conn.Close() // 清理失效连接
    default:
    }
    // 新建连接(带拨号上下文)
    wc, _, err := p.d.DialContext(ctx, p.url, nil)
    if err != nil {
        return nil, err
    }
    return &PooledConn{Conn: wc, pool: p}, nil
}

DialContext 确保建连可被取消;IsAlive() 通过 WriteControl 发送 ping 帧验证连接活性;PooledConn 封装原始 Conn 并实现 io.Closer 接口,Close() 会自动归还至 p.ch

池状态概览

状态项 说明
Cap 池最大容量(缓冲 channel 长度)
Len 当前空闲连接数
TotalCreated 累计创建连接数
graph TD
    A[Get] --> B{channel有空闲?}
    B -->|是| C[取连接并校验活性]
    B -->|否| D[新建连接]
    C --> E[返回PooledConn]
    D --> E

2.3 客户端心跳保活与异常断连自动清理策略

客户端与服务端长连接的生命维持,依赖于双向心跳机制:客户端周期性发送 PING 帧,服务端收到后立即响应 PONG,并刷新该连接的最后活跃时间戳。

心跳检测逻辑(服务端伪代码)

# 每5秒执行一次扫描(可配置)
for conn in active_connections:
    if now() - conn.last_pong_time > 30:  # 超过30秒未响应
        conn.close()
        cleanup_resources(conn.id)  # 清理会话、订阅关系、内存缓存

参数说明:last_pong_time 记录最近一次成功 PONG 时间;30秒为可调超时阈值,需大于心跳间隔(如10s)与网络抖动容忍窗口之和。

自动清理维度对比

清理项 触发条件 关联资源
连接句柄 心跳超时或TCP FIN/RST Socket fd、TLS上下文
用户会话状态 连接关闭且无重连请求 JWT续期标记、登录态缓存
订阅主题映射 会话失效后10s内未恢复 Redis Pub/Sub channel 绑定

异常断连处理流程

graph TD
    A[心跳超时] --> B{是否启用快速重连?}
    B -->|是| C[发送reconnect_hint给客户端]
    B -->|否| D[立即释放连接]
    C --> E[服务端保留会话30s]
    D --> F[同步清理所有关联资源]

2.4 基于Conn.SetReadDeadline的超时感知与优雅下线

SetReadDeadlinenet.Conn 提供的关键控制接口,用于为单次读操作设置绝对截止时间,而非超时周期。它不阻塞连接,却能精准触发 i/o timeout 错误,成为服务端实现连接级健康感知与平滑退出的核心机制。

超时感知原理

当调用 conn.SetReadDeadline(time.Now().Add(30 * time.Second)) 后:

  • 若读操作在截止前完成,返回正常数据;
  • 若超时未就绪,Read() 立即返回 net.ErrTimeout(底层为 os.ErrDeadlineExceeded);
  • 后续读写仍可继续——Deadline 不关闭连接,仅标记单次操作状态

优雅下线流程

// 示例:HTTP长连接中结合context与deadline
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
n, err := conn.Read(buf)
if err != nil {
    if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
        log.Println("read timeout, initiating graceful shutdown")
        // 触发连接清理、通知负载均衡器摘除节点等
        return
    }
}

逻辑分析:SetReadDeadline 必须在每次 Read() 前显式重置,否则后续读操作沿用旧时间点导致立即超时。net.Error.Timeout() 是安全判据,避免误判 EOF 或网络中断。

场景 SetReadDeadline 行为 推荐策略
心跳保活检测 每次心跳前设短 deadline(5s) 超时即断连并释放资源
数据帧解析阶段 解析 header 后动态更新 deadline 防止粘包导致无限等待
下线预通告期 设为 30s + 逐步缩短 配合反向代理 drain 机制
graph TD
    A[客户端发起连接] --> B[服务端 Accept]
    B --> C[启用 SetReadDeadline]
    C --> D{读操作是否超时?}
    D -->|是| E[记录日志,触发清理钩子]
    D -->|否| F[处理业务逻辑]
    E --> G[Close 连接 / 放入待回收池]

2.5 连接元数据绑定:用户ID、设备指纹与会话上下文注入

在实时数据管道中,元数据绑定是实现精准归因与行为追踪的关键环节。需将离散的原始事件流与用户身份、终端特征及会话状态动态关联。

核心绑定策略

  • 用户ID:优先采用登录态 auth_token 解析出的 user_id,降级使用 anonymous_id(由首次访问生成的持久化 UUID)
  • 设备指纹:基于 User-Agentscreen_resolutioncanvas_hash 等 12+ 维度哈希生成 device_fingerprint_v2
  • 会话上下文:通过 session_start_time + page_path + referral_source 构建 session_context_id

元数据注入示例(Flink SQL UDF)

-- 注入用户ID与设备指纹到事件流
SELECT 
  event_id,
  user_id,
  device_fingerprint,
  session_context_id,
  event_ts
FROM events
  JOIN LATERAL TABLE(enrich_metadata(user_agent, cookies, headers)) AS T(
    user_id, device_fingerprint, session_context_id)

此 UDF 内部调用 AuthResolver(解析 JWT)、FingerprintGenerator(SHA-256 多维哈希)、SessionContextBuilder(基于 30min 不活跃窗口),确保低延迟(P99

字段 来源 更新频率 是否可空
user_id JWT payload / Cookie 每次请求 否(降级为 anonymous_id)
device_fingerprint 浏览器侧采集 + 服务端校验 首次访问
session_context_id 服务端 Session Manager 每新会话
graph TD
  A[原始事件] --> B{元数据就绪?}
  B -->|是| C[注入 user_id/device_fingerprint/session_context_id]
  B -->|否| D[异步补全 + 延迟队列重试]
  C --> E[写入 Kafka Topic: enriched_events]

第三章:Redis Streams作为实时事件总线的设计与落地

3.1 Redis Streams数据模型与消息持久化语义分析

Redis Streams 是一种仅追加(append-only)的日志式数据结构,天然支持消息的持久化、多消费者组分发与精确一次(exactly-once)语义保障。

核心数据模型

每个 Stream 由有序的 entry(消息条目)组成,每条 entry 具有唯一 ID(格式:<milliseconds>-<sequence>),并以键值对形式存储字段:

# 创建并写入一条消息
XADD mystream * sensor_id 123 temperature 25.6 humidity 60
# 返回: "1718924523123-0"

逻辑分析* 表示由 Redis 自动生成时间戳+序列 ID;sensor_id/temperature 等为字段名,值可为任意二进制安全字符串。ID 的单调递增性保障全局顺序与可比较性。

持久化语义保障

特性 说明
写即持久(Write-through) 所有 XADD 均同步落盘(若配置 appendonly yes
消费者组 ACK 机制 XACK 显式标记已处理,失败时可重拉未确认消息
消息不丢失前提 AOF + RDB 持久化开启,且 aof-fsync alwayseverysec

消费者组消息生命周期

graph TD
    A[XADD → 追加到Stream] --> B[消费者组读取: XREADGROUP]
    B --> C{是否调用XACK?}
    C -->|是| D[消息从PEL中移除]
    C -->|否| E[仍驻留PEL,支持故障重投]

3.2 使用XADD/XREADGROUP实现会话事件的有序广播与消费隔离

Redis Streams 提供天然的时序性与多消费者组支持,是会话事件(如用户登录、心跳、登出)有序广播与隔离消费的理想载体。

数据同步机制

使用 XADD 写入事件,确保全局单调递增ID(如 * 自动生成):

XADD session:events * user_id 123 action "login" ts "1717024560"

* 由 Redis 自动分配毫秒级时间戳+序列号,保证严格时间序与分布式唯一性;字段键值对可灵活扩展会话元数据。

消费者组隔离模型

创建独立消费者组,实现不同服务(鉴权/审计/通知)逻辑解耦:

XGROUP CREATE session:events audit_cg $ MKSTREAM
XREADGROUP GROUP audit_cg consumer-1 COUNT 1 STREAMS session:events >

$ 表示从最新消息开始读取;> 表示仅读取未被该组任何消费者处理过的消息,天然支持失败重试与ACK确认。

关键参数对比

参数 作用 示例值
XREADGROUP ... > 读取未分配消息 >
XACK stream group id 标记消息已处理 XACK session:events audit_cg 1717024560-0
graph TD
    A[Producer XADD] -->|有序追加| B[session:events Stream]
    B --> C{audit_cg}
    B --> D{notify_cg}
    C --> E[独立偏移量/ACK状态]
    D --> F[独立偏移量/ACK状态]

3.3 消费组(Consumer Group)在多实例部署下的负载均衡与容错保障

消费组是 Kafka 实现水平扩展与高可用的核心抽象。当多个消费者实例加入同一 group.id,Kafka 自动触发 Rebalance 协议,将 Topic 分区均匀分配至各成员。

分区分配策略对比

策略 特点 适用场景
RangeAssignor 按字母序分组分区,易导致不均衡 小规模、分区数少
RoundRobinAssignor 轮询分配,需所有消费者订阅相同 Topic 均一订阅场景
CooperativeStickyAssignor 增量重平衡,减少停顿 生产环境推荐

Rebalance 触发条件

  • 新消费者加入或退出
  • 订阅 Topic 数量变更
  • 心跳超时(session.timeout.ms
props.put("group.id", "order-processor");
props.put("enable.auto.commit", "false");
props.put("auto.offset.reset", "earliest");
props.put("session.timeout.ms", "45000"); // 容忍短暂网络抖动

session.timeout.ms=45000 避免因 GC 或瞬时延迟误判为失联;enable.auto.commit=false 配合手动提交,确保精确一次语义。

容错行为流程

graph TD
    A[消费者心跳失败] --> B{是否超 session.timeout.ms?}
    B -->|是| C[Coordinator 发起 Rebalance]
    B -->|否| D[继续拉取并处理]
    C --> E[暂停消费、提交偏移量]
    E --> F[重新分配分区]
    F --> G[恢复消费]

第四章:重构实时会话中心的工程化实现

4.1 会话状态双写一致性:内存Map + Redis Streams协同机制

为兼顾低延迟与高可用,采用内存 Map(ConcurrentHashMap<String, Session>)作为热读主存储,Redis Streams 作为持久化与跨节点广播通道。

数据同步机制

写入时执行原子双写:先更新本地内存,再异步追加至 session_stream。失败时触发补偿重试(最多3次,指数退避)。

// 双写逻辑(带幂等校验)
String eventId = streamClient.xadd("session_stream", 
    Map.of("sid", sessionId, "data", json, "ts", String.valueOf(System.currentTimeMillis())));
sessionCache.put(sessionId, session); // 内存更新不可逆,保障读性能

xadd 返回唯一事件ID用于去重;ts 字段支撑按时间窗口回溯;内存 put 无锁且线程安全,确保读路径零延迟。

一致性保障策略

维度 内存Map Redis Streams
读延迟 ~5ms(网络+序列化)
容错能力 进程级丢失风险 持久化+ACK确认机制
重放能力 不支持 支持消费者组多点重放
graph TD
    A[Session Write] --> B[Update ConcurrentHashMap]
    A --> C[Send to Redis Stream]
    C --> D{ACK received?}
    D -- Yes --> E[Commit complete]
    D -- No --> F[Retry with backoff]

4.2 在线用户统计聚合服务:基于XINFO与XRANGE的实时快照计算

核心设计思路

利用 Redis Streams 的时序特性,将用户登录/登出事件写入 user:events 流,通过 XINFO STREAM 获取最新 ID 与长度,结合 XRANGE 拉取指定时间窗口内的活跃事件。

实时快照计算逻辑

# 获取当前流元信息(关键用于边界判定)
XINFO STREAM user:events

# 拉取最近30秒内所有登录事件(示例)
XRANGE user:events 1717028520000-0 + COUNT 1000

XINFO STREAM 返回 lengthfirst-entrylast-entry 等字段,其中 last-entry[0] 即最新消息ID的时间戳部分,可反解为毫秒时间;XRANGE 的起始参数需动态计算为 now - 30000,确保低延迟快照。

关键参数对照表

参数 含义 示例值
XINFO STREAM ... 获取流元数据 length: 1245
XRANGE key min max 按ID范围拉取事件 1717028520000-0 表示毫秒时间戳+序列号

数据同步机制

  • 登录事件格式:XADD user:events * action login uid 10023 ts 1717028520000
  • 聚合服务每5秒触发一次快照:先 XINFO 定位边界,再 XRANGE 拉取并去重计数。

4.3 若依权限体系对接:WebSocket鉴权中间件与JWT Token解析集成

鉴权流程概览

WebSocket 连接建立时需复用若依的 RBAC 权限模型,避免重复鉴权逻辑。核心路径:HTTP 握手 → 提取 Authorizationtoken 参数 → 解析 JWT → 校验签发者、过期时间及用户角色 → 绑定 SecurityContext 到 WebSocket session。

JWT 解析与角色注入

public class JwtWebSocketAuthInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String token = extractToken(request);
        if (token == null) return false;
        Claims claims = Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
        // 解析出若依标准字段:userId、username、deptId、roles(逗号分隔)
        attributes.put("userId", claims.get("userId", Long.class));
        attributes.put("roles", Arrays.asList(claims.get("roles", String.class).split(",")));
        return true;
    }
}

逻辑分析extractToken() 优先从 request.getQueryParams().getFirst("token") 获取,其次尝试 Authorization: Bearer xxxclaims.get("roles") 直接复用若依 SysUserEntitygetRoleNames() 序列化格式,确保 @PreAuthorize("hasRole('admin')")@MessageMapping 方法中无缝生效。

权限校验关键字段对照

JWT Claim 字段 若依实体属性 用途
userId sys_user.user_id 用户唯一标识,用于查权限缓存
roles sys_role.role_key 角色标识符,匹配 @PreAuthorize 表达式
deptId sys_dept.dept_id 支持部门级消息广播过滤

数据同步机制

  • WebSocket Session 启动后,自动订阅当前用户所属部门的 Redis Channel(如 dept:102:msg
  • 所有服务端广播消息前,经 DeptPermissionFilter 校验接收者 deptId 是否在当前用户 deptPath 范围内
graph TD
    A[WebSocket Handshake] --> B[JwtWebSocketAuthInterceptor]
    B --> C{Token Valid?}
    C -->|Yes| D[绑定 userId/roles 到 attributes]
    C -->|No| E[拒绝连接 401]
    D --> F[StompSession 注入 SecurityContext]

4.4 监控可观测性增强:Prometheus指标埋点与Grafana看板配置

指标埋点实践

在业务服务中注入 promhttp 中间件,暴露标准 /metrics 端点:

import (
  "github.com/prometheus/client_golang/prometheus"
  "github.com/prometheus/client_golang/prometheus/promhttp"
)

var httpReqCounter = prometheus.NewCounterVec(
  prometheus.CounterOpts{
    Name: "http_requests_total",
    Help: "Total number of HTTP requests",
  },
  []string{"method", "endpoint", "status"},
)

func init() {
  prometheus.MustRegister(httpReqCounter)
}

逻辑分析:CounterVec 支持多维标签聚合;method/endpoint/status 三元组可支撑按路由与状态码下钻分析;MustRegister 自动注册至默认注册器,确保 promhttp.Handler() 可采集。

Grafana 配置要点

  • 添加 Prometheus 数据源(URL: http://prometheus:9090
  • 导入预置看板 ID 12345(含 QPS、延迟 P95、错误率热力图)
  • 关键查询示例:
    rate(http_requests_total{job="api-service"}[5m])

核心指标维度对照表

维度 标签示例 用途
method "GET", "POST" 区分请求类型负载特征
endpoint "/v1/users", "/health" 定位高耗时或异常路由
status "200", "500", "404" 实时识别服务健康态
graph TD
  A[业务代码埋点] --> B[Prometheus拉取/metrics]
  B --> C[TSDB存储时序数据]
  C --> D[Grafana查询渲染]
  D --> E[告警规则触发Alertmanager]

第五章:性能压测、线上验证与长期演进路径

压测环境与生产环境的精准对齐策略

在某千万级用户电商中台项目中,我们构建了“三镜像”压测环境:网络拓扑镜像(复刻SLB→Nginx→K8s Service→Pod的完整链路)、数据分布镜像(基于真实流量采样生成1:1热键分布+冷数据比例)、依赖服务镜像(通过GoReplay录制并回放Redis/MQ/MySQL协议流量)。关键发现:当压测中MySQL连接池耗尽时,并非数据库CPU瓶颈,而是应用层未配置maxIdleTime=30s导致连接泄漏——该问题在线上灰度阶段才暴露,促使我们将连接池健康检查纳入CI/CD流水线的准入门禁。

全链路压测中的影子库与流量染色实践

采用ShardingSphere-Proxy + 自研染色中间件实现无侵入流量标记。HTTP请求头注入X-Trace-ID: shadow-20240521-abc123,下游所有服务通过SPI扩展自动识别并路由至影子库表(如order_shadow_202405)。压测期间记录关键指标:

指标类型 正常流量P99 影子流量P99 差异率 根因分析
订单创建耗时 128ms 347ms +171% 影子库未开启Buffer Pool预热
库存扣减成功率 99.998% 99.982% -0.016% Redis Lua脚本存在竞争窗口

线上灰度验证的渐进式放量机制

设计四阶段放量模型:

  1. 1%流量+人工值守:仅开放订单查询接口,监控ELK中error_rate > 0.1%自动熔断;
  2. 5%流量+自动化巡检:调用Prometheus Alertmanager触发ChaosBlade故障注入(模拟ETCD集群延迟);
  3. 20%流量+业务核验:接入财务系统对账平台,比对影子支付流水与主库金额一致性;
  4. 100%流量+混沌演练:执行kubectl drain node --force --ignore-daemonsets强制驱逐节点,验证Service Mesh自动重试能力。

长期演进的技术债治理路线图

graph LR
A[当前架构:单体Spring Boot] --> B[2024 Q3:核心域拆分为3个Domain Service]
B --> C[2025 Q1:引入Wasm插件化网关,支持Lua规则热加载]
C --> D[2025 Q4:全链路迁移至eBPF可观测性栈,替换Sidecar]
D --> E[2026 Q2:AI驱动的容量预测引擎上线,基于LSTM训练12个月历史指标]

生产事故的反向驱动演进案例

2023年双十二凌晨出现支付回调超时,根因是RocketMQ消费者组Rebalance耗时达8.2秒。改进措施包括:将sessionTimeoutMs从30s调整为60s、增加Consumer端心跳探测线程、在K8s Deployment中添加readinessProbe检测Rebalance状态。该事件直接催生了《消息中间件SLA保障白皮书》,明确要求所有新接入MQ服务必须通过rebalance_max_latency_ms < 2000压测阈值。

多维度性能基线的持续沉淀机制

建立每季度更新的《性能基线档案》,包含:JVM GC日志模式(G1GC下-XX:MaxGCPauseMillis=200)、K8s HPA触发阈值(CPU>65%且持续300s)、数据库慢查阈值(MySQL long_query_time=1.0s)。最新基线已集成至GitOps流程,任何Pod资源请求变更需通过kubebench校验是否偏离基线±15%。

真实用户行为建模的压测数据生成

放弃传统RPS固定模型,基于埋点日志构建用户旅程图谱:使用Apache Flink实时解析APP端PageView事件,提取“首页→搜索→商品详情→加入购物车→下单”转化漏斗,生成符合泊松分布的并发模型。压测中发现搜索服务在“关键词联想”环节存在缓存穿透,紧急上线布隆过滤器后QPS承载能力从12,000提升至38,000。

演进路径中的组织能力建设

推行SRE工程师轮岗制:每位开发人员每季度需完成2次线上值班、撰写1份Postmortem报告、参与1次混沌工程演练设计。2024年已沉淀57个典型故障模式知识库条目,覆盖从DNS解析异常到GPU显存泄漏等场景,全部嵌入IDEA插件实现编码时实时提示。

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

发表回复

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