Posted in

Go操作CockroachDB分布式事务的2个隐藏限制:SERIALIZABLE隔离级别下的写偏斜与重试风暴解析

第一章:Go操作CockroachDB分布式事务的底层机制概览

CockroachDB 的分布式事务并非简单封装 SQL 语句,而是依托其基于 Raft 共识与 Spanner 风格 TrueTime 启发的混合逻辑时钟(HLC)协同实现。当 Go 应用通过 database/sql 驱动发起 BEGIN,驱动实际向集群发送带事务优先级与时间戳边界(min_timestamp, max_timestamp)的 BeginTransaction RPC;该请求由协调节点(coordinator node)解析,并依据键范围(key range)将涉及的分区(range)元数据动态路由至对应 replica。

事务协调与冲突检测

CockroachDB 采用乐观并发控制(OCC):事务在执行阶段不加锁,仅在 COMMIT 阶段触发两阶段提交(2PC)。Go 客户端调用 tx.Commit() 时,驱动向 coordinator 发送 EndTransaction 请求,携带所有已读写键的版本向量(read timestamp, write intents)。若检测到读写冲突(如另一事务已提交更高 HLC 时间戳的写入),服务端返回 TransactionRetryError,Go SDK 自动按指数退避重试(默认最多 15 次):

for i := 0; i < 3; i++ {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
    if err != nil {
        panic(err)
    }
    _, err = tx.Exec("UPDATE accounts SET balance = balance - 100 WHERE id = $1", 1)
    if err != nil {
        tx.Rollback()
        continue // 触发重试逻辑
    }
    err = tx.Commit() // 此处可能返回 *pq.Error{Code: "40001"}
    if err == nil {
        break
    }
}

时间戳分配与线性一致性保障

每个事务被赋予唯一 HLC 时间戳,由物理时钟(nanos)与逻辑计数器(counter)合成,确保全局可比性。Go 驱动通过 SET TRANSACTION PRIORITY HIGH 显式提升事务优先级,影响冲突时的回滚决策——高优先级事务可强制低优先级事务中止。

特性 实现方式
分布式原子性 基于 Raft 日志同步的 2PC 提交协议
可串行化隔离 键级写意向(write intent)+ 时间戳排序
故障透明性 驱动自动重连 leader 并重放未确认事务

第二章:SERIALIZABLE隔离级别下的写偏斜问题深度剖析

2.1 写偏斜的理论根源:可串行化与谓词读的冲突本质

写偏斜(Write Skew)并非并发控制的实现缺陷,而是可串行化(Serializability)语义与谓词读(Predicate Read)能力之间固有的张力所致。

谓词读触发的隐式依赖

当事务基于范围条件(如 SELECT * FROM accounts WHERE status = 'active')读取数据时,其正确性依赖于整个谓词集合的稳定性,但传统锁或MVCC快照无法显式锁定“未来可能满足该谓词的新插入行”。

冲突本质图示

graph TD
    T1[事务T1: 读 active=2] -->|不加锁谓词| D1[DB状态]
    T2[事务T2: 读 active=2] --> D1
    T1 -->|更新其中1条| T1W[SET status='frozen']
    T2 -->|更新另一条| T2W[SET status='frozen']
    T1W --> D2[active=0]
    T2W --> D2

经典反例代码

-- 事务T1(银行双人提款)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id IN (1,2) AND balance >= 100; -- 谓词读
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

逻辑分析:REPEATABLE READ 保证已读行不被修改,但不阻止新满足 id IN (1,2) 的行插入或其它未读行被并发更新;参数 balance >= 100 构成开放谓词约束,其完整性无法由行级锁覆盖。

隔离级别 捕获写偏斜 依赖机制
READ COMMITTED 仅保障单行可见性
REPEATABLE READ 快照固化已读行
SERIALIZABLE 谓词锁 / SSI检测

2.2 Go客户端复现写偏斜的经典场景与SQL+代码双视角验证

数据同步机制

写偏斜(Write Skew)常发生于乐观并发控制中,当两个事务读取相同数据、各自修改不同字段、再同时提交时,数据库无法检测到冲突。

复现场景:银行账户双币种余额更新

// 模拟事务A:增加USD余额,保持CNY不变
txA, _ := db.Begin()
var usdA, cnyA int
txA.QueryRow("SELECT usd, cny FROM accounts WHERE id = 1").Scan(&usdA, &cnyA)
txA.Exec("UPDATE accounts SET usd = ? WHERE id = 1", usdA+100) // ✅ 未读cny,却改usd
txA.Commit()

// 模拟事务B:增加CNY余额,保持USD不变  
txB, _ := db.Begin()
var usdB, cnyB int
txB.QueryRow("SELECT usd, cny FROM accounts WHERE id = 1").Scan(&usdB, &cnyB)
txB.Exec("UPDATE accounts SET cny = ? WHERE id = 1", cnyB+700) // ✅ 未读usd,却改cny
txB.Commit()

逻辑分析:两事务均基于初始快照(usd=0, cny=0)读取,各自独立更新不同列,最终结果为 usd=100, cny=700,但丢失了彼此的变更约束(如“USD+CNY总额不能超1000”)。参数 id = 1 是共享行键,触发写偏斜核心条件。

SQL层面验证(PostgreSQL可重复读隔离级)

事务 步骤 SQL语句 是否检测到冲突
A SELECT usd,cny FROM accounts WHERE id=1; 否(仅读快照)
B SELECT usd,cny FROM accounts WHERE id=1; 否(同A快照)
A UPDATE accounts SET usd = usd + 100 ... 否(无全行锁)
B UPDATE accounts SET cny = cny + 700 ... 否 → 写偏斜发生
graph TD
    A[事务A: 读usd/cny] -->|快照S| B[更新usd]
    C[事务B: 读usd/cny] -->|快照S| D[更新cny]
    B --> E[提交成功]
    D --> E
    E --> F[最终状态违反业务约束]

2.3 基于CRDB事务日志与EXPLAIN (DISTSQL)的偏斜路径追踪实践

数据同步机制

CRDB通过Raft日志复制保障跨节点事务一致性。事务提交时,写入的RANGE_LOG包含txn_idtimestampaffected_ranges,是定位热点范围的第一手依据。

分布式执行计划分析

使用EXPLAIN (DISTSQL) SELECT ...可暴露查询在各节点的分发权重:

EXPLAIN (DISTSQL) 
SELECT user_id, COUNT(*) 
FROM orders 
GROUP BY user_id 
ORDER BY COUNT(*) DESC 
LIMIT 10;

逻辑分析DISTSQL输出中Distribution字段标识数据是否本地聚合(local)或需跨节点Shuffle(all)。若某node_idRows列显著高于均值(如 >3×标准差),即为调度偏斜锚点;Ranges列对应底层Range ID,可直接关联crdb_internal.ranges表查其副本分布。

偏斜根因对照表

指标 正常表现 偏斜信号
Node CPU Usage 各节点波动 单节点持续 >85%
Range Lease Holders 均匀轮转 某Range长期驻留同一节点

追踪流程

graph TD
  A[捕获高延迟事务] --> B[解析txn_id对应Range Log]
  B --> C[EXPLAIN DISTSQL定位高Rows节点]
  C --> D[JOIN crdb_internal.ranges确认副本倾斜]
  D --> E[调整zone config或split range]

2.4 应用层防御策略:显式锁提示、SELECT FOR UPDATE语义适配与读写依赖建模

在高并发事务场景中,仅依赖数据库默认隔离级别易引发幻读或更新丢失。应用层需主动参与并发控制。

显式锁提示的语义强化

通过 /*+ LOCK_IN_SHARE_MODE *//*+ FOR UPDATE */ 向 MySQL 传递锁意图,避免优化器误判:

SELECT id, balance FROM accounts 
WHERE user_id = 1001 
/*+ FOR UPDATE WAIT 5 */; -- 等待5秒超时,避免无限阻塞

WAIT 5 显式声明最大等待时长,防止事务雪崩;FOR UPDATE 在可重复读下对匹配行加临键锁,覆盖幻读风险区间。

读写依赖建模示意

操作类型 读集(R-set) 写集(W-set) 冲突判定条件
转账A→B {acc_A, acc_B} {acc_A, acc_B} R∩W’ ≠ ∅ 或 W∩W’ ≠ ∅

并发控制决策流

