Menu
ESC

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

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

Đang tải...

Date/Time functions: filter, format, đo khoảng cách

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

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

Hãy thử nghĩ về một ngày làm việc bình thường của bạn với vai trò Business Analyst. Sếp hỏi: "Doanh thu tháng này so với tháng trước thế nào?". Team marketing nhờ: "Cho chị danh sách khách đăng ký trong 7 ngày gần nhất". Bộ phận vận hành cần: "Đơn hàng nào giao trễ hơn 48 tiếng kể từ lúc đặt?". Bạn để ý điểm chung chưa? Cả ba câu hỏi đều xoay quanh thời gian.

Trong gần như mọi bảng dữ liệu thực tế bạn sẽ gặp — đơn hàng, giao dịch, đăng ký, log truy cập — đều có ít nhất một cột ngày giờ: created_at, order_date, updated_at, paid_at. Và phần lớn các câu hỏi nghiệp vụ thực sự có giá trị đều liên quan đến việc lọc theo thời gian, định dạng lại thời gian cho người đọc hiểu, hoặc đo khoảng cách giữa hai mốc thời gian.

Nếu bạn không nắm chắc nhóm hàm date/time, bạn sẽ liên tục viết ra những query "gần đúng" — kiểu lọc nhầm múi giờ, đếm thiếu một ngày ở biên, hay so sánh chuỗi thay vì so sánh ngày. Những lỗi này âm thầm làm sai báo cáo mà không hề báo lỗi đỏ. Bài này tập trung đúng vào ba kỹ năng cốt lõi mà một BA cần: filter (lọc), format (định dạng), và đo khoảng cách thời gian. Đây là nền tảng để sau này bạn làm time-series, cohort, churn — những bài nâng cao phía sau khóa học.

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

Lấy thời gian hiện tại

Điểm khởi đầu là biết "bây giờ" là khi nào theo góc nhìn của database:

SELECT NOW();        -- 2026-06-27 14:32:01  (ngày + giờ)
SELECT CURDATE();    -- 2026-06-27           (chỉ ngày)
SELECT CURTIME();    -- 14:32:01             (chỉ giờ)

Trên PostgreSQL, các hàm tương đương là NOW(), CURRENT_DATE, và CURRENT_TIME. Một lưu ý quan trọng ngay từ đầu: NOW() trả về thời gian của server database, không phải máy bạn. Nếu server đặt ở Singapore còn bạn ngồi ở Hà Nội thì thường trùng múi giờ (UTC+7 vs UTC+8 — cần kiểm tra), nhưng nhiều server cloud chạy theo UTC. Đây là nguồn gốc của vô số lỗi lệch giờ mà ta sẽ nói kỹ ở phần lỗi thường gặp.

Hiểu kiểu dữ liệu: DATE, TIME, DATETIME, TIMESTAMP

Trước khi xử lý, bạn phải biết cột mình đang đụng vào thuộc kiểu gì:

  • DATE: chỉ ngày, ví dụ 2026-06-27.
  • TIME: chỉ giờ trong ngày.
  • DATETIME (MySQL): ngày + giờ, không gắn múi giờ, lưu y nguyên giá trị bạn ghi vào.
  • TIMESTAMP: ngày + giờ nhưng có liên quan đến múi giờ; MySQL quy đổi về UTC khi lưu và đổi lại theo time_zone của phiên khi đọc. PostgreSQL phân biệt rõ timestamp without time zonetimestamptz.
Sự khác biệt DATETIME vs TIMESTAMP nghe có vẻ học thuật, nhưng nó quyết định việc một đơn hàng đặt lúc 23:30 ngày 30 rơi vào báo cáo tháng nào.

Trích xuất thành phần thời gian

Rất nhiều phân tích cần "bẻ" một mốc thời gian ra từng mảnh: năm, tháng, ngày, giờ, thứ trong tuần.

-- MySQL
SELECT YEAR(order_date), MONTH(order_date), DAY(order_date);
SELECT HOUR(created_at), MINUTE(created_at);
SELECT DAYOFWEEK(order_date);   -- 1=Chủ nhật ... 7=Thứ bảy
SELECT DAYNAME(order_date);     -- 'Friday'
SELECT EXTRACT(YEAR FROM order_date);  -- cú pháp chuẩn, chạy cả PostgreSQL

Trên PostgreSQL, cách phổ biến là EXTRACT(MONTH FROM order_date) hoặc date_part('month', order_date). Ghi nhớ EXTRACT(...) vì nó là cú pháp chuẩn SQL, dùng được ở cả hai hệ.

Định dạng (format) cho con người đọc

Database lưu ngày theo YYYY-MM-DD, nhưng người Việt quen đọc 27/06/2026. Đây là lúc hàm format vào cuộc:

-- MySQL: DATE_FORMAT
SELECT DATE_FORMAT(order_date, '%d/%m/%Y');           -- 27/06/2026
SELECT DATE_FORMAT(created_at, '%Y-%m');              -- 2026-06  (gom theo tháng)
SELECT DATE_FORMAT(created_at, '%H:%i %d/%m/%Y');     -- 14:32 27/06/2026

-- PostgreSQL: TO_CHAR SELECT TO_CHAR(order_date, 'DD/MM/YYYY'); SELECT TO_CHAR(created_at, 'YYYY-MM');

Một mẹo vàng cho BA: định dạng '%Y-%m' (lấy năm-tháng) cực kỳ hữu ích để gom nhóm theo tháng trong báo cáo, vì nó vừa sắp xếp đúng thứ tự, vừa dễ đọc.

Đo khoảng cách giữa hai mốc thời gian

Đây là phần "đắt giá" nhất với BA. Bạn thường cần biết: khách mất bao lâu để thanh toán? Đơn giao trong mấy giờ? Người dùng đã không đăng nhập bao nhiêu ngày?

-- MySQL
SELECT DATEDIFF(delivered_at, order_date);          -- chênh lệch theo SỐ NGÀY
SELECT TIMESTAMPDIFF(HOUR, order_date, paid_at);    -- theo GIỜ
SELECT TIMESTAMPDIFF(MINUTE, start_at, end_at);     -- theo PHÚT

-- PostgreSQL: phép trừ trả về kiểu interval SELECT delivered_at - order_date; -- '2 days 03:15:00' SELECT EXTRACT(EPOCH FROM (paid_at - order_date))/3600; -- ra số giờ

Lưu ý cú pháp khác nhau giữa hai hệ: DATEDIFF của MySQL nhận (ngày sau, ngày trước) và chỉ đếm theo ngày. Còn TIMESTAMPDIFF linh hoạt hơn vì cho chọn đơn vị (HOUR, MINUTE, DAY...). PostgreSQL thì đơn giản dùng phép trừ trực tiếp.

Cộng/trừ thời gian: dịch chuyển mốc

Để lọc "30 ngày gần nhất" hay "quý trước", bạn cần dịch chuyển một mốc thời gian:

-- MySQL
SELECT DATE_ADD(CURDATE(), INTERVAL 7 DAY);     -- 7 ngày sau
SELECT DATE_SUB(NOW(), INTERVAL 30 DAY);        -- 30 ngày trước
SELECT NOW() - INTERVAL 1 MONTH;                -- tháng trước

-- PostgreSQL SELECT CURRENT_DATE + INTERVAL '7 days'; SELECT NOW() - INTERVAL '30 days';

Tình huống thực tế

Ví dụ 1 — Tiki: báo cáo đơn hàng "7 ngày gần nhất" và bẫy lọc theo ngày

Giả sử bạn là BA tại một sàn TMĐT mô phỏng theo Tiki. Sếp muốn dashboard "đơn hàng trong 7 ngày qua". Bạn viết:

SELECT order_id, customer_id, total_amount, created_at
FROM orders
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
ORDER BY created_at DESC;

Nhìn thì ổn, nhưng có một cái bẫy. CURDATE() trả về 2026-06-27 tức là 2026-06-27 00:00:00. Nếu một đơn được tạo lúc 2026-06-20 22:00:00, nó vẫn nằm trong "7 ngày" mong đợi, nhưng cách viết >= today - 7 days thực ra bao gồm cả phần đầu ngày 20 và toàn bộ ngày hôm nay. Nếu sếp muốn đúng 7 ngày tròn KHÔNG tính hôm nay, bạn phải nói rõ:

WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
  AND created_at <  CURDATE();

Bài học rút ra: "7 ngày gần nhất" là một yêu cầu mơ hồ. Là BA, việc của bạn không chỉ là viết query mà còn phải làm rõ định nghĩa: có tính hôm nay không, biên đầu và biên cuối tính đến giờ nào. Một con số "tổng doanh thu 7 ngày" sai lệch chỉ vì khác cách đếm biên có thể khiến cả phòng tranh cãi.

Ví dụ 2 — Giao Hàng Nhanh: đo thời gian giao và đếm đơn trễ SLA

Một công ty logistics kiểu Giao Hàng Nhanh cam kết giao nội thành trong 48 giờ. Bạn cần biết tỷ lệ đơn vi phạm SLA trong tháng 6/2026. Bảng shipmentspicked_at (lúc lấy hàng) và delivered_at (lúc giao xong).

SELECT
    DATE_FORMAT(picked_at, '%Y-%m')                         AS thang,
    COUNT(*)                                                AS tong_don,
    SUM(CASE WHEN TIMESTAMPDIFF(HOUR, picked_at, delivered_at) > 48
             THEN 1 ELSE 0 END)                             AS don_tre,
    ROUND(
        SUM(CASE WHEN TIMESTAMPDIFF(HOUR, picked_at, delivered_at) > 48
                 THEN 1 ELSE 0 END)  100.0 / COUNT(), 1)  AS ty_le_tre_pct
FROM shipments
WHERE picked_at >= '2026-06-01'
  AND picked_at <  '2026-07-01'
  AND delivered_at IS NOT NULL
GROUP BY DATE_FORMAT(picked_at, '%Y-%m');

Giả sử kết quả ra: 12.480 đơn, trong đó 1.123 đơn trễ, tức 9,0%. Đây là một con số có thể đem ra họp ngay. Chú ý ba điểm kỹ thuật: dùng TIMESTAMPDIFF(HOUR, ...) để đo chính xác theo giờ chứ không phải ngày; lọc khoảng >= '2026-06-01' AND < '2026-07-01' thay vì MONTH() = 6 (ta sẽ giải thích vì sao ở phần lỗi); và delivered_at IS NOT NULL để loại đơn chưa giao xong, tránh tính nhầm.

Bài học rút ra: Khi đo khoảng cách thời gian liên quan đến cam kết dịch vụ (SLA), hãy luôn chọn đúng đơn vị (giờ chứ không phải ngày) và luôn xử lý các giá trị NULL — đơn chưa hoàn thành không nên bị tính là "giao trễ".

Ví dụ 3 — MoMo: phát hiện ví "ngủ đông" theo số ngày không giao dịch

Một ví điện tử kiểu MoMo muốn tìm những người dùng đã lâu không phát sinh giao dịch để gửi chương trình kích hoạt lại. Bảng transactionsuser_idtxn_time. Định nghĩa "ngủ đông" là không có giao dịch nào trong 60 ngày trở lên.

SELECT
    user_id,
    MAX(txn_time)                                  AS lan_giao_dich_cuoi,
    DATEDIFF(CURDATE(), MAX(txn_time))             AS so_ngay_im_lang
FROM transactions
GROUP BY user_id
HAVING DATEDIFF(CURDATE(), MAX(txn_time)) >= 60
ORDER BY so_ngay_im_lang DESC;

Ở đây MAX(txn_time) lấy lần giao dịch gần nhất của mỗi người, rồi DATEDIFF(CURDATE(), ...) đếm số ngày từ đó đến hôm nay. Lọc bằng HAVING vì điều kiện áp lên kết quả đã gom nhóm. Đội marketing có thể lấy danh sách này gửi voucher hoàn tiền để "đánh thức" họ.

Bài học rút ra: Đo khoảng cách thời gian không chỉ để biết "mất bao lâu" mà còn để phát hiện "đã im lặng bao lâu" — một nền tảng trực tiếp cho các phân tích churn và retention bạn sẽ học ở các bài sau.

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

Khi gặp bất kỳ yêu cầu liên quan thời gian, hãy đi theo quy trình 5 bước này:

  • Xác định kiểu dữ liệu của cột thời gian. Chạy thử SELECT created_at FROM orders LIMIT 5; để xem nó là DATE hay DATETIME. Một cột DATETIME mà bạn xử lý như DATE sẽ làm lệch các đơn cuối ngày.
  • Làm rõ định nghĩa khoảng thời gian với người yêu cầu. "Tháng này" tính từ ngày 1 đến hôm nay, hay cả tháng? "Gần nhất" có tính hôm nay không? Viết câu hỏi ra giấy trước khi viết SQL.
  • Chọn cách lọc theo khoảng >=<. Ưu tiên WHERE col >= '2026-06-01' AND col < '2026-07-01' thay vì dùng hàm bọc quanh cột. Cách này vừa chính xác ở biên, vừa cho phép database dùng index (bạn sẽ hiểu sâu ở bài về Indexes).
  • Chọn đúng hàm đo khoảng cách và đơn vị. Cần ngày thì DATEDIFF; cần giờ/phút thì TIMESTAMPDIFF. Tự hỏi: kết quả nên là số nguyên ngày, hay số giờ lẻ?
  • Format ở bước cuối, chỉ để hiển thị. Giữ dữ liệu ở dạng gốc khi tính toán và lọc; chỉ dùng DATE_FORMAT/TO_CHAR ở cột hiển thị cuối cùng. Đừng bao giờ lọc trên chuỗi đã format.

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

Lỗi 1 — Bọc hàm quanh cột khi lọc. Rất nhiều BA mới viết WHERE YEAR(order_date) = 2026 AND MONTH(order_date) = 6. Nó cho kết quả đúng nhưng có hai vấn đề: database không dùng được index trên order_date (query chậm khi bảng lớn), và rất dễ quên xử lý năm. Mẹo: luôn đổi sang lọc khoảng order_date >= '2026-06-01' AND order_date < '2026-07-01'.

Lỗi 2 — So sánh ngày như so sánh chuỗi. Nếu cột được lưu nhầm dưới dạng text kiểu '27/06/2026', thì WHERE ngay >= '2026-06-01' sẽ cho kết quả vô nghĩa vì nó so sánh ký tự, không phải ngày. Mẹo: kiểm tra kiểu cột; nếu là text, ép kiểu bằng STR_TO_DATE (MySQL) hoặc ::date (PostgreSQL) trước.

Lỗi 3 — Quên biên cuối khi dùng BETWEEN với DATETIME. Viết WHERE created_at BETWEEN '2026-06-01' AND '2026-06-30' sẽ bỏ sót mọi đơn từ 2026-06-30 00:00:01 trở đi, vì '2026-06-30' được hiểu là 2026-06-30 00:00:00. Bạn vô tình mất gần trọn một ngày dữ liệu. Mẹo: với cột có giờ, đừng dùng BETWEEN kiểu này; hãy dùng >= '2026-06-01' AND < '2026-07-01'.

Lỗi 4 — Lệch múi giờ. Server chạy UTC, đơn đặt lúc 06:30 sáng giờ Việt Nam (UTC+7) bị lưu thành 23:30 ngày hôm trước theo UTC, khiến nó rơi nhầm sang ngày trước trong báo cáo. Mẹo: hỏi đội kỹ thuật server lưu giờ gì; khi cần, quy đổi bằng CONVERT_TZ(created_at, '+00:00', '+07:00') (MySQL) hoặc created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Ho_Chi_Minh' (PostgreSQL).

Lỗi 5 — DATEDIFF chỉ đếm ngày, bỏ phần giờ. DATEDIFF('2026-06-02 01:00', '2026-06-01 23:00') trả về 1 dù thực tế chỉ cách nhau 2 tiếng. Mẹo: nếu cần độ chính xác theo giờ, luôn dùng TIMESTAMPDIFF(HOUR, ...).

Mẹo chung: Khi nghi ngờ một query thời gian, hãy SELECT ra vài dòng kèm cả cột gốc và cột đã tính, mắt thường kiểm vài trường hợp ở biên (đầu tháng, cuối tháng, nửa đêm) trước khi tin vào con số tổng hợp.

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

Dùng một bảng orders(order_id, customer_id, total_amount, created_at, paid_at, delivered_at) mô phỏng sàn TMĐT Việt Nam:

  • Liệt kê tất cả đơn được tạo trong tháng 5/2026, hiển thị created_at theo định dạng dd/mm/yyyy hh:mm. (Gợi ý: lọc khoảng >=/<, format bằng DATE_FORMAT.)
  • Tính số giờ trung bình từ lúc đặt (created_at) đến lúc thanh toán (paid_at), chỉ tính các đơn đã thanh toán. Làm tròn 1 chữ số thập phân.
  • Đếm số đơn theo từng tháng trong năm 2026, sắp xếp theo tháng tăng dần. Cột tháng hiển thị dạng 2026-01, 2026-02... (Gợi ý: GROUP BY DATE_FORMAT(created_at, '%Y-%m').)
  • Tìm các khách hàng có đơn giao trễ hơn 72 giờ kể từ lúc thanh toán, hiển thị order_id và số giờ giao thực tế.
  • (Nâng cao) Với mỗi khách, tìm số ngày kể từ đơn hàng gần nhất đến hôm nay; lọc ra những khách đã hơn 90 ngày không mua. Đây chính là tiền đề của phân tích churn.
Hãy viết query cho cả MySQL lẫn PostgreSQL nếu có thể, để quen với khác biệt cú pháp giữa hai hệ.

Tóm tắt

Date/time là nhóm hàm bạn sẽ chạm vào mỗi ngày trong vai trò BA, vì gần như mọi câu hỏi nghiệp vụ giá trị đều gắn với thời gian. Ba kỹ năng cốt lõi cần nắm vững: lọc theo khoảng thời gian (ưu tiên >=< thay vì bọc hàm quanh cột), định dạng cho người đọc (chỉ ở bước hiển thị cuối, với DATE_FORMAT/TO_CHAR), và đo khoảng cách giữa hai mốc (chọn đúng DATEDIFF cho ngày hay TIMESTAMPDIFF cho giờ/phút).

Quan trọng hơn cú pháp là tư duy: luôn xác định kiểu dữ liệu cột, làm rõ định nghĩa khoảng thời gian với người yêu cầu, cẩn thận với biên ngày và múi giờ. Một con số sai lệch chỉ vì đếm nhầm một ngày ở biên có thể dẫn cả đội đến quyết định sai. Khi bạn đã thành thạo phần này, bạn đã có nền tảng vững để bước vào các phân tích thời gian nâng cao như time-series, cohort retention và churn ở những bài tiếp theo.