Harbor

Giao hàng nhanh nội thành

Trung bìnhTồn kho theo vị tríKhông bán vượtSaga đặt đơnƯớc lượng ETAGiống GrabMart, Gopuff, BAEMIN

Hiểu đề

Giao hàng nhanh nội thành là gì? Ứng dụng giao tạp hóa, đồ uống, đồ dùng trong 15-30 phút từ các kho nhỏ (dark store) đặt rải rác trong thành phố. Ví dụ một khách ở quận 7 mở app lúc 19h, chỉ thấy món còn hàng ở kho gần nhất và nhận hàng sau khoảng 20 phút.

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

  1. Người dùng có thể xem các món còn hàng ở kho phục vụ địa chỉ của mình, kèm thời gian giao dự kiến
  2. Người dùng có thể đặt đơn nhiều món và thanh toán bằng ví điện tử hoặc tiền mặt
  3. Kho nhận đơn để soạn hàng, hệ thống gán shipper gần kho
  4. Người dùng có thể theo dõi trạng thái đơn và vị trí shipper

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

  • Không bán vượt tồn kho: đơn bị hủy vì hết hàng < 0,1%
  • Đặt đơn p95 < 1 s, xem danh mục p95 < 200 ms
  • Tồn kho hiển thị trên app trễ ≤ 5 s so với thực tế
  • ETA lệch không quá ±5 phút với 80% đơn
  • Độ sẵn sàng 99,9%, đặc biệt trong giờ cao điểm 11-13h và 18-20h

Ngoài phạm vi: Nhập hàng, quản lý nhà cung cấp và dự báo nhu cầu; Ghép nhiều đơn cho một shipper (batching) và tối ưu tuyến; Hệ thống khuyến mãi, voucher; Thanh toán tiền công cho shipper.

Thực thể và API

DarkStore
Kho nhỏ: vị trí, vùng phục vụ (danh sách ô H3/geohash), giờ mở, công suất soạn đơn mỗi giờ.
Product
Thông tin món dùng chung mọi kho: SKU, tên, ảnh, giá, danh mục.
Inventory
Tồn kho của một SKU tại một kho: on_hand, reserved, safety_buffer, version.
Order
Đơn: user, store_id, danh sách món, tổng tiền, trạng thái (PENDING → CONFIRMED → PICKING → DELIVERING → DONE / CANCELLED), idempotency key, ETA.
Reservation
Phần hàng đang giữ cho một đơn chưa thanh toán: order_id, store_id, sku, qty, expires_at.
Courier
Shipper: trạng thái (rảnh/đang giao), vị trí mới nhất, kho đang trực.
MethodEndpointGhi chú
GET/v1/stores/serving?lat=&lng=Trả kho phục vụ địa chỉ này và ETA hiện tại. Kết quả cache theo ô H3.
GET/v1/stores/{storeId}/catalog?category=Danh sách món (thông tin tĩnh qua CDN) ghép với trạng thái còn hàng từ Redis.
POST/v1/ordersBody {storeId, items[], address, paymentMethod}, header Idempotency-Key. Giữ hàng và tạo đơn PENDING; 409 nếu món đã hết.
POST/v1/payments/webhookVí điện tử gọi về. Idempotent theo mã giao dịch, xác nhận đơn.
GET/v1/orders/{id}Trạng thái đơn và ETA; cập nhật real-time qua WebSocket /v1/orders/{id}/live.
POST/v1/couriers/me/locationApp shipper gửi vị trí mỗi 5 s khi đang trực.

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 xem món còn hàng ở kho gần mình

