Posted in

Golang云平台面试最后1小时急救包:5类必考场景题(分布式锁/幂等/灰度发布/配置热更/可观测性)

第一章:Golang云平台面试全景概览

Golang 因其并发模型轻量、编译高效、部署简洁等特性,已成为云原生基础设施(如 Kubernetes、Docker、Terraform、etcd)的核心实现语言。云平台岗位对 Go 工程师的考察已远超基础语法,聚焦于分布式系统设计能力、云原生工具链理解深度及高可用工程实践。

核心能力维度

  • 语言底层机制:goroutine 调度器工作原理、GC 触发时机与调优策略、unsafe 与 reflect 的安全边界
  • 云原生开发范式:Operator 模式实现、CRD 定义与 Controller 编写、gRPC/HTTP/protobuf 多协议服务集成
  • 可观测性工程:OpenTelemetry SDK 埋点、结构化日志(Zap/Slog)、Prometheus 指标暴露与自定义 Collector 开发
  • 基础设施交互:Kubernetes client-go 实战(Watch Pod 状态变更、动态 Patch Deployment)、AWS SDK for Go v2 异步批处理

典型实操题示例

以下代码演示如何使用 client-go 监听集群中所有命名空间的 Pod 创建事件,并打印其 IP 与节点名:

// 初始化 rest.Config 并构建 Clientset(省略 kubeconfig 加载逻辑)
config, _ := rest.InClusterConfig() // 或 rest.InClusterConfig()
clientset, _ := kubernetes.NewForConfig(config)

// Watch Pod 资源变更
watcher, _ := clientset.CoreV1().Pods("").Watch(context.TODO(), metav1.ListOptions{})
for event := range watcher.ResultChan() {
    if pod, ok := event.Object.(*corev1.Pod); ok && event.Type == watch.Added {
        fmt.Printf("New Pod: %s/%s, IP: %s, Node: %s\n",
            pod.Namespace, pod.Name, pod.Status.PodIP, pod.Spec.NodeName)
    }
}

该逻辑需在 Pod 中以 ServiceAccount 运行,并配置 pods/watch RBAC 权限。

面试技术栈分布(近12个月主流云厂商抽样)

考察方向 出现频率 常见子项
并发与内存模型 92% channel 死锁分析、sync.Map 使用场景
Kubernetes 集成 87% Informer 机制、LeaderElection 实现
微服务治理 76% OpenTracing 上下文透传、熔断器定制
安全与合规 53% TLS 双向认证、Secret 注入最佳实践

面试官常通过白板编码要求现场修复 goroutine 泄漏问题,或基于给定 API Server 日志片段推导 etcd 请求路径。

第二章:分布式锁的原理与实战落地

2.1 基于Redis的Redlock算法在Go中的正确实现与边界规避

Redlock并非官方Redis协议,而是Antirez提出的分布式锁容错方案:需向≥3个独立Redis节点(主节点,无从属关系)并发申请锁,多数派(N/2+1)成功且总耗时低于锁TTL才视为获取成功。

核心实现要点

  • 锁key需含唯一client ID(如UUID),避免误删他人锁
  • 每次SET命令必须使用SET key value NX PX ttl_ms原子指令
  • 解锁必须通过Lua脚本校验value一致性,防止释放他人锁

Go中关键参数配置

