Posted in

Go HTTP client超时崩溃案例深度复盘(生产环境血泪实录)

第一章:Go HTTP client超时崩溃案例深度复盘(生产环境血泪实录)

凌晨三点,核心支付网关突现 100% CPU 占用与连接耗尽,下游服务批量超时。排查发现:并非高并发压测,而是单个异常订单触发了 http.DefaultClient 的隐式无限等待——该客户端未设置任何超时,当第三方风控接口因网络抖动返回半开放 TCP 连接(SYN-ACK 后无 FIN)时,RoundTrip 长期阻塞在 readLoop,goroutine 泄露叠加,最终耗尽系统资源。

根本原因定位

  • Go 1.18+ 默认 http.DefaultClient 的 Timeout 字段为零值,意味着无读/写/连接超时约束
  • net/http 底层使用 net.Conn.Read(),在连接已建立但对端静默时,将无限期等待数据到达(OS 层级阻塞)
  • GOMAXPROCS=4 下,200+ 持久化 goroutine 卡死在 runtime.gopark,无法被调度回收

关键修复代码

// ✅ 正确做法:显式配置全链路超时
client := &http.Client{
    Timeout: 10 * time.Second, // 总超时(含 DNS、连接、TLS、发送、响应头)
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,  // TCP 连接建立超时
            KeepAlive: 30 * time.Second, // TCP keep-alive 间隔
        }).DialContext,
        TLSHandshakeTimeout: 5 * time.Second, // TLS 握手超时
        ResponseHeaderTimeout: 3 * time.Second, // 从发送完请求到收到响应头的超时
        ExpectContinueTimeout: 1 * time.Second, // 100-continue 等待超时
    },
}

生产验证 checklist

  • [ ] 使用 go tool trace 抓取崩溃前 60 秒 trace,筛选 block 事件确认 goroutine 阻塞点
  • [ ] 在 init() 中强制覆盖 http.DefaultClient(仅限遗留系统过渡期)
  • [ ] 通过 netstat -an | grep :443 | wc -l 监控 ESTABLISHED 连接数突增趋势
  • [ ] 在 CI 流程中加入静态检查:grep -r "http\.DefaultClient" --include="*.go" . | grep -v "Timeout"

⚠️ 注意:Timeout 字段不控制 Transport 内部各阶段超时,必须单独配置 Transport 子项;否则 Timeout=10s 可能因 DNS 解析卡顿 8 秒后才触发连接超时,实际仍远超预期。

第二章:Go标准库net/http超时机制源码级解析

2.1 DialContext底层阻塞与cancel信号传递路径分析

DialContext 的核心在于将 context.Context 的取消信号转化为底层网络连接的中断指令。

阻塞点与信号注入时机

net.Dialer.DialContext 在调用 dialContext 内部方法时,会启动 goroutine 执行实际拨号,并通过 select 同时监听:

  • 底层 conn, err := d.dialSingle(ctx, ...) 完成
  • ctx.Done() 通道关闭
select {
case <-ctx.Done():
    // 主动取消:关闭正在进行的 dialer socket(若已创建)
    if c != nil {
        c.Close() // 触发 syscall.EINTR 或 net.OpError
    }
    return nil, ctx.Err()
case conn := <-ch:
    return conn, nil
}

该 select 是阻塞解除的唯一入口;ctx.Err() 返回 context.Canceled 或 context.DeadlineExceeded,直接影响上层错误处理分支。

cancel信号传递链路

graph TD
    A[User calls DialContext with cancelable ctx] --> B[spawn dial goroutine]
    B --> C{select on ctx.Done() or dial result}
    C -->|ctx.Done()| D[Close in-flight fd via runtime poller]
    D --> E[syscall write/read returns EINTR/ECANCELED]
    E --> F[net.Error with Timeout()==true & Temporary()==false]

关键状态表

组件 取消前状态 取消后响应行为
net.Conn(未建立) nil 不创建,直接返回 error
poll.FD(已分配) active runtime_pollUnblock 清除等待队列
syscall.Connect blocking 被内核中断,返回 EINTR

2.2 Transport.RoundTrip中timeoutTimer的生命周期与竞态隐患

timeoutTimer 是 http.Transport 在 RoundTrip 中动态创建的 *time.Timer,其生命周期严格绑定于单次请求上下文。

创建与启动时机

timer := time.NewTimer(req.CancelTimeout()) // 实际为 req.Context().Done() 驱动
// 注意:非固定 timeout,而是由 context.Deadline 或 cancel channel 触发

该 timer 并非总被创建——仅当 req.Context() 非 context.Background() 且含 deadline/cancel 时才启用。若未显式设置超时,timer 为 nil,跳过后续清理逻辑。

竞态高发路径

  • ✅ 正常流程:timer.Stop() 在 defer 或响应接收后调用
  • ⚠️ 竞态场景:timer.C 被 goroutine 写入(触发 select 分支)的同时,主 goroutine 调用 Stop() —— time.Timer.Stop() 文档明确指出:返回 false 表示已触发,此时 channel 可能已被写入但尚未被消费
状态 Stop() 返回值 channel 是否已写入 风险
timer 未触发 true 否 安全,资源立即释放
timer 已触发(C false 是 select 可能读到陈旧信号

生命周期图谱

graph TD
    A[RoundTrip 开始] --> B{Context 有 Deadline?}
    B -->|是| C[NewTimer]
    B -->|否| D[skip timer]
    C --> E[启动 goroutine 监听 timer.C 或 ctx.Done()]
    E --> F[响应返回/错误/取消]
    F --> G[调用 timer.Stop()]
    G --> H{Stop() 返回 true?}
    H -->|是| I[安全释放]
    H -->|否| J[需额外 drain channel 防竞态]

2.3 Response.Body.Read未关闭引发的连接泄漏与超时失效实证

HTTP客户端在读取 Response.Body 后若未显式调用 Close(),底层 TCP 连接将无法归还至连接池,持续占用资源。

连接泄漏链路

resp, err := http.DefaultClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close() // ✅ 正确:确保释放
// ❌ 遗漏此行 → 连接永不复用

resp.Body 是 io.ReadCloser,其底层 net.Conn 在 Close() 时才触发 putIdleConn,否则连接滞留于 idleConn map 中。

超时失效表现

现象 根本原因
context deadline exceeded 频发 空闲连接耗尽,新请求阻塞等待
http: persistent connection closed 远端主动断连,客户端仍持旧引用

泄漏传播路径

graph TD
A[Do request] --> B[Read Body]
B --> C{Body.Close called?}
C -- No --> D[Conn stays idle]
D --> E[MaxIdleConns exhausted]
E --> F[New requests timeout]

关键参数:http.Transport.MaxIdleConnsPerHost(默认2)直接决定泄漏敏感度。

2.4 DefaultClient全局共享导致超时配置被意外覆盖的调试复现

现象复现:并发场景下的超时突变

当多个业务模块共用 http.DefaultClient 并分别调用 Timeout 设置时,后设置者会覆盖前者:

// 模块A(期望3s超时)
http.DefaultClient.Timeout = 3 * time.Second

// 模块B(误设为30s,影响全系统)
http.DefaultClient.Timeout = 30 * time.Second // ⚠️ 全局生效!

逻辑分析:http.DefaultClient 是包级全局变量,其 Timeout 字段为 time.Duration 值类型。赋值操作直接覆写内存值,无作用域隔离。所有后续 http.Get() 调用均继承该值。

关键差异对比

配置方式 是否线程安全 是否影响其他模块 推荐度
http.DefaultClient ❌ ✅ ⛔
自定义 &http.Client{} ✅ ❌ ✅

根本解决路径

// ✅ 正确做法:按需构造独立Client
clientA := &http.Client{
    Timeout: 3 * time.Second,
    Transport: &http.Transport{...},
}

使用独立实例可确保超时策略隔离,避免隐式耦合。

2.5 context.WithTimeout与http.Client.Timeout双机制叠加引发的panic传播链

当 context.WithTimeout 与 http.Client.Timeout 同时设置且超时值不一致时,底层 net/http 的 cancel 信号可能触发非预期的 panic 传播。

双超时机制冲突根源

  • http.Client.Timeout 控制整个请求生命周期(含 DNS、连接、TLS、读写)
  • context.WithTimeout 触发 req.Cancel 或 req.Context().Done(),由 transport.roundTrip 监听并提前终止

panic传播关键路径

client := &http.Client{
    Timeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.Get(ctx, "https://api.example.com")
// 若 transport 已释放 req.cancelCtx 但 context 已 cancel,可能 panic in runtime.goparkunlock

此处 resp 获取前,context 超时导致 cancelCtx 关闭,而 http.Transport 在 roundTrip 中尝试调用已释放的 ctx.Done() 引发 invalid memory address panic。

机制 超时起点 可中断阶段 风险点
Client.Timeout RoundTrip 开始 全流程 阻塞在 readLoop 时无法及时响应
context.WithTimeout ctx 创建时刻 任意阶段(含 DNS) cancel() 后 ctx 复用或 late-cancel 导致竞态
graph TD
    A[发起 HTTP 请求] --> B{context.Done() ?}
    B -->|是| C[transport.cancelRequest]
    C --> D[关闭底层 conn]
    D --> E[尝试唤醒 readLoop goroutine]
    E --> F[若 goroutine 已 exit,则 panic]

第三章:典型崩溃现场还原与核心错误日志归因

3.1 “net/http: request canceled”背后的真实goroutine阻塞栈溯源

该错误常被误判为客户端主动断开,实则多源于服务端 goroutine 在 http.Handler 中意外阻塞,导致 context.WithTimeout 触发 cancel。

阻塞常见场景

  • HTTP handler 中调用未设超时的 time.Sleep 或无缓冲 channel 写入
  • 数据库查询未配置 context 透传与 deadline
  • 第三方 SDK 忽略 ctx.Done() 监听

典型阻塞代码示例

func badHandler(w http.ResponseWriter, r *http.Request) {
    select {
    case <-time.After(5 * time.Second): // ❌ 硬编码休眠,无视 r.Context().Done()
        w.Write([]byte("done"))
    }
}

逻辑分析:time.After 不响应 r.Context().Done(),即使客户端已断开,goroutine 仍阻塞 5 秒;此时 net/http 检测到 r.Context().Err() == context.Canceled,记录 "request canceled" 并关闭连接,但阻塞 goroutine 未回收。

阻塞源 是否响应 ctx.Done() 是否可被 net/http 及时回收
time.Sleep 否 否
http.Client.Do(带 ctx) 是 是
sync.Mutex.Lock 否(需手动检测) 否
graph TD
    A[HTTP 请求抵达] --> B{Handler 执行}
    B --> C[启动 goroutine]
    C --> D[调用阻塞操作]
    D --> E[未监听 ctx.Done()]
    E --> F[客户端断开]
    F --> G[net/http 标记 canceled]
    G --> H[连接关闭,但 goroutine 仍在运行]

3.2 “i/o timeout”误判为网络问题——实际是TLS握手卡在crypto/rand读取熵池

当Go程序在容器或低熵环境(如Kubernetes InitContainer)中发起HTTPS请求时,net/http常返回模糊的i/o timeout错误,实则阻塞于crypto/tls.(*Conn).Handshake内部对crypto/rand.Read()的调用。

熵池耗尽的典型表现

  • /dev/random 阻塞式读取,无足够熵时永久挂起
  • strace -e trace=open,read,ioctl 可见进程卡在read(3, ...)系统调用

Go TLS握手关键路径

// 源码简化示意(src/crypto/tls/handshake_client.go)
func (c *Conn) clientHandshake(ctx context.Context) error {
    // ... 
    c.config.rand = rand.Reader // 默认指向 crypto/rand.Reader
    // ↓ 此处可能无限阻塞
    if _, err := io.ReadFull(c.config.rand, key[:]); err != nil {
        return err // 实际错误被吞没,超时后返回 i/o timeout
    }
}

crypto/rand.Reader底层调用/dev/random(Linux)或getrandom(2)(内核4.17+),若熵池/dev/random将阻塞。

解决方案对比

方案 原理 风险
GODEBUG=randseed=0 强制使用/dev/urandom 安全性略降(但现代Linux中/dev/urandom已足够安全)
seccomp白名单getrandom 允许非阻塞系统调用 需内核≥3.17且容器配置支持
注入crypto/rand.Reader = &urandomReader{} 替换全局rand源 需提前初始化,影响所有TLS连接
graph TD
    A[HTTP Client Do] --> B[TLS Handshake]
    B --> C[crypto/rand.Read]
    C --> D{/dev/random 可读?}
    D -- 是 --> E[生成密钥]
    D -- 否 --> F[永久阻塞]
    F --> G[i/o timeout 报错]

3.3 panic: send on closed channel由http.Transport.IdleConnTimeout触发的并发写冲突

当 http.Transport.IdleConnTimeout 触发连接回收时,底层 persistConn 可能正与 goroutine 并发操作同一 channel。

数据同步机制

persistConn 中的 writeCh 用于调度写请求。Idle 超时调用 close() 后,若另一 goroutine 仍执行 writeCh <- req,即触发 panic。

// 模拟竞态场景(简化)
ch := make(chan int, 1)
close(ch)                 // IdleConnTimeout 触发
ch <- 42                  // panic: send on closed channel

→ close(ch) 使 channel 进入不可写状态;后续发送操作立即 panic,无缓冲区检查。

关键防护点

  • persistConn.roundTrip 在写前检查 pc.closed 原子标志
  • pc.closeLocked() 先设标志再 close channel,避免写端盲发
阶段 操作 安全性
超时检测 atomic.LoadUint32(&pc.closed) ✅
channel 关闭 close(pc.writeCh) ⚠️(需前置判空)
写请求调度 select { case pc.writeCh <- req: ... } ❌ 若未判 closed
graph TD
    A[IdleConnTimeout 触发] --> B{atomic.LoadUint32\\n&pc.closed == 0?}
    B -- 是 --> C[允许写入 writeCh]
    B -- 否 --> D[跳过写入,返回 error]

第四章:生产级HTTP客户端健壮性加固实践

4.1 自定义RoundTripper封装:超时熔断+连接池健康探测双校验

在高可用 HTTP 客户端设计中,原生 http.Transport 缺乏运行时连接健康反馈与服务级熔断能力。需通过组合式 RoundTripper 实现双重校验。

核心职责分离

  • 超时控制:基于 context.WithTimeout 封装请求生命周期
  • 熔断决策:依赖 gobreaker 状态机,失败率 > 60% 自动开启
  • 健康探测:空闲连接复用前执行轻量 HEAD /health 探活

自定义 RoundTripper 结构

type DualCheckTransport struct {
    base     http.RoundTripper // 底层 transport(如 http.DefaultTransport)
    breaker  *breaker.CircuitBreaker
    poolProber *HealthProber // 连接池探针
}

该结构将熔断器与探针解耦注入,便于单元测试与策略替换;breaker 控制请求准入,poolProber 在 RoundTrip 前校验复用连接有效性。

健康探测响应码映射表

状态码 含义 是否视为健康
200 服务就绪 ✅
503 临时不可用 ❌
其他 协议异常 ❌

请求流程(mermaid)

graph TD
    A[Start RoundTrip] --> B{熔断器允许?}
    B -->|否| C[返回 ErrServiceUnavailable]
    B -->|是| D[获取连接]
    D --> E{连接是否需探活?}
    E -->|是| F[HEAD /health]
    F --> G{状态码 ∈ [200]?}
    G -->|否| H[标记连接失效,新建]
    G -->|是| I[发起原始请求]

4.2 基于context.Value的请求上下文透传与全链路超时继承方案

在微服务调用链中,需将初始请求的截止时间(Deadline)自动向下传递,避免各环节独立设置超时导致“超时错配”。

超时继承的核心机制

利用 context.WithDeadline 封装原始 context,并通过 context.WithValue 注入透传标识(如 ctx = context.WithValue(parent, keyRequestID, reqID)),确保下游可安全提取。

关键代码示例

// 从上游获取 deadline,构造带继承的子 context
deadline, ok := parent.Deadline()
if ok {
    childCtx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()
    // 向 childCtx 注入业务元数据
    childCtx = context.WithValue(childCtx, traceKey, traceID)
}

逻辑说明:parent.Deadline() 提取上游截止时间;WithDeadline 确保子 context 自动继承并触发取消;WithValue 仅用于只读元数据(非控制流),符合 context 最佳实践。

全链路超时一致性保障

组件 是否继承 Deadline 是否可修改超时
HTTP Handler ✅ ❌(只读)
gRPC Client ✅ ❌
DB Driver ✅ ❌
graph TD
    A[Client Request] -->|WithDeadline| B[API Gateway]
    B -->|propagate| C[Service A]
    C -->|propagate| D[Service B]
    D -->|auto-cancel on expiry| E[DB Query]

4.3 Body读取防御性封装:io.LimitReader + io.MultiReader + defer close组合模式

HTTP请求体(r.Body)若未加约束直接读取,易引发内存溢出、DoS攻击或资源泄漏。防御性封装需三重保障:限流、组合与终态清理。

限流:用 io.LimitReader 截断恶意长Body

// 限制最多读取 1MB 请求体
limited := io.LimitReader(r.Body, 1<<20)

io.LimitReader(reader, n) 在读取累计达 n 字节后返回 io.EOF,底层不缓冲,零拷贝拦截,n 应设为业务可接受的硬上限(如 JSON API 常设 512KB–2MB)。

组合:io.MultiReader 支持预置头信息注入

// 注入校验头 + 实际Body,统一读取入口
headerReader := strings.NewReader(`{"version":"1.0"}`)
bodyReader := io.MultiReader(headerReader, limited)

io.MultiReader 将多个 io.Reader 串联为单一流,常用于注入签名头、版本标记等元数据,避免手动拼接字节切片。

终态:defer r.Body.Close() 不可省略

defer func() {
    if err := r.Body.Close(); err != nil {
        log.Printf("failed to close body: %v", err)
    }
}()

Close() 释放底层连接资源(尤其 HTTP/1.1 keep-alive 场景),缺失将导致连接泄漏,defer 确保无论读取成功与否均执行。

组件 作用 安全价值
io.LimitReader 字节级硬限流 防止 OOM 和慢速攻击
io.MultiReader 多源 Reader 合并 解耦元数据注入与主体解析
defer Close() 连接资源确定性释放 避免连接池耗尽
graph TD
    A[HTTP Request] --> B[io.LimitReader]
    B --> C{是否超限?}
    C -->|是| D[立即返回 EOF]
    C -->|否| E[io.MultiReader]
    E --> F[Header + Body]
    F --> G[业务解析]
    G --> H[defer r.Body.Close]

4.4 单元测试+混沌工程验证:使用toxiproxy注入延迟/断连/重置故障场景

在服务间调用链中,仅靠单元测试难以覆盖网络异常。Toxiproxy 作为轻量级混沌代理,可在测试阶段精准模拟真实故障。

部署与基础配置

# 启动 toxiproxy 服务(默认监听 8474)
docker run -d -p 8474:8474 -p 2626:2626 --name toxiproxy shopify/toxiproxy

该命令启动代理服务,其中 2626 是代理端口,8474 是管理 API 端口;容器名便于后续集成测试脚本引用。

注入典型故障策略

故障类型 toxiproxy 命令示例 行为效果
延迟 curl -X POST http://localhost:8474/proxies/myapi/toxics -d '{"type":"latency","stream":"downstream","toxicity":1.0,"attributes":{"latency":3000}}' 强制下游响应延迟 3s
断连 curl -X POST http://localhost:8474/proxies/myapi/toxics -d '{"type":"timeout","stream":"downstream","toxicity":1.0,"attributes":{"timeout":500}}' 下游连接在 500ms 后超时
TCP 重置 curl -X POST http://localhost:8474/proxies/myapi/toxics -d '{"type":"turbulence","stream":"downstream","toxicity":1.0,"attributes":{"corrupt":0,"delay":0,"delay_variation":0,"packet_loss":0,"reset_probability":1.0}}' 概率性发送 RST 包中断连接

测试流程协同

# pytest + toxiproxy client 示例(伪代码)
def test_api_resilience():
    proxy = ToxicProxy("http://localhost:8474")
    proxy.add("myapi", "localhost:8080")  # 将本地服务接入代理
    proxy.inject("myapi", "latency", latency=5000)
    with pytest.raises(TimeoutError):
        requests.get("http://localhost:2626/api/data", timeout=2)

该测试先注册被测服务,再注入 5s 延迟毒剂,最后验证客户端是否在 2s 超时内主动失败——体现熔断与超时配置的有效性。

graph TD A[pytest 执行用例] –> B[调用 toxiproxy API 注入故障] B –> C[请求经 Toxiproxy 代理转发] C –> D{下游服务响应} D –>|正常/延迟/断连| E[客户端行为断言] E –> F[验证重试/降级/超时逻辑]

第五章:从事故到体系化防御的演进思考

一次真实勒索攻击的复盘断点

2023年Q3,某省级政务云平台遭遇Conti变种勒索软件攻击,初始入侵向量为未及时打补丁的Apache Flink UI(CVE-2023-26551)。攻击者利用默认配置暴露的REST API执行远程代码,横向移动至数据库服务器后加密PGDATA目录。事后溯源发现:漏洞扫描报告早在攻击前47天已生成,但工单系统中标记为“低风险”,且无闭环验证机制。

防御能力成熟度的三级跃迁

阶段 响应模式 工具链特征 平均MTTR
事故驱动 手动查日志+临时脚本 Splunk单点查询、无SOAR 18.2小时
流程驱动 标准化SOP+Playbook Elastic SIEM+Jira联动 6.5小时
体系驱动 自动化决策+预测阻断 XDR+威胁图谱+蜜网反馈闭环 22分钟

蜜网捕获的ATT&CK战术链路

flowchart LR
A[钓鱼邮件] --> B[PowerShell内存加载]
B --> C[LSASS进程注入]
C --> D[NTDS.dit哈希导出]
D --> E[域控横向渗透]
E --> F[备份服务器静默加密]

安全左移落地的硬性约束

某金融客户在CI/CD流水线嵌入SAST/DAST时,强制要求:① SonarQube高危漏洞阻断率100%;② OWASP ZAP扫描必须覆盖全部Swagger定义接口;③ 每次合并请求需通过Terraform安全检查(禁止security_groups = ["0.0.0.0/0"])。该策略上线后,生产环境SQL注入漏洞下降92%,但构建平均耗时增加3.7分钟——团队通过并行化扫描任务与缓存策略将延迟压缩至1.2分钟内。

红蓝对抗暴露的认知盲区

2024年某次实战攻防中,红队使用合法云服务API密钥(通过GitHub泄露)调用AWS Lambda创建隐蔽C2通道,绕过所有网络层检测。蓝队最终通过分析CloudTrail中异常的InvokeAsync调用频率突增(>200次/分钟)结合Lambda冷启动日志中的非常规UserAgent识别。此案例推动该企业建立API行为基线模型,将云原生资产的调用链纳入UEBA分析范围。

运维习惯重构的关键切口

某制造企业将“重启服务”操作从手动执行改为Ansible Playbook强制校验:每次重启前自动比对当前运行版本与制品库SHA256值,若不一致则拒绝执行并触发告警。三个月内拦截17次因误操作导致的降级部署,同时沉淀出3个核心服务的标准化启停checklist。

威胁情报的本地化适配实践

将MISP平台接入本地SIEM后,并非直接应用原始IOC,而是建立三层过滤规则:第一层剔除TLP:RED以外的私有情报;第二层匹配本地资产指纹(如仅保留影响Oracle 19c RAC集群的漏洞);第三层关联历史攻击时间窗(过滤掉已失效的C2域名)。该机制使有效情报命中率从11%提升至68%。

安全度量指标的反脆弱设计

放弃单纯统计“漏洞修复数量”,转而监控三个韧性指标:① 关键业务链路在漏洞披露后24小时内完成热修复的比例;② 安全策略变更引发的SLO劣化次数;③ 红队绕过现有控制措施所需平均步骤数。当第三项指标连续两季度低于5步时,自动触发防御体系重构评审。

人机协同的决策边界划分

在SOC运营中明确:机器负责毫秒级响应(如WAF自动封禁IP)、分钟级研判(如EDR进程树异常聚类);人类专注小时级决策(如是否隔离核心数据库节点)。某次事件中,AI引擎在17秒内判定为APT组织活动,但将“是否启用蜜罐诱捕”交由值班工程师决策——其依据是当前产线订单峰值状态,避免蜜罐流量干扰实时控制系统。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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