Redis 被 OOM 殺掉怎麼辦?WordPress 共用 Redis 吃光記憶體與 Swap 的排查與修復 - Mr. 蔡大痣數位轉型顧問 - WordPress網站架設 及 SEO 專家

Redis 被 OOM 殺掉怎麼辦?WordPress 共用 Redis 吃光記憶體與 Swap 的排查與修復

827 瀏覽
2026-11-01 更新

同一台主機跑了多個 WordPress 站,object cache 全部接到同一台 Redis。某天下午所有站的後台都變慢,log 開始出現 Connection refused,一查才發現 Redis 已經停了好幾個小時,systemctl status 寫著 failed (Result: oom-kill)。這篇整理這次從斷線、OOM、Swap 一路追到根因的過程,以及最後怎麼把 Redis、核心參數與 PHP-FPM 的記憶體重新配好。

先講結論:Redis 沒設 maxmemory、又開著 RDB 存檔,資料長到 5 GB 後每次 fork 存檔都可能再多吃一份記憶體。主機被逼進 Swap,最後 OOM killer 出手,Redis 就整個停了。修法是設上限、選對淘汰策略、關掉純快取用不到的 RDB,再讓 systemd 被殺時會自動拉起來。

症狀:所有站同時連不到 Redis

Redis 只當 object cache 時,它掛掉不一定會讓網站直接 500,但每一頁都會回頭打資料庫,後台與 WooCommerce 結帳會明顯變慢。先從應用端確認是不是 Redis 本身的問題:

# 密碼放環境變數,避免出現在 ps 與 shell history
export REDISCLI_AUTH='你的密碼'
redis-cli -h 10.0.0.10 -p 6379 PING

systemctl status redis --no-pager
journalctl -u redis -n 100 --no-pager
ss -lntp | grep 6379
錯誤訊息代表意思
Connection refused主機到得了,但 6379 沒有程序在聽:服務停了,或 bind 不到那個 IP。
Connection timed out網路或防火牆擋住,或 Redis 卡住沒回應。
NOAUTH/WRONGPASS服務正常,是密碼或 ACL 問題。
LOADING剛啟動,正在從磁碟載入資料。

這次是 Connection refused,而 systemctl status 明確寫了 Result: oom-kill,方向就很清楚了:Redis 是被核心的 OOM killer 殺掉的。

OOM killer 是什麼?為什麼偏偏殺 Redis

OOM 是 Out Of Memory。當實體記憶體加 Swap 都不夠用,Linux 核心會依每個程序的 oom_score 挑一個「犧牲品」強制結束,用來保住整台主機。分數主要看程序佔用多少記憶體,所以平常就吃最多記憶體的 Redis、MySQL 往往最先中槍。

dmesg -T | grep -iE 'oom|killed process' | tail
journalctl -k --since '-7 days' | grep -i 'killed process'

看 log 時要分清楚兩行:xxx invoked oom-killer 的 xxx 只是「剛好在申請記憶體、觸發 OOM 的程序」,常常是某個 php-fpm worker;真正被殺的要看 Killed process <pid> (<name>)。

這次被殺的 PID 不是 Redis 主程序,而是 RDB 存檔時 fork 出來的子程序。但 systemd 的服務預設 OOMPolicy=stop:服務內任何一個程序被 OOM 殺掉,整個服務都會被停掉。再加上 unit 沒設 Restart,Redis 就一直停在那裡,直到有人發現。

不要用 RSS 加總找元兇:PHP-FPM 會被嚴重高估

OOM 時核心會印一張程序表,裡面有每個程序的 RSS。我一開始把同名程序的 RSS 加起來:php-fpm 合計 33 GB、Redis 2.8 GB,直覺就認定是 PHP-FPM 開太多 worker。但這台主機總共才 16 GB,33 GB 顯然不合理。

問題在於 RSS 會把共享記憶體重複計算。PHP-FPM 的每個 worker 都映射同一份 OPcache 與共用函式庫,80 個 worker 就把同一塊記憶體算了 80 次。改用 PSS(Proportional Set Size,共享頁依共用程序數平均分攤)才是實際佔用:

# 依程式名稱加總 PSS(MiB)
for name in php-fpm mysqld redis-server; do
  total=0
  for pid in $(pgrep -x "$name"); do
    kb=$(awk '/^Pss:/{print $2}' /proc/$pid/smaps_rollup 2>/dev/null)
    total=$(( total + ${kb:-0} ))
  done
  echo "$name $(( total / 1024 )) MiB"
done

改看 PSS 之後,80 個 php-fpm worker 合計只有約 6 GB;Redis 的 used_memory 則已經長到 5 GB,而且還在漲。真正的問題是 Redis。

根因:四個預設值疊在一起

redis-cli -h 10.0.0.10 INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy'
redis-cli -h 10.0.0.10 CONFIG GET save
redis-cli -h 10.0.0.10 INFO keyspace
sysctl vm.overcommit_memory
free -h
設定當時的值造成的影響
maxmemory0(不限制)快取只增不減,會一直長到把主機記憶體吃光。
maxmemory-policynoeviction就算設了上限,滿了也不淘汰舊資料,而是讓寫入直接回錯誤。
RDB(save)開啟定期 fork 子程序寫檔。fork 期間資料一被改寫就會複製一份(copy-on-write),寫入頻繁時最壞接近兩倍記憶體。
vm.overcommit_memory0Redis 官方建議設 1,否則記憶體吃緊時背景存檔的 fork 可能失敗。

Swap 為什麼一直偏高也就說得通了:Redis 一直長大,核心只好把比較少用的頁面換到 Swap。Redis 的資料一旦有部分在 Swap,查詢延遲會從微秒級變成磁碟 I/O 等級,WordPress 反而比不用快取還慢。Swap 用掉一半以上,就是記憶體長期不足的訊號,不能只當成「還有空間」。

Step 1:先確認每個 DB 都只有快取資料

接下來要把淘汰策略改成 allkeys-lru,也就是滿了就從所有 key 中淘汰最久沒用的。maxmemory 是整個 Redis instance 共用的上限,淘汰會橫跨 db0 到 db15,不會只動某一個 DB。如果同一台 Redis 還放了 session、佇列或任何不能遺失的資料,改之前一定要先分開。

# 用 SCAN 抽樣,不要在正式環境下 KEYS *
for n in 0 1 2 3 4 5 6; do
  echo "== db$n"
  redis-cli -h 10.0.0.10 -n $n --scan --count 1000 | head -5
done

WordPress 的 Redis Object Cache 外掛通常會以網域或 WP_REDIS_PREFIX 當 key 前綴。每個 DB 抽到的都是這類快取 key,才能放心使用 allkeys-*。

Step 2:依序調整 policy、RDB、maxmemory

順序很重要。如果在 noeviction 下先把 maxmemory 設得比目前用量低,Redis 會立刻拒絕所有寫入,等於讓全部站台的快取寫入失敗。所以要先改淘汰策略,再停 RDB,最後才設上限:

redis-cli -h 10.0.0.10 CONFIG SET maxmemory-policy allkeys-lru
redis-cli -h 10.0.0.10 CONFIG SET save ""
redis-cli -h 10.0.0.10 CONFIG SET maxmemory 2816mb

# 讀回確認
redis-cli -h 10.0.0.10 CONFIG GET maxmemory
redis-cli -h 10.0.0.10 CONFIG GET maxmemory-policy
redis-cli -h 10.0.0.10 INFO stats | grep evicted_keys

套用後 Redis 會立刻依 LRU 淘汰超出上限的部分,這段時間各站 cache miss 會變多,屬於正常現象,幾分鐘到幾十分鐘內就會回穩。

CONFIG SET 只改線上值,Redis 重啟後就會回到設定檔裡的舊值。要寫回 /etc/redis/redis.conf:

