Harbor

Ứng dụng hẹn hò quẹt thẻ

Trung bìnhTìm theo vị tríGhi nhiềuMatch nguyên tửBloom filterGiống Tinder, Bumble

Hiểu đề

Ứng dụng hẹn hò quẹt thẻ là gì? Ứng dụng hẹn hò cho người dùng xem lần lượt hồ sơ của những người ở gần, quẹt phải nếu thích, quẹt trái nếu bỏ qua. Khi hai người cùng quẹt phải nhau thì tạo thành một match và cả hai được báo ngay.

Yêu cầu chức năng

  1. Người dùng có thể tạo hồ sơ với ảnh và đặt tiêu chí (độ tuổi, giới tính, bán kính tìm)
  2. Người dùng có thể xem một chồng (stack) hồ sơ ở gần, phù hợp tiêu chí
  3. Người dùng có thể quẹt phải (thích) hoặc quẹt trái (bỏ qua) từng hồ sơ
  4. Khi hai người cùng quẹt phải nhau, cả hai nhận thông báo match ngay lập tức

Yêu cầu phi chức năng

  • Quẹt p95 < 100 ms, cảm giác tức thì kể cả lúc 115.000 lượt quẹt/s
  • Lấy stack p95 < 300 ms, hồ sơ trong stack cách tối đa bán kính người dùng chọn
  • Không bỏ sót match: 2 người quẹt phải nhau thì 100% phải ra match, kể cả khi quẹt cùng một lúc
  • Không hiện lại hồ sơ đã quẹt trong 90 ngày (chấp nhận bỏ sót khoảng 1% hồ sơ chưa xem)
  • Độ sẵn sàng 99,9%; ảnh tải qua CDN p95 < 200 ms

Ngoài phạm vi: Nhắn tin sau khi match (xem bài Chat); Gói trả phí, Super Like, Boost; Mô hình ML xếp hạng hồ sơ (chỉ nói ở mức có một bước xếp hạng); Kiểm duyệt ảnh và xác minh danh tính.

Thực thể và API

User / Profile
Tài khoản và hồ sơ: tên, tuổi, giới tính, giới thiệu, tiêu chí tìm (tuổi, giới tính, bán kính), vị trí gần nhất, last_active.
Photo
Metadata ảnh (thứ tự, URL). File nằm trên S3, phục vụ qua CDN.
Swipe
Một lượt quẹt: swiper_id, target_id, hướng (LEFT/RIGHT), thời điểm. Lượt trái có TTL 90 ngày.
LikePair
Một bản ghi cho mỗi cặp (low_id, high_id) với cờ low_liked, high_liked. Dùng để phát hiện match nguyên tử.
Match
Hai người đã thích nhau: match_id = hash(low_id, high_id), thời điểm tạo, trạng thái (còn/đã hủy).
Stack
Danh sách khoảng 100 user_id ứng viên đã tính sẵn cho một người, lưu trong Redis.
MethodEndpointGhi chú
PUT/v1/me/profileCập nhật hồ sơ và tiêu chí tìm.
POST/v1/me/photosXin presigned URL để client upload ảnh thẳng lên S3.
PUT/v1/me/locationGửi {lat, lng} khi mở app hoặc khi di chuyển xa. Server làm tròn vị trí trước khi lưu.
GET/v1/feed?limit=20Lấy 20 hồ sơ tiếp theo trong stack, kèm URL ảnh trên CDN.
POST/v1/swipesBody {targetId, direction}. Idempotent theo (swiper, target). Trả {matched, matchId}.
GET/v1/matches?cursor=Danh sách match, phân trang theo cursor.

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 dùng có thể tạo hồ sơ với ảnh

Ảnh là phần nặng nhất nên không đi qua API. Client xin presigned URL rồi upload thẳng lên S3, CDN phục vụ khi người khác xem.

  1. 1Client gọi POST /v1/me/photos, API trả presigned URL có hạn 5 phút
  2. 2Client upload ảnh lên S3, sau đó gọi PUT /v1/me/profile để lưu thứ tự ảnh
  3. 3Worker tạo các kích thước nhỏ (thumbnail, 640px) cho tải nhanh
  4. 4Hồ sơ và tiêu chí lưu ở Postgres, vị trí lưu ở cột geography có index

