27 September 2019

一.背景介绍

酒店报价接口可以返回一家酒店在某一入住条件下所有房型信息及房间报价,是整个酒店预订环节最核心的接口之一。

酒店闭环预订系统上线之初只对接了一家OTA(酒店供应商),访问量也不大,所以初期酒店报价接口对性能的要求并不高,随着对接的OTA越来越多(从一家到几十家),业务量、访问量、业务复杂度持续增加,性能瓶颈逐渐显现,在活动大促期间或遭遇恶意抓站时,报价接口会出现服务不稳定的情况(频繁出现请求超时或502错误),严重影响用户体验,对接口进行性能优化迫在眉睫,我们通过各种分析发现对系统造成不稳定的因素很多,有数据库查询方面的,有NoSQL存储方面的,有PHP自身特性决定的,有代码逻辑方面的,有网络出口方面的,有系统配置方面的,需要逐一各个击破,本文主要介绍对OTA价格抓取这部分的改造优化,通过一种新的架构,实现了报价接口高可用和可扩展。

二.问题分析

省略掉风控、反扒、频次控制、缓存等附加环节,报价接口的核心逻辑是这样的:

 

可以看到,用户访问一家酒店详情页时会向报价接口发送请求,报价接口主要做了下面两件事:

l 实时获取这家酒店关联的所有OTA的接口报价。

l 待所有OTA接口返回数据以后,再根据返回结果加入马蜂窝的营销/调价策略,最终生成用户看到的酒店报价。

其中第一步“实时获取酒店关联OTA报价”是我们此次优化重点关注的地方,这一步需要调用远程接口获取数据,并且一个酒店通常关联十几个OTA(甚至更多),也就是说用户访问一次,询价服务器要发送十几次请求获取OTA数据,这是一项非常耗时并且开销很大的工作,传统实现方式是使用PHP的curl_multi函数集实现并发请求,虽然是并发的发送请求,但是PHP必须不断轮询等待所有OTA请求都返回(或超时)才能继续执行后续逻辑,这一步实际上是I/O阻塞的,所耗费的时间取决于响应最慢的那个OTA(通常需要2-3秒)。

我们知道Nginx是通过FastCGI接口调用的PHP,PHP通过PHP-FPM这个应用程序接口(SAPI)实现了FastCGI。一台服务器上的PHP-FPM worker进程数量是有限的,一个worker进程同一时刻只能处理一个用户请求,意味着如果单个请求的处理时间过长,worker进程会持续被占用得不到释放,如果有大量这样的请求,就会造成所有worker进程都被占用,如果此时再有新的用户请求会因为没有可用进程返回502错误。此外,频繁对外发送http请求对于web前端机来说也是一笔不小的开销,会降低服务器性能。总之,PHP-FPM的worker进程很昂贵,大量发送外部请求-阻塞等待返回这种事不该由PHP-FPM进程来做,我们优化的重点就是要把这一步放到PHP-FPM之外完成。

三.实现目标

我们希望通过一些改造优化手段,实现以下目标:

l 高可用,服务稳定,解决询价服务器的PHP-FPM进程长时间阻塞造成的进程不够用问题

l 高并发,可同时发送大量HTTP请求并处理返回结果

l 可扩展,活动促销期间可通过增加服务器快速部署提高系统性能

l 不影响上层逻辑,对上层业务代码做尽可能小的改动

四.设计实现概述

我们设计并实现了一个新的架构,此架构的核心由两部分组成:

  1. PHP通过HTTP 3xx 跳转将阻塞等待这一步交给ngx_lua处理
  2. 利用消息队列+Swoole协程实现高并发数据抓取

PHP进程不再实时发起每个OTA的HTTP请求,而是把任务发送到消息队列,Swoole负责消费队列,向OTA发送请求,并把返回结果写入redis,PHP进程也不再阻塞等待询价请求全部执行完毕,而是通过一个HTTP 3XX跳转,把等待的工作交由ngx_lua脚本完成,ngx_lua轮询等待所有OTA请求完成,再次发起内部重定向到PHP-FPM,PHP从redis缓存中取出数据,继续执行后面的逻辑(加入调价策略等),整个询价任务完成。

五.详细设计步骤

第一步 送入消息队列(PHP 5.6实现)

在此之前我们已经获取了一次用户请求所有需要询价的OTA以及每一个OTA的HTTP请求参数,然后我们把每个OTA请求作为一条消息发送到消息队列,这里有一个问题,我们必须知道MQ中哪些消息是来自同一个用户同一次请求,不然即使询价完成也无法把结果拼接在一起了,解决办法是给来自同一次请求的消息分配同一个唯一ID,我们用一个唯一ID发生器实现这一步。消息体如下:

字段

说明

topic_name

消息队列名称

uni_id

请求唯一ID

http

OAT HTTP请求参数

assist_params

附带业务参数

options

附加选项

PS:

l 为了兼容原有代码逻辑,这一步是在PHP5.6上实现

l 消息体经过igbinary_serialize序列化

第二步 HTTP 3XX跳转 (PHP 5.6实现)

消息push到MQ以后,PHP端直接做一个HTTP 3XX跳转,具体的HTTP code可以自定义(比如314),跳转的Location中包含以下参数:

参数

说明

redis_ip

Redis IP(虚IP)

redis_port

Redis 端口

res_count

请求OTA数量

key

返回结果的redis集合key

timeout

超时时间

def_r

未取到数据的默认返回值(json格式)

src_uri

原始请求的uri

为什么要附带这么多参数呢?因为我们是要把下一步交由ngx_lua处理,Lua脚本目前还没有成熟的发布系统支持,修改上线比较繁琐,所以我们希望尽量少的对它做改动,并且一些配置(比如redis IP和端口)我们希望在PHP端做统一维护,所以我们把可能发生变化的参数都通过QueryString参数传递到ngx_lua,这样Lua脚本只需关注核心逻辑,几乎不用再做任何改动了。

PS:这一步也是在PHP5.6上实现。

第三步 捕获状态码,内部重定向到ngx_lua(Nginx实现)

这一步是在nginx中完成的,PHP做了HTTP 314跳转以后,我们不希望用户在浏览器端察觉到这个跳转,这一步对用户来说应该是透明的,所以第一步先要让Nginx捕获这个状态码,只需两行配置即可实现:

(1). 将Fast-CGI后端服务器相应状态码(>=300)返回给Nginx(而不是发给客户端):

fastcgi_intercept_errors on;

(2). 捕获314错误码,内部重定向,这实际上是有些hack的方法:

error_page 314 = @hotel_314;

接下来需要配置一个loaction,让nginx跳转到Lua脚本处理:

location @hotel_314 {

set $saved_redirect_location '$upstream_http_location';

content_by_lua_file /usr/local/openresty/src/hotel/hotel_wait_ota.lua;

}

这样配置以后,用户在浏览器端就观察不到这个跳转了。

第四步 这一步分为两部分,是并行执行的

1. 队列消费,利用swoole协程向OTA发送请求,写入缓存(PHP7 + Swoole4实现

消息队列的实现方式很多,我们使用redis+swoole4实现了队列的消费,swoole4必须在PHP7以上版本上部署。

Swoole 4赋予了PHP高性能的异步编程的能力,其最大优势是支持协程,所有操作都可以在用户态完成,协程间通过协作而不是抢占来进行切换,协程创建和切换的开销比进程小的多,带来的性能提升十分明显。并且开发者可以无感知的用同步的代码编写方式达到异步IO的效果和性能,避免了传统异步回调所带来的离散的代码逻辑和陷入多层回调中导致代码无法维护。

我们设计的Swoole协程并发处理器分以下三层:

 

最底层swoole协程处理器可以实现队列的读取消费,进程的创建和平滑终止,协程的创建和控制,一台服务器可以并发创建5000(可调整)个协程处理业务,这一层会暴露一个consume()抽象方法,上层可以实现这个方法执行具体的业务逻辑,比如URL抓取和爬虫识别,在URL抓取器之上又可以实现具体的抓取任务,报价接口实时抓取只是其中一项任务,除此之外还有价格日历落地和艺龙静态数据抓取等也是基于swoole协程处理器之上实现的。要注意的一点是,在协程中执行的代码必须都是非阻塞的,否则服务会自动降级为同步阻塞模式,性能会大打折扣。常见的Socket IO操作,比如TCP/UDP请求、HTTP请求、MySQL读取、Redis的读取等,在Swoole中都提供了相应的协程版组件供使用。

对于“报价接口实时抓取”这一层,对于单个协程,也就是单个HTTP请求,主要实现以下几步逻辑:

 

同一个用户同一次请求会生成一个唯一的key,并以此key创建一个redis集合。每一个OTA请求的返回结果也会生成一个唯一的key(记为dataKey

),然后把数据缓存到dataKey中,最后把这个dataKey添加到集合中,这样通过集合key就可以取到本次询价的所有OTA的返回结果。要注意的是为避免缓存数据无限增大,必须要给key添加过期时间,因为队列的消费几乎是实时的,这个过期时间通常不需要设很大(比如1分钟)。

以上介绍的是单个进程的执行情况,因为redis的操作都是原子性的,所以同时还可以启动多个进程消费队列,处理速度更快,不会冲突,并且可以把进程部署在多台服务器上,增强系统可用性。

最后要注意一点,常驻内存的进程操作redis时要有重连机制(定时重连、断开重连),以防redis服务不稳定时出现意想不到的问题。

2. 与此同时,Lua脚本轮询等待队列消费完成(ngx_lua实现

这一步是让OpenResty的ngx_lua模块代替PHP做等待所有OTA请求返回的工作,为什么要让ngx_lua做呢?答案是因为它适合做这件事。OpenResty最核心最强大的特性就是其称为cosocket的技术,cosocket就是coroutine(Lua协程)+ socket(Nginx使用非阻塞的epool事件模型),这两个技术相结合使OpenResty具备了一种同步非阻塞的能力,同Swoole一样,Lua也是天然支持协程,当进程内部发生IO阻塞时,会自动进行协程切换,协程可以随时被暂停和唤醒。ngx_lua可以把网络事件注册到Nginx的监听列表中,然后把运行权交给Nginx,当注册的网络事件达到触发条件时,会唤醒协程继续处理,不仅是和HTTP客户端的网络通信是非阻塞的,与Mysql、Redis等众多后端服务的网络通信也是非阻塞的,看到这里你会发现OpenResty的ngx_lua和PHP的Swoole在设计上有很多相似之处。

Lua 脚本流程图:

 

上图可以看到有ngx.sleep循环等待这一步,这里可以体会一下同PHP 的sleep(1)的区别,执行 ngx.sleep(1) 时Lua会发生协程切换,但进程并不会切换,其他协程还在运行,模拟的是一种IO阻塞,而PHP的sleep(1)语句会导致整个进程陷入睡眠阻塞,直到指定时间后操作系统才会重新唤醒当前进程,这对资源是一种严重的浪费(Swoole中也有非阻塞的Coroutine::sleep()语句)。另外ngx_lua中对redis的操作也是非阻塞的。

如果在规定时间内返回了全部OTA结果,Lua就再次进行内部重定向(ngx_exec),把操作权交给Nginx(同时在header头中打一个标记代表是从Lua过来的),如果超出规定时间有部分OTA未返回,就把这些OTA的返回结果丢弃。

第五步 再次发起内部重定向到PHP-FPM(Nginx实现

这一步很简单,通过Nginx重定向到FastCGI,把处理权重新交还给PHP。

fastcgi_pass fastcgi_backend;

第六 PHP执行后续逻辑(PHP 5.6实现

兜了一大圈儿,现在权力重新回到PHP手中,PHP从$_SERVER中判断当前任务来源,如果来自Lua二次重定向,则直接从Redis集合中取出数据($_SERVER中存储了当前集合的key),直接返回给上层业务逻辑即可。至此,整个询价逻辑就完成了。

完整的执行逻辑如下:

六.其他设计细节

1. 现有代码如何快速接入新架构?

我们的设计目标之一是不影响上层逻辑,对上层业务代码做尽可能小的改动,所以为了实现这一点,我们做了以下两步:

(1). curl参数到http的转换

因为之前的代码是用curl函数集实现的,为了保持兼容性,我们对生成curl的options参数的代码未做任何修改, 而是使用一个工具类完成curl参数到Swoole\Coroutine\Http\Client请求的转换,只需在原有curl参数上包装一层即可。后来我们发现最新的swoole 4.4版实现了curl的协程化,但底层使用的也是Swoole\Coroutine\Http\Client,和我们的实现思路是类似的。

(2). 提供统一接口,具体实现逻辑不暴露给上层

接口在设计之初就考虑到数据抓取可能有多种实现方式(curl_multi、swoole、Go、Java等),所以设计了一个唯一接口,可以通过传递一个类型参数制定采取何种方式抓取数据,这样上层逻辑所做的唯一修改就是更换这个参数。

2. 后台辅助工具

为了方便开发工程师、产品及测试工程师对报价接口进行测试及性能分析,我们在后台开发了接口测试工具,可以方便的查看接口执行中的各种状态、每个步骤的执行时间以及各OTA的请求响应时间。

3. 监控、报警、测试、高可用及降级

系统对当前消息队列长度会有监控,可在后台实时查看,如果队列堆积超过阀值会有报警。

系统提供一条专用消息队列用于测试,可专门针对此队列输出日志,可指定某些测试用户走此队列,不会对线上业务造成影响。

服务器对处理队列的进程有监控,时刻保证有足够的进程数消费队列。

为了实现高可用,我们在两台服务器上部署了基于swoole的数据抓取服务,每一台都有足够的处理能力,其中任意一台服务器故障不会对服务整体造成影响。

我们在AOS的配置中心增加了若干开关和配置,可以实现一键切换抓取方式,在基础服务(比如redis)故障时能迅速降级到传统的curl_multi方式,也可以指定某一时间段切换到传统方式(比如存储服务器升级期间),也可以同时采用两种方式获取(每种按一定比例分配)。另外还增加了日志输出开关。

七.上线效果

系统上线后运行稳定,线上询价服务器由8台减到6台依然没有压力(感觉还有进一步缩减空间),目前OTA日常的请求量至少是一年前的两倍,服务器没有再出现因OTA请求时间过长导致的5XX错误。

八.感谢

感谢酒店部黄志君提供核心技术指导,感谢系统部曹迪、子钰、大鹏等同事对改造提供大力支持。