Mỗi địa chỉ được gán cho đúng một kho dựa trên vùng phục vụ đã tính sẵn, nên app chỉ hiển thị danh mục của một kho. Thông tin món gần như tĩnh và đi qua CDN; chỉ phần 'còn hàng' là động và được đọc từ Redis.

  1. 1App đổi tọa độ giao hàng thành ô H3 (cạnh khoảng 170 m)
  2. 2Tra bảng ô H3 → store_id (đã tính sẵn theo thời gian di chuyển, không theo đường chim bay)
  3. 3Tải danh mục tĩnh của kho qua CDN (tên, ảnh, giá)
  4. 4Gọi Catalog API lấy trạng thái còn hàng từ Redis: available = on_hand - reserved - safety_buffer
  5. 5Kèm ETA hiện tại của kho cho ô H3 đó

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Searching" và "Search using a QuadTree" (chia vùng để tìm gần) ; 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) ; Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Pull CDN" ; Chưa có nguồn trong thư viện: lưới H3, GEOSEARCH của Redis và PostGIS không có sách trong thư viện; sách Grokking dùng QuadTree và segment cho tìm theo vị trí

2) Người dùng đặt đơn và thanh toán

Một đơn chỉ lấy hàng từ một kho, nên giữ hàng cho mọi món nằm trong một transaction trên cùng một DB. Đơn và phần giữ hàng được tạo cùng lúc, thanh toán đi sau; thanh toán thất bại thì nhả hàng.

  1. 1POST /v1/orders với Idempotency-Key; trùng key thì trả lại kết quả cũ
  2. 2Trong 1 transaction: với mỗi món (sắp theo SKU để tránh deadlock) UPDATE inventory SET reserved = reserved + q WHERE store_id=? AND sku=? AND on_hand - reserved - safety_buffer >= q
  3. 3Món nào cập nhật 0 dòng thì rollback, trả 409 kèm danh sách món hết
  4. 4Thành công: ghi Order PENDING, Reservation hạn 10 phút và bản ghi outbox
  5. 5Ví gọi webhook: đơn sang CONFIRMED; tiền mặt thì xác nhận ngay; quá hạn chưa trả thì hủy và nhả hàng

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.1 Transaction management in a microservice architecture · "The trouble with distributed transactions" (tr. 112) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.2.2 Orchestration-based sagas (tr. 121) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3 (semantic lock cho bản giữ hàng có hạn, tr. 128) ; Redis in Action ↗ · ch.4 · §4.4 Redis transactions (tr. 78)

3) Kho soạn hàng và hệ thống gán shipper

Đơn đã xác nhận được đẩy sang app của nhân viên kho qua sự kiện. Việc gán shipper bắt đầu ngay khi kho nhận đơn chứ không chờ soạn xong, để shipper tới vừa lúc hàng sẵn sàng.

  1. 1Sự kiện order_confirmed từ outbox đi vào queue
  2. 2App kho nhận đơn, nhân viên soạn và quét mã từng món
  3. 3Khi soạn xong: on_hand -= q, reserved -= q trong 1 transaction; món thiếu thực tế thì báo khách thay thế hoặc hoàn tiền
  4. 4Dispatch tìm shipper rảnh gần kho (Redis GEOSEARCH bán kính 2 km) và gửi lời mời, không nhận trong 20 s thì mời người tiếp theo

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Motivation" (Improved performance, Granular scalability) ; Grokking Modern System Design · ch.30 Design Uber ↗ · Detailed Design of Uber · mục "Find driver" ; Chưa có nguồn trong thư viện: lưới H3, GEOSEARCH của Redis và PostGIS không có sách trong thư viện; sách Grokking dùng QuadTree và segment cho tìm theo vị trí

4) Người dùng theo dõi đơn và shipper

App shipper gửi vị trí mỗi 5 s vào Redis GEO. Khách đang mở màn hình đơn giữ một WebSocket và nhận vị trí shipper cùng ETA cập nhật.

  1. 1App shipper POST vị trí mỗi 5 s, lưu GEOADD couriers:{city} và key vị trí theo shipper
  2. 2Tracking service publish vị trí của shipper đang giao lên kênh của đơn
  3. 3Khách mở màn hình đơn: WebSocket subscribe kênh đơn, nhận vị trí và ETA mới
  4. 4Mất kết nối thì GET /v1/orders/{id} để lấy trạng thái mới nhất

