Web 后端开发中的异步和非堵塞 IO

当下追新的开发者会尝试用 Node.JS 来做 Web 后端开发,相信这部分人在此之前是以 PHP 开发而主,但由于 PHP 的单进程、堵塞 IO 特性而无法获取很高性能而转移阵线到 Node.JS 上。不过我认为这是个错误的决定,因 Node.JS 基于 CallBack 的异步模式实现的非堵塞 IO 操作并不适合 Web 开发场景。

非堵塞与堵塞

堵塞 IO,即业务逻辑发起 IO 操作后会把整个业务堵塞在此处,等待 IO 操作结果后再继续运行,会导致 CPU 空转。而非堵塞 IO 则不会堵塞业务逻辑,发出 IO 操作后可以继续做其他事情。那么那个性能更好就很明显了。

如何使用非堵塞 IO

因非堵塞 IO 在发出 IO 操作后并不知道何时可以收到结果,我们必须为此加入接收结果的代码。看过 Node.JS 的 CallBack 异步回调可以更容易明白

query('SELECT * FROM table', function(result){...})

上面的代码发起一个非堵塞的 IO 操作,并使用一个 function 来异步接收结果。而在 C 或 Java 这些支持多线程的语言中,我们是使用多线程来发起非堵塞 IO 操作的,等结果来时再唤醒线程,继续运行接下去的业务逻辑。

可以说 Node.JS 的 CallBack 异步回调开发模式,简化了业务开发者的工作,不需要开发者自己开启线程和线程调度。这也是现在这么多人会使用 Node.JS 的原因。

但并不只有 CallBack 异步回调的开发模式才能使用非堵塞 IO,可能大家对协程的了解还不多,否则你也会选择协程。其实协程更适合用来做这些工作,协程是专为非堵塞 IO 而生的好东西。它相对于多线程所消耗的资源更少、性能更高,重点是使用协程可以让你以同步逻辑的思维来书写业务代码。

results = query('SELECT * FROM table')

一眼看过去,似乎是 PHP 这样的堵塞 IO 操作啊?非也,我们要看 query 函数里面的东西才能下定论 :)

function query(sql)
send_query(db, sql)
yield()
return read_result(db)
end

看上面的代码是否跟堵塞 IO 的很像?但请注意 yield 这个函数的调用,在协程的世界中 yield 即把当前运行权交出,等待程序主体的 resume 再运行接下去的代码,当我们的程序主体接收到该次 IO 操作的结果,resume 协程即可。如果我们把 yield 函数的调用都去掉,就是堵塞 IO 的逻辑,而加上则支持非堵塞 IO 操作了。

OK! 现在我们知道非堵塞 IO 可以使用异步回调和协程两种方式来操作!

那为什么说 CallBack 模式不适合 Web 场景?

我们在这里举一个业务例子,分别使用两种方式看看代码是怎样的

业务需求:
1. 读取一个会员信息,获得 uid
2. 并根据 uid 查询对应的文章记录
3. 根据 uid 查询对应的回复记录

使用 Node.JS 会这样写业务代码:

query('SELECT uid FROM users WHERE username=\'aaa\'',
function(uid){
query("SELECT * FROM Article WHERE uid="+uid,
function(res){
query("SELECT * FROM Reply WHERE uid="+uid,
function(res2){
console.log(uid, res, res2);
}
)
}
)
}
)

使用 Lua 类协程方式会这样写业务代码:

uid = query('SELECT uid FROM users WHERE username=\'aaa\'')
res = query("SELECT * FROM Article WHERE uid="+uid)
res2 = query("SELECT * FROM Reply WHERE uid="+uid)
print(uid, res, res2)

上面两段代码都使用非堵塞 IO,但很明显的看到,使用协程方式书写的代码其实跟堵塞 IO 的完全没区别,非常精简。而看 Node.JS 基于异步回调方式写的代码,就有很长的嵌套“尾巴”,多个累赘的 function 声明。

上面的代码例子是串行逻辑的情景,也许你想说在这个情景下 Node.JS 会吃亏。好的,我们写个并行的代码来看看

Node.JS

query('SELECT uid FROM users WHERE username=\'aaa\'',
function(uid){
async.parallel({
res: query("SELECT * FROM Article WHERE uid="+uid),
res2: query("SELECT * FROM Reply WHERE uid="+uid)
},
function(result) {
console.log(uid, result.res, result.res2);
});
}
)

Lua

uid = query("SELECT uid FROM users WHERE username='aaa'")
wait(
newthread(function() res = query("SELECT * FROM Article WHERE uid="+uid) end),
newthread(function() res2 = query("SELECT * FROM Reply WHERE uid="+uid) end)
)
print(uid, res, res2)

Node.JS 即使引入 Async 类的功能支持都无法回避嵌套,只能把嵌套的层级尽量缩小而已,嵌套达到5层的代码就很难维护了。而 Lua 这些基于协程方式实现的非堵塞 IO 操作则精简得多,业务代码很清晰,也更容易维护。

CallBack 回调模式适合什么业务场景?

CallBack 回调的开发模式很适合客户端场景,因客户端与客户有很多交互动作,并且这些动作都是未知的,我们总不能每时每刻去问下,客户点击某个按钮了吗?按了我就得做什么事情去了。在这样场景下,我们更希望在每个按钮上绑定一个事件通知的回调,让客户点击某个按钮时会触发我去做相应的事情。

而 Web 后端开发场景上明显不是上面所说的,完全不同。所有事件的产生你都知道,因为是你发起的非堵塞 IO 操作,你可以用更好的方式来得到事件的结果,而不是 CallBack 回调。

对协程开发有兴趣的同学可以考虑:

OpenResty: http://openresty.org/
一个基于 Nginx + Lua 的高性能 Web 应用服务器

aLiLua: http://alilua.com/
一个基于 Epoll + Lua 的高性能 Web 开发框架
(PS:这也是我的开源项目,主打提供一整套 Web 后端开发所需的支持)

为什么重复制造轮子 – aLiLua

其实早在 2010 年我就在 Lua 用于 Web 开发方面做过尝试,当时还以 PaaS 形式对外提供服务的。但工作重点转移到云存储方向,并且服务也没见起色就停止维护了。

而到了 2013 年工作稍微轻松,想搞点小东西玩玩,也尝了把 node.js,但过程中发现基于回调的形式进行 Web 开发还是比较累的,开发效率相对于 PHP 低得多,尤其是业务逻辑多起来的时候。但 node.js 的异步处理效率很高,换用 node.js 是有得也有失啊。这让我再次想起 Lua,这个开发效率跟 PHP 一样快,同步逻辑的思维相对 node.js 来说清晰太多了。但之前做的 yo2lua 项目由于个人对 Lua 的了解不够深入,基于堵塞 IO 实现,可以说性能一败涂地。当时居然还搞什么 PaaS,搞笑了~(个人经验:回头看自己,会发现有很多sb,哈哈)既然这样我就重新搞搞 Lua 吧,aLiLua.com 这个域名在 2010 年做 yo2lua 的时候就注册好了,现在就以此名开展这个开源项目。

设计目的:
1. 开发简单、效率高
2. 运维简单、性能高
3. 稳定性高、资源消耗少
4. 满足 Web 开发中的常用功能需求

Lua 语言跟 PHP 类似的同步逻辑开发模式,满足开发效率要求。而 Lua 语言的运行效率可以说是这么多高级语言当中的佼佼者,满足性能高的要求。并且该语言至今已迎来第 20 个生日,在语言本身而言是非常稳定的。

为了简化运维和提高性能,所以决定采用 epoll 事件来驱动整个系统,而不是基于 nginx 来实现。(在第一版成熟后,会考虑其他平台的支持)当然完全自主实现有各有利弊,好处是可定制性高、相对的业务逻辑少,更纯粹于提供 Web 服务。

aLiLua 第一版所支持的功能列表:
1. cosocket (异步网络IO)
2. connection pool
3. HTTP/1.1 (keepalive)
4. gzip/deflate 压缩
5. session
6. 基于共享内存的 key/value cache (Yac weibo.com 在用)
7. template 模板引擎
8. sandbox (可用于做 PaaS 服务)
9. iconv/字符串处理
10. hooker/filter 钩子和过滤器
11. CLI 支持命令行模式
12. writev/sendfile 高性能的网络操作
13. ssl socket
14. mysql/memcached/redis/http client (由 ngx_lua 提供)

计划功能列表:
1. cron 定时任务
2. queue 任务队列
3. aio 文件异步IO
4. cluster key/value 存储支持集群分布
5. mbstring 字符串处理
6. Web Framework 基于 aLiLua 的 Web 开发框架
7. ssl server
8. msgpack rpc
9. …

在完成第一版时我对 Lua 和 node.js 等做了很多压力测试,性能跟 node.js 差不多,但如用 LuaJit 则高出 30%+。最主要的是 Lua 所消耗的内存和 CPU 都比 node.js 少,且占有率比较稳定。压力测试中使用的业务逻辑会比实际项目少,很可能实际效果比 node.js 更好一些。

aLiLua 是个新生的开发模式,底层兼容 ngx_lua ,大家可以先尝试用 aLiLua 来做些小应用 :) 我们提供免费终生保养服务。更希望大家在使用过程中能提出宝贵意见,以使 aLiLua 更完善

下载地址: https://github.com/yo2oneoo/alilua

ngx_xxtea 模块发布

  就如其名,这是一个基于 XXTEA 加密算法实现的 NGINX 模块。至于用途,我也不知道。我是用在 URL 防盗方面的(隐藏真实地址)

安装:

configure –add-module=../ngx_xxtea-1.0

配置:

#加密方法,其中 content 和 key 支持 nginx 内部变量
set xxtea_data “aaa”;
set xxtea_key “123″;
set $var1 $xxtea_encrypto;

#解密方法,其中 q+tThwMc2vg= 是需解密的内容
set xxtea_data “q+tThwMc2vg=”;
set xxtea_key “123″;
set $var1 $xxtea_decrypto;

场景:

  我需要对 http://a.com/*.url 下的所有图片资源进行防盗处理,我会在调用页面上使用 php 对这些 url 进行加密。如:

xxtea_encode($URI, $REMOTE_ADDR); // 使用用户的客户端IP,当然你也可以用 COOKIE

  这样页面上所有的图片请求,都应该是:http://a.com/e/q+tThwMc2vg= 类的地址(注意别对 URI 进行 encode)。并且该地址是通过客户端IP进行加密的,也就是说其他人无法使用这个地址。

配置 NGINX 对这类地址进行解密操作:

location /e {
if ( $request_uri ~* “/e/(.*)$”)
{
set $xxtea_data $1;
set $xxtea_key $remote_addr;
rewrite (.*) $xxtea_decrypto;
}
}

  ngx_xxtea 模块 下载地址 (.tar.gz) 压缩包中包含 PHP XXTEA 方法

  另外,章哥也有一个类似功能的模块,地址:https://github.com/agentzh/encrypted-session-nginx-module ,大家按需求选择 :)

充分发挥服务器的各项资源

在当前的网站架构中,经常会有较多的角色分工,如:缓存服务(文件型)、缓存加速服务(内存型)、PHP/Java 计算服务和数据库服务等等。每种服务对服务器的资源占用各有不同,例如:缓存加速服务就需要大量的内存,而 PHP/Java 计算服务则需要占用大量的 CPU 资源。如何充分发挥单机的资源利用,往往是运维的同学负责规划。认为的来进行分配,必然会有所疏漏,也正因此现在发展出 VMware 类虚拟服务器的云计算,可以实现一定的自动分配资源,达到节省成本的效果。

但使用 VMware 类方案并不太适合单个企业选用,原因一:架设一套自主的虚拟机集群,会产生软硬件成本,二:虚拟服务器系统本身要消耗掉一小部分性能。或者我们可以考虑以下方案 :) 可在原有的业务基础上进行简单的梳理,以达到需要的效果。

我们把服务器抽象出,磁盘、内存和 CPU 三项资源。服务角色可把 PHP/Java 归为计算类型,而 Memcached/Redis 等归为内存类型,对文件/页面缓存归为磁盘类,数据库较为特殊,它对三类资源的需求都比较高,将其定位特殊的全需求类型。

现在资源和服务类型都很清晰了,那么我们就可以针对服务器的三项资源进行网络化,抽象出三小块分布式服务。PHP/Java 计算类型,可在Web Server层加入分布式逻辑,使得业务可自动均衡的分配到服务器群;对于 Memcached/Redis 这些缓存加速服务基本都带有分布式支持能力;而磁盘类型的业务则需要找到一个适合业务所需要的解决方案(如业务本身对文件名没要求,那可考虑 FastDFS 类分布式存储系统,否则可考虑 MongoFS 类存储系统)以此我们可以构建出下图所绘的架构:

此架构不仅能让服务器的各项资源得到充分发挥,还具备分布式计算的热备能力,并且单台服务器的软件、配置环境标准化,可减少运维的工作量,日常维护也更加方便。是一举几得的好事!以下为我们某个业务的服务器组状态:

上面这组服务器,我们部署有 PHP/Memcached/MySQL 和一个分布式文件系统。

题外话:这素云计算的基础形态麽?嗯,有点云的意思,当然未能达到这个高度。主要因这个方案是非常基础的,仅适合单网站(公司)使用,如果非要说云,那只能说私有云。而要做公有云,我们还需要在此架构基础上,加入安全隔离、资源租赁和统计等很多子系统。

此文撰写于昨晚国航灰机晚点的几个小时里~晚上10点的票居然凌晨3点才到家~故留此印记。

使用 Nginx 构建一个“高”可用的 PHP 集群

  跳过没必要的介绍,直接进入主题。目前建立一个高可用集群的方案不少,可以使用硬件或软件 LVS 类构建,现在我说的方案是只用 Nginx 来进行构建。

  这个集群的架构如下图:

  上面我们共部署了5个节点,每个节点上配有 Nginx + PHP。这个架构的重点就在于,Nginx 不只是与本机的 PHP 通信,整个集群应该把 Nginx 部分抽象到面向业务的第一层,而 PHP 则在第二层。每层都为多节点均衡架构。

  其中 Nginx 层面使用 DNS 均衡实现,DNS 负载均衡是一个很传统的方案,在单个域名下绑定多个 IP 进行轮循,可有效的把业务请求分发到多个节点上,但某节点故障时则需要有相应的解析处理,把故障的节点从 DNS 记录中删除。目前推荐使用 DNSPOD 的解析服务,可支持 API 操作。这样我们就可以自己建立一个服务器状态管理的程序,自动切换 DNS 解析。(注意:域名解析的切换需要 5~10 分钟,当然这是由域名解析记录的 TTL 值决定,为避免大量的 DNS 解析影响请求打开速度,建议 10 分钟或以上为佳)

  第二层 PHP 则由 Nginx 使用 upstream 实现均衡,Nginx 本身的 upstream 就已支持节点健康维护的功能,可以放心的交给 Nginx 来做。而如果 PHP 业务层带缓存功能,则要考虑使用一致性哈希模块来实现 upstream 的均衡策略,否则节点故障对整个 PHP 集群的缓存造成大幅度的震荡。根据我们测试的数据,在普通哈希策略下,一个节点故障会导致 90% 的缓存失效,而使用一致性哈希则可降低到 50% 。并且我们的 Nginx 一致性哈希模块,还可以把故障节点的请求分发到邻近的节点,可以再提高部分缓存命中率,使得整体提升到 70% 的样子。

  这样一个架构方案给我们实现了一个“高”可用的 PHP 集群,并且没有单点故障的隐患存在。DNS 解析服务是多节点,Nginx 层是多节点,PHP 层更是多节点的模式。如使用 LVS 方案,LVS 服务本身也要做一套热备,才能避免单点问题,且增加了架构复杂性。

  应该选择那套架构方案还由业务决定,这里我只是提供一个新思路罢了 ;)

  Nginx 一致性哈希模块:ngx_consistent_hash-1.0.tar

             ngx_consistent_hash-1.1.tar.gz (Fix nginx reload bug)

  使用方法:

upstream backend {
server 192.168.1.101 weight=1;
server 192.168.1.102 weight=2;
server 192.168.1.103 weight=3;
server 192.168.1.104 weight=4;
server 192.168.1.105 weight=5;
consistent_hash $host$request_uri 2;
}

  consistent_hash 支持2个参数,第一个参数为哈希字符串,第二个参数为备份节点数量。当某节点故障时,将把该节点的请求分发到2个备份节点上。当然你可以设置1或更高,建议2为佳 :)

  模块对 nginx 原 upstream 模块的 weight 节点权重功能进行了替换,weight 的功能是配置节点在集群中的位置顺序。(做一致性哈希,这是必须的)