Nguồn: Grokking Modern System Design · ch.20 Blob Store ↗ · mục "High-level design" ; Grokking Modern System Design · ch.11 CDN ↗ · mục "Pull CDN" ; Chưa có nguồn trong thư viện: presigned URL là tính năng của nhà cung cấp object storage ; Chưa có nguồn trong thư viện: PostGIS, geohash, OpenSearch geo_point không có sách trong thư viện

2) Người dùng có thể xem stack hồ sơ ở gần

Tìm ứng viên cần lọc theo khoảng cách, tuổi, giới tính và loại người đã quẹt. Việc này tốn kém nên tính sẵn khoảng 100 ứng viên rồi phát dần cho client.

  1. 1Client gọi GET /v1/feed, API lấy 20 id đầu từ stack trong Redis
  2. 2API lấy hồ sơ rút gọn của 20 id (cache), trả kèm URL ảnh CDN
  3. 3Khi stack còn dưới 20 id, API phát sự kiện yêu cầu dựng thêm
  4. 4Stack builder truy vấn geo index theo bán kính và tiêu chí, loại id có trong Bloom filter, xếp hạng, ghi 100 id mới vào Redis

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Search using a QuadTree" ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · Background of Distributed Cache · mục "Writing policies" (write-around: ghi vào cache sau lần trượt) ; Seven Databases in Seven Weeks ↗ (.zip) · ch.3 HBase · Day 2: Working with Big Data · mục "Compression and Bloom Filters" (How Do Bloom Filters Work?, tr. 110–111) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability)

3) Người dùng có thể quẹt trái hoặc phải

Quẹt là thao tác nhiều nhất hệ thống, khoảng 100 lần mỗi người mỗi ngày. Ghi thẳng vào store chịu ghi tốt và trả lời ngay, các việc phụ đi qua queue.

  1. 1Client gửi POST /v1/swipes với targetId và direction
  2. 2Swipe API ghi Swipe (swiper_id, target_id) và thêm target_id vào Bloom filter của người quẹt
  3. 3Nếu là quẹt phải thì cập nhật LikePair của cặp (xem luồng match)
  4. 4Phát sự kiện swipe lên queue cho thống kê và mô hình xếp hạng

Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability) ; Grokking Modern System Design · ch.10 Key-value Store ↗ · Design of a Key-value Store (kho khóa-giá trị chịu ghi lớn) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Cache client" (Note: Bloom filter kiểm tra phần tử chắc chắn không có)

4) Hai người cùng thích nhau thì được báo match

Hai lượt thích của một cặp ghi vào cùng một bản ghi LikePair, nên DB tự xếp chúng tuần tự. Lượt ghi nào thấy cả hai cờ đều true thì tạo match.

  1. 1Tính low = min(A, B), high = max(A, B)
  2. 2Cập nhật nguyên tử: đặt cờ của người quẹt = true, trả về bản ghi sau khi cập nhật
  3. 3Nếu cả low_liked và high_liked đều true thì ghi Match với match_id = hash(low, high), ghi trùng cũng không sao
  4. 4Trả matched=true cho người vừa quẹt và gửi push cho người kia qua FCM/APNs

Nguồn: Redis in Action ↗ · ch.11 · §11.1 Adding functionality without writing C (tr. 250) ; Redis in Action ↗ · ch.4 · §4.4 Redis transactions (tr. 78) ; Chưa có nguồn trong thư viện: cập nhật nguyên tử LikePair low/high và match_id tất định là thiết kế của khoá học

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.

Người dùng
10.000 DAU, 50.000 người đăng ký, một thành phố
Tải đỉnh
≈ 150 req/s buổi tối
Chi phí ước lượng
≈ 60–150 USD/tháng (ước lượng)

App hẹn hò cho một thành phố. Hai instance API và một Postgres có PostGIS làm hết: tìm người gần bằng index không gian, ghi lượt quẹt và LikePair ngay trong Postgres.

