Thiết kế hệ thống tracking đơn hàng và vị trí shipper realtime
1. Tổng quan
Khi đặt đồ ăn hay giao hàng, khách muốn biết đơn đang ở trạng thái nào và shipper đang ở đâu trên bản đồ. Bài này thiết kế một hệ thống như vậy từ yêu cầu đến code. Bài đi qua mô hình dữ liệu, API, luồng xử lý, rồi tới ba điểm khó: tranh chấp khi nhận đơn, nhất quán dữ liệu và tải đột biến.
Đọc xong, bạn có một kiến trúc tham khảo và các đoạn code Spring Boot 3.x để bắt đầu triển khai.
2. Yêu cầu
2.1. Yêu cầu chức năng
- Shipper gửi vị trí GPS định kỳ từ app.
- Khách xem trạng thái đơn và vị trí shipper gần như realtime, độ trễ dưới 3 giây.
- Shipper nhận đơn. Mỗi đơn chỉ được gán cho đúng một shipper.
- Trạng thái đơn đi theo vòng đời:
PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. Từ một số trạng thái có thể chuyển sangCANCELLED. - Lưu lịch sử trạng thái để đối soát và xử lý khiếu nại.
2.2. Quy mô ước lượng
| Chỉ số | Giá trị |
|---|---|
| Shipper online giờ cao điểm | 50.000 |
| Chu kỳ gửi vị trí | 4 giây |
| Lượt ghi vị trí | ~12.500 msg/s |
| Khách đang mở màn hình tracking | ~200.000 kết nối |
| Đơn mới giờ cao điểm | ~300 đơn/s |
2.3. Ràng buộc
- Vị trí là dữ liệu tạm thời. Mất một vài điểm vẫn chấp nhận được, nhưng không được hiển thị vị trí cũ đè lên vị trí mới.
- Trạng thái đơn là dữ liệu quan trọng. Không được mất và không được sai thứ tự.
- Mạng di động chập chờn, nên app sẽ gửi lại và gửi theo lô.
Từ đây có một quyết định then chốt: tách hai luồng. Luồng vị trí cần thông lượng cao và chấp nhận mất mát. Luồng trạng thái cần giao dịch và độ bền.
3. Kiến trúc tổng thể
App shipper ──HTTP batch──> Location API ──> Kafka [shipper-locations]
│
▼
Location Consumer ──> Redis (vị trí mới nhất, GEO)
│
└──> Redis Pub/Sub [order:{id}:loc]
│
App khách <──────── SSE ──────── Tracking Gateway <────────────┘
App shipper ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
│
└──> Outbox relay ──> Kafka [order-events]
Mỗi thành phần đảm nhận một việc:
- Kafka hấp thụ tải ghi vị trí và giữ thứ tự theo từng shipper, nhờ dùng key là
shipperId. - Redis chỉ giữ vị trí mới nhất của mỗi shipper. Hệ thống không ghi từng điểm GPS vào database quan hệ.
- PostgreSQL là nguồn sự thật cho trạng thái đơn.
- SSE đẩy dữ liệu một chiều từ server xuống client. SSE đơn giản hơn WebSocket và đủ dùng cho bài toán này.
4. Mô hình dữ liệu
4.1. PostgreSQL cho đơn hàng
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_events (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
actor_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;
Cột version phục vụ optimistic locking. Bảng order_events là lịch sử chỉ thêm, không sửa. Bảng outbox đảm bảo sự kiện được phát ra Kafka mà không mất. Mục 7 sẽ giải thích chi tiết.
4.2. Redis cho vị trí
shipper:loc:{shipperId} HASH {lat, lng, ts} TTL 120s
shippers:geo:{city} GEO member = shipperId
shipper:active-order:{shipperId} STRING orderId
TTL 120 giây giúp shipper mất kết nối tự biến mất khỏi hệ thống. Bạn không cần job dọn dẹp riêng. Key GEO được tách theo thành phố, nên lệnh GEOSEARCH khi tìm shipper gần nhất không phải quét toàn quốc.
5. API
POST /api/v1/shippers/{shipperId}/locations body: [ {lat,lng,ts}, ... ] -> 202
POST /api/v1/orders/{orderId}/claim -> 200 | 409
POST /api/v1/orders/{orderId}/transitions body: {to: "PICKED_UP"} -> 200 | 409
GET /api/v1/orders/{orderId} -> trạng thái + vị trí hiện tại
GET /api/v1/orders/{orderId}/track -> text/event-stream
API vị trí trả về 202 Accepted vì nó chỉ đẩy dữ liệu vào Kafka rồi trả lời ngay. API này nhận dữ liệu theo lô, để app có thể gom các điểm bị kẹt lúc mất sóng rồi gửi một lần.
6. Luồng xử lý vị trí
Bạn cần các dependency sau:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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>
Cấu hình serializer JSON cho Kafka:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: 1
consumer:
group-id: location-consumer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.nonla.tracking"
Giá trị acks: 1 là lựa chọn có chủ đích. Mất vài điểm GPS khi broker leader chết là chấp nhận được, đổi lại độ trễ ghi thấp hơn acks: all.
6.1. Nhận vị trí
public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}
@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {
private final KafkaTemplate<String, LocationUpdate> kafka;
public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
this.kafka = kafka;
}
@PostMapping("/{shipperId}/locations")
public ResponseEntity<Void> push(@PathVariable long shipperId,
@RequestBody List<LocationUpdate> batch) {
batch.stream()
.filter(u -> u.shipperId() == shipperId)
.forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
return ResponseEntity.accepted().build();
}
}
Hai điểm cần chú ý:
- Key là
shipperId. Mọi điểm của cùng một shipper rơi vào cùng partition, nên consumer xử lý chúng theo đúng thứ tự gửi. - Bộ lọc
filterloại bỏ điểm mangshipperIdkhác với path. Trong thực tế, bạn nên lấyshipperIdtừ JWT thay vì tin vào body.
6.2. Ghi vị trí mới nhất, bỏ điểm cũ
Thứ tự trong partition chưa đủ. App có thể gửi lại một lô cũ sau khi có sóng trở lại, và lô đó đến sau điểm mới hơn. Vì vậy consumer phải so sánh ts và chỉ ghi khi điểm mới hơn. Phép so sánh và phép ghi cần nguyên tử, nên ta dùng Lua script:
@Component
public class LocationConsumer {
private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
local cur = redis.call('HGET', KEYS[1], 'ts')
if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 120)
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LocationConsumer(StringRedisTemplate redis) {
this.redis = redis;
}
@KafkaListener(topics = "shipper-locations", concurrency = "6")
public void onLocation(LocationUpdate u) {
Long updated = redis.execute(UPSERT_IF_NEWER,
List.of("shipper:loc:" + u.shipperId()),
String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));
if (updated == null || updated == 0) return; // điểm cũ, bỏ qua
redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
String.valueOf(u.shipperId()));
String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
if (orderId != null) {
redis.convertAndSend("order:" + orderId + ":loc",
"{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
}
}
}
Luồng xử lý như sau:
- Script trả về
0nếu điểm cũ hơn hoặc bằng điểm đang lưu. Khi đó consumer dừng luôn, không cập nhật GEO và không đẩy cho khách. - Lệnh
GEOADDnhận tham số theo thứ tự kinh độ trước, vĩ độ sau. Đây là lỗi rất hay gặp. - Hệ thống chỉ publish khi shipper đang có đơn. Shipper rảnh không tạo traffic fan-out.
Lưu ý: Script trên chỉ đụng một key nên chạy được trên Redis Cluster. Nếu bạn muốn gộp cả
GEOADDvào script, các key phải cùng slot. Khi đó hãy dùng hash tag như{hcm}:loc:42và{hcm}:geo.
6.3. Đẩy xuống khách bằng SSE
@RestController
public class TrackingController implements MessageListener {
private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();
@GetMapping(value = "/api/v1/orders/{orderId}/track",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter track(@PathVariable String orderId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
set.add(emitter);
Runnable cleanup = () -> set.remove(emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
return emitter;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // order:123:loc
String orderId = channel.split(":")[1];
String body = new String(message.getBody());
subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name("location").data(body));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
Bạn đăng ký listener với pattern order:*:loc:
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
var c = new RedisMessageListenerContainer();
c.setConnectionFactory(cf);
c.addMessageListener(listener, new PatternTopic("order:*:loc"));
return c;
}
Client nhận được các event như sau:
event:location
data:{"lat":10.7769,"lng":106.7009,"ts":1760342400000}
Gateway là stateless, nên bạn scale ngang bao nhiêu instance cũng được. Instance nào giữ kết nối SSE của khách sẽ nhận message qua Pub/Sub và đẩy xuống. Với 200.000 kết nối, bạn nên bật virtual threads (spring.threads.virtual.enabled=true) hoặc chuyển sang WebFlux, để mỗi kết nối không giữ một platform thread.
7. Các điểm khó và cách giải
7.1. Tranh chấp: hai shipper cùng nhận một đơn
Đơn mới được hiển thị cho nhiều shipper gần đó. Hai người có thể bấm “Nhận” cùng lúc. Cách sai là đọc trạng thái, kiểm tra PENDING, rồi mới ghi, vì giữa lúc đọc và lúc ghi có khoảng hở. Cách đúng là dùng UPDATE có điều kiện:
@Service
public class OrderClaimService {
private final JdbcTemplate jdbc;
public OrderClaimService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean claim(long orderId, long shipperId) {
int rows = jdbc.update("""
UPDATE orders
SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
WHERE id = ? AND status = 'PENDING'
""", shipperId, orderId);
if (rows == 0) return false; // người khác đã nhận
jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
orderId, shipperId);
jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
return true;
}
}
PostgreSQL khoá dòng trong lúc thực hiện UPDATE. Shipper thứ hai phải chờ, sau đó đánh giá lại điều kiện status = 'PENDING', thấy không còn khớp và nhận rows == 0. Controller trả 409 Conflict, app hiện thông báo “Đơn đã có người nhận”. Bạn không cần distributed lock.
7.2. Nhất quán: state machine và outbox
Mọi chuyển trạng thái đều phải hợp lệ. Bạn mã hoá luật chuyển trạng thái ngay trong enum:
public enum OrderStatus {
PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;
public boolean canTransitionTo(OrderStatus next) {
return switch (this) {
case PENDING -> next == ASSIGNED || next == CANCELLED;
case ASSIGNED -> next == PICKED_UP || next == CANCELLED;
case PICKED_UP -> next == DELIVERING;
case DELIVERING -> next == DELIVERED;
case DELIVERED, CANCELLED -> false;
};
}
}
Test cho hành vi chính:
class OrderStatusTest {
@Test
void shouldAllowHappyPath() {
assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
}
@Test
void shouldRejectCancelAfterPickup() {
assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void terminalStatesAreFinal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo)
.noneMatch(OrderStatus.CANCELLED::canTransitionTo);
}
}
Khi chuyển trạng thái, bạn dùng điều kiện WHERE id = ? AND version = ?. Nếu không có dòng nào được cập nhật, nghĩa là ai đó đã đổi đơn trước. Lúc này bạn trả 409 và để client tải lại.
Outbox pattern giải quyết bài toán “ghi DB thành công nhưng gửi Kafka thất bại”. Sự kiện được ghi vào bảng outbox trong cùng transaction với orders. Một relay định kỳ đọc các dòng chưa publish rồi đẩy lên Kafka:
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
var rows = jdbc.queryForList(
"SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
for (var r : rows) {
kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
}
}
Mệnh đề FOR UPDATE SKIP LOCKED cho phép chạy nhiều relay song song mà không xử lý trùng một dòng. Relay đảm bảo at-least-once: nếu nó chết sau khi gửi nhưng trước khi đánh dấu published, sự kiện sẽ bị gửi lại. Vì vậy consumer của order-events phải idempotent. Ví dụ, consumer bỏ qua sự kiện có version nhỏ hơn hoặc bằng version đã xử lý.
Consumer của order-events cũng là nơi set hoặc xoá key shipper:active-order:{shipperId}. Nhờ vậy, luồng vị trí biết cần đẩy cho đơn nào.
7.3. Tải đột biến
Giờ trưa, mưa lớn hoặc ngày sale có thể làm tải tăng gấp 3–5 lần chỉ trong vài phút. Bạn có các cách xử lý sau:
- Kafka làm bộ đệm. Consumer chậm thì lag tăng, nhưng API vẫn trả lời nhanh. Bạn tăng số partition (ví dụ 48) từ trước, vì tăng partition khi đang chạy sẽ làm xáo trộn thứ tự theo key.
- Bỏ điểm quá cũ. Khi lag lớn, điểm GPS cũ 30 giây không còn giá trị hiển thị. Consumer có thể bỏ qua ngay:
if (System.currentTimeMillis() - u.ts() > 30_000) return;. - Chu kỳ gửi thích ứng. Server trả về header gợi ý, ví dụ
X-Next-Interval: 8, để app giãn chu kỳ gửi khi hệ thống quá tải. Shipper đang đứng yên thì gửi thưa hơn. - Gộp ở client. App gom 2–3 điểm vào một request. Số request HTTP giảm mạnh, còn số message Kafka vẫn giữ nguyên.
- Rate limit theo shipper. Bạn giới hạn mỗi shipper tối đa 1 request/giây để chặn app lỗi hoặc client giả mạo spam.
- Tách tài nguyên. Luồng vị trí chạy trên cluster Redis và nhóm instance riêng. Nếu luồng này sập, việc nhận đơn và cập nhật trạng thái vẫn chạy bình thường.
8. Lỗi hay gặp và khi nào không nên làm như vậy
- Ghi từng điểm GPS vào PostgreSQL. 12.500 insert/s cộng index sẽ nhanh chóng làm nghẽn database. Nếu bạn cần lưu lộ trình để đối soát, hãy cho một consumer khác ghi theo lô vào kho phù hợp như TimescaleDB hoặc object storage, với tần suất thấp hơn.
- Dùng distributed lock (Redisson) để nhận đơn. Cách này phức tạp hơn và dễ sai hơn so với UPDATE có điều kiện trên chính nguồn sự thật.
- Tin vào thứ tự đến của request. Mạng di động không đảm bảo thứ tự. Bạn luôn phải so sánh
ts, và với trạng thái thì so sánhversion. - Redis Pub/Sub cho dữ liệu quan trọng. Pub/Sub kiểu fire-and-forget. Subscriber mất kết nối là mất message. Cách này ổn cho vị trí, nhưng không phù hợp cho sự kiện trạng thái. Sự kiện trạng thái nên đi qua Kafka. Khi client kết nối lại SSE, nó gọi
GET /orders/{id}để lấy snapshot mới nhất.
Lưu ý: Nếu hệ thống của bạn chỉ có vài trăm shipper, kiến trúc này là quá tay. Một service duy nhất ghi vị trí vào Redis, kết hợp client polling 5 giây một lần, là đủ. Hãy thêm Kafka và gateway SSE khi số liệu thực tế buộc bạn phải làm vậy.
9. Kết luận
Bài này đã thiết kế hệ thống tracking đơn hàng và vị trí shipper theo nguyên tắc cốt lõi: tách dữ liệu tạm thời khỏi dữ liệu quan trọng.
- Vị trí đi qua Kafka rồi vào Redis. Hệ thống chấp nhận mất mát, nhưng chặn ghi đè điểm cũ bằng Lua script so sánh timestamp.
- Trạng thái đơn nằm trong PostgreSQL. Tranh chấp được giải bằng UPDATE có điều kiện, nhất quán được giữ bằng state machine và outbox pattern.
- Tải đột biến được hấp thụ bằng Kafka, kết hợp bỏ điểm cũ, chu kỳ gửi thích ứng và tách tài nguyên.
Từ khung này, bạn có thể mở rộng thêm các tính năng như tính ETA, ghép đơn theo vị trí bằng GEOSEARCH, hoặc lưu lộ trình phục vụ đối soát.