DynamoDB: key design, GSI/LSI, capacity mode, DAX, Streams, TTL, Global Tables
Tiếp đi: DynamoDB. Giỏ hàng của shop-app đang lưu trong PostgreSQL, bảng cart_item toàn insert/delete nhỏ, đến đợt sale thì RDS db.t3.micro hết connection trước khi hết CPU. Mình nghe nói giỏ hàng và session nên đưa sang DynamoDB, nhưng mình quen SQL, không rõ bắt đầu từ đâu.
Giỏ hàng và session là hai ví dụ kinh điển cho DynamoDB: truy cập theo một key rõ ràng (userId, sessionId), đọc ghi nhỏ, số lượng rất lớn, không cần JOIN. Ở bài 22.7 ta đã có shop-db PostgreSQL cho đơn hàng, sản phẩm, thanh toán. Bài này tách giỏ hàng ra bảng shop-carts và session ra shop-sessions, hai bảng DynamoDB nằm ngoài VPC, không tốn connection của RDS.
1. Bài toán: giỏ hàng trên RDS trong đợt sale
-- Bảng hiện tại trong shop-db (PostgreSQL)
cart_item(user_id, product_id, qty, price_snapshot, added_at)
-- Mỗi lần người dùng bấm "thêm vào giỏ"
INSERT INTO cart_item ... ON CONFLICT (user_id, product_id) DO UPDATE ...
-- Mỗi lần mở trang giỏ
SELECT * FROM cart_item WHERE user_id = ?
Đợt flash sale: 20.000 người online, mỗi người bấm thêm/xóa vài lần một phút. Số liệu quan sát:
- RDS
db.t3.microgiới hạn khoảng vài chục đến hơn trăm connection; HikariCP trên 4 instance app đã chiếm hết. - CPU DB chỉ ~30%, nhưng request đứng chờ connection, p99 giỏ hàng lên 2–3 giây.
- Giỏ hàng là dữ liệu "tạm", 7 ngày không đụng thì xóa. Hiện phải viết cron
DELETE ... WHERE added_at < now() - interval '7 days', chạy đêm, lock bảng.
2. Vấn đề
- Scale theo chiều dọc: RDS scale bằng cách đổi instance to hơn (downtime, tốn tiền), hoặc thêm Read Replica (giỏ hàng là write-heavy, replica không giúp).
- Connection là tài nguyên hữu hạn: JDBC pool + nhiều instance app = nghẽn connection trước khi nghẽn CPU. RDS Proxy (bài 22.7) đỡ được một phần, nhưng không đổi bản chất.
- Truy cập không cần quan hệ: giỏ hàng chỉ cần "lấy toàn bộ item của user X" và "thêm/xóa 1 item". Trả tiền cho ACID đa bảng, JOIN, planner mà không dùng.
- Dọn dữ liệu hết hạn phải tự làm, tốn IOPS lúc chạy batch.
- Session của HTTP đặt trong RDS còn tệ hơn: mỗi request đọc + ghi session, DB thành điểm nghẽn toàn hệ thống.
3. Giải pháp: DynamoDB
Amazon DynamoDB là NoSQL key-value / document, serverless, fully managed, trả tiền theo request hoặc theo capacity, độ trễ một chữ số mili-giây ở mọi quy mô. Không có server, không có connection pool, gọi qua HTTPS API (SDK ký request bằng IAM). Các thuật ngữ đề thi hay dùng:
| Thuật ngữ | Nghĩa | Trên shop-app |
|---|---|---|
| Table | Bảng, không có schema cố định ngoài key | shop-carts, shop-sessions |
| Item | Một "dòng", tối đa 400 KB | Một item trong giỏ của một user |
| Attribute | Trường của item; kiểu S, N, B, BOOL, L, M, SS, NS… | qty (N), name (S), options (M) |
| Partition key (PK, hash key) | Bắt buộc; quyết định item nằm ở partition nào (hash) | userId |
| Sort key (SK, range key) | Tùy chọn; sắp thứ tự item trong cùng PK; PK+SK = primary key duy nhất | itemId |
| GSI (Global Secondary Index) | Index với PK/SK khác bảng; tạo bất kỳ lúc nào; capacity riêng; eventually consistent | Tìm giỏ chứa productId X (để báo "hết hàng") |
| LSI (Local Secondary Index) | Cùng PK, SK khác; chỉ tạo lúc tạo bảng; tối đa 5; dùng chung capacity; hỗ trợ strongly consistent read | Sắp giỏ của user theo addedAt |
| RCU / WCU | Read/Write Capacity Unit: 1 RCU = 1 strongly consistent read 4 KB/s (hoặc 2 eventually); 1 WCU = 1 write 1 KB/s | Tính tiền ở provisioned mode |
| On-demand | Trả theo request (RRU/WRU), không cần dự đoán | Lab dùng mode này |
| DAX | In-memory cache đặt trước DynamoDB, API tương thích, micro-giây | Trang chi tiết sản phẩm đọc cực nhiều |
| Streams | Change log của bảng (24 giờ), trigger Lambda / Kinesis | Giỏ thay đổi → cập nhật gợi ý |
| TTL | Attribute epoch giây; hết hạn thì DynamoDB tự xóa (miễn WCU) | Giỏ 7 ngày, session 30 phút |
| Global Tables | Multi-region, multi-active replication | Mở shop ở Nhật, user hai nơi đọc gần |
Tư duy khác SQL hoàn toàn. Trong SQL: thiết kế bảng chuẩn hóa trước, viết query sau. Trong DynamoDB: liệt kê access pattern trước, thiết kế key để mỗi pattern là một Query (không phải Scan).
Access pattern của giỏ hàng → Key / index
──────────────────────────────────────────────────────────────
1. Lấy toàn bộ giỏ của user U → Query PK = userId
2. Thêm / sửa / xóa 1 item → PutItem / UpdateItem / DeleteItem (userId, itemId)
3. Giỏ của user U sắp theo thời gian → LSI SK = addedAt (hoặc để SK = addedAt#itemId)
4. Ai đang có sản phẩm P trong giỏ → GSI PK = productId
5. Tự xóa giỏ sau 7 ngày → TTL attribute expiresAt
4. Code / cấu hình từng bước
Bước 1: Tạo bảng bằng CLI (on-demand, có GSI, có tag chung).
export AWS_REGION=ap-southeast-1
aws dynamodb create-table --profile shop-lab \
--table-name shop-carts \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=itemId,AttributeType=S \
AttributeName=productId,AttributeType=S \
--key-schema AttributeName=userId,KeyType=HASH AttributeName=itemId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--global-secondary-indexes '[{
"IndexName": "productId-index",
"KeySchema": [{"AttributeName":"productId","KeyType":"HASH"}],
"Projection": {"ProjectionType":"KEYS_ONLY"}
}]' \
--tags Key=Project,Value=shop-app Key=Env,Value=lab
Chỉ attribute nào nằm trong key (bảng hoặc index) mới cần khai báo trong --attribute-definitions. qty, name, price không cần khai.
Bước 2: Dependency (AWS SDK v2 + Enhanced Client).
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.29.x</version> <!-- dùng bản mới nhất 2.x -->
<type>pom</type><scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb-enhanced</artifactId>
</dependency>
<dependency> <!-- HTTP client nhẹ, khởi động nhanh -->
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>
</dependencies>
Bước 3: Bean client. Credential lấy từ default provider chain: ở EC2 là shop-app-ec2-role, ở local là profile shop-lab (xem lại devops 18.5). Không có access key trong code.
@Configuration
public class DynamoConfig {
@Bean
DynamoDbClient dynamoDbClient(@Value("${aws.region:ap-southeast-1}") String region) {
return DynamoDbClient.builder()
.region(Region.of(region))
.httpClientBuilder(UrlConnectionHttpClient.builder())
.build(); // credential: default provider chain
}
@Bean
DynamoDbEnhancedClient enhancedClient(DynamoDbClient client) {
return DynamoDbEnhancedClient.builder().dynamoDbClient(client).build();
}
@Bean
DynamoDbTable<CartItem> cartTable(DynamoDbEnhancedClient enhanced,
@Value("${shop.cart.table:shop-carts}") String name) {
return enhanced.table(name, TableSchema.fromBean(CartItem.class));
}
}
Bước 4: Entity với Enhanced Client (bean annotation, giống JPA nhưng không có quan hệ).
@DynamoDbBean
public class CartItem {
private String userId;
private String itemId; // = productId hoặc productId#variant
private String productId;
private String name;
private int qty;
private long priceSnapshot; // VND, lưu số nguyên
private long addedAt; // epoch millis
private long expiresAt; // epoch SECONDS, dùng cho TTL
@DynamoDbPartitionKey
public String getUserId() { return userId; }
@DynamoDbSortKey
public String getItemId() { return itemId; }
@DynamoDbSecondaryPartitionKey(indexNames = "productId-index")
public String getProductId() { return productId; }
// getter/setter còn lại...
}
Bước 5: Repository. Ba thao tác chính đều là single-key, không có Scan.
@Repository
public class CartRepository {
private final DynamoDbTable<CartItem> table;
public CartRepository(DynamoDbTable<CartItem> table) { this.table = table; }
public List<CartItem> findByUser(String userId) {
return table.query(QueryConditional.keyEqualTo(Key.builder().partitionValue(userId).build()))
.items().stream().toList();
}
public void upsert(CartItem item) {
item.setExpiresAt(Instant.now().plus(7, ChronoUnit.DAYS).getEpochSecond());
table.putItem(item); // ghi đè toàn item
}
/** Tăng số lượng atomic, không cần đọc trước (tránh race) */
public void addQty(String userId, String itemId, int delta) {
table.updateItem(r -> r.item(partial(userId, itemId, delta))
.ignoreNulls(true));
}
public void remove(String userId, String itemId) {
table.deleteItem(Key.builder().partitionValue(userId).sortValue(itemId).build());
}
}
Với phép tăng giảm atomic thật sự (SET qty = qty + :d) Enhanced Client chưa tiện bằng low-level client; viết thẳng UpdateItemRequest:
UpdateItemRequest req = UpdateItemRequest.builder()
.tableName("shop-carts")
.key(Map.of("userId", AttributeValue.fromS(userId),
"itemId", AttributeValue.fromS(itemId)))
.updateExpression("SET qty = if_not_exists(qty, :zero) + :d, expiresAt = :exp")
.conditionExpression("attribute_not_exists(qty) OR qty + :d <= :max")
.expressionAttributeValues(Map.of(
":d", AttributeValue.fromN(String.valueOf(delta)),
":zero", AttributeValue.fromN("0"),
":max", AttributeValue.fromN("99"),
":exp", AttributeValue.fromN(String.valueOf(expiresAt))))
.build();
client.updateItem(req); // ném ConditionalCheckFailedException nếu vượt 99
ConditionExpression là cách DynamoDB làm optimistic locking không cần transaction: điều kiện sai thì ghi bị từ chối, không có "đọc rồi ghi" bị đè.
Bước 6: Query nhanh bằng PartiQL (SQL-like, tiện khi debug, vẫn phải có PK trong WHERE để không thành Scan).
aws dynamodb execute-statement --profile shop-lab \
--statement "SELECT itemId, qty, name FROM \"shop-carts\" WHERE userId = 'u-1001'"
# Qua GSI
aws dynamodb execute-statement --profile shop-lab \
--statement "SELECT userId FROM \"shop-carts\".\"productId-index\" WHERE productId = 'p-42'"
Bước 7: Bật TTL.
aws dynamodb update-time-to-live --profile shop-lab --table-name shop-carts \
--time-to-live-specification Enabled=true,AttributeName=expiresAt
Item có expiresAt nhỏ hơn hiện tại sẽ bị xóa trong vòng vài ngày (thường vài phút đến 48 giờ), không tốn WCU. Khi đọc vẫn nên FilterExpression expiresAt > :now để không hiện item "đã chết nhưng chưa dọn".
5. Cách hoạt động
PutItem(userId="u-1001", itemId="p-42")
│
▼ hash(userId) → chọn partition
┌─────────────────────────────────────────────────────────────┐
│ Table shop-carts (tự chia partition, mỗi partition ≤ 10 GB) │
│ │
│ Partition A Partition B Partition C │
│ u-1001 ┬ p-07 u-2002 ┬ p-11 u-3003 ─ p-42│
│ ├ p-42 └ p-42 │
│ └ p-99 │
│ (item cùng PK nằm chung, sắp theo SK → Query PK = 1 lần │
│ đọc tuần tự, rẻ) │
└─────────────────────────────────────────────────────────────┘
│ mỗi write async nhân bản
▼
┌───────────────────────────────┐ ┌──────────────────────────┐
│ GSI productId-index │ │ DynamoDB Streams │
│ p-42 → {u-1001}, {u-2002}, │ │ INSERT/MODIFY/REMOVE │
│ {u-3003} │ │ → Lambda shop-order-... │
│ (bảng riêng, eventually) │ └──────────────────────────┘
└───────────────────────────────┘
Mỗi partition: ~3.000 RCU + 1.000 WCU. Một PK nóng (celebrity user,
hoặc PK = "CART" cho tất cả) → hot partition → ThrottlingException
dù tổng capacity còn thừa (adaptive capacity đỡ phần nào, không hết).
Đọc consistent hay eventually? Mặc định GetItem/Query là eventually consistent (có thể trả về dữ liệu cũ vài trăm ms, giá nửa RCU). Thêm ConsistentRead=true để đọc từ leader (giá gấp đôi). GSI không hỗ trợ consistent read; LSI có.
Transaction. TransactWriteItems gom tới 100 item (nhiều bảng) ACID, tốn gấp đôi WCU. Dùng khi "trừ tồn kho và tạo đơn" phải cùng thành công; không lạm dụng cho giỏ hàng.
6. So sánh: những cặp đề thi hay hỏi
| GSI | LSI | |
|---|---|---|
| Partition key | Khác bảng gốc | Bằng bảng gốc |
| Tạo khi nào | Bất kỳ lúc nào | Chỉ lúc create-table |
| Capacity | Riêng (phải cấp đủ WCU, không thì bảng gốc bị throttle theo) | Dùng chung bảng |
| Consistent read | Không | Có |
| Giới hạn | 20/bảng (mặc định) | 5/bảng; 10 GB mỗi partition key |
| On-demand | Provisioned (+ Auto Scaling) | |
|---|---|---|
| Tính tiền | Theo request thực tế | Theo RCU/WCU đã cấp, dù dùng hay không |
| Traffic | Không dự đoán được, spiky, bảng mới | Ổn định, dự đoán được |
| Chi phí khi tải đều cao | Đắt hơn (khoảng vài lần) | Rẻ hơn; có Reserved Capacity giảm thêm |
| Throttle | Hiếm (vẫn có khi gấp đôi peak trước đó quá nhanh) | Có nếu vượt capacity; auto scaling phản ứng sau vài phút |
| Đề thi | "unpredictable workload", "new application", "pay only for what you use" | "steady, predictable", "MOST cost-effective at consistent load" |
| DynamoDB | RDS PostgreSQL (shop-db) | |
|---|---|---|
| Mô hình | Key-value / document, thiết kế theo access pattern | Quan hệ, chuẩn hóa, JOIN tự do |
| Scale | Ngang, tự động, gần như vô hạn | Dọc + Read Replica |
| Kết nối | HTTPS stateless, không pool | TCP connection, pool, giới hạn theo instance |
| Query ad-hoc | Kém (Scan đắt), phải biết trước | Mạnh, index tùy ý |
| Transaction | Có, giới hạn 100 item, tốn gấp đôi | Đầy đủ ACID, isolation level |
| Vận hành | Serverless, không patch, không maintenance window | Managed nhưng vẫn có instance, patch, failover |
| Phù hợp shop-app | Giỏ hàng, session, feature flag, event log, leaderboard | Đơn hàng, thanh toán, báo cáo, mọi thứ cần JOIN |
| DAX | ElastiCache (bài 22.9) | |
|---|---|---|
| Dành cho | Chỉ DynamoDB, API giống hệt (đổi client là xong) | Bất kỳ nguồn nào, tự viết logic cache |
| Cache gì | Item cache (GetItem) + query cache (Query/Scan), write-through | Tùy bạn: object, page, session, counter |
| Đề thi | "microsecond latency for DynamoDB reads with minimal code change" | "cache for RDS", "session store", "leaderboard with sorted set" |
7. Khi nào dùng / không dùng
- Dùng: access pattern biết trước và ít (lấy theo key), tải lớn hoặc spiky, cần serverless, dữ liệu có hạn dùng (TTL), cần multi-region active-active (Global Tables), lưu session/giỏ/feature flag/IoT/event.
- Không dùng: báo cáo ad-hoc nhiều chiều, JOIN nhiều bảng, aggregate (SUM/GROUP BY) trực tiếp, item > 400 KB (đưa file vào S3, lưu key), team chưa quen và dữ liệu nhỏ (RDS đơn giản hơn), cần SQL đầy đủ cho BI (dùng Aurora, hoặc export DynamoDB sang S3 rồi Athena, bài 22.17).
- Thực tế shop-app: cả hai. RDS cho nghiệp vụ cốt lõi, DynamoDB cho phần "nhiều, nhỏ, theo key". Đây cũng là câu trả lời đúng cho đề SAA kiểu "polyglot persistence".
Mình đọc blog thấy khuyên "single-table design", gom user, order, cart vào chung một bảng với PK kiểu USER#123, SK kiểu ORDER#2025.... Nhìn rất rối. Shop-app có nên làm vậy không, hay cứ mỗi loại một bảng như SQL?
Bạn hiểu đúng bản chất: single-table là gom nhiều "loại" item vào một bảng, dùng prefix trong key để phân biệt. Nó không bắt buộc, và với shop-app mình khuyên bắt đầu bằng nhiều bảng, chỉ dùng single-table khi có access pattern cần đọc nhiều loại trong một Query.
Single-table giải quyết bài gì?
DynamoDB không có JOIN. Nếu trang "tài khoản" cần hiện thông tin user + 5 đơn gần nhất + giỏ hiện tại, với 3 bảng bạn gọi 3 request song song. Với single-table, đặt cùng PK và SK có prefix thì một Query lấy hết:
PK SK attributes
USER#u-1001 PROFILE name, email, tier
USER#u-1001 CART#p-42 qty=2, price=...
USER#u-1001 CART#p-07 qty=1
USER#u-1001 ORDER#2025-10-20T08:11 total, status
USER#u-1001 ORDER#2025-10-18T19:02 total, status
Query PK = "USER#u-1001" → mọi thứ của user
Query PK = "USER#u-1001" AND begins_with(SK, "ORDER#") → chỉ đơn, mới nhất trước (ScanIndexForward=false)
Query PK = "USER#u-1001" AND begins_with(SK, "CART#") → chỉ giỏ
Lợi: ít round-trip, một bảng để quản capacity, backup, IAM. Hại: code khó đọc, không dùng được Enhanced Client "một class một bảng" đơn giản, TTL áp cho cả bảng (giỏ cần TTL, đơn hàng thì không, phải để expiresAt null cho đơn), GSI phải "overload" (một GSI phục vụ nhiều pattern bằng attribute GSI1PK, GSI1SK chung tên).
Khuyến nghị cho shop-app
| Dữ liệu | Nơi để | Lý do |
|---|---|---|
| Giỏ hàng | Bảng riêng shop-carts, TTL 7 ngày | Vòng đời khác, cần TTL, đọc ghi độc lập |
| Session | Bảng riêng shop-sessions, TTL 30 phút | Tần suất cực cao, bật on-demand riêng |
| Đơn hàng, thanh toán | Ở lại RDS | Cần transaction, báo cáo, JOIN với sản phẩm |
| Nếu sau này cần "trang tài khoản 1 Query" | Cân nhắc single-table cho view đó (CQRS, xem distributed 14.8) | Chỉ khi đo được round-trip là nút cổ chai |
Nguyên tắc nhớ: single-table là tối ưu hóa, không phải quy tắc. Đề SAA không hỏi single-table; phỏng vấn senior có thể hỏi để xem bạn hiểu "thiết kế theo access pattern".
ProvisionedThroughputExceededException, trong khi mình đang để on-demand. Vì sao?Mình để shop-carts on-demand mà vẫn bị ProvisionedThroughputExceededException trong đợt sale, lại còn thấy một partition nóng trong CloudWatch Contributor Insights. On-demand không phải "vô hạn" à?
Đúng là on-demand không cần cấp capacity, nhưng nó không bỏ giới hạn vật lý của một partition: khoảng 3.000 RCU và 1.000 WCU mỗi partition. On-demand cũng chỉ tự nâng lên tối đa gấp đôi peak 30 phút trước, tăng đột biến hơn thế vẫn bị throttle vài phút. Hai nguyên nhân hay gặp trên shop-app:
Nguyên nhân 1: hot key
Sai: dùng productId làm PK của bảng "đã thêm vào giỏ" để đếm độ hot
→ sản phẩm flash sale p-42 nhận 50.000 write/phút vào MỘT partition
→ 1.000 WCU/partition không đủ → throttle, dù bảng còn thừa
Sửa: write sharding
PK = productId + "#" + (hash(userId) % 10) → 10 partition chia tải
Đọc tổng: Query 10 PK song song rồi cộng (hoặc dùng Streams + Lambda gom)
Với shop-carts PK = userId thì phân phối tốt vì user nhiều và đều. Vấn đề của bạn nhiều khả năng là GSI productId-index: GSI có partition riêng, PK = productId, và sản phẩm sale chính là hot key. Khi GSI bị throttle, write vào bảng gốc cũng bị throttle (vì phải nhân bản sang index). Cách sửa: shard GSI key (gsiPk = productId#0..9), hoặc bỏ GSI và dùng Streams đẩy sang bộ đếm riêng.
Nguyên nhân 2: burst quá nhanh
Bảng mới hoặc lâu không dùng, peak trước đó thấp. Đợt sale mở lúc 20:00, request tăng 20 lần trong một phút. Giải pháp: trước giờ sale, pre-warm bằng cách chuyển sang provisioned với WCU cao rồi về lại on-demand (được đổi mode mỗi 24 giờ), hoặc chạy load test nhẹ trước 30 phút để nâng peak.
Việc phải làm trong code dù có cách nào
// SDK v2 đã retry mặc định (3 lần, backoff). Tăng cho write giỏ hàng:
DynamoDbClient.builder()
.overrideConfiguration(o -> o.retryStrategy(
AwsRetryStrategy.adaptiveRetryStrategy().toBuilder().maxAttempts(6).build()))
.build();
Và theo dõi metric ThrottledRequests, ConsumedWriteCapacityUnits theo bảng và theo GSI (dimension GlobalSecondaryIndexName); bật Contributor Insights để thấy key nóng, như bạn đã làm.
Sang năm shop-app mở thêm thị trường Nhật, chạy ở ap-northeast-1. Giỏ hàng muốn dùng chung hai region. Với RDS mình phải làm Read Replica cross-region và chỉ ghi một chỗ. DynamoDB có cách nào ghi được ở cả hai nơi không? Và dữ liệu giỏ hàng lỡ xóa nhầm thì khôi phục kiểu gì?
Có, và đây là một trong những điểm DynamoDB hơn RDS rõ rệt: Global Tables cho phép ghi ở mọi region (multi-active), còn backup thì có hai cơ chế không cần tự viết gì.
1. Global Tables: multi-region, multi-active
┌──────────────── replication 2 chiều (~1 giây) ────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ ap-southeast-1 (Singapore) │ │ ap-northeast-1 (Tokyo) │
│ shop-carts (replica) │ │ shop-carts (replica) │
│ ◄── app VN ghi/đọc │ │ ◄── app JP ghi/đọc │
└──────────────────────────────┘ └──────────────────────────────┘
▲ DynamoDB Streams (bắt buộc bật) là "ống" chuyển thay đổi
Xung đột: hai region cùng sửa 1 item trong ~1 giây → LAST WRITER WINS (theo timestamp)
# Bật Streams (yêu cầu của Global Tables) rồi thêm replica
aws dynamodb update-table --profile shop-lab --table-name shop-carts \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
aws dynamodb update-table --profile shop-lab --table-name shop-carts \
--replica-updates '[{"Create": {"RegionName": "ap-northeast-1"}}]'
# Lưu ý: replicated write tính tiền riêng (rWCU/rWRU) ở MỌI region → không Free Tier, xóa sau khi thử
- App ở mỗi region trỏ vào endpoint region của mình; Route 53 latency-based routing (bài 22.10) đưa user tới region gần.
- Đọc strongly consistent chỉ trong region; cross-region luôn eventually. Giỏ hàng chấp nhận được; số dư tài khoản thì không, nên ví tiền vẫn ở RDS một region.
- Last-writer-wins có thể làm mất một thao tác khi user nhảy region đúng lúc. Với giỏ hàng, giảm rủi ro bằng cách thiết kế item theo
itemId(mỗi sản phẩm một item) thay vì một item JSON chứa cả giỏ: xung đột chỉ xảy ra khi cùng sửa một sản phẩm. - So với RDS: Aurora Global Database (bài 22.7) chỉ có một region ghi, region phụ chỉ đọc; chuyển writer khi DR mất khoảng một phút. Đề SAA: "active-active multi-region database with low latency writes everywhere" → DynamoDB Global Tables; "relational, global reads, DR under a minute" → Aurora Global Database.
2. Streams nói thêm
Bật Streams không chỉ để Global Tables. Mỗi thay đổi (INSERT/MODIFY/REMOVE) thành một record giữ 24 giờ, đọc theo shard giống Kinesis. Dùng trên shop-app:
| Nhu cầu | Cách làm |
|---|---|
| Giỏ bị bỏ quên 24 giờ → gửi email nhắc | Streams → Lambda shop-order-notifier (bài 22.12) lọc record MODIFY, đẩy SQS delay |
| Đếm "N người đang có sản phẩm này trong giỏ" không làm nóng GSI | Streams → Lambda cộng dồn vào ElastiCache (bài 22.9) |
| Đồng bộ sang Elasticsearch/OpenSearch để tìm kiếm | Streams → Lambda hoặc Kinesis Data Streams for DynamoDB → OpenSearch (bài 22.17) |
| Phân biệt TTL xóa với user xóa | Record REMOVE có userIdentity.principalId = dynamodb.amazonaws.com |
StreamViewType: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES. Chọn NEW_AND_OLD_IMAGES khi cần biết "đổi từ gì sang gì". Đây là nền của pattern CDC đã bàn ở distributed 14.7, nhưng do AWS làm sẵn.
3. Backup và khôi phục
| On-demand backup | PITR (Point-in-time recovery) | |
|---|---|---|
| Là gì | Snapshot toàn bảng lúc bạn bấm/gọi API | Ghi liên tục, khôi phục về bất kỳ giây trong 35 ngày |
| Ảnh hưởng hiệu năng | Không (không tốn RCU) | Không |
| Khôi phục | Thành bảng mới (phải đổi tên hoặc trỏ app lại) | Thành bảng mới, chọn timestamp |
| Tình huống | Trước khi migration / deploy lớn | Xóa nhầm, bug ghi sai dữ liệu lúc 14:03 |
| Chi phí | Theo GB lưu | Theo GB bảng mỗi tháng (cao hơn), không Free Tier |
| Quản lý tập trung | AWS Backup (bài 22.18) đặt lịch, giữ bản, copy cross-region, cross-account | |
# Khôi phục giỏ hàng về trước khi script lỡ xóa (PITR phải đang bật)
aws dynamodb restore-table-to-point-in-time --profile shop-lab \
--source-table-name shop-carts --target-table-name shop-carts-restored \
--restore-date-time "2026-10-20T14:02:00Z"
# Sau đó: so sánh, copy item cần thiết về bảng gốc (hoặc đổi tên app sang bảng mới)
Lưu ý hay bị hỏi: bảng khôi phục không mang theo cấu hình Auto Scaling, TTL setting, Streams, tag, IAM policy và CloudWatch alarm; chỉ mang dữ liệu, key, LSI/GSI. Phải bật lại TTL cho bảng khôi phục nếu muốn giỏ tiếp tục tự dọn.
4. Export sang S3 để phân tích
Cần "doanh thu tiềm năng đang nằm trong giỏ theo ngày" cho BI? Không Scan bảng production. Dùng export-table-to-point-in-time (cần PITR) ra S3 dạng DynamoDB JSON hoặc Ion, rồi Athena query (bài 22.17); không tốn RCU, không ảnh hưởng app.
Cho mình bài lab để tự tay làm phần này nhé.
Lab 22.8: Giỏ hàng shop-carts trên DynamoDB
Mục tiêu
- Tạo bảng
shop-carts(PKuserId, SKitemId) on-demand, có GSIproductId-indexvà TTLexpiresAt. - Ghi/đọc bằng CLI, PartiQL và Java SDK v2 Enhanced Client từ máy local với profile
shop-lab. - Quan sát sự khác nhau giữa
QueryvàScanquaConsumedCapacity. - Thấy TTL tự xóa item và GSI eventually consistent.
Chi phí & thời gian
- DynamoDB Free Tier (always free): 25 GB storage, 25 WCU/25 RCU provisioned, và một lượng request on-demand đáng kể mỗi tháng. Lab này dùng vài trăm request, gần như 0 USD.
- Không tốn tiền kể cả quên xóa bảng (dưới 25 GB), nhưng vẫn dọn để tài khoản lab sạch. Nếu bạn bật PITR hoặc DAX ở phần "Thử thêm" thì DAX tính tiền theo giờ node (không có Free Tier) — xóa ngay sau khi thử.
- Thời gian: 45–60 phút.
Chuẩn bị
- Profile
shop-labvà biếnAWS_REGION=ap-southeast-1,ACCOUNT_IDtừ bài 22.1. Role đang assume cần quyềndynamodb:*trên tài nguyên có tagProject=shop-app(hoặcAmazonDynamoDBFullAccesscho lab). - Java 21, Maven, project
shop-app. Không cần VPC, EC2 hay RDS: DynamoDB gọi từ máy bạn qua Internet. jqđể đọc output.
Các bước
Bước 1: Tạo bảng (lệnh ở mục 4 bước 1 phía trên). Chờ ACTIVE:
aws dynamodb wait table-exists --profile shop-lab --table-name shop-carts
aws dynamodb describe-table --profile shop-lab --table-name shop-carts \
| jq '.Table | {TableStatus, BillingModeSummary, GlobalSecondaryIndexes: [.GlobalSecondaryIndexes[].IndexName]}'
Console: DynamoDB → Tables → Create table → Partition key userId, Sort key itemId → Table settings: Customize → Capacity mode On-demand → Secondary indexes: Create global index → Tags.
Bước 2: Bật TTL (mục 4 bước 7). Kiểm tra:
aws dynamodb describe-time-to-live --profile shop-lab --table-name shop-carts
# "TimeToLiveStatus": "ENABLED", "AttributeName": "expiresAt"
Console: Tables → shop-carts → Additional settings → Time to Live → Turn on.
Bước 3: Ghi vài item bằng CLI. Một item hết hạn sau 2 phút để xem TTL.
NOW=$(date +%s)
aws dynamodb put-item --profile shop-lab --table-name shop-carts --item "{
\"userId\": {\"S\": \"u-1001\"}, \"itemId\": {\"S\": \"p-42\"},
\"productId\": {\"S\": \"p-42\"}, \"name\": {\"S\": \"Áo thun shop-app\"},
\"qty\": {\"N\": \"2\"}, \"priceSnapshot\": {\"N\": \"199000\"},
\"addedAt\": {\"N\": \"${NOW}000\"}, \"expiresAt\": {\"N\": \"$((NOW + 7*86400))\"}}"
aws dynamodb put-item --profile shop-lab --table-name shop-carts --item "{
\"userId\": {\"S\": \"u-1001\"}, \"itemId\": {\"S\": \"p-07\"},
\"productId\": {\"S\": \"p-07\"}, \"name\": {\"S\": \"Nón lưỡi trai\"},
\"qty\": {\"N\": \"1\"}, \"priceSnapshot\": {\"N\": \"89000\"},
\"addedAt\": {\"N\": \"${NOW}000\"}, \"expiresAt\": {\"N\": \"$((NOW + 120))\"}}"
aws dynamodb put-item --profile shop-lab --table-name shop-carts --item "{
\"userId\": {\"S\": \"u-2002\"}, \"itemId\": {\"S\": \"p-42\"},
\"productId\": {\"S\": \"p-42\"}, \"name\": {\"S\": \"Áo thun shop-app\"},
\"qty\": {\"N\": \"1\"}, \"priceSnapshot\": {\"N\": \"199000\"},
\"addedAt\": {\"N\": \"${NOW}000\"}, \"expiresAt\": {\"N\": \"$((NOW + 7*86400))\"}}"
Console: Tables → shop-carts → Explore table items → Create item (JSON view).
Bước 4: Query vs Scan, nhìn capacity tiêu thụ.
# Query theo PK: chỉ đọc partition của u-1001
aws dynamodb query --profile shop-lab --table-name shop-carts \
--key-condition-expression "userId = :u" \
--expression-attribute-values '{":u":{"S":"u-1001"}}' \
--return-consumed-capacity TOTAL | jq '{Count, ConsumedCapacity}'
# Scan + filter: đọc CẢ bảng rồi lọc, Count nhỏ nhưng ScannedCount và capacity lớn
aws dynamodb scan --profile shop-lab --table-name shop-carts \
--filter-expression "userId = :u" \
--expression-attribute-values '{":u":{"S":"u-1001"}}' \
--return-consumed-capacity TOTAL | jq '{Count, ScannedCount, ConsumedCapacity}'
# PartiQL qua GSI: ai có p-42 trong giỏ?
aws dynamodb execute-statement --profile shop-lab \
--statement "SELECT userId FROM \"shop-carts\".\"productId-index\" WHERE productId = 'p-42'" \
| jq '.Items'
Với 3 item thì chênh lệch nhỏ; hãy tưởng tượng 3 triệu item: Query vẫn ~0.5 RCU, Scan đọc hết bảng.
Bước 5: Java Enhanced Client từ local. Thêm config/entity/repository ở mục 4 vào shop-app, rồi viết một CommandLineRunner nhỏ (hoặc test) để chạy:
@Bean
CommandLineRunner cartDemo(CartRepository repo) {
return args -> {
CartItem it = new CartItem();
it.setUserId("u-1001"); it.setItemId("p-99"); it.setProductId("p-99");
it.setName("Túi tote"); it.setQty(1); it.setPriceSnapshot(59000);
it.setAddedAt(System.currentTimeMillis());
repo.upsert(it);
repo.findByUser("u-1001").forEach(c ->
System.out.printf("%s x%d (%d)%n", c.getName(), c.getQty(), c.getPriceSnapshot()));
};
}
AWS_PROFILE=shop-lab AWS_REGION=ap-southeast-1 ./mvnw spring-boot:run
# Áo thun shop-app x2 (199000)
# Nón lưỡi trai x1 (89000)
# Túi tote x1 (59000)
SDK đọc AWS_PROFILE từ môi trường; code không chứa credential. Lên EC2 (bài 22.4) chỉ cần gắn policy DynamoDB vào shop-app-ec2-role, không đổi một dòng code.
Bước 6: Conditional write. Chạy lệnh UpdateItemRequest ở mục 4 bước 5 với delta = 100: nhận ConditionalCheckFailedException vì vượt 99. Bằng CLI:
aws dynamodb update-item --profile shop-lab --table-name shop-carts \
--key '{"userId":{"S":"u-1001"},"itemId":{"S":"p-42"}}' \
--update-expression "SET qty = qty + :d" \
--condition-expression "qty + :d <= :max" \
--expression-attribute-values '{":d":{"N":"100"},":max":{"N":"99"}}'
# An error occurred (ConditionalCheckFailedException) ...
Kiểm tra kết quả
# 1. Item p-07 của u-1001 biến mất sau vài phút đến vài chục phút (TTL)
aws dynamodb get-item --profile shop-lab --table-name shop-carts \
--key '{"userId":{"S":"u-1001"},"itemId":{"S":"p-07"}}' | jq '.Item // "deleted by TTL"'
# 2. Metric TTL trong CloudWatch (có độ trễ vài phút)
aws cloudwatch get-metric-statistics --profile shop-lab --namespace AWS/DynamoDB \
--metric-name TimeToLiveDeletedItemCount --dimensions Name=TableName,Value=shop-carts \
--start-time $(date -u -d '-1 hour' +%FT%TZ) --end-time $(date -u +%FT%TZ) \
--period 300 --statistics Sum | jq '.Datapoints'
# 3. GSI trả về đủ 2 user cho p-42 (nếu chạy ngay sau put có thể thiếu 1: eventually consistent)
aws dynamodb query --profile shop-lab --table-name shop-carts --index-name productId-index \
--key-condition-expression "productId = :p" \
--expression-attribute-values '{":p":{"S":"p-42"}}' | jq '.Count' # 2
Thử thêm
- Streams + Lambda (chuẩn bị cho bài 22.12):
aws dynamodb update-table --table-name shop-carts --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES, rồidescribe-table | jq .Table.LatestStreamArn. Record TTL xóa cóuserIdentity.principalId = "dynamodb.amazonaws.com", dùng để phân biệt "user xóa" với "hết hạn". - Backup / PITR:
aws dynamodb update-continuous-backups --table-name shop-carts --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true. Xem phí theo GB-tháng, tắt lại sau khi xem. - Đổi capacity mode:
update-table --billing-mode PROVISIONED --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5; chạy script put 200 item liên tục và quan sátThrottledRequests. Chú ý chỉ đổi mode được một lần mỗi 24 giờ. - Local: DynamoDB Local (Docker
amazon/dynamodb-local) cho test tích hợp, đổiendpointOverridetrong client; không tốn tiền, không cần mạng.
Dọn tài nguyên
# Tắt stream / PITR nếu đã bật (không bắt buộc, delete-table xóa hết)
aws dynamodb delete-table --profile shop-lab --table-name shop-carts
aws dynamodb wait table-not-exists --profile shop-lab --table-name shop-carts
# Nếu đã tạo DAX cluster ở phần thử thêm (tốn tiền theo giờ, KHÔNG Free Tier):
aws dax delete-cluster --profile shop-lab --cluster-name shop-dax 2>/dev/null || true
# Kiểm tra không còn bảng nào gắn tag Project=shop-app
aws resourcegroupstaggingapi get-resources --profile shop-lab \
--resource-type-filters dynamodb --tag-filters Key=Project,Values=shop-app | jq '.ResourceTagMappingList'
Bảng dưới 25 GB không tốn tiền kể cả quên; thứ tốn tiền nếu quên là DAX, on-demand backup lớn, PITR và Global Tables (replicate sang region khác).
Phỏng vấn và đề thi SAA hỏi về DynamoDB thì sao?
Q1. Khi nào bạn chọn DynamoDB thay cho RDS?
🇻🇳 Tiếng Việt:
Khi access pattern biết trước và chủ yếu theo key: lấy giỏ hàng của user, session theo id, feature flag. Tải lớn hoặc bất thường, cần scale ngang không downtime, và không cần JOIN hay báo cáo ad-hoc. Trong shop-app em giữ đơn hàng và thanh toán ở RDS vì cần transaction và báo cáo, còn giỏ hàng và session đưa sang DynamoDB để không chiếm connection của RDS và dùng TTL tự dọn.
🇬🇧 English:
When the access patterns are known up front and mostly key-based: a user's cart, a session by id, feature flags. When traffic is large or spiky, we need horizontal scaling with no downtime, and we do not need joins or ad-hoc reporting. In shop-app I keep orders and payments in RDS because they need transactions and reporting, while carts and sessions go to DynamoDB so they do not consume RDS connections and can expire automatically with TTL.
Q2. Partition key tốt là gì? Hot partition xử lý sao?
🇻🇳 Tiếng Việt:
Partition key tốt có nhiều giá trị phân biệt và truy cập phân bố đều, ví dụ userId. Key xấu là trạng thái, ngày, hoặc một productId đang sale, vì dồn tải vào một partition và bị throttle dù bảng còn capacity. Khắc phục bằng write sharding, thêm hậu tố ngẫu nhiên hoặc hash vào key rồi đọc gộp, hoặc đưa phần đếm nóng sang DAX hay ElastiCache. Em cũng kiểm tra GSI vì GSI có partition riêng và throttle của GSI làm bảng gốc throttle theo.
🇬🇧 English:
A good partition key has high cardinality and evenly distributed access, like userId. Bad keys are a status, a date, or a product on sale, because they funnel traffic into one partition and get throttled even when the table has spare capacity. We fix it with write sharding, appending a random or hashed suffix and aggregating on read, or by moving the hot counter to DAX or ElastiCache. I also check GSIs, since a GSI has its own partitions and GSI throttling throttles writes to the base table.
Q3. GSI và LSI khác nhau thế nào, và on-demand so với provisioned?
🇻🇳 Tiếng Việt:
LSI cùng partition key với bảng, khác sort key, chỉ tạo được lúc tạo bảng, dùng chung capacity và hỗ trợ đọc strongly consistent. GSI có partition key khác, tạo lúc nào cũng được, capacity riêng, chỉ eventually consistent. On-demand trả theo request, hợp tải không dự đoán được hoặc bảng mới; provisioned cấp RCU/WCU cố định kèm auto scaling, rẻ hơn khi tải ổn định.
🇬🇧 English:
An LSI shares the table's partition key with a different sort key, can only be created with the table, shares its capacity and supports strongly consistent reads. A GSI has a different partition key, can be added any time, has its own capacity and is eventually consistent only. On-demand bills per request and suits unpredictable traffic or new tables; provisioned sets fixed RCU/WCU with auto scaling and is cheaper for steady workloads.
Q4. Làm sao tránh race condition khi hai request cùng sửa một item?
🇻🇳 Tiếng Việt:
Dùng UpdateExpression để tăng giảm atomic ngay trên server thay vì đọc rồi ghi, kèm ConditionExpression làm optimistic locking, ví dụ kiểm tra version hoặc giới hạn số lượng. Nếu phải sửa nhiều item cùng lúc thì dùng TransactWriteItems, chấp nhận tốn gấp đôi WCU.
🇬🇧 English:
Use an UpdateExpression so increments happen atomically on the server instead of read-then-write, together with a ConditionExpression as optimistic locking, for example checking a version attribute or a quantity limit. If several items must change together, use TransactWriteItems and accept the double WCU cost.
Góc thi SAA-C03
Câu 1: Một công ty game lưu điểm người chơi trong DynamoDB. Vào giờ cao điểm, một số request bị throttle dù CloudWatch cho thấy bảng chỉ dùng 40% WCU đã cấp. Nguyên nhân có khả năng nhất và cách khắc phục với LEAST operational overhead?
A. Bật DynamoDB Auto Scaling cho bảng.
B. Partition key có cardinality thấp gây hot partition; thiết kế lại key để phân bố đều hơn.
C. Chuyển bảng sang DynamoDB Global Tables.
D. Tăng gấp đôi WCU đã cấp.
Đáp án: B. Throttle khi tổng capacity còn dư là dấu hiệu kinh điển của hot partition: tải dồn vào một key. A và D tăng capacity tổng nhưng giới hạn mỗi partition không đổi, vẫn throttle. C là multi-region, không liên quan đến phân bố key. Từ khóa: "throttled while well below provisioned capacity" → hot partition / key design.
Câu 2: Một ứng dụng mới sắp ra mắt, lưu session người dùng trong DynamoDB. Lượng truy cập chưa dự đoán được, có thể tăng đột biến sau chiến dịch marketing. Session phải tự hết hạn sau 30 phút. Giải pháp nào MOST cost-effective và ít việc vận hành nhất?
A. Provisioned mode với WCU/RCU cao để chịu peak; Lambda theo lịch xóa session cũ.
B. On-demand mode; bật TTL trên attribute thời gian hết hạn.
C. Provisioned mode với Auto Scaling; Lambda theo lịch xóa session cũ.
D. On-demand mode; dùng DynamoDB Streams kích hoạt Lambda xóa session cũ.
Đáp án: B. "Unpredictable", "new application" → on-demand. "Expire automatically" → TTL, miễn WCU, không cần code. A trả tiền cho capacity không dùng và tự viết dọn. C auto scaling phản ứng chậm với spike và vẫn phải tự dọn. D dùng Streams + Lambda để xóa là thừa và tốn tiền khi TTL làm miễn phí.
Câu 3: Trang chi tiết sản phẩm đọc từ DynamoDB với hàng triệu request đọc giống nhau mỗi phút trên vài nghìn item. Công ty muốn giảm độ trễ xuống micro-giây và giảm RCU tiêu thụ với thay đổi code ít nhất. Chọn gì?
A. Thêm ElastiCache for Redis trước DynamoDB, viết logic cache-aside trong ứng dụng.
B. Tăng RCU của bảng và bật strongly consistent read.
C. Thêm DynamoDB Accelerator (DAX) và đổi ứng dụng sang dùng DAX client.
D. Tạo GSI cho bảng sản phẩm.
Đáp án: C. DAX là cache in-memory tương thích API DynamoDB, độ trễ micro-giây, chỉ đổi client. A cũng cache được nhưng phải tự viết logic (nhiều code hơn). B tăng chi phí, không giảm độ trễ xuống micro-giây; strongly consistent còn tốn gấp đôi. D không giảm số lần đọc. Từ khóa: "microsecond", "minimal code change", "read-heavy DynamoDB" → DAX.
Cách nhớ ngắn gọn
- Thiết kế từ access pattern → key; mọi đường đọc chính phải là Query, không Scan.
- PK phân bố đều (userId tốt, status/date xấu); hot partition → shard key.
- LSI: cùng PK, lúc tạo bảng, consistent được. GSI: PK khác, tạo lúc nào cũng được, eventually, capacity riêng.
- On-demand cho tải lạ; provisioned + auto scaling cho tải đều; TTL dọn miễn phí; DAX cache micro-giây; Streams để trigger; Global Tables cho multi-region active-active.
| Từ khóa trong đề | Nghĩ tới |
|---|---|
| single-digit millisecond, key-value, serverless, unpredictable scale | DynamoDB on-demand |
| microsecond latency, minimal code change | DAX |
| automatically delete expired items | TTL |
| react to item changes / trigger Lambda | DynamoDB Streams |
| multi-region active-active, low latency globally | Global Tables |
| throttled below provisioned capacity | Hot partition, redesign key |
| query by a different attribute after table creation | GSI |
| point-in-time recovery, 35 days | PITR |
Tự kiểm tra
Vì sao shop-carts dùng PK userId + SK itemId thay vì PK itemId?
Access pattern chính là "lấy toàn bộ giỏ của một user": với PK = userId, một Query đọc hết item của user trong một partition. PK = itemId sẽ phải Scan hoặc cần GSI theo userId.
GSI bị throttle thì ảnh hưởng gì đến bảng gốc?
Write vào bảng gốc cũng bị throttle, vì mỗi write phải nhân bản sang GSI. Cần cấp đủ WCU cho GSI (provisioned) hoặc tránh hot key trong GSI (shard key).
TTL xóa item ngay khi hết hạn không? Code cần làm gì?
Không. Xóa trong vòng vài phút đến khoảng 48 giờ sau hết hạn. Khi đọc nên lọc expiresAt > now để không hiện item đã hết hạn nhưng chưa bị dọn.
Khác nhau giữa PutItem và UpdateItem với UpdateExpression?
PutItem ghi đè toàn item (mất attribute không gửi). UpdateItem sửa từng attribute, hỗ trợ phép toán atomic (SET qty = qty + :d) và điều kiện, tránh race "đọc rồi ghi".
Khi nào on-demand vẫn bị throttle?
Khi một partition vượt ~3.000 RCU / 1.000 WCU (hot key), hoặc traffic tăng quá gấp đôi peak 30 phút trước đó quá nhanh. Pre-warm hoặc chia key.