EventBridge: Tách Việc Ghi Nhận Click Khỏi Đường Chuyển Hướng

K
Kai··6 min read

Ở bài 06, handler mở link đếm click ngay trong đường chuyển hướng: mỗi lượt mở phải chờ một lệnh ghi DynamoDB xong rồi mới trả về 301. Cách đó chạy được, nhưng nó buộc đường nóng nhất của hệ thống phải gánh thêm việc ghi, và mọi xử lý analytics sau này sẽ chất chồng lên đúng chỗ đó. Bài này tách ra: mở link chỉ phát một sự kiện, còn việc đếm và phân tích để một thành phần khác lo. Đây là viên gạch đầu của kiến trúc event-driven.

Mục tiêu

Dựng một custom event bus trên EventBridge, cho handler resolve phát sự kiện link.clicked thay vì đếm trực tiếp, gắn một consumer nhận sự kiện qua event pattern, rồi mở link thật để thấy sự kiện đi từ resolve qua bus tới consumer. Consumer ở bài này chỉ ghi log để chứng minh luồng; bài sau mới biến nó thành bộ đếm thật. Chi phí EventBridge cho lượng sự kiện nhỏ là không đáng kể.

Vì sao tách ra

Trộn việc đếm vào đường chuyển hướng tạo ra một cặp ràng buộc không cần thiết. Đường mở link nên nhanh và đơn giản nhất có thể, vì nó chạy nhiều nhất; nhưng analytics thì muốn làm nhiều thứ hơn theo thời gian (đếm theo ngày, theo nguồn, phát hiện bất thường). Nếu cả hai sống chung một handler, mỗi lần thêm việc cho analytics là một lần làm chậm đường chuyển hướng, và một lỗi trong phần đếm có thể làm hỏng cả việc mở link.

Kiến trúc event-driven cắt sợi dây đó. Resolve phát một sự kiện rồi trả 301 ngay; ai quan tâm tới click thì đăng ký nhận sự kiện và xử lý độc lập. Thêm một loại xử lý mới sau này chỉ là thêm một consumer, không đụng vào resolve. Đường nóng nhẹ đi, và các phần tách bạch nhau về cả hiệu năng lẫn lỗi.

Sự kiện trông như thế nào

EventBridge mô tả sự kiện là "a change in an environment", biểu diễn bằng JSON với các trường top-level giống nhau. Một sự kiện ta phát có dạng:

{
  "source": "urlshortener",
  "detail-type": "link.clicked",
  "detail": { "code": "s2fYqYI", "at": "2026-05-25T16:56:50.669Z" }
}

source cho biết ai phát, detail-type cho biết loại sự kiện, và detail chứa dữ liệu riêng của sự kiện. EventBridge thêm các trường như id, time, account, region khi nhận. Ba trường ta điền là đủ để consumer biết "link nào vừa được mở, lúc nào".

Custom bus thay vì default bus

Mỗi tài khoản có sẵn một default event bus, nơi các dịch vụ AWS gửi sự kiện của chúng. Ta tạo một custom bus riêng cho ứng dụng để tách sự kiện của mình khỏi luồng ồn ào đó, dễ phân quyền và đặt luật hơn:

EventBus:
  Type: AWS::Events::EventBus
  Properties:
    Name: url-shortener-events

Resolve phát sự kiện

Handler mở link giờ tra cứu link, phát sự kiện, rồi trả 301. Phần đếm bằng atomic counter ở bài 06 được gỡ ra (nó chuyển sang consumer ở bài sau):

const eb = new EventBridgeClient({});
const BUS = process.env.EVENT_BUS ?? "default";

// ...sau khi GetItem tim thay link...
await eb.send(
  new PutEventsCommand({
    Entries: [
      {
        EventBusName: BUS,
        Source: "urlshortener",
        DetailType: "link.clicked",
        Detail: JSON.stringify({ code, at: new Date().toISOString() }),
      },
    ],
  })
);
return { statusCode: 301, headers: { location: res.Item.target as string }, body: "" };

Quyền của resolve đổi theo: nó không còn ghi vào bảng nên hạ từ quyền đọc-ghi xuống chỉ đọc, và được thêm quyền phát sự kiện lên đúng bus:

Environment:
  Variables:
    TABLE_NAME: !Ref Table
    EVENT_BUS: !Ref EventBus
Policies:
  - DynamoDBReadPolicy:
      TableName: !Ref Table
  - EventBridgePutEventsPolicy:
      EventBusName: !Ref EventBus

Consumer đăng ký qua event pattern

Một consumer không nhận mọi thứ trên bus, nó khai một event pattern để lọc. Tài liệu mô tả: "An event pattern defines the data EventBridge uses to determine whether to send the event to the target. If the event pattern matches the event, EventBridge sends the event to the target." Pattern có cùng cấu trúc với sự kiện nó khớp.

Consumer của ta đăng ký nhận đúng source: urlshortenerdetail-type: link.clicked. Trong SAM, một hàm khai event nguồn kiểu EventBridgeRule sẽ tự tạo rule, target và quyền:

ClickLoggerFunction:
  Type: AWS::Serverless::Function
  Properties:
    Handler: handlers/click-logger.handler
    Events:
      Clicked:
        Type: EventBridgeRule
        Properties:
          EventBusName: !Ref EventBus
          Pattern:
            source: [urlshortener]
            detail-type: [link.clicked]

Bản thân handler ở bài này chỉ ghi log để ta thấy sự kiện tới đúng hình dạng:

export const handler = async (
  event: EventBridgeEvent<"link.clicked", { code: string; at: string }>
): Promise<void> => {
  console.log("CLICK EVENT", JSON.stringify({
    source: event.source, detailType: event["detail-type"], detail: event.detail,
  }));
};
   GET /{code}                         EventBridge: url-shortener-events (bus)
        │                                      │
   ResolveLinkFunction                  ┌──────┴───────┐
   ─ GetItem (target)                   │   rule:      │  pattern khop?
   ─ PutEvents ──────────────────────▶  │ source=...   │──┬── khop ──▶ ClickLoggerFunction
   ─ tra 301 ngay                       │ detail-type= │  │            (log su kien)
                                        └──────────────┘  └── khong ──▶ (bo qua)

Tạo một link rồi mở ba lần, mỗi lần phát một sự kiện:

$ for i in 1 2 3; do curl -s -o /dev/null -w "GET /$CODE -> %{http_code}\n" "$API/$CODE"; done
GET /s2fYqYI -> 301
GET /s2fYqYI -> 301
GET /s2fYqYI -> 301

Đọc log của consumer, thấy đúng ba sự kiện đã đi qua bus và tới nơi:

$ aws logs tail "/aws/lambda/$CLICK_LOGGER" --since 2m | grep 'CLICK EVENT'
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:50.669Z"}}
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:51.290Z"}}
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:51.609Z"}}

Resolve và consumer giờ là hai thành phần tách rời, nối nhau qua bus. Resolve không biết gì về ai đang nghe; nó chỉ phát. Consumer không biết gì về resolve; nó chỉ khai pattern mình quan tâm. Thêm một consumer thứ hai (ví dụ một thành phần phát hiện link bị spam) sau này sẽ chỉ là thêm một rule trên cùng bus, không sửa resolve.

🧹 Dọn dẹp

Xóa user và link demo, giữ lại bus và consumer cho bài sau:

aws dynamodb delete-item --table-name url-shortener \
  --key "{\"PK\":{\"S\":\"LINK#$CODE\"},\"SK\":{\"S\":\"META\"}}"
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username ev@example.com

Tổng kết

Việc ghi nhận click đã tách khỏi đường chuyển hướng. Resolve phát một sự kiện link.clicked lên custom bus rồi trả 301 ngay, còn consumer đăng ký qua event pattern và xử lý độc lập. Hai bên không biết về nhau, chỉ biết về sự kiện. Đường nóng nhẹ hơn, và analytics có chỗ riêng để lớn lên.

Nhưng consumer hiện tại mới chỉ ghi log, và nó còn ngây thơ về hai vấn đề của xử lý bất đồng bộ thực tế. Cùng một sự kiện có thể được giao nhiều hơn một lần, nên nếu cứ cộng bộ đếm mỗi lần nhận thì sẽ đếm trùng. Và khi xử lý lỗi, sự kiện sẽ bị thử lại, rồi đi đâu nếu vẫn lỗi? Bài sau biến consumer này thành một aggregator thật: đếm click vào DynamoDB, làm cho nó idempotent để nhận trùng không đếm sai, và gắn một dead-letter queue để sự kiện lỗi không biến mất.