12

由Lua 粘合的Nginx生态环境

agentzh tech-club.org 演讲听录

活动: Tech-Club技术沙龙(2012年2月)活动小结
幻灯: ngx_openresty: an Nginx ecosystem glued by Lua
录音: http://vdisk.weibo.com/s/2Qcon
笔录: Zoom.Quiet
很早就一直关注 agentzh 对 nginx 的给力 hacking,这次总算有个阶段性的说明,虽然无法现场交流, 好在有录音,为了其它没有时间听的人们,以及给搜索引擎更好的搜索数据,俺义务听录全文;

免责聲明

录音/幻灯来自作者,版权当然属于他们
文字听录来自 Zoom.Quiet,一切文字问题都是我造成的,与原著无关
因为本人技术有限,仅通过幻灯和录音,记错的地方负责在我,与原著者无关
任何不满和意见,请直接与我联系以便改进
zoomquiet+nginx@gmail.com

Lua 粘合的 Nginx 生态环境

很高今天和大家进行分享,之前,在北京进行过相关的分享; 今天我们的話題是 Nginx 也可以說是关于 Lua 的; 介绍过去3年以来我们的工作, 工程名字是,openresty,可以追溯到2007年,那会儿,我刚刚进入 Yahoo! 中国, 第一份工作就是架构一个开放平台, Yahoo! 自个儿的开放平台, 系统作到后来逐渐偏离了初衷, 我们开始为大型的互联网公司作一些和web 前端打交道的系统支持;
我在 Yahoo!和 TaoBao 分别工作了两年,就辞职了; 主要因为,我们的开源作品,越来越多人使用了, 而我一方面,要应付所謂业务需求,另方面要响应来自国内外积极开发者们的要求或是bug; 所以,干脆辞了专心作事儿; 本来,我想搬到厦门,可是我老婆在福州找到了工作,于是,, 现在,我不拿工资,义务为全球的愛好者开发 ;-) 现在,已经在福州呆了7个月,这是我老婆给拍的照片; 

我习惯,先在纸上写好代码,然后输入电脑,
前面放的是 kindle ; 这台 kindle 的来历比较有意思
在TaoBao 的时候,我打算将 openresty 重写,因为一开始是用 Perl 来写的
而在Yahoo! 的时候虽然已经使用 openresty 統一了搜索功能,但性能的确一般
当时,本想基于 Apache 来改写,不过一位师傅跟我讲:"你就直接拿c 写吧,基于 Apache 写没有前途的!"
俺很郁闷,就问,那怎么整? 师傅回答,你研究一下 nginx 的源代码吧,然后就没再理我 而看代码是很累的,所以,俺一到 TaoBao 就买了台 Kindel 来看代码...

OpenResty

刚刚提过, OpenResty 在开发过程中逐偏离了原计划; 再面对后来,更加具体的公司业务后, 这时,已经可以看出所谓 Ajax/Servise 化了, 在我接触过的各种繁忙的互联网公司,都有种趋势,就是:

对看起来是个整体的web 应用
习惯在后台拆成很多 Service
有些Service 是供給客户端发起請求来访问的
而有些Service 根本就是为其它服务而服务的,也使用了 http 协议进行发布

这种结构,导致整体系统变得非常分散

由多个部门,分别实现一部分系统
而每个部门,暴露给其它部门的,都是 http 协议,resful 形式的接口而已,
比如说,去哪儿网就是非常非常松散的服务组合成的。
一个请求进入后,立即分解成各种请求分别进行
而有些就在 Service 之间进行了
既然,http 协议如此常见,我们就需要强大的实现基础。
nginx 是我们调研的各种平台中最不烂的一个!
其它真心都特别烂:
Apache 最大的问题是其 I/O 模型,无法完成非常高效的响应。但是优点是:开发接口规整,基于它来写 mod 非常方便。
Lighttpd 正好相反,其 I/O 非常高效,但是开发接口不怎么友好。
而 Nginx 融合了两者的优点 ;-)
一方面使用了 Lighttpd 多路复用的 I/O 模型
另一方面以借鉴了 Apache 的模块开发支持
在(OpenResty)开发过程中,经常有人问,为什么 Nginx 如此之快?
我们知道 Nginx 是单线程的,而单线程的模型为什么可以承担上万甚至上几十万的并发请求? 
因为 Nginx 的工作方式如动画所示,这是我刚刚用 Perl 生成的一个简单 git 动画:点击这里