Nguồn: Grokking Modern System Design · ch.30 Design Uber ↗ · Detailed Design of Uber · mục "Location manager" và "Show trip updates" ; Grokking Modern System Design · ch.36 WhatsApp ↗ · mục "Connection with a WebSocket server" ; Grokking Modern System Design · ch.18 Pub-sub ↗ · mục "High-level design" ; Chưa có nguồn trong thư viện: lưới H3, GEOSEARCH của Redis và PostGIS không có sách trong thư viện; sách Grokking dùng QuadTree và segment cho tìm theo vị trí

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, 10 kho ở một thành phố
Tải đỉnh
≈ 55 req/s đỉnh
Chi phí ước lượng
≈ 150–400 USD/tháng (ước lượng, phí Maps API chiếm phần đáng kể)

Thử nghiệm ở một thành phố. Hai instance API, một Postgres có PostGIS chứa cả tồn kho, đơn và vùng phục vụ. Giữ hàng bằng UPDATE có điều kiện ngay trên Postgres.

Người dùngNgười dùngNgười dùngApp shipperCDNCDN ảnhDịch vụAPI Node.jsDatabasePostgres+PostGISBên ngoàiCổng thanh toánBên ngoàiMaps APIBên ngoàiPush (FCM)ảnh, danh mụcHTTPSvị trí 5 sgiữ hàngthanh toángeocode, ETAtrạng thái đơn

Ước lượng

  • Duyệt: 10.000 DAU × 30 req / 86.400 ≈ 3,5 req/s, đỉnh ×10 ≈ 35 req/s
  • Vị trí shipper: 100 shipper × 1 lần / 5 s = 20 ghi/s
  • API: ceil((35 + 20) / (1.000 × 0,6)) = 1 → lấy tối thiểu 2 instance
  • Đơn: 10.000 × 20% = 2.000 đơn/ngày; giờ cao điểm 15% = 300 đơn / 3.600 s ≈ 0,08 đơn/s × 5 món ≈ 0,4 ghi tồn kho/s
  • Tồn kho: 10 kho × 3.000 SKU = 30.000 dòng × 200 B = 6 MB
  • Đơn hàng: 2.000 × 2 KB × 365 = 1.460.000 KB ≈ 1,5 GB/năm

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) ; Grokking Modern System Design · ch.30 Design Uber ↗ · Requirements of Uber's Design · mục "Resource estimation" ; 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)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
CDNCloudflare1Ảnh sản phẩm, danh mục tĩnhẢnh là phần nặng nhất khi duyệt, đẩy hết ra CDN.
APINode.js (NestJS), Docker trên Cloud Run22 vCPU · 4 GB RAM55 req/s chỉ cần 1 instance, chạy 2 để dự phòng.
DatabasePostgreSQL managed + PostGIS12 vCPU · 8 GB · 50 GB SSD6 MB tồn kho và dưới 1 ghi/s; PostGIS tìm kho gần, UPDATE có điều kiện chống bán vượt.
Job nhả hàngCloud Scheduler gọi API mỗi phút1Chạy trong APIHủy đơn PENDING quá 10 phút và trả lại reserved.
Thanh toánMoMo / ZaloPay / VNPay1Dịch vụ bên ngoàiChỉ nhận webhook, không tự xử lý thẻ.
Bản đồ + pushGoogle Maps / Goong API, Firebase Cloud Messaging1Dịch vụ bên ngoàiGeocoding địa chỉ, thời gian đi; báo trạng thái đơn cho khách và shipper.
Thay đổi so với mức trước
  • Điểm xuất phát: một API, một Postgres, CDN cho ảnh
  • Tìm kho gần bằng PostGIS, giữ hàng bằng UPDATE có điều kiện, chưa cần Redis
  • ETA tính đơn giản: thời gian soạn trung bình + thời gian đi từ Maps API
Nút thắt tiếp theo

