返回文章列表

多云分流的两条链路:ESA 边缘分流 vs 小程序网关分流

同一个业务,同一批后端,我们前后落地了两套多云分流方案。它们解决的是同一个问题——把请求按用户和接口调度到不同的云上——但一套把决策做在云外的边缘节点,另一套把决策压进了云内的网关。 这篇文章讲清楚两套方案的技术栈、各自为什么长成这样,以及那几处真正拉开差距的地方。

2026年10月6日27 分钟读完WhiteEnzuo

1. 背景:为什么需要多云分流

单一云厂商的依赖风险是实打实的。我们要的不是「再买一朵云做冷备」——冷备切换太慢,而且平时不接流量,真出事时没人敢切。我们要的是热备:两朵云同时承载生产流量,随时可以按比例调整。

这带来三个硬需求:

  1. 流量可调度:按 userid 和接口维度配置各云比例,粒度和灰度都要能控。
  2. 故障可快切:单云故障时,能在秒级把流量全量拨到健康的云。
  3. 数据要一致:跨云之后,数据库主从、缓存一致性都得有说法。

围绕这三条,我们前后做了两套分流链路。它们的分流策略几乎一模一样,但分流的位置完全不同——而位置决定了一切。


2. 两条链路总览

2.1 ESA 边缘分流

请求在还没有进入任何一朵云的 VPC 之前,就在 ESA 的边缘节点上被判定归属,然后直接回源到目标云的负载均衡。

2.2 小程序网关分流

请求先进入腾讯云,经过网关、WAF、CLB,最后在 APISIX 上完成分流决策——而 APISIX 就部署在腾讯云的 UI 机器里,判定给腾讯云的请求走本机环回,连 VPC 都不用出。

两张图摆在一起,差异一眼可见:决策点一个在云外,一个在云内。这一个位置差,往下衍生出了协议、网络路径、承载能力、配置下发方式的全部区别。


3. 方案一:ESA 边缘分流

3.1 技术栈

层 选型 说明
边缘运行时 ESA 边缘函数(JavaScript) Web Worker 风格的 fetch(request) 入口
决策载体 单文件 JS,编译期常量 无外部依赖,无网络调用
配置来源 控制台配置 + 代码内常量表 改分流比例需要更新边缘函数
回源目标 阿里云 ALB / 腾讯云 CLB 公网回源
可观测 边缘日志 + X-Backend-Server / X-Match-Rule 响应头 每个响应都能看出走了哪朵云

选择 ESA 的理由很直接:它是成熟的边缘产品,节点多、故障率低,分流逻辑写成一个纯函数就够了。决策不需要查任何外部状态,所有规则都编译进代码,运行期是一次内存查找。

3.2 分流因子与优先级链

分流输入只有两个:请求路径和userid。userid 从 Cookie 里的 AT= 解析出来(一段 % 分隔的载荷,取倒数第二段纯数字字段)。两者喂进同一条固定顺序的判定链:

1. userMapping   —— userid 精确白名单,命中即定
2. modConfig     —— userid 取模,按余数区间分桶(灰度进度的主要旋钮)
3. force         —— 强制指定后端(应急开关)
4. weighted      —— 按各后端权重随机

前三级都是「确定性」的——同一个用户永远落同一朵云,这是灰度可控的前提。只有最后一级是随机权重,用于无差别地按比例放量。

路径匹配走三个索引,顺序是 exact → prefix → suffix:

  • exact:Map 直查,O(1)
  • prefix:按长度降序排序后线性扫描,保证最长前缀优先
  • suffix:取路径最后一段做精确匹配(注意是最后一段完全相等,不是子串包含)

三个索引在建表时一次性构建好,运行期只有查表,没有正则、没有遍历字符串。

3.3 分流规则长什么样

一份典型的规则配置:

const domainConfig = {
  aliyun:  { weight: 100, userids: [] },
  tencent: { weight: 0,   userids: ['10000001', '10000002'] }  // 白名单用户
};
 
// 取模分流:userid 末位对 10 取余,余数 3 走腾讯云,其余走阿里云
const controllerMod = {
  goodsDetailMod: {
    mod: 10,
    mapping: { '3': 'tencent', '4-9': 'aliyun', '0-2': 'aliyun' },
  },
};
 
const pathConfig = {
  // 后缀匹配:只按路径最后一段比
  'getFeedHome':  { matchType: 'suffix', tencent: 100 },
  'getGoodsList': { matchType: 'suffix', tencent: 100 },
 
  // 带用户维度的复合规则
  'getGoodsDetail': {
    matchType: 'suffix',
    userMapping: userMappingTencent,   // 白名单优先
    modConfig: controllerMod.goodsDetailMod,  // 其次取模
  },
};

