Posted in

【Go云原生基建基石】:基于`golang.org/x/net/http2`定制gRPC流控中间件,实现毫秒级熔断与动态权重路由(K8s Ingress已接入)

第一章:Go云原生基建基石的底层本质

Go 语言并非为云原生而生,却成为其事实上的基础设施语言——这一现象根植于其运行时、内存模型与并发范式的深层设计契约。理解其底层本质,关键在于穿透语法糖,直抵三个不可替代的内核:轻量级 Goroutine 的协作式调度、基于逃逸分析的栈自动伸缩机制,以及无侵入式的接口实现所支撑的“组合优于继承”哲学。

Goroutine 与 M:P:G 调度模型

Go 运行时通过 M(OS 线程)、P(逻辑处理器) 和 G(Goroutine) 的三层抽象实现高密度并发。当执行 go http.ListenAndServe(":8080", nil) 时,运行时会将该 Goroutine 绑定至当前 P 的本地队列;若遇阻塞系统调用(如网络 I/O),M 会主动解绑 P 并让出线程,由其他 M 接管空闲 P 继续调度其余 G——整个过程无需操作系统介入上下文切换,开销仅为纳秒级。

内存分配的静默契约

Go 编译器在编译期执行逃逸分析,决定变量分配在栈还是堆。例如:

func NewHandler() *http.ServeMux {
    mux := http.NewServeMux() // 此处 mux 必然逃逸至堆:函数返回其指针
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "OK") // 闭包捕获 w/r,导致其生命周期超出栈帧
    })
    return mux
}

该设计消除了手动内存管理负担,同时避免了 C++/Rust 中常见的生命周期注解复杂性,天然适配短生命周期、高创建频率的云服务请求模型。

接口即契约:零成本抽象

io.Reader、context.Context 等核心接口不依赖继承或泛型(Go 1.18 前),仅通过方法签名定义行为契约。任意类型只要实现 Read([]byte) (int, error) 即自动满足 io.Reader——这种隐式实现使中间件、装饰器、Mock 测试等云原生常见模式可无侵入组装:

场景 实现方式
请求日志中间件 包装 http.ResponseWriter 实现 Write() 计时
上下文超时传播 context.WithTimeout(parent, 5*time.Second)
配置热加载 实现 config.Provider 接口,替换运行时实例

这种设计使 Go 的标准库与生态组件形成松耦合、高复用的协议层,构成云原生基建最稳固的语义基座。

第二章:HTTP/2协议深度解构与gRPC流控原语剖析

2.1 HTTP/2帧结构解析与gRPC消息序列化映射关系

gRPC 基于 HTTP/2 实现多路复用与低延迟通信,其核心在于将 Protocol Buffer 序列化后的二进制数据精准嵌入 HTTP/2 帧流。

帧类型与语义分工

  • HEADERS 帧携带 gRPC 状态码、content-type: application/grpc 及自定义 metadata;
  • DATA 帧承载序列化 payload,首字节为压缩标志(0x00 或 0x01),后接 4 字节大端 length-prefix;
  • RST_STREAM 用于异常中止,对应 gRPC 错误码(如 CANCELLED → 0x8)。

DATA 帧负载结构(含 length-prefix)

// 示例:gRPC Unary RPC 的 DATA 帧有效载荷
0x00          // compression flag (no compression)
0x00 0x00 0x00 0x0a  // message length = 10 bytes
0x0a 0x08 0x68 0x65 0x6c 0x6c 0x6f 0x77 0x6f 0x72  // serialized proto: "helloworld"

逻辑分析:length-prefix 保障流式解包边界;0x00 表示未启用压缩(默认),若为 0x01 则后续数据需经 grpc-encoding: gzip 解压。Protocol Buffer 的二进制紧凑性与帧对齐共同支撑高吞吐。

帧类型 gRPC 作用 是否可分片
HEADERS 传输 method、timeout、metadata 否
DATA 承载序列化 request/response 是
CONTINUATION 补充长 HEADERS 否
graph TD
    A[Client gRPC Call] --> B[Proto Serialize]
    B --> C[Add Length-Prefix + Compression Flag]
    C --> D[Split into DATA Frames]
    D --> E[Interleave with HEADERS on same Stream ID]
    E --> F[HTTP/2 Transport]

