Skip to content

Data Modeling: Normalized References vs Embedded Data

Domain: System Design → Data Modeling
Tags: normalization data-consistency caching query-strategy cms event-driven
Difficulty: Intermediate
Last updated: 2026-06


1. Bối cảnh & Vấn đề

Trong nhiều hệ thống có CMS + Application Server, một entity (ví dụ: Game) thường quan hệ với một entity phụ (ví dụ: Tag). Câu hỏi thiết kế nảy sinh:

Nên lưu full data của entity phụ vào trong entity chính, hay chỉ lưu reference (ID)?

Đây là bài toán cốt lõi của data normalization trong ngữ cảnh không phải relational database thuần túy (ví dụ: DynamoDB, document store, event-driven sync).


2. Hai Trường Phái Thiết Kế

2.1 Embedded (Denormalized)

Khi publish entity chính, toàn bộ data của entity phụ được sao chép vào trong.

// Stored in DB
{
  "id": "game-001",
  "name": "Super Slots",
  "tag": {
    "id": "tag-1",
    "name": "Slots",
    "color": "#FF0000",
    "icon": "🎰"
  }
}

Đọc: 1 query duy nhất → trả về đầy đủ data.
Ghi: Mỗi lần publish game, round-trip sang CMS để lấy full tag.


2.2 Normalized References

Entity chính chỉ lưu ID của entity phụ. Entity phụ tồn tại độc lập trong DB của mình.

// Game stored in DB
{
  "id": "game-001",
  "name": "Super Slots",
  "tagId": "tag-1"
}

// Tag stored separately in DB
{
  "id": "tag-1",
  "name": "Slots",
  "color": "#FF0000",
  "icon": "🎰"
}

Đọc: 2 queries (hoặc JOIN) → ghép data ở application layer.
Ghi: Publish game và publish tag hoàn toàn độc lập.


3. Trade-off Phân Tích

Tiêu chíEmbeddedNormalized Reference
Read performance✅ 1 query⚠️ 2+ queries (mitigable)
Write simplicity✅ Đơn giản✅ Đơn giản hơn (độc lập)
Data consistency❌ Stale risk✅ Single source of truth
Entity evolution❌ Thay đổi tag = re-publish game✅ Tag thay đổi độc lập
Storage❌ Duplicate data✅ Normalized
Query complexity✅ Thấp⚠️ Cao hơn (cần strategy)

4. Rủi Ro Của Embedded: Stale Data

Đây là vấn đề nguy hiểm nhất của embedded mà dễ bị bỏ qua khi thiết kế ban đầu.

Scenario:

T=0: Game A, B, C được publish với tag { id: 1, name: "Slots" }
T=1: Marketing team đổi tag thành "Slot Games" và re-publish tag
T=2: Game A, B, C vẫn hiển thị "Slots" cho end user ← STALE DATA

Để fix stale data với embedded approach, cần một trong hai:

  • Re-publish toàn bộ game liên quan (cascade operation, tốn công)
  • Implement invalidation logic (phức tạp, dễ miss edge case)

Với normalized reference, bước T=2 không xảy ra — end user tự động thấy "Slot Games".


5. Giải Quyết Performance Cho Normalized Reference

Concern thường gặp: "Thêm query = chậm hơn cho end user."

Đây là concern hợp lý, nhưng có nhiều cách mitigate.

❌ Anti-pattern: N+1 Query

typescript
// BAD - N+1 problem
const games = await getGames();           // 1 query
for (const game of games) {
  game.tag = await getTag(game.tagId);    // N queries
}
// Tổng: 1 + N queries

✅ Pattern 1: Batch Query

typescript
// GOOD - chỉ 2 queries tổng cộng
const games = await getGames();                          // query 1
const tagIds = [...new Set(games.map(g => g.tagId))];
const tags = await getTagsByIds(tagIds);                 // query 2 (batch)

const tagMap = Object.fromEntries(tags.map(t => [t.id, t]));
const enriched = games.map(g => ({ ...g, tag: tagMap[g.tagId] }));

Tags là low-cardinality data — một platform thường chỉ có vài chục đến vài trăm tags, thay đổi rất ít. Đây là ứng viên lý tưởng cho in-memory cache.

typescript
// Cache tags tại Lambda initialization (warm state)
let tagCache: Map<string, Tag> | null = null;

async function getTagCache(): Promise<Map<string, Tag>> {
  if (tagCache) return tagCache;                    // cache hit
  const tags = await getAllTagsFromDB();
  tagCache = new Map(tags.map(t => [t.id, t]));
  return tagCache;
}

// Khi tag được publish/updated → invalidate cache
async function onTagPublished(tag: Tag) {
  tagCache = null; // force reload on next request
}

// Khi get games → O(1) lookup, không thêm query
const games = await getGames();
const cache = await getTagCache();
const enriched = games.map(g => ({ ...g, tag: cache.get(g.tagId) }));

Kết quả: End user experience tương đương hoặc tốt hơn embedded, vì không thêm DB query nào cả sau lần cache đầu tiên.


6. Khi Nào Dùng Cái Nào?

Dùng Embedded khi:

  • Entity phụ không bao giờ thay đổi sau khi được associate (ví dụ: snapshot tại thời điểm publish — intentional)
  • Cần audit trail: "Tag của game này trông như thế nào tại thời điểm publish?"
  • Hệ thống đơn giản, entity phụ không có vòng đời độc lập

Dùng Normalized Reference khi:

  • Entity phụ có thể thay đổi và cần reflect ngay lập tức
  • Entity phụ được share bởi nhiều entity chính
  • Entity phụ có vòng đời riêng (được tạo/sửa/xóa độc lập)
  • Cần tránh cascade re-publish khi data thay đổi

7. Mental Model Để Ghi Nhớ

Hỏi: Entity phụ có vòng đời riêng không?

  • Không (gắn chặt với entity chính) → Embedded
  • (tồn tại và thay đổi độc lập) → Normalized Reference

Hỏi: Khi entity phụ thay đổi, entity chính có cần reflect không?

  • Không cần (snapshot là OK) → Embedded
  • Cần ngay lập tức → Normalized Reference

8. Điểm Mấu Chốt (Key Takeaways)

  1. Stale data nguy hiểm hơn extra query — stale data là bug trên production, extra query là performance issue có thể optimize.

  2. Performance concern không nên dẫn đến bad data model — optimize query strategy trước khi thỏa hiệp về consistency.

  3. N+1 là implementation problem, không phải architectural problem — normalized reference không tự sinh ra N+1, cách implement mới tạo ra N+1.

  4. Low-cardinality reference data nên được cache — không phải mọi "extra query" đều thực sự hit database.

  5. Single Source of Truth là nguyên tắc nền tảng — mỗi piece of data chỉ nên có một owner duy nhất.


9. Liên Quan & Đọc Thêm

  • Event-driven sync: Khi tag publish → fire event → invalidate/update downstream cache
  • CQRS: Read model có thể denormalize (embed) nhưng write model vẫn normalized
  • Database normalization forms: 1NF, 2NF, 3NF — lý thuyết nền tảng
  • DynamoDB single-table design: Normalized reference đặc biệt quan trọng trong NoSQL

Today I Learned