Posted in

Go语言代码遗产拯救计划(从崩溃边缘到高可用服务)

第一章:Go语言代码遗产拯救计划(从崩溃边缘到高可用服务)

当一个运行三年的Go微服务突然每小时panic一次、goroutine泄漏达数万、HTTP超时率飙升至42%,它不再只是技术债——而是生产环境中的定时炸弹。本章聚焦真实世界中濒临崩溃的Go遗留系统,提供可立即落地的诊断与重构路径。

诊断:定位崩溃根源的三把钥匙

首先启用Go运行时诊断工具:

# 启用pprof并暴露调试端点(在main.go中添加)
import _ "net/http/pprof"
go func() { http.ListenAndServe("localhost:6060", nil) }()

然后执行:

# 获取goroutine快照,识别阻塞/泄漏
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -A 10 -B 10 "blocking"  
# 采集10秒CPU火焰图  
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10

修复:零停机热更新关键组件

对存在竞态的全局map进行原子化改造:

// 原危险代码(sync.Map替代方案)  
// var cache = make(map[string]*User) // ❌ 并发写panic  

// 改造后(线程安全且兼容旧接口)  
var userCache sync.Map // ✅  

func GetUser(id string) (*User, bool) {
    if u, ok := userCache.Load(id); ok {
        return u.(*User), true
    }
    return nil, false
}

func SetUser(id string, u *User) {
    userCache.Store(id, u) // 自动处理内存屏障
}

防御:构建弹性边界层

为脆弱依赖添加熔断与降级: 组件 熔断阈值 降级策略
用户认证服务 连续5次失败 返回缓存token(TTL 30s)
订单查询API 错误率>15% 返回空列表+HTTP 206

部署前必须验证:

# 模拟依赖故障,确认降级逻辑生效  
export MOCK_AUTH_FAILURE=true  
go test -run TestAuthFallback -v

每一次panic日志都是系统在呼救,而Go的recoverpprofsync.Map不是语法糖,是抢救服务生命的手术刀。

第二章:遗产代码诊断与稳定性基线重建

2.1 运行时崩溃根因分析:panic traceback与goroutine泄漏定位

Go 程序崩溃时,panic 自动生成的 traceback 是第一手诊断线索。关键需关注最后一帧(最深层调用)及 created by 注释行——它揭示 goroutine 的启动源头。

