Posted in

Go语言写抖音API网关,我们踩过的17个坑,第12个让整个SRE团队通宵回滚!

第一章:Go语言抖音API网关项目背景与架构演进

随着抖音生态开放能力持续增强,企业级应用对短视频内容分发、用户行为分析、电商对接等场景的API调用需求呈指数级增长。早期采用Nginx+Lua的轻量网关方案在面对高并发鉴权、动态路由、精细化限流及多租户隔离时逐渐暴露可维护性差、扩展成本高、可观测性弱等问题。为支撑日均超20亿次API请求、毫秒级SLA保障及快速迭代的业务诉求,团队启动了基于Go语言重构API网关的演进工程。

项目驱动因素

  • 性能瓶颈:Lua层处理JWT解析与RBAC校验平均耗时达42ms,无法满足
  • 运维复杂度:配置分散于Nginx conf、Redis规则库、独立鉴权服务,故障定位平均耗时>30分钟;
  • 生态割裂:缺乏原生OpenTelemetry支持,链路追踪覆盖率不足40%,难以对接公司统一监控平台。

架构演进路径

初始版本采用标准Go HTTP Server + Gin框架,但发现中间件链过深导致CPU缓存失效率升高。经压测验证后,切换至net/http原生Server并自研轻量路由引擎,关键优化包括:

  • 使用sync.Pool复用HTTP头解析器实例,降低GC压力;
  • 将JWT公钥验签逻辑下沉至连接复用层,避免每次请求重复加载证书;
  • 集成gRPC-Gateway实现REST/JSON与内部gRPC协议双向透传。

核心技术选型对比

组件 选用方案 替代方案 关键优势
路由引擎 自研Trie+前缀匹配 httprouter 支持动态权重路由与灰度标签匹配
限流组件 golang.org/x/time/rate Sentinel Go 内存零分配,QPS精度达±0.3%
配置中心 etcd v3 + Watch机制 Consul KV 秒级配置热更新,无单点依赖

部署时需执行以下初始化步骤:

# 启动etcd配置监听(确保etcd集群已就绪)
go run cmd/gateway/main.go --config-etcd-endpoints="http://10.0.1.10:2379" \
  --config-watch-path="/gateway/routes/v2"

# 验证网关健康状态(返回200且包含build info)
curl -s http://localhost:8080/healthz | jq '.build.commit'

该演进使P99延迟从42ms降至8.3ms,配置变更生效时间从分钟级压缩至亚秒级,并为后续接入抖音云函数(Douyin Cloud Function)提供标准化协议适配能力。

第二章:高并发场景下的Go核心机制踩坑实录

2.1 Goroutine泄漏的定位与pprof实战分析

Goroutine泄漏常因未关闭的channel、阻塞的waitgroup或遗忘的time.AfterFunc引发。定位需结合运行时指标与火焰图。

pprof采集关键步骤

  • 启动HTTP pprof端点:import _ "net/http/pprof"
  • 采样goroutine堆栈:curl -s http://localhost:6060/debug/pprof/goroutine?debug=2

典型泄漏代码示例

func leakyWorker() {
    ch := make(chan int)
    go func() { // ❌ 无接收者,goroutine永久阻塞
        ch <- 42 // 阻塞在此
    }()
    // 忘记 close(ch) 或 <-ch
}

该协程因向无缓冲channel发送数据且无接收方,进入chan send不可恢复等待态;debug=2输出含完整调用栈与状态标记。

常见泄漏模式对比

场景 检测信号 修复方式
无接收channel发送 chan send + goroutine数持续增长 添加超时/接收逻辑
WaitGroup未Done runtime.gopark + wg.wait() 确保所有goroutine调用Done
graph TD
    A[pprof/goroutine?debug=2] --> B{是否存在大量<br>相同栈帧的阻塞goroutine?}
    B -->|是| C[定位channel/timer/wg源码位置]
    B -->|否| D[检查pprof/heap确认内存关联泄漏]

2.2 Context超时传递链断裂导致雪崩的修复方案

核心修复原则

Context 超时必须沿调用链透传且不可重置,否则下游服务无法感知上游截止时间,引发级联超时与资源耗尽。

关键代码修复(Go)

// ✅ 正确:从父ctx派生,继承Deadline
func handleRequest(parentCtx context.Context, req *Request) error {
    childCtx, cancel := context.WithTimeout(parentCtx, 200*time.Millisecond)
    defer cancel()

    // 向下游HTTP服务透传context(自动携带Deadline)
    resp, err := http.DefaultClient.Do(req.WithContext(childCtx))
    // ...
}

逻辑分析WithTimeout(parentCtx, ...) 会检查 parentCtx.Deadline(),若已过期则立即返回取消的 ctx;若父 ctx 无 deadline,则以当前时间为起点计算新 deadline。参数 200ms剩余容忍延迟,非绝对超时值。

修复效果对比

场景 修复前 修复后
上游提前300ms超时 下游仍执行至自身timeout(如5s) 下游在300ms内主动退出
Context链中断 goroutine 泄漏、连接池耗尽 全链路协同取消,资源及时释放

数据同步机制

  • 所有中间件、RPC client、DB driver 必须显式接收 context.Context 参数
  • 禁止使用 context.Background()context.TODO() 替代传入 ctx
graph TD
    A[API Gateway] -->|ctx with Deadline| B[Auth Service]
    B -->|propagate unchanged| C[User Service]
    C -->|propagate unchanged| D[Cache/DB]
    D -.->|cancel signal on timeout| A

2.3 sync.Pool误用引发内存抖动的压测复现与重构

压测现象复现

高并发场景下,GC Pause 频率陡增(>50ms/次),pprof 显示 runtime.mallocgc 占比超65%,sync.Pool.Get() 返回率仅38%。

问题代码示例

var bufPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 1024) // ❌ 固定容量掩盖真实负载
    },
}

func processReq(data []byte) {
    buf := bufPool.Get().([]byte)
    buf = append(buf, data...) // 可能触发底层数组扩容 → 新分配 → Pool失效
    bufPool.Put(buf)           // Put 的是扩容后的新底层数组,与 New 不匹配
}

逻辑分析append 导致 slice 底层数组重分配,Put 存入的 buffer 容量远大于 New 初始值,Pool 缓存失效;GC 无法及时回收大量临时切片。

重构策略对比

方案 复用率 内存波动 实现复杂度
原始 Pool + make([]byte, 0, 1024) 38%
bytes.Buffer + Reset() 92%
按需 Pool(New: func() interface{} { return &bytes.Buffer{} } 89%

正确用法

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer) // ✅ 使用指针类型,Reset 可复用底层字节数组
    },
}

func processReq(data []byte) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    buf.Write(data)
    // ... use buf.Bytes()
    bufPool.Put(buf)
}

关键点bytes.Buffer.Reset() 清空内容但保留已分配内存,Put 时传入原对象,确保 Pool 缓存命中。

2.4 HTTP/2连接复用失效与net/http.Transport调优实践

HTTP/2 连接复用依赖底层 TCP 连接的长生命周期与流多路复用能力,但 net/http.Transport 默认配置在高并发或服务端主动关闭连接时易触发复用中断。

常见失效诱因

  • 后端过早发送 GOAWAY
  • 客户端未启用 http2.Transport(需显式注册)
  • MaxIdleConnsPerHost 过小导致连接被提前回收

关键调优参数示例

transport := &http.Transport{
    MaxIdleConns:        200,
    MaxIdleConnsPerHost: 100, // 必须 ≥ 并发请求数量级
    IdleConnTimeout:     90 * time.Second,
    TLSHandshakeTimeout: 10 * time.Second,
    // 显式启用 HTTP/2(Go 1.18+ 默认启用,但需确保无 http2.ConfigureTransport 覆盖)
}

MaxIdleConnsPerHost 控制每 host 最大空闲连接数;若设为 或过小,将强制新建连接,绕过 HTTP/2 复用。IdleConnTimeout 应略大于服务端 keepalive_timeout,避免客户端单方面关闭复用通道。

推荐配置对照表

参数 默认值 生产推荐值 影响维度
MaxIdleConnsPerHost 2 100 连接复用率
IdleConnTimeout 30s 90s 连接保活窗口
TLSHandshakeTimeout 10s 5s TLS 握手容错
graph TD
    A[发起 HTTP/2 请求] --> B{Transport 查找空闲连接}
    B -->|存在且健康| C[复用连接,新建 stream]
    B -->|超时/不可用| D[新建 TCP + TLS + HTTP/2 协商]
    D --> E[更新空闲连接池]

