第一章:Go并发性能跃迁公式的本质洞察
Go 并发性能的跃迁并非源于 Goroutine 数量的线性堆叠,而根植于“低开销协程 + 无锁通信 + 调度器自适应”三者的协同涌现。其本质可凝练为一个隐式公式:
吞吐量 ≈ min(逻辑并发度, OS线程承载力, 内存带宽约束) × 单任务平均有效计算密度 ÷ 跨Goroutine同步开销
Goroutine 的轻量级并非魔法,而是内存与调度的双重让渡
每个新 Goroutine 仅初始分配 2KB 栈空间(可动态伸缩),远低于 OS 线程的 MB 级固定栈。这使百万级并发成为可能,但代价是栈拷贝与逃逸分析敏感性增强。验证方式如下:
# 查看当前 Goroutine 栈大小配置(Go 1.22+)
go env GODEBUG
# 输出示例:schedtrace=1000 表示每秒打印调度器统计
Channel 通信的本质是同步契约,而非数据搬运管道
向 chan int 发送值时,若接收方未就绪,发送方将被挂起并移交 M(OS线程)控制权——此过程不涉及系统调用,由 Go 运行时在用户态完成状态切换。关键在于:阻塞操作即调度点。
调度器的三级抽象屏蔽了硬件异构性
| 抽象层 | 实体 | 职责 |
|---|---|---|
| G | Goroutine | 用户代码执行单元,可被抢占 |
| M | Machine(OS线程) | 执行 G 的载体,绑定系统调用 |
| P | Processor | 本地运行队列、内存缓存、调度上下文 |
当 P 的本地队列为空时,调度器自动触发 work-stealing:从其他 P 的队列尾部窃取一半 Goroutine。该策略显著降低全局锁竞争,使并发扩展性趋近线性。
性能跃迁的临界点取决于同步模式选择
- ✅ 推荐:
chan配合select实现无共享通信 - ⚠️ 谨慎:
sync.Mutex在高争用场景下引发 M 频繁休眠唤醒 - ❌ 规避:跨 Goroutine 直接读写同一
[]byte而无同步
实测表明,在 32 核机器上启动 10 万 Goroutine 执行独立 HTTP 请求时,使用 buffered channel 控制并发数(如 make(chan struct{}, 100))比 sync.WaitGroup + mutex 组合提升约 37% 吞吐量——因前者将阻塞语义交由调度器统一优化,后者则依赖内核调度器介入。
第二章:GMP调度模型的深度解构与瓶颈识别
2.1 GMP核心组件行为建模:goroutine、M、P状态迁移图谱
Go运行时通过goroutine(G)、machine(M)和processor(P)三者协同实现并发调度。其状态迁移并非静态配置,而是由调度器动态驱动的有限状态机。
状态迁移关键触发点
G:_Grunnable → _Grunning → _Gsyscall → _Gwaiting(如chan receive阻塞)M:_Mrunning → _Msyscall → _Mspinning(空闲轮询P)P:_Prunning → _Pidle → _Pgcstop
goroutine阻塞示例(带状态注释)
func blockOnChan() {
ch := make(chan int, 1)
go func() { ch <- 42 }() // G1: _Grunnable → _Grunning → _Gwaiting (写入后休眠)
<-ch // G2: _Grunnable → _Grunning → _Gwaiting → _Grunnable (唤醒后就绪)
}
此代码中,<-ch使G2进入_Gwaiting态,触发gopark()调用,保存寄存器上下文并移交P给其他G;当发送G完成写入,调用ready()将G2标记为_Grunnable并尝试抢占空闲P。
M与P绑定关系变迁
| M状态 | P状态 | 触发条件 |
|---|---|---|
_Mrunning |
_Prunning |
正常执行用户G |
_Msyscall |
_Pidle |
系统调用返回且无G可运行 |
_Mspinning |
nil |
尝试窃取其他P的runq |
graph TD
G1[_Grunnable] -->|schedule| G2[_Grunning]
G2 -->|blocking syscall| G3[_Gsyscall]
G3 -->|park| G4[_Gwaiting]
G4 -->|ready| G1
2.2 真实生产Trace分析:pprof+trace可视化定位调度阻塞热点
在高并发微服务中,goroutine 调度阻塞常表现为 P 处于 Gwaiting 或 Grunnable 长时间积压。我们通过 runtime/trace 采集原始 trace 数据,并用 go tool trace 可视化调度器行为。
采集与转换流程
# 启动带 trace 的服务(采样率 1ms)
GOTRACEBACK=all go run -gcflags="all=-l" main.go 2> trace.out
# 生成可交互 HTML 报告
go tool trace -http=localhost:8080 trace.out
-gcflags="all=-l" 禁用内联以保留更精确的调用栈;2> trace.out 将二进制 trace 重定向至文件,避免 stdout 混淆日志。
关键调度视图识别
- Scheduler Dashboard:观察
P状态切换频率,若idle→running延迟 >100μs,表明 M 绑定异常或系统负载过载 - Goroutine Analysis:筛选状态为
Runnable超过 5ms 的 goroutine,定位runtime.gopark上游调用点
| 视图 | 关注指标 | 异常阈值 |
|---|---|---|
| Goroutines | 平均等待时长 | >3ms |
| Network | netpoll wait time | >50ms |
| Scheduler | P idle duration | >200μs |
graph TD
A[启动 trace] --> B[运行 30s]
B --> C[导出 trace.out]
C --> D[go tool trace]
D --> E[Web UI 分析 G/P/M 状态流]
2.3 GC STW对M抢占的影响量化:从GC pause到P饥饿的链式推演
GC STW期间的M状态冻结机制
Go运行时在STW阶段会调用 stopTheWorldWithSema(),强制所有M进入休眠并解除与P的绑定:
// src/runtime/proc.go
func stopTheWorldWithSema() {
// ...
for _, p := range allp {
if p != nil && p.status == _Prunning {
p.status = _Pgcstop // 标记P为GC暂停态
handoffp(p) // 解绑M,触发M进入findrunnable()循环
}
}
}
handoffp(p) 使原绑定M失去P,被迫进入调度循环等待新P;若全局P池已空,则M陷入自旋等待——此时虽未阻塞,但无法执行用户G。
M饥饿→P饥饿的传导路径
- M在
findrunnable()中轮询allp和pidle,但STW期间pidle为空且allp[i].status ≠ _Prunning不成立 - 每次轮询耗时约50ns,1000个M并发争抢将产生显著CPU空转开销
- P资源被GC独占,新G无法入队,就绪G积压于
runq或gfree链表
关键参数影响对比
| 参数 | 默认值 | STW期间实际值 | 影响 |
|---|---|---|---|
sched.nmidle |
0 | ≈ runtime.GOMAXPROCS | M全部脱离P,无空闲M可用 |
sched.npidle |
0 | 0 | P无一可分配,P饥饿显性化 |
gomaxprocs |
8 | 锁定不变 | 并发上限固化,放大争抢效应 |
graph TD
A[GC触发STW] --> B[所有P设为_Pgcstop]
B --> C[M调用handoffp解绑]
C --> D[M进入findrunnable自旋]
D --> E{pidle为空?}
E -->|是| F[M持续空转消耗CPU]
E -->|否| G[获取P继续调度]
F --> H[用户G就绪队列积压]
H --> I[P饥饿导致吞吐骤降]
2.4 网络轮询器(netpoll)与OS线程绑定失衡的实测复现
当 GOMAXPROCS=1 但存在高并发短连接时,netpoll 可能持续绑定单个 M,导致其他 M 空闲而 accept 队列堆积。
复现场景构造
- 启动监听服务并施加 500 QPS 连接洪流(每次 connect+close)
- 通过
runtime.GOMAXPROCS(1)强制单 P 调度 - 使用
perf record -e sched:sched_migrate_task观察 M 迁移频次为 0
关键观测数据
| 指标 | 值 | 说明 |
|---|---|---|
netpollWaitUntil 调用占比 |
92% | 单 M 长期陷于 epoll_wait |
其他 M 的 m->spinning 时间 |
几乎不参与网络事件分发 |
// net/http/server.go 中 accept 循环简化示意
for {
rw, err := listener.Accept() // 实际由 netpoller 触发
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Temporary() {
continue
}
return
}
go c.serve(connCtx) // 新 goroutine,但调度仍受限于绑定 M
}
此处
listener.Accept()底层依赖epoll_wait,在单 M 绑定下无法被其他 M 抢占执行,造成轮询器“独占”现象。参数epoll_wait的timeout=-1导致无限等待,加剧失衡。
graph TD
A[netpoller 启动] --> B{M 是否空闲?}
B -->|否| C[当前 M 持续 epoll_wait]
B -->|是| D[唤醒 idle M 处理新事件]
C --> E[accept 队列积压]
D --> F[负载均衡]
2.5 高频channel争用场景下的锁竞争热区定位(mutex profile+go tool pprof –mutex)
当多个 goroutine 频繁通过 chan<- / <-chan 触发底层 hchan 的 sendq/recvq 操作时,runtime.chansend 和 runtime.chanrecv 内部会竞争 hchan.lock —— 这正是 mutex profile 的核心捕获目标。
启用互斥锁分析
# 编译时启用竞态检测与锁统计
go build -gcflags="-m" -ldflags="-s -w" -o app .
# 运行并采集 mutex profile(需 GODEBUG=mutexprofile=1)
GODEBUG=mutexprofile=1 ./app &
sleep 30
kill %1
go tool pprof --mutex app mutex.prof
GODEBUG=mutexprofile=1启用运行时锁事件采样;--mutex模式解析mutex.prof,聚焦sync.Mutex.Lock调用栈深度与阻塞时间。
典型竞争热点分布
| 热点位置 | 平均阻塞时间 | 调用频次 | 关联 channel 类型 |
|---|---|---|---|
runtime.chansend |
12.4ms | 8,217 | 无缓冲 channel |
sync.(*Mutex).Lock |
9.8ms | 15,632 | 自定义 ring buffer |
锁竞争调用链(简化)
graph TD
A[goroutine send to chan] --> B[runtime.chansend]
B --> C[hchan.lock.Lock]
C --> D{是否被占用?}
D -->|Yes| E[加入sendq等待]
D -->|No| F[完成发送]
关键参数:-seconds=30 控制采样窗口,--focus=chansend 可过滤聚焦通道相关锁路径。
第三章:内存与调度协同优化的三大支柱
3.1 sync.Pool精准复用模式:避免逃逸+对象生命周期对齐实战
为什么 Pool 不是万能缓存?
sync.Pool仅适用于临时、无状态、可丢弃的对象(如切片、结构体实例)- GC 会周期性清理空闲 Pool,不保证对象长期驻留
- 对象若持有外部引用或需显式释放资源(如文件句柄),则不可放入 Pool
生命周期对齐关键实践
var bufPool = sync.Pool{
New: func() interface{} {
// 避免逃逸:预分配固定大小,不依赖闭包捕获变量
b := make([]byte, 0, 1024) // cap=1024 → 复用时零分配
return &b
},
}
逻辑分析:
make([]byte, 0, 1024)在 New 函数内完成,返回指针但底层数组在 Pool 内部管理;调用方获取后可buf := *bufPool.Get().(*[]byte),bufPool.Put(&buf)归还。cap固定确保后续append不触发扩容逃逸。
逃逸检测对比表
| 场景 | 是否逃逸 | 原因 |
|---|---|---|
make([]byte, 1024) 在函数内直接返回 |
✅ 是 | 切片头逃逸至堆(编译器无法确定生命周期) |
make([]byte, 0, 1024) 封装于 Pool.New |
❌ 否 | 底层数组由 Pool 管理,栈上仅存指针,且 Pool 控制复用边界 |
graph TD
A[请求缓冲区] --> B{Pool 有可用对象?}
B -->|是| C[直接复用:零分配]
B -->|否| D[调用 New 创建]
C --> E[使用后显式 Put]
D --> E
E --> F[GC 清理空闲对象]
3.2 避免goroutine泄漏的三重守卫:context超时、defer recover、pprof实时监控
Context 超时控制:第一道防线
使用 context.WithTimeout 为 goroutine 设置生命周期边界,确保其不会无限驻留:
func fetchWithTimeout(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 必须调用,释放资源
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
_, err := http.DefaultClient.Do(req)
return err
}
context.WithTimeout 返回可取消的子上下文与 cancel 函数;defer cancel() 保障无论成功或 panic 均释放信号通道,防止父 context 泄漏。
defer recover:第二道防线
在长生命周期 goroutine 中嵌入 panic 捕获机制:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panicked: %v", r)
}
}()
for range time.Tick(time.Second) {
// 可能 panic 的业务逻辑
}
}()
defer recover 阻断 panic 导致的 goroutine 意外终止,避免“幽灵 goroutine”残留。
pprof 实时监控:第三道防线
通过 HTTP 接口暴露运行时指标,快速定位异常增长:
| 端点 | 用途 | 示例 |
|---|---|---|
/debug/pprof/goroutine?debug=2 |
当前 goroutine 栈快照 | 查看阻塞/空闲 goroutine |
/debug/pprof/heap |
堆内存与 goroutine 关联分析 | 识别未释放的 channel 或闭包引用 |
graph TD
A[启动 goroutine] --> B{是否设 context 超时?}
B -->|否| C[高风险泄漏]
B -->|是| D{是否 defer recover?}
D -->|否| E[panic 后永久消失]
D -->|是| F{pprof 定期采样}
F --> G[发现 goroutine 数持续上升]
G --> H[回溯栈中未关闭的 channel/select]
3.3 P数量动态调优策略:GOMAXPROCS自适应算法与NUMA感知部署
Go 运行时通过 GOMAXPROCS 控制可并行执行的 OS 线程数(即逻辑处理器 P 的数量),但静态设置易导致 NUMA 跨节点内存访问或 CPU 资源闲置。
自适应 GOMAXPROCS 调整示例
// 基于当前可用逻辑 CPU 数动态设置,排除超线程干扰
func init() {
n := runtime.NumCPU()
if n > 0 && n%2 == 0 {
n /= 2 // 在启用了超线程的服务器上保守取半
}
runtime.GOMAXPROCS(n)
}
该逻辑在启动时规避超线程带来的缓存争用;runtime.NumCPU() 返回操作系统报告的逻辑核数,GOMAXPROCS 随后限制 P 的最大数量,直接影响 goroutine 调度吞吐。
NUMA 感知部署关键约束
| 约束维度 | 推荐值 | 说明 |
|---|---|---|
| P 数量上限 | ≤ 物理核心数 | 避免跨 NUMA 节点调度 |
| 内存绑定策略 | numactl --cpunodebind=0 --membind=0 |
绑定 P 到本地内存节点 |
调度路径优化示意
graph TD
A[goroutine 就绪] --> B{P 是否空闲?}
B -->|是| C[直接在本地 P 执行]
B -->|否| D[尝试窃取同 NUMA 节点其他 P 的 runq]
D --> E[仅当无可用时才跨节点迁移]
第四章:高吞吐IO密集型服务的并发重构范式
4.1 基于io_uring的net.Conn零拷贝封装:减少M阻塞与系统调用开销
传统 net.Conn 的 Read/Write 调用频繁触发 read(2)/write(2),引发用户态-内核态切换与数据拷贝。io_uring 通过提交队列(SQ)与完成队列(CQ)实现异步、批量、无锁 I/O,为零拷贝网络封装提供底层支撑。
核心优化路径
- 复用
io_uring提交缓冲区作为 socket 接收环形缓冲区(避免recvfrom→ 用户 buffer → 应用 buffer 两次拷贝) - 将
Conn的Read方法重定向为IORING_OP_RECV提交,配合IOSQE_IO_LINK链式提交IORING_OP_PROVIDE_BUFFERS - 使用
IORING_FEAT_SQPOLL启用内核轮询线程,规避 syscall 开销
关键代码片段
// 注册固定缓冲区(一次注册,多次复用)
_, err := ring.RegisterBuffers([][]byte{buf})
// buf 直接映射为 socket 接收目标,绕过 copy_from_user
此处
RegisterBuffers将用户空间内存页锁定并注册至io_uring,后续IORING_OP_RECV可直接写入该地址,消除内核到用户态的数据拷贝;buf长度需对齐页边界(通常 4KB),且生命周期需长于所有依赖该 buffer 的 I/O 请求。
| 特性 | 传统 syscalls | io_uring 封装 |
|---|---|---|
| 系统调用次数/请求 | 2+(read + epoll_wait) | 0(SQE 提交即异步) |
| 内存拷贝次数 | 2(kernel→user→app) | 0(直接写入应用 buffer) |
| Goroutine 阻塞 | 是(M 被挂起) | 否(由 CQE 通知完成) |
graph TD
A[Conn.Read] --> B[构造 IORING_OP_RECV SQE]
B --> C[提交至 SQ]
C --> D[内核直接填充注册 buffer]
D --> E[CQE 返回完成事件]
E --> F[Go runtime 唤醒 goroutine]
4.2 channel流水线模式重构:扇入扇出结构化设计与背压控制实现
在高吞吐数据处理场景中,原始串行 channel 链易因下游阻塞导致上游 goroutine 泄漏。引入扇出(fan-out)与扇入(fan-in)结构可解耦处理单元,并通过带缓冲 channel 与信号协调实现背压。
扇入扇出拓扑示意
graph TD
A[Source] --> B[Worker Pool<br/>Fan-out]
B --> C[Worker-1]
B --> D[Worker-2]
B --> E[Worker-N]
C --> F[Fan-in Aggregator]
D --> F
E --> F
F --> G[Sink]
背压敏感的 Worker 实现
func worker(id int, in <-chan int, out chan<- int, done <-chan struct{}) {
for {
select {
case val, ok := <-in:
if !ok { return }
// 模拟处理延迟
time.Sleep(10 * time.Millisecond)
select {
case out <- val * 2:
case <-done: // 主动退出信号,避免死锁
return
}
case <-done:
return
}
}
}
逻辑分析:select 双重判断确保 out 通道未满才写入;done 通道提供优雅退出路径,防止因下游阻塞导致 goroutine 悬挂。参数 id 用于调试追踪,time.Sleep 模拟真实处理耗时,凸显背压触发条件。
缓冲策略对比表
| 缓冲类型 | 适用场景 | 背压响应 | 内存开销 |
|---|---|---|---|
| 无缓冲 | 强实时、低延迟 | 即时 | 极低 |
| 固定缓冲 | 吞吐稳定、可控积压 | 延迟触发 | 中等 |
| 动态缓冲 | 波峰流量突增 | 自适应 | 较高 |
4.3 worker pool + context取消的弹性任务分发:CPU/IO混合负载动态配比
在高并发服务中,单一固定线程池难以应对 CPU 密集型(如图像缩放)与 IO 密集型(如 HTTP 调用)任务的混合负载。需根据实时指标动态调整资源配比。
动态权重调度器
type Task struct {
Kind string // "cpu" or "io"
Fn func()
Timeout time.Duration
}
func dispatch(ctx context.Context, task Task, cpuPool, ioPool *WorkerPool) {
select {
case <-ctx.Done():
return // 上游取消立即退出
default:
if task.Kind == "cpu" {
cpuPool.Submit(task.Fn)
} else {
ioPool.Submit(task.Fn)
}
}
}
ctx 保障链路级可取消;cpuPool/ioPool 独立伸缩(如 CPU 池固定 4 goroutine,IO 池基于 http.DefaultClient.Timeout 自适应扩容)。
负载感知配比策略
| 指标源 | CPU 权重系数 | IO 权重系数 | 触发条件 |
|---|---|---|---|
runtime.NumCpu() |
×1.0 | ×0.2 | 启动时静态基准 |
net/http 延迟 P95 |
×0.1 | ×1.5 | >200ms 自动提升 IO 池容量 |
graph TD
A[新任务入队] --> B{Kind == 'cpu'?}
B -->|是| C[路由至 CPU Pool]
B -->|否| D[路由至 IO Pool]
C & D --> E[Context Done?]
E -->|是| F[立即终止执行]
E -->|否| G[运行并上报耗时]
4.4 http.Handler并发安全改造:从全局锁到per-Request资源隔离(sync.Map vs atomic.Value)
数据同步机制
传统 http.Handler 中共享状态常依赖 sync.Mutex 全局加锁,易成性能瓶颈。演进路径为:
- 全局互斥锁 → 按键分片锁 →
sync.Map(读多写少场景) →atomic.Value(不可变值高频读)
sync.Map vs atomic.Value 对比
| 特性 | sync.Map | atomic.Value |
|---|---|---|
| 写操作开销 | 较高(需哈希+动态扩容) | 极低(仅指针原子替换) |
| 读操作开销 | O(1) 平均,但含内存屏障 | 零分配、无锁、纯加载 |
| 类型安全性 | interface{},需运行时断言 | 强类型(需固定具体类型) |
| 适用场景 | 动态增删 key 的缓存映射 | per-Request 配置/上下文快照 |
atomic.Value 实践示例
var reqCtx atomic.Value // 存储 *RequestContext
// 初始化(通常在 middleware 中)
reqCtx.Store(&RequestContext{
TraceID: traceID,
Timeout: 30 * time.Second,
})
// handler 中安全读取
ctx := reqCtx.Load().(*RequestContext)
atomic.Value.Store() 要求传入非 nil 指针,且后续 Load() 返回值必须显式类型断言;其底层通过 unsafe.Pointer 原子替换实现零拷贝传递,避免锁竞争与 GC 压力。
并发模型演进示意
graph TD
A[Handler 共享 map] --> B[Mutex 全局锁]
B --> C[sync.Map 分段锁]
C --> D[atomic.Value per-Request]
D --> E[无锁、无共享、纯函数式]
第五章:从性能数字到工程范式的终局思考
性能指标的幻觉与真实代价
某头部电商在大促压测中达成 99.999% 可用性、P99 延迟
架构决策中的隐性契约转移
以下表格对比了三种常见缓存失效策略在真实生产环境中的副作用:
| 策略 | 平均失效耗时 | 雪崩概率(万级 QPS) | 运维干预频次/月 | 根本约束条件 |
|---|---|---|---|---|
| 主动双删(DB→Cache) | 42ms | 17% | 6.3 | DB 主从延迟 |
| 延迟双删 + 消息队列 | 210ms | 0.8% | 0.2 | 消息投递可靠性 ≥99.999% |
| Cache-Aside + TTL | 无显式耗时 | 23%(TTL 同步到期) | 12 | 业务容忍脏读窗口 ≤2s |
数据来自 2023 年某金融支付中台灰度实验。选择延迟双删并非因“更先进”,而是将强一致性契约从数据库层转移到消息中间件——这要求团队具备 Kafka 分区重平衡诊断、死信队列自动修复等新能力。
工程范式迁移的临界点
flowchart LR
A[单体应用] -->|API 响应 P99 > 1.2s| B[服务拆分]
B -->|跨服务调用超时激增| C[引入 Service Mesh]
C -->|Envoy 内存泄漏导致节点逐出| D[重构为 eBPF 加速的 L4/L7 代理]
D -->|内核模块签名合规问题| E[建立 CNCF Sig-Auth 跨团队认证流程]
该演进路径发生在某政务云平台三年间。当性能瓶颈从应用层下沉至内核层,工程重心便从“写代码”转向“定义协作契约”:SRE 团队必须参与 Linux 内核参数评审,安全团队需提前介入 eBPF 字节码签名策略设计,法务开始审核 CNCF CLA 协议条款。
可观测性的范式反转
某物流调度系统将全链路 trace 数据采样率从 1% 提升至 100%,结果发现:92% 的 Span 标签值重复率超 99.7%,而真正影响决策的 3 个维度(车辆电池 SOC、实时路况拥堵指数、司机连续驾驶时长)在原始埋点中缺失。团队最终废弃 APM 工具,改用 OpenTelemetry 自定义 exporter,直接将车载终端 CAN 总线数据、高德 SDK 实时路况 API 响应、TMS 排班引擎状态三源融合为统一 context,并通过 Prometheus remote_write 直连时序库——性能数字从此成为业务状态的自然副产品,而非独立优化目标。
工程文化的具象锚点
在某自动驾驶公司,性能评审会已取消所有“QPS”“RT”幻灯片。取而代之的是:
- 每次发布前签署《感知模型推理延迟承诺书》,明确标注不同光照条件下的置信度衰减曲线;
- 硬件团队向软件团队交付的不是“算力TOPS”,而是实车路测中 FPGA 温度每升高 1℃ 导致的帧率抖动标准差;
- 测试用例必须包含 3 类极端场景:隧道出口强光眩光、暴雨雷达点云稀疏、5G 切换瞬间的 V2X 消息乱序。
这些不再是质量门禁,而是新范式下每个工程师每日提交 PR 时必须填写的元信息字段。
