Posted in

为什么你的Go微服务CPU永远>90%?真相:默认GOMAXPROCS=0在K8s中已失效(附v1.21+动态适配方案)

第一章:Go微服务CPU高负载的根源诊断

高CPU使用率在Go微服务中往往不是单纯由业务量激增引起,而是隐藏着典型的运行时或代码层面问题。诊断需从操作系统层、Go运行时层和应用逻辑层逐级下探,避免过早假设。

系统级初步筛查

首先确认是否为真实CPU瓶颈而非I/O等待伪高负载(top 中 us 与 sy 占比需重点关注):

# 观察各线程CPU占用(-H 显示线程,-p 指定PID)
top -H -p $(pgrep -f "my-service")
# 同时检查是否有大量goroutine阻塞在系统调用
cat /proc/$(pgrep -f "my-service")/stack | grep "sys_"

Go运行时热点分析

启用pprof实时采集CPU profile,持续30秒以捕获典型负载模式:

# 在服务HTTP端口已暴露pprof(默认/debug/pprof)前提下执行
curl -o cpu.pprof "http://localhost:8080/debug/pprof/profile?seconds=30"
go tool pprof cpu.pprof
# 进入交互式终端后输入:top10 — 查看耗时最长的10个函数

重点关注 runtime.mcall、runtime.gopark 高频出现,可能暗示goroutine调度异常;若 syscall.Syscall 或 net.(*pollDesc).waitRead 占比过高,则需排查网络阻塞或未设超时的IO操作。

常见根因对照表

现象特征 典型原因 验证方式
runtime.findrunnable 占比突增 Goroutine泄漏或channel无缓冲导致调度器忙等 go tool pprof http://.../debug/pprof/goroutine?debug=2 查看活跃goroutine数
strconv.ParseFloat 耗时异常 JSON反序列化中高频字符串转浮点(如日志字段) 检查结构体tag是否误用string类型解析数字字段
crypto/sha256.blockAvx2 持续占用 JWT签名验证或文件哈希在请求路径中同步执行 使用go tool trace观察goroutine阻塞在加密调用栈

内存分配引发的间接CPU压力

高频小对象分配会加剧GC压力,导致STW阶段集中消耗CPU。通过-gcflags="-m"编译检查逃逸分析:

go build -gcflags="-m -m" main.go 2>&1 | grep "moved to heap"

若关键循环内对象持续逃逸至堆,应改用对象池(sync.Pool)复用或重构为栈分配结构。

第二章:GOMAXPROCS机制深度解析与K8s环境失效归因

2.1 GOMAXPROCS=0的历史语义与调度器演进路径

早期 Go 1.0–1.4 中,GOMAXPROCS=0 表示“重置为启动时 CPU 数”,但该值在运行时不可变,导致动态伸缩能力缺失。

调度器关键转折点

  • Go 1.5:引入 M:N 调度器,GOMAXPROCS 可运行时修改, 语义仍为“恢复初始值”
  • Go 1.9:runtime.GOMAXPROCS(0) 明确定义为 只读查询,返回当前值(不再重置)
package main
import "runtime"
func main() {
    runtime.GOMAXPROCS(0) // 返回当前有效 P 数,无副作用
}

此调用不修改调度器状态,仅用于观测;参数 是约定哨兵值,非索引或占位符。

演进对比表

Go 版本 GOMAXPROCS=0 行为 可变性
启动时快照,不可运行时改 ❌
≥1.5 重置为初始 CPU 数 ✅
≥1.9 纯查询,返回当前 P 数 ✅(只读)
graph TD
    A[Go 1.0] -->|固定初始化| B[Go 1.5 M:N 调度器]
    B -->|支持 runtime.SetMaxProcs| C[Go 1.9 语义收敛]
    C --> D[GOMAXPROCS=0 ⇒ 查询接口]

2.2 Kubernetes v1.21+ Cgroups v2与容器CPU限制的底层交互实测

Kubernetes v1.21 起默认启用 Cgroups v2(需内核 ≥4.15 且 systemd 启用 unified hierarchy),彻底改变 CPU 资源隔离语义。

Cgroups v2 CPU 控制器关键路径

# 查看 Pod 容器在 cgroup v2 下的真实 CPU 配置(以 runtime 为 containerd 为例)
cat /sys/fs/cgroup/kubepods/pod<uid>/container<hash>/cpu.max
# 输出示例:120000 100000 → 表示 120ms/100ms 周期,即 1.2 CPU 核心配额

该值由 kubelet 根据 resources.limits.cpu: "1200m" 自动换算(1200m = 1.2 → ceil(1.2 × 100000) = 120000),周期固定为 100000μs。

CPU 配额映射关系

K8s limits.cpu cgroups v2 cpu.max (quota/per) 实际调度权重
500m 50000 100000 ~0.5 核保障
2 200000 100000 硬上限 2 核

调度行为差异示意

graph TD
    A[Pod 设置 limits.cpu=1] --> B[cgroups v2: cpu.max = “100000 100000”]
    B --> C{超配时}
    C --> D[被 cfs_bandwidth_timer 强制 throttled]
    C --> E[不抢占其他 cgroup 的空闲周期]
  • Cgroups v1 使用 cpu.shares(相对权重)+ cpu.cfs_quota_us(硬限),易受邻居干扰;
  • Cgroups v2 统一为 cpu.max,配额不可借、不可贷,隔离性更强。

2.3 runtime.GOMAXPROCS()在Pod CPU request/limit不一致场景下的行为反模式

当 Kubernetes Pod 的 cpu request=500m 而 cpu limit=2000m 时,容器运行时(如 containerd)通常按 limit 设置 runtime.GOMAXPROCS() ——但 Go 运行时仅在启动时读取 GOMAXPROCS 环境变量或调用 runtime.GOMAXPROCS(n),且不会动态响应 cgroups CPU quota 变化。

GOMAXPROCS 与 cgroups 的错配根源

  • Go 程序启动后,GOMAXPROCS 固定为 limit / 1000 = 2(假设 limit=2000m)
  • 实际可用 CPU 时间受 request=500m 约束(即平均仅 0.5 核),导致:
    • P 数量过剩 → 频繁 OS 线程调度开销
    • GC 停顿加剧(并行标记线程数超物理并发能力)

典型误用代码

func main() {
    // ❌ 错误:假设 runtime 会自适应 cgroups
    fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0))
    // 输出恒为 2,即使当前被 throttled 到 0.5 核
}

逻辑分析:runtime.GOMAXPROCS(0) 仅返回当前值,不感知 cpu.shares 或 cpu.cfs_quota_us/cpu.cfs_period_us。参数 无重载语义,仅作查询用途。

推荐实践对照表

场景 request=limit request 建议
GOMAXPROCS 设置依据 使用 limit(稳定) 应基于 request(防过载) 显式设置 GOMAXPROCS=$(echo $CPU_REQUEST_MILLICORES/1000 \| bc)
graph TD
    A[Pod 启动] --> B{request == limit?}
    B -->|Yes| C[GOMAXPROCS ≈ limit/1000]
    B -->|No| D[需主动解析 /sys/fs/cgroup/cpu/cpu.shares]
    D --> E[按 request 比例缩放 GOMAXPROCS]

2.4 Go 1.19–1.23中sched、proc、m结构体对GOMAXPROCS动态响应的源码级验证

Go 1.19 起,runtime.GOMAXPROCS 的变更不再仅作用于 sched.nmidle 的静态快照,而是通过 sched.gcwaiting 和 sched.nmspinning 的原子协同实现热感知。

动态调度器唤醒路径

// src/runtime/proc.go (Go 1.22)
func procresize(newm int32) {
    // …省略校验…
    for i := int32(len(allm)); i < newm; i++ {
        m := allocm(nil, nil)
        m.nextwaitm = sched.waitm
        sched.waitm = m // 插入等待链表,非阻塞唤醒
    }
}

allocm 创建新 m 后直接挂入 sched.waitm,绕过旧版需 startm 显式唤醒的同步开销;nextwaitm 形成无锁单链,避免 sched.lock 竞争。

关键字段演进对比

字段 Go 1.19 Go 1.23 语义变化
sched.nmspinning uint32,仅统计 atomic.Uint32,带 fence 支持 cas 原子增减与内存序保障
m.spinning bool atomic.Bool 消除写-写竞争,支持并发自旋状态切换

核心同步机制

  • m.startspinning() 在 findrunnable() 中触发,更新 sched.nmspinning;
  • stopm() 调用 m.stops spinning(),自动触发 wakep() 尝试唤醒空闲 p;
  • 所有 GOMAXPROCS 变更均走 procresize → handoffp → wakep 链式响应。

2.5 基于pprof+trace+perf的GOMAXPROCS误配导致P空转与GC STW加剧的联合取证

当 GOMAXPROCS=1 运行高并发 Go 程序时,调度器被迫序列化 M 绑定 P,引发 P 频繁空转与 GC STW 延长。

关键现象定位

  • go tool pprof -http=:8080 cpu.pprof 显示 runtime.schedule 占比异常高(>40%)
  • go tool trace trace.out 中可见大量 ProcIdle 状态与 GC mark termination 阶段重叠
  • perf record -e sched:sched_switch -g -- ./app 捕获到 runtime.mcall 频繁上下文切换

联合分析证据链

# 同时采集三类信号
GOMAXPROCS=1 go run -gcflags="-l" -cpuprofile=cpu.pprof -trace=trace.out main.go
perf record -e cycles,instructions,sched:sched_switch -g -- ./main

此命令启用全栈采样:-cpuprofile 捕获用户态调度热点;-trace 记录 Goroutine 生命周期与 GC 事件;perf 获取内核级调度延迟。三者时间戳对齐后可交叉验证 P 空转周期与 STW 起始点是否同步。

工具 核心指标 异常阈值
pprof runtime.findrunnable >25% CPU 时间
trace GC pause > 5ms 连续3次超阈值
perf sched:sched_switch 频率 >10k/s(单核)
graph TD
    A[GOMAXPROCS=1] --> B[仅1个P可用]
    B --> C[新Goroutine需等待P空闲]
    C --> D[P频繁进入idle→wake循环]
    D --> E[GC mark phase无法抢占P]
    E --> F[STW延长+用户goroutine饥饿]

第三章:Go运行时CPU资源调控的核心API与约束边界

3.1 runtime.GOMAXPROCS()调用时机、幂等性与并发安全实践

GOMAXPROCS 控制 Go 程序可并行执行的操作系统线程数(即 P 的数量),其设置直接影响调度器吞吐与资源争用。

调用时机建议

  • 启动时一次性设置:通常在 main.init() 或 main.main() 开头调用,避免后续调度状态混乱
  • 禁止热更新场景:如高频 HTTP 服务中动态调整,易引发 P 队列迁移抖动

幂等性保障

// 安全的幂等设置(多次调用效果相同)
old := runtime.GOMAXPROCS(0) // 获取当前值
runtime.GOMAXPROCS(runtime.NumCPU()) // 设置为逻辑 CPU 数
// 即使重复执行,最终 GOMAXPROCS 值唯一确定

逻辑分析:GOMAXPROCS(n) 返回前值,参数 n=0 仅读取不修改;n>0 时原子更新全局 gomaxprocs 变量,并触发 procresize() 重平衡 P 集合——该操作本身是线程安全且幂等的(重复设相同值不触发冗余迁移)。

并发安全实践要点

  • ✅ 允许任意 goroutine 调用(内部加锁保护)
  • ❌ 不应在 init() 中依赖 runtime.NumCPU() 之外的动态环境(如容器 cgroup 限制需显式读取)
场景 推荐做法
云原生容器环境 读取 /sys/fs/cgroup/cpu/cpu.cfs_quota_us 后调用
混合部署(CPU 绑核) 与 taskset 配合,设为绑定核数
CI/CD 测试环境 固定为 2 保证结果可重现

3.2 GODEBUG=schedtrace=1000与GOTRACEBACK=crash在CPU过载定位中的实战组合

当Go服务出现持续高CPU但无panic时,GODEBUG=schedtrace=1000可每秒输出调度器快照:

GODEBUG=schedtrace=1000 ./myapp

参数说明:1000表示毫秒级采样间隔;输出含M/P/G状态、阻塞/运行时长、GC停顿等关键指标,精准暴露goroutine自旋或P饥饿。

配合GOTRACEBACK=crash,可在进程因信号(如SIGABRT)终止时强制打印完整栈:

GOTRACEBACK=crash GODEBUG=schedtrace=1000 ./myapp

此组合将调度异常(如大量runnable G堆积)与崩溃时刻的调用链关联,直指热点函数。

典型定位路径:

  • 观察schedtrace中idle P数骤降 + runnable G飙升 → 疑似锁竞争或无限循环
  • crash栈中高频出现runtime.mapassign或sync.(*Mutex).Lock → 验证推测
指标 正常值 过载征兆
idle P数 ≈ GOMAXPROCS
runnable G > 500(goroutine积压)

3.3 通过runtime.LockOSThread()与GOMAXPROCS协同实现关键路径CPU亲和性控制

Go 运行时默认不保证 Goroutine 与 OS 线程的绑定关系,但在实时性敏感场景(如高频交易、音视频编解码)中,需避免上下文切换与跨核缓存失效。runtime.LockOSThread() 可将当前 Goroutine 与其底层 M(OS 线程)永久绑定,而 GOMAXPROCS 控制可并行执行的 P 数量——二者协同可实现逻辑核级调度约束。

绑定线程 + 限缩调度器数量

func pinToCore0() {
    runtime.GOMAXPROCS(1) // 仅启用1个P,限制调度器并发度
    runtime.LockOSThread() // 锁定当前G到当前M(进而绑定至某OS线程)
    defer runtime.UnlockOSThread()

    // 关键路径代码:将稳定运行在单个物理核上(若OS未迁移该线程)
}

逻辑分析:GOMAXPROCS(1) 防止其他 Goroutine 抢占同一 P,降低调度干扰;LockOSThread() 确保该 Goroutine 始终由同一 OS 线程执行。两者结合,使关键路径获得近似 CPU 亲和性(需配合 taskset 或 sched_setaffinity 进一步固化到指定 CPU 核)。

协同效果对比表

策略 调度确定性 缓存局部性 跨核同步开销 备注
默认 Goroutine 调度 低 差 高 动态迁移,无核绑定
LockOSThread() 单独使用 中 中 中 线程可能被 OS 迁移至任意核
+ GOMAXPROCS(1) 协同 高 优 极低 配合系统级绑核可达最佳效果

执行流程示意

graph TD
    A[启动关键路径Goroutine] --> B[调用 runtime.GOMAXPROCS 1]
    B --> C[调用 runtime.LockOSThread]
    C --> D[进入计算密集循环]
    D --> E[OS线程被内核调度器固定于某CPU]
    E --> F[L1/L2缓存命中率显著提升]

第四章:面向K8s生产环境的GOMAXPROCS动态适配方案

4.1 基于cgroupfs自动读取cpu.max/cpu.cfs_quota_us的Go初始化适配器封装

为实现容器运行时对 Linux cgroups v2 CPU 限流参数的零配置感知,我们封装了轻量级 CpuLimitAdapter 结构体。

核心适配逻辑

  • 自动探测 /proc/self/cgroup 定位 cgroup 路径
  • 优先读取 cpu.max(cgroups v2);若不存在,回退至 cpu.cfs_quota_us(v1 兼容)
  • 将原始值(如 "120000 100000" 或 "50000")解析为标准化的 quotaUs / periodUs 比率

参数解析示例

// 从 cgroupfs 读取并解析 CPU 配额
func (a *CpuLimitAdapter) Load() (float64, error) {
    content, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
    if os.IsNotExist(err) {
        // 回退 v1:读 cpu.cfs_quota_us 和 cpu.cfs_period_us
        return parseV1CpuQuota()
    }
    parts := strings.Fields(string(content)) // ["120000", "100000"]
    quota, _ := strconv.ParseInt(parts[0], 10, 64)
    period, _ := strconv.ParseInt(parts[1], 10, 64)
    return float64(quota) / float64(period), nil // → 1.2 表示 120% CPU
}

逻辑说明:cpu.max 第一字段为 quota(微秒),第二为 period(微秒);比值即实际可用 CPU 核心数上限。当 quota = "max" 时返回 math.Inf(1)。

支持的 cgroup 版本对照

文件路径 cgroups 版本 示例值 解析结果
/sys/fs/cgroup/cpu.max v2 "50000 100000" 0.5
/sys/fs/cgroup/cpu.cfs_quota_us + .../cpu.cfs_period_us v1 25000 / 10000 2.5
graph TD
    A[Init Adapter] --> B{Read /sys/fs/cgroup/cpu.max?}
    B -- Yes --> C[Parse quota/period]
    B -- No --> D[Read v1 quota & period]
    C --> E[Return normalized ratio]
    D --> E