Mở rộng ra vài trăm kho thì mỗi lần mở app đều chạm DB và gọi Maps API, chi phí và độ trễ tăng nhanh. Vị trí shipper ghi thẳng vào Postgres cũng sẽ nghẽn khi có hàng nghìn shipper.

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, 10 kho ở một thành phố1 triệu DAU, 300 kho ở 3 thành phố10 triệu DAU, 3.000 kho ở 10 thành phố
Chi phí≈ 150–400 USD/tháng (ước lượng, phí Maps API chiếm phần đáng kể)≈ 4.000–10.000 USD/tháng (ước lượng)≈ 40.000–100.000 USD/tháng (ước lượng, chưa tính phí bản đồ và chạy mô hình)
CDN
Cloudflare
1 × Ảnh sản phẩm, danh mục tĩnh
CloudFront / Cloudflare
1 × Ảnh, danh mục tĩnh theo kho (TTL 5 phút)
Chưa cần
API
Node.js (NestJS), Docker trên Cloud Run
2 × 2 vCPU · 4 GB RAM
Chưa cầnChưa cần
Database
PostgreSQL managed + PostGIS
1 × 2 vCPU · 8 GB · 50 GB SSD
PostgreSQL managed (HA) + PostGIS
1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD
PostgreSQL + Citus (hoặc Aurora chia shard), shard theo store_id
8 shard × (1 primary + 1 replica) × 8 vCPU · 32 GB · 1 TB SSD mỗi node
Job nhả hàng
Cloud Scheduler gọi API mỗi phút
1 × Chạy trong API
Chưa cầnChưa cần
Thanh toán
MoMo / ZaloPay / VNPay
1 × Dịch vụ bên ngoài
Chưa cầnChưa cần
Bản đồ + push
Google Maps / Goong API, Firebase Cloud Messaging
1 × Dịch vụ bên ngoài
Chưa cầnChưa cần
Catalog APIChưa cần
Node.js, container autoscale
2–4 (autoscale) × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes, 3 AZ
39 pod (HPA) × 2 vCPU · 4 GB mỗi pod
Order serviceChưa cần
Node.js (NestJS) + saga điều phối
2 × 2 vCPU · 4 GB RAM
Node.js, saga điều phối
6 pod × 2 vCPU · 4 GB mỗi pod
Tracking serviceChưa cần
Node.js WebSocket + Redis GEO, pub/sub
3 × 2 vCPU · 4 GB RAM
Chưa cần
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 8 GB
Chưa cần
Queue + WorkerChưa cần
SQS / Pub/Sub + Node.js consumer
1 queue, 3 worker × 1 vCPU · 2 GB mỗi worker
Chưa cần
Dịch vụ ngoàiChưa cần
Ví điện tử, Maps API, FCM
3 dịch vụ × Maps chỉ dùng để tính sẵn thời gian đi
Chưa cần
CDN + WAFChưa cầnChưa cần
Cloudflare / CloudFront
1 × Ảnh, danh mục tĩnh theo kho, chống bot khi flash sale
Location ingest + TrackingChưa cầnChưa cần
Node.js trên Kubernetes
20 pod ingest + 6 pod WebSocket × 2 vCPU · 4 GB mỗi pod
Redis ClusterChưa cầnChưa cần
ElastiCache Redis Cluster
3 shard × (1 primary + 1 replica) × 4 vCPU · 16 GB mỗi node
KafkaChưa cầnChưa cần
MSK / Confluent
3 broker × Topic orders, inventory (theo store_id), locations 12 partition; RF 3
Dispatch + ETAChưa cầnChưa cần
Node.js consumer + dịch vụ mô hình (Python)
8 pod + 4 pod × 2 vCPU · 4 GB mỗi pod

Đà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 biết món nào còn hàng ở kho gần khách, khi có hàng trăm kho và hàng nghìn SKU?

Cách chưa tốt
Mỗi request tìm mọi kho trong bán kính 3 km rồi JOIN bảng tồn kho

Query khoảng cách trên tọa độ kho cộng với JOIN inventory cho hàng nghìn SKU ở mỗi lần mở app. Kho gần theo đường chim bay có thể cách một con sông hoặc phải đi vòng xa.

Đánh đổi: Tốn DB cho thao tác đọc nhiều nhất, và gộp hàng của nhiều kho thì đơn phải tách giao nhiều nơi.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · Design Considerations of Yelp · mục "Search using segments" (quét theo vùng thay vì JOIN toàn bộ) ; Chưa có nguồn trong thư viện: truy vấn khoảng cách + JOIN tồn kho là ví dụ minh họa của khoá học

