Global Secondary Index và Sparse Index: Mở Đường Truy Vấn Mới
Bảng ở bài trước mở một link rất nhanh vì ta biết chính xác code. Nhưng trang quản lý cần trả lời câu khác: cho một ownerId, liệt kê mọi link người đó tạo. Với khóa hiện tại, câu này không trả lời được, vì partition key là LINK#<code> và ta đâu biết trước các code của một người. Bài này thêm một global secondary index để mở đúng đường đó, và nhân tiện gặp một kỹ thuật gọn: sparse index.
Mục tiêu
Hiểu vì sao một số access pattern cần index thứ cấp, thêm một global secondary index đảo khóa để query theo người dùng, và thấy GSI mặc định là sparse, nghĩa là chỉ những item có đủ khóa index mới xuất hiện. Tạo GSI thật, ghi dữ liệu và query thật. Vẫn dùng bảng on-demand từ bài trước, chi phí không đáng kể.
Vì sao không thể chỉ dùng khóa chính
DynamoDB chỉ cho query hiệu quả theo partition key (và lọc thêm theo sort key). Muốn lấy link theo ownerId, thuộc tính đó lại không nằm trong khóa, nên cách duy nhất với bảng gốc là Scan, tức đọc toàn bộ bảng rồi lọc. Tài liệu cảnh báo đúng vấn đề này: khi cần lấy dữ liệu theo một thuộc tính không phải khóa, "it would need to use a Scan operation. As more items are added to the table, scans of all the data would become slow and inefficient."
Đây là chỗ global secondary index giải quyết. Tài liệu mô tả nó là "a selection of attributes from the base table, but they are organized by a primary key that is different from that of the table", và quan trọng: "The index key does not need to have any of the key attributes from the table." Nói cách khác, GSI là một góc nhìn khác của cùng dữ liệu, sắp xếp theo một khóa do ta chọn, để mở một đường query mà bảng gốc không có.
Đảo khóa theo người dùng
Ta thêm một index tên GSI1 với hai thuộc tính khóa đặt tên trung tính GSI1PK và GSI1SK. Trên item link (item có SK = META), ta gán thêm:
GSI1PK = USER#<ownerId>
GSI1SK = LINK#<createdAt>
Giờ nhìn từ GSI1, mọi link của user-001 chung GSI1PK = USER#user-001, và sắp theo GSI1SK tức theo thời gian tạo. Query index theo GSI1PK trả về đúng danh sách link của người đó, sắp theo ngày.
Bảng gốc (PK/SK) Nhìn qua GSI1 (GSI1PK/GSI1SK)
───────────────────────── ──────────────────────────────────
LINK#aK9xQ2z / META ─────┐ USER#user-001 / LINK#2026-05-24...
LINK#Zb7Kp1m / META ─────┼────▶ USER#user-001 / LINK#2026-05-25...
LINK#aK9xQ2z / STAT#... ────┘ (STAT khong co GSI1PK -> khong vao index)
Khai báo GSI trong SAM
Thêm hai thuộc tính khóa của index vào AttributeDefinitions, rồi khai GlobalSecondaryIndexes:
AttributeDefinitions:
- { AttributeName: PK, AttributeType: S }
- { AttributeName: SK, AttributeType: S }
- { AttributeName: GSI1PK, AttributeType: S }
- { AttributeName: GSI1SK, AttributeType: S }
KeySchema:
- { AttributeName: PK, KeyType: HASH }
- { AttributeName: SK, KeyType: RANGE }
GlobalSecondaryIndexes:
- IndexName: GSI1
KeySchema:
- { AttributeName: GSI1PK, KeyType: HASH }
- { AttributeName: GSI1SK, KeyType: RANGE }
Projection:
ProjectionType: ALL
ProjectionType: ALL cho mọi thuộc tính của item gốc đi vào index, nên query GSI trả về đủ dữ liệu mà không phải đọc ngược về bảng. Đánh đổi là tốn thêm dung lượng lưu và write cho index; với bảng nhỏ điều đó không đáng kể, còn khi dữ liệu lớn thì cân nhắc KEYS_ONLY hoặc INCLUDE để chỉ chiếu các thuộc tính cần. Khi sam deploy áp thay đổi này lên bảng đã có, DynamoDB tạo index ở chế độ nền và mất một lúc để chuyển từ CREATING sang ACTIVE trước khi query được.
Sparse index: GSI sparse theo mặc định
Để ý ở sơ đồ trên: item STAT#... không có GSI1PK. Đó không phải thiếu sót mà là chủ đích. Tài liệu nêu quy tắc: "DynamoDB writes a corresponding index entry only if the index key attributes are present in the item... If either key attribute is missing from an item, that item does not appear in the index." Một index mà chỉ một phần item của bảng gốc xuất hiện gọi là sparse index, và tài liệu nói rõ "Global secondary indexes are sparse by default."
Hệ quả rất hợp với ta: vì chỉ item link (SK = META) được gán GSI1PK, còn item thống kê thì không, GSI1 tự động chỉ chứa link. Ta không cần lọc bỏ thống kê khi query danh sách link, chúng vốn không nằm trong index. Việc chỉ gán khóa GSI cho item link là cách phân loại sẵn ngay trong tầng dữ liệu.
Query thật
Sau khi GSI1 chuyển ACTIVE, ghi hai link của user-001 (kèm GSI1PK/GSI1SK) và một bản ghi thống kê (không kèm), rồi kiểm. Bảng gốc có ba item:
$ aws dynamodb scan --table-name url-shortener --select COUNT --query Count
3
Query GSI1 theo người dùng, đặt --no-scan-index-forward để lấy mới nhất trước:
$ aws dynamodb query --table-name url-shortener --index-name GSI1 \
--key-condition-expression "GSI1PK = :u" \
--expression-attribute-values '{":u":{"S":"USER#user-001"}}' \
--no-scan-index-forward \
--query 'Items[].{code:PK.S,createdAt:createdAt.S,target:target.S}' --output table
-----------------------------------------------------------------------------------
| code | createdAt | target |
+--------------+------------------------+-----------------------------------------+
| LINK#Zb7Kp1m| 2026-05-25T14:30:00Z | https://aws.amazon.com/lambda/ |
| LINK#aK9xQ2z| 2026-05-24T09:00:00Z | https://docs.aws.amazon.com/dynamodb/ |
-----------------------------------------------------------------------------------
Hai link trả về, sắp theo thời gian tạo giảm dần, đúng thứ tự dashboard cần. Và bằng chứng cho tính sparse: dù bảng có ba item, query GSI1 chỉ thấy hai:
$ aws dynamodb query --table-name url-shortener --index-name GSI1 \
--key-condition-expression "GSI1PK = :u" \
--expression-attribute-values '{":u":{"S":"USER#user-001"}}' --select COUNT --query Count
2
Bản ghi STAT#... không có mặt trong index vì nó không có GSI1PK. Ta vừa mở một đường query mới (theo người dùng) và lọc sẵn loại item không liên quan, cả hai chỉ bằng cách đặt khóa.
🧹 Dọn dẹp
Xóa các item demo, giữ lại bảng và index cho bài sau:
for k in 'LINK#aK9xQ2z|META' 'LINK#Zb7Kp1m|META' 'LINK#aK9xQ2z|STAT#2026-05-25'; do
pk="${k%%|*}"; sk="${k##*|}"
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"$pk\"},\"SK\":{\"S\":\"$sk\"}}"
done
Tổng kết
Global secondary index cho ta một góc nhìn khác của cùng dữ liệu, sắp theo một khóa do ta chọn, để mở đường query mà bảng gốc không có. Đảo khóa theo USER#<ownerId> cho phép liệt kê link theo người dùng, và vì GSI sparse theo mặc định, chỉ những item có khóa index mới xuất hiện, nên index tự lọc bỏ thống kê. URL shortener của ta giờ có đủ ba đường: mở link theo code, lấy item collection của một link, và liệt kê link theo người dùng.
Phần thiết kế dữ liệu tới đây là đủ để nối vào code. Bài sau gắn DynamoDB vào hai handler ở bài 03: tạo link sẽ ghi thật vào bảng, mở link sẽ tra cứu thật. Trên đường đó ta giải hai bài toán hay gặp sai: làm sao tạo link idempotent để một request lặp không sinh ra hai bản ghi, và làm sao đếm click an toàn khi nhiều lượt mở xảy ra cùng lúc bằng atomic counter.