Posted in

Go程序员进外企必考的7道系统设计题:从GRPC网关到分布式限流,附评分标准

第一章:Go程序员外企系统设计面试全景图

外企系统设计面试对Go程序员而言,既是技术深度的试金石,也是工程思维的综合考场。它不考察单一语法细节,而聚焦于如何用Go语言的特性支撑高并发、可维护、可观测的分布式系统——这要求候选人同时理解语言机制(如goroutine调度、channel语义、内存模型)、标准库设计哲学(net/http、sync、context),以及云原生基础设施(Kubernetes Service Mesh、OpenTelemetry集成)的协同逻辑。

面试核心维度

  • 问题建模能力:能否将模糊需求(如“设计一个短链服务”)快速拆解为可量化的SLA(P99延迟
  • Go特化权衡意识:例如选择sync.Map还是RWMutex + map[string]interface{}需结合读写比与GC压力;用http.Server{ReadTimeout: 5 * time.Second}而非全局time.AfterFunc实现超时,体现对HTTP生命周期的理解
  • 可观测性前置设计:在代码中自然嵌入指标埋点,如使用prometheus.NewCounterVec统计不同HTTP状态码分布,并通过http.Handler中间件自动注入request_idtrace_id

典型现场编码片段示例

// 实现带过期时间的LRU缓存(面试高频题),突出Go并发安全与资源控制
type Cache struct {
    mu      sync.RWMutex
    data    map[string]*cacheEntry
    heap    *heap // 自定义最小堆按expireAt排序
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    entry, ok := c.data[key]
    c.mu.RUnlock()
    if !ok || time.Now().After(entry.expireAt) {
        return "", false
    }
    // 延长TTL并提升访问优先级(需在heap中调整位置)
    c.promote(key)
    return entry.value, true
}

该实现避免了map+time.Timer的资源泄漏风险,用最小堆替代全量扫描过期项,体现对时间和空间复杂度的主动控制。面试官常会追问:“如果QPS达10万,如何减少锁竞争?”——此时应提出分片策略(sharded cache)或sync.Pool复用cacheEntry对象。

常见评估陷阱

行为 风险点
过度依赖第三方ORM 暴露SQL优化盲区,忽略连接池配置
忽略context传播 导致goroutine泄漏,违反Cancel/Deadline契约
硬编码HTTP状态码 削弱错误分类能力,影响前端重试策略

第二章:GRPC网关架构设计与高可用实践

2.1 GRPC网关核心原理与Protobuf编译链路解析

gRPC-Gateway 是一个反向代理,将 REST/HTTP JSON 请求动态翻译为 gRPC 调用。其核心依赖于 Protobuf 的 google.api.http 扩展与 grpc-gateway 插件生成的反向路由代码。

编译链路关键阶段

  • protoc 解析 .proto 文件,提取 service、method 及 http_rule 注解
  • protoc-gen-grpc-gateway 插件读取 HttpRule,生成 Go HTTP handler(含路径映射、JSON 编解码逻辑)
  • 生成代码与 gRPC Server 共享同一 Protobuf 类型,实现零拷贝结构复用

示例:HTTP 映射定义

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{id}"  // 动态路径参数绑定
      additional_bindings { post: "/v1/users:lookup" }
    };
  }
}

该定义触发插件生成 /v1/users/{id}GetUser 的路由注册逻辑,并自动提取 id 字段注入 GetUserRequest.Id

核心流程(mermaid)

graph TD
  A[REST Client] -->|HTTP GET /v1/users/123| B(gRPC-Gateway HTTP Handler)
  B --> C[JSON → Proto Unmarshal]
  C --> D[gRPC Client Stub]
  D --> E[gRPC Server]

2.2 Envoy与grpc-gateway双模式选型对比与落地案例

在混合微服务架构中,需同时支持 gRPC 原生调用与 RESTful JSON 接口。Envoy 作为 L7 边界代理,可统一处理协议转换、限流与可观测性;而 grpc-gateway 则以 Go 插件方式在服务端内嵌生成反向代理。

核心能力对比

