Skip to main content

Penulis

Akira Noda - VoicePing Inc.

TL;DR

Kami menulis semula pelayan proksi WebSocket kami dari Python ke Go dan mengurangkan penggunaan CPU kepada 1/10 dan penggunaan memori kepada 1/100. Projek ini bukan sahaja meningkatkan kecekapan sumber tetapi juga mengajar kami pelajaran keserentakan yang penting:
Pastikan kunci sekecil dan sesedikit mungkin.

Konteks

Sistem kami adalah saluran paip STT (pertuturan-ke-teks) dan terjemahan masa nyata yang digunakan dalam VoicePing, di mana setiap peranti klien mengalirkan audio ke backend kami untuk pertuturan-ke-teks dan terjemahan dalam pelbagai bahasa. Pelayan proksi WebSocket berada di tengah saluran paip ini:
Seni Bina

Gambaran keseluruhan seni bina sistem

  • Setiap klien mengekalkan sesi WebSocket berterusan dengan proksi STT
  • Proksi menyampaikan paket audio ke salah satu daripada beberapa pelayan inferens berasaskan GPU
  • Menunggu teks yang ditranskripsikan dan mengalirkan balik transkrip separa dan terjemahan
Seni bina ini mesti mengendalikan beribu-ribu sesi audio masa nyata serentak - dengan latensi sub-saat. Walau bagaimanapun, proksi berasaskan Python kami sebelumnya menjadi bottleneck.

Sebelum: Proksi Python (Tidak Cekap)

Pelayan proksi pertama kami dilaksanakan dalam Python (FastAPI + asyncio + websockets) dan digunakan menggunakan Gunicorn dengan berbilang proses pekerja. Ia berfungsi dengan baik pada skala kecil tetapi dengan cepat mencapai had sumber di bawah trafik pengeluaran:
MetrikSebelum (Python)Selepas (Go)
Penggunaan CPU~12 teras, 40-50%~12 teras, 4-5%
Penggunaan memori~25 GB~10 MB

Mengapa Python Bergelut

Walaupun tak segerak, seni bina Python mengenakan beberapa bottleneck sistemik: Event Loop Single-Threaded: Model asyncio menggandakan ribuan coroutine pada satu thread. Ini bermakna hanya satu coroutine berjalan pada satu masa - yang lain menunggu sehingga gelung menyerahkan kawalan. Di bawah I/O yang berat, gelung tunggal ini menjadi titik cekik pusat, terutamanya untuk beban kerja WebSocket dengan peristiwa baca/tulis yang berterusan. Gunicorn Multiprocessing: Untuk menggunakan semua teras CPU, kami mencipta berbilang proses pekerja. Setiap proses memuatkan runtime Python penuh dan keadaan aplikasi - menggandakan penggunaan memori secara linear. Konteks Tugas Berat: Setiap sambungan WebSocket menyimpan bingkai tindanan, futures, dan callbacks sendiri - menggunakan memori yang besar setiap sambungan. Overhead Interpreter: Setiap coroutine berjalan di dalam interpreter CPython, menambah pemeriksaan jenis dinamik dan overhead penghantaran bytecode. Akibatnya, walaupun sistem kelihatan serentak, ia berurutan pada terasnya - setiap coroutine menunggu pada event loop yang sama, memperbesarkan latensi dan beban CPU apabila kiraan sambungan meningkat. Ia tidak dapat dielakkan - model Python tidak sesuai untuk multiplexing WebSocket yang berumur panjang, throughput tinggi, latensi rendah pada skala ini. Jadi kami menulis semula dalam Go.

Pandangan Burung Pelayan Proksi

Gambaran Keseluruhan Proksi

Seni bina pelayan proksi dengan kumpulan sambungan

Pelayan proksi kami bertindak sebagai lapisan tengah antara klien dan berbilang pelayan inferens. Klien menghantar bait audio melalui WebSocket, dan proksi mengarahkan setiap aliran ke salah satu daripada beberapa pelayan inferens STT (pertuturan-ke-teks). Klien -> Proksi: Setiap klien membuka sambungan WebSocket ke proksi dan terus menghantar potongan audio. Proksi -> Pelayan Inferens: Proksi memilih satu sambungan aktif dari kumpulan sambungan WebSocketnya - kumpulan sambungan backend berterusan (cth., Pool A untuk Server A, Pool B untuk Server B). Pemprosesan Penstriman: Proksi mengekalkan pemetaan antara klien dan sambungan backend yang dipilih untuk keseluruhan sesi, memajukan paket audio dan mengembalikan keputusan STT dalam masa nyata. Penggunaan Semula Sambungan: Apabila sesi tamat (klien terputus), proksi mengembalikan sambungan backend ke kumpulan, menjadikannya tersedia untuk klien lain. Mekanisme penggunaan semula ini secara drastik mengurangkan pertukaran sambungan dan overhead sumber. Pelayan proksi mengekalkan dua bahagian utama:
  1. Pengurus sambungan: mengendalikan penghalaan dan kitaran hayat sambungan klien
  2. Kumpulan sambungan WebSocket: mengurus sambungan backend boleh guna semula untuk setiap pelayan inferens
Setiap kumpulan sepadan dengan satu sasaran inferens (cth., A atau B), dan memegang bilangan terhad sambungan WebSocket yang telah ditetapkan. Seni bina ini membolehkan proksi untuk:
  • Mengimbangi beban dengan cekap merentasi pelayan inferens
  • Mengelakkan overhead penubuhan sambungan yang kerap

Keperluan Fungsi untuk Pengurusan Kumpulan Sambungan

Reka Bentuk Kumpulan

Keperluan fungsi kumpulan sambungan

Mereka bentuk pengurusan kumpulan sambungan adalah bahagian paling kritikal seni bina proksi baharu. Kumpulan perlu mengendalikan beribu-ribu sesi WebSocket serentak dengan cekap sambil mengekalkan sistem yang stabil dan ringan.
KeperluanTujuan
Pilih sambungan yang tersediaSetiap permintaan masuk mesti mendapatkan sambungan backend yang siap digunakan dengan cepat tanpa menyekat klien lain. Memastikan latensi rendah dan pengimbangan beban yang lancar.
Kembalikan sambungan ke kumpulanSebaik sahaja sesi klien tamat, sambungan harus dilepaskan dan disediakan untuk digunakan semula. Meminimumkan overhead membuka/menutup sambungan berulang kali.
Simpan hanya sambungan yang sihatPemeriksaan kesihatan berkala membuang atau mencipta semula sambungan yang gagal. Menghalang sambungan tidak sihat daripada terkumpul dan menyebabkan kegagalan senyap.
Segerakkan konfigurasi pangkalan dataMenyegerakkan konfigurasi sambungan backend secara berkala dari pangkalan data pusat, membenarkan penskalaan dinamik tanpa memulakan semula.

Reka Bentuk Pertama (Naif)

Reka Bentuk Pertama

Reka bentuk awal dengan mutex global

Pada mulanya, saya melaksanakan reka bentuk yang mudah - ringkas tetapi naif:
  • Satu tatasusunan yang memegang semua sambungan
  • Bendera Boolean untuk keadaan “sedang digunakan” / “tersedia”
  • Satu kunci global untuk semua operasi
Memilih sambungan melibatkan:
  1. Mendapatkan kunci
  2. Mengimbas tatasusunan untuk mencari sambungan yang tersedia
  3. Menandakannya sebagai “sedang digunakan”
  4. Melepaskan kunci
Mengembalikan sambungan berfungsi serupa - dapatkan kunci, tukar bendera, lepaskan kunci. Kami juga mempunyai goroutine berasingan untuk menyemak konfigurasi pangkalan data secara berkala. Goroutine ini menyegar semula tetapan backend seperti senarai pelayan atau saiz kumpulan dari pangkalan data, memastikan proksi sentiasa mempunyai konfigurasi terkini tanpa memerlukan memulakan semula. Goroutine pemeriksaan kesihatan berasingan mengimbas semua sambungan secara berkala, membuang yang tidak sihat, dan menambah yang baharu apabila diperlukan.
// ────────────────────────────
// REKA BENTUK PERTAMA (naif, mutex global)
// Slice tunggal + bendera, satu mutex kasar.
// ────────────────────────────

type Conn struct {
    id      string
    ws      *websocket.Conn
    inUse   bool
    healthy bool
}

type Pool struct {
    mu       sync.Mutex
    conns    []*Conn
    maxSize  int
    dialURL  string
}

Isu dalam Reka Bentuk Pertama

Walaupun berfungsi dalam ujian kecil, model pengumpulan awal rosak di bawah beban. Kami memerhati: Kiraan jumlah tidak betul (terlebih/terkurang): Operasi pilih/kembalikan serentak mengubah slice + bendera yang sama di bawah satu kunci kasar, jadi percubaan semula dan timeout kadangkala mengembalikan dua kali atau kehilangan sambungan, melepasi maksimum atau menyebabkan kumpulan kebuluran. Akses serentak bertembung -> ranap & keadaan rosak: Pemeriksaan kesihatan dan goroutine metrik bertembung dengan pengendali permintaan; pemeriksaan kesihatan yang panjang memegang kunci global, sementara pembaca kadangkala memerhatikan bendera separuh dikemas kini, mencetuskan panik atau ralat “tiada sambungan tersedia” walaupun kapasiti ada. Kebocoran goroutine: Dail gagal dan pemeriksaan kesihatan timeout tidak selalu dibatalkan atau dituai; percubaan semula mencipta goroutine baharu sementara rujukan kepada yang lama kekal. Kebolehperhatian rapuh: Pembilang yang diperoleh dari slice + bendera kerap tidak bersetuju dengan realiti, menjadikan amaran bising dan menutup insiden sebenar. Pertembungan kunci & lonjakan latensi: Imbasan O(n) untuk slot yang tersedia di bawah satu kunci memperbesarkan latensi ekor apabila keserentakan meningkat.
Punca asal dalam satu baris: terlalu banyak keadaan dikongsi dilindungi oleh satu kunci luas yang dipegang lama, ditambah operasi (kesihatan/metrik) yang sepatutnya bebas bersaing untuk kunci yang sama.

Reka Bentuk Disemak

Reka Bentuk Disemak

Reka bentuk tanpa kunci dengan atomik dan saluran

Reka bentuk semula memberi tumpuan kepada meminimumkan keadaan dikongsi dan mengasingkan tanggungjawab:
KomponenTujuan
Baris gilir untuk sambungan tersediaEnqueue/dequeue mengendalikan kunci dalaman secara automatik
sync.Map untuk sambungan sedang digunakanPeta serentak tanpa kunci
Pemboleh ubah atomikBendera kesihatan dan pembilang
Goroutine khusus setiap sambunganPemeriksaan kesihatan bebas
Setiap komponen kini beroperasi secara bebas, tanpa kunci seluruh kumpulan. Bilangan & skop kunci menurun secara dramatik.
package pool

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"time"

	"github.com/google/uuid"
	"github.com/gorilla/websocket"
)

// ────────────────────────────
// Conn (satu sambungan WebSocket backend boleh guna semula)
// ────────────────────────────

type Conn struct {
	id       string
	ws       *websocket.Conn
	healthy  atomic.Bool   // bendera status kesihatan tanpa kunci
	lastPing atomic.Int64  // cap masa atomik untuk degupan jantung terakhir
}

// StartHealthLoop berjalan secara bebas setiap sambungan
func (c *Conn) StartHealthLoop(ctx context.Context, interval time.Duration) {
	t := time.NewTicker(interval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			deadline := time.Now().Add(interval / 2)
			if err := c.ws.WriteControl(websocket.PingMessage, []byte("ping"), deadline); err != nil {
				c.healthy.Store(false)
				_ = c.Close()
				return
			}
			c.lastPing.Store(time.Now().UnixNano())
			c.healthy.Store(true)
		}
	}
}

// ────────────────────────────
// ConnPool (laluan pantas sahaja - tiada dail, tiada I/O menyekat)
// ────────────────────────────

type ConnPool struct {
	available chan *Conn   // saluran buffer untuk conns siap (tanpa kunci)
	inUse     sync.Map     // peta serentak untuk menjejaki conns aktif
	statsIn   atomic.Int64 // pembilang atomik (tidak perlu kunci)
	statsOut  atomic.Int64
}

// Acquire tidak menyekat
func (p *ConnPool) Acquire(ctx context.Context) (*Conn, error) {
	select {
	case c := <-p.available:
		p.inUse.Store(c.id, c)
		p.statsIn.Add(1)
		return c, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
		return nil, errors.New("tiada sambungan tersedia")
	}
}

// Release tidak menyekat
func (p *ConnPool) Release(c *Conn) {
	p.inUse.Delete(c.id)
	p.statsOut.Add(1)

	if c.IsAlive() {
		select {
		case p.available <- c:
			// dikembalikan ke kumpulan dengan selamat
		default:
			// kumpulan penuh -> buang conn basi dengan selamat
			_ = c.Close()
		}
	} else {
		// tidak sihat -> tutup segera
		_ = c.Close()
	}
}
Setiap komponen beroperasi secara bebas tanpa kunci seluruh kumpulan. Bilangan dan skop kunci menurun secara dramatik.

Penyesuaian Dipacu Peristiwa

Penyesuaian

Corak pekerja penyesuaian

Mengekalkan saiz kumpulan yang betul adalah cabaran lain. Apabila sambungan gagal atau dikembalikan, penyesuaian boleh dengan mudah berjalan secara serentak dan melebihi saiz maksimum kumpulan. Penyelesaiannya adalah gelung penyesuaian dipacu peristiwa:
  • Setiap operasi menghantar mesej ke dalam saluran (messageCh)
  • Goroutine penyesuaian memproses mesej-mesej ini secara berurutan
  • Ini memastikan tiada keadaan perlumbaan
Model ini membolehkan kami mengendalikan keserentakan tinggi sambil mengekalkan sistem yang deterministik dan selamat. Corak utama:
  • Pekerja single-threaded menserialisasikan penyesuaian
  • Saluran buffer menggabungkan lonjakan menjadi operasi tunggal
  • Jaring keselamatan berkala dengan jitter menghalang kawanan menggerunkan

Semakan Prestasi Tempatan

Ujian Prestasi

Persediaan dan keputusan ujian prestasi tempatan

Kami mengesahkan prestasi secara tempatan dengan persediaan berikut:

Persediaan Ujian

KomponenKonfigurasi
ProksiProksi WebSocket berasaskan Go
Backend3 x Pelayan WebSocket Echo
Beban3,000 sambungan serentak (tiada peningkatan)
TrafikMesej teks 1 KB @ 100 msg/s setiap sambungan

Keputusan

MetrikNilai
Sesi serentak~3,000 stabil
Throughput~300K mesej/saat
Memori puncak~150 MB
Memori purata~60 MB
Penggunaan CPU~4-5% daripada 12 teras
Ini mengesahkan bahawa proksi mengekalkan jejak memori yang rata, walaupun di bawah lonjakan sambungan sepenuhnya serentak - mengesahkan keberkesanan pengasingan kumpulan sambungan dan model penyesuaian dipacu peristiwa.
Keputusan

Ringkasan perbandingan prestasi

Kesimpulan

Selepas menggunakan proksi berasaskan Go baharu, kami memerhati peningkatan utama merentasi prestasi, skalabiliti, dan kestabilan:
KategoriPython (FastAPI + asyncio + Gunicorn)Go (Goroutines + Channels + Atomics)Peningkatan
Penggunaan CPU~12 teras x 40-50%~12 teras x 4-5%~90% pengurangan
Penggunaan memori~25 GB~60-150 MB~99% pengurangan
SkalabilitiTerhad kepada ratusanMenampung ribuan10x skala
Penulisan semula Go bukan sekadar perubahan bahasa - ia adalah transformasi model keserentakan.
Pengambilan Utama: Reka bentuk keserentakan sebagai proses bebas yang berkomunikasi - bukan sebagai keadaan boleh ubah dikongsi di bawah perlindungan.
Peralihan seni bina ini membolehkan proksi berskala dari ratusan ke ribuan sesi WebSocket serentak dengan penggunaan sumber hampir tetap dan tanpa kehilangan kejelasan dalam kod atau operasi.

Rujukan

  1. Go Concurrency Patterns - golang.org/doc/effective_go
  2. gorilla/websocket - github.com/gorilla/websocket
  3. Python asyncio Event Loop - docs.python.org