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 — nối chuỗi để build query là mở cửa cho SQL injection, bất kể có ORM hay không. Raw SQL parameterized đúng cách (dùng placeholder $1, ? qua database/sql, psycopg2, v.v.) an toàn tương đương ORM về injection, chỉ là không có type safety ở compile time.

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. Không có N+1 ẩn vì không có relationship magic.

Một điểm cần biết: generic type như db.fn.sum<number>('o.total') chỉ là compile-time hint mình tự khai — Kysely không verify nó khớp với type thật của cột trong Postgres. NUMERIC/DECIMAL trong Postgres, qua driver pg mặc định, trả về dưới dạng string, không phải number (tránh mất precision khi convert qua JS float). Khai <number> mà không config type parser tương ứng thì lúc runtime results[0].revenue vẫn là string — TypeScript compile qua mà không báo gì.

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) {
    // ...
}

Generated code luôn khớp với query trong file .sql — không có drift giữa querycode gọi query đó. Nhưng sqlc không phải công cụ migration: nó không biết gì về schema thật trên production, chỉ đọc lại schema từ file migration mình trỏ vào lúc generate. Ai đó ALTER TABLE thẳng tay trên production như câu chuyện metadata jsonb ở trên — sqlc không phát hiện được. Muốn tránh drift giữa schema thật và migration history vẫn phải kỷ luật: mọi thay đổi schema đi qua migration, không táy máy trực tiếp trên DB.


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",
            }
        }
    }
})

Một lệnh create với nested relationship, Prisma tự lo insert vào users rồi profiles với đúng foreign key, trong một transaction. Viết tay thì phải hai câu INSERT liên tiếp — insert users trước, lấy id vừa tạo, insert profiles với user_id đó, bọc trong transaction để không half-write nếu câu thứ hai fail. Không sai gì, chỉ là nhiều boilerplate hơn cho use case CRUD thuần túy.

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.