2.5 Go 1.21+ runtime.LockOSThread在JNI调用中的陷阱与绕行策略

JNI线程绑定的隐式约束

Go 1.21+ 强化了 runtime.LockOSThread() 对 OS 线程生命周期的管控,而 JVM 要求 JNI 函数必须在 AttachCurrentThread/AttachCurrentThreadAsDaemon 后的线程上调用。若 Go goroutine 在未显式 Attach 的线程上调用 JNI,将触发 java.lang.UnsatisfiedLinkError 或 JVM abort。

典型崩溃场景

// ❌ 危险:LockOSThread 后直接调用 JNI(无 JVM attach)
func callJavaMethod() {
    runtime.LockOSThread()
    C.Java_com_example_Native_callFromGo(env, obj) // env 无效!
}

逻辑分析runtime.LockOSThread() 仅固定 goroutine 到当前 OS 线程,但 JVM 完全 unaware;env 是 Attach 后由 JVM 分配的线程局部指针,此处为悬空值。参数 envobj 均非法,触发 SIGSEGV。

安全绕行策略对比

方案 是否需手动 Attach GC 友好性 适用场景
C.JNI_CreateJavaVM + AttachCurrentThread ⚠️ 需 DetachCurrentThread 长期驻留线程
runtime.LockOSThread + defer Detach 短时 JNI 批量调用
使用 Cgo 线程池预 Attach ✅✅ 高频低延迟调用

推荐实践流程

graph TD
    A[Go goroutine] --> B{LockOSThread?}
    B -->|Yes| C[AttachCurrentThread]
    B -->|No| D[Use dedicated JNI thread pool]
    C --> E[Call JNI functions]
    E --> F[DetachCurrentThread]
    F --> G[UnlockOSThread]

第三章:抖音生态对接特有的协议与安全合规陷阱

3.1 抖音OpenAPI v2签名算法在Go中时间精度与时区导致验签失败的全链路排查

抖音OpenAPI v2要求 timestamp 参数为毫秒级 Unix 时间戳(UTC),且签名时需与服务端时间误差 ≤ 300 秒。Go 默认 time.Now().UnixMilli() 返回本地时区时间戳,若未显式转为 UTC,将引入时区偏移(如东八区 +28800000ms),直接导致验签失败。

关键差异点对比

场景 Go 代码 实际值(示例:北京时间 12:00:00.123) 是否符合抖音要求
❌ 错误:time.Now().UnixMilli() 1717099200123(含本地时区偏移) 对应 UTC 时间为 04:00:00.123
✅ 正确:time.Now().UTC().UnixMilli() 1717070400123(纯 UTC 毫秒) 精确对应 UTC 时间戳

验签失败链路还原

// 错误写法:隐含本地时区
ts := time.Now().UnixMilli() // ⚠️ 可能多出 8*3600*1000 ms
signature := sign(params, appSecret, ts)

// 正确写法:强制 UTC 时间戳
ts := time.Now().UTC().UnixMilli() // ✅ 精确对齐抖音服务端

逻辑分析:UnixMilli() 底层调用 t.Unix()*1000 + int64(t.Nanosecond()/1e6),而 t 若为本地时间,其 Unix() 已包含 t.Location().Offset() 补偿;必须先 .UTC() 归一化,再取毫秒值。

全链路时序偏差示意

graph TD
    A[客户端生成 timestamp] -->|未 .UTC()| B[含时区偏移的毫秒值]
    B --> C[签名计算]
    C --> D[抖音服务端校验]
    D -->|UTC 时间比对失败| E[HTTP 401 Signature Invalid]

3.2 字节跳动OAuth2.0 Refresh Token轮转机制与Go token store一致性设计

字节跳动在高并发场景下强制启用单次使用型 Refresh Token(One-Time-Use RT),每次调用 /refresh 时旧 token 失效、新 token 签发,并同步更新存储。

数据同步机制

为避免分布式节点间 token 状态不一致,采用 CAS + TTL 双保障策略

// 原子更新 refresh token(Redis Lua 脚本)
local old_rt = KEYS[1]
local new_rt = ARGV[1]
local ttl_sec = tonumber(ARGV[2])
if redis.call("GET", old_rt) == "valid" then
  redis.call("DEL", old_rt)
  redis.call("SET", new_rt, "valid")
  redis.call("EXPIRE", new_rt, ttl_sec)
  return 1
else
  return 0  -- 并发冲突,拒绝刷新
end

逻辑说明:脚本以原子方式校验旧 token 有效性、删除旧 token、写入新 token 并设 TTL(通常 7 天)。返回 表示已被其他节点抢先刷新,客户端需回退至授权流程。

一致性保障维度

维度 实现方式
时序一致性 所有刷新请求经同一 Redis 集群路由
状态终局性 旧 token 删除不可逆,无软删除
客户端兜底 响应头含 X-Token-Rotation: rotated 标识
graph TD
  A[Client POST /refresh] --> B{Token Store CAS Check}
  B -->|Success| C[Invalidate Old RT + Issue New RT]
  B -->|Fail| D[Return 400 + error=invalid_grant]

3.3 抖音Webhook事件幂等性保障:基于Redis Stream + Lua原子操作的落地实现

核心挑战

抖音高频 Webhook(如订单创建、支付回调)存在网络重试导致的重复投递。传统数据库唯一索引在高并发下易触发写冲突,且无法覆盖跨服务重放场景。

架构设计

采用 Redis Stream 存储事件指纹,配合 Lua 脚本实现「查-存-判」原子操作:

-- check_and_mark_idempotent.lua
local stream_key = KEYS[1]
local event_id = ARGV[1]
local ttl_sec = tonumber(ARGV[2])

-- 原子检查是否存在
if redis.call("XREAD", "COUNT", "1", "STREAMS", stream_key, "$") == nil then
  -- 首次写入,设置过期时间
  redis.call("XADD", stream_key, "*", "id", event_id)
  redis.call("EXPIRE", stream_key, ttl_sec)
  return 1  -- 新事件
else
  return 0  -- 已存在,幂等拒绝
end

逻辑说明:脚本以 event_id 为唯一标识写入 Stream,利用 XADD 的自增 ID 特性规避竞态;EXPIRE 确保指纹自动清理,ttl_sec(建议 24h)平衡存储与安全性。

关键参数对照表

参数 推荐值 说明
stream_key webhook:idempotent:order 按业务维度隔离命名空间
event_id order_123456#20240520 业务ID+时间戳防哈希碰撞
ttl_sec 86400 避免无限累积,适配抖音事件TTL策略

执行流程

graph TD
  A[收到Webhook] --> B{调用Lua脚本}
  B -->|返回1| C[执行业务逻辑]
  B -->|返回0| D[直接返回200 OK]
  C --> E[异步落库+通知]

第四章:SRE视角下网关可观测性与稳定性工程实践

4.1 基于OpenTelemetry的全链路追踪在抖音多跳网关中的Span语义标准化

在抖音多跳网关(LVS → Edge → API → BFF → 微服务)中,跨10+组件的Span语义不一致曾导致链路分析误判率超35%。我们基于OpenTelemetry SDK统一注入span.kindhttp.routenet.peer.name等语义属性。

核心标准化字段表

字段名 示例值 说明
span.kind SERVER / CLIENT 明确网关角色(入向/出向)
http.route /api/v1/feed?tab=hot 标准化路由路径,剥离动态参数
gateway.hop edge-2024 标识当前网关节点及版本

自动化注入示例(Go)

// 在网关中间件中注入标准化属性
span.SetAttributes(
    semconv.HTTPRouteKey.String("/api/v1/feed"),
    semconv.HTTPMethodKey.String("GET"),
    attribute.String("gateway.hop", "edge-2024"), // 动态注入部署标识
)

该代码确保所有Span携带可聚合、可过滤的语义标签,避免人工埋点偏差;gateway.hop支持按版本维度下钻故障定位。

跨跳链路语义对齐流程

graph TD
    A[LVS入口] -->|span.kind=SERVER<br>gateway.hop=lvs-2024| B[Edge网关]
    B -->|span.kind=CLIENT<br>http.route=/api/v1/feed| C[API网关]
    C -->|span.kind=SERVER<br>gateway.hop=api-2024| D[BFF层]

4.2 Prometheus指标爆炸与label cardinality失控的Golang SDK定制化降维方案

当业务维度(如 user_idrequest_idtrace_id)被误用为 Prometheus label 时,cardinality 指数级增长,导致内存溢出与查询延迟飙升。

核心降维策略

  • Label 聚合裁剪:在 SDK 层拦截高基数 label,替换为预定义桶(如 user_tier{tier="premium"}
  • 指标动态注册控制:按命名空间白名单 + label key 黑名单双重校验
  • 运行时 cardinality 熔断:单指标 label 组合超 1000 时自动禁用该 metric 实例

SDK 定制化注册示例

// 自定义 Collector,内置 label 归一化逻辑
type SafeCounterVec struct {
    *prometheus.CounterVec
    labelNormalizer func(labels prometheus.Labels) prometheus.Labels
}

func (s *SafeCounterVec) With(l prometheus.Labels) prometheus.Counter {
    // 对高风险 label 键进行哈希截断或分级映射
    normalized := s.labelNormalizer(l)
    return s.CounterVec.With(normalized)
}

labelNormalizer 接收原始 labels,对 user_id 执行 hash(user_id) % 100 映射为 user_bucket="042",将无限 cardinality 降至固定 100 桶;With() 调用前完成归一化,保障下游存储稳定。

降维效果对比

维度 原生 SDK 定制 SDK
http_requests_total{path,method,user_id} 50w+ series
内存占用(1h) 3.2 GB 186 MB
graph TD
    A[HTTP Handler] --> B[Metrics Middleware]
    B --> C{Label Validator}
    C -->|高基数key| D[Hash → Bucket]
    C -->|安全key| E[直通]
    D & E --> F[SafeCounterVec.With]

4.3 熔断器误触发导致第12坑:Resilience4go与gobreaker在抖音流量峰谷模型下的行为差异验证

流量峰谷建模特征

抖音典型场景:每分钟请求量呈双峰分布(早8点/晚8点),脉冲峰值达均值8.3倍,持续≤90秒,衰减斜率陡峭(τ≈12s)。

熔断器响应对比实验

指标 Resilience4go(v1.2.0) gobreaker(v1.0.1)
熔断触发延迟 327ms(基于滑动窗口) 89ms(基于计数器)
峰值期间误熔断率 63.2% 11.7%
恢复滞后时间 2.1s 380ms

核心逻辑差异

// Resilience4go 默认配置(易误触发)
cfg := resilience4go.NewCircuitBreakerConfig(
    resilience4go.WithFailureThreshold(50), // 固定阈值,无视RT波动
    resilience4go.WithSlidingWindow(100),    // 100个样本,平滑不足
)

该配置在流量突增时将大量超时(非错误)请求计入失败统计,因未区分timeoutbusiness_error,导致熔断器过早开启。

graph TD
    A[请求涌入] --> B{Resilience4go}
    B -->|滑动窗口累计失败| C[阈值触达→熔断]
    A --> D{gobreaker}
    D -->|仅统计panic/err!=nil| E[忽略超时→维持半开]

4.4 日志结构化治理:Zap日志采样率动态调控与抖音业务域上下文注入规范

动态采样策略设计

基于 QPS 和错误率双指标实时调控采样率,避免高负载下日志洪峰冲击:

// 动态采样器:每秒统计并更新采样率(0.01~1.0)
sampler := zapcore.NewSamplerWithOptions(
    core, time.Second, 100, // 每秒最多100条全量日志
    zapcore.SamplingDisabled, // 默认关闭静态采样
)

逻辑分析:NewSamplerWithOptions 在滑动时间窗口内限流,100 表示峰值允许的全量日志条数;超出部分按动态权重降级为 Debug 级别丢弃,保障核心 Error/Warn 不丢失。

业务上下文注入规范

抖音微服务需强制注入以下字段:

  • app_id(服务唯一标识)
  • trace_id(全链路追踪ID)
  • scene(如 feed_home, live_room
  • user_tier(用户等级:vip/normal/guest)

上下文注入流程

graph TD
    A[HTTP Middleware] --> B[解析X-B3-TraceId]
    B --> C[提取scene from URI path]
    C --> D[注入zap.Fields]

采样率调控效果对比

场景 峰值QPS 采样率 日志体积降幅
直播间开播 12k 0.05 95%
信息流刷新 8k 0.12 88%
后台任务 300 1.0 0%

第五章:从17个坑到生产级网关的演进方法论

在支撑日均2.3亿次API调用的电商中台项目中,我们历经14个月、跨越5个大版本迭代,系统性梳理出17类高频生产事故——从Kubernetes Service DNS缓存导致的路由漂移,到JWT密钥轮转期间的双签验证失败;从Prometheus指标采样丢失引发的熔断误判,到gRPC-Web代理中HTTP/2 HEADERS帧被Nginx截断。这些并非孤立故障,而是演进路径上必然踩中的“路标”。

网关能力分层建模

我们将网关能力解耦为四层原子能力:

  • 接入层:支持TLS 1.3+ALPN、SNI路由、mTLS双向认证
  • 路由层:基于Consul健康检查的动态服务发现 + 权重灰度(支持0.1%粒度)
  • 策略层:RBAC+ABAC混合鉴权、JSON Schema请求体校验、WAF规则热加载(ModSecurity 3.4)
  • 可观测层:OpenTelemetry原生埋点、TraceID跨gRPC/HTTP透传、慢查询自动聚类(P99 > 2s触发)

坑洞归因矩阵

坑编号 现象描述 根本原因 验证方式
#7 熔断器在流量突增时持续开启 Hystrix默认滑动窗口为10秒,未适配秒级脉冲 Chaos Mesh注入100ms延迟+5倍流量
#12 WebSocket连接随机中断 Envoy配置中max_frame_size未对齐前端SDK 抓包分析FIN/RST序列与frame length字段

演进验证双轨制

所有变更必须通过两条独立验证轨道:

  • 混沌验证轨道:使用LitmusChaos执行预设故障谱(网络分区、CPU飙高、etcd响应延迟>500ms),观测熔断恢复时间≤800ms;
  • 流量回放轨道:采集线上真实流量(脱敏后),通过Goreplay录制→修改Header→回放至预发集群,比对响应码分布差异率
# 生产环境网关核心配置节选(Kong 3.4)
plugins:
- name: rate-limiting-advanced
  config:
    minute: 6000
    policy: local  # 强制本地内存计数,规避Redis网络抖动
    identifier: consumer_id
    limit_by: consumer

关键决策树

当新增一个OAuth2.0授权端点时,必须按此流程决策:

graph TD
    A[是否需跨域?] -->|是| B[启用CORS插件并设置Vary: Origin]
    A -->|否| C[跳过CORS]
    B --> D[是否含敏感头?]
    D -->|是| E[显式声明Access-Control-Expose-Headers]
    D -->|否| F[保留默认暴露头]
    E --> G[是否需凭证传输?]
    G -->|是| H[设置Access-Control-Allow-Credentials: true]
    G -->|否| I[禁用Credentials]

团队协作契约

运维团队承诺SLA:网关Pod重启时间≤12s(实测9.3s);开发团队签署《上游服务契约》:所有新接口必须提供OpenAPI 3.1规范、定义x-kong-upstream-timeout-ms、标注是否支持重试。契约违规者自动触发CI门禁拦截。

数据面稳定性加固

在Kubernetes DaemonSet部署模式下,通过eBPF程序实时监控Envoy worker线程CPU占用,当单核超75%持续30s时,自动触发worker进程优雅重启(SIGUSR2),避免OOM Killer粗暴kill。该机制上线后,网关节点月均宕机时长从47分钟降至1.2分钟。

流量染色追踪体系

所有入口请求强制注入X-Request-IDX-Trace-ID,并通过OpenTelemetry Collector统一汇聚至Jaeger。特别地,在灰度链路中注入X-Stage: canary,使Kiali服务图谱可自动着色区分流量走向,辅助快速定位灰度异常。

安全策略渐进式落地

WAF规则分三级灰度:Level 1仅记录(log_only)、Level 2阻断SQLi/XSS基础payload、Level 3启用自定义JS沙箱检测。每级上线前需完成72小时攻击模拟测试(使用OWASP ZAP+自研Fuzzer),阻断准确率≥99.97%方进入下一阶段。

记录 Go 学习与使用中的点滴,温故而知新。

发表回复

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