Daniel Nguyen
← Tất cả dự án

Import hóa đơn hàng loạt, viết lại quanh COPY

Tôi thay luồng import ORM từng dòng bằng một lớp COPY PostgreSQL dùng lại được, rút một lần import hóa đơn lớn từ khoảng 3 giờ xuống khoảng 15 phút.

TTMI
3 h → 15 min~7.000 hóa đơn, mỗi hóa đơn 50–100 dòng
Mảng
ERP bán lẻ
Thời gian
2025–2026
Vai trò
Backend developer, lớp bulk-load
  • Python
  • Django
  • PostgreSQL
  • pytest

Các phần ghép với nhau thế nào

Import hóa đơn hàng loạt, viết lại quanh COPYAn import batch resolves foreign keys once in memory, then COPYs rows through a staging table (large batches) or straight into the invoice tables (small ones).Import filengười dùngImport workerdịch vụFK cache (in memory)kho dữ liệuLarge batch?kiểm tra, cảnh báoTemp staging tablekho dữ liệuInvoice tableskho dữ liệusubmit batchprefetch keys onceids mapped in memorychunked rowsyes: COPY to staginginsert, skip existingno: COPY directly
An import batch resolves foreign keys once in memory, then COPYs rows through a staging table (large batches) or straight into the invoice tables (small ones).
  • Người dùng
  • Dịch vụ
  • Kho dữ liệu
  • Kiểm tra, cảnh báo

Cùng một lần import, hai cách

Trước: ORM, từng dòng một

0:00:00

Với mỗi dòng hóa đơn: tra sản phẩm, khách hàng, kho, insert một dòng, lặp lại.

Sau: COPY + bảng staging

0:00:00

Đổ toàn bộ dòng vào bảng staging, tra khóa bằng join theo tập và cache, insert chi tiết theo lô.

Mỗi ô là ~100 hóa đơn. Thời gian là số trước và sau trên production với ~7.000 hóa đơn, mỗi hóa đơn 50–100 dòng. Hoạt ảnh nén 3 giờ thành 12 giây.

Vấn đề

TTMI vận hành một ERP bán lẻ nội bộ cho 50 cửa hàng, 500 nhân viên và 4 thương hiệu. Chứng từ bán hàng, mua hàng và kế toán thường đổ về theo lô lớn. Một trong những lô nặng nhất là import hóa đơn: khoảng 7.000 header, mỗi header có 50 đến 100 dòng chi tiết.

Luồng import cũ đi qua Django ORM từng dòng một. Mỗi header, mỗi dòng chi tiết là một lệnh insert riêng, và mỗi foreign key trên dòng (sản phẩm, cửa hàng, tài khoản…) lại được tra riêng. Đó là N+1 kinh điển, nhân lên hàng trăm nghìn dòng. Phần lớn thời gian nằm ở round trip mạng và phần xử lý Python xen giữa. Một lần chạy đầy đủ mất khoảng ba giờ.

Còn một yêu cầu nữa khiến việc sửa nhanh trở nên rủi ro. Import đôi khi lỗi giữa chừng, và người dùng phải chạy lại được cùng file đó mà không sinh hóa đơn trùng.

Tôi đã làm gì

Tôi viết một lớp bulk-load dùng chung, chuyển luồng import hóa đơn sang đó, rồi tích hợp vào thêm vài loại chứng từ khác.

Dùng COPY của PostgreSQL cho dữ liệu lớn

COPY đẩy rất nhiều dòng vào database trong một thao tác, nên chi phí mỗi dòng gần như bằng không. Tôi dùng nó cho cả header lẫn dòng chi tiết.

Cái giá là COPY bỏ qua ORM hoàn toàn: signal, hook khi save và các giá trị mặc định tính bằng Python đều không chạy. Tôi rà từng side effect mà luồng cũ dựa vào và viết chúng ra tường minh trong code import, để không có gì xảy ra ngầm, và cũng không có gì âm thầm biến mất.

Lô lớn đi qua bảng tạm

Với lô lớn, dữ liệu được COPY vào một bảng tạm chỉ sống trong transaction. Sau đó một lệnh insert-select chuyển sang bảng thật và bỏ qua những dòng đã tồn tại. Nhờ vậy chạy lại là an toàn: dòng nào đã vào ở lần trước thì bị bỏ qua, chỉ dòng còn thiếu được thêm.

Lô nhỏ thì đi thẳng vào bảng đích. Bảng tạm tốn thêm một lần chép dữ liệu, đáng giá với hàng nghìn dòng nhưng là chi phí thừa với vài dòng. Một ngưỡng kích thước quyết định đi đường nào.

Resolve foreign key một lần cho cả lô

Trước khi insert, lớp này gom mọi thực thể được tham chiếu trong lô, lấy chúng bằng một query cho mỗi loại thực thể, rồi dựng một bảng map trong bộ nhớ. Mỗi dòng tra key từ map đó, không gọi database.

Cách này tốn bộ nhớ hơn cho mỗi lô. Với kích thước lô thực tế, chi phí đó nhỏ so với việc bỏ được hàng trăm nghìn lượt tra cứu.

Ghi dòng chi tiết theo chunk

Dòng chi tiết được ghi theo chunk gộp từ nhiều header thay vì từng header một. Mỗi lần ghi đủ lớn để hiệu quả, còn bộ nhớ vẫn trong giới hạn khi file rất lớn.

Một lớp cho nhiều loại chứng từ

Tôi làm nó thành một lớp dùng chung với interface tổng quát, thay vì một bản vá riêng cho hóa đơn. Các luồng import chứng từ khác giờ dùng chung đường này. Cái giá là phải bảo trì một API trừu tượng hơn; đổi lại chỉ có một con đường vào database, đã được test kỹ, thay cho nhiều bản na ná nhau.

Kết quả

Trên production, lần import khoảng 7.000 header hóa đơn với 50 đến 100 dòng mỗi header giảm từ khoảng 3 giờ xuống khoảng 15 phút. Chạy lại một lần import lỗi giữa chừng không còn tạo bản trùng, vì bước insert từ bảng tạm bỏ qua dòng đã có. Các loại chứng từ khác dùng lại cùng lớp này, nên import hàng loạt mới bắt đầu từ một con đường đã có test.

Widget ở trang chủ là mô hình minh họa số round trip của ORM từng dòng, ORM theo lô và COPY kèm bảng tạm. Nó cho thấy hình dạng của sự khác biệt, còn các con số trong đó không phải số đo thật.

Nếu làm lại

Tôi sẽ ghi lại cách đo ngay lúc đó: bộ dữ liệu nào, môi trường nào, chạy mấy lần. Con số trước và sau là thật, nhưng tôi mô tả nó tự tin hơn khả năng tái hiện nó. Tôi cũng sẽ thêm một benchmark nhỏ vào bộ test, để thay đổi nào kéo lại kiểu xử lý từng dòng sẽ lộ ra ngay lúc review, trước khi tới production.