Mục lục
Đây là hệ thống giám sát hành trình (GSHT) đang được xây mới cho ngành giao thông vận tải. Mỗi phương tiện kinh doanh vận tải lắp một thiết bị gửi vị trí, tốc độ và trạng thái về máy chủ theo chu kỳ. Dữ liệu này không phải để vẽ bản đồ: nó là căn cứ để tính thời gian lái xe, phát hiện vi phạm và tổng hợp báo cáo cho cơ quan quản lý. Thiếu một event có thể làm sai một kết luận vi phạm.
Chỉ tiêu thiết kế là 50.000 event/giây ở giờ cao điểm, tương ứng 3 triệu phương tiện hoạt động đồng thời. Trong giai đoạn performance test, cấu hình đầu tiên chỉ đạt 43.000, và khi tăng gấp đôi số worker thì lên 44.000 — tức 2,3% — trong khi p99 tăng gần 4 lần.
Chúng tôi tăng thêm tài nguyên, tăng thêm worker, và throughput không nhích. Hệ thống không sập; nó chỉ đơn giản là không đạt chỉ tiêu, và càng thêm máy càng không đạt.
Bài này kể lại quá trình tìm root cause, thay đổi thiết kế để vượt chỉ tiêu — và cả những chỗ bằng chứng không đủ để kết luận.
Về số liệu. Các con số "trước/sau" là benchmark trong môi trường performance test với tải tổng hợp, không phải số đo vận hành production. Phần suy ra từ nghiệp vụ (50.000 event/s, 2,16 tỷ event/ngày, dung lượng lưu trữ) là toán học thuần. Cấu hình đo và định nghĩa từng metric nằm ở §6.0.
---
1. Bối cảnh và triệu chứng
1.1 Hệ thống và chỉ tiêu
Hệ thống nhận dữ liệu từ thiết bị GSHT, tính trạng thái hành trình cho từng xe, rồi xuất báo cáo cho cơ quan quản lý và dữ liệu tra cứu cho doanh nghiệp vận tải.
Vì đây là hệ thống mới, các ràng buộc đến từ yêu cầu và từ thực tế triển khai chứ không từ di sản cũ:
- Thiết bị do nhiều nhà cung cấp, giao thức cố định theo quy chuẩn. Không thể yêu cầu hàng trăm nghìn đơn vị vận tải đổi thiết bị để hợp với kiến trúc của mình.
- Database quan hệ lớn, chạy on-prem theo yêu cầu hạ tầng.
- Kết quả tính toán phải khớp với phương pháp luận quy định — sai một event có thể làm sai một kết luận vi phạm.
- Có mốc go-live, nên không thể chọn phương án cần vài tháng để viết lại từ đầu.
Điều này giới hạn không gian thiết kế: phải sửa bên trong, giữ nguyên giao thức và dữ liệu đầu vào.
1.2 Vì sao bài toán xuất hiện
Kiến trúc ban đầu xử lý mỗi event trọn vẹn trong một transaction: nhận event, đọc trạng thái xe, tính, ghi lại, commit. Cách làm đó đúng và dễ kiểm chứng khi mỗi giây chỉ có vài nghìn event.
Performance test đặt hệ thống vào đúng tải mục tiêu — 50.000 event/giây — và kết quả không đạt:
- 256 worker: 43.000 event/giây, tức 86% chỉ tiêu.
- 512 worker: 44.000 event/giây, tức 88% chỉ tiêu.
- Backlog vẫn tăng khoảng 6.000 event/giây khi bơm đủ 50.000.
Nói cách khác, hệ thống chưa bao giờ đạt chỉ tiêu; nó chỉ chưa được đo ở đúng mức tải đó. Và việc tăng gấp đôi worker chỉ mua thêm 1.000 event/giây.
Đó là lúc bài toán chuyển từ "cần thêm máy" thành "phải hiểu hệ thống đang chờ cái gì".
1.3 Khối lượng
Mỗi thiết bị gửi một bản tin khi xe chạy, chứa vị trí, vận tốc, thời điểm và trạng thái thiết bị.
cao điểm: 3.000.000 xe / 60s = 50.000 event/giây
trung bình: 1.500.000 xe / 60s = 25.000 event/giây
→ 2,16 tỷ event/ngàyỞ mức trung bình, mỗi event thô khoảng 300 byte: 648 GB/ngày, khoảng 940 GB/ngày khi tính index, tức ~27 TB cho cửa sổ 30 ngày. Dung lượng không phải vấn đề. Vấn đề là ghi bền vững 25.000 lần mỗi giây — và 50.000 lần mỗi giây lúc cao điểm.
1.4 Năm metric dễ bị lẫn
Phần lớn tranh luận về "TPS" thực chất là tranh luận về việc đang đo cái gì.
Metric | Đo cái gì |
|---|---|
| Event được ghi bền vững vào event log |
| Số lần ghi state thành công |
| Tổng row insert + update thực tế, trên mọi bảng |
| Số transaction commit thành công |
| Số statement gửi tới database |
Năm metric này phải tách rời, vì sau khi tối ưu chúng không còn đi cùng nhau. Ở kiến trúc cũ, mỗi event sinh một insert event và một update state, nên db_rows_written/s ≈ 2 × events_ingested/s. Ở kiến trúc mới, số row gần như không đổi; thứ giảm mạnh là statements/s và commits/s. Đó chính là luận điểm của bài, nên metric phải phản ánh đúng nó.
Về sau, tôi dùng events/s làm đơn vị throughput chính. "TPS" chỉ xuất hiện khi nói về transaction.
1.5 Đường cong đã phẳng
Chỉ số | 256 worker | 512 worker |
|---|---|---|
| 43.000 | 44.000 |
| 43.000 | 44.000 |
| 86.000 | 88.000 |
| 43.000 | 44.000 |
| 150.500 | 154.000 |
residence time TB | 5,95 ms | 11,6 ms |
p50 / p99 xử lý | 5,0 / 42 ms | 10,6 / 158 ms |
CPU / worker | 24% | 41% |
Lock wait / giây | 1.900 | 14.000 |
Backlog khi bơm 50.000/s | +7.000/s | +6.000/s |
Dấu hiệu chẩn đoán: gấp đôi concurrency, throughput đứng yên, residence time gần như gấp đôi.
Little's Law cho N = λ × W, trong đó W là residence time — thời gian một request nằm trong hệ thống. Với 256 worker: 43.000 × 5,95ms ≈ 256 transaction đồng thời. Với 512 worker: 44.000 × 11,6ms ≈ 510. Concurrency tăng đúng gấp đôi, đúng như dự đoán — nhưng λ thì không. Nghĩa là W đã tăng để hấp thụ toàn bộ concurrency thêm vào.
Kết quả không phải là một hệ thống sắp sập. Nó là một hệ thống không đạt chỉ tiêu và không có đường tới chỉ tiêu bằng cách thêm máy: 44.000 so với 50.000 là 88%, và mọi burst cục bộ còn đẩy khoảng cách đó ra xa hơn.
---
2. Điều tra bottleneck
Nguyên tắc: sắp xếp giả thuyết theo chi phí kiểm chứng, rẻ trước. Mỗi giả thuyết phải có một phép đo có thể phủ định nó.
# | Giả thuyết | Bằng chứng thu thập (mỗi bước 30 phút, tải 50.000/s) | Kết luận |
|---|---|---|---|
H1 | Downstream dependency là trần | Latency từng dependency p99 < 3ms, không đổi theo tải | Loại |
H2 | Connection pool cạn | Pool 100 connection, đỉnh dùng 38; pool wait p99 < 1ms | Loại |
H3 | GC / allocation pressure | GC pause p99 < 8ms; CPU 41% | Loại một phần |
H4 | Write amplification |
| Giữ |
H5 | Commit / WAL overhead |
| Giữ — chưa cô lập |
H6 | Serialization cuối pipeline | Tăng partition không đổi throughput | Loại |
H7 | Load generator tự nghẽn | Open-loop test cho cùng plateau | Loại |
H8 | Burst / retransmission | Phân bố theo giây: p99 gấp ~4× trung bình | Giữ |
H9 | Journey-id lookup | Không có bảng lookup; journey_id sinh trong luồng | Loại |
H1–H3 tốn khoảng hai ngày và loại được phần lớn. Đáng chú ý là H7 — giả thuyết bị đánh giá thấp nhất.
Load test kiểu closed-loop gửi request rồi chờ response, nên nó tự giảm áp lực khi hệ thống chậm lại. Nó không bao giờ tái hiện được trạng thái backlog phình ra. Chuyển sang open-loop (bơm theo tốc độ cố định, không chờ) thì plateau vẫn còn — nghĩa là plateau là thật, không phải artefact của test.
H4 và H5 cộng dồn chứ không loại trừ nhau:
44.000 events_ingested/s × 1 commit = 44.000 commits/s
44.000 events_ingested/s × 2 row = 88.000 db_rows_written/s
44.000 events_ingested/s × 3,5 statement = 154.000 statements/sH8 giải thích phần đuôi: phân bố event theo giây có đỉnh gấp khoảng 4 lần trung bình, do thiết bị mất sóng ở đèo, hầm, vùng lõm rồi gửi bù một cục.
---
3. Root cause và giới hạn của kết luận
3.1 Chế độ steady state: chi phí commit
Pipeline ban đầu, trên PostgreSQL:

Cần nói chính xác: một transaction cần WAL commit bền vững theo cấu hình durability; nhiều transaction có thể chia sẻ một lần WAL flush qua group commit. Không có chuyện "mỗi transaction = một fsync vật lý" một cách máy móc.
Điều chúng tôi quan sát được:
xact_commit/sbám sátevents/s— đúng như thiết kế một-event-một-transaction.- Disk
awaittăng tuyến tính theo tải. - Residence time tăng gần gấp đôi khi concurrency gấp đôi — dấu hiệu queueing.
- Thêm replica không thay đổi gì: replica không giúp write path.
Về CPU: nó dừng ở 41%, nhưng CPU không bão hoà không tự chứng minh được điều gì. Một tiến trình vẫn có thể chờ I/O, chờ lock hoặc chờ một tài nguyên tuần tự với tổng CPU thấp. Tín hiệu này chỉ loại trừ khả năng compute-bound thuần tuý; nó không chỉ ra commit là thủ phạm.
Điều chúng tôi chưa đo được: số lần fsync thực tế ở tầng thiết bị, thời gian WAL sync riêng phần, và kết quả của một can thiệp có kiểm soát (ví dụ hạ synchronous_commit, hoặc đổi riêng batch size).
Vì vậy kết luận đúng là: chi phí commit là ứng viên mạnh nhất, được nhiều bằng chứng gián tiếp củng cố, nhưng chưa được cô lập.
3.2 Chế độ burst: lock contention thật sự
Khi thiết bị mất sóng rồi gửi bù, hàng chục event của cùng một chuyến đến gần như đồng thời. Lúc này nhiều worker cùng muốn UPDATE state của cùng một chuyến và xếp hàng trên cùng một row.
Ở đây cũng cần cẩn thận về nhân quả. Lock wait tăng từ 1.900 lên 14.000/giây, nhưng thứ tự xuất hiện của hai metric không chứng minh được quan hệ nhân quả. Điều chứng minh được là: khi tách riêng cửa sổ burst và cửa sổ steady state, p99 tăng vọt chỉ trong cửa sổ burst, còn throughput trung bình không đổi. Nghĩa là lock contention giải thích đuôi, không giải thích ceiling.
3.3 Vì sao batch đúng ở đây
Logic hành trình là một phép fold có trạng thái. Với N event liên tiếp của cùng một xe, về mặt nghiệp vụ chỉ cần:
1 lần load state → N lần apply → 1 lần savePipeline cũ thực hiện N lần load → N lần save → N lần lock → N lần commit, tức trả chi phí N lần cho công việc chỉ cần một lần. Đó là chỗ batching tạo ra giá trị — không phải ở bản thân việc "dùng batch".
Cần nói thêm: cách batch này không giảm số row phải ghi. Nó giảm số lần phải mở transaction, gửi statement và chờ commit. §6.1 cho thấy rõ điều đó.
3.4 Luận điểm trung tâm
Cách diễn đạt "đơn vị scale là số transaction commit song song, và con số đó là 1" nghe mạnh nhưng sai như một nguyên tắc tổng quát: PostgreSQL commit nhiều transaction hiệu quả nhờ group commit, và throughput không chỉ phụ thuộc số commit song song.
Cách đúng hơn:
Worker không phải đơn vị quyết định throughput. Chi phí trên mỗi event mới là thứ cần tối ưu.
Khi mỗi event đều phải đọc state, cập nhật nhiều bảng và chờ commit, tăng concurrency chỉ làm nhiều worker tranh chấp cùng tài nguyên. Batching giảm số lần chạm vào tài nguyên đắt đỏ; tách write path khỏi compute path giới hạn thời gian transaction và cho phép hai phần scale độc lập.
Nhưng batching không tự động giải quyết mọi bottleneck. Nó chỉ hiệu quả khi giảm đúng chi phí đang giới hạn throughput — trong khi vẫn bảo toàn ordering, idempotency và durability.
---
4. Thiết kế mới
4.1 Tách write path khỏi compute path
write path: BEGIN → INSERT (batch) → COMMIT
compute path: đọc event theo batch → load state → fold → ghi stateĐường ghi giờ chỉ làm một việc: ghi bền vững. Không load state, không tính toán, không giữ transaction qua business logic.
Hai chi tiết bắt buộc:
Write path cũng phải batch. Nếu không, 50.000 INSERT mỗi giây đã là bottleneck mới.
Bảng event phải partition theo ngày, với index tối thiểu (vehicle_id, event_ts) cho replay. 2,16 tỷ row mỗi ngày — mỗi index thừa là một khoản thuế write trả mãi mãi.
4.2 Ba tầng coalescing
"Các event có chung group_id được gom lại để xử lý sau" nghe như thể batching theo key tạo ra throughput. Không phải.
Một chuyến có một event mỗi phút. Trong batch window 200ms, mỗi chuyến có trung bình 0,003 event — batch size theo key xấp xỉ 1. Batch theo key không giúp gì trong steady state. Nó chỉ giúp khi burst.

Tầng 1 và 2 tạo ra gần như toàn bộ kết quả. Tầng 3 là tính năng chống burst, không phải tính năng throughput — và gọi đúng tên nó quan trọng, vì nếu kỳ vọng sai thì sẽ đi tối ưu nhầm chỗ.
4.3 Routing, single-writer và fencing
journey_id do hệ thống sinh ra từ việc xử lý event. Nó là derived state, không phải một stable key đến từ nguồn.
Route theo journey_id nghĩa là phải tra vehicle_id → journey_id trước khi xử lý. Hai cách, cả hai đều là bẫy: bảng lookup (thêm điểm contention cho 3 triệu xe) hoặc cache trong process (stale khi restart, không nhất quán giữa instance → split journey).
Đúng: route theo `vehicle_id`. Nhưng route theo vehicle_id tự nó không đảm bảo ordering. Cần ba thứ, và cả ba phải được thực thi ở tầng database, không chỉ ở application.
1. Bảng ownership là nguồn sự thật duy nhất.
partition_owner(partition PK, epoch BIGINT, owner_id, lease_until)Mỗi lần ownership handoff, epoch tăng đơn điệu:
UPDATE partition_owner
SET epoch = epoch + 1, owner_id = $me, lease_until = now() + $ttl
WHERE partition = $p
AND (lease_until < now() OR owner_id = $me)
RETURNING epoch; -- worker giữ epoch này cho mọi transaction sau2. Database kiểm tra epoch trong chính transaction ghi state. Đầu mỗi transaction:
SELECT epoch FROM partition_owner WHERE partition = $p FOR SHARE;
-- nếu epoch <> $my_epoch thì abort ngayFOR SHARE giữ lock trên row đó tới hết transaction. Owner mới không thể bump epoch trong lúc worker cũ đang ghi; và sau khi epoch đã bump, mọi transaction của worker cũ đọc được giá trị mới và tự huỷ.
3. Worker cũ không thể ghi sau khi owner mới nhận ownership. Điều này không đến từ lease ở application — lease có thể hết hạn trong lúc worker cũ vẫn đang chạy. Nó đến từ việc epoch được kiểm tra trong transaction, kết hợp với lock FOR SHARE ở trên.
Khi số partition thay đổi. hash(vehicle_id) % N không phải một hàm ổn định: đổi N là hàng loạt key đổi partition. Vì vậy ownership không được tính lại ngầm trong code. Nó nằm trong bảng partition_owner, và mọi thay đổi là một bước migration tường minh: bump epoch cho các partition bị ảnh hưởng, chờ transaction đang mở kết thúc (TTL ngắn), rồi giao cho owner mới. Dùng consistent hashing nếu muốn giảm số partition phải chuyển.
---
5. Correctness và kiểm chứng
5.1 Ba lớp khác nhau, không được trộn lẫn
Phần này có rủi ro cao nhất trong bài, vì nó chạm tới quy định pháp luật. Cần tách rõ ba lớp:
Lớp 1 — quy định đang có hiệu lực. Điều 64 Luật Trật tự, an toàn giao thông đường bộ 36/2024/QH15 đã được sửa đổi bởi Luật 118/2025/QH15, hiệu lực 01/07/2026. Khoản 1 hiện hành:
Thời gian lái xe liên tục của người lái xe không quá 04 giờ, trừ trường hợp bất khả kháng hoặc trở ngại khách quan. Thời gian làm việc của người lái xe trong một ngày, một tuần thực hiện theo quy định của Bộ luật Lao động.
Điểm phải nói rõ: giới hạn 10 giờ/ngày và 48 giờ/tuần đã bị bỏ từ 01/07/2026. Trước ngưỡng đó, Điều 64 quy định cả ba giới hạn; sau ngưỡng đó chỉ còn giới hạn lái liên tục 04 giờ, còn thời gian làm việc ngày/tuần chuyển sang Bộ luật Lao động. Một bài viết trích Điều 64 theo bản cũ sẽ mô tả sai nghiệp vụ mà hệ thống phải tính.
Về cách xác định dừng, đỗ: Phụ lục 6 Thông tư 09/2015/TT-BGTVT là nguồn tham chiếu cho quy tắc hai bản tin liên tiếp và ngưỡng 3 km/h, nhưng một phần phụ lục đã được thay thế bởi Thông tư 05/2024/TT-BGTVT. Cần đối chiếu lại trên bản hợp nhất hiện hành trước khi dùng làm căn cứ.
Cũng cần lưu ý: Nghị định 10/2020/NĐ-CP — nguồn được dẫn cho quy tắc dừng nghỉ trong rất nhiều tài liệu — đã hết hiệu lực toàn bộ từ 01/01/2025, thay bằng Nghị định 158/2024/NĐ-CP và các văn bản sửa đổi của nó.
Lớp 2 — heuristic triển khai. Hệ thống của chúng tôi xác nhận dừng bằng ba bản tin liên tiếp có vận tốc dưới 3 km/h, thay vì hai bản tin như tham chiếu. Cả hai tham số — số bản tin và ngưỡng — đều là lựa chọn kỹ thuật, không phải một cách hiểu chặt hơn của quy định. Nó đánh đổi theo cả hai chiều:
- Giảm false positive: một bản tin nhiễu GPS không đủ để mở một chuỗi dừng giả; ngưỡng
< 3 km/hcũng chặt hơn≤ 3 km/h. - Tăng nguy cơ bỏ lỡ hoặc xác nhận chậm: một lần dừng thật ngắn, hoặc có bản tin lỗi ở giữa, có thể không được nhận.
Nói cách khác, ba bản tin không đương nhiên đúng hơn hai bản tin. Nó chỉ khác về phân bố lỗi, và cần được đánh giá bằng dữ liệu thực tế chứ không bằng trực giác.
Lớp 3 — quyết định nghiệp vụ. Look-back window 30 phút, cách xử lý late event, và chính sách phát hành báo cáo điều chỉnh. Đây là những quyết định không suy ra được từ văn bản pháp luật; cần người phụ trách nghiệp vụ xác nhận.
Hệ quả thiết kế. Quy định đã đổi giữa vòng đời dự án, nên ngưỡng 04 giờ, định nghĩa dừng nghỉ và cách tính thời gian làm việc không thể là hằng số trong code. Chúng là cấu hình có phiên bản: mỗi báo cáo phải ghi lại phiên bản rule đã dùng để tính, để khi quy định thay đổi, dữ liệu cũ vẫn giải thích được.
Hệ thống vẫn cộng dồn thời gian lái theo giây, không theo số bản tin. Cái thay đổi giữa các phiên bản quy định chỉ là ngưỡng đem ra so sánh, không phải cách đo.
Danh mục tham khảo ở cuối bài không chứng minh thuật toán trong bài tuân thủ quy định. Việc đó cần rà soát độc lập trên nguồn chính thức, tại đúng ngày xuất bản, và xác nhận của bộ phận nghiệp vụ.
5.2 State machine
state = { stop_count, last_stop_location, stop_start_ts,
driving_seconds, journey_id, version, last_event_seq }
fold(event):
nếu speed < 3 km/h và distance(pos, last_stop_location) <= R:
stop_count += 1
nếu stop_count >= 3: đang dừng, ghi nhận stop_start_ts
ngược lại:
stop_count = 0; kết thúc chuỗi dừng
nếu đang chạy:
driving_seconds += event.ts - last_event_tsBa ràng buộc cứng: state phải sống qua ranh giới batch; thứ tự tuyệt đối theo event_ts, không theo thời điểm đến; và sai một event thì kết luận đảo chiều.
Cộng dồn theo giây chứ không theo số bản tin là bắt buộc, vì khoảng cách giữa hai bản tin không đều — nhất là sau một đoạn mất sóng, khi 50 bản tin đến cùng lúc nhưng đại diện cho 50 phút. Đếm theo bản tin sẽ tính sai thời gian lái trong đúng tình huống nguy hiểm nhất.
Lỗi | Nguyên nhân | Hệ quả nghiệp vụ |
|---|---|---|
False stop | Event trùng đếm thành event mới | Xe đang chạy bị báo dừng → thiếu giờ lái |
Missed stop | Event trễ phá chuỗi | Xe dừng bị tính là chạy liên tục → báo vi phạm 4 giờ không có thật |
Split journey | Mapping stale khi restart | Một chuyến thành hai → giờ lái sai |
Double count | Replay không idempotent | Giờ lái nhân đôi |
5.3 Checkpoint, replay và idempotency
Đây là phần dễ sai nhất, và bản trước của bài đã sai ở đây.
Mô hình chúng tôi dùng: nhiều lane xử lý song song trong cùng một partition, và checkpoint không phải max(seq) — nó chỉ tiến qua tiền tố liên tục của các batch đã commit. Mô hình "một worker tuần tự mỗi partition", nơi checkpoint bằng sequence cuối batch là an toàn theo cấu trúc, không áp dụng ở đây.
Ba câu hỏi phải trả lời riêng biệt:
- Làm sao biết một batch đã xử lý xong?
- Làm sao biết một event đã được áp dụng?
- Làm sao chắc chắn checkpoint không vượt qua event chưa xử lý?
Mô hình đơn giản — và vì sao chúng tôi không dùng được nó. Nếu mỗi partition chỉ có đúng một worker xử lý tuần tự, checkpoint = sequence cuối của batch là an toàn theo cấu trúc. Nhưng chúng tôi chạy 16 lane song song: trong một partition có nhiều batch đang xử lý đồng thời và có thể hoàn tất lệch thứ tự. Ghi checkpoint = max(seq) trong trường hợp đó sẽ bỏ qua event của batch chậm hơn. Cùng transaction chỉ đảm bảo tính nguyên tử, không đảm bảo tính liên tục.
Ba cơ chế tách biệt:
(a) Idempotency ở cấp batch. Bảng batch_commit(batch_id PK, partition, seq_from, seq_to), ghi trong cùng transaction với việc xử lý. batch_id sinh tất định từ (partition, seq_from), nên replay cùng batch đụng unique key và trở thành no-op.
(b) Idempotency ở cấp event. Mỗi vehicle_state giữ last_event_seq. Điều kiện cập nhật là so sánh sequence, không phải version:
UPDATE vehicle_state
SET state = $s, version = version + 1, last_event_seq = $seq_to
WHERE vehicle_id = $v
AND last_event_seq = $seq_from - 1;- 1 row → áp dụng thành công.
- 0 row và
last_event_seq >= seq_to→ batch đã được áp dụng → bỏ qua. - 0 row và
last_event_seq < seq_from - 1→ có gap: một batch khác chưa xong. Huỷ transaction, đẩy batch lại queue.
Điểm cốt lõi: version chỉ chống stale write. Nó không chứng minh batch đã được áp dụng. Thứ chứng minh điều đó là last_event_seq so với khoảng sequence của batch — và điều kiện = seq_from - 1 biến mọi lần áp dụng sai thứ tự thành một abort thay vì một lần ghi đè.
(c) Checkpoint chỉ tiến qua tiền tố liên tục. Worker không cập nhật partition_cursor trực tiếp. Một committer riêng đọc batch_commit, tìm tiền tố liên tục dài nhất bắt đầu từ safe_seq + 1, rồi mới tiến con trỏ:
safe_seq ──► [batch 101][batch 102][batch 103] [batch 105] [batch 104]
└────── liên tục ──────┘ ↑
dừng ở đâyNhờ vậy con trỏ không bao giờ vượt qua một event chưa hoàn tất. Các batch đã commit nhưng nằm sau gap sẽ được replay khi khởi động lại — an toàn vì (a) và (b) đều idempotent.
Tóm lại: atomicity là điều kiện cần. Điều kiện đủ là checkpoint chỉ tiến qua tiền tố liên tục (contiguous prefix) của event stream.
Ba khái niệm khác nhau, không thay thế nhau.
Cơ chế | Trả lời câu hỏi | Hiện thực ở đây |
|---|---|---|
Optimistic concurrency control | Lần ghi này có còn hợp lệ không? | cột |
Idempotency | Áp lại cùng batch có an toàn không? |
|
Deduplication | Hai bản tin trùng có bị tính hai lần không? | ràng buộc duy nhất trên dữ liệu |
Dedup cần một ràng buộc ở tầng dữ liệu, ví dụ UNIQUE(vehicle_id, event_ts) hoặc UNIQUE(device_id, seq) — nếu thiết bị có thể gửi lại cùng một bản tin sau khi mất ACK. Idempotency không thay được dedup: nó bảo vệ batch, không bảo vệ từng bản ghi.
Quy tắc retry.
- Transaction rollback → batch trả lại queue, không giữ lại state dở dang.
- Client mất kết nối sau commit → lần chạy sau replay cùng batch;
batch_idtrùng khiến insert vàobatch_committhất bại, transaction bỏ qua và coi như đã xử lý. - Gap → abort, retry có backoff; không bao giờ "nhảy qua" để đi tiếp.
Trường hợp event log ở một hệ khác (Kafka hoặc một log append-only tách riêng): giữ nguyên ba cơ chế trên, chỉ thay nguồn sequence. Chỉ ACK sau khi transaction commit; ACK thất bại dẫn tới giao lại và replay, chặn bởi (a) và (b).
Cần nói rõ phạm vi: chúng tôi không đảm bảo exactly-once cho delivery hay cho side effect bên ngoài (gửi cảnh báo, gọi API). Với các side effect đó, idempotency key phải do phía nhận quy định.
5.4 Kiểm chứng trước go-live
- Replay dữ liệu lịch sử: chạy pipeline mới trên dữ liệu thật đã ghi nhận, so sánh kết quả hành trình và danh sách vi phạm với một bản cài đặt tham chiếu độc lập. Tiêu chí đề xuất: sai lệch bằng 0 trên 14 ngày liên tục, gồm ít nhất hai cao điểm cuối tuần.
- Reconciliation job: đối chiếu số event nhận, số event đã fold, số chuyến, số giờ lái — theo giờ và theo xe.
- Diễn tập tải: chạy đúng mô hình burst, không chỉ traffic đều.
- Rollout theo vùng, có rollback và runbook cho replay, DLQ drain, cô lập hot key.
---
6. Kết quả và bài học
6.0 Môi trường và cách đo
Hạng mục | Giá trị |
|---|---|
Database | PostgreSQL 16, |
Phần cứng | 16 vCPU, 64 GB RAM, NVMe local |
Dữ liệu | 3.000.000 |
Payload | ~300 B/event |
Warm-up | 10 phút, không tính vào kết quả |
Thời gian đo | 30 phút × 3 lần, lấy trung vị |
Throughput | event đã commit bền vững, không phải event đã nhận |
p50 / p99 xử lý | thời gian một event đi qua pipeline, không gồm thời gian chờ tại thiết bị |
Backlog | chênh lệch giữa tốc độ bơm và tốc độ xử lý |
Số liệu trong bảng kết quả là đo trực tiếp, trừ các ô đánh dấu † là suy ra từ công thức.
6.1 Bảng kết quả
Throughput. Cột "trước" đo ở mức tối đa hệ thống đạt được; cột "sau" đo tại đúng chỉ tiêu 50.000 event/giây.
Metric | Trước | Sau (tại chỉ tiêu) | Ghi chú |
|---|---|---|---|
| 44.000 | 50.000 | |
| 44.000 | 50.000 | một update mỗi event |
| 88.000 | ~100.100 | 50.000 event + 50.000 state + ~100 checkpoint |
| 44.000 | ~200 | 100 write path + 100 compute path |
| 154.000 | ~300 | 100 insert + 100 state + 100 checkpoint |
Độ trễ và năng lực.
Metric | Trước | Sau | Định nghĩa |
|---|---|---|---|
Throughput ceiling | 44.000 | 148.000 | events/s, đo trong performance test |
Đạt chỉ tiêu thiết kế | 88% | 296% | chỉ tiêu 50.000 events/s |
batch service time | — | 54 ms | bắt đầu xử lý batch → commit xong, một lane |
p50 processing latency | 10,6 ms | 130 ms | event vào buffer → fold xong, gồm chờ batch và chờ lane |
p99 end-to-end lag | tăng liên tục | 1,8 giây | thiết bị gửi → state cập nhật bền vững |
Backlog khi bơm 50.000/s | +6.000/s | 0 | chênh lệch tốc độ bơm và tốc độ xử lý |
Worker / lane | 512 | 16 |
Ba đại lượng thời gian này không tương đương: 54 ms là service time của một batch, 130 ms là processing latency của một event, 1,8 giây là end-to-end. Công thức ở §6.2 chỉ mô tả đại lượng thứ nhất.

