เมื่อเว็บไซต์ 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_idwp_term_taxonomy
กำหนดประเภท (เช่นcategory,post_tag) ลำดับชั้น (parent) มี Primary Key คือterm_taxonomy_idเชื่อมโยงกับwp_termsผ่านterm_idwp_term_relationships
เชื่อมโพสต์เข้ากับหมวดหมู่/แท็ก โดยอ้างอิงผ่านterm_taxonomy_idไม่ใช่term_idwp_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:
- ระบบหยิบ แถว A มาเปลี่ยน
term_idจาก100เป็น1 - ระบบตรวจพบว่าในตาราง มีแถว B ที่ใช้
term_id = 1และtaxonomy = 'post_tag'อยู่แล้ว - เกิดการชนกันของ 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 คือ:
- Sanitize Data
ล้างข้อมูลกำพร้าในwp_term_relationshipsและwp_termmetaที่ไม่มี Term จริงรองรับ - สร้างตาราง Mapping ID
จัดเรียงลำดับ ID ใหม่ตามลำดับเดิมแบบต่อเนื่อง (1, 2, 3, …) - อัปเดตตารางรอบนอก
ปรับ ID ในwp_termmetaและwp_term_relationshipsให้ตรงกับค่าใหม่ - สร้างตารางเงา (Shadow Table)
สร้างwp_terms_newและwp_term_taxonomy_newแล้วINSERTข้อมูลที่ผูก ID ใหม่เสร็จสรรพเข้าไปทีเดียว ป้องกันปัญหา Transient Collision 100% - 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