Data Models & Query Languages — Cách dữ liệu được tổ chức và truy vấn (DDIA)

Phong

Mở đầu

Ảnh: Paras Katwal — Pexels

Tiếp tục series DDIA, hôm nay chúng ta bước qua Chương 2 — "Data Models & Query Languages". Đây là một trong những chương nền tảng nhất trong cuốn sách, bởi vì cách bạn mô hình hoá dữ liệu sẽ ảnh hưởng đến mọi thứ phía sau: cách lưu trữ, cách truy vấn, cách scale, và cả cách bảo trì.

Hầu hết ứng dụng đều được xây dựng bằng cách xếp chồng các lớp (layer) lên nhau. Mỗi lớp ẩn đi sự phức tạp của lớp bên dưới và đưa ra một mô hình dữ liệu rõ ràng. Một ứng dụng web có thể:

  • Developer nhìn thấy object trong code (OOP)
  • ORM chuyển object đó thành JSON/struct
  • Database lưu dưới dạng bảng (relational) hoặc document (NoSQL)
  • OS lưu thành bytes trên disk
  • CPU xử lý như điện tích trong silicon

Vấn đề là: mỗi lớp chuyển đổi đều có chi phí. Nếu data model của layer trên khớp với data model của layer dưới, thì mọi thứ trơn tru. Nếu không — bạn sẽ phải đối mặt với cái gọi là "impedance mismatch" (sự không tương thích trở kháng).

Relational Model vs Document Model

Ảnh: Tim Mossholder — Pexels

Relational model (SQL) — thống trị từ thập niên 80. Dữ liệu được tổ chức thành các quan hệ (bảng), mỗi quan hệ là một tập hợp các bộ (rows). Các quan hệ có thể liên kết với nhau qua foreign keys. SQL là ngôn ngữ truy vấn declarative — bạn chỉ cần nói cái gì bạn muốn, không phải nói làm thế nào.

Document model (NoSQL) — thịnh hành từ cuối những năm 2000, với MongoDB, CouchDB, Firebase Firestore. Dữ liệu được lưu dưới dạng document — JSON hoặc XML — với cấu trúc lồng nhau (nested). Document model nổi lên vì ba lý do:

  • Schema flexibility — dev muốn linh hoạt, không muốn alter table mỗi lần đổi model
  • Object-relational impedance mismatch — code OOP map vào bảng quan hệ luôn awkward, ORM chỉ là miếng băng keo
  • Scale-out — document database dễ sharding hơn (trong quan niệm ban đầu)

Nhưng thực ra, câu chuyện không đơn giản chỉ là "chọn cái nào tốt hơn". Mỗi model có ưu nhược điểm, và tuỳ vào use case mà bạn chọn.

Many-to-One và Many-to-Many

Điểm khác biệt lớn nhất là cách xử lý quan hệ. Trong relational model, dùng foreign key là chuyện bình thường. Trong document model, bạn thường nhúng dữ liệu (embedding) thay vì dùng reference — dẫn đến việc duplicate dữ liệu.

DDIA chỉ ra một vấn đề thú vị: hầu hết ứng dụng đều có nhiều quan hệ many-to-one và many-to-many. Một địa điểm tổ chức sự kiện có thể được phân loại (một khu vực có nhiều sự kiện — many-to-one). Một người dùng làm việc ở nhiều công ty (many-to-many). Với document model thuần tuý (nhúng hết), việc cập nhật dữ liệu là nightmare — bạn phải tìm và update tất cả document chứa thông tin đó.

Đây là lúc mà document model bộc lộ điểm yếu. Các document database hiện đại (MongoDB 4.0+ đã hỗ trợ multi-document ACID transactions) đã giải quyết phần nào, nhưng về bản chất, việc mô hình hoá quan hệ many-to-many vẫn dễ dàng hơn trong relational model.

Schema-on-Read vs Schema-on-Write

Ảnh: Ilya — Pexels

Một điểm nhức nhối khi chọn giữa hai model là schema flexibility:

  • Schema-on-write (relational) — bạn phải định nghĩa schema trước (CREATE TABLE), và mọi dữ liệu ghi vào đều phải tuân theo schema đó. Muốn thêm field? Phải ALTER TABLE — migration.
  • Schema-on-read (document) — bạn không cần khai báo trước. Mỗi document có thể có cấu trúc khác nhau. Khi đọc dữ liệu, application tự quyết định cách xử lý.

Ví dụ trong sách: tưởng tượng bạn đang lưu thông tin người dùng. Một ngày nọ, team product quyết định thêm trường "education" cho profile. Với document database, bạn chỉ việc ghi thêm field vào document mới — không cần migration. Với SQL, bạn phải ALTER TABLE — và nếu table có 10 triệu rows, migration đó tốn thời gian và có thể gây downtime.

Nhưng schema flexibility không phải lúc nào cũng tốt. Nếu dữ liệu của bạn cần tính nhất quán cao (ví dụ: tài chính) và cấu trúc ít thay đổi, schema-on-write sẽ giúp bạn phát hiện lỗi sớm (tại write time) thay vì phát hiện muộn khi đọc dữ liệu (read time) và phải xử lý exception.

Declarative vs Imperative Query Languages

Một trong những insight thú vị nhất của chương này là sự so sánh giữa declarative (SQL) và imperative code (ví dụ: lặp for để filter data trong code application).

Imperative: bạn mô tả từng bước để đạt được kết quả.

def get_gà_viên(orders):
    result = []
    for order in orders:
        for item in order.items:
            if item.name == "gà viên" and item.quantity > 0:
                result.append((order.id, item.price))
    return result

Declarative (SQL): bạn mô tả kết quả mong muốn, database tự lo cách thực thi.

SELECT order_id, price FROM items
WHERE name = 'gà viên' AND quantity > 0;

Declarative có lợi thế gì? Database có thể tối ưu — nó thêm index, thay đổi execution plan, thậm chí chạy parallel mà bạn không cần sửa query. Với imperative code, nếu bạn muốn thêm index, bạn phải tự sửa logic lặp.

DDIA đưa ra ví dụ minh hoạ: trình duyệt web. CSS là declarative — bạn nói "cái button này màu đỏ", trình duyệt tự lo render. Còn JavaScript DOM manipulation là imperative — bạn phải tự tính toán từng bước, và nếu trình duyệt thay đổi cách render, code của bạn hỏng hết.

MapReduce — Middle ground

MapReduce là một mô hình query nằm giữa declarative và imperative. Bạn viết hàm xử lý bằng mã (JavaScript, Python) nhưng framework tự động phân phối execution trên cluster. MongoDB có mapReduce riêng, và các hệ thống như Hadoop vận hành theo nguyên lý này.

Ví dụ: bạn muốn đếm số lần xuất hiện của mỗi loại động vật trong một bộ sưu tập document. Mỗi document là một observation:

// Map phase: emit key-value pairs
function map() {
    emit(this.species, 1);
}
// Reduce phase: aggregate by key
function reduce(key, values) {
    return Array.sum(values);
}

Điểm mạnh của MapReduce là khả năng mở rộng trên cluster. Điểm yếu là bạn viết code thủ công (imperative) cho mỗi bước — database không thể tự động tối ưu như SQL.

Key Takeaways

  • Không có silver bullet — relational tốt cho dữ liệu có cấu trúc chặt chẽ, nhiều quan hệ; document tốt cho dữ liệu linh hoạt, ít quan hệ many-to-many
  • Schema flexibility là con dao hai lưỡi — schema-on-read cho bạn tốc độ phát triển ban đầu, nhưng có thể dẫn đến nợ kỹ thuật nếu data không được kiểm soát
  • Declarative > Imperative — SQL cho phép database tối ưu execution, giúp hệ thống linh hoạt hơn trước sự thay đổi của underlying infrastructure
  • MapReduce — cầu nối giữa hai thế giới, hữu ích khi cần xử lý data phức tạp trên cluster nhưng mất đi khả năng tối ưu của query optimizer
  • Xu hướng hiện tại — nhiều hệ thống đang kết hợp cả hai: PostgreSQL hỗ trợ JSONB, MongoDB có joins (lookup), các NewSQL database (CockroachDB, TiDB) mang tính relational + khả năng scale-out

Glossary

Data ModelCách tổ chức và biểu diễn dữ liệu — relational, document, graph, v.v.
Schema-on-WriteSchema được định nghĩa trước, dữ liệu vi phạm schema bị từ chối khi ghi
Schema-on-ReadSchema được áp dụng khi đọc dữ liệu, data có thể có cấu trúc linh hoạt khi lưu
Impedance MismatchSự không tương thích giữa OOP objects và relational tables — phải có ORM làm cầu nối
Declarative QueryBạn mô tả kết quả mong muốn, hệ thống tự lo cách thực thi (SQL, CSS)
MapReduceMô hình xử lý phân tán: Map (transform + emit key-value) + Reduce (aggregate theo key)
Many-to-OneNhiều bản ghi tham chiếu đến một bản ghi duy nhất (VD: nhiều sinh viên học một lớp)
Many-to-ManyNhiều bản ghi liên kết với nhiều bản ghi khác (VD: sinh viên tham gia nhiều lớp, mỗi lớp có nhiều sinh viên)

Kết

Chương 2 của DDIA đặt nền tảng cho việc hiểu về data models — một kỹ năng mà backend engineer nào cũng cần. Nó không khuyên bạn nên chọn "cái nào tốt nhất" mà chỉ ra tradeoffs giữa các lựa chọn.

Trong bài tiếp theo của series, chúng ta sẽ bước sang Chương 2.2 — Graph Data Models và các ngôn ngữ truy vấn đồ thị: Cypher, SPARQL, và Datalog. Nếu bạn từng thắc mắc "sao lại phải dùng Neo4j thay vì PostgreSQL?", bài đó sẽ giải thích.

PS: Nếu chưa đọc series từ đầu, bạn có thể xem lại bài trước về Reliability & Scalability — Hai trụ cột của hệ thống backendMaintainability & Summary.