getGoodsDetail 这条规则值得单独说:它同时挂了 userMapping 和 modConfig。判定链会先查白名单,白名单没命中再走取模——这就是「VIP 用户固定走一朵云,其余用户按比例灰度」的实现方式。

3.4 决策与回源

边缘函数的骨架:

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const pathname = url.pathname.toLowerCase();
    const userId = parseUserIdFromCookie(request.headers.get('cookie'));
 
    // 三级索引查找
    let matched = exactPathIndex.get(pathname)
               || matchPrefix(pathname)
               || suffixExactIndex.get(lastSegment(pathname));
 
    // 命中规则 → 本地判定;未命中 → 全局兜底
    const backend = matched
      ? resolveBackend(matched, userId)
      : resolveGlobalBackend(domainConfig, userId);
 
    // 通过请求头把决策结果传给回源
    const newRequest = new Request(request.url, { /* ... */ });
    newRequest.headers.set('x-backend-server', backend);
 
    const response = await fetch(newRequest);
    response.headers.set('X-Backend-Server', backend);
    response.headers.set('X-Match-Rule', rule);
    return response;
  }
};

注意这里的形态:整个分流器是一个无状态的纯函数,输入 (path, userid),输出 backend。没有缓存、没有外部存储、没有状态机。这是边缘计算最舒服的形态,也是它能在毫秒级完成决策的原因。


4. 方案二:小程序网关分流

小程序链路是完全不同的接入面:

小程序 → 小程序网关 → WAF → CLB → APISIX → 阿里云 / 腾讯云

4.1 技术栈

层 选型 说明
接入层 小程序网关 承载小程序私有请求协议
安全层 WAF 应用层防护
负载均衡 CLB 腾讯云负载均衡
分流层 APISIX(serverless-pre-function) 分流主逻辑,Lua 编写
部署形态 与腾讯云 UI 机器同机部署 不单独占机器,复用 UI 的算力
配置源 Redis(每 60s 拉取) 配置热更新,无需 reload
兜底 本地文件 → 内置常量 三级降级,永不空转
回源 127.0.0.1 环回 / 公网 到阿里云 命中腾讯云不出机器

4.2 为什么 CLB 之后还要加一层 APISIX

这是整条链路最容易被问到的问题:CLB 本身就能把流量分摊到多个后端,为什么后面还要挂一层 APISIX?

因为 CLB 能转发,但不能决策。

云负载均衡的能力边界很清楚:按域名、路径前缀、权重做转发——这些都是静态规则。而我们要的分流长这样:

  • 同一个接口,userid 末位是 3 的走腾讯云,其余走阿里云;
  • 一批白名单用户固定走某一朵云,其余用户按比例灰度;
  • 不同接口配不同比例,随时可调;
  • 出故障时一键把某朵云全量摘掉。

这些是决策:要读配置、解析 Cookie、做取模运算、查索引表。CLB 的规则语言里没有「逻辑」这回事——它配不出「userid 末位取模等于 3」这种条件。改一次比例还得走控制台或 API 并等待生效,也不具备「往配置中心写一份、几十秒后自动生效」的能力。

对比 ESA 那条链路,这件事一下就清楚了:

ESA 的边缘函数本来就是可编程的——它能在边缘跑一段 JS,读 cookie、算取模、查索引。所以 ESA 那条链路天然就能做细分流,不需要额外的可编程层。

而 CLB 只是个转发器。要在它后面做细分流,就必须补一层可编程的网关——这就是 APISIX 的位置:跑 Lua、连 Redis、执行任意判定逻辑。

一句话:

ESA 链路不缺可编程层,因为 ESA 本身就是;小程序链路缺,所以补了一个 APISIX。

4.3 分流逻辑:一次请求的判定路径

APISIX 的 run() 函数做的事,和 ESA 那个 fetch() 逐条对应:

function _M.run(conf, ctx)
    -- 1. 只读一次内存快照,整个请求周期不再碰 Redis / 磁盘
    local st = get_state()
 
    local pathname = string.lower(ctx.var.uri or "/")
    local userId   = getUserIdFromCookie(ctx.var.http_cookie or "")
 
    -- 2. 三级索引查找,顺序与 ESA 完全一致:exact → prefix → suffix
    local matched = st.exactPathIndex[pathname]
                 or match_prefix(st.prefixPathIndex, pathname)
                 or st.suffixExactIndex[last_segment(pathname)]
 
    -- 3. 同一套判定链:userMapping → modConfig → force → weighted
    local usedKey = matched and resolveBackend(matched, userId)
 
    -- 4. 全局兜底:domainConfig 按权重选,选不出来走 defaultBackend
    if not usedKey then
        usedKey = resolveGlobalBackend(st.domainConfig, userId, st.defaultBackend)
    end
 
    -- 5. 应急开关:routePriority == 0 时全部压到 defaultBackend
    if st.routePriority == 0 then
        usedKey = st.defaultBackend
    end
 
    -- 6. 设置动态上游(关键:必须用 upstream.set,不能改 ctx.var.upstream_host)
    local target = st.upstreamUrlMap[usedKey]
    local nodeHost = resolve_to_ip(target.host)   -- balancer 只认 IP,不认域名
    upstream.set(ctx, "flowProxy#" .. usedKey, ctx.conf_version, {
        type   = "roundrobin",
        scheme = target.scheme,
        nodes  = { { host = nodeHost, port = target.port, weight = 1 } },
        pass_host     = "rewrite",
        upstream_host = ctx.var.host,   -- 保留原始 Host
    })
end

函数名、判定顺序、匹配类型、兜底策略——和 ESA 那版 JS 是同一套设计。换个语言重写一遍,这不是巧合,而是刻意的:两套链路的分流语义必须一致,否则同一个用户在两条链路上会落到不同的云,灰度比例就没法对齐了。

4.4 配置形态:从「编译期」到「运行时」

这是两套方案最本质的工程差异。

ESA 的配置是编译进代码的常量。改一个分流比例 = 改代码 = 重新发布边缘函数。

APISIX 的配置是运行时从 Redis 下发的 JSON:

{
  "schemaVersion": 1,
  "version": "2026-10-06T12:00:00Z",
  "routePriority": 2,
  "defaultBackend": "aliyun",
  "upstreamUrlMap": {
    "aliyun":  { "scheme": "http", "host": "alb-xxxxxxxx.cn-beijing.alb.aliyuncsslb.com",  "port": 80 },
    "tencent": { "scheme": "http", "host": "lb-xxxxxxxx.clb.bj-tencentclb.com", "port": 80 }
  },
  "domainConfig": {
    "aliyun":  { "weight": 100, "userids": [] },
    "tencent": { "weight": 0,   "userids": ["10000001", "10000002"] }
  },
  "userMappings": {
    "vipTencent": { "10000001": "tencent", "10000002": "tencent" }
  },
  "modConfigs": {
    "goodsDetailMod": { "mod": 10, "mapping": { "3": "tencent", "4-9": "aliyun", "0-2": "aliyun" } }
  },
  "pathConfig": {
    "/api/feed/home":  { "matchType": "exact",  "tencent": 100 },
    "/api/goods/list": { "matchType": "prefix", "aliyun": 90, "tencent": 10 },
    "getGoodsDetail":  { "matchType": "suffix", "userMapping": "vipTencent", "modConfig": "goodsDetailMod" }
  }
}

运营改分流比例,是往 Redis 里写一个 key,60 秒内自动生效——不动一行 Lua 代码,不需要 reload,更不需要发版。

这是 Redis 带来的核心价值:分流策略变成了数据,而不是代码。灰度放量、临时把某个接口切到另一朵云、给一批用户开白名单,这些本来要排期改代码的事,全都变成了一次配置写入。

注意 routePriority 这个字段,它是个三级应急开关:

值 行为
2 全功能:精确/前缀/后缀匹配 + 全局分流
1 关闭路径匹配,只保留全局分流
0 全部压到 defaultBackend——一键把流量收回单云

routePriority = 0 是我们为「单云故障」预留的核按钮。它不改变任何规则,只是让所有请求无条件落到默认后端,一个同步周期内就翻转完成。

4.5 本地文件兜底:不强制依赖 Redis

Redis 解决了「配置改得动」,但也顺手引入了一个新的依赖——Redis 自己挂了怎么办?

如果分流器每次都去读 Redis,Redis 就会变成和业务服务同级的关键路径:它抖一下,全站路由跟着抖;它崩了,连「切回单云」这个应急动作都做不了。用一个可能故障的组件去支撑容灾能力,是本末倒置。

所以配置在本地也留一份,读取时按优先级取:

Redis(每 60s 覆盖 —— 保证「改得动」)
  └─ 本地文件(worker 冷启动时加载 —— 保证「Redis 崩了也活得下去」)
       └─ 内置常量(两者都不可用时的最后防线)

三层各自解决一个不同的问题:

层级 解决的问题 失效后的表现
Redis 配置能不能改 改不动新配置,但旧配置继续生效,路由不受影响
本地文件 配置能不能活 退回内置常量,退化成单后端
内置常量 服务能不能起 —

关键在于:Redis 故障只影响「变更能力」,不影响「服务能力」。 运维改配置会失败,但线上路由一切照旧——这正好是容灾最需要保证的性质。

这条链路还有几处刻意的设计,都是踩过坑之后定下来的:

① 热路径绝不碰 Redis 和磁盘。 每个请求只读一次内存快照 get_state()。Redis 拉取、JSON 解码、规则校验全部在 60 秒一次的定时器里完成。请求路径上没有任何 I/O。

② 校验必须在写文件之前。 顺序是「拉取 → 解码 → 校验 → 写文件 → 发布快照」。如果先写文件后校验,一次误推就会污染持久兜底文件,之后每次 worker 重启都会把坏配置复活——正好是兜底机制的反面。

③ 写文件是原子的,且带 pid 后缀。 -s reload 期间新旧两代 worker 会并存,如果 tmp 文件名共用,两个进程会互相截断,把半截字节 rename 成正式文件。所以 tmp 名带上 ngx.worker.pid()。

④ 只有 worker 0 写文件。 避免多 worker 并发写同一个文件。

⑤ 内置兜底里刻意不放业务数据。 最后那层 FALLBACK 常量只有后端地址表,没有那批白名单 userid。原因很直白:那 200 多个 ID 是业务配置,属于 Redis 和本地文件,不属于代码。真出现 Redis 和文件同时不可用的双重故障,宁可退回单后端裸跑,也不要让代码里埋一份会过期的分流表。

4.6 热路径的安全绳:同步链与看门狗

定时同步这一段,是整个 Lua 实现里最讲究的部分。

问题:APISIX 用的这个 OpenResty 版本里,ngx.timer.at / ngx.timer.every 只返回数字 1,拿不到可取消的句柄。这意味着如果同步链意外死掉,没法取消旧的,只能再加一个——于是两个循环同时打 Redis,看门狗每触发一次就再叠一个。

解法:改成自续期 + 代次号。

local SYNC = { generation = 0 }
 
local function sync_loop(gen, delay)
    ngx.timer.at(delay or 60, function(premature)
        if premature then return end
 
        -- 代次已被看门狗更新:这条链就此终结,不再续期
        if gen ~= SYNC.generation then return end
 
        timer_cb(false)
 
        if gen == SYNC.generation then
            sync_loop(gen)   -- 只有代次还对得上,才续下一轮
        end
    end)
end

看门狗发现「超过 3 个周期没跑」,只做一件事:SYNC.generation = SYNC.generation + 1,然后重启装载。老的链在走到自己下一步时会发现代次不符,自行终止。全程不需要 cancel——因为这个版本给不了 cancel。

与之配套的还有几个细节:

  • 短连接,不用连接池:60 秒建一次连不心疼。而池化连接会被 Redis 自己的 timeout 或中间设备断掉,陈旧 socket 报的错会被误判成「Redis 挂了」,进而导致本地文件永远不再更新。
  • 只对比「上次写成功的字节」:如果对比「上次读到的」,一次写失败(磁盘满、权限错)之后就再也不会重试了,因为下一轮看到内容没变就跳过了。
  • 日志去重:同一个失败原因只打一条,换原因才再打,成功即清空。否则定时器每 60 秒刷一次,error.log 就没法看了。

4.7 配置校验:把「静默误路由」挡在门外

分流配置是运营可以改的。而配置写错最可怕的后果不是报错——是静默生效成另一种语义。所以校验器几乎是逐条对着「代码里会静默出错的位置」来写的:

校验项 拦住的静默故障
matchType 必须显式且为三值之一 "Suffix" 拼错会把 suffix 表静默变成 prefix 表,一批规则一起错
键转小写后不得重名 索引会互相覆盖,赢家取决于 pairs 顺序(未定义)
suffix 键不得含 / suffix 只和路径最后一段比,带斜杠永远匹配不上
exact / prefix 键必须有前导 / 路径恒以 / 开头,缺斜杠规则永远匹配不上
权重必须是 > 0 的整数 权重 0 会被 value > 0 丢掉,字符串权重会被类型检查丢掉,两者都不报错
userids 不得出现在 pathConfig 它是 domainConfig 的字段,放错位置会被静默忽略
死条目检测 既无权重、又无映射/取模/force 的规则,每个请求都静默落全局默认
mapping 余数区间不得重叠 重叠时路由结果取决于 pairs 遍历顺序
mapping 端点不得 >= mod 余数恒 < mod,越界部分是死配置
顶层禁止出现 JSON null cjson 把 null 解成 userdata,== nil 判不出来但 pairs 会枚举到
空规则集拒绝覆盖 挡住截断或误推 {} 把可用配置抹掉

每一条后面都对应一个真实会踩的坑。这套校验的定位不是「防止崩溃」——大部分错误本来就不会崩——而是把语义漂移变成显式拒绝。


