Posted in

Go数据库连接池失控真相:maxOpen/maxIdle/maxLifetime参数组合的8种致命配置

第一章:Go数据库连接池失控的根源剖析

Go 应用中数据库连接池看似简单,却常因隐式行为与配置失配引发严重问题:连接泄漏、连接耗尽、响应延迟陡增,甚至服务雪崩。其失控并非源于代码逻辑错误,而是对 database/sql 包底层机制的误读与配置疏忽。

连接池生命周期脱离应用管控

sql.DB 实例本身不持有连接,而是管理连接池及连接创建/回收策略。但开发者常误以为调用 db.Close() 即释放全部资源——实际上它仅标记池为“已关闭”,后续新请求会立即返回错误,而已建立但空闲的连接仍驻留数分钟(默认 MaxIdleTime 30 分钟),期间无法被复用或强制回收。若服务频繁重启而未显式等待连接自然超时,将导致数据库端堆积大量 idle in transactionidle 状态连接。

配置参数间的隐式耦合关系

以下关键参数非独立生效,需协同调整:

参数 默认值 风险点
SetMaxOpenConns(n) 0(无限制) 设为过小值引发请求排队;设为过大可能压垮数据库
SetMaxIdleConns(n) 2 若小于 MaxOpenConns,空闲连接不足将频繁新建连接
SetConnMaxLifetime(d) 0(永不过期) 不设将导致连接长期复用,易遇网络中断或数据库主动断连

连接泄漏的典型代码模式

以下写法看似安全,实则泄漏连接:

func badQuery(db *sql.DB) error {
    row := db.QueryRow("SELECT id FROM users WHERE name = ?", "alice")
    var id int
    if err := row.Scan(&id); err != nil {
        return err // ❌ 忘记检查 row.Err()!若查询失败,连接未归还池中
    }
    return nil
}

正确做法:始终检查 row.Err() 或使用 defer row.Close()(对 *sql.Row 无效,应改用 *sql.Rows);更推荐统一用 context 控制生命周期:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT id FROM users WHERE name = ?", "alice")
// 后续必须检查 row.Err() —— 它包含连接归还状态

连接池健康状态可观测性缺失

缺乏对连接池实时指标的采集,使问题滞后暴露。建议在启动时注入监控钩子:

db.SetConnMaxLifetime(10 * time.Minute)
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(25)
// 暴露指标供 Prometheus 抓取
http.HandleFunc("/metrics/db", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain")
    fmt.Fprintf(w, "db_open_connections %d\n", db.Stats().OpenConnections)
    fmt.Fprintf(w, "db_idle_connections %d\n", db.Stats().IdleConnections)
})

第二章:maxOpen参数的8种致命配置与实战修复

2.1 maxOpen=0导致连接无限创建的底层机制与复现验证

当连接池配置 maxOpen=0 时,HikariCP 将禁用最大活跃连接数限制,但未触发连接数兜底保护逻辑,导致每次获取连接均新建物理连接。

连接获取路径异常

// HikariPool.java 片段(简化)
public Connection getConnection(long timeout) throws SQLException {
    if (config.getMaxConnections() == 0) { // ← 此处未拦截,直接进入创建分支
        return newConnection(); // 无上限调用
    }
    // ... 其他池化逻辑被跳过
}

maxOpen=0 被解释为“不限制”,而非“禁止创建”,绕过所有容量校验,每 getConnection() 调用均触发 Driver.connect()