Cách tốt
Index không gian (PostGIS / geohash) tìm kho, tồn kho đọc từ replica

Index GiST trên vị trí kho cho ra vài kho gần nhất trong vài ms, chọn kho gần nhất còn mở. Danh mục và tồn kho đọc từ read replica, cache vài giây.

Đánh đổi: Vẫn tính khoảng cách mỗi lần, và replica trễ làm khách thấy món đã hết.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · mục "Search using a QuadTree" ; Grokking Modern System Design · ch.9 Databases ↗ · Data Replication · mục "Single leader/primary-secondary replication" và "Synchronous versus asynchronous replication" (đọc từ replica có thể trễ) ; Chưa có nguồn trong thư viện: lưới H3, GEOSEARCH của Redis và PostGIS không có sách trong thư viện; sách Grokking dùng QuadTree và segment cho tìm theo vị trí

Cách tốt nhất
Vùng phục vụ tính sẵn theo ô H3 + độ còn hàng trong Redis cập nhật bằng sự kiện

Mỗi đêm (hoặc khi kho đổi giờ mở) tính sẵn ô H3 nào thuộc kho nào dựa trên thời gian di chuyển thực tế, lưu thành bảng tra cứu. Mở app chỉ là một lookup ô H3 → store_id. Thông tin món tĩnh nằm trên CDN theo kho. Độ còn hàng là một hash trong Redis store:{id}:avail được cập nhật từ sự kiện tồn kho (outbox), trễ dưới 1-2 s. Hiển thị available = on_hand - reserved - safety_buffer để chừa phần chênh lệch kiểm kê. Kho quá tải hoặc đóng cửa thì đổi ô H3 sang kho dự phòng.

Đánh đổi: Redis chỉ là bản hiển thị và có thể lệch vài giây; bước đặt đơn vẫn phải kiểm tra lại trên DB.

Nguồn: Grokking Modern System Design · ch.29 Design a Proximity Service (Yelp) ↗ · mục "Search using segments" và "Dynamic segments" ; 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) ; Grokking Modern System Design · ch.11 CDN ↗ · mục "Pull CDN" ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Chưa có nguồn trong thư viện: lưới H3, GEOSEARCH của Redis và PostGIS không có sách trong thư viện; sách Grokking dùng QuadTree và segment cho tìm theo vị trí

2) Hai khách cùng đặt món cuối cùng trong kho. Làm sao không bán vượt?

Cách chưa tốt
Đọc tồn kho, thấy còn thì tạo đơn, trừ kho sau

SELECT on_hand, nếu > 0 thì INSERT đơn rồi UPDATE trừ. Hai request chạy song song đều đọc thấy còn 1 và đều tạo đơn.

Đánh đổi: Bán vượt ngay trong giờ cao điểm hoặc khi chạy khuyến mãi.

Nguồn: Redis in Action ↗ · ch.4 · §4.4 Redis transactions (tr. 78, vì sao đọc rồi ghi cần WATCH/MULTI/EXEC) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3 Handling the lack of isolation · "Countermeasures for handling the lack of isolation" (semantic lock, tr. 128)

Cách tốt
Khóa Redis hoặc DECR trên Redis làm nguồn tồn kho

Dùng script Lua kiểm tra và giảm số lượng nguyên tử cho mọi món trong đơn. Rất nhanh, chịu được hàng chục nghìn thao tác mỗi giây.

Đánh đổi: Redis failover có thể mất lần ghi vừa xong, và tồn kho nằm hai nơi (Redis và DB) phải đối soát liên tục.

Nguồn: Redis in Action ↗ · ch.11 Scripting Redis with Lua · §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 lần ghi cuối) ; Redis in Action ↗ · ch.6 · §6.2 Distributed locking (tr. 116)

Cách tốt nhất
UPDATE có điều kiện trên DB, giữ hàng có hạn, phân vùng theo kho

