PlusMagi's Blog By Pitt Phunsanit Database Safe Re-index ตาราง Taxonomy ใน WordPress: จัดระเบียบ ID ให้เรียงชิด โดยไม่เจอปัญหา Duplicate Key

Safe Re-index ตาราง Taxonomy ใน WordPress: จัดระเบียบ ID ให้เรียงชิด โดยไม่เจอปัญหา Duplicate Key

เมื่อเว็บไซต์ WordPress ใช้งานมาเป็นเวลานาน มีการสร้าง ลบ หรือแก้ไข Categories, Tags และ Custom Taxonomies เป็นจำนวนมาก สิ่งที่มักพบคือค่า term_id และ term_taxonomy_id เกิดการกระโดดข้ามลำดับ มีช่องว่าง (ID Fragmentation) และมีค่า AUTO_INCREMENT พุ่งสูงเกินความจำเป็น

หลายคนพยายามเขียนคำสั่ง SQL เพื่อ UPDATE รันเลข ID ให้กลับมาต่อกันตั้งแต่ 1 ใหม่อีกครั้ง แต่สิ่งที่มักตามมาคือข้อผิดพลาดคลาสสิกของ MySQL

Plain Text

Plain Text

ERROR 1062 (23000): Duplicate entry '...-post_tag' for key 'term_id_taxonomy'

บทความนี้จะพาไปดูว่าทำไมคำสั่ง UPDATE แบบเดิมถึงทำให้ระบบพัง และเทคนิค Shadow Table & Atomic Swap ร่วมกับการล้างข้อมูลขยะตกค้าง ช่วยแก้ปัญหานี้ได้อย่างปลอดภัย 100% อย่างไร


เข้าใจโครงสร้างความสัมพันธ์ของระบบ Taxonomy

ความซับซ้อนของระบบ Taxonomy ใน WordPress อยู่ที่การแยกความสัมพันธ์ออกเป็น 4 ตารางหลัก

Plain Text

Plain Text

[wp_terms] (term_id)
      ▲
      │ (1:N)
[wp_term_taxonomy] (term_taxonomy_id, term_id, parent)
      ▲                               ▲
      │ (1:N)                         │ (1:N)
[wp_term_relationships]         [wp_termmeta]
  • wp_terms
    เก็บชื่อ (name) และ slug มี Primary Key คือ term_id
  • wp_term_taxonomy
    กำหนดประเภท (เช่น category, post_tag) ลำดับชั้น (parent) มี Primary Key คือ term_taxonomy_id เชื่อมโยงกับ wp_terms ผ่าน term_id
  • wp_term_relationships
    เชื่อมโพสต์เข้ากับหมวดหมู่/แท็ก โดยอ้างอิงผ่าน term_taxonomy_id ไม่ใช่ term_id
  • wp_termmeta
    เก็บข้อมูลเสริมของ term เชื่อมโยงผ่าน term_id

จุดสำคัญที่สุดคือ ตาราง wp_term_taxonomy มีดัชนีพิเศษแบบ Unique บังคับไว้

Plain Text

Plain Text

UNIQUE KEY term_id_taxonomy (term_id, taxonomy)

กฎข้อนี้บังคับว่า ใน Taxonomy เดียวกัน ห้ามมี term_id ซ้ำกันเด็ดขาด


ทำไมคำสั่ง UPDATE แบบเดิมจึงติด ERROR 1062?

สมมติว่าในระบบมี Tag สองตัว

  • แถว A: term_id = 100, taxonomy = 'post_tag' (กำลังจะถูกเปลี่ยนเป็น 1)
  • แถว B: term_id = 1, taxonomy = 'post_tag' (กำลังจะถูกเปลี่ยนเป็น 2)

เมื่อรันคำสั่งอัปเดตตรง ๆ บนตารางเดิม

Plain Text

Plain Text

UPDATE wp_term_taxonomy tt
JOIN mapping_table m ON tt.term_id = m.old_id
SET tt.term_id = m.new_id;

InnoDB ใน MySQL จะประมวลผลแบบ Row-by-Row:

  1. ระบบหยิบ แถว A มาเปลี่ยน term_id จาก 100 เป็น 1
  2. ระบบตรวจพบว่าในตาราง มีแถว B ที่ใช้ term_id = 1 และ taxonomy = 'post_tag' อยู่แล้ว
  3. เกิดการชนกันของ Unique Constraint ทันที ส่งผลให้การทำงาน Rollback ล้มเหลวด้วย ERROR 1062

แม้ข้อมูลปลายทางหลังรันเสร็จทั้งหมดจะไม่มีตัวซ้ำจริง แต่ความขัดแย้งชั่วคราวระหว่างประมวลผล (Transient Collision) ทำให้คำสั่ง UPDATE ล้มเหลวเสมอ


กับดักอีกข้อ: ความสัมพันธ์กำพร้า (Orphan Records)

ใน WordPress รุ่นก่อนหน้าหรือเว็บที่เคยติดตั้ง/ลบปลั๊กอิน Taxonomy มาหลายตัว บ่อยครั้งที่การลบหมวดหมู่หรือแท็กไม่ได้ลบข้อมูลใน wp_term_relationships ออกไปด้วย ทำให้เกิด term_taxonomy_id กำพร้าค้างอยู่ในระบบ

เมื่อเรา Re-index ตารางหลักเป็น 1, 2, 3 ข้อมูลกำพร้าเหล่านี้จะไม่ถูกจับคู่ในตาราง Mapping และยังค้างเป็นเลขเดิม (เช่น 20058) กลายเป็น Foreign Key ลอยที่ชี้ไปยังหมวดหมู่ที่ไม่มีอยู่จริง ดังนั้น การล้างข้อมูลกำพร้า (Data Sanitization) จึงต้องทำเป็นขั้นตอนแรกเสมอ


ทางออก: Shadow Table & Atomic Swap

แทนที่จะพยายามอัปเดตข้อมูลทับลงบนตารางเดิม สถาปัตยกรรมที่ปลอดภัยและนิยมใช้ในระบบระดับ Enterprise คือ:

  1. Sanitize Data
    ล้างข้อมูลกำพร้าใน wp_term_relationships และ wp_termmeta ที่ไม่มี Term จริงรองรับ
  2. สร้างตาราง Mapping ID
    จัดเรียงลำดับ ID ใหม่ตามลำดับเดิมแบบต่อเนื่อง (1, 2, 3, …)
  3. อัปเดตตารางรอบนอก
    ปรับ ID ใน wp_termmeta และ wp_term_relationships ให้ตรงกับค่าใหม่
  4. สร้างตารางเงา (Shadow Table)
    สร้าง wp_terms_new และ wp_term_taxonomy_new แล้ว INSERT ข้อมูลที่ผูก ID ใหม่เสร็จสรรพเข้าไปทีเดียว ป้องกันปัญหา Transient Collision 100%
  5. Atomic Swap
    สลับชื่อตารางด้วย RENAME TABLE ภายในคำสั่งเดียว รวดเร็วระดับมิลลิวินาทีและปลอดภัยต่อโครงสร้าง

Full Safe Re-index Script

คำเตือน
ก่อนรันคำสั่งบนฐานข้อมูล Production ควรทำ Full Database Backup หรือ Snapshot เสมอ และหากใช้ Table Prefix อื่นที่ไม่ใช่ wp_ ให้ปรับชื่อตารางให้ตรงกับสภาพแวดล้อมที่ใช้งานจริง

Plain Text

Plain Text

SET FOREIGN_KEY_CHECKS = 0;

-- =============================================================
-- ขั้นตอนที่ 0: ล้างข้อมูลกำพร้าตกค้าง (Data Sanitization)
-- =============================================================
-- 0.1 ลบ relationship กำพร้าที่ไม่มี taxonomy รองรับ
DELETE tr FROM wp_term_relationships tr
LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.term_taxonomy_id IS NULL;

