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.
  • version phục vụ optimistic locking khi nhiều bên cùng cập nhật một đơn.
  • order_items lưu lại dish_name và price tại thời điểm đặt. Quán đổi giá sau đó cũng không làm sai hoá đơn cũ.
  • Bảng outbox dù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-events với group.id riê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 channel track: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_id và 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 orders và outbox rồ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 SELECT rồi mới UPDATE. 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 @Transactional mà không có outbox. Transaction rollback nhưng message đã bay đi, hoặc ngược lại.
  • Dùng một SseEmitter khô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ằng GET /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 UPDATE có đ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.

system-designspring-bootrediskafkacase-study