2.2 golang.org/x/net/http2源码级调试:ServerConn与ClientConn状态机实践

HTTP/2 连接的核心是双向状态机:*http2.ServerConn 处理入站帧流,*http2.ClientConn 管理出站请求与流复用。

状态跃迁关键点

  • stateIdle → stateOpen:首帧(SETTINGS)接收/发送后触发
  • stateOpen → stateClosed:收到 GOAWAY 或连接中断
  • 流级别状态(stream.state)独立于连接状态,支持并发多路复用

调试技巧

  • 在 http2/frame.go:ReadFrame() 设置断点,观察帧解析路径
  • 打印 sc.state 和 cc.tconn.LocalAddr() 辅助定位连接生命周期
// 调试 ServerConn 状态变更入口(server.go)
func (sc *serverConn) setState(state http2state) {
    sc.logf("conn state %v → %v", sc.state, state) // 日志含时间戳与 goroutine ID
    sc.state = state
}

该函数被 processHeaderBlockFragment、writeSettings 等多处调用,参数 state 为 http2state 枚举值(如 stateHalfClosedLocal),直接反映连接层协议阶段。

状态值 触发条件 是否可重入
stateActive 首个 HEADERS 帧成功处理 否
stateClosed net.Conn.Close() 完成 是
graph TD
    A[stateIdle] -->|SETTINGS received| B[stateOpen]
    B -->|GOAWAY sent| C[stateClosed]
    B -->|PING timeout| C

2.3 流控窗口动态计算模型:基于RTT与Buffer Occupancy的毫秒级反馈闭环

该模型以实时网络状态为输入,每5ms执行一次窗口更新,形成紧耦合反馈闭环。

核心计算公式

窗口值 $ w{t} = \max\left( w{\min},\ \alpha \cdot \frac{B_{\text{avail}}}{\text{RTT}_t} + \beta \cdot (1 – \rho_t) \right) $,其中:

  • $ B_{\text{avail}} $:接收端空闲缓冲区字节数
  • $ \rho_t $:发送端队列填充率(0.0–1.0)
  • $ \alpha=128,\ \beta=64 $:经验调优系数

实时更新逻辑(伪代码)

def update_window(rtt_ms: float, buf_used_pct: float, buf_avail_bytes: int) -> int:
    # rtt_ms: 最近采样RTT(毫秒),buf_used_pct ∈ [0.0, 1.0]
    base_rate = buf_avail_bytes / max(rtt_ms, 1.0)  # 防除零
    occupancy_penalty = (1.0 - buf_used_pct) * 64
    new_win = int(128 * base_rate + occupancy_penalty)
    return max(32, min(65535, new_win))  # 硬限界

逻辑分析:base_rate 表征带宽-延迟乘积潜力;occupancy_penalty 抑制发送端拥塞恶化。max/min 保障协议鲁棒性。

关键参数对照表

参数 典型范围 作用
RTT采样周期 1–5 ms 决定反馈时效性
Buffer Occupancy 更新延迟 依赖硬件DMA+ring buffer零拷贝

闭环流程示意

graph TD
    A[RTT采样] --> B[Buffer Occupancy读取]
    B --> C[窗口计算]
    C --> D[TCP窗口通告]
    D --> A

2.4 自定义Transport RoundTripper注入点:拦截、观测与重写gRPC流生命周期

gRPC 的底层 HTTP/2 通信由 http.RoundTripper 驱动,而 grpc-go 通过 transport.Creds 和 http.Transport 的组合暴露了关键注入点。

拦截入口:WrapRoundTripper

type observabilityRoundTripper struct {
    base http.RoundTripper
}

func (r *observabilityRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    // 注入请求头(如 trace-id)、记录发起时间、校验 gRPC-encoding
    start := time.Now()
    resp, err := r.base.RoundTrip(req)
    log.Printf("gRPC call %s → %v in %v", req.URL.Path, err, time.Since(start))
    return resp, err
}

该实现劫持所有 gRPC over HTTP/2 请求;req.URL.Path 即 /package.Service/Method,是流生命周期的起点标识。

流生命周期可观测维度

阶段 可捕获信号 用途
Init req.Header.Get("grpc-encoding") 判断压缩策略
Headers resp.Header.Get("grpc-status") 快速失败判定
Trailers resp.Trailer 获取最终状态与自定义元数据

流重写能力边界

  • ✅ 可修改 req.Header(影响服务端 metadata.MD)
  • ❌ 不可篡改 resp.Body(HTTP/2 DATA 帧已加密分帧)
  • ⚠️ Trailer 修改需在 resp.Body.Close() 后生效(依赖 net/http 内部状态机)
graph TD
    A[Client RPC Call] --> B[RoundTrip Init]
    B --> C{Header Injection?}
    C -->|Yes| D[Add trace-id, auth-token]
    C -->|No| E[Pass-through]
    D --> F[HTTP/2 Stream Open]

2.5 压力测试验证:wrk+grpcurl构建混沌流控场景并量化P99延迟抖动

为精准刻画服务在流控阈值附近的响应韧性,我们组合 wrk(HTTP/HTTPS)与 grpcurl(gRPC)双通道注入阶梯式流量,并注入随机限流熔断事件。

混沌注入脚本示例

# 启动带随机5%请求失败的流控干扰(模拟Sentinel规则抖动)
kubectl exec -n istio-system deploy/istio-ingressgateway \
  -- sh -c "echo 'chaos: {fail_rate: 0.05}' > /tmp/chaos.yaml"

该命令动态修改网关侧混沌配置,触发Envoy层随机拦截,复现真实流控抖动源。

P99延迟对比(1000 QPS下)

场景 P50 (ms) P99 (ms) P99抖动 Δ
无流控 12 48 —
固定QPS限流 15 137 +89 ms
混沌流控(含抖动) 18 214 +166 ms

流量编排逻辑

graph TD
    A[wrk生成HTTP压测流] --> B{是否触发流控?}
    B -->|是| C[grpcurl调用/v1/breaker/reset]
    B -->|否| D[持续采集latency metrics]
    C --> E[Prometheus抓取P99_histogram_quantile]

核心指标通过 rate(http_request_duration_seconds_bucket[5m]) 聚合,确保抖动捕获具备时间窗口鲁棒性。

第三章:毫秒级熔断器设计与实现

3.1 滑动时间窗+指数退避熔断策略:超越Hystrix的Go原生并发安全实现

传统熔断器(如 Hystrix)依赖固定窗口与全局锁,难以应对高并发下的精度与性能双重挑战。本方案采用无锁滑动时间窗结合动态指数退避恢复机制,完全基于 sync/atomic 与 time.Ticker 实现。

核心数据结构

type CircuitBreaker struct {
    window     *slidingWindow // 原子计数的环形缓冲区(1s粒度,60槽)
    state      uint32         // atomic: 0=Closed, 1=Open, 2=HalfOpen
    backoffExp uint64         // 当前退避指数(用于计算下次尝试间隔)
}

逻辑分析:slidingWindow 使用 []uint64 + 原子偏移索引,避免互斥锁;backoffExp 初始为0,每次半开失败则 backoffExp++,恢复时间 = min(60s, 1 << backoffExp) 秒。

状态跃迁规则

当前状态 触发条件 下一状态 行为
Closed 错误率 ≥50%(10s滑窗) Open 立即拒绝,启动退避计时器
Open 退避期满 HalfOpen 允许单请求探活
HalfOpen 探活成功 Closed 重置退避指数为0

熔断决策流程

graph TD
    A[请求进入] --> B{当前状态?}
    B -->|Closed| C[记录成功/失败 → 更新滑窗]
    B -->|Open| D[直接返回 ErrCircuitOpen]
    B -->|HalfOpen| E[原子CAS抢占探活权]
    E -->|抢占成功| F[执行请求 → 成功→Closed / 失败→Open+backoffExp++]

3.2 基于atomic.Value与sync.Pool的零GC熔断状态管理

传统熔断器常将状态(如 State、counters)封装在结构体中,频繁更新触发堆分配与 GC 压力。零GC设计核心在于:状态复用 + 无锁读写分离。

数据同步机制

  • atomic.Value 安全承载不可变状态快照(如 *circuitState)
  • sync.Pool 复用计数器对象(避免 new(counter) 分配)
var statePool = sync.Pool{
    New: func() interface{} { return new(circuitState) },
}

// 读取:原子加载不可变快照
func (c *CircuitBreaker) getState() *circuitState {
    return c.state.Load().(*circuitState)
}

// 写入:从池获取新实例 → 修改 → 原子替换
func (c *CircuitBreaker) updateState(newState *circuitState) {
    c.state.Store(newState)
}

逻辑分析:atomic.Value 仅支持 Store/Load 接口,要求传入值为指针或不可变结构;sync.Pool 避免每秒数千次 counter{} 分配,实测 GC pause 降低 92%。

状态流转对比

方式 分配频次(QPS=10k) GC 次数/分钟 状态一致性
堆分配结构体 ~10,000 8–12 依赖 mutex
atomic.Value + Pool 0(复用) 0 乐观快照一致
graph TD
    A[请求进入] --> B{atomic.Load<br>获取当前快照}
    B --> C[判断是否熔断]
    C -->|是| D[直接返回失败]
    C -->|否| E[执行业务]
    E --> F[成功/失败?]
    F -->|成功| G[pool.Put 旧计数器]
    F -->|失败| H[pool.Put 旧计数器]
    G & H --> I[pool.Get 新计数器<br>更新统计]
    I --> J[atomic.Store 新快照]

3.3 熔断决策实时可视化:Prometheus指标暴露与Grafana动态阈值联动

熔断状态需脱离日志排查,直连指标驱动。服务需主动暴露 circuit_breaker_state{service="order",state="open|half_open|closed"} 与 circuit_breaker_failure_rate{service="order"} 等核心指标。

指标暴露(Spring Boot Actuator + Micrometer)

@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
    return registry -> registry.config()
        .commonTag("app", "order-service")
        .commonTag("env", System.getProperty("spring.profiles.active", "prod"));
}

该配置为所有熔断指标注入统一维度标签,确保Grafana多环境筛选一致性;state 标签值为枚举字符串,便于Grafana变量下拉联动。

Grafana动态阈值联动机制

变量名 类型 查询语句 用途
$service Query label_values(circuit_breaker_state, service) 服务维度切换
$threshold Custom 0.5, 0.7, 0.9 故障率阈值预设
graph TD
    A[Resilience4j 熔断器] -->|上报状态/失败率| B[Prometheus Scraping]
    B --> C[metric: circuit_breaker_failure_rate]
    C --> D[Grafana Panel]
    D --> E{告警阈值 = $threshold}
    E -->|动态绑定| F[Threshold line overlay]

第四章:动态权重路由引擎与K8s Ingress集成

4.1 权重路由抽象层:从xDS v3 ClusterLoadAssignment到Go结构体映射

xDS v3 的 ClusterLoadAssignment 是权重路由的核心载体,其 endpoints 字段通过 LocalityLbEndpoints 定义带权重的本地化端点集合。

数据同步机制

Envoy 通过 ADS 增量推送 ClusterLoadAssignment,Go 控制平面需精准映射为内存结构:

type ClusterLoadAssignment struct {
    ClusterName string                     `json:"cluster_name"`
    Endpoints   []LocalityLbEndpoints      `json:"endpoints"`
}

type LocalityLbEndpoints struct {
    Locality    *core.Locality             `json:"locality"`
    LbEndpoints []endpoint.LbEndpoint      `json:"lb_endpoints"`
    LoadBalancingWeight *wrappers.UInt32Value `json:"load_balancing_weight,omitempty"`
}

LoadBalancingWeight 是可选字段,默认为1;若缺失,应显式设为1以保证加权一致性。Locality 字段用于多区域流量调度,驱动 locality-aware 负载均衡策略。

映射关键约束

  • 权重总和非归一化(Envoy 自动归一化处理)
  • 同一 locality 内多个 endpoint 权重可叠加
字段 类型 说明
cluster_name string 必填,关联 CDS 中的 cluster
load_balancing_weight uint32 非负整数,0 表示剔除该 locality
graph TD
    A[xDS v3 CLA] --> B[JSON 解析]
    B --> C[Go 结构体验证]
    C --> D[权重归一化预检]
    D --> E[注入 Envoy xDS Cache]

4.2 实时权重热更新机制:etcd Watch + atomic.LoadUint64无锁路由表切换

数据同步机制

利用 etcd 的 Watch 接口监听 /routing/weights 路径变更,事件触发后解析 JSON 权重配置并生成新版本路由表。

watchCh := client.Watch(ctx, "/routing/weights")
for wresp := range watchCh {
    if wresp.Events != nil {
        newTable := parseWeights(wresp.Events[0].Kv.Value) // 解析权重数组
        atomic.StoreUint64(&globalVersion, uint64(newTable.version)) // 原子更新版本号
        globalTable.Store(newTable) // unsafe.Pointer 写入新表指针
    }
}

globalVersion 为全局单调递增版本号;globalTable 是 *sync.Map 或 atomic.Value,避免写时加锁。parseWeights 确保输入合法,失败则跳过本次更新。

无锁读取路径

请求路由时仅执行两次原子读取:

操作 说明
v := atomic.LoadUint64(&globalVersion) 获取当前生效版本
table := globalTable.Load().(*RoutingTable) 读取对应版本的只读表
graph TD
    A[Client 请求] --> B{atomic.LoadUint64<br>获取版本号}
    B --> C[atomic.Load<br>获取路由表指针]
    C --> D[查表路由<br>零拷贝访问]

该机制实现毫秒级配置生效,且读路径 100% 无锁、无内存分配。

4.3 K8s Ingress Controller扩展:自定义Annotation驱动gRPC路由权重下发

为支持gRPC流量的灰度发布,需在Ingress资源中注入可被Ingress Controller识别的语义化控制指令。

自定义Annotation设计

支持以下关键注解:

  • grpc.ingress.k8s.io/weight: 指定后端Service权重(0–100整数)
  • grpc.ingress.k8s.io/backend-selector: 标签选择器匹配gRPC子服务

配置示例

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    grpc.ingress.k8s.io/weight: "70"          # 70%流量导向当前Ingress关联Service
    grpc.ingress.k8s.io/backend-selector: "version=v2"
spec:
  rules:
  - http:
      paths:
      - path: /helloworld.Greeter/
        pathType: Prefix
        backend:
          service:
            name: greeter-svc
            port:
              number: 80

逻辑分析:Ingress Controller监听Ingress变更事件,解析grpc.ingress.k8s.io/*注解,将weight值转换为xDS RouteConfiguration中的weighted_clusters配置项;backend-selector用于动态匹配Pod标签,实现细粒度gRPC服务发现。

权重下发流程

graph TD
  A[Ingress资源更新] --> B{Controller监听}
  B --> C[解析grpc.* annotation]
  C --> D[生成gRPC-aware RDS]
  D --> E[推送至Envoy xDS]
注解键 类型 必填 说明
grpc.ingress.k8s.io/weight integer 是 权重总和需为100(多Ingress协同时)
grpc.ingress.k8s.io/backend-selector string 否 支持标准label selector语法

4.4 多集群灰度路由实践:基于Service Mesh Sidecar透明劫持与Header染色分流

在跨集群灰度发布中,Istio通过Envoy Sidecar自动劫持Pod流量,无需修改业务代码即可实现请求级路由控制。

流量染色与识别机制

业务服务在入口网关注入x-canary-version: v2 Header,Sidecar依据该字段匹配VirtualService规则:

# virtualservice-canary.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: product-service
spec:
  hosts: ["product.default.svc.cluster.local"]
  http:
  - match:
    - headers:
        x-canary-version:
          exact: "v2"  # 精确匹配灰度标识
    route:
    - destination:
        host: product.default.svc.cluster.local
        subset: v2  # 指向v2子集(跨集群Endpoint)

逻辑分析:x-canary-version作为轻量染色载体,由前端/网关统一注入;subset: v2关联DestinationRule中定义的跨集群服务端点,实现流量导向远端集群的v2实例。exact匹配确保语义严谨,避免模糊路由。

跨集群服务发现拓扑

集群名 控制平面角色 是否启用East-West Gateway
cluster-a 主控 是
cluster-b 成员 是

请求路由流程

graph TD
  A[客户端请求] --> B[Ingress Gateway注入x-canary-version]
  B --> C[Sidecar透明劫持]
  C --> D{Header匹配VirtualService?}
  D -->|是| E[路由至目标集群v2 Endpoint]
  D -->|否| F[默认路由至cluster-a v1]

第五章:生产落地经验与演进思考

灰度发布机制的三次迭代演进

在支撑日均 2000 万次 API 调用量的订单履约系统中,我们经历了三阶段灰度策略升级:第一阶段仅按机器 IP 白名单控制;第二阶段引入基于 HTTP Header 中 x-canary-version 的路由标签,并与 Nacos 配置中心联动实现秒级生效;第三阶段融合 OpenTelemetry 上报的业务成功率、P95 延迟指标,自动触发灰度比例升降(如成功率

数据库连接池泄漏的根因定位实践

某次大促前压测中,应用节点持续出现 Connection reset by peer 报错。通过 Arthas 执行 watch com.zaxxer.hikari.HikariDataSource getConnection -n 5 '{params, throwExp}' 捕获异常堆栈,结合 Prometheus 中 hikari_connections_active 与 hikari_connections_idle 指标对比,最终定位到 MyBatis 动态 SQL 中一处未关闭的 SqlSession 引用。修复后连接复用率从 42% 提升至 99.1%。

多云环境下的配置一致性保障方案

环境类型 配置源系统 同步方式 校验机制 平均同步延迟
生产阿里云 Apollo Webhook + Sidecar 注入 SHA256 文件哈希比对 2.3s
生产 AWS Consul KV 自研 ConfigSyncer(CRD 控制器) etcd watch event 回溯校验 4.7s
灾备集群 GitOps(Helm Chart Values) FluxCD v2 GitRepository + Kustomization JSON Schema 静态校验 + 运行时 configmap diff ≤15s

服务网格化迁移中的性能权衡

将核心交易链路接入 Istio 1.18 后,观测到平均 RT 增加 12ms。通过 istioctl analyze --use-kubeconfig 发现大量未启用 mTLS 的 service 导致双向 TLS 握手失败重试;进一步使用 kubectl get sidecar -n default -o yaml 审计发现 37% 的 Pod 缺失 egress 规则导致 DNS 请求绕行。优化后开启 STRICT mTLS 模式并补充出口网关策略,CPU 使用率下降 21%,但内存占用上升 14%——这促使团队建立 mesh 资源成本看板,纳入容量规划基线。

graph LR
A[新功能代码提交] --> B{CI 流水线}
B --> C[静态扫描+单元测试]
B --> D[生成镜像并推送到 Harbor]
C --> E[准入检查:SonarQube 覆盖率≥80%]
D --> F[部署至预发集群]
E --> F
F --> G[自动化契约测试<br/>(Pact Broker 验证)]
G --> H[流量染色注入<br/>(Header x-env: staging)]
H --> I[监控告警基线比对<br/>(Prometheus Alertmanager)]
I --> J[人工验收确认]
J --> K[自动合并至 release 分支]

日志采集链路的可靠性加固

原 Logstash Agent 在容器重启时丢失约 1.2% 的 error 日志。切换为 Filebeat + Kafka + Logstash 架构后,通过启用 at_least_once: true 和 bulk_max_size: 2048 参数,并在 Kafka Topic 设置 min.insync.replicas=2,配合 Filebeat 的 registry 文件持久化机制,实现端到端投递成功率 99.9993%。同时将日志字段 trace_id 与 span_id 提取为 Loki 的 labels,使分布式追踪查询响应时间从 8.4s 降至 1.2s。

故障自愈脚本的边界条件验证

针对 Redis 主从切换后应用连接抖动问题,编写 Python 自愈脚本监听 Sentinel 事件。初期脚本在跨机房网络分区场景下误判主节点状态,导致反复执行 redis-cli -h $slave_ip slaveof no one。后续增加三重校验:① 检查本地 sentinel monitor 列表中 master 地址是否变更;② 调用 redis-cli -h $master_ip info replication 确认 role 字段为 master;③ 对比 sentinel ckquorum mymaster 返回值是否 ≥ quorum 值。该脚本已在 12 个生产集群稳定运行 217 天,成功拦截 43 次潜在脑裂风险。

分享 Go 开发中的日常技巧与实用小工具。

发表回复

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