panic traceback 解析要点

  • goroutine N [running]: 表示当前活跃 goroutine ID
  • 每行含文件路径、函数名、行号(如 main.go:42
  • created by main.startWorker 指明协程创建点

goroutine 泄漏定位三步法

  • 使用 runtime.NumGoroutine() 监控增长趋势
  • pprof 抓取 goroutine profile(/debug/pprof/goroutine?debug=2
  • 结合 GODEBUG=schedtrace=1000 观察调度器状态
// 启动一个未受控的 goroutine(典型泄漏源)
go func() {
    defer wg.Done()
    for range time.Tick(1 * time.Second) { // 永不停止的 ticker
        http.Get("https://api.example.com") // 可能阻塞或重试无界
    }
}()

此代码中 time.Tick 返回永不关闭的 Ticker.C,循环体无退出条件且无上下文控制,导致 goroutine 持续存活。wg.Done() 永不执行,进一步掩盖泄漏。

检测手段 输出特征 定位价值
runtime.Stack() 全量 goroutine 栈快照 识别阻塞/空转 goroutine
pprof goroutine 按状态(runnable/waiting)分组 发现堆积在 channel receive 的协程
GODEBUG=schedtrace 每秒打印调度器摘要 观察 GRQ(goroutine run queue)持续膨胀
graph TD
    A[panic 发生] --> B{traceback 分析}
    B --> C[定位最后调用栈]
    B --> D[查找 created by 行]
    C --> E[检查参数是否为 nil/越界]
    D --> F[回溯启动逻辑是否缺少 cancel]
    F --> G[确认 context 是否被传递]

2.2 依赖熵值评估:过时module版本、不兼容API调用与隐式副作用识别

依赖熵值(Dependency Entropy)量化项目中依赖关系的混乱程度——版本碎片化、API断裂与未声明副作用共同推高熵值,削弱可维护性。

熵值驱动的静态扫描逻辑

# entropy_scanner.py
def assess_module_entropy(dep_tree: dict) -> float:
    version_diversity = len(set(d["version"] for d in dep_tree.values())) / len(dep_tree)
    api_breakage_score = sum(1 for d in dep_tree.values() if d.get("deprecated_api_used"))
    side_effect_risk = sum(1 for d in dep_tree.values() if d.get("has_implicit_side_effect"))
    return 0.4 * version_diversity + 0.35 * api_breakage_score + 0.25 * side_effect_risk

该函数加权聚合三类风险:version_diversity 衡量同模块多版本共存程度;api_breakage_score 统计已废弃API调用次数;side_effect_risk 标识未通过 package.json#exports__init__.py 显式导出却被外部直接引用的模块路径。

高熵典型模式

  • lodash@4.17.11lodash@4.17.21 并存于同一 node_modules
  • 直接调用 React.unstable_flushControlled(v18+ 已移除)
  • moment-timezone/data/packed/latest.json 被硬编码加载(无 exports 声明,属隐式副作用)
风险类型 检测方式 修复建议
过时 module 版本 npm outdated --depth=0 + 语义化版本比对 升级至 LTS 版本并统一 lockfile
不兼容 API 调用 AST 解析 + React/Node.js 官方弃用映射表 替换为 createRoot / globalThis 等等效新 API
隐式副作用 分析 import 路径是否匹配 exports 字段 补全 exports 或改用 require.resolve() 显式定位
graph TD
    A[解析 package.json & node_modules] --> B[提取依赖树与版本元数据]
    B --> C{检测三类熵源}
    C --> D[版本离散度计算]
    C --> E[AST 扫描废弃 API]
    C --> F[路径匹配 exports 字段]
    D & E & F --> G[加权熵值输出]

2.3 性能劣化归因:pprof火焰图解读与GC压力反模式实测

火焰图关键读取原则

  • 横轴表示采样堆栈的相对耗时(非绝对时间),越宽越热点;
  • 纵轴为调用栈深度,顶层为叶子函数(如 runtime.mallocgc);
  • 高频出现的“扁平宽条”常指向 GC 触发点或内存分配热点。

典型 GC 压力反模式代码

func badBatchProcess(data []string) []string {
    var results []string
    for _, s := range data {
        // ❌ 每次 append 触发底层数组扩容 + 内存拷贝
        results = append(results, strings.ToUpper(s)) // 分配新字符串,逃逸至堆
    }
    return results
}

逻辑分析:strings.ToUpper 返回新字符串(堆分配),循环中 append 频繁触发 slice 扩容(2倍增长策略),导致大量短期对象涌入堆,加剧 GC 频率。-gcflags="-m" 可验证 s 和返回值逃逸。

pprof 采集命令速查

场景 命令
CPU profile go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
Heap profile go tool pprof http://localhost:6060/debug/pprof/heap
graph TD
    A[HTTP /debug/pprof] --> B[CPU Profile]
    A --> C[Heap Profile]
    B --> D[生成火焰图 SVG]
    C --> E[查看 topN 分配源]

2.4 并发安全审计:data race检测、sync.Mutex误用与channel阻塞链路追踪

数据同步机制

go run -race 是检测 data race 的黄金标准。以下典型误用会触发竞态报告:

var counter int
func increment() {
    counter++ // ❌ 非原子操作,多 goroutine 并发写入
}

counter++ 编译为读-改-写三步,无锁时多个 goroutine 可能同时读取旧值,导致丢失更新。-race 在运行时插桩内存访问,标记读/写事件及调用栈,精准定位冲突点。

Mutex 使用陷阱

常见误用包括:

  • 忘记加锁/解锁(尤其 panic 后)
  • 锁粒度过粗(串行化无关逻辑)
  • 复制已加锁的 mutex 值(违反 sync.Locker 零值安全约定)

channel 阻塞链路追踪

ch := make(chan int, 1)
ch <- 1 // ✅ 缓冲满前不阻塞
ch <- 2 // ❌ 永久阻塞 —— 无接收者且缓冲区满

此阻塞将使 goroutine 泄漏。pprof + runtime.Stack() 可捕获阻塞 goroutine 栈帧,结合 golang.org/x/exp/trace 可可视化 channel 等待链。

检测手段 覆盖场景 实时性
-race 内存级竞态 运行时
go vet -shadow 变量遮蔽引发的逻辑错误 编译期
pprof/goroutines goroutine 阻塞/泄漏 运行时
graph TD
    A[goroutine A] -->|send to ch| B[chan buffer]
    B -->|full| C[goroutine A blocked]
    D[goroutine B] -->|recv from ch| B
    C -->|requires| D

2.5 可观测性缺口填补:在零埋点旧代码中注入结构化日志与指标探针

遗留系统常因无日志埋点而丧失可观测性。现代方案无需修改源码,借助字节码增强(如 Byte Buddy)在类加载期动态织入结构化日志与指标采集逻辑。

动态探针注入示例

// 使用 Byte Buddy 在 UserService.findUser() 返回前注入指标上报
new ByteBuddy()
  .redefine(UserService.class)
  .method(named("findUser"))
  .intercept(MethodDelegation.to(MetricsInterceptor.class))
  .make()
  .load(UserService.class.getClassLoader(), ClassLoadingStrategy.Default.INJECTION);

逻辑分析:redefine() 加载原类字节码;method(named("findUser")) 定位目标方法;MethodDelegation 将执行委托至拦截器,避免侵入业务逻辑。INJECTION 策略确保运行时热替换生效。

探针能力对比

能力 静态埋点 字节码增强 代理容器
代码侵入性
支持旧 JDK 版本 ≥JDK7 ≥JDK8
graph TD
  A[类加载器触发] --> B{是否匹配探针规则?}
  B -->|是| C[重写字节码注入MetricsInterceptor]
  B -->|否| D[原样加载]
  C --> E[执行时自动上报latency、status]

第三章:渐进式重构方法论与边界控制

3.1 “绞杀者模式”在Go微服务中的落地:接口契约冻结与适配层设计

在渐进式迁移中,接口契约冻结是绞杀者模式的基石——旧服务对外暴露的 API 版本(如 /v1/users/{id})必须严格保持向后兼容,禁止字段删减或语义变更。

适配层核心职责

  • 协议转换(HTTP ↔ gRPC)
  • 错误码映射(500 → STATUS_INTERNAL_ERROR
  • 请求/响应结构桥接

Go 适配层示例(HTTP 转 gRPC)

// Adapter: 将 legacy HTTP UserRequest 映射为 gRPC GetUserRequest
func (a *UserAdapter) HandleHTTPGetUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    req := &pb.GetUserRequest{Id: id} // gRPC 请求结构
    resp, err := a.client.GetUser(r.Context(), req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    json.NewEncoder(w).Encode(map[string]interface{}{
        "user_id": resp.User.Id,
        "name":    resp.User.Name,
    })
}

逻辑分析:该适配器不修改业务逻辑,仅做结构翻译;req 是强类型 gRPC 消息,resp.User 字段经 protobuf 编译生成,保障契约一致性。r.Context() 透传超时与取消信号,实现跨协议上下文传递。

契约维度 冻结要求 验证方式
URL 路径 不变 OpenAPI v3 Schema Diff
响应字段 只增不删 JSON Schema 严格校验
HTTP 状态码 语义不变 自动化契约测试
graph TD
    A[Legacy HTTP Client] -->|/v1/users/123| B[Adapter Layer]
    B -->|gRPC GetUserRequest| C[New gRPC Service]
    C -->|GetUserResponse| B
    B -->|JSON user object| A

3.2 单元测试护城河建设:基于gomock+testify对无测试遗产的逆向覆盖率攻坚

面对零测试覆盖的遗留服务,我们采用“接口先行→Mock驱动→断言闭环”三步法重构测试基线。

接口抽象与Mock生成

首先提取核心依赖接口(如 UserRepo),用 mockgen 自动生成桩:

mockgen -source=user_repo.go -destination=mocks/mock_user_repo.go -package=mocks

该命令基于源码接口定义生成符合 gomock 规范的 mock 实现,支持 EXPECT() 链式声明行为。

断言强化与覆盖率追踪

使用 testify/assert 替代原生 if !ok { t.Fatal() },提升可读性与失败定位精度:

func TestUserService_GetByID(t *testing.T) {
    mockCtrl := gomock.NewController(t)
    defer mockCtrl.Finish()

    mockRepo := mocks.NewMockUserRepo(mockCtrl)
    mockRepo.EXPECT().FindByID(123).Return(&User{Name: "Alice"}, nil)

    service := NewUserService(mockRepo)
    user, err := service.GetByID(123)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name) // 精确字段比对,非结构全量断言
}

逻辑分析EXPECT().FindByID(123) 声明调用契约;Return() 指定响应;assert.Equal 对比关键业务字段,避免过度耦合内部结构。参数 123 是可复现的边界用例ID,确保行为可验证。

测试有效性度量

指标 目标值 工具链
方法级覆盖率 ≥85% go test -cover
Mock调用验证率 100% gomock.VerifyAll
断言失败定位耗时 testify diff 输出
graph TD
    A[识别高风险函数] --> B[抽取依赖接口]
    B --> C[生成gomock桩]
    C --> D[编写testify断言用例]
    D --> E[运行并采集覆盖率]
    E --> F{覆盖率≥85%?}
    F -->|否| B
    F -->|是| G[合并至CI门禁]

3.3 模块解耦实践:通过go:embed与plugin机制隔离高风险遗留逻辑块

在微服务演进中,遗留系统常包含强耦合、难测试的支付/风控逻辑。我们采用双层隔离策略:

嵌入式配置驱动

// embed_config.go
import _ "embed"

//go:embed config/risk_rules.yaml
var riskRulesYAML []byte // 编译期固化,避免运行时文件依赖

go:embed 将规则文件编译进二进制,消除 I/O 故障面;[]byte 类型确保零拷贝加载,_ 导入仅触发嵌入注册。

动态插件加载

// plugin_loader.go
p, err := plugin.Open("./risk_v1.so")
sym, _ := p.Lookup("ValidateTransaction")
validate := sym.(func(map[string]interface{}) bool)

plugin.Open() 加载独立编译的 .so 文件,Lookup() 获取符号,类型断言保证接口契约。插件可热更新且崩溃不波及主进程。

隔离维度 go:embed plugin
生命周期 编译期绑定 运行时动态加载
故障域 主进程内 独立地址空间
graph TD
    A[主应用] -->|调用接口| B[plugin.Open]
    B --> C[加载risk_v1.so]
    C --> D[Lookup ValidateTransaction]
    D --> E[安全沙箱执行]

第四章:高可用能力内生化改造

4.1 熔断与降级:基于gobreaker封装遗留HTTP客户端并注入超时/重试策略

遗留系统中裸调用 http.Client 易导致雪崩。需在不侵入业务逻辑前提下增强容错能力。

封装思路

  • 使用 gobreaker 实现熔断器状态机(Closed → Open → Half-Open)
  • 组合 context.WithTimeoutretryablehttp 注入超时与指数退避重试

核心封装代码

func NewResilientClient() *http.Client {
    cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "legacy-api",
        MaxRequests: 5,          // 半开态允许试探请求数
        Timeout:     60 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            return counts.ConsecutiveFailures > 3 // 连续失败3次触发熔断
        },
    })

    return &http.Client{
        Transport: &cbTransport{cb: cb},
    }
}

