Posted in

并发刷题不丢题、不重复、不超时:Go中sync.Map+原子计数器+上下文取消的4重保障机制

第一章:并发刷题不丢题、不重复、不超时:Go中sync.Map+原子计数器+上下文取消的4重保障机制

在高并发刷题系统中,题目分发需严格满足「不丢题」(所有题目最终被处理)、「不重复」(同一题不被多个协程重复领取)、「不超时」(单题处理有硬性截止时间)。单一同步原语无法兼顾三者,需组合四层协同机制。

四重保障机制设计原理

  • 第一重:sync.Map 保证题库状态的无锁读写 —— 题目ID为key,值为*QuestionState{claimed bool, claimedAt time.Time, workerID string},支持高并发LoadOrStore原子插入与查询;
  • 第二重:原子计数器控制全局领取配额 —— 使用atomic.Int64跟踪已发放题目数,配合限流阈值防止突发洪峰压垮下游;
  • 第三重:context.WithTimeout 精确约束单题生命周期 —— 每次领取后立即创建带5s超时的子ctx,超时自动触发清理与重试标记;
  • 第四重:双重校验+CAS式更新确保状态幂等 —— 先Load确认未被领取,再CompareAndSwap更新状态,失败则重试或跳过。

关键代码实现

// 初始化共享状态
var (
    questionMap sync.Map // key: string(questionID), value: *QuestionState
    issuedCount atomic.Int64
)

func tryClaimQuestion(qid string, workerID string) (success bool) {
    now := time.Now()
    state := &QuestionState{Claimed: false, ClaimedAt: now, WorkerID: workerID}

    // LoadOrStore 返回已存在值或存入新值 + 是否存入成功
    if loaded, loadedOK := questionMap.LoadOrStore(qid, state); loadedOK {
        // 首次存入成功 → 可领取
        return true
    } else if qs, ok := loaded.(*QuestionState); ok && !qs.Claimed {
        // 已存在但未领取 → 尝试CAS抢占
        if atomic.CompareAndSwapUint32(&qs.ClaimedUint32, 0, 1) {
            qs.Claimed = true
            qs.ClaimedAt = now
            qs.WorkerID = workerID
            return true
        }
    }
    return false // 领取失败(已占用或CAS竞争失败)
}

超时与兜底策略

触发条件 处理动作 保障目标
context.DeadlineExceeded 自动调用questionMap.Delete(qid) 防止题被“卡死”
worker panic/崩溃 定期扫描ClaimedAt超20s的题并重置状态 不丢题
issuedCount.Load() >= limit 拒绝新领取,返回ErrRateLimited 不超时

第二章:高并发刷题场景下的核心问题建模与底层约束分析

2.1 刷题状态一致性建模:从竞态条件到状态机抽象

刷题平台中,用户同时在 Web 端提交、移动端标记“已掌握”、IDE 插件同步进度,极易引发状态撕裂。传统锁机制难以覆盖跨设备、离线重连等场景。

状态冲突的典型表现

  • 多端并发更新 solved_atreview_status 字段
  • 离线缓存未及时合并导致“已解锁题目”回退为“未解锁”

基于有限状态机(FSM)的统一建模

from enum import Enum

class ProblemState(Enum):
    LOCKED = "locked"      # 未解锁(依赖前置题)
    UNLOCKED = "unlocked"  # 可见但未尝试
    ATTEMPTED = "attempted" # 已提交但未通过
    SOLVED = "solved"      # 一次 AC
    MASTERED = "mastered"  # 三次间隔复习后标记

# 状态迁移规则(仅允许合法跃迁)
TRANSITIONS = {
    ProblemState.LOCKED: [ProblemState.UNLOCKED],
    ProblemState.UNLOCKED: [ProblemState.ATTEMPTED, ProblemState.LOCKED],
    ProblemState.ATTEMPTED: [ProblemState.SOLVED, ProblemState.UNLOCKED],
    ProblemState.SOLVED: [ProblemState.MASTERED, ProblemState.ATTEMPTED],
    ProblemState.MASTERED: [ProblemState.ATTEMPTED],  # 复习触发重做
}

该 FSM 强制所有状态变更经由 transition_to(new_state) 校验:参数 new_state 必须存在于当前状态的 TRANSITIONS[current] 列表中,否则抛出 InvalidTransitionError,从根源杜绝非法中间态。