Nguồn sự thật là dòng inventory trên DB. Giữ hàng là UPDATE ... SET reserved = reserved + q WHERE on_hand - reserved - safety_buffer >= q, mọi món trong một transaction, sắp theo SKU để không deadlock. Đơn chỉ thuộc một kho nên transaction luôn nằm gọn trong một shard khi chia theo store_id. Reservation có expires_at và job nhả hàng quá hạn, giống semantic lock (Microservices Patterns §4.3). Tải ghi mỗi kho bị chặn tự nhiên bởi công suất soạn đơn (vài trăm đơn/giờ), nên khó có điểm nóng. Lúc flash sale món hot thì thêm Redis Lua phía trước để loại sớm request chắc chắn thua.

Đánh đổi: Thiếu hàng thực tế (vỡ, hết hạn, đếm sai) vẫn xảy ra, nên cần safety_buffer và luồng thay thế/hoàn tiền khi nhân viên soạn báo thiếu.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.3 Handling the lack of isolation · "Countermeasures for handling the lack of isolation" (semantic lock, tr. 128) ; Grokking Modern System Design · ch.9 Databases ↗ · Data Partitioning · mục "Sharding" (Key-range based sharding, Hash-based sharding, Consistent hashing) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3 Handling the lack of isolation (tr. 126–132) ; Redis in Action ↗ · ch.11 Scripting Redis with Lua · §11.3 Doing away with WATCH/MULTI/EXEC (tr. 260) ; Chưa có nguồn trong thư viện: mệnh đề UPDATE ... WHERE on_hand - reserved >= q và safety_buffer là thiết kế của khoá học

3) Đặt đơn đụng tới tồn kho, thanh toán, kho và shipper. Giữ nhất quán thế nào?

Cách chưa tốt
Gọi đồng bộ lần lượt từng service, lỗi ở đâu thì thôi

Order API gọi Inventory trừ kho, gọi Payment trừ tiền, gọi Dispatch tìm shipper trong cùng một request. Payment timeout thì không rõ tiền đã trừ chưa, hàng đã giữ thì không ai nhả.

Đánh đổi: Dữ liệu lệch giữa các service, khách bị trừ tiền mà không có đơn hoặc hàng bị treo mãi.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.4 Using asynchronous messaging to improve availability · "Synchronous communication reduces availability" (tr. 103) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.1 (tr. 111–114)

Cách tốt
Transaction phân tán 2PC giữa các DB

Coordinator khóa tồn kho, đơn và thanh toán rồi commit cùng lúc. Đảm bảo nhất quán nếu mọi bên hỗ trợ.

Đánh đổi: Ví điện tử bên ngoài không tham gia 2PC, khóa giữ lâu trong lúc chờ người dùng xác nhận thanh toán, một bên chậm là cả hệ thống chậm (Microservices Patterns §4.1.2).

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.1 Transaction management in a microservice architecture · "The trouble with distributed transactions" (tr. 112) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.1 · "Using the Saga pattern to maintain data consistency" (tr. 114)

Cách tốt nhất
Saga điều phối: transaction cục bộ + outbox + bước bù, mọi bước idempotent

Bước 1 là một transaction cục bộ: tạo Order PENDING, giữ hàng và ghi outbox. Các bước sau do order saga điều phối qua sự kiện: chờ thanh toán, xác nhận đơn, gửi kho, gán shipper. Mỗi bước có bước bù: thanh toán thất bại hoặc quá 10 phút thì hủy đơn và nhả hàng; kho báo thiếu thì hoàn tiền phần thiếu. Idempotency-Key ở API và mã giao dịch ở webhook để retry không tạo đơn hay trừ tiền hai lần (Microservices Patterns §4.2.2, §3.3.7).

Đánh đổi: Hệ thống nhất quán sau vài giây và có trạng thái trung gian (PENDING) mà app phải hiển thị rõ; phải theo dõi saga bị kẹt.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.2 Coordinating sagas (tr. 117–126) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.2.2 Orchestration-based sagas (tr. 121) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95)

4) Ước lượng thời gian giao (ETA) thế nào cho đủ chính xác?

Cách chưa tốt
Hiển thị cố định 30 phút hoặc khoảng cách / tốc độ trung bình

Không tính tới kho đang dồn đơn, thiếu shipper, kẹt xe giờ tan tầm hay trời mưa. 18h ngày mưa ở TP.HCM có thể chậm gấp đôi.

