Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

VN context: schema ride-hailing Be/Grab mô phỏng

SQL and Data Analysis cho BA Bài 41/60

Mở đầu — vì sao bài này quan trọng

Khi bạn làm BA (Business Analyst) cho một công ty công nghệ Việt Nam, có một sự thật phũ phàng: phần lớn thời gian bạn không ngồi viết những câu SQL hoa mỹ. Bạn ngồi cố hiểu xem dữ liệu của công ty được tổ chức như thế nào. Một bảng tên trips thực ra chứa gì? Tại sao có tới ba cột thời gian khác nhau cho một chuyến đi? "GMV" mà sếp nhắc trong cuộc họp được tính từ cột nào? Nếu bạn không nắm được schema — tức là cấu trúc bảng và mối quan hệ giữa chúng — thì mọi câu SQL bạn viết đều chỉ là đoán mò.

Bài học này khác với các bài trước. Ở các bài JOIN, GROUP BY hay window function, bạn học kỹ thuật. Còn ở đây, bạn học cách đọc và sống cùng một schema thực tế của ngành gọi xe (ride-hailing) — ngành mà Grab, Be, Gojek (trước đây) và Xanh SM đang cạnh tranh khốc liệt tại Việt Nam. Tôi sẽ dựng cho bạn một schema mô phỏng sát với cách các công ty này tổ chức dữ liệu, rồi hướng dẫn bạn tư duy như một BA: nhìn vào bảng và lập tức hiểu mình khai thác được chỉ số gì.

Vì sao gọi xe là một ví dụ tuyệt vời để học? Vì nó có đủ độ phức tạp đời thực: có hai phía của thị trường (tài xế và khách), có vòng đời chuyến đi nhiều trạng thái, có tiền (cước, phụ phí, hoa hồng, khuyến mãi), có thời gian và vị trí địa lý. Hiểu được schema này, bạn sẽ có khung tư duy áp dụng được cho gần như mọi nền tảng marketplace khác.

Khái niệm cốt lõi

Marketplace hai phía và cách nó định hình schema

Gọi xe là một two-sided marketplace: một bên là rider (khách đặt xe), một bên là driver (tài xế). Nền tảng đóng vai trò khớp lệnh (matching). Đặc điểm này quyết định schema: bạn sẽ luôn có ít nhất hai bảng thực thể chính cho hai phía, và một bảng giao dịch ở giữa nối chúng lại — đó là bảng trips (chuyến đi).

Hãy bắt đầu với schema mô phỏng. Tôi viết theo cú pháp PostgreSQL, nhưng tư duy áp dụng cho cả MySQL.

CREATE TABLE riders (
  id           BIGINT PRIMARY KEY,
  phone        VARCHAR(20) UNIQUE,
  full_name    VARCHAR(100),
  city         VARCHAR(50),          -- 'HCM', 'HN', 'DN'...
  signup_at    TIMESTAMP,
  referral_code VARCHAR(20),
  is_active    BOOLEAN DEFAULT TRUE
);

CREATE TABLE drivers ( id BIGINT PRIMARY KEY, phone VARCHAR(20) UNIQUE, full_name VARCHAR(100), city VARCHAR(50), vehicle_type VARCHAR(20), -- 'bike', 'car_4', 'car_7' plate_number VARCHAR(15), onboarded_at TIMESTAMP, rating_avg NUMERIC(3,2), -- 4.87 status VARCHAR(20) -- 'active','suspended','offboarded' );

CREATE TABLE trips ( id BIGINT PRIMARY KEY, rider_id BIGINT REFERENCES riders(id), driver_id BIGINT REFERENCES drivers(id), city VARCHAR(50), service_type VARCHAR(20), -- 'bike','car_4','car_7','food','express' status VARCHAR(20), -- 'completed','cancelled_rider','cancelled_driver','no_driver' requested_at TIMESTAMP, -- lúc khách bấm đặt accepted_at TIMESTAMP, -- lúc tài xế nhận pickup_at TIMESTAMP, -- lúc đón khách completed_at TIMESTAMP, -- lúc kết thúc pickup_lat NUMERIC(9,6), pickup_lng NUMERIC(9,6), dropoff_lat NUMERIC(9,6), dropoff_lng NUMERIC(9,6), distance_km NUMERIC(6,2), duration_min INT, fare_amount INT, -- cước gốc, đơn vị VND surge_multiplier NUMERIC(3,2) DEFAULT 1.0, promo_id BIGINT, -- NULL nếu không dùng KM discount_amount INT DEFAULT 0, final_amount INT, -- khách thực trả commission_amount INT, -- phần nền tảng giữ lại payment_method VARCHAR(20) -- 'cash','wallet','card' );

CREATE TABLE promotions ( id BIGINT PRIMARY KEY, code VARCHAR(30), -- 'GRABHE50' discount_type VARCHAR(10), -- 'percent','fixed' discount_val INT, city VARCHAR(50), valid_from DATE, valid_to DATE );

Vì sao có nhiều cột thời gian đến vậy?

Đây là điểm BA mới hay bối rối. Một chuyến đi không phải một khoảnh khắc, mà là một vòng đời gồm nhiều mốc:

  • requested_at: khách bấm nút đặt. Đây là điểm khởi đầu của cầu (demand).
  • accepted_at: có tài xế nhận. Khoảng cách giữa requested_ataccepted_at chính là thời gian chờ ghép xe — một KPI tối quan trọng. Nếu cột này NULL nghĩa là không ai nhận chuyến (status = 'no_driver').
  • pickup_at: tài xế đến điểm đón. Khoảng cách accepted_at → pickup_atETA thực tế (thời gian tài xế chạy tới).
  • completed_at: chuyến kết thúc. Chỉ chuyến completed mới có giá trị này.
Hiểu các mốc này, bạn mới tính đúng được những chỉ số then chốt. Ví dụ thời gian chờ ghép xe trung bình theo thành phố:

SELECT city,
       AVG(EXTRACT(EPOCH FROM (accepted_at - requested_at))) AS avg_wait_seconds
FROM trips
WHERE accepted_at IS NOT NULL
  AND requested_at >= '2026-06-01'
GROUP BY city;

Các thực thể tiền (money) và lý do tách cột

Một sai lầm chết người của BA non kinh nghiệm là dùng nhầm cột tiền. Trong schema này có tới năm cột liên quan tiền, mỗi cột một ý nghĩa:

  • fare_amount: cước gốc theo quãng đường và thời gian, trước surge và khuyến mãi.
  • surge_multiplier: hệ số nhân giờ cao điểm (mưa, kẹt xe). 1.5 nghĩa là cước tăng 50%.
  • discount_amount: số tiền khách được giảm nhờ khuyến mãi.
  • final_amount: số tiền khách thực trả. Đây là con số trên hoá đơn.
  • commission_amount: phần nền tảng giữ lại (thường 20–25% với Grab). Phần còn lại là thu nhập tài xế.
Hai khái niệm bạn buộc phải phân biệt:

  • GMV (Gross Merchandise Value): tổng giá trị giao dịch chạy qua nền tảng — thường tính bằng SUM(final_amount) của chuyến completed. Đây là con số sếp khoe với nhà đầu tư.
  • Net Revenue: doanh thu thực của nền tảng — chính là SUM(commission_amount). Đây mới là tiền công ty thực sự "ăn".
Nhầm hai con số này là bạn báo cáo lệch gấp 4–5 lần, đủ để mất việc.

Tình huống thực tế

Tình huống 1 — "GMV tháng này của Be tăng hay giảm?" (Be Group, TP.HCM)

Bạn là BA tại một startup gọi xe kiểu Be. Sáng thứ Hai, Giám đốc vận hành nhắn: "GMV mảng xe máy tháng 6 ở Sài Gòn thế nào so với tháng 5?" Người mới sẽ hốt hoảng. Nhưng nếu đã hiểu schema, bạn biết ngay: GMV = SUM(final_amount) cho chuyến completed, lọc service_type = 'bike', city = 'HCM', group theo tháng.

SELECT DATE_TRUNC('month', completed_at) AS thang,
       SUM(final_amount)        AS gmv,
       COUNT(*)                 AS so_chuyen,
       SUM(commission_amount)   AS net_revenue
FROM trips
WHERE service_type = 'bike'
  AND city = 'HCM'
  AND status = 'completed'
  AND completed_at >= '2026-05-01'
  AND completed_at <  '2026-07-01'
GROUP BY 1
ORDER BY 1;

Diễn giải: chú ý ba điều kiện lọc bắt buộc — status = 'completed' (chuyến huỷ không tạo GMV), service_type đúng mảng, và khung thời gian dùng completed_at chứ không phải requested_at. Nếu bạn lọc theo requested_at, một chuyến đặt cuối tháng 5 nhưng hoàn thành đầu tháng 6 sẽ bị tính nhầm tháng.

Bài học: schema cho bạn nhiều cột thời gian không phải để rối, mà để bạn chọn đúng mốc cho đúng câu hỏi. Câu hỏi về doanh thu thì dùng completed_at. Câu hỏi về nhu cầu thị trường thì dùng requested_at.

Tình huống 2 — "Tỉ lệ huỷ chuyến đang ở mức nào?" (mô phỏng Grab giờ cao điểm)

Đội Operations lo lắng vì khách phàn nàn không đặt được xe lúc 17h–19h khi trời mưa ở Hà Nội. Họ cần bạn tính tỉ lệ huỷ và tỉ lệ không có tài xế theo khung giờ. Đây là lúc cột status toả sáng. Nó không chỉ có completed, mà còn phân biệt rõ ai huỷ: cancelled_rider, cancelled_driver, hay hệ thống không tìm được tài xế (no_driver).

SELECT EXTRACT(HOUR FROM requested_at) AS gio,
       COUNT(*) AS tong_yeu_cau,
       ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)
             / COUNT(*), 1) AS ty_le_hoan_thanh,
       ROUND(100.0 * SUM(CASE WHEN status = 'no_driver' THEN 1 ELSE 0 END)
             / COUNT(*), 1) AS ty_le_khong_tai_xe,
       ROUND(100.0 * SUM(CASE WHEN status = 'cancelled_rider' THEN 1 ELSE 0 END)
             / COUNT(*), 1) AS ty_le_khach_huy
FROM trips
WHERE city = 'HN'
  AND requested_at >= '2026-06-01'
GROUP BY 1
ORDER BY 1;

Diễn giải: ở đây ta đếm trên tất cả yêu cầu (requested_at), không lọc status, vì mẫu số phải là toàn bộ cầu phát sinh. Phép CASE WHEN ... THEN 1 ELSE 0 đếm có điều kiện — bạn đã học ở bài conditional logic. Kết quả thường cho thấy khung 17h–19h có ty_le_khong_tai_xe vọt lên, chứng tỏ thiếu cung tài xế chứ không phải khách khó tính.

Bài học: một cột status thiết kế tốt (phân biệt nguyên nhân huỷ) cho phép BA chẩn đoán gốc rễ vấn đề. Nếu schema chỉ có status dạng completed/cancelled chung chung, bạn sẽ không bao giờ biết là thiếu xe hay khách bỏ. Bài học cho BA: khi tham gia thiết kế schema, hãy đòi hỏi các cột trạng thái phải đủ chi tiết.

Tình huống 3 — "Khuyến mãi GRABHE50 có thực sự kéo khách?" (phân tích hiệu quả promo)

Marketing tung mã GRABHE50 (giảm 50% tối đa 30k) cho khách ở Đà Nẵng. Sau hai tuần, họ hỏi: "Tốn bao nhiêu tiền giảm giá, và những khách dùng mã có quay lại không?" Bạn cần JOIN trips với promotions, và phân biệt khách dùng promo với khách không dùng.

SELECT p.code,
       COUNT(t.id)              AS so_chuyen_dung_ma,
       COUNT(DISTINCT t.rider_id) AS so_khach,
       SUM(t.discount_amount)   AS tong_tien_giam,
       SUM(t.final_amount)      AS gmv_tu_promo,
       ROUND(AVG(t.final_amount)) AS gia_tri_tb_moi_chuyen
FROM trips t
JOIN promotions p ON t.promo_id = p.id
WHERE p.code = 'GRABHE50'
  AND t.status = 'completed'
GROUP BY p.code;

Diễn giải: COUNT(DISTINCT t.rider_id) cho biết mã chạm tới bao nhiêu người thật, vì một khách có thể dùng mã nhiều lần. SUM(discount_amount) là chi phí marketing thực. Chia chi phí này cho số khách mới thu được, bạn ra chi phí thu hút mỗi khách (CAC) — con số Marketing buộc phải biết.

Bài học: cột promo_id để NULL khi không dùng mã chính là chìa khoá. Nhờ nó bạn dễ dàng tách hai nhóm khách (dùng/không dùng) để so sánh. Một schema tốt luôn để lại "dấu vết" của mỗi quyết định kinh doanh — và việc của BA là tìm ra dấu vết đó.

Hướng dẫn từng bước

Khi nhận một schema mới hoàn toàn (ví dụ ngày đầu đi làm ở công ty gọi xe), hãy theo quy trình sau để làm chủ nó nhanh nhất:

Bước 1 — Liệt kê bảng và phân loại. Tách thành bảng thực thể (riders, drivers, promotions — mô tả "ai/cái gì") và bảng giao dịch (trips — ghi lại "sự kiện gì đã xảy ra"). Bảng giao dịch thường là nơi bạn truy vấn nhiều nhất.

Bước 2 — Tìm khoá và quan hệ. Xác định primary key của từng bảng và foreign key nối chúng. Ở đây: trips.rider_id → riders.id, trips.driver_id → drivers.id, trips.promo_id → promotions.id. Vẽ nhanh sơ đồ quan hệ ra giấy.

Bước 3 — Khám phá giá trị thực của các cột phân loại. Đừng tin tên cột, hãy nhìn dữ liệu thật:

SELECT status, COUNT(*) FROM trips GROUP BY status ORDER BY 2 DESC;
SELECT service_type, COUNT(*) FROM trips GROUP BY service_type;

Bạn sẽ biết chính xác status có những giá trị nào, tỉ lệ ra sao. Đây là bước người mới hay bỏ qua và trả giá đắt.

Bước 4 — Kiểm tra chất lượng các cột then chốt. Bao nhiêu phần trăm completed_at bị NULL? final_amount có giá trị âm hay bằng 0 bất thường không?

SELECT
  COUNT(*) FILTER (WHERE completed_at IS NULL) AS thieu_completed,
  COUNT(*) FILTER (WHERE final_amount <= 0)    AS gia_bat_thuong
FROM trips;

Bước 5 — Dựng một câu truy vấn "xương sống". Viết sẵn một query tính các chỉ số cốt lõi (chuyến completed, GMV, net revenue theo ngày) làm điểm tựa. Mọi báo cáo sau này đều biến tấu từ nó.

Bước 6 — Ghi lại hiểu biết. Mỗi khi giải mã được một cột (ví dụ "à, final_amount đã trừ discount rồi"), hãy ghi vào tài liệu data dictionary của riêng bạn. Bài 50 trong khoá sẽ nói sâu về việc này.

Lỗi thường gặp & mẹo

Lỗi 1 — Quên lọc status = 'completed' khi tính tiền. Chuyến huỷ vẫn nằm trong bảng trips, và một số có thể có fare_amount ước tính. Tính GMV mà không lọc trạng thái, bạn thổi phồng doanh thu. Luôn hỏi: "chỉ số này áp dụng cho chuyến nào?"

Lỗi 2 — Lẫn lộn GMV và Net Revenue. Đã nói ở trên nhưng phải nhắc lại vì nó là lỗi phổ biến nhất. GMV (SUM(final_amount)) lớn gấp 4–5 lần Net Revenue (SUM(commission_amount)). Khi sếp hỏi "doanh thu", hãy hỏi lại họ muốn con số nào.

Lỗi 3 — Dùng nhầm cột thời gian. Báo cáo nhu cầu thị trường dùng requested_at. Báo cáo doanh thu dùng completed_at. Đo hiệu suất ghép xe dùng hiệu accepted_at − requested_at. Chọn sai mốc, số liệu lệch âm thầm mà không báo lỗi — loại sai nguy hiểm nhất.

Lỗi 4 — Quên NULL khi trừ thời gian. Chuyến no_driveraccepted_at NULL. Nếu bạn tính AVG(accepted_at - requested_at) mà không lọc accepted_at IS NOT NULL, kết quả vẫn ra nhưng mẫu số bị méo hoặc một số DB cho NULL. Luôn lọc NULL khi đo khoảng thời gian.

Mẹo 1 — Đếm DISTINCT rider để đo "người", đếm bản ghi để đo "lượt". COUNT(*) là số chuyến, COUNT(DISTINCT rider_id) là số người. Hai con số trả lời hai câu hỏi khác nhau.

Mẹo 2 — Cột city lặp ở cả tripsriders là cố ý. Đây là denormalization (phi chuẩn hoá) để báo cáo nhanh hơn, tránh phải JOIN. Khi phân tích chuyến đi theo địa bàn, dùng trips.city (nơi diễn ra chuyến), không phải riders.city (nơi khách đăng ký).

Mẹo 3 — Hỏi về "soft delete". Cột is_active của rider gợi ý hệ thống không xoá cứng mà đánh dấu. Khi đếm khách hoạt động, nhớ lọc is_active = TRUE.

Bài tập thực hành

Dùng schema mô phỏng ở trên, hãy viết SQL cho các yêu cầu sau (tự kiểm bằng cách đối chiếu logic với phần bài học):

  • Cơ bản: Đếm số chuyến hoàn thành theo từng service_type trong tháng 6/2026, sắp xếp giảm dần.
  • Tiền: Tính GMV và Net Revenue theo từng thành phố, chỉ cho chuyến completed. Thêm cột tỉ lệ commission (net_revenue / gmv * 100).
  • Chất lượng dịch vụ: Tính thời gian chờ ghép xe trung bình (giây) theo thành phố, chỉ tính chuyến có accepted_at không NULL. Thành phố nào chờ lâu nhất?
  • Trạng thái: Với mỗi thành phố, tính tỉ lệ phần trăm chuyến bị cancelled_driver (tài xế huỷ) trên tổng yêu cầu. Gợi ý: dùng CASE WHEN trong tử số, COUNT(*) mẫu số.
  • JOIN promo: Liệt kê 5 mã khuyến mãi có tổng discount_amount cao nhất, kèm số khách distinct mỗi mã đã chạm tới.
  • Nâng cao — surge: Tìm khung giờ (theo EXTRACT(HOUR FROM requested_at)) có surge_multiplier trung bình cao nhất ở HCM. Điều này cho bạn biết giờ nào cầu vượt cung mạnh nhất.
  • Tư duy BA: Không cần viết SQL, hãy trả lời bằng lời: nếu sếp hỏi "tài xế của chúng ta kiếm trung bình bao nhiêu mỗi chuyến?", bạn sẽ dùng cột nào và lọc điều kiện gì? (Gợi ý: thu nhập tài xế = final_amount − commission_amount, chỉ chuyến completed.)

Tóm tắt

Bài này không dạy một cú pháp SQL mới, mà dạy bạn điều quan trọng hơn: cách đọc một schema thực tế và tư duy như BA của ngành. Với một nền tảng gọi xe kiểu Be/Grab, ta có hai bảng thực thể (riders, drivers), một bảng giao dịch trung tâm (trips) nối hai phía của marketplace, và các bảng phụ trợ như promotions.

Ba điều cốt lõi cần khắc cốt ghi tâm: thứ nhất, nhiều cột thời gian (requested_at, accepted_at, pickup_at, completed_at) phản ánh vòng đời chuyến đi — chọn đúng mốc cho đúng câu hỏi. Thứ hai, nhiều cột tiền mỗi cột một nghĩa — đặc biệt phân biệt GMV (final_amount) với Net Revenue (commission_amount). Thứ ba, cột status chi tiết cho phép chẩn đoán gốc rễ vấn đề vận hành chứ không chỉ đếm thành/bại.

Quy trình làm chủ schema mới — phân loại bảng, tìm khoá, khám phá giá trị cột, kiểm chất lượng, dựng query xương sống, ghi lại hiểu biết — là kỹ năng bạn sẽ dùng đi dùng lại suốt sự nghiệp BA, dù sau này có chuyển sang e-commerce hay fintech (chính là hai bài tiếp theo trong khoá). Schema thay đổi, nhưng cách tư duy thì không.