0

Khai Phá Sức Mạnh Elasticsearch Bằng Node.js: Kiến Trúc Kết Nối Chuẩn Enterprise

Ở bài trước, chúng ta đã hiểu về lý thuyết Inverted Index cực kỳ bá đạo của Elasticsearch. Nhưng khi mang lý thuyết đó vào thực hành, việc kết nối con server Node.js với cụm (Cluster) Elasticsearch lại là nơi chứa đựng nhiều "cú lừa" nhất.

Nhiều anh em chỉ biết dùng lệnh client.index() để nhét từng dòng dữ liệu vào ES, hoặc mở hàng ngàn kết nối cùng lúc khiến server sập nguồn. Một Vibe Coder phải biết cách thiết lập kết nối sao cho nó chịu được traffic của một sàn thương mại điện tử! Chuẩn bị sẵn sàng nhé, chúng ta sẽ xây dựng một file cấu hình kết nối chuẩn Enterprise ngay bây giờ!

PHẦN 1: TƯ DUY "THỢ GÕ" VÀ CÁI CHẾT CỦA CÁC KẾT NỐI

Nếu bạn lên mạng gõ "How to connect Node.js to Elasticsearch", 90% các bài viết sẽ chỉ bạn cài thư viện @elastic/elasticsearch và viết ngay đoạn code này ở đầu mọi file Router:

// TƯ DUY THỢ GÕ - RẤT TỆ!
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://localhost:9200' });

app.get('/search', async (req, res) => {
    const result = await client.search({ index: 'products', q: req.query.keyword });
    res.json(result);
});

Đoạn code trên chạy được không? Có. Nhưng khi mang lên Production, nó mang theo 3 tử huyệt:

  1. Khởi tạo bừa bãi: Không có Singleton. Nếu bạn require file này ở 10 nơi, Node.js sẽ tạo ra 10 cái Client.
  2. Không có cơ chế "Lì đòn": Nếu mạng chập chờn 1 giây, request sẽ văng lỗi Connection Refused ngay lập tức, user nhận lỗi 500 trắng màn hình.
  3. Mù thông tin Cụm (Cluster): Elasticsearch hiếm khi chạy 1 Node. Nó chạy 3, 5, hoặc 10 Nodes. Client ngây ngô kia chỉ biết đâm đầu vào đúng 1 Node (localhost:9200). Nếu Node đó chết, toàn bộ App sập, dù các Node khác vẫn đang sống!

PHẦN 2: BÀN TAY CỦA VIBE CODER - THE SINGLETON CLIENT

Chúng ta sẽ đập đi xây lại. Tạo một file riêng biệt mang tên elastic.service.js. Đây sẽ là "Bộ não" quản lý mọi luồng giao tiếp với Elasticsearch.

npm install @elastic/elasticsearch

Và đây là code thực chiến chuẩn Enterprise (Dành cho ES Version 8.x trở lên):

// elastic.service.js
const { Client } = require('@elastic/elasticsearch');

class ElasticService {
    constructor() {
        if (!ElasticService.instance) {
            console.log('🚀 Khởi tạo Kết nối Elasticsearch...');
            
            this.client = new Client({
                // 1. Cung cấp mảng các Nodes để Load Balancing ở phía Client
                nodes: [
                    'http://es-node-1.vibecoder.com:9200',
                    'http://es-node-2.vibecoder.com:9200'
                ],
                
                // 2. Xác thực (Dùng API Key hoặc Username/Password)
                auth: {
                    apiKey: process.env.ELASTIC_API_KEY
                    // Hoặc: username: 'elastic', password: 'changeme'
                },

                // 3. Cơ chế Lì đòn (Resilience)
                maxRetries: 3,             // Nếu gọi lỗi, tự động thử lại 3 lần trước khi bỏ cuộc
                requestTimeout: 10000,     // Giới hạn 10s cho mỗi request
                
                // 4. Tuyệt kỹ Sniffing (Đánh hơi)
                // Client sẽ tự động hỏi ES: "Ê, cụm mày có bao nhiêu Node?". 
                // Sau đó nó tự động cập nhật danh sách Node để gửi Request cho đều.
                sniffOnStart: true,
                sniffInterval: 300000,     // 5 phút cập nhật lại danh sách Node 1 lần
            });

            ElasticService.instance = this;
        }
        return ElasticService.instance;
    }

    getClient() {
        return this.client;
    }
}

// Xuất ra một instance duy nhất (Singleton)
const instance = new ElasticService();
module.exports = instance.getClient();

Phân tích ma thuật: Với cấu hình sniffOnStartnodes dạng mảng, con Node.js của bạn giờ đây thông minh như một Load Balancer thu nhỏ. Nó tự biết né các Node đang chết và phân bổ lực request đều đặn.

PHẦN 3: GHI DỮ LIỆU TỐC ĐỘ BÀN THỜ VỚI BULK API

Elasticsearch tối ưu cho việc tìm kiếm, việc Ghi (Index) dữ liệu vào nó rất tốn CPU (do phải chạy qua máy băm Analyzer để làm Inverted Index).

Nếu bạn có 10.000 sản phẩm mới và gọi hàm client.index() 10.000 lần trong 1 vòng lặp for, Node.js của bạn sẽ tự DDoS chính nó và ES sẽ từ chối kết nối (429 Too Many Requests).

Tuyệt kỹ: Phải dùng Bulk API (Đóng gói hàng loạt).

// product.controller.js
const esClient = require('./elastic.service');

async function syncProductsToES(productsArray) {
    console.log(`Đang đồng bộ ${productsArray.length} sản phẩm lên ES...`);

    // Dùng helper bulk của thư viện @elastic/elasticsearch v8
    const result = await esClient.helpers.bulk({
        datasource: productsArray,
        onDocument: (doc) => ({
            index: { _index: 'products_v1', _id: doc.id } // Khai báo index và ID
        }),
        // Tùy chỉnh sức mạnh
        flushBytes: 5000000, // Gửi đi khi gói hàng đạt 5MB
        flushInterval: 2000, // Hoặc gửi đi sau mỗi 2 giây
        concurrency: 3,      // Cho phép 3 worker chạy bulk cùng lúc
        refreshOnCompletion: true // Đồng bộ xong thì làm mới Index để search được ngay
    });

    console.log(`Đồng bộ hoàn tất! Thành công: ${result.successful}, Thất bại: ${result.aborted}`);
}

Nhờ helpers.bulk, dù bạn có ném cho nó cái mảng 1 triệu phần tử, nó cũng sẽ tự động băm nhỏ ra thành từng gói (chunks) 5MB để gửi đi êm ái mà không làm nghẽn RAM.

PHẦN 4: TRUY VẤN NHƯ MỘT THỢ SĂN (COMPLEX QUERY)

Bây giờ dữ liệu đã có. Đừng dùng hàm search match_all nhàm chán. Hãy viết một câu truy vấn đúng chuẩn E-commerce:

  • Tìm sản phẩm tên có chứa "Iphone" (Chấp nhận gõ sai chính tả).
  • CHỈ LẤY những sản phẩm thuộc danh mục "Điện thoại".
  • CHỈ LẤY những sản phẩm giá dưới 20 triệu.

Vibe Coder sẽ dùng Boolean Query:

async function searchProducts(keyword) {
    const response = await esClient.search({
        index: 'products_v1',
        query: {
            bool: {
                // MUST: Bắt buộc phải có từ khóa, và dùng Fuzzy để chống sai chính tả
                must: [
                    {
                        match: {
                            name: {
                                query: keyword,
                                fuzziness: "AUTO" // Gõ "Iphon" vẫn ra "Iphone"
                            }
                        }
                    }
                ],
                // FILTER: Lọc cứng. KHÔNG tính điểm (Score), nên ES sẽ Cache lại cho siêu nhanh!
                filter: [
                    { term: { category: "dien_thoai" } },
                    { range: { price: { lte: 20000000 } } }
                ]
            }
        },
        // Phân trang
        from: 0,
        size: 10,
        // Chỉ lấy 3 cột này về, đừng lấy hết cho nhẹ băng thông
        _source: ["id", "name", "price"] 
    });

    // Lấy mảng dữ liệu thật sự từ cục JSON khổng lồ của ES
    const hits = response.hits.hits.map(item => item._source);
    return hits;
}

Bí kíp Tối ưu (Filter vs Must):

  • must: ES sẽ phải tính toán "Điểm số liên quan" (Score) xem thằng nào giống từ khóa nhất để xếp lên đầu. Quá trình này tốn CPU.
  • filter: ES chỉ trả lời CÓ hoặc KHÔNG (Giá có dưới 20tr không? Thuộc danh mục điện thoại không?). Nó bỏ qua việc tính Score và TỰ ĐỘNG CACHE kết quả trên RAM. Luôn nhét những điều kiện tuyệt đối vào filter để query bay nhanh như gió!

LỜI KẾT & HẠ CÁNH MỀM

Bạn đã có một cỗ máy kết nối hoàn hảo. Nhưng hãy nhớ bài học về Server Node.js đầu tiên chứ? Khi Node.js tắt, đừng để các kết nối đến Elasticsearch bị treo lơ lửng.

Trong file server.js, chỗ bạn cấu hình Graceful Shutdown, hãy thêm một dòng này vào trước khi process.exit():

const esClient = require('./elastic.service');
// Đóng cửa an toàn
await esClient.close();
console.log('✅ Đã ngắt kết nối Elasticsearch an toàn');

Mang tư duy kiến trúc này vào dự án, hệ thống Search của bạn sẽ vững như bàn thạch, sẵn sàng gánh những đợt Flash Sale khốc liệt nhất.

** Chủ đề tiếp theo: Đồng Bộ Dữ Liệu - "Cơn Ác Mộng" Giữa MySQL và Elasticsearch**

Bạn đã biết cách Ghi (Bulk) và Đọc (Search) với ES. Nhưng có một sự thật: ES không phải là Database chính (Primary DB). Database chính của bạn là MySQL/PostgreSQL.

Khi User vào App sửa tên sản phẩm trong MySQL, làm sao để con Elasticsearch cũng biết mà đổi tên theo ngay lập tức (Real-time)? Nếu không đồng bộ, khách tìm "Iphone 15" trên ES, bấm vào giỏ hàng MySQL lại ra "Iphone 14"?

Có 3 cảnh giới để đồng bộ:

  1. Ghi đúp (Dual Write) bằng code Backend.
  2. Quét định kỳ (Cronjob + Logstash).
  3. Đẳng cấp Vibe Coder: Bắt mạch nhịp đập Database bằng Debezium & Kafka (Change Data Capture - CDC).

Ở bài viết tới, chúng ta sẽ đàm đạo về cách thiết lập luồng máu đồng bộ dữ liệu này. Anh em nhớ đón đọc nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí