给博客写了个缓存预热脚本
站点现在部署在 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 判断命中情况。
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= 这几个真实属性,再叠一道扩展名白名单和”只收本站路径”的过滤。这么一缩范围,正文和代码块里纯文字提到的文件名自然就进不来了。提取逻辑现在是这样的:
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 = 串行。免费版建议 ≤4RETRY=2 # 失败重试次数TIMEOUT=20 # 单请求超时(秒)LIMIT=0 # 只预热前 N 条页面;0 = 全量WARM_ASSETS=1 # 1 = 顺带预热本站静态资源;0 = 只预热页面LOG_LEVEL="info" # debug | info | warn | errorDRY_RUN=0 # 1 = 只列 URL 不发请求VERIFY=0 # 1 = 预热后再跑一轮,统计 EO-Cache-StatusVERIFY_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.txtif [ ! -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 donefi
# 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}') fifi
# 去重 + 只保留本站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 1fiif [ "$LIMIT" -gt 0 ] && [ "$TOTAL" -gt "$LIMIT" ]; then head -n "$LIMIT" "$PAGES" > "$PAGES.lim" && mv "$PAGES.lim" "$PAGES"fiCOUNT=$(wc -l < "$PAGES")log info "采集完成:sitemap/robots 共 $TOTAL 条,本次预热 $COUNT 条(并发 $CONCURRENCY,重试 $RETRY)"
if [ "$DRY_RUN" = "1" ]; then log info "DRY-RUN 模式,仅输出 URL:" cat "$PAGES" exit 0fi
###################### 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_numexport 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 "$@"' _ {} fifi
###################### 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 || trueelse 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 "无验证记录" fifi
log info "全部完成 ✅"现在它每天自动跑两次,偶尔手动执行一次看命中率。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











