​背景

CDN域名太多造成请求碎片化,导致以下几个问题:

TCP建连频繁,网络请求性能差

用于请求CDN静态资源的网络连接池资源有限,由于不同域名会各自创建TCP连接,进而竞争TCP连接池资源,导致TCP连接频繁中断。再次发起网络请求需要重新进行TCP建连增加了建连阶段耗时(包括:DNS解析、TCP握手、TLS握手),导致总耗时升高。

域名太多,日常维护成本高

域名太多导致域名管理、性能监控、性能优化、线上变更复杂度增加,人力成本及运维成本高。

如:得物IPv6升级项目、TLS1.3协议升级项目都需要按域名分批执行多次线上变更流程(包括:测试回归,变更申请,变更评审,变更验证,性能监控)。

部分域名命名不规范,存在下线风险

由于历史原因,存在多个不符合现有新域名规范的域名名称(如:xxx.poizon.com,xxx.dewu.com)。非得物主体的域名,存在被强制下线的风险,比如旧域名下线项目,投入了大量人力成本进行改造。

DNS解析IP频繁,阿里云HttpDNS服务成本高

域名太多增加了每个域名在TCP建连时调用阿里云HttpDNS解析对应IP的频次,解析次数升高导致HttpDNS服务成本高。

为了解决CDN域名太多导致的问题,我们决定在客户端侧进行CDN域名收敛优化。

2、CDN域名收敛

2.1 收敛思路

我们先思考一个问题:怎样实现在不影响业务的前提下,客户端侧将多个CDN域名收敛为统一域名?

主要需要考虑以下几个点:

  • 服务端侧无需做任何改造,依然按现有方式下发多个CDN域名的URL。
  • 客户端侧实现在不侵入业务代码的情况下完成CDN域名收敛逻辑,对业务上层无感知。
  • 客户端实现在最终发出网络请求时,请求URL的域名已替换为统一域名。
  • CDN服务端接收到统一域名的URL请求后可以正常回源,返回所请求的静态资源。

先看下CDN域名收敛前客户端通过CDN服务请求静态资源的示例图:

一次静态资源URL请求到源站的过程可以大体分为三段:

  • 客户端侧:业务上层 -> 客户端网络库;
  • 公网:客户端网络库 -> CDN服务;
  • CDN服务侧:CDN服务 -> 阿里OSS源站;

上图红框中第2段 公网:客户端网络库 -> CDN服务 这段为多条一对一关系,要实现多个CDN域名收敛为统一域名,主要就是将这段改造为单条一对一关系。因此,只要将第1段改造为多对一,第3段改造为一对多即可。简单来说就是客户端侧如何收敛域名,CDN服务侧如何分发回源站。

2.2 客户端侧收敛域名

在客户端侧网络库初始化时提前插入网络请求拦截器,用于拦截网络请求后在底层做统一处理,将各域名替换为统一域名。避免侵入业务层代码,实现业务上层无感知情况下底层完成域名统一收敛。

以得物Android端为例,网络库使用的是OkHttp3,OkHttp3支持添加自定义拦截器,我们可以为所有的OkHttpClient对象添加用于CDN域名收敛的拦截器。通过ASpectJ插桩方式实现插入拦截器,既不侵入业务代码,也可以保证后续新创建的OkHttpClient对象都自动插入拦截器。

// ASpectJ插桩@Aspectpublic class OkHttpAspect {
/** * 在okhttp3.OkHttpClient.Builder.new(..)方法后插桩, * 可以保证OkHttpClient使用new方法或buidler方法创建都被覆盖 */ @After("execution(okhttp3.OkHttpClient.Builder.new(..))") public void addInterceptor(JoinPoint joinPoint) { OkHttpClient.Builder target = (OkHttpClient.Builder) joinPoint.getTarget(); addMergeHostInterceptor(target); }
/** * 添加CDN域名收敛拦截器MergeHostInterceptor */ private void addMergeHostInterceptor(OkHttpClient.Builder builder){ // 避免重复添加,判断当前是否已经添加了拦截器,如果已添加则返回 for (Interceptor interceptor : builder.interceptors()) { if (interceptor instanceof MergeHostInterceptor) { return; } } builder.addInterceptor(new MergeHostInterceptor()); }}

当客户端侧业务上层发起静态资源URL网络请求时,网络库拦截器拦截该请求,并执行CDN域名收敛逻辑。将各域名替换为统一域名,并通过插入代表各域名的path前缀(与各域名一一映射)携带原域名信息。

原域名与Path前缀的一一映射表

原域名

Path前缀

image.xxx.com

/image

product.xxx.com

/product

community.xxx.com

/community

image.xxx.com域名的原URL进行请求被拦截后生成新URL的示例

先替换image.xxx.com域名为统一域名cdn.xxx.com,再插入path前缀/image生成新URL。 

原URL与新URL对比如下: 

https://image.xxx.com/xxx.jpg替换为https://cdn.xxx.com/image/xxx.jpg

具体实现代码如下:

/** * 收敛域名逻辑,进行域名与path映射 * * @param urlStr 原url * @param sourceHost 原域名 * @param targetHost 统一域名 * @param pathPrefix 原域名映射的path前缀 * @return 拼接好的新url */public static String replaceMergeHostUrl(String urlStr, String sourceHost,                                     String targetHost, String pathPrefix)
if (!TextUtils.isEmpty(urlStr)) { //替换域名 urlStr = urlStr.replaceFirst(sourceHost, targetHost);
if (!TextUtils.isEmpty(pathPrefix)) { //插入path前缀 StringBuilder urlStrBuilder = new StringBuilder(urlStr); int offset = urlStr.indexOf(targetHost) targetHost.length(); urlStrBuilder.insert(offset, pathPrefix); urlStr = urlStrBuilder.toString(); } }
return urlStr;}

网络库使用新URL请求CDN服务,如果CDN服务节点不存在新URL的资源缓存或缓存已过期,则触发CDN服务侧分发源站逻辑。

2.3 CDN服务侧分发源站

  • CDN服务侧分发源站方案选型

方案1:CDN边缘脚本重定向

CDN边缘脚本阿里云官方文档:https://help.aliyun.com/document_detail/126588.html

编写CDN边缘脚本并部署在CDN服务节点上,支持将请求通过重定向的方式还原转发到源OSS。

方案2:阿里云OSS镜像回源

镜像回源阿里云官方文档:https://help.aliyun.com/document_detail/409627.html

对统一OSS配置镜像回源规则,当统一OSS不存在静态资源时发生404错误,触发镜像回源到源OSS。从源站成功拉取资源后在统一OSS存储一份副本,下次访问时统一OSS便直接返回存储的资源副本,不再触发镜像回源。

镜像回源原理示意图(来源于阿里云官方文档)

两种方案各有优劣,具体对比如下表:

回源方案

优点

不足

CDN边缘脚本重定向

CDN服务侧实现,无需依赖阿里云OSS镜像回源

边缘脚本开发和维护成本较大

执行重定向逻辑对性能有一定影响

边缘脚本迭代上线的稳定性风险较大



阿里云OSS镜像回源

仅需进行镜像回源配置,无开发成本

镜像回源可以将多个OSS资源迁移到统一OSS,对OSS进行了收敛

镜像回源仅全网首次,后续请求直接返回资源副本,性能影响忽略不计



依赖阿里云OSS镜像回源能力

从改造成本及性能影响两方面考虑,最终选择“阿里云OSS镜像回源”作为CDN服务侧分发源站方案。

  • 阿里云OSS镜像回源具体实现

在统一OSS镜像回源配置规则中匹配path前缀,映射还原为原域名对应的源OSS,实现准确镜像回源到源OSS。

Path前缀与源OSS的一一映射表如下:

Path前缀

源OSS

/image

image_oss

/product

product_oss

/community

community_oss

如域名community.xxx.com的OSS镜像回源配置示例图

实现客户端侧收敛、CDN服务侧分发源站后,再来看下客户端通过CDN服务请求静态资源的示例图:

左侧红框中为客户端侧收敛完成多对一改造的示例,右侧红框中为服务侧OSS镜像回源完成一对多改造的示例,架构上基本已经实现CDN域名收敛的目标。但我们还需要考虑如何保证功能上线阶段的稳定性及上线后域名收敛的灵活性。

2.4 上线阶段稳定及灵活放量

通过支持功能整体可灰度及监控日志保证稳定性

配置AB实验开关作为客户端功能灰度开关,支持按百分比放量,控制客户端CDN域名收敛功能的灰度比例,保证功能上线的稳定性。

客户端

key

value

描述

Android

merge_host_android

1-开启,0-关闭(默认)

Android端CDN域名收敛 AB实验开关

iOS

merge_host_ios

1-开启,0-关闭(默认)

iOS端CDN域名收敛 AB实验开关

客户端通过AB实验控制CDN域名收敛功能可灰度的示例图:

在CDN域名收敛功能的关键代码逻辑处预埋日志(支持采样),上报到阿里云日志服务SLS,在SLS平台配置监控告警,便于及时发现线上异常进行处理。

CDN域名收敛功能代码级监控埋点定义

字段

描述

类型

是否必填

bi_id

业务描述

String

必填

"mergeHost"

section

代码执行关键点

String

必填

"init_config_data"

desc

描述

String

可选

"域名收敛数据初始化"

host

当前host

String

可选

"cdn.xxx.com"

url

当前url

String

可选


originHost

原始host

String

可选

"image.xxx.com"

code

http状态码

int

可选

5xx

stack

报错堆栈信息

String

可选


通过支持待收敛域名单独放量保证灵活性

客户端配置中心下发待收敛域名列表配置数据,按单域名维度支持待收敛域名动态下发及逐步放量。

{
"mergeHostConfigs": [{ //待收敛的CDN域名列表
"host": "image.xxx.com", //原域名
"pathPrefix": "/image", //原域名一一映射的path前缀
"rate": 1 //单域名收敛灰度率
}, {
"host": "product.xxx.com",
"pathPrefix": "/product",
"rate": 0.5
}, {
"host": "community.xxx.com",
"pathPrefix": "/community",
"rate": 0.1
}]
}

现在,我们再看下CDN域名收敛后客户端请求静态资源的流程图:

开发、测试、发布阶段的灰度放量流程

AB实验放量流程

阶段

事项

App端新版本开发及测试阶段

T1测试环境AB实验开关关闭,通过白名单命中实验。

测试通过后到灰度前

T1测试环境AB实验组开50%,观察所有安装测试包的内部同事是否有问题反馈。

App灰度期间

线上AB实验组和对照组各开1%,观察线上稳定性。

正式发布

实验组和对照组逐步放量5%,10%,30%,50%。

实验组和对照组各开50%后

观察实验组与对照组数据,在此期间进行待收敛域名按单域名维度收敛放量。

待收敛域名按单域名维度收敛全量后

实验组扩大放量70%,90%,100%。

单域名维度收敛放量流程

阶段

事项

放量前

制定灰度放量时间表。

拉灰度放量通知群,便于线上变更及时通知运维及各业务方。

配置OSS镜像回源规则,通知测试同学进行线下验证,包括:



命中AB实验组,域名收敛功能生效,使用拼接新URL且资源访问正常。

未命中AB实验,域名收敛功能不生效,使用原URL且资源访问正常。

放量中

按放量时间表操作配置中心,发布新灰度量。

线上变更同步到灰度放量通知群,at相关方共同关注各项指标。

同步测试同学进行线上环境回归验证。

观察各监控平台指标1个小时。




放量后

持续关注各项监控指标及用户反馈。

3、CDN多厂商容灾

得物作为电商平台,无论是大促活动(如618,七夕节,双十一,双十二等)还是日常服务,都需要给用户提供稳定可靠的CDN服务。

阿里云CDN服务SLA仅支持99.9%,即每月存在43分钟线上服务不可用的风险 阿里云CDN服务SLA官方文档:http://terms.aliyun.com/legal-agreement/terms/suit_bu1_ali_cloud/suit_bu1_ali_cloud201803050950_21147.html?spm=a2c4g.11186623.0.0.7af85fcey4BKBZ

因此,将多个CDN域名收敛为统一域名后,我们决定再对统一域名进行CDN同厂商、多厂商容灾改造升级。

3.1 容灾思路

主要考虑以下几个点:

  • 单个主域名存在不可用风险,可以通过动态下发域名列表给客户端。
  • 客户端在主域名不可用时自动容灾降级,选择备用域名进行网络请求。
  • 域名列表中所有域名都不可用时,可以还原使用域名收敛前的原域名作为兜底。
  • 对域名列表中不可用的域名,可以通过可用性检测,自动恢复域名可用状态。

3.2 动态下发域名列表

客户端支持CDN域名可配置,根据配置中心下发的域名列表选择加载静态资源时使用的域名。选择域名的优先级策略是按域名在域名列表中的顺序,默认取域名列表第一个可用域名作为当前域名,列表中还可配置同厂商、多厂商的其他域名作为备用域名。域名切换优先级:主域名 -> 同厂商备用域名 -> 多厂商备用域名。


{
"hostListConfig": [{//CDN域名列表
"host": "cdn.xxx.com", //主域名
"rate": 1
}{
"host": "cdn-ss.xxx.com", //同厂商备用域名
"rate": 1 //单域名灰度率
}{
"host": "cdn-bak.xxx.com", //多厂商域名
"rate": 1 //单域名灰度率
}],
"reviveProbeInterval":600000, //域名复活探测时间间隔,单位ms
"configRequestInterval":600000, //配置兜底接口请求时间间隔,单位ms
"configVersion":1, //配置内容版本号,每变更一次配置就需要版本号 1
"publicIpList":["223.5.5.5","180.76.76.76"] //国内公共DNS服务IP
}

3.3 域名自动容灾降级

客户端业务层发起静态资源URL网络请求时,在网络库CDN域名收敛拦截器中执行域名替换逻辑,替换原URL的域名为从域名列表中选择的当前域名,发起加载静态资源网络请求。

监听当前域名请求的异常回调,如果判定当前域名不可用,则更新当前CDN域名的可用性状态字段isDisabled赋值为true(不可用为true,可用为false(默认))。当前域名不可用的判定条件如下:

Http协议状态码返回 5XX(500<=code and code <600)

socket连接超时/失败,并且客户端网络状态正常(排查客户端网络原因)。

客户端网络状态判断方法: 

通过 InetAddress.isReachable函数(Android)或者 ping命令(如:ping -c 1 223.5.5.5) 检测国内公用的DNS服务中 (配置下发IP列表,如:223.5.5.5, 180.76.76.76 ) 至少有一个能ping成功。 

如果ping成功,说明客户端网络正常;如果ping失败,说明客户端网络异常。


/**
* 判断客户端网络是否正常
*/
public static boolean isNetworkStatusNormal() {

//IP列表中有一个公共IP可触达则认为网络状态正常
for (int i = 0; i < publicIpList.size(); i ) {
String ip = publicIpList.get(i);
try {
if (!TextUtils.isEmpty(ip)) {
InetAddress inetAddress = InetAddress.getByName(ip);
boolean result = inetAddress.isReachable(3000);
if (result) {
return true;
}
}
} catch (IOException e) {
DuLogger.t(TAG).e(ip " test is bad, error e = " e);
}
}
return false;
}

如果当前CDN域名不可用,遍历CDN域名列表,获取下一个可用CDN域名作为新的当前CDN域名。再次替换URL的域名为新的当前域名,发起静态资源网络请求,实现端侧域名自动容灾降级支持同厂商、多厂商容灾的能力。

如果CDN域名列表中所有域名都不可用,则执行兜底逻辑,还原使用CDN域名收敛前的原URL,再次发起请求。

域名自动容灾降级流程图:

3.4 域名可用性自动恢复​

对于域名列表状态字段isDisabled已被赋值为true的CDN域名,在CDN域名解禁、CDN服务器故障恢复后,客户端侧需要及时感知并自动恢复CDN域名可用性,保证客户端网络请求流量从优先级低的备用域名逐步切换回主域名。

域名可用性恢复的具体步骤如下:

  • 客户端实现域名可用性探测逻辑,支持检测CDN域名是否恢复可用。
  • 网络库拦截到静态资源URL网络请求后,如果当前时间距离上次探测时间大于间隔时间(配置下发,如10分钟),异步执行域名可用性探测逻辑。
  • 在子线程中遍历CDN域名列表,依次取出列表中可用性状态字段isDisabled被赋值为true的不可用域名,替换原URL的原域名生成新的URL。
  • 使用新URL发送Http HEAD请求进行探测,如果请求返回的Http状态码为2XX,则说明该CDN域名已恢复可用,将状态字段isDisabled赋值为false;否则跳过。
  • 继续遍历并探测下一个不可用域名的可用性,直到CDN域名列表遍历结束。
  • 重新恢复可用性的CDN域名在后续静态资源网络请求中可按优先级被重新选择使用。

域名可用性自动恢复流程图:

4、遇到的挑战

4.1 存在少量资源动态更新的场景

  • 挑战点

客户端通过CDN服务请求的静态资源(图片,视频,zip文件)一般文件内容不会更新(URL不变,文件被覆盖更新)。但存在部分业务场景请求的资源是json文件,会动态更新json文件内容,如客户端配置中心平台发布配置数据,就会覆盖更新json文件。

在上面我们已经介绍过OSS镜像回源工作原理,从源OSS拉取资源后统一OSS会存储一份副本,下次访问时便直接返回存储的资源副本,不再触发镜像回源。因此,这类资源动态更新的情况需要进行单独兼容处理。

  • 解决方案

梳理出所有存在资源内容动态更新的业务场景(可枚举的),推动进行OSS双写兼容改造。资源内容需要动态更新时,对源OSS和统一OSS进行同步更新,保证统一OSS上资源的文件内容是最新的。

4.2 CDN服务控制台监控不支持Path维度监控

  • 挑战点

客户端完成CDN域名收敛后,多个原域名被收敛为统一域名,原域名以Path前缀形式进行区分。业务侧期望域名收敛后可以支持按Path维度查看性能监控报表,但阿里云CDN服务控制台目前仅支持域名维度监控。因此,需要考虑自研Path维度监控报表。

  • 解决方法

客户端网络监控平台支持Path维度监控指标,包括:请求次数,回源次数,流量,带宽,成本。

通过阿里云CDN提供的获取Path维度监控数据API查询数据,包括请求次数和流量。

通过阿里云CDN提供的回源热门URL接口API查询热门URL的回源次数,然后统计Path维度回源次数。

Path维度监控报表示例:

4.3 后续新申请的CDN域名、OSS如何卡口​

  • 挑战点

虽然对现存的多个域名和多个源OSS做了域名收敛和镜像回源,但还需要考虑如何保证得物App端不会新增新的CDN域名。

  • 解决方法

找运维同学沟通后,在新域名及新OSS申请流程中加入审批环节,实现卡口。

4.4 配置数据也是通过CDN域名下发的,存在不可用风险​

  • 挑战点

由于配置数据也是通过CDN域名下发的,当域名不可用时,客户端通过配置中心SDK无法拉取到最新的配置数据(如配置平台新配置的域名列表),客户端资源请求故障将无法通过调整配置数据的方式得到及时恢复。

  • 解决方法

为了保证配置数据的及时性和可靠性,我们可以添加获取配置数据的专用API接口作为兜底。当App冷启动或CDN域名列表中所有域名都不可用时,异步发送专用API接口请求获取配置数据。


获取配置数据的专用API接口
接口定义:/app/config-center/module-config
请求方式:Post

由于配置中心SDK和专用API接口都是获取同源的配置数据,且相互独立,因此客户端在使用配置数据时,需要对2个数据来源的配置数据进行对比,使用最新的配置数据。如何实现呢?我们通过增加一个configVersion字段代表配置数据的版本,每次更新配置数据时对configVersion进行递增 1,版本号越大,代表数据越新。客户端就可以通过判断两个数据来源的配置数据中configVersion的大小确定哪个配置数据为最新数据,并进行使用。


//客户端获取最新的配置数据
private ConfigModel getConfigData() {
ConfigModel configModel = DataSource.getConfigCenterData();
ConfigModel apiConfigModel = DataSource.getConfigApiData();

//使用配置中心下发配置数据与兜底接口下发配置数据中最新的数据
if (configModel != null && apiConfigModel != null) {
if (configModel.configVersion < apiConfigModel.configVersion) {
configModel = apiConfigModel;
}

} else if (configModel == null && apiConfigModel != null) {
configModel = apiConfigModel;
}

return configModel;
}

5、上线后效果

完成8个CDN域名收敛收敛为统一主域名,并支持CDN域名同厂商、多厂商容灾能力。

网络请求性能提升

  • Android 平均耗时:354ms -> 277ms,降低77ms(21%)
  • iOS 平均耗时:226ms -> 194ms,降低32ms(14%)

网络请求异常率降低

CDN域名收敛功能上线后,TCP连接复用率明显提升,DNS解析失败、TCP建连失败导致的异常次数明显减少,双端异常率均有明显降低

  • Android 异常率:3.42% -> 2.56%,降低0.86%
  • iOS 异常率:0.61% -> 0.13%,降低0.48%

稳定性提升

  • 域名主体统一为dewucdn.com,避免类似旧域名下线全域改造风险。
  • 容灾能力提升,CDN域名支持同厂商、多厂商容灾兜底, SLA从99.9%提升到99.99% 。

支持阿里云&腾讯云多厂商容灾能力,用户网络正常情况下访问阿里云CDN服务请求资源失败(http code 5xx,或socket失败),自动重试切换备用域名走腾讯云CDN请求资源,SLA从单厂商99.9%提升到99.99% 。

HTTPDNS成本降低

阿里云HttpDNS服务成本降低24%

6、踩坑经验

灰度放量阶段,内部同学反馈鉴别页面存在部分鉴别图片模糊问题

原因:OSS镜像回源规则配置问题,勾选了“回源参数:携带请求字符串”。

这意味着统一OSS在镜像回源时会将“?”后的请求参数携带给源OSS,源OSS按裁剪参数返回给统一OSS裁剪后的缩略图,而非原图。客户端再次请求时,统一OSS便会以该缩略图作为原图,再根据图片裁剪参数做了二次裁剪,导致返回给客户端的图片尺寸很小,被View拉伸后造成图片模糊。

需要注意的是,全网首次请求携带的裁剪参数越接近原图,对图片的清晰度影响越小;裁剪参数越小,二次请求的裁剪参数越大,对图片被拉伸的程度越大,图片越模糊。

全网首次请求示例URL:https://cdn.xxx.com/image-cdn/app/xxx/identify/du_android_w1160_h2062.jpeg?x-oss-process=image//resize,m_lfit,w_260,h_470 二次请求示例URL:

https://cdn.xxx.com/image-cdn/app/xxx/identify/du_android_w1160_h2062.jpeg?x-oss-process=image//resize,m_lfit,w_760,h_1500

如示例URL,原图是一张宽1160、高2062的图片,因客户端View(宽260,高470)展示的需要,拼接了阿里云图片裁剪参数"x-oss-process=image//resize,m_lfit,w_260,h_470"。命中CDN域名收敛灰度后,全网首次使用替换为统一域名的新URL请求该图片。CDN服务无此URL缓存,回源到统一OSS,统一OSS触发镜像回源,携带请求参数给源OSS。源OSS会根据请求参数中的图片裁剪参数返回宽259、高473的缩略图给统一OSS,统一OSS将该缩略图存储做为原图副本。

二次请求时,统一OSS按二次请求的裁剪参数宽760,高1500再做裁剪,然而原图副本的宽、高均小于裁剪参数(宽260 < 760、高470 < 1500),最终返回给客户端宽260、高470的缩略图。客户端View(宽760,高1500)拉伸缩略图进行展示,导致图片模糊。

测试阶段仅验证了图片是否正常返回并展示,未关注携带图片参数镜像回源会导致图片模糊问题。

解决方法:

关闭image.xxx.com灰度放量配置。

重新配置OSS镜像回源规则,去掉勾选“回源参数:携带请求字符串”。使用新的Path前缀作为映射,保证重新触发镜像回源正确拉取原图。

进行测试验证,确认无图片模糊问题后重新放量。

删除/image-cdn下错误的缩略图片,避免oss存储成本浪费。

7、小结

通过CDN域名收敛我们不仅收获了CDN网络性能、稳定性上的提升,而且实现了多个域名的统一和规范,大大降低了后续CDN域名的优化和维护复杂度。另外,也支持了CDN主域名的容灾能力,保证了线上服务的稳定性。​