DynamoDB Single-Table Design: Bắt Đầu Từ Câu Hỏi, Không Phải Từ Bảng

K
Kai··9 min read

Mã ngắn ở bài trước sinh ra rồi biến mất, vì chưa có chỗ lưu. Bài này dựng chỗ đó bằng DynamoDB. Nhưng trước khi tạo bảng, có một việc quan trọng hơn cú pháp: cách nghĩ về thiết kế dữ liệu trong DynamoDB ngược hẳn với những gì quen thuộc từ cơ sở dữ liệu quan hệ. Hiểu sai chỗ này thì sau đó mọi truy vấn đều vướng.

Mục tiêu

Nắm cách tiếp cận thiết kế đi từ access pattern, hiểu partition key và sort key thực sự làm gì, rồi dựng một single-table cho URL shortener với khái niệm item collection. Ta tạo bảng thật, ghi và truy vấn dữ liệu thật để thấy vì sao thiết kế này vừa nhanh vừa rẻ. Bảng dùng chế độ on-demand, chi phí lưu vài item là không đáng kể.

Vì sao không thiết kế như cơ sở dữ liệu quan hệ

Với một RDBMS, bạn chuẩn hóa dữ liệu thành các bảng tách bạch mà chưa cần biết sẽ truy vấn ra sao, rồi lúc cần thì viết JOIN. Tài liệu DynamoDB mô tả đúng sự khác biệt: "In RDBMS, you design for flexibility without worrying about implementation details or performance... In DynamoDB, you design your schema specifically to make the most common and important queries as fast and as inexpensive as possible."

Lý do nằm ở chỗ DynamoDB đánh đổi tính linh hoạt lấy khả năng mở rộng. Tài liệu nói thẳng: "In a NoSQL database such as DynamoDB, data can be queried efficiently in a limited number of ways, outside of which queries can be expensive and slow." Bạn được một số đường truy vấn rất nhanh ở mọi quy mô, đổi lại phải thiết kế những đường đó trước. Hệ quả là một câu gần như thành khẩu lệnh trong tài liệu: "you shouldn't start designing your schema for DynamoDB until you know the questions it will need to answer."

Nên bước đầu tiên không phải vẽ bảng, mà liệt kê câu hỏi.

Access pattern của URL shortener

Đây là những câu hỏi hệ thống phải trả lời, kèm mức độ thường xuyên, vì cái nào chạy nhiều nhất thì phải nhanh nhất:

  1. Mở một link: cho mã ngắn code, lấy URL gốc. Đây là đường nóng, chạy mỗi lần ai đó bấm vào link, phải nhanh nhất.
  2. Tạo một link: ghi một link mới.
  3. Liệt kê link của một người dùng: cho ownerId, lấy các link người đó tạo (cho trang quản lý).
  4. Đếm và xem thống kê click của một link: tăng bộ đếm mỗi lượt mở, và lấy số liệu theo ngày cho dashboard.

Bài này lo pattern 1 và đặt nền cho pattern 4. Pattern 3 (liệt kê theo người dùng) cần một global secondary index và là nội dung bài sau. Việc xác định trước bốn câu hỏi này là cái quyết định toàn bộ thiết kế khóa bên dưới.

Partition key và sort key

Khi tạo bảng, bạn phải khai primary key, thứ định danh duy nhất mỗi item. DynamoDB có hai kiểu. Kiểu đơn giản chỉ gồm partition key. Tài liệu giải thích cơ chế: "DynamoDB uses the partition key's value as input to an internal hash function. The output from the hash function determines the partition (physical storage internal to DynamoDB) in which the item will be stored." Giá trị partition key được băm ra để chọn phân vùng vật lý chứa item.

Kiểu thứ hai là composite key, gồm partition key cộng sort key. Khi đó nhiều item có thể chung một partition key, và chúng được sắp xếp theo sort key bên trong phân vùng đó. Đây là chìa khóa kỹ thuật của single-table design, vì nó cho phép gom các item liên quan vào cùng một phân vùng và lấy chúng theo thứ tự trong một lần truy vấn.

Tài liệu best practices nêu hai nguyên tắc dẫn tới thiết kế tốt. Thứ nhất, "Keep related data together", giữ dữ liệu liên quan ở cùng chỗ, vì locality of reference cải thiện tốc độ. Thứ hai, "Use sort order", thiết kế khóa sao cho các item liên quan sắp xếp cạnh nhau để truy vấn gọn. Và một lời khuyên bao trùm: "maintain as few tables as possible", giữ càng ít bảng càng tốt.

Single-table và item collection

Gộp ba ý đó lại ra mô hình single-table: mọi loại dữ liệu (link, thống kê, sau này là cả người dùng) sống chung trong một bảng, phân biệt nhau bằng tiền tố trong khóa. Bảng có hai thuộc tính khóa đặt tên trung tính là PK (partition key) và SK (sort key), vì chúng sẽ chứa nhiều loại giá trị khác nhau.

Với URL shortener, ta thiết kế khóa thế này:

  PK (partition)        SK (sort)            các thuộc tính
  ──────────────────    ─────────────────    ─────────────────────────────────
  LINK#<code>           META                 target, ownerId, createdAt, clicks
  LINK#<code>           STAT#<ngày>          count

Một item collection là tập các item chung partition key. Ở đây mọi thứ thuộc về link aK9xQ2z (bản thân link và mọi bản ghi thống kê theo ngày của nó) chung PK = LINK#aK9xQ2z, nên chúng nằm cùng một phân vùng:

   PK = LINK#aK9xQ2z   (một item collection)
   ┌─────────────────────────────────────────────────────────┐
   │ SK = META            target=..., ownerId=..., clicks=17   │  ← bản thân link
   │ SK = STAT#2026-05-24 count=5                              │  ← thống kê ngày 24
   │ SK = STAT#2026-05-25 count=12                             │  ← thống kê ngày 25
   └─────────────────────────────────────────────────────────┘
      sắp theo SK:  META  <  STAT#2026-05-24  <  STAT#2026-05-25

Sort key được chọn có chủ đích. META đứng trước mọi STAT#... vì chữ M sắp trước chữ S, nên bản thân link luôn là item đầu trong collection. Các bản ghi thống kê dùng tiền tố STAT# cộng ngày theo dạng YYYY-MM-DD, nên chúng tự sắp theo thứ tự thời gian. Cách đặt khóa này cho ta hai đường truy vấn rất khác nhau từ cùng một dữ liệu, mà sẽ thấy ngay dưới đây.

Tạo bảng thật

Trong template SAM, bảng khai báo trực tiếp bằng AWS::DynamoDB::Table (không dùng SimpleTable vì ta cần sort key):

Table:
  Type: AWS::DynamoDB::Table
  Properties:
    TableName: url-shortener
    BillingMode: PAY_PER_REQUEST
    AttributeDefinitions:
      - { AttributeName: PK, AttributeType: S }
      - { AttributeName: SK, AttributeType: S }
    KeySchema:
      - { AttributeName: PK, KeyType: HASH }   # partition key
      - { AttributeName: SK, KeyType: RANGE }   # sort key

BillingMode: PAY_PER_REQUEST là chế độ on-demand: trả tiền theo từng read/write, không phải cấp phát công suất trước, hợp với tải thất thường của serverless. Một điểm dễ nhầm: AttributeDefinitions chỉ khai những thuộc tính thuộc về khóa, không phải mọi thuộc tính của item. Ngoài PKSK, bảng là schemaless, mỗi item tự mang thuộc tính riêng. Sau sam deploy, bảng ở trạng thái ACTIVE:

$ aws dynamodb describe-table --table-name url-shortener \
    --query 'Table.{Keys:KeySchema,Billing:BillingModeSummary.BillingMode,Status:TableStatus}'
{
    "Keys": [
        { "AttributeName": "PK", "KeyType": "HASH" },
        { "AttributeName": "SK", "KeyType": "RANGE" }
    ],
    "Billing": "PAY_PER_REQUEST",
    "Status": "ACTIVE"
}

Ghi dữ liệu

Ghi một link và hai bản ghi thống kê cho nó. Để ý cả ba item chung PK:

aws dynamodb put-item --table-name url-shortener --item '{
  "PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"META"},
  "target":{"S":"https://docs.aws.amazon.com/dynamodb/"},
  "ownerId":{"S":"user-001"},"createdAt":{"S":"2026-05-24T09:00:00Z"},
  "clicks":{"N":"17"}
}'
aws dynamodb put-item --table-name url-shortener --item \
  '{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"STAT#2026-05-24"},"count":{"N":"5"}}'
aws dynamodb put-item --table-name url-shortener --item \
  '{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"STAT#2026-05-25"},"count":{"N":"12"}}'

Mỗi thuộc tính bọc trong một object chỉ kiểu (S cho string, N cho number). Đây là dạng JSON gốc của DynamoDB; trong code Lambda ta sẽ dùng DocumentClient để khỏi viết tay kiểu, nhưng thấy dạng thô một lần giúp hiểu rõ bên dưới.

Hai đường truy vấn từ cùng dữ liệu

Đường nóng là mở một link: cho code, lấy URL gốc. Vì ta biết chính xác cả PKSK, đây là một GetItem, thao tác rẻ nhất và nhanh nhất của DynamoDB, đọc đúng một item:

$ aws dynamodb get-item --table-name url-shortener \
    --key '{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"META"}}' \
    --query 'Item.{target:target.S,clicks:clicks.N}'
{
    "target": "https://docs.aws.amazon.com/dynamodb/",
    "clicks": "17"
}

Đường thứ hai là lấy toàn bộ item collection của một link, cho dashboard cần cả link lẫn mọi thống kê. Đây là một Query chỉ theo PK, trả về cả collection đã sắp theo SK, trong một lần gọi:

$ aws dynamodb query --table-name url-shortener \
    --key-condition-expression "PK = :pk" \
    --expression-attribute-values '{":pk":{"S":"LINK#aK9xQ2z"}}' \
    --query 'Items[].{SK:SK.S,target:target.S,count:count.N}' --output table
-----------------------------------------------------------------------
|        SK        | count  |                 target                  |
+------------------+--------+-----------------------------------------+
|  META            |  None  |  https://docs.aws.amazon.com/dynamodb/  |
|  STAT#2026-05-24 |  5     |  None                                   |
|  STAT#2026-05-25 |  12    |  None                                   |
+------------------+--------+-----------------------------------------+

Đây là điểm cốt lõi của single-table design, nhìn bằng dữ liệu thật. Một Query lấy về link và cả hai bản ghi thống kê cùng lúc, sắp đúng thứ tự, vì chúng nằm chung một phân vùng. Trong một RDBMS việc này cần JOIN giữa bảng links và bảng stats; ở đây nó là một thao tác đọc một phân vùng, nhanh và rẻ ở mọi quy mô. Đó chính là "keep related data together" mà tài liệu nói tới, hiện ra thành một con số: một lần đọc thay vì một phép nối.

🧹 Dọn dẹp

Các item demo ở trên xóa bằng delete-item cho từng khóa:

for sk in META STAT#2026-05-24 STAT#2026-05-25; do
  aws dynamodb delete-item --table-name url-shortener \
    --key "{\"PK\":{\"S\":\"LINK#aK9xQ2z\"},\"SK\":{\"S\":\"$sk\"}}"
done

Bảng giữ lại để các bài sau tiếp tục dùng (nó là một phần của sản phẩm). Bảng on-demand rỗng gần như không phát sinh chi phí. Nếu bạn muốn dừng hẳn ở đây thì sam delete xóa cả stack lẫn bảng.

Tổng kết

Thiết kế DynamoDB bắt đầu từ các câu hỏi truy vấn, không từ bảng. Partition key chọn phân vùng qua hàm băm, sort key sắp xếp item trong phân vùng, và đặt nhiều loại item chung một partition key tạo ra item collection lấy về gọn trong một query. URL shortener của ta giờ có một single-table với link và thống kê sống chung, cho cả đường đọc một item lẫn đường đọc cả collection.

Còn một access pattern chưa giải: liệt kê link theo người dùng. Khóa hiện tại không trả lời được câu đó, vì ta đâu biết code của một người để mà truy vấn theo PK. Bài sau thêm một global secondary index để mở đúng đường truy vấn đó, và bàn tới sparse index, một kỹ thuật biến chính sự vắng mặt của thuộc tính thành công cụ lập chỉ mục.