和大家聊一聊七层流量接入中间件。
1. 接入系统简介与架构
1.1 Go反向代理
用Go语言实现一个订制化的反向代理。
Go语言
近几年在国内较流行,随着docker的成名而愈加受人追捧。目前国内使用Go开发的团队和系统越来越多,像百度的BFE、360的长连接推送、七牛云存储、滴滴登录认证等,名单很长。
Go比较适合于中间件(反向代理、消息队列等)以及旁路系统(存储、长连接推送等)的开发,也有很多团队开始使用Go来编写Web
API(使用beego框架)。Go语言提供原生的协程(go
routine)支持,天生高并发,而且是用同步逻辑编写异步程序(语言底层封装了异步I/O),开发效率极高。
重复造轮子
业界有很多成熟的反向代理(Nginx、Tengine),为何不基于这些开源的项目二次开发?主要考虑三点:1)开发效率;2)订制化;3)维护成本。
Nginx和Tengine都基于C语言开发,C语言的开发效率相对较低,维护成本更大,在语言选型上更倾向于Go;考虑到订制化以及精简易懂(代码量小、逻辑少),倾向于重新实现一个反向代理,保持高扩展性的同时剔除不必要的逻辑。其实,主导思想就是:忌大而全。
1.2 一核多路
作为一个流量入口,其稳定性是设计时首要考虑的目标,于是提出了一核多路的思想。即一个涵盖基本功能且稳定运行的核心 + 多个扩展功能的旁路系统。有点像微服务的理念。
核心Server
Server必须保证高并发且提供可以扩展的口子。
Nginx通过master-worker多进程 + 异步I/O来实现高并发。借助于Go语言的优势,要实现高并发显得更加容易。用一个master的goroutine + 多个worker的goroutine即可。确切地,是每来一个http的请求,就新建一个goroutine。
每个worker协程中提供加解密、分流、防抓取以及转发等逻辑。借鉴Nginx的请求处理过程(phase),Server将请求抵达到请求返回划分为多个阶段,选择几个具有典型的阶段为回调注册点。所谓回调注册点,即允许在此点上注册handler函数,当请求执行到此流程点后,Server回调此函数。每个回调点对应一个handlers数组,在回调时,handlers被顺序执行。显然回调点机制提供了极方便的扩展性。
每个worker协程中提供加解密、分流、防抓取以及转发等逻辑。借鉴Nginx的请求处理过程(phase),Server将请求抵达到请求返回划分为多个阶段,选择几个具有典型的阶段为回调注册点。所谓回调注册点,即允许在此点上注册handler函数,当请求执行到此流程点后,Server回调此函数。每个回调点对应一个handlers数组,在回调时,handlers被顺序执行。显然回调点机制提供了极方便的扩展性。
Module模型
回调点上的handler代表一个功能,Module则代表一类功能实体。即,一个Module可以在多个回调点上分别注册handler。譬如数据报表客户端和访问日志的Module就在两个回调点上分别注册了handler。
Module模型的出现,就象征着一核多路设计理念的实践与落地。当监控到系统运行状态负载过高、压力过大后,可以采取停掉某些Module的方式保障核心的稳定,实现服务降级。
2. 配置热更与优雅重启
接入服务,分流规则的变更、业务后端机器的变更、新增接入业务都是不可避免的事情,况且服务本身的升级与迭代更是持续的过程。如何在进行变更的时候保证系统服务的持续运行以及变更效率是接下来要聊得话题。
2.1 配置热更
在不停服务的情况下完成配置的变更叫配置热更。热更的唯一难点在于更换前后数据的一致
性,即,当t时刻发生配置热更,对于t时刻以前抵达正的请求应该使用变更前的配置;对于t时候后抵达的请求,应该采用变更后的配置。
业界一般有两种解决方案:fork进程和指针切换。
fork进程
master进程fork出一个子进程来load配置,当load完成后,master进程使配置变更前的子进程优雅退出。此种方案的好处是不必考虑各配置间的耦合性;缺点是实现起来略复杂。Nginx采用此种方案。
指针切换
通过切换配置数据内存的地址,来实现变更。伪代码如下:
此方案之所以简单,依赖一个前提:语言本身提供gc机制。对于t时刻前的请求未服务完前,旧的配置内存不会被释放;当所有t时刻的请求都服务完后,gc回收内存。基于Go实现的接入系统,自然选择指针切换的方式实现配置热更。
配置热更其实还有一个潜在的巨大收益:平台化。将平台开放给各业务同学,从而解放接入系统维护人员的双手(再也不用每天接收大量的配置变更任务了)。
2.2 优雅重启
大家在进行系统迭代升级时,不得不面临发布上线的问题。如何能不停服务的情况实现系统的升级了?一般业界有三种解决方案:reuse port、fork + exec和healthcheck + supervisord。
reuse port
linux内核3.9提供了SO_REUSEPORT的属性,通过此选项可以让不同的进程bind()/listen()同一个TCP/UDP端口。意味着,当我们进行代码迭代时,可以在线上同时运行新旧两份代码,当新代码服务稳定后,让旧的进程退出从而完成代码平滑升级。利用此方案有两个点:1)内核要求;2)旧服务优雅退出。优雅退出并不困难,发送一个信号给旧进程,旧进程关闭掉listen端口,等系统中残留的请求服务完后退出即可。
fork + exec
master进程fork子进程,用exec调用自己,当系统服务运行后,发送信号给master进程,master进程关闭listen然后退出,这是一种最优雅的重启方式。伪码如下:
healthcheck + supervisord
发布前先摘掉机器上的healthcheck文件,等系统流量干净后,开始替换系统代码文件(二进制或其它),然后kill掉服务进程,supervisord拉起,从而实现升级,发布完后,添加healthcheck文件。
此种方案是业界使用最多的,很trik,但却比较有效。
3. 最佳实践
3.1 GC优化
go的gc优化主要有如下几种方法:小对象合并、栈上分配对象、sync.pool、cgo、内存池和升级go版本。
其中cgo、内存池和升级go版本效果明显。cgo是Go提供的调用c代码的方式,运行效率相较于go要高;go1.7的release,和1.4.2版本对比测试,gc的停顿时间减少了30%。内存池(下图所示),即自行管理内存,在gc看来是一个对象,因此也能很好的缓解gc。
3.2 TCP + protobuf
基于TCP协议,设计私有协议时,一般需要定义一个header和msg,如下图所示。对于msg的内容一般希望接收端能够直接映射成数据结构,即需要序列化和反序列化。常见的序列化协议很多,譬如json、thrift、hessian、protobuf等。我个人比较推荐protobuf,主要protobuf支持的语言较多、协议字段兼容性好、序列化反序列化速度快以及大厂(google)支持。
3.3 UDP
UDP作为一个无连接的协议,采用“尽力而为”的传输主旨,即不考虑网络状况和对方的接收能力,只要能发就尽力发,毫无顾虑。UDP的这个特性就意味着它不保证数据准确送达也不保证有序,对于拥塞的网络可能会使得网络情况更糟糕,这是UDP相较于TCP的缺点。但,却也正因为此,UDP传输效率高。
对于内网间的通信,网络情况可控,评估业务的特点(譬如实时性要求高,但可以容忍一定程度的丢包),可以尝试使用UDP作为传输协议。下图是我实测的内网跨机房间的udp丢包率。
3.4 Unix Domain Socket
对于进程间通信,Unix Domain Socket相较于Socket通信有较大的优势,其不需要进行网络协议栈的处理,简单的通过内存间的拷贝,速度极快。对于同机部署的服务间的通信,是个不错的选择。
至于Unix Domain Socket中面向连接的字节流和面向无连接数据报,两种都能保证数据准确抵达、且有序,唯一的差异只在于语义。譬如,Read操作时,字节流的服务,可以多次调用Read操作来接收发送端发送的一个报文数据;但数据包的服务,则一个包只允许一次Read操作。
4. 服务降级与预案
接入系统作为一个流量入口,稳定性首当其冲,当发生攻击和后端业务故障后,我们必须要有应对的方案。本章和大家讨论此问题。
4.1 服务降级
入口流量控制
当遭遇攻击导致流量突增后,可能导致系统资源瞬间耗尽而被操作系统杀掉进程。应对这种情况的比较粗暴的方法就是设置一个全局的计数器,此计数器记录了当前系统中驻留的请求的数目,一旦其值超过某个阈值后,新进来的请求将被拒绝。
如果要优雅的解决上述问题,则需要一个旁路的DDos防攻击的系统,其对请求进行多维度的计数,当达到一个阈值后,下发指令至接入系统,接入系统对新进来匹配上标记特性的请求实施拒绝。
业务隔离
当后端业务发生严重的超时故障后,转发至此业务的请求出现超时重试,一段时间内,系统中被消耗的资源随着时间推移而线性增加,导致GC压力过大,从而影响其它业务的响应时间。为了应对此种情况,设计了业务配额机制来使业务故障隔离,如下图所示。
设置各业务的配额有两种机制:静态和动态。静态配额严格依赖实验和经验,不是最优配置,但实现简单;动态配额,能最大程度的利用系统资源,但实现难度稍大。
4.2 预案
略