一个接口管多家 CDN:多厂商刷新预热的适配层设计

4279 字
21 分钟
一个接口管多家 CDN:多厂商刷新预热的适配层设计

很多公司会同时用好几家 CDN:容灾、调度、成本都需要。但每家厂商的控制台和 API 都不一样,刷新一批 URL,要分别登录几个控制台、调几套接口。

我在字节参与过火山引擎多云 CDN 的商业化,负责其中的内容管理模块:客户只调用多云的一套接口,多云平台把刷新、预热任务分发到客户接入的各家厂商,汇总结果后统一返回。同一套能力,对内也服务字节的融合 CDN 平台,两条线并行维护。

这篇整理这个模块的设计思路。

说明:文中的架构图和代码都是为了说明思路而简化的示例,不是原始实现。实际系统还要处理很多这里没有展开的细节,比如各厂商千差万别的边界情况、任务去重、异常告警、灰度发布和监控等。

一、要解决的问题#

内容管理看起来简单,就是把一批 URL 发给厂商。难点在于各家厂商的差异和限制:

  • 接口不同:认证方式(AK/SK 签名、Token、各种自定义签名算法)、入参出参格式、任务状态的定义都不一样;
  • 单次提交上限不同:有的厂商一次最多提交几十个 URL,有的上百个;
  • 频率限制不同:每秒能调用几次、超了是直接报错还是排队,各不相同;
  • 总量限制不同:很多厂商对每个账号每天能刷新、预热的 URL 总数有配额;
  • 都是异步的:提交成功只代表厂商收到了任务,真正执行完要另外查询。

多云平台要做的,就是把这些差异全部吸收掉,让客户感觉只在用「一家」CDN。

二、整体架构#

统一接口

单次 ≤ 1000 URL,50 QPS

主任务

按厂商拆分的子任务

共享各厂商配额

共享各厂商配额

共享各厂商配额

查询执行状态

更新子任务、主任务进度

客户 / 融合 CDN

多云内容管理 API

MongoDB

主任务 / 子任务

RocketMQ

下发服务 1

下发服务 2

下发服务 N

Redis

厂商适配层

厂商 A

厂商 B

厂商 C

定时轮询

统一接口

单次 ≤ 1000 URL,50 QPS

主任务

按厂商拆分的子任务

共享各厂商配额

共享各厂商配额

共享各厂商配额

查询执行状态

更新子任务、主任务进度

客户 / 融合 CDN

多云内容管理 API

MongoDB

主任务 / 子任务

RocketMQ

下发服务 1

下发服务 2

下发服务 N

Redis

厂商适配层

厂商 A

厂商 B

厂商 C

定时轮询

一次刷新请求的生命周期:

定时轮询厂商(经适配器)Redis下发服务RocketMQMongoDB多云 API客户定时轮询厂商(经适配器)Redis下发服务RocketMQMongoDB多云 API客户alt[配额充足][配额不足]loop[每个主任务定时执行,直到全部子任务结束]提交刷新(≤ 1000 URL)1多云层配额、限频校验2创建主任务3按厂商和单次上限拆出子任务4投递子任务5返回主任务 ID6消费子任务7检查并扣减该厂商配额8提交 URL9厂商任务 ID10子任务 → 已提交11延迟后重新投递12查询子任务执行状态13更新子任务,汇总主任务状态和完成率14查询主任务进度15
定时轮询厂商(经适配器)Redis下发服务RocketMQMongoDB多云 API客户定时轮询厂商(经适配器)Redis下发服务RocketMQMongoDB多云 API客户alt[配额充足][配额不足]loop[每个主任务定时执行,直到全部子任务结束]提交刷新(≤ 1000 URL)1多云层配额、限频校验2创建主任务3按厂商和单次上限拆出子任务4投递子任务5返回主任务 ID6消费子任务7检查并扣减该厂商配额8提交 URL9厂商任务 ID10子任务 → 已提交11延迟后重新投递12查询子任务执行状态13更新子任务,汇总主任务状态和完成率14查询主任务进度15
  1. 客户调用多云接口提交 URL,多云层先做自己的配额和限频;
  2. 生成一个主任务,存进 MongoDB;
  3. 按客户接入的厂商、以及每家厂商的单次上限,把主任务拆成多个子任务,投递到 RocketMQ 排队;
  4. 多台下发服务消费子任务,通过 Redis 检查并扣减该厂商的共享配额,再经适配层调用厂商接口;
  5. 每个主任务开启定时轮询,查询各子任务在厂商侧的执行状态,汇总成主任务的状态和完成率。

下面逐个展开。

三、适配器模式:屏蔽厂商差异#

所有厂商实现同一个接口,多云平台的其他部分只和这个接口打交道,完全不知道背后是哪家厂商:

// 统一的任务类型和状态,由多云平台定义
type TaskType string // refresh_url / refresh_dir / preload
type VendorTaskStatus string // pending / running / success / failed
// 每家厂商的能力和限制,作为适配器的一部分声明出来
type Limits struct {
MaxURLsPerCall int // 单次提交上限
CallsPerSecond int // 接口频率限制
DailyQuota map[TaskType]int // 每日总量配额,0 表示不限
}
type Adapter interface {
Name() string
Limits() Limits
// 提交任务,返回厂商侧的任务 ID
Submit(ctx context.Context, cred Credential, typ TaskType, urls []string) (vendorTaskID string, err error)
// 查询执行状态,把厂商自己的状态翻译成统一状态
Query(ctx context.Context, cred Credential, vendorTaskID string) (VendorTaskStatus, error)
}
var registry = map[string]Adapter{}
func Register(a Adapter) { registry[a.Name()] = a }

每家厂商一个实现,认证签名、参数拼装、状态翻译都封装在里面。接入一家新厂商,就是新写一个适配器并注册进去,核心流程一行都不用改。

限制也是适配器的一部分。单次上限、频率、配额这些信息跟着适配器走,拆分和限流的逻辑读取它们,而不是写死在流程里。这样不同厂商的差异就收敛在一个地方。

四、多云层的配额与限频#

客户面对的是多云平台,所以多云层先要有一套自己的、统一的限制:

  • 单次最多 1000 个 URL;
  • 接口频率 50 QPS,也就是每秒最多可以接收 5 万个 URL。

这组数值是参考各家主流厂商的能力,取的一个中间值。它带来两种情况:

  • 如果多云的单次上限比某家厂商的大,提交给这家厂商时要拆分成多批;
  • 如果比厂商的小,就直接提交。

这样设计的好处是,客户不需要了解每家厂商的限制;代价是多云层要负责把一个「大请求」翻译成符合每家厂商规矩的若干「小请求」。

另外,如果厂商侧有每日总量限制,这是多云平台无法替厂商突破的。这类限制会同步给客户,让客户去厂商那里申请提高限额。

五、任务拆分与排队:RocketMQ#

客户的请求是突发的,比如一次发版要刷新几万个 URL;而厂商接口的承受能力是固定的。中间必须有一层缓冲,所以子任务会进 RocketMQ 排队,由多台下发服务按各厂商的节奏消费。

拆分逻辑按每家厂商的单次上限切批:

func split(urls []string, limit int) [][]string {
var batches [][]string
for len(urls) > limit {
batches = append(batches, urls[:limit])
urls = urls[limit:]
}
if len(urls) > 0 {
batches = append(batches, urls)
}
return batches
}
// 一个主任务,按客户接入的每家厂商分别拆成子任务
func dispatch(task *MainTask) error {
for _, vendor := range task.Vendors {
a := registry[vendor]
for _, batch := range split(task.URLs, a.Limits().MaxURLsPerCall) {
sub := NewSubTask(task.ID, vendor, batch)
if err := saveSubTask(sub); err != nil { // 子任务先落库,再投递
return err
}
if err := mq.Send("content-task", sub.ID); err != nil {
return err
}
}
}
return nil
}

实际的拆分比这复杂一些:有的厂商对目录刷新、URL 刷新、预热分别有不同上限,有的对单个任务里的域名数量也有要求,所以每家厂商有自己单独的拆分逻辑。这部分只能靠深入了解每家厂商的机制,再一点点适配。

主任务和子任务存在 MongoDB 里:一个主任务下挂着数量不定的子任务,每家厂商返回的任务信息格式也不一样,文档型数据库存这种结构很自然。

六、分布式共享配额:Redis#

下发服务有多台,它们同时在给同一家厂商提交任务。厂商的频率和总量限制是针对整个账号的,不是针对某一台机器的。如果每台服务器各自计数,加起来就会超限。

所以配额计数放在 Redis 里,所有下发服务共享。Redis 的键可以设置过期时间,正好对应厂商限额的时间窗口:限每秒就让键 1 秒过期,限每天就让键在当天结束时过期,窗口一过,计数自动清零。

Redis:厂商 A 的配额计数

多台下发服务(同时给厂商 A 提交)

INCRBY 原子计数

INCRBY 原子计数

INCRBY 原子计数

未超限:放行

超限:DECRBY 回退

窗口结束,键过期

下发服务 1

本批 100 个

下发服务 2

本批 100 个

下发服务 3

本批 100 个

quota:A:账号:当前窗口

已用 / 上限

TTL = 窗口剩余时间

调用厂商 A 接口

子任务延迟重投

计数自动清零

Redis:厂商 A 的配额计数

多台下发服务(同时给厂商 A 提交)

INCRBY 原子计数

INCRBY 原子计数

INCRBY 原子计数

未超限:放行

超限:DECRBY 回退

窗口结束,键过期

下发服务 1

本批 100 个

下发服务 2

本批 100 个

下发服务 3

本批 100 个

quota:A:账号:当前窗口

已用 / 上限

TTL = 窗口剩余时间

调用厂商 A 接口

子任务延迟重投

计数自动清零

检查和扣减必须避免竞争:如果先 GET 读出已用量、判断够不够、再写回去,两台服务器可能同时读到「还剩 100」,都去提交 100 个,就超了。

解决办法是利用 INCRBY 本身是原子的:不先读再写,而是先加、再判断。Redis 单线程执行命令,多台服务并发 INCRBY 时,每台拿到的都是自己加完之后的结果,不会读到同一个值。加完发现超过上限,就 DECRBY 把这次的数量退回去,本次不放行。

下发服务用 Go(go-redis)直接操作 Redis,大致是这样(示例):

// 申请本次要提交的 n 个 URL 的配额;limit 为该厂商在窗口内的上限
func acquire(ctx context.Context, rdb *redis.Client, vendor, account string,
n, limit int64, window time.Duration) (bool, error) {
// 键名带上窗口编号,窗口切换时自然换成新键
key := fmt.Sprintf("quota:%s:%s:%d", vendor, account,
time.Now().Unix()/int64(window.Seconds()))
// 窗口内第一次使用时创建键并设置过期时间;键已存在则什么都不做
if err := rdb.SetNX(ctx, key, 0, window).Err(); err != nil {
return false, err
}
// 原子地加上本次数量,返回加完之后的总量
used, err := rdb.IncrBy(ctx, key, n).Result()
if err != nil {
return false, err
}
if used > limit {
// 超限:把本次数量退回去,不放行
rdb.DecrBy(ctx, key, n)
return false, nil
}
return true, nil
}

这里有两个细节:

  • 先 SET NX 带过期时间,再 INCRBY。如果反过来先 INCRBY 再单独 EXPIRE,进程恰好在两步之间挂掉,这个键就永远不过期,配额会一直被占着。SET NX 一条命令同时完成「创建」和「设置过期」,就没有这个问题。
  • 超限时的回退是安全的。在 INCRBY 和 DECRBY 之间,其他服务可能短暂看到一个偏高的已用量,结果只是它们这一次也被拒绝、稍后重试,不会导致超发。宁可偶尔少放行,也不能多放行。

如果逻辑更复杂,比如要同时扣减「每秒频率」和「每日总量」两个计数器,并且要求两者同时成功或同时失败,就可以把这些步骤写成一段 Lua 脚本,在 Go 里用 EVAL 调用,由 Redis 一次性执行完。单个计数器的场景,直接用命令就足够了。

拿不到配额的子任务不会被丢弃,而是重新投回队列,稍后再提交,和调用失败一样走 3 次重试的逻辑。

Redis 出问题怎么办? 这里的选择是宁可停,也不超发:申请配额时 Redis 报错,和配额不足一样处理,子任务重新入队重试,重试用完仍失败就标记为失败,绝不会在没有配额的情况下调用厂商接口。这是因为刷新、预热本身是异步任务,晚几分钟执行可以接受;而超出厂商限额,可能导致厂商侧报错甚至限制账号。另外,Redis 使用的是公司的高可用部署,实际很少出现不可用;每个主任务也有定时检查兜底,不会有任务卡住无人处理。

这里只是配额控制的基本思路,实际实现中还有不少细节没有展开,比如窗口边界上的计数、各厂商限额口径的差异(按 URL 数、按请求次数还是按目录)。

七、失败重试与进度汇总#

重试:调用厂商接口失败(超时、限流、服务端错误)时,最多重试 3 次。仍然失败就不再重试,这个子任务标记为失败,但主任务照常完成,只是完成结果里会体现哪些子任务、哪家厂商失败了。

这样做的考虑是:一家厂商出问题,不应该拖住其他厂商的结果。客户能清楚地看到「厂商 A、B 已刷新成功,厂商 C 失败」,再决定要不要单独重试。

重试策略跟着时效性走。 不同任务类型对时效的要求不一样,重试次数和主任务的检查周期可以按任务类型配置:

任务类型时效要求重试与检查策略
刷新(URL、目录)高:源站内容已更新,用户可能正在访问旧内容重试次数少、检查周期短,失败尽快反馈给客户
预热低:通常在活动或发版前提前进行可以配置更多重试次数、更长的检查周期,耐心等配额和厂商恢复

比如某家厂商当天的刷新配额用完了,刷新任务会在几次重试后直接失败,客户能立刻看到,去厂商那里申请提额,而不是让任务悬而未决;预热任务则可以多等一会儿再试。

进度汇总:厂商的刷新、预热都是异步的,提交成功之后还要等它真正执行完。所以每个主任务都会开启一个定时轮询:

主任务:刷新 3000 个 URL

完成率 = 已结束子任务 / 子任务总数

厂商 A(单次上限 1000)

厂商 B(单次上限 500)

子任务 A-1 ✅ 成功

子任务 A-2 ✅ 成功

子任务 A-3 ⏳ 执行中

子任务 B-1 ✅ 成功

子任务 B-2 ❌ 重试 3 次后失败

……共 6 个

主任务:刷新 3000 个 URL

完成率 = 已结束子任务 / 子任务总数

厂商 A(单次上限 1000)

厂商 B(单次上限 500)

子任务 A-1 ✅ 成功

子任务 A-2 ✅ 成功

子任务 A-3 ⏳ 执行中

子任务 B-1 ✅ 成功

子任务 B-2 ❌ 重试 3 次后失败

……共 6 个

图中的厂商上限只是示意。同一批 URL 要分别提交给客户接入的每一家厂商,所以每家厂商各有一组子任务。

  • 查询所有未结束子任务在厂商侧的状态,经适配器翻译成统一状态后更新;
  • 根据子任务的状态,汇总出主任务的状态和完成率;
  • 所有子任务都结束(成功或失败)后,主任务结束,停止轮询。

客户在多云的任务列表里看到的是一个主任务,点开可以看到它被拆成了哪些厂商的哪些子任务,以及每个子任务的执行情况。

八、接入新厂商:从手工适配到 AI 辅助#

最初接入一家新厂商很慢。要先读完厂商的文档,弄清楚它的认证鉴权方式、内容管理的接口和限制,再手动写签名算法、请求和响应的结构体、状态映射,最后按适配器模式嵌入平台、联调测试。

后来我们开始借助 AI 来写适配器里最繁琐的部分:各厂商的认证鉴权实现,以及接口入参、出参的代码。这部分本质上是把厂商文档翻译成代码,规则明确、重复性高,正好是 AI 擅长的。人主要负责的,变成了确认厂商的限制和边界情况、设计拆分策略、联调验证。

结果是,接入一家新厂商从原来的较长周期,缩短到一两天就能完成开发并发布上线。

之所以能做到这一点,前提是前面的适配器设计:接口和限制的抽象已经稳定,新厂商只需要填空,AI 生成的代码有明确的边界和约束,验证起来也简单。

九、总结#

回头看,这个模块的几个关键决策:

  1. 适配器模式把厂商差异收敛到一处,核心流程和厂商无关;
  2. 多云层先定义自己的统一限制,再负责翻译成每家厂商的规矩,客户不需要了解厂商细节;
  3. 消息队列削峰,把突发请求转成平稳的下发节奏;
  4. Redis 共享配额,用过期时间对齐厂商的限额窗口,用 INCRBY 的原子性保证多台服务不超发;
  5. 失败隔离,一家厂商失败不影响其他厂商,结果透明地展示给客户;
  6. 稳定的抽象让 AI 辅助真正落地,接入新厂商从「写一个系统」变成「填一个模板」。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

一个接口管多家 CDN:多厂商刷新预热的适配层设计
https://tracerzzz.cn/posts/multi-cdn-content-management/
作者
tracerZzz
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
从业务里长出来的状态机:一个资源建设审批流平台的设计与反思
工作我在字节从零设计开发过一个 CDN 资源建设流程平台,管理物理机、带宽、IP 从需求发起到交付的全过程,状态机和审批流都是自己实现的。这篇记录当时的设计:多级评审、乐观锁、基于 MongoDB 的变更 diff 审计、父子任务自动结单、事务化回滚、按平台协商的轮询;以及现在回头看,状态机和业务规则应该怎么解耦。
2
CDN 的错峰与超卖:为什么厂商能把同一份带宽卖好几次
工作在 95 计费下,一个新客户接入后,大盘的 95 可能几乎不涨,这就是错峰客户。它让 CDN 厂商可以把同一份带宽卖给多个客户,也就是超卖。这篇用模拟数据讲清楚错峰和超卖,以及衡量它们的两个指标:单个客户的边际超卖倍数,和整个平台的超售比。
3
CDN 是怎么收费的:从流量、95 峰值到日峰月均,厂商和客户两边看计费
工作我在百度云 CDN 做过客户账单,在字节做过火山 CDN 的计费推量,也做过融合 CDN 向多家厂商采购的对账和成本分摊,厂商和采购两边都经历过。这篇整理 CDN 常见的计费方式:流量、带宽峰值、95 峰值、日峰月均、月第四峰值、夜间减半、闲忙时、请求数,以及计费区域、包头系数这些容易忽略的细节,并用一个月的模拟数据对比不同方式算出来差多少。
4
指标异动归因:从贡献度公式到 CDN 毛利率的层层下钻
工作整理我学习指标异动归因的过程:加法、乘法、比率三类指标分别怎么算贡献度,为什么比率指标要拆出「结构变化」,以及我在字节火山 CDN 经营分析中,如何用这套方法对毛利率、收入、成本做逐层递归归因,把收入变化拆到客户增减、用量变化和价格变化。
5
CDN 新客户接入前的价格测算:用波形拟合算出最低成本价
工作火山 CDN 经营分析平台上的价格测算功能:销售拿到一个新客户的带宽打点,平台把它和大盘拟合,判断客户是否错峰、算出边际超卖倍数,再结合资源诉求的加权成本和包头系数,给出不同计费方式下的最低成本价。这篇讲它的输入、计算过程、示例和后续的优化方向。
随机文章随机推荐

评论区

Profile Image of the Author
tracerZzz
想当木匠的程序员!
分类
标签
站点统计
文章
19
分类
9
标签
48
总字数
79,096
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
ESA Pages
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录