Thiết kế ứng dụng gọi xe: tìm tài xế gần nhất và ghép chuyến
1. Tổng quan
Ứng dụng gọi xe có hai câu hỏi cốt lõi. Tài xế nào đang ở gần khách? Làm sao để một chuyến chỉ thuộc về đúng một tài xế, kể cả khi hàng chục tài xế cùng bấm “Nhận”?
Bài này đi qua toàn bộ bài toán theo kiểu thiết kế hệ thống: yêu cầu, ước lượng quy mô, mô hình dữ liệu, API và luồng xử lý. Sau đó bài tập trung vào các điểm khó: tranh chấp, nhất quán và tải đột biến. Đọc xong, bạn có thể dựng được phần lõi “tìm tài xế và ghép chuyến” bằng Spring Boot 3, Redis, Kafka và PostgreSQL.
2. Yêu cầu
2.1. Yêu cầu chức năng
- Tài xế gửi vị trí GPS định kỳ khi đang online.
- Khách đặt chuyến với điểm đón và điểm đến.
- Hệ thống tìm các tài xế rảnh gần nhất rồi gửi lời mời lần lượt cho từng người.
- Tài xế chấp nhận hoặc từ chối. Nếu tài xế không phản hồi sau 15 giây, hệ thống chuyển lời mời cho người kế tiếp.
- Khách theo dõi được trạng thái chuyến:
REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(hoặcCANCELLED).
2.2. Quy mô và ràng buộc
Giả sử hệ thống vận hành ở một quốc gia cỡ trung:
- Giờ cao điểm có 300.000 tài xế online, mỗi tài xế gửi vị trí 4 giây một lần. Như vậy có khoảng 75.000 lượt ghi vị trí mỗi giây.
- Có khoảng 1.500 yêu cầu đặt chuyến mỗi giây. Khi trời mưa hoặc tan tầm, con số này có thể tăng gấp 5 lần.
- Tìm tài xế phải xong trong khoảng 200 ms (p99).
- Một chuyến chỉ có đúng một tài xế. Một tài xế chỉ có tối đa một chuyến đang hoạt động. Đây là ràng buộc không được vi phạm.
Từ các con số này rút ra được một nhận xét quan trọng. Vị trí tài xế là dữ liệu ghi rất nhiều, sống ngắn và chấp nhận mất mát: mất một điểm GPS cũng không sao. Ngược lại, dữ liệu chuyến đi ghi ít hơn nhiều nhưng cần nhất quán tuyệt đối. Vì vậy hai loại dữ liệu này nên nằm ở hai kho khác nhau.
3. Mô hình dữ liệu
Vị trí tài xế lưu trong Redis. Chuyến đi lưu trong PostgreSQL:
CREATE TABLE rides (
id BIGSERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
driver_id VARCHAR(64),
pickup_lat DOUBLE PRECISION NOT NULL,
pickup_lng DOUBLE PRECISION NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
created_at TIMESTAMP NOT NULL DEFAULT now(),
accepted_at TIMESTAMP
);
-- Mỗi tài xế chỉ có tối đa một chuyến đang hoạt động
CREATE UNIQUE INDEX ux_rides_driver_active
ON rides (driver_id)
WHERE status IN ('ACCEPTED', 'IN_PROGRESS');
Partial unique index ở cuối là “lưới an toàn” cuối cùng. Giả sử tầng ứng dụng có bug và cố gán hai chuyến cho cùng một tài xế. Khi đó database sẽ từ chối lệnh ghi thứ hai.
Phía Redis dùng ba loại key:
drivers:available GEO set: driverId -> (lng, lat), chỉ chứa tài xế rảnh
driver:heartbeat:{driverId} String, TTL 30s, đánh dấu tài xế còn online
driver:lock:{driverId} String, TTL 15s, giá trị = rideId đang mời
4. API
POST /api/drivers/{driverId}/location { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept { "driverId": "d-7" }
GET /api/rides/{rideId}
Endpoint cập nhật vị trí có tải rất lớn. Trong thực tế, phần này thường đi qua WebSocket hoặc gRPC stream để không phải mở một kết nối HTTP mỗi 4 giây. Để giữ ví dụ đơn giản, bài này vẫn dùng REST.
5. Lưu và tìm vị trí tài xế bằng Redis GEO
Thêm dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Cập nhật vị trí
@Service
public class DriverLocationService {
static final String AVAILABLE_KEY = "drivers:available";
private final StringRedisTemplate redis;
public DriverLocationService(StringRedisTemplate redis) {
this.redis = redis;
}
public void updateLocation(String driverId, double lng, double lat) {
redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
}
public void markBusy(String driverId) {
redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
}
}
GEOADD lưu tọa độ dưới dạng geohash trong một sorted set. Mỗi lệnh có độ phức tạp O(log N), nên một Redis node chịu được hàng chục nghìn lệnh mỗi giây mà không gặp khó khăn.
Phần heartbeat tồn tại vì member trong GEO set không có TTL riêng. Nếu tài xế mất sóng hoặc tắt app, tọa độ cũ vẫn nằm trong set. Key heartbeat hết hạn sau 30 giây sẽ giúp ta lọc bỏ những tài xế “ma” này.
Lưu ý: Redis GEO nhận tham số theo thứ tự (longitude, latitude). Đảo ngược thứ tự là lỗi rất hay gặp. Khi bị đảo, các tài xế ở TP.HCM sẽ “dời” sang tận Nam Cực.
5.2. Tìm tài xế gần nhất
public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit * 2L); // lấy dư để bù cho tài xế bị lọc
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
AVAILABLE_KEY,
GeoReference.fromCoordinate(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
args);
List<String> candidates = results.getContent().stream()
.map(r -> r.getContent().getName())
.toList();
List<String> alive = redis.opsForValue().multiGet(
candidates.stream().map(id -> "driver:heartbeat:" + id).toList());
List<String> online = new ArrayList<>();
for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
if (alive.get(i) != null) {
online.add(candidates.get(i));
}
}
return online;
}
GEOSEARCH trả về các tài xế trong bán kính đã cho, sắp xếp từ gần đến xa. Sau đó một lệnh MGET duy nhất kiểm tra heartbeat của toàn bộ ứng viên, nên chỉ tốn thêm một round-trip. Với khách ở Quận 1 và bán kính 3 km, kết quả có thể trông như sau:
[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]
Khoảng cách ở đây là đường chim bay. Ở bản thật, bạn nên xếp hạng lại top-N ứng viên bằng ETA thực tế từ dịch vụ bản đồ. Lý do là tài xế cách 500 m nhưng ở bên kia sông có thể đến chậm hơn tài xế cách 1 km.
6. Ghép chuyến và xử lý tranh chấp
Có hai kiểu tranh chấp cần chặn:
- Hai chuyến cùng mời một tài xế. Hai khách đứng gần nhau đặt xe cùng lúc, và cả hai luồng matching đều chọn
d-7. - Hai tài xế cùng nhận một chuyến. Chuyện này xảy ra khi bạn gửi lời mời song song cho nhiều tài xế để giảm thời gian chờ.
6.1. Khóa tài xế bằng Redis trong lúc mời
@Service
public class DriverLockService {
private static final RedisScript<Long> RELEASE = RedisScript.of("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
public DriverLockService(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryLock(String driverId, long rideId) {
return Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
}
public void release(String driverId, long rideId) {
redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
}
}
SET NX EX là thao tác nguyên tử. Chỉ một luồng matching giữ được lock cho mỗi tài xế, và lock tự hết hạn sau 15 giây, đúng bằng thời gian chờ tài xế phản hồi. Nhờ TTL này, hệ thống tự “chuyển người” khi tài xế không phản hồi mà không cần tác vụ dọn dẹp nào.
Script Lua dùng để nhả lock chỉ xóa key khi giá trị khớp với rideId. Nếu bạn dùng DEL trần, chuyến A đã hết hạn lock có thể vô tình xóa mất lock mà chuyến B vừa lấy được.
6.2. Chốt chuyến bằng conditional update
Redis lock chỉ là cơ chế tối ưu để tránh gửi lời mời trùng. Nguồn sự thật vẫn là database:
@Repository
public class RideRepository {
private final JdbcTemplate jdbc;
public RideRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean assignDriver(long rideId, String driverId) {
int updated = jdbc.update("""
UPDATE rides
SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'REQUESTED'
""", driverId, rideId);
return updated == 1;
}
}
Điều kiện status = 'REQUESTED' biến lệnh UPDATE thành một phép compare-and-set. Khi hai tài xế cùng bấm nhận, database tuần tự hóa hai lệnh ghi trên cùng một row. Lệnh đầu tiên trả về 1. Lệnh thứ hai thấy status đã là ACCEPTED nên trả về 0. Cách này không cần SELECT ... FOR UPDATE, cũng không cần cột version riêng.
Service xử lý việc chấp nhận chuyến ghép các bước lại:
@Transactional
public AcceptResult accept(long rideId, String driverId) {
if (!rideRepository.assignDriver(rideId, driverId)) {
return AcceptResult.ALREADY_TAKEN;
}
locationService.markBusy(driverId);
lockService.release(driverId, rideId);
return AcceptResult.ACCEPTED;
}
Thứ tự ở đây có chủ đích: ghi database trước, cập nhật Redis sau. Nếu bước markBusy lỗi, tài xế vẫn nằm trong danh sách rảnh thêm vài giây. Hậu quả tệ nhất là tài xế nhận thêm một lời mời, và khi đó partial unique index sẽ chặn việc gán chuyến thứ hai. Làm ngược lại thì rủi ro hơn: xóa tài xế khỏi Redis rồi mới ghi database thất bại sẽ khiến tài xế “biến mất” khỏi hệ thống.
7. Luồng xử lý bất đồng bộ với Kafka
Tìm tài xế, gửi lời mời rồi chờ 15 giây là một quy trình dài. Không nên giữ HTTP request của khách trong suốt thời gian đó. API POST /api/rides chỉ lưu chuyến rồi trả về 202 Accepted ngay. Phần ghép chuyến chạy ở consumer riêng.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}
@Component
public class MatchingConsumer {
private final DriverLocationService locations;
private final DriverLockService locks;
private final OfferSender offerSender;
private final KafkaTemplate<String, RideRequested> kafka;
// constructor bỏ qua cho gọn
@KafkaListener(topics = "ride-requested", groupId = "matching")
public void onRideRequested(RideRequested event) {
for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
if (locks.tryLock(driverId, event.rideId())) {
offerSender.send(driverId, event.rideId()); // push notification / WebSocket
return;
}
}
if (event.attempt() < 5) {
// chưa ai rảnh: đẩy lại để thử sau
kafka.send("ride-requested-retry", event.cityId(),
new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
}
}
}
Consumer duyệt ứng viên từ gần đến xa. Nó khóa tài xế đầu tiên còn trống và gửi lời mời cho người đó. Nếu không ai rảnh, sự kiện được đẩy sang topic retry, có giới hạn số lần thử.
Key của message là cityId. Nhờ vậy mọi chuyến trong một thành phố rơi vào cùng một nhóm partition, và cache tài xế cục bộ của consumer (nếu có) đạt hit rate cao hơn.
Còn một vấn đề nhỏ: lưu chuyến vào PostgreSQL rồi mới gửi Kafka là hai thao tác tách rời. Nếu ứng dụng chết giữa chừng, chuyến sẽ bị “kẹt” ở trạng thái REQUESTED mãi mãi. Cách chữa chuẩn là transactional outbox. Bạn ghi sự kiện vào bảng outbox trong cùng transaction với bảng rides, rồi để một tiến trình riêng (hoặc Debezium) đọc bảng outbox và đẩy lên Kafka.
Lưu ý: Kafka đảm bảo at-least-once, nên consumer có thể nhận một sự kiện hai lần. Logic matching phải idempotent. Trước khi gửi lời mời, hãy kiểm tra chuyến vẫn còn ở trạng thái
REQUESTED. Conditional update ở mục 6.2 vốn đã an toàn với trường hợp trùng lặp.
8. Chịu tải đột biến
Khi trời đổ mưa lúc 17 giờ, cả lượng đặt chuyến lẫn lượng tài xế bật app đều tăng vọt. Có bốn cách xử lý chính.
- Shard dữ liệu vị trí theo khu vực. Thay vì dùng một key
drivers:availablecho cả nước, hãy chia theo thành phố hoặc theo ô geohash, ví dụdrivers:available:hcm. Mỗi key nằm trên một shard của Redis Cluster, nhờ đó tải ghi được chia đều. Khi khách đứng gần ranh giới hai ô, bạn truy vấn thêm các ô lân cận. - Giảm tần suất gửi vị trí. Tài xế đứng yên hoặc đang chở khách không cần gửi vị trí mỗi 4 giây. Chỉ cần gửi khi họ di chuyển quá 50 m hoặc khi đã qua 15 giây. Riêng tối ưu này có thể cắt 40–60% lượng ghi.
- Backpressure thay vì sập. Kafka đóng vai trò bộ đệm. Khi lượng đặt chuyến tăng gấp 5, độ trễ ghép chuyến tăng lên vài giây, nhưng không chuyến nào bị mất. Bạn scale số consumer theo consumer lag, với giới hạn trên là số partition.
- Giá động (surge pricing). Đây là giải pháp ở tầng sản phẩm, không phải tầng kỹ thuật. Giá cao hơn làm giảm cầu và kéo thêm tài xế online, giúp cân bằng lại hệ thống.
Ngoài ra, nên rate limit API đặt chuyến theo customerId. Khi app hiển thị “đang tìm xe” quá lâu, khách có xu hướng bấm đặt lại liên tục, tự tạo ra một đợt tải nhân đôi.
9. Kiểm thử tranh chấp khi nhận chuyến
Hành vi quan trọng nhất cần test là: nhiều tài xế cùng nhận một chuyến thì chỉ một người thắng. Test dưới đây dùng H2 in-memory qua @JdbcTest. File src/test/resources/schema.sql chứa bảng rides như ở mục 3, bỏ đi partial index vì H2 không hỗ trợ.
@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {
@Autowired
RideRepository rideRepository;
@Autowired
JdbcTemplate jdbc;
@Test
void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
+ "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");
List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Boolean>> results = drivers.stream()
.map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
.toList();
long winners = 0;
for (Future<Boolean> f : results) {
if (f.get()) winners++;
}
assertThat(winners).isEqualTo(1);
}
String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
assertThat(status).isEqualTo("ACCEPTED");
}
}
Năm virtual thread cùng gọi assignDriver trên một chuyến. Dù thứ tự thực thi thế nào, chỉ đúng một lệnh UPDATE khớp điều kiện status = 'REQUESTED'.
Bạn có thể nhận thấy test đã bỏ @Transactional mặc định của @JdbcTest ra khỏi tính toán. Lý do: các thread mới không chạy chung transaction với test method, và lệnh INSERT vẫn hiển thị với chúng vì dữ liệu đã được commit theo cấu hình auto-commit. Nếu bạn gặp kết quả winners = 0, hãy thêm @Transactional(propagation = Propagation.NOT_SUPPORTED) lên lớp test.
Với logic GEO, nên dùng Testcontainers chạy Redis thật thay vì mock. Phép tính khoảng cách và thứ tự sắp xếp là thứ mock không thể kiểm chứng.
10. Kết luận
Bài này đã phác thảo phần lõi của một ứng dụng gọi xe. Điểm mấu chốt là tách dữ liệu theo tính chất:
- Vị trí tài xế ghi nhiều và chấp nhận mất mát, nên đặt trong Redis GEO, kèm heartbeat để loại tài xế mất kết nối.
- Chuyến đi cần nhất quán, nên đặt trong PostgreSQL. Conditional update đóng vai trò compare-and-set, còn partial unique index là lưới an toàn cuối cùng.
- Redis lock với TTL ngăn mời trùng tài xế và tự xử lý timeout. Tuy vậy, lock chỉ là cơ chế tối ưu, không phải nguồn sự thật.
- Kafka tách luồng ghép chuyến khỏi request của khách, hấp thụ tải đột biến và cho phép retry. Outbox pattern đảm bảo không mất sự kiện.
Khi mở rộng tiếp, bạn có thể xem xét thêm các hướng sau: xếp hạng ứng viên bằng ETA thay vì đường chim bay, ghép chuyến theo lô (batch matching) mỗi vài giây để tối ưu toàn cục, và đi chung xe (pooling). Tất cả đều xây được trên nền tảng đã trình bày ở đây.