Thiết kế hệ thống giao đồ ăn: đơn hàng, quán, shipper và trạng thái realtime
1. Tổng quan
Một ứng dụng giao đồ ăn có ba bên cùng tác động lên một đơn hàng: khách đặt, quán xác nhận và shipper nhận giao. Cả ba thao tác diễn ra đồng thời, trên mạng di động chập chờn, và dồn lại vào giờ trưa.
Bài này đi qua toàn bộ quá trình thiết kế: yêu cầu, mô hình dữ liệu, API, máy trạng thái, cách phân đơn cho shipper mà không bị trùng, cách giữ nhất quán giữa database và Kafka, và cách đẩy trạng thái realtime tới khách. Đọc xong, bạn có một khung thiết kế cụ thể để áp dụng hoặc mang vào buổi phỏng vấn system design.
2. Yêu cầu
2.1. Yêu cầu chức năng
- Khách xem menu, đặt đơn, thanh toán, theo dõi trạng thái và vị trí shipper.
- Quán nhận đơn, xác nhận hoặc từ chối, rồi báo món đã sẵn sàng.
- Shipper bật trạng thái trực tuyến, gửi vị trí định kỳ, nhận hoặc từ chối đơn được mời, cập nhật đã lấy hàng và đã giao.
- Khách có thể huỷ đơn trước khi quán bắt đầu nấu.
2.2. Quy mô và ràng buộc
Ta giả định các con số sau để có cơ sở tính toán:
Đơn hàng: 1.000.000 đơn/ngày, giờ cao điểm (11h–13h) chiếm ~30%
→ ~42 đơn/giây trung bình cao điểm, đột biến tới ~200 đơn/giây
Shipper: 50.000 shipper online, gửi vị trí mỗi 4 giây
→ ~12.500 lượt ghi vị trí/giây
Theo dõi: khách mở màn hình tracking → hàng trăm nghìn kết nối realtime
Từ các con số này, có ba ràng buộc chính:
- Đơn hàng phải nhất quán mạnh. Không được mất đơn và không được gán một đơn cho hai shipper.
- Vị trí shipper chấp nhận nhất quán yếu. Mất một điểm vị trí không sao, nhưng ghi phải rất nhanh. Ta không ghi vị trí vào database quan hệ.
- Đặt đơn phải idempotent. Mạng di động hay khiến client gửi lại request.
3. Kiến trúc tổng thể
App khách / quán / shipper
│ REST + SSE/WebSocket
▼
API Gateway (rate limit, auth)
│
┌────────┼───────────────┬──────────────────┐
▼ ▼ ▼ ▼
Order Restaurant Dispatch Tracking
Service Service Service Service
│ PostgreSQL │ Redis GEO │ Redis (vị trí, pub/sub)
│ + outbox │ │
└──────────► Kafka (order-events) ◄────────┘
Order Service là nguồn sự thật của đơn hàng. Các service còn lại phản ứng với sự kiện trên Kafka. Vị trí shipper chỉ nằm trong Redis.
Các ví dụ bên dưới dùng những dependency sau:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
4. Mô hình dữ liệu
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
restaurant_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL,
total_amount NUMERIC(12,0) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, idempotency_key)
);
CREATE TABLE order_items (
order_id BIGINT REFERENCES orders(id),
dish_id BIGINT NOT NULL,
dish_name VARCHAR(200) NOT NULL,
price NUMERIC(12,0) NOT NULL,
quantity INT NOT NULL
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;
Có vài điểm cần chú ý:
UNIQUE (customer_id, idempotency_key)chặn đơn trùng khi client gửi lại request.versionphục vụ optimistic locking khi nhiều bên cùng cập nhật một đơn.order_itemslưu lạidish_namevàpricetại thời điểm đặt. Quán đổi giá sau đó cũng không làm sai hoá đơn cũ.- Bảng
outboxdùng cho phần nhất quán ở mục 8.
5. Máy trạng thái đơn hàng
Một đơn hàng chỉ được đi theo các bước hợp lệ. Bạn nên mã hoá quy tắc này ở một chỗ duy nhất thay vì rải if khắp các service:
public enum OrderStatus {
PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
PLACED, Set.of(CONFIRMED, CANCELLED),
CONFIRMED, Set.of(PREPARING, CANCELLED),
PREPARING, Set.of(READY_FOR_PICKUP),
READY_FOR_PICKUP, Set.of(PICKED_UP),
PICKED_UP, Set.of(DELIVERED),
DELIVERED, Set.of(),
CANCELLED, Set.of()
);
public boolean canTransitionTo(OrderStatus next) {
return TRANSITIONS.get(this).contains(next);
}
}
TRANSITIONS liệt kê các trạng thái kế tiếp hợp lệ của từng trạng thái. Khách chỉ huỷ được ở PLACED hoặc CONFIRMED. Khi quán đã nấu (PREPARING), đơn không còn đường về CANCELLED. Hai trạng thái cuối không có bước tiếp theo.
Test kiểm tra quy tắc này:
class OrderStatusTest {
@Test
void customerCanCancelBeforePreparing() {
assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
}
@Test
void cannotCancelOnceKitchenStarted() {
assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void deliveredIsTerminal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo);
}
}
6. API
POST /api/orders Khách đặt đơn (header Idempotency-Key)
GET /api/orders/{id} Xem chi tiết đơn
POST /api/orders/{id}/cancel Khách huỷ đơn
POST /api/restaurant/orders/{id}/confirm Quán xác nhận
POST /api/restaurant/orders/{id}/ready Quán báo món sẵn sàng
PUT /api/shippers/me/location Shipper gửi vị trí {lat, lng}
POST /api/shippers/me/offers/{orderId}/accept Shipper nhận đơn
POST /api/orders/{id}/pickup | /deliver Shipper cập nhật
GET /api/orders/{id}/stream SSE: trạng thái + vị trí shipper
API được thiết kế theo hành động (/confirm, /accept) chứ không theo kiểu PATCH status=... chung chung. Cách này giúp kiểm tra quyền dễ hơn: chỉ quán của đơn mới được gọi /confirm, chỉ shipper được gán mới được gọi /pickup.
7. Xử lý tranh chấp
7.1. Cập nhật trạng thái với optimistic locking
Giả sử khách bấm huỷ đúng lúc quán bấm xác nhận. Nếu cả hai cùng đọc thấy PLACED, cả hai đều nghĩ mình hợp lệ. Ta giải quyết bằng một câu UPDATE có điều kiện:
@Repository
public class OrderRepository {
private final JdbcClient jdbc;
public OrderRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
if (!from.canTransitionTo(to)) {
throw new IllegalStateException(from + " -> " + to + " không hợp lệ");
}
int updated = jdbc.sql("""
UPDATE orders SET status = :to, version = version + 1
WHERE id = :id AND status = :from AND version = :version
""")
.param("to", to.name())
.param("id", orderId)
.param("from", from.name())
.param("version", version)
.update();
return updated == 1;
}
}
Database đảm bảo chỉ một trong hai câu UPDATE khớp điều kiện version. Bên thắng nhận true. Bên thua nhận false và trả về HTTP 409 kèm trạng thái mới nhất, để app hiển thị “Quán vừa xác nhận đơn”.
7.2. Phân đơn cho shipper
Đây là điểm khó nhất. Ta cần tìm shipper gần quán và đảm bảo mỗi đơn chỉ có một shipper.
Bước 1: lưu vị trí bằng Redis GEO. Mỗi lần shipper gửi vị trí, ta ghi vào một sorted set GEO và làm mới một key heartbeat:
@Service
public class ShipperLocationService {
private final StringRedisTemplate redis;
public ShipperLocationService(StringRedisTemplate redis) {
this.redis = redis;
}
public void update(long shipperId, double lat, double lng) {
String id = String.valueOf(shipperId);
redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
}
public List<String> findNearby(double lat, double lng) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance().sortAscending().limit(20);
var results = redis.opsForGeo().search("shipper:geo",
GeoReference.fromCoordinate(lng, lat),
new Distance(3, Metrics.KILOMETERS), args);
return results.getContent().stream()
.map(r -> r.getContent().getName())
.filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
.toList();
}
}
Point nhận kinh độ trước, vĩ độ sau, một lỗi rất dễ mắc. Member trong GEO set không tự hết hạn. Vì vậy key shipper:alive:* có TTL 30 giây được dùng để loại shipper đã tắt app hoặc mất sóng. Một job định kỳ có thể ZREM các member đã chết khỏi shipper:geo.
Với 12.500 lượt ghi mỗi giây, một Redis instance vẫn đáp ứng tốt. Khi mở rộng sang nhiều thành phố, bạn shard theo khu vực: shipper:geo:hcm, shipper:geo:hn.
Bước 2: mời từng shipper, mỗi người một lời mời. Dispatch Service gửi lời mời lần lượt cho shipper gần nhất. Mỗi lời mời có thời hạn 20 giây. Để một shipper không bị mời hai đơn cùng lúc, ta khoá bằng SET NX:
public boolean reserveShipperForOffer(String shipperId, long orderId) {
Boolean ok = redis.opsForValue().setIfAbsent(
"offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
return Boolean.TRUE.equals(ok);
}
Nếu false, shipper đang xem lời mời khác, ta chuyển sang người kế tiếp.
Bước 3: chốt gán đơn trong database. Khoá Redis chỉ giúp điều phối. Quyết định cuối cùng phải nằm ở database:
public boolean assignShipper(long orderId, long shipperId) {
return jdbc.sql("""
UPDATE orders SET shipper_id = :shipperId, version = version + 1
WHERE id = :orderId AND shipper_id IS NULL
AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
""")
.param("shipperId", shipperId)
.param("orderId", orderId)
.update() == 1;
}
Điều kiện shipper_id IS NULL đảm bảo dù có hai shipper cùng bấm “Nhận” (ví dụ do lời mời cũ hết hạn và được gửi lại), chỉ một người thắng.
Lưu ý: Đừng dùng khoá Redis làm nguồn sự thật cho việc gán đơn. Redis có thể failover và mất khoá. Khi đó, ràng buộc trong database là lớp bảo vệ cuối cùng.
8. Nhất quán giữa database và Kafka
Sau khi tạo đơn, Order Service phải phát sự kiện OrderPlaced để quán nhận thông báo. Nếu bạn ghi database rồi gọi kafkaTemplate.send(), service có thể chết giữa hai bước. Kết quả là đơn đã tồn tại nhưng quán không bao giờ biết. Đây là bài toán dual write.
Cách giải là transactional outbox: ghi sự kiện vào bảng outbox trong cùng transaction với đơn hàng.
@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
long orderId = orderRepository.insert(cmd); // ném DuplicateKeyException nếu trùng key
jdbc.sql("""
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
""")
.param("id", orderId)
.param("payload", toJson(cmd, orderId))
.update();
return orderId;
}
Hai bản ghi cùng commit hoặc cùng rollback. Nếu client gửi lại cùng Idempotency-Key, ràng buộc UNIQUE ném DuplicateKeyException. Controller bắt lỗi này, tìm đơn cũ và trả về đơn đó thay vì tạo mới.
Một relay đọc outbox và đẩy lên Kafka:
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<OutboxEvent> events = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload::text AS payload FROM outbox
WHERE published = false ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""")
.query(OutboxEvent.class).list();
for (OutboxEvent e : events) {
kafka.send("order-events", String.valueOf(e.aggregateId()), e.payload()).join();
jdbc.sql("UPDATE outbox SET published = true WHERE id = :id")
.param("id", e.id()).update();
}
}
FOR UPDATE SKIP LOCKED cho phép chạy nhiều instance relay mà không xử lý trùng bản ghi. Key của message là orderId, nên mọi sự kiện của cùng một đơn rơi vào cùng partition và giữ đúng thứ tự.
Relay đảm bảo at-least-once. Nếu service chết sau khi send nhưng trước khi commit, sự kiện sẽ bị gửi lại. Vì vậy consumer phải idempotent, ví dụ lưu event_id đã xử lý hoặc dùng UPDATE ... WHERE status = ... như ở mục 7.1.
9. Trạng thái realtime cho khách
Khách cần thấy hai loại cập nhật: trạng thái đơn (ít, quan trọng) và vị trí shipper (nhiều, có thể bỏ sót). Server-Sent Events là lựa chọn đủ tốt vì luồng dữ liệu chỉ đi một chiều từ server tới client:
@RestController
public class OrderStreamController {
private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
@GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable long id) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
emitter.onCompletion(() -> emitters.get(id).remove(emitter));
emitter.onTimeout(emitter::complete);
return emitter;
}
public void push(long orderId, String eventName, Object data) {
emitters.getOrDefault(orderId, List.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name(eventName).data(data));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
Mỗi đơn có danh sách emitter (khách có thể mở trên nhiều thiết bị). push gửi sự kiện có tên, ví dụ status hoặc location. Phía client nhận được:
event:status
data:{"orderId":1024,"status":"PICKED_UP"}
event:location
data:{"lat":10.7769,"lng":106.7009}
Khi chạy nhiều instance, kết nối SSE của khách chỉ nằm trên một instance. Có hai cách để sự kiện tới đúng chỗ:
- Trạng thái đơn: mỗi instance Tracking tiêu thụ
order-eventsvớigroup.idriêng, nên instance nào cũng nhận đủ sự kiện và chỉ đẩy cho emitter mình đang giữ. - Vị trí shipper: khi đơn đang ở trạng thái
PICKED_UP, Tracking publish vị trí lên Redis channeltrack:order:{id}. Instance nào giữ kết nối thì subscribe channel đó. Ta không đẩy 12.500 message/giây qua Kafka khi chỉ một phần nhỏ được xem.
Để giảm tải, bạn có thể gộp vị trí: client chỉ cần một điểm mỗi 4–5 giây và tự nội suy chuyển động trên bản đồ.
10. Tải đột biến giờ cao điểm
Giờ trưa hoặc khi chạy khuyến mãi, lưu lượng có thể tăng gấp 5–10 lần. Một số kỹ thuật giúp hệ thống trụ vững:
- Cache menu. Menu đọc nhiều, ghi ít. Bạn cache trong Redis theo
restaurant_idvà xoá cache khi quán sửa món. Phần lớn request “xem quán” sẽ không chạm tới database. - Rate limit theo user và theo quán tại gateway. Bạn cũng có thể tạm dừng nhận đơn ở quán đang quá tải (quá N đơn ở trạng thái
PLACED) thay vì để khách chờ 40 phút. - Tách đường ghi khỏi đường xử lý. API đặt đơn chỉ ghi
ordersvàoutboxrồi trả về ngay. Thông báo, tính phí ship, gợi ý khuyến mãi chạy bất đồng bộ qua Kafka. - Dispatch theo lô. Khi cầu vượt cung, việc ghép đơn và shipper mỗi 5–10 giây (batch matching) cho kết quả tốt hơn mời từng người ngay lập tức. Hệ thống còn có thể gom hai đơn cùng quán cho một shipper.
- Suy giảm có kiểm soát. Khi Redis Tracking chậm, ưu tiên giữ luồng đặt đơn và tạm ngừng đẩy vị trí. Khách chấp nhận bản đồ đứng yên vài giây hơn là không đặt được đơn.
11. Lỗi hay gặp
- Lưu vị trí shipper vào PostgreSQL. 12.500 lượt ghi mỗi giây, mỗi lượt cập nhật một hàng, sẽ gây bloat và lock contention. Bạn chỉ nên lưu lịch sử hành trình theo lô (ví dụ đẩy qua Kafka vào kho dữ liệu) nếu cần đối soát.
- Kiểm tra trạng thái bằng
SELECTrồi mớiUPDATE. Giữa hai câu lệnh luôn có khe hở cho race condition. Bạn nên đưa điều kiện vào mệnh đềWHERE. - Gửi Kafka trong
@Transactionalmà không có outbox. Transaction rollback nhưng message đã bay đi, hoặc ngược lại. - Dùng một
SseEmitterkhông có timeout. Kết nối treo tích tụ dần và làm cạn tài nguyên. Bạn nên đặt timeout và để client tự kết nối lại bằngGET /api/orders/{id}để lấy trạng thái mới nhất.
Lưu ý: Kiến trúc nhiều service với Kafka, Redis GEO và outbox chỉ đáng giá ở quy mô đủ lớn. Với vài trăm đơn mỗi ngày, một ứng dụng Spring Boot đơn khối, một PostgreSQL và polling 5 giây là đủ, lại dễ vận hành hơn nhiều.
12. Kết luận
Trong bài này, bạn đã thiết kế một hệ thống giao đồ ăn từ yêu cầu tới triển khai. Có bốn ý chính:
- Mô hình hoá đơn hàng bằng máy trạng thái rõ ràng.
- Giải quyết tranh chấp bằng
UPDATEcó điều kiện và optimistic locking. - Phân đơn bằng Redis GEO kết hợp ràng buộc trong database.
- Giữ nhất quán với Kafka nhờ transactional outbox.
Nguyên tắc xuyên suốt là phân loại dữ liệu theo yêu cầu nhất quán. Đơn hàng cần nhất quán mạnh nên nằm trong database quan hệ. Vị trí shipper chấp nhận mất mát nên đi qua Redis, ưu tiên tốc độ. Khi đã tách được hai loại này, phần lớn các điểm khó của bài toán trở nên dễ xử lý hơn.