参数 推荐值 说明
quorum len(nodes)/2 + 1 最小成功节点数
retryDelay 50–200ms 网络抖动下退避间隔
lockTTL max(processingTime, 5*retryDelay) 防止业务未完成即过期
// 安全解锁Lua脚本(必须原子执行)
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end`

// 调用示例(redis-go/v9)
result, err := rdb.Eval(ctx, unlockScript, []string{lockKey}, clientID).Int()

该脚本确保仅当key存在且value匹配当前clientID时才删除,规避了“误删”和“重复释放”双重风险;clientID作为全局唯一标识,是跨进程/重启状态一致性的基石。

2.2 Etcd分布式锁的Lease机制与Session保活实践

Etcd 的 Lease 是实现分布式锁可靠性的核心——它将锁的持有权与租约生命周期强绑定,避免节点宕机导致死锁。

Lease 生命周期管理

创建 Lease 时需指定 TTL(如 15s),并配合 KeepAlive 流式续期:

leaseResp, _ := cli.Grant(ctx, 15) // 创建15秒TTL租约
leaseID := leaseResp.ID

// 启动后台保活
ch, _ := cli.KeepAlive(ctx, leaseID)
go func() {
    for range ch { /* 续期成功事件 */ }
}()

Grant 返回唯一 leaseIDKeepAlive 返回持续接收续期响应的 channel,失败时 channel 关闭,需重连重建 Lease。

Session 封装简化逻辑

etcd clientv3 提供 Session 自动处理 Lease 创建、续期与过期回调:

特性 Session 封装 原生 Lease 手动管理
租约创建 ✅ 自动 ✅ 需显式 Grant
心跳续期 ✅ 自动后台 ✅ 需手动 KeepAlive
过期监听 Done() channel ✅ 需监听 ch 关闭

锁获取与保活协同流程

graph TD
    A[客户端请求加锁] --> B{Lease 是否有效?}
    B -->|是| C[写入 key + leaseID]
    B -->|否| D[重建 Lease]
    C --> E[启动 Session 保活]
    E --> F[定期 KeepAlive]

2.3 数据库乐观锁在高并发订单场景下的Go建模与冲突退避

核心建模:版本号字段与结构体映射

type Order struct {
    ID        int64  `db:"id"`
    UserID    int64  `db:"user_id"`
    Status    string `db:"status"` // "pending", "confirmed", "cancelled"
    Version   int64  `db:"version"` // 乐观锁核心:每次更新需匹配当前version
    UpdatedAt time.Time `db:"updated_at"`
}

Version 字段作为CAS(Compare-and-Swap)依据,初始为0;更新时使用 WHERE id = ? AND version = ? 确保无中间修改。若RowsAffected == 0,即发生版本冲突。

冲突退避策略:指数退避重试

  • 首次失败后等待 10ms
  • 每次重试延迟翻倍,上限 120ms
  • 最多重试 3 次,超限返回 ErrOrderConflict

退避执行流程

graph TD
    A[Load Order with Version] --> B{Update DB with WHERE version=?}
    B -->|Success| C[Return OK]
    B -->|Failed: RowsAffected=0| D[Sleep + Increment Retry]
    D --> E{Retry < 3?}
    E -->|Yes| A
    E -->|No| F[Return Conflict Error]
重试次数 延迟时间 适用场景
1 10 ms 瞬时竞争(如秒杀首波)
2 20 ms 中等负载下的短暂争用
3 40 ms 高频写入的临界窗口

2.4 分布式锁的可重入性设计与goroutine安全上下文传递

可重入性核心挑战

传统 Redis 分布式锁(如 SET NX PX)无法识别同一 goroutine 的多次加锁请求,易导致死锁或误释放。可重入需唯一标识持有者,并支持嵌套计数。

goroutine 安全的上下文绑定

利用 context.WithValue 将锁 token 与 goroutine 生命周期绑定,避免跨协程误操作:

// 持有者标识:goroutine ID + 随机token(防伪造)
type ReentrantLock struct {
    client redis.Cmdable
    key    string
    token  string // "gid:12345:rand-abc"
    count  int    // 重入次数
}

func (l *ReentrantLock) Lock(ctx context.Context) error {
    // 原子校验+自增:Lua 脚本保障 CAS 安全
    script := `
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            redis.call("INCR", KEYS[2])  -- 计数key
            return 1
        else
            return redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
        end`
    // ... 执行脚本并解析结果
}

逻辑分析:脚本先比对当前锁 token 是否匹配本 goroutine 标识;匹配则递增计数器(KEYS[2]),否则尝试争抢新锁。ARGV[2] 为租期毫秒数,确保自动续期安全边界。

重入状态管理对比

维度 简单锁 可重入锁
持有者识别 仅 token token + goroutine ID
释放逻辑 无条件删 key 计数 > 1 则 decr,=0 才 del
上下文隔离 全局共享 context.WithValue() 绑定
graph TD
    A[goroutine 进入] --> B{已持锁?}
    B -- 是 --> C[原子 incr 计数器]
    B -- 否 --> D[执行 SET NX PX]
    C & D --> E[更新本地 count 字段]
    E --> F[返回成功]

2.5 锁失效、脑裂与时钟漂移下的Go服务容错验证方案

在分布式环境中,单机锁(如 sync.Mutex)无法跨进程保证互斥,而基于 Redis 或 Etcd 的分布式锁易受网络分区、时钟漂移与租约续期失败影响,导致锁提前释放或重复持有。

数据同步机制

采用带 Lease ID 和 Revision 检查的 Etcd 分布式锁,配合客户端本地单调时钟校验:

// 使用 etcdv3 客户端实现带时钟漂移防护的锁获取
resp, err := cli.Grant(ctx, 10) // 租约 TTL=10s,非依赖系统时钟
if err != nil { panic(err) }
_, err = cli.Put(ctx, "/lock/order-123", "owner-A", clientv3.WithLease(resp.ID))
// 后续操作前校验:Get + WithRev 保证操作基于最新 revision

逻辑分析:Grant 返回的 lease 由 etcd 服务端统一管理生命周期,规避客户端 NTP 调整导致的时钟倒退;WithLease 确保键绑定租约,自动过期;WithRev 防止脑裂后旧 leader 的脏写。

容错验证维度

场景 触发方式 验证指标
锁失效 主动 revoke lease 是否触发 fallback 逻辑
脑裂(双主) 模拟网络分区 是否仅一个节点执行关键路径
时钟漂移 容器内 mock time.Now 租约是否仍按服务端时间到期
graph TD
    A[客户端发起锁请求] --> B{Etcd 返回 lease ID}
    B --> C[Put with Lease]
    C --> D[定期 KeepAlive]
    D -->|失败| E[主动释放并重试]
    D -->|成功| F[执行业务逻辑]

第三章:幂等性保障体系构建

3.1 基于唯一业务ID+状态机的HTTP接口幂等实现

核心思想:客户端携带全局唯一 bizId(如订单号、支付流水号),服务端结合状态机原子校验与更新,避免重复执行。

状态流转约束

  • 初始态 INIT → 处理中 PROCESSING → 成功 SUCCESS / 失败 FAILED
  • 仅允许合法跃迁(如 INIT → PROCESSING,禁止 SUCCESS → PROCESSING

幂等校验代码示例

// 基于数据库乐观锁 + 状态机校验
int updated = jdbcTemplate.update(
    "UPDATE order_task SET status = ?, updated_at = ? " +
    "WHERE biz_id = ? AND status = ?",
    PROCESSING, now(), bizId, INIT);
if (updated == 0) {
    // 已存在非INIT状态,查当前状态并返回对应结果
    return queryCurrentStatus(bizId); // 幂等响应
}

逻辑分析:WHERE biz_id = ? AND status = ? 确保仅当处于初始态时才允许进入处理;updated == 0 表明已被处理过,直接查询当前状态返回,不重复执行业务逻辑。参数 bizId 由客户端生成并保证全局唯一,status 为枚举值,强约束状态合法性。

状态迁移合法性表

当前状态 允许目标状态 说明
INIT PROCESSING 首次提交
PROCESSING SUCCESS/FAILED 处理完成
SUCCESS 终态,不可再变更
FAILED PROCESSING 仅支持重试(需幂等重入)
graph TD
    INIT -->|submit| PROCESSING
    PROCESSING -->|success| SUCCESS
    PROCESSING -->|fail| FAILED
    FAILED -->|retry| PROCESSING
    SUCCESS -.->|reject| PROCESSING

3.2 消息队列消费端的At-Least-Once语义与Go幂等存储策略

消息队列(如 Kafka、RabbitMQ)默认提供 At-Least-Once 投递保障:消费者可能重复收到同一条消息。为避免业务重复处理,需在消费端实现幂等性。

数据同步机制

核心思路:将「消息唯一标识(如 msg_id + topic_partition_offset)」作为幂等键,写入支持原子写入的存储(如 Redis 或本地 BoltDB)。

// 使用 Redis SETNX 实现幂等写入(带过期时间防堆积)
ok, err := redisClient.SetNX(ctx, "idempotent:"+msgID, "1", 24*time.Hour).Result()
if err != nil {
    log.Fatal(err)
}
if !ok {
    log.Printf("duplicate message ignored: %s", msgID)
    return // 幂等拒绝
}

SetNX 原子判断并设置;24h TTL 防止键无限累积;msgID 应由生产端生成(如 UUID 或 traceID+seq),确保全局唯一。

存储选型对比

存储方案 原子性 持久化 适用场景
Redis ⚠️(可配) 高吞吐、容忍短时丢失
PostgreSQL ✅(INSERT … ON CONFLICT) 强一致性、审计要求高
SQLite/BoltDB ✅(事务内) 单机消费、无外部依赖

幂等校验流程

graph TD
    A[收到消息] --> B{查幂等键是否存在?}
    B -- 是 --> C[跳过处理]
    B -- 否 --> D[写入幂等键]
    D --> E[执行业务逻辑]
    E --> F[提交位点/ACK]

3.3 幂等Key生成算法(如SHA256+结构体序列化)的性能与安全性权衡

幂等Key的核心目标是在语义一致的前提下,确保输入结构体生成唯一、稳定、抗碰撞的哈希值。

序列化方式决定安全边界

  • JSON序列化:可读性强,但字段顺序不保证(需预排序),易受空格/换行干扰
  • Protocol Buffers二进制序列化:确定性编码(--experimental_allow_proto3_optional启用后),体积小、速度快,天然规避格式歧义

SHA256哈希性能实测对比(1KB结构体,10万次)

序列化方式 平均耗时(μs) 冲突率(1e9样本)
JSON(sorted) 84.2
Protobuf binary 21.7
func GenerateIdempotentKey(req *OrderRequest) string {
    // 使用gogoprotobuf的MarshalStrict确保确定性二进制输出
    data, _ := proto.Marshal(req) // 不含未知字段、无默认值填充扰动
    return fmt.Sprintf("%x", sha256.Sum256(data)) // 固定32字节输出,避免base64变长开销
}

逻辑分析:proto.Marshal替代json.Marshal消除Unicode归一化、浮点精度、字段顺序等非确定性源;sha256.Sum256返回栈上固定大小结构体,避免堆分配,提升缓存局部性。参数req需为已验证的洁净结构体(无未初始化指针或NaN字段)。

graph TD
A[原始结构体] –> B{序列化策略}
B –>|JSON+排序| C[文本哈希]
B –>|Protobuf binary| D[二进制哈希]
C –> E[高可读性/中延迟]
D –> F[低延迟/强确定性]

第四章:云原生关键发布与运维能力

4.1 基于Service Mesh的灰度路由规则配置与Go微服务标签透传

在 Istio 环境中,灰度发布依赖于 VirtualServiceDestinationRule 协同实现流量切分。核心在于将业务标签(如 version: v2-canary)从入口网关透传至后端 Go 微服务。

标签透传机制

Go 服务需从 HTTP 请求头(如 x-envoy-downstream-service-cluster 或自定义 x-canary-tag)提取灰度标识,并通过上下文传递:

// 从入站请求头提取灰度标签
func extractCanaryTag(r *http.Request) string {
    tag := r.Header.Get("x-canary-tag") // 由 Envoy 注入
    if tag == "" {
        tag = "stable" // 默认分流标签
    }
    return tag
}

逻辑说明:Istio Sidecar 自动注入 x-canary-tag 头(需在 EnvoyFilter 中配置 header manipulation),Go 服务无需修改业务逻辑即可获取路由上下文;r.Header.Get 安全读取,空值兜底保障服务可用性。

路由规则关键字段对照

字段 示例值 作用
match.headers["x-canary-tag"] exact: "v2-canary" 匹配灰度请求
route.weight 20 百分比流量权重
destination.subset canary 指向带 label 的 Pod

流量控制流程

graph TD
    A[Ingress Gateway] -->|添加 x-canary-tag| B[Sidecar Proxy]
    B --> C[Go 微服务]
    C -->|读取 header 并记录 trace| D[日志/监控系统]

4.2 配置中心(Nacos/Consul)驱动的Go应用热更新:监听、原子切换与回滚机制

核心设计原则

  • 监听即响应:基于长轮询或事件推送(如 Nacos 的 WatchConfig 或 Consul 的 Watch API)建立实时通道
  • 切换即原子:新配置加载完成前,旧配置持续生效;切换通过指针原子赋值(atomic.StorePointer)实现零停顿
  • 回滚即快照:保留最近3版配置快照,异常时毫秒级还原

配置热加载示例(Nacos)

// 使用 nacos-sdk-go v2.x 监听并原子更新
client, _ := vo.NewClient(vo.Config{
    ServerConfigs: []constant.ServerConfig{{ContextPath: "/nacos", IpAddr: "127.0.0.1", Port: 8848}},
})
configClient, _ := clients.CreateConfigClient(map[string]interface{}{"client": client})

var currentConfig unsafe.Pointer // 指向 *AppConfig 的原子指针

configClient.ListenConfig(vo.ConfigParam{
    DataId: "app.yaml",
    Group:  "DEFAULT_GROUP",
    OnChange: func(namespace, group, dataId, data string) {
        newCfg := parseYAML(data) // 解析为结构体
        atomic.StorePointer(&currentConfig, unsafe.Pointer(&newCfg))
    },
})

逻辑分析atomic.StorePointer 确保指针更新对所有 goroutine 瞬时可见;unsafe.Pointer 绕过 GC 引用计数,需配合 runtime.KeepAlive(newCfg) 防止提前回收(实际生产中建议使用 sync.Map 封装)。参数 OnChange 是唯一回调入口,承载解析、校验、切换全链路。

回滚能力对比

能力 Nacos 支持 Consul 支持 备注
版本快照 ✅(历史版本) ✅(KV TTL + 自建快照) Nacos 原生提供版本号追溯
自动触发回滚 需业务层结合健康检查实现
graph TD
    A[配置变更事件] --> B{校验通过?}
    B -->|是| C[加载新配置到内存]
    B -->|否| D[触发告警并跳过]
    C --> E[原子替换 currentConfig 指针]
    E --> F[通知各模块 reload]
    F --> G[启动回滚看门狗:5s内指标异常则自动切回上一版]

4.3 OpenTelemetry SDK集成:Go服务中Trace、Metrics、Logs的统一采集与上下文注入

OpenTelemetry 提供统一的可观测性信号采集能力,Go SDK 支持在单进程内协同注入 trace context、记录指标与结构化日志。

初始化全局 SDK

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/sdk/resource"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initTracer() {
    r, _ := resource.Merge(
        resource.Default(),
        resource.NewWithAttributes(semconv.SchemaURL,
            semconv.ServiceNameKey.String("order-service"),
        ),
    )
    // 配置 exporter(如 OTLP)、trace provider 和 propagator
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.TraceContext{})
}

该初始化建立全局 TracerProvider 与 W3C TraceContext 传播器,确保 HTTP 中间件、gRPC 拦截器等可自动透传 span context。

三信号协同示例

信号类型 注入方式 上下文关联机制
Trace tracer.Start(ctx) ctx 携带 span context
Metrics meter.Int64Counter("http.requests") 自动绑定当前 span
Logs log.With("trace_id", trace.SpanFromContext(ctx).SpanContext().TraceID().String()) 手动提取或使用 log bridge

数据同步机制

graph TD
    A[HTTP Handler] --> B[Start Span]
    B --> C[Record Metric]
    B --> D[Inject TraceID into Log Fields]
    C & D --> E[Export via OTLP]

4.4 可观测性Pipeline实战:从Go程序埋点到Prometheus+Grafana+Jaeger的端到端链路

埋点:OpenTelemetry Go SDK基础集成

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/prometheus"
    "go.opentelemetry.io/otel/sdk/metric"
)

// 初始化Prometheus指标导出器
exporter, _ := prometheus.New()
provider := metric.NewMeterProvider(metric.WithExporter(exporter))
otel.SetMeterProvider(provider)

该代码注册OpenTelemetry指标SDK,prometheus.New()启动内置HTTP端点(默认/metrics),支持Prometheus主动拉取。WithExporter将指标流式接入Prometheus生态,无需额外代理。

三组件协同架构

组件 职责 数据协议
Prometheus 指标采集与存储 HTTP Pull
Jaeger 分布式追踪(Trace) gRPC/Thrift
Grafana 多源可视化(Metrics+Traces) Data Source插件

端到端链路流程

graph TD
    A[Go应用] -->|OTLP/metrics| B[Prometheus]
    A -->|OTLP/traces| C[Jaeger Collector]
    B & C --> D[Grafana]
    D --> E[统一Dashboard]

第五章:高频陷阱复盘与临场应答策略

线程安全误判:ConcurrentHashMap 的“假安全”场景

某电商秒杀系统在压测中突发库存超卖,排查发现代码使用了 ConcurrentHashMap 存储商品剩余量,并假设其 put()get() 组合天然线程安全。实际问题出在非原子操作:map.get(key) > 0 && map.put(key, map.get(key) - 1)。两个线程同时读到值为1,均判定可扣减,最终写入0两次,库存变为-1。修复方案必须改用 computeIfPresent() 或引入 StampedLock 控制临界区:

inventoryMap.compute(key, (k, v) -> (v != null && v > 0) ? v - 1 : v);

数据库唯一索引失效的隐性条件

一张用户表建有 (email, tenant_id) 联合唯一索引,但业务层仍频繁出现重复注册。日志显示冲突发生在 email='admin@'(末尾空格)和 'admin@'(无空格)——MySQL 默认忽略尾部空格(PAD SPACE 校对规则),导致 'admin@ ''admin@' 被视为相同键。解决方案需显式声明校对规则:

ALTER TABLE users 
MODIFY COLUMN email VARCHAR(255) COLLATE utf8mb4_0900_as_cs;

HTTP状态码滥用导致重试风暴

微服务A调用B接口时,B在数据库连接池耗尽时返回 HTTP 500,而A的客户端配置了指数退避重试(3次,间隔1s/2s/4s)。结果单次DB故障引发每秒数百次无效重试,加剧连接池压力。根因是错误将临时性资源不足归类为 500,应改为 503 Service Unavailable 并携带 Retry-After: 30 头,使客户端暂停重试。

常见陷阱与对应防御手段对照表

陷阱类型 典型表现 推荐检测方式 生产级防御措施
JSON序列化精度丢失 Java double → JS number 丢失小数 单元测试覆盖边界值(如0.1+0.2) 使用 BigDecimal + 自定义序列化器
Redis缓存穿透 大量查询不存在的ID(如恶意刷单) 监控 keyspace_misses 指标 布隆过滤器前置 + 空值缓存(带随机TTL)
Kubernetes滚动更新中断 新Pod就绪前旧Pod被强制终止 kubectl get events 查看unhealthy事件 配置 readinessProbe + minReadySeconds: 10
flowchart TD
    A[请求到达] --> B{是否命中布隆过滤器?}
    B -->|否| C[直接返回404]
    B -->|是| D[查询Redis]
    D --> E{Redis返回null?}
    E -->|是| F[查DB并写入Redis]
    E -->|否| G[返回缓存数据]
    F --> H{DB是否存在?}
    H -->|否| I[写入空值缓存,TTL=2m~5m]
    H -->|是| J[正常写入Redis,TTL=30m]

日志埋点缺失引发的定位黑洞

某支付回调服务偶发“重复通知”告警,但日志仅记录 Received callback for order#12345,无法区分是真实重发还是上游重复推送。补全埋点后新增字段:X-Request-ID: abc-789X-Forwarded-For: 203.0.113.5callback_timestamp: 1715234892111。结合ELK聚合分析发现:92%重复来自同一IP段,最终定位为第三方支付网关的ACK超时重传机制缺陷。

内存泄漏的渐进式特征识别

一个批处理任务JVM堆内存每小时增长80MB,Full GC后仅回落至初始值的120%,持续72小时后OOM。通过 jstat -gc <pid> 发现 MC(元空间)稳定,但 OU(老年代使用量)单向爬升。MAT分析显示 ThreadLocal<SimpleDateFormat> 实例达2.3万个——每个线程创建独立实例且未清理。修复后采用 DateTimeFormatter(线程安全)或 ThreadLocal.withInitial() 确保及时回收。

从入门到进阶,系统梳理 Go 高级特性与工程实践。

发表回复

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