系统极客一直在努力
专注操作系统及软件使用技能

UPYUN CDN 高可用架构实践

UPYUN CDN 高可用架构实践

10 月 22 日,2015 全国架构师大会(SACC)在北京新云南皇冠假日酒店盛大召开,来自全国各地的逾 2000 名开发者参加大会。今天 ,UPYUN 架构师张聪受邀出席了会议,并做了关于 UPYUN CDN 专题技术实战分享。

在“互联网+ ”的构建中,云、网、端被很多人称作“最强有力的三件武器”。而对于担负着数据托管重责的云计算平台来说,保证稳定可靠是最为关键的。这点,必须通过构建完善的应用架构来实现。在分享中,张聪围绕着架构议题,首先阐述了 NGINX 作为反向代理服务器,如何取其精华为 UPYUN 的 CDN 节点提供服务,使得程序员在开发一个模块的代码从原来的 500 行减少到 50 行,并且保持同样的性能。

ngx_lua 在 UPYUN CDN 的应用

张聪介绍,UPYUN 的 CDN 节点大量使用了 NGINX 作为反向代理服务器,其中绝大部分的业务逻辑由 Lua 来驱动。而 UPYUN CDN 的框架,就是借鉴了 Openresty 的组织方式,把 ngx_lua 以及 UPYUN 需要用到的 lua-resty-* 类库直接集成进来自身系统维护的。Openresty 是一套基于 NGINX 核心的相对完整的 Web 应用开发框架,包含了 ngx_lua 在内的众多第三方优秀的 NGINX C 模块。

UPYUN CDN 线上通过 Redis 从复制的方式由中心节点向外围节点同步用户配置,另外,由于 Redis 本身不支持加密传输,UPYUN 还在此基础上利用 stunnel 对传输通道进行了加密,保障数据传输的安全性。

缓存是万金油

当然,不是说节点上有了 Redis 就能直接把它当做主要的缓存层来用了,要知道从 NGINX 到 Redis 获取数据是要消耗一次网络请求的,而这个毫秒级别的网络请求对于外围节点巨大的访问量来说是不可接受的。所以,在这里 Redis 更多地承担着数据存储的角色,而主要的缓存层则是在 NGINX 的共享内存上。

根据业务特点,我们的缓存内容与数据源是不需要严格保持一致的,既能够容忍一定程度的延迟,因此这里简单采用被动更新缓存的策略即可。ngx_lua 提供了一系列共享内存相关的 API ,可以很方便地通过设置过期时间来使得缓存被动过期,值得一提的是,当缓存的容量超过预先申请的内存池大小的时候,ngx.shared.DICT.set 方法则会尝试以 LRU 的形式淘汰一部分内容。

以下代码片段给出了一个简陋的实现,当然我们下面会提到这个实现其实存在不少问题,但基本结构大致上是一样的,可以看到下面区分了 4 种状态,分别是:HIT 和 MISS, HIT_NEGATIVE 和 NO_DATA,前两者是对于有数据的情况而言的,后两者则是对于数据不存在的情况而言的,一般来说,对于 NO_DATA 我们会给一个相对更短的过期时间,因为数据不存在这种情况是没有一个固定的边界的,容易把容量撑满。

UPYUN CDN 高可用架构实践

什么是 Dog-Pile 效应?

在缓存系统中,当缓存过期失效的时候,如果此时正好有大量并发请求进来,那么这些请求将会同时落到后端数据库上,可能造成服务器卡顿甚至宕机。

很明显上面的代码也存在这个问题,当大量请求进来查询同一个 key 的缓存返回 nil 的时候,所有请求就会去连接 Redis,直到其中有一个请求再次将这个 key 的值缓存起来为止,而这两个操作之间是存在时间窗口的,无法确保原子性:

UPYUN CDN 高可用架构实践

避免 Dog-Pile 效应一种常用的方法是采用主动更新缓存的策略,用一个定时任务主动去更新需要变更的缓存值,这样就不会出现某个缓存过期的情况了,数据会永远存在,不过,这个不适合我们这里的场景;另一种常用的方法就是加锁了,每次只允许一个请求去更新缓存,其它请求在更新完之前都会等待在锁上,这样一来就确保了查询和更新缓存这两个操作的原子性,没有时间窗口也就不会产生该效应了。

lua-resty-lock – 基于共享内存的非阻塞锁实现

首先,我们先来消除下大家对锁的抗拒,事实上这把共享内存锁非常轻量。第一,它是非阻塞的,也就是说锁的等待并不会导致 NGINX Worker 进程阻塞;第二,由于锁的实现是基于共享内存的,且创建时总会设置一个过期时间,因此这里不用担心会发生死锁,哪怕是持有这把锁的 NGINX Worker Crash 了。

那么,接下来我们只要利用这把锁按如下步骤来更新缓存即可:

  • 检查某个 Key 的缓存是否命中,如果 MISS,则进入步骤 2。
  • 初始化 resty.lock 对象,调用 lock 方法将对应的 Key 锁住,检查第一个返回值(即等待锁的时间),如果返回 nil,按相应错误处理;反之则进入步骤 3。
  • 再次检查这个 Key 的缓存是否命中,如果依然 MISS,则进入步骤 4;反之,则通过调用 unlock 方法释放掉这把锁。
  • 通过数据源(这里特是 Redis)查询数据,把查询到的结果缓存起来,最后通过调用 unlock 方法释放当前 Hold 住的这把锁。

当数据源故障的时候怎么办?NO_DATA?

同样,我们以上面的代码片段为例,当 Redis 返回出现 err 的时候,此时的状态即不是 MISS 也不是 NO_DATA,而这里统一把它归类到 NO_DATA 了,这就可能会引发一个严重的问题,假设线上这么一台 Redis 挂了,此时,所有更新缓存的操作都会被标记为 NO_DATA 状态,原本旧的拷贝可能还能用的,只是可能不是最新的罢了,而现在却都变成空数据缓存起来了。

那么如果我们能在这种情况下让缓存不过期是不是就能解决问题了?答案是 yes。

ua-resty-shcache – 基于 ngx.shared.DICT 实现了一个完整的缓存状态机,并提供了适配接口

恩,这个库几乎解决了我们上面提到的所有问题:1. 内置缓存锁实现 2. 故障时使用陈旧的拷贝 – STALE

所以,不想折腾的话,直接用它就是的。另外,它还提供了序列化、反序列化的接口,以 UPYUN 为例,我们的元数据原始格式是 JSON,为了减少内存大小,我们又引入了 MessagePack,所以最终缓存在 NGINX 共享内存上是被 MessagePack 进一步压缩过的二进制字节流。

序列化、反序列化太耗时?!

由于 ngx.shared.DICT 只能存放字符串形式的值(Lua 里面字符串和字节流是一回事),所以即使缓存命中,那么在使用前,还是需要将其反序列化为 Lua Table 才行。而无论是 JSON 还是 MessagePack,序列化、反序列操作都是需要消耗一些 CPU 的。

如果你的业务场景无法忍受这种程度的消耗,那么不妨可以尝试下这个库:lua-resty-lrucache。它直接基于 LuaJIT FFI 实现,能直接将 Lua Table 缓存起来,这样就不需要额外的序列化反序列化过程了。当然,我们目前还没尝试这么做,如果要做的话,建议在 shcache 共享内存缓存层之上再加一层 lrucache,也就是多一级缓存层出来,且这层缓存层是 Worker 独立的,当然缓存过期时间也应该设置得更短些。

当然,我们在这基础上还增加了一些东西,例如 shcache 无法区分数据源中数据不存在和数据源连接不上两种状态,因此我们额外新增了一个 NET_ERR 状态来标记连接不上这种情况。

ngx_lua 在 UPYUN 还有很多方面的应用,例如流式上传、跨多个 NGINX 实例的访问速率控制等,这些保证了 UPYUN 的Web 应用开发框架整体的稳固性,也是UPYUN 多年来能够保持 CDN 节点服务领先的重要基础。

UPYUN CDN 高可用架构实践

那么,用户该如何感知NGINX的便捷性呢?传统 CDN 企业的用户只能适配该企业特定的功能模块,UPYUN 将之开发成后台用户可操作的功能模块,用户可用类似于 NGINX 的配置来配置回源源站(见上图),达到方便、快捷、满足用户的个性化需求。

赞(0) 赞赏

评论 抢沙发

微信赞赏