Người dùngApp di độngCDNCDNDịch vụAPI Node.jsLưu trữS3 ảnhDatabasePostgres+PostGISBên ngoàiFCM / APNstải ảnhcache missHTTPSgeo + quẹtbáo match

Ước lượng

  • Quẹt: 10.000 × 100 lượt / 86.400 ≈ 11,6 lượt/s, buổi tối ×10 ≈ 116 lượt/s
  • Request khác: 10.000 × 30 / 86.400 ≈ 3,5 req/s, ×10 ≈ 35 req/s
  • API: ceil((116 + 35) / (1.000 × 0,6)) = ceil(0,25) = 1 → lấy tối thiểu 2 instance
  • Lượt quẹt lưu: 10.000 × 100 = 1.000.000 lượt/ngày × 30 B × 365 ≈ 11 GB/năm (chưa tính index)
  • Ảnh: 50.000 người × 5 ảnh × 300 KB = 75 GB trên S3
  • Match: 1.000.000 lượt × 1,5% ≈ 15.000 match/ngày

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Examples of Resource Estimation" (Number of servers required, Storage requirements, Bandwidth requirements) ; Quy tắc ước lượng của khoá học · /hoc/chon-server (1.000 req/s mỗi máy 2 vCPU, chạy tối đa 60% CPU, Redis ×1,3) ; Chưa có nguồn trong thư viện: 100 lượt quẹt/người/ngày và tỷ lệ match 1,5% là giả định của khoá học

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
CDNCloudflare / CloudFront1Cache ảnh hồ sơẢnh chiếm gần hết băng thông, CDN gánh thay origin.
APINode.js (NestJS), Docker trên Cloud Run22 vCPU · 4 GB RAM150 req/s chỉ cần 1 instance, chạy 2 để dự phòng.
DatabasePostgreSQL managed + PostGIS12 vCPU · 8 GB · 100 GB SSD50.000 hồ sơ, 116 ghi/s; index GiST cho tìm theo bán kính, ON CONFLICT cho LikePair.
Lưu ảnhS3 / GCS175 GB, presigned uploadKhông để ảnh trong DB; client upload thẳng.
PushFCM / APNs1Dịch vụ bên ngoài15.000 match/ngày, gửi trực tiếp từ API là đủ.
Thay đổi so với mức trước
  • Điểm xuất phát: một API, một Postgres có PostGIS, ảnh trên S3 + CDN
  • Stack tính ngay khi mở app bằng truy vấn geo, loại người đã quẹt bằng NOT EXISTS
  • LikePair và Match nằm chung Postgres, nguyên tử nhờ ON CONFLICT
Nút thắt tiếp theo

Bảng swipes là thứ lớn nhanh nhất: lên 1 triệu DAU là 100 triệu hàng mỗi ngày, và truy vấn NOT EXISTS khi dựng stack chậm dần theo lịch sử quẹt.

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ầnMức 1Mức 2Mức 3
Người dùng10.000 DAU, 50.000 người đăng ký, một thành phố1 triệu DAU, 5 triệu người đăng ký, toàn quốc50 triệu DAU, 100 triệu người đăng ký, 3 region
Chi phí≈ 60–150 USD/tháng (ước lượng)≈ 4.000–10.000 USD/tháng (ước lượng, chưa tính băng thông ảnh)≈ 120.000–300.000 USD/tháng (ước lượng, chưa tính băng thông CDN cho ảnh)
CDN
Cloudflare / CloudFront
1 × Cache ảnh hồ sơ
Chưa cầnChưa cần
API
Node.js (NestJS), Docker trên Cloud Run
2 × 2 vCPU · 4 GB RAM
Node.js, container autoscale
2–13 (autoscale) × 2 vCPU · 4 GB RAM
Chưa cần
Database
PostgreSQL managed + PostGIS
1 × 2 vCPU · 8 GB · 100 GB SSD
PostgreSQL + PostGIS (HA)
1 primary + 1 read replica × 4 vCPU · 16 GB · 200 GB SSD
Chưa cần
Lưu ảnh
S3 / GCS
1 × 75 GB, presigned upload
Chưa cầnChưa cần
Push
FCM / APNs
1 × Dịch vụ bên ngoài
Chưa cầnChưa cần
CDN + S3Chưa cần
CloudFront + S3
1 × 5 triệu × 5 ảnh × 300 KB = 7,5 TB
CloudFront + S3 theo region
3 region × 100 triệu × 5 ảnh × 300 KB = 150 TB
Swipe storeChưa cần
DynamoDB on-demand
1 bảng swipes + 1 bảng like_pairs × Đỉnh ≈ 5.800 ghi/s, ≈ 520 GB
ScyllaDB, RF 3 trong mỗi region
21 node × 32 vCPU · 256 GB · 7,5 TB NVMe (dùng 50%)
RedisChưa cần
ElastiCache + RedisBloom
1 primary + 1 replica × 2 vCPU · 13 GB
Chưa cần
QueueChưa cần
Amazon SQS
1 × Managed, có dead-letter queue
Chưa cần
WorkerChưa cần
Node.js consumer (stack builder + push)
2–4 × 1 vCPU · 2 GB RAM
Stack builder + Notifier trên Kubernetes
≈ 30 pod × 2 vCPU · 4 GB mỗi pod
Swipe serviceChưa cầnChưa cần
Node.js trên Kubernetes, 3 region
193 pod (HPA) × 2 vCPU · 4 GB mỗi pod
Profile/Feed APIChưa cầnChưa cần
Node.js trên Kubernetes
58 pod (HPA) × 2 vCPU · 4 GB mỗi pod
Redis ClusterChưa cầnChưa cần
ElastiCache Redis Cluster + RedisBloom
10 shard × (1 primary + 1 replica) × 64 GB mỗi node
Geo indexChưa cầnChưa cần
OpenSearch geo_point, shard theo vùng
6 node mỗi region × 8 vCPU · 32 GB mỗi node
Database hồ sơChưa cầnChưa cần
Aurora PostgreSQL, mỗi region một cụm
1 primary + 2 replica mỗi region × 8 vCPU · 64 GB
KafkaChưa cầnChưa cần
MSK / Confluent
6 broker (2 mỗi region) × Topic swipes 24 partition, RF 3

Đà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) Làm sao tạo stack ứng viên ở gần nhanh khi có hàng chục triệu hồ sơ?

Cách chưa tốt
Tính khoảng cách Haversine cho mọi hồ sơ trong mỗi lần mở app

SELECT ... WHERE haversine(lat, lng, :lat, :lng) < :r. Hàm tính trên từng hàng nên không dùng được index, phải quét cả bảng.

Đánh đổi: Chạy được với vài nghìn hồ sơ, với hàng triệu hồ sơ thì mỗi lần mở app mất vài giây và DB nóng máy.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Searching" (quét toàn bộ để tìm gần chậm) ; Chưa có nguồn trong thư viện: hàm Haversine không có trong sách

Cách tốt
Geo index (PostGIS GiST, geohash hoặc quadtree) và truy vấn khi người dùng mở app

Index không gian chia bản đồ thành ô, truy vấn chỉ đọc các ô quanh người dùng rồi lọc tuổi, giới tính. Cách chia ô và tìm ô lân cận giống phần QuadTree ở bài Yelp trong Grokking ch.29.

Đánh đổi: Nhanh hơn nhiều nhưng mỗi lần mở app vẫn là một truy vấn geo cộng lọc người đã quẹt, ở thành phố đông có thể vài trăm ms và dồn tải vào DB giờ cao điểm.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Search using a QuadTree" ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.6 Special Index and Collection Types · mục "Geospatial Indexing" (tr. 120) ; Chưa có nguồn trong thư viện: PostGIS, geohash, OpenSearch geo_point không có sách trong thư viện

Cách tốt nhất
Tính sẵn stack bằng worker trên geo index chia theo vùng, phát dần từ Redis

Hồ sơ đang hoạt động được đánh index trong OpenSearch với geo_point, chia shard theo vùng địa lý. Stack builder chạy nền: truy vấn theo bán kính và tiêu chí, loại id có trong Bloom filter, xếp hạng (gần, hoạt động gần đây, mức hợp), ghi 100 id vào Redis. API chỉ việc lấy 20 id từ list nên p95 vài ms. Stack được dựng lại khi còn dưới 20 id hoặc khi người dùng đổi vị trí hay tiêu chí.

