Menu
ESC

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

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

Đang tải...

String functions: CONCAT, SUBSTRING, REPLACE, LENGTH

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

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

Dữ liệu thực tế hiếm khi sạch sẽ và đẹp đẽ như trong sách giáo khoa. Khi bạn là một BA và kéo dữ liệu từ hệ thống thật — CRM, ERP, file Excel mà phòng kinh doanh nhập tay, log từ app — bạn sẽ gặp đủ thứ "rác": tên khách hàng viết hoa lung tung, số điện thoại lúc có dấu cách lúc không, mã đơn hàng gắn tiền tố thừa, họ và tên nằm tách hai cột nhưng sếp lại muốn một cột "Họ tên đầy đủ". Đây chính là lúc các hàm xử lý chuỗi (string functions) trở thành công cụ sống còn.

Nói thẳng: phần lớn thời gian một BA làm report không phải là viết những câu query thông minh, mà là dọn và định dạng dữ liệu văn bản để nó trình bày được, ghép được, và lọc được. Một câu CONCAT đúng chỗ giúp bạn tạo nhãn hiển thị đẹp cho dashboard. Một câu SUBSTRING giúp bạn tách mã vùng từ số điện thoại. Một câu REPLACE giúp bạn chuẩn hoá định dạng tiền tệ trước khi gửi cho phòng tài chính. Và LENGTH giúp bạn phát hiện những bản ghi bị lỗi ngay từ đầu.

Trong bài này, chúng ta tập trung vào bốn hàm nền tảng nhất — CONCAT, SUBSTRING, REPLACE, LENGTH — cùng vài người bạn đồng hành thường đi kèm (CONCAT_WS, TRIM, UPPER/LOWER, POSITION). Đây không phải nhóm hàm "biết cũng được", mà là nhóm bạn sẽ dùng gần như mỗi ngày.

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

CONCAT và CONCAT_WS — ghép chuỗi

CONCAT() ghép nhiều giá trị thành một chuỗi. Đây là cách bạn gộp các mảnh dữ liệu rời rạc thành một chuỗi có ý nghĩa.

-- Ghép họ và tên thành tên đầy đủ
SELECT CONCAT(first_name, ' ', last_name) AS ho_ten_day_du
FROM users;

Lưu ý dấu cách ' ' ở giữa — nếu thiếu, bạn sẽ nhận được NguyễnVăn An dính liền. Khi cần ghép nhiều mảnh với cùng một dấu phân cách, dùng CONCAT_WS (WS = "With Separator") để khỏi lặp lại dấu phân cách:

-- Ghép địa chỉ đầy đủ, ngăn cách bằng dấu phẩy
SELECT CONCAT_WS(', ', street, ward, district, city) AS dia_chi
FROM customers;
-- Kết quả: "12 Lê Lợi, Phường Bến Nghé, Quận 1, TP.HCM"

Một điểm khác biệt rất quan trọng giữa hai hệ quản trị:

  • MySQL: CONCAT(a, NULL, b) trả về NULL — chỉ cần một mảnh là NULL thì cả chuỗi thành NULL. Nhưng CONCAT_WS thì bỏ qua giá trị NULL, đây là lý do nó rất tiện cho dữ liệu thực tế hay thiếu trường.
  • PostgreSQL: CONCAT() âm thầm bỏ qua NULL (coi như chuỗi rỗng). PostgreSQL cũng hỗ trợ toán tử || để nối: first_name || ' ' || last_name. Nhưng cẩn thận — với ||, nếu có NULL thì toàn bộ thành NULL.

SUBSTRING — cắt một phần chuỗi

SUBSTRING(chuoi, vi_tri_bat_dau, do_dai) lấy ra một đoạn con. Vị trí bắt đầu tính từ 1, không phải từ 0 như nhiều ngôn ngữ lập trình.

-- Lấy 4 ký tự đầu của mã đơn hàng
SELECT SUBSTRING(order_code, 1, 4) AS tien_to
FROM orders;
-- 'TIKI-2024-00873' -> 'TIKI'

Trong PostgreSQL có thể viết SUBSTRING(order_code FROM 1 FOR 4) — cùng kết quả. Nếu bỏ tham số độ dài, hàm sẽ lấy từ vị trí bắt đầu đến hết chuỗi:

SELECT SUBSTRING(order_code, 6) FROM orders;
-- 'TIKI-2024-00873' -> '2024-00873'

Hai hàm họ hàng rất hữu ích: LEFT(chuoi, n) lấy n ký tự đầu, RIGHT(chuoi, n) lấy n ký tự cuối. Chúng đọc rõ nghĩa hơn SUBSTRING trong nhiều trường hợp.

REPLACE — thay thế chuỗi con

REPLACE(chuoi, can_tim, thay_bang) thay mọi lần xuất hiện của một chuỗi con. Đây là công cụ chuẩn hoá dữ liệu hàng đầu.

-- Bỏ dấu cách trong số điện thoại
SELECT REPLACE(phone, ' ', '') AS phone_sach
FROM contacts;
-- '090 123 4567' -> '0901234567'

-- Bỏ dấu chấm phân cách hàng nghìn trong chuỗi tiền SELECT REPLACE('1.250.000', '.', '') AS so_sach; -- -> '1250000'

REPLACE phân biệt chữ hoa/chữ thường và thay tất cả các vị trí khớp. Muốn xoá một ký tự, bạn thay nó bằng chuỗi rỗng ''.

LENGTH — đếm độ dài

LENGTH() trả về độ dài chuỗi. Nhưng có một cái bẫy với tiếng Việt mà bạn bắt buộc phải biết:

  • MySQL: LENGTH() đếm theo byte, không phải ký tự. Với chữ có dấu tiếng Việt (lưu UTF-8), một ký tự có thể chiếm 2–3 byte. Để đếm số ký tự thật, dùng CHAR_LENGTH().
  • PostgreSQL: LENGTH() đếm theo ký tự, đúng như mong đợi.
-- MySQL
SELECT LENGTH('Hà Nội');      -- 8 (byte, vì à và ộ chiếm nhiều byte)
SELECT CHAR_LENGTH('Hà Nội'); -- 6 (ký tự, đúng cái ta muốn)

Quy tắc ghi nhớ cho BA dùng MySQL: khi đo độ dài văn bản tiếng Việt, luôn dùng CHAR_LENGTH, đừng dùng LENGTH, nếu không các phép kiểm tra "số điện thoại phải đủ 10 số" sẽ sai bét.

Bộ ba làm sạch thường đi kèm

TRIM() cắt khoảng trắng thừa đầu/cuối; UPPER()/LOWER() chuẩn hoá chữ hoa/thường. Ba hàm này gần như luôn xuất hiện cùng nhóm bốn hàm chính khi bạn dọn dữ liệu thật.

SELECT LOWER(TRIM(email)) AS email_chuan FROM users;
-- '  An.Nguyen@Gmail.COM ' -> 'an.nguyen@gmail.com'

Tình huống thực tế

Tình huống 1 — Sàn TMĐT Tiki: chuẩn hoá mã đơn để đối soát

Bạn là BA tại một sàn thương mại điện tử kiểu Tiki. Đội vận hành kho và đội tài chính dùng hai hệ thống khác nhau, và mã đơn hàng được ghi không nhất quán: hệ thống kho ghi tiki-2024-00873, hệ thống tài chính ghi TIKI_2024_00873. Mỗi cuối tháng, hai bên phải đối soát 180.000 đơn nhưng cứ lệch nhau vì mã không khớp ký tự.

Bạn tạo một cột mã chuẩn để cả hai hệ thống join được với nhau:

SELECT
  order_id,
  UPPER(REPLACE(REPLACE(raw_order_code, '_', '-'), ' ', '')) AS ma_chuan
FROM finance_orders;
-- 'TIKI_2024_00873' -> 'TIKI-2024-00873'
-- 'tiki-2024-00873' -> 'TIKI-2024-00873'

Bạn lồng hai REPLACE (đổi gạch dưới thành gạch ngang, rồi xoá dấu cách), bọc bằng UPPER để hai chuỗi cuối cùng giống hệt nhau. Sau khi áp dụng, tỷ lệ khớp đối soát nhảy từ 76% lên 99,3%, và đội tài chính tiết kiệm khoảng 2 ngày công xử lý thủ công mỗi tháng.

Bài học: Việc chuẩn hoá khoá join bằng string functions thường có giá trị lớn hơn nhiều so với độ phức tạp của câu query. Một BA giỏi nhìn ra rằng "dữ liệu không khớp" thường chỉ là vấn đề định dạng, không phải vấn đề logic.

Tình huống 2 — App gọi xe kiểu Be/Grab: tách mã vùng từ số điện thoại

Bạn làm BA cho một app gọi xe. Đội marketing muốn phân tích tỷ lệ hoàn thành chuyến theo nhà mạng để quyết định chương trình tặng data. Số điện thoại trong DB lưu lẫn lộn nhiều định dạng: 0901234567, +84 90 123 4567, 84-903-111-222.

Bước một là làm sạch về một định dạng thuần số, bước hai là tách 3 chữ số đầu (đầu số) để map sang nhà mạng:

SELECT
  user_id,
  phone_clean,
  SUBSTRING(phone_clean, 1, 3) AS dau_so,
  CASE
    WHEN SUBSTRING(phone_clean, 1, 3) IN ('090','093','070','079','077','076','078')
      THEN 'Mobifone'
    WHEN SUBSTRING(phone_clean, 1, 3) IN ('091','094','088','083','084','085')
      THEN 'Vinaphone'
    ELSE 'Khác'
  END AS nha_mang
FROM (
  SELECT
    user_id,
    -- Chuẩn hoá: bỏ khoảng trắng, dấu gạch, dấu cộng và đổi 84 đầu thành 0
    CONCAT('0', SUBSTRING(
      REPLACE(REPLACE(REPLACE(phone,' ',''),'-',''),'+',''),
      3)) AS phone_clean
  FROM users
  WHERE phone LIKE '+84%' OR phone LIKE '84%'
) t;

Phần con bên trong xoá mọi ký tự phân cách rồi cắt từ vị trí 3 (bỏ "84") và gắn lại "0" ở đầu, đưa mọi số về dạng 090... thống nhất. Sau đó SUBSTRING(..., 1, 3) lấy đầu số để phân loại nhà mạng. Kết quả giúp marketing phát hiện nhóm dùng một nhà mạng cụ thể có tỷ lệ huỷ chuyến cao bất thường ở giờ cao điểm — hoá ra do vùng phủ sóng yếu ở vài quận.

Bài học: Khi cần phân loại theo một phần của chuỗi, hãy chia làm hai tầng rõ ràng — làm sạch trước, cắt và phân loại sau. Lồng quá nhiều hàm vào một dòng khiến query khó đọc và khó sửa.

Tình huống 3 — Fintech kiểu MoMo: dựng nhãn hiển thị cho dashboard giao dịch

Bạn là BA tại một ví điện tử. Đội sản phẩm cần một bảng giám sát giao dịch nghi ngờ gian lận, và họ muốn mỗi dòng có một nhãn dễ đọc thay vì hàng loạt cột rời rạc. Họ cũng yêu cầu che bớt số điện thoại vì lý do bảo mật PII.

SELECT
  CONCAT_WS(' | ',
    CONCAT('GD#', transaction_id),
    CONCAT(FORMAT(amount, 0), 'đ'),
    CONCAT(
      LEFT(phone, 3),
      '**',
      RIGHT(phone, 3)
    ),
    UPPER(status)
  ) AS nhan_hien_thi
FROM transactions
WHERE flagged = 1;
-- 'GD#88213 | 1.500.000đ | 090**567 | PENDING'

Ở đây CONCAT_WS(' | ', ...) ghép bốn mảnh thông tin bằng dấu phân cách dễ nhìn. Phần che số điện thoại dùng LEFT lấy 3 số đầu, chuỗi '**' ở giữa, và RIGHT lấy 3 số cuối — vừa đủ để nhân viên nhận diện vừa không lộ trọn số. CONCAT_WS đặc biệt hợp ở đây vì nếu trường status thiếu, nó tự bỏ qua mà không để lại dấu phân cách thừa.

Bài học: String functions không chỉ để dọn dữ liệu — chúng còn là công cụ trình bày. Một nhãn hiển thị tốt giúp người đọc dashboard hiểu ngay không cần giải thích, và việc che PII ngay trong tầng query là cách bảo vệ dữ liệu nhạy cảm hiệu quả.

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

Khi gặp một bài toán xử lý chuỗi, hãy đi theo trình tự sau để khỏi rối:

  • Nhìn dữ liệu thô trước. Chạy SELECT cot, LENGTH(cot), CHAR_LENGTH(cot) FROM bang LIMIT 50; để thấy độ dài thật và phát hiện khoảng trắng ẩn, ký tự lạ. Đừng đoán định dạng — hãy nhìn.
  • Làm sạch trước, biến đổi sau. Áp TRIM, LOWER/UPPER, và REPLACE để đưa dữ liệu về một dạng chuẩn nhất quán trước khi cắt hay ghép. Sạch trước thì các bước sau mới đáng tin.
  • Cắt hoặc ghép theo nhu cầu. Dùng SUBSTRING/LEFT/RIGHT để tách phần cần, hoặc CONCAT/CONCAT_WS để gộp. Nếu vị trí cắt không cố định, dùng POSITION/LOCATE để tìm vị trí dấu phân cách trước rồi mới cắt.
  • Kiểm tra biên (edge cases). Hỏi ngay: chuỗi NULL thì sao? Chuỗi rỗng thì sao? Chuỗi ngắn hơn vị trí cắt thì sao? Luôn thử với vài bản ghi xấu chứ không chỉ bản ghi đẹp.
  • Đặt alias rõ ràng và lưu lại logic. Mỗi cột kết quả nên có tên tiếng Việt/tiếng Anh dễ hiểu. Nếu logic phức tạp, cân nhắc đưa vào CTE để người sau đọc được.
Ví dụ tìm vị trí ký tự để cắt động:

-- Lấy phần tên miền của email (sau dấu @)
SELECT
  email,
  SUBSTRING(email, POSITION('@' IN email) + 1) AS ten_mien
FROM users;
-- 'an.nguyen@gmail.com' -> 'gmail.com'

POSITION('@' IN email) trả về vị trí dấu @, cộng 1 để bắt đầu ngay sau nó. Trong MySQL hàm tương đương là LOCATE('@', email).

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

Quên cái bẫy LENGTH vs CHAR_LENGTH trong MySQL. Đây là lỗi phổ biến nhất với dữ liệu tiếng Việt. WHERE LENGTH(name) > 50 sẽ loại oan những tên có dấu vì byte nhiều hơn ký tự. Luôn dùng CHAR_LENGTH khi ý bạn là "số ký tự".

NULL nuốt cả chuỗi trong MySQL CONCAT. CONCAT(first_name, ' ', last_name) trả về NULL nếu last_name là NULL. Khắc phục bằng CONCAT_WS (tự bỏ NULL) hoặc bọc từng trường: CONCAT(COALESCE(first_name,''), ' ', COALESCE(last_name,'')).

Nhầm vị trí bắt đầu từ 0. SUBSTRING trong SQL đếm từ 1. Viết SUBSTRING(code, 0, 4) sẽ cho kết quả không như mong đợi (thường thiếu một ký tự). Luôn nhớ: ký tự đầu tiên ở vị trí 1.

REPLACE thay tất cả, kể cả chỗ không muốn. REPLACE(text, '0', '') sẽ xoá mọi số 0, không chỉ số 0 đầu chuỗi. Nếu chỉ muốn xử lý đầu/cuối, cân nhắc TRIM, LTRIM, RTRIM thay vì REPLACE.

Mẹo — luôn TRIM trước khi so sánh hay join. Khoảng trắng ẩn ở cuối chuỗi là thủ phạm số một khiến hai giá trị "trông giống nhau" lại không khớp. TRIM cả hai vế trước khi join giúp tránh hàng giờ debug vô nghĩa.

Mẹo — đừng nhồi quá nhiều hàm vào một dòng. Query lồng năm tầng REPLACE thì chạy đúng nhưng không ai sửa nổi. Tách logic phức tạp vào CTE với tên bước rõ ràng, người sau (và chính bạn sau ba tháng) sẽ cảm ơn.

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

Giả sử bạn có bảng khach_hang(id, ho, ten, email, sdt, dia_chi_raw) với dữ liệu nhập tay lộn xộn. Hãy thử:

  • Tạo cột ho_ten ghép họ và tên đầy đủ, xử lý đúng trường hợp một trong hai trường bị NULL (gợi ý: CONCAT_WS hoặc COALESCE).
  • Chuẩn hoá cột email: bỏ khoảng trắng thừa và đưa về chữ thường toàn bộ.
  • Tách tên miền email (phần sau dấu @) và đếm số khách hàng theo từng nhà cung cấp email (gmail, yahoo, outlook...). Gợi ý: dùng SUBSTRING kết hợp POSITION.
  • Làm sạch cột sdt về dạng thuần số (bỏ dấu cách, gạch ngang, dấu cộng), rồi dùng CHAR_LENGTH để tìm những số điện thoại không đủ 10 chữ số — đây là các bản ghi nghi ngờ lỗi.
  • Tạo cột sdt_che che 4 chữ số giữa của số điện thoại (dạng 090**567) bằng LEFT, RIGHTCONCAT.
  • (Nâng cao) Với mỗi khách, tạo một nhãn hiển thị bằng CONCAT_WS gồm: id có tiền tố KH#, họ tên, và 3 ký tự cuối của email — để dùng cho một danh sách thả xuống (dropdown) trong công cụ nội bộ.
Hãy chạy thử trên cả MySQL và PostgreSQL nếu có thể, và để ý sự khác biệt ở CONCAT với NULL cũng như LENGTH với chữ có dấu — đây là hai điểm hay gây bất ngờ nhất.

Tóm tắt

Bốn hàm CONCAT, SUBSTRING, REPLACE, LENGTH là bộ công cụ xử lý văn bản cốt lõi mà một BA dùng gần như mỗi ngày: ghép dữ liệu rời thành chuỗi có nghĩa, cắt ra phần cần thiết, chuẩn hoá định dạng, và đo độ dài để kiểm tra chất lượng. Đi kèm là CONCAT_WS, TRIM, UPPER/LOWER, POSITION để hoàn thiện quy trình làm sạch.

Hãy khắc cốt ba điều dễ vấp nhất: trong MySQL, CONCAT gặp NULL thì trả NULL (dùng CONCAT_WS để né), và LENGTH đếm byte chứ không đếm ký tự với chữ tiếng Việt (dùng CHAR_LENGTH); còn SUBSTRING luôn đếm vị trí từ 1. Nguyên tắc làm việc thì gọn trong một câu: nhìn dữ liệu thật, làm sạch trước, biến đổi sau, và luôn kiểm tra với bản ghi xấu. Nắm vững bài này, bạn sẽ biến những cột dữ liệu lộn xộn nhất thành report sạch sẽ, đáng tin, sẵn sàng trình bày.