EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / นำเข้า/ย้ายข้อมูลไทยให้ไม่พัง: กับดัก VARCHAR, idempotency, สระ OCR
Linux

นำเข้า/ย้ายข้อมูลไทยให้ไม่พัง: กับดัก VARCHAR, idempotency, สระ OCR

โดย Ton-Or ·24 ก.ค. 2026·อ่าน 9 นาที
นำเข้า/ย้ายข้อมูลไทยให้ไม่พัง: กับดัก VARCHAR, idempotency, สระ OCR

ย้ายข้อมูลจากไฟล์เก่าเข้าฐานใหม่ดูเหมือนงานถึก แต่ตัวที่กัดจริงไม่ใช่ปริมาณ — มันคือ key ที่ถูกตัดเงียบๆ จนชนกัน, สคริปต์ที่รันซ้ำแล้วยอดบวม, กับสระไทยตัวเดียวจาก OCR ที่ทำให้ "การเงิน" กลายเป็นคนละรายการ ทั้งหมดนี้ไม่มี error โผล่ให้เห็นสักตัว

สรุปใน 1 นาที

อาการ/ปัญหา
นำเข้าข้อมูลไทยหลายแสนแถวแล้ว key ชนกันเงียบ ยอดพุ่งเมื่อรันซ้ำ และสระจาก OCR เพี้ยน
ใครควรอ่าน
คนที่ย้ายหรือนำเข้าข้อมูลไทยจำนวนมากเข้าฐานข้อมูล
ทางแก้แบบสั้น
ใช้ VARCHAR ยาวพอ DELETE ก่อน INSERT ให้รันซ้ำได้ ระวัง collation ที่มอง su001 กับ SU001 เท่ากัน และทำ normalization map ให้สระไทยก่อน insert
ข้ามไปขั้นตอน

พอดีช่วงนี้ผมรับงานนำเข้าข้อมูลชุดใหญ่ ย้ายของเก่าจากหลายแหล่งมารวมลงฐานเดียว — ไฟล์ xlsx ที่คนกรอกมือ, PDF เก่าที่ต้องเอาไปผ่าน OCR, แล้วก็ API อีกเจ้า นับแถวรวมกันก็หลายแสน ตอนแรกผมนึกว่ามันเป็นงานถึกอย่างเดียว เขียน loop insert ให้จบก็พอ ที่ไหนได้ ตัวที่กัดจริงไม่ใช่ปริมาณครับ มันคือรายละเอียดเล็กๆ ที่ทำข้อมูลเพี้ยนแบบไม่มี error โผล่ให้เห็น กว่าจะจับได้ก็ตอนยอดรวมมันผิดไปแล้ว ผมเลยเก็บสี่กับดักที่โดนมาเขียนไว้ เผื่อใครกำลังจะทำงานแนวนี้

ชื่อตารางกับตัวเลขในบทความเป็นตัวอย่าง แต่กลไกที่ทำพัง และวิธีเช็ค คือของจริงที่เจอมาเอง

21ความยาว key จริง
VARCHAR(20)คอลัมน์ที่เตรียมไว้
200%ยอด weight หลังรันซ้ำ
0error ที่ระบบแจ้ง

กับดักที่ 1: VARCHAR สั้นไป ตัด synthetic key เงียบๆ จนชนกัน

ข้อมูลชุดนี้ไม่มี primary key ธรรมชาติ ผมเลยสร้าง synthetic key ขึ้นมาเองจาก base code ต่อด้วย suffix ของ variant เช่น IT100802093025A_A0073 แล้วก็เผลอตั้งคอลัมน์ไว้ VARCHAR(20) ตามที่กะด้วยตาว่า "น่าจะพอ"

ปัญหาคือ key จริงมันยาว 21 ตัว เกินมา 1 ตัวพอดี และ MySQL/MariaDB ถ้า sql_mode ไม่ได้เปิด strict มันจะตัดตัวท้ายทิ้งเงียบๆ เหลือแค่ warning ที่ไม่มีใครไปอ่าน — สองรายการที่ต่างกันแค่ตัวสุดท้ายเลยกลายเป็น key เดียวกัน แล้วชนกัน

-- คอลัมน์เดิม กว้างไม่พอสำหรับ synthetic key
CREATE TABLE items (
  id         BIGINT PRIMARY KEY AUTO_INCREMENT,
  item_code  VARCHAR(20) NOT NULL,   -- ปัญหาอยู่ตรงนี้
  name       VARCHAR(255) NOT NULL,
  UNIQUE KEY uq_code (item_code)
);

-- key จริงสองตัว ยาว 21 ตัวเท่ากัน ต่างแค่ตัวสุดท้าย
--   'IT100802093025A_A0073'
--   'IT100802093025A_A0074'
-- ถูกตัดเหลือ 20 ตัว กลายเป็น 'IT100802093025A_A007' ทั้งคู่ -> ชนกัน

พอมี UNIQUE KEY ครอบอยู่ ตัวที่สองก็ insert ไม่เข้า (duplicate) — ถ้าโค้ดผมดัน error ทิ้งเงียบๆ หรือใช้ INSERT ... ON DUPLICATE KEY UPDATE มันก็ทับตัวแรกไปเลย ผลลัพธ์เหมือนกันคือหายไป 1 รายการโดยไม่มีใครรู้ กว่าจะเจอก็ตอนลูกค้าถามหาของที่ควรมี

ทางที่ข้อมูลหายเงียบๆkey ยาว 21คอลัมน์ VARCHAR(20)→ตัดเหลือ 20ไม่มี error ถ้า strict off→ชนกับอีก keyเหลือแถวเดียว→หาย 1 รายการไม่มีใครรู้

ทางแก้ผมทำสองชั้น ชั้นแรกคือขยาย schema ให้เผื่อ (VARCHAR(40) ไปเลย ไม่ต้องกั๊กเพราะ VARCHAR จองตามที่ใช้จริงอยู่แล้ว) ชั้นที่สองที่สำคัญกว่าคือเช็คความยาวใน Python ก่อน insert เสมอ ห้ามเชื่อว่า DB จะเตือนให้ เพราะบางเครื่อง strict บางเครื่องไม่ strict

from collections import Counter

def preflight_keys(keys, col_maxlen):
    # 1. มี key ตัวไหนยาวเกินคอลัมน์ไหม -> ถ้ามี ต้องขยาย schema ก่อน อย่าปล่อยให้ DB ตัด
    toolong = [(k, len(k)) for k in keys if len(k) > col_maxlen]
    assert not toolong, f"key ยาวเกิน VARCHAR({col_maxlen}): {toolong[:5]}"

    # 2. หลังมั่นใจว่าไม่มีอะไรถูกตัด ค่อยเช็คว่า key ซ้ำกันเองไหม
    dup = [k for k, n in Counter(keys).items() if n > 1]
    assert not dup, f"key ซ้ำกันเอง: {dup[:5]}"

    return max(len(k) for k in keys)  # ไว้เทียบกับ schema
เปิด strict mode ไว้ กันการตัดเงียบ

ตั้ง sql_mode ให้มี STRICT_ALL_TABLES แล้วการยัดค่ายาวเกินจะกลายเป็น error "Data too long" แทนที่จะตัดเงียบๆ — เจ็บตอน insert ดีกว่าเจ็บตอนหาของหายไม่เจอ แต่ก็ยังต้องเช็คใน Python อยู่ดี เพราะ strict ไม่กันเคส key ชนกันหลังตัด (ถ้าไม่มี unique key)

กับดักที่ 2: idempotency — สคริปต์ "replace" ต้อง DELETE ก่อน INSERT

กับดักที่แสบที่สุดคืออันนี้ครับ เวลาเจอข้อมูลผิดแล้วเขียนสคริปต์ไปแก้ ธรรมชาติของงาน migration คือเราจะรันสคริปต์เดิมซ้ำหลายรอบ — รันแล้วเจอ bug, แก้, รันใหม่ วนแบบนี้สิบรอบก็มี ถ้าสคริปต์ไม่ idempotent (รันซ้ำแล้วผลไม่เหมือนเดิม) ข้อมูลจะบวมขึ้นทุกรอบ

ตัวที่โดนหนักคือตาราง item_weights ที่เก็บ "น้ำหนัก" ของแต่ละองค์ประกอบ ซึ่งต้องรวมกันได้ราวๆ 100 ต่อหนึ่ง item ผมเขียนสคริปต์เติม weight ที่ขาด แต่ลืมไปว่ารอบก่อนหน้าใส่ของเก่าไว้แล้ว พอรันซ้ำมันก็ insert ทับซ้อนเข้าไปอีกชุด — ยอดรวมเลยพุ่งเป็น 200% องค์ประกอบเดียวถูกนับสองครั้ง