Đánh đổi: Stack có thể cũ vài phút (người kia vừa tắt tài khoản hoặc đổi vị trí), nên lúc phát ra vẫn kiểm tra nhanh trạng thái hồ sơ; thêm một cụm search và worker phải vận hành.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Search using segments", "Data partitioning" ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · Background of Distributed Cache · mục "Writing policies" (write-around: ghi vào cache sau lần trượt) ; Seven Databases in Seven Weeks ↗ (.zip) · ch.3 HBase · Day 2: Working with Big Data · mục "Compression and Bloom Filters" (How Do Bloom Filters Work?, tr. 110–111) ; Chưa có nguồn trong thư viện: OpenSearch geo_point và mô hình xếp hạng stack là thiết kế của khoá học

2) Ghi hàng trăm nghìn lượt quẹt mỗi giây thế nào?

Cách chưa tốt
Một bảng swipes trên Postgres, mỗi lượt quẹt một transaction

Đơn giản và đúng ở quy mô nhỏ. Ở 5 tỷ lượt/ngày, bảng tăng 150 GB mỗi ngày, index B-tree lớn hơn RAM và một primary không nhận nổi hơn 100.000 ghi/s.

Đánh đổi: Tốt cho mức khởi đầu, sẽ thành nút thắt đầu tiên khi tăng trưởng.

Nguồn: Grokking Modern System Design · ch.4 Non-functional System Characteristics ↗ · Scalability · mục "Vertical scalability--scaling up" và "Horizontal scalability--scaling out" ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Why do we partition data?" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (1.000 req/s mỗi máy 2 vCPU, chạy tối đa 60% CPU, Redis ×1,3)

Cách tốt
Đẩy lượt quẹt vào queue rồi ghi theo lô vào Postgres chia partition

API ghi vào queue và trả lời ngay, worker gom 500 lượt một lần INSERT vào bảng chia partition theo hash(swiper_id). Số transaction giảm hàng trăm lần.

Đánh đổi: Lượt quẹt nằm trong queue vài giây nên kiểm tra match và Bloom filter phải dựa vào chỗ khác; vẫn phải tự chia shard khi một node đầy.

Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability) · mục "Improved performance" ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Sharding" (Key-range based sharding, Hash-based sharding, Consistent hashing) (hash-based sharding)

Cách tốt nhất
Store ghi tối ưu (ScyllaDB/Cassandra hoặc DynamoDB), khóa phân vùng là swiper_id

LSM-tree ghi tuần tự nên mỗi node nhận hàng chục nghìn ghi/s. Partition key swiper_id, clustering key target_id: kiểm tra 'đã quẹt người này chưa' là một lần đọc theo khóa. Lượt quẹt trái đặt TTL 90 ngày để dữ liệu không phình mãi. Sự kiện quẹt đi qua Kafka cho thống kê và mô hình xếp hạng.

Đánh đổi: Không có join hay transaction nhiều hàng; truy vấn nào cũng phải thiết kế bảng theo đúng mẫu truy cập từ trước.

Nguồn: Grokking Modern System Design · ch.10 Key-value Store ↗ · mục "Consistent hashing" và "Data replication" ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Sharding" (Key-range based sharding, Hash-based sharding, Consistent hashing) ; Chưa có nguồn trong thư viện: LSM-tree và TTL của ScyllaDB/Cassandra/DynamoDB (thư viện không có sách về các sản phẩm này)

3) A và B quẹt phải nhau gần như cùng lúc. Làm sao chắc chắn ra đúng một match?

Cách chưa tốt
Đọc xem B đã thích A chưa, rồi mới ghi A thích B

Kiểm tra rồi mới làm (check-then-act) trong code ứng dụng. Nếu hai request chạy song song, cả hai cùng đọc thấy 'chưa', cùng ghi, và không ai tạo match.

Đánh đổi: Lỗi chỉ xảy ra khi hai người quẹt sát nhau nên khó tái hiện, nhưng ở 34.000 lượt thích/s thì ngày nào cũng gặp.

Nguồn: Redis in Action ↗ · ch.4 · §4.4 Redis transactions (tr. 78, vì sao đọc-rồi-ghi cần giao dịch) ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Race condition"

Cách tốt
Script Lua trên Redis cho mỗi cặp

Một script ghi 'A thích B' và đọc 'B thích A' trong cùng một lệnh. Redis chạy lệnh tuần tự nên script là nguyên tử (Redis in Action ch.11).

Đánh đổi: Redis không phải nguồn sự thật: failover có thể mất lượt thích vừa ghi, và giữ mọi lượt thích trong RAM rất tốn.

Nguồn: Redis in Action ↗ · ch.11 · §11.1 Adding functionality without writing C (tr. 250) ; Redis in Action ↗ · ch.11 · §11.3 Doing away with WATCH/MULTI/EXEC (tr. 260) ; Grokking Modern System Design · ch.9 Databases ↗ · Data Replication · mục "Single leader/primary-secondary replication" và "Synchronous versus asynchronous replication" (failover bất đồng bộ có thể mất bản ghi cuối)

Cách tốt nhất
Bản ghi LikePair theo khóa (low_id, high_id), cập nhật có điều kiện trong DB, match_id tất định

Cả hai lượt thích ghi vào cùng một bản ghi, nên DB tuần tự hóa chúng. Postgres: INSERT ... ON CONFLICT DO UPDATE SET high_liked = true RETURNING low_liked, high_liked. DynamoDB: UpdateItem với ReturnValues=ALL_NEW. ScyllaDB: lightweight transaction (Paxos) chỉ cho lượt quẹt phải. Ai thấy cả hai cờ true thì tạo match với match_id = hash(low, high); nếu cả hai cùng thấy thì ghi trùng cùng một id, không sinh hai match.

Đánh đổi: LWT tốn khoảng 4 vòng mạng nên chỉ dùng cho quẹt phải (30% lượt quẹt); push báo match phải chống gửi trùng theo match_id.

Nguồn: Chưa có nguồn trong thư viện: LikePair theo khóa (low, high), lightweight transaction của ScyllaDB và UpdateItem của DynamoDB là thiết kế/sản phẩm không có sách trong thư viện ; Grokking Modern System Design · ch.9 Databases ↗ · mục "Hash-based sharding" (khóa quyết định cùng một nơi ghi)

4) Làm sao không hiện lại hồ sơ người dùng đã quẹt?

Cách chưa tốt
WHERE id NOT IN (SELECT target_id FROM swipes WHERE swiper_id = ?)

Mỗi lần dựng stack lại đọc toàn bộ lịch sử quẹt. Người quẹt 100 lần/ngày sau 3 tháng có 9.000 lượt, truy vấn ngày càng chậm.

Đánh đổi: Chính xác tuyệt đối nhưng chi phí tăng theo tuổi tài khoản.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Improve data fetching" (tránh đọc toàn bộ dữ liệu mỗi lần) ; Chưa có nguồn trong thư viện: truy vấn NOT IN là ví dụ minh họa của khoá học

Cách tốt
Tập id đã quẹt trong Redis (SET)

SISMEMBER cho mỗi ứng viên là O(1). Nhưng một phần tử trong SET tốn khoảng 50 B, 5.000 id là 250 KB mỗi người, 50 triệu người là khoảng 12,5 TB RAM.

Đánh đổi: Nhanh và chính xác nhưng quá đắt ở quy mô lớn.

Nguồn: Redis in Action ↗ · ch.3 · §3.3 Sets (tr. 46) ; Quy tắc ước lượng của khoá học · /hoc/chon-server (1.000 req/s mỗi máy 2 vCPU, chạy tối đa 60% CPU, Redis ×1,3) (Redis ×1,3)

Cách tốt nhất
Bloom filter mỗi người (RedisBloom), dựng lại từ swipe store khi mất

