Nối DynamoDB Vào Code: Ghi An Toàn và Atomic Counter

K
Kai··6 min read

Bốn bài qua dựng từng mảnh rời: API có route nhưng trả dữ liệu giả, bảng DynamoDB có thiết kế nhưng chưa ai ghi vào. Bài này ráp chúng lại. Hai handler ở bài 03 sẽ nói chuyện thật với bảng ở bài 04, và trên đường đó ta giải hai bài toán dễ làm sai: ghi sao cho không đè trùng, và đếm sao cho không sai khi nhiều lượt mở xảy ra cùng lúc.

Mục tiêu

Gắn create-linkresolve-link vào DynamoDB qua DocumentClient, dùng conditional write để mã ngắn không bao giờ đè lên nhau, và atomic counter để đếm click an toàn dưới tải song song. Ta deploy thật rồi bắn nhiều request đồng thời để kiểm, và đọc kết quả thật kể cả khi nó không như mong đợi. Bảng on-demand từ bài trước, chi phí không đáng kể.

Client đặt ở tầng module

Theo nguyên tắc từ bài 02, client tốn kém nên tạo một lần ở code static để tái dùng qua các invoke warm. Một file dùng chung lo việc đó:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
export const ddb = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
});

export const TABLE = process.env.TABLE_NAME ?? "url-shortener";
export const linkKey = (code: string) => ({ PK: `LINK#${code}`, SK: "META" });

DynamoDBDocumentClient cho phép làm việc với object JavaScript thuần thay vì dạng {"S": "..."} thô ta thấy ở bài 04. Tên bảng đọc từ biến môi trường TABLE_NAME, được SAM truyền vào, để code không hard-code tên tài nguyên.

Mã ngắn sinh ngẫu nhiên base62, và dù không gian rất lớn vẫn có xác suất hai lần sinh trùng nhau. Nếu cứ ghi đè, một link mới có thể xóa mất link cũ trùng mã. Conditional write chặn đúng tình huống đó: chỉ ghi nếu PK chưa tồn tại.

await ddb.send(
  new PutCommand({
    TableName: TABLE,
    Item: {
      ...linkKey(code),
      target, ownerId: OWNER, createdAt, clicks: 0,
      GSI1PK: `USER#${OWNER}`, GSI1SK: `LINK#${createdAt}`,
    },
    ConditionExpression: "attribute_not_exists(PK)",
  })
);

ConditionExpression: "attribute_not_exists(PK)" bảo DynamoDB chỉ thực hiện ghi khi chưa có item nào cùng khóa. Nếu trùng, lệnh ném ConditionalCheckFailedException, và handler bắt lỗi đó để sinh mã khác rồi thử lại trong một vòng lặp ngắn:

for (let attempt = 0; attempt < 5; attempt++) {
  const code = generateShortCode();
  try {
    await ddb.send(new PutCommand({ /* ...như trên... */ }));
    return json(201, { code, shortUrl: `${base}/${code}`, target });
  } catch (err) {
    if ((err as { name?: string }).name === "ConditionalCheckFailedException") continue;
    throw err;
  }
}

Điều kiện được kiểm ngay tại server trong cùng thao tác ghi, nên không có khe hở giữa "kiểm tra tồn tại" và "ghi" như khi làm hai bước tách rời. Đây là cách DynamoDB đảm bảo tính duy nhất của khóa mà không cần khóa bi quan.

ownerId tạm gán cố định user-001; xác thực thật bằng Cognito là bài 07.

Handler resolve tra cứu link, trả 301, và tăng bộ đếm click. Phần đếm là chỗ dễ sai nhất. Cách ngây thơ là đọc clicks hiện tại, cộng một, rồi ghi lại. Nhưng khi hai lượt mở xảy ra gần như đồng thời, cả hai đọc cùng một giá trị cũ, cùng cộng một, cùng ghi lại, và một lượt đếm bị mất. DynamoDB cho cách đếm an toàn bằng UpdateExpression với ADD, cộng ngay tại server:

const res = await ddb.send(new GetCommand({ TableName: TABLE, Key: linkKey(code) }));
if (!res.Item) {
  return { statusCode: 404, headers: { "content-type": "text/plain" }, body: "khong tim thay link" };
}
await ddb.send(
  new UpdateCommand({
    TableName: TABLE,
    Key: linkKey(code),
    UpdateExpression: "ADD clicks :one",
    ExpressionAttributeValues: { ":one": 1 },
  })
);
return { statusCode: 301, headers: { location: res.Item.target as string }, body: "" };

ADD clicks :one là một thao tác đọc-cộng-ghi nguyên tử do DynamoDB thực hiện trong một bước, nên hai lượt mở đồng thời cộng dồn đúng, không đè lên nhau.

   Đếm ngây thơ (read-modify-write)      Atomic counter (ADD)

   req A đọc clicks=5 ─┐                 req A: ADD :1 ─┐
   req B đọc clicks=5 ─┤ cả hai thấy 5    req B: ADD :1 ─┤ server cộng tuần tự
   A ghi 6            ─┤                  ────────────────┤
   B ghi 6            ─┘ mất 1 luot!     clicks: 5 -> 6 -> 7  dung

Quyền truy cập bảng cấp cho hàm qua một policy template của SAM:

Environment:
  Variables:
    TABLE_NAME: !Ref Table
Policies:
  - DynamoDBCrudPolicy:
      TableName: !Ref Table

DynamoDBCrudPolicy cấp quyền đọc/ghi trên đúng bảng đó. Đây chưa phải least-privilege chặt nhất (resolve chỉ cần đọc và update, không cần xóa), và bài 16 sẽ siết lại; ở đây ta ưu tiên chạy được trước.

Thử thật, và một bất ngờ

Tạo một link rồi mở nó:

$ curl -X POST "$API/links" -H 'content-type: application/json' -d '{"url":"https://kubernetes.io/docs/home/"}'
{"code":"CDSP0We","shortUrl":".../CDSP0We","target":"https://kubernetes.io/docs/home/"}

$ curl -i "$API/CDSP0We"
HTTP/2 301
location: https://kubernetes.io/docs/home/

$ curl -s -o /dev/null -w "%{http_code}\n" "$API/zzzNOPE"
404

Tạo ghi thật, mở tra cứu thật, mã không tồn tại trả 404. Giờ kiểm atomic counter dưới tải: tạo một link mới rồi bắn 50 request mở song song, ghi lại status mỗi request, rồi đọc clicks:

$ for i in $(seq 1 50); do (curl -s -o /dev/null -w "%{http_code}\n" "$API/$CODE" >> codes.txt) & done; wait
$ grep -c 301 codes.txt
10
$ sort codes.txt | uniq -c
  10 301
  40 503
$ aws dynamodb get-item --table-name url-shortener \
    --key '{"PK":{"S":"LINK#JeXP3vg"},"SK":{"S":"META"}}' --query 'Item.clicks.N'
"10"

Có hai điều đáng chú ý ở đây. Thứ nhất, atomic counter chính xác tuyệt đối: clicks bằng đúng 10, đúng bằng số request nhận 301. Không có lượt đếm nào bị mất dù chạy song song, vì ADD cộng tại server. Thứ hai, 40 trong 50 request trả về 503. Đó không phải lỗi code mà là một giới hạn thật của tài khoản:

$ aws lambda get-account-settings --query 'AccountLimit.ConcurrentExecutions'
10

Tài khoản này có giới hạn concurrent executions của Lambda là 10. Khi 50 request ập tới gần như cùng lúc, Lambda chỉ chạy được 10 môi trường đồng thời, số còn lại bị throttle, và API Gateway trả 503 cho chúng. Đây là quota giảm thường thấy ở tài khoản mới (mặc định nhiều tài khoản là 1000). Con số 10 request thành công khớp chính xác với giới hạn đó.

Điều này đáng nhớ vì nó định hình kỳ vọng cho phần sau. Atomic counter cho ta dữ liệu đúng, nhưng "đúng" chỉ tính trên những request thực sự chạy. Việc nâng quota concurrency, đo hành vi dưới tải, và xử lý throttle là nội dung bài 19 (load test) và bài 16 (throttling). Ở đây ta ghi nhận giới hạn và đi tiếp.

🧹 Dọn dẹp

for c in CDSP0We JeXP3vg; do
  aws dynamodb delete-item --table-name url-shortener \
    --key "{\"PK\":{\"S\":\"LINK#$c\"},\"SK\":{\"S\":\"META\"}}"
done

Giữ lại stack và bảng cho bài sau.

Tổng kết

Hai handler giờ nói chuyện thật với DynamoDB. Conditional write attribute_not_exists(PK) giữ mã ngắn không bao giờ đè trùng, kiểm ngay trong thao tác ghi nên không có khe hở. Atomic counter ADD đếm click chính xác kể cả khi nhiều lượt mở chạy song song, và phép thử song song cũng tình cờ lộ ra giới hạn concurrency Lambda của tài khoản là 10, một con số ta sẽ quay lại ở bài tải.

Phần lõi sản phẩm tới đây đã chạy được đầu cuối, nhưng ai cũng tạo link dưới danh nghĩa user-001. Phần sau thêm người dùng thật: bài kế dựng Cognito để đăng nhập và phát JWT, gắn JWT authorizer của HTTP API để chỉ người đã đăng nhập mới tạo được link, và đặt nền cho việc mỗi người chỉ thấy link của mình.