4.2 使用k8s downward API注入CPU limit并实现启动时GOMAXPROCS精准设值

Go 应用在 Kubernetes 中常因默认 GOMAXPROCS(等于系统逻辑 CPU 数)远超容器实际 CPU limit 而引发调度争抢与 GC 压力。Downward API 可将 limits.cpu 以环境变量形式注入,供启动脚本解析并设置。

环境变量注入配置

env:
- name: MY_CPU_LIMIT_MILLICORES
  valueFrom:
    resourceFieldRef:
      containerName: app
      resource: limits.cpu  # 返回如 "100m" 字符串

此字段由 kubelet 动态解析为容器实际限制值(非节点总核数),单位为毫核(millicores)。需注意:resourceFieldRef 必须指定 containerName,且仅支持 limits.cpu/requests.cpu。

启动时自动计算 GOMAXPROCS

#!/bin/sh
# 将 100m → 0.1 → 向上取整为 1
CPUS=$(echo "$MY_CPU_LIMIT_MILLICORES" | sed 's/m$//' | awk '{printf "%.0f", $1/1000}')
export GOMAXPROCS=${CPUS:-1}
exec "$@"

该脚本将毫核值标准化为整数 CPU 数(向下取整可能得 0,故用 awk 四舍五入后 %.0f 截断),避免 Go 运行时过度并行。

输入 limit 解析结果 推荐 GOMAXPROCS
250m 0.25 1
1000m 1.0 1
1500m 1.5 2
graph TD
  A[Pod 创建] --> B[Downward API 注入 limits.cpu]
  B --> C[Entrypoint 解析 millicores]
  C --> D[转换为整数 CPU 数]
  D --> E[设置 GOMAXPROCS 并 exec]

4.3 Operator级GOMAXPROCS策略引擎:支持HPA触发下的运行时热重置(含atomic.Value+sync.Once双保险)

核心设计目标

在Kubernetes HPA动态扩缩容场景下,Go runtime需响应Pod副本数变化,实时调整GOMAXPROCS以匹配vCPU资源配额,避免调度争用或资源浪费。

双保险机制实现

var (
    gomaxprocs atomic.Value // 存储当前生效值(int)
    initOnce   sync.Once    // 确保全局初始化仅执行一次
)

func SetGOMAXPROCS(n int) {
    initOnce.Do(func() {
        runtime.GOMAXPROCS(n)
        gomaxprocs.Store(n)
    })
    // 热更新:仅当n有效且不同于当前值时才变更
    if curr := gomaxprocs.Load().(int); n > 0 && n != curr {
        runtime.GOMAXPROCS(n)
        gomaxprocs.Store(n)
    }
}

逻辑分析:sync.Once保障首次加载安全;atomic.Value提供无锁读写,避免runtime.GOMAXPROCS并发调用冲突。n > 0校验防止非法值穿透。

触发时机与参数来源

  • 来源:Operator监听HorizontalPodAutoscaler事件 + Pod limits.cpu解析
  • 关键参数:ceil(cpuLimit * 1000) / 1000(毫核转整数vCPU)
场景 GOMAXPROCS建议值 依据
单vCPU Pod 1 避免goroutine抢占
2.5 vCPU(HPA扩容) 2 向下取整,保守适配
graph TD
    A[HPA检测CPU使用率超阈值] --> B[Operator计算新vCPU配额]
    B --> C{是否变更GOMAXPROCS?}
    C -->|是| D[调用SetGOMAXPROCS]
    C -->|否| E[跳过]
    D --> F[atomic.Value更新+runtime生效]

4.4 eBPF辅助验证:使用libbpf-go实时监控goroutine-to-P绑定状态与空闲P堆积告警

Go运行时调度器中,P(Processor)的空闲堆积常预示着协程阻塞、系统调用未及时归还或GC停顿异常。传统pprof仅提供快照,而eBPF可实现纳秒级、零侵入的P状态追踪。

核心监控维度

  • runtime.pidle 全局空闲P计数(需符号解析)
  • 每个P的status字段(_Pidle=0, _Prunning=1, _Psyscall=2)
  • goroutine在P间迁移的g->m->p链路变更事件

libbpf-go采集逻辑

// attach to tracepoint: sched:sched_p_idle
prog, _ := bpfModule.Load("trace_idle_p")
link, _ := prog.AttachTracepoint("sched", "sched_p_idle")
// 触发时读取当前CPU对应P的地址及状态

该程序挂钩内核调度事件,避免轮询开销;sched_p_idle事件携带cpu和p指针,经bpf_probe_read_kernel安全解引用获取p->status,再通过ringbuf异步推送至用户态。

字段 类型 说明
p_id uint32 P索引(非地址)
status uint32 状态码(0=idle, 1=running…)
ts_ns uint64 时间戳(纳秒)
graph TD
    A[Kernel: sched_p_idle] --> B[bpf_prog: read p->status]
    B --> C{status == 0?}
    C -->|Yes| D[Ringbuf: idle_p_event]
    C -->|No| E[Drop]
    D --> F[Userspace: threshold check]
    F --> G[Alert if idle_p > 3 for 5s]

第五章:从CPU利用率到服务SLA的全局治理范式

传统运维常将CPU利用率作为核心健康指标——某电商大促期间,订单服务集群CPU平均值稳定在62%,SRE团队判定“系统正常”,却未察觉P99响应延迟悄然升至3.8秒(SLA阈值为2.5秒),导致17%的支付请求超时失败,最终影响当日GMV约¥2300万元。这一典型失察揭示了单一资源指标与业务结果间的巨大鸿沟。

指标语义对齐的实践路径

必须建立“资源层—服务层—业务层”三级指标映射关系。例如:

  • CPU利用率 > 85% → 对应服务实例线程阻塞率 > 40%
  • HTTP 5xx错误率 > 0.3% → 关联下游库存服务gRPC超时率 > 12%
  • 支付成功率下降 > 1.5pp → 触发订单服务SLI(Success Rate)自动重算

基于SLI/SLO驱动的告警收敛机制

某金融中台将告警规则重构为SLO偏差驱动:当payment_sli = success_count / total_count连续15分钟低于99.95%(SLO目标)且偏差达0.08pp时,才触发P1级告警;相比旧版基于单实例CPU>90%的告警,误报率下降76%,MTTR缩短至8.2分钟。

全链路黄金信号看板示例

维度 SLI定义 当前值 SLO目标 偏差状态
支付可用性 1 - (failed_payments / total_payments) 99.932% ≥99.95% ⚠️ 降级中
库存查询延迟 p99(ms) of /api/v1/inventory/check 186ms ≤150ms ❌ 违规
订单创建吞吐 requests/sec over last 5min 1,240 ≥1,500 ❌ 违规

动态容量治理的闭环反馈

采用Mermaid流程图实现SLA驱动的弹性扩缩容决策:

flowchart LR
    A[实时采集SLI数据] --> B{SLI是否持续偏离SLO?}
    B -- 是 --> C[根因分析:定位瓶颈服务]
    C --> D[执行容量干预:水平扩缩+配置调优]
    D --> E[验证SLI恢复情况]
    E -- 已达标 --> F[关闭干预任务]
    E -- 仍偏离 --> G[触发架构评审工单]

跨团队SLA契约化落地

在2023年Q3跨部门协同中,支付网关团队与风控服务团队签署《SLA服务协议》:明确约定“风控同步校验接口p95延迟≤80ms”,若连续2小时超标,则风控团队需启动熔断预案并提供根因报告。该机制使联合故障平均定位时间从4.7小时压缩至22分钟。

数据血缘驱动的治理溯源

通过OpenTelemetry采集全链路Span数据,构建服务依赖拓扑图,当用户中心服务SLI异常时,系统自动追溯至上游认证服务JWT解析模块的GC停顿尖刺,并关联JVM参数配置变更记录,形成从SLA劣化到代码配置的完整证据链。

某省级政务云平台在迁移至Kubernetes后,将CPU利用率监控权重从100%降至15%,转而将SLI合规率、服务间SLO违约次数、跨AZ调用失败率纳入核心考核。三个月内,市民办事平均耗时下降31%,投诉率降低44%。

专注后端开发日常,从 API 设计到性能调优,样样精通。

发表回复

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