5. 核心差异对比

5.1 一张表

维度 ESA 边缘分流 小程序网关分流
决策位置 云外边缘节点 云内 APISIX
运行环境 ESA 边缘函数(JS) APISIX serverless-pre-function(Lua)
可编程能力 平台自带(边缘函数) CLB 不具备,靠 APISIX 补齐
入口协议 标准 HTTP/HTTPS 小程序私有协议
配置来源 编译期常量 + 控制台 Redis 下发(60s)+ 本地文件 + 内置兜底
配置生效 改代码/改控制台,重新发布 写 Redis,秒级~60s 自动生效
部署形态 云外边缘节点(托管) 与腾讯云 UI 机器同机部署
回源网络 公网回源到 ALB / CLB 腾讯云 127.0.0.1 环回,阿里云走公网
覆盖端 H5 / APP / Web 小程序(可扩展至 APP / H5)
判定链 userMapping → mod → force → weighted 完全相同
索引结构 exact / prefix / suffix 完全相同
容灾粒度 改分流配置 routePriority = 0 一键收口
可观测 响应头 X-Backend-Server / X-Match-Rule 同左

5.2 差异一:决策位置——云外 vs 云内

这是根源性的差异,其余几条都从它推出来。

ESA 在流量进入任何一朵云之前做决策。好处是分流彻底、对后端零侵入:后端完全不知道自己是被调度的一方,两朵云看到的就是普通请求。代价是决策只能依据请求本身携带的信息——路径、Cookie、Header。边缘节点看不到 VPC 内的服务健康状态,也拿不到网关解出来的身份。

APISIX 在流量已经进入 VPC 之后做决策。此时请求已经过网关和 WAF,能拿到的东西更多,而且——这是关键——决策点和它要转发到的业务服务在同一台机器上(详见 5.5)。

5.3 差异二:协议——HTTP vs 小程序私有协议

这是小程序链路最实质的优势,也是它必须单独走一条链路的原因。

小程序网关承载的是小程序自己的请求协议,不是标准 HTTP。对小程序来说,请求根本不以裸 HTTP 的形态暴露在公网上。

这带来几个直接收益:

  • 抓包难度陡增:攻击者拿到的不是标准 HTTP 报文,常规的抓包重放工具箱直接失效。
  • 爬虫门槛抬高:想模拟请求,得先过协议层,而不是拼一个 URL 就行。对于有接口级灰度分流的业务,这一点尤其重要——分流规则本身会暴露「哪个接口可以打」。
  • 可以做协议内建的安全能力:签名、时效、防重放这类机制,放在协议层比放在应用层更难绕过。
  • 与 WAF 形成两层防护:私有协议层挡掉一批,WAF 再挡一层。

这条协议层的价值,ESA 那条链路给不了。 ESA 的边缘函数工作在标准 HTTP 语义上——它拿到的就是一个 Request 对象。它能在边缘做拦截和分流,但请求本身仍然是公网上的标准 HTTP。

需要说明的是:两条链路不是替代关系。ESA 适合直接在公网侧接入标准 HTTP 的端(H5、Web、APP),小程序链路的入口是网关,天然在云内。这是两个并行的接入面,各适配各的端。

5.4 差异三:配置下发——编译期 vs 运行时

ESA 的分流规则是代码里的常量表。这有明确的优点:极快,规则是编译期常量,Set 和 Map 在模块加载时构建好,运行期只有 O(1) 查表,没有任何外部依赖。缺点是改配置要走发布流程。

APISIX 的规则从 Redis 下发。多出来的复杂度全在降级路径上——60 秒拉一次,失败要保住上一次的好数据,本地文件要能持久兜底,写文件要原子,定时器死了要有看门狗捞回来。4.5 和 4.6 两节讲的就是这些。

这个复杂度的投入换来的是:运营改分流比例不用找研发发版。在多云灰度这种场景下,这个差别是决定性的——灰度放量本来就是要频繁调的操作。

5.5 差异四:分流层放在哪——云外边缘 vs 云内业务机器

先分清两件事:APISIX 的引入动机是「CLB 不可编程」(见 4.2)——这个理由和网络没有半点关系。

但 APISIX 放在哪里,是完全独立的一个设计选择。而正是「和 UI 机器同机」这个选择,让分流层顺带把网络和算力的账也算了一遍。这一段讲的是后者:不是为什么要有 APISIX,而是为什么它长在这个位置。

ESA 链路:边缘节点判定归属后,回源到阿里云 ALB 或腾讯云 CLB,这一段走公网。请求的每一跳都是一次公网连接。

