同事的基於 WordPress 搭建的網站,因為數據越來越多,變得慢,我從 PHP slow log 里面看出是 WordPress 有些查詢總是很慢,即使已經安裝了頁面緩存插件,但是由於頁面眾多,命中率不高,所以加速效果也不明顯,而且由於界面經常改版,頁面緩存需要清空重新生成,進一步降低了緩存的效果。反正就是不流暢,有點慢。
看了下服務器配置雖然不高,但是也不至於打開一個一面要 4 秒鍾吧,而且 CPU 占用率奇高,雖然說升級硬件可以緩解,但根源還是程序效率的問題,所以不妨先趁性能出現問題的情況下,優化程序,解決程序的性能問題后,再升級服務器硬件,這樣效果才持久。
於是乎打算從表結構上作些優化。主要影響性能的,是兩張表:wp_postmeta、wp_term_relationships、wp_posts
先看一下最終結果:

可以看到 CPU 明顯下降了不少(那兩個劇烈波動的折線請忽略,跟本文無關)。
優化過程
先介紹一下本次優化涉及到的數據庫表結構:
業務和表的關系
內容類型 | 數據表 |
文章 | wp_posts |
頁面 | wp_posts |
自定義文章類型 | wp_posts |
附件 | wp_posts |
導航菜單 | wp_posts |
文章元數據 | wp_post_meta |
分類目錄 | wp_terms |
標簽 | wp_terms |
自定義分類法 | wp_term_taxonomy |
表之間的關系
數據表 | 存儲的數據 | 關聯到 |
wp_posts | 文章、頁面、附件、版本、導航菜單項目 | wp_postmeta (通過post_id關聯) |
wp_postmeta | 每個文章的元數據 | wp_posts (通過 post_id關聯) |
wp_term_relationships | 文章和自定義分類法之間的關系 | wp_posts (通過 post_id 關聯) wp_term_taxonomy (通過term_taxonomy_id 關聯) |
wp_term_taxonomy | 自定義分類法(包括默認的分類目錄和標簽) | wp_term_relationships(通過 term_taxonomy_id關聯) |
wp_terms | 關聯到分類法中的分類目錄,標簽和自定義分類項目 | wp_term_taxonomy (通過term_id 關聯) |
wp_postmeta 是查詢最慢的一張表,它存放文章/頁面/自定義內容(wp_posts)的元數據信息,所謂元數據,也包括如文章查看數、封面圖片,還有你自定義的字段。
按理說,一篇文章(wp_posts),對應 wp_postmeta 一行記錄,為啥會慢呢?原因是,WordPress 把 wp_postmeta 設計成了一張
縱表,而且沒有恰當的索引。
關於橫表和縱表,橫表是我們做項目最常用的,不清楚這個概念的朋友,看下面的的小實驗就明白了:
普通橫表 STUDENT_SCORE 有語文成績、英語成績等7個KPI指標,三個學生的三條記錄:
SQL> SELECT * FROM STUDENT_SCORE;
Id CHINESE_SCORE ENGLISH_SCORE MATH_SOCRE PHYSICAL_SCORE SPORTS_SCORE CHEMICAL_SCORE BIOLOGICAL_SCORE
----------- ------------- ------------- ---------- -------------- ------------ -------------- ----------------
10001 87.4 63 92 86 75 85 89
10002 91 89 98 62 76 82 73
10006 74 63 57 42 76 59 67
對應於
縱表/豎表,這三個學生的7個KPI指標需要21條記錄才能描述清楚:
SQL> SELECT * FROM STUDENT_SCORE;
Id FieldName Value
----------- --------------------- ----------
10001 CHINESE_SCORE 87.4
10001 ENGLISH_SCORE 63
10001 MATH_SOCRE 92
10001 PHYSICAL_SCORE 86
10001 SPORTS_SCORE 75
10001 CHEMICAL_SCORE 85
10001 BIOLOGICAL_SCORE 89
10002 CHINESE_SCORE 91
10002 ENGLISH_SCORE 89
10002 MATH_SOCRE 98
10002 PHYSICAL_SCORE 62
10002 SPORTS_SCORE 76
10002 CHEMICAL_SCORE 82
10002 BIOLOGICAL_SCORE 73
10006 CHINESE_SCORE 74
10006 ENGLISH_SCORE 63
10006 MATH_SOCRE 57
10006 PHYSICAL_SCORE 42
10006 SPORTS_SCORE 76
10006 CHEMICAL_SCORE 59
10006 BIOLOGICAL_SCORE 67
所以我們從這個小實驗中可以看到,
橫表轉成縱表/豎表,對應的記錄會翻倍增長,這對應於數據量大的表或寬表,都是一件不好的消息。很多時候,數據量上去了,性能問題就出來了。
分析得到 WordPress 從來是不會根據 meta_id 去查 postmeta 表的,都是根據 post_id 去查 post 的單個 meta 信息或者所有 meta key 和 value,所以原本的主鍵 meta_id 仍然保持自增(因為 的,它就僅僅是一個自增 ID)
提升性能的辦法是把 post_id 和 meta_key 改為主鍵,然后根據 post_id 做分區表,這樣,這樣有兩個好處,一是查詢時,可以根據 post_id 去讀區分區表的數據了,不用再全表查找了,另外是這倆字段組成唯一約束和索引了,查詢速度自然會加快,而原本的主鍵 meta_id 仍然保持自增,不會影響到原本的業務邏輯。
WordPress 默認沒有為 wp_postmeta 的表沒有設定 post_id 和 meta_key 的唯一約束,也就是說,是存在一個 post 再 postmeta 表有多個同樣的的 meta key 和 value 的情況的,我驗證了一下:
SELECT * FROM wp_postmeta pm WHERE meta_id NOT IN ( SELECT max(meta_id) FROM wp_postmeta pm2 where pm2.post_id=pm.post_id and pm2.meta_key=pm.meta_key ) SELECT distinct meta_key From wp_postmeta Group By post_id,meta_key Having Count(*)>1
返回內容大致如下:
/*
'_wp_old_slug'
'_thumbnail_id'
'_edit_lock'
*/
確實是這樣,但是看了下都是 WordPress 運行過程中產生的垃圾數據,是可以無副作用刪除的,那么此路是可行的。
好,那么,先先清理下垃圾數據:
DELETE FROM wp_postmeta WHERE meta_key = '_edit_lock'; DELETE FROM wp_postmeta WHERE meta_key = '_edit_last'; DELETE FROM wp_postmeta WHERE meta_key = '_revision-control'; DELETE FROM wp_postmeta WHERE post_id NOT IN (SELECT post_id FROM wp_posts); DELETE FROM wp_postmeta WHERE meta_key = '_wp_old_slug'; DELETE FROM wp_postmeta WHERE meta_key = '_revision-control'; DELETE FROM wp_postmeta WHERE meta_value = '{{unknown}}’;
然后,刪除掉重復的 meta key 和 value 記錄,僅保留最新的一個
DELETE FROM wp_postmeta WHERE meta_id IN ( select * from ( select meta_id FROM wp_postmeta pm WHERE meta_id NOT IN ( SELECT max(meta_id) FROM wp_postmeta pm2 where pm2.post_id=pm.post_id and pm2.meta_key=pm.meta_key ) ) as g1 )
這里存在一個問題,就是 WordPress 在開啟了文章的版本控制情況下,是存在插入重復 post 和 meta key 的情況的,數據庫改成唯一約束后會報錯,或者其它插件會這么做,解決辦法是,WordPress 里面 Hook 一下 add metadata 函數,insert 前先 check 是否已經 exists,另外就是數據庫里面加個 Trigger 做判斷,如果已存在,就更新。
數據清理完畢,那么可以開始建立分區表了
必須先 ADD UNIQUE(`meta_id`),才能 DROP meta_id 的 PRIMARY KEY。
ALTER TABLE `wp_postmeta` ADD UNIQUE INDEX `UNQ_meta_id` (`meta_id` ASC); ALTER TABLE `wp_postmeta` DROP PRIMARY KEY (`meta_id`);
再 DROP 掉 meta_id 的 UNIQUE,這是因為后面分區,要求 RANGE 分區列的UNIQUE INDEX 必須包含所有 primary key ,即任意 UNIQUE INDEX 都要包含 post_id,meta_key 分區函數列,否則分區函數是無法創建,會報錯誤:Error Code: 1503. A UNIQUE INDEX must include all columns in the table's partitioning function。
ALTER TABLE `wp_postmeta` DROP UNIQUE INDEX `UNQ_meta_id` (`meta_id` ASC); ALTER TABLE `wp_postmeta` ADD PRIMARY KEY (`post_id`, `meta_key`); ALTER TABLE `wp_postmeta` CHANGE COLUMN `meta_key` `meta_key` VARCHAR(255) NOT NULL , CHANGE COLUMN `post_id` `post_id` BIGINT(20) UNSIGNED NOT NULL ; ALTER TABLE `wp_postmeta` ADD UNIQUE INDEX `UNQ_post_id_meta_key` (`post_id` ASC, `meta_key` ASC),/* 這句可以加可以不加,因為已經是 PRIMARY KEY */
ADD UNIQUE INDEX `UNQ_meta_id_post_id_meta_key` (`meta_id` ASC, `post_id` ASC, `meta_key` ASC);
好了,先看下 post 表 id 的分布,我的是從 id 從 5萬到11萬,先給 posts 表分好區:
SELECT id FROM wp_posts order by id asc;
ALTER TABLE wp_posts PARTITION BY RANGE(id) ( PARTITION p0 VALUES LESS THAN (60000), PARTITION p1 VALUES LESS THAN (70000), PARTITION p2 VALUES LESS THAN (80000), PARTITION p3 VALUES LESS THAN (90000), PARTITION p4 VALUES LESS THAN (100000), PARTITION p5 VALUES LESS THAN (110000), PARTITION p6 VALUES LESS THAN MAXVALUE );
wp_postmeta 表,也如法炮制,這樣再查詢 post 的 meta,不但不用全表掃描,只用掃分區內的數據了,而且還可以走索引 :
ALTER TABLE wp_postmeta PARTITION BY RANGE COLUMNS(post_id,meta_key) ( PARTITION p0 VALUES LESS THAN (60000,MAXVALUE), PARTITION p1 VALUES LESS THAN (70000,MAXVALUE), PARTITION p2 VALUES LESS THAN (80000,MAXVALUE), PARTITION p3 VALUES LESS THAN (90000,MAXVALUE), PARTITION p4 VALUES LESS THAN (100000,MAXVALUE), PARTITION p5 VALUES LESS THAN (110000,MAXVALUE), PARTITION p6 VALUES LESS THAN (MAXVALUE,MAXVALUE) );
另外, 這個表的查詢也比較耗時,把 object_id,term_taxonomy_id 改為主鍵后,也分下區:
ALTER TABLE wp_term_relationships PARTITION BY RANGE COLUMNS(object_id,term_taxonomy_id) (
PARTITION p0 VALUES LESS THAN (60000,MAXVALUE),
PARTITION p1 VALUES LESS THAN (70000,MAXVALUE),
PARTITION p2 VALUES LESS THAN (80000,MAXVALUE),
PARTITION p3 VALUES LESS THAN (90000,MAXVALUE),
PARTITION p4 VALUES LESS THAN (100000,MAXVALUE),
PARTITION p5 VALUES LESS THAN (110000,MAXVALUE),
PARTITION p6 VALUES LESS THAN (MAXVALUE,MAXVALUE)
);
最后,順便根據 MySQL 的統計信息,對 MySQL 的性能參數做了適當的調整:
性能調整對應的參數表格:
增大了 sort_buffer_size ,使得原本【創建臨時表到磁盤】有 51%,增加 tmp_table_size 調整后降低到 29.36% 。
分區后,原本未緩存的頁面打開要 4s-5s,現在 2-3s 就可以打開啦。觀察一段時間再升級下服務器。
CPU 的使用率也下降了不少(那兩個劇烈波動的折線請忽略,那個是之前別的進程hang了,跟本次無關)。

然后找了個網站速度測試工具,輸入網址測試一下:
另外我本來是熟 SQL Server 數據庫優化的,MySQL 的數據庫優化其實一直都是以過去 SQL Server 優化經驗為指導的,有些地方可能存在盲區和不足,如果有還請指出,謝謝!