# 先註解掉原本的同名設定,再在檔尾加上
maxmemory 2816mb
maxmemory-policy allkeys-lru
save ""

也可以用 CONFIG REWRITE 讓 Redis 自己回寫設定檔。但它會重新整理整份檔案的格式,習慣在設定檔裡寫註解的話,手動改比較好掌控。改之前記得先備份原檔。

Step 3:把 vm.overcommit_memory 設成 1

sysctl -w vm.overcommit_memory=1
echo 'vm.overcommit_memory = 1' > /etc/sysctl.d/99-redis.conf
sysctl vm.overcommit_memory

這是 Redis 官方管理文件明確建議的設定,Redis 啟動時如果是 0,log 也會印出警告。就算這台 Redis 已經關掉 RDB,BGREWRITEAOF、手動 BGSAVE 或之後重新開啟持久化時都還會 fork,設成 1 比較保險。

Step 4:讓 systemd 自動重啟,並降低 Redis 被選中的機率

不要直接改 /usr/lib/systemd/system/redis.service,套件更新時會被覆蓋。改用 drop-in:

mkdir -p /etc/systemd/system/redis.service.d
cat > /etc/systemd/system/redis.service.d/override.conf <<'EOF'
[Service]
Restart=on-failure
RestartSec=5s
OOMScoreAdjust=-900
EOF
systemctl daemon-reload

# 不重啟 Redis,直接讓執行中的主程序生效
echo -900 > /proc/$(systemctl show -p MainPID --value redis)/oom_score_adj
systemctl show redis -p Restart -p OOMScoreAdjust

這裡有個小陷阱:用 pgrep redis-server 抓 PID 時,如果剛好在做背景存檔,會抓到兩個 PID,其中一個是 fork 子程序。要用 systemctl show -p MainPID 拿主程序。OOMScoreAdjust 範圍是 -1000 到 1000,-900 代表「除非真的沒得選,不然別殺它」。不建議設成 -1000 完全豁免,記憶體真的耗盡時核心需要有東西可以殺。

Step 5:用 PSS 重新抓整台主機的記憶體預算

Redis 設好上限只解決了一半,另一半是確保 PHP-FPM、MySQL、Redis 加起來不會超過實體記憶體。以這台 16 GB 主機為例,我用的分配方式是:

項目比例約略數字說明
系統保留10%(至少 1 GB)1.6 GBkernel、page cache 與突發用量。
MySQL15%2.4 GBinnodb_buffer_pool_size 取其中約 70%。
Redis20%3.2 GB關閉 RDB 時 maxmemory 取 90%(約 2.8 GB);開著 RDB 只取 60%,留空間給 fork。
PHP-FPM其餘約 8.7 GB除以每個 worker 的平均 PSS,就是 pm.max_children 的上限。

以實測每個 worker 平均 PSS 約 80 MB 計算,PHP-FPM 的預算可以容納約 105 個 worker,目前設定的 80 在安全範圍內。但最大的 worker 長到 155 MB,若 80 個都長到這麼大會需要 12 GB 以上。worker 處理越多請求越容易膨脹,可以把 pm.max_requests 設在 500 左右,讓 worker 定期回收重開。

這套比例不是標準答案。MySQL 負載重的主機可以把 MySQL 比例拉高;Redis 若獨立到另一台主機,這台就能把 20% 還給 PHP-FPM。重點是每一項都有明確上限,而且加總後還留有餘裕。

修復後怎麼驗證

# Redis 線上值與設定檔是否一致
redis-cli -h 10.0.0.10 INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy'
redis-cli -h 10.0.0.10 CONFIG GET save
grep -E '^(maxmemory|maxmemory-policy|save)' /etc/redis/redis.conf

# 淘汰是否正常、命中率有沒有掉太多
redis-cli -h 10.0.0.10 INFO stats | grep -E 'evicted_keys|keyspace_hits|keyspace_misses'

# 核心與 systemd
sysctl vm.overcommit_memory
systemctl show redis -p Restart -p OOMScoreAdjust
cat /proc/$(systemctl show -p MainPID --value redis)/oom_score_adj

# 觀察幾天的 Swap 趨勢
free -h

這次修完後,Redis 的 used_memory 穩定在 2.8 GB 上下,evicted_keys 持續增加,代表 LRU 有在運作。Swap 不會立刻歸零,被換出去的頁面要等程序重新存取或重啟才會回到記憶體,觀察一週的趨勢比看單一時間點準確。

補上監控:不要等客戶回報才發現 Redis 停了

這次最大的教訓是 Redis 停了好幾個小時沒人知道。現在的做法是每分鐘用 cron 跑一支檢查腳本,邏輯很簡單:

  • PING 連續失敗 2 次才告警,避免網路抖一下就發通知。
  • 仍在斷線時每 30 分鐘提醒一次,恢復時也發一則通知。
  • 順便檢查 maxmemory 是否又變回 0、rejected_connections 是否增加、rdb_last_bgsave_status 是否失敗。
  • 主機沒有安裝 redis-cli 就自動略過,同一份 crontab 可以套用到每台主機。

通知可以接 Slack Incoming Webhook 或任何你慣用的告警管道。cron 搭配 flock -n 避免前一次還沒跑完又重疊執行。WordPress 主機排程的整理方式可以參考如何禁用 WP-Cron 改用系統 Cron。

常見問題 FAQ

結論:快取也需要上限

Redis 的預設值是為「通用資料庫」設計的:不設上限、不淘汰、定期存檔,資料一筆都不能少。但 WordPress 的 object cache 本來就是可以丟掉的資料,沿用這些預設值,時間一久就會變成整台主機最大的記憶體風險。把 Redis 當成真正的快取來設定:有上限、會淘汰、不存檔、被殺會自己起來,再用 PSS 替每個服務抓好預算,就能避免同一個問題再發生。長期來看,70 多個站共用的 Redis 最好獨立到自己的主機,讓它不必和 PHP-FPM 搶記憶體。

參考資料

常見問題

WordPress 的 Redis object cache 應該用哪個淘汰策略?

如果 Redis 只存快取,建議 allkeys-lru,滿了就淘汰最久沒用的 key。前提是同一個 instance 沒有 session、佇列等不能遺失的資料,因為 maxmemory 與淘汰是整個 instance 共用,會橫跨所有 DB。

Redis 只當快取,可以關掉 RDB 嗎?

可以。快取資料遺失只會讓網站暫時回頭查資料庫,關掉 RDB 可以避免每次存檔 fork 時的 copy-on-write 記憶體尖峰,也能把 maxmemory 設得比較寬。重啟後快取會從空的開始重新累積。

OOM 日誌裡 invoked oom-killer 的程序就是元兇嗎?

不一定。invoked oom-killer 的程序只是剛好在申請記憶體時觸發 OOM,真正被殺的要看 Killed process 那一行。要找出長期佔用記憶體的程序,請用 PSS 而不是把同名程序的 RSS 相加。

為什麼 Redis 的 fork 子程序被殺,整個 Redis 服務都停了?

systemd 服務預設 OOMPolicy=stop,服務內任何程序被 OOM killer 結束,整個服務都會被停止。若 unit 沒有設定 Restart,服務就不會自動恢復,需要用 drop-in 加上 Restart=on-failure。

Swap 用很多,直接加大 Swap 可以解決嗎?

不能根本解決。Swap 只是延後 OOM 發生,Redis 與 PHP-FPM 的資料被換到磁碟後延遲會大幅增加,網站反而變慢。應該先替 Redis、MySQL、PHP-FPM 設定明確上限,讓加總低於實體記憶體。

▧ 文章分類

▧ Google熱搜

▧ 最新文章

✦ 虎鯨 OrcaBiz SEO 優化專業團隊 ✦

專業 SEO 公司幫助你將流量累積成看得見的業績,成為長期有效的最強業務!

載入中…
沒有更多相關文章可閱讀