小程序链路:APISIX 不是独立的网关集群,而是直接跑在腾讯云的 UI 机器里。于是路径变成:

  • 命中腾讯云 → APISIX 把请求交给本机的 UI Nginx,走 127.0.0.1 环回,连 VPC 网络栈都不经过;
  • 命中阿里云 → 这才是唯一需要出机器的流量,走公网 转发到阿里云。

换句话说,在这条链路上,只有判定给阿里云的请求才真正「走出去」。

这个部署形态能成立,靠的是两个前提:

① 腾讯云的机器便宜。 腾讯云的实例单价大约是阿里云的近一半。这意味着腾讯云侧的 UI 机器本来就该多留些余量——把这些余量拿出来跑 APISIX,等于用低单价算力干分流这件活。

② APISIX 的占用足够低。 它基于 OpenResty 的事件驱动模型,常驻内存和 CPU 开销都很小,和 UI 挤在同一台机器上不会抢业务资源。反过来,如果分流器是个吃资源的重量级组件,同机部署就不成立——省下的流量钱还不够赔 UI 的性能。

两者叠加,结果是腾讯云 UI 机器的利用率被抬起来了:原本只跑 UI 的机器,顺手把全站的分流决策也扛了。

那为什么不把分流节点放到边缘?

这里有个自然的疑问:ESA 那条链路把分流做在边缘,小程序这条为什么不照做?

因为流量已经回来了。WAF 这一跳的作用就是把请求收回机房——请求走到 WAF 时,人已经在腾讯云的机房里了。此时再把它送出去到边缘节点做分流,等于让请求白跑一趟公网:出去、判定、再回来,凭空多出一段往返延迟和一段网络流量。

所以分流节点的位置,应该跟着「流量当前在哪」走:

  • ESA 链路里,ESA 本身就是入口,流量天然在边缘,分流就在边缘做;
  • 小程序链路里,WAF 已经把流量带回机房,分流就该就近在机房里做。

分流只该落在请求的必经之路上,不该制造额外的网络往返。 这是两条链路共同的原则,只是各自的「必经之路」不在同一个位置。

这个部署形态落到拓扑上,就是这样——分流层长在机器内部,两条出口分别是环回和 VPC:

同机这个位置带来的三层收益(都是顺带的,不是引入 APISIX 的理由):

第一层,网络路径缩到最短。 命中腾讯云的请求走的是 127.0.0.1——所有网络路径里最短的一条。它比同 VPC 内跳还短:不经过虚拟网络,不出网卡,延迟和抖动趋近于零。只有判定给阿里云的流量才会离开这台机器。

第二层,算力成本被复用掉了。 APISIX 没有独立的机器开销,它借住的是腾讯云那些本来就便宜的 UI 机器。分流的算力成本接近于零,同时把腾讯云侧的资源利用率拉高了。

第三层,LB 层的配置被大幅简化。 引入 APISIX 之前,负载均衡需要为每个后端维护转发规则。引入之后,CLB 只需要把流量转给 APISIX——分流规则全都收在 APISIX 的 Redis 配置里。LB 层的配置面从「一堆规则」变成「一跳转发」,变更风险和运维成本都降下来了。

这里还有一个容易忽略的点:APISIX 本身不是吞吐瓶颈。它继承了 OpenResty 的事件驱动模型,单节点就能承接远超业务峰值的并发,并且可以水平扩展成集群。把分流收敛到 APISIX,在吞吐上留了充足的余量——这正是敢把它和业务塞进同一台机器的底气。反过来,如果分流层是个性能瓶颈,它根本没资格和 UI 抢资源,前面那些顺带收益也就无从谈起。

但代价也要说清楚。 同机部署省下了机器,同时也把 APISIX 的可用性和 UI 机器的可用性绑在了一起:UI 机器崩,分流器跟着崩。分流器一旦不可用,链路内部那些「切流开关」也就没人来执行了——请求根本到不了它。这个问题怎么解,见 7.1。

5.6 差异五:分流逻辑却是同构的

讲完五条差异,说一个反直觉的结论:两套分流器的逻辑其实一模一样。

看这段并排:

// ESA 边缘函数(JavaScript)
function resolveBackend(config, userId) {
  if (config.userMapping?.[userId])  return config.userMapping[userId];  // 1
  if (config.modConfig)              { /* 取模 */ }                       // 2
  if (config.force)                  return config.force;                 // 3
  /* 权重随机 */                                                          // 4
}
-- APISIX 分流器(Lua)
local function resolveBackend(config, userId)
    if config.userMapping and config.userMapping[userId] then  -- 1
        return config.userMapping[userId]
    end
    if config.modConfig then /* 取模 */ end                    -- 2
    if config.force then return config.force end               -- 3
    -- 4 权重随机
end

判定顺序相同,索引结构相同,取模算法相同,兜底策略相同,连响应头都相同(X-Backend-Server / X-Match-Rule)。取 userid 的那段 AT Cookie 解析逻辑,JS 和 Lua 是逐行对应的——包括那个「从后往前找第二段连续数字字段」的细节,和 % 分隔时不能用正则、必须用字面量查找的坑。

这不是偷懒,是刻意的同构。原因很简单:两条链路面向的是同一批用户。如果两边语义不一致,同一个用户在 H5 上落阿里云、在小程序里落腾讯云,跨云的缓存一致性和灰度比例就全乱了。

所以真正的设计主张是:

分流语义统一,分流位置按接入面各就各位。

语义是业务契约,必须一致;位置是工程选择,按每个接入面的特点来定。


6. 小程序链路的延伸价值

6.1 协议层安全

前面 5.3 讲过,这里补充一点:小程序私有协议不是终态,是可复用的接入能力。

6.2 可复用到 APP 和 H5

这是当初做这条链路时埋的一个伏笔。

小程序网关承载的是「端到网关」这一跳的协议。而这一跳是端无关的——只要端侧能跑起来对应的接入 SDK,理论上就能接进来:

端 接入方式 收益
小程序 现已接入 私有协议 + 环回分流
APP 内置 SDK 复用同一套协议层与分流策略
H5 JS SDK 同上;H5 侧的协议强度取决于运行环境

一旦 APP 和 H5 也接进来,收益是复合的:

  1. 一套分流策略覆盖全端——APISIX 里的那份 Redis 配置,对所有接入端同时生效。改一次,全端灰度同步推进。
  2. 协议层安全能力向全端铺开——签名、时效、防重放这些,不用再各端各做一遍。
  3. 环回收益成倍放大——目前只有小程序流量里命中腾讯云的那部分不出机器;全端接入后,全站流量都按同一个比例收敛到本机环回。

6.3 环回收益的规模效应

APISIX 的引入理由是「CLB 不可编程」(4.2),环回是顺带的好处。但这个顺带的好处有个很好的性质:它随分流比例一起放大。

因为分流决策的位置决定了流量的网络路径。决策在云外,每一次回源都是一次公网连接;决策在云内、而且和业务同机,命中本云的部分连 VPC 都不出,直接环回。

分流比例越高,收益越大。当腾讯云的承接比例拉到 100% 时,公网出向流量趋近于零——而多云分流的终局状态,恰恰就是「日常按比例分散,故障时全量切到某一朵」。切到单云的那一刻,也正是环回收益最大的那一刻。


7. 容灾与切流

两套链路都支撑同一套容灾目标,但操作方式不同:

场景 ESA 链路 小程序链路
调整灰度比例 改分流配置 写 Redis key
生效时间 改配置后秒级~15 秒 一个同步周期(≤60s)
全量切到单云 一键切流开关 routePriority = 0
决策依据 健康检查(规划中) 同左

一次真实故障:腾讯云侧 RedisProxy 崩溃导致 APP 瘫痪,运维通过 ESA 快速开关把腾讯云全部接口流量切到阿里云,从改配置到生产恢复约 15 秒。

这次故障也直接影响了小程序链路上的设计——分流配置本身就成了一条关键路径,所以它必须比自己调度的那些服务更稳。4.5 的三级降级和 4.6 的看门狗,都是冲着这个目标去的:Redis 挂了不影响路由,文件坏了还能跑,监控进程死了能自愈。

7.1 两级回滚:从小手术刀到总闸

回滚能力是分级的,越靠前的干预越轻:

级别 动作 作用范围 代价
一级 APISIX 里 routePriority = 0 新链路内部收口,全部压到默认后端 最小,不改变链路结构
二级 全部切回 ESA 整条链路收回 ESA 统一承载 总闸,新链路整体退出

一级是关掉精细分流。 分流规则还是那套,只是让所有请求无条件落到默认后端,一个同步周期内生效。它解决的是「分流配置本身出了问题」这一类事——配置推错了、灰度比例调歪了、某个后端压根不该接量。

二级是总闸。 如果有问题的不是配置,而是 APISIX、小程序网关,或者腾讯云这一串里的任何一环——那切 routePriority = 0 也没用,因为请求根本到不了 APISIX,这个开关也就无从生效。最典型的就是 5.5 里说的那件事:UI 机器崩了。

这时候的动作是做减法:把整条链路收回 ESA,让分流重新由那条已经验证过的成熟链路承担,同时把流量快速切到还活着的云节点上去。

为什么总闸是 ESA? 因为它的失效模式和 APISIX 不一样。

APISIX 和 UI 同机部署(见 5.5),省了机器,代价是分流器的可用性和业务机器的可用性绑在了一起——UI 机器崩,分流器跟着崩。而分流器一崩,上面那一级 routePriority = 0 也就成了空话:请求到不了 APISIX,那个开关没人执行。

ESA 正好补上这个位置。它是边缘化部署的——大量边缘节点分布在不同位置,全部同时故障的概率几乎为零。业务机器会崩,但边缘节点不会一起崩。

于是这两级回滚的分工就清楚了:

  • 一级(routePriority = 0)解决「分流配置错了」,前提是 APISIX 还活着;
  • 二级(切回 ESA)解决「APISIX 或者它所在的机器没了」,靠的是一个失效模式完全不同、且独立于业务机器的承载面。

总闸的价值不在于它有多快,而在于它保证新链路无论怎么崩,都有一条已知能工作的旧路径可以退回去。新链路是增量,ESA 是那个「出事了总能回去」的地方。

一句话概括这套设计:

一个和业务同生共死的分流器,必须配一个不同生共死的兜底。


8. 演进方向

8.1 理想态:小程序网关直连 EO,分流回到边缘

先说终局,因为它反过来解释了现在的很多选择。

目前「分流在机房内」,是被现状约束出来的结果——它的前提是「流量已经回到机房了」。而理想状态是让分流重新回到边缘:

小程序网关直接对接腾讯云 EdgeOne(EO),由 EO 在边缘完成分流判定。

为什么这是理想态?因为判定发生在请求的必经之路上,请求不必先进机房、再决定去哪:

场景 现在的路径 理想态
命中腾讯云 进机房 → 就地环回 边缘判定后进机房,路径不变
命中阿里云 先进腾讯云机房,再折返回阿里云 边缘判定后直接去阿里云,根本不进腾讯云机房

关键在第二行。现在命中阿里云的请求,实际上是绕了一圈——先进了腾讯云机房,才被判定要走阿里云,然后再折返出去。EO 接上之后,这一圈就省掉了。

要注意,理想态并不牺牲环回收益:命中腾讯云的请求照样进机房、照样走本机环回。EO 省掉的是命中阿里云那部分「白跑一趟机房」的往返。

更重要的是,这条演进不违反 5.5 的原则,恰恰是它的延伸:

分流节点应该落在流量本来就经过的位置,而不是制造额外的往返。

现在 APISIX 放在机房内,理由是「流量已经到机房了」;将来 EO 立在了请求的必经之路上,分流就该回到边缘。位置跟着流量路径走,原则始终没变。

至于现在这层 APISIX,届时会退居两种角色之一:作为精细分流的兜底层保留,或者直接去掉、由 EO 统一承接。

8.2 其他方向

  1. 自动切流:接入健康检查探针,自动探测各云核心服务可用性,故障时自动调用 API 翻转分流配置——把恢复时间从人工的十几秒压缩到秒级。
  2. 读流量本地化:把跨云的读请求逐步迁到本云中间件,降低跨云依赖。
  3. 主备 → 双活:从「一主一备」向真正双活演进,提升资源利用率。
  4. 统一控制面:把两条链路的分流配置收敛到一个可视化平台——流量分布、健康状态、改配操作都在这一个面板上完成。目前 ESA 走控制台、小程序走 Redis,是两套操作入口。
  5. 全端接入:APP 和 H5 复用小程序网关的协议层与分流策略,让环回收益覆盖全站流量。

9. 小结

一句话概括两套方案的关系:

分流语义是同构的,分流位置是不同的。

ESA 把决策做在云外的边缘节点——决策快、对后端零侵入,用标准 HTTP 语义服务 H5 / APP / Web,配置编译期固化。

小程序链路把决策做在云内的 APISIX——请求先过小程序网关(私有协议,更安全),再进腾讯云 UI 机器上的 APISIX。命中腾讯云的请求走 127.0.0.1 环回、连 VPC 都不出,只有命中阿里云的才真正离开机器;配置从 Redis 下发、运营可自助调整,并且这套协议层与分流策略可以复用到 APP 和 H5。

选哪套,取决于接入面:请求是标准 HTTP 从公网进来的,用 ESA;请求是自家协议从端上进来的,用网关 + APISIX。而两条链路共用的那套判定链——白名单、取模、权重、exact/prefix/suffix——是同一个业务契约,必须保持一致。

把时间拉长看,两条链路最终还会收敛:如果小程序网关能和 EO 打通,分流会重新回到边缘,两条链路的分流位置也就重新一致了。到那时留下的,只有那套从头到尾没变过的判定链。

评论(0)

还没有评论,来抢沙发吧 ✦

WhiteEnzuo

已暂停

--:--
--:--
50%