Đánh đổi: Sai lệch lớn đúng lúc nhiều đơn nhất, khách mất niềm tin.

Nguồn: Grokking Modern System Design · ch.30 Design Uber ↗ · Detailed Design of Uber · mục "ETA service" (ETA không thể là hằng số)

Cách tốt
Cộng các thành phần: chờ soạn + chờ shipper + thời gian đi

Chờ soạn = số đơn đang chờ ở kho × thời gian soạn trung bình / số nhân viên. Chờ shipper dựa trên số shipper rảnh quanh kho. Thời gian đi lấy từ Maps API theo giờ trong ngày.

Đánh đổi: Gọi Maps API cho mỗi lần mở app rất tốn tiền; các hệ số trung bình không bắt được ngày mưa hay sự kiện.

Nguồn: Grokking Modern System Design · ch.30 Design Uber ↗ · Detailed Design of Uber · mục "ETA service" ; Chưa có nguồn trong thư viện: cách cộng thời gian soạn + chờ shipper + đi là mô hình của khoá học

Cách tốt nhất
Mô hình học từ lịch sử, tính trước theo kho và ô H3, hiển thị dạng khoảng

Mỗi 30 s tính trạng thái từng kho (hàng đợi soạn, shipper rảnh) lưu vào Redis. Thời gian đi giữa kho và từng ô H3 theo khung giờ được tính sẵn từ lịch sử chuyến giao. Một mô hình gradient boosting kết hợp các đặc trưng này với thời tiết, ngày trong tuần để ra ETA, nên mở app chỉ là đọc Redis vài key. Hiển thị dạng '15-25 phút', cập nhật lại sau khi gán shipper và trong lúc giao. Theo dõi sai số mỗi ngày để huấn luyện lại; ETA vượt 45 phút thì tạm thu hẹp vùng phục vụ của kho.

Đánh đổi: Cần pipeline dữ liệu và huấn luyện mô hình; kho mới chưa có lịch sử phải dùng cách cộng thành phần trong vài tuần đầu.

Nguồn: Grokking Modern System Design · ch.30 Design Uber ↗ · Detailed Design of Uber · mục "DeepETA" (mô hình học máy dự đoán ETA) ; 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) ; Chưa có nguồn trong thư viện: gradient boosting, ngưỡng 45 phút, tính trước theo ô H3 là lựa chọn của khoá học

Trong phỏng vấn

Nên nói
  • “Tôi gán mỗi địa chỉ cho đúng một kho qua vùng phục vụ tính sẵn, nên một đơn chỉ đụng tồn kho của một kho.”
  • “Redis chỉ để hiển thị còn hàng; giữ hàng là UPDATE có điều kiện trên DB với on_hand - reserved - safety_buffer.”
  • “Đặt đơn là saga: transaction cục bộ tạo đơn và giữ hàng, các bước sau qua outbox, mỗi bước có bước bù và idempotent.”
  • “ETA là tổng chờ soạn, chờ shipper và thời gian đi, tính sẵn theo kho; mở app chỉ đọc cache.”
Bẫy hay gặp
  • Đọc tồn kho rồi mới trừ ở bước riêng, gây bán vượt khi hai người đặt cùng lúc.
  • Dùng Redis làm nguồn sự thật duy nhất của tồn kho.
  • Gọi đồng bộ nối tiếp kho, thanh toán, shipper trong một request mà không có bước bù.
  • Tính kho gần nhất theo đường chim bay và gọi Maps API ở mỗi lần mở app.

Đọc thêm

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.
Quy trình nhiều bước
Giữ dữ liệu nhất quán khi một nghiệp vụ đi qua nhiều service: saga choreography và orchestration, hành động bù trừ, trạng thái PENDING, transactional outbox, idempotent consumer và workflow engine như Temporal.
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.
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.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.
Cập nhật realtime
Đẩy thay đổi tới client trong vài trăm mili giây: chọn giữa polling, long polling, SSE, WebSocket, rồi fan-out qua nhiều gateway, xử lý reconnect và backpressure.

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