状态同步保障机制

组件 职责 冲突消解策略
客户端 SDK 本地状态暂存 + 时间戳标记 基于向量时钟(Vector Clock)排序事件
同步网关 收敛多端操作流 按状态机路径优先级裁决(如 SOLVED > ATTEMPTED
状态审计服务 每日扫描不一致记录 自动回滚至最近合法快照
graph TD
    A[用户点击“提交”] --> B{状态校验}
    B -->|合法| C[写入带版本号的状态事件]
    B -->|非法| D[拒绝并返回推荐路径]
    C --> E[广播至各端状态机实例]
    E --> F[本地重放+冲突检测]

2.2 题目分发幂等性验证:基于哈希指纹与CAS语义的实践实现

为确保题目在分布式下发链路中不重复投递,我们采用「内容指纹 + 原子比较并设置(CAS)」双机制。

核心设计逻辑

  • 对题目 JSON 序列化后计算 SHA-256 得到唯一哈希指纹(fingerprint
  • 使用 Redis 的 SETNX + EXPIRE 组合模拟 CAS,或直接使用 SET key value EX seconds NX

关键代码实现

import hashlib
import json
import redis

def issue_question_safe(r: redis.Redis, question: dict) -> bool:
    # 生成确定性指纹:忽略空格/顺序,标准化键名与值类型
    normalized = json.dumps(question, sort_keys=True, separators=(',', ':'))
    fp = hashlib.sha256(normalized.encode()).hexdigest()[:16]  # 截取前16位降低存储开销

    # CAS式写入:仅当key不存在时设置,有效期30分钟防残留
    return r.set(f"issued:{fp}", "1", ex=1800, nx=True)

逻辑分析nx=True 实现原子性“不存在才设”,避免并发重复下发;ex=1800 防止指纹长期驻留;截取16位是经压测验证的冲突率

状态流转示意

graph TD
    A[题目进入分发队列] --> B{计算SHA-256指纹}
    B --> C[Redis SET issued:{fp} 1 EX 1800 NX]
    C -->|成功| D[标记为已下发]
    C -->|失败| E[跳过,视为重复]

2.3 并发吞吐瓶颈定位:pprof火焰图与goroutine泄漏实测诊断

火焰图快速捕获高耗时调用栈

go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30

该命令采集30秒CPU profile,自启Web服务生成交互式火焰图;-http指定监听地址,避免本地端口冲突。

goroutine泄漏的典型信号

  • /debug/pprof/goroutine?debug=2 返回全量goroutine堆栈
  • 持续增长的runtime.gopark调用占比(>85%)暗示阻塞型泄漏
指标 健康阈值 风险表现
goroutine 数量 >5k且线性增长
net/http.(*conn).serve 占比 >30%(空闲连接未回收)

定位泄漏根因

// 错误示例:未关闭HTTP响应体导致goroutine滞留
resp, _ := http.Get("http://api.example.com")
defer resp.Body.Close() // ❌ 缺失此行将泄漏goroutine+连接

resp.Body未关闭时,底层transport会保留读取goroutine等待EOF,持续占用资源。

graph TD
A[HTTP请求] –> B{resp.Body.Close()调用?}
B –>|是| C[goroutine正常退出]
B –>|否| D[goroutine阻塞在readLoop]
D –> E[连接池复用失败+新goroutine累积]

2.4 超时传播链路剖析:从HTTP客户端到业务层context.WithTimeout穿透实践

超时不应是孤立配置,而需沿调用链逐层透传。context.WithTimeout 是实现跨层超时协同的核心机制。

超时穿透关键路径

  • HTTP 客户端(http.Client.Timeout)仅控制连接/读写阶段,不覆盖业务逻辑耗时
  • 中间件与服务层必须显式接收并传递 context.Context
  • 数据库驱动、RPC 客户端等下游组件需支持 ctx 参数并响应取消信号

典型穿透代码示例

func HandleOrder(ctx context.Context, orderID string) error {
    // 业务层注入超时(例如:总链路≤3s)
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // 向下游透传 ctx
    return fetchUser(ctx, orderID) // ← 此处 ctx 已携带 deadline
}

逻辑分析:context.WithTimeout 返回新 ctxcancel 函数;defer cancel() 防止 goroutine 泄漏;所有子调用必须接收该 ctx 并在阻塞操作中检查 ctx.Done()

超时传播状态对照表

组件层 是否响应 ctx.Done() 超时是否影响上层?
net/http ✅(通过 http.Client
database/sql ✅(QueryContext
自定义 RPC ❌(若未接入 ctx) 否(造成超时断裂)
graph TD
    A[HTTP Server] -->|ctx with 3s deadline| B[Handler]
    B --> C[Service Layer]
    C --> D[DB QueryContext]
    C --> E[HTTP Client Do]
    D & E --> F[Deadline Hit? → ctx.Err()]

2.5 分布式ID与本地序号协同:确保题目唯一标识的双保险设计

在高并发题库系统中,单靠雪花算法生成的分布式ID(如 1234567890123456789)虽全局唯一,但缺乏业务语义;而纯本地自增序号(如 Q-0001)易在分库分表下冲突。二者协同可兼顾唯一性与可读性。

双标识生成策略

  • 分布式ID作为主键,保障数据库写入无冲突
  • 本地序号按「科目+年份」维度独立计数,格式化为 MATH-2024-0001

格式化示例代码

public String generateQuestionCode(long snowflakeId, int localSeq, String subject) {
    return String.format("%s-%d-%04d", subject, Year.now().getValue(), localSeq);
}
// snowflakeId:64位长整型,用于DB主键与幂等校验
// localSeq:Redis INCR 原子计数器返回值,隔离于分片维度
// subject:业务标识,避免跨科目序号复用

协同校验流程

graph TD
    A[接收题目创建请求] --> B{查本地序号缓存}
    B -->|命中| C[INCR并生成code]
    B -->|未命中| D[初始化为1]
    C --> E[写入DB:snowflakeId + code]
组件 作用 容错能力
Snowflake ID 全局唯一主键
Redis本地序号 业务友好编码前缀 最终一致

第三章:四重保障机制的协同设计原理与关键接口契约

3.1 sync.Map在高频读写场景下的性能边界与替代策略权衡

数据同步机制

sync.Map 采用读写分离+懒惰删除设计:读操作无锁,写操作仅对 dirty map 加锁。但当 misses 达到 dirty 长度时触发 dirty 升级,引发全量键拷贝,成为写密集型场景的隐性瓶颈。

性能拐点实测(100万次操作,8核)

场景 平均延迟 (ns) GC 压力 并发安全
95% 读 + 5% 写 8.2
50% 读 + 50% 写 217.6 中高
纯写(含删除) 493.1
// 模拟写竞争热点:频繁 Store/Load 同一键
var m sync.Map
for i := 0; i < 1e6; i++ {
    m.Store("hot_key", i) // 触发 dirty map 扩容与原子指针替换
    _, _ = m.Load("hot_key") // 读路径虽快,但写导致 read map stale
}

该循环持续触发 read 失效→misses++dirty 提升,每次提升需 O(n) 拷贝 entry,且 atomic.StorePointer 在高争用下存在缓存行伪共享开销。

替代方案权衡

  • sharded map(如 github.com/orcaman/concurrent-map:分片锁降低冲突,但内存占用翻倍;
  • RWMutex + map[string]any:读吞吐更高,但写阻塞全部读;
  • fastmap(基于 CAS 的无锁哈希):写延迟更稳,但不兼容 nil value 语义。
graph TD
    A[高频读写请求] --> B{读写比 > 9:1?}
    B -->|是| C[sync.Map 推荐]
    B -->|否| D[评估分片/RCU/专用结构]
    D --> E[写延迟敏感 → sharded]
    D --> F[读一致性要求高 → RWMutex+copy-on-write]

3.2 原子计数器(atomic.Int64)在任务进度追踪中的无锁同步实践

数据同步机制

传统 sync.Mutex 在高频更新任务进度时易成性能瓶颈。atomic.Int64 提供硬件级 CAS 指令保障单变量读写原子性,避免锁开销。

核心实现示例

var progress atomic.Int64

// 模拟工作协程持续上报进度(0~10000)
func report(i int) {
    progress.Add(int64(i)) // 线程安全累加
}

// 主协程实时读取当前值
func current() int64 {
    return progress.Load() // 无锁读取快照
}

Add() 执行带符号整数原子加法,底层映射为 LOCK XADD 指令;Load() 对应 MOV + 内存屏障,确保可见性。

性能对比(10万次更新/秒)

同步方式 平均延迟 CPU 占用
sync.Mutex 842 ns 32%
atomic.Int64 9.3 ns 5%
graph TD
    A[任务分发] --> B[Worker 1: report(120)]
    A --> C[Worker 2: report(87)]
    A --> D[Worker N: report(215)]
    B & C & D --> E[progress.Add]
    E --> F[主循环 Load→展示]

3.3 context.Context取消信号的跨层传递与资源清理钩子注册机制

context.Context 的核心价值在于取消信号的穿透式传播确定性资源回收。它不依赖返回值或异常,而是通过 Done() channel 广播终止意图,并允许各层注册不可撤销的清理回调。

取消信号的跨层穿透

调用链中每一层只需接收父 Context,调用 context.WithCancel/WithTimeout 创建子上下文,信号即可自动逐层向下游广播:

func serve(ctx context.Context) {
    dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 防止 goroutine 泄漏
    // … 使用 dbCtx 访问数据库
}

cancel() 是“退出通知触发器”,不阻塞;dbCtx.Done() 在父 ctx 被取消或超时时立即关闭,驱动 I/O 中断与提前返回。

清理钩子注册机制

Go 标准库未内置钩子注册 API,但可通过 context.WithValue + 外部管理器实现(如 errgroup.Group 或自定义 CleanupGroup):

组件 职责 生命周期
context.Context 信号广播载体 只读、不可变
cleanup.Register(ctx, fn) fn() 绑定到 ctx 取消事件 仅在 ctx.Done() 关闭后执行一次
graph TD
    A[Client Request] --> B[HTTP Handler]
    B --> C[Service Layer]
    C --> D[DB Query]
    D --> E[Network Dial]
    E -.->|ctx.Done() close| B
    B -.->|defer cleanup.Run| C
    C -.->|run all registered fn| D & E

第四章:生产级刷题服务的工程落地与稳定性验证

4.1 四重保障机制集成:题目缓存层、分发调度器、执行沙箱、结果归集器的职责切分

四重保障机制通过职责解耦实现高并发判题系统的稳定性与可扩展性。

各组件核心职责

  • 题目缓存层:预加载题目元数据与测试用例,支持 LRU 驱动的 TTL 缓存(默认 15min)
  • 分发调度器:基于权重+队列深度的动态路由,避免热点题目阻塞
  • 执行沙箱:隔离进程+资源配额(CPU 200ms / 内存 128MB),强制超时熔断
  • 结果归集器:聚合多沙箱实例输出,校验一致性并生成结构化 verdict(AC/RE/TLE 等)

调度逻辑示例

def route_task(problem_id: str, workers: List[Worker]) -> Worker:
    # 优先选择负载最低且已缓存题目的节点
    cached_workers = [w for w in workers if problem_id in w.cache_keys]
    return min(cached_workers, key=lambda w: w.queue_length) \
        if cached_workers else min(workers, key=lambda w: w.queue_length)

problem_id 触发缓存亲和性路由;queue_length 实时反映处理压力,避免单点过载。

组件协作流程

graph TD
    A[题目缓存层] -->|提供 test cases & limits| B[分发调度器]
    B -->|分配 task + sandbox config| C[执行沙箱]
    C -->|stdout/stderr/exit_code| D[结果归集器]
    D -->|统一 verdict + performance metrics| E[API 响应]

4.2 压力测试方案:使用ghz+自定义负载生成器模拟万级并发刷题流

为精准复现真实用户高峰刷题行为,我们采用双引擎协同压测策略:ghz 负责标准 gRPC 接口基准验证,自定义 Go 负载生成器实现动态题库轮询与会话保持。

核心工具选型对比

工具 并发粒度 动态参数支持 会话状态管理 适用场景
ghz 连接级 ✅(JSON 模板) 接口吞吐/延迟基线
自定义生成器 用户级(JWT + sessionID) ✅(实时题库拉取) ✅(HTTP Cookie + gRPC metadata) 行为仿真(如连续5题不重复)

ghz 基准命令示例

ghz --insecure \
  --proto ./api/problem.proto \
  --call pb.ProblemService.SubmitSolution \
  -d '{"problem_id":"p1001","code":"print(1)"}' \
  -n 50000 -c 2000 \
  --rps 1000 \
  grpc-prod.example.com:443

逻辑分析-c 2000 启动2000个并发连接,--rps 1000 限速每秒1000次调用,避免突发洪峰掩盖服务瓶颈;-n 50000 总请求数确保统计置信度。--insecure 用于内网直连跳过 TLS 开销,聚焦业务层性能。

动态负载生成流程

graph TD
  A[读取用户行为配置] --> B[拉取实时题库元数据]
  B --> C[按难度/标签生成题序]
  C --> D[注入JWT & sessionID]
  D --> E[发起gRPC SubmitSolution]
  E --> F[记录响应延迟与错误码]

4.3 故障注入验证:通过goleak+failpoint模拟网络抖动、GC停顿与panic恢复路径

在高可用服务开发中,仅靠单元测试无法覆盖真实运行时异常。我们组合 goleak(检测 goroutine 泄漏)与 failpoint(动态注入故障点),构建可复现的混沌验证链。

故障注入三类典型场景

  • 网络抖动:在 http.RoundTrip 前插入随机延迟
  • GC停顿:在关键临界区触发 runtime.GC()time.Sleep(50ms) 模拟 STW 影响
  • panic恢复路径:在 handler 中主动 panic,并验证 recover() + http.Error 的兜底行为

failpoint 注入示例

// 在 client.go 的 Send 方法内启用故障点
failpoint.Inject("network_jitter", func(val failpoint.Value) {
    if delay, ok := val.(int); ok {
        time.Sleep(time.Duration(delay) * time.Millisecond) // 可通过环境变量控制:FAILPOINTS="network_jitter=120"
    }
})

该代码在编译期被 failpoint-ctl 处理为条件编译桩;运行时通过 FAILPOINTS 环境变量动态启用,延迟值可热更新,避免重启服务。

验证维度对照表

故障类型 检测工具 观察指标 恢复预期
网络抖动 goleak + pprof goroutine 数量突增 连接池自动驱逐超时连接
GC停顿 runtime/trace GC pause > 30ms 标记 请求延迟 P99 ≤ 200ms
panic HTTP status 500 recover 日志是否输出 无 goroutine 泄漏,状态码可控
graph TD
    A[启动服务] --> B{启用 failpoint}
    B --> C[注入 network_jitter]
    B --> D[注入 gc_pause]
    B --> E[注入 panic_handler]
    C --> F[goleak 检查 goroutine 泄漏]
    D --> F
    E --> F
    F --> G[生成验证报告]

4.4 灰度发布策略:基于feature flag控制保障机制开关与指标熔断联动

灰度发布需兼顾灵活性与安全性,核心在于将功能开关(Feature Flag)与实时监控指标深度耦合。

动态开关与熔断协同逻辑

当服务请求错误率 >5% 持续60秒,自动关闭 payment-v2 标志:

# 基于OpenFeature + Sentinel的联动示例
from openfeature import api
from sentinel.metrics import MetricEvent

def on_metric_alert(event: MetricEvent):
    if event.name == "error_rate" and event.value > 0.05:
        api.get_client().set_provider_context({
            "env": "prod",
            "flags": {"payment-v2": False}  # 熔断即刻生效
        })

此逻辑通过 OpenFeature 上下文热更新实现毫秒级开关切换;set_provider_context 触发所有后续 getStringEvaluation("payment-v2") 返回 False,无需重启。

关键参数说明

  • error_rate:采样窗口内 5xx/总请求比值
  • 0.05:业务可容忍失败阈值(可配置)
  • payment-v2:语义化 flag key,支持多环境隔离

熔断状态映射表

状态类型 触发条件 开关动作 恢复方式
预警 错误率 >3% × 30s 日志告警 自动降级不干预
熔断 错误率 >5% × 60s flag = false 人工审核+健康检查通过
graph TD
    A[请求流量] --> B{Feature Flag?}
    B -- true --> C[执行新逻辑]
    B -- false --> D[走旧路径]
    C --> E[上报指标]
    E --> F{错误率超阈值?}
    F -- yes --> G[动态关闭Flag]
    G --> B

第五章:总结与展望

技术栈演进的现实路径

在某大型金融风控平台的三年迭代中,团队将原始基于 Spring Boot 2.1 + MyBatis 的单体架构,逐步迁移至 Spring Boot 3.2 + Jakarta EE 9 + R2DBC 响应式数据层。关键转折点发生在第18个月:通过引入 r2dbc-postgresql 驱动与 Project Reactor 的组合,将高并发反欺诈评分接口的 P99 延迟从 420ms 降至 68ms,同时数据库连接池占用下降 73%。该实践验证了响应式编程并非仅适用于“玩具项目”,而可在强事务一致性要求场景下稳定落地——其核心在于将非阻塞 I/O 与领域事件驱动模型深度耦合,例如用 Mono.zipWhen() 实现信用分计算与实时黑名单校验的并行编排。

工程效能的真实瓶颈

下表对比了 2022–2024 年间三个典型微服务模块的 CI/CD 效能指标变化:

模块名称 构建耗时(平均) 测试覆盖率 部署失败率 关键改进措施
账户服务 8.2 min → 2.1 min 64% → 89% 12.7% → 1.3% 引入 Testcontainers + 并行模块化测试
支付网关 14.5 min → 3.7 min 51% → 76% 23.1% → 0.8% 迁移至 Gradle Configuration Cache + 自定义 JVM 参数调优
实时对账引擎 22.3 min → 5.9 min 47% → 82% 18.4% → 2.1% 采用 Quarkus 原生镜像 + 编译期反射白名单

值得注意的是,部署失败率下降主因并非工具升级,而是将 Kubernetes Helm Chart 的 values.yaml 校验逻辑内嵌至 CI 流水线的 pre-deploy 阶段,并强制执行 helm template --dry-run 验证。

生产环境可观测性闭环

某电商大促期间,通过 OpenTelemetry Collector 的自定义 Processor 实现了链路追踪数据的动态采样策略:当 /api/v2/order/submit 接口错误率突破 0.5% 时,自动将该服务所有 Span 的采样率从 1% 提升至 100%,并触发 Prometheus AlertManager 向 SRE 群组推送结构化告警(含 TraceID、Pod 名称、上游调用方)。该机制使故障定位时间从平均 27 分钟缩短至 4.3 分钟。以下是关键配置片段:

processors:
  probabilistic_sampler:
    hash_seed: 42
    sampling_percentage: 1.0
  attribute_filter:
    include:
      attributes:
      - key: http.status_code
        value: "5xx"

未来技术债的量化管理

团队已建立技术债看板,对每个待重构项标注三类成本:

  • 人力成本:基于 Git 历史提交估算(如 git log --author="tech-debt" --oneline | wc -l
  • 风险成本:关联线上事故数(如 SELECT COUNT(*) FROM incidents WHERE cause LIKE '%legacy-auth%'
  • 机会成本:测算新功能延迟上线天数(如 “JWT 替换 Session” 延误 3 个营销活动上线)

当前 Top3 高优先级债务为:遗留 SOAP 接口适配器(年均修复工时 186h)、Elasticsearch 6.x 升级(影响 7 个搜索相关功能迭代)、Kafka 旧版消费者组重平衡问题(导致日均 23 次消费延迟告警)。

多云架构下的流量治理实验

在混合云环境中,使用 Istio 1.21 的 VirtualService 实现灰度路由策略:将 5% 的生产流量导向 Azure 上的预发集群,其请求头自动注入 x-env: azure-staging,并通过 Envoy Filter 将该 Header 透传至后端服务。该方案已支撑 12 次重大版本发布,成功拦截 3 次因云厂商网络 DNS 解析差异引发的会话失效问题。

graph LR
  A[用户请求] --> B{Istio Ingress Gateway}
  B -->|95% 流量| C[阿里云生产集群]
  B -->|5% 流量| D[Azure 预发集群]
  C --> E[Session Redis Cluster]
  D --> F[独立 Azure Cache for Redis]
  F -->|同步会话元数据| G[(Kafka Topic: session-sync)]
  E --> G

专治系统慢、卡、耗资源,让服务飞起来。

发表回复

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