gobreaker.SettingsMaxRequests 控制半开态试探流量,ReadyToTrip 定义熔断阈值;cbTransport 需实现 RoundTrip 方法拦截请求并委托熔断器执行。

策略组合效果对比

策略 超时 重试 熔断 降级兜底
原生 HTTP
封装后客户端 ✅(可扩展)
graph TD
    A[HTTP Request] --> B{Circuit Breaker}
    B -- Closed --> C[Execute with Timeout+Retry]
    B -- Open --> D[Return Fallback]
    B -- Half-Open --> E[Allow 1 request]
    E -->|Success| F[Reset to Closed]
    E -->|Fail| G[Back to Open]

4.2 连接池与资源复用:net/http Transport优化与数据库sql.DB连接泄漏治理

HTTP Transport 连接复用关键配置

transport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 100,
    IdleConnTimeout:     30 * time.Second,
    TLSHandshakeTimeout: 10 * time.Second,
}

MaxIdleConnsPerHost 控制单主机空闲连接上限,避免 http.DefaultTransport 默认值(2)引发连接阻塞;IdleConnTimeout 防止长时空闲连接被中间设备(如NAT网关)静默断开。

sql.DB 连接泄漏典型模式

  • 忘记调用 rows.Close() 导致底层连接无法归还池中
  • defer rows.Close() 放在错误分支外,panic 时未执行
  • 使用 QueryRow() 后未检查 err 就调用 Scan(),连接滞留

常见配置对比表

组件 默认值 生产推荐值 风险点
http.MaxIdleConns 0(不限) 100 过高易耗尽文件描述符
sql.DB.MaxOpenConns 0(不限) 50–100 未设限可能压垮DB
sql.DB.MaxIdleConns 2 20 过低导致频繁建连

连接生命周期管理流程

graph TD
    A[HTTP Client 发起请求] --> B{Transport 查找可用空闲连接}
    B -->|命中| C[复用连接,跳过握手]
    B -->|未命中| D[新建TCP/TLS连接]
    C & D --> E[执行请求/响应]
    E --> F[连接放回idle队列或关闭]

4.3 热更新与平滑重启:利用fork/exec + unix socket实现零停机配置热加载

传统 reload 依赖信号(如 SIGHUP)触发进程重载,存在竞态与连接中断风险。现代实践采用 双进程协作模型:主进程(master)监听 Unix socket,子进程(worker)处理业务请求。

核心流程

  • master 进程持续监听 /tmp/nginx-hot.sock
  • 配置变更时,master fork() 新 worker,通过 exec() 加载新二进制/配置;
  • 旧 worker 完成当前请求后优雅退出(SO_LINGER + 连接 draining);
  • 进程间通过 Unix socket 传递监听 fd(SCM_RIGHTS),避免端口争用。
// 传递监听 socket fd 的关键代码片段
struct msghdr msg = {0};
struct cmsghdr *cmsg;
char cmsg_buf[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;  // 传递文件描述符类型
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &listen_fd, sizeof(int));

SCM_RIGHTS 是 Unix domain socket 特有的控制消息类型,用于在进程间安全传递打开的文件描述符;CMSG_SPACE 确保控制消息缓冲区对齐,避免内核拒绝传递。

关键参数对比

参数 旧模式(SIGHUP) 新模式(fork/exec + socket)
停机时间 毫秒级中断 零连接中断(draining 期间无新连接)
配置验证时机 reload 时 fork 前预校验,失败则不启动新 worker
graph TD
    A[配置变更] --> B{master 进程}
    B --> C[fork() 新 worker]
    C --> D[exec() 加载新配置]
    D --> E[通过 Unix socket 传递 listen_fd]
    E --> F[新 worker accept 连接]
    B --> G[旧 worker graceful shutdown]

4.4 分布式追踪注入:OpenTelemetry SDK轻量集成与span上下文跨legacy handler透传

在遗留系统中实现无侵入式追踪,关键在于上下文轻量注入跨handler透传。OpenTelemetry Python SDK 提供 trace.get_current_span()TraceContextTextMapPropagator,无需修改核心业务逻辑。

跨Handler上下文透传示例

from opentelemetry import trace
from opentelemetry.propagate import inject, extract
from opentelemetry.trace import SpanContext, TraceFlags

# 在入口handler中注入traceparent
carrier = {}
inject(carrier)  # 自动写入 'traceparent' 字段
# → carrier = {"traceparent": "00-123...-456...-01"}

inject() 使用 W3C Trace Context 格式序列化当前 span 上下文;carrier 作为字典被下游 handler(如 Flask before_request 或 Celery task)通过 extract(carrier) 还原,确保 span 链路连续。

Propagator兼容性对比

Propagator Legacy Support Header Format
TraceContextTextMapPropagator ✅ 高兼容 traceparent
BaggagePropagator ⚠️ 需额外适配 baggage

数据透传流程

graph TD
    A[Legacy HTTP Handler] -->|inject→ carrier| B[MQ Producer]
    B --> C[Legacy Worker Handler]
    C -->|extract← carrier| D[Reconstruct SpanContext]

第五章:从救火到自治——遗产系统的终局演进

银行核心账务系统的真实迁移路径

某全国性股份制银行的COBOL核心账务系统已运行27年,日均处理交易1.2亿笔。2021年起启动“星火计划”,并非一次性重写,而是采用分域渐进式自治化改造:首先将“利息计算引擎”剥离为独立微服务(Java+Spring Boot),通过Apache Kafka与原系统解耦;随后在6个月内完成全量灰度验证,错误率从0.034%降至0.0007%。关键突破在于构建了双向事务补偿网关,确保TCC模式下跨新旧系统的资金一致性。

自治能力成熟度四象限模型

维度 初始态(救火模式) 过渡态(可观测) 成熟态(自愈) 终极态(自演化)
监控响应 人工轮询日志+PagerDuty告警 Prometheus+Grafana实时指标+异常检测AI模型 自动定位根因+生成修复建议 动态调整采样率与阈值策略
配置治理 生产环境直接修改INI文件 GitOps驱动的ConfigMap版本化管理 基于流量特征自动扩缩容配置 根据业务SLA自主重构路由拓扑
flowchart LR
    A[遗留系统黑盒] --> B[嵌入式探针Agent]
    B --> C[实时提取JVM/DB/IO特征向量]
    C --> D[在线学习模型:LSTM+Isolation Forest]
    D --> E{预测准确率>92%?}
    E -->|是| F[触发自动化预案:回滚/降级/熔断]
    E -->|否| G[反馈至训练数据池并标记噪声样本]
    F --> H[生成可审计的自治操作凭证]

某保险理赔平台的自治化实践

该平台原基于IBM WebSphere+Oracle 10g,2023年完成容器化改造后,在Kubernetes集群中部署了三类自治组件:① 智能熔断器——基于历史赔付率、OCR识别耗时、第三方接口RTT构建动态阈值,当某地区暴雨导致影像上传失败率突增300%时,自动切换至本地缓存OCR模型;② 配置自演化引擎——每日凌晨扫描业务规则库变更,结合A/B测试结果,自动将高转化率的新核保策略注入生产配置中心;③ 灾备自编排系统——当检测到主数据中心网络延迟>800ms持续5分钟,无需人工介入,即刻启动跨云双活切换,RTO压缩至47秒。

技术债偿还的量化仪表盘

团队在Jenkins流水线中嵌入技术债评估插件,对每次合并请求进行静态扫描与动态覆盖率分析:

  • cyclomatic_complexity > 15 的COBOL段落自动标记为「重构优先级:P0」
  • 新增Java模块若单元测试覆盖率<85%,CI阶段强制阻断发布
  • 每月生成《自治能力健康度报告》,包含:服务自治响应时长中位数(当前2.3s)、非人工干预事件占比(89.7%)、配置变更自验证通过率(99.992%)

遗产系统自治化的组织适配

某电信运营商在改造BOSS系统时发现:73%的线上故障源于配置误操作。为此设立「自治赋能小组」,由SRE、领域专家、运维开发三方组成,职责包括:维护自治策略知识图谱(Neo4j存储327条业务规则与技术约束映射关系)、每季度更新《自治边界白皮书》(明确禁止自动化操作的12类核心事务)、开发低代码自治策略编辑器(支持拖拽组合“当…则…”条件链)。2024年上半年,该系统人工干预次数同比下降64%,但平均单次干预复杂度提升2.8倍——工程师正从“键盘侠”转型为“自治系统教练”。

深入 goroutine 与 channel 的世界,探索并发的无限可能。

发表回复

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