维度 Envoy 模式 grpc-gateway 模式
部署粒度 独立 Sidecar/边缘网关 与 gRPC Server 同进程
协议转换时机 请求入口处(无侵入) 编译期生成 HTTP handler
调试可观测性 全链路 x-envoy-* 头 + 访问日志 依赖应用层日志与 Prometheus

典型部署流程(Mermaid)

graph TD
    A[REST Client] --> B[Envoy: /v1/users → gRPC]
    A --> C[grpc-gateway: /v1/users → http.Handler]
    B --> D[gRPC Service]
    C --> D

Envoy 配置片段(HTTP filter)

http_filters:
- name: envoy.filters.http.grpc_json_transcoder
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
    proto_descriptor: "/etc/envoy/proto.pb"
    services: ["user.UserService"]  # 必须与 .proto service 名一致
    print_options:
      add_whitespace: true
      always_print_primitive_fields: true

该配置启用 JSON-to-gRPC 双向透传:proto_descriptor 是编译后的二进制描述符,services 指定需暴露的 gRPC 服务;print_options 控制响应 JSON 格式化行为,影响前端调试体验。

2.3 请求透传、元数据注入与跨协议Header转换实战

在微服务网关层,请求透传需保障上下文完整性,元数据注入则用于链路追踪与灰度路由。常见 Header 映射关系如下:

HTTP Header gRPC Metadata Key 用途
X-Request-ID request-id 全局唯一请求标识
X-Env env 环境标签(prod/stage)
X-User-ID user_id 认证后用户主键

数据同步机制

通过 Spring Cloud Gateway 的 GlobalFilter 实现透传与注入:

public class HeaderPropagationFilter implements GlobalFilter {
  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    // 从HTTP Header提取并注入gRPC Metadata(透传+增强)
    String traceId = request.getHeaders().getFirst("X-B3-TraceId");
    if (traceId != null) {
      exchange.getAttributes().put("GRPC_TRACE_ID", traceId); // 注入元数据
    }
    return chain.filter(exchange);
  }
}

逻辑分析:该 Filter 在请求进入网关时捕获 X-B3-TraceId,存入 exchange.attributes,供后续 gRPC 客户端拦截器读取并写入 Metadata;参数 exchange 封装全链路上下文,attributes 是轻量级跨组件通信载体。

协议头转换流程

graph TD
  A[HTTP Client] -->|携带X-Env/X-User-ID| B(Spring Cloud Gateway)
  B --> C{Header Normalize}
  C --> D[注入env=user-prod]
  C --> E[映射user_id=1001]
  D & E --> F[gRPC Stub]

2.4 网关层可观测性建设:OpenTelemetry集成与Trace透传

网关作为流量入口,需无损透传 TraceID 并自动注入 Span,实现全链路追踪起点对齐。

OpenTelemetry SDK 集成要点

在 Spring Cloud Gateway 中引入 opentelemetry-instrumentation-spring-webflux-5.3,启用自动上下文传播:

@Bean
public Tracer tracer(SdkTracerProvider tracerProvider) {
    return tracerProvider.get("gateway-service"); // 指定服务名,用于服务拓扑识别
}

此配置确保所有路由请求自动生成 server.request Span,并继承上游 traceparent 头;"gateway-service" 将作为 Jaeger/OTLP 后端的服务维度标签。

Trace 透传关键 Header

网关必须透传并补全以下标准字段:

Header 名称 作用 是否必传
traceparent W3C 标准 Trace 上下文
tracestate 跨厂商状态传递(如 vendor=aws) ⚠️(推荐)
X-B3-TraceId 兼容 Zipkin 旧系统 ❌(优先用 traceparent)

请求链路透传流程

graph TD
    A[Client] -->|traceparent: 00-abc123...| B[API Gateway]
    B -->|保留并透传所有trace*头| C[Auth Service]
    B -->|新增 gateway.route.span| D[Backend API]

2.5 灰度发布与动态路由策略在Go网关中的实现

灰度发布需在请求入口层实现细粒度流量分流,核心依赖动态路由决策引擎。以下为基于HTTP Header的版本路由示例:

func versionRouter(c *gin.Context) {
    version := c.GetHeader("X-App-Version") // 读取灰度标识头
    if version == "v2" {
        c.Request.URL.Host = "service-v2.internal"
        c.Request.URL.Scheme = "http"
        proxy.ServeHTTP(c.Writer, c.Request) // 转发至v2服务
        return
    }
    // 默认走v1
    c.Request.URL.Host = "service-v1.internal"
}

该逻辑将X-App-Version作为灰度开关,避免硬编码路由,支持运行时热更新。

路由策略匹配优先级

策略类型 匹配依据 生效时机 可配置性
用户ID哈希 X-User-ID % 100 < 10 请求解析阶段 ✅ 动态加载
地域标签 X-Region: shanghai Header预检 ✅ 配置中心驱动
流量百分比 随机数 中间件拦截 ✅ 实时调整

决策流程示意

graph TD
    A[HTTP Request] --> B{Header contains X-App-Version?}
    B -->|Yes| C[Match version rule]
    B -->|No| D[Apply fallback policy]
    C --> E[Route to corresponding upstream]
    D --> E

第三章:分布式限流系统设计与工程落地

3.1 滑动窗口、令牌桶与漏桶算法的Go语言并发实现差异分析

核心设计哲学差异

  • 滑动窗口:基于时间切片计数,轻量但精度受限于窗口粒度;
  • 令牌桶:主动预分配配额,允许突发流量,依赖定时器或懒加载填充;
  • 漏桶:恒定速率输出,平滑流量,但无法应对瞬时突增。

并发安全关键点

所有实现均需避免竞态:滑动窗口用 sync.Map 或分片 []int64 + atomic;令牌桶/漏桶须对 tokenslastUpdate 字段做原子读写或 mutex 保护。

// 滑动窗口(分片原子计数器,窗口5s,100ms分片)
type SlidingWindow struct {
    buckets [50]int64 // 50 × 100ms = 5s
    offset  uint64     // 当前桶索引(原子)
}

使用循环数组+原子偏移,避免锁竞争;offset 每100ms递增,旧桶值自然过期。分片数决定时间分辨率与内存开销权衡。

算法 吞吐适应性 实现复杂度 Go原生支持度
滑动窗口 ⚡(atomic)
令牌桶 ⏱️(time.Ticker)
漏桶 🔁(channel阻塞)

3.2 基于Redis+Lua的分布式令牌桶一致性保障与性能压测

核心设计动机

单机令牌桶在分布式场景下易因时钟漂移、网络延迟导致超发。Redis + Lua 的原子执行能力天然规避竞态,是强一致限流的最优解。

Lua脚本实现(带注释)

-- KEYS[1]: token bucket key
-- ARGV[1]: capacity, ARGV[2]: refill rate (tokens/sec), ARGV[3]: current timestamp (ms)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local last_time = redis.call('HGET', KEYS[1], 'last_time') or '0'
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens')) or capacity

-- 按时间推移补充令牌(线性补发)
local delta = math.max(0, now - tonumber(last_time))
local new_tokens = math.min(capacity, tokens + delta * rate / 1000)

-- 尝试获取1个令牌
if new_tokens >= 1 then
    redis.call('HMSET', KEYS[1], 'tokens', new_tokens - 1, 'last_time', now)
    return 1
else
    redis.call('HMSET', KEYS[1], 'tokens', new_tokens, 'last_time', now)
    return 0
end

逻辑分析:脚本以毫秒级时间戳驱动补发,避免浮点累积误差;HMSET确保状态更新原子性;返回值 1/0 直接表征是否允许请求。

性能压测关键指标(单节点 Redis 6.2)

并发数 QPS P99延迟(ms) 令牌精度偏差
1000 42.8k 2.1
5000 43.1k 3.7

数据同步机制

  • 无跨节点同步开销:所有操作聚焦单Key,依赖Redis主从异步复制
  • 客户端本地缓存last_time不参与决策,完全由Lua脚本内联计算保障一致性
graph TD
    A[客户端请求] --> B{调用EVAL}
    B --> C[Redis执行Lua脚本]
    C --> D[读取HGET last_time/tokens]
    D --> E[计算新令牌数并条件写入]
    E --> F[返回1或0]

3.3 服务网格侧限流(Istio Envoy Filter)与应用层限流协同策略

服务网格侧限流与应用层限流并非互斥,而是分层防御的互补组合:Envoy Filter 在七层网关处拦截超量请求,避免下游服务过载;应用层限流(如 Resilience4j)则保障业务关键路径的精确控制。

协同边界划分

  • 网格层:全局速率限制(QPS/连接数)、IP/路径级粗粒度限流
  • 应用层:用户ID、订单类型等业务维度细粒度熔断与滑动窗口计数

Envoy Filter 限流配置示例(Lua + rate_limit_service)

# envoyfilter-rate-limit.yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: http-ratelimit-filter
spec:
  workloadSelector:
    labels:
      app: product-service
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: SIDECAR_INBOUND
      proxy:
        proxyVersion: ^1\.20.*
    patch:
      operation: INSERT_FIRST
      value:
        name: envoy.filters.http.local_ratelimit
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
          stat_prefix: http_local_rate_limiter
          token_bucket:
            max_tokens: 100       # 桶容量
            tokens_per_fill: 10   # 每秒填充令牌数 → 10 QPS
            fill_interval: 1s
          filter_enabled:
            runtime_key: local_rate_limit_enabled
            default_value:
              numerator: 100
              denominator: HUNDRED

逻辑分析:该配置在 Sidecar 入口注入本地令牌桶限流器。tokens_per_fill: 10 实现每秒最多放行10个请求,超出即返回 HTTP 429stat_prefix 便于通过 Envoy Admin /stats 查看 http_local_rate_limiter.ratelimit_ok 等指标。注意:此为无状态本地限流,适用于单实例压测场景;生产需对接 Redis 实现分布式限流。

协同决策流程

graph TD
  A[请求到达] --> B{Envoy Filter 本地限流?}
  B -- 是 --> C[返回 429]
  B -- 否 --> D[转发至应用]
  D --> E{应用层业务规则匹配?}
  E -- 是 --> F[Resilience4j 滑动窗口校验]
  E -- 否 --> G[正常处理]
层级 响应延迟 一致性 适用场景
Envoy Filter 本地 防雪崩、流量削峰
应用层限流 ~2–5ms 分布式 用户级配额、VIP优先级

第四章:微服务状态一致性与分布式事务建模

4.1 Saga模式在Go微服务中的状态机驱动实现(go-statemachine集成)

Saga 模式通过一系列本地事务与补偿操作保障跨服务最终一致性。go-statemachine 提供轻量、可嵌入的状态机能力,天然适配 Saga 的阶段化生命周期管理。

状态建模与事件驱动流转

Saga 各阶段(如 ReserveInventoryChargePaymentShipOrder)映射为状态节点,失败则触发对应 CompensateXxx 转移。

type OrderSaga struct {
    sm *statemachine.StateMachine
}

func NewOrderSaga() *OrderSaga {
    sm := statemachine.NewStateMachine(
        statemachine.WithInitialState("reserved"),
        statemachine.WithTransitions([]statemachine.Transition{
            {From: "reserved", To: "charged",   On: "CHARGE_SUCCESS"},
            {From: "charged",  To: "shipped",   On: "SHIP_SUCCESS"},
            {From: "charged",  To: "reserved",  On: "CHARGE_FAILED"}, // 补偿回退
        }),
    )
    return &OrderSaga{sm: sm}
}

该初始化定义了三态两正向一反向转移;CHARGE_FAILED 事件将跳过发货,直接回滚库存预留,体现 Saga 的补偿本质。

核心优势对比

特性 传统 Saga(手动状态管理) go-statemachine 驱动
状态一致性保障 易出错,依赖开发者自律 强约束,非法转移被拦截
可观测性 日志分散,需人工串联 内置事件钩子,便于埋点审计
graph TD
    A[reserved] -->|CHARGE_SUCCESS| B[charged]
    B -->|SHIP_SUCCESS| C[shipped]
    B -->|CHARGE_FAILED| A

4.2 基于消息队列的最终一致性补偿机制与幂等性设计要点

数据同步机制

在分布式事务中,本地事务提交后异步发送消息至 RocketMQ/Kafka,下游服务消费并执行业务逻辑。失败时依赖重试 + 死信队列触发人工或自动补偿。

幂等性核心策略

  • 基于业务唯一键(如 order_id + event_type)构建幂等表或 Redis Set
  • 消费前校验是否已处理,已存在则直接跳过
  • 使用 Lua 脚本保证原子性写入与判断
// Redis 幂等校验(Lua 脚本封装)
String script = "if redis.call('exists', KEYS[1]) == 1 then return 0 else redis.call('setex', KEYS[1], ARGV[1], ARGV[2]); return 1 end";
Boolean isProcessed = redis.eval(script, Collections.singletonList("idempotent:ord1001:pay"), 
                                 Arrays.asList("3600", "success")); // 3600s 过期,值为状态标识

逻辑分析:脚本先检查 key 是否存在;若存在返回 0(已处理),否则设置带 TTL 的 key 并返回 1。KEYS[1] 为幂等键,ARGV[1] 是过期时间(秒),ARGV[2] 为可选业务上下文标记。

补偿流程示意

graph TD
    A[本地事务成功] --> B[发消息到MQ]
    B --> C{消费成功?}
    C -->|是| D[更新幂等状态]
    C -->|否| E[重试/进DLQ]
    E --> F[定时扫描DLQ触发补偿任务]
组件 关键要求
消息中间件 支持事务消息、ACK 重投机制
存储层 幂等记录需支持高并发写+短TTL
补偿服务 具备幂等执行能力与失败告警链路

4.3 分布式锁选型:Redlock vs Etcd vs Redis Streams在Go中的可靠性验证

分布式锁的可靠性取决于原子性、租约续期与故障恢复能力。三者在 Go 生态中实现路径迥异:

  • Redlock:依赖多个独立 Redis 实例,需客户端实现时钟漂移校正与多数派确认;
  • Etcd:基于 Raft 协议,天然支持租约(Lease)与 Watch 机制,clientv3.Concurrency 提供开箱即用的 Mutex
  • Redis Streams:利用 XADD + XREADGROUP + 消费者组 ACK 实现“伪锁”,需自行管理超时与消息重投。

核心对比维度

方案 容错性 网络分区容忍 租约自动续期 Go 官方 SDK 支持
Redlock 弱(依赖时钟) 否(需手动) 社区库(e.g., github.com/go-redsync/redsync/v4
Etcd 强(Raft) 是(Lease TTL) 官方 go.etcd.io/etcd/client/v3
Redis Streams 弱(无强一致) 官方 github.com/redis/go-redis/v9

Etcd 锁实现片段(带租约)

cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
leaseResp, _ := cli.Grant(context.TODO(), 10) // 10秒TTL
mutex := concurrency.NewMutex(session.NewSession(cli, concurrency.WithLease(leaseResp.ID)), "lock:/order")
if err := mutex.Lock(context.TODO()); err != nil {
    log.Fatal("acquire failed:", err)
}
// 自动续期由 Lease 维护,无需手动心跳

逻辑分析:Grant() 创建带 TTL 的租约,NewMutex 将锁绑定至该租约;若会话中断或租约过期,Etcd 自动删除锁 Key,避免死锁。参数 concurrency.WithLease 是关键,缺失则退化为无超时的临时锁。

4.4 TCC模式下Try/Confirm/Cancel三阶段资源预占与回滚边界控制

TCC(Try-Confirm-Cancel)通过业务层面的三阶段协作实现分布式事务最终一致性,其核心在于资源预占的粒度控制回滚边界的精确界定

Try阶段:资源预占与状态冻结

@Compensable(confirmMethod = "confirmOrder", cancelMethod = "cancelOrder")
public void tryCreateOrder(Order order) {
    // 冻结库存:status='LOCKED', locked_quantity = order.qty
    inventoryMapper.lockQuantity(order.getProductId(), order.getQty());
    // 预占账户余额(不扣款,仅标记)
    accountMapper.reserveBalance(order.getUserId(), order.getAmount());
}

逻辑分析:lockQuantity 在库存表中新增锁定字段而非直接减库,避免超卖;reserveBalance 更新用户可用余额为 available - reserved,确保资金隔离。参数 order.getQty()order.getAmount() 是预占依据,必须幂等且不可变。

Confirm/Cancel的边界判定

阶段 触发条件 回滚边界约束
Confirm 全局事务提交成功 仅允许对已Try成功的资源执行
Cancel Try失败或全局事务回滚 必须严格匹配Try的预占快照
graph TD
    A[Try: 预占资源] -->|成功| B[Confirm: 提交预占]
    A -->|失败| C[Cancel: 释放预占]
    B --> D[状态转为 FINAL]
    C --> E[状态还原为 AVAILABLE]

第五章:从面试题到生产系统的认知跃迁

面试中的LRU缓存 vs 真实服务的缓存雪崩

在LeetCode上实现一个O(1)时间复杂度的LRU缓存,只需哈希表+双向链表;但在某电商订单中心服务中,我们曾因未考虑maxSize动态伸缩与本地缓存一致性,导致促销期间Redis集群QPS突增370%,最终通过引入Caffeine的refreshAfterWrite(30s) + 分布式锁预热机制解决。关键差异在于:面试代码不处理并发写入竞争、不对接监控埋点、不响应配置中心变更。

单体架构的“完美”SQL优化陷阱

面试常考“如何优化慢查询”,答案往往是加索引或改JOIN顺序。但某SaaS客户管理后台上线后,一条看似最优的SELECT * FROM customers WHERE tenant_id = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 20在千万级数据下仍超时——原因在于MySQL 5.7的tenant_id索引选择性差,且updated_at未纳入联合索引。最终方案是重构为(tenant_id, status, updated_at)覆盖索引,并配合应用层分页游标(WHERE tenant_id = ? AND status = 'active' AND updated_at < ?)。

微服务拆分中的分布式事务幻觉

面试者常自信列举Saga、TCC、本地消息表方案,却忽略真实约束:

  • 支付服务调用库存服务扣减时,若库存服务返回503 Service Unavailable,Saga补偿操作需保证幂等且具备重试退避(指数退避+最大重试5次);
  • 实际生产中,我们发现83%的“事务失败”源于网络抖动而非业务异常,因此将补偿任务接入Kafka死信队列并设置retry-topic三级重试策略。
flowchart LR
    A[支付服务发起扣款] --> B{库存服务HTTP调用}
    B -->|200 OK| C[更新本地支付状态]
    B -->|5xx/timeout| D[写入补偿任务到Kafka]
    D --> E[消费端执行库存回滚]
    E --> F[回调支付服务标记补偿完成]

监控告警的语义鸿沟

面试题:“如何设计高可用系统?”答案常聚焦冗余与熔断。而某物流轨迹服务的真实故障中,Prometheus告警规则rate(http_request_duration_seconds_count{job=\"tracking\"}[5m]) < 100持续触发,运维团队却误判为流量下降——实际是上游网关因TLS 1.2协议升级导致大量499 Client Closed Request未被计入计数器。最终修复需同时修改指标采集标签(增加status_code维度)和告警逻辑(排除499状态码)。

技术债的量化偿还路径

某金融风控系统遗留的Python 2.7单体服务,在迁移至Kubernetes过程中暴露三大硬伤: 问题类型 表现 解决方案 耗时
日志无TraceID 全链路排查需人工拼接Nginx+应用日志 注入OpenTelemetry SDK并统一日志格式 3人日
配置硬编码 每次灰度需手动修改config.py 接入Apollo配置中心,支持运行时热更新 5人日
健康检查不可靠 /health仅检查DB连接,忽略Redis连接池耗尽 新增/actuator/health端点,集成Lettuce连接池状态检测 2人日

该系统上线后,平均故障定位时间从47分钟降至6分钟,发布成功率从72%提升至99.8%。

守护服务器稳定运行,自动化是喵的最爱。

发表回复

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