第一章:高并发文件传输的底层挑战与Go语言适配性
高并发文件传输并非简单地提升连接数或带宽,其本质是系统在I/O密集、内存压力、上下文切换与数据一致性等多维度约束下的协同博弈。传统阻塞式I/O模型在万级并发连接下迅速遭遇内核态资源耗尽(如epoll句柄上限、TIME_WAIT套接字堆积);而用户态线程(如pthread)因栈空间固定(通常2MB/线程)导致内存爆炸,10k并发即需20GB仅用于线程栈。
内核态I/O瓶颈与系统调用开销
Linux中每次read()/write()均触发一次上下文切换(用户态↔内核态),高吞吐场景下每秒百万次调用将显著消耗CPU周期。sendfile()和splice()虽可零拷贝绕过用户缓冲区,但受限于跨文件系统、socket类型(如不支持UDP)及中断处理延迟。
Go运行时调度器的天然优势
Go的M:N调度模型将goroutine(轻量协程)复用至少量OS线程(M),单goroutine初始栈仅2KB,按需动态伸缩。配合net.Conn.Read()的非阻塞封装与runtime.netpoll基于epoll/kqueue的集成,使单机轻松承载10w+活跃连接:
// 示例:启动10万并发上传连接(简化逻辑)
func startUploadWorkers(n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, _ := net.Dial("tcp", "server:8080")
// 复用conn.Write()发送文件分片,底层由runtime自动绑定到epoll事件循环
conn.Write(fileChunk[id])
}(i)
}
wg.Wait()
}
文件描述符与内存管理权衡
| 维度 | C/Java传统方案 | Go方案 |
|---|---|---|
| 连接保活 | 需手动心跳+超时回收 | net.Conn.SetKeepAlive() + GC自动清理 |
| 缓冲区分配 | 预分配大buffer易OOM | sync.Pool复用[]byte切片,降低GC压力 |
| 错误传播 | errno全局变量易被覆盖 | error接口显式返回,避免状态污染 |
Go的io.CopyBuffer支持自定义缓冲区复用,结合bytes.Buffer池化策略,可在32KB缓冲区下实现95%+的CPU利用率,同时将GC pause控制在100μs内。
第二章:缓冲区管理失当导致的内存雪崩
2.1 理论剖析:TCP窗口、OS内核缓冲区与Go runtime buffer生命周期
TCP连接中,滑动窗口由接收方通告(rwnd),受制于OS内核的sk_rcvbuf(接收缓冲区);而Go net.Conn在read()时,实际从内核缓冲区拷贝数据到runtime管理的临时buffer(如bufio.Reader底层数组),其生命周期始于make([]byte, n),终于GC标记或显式复用。
数据同步机制
内核缓冲区与Go runtime buffer间存在两次拷贝:
- 第一次:内核→用户空间(
recv()系统调用) - 第二次:runtime buffer→应用逻辑(
io.Read())
conn, _ := net.Dial("tcp", "localhost:8080")
reader := bufio.NewReaderSize(conn, 4096) // 显式指定runtime buffer大小
buf := make([]byte, 1024)
n, _ := reader.Read(buf) // 触发内核→runtime buffer→应用内存三阶段流转
bufio.NewReaderSize创建的buffer由Go runtime分配,其容量影响单次系统调用频次;reader.Read(buf)不直接操作内核buffer,而是协调runtime buffer填充与消费节奏。
关键参数对照表
| 层级 | 参数 | 默认值 | 影响 |
|---|---|---|---|
| TCP层 | net.ipv4.tcp_rmem |
4096 131072 6291456 |
内核接收窗口上下限 |
| Go runtime | bufio.Reader.Size |
4096 |
用户态缓冲粒度,降低syscall开销 |
graph TD
A[内核sk_receive_queue] -->|recv syscall| B[Go runtime buffer]
B -->|io.Read| C[应用栈变量]
C --> D[GC可达性分析]
D -->|无引用| E[buffer回收]
2.2 实践验证:使用pprof+netstat复现writev阻塞引发的goroutine堆积
复现环境准备
- Go 1.21+,Linux 5.15+(启用
tcp_tw_reuse) - 模拟客户端快速断连 + 服务端未设写超时
关键复现代码
func handleConn(c net.Conn) {
defer c.Close()
buf := make([]byte, 4096)
for {
n, err := c.Read(buf)
if err != nil { return }
// 强制触发writev:分片写入但对端已RST
for i := 0; i < 10; i++ {
_, _ = c.Write(buf[:n]) // 可能阻塞在内核writev系统调用
}
}
}
c.Write()在 TCP 连接半关闭或对端丢包时,可能卡在writev(2)系统调用中(内核等待 TCP 窗口恢复或重传超时),导致 goroutine 永久阻塞于IO wait状态。
监控链路
| 工具 | 观测目标 |
|---|---|
go tool pprof http://:6060/debug/pprof/goroutine?debug=2 |
查看 syscall.Syscall 占比突增 |
netstat -s | grep -A 5 "packet loss" |
验证重传/丢包是否关联 |
ss -i 'dst :8080' |
查看 retrans、rto、qsize |
阻塞传播路径
graph TD
A[goroutine 调用 Write] --> B[进入 runtime.syscall]
B --> C[陷入 writev 系统调用]
C --> D[内核等待 TCP 发送缓冲区可用]
D --> E[对端 RST/丢包 → RTO 增长 → 长期阻塞]
2.3 错误模式识别:io.Copy无界读写导致runtime.mheap.grow耗尽内存
当 io.Copy 的源(如 http.Request.Body)未设限,而目标为内存缓冲(如 bytes.Buffer),会触发持续分配,最终迫使 runtime.mheap.grow 频繁扩容堆,直至 OOM。
典型错误代码
func handler(w http.ResponseWriter, r *http.Request) {
buf := new(bytes.Buffer)
_, _ = io.Copy(buf, r.Body) // ❌ 无界读取,Body 可能达 GB 级
// ... 处理 buf.Bytes()
}
io.Copy 内部循环调用 Read(p []byte),默认使用 32KB 临时缓冲;若 r.Body 无 Content-Length 或 Transfer-Encoding: chunked 且客户端不关闭连接,将无限读取并追加至 buf,引发指数级内存增长。
防御策略对比
| 方法 | 是否阻断恶意输入 | 内存上限可控 | 实现复杂度 |
|---|---|---|---|
io.LimitReader(r.Body, 10<<20) |
✅ | ✅(10MB) | ⭐ |
http.MaxBytesReader |
✅✅(含 header + body) | ✅ | ⭐⭐ |
自定义 Read 限流器 |
✅ | ✅ | ⭐⭐⭐ |
安全调用示例
func handler(w http.ResponseWriter, r *http.Request) {
lr := http.MaxBytesReader(w, r.Body, 10<<20) // 10MB 全局上限
buf := new(bytes.Buffer)
_, err := io.Copy(buf, lr) // 超限时返回 http.ErrBodyTooLarge
if err != nil { /* handle */ }
}
2.4 安全修复:基于io.LimitedReader/io.LimitWriter的流控封装示例
在处理不可信输入流(如 HTTP 请求体、上传文件)时,未加限制的读写操作易引发内存耗尽或 DoS 攻击。io.LimitedReader 和 io.LimitWriter 提供轻量、无拷贝的流控原语。
封装为安全中间件
func NewSafeReader(r io.Reader, maxBytes int64) io.Reader {
return &io.LimitedReader{R: r, N: maxBytes}
}
func NewSafeWriter(w io.Writer, maxBytes int64) io.Writer {
return io.LimitWriter(w, maxBytes)
}
逻辑分析:
LimitedReader在每次Read()时原子递减N,当N <= 0时返回io.EOF;LimitWriter同理,在Write()超限时返回&errors.ErrShortWrite{}。二者均不缓冲数据,零分配,适用于高并发场景。
关键参数对照
| 类型 | 控制方向 | 触发行为 | 典型用途 |
|---|---|---|---|
io.LimitedReader |
输入流 | Read() 返回 EOF |
限制请求体大小 |
io.LimitWriter |
输出流 | Write() 返回短写 |
防止日志/响应无限写入 |
流控生效路径
graph TD
A[HTTP Body] --> B[NewSafeReader<br/>max=1MB]
B --> C[JSON Decoder]
C --> D[业务逻辑]
2.5 性能对比实验:固定buffer vs dynamic buffer在10G文件分块传输中的GC压力差异
实验设计要点
- 使用 JFR(Java Flight Recorder)持续采集 GC pause、allocation rate、young gen promotion rate;
- 两组对照:
FixedByteBufferPool(8KB)预分配 1024 个 direct buffer;DynamicByteBuffer()每次ByteBuffer.allocateDirect(8KB); - 传输 10GB 文件,分块大小统一为 8KB,禁用 G1 的 region-based allocation 以排除干扰。
核心代码片段
// 固定池模式(复用)
ByteBuffer buf = pool.acquire(); // 线程安全,无新对象分配
channel.read(buf);
buf.flip();
channel.write(buf);
pool.release(buf); // 归还至池,不触发 GC
逻辑分析:
acquire()返回池中已存在DirectByteBuffer实例,全程零堆外内存申请;release()仅重置状态位,避免Cleaner注册与后续Unsafe.freeMemory调度,显著降低System.gc()诱因频率。
GC 压力对比(JFR 采样均值)
| 指标 | 固定 buffer | dynamic buffer |
|---|---|---|
| Young GC 次数 | 12 | 3,842 |
| Promotion rate (MB/s) | 0.04 | 11.7 |
| Full GC 触发次数 | 0 | 2 |
内存生命周期示意
graph TD
A[Fixed Pool] -->|acquire| B[Existing DirectByteBuffer]
B --> C[read/write 复用]
C -->|release| A
D[Dynamic] -->|allocateDirect| E[New DirectByteBuffer]
E --> F[注册 Cleaner]
F --> G[FinalizerQueue → GC 后 cleanup]
第三章:HTTP multipart边界解析引发的协议级崩溃
3.1 理论剖析:RFC 7578中boundary随机性与Go net/http multipart.Reader状态机缺陷
RFC 7578 要求 boundary 必须由客户端生成且具备统计随机性(如 16 字节 cryptographically secure PRNG),以避免边界误匹配。但 Go 标准库 net/http 的 multipart.Reader 在解析时未校验 boundary 长度与熵强度,仅依赖首行匹配逻辑。
解析状态机的临界缺陷
multipart.Reader 使用线性扫描 + 状态缓存,当遇到形如 --boundary\r\n 后紧接非法换行或截断数据时,会错误进入 partHeader 状态而跳过后续 \r\n\r\n 分隔符校验:
// 源码简化示意(src/mime/multipart/reader.go)
func (r *Reader) NextPart() (*Part, error) {
// ❌ 无 boundary 随机性验证
// ❌ 未检查 boundary 是否被提前截断或伪造
if bytes.HasPrefix(line, r.boundary) { // 仅字符串前缀匹配
r.state = partHeader // 危险跃迁
}
}
该逻辑忽略 RFC 7578 §4.1 明确要求:“boundary must not appear inside any part payload”,而 Go 实现未做 payload 内部 boundary 冲突检测。
安全影响对比表
| 维度 | RFC 7578 合规要求 | Go multipart.Reader 行为 |
|---|---|---|
| Boundary 随机性 | 强制 CSPRNG 生成 | 允许任意 ASCII 字符串 |
| 边界唯一性保障 | 要求全局唯一且不可预测 | 无重复性/熵值校验 |
| 状态回退机制 | 明确定义错误恢复策略 | 无状态回滚,易卡死 |
graph TD
A[读取一行] --> B{是否以 --boundary 开头?}
B -->|是| C[进入 partHeader 状态]
B -->|否| D[继续扫描]
C --> E{后续是否出现 \\r\\n\\r\\n?}
E -->|缺失| F[错误解析:将payload头部误认为header]
3.2 实践验证:构造恶意boundary触发multipart.NextPart panic及panic recover失效场景
恶意 boundary 构造原理
multipart/form-data 解析依赖 boundary 字符串分隔字段。当 boundary 包含 \r\n-- 或嵌套破折号(如 ----boundary)时,mime/multipart.Reader.NextPart() 内部状态机可能误判边界位置,导致越界读取或空指针解引用。
复现 panic 的最小示例
// 构造含非法前缀的 boundary
body := `--\r\n--malicious\r\nContent-Disposition: form-data; name="test"\r\n\r\nhello\r\n--\r\n--malicious--`
reader := multipart.NewReader(strings.NewReader(body), "\r\n--malicious")
_, err := reader.NextPart() // panic: runtime error: invalid memory address or nil pointer dereference
逻辑分析:
multipart.Reader将\r\n--malicious解析为boundary后,实际扫描时因\r\n--被误识别为独立分隔符,导致r.currentPart未初始化即被访问。recover()在NextPart调用栈深层触发,若调用方未在defer中包裹整个解析流程,则 recover 失效。
关键失效条件对比
| 场景 | recover 是否生效 | 原因 |
|---|---|---|
defer func(){recover()}() 包裹单次 NextPart() |
❌ | panic 发生在 NextPart 内部 goroutine 或不可达栈帧 |
defer 包裹整个 for { part, _ := r.NextPart(); ... } 循环 |
✅ | panic 在 defer 作用域内直接捕获 |
graph TD
A[Start parsing] --> B{Scan for boundary}
B -->|Match \r\n--| C[Reset state]
C --> D[Attempt to read header]
D -->|r.currentPart == nil| E[Panic on dereference]
E --> F[recover only works if deferred at same stack level]
3.3 安全修复:自定义boundary校验器+预分配part header buffer的防御型解析器
传统 multipart 解析器易受 boundary 污染攻击(如 boundary=----A--B-- 被篡改为 boundary=----A--B--\r\nContent-Type: text/html),导致 header 注入与 MIME 解析绕过。
防御双支柱设计
- 严格 boundary 校验器:拒绝含控制字符、换行符、空格或非 ASCII 字符的 boundary 值
- 预分配 header buffer:固定大小(如 1024B)避免堆喷与越界读写
核心校验逻辑(Go)
func isValidBoundary(b string) bool {
if len(b) == 0 || len(b) > 70 { // RFC 7578 限制
return false
}
for _, r := range b {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') {
return false // 仅允许字母、数字、-, _, .
}
}
return true
}
isValidBoundary严格遵循 RFC 7578,排除\r,\n,\t,SP等危险字符;长度上限 70 字节防止 header 缓冲区溢出。
预分配 buffer 效果对比
| 场景 | 动态分配(默认) | 预分配 1KB buffer |
|---|---|---|
| 内存碎片率 | 高 | |
| 最大 header 解析耗时 | 12.7ms | 0.89ms |
graph TD
A[HTTP Request] --> B{Boundary Valid?}
B -->|No| C[Reject 400 Bad Request]
B -->|Yes| D[Use Pre-allocated 1KB Header Buffer]
D --> E[Safe Header Parsing]
E --> F[Proceed to Part Body]
第四章:TLS握手与文件流交织引发的连接泄漏
4.1 理论剖析:Go crypto/tls.Conn.Read超时机制与底层conn fd生命周期错位
crypto/tls.Conn.Read 的超时行为并非原子操作,而是由上层 net.Conn.SetReadDeadline 驱动,但 TLS 层的读缓冲(in buffer)可能已预读未消费数据,导致 Read 返回不触发系统调用,绕过 fd 级超时检测。
数据同步机制
- TLS 连接在
Read前优先消费内部解密缓冲区(c.input) - 仅当缓冲区为空时,才调用底层
conn.Read - 此时
fd的epoll_wait/kqueue超时才生效;否则超时逻辑“静默失效”
关键代码路径
// src/crypto/tls/conn.go:Read
func (c *Conn) Read(b []byte) (int, error) {
if len(c.input.Bytes()) > 0 { // 缓冲区有残留明文 → 直接拷贝
return c.input.Read(b)
}
// 此时才进入底层 conn.Read → 触发 fd 超时检查
return c.conn.Read(b) // ← 实际受 SetReadDeadline 约束的位置
}
逻辑分析:
c.input是bytes.Buffer,其Read不感知网络超时;c.conn才绑定fd和deadline。参数b的长度影响是否触发重协商或分片处理,但不改变超时判定时机。
| 场景 | 是否触发 fd 超时 | 原因 |
|---|---|---|
input 非空 |
❌ | 完全走内存拷贝路径 |
input 为空 + 底层阻塞 |
✅ | 进入 conn.Read,受 readDeadline 约束 |
| TLS 记录头已收齐但应用未读完 | ⚠️ | 后续 Read 可能仍从缓冲区返回,延迟暴露超时 |
graph TD
A[Read called] --> B{c.input.Len > 0?}
B -->|Yes| C[Return from bytes.Buffer.Read<br>← 无视 deadline]
B -->|No| D[c.conn.Read<br>← 受 fd 级 deadline 控制]
4.2 实践验证:长连接上传中TLS renegotiation失败导致fd未释放的strace追踪
在长连接文件上传场景下,当服务端触发TLS重协商(renegotiation)而客户端拒绝或超时响应时,内核套接字状态可能卡在CLOSE_WAIT,但应用层未调用close()。
strace关键片段
# strace -p $PID -e trace=connect,sendto,recvfrom,close,shutdown 2>&1 | grep -E "(close|SSL|reneg)"
12345 recvfrom(8, "\x14\x03\x03\x00\x01\x01", 5, MSG_DONTWAIT, NULL, NULL) = 6
12345 sendto(8, "\x15\x03\x03\x00\x02\x02\x50", 7, MSG_NOSIGNAL, NULL, 0) = 7 # Alert: handshake_failure
该recvfrom捕获到TLS ChangeCipherSpec后紧随的Alert帧,sendto返回加密告警;但后续无close(8)调用——fd 8 持续泄漏。
fd泄漏链路分析
- TLS renegotiation失败 → OpenSSL触发
SSL_ERROR_SSL→ 应用未检查SSL_get_error()返回值 SSL_read()返回-1且errno未置为EAGAIN→ 误判为正常EOF → 跳过资源清理逻辑
| 现象 | 根因 | 修复要点 |
|---|---|---|
lsof -p $PID显示fd持续增长 |
未处理SSL_R_RENEGOTIATE错误 |
在SSL_read()/SSL_write()后必查SSL_get_error() |
graph TD
A[客户端发起上传] --> B[服务端触发TLS reneg]
B --> C{客户端拒绝reneg}
C -->|SSL_alert| D[recvfrom读取Alert帧]
D --> E[应用忽略SSL错误]
E --> F[fd未close,泄漏]
4.3 错误模式识别:http.Transport.MaxIdleConnsPerHost配置失效的深层原因分析
核心失效场景还原
当 MaxIdleConnsPerHost 设为 10,但监控显示某主机空闲连接数持续为 ,常见于以下组合配置:
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
// ❌ 遗漏:未设置 TLSClientConfig 或忽略 HTTP/2 协议协商
}
逻辑分析:
MaxIdleConnsPerHost仅控制 HTTP/1.x 连接池中按 Host 分桶的空闲连接上限;若启用了 HTTP/2(默认开启),Go 会绕过该限制,统一使用MaxIdleConns管理共享连接池,导致 per-host 配置静默失效。
HTTP/2 连接复用机制
HTTP/2 强制多路复用单 TCP 连接,不再按 Host 维护独立空闲连接队列:
| 协议版本 | 连接管理粒度 | MaxIdleConnsPerHost 是否生效 |
|---|---|---|
| HTTP/1.1 | 每 Host 独立队列 | ✅ 生效 |
| HTTP/2 | 全局连接池 + 多路复用 | ❌ 被忽略 |
修复路径
- 显式禁用 HTTP/2:
Transport.ForceAttemptHTTP2 = false - 或升级至 Go 1.19+,使用
http.Transport.DialContext+ 自定义连接池实现 Host 粒度控制
graph TD
A[发起 HTTP 请求] --> B{是否协商 HTTP/2?}
B -->|是| C[进入全局连接池<br>忽略 MaxIdleConnsPerHost]
B -->|否| D[按 Host 分桶入池<br>遵守 MaxIdleConnsPerHost]
4.4 安全修复:基于context.WithTimeout封装的tls.Conn wrapper及连接池回收钩子
在高并发 TLS 场景下,原生 tls.Conn 缺乏上下文感知能力,导致超时连接滞留、资源泄漏。为此,我们构建了带上下文生命周期绑定的 timeoutConn 封装器。
核心封装逻辑
type timeoutConn struct {
net.Conn
ctx context.Context
}
func (c *timeoutConn) Read(b []byte) (n int, err error) {
// 非阻塞检查上下文状态,避免死等
select {
case <-c.ctx.Done():
return 0, c.ctx.Err() // 返回 context.Canceled 或 DeadlineExceeded
default:
}
return c.Conn.Read(b)
}
ctx 由 context.WithTimeout(parent, 5*time.Second) 创建,确保所有 I/O 操作受统一超时约束;Read 前主动轮询 ctx.Done(),不依赖底层 Conn.SetReadDeadline 的竞态行为。
连接池回收钩子设计
| 钩子类型 | 触发时机 | 安全作用 |
|---|---|---|
Close |
连接显式关闭 | 立即清理 TLS session 缓存 |
IdleTimeout |
连接空闲超时 | 主动调用 tls.Conn.Close() |
MaxLifetime |
连接存活超时 | 强制刷新证书链,防密钥泄露 |
graph TD
A[NewConn] --> B{Context Done?}
B -- Yes --> C[Return to Pool]
B -- No --> D[Perform TLS Handshake]
D --> E[Attach timeoutConn wrapper]
E --> F[Register onIdle/onClose hooks]
第五章:从单体传输到云原生文件网关的演进思考
传统单体文件服务的瓶颈实录
某省级政务云平台早期采用Spring Boot单体应用承载全部文件上传、元数据管理与分发功能。随着接入委办局从12家激增至87家,日均文件量突破420万次(峰值达1.2万TPS),系统频繁触发Full GC,平均响应延迟从320ms飙升至4.8s。一次真实故障中,因单点MySQL写入锁表导致连续6小时无法上传PDF扫描件,倒逼业务方启用U盘人工拷贝——这成为推动架构重构的关键转折点。
拆分路径与灰度迁移策略
团队将原单体拆解为三个独立服务:
file-ingest:基于Kafka+Netty实现高吞吐接收(支持断点续传与SHA256预校验)meta-store:采用TiDB集群存储结构化元数据,QPS提升至23,000+delivery-gateway:无状态API网关,通过OpenResty实现动态路由与Token鉴权
采用双写+流量镜像灰度方案:新老系统并行运行3周,通过对比日志哈希值验证数据一致性,最终在凌晨2点完成零感知切换。
云原生网关的核心能力矩阵
| 能力维度 | 单体架构实现 | 云原生网关实现 |
|---|---|---|
| 弹性伸缩 | 手动扩容ECS,耗时≥45分钟 | K8s HPA基于CPU+QPS双指标自动扩缩容( |
| 多协议兼容 | 仅支持HTTP/HTTPS | 同时暴露S3 API、WebDAV、FTP over TLS接口 |
| 安全审计 | 日志分散在各模块,追溯需跨服务查 | 统一OpenTelemetry埋点,审计事件实时推送至SIEM |
生产环境性能压测对比
使用JMeter对核心场景进行对比测试(16核32G节点×3):
# 单体架构(未优化JVM参数)
$ wrk -t4 -c1000 -d30s http://legacy/file/upload
Requests/sec: 1287.32
# 云原生网关(启用eBPF加速)
$ wrk -t4 -c1000 -d30s http://gateway/v1/s3/buckets/doc/upload
Requests/sec: 18642.91
架构演进中的关键决策点
- 存储解耦:放弃自研对象存储,直接对接MinIO私有云集群,通过Bucket Policy实现租户级配额控制
- 流量治理:在Istio Ingress Gateway注入Envoy Filter,实现按文件类型(
.pdf/.xlsx)动态限流 - 灾备设计:跨AZ部署双活网关,利用RabbitMQ消息队列同步元数据变更,RPO
真实业务价值量化
某医保结算系统接入后,单日处理电子病历文件量达187万份(平均大小4.2MB),上传成功率从92.7%提升至99.995%,CDN缓存命中率稳定在83.6%。运维团队通过Grafana看板实时监控各网关实例的http_request_duration_seconds_bucket{le="1"}指标,异常检测响应时间缩短至17秒。
flowchart LR
A[客户端] --> B{Ingress Controller}
B --> C[Authz Filter]
C --> D[Rate Limiting]
D --> E[Protocol Adapter]
E --> F[MinIO Cluster]
F --> G[Async Metadata Sync]
G --> H[TiDB Cluster] 