Tổng hợp lượt click quảng cáo
Hiểu đề
Yêu cầu chức năng
- Người xem bấm quảng cáo được chuyển tới trang đích, mỗi click được ghi nhận
- Nhà quảng cáo xem số click theo quảng cáo hoặc chiến dịch, chọn khoảng thời gian và độ chi tiết phút/giờ/ngày
- Số liệu tạm tính hiện trong vòng 1 phút, số đã chốt để tính tiền có mỗi ngày
- Click trùng và click gian lận bị loại khỏi số tính tiền
Yêu cầu phi chức năng
- Redirect p95 < 50 ms, sẵn sàng 99,99% (lỗi redirect là mất tiền của cả hai bên)
- Không mất click đã redirect; số tính tiền sau đối soát lệch < 0,01%
- Dashboard trễ ≤ 1 phút, truy vấn 30 ngày theo một nhà quảng cáo p95 < 1 s
- Chịu ≈ 58.000 click/s lúc đỉnh (Black Friday, chung kết bóng đá)
Ngoài phạm vi: Đấu giá và chọn quảng cáo để hiển thị (ad serving); Xuất hóa đơn và thu tiền nhà quảng cáo; Theo dõi chuyển đổi (mua hàng sau click); Mô hình ML phát hiện gian lận chi tiết (chỉ nói ở mức luật).
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| GET | /c/{token} | Click vào quảng cáo. Xác minh chữ ký token, trả 302 Location tới trang đích, ghi sự kiện bất đồng bộ. |
| GET | /v1/advertisers/{id}/clicks?adIds=&from=&to=&granularity=minute|hour|day | Chuỗi thời gian số click. Kèm cờ tạm tính hay đã chốt cho từng khoảng. |
| GET | /v1/advertisers/{id}/campaigns/{cid}/summary?from=&to= | Tổng click và click hợp lệ theo chiến dịch. |
| GET | /v1/advertisers/{id}/reports/daily?date= | Số đã chốt sau đối soát, dùng để tính tiền. |
Thiết kế tổng thể
Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.
1) Người xem bấm quảng cáo và được chuyển tới trang đích
Click đi qua redirect service của ta thay vì nhảy thẳng tới trang đích, nên không phụ thuộc JavaScript phía client hay trình chặn quảng cáo. Service chỉ xác minh token và ghi sự kiện, việc đếm làm phía sau.
- 1Ad server sinh link /c/{token}, token ký HMAC chứa impression_id, ad_id, thời điểm hiển thị
- 2Người xem bấm, redirect service kiểm tra chữ ký và hạn của token
- 3Service tra URL đích của ad_id trong cache cục bộ, trả 302
- 4Sự kiện click được gửi vào queue (Kafka ở mức lớn) với key ad_id; token sai thì vẫn redirect nhưng ghi cờ không hợp lệ
Nguồn: Grokking Modern System Design · ch.34 TinyURL ↗ · mục "Requirements" (redirect phải sẵn sàng cao vì lỗi redirect là lỗi người dùng thấy) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "Design of a Distributed Messaging Queue" (producer ghi vào queue) ; Chưa có nguồn trong thư viện: token ký HMAC là kỹ thuật bảo mật cơ bản, thư viện không bàn
2) Số click được tổng hợp gần real-time
Tầng stream đọc sự kiện, loại trùng theo click_id, áp luật gian lận cơ bản, rồi đếm theo cửa sổ 1 phút cho mỗi ad_id dựa trên thời điểm click (event time).
- 1Flink đọc topic clicks, key theo click_id để loại trùng trong keyed state 24 h
- 2Áp luật: quá nhiều click từ một IP hoặc thiết bị, click ngay sau hiển thị dưới 100 ms, user agent bot
- 3Key lại theo ad_id, đếm theo cửa sổ tumbling 1 phút với watermark
- 4Ghi (ad_id, phút, clicks, valid_clicks) vào kho OLAP, ghi đè được nếu cửa sổ tính lại
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (track messages and discard duplicates theo message id) ; Chưa có nguồn trong thư viện: thư viện không có sách về Flink/Spark/ClickHouse/Kafka; chỉ nêu khái niệm cơ bản (event time, watermark, cửa sổ tumbling)
3) Nhà quảng cáo xem số liệu theo thời gian
Truy vấn luôn đọc bảng đã tổng hợp sẵn, không bao giờ đọc click thô. Kho OLAP giữ bảng phút và các bảng giờ, ngày gộp sẵn, phân vùng theo ngày và sắp theo advertiser_id.
- 1Dashboard gọi /v1/advertisers/{id}/clicks với khoảng thời gian
- 2Query API chọn bảng theo độ chi tiết: ≤ 2 ngày dùng bảng phút, ≤ 90 ngày dùng bảng giờ, dài hơn dùng bảng ngày
- 3Kết quả cache 30 s trong Redis theo (advertiser, truy vấn)
- 4Khoảng đã đối soát lấy số batch, khoảng hôm nay lấy số stream và gắn nhãn tạm tính
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Indexing" (dữ liệu tổng hợp sẵn phục vụ truy vấn nhanh) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Writing policies" ; Chưa có nguồn trong thư viện: kho OLAP và chọn bảng theo độ chi tiết là thiết kế của khoá học
4) Số tính tiền được chốt mỗi ngày
Click thô được lưu nguyên vào data lake. Mỗi đêm job batch tính lại toàn bộ ngày hôm trước với luật loại trùng và gian lận đầy đủ, so với số stream và thay thế phân vùng ngày đó.
- 1Kafka Connect đổ topic clicks vào S3 dạng Parquet theo giờ
- 2Spark đọc cả ngày, loại trùng theo click_id, áp luật gian lận cả ngày
- 3So từng ad_id với số stream, ghi ReconciliationReport, cảnh báo nếu lệch > 0,1%
- 4Ghi đè phân vùng ngày trong kho OLAP bằng số batch, đánh dấu đã chốt
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Scaling Search and Indexing" (MapReduce xử lý hàng loạt trên cụm phân tán) ; Chưa có nguồn trong thư viện: thư viện không có sách về Flink/Spark/ClickHouse/Kafka; chỉ nêu khái niệm cơ bản (Spark, Parquet, đối soát)
Hạ tầng theo quy mô
Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.
Một ad network nhỏ. Một service vừa redirect vừa phục vụ dashboard, ghi click vào Postgres với khóa chính click_id, cron mỗi phút gộp thành bảng phút.
Ước lượng
- Click: 100.000 / 86.400 ≈ 1,2 click/s, đỉnh ×10 ≈ 12 click/s
- API: ceil(12 / 600) = 1 → chạy 2 instance để dự phòng
- Ghi: 12 INSERT/s, PRIMARY KEY click_id + ON CONFLICT DO NOTHING chặn trùng
- Lưu thô: 100.000 × 300 B = 30 MB/ngày ≈ 11 GB/năm
- Bảng phút: ≤ 100.000 hàng/ngày (không quá số click) × 50 B ≈ 5 MB/ngày
- Dashboard: 50 × 50 truy vấn = 2.500 truy vấn/ngày, đọc bảng phút
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Requests estimation", "Storage requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (60% CPU, 600 req/s mỗi instance, Redis ×1,3, năng lực Kafka/Flink là giả định của khoá học, cần load test)
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| Redirect + API | Node.js (NestJS) trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 12 req/s rất nhỏ, chạy 2 để redirect không chết khi một instance lỗi. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 11 GB/năm thô; ghi đồng bộ trước khi redirect vẫn nhanh ở tải này. |
| Gộp số liệu | Cron (Cloud Scheduler) mỗi phút | 1 | Chạy trong API | INSERT ... SELECT gộp phút vừa xong vào bảng click_minute, chạy lại được vì ghi đè theo (ad_id, phút). |
| Dashboard | SPA tĩnh trên CDN | 1 | Cloudflare Pages / S3 + CloudFront | Gần như miễn phí, gọi API để lấy số. |
- Điểm xuất phát: một service, một Postgres
- Chặn trùng bằng khóa chính click_id, gộp số bằng cron
- Chưa cần queue hay stream processing
Khi click lên hàng trăm mỗi giây, ghi đồng bộ vào Postgres làm redirect chậm theo DB, và cron gộp phút tranh tài nguyên với dashboard.
Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.
So sánh nhanh ba mức
| Thành phần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 50 nhà quảng cáo, 100.000 click/ngày | 5.000 nhà quảng cáo, 10 triệu click/ngày | 100.000 nhà quảng cáo, 1 tỷ click/ngày |
| Chi phí | ≈ 40–100 USD/tháng (ước lượng) | ≈ 800–2.000 USD/tháng (ước lượng) | ≈ 30.000–70.000 USD/tháng (ước lượng) |
| Redirect + API | Node.js (NestJS) trên Cloud Run 2 × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL managed 1 primary + 1 read replica × 4 vCPU · 16 GB · 200 GB SSD | Chưa cần |
| Gộp số liệu | Cron (Cloud Scheduler) mỗi phút 1 × Chạy trong API | Chưa cần | Chưa cần |
| Dashboard | SPA tĩnh trên CDN 1 × Cloudflare Pages / S3 + CloudFront | Chưa cần | Chưa cần |
| Redirect service | Chưa cần | Node.js, container autoscale 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes, 3 region 97 pod (HPA) × 2 vCPU · 4 GB mỗi pod, đĩa đệm cục bộ |
| Stream | Chưa cần | Amazon Kinesis Data Streams 2 shard × Key theo ad_id, giữ 24 h | Chưa cần |
| Aggregator | Chưa cần | Node.js consumer (KCL) 2 × 1 vCPU · 2 GB RAM | Chưa cần |
| Redis dedup | Chưa cần | ElastiCache 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Lưu thô | Chưa cần | Kinesis Firehose → S3 (Parquet) 1 × 10.000.000 × 500 B = 5 GB/ngày | Chưa cần |
| Đối soát | Chưa cần | Athena / Spark job hàng đêm 1 job × Quét 5 GB mỗi đêm | Chưa cần |
| Global LB | Chưa cần | Chưa cần | Anycast LB (CloudFront / Cloud Load Balancing) 1 × Định tuyến tới region gần nhất |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 6 broker × Topic clicks 32 partition, RF 3, 4 TB mỗi broker |
| Flink | Chưa cần | Chưa cần | Flink trên Kubernetes, RocksDB, checkpoint lên S3 8 TaskManager × 4 vCPU · 16 GB · 100 GB SSD mỗi TM |
| OLAP | Chưa cần | Chưa cần | ClickHouse (ReplicatedReplacingMergeTree) 3 shard × 2 replica × 8 vCPU · 32 GB · 2 TB SSD mỗi node |
| Query API | Chưa cần | Chưa cần | Node.js trên Kubernetes 3 pod × 2 vCPU · 4 GB mỗi pod |
| Redis | Chưa cần | Chưa cần | ElastiCache 1 primary + 1 replica × 2 vCPU · 8 GB |
| Data lake + đối soát | Chưa cần | Chưa cần | Kafka Connect → S3 Parquet, Spark trên EMR 1 cụm theo lịch × ≈ 100 GB Parquet/ngày (500 GB thô nén ~5 lần) |
Đào sâu
Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.
1) Ghi nhận click thế nào để không mất click mà redirect vẫn nhanh?
Trình duyệt chuyển trang ngay nên request ghi click hay bị hủy giữa chừng, trình chặn quảng cáo chặn luôn. Kẻ gian cũng gọi thẳng API này để tạo click giả.
Đánh đổi: Redirect nhanh nhất nhưng mất vài phần trăm click và không kiểm soát được nguồn.
⚠ Chưa có nguồn trong thư viện: hành vi trình duyệt hủy request khi chuyển trang và trình chặn quảng cáo không có trong sách
Mọi click đi qua server của ta và được ghi chắc chắn trước khi chuyển hướng.
Đánh đổi: Độ trễ redirect phụ thuộc DB, và DB thành nút thắt khi click lên hàng chục nghìn mỗi giây.
Nguồn: Grokking Modern System Design · ch.34 TinyURL ↗ · mục "Requirements" (độ trễ redirect) ; Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Standard numbers to remember" (QPS một DB điển hình ≈ 1.000)
Service xác minh token, gửi sự kiện tới Kafka (thường 2-5 ms) rồi trả 302. Nếu Kafka chậm quá 20 ms, sự kiện được ghi vào file đệm trên đĩa của pod và một tiến trình nền gửi lại sau, nên redirect không bao giờ chờ lâu. Token chứa impression_id làm click_id cố định, nên request lặp hay gửi lại đều mang cùng một id.
Đánh đổi: Phải xử lý đĩa đệm khi pod bị xóa (drain trước khi tắt) và chấp nhận at-least-once, tức trùng sẽ được loại ở bước sau.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "Design of a Distributed Messaging Queue" ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (broker giao tối thiểu một lần, nên cần chống trùng) ; Chưa có nguồn trong thư viện: Kafka acks=all và bộ đệm đĩa cục bộ
2) Tổng hợp gần real-time thế nào, nhất là với click đến muộn?
Mỗi lần mở dashboard quét hàng triệu tới hàng tỷ dòng. Hàng nghìn nhà quảng cáo mở cùng lúc thì DB quá tải.
Đánh đổi: Linh hoạt mọi chiều, nhưng chậm và đắt ở quy mô thật.
Nguồn: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Standard numbers to remember" (QPS một DB điển hình) ; Chưa có nguồn trong thư viện: chi phí quét COUNT/GROUP BY là ước lượng của khoá học
Cron hoặc Spark gộp phần dữ liệu mới vào bảng tổng hợp, dashboard đọc bảng này.
Đánh đổi: Đơn giản và đúng, nhưng trễ 5-10 phút, còn job chạy chồng nhau khi dữ liệu tăng.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Scaling Search and Indexing" (MapReduce, xử lý hàng loạt)
Đếm theo thời điểm click thật nên click từ điện thoại mất mạng gửi lại muộn vẫn vào đúng phút. Watermark chậm 30 s, cửa sổ vẫn nhận thêm trong 2 phút và mỗi lần nhận thêm thì phát lại kết quả đầy đủ của phút đó. Kho OLAP giữ bản mới nhất theo (ad_id, phút), nên ghi lại là an toàn. Click muộn hơn 2 phút đi vào side output và được batch đêm xử lý. Checkpoint gắn offset Kafka với state nên khởi động lại không đếm hai lần.
Đánh đổi: Cần vận hành Flink và chọn độ trễ watermark: ngắn thì số tạm tính thiếu, dài thì dashboard chậm.
Nguồn: Chưa có nguồn trong thư viện: thư viện không có sách về Flink/Spark/ClickHouse/Kafka; chỉ nêu khái niệm cơ bản (event time, watermark, allowed lateness, side output) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (xử lý lại an toàn khi ghi idempotent)
3) Loại click trùng và click gian lận thế nào?
Người dùng bấm đúp, trình duyệt tải lại, consumer xử lý lại sau sự cố đều thành click mới. Bot bấm liên tục cũng được tính.
Đánh đổi: Nhà quảng cáo trả tiền cho click không có thật và mất niềm tin.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (duplicate message do client crash trước khi ack, nên một sự kiện có thể xử lý nhiều lần)
Chỉ click lần đầu thấy click_id mới được đếm. Đây là cách theo dõi id đã xử lý để bỏ bản trùng trong Microservices Patterns §3.3.6. Lưu kèm sequence number của bản ghi, để khi consumer chạy lại cùng đoạn sau sự cố thì không bị coi là trùng.
Đánh đổi: Hiệu quả ở mức vừa; 1 tỷ click/ngày cần ≈ 183 GB RAM, và click_id do client tự đặt thì vẫn giả được.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (mục "Tracking messages and discarding duplicates": lưu id đã xử lý) ; Redis in Action ↗ · ch.3 · §3.7.3 Expiring keys ; Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Storage requirements" (phép tính RAM)
Click chỉ hợp lệ khi token có chữ ký đúng và còn hạn, nên mỗi lần hiển thị cho tối đa một click tính tiền và không ai tự tạo id được. Flink key theo click_id, state TTL 24 h trong RocksDB, cùng checkpoint với offset nên chính xác kể cả khi chạy lại. Luật nhanh (tần suất theo IP, thiết bị, click quá sớm) chạy trong stream; luật chậm và ML chạy ở batch đêm. Giữ cả clicks và valid_clicks để nhà quảng cáo thấy số bị loại.
Đánh đổi: State dedup lớn (≈ 64 GB ở 1 tỷ click/ngày) và luật gian lận có thể loại nhầm, nên phải có số liệu và kênh khiếu nại.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (idempotent handler, tracking messages) ; Chưa có nguồn trong thư viện: thư viện không có sách về Flink/Spark/ClickHouse/Kafka; chỉ nêu khái niệm cơ bản (keyed state, checkpoint) ; Chưa có nguồn trong thư viện: luật gian lận và ML không có trong sách
4) Trả số liệu theo nhà quảng cáo nhanh và khớp với số tính tiền thế nào?
Lỗi code ở job stream, mất một partition hay luật gian lận đổi đều làm sai số tiền mà không ai biết. Bảng phút hàng trăm triệu hàng mỗi ngày làm Postgres truy vấn chậm.
Đánh đổi: Một hệ thống duy nhất, nhưng không có cách kiểm chứng.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96) (không có cách kiểm chứng khi chỉ có một đường xử lý thì lỗi âm thầm) ; Chưa có nguồn trong thư viện: ví dụ lỗi stream job là suy luận của khoá học
Click thô lưu ở S3, Spark tính lại mỗi đêm và so với số stream. Kiến trúc lambda: stream cho nhanh, batch cho đúng.
Đánh đổi: Phát hiện được sai nhưng vẫn phải quyết định số nào hiển thị và hai bộ code dễ lệch logic.
Nguồn: Chưa có nguồn trong thư viện: kiến trúc lambda (stream cho nhanh, batch cho đúng) không có trong sách ; Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Scaling Search and Indexing" (batch bằng MapReduce)
ClickHouse (hoặc Druid, Pinot) giữ bảng phút, materialized view gộp lên giờ và ngày, sắp xếp theo (advertiser_id, ad_id, thời gian) nên truy vấn của một nhà quảng cáo chỉ đọc một vùng nhỏ. Hôm nay hiển thị số stream gắn nhãn tạm tính. Sau đối soát, batch ghi đè phân vùng ngày và đánh dấu đã chốt; hóa đơn chỉ dùng số đã chốt. Dùng chung thư viện luật cho Flink và Spark, và giữ Kafka 7 ngày để replay khi sửa lỗi.
Đánh đổi: Số hôm qua có thể đổi nhẹ sau khi chốt, phải giải thích cho nhà quảng cáo; vận hành thêm một kho OLAP.
Nguồn: Chưa có nguồn trong thư viện: ClickHouse/Druid/Pinot, materialized view và ghi đè phân vùng không có trong sách ; Grokking Modern System Design · ch.9 Databases ↗ · mục "Columnar database" (lưu theo cột phù hợp truy vấn tổng hợp)
Trong phỏng vấn
- “Click đi qua redirect service của tôi, service chỉ xác minh token và đẩy sự kiện vào Kafka rồi trả 302.”
- “click_id là impression_id đã ký HMAC, nên dedup theo click_id vừa chống trùng vừa chống tạo click giả.”
- “Flink đếm theo cửa sổ 1 phút trên event time, có watermark và allowed lateness, ghi đè idempotent theo (ad_id, phút).”
- “Số stream là tạm tính; batch đêm trên dữ liệu thô là số tính tiền và ghi đè phân vùng ngày.”
- Đếm click bằng request JavaScript phía client.
- Dùng processing time nên click đến muộn rơi vào sai phút.
- Dedup bằng Redis mà không tính tới consumer chạy lại cùng đoạn sau sự cố.
- Dashboard truy vấn thẳng bảng click thô.
Đọc thêm
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.