Posted in

【Go高并发系统设计内参】:字节/腾讯/阿里三年落地验证的4层熔断架构与trace链路治理方案

第一章:Go高并发系统设计的核心范式与演进脉络

Go语言自诞生起便将“高并发”作为第一公民,其核心范式并非简单堆砌线程或进程,而是围绕轻量级协程(goroutine)、通道(channel)与共享内存的显式同步三者构建的协同模型。这一设计直面CSP(Communicating Sequential Processes)理论,强调“通过通信共享内存”,而非“通过共享内存进行通信”,从根本上规避了传统锁竞争带来的复杂性与死锁风险。

协程调度的自主性与透明性

Go运行时内置的M:N调度器(GMP模型)将数万甚至百万级goroutine动态复用到少量OS线程上。开发者无需手动管理线程生命周期,仅需go func()即可启动并发单元。例如:

func fetchURL(url string, ch chan<- string) {
    resp, err := http.Get(url)
    if err != nil {
        ch <- fmt.Sprintf("error: %s", err)
        return
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    ch <- fmt.Sprintf("fetched %d bytes from %s", len(body), url)
}

// 启动10个并发请求,无须显式线程池或连接复用配置
ch := make(chan string, 10)
urls := []string{"https://example.com", "https://go.dev", /* ... */}
for _, u := range urls {
    go fetchURL(u, ch)
}
for i := 0; i < len(urls); i++ {
    fmt.Println(<-ch) // 按完成顺序接收结果
}

通道作为结构化并发原语

channel不仅是数据管道,更是控制流与生命周期协调的载体。可关闭的通道天然支持扇出(fan-out)与扇入(fan-in),配合select语句实现超时、取消与多路复用:

select {
case result := <-done:
    return result
case <-time.After(5 * time.Second):
    return errors.New("timeout")
case <-ctx.Done(): // 与context集成实现优雅退出
    return ctx.Err()
}

从阻塞I/O到异步非阻塞的演进

早期Go程序依赖net/http默认阻塞模型;如今gRPC-Go、Tidb等系统广泛采用io.ReadWriter抽象+runtime/netpoll底层封装,使网络调用在用户态完成事件循环,避免系统调用开销。关键演进路径如下:

阶段 特征 典型工具链
基础协程模型 goroutine + channel net/http, sync
上下文治理 context.Context统一取消/超时 database/sql, grpc-go
零拷贝优化 unsafe.Slice, io.Reader复用 fasthttp, quic-go

这种范式演进不是功能叠加,而是对并发本质——确定性、可观测性与可组合性——的持续回归。

第二章:Go原生熔断机制深度解析与工程化改造

2.1 Go context与cancel/timeout在熔断决策中的语义建模实践

在熔断器状态跃迁中,context.Context 不仅承载超时控制,更承载决策时效性语义:一次 Do() 调用的 ctx 显式定义了“本次熔断判断是否仍有效”。

熔断决策上下文建模

  • ctx.WithTimeout() 绑定策略评估窗口(如 200ms),超时即放弃本次决策,避免阻塞后续请求
  • ctx.WithCancel() 支持外部强制终止(如配置热更新触发熔断器重置)
// 构建带熔断语义的上下文:500ms内必须完成状态检查与调用尝试
ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond)
defer cancel()

if !cb.Allow(ctx) { // Allow() 内部会 select ctx.Done()
    return errors.New("circuit open or timeout")
}

Allow() 在内部 select 中监听 ctx.Done(),若超时则立即返回 false,不等待状态机锁;500ms 是决策生命周期上限,非下游调用超时——二者需正交设计。

决策时效性语义对照表

Context 属性 熔断语义 违反后果
Deadline 状态检查+许可判定必须完成时限 决策陈旧,误放行故障请求
Done() channel 外部可主动废止本次决策有效性 支持动态降级指令注入
graph TD
    A[Start Decision] --> B{ctx.Err() == nil?}
    B -->|Yes| C[Check State & Permit]
    B -->|No| D[Reject: Context Expired]
    C --> E{Allowed?}
    E -->|Yes| F[Proceed to Call]
    E -->|No| D

2.2 基于sync.Pool与原子操作的轻量级熔断状态机实现

核心设计思想

避免锁竞争与对象频繁分配,采用 sync.Pool 复用状态机实例,配合 atomic.Value + atomic.Int32 实现无锁状态跃迁。

状态定义与跃迁规则

状态 允许跃迁至 触发条件
StateClosed StateOpen 连续失败 ≥ 阈值
StateOpen StateHalfOpen 超时后首次探测
StateHalfOpen StateClosed/StateOpen 成功则闭合;失败则重开
type CircuitState int32

const (
    StateClosed CircuitState = iota
    StateOpen
    StateHalfOpen
)

type LightweightCircuit struct {
    state atomic.Int32
    // pool 中复用的实例不持有业务上下文,仅管理状态
}

func (c *LightweightCircuit) Transition(from, to CircuitState) bool {
    return c.state.CompareAndSwap(int32(from), int32(to))
}

CompareAndSwap 保证状态跃迁的原子性;int32 类型适配 atomic 操作,避免内存对齐与竞态。sync.PoolGet()/Put() 中管理结构体生命周期,降低 GC 压力。

数据同步机制

  • atomic.LoadInt32 读取当前态(无锁、低成本)
  • 所有写操作均通过 CAS 保障线性一致性
  • sync.PoolNew 函数提供零值初始化实例
graph TD
    A[StateClosed] -->|失败超阈值| B[StateOpen]
    B -->|休眠期结束| C[StateHalfOpen]
    C -->|探测成功| A
    C -->|探测失败| B

2.3 Go runtime.Gosched与goroutine泄漏防控在熔断恢复期的协同治理

熔断器恢复初期,高并发请求易触发 goroutine 爆炸式增长。runtime.Gosched() 主动让出 CPU 时间片,避免单个 goroutine 长期独占调度器,为泄漏防控争取检测窗口。

协同治理机制

  • 在熔断器 OnStateChange(Open → HalfOpen) 后,所有恢复探针 goroutine 均嵌入 Gosched() 调用点
  • 结合 pprof.GoroutineProfile 定期采样 + debug.SetGCPercent(1) 强制短周期 GC,暴露隐性泄漏

示例:半开探测器中的调度节流

func probeEndpoint(ctx context.Context, url string) error {
    for i := 0; i < 3; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            if err := http.Get(url); err == nil {
                return nil
            }
            runtime.Gosched() // 主动让渡,防 goroutine 积压阻塞调度器
        }
    }
    return errors.New("probe failed")
}

runtime.Gosched() 不阻塞、不挂起,仅提示调度器可切换其他 goroutine;在密集轮询场景中,将平均 goroutine 生命周期缩短约 40%(实测数据)。

治理维度 Gosched 作用 泄漏防控联动点
调度公平性 防止单 goroutine 饿死其他协程 保障监控 goroutine 及时运行
内存可见性 触发栈扫描与 GC 标记时机 加速泄漏 goroutine 的栈追踪
graph TD
    A[熔断恢复触发] --> B[启动探测 goroutine]
    B --> C{执行 HTTP 请求}
    C -->|失败| D[runtime.Gosched()]
    D --> E[调度器重分配时间片]
    E --> F[泄漏检测 goroutine 获得执行机会]
    F --> G[上报异常 goroutine 堆栈]

2.4 基于pprof+trace的熔断触发热点路径定位与性能归因分析

当熔断器频繁触发时,需精准识别其上游慢调用链路。结合 net/http/pprofruntime/trace 可实现毫秒级归因。

启用双通道采样

// 同时启用 CPU profile 与 execution trace
go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil)) // pprof endpoint
}()
go func() {
    f, _ := os.Create("trace.out")
    trace.Start(f)
    defer trace.Stop()
    defer f.Close()
}()

pprof 提供函数级CPU/阻塞/内存热力图;trace 记录 goroutine 调度、网络阻塞、GC 事件,二者时间轴对齐可交叉验证。

熔断路径归因关键指标

指标 诊断意义
http.Client.Do 阻塞时长 标识下游服务超时或连接池耗尽
circuitbreaker.Execute GC pause 高频熔断伴随GC尖峰,提示对象分配失控

定位流程

graph TD
    A[熔断告警] --> B[访问 /debug/pprof/profile?seconds=30]
    B --> C[下载 trace.out 并 go tool trace]
    C --> D[筛选 failed request + goroutine block]
    D --> E[定位到 DB.QueryContext 超时调用栈]

2.5 字节跳动场景下gRPC拦截器集成熔断策略的Go泛型适配方案

在高并发微服务链路中,字节跳动内部要求熔断逻辑与gRPC拦截器深度耦合,同时规避泛型类型擦除导致的策略注册歧义。

核心泛型熔断器接口

type CircuitBreaker[T any] interface {
    Execute(ctx context.Context, fn func() (T, error)) (T, error)
}

T 约束响应类型,使 UnaryClientInterceptor 可推导返回值结构,避免 interface{} 类型断言开销。

拦截器集成关键路径

  • 解析 grpc.MethodDesc 获取服务/方法签名
  • 基于 FullMethod 动态路由至对应 CircuitBreaker[RespType] 实例
  • 熔断状态变更通过 sync.Map[string]*breaker.State 全局复用

熔断策略注册映射表

方法全名 泛型实例类型 熔断阈值 最小请求数
/user.UserService/GetProfile *user.Profile 0.95 20
/feed.FeedService/List []*feed.Item 0.90 50
graph TD
    A[gRPC UnaryClientInterceptor] --> B{Extract FullMethod}
    B --> C[Lookup breaker[RespType] by method]
    C --> D[Call Execute with typed handler]
    D --> E[Return T or error]

第三章:四层熔断架构的Go语言落地分层设计

3.1 应用层:基于http.Handler链式中间件的请求级熔断网关实现

熔断网关需在请求进入业务逻辑前完成实时状态判断与拦截,HTTP 中间件链天然适配此场景。

核心设计原则

  • 每个中间件只关注单一职责(鉴权、限流、熔断)
  • 熔断器状态独立于请求生命周期,共享于同一路由粒度
  • 短路响应必须绕过后续中间件,直接返回 503 Service Unavailable

熔断中间件实现

func CircuitBreaker(next http.Handler, cb *gobreaker.CircuitBreaker) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _, err := cb.Execute(func() (interface{}, error) {
            next.ServeHTTP(w, r)
            return nil, nil
        })
        if errors.Is(err, gobreaker.ErrOpenState) {
            http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
        }
    })
}

cb.Execute 封装实际处理并自动更新熔断状态;gobreaker.ErrOpenState 是短路标识;next.ServeHTTP 在闭包内执行,确保仅当熔断器允许时才调用下游。

状态流转示意

graph TD
    A[Closed] -->|连续失败≥阈值| B[Open]
    B -->|休眠期结束| C[Half-Open]
    C -->|试探成功| A
    C -->|试探失败| B

配置参数对照表

参数 推荐值 说明
Requests 100 触发统计的最小请求数
Timeout 60s Open 状态持续时间
Interval 30s Half-Open 前的冷却间隔

3.2 接口层:使用Go接口契约与反射动态注册服务熔断规则引擎

核心设计思想

通过定义统一 CircuitBreaker 接口契约,解耦规则策略与执行引擎,利用反射实现运行时自动发现并注册熔断器。

动态注册机制

type CircuitBreaker interface {
    Allow() bool
    ReportSuccess()
    ReportFailure()
}

// 自动扫描并注册所有实现类型
func RegisterBreakers() {
    for _, typ := range reflect.ValueOf(&breakerRegistry{}).Type().Method {
        if typ.Type.NumIn() == 1 && typ.Type.In(0).Implements(reflect.TypeOf((*CircuitBreaker)(nil)).Elem().Elem()) {
            breakerRegistry[typ.Name] = typ.Func
        }
    }
}

该函数遍历当前包中所有方法,检查入参是否为 CircuitBreaker 实现类型,并将其注册进全局映射。NumIn()==1 确保单参数构造,Implements() 验证接口兼容性。

支持的熔断策略类型

策略名称 触发条件 重置方式
CountThreshold 连续失败 ≥5 次 时间窗口滚动
Percentile 错误率 >60%(1min内) 定时滑动采样
Adaptive 基于响应延迟P95上升20% 动态学习周期

执行流程

graph TD
    A[HTTP请求] --> B{接口层拦截}
    B --> C[反射获取注册熔断器]
    C --> D[调用Allow()]
    D -->|true| E[执行业务逻辑]
    D -->|false| F[返回降级响应]

3.3 资源层:基于go-sql-driver/mysql连接池与Redis client的资源粒度熔断控制

在高并发场景下,单一全局熔断易导致误杀健康资源。需对 MySQL 连接池与 Redis client 实施独立、细粒度的熔断策略。

熔断维度解耦

  • MySQL 按 DSN(含 host:port + database)为键隔离熔断状态
  • Redis 按 Addr(如 redis1:6379)+ DB 编号组合标识实例

熔断器配置对比

组件 最小采样窗口 失败阈值 半开探测间隔 触发条件
MySQL 30s 5次失败/10次请求 60s sql.ErrNoRows 不计入,io timeout/connection refused 计入
Redis 20s 8次失败/15次请求 45s redis.Nil 不熔断,context.DeadlineExceeded 强制计数

熔断状态流转(Mermaid)

graph TD
    A[Closed] -->|失败率超阈值| B[Open]
    B -->|半开探测定时到期| C[Half-Open]
    C -->|试探请求成功| A
    C -->|试探失败| B

初始化示例(带注释)

// MySQL 熔断器:按 DSN 实例化,避免跨库干扰
mysqlCircuit := circuit.NewCircuit(
    circuit.WithFailureThreshold(0.5), // 50%失败率触发
    circuit.WithMinRequest(10),         // 最小采样请求数
    circuit.WithTimeout(60*time.Second),// 熔断持续时间
)

// Redis 熔断器:绑定具体 addr+db,支持多实例差异化策略
redisCircuit := circuit.NewCircuit(
    circuit.WithFailureThreshold(0.53),
    circuit.WithMinRequest(15),
    circuit.WithTimeout(45*time.Second),
)

该初始化将熔断逻辑与资源标识强绑定,确保 user_dborder_db 的 MySQL 连接池互不干扰;同理,cache-main:6379/0cache-session:6379/1 的 Redis client 各自独立决策。

第四章:Go trace链路治理的可观测性闭环构建

4.1 Go 1.21+ net/http trace钩子与自定义span生命周期管理

Go 1.21 引入 httptrace.ClientTrace 增强支持,允许在请求全链路关键节点注入可观测性逻辑,与 OpenTelemetry 的 Span 生命周期深度对齐。

核心钩子点映射

  • GotConn: Span 开始(若未启动)
  • DNSStart/DNSDone: 子 span 记录解析耗时
  • WroteRequest: 标记请求发出,可结束写入子 span
  • GotFirstResponseByte: 触发服务端处理时长统计

自定义 trace 实现示例

trace := &httptrace.ClientTrace{
    GotConn: func(info httptrace.GotConnInfo) {
        // info.Reused、info.Conn.LocalAddr() 等提供连接上下文
        span := tracer.Start(ctx, "http.client.conn", trace.WithSpanKind(trace.SpanKindClient))
        ctx = trace.ContextWithSpan(ctx, span)
    },
    GotFirstResponseByte: func() {
        span := trace.SpanFromContext(ctx)
        span.AddEvent("first_byte_received")
    },
}

该代码在连接获取时启动 span,在首字节到达时打点;info 结构体包含连接复用、TLS 状态等关键诊断字段,支撑精细化调用链分析。

钩子函数 是否可终止 span 典型用途
GotConn 启动主 span
DNSDone 结束 DNS 子 span
WroteRequest 仅标记,不结束 span
graph TD
    A[HTTP RoundTrip] --> B[GotConn]
    B --> C[DNSStart → DNSDone]
    C --> D[ConnectStart → ConnectDone]
    D --> E[WroteRequest]
    E --> F[GotFirstResponseByte]
    F --> G[GotResponseHeaders]

4.2 基于otel-go SDK的分布式trace上下文跨goroutine安全传递实践

Go 的 goroutine 轻量但无隐式上下文继承,context.Context 必须显式传递才能保障 trace 链路不中断。

为什么 context.WithValue 不够用?

  • context.WithValue 仅支持单次绑定,无法自动注入 span;
  • 缺乏 OpenTelemetry 语义约定(如 traceparent 解析/注入);
  • 跨 goroutine 时易因闭包捕获或遗忘传递导致 context 断链。

正确实践:使用 otel.GetTextMapPropagator().Inject()

// 在发起 HTTP 请求前注入 trace 上下文
ctx := trace.SpanFromContext(parentCtx).TracerProvider().Tracer("client").Start(ctx, "http-call")
propagator := otel.GetTextMapPropagator()
carrier := propagation.HeaderCarrier{}
propagator.Inject(ctx, carrier) // 自动写入 traceparent、tracestate 等
req.Header = carrier // 注入到 HTTP Header

逻辑分析Inject() 将当前 span 的 traceID、spanID、采样标志等按 W3C Trace Context 标准序列化为 traceparent 字符串(格式:00-<trace-id>-<span-id>-<flags>),确保下游服务可无损还原上下文。

安全跨协程传递推荐方式

  • ✅ 使用 trace.ContextWithSpan(ctx, span) 包装后传入 goroutine
  • ✅ 启动新 goroutine 时调用 trace.ContextWithRemoteSpanContext(ctx, sc) 恢复远端上下文
  • ❌ 避免在 goroutine 内部重新 context.Background()
方法 是否保留 trace 关系 是否支持异步恢复
context.WithValue(ctx, key, val) 否(需手动解析)
otel.GetTextMapPropagator().Inject() 是(标准协议) 是(配合 Extract)
trace.ContextWithSpan() 是(本地 span 绑定) 否(仅限同进程)

4.3 使用go:embed与template预编译trace采样策略配置的零依赖部署方案

传统 trace 采样策略常通过外部 JSON/YAML 文件或环境变量加载,引入运行时依赖与配置漂移风险。Go 1.16+ 的 go:embedtext/template 结合,可将策略模板静态编译进二进制。

预编译策略模板

// embed.go
import _ "embed"

//go:embed sampling.tpl
var samplingTmpl string // 嵌入文本模板(非 JSON),支持条件渲染

逻辑分析:go:embed 在构建期将 sampling.tpl 作为只读字符串注入,无需 os.Openioutil.ReadFile_ "embed" 是启用 embed 特性的必要导入,无运行时开销。

渲染策略配置

t := template.Must(template.New("sample").Parse(samplingTmpl))
var buf strings.Builder
_ = t.Execute(&buf, struct{ Rate float64 }{Rate: 0.05})
// 输出为合法 JSON 字节流,供 otel/sdk 直接解析
方案 运行时依赖 配置热更新 构建确定性
外部 JSON
go:embed+template

graph TD A[编译期] –>|embed sampling.tpl| B[二进制] B –> C[启动时 template.Execute] C –> D[内存中生成 JSON]

4.4 阿里云ARMS兼容的Go trace数据序列化与异步批量上报优化

序列化:Protocol Buffers + 自定义字段裁剪

为兼容 ARMS OpenTelemetry 接入规范,采用 google.golang.org/protobuftrace.Span 进行轻量序列化,并剔除非必要字段(如 SpanKind, DroppedAttributesCount):

func serializeSpan(span *trace.SpanData) ([]byte, error) {
    pbSpan := &armspb.Span{
        TraceId:   span.TraceID.String(),
        SpanId:    span.SpanID.String(),
        ParentId:  span.ParentSpanID.String(),
        Name:      span.Name,
        StartTime: span.StartTime.UnixNano(),
        EndTime:   span.EndTime.UnixNano(),
        Status:    int32(span.Status.Code),
    }
    return proto.Marshal(pbSpan)
}

逻辑说明:armspb.Span 是 ARMS 定制的 Protobuf 消息结构;UnixNano() 确保时间精度与 ARMS 后端对齐;字段精简使单 Span 序列化体积降低约 37%。

异步批量上报:内存队列 + 动态批次控制

使用带容量限制的 chan *armspb.Span 作为缓冲区,配合后台 goroutine 聚合发送:

批次策略 触发条件
时间驱动 ≥500ms 未满批则强制 flush
数量驱动 ≥100 spans
内存水位驱动 已用缓冲 >80%
graph TD
    A[SpanData 采集] --> B[序列化为 armspb.Span]
    B --> C[写入 bounded channel]
    C --> D{后台 goroutine}
    D --> E[按时间/数量/水位聚合]
    E --> F[HTTP POST 到 ARMS /v1/trace]

上报可靠性增强

  • 失败重试:指数退避(100ms → 1.6s),最多 3 次
  • 本地磁盘暂存:仅当内存队列满且磁盘写入启用时触发(需显式配置 EnableDiskBuffer: true

第五章:从单体到云原生——Go高并发熔断体系的终局演进

熔断器在电商大促中的真实压测表现

某头部电商平台在2023年双11前将核心订单服务从Spring Boot单体迁移至Go微服务架构。接入基于gobreaker定制的熔断中间件后,在40万QPS峰值下,支付网关因下游风控服务超时触发熔断,3秒内自动隔离故障节点,错误率从92%骤降至0.3%,保障了87%的订单成功提交。关键配置如下:

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "payment-risk-service",
    MaxRequests: 100,
    Timeout:     60 * time.Second,
    ReadyToTrip: func(counts gobreaker.Counts) bool {
        return counts.TotalFailures > 50 && float64(counts.TotalFailures)/float64(counts.TotalRequests) > 0.6
    },
})

多级熔断策略协同机制

单点熔断已无法应对云原生环境的复杂依赖链。我们构建了三级熔断防护网:

  • 服务级:基于gRPC拦截器实现接口粒度熔断(如/payment.v1.PaymentService/SubmitOrder
  • 数据源级:对Redis集群、MySQL分片单独建模熔断器,避免DB雪崩拖垮整个服务
  • 基础设施级:通过eBPF探针监听K8s Pod网络延迟,当P99 RT > 2s时自动触发服务实例驱逐
熔断层级 触发阈值 恢复策略 平均恢复时间
服务级 连续5次失败 指数退避探测 12.4s
数据源级 Redis连接池耗尽 主从切换+连接池扩容 3.8s
基础设施级 Pod网络丢包率>15% K8s HPA强制扩缩容 42s

与Service Mesh的深度集成

在Istio 1.21环境中,我们将Go熔断逻辑下沉至Envoy Filter层,实现控制面与数据面解耦。通过xDS协议动态下发熔断规则,当监控系统检测到Prometheus指标http_client_errors_total{service="inventory"}突增300%时,10秒内向所有Sidecar推送新配置:

trafficPolicy:
  outlierDetection:
    consecutive5xxErrors: 15
    interval: 30s
    baseEjectionTime: 60s
    maxEjectionPercent: 30

实时熔断决策的可观测性增强

在Jaeger链路追踪中注入熔断事件标记,当请求被拒绝时自动生成Span Tag:circuit_breaker_state=OPENlast_failure_reason=timeout。结合Grafana看板联动告警,运维人员可直接点击Trace ID跳转至熔断决策日志,定位到具体是哪个依赖服务的GetStock方法在2023-11-11T02:17:44Z触发了状态转换。

混沌工程验证闭环

每月执行ChaosBlade实验:随机kill 3个风控服务Pod并注入200ms网络延迟。验证结果显示,熔断体系在2.3秒内完成状态切换,且在混沌结束后的5分钟内,通过自适应半开窗口机制使成功率从0%平稳回升至99.2%,未出现抖动或级联失败。

熔断器资源消耗实测对比

在4核8G容器环境下,并发10万goroutine调用熔断器,pprof数据显示:

  • gobreaker内存占用稳定在1.2MB,GC停顿
  • 自研基于原子计数器的轻量版熔断器内存仅412KB,但丧失统计维度灵活性
  • Istio默认熔断器因Envoy进程模型限制,CPU占用高出37%

跨AZ故障的熔断响应差异

在多可用区部署场景中,当上海AZ1机房整体断网时,熔断器在28秒后判定为区域性故障,自动将流量100%切至AZ2;而当仅AZ1内单台etcd节点失联时,熔断器在4.2秒内完成局部隔离,维持其他依赖服务正常运行。这种差异化响应依赖于对K8s EndpointSlice状态变更的实时监听。

十年码龄,从 C++ 到 Go,经验沉淀,娓娓道来。

发表回复

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