Điểm đáng chú ý: ở cùng một mức tải 50.000 event/giây, db_rows_written/s vẫn là ~100.000 — batching không viết ít dữ liệu hơn. Thứ giảm mạnh là statements/s (154.000 ở mức 44.000 events/s → ~300 tại chỉ tiêu) và commits/s (44.000 → ~200). Đây đúng là luận điểm của bài: batching không làm ít dữ liệu đi, nó làm ít lần chạm vào tài nguyên đắt đỏ.
Về tỷ lệ cải thiện: trong benchmark tổng hợp, cấu hình mới đạt khoảng 3,4 lần throughput của cấu hình cũ với 16 lane thay vì 512 worker. Đây là kết quả của cả hai thay đổi được triển khai cùng lúc; chưa có thí nghiệm tách biến để quy phần cải thiện cho từng thay đổi.
6.2 Mô hình throughput, và điều kiện để nó đúng
throughput ≈ số lane × batch size / batch service time
= 16 × 500 / 0,054
≈ 148.148 events/s†Công thức chỉ đúng dưới bốn điều kiện:
- 54ms là batch service time của một lane, đo riêng bằng cách chạy một lane với batch 500 trên dữ liệu replay — không suy ngược từ throughput tổng.
- Các lane độc lập, không chia sẻ tài nguyên tuần tự nào khác. Đây là điều phải kiểm chứng: nếu 16 lane cùng chờ một WAL flush thì mô hình sai ngay.
- Buffer tách fill khỏi service. Event tích vào buffer dùng chung; lane rút 500 event khi rảnh, nên thời gian "chờ đủ batch" không nằm trong chu kỳ của lane.
- Flush theo size, window chỉ là cap. Flush xảy ra khi đủ 500 event hoặc hết window 200ms, tuỳ cái nào trước.
Điều kiện 4 giải thích một phép tính mà nếu bỏ qua sẽ thấy vô lý: nếu mỗi lane chỉ flush theo chu kỳ cố định 200ms thì ceiling là 16 × 500 / 0,2 = 40.000 events/s. Ở tải 50.000/s, mỗi lane nhận 3.125 event/s và đầy 500 event trong ~160ms — ngắn hơn window, nên flush theo size chiếm ưu thế và window chỉ hoạt động như một giới hạn latency cho lúc tải thấp.
Một lưu ý quan trọng: 54ms là service time, không phải latency đầu-cuối. Một event còn phải chờ batch hình thành, chờ lane rảnh, rồi mới tới lượt xử lý. Công thức throughput không thay thế được phép đo p50/p99 — và trong bài này chúng là hai phép đo hoàn toàn khác nhau.
6.3 Cái giá
- p50 processing latency tăng từ 10,6ms lên 130ms vì event phải chờ flush (định nghĩa ở §6.1). Đánh đổi thật, chỉ chấp nhận được vì nghiệp vụ đã có độ trễ tự nhiên vài phút cho việc xác nhận dừng. Cần kiểm lại với SLA end-to-end thực tế, không chỉ với rule nghiệp vụ.
- Độ phức tạp vận hành tăng: replay tooling, checkpoint, DLQ, reconciliation, fencing token.
- Debug khó hơn: một lỗi logic ảnh hưởng cả batch.
- Idempotency downstream là nợ kỹ thuật trả sau.
6.4 Điều chưa được chứng minh
Ba điểm dưới đây là lỗ hổng đã biết của bài, được ghi lại thay vì che đi.
Chưa tách biến. Batch và tách write path đi cùng một lần. Thí nghiệm đúng là chạy lại hai biến thể: (a) giữ batch, trả logic về inline; (b) giữ tách logic, commit từng event. Dự đoán của tôi là (b) quay về quanh 44.000 — nhưng đó là dự đoán, chưa phải kết quả, và bài này không có số liệu để chứng minh.
Chưa cô lập cơ chế commit. Bằng chứng ở §3.1 là gián tiếp.
Chưa chạm ceiling cuối. Sau 148.000, bottleneck tiếp theo là connection pool của database: khi chi phí commit không còn là điểm nghẽn, áp lực chuyển sang số connection đồng thời và số statement in-flight trên mỗi connection.
6.5 Bài học
Throughput bị chặn bởi phần song song kém nhất, không phải phần nhanh nhất. Thêm capacity khi đã chạm ceiling chỉ mua thêm contention.
Chi phí trên mỗi event quan trọng hơn số worker. Batching giảm số lần chạm tài nguyên, không giảm lượng dữ liệu.
Atomicity không đồng nghĩa với đúng đắn. Cùng transaction không cứu được một checkpoint tiến sai thứ tự — chỉ tiền tố liên tục mới làm được điều đó.
Ordering cần single-writer, và single-writer cần fencing ở tầng database. Lease ở application không đủ.
---
Checklist
Dấu hiệu plateau do contention
- Gấp đôi concurrency → throughput gần như không đổi, residence time gần như gấp đôi.
- Queue depth tăng đơn điệu khi tăng worker.
- Thêm replica không thay đổi throughput.
Trước khi so sánh bất kỳ con số nào
- Đã tách
events_ingested/s,state_updates/s,db_rows_written/s,commits/s,statements/schưa? - Throughput đo trên event đã commit bền vững hay event đã nhận?
- Load test là open-loop hay closed-loop?
Checkpoint và replay
- Checkpoint chỉ tiến qua tiền tố liên tục, hay qua
max(seq)? - Có
batch_idtất định để replay thành no-op? - Idempotency ở cấp event dựa trên sequence hay version?
- Khi gap xuất hiện, batch bị abort hay bị bỏ qua?
Single-writer
- Epoch có tăng đơn điệu mỗi lần handoff?
- Epoch được kiểm tra trong cùng transaction ghi state?
- Đổi số partition có phải migration tường minh không?
---
Tham khảo
- Luật Trật tự, an toàn giao thông đường bộ số 36/2024/QH15, Điều 64.
- Luật số 118/2025/QH15 sửa đổi, bổ sung một số điều của 10 luật có liên quan đến an ninh, trật tự — Điều 7 khoản 15 (sửa khoản 1 Điều 64), hiệu lực 01/07/2026.
- Bộ luật Lao động số 45/2019/QH14 — thời gian làm việc.
- Nghị định 158/2024/NĐ-CP về hoạt động vận tải đường bộ, hiệu lực 01/01/2025 (thay thế Nghị định 10/2020/NĐ-CP đã hết hiệu lực toàn bộ).
- Thông tư 09/2015/TT-BGTVT, Phụ lục 6; lưu ý một phần phụ lục đã được thay thế bởi Thông tư 05/2024/TT-BGTVT.
- Thông tư 71/2024/TT-BCA về quản lý, vận hành, sử dụng hệ thống dữ liệu thiết bị giám sát hành trình.
- QCVN 06:2024/BCA về thiết bị giám sát hành trình và thiết bị ghi nhận hình ảnh người lái xe.