EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / DirectAdmin / ย้ายบอร์ด vBulletin 4.2.5 ไป Flarum: บทเรียนจากสองแสนโพสต์
DirectAdmin

ย้ายบอร์ด vBulletin 4.2.5 ไป Flarum: บทเรียนจากสองแสนโพสต์

โดย Ton-Or ·25 ก.ค. 2026·อ่าน 11 นาที
ย้ายบอร์ด vBulletin 4.2.5 ไป Flarum: บทเรียนจากสองแสนโพสต์

ฟอรัมเก่าที่รันมาสิบกว่าปีย่อมมีข้อมูลสะสมเยอะ — พอถึงวันต้องย้ายจาก vBulletin 4.2.5 ไป Flarum ปัญหาไม่ได้อยู่ที่ "ย้ายได้ไหม" แต่อยู่ที่รายละเอียดเล็ก ๆ ที่ทำ import ล้มทั้งชุดตอนตีสาม บทความนี้คือบันทึกกับดักที่ผมเจอกับตัวเอง

พอดีบอร์ด vBulletin 4.2.5 ตัวเก่าถึงเวลาต้องเลิกใช้ — โค้ดเบสหยุดพัฒนา ค่าไลเซนส์ก็ยังต้องจ่าย เลยตัดสินใจย้ายไป Flarum ซึ่งเป็น PHP ตัวใหม่ น้ำหนักเบากว่า และแก้ธีมง่ายกว่ามาก ตัวเลขที่ต้องขนย้ายคือสมาชิกราวเจ็ดพันกว่าคน กระทู้สองหมื่นกว่า และโพสต์สองแสนกว่า งานแบบนี้ถ้ารันสคริปต์รวดเดียวแล้วเชื่อว่าจบ มักจะเจ็บตอนตีสาม เลยอยากบันทึกกับดักที่เสียเวลาจริงไว้ เผื่อใครกำลังจะย้ายบอร์ดตัวเองจะได้ไม่ตกหลุมเดิม

ชื่อฐานข้อมูล ชื่อ user และ IP ในบทความเป็นตัวอย่าง ส่วนโครงสร้างตารางกับลำดับขั้นตอนคือของจริง

แผนใหญ่: อย่า import ทั้งก้อนทีเดียว

สิ่งแรกที่ต้องยอมรับคือ schema ของสองตัวนี้คนละโลกกัน จะ dump ตารางเก่ายัดเข้าตารางใหม่ตรง ๆ ไม่ได้ ต้อง map ทีละชุดตามลำดับ dependency — ของที่ถูกอ้างถึง (users, tags) ต้องเข้าก่อนของที่อ้างถึงมัน (discussions, posts) เสมอ

ลำดับ import ที่ปลอดภัยusersเจ็ดพันกว่าคน→tags39 ฟอรัมเดิม→discussionsกระทู้→postsสองแสนกว่า→metadatacounts/sticky/lock

ตัว "forum" ของ vBulletin ผมแปลงเป็น "tag" ของ Flarum เพราะ Flarum ไม่มีคอนเซ็ปต์ forum แบบซ้อนชั้น ใช้ tag แทนหมด ส่วน thread กลายเป็น discussion และ post ก็คือ post เหมือนเดิม — แต่รายละเอียดข้างในนี่แหละที่เป็นเรื่อง

user_id = 0 กับ FK ที่พังทั้งชุด

กับดักแรกที่เจอ: vBulletin ใช้ userid = 0 แทน guest (คนโพสต์โดยไม่ล็อกอิน หรือ user ที่ถูกลบไปแล้ว) แต่ Flarum วาง foreign key ไว้ว่า posts.user_id ต้องชี้ไปหา user ที่มีจริง หรือไม่ก็เป็น NULL เท่านั้น พอ import โพสต์ที่ userid=0 เข้าไปตรง ๆ FK constraint เด้งทันที insert ล้มทั้ง batch

ทางแก้มีสองจุด: แปลง 0 เป็น NULL ตอน map และปิด FK check ระหว่าง bulk insert เพราะเราคุมลำดับเองอยู่แล้ว

-- ปิด FK check ทั้ง session ก่อน bulk insert
-- (เราคุมลำดับ users -> posts เองแล้ว ไม่ต้องให้ engine เช็คทีละแถวให้ช้า)
SET FOREIGN_KEY_CHECKS = 0;

INSERT INTO flarum_db.posts (id, discussion_id, user_id, type, content, created_at)
SELECT p.postid,
       p.threadid,
       NULLIF(p.userid, 0) AS user_id,   -- guest 0 -> NULL กันชน FK
       'comment',
       p.pagetext,                        -- ยังเป็น BBCode ตรงนี้ เดี๋ยวแปลงทีหลัง
       FROM_UNIXTIME(p.dateline)
FROM vb_db.post p;

SET FOREIGN_KEY_CHECKS = 1;
ปิด FK check แล้วต้องเปิดคืน + ตรวจย้อน

ปิด FOREIGN_KEY_CHECKS คือการเชื่อว่าเราจะไม่ทำ orphan row หลุดเข้าไป ถ้าเผลอ import posts ที่ discussion_id ชี้ไปหากระทู้ที่ยังไม่ได้ย้าย มันจะยอมรับเงียบ ๆ แล้วไปพังตอน user เปิดอ่าน จบงานแต่ละชุดควรนับแถวเทียบสองฝั่งเสมอ

รหัสผ่านเก่า: md5 ซ้อน md5 อย่าไปแตะ

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

vBulletin: md5( md5(password) . salt )   -- salt ต่อท้าย hash ชั้นใน

เราแฮชกลับไม่ได้อยู่แล้ว (มันคือ hash) เพราะฉะนั้นห้าม re-hash ห้ามแปลงอะไรทั้งสิ้น — ให้ยกทั้ง hash เดิมและ salt เดิมมาตรง ๆ แล้วให้ extension migratetoflarum/old-passwords เป็นคนตรวจตอนสมาชิก login ครั้งแรก ตั้งค่าอัลกอริทึมเป็น md5-double และบอกว่า salt อยู่ตำแหน่ง salt-after (ต่อท้าย) ให้ตรงกับสูตร vBulletin

-- ยก hash เดิม + salt เดิมมาทั้งดุ้น ห้ามแปลง
INSERT INTO flarum_db.users (id, username, email, password, joined_at)
SELECT u.userid, u.username, u.email, u.password, FROM_UNIXTIME(u.joindate)
FROM vb_db.user u;

-- salt เดิมเก็บไว้ให้ extension เอาไปต่อท้ายตอนตรวจ (salt-after)
UPDATE flarum_db.users f
JOIN vb_db.user u ON u.userid = f.id
SET f.migratetoflarum_old_password_salt = u.salt;
ตรวจครั้งแรกแล้ว re-hash เป็น bcrypt ให้เอง

พอสมาชิกล็อกอินสำเร็จครั้งแรก extension จะตรวจ md5-double กับรหัสที่กรอก ถ้าผ่านมันจะ re-hash รหัสนั้นเป็น bcrypt ของ Flarum ให้อัตโนมัติ hash เก่าถูกทิ้ง — แปลว่ายิ่งเวลาผ่านไป ฐานข้อมูลก็ค่อย ๆ อัปเกรดรหัสเป็นมาตรฐานใหม่เองโดยสมาชิกไม่รู้ตัว

schema ไม่เหมือนกัน: sticky/lock/approve ไปอยู่ไหน

vBulletin เก็บสถานะกระทู้ (ปักหมุด, ล็อก, อนุมัติ) เป็นคอลัมน์ในตาราง thread ตรง ๆ แต่ Flarum ตัด core ให้บางลง — ตาราง discussions ไม่มี is_sticky, is_locked, is_approved เลย ของพวกนี้ย้ายไปอยู่ที่ extension กับ soft-delete pattern แทน

vBulletin

ทุกสถานะอยู่ในตาราง thread

sticky, open (lock), visible (approve) เป็นคอลัมน์ในตาราง thread ตรง ๆ query ง่ายแต่ core บวม

Flarum

แยกออก extension + soft-delete

ปักหมุด/ล็อกอยู่ที่ flarum/sticky + flarum/lock ส่วน is_approved กลายเป็น hidden_at (NULL = โชว์, มีค่า = ซ่อน)

ผลคือตอน map ต้องรู้ว่าค่าเก่าไปลงคอลัมน์ไหนใหม่ ตัวอย่าง sticky กับ lock ต้อง set หลัง import discussion เสร็จ:

vBulletinFlarumthread.sticky = 1ปักหมุดdiscussions.is_stickyผ่าน flarum/stickythread.open = 0ปิด (ล็อก)discussions.is_locked = 1ผ่าน flarum/lockthread.visibleอนุมัติhidden_atNULL = โชว์ มีค่า = ซ่อนแก้สองตาราง
สถานะกระทู้ใน vBulletin เป็นคอลัมน์ในตาราง thread ส่วน Flarum แยกไปอยู่ใน extension และ hidden_at ซึ่งการซ่อนต้องแก้ทั้งตาราง discussions และ posts
-- ปักหมุด: flarum/sticky อ่านคอลัมน์ is_sticky ที่ extension เพิ่มเข้ามา
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_sticky = 1
WHERE t.sticky = 1;

-- ล็อก: flarum/lock ใช้ is_locked ; vBulletin open=0 คือปิด (ล็อก)
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_locked = 1
WHERE t.open = 0;
ซ่อน/private ต้องแก้สองตาราง ไม่ใช่ตารางเดียว

Flarum เก็บสถานะซ่อนไว้ทั้ง discussions.hidden_at และ posts.hidden_at แก้แค่ตาราง discussions เนื้อหายังโผล่ให้คนนอกเห็นได้ เช่นเดียวกับ is_private ของ fof/byobu — เวลา flip เป็น public ต้องเคลียร์ทั้ง discussions และ posts ไม่งั้นบางคนเห็นบางคนไม่เห็น แล้วต้อง php flarum cache:clear ตามทุกครั้ง

BBCode เป็นสองแสนโพสต์ ต้องแปลงเป็น s9e XML

ตอน insert posts ข้างบน ผมยัด pagetext ดิบ ๆ ที่ยังเป็น BBCode เข้าไปก่อน แต่ Flarum ไม่ได้เก็บ markdown/BBCode ดิบในคอลัมน์ content — มันเก็บเป็น s9e XML ที่ parse เสร็จแล้ว เวลาเรนเดอร์มันอ่านจาก XML ตรง ๆ ไม่ได้ parse ซ้ำ เพราะฉะนั้นถ้าปล่อย BBCode ดิบไว้ หน้าเว็บจะโชว์ [b]...[/b] เป็นตัวอักษรตามตัว

ขั้นตอนคือเปิด extension flarum/bbcode ก่อน แล้ว flush formatter cache ไม่งั้นตัว parser จะยังไม่รู้จักแท็ก BBCode จากนั้นไล่แปลงทุกโพสต์ผ่าน formatter ของ Flarum เอง เพื่อให้ได้ XML ที่ถูกต้องตาม pipeline ของมันจริง ๆ

<?php
// รันด้วย php cli ของ Flarum เอง (bootstrap แล้วจะมี $formatter ให้ใช้)
// เปิด flarum/bbcode ใน admin ก่อน แล้ว flush ไม่งั้น [b] ไม่ถูกตีความ
$formatter->flush();

$rows = DB::table('posts')->select('id', 'content')->orderBy('id')->cursor();
foreach ($rows as $p) {
    // content ตอนนี้ยังเป็น BBCode ดิบ -> parse เป็น s9e XML ที่ Flarum เก็บจริง
    $xml = $formatter->parse($p->content, null);
    DB::table('posts')->where('id', $p->id)->update(['content' => $xml]);
}
ทำเป็น batch แล้ว log id ล่าสุดไว้

สองแสนกว่าโพสต์ parse ทีเดียวกินเวลาและ RAM ถ้าหลุดกลางทางจะไม่รู้ว่าถึงไหน ผมแบ่งเป็น chunk แล้วเขียน id ตัวสุดท้ายลงไฟล์ทุกรอบ ล้มแล้วรันต่อจากจุดเดิมได้ — งานยาว ๆ ต้อง resume ได้เสมอ

attachment อยู่บนดิสก์ ไม่ได้อยู่ใน DB

อีกจุดที่หลายคนพลาด: vBulletin 4 ตั้งค่า attachfile = 2 หมายถึงเก็บไฟล์แนบไว้บน filesystem ไม่ใช่ BLOB ในฐานข้อมูล เพราะฉะนั้นถ้าไปไล่ดูตาราง filedata จะเห็นคอลัมน์ BLOB ว่างเปล่า แล้วนึกว่าไฟล์หาย — จริง ๆ ไฟล์อยู่บนดิสก์ตาม pattern ของมัน

# vBulletin attachfile = 2 = ไฟล์อยู่บนดิสก์ ตาราง filedata เก็บแต่ metadata
# path pattern: ./attachment/vbulletin/<hash>/<filedataid>.attach
find /home/vbforum/attachment -name '*.attach' | wc -l

# ปลายทาง Flarum + fof/upload:
#   ตัวไฟล์  -> public/assets/files/
#   record   -> ตาราง fof_upload_files + fof_upload_file_posts (ผูกไฟล์กับโพสต์)

ไฟล์ .attach คือ byte ดิบของไฟล์จริง แค่ไม่มีนามสกุล ต้องไปอ่านนามสกุลจริงจากคอลัมน์ extension ในตาราง filedata แล้ว copy ออกมาตั้งชื่อใหม่ ก่อนจะลงทะเบียนเข้า fof/upload ให้ผูกกลับไปหาโพสต์ที่มันเคยแนบอยู่

รันสคริปต์บนเซิร์ฟเวอร์: อย่าใช้ heredoc

เรื่องสุดท้ายเป็นบทเรียนตอนรันจริง สคริปต์ migrate ของผมเขียนบนเครื่อง dev แล้วต้องไปรันบนเซิร์ฟเวอร์ ตอนแรกขี้เกียจ scp เลยส่ง PHP เข้าไปทาง SSH heredoc ตรง ๆ ปรากฏว่าโค้ดที่มี $row["userid"] พังหมด เพราะ shell ไปยุ่งกับ $ และ double-quote ข้างในก่อนที่ PHP จะได้เห็น กลายเป็นตัวแปรว่าง อ่าน error งงอยู่นาน

# อย่า: heredoc ทำ shell แปลง $var["key"] ก่อน PHP ได้เห็น -> ค่าเพี้ยน
ssh [email protected] php <<EOF
  echo \$row["userid"];   // โดน shell กินตั้งแต่ยังไม่ถึง PHP
EOF

# ทำ: scp ไฟล์ขึ้นไปก่อน แล้วสั่งรันเป็นไฟล์ ให้ PHP อ่านซอร์สเองทั้งก้อน
scp migrate_posts.php [email protected]:/home/admin/
ssh [email protected] '/usr/local/php82/bin/php /home/admin/migrate_posts.php'
รันเสร็จเก็บของด้วย

สคริปต์ที่ scp ขึ้นไปชั่วคราวกับไฟล์ dump ระหว่างทาง อย่าลืมลบทิ้งหลังจบงาน และในสคริปต์อย่า hardcode path แบบ Windows (d:\...) เข้าไป เพราะพอไปรันบน Linux มันจะกลายเป็นชื่อไฟล์แปลก ๆ ที่ตัวมอนิเตอร์ไฟล์ของเซิร์ฟเวอร์จับได้ — สั่งรันเป็นไฟล์ผ่าน stdin หรือ path แบบ Linux เท่านั้น

สรุปลำดับที่ผมใช้จริง

  1. map ตามลำดับ dependency: users -> tags -> discussions -> posts -> metadata อย่ายัดทีเดียว
  2. guest userid 0 -> NULL ด้วย NULLIF และปิด FOREIGN_KEY_CHECKS ระหว่าง bulk insert แล้วเปิดคืน + นับแถวเทียบ
  3. รหัสผ่าน: ยก hash + salt เดิมมาตรง ๆ ตั้ง old-passwords เป็น md5-double / salt-after ห้าม re-hash
  4. sticky/lock/approve ไม่มีใน core Flarum แล้ว -> flarum/sticky, flarum/lock, และ hidden_at (ซ่อนต้องแก้ทั้ง discussions และ posts)
  5. BBCode -> s9e XML ผ่าน formatter ของ Flarum เอง (เปิด flarum/bbcode + flush ก่อน) ทำเป็น batch ที่ resume ได้
  6. attachment attachfile=2 อยู่บนดิสก์ (.attach) ไม่ใช่ BLOB -> copy + ตั้งนามสกุลจาก filedata.extension -> ลง fof/upload
  7. รันบนเซิร์ฟเวอร์ scp ไฟล์แล้วสั่งรันเป็นไฟล์ อย่าใช้ heredoc และเก็บของชั่วคราวให้หมด

ส่วนตัวผมมองว่างานย้ายบอร์ดไม่ได้ยากที่ตัวข้อมูล แต่ยากตรงรายละเอียดเล็ก ๆ ที่ต่างกันระหว่างสอง schema นี่แหละ — จุดที่ทำ import ล้มมักไม่ใช่โพสต์สองแสนกว่า แต่เป็น userid 0 แถวเดียว หรือ salt ที่วางผิดตำแหน่ง ใครกำลังจะย้ายบอร์ดตัวเอง แนะนำให้ทำบน copy ของฐานข้อมูลก่อนเสมอ นับแถวเทียบสองฝั่งทุกชุด แล้วค่อยตัด over ตัวจริงตอนมั่นใจ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

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

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

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

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

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

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

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

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