缓存预热的作用不是让边缘节点“多存一些文件”,而是让首批真实请求尽量命中边缘磁盘或内存,避免源站被瞬时回源流量打满。自建CDN与商业CDN不同,边缘节点地址列表通常掌握在自己手里,调度策略也可以自己定义,因此预热脚本可以直接请求每台边缘机,不再依赖全网统一的管理API。

先想清楚预热的边界:预热不是全量回源
如果对整站资源做无差别预热,既浪费边缘磁盘,也会把源站出口带宽打高。预热的目标应该是热文件、首页、版本发布后的新资源、大促活动页等。把范围控制好,才能用较低的源站压力换取更稳定的命中率。
实现前需要准备两类输入:一是边缘节点清单,二是URL或路径清单。边缘节点可以是IP、IP加端口、内部域名;URL清单最好由构建系统或日志分析系统生成。脚本只需读取文件、发起请求,但要注意请求到达边缘节点时必须保留正确的Host头,否则边缘可能无法匹配站点配置。
还需要区分预热与刷新。刷新通常发送PURGE或调用API删除缓存,预热则发送普通GET请求让边缘向源站拉取并缓存。自建CDN可以使用两者组合:先刷新旧缓存,再预热新版本,这样能同时解决旧内容过期和新内容未命中的问题。
Python脚本的基础版本:requests加线程池
基础实现选择requests库,原因是代码直观,适合边缘节点数量在几十台以内、URL数量在几千条以内的场景。并发控制用concurrent.futures.ThreadPoolExecutor,避免无限制开线程导致边缘节点连接数暴涨。脚本入口接收三个参数:边缘节点文件、URL文件、Host头。URL文件每行一个路径或完整URL。如果传入的是完整URL但预热目标是边缘节点IP,脚本可以统一替换地址;如果传入的是路径,则拼接边缘地址。
下面是一个可以直接运行的版本,日志会输出每个URL的状态码和耗时。这里需要小心requests的verify参数。内网自建CDN大多使用自签名证书或HTTP,生产脚本应把TLS校验作为可选项,而不是长期默认关闭。
import argparse
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from urllib.parse import urlparse, urlunparse
def warm_url(url, host, timeout):
headers = {"Host": host} if host else {}
try:
resp = requests.get(url, headers=headers, timeout=timeout, verify=False)
return url, resp.status_code, resp.elapsed.total_seconds(), None
except Exception as exc:
return url, None, None, str(exc)
def build_warm_urls(edges, paths, host_header):
urls = []
for edge in edges:
for path in paths:
parsed = urlparse(path)
if parsed.scheme:
urls.append(urlunparse((
parsed.scheme,
edge,
parsed.path,
parsed.params,
parsed.query,
parsed.fragment,
)))
else:
urls.append(f"http://{edge}{path}")
return urls
def main():
parser = argparse.ArgumentParser(description="自建CDN边缘节点缓存预热脚本")
parser.add_argument("--edge-file", required=True, help="边缘节点地址文件,每行一个IP或IP:端口")
parser.add_argument("--url-file", required=True, help="需要预热的URL或路径文件")
parser.add_argument("--host", default="", help="回源Host头,例如 www.ipipp.com")
parser.add_argument("--concurrency", type=int, default=10)
parser.add_argument("--timeout", type=float, default=5.0)
args = parser.parse_args()
with open(args.edge_file, encoding="utf-8") as f:
edges = [line.strip() for line in f if line.strip()]
with open(args.url_file, encoding="utf-8") as f:
paths = [line.strip() for line in f if line.strip()]
urls = build_warm_urls(edges, paths, args.host)
start = time.time()
success = fail = 0
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = [pool.submit(warm_url, url, args.host, args.timeout) for url in urls]
for future in as_completed(futures):
url, status, elapsed, error = future.result()
if error or status != 200:
fail += 1
print(f"[FAIL] {url} status={status} error={error}")
else:
success += 1
print(f"[OK] {url} status={status} elapsed={elapsed:.3f}s")
total = len(urls)
print(f"summary total={total} success={success} fail={fail} cost={time.time()-start:.2f}s")
if __name__ == "__main__":
main()
这段脚本的核心是build_warm_urls函数,它先把边缘地址和路径做笛卡尔积,再根据路径是否带协议决定是替换主机还是直接拼接。对于大多数自建CDN,边缘节点监听的常常是HTTP,而真实站点域名通过Host头传递,因此--host参数非常重要。没有正确Host头时,边缘节点可能返回默认站点或直接拒绝请求。
多边缘节点场景下的并发与失败重试
当边缘节点规模变大,请求总数等于节点数乘以URL数,线程池任务数量可能非常庞大。此时需要把并发控制放在更细的粒度上:按节点分组,限制单节点的并发连接数;避免对同一边缘节点同时发起大量请求导致连接被拒绝。线程池适合中小规模,节点数量达到几百台或URL达到十几万条时,可以考虑分片执行或引入异步IO。
失败重试不建议对所有异常盲目重试。HTTP 404通常表示源站没有该资源,重试无意义;连接超时、TLS握手失败、边缘节点返回502或503则可以重试。可以给每个URL设置最大重试次数和退避时间,例如第一次失败等待0.5秒,第二次等待1秒。下面代码增加重试逻辑,并使用指数退避。
import time
import requests
def fetch_with_retry(url, host, timeout=5, retries=2):
headers = {"Host": host} if host else {}
last_error = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, headers=headers, timeout=timeout, verify=False)
if resp.status_code in (200, 206, 304):
return url, resp.status_code, resp.elapsed.total_seconds(), None
if resp.status_code in (404, 410):
return url, resp.status_code, resp.elapsed.total_seconds(), None
last_error = f"status {resp.status_code}"
except Exception as exc:
last_error = str(exc)
if attempt < retries:
time.sleep(0.5 * (2 ** attempt))
return url, None, None, last_error
这个重试函数把连接异常和5xx当作可重试场景,4xx直接返回,206和304也算有效,因为部分内容和本地缓存命中都是正常结果。指数退避避免在边缘节点抖动时形成请求风暴。生产环境还可以加入随机抖动,防止大量任务同时醒来后再次同时打向同一节点。
与发布流程集成:从脚本到可观测的预热任务
单独跑脚本只能解决一次性的预热,真正有价值的是把预热纳入发布流水线。当源站完成静态资源上传后,CI/CD系统可以先生成变更文件列表,再调用Python脚本对全部边缘节点执行预热。预热完成后,通过日志统计成功率和耗时,超出阈值时中断发布或通知责任人。
日志输出建议落地到文件,并包含时间、边缘节点、URL、状态码、耗时、错误信息。可以使用logging模块替代print,输出JSON格式便于后续采集。预热任务结束后,再从CDN访问日志中观察命中率变化,确认预热是否覆盖了大部分首批请求。只看预热脚本的成功率不够,因为边缘节点返回200只代表拉取成功,不代表后续用户一定命中,还需要结合命中率指标一起评估。
如果业务对预热速度要求更高,可以把requests换成aiohttp并配合asyncio.Semaphore控制并发。异步版本在边缘节点数多、RTT较高时能更充分利用带宽,但也要注意异步代码的异常处理和连接池复用比同步版本复杂。没有明确的性能瓶颈时,先用同步线程池版本跑通流程,再逐步优化更稳妥。
最后,缓存预热不是一次性动作,应配合定时任务对首页、分类页、推荐接口等核心URL周期性预热,同时避免对个性化接口做全局预热,否则会把不同用户的私有数据错误缓存到边缘共享节点。边缘预热脚本的价值最终体现在用户请求是否更快命中,而不是脚本执行了多少次。