INSERT อย่างเดียวDELETE แล้ว INSERT ใน transactionรอบ 1รอบ 2รอบ 1รอบ 2403525= 100403525403525= 200403525= 100403525= 100รอบสองใส่ชุดเดิมซ้อนอีกชุดองค์ประกอบเดียวถูกนับสองครั้งรอบสองลบชุดเก่าของ item นี้ก่อน แล้วใส่ทั้งชุดใหม่ยอดคงที่ที่ 100 ไม่ว่ารันกี่รอบตัวเลขในช่องคือ weight ต่อองค์ประกอบ รวมกันควรได้ราว 100 ต่อ item
รันซ้ำแบบ INSERT อย่างเดียวทำให้ weight ซ้อนกันจนยอดรวมเป็น 200 ส่วน DELETE ก่อน INSERT ให้ยอดเท่าเดิมทุกรอบ
-- ผิด: insert อย่างเดียว รันซ้ำเมื่อไหร่ weight ซ้อนทันที
INSERT INTO item_weights (item_id, component, weight) VALUES (?, ?, ?);
-- รันรอบสอง -> item เดียวกันมี weight สองชุด -> SUM ทะลุ 100

-- ถูก: สำหรับ operation แบบ "replace" ต้องลบของเก่าตาม scope ก่อน แล้วค่อย insert ทั้งก้อน
START TRANSACTION;
DELETE FROM item_weights WHERE item_id = ?;      -- ล้างของเก่าเฉพาะ item นี้
INSERT INTO item_weights (item_id, component, weight) VALUES
  (?, 'A', 40), (?, 'B', 35), (?, 'C', 25);      -- ใส่ชุดใหม่ทั้งชุด
COMMIT;

กฎเหล็กของผมหลังจากงานนี้คือ งานไหนที่ความหมายคือ "แทนที่" ให้ DELETE-แล้ว-INSERT เสมอ อย่า merge แบบเติมเข้าไปเฉยๆ เพราะ merge จะ idempotent ก็ต่อเมื่อเราเช็คก่อนทุกแถวว่ามีอยู่แล้วหรือยัง ซึ่งเขียนพลาดง่ายกว่าเยอะ ส่วน INSERT ก็ได้ใช้ ON DUPLICATE KEY UPDATE ช่วยได้ถ้ามี unique key ครอบครบ แต่กรณีน้ำหนักหลายแถวต่อ item แบบนี้ DELETE-then-INSERT ตรงไปตรงมากว่า

หลังนำเข้าทุกรอบผมจะยิง audit ตัวนี้ก่อนเสมอ ถ้ามีแถวโผล่มาคือรู้เลยว่ารันซ้อน:

-- item ไหนที่ยอด weight เกิน 100 = โดน insert ซ้อนแน่ๆ
SELECT item_id, ROUND(SUM(weight), 2) AS total
FROM item_weights
GROUP BY item_id
HAVING total > 100.5      -- เผื่อ error ปัดเศษเล็กน้อย
ORDER BY total DESC
LIMIT 20;
DELETE ต้องมี WHERE และต้องอยู่ใน transaction

สอง disaster ที่มากับ pattern นี้: หนึ่ง — DELETE ที่ลืมใส่ WHERE ล้างทั้งตารางในพริบตา, สอง — DELETE สำเร็จแต่ INSERT พังกลางคัน ถ้าไม่ได้ห่อด้วย transaction เดียวกัน ข้อมูลของ item นั้นจะหายทั้งก้อน ระหว่าง migration ผมให้ทุก replace อยู่ใน START TRANSACTION ... COMMIT และ scope WHERE ให้แคบที่สุดเสมอ

กับดักที่ 3: collation utf8mb4_unicode_ci มอง su001 กับ SU001 เท่ากัน

อันนี้หลอกตาสุด เพราะมันเป็น "feature" ของ collation ที่ตั้งใจให้เป็น คอลัมน์ text ส่วนใหญ่ผมตั้งเป็น utf8mb4_unicode_ci ซึ่ง _ci คือ case-insensitive — เวลา compare มันมอง su001, SU001, Su001 เป็นค่าเดียวกันหมด

ปัญหาโผล่ตอนผมจะหา duplicate ด้วย WHERE ธรรมดา — มันหลอกให้ผมมั่นใจว่า "ไม่มีซ้ำ" ทั้งที่ในตารางเก็บทั้ง su001 และ SU001 ไว้จริงๆ (คนละแถว มาจากคนละแหล่ง) เพราะตอน insert ผมไม่ได้มี unique key ครอบ column นั้นเดี่ยวๆ

ตอนเทียบ / WHERE

collation มองเท่ากัน

WHERE component = 'SU001' คืนแถว 'su001' มาด้วย — dedupe ด้วย = เลยหลอกตาว่า "มีตัวเดียว ไม่ซ้ำ" ทั้งที่จริงมีสอง

ตอนเก็บ / INSERT

เก็บทั้งคู่จริง

ถ้า unique index ไม่ได้ครอบ (หรือไม่มี) ค่าที่ต่างกันแค่ case เข้าไปได้หลายแถว สุดท้ายเวลา join/นับ ก็นับซ้ำหรือ match ผิดตัว

วิธีหาให้เจอจริงๆ ต้องบังคับเทียบแบบ byte ตรงตัวด้วย BINARY ไม่งั้น GROUP BY ก็ยังยุบ case ให้เหมือนเดิม:

-- หา component ที่ต่างกันแค่ case (su001 vs SU001) ในตารางเดียว
SELECT UPPER(component)              AS norm,
       COUNT(DISTINCT BINARY component) AS variants,
       GROUP_CONCAT(DISTINCT component) AS seen
FROM item_weights
GROUP BY UPPER(component)
HAVING variants > 1;
-- variants > 1 = มีหลาย casing ของ key เดียวกันปนอยู่

ฝั่งสคริปต์ ผมเลิกไว้ใจ WHERE = ในการ dedupe แล้วครับ เปลี่ยนมา normalize ใน Python ก่อนเทียบ — เอา key มา .lower() แล้วค่อยเทียบใน set หรือ dict เอง จะได้ตัดสินใจได้ชัดเจนว่า "ให้ถือว่าเท่ากันไหม" ไม่ต้องฝากชะตากับ collation ของคอลัมน์:

# dedupe ฝั่งแอป: ตัดสินใจเองว่า case-insensitive หรือไม่ ไม่ฝากไว้กับ collation
seen = {}
for row in incoming:
    norm = row["component"].strip().lower()   # กติกาชัดเจน: lower = ถือว่าเท่ากัน
    if norm in seen:
        # เจอ case-variant ของตัวเดิม -> merge/ข้าม ตามนโยบายที่ตั้งไว้
        continue
    seen[norm] = row

ส่วนตัวผมถือว่า _ci ดีสำหรับ "การค้นหาให้คนใช้" แต่อันตรายสำหรับ "การพิสูจน์ความไม่ซ้ำ" — งานไหนที่ต้องยืนยันว่า unique จริง ให้เทียบด้วย BINARY หรือ normalize เองใน code เสมอ อย่าเชื่อ =

กับดักที่ 4: สระไทยเพี้ยนจาก OCR — "การเงนิ" ไม่ใช่ "การเงิน"

ข้อมูลส่วนที่มาจาก PDF เก่าต้องผ่าน OCR ก่อน และ OCR ภาษาไทยชอบสลับตำแหน่งสระบน/ล่างกับพยัญชนะ ตาเรามองเผินๆ เหมือนเป๊ะ แต่ระดับ byte มันคนละสตริง พอเอาไป GROUP BY หรือ JOIN ก็กลายเป็นคนละรายการทันที ตัวอย่างที่ผมเจอในงานจริง:

สระ/วรรณยุกต์สลับที่จาก OCRการเงนิิ มาก่อน น→การเงินตัวที่ถูก→byte คนละชุดGROUP BY แยกเป็น 2

คู่ที่เจอบ่อย: การเงนิ กับ การเงิน, ศลิปกรรม กับ ศิลปกรรม, สาธารณสขุ กับ สาธารณสุข, สอื่สาร กับ สื่อสาร, รังสติ กับ รังสิต, นวิตกรรม กับ นวัตกรรม — พวกนี้เกิดซ้ำๆ จนผมทำเป็น normalization map ประจำ ใส่ไว้ในขั้นตอน insert เลย นอกจาก map แล้วต้องยุบ whitespace ทิ้งด้วย เพราะอีกครึ่งของ dup ที่เจอคือ newline หรือ double-space ที่แทรกมาจากการ parse เช่น 'หลักสูตร\nสาขาวิชา...' กับ 'หลักสูตร สาขาวิชา...'

import re

# แผนที่ typo สระ/วรรณยุกต์ไทยจาก OCR ที่เจอซ้ำๆ — เติมได้เรื่อยๆ ตามที่ audit เจอ
OCR_FIX = {
    "การเงนิ":   "การเงิน",
    "ศลิปกรรม":  "ศิลปกรรม",
    "สาธารณสขุ": "สาธารณสุข",
    "สอื่สาร":   "สื่อสาร",
    "รังสติ":    "รังสิต",
    "นวิตกรรม":  "นวัตกรรม",
    "อตุสาหกรรม": "อุตสาหกรรม",
}

def normalize_th(name: str) -> str:
    name = name.replace("​", "")        # ตัด zero-width space
    name = re.sub(r"\s+", " ", name).strip()  # ยุบ newline/space ซ้อน เหลือช่องเดียว
    for bad, good in OCR_FIX.items():
        name = name.replace(bad, good)
    return name

# กติกา: ทุกค่าที่จะ insert ต้องผ่าน normalize_th() ก่อนเสมอ ไม่มีข้อยกเว้น

Map แบบนี้แก้ได้เฉพาะ typo ที่ "รู้จักแล้ว" ตัวที่ยังไม่รู้จักต้องมีวิธีไล่หา ผมใช้เทคนิค squashed comparison — ตัด whitespace ออกให้หมดแล้วเทียบ ถ้าชื่อสองอันเหลือ byte ต่างกันนิดเดียวแต่ตกลงในกลุ่มใกล้กัน ก็เอามาเทียบตาเป็นคู่ๆ ได้:

from collections import defaultdict

# หาชื่อที่ "เกือบเหมือนกัน" — ต่างแค่ space หรือสระเพี้ยน ยังไม่อยู่ใน OCR_FIX
buckets = defaultdict(set)
for r in rows:
    squashed = re.sub(r"\s+", "", r["name"])   # ตัด space/newline ทิ้งหมด
    buckets[squashed].add(r["name"])

for squashed, variants in buckets.items():
    if len(variants) > 1:
        print("NEAR-DUP:", variants)           # เอามาดูตา แล้วตัดสินใจเติมเข้า map

เจอคู่ไหนที่ชัวร์ว่าเป็นตัวเดียวกัน ก็เติมเข้า OCR_FIX แล้วรัน normalize ใหม่ วนแบบนี้จนกลุ่ม near-dup เหลือแต่ของที่ต่างกันจริง

สรุปเป็น checklist ก่อน/หลัง นำเข้าทุกรอบ

สี่กับดักนี้มีจุดร่วมเดียวกันคือมันเงียบ — ไม่มี error ตัวไหนเด้งมาบอก กว่าจะรู้ตัวก็ต้องไปเจอตอนยอดรวมผิด หรือลูกค้าทักว่าของหาย ผมเลยทำเป็น gate บังคับ ก่อน insert รันชุด preflight หลัง insert รันชุด audit ถ้าไม่ผ่านคือไม่ปล่อยขึ้นของจริง

  1. เช็คความยาว key ทุกตัวเทียบ column max ก่อน insert — ยาวเกินคือขยาย schema ไม่ใช่ปล่อยให้ DB ตัด
  2. เช็ค key ซ้ำกันเอง หลังจาก normalize แล้วเท่านั้น
  3. normalize ข้อความไทยทุกค่า — map สระ OCR + ยุบ whitespace ก่อน insert เสมอ
  4. งาน "replace" ให้ DELETE ตาม scope WHERE แล้ว INSERT ทั้งชุด ห่อด้วย transaction เดียว
  5. postflight: audit SUM ต่อ item, หา case-dup ด้วย BINARY, ไล่ near-dup ชื่อด้วย squashed comparison

รวบทั้งหมดเป็นสคริปต์เดียว รันก่อนทุกครั้งที่จะแตะข้อมูลจริง:

# preflight: ผ่านหมดค่อยปล่อย import ตัวจริง
python preflight_import.py \
  --check-keys      \
  --check-length 40 \
  --normalize-th    \
  --dry-run

# postflight: หลัง import เสร็จ audit ทันที
mysql migration_db < audit_after_import.sql

ส่วนตัวผมได้บทเรียนใหญ่สุดจากงานนี้คือ อย่าไว้ใจว่า "ไม่มี error = ข้อมูลถูก" — ทั้ง VARCHAR ตัด, collation ยุบ case, OCR สลับสระ ล้วนเป็นพฤติกรรมปกติของเครื่องมือที่ตั้งใจให้เป็นแบบนั้น มันไม่ใช่ bug ที่ระบบจะฟ้อง หน้าที่กันของแบบนี้เลยต้องเป็นเราเอง เขียน preflight/audit ให้ครบแล้วรันทุกรอบ อย่าไว้ใจตา

ใครจะเอาไปปรับใช้กับงานตัวเอง เปลี่ยนชื่อตารางกับตัวเลข tier ได้ตามสะดวกครับ แนวคิด "เช็คก่อน insert, audit หลัง insert, DELETE ก่อน replace, normalize ก่อนเทียบ" ใช้ได้กับงาน migration ทุกแบบไม่ใช่แค่ข้อมูลไทย ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

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

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย