Posted in

Golang游戏跨服合区技术揭秘:全局唯一ID生成、跨服消息广播、状态迁移一致性(三阶段提交+Saga模式实战)

第一章:Golang游戏跨服合区技术全景概览

跨服合区是大型多人在线游戏(MMO)演进至成熟阶段的关键架构能力,其本质是在保障玩家数据一致性、服务高可用与业务连续性的前提下,将多个逻辑独立的服务器集群合并为统一的游戏世界。Golang 凭借其轻量协程、原生并发模型、静态编译与低延迟 GC 特性,成为实现高性能跨服通信与状态协同的理想语言载体。

核心挑战维度

  • 数据一致性:角色、背包、帮派等跨服共享实体需支持分布式事务或最终一致性策略;
  • 路由透明性:玩家无感访问目标服资源,依赖全局唯一 ID 映射与智能路由中间件;
  • 状态同步时效性:跨服战斗、排行榜、跨服副本等场景要求亚秒级状态广播与冲突消解;
  • 灰度与回滚能力:合区过程需支持分批次迁移、双写验证及快速回退机制。

关键技术组件

  • 全局服务注册中心:基于 etcd 或 Consul 实现多服节点自动发现与健康探活;
  • 跨服消息总线:采用 Protocol Buffers 序列化 + NATS 或 Kafka 构建低开销异步通道;
  • 分布式锁与幂等框架:使用 Redis Redlock 封装 LockWithContext,配合请求指纹(如 sha256(playerID+action+timestamp))保障操作幂等。

典型合区流程示例

// 合区预检:校验两服玩家ID是否全局唯一(避免冲突)
func validatePlayerIDUniqueness(src, dst *GameServer) error {
    srcIDs := src.GetPlayerIDSet() // 从源服Redis批量获取ID集合
    dstIDs := dst.GetPlayerIDSet()
    intersect := srcIDs.Intersect(dstIDs)
    if !intersect.Empty() {
        return fmt.Errorf("conflict player IDs detected: %v", intersect.List())
    }
    return nil
}
// 执行前需调用此函数,返回非nil错误则中止合区流程

合区后服务拓扑示意

组件 部署模式 职责说明
GateServer 多实例+LB 统一接入、协议解析、路由分发
WorldRouter 单点主备 维护服间映射表,转发跨服请求
ShardManager 分布式共识 协调合区进度、触发状态快照同步
BackupAgent 每服独立运行 增量备份关键表,支持按时间点恢复

第二章:全局唯一ID生成体系构建

2.1 雪花算法(Snowflake)原理与Go语言高并发适配实践

雪花算法生成64位唯一ID,结构为:1bit(预留)+41bit(时间戳)+10bit(机器ID)+12bit(序列号)。Go语言通过sync/atomic保障高并发下序列号安全递增。

核心结构设计

  • 时间戳部分使用毫秒级偏移,避免回拨依赖外部时钟同步
  • 机器ID支持配置或从环境变量注入,避免硬编码
  • 序列号在单毫秒内自增,溢出时阻塞至下一毫秒

Go实现关键片段

type Snowflake struct {
    mu        sync.Mutex
    epoch     int64
    node      int64
    sequence  int64
    lastStamp int64
}

func (s *Snowflake) NextID() int64 {
    s.mu.Lock()
    defer s.mu.Unlock()
    now := time.Now().UnixMilli()
    if now < s.lastStamp {
        panic("clock moved backwards")
    }
    if now == s.lastStamp {
        s.sequence = (s.sequence + 1) & 0xfff // 12位掩码
        if s.sequence == 0 {
            now = s.waitNextMillis(s.lastStamp)
        }
    } else {
        s.sequence = 0
    }
    s.lastStamp = now
    return ((now-s.epoch)<<22 | (s.node<<12) | s.sequence)
}

NextID()& 0xfff 确保序列号严格限制在12位(0–4095),waitNextMillis 自旋等待下一毫秒,避免ID重复。epoch 通常设为服务上线时间,延长ID可用年限。

组件 位宽 取值范围 说明
时间戳 41 ~69年 以自定义epoch起始
机器ID 10 0–1023 支持1024个节点
序列号 12 0–4095/毫秒 单节点峰值4096 QPS
graph TD
    A[调用NextID] --> B{当前时间 == 上次时间?}
    B -->|是| C[序列号+1]
    B -->|否| D[序列号重置为0]
    C --> E{是否溢出?}
    E -->|是| F[等待至下一毫秒]
    E -->|否| G[拼接并返回64位ID]
    F --> G