这其实是操作系统线程作的事儿
前面3个,分别对应不同的 http 请求
每个方块代表一个读或是写操作
最后的 epoll_wait 就是 linux 系統中最高效的一种事件接口
也就是説 nginx 内部其实是种事件驱动的机制
只有相关事件发生时才处理具体数据
如果当前接口没有数据时,就会立即切换出去,处理其它请求
所以,虽然只有一个线程。
但是,可以同时处理很多很多线程的请求处理。
那么,这种形式的 web 系統,可以很轻易的将 cpu 跑满,即使带宽没有跑满的情况下。而 apache 这类多进程多线程模型的服务器,则很难将 cpu 跑满
因为并发达到一定量时
内存首先将耗尽
因为在 linux 系统中,线程数是有限的,每个线程必须预分配8m大小的栈,不论是否使用!
所以,线程增加时内存首先成为瓶颈
即使挺过内存问题,当并发请求足够多时,cpu 争用线程的调度问题又成为系統瓶颈
所以 Nginx 这样简单的单进单线模型,反而被 Memcached 等高性能系统定为I/O 模型。
那么,我们作了什么呢?
主要是为 Nginx 提供了很多补丁进行了 bugfix
同时利用 Nginx 提供的开发者接口贡献了很多模块
我们还将之前提及的 Lua 嵌入 Nginx,使其具有全功能的交互能力
更加把 Lux 一些常用库也放进去了
然后打成一个大包,命名为 OpenResty ...
这是使用 Tiddlywiki 随便作的一个 主页:http://openresty.org

配置小语言

Nginx 本身有个很重要的特点,这在维基百科的条目中也强调过
其配置文件记法是非常灵活,并可读的
Nginx.conf 配置文件,本地其实就是个小语言,比如:

location = '/hello' {
       set_unescape_uri $person $arg_person;
       set_if_empty $person 'anonymous';
       echo "hello, $person!";
}

这段配置对于 Apache 用户来说也很熟悉
我们首先使用类似正则表达式的形式来约定一个响应的 URL
然后,可以使用各种 Nginx 的指令对内部变量进行到系列操作
变量也是配置文件的一部分,很象一种编程語言
比如,这里我们就将 person 这个变量使用 arg_person 进行赋值
然后用 'anonymous' 作为空值时的默认值给 $person
最后直接使用 echo 将结果输出这样,我们就可以使用 curl 模拟浏览器访问给 /hello 提供一个utf8 编码的字串值,以 ?person= 的GET 方式变量,就可以获得預期的反馈:

$curl 'http://localhost/hello?person=%E7%AB%A0%E4%BA%A6%E6%98%A5'
hello, 章亦春

如不给参数的话刚刚的 anonymouse 就起作用了

$curl 'http://localhost/hello'
hello, anonymous

所以整体上,我们期望在 Nginx 中实现服务接口,就这样写点配置就好不用写什么认真的 C 代码;-)
而跑起来就象飞一样。因为,这么来写实际和用 C 现实没有什么区别。
事实上全世界的开发者都在使用 Nginx 的开发接口,在拼命丰富这种配置文件小语言的词汇表!
而真正决定其表达能力的是:"vicabulary"
比如说,我们看这个例子:
这是我写向第 2 或是第 3 个 Nginx 模块:ngx_memc
用以直接访问 Memcached 的所谓上游模块(针对 Memcached 服务器的 Nginx 上游模块)
Nginx 有自个儿的一套术语,在其后的各种服务比如 Memcached,在 Nginx 而言就是上游对应的那些访问  Nginx 的浏览器等等,客户端就视为下游

# (not quite) REST interface to our memcached server
#  at 127.0.0.1:11211
location = /memc {
    set $memc_cmd $arg_cmd;
    set $memc_key $arg_key;
    set $memc_value $arg_val;
    set $memc_exptime $arg_exptime;
    memc_pass 127.0.0.1:11211;
}

这样简单的配置一下,通过 set 将url 上的各种参数映射给几个变量
然后通过 memc_pass 连接到远端一个memcached 服务,当然后面也可以是个集群
立即我们就得到一个,应该说是种伪 restfule 的 memcached 的使用接口服务
我们可以使用 curl 来操作目标 memcached 了
比如说,著名的 flush_all 命令就可以直接通过 url 来执行

$curl 'http://localhost/memc?cmd=flush_all';
OK
$curl 'http://localhost/memc?cmd=replace&key=foo&val=FOO';
NOT_STORED
通过这种形式,我们可以快速扩展成对 memcached 集群的简洁管理服务,进行各种操作
这样作的好处在于:
不论其它相关应用使用 php 什么乱七八糟的语言写的,都可以统一包装成 http 接口
令整个业务系统变成 http 协议,这样系统的复杂度就能够有效降低
同样可以这样对 MySQL 等等其它集群服务进行包装
包括大家知道的 taobao 集群,对外部开发来说好象是专门为外部扩展发布的服务
其实在 taobao 内部各种服务也是以两样形式組合起来的
大家知道 taobao 是 java 系的,它很多服务是通过定制 jvm 完成的
所以,对于ali 原先业务、以及合作方的业务、还有我们数据统计部门的业务,对于jvm是无法直接使用的
怎么办?所以,通过开放平台业务,将各种内部服务封装成一系列 http 接口方便使用
包括 taobao 的登录,其实也封装成 http 接口,供给 taobao 子域名应用来使用
不论使用什么开发语言,总是可以对 http 协议进行访问的
而且 http 协议本身非常简单
我们可以方便的获取许多现成的工具进行调试/追踪/优化
另外,由于选择了 Nginx 这使得 http 的开销代价变的非常非常的低
记得去哪儿网,原先有业务使用了几十台 MySQL
前端使用 java 的 jodb 进行连接
而因为代码写的比较糟糕,因为业务部门嘛写的时侯不会注意连接池的效率
所以,每台主机的负载都非常非常高
而我们后来改为 Nginx 作前端结果一台 Nginx 就将以前几十台 java 主机的业务抗了下来
通过封装成 http 接口,业务代码随便长连接/短连接,随便它搞都撑得住了!
于是,被他们 java 程序员描述成不可能的任务被一台 Nginx 主机就解决了

(中间有人提问):

封装具体作了什么?为什么比原先的方式效率高? 虽然改成了 http 实际连接MySQL 时不同样要消耗?

因为,封装成 http 接口的数据库,我们内部使用了连接池
已经优化的高效数据库连接池,而一般工程师不用关注连接池的技巧,专心完成业务代码就好不容易出错
而且,使用语言专用中间件的话,牵涉到其它问题:

中间件本身是否稳定? 高效率?
中间件本身是否易于扩展好维护?
等等一系列问题,远没有统一成 http 服务于所有语言实现的应用来的干脆简洁
甚至于我们后来引入了完整的 Lua 语言,它基本足够完备,可以支持我们直接完成业务
taobao 的数据魔方就直接使用脚本在 Nginx 中完成的
相比原先 php 的版本,仅仅这一项,就提高响应速度一个量級!
所以,不论 memcached 还是什么数据库我们可以統一到一个中间件
而且 http 协议的中间件,还有个好处是可以直接公开給外部使用
因为 http 上的访问控制很好作,复杂度也低
我们的量子统计,就是直接和 taobao 主站服务通过 http 良好整合在了一起
可以简单的一个参数处理就发布給外部或是内部来安全使用

ngx_drizzle

通过模块,我们可以建立应用和 MySQL 间的非阻塞通讯
这点非常重要!
因为,当前端访问后端很大的数据集群的时候,其本身的并发能力就成为瓶颈
设想后端有近百台 MySQL 时,后台本身的并发量就已经非常大了
而前端类似 php 技术根本无法将后端所有主机的能力都应用起来
所以,我们非常需要非阻塞技术
需要一种数据库代理,就象很高能的网关一样,将后端所有 MySQL 服务器的能力都激发出来而不用期待前端应用来自行完成并发调度
基于以上认知,我们开发了各种数据的非阻塞上游模块:
包括对 MySQL/Postgres/redis 等等
也尝试过对 Oracole,但是,其官方的 c 驱动有些限制,虽然也提供了非阻塞接口,但是不完整
在建立连接和銷毁连接时,只能以阻塞方式进行,所以很纠结
MySQL 官方的 c 驱动也只提供了阻塞方式!
那只好寻求第三方的驱动,我们选择了 Drizzle 这个驱动,并整合进来成为 ngx_drizzle 模块

upstream my_mysql_backend {
    drizzle_server 127.0.0.1:3306 dbname=test
    password=some_pass user=monty
    protocol=mysql;
    # a connection pool that can cache up to
    #   200 mysql TCP connections
    drizzle_keepalive max=200 overflow=reject;
}

我们这样简单配置:
通过 drizzle_server 配置连接口令和协议,因为模块可以连接 MySQL 和 drizzle 两种数据源,所以要声明协议模式
使用 drizzle_keepalive 建立一个连接池,限定上限为200当超过连接限制时就 reject,相当对数据库的简单保护
然后这样定义一个 cat 接口