-- 0.2 ลบ termmeta กำพร้าที่ไม่มี term รองรับ
DELETE tm FROM wp_termmeta tm
LEFT JOIN wp_terms t ON tm.term_id = t.term_id
WHERE t.term_id IS NULL;

-- =============================================================
-- ขั้นตอนที่ 1: สร้างตาราง Mapping พักข้อมูล ID เก่าและใหม่
-- =============================================================
DROP TABLE IF EXISTS wp_term_id_mapping;
CREATE TABLE wp_term_id_mapping (
    old_id BIGINT(20) UNSIGNED NOT NULL PRIMARY KEY,
    new_id BIGINT(20) UNSIGNED NOT NULL,
    INDEX (new_id)
);

DROP TABLE IF EXISTS wp_tt_id_mapping;
CREATE TABLE wp_tt_id_mapping (
    old_id BIGINT(20) UNSIGNED NOT NULL PRIMARY KEY,
    new_id BIGINT(20) UNSIGNED NOT NULL,
    INDEX (new_id)
);

-- สร้าง ID ต่อเนื่องชุดใหม่ (1, 2, 3, ...)
SET @c_term := 0;
INSERT INTO wp_term_id_mapping (old_id, new_id)
SELECT term_id, (@c_term := @c_term + 1) AS new_id
FROM wp_terms
ORDER BY term_id ASC;

SET @c_tt := 0;
INSERT INTO wp_tt_id_mapping (old_id, new_id)
SELECT term_taxonomy_id, (@c_tt := @c_tt + 1) AS new_id
FROM wp_term_taxonomy
ORDER BY term_taxonomy_id ASC;

-- =============================================================
-- ขั้นตอนที่ 2: อัปเดตตารางรอบนอก (Relationships, Termmeta)
-- =============================================================
-- 2.1 termmeta
UPDATE wp_termmeta tm
JOIN wp_term_id_mapping m ON tm.term_id = m.old_id
SET tm.term_id = m.new_id;

-- 2.2 term_relationships (ชี้ผ่าน term_taxonomy_id)
UPDATE wp_term_relationships tr
JOIN wp_tt_id_mapping m ON tr.term_taxonomy_id = m.old_id
SET tr.term_taxonomy_id = m.new_id;

-- =============================================================
-- ขั้นตอนที่ 3: สร้างตารางเงา wp_terms_new และย้ายข้อมูล
-- =============================================================
DROP TABLE IF EXISTS wp_terms_new;
CREATE TABLE wp_terms_new LIKE wp_terms;

INSERT INTO wp_terms_new (term_id, name, slug, term_group)
SELECT 
    m.new_id,
    t.name,
    t.slug,
    t.term_group
FROM wp_terms t
JOIN wp_term_id_mapping m ON t.term_id = m.old_id
ORDER BY m.new_id ASC;

-- =============================================================
-- ขั้นตอนที่ 4: สร้างตารางเงา wp_term_taxonomy_new และย้ายข้อมูล
-- =============================================================
DROP TABLE IF EXISTS wp_term_taxonomy_new;
CREATE TABLE wp_term_taxonomy_new LIKE wp_term_taxonomy;

INSERT INTO wp_term_taxonomy_new (term_taxonomy_id, term_id, taxonomy, description, parent, count)
SELECT 
    m_tt.new_id,
    m_term.new_id,
    tt.taxonomy,
    tt.description,
    IFNULL(m_parent.new_id, 0),
    tt.count
FROM wp_term_taxonomy tt
JOIN wp_tt_id_mapping m_tt ON tt.term_taxonomy_id = m_tt.old_id
JOIN wp_term_id_mapping m_term ON tt.term_id = m_term.old_id
LEFT JOIN wp_term_id_mapping m_parent ON tt.parent = m_parent.old_id AND tt.parent > 0
ORDER BY m_tt.new_id ASC;

-- =============================================================
-- ขั้นตอนที่ 5: สลับตารางจริง (Atomic Swap) และรีเซ็ต AUTO_INCREMENT
-- =============================================================
DROP TABLE IF EXISTS wp_terms_old;
DROP TABLE IF EXISTS wp_term_taxonomy_old;

RENAME TABLE 
    wp_terms TO wp_terms_old,
    wp_terms_new TO wp_terms,
    wp_term_taxonomy TO wp_term_taxonomy_old,
    wp_term_taxonomy_new TO wp_term_taxonomy;

-- ปรับ AUTO_INCREMENT ให้พร้อมรับข้อมูลแถวถัดไป
SET @next_t := (SELECT IFNULL(MAX(term_id), 0) + 1 FROM wp_terms);
SET @sql_t := CONCAT('ALTER TABLE wp_terms AUTO_INCREMENT = ', @next_t);
PREPARE stmt_t FROM @sql_t;
EXECUTE stmt_t;
DEALLOCATE PREPARE stmt_t;

SET @next_tt := (SELECT IFNULL(MAX(term_taxonomy_id), 0) + 1 FROM wp_term_taxonomy);
SET @sql_tt := CONCAT('ALTER TABLE wp_term_taxonomy AUTO_INCREMENT = ', @next_tt);
PREPARE stmt_tt FROM @sql_tt;
EXECUTE stmt_tt;
DEALLOCATE PREPARE stmt_tt;

-- =============================================================
-- ขั้นตอนที่ 6: ล้างตาราง Mapping และตารางสำรองชั่วคราว
-- =============================================================
DROP TABLE IF EXISTS wp_term_id_mapping;
DROP TABLE IF EXISTS wp_tt_id_mapping;
DROP TABLE IF EXISTS wp_terms_old;
DROP TABLE IF EXISTS wp_term_taxonomy_old;

SET FOREIGN_KEY_CHECKS = 1;

การตรวจสอบความสมบูรณ์หลังการรัน (Sanity Checks)

หลังรันคำสั่งเสร็จสิ้น ให้ตรวจสอบความถูกต้องของข้อมูลด้วย 3 คำสั่งต่อไปนี้

เช็คความต่อเนื่องของ ID

Plain Text

Plain Text

SELECT MIN(term_id) AS min_t, MAX(term_id) AS max_t, COUNT(*) AS count_t FROM wp_terms;
SELECT MIN(term_taxonomy_id) AS min_tt, MAX(term_taxonomy_id) AS max_tt, COUNT(*) AS count_tt FROM wp_term_taxonomy;

(ถ้า ID เรียงชิดกันสมบูรณ์ ค่า MAX จะเท่ากับ COUNT และ MIN จะต้องเริ่มที่ 1)

เช็คว่ามี Term หลุดจากการจับคู่หรือไม่

Plain Text

Plain Text

SELECT t.term_id, tt.term_id 
FROM wp_terms t 
LEFT JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id 
WHERE tt.term_id IS NULL;

(ผลลัพธ์ต้องได้ 0 rows แสดงว่าทุก Term มี Taxonomy รองรับ)

เช็คความสัมพันธ์ของโพสต์

Plain Text

Plain Text

SELECT tr.object_id, tr.term_taxonomy_id 
FROM wp_term_relationships tr 
LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id 
WHERE tt.term_taxonomy_id IS NULL;

(ผลลัพธ์ต้องได้ 0 rows แสดงว่าไม่มีบทความใดเสียความเชื่อมโยงกับหมวดหมู่หรือแท็ก)


การเลือกใช้ Shadow Table & Atomic Swap ร่วมกับการ Sanitize ข้อมูลขยะกำพร้าล่วงหน้า ไม่เพียงแต่แก้ปัญหา Duplicate entry บน Unique Index ได้อย่างถาวร แต่ยังช่วยให้กระบวนการจัดระเบียบฐานข้อมูลเป็นไปอย่างราบรื่น ไม่เสี่ยงต่อ Data Inconsistency และปลอดภัยต่อระบบ Production