Thay thế Node.js bằng Bun để đạt được 5 lần thông lượng

06 tháng 4, 2026·8 phút đọc

Trigger.dev đã chuyển dịch vụ môi giới kết nối "Firestarter" từ Node.js sang Bun. Thông qua việc tối ưu hóa cơ sở dữ liệu, biên dịch thành binary và sửa lỗi rò rỉ bộ nhớ đặc thù của Bun, họ đạt được mức tăng 5 lần thông lượng, giảm độ trễ và thu nhỏ đáng kể dung lượng container.

Thay thế Node.js bằng Bun để đạt được 5 lần thông lượng

Chúng tôi đã thay thế Node.js bằng Bun trong một trong những dịch vụ nhạy cảm về độ trễ nhất của mình và đạt được mức tăng gấp 5 lần thông lượng. Đồng thời, chúng tôi cũng phát hiện ra một lỗi rò rỉ bộ nhớ chỉ tồn tại trong mô hình HTTP của Bun.

Dịch vụ này có tên là Firestarter. Nó đóng vai trò là môi giới kết nối cho warm start: duy trì hàng nghìn kết nối HTTP long-poll từ các bộ điều khiển (controller) đang rảnh, mỗi cái chờ đợi công việc. Khi một tác vụ đến, Firestarter sẽ ghép nối nó với một controller đang chờ và gửi dữ liệu payloads qua kết nối đó. Không có cold start, không cần quay container. Nó nằm trên đường dẫn quan trọng của mọi lần thực thi tác vụ trên Trigger.dev.

Firestarter architectureFirestarter architecture

Vấn đề: Firestarter tiêu tốn quá nhiều CPU

Ban đầu, Firestarter chạy trên Node.js và chiếm dụng CPU cao. Nó dành 31% thời gian để thực hiện truy vấn SQLite, phân tích cú pháp mọi yêu cầu với Zod và chuyển đổi headers bằng Object.fromEntries() trên mỗi yêu cầu GET. Nó hoạt động được, nhưng rất chậm.

Phải mất 4 vòng profiling (phân tích hiệu năng) để giải quyết vấn đề, và chúng tôi đã gặp phải một số bất ngờ với Bun mà chưa thấy tài liệu nào đề cập đến.

Giai đoạn 1: Loại bỏ SQLite

Trình quản lý kết nối ban đầu được thiết kế như một kho lưu trữ truy vấn chung. Nó chấp nhận siêu dữ liệu lồng nhau tùy ý, làm phẳng chúng thành các cặp key-value và lập chỉ mục mọi thứ trong cơ sở dữ liệu SQLite trong bộ nhớ. Node 22 đi kèm với node:sqlite tích hợp sẵn, nên nó không có phụ thuộc bên ngoài. SQL cung cấp khả năng khớp một phần linh hoạt trên bất kỳ kết hợp trường nào.

Thực tế, mô hình truy cập luôn giống nhau với 4 trường. Mọi nỗ lực khớp đều chạy truy vấn phức tạp bao gồm JOIN, GROUP BY, và HAVING COUNT(DISTINCT) cho những gì về bản chất là một tra cứu bảng băm (chúng tôi đã làm quá mức cần thiết).

Chúng tôi chạy node --prof dưới tải và xử lý đầu ra bằng --prof-process. Hàm getConnection chiếm 31% tổng thời gian CPU.

Chúng tôi đã thay thế SQLite bằng một Map>. Khóa là một chuỗi được phân tách bằng null bao gồm deployment + version + cpu + memory. Việc khớp trở thành O(1) thay vì một truy vấn SQL.

Kết quả:

  • Thông lượng tăng từ 2.099 lên 4.534 req/s (tăng 2.2 lần).
  • Độ trễ trung vị (p50) giảm từ 22.5ms xuống 10.1ms.

Giai đoạn 2: Chuyển sang Bun

Sau khi loại bỏ SQLite, việc phân tích lại cho thấy hơn 50% thời gian CPU nằm ở nội bộ của node:http: ghi dữ liệu, quản lý socket, xử lý luồng. Ngăn xếp HTTP của Node.js có chi phí vượt mức khi bạn duy trì hàng nghìn kết nối long-poll đồng thời.

Chúng tôi đã thêm một điểm nhập Bun sử dụng Bun.serve() với API định tuyến tích hợp của nó. Trình quản lý kết nối đã bất khả tri về phương thức truyền tải (chúng tôi đã trích xuất nó trong quá trình loại bỏ SQLite), nên chủ yếu là việc kết nối dây.

Với Bun.serve(), thông lượng tăng thêm gấp đôi (4.534 lên 9.434 req/s) và độ trễ giảm một nửa.

Giai đoạn 3: Profiling và loại bỏ các tác vụ nặng

Bun nhanh hơn ngay lập tức, nhưng chúng tôi chưa dừng lại ở việc tối ưu hóa. Bun có cờ --cpu-prof-md xuất ra hồ sơ CPU dưới dạng Markdown thay vì định dạng Chrome DevTools.

Ba điểm nóng (hotspots) rõ ràng đã được tìm thấy:

  1. Zod: Phương thức DequeuedMessage.safeParse() trên mỗi POST chiếm 22% CPU.
  2. Object.fromEntries: Chuyển đổi iterator của headers thành object thuần túy trên mỗi yêu cầu chiếm 10.5% CPU.
  3. Debug logging: Bộ ghi log cấu trúc đang tuần tự hóa các đối số log trước khi kiểm tra mức log.

Chúng tôi đã thay thế Zod bằng các kiểm tra hiện diện trường tối thiểu, thay thế Object.fromEntries bằng các lệnh gọi req.headers.get() trực tiếp và sửa logic logging. Tổng cộng, ba bản sửa này đã cắt giảm mức sử dụng CPU khoảng 40% dưới cùng một tải.

Giai đoạn 4: Biên dịch thành một Binary duy nhất

Tiếp theo là chính thời gian chạy (runtime). Bun có cờ bun build --compile tạo ra một tệp thực thi độc lập. Không cần runtime, không cần node_modules, không cần tệp nguồn trong container.

Kết quả so với Bun được thông dịch:

  • Thông lượng tăng thêm 14%.
  • Độ trễ p95 giảm 24%.
  • Kích thước ảnh container giảm từ ~120MB xuống ~68MB.

Lỗi rò rỉ bộ nhớ trong Bun

Sau khi triển khai sản xuất, CPU đã giảm, nhưng bộ nhớ RSS (Resident Set Size) lại tăng vọt.

Memory usage graphMemory usage graph

Mọi yêu cầu GET /warm-start trả về một Promise mà Bun giữ cho đến khi chúng tôi giải quyết nó. Có ba cách để nó được giải quyết:

  1. Được khớp (matched).
  2. Hết giờ (timeout).
  3. Máy ngắt kết nối (Client disconnect).

Con đường số 3 là nguyên nhân gây rò rỉ.

Nguyên nhân gốc rễ

Khi máy ngắt kết nối, bộ xử lý abort của chúng tôi gọi removeConnection() để dọn dẹp trình quản lý kết nối. Nhưng nó không bao giờ giải quyết Promise đang chờ.

Trong Node.js với Fastify hoặc Express, đây không phải là vấn đề. Máy chủ gắn kết trạng thái phản hồi với socket, và khi socket chết, mọi thứ đều được dọn dẹp bất kể bạn có gọi res.end() hay không.

Ở Bun, hợp đồng của fetch handler khác biệt. Mọi Promise phải được giải quyết (settle). Bun giữ trạng thái yêu cầu nội bộ cho đến khi promise được giải quyết hoặc từ chối. Nếu không bao giờ thực hiện, trạng thái đó sẽ nằm trong bộ nhớ mãi mãi.

Mỗi kết nối bị rò rỉ khoảng 500-2000 byte. Với hàng trăm lần ngắt kết nối mỗi giờ, nó tích tụ rất nhanh.

Giải pháp

Chỉ cần một dòng code để giải quyết promise khi có sự kiện abort:

req.signal.addEventListener("abort", () => {
  if (!resolved) {
    cb();
    // Resolve để Bun có thể giải phóng ngữ cảnh yêu cầu
    wrappedResolve(new Response(null, { status: 499 }));
  }
}, { once: true });

Mã trạng thái 499 là "Client Closed Request" của Nginx. Máy khách không bao giờ thấy nó (họ đã đi rồi). Nhưng việc giải quyết promise cho phép Bun giải phóng ngữ cảnh yêu cầu.

Bức tranh toàn cảnh

So với cấu hình Node + SQLite ban đầu:

  • Thông lượng: Tăng 5 lần (từ 2.099 lên ~10.700 req/s).
  • Độ trễ tối đa: Tốt hơn 28 lần.
  • Dung lượng ảnh container: Giảm từ 180MB xuống 68MB.

Trên môi trường sản xuất

Các chuẩn đoán cục bộ kể một câu chuyện. Môi trường sản xuất kể một câu chuyện tốt hơn.

Bộ nhớ giảm từ ~192 MB xuống ~85 MB, nhưng sự khác biệt thực sự nằm ở sự ổn định. CPU dưới Node có những biến động mạnh, tăng gấp đôi mức cơ bản dưới tải. Bun giữ một đường dẫn ổn định.

CPU performance comparisonCPU performance comparison

Độ trễ vòng lặp sự kiện (event loop lag) dưới Node tăng vọt lên 40-80ms. Bun gần như không ghi nhận mức nào.

Bài học rút ra

  1. Hãy Profile trước khi tối ưu hóa: Việc loại bỏ SQLite có vẻ rõ ràng sau sự việc, nhưng chúng tôi chỉ tìm thấy nó vì đã profile.
  2. Mô hình HTTP của Bun khác Node: Vòng đời phản hồi gắn liền với promise, không phải socket. Nếu bạn đang chuyển đổi các điểm cuối long-poll hoặc streaming, hãy suy nghĩ kỹ về mọi đường dẫn code giải quyết Promise của bạn.
  3. Biên dịch Binary của bạn: bun build --compile mang lại hiệu năng tốt hơn và kích thước nhỏ hơn mà không cần thay đổi code.
  4. Sửa rò rỉ, chấp nhận tốn CPU: Sau khi sửa lỗi rò rỉ bộ nhớ, CPU tăng khoảng 5%. Đó là cái giá phải trả để dọn dẹp đúng cách các kết nối trước đây bị rò rỉ âm thầm.
  5. Staging là chuẩn đoán thực sự: Các lõi cục bộ nhanh hơn vCPU sản xuất. Chúng tôi đã bắt được một số bất ngờ khi triển khai trước khi tuyên bố chiến thắng.

Ngoài ra, việc sử dụng công cụ như k6 để kiểm tra tải và hiểu rõ cách Bun xử lý bộ nhớ (heap stats, prom-client compatibility) là chìa khóa để thành công trong việc chuyển đổi công nghệ này.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