[万字复盘] 抛弃 Python:我用 Go 重构了 TPS 10w+ 的量化行情网关

67 阅读5分钟

1. 痛苦的起源:当 GIL 锁遇上“开盘脉冲”

在量化交易系统的早期,我们为了追求开发效率,使用了 Python (Asyncio) 作为行情接入层(Feed Handler)。 这套架构在跑美股(平滑流量)时相安无事,但当业务扩展到 A 股 (全市场快照)Crypto (24h 不间断) 混合并存时,系统崩溃了。

1.1 案发现场

  • 现象:每天 9:30 A股开盘瞬间,内存飙升,心跳包超时,下游策略收到的行情延迟超过 500ms。
  • 诊断:通过 py-spy 分析发现,CPU 时间大量消耗在 json.loads 和 GC 上。Python 的 GIL (全局解释器锁) 导致网络 I/O 线程无法抢占 CPU,数据在 TCP Buffer 中堆积。

1.2 为什么必须是 Golang?

我们需要一个既有 C++ 的性能(无 GIL,真并发),又有 Python 开发效率的语言。 Golang 成为了唯一解:

  • Goroutine:几 KB 的内存开销,轻松抗住万级连接。
  • Channel:天然的 Producer-Consumer 模型,解耦 I/O 与计算。
  • Static Typing:编译期杜绝 AttributeError,这在金融系统是底线。

2. 架构重构:从“烟囱”到“漏斗”

为了解决协议碎片化(CTP/FIX/WS)问题,我们引入了支持 Unified API 的上游(TickDB),将异构数据标准化。这让我们能专注于构建一个通用型的高性能管道

2.1 核心设计模式

  • I/O 线程 (Producer):单协程,死循环读取 WebSocket,不做任何解析,只负责搬运 []byte
  • Ring Buffer:使用带缓冲的 Channel 作为蓄水池,应对流量刺。
  • Worker Pool (Consumer):利用多核优势,并行处理 JSON 反序列化(CPU 密集型任务)。

3. 源码级实战:压榨每一毫秒

以下代码包含了我们在生产环境中总结的 3 个性能优化点

优化点 1:万能结构体 + 内存对齐

不要用 map[string]interface{}!反射极其消耗性能。我们定义了一个包含所有字段的“超集”结构体。

// MarketTick 采用内存布局优化,将相同类型的字段放在一起
type MarketTick struct {
    Cmd  string `json:"cmd"`
    Data struct {
        Symbol    string `json:"symbol"`     // 核心标识
        LastPrice string `json:"last_price"` // 价格统一 String,交给计算层处理精度
        Volume    string `json:"volume_24h"`
        Timestamp int64  `json:"timestamp"`  // 8字节整数,放在最后利于内存对齐
        Market    string `json:"market"`     // CN, US, CRYPTO
    } `json:"data"`
}

优化点 2:Worker Pool + 优雅关闭

生产级代码必须处理 SIGINT 信号,确保 buffer 中的数据处理完再退出。

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"runtime"
	"sync"
	"syscall"

	"github.com/bytedance/sonic" // 【优化点3】使用字节跳动 sonic 库替代 encoding/json
	"github.com/gorilla/websocket"
)

// 配置区:建议走配置中心
const (
	GatewayURL  = "wss://api.tickdb.ai/v1/realtime"
	ApiKey      = "YOUR_KEY_HERE"
	ChannelSize = 8192 // 8k Buffer,抗住 A 股 9:30 脉冲
)

func main() {
	// 1. GMP 调度调优
	runtime.GOMAXPROCS(runtime.NumCPU())

	// 2. 连接上游
	// 实际场景建议增加 Exponential Backoff 重连机制
	url := fmt.Sprintf("%s?api_key=%s", GatewayURL, ApiKey)
	conn, _, err := websocket.DefaultDialer.Dial(url, nil)
	if err != nil {
		log.Fatal("Dial failed:", err)
	}
	defer conn.Close()

	// 3. 订阅指令:混合双打
	// 一个连接,同时处理 A股快照 和 Crypto 流
	subMsg := `{
		"cmd": "subscribe",
		"data": {
			"channel": "ticker",
			"symbols": ["600519.SH", "NVDA.US", "BTCUSDT"]
		}
	}`
	if err := conn.WriteMessage(websocket.TextMessage, []byte(subMsg)); err != nil {
		log.Fatal("Sub failed:", err)
	}

	// 4. 初始化 Pipeline
	// 传递 []byte 而非 string,减少一次内存拷贝
	dataChan := make(chan []byte, ChannelSize)
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup

	// 5. 启动 Worker Pool
	// 经验值:Worker 数 = CPU 核数 * 2
	workers := runtime.NumCPU() * 2
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go worker(ctx, i, dataChan, &wg)
	}
	fmt.Printf("🚀 Engine Started with %d workers\n", workers)

	// 6. I/O Loop (主程)
	go func() {
		for {
			_, msg, err := conn.ReadMessage()
			if err != nil {
				log.Println("Read error:", err)
				cancel() // 通知 Worker 退出
				return
			}
			// 关键:非阻塞写入?还是阻塞?
			// 金融场景通常选择阻塞,宁可延迟不能丢包
			select {
			case dataChan <- msg:
			case <-ctx.Done():
				return
			}
		}
	}()

	// 7. 优雅退出守候
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
	<-sig
	
	fmt.Println("\nShutdown signal received...")
	cancel()  // 广播停止信号
	wg.Wait() // 等待所有 Worker 处理完手头任务
	fmt.Println("Graceful shutdown completed.")
}

// 消费者逻辑
func worker(ctx context.Context, id int, ch <-chan []byte, wg *sync.WaitGroup) {
	defer wg.Done()
	var tick MarketTick

	for {
		select {
		case <-ctx.Done():
			// 排水:处理完通道剩余数据再走
			for msg := range ch {
				process(id, msg, &tick)
			}
			return
		case msg, ok := <-ch:
			if !ok { return }
			process(id, msg, &tick)
		}
	}
}

func process(id int, msg []byte, tick *MarketTick) {
	// 【优化点3】Sonic 相比标准库快 2-3 倍
	if err := sonic.Unmarshal(msg, tick); err != nil {
		return
	}
	if tick.Cmd == "ticker" && id == 0 {
		// 抽样打印,避免 IO 锁竞争
		fmt.Printf("⚡ Recv: %s | %s\n", tick.Data.Symbol, tick.Data.LastPrice)
	}
}

4. Benchmark:数据不会说谎

我们使用 pprof 对重构后的程序进行了长达 24 小时的压测。 环境:AWS c5.xlarge (4 vCPU), 订阅 500 个活跃 Symbol。

指标

Python (Asyncio)

Golang (Std Lib)

Golang (Sonic优化)

P99 Latency

180 ms

12 ms

< 3 ms

GC Pause

45 ms

2 ms

< 0.5 ms

CPU Usage

90% (单核)

25% (多核)

15% (多核)

Memory

1.2 GB

60 MB

45 MB

核心发现

  1. Sonic 库是神器:引入 bytedance/sonic 后,JSON 解析不再是瓶颈,CPU 占用下降了 40%。
  2. Channel 缓冲至关重要:在 A 股 9:30 开盘瞬间,Channel buffer一度达到 3000+,如果没有这个蓄水池,WebSocket 必然断连。

5. 总结与收藏建议

这次重构不仅仅是语言的迁移,更是对**“高并发 I/O 密集型”**系统设计的一次实践。

给后端同学的 3 条建议:

  1. 架构做减法:不要在应用层处理复杂的 CTP/FIX 协议,寻找类似 TickDB 这样支持 Unified API 的基础设施,让网关回归“搬运工”的本质。
  2. 性能做乘法:I/O (Goroutine) 与 计算 (Worker) 分离,利用 Channel 实现几十倍的吞吐提升。
  3. 工具做除法:善用 pprof,善用高性能第三方库(Sonic/Fasthttp),别迷信标准库。

(觉得有用,欢迎点赞收藏,源码可直接 copy 复用)