Bloom filter trả lời 'chắc chắn chưa thấy' hoặc 'có thể đã thấy'. Với tỉ lệ dương tính giả 1% cần khoảng 9,6 bit mỗi id, 5.000 id chỉ khoảng 6 KB. Sai thì chỉ bỏ qua nhầm khoảng 1% hồ sơ chưa xem, không bao giờ hiện lại hồ sơ đã quẹt. Cơ chế này cũng là cách HBase dùng Bloom filter để tránh đọc đĩa vô ích (Seven Databases in Seven Weeks, phần HBase).

Đánh đổi: Không xóa được phần tử, nên để quẹt trái hết hạn sau 90 ngày phải xoay vòng 2 filter theo thời gian; filter mất thì dựng lại từ ScyllaDB.

Nguồn: Seven Databases in Seven Weeks ↗ (.zip) · ch.3 HBase · Day 2: Working with Big Data · mục "Compression and Bloom Filters" (How Do Bloom Filters Work?, tr. 110–111) (không có âm tính giả, dương tính giả tăng khi thêm nhiều phần tử, dùng bộ nhớ cố định) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Cache client" (Note: Bloom filter kiểm tra phần tử chắc chắn không có) ; Chưa có nguồn trong thư viện: 9,6 bit mỗi id cho tỉ lệ dương tính giả 1% và việc xoay vòng 2 filter là ước lượng/thiết kế của khoá học

Trong phỏng vấn

Nên nói
  • “Đây là hệ thống ghi nhiều: mỗi người quẹt khoảng 100 lần/ngày, nên tôi chọn store tối ưu cho ghi với khóa là swiper_id.”
  • “Tôi tính sẵn stack ứng viên ở nền và phát từ Redis, để lấy feed chỉ là một lần đọc list.”
  • “Hai lượt thích của một cặp ghi vào cùng một bản ghi (low_id, high_id), DB tuần tự hóa nên không bao giờ mất match.”
  • “Bloom filter loại người đã quẹt với khoảng 1,2 B mỗi id; sai thì chỉ bỏ qua nhầm hồ sơ, không bao giờ hiện lại.”
Bẫy hay gặp
  • Kiểm tra 'B đã thích A chưa' rồi mới ghi, bị race khi hai người quẹt cùng lúc.
  • Tính khoảng cách cho mọi hồ sơ trong mỗi lần mở app.
  • Lưu toàn bộ id đã quẹt dạng SET trong RAM, tốn hàng TB.
  • Cho ảnh đi qua API hoặc lưu ảnh trong DB.

Đọc thêm

Tìm kiếm gần (proximity search)
Cách tìm nhà hàng, tài xế hay cửa hàng gần một tọa độ: geohash, quadtree, S2/H3, Redis GEO, PostGIS và Elasticsearch geo_point, cùng cách chọn giữa dữ liệu ghi liên tục và dữ liệu chủ yếu để đọc.
Scale ghi
Khi lượng ghi vượt sức một database: gom batch, ghi bất đồng bộ qua hàng đợi, chọn storage tối ưu cho ghi, chia dữ liệu theo key, xử lý partition nóng bằng salting, sharded counter và tổng hợp trước. Kèm con số cho từng quy mô.
Cassandra
Cassandra là database wide-column phân tán, ghi rất nhanh nhờ LSM tree, mở rộng gần tuyến tính bằng ring và consistent hashing. Bài này giải thích partition key, clustering column, cách mô hình hoá theo câu truy vấn, tunable consistency và những cái bẫy như tombstone.
Redis
Redis là kho key-value trong RAM với nhiều cấu trúc dữ liệu: dùng làm cache, bộ đếm, leaderboard, hàng đợi, GEO và pub/sub. Bài này đi qua mô hình dữ liệu, persistence, replication, Cluster, Lua và cách tính RAM.
Cấu trúc dữ liệu cho big data
Bloom filter, Count-Min Sketch, HyperLogLog, Top-K bằng heap và Space-Saving, skip list, LSM tree: mỗi cấu trúc giải bài toán gì, tốn bao nhiêu RAM, sai số bao nhiêu, và bài thiết kế nào cần tới.
Tranh chấp tài nguyên
Chống bán vượt và đặt trùng khi nhiều request cùng giành một tài nguyên: khóa bi quan, optimistic locking, UPDATE có điều kiện, distributed lock, hàng đợi theo key và giữ chỗ có hạn.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.