資料復原,phpBB 與 WordPress 的多代升級鏈
phpBB 從 2.0 升到 3.3.15 跨 5 個 major version、WordPress 從 1.5 升到 6.9 跨十多個 schema 版本。中文編碼、附件路徑、prefix 差異、私版破圖救援,每一步怎麼做、怎麼證明資料沒掉。系列文 5 篇之 #3。
兩個資料層、四個 schema 版本、3,159 個附件
#2 那篇講了「怎麼自己做」的工作節奏與委託邊界。實際做下去,第一個要解決的就是資料復原這一塊,這也是這 6 週裡 debug 量最大的環節。
這個專案要救的有兩個資料層。一個是兩個 phpBB 站,eq2.3d-olg.net(公會主站)和 eq.3d-olg.net(EQ 1 那邊的舊站),兩站都從 phpBB 3.0.14 升到 3.3.15。另一個是 WordPress archive,eq2 域名根目錄底下的部落格區,從 2015 年 dump 出來的 1.5 / 2.7 時代資料庫升到 6.9.4。
這個 dump 有 14 篇 2008 到 2011 年的 publish post、12 個 user、12 個 comment 加 7 個 link,附件那邊兩個 phpBB 站合計 3,159 個檔案,從螢幕截圖、地圖標記到自製攻略 PDF 都有。整個資料復原工程的目標是把這些東西完整搬到新環境、不丟一筆、不錯一個編碼,最後在新 VPS 上跑得起來。
phpBB 跨大版本升級鏈
phpBB 不像 WordPress 可以一個 image 直接吞舊資料庫,跨大版本升級必須照官方升級路徑逐版跑 migration,3.0 → 3.1 → 3.2 → 3.3 一步都不能跳。每一版的 migrations/ 目錄裡都有對應的 schema 變更腳本,包括 ACP 設定 key 的 rename、權限模型變更、ucp profile field 結構調整等等。跳級的話 migrator 找不到 baseline 會直接 abort。
eq2 站照官方路徑跑了完整的 3.0.14 → 3.1.12 → 3.2.11 → 3.3.15 四段升級,每一段都在本機 docker 環境跑、跑通才進下一段。eq 站體量小很多(10 倍少於 eq2),可以直接從 3.0 跳到 3.3,888 個 migrations 在 30 秒內跑完,沒有觸發任何資料層的副作用。
升級過程踩到的第一個小坑是官方 installer。phpBB 的 CLI installer 在 PHP 8 加 mysqli 環境下會卡在某個 metadata check 階段,跑超過 15 分鐘還沒結束。我繞過 installer,寫了一個 run-migrator.php 直接呼叫 $migrator->update(),4 秒內整個 migration chain 跑完。這個小腳本後來在每次 phase 切換都用得到。
第二個小坑是兩站的 prefix 不同。eq2 用 phpbb3_ 開頭(phpBB 3 時代命名),eq 用 phpbb_(phpBB 2 時代沿用)。這個差異在我寫任何跨站 SQL 時都要記得切換,後來我把它寫進專案 memory,每次 Claude Code 開新 session 都會自動讀到。沒寫進 memory 之前已經被坑過幾次,跑下去 0 row affected 才發現是 prefix 沒對。
中文編碼這條老坑
中文編碼是 phpBB 2 時代留下來最具歷史風味的問題。phpBB 2 預設用 latin1 charset 加 BIG5 collation,內容是中文但 MySQL 不知道,所以 SELECT 出來的 byte 直接 cast 成 latin1 變亂碼。phpBB 3.0 開始全面改 UTF-8,升級過程必須做一次 charset 轉換,把所有 user_post / post_subject / pm_text 欄位的 byte 重新解碼為 BIG5 再編碼為 UTF-8。
實作上 phpBB 官方 convertor 會幫忙處理,但那個 convertor 是 phpBB 2 → phpBB 3.0 的版本,假設源資料的 charset 是某個固定值。我們站的歷史更複雜一些,中間有兩段時期分別用過 BIG5 和 GBK,要先做 sniff 才知道哪些 row 走哪條 decode 路徑。後來我們的處理方式是先 mysqldump 出來、用 Python 腳本逐 row 嘗試解碼、確認出 charset 之後再批次轉。這個過程花了大概兩個晚上。
驗證階段用 iconv -f utf-8 -t utf-8 跑一遍 dump 檔,exit code 0 表示整份檔案都是 valid UTF-8 sequence,沒有殘留的 latin1 byte。再隨機抽 10 筆中文 post 比對 hex dump 跟原始 phpBB 2 站上看到的字符一致。這個 hex 比對在後面的 WordPress 升級鏈也用上了,是中文資料完整性的最終 gate。
WordPress 升級鏈為什麼必須走三段
WordPress 跟 phpBB 一樣不能跳級升,但麻煩的是它跨度大太多。我們的源 dump 是 2015 年從 Bluehost 抓下來的 wp_* 表,schema 版本對應大約 WP 2.7。要升到 6.9 跨 4 個 major version 加幾十個 minor version,整條 migration chain 包含上百個 schema 變更腳本。
實際做的時候我把它切成 4 段,每段用一個對應 image 跑 migration,跑完 dump 出來當下一段的 input。順序是 2.7 → 4.8 → 5.0 → 6.0 → 6.9。為什麼要在這幾個版本切?因為它們各自處理的事情不同。
第一個切點是 4.8。WordPress 4.2 引入 wp_convert_to_utf8mb4() 函式,把所有 utf8 欄位轉成 utf8mb4,這是 emoji 與更廣的 Unicode 字符(包含某些中文罕用字)能存進來的關鍵。這個轉換只要 schema 版本 < 31536 升上去就會自動觸發,但 Mac arm64 上 WordPress 官方 image 最舊只到 4.8(4.2 / 4.5 / 4.6 / 4.7 都沒 arm64 版本),所以實作切點放 4.8。從 2.7 跳 4.8 等價走過 utf8mb4 conversion,目的達成。
剩下的切點 5.0、6.0、6.9 處理各自時代的 schema 變更,包含 user meta 結構、auto-saved post draft、Gutenberg block 的 metadata、site health check 表等等。每段升完都跑 iconv 確認 UTF-8 還是乾淨的,再 hex dump 14 篇 post 的 post_content 跟前一段比對,確認 CJK byte 沒被改寫。
Volume 掛載陷阱
WordPress 升級鏈跑到一半我踩了一個很討厭的雷,跟 Docker volume 有關。
最初的設計是把 wp-core 程式碼放在一個 volume 裡叫 wp_html,每段升級切 image 的時候 volume 不動、image 變,這樣 WordPress core 跟著 image 升、user content 留在 volume 裡。聽起來合理,跑下去就壞。
問題在於 WordPress 6.9 image 的 entrypoint 啟動時會檢查 /var/www/html/wp-includes/version.php,如果版本對不上就會把整個 wp-core 寫成 image 自帶的版本(6.9.4)。所以我先掛 wp_html 跑 4.8,wp-core 是 4.8 沒問題,dump 之後切到 6.9 image,entrypoint 第一件事就把 wp_html 裡的 wp-core 從 4.8 蓋成 6.9.4。下一輪要回來跑 5.0 image 時,wp_html 已經是 6.9.4 的 core,5.0 image 的 PHP 5.6 直接 parse error 拒絕啟動。
修法是放棄共用 volume 的設計。每段升級的 image 用 ephemeral wp-core(image 自帶、container 起來自己用),只有最後 6.9 用 wp_html 把 wp-core 持久化下來。資料庫部分一直在 mysql_data volume 裡跨段共用,因為 mysql 不會被 entrypoint 主動覆蓋。
這個雷我認真 debug 了大半天才搞清楚,因為錯誤訊息(PHP 5.6 parse error)跟根因(6.9 entrypoint 寫過了 wp_html)距離很遠,要 trace 兩層 image 啟動流程才看得到。
WordPress 的取捨,14 篇老文還是 2 篇新文
WordPress 這塊本來有兩個方案。X 方案是承接 Bluehost 現況,那邊的 DB 用 Bluehost cPanel 自動產的隨機 prefix(不是 default wp_),有兩篇 2024 年的 post 加 308 MB 的 plugin 群(Akismet、Yoast、Jetpack 之類)。Y 方案是還原 2015 年的 dump,default wp_* prefix,14 篇 2008 到 2011 年的老 post、12 個 user、12 個 comment、7 個 link,無 plugin。
兩個方案的內容完全不重疊,要選一個放棄一個。我選了 Y 方案。
理由是這個 archive 的核心保存價值在於那 14 篇 2008 到 2011 年的老 post,那段時期我用 WordPress 寫過一陣子個人觀察、遊戲心得、社群動態,而 2024 年那兩篇近期文純粹是測試性質、沒實質內容。Plugin 群更不該保留,Akismet 的反垃圾規則早就過期、Yoast 的 SEO 設定針對的是已停更的論壇生態、Jetpack 那堆 widget 全部是 2014 年代設計風格,留下來只會在 6.9 環境出 deprecation warning 跟 PHP 8 fatal。
選 Y 方案還有一個工程好處,default wp_ prefix 跨工具相容性最高,wp-cli、官方升級流程、所有 third-party 文件都假設是 wp_。換 prefix 是個自找麻煩的事,能避則避。
代價是 2024 那兩篇近期文丟了,但因為它們是測試文,這個代價可以接受。
一致性驗證怎麼做
跨幾段升級之後最關鍵的問題是「資料真的沒掉嗎?」靠肉眼看是不可能的(光附件就 3,159 個),所以我建立了一套自動化的 parity gate 在每個 phase 末尾跑。
第一層是 SQL count。phpBB 兩站的 phpbb_posts / phpbb3_posts、phpbb_users / phpbb3_users、phpbb_attachments / phpbb3_attachments 各自的 row count 升級前後必須一致。WordPress 那邊的 wp_posts WHERE post_type='post' AND post_status='publish' 必須是 14、wp_users 必須是 12、wp_comments 必須是 12、wp_links 必須是 7。
第二層是附件數對齊。find eq2/community/files -type f | wc -l 期望值 3,159,跟 SELECT COUNT(*) FROM phpbb3_attachments 必須對得上。沒對上的話有兩種可能,要嘛附件實體檔少於 DB 紀錄(檔案在 Bluehost 抓下來時掉了),要嘛 DB 有 orphan row(附件被刪了但 row 沒 cascade)。任何一邊偏移都要追到底。
第三層是 SHA256 byte-for-byte 比對。對 14 篇 WordPress publish post 的 post_title / post_date / post_content 三個欄位連起來算 SHA256,新 production 跟 2015 baseline dump 必須完全一致。任何一個 byte 不一樣(比如 charset 解碼錯一個字、或 search-replace 不小心動到了實際內容)SHA256 就會炸。
第四層是 URL 殘留檢查。WordPress 升級過程中我們的 siteurl 從 http://eq2.3d-olg.net/wordpress/ 改到 https://eq2.3d-olg.net/,post_content 裡所有舊 URL 也要跟著改。wp search-replace 跑了三輪共 65 加 46 個 replacements,最後查 LIKE '%http://eq2.3d-olg.net/wordpress/%' 必須回 0 row。
四層都綠了才算這一段升級完成。每段都跑、不只最後跑,因為錯誤越早發現越好 debug。
一個沒料到的小細節,user_lang=en_us
phpBB 3.3 的語系包命名是 en 和 zh_cmn_hant,但我們 eq2 站有 16 個 user 的 user_lang 欄位是 en_us。這是 phpBB MOD 時代的命名遺跡,當時某個 MOD 用了 en_us 而不是官方的 en。
phpBB 3.3 的 fallback 邏輯是 user_lang 找不到就用 default_lang,預設是 zh_cmn_hant,所以這 16 個 user 登入後會看到中文介面,乍看沒問題。但會觸發另一個問題,PHP 8 加 user_lang=en 訪問 ucp 會踩到一個 cosmetic 的 [phpBB Debug] Undefined array key DATETIME_FORMAT warning,本來不會被觸發,現在因為 fallback 路徑多繞一圈被觸發了。
修法是直接 UPDATE,把 16 個 en_us 改成 en。順手做的決定,但讓我意識到 MOD 時代留下來的命名與設定假設一個個都要 audit,越早發現越省事。
私版破圖救援
附件那邊還有一個 edge case 值得記下來。
WordPress 的 14 篇 publish post 裡有兩篇引用了 phpBB 私版的 attachment,分別是 post 4 和 post 6 引用了 forum_id 6(公會成員友誼廳)的兩個圖片。這個論壇對訪客 by design 拒絕下載,所以從 WordPress 渲染出來的 <img> tag 即使路徑對也會 403。
修法是手動把這兩個圖片從 phpBB 站 docker container 內拷貝到 WordPress 的 uploads 目錄,路徑改成 wp-content/uploads/private-archive/,再用 wp search-replace 改 post_content 的 src。這樣 WordPress 那邊看得到、phpBB 站那邊權限照舊。
剩下 9 篇 post(3、7、9、15、47、56、59、67、73)引用的是已經消失的 wp-content/uploads/,Bluehost 那邊在 2026 年 4 月抓 snapshot 的時候就已經是空骨架,Wayback Machine 對 eq2.3d-olg.net 整 domain 0 個 snapshot,永久救不回來。處理方式是在 Kubrick 主題的 footer.php 加一段 inline <script>,偵測 <img> 的 onerror 事件後動態替換成一個米色背景加棕色虛線邊框、寫著「圖片已遺失」加 alt 截前 40 字的 inline SVG。視覺上不是空白破圖、語意上有交代圖片原本的描述。
JS 那段加了 { once: true } 跟 dataset.kbPlaceholder 雙重 guard 防 infinite loop(替換後的 SVG 又觸發 onerror 變成永遠跑)。一個 12 行的 script,花了我大半小時 debug 兩個版本才穩。
接下來
資料層接好了之後下一步是基礎設施。系列文 #4「部署架構」會處理 Docker Compose 三服務、Traefik path-based routing 一個 domain 分流到三站、Resend SMTP relay 怎麼繞過 DigitalOcean 封 port、MySQL 8.4 in-place 升級,那是這 6 週裡架構決策最多的環節。
天地日月公會論壇現在站在 eq2.3d-olg.net/community 與 eq.3d-olg.net。系列文 5 篇之 #3,下一篇 #4 進部署架構。
留言討論