Code chạy ngon trên máy dev với 10 bản ghi, lên production 10.000 bản ghi thì đơ. Thủ phạm số một là bẫy N+1: một vòng lặp trông vô hại nhưng mỗi lần lặp lại bắn thêm một câu SQL. Với 10 bản ghi bạn không thấy gì; với 10.000 bản ghi là 10.000 câu SQL, và trang treo. Bài này đo số query thật để phơi bày bẫy đó, rồi chỉ cách né bằng read_group và mapped.

N+1 là gì

Tên "N+1" nghĩa là: 1 câu để lấy N bản ghi, rồi N câu nữa — mỗi bản ghi một câu — để lấy dữ liệu liên quan. Tổng N+1 câu SQL cho một việc lẽ ra chỉ cần 1-2 câu.

Odoo có cơ chế prefetch tự động — khi bạn duyệt một recordset và đọc một field, Odoo đọc field đó cho cả recordset trong một câu. Nhờ vậy nhiều vòng lặp không dính N+1. Nhưng prefetch không cứu được khi bạn gọi search() bên trong vòng lặp — mỗi search là một truy vấn mới, độc lập.

Ảnh chụp mã Python nền tối so hai cách tính tổng điểm cộng. Phần trên nhãn đỏ SAI search trong vòng lặp N thẻ bằng N câu SQL: tong bằng 0, for the in cards lặp N thẻ, lines bằng Hist search domain the_id bằng the id chú thích 1 query mỗi vòng, tong cộng sum lines mapped diem_cong. Phần dưới nhãn xanh ĐÚNG read_group gộp 1 câu SQL cho tất cả: groups bằng Hist _read_group domain the_id in cards ids chú thích 1 truy vấn duy nhất, groupby the_id, aggregates diem_cong sum chú thích DB tự cộng không lặp Python, tong bằng sum s for _the s in groups

Hình 1: Hai cách tính cùng một tổng. Cách sai: lặp qua từng thẻ, mỗi vòng gọi search() để lấy lịch sử của riêng thẻ đó — mỗi vòng một câu SQL. Cách đúng: một _read_group gộp tất cả, để PostgreSQL tự cộng (diem_cong:sum) thay vì kéo hết về Python rồi cộng bằng vòng lặp.

Đo thật: 50 câu SQL sụt còn 1

Không nói lý thuyết. Mình tạo 50 thẻ tạm (mỗi thẻ 3 dòng lịch sử điểm) trên blog19, bọc cr.execute để đếm số câu SQL thật, rồi chạy cả hai cách:

Ảnh chụp terminal nền tối odoo shell đếm SQL thật N cộng 1 so với read_group trên 50 thẻ tạm. Dòng chú thích tạo 50 thẻ tạm mỗi thẻ 3 dòng lịch sử bọc cr execute để đếm. Dòng Da tao 50 the tam. Dòng đỏ SAI N cộng 1 search mỗi thẻ 50 câu SQL tong bằng 1750 4.8 ms. Dòng xanh DUNG read_group 1 lần 1 câu SQL tong bằng 1750 0.2 ms. Dòng chú thích cùng kết quả 1750 nhưng 50 câu thành 1 câu nhanh khoảng 24 lần. Dòng Da xoa the tam con lai 4 the

Hình 2: Thật, đo bằng bộ đếm bọc quanh cr.execute. Cách N+1: 50 câu SQL, 4.8 ms. Cách read_group: 1 câu SQL, 0.2 ms. Cùng ra kết quả 1750 — không cách nào sai về mặt số liệu — nhưng một cách nhanh gấp ~24 lần và, quan trọng hơn, số query của nó không tăng theo số thẻ. Với 50 thẻ khác biệt đã rõ; với 5.000 thẻ, cách sai là 5.000 câu SQL. (Đã xoá 50 thẻ tạm sau khi đo.)

Bốn công cụ để né N+1

Odoo cho sẵn công cụ, dùng chúng thay cho vòng lặp Python:

  • read_group / _read_group — gộp thống kê (đếm, tổng, trung bình) ngay trong SQL. Thay cho "lặp rồi đếm/cộng".
  • mapped('field') — lấy một field (hay đi qua quan hệ) cho cả recordset một lần, tận dụng prefetch. cards.mapped('lich_su_ids.diem_cong') thay cho vòng lặp.
  • filtered(func) — lọc trong bộ nhớ trên recordset đã nạp, không đẻ query mới.
  • Một search rồi nhóm bằng dict — nếu cần dữ liệu con, search một lần cho mọi cha rồi gom bằng defaultdict, thay vì search trong vòng lặp.
# thay vì search trong vòng lặp:
lines = Hist.search([('the_id', 'in', cards.ids)])   # 1 query
theo_the = defaultdict(list)
for l in lines:
    theo_the[l.the_id.id].append(l)                  # gom trong Python

Cách phát hiện N+1

  • Bật log SQL: chạy Odoo với --log-level=debug_sql (hoặc bọc cr.execute như trên) và nhìn số câu lặp lại giống hệt nhau chỉ khác tham số id.
  • Nghi ngờ mọi search/search_count nằm trong for. Đó là dấu hiệu số một.
  • Dùng profiler của Odoo (from odoo.tools.profiler import Profiler) để đếm query theo từng đoạn.
  • Quy tắc ngón tay cái: số câu SQL không được tăng theo số bản ghi. Nếu thêm dữ liệu mà số query tăng tuyến tính, bạn đang dính N+1.

Ba ý mang về

  1. Bẫy N+1 là gọi search()/truy vấn con bên trong vòng lặp — mỗi vòng một câu SQL. Prefetch tự động của Odoo cứu được việc đọc field, nhưng không cứu được search trong loop.
  2. Đo thật cho thấy 50 câu SQL (4.8 ms) sụt còn 1 câu (0.2 ms) khi thay vòng lặp search bằng _read_group — cùng kết quả 1750, nhưng số query không tăng theo số bản ghi.
  3. Dùng công cụ ORM thay vòng lặp: read_group để gộp thống kê, mapped để đọc field/quan hệ cả recordset, filtered để lọc trong bộ nhớ, và một search + nhóm bằng dict thay cho search trong loop.

Né được N+1 ở tầng code rồi, còn một tầng nữa quyết định tốc độ truy vấn: cách CSDL tìm dữ liệu. Phần sau nói về index và tối ưu truy vấn — khi nào Odoo tự tạo index, khi nào bạn phải thêm index=True, và vì sao một field tìm kiếm thiếu index có thể làm chậm cả bảng.