Rust trong môi trường production: Vì sao chúng tôi viết lại bộ xử lý tín hiệu
Chúng tôi viết lại engine xử lý tín hiệu từ Go sang Rust. Đây là lý do, những gì chúng tôi rút ra và mức cải thiện throughput gấp 4 lần đạt được.
Sáu tháng trước, chúng tôi đưa ra quyết định viết lại engine xử lý tín hiệu — thành phần biến các tín hiệu trình duyệt thô thành các vector đặc trưng đã chuẩn hóa và có thể băm — từ Go sang Rust. Đây không phải là quyết định được đưa ra một cách nhẹ nhàng. Bản triển khai Go của chúng tôi vẫn hoạt động. Nó đã được kiểm thử. Nó đã được triển khai. Nhưng nó đã trở thành nút thắt trong pipeline của chúng tôi, và chúng tôi cần một bước nhảy vọt về throughput. Đây là những gì đã diễn ra.
Vì sao chúng tôi vượt khỏi giới hạn của Go
Bộ xử lý tín hiệu của chúng tôi thực hiện công việc đòi hỏi nhiều tính toán: phân tích cú pháp payload JSON, áp dụng các hàm chuẩn hóa cho hơn 130 tín hiệu, tính các hash độc quyền và dựng các vector hash định danh. Trong Go, công việc này bị giới hạn bởi CPU, và garbage collector của Go trở thành vấn đề ở quy mô lớn. Mỗi chu kỳ xử lý tín hiệu đều cấp phát các đối tượng trung gian — các nút JSON đã phân tích, các giá trị chuỗi đã chuẩn hóa, các buffer hash — tạo ra áp lực lên GC.
Ở mức 30K sự kiện/giây, bộ xử lý tín hiệu Go của chúng tôi có các đợt dừng GC 2-5ms sau mỗi vài giây. Những đợt dừng này chấp nhận được. Ở mức 50K sự kiện/giây, các đợt dừng GC tăng lên 8-15ms và diễn ra thường xuyên hơn. Ở mức 80K sự kiện/giây — mức tải chúng tôi dự phóng cho Q3 — các đợt dừng GC sẽ khiến độ trễ p99 vượt SLA của chúng tôi. Chúng tôi cần hoặc thêm máy chủ (tốn kém) hoặc một bản triển khai hiệu quả hơn.
Vì sao chọn Rust
Chúng tôi đánh giá ba lựa chọn: tối ưu bản triển khai Go (sync.Pool, cấp phát arena, tinh chỉnh GOGC), viết lại bằng C++, và viết lại bằng Rust. Tối ưu Go mang lại cải thiện 30% nhưng không giải quyết tận gốc vấn đề GC. C++ bị loại vì các lo ngại về an toàn bộ nhớ trong một hệ thống quan trọng về bảo mật. Rust mang lại zero-cost abstraction, không có garbage collector và các đảm bảo an toàn bộ nhớ được thực thi ở thời điểm biên dịch.
Hệ sinh thái Rust cũng có các thư viện trưởng thành cho mọi thứ chúng tôi cần: serde để phân tích JSON, các crate băm hiệu năng cao, và tokio cho I/O bất đồng bộ. Đường cong học tập là có thật — đội ngũ của chúng tôi có kinh nghiệm Go sâu nhưng ít kinh nghiệm Rust — nhưng các đặc tính hiệu năng chính xác là những gì chúng tôi cần.
Quá trình viết lại
Chúng tôi viết lại bộ xử lý tín hiệu thành một dịch vụ độc lập giao tiếp với phần còn lại của pipeline qua gRPC. Điều này cho phép chúng tôi triển khai nó song song với bản Go và dịch chuyển lưu lượng dần dần. Việc viết lại tốn ba kỹ sư trong bốn tuần — hai tuần cho phần triển khai cốt lõi và hai tuần cho kiểm thử, benchmark và xử lý các trường hợp biên.
Khía cạnh thách thức nhất không phải bản thân ngôn ngữ mà là việc đảm bảo hành vi tương đương với bản Go. Chúng tôi dựng một bộ khung so sánh chạy cả hai bản triển khai trên cùng đầu vào và kiểm chứng rằng chúng cho ra kết quả giống hệt nhau. Chúng tôi phát hiện 14 khác biệt tinh vi trong quá trình này — chủ yếu liên quan đến xử lý số dấu phẩy động, chuẩn hóa Unicode và các trường hợp biên khi phân tích JSON.
Kết quả hiệu năng
Bản triển khai Rust xử lý tín hiệu trung bình 0.8ms so với 3.2ms của Go — cải thiện gấp 4 lần. Mức sử dụng bộ nhớ giảm từ 2.1GB xuống 340MB cho cùng khối lượng công việc. Không có đợt dừng GC nào vì không có garbage collector. Mức sử dụng CPU giảm 60% ở cùng throughput, nghĩa là mỗi máy chủ xử lý lưu lượng nhiều gấp 4 lần.
Ở mức 80K sự kiện/giây, bản triển khai Rust duy trì thời gian xử lý p99 là 1.4ms mà không có đợt dừng nào. Khoảng dư này nghĩa là chúng tôi sẽ không cần xem lại hiệu năng xử lý tín hiệu trong tương lai gần. Mức CPU và bộ nhớ giảm cũng trực tiếp chuyển thành chi phí hạ tầng thấp hơn — chúng tôi đã loại bỏ 8 trên 12 máy chủ xử lý tín hiệu.
Bài học rút ra
Viết lại bằng Rust là xứng đáng cho trường hợp cụ thể của chúng tôi — một khối lượng công việc bị giới hạn bởi CPU, cấp phát nhiều và nhạy cảm với độ trễ. Chúng tôi sẽ không viết lại lớp ingestion HTTP hay dịch vụ truy vấn ClickHouse bằng Rust, vì những thành phần đó bị giới hạn bởi I/O và Go xử lý chúng hiệu quả. Bài học không phải là "viết lại mọi thứ bằng Rust" mà là "dùng Rust ở nơi mà zero-cost abstraction và hiệu năng tất định có ý nghĩa nhất".
Điều bất ngờ lớn nhất là trình biên dịch Rust bắt được bao nhiêu lỗi trong quá trình viết lại. Một số lỗi tiềm ẩn trong bản Go của chúng tôi — race condition trên các buffer dùng chung, tràn số nguyên trong tính toán hash, và truy cập ngoài biên với đầu vào dị dạng — được bắt dưới dạng lỗi biên dịch trong Rust. Trình biên dịch khó tính, nhưng nó tự bù đắp bằng tính đúng đắn.
Thành thật mà nói, tuần đầu tiên rất vất vả. Sarah ghi lại số lần "đấu với borrow checker" trên bảng trắng — chúng tôi chạm mốc 47 trước khi cả đội ngừng đếm. Nhưng đến tuần thứ ba, đoạn code nào biên dịch được thì đơn giản là chạy đúng. Không có những cú panic bí ẩn trong production, không có data race dưới tải. Sự đánh đổi đó là xứng đáng cho bất cứ thứ gì nằm trên hot path.