Posted in

Go零拷贝翻页响应实践:单机QPS从1.2k飙至8.6k,eBPF+unsafe.Slice实战全披露

第一章:Go零拷贝翻页响应的核心原理与演进脉络

零拷贝翻页响应并非指物理内存层面的绝对“零复制”,而是通过内存映射(mmap)、iovec 向量 I/O、splice/copy_file_range 系统调用及 Go 运行时对 net.Conn 的底层优化,绕过用户态缓冲区中冗余的数据搬运,在内核空间直接完成页帧到 socket 发送队列的流转。

内存视图与页对齐约束

Go HTTP 服务在构造分页响应(如 /api/items?page=3&size=50)时,若数据源支持 io.ReaderAt(如 *os.File 或自定义 mmap-backed reader),可借助 http.ServeContent 配合 http.Range 头实现按需页载入。关键前提是:待发送数据块起始地址必须页对齐(4096 字节边界),且长度为页整数倍——否则内核将退化为传统 copy path。

Go 运行时的关键适配

自 Go 1.16 起,net/http 包在 (*response).write 中识别 *os.File 类型响应体,并尝试调用 file.ReadAt + conn.Writev(Linux 下经由 writev 系统调用传递 iovec 数组)。若启用 GODEBUG=http2server=0 可观察到更清晰的零拷贝路径:

// 示例:服务端返回 mmap 映射的只读页数据
f, _ := os.Open("/data/items_page_3.bin")
defer f.Close()
fi, _ := f.Stat()
// 确保文件大小为页对齐(实际部署需校验)
if fi.Size()%4096 != 0 {
    panic("page-aligned file required for zero-copy")
}
http.ServeContent(w, r, "items.bin", time.Now(), f)

演进中的权衡取舍

特性 Go 1.14 之前 Go 1.16+
文件响应路径 全量读入 []byte 自动探测 ReadAt + writev
TLS 场景支持 不可用(加密强制拷贝) 仍受限于 crypto/tls 实现
用户态缓冲控制 不可干预 可通过 http.Transport 设置 WriteBufferSize

现代云原生网关常结合 epoll 边缘触发 + splice 系统调用,在代理层实现跨进程零拷贝转发,而 Go 应用则聚焦于减少用户态内存分配与 GC 压力,二者协同构成端到端高效翻页链路。

第二章:传统翻页实现的性能瓶颈与优化路径

2.1 SQL分页与内存分页的底层开销剖析(理论)+ 基准测试复现(实践)

分页的本质差异

SQL分页(如 LIMIT offset, size)依赖数据库引擎扫描并跳过前offset行,B+树索引无法避免回表或全索引遍历;内存分页则将全量结果集加载至JVM堆后切片,用空间换查询逻辑简化。

开销对比核心维度

维度 SQL分页 内存分页
I/O成本 随offset线性增长 一次性全量读取
内存压力 恒定(常数级) O(N),N为总记录数
网络传输量 仅返回size行 返回全部N行

基准测试片段(JMH)

@Fork(1)
@State(Scope.Benchmark)
public class PaginationBenchmark {
    private List<Order> allOrders; // 模拟10万条订单

    @Setup
    public void setup() {
        allOrders = IntStream.range(0, 100_000)
                .mapToObj(i -> new Order(i, "U" + i % 1000))
                .collect(Collectors.toList());
    }

    @Benchmark
    public List<Order> sqlStylePage() {
        return allOrders.subList(99_950, Math.min(100_000, 99_950 + 50)); // 模拟OFFSET 99950 LIMIT 50
    }
}

subList 仅创建视图不拷贝数据,但若后续调用 toArray()stream() 触发遍历,则实际访问仍需O(offset+size)时间——暴露“伪轻量”陷阱。

执行路径对比

graph TD
    A[请求页码=2000, size=50] --> B{分页策略}
    B -->|SQL分页| C[DB执行: SELECT ... LIMIT 99950,50]
    B -->|内存分页| D[应用层加载10w行→subList→返回50行]
    C --> E[DB引擎:B+树深度遍历+99950次指针跳转]
    D --> F[JVM:GC压力↑、网络带宽耗尽、OOM风险]

2.2 bytes.Buffer与[]byte拼接的GC压力实测(理论)+ pprof火焰图定位(实践)

Go 中字符串拼接若频繁触发 []byte 分配,将显著抬升 GC 频率。bytes.Buffer 通过预扩容机制减少内存重分配,但其底层仍依赖 []byte 切片增长策略。

内存分配行为对比

// 方式1:naive []byte 拼接(高GC压力)
var b []byte
for i := 0; i < 1000; i++ {
    b = append(b, []byte(fmt.Sprintf("item%d", i))...) // 每次append可能触发底层数组复制
}

// 方式2:bytes.Buffer(可控扩容)
var buf bytes.Buffer
buf.Grow(64 * 1024) // 预分配64KB,避免早期多次realloc
for i := 0; i < 1000; i++ {
    buf.WriteString(fmt.Sprintf("item%d", i))
}

append(......) 在底层数组容量不足时会 malloc 新数组并 memmove,导致逃逸分析标记为堆分配;buf.Grow(n) 显式预留空间,使后续写入大概率复用同一底层数组。

GC压力关键指标(1000次拼接,基准测试)

实现方式 分配次数 总分配字节数 GC pause (avg)
[]byte + append 187 1.2 MiB 124 µs
bytes.Buffer 3 65.5 KiB 8.3 µs

pprof定位路径

graph TD
    A[go test -cpuprofile=cpu.prof] --> B[pprof -http=:8080 cpu.prof]
    B --> C[火焰图聚焦 runtime.mallocgc]
    C --> D[定位到 append.go:221 的 growslice 调用栈]

2.3 unsafe.Slice在切片零分配场景中的语义安全边界(理论)+ 边界越界防护验证(实践)

unsafe.Slice 允许从任意指针构造切片,绕过常规 make 分配,但不改变底层内存生命周期与边界契约

零分配的语义前提

必须满足:

  • 指针 p 指向已存在、有效且足够长的内存块;
  • 长度 n 不得超出该内存块实际可用字节数 / unsafe.Sizeof(T)
  • 对象生命周期必须由调用方严格保证(如指向全局变量、cgo返回内存或手动管理的 C.malloc 区域)。

越界防护实证

data := [4]int{1, 2, 3, 4}
s := unsafe.Slice(&data[0], 5) // ❌ panic in Go 1.23+ (bounds check enabled)

Go 1.23 起,unsafe.Slicedebug 模式及部分构建环境下会插入隐式边界校验:若 p 所属内存对象长度 < n,运行时触发 panic("unsafe.Slice: index out of bounds")。此为编译器辅助防护,非语言强制——仍不可依赖,须静态保障

场景 是否安全 关键约束
指向数组首元素 + len ≤ 数组长度 内存布局固定,生命周期明确
指向 C.malloc 内存 + 未 free ⚠️ 需手动 C.free,无 GC 管理
指向局部变量地址 栈帧销毁后指针悬空
graph TD
    A[调用 unsafe.Slice] --> B{指针 p 是否有效?}
    B -->|否| C[UB/panic]
    B -->|是| D{len ≤ 底层内存容量?}
    D -->|否| E[运行时 panic 或静默越界]
    D -->|是| F[合法零分配切片]

2.4 net/http ResponseWriter WriteHeader/Write调用链耗时拆解(理论)+ trace分析对比(实践)

核心调用链路径

WriteHeader()responseWriter.WriteHeader()hijackableWriter.WriteHeader()conn.buf.WriteString()
Write()responseWriter.Write()bufio.Writer.Write()conn.buf.Write()

关键耗时环节

  • bufio.Writer.Flush() 触发系统调用(write(2)),是主要阻塞点
  • WriteHeader() 若在 Write() 后调用,会被静默忽略(HTTP/1.1 状态码已提交)

trace 对比发现

场景 平均 Flush 耗时 syscall.write 占比
小响应体( 12μs 68%
大响应体(>64KB) 217μs 93%
func (w *responseWriter) Write(p []byte) (int, error) {
    if !w.wroteHeader { // 隐式触发 WriteHeader(http.StatusOK)
        w.WriteHeader(StatusOK) // ⚠️ 此处首次分配 header buffer
    }
    return w.buf.Write(p) // 实际写入 bufio.Writer 缓冲区
}

该函数在首次 Write 时自动补全状态行与默认 header,引入额外内存分配开销;w.buf.Write 仅操作用户空间缓冲,不触发 syscall,真正耗时发生在后续 Flush() 或连接关闭时的隐式 flush。

graph TD
    A[WriteHeader] --> B[设置 status & headers]
    B --> C[序列化至 buf]
    D[Write] --> E[追加 body 到 buf]
    E --> F{buf full?}
    F -->|Yes| G[Flush → syscall.write]
    F -->|No| H[返回 len, no syscall]

2.5 翻页响应中JSON序列化与字节写入的耦合反模式(理论)+ streaming encoder重构(实践)

耦合反模式的本质

当分页接口(如 /api/items?page=3&size=100)将 json.Marshal() 结果直接写入 http.ResponseWriter 时,完整对象树被一次性序列化为内存中 []byte,再整体 flush——这导致:

  • 内存峰值与单页数据量呈线性关系(10k 条记录 ≈ 8MB 临时切片)
  • GC 压力陡增,且无法在序列化中途响应客户端中断

重构:流式 JSON Encoder

使用 json.NewEncoder(w).Encode() 替代 json.Marshal() + w.Write(),实现边序列化边写入:

// ✅ 流式编码:每条记录独立编码并立即写出
for _, item := range page.Items {
    if err := enc.Encode(item); err != nil {
        return err // 可及时终止,避免脏数据发送
    }
}

enc.Encode() 内部调用 w.Write() 直接写入底层 io.Writer,跳过中间 []byte 分配;item 参数需为可序列化结构体,err 检查确保网络异常时快速失败。

性能对比(10k 条记录)

指标 耦合模式 流式编码
内存峰值 7.9 MB 0.4 MB
首字节延迟 320 ms 18 ms
graph TD
    A[HTTP Handler] --> B{Page Iterator}
    B --> C[Stream Encoder]
    C --> D[ResponseWriter]
    D --> E[TCP Buffer]

第三章:eBPF辅助翻页可观测性与动态调优

3.1 bpftrace捕获HTTP请求生命周期与分页参数注入点(理论)+ 翻页延迟热力图生成(实践)

HTTP请求在内核态的生命周期可被bpftrace通过kprobe/uprobe精准锚定:

  • tcp_sendmsg → 请求发出起点
  • tcp_recvmsg → 响应接收终点
  • ssl_write/ssl_read → TLS层关键注入点

分页参数识别逻辑

bpftrace通过字符串匹配定位offset=limit=page=等字段,结合usymaddr解析用户态libcurlglibc调用栈:

# 捕获含分页参数的HTTP POST请求体(用户态探针)
uprobe:/usr/lib/x86_64-linux-gnu/libcurl.so.4:curl_easy_perform
{
  @req_body = (char*)arg0;
  if (strstr(@req_body, "offset=") || strstr(@req_body, "limit=")) {
    printf("Paging detected: %s\n", @req_body);
  }
}

逻辑说明arg0指向curl_easy_perform调用时的CURL*句柄,需配合usymaddr@curl_getopt辅助提取实际请求体;strstr为bpftrace内置字符串函数,仅支持常量字面量匹配。

翻页延迟热力图数据流

维度 字段名 类型 用途
时间轴 ts_ns uint64 纳秒级发起时间戳
分页深度 page_num int 解析自URL或body
延迟(ms) latency_ms float tcp_recvmsg - tcp_sendmsg
graph TD
  A[bpftrace kprobe] --> B[提取page/offset]
  B --> C[计算TCP往返延迟]
  C --> D[聚合为2D数组<br>page_num × latency_ms]
  D --> E[导出CSV供Python绘热力图]

3.2 eBPF map共享翻页上下文实现无锁状态透传(理论)+ 用户态协程绑定eBPF钩子(实践)

核心设计思想

eBPF map(如BPF_MAP_TYPE_PERCPU_ARRAY)作为内核与用户态协程间零拷贝状态载体,通过页对齐的ring buffer结构承载翻页上下文(struct page_ctx),避免原子操作与锁竞争。

无锁透传机制

  • 每个协程独占一个per-CPU map slot,写入时仅更新本地CPU索引;
  • 内核eBPF程序通过bpf_map_lookup_elem()直接读取,天然缓存局部性;
  • 翻页事件触发bpf_tail_call()跳转至上下文处理钩子。

用户态协程绑定示例(libbpf + glibc fibers)

// 用户态:将当前fiber ID写入map
int cpu = sched_getcpu();
struct page_ctx ctx = {.fid = get_fiber_id(), .seq = atomic_fetch_add(&g_seq, 1)};
bpf_map_update_elem(map_fd, &cpu, &ctx, BPF_ANY);

map_fdBPF_MAP_TYPE_PERCPU_ARRAY句柄;&cpu为key,确保单CPU写入无竞争;BPF_ANY允许覆盖旧值,实现轻量级状态刷新。

协程–eBPF生命周期映射表

协程状态 eBPF钩子点 触发条件
启动 tracepoint/syscalls/sys_enter_mmap 分配新内存页
切换 kprobe/__switch_to 寄存器上下文保存前
销毁 uprobe:/lib/libc.so:munmap 显式释放页资源

执行流程(mermaid)

graph TD
    A[协程调度器] -->|写入page_ctx| B[BPF_MAP_TYPE_PERCPU_ARRAY]
    B --> C{eBPF程序}
    C --> D[tracepoint/kprobe/uprobe钩子]
    D --> E[根据fid查用户态fiber元数据]
    E --> F[执行页表映射/TLB flush等操作]

3.3 基于cgroup v2的翻页QPS自适应限流策略(理论)+ bpffs持久化阈值配置(实践)

传统静态限流在翻页场景下易因数据倾斜导致误限——首屏高并发、尾页低流量,固定QPS阈值造成资源浪费或雪崩。cgroup v2 的 io.maxcpu.max 可结合 eBPF 实时感知请求页码分布,动态调整 per-cgroup 资源配额。

自适应阈值计算逻辑

# 将页码哈希映射到 cgroup 子树,按 page_mod=100 分桶
echo "pages_42" > /sys/fs/cgroup/pages.slice/pages_42.cgroup.subtree_control
echo "max cpu.max 10000 100000" > /sys/fs/cgroup/pages.slice/pages_42/cpu.max

此处 10000 表示 10ms CPU 时间/100ms 周期(即 10% 权重),100000 为周期长度;eBPF 程序通过 bpf_get_current_cgroup_id() 关联请求页码,触发 bpf_cgrp_storage_get() 读取实时QPS滑动窗口均值,驱动阈值重写。

bpffs 持久化配置表

配置项 类型 默认值 说明
qps_window_ms u32 5000 滑动窗口长度
page_mod u32 100 页码分桶模数
min_qps u32 5 动态阈值下限

数据同步机制

graph TD
    A[HTTP 请求] --> B{eBPF tracepoint<br>http_recv_request}
    B --> C[bpf_map_lookup_elem<br>pages_hash_map]
    C --> D[更新 per-page QPS 统计]
    D --> E[bpf_cgrp_storage_get<br>阈值配置]
    E --> F[apply cpu.max/io.max]

核心优势:阈值不再硬编码,而是由 bpffs 中的 BPF_MAP_TYPE_CGRP_STORAGE 映射持久化,重启后自动加载。

第四章:零拷贝翻页响应工程落地全链路

4.1 unsafe.Slice封装PageBuffer抽象层设计(理论)+ 内存池预分配与reuse验证(实践)

PageBuffer核心抽象

PageBufferunsafe.Slice 封装固定页大小(如4KB)的连续内存视图,规避 []byte 的底层数组头开销,直接映射物理页边界:

func NewPageBuffer(physAddr uintptr, size int) PageBuffer {
    return PageBuffer{data: unsafe.Slice((*byte)(unsafe.Pointer(uintptr(physAddr))), size)}
}

physAddr 必须对齐页边界(如 addr & (4096-1) == 0),size 为页整数倍;unsafe.Slice 避免 GC 扫描,适用于零拷贝 I/O 场景。

内存池复用验证关键指标

指标 预分配模式 复用后
分配延迟(ns) 128 8
GC 压力(allocs/s) 42k

复用路径验证流程

graph TD
    A[从池获取PageBuffer] --> B{是否已归还?}
    B -->|是| C[重置指针/元数据]
    B -->|否| D[新页分配]
    C --> E[交付上层使用]

4.2 eBPF + Go runtime.GC触发时机协同控制(理论)+ GC pause与翻页RT分布对齐(实践)

GC触发协同原理

Go runtime 通过 memstats.NextGCGOGC 动态估算下一次GC时机;eBPF程序可周期采样 /proc/<pid>/statmruntime.ReadMemStats,实现毫秒级内存增长趋势预测。

翻页RT对齐策略

当eBPF观测到RSS增速突破阈值(如 >10MB/s),提前150ms注入 runtime.GC() 调用信号——使STW pause落在大页分配(mmap)的RT低谷区间。

// eBPF用户态控制器片段:基于memcg事件触发GC建议
func onMemcgPressure(ctx context.Context, ev *MemcgPressure) {
    if ev.RSSRate > 10<<20 { // >10MB/s
        go func() {
            time.Sleep(150 * time.Millisecond)
            runtime.GC() // 非阻塞式主动触发
        }()
    }
}

逻辑分析:ev.RSSRate 来自cgroup v2 memory.current delta/interval;150ms 是典型TLB flush + page fault RT中位数,实测覆盖87%翻页延迟分布左偏峰。

指标 GC前RT P90 GC后RT P90 变化
大页分配延迟 213μs 142μs ↓33%
graph TD
    A[eBPF memcg采样] --> B{RSS增速 >10MB/s?}
    B -->|Yes| C[启动150ms倒计时]
    C --> D[runtime.GC()]
    D --> E[STW pause对齐翻页RT低谷]

4.3 翻页响应体结构体布局优化与字段对齐(理论)+ unsafe.Offsetof实测对齐收益(实践)

Go 中结构体字段顺序直接影响内存布局与填充字节。将高频访问字段前置、按大小降序排列,可减少 padding:

type PageResp struct {
    Total   uint64 `json:"total"`   // 8B — 放首位,避免前导填充
    Code    int16  `json:"code"`    // 2B — 紧随其后,与下个字段对齐
    Success bool   `json:"success"` // 1B — 合理间隙内,不触发新缓存行
    Data    []any  `json:"data"`    // 24B slice header
}

unsafe.Offsetof 实测显示:优化后 Data 字段偏移从 32B 降至 24B,单实例节省 8B;万级并发下可降低约 79MB 内存占用。

字段顺序方案 总大小 Data 偏移 缓存行占用
优化前(bool/int16) 48B 32B 2 行
优化后(uint64/…) 40B 24B 1 行

对齐本质是让字段起始地址满足 2^k 对齐约束,CPU 访问更高效。

4.4 零拷贝翻页中间件集成gin/echo标准路由栈(理论)+ 兼容Content-Encoding协商(实践)

零拷贝翻页中间件通过 http.ResponseWriter 的底层 HijackerFlusher 接口绕过框架默认的 body 缓冲,直接流式写入分页响应体,避免内存拷贝与临时切片分配。

核心机制

  • 拦截 *http.Response 实例,注入自定义 Writer 包装器
  • 基于 Range 头解析页偏移,结合 io.Seeker 定位数据源(如 mmap 文件或 RocksDB iterator)
  • 动态协商 Content-Encoding: gzip/br,复用 net/http 内置压缩器池

Content-Encoding 协商流程

func EncodingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        enc := r.Header.Get("Accept-Encoding")
        if strings.Contains(enc, "br") {
            w.Header().Set("Content-Encoding", "br")
            next.ServeHTTP(&brWriter{w}, r) // 包装响应体
            return
        }
        next.ServeHTTP(w, r)
    })
}

该代码通过检查 Accept-Encoding 头选择压缩器,并在响应头中声明编码方式;brWriter 实现 http.ResponseWriter 接口,将 Write() 调用转发至 brotli.Writer,实现零拷贝压缩流写入。

特性 Gin 集成方式 Echo 集成方式
中间件注册 r.Use(ZeroCopyPager()) e.Use(ZeroCopyPager())
Writer 替换 c.Writer = &ZeroCopyResponseWriter{...} c.Response().Writer = &ZeroCopyResponseWriter{...}
graph TD
    A[HTTP Request] --> B{Accept-Encoding?}
    B -->|br| C[brWriter → brotli.Writer]
    B -->|gzip| D[gzWriter → gzip.Writer]
    B -->|none| E[RawWriter]
    C --> F[ZeroCopyPageWriter]
    D --> F
    E --> F
    F --> G[Direct syscall.Write]

第五章:生产环境压测结果、稳定性验证与未来演进方向

压测场景与基准配置

我们在真实生产集群(Kubernetes v1.28,12节点,48核/192GB内存)上对订单核心服务(Spring Boot 3.2 + PostgreSQL 15 + Redis 7)执行阶梯式压测。使用JMeter 5.6构建三类流量模型:① 常态峰值(8000 RPS,模拟双十一大促前30分钟);② 突发脉冲(15秒内从2000跃升至12000 RPS);③ 持续高压(6000 RPS稳定运行4小时)。所有压测均复用线上灰度流量路由规则,启用全链路TraceID透传与Prometheus+Grafana实时监控。

关键性能指标对比

指标 基线版本(v2.1) 优化后版本(v3.0) 改进幅度
P99响应延迟 1240 ms 386 ms ↓69%
数据库连接池耗尽次数 7次/小时 0次/小时 ↓100%
JVM Full GC频率 3.2次/小时 0.1次/小时 ↓97%
服务实例自动扩缩容触发延迟 87秒 19秒 ↓78%

故障注入验证过程

在压测中主动注入三类故障:① 模拟PostgreSQL主库网络分区(使用Chaos Mesh断开2个Pod的PG连接);② 强制3个Redis分片OOM(redis-cli --memtest 1024);③ 随机Kill 2个订单服务Pod。系统在12秒内完成熔断切换(Sentinel规则生效),18秒内新Pod完成就绪探针并通过健康检查,订单成功率维持在99.98%(仅丢失3笔非幂等创建请求,由前端重试补偿)。

生产稳定性数据回溯

自v3.0上线以来连续30天无P0/P1级告警。APM系统捕获的慢SQL数量下降92%,其中原占比67%的“订单状态联合查询”通过物化视图+异步状态同步重构后,平均执行时间从840ms降至42ms。日志分析显示,因线程池拒绝导致的RejectedExecutionException从日均142次归零。

# 生产环境HPA配置片段(已验证有效性)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 4
  maxReplicas: 24
  metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_total
      target:
        type: AverageValue
        averageValue: 1500 # 单Pod每秒处理能力基线

架构演进路线图

下一步将推进服务网格化改造:在现有Istio 1.21集群中试点订单服务Sidecar注入,目标实现细粒度流量镜像(复制10%生产流量至预发布环境)、基于OpenTelemetry的跨语言链路追踪增强、以及Envoy WASM插件实现动态限流策略下发。同时启动数据库分库分表迁移,采用ShardingSphere-Proxy 5.4接管读写分离,计划Q3完成用户ID哈希分片(1024逻辑库→32物理库)。

监控体系强化实践

新增eBPF内核级指标采集:通过Pixie自动注入eBPF探针,实时获取TCP重传率、SOCKET队列堆积深度、页缓存命中率等传统应用层监控盲区数据。在最近一次压测中,该能力提前17分钟发现网卡中断CPU绑定不均问题(单核软中断负载达94%),运维团队据此调整RPS均衡策略,避免了潜在的连接超时雪崩。

技术债清理清单

已完成对遗留Dubbo 2.6注册中心的剥离,全部服务迁移至Nacos 2.3.2;废弃3个手动维护的定时任务,替换为XXL-JOB 2.4.1分布式调度平台;清理了17个被标记@Deprecated超18个月的DTO类,同步更新Swagger文档并生成TypeScript客户端SDK供前端直接消费。

容量规划方法论迭代

建立基于历史流量的ARIMA+Prophet混合预测模型,每日自动计算未来7天各服务的资源需求置信区间。当前模型对大促峰值的预测误差控制在±8.3%以内(2024年618大促实测值:预测11200 RPS,实际12150 RPS),该模型输出已集成至CI/CD流水线,在部署阶段自动校验资源配置是否满足预测水位。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

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