location ~ '^/cat/(.*)' {
    set $name $1;
    set_quote_sql_str $quoted_name $name;
    drizzle_query "select * from cats where name=$quoted_name";
    drizzle_pass my_mysql_backend;
    rds_json on;
}

cat 之后是这猫的名字,使用 set 获得,这是 Nginx 本身的功能
然后使用 set_quote_sql_str 对查询语句进行转义,以防止SQL注入攻击
通过 drizzle_query 组合成查询语句
drizzle_pass 来完成对后端数据集群的查询,因为前面的 drizzle_server·可聲明一组 MySQL服务器
甚至于我们为查询返回的结果集定制了一种格式叫 rds_json
这种格式是面向各种关系型数据库的
我们针对这种格式开发了一系列过滤器,可以自由输出 csd 或是 json 格式
这样,几乎所有报表接口,都通过这种方式实现的
taobao 直通车就使用了 csd 格式,因为他们是将这当成中间件来使用的
而我们是直接通过 json 以 Ajax 形式对外的
这样,通过 curl 访问 cat 接口查询 Jerry,就可以获得名叫 Jerry 的猫的相关数据

$ curl 'http://localhost/cat/Jerry'
[{"name":"Jerry","age":1}]
这里 json 的输出,可以通过一系列方式进行自由的调整
比如说,有的要求每行数据都是 key/value 的格式,有的要求紧凑格式,第一行包含key之后,以后的全部是数据等等

ngx_postgres

那么 portsgres 访问接口模块名叫:ngx_postgres
这是一位波兰的 hacker 在我们的ngx_drizzle 基础上完成的
因为它仿造了我们的接口形式
pg 的官方模块是无法使用的,于是他花了两个月的时间完成了这个模块
去哪儿网,有很多地方就使用了这一模块
我们可以看到如何使用 Lua 来调用这个标准模块,因为在 web 开发中,每向上一层速度会下降一级。但是,功能会丰富很多
但是使用 nginx 模块来完成,速度损失很有限

upstream my_pg_backend {
    postgres_server 10.62.136.3:5432 dbname=test user=someone password=123456;
    postgres_keepalive max=50 mode=single overflow=ignore;
}

这里,我们配置 overflow 时 ignore,忽略就是说连接超过限定时,直接进入短连接模式

location ~ '^/cat/(.*)' {
   set $name $1;
   set_quote_pgsql_str $quoted_name $name;
   postgres_query "select * from cats where name=$quoted_name";
   postgres_pass my_pg_backend;
   rds_json on;
}

这样定义一个 pg 版本的 cat 接口

$ curl 'http://localhost/cat/Jerry'
[{"name":"Jerry","age":1}]
注意,进行SQL 转义时问的是 set_quote_pgsql_str, 因为pg 的SQL转义和其它的不同

ngx_redis2

然后去年的时候,我为了好玩,写了个 redis 的模块:ngx_redis2
依然是100%非阻塞,去哪儿和天涯也都大量使用了这一模块

upstream my_redis_node {
   server 127.0.0.1:6379;
   keepalive 1024 single;
}

同样使用 upstream 定义一个或是多个连接池
使用 keepalive 定义并发策略,这种场景中 tcp 在 http 的连接消耗是非常低的

# multiple pipelined queries
location /foo {
    set $value 'first';
    redis2_query set one $value;
    redis2_query get one;
    redis2_pass my_redis_node;
}

这里,我使用 redis2_query 定义了两个请求
通过流水线形式,一次请求发送了两个命令过去响应时,就有两个响应按照顺序返回

ngx_srcache

ngx_srcache 是个很有趣的通用缓存模块
之前为 Apache 写过一些模块,其中一个比较有趣的,就是针对 mod_cache 模块写了个 memcached 的模块,就可以通过 memcached 对 Apache 中任意的响应进行缓存!
这模块当初是为 Yahoo! 的搜索业务中,爬虫的抽取系统进行设计的
当然我就发现 Apache 里对 memcached 进行阻塞访问时,有点虚焦? 随着并发数增加,响应速度极速下降
所以,在 Nginx 时就不会有这种问题,保证所有处理都是非阻塞的!包括访问 memcached
所以,我们可以在配置文件中自行决定使用什么后端来存储缓存

location /api {
    set $key "$uri?$args";
    srcache_fetch GET /memc key=$key;
    srcache_store PUT /memc key=$key&exptime=3600;
    # proxy_pass/drizzle_pass/postgres_pass/etc
}

这里我们定义两种调用,所谓 fetch 是在 Apache 中一种模板 c 级别的调用,但是技法和 http 的 get 一样
这样声明的 location,我们可以同时即对外提供调用,也可以对配置内部其它 location 进行调用!

location /memc {
    internal;
    set_unescape_uri $memc_key $arg_key;
    set $memc_exptime $arg_exptime;
    set_hashed_upstream $backend my_memc_cluster $memc_key;
    memc_pass $backend;
}

这样,其实就是在收到请求时,实际调用了 /memc 接口,访问后端缓存
收到结果后,再使用 srcache_store 接口整理 put 回请求的入口 location,设置相应的格式
而 /memc 接口通过 internal 标记,成为仅仅对内服务的接口
后面我们通过一系列指令从 url 参数 ;-)
即使是内部调用,依然是个标准的 http 请求界面
然后使用 set_hashed_upstream 对 memcached 的集群进行基于键的模的 hash 将结果放到 $backend
最后使用 memc_pass 完成对集群的查询
这里的 my_memc_cluster 是怎么定义的呢?

upstream memc1 {
    server 10.32.126.3:11211;
}
upstream memc2 {
    server 10.32.126.4:11211;
}
upstream_list my_memc_cluster memc1 memc2;

使用 upstream 定义两个服务,使用 upstream_list 声明为一个集群
这里其实也有限制的:
在我们动态追加主机时
我们要重新生成配置文件,然后使用 touch 命令通知 Nginx 重新加载
而这一限制,我们将看到在基于 Lua 的实现中会不存在 ;-)
前面我们看到,经过简单的配置,我们就可以获得一系列强大的 AP 服务

ngx_iconv

实际使用中,还有一个重要的需求就是字符串编码:
因为,有的业务是基于 gbk的有的又是 utf-8 的
一般我们可以在数据库层面进行处理
但是,对于一些功能弱些的产品,比如说 memcache/redis 等就没办法了
所以,我们完成了自己的动态编码转换模块:
ngx_iconv
不管大家在访问 MySQL 时,使用的什么途径,比如习惯的反向代理什么的
都可以通过 iconv_filter 对响应体进行编码转换!
而且是流式的转换,也就是说,不需要 buffer 来一点数据就立即完成转换

location /api {
     # drizzle_pass/postgres_pass/etc
    iconv_filter from=UTF-8 to=GBK;
}

以上这是从 utf-8 到 gbk 的转换

嵌入 Lua

后面我们化了很大力气将 Lua 嵌入到了里面:
这样使得,可以实现任意复杂的业务了

# nginx.conf
location = /hello {
    content_by_lua '
    ngx.say("Hello World")';
}

这样我们就完成了一个 hallo world

$ curl 'http://localhost/hello'
Hello World

ngx.say 是 lua 显露給模块的接口
另外当然也可以调用外部脚本
如同我们写php 应用时,习惯将业务脚本单独组织在 .php 文件中一样

# nginx.conf
location = /hello {
    content_by_lua_file conf/hello.lua;
}

通过 content_by_lua_file 调用外部文件:

#hello.lua
ngx.say("Hello World")

这里的脚本可以任意复杂,也可以使用Lua 自己的库
早先,我们非常依赖 Ngninx 的子请求,来复用 Nginx 的请求模块:
比如说,我们一个模块,需要同时访问 memcached/mysql/pg 等许多后端
这时,怎么办?这么来:

location = /memc {
    internal;
    memc_pass ...;
}
location = /api {
    content_by_lua '
        local resp = ngx.location.capture("/memc")
        if resp.status ~= 200 then
            ngx.exit(500)
        end
        ngx.say(resp.body)
    ';
}

先在 /memc 中建立到 memcache 的连接,并声明为内部接口
然后,在 /api 中使用 ngx.location.capture 发起一个 location 請求
就象发起一个正当的 http 请求一样,请求它,但是,其实没有http的开销,因为,这是c 级别的内部调用!
而且是个异步调用,虽然我们是以同步的方式来写的
然后我们可以检验响应是否 200,否则访问 500
最后就可以将响应体输出出来

同步形式异步执行

这里为什么可以同步的写?

写过 javascript 前端程序的朋友,应该知道要实现异步效果,我们很多时候,要使用回调
而在 Lua 中我们可以这么来,因为 Lua 支持协程,即 concurrent
这样,我们可以在一个 Lua 线程中分割出多个Lua 用户级的逻辑线程
这种伪线程,可以实现比操作系统高的多的多的并发能力,因为系统开销非常的小
近年有一些技术,也都支持了 concurrent 的技术,可以像 http 请求顺序一样顺着写
不用象 js 程序员那些纠结倒着写,在需要顺序操作时,又必须借重一些技法,而应用技法的代码,又实在难看无法习惯
所以,我们当初选择 Lua 一个很重要的原因就是支持协程
这里我们假定,同时要访问多个数据源
而且,查询是没有依赖关系的,那我们就可以同时发出请求
这样我总的延时,是我所有请求中最慢的一个所用时间,而不是原先的所有请求用时的叠加!
这种方式,就是用并发换取了响应时间

location = /api {
    content_by_lua '
        local res1, res2, res3 =
            ngx.location.capture_multi{
                {"/memc"}, {"/mysql"}, {"/postgres"}
            }
        ngx.say(res1.body, res2.body, res3.body)
    ';
}

这里我们就同时发出了3个请求
同时到 memcached/mysql/pg
然后全新响应后,将結果放到 res1/2/3 三个变量中返回
所以,这种模型里,实现并发访问也是很方便的 ;-)

lua_shared_dict

这是我去年,花力气完成的 nginx 共享内存字典模块:lua_shared_dict
因为 Nginx 是多 worker 模型,可以有多个进程
但是,其实 workder 数量和并发无关,这不同于 Apache
Nginx 多 worker 的目的是将 cpu 跑满,因为它是单进程的嘛
Nginx 实际只跑了操作系统的一个线程,所以,多核主机中如果有8核心,我们一般就起8个 worker 的
如果业务有硬盘 I/O 的操作时,我们一般会起比核数略多的 worker 数
因为在 Linux 中,磁盘很难有非阻塞的操作
虽然有什么 aio 的模型,但是有很多其它问题
所以,本质上 Nginx 多 worker 是为了跑满 cpu
那么,一但多进程了,就存在满满的共存问题
比如说,我们想在多个进程间共存配置/业务数据
所以,基于共存内存来作

lua_shared_dict dogs 10m;
server {
    location = /set {
        content_by_lua '
            local dogs = ngx.shared.dogs
            dogs:set("Tom", ngx.var.arg_n)
            ngx.say("OK")
        '
    }
    location = /get {
        content_by_lua '
            local dogs = ngx.shared.dogs
            ngx.say("Tom: ", dogs.get("Tom"))
        ';
    }
}

上面这个例子:
首先,使用 lua_shared_dict 分配一 10M 的空间
然后,使用 OOP 方式来定义两个接口:一个 /set 一个/get
然后,不论哪个 worker 具体调用哪个操作
但是结果是終保存一致的
使用 curl 先 set 一下再 get 就变成了 59,因为内部进行了自增

$ curl 'localhost/set?n=58'
OK
 
