Ngưng coi DynamoDB là SQL: Hướng dẫn thực tế về Single-Table Design

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Khoản “thuế ẩn” trong Kiến trúc Serverless của bạn

Thói quen dùng cơ sở dữ liệu quan hệ (SQL) thường rất khó bỏ. Tôi nhớ lần đầu tiên triển khai một schema DynamoDB “chuẩn hóa” (normalized) cho một dự án xử lý 5.000 yêu cầu mỗi giây. Trên máy local, mọi thứ đều mượt mà. Nhưng trên production, độ trễ p99 vọt từ 30ms lên hơn 200ms. Tôi đã thiết kế schema giống như một database PostgreSQL tiêu chuẩn: các bảng riêng biệt cho người dùng (users), đơn hàng (orders) và sản phẩm (products). Trên lý thuyết thì nó trông rất sạch sẽ, nhưng lại là một thảm họa hiệu năng khi chạy trên cloud.

Trong môi trường serverless, độ trễ chính là chi phí. Mỗi mili giây hàm Lambda của bạn phải chờ database phản hồi là tiền bạc đang đội nón ra đi. Các hàm của tôi đã phải thực hiện bốn lệnh gọi mạng riêng biệt chỉ để hiển thị một dashboard người dùng duy nhất. Về cơ bản, tôi đang giả lập các lệnh JOIN ngay trong mã nguồn ứng dụng — một chiến lược vừa chậm chạp, đắt đỏ lại vừa mong manh.

Tại sao giả lập Join thất bại khi mở rộng quy mô

DynamoDB không phải là vấn đề; vấn đề nằm ở việc tôi từ chối từ bỏ các mô hình SQL. Các cơ sở dữ liệu SQL sử dụng CPU để join các bảng tại thời điểm truy vấn. Ngược lại, DynamoDB được xây dựng để có khả năng mở rộng theo chiều ngang (horizontal scalability) và hiệu năng ổn định. Nó cố tình lược bỏ toán tử JOIN để đảm bảo tốc độ không bao giờ bị chậm lại, dù bạn có 1.000 hay 10 tỷ bản ghi.

Khi bạn cố ép các cấu trúc quan hệ vào DynamoDB, bạn sẽ vấp phải ba bức tường:

  • Bùng nổ băng thông mạng: Mỗi lệnh gọi GetItem là một vòng hồi đáp (round-trip). Việc thay thế một truy vấn bằng bốn lệnh gọi sẽ biến một tác vụ 15ms thành một cuộc chạy marathon 60ms.
  • Lỗ hổng nhất quán: Giữ dữ liệu đồng bộ trên năm bảng yêu cầu TransactWriteItems. Những lệnh này đắt gấp đôi so với ghi thông thường và đi kèm với giới hạn nghiêm ngặt 100 item mỗi lần.
  • Bẫy cấu hình: Quản lý các IAM role, cài đặt TTL và auto-scaling cho 30 bảng khác nhau là một cơn ác mộng vận hành và nó chỉ ngày càng tồi tệ theo thời gian.

Bước chuyển mình về kiến trúc: Một bảng để thống trị tất cả

Nếu bạn đang xây dựng một nền tảng thương mại điện tử, bạn có hai con đường. Bạn có thể làm theo cách cũ, hoặc bạn có thể tối ưu hóa cho cloud.

Sự dàn trải nhiều bảng (Multi-Table Sprawl)

Đây là kiểu “SQL nửa mùa” (Relational-lite). Bạn có các bảng Users, Orders, và Products. Để xem khách hàng đã mua gì, bạn phải truy cập cả ba bảng. Điều này đi ngược lại kiến trúc cốt lõi của DynamoDB và khiến các hàm Lambda của bạn phải chạy không tải (idle) trong khi chờ dữ liệu.

Mô hình hợp nhất một bảng (Single-Table Unified Model)

Single-table design hợp nhất mọi thực thể vào một phân vùng (partition) duy nhất. Bạn phân biệt giữa “Người dùng” và “Đơn hàng” bằng các khóa (key) chung. Điều này cho phép bạn lấy thông tin profile người dùng và năm đơn hàng gần nhất của họ chỉ trong một lần Query duy nhất. Đối với những người quen dùng SQL, điều này có vẻ lộn xộn, nhưng nó là cách hiệu quả nhất để tận dụng phần cứng.

Chiến lược xây dựng Schema hiệu năng cao

Chuyển sang mô hình single-table đòi hỏi bạn phải đảo ngược quy trình thiết kế. Đây là cách tôi xây dựng các schema luôn giữ được tốc độ bất kể lưu lượng truy cập lớn đến đâu.

1. Thiết kế cho truy vấn, không phải cho đối tượng

Hãy vứt bỏ các Sơ đồ quan hệ thực thể (ERD) đi. Trong SQL, bạn mô hình hóa dữ liệu trước. Trong DynamoDB, bạn mô hình hóa các mẫu truy cập (access patterns) trước. Trước khi chạm vào AWS Console, tôi liệt kê mọi truy vấn mà ứng dụng cần. Ví dụ:

  • Lấy profile người dùng theo UUID.
  • Liệt kê tất cả đơn hàng của Người dùng X, sắp xếp theo mốc thời gian.
  • Tìm tất cả sản phẩm trong danh mục ‘Điện tử’ có giá dưới $50.

2. Sức mạnh của Key Overloading (Chồng chéo khóa)

Vì chúng ta chỉ có một bộ khóa chính, chúng ta sẽ bắt chúng làm việc gấp đôi. Chúng ta sử dụng các tên chung như PK (Partition Key) và SK (Sort Key). Đây chính là Key Overloading.

# Cấu trúc dữ liệu single-table điển hình
[
    {"PK": "USER#445", "SK": "PROFILE#445", "Name": "Jane Smith", "Tier": "Premium"},
    {"PK": "USER#445", "SK": "ORDER#2024-05-01", "Total": 89.99, "Status": "Shipped"},
    {"PK": "USER#445", "SK": "ORDER#2024-05-10", "Total": 12.50, "Status": "Pending"},
    {"PK": "PROD#SKU-99", "SK": "DETAIL#SKU-99", "Price": 45.00, "Stock": 12}
]

Lưu ý cách USER#445 nhóm profile và các đơn hàng lại với nhau. Bằng cách truy vấn PK = 'USER#445', tôi lấy được thông tin định danh của người dùng và toàn bộ lịch sử đơn hàng của họ chỉ trong một lần gọi mạng duy nhất kéo dài 10ms.

3. Lọc dữ liệu với GSI và Sparse Index (Chỉ mục thưa)

Nếu bạn cần tìm một đơn hàng theo ID duy nhất mà không biết người dùng thì sao? Đây là lúc Global Secondary Indexes (GSI) tỏa sáng. Bạn có thể chiếu (project) chỉ OrderId vào một index mới. Để tiết kiệm bộ nhớ, chỉ điền dữ liệu vào index này cho các đơn hàng “Đang chờ xử lý” (Pending). Điều này tạo ra một Sparse Index, cho phép bạn quét hàng ngàn đơn hàng trong khi chỉ phải trả phí cho một số ít đơn hàng thực sự cần chú ý.

4. Giải quyết quan hệ Nhiều-Nhiều với Adjacency List (Danh sách kề)

Quản lý sinh viên và các khóa học? Đừng dùng bảng trung gian (join table). Hãy sử dụng Adjacency List. Lưu trữ hai item cho mỗi lần đăng ký:

  1. PK: STUDENT#S101, SK: COURSE#C202
  2. PK: COURSE#C202, SK: STUDENT#S101

Giờ đây bạn có thể trả lời các câu hỏi “Sinh viên X đang học những lớp nào?” và “Ai đang đăng ký Lớp Y?” bằng cách sử dụng chính logic bảng đó.

Xác thực trước khi triển khai

Lỗi mô hình hóa rất tốn kém để sửa chữa một khi bạn đã có hàng triệu dòng dữ liệu. Tôi luôn sử dụng một script nhanh để kiểm tra các giả định của mình với thư viện boto3 trước khi chốt schema.

import boto3
from boto3.dynamodb.conditions import Key

table = boto3.resource('dynamodb').Table('ProductionStore')

# Xác thực: Chúng ta có thể lấy người dùng và các đơn hàng trong một lần gọi không?
def fetch_customer_bundle(user_id):
    return table.query(
        KeyConditionExpression=Key('PK').eq(f'USER#{user_id}')
    )['Items']

# Một yêu cầu, trả về nhiều kiểu dữ liệu.
print(fetch_customer_bundle('445'))

Làm chủ tư duy

Single-table design không phải là về sự phức tạp; nó là về sự am hiểu hệ thống (mechanical sympathy). Bằng cách điều chỉnh dữ liệu phù hợp với cách DynamoDB thực sự lưu trữ dữ liệu trên đĩa, bạn loại bỏ nhu cầu sử dụng JOIN và đảm bảo backend luôn nhanh như chớp. Hãy bắt đầu với NoSQL Workbench để trực quan hóa các khóa chồng chéo này. Sẽ cần thời gian thực hành, nhưng đó là cách duy nhất để xây dựng các hệ thống serverless thực sự có khả năng mở rộng mà không làm “cháy túi” của bạn.

Share: