Posted in

Go语言HTTP Server配置暗礁:ListenAndServeTLS、KeepAlive、MaxHeaderBytes参数失效真相揭秘

第一章:Go语言HTTP Server配置暗礁:ListenAndServeTLS、KeepAlive、MaxHeaderBytes参数失效真相揭秘

Go标准库net/http中,http.Server的诸多关键配置项常被开发者误认为“只要设置就生效”,实则存在多个隐式覆盖与初始化时机陷阱。最典型的是ListenAndServeTLS——它会自动创建并覆盖一个全新的http.Server实例,导致你在调用前手动设置的KeepAlive、MaxHeaderBytes等字段全部丢失。

ListenAndServeTLS的静默覆盖行为

srv := &http.Server{
    Addr:           ":443",
    Handler:        myHandler,
    KeepAlive:      30 * time.Second,     // ✅ 显式设置
    MaxHeaderBytes: 1 << 20,              // ✅ 显式设置
}
// ❌ 错误:此调用会忽略 srv 中的 KeepAlive/MaxHeaderBytes
log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", myHandler))

ListenAndServeTLS内部直接构造新Server,未复用传入的Handler以外的任何配置。正确做法是显式调用srv.ListenAndServeTLS:

srv := &http.Server{
    Addr:           ":443",
    Handler:        myHandler,
    KeepAlive:      30 * time.Second,
    MaxHeaderBytes: 1 << 20,
    // 注意:TLSConfig 需单独配置(如需自定义)
}
log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))

KeepAlive失效的常见场景

  • KeepAlive仅在TCP层启用,若反向代理(如Nginx)未透传Connection: keep-alive头或设置了proxy_http_version 1.1,Go服务端无法协商长连接;
  • KeepAlive值必须小于操作系统tcp_keepalive_time,否则内核提前断连。

MaxHeaderBytes被忽略的根源

当使用http.Serve或http.ListenAndServe时,若未显式传入*http.Server,Go会使用默认全局http.DefaultServer,其MaxHeaderBytes为0(即无限制)。但若你自定义Server却调用http.ListenAndServe(非srv.ListenAndServe),你的配置将被丢弃。

调用方式 是否尊重自定义 Server 字段 推荐替代方案
http.ListenAndServe(...) ❌ 否 srv.ListenAndServe()
http.ListenAndServeTLS(...) ❌ 否 srv.ListenAndServeTLS(...)
http.Serve(lis, handler) ❌ 否(忽略 srv 配置) srv.Serve(lis)

务必确保所有监听逻辑均通过*http.Server方法触发,避免落入标准库封装函数的配置黑洞。

第二章:TLS配置失效的深层机理与实证分析

2.1 ListenAndServeTLS底层调用链与证书加载时机剖析

ListenAndServeTLS 的核心在于延迟加载证书——仅在首次 TLS 握手前解析 certFile 和 keyFile,而非启动时。

证书加载触发点

  • 首次 accept() 返回新连接后,进入 tlsConn.Handshake()
  • 此时才调用 tls.LoadX509KeyPair(certFile, keyFile)
  • 若文件缺失或格式错误,错误延至握手阶段才暴露

关键调用链(简化)

http.Server.ListenAndServeTLS()
└── srv.Serve(ln)
    └── srv.handleConn(c)
        └── c.serve()
            └── c.handshake() // ← 此处首次读取并解析证书

证书加载逻辑分析

// 源码节选(net/http/server.go 中 c.handshake)
func (c *conn) handshake() error {
    tlsConn := tls.Server(c.rwc, c.server.TLSConfig) // TLSConfig 可为空
    if err := tlsConn.Handshake(); err != nil {       // ← LoadX509KeyPair 在此内部触发
        return err
    }
    // ...
}

tls.Server 构造不加载证书;Handshake() 内部调用 getCertificate 回调(默认使用 TLSConfig.Certificates),若为空则尝试从 TLSConfig.NextProtos 等推导——但 ListenAndServeTLS 初始化时已将证书注入 TLSConfig.Certificates,实际由 tls.loadKeyPair 延迟执行解析。

阶段 是否读磁盘 是否校验证书链 错误可见性
启动调用 否 否 无
Handshake() 是 是 连接级 panic
graph TD
    A[ListenAndServeTLS] --> B[net.Listen + http.Serve]
    B --> C[accept 新连接]
    C --> D[conn.serve]
    D --> E[conn.handshake]
    E --> F[tls.Server 构造]
    E --> G[tlsConn.Handshake]
    G --> H[loadKeyPair: 读 cert/key 文件]
    H --> I[解析 PEM → x509.Certificate]

2.2 TLS配置被覆盖的典型场景:DefaultServeMux与自定义Server冲突验证

当同时使用 http.DefaultServeMux 和显式配置 *http.Server 时,TLS 配置可能意外失效。

默认复用陷阱

Go 的 http.ListenAndServeTLS 内部会创建新 Server 实例并绑定到 DefaultServeMux,但若此前已启动自定义 Server 实例(如监听同一端口),后者将接管连接,而其 TLSConfig 若未显式设置,则为 nil —— 导致握手失败。

冲突复现代码

mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(200)
})

// ❌ 错误:DefaultServeMux 被忽略,自定义 server 无 TLSConfig
srv := &http.Server{
    Addr:    ":443",
    Handler: mux, // 未设 TLSConfig → 明文监听
}
go srv.ListenAndServe() // panic: listen tcp :443: bind: permission denied (if no root)

// ✅ 正确:显式注入 TLS 配置
srv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}

逻辑分析:ListenAndServe() 不检查 TLSConfig 是否为空;若未设置,底层 net.Listener 无法完成 TLS 握手,连接直接中断。参数 TLSConfig 是 TLS 协商的核心凭证容器,缺失即退化为 HTTP。

场景 DefaultServeMux 参与 TLSConfig 设置 结果
仅 ListenAndServeTLS ✅ 自动绑定 ✅ 内置 成功
自定义 Server + 无 TLSConfig ❌ 未生效 ❌ 空指针 握手失败

graph TD A[启动自定义 Server] –> B{TLSConfig != nil?} B –>|否| C[Accept 连接但 TLS 握手失败] B –>|是| D[协商证书/版本/密钥交换]

2.3 HTTP/2启用对TLS配置生效性的隐式干扰实验

HTTP/2协议强制要求TLS(ALPN协商),但其启用会悄然覆盖部分TLS配置的预期行为。

ALPN协商优先级覆盖现象

当服务器同时启用HTTP/2与TLS 1.2/1.3时,OpenSSL的SSL_CTX_set_alpn_protos()调用可能被Nginx或Envoy的ALPN默认策略静默覆盖:

// 示例:显式设置ALPN,但HTTP/2模块可能重写
const unsigned char alpn[] = "\x02h2\x08http/1.1";
SSL_CTX_set_alpn_protos(ctx, alpn, sizeof(alpn)-1); // 注意:长度不含首字节长度字段

此代码中sizeof(alpn)-1用于跳过首字节长度前缀;若未严格匹配ALPN格式(RFC 7301),HTTP/2栈将回退至默认h2,导致TLS层配置失效。

关键配置冲突点

干扰项 HTTP/1.1行为 HTTP/2启用后行为
TLS 1.2降级支持 允许协商 强制TLS 1.3+(取决于实现)
OCSP Stapling 可手动关闭 某些代理自动启用

协议栈交互流程

graph TD
A[Client Hello] --> B{ALPN Extension?}
B -->|Yes| C[HTTP/2 Negotiation]
B -->|No| D[HTTP/1.1 Fallback]
C --> E[TLS Config Override]
D --> F[Respect TLS Settings]

2.4 自签名证书与CA信任链缺失导致的静默降级复现实战

当客户端(如 curl 或浏览器)访问使用自签名证书的 HTTPS 服务时,若未显式跳过验证,将直接终止连接;但某些 SDK(如旧版 OkHttp、Node.js 的 https.Agent 默认配置)在遭遇证书错误时可能静默回退至 HTTP,造成协议降级。

复现环境搭建

# 生成自签名证书(无 CA 签发)
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
# 启动 HTTPS 服务(Python http.server 扩展)
python3 -m http.server 8000 --bind 127.0.0.1:8000 --cert cert.pem --key key.pem

此命令启动的 http.server 使用自签名证书,无上级 CA,系统信任链断裂。--cert 和 --key 参数指定证书路径,--bind 强制绑定本地地址以避免端口冲突。

静默降级触发条件

  • 客户端禁用证书校验(如 curl -k)→ 明确绕过,非静默
  • 客户端 SDK 内置 fallback 逻辑(如某些 IoT 设备固件)→ 真实静默降级场景
组件 是否默认校验 降级行为
curl 是 报错退出(不降级)
OkHttp 3.12 是 抛出 SSLHandshakeException
某嵌入式 SDK 否 自动重试 HTTP(无日志)
graph TD
    A[HTTPS 请求] --> B{证书可信?}
    B -->|否| C[尝试 HTTP 回退]
    B -->|是| D[建立 TLS 连接]
    C --> E[静默发送明文]

2.5 多端口HTTPS服务中TLS配置隔离失败的调试追踪

当同一进程监听多个HTTPS端口(如 :443 和 :8443)时,若共用全局 TLS 配置,易导致证书错绑或 ALPN 协商冲突。

常见诱因分析

  • Go http.Server 未为各端口独立设置 TLSConfig
  • Nginx 中 server { listen 443 ssl; } 与 listen 8443 ssl; 共享 ssl_certificate 指令
  • 容器化部署中 ConfigMap 覆盖多端口配置

Go 服务典型错误配置

// ❌ 错误:全局复用同一 *tls.Config
var globalTLS = &tls.Config{Certificates: certs}
srv1 := &http.Server{Addr: ":443", TLSConfig: globalTLS}
srv2 := &http.Server{Addr: ":8443", TLSConfig: globalTLS} // 隔离失效!

此处 globalTLS 被两个 Server 实例共享,GetCertificate 回调无法按 ClientHello.ServerName 动态路由证书;需为每个 Server 构造独立 tls.Config 并启用 NextProtos/GetCertificate。

验证工具链

工具 用途 示例命令
openssl s_client 检查特定端口证书链 openssl s_client -connect example.com:8443 -servername api.example.com
curl -v 观察 ALPN 协商结果 curl -v --alpn http/1.1 https://example.com:8443
graph TD
    A[Client Hello] --> B{SNI 匹配}
    B -->|匹配成功| C[加载对应证书]
    B -->|无SNI或未命中| D[回退默认证书]
    C --> E[ALPN 协商]
    D --> F[可能ALPN不兼容]

第三章:连接生命周期管理失效的关键路径

3.1 KeepAlive参数在TCP层与HTTP/1.1语义间的映射失配验证

HTTP/1.1 的 Connection: keep-alive 仅表示逻辑连接复用意愿,而 TCP 层的 SO_KEEPALIVE 是底层保活探测机制,二者无语义绑定。

TCP KeepAlive 参数配置示例

int enable = 1;
int idle = 60;     // 首次探测前空闲秒数(Linux默认7200s)
int interval = 5;  // 探测间隔(秒)
int probes = 3;    // 失败重试次数
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &enable, sizeof(enable));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &probes, sizeof(probes));

该配置使TCP在60秒空闲后启动心跳,但HTTP客户端可能已在30秒超时后主动关闭逻辑连接——此时TCP保活仍在运行,造成“幽灵连接”。

HTTP/1.1 与 TCP 保活关键差异对比

维度 HTTP/1.1 Keep-Alive TCP SO_KEEPALIVE
触发条件 请求头显式声明 内核自动触发(空闲后)
超时控制权 应用层(如Keep-Alive: timeout=30) 内核参数,应用不可动态调整
连接终止信号 RST/FIN 由应用发起 仅探测失败后关闭套接字

协议栈行为错位示意

graph TD
    A[HTTP Client 发送 Request] --> B[HTTP Server 返回响应+Keep-Alive头]
    B --> C{TCP空闲60s}
    C --> D[TCP发送ACK探测包]
    D --> E[HTTP层早已关闭逻辑连接]
    E --> F[内核仍维持socket状态]

3.2 连接空闲超时与客户端Keep-Alive头协商不一致的抓包分析

当Nginx配置 keepalive_timeout 30s,而客户端发送 Connection: keep-alive 且未携带 Keep-Alive: timeout=60 时,双方对连接保活窗口的认知产生偏差。

抓包关键现象

  • 客户端在第28秒发起新请求,服务端仍接受(因自身计时器未超)
  • 第32秒客户端重发FIN,服务端响应RST(其30秒计时器已过期)

超时参数对比表

实体 配置项 实际生效值 行为触发点
服务端(Nginx) keepalive_timeout 30s 空闲30s后主动关闭连接
客户端(Chrome) Keep-Alive: timeout=60(未发送) 默认约5–10s(实现依赖) 依赖TCP栈及应用层策略
GET /api/data HTTP/1.1
Host: example.com
Connection: keep-alive
# 缺失 Keep-Alive: timeout=XX, max=XXX 头

此请求未声明保活意图,服务端仅依据自身timeout决策;客户端却按自身默认策略维持连接,导致第31秒时状态不同步。

协商失效流程

graph TD
    A[客户端发Keep-Alive请求] --> B{是否含Keep-Alive头?}
    B -->|否| C[服务端按自身timeout计时]
    B -->|是| D[尝试协商timeout/max]
    C --> E[30s后服务端close socket]
    E --> F[客户端未知此决策→RST]

3.3 Go 1.19+中net/http.Server内部连接池重构对KeepAlive行为的影响实测

Go 1.19 起,net/http.Server 将连接管理从 conn 级抽象提升至 http2.connPool 与 http1.connState 统一调度层,keep-alive 生命周期不再仅依赖 ReadTimeout,而由 idleConnTimeout(默认 5m)与 maxConnsPerHost 协同控制。

连接复用状态变迁

srv := &http.Server{
    Addr: ":8080",
    IdleTimeout: 30 * time.Second, // 替代旧版 KeepAlive timeout
    ReadTimeout: 10 * time.Second,
}

IdleTimeout 现为 KeepAlive 的核心裁决者:空闲连接在无请求后等待该时长即关闭;ReadTimeout 仅约束单次读操作,不终止连接。

行为差异对比(Go 1.18 vs 1.21)

特性 Go 1.18 Go 1.21+
KeepAlive 控制字段 KeepAliveTimeout IdleTimeout
空闲连接清理时机 定期轮询扫描 基于 channel timer 精确触发
并发连接回收粒度 按 conn goroutine 按 connection state 状态机

连接生命周期流程

graph TD
    A[新连接建立] --> B{有活跃请求?}
    B -- 是 --> C[处理请求]
    B -- 否 --> D[进入 idle 状态]
    D --> E[启动 IdleTimeout timer]
    E --> F{超时?}
    F -- 是 --> G[关闭连接]
    F -- 否 --> H[接收新请求 → 回到 B]

第四章:请求头解析边界控制失效的技术溯源

4.1 MaxHeaderBytes参数在HTTP/1.1解析器中的实际截断点定位

HTTP/1.1解析器对请求头大小存在硬性限制,MaxHeaderBytes即为Go标准库net/http.Server中控制该上限的核心字段(默认值为1 MiB)。

截断行为触发条件

当请求头总字节数(含CRLF、冒号、空格及所有字段名与值)严格超过MaxHeaderBytes时,解析器在读取完第MaxHeaderBytes字节后立即终止,并返回400 Bad Request(http.ErrHeaderTooLong)。

实际截断点验证示例

srv := &http.Server{
    Addr: ":8080",
    MaxHeaderBytes: 1024, // 设为1KB便于观测
}

此配置下,若请求头累计写入1025字节(如构造超长Cookie),readRequest函数将在第1024字节处完成最后一次有效读取,第1025字节被丢弃且不进入header map——截断点精确落在MaxHeaderBytes字节边界,而非字段逻辑边界。

关键特性对比

行为维度 表现
截断粒度 字节级,非字段/行级
错误响应状态码 400 Bad Request
是否缓冲剩余数据 否,底层连接可能仍可读
graph TD
    A[开始读取HTTP请求] --> B{Header字节数 ≤ MaxHeaderBytes?}
    B -->|是| C[继续解析header]
    B -->|否| D[立即返回ErrHeaderTooLong]
    D --> E[关闭当前连接读取流]

4.2 大Header攻击场景下参数失效的Wireshark+pprof联合诊断

当攻击者构造超长 HTTP Header(如 Cookie: a=1; b=2; ... 达 64KB)时,Go net/http 服务器可能跳过 ReadTimeout 参数生效,导致连接悬停。

网络层异常捕获

Wireshark 过滤表达式:

http.request.uri contains "api" && tcp.len > 8000

此过滤快速定位携带巨量 Header 的请求包;tcp.len > 8000 暗示应用层解析前已堆积大量未处理字节,ReadTimeout 因尚未进入 readRequest 阶段而未启动计时器。

性能热点追踪

pprof CPU profile 显示 net/http.(*conn).serve 占比 92%,但 time.AfterFunc 调用缺失——证实超时注册逻辑被绕过。

阶段 是否触发 ReadTimeout 原因
TCP 握手完成 否 计时器未启动
Header 解析中 否 bufio.Reader.ReadSlice 阻塞于 syscall
Body 读取阶段 是 req.Body.Read 已注册超时

联动诊断流程

graph TD
  A[Wireshark 捕获大Header包] --> B{Header长度 > 4KB?}
  B -->|Yes| C[pprof 查看 conn.serve 栈深]
  C --> D[确认 readRequest 未返回]
  D --> E[定位 bufio.Reader.readSlice 长阻塞]

4.3 HTTP/2帧解析绕过MaxHeaderBytes限制的协议层验证

HTTP/2通过二进制帧(FRAME)传输数据,头部压缩(HPACK)与流多路复用解耦了应用层头大小限制与协议层解析逻辑。

帧结构关键字段

  • Length(24位):指示负载字节数,独立于MaxHeaderBytes
  • Type(8位):0x01为HEADERS帧,携带压缩后的头部块
  • Flags含END_HEADERS,但不触发Go标准库的maxHeaderBytes校验——该限制仅作用于解压后、拼接前的单次hpack.Decoder.Write()

Go net/http 服务端校验盲区

// src/net/http/h2_bundle.go 中实际校验点(简化)
func (d *decodeState) writeFrame(f *Frame) {
    if f.Type == HeadersFrame {
        // ⚠️ 此处未检查原始帧payload长度,
        // MaxHeaderBytes仅在hpack.Decode()逐段解压时累加校验
        d.hpackDec.Write(f.HeaderBlockFragment)
    }
}

逻辑分析:HeaderBlockFragment可被拆分为多个CONTINUATION帧,每段均小于MaxHeaderBytes,但总和远超限制;hpack.Decoder仅对每次Write()的片段做局部检查,无跨帧累计机制。

绕过路径示意

graph TD
    A[客户端发送 HEADERS帧] --> B[Payload分片为3段]
    B --> C[每段<1MB MaxHeaderBytes]
    C --> D[服务端逐段解压并累积]
    D --> E[最终解压头总长>8MB → OOM]
帧类型 是否触发MaxHeaderBytes校验 触发时机
HEADERS 否(仅校验片段) hpack.Decoder.Write()调用时
CONTINUATION 否 同上,独立计数
DATA 否 完全不参与头校验

4.4 自定义ReadTimeout与MaxHeaderBytes协同失效的压测复现

在高并发场景下,ReadTimeout 与 MaxHeaderBytes 的组合配置可能因底层 net/http 读取逻辑耦合而意外失效。

失效触发条件

  • ReadTimeout 设置过短(如 < 500ms)
  • MaxHeaderBytes 显式设为较小值(如 1024)
  • 请求头体积接近但未超限(如 1018B),且网络延迟抖动 ≥ ReadTimeout

复现代码片段

srv := &http.Server{
    Addr:           ":8080",
    ReadTimeout:    300 * time.Millisecond, // 关键:过短
    MaxHeaderBytes: 1024,                   // 关键:边界值
    Handler:        http.HandlerFunc(handler),
}

该配置下,当 TCP 包分片到达时,readRequest 可能因 ReadTimeout 中断在 header 解析中途,导致连接提前关闭,而 MaxHeaderBytes 校验尚未执行——二者校验阶段错位。

压测结果对比(1000 QPS,Header=1018B)

配置组合 超时错误率 Header截断率 实际生效校验
ReadTimeout=300ms 23.7% 0% 仅超时
ReadTimeout=2s 0% 100% 仅长度限制
graph TD
    A[Accept conn] --> B{read header loop}
    B --> C[read chunk]
    C --> D{timeout?}
    D -- Yes --> E[close conn]
    D -- No --> F{header bytes > MaxHeaderBytes?}
    F -- Yes --> G[400 Bad Request]
    F -- No --> H[parse headers]

第五章:构建健壮HTTP Server的工程化配置范式

配置分层与环境隔离

生产级 HTTP Server 必须支持多环境(dev/staging/prod)差异化配置。以 Express + dotenv 为例,采用 .env 基础文件 + 环境后缀扩展(.env.production, .env.staging),配合 dotenv-expand 实现变量继承。关键配置项如 PORT, NODE_ENV, DB_URI, JWT_SECRET 均通过 process.env 注入,禁止硬编码。以下为典型配置结构:

层级 配置来源 优先级 示例用途
应用默认值 config/default.js 最低 timeout: 30000
环境覆盖 .env.* 文件 中等 LOG_LEVEL=warn(staging)
运行时注入 Docker -e 或 Kubernetes ConfigMap 最高 REDIS_URL=redis://prod-cluster:6379

中间件加载的声明式编排

避免传统 app.use() 的线性堆叠,改用模块化中间件注册器。定义 middleware/index.js 统一导出按职责分组的中间件数组:

// middleware/index.js
module.exports = {
  preRequest: [cors(), helmet(), rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })],
  auth: [passport.authenticate('jwt', { session: false })],
  errorHandling: [errorHandler(), notFoundHandler()]
};

主服务启动时按阶段加载:app.use(...middleware.preRequest) → 路由注册 → app.use(...middleware.auth),确保安全策略在路由解析前生效。

健康检查与就绪探针标准化

Kubernetes 生产部署要求 /healthz(Liveness)与 /readyz(Readiness)端点严格分离。前者仅检查进程存活(如 res.status(200).send('OK')),后者需验证下游依赖:

graph LR
A[/readyz 请求] --> B[检查 PostgreSQL 连接]
A --> C[检查 Redis ping]
A --> D[验证 JWT 密钥轮换状态]
B & C & D --> E{全部成功?}
E -->|是| F[res.status(200)]
E -->|否| G[res.status(503)]

日志结构化与上下文透传

使用 pino 替代 console.log,结合 pino-http 自动注入请求ID、响应时间、HTTP状态码。关键实践:

  • 所有日志必须包含 reqId 字段(通过 express-pino-logger 自动生成)
  • 异步操作中通过 AsyncLocalStorage 透传上下文,避免日志丢失关联性
  • 错误日志强制包含 err.stack 与 err.code(如 ECONNREFUSED)

TLS 证书自动续期集成

在 Nginx 反向代理层启用 Let’s Encrypt ACME 协议,通过 certbot --nginx --deploy-hook 触发服务重载。配置片段示例:

# /etc/nginx/sites-enabled/app.conf
server {
  listen 443 ssl http2;
  ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
  # 自动续期后执行:systemctl reload nginx && systemctl reload node-app
}

连接池与超时熔断策略

PostgreSQL 客户端配置连接池参数:max: 20, min: 5, acquireTimeoutMillis: 5000, idleTimeoutMillis: 30000;HTTP 客户端统一设置 timeout: { response: 10000, deadline: 15000 };对第三方 API 调用启用 circuit-breaker(如 opossum),失败率阈值设为 40%,半开状态持续 60 秒。

记录分布式系统搭建过程,从零到一,步步为营。

发表回复

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