Skip to content

Bẫy của ORM ​

ORM (Sequelize, TypeORM, Prisma, MikroORM…) giúp viết nhanh và gọn, nhưng giấu câu SQL thật. Phần lớn lỗi hiệu năng và lỗi dữ liệu trong ứng dụng Node.js dùng ORM rơi vào vài nhóm dưới đây. Trang này nói các bẫy chung; ví dụ code dùng Sequelize và Prisma vì hay gặp nhất.

Thói quen quan trọng nhất: bật log SQL khi phát triển và đọc nó. Một màn hình sinh ra 50 query, hay một query kéo về 10.000 dòng, nhìn log là thấy ngay.

ORMBật log SQL
Sequelizelogging: console.log (mặc định đã bật), thêm benchmark: true để in thời gian
Prismanew PrismaClient({ log: ['query'] })
TypeORMlogging: ['query'], maxQueryExecutionTime: 200 để log query chậm

N+1 query ​

Lấy danh sách N bản ghi bằng 1 query, rồi với mỗi bản ghi lại chạy thêm 1 query để lấy dữ liệu liên quan: tổng cộng 1 + N query.

js
// Sequelize: 1 query lấy bài, rồi 1 query cho mỗi bài
const posts = await Post.findAll({ limit: 50 })
for (const post of posts) {
  post.comments = await post.getComments()   // SELECT … WHERE post_id = ?
}

Code trông vô hại. Trên máy dev database nằm cùng máy, mỗi query mất vài phần mười mili giây nên không ai để ý. Lên production, mỗi query là một lần đi qua mạng, và thời gian tăng theo số bản ghi × độ trễ mạng.

Lỗi này còn dễ lọt vào ở những chỗ không có vòng lặp rõ ràng: lazy relation của TypeORM (thuộc tính kiểu Promise), hàm serialize gọi tới quan hệ, resolver GraphQL cho từng trường.

Thử: lấy 50 bài viết kèm comment
Cùng máy ~0,1 ms · cùng data center ~0,5 ms · khác vùng sẵn sàng (AZ) 1–2 ms · database ở xa hơn thì vài ms.
51query
550dòng trả về
33,3 msước tính
Mỗi ô là một query, chạy lần lượt
Lazy load trong vòng lặp51 query · 550 dòng · 33,3 ms
Eager load bằng JOIN1 query · 500 dòng · 3,1 ms
Query riêng với IN (…)2 query · 550 dòng · 4 ms
SELECT * FROM posts LIMIT 50;
SELECT * FROM comments WHERE post_id = 1;
SELECT * FROM comments WHERE post_id = 2;
-- … thêm 48 query giống hệt, chỉ khác post_id

Mỗi bài thêm một query. Thời gian tăng theo số bài × độ trễ, nên trên máy dev (database cùng máy) gần như không thấy, lên production mới chậm.

Giả định minh hoạ: mỗi query tốn thêm 0,1 ms ở database, mỗi dòng 0,005 ms để truyền và dựng object; query chạy tuần tự như vòng lặp await. Bài không có comment vẫn trả 1 dòng trong LEFT JOIN.

Cách sửa: nói trước cho ORM biết cần quan hệ nào (eager loading).

js
// Sequelize: include → một câu LEFT JOIN
const posts = await Post.findAll({ limit: 50, include: [{ model: Comment }] })

// Sequelize: separate → query thứ hai dạng WHERE post_id IN (…), chỉ dùng cho hasMany
const posts2 = await Post.findAll({ limit: 50, include: [{ model: Comment, separate: true }] })

// Prisma: include
const posts3 = await prisma.post.findMany({ take: 50, include: { comments: true } })
CáchSố queryKhi nào dùng
JOIN (include của Sequelize, relations của TypeORM)1Quan hệ một – một, nhiều – một (bài viết → tác giả)
Query riêng với IN (…) (separate: true, relationLoadStrategy: 'query' của TypeORM)1 + số quan hệQuan hệ một – nhiều, nhất là khi lấy từ hai quan hệ một – nhiều trở lên
DataLoaderGom các lần gọi trong cùng một tick thành một IN (…)GraphQL, hoặc code tách nhiều hàm khó gom tay

JOIN nhiều quan hệ một – nhiều cùng lúc thì số dòng nhân lên (cartesian explosion): bài có 30 comment và 5 tag thì JOIN trả 150 dòng cho một bài. ORM phải tải hết rồi gom lại. Quan hệ một – nhiều thứ hai trở đi nên tách thành query riêng.

JOIN với LIMIT: LIMIT 50 trên câu JOIN với comment cắt theo dòng, không theo bài. Sequelize tự bọc thành subquery để LIMIT đúng theo bài, và câu sinh ra thường phức tạp, chậm. Thêm một lý do để quan hệ một – nhiều dùng query riêng.

Phát hiện sớm: đếm số query mỗi request (middleware đếm qua hook log của ORM), cảnh báo khi vượt ngưỡng; trong test, assert số query của các API quan trọng.

Lấy nhiều hơn cần ​

  • Mọi cột. findAll() mặc định là SELECT mọi cột, kéo theo cả cột TEXT, JSON lớn mà màn hình không dùng, và mất cơ hội dùng covering index (MySQL chương 4). Chọn cột: attributes: ['id', 'title'] (Sequelize), select: { id: true, title: true } (Prisma).
  • Lọc, đếm, phân trang trong JavaScript. (await Order.findAll()).filter(…), .length để đếm, .slice() để chia trang: tải cả bảng về để dùng vài dòng. Đẩy xuống database bằng where, count(), limit, và chỉ trả về đúng thứ cần.
  • Không giới hạn số dòng. API trả danh sách không có limit chạy ổn lúc bảng nhỏ, rồi một ngày làm tràn bộ nhớ. Luôn có giới hạn tối đa, và với job xử lý toàn bộ bảng thì đọc theo lô bằng keyset (Phân trang).
  • Chi phí dựng object. Mỗi dòng thành một instance model có getter, setter, theo dõi thay đổi. Với báo cáo, export hàng chục nghìn dòng, đọc dạng object thường (raw: true của Sequelize) hoặc viết SQL tay.

Ghi dữ liệu ​

Đọc, sửa trong JavaScript, rồi lưu ​

js
const product = await Product.findByPk(id)
product.stock = product.stock - qty      // hai request cùng đọc stock = 10
await product.save()                     // cả hai ghi 9 → mất một lần trừ

Đây là lost update: giữa lúc đọc và lúc ghi, request khác đã ghi giá trị mới (MySQL chương 6). ORM làm lỗi này dễ xảy ra vì mẫu “lấy object, sửa thuộc tính, gọi save()” rất tự nhiên.

Sửa bằng một trong ba cách:

  1. Để database tính: UPDATE products SET stock = stock - ? WHERE id = ?. Sequelize có product.decrement('stock', { by: qty }), Prisma có data: { stock: { decrement: qty } }.
  2. Ghi có điều kiện: UPDATE … SET stock = stock - ? WHERE id = ? AND stock >= ?, ảnh hưởng 0 dòng nghĩa là không đủ hàng. Không cần đọc trước.
  3. Version (optimistic locking): thêm WHERE version = ? khi ghi (Versioning). Sequelize có sẵn bằng tuỳ chọn model version: true.

Ghi từng dòng trong vòng lặp ​

for (const row of rows) await Model.create(row) là N round-trip và N transaction ngầm. Dùng bulkCreate (Sequelize), createMany (Prisma), hoặc một câu UPDATE … WHERE id IN (…) thay cho N lần save(). Lô rất lớn thì chia thành từng phần vài trăm tới vài nghìn dòng.

Validation và hook của ORM không phải ràng buộc ​

  • Kiểm tra trùng ở ORM (unique validator, hoặc findOne rồi mới create) bị race: hai request cùng thấy chưa có, cùng ghi. Chỉ UNIQUE trong database chặn được (Chống trùng & race condition).
  • Lệnh ghi hàng loạt bỏ qua hook từng dòng. Model.update({…}, { where }) và destroy({ where }) của Sequelize không gọi hook từng bản ghi trừ khi bật individualHooks: true. Logic quan trọng (ghi audit, xoá cache) đặt trong hook sẽ bị bỏ sót.
  • Đừng để quy tắc dữ liệu chỉ nằm ở model: thêm NOT NULL, CHECK, FOREIGN KEY trong schema. Script, job, người sửa tay đều đi vòng qua ORM.

Mass assignment ​

User.create(req.body) hay user.update(req.body) cho client gửi lên bất kỳ cột nào, kể cả role: 'admin' hay balance. Chỉ nhận các trường được phép: liệt kê rõ khi gọi (fields: ['name', 'email'] của Sequelize) hoặc validate body bằng schema (zod, joi) rồi chỉ truyền phần đã lọc.

Transaction ​

Quên truyền transaction ​

js
await sequelize.transaction(async (t) => {
  const order = await Order.create(data, { transaction: t })
  await Stock.decrement('qty', { by: 1, where: { id: pid } })   // thiếu { transaction: t }
})

Câu thứ hai chạy trên một connection khác, ngoài transaction: transaction rollback thì kho vẫn bị trừ. Tệ hơn, nếu câu đó phải chờ lock mà chính transaction đang giữ, nó chờ tới khi hết innodb_lock_wait_timeout (mặc định 50 giây) trong khi transaction lại chờ nó. Khi nhiều request cùng làm vậy, mọi connection trong pool bị giữ và ứng dụng treo.

ORMCách đúng
SequelizeTruyền { transaction: t } cho mọi câu, hoặc bật CLS (Sequelize.useCLS) để tự gắn
PrismaTrong prisma.$transaction(async (tx) => …) chỉ dùng tx, không dùng prisma
TypeORMTrong dataSource.transaction(async (manager) => …) chỉ dùng manager

Transaction quá dài ​

Transaction giữ một connection và các lock suốt thời gian chạy. Gọi API thanh toán, gửi email, upload file bên trong transaction là giữ lock thêm vài trăm mili giây tới vài giây, và nếu API đó treo thì lock treo theo (MySQL chương 6). Làm việc chậm ở ngoài; việc cần “ghi DB rồi gọi bên ngoài” thì dùng outbox.

Prisma đặt sẵn giới hạn cho interactive transaction (timeout mặc định 5 giây); các ORM khác thì không, phải tự đặt timeout ở tầng ứng dụng hoặc database.

Thiếu transaction ​

Ngược lại, nhiều câu ghi phải cùng thành công (tạo đơn + trừ kho + ghi sổ cái) mà chạy rời rạc thì lỗi giữa chừng để lại dữ liệu dở dang. Mỗi lệnh ORM mặc định là một transaction riêng (autocommit).

Isolation level: ORM dùng mặc định của database: MySQL InnoDB là REPEATABLE READ, PostgreSQL là READ COMMITTED. Cùng một đoạn code có thể cho kết quả khác nhau khi đổi database.

Connection ​

  • Tạo client mới mỗi request (new PrismaClient() hay new Sequelize() trong handler) là tạo pool mới mỗi lần, connection tăng tới khi database từ chối. Tạo một lần khi khởi động và dùng chung. Chế độ hot reload khi dev cũng hay tạo nhiều instance, cần giữ instance trên biến toàn cục.
  • Kích thước pool mặc định khác nhau giữa các ORM (Sequelize mặc định tối đa 5 connection). Đặt rõ theo tính toán: số instance × pool mỗi instance phải nhỏ hơn max_connections (MySQL chương 10).
  • Serverless (Lambda, Cloudflare Workers): mỗi instance có pool riêng, tải tăng là số connection tăng vọt. Dùng pool nhỏ (1–2) kèm proxy gom connection (RDS Proxy, PgBouncer).

Schema và migration ​

  • Không dùng sync({ alter: true }) (Sequelize) hay synchronize: true (TypeORM) trên production. Chúng so model với bảng rồi tự sửa schema, có thể xoá cột hoặc khoá bảng lớn. Dùng file migration được review (Migration trong team).
  • Kiểu mặc định của ORM chưa chắc hợp: chuỗi thường thành VARCHAR(255) hoặc VARCHAR(191), tiền phải là DECIMAL chứ không phải FLOAT. Đọc migration sinh ra trước khi chạy.
  • Index cho khoá ngoại: MySQL InnoDB tự tạo index cho cột khoá ngoại nếu chưa có; PostgreSQL thì không. Khai báo quan hệ trong ORM trên PostgreSQL mà quên index thì mọi JOIN và mọi include theo quan hệ đó đều quét bảng.

Kiểu dữ liệu đi qua driver ​

  • BIGINT và DECIMAL không vừa với kiểu number của JavaScript (chỉ chính xác tới 253; số thập phân bị sai số). Tuỳ driver và cấu hình mà chúng về thành string (driver pg mặc định làm vậy), number bị mất chính xác, hoặc BigInt / object Decimal (Prisma). Thống nhất một cách trong cả dự án; nhớ JSON.stringify báo lỗi khi gặp BigInt.
  • Ngày giờ: ORM và driver có thể tự đổi múi giờ khi đọc và ghi. Lưu UTC, đặt rõ cấu hình múi giờ của kết nối (Thời gian & múi giờ).

Khi nào viết SQL tay ​

ORM hợp với CRUD thông thường. Viết SQL tay (hoặc dùng query builder như Knex, Kysely) khi:

  • Báo cáo, thống kê: GROUP BY nhiều cấp, window function, CTE đệ quy.
  • Lệnh theo lô: cập nhật hàng nghìn dòng bằng một câu, INSERT … SELECT, INSERT … ON DUPLICATE KEY UPDATE / ON CONFLICT.
  • Cần kiểm soát lock: SELECT … FOR UPDATE SKIP LOCKED (Hàng đợi bằng database).
  • Câu ORM sinh ra chậm và không ép được ORM sinh câu tốt hơn.

Luôn truyền tham số, không nối chuỗi. Prisma $queryRaw dạng tagged template tự tham số hoá, còn $queryRawUnsafe thì không. Sequelize dùng replacements hoặc bind. sequelize.literal() và tên cột lấy từ input người dùng (cột sắp xếp chẳng hạn) phải so với danh sách cho phép.

Khi review code dùng ORM ​

  1. Có lệnh query nào nằm trong vòng lặp không?
  2. Có giới hạn số dòng không? Có chọn cột không?
  3. Đọc – sửa – lưu một giá trị dùng chung (số dư, tồn kho, bộ đếm) có được bảo vệ không?
  4. Mọi câu trong transaction có dùng đúng đối tượng transaction không? Có gọi dịch vụ bên ngoài trong transaction không?
  5. Body request có được lọc trước khi đưa vào create / update không?
  6. Migration sinh ra có kiểu cột và index đúng không?

Ghi nhớ

  1. Bật log SQL và đếm số query mỗi request; ORM chỉ đáng tin khi biết nó sinh ra câu gì.
  2. N+1: eager load. Quan hệ một – một / nhiều – một dùng JOIN, quan hệ một – nhiều dùng query riêng với IN (…).
  3. Đọc – sửa – save() gây lost update; dùng cập nhật nguyên tử, ghi có điều kiện, hoặc version.
  4. Trong transaction, mọi câu phải đi qua đúng đối tượng transaction; không gọi dịch vụ ngoài bên trong.
  5. Ràng buộc dữ liệu đặt trong database, không chỉ trong model; không sync/synchronize trên production.
  6. Báo cáo, lệnh theo lô, lock đặc biệt thì viết SQL tay, luôn có tham số.