AIAI Smart Summary (TL;DR)
Collapse

When running cross-border or globally distributed systems, the physical latency and bandwidth cost of transoceanic networks have always been a problem we cannot avoid.

Our cross-region resource scheduling system has its core control service and primary database deployed in a Tokyo data center, while simultaneously serving client requests from both domestic and overseas nodes.

The system exposes a very high-frequency core HTTP endpoint: clients (desktop apps or automated agents) periodically send their credentials (token) to the backend API to fetch the current connection list of available servers, network policies, and real-time traffic quota information (returned as JSON or YAML text, typically around 2KB in size).

Since both the control database and backend services are in Tokyo, the initial architecture was brutally simple — clients worldwide requested the Tokyo origin directly.

After running for a while, several thorny problems emerged one after another:

  1. High transoceanic latency for domestic users: A request initiated from China requires a TCP three-way handshake plus TLS 1.3 handshake to cross the Pacific or pass through the international gateway — just establishing the connection alone costs 200–300ms.
  2. Meaningless load on the origin database: To keep their configs up to date, clients running in the background frequently poll on a timer. Thousands of endpoints request constantly, each one penetrating all the way to the PostgreSQL instance in Tokyo.
  3. Traditional CDN caching is too risky to enable: This is a dynamic API. If long caching is turned on, then once an account is banned in the backend, its quota is exhausted, or the customer resets their key, they can still retrieve the old config from cache and keep communicating normally until the cache expires — directly breaking risk control.

To fundamentally resolve the contradiction of "extremely low latency AND reduced origin load AND second-level risk-control responsiveness," we rebuilt the entire delivery pipeline using Cloudflare Worker edge computing + 120-second micro-caching + event-driven active invalidation on the Go backend.

Along the way we hit several extremely subtle pitfalls. Here I document the troubleshooting details and the final solution.


I. Why a "120-Second Micro-Cache"?

When many engineers hear "dynamic API," their first reaction is "absolutely no caching allowed."

But if you carefully analyze client behavior, you'll notice:

  • Although clients pull frequently, a user's config and quota don't change every second;
  • When thousands of clients request the same endpoint within a minute, 99% of the requests get identical data.

The core idea of micro-caching:
Even caching for a short 60 to 120 seconds at the CDN edge node closest to the user:

  • For the user, data update delay is at most 2 minutes — virtually "real-time" in terms of experience;
  • For the origin, no matter how many times a single user hammers refresh within a minute, the origin only ever receives 1 request — directly absorbing 99% of the concurrency pressure on behalf of the origin.

II. Tokyo Origin vs. Domestic Users: The "Counter-Intuitive Routing" of Cloudflare Anycast

Before going live with the Cloudflare Worker, we ran a detailed network path test. Here we encountered the first phenomenon that confuses almost every domestic developer:

Inspecting which data center a request landed in via Cloudflare's official diagnostic endpoint:

bash 复制代码
curl -s "https://your-domain.com/cdn-cgi/trace"
  1. When testing from the local Tokyo network:

    text 复制代码
    colo=NRT
    loc=JP

    Millisecond-level response, directly entering Cloudflare's Tokyo Narita (NRT) data center.

  2. When requesting the same domain from the domestic network (China Telecom/Unicom/Mobile):

    text 复制代码
    colo=LAX
    loc=CN

    Requests coming from China were not assigned to the geographically nearest Tokyo (NRT) or Hong Kong (HKG), but were instead pulled directly to the Los Angeles, US West Coast (LAX) data center!

Why go the long way around?

Those who work with overseas networks or data center hosting should understand the underlying reason: bandwidth and public peering costs in the Asia-Pacific region are extremely expensive. In Cloudflare's Anycast BGP routing system, Asia-Pacific core nodes (e.g., Tokyo, Hong Kong, Singapore) have high relay and bandwidth costs; unless under a custom enterprise plan, large volumes of mainland China traffic are typically not allowed to flood directly into Asia-Pacific nodes. Instead, US West Coast (LAX Los Angeles, SJC San Jose) has extremely cheap submarine cables and high-capacity data centers, so mainland traffic is uniformly scheduled to US West Coast.

Since it routes to US West Coast, will latency still be low?

The answer is: yes, still much faster!

  • Previously, clients connected directly to the Tokyo origin; every request required building a brand-new cross-border TLS handshake and waiting for the backend to query the database, totaling 300ms+;
  • After adding Cloudflare, the client connects directly to Cloudflare's Anycast node (and edge nodes keep HTTP/2 / HTTP/3 connections warm), and once it hits the edge cache (HIT-EDGE), the 2KB of data is served straight from the edge, with end-to-end latency of only around 30–50ms, completely avoiding the origin database query.

III. 3 Hidden Pitfalls in Cloudflare Worker Edge Micro-Caching

Below is the core code of the Worker deployed at the edge node, which mainly does three things:

  1. Identify the client environment and adaptively serve the format;
  2. Implement 120-second micro-caching based on caches.default;
  3. Pass the client's real IP through to the origin.
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. Prefer explicit param; otherwise intelligently detect format from 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. 【Pitfall 1】Must rebuild the Cache Key to prevent different client formats overwriting each other
    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. Cache miss, fall back to Tokyo origin server
    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,
      },
    });

    // Never cache non-200 status (e.g., 403 unpaid, 404 invalid token); pass through directly
    if (originResp.status !== 200) {
      return originResp;
    }

    // 4. Write to edge cache with 120-second 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;
  },
};

Pitfall 1: Cache Key Pollution (cross-format bleed between clients)

Many beginners write await cache.match(request) directly in their Worker. In a multi-endpoint scenario, the user's mobile app (wants JSON) and desktop app (wants YAML) use the same URL. If you use the raw request as the key, the mobile client requests first and generates the cache, then the desktop client pulls right after and gets JSON — the client parsing fails on the spot.
Lesson learned: You must centrally compute and rewrite the URL inside the Worker, appending the resolved format into the query parameters to construct a new cacheKey.

Pitfall 2: Why does curl -I report 404 when verifying the cache?

After writing the Worker, many people like to use curl -I https://api.yourdomain.com/... to inspect CF-Cache-Status in the response headers. The terminal then spits out an HTTP/2 404.
The reason: curl -I sends a HEAD request by default. But the backend web framework (such as Go's Gin router) usually only declares r.GET("/api/v1/profile", ...) and doesn't listen for the HEAD method at all — Gin finds no route and naturally returns 404.
The correct approach: Use a GET request and redirect the body to the null device:

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

Request twice in a row to clearly see the status change:

  • 1st time: x-cache-status: MISS-ORIGIN, ~300ms;
  • 2nd time: x-cache-status: HIT-EDGE, cf-cache-status: HIT, latency instantly drops to around 20ms.

IV. Solving the Strong-Consistency Problem: "Edge-Triggered" Active Invalidation in the Go SDK

The biggest risk of micro-caching is: if an account's status changes, but the edge node keeps serving stale data, what then?
For example:

  • An admin disables a violating account in the backend;
  • A user resets their access token in the console;
  • A user's traffic quota was just exhausted.

We cannot let the client keep connecting to the server with the old config for the next 2 minutes. The solution: at the exact moment the state changes on the Go backend, precisely call the Cloudflare API to wipe that user's edge cache.

1. Go Backend Cache Purge Implementation

On the Go server, wrap a purge function using the official cloudflare-go SDK:

go 复制代码
package cloudflare

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

// PurgeProfileCache asynchronously and precisely purges the edge cache slice for a specific 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)
	// Note: the URL here must be exactly consistent with the cacheKey construction rules in the Worker
	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
}

Special notes:

  1. Purge by URL can only purge the real domain bound to the current Zone; you cannot fabricate a fake domain inside the Worker;
  2. The purged URL parameters must strictly match the cacheKey constructed by the Worker (including parameter casing and order).

2. Pitfall 3: The API-Flood-Proof "Edge Trigger"

When should PurgeProfileCache be called?

  • When an admin disables a user or modifies their quota, call it once;
  • When a user actively resets their token, call it once;
  • The trickiest is the traffic reporting service (Billing/Metering).

In a distributed architecture, edge nodes everywhere aggregate and report users' up/down traffic to the central controller every minute. If we purge the cache every time a report detects used traffic >= total quota, then because users who already exhausted their quota may still have residual connections initiating handshakes, the per-minute scheduled task would call the Cloudflare API every time — easily triggering API rate limits.

Core solution: introduce a state-transition check (edge trigger)
Trigger the purge task only at the single, unique moment when a user transitions from "under quota" to "over quota" during a report:

go 复制代码
// u.UploadBytes + u.DownloadBytes represent the old traffic in the DB before reporting
wasUnderQuota := u.UploadBytes + u.DownloadBytes < u.QuotaBytes

// Plus the newly reported traffic from this edge node
isQuotaOut := u.UploadBytes + u.DownloadBytes + billedUpload + billedDownload >= u.QuotaBytes

// State decision: account banned, expired, or traffic exhausted
if !u.Enable || isExpired || isQuotaOut {
    // 1. Local in-memory circuit breaker: kick the user off via gRPC
    _ = gateway.RemoveUser(u.Username)

    // 2. Core edge trigger: only when this report precisely causes the quota to be exhausted
    //    (was not full before && is full now) do we trigger the Cloudflare cache purge
    if wasUnderQuota && isQuotaOut {
        taskQueue.Enqueue(func() {
            _ = cloudflare.PurgeProfileCache(cfg.ApiToken, cfg.ZoneID, cfg.Domain, u.Token)
        })
    }
    return
}

With this layer of conditional constraint, no matter how many machines are reporting, each over-quota user triggers exactly one cache wipe at the moment of going over quota over their global lifecycle. After the connection drops and the user refreshes their config, the request penetrates to the origin and immediately receives the latest "quota exhausted" response.


V. Benefits and Production Retrospective

After this "edge micro-cache + edge-triggered active invalidation" refactor, our production performance improved qualitatively:

Metric Before (direct origin) After (Worker micro-cache) Improvement
Domestic pull average latency 280ms ~ 450ms 15ms ~ 40ms ~85% reduction
Central DB read-only QPS Linear growth with endpoints, peak fills connection pool Edge absorption 99.2%, origin load constant Origin barely notices
Config change effective latency Static CDN waits for TTL (tens of minutes) ≤ 1 second (API precise second-level purge) Strong consistency retained
Cross-terminal format compatibility Prone to cache pollution by client type Worker unified format rewrite, 0 pollution Parsing anomalies eliminated

VI. Conclusion

The essence of this solution is finding the optimal balance point between blazing-fast edge caching and precise central control:

  1. Don't fear caching dynamic APIs: Just by adding a short 60–120 second "micro-cache," you can shield the origin from thousands of unnecessary polls with almost no impact on data freshness;
  2. Always normalize your Cache Key: When a single endpoint serves multi-terminal logic, you must normalize parameters and formats at the edge layer — never trust the raw URL directly;
  3. Use event-driven invalidation instead of short TTLs: Blindly shortening cache time waste CDN performance; extend the cache duration and use the backend's explicit state-transition events (such as wasUnderQuota && isQuotaOut) to precisely pierce the cache — that is the most elegant highly-available architecture design.

Comments & Discussions

Write a comment...
0 / 500
Comments & Discussions (0)
暂无评论,快来抢占第一条沙发吧~
0
0
AIAI