2.2 多数据中心ID生成器的时钟漂移容错与序列号冲突规避

在跨地域部署中,NTP同步误差常达10–100ms,直接依赖物理时钟易引发ID回退或重复。核心解法是分离逻辑时间戳与物理时钟,并引入数据中心感知的序列号隔离机制。

逻辑时钟增强策略

采用混合逻辑时钟(HLC)替代纯毫秒时间戳:

// HLC = (physical_time_ms << 16) | (logical_counter & 0xFFFF)
long hlc = (System.currentTimeMillis() << 16) | (counter.incrementAndGet() & 0xFFFF);

逻辑分析:高位保留粗粒度时间顺序(毫秒级),低位承载同一毫秒内自增序列;counter按数据中心ID分片隔离,避免跨中心竞争。<< 16预留65536次/毫秒容量,兼顾精度与吞吐。

冲突规避维度对比

维度 纯时间戳方案 HLC + 分片序列号
时钟回拨容忍 ❌ 崩溃 ✅ 自动递增逻辑位
跨中心并发冲突 ❌ 高概率 ✅ 序列号空间正交

数据同步机制

graph TD
  A[DC-A ID Generator] -->|HLC+DC-A-SN| B[Global Log]
  C[DC-B ID Generator] -->|HLC+DC-B-SN| B
  B --> D[Consistent Read View]

2.3 基于etcd分布式协调的ID段预分配与动态伸缩机制

核心设计思想

将全局ID生成拆解为「段(Segment)」粒度,由各服务节点向etcd争抢预分配区间(如 [10000, 10999]),避免单点瓶颈与强同步。

etcd事务化预分配示例

// 使用 CompareAndSwap 确保原子性分配
txn := client.Txn(ctx)
txn.If(
    clientv3.Compare(clientv3.Version(key), "=", 0), // 未被分配过
).Then(
    clientv3.OpPut(key, string(serializeRange(10000, 10999)), clientv3.WithLease(leaseID)),
).Else(
    clientv3.OpGet(key), // 获取已分配段
)

逻辑分析Version(key) == 0 判断首次写入;WithLease 绑定租约实现自动回收;serializeRange 将区间序列化为JSON字节流。失败时读取已有值,避免重复争抢。

动态伸缩策略

  • 预分配段大小根据节点QPS自适应:低负载用小段(100),高负载用大段(10000)
  • 段耗尽前30%触发异步预热请求,降低延迟毛刺

协调状态表

字段 类型 说明
/idgen/segments/{node-id} string 当前持有段(JSON: {"start":10000,"end":10999,"version":12}
/idgen/leases/{node-id} lease ID 续约心跳绑定租约
graph TD
    A[节点启动] --> B{查询etcd中可用段}
    B -->|存在空闲段| C[CAS抢占并绑定lease]
    B -->|无空闲段| D[等待通知或扩容集群]
    C --> E[本地缓存ID池,按需递增]
    E --> F{剩余<30%?}
    F -->|是| G[异步发起下一段预分配]

2.4 ID语义化设计:服务标识+时间戳+逻辑分片+自增序列的Go结构体封装

ID生成需兼顾唯一性、可读性与分布式友好性。以下结构体将四维语义内聚封装:

type SemanticID struct {
    ServiceID uint16 // 0-65535,预分配服务编码(如 auth=1, order=2)
    Timestamp uint32 // 毫秒级时间戳(相对2024-01-01起始偏移,节省2字节)
    ShardID   uint8  // 逻辑分片号(0-255),支持水平扩容
    Sequence  uint16 // 同毫秒内自增(支持65536次/毫秒)
}

func (s *SemanticID) ToUint64() uint64 {
    return uint64(s.ServiceID)<<48 |
           uint64(s.Timestamp)<<16 |
           uint64(s.ShardID)<<8 |
           uint64(s.Sequence)
}

逻辑分析

  • ServiceID 占16位,避免硬编码字符串,提升路由与日志过滤效率;
  • Timestamp 使用相对纪元(非Unix时间),压缩为32位,兼顾精度与长度;
  • ShardIDSequence 共占24位,平衡分片粒度与并发吞吐;
  • 位运算组装确保字典序 ≈ 时间序,天然支持数据库索引优化。

语义字段对照表

字段 位宽 取值范围 用途
ServiceID 16 0–65535 服务路由、权限隔离
Timestamp 32 0–4294967295 毫秒偏移(约136年跨度)
ShardID 8 0–255 数据库分库/分表键
Sequence 16 0–65535 同毫秒内唯一递增

生成流程(mermaid)

graph TD
    A[获取本地服务ID] --> B[读取单调递增毫秒偏移]
    B --> C[哈希请求上下文取ShardID]
    C --> D[原子递增Sequence]
    D --> E[位拼接生成uint64]

2.5 压测验证与ID生成吞吐量优化(百万QPS级实测与pprof调优)

为支撑分布式事务链路中全局唯一、单调递增、无时钟依赖的ID生成,我们基于Snowflake变体构建了AtomicIDGenerator,并在K8s集群(16C32G × 4节点)上开展百万QPS压测。

性能瓶颈定位

通过 go tool pprof -http=:8080 cpu.pprof 分析发现:

  • sync/atomic.LoadUint64 占比达38%,源于高竞争下的序列号读取;
  • runtime.mallocgc 次要热点(19%),由临时对象逃逸引发。

核心优化手段

  • 采用分段预分配+本地缓存策略,单Worker批量获取1024个ID段;
  • 将逻辑时钟更新粒度从毫秒级放宽至“微秒窗口对齐”,降低时间同步开销;
  • 关键字段使用 unsafe.Pointer + atomic 批量读写,消除结构体拷贝。
// 预分配段式ID池(线程安全)
type IDSegment struct {
    base  uint64 // 起始ID
    end   uint64 // 终止ID(不含)
    used  uint64 // 已分配计数(原子操作)
}

base/end 确保无锁分配边界;usedatomic.AddUint64 递增,避免CAS重试——实测将单核吞吐从42万QPS提升至97万QPS。

优化前后对比(单节点)

指标 优化前 优化后 提升
P99延迟 84μs 21μs
GC暂停时间 120μs 18μs 6.7×
CPU利用率 92% 63%
graph TD
    A[请求到达] --> B{本地缓存ID充足?}
    B -->|是| C[直接返回ID]
    B -->|否| D[批量申请新ID段]
    D --> E[原子更新segment.used]
    E --> C

第三章:跨服消息广播架构实现

3.1 基于NATS JetStream的持久化跨服事件总线设计与Go客户端封装

跨服事件需强有序、可重放、不丢失——JetStream 的 StreamConsumer 模型天然适配。我们采用 WorkQueue 策略保障单消费者语义,配合 AckWaitMaxDeliver 实现精确一次(at-least-once + 幂等去重)语义。

核心流控策略

  • 持久化流:replicas=3 防止单点故障
  • 消费者:ack_policy: explicit + deliver_policy: by_start_time 支持按时间回溯
  • 主题映射:srv.<service>.event.<type> 实现服务隔离

Go客户端关键封装

// 初始化带重连与上下文超时的JetStream连接
js, err := nc.JetStream(nats.PublishAsyncMaxPending(256))
if err != nil {
    return nil, fmt.Errorf("jetstream init failed: %w", err)
}
// ⚠️ PublishAsyncMaxPending 控制未确认消息缓冲上限,防内存溢出
参数 推荐值 说明
Retention LimitsPolicy 基于大小/时效双维度清理
MaxMsgsPerSubject 100k 防止单主题爆炸性增长
DuplicateWindow 2m 支持重复消息自动丢弃
graph TD
    A[跨服服务] -->|Publish| B(JetStream Stream)
    B --> C{Consumer Group}
    C --> D[Service-A]
    C --> E[Service-B]
    D --> F[本地事件处理器]
    E --> F

3.2 消息去重、幂等性保障与延迟广播控制的中间件层实现

核心设计原则

采用「消息指纹 + 窗口存储 + 延迟调度」三位一体架构,兼顾性能、一致性和可控性。

消息指纹生成

// 基于业务主键与时间戳生成唯一指纹(64位 MurmurHash3)
String fingerprint = Hashing.murmur3_128()
    .hashString(msg.getOrderId() + ":" + msg.getVersion(), UTF_8)
    .asLong() + "";

逻辑分析:orderId确保业务维度唯一,version抵御重放;MurmurHash3 高吞吐低碰撞,asLong()适配 Redis ZSET 排序需求。

幂等状态表结构

字段 类型 说明
fingerprint STRING 主键,TTL 自动清理
status ENUM PROCESSED/EXPIRED
created_at TIMESTAMP 写入时间,用于滑动窗口

延迟广播流程

graph TD
    A[消息抵达] --> B{是否已存在fingerprint?}
    B -->|是| C[丢弃/返回ACK]
    B -->|否| D[写入Redis+设置TTL]
    D --> E[触发延迟队列投递]
    E --> F[定时器唤醒执行广播]

3.3 跨服广播的订阅域隔离策略与玩家在线状态感知联动机制

跨服广播需在逻辑域层面实现精准隔离,避免消息洪泛与状态误判。核心在于将“订阅域”与“实时在线状态”深度耦合。

订阅域动态绑定机制

玩家登录时,服务端依据其归属服ID、跨服组ID及当前活动副本ID生成唯一订阅域标识:

def gen_subscription_domain(player_id: str, home_shard: int, group_id: int, instance_id: Optional[str] = None) -> str:
    # 示例:shard-1024_group-7_instance-dt2024 → 确保跨服组内广播不越界
    base = f"shard-{home_shard}_group-{group_id}"
    return f"{base}_instance-{instance_id}" if instance_id else base

该函数输出作为Redis Pub/Sub频道名前缀,确保同一跨服组内不同副本间消息物理隔离;instance_id为空时退化为组级广播域。

在线状态联动流程

当玩家心跳超时触发下线事件,系统自动撤销其所有订阅域监听,并广播轻量状态变更:

graph TD
    A[玩家心跳超时] --> B{状态机判定 offline}
    B --> C[从所有订阅域Pub/Sub channel退订]
    C --> D[向各域发布 state:offline + domain]
    D --> E[下游服更新本地在线缓存]

关键参数对照表

参数 含义 生效范围 示例
home_shard 主服编号 全局路由锚点 1024
group_id 跨服逻辑组ID 广播作用域边界 7
instance_id 副本实例标识 最细粒度隔离单元 dt2024

第四章:状态迁移一致性保障实战

4.1 三阶段提交(3PC)在跨服角色数据迁移中的Go协程安全实现

核心挑战

跨服迁移需保证角色数据强一致性,而传统2PC存在协调者单点阻塞问题。3PC通过引入 CanCommit → PreCommit → DoCommit 三阶段拆分,配合超时回退机制,显著提升分布式事务可用性。

协程安全设计要点

  • 使用 sync.WaitGroup 控制阶段协同生命周期
  • 所有网络调用封装为带上下文取消的 ctx.Do() 操作
  • 共享状态(如投票结果)通过 sync.Map 线程安全访问

阶段协调流程

// PreCommit 阶段广播(协程安全示例)
func (c *Coordinator) broadcastPreCommit(ctx context.Context, roleID string) error {
    var wg sync.WaitGroup
    results := sync.Map{} // key: serverID, value: bool (ack)

    for _, srv := range c.servers {
        wg.Add(1)
        go func(s string) {
            defer wg.Done()
            ack, err := c.sendPreCommit(ctx, s, roleID)
            if err == nil {
                results.Store(s, ack)
            }
        }(srv)
    }
    wg.Wait()
    return c.checkQuorum(&results) // 投票过半即推进
}

逻辑分析sync.Map 替代 map[string]bool 避免并发写panic;wg.Wait() 保障所有协程完成后再统计;checkQuorum 依据预设阈值(如 ≥2/3)判定是否进入 DoCommit

3PC状态迁移对比

阶段 超时行为 协调者崩溃后恢复动作
CanCommit 直接中止 向所有参与者发送 Abort
PreCommit 等待多数响应 查询参与者状态并续判
DoCommit 强制提交 重发 DoCommitAbort
graph TD
    A[CanCommit] -->|Yes| B[PreCommit]
    A -->|No/Timeout| C[Abort]
    B -->|All Ack| D[DoCommit]
    B -->|Any Reject/Timeout| C
    D --> E[Committed]

4.2 Saga模式选型对比:Choreography vs Orchestration及Go微服务编排实践

Saga 是解决分布式事务的成熟模式,核心在于将长事务拆解为一系列本地事务,并通过补偿机制保障最终一致性。在微服务架构中,其落地主要分为两种编排范式:

Choreography(编舞式)

  • 服务间通过事件驱动松耦合通信
  • 无中心协调者,每个服务监听并发布事件
  • 更具弹性,但调试与追踪复杂

Orchestration(编排式)

  • 由专用 Orchestrator(如 Saga Coordinator)控制流程走向
  • 流程逻辑集中,可观测性强,易于回滚决策
  • 引入单点依赖,需保障协调器高可用
维度 Choreography Orchestration
耦合度 中(与协调器耦合)
可维护性 较差(隐式流程) 高(显式流程定义)
Go 实现复杂度 依赖消息中间件 SDK 需集成工作流引擎
// Go 中基于 Temporal 的 Orchestration 示例片段
func TransferWorkflow(ctx workflow.Context, input TransferInput) error {
    ao := workflow.ActivityOptions{StartToCloseTimeout: 10 * time.Second}
    ctx = workflow.WithActivityOptions(ctx, ao)

    err := workflow.ExecuteActivity(ctx, ChargeActivity, input.Source, input.Amount).Get(ctx, nil)
    if err != nil {
        return err
    }
    return workflow.ExecuteActivity(ctx, CreditActivity, input.Target, input.Amount).Get(ctx, nil)
}

该代码定义了明确的执行顺序与失败传播路径;workflow.ExecuteActivity 封装了重试、超时与上下文传递,Get(ctx, nil) 同步阻塞并捕获补偿触发条件。

graph TD
    A[用户发起转账] --> B[Orchestrator 启动 Workflow]
    B --> C[调用 ChargeActivity]
    C --> D{成功?}
    D -->|是| E[调用 CreditActivity]
    D -->|否| F[触发 Compensation]
    E --> G[完成]

4.3 补偿事务的自动注册、超时回滚与可观测性埋点(OpenTelemetry集成)

自动注册补偿动作

使用 Spring AOP 在 @Compensable 方法执行后自动注册 CompensationHandler,绑定唯一 sagaId 与 TTL。

@Around("@annotation(compensable)")
public Object autoRegister(ProceedingJoinPoint pjp) throws Throwable {
    String sagaId = MDC.get("saga_id"); // 透传链路ID
    CompensationRegistry.register(sagaId, 
        () -> refundOrder(sagaId), 
        Duration.ofMinutes(30)); // 超时窗口
    return pjp.proceed();
}

逻辑分析:拦截器提取 MDC 中的分布式追踪上下文 saga_id,将补偿闭包与 30 分钟 TTL 绑定至全局注册表;超时后由后台调度器触发回滚。

OpenTelemetry 埋点集成

在补偿注册、执行、超时三个关键节点注入 Span:

事件类型 Span 名称 属性示例
注册 compensation.register saga_id, handler_class
执行成功 compensation.executed status=success, duration
超时触发 compensation.timeout ttl_expired=true

回滚流程可视化

graph TD
    A[发起支付] --> B[注册扣款补偿]
    B --> C{30min内是否完成?}
    C -->|否| D[OTel上报timeout Span]
    C -->|是| E[调用refundOrder]
    D --> F[告警+人工介入]

4.4 状态迁移过程中的服务灰度切流与双写校验机制(Go泛型校验器开发)

在状态迁移期间,需保障数据一致性与服务可用性。灰度切流通过流量权重动态调度请求至新旧服务实例;双写校验则确保关键状态变更同步写入双源并交叉验证。

数据同步机制

采用 DualWriter[T any] 泛型结构封装双写逻辑:

type DualWriter[T any] struct {
    primary, secondary io.Writer
    validator          func(T) error // 校验器:泛型输入,返回业务合规性错误
}

func (dw *DualWriter[T]) Write(data T) error {
    if err := dw.validator(data); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    // 并行写入 + 错误聚合(生产环境建议加超时与重试)
    return errors.Join(
        json.NewEncoder(dw.primary).Encode(data),
        json.NewEncoder(dw.secondary).Encode(data),
    )
}

逻辑分析validator 是可注入的泛型校验函数,支持对任意状态类型 T 执行字段完整性、状态机合法性等检查;errors.Join 提供双写原子性语义——任一失败即整体失败,避免脏写。

校验策略对比

策略 实时性 一致性保障 实现复杂度
单写+异步校验 弱(存在窗口)
双写+同步校验 强(强一致)
graph TD
    A[灰度路由] -->|权重分流| B[旧服务]
    A --> C[新服务]
    B --> D[双写入口]
    C --> D
    D --> E[泛型校验器]
    E --> F[主库写入]
    E --> G[影子库写入]

第五章:跨服合区工程落地总结与演进方向

工程实施关键里程碑回溯

2023年Q3启动跨服合区专项,覆盖《九州幻境》全12个大区、87组服务器。首期合区于2023年10月15日凌晨执行,涉及A1-A4四组服务器(原日均DAU 18.6万),合区后7日留存率提升23.7%,但登录峰值并发请求激增310%,触发网关限流策略。通过动态扩缩容机制,在3分钟内完成API网关实例从16→64节点的弹性伸缩,保障用户无感迁移。

数据一致性保障实践

采用双写+最终一致方案处理玩家资产数据。核心表如player_inventory启用MySQL GTID主从复制,并在应用层嵌入Binlog监听服务,对跨库转账类操作追加Redis事务校验锁(key: lock:trade:${uid}:${target_uid})。下表为合区后72小时内异常数据修复统计:

异常类型 发生次数 自动修复率 人工介入耗时(平均)
装备归属错位 142 92.3% 8.4分钟
帮派成员状态不一致 37 100%
邮件附件丢失 9 0% 22.6分钟

合区后性能瓶颈诊断

通过Arthas在线诊断发现,GuildService.mergeGuild()方法存在N+1查询问题:合并帮派时未批量加载成员离线消息,导致单次合区操作触发2300+次DB查询。优化后采用MyBatis BatchExecutor + 分页预加载策略,该接口P99响应时间由8.2s降至327ms。

客户端兼容性攻坚

旧版Android客户端(v4.2.1及以下)在合区后出现角色模型渲染异常。经逆向分析定位为Shader编译器版本差异:新服统一升级OpenGL ES 3.2,而旧客户端硬编码使用ES 2.0着色器。最终采用运行时Shader降级策略——客户端启动时探测GPU能力,自动加载vertex_v20.glsl/vertex_v32.glsl双版本着色器,兼容率从61%提升至99.8%。

-- 合区元数据快照SQL(用于灾备回滚)
INSERT INTO merge_snapshot (merge_id, src_servers, target_server, snapshot_time, data_hash)
SELECT 'MERGE-20231015-A1A4', '["A1","A2","A3","A4"]', 'A1-MERGE', NOW(), 
       MD5(CONCAT(GROUP_CONCAT(table_rows), GROUP_CONCAT(data_length)))
FROM information_schema.tables 
WHERE table_schema = 'game_db' AND table_name REGEXP '^(player|guild|mail)'; 

演进方向:构建可编程合区引擎

当前合区流程仍依赖人工配置JSON模板(如merge_config_v3.json),下一步将抽象为DSL规则引擎。示例规则片段:

flowchart LR
    A[读取合区策略] --> B{是否启用灰度}
    B -->|是| C[分流1%流量至新服]
    B -->|否| D[全量切换]
    C --> E[监控错误率>0.5%?]
    E -->|是| F[自动回滚并告警]
    E -->|否| D

运维自动化体系升级

已将Ansible Playbook重构为GitOps工作流:合区任务提交至ops/merge-manifests仓库,ArgoCD自动同步至K8s集群,执行前强制校验etcd中/config/merge/lock键值状态。2024年Q1计划接入混沌工程平台,对合区过程注入网络延迟、Pod驱逐等故障场景。

多维度监控看板建设

新建Grafana合区专属仪表盘,集成12项核心指标:包括跨服消息积压量(Kafka lag)、分服数据同步延迟(Prometheus采集Flink checkpoint间隔)、客户端崩溃率突增检测(ELK聚类分析)。当merge_progress_rate < 0.85持续5分钟时,自动触发SOP工单系统生成处置任务。

热爱 Go 语言的简洁与高效,持续学习,乐于分享。

发表回复

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