GPS, cập nhật vị trí, tối ưu dữ liệuGPS, location updates, data volume
Điểm GPS đi qua một pipeline: broker MQTT nhận và giới hạn tốc độ, Kafka giữ thứ tự theo tài xế, processor loại điểm cũ rồi tách ra ba nhánh: vị trí mới nhất trong Redis GEO, lịch sử trong TimescaleDB và cập nhật cho người đang xem qua pub/sub. Bật “Điểm đến muộn” và “Tìm xe gần” để xem hai luồng còn lại.GPS points travel a pipeline: an MQTT broker receives and throttles, Kafka keeps per-driver order, and the processor drops stale points and splits into three branches: the latest position in Redis GEO, history in TimescaleDB, and updates to watchers over pub/sub. Turn on “Late points” and “Nearby search” to see the other flows.
1Tài xế d-881 đang chở khách trong chuyến T-5521. App gửi một điểm protobuf 28 byte với timestamp từ GPS và số thứ tự.Driver d-881 is on trip T-5521. The app sends a 28-byte protobuf point with the GPS timestamp and a sequence number.
Không ghi từng điểm vào Postgres. Kafka làm bộ đệm, partition theo driver_id; Redis chỉ giữ một dòng cho mỗi tài xế; lịch sử ghi theo lô bằng COPY. Một instance Redis xử lý khoảng 100k lệnh/giây, nên tách cluster theo thành phố.Never write each point to Postgres. Kafka buffers, partitioned by driver_id; Redis keeps one row per driver; history is written in batches with COPY. One Redis instance handles about 100k commands a second, so shard the cluster by city.
Mạng di động giao muộn và giao lại. So ts (và seq) với điểm mới nhất trong cùng script Lua để đọc-so-ghi là nguyên tử; điểm cũ bị loại khỏi luồng live nhưng vẫn vào lịch sử. Dùng ts của GPS, không dùng giờ server nhận.Mobile networks deliver late and deliver twice. Compare ts (and seq) with the latest point inside one Lua script so read-compare-write is atomic; stale points leave the live path but still go to history. Use the GPS ts, not the server's receive time.
GEOADD lưu vào một sorted set, từng thành viên không có TTL. Giữ thêm seen:{city} (ZSET theo last_seen), job mỗi 10 giây ZRANGEBYSCORE rồi ZREM những tài xế im lặng quá 60 giây; truy vấn cũng lọc theo last_seen.GEOADD stores into a sorted set, and members have no TTL. Keep seen:{city} as a ZSET by last_seen; a job every 10 seconds runs ZRANGEBYSCORE and ZREMs drivers silent for over 60 seconds, and queries filter on last_seen too.
Không broadcast mọi điểm. Mỗi chuyến một kênh trip:{id}; gateway chỉ subscribe kênh của các WebSocket nó đang giữ. Giới hạn 1 cập nhật mỗi giây cho mỗi người xem, client tự nội suy chuyển động giữa hai điểm.Never broadcast every point. Each trip has a channel trip:{id}; a gateway subscribes only to channels of WebSockets it holds. Cap updates at one a second per watcher and let the client interpolate between points.
4.3 tỷ điểm một ngày. TimescaleDB chia chunk theo giờ, nén chunk cũ hơn 1 ngày (giảm khoảng 90%), continuous aggregate 1 phút cho báo cáo, retention xoá dữ liệu thô sau 7 ngày. Lộ trình chuyến được đơn giản hoá (Douglas-Peucker) và lưu riêng.4.3 billion points a day. TimescaleDB chunks by hour, compresses chunks older than a day (about 90% smaller), keeps a 1-minute continuous aggregate for reporting, and drops raw data after 7 days. Trip routes are simplified (Douglas-Peucker) and stored separately.
Tần suất thích ứng: 4 giây khi đang chạy, 15–30 giây khi đứng yên, bỏ điểm dịch dưới 10 m. Gửi protobuf, delta encoding so với điểm trước, gom 3–5 điểm một lần khi không trong chuyến. MQTT giữ một kết nối nhẹ, keepalive 60 giây, thay vì mở HTTPS mỗi lần.Adaptive interval: every 4 seconds while moving, 15–30 seconds when stopped, skip moves under 10 m. Send protobuf with delta encoding against the previous point, and batch 3–5 points when not on a trip. MQTT keeps one light connection with a 60-second keepalive instead of a new HTTPS call each time.
Biển số, loại xe, ảnh tài xế không đi kèm từng điểm. Điểm chỉ mang driver_id và toạ độ; thông tin xe là đối tượng dùng chung, client tải một lần khi bắt đầu chuyến và cache lại.Plate, car model and driver photo never travel with each point. A point carries only driver_id and coordinates; vehicle details are a shared object the client loads once at trip start and caches.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
drv:{city}:{driver_id} (Redis hash) | ts, lat, lng, heading, speed, status, trip_id | Vị trí mới nhất, EXPIRE 120 s; hash tag {city} để cùng slot với geo:{city} trong script LuaLatest position, EXPIRE 120 s; the {city} hash tag keeps it in the same slot as geo:{city} for the Lua script |
geo:{city} · seen:{city} (Redis ZSET) | member = driver_id, score = geohash | last_seen_ms | GEOSEARCH cho tìm xe gần; seen dùng để quét tài xế im lặngGEOSEARCH for nearby drivers; seen is used to sweep silent drivers |
driver_location (hypertable) | driver_id, ts, lat, lng, speed, heading, accuracy_m, trip_id | Chunk 1 giờ, nén sau 1 ngày, giữ thô 7 ngày; index (driver_id, ts DESC)1-hour chunks, compressed after a day, raw kept 7 days; index (driver_id, ts DESC) |
driver_location_1m (continuous aggregate) | driver_id, bucket, first(lat,lng), last(lat,lng), avg_speed, points | Downsample 1 phút, giữ 1 năm cho báo cáo và tranh chấp1-minute downsample kept for a year for reporting and disputes |
Processor ghi mọi điểm vào lịch sử, còn luồng live chỉ nhận điểm mới hơn điểm đã lưu. Việc so sánh và ghi nằm trong một script Lua để nguyên tử.The processor writes every point to history, while the live path accepts only points newer than the stored one. The compare and the writes live in one Lua script so they are atomic.
@Component
@RequiredArgsConstructor
public class LocationProcessor {
private static final Duration MAX_LIVE_AGE = Duration.ofSeconds(30);
private final StringRedisTemplate redis;
private final RedisScript<Long> updateLatest; // update_latest.lua
private final HistoryWriter history; // COPY into TimescaleDB
private final Clock clock;
@KafkaListener(topics = "location.raw", batch = "true", concurrency = "8")
public void onBatch(List<LocationPoint> points) {
history.copy(points); // every point, original ts
Instant now = clock.instant();
for (LocationPoint p : points) {
if (Duration.between(p.ts(), now).compareTo(MAX_LIVE_AGE) > 0) continue;
String tag = "{" + p.city() + "}";
Long applied = redis.execute(updateLatest,
List.of("drv:" + tag + ":" + p.driverId(), "geo:" + tag, "seen:" + tag),
p.driverId(), String.valueOf(p.lng()), String.valueOf(p.lat()),
String.valueOf(p.ts().toEpochMilli()));
if (applied == null || applied == 0) continue; // older than the latest
p.tripId().ifPresent(trip ->
redis.convertAndSend("trip:" + trip, p.toWireJson()));
}
}
}type Processor struct {
rdb *redis.ClusterClient
latest *redis.Script // update_latest.lua
history *HistoryWriter
maxAge time.Duration
}
func (p *Processor) Run(ctx context.Context, r *kafka.Reader) error {
for {
batch, err := fetchBatch(ctx, r, 500, 200*time.Millisecond)
if err != nil {
return err
}
points := decode(batch) // protobuf; malformed points are counted and skipped
if err := p.history.Copy(ctx, points); err != nil {
return err // offsets not committed: the batch is read again
}
for i := range points {
if time.Since(points[i].Time()) <= p.maxAge {
p.applyLive(ctx, &points[i])
}
}
if err := r.CommitMessages(ctx, batch...); err != nil {
return err
}
}
}
func (p *Processor) applyLive(ctx context.Context, pt *Point) {
tag := "{" + pt.City + "}"
keys := []string{"drv:" + tag + ":" + pt.DriverID, "geo:" + tag, "seen:" + tag}
ok, err := p.latest.Run(ctx, p.rdb, keys, pt.DriverID, pt.Lng, pt.Lat, pt.TsMillis).Int()
if err != nil || ok == 0 {
return // stale point, or a Redis hiccup the next point repairs
}
if pt.TripID != "" {
p.rdb.Publish(ctx, "trip:"+pt.TripID, pt.WireJSON())
}
}| MQTT (hoặc WebSocket)MQTT (or WebSocket) | HTTP batchBatched HTTP | |
|---|---|---|
| Độ trễLatency | Dưới 1 giây, kết nối luôn mởUnder a second over a connection that stays open | Bằng chu kỳ gom lô, thường 10–30 giâyAs long as the batching interval, usually 10–30 seconds |
| Pin, dataBattery, data | Header nhỏ, keepalive tốn ít; cần xử lý reconnectSmall headers, cheap keepalives; reconnects must be handled | Mỗi request tốn TLS và header, nhưng radio được nghỉ giữa các lôEach request pays for TLS and headers, but the radio rests between batches |
| Phía serverServer side | Cần broker (EMQX, HiveMQ, AWS IoT) giữ hàng trăm nghìn kết nốiNeeds a broker (EMQX, HiveMQ, AWS IoT) holding hundreds of thousands of connections | API stateless sau load balancer, dễ vận hànhA stateless API behind a load balancer, easy to run |
| Chọn khiPick it when | Đang trong chuyến, khách nhìn bản đồOn a trip, with a rider watching the map | Tài xế rảnh, chỉ cần vị trí gần đúng để ghép chuyếnThe driver is idle and matching needs only a rough position |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Để mọi điểm của một tài xế đi qua cùng một partition và một consumer, giữ đúng thứ tự gửi và không cần khoá giữa các processor. 128 partition đủ cho 50k điểm/giây và cho phép tăng consumer sau này.So every point from one driver goes through one partition and one consumer, keeping send order without locks between processors. 128 partitions handle 50k points a second and leave room to add consumers.
Kafka giữ thứ tự nhận, không phải thứ tự GPS. Điện thoại gửi lại lô cũ sau khi mất mạng, MQTT QoS1 có thể giao trùng, và rebalance có thể làm đọc lại. So ts (hoặc seq) với điểm đã lưu là cách duy nhất bảo đảm vị trí không đi lùi.Kafka keeps arrival order, not GPS order. Phones resend old batches after an outage, MQTT QoS1 can deliver twice, and a rebalance can re-read records. Comparing ts (or seq) with the stored point is the only way to keep the position from going backwards.
GEOSEARCH trên sorted set geohash là O(N+log M) với N là số điểm trong vùng, thường dưới 1 ms. Tách key theo thành phố để mỗi set nhỏ, đọc từ replica, giới hạn COUNT. Khi cần lọc phức tạp hơn thì dùng chỉ mục H3 hoặc S2 theo ô.GEOSEARCH on a geohash sorted set is O(N+log M), where N is the points in the area, usually under 1 ms. Split keys by city so each set stays small, read from replicas and cap COUNT. For richer filtering, use an H3 or S2 cell index.
Tần suất thích ứng theo tốc độ và trạng thái chuyến, bỏ điểm dịch dưới 10 m, client nội suy giữa hai điểm, server giới hạn 1 cập nhật/giây cho mỗi người xem. Tài xế rảnh gửi 15–30 giây một lần là đủ cho ghép chuyến.Adapt the interval to speed and trip state, skip moves under 10 m, let the client interpolate between points, and cap updates at one a second per watcher. Idle drivers reporting every 15–30 seconds is enough for matching.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.