+4

Bulk Upsert: Tuyệt Kỹ "San Bằng" Hàng Vạn Dòng Dữ Liệu Trong 1 Tích Tắc

Ở bài UPSERT trước, chúng ta đã giải quyết được vấn đề "Cạnh tranh dữ liệu" (Race Condition) khi 2 request cùng đập vào 1 dòng. Câu lệnh INSERT ... ON DUPLICATE KEY UPDATE đã thể hiện sức mạnh Nguyên tử (Atomic) tuyệt đối của nó.

Nhưng đời không chỉ có những Request đơn lẻ. Giả sử bạn làm hệ thống đồng bộ dữ liệu: Nửa đêm, bạn cần kéo 100.000 sản phẩm từ Shopee về Database của công ty. Yêu cầu vẫn là: "Sản phẩm nào chưa có thì thêm mới, có rồi thì cập nhật giá".

Nếu bạn đem câu lệnh UPSERT của bài trước bỏ vào một vòng lặp for chạy 100.000 lần... Server của bạn sẽ gào thét, thời gian đồng bộ mất hàng tiếng đồng hồ!

Làm sao để giải quyết? Chào mừng bạn đến với kỹ thuật "San bằng tất cả" của Vibe Coder: Bulk Upsert (Upsert Hàng Loạt).

PHẦN 1: KHI VÒNG LẶP FOR TRỞ THÀNH TỘI ÁC

Nhiều anh em biết UPSERT, nhưng lại lười suy nghĩ khi gặp mảng dữ liệu lớn. Họ viết code Node.js/PHP như thế này:

// TƯ DUY THỢ GÕ: UPSERT trong vòng lặp
const products = [...]; // Mảng 100,000 sản phẩm

for (let item of products) {
    // Gọi lệnh UPSERT 100,000 lần!
    await db.query(`
        INSERT INTO products (sku, name, price) 
        VALUES (?, ?, ?)
        ON DUPLICATE KEY UPDATE name = VALUES(name), price = VALUES(price)
    `, [item.sku, item.name, item.price]);
}

Tại sao cách này là một tội ác với hệ thống?

Bởi vì "Khoảng cách địa lý". Con Server Node.js của bạn và con Server MySQL là 2 cỗ máy khác nhau

Mỗi lần chạy lệnh await db.query(), dữ liệu phải đi qua dây cáp mạng (Network Roundtrip).

  • 1 vòng đi-về mất 2ms (mili-giây).
  • 100.000 vòng đi-về mất 200.000ms = 200 giây (hơn 3 phút)! Trong 3 phút đó, Connection Pool của Database bị chiếm dụng liên tục, các API khác của user sẽ bị nghẽn mạng trầm trọng.

PHẦN 2: SỨC MẠNH CỦA BULK UPSERT - GOM BI ĐÁNH MỘT LẦN

Thay vì gửi 100.000 tờ giấy báo cáo, mỗi tờ 1 dòng. Tại sao ta không viết 10.000 dòng lên 1 cuộn giấy khổng lồ, rồi nhét vào 1 cái thùng gửi đi đúng 1 LẦN duy nhất?

Cú pháp SQL thực chất cho phép bạn truyền HÀNG LOẠT các giá trị (Values) vào sau chữ INSERT.

Hãy nhìn câu lệnh Bulk Upsert của MySQL:

INSERT INTO products (sku, name, price) 
VALUES 
    ('SKU001', 'Iphone 15', 20000),    -- Dòng 1
    ('SKU002', 'Macbook Pro', 50000),  -- Dòng 2
    ('SKU003', 'Airpods', 4000)        -- Dòng 3
    -- ... Có thể nhét thêm hàng ngàn dòng ở đây
ON DUPLICATE KEY UPDATE 
    name = VALUES(name), 
    price = VALUES(price);

Chỉ với đúng 1 câu Query, MySQL Engine sẽ nhận nguyên cuộn giấy, lật tung Index lên, kiểm tra cả ngàn dòng đó cùng một lúc trong bộ nhớ C/C++ siêu tốc của nó.

  • Dòng SKU001 có rồi -> Đổi thành Lệnh Update.
  • Dòng SKU002 chưa có -> Cho Insert. Thời gian thực thi cho 10.000 dòng có thể giảm từ hàng chục giây xuống chỉ còn ~0.1 giây!

PHẦN 3: NGHỆ THUẬT CHUNKING (BĂM NHỎ) TRONG NODE.JS

Biết Bulk Upsert là tốt, nhưng nếu bạn "tham lam" gom cả 100.000 dòng vào chung 1 câu lệnh SQL thì sao?

Câu SQL của bạn sẽ dài cỡ... 50 Megabyte!

Lúc này, bạn sẽ dính một cái tát chí mạng từ cấu hình của Database:

Lỗi Packet too large. MySQL mặc định (biến max_allowed_packet) thường chỉ cho phép một câu lệnh SQL dài tối đa 16MB hoặc 64MB. Cố nhét câu lệnh quá to, DB sẽ từ chối nhận.

Tư duy Vibe Coder: Phải biết Chunking (Băm nhỏ theo lô).

Chúng ta không gửi 1 lần 100.000, cũng không gửi 100.000 lần 1. Chúng ta sẽ gom thành các lô, mỗi lô 1.000 dòng. Nghĩa là chỉ tốn 100 lần kết nối mạng!

// Dùng thư viện lodash để băm mảng dễ dàng
const _ = require('lodash'); 

async function syncProductsVibeCoder(allProducts) {
    // Băm mảng 100.000 phần tử thành các mảng con, mỗi mảng 1.000 phần tử
    const chunks = _.chunk(allProducts, 1000); 

    for (let chunk of chunks) {
        // Tự động sinh ra chuỗi ( ?,?,? ), ( ?,?,? ) tương ứng với số lượng trong chunk
        const placeholders = chunk.map(() => '(?, ?, ?)').join(',');
        
        // Trải phẳng mảng Object thành mảng giá trị [sku1, name1, price1, sku2, name2...]
        const flatValues = chunk.flatMap(p => [p.sku, p.name, p.price]);

        const sql = `
            INSERT INTO products (sku, name, price) 
            VALUES ${placeholders}
            ON DUPLICATE KEY UPDATE 
                name = VALUES(name), 
                price = VALUES(price)
        `;

        // Bắn 1 lô 1.000 dòng xuống DB
        await db.query(sql, flatValues);
    }
}

(Lưu ý: Nếu bạn dùng các ORM như Prisma, TypeORM, Knex.js, chúng đều có sẵn các hàm hỗ trợ upsert hoặc onConflict giúp bạn tạo ra câu Query khổng lồ này mà không cần nối chuỗi tay như ví dụ trên).

PHẦN 4: NHỮNG LƯU Ý SỐNG CÒN KHI BULK UPSERT

Dùng đồ xịn thì phải biết cách né tác dụng phụ:

  1. Deadlock (Khóa chết) ngầm: Khi Bulk Upsert hàng ngàn dòng, Database sẽ tạo ra rất nhiều "khóa" (Locks) trên các dòng đó. Nếu có một luồng khác cũng đang Bulk Upsert một mảng chứa những dòng y hệt nhưng theo thứ tự ngược lại, hai luồng sẽ vướng vào nhau và sinh ra Deadlock.

Bí kíp: Luôn Sắp xếp (Sort) mảng dữ liệu trong Node.js (ví dụ: products.sort((a,b) => a.sku.localeCompare(b.sku))) TRƯỚC KHI đem đi Bulk Upsert để đảm bảo DB luôn khóa dòng theo đúng một chiều tịnh tiến.

  1. Khóa bảng tạm thời: Một câu lệnh Bulk Upsert quá bự có thể khiến MySQL phải khóa toàn bộ các khoảng trống (Gap Lock) liên quan đến cái Index đó, khiến các truy vấn Insert nhỏ lẻ khác của user đang dùng app bị treo trong vài chục mili-giây. Đó là lý do tại sao Chunk = 1.000 thường là con số an toàn để DB "thở".

Lời kết

Bulk Upsert là ranh giới phân biệt giữa một đoạn script cùi bắp chạy cả đêm và một hệ thống Enterprise đồng bộ hàng triệu dữ liệu chỉ trong vài phút lúc rạng sáng. Kết hợp nó với Batch Processing (Xử lý theo lô) và Message Queues, bạn sẽ có thể làm chủ những dòng thác dữ liệu khổng lồ nhất!

Chủ đề tiếp theo: Khi Bảng Dữ Liệu Lên Tới 50 Triệu Dòng - Table Partitioning

Chúng ta đã có thể đẩy 1 triệu dòng vào Database cực nhanh. Nhưng chuyện gì xảy ra khi bảng Orders (Đơn hàng) của bạn hoạt động qua 3 năm, phình to lên 50 triệu dòng? Lúc này, dù bạn có đánh Index xịn đến mấy, mỗi lần query lấy danh sách đơn hàng của tháng này cũng khiến ổ cứng SSD chạy bốc khói, tốc độ giảm thê thảm. Bạn không thể xóa bớt vì đó là dữ liệu kế toán.

Làm sao để Database tự động "chặt" cái bảng 50 triệu dòng đó ra thành 36 cái bảng nhỏ xíu (mỗi tháng 1 bảng) giấu ở dưới nền ổ cứng, nhưng ở tầng SQL, lập trình viên vẫn query bằng đúng cái tên bảng Orders như bình thường?

Ở bài tới, chúng ta sẽ mở khóa thuật toán chia để trị hạng nặng của CSDL phân tán: Table Partitioning (Phân vùng dữ liệu). Cực kỳ đỉnh cao, đừng bỏ lỡ 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í