AI文章智能速读 (TL;DR)
收起

做跨国业务或海外分布式系统时,跨洋网络的物理延迟与带宽成本向来是个绕不开的难题。

我们的一套跨区域资源调度系统,核心控制服务与主数据库部署在东京机房,同时需要服务国内以及海外多个节点的客户端请求。

系统里有一个非常高频的核心 HTTP 接口:客户端(桌面端或自动化 Agent)每隔一段时间会带着凭证(Token)请求后端的 API,拉取当前可用服务器的接入清单、网络策略以及实时的流量配额信息(返回 JSON 或 YAML 文本,体积通常在 2KB 左右)。

因为主控数据库和后端服务都在东京,最开始的架构非常简单粗暴——全球客户端直接请求东京源站。

跑了一段时间后,几个棘手的问题接踵而至:

  1. 国内用户跨洋延迟高:从国内发起请求,TCP 三次握手 + TLS 1.3 握手要跨越太平洋或者经过国际出口,光是建立连接就要 200~300ms;
  2. 源站数据库“无意义消耗”:客户端为了保证配置是最新的,挂在后台经常定时自动轮询,上千个终端频繁请求,每次都穿透到东京的 PostgreSQL 查库;
  3. 传统 CDN 缓存不敢乱开:这是动态 API,如果开了长缓存,一旦某个账号在后台被封禁、配额耗尽,或者客户重置了密钥,在缓存过期前他依然能拿到旧配置正常通信,直接导致风控失效。

为了彻底解决“既要极低延迟、又要减轻源站负担、还要保证风控秒级响应”的矛盾,我们用 Cloudflare Worker 边缘计算 + 120 秒微缓存(Micro-Caching)+ Go 后端事件驱动主动失效 重构了整套分发链路。

过程中踩了几个极其隐蔽的坑,这里把排障细节和最终方案记录下来。


一、为什么是“120 秒微缓存”?

很多工程师一听到“动态 API”,第一反应就是“绝对不能加缓存”。

但如果仔细分析客户端的行为,你会发现:

  • 客户端虽然频繁拉取,但用户的配置和配额并不会每秒都在变化;
  • 当上千个客户端在同一分钟内请求同一个接口时,99% 的请求拿到的数据是一模一样的。

微缓存(Micro-Caching)的核心思想:
哪怕只在离用户最近的 CDN 边缘节点缓存短短 60 秒到 120 秒:

  • 对于用户来说,数据更新延迟最多不超过 2 分钟,体验上几乎等同于“实时”;
  • 对于源站来说,同一个人无论一分钟内狂点多少次刷新,源站永远只会收到 1 次请求,直接替源站吸收了 99% 的并发压力。

二、人在东京 vs 国内用户:Cloudflare Anycast 的“反直觉路由”

在上线 Cloudflare Worker 之前,我们做了一次详细的网络链路测试。这里遇到了第一个绝大多数国内开发者都会感到困惑的现象:

通过 Cloudflare 提供的官方诊断接口查看请求落到了哪个机房:

bash 复制代码
curl -s "https://your-domain.com/cdn-cgi/trace"
  1. 在东京本地网络测试时:

    text 复制代码
    colo=NRT
    loc=JP

    毫秒级响应,直接进入 Cloudflare 东京成田(NRT)机房。

  2. 从国内网络(电信/联通/移动)请求同一个域名时:

    text 复制代码
    colo=LAX
    loc=CN

    国内过来的请求,竟然没有分配给地理距离最近的东京(NRT)或香港(HKG),而是直接被拉到了美国西海岸的洛杉矶(LAX)机房!

为什么会舍近求远?

做海外网络或机房托管的朋友应该清楚背后的底层原因:亚太地区的带宽与公网交换(Peering)成本极度昂贵。
在 Cloudflare 的 Anycast BGP 路由体系中,亚太核心节点(如东京、香港、新加坡)由于中继和带宽成本高,非大企业订制计划通常不会让中国大陆的大流量直接涌入亚太节点;相反,美西(LAX 洛杉矶、SJC 圣何塞)拥有极其廉价的海底光缆和大容量机房,因此大陆流量会被统一调度到美西。

既然绕到了美西,延迟还会低吗?

答案是:依旧会快非常多!

  • 以前客户端直接连东京源站,每一次都要跨国建立全新的 TLS 握手并等待后端查库,整体需要 300ms+;
  • 套上 Cloudflare 后,客户端直接与 Cloudflare 的 Anycast 节点建立连接(且边缘节点常年保持 HTTP/2 / HTTP/3 复用),一旦命中边缘缓存(HIT-EDGE),2KB 的数据由边缘直接吐出,端到端延迟仅需 30~50ms 左右,而且彻底规避了源站数据库查询。

三、Cloudflare Worker 边缘微缓存实战中的 3 个暗坑

下面是我们部署在边缘节点的 Worker 核心代码,主要完成三件事:

  1. 识别客户端环境自适应下发格式;
  2. 基于 caches.default 实现 120 秒微缓存;
  3. 将客户端真实 IP 透传给源站。
javascript 复制代码
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname !== "/api/v1/profile") {
      return new Response("Not Found", { status: 404 });
    }

    const token = url.searchParams.get("token");
    if (!token) {
      return new Response("Missing token", { status: 400 });
    }

    // 1. 优先读取显式传参,未传参则根据 User-Agent 智能识别格式
    let format = url.searchParams.get("format") || "";
    if (!format) {
      const ua = (request.headers.get("User-Agent") || "").toLowerCase();
      if (ua.includes("mobile-app") || ua.includes("agent-json")) {
        format = "json";
      } else if (ua.includes("desktop-daemon") || ua.includes("agent-yaml")) {
        format = "yaml";
      } else {
        format = "base64";
      }
    }

    // 2. 【暗坑 1】必须重构 Cache Key,防止不同客户端格式相互覆盖
    const cacheUrl = new URL(request.url);
    cacheUrl.searchParams.set("format", format);
    const cacheKey = new Request(cacheUrl.toString(), { method: "GET" });

    const cache = caches.default;
    let response = await cache.match(cacheKey);
    if (response) {
      const headers = new Headers(response.headers);
      headers.set("X-Cache-Status", "HIT-EDGE");
      return new Response(response.body, {
        status: response.status,
        headers,
      });
    }

    // 3. 缓存未命中,回源东京源站服务器
    const originUrl = `https://origin-api.example.com/api/v1/profile?token=${encodeURIComponent(token)}&format=${encodeURIComponent(format)}`;
    const clientIP = request.headers.get("CF-Connecting-IP") || "";

    const originResp = await fetch(originUrl, {
      method: "GET",
      headers: {
        "User-Agent": request.headers.get("User-Agent") || "",
        "X-Forwarded-For": clientIP,
      },
    });

    // 状态码非 200(如 403 欠费、404 Token 失效)绝不缓存,直接透传
    if (originResp.status !== 200) {
      return originResp;
    }

    // 4. 写入边缘缓存,设置 120 秒 TTL
    const respHeaders = new Headers(originResp.headers);
    respHeaders.set("Cache-Control", "public, max-age=120");
    respHeaders.set("X-Cache-Status", "MISS-ORIGIN");

    response = new Response(originResp.body, {
      status: 200,
      headers: respHeaders,
    });

    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

暗坑 1:Cache Key 污染(不同终端串格式)

很多初学者写 Worker 时会直接写 await cache.match(request)。
在多端场景下,用户手里的移动端(要 JSON)和桌面端(要 YAML)使用的是同一个 URL。如果直接拿原始 request 当 Key,移动端先请求生成了缓存,桌面端紧接着去拉,拿到的是 JSON,客户端解析当场报错。
避坑经验:必须在 Worker 里统一计算并重写 URL,把计算好的 format 拼进 Query 参数里构造一个新的 cacheKey。

暗坑 2:验证缓存时为什么 curl -I 报 404?

写完 Worker 后,很多人喜欢用 curl -I https://api.yourdomain.com/... 去查看响应头里的 CF-Cache-Status。
结果终端直接弹出了一个 HTTP/2 404。
原因在于:curl -I 默认发送的是 HEAD 请求。而后端的 Web 框架(如 Go 的 Gin 路由)通常只声明了 r.GET("/api/v1/profile", ...),根本没有监听 HEAD 方法,Gin 找不到路由自然返回 404。
正确姿势:使用 GET 请求并将正文重定向到空设备:

bash 复制代码
curl -s -D - -o /dev/null "https://api.yourdomain.com/api/v1/profile?token=xxx"

连续请求两次,就能清晰看到状态变化:

  • 第 1 次:x-cache-status: MISS-ORIGIN,耗时 ~300ms;
  • 第 2 次:x-cache-status: HIT-EDGE,cf-cache-status: HIT,耗时瞬间降至 20ms 左右。

四、破解强一致性难题:Go SDK 的“边沿触发”主动失效

微缓存最大的风险是:如果账号状态变了,边缘节点还在吐旧数据怎么办?
例如:

  • 管理员在后台停用了某个违规账号;
  • 用户在控制台重置了访问 Token;
  • 用户的流量额度刚刚用尽。

我们不能让客户端在接下来的 2 分钟内继续拿着旧配置连上服务器。解决方案是:在 Go 后端状态变更的那一瞬间,精准调用 Cloudflare API 擦除该用户的边缘缓存。

1. Go 后端缓存擦除实现

在 Go 服务端利用官方的 cloudflare-go SDK 封装清理函数:

go 复制代码
package cloudflare

import (
	"context"
	"fmt"
	cf "github.com/cloudflare/cloudflare-go"
)

// PurgeProfileCache 异步精确清理特定 Token 的边缘缓存切片
func PurgeProfileCache(apiToken, zoneID, domain, token string) error {
	if apiToken == "" || zoneID == "" {
		return nil
	}

	api, err := cf.NewWithAPIToken(apiToken)
	if err != nil {
		return err
	}

	rc := cf.ZoneIdentifier(zoneID)
	// 注意:这里的 URL 必须与 Worker 中的 cacheKey 构造规则完全一致
	pcr := cf.PurgeCacheRequest{
		Files: []string{
			fmt.Sprintf("https://%s/api/v1/profile?token=%s&format=yaml", domain, token),
			fmt.Sprintf("https://%s/api/v1/profile?token=%s&format=json", domain, token),
			fmt.Sprintf("https://%s/api/v1/profile?token=%s&format=base64", domain, token),
		},
	}

	_, err = api.PurgeCache(context.Background(), rc.Identifier, pcr)
	return err
}

特别注意:

  1. Purge by URL 只能清理当前 Zone 绑定的真实域名,不能在 Worker 里编造伪域名;
  2. 清理的 URL 参数必须与 Worker 构造的 cacheKey 严格一致(包括参数大小写和顺序)。

2. 暗坑 3:防 API 刷爆的“边沿触发(Edge Trigger)”

什么时候应该调用 PurgeProfileCache?

  • 后台停用用户或修改配额时,调用一次;
  • 用户主动重置 Token 时,调用一次;
  • 最棘手的是流量上报服务(Billing/Metering)。

在分布式架构中,遍布在各地的边缘节点每分钟都会将用户的上下行流量汇总上报给中心主控。
如果我们在每次上报检测到 已用流量 >= 总配额 时都调一次清缓存,由于已耗尽流量的用户可能仍有残余连接在发起握手,导致每分钟的定时任务都会调用 Cloudflare API,极易触发 API 速率限制(Rate Limit)。

核心解法:引入状态跃迁判断(边沿触发)
只在用户由“未超额”跨越到“超额”的那唯一一次上报瞬间,才触发清理任务:

go 复制代码
// u.UploadBytes + u.DownloadBytes 代表上报前数据库中的旧流量
wasUnderQuota := u.UploadBytes + u.DownloadBytes < u.QuotaBytes

// 加上本次边缘节点上报的新增流量
isQuotaOut := u.UploadBytes + u.DownloadBytes + billedUpload + billedDownload >= u.QuotaBytes

// 状态判定:账号被封禁、到期或流量用尽
if !u.Enable || isExpired || isQuotaOut {
    // 1. 本地内存熔断:通过 gRPC 动态将该用户踢下线
    _ = gateway.RemoveUser(u.Username)

    // 2. 核心边沿触发:仅当本次上报恰好导致流量用尽(之前没满 && 现在满了),才触发 Cloudflare 缓存清理
    if wasUnderQuota && isQuotaOut {
        taskQueue.Enqueue(func() {
            _ = cloudflare.PurgeProfileCache(cfg.ApiToken, cfg.ZoneID, cfg.Domain, u.Token)
        })
    }
    return
}

通过这一层条件约束,不管有多少台机器在上报,每个超额用户在全球生命周期内只会在超流瞬间触发唯一一次缓存擦除。用户在连接中断后去刷新配置,请求穿透到源站,立刻收到最新的“额度已用尽”响应。


五、方案收益与生产环境复盘

经过这套“边缘微缓存 + 边沿触发主动失效”方案的改造,我们线上的性能表现有了质的提升:

评估指标 重构前(直接回源) 重构后(Worker 微缓存) 优化幅度
国内拉取平均耗时 280ms ~ 450ms 15ms ~ 40ms 降低约 85%
中心数据库只读 QPS 随终端增长线性上升,峰值打满连接池 边缘吸收率 99.2%,源站负载恒定 源站基本无感
配置状态变更生效时延 纯静态 CDN 需等待 TTL(几十分钟) ≤ 1 秒(API 精准秒级擦除) 兼顾强一致性
跨终端格式兼容性 易因客户端类型不同产生缓存污染 Worker 统一格式重构,0 污染 彻底杜绝解析异常

六、总结

这套方案的本质是在极速的边缘缓存和精准的中心控制之间找到了最佳平衡点:

  1. 不要害怕对动态 API 上缓存:只要加上短短的 60~120 秒“微缓存”,就能在几乎不影响数据新鲜度的前提下,为源站挡下成千上万次不必要的轮询;
  2. 永远规范你的 Cache Key:当同一个接口承载多端逻辑时,必须在边缘层做好参数与格式的归一化,切忌直接信任原始 URL;
  3. 用事件驱动代替短 TTL:一味缩短缓存时间是对 CDN 性能的浪费;把缓存时间拉长,用后端明确的状态跃迁事件(如 wasUnderQuota && isQuotaOut)去精准刺穿缓存,才是最优雅的高可用架构设计。

评论交流

发表你的评论...
0 / 500
评论交流 (0)
暂无评论,快来抢占第一条沙发吧~
0
0
AIAI