有赞 API 网关实践

一、API 网关简介

随着移动互联网的兴起、开放合作思维的盛行,不同终端和第三方开发者都需要大量的接入企业核心业务能力,此时各业务系统将会面临同一系列的问题,例如:如何让调用方快速接入、如何让业务方安全地对外开放能力,如何应对和控制业务洪峰调用等等。于是就诞生了一个隔离企业内部业务系统和外部系统调用的屏障 - API 网关,它负责在上层抽象出各业务系统需要的通用功能,例如:鉴权、限流、ACL、降级等。另外随着近年来微服务的流行,API 网关已经成为一个微服务架构中的标配组件。

二、有赞 API 网关简介

有赞 API 网关目前承载着微商城、零售、微小店、餐饮、美业、AppSDK、部分 PC、三方开发者等多个业务的调用,每天有着亿级别的流量。

有赞后端服务最开始是由 PHP 搭建,随着整个技术体系的升级,后面逐步从 PHP 迁移到 Java 体系。在 API 网关设计之初主要支持 Dubbo、Http 两种协议。迁移过程中,我们发现部分服务需要通过 RPC 方式调用 PHP 服务,于是我们 (公司) 基于 Dubbo 开发了一个新的框架 Nova,兼容 Dubbo 调用,同时支持调用 PHP 服务。于是网关也支持了新的 Nova 协议,这样就有 Dubbo、Http、Nova 三种协议。

随着业务的不断发展,业务服务化速度加快,网关面临各类新的需求。例如回调类型的 API 接入,这种 API 不需要鉴权,只需要一个限流服务,路由到后端服务即可;另外还有参数、返回值的转换需求也不断到来,这期间我们快速迭代满足新的需求。而在这个过程中我们也走了很多弯路,例如 API 的规范,在最开始规范意识比较笼统,导致返回值在对外暴露时出现了不统一的情况,后续做 SDK 自动化的时候比较棘手,经过不断的约束开发者,最终做到了统一。

三、架构与设计

1. 网关架构

部署架构图

网关的调用方主要包括微商城、微小店、零售等 App 应用,以及三方开发者和部分 PC 业务。通过 LVS 做负载均衡,后端 Tengine 实现反向代理,网关应用调用到实际的业务集群

应用架构图

网关核心由 Pipe 链构成,每个 Pipe 负责一块功能,同时使用缓存、异步等特性提升并发及性能

线程模型图

网关采用 Jetty 部署,调用采用 Http 协议,请求由容器线程池处理 (容器开启了 Servlet3.0 异步,提升了较大的吞吐量),之后分发到应用线程池异步处理。应用线程池在设计之初考虑不同的任务执行可能会出现耗时不一的情况,所以将任务分别拆分到不同的线程池,以提高不同类型任务的并发度,如图分为 CommonGroup, ExecutionGroup, ResultGroup

CommonGroup 执行通用任务,ExecutionGroup 执行多协议路由及调用任务,ResultGroup 执行结果处理任务 (包含异常)

网关业务生态图

网关生态主要包含控制台、网关核心、网关统计与监控
控制台主要对 API 生命周期进行管理,以及 ACL、流量管控等功能;
网关核心主要处理 API 调用,包含鉴权、限流、路由、协议转换等功能;
统计与监控模块主要完成 API 调用的统计以及对店铺、三方的一些报表统计,同时提供监控功能和报警功能

2. 网关核心设计

2.1 异步

我们使用 Jetty 容器来部署应用,并开启 Servlet3.0 的异步特性,由于网关业务本身就是调用大量业务接口,因此 IO 操作会比较频繁,使用该特性能较大提升网关整体并发能力及吞吐量。另外我们在内部处理开启多组线程池进行异步处理,以异步回调的方式通知任务完成,进一步提升并发量

image

2.2 二级缓存

为了进一步提升网关的性能,我们增加了一层分布式缓存(借用 Codis 实现),将一些不经常变更的 API 元数据缓存下来,这样不仅减少了应用和 DB 的交互次数,还加快了读取效率。我们同时考虑到 Codis 在极端情况下存在不稳定因素,因此我们在本地再次做了本地缓存,这样的读取可以从 ms 级别降低到 ns 级别。为了实现多台机器的本地缓存一致性,我们使用了 ZK 监听节点变化来更新各机器本地缓存

image

2.3 链式处理

在设计网关的时候,我们采用责任链模式来实现网关的核心处理流程,将每个处理逻辑看成一个 Pipe,每个 Pipe 按照预先设定的顺序先后执行,与开源的 Zuul 1.x 类似,我们也采用了 PRPE 模式 (Pre、Routing、Post、Error),在我们这里 Pre 分为 PrePipe、RateLimitPipe、AuthPipe、AclPipe、FlowSepPipe,这些 Pipe 对数据进行预处理、限流、鉴权、访问控制、分流,并将过滤后的 Context 向下传递;Routing 分为 DubboPipe、HttpPipe,这些 Pipe 分别处理 Dubbo 协议、Http 协议路由及调用;Post 为 ResultPipe,处理正常返回值以及统计打点,Error 为 ErrorPipe,处理异常场景

image

2.4 线程池隔离

Jetty 容器线程池 (QTP) 负责接收 Http 请求,之后交由应用线程池 CommonGroup,ExecutionGroup, ResultGroup,通用的操作将会被放到 CommonGroup 线程池执行,执行真实调用的被放到 ExecutionGroup,结果处理放到 ResultGroup。这样部分 Pipe 之间线程隔离,通常前置 Pipe 处理都比较快,所以共享线程池即可,真实调用通常比较耗时,因此我们放到独立的线程池,同时结果处理也存在一些运算,因此也放到独立线程池

image

2.5 平滑限流

最早我们采用了简单的分布式缓存(Codis)计数实现限流,以 IP、API 维度构建 Key 进行累加,这种限流方式实现简单,但是不能做到连续时间段内平滑限流。例如针对某个 API 每分钟限流 100 次,第 1 秒发起 20 次,第二秒发起 30 次,第 3 秒发起 40 次,这样的限流波动比较大,因此我们决定将其改进。经过调研我们最终选择了令牌桶限流,令牌桶限流相比于漏桶限流能适应闲置较长时段后的尖峰调用,同时消除了简单计数器限流带来的短时间内流量不均的问题。目前网关支持 IP、店铺、API、应用 ID 和三方 ID 等多个维度的限流,也支持各维度的自由组合限流,可以很容易扩展出新的维度

image

2.6 熔断降级

由于我们经常遇到调用后端接口超时,或者异常的情况,后端服务无法立即恢复,这种情况下再将请求发到后端已没有意义。于是我们使用 Hystrix 进行熔断降级处理。Hystrix 支持线程池和信号量 2 种模式的隔离方案,网关的业务场景是多 API 和 API 分组,每个 API 都可能路由到不同后端服务,如果我们对 API 或者 API 分组做线程池隔离,就会产生大量的线程,所以我们选择了信号量做隔离。我们为每个 API 提供一个降级配置,用户可以选择自己配置的 API 在达到多少错误率时进行熔断降级。
引入 Hystrix 后,Hystrix 会对每个 API 做统计,包括总量、正确率、QPS 等指标,同时会产生大量事件,当 API 很多的时候,这些指标和事件会占用大量内存,导致更加频繁的 YoungGC,这对应用性能产生了一定的影响,不过整体的收益还是不错的

另外有赞内部也开发了一个基于 Hystrix 的服务熔断平台(Tesla),平台在可视化、易用性、扩展性上面均有较大程度的提升;后续网关会考虑熔断模块的实现基于服务熔断平台,以提供更好的服务

image

2.7 分流

有赞内部存在多种协议类型的后端服务,最原始的服务是 PHP 开发,后面逐渐迁移到 Java,很早一部分 API 是由 PHP 暴露的,后续为了能做灰度迁移到 Java,我们做了分流,将老的 PHP 接口的流量按照一定的比例分发到新的 Java 接口上

3. 控制台

除了核心功能的调用外,网关还需要支持内部用户 (下称业务方) 快速配置接口暴露给开发者。
控制台主要职责包括:快速配置 API、一站式测试 API、一键发布 API,自动化文档生成,自动化 SDK 生成

  • 快速配置 API:这块我们主要是按照对外、对内来进行配置,业务方将自己要对外公开的名称、参数编辑好,再通过对内映射将对外参数映射到内部服务的接口里面

image

  • 一站式测试 API:API 配置完成后,为了能让业务方快速测试,我们做了一站式获取鉴权值,参数值自动保存,做到一站式测试

image

  • 一键发布 API:在完成配置和测试后,API 就可以直接发布,这个时候选择对应环境的注册中心或者服务域名即可

image

  • 自动化文档生成:我们针对文档这块做了文档中心,对内部用户,他们只需要到平台来搜索即可,对外部用户,可以在有赞云官网查看或者在控制台直接导出 pdf 文件给用户

image

  • 自动化 SDK 生成:对于开发者来说,接入一个平台必然少不了 SDK,我们针对多语言做了自动化 SDK 生成,当用户的接口发布成功后,我们会监听到有新的接口,这时会触发自动编译 (Java)SDK 的模块,将新接口打包成新版本的压缩包,供开发者使用;如果编译失败(Java) 则不会替换老的压缩包,我们会发送报警给相应的开发者,让其调整不规范的地方

image

4. 数据统计

为了让业务方能在上线后了解自己的接口的运行状况,我们做了 API 相关的统计。我们通过在核心模块里面打日志,利用 rsyslog 采集数据到 Kafka,然后从 Kafka 消费进行统计,之后回流到数据库供在线查询

除此之外,我们为每个商家做了他们授权的服务商调用接口的统计。这块功能的实现,我们通过 Storm 从 Kafka 实时消费,并实时统计落 HBase,每天凌晨将前一天的数据同步到 Hive 进行统计并回流到数据库

image

5. 报警监控

业务方 API 上线后,除了查看统计外,当 API 出问题时,还需要及时发现。我们针对这块做了 API 报警功能。用户在平台配置自己的 API 的报警,这里我们主要支持基于错误数或 RT 维度的报警。
我们实时地从 Kafka 消费 API 调用日志,如果发现某个 API 的 RT 或者错误次数超过配置的报警阈值,则会立即触发报警

image

四、实践总结

1. 规范

在网关上暴露的 API 很多,如何让这些 API 按照统一的标准对外暴露,让开发者能够低门槛快速接入是网关需要思考的问题

网关规范主要是对 API 的命名、入参(公用入参、业务入参)、内部服务返回值、错误码(公用错误码、业务错误码)、出参(公用出参、业务出参),进行规范

在我们的实践过程中,总结了以下规范:

  • 命名规范:youzan.[业务线 ( 可选)].[应用名].[动作].[版本],例如:youzan.item.create.3.0.0
  • 入参规范:要求全部小写,组合单词以下划线分隔,例如:title, item_id;入参如果是一个结构体,要求以 json 字符串传入,并且 json 中的 key 必须小写并且以下划线分隔
  • 出参规范:要求全部小写,组合单词以下划线分隔,例如:page_num, total_count;如果参数为结构体,结构体里面的 key 必须小写且以下划线分隔
  • 错误码规范:我们做了统一的错误码,例如系统级错误码 51xxx,业务错误码 50000,详情信息由 msg 显示;业务级错误码由业务方自行定义,同时约束每个业务方的错误码范围
  • 服务返回值规范:针对不同的业务方,每个 API 可能会有不同的业务错误,我们需要将这部分业务级错误展示给开发者,因此我们约定返回值需要按照一个 POJO 类型 (包含 code, msg, data) 来返回,对于 code 为 200,我们认为正常返回,否则认为是业务错误,将返回值包装为错误结果

2. 发布

  • 我们将 API 划分到 3 个环境,分别为测试环境、预发环境、生产环境。API 的创建、编辑必须在测试环境进行,测试完成后,可以将 API 发布到预发环境,之后再从预发环境发布到生产环境,这样可以保持三个环境的 API 数据一致。好处是:一方面可以让测试开发能在测试环境进行自动化验证,另一方面可以防止用户直接编辑线上接口引发故障

3. 工具化

  • 对于内部用户经常可能需要排查问题,例如 OAuth Token 里面带的参数,需要经常查询,我们提供工具化的控制台,能让用户方便查询,从而减少答疑量
  • 我们上线后也曾经出现过缓存不一致的情况,为了能快速排查问题,我们做了缓存管理工具,能在图形化界面上查看本地缓存以及 Codis 的缓存,可以进行对比找出差异
  • 为了更好的排查线上问题,我们接入了有赞对比引擎(Replay)平台,该平台能将线上的流量引到预发,帮助开发者更快定位问题

五、踩过的坑

  • Meta 区 Full GC 导致服务无法响应

    现象:应用 hung 死,调用接口返回 503,无法服务

    排查过程:现场 dump 了内存,GC 记录,以及线程运行快照。首先看了 GC 发现是 Full GC,但是不清楚是哪里发生的,看线程运行快照也没发现什么问题。于是在本地用 HeapAnalysis 分析,堆区没看出什么问题,大对象都是应该占用的;于是查看方法区,通过 ClassLoader Analysis 发现 Fastjson 相关的类较多,因此怀疑是 class 泄露,进一步通过 MAT 的 OQL 语法分析,发现是 Fastjson 在序列化 Jetty 容器的 HttpServletRequest 时,为了加快速度于是创建新的类时抛了异常,导致动态创建的类在方法区堆积从而引发 Full GC,后续我们也向 Fastjson 提了相关 bug

    解决方案:将序列化 HttpServletRequest 的代码移除

  • 伪死循环导致 CPU 100%

    现象:在有赞双 11 全链路压测期间,某个业务调用 API,导致我们的应用 CPU 几乎接近 100%

    排查过程:经过日志分析,发现该接口存在大量超时,但是从代码没看出特别有问题的地方。于是我们将接口在 QA 环境模拟调用,用 VisualVM 连上去,通过抽样器抽样 CPU,发现某个方法消耗 CPU 较高,因此我们迅速定位到源码,发现这段代码主要是执行轮询任务是否完成,如果完成则调用完成回调,如果未完成继续放到队列。再结合之前的环境观察发现大量超时的任务被放到队列,导致任务被取出后,任务仍然是未完成状态,这样会将任务放回队列,这样其实构成了一个死循环

    解决方案:将主动轮询改为异步通知,我们这里是 Dubbo 调用,Dubbo 调用返回的 Future 实际是一个 FutureAdapter,可以获取到里面的 ResponseFuture(DefaultFuture),这个类型的 Future 支持设置 Callback,任务完成时会通知到设置的回调

六、未来展望

  1. 业务级资源组隔离。随着业务的不断发展,当业务线较多时,可以将重要的业务分配到更优质的资源组 (例如:机器性能、线程池的大小),将一般业务放到普通资源组,这样可以更好的服务不同的业务场景
  2. 更高并发的线程池 /IO 的优化。随着业务的发展,未来可能会出现更高的并发,需要更精良的线程及 IO 模型
  3. 更多的协议支持。以后技术的发展,Http2 可能会蓬勃发展,这时需要接入 Http2 的协议

七、结语

有赞网关目前归属有赞共享技术 - 基础服务中心团队开发和维护;
该团队目前主要分为商品中心、库存中心、物流中心、消息沟通平台、云生态 5 个小组;
商品 / 库存 / 物流中心:通过不断抽象上层业务,完成通用的模型建设;为上层业务方提供高可用的服务,并快速响应多变的业务需求;针对秒杀、洪峰调用、及上层业务多变等需求,三个小组还齐力开发和持续完善着 对比引擎、服务熔断、热点探测等三个通用系统;
消息沟通平台:提供几乎一切消息沟通相关的能力及一套帮助商家与用户联系的多客服系统,每天承载着上亿次调用(短信、apppush、语音、微信、微博、多客服、邮件等通道);
云生态:承担着核心网关的建设和发展(上面的网关应用系统)、三方推送系统、有赞云后台、商业化订购以及 App Engine 的预研和开发;

目前该团队 HC 开放,期待有机会与各位共事;(内推邮箱:huangtao@youzan.com)

注,本文作者:有赞网关(黄涛、尹铁夫、叮咚)