graph TD
    A[应用发起读请求] --> B{是否含写依赖?}
    B -->|是| C[自动追加 FOR UPDATE]
    B -->|否| D[使用 READ COMMITTED + 乐观校验]
    C --> E[执行前检查锁等待队列深度]

2.5 使用go-cockroachdb驱动的Session级Hint配置与事务注解增强

Session级Hint:动态控制查询行为

go-cockroachdb 支持通过 session setting 注入执行Hint,例如强制使用特定索引或禁用分布式执行:

_, err := db.Exec("SET session_override = 'force_index=idx_user_email'")
if err != nil {
    log.Fatal(err) // 启用会话级索引强制提示
}

此设置仅对当前连接生效,避免全局影响;session_override 是CockroachDB专有GUC变量,需服务端v22.2+支持。

事务注解:语义化追踪与调度

在事务开始时添加结构化注解,便于审计与调度器识别:

tx, err := db.Begin()
if err != nil {
    panic(err)
}
// 注入业务上下文标签
_, _ = tx.Exec("SET TRANSACTION AS OF SYSTEM TIME '-10s'; COMMENT ON TRANSACTION 'order-creation-flow-v2'")

COMMENT ON TRANSACTION 将元数据写入事务记录,可观测平台可据此聚合慢事务归因。

支持的Hint类型对比

Hint类型 作用域 持久性 示例
force_index 单会话 临时 SET session_override='force_index=idx_orders_status'
distsql 单查询 语句级 /*+ DISTSQL=off */ SELECT ...
transaction_comment 单事务 持久至提交 COMMENT ON TRANSACTION 'payment-retry'

执行流程示意

graph TD
    A[应用发起Query] --> B{是否含Hint注释?}
    B -->|是| C[解析Hint并注入Session Context]
    B -->|否| D[走默认优化路径]
    C --> E[生成带Hint的物理计划]
    E --> F[执行并上报注解元数据]

第三章:重试风暴的触发条件与传播机理

3.1 重试风暴的分布式因果链:时钟偏差、网络分区与乐观并发控制的叠加效应

当全局时钟漂移超过事务超时阈值,客户端在未收到响应时触发重试;此时若恰逢网络分区,副本间状态分裂,而乐观并发控制(OCC)又依赖版本戳校验——三者耦合将指数级放大无效重试。

数据同步机制

以下伪代码体现带时钟补偿的重试抑制逻辑:

def safe_retry(tx, max_attempts=3):
    for i in range(max_attempts):
        # 使用混合逻辑时钟(HLC)替代物理时间戳
        hlc = hybrid_logical_clock()  # 同步精度 ±5ms,含物理+计数器分量
        tx.version = hlc.to_version()
        try:
            return execute_tx(tx)
        except WriteConflictError:
            if hlc.is_stale(threshold_ms=10):  # 防止陈旧时钟驱动重试
                hlc.resync()  # 触发NTP轻量对齐
            continue

hybrid_logical_clock() 融合物理时间与事件计数,缓解纯Lamport时钟的单调性缺失;is_stale() 检测本地时钟是否偏离集群中位数超阈值,避免因时钟偏差误判冲突。

叠加效应关键因子

因子 影响路径 典型放大倍数
时钟偏差 > 20ms 使 CAS 版本检查提前失败 3.2×
网络分区(P) 隔离副本导致 read-your-writes 违反 5.7×
OCC 验证粒度粗(如行级→表级) 冲突检测范围扩大 4.1×
graph TD
    A[客户端发起写请求] --> B{时钟偏差 > 10ms?}
    B -->|是| C[版本戳生成偏移]
    B -->|否| D[正常提交]
    C --> E[OCC验证失败]
    E --> F[触发重试]
    F --> G{网络分区中?}
    G -->|是| H[多副本状态不一致]
    H --> I[重试请求散射至不同视图]
    I --> J[形成跨分区重试风暴]

3.2 Go应用中通过crdb.RetryableError与context.DeadlineExceeded识别风暴初兆

在高并发写入场景下,CockroachDB 的重试语义与上下文超时共同构成关键的异常信号源。

异常信号语义差异

异常类型 触发场景 是否建议重试 反映系统状态
crdb.RetryableError 事务冲突、临时不可用 ✅ 是 负载尖峰或热点竞争
context.DeadlineExceeded 请求耗时超限(含重试总耗时) ❌ 否 端到端延迟恶化初兆

典型错误分类处理逻辑

if err != nil {
    if crdb.IsRetryable(err) {
        // 指数退避后重试:表明集群尚可服务,但存在瞬时争用
        time.Sleep(backoff)
        continue
    }
    if errors.Is(err, context.DeadlineExceeded) {
        // 立即上报指标并触发熔断:延迟已穿透SLA阈值
        metrics.Inc("storm_alert")
        return err // 不再重试
    }
}

该逻辑将重试控制权交由业务感知层,避免盲目重试放大雪崩风险。crdb.RetryableError 是内部协调压力的“呼吸声”,而 DeadlineExceeded 则是系统进入应激状态的“警报器”。

3.3 基于pprof+trace分析重试密集型goroutine的调度瓶颈与阻塞点

重试密集型任务(如幂等HTTP调用、分布式锁轮询)易因高频runtime.gopark导致goroutine堆积,掩盖真实阻塞源。

数据同步机制

典型模式:

func retryLoop(ctx context.Context, url string) {
    for i := 0; i < 5; i++ {
        if resp, err := http.Get(url); err == nil {
            _ = resp.Body.Close()
            return
        }
        time.Sleep(time.Second * time.Duration(1<<i)) // 指数退避
    }
}

time.Sleep触发gopark,但pprof goroutine profile仅显示syscalltimerSleep,需结合trace定位实际阻塞时长分布

分析路径对比

工具 可见信息 局限
go tool pprof -goroutines goroutine数量/状态 无法区分park原因
go tool trace 精确到微秒的goroutine生命周期 需手动筛选重试goroutine

调度瓶颈归因流程

graph TD
    A[启动trace] --> B[过滤retryLoop相关goroutine]
    B --> C[检查G状态转换:runnable→waiting→running]
    C --> D[定位最长waiting环节:timerWait vs sync.Mutex]

第四章:Go客户端韧性增强的工程化实践

4.1 自定义重试策略:指数退避+抖动+最大重试预算的Go实现

在分布式系统中,简单固定间隔重试易引发雪崩。我们构建一个兼顾鲁棒性与公平性的重试控制器。

核心设计三要素

  • 指数退避:避免重试风暴,基础等待时间随失败次数倍增
  • 随机抖动:防止大量客户端同步重试,引入均匀随机偏移
  • 最大重试预算:以总耗时而非次数为约束,适配长尾延迟场景

Go 实现示例

type RetryBudget struct {
    MaxDuration time.Duration // 总预算(如 3s)
    BaseDelay   time.Duration // 初始延迟(如 100ms)
    MaxJitter   time.Duration // 抖动上限(如 50ms)
}

func (rb *RetryBudget) NextDelay(attempt int, start time.Time) time.Duration {
    if time.Since(start) >= rb.MaxDuration {
        return 0 // 预算耗尽
    }
    base := time.Duration(float64(rb.BaseDelay) * math.Pow(2, float64(attempt)))
    jitter := time.Duration(rand.Int63n(int64(rb.MaxJitter)))
    delay := base + jitter
    if total := time.Since(start) + delay; total > rb.MaxDuration {
        return rb.MaxDuration - time.Since(start) // 截断至剩余预算
    }
    return delay
}

逻辑说明:attempt 从 0 开始;baseBaseDelay × 2^attempt 指数增长;jitter[0, MaxJitter) 均匀采样;最终延迟确保不突破 MaxDuration 剩余窗口。

参数影响对比(单位:毫秒)

Attempt Base (100ms) + Jitter (0–50ms) 实际 Delay(示例)
0 100 +23 123
1 200 +41 241
2 400 +17 417
graph TD
    A[请求失败] --> B{尝试次数 ≤ 预算?}
    B -->|否| C[放弃]
    B -->|是| D[计算指数退避+抖动延迟]
    D --> E[检查剩余时间是否充足]
    E -->|否| C
    E -->|是| F[Sleep 后重试]

4.2 事务边界重构:将长事务拆分为CRDB友好的短原子单元(SAGA轻量模式)

在 CockroachDB(CRDB)中,长事务易触发 transaction deadline exceeded 或引发分布式锁争用。SAGA 轻量模式通过可补偿的本地事务链替代全局长事务,每个步骤在单分片内完成,天然契合 CRDB 的乐观并发控制。

核心设计原则

  • 每个子事务幂等、短时(
  • 补偿动作与正向操作同库同事务,确保原子性
  • 状态机驱动流程,避免内存状态漂移

订单创建 SAGA 示例(Go + pgx)

// 步骤1:创建订单(本地事务)
_, err := tx.Exec(ctx, `
  INSERT INTO orders (id, status, created_at) 
  VALUES ($1, 'pending', now())`, orderID)
// ✅ CRDB 友好:单表、无跨节点写入、无长锁

// 步骤2:扣减库存(独立事务,失败则触发 CancelOrder)
_, err = db.Exec(ctx, `
  UPDATE products SET stock = stock - $1 
  WHERE id = $2 AND stock >= $1`, qty, productID)
// ⚠️ 若失败,由 Saga 协调器调用补偿:UPDATE orders SET status = 'cancelled'

逻辑分析:两步均规避了 CRDB 对跨分片事务的严格超时限制(默认2s)。INSERTUPDATE 各自为独立短事务,由应用层保证最终一致性;参数 $1/$2 绑定明确,防止 SQL 注入且利于查询计划缓存。

SAGA 状态流转(Mermaid)

graph TD
  A[Init] --> B[CreateOrder]
  B --> C{Stock OK?}
  C -->|Yes| D[Pay]
  C -->|No| E[CancelOrder]
  D --> F[Ship]
  F --> G[Complete]
  E --> H[Failed]
阶段 CRDB 友好性 补偿动作
CreateOrder ✅ 单表插入 DeleteOrder
ReserveStock ✅ 分区键路由 RestoreStock
Pay ✅ 外部系统异步 Refund

4.3 连接池与会话状态管理:pgxpool配置调优与session-level retry counter注入

连接池基础配置

pgxpool.Config 提供了连接生命周期控制的核心参数:

cfg := pgxpool.Config{
    MaxConns:        20,
    MinConns:        5,
    MaxConnLifetime: 1 * time.Hour,
    MaxConnIdleTime: 30 * time.Minute,
    HealthCheckPeriod: 10 * time.Second,
}

MaxConns 限制并发连接上限,防止数据库过载;MinConns 预热常驻连接以降低首请求延迟;HealthCheckPeriod 主动探测空闲连接有效性,避免 connection reset 错误。

Session-level 重试计数器注入

需在 Acquire() 后的 *pgx.Conn 上绑定上下文感知的重试状态:

字段 类型 用途
retry_count int 当前会话已触发的重试次数
max_retries int 会话级硬性上限(非全局)
last_error error 最近一次失败原因
graph TD
    A[Acquire Conn] --> B{Has retry_count ctx?}
    B -->|No| C[Inject new counter]
    B -->|Yes| D[Increment & validate]
    D --> E[Execute Query]
    E --> F{Error?}
    F -->|Yes| D
    F -->|No| G[Release]

4.4 生产就绪监控:Prometheus指标埋点(crdb_transaction_retries_total、crdb_txn_write_skew_errors)与Grafana看板联动

CockroachDB 内置的 Prometheus 指标为事务稳定性提供关键观测维度。crdb_transaction_retries_total 统计所有重试次数(含客户端显式重试与服务端自动重试),而 crdb_txn_write_skew_errors 专指因写偏斜(Write Skew)检测触发的失败——这是 Serializable 隔离级别下特有的冲突类型。

关键指标语义对齐

指标名 类型 标签示例 业务含义
crdb_transaction_retries_total Counter reason="serializable" 可序列化事务因并发冲突自动重试总次数
crdb_txn_write_skew_errors Counter node_id="1" 明确由写偏斜检测机制拒绝的事务数

Grafana 查询示例(PromQL)

# 过去5分钟内每秒写偏斜错误率(按节点聚合)
rate(crdb_txn_write_skew_errors[5m]) by (node_id)

此查询返回各节点单位时间错误频次,用于定位热点节点或异常分片;rate() 自动处理 Counter 重置与时间窗口滑动,避免手工差值计算误差。

埋点联动逻辑

graph TD
    A[CockroachDB metrics endpoint] -->|scrape| B[Prometheus server]
    B --> C[Stored as time-series]
    C --> D[Grafana dashboard: 'Txn Stability']
    D --> E[Alert: write_skew_rate > 0.5/s for 2m]

第五章:未来演进与跨数据库事务一致性思考

分布式事务的现实困局:电商履约链路实录

某头部电商平台在2023年双11大促期间遭遇典型跨库一致性故障:用户支付成功(MySQL主库)后,库存扣减(TiDB集群)延迟3.7秒才生效,导致超卖127单。根本原因在于Saga模式中补偿事务因网络抖动重试失败,而本地消息表未做幂等校验。该案例推动团队将TCC协议下沉至服务网格层,通过Envoy Filter拦截SQL执行并注入事务上下文ID,使跨库操作具备可追踪性。

新型一致性原语:基于WAL共享的日志协同架构

当前主流方案正从“应用层协调”转向“存储层协同”。阿里云PolarDB-X 2.0已支持跨分片WAL流式订阅,允许下游Flink作业消费多节点redo日志并构建全局有序事件流。下表对比三种跨库事务保障机制在金融级场景下的实测指标:

方案 端到端延迟 最终一致窗口 补偿成功率 运维复杂度
传统XA两阶段 ≥850ms 实时 99.2%
Saga+死信队列 220~650ms 15s 99.98%
WAL协同+LSN对齐 45~110ms 100%

混合持久化架构下的事务边界重构

某证券行情系统采用PostgreSQL(行情元数据)+ RedisTimeSeries(实时K线)+ ClickHouse(历史回溯)三存储混合架构。为保证“新增合约→初始化K线→生成回溯任务”原子性,团队设计轻量级事务协调器:所有写操作先写入RocksDB本地事务日志(含LSN戳),再由后台Worker按LSN顺序同步至各存储。该方案使跨引擎事务吞吐量提升3.2倍,且避免了分布式锁竞争。

-- WAL协同架构关键SQL:通过LSN锚点实现跨库读取一致性
SELECT * FROM pg_logical_slot_get_changes(
  'replication_slot', 
  '0/1A2B3C4D', -- 起始LSN(来自PostgreSQL)
  NULL, 
  'proto_version', '1',
  'publication_names', 'trade_pub'
);

异构数据库联邦事务的实践拐点

随着Doris、StarRocks等MPP引擎接入OLTP业务,传统事务模型面临范式迁移。某物流调度平台将订单状态(MySQL)、运单轨迹(Doris)、司机位置(MongoDB)统一建模为“事务图谱”,利用Neo4j图数据库维护跨库实体关系约束。当触发“订单取消”事件时,图遍历算法自动识别关联节点并生成补偿指令序列,实测平均修复耗时从42秒降至1.8秒。

graph LR
A[MySQL订单表] -->|LSN=0/ABC123| B(WAL订阅服务)
C[Doris运单表] -->|LSN=0/DEF456| B
D[MongoDB位置表] -->|OplogTS=12345| B
B --> E{事务图谱引擎}
E -->|生成补偿路径| F[MySQL补偿事务]
E -->|生成删除指令| G[Doris异步清理]
E -->|发送失效通知| H[MongoDB TTL更新]

开源生态的协同演进趋势

Debezium 2.4版本新增对Oracle GoldenGate日志格式的原生解析能力,使Oracle→MySQL跨库事务追踪延迟压缩至80ms内;同时,CNCF项目Vitess 14.0正式支持跨Shard的乐观并发控制(OCC),通过时间戳向量替代全局锁。这些演进正加速企业从“最终一致性妥协”走向“强一致性可选”。

边缘计算场景的事务轻量化改造

某工业物联网平台需在边缘网关(ARM架构)完成PLC数据采集(SQLite)、设备告警(InfluxDB)、规则引擎(DuckDB)三重写入。团队剥离ACID语义,改用CRDT(Conflict-free Replicated Data Type)实现状态同步:所有写操作转换为带逻辑时钟的增量操作集,中心节点通过向量时钟合并冲突。该方案使边缘设备CPU占用率下降63%,且网络断连30分钟内数据自愈率达100%。

记录一位 Gopher 的成长轨迹,从新手到骨干。

发表回复

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