N+1 Query và vấn đề của backend

Khi backend "chạy được" nhưng không thể scale

Trong rất nhiều hệ thống backend hiện đại, đặc biệt là dùng GraphQL hay các thư viện như Prisma / TypeORM / Sequelize / Hibernate — có một vấn đề gần như mọi team đều từng gặp và xảy ra thường xuyên:

N+1 Query Problem

Điều nguy hiểm là N+1 thường không làm hệ thống chết ngay. Mà nó âm thầm, lặng lẽ làm:

  • Tăng latency
  • Tăng CPU database
  • Ăn connection pool
  • Làm API chậm dần theo thời gian

Cho đến khi production traffic tăng đủ lớn. Lúc đó:

  • dashboard load tính bằng giây
  • graphQL timeout
  • RDS CPU 100%
  • Redis cũng không cứu nổi
  • Càng scaling thì cost tăng mạnh, nhưng vẫn không giải quyết được vấn đề

Và phần nguy hiểm nhất chính là: code nhìn hoàn toàn "đúng", clean, business theo yêu cầu — nhưng lại không thấy được vấn đề ngay lập tức. Team phải chờ đợi âm thầm... đến khi khách hàng phàn nàn "web gì chậm như rùa".


1. Vậy N+1 Query là gì?

Có lẽ vấn đề này mọi develop đều đã biết hoặc đã từng nghe.

N+1 Query xảy ra khi:

  • 1 query đầu tiên lấy danh sách dữ liệu chính
  • Sau đó phát sinh thêm N query khác để lấy dữ liệu liên quan cho từng item

Ví dụ nếu có 100 users → 1 + 100 = 101 queries.

Ví dụ trong RESTful API

// Backend tự loop:
const users = await getUsers();           // 1 query
for (const user of users) {
  user.posts = await getPostsByUser(user.id); // N queries
}

Khi số lượng users tăng lên, số query tăng theo tuyến tính. Đây chính là N+1 problem.

Ví dụ trong GraphQL

GraphQL dễ gặp N+1 hơn vì cơ chế resolver hoạt động theo từng field.

// Schema
type User {
  id: ID
  name: String
  posts: [Post]
}

// Client query
query {
  users {
    name
    posts {
      title
    }
  }
}
// Resolver
const resolvers = {
  Query: {
    users: () => db.query("SELECT * FROM users"),
  },
  User: {
    posts: (user) => db.query(`SELECT * FROM posts WHERE user_id = ${user.id}`),
  },
};

Nếu có 100 users → 1 + 100 = 101 queries.


Tại sao đây là vấn đề lớn?

Developer thường nghĩ: "Mỗi query chỉ tốn vài ms, có gì đáng lo?"

Sai. Vì database query không miễn phí. Mỗi query đều cần:

  • network roundtrip
  • parse SQL
  • query planning
  • locking
  • memory allocation
  • connection handling

Khi traffic tăng, database bắt đầu nghẹt.

Vấn đề nguy hiểm nhất: Code nhìn rất sạch

Đây là lý do N+1 tồn tại lâu trong production.

const users = await User.findAll();
for (const user of users) {
  const posts = await user.getPosts(); // lazy loading
}

Developer nhìn vào thấy: readable, async/await đẹp, logic đúng.
Nhưng phía dưới là N+1 queries.


Vì sao GraphQL đặc biệt dễ gặp N+1?

Trong REST, backend quyết định response shape, backend chủ động và biết rõ data trả về như thế nào, nhìn thấy toàn bộ response shape trước khi code. Nhưng GraphQL cho phép client tự define query — điều này cực mạnh, nhưng cũng nguy hiểm.

Resolver hoạt động độc lập theo từng field. Mỗi tầng có thể tiếp tục tạo thêm query.

Query Explosion — ví dụ thực tế

Giả sử: 10 users, mỗi user có 5 posts, mỗi post có 10 comments.

query {
  users {          # 10 users
    posts {        # 5 posts/user
      comments {   # 10 comments/post
        author { name }
      }
    }
  }
}
Step Thao tác Số queries
1 users() 1
2 posts(user) × 10 users 10
3 comments(post) × 50 posts 50
4 author(comment) × 500 comments 500
Total 561 queries

561 queries cho 10 users — và không ai thấy vấn đề cho đến khi production đổ.


Điều khiến N+1 cực kỳ nguy hiểm

Local không thấy vấn đề

Ở local với vài trăm, vài nghìn records, mọi thứ chạy rất nhanh. Và team sẽ nói: "Ổn rồi, release thôi."

QA khó detect

QA test: data đúng không, response đúng không — họ không kiểm tra query count hay latency dưới load. Nếu test thực tế stress load với data lớn thì mới có thể phát hiện vấn đề.

ORM che giấu vấn đề

// TypeORM lazy loading — nhìn như object access bình thường
const user = await User.findOne(id);
const posts = await user.posts; // đây là 1 query

ORM khiến developer mất cảm giác về database. Nhiều developer dần không đọc SQL, không hiểu execution plan, không biết query count. Họ chỉ nhìn thấy object access (user.posts) — nhưng phía dưới có thể là hàng trăm queries.


Database không chết vì 1 query cực lớn

Nó chết vì hàng nghìn query nhỏ.

1 query = 2ms  ✅
1000 queries = 2000ms + overhead ❌

Chưa tính: network overhead, connection acquire, serialization, lock wait, context switching.


2. Giải pháp: Vậy làm sao để giải quyết?

DataLoader là giải pháp nổi tiếng nhất cho GraphQL N+1.

Ý tưởng: thay vì query ngay, gom tất cả IDs lại và query 1 lần duy nhất.

Không dùng DataLoader

// 100 users → 1 query user + 100 queries post
User: {
  posts: (user) => db.query(`SELECT * FROM posts WHERE user_id = ${user.id}`)
}

Dùng DataLoader

const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.query(
    `SELECT * FROM posts WHERE user_id IN (${userIds.join(",")})`
  );
  return userIds.map(id => posts.filter(p => p.user_id === id));
});

// Resolver
User: {
  posts: (user) => postLoader.load(user.id)
}

GraphQL gọi postLoader.load() cho từng user, DataLoader gom lại → chỉ còn 1 query.

DataLoader còn có request-level cache

// Cùng 1 request, user_id = 1 được load 2 lần
postLoader.load(1); // query
postLoader.load(1); // cache hit — không query lại

Nhưng DataLoader cũng không phải hoàn toàn xử lý triệt để N+1 query

Nếu query depth quá lớn:

users → posts → comments → authors → followers → ...
query {
  organizations {
    users {
      posts {
        comments {
          author {
            organizations {
              users {
                posts {
                  id
                }
              }
            }
          }
        }
      }
    }
  }
}

Workload vẫn khổng lồ. DataLoader giảm được N+1 nhưng không giải quyết được bad query design.

DataLoader có thể biến:

SELECT * FROM users WHERE organization_id = ?

thành

SELECT * FROM users WHERE organization_id IN (...)

nhưng nếu query đòi lấy:

  • 100 organizations
  • mỗi organization 100 users
  • mỗi user 100 posts
  • mỗi post 100 comments

thì kết quả vẫn là: 100 × 100 × 100 × 100 = 100 triệu records

Một vấn đề khác: Overfetching bằng JOIN

Một số team chống N+1 bằng cách JOIN tất cả:

SELECT u.*, p.*, c.*
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
LEFT JOIN comments c ON c.post_id = p.id

- Query giảm từ nhiều lần xuống một lần, nhưng đổi lại là dữ liệu bị lặp, payload lớn hơn cần thiết, tốn RAM và chậm serialize.

- Ví dụ, một user có 100 posts và mỗi post có 50 comments có thể tạo ra hàng nghìn dòng kết quả chỉ để biểu diễn cùng một user.

N+1 queryJOIN quá nhiều đều không phải lời giải hoàn hảo. Công việc của backend engineer là lựa chọn điểm cân bằng phù hợp giữa số lượng query, lượng dữ liệu trả về và chi phí xử lý của hệ thống tùy từng trường hợp sử dụng.


Các kỹ thuật  thường dùng giúp khắc phục điều này

2.1 Query Complexity Analysis

Tính độ phức tạp của query trước khi execute.

query {
  organizations { 	// 10 record
    users {		  	// x10		
      posts {		// x10
        comments { 	// x10
          author {
            name
          }
        }
      }
    }
  }
}

complexity = 10x10x10x10 = 10000

Nếu vượt ngưỡng:

if (complexity > 5000) {
  throw new Error("Query too complex");
}

2.2 Batch loading

// Thay vì:
for (const id of ids) await fetchUser(id);

// Dùng:
await fetchUsers(ids); // 1 query

2.3 Preloading

Một số relation nên preload trước khi vào resolver:

const users = await User.findAll({ include: [{ model: Post }] });

2.4 Depth limiting

Giới hạn độ sâu của GraphQL query để tránh query explosion:

Max Depth = 5

query {
  user {
    posts {
      comments {
        user {
          id
        }
      }
    }
  }
}
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
  validationRules: [depthLimit(5)]
});

Với Depth = 5 => Query sâu hơn sẽ bị từ chối:

{"errors": [{"message": "Query exceeds maximum depth"}]}

2.5 Query-specific Resolver

Đối với dashboard (hoặc các tác vụ business tương tự), tránh dùng graph traversal chỉ để lấy count hoặc statistics vì sẽ fetch nhiều dữ liệu không cần thiết.

Không nên

query {
  dashboard {
    users {
      id
    }
  }
}

Sau đó FE tự đếm:

business.users.length


Mà nên tạo resolver riêng để query trực tiếp các giá trị aggregate cần thiết.

query {
    dashboard {
      totalUsers
      totalPosts
      latestComments
	}
}

=> Cách này giảm payload, tránh N+1 và tối ưu cho dashboard.


2.6 Persisted queries

Không cho phép client gửi query tùy ý, mà chỉ được gọi những query đã được đăng ký trước trên server.

POST /graphql
{"queryId": "dashboard_v2"}

Server:

dashboard_v2
=> query đã được review
=> complexity đã biết

2.7 Cache

DataLoader chỉ cache trong 1 request.

Thường kết hợp thêm:

  • Redis
  • CDN
  • Response Cache
  • Apollo Cache

Thực tế sẽ giải quyết như sau

GraphQL
├─ DataLoader
├─ Depth Limit
├─ Complexity Limit
├─ Pagination
├─ Redis Cache
├─ Custom Dashboard Queries
└─ Persisted Queries


3. Index và Partition có giải quyết được N+1 không?

Liên hệ với bài viết trước đây về index và partition. Liệu nếu tôi đánh index và partition đúng thì chắc có lẽ sẽ giải quyết được phần N+1 query này thôi ?

Nhưng sự thật là Có thể giúp, nhưng không giải quyết được gốc của N+1.

Nhiều team khi thấy query chậm nên:

  • Thêm index
  • Partition table
  • Tăng RDS size
  • Thêm Redis
  • Scale pod

Trong khi vấn đề thật sự là: quá nhiều queries, mỗi query lại quá chậm.

Index có giúp không?

Có thể giúp, nhưng không giải quyết được gốc của N+1.

  • 1 query = 2ms → rất nhanh
  • 1000 queries = 2000ms
    → chưa tính overhead, network overhead, connection, serialization, lock wait, context switching

=> Index không giảm roundtrip, Mỗi query vẫn cần:

acquire connection → gửi SQL → DB parse → execution → return data → deserialize → release connection

Trong khi N+1 sẽ khiến làm việc này lặp lại hàng trăm lần.

Đây là thứ nhiều người bỏ qua.Còn Partition có giúp không? Có, nhưng ở layer khác.

Partition có giúp không?

Partition giúp giảm scan size, improve large table performance. Nhưng không giải quyết số lần roundtrip.

Điều này giống như : Bạn ship hàng cho 1000 khách hàng với cùng 1 đích đến.
- Không có N+1 → 1 chuyến xe tải: 1 lần thực hiện cho phép xử lý toàn bộ hàng hóa.
- Có N+1 → 1000 chuyến xe máy.

=> Index/Partition giúp: con đường di chuyển thông thoáng, phân tải nhiều hướng để di chuyển hơn, gọn gàng hơn, nhưng bạn vẫn đang đi 1000 chuyến.

Sai lầm phổ biến của team backend

Khi gặp chậm thì làm rất nhiều cách cao siêu, tốn nhiều thời gian, chi phí hạ tầng:

Trong khi chỉ cần:

Fix N+1 → reduce query count → batch loading → optimize fetch strategy → rồi mới tuning DB

Thứ tự fix đúng:

Fix N+1
→ Reduce query count
→ Batch loading
→ Optimize fetch strategy
→ Rồi mới tuning DB

Rule quan trọng nhất khi viết GraphQL resolver

❌ Đừng hỏi: "Query này có đúng không?"

✅ Hãy hỏi: "Query này sẽ tạo ra bao nhiêu SQL khi có 1000 records?"


Kết luận

N+1 Query Problem không chỉ là vấn đề performance. Nó phản ánh:

  • Cách developer hiểu database
  • Cách team thiết kế architecture
  • Mức độ observability của hệ thống

Điều đáng sợ nhất về N+1 là: code vẫn chạy đúng — cho tới ngày production traffic tăng lên.

Và lúc đó, thêm CPU, thêm pod, thêm cache đều không cứu được — bởi vì gốc rễ vấn đề nằm ở cách dữ liệu được fetch.

Tài liệu tham khảo: