Tối ưu phân tích dữ liệu trong Node.js với DuckDB: Giải pháp OLAP in-process gọn nhẹ

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Ba hướng tiếp cận khi xử lý dữ liệu lớn trong Node.js

Giả sử bạn cần tính tổng doanh thu và giá trị đơn trung bình theo từng danh mục từ file xuất 5 triệu bản ghi (khoảng 1.2 GB). Viết bằng Node.js thế nào để server không sập?

Thực tế khi làm backend, bạn thường có 3 lựa chọn:

  • Xử lý thuần bằng JavaScript (Streams / Chunks): Đọc từng dòng CSV hoặc parse JSON rồi tính bằng map, reduce. Cách này không cần thêm phụ thuộc nào. Đổi lại, CPU bị vắt kiệt và code xử lý stream khá dài dòng.
  • Đẩy sang database chuyên dụng (PostgreSQL, ClickHouse): Node.js chỉ đóng vai trò trung gian gửi câu lệnh SQL qua mạng rồi chờ kết quả. Cách này ổn định nhưng tốn chi phí hạ tầng. Bạn phải duy trì thêm cụm database và chịu độ trễ mạng từ 20ms – 50ms cho mỗi lượt truy vấn.
  • Nhúng DuckDB trực tiếp vào Node.js: DuckDB chạy ngay trong cùng tiến trình (in-process) thông qua C++ binding. Engine này đọc thẳng file CSV, Parquet trên đĩa với cú pháp SQL quen thuộc. Không qua network. Không cần dựng database server.

So sánh chi tiết ưu nhược điểm từng giải pháp

Mỗi phương án đều đánh đổi giữa tốc độ, bộ nhớ và chi phí vận hành.

1. Xử lý bằng JavaScript thuần

  • Ưu điểm: Triển khai nhanh cho tệp dữ liệu dưới 50.000 dòng. Không phát sinh dependency bên thứ ba.
  • Hạn chế: JavaScript chạy đơn luồng. Khi tính toán trên mảng hàng triệu object, V8 engine dễ chạm ngưỡng 1.4 GB heap memory và gây lỗi Out of Memory (OOM). Nếu không tách worker thread, Event Loop sẽ bị nghẽn hoàn toàn, khiến toàn bộ API khác đứng hình.

2. Dùng cơ sở dữ liệu quan hệ (PostgreSQL, MySQL)

  • Ưu điểm: Quen thuộc, hỗ trợ giao dịch ACID chặt chẽ, bảo mật và phân quyền bài bản.
  • Hạn chế: RDBMS truyền thống lưu trữ dạng dòng (row-oriented). Khi chạy truy vấn quét hàng triệu bản ghi để SUM hay AVG, đĩa phải đọc toàn bộ các cột không liên quan. Với server cơ bản (2 vCPU, 4GB RAM), câu lệnh GROUP BY trên 5 triệu dòng có thể mất từ 15 đến 30 giây.

3. Nhúng DuckDB cục bộ

  • Ưu điểm:
    • Lưu trữ dạng cột (Columnar): Chỉ đọc đúng các cột được chọn trong mệnh đề SELECT. Lượng I/O đĩa giảm tới 80%.
    • Vectorized Execution: Xử lý dữ liệu theo từng lô (chunk) trên thanh ghi CPU thay vì duyệt từng dòng đơn lẻ.
    • Zero Network Latency: Dữ liệu chuyển trực tiếp giữa DuckDB và Node.js qua bộ nhớ chung.
    • Đọc file trực tiếp: Hỗ trợ truy vấn SQL thẳng vào file Parquet, CSV, Arrow mà không cần tạo bảng trung gian.
  • Hạn chế: Không thiết kế cho tác vụ ghi đồng thời cao (concurrency writes). Nếu hàng nghìn request cùng ghi vào một file .duckdb, ứng dụng sẽ gặp lock tranh chấp.

Khi nào nên chọn công cụ nào?

Hãy chọn công cụ dựa vào bản chất tải công việc:

  • JavaScript thuần: Phù hợp khi file nhỏ dưới 10MB, logic xử lý chuỗi phức tạp và tần suất chạy không thường xuyên.
  • PostgreSQL / MySQL: Lựa chọn chuẩn cho nghiệp vụ OLTP: lưu giỏ hàng, đặt đơn, xác thực người dùng và cập nhật số dư.
  • DuckDB: Tối ưu cho tác vụ OLAP cục bộ: tạo báo cáo theo kỳ, tổng hợp log hệ thống, dashboard phân tích nội bộ hoặc xuất dữ liệu định kỳ.

Trong thực tế, một hệ thống thương mại điện tử có thể lưu đơn hàng vào PostgreSQL. Hàng đêm, script worker trích xuất dữ liệu ra file snapshot Parquet. Khi admin mở dashboard xem biểu đồ xu hướng 12 tháng, API Node.js gọi DuckDB đọc trực tiếp file Parquet 2GB này. Thời gian phản hồi giảm từ 42 giây trên database chính xuống còn 350ms, đồng thời tránh ảnh hưởng đến luồng thanh toán của khách.

Thực hành: Tích hợp DuckDB vào ứng dụng Node.js

Các bước dưới đây hướng dẫn bạn cài đặt và truy vấn dữ liệu từ file thực tế.

Bước 1: Cài đặt thư viện

Tạo thư mục dự án và cài đặt package duckdb:

mkdir node-duckdb-demo
cd node-duckdb-demo
npm init -y
npm install duckdb express

Bước 2: Khởi tạo kết nối DuckDB

DuckDB có thể chạy trên RAM (:memory:) hoặc lưu trữ vào file cục bộ. Tạo file analytics.js:

const duckdb = require('duckdb');

// Khởi tạo database in-memory
const db = new duckdb.Database(':memory:');

// Bọc db.all thành Promise để dùng async/await
const query = (sql) => {
  return new Promise((resolve, reject) => {
    db.all(sql, (err, rows) => {
      if (err) return reject(err);
      resolve(rows);
    });
  });
};

async function main() {
  const result = await query('SELECT 42 AS answer');
  console.log('Kết quả kết nối:', result);
}

main();

Bước 3: Truy vấn trực tiếp trên file CSV

Tạo file mẫu orders.csv mô phỏng lịch sử bán hàng:

order_id,customer_id,category,amount,created_at
1,101,Electronics,250.50,2024-01-01
2,102,Books,15.20,2024-01-01
3,101,Electronics,120.00,2024-01-02
4,103,Clothing,89.90,2024-01-02

Bạn có thể viết SQL trực tiếp trên đường dẫn file mà không cần chạy lệnh import:

async function analyzeSales() {
  const sql = `
    SELECT 
      category,
      COUNT(*) AS total_orders,
      ROUND(SUM(amount), 2) AS total_revenue,
      ROUND(AVG(amount), 2) AS average_order_value
    FROM 'orders.csv'
    GROUP BY category
    ORDER BY total_revenue DESC;
  `;

  console.time('Query CSV');
  const summary = await query(sql);
  console.timeEnd('Query CSV');

  console.table(summary);
}

analyzeSales();

Bước 4: Nén và đọc với định dạng Parquet

File CSV tốn dung lượng và phải parse chuỗi mỗi lần đọc. Với tập dữ liệu trên 1 triệu dòng, bạn nên đổi sang định dạng Parquet để đọc theo cột và nén tốt hơn:

async function convertAndAnalyze() {
  // 1. Chuyển CSV sang Parquet nén Snappy
  await query(`
    COPY (SELECT * FROM 'orders.csv') 
    TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
  `);
  console.log('Đã tạo orders.parquet thành công.');

  // 2. Query trực tiếp trên Parquet
  const parquetSql = `
    SELECT 
      category,
      ROUND(SUM(amount), 2) AS revenue 
    FROM 'orders.parquet' 
    WHERE amount > 50
    GROUP BY category;
  `;
  const result = await query(parquetSql);
  console.table(result);
}

convertAndAnalyze();

Bước 5: Tạo API báo cáo với Express

Ghép nối câu truy vấn DuckDB vào một route API để trả dữ liệu về frontend:

const express = require('express');
const app = express();

app.get('/api/reports/monthly', async (req, res) => {
  try {
    const data = await query(`
      SELECT 
        strftime('%Y-%m', CAST(created_at AS DATE)) AS month,
        ROUND(SUM(amount), 2) AS monthly_revenue
      FROM 'orders.parquet'
      GROUP BY month
      ORDER BY month ASC
    `);
    res.json({ success: true, count: data.length, data });
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

app.listen(3000, () => {
  console.log('API analytics sẵn sàng tại port 3000');
});

DuckDB giải quyết tốt bài toán phân tích dữ liệu cục bộ trong Node.js. Bạn không cần cấu hình cụm OLAP cồng kềnh, giảm thiểu chi phí máy chủ mà vẫn đảm bảo thời gian phản hồi cho các báo cáo nội bộ.

Share: