暂未播放
0:00
0:00

给博客写了个缓存预热脚本

2997 字
15 分钟
给博客写了个缓存预热脚本

站点现在部署在 EdgeOne 上,免费版。免费版没有缓存预热 API。CreatePrefetchTask 要个人版往上才有,白嫖党用不了。

但我手里有台轻量云服务器能稳定公网出访。预热说白了就是让边缘节点先把内容缓存上——官方不给我接口,我自己模拟一次访问不就完了?从 sitemap 把页面 URL 全拉出来,逐个走公网 GET 一遍,请求打到 EdgeOne 边缘,没命中就回源,回源完边缘就把这份内容存下来,下一次真实访客来就是 HIT,道理就这么朴素。

缺点是预热只能预热服务器周围的节点,再远一些就没办法了。

怎么模拟”预热”#

其实就一个动作:curl 公网 GET 一遍。得强调”公网”——本地 curl 127.0.0.1 或者内网地址根本不经过 EdgeOne,缓存自然填不上。所以 SITE 必须填对外域名 https://x1anyu.cn,脚本里还专门做了去末尾斜杠的防呆,并且把 . 转义成正则,后面过滤本站 URL 用得上。

单次请求我用 req 包了一层:带超时、失败指数退避重试,再从响应头抠出 EO-Cache-Status 判断命中情况。

Terminal window
req() {
local url="$1" out="${2:-/dev/null}"
local attempt=0 code=000 cache="UNKNOWN" wait=1
local hdr="$TMPDIR/hdr.$$"
while :; do
: > "$hdr"
code="$(curl -sS --compressed -m "$TIMEOUT" -A "$UA" \
-D "$hdr" -o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000)"
cache="$(grep -i '^EO-Cache-Status:' "$hdr" 2>/dev/null \
| head -n1 | sed -E 's/^[^:]+:[[:space:]]*//; s/\r$//')"
[ -z "$cache" ] && cache="UNKNOWN"
if [ "${code:0:1}" = "2" ] || [ "${code:0:1}" = "3" ]; then
echo "$code|$cache"; return 0
fi
attempt=$((attempt+1))
[ "$attempt" -gt "$RETRY" ] && { echo "$code|$cache"; return 1; }
sleep "$wait"; wait=$((wait*2))
done
}

缓存状态看响应头里的 EO-Cache-Status,值是 HIT / MISS / RefreshHit 这类。首次预热 MISS 是正常的——本来就是去填缓存的;跑完再打一遍才是看命中率的时机,后面说。

页面预热完,资源也得管#

光预热 HTML 不够。页面里引用的 css/js/图片/字体,第一次访问时也照样回源。所以每拉完一个页面,我把响应体里本站的静态资源地址全抠出来,先收集,最后统一去重再预热一遍。公共 css/js 全站就一份,去重后只拉一次,省得重复打。

一个真坑:别把文章里的文件名当成资源#

从 HTML 抓资源 URL,最早我用的是”抓所有带引号的路径字符串”,结果翻车了——文章代码块里写的示例文件名(比如 wxbot.js)会被当成真资源去请求,平白多发一堆 404。

后来改成只认 href= / src= / srcset= 这几个真实属性,再叠一道扩展名白名单和”只收本站路径”的过滤。这么一缩范围,正文和代码块里纯文字提到的文件名自然就进不来了。提取逻辑现在是这样的:

Terminal window
extract_assets() {
local html="$1"
{
grep -oE '(href|src)="[^"]+"' "$html" | sed -E 's/^(href|src)="//; s/"$//'
grep -oE 'srcset="[^"]+"' "$html" | sed -E 's/^srcset="//; s/"$//' | tr ',' '\n' | awk '{print $1}'
} | while read -r u; do
[ -z "$u" ] && continue
u="${u%%\#*}"; u="${u%%\?*}"
case "$u" in
//*) u="https:$u" ;;
/*) u="$SITE$u" ;;
http*) ;;
*) continue ;;
esac
case "$u" in "$SITE"*) ;; *) continue ;; esac
case "$u" in
*.css|*.js|*.png|*.jpg|*.jpeg|*.webp|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.otf|*.avif)
echo "$u" ;;
esac
done
}

脚本的几样功能#

  • 并发:默认 CONCURRENCY=4,用 xargs -P 拉起。想串行就设 1。
  • 重试:RETRY=2,失败指数退避(1 秒、2 秒…),网络抖一下不至于整批挂掉。
  • 统计:跑完按 EO-Cache-Status 汇总命中分布,顺手把非 2xx 的 URL 单独列出来,方便一眼看出哪些没预热成功。
  • 二次验证:VERIFY=1 会等几秒让边缘生效,再把所有请求过的 URL 打一遍,统计 HIT 占比——这是检验”预热到底填上没有”最直接的方式。
  • 干跑:DRY_RUN=1 只列 URL 不发请求,先确认采集到的页面数对不对,再真跑。

所有配置项都能用环境变量覆盖,比如只预热前 20 条、并发 2:

EO_WARM_LIMIT=20 EO_WARM_CONCURRENCY=2 bash /opt/eo-warm.sh

缓存状态是 UNKNOWN 不一定是你的问题#

日志里 EO-Cache-Status 出现 UNKNOWN,说明那个请求根本没拿到缓存头。两种情况都不算脚本错:要么域名没解析到 EdgeOne(走的是别的回源),要么那个资源配置成了不缓存。脚本只是如实记下来,得你自己对着 DNS 和 EO 规则查。非 2xx 的 URL 我单独列了明细,缓存状态分布里也能看清有多少 MISS,方便定位。

完整脚本#

挂在 1Panel 的「计划任务 → Shell 脚本」里跑,bash /opt/eo-warm.sh 就行。完整版如下:

#!/usr/bin/env bash
# eo-warm.sh —— EdgeOne(免费版)缓存预热脚本(含缓存命中统计 + 二次验证)
#
# 原理:EdgeOne 免费版无预热 API,用「模拟真实访问」代替。
# 从 sitemap / robots.txt / 本地列表取 URL,走公网 GET 一遍,
# 命中边缘未命中则回源,边缘缓存被填充;页面引用的本站静态资源顺带预热。
# 通过响应头 EO-Cache-Status 判断 HIT / MISS / RefreshHit。
#
# 用法:
# bash /opt/eo-warm.sh
# EO_WARM_LIMIT=20 EO_WARM_CONCURRENCY=2 bash /opt/eo-warm.sh
# EO_WARM_DRY_RUN=1 bash /opt/eo-warm.sh # 只列 URL
# EO_WARM_VERIFY=1 bash /opt/eo-warm.sh # 预热后再跑一遍验证命中率
set -u
###################### 配置:直接改这里的值即可 ######################
SITE="https://x1anyu.cn" # 站点地址,不带末尾斜杠;必须走公网域名
SITEMAP="/sitemap-index.xml" # sitemap 入口
ROBOTS="/robots.txt" # robots 兜底(SITEMAP 取不到时用)
LOCAL_LIST="" # 本地 URL 列表文件(可选,优先级最高)
DELAY=1 # 每个请求间隔(秒)
CONCURRENCY=4 # 并发数;1 = 串行。免费版建议 ≤4
RETRY=2 # 失败重试次数
TIMEOUT=20 # 单请求超时(秒)
LIMIT=0 # 只预热前 N 条页面;0 = 全量
WARM_ASSETS=1 # 1 = 顺带预热本站静态资源;0 = 只预热页面
LOG_LEVEL="info" # debug | info | warn | error
DRY_RUN=0 # 1 = 只列 URL 不发请求
VERIFY=0 # 1 = 预热后再跑一轮,统计 EO-Cache-Status
VERIFY_WAIT=5 # 二次验证前等待秒数
UA="Mozilla/5.0 (compatible; SiteWarmer/1.0; +${SITE})"
############################################################
###################### 环境变量覆盖 ######################
SITE="${EO_WARM_SITE:-$SITE}"
SITEMAP="${EO_WARM_SITEMAP:-$SITEMAP}"
ROBOTS="${EO_WARM_ROBOTS:-$ROBOTS}"
LOCAL_LIST="${EO_WARM_LOCAL_LIST:-$LOCAL_LIST}"
DELAY="${EO_WARM_DELAY:-$DELAY}"
CONCURRENCY="${EO_WARM_CONCURRENCY:-$CONCURRENCY}"
RETRY="${EO_WARM_RETRY:-$RETRY}"
TIMEOUT="${EO_WARM_TIMEOUT:-$TIMEOUT}"
LIMIT="${EO_WARM_LIMIT:-$LIMIT}"
WARM_ASSETS="${EO_WARM_ASSETS:-$WARM_ASSETS}"
LOG_LEVEL="${EO_WARM_LOG_LEVEL:-$LOG_LEVEL}"
DRY_RUN="${EO_WARM_DRY_RUN:-$DRY_RUN}"
VERIFY="${EO_WARM_VERIFY:-$VERIFY}"
VERIFY_WAIT="${EO_WARM_VERIFY_WAIT:-$VERIFY_WAIT}"
SITE="${SITE%/}" # 去掉末尾斜杠
SITE_RE="$(printf '%s' "$SITE" | sed 's/\./\\./g')" # 用于 grep 的正则
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
PAGES="$TMPDIR/pages.txt"
ASSETS="$TMPDIR/assets.txt"
RESULTS="$TMPDIR/results.txt"
VERIFY_RESULTS="$TMPDIR/verify.txt"
: > "$PAGES"; : > "$ASSETS"; : > "$RESULTS"; : > "$VERIFY_RESULTS"
###################### 日志 ######################
_level_num() {
case "$1" in
debug) echo 0;; info) echo 1;; warn) echo 2;; error) echo 3;; *) echo 1;;
esac
}
LOG_NUM="$(_level_num "$LOG_LEVEL")"
log() {
local lvl="$1"; shift
[ "$(_level_num "$lvl")" -lt "$LOG_NUM" ] && return 0
printf '[%s] [%s] %s\n' "$(date '+%F %T')" "$lvl" "$*" >&2
}
###################### 工具函数 ######################
# 单次请求:输出 "状态码|缓存状态",重试走指数退避
req() {
local url="$1" out="${2:-/dev/null}"
local attempt=0 code=000 cache="UNKNOWN" wait=1
local hdr="$TMPDIR/hdr.$$"
while :; do
: > "$hdr"
code="$(curl -sS --compressed -m "$TIMEOUT" -A "$UA" \
-D "$hdr" \
-o "$out" -w '%{http_code}' "$url" 2>/dev/null || echo 000)"
# 提取 EO-Cache-Status(不区分大小写,容忍空格)
cache="$(grep -i '^EO-Cache-Status:' "$hdr" 2>/dev/null \
| head -n1 \
| sed -E 's/^[^:]+:[[:space:]]*//; s/\r$//')"
[ -z "$cache" ] && cache="UNKNOWN"
if [ "${code:0:1}" = "2" ] || [ "${code:0:1}" = "3" ]; then
echo "$code|$cache"; return 0
fi
attempt=$((attempt+1))
if [ "$attempt" -gt "$RETRY" ]; then
echo "$code|$cache"; return 1
fi
sleep "$wait"
wait=$((wait*2))
done
}
# 仅取缓存状态(验证用,不关心 body)
req_cache_only() {
local url="$1"
local hdr="$TMPDIR/hdr.$$"
: > "$hdr"
curl -sS --compressed -m "$TIMEOUT" -A "$UA" \
-D "$hdr" -o /dev/null "$url" 2>/dev/null || true
local cache
cache="$(grep -i '^EO-Cache-Status:' "$hdr" 2>/dev/null \
| head -n1 \
| sed -E 's/^[^:]+:[[:space:]]*//; s/\r$//')"
[ -z "$cache" ] && cache="UNKNOWN"
echo "$cache"
}
# 从 HTML 里提取本站静态资源
extract_assets() {
local html="$1"
{
grep -oE '(href|src)="[^"]+"' "$html" | sed -E 's/^(href|src)="//; s/"$//'
grep -oE 'srcset="[^"]+"' "$html" | sed -E 's/^srcset="//; s/"$//' \
| tr ',' '\n' | awk '{print $1}'
} | while read -r u; do
[ -z "$u" ] && continue
u="${u%%\#*}"; u="${u%%\?*}"
case "$u" in
//*) u="https:$u" ;;
/*) u="$SITE$u" ;;
http*) ;;
*) continue ;;
esac
case "$u" in "$SITE"*) ;; *) continue ;; esac
case "$u" in
*.css|*.js|*.png|*.jpg|*.jpeg|*.webp|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.otf|*.avif)
echo "$u" ;;
esac
done
}
###################### 1. 采集页面 URL ######################
log info "开始预热:$SITE"
# 1.1 本地列表优先
if [ -n "$LOCAL_LIST" ] && [ -f "$LOCAL_LIST" ]; then
log info "从本地列表读取:$LOCAL_LIST"
grep -E '^https?://' "$LOCAL_LIST" >> "$PAGES"
fi
# 1.2 sitemap(递归),失败时兜底 robots.txt
if [ ! -s "$PAGES" ]; then
STACK=("${SITE}${SITEMAP}")
DEPTH_MAP=("0")
while [ "${#STACK[@]}" -gt 0 ]; do
url="${STACK[0]}"; depth="${DEPTH_MAP[0]}"
STACK=("${STACK[@]:1}"); DEPTH_MAP=("${DEPTH_MAP[@]:1}")
[ "$depth" -gt 3 ] && { log debug "sitemap 递归过深,跳过:$url"; continue; }
body="$(curl -sS --compressed -m "$TIMEOUT" -A "$UA" "$url" 2>/dev/null || true)"
if [ -z "$body" ]; then
log warn "无法拉取 sitemap:$url"
continue
fi
if printf '%s' "$body" | grep -q "<sitemapindex"; then
while IFS= read -r sub; do
[ -z "$sub" ] && continue
STACK+=("$sub"); DEPTH_MAP+=("$((depth+1))")
done < <(printf '%s' "$body" | grep -oE '<loc>[^<]+</loc>' | sed -E 's#</?loc>##g')
else
printf '%s' "$body" | grep -oE '<loc>[^<]+</loc>' | sed -E 's#</?loc>##g' >> "$PAGES"
fi
done
fi
# 1.3 robots.txt 兜底
if [ ! -s "$PAGES" ]; then
log warn "sitemap 无结果,尝试 robots.txt 兜底"
body="$(curl -sS --compressed -m "$TIMEOUT" -A "$UA" "${SITE}${ROBOTS}" 2>/dev/null || true)"
if [ -n "$body" ]; then
while IFS= read -r sub; do
[ -z "$sub" ] && continue
sub_body="$(curl -sS --compressed -m "$TIMEOUT" -A "$UA" "$sub" 2>/dev/null || true)"
printf '%s' "$sub_body" | grep -oE '<loc>[^<]+</loc>' | sed -E 's#</?loc>##g' >> "$PAGES"
done < <(printf '%s' "$body" | awk -F': *' 'tolower($1)=="sitemap" {print $2}')
fi
fi
# 去重 + 只保留本站
if [ -s "$PAGES" ]; then
grep -E "^${SITE_RE}" "$PAGES" | sort -u > "$PAGES.clean" || true
mv "$PAGES.clean" "$PAGES"
fi
TOTAL=$(wc -l < "$PAGES" 2>/dev/null || echo 0)
if [ "$TOTAL" -eq 0 ]; then
log error "未采集到任何页面 URL,退出"
exit 1
fi
if [ "$LIMIT" -gt 0 ] && [ "$TOTAL" -gt "$LIMIT" ]; then
head -n "$LIMIT" "$PAGES" > "$PAGES.lim" && mv "$PAGES.lim" "$PAGES"
fi
COUNT=$(wc -l < "$PAGES")
log info "采集完成:sitemap/robots 共 $TOTAL 条,本次预热 $COUNT 条(并发 $CONCURRENCY,重试 $RETRY)"
if [ "$DRY_RUN" = "1" ]; then
log info "DRY-RUN 模式,仅输出 URL:"
cat "$PAGES"
exit 0
fi
###################### 2. 预热页面(并发) ######################
warm_page() {
local page="$1"
local body="$TMPDIR/page.$$.html"
local result code cache
result="$(req "$page" "$body")" || true
code="${result%%|*}"
cache="${result##*|}"
printf 'PAGE\t%s\t%s\t%s\n' "$code" "$cache" "$page" >> "$RESULTS"
log info "PAGE $code [$cache] $page"
if [ "$WARM_ASSETS" = "1" ] && [ "${code:0:1}" = "2" ]; then
extract_assets "$body" >> "$ASSETS"
fi
rm -f "$body"
[ "$DELAY" -gt 0 ] && sleep "$DELAY"
}
export -f warm_page req extract_assets log _level_num
export UA TIMEOUT RETRY DELAY RESULTS ASSETS TMPDIR WARM_ASSETS SITE LOG_NUM
log info "开始预热页面…"
if [ "$CONCURRENCY" -le 1 ]; then
while IFS= read -r p; do [ -n "$p" ] && warm_page "$p"; done < "$PAGES"
else
# shellcheck disable=SC2016
xargs -a "$PAGES" -I{} -P "$CONCURRENCY" bash -c 'warm_page "$@"' _ {}
fi
###################### 3. 预热静态资源(去重 + 并发) ######################
if [ "$WARM_ASSETS" = "1" ] && [ -s "$ASSETS" ]; then
sort -u "$ASSETS" > "$ASSETS.clean" && mv "$ASSETS.clean" "$ASSETS"
ASSET_COUNT=$(wc -l < "$ASSETS")
log info "开始预热 $ASSET_COUNT 个静态资源…"
warm_asset() {
local a="$1" result code cache
result="$(req "$a")" || true
code="${result%%|*}"
cache="${result##*|}"
printf 'ASSET\t%s\t%s\t%s\n' "$code" "$cache" "$a" >> "$RESULTS"
log info "ASSET $code [$cache] $a"
[ "$DELAY" -gt 0 ] && sleep "$DELAY"
}
export -f warm_asset
if [ "$CONCURRENCY" -le 1 ]; then
while IFS= read -r a; do [ -n "$a" ] && warm_asset "$a"; done < "$ASSETS"
else
xargs -a "$ASSETS" -I{} -P "$CONCURRENCY" bash -c 'warm_asset "$@"' _ {}
fi
fi
###################### 4. 预热结果统计 ######################
log info "预热完成,统计如下:"
if [ -s "$RESULTS" ]; then
awk -F'\t' '
{ total++; cls=substr($2,1,1); cache=$3
if (cls=="2") ok++; else if (cls=="3") redir++; else if (cls=="4") c4++; else if (cls=="5") c5++; else fail++
cstat[cache]++
key=$1" "$2" ["$3"]"; cnt[key]++
}
END {
printf " 总计: %d\n", total
printf " 成功(2xx): %d 重定向(3xx): %d 客户端错(4xx): %d 服务端错(5xx): %d 失败: %d\n", ok, redir, c4, c5, fail
print " --- 缓存状态分布(首次预热,MISS 属正常)---"
for (c in cstat) printf " %-12s %d\n", c, cstat[c]
print " --- PAGE/ASSET 明细 ---"
for (k in cnt) printf " %-32s %d\n", k, cnt[k]
}
' "$RESULTS" | sort
echo " --- 非 2xx 明细 ---" >&2
awk -F'\t' '$2 !~ /^2/ {print " "$2" ["$3"] "$4}' "$RESULTS" >&2 || true
else
log warn "无请求记录"
fi
###################### 5. 二次验证:缓存命中率 ######################
if [ "$VERIFY" = "1" ]; then
log info "等待 ${VERIFY_WAIT}s 让边缘缓存生效,然后二次验证…"
sleep "$VERIFY_WAIT"
# 汇总所有已请求的 URL(页面 + 资源)
ALL_URLS="$TMPDIR/all_urls.txt"
awk -F'\t' '{print $4}' "$RESULTS" | sort -u > "$ALL_URLS"
V_TOTAL=$(wc -l < "$ALL_URLS")
log info "开始验证 $V_TOTAL 个 URL 的 EO-Cache-Status…"
verify_one() {
local u="$1" c
c="$(req_cache_only "$u")"
printf '%s\t%s\n' "$c" "$u" >> "$VERIFY_RESULTS"
}
export -f verify_one req_cache_only
export VERIFY_RESULTS
if [ "$CONCURRENCY" -le 1 ]; then
while IFS= read -r u; do [ -n "$u" ] && verify_one "$u"; done < "$ALL_URLS"
else
xargs -a "$ALL_URLS" -I{} -P "$CONCURRENCY" bash -c 'verify_one "$@"' _ {}
fi
log info "验证完成,缓存命中统计:"
if [ -s "$VERIFY_RESULTS" ]; then
awk -F'\t' '
{ total++; cstat[$1]++ }
END {
printf " 验证总数: %d\n", total
for (c in cstat) {
pct = (total>0) ? (cstat[c]*100.0/total) : 0
printf " %-12s %d (%.1f%%)\n", c, cstat[c], pct
}
}
' "$VERIFY_RESULTS" | sort
echo " --- 非 HIT 明细 ---" >&2
awk -F'\t' '$1 != "HIT" {print " "$1"\t"$2}' "$VERIFY_RESULTS" >&2 || true
else
log warn "无验证记录"
fi
fi
log info "全部完成 ✅"

现在它每天自动跑两次,偶尔手动执行一次看命中率。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
给博客写了个缓存预热脚本
https://x1anyu.cn/posts/20/
作者
羡鱼
发布于
2026-10-09
许可协议
CC BY-NC-SA 4.0
随机文章随机推荐

评论区

Profile Image of the Author
羡鱼
临渊空慕水中鱼, 不如携风自渡河.
分类
标签
最新动态
站点统计
文章
20
分类
7
标签
49
总字数
26,919
运行时长
0 天
最后活动
0 天前
文章目录