Databricks SQL · CRM Master Class · ภาษาไทย

CRM SQL Master Blueprint

10 Core Challenges (Databricks Edition) ฉบับภาษาไทย

รวม 10 โจทย์จริงที่ทีม CRM เจอบ่อยที่สุดบน Databricks ตั้งแต่การล้างข้อมูลซ้ำ ไปจนถึงการวาง สถาปัตยกรรมข้ามตาราง ทุกตอนมีโค้ดที่ใช้ได้จริงพร้อมภาพประกอบและเคล็ดลับจากพี่ที่เพิ่งผ่านด่านนี้มาหมาดๆ

โจทย์รวม
10โจทย์รวม
Databricks SQL
SQLDatabricks SQL
ใช้กับงานจริง
100%ใช้กับงานจริง

สารบัญ 10 โจทย์

  1. 01Deduplicationกำจัดข้อมูลซ้ำ
  2. 02Tie-Breakersตัวช่วยตอนผลเสมอ
  3. 03Dynamic Mapping Tablesแมปโค้ดเป็นป้ายชื่อด้วย VALUES
  4. 04Recency Gapหาช่องว่างระหว่างแถว
  5. 05Suppression Listรายการห้ามติดต่อ
  6. 06Running Balanceยอดคงเหลือสะสม
  7. 07Regex Exclusionคัดรายการที่ไม่เอาโดยใช้ RLIKE
  8. 08Logical Orderลำดับการทำงานของ SQL
  9. 09Grain Bloat Fixแก้จำนวนแถวเกินจริงจากการ join
  10. 10Production 4-Pinto Architectureสถาปัตยกรรมปิ่นโต 4 ชั้น
โจทย์ 01

Deduplication

กำจัดข้อมูลซ้ำ · ลูกค้า 1 คนต้องเหลือแค่ 1 แถวล่าสุด

ถ้าเราดึงข้อมูลลูกค้ามา append กันทุกวัน ตารางจะซ้ำกันพร่ำเหรื่อ เพราะข้อมูลเก่าไม่ถูกลบทิ้ง ลูกค้าคนเดียวกันเลยมีหลายแถว โดยที่เราอยากได้แค่ "เวอร์ชันล่าสุด" ของแต่ละคน

หัวใจของโจทย์นี้คือ window function ROW_NUMBER() จับคู่กับ QUALIFY จุดเด่นของ Databricks SQL คือเขียนสั้นและอ่านง่ายกว่า subquery แบบเก่า

ก่อน/หลัง: แถวซ้ำยุบเหลือ 1 แถว

ก่อน (มีแถวซ้ำ)

customer_idemailupdated_at
1021a@mail.com2026-03-01
1021a@mail.com2026-03-08
1021a@mail.com2026-03-15
1022b@mail.com2026-03-02

หลัง (1 คน 1 แถว)

customer_idemailupdated_at
1021a@mail.com2026-03-15
1022b@mail.com2026-03-02
databricks_sql.sql
SELECT *
FROM customers
QUALIFY
  ROW_NUMBER() OVER (
    PARTITION BY customer_id          -- แบ่งกลุ่มตามลูกค้า
    ORDER BY updated_at DESC          -- เอาข้อมูลใหม่สุดมาก่อน
  ) = 1;                              -- เก็บแค่แถวแรกของแต่ละคน
PARTITION BY customer_id· แบ่งกลุ่ม ให้เลข 1,2,3... ใหม่ทุกคนORDER BY updated_at DESC· แถวที่อัปเดตล่าสุดได้ rank = 1QUALIFY ... = 1· กรองเก็บแค่แถวแรกของแต่ละกลุ่ม
โจทย์ 02

Tie-Breakers

ตัวช่วยตอนผลเสมอ · ผลลัพธ์ต้องเหมือนเดิมทุกครั้งที่รัน

เรื่องที่คนลืมมากที่สุดคือ "รัน query นี้แล้วได้ตัวเลขตรงกันไหมทุกวัน" ถ้า ORDER BY มีคอลัมน์เดียว แล้วมีค่าเท่ากัน (tie) แถวที่เสมอกันจะเรียงแบบไหนไม่การันตี พี่เคยเจอว่าข้อมูลเปลี่ยนทุกครั้งที่รัน แล้วสายงานหาว่าตัวเลขไม่ตรงกัน

วิธีแก้คือทำให้ deterministic คือเรียงให้ "จบจริง" ตลอดทั้งเชน ถ้าค่าแรกเท่ากันก็ทะลุไปคอลัมน์ถัดไป จนกว่าจะมีตัวใดตัวหนึ่งตัดสินได้

เชนตัดสินเมื่อค่าเท่ากัน
spendtxn_dateidลำดับสุดท้าย
9002026-03-20771
9002026-03-20322
9002026-03-18553
9002026-03-18124
spend เท่ากัน?date เท่ากัน?id เล็กกว่ามาก่อน ✓
databricks_sql.sql
SELECT *
FROM customer_orders
ORDER BY
  spend DESC,      -- 1. ใช้จ่ายมากสุดมาก่อน
  txn_date DESC,   -- 2. ชนกัน? วันที่ใหม่กว่ามาก่อน
  id ASC           -- 3. ชนะกันอีก? id เล็กกว่ามาก่อน
spend DESC· คอลัมน์หลัก เรียงมากไปน้อยtxn_date DESC· ตัวแก้อันดับ 1 เมื่อ spend เท่ากันid ASC· ตัวจบเกม ไม่ให้เหลือจุดเสมอ
โจทย์ 03

Dynamic Mapping Tables

แมปโค้ดเป็นป้ายชื่อด้วย VALUES · แปลงรหัสลับให้สายงานอ่านรู้เรื่อง

ระบบ CRM มักเก็บค่าเป็นรหัส เช่น G, S, B เราอยากแปลให้คนอ่านได้เป็น Gold, Silver, Bronze สไตล์ที่ชอบใน Databricks คือสร้าง mapping table ชั่วคราวด้วย VALUES clause ไม่ต้องพึ่งตารางจริงให้รกพื้นที่ ใช้จบในคิวรีเดียว

หลักคือ LEFT JOIN mapping เข้าไปแล้วดึง label ออกมา อยากเพิ่มโค้ดใหม่ก็แค่เพิ่มหนึ่งแถวใน VALUES

แมป code → label แบบอินไลน์
codelabelตัวอย่างลูกค้า
GGoldลูกค้า VIP ใช้จ่ายสูง
SSilverลูกค้าทั่วไป
BBronzeลูกค้าใหม่/เพิ่งเริ่ม
databricks_sql.sql
WITH tier_map AS (
  SELECT *
  FROM VALUES
    ('G', 'Gold'),
    ('S', 'Silver'),
    ('B', 'Bronze')
    AS t(code, label)
)
SELECT
  c.customer_id,
  c.code,
  COALESCE(m.label, 'ไม่ทราบรหัส') AS label
FROM customers c
LEFT JOIN tier_map m
  ON c.code = m.code;
WITH tier_map AS (...)· สร้างตารางชั่วคราวในคิวรีเดียวVALUES ('G','Gold'), ...· ประกาศแถวแมปโดยไม่ต้องมีตารางจริงCOALESCE(..., 'ไม่ทราบรหัส')· กัน code ใหม่ที่ยังไม่ได้แมป
โจทย์ 04

Recency Gap

หาช่องว่างระหว่างแถว · นับวันห่างระหว่างการซื้อหรือการชำระ

โจทย์แบบ Recency คือ "แถวนี้ออกจากแถวก่อนหน้าไปกี่วัน" เช่น ลูกค้าไม่จ่ายหนี้มากี่วัน หรือห่าง ระหว่างช็อปแต่ละครั้งแค่ไหน เราใช้ LAG ซึ่งเอาค่าของแถว "ข้างบน" ขึ้นมาอ้างอิง แล้วลบออกจากแถวนี้

ใช้ DATEDIFF ลบวันที่ ได้ "ช่องว่าง" ระหว่างกัน อยากรู้ว่าห่างไป 2 แถวก็ใช้ LAG(txn_date, 2)

ไทม์ไลน์ช่องว่างระหว่างธุรกรรม
1 ม.ค.+0d5 ม.ค.+4d12 ม.ค.+7d14 ม.ค.+2d28 ม.ค.+14d
databricks_sql.sql
SELECT
  customer_id,
  txn_date,
  LAG(txn_date) OVER (
    PARTITION BY customer_id
    ORDER BY txn_date
  ) AS prev_txn_date,
  DATEDIFF(
    txn_date,
    LAG(txn_date) OVER (
      PARTITION BY customer_id
      ORDER BY txn_date
    )
  ) AS gap_days          -- จำนวนวันห่างจากแถวก่อนหน้า
FROM customer_transactions;
LAG(txn_date) OVER (...)· เอาแถวก่อนหน้ามาเปรียบเทียบDATEDIFF(txn_date, LAG(...))· ลบวัน ได้ 'ช่องว่าง'PARTITION BY customer_id· นับแยกต่อลูกค้า ไม่ปนกัน
โจทย์ 05

Suppression List

รายการห้ามติดต่อ · กรองคนในบัญชีดำออกจากแคมเปญ

ของจริงในงาน CRM คือมี "รายชื่อห้ามปล่อยแคมเปญ" เช่น เลขที่ขอปิดเบอร์ หรือโดนร้องเรียนก่อนหน้า เราไม่ควร JOIN เข้าไปเฉยๆ เพราะเจอบัญชีดำต้องตัดออก เทคนิคที่ใช้คือ anti-join เอาทุกคนมา LEFT JOIN กับรายการห้าม แล้วเก็บเฉพาะแถวที่ไม่เจอ match ฝั่งขวา

หัวใจคือ WHERE ... IS NULL แปลว่า "ไม่มีแถวห้ามตรงกัน" นั่นคือคนที่ปล่อยติดต่อได้ปลอดภัย

บัญชีดำถูกกรองออก

ลูกค้าทั้งหมด

customer_idname
101แอน
102บอล
103จีน
104ดิว

บัญชีดำ

customer_id
102
104

เหลือเฉพาะคนที่ปลอดภัย

customer_idname
101แอน
103จีน
databricks_sql.sql
SELECT c.*
FROM customers c
LEFT JOIN blacklist b
  ON c.customer_id = b.customer_id
WHERE
  b.customer_id IS NULL;   -- ไม่อยู่ในบัญชีดำ
LEFT JOIN blacklist b· เอาทุกคนมา ฝั่ง b ต้อง matchWHERE b.customer_id IS NULL· กรองเฉพาะที่ไม่มีในบัญชีดำ
โจทย์ 06

Running Balance

ยอดคงเหลือสะสม · บวกยอดไปเรื่อยๆ ทีละแถว

อยากได้ยอดสะสม เช่น ยอดขายสะสมรายเดือน หรือหนี้คงเหลือที่ทบไปเรื่อยๆ เราใช้ SUM(...) OVER (ORDER BY ...) ถ้าอยากสะสมแยกต่อบัญชีก็ใส่ PARTITION BY account_id

frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW แปลว่า "นับจากแถวแรกสุดถึงแถวนี้" จึงเป็นยอดที่เพิ่มทีละแถว หรือที่นักบัญชีเรียกว่า running total

ยอดสะสมแบบแท่งต่อเนื่อง
100
1
250
2
410
3
560
4
620
5
databricks_sql.sql
SELECT
  account_id,
  txn_date,
  amount,
  SUM(amount) OVER (
    PARTITION BY account_id
    ORDER BY txn_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_balance
FROM account_transactions;
SUM(amount) OVER (...)· บวกค่าสะสมตาม windowROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW· นับจากแถวแรกถึงแถวนี้PARTITION BY account_id· สะสมแยกต่อบัญชี
โจทย์ 07

Regex Exclusion

คัดรายการที่ไม่เอาโดยใช้ RLIKE · ตัดคำที่ตรง pattern ทิ้งรายงาน

บางรายการเราไม่อยากให้โผล่ในรายงาน เช่น ค่าธรรมเนียม fee, ดอกเบี้ย interest, หรือเงินสด cash RLIKE คือ regex ของ Databricks ที่ดักคำได้ละเอียดกว่า LIKE

(?i) ข้างหน้าคือ flag case-insensitive ให้เจอทั้ง Fee, INTEREST, CASH จากนั้น NOT RLIKE คือ "ไม่ให้มีคำ พวกนี้"

ไฮไลต์คำที่ถูกตัด และแถวที่หลุด
ค่าธรรมเนียมการโอน fee ประจำเดือนตัดออก
ดอกเบี้ยเงินฝาก interestตัดออก
เบิกเงินสด cash ฉุกเฉินตัดออก
ซื้อสินค้า grocery onlineเก็บไว้
จ่ายค่าบริการ internetเก็บไว้
databricks_sql.sql
SELECT
  txn_id,
  description,
  amount
FROM transactions
WHERE
  description NOT RLIKE '(?i)(fee|interest|cash)';
(?i)· ไม่ยึดตัวพิมพ์ใหญ่เล็ก(fee|interest|cash)· แมตช์คำใดคำหนึ่งในวงเล็บNOT RLIKE· ตัดรายการที่ตรง pattern ทิ้ง
โจทย์ 08

Logical Order

ลำดับการทำงานของ SQL · เข้าใจว่าทำไม WHERE กับ HAVING ต่างกัน

มือใหม่สงสัยว่าทำไม WHERE ใช้ alias ที่ตั้งใน SELECT ไม่ได้ แต่ HAVING ใช้ได้ คำตอบอยู่ที่ "ลำดับการทำงาน" ของ query engine ซึ่งต่างจากลำดับที่เราเขียน

เริ่มจากอ่านข้อมูล (FROM/JOIN) → WHERE กรองเป็นแถว → GROUP BY จัดกลุ่มและรวมค่า → HAVING กรองกลุ่ม → SELECT เลือกคอลัมน์

กรวย: แถวไหลผ่านทีละขั้นก่อนผลลัพธ์
อ่านข้อมูล 10,000 แถว
WHERE กรองแถวเหลือ 6,000
GROUP BY รวมเป็น 5 กลุ่ม
HAVING เหลือ 3 กลุ่ม
databricks_sql.sql
SELECT
  region,
  COUNT(*) AS customer_count,
  SUM(spend) AS total_spend
FROM customers
WHERE status != 'ยกเลิก'          -- 1. กรองแถวก่อน
GROUP BY region                   -- 2. จัดกลุ่ม + รวมค่า
HAVING SUM(spend) > 100000        -- 3. กรองกลุ่มที่รวมแล้ว
ORDER BY total_spend DESC;        -- 4. เรียงผลลัพธ์
WHERE status != 'ยกเลิก'· กรองแถวรายละเอียดก่อนGROUP BY region· จัดกลุ่ม + รวมค่า (SUM, COUNT)HAVING SUM(spend) > 100000· กรองเฉพาะกลุ่มที่ผ่านเกณฑ์
โจทย์ 09

Grain Bloat Fix

แก้จำนวนแถวเกินจริงจากการ join · ลูกค้า 1 คนต้องได้ 1 แถว ไม่ใช่หลายแถว

โจทย์สุดคลาสสิกของงาน CRM คือการ join กับตารางระดับที่ละเอียดกว่า เช่น ลูกค้า 1 คนมีคะแนนที่ตรงกับ หลาย tier พร้อมกัน ผลคือแถวทวีขึ้น (grain bloat) แล้วนับยอดผิด

ตัวอย่างนี้ลูกค้าคนเดียวตรงกับ 3 tier ทำให้เกิด 3 แถวทั้งที่ควรเป็น 1 เราใช้ QUALIFY คู่กับ ROW_NUMBER() เก็บแค่ 1 แถว ที่แมปได้ เพื่อให้ระดับความละเอียดกลับมาสมเหตุสมผล

ลูกค้า 1 คน ตรง 3 tier ยุบเหลือ 1

ลูกค้า #2041 คะแนน 750 ตรงกับ 3 tier

Bronze (300-600)Silver (600-700)Gold (700-900)
เหลือ 1 แถว: tier = Gold
databricks_sql.sql
SELECT
  c.customer_id,
  c.name,
  t.tier_name
FROM customers c
LEFT JOIN score_tiers t
  ON c.spend BETWEEN t.min_spend AND t.max_spend
QUALIFY
  ROW_NUMBER() OVER (
    PARTITION BY c.customer_id
    ORDER BY t.tier_min DESC
  ) = 1;      -- เก็บแค่ tier สูงสุดที่แมปได้
ON spend BETWEEN min_spend AND max_spend· range join ที่ทำให้แมทช์หลายแถวPARTITION BY customer_id· นับแถวต่อลูกค้า 1 คนORDER BY t.tier_min DESC· เก็บ tier ที่สูงที่สุด
โจทย์ 10

Production 4-Pinto Architecture

สถาปัตยกรรมปิ่นโต 4 ชั้น · วางกรอบการทำงานให้แยกส่วนและต่อกันได้

ข้อสุดท้ายคือการ "วางสถาปัตยกรรม" ที่พี่ใช้จริงตอนทำงานกับตารางข้ามทีม ให้คิดแบบ"ปิ่นโต" คือแยกส่วน ทำสุกทีละกล่อง แล้วค่อยเอามาเจอกันจานเสิร์ฟ ประโยชน์คือทีมอื่นเอาไปใช้ต่อได้ ไม่ต้องมานั่งไล่ query ใหญ่ๆ อีก

หลักกว้างๆ คือแยก "ธุรกรรม" ออกจาก "ข้อกำหนด/สิทธิ์" แล้วค่อย JOIN กันเป็นจานเสิร์ฟ แต่ละกล่องมี grain (ระดับความละเอียด) ของตัวเองชัดเจน

ปิ่นโต 3 กล่อง → จานเสิร์ฟ
ปิ่นโต 1ธุรกรรมtransactions
ปิ่นโต 2คน / สิทธิ์people · eligibility
ปิ่นโต 3จานเสิร์ฟfinal join & serve
databricks_sql.sql
-- ปิ่นโต 1: ธุรกรรม (transactions)
CREATE OR REPLACE TEMP VIEW v_transactions AS
SELECT customer_id, txn_date, amount
FROM raw.transactions;

-- ปิ่นโต 2: คน/สิทธิ์ (people & eligibility)
CREATE OR REPLACE TEMP VIEW v_people AS
SELECT customer_id, name, tier
FROM gold.customers;

-- ปิ่นโต 3 / จานเสิร์ฟ: นำมาเจอกัน
SELECT
  p.name,
  p.tier,
  SUM(t.amount) AS total_spend
FROM v_people p
LEFT JOIN v_transactions t
  ON p.customer_id = t.customer_id
GROUP BY p.name, p.tier;
v_transactions· กล่องธุรกรรม ระดับแถวของ txnv_people· กล่องคน/สิทธิ์ ระดับลูกค้าJOIN + GROUP BY· จานเสิร์ฟนำทั้งสองมาเจอกัน