$ curl 'localhost/get'
Tom: 58
共存的实现是通过红黑树+自旋锁来达成的:
红黑树的查找类似 hash 表查找的一种算法
为保持读写的数据一致性,使用自旋锁来保证
所以,当并发增大或是更新量增大时,自旋锁可能有问题,未来我们准备进一步修改成报灰的模型
其实,共享内存的方式在锁开销非常小时,效率是非常高的,在腾讯单机并发跑到20万都是小意思
另外,在 Lua 中我们需要对大数据量的一种非缓存的输出:
因为,在很多 web 框架中或多或少都有缓存,有的甚至使用了全缓存
那么,当你输出体积很大的数据时,就很易囧掉
而在 Lua 中我们就很容易控制这点
-- api.lua
-- asynchronous emit data as a response body part
ngx.say("big data chunk")
-- won`t return until all the data flushed out
ngx.flush(true)
-- ditto
ngx.say("another big data chunk")
ngx.flush(true)

比如,这里我们先 ngx.say,异步的输出一个数据
这段数据不一定刷得出去,如果网卡没来得及输出这投数据的话,这会在 nginx 的进程中缓存
如果,我想等待数据输出后,再继续,就使用 ngx.flush,这时,只有数据真正刷到系统的缓冲区后才返回
这样保证我们 Nginx 的缓存是非低的,然后我们再处理下一个数据段
如此就实现了流式的大数据输出
这样,有时网络很慢、而数据量又大,最好的方式就是:
既然你发的慢,那我也收的慢,你一点点发,我就一点点收
这样我们就可以使用很少的资源,来支持很多大数据量的慢连接用户

Socket形式

然而,还有些慢连接就是恶意攻击:
我可以生成很多 http 连接,接进来后,慢的发送,甚至就不发送,来拖死你的应用
这种情况中,你一不注意服务分配给太多资源的话,整个系统就很容易被拖垮。
所以,去年年底今年年初,我下决心,完成了一个同步非阻塞的 socket 接口:
这样,我就不用通过 Nginx 的上游模块来访问 http 请求:
我们就可以让 Lua 直接通过 http 或是 unix socket 协议访问任意后端服务

local sock = ngx.socket.tcp()
sock:settimeout(1000)   -- one second
local ok, err = sock:connect("127.0.0.1", 11211)
if not ok then
    ngx.say("failed to connect: ", err)
    return
end

象这样建立 socket 端口,并可以设定超时
我们就可以进行非阻塞的访问控制,当超时的时候,Nginx 就可以自动挂起切入其它协程进行处理
如果所有连接都不活跃,我也可以等待系统的 epoll 调用了就不用傻傻的完全呆在那儿了

local bytes, err = sock:send("flush_all\r\n")
if not bytes then
    ngx.say("failed to send query: ", err)
    return
end
 
local line, err = sock:receive()
if not line then
    ngx.say("failed to receive a line: ", err)
    return
end
 
ngx.say("result: ", line)

或是使用 sock:send 直接返回,就可以继续其它请求了
使用 receive 来接收查询的返回,读失败有失败处理,成功就打印出来 一切都是自然顺序

local ok, err = sock:setkeepalive(60000, 500)
if not ok then
    ngx.say("failed to put the connection into pool "
        .. "with pool capacity 500 "
        .. "and maximal idle time 60 sec")
    return
end

这是连接池的调用
通过 sock:setkeepalive, Lua 模块就会将当前连接,放入另一连接池中以供其它请求复用
也就是说,如果其它请求,请求到同一个url时,Nginx 会直接交給它原先的连接,而省去了开新连接的消耗
keepalive 的参数比较少:
头一个是:最大空闲时间,即一个连接放在连接池里没有任何人来使用的最大时间
这里是60秒,因为维持一连接的代价还是很昂贵的,如果一分钟了也没有人来用,我就主动关闭你节省资源
对于负载比较大的应用,这样可以减少浪费
第二个参数是:最大连接数
这里是500,如果连接数超过限制,就自动进入转移连接的模式
Unix 域套接字 是 Linux/Unix 系统独特的进程接口
虽然不走 http 协议,但是调用形式和 tcp 的 socket 完全类似

local sock = ngx.socket.tcp()
local ok, err = sock:connect("/tmp/some.sock")
if not ok then
    ngx.say("failed to connect to /tmp/some.sock: ", err)
    return
end

一样通过 ngx.socket.tcp 来建立连接
然后,使用 sock:connect 来指定一个特殊文件,接入套接字
就可以进行各种日常的操作了

concurrent ~ "cosocket"

这个模块是基于 concurrent 的:

写是顺序写,但是执行是非阻塞的! 这点非常重要!
协程技术诞生也有些年头了,
但是,至今 99.9% 的 web 应用依然是阻塞式的
因为早年,基于阻塞的应用开发太习惯了
而基于异步的开发,对于工程师的思維能力要求太高,这也是为什么 node.js 工程师在开发时的主要痛苦
因为,要求改变思維方式来考虑问题,我们的程序员多是 php 的,要求他们改变思维是很痛苦的
所以,不仅仅是为了推广我们的平台
更是为了兼容工程师的阻塞式思維,同时又可以利用协程来提高系统性能,达到单机上万的响应能力
我们引入了 Lua 的协程,并称之为:"cosocket" 即 concurrent based socket
而一位资深的 python 粉丝告诉我,python也有优秀的协程库:
是基于 greenlet 的 Gevent
当然,类似我们的系統,都是可以支撑非常高并发的响应
但是,我们当初选择 Lua 还有个很重要的原因是:
cpu 的执行效率
当你的并发模式,已经是极致的时候
cpu 很容易成为瓶颈!
一般情况下是 带宽首先不够了,然后 cpu 被跑满
而在 Apache 模型中,反而是内存首先不足
经常是24个进程 swap 8G/24G 不断的增长,卡住什么也玩不了了
而 cpu 光在那儿进行上下文切换,没有作什么有意义的事儿,即所谓内耗
当我们将应用从 I/O 模型解放后,拼的都是 CPU:
因为,内存一般消耗都不太大
我们经常在 256M 内存的虚拟机或是64M 内存的嵌入式设备中跑生产服务内存,真心不应该是问题所在
但是,要进行计算时就一定要快!
而 Lua 近年发展编译器到什么地步?
有种编译器,可以运行时动态生成机器码
在我们的测试中,高过了末启用优化的 gpc
而启用优化的 gpc,消耗资源又高过 Lua
所以,Lua 的性能没有问题
然后我们实际,按照 ruby 社区的説法,就是直接基于Lua 扩展出了一种专用小语言
业务团队实际并没有直接使用 Lua 来写,而是使用我们为业务专门定制的一种专用脚本(DSL)
所以,代码量非常的少,而且,我们的定制小语言是强类型的:
强类型语言有很多好处
而且,可以在小语言中定义对业务领域的高层次约束
你就可以很方便的查找出业务工程师常范的错误,转化成语言特性包含到约束中,在编译器中实现!
最后编译成包含优化的 Lua 代码,让它跑的象飞一样! 而且! 哪天,我高兴了,也可以让它生成 C 代码让它跑到极致!
这样,业务不用改一行代码,但是系统效能可以提高几倍
等等,这些都是可以实现的。。。
要实现这些要求,我们的基础必须非常非常的高效、同时又非常非常小巧!
这样我们才能在上面搭上层建筑
即,所谓的:"勿在浮沙筑高台"!
在这一过程中,我们也吃过很多苦。。。好在有 Nginx。。。
再有,我们发现 socket 模型,一样可以用来读取下游,即客户端请求数据!
当请求体很大,比如说,上传一个很大的文件时也需要异步处理,就省的我操心了
所以,我就对下游请求包装了一个只读的 socket,可以对请求数据进行流式读取

local sock, err = ngx.req.socket()
if not sock then
    ngx.say("failed to get request socket: ", err)
    return
end
sock:settimeout(10000)  -- 10 sec timeout
 
while true do
    local chunk, err = sock:receive(4096)
    if not chunk then
        if err == "closed" then
            break
        end
        ngx.say("faile to read: ", err)
        return
    end
    process_chunk(chunk)
end

这样,建立一个下游 socket 后,以 4096 字节为一个块(trunk)进行读取
然后检查是否结束,即使没有结束,我也可以一块块的进行处理
比如,读一块就写到硬盘上或是写到远程的一个 tcp 连接,这连接也是非阻塞的!
象这样,我这层就非常非常高效!

高层实现

进行各种高层次的实现就非常方便了

以前我用几年时间才能实现纯 Lua 的 MySQL 的连接模块
现在用几百行 Lua 脚本就实现了:lua-resty-mysql
而且是非常完备的实现
支持多結果/存储过程等等高級功能
而且性能非常接近纯 C 写的模块,我评测下来,也就差 10~20% 的响应
如果未来我用 C 改写其中计算密集型的处理模块,那性能可以进一步大幅度提升!
lua-resty-memcached 也就500多行就搞掂了!
是完整的 memcached 协议的支持
所以用这种技术,可以很方便的实现公司里固定的或是全新的后端服务
redis协议本身设计的非常巧妙,虽然命令多,但是底层传输协议非常简洁
所以,我只用 200 多行,就实现了:lua-resty-redis
后面两个模块都比较粗糙,仅仅封装了传输协议。所以,执行效率高于它们官方c 实现的等价物 ;-)
lua-resty-upload 就是提及的大文件上传模块
不过,这模块写的比较粗糙
api 暴露的不够优美

关于作者:章亦春

[1] 作者GitHub上天天提交代码
[2] 作者微博 不过,最近刷的比较少
[3] 作者个人博

向作者提问:

将 Lua 当成什么来用? 直接业务嘛?

简单的可以直接来
也可以架构更高层的領域脚本,编译成 Lua 来执行
不过,最终,都是通过寄生在 nginx 平台上的 Lua 来实际跑 

那 openresty 主要解决了nginx 的什么问题?是nginx 的缺陷嘛?

分两个方面来想:
1.作为 nginx 的补充,很多人也是这么用的,比如说负载的接入,简化 F5 的前端配置,访问的逻辑控制
2. 直接作为 web 应用的机制,直接实现所有的应用、输出网页、发布 web service 等等 

和 apache 什么的性能差别主要在哪里?

主要是 I/O 模型的本质差异
nginx + Lua 可以完成数量级上的提升
而且,作为应用或是作为 httpd 可以同时胜任双重角色!
上一篇 下一篇

相关文章