Chuyển một nghìn viên gạch từ sân vào nhà. Cách một: mỗi lần bê đúng một viên, đi vào, đặt xuống, quay ra, ký nhận vào sổ, rồi mới bê viên tiếp — một nghìn lượt đi về, một nghìn lần ký sổ. Cách hai: xúc cả xe cút kít đầy gạch đẩy một chuyến. Cùng một nghìn viên gạch, cùng quãng đường, nhưng thời gian chênh nhau cả trăm lần — và cái chênh đó gần như hoàn toàn không nằm ở việc khiêng gạch, mà ở số lượt đi về và số lần ký sổ. Nạp dữ liệu vào cơ sở dữ liệu vấp đúng chuyện này, và bài này là lần tôi suýt đổ lỗi cho "gạch nặng".

Bạn cần nạp một trăm nghìn dòng vào một bảng — nhập từ file, chuyển dữ liệu, khởi tạo. Cách hiển nhiên là chạy một trăm nghìn câu INSERT. Nếu làm vậy rồi ngồi đợi, bạn dễ kết luận "ghi vào cơ sở dữ liệu vốn chậm". Nhưng con số đó gần như hoàn toàn không phản ánh chi phí ghi dữ liệu — nó phản ánh cách bạn nạp. Bài này đo bốn cách nạp cùng một trăm nghìn dòng, và khoảng cách giữa cách chậm nhất và nhanh nhất là hơn một trăm lần.

Bốn cách nạp cùng một dữ liệu

Tất cả bốn cách dưới đây nạp y hệt 100.000 dòng vào cùng một bảng, cho ra cùng kết quả với cùng độ bền. Chúng chỉ khác nhau ở cách đưa dữ liệu vào:

  • (a) INSERT từng dòng, mỗi dòng một giao dịch: chạy 100.000 câu INSERT riêng lẻ, mỗi câu tự động commit (autocommit) — kiểu mặc định nếu bạn không bọc gì.
  • (b) INSERT từng dòng, gom một giao dịch: vẫn 100.000 câu, nhưng bọc trong một cặp BEGIN ... COMMIT.
  • (c) multi-row INSERT: gom nhiều dòng vào một câu — INSERT INTO t VALUES (...),(...),... với 1000 dòng mỗi câu.
  • (d) COPY: nạp cả khối dữ liệu thành một luồng bằng lệnh COPY.

Đo: từ 8.5 giây xuống 62 mili giây

Đây là thời gian đo được, cùng một bảng, cùng 100.000 dòng:

(a) INSERT lẻ, mỗi dòng 1 giao dịch:  8487 ms
(b) INSERT lẻ, gom 1 giao dịch:       3065 ms
(c) multi-row INSERT (1000 dòng/câu):  152 ms
(d) COPY:                               62 ms

COPY nhanh hơn INSERT lẻ khoảng 137 lần cho đúng cùng một công việc. Đó không phải một chút cải thiện — nó là khác biệt giữa 8 giây rưỡi ngồi chờ và một cái chớp mắt gần như không kịp nhận ra. Và điều quan trọng: dữ liệu nạp vào giống hệt nhau, độ bền như nhau (COPY cũng ghi WAL, cũng bền sau khi xong). Vậy 137 lần đó đến từ đâu?

Chậm không nằm ở việc ghi

Từng bước một trong bảng trên gỡ đi một loại chi phí, và nhìn vào đó thấy rõ tiền đi đâu.

Từ (a) sang (b): bỏ commit mỗi dòng — nhanh 2.8 lần. Mỗi lần COMMIT buộc CSDL ghi WAL và (mặc định) fsync xuống đĩa để đảm bảo bền. Với autocommit, mỗi dòng là một commit riêng — 100.000 lần ghi-và-đồng-bộ (đúng một nghìn lần ký sổ). Gom cả 100.000 dòng vào một giao dịch nghĩa là chỉ commit một lần. Phần tiết kiệm 5422ms đó chính là chi phí commit lặp lại — không liên quan gì tới việc ghi dòng dữ liệu, như bài WAL và độ bền đã đo về giá của mỗi lần đồng bộ đĩa.

Từ (b) sang (c): bỏ parse và round-trip mỗi câu — nhanh thêm 20 lần. Ngay cả trong một giao dịch, 100.000 câu INSERT riêng vẫn bắt CSDL phân tích cú pháp 100.000 lần, và client gửi-nhận 100.000 lượt qua kết nối. Gom 1000 dòng vào một câu biến 100.000 lần parse+round-trip thành 100 — phần lớn overhead per-statement biến mất. Cái round-trip đó là chi phí thật: như bài kết nối đã đo, mỗi lần gửi một câu và chờ phản hồi tốn một vòng khứ hồi trên kết nối — đúng một nghìn lượt đi về; nhân với 100.000 câu, nó thành một khối thời gian lớn ngay cả khi kết nối là socket nội bộ, và lớn hơn nhiều qua mạng thật.

Từ (c) sang (d): COPY nạp thành luồng — nhanh thêm 2.5 lần. COPY không phải là SQL với các giá trị cần phân tích; nó là một giao thức nạp khối chuyên dụng, đọc thẳng dòng dữ liệu vào bảng, bỏ qua cả bước dựng và phân tích câu INSERT — chính là cả xe cút kít một chuyến. Đó là lý do nó là cách nhanh nhất để đưa nhiều dữ liệu vào PostgreSQL. Một điểm cần biết: COPY nạp cả khối như một giao dịch, nên nếu một dòng sai kiểu, cả mẻ bị hủy — dữ liệu nguồn phải sạch, hoặc bạn nạp vào một bảng tạm rồi lọc. Đổi lại sự khắt khe đó là tốc độ.

Suýt đổ lỗi cho "ghi chậm"

Khi lần đầu đo cách (a) và thấy 8.5 giây cho 100.000 dòng, phản xạ của tôi là kết luận "ghi vào CSDL tốn cỡ này, chắc cái bảng hay cái đĩa chậm". Đó là một suy diễn sai về nguyên nhân. Cùng 100.000 dòng đó, cùng cái bảng, cùng cái đĩa, COPY làm xong trong 62ms. Nếu việc ghi thật sự tốn 8.5 giây thì COPY không thể nhanh hơn 137 lần được — nó cũng phải ghi đúng ngần ấy byte. Con số bất khả này là dấu hiệu tôi đo nhầm đại lượng: 8.5 giây không đo tốc độ ghi, nó đo chi phí cách nạp — 100.000 lần commit+fsync cộng 100.000 lần parse+round-trip. Việc ghi dữ liệu thật chỉ là một phần nhỏ trong đó.

Một lưu ý trung thực về môi trường: đĩa của container này là đĩa máy ảo, nơi fsync rẻ. Trên một đĩa vật lý thật, mỗi COMMIT phải chờ một lần đồng bộ đĩa thực (thường vài mili giây), nên cách (a) sẽ còn thảm hơn nhiều — có thể lên tới hàng phút cho 100.000 dòng. Nghĩa là khoảng cách 2.8 lần giữa (a) và (b) mà tôi đo ở đây là cận dưới; trên phần cứng thật, gom giao dịch còn quan trọng hơn.

Về đến code hằng ngày

Điều đầu tiên: để nạp khối, dùng COPY. Khi nhập một file lớn, chuyển dữ liệu, hay seed một bảng, COPY (hay lệnh tương đương của thư viện bạn dùng — copy_from của psycopg, CopyManager của JDBC) nhanh hơn INSERT hàng chục tới hàng trăm lần. Đừng vòng lặp INSERT từng dòng cho việc nạp lớn. Nhiều thư viện có API COPY sẵn; nếu ORM của bạn chỉ sinh INSERT lẻ, hãy tìm cách xuống thẳng tầng driver cho các job nạp dữ liệu nặng.

Điều thứ hai: nếu buộc phải INSERT, ít nhất hãy gom giao dịch và gom dòng. Không phải lúc nào cũng dùng được COPY (dữ liệu tới lẻ tẻ, cần logic mỗi dòng), nhưng bọc nhiều INSERT trong một giao dịch và gom nhiều dòng vào một câu đã lấy lại phần lớn tốc độ. Gói lại bằng số: cùng 100.000 dòng, INSERT lẻ mỗi dòng một giao dịch mất 8487ms còn COPY chỉ 62ms — nhanh 137 lần; khoảng cách không đến từ tốc độ ghi mà từ commit+fsync mỗi dòng (gom giao dịch: 2.8 lần) và parse+round-trip mỗi câu (gom dòng: thêm 20 lần), COPY gỡ nốt cả phần parse SQL. Khi một thao tác nạp chậm lê thê, đừng đổ cho "CSDL chậm" — hỏi bạn đang trả phí gì cho mỗi dòng.

Và cái mẫu hình "chi phí thật nằm ở overhead mỗi thao tác, không ở bản thân công việc" này bạn gặp lại ở khắp nơi khi lập trình. JDBC có addBatch()/executeBatch() đúng để gộp nhiều INSERT thành một round-trip, và bật rewriteBatchedStatements=true để driver MySQL tự gộp thành multi-row — chính là chuyển từ cách (a) sang (c). Một vòng lặp gọi REST API cho từng phần tử (chatty API) chậm thảm hại không phải vì server chậm mà vì mỗi lần là một round-trip mạng, và cách chữa luôn là một endpoint batch. Ngay cả đọc file: một triệu lần read() một byte chậm hơn nghìn lần một cú đọc cả buffer, dù tổng số byte y hệt. Mẫu số chung đáng mang theo: khi một việc "đơn giản" chạy chậm khó tin, hãy đếm xem bạn đang trả bao nhiêu lần cái phí cố định mỗi thao tác — commit, round-trip, parse, syscall — vì gần như luôn, thủ phạm là số lần bạn trả phí đó, không phải khối lượng việc thật.

Muốn tự thấy thì trong psql, bật \timing, tạo CREATE TABLE t (n int);. Chạy một vòng INSERT lẻ trong một khối vô danh: DO $$ BEGIN FOR i IN 1..50000 LOOP INSERT INTO t VALUES (i); END LOOP; END $$; và xem thời gian (đây đã là một giao dịch, nên đại diện cho cách (b)). Giờ TRUNCATE t; rồi thử COPY từ một lệnh sinh dữ liệu: COPY t FROM PROGRAM 'seq 1 50000'; và so thời gian — COPY sẽ nhanh hơn hẳn. Nếu muốn thấy cách (a) chậm cỡ nào, chạy 50.000 câu INSERT riêng từ dòng lệnh với autocommit; bạn sẽ đợi lâu hơn nhiều. Ba phép đo đó, trong nửa phút, cho bạn thấy phần lớn "thời gian ghi" thật ra là thời gian commit và parse.