复现关键步骤

  • 配置 spring.datasource.hikari.maximum-pool-size=0
  • 并发发起 100 次 JDBC 查询
  • 观察进程句柄数持续飙升(lsof -p <pid> | wc -l

连接增长对比表

maxOpen 实际创建连接数(100次请求) 是否复用
10 ≤10
0 100
graph TD
    A[getConnection()] --> B{maxOpen == 0?}
    B -->|Yes| C[newConnection()]
    B -->|No| D[acquireFromPoolOrNew()]
    C --> E[OS socket fd +1]

2.2 maxOpen过小引发高并发请求排队阻塞的压测实证分析

压测现象复现

JMeter 500线程持续发送HTTP请求,HikariCP连接池监控显示:ActiveConnections=20, IdleConnections=0, ThreadsAwaitingConnection峰值达187,平均响应时间从82ms飙升至2.4s。

核心配置缺陷

# application.yml(问题配置)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20     # ✅ 合理上限
      max-lifetime: 1800000
      connection-timeout: 30000
      max-open: 10                # ❌ 关键错误:非标准参数,实际被忽略→默认值0→触发无限等待逻辑

max-open 并非 HikariCP 官方参数(正确参数为 maximum-pool-size),Spring Boot 2.4+ 会静默忽略该配置,导致底层使用 com.zaxxer.hikari.HikariConfig 默认 connectionTimeout=30000ms,但无有效连接供给时线程持续阻塞。

阻塞链路可视化

graph TD
    A[HTTP线程请求] --> B{HikariCP获取连接}
    B -->|池空且maxOpen无效| C[进入await()等待队列]
    C --> D[超时前持续阻塞]
    D --> E[线程堆积→CPU空转→拒绝服务]

参数修正对照表

参数名 错误值 正确值 作用说明
maximum-pool-size 未显式设置(默认10) 20 控制最大连接数,直接约束并发能力
connection-timeout 30000 5000 缩短等待阈值,快速失败而非阻塞

2.3 maxOpen远大于实际负载造成资源耗尽的内存泄漏追踪

当连接池 maxOpen 配置为 1000,而日均峰值仅 80 连接时,空闲连接长期驻留导致 GC 无法回收底层 Socket 与 Buffer 资源。

关键诊断线索

  • netstat -an | grep :3306 | wc -l 持续高于业务连接数
  • JVM 堆外内存(DirectByteBuffer)持续增长
  • jcmd <pid> VM.native_memory summary 显示 internal 区异常膨胀

典型错误配置示例

// 错误:过度预留连接,未启用连接驱逐
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(1000);      // ❌ 远超实际需求(P99=42)
config.setConnectionTimeout(30000);
config.setLeakDetectionThreshold(60000);

maximumPoolSize 并非并发上限,而是最大持有连接数。每个连接默认持有约 1.2MB 堆外内存(含 TLS buffer、socket R/W buffers),1000 连接即隐式占用 >1.2GB native memory,且不随业务低谷释放。

内存泄漏路径

graph TD
A[setMaxOpen=1000] --> B[连接创建后长期 idle]
B --> C[SocketChannel + DirectByteBuffer 未及时清理]
C --> D[FinalizerQueue 积压 → Finalizer 线程延迟执行]
D --> E[堆外内存持续泄漏]
参数 推荐值 说明
maximumPoolSize 2×P99 如 P99=42 → 设为 80~100
idleTimeout 10min 强制回收空闲连接
maxLifetime 30min 防止长连接状态漂移

2.4 maxOpen与goroutine数量失配引发调度风暴的pprof诊断

当数据库连接池 maxOpen=10,而并发发起 200 个 HTTP 请求并为每个请求启动独立 goroutine 执行 db.Query() 时,大量 goroutine 将阻塞在获取连接上。

goroutine 阻塞链路

// 示例:未控制并发的危险调用
for i := 0; i < 200; i++ {
    go func() {
        rows, _ := db.Query("SELECT * FROM users") // 若连接池已满,此处挂起
        defer rows.Close()
    }()
}

db.Query 内部调用 pool.acquireConn(),若 maxOpen 耗尽,则进入 select { case <-ctx.Done(): ... case <-p.ch: ... } 等待,导致 goroutine 持续处于 semacquire 状态。

pprof 定位关键指标

指标 正常值 失配风暴表现
goroutines 数百级 数千至上万
sched.latency >1ms(调度延迟飙升)
block profile 低占比 runtime.semacquire 占比超70%

调度风暴形成逻辑

graph TD A[200 goroutine 启动] –> B{尝试 acquireConn} B –>|maxOpen=10| C[10个获连执行] B –>|190个等待| D[排队进入 p.ch channel] D –> E[netpoll + gopark 频繁切换] E –> F[调度器过载 → GMP争抢加剧]

2.5 动态调整maxOpen的热更新实现与生产环境灰度验证

核心机制:配置监听与连接池重建

采用 Spring Boot Actuator + @RefreshScope 实现配置热感知,配合 HikariCP 的 setMaximumPoolSize() 安全重置接口,避免连接池重启导致的请求中断。

灰度验证策略

  • 按服务实例标签(env=gray)分批推送新 maxOpen
  • 监控指标:连接获取耗时 P99、活跃连接数、拒绝连接异常率

配置热更新代码示例

@Component
public class MaxOpenHotUpdater {
    private final HikariDataSource dataSource;

    public MaxOpenHotUpdater(HikariDataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 被 ConfigurationPropertiesBindingPostProcessor 触发调用
    public void updateMaxOpen(int newMax) {
        dataSource.setMaximumPoolSize(newMax); // 线程安全,Hikari 内部同步
        log.info("maxOpen updated to {}", newMax);
    }
}

setMaximumPoolSize() 在运行时动态扩容/缩容连接池,Hikari 会平滑迁移连接(保留空闲连接,拒绝新建连接直至旧连接自然归还),保障业务零感知。

灰度验证结果概览

环境 maxOpen P99 获取耗时 拒绝率
production 20 12ms 0.00%
gray-v1 30 8ms 0.00%
gray-v2 50 7ms 0.02%
graph TD
    A[配置中心变更] --> B{是否灰度标签匹配?}
    B -->|是| C[触发updateMaxOpen]
    B -->|否| D[忽略]
    C --> E[异步校验连接池健康状态]
    E --> F[上报监控指标]

第三章:maxIdle与maxLifetime协同失效的三大陷阱

3.1 maxIdle > maxOpen时连接泄露的源码级行为解析与修复

连接池核心约束冲突

maxIdle = 20maxOpen = 10 时,HikariCP 的 addConnection() 逻辑因违反“idle ≤ open”隐式契约,导致空闲连接无法被及时驱逐。

源码关键路径分析

// HikariPool.java#fillPool()
if (getActiveConnections() + getIdleConnections() < config.getMaximumPoolSize()) {
    addConnection(); // 此处未校验 maxIdle > maxOpen 的非法状态
}

该分支忽略 maxIdle 超限对 pruneConnection() 的干扰,使多余 idle 连接滞留于 connectionBag 中,无法进入 evict() 流程。

修复策略对比

方案 实现方式 风险
启动校验 validateConfig() 中抛 IllegalArgumentException 阻断非法配置,最安全
动态裁剪 setIdleTimeout() 时自动 clamp maxIdle = min(maxIdle, maxOpen) 兼容旧配置,但语义模糊

修复代码示例

// ConfigValidator.java(新增校验)
if (config.getMaxIdle() > config.getMaxOpen()) {
    throw new IllegalArgumentException(
        "maxIdle (" + config.getMaxIdle() + ") cannot exceed maxOpen (" 
        + config.getMaxOpen() + ")");
}

此校验在 HikariConfig 初始化阶段拦截,避免运行时状态不一致。参数 maxIdle 表示最大空闲连接数,maxOpen 是物理连接上限;二者逻辑上必须满足 maxIdle ≤ maxOpen,否则连接回收机制失效。

graph TD
    A[配置加载] --> B{maxIdle > maxOpen?}
    B -->|是| C[抛出 IllegalArgumentException]
    B -->|否| D[正常初始化连接池]

3.2 maxLifetime设置不当导致连接静默中断的TCP层抓包验证

当 HikariCP 的 maxLifetime 设置为 30 分钟(1800000ms),而底层 MySQL 服务器 wait_timeout=600(10分钟)时,连接池中存活超时的连接未被及时清除,导致应用复用已失效连接。

抓包现象特征

Wireshark 中可见:

  • 应用侧发送 TCP PSH+ACK 查询包
  • MySQL 侧无响应,仅触发 TCP Keep-Alive 探测(间隔 75s)
  • 最终连接被 RST 重置

关键配置对比

参数 HikariCP MySQL Server
maxLifetime 1800000 ms
wait_timeout 600 s

复现代码片段

HikariConfig config = new HikariConfig();
config.setMaxLifetime(1800000); // ⚠️ 超过数据库 wait_timeout
config.setConnectionTestQuery("SELECT 1"); // 仅在借用时校验,不防静默失效

此配置使连接池误判连接健康状态——connectionTestQuery 在获取连接时执行,但若连接在归还后、下次借用前被 DB 主动关闭,则测试无法覆盖该窗口期。

TCP 状态流转示意

graph TD
    A[应用获取连接] --> B[连接处于 ESTABLISHED]
    B --> C{MySQL wait_timeout 触发}
    C --> D[MySQL 发送 FIN/RST]
    D --> E[连接池仍认为 VALID]
    E --> F[下次借用 → 查询失败]

3.3 空闲连接过早驱逐引发重连风暴的metrics监控与熔断策略

核心监控指标体系

需重点采集三类时序指标:

  • pool.idle_connections(当前空闲连接数)
  • pool.evictions_total{reason="idle_timeout"}(因空闲超时被驱逐次数)
  • connection.reconnects_per_sec(每秒重连速率,突增即预警)

动态熔断触发逻辑

# Prometheus告警规则片段(PromQL)
ALERT ConnectionEvictionStorm
  IF rate(pool_evictions_total{reason="idle_timeout"}[1m]) > 50
    AND rate(connection_reconnects_per_sec[1m]) > 30
  FOR 30s
  LABELS {severity="critical"}
  ANNOTATIONS {summary="空闲驱逐引发重连风暴"}

该规则捕获双指标协同异常:单位时间内空闲驱逐频次与重连速率同步超标,表明连接池配置与下游服务心跳不匹配,非单纯流量激增。

熔断响应流程

graph TD
  A[指标超阈值] --> B{是否连续2个窗口触发?}
  B -->|是| C[自动降级:关闭空闲驱逐]
  B -->|否| D[仅告警]
  C --> E[启用连接保活探测]
  E --> F[30s后评估恢复条件]
配置项 推荐值 说明
maxIdleTimeMs heartbeatInterval * 3 避免早于服务端心跳超时驱逐
evictionCheckIntervalMs ≥ 60000 降低驱逐检查频率,缓解CPU抖动

第四章:组合参数的交叉致灾场景与工程化防御体系

4.1 maxOpen=10 + maxIdle=5 + maxLifetime=1m的雪崩链路复现

当连接池配置为 maxOpen=10maxIdle=5maxLifetime=1m 时,短生命周期连接与高并发请求易触发级联超时。

连接生命周期冲突

  • 每个连接存活仅60秒,但业务请求耗时波动(如DB慢查询达800ms)
  • 连接复用率下降 → 频繁创建新连接 → 快速触达 maxOpen=10 上限

关键参数行为表

参数 实际影响
maxOpen 10 并发连接数硬上限,第11个请求阻塞等待
maxIdle 5 空闲连接最多保留5个,其余立即销毁
maxLifetime 1m 所有连接强制回收,无视空闲状态
// HikariCP 典型配置片段(含注释)
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(10);     // 对应 maxOpen
config.setMinimumIdle(5);         // 对应 maxIdle
config.setMaxLifetime(60_000);    // 单位毫秒 → 1分钟

该配置导致连接在 maxLifetime 到期前已因 maxIdle 被提前驱逐;到期瞬间大量连接集中重建,叠加 maxOpen 限制,形成请求排队→超时→重试→流量放大雪崩闭环。

graph TD
A[请求涌入] --> B{连接池可用?}
B -- 否 --> C[阻塞等待]
B -- 是 --> D[分配连接]
D --> E[执行SQL]
E --> F[连接归还]
F --> G{空闲数 > 5?}
G -- 是 --> H[销毁多余连接]
G -- 否 --> I[缓存至idle队列]
H --> J[1分钟后强制close所有]
J --> K[新建连接激增]
K --> A

4.2 maxOpen=100 + maxIdle=0 + maxLifetime=0的连接饥饿死锁调试

当连接池配置为 maxOpen=100maxIdle=0maxLifetime=0 时,连接永不过期且不缓存空闲连接,极易触发资源争用。

连接生命周期失控表现

  • 所有连接创建后永不释放(maxLifetime=0 → 永不回收)
  • maxIdle=0 导致无缓冲余量,每次获取均需新建或复用活跃连接
  • 高并发下快速耗尽 maxOpen=100 上限,后续请求阻塞等待

典型堆栈特征

// HikariCP 等待线程堆栈片段
"pool-1-thread-45" #45 prio=5 waiting on condition
  at sun.misc.Unsafe.park(Native Method)
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
  at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)

此处 HikariPool.getConnection() 阻塞在 semaphore.tryAcquire(),表明连接池已满且无空闲连接可分配。

关键参数影响对比

参数 含义 风险
maxOpen 100 最大并发连接数 达限时请求排队
maxIdle 0 不保留空闲连接 无法复用,加剧创建压力
maxLifetime 0 连接永不过期 连接泄漏风险放大

死锁路径可视化

graph TD
    A[应用请求连接] --> B{池中可用连接?}
    B -- 是 --> C[分配连接]
    B -- 否 --> D[阻塞等待信号量]
    D --> E[等待超时或被唤醒]
    C --> F[使用后归还]
    F -->|maxIdle=0| G[立即销毁]
    G --> B

4.3 maxOpen=50 + maxIdle=50 + maxLifetime=30s的TIME_WAIT堆积根因定位

TCP连接生命周期与连接池配置冲突

maxLifetime=30s 过短,而下游服务响应延迟波动(如 P99=280ms),连接在被归还前即被强制关闭,触发主动 FIN。此时连接进入 TIME_WAIT 状态(Linux 默认 60s),但连接池仍按 maxIdle=50 持续创建新连接以满足负载——导致端口耗尽。

关键参数行为分析

HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50);      // maxOpen
config.setMinimumIdle(50);         // maxIdle → 池始终维持50空闲连接
config.setMaxLifetime(30_000);     // 30s后强制 evict,无视是否在使用中

⚠️ maxLifetime=30s 使活跃连接在未完成业务时被中断,SO_LINGER=0 导致 RST 发送,加剧 TIME_WAIT 积压。

TIME_WAIT 分布特征(netstat 统计)

状态 数量 占比 主要端口范围
TIME_WAIT 2840 92.1% 32768–33267
ESTABLISHED 50 1.6%

根因链路

graph TD
A[业务请求激增] --> B{连接池维持 maxIdle=50}
B --> C[maxLifetime=30s 触发强制关闭]
C --> D[主动关闭 → TIME_WAIT]
D --> E[内核未复用端口:net.ipv4.tcp_tw_reuse=0]

4.4 基于sql.DB Stats的实时参数健康度评估与自动调优框架

sql.DB 提供的 Stats() 方法返回 sql.DBStats 结构体,包含连接池状态、等待/打开连接数、查询延迟分布等关键运行时指标,是构建轻量级自适应调优系统的核心数据源。

核心指标采集逻辑

stats := db.Stats()
// 每5秒采样一次,避免高频调用影响性能
if stats.WaitCount > 0 && float64(stats.WaitCount)/float64(stats.MaxOpenConnections) > 0.3 {
    // 连接等待率超阈值 → 触发健康度降级信号
}

逻辑说明:WaitCount 表示因连接池耗尽而阻塞等待的请求数;MaxOpenConnections 是硬上限。比值 >0.3 表明连接池持续承压,需动态扩容或慢查询干预。

健康度评估维度

  • 连接池健康度Idle/InUse 比率 + WaitDuration 中位数
  • 查询响应健康度:基于 sql.DB 无原生延迟统计,需结合 context.WithTimeoutprometheus.Histogram 补充
  • ⚠️ 事务吞吐健康度:依赖外部 tx.Begin()/Commit() 计数器联动

自动调优决策表

指标异常类型 健康分(0–100) 推荐动作
WaitCount 突增 SetMaxOpenConns(n*1.2)
Idle 启动慢SQL探针 + SetConnMaxLifetime 缩短
graph TD
    A[每5s采集db.Stats] --> B{WaitCount/MaxOpen > 0.3?}
    B -->|Yes| C[触发健康度评分]
    C --> D[查慢SQL Top3 + 连接泄漏检测]
    D --> E[执行分级调优:扩池/限流/告警]

第五章:Go连接池治理的最佳实践演进路线

连接泄漏的典型现场复现

某支付网关服务在大促期间频繁触发 dial tcp: lookup failed: no such hosttoo many open files 报错。通过 netstat -an | grep :3306 | wc -l 发现活跃连接数达 2147(系统 ulimit -n = 2048),进一步用 pprof 分析 goroutine 堆栈,定位到未调用 rows.Close() 的旧版 SQL 查询逻辑——该代码在 defer db.Query(...) 后未显式关闭结果集,导致底层连接长期滞留于 sql.Rows 对象中无法归还。

连接池参数的动态调优策略

生产环境采用分阶段配置法: 场景 MaxOpenConns MaxIdleConns ConnMaxLifetime ConnMaxIdleTime
日常流量 50 25 30m 5m
大促预热期 120 60 15m 2m
熔断降级态 20 10 5m 30s

通过 Prometheus + Grafana 监控 sql_open_connectionssql_wait_count 指标,当 sql_wait_count/sec > 5 且持续 30 秒时,自动触发 sql.DB.SetMaxOpenConns() 调整。

// 实时连接池健康检查器
func (c *PoolChecker) Run() {
    ticker := time.NewTicker(30 * time.Second)
    for range ticker.C {
        stats := c.db.Stats()
        if float64(stats.WaitCount)/float64(stats.WaitDuration.Seconds()) > 10.0 {
            c.adjustPoolSize(stats.OpenConnections)
        }
        c.logPoolMetrics(stats)
    }
}

基于链路追踪的连接生命周期审计

集成 OpenTelemetry,在 database/sqldriver.Conn 实现中注入 span context,记录每次 Conn.Begin()Conn.Close() 的耗时与调用栈。某次审计发现 12% 的连接在事务提交后 8.2s 才被归还,根源是 defer tx.Commit() 被包裹在嵌套函数中,实际执行延迟至外层函数 return 之后。

连接池与熔断器的协同治理

使用 gobreaker 实现连接池级熔断:当连续 5 次 PingContext() 超过 1s 或返回 io timeout,自动将 MaxOpenConns 降至 1,并启动后台探针每 10s 尝试恢复。同时修改 sql.DB.PingContext() 超时为 2s(而非默认 30s),避免阻塞 goroutine。

graph LR
A[应用请求] --> B{连接池可用?}
B -- 是 --> C[获取连接]
B -- 否 --> D[触发熔断]
D --> E[降级为单连接模式]
E --> F[并行探测DB健康]
F -->|成功| G[逐步恢复连接数]
F -->|失败| H[维持熔断状态]

容器化环境下的文件描述符隔离

Kubernetes 集群中为每个 Go 服务 Pod 设置 securityContext

securityContext:
  fsGroup: 1001
  seccompProfile:
    type: RuntimeDefault
resources:
  limits:
    memory: "512Mi"
    # 关键:显式限制 fd 数量
    hugepages-2Mi: "128Mi"

配合启动脚本 ulimit -n 4096 && exec "$@",避免因容器 runtime 默认 ulimit -n 过低(如 1024)导致连接池无法扩容。

连接验证机制的渐进式升级

从简单 Ping() 升级为带业务语义的验证:

  • 阶段一:db.PingContext(ctx)(仅 TCP 层)
  • 阶段二:db.QueryRow("SELECT 1").Scan(&dummy)(验证协议握手)
  • 阶段三:db.QueryRow("SELECT NOW()").Scan(&t)(验证时钟同步与权限)
    验证失败时记录 connection_validation_failure{reason="permission_denied"} 指标,驱动权限自动化巡检。

混沌工程验证方案

使用 Chaos Mesh 注入以下故障组合:

  • 网络延迟:对 MySQL Service IP 注入 200ms ±50ms 延迟
  • DNS 故障:随机丢弃 5% 的 mysql.default.svc.cluster.local DNS 请求
  • 连接中断:每 3 分钟随机 kill 1 个活跃连接(通过 tcpkill -i eth0 port 3306
    观测连接池能否在 15 秒内自动剔除失效连接并重建新连接。

不张扬,只专注写好每一行 Go 代码。

发表回复

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