database

Tại sao mình bỏ ORM sau 4 năm dùng

4 năm dùng SQLAlchemy, TypeORM, Prisma — mình không bỏ vì ORM xấu. Mình bỏ vì N+1 ẩn mình, complex query trở nên ugly, và migration drift ngày càng đau đầu.

Năm 2021 mình join một team TypeORM. Setup nhanh, entity clean, auto-migration một lệnh. Mọi thứ trông ổn.

Ba năm sau mình ngồi stare vào một query chạy 8 giây trên production. Cái query đó trông hoàn toàn innocent. Đó là lúc mình bắt đầu nghi ngờ mọi thứ.


N+1 ẩn mình trong code đẹp nhất

Đây là đoạn code mình viết hồi đó. Trông clean, trông đúng:

# SQLAlchemy — lazy loading mặc định
users = session.query(User).filter(User.active == True).all()

for user in users:
    print(user.orders)  # BÀI TOÁN: mỗi vòng lặp = 1 query mới

500 users = 501 queries. Không exception, không warning. ORM âm thầm bắn từng cái một như chưa có chuyện gì xảy ra.

Fix thì có, nhưng phải nhớ tự thêm:

# Phải explicit dùng joinedload hoặc selectinload
from sqlalchemy.orm import joinedload

users = (
    session.query(User)
    .options(joinedload(User.orders))
    .filter(User.active == True)
    .all()
)

Vấn đề không phải ORM không có cách fix. Vấn đề là anh em phải nhớ fix. Trong team 5 người, reviewer phải nhớ check từng chỗ có loop qua relationship. Mình đã miss ít nhất hai lần trước khi bắt đầu paranoid về nó.


Complex query: ORM không thua, nhưng mình mệt hơn

Đây mới là straw that broke the camel’s back.

Task bình thường: top 10 users theo revenue trong 30 ngày, kèm số đơn và tên tier.

SQLAlchemy version:

from sqlalchemy import func, select, and_
from datetime import datetime, timedelta

thirty_days_ago = datetime.now() - timedelta(days=30)

stmt = (
    select(
        User.id,
        User.name,
        CustomerTier.name.label("tier"),
        func.count(Order.id).label("order_count"),
        func.sum(Order.total).label("revenue"),
    )
    .join(Order, and_(
        Order.user_id == User.id,
        Order.created_at >= thirty_days_ago,
        Order.status == "completed",
    ))
    .join(CustomerTier, CustomerTier.id == User.tier_id)
    .group_by(User.id, User.name, CustomerTier.name)
    .order_by(func.sum(Order.total).desc())
    .limit(10)
)

results = session.execute(stmt).all()

SQL version:

SELECT
    u.id,
    u.name,
    ct.name AS tier,
    COUNT(o.id) AS order_count,
    SUM(o.total) AS revenue
FROM users u
JOIN orders o
    ON o.user_id = u.id
    AND o.created_at >= NOW() - INTERVAL '30 days'
    AND o.status = 'completed'
JOIN customer_tiers ct ON ct.id = u.tier_id
GROUP BY u.id, u.name, ct.name
ORDER BY revenue DESC
LIMIT 10;

ORM version không sai. Nhưng mình phải đọc hai lần để parse nó. SQL version đọc một lần là xong.

Thêm window function vào là ORM bắt đầu thực sự đau:

# ORM với window function — row_number() để rank trong mỗi tier
from sqlalchemy import func, over

rank_expr = func.row_number().over(
    partition_by=CustomerTier.name,
    order_by=func.sum(Order.total).desc()
).label("rank_in_tier")

So với SQL:

ROW_NUMBER() OVER (
    PARTITION BY ct.name
    ORDER BY SUM(o.total) DESC
) AS rank_in_tier

ORM làm được hết. Nhưng mình đang học một ngôn ngữ thứ hai để kể câu chuyện mà mình đã biết kể bằng tiếng mẹ đẻ.


Migration drift — cái đau thầm lặng nhất

Cái này không phải edge case. Nó xảy ra ở mọi team đủ lớn và đủ lâu.

Tháng 1: dev A thêm cột metadata jsonb trực tiếp trên staging để test nhanh. Quên update model.

Tháng 2: dev B generate migration mới. SQLAlchemy không biết về cột manual kia, migration mới bỏ qua nó.

Tháng 3: production có cột metadata, model không có, migration history cũng không. Sync database từ staging về local thì local có cột. Chạy từ migration history thì không có.

# Model trong code
class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    total = Column(Numeric)
    status = Column(String)
    # metadata không có ở đây
-- Actual table trong production
\d orders
   Column   |  Type   |
------------+---------+
 id         | integer |
 total      | numeric |
 status     | varchar |
 metadata   | jsonb   |  ← cái này ở đâu ra?

Lỗi của process, không phải ORM. Nhưng ORM tạo ảo giác rằng model trong code là source of truth. Khi ảo giác đó vỡ, mình mất nửa ngày để reconstruct cái gì đã xảy ra.


Mình dùng gì thay thế

Không phải raw SQL string concatenation. Cái đó còn tệ hơn ORM vì không có type safety.

Mình chuyển sang query builder — type-safe, không có magic, không có lazy loading ẩn.

Kysely cho TypeScript

import { Kysely, sql } from 'kysely'

const results = await db
  .selectFrom('users as u')
  .innerJoin('orders as o', (join) =>
    join
      .onRef('o.user_id', '=', 'u.id')
      .on('o.status', '=', 'completed')
      .on('o.created_at', '>=', sql<Date>`NOW() - INTERVAL '30 days'`)
  )
  .innerJoin('customer_tiers as ct', 'ct.id', 'u.tier_id')
  .select([
    'u.id',
    'u.name',
    'ct.name as tier',
    db.fn.count<number>('o.id').as('order_count'),
    db.fn.sum<number>('o.total').as('revenue'),
  ])
  .groupBy(['u.id', 'u.name', 'ct.name'])
  .orderBy('revenue', 'desc')
  .limit(10)
  .execute()

Kysely không lazy load gì hết. Mỗi query là một round-trip rõ ràng. TypeScript biết chính xác shape của results. Không có N+1 ẩn vì không có relationship magic.

sqlc cho Go

Với Go, mình viết SQL thẳng vào file .sql, sqlc generate type-safe Go code từ đó:

-- query.sql
-- name: GetTopUsersByRevenue :many
SELECT
    u.id,
    u.name,
    ct.name AS tier,
    COUNT(o.id)::int AS order_count,
    SUM(o.total) AS revenue
FROM users u
JOIN orders o
    ON o.user_id = u.id
    AND o.created_at >= NOW() - INTERVAL '30 days'
    AND o.status = 'completed'
JOIN customer_tiers ct ON ct.id = u.tier_id
GROUP BY u.id, u.name, ct.name
ORDER BY revenue DESC
LIMIT $1;

sqlc generate ra:

// generated — không cần viết tay
func (q *Queries) GetTopUsersByRevenue(ctx context.Context, limit int32) ([]GetTopUsersByRevenueRow, error) {
    // ...
}

SQL là source of truth. Generated code là output. Không có drift.


Khi nào ORM vẫn là lựa chọn tốt

Mình không bảo anh em drop ORM ngay. Có những case nó là tool đúng.

CRUD app thuần túy — ORM shine rõ nhất ở đây:

# Prisma (TypeScript) — cái này ORM win rõ ràng
const user = await prisma.user.create({
    data: {
        name: "Nguyen Van A",
        email: "[email protected]",
        profile: {
            create: {
                bio: "Backend dev",
            }
        }
    }
})

Nested create với relationship, type-safe, không cần viết JOIN. SQL equivalent dài hơn và dễ error hơn nhiều.

Prototyping — schema nhanh, migration một lệnh, chưa có production data, chưa có team lớn. ORM tuyệt vời ở giai đoạn này.

Team chưa quen SQL — ORM giúp tránh injection, tránh syntax error, tránh một đống footgun. Trade-off này có giá trị thực tế, không phải lý thuyết.

Mình bỏ ORM vì làm data-heavy systems, team đủ lớn để drift xảy ra, và queries đủ phức tạp để ORM syntax thành overhead. Không phải context của tất cả mọi người.


Kết

  • N+1 không phải ORM tạo ra — ORM tạo điều kiện để nó ẩn mình và không bị phát hiện cho đến khi production chậm. Bật SQL logging từ ngày đầu, không phải lúc đang debug incident.
  • Khi mình thấy ORM query dài hơn SQL equivalent, đó là signal đủ tốt để xem lại. Kysely hoặc sqlc không khó học, nhưng cần một buổi chiều setup đúng cách.
  • ORM không xấu — nó chỉ là wrong tool khi query vượt qua CRUD. Mình giữ Prisma cho prototypes, dùng Kysely cho production TypeScript, sqlc cho Go. Không có gì cấm dùng cả hai trong cùng một project.