Tại sao chúng tôi chuyển từ Express sang Fastify: Đánh giá sau 6 tháng vận hành thực tế

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Vượt xa những lựa chọn mặc định với Express

Express.js đã là xương sống của hệ sinh thái Node.js trong hơn một thập kỷ. Nó từng là lựa chọn hàng đầu của tôi cho mọi dự án, từ các bản hack cuối tuần đến các dịch vụ doanh nghiệp quy mô lớn. Tuy nhiên, sáu tháng trước, đội ngũ của chúng tôi đã gặp bế tắc. Chúng tôi đang quản lý một microservice xử lý 15.000 request mỗi giây và các vết nứt bắt đầu xuất hiện. Bất chấp việc tích cực sử dụng caching và mở rộng theo chiều dọc (vertical scaling), độ trễ p99 của chúng tôi thường xuyên vọt lên trên 500ms và mức sử dụng CPU luôn duy trì ở mức 85% đầy rủi ro.

Hiệu năng không phải là cơn đau đầu duy nhất; chúng tôi còn phải đối mặt với tình trạng “Type Drift” (lệch kiểu dữ liệu). Ngay cả với TypeScript, các request body trong Express về cơ bản vẫn là any cho đến khi chúng tôi xác thực (validate) chúng một cách thủ công. Chúng tôi dành nhiều thời gian để viết các mã boilerplate lặp đi lặp lại hơn là xây dựng các tính năng thực tế. Cuộc vật lộn đó đã dẫn chúng tôi đến việc di chuyển toàn bộ stack sang Fastify. Sau nửa năm vận hành thực tế, kết quả không chỉ dừng lại ở tốc độ thuần túy—nó đã thay đổi căn bản cách chúng tôi xây dựng API.

Nút thắt cổ chai thực tế: Middleware và xác thực thủ công

Vấn đề chính không chỉ nằm ở tốc độ thực thi. “Kẻ sát nhân” thực sự là overhead của mô hình middleware và việc thiếu cơ chế bắt buộc thực thi schema (schema enforcement) gốc. Trong một ứng dụng Express điển hình, việc validation thường chỉ được thêm vào sau cùng. Bạn thường sử dụng một thư viện như Joi hoặc Yup, chạy nó như một middleware và cầu nguyện rằng các interface TypeScript của bạn vẫn đồng bộ với các trình validator đó.

Khi codebase phát triển, ba vấn đề cụ thể đã nảy sinh:

  • Serialization Overhead: JSON.stringify() tiêu chuẩn nặng nề một cách đáng ngạc nhiên. Với các payload lớn (trên 100KB), nó sẽ chặn event loop trong vài mili giây trong những lúc cao điểm truy cập đồng thời.
  • Memory Bloat: Express thiếu một hệ thống quản lý vòng đời (lifecycle) tích hợp. Điều này dẫn đến các pattern singleton lộn xộn cho các kết nối cơ sở dữ liệu, rất khó để dọn dẹp khi triển khai (deployment).
  • Validation Gaps: Các lập trình viên thỉnh thoảng quên áp dụng middleware validation cho các route mới. Điều này dẫn đến các lỗi runtime crash mà TypeScript không thể bắt được.

Tại sao Express gặp khó khăn với các yêu cầu hiện đại

Express thuộc về một kỷ nguyên cũ của Node.js. Logic định tuyến (routing) của nó dựa trên việc quét tuyến tính các middleware. Điều này hoạt động tốt với 5 route, nhưng sẽ trở thành gánh nặng đáng kể khi bạn có 50 route. Hơn nữa, Express quá tự do (unopinionated). Nó không quan tâm bạn validate dữ liệu như thế nào, dẫn đến các pattern không đồng nhất giữa các team khác nhau.

Node.js đã phát triển, nhưng lõi của Express phần lớn vẫn dậm chân tại chỗ. Các API hiệu năng cao hiện đại được hưởng lợi từ các tối ưu hóa ahead-of-time (AOT). Fastify thực hiện điều này bằng cách biên dịch trước các JSON schema thành các hàm validation được tối ưu hóa cao—điều mà Express đơn giản là không được thiết kế để xử lý.

Đánh giá các ứng cử viên

Trước khi quyết định di chuyển, chúng tôi đã benchmark ba framework chính:

  1. NestJS: Một framework mạnh mẽ, nhưng có cảm giác quá nặng nề cho microservice cụ thể này. Mặc dù nó có thể sử dụng Fastify bên dưới, nhưng lớp trừu tượng bổ sung không xứng đáng với sự phức tạp so với nhu cầu của chúng tôi.
  2. Koa: Cung cấp khả năng kiểm soát middleware tuyệt vời, nhưng vẫn yêu cầu lắp ghép thủ công cho validation và tài liệu OpenAPI.
  3. Fastify: Nó hứa hẹn thông lượng (throughput) cao hơn đáng kể và sở hữu hệ thống plugin tích hợp với schema validation thông qua Ajv.

Fastify chiến thắng vì nó coi schema là tính năng cốt lõi. Nó sử dụng fast-json-stringify để biên dịch trước response schema, thường nhanh gấp 2 lần so với thư viện tiêu chuẩn. Đối với các endpoint có lưu lượng truy cập cao của chúng tôi, đây là một thắng lợi lớn.

Sự chuyển dịch sang Schema-First

Đây là nơi phép màu xảy ra. Thay vì viết mã rồi sau đó cố gắng viết tài liệu cho nó, Fastify khuyến khích bạn xác định cấu trúc dữ liệu ngay từ đầu. Bằng cách sử dụng @sinclair/typebox, chúng tôi định nghĩa một schema duy nhất đóng vai trò vừa là trình validator lúc runtime vừa là trình cung cấp kiểu dữ liệu (type provider) cho TypeScript.

import { Type } from '@sinclair/typebox';
import Fastify from 'fastify';

const server = Fastify();

const UserSchema = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String({ format: 'email' }),
});

// Kiểu dữ liệu được suy luận tự động—không cần tạo interface thủ công
type User = typeof UserSchema.static;

server.post<{ Body: User }>('/users', {
  schema: {
    body: UserSchema,
    response: {
      201: UserSchema,
    },
  },
}, async (request, reply) => {
  const { name, email } = request.body;
  return { id: '1', name, email };
});

Cách tiếp cận này đã loại bỏ vấn đề “Type Drift”. Nếu chúng tôi thay đổi schema, các kiểu dữ liệu TypeScript sẽ tự động cập nhật. Tuyệt vời hơn nữa, tài liệu Swagger của chúng tôi luôn đồng bộ hoàn hảo mà không tốn thêm công sức.

Tính đóng gói với hệ thống Plugin

Quản lý global state trong Express—như database pool hoặc cấu hình dùng chung—thường kết thúc trong một mớ hỗn độn các lệnh import. Fastify giải quyết vấn đề này với API register và wrapper fastify-plugin (fp). Điều này cho phép bạn tạo ra các module được đóng gói (encapsulated) mà không vô tình bị rò rỉ sang các phần khác của ứng dụng.

import fp from 'fastify-plugin';

export default fp(async (fastify, opts) => {
  const db = await createDbConnection(opts.uri);
  
  // Decorate giúp 'db' có thể truy cập được trên instance của fastify
  fastify.decorate('db', db);

  fastify.addHook('onClose', async (instance) => {
    await instance.db.close();
  });
});

Việc kiểm thử trở nên dễ dàng hơn đáng kể. Giờ đây chúng tôi có thể khởi tạo một instance Fastify, chỉ đăng ký các plugin cụ thể cần thiết cho một bài test và chạy nó mà không có tác dụng phụ (side effect) nào từ phần còn lại của ứng dụng.

Ba tối ưu hóa bắt buộc cho môi trường Production

Chuyển sang Fastify không chỉ là thay đổi một thư viện; đó là việc áp dụng các thói quen vận hành tốt hơn. Dưới đây là ba tối ưu hóa mà chúng tôi hiện đang sử dụng trong mọi dịch vụ:

1. Logging tốc độ cao với Pino

Fastify đi kèm với Pino, nhanh hơn khoảng 10 lần so với Winston. Không giống như các logger khác, Pino mặc định log dưới dạng JSON và có mức chiếm dụng CPU không đáng kể. Trong môi trường lưu lượng cao, các lệnh console log tiêu chuẩn thực tế có thể chặn event loop. Pino tránh được hoàn toàn điều này.

2. Graceful Shutdown đáng tin cậy

Các tiến trình không nên chỉ đột ngột biến mất. Khi nhận tín hiệu SIGTERM—thường thấy trong quá trình rolling update của Kubernetes—bạn phải đóng các kết nối cơ sở dữ liệu và hoàn tất các request đang xử lý. Hệ thống hook của Fastify giúp việc này trở nên cực kỳ đơn giản.

const start = async () => {
  try {
    await server.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

['SIGINT', 'SIGTERM'].forEach((signal) => {
  process.on(signal, async () => {
    server.log.info(`Đã nhận ${signal}, đang tắt ứng dụng...`);
    await server.close();
    process.exit(0);
  });
});

3. Xử lý lỗi chuẩn hóa

Việc xử lý lỗi trong Express thường là một sự kết hợp hỗn loạn của các khối try-catch. Fastify cung cấp một hàm setErrorHandler tập trung. Điều này đảm bảo mọi lỗi đều tuân theo một định dạng chuẩn và ngăn chặn việc rò rỉ stack trace nhạy cảm đến người dùng.

Kết luận cuối cùng

Những con số đã nói lên tất cả. Sau khi di chuyển, độ trễ trung bình của chúng tôi giảm 30% và mức chiếm dụng bộ nhớ giảm 25%. Tuy nhiên, chiến thắng thực sự nằm ở trải nghiệm của lập trình viên (developer experience). Sự kết hợp giữa TypeScript, TypeBox và cơ chế validation schema nghiêm ngặt giúp chúng tôi phát hiện lỗi ngay trong quá trình phát triển thay vì trong các bản log lúc 2 giờ sáng trên production.

Nếu bạn đang xây dựng một ứng dụng CRUD đơn giản, Express vẫn là một lựa chọn tốt. Nhưng nếu bạn cần mở rộng, xử lý dữ liệu phức tạp hoặc duy trì độ tin cậy cao, Fastify là công cụ vượt trội hơn. Nó buộc bạn phải viết mã tốt hơn một cách mặc định. Bản thân bạn trong tương lai—và đội ngũ vận hành (Ops team)—sẽ cảm ơn bạn vì điều đó.

Share: