如何定位问题
最后更新于:2022-04-01 02:18:48
# 如何定位问题
一个正常的火焰图,应该呈现出如[官网](http://openresty.org/download/user-flamegraph.svg)给出的样例(官网的火焰图是抓C级别函数):![正常](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-11_55c9ff5840c23.png)
从上图可以看出,正常业务下的火焰图形状类似的“山脉”,“山脉”的“海拔”表示worker中业务函数的调用深度,“山脉”的“长度”表示worker中业务函数占用cpu的比例。
下面将用一个实际应用中遇到问题抽象出来的示例(CPU占用过高)来说明如何通过火焰图定位问题。
问题表现,nginx worker运行一段时间后出现CPU占用100%的情况,reload后一段时间后复现,当出现CPU占用率高情况的时候是某个worker 占用率高。
问题分析,单worker cpu高的情况一定是某个input中包含的信息不能被lua函数以正确地方式处理导致的,因此上火焰图找出具体的函数,抓取的过程需要抓取C级别的函数和lua级别的函数,抓取相同的时间,两张图一起分析才能得到准确的结果。
抓取步骤:
1. [安装SystemTap](#);
1.
获取CPU异常的worker的进程ID;
> ps -ef | grep nginx
1.
使用[ngx-sample-lua-bt](https://github.com/openresty/nginx-systemtap-toolkit)抓取栈信息,并用fix-lua-bt工具处理;
> ./ngx-sample-lua-bt -p 9768 --luajit20 -t 5 > tmp.bt
./fix-lua-bt tmp.bt > a.bt
1.
使用[stackcollapse-stap.pl和](https://github.com/brendangregg/FlameGraph);
> ./stackcollapse-stap.pl a.bt > a.cbt
./flamegraph.pl a.cbt > a.svg
1. a.svg即是火焰图,拖入浏览器即可:
![problem](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-11_55c9ff58cfe36.png)
1. 从上图可以清楚的看到get_serial_id这个函数占用了绝大部分的CPU比例,问题的排查可以从这里入手,找到其调用栈中异常的函数。
ps:一般来说一个正常的火焰图看起来像一座座连绵起伏的“山峰”,而一个异常的火焰图看起来像一座“平顶山”。
如何安装火焰图生成工具
最后更新于:2022-04-01 02:18:46
# 如何安装火焰图生成工具
#### 安装SystemTap
> 环境 CentOS 6.5 2.6.32-504.23.4.el6.x86_64
SystemTap是一个诊断Linux系统性能或功能问题的开源软件,为了诊断系统问题或性能,开发者或调试人员只需要写一些脚本,然后通过SystemTap提供的命令行接口就可以对正在运行的内核进行诊断调试。
首先需要安装内核开发包和调试包(这一步非常重要并且最为繁琐):
~~~
# #Installaion:
# rpm -ivh kernel-debuginfo-($version).rpm
# rpm -ivh kernel-debuginfo-common-($version).rpm
# rpm -ivh kernel-devel-($version).rpm
~~~
其中$version使用linux命令 uname -r 查看,需要保证内核版本和上述开发包版本一致才能使用systemtap。([下载](http://debuginfo.centos.org))
安装systemtap:
~~~
# yum install systemtap
# ...
# 测试systemtap安装成功否:
# stap -v -e 'probe vfs.read {printf("read performed\n"); exit()}'
Pass 1: parsed user script and 103 library script(s) using 201628virt/29508res/3144shr/26860data kb, in 10usr/190sys/219real ms.
Pass 2: analyzed script: 1 probe(s), 1 function(s), 3 embed(s), 0 global(s) using 296120virt/124876res/4120shr/121352data kb, in 660usr/1020sys/1889real ms.
Pass 3: translated to C into "/tmp/stapffFP7E/stap_82c0f95e47d351a956e1587c4dd4cee1_1459_src.c" using 296120virt/125204res/4448shr/121352data kb, in 10usr/50sys/56real ms.
Pass 4: compiled C into "stap_82c0f95e47d351a956e1587c4dd4cee1_1459.ko" in 620usr/620sys/1379real ms.
Pass 5: starting run.
read performed
Pass 5: run completed in 20usr/30sys/354real ms.
~~~
如果出现如上输出表示安装成功。
#### 火焰图绘制
首先,需要下载ngx工具包:[Github地址](https://github.com/openresty/nginx-systemtap-toolkit),该工具包即是用perl生成stap探测脚本并运行的脚本,如果是要抓lua级别的情况,请使用工具 ngx-sample-lua-bt
~~~
# ps -ef | grep nginx (ps:得到类似这样的输出,其中15010即使worker进程的pid,后面需要用到)
hippo 14857 1 0 Jul01 ? 00:00:00 nginx: master process /opt/openresty/nginx/sbin/nginx -p /home/hippo/skylar_server_code/nginx/main_server/ -c conf/nginx.conf
hippo 15010 14857 0 Jul01 ? 00:00:12 nginx: worker process
# ./ngx-sample-lua-bt -p 15010 --luajit20 -t 5 > tmp.bt (-p 是要抓的进程的pid --luajit20|--luajit51 是luajit的版本 -t是探测的时间,单位是秒, 探测结果输出到tmp.bt)
# ./fix-lua-bt tmp.bt > flame.bt (处理ngx-sample-lua-bt的输出,使其可读性更佳)
~~~
其次,下载Flame-Graphic生成包:[Github地址](https://github.com/brendangregg/FlameGraph),该工具包中包含多个火焰图生成工具,其中,stackcollapse-stap.pl才是为SystemTap抓取的栈信息的生成工具
~~~
# stackcollapse-stap.pl flame.bt > flame.cbt
# flamegraph.pl flame.cbt > flame.svg
~~~
如果一切正常,那么会生成flame.svg,这便是火焰图,用浏览器打开即可。
#### 问题回顾
在整个安装部署过程中,遇到的最大问题便是内核开发包和调试信息包的安装,找不到和内核版本对应的,好不容易找到了又不能下载,@!¥#@……%@#,于是升级了内核,在后面的过程便没遇到什么问题。ps:如果在执行ngx-sample-lua-bt的时间周期内(上面的命令是5秒),抓取的worker没有任何业务在跑,那么生成的火焰图便没有业务内容,不要惊讶哦~~~~~
显示的是什么
最后更新于:2022-04-01 02:18:43
# 显示的是什么
什么时候使用
最后更新于:2022-04-01 02:18:41
# 什么时候使用
一般来说,当发现CPU的占用率和实际业务应该出现的占用率不相符,或者对nginx worker的资源使用率(CPU,内存,磁盘IO)出现怀疑的情况下,都可以使用火焰图进行抓取。另外,对CPU占用率低、吐吞量低的情况也可以使用火焰图的方式排查程序中是否有阻塞调用导致整个架构的吞吐量低下。
关于[Github](https://github.com/openresty/nginx-systemtap-toolkit)上提供的由perl脚本完成的栈抓取的程序是一个傻瓜化的stap脚本,如果有需要可以自行使用stap进行栈的抓取并生成火焰图,各位看官可以自行尝试。
火焰图
最后更新于:2022-04-01 02:18:39
# 火焰图
火焰图是定位疑难杂症的神器,比如CPU占用高、内存泄漏等问题。特别是Lua级别的火焰图,可以定位到函数和代码级别。
下图来自openresty的[官网](http://openresty.org/download/user-flamegraph.svg),显示的是一个正常运行的openresty应用的火焰图,先不用了解细节,有一个直观的了解。
![Alt text](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-11_55c9ff577deda.png)
里面的颜色是随机选取的,并没有特殊含义。火焰图的数据来源,是通过[systemtap](https://sourceware.org/systemtap/)定期收集。
与Docker使用的网络瓶颈
最后更新于:2022-04-01 02:18:36
# 与Docker使用的网络瓶颈
Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app)。几乎没有性能开销,可以很容易地在机器和数据中心中运行。最重要的是,他们不依赖于任何语言、框架包括系统。
Docker自2013年以来非常火热,无论是从 github 上的代码活跃度,还是Redhat在RHEL6.5中集成对Docker的支持, 就连 Google 的 Compute Engine 也支持 docker 在其之上运行。
在360企业版安全中,我们使用Docker的原因和目的,可能与其他公司不太一样。我们一直存在比较特殊的"分发"需求,Docker主要是用来屏蔽企业用户平台的不一致性。我们的企业用户使用的系统比较杂,仅仅主流系统就有Ubuntu, Centos,RedHat,AIX,还有一些定制裁减系统等,百花齐放。
虽然OpenResty具有良好的跨平台特性,无奈我们的安全项目比较重,组件比较多,是不可能逐一适配不同平台的,工作量、稳定性等,难度和后期维护复杂度是难以想象的。如果您的应用和我们一样需要二次分发,非常建议考虑使用docker。这个年代是云的时代,二次分发其实成本很高,后期维护成本也很高,所以尽量做到云端。
说说Docker与OpenResty之间的"坑"吧,你们肯定对这个更感兴趣。
我们刚开始使用的时候,是这样启动的:
~~~
docker run -d -p 80:80 openresty
~~~
首次压测过程中发现Docker进程CPU占用率100%,单机接口4-5万的QPS就上不去了。经过我们多方探讨交流,终于明白原来是网络瓶颈所致(OpenResty太彪悍,Docker默认的虚拟网卡受不了了 ^_^)。
最终我们绕过这个默认的桥接网卡,使用`--net`参数即可完成。
~~~
docker run -d -p --net=host 80:80 openresty
~~~
多么简单,就这么一个参数,居然困扰了我们好几天。一度怀疑我们是否要忍受引入docker带来的低效率网络。所以有时候多出来交流、学习,真的可以让我们学到好多。虽然这个点是我们自己挖出来的,但是在交流过程中还学到了很多好东西。
> Docker Network settings,引自:[http://www.lupaworld.com/article-250439-1.html](http://www.lupaworld.com/article-250439-1.html)
~~~
默认情况下,所有的容器都开启了网络接口,同时可以接受任何外部的数据请求。
--dns=[] : Set custom dns servers for the container
--net="bridge" : Set the Network mode for the container
'bridge': creates a new network stack for the container on the docker bridge
'none': no networking for this container
'container:<name|id>': reuses another container network stack
'host': use the host network stack inside the container
--add-host="" : Add a line to /etc/hosts (host:IP)
--mac-address="" : Sets the container's Ethernet device's MAC address
~~~
你可以通过`docker run --net none`来关闭网络接口,此时将关闭所有网络数据的输入输出,你只能通过STDIN、STDOUT或者files来完成I/O操作。默认情况下,容器使用主机的DNS设置,你也可以通过`--dns`来覆盖容器内的DNS设置。同时Docker为容器默认生成一个MAC地址,你可以通过`--mac-address 12:34:56:78:9a:bc`来设置你自己的MAC地址。
Docker支持的网络模式有:
- none。关闭容器内的网络连接
- bridge。通过veth接口来连接容器,默认配置。
- host。允许容器使用host的网络堆栈信息。 注意:这种方式将允许容器访问host中类似D-BUS之类的系统服务,所以认为是不安全的。
- container。使用另外一个容器的网络堆栈信息。
#### None模式
将网络模式设置为none时,这个容器将不允许访问任何外部router。这个容器内部只会有一个loopback接口,而且不存在任何可以访问外部网络的router。
#### Bridge模式
Docker默认会将容器设置为bridge模式。此时在主机上面将会存在一个docker0的网络接口,同时会针对容器创建一对veth接口。其中一个veth接口是在主机充当网卡桥接作用,另外一个veth接口存在于容器的命名空间中,并且指向容器的loopback。Docker会自动给这个容器分配一个IP,并且将容器内的数据通过桥接转发到外部。
#### Host模式
当网络模式设置为host时,这个容器将完全共享host的网络堆栈。host所有的网络接口将完全对容器开放。容器的主机名也会存在于主机的hostname中。这时,容器所有对外暴露的端口和对其它容器的连接,将完全失效。
#### Container模式
当网络模式设置为Container时,这个容器将完全复用另外一个容器的网络堆栈。同时使用时这个容器的名称必须要符合下面的格式:--net container:.
比如当前有一个绑定了本地地址localhost的Redis容器。如果另外一个容器需要复用这个网络堆栈,则需要如下操作:
~~~
$ sudo docker run -d --name redis example/redis --bind 127.0.0.1
$ # use the redis container's network stack to access localhost
$ sudo docker run --rm -ti --net container:redis example/redis-cli -h 127.0.0.1
~~~
TIME_WAIT问题
最后更新于:2022-04-01 02:18:34
# TIME_WAIT
这个是高并发服务端常见的一个问题,一般的做法是修改sysctl的参数来解决。但是,做为一个有追求的程序猿,你需要多问几个为什么,为什么会出现TIME_WAIT?出现这个合理吗?
我们需要先回顾下tcp的知识,请看下面的状态转换图(图片来自[「The TCP/IP Guide」](http://www.tcpipguide.com/)):![tcp](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-11_55c9ff575c3dd.png)
因为TCP连接是双向的,所以在关闭连接的时候,两个方向各自都需要关闭。先发FIN包的一方执行的是主动关闭;后发FIN包的一方执行的是被动关闭。**_主动关闭的一方会进入TIME_WAIT状态,并且在此状态停留两倍的MSL时长。_**
修改sysctl的参数,只是控制TIME_WAIT的数量。你需要很明确的知道,在你的应用场景里面,你预期是服务端还是客户端来主动关闭连接的。一般来说,都是客户端来主动关闭的。
nginx在某些情况下,会主动关闭客户端的请求,这个时候,返回值的connection为close。我们看两个例子:
-
#### http 1.0协议
请求包:
~~~
GET /hello HTTP/1.0
User-Agent: curl/7.37.1
Host: 127.0.0.1
Accept: */*
Accept-Encoding: deflate, gzip
~~~
应答包:
~~~
HTTP/1.1 200 OK
Date: Wed, 08 Jul 2015 02:53:54 GMT
Content-Type: text/plain
Connection: close
Server: 360 web server
hello world
~~~
对于http 1.0协议,如果请求头里面没有包含connection,那么应答默认是返回Connection: close,也就是说nginx会主动关闭连接。
-
#### user agent
请求包:
~~~
POST /api/heartbeat.json HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Cache-Control: no-cache
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT)
Accept-Encoding: gzip, deflate
Accept: */*
Connection: Keep-Alive
Content-Length: 0
~~~
应答包:
~~~
HTTP/1.1 200 OK
Date: Mon, 06 Jul 2015 09:35:34 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: close
Server: 360 web server
Content-Encoding: gzip
~~~
这个请求包是http1.1的协议,也声明了Connection: Keep-Alive,为什么还会被nginx主动关闭呢?**_问题出在User-Agent_**,nginx认为终端的浏览器版本太低,不支持keep alive,所以直接close了。
在我们应用的场景下,终端不是通过浏览器而是后台请求的,而我们也没法控制终端的User-Agent,那有什么方法不让nginx主动去关闭连接呢?可以用[keepalive_disable](http://nginx.org/en/docs/http/ngx_http_core_module.html#keepalive_disable)这个参数来解决。这个参数并不是字面的意思,用来关闭keepalive,而是用来定义哪些古代的浏览器不支持keepalive的,默认值是MSIE6。
~~~
keepalive_disable none;
~~~
修改为none,就是认为不再通过User-Agent中的浏览器信息,来决定是否keepalive。
注:本文内容参考了[火丁笔记](http://huoding.com/2013/12/31/316)和[Nginx开发从入门到精通](http://tengine.taobao.org/book/chapter_02.html),感谢大牛的分享。
c10k编程
最后更新于:2022-04-01 02:18:32
# c10k编程
比较传统的服务端程序(PHP、FAST CGI等),大多都是通过每产生一个请求,都会有一个进程与之相对应,请求处理完毕后相关进程自动释放。由于进程创建、销毁对资源占用比较高,所以很多语言都通过常驻进程、线程等方式降低资源开销。即使是资源占用最小的线程,当并发数量超过1k的时候,操作系统的处理能力就开始出现明显下降,因为有太多的CPU时间都消耗在系统上下文切换。
由此催生了c10k编程,指的是服务器同时支持成千上万个连接,也就是concurrent 10 000 connection(这也是c10k这个名字的由来)。由于硬件成本的大幅度降低和硬件技术的进步,加上一台服务器同时能够服务更多的客户端,就意味着服务每一个客户端的成本大幅度降低,从这个角度来看,c10k问题显得非常有意义。
理想情况下,具备c10k能力的服务端处理能力是c1k的十倍,返回来说我们可以减少90%的服务器资源,多么诱人的结果。
c10k解决了这几个主要问题:
- 单个进程或线程可以服务于多个客户端请求
- 事件触发替代业务轮询
- IO采用非阻塞方式,减少额外不必要性能损耗
c10k编程的世界,一定是异步编程的世界,他俩绝对是一对儿好基友。服务端一直都不缺乏新秀,各种语言、框架层出不穷。笔者了解的就有OpenResty,Golang,Node.js,Rust,Python(gevent)等。每个语言或解决方案,都有自己完全不同的定位和表现,甚至设计哲学。但是他们从系统底层API应用、基本结构,都是相差不大。这些语言自身的实现机理、运行方式可能差别很大,但只要没有严重的代码错误,他们的性能指标都应该是在同一个级别的。
如果你用了这些解决方案,发现自己的性能非常低,就要好好看看自己是不是姿势有问题。
> c1k --> c10k --> c100k --> ???
人类前进的步伐,没有尽头的,总是在不停的往前跑。c10k的问题,早就被解决,而且方法还不止一个。目前方案优化手段给力,做到c100k也是可以达到的。后面还有世界么?我们还能走么?
告诉你肯定是有的,那就是c10m。推荐大家了解一下[dpdk](http://www.dpdk.eu)这个项目,并搜索一些相关领域的知识。要做到c10m,可以说系统网络内核、内存管理,都成为瓶颈了。所以要揭竿起义,统统推到重来。直接操作网卡绕过内核对网络的封装,直接使用用户态内存,再次绕过系统内核。
c10m这个动作比较大,而且还需要特定的硬件型号支持(主要是网卡,网络处理嘛),所以目前这个项目进展还比较缓慢。不过对于有追求的人,可能就要两眼放光了。
前些日子dpdk组织国内CDN厂商开了一个小会,阿里的朋友说已经用这个开发出了c10m级别的产品。小伙伴们,你们怎么看?心动了,行动不?
连接池
最后更新于:2022-04-01 02:18:30
# 连接池
作为一个专业的服务端开发工程师,我们必须要对连接池、线程池、内存池等有较深理解,并且有自己熟悉的库函数可以让我们轻松驾驭这些不同的`池子`。既然他们都叫某某池,那么他们从基础概念上讲,原理和目的几乎是一样的,那就是`复用`。
以连接池做引子,我们说说服务端工程师基础必修课。
从我们应用最多的HTTP连接、数据库连接、消息推送、日志存储等,所有点到点之间,都需要花样繁多的各色连接。为了传输数据,我们需要完成创建连接、收发数据、拆除连接。对并发量不高的场景,我们为每个请求都完整走这三步(短连接),开发工作基本只考虑业务即可,基本上也不会有什么问题。一旦挪到高并发应用场景,那么可能我们就要郁闷了。
你将会碰到下面几个常见问题:
- 性能普遍上不去
- CPU大量资源被系统消耗
- 网络一旦抖动,会有大量TIME_WAIT产生,不得不定期重启服务或定期重启机器
- 服务器工作不稳定,QPS忽高忽低
这时候我们可以优化的第一件事情就是把短链接改成长连接。也就是改成创建连接、收发数据、收发数据...拆除连接,这样我们就可以减少大量创建连接、拆除连接的时间。从性能上来说肯定要比短链接好很多。但这里还是有比较大的浪费。
举例:请求进入时,直接分配数据库长连接资源,假设有80%时间在与关系型数据库通讯,20%时间是在与Nosql数据库通讯。当有50K个并行请求时,后端要分配50K*2=100K的长连接支撑请求。无疑这时候系统压力是非常大的。数据库在牛逼也抵不住滥用不是?
连接池终于要出厂了,它的解决思路是先把所有长连接存起来,谁需要使用,从这里取走,干完活立马放回来。那么按照这个思路,刚刚的50K的并发请求,最多占用后端50K的长连接就够了。省了一半啊有木有?
在OpenResty中,所有具备set_keepalive的类、库函数,说明他都是支持连接池的。
来点代码,给大家提提神,看看连接池使用时的一些注意点,麻雀虽小,五脏俱全。
~~~
server {
location /test {
content_by_lua '
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("failed to connect: ", err)
return
end
-- red:set_keepalive(10000, 100) -- 坑①
ok, err = red:set("dog", "an animal")
if not ok then
-- red:set_keepalive(10000, 100) -- 坑②
return
end
-- 坑③
red:set_keepalive(10000, 100)
';
}
}
~~~
- 坑①:只有数据传输完毕了,才能放到池子里,系统无法帮你自动做这个事情
- 坑②:不能把状态位置的连接放回池子里,你不知道这个连接后面会触发什么错误
- 坑③:逗你玩,这个不是坑,是正确的
尤其是掉进了第二个坑,你一定会莫名抓狂。不信的话,你就自己模拟试试,老带劲了。
理解了连接池,那么线程池、内存池,就应该都明白了,只是存放的东西不一样,思想没有任何区别。
代码规范
最后更新于:2022-04-01 02:18:27
# 代码规范
其实选择`OpenResty`的同学,应该都是对执行性能、开发效率比较在乎的,而对于代码风格、规范等这些`小事`不太在意。作为一个从Linux C/C++转过来的研发,脚本语言的开发速度,接近C/C++的执行速度,在我轻视了代码规范后,一个BUG的发生告诉我,没规矩不成方圆。
既然我们玩的是`OpenResty`,那么很自然的联想到,`OpenResty`自身组件代码风格是怎样的呢?
> lua-resty-string 的 string.lua
~~~
local ffi = require "ffi"
local ffi_new = ffi.new
local ffi_str = ffi.string
local C = ffi.C
local setmetatable = setmetatable
local error = error
local tonumber = tonumber
local _M = { _VERSION = '0.09' }
ffi.cdef[[
typedef unsigned char u_char;
u_char * ngx_hex_dump(u_char *dst, const u_char *src, size_t len);
intptr_t ngx_atoi(const unsigned char *line, size_t n);
]]
local str_type = ffi.typeof("uint8_t[?]")
function _M.to_hex(s)
local len = #s * 2
local buf = ffi_new(str_type, len)
C.ngx_hex_dump(buf, s, #s)
return ffi_str(buf, len)
end
function _M.atoi(s)
return tonumber(C.ngx_atoi(s, #s))
end
return _M
~~~
代码虽短,但我们可以从中获取很多信息:
1. 没有全局变量,所有的变量均使用`local`限制作用域
1. 提取公共函数到本地变量,使用本地变量缓存函数指针,加速下次使用
1. 函数名称全部小写,使用下划线进行分割
1. 两个函数之间距离两个空行
这里的第2条,是有争议的。当你按照这个方式写业务的时候,会有些痛苦。因为我们总是把标准API命名成自己的别名,不同开发协作人员,命名结果一定不一样,最后导致同一个标准API在不同地方变成不同别名,会给开发造成极大困惑。
因为这个可预期的麻烦,我们没有遵循第2条标准,尤其是具体业务上游模块。但对于被调用的次数比较多基础模块,可以使用这个方式进行调优。其实这里最好最完美的方法,应该是Lua编译成Luac的时候,直接做Lua Byte Code的调优,直接隐藏这个简单的处理逻辑。
有关更多代码细节,其实我觉得主要还是多看写的漂亮的代码,一旦看他们看的顺眼、形成习惯,那么就很自然能写出风格一致的代码。规定的条条框框死记硬背总是很不爽的,所以多去看看春哥开源的`resty`系列代码,顺手品一品一下不同组件的玩法也别有一番心得。
说说我上面提及的因为风格问题造出来的坑吧。
~~~
local
function test()
-- do something
end
function test2()
-- do something
end
~~~
这是我当时不记得从哪里看到的一个`Lua`风格,在被引入项目初期,自我感觉良好。可突然从某个时间点开始,新合并进来的代码无法正常工作。查看最后的代码发现原来是`test()`函数作废,被删掉,手抖没有把上面的`local`也删掉。这个隐形的`local`就作用到了下一个函数,最终导致异常。
协议无痛升级
最后更新于:2022-04-01 02:18:25
# 协议无痛升级
使用度最高的通讯协议,一定是`HTTP`了。优点有多少,相信大家肯定有切身体会。我相信每家公司对`HTTP`的使用都有自己的规则,甚至偏好。这东西没有谁对谁错,符合业务需求、量体裁衣是王道。这里我们想通过亲身体会,告诉大家利用好`OpenResty`的一些特性,会给我们带来惊喜。
在产品初期,由于产品初期存在极大不确定性、不稳定性,所以要暴露给开发团队、测试团队完全透明的传输协议,所以我们1.0版本就是一个没有任何处理的明文版本`HTTP+JSON`。但随着产品功能的丰富,质量的逐步提高,具备一定的交付能力,这时候通讯协议必须要升级了。
为了更好的安全、效率控制,我们需要支持压缩、防篡改、防重复、简单加密等特性,为此我们设计了全新2.0通讯协议。如何让这个协议升级无感知、改动少,并且简单呢?
> 1.0明文协议配置
~~~
location ~ ^/api/([-_a-zA-Z0-9/]+).json {
content_by_lua_file /path/to/lua/api/$1.lua;
}
~~~
> 1.0明文协议引用示例:
~~~
# curl http://ip:port/api/hearbeat.json?key=value -d '...'
~~~
> 2.0密文协议引用示例:
~~~
# curl http://ip:port/api/hearbeat.json?key=value&ver=2.0 -d '...'
~~~
从引用示例中看到,我们的密文协议主要都是在请求`body`中做的处理。最生硬的办法就是我们在每个业务入口、出口分别做协议的解析、编码处理。如果你只有几个API接口,那么直来直去的修改所有API接口源码,最为直接,容易理解。但如果你需要修改几十个API入口,那就要静下来考虑一下,修改的代价是否完全可控。
最后我们使用了`OpenResty`阶段的概念完成协议的转换。
~~~
location ~ ^/api/([-_a-zA-Z0-9/]+).json {
access_by_lua_file /path/to/lua/api/protocal_decode.lua;
content_by_lua_file /path/to/lua/api/$1.lua;
body_filter_by_lua_file /path/to/lua/api/protocal_encode.lua;
}
~~~
> 内部处理流程说明
- `Nginx`中这三个阶段的执行顺序:access --> content --> body_filter;
- access_by_lua_file:获取协议版本 --> 获取body数据 --> 协议解码 --> 设置body数据;
- content_by_lua_file:正常业务逻辑处理,零修改;
- body_filter_by_lua_file:判断协议版本 --> 协议编码。
刚好前些日子春哥公开了一篇`Github`中引入了`OpenResty`解决SSL证书的问题,他们的解决思路和我们差不多。都是利用access阶段做一些非标准HTTP(S)上的自定义修改,但对于已有业务是不需要任何感知的。
我们这个通讯协议的无痛升级,实际上是有很多玩法可以实现,如果我们的业务从一开始有个相对稳定的框架,可能完全不需要操这个心。没有引入框架,一来是现在没有哪个框架比较成熟,而来是从基础开始更容易摸到细节。对于目前`OpenResty`可参考资料少的情况下,我们更倾向于从最小工作集开始,减少不确定性、复杂度。
也许在后面,我们会推出我们的开发框架,用来统一规避现在碰到的问题,提供完整、可靠、高效的解决方法,我们正在努力ing,请大家拭目以待。
数据合法性检测
最后更新于:2022-04-01 02:18:22
# 数据合法性检测
对用户输入的数据进行合法性检查,避免错误非法的数据进入服务,这是业务系统最常见的需求。很可惜Lua目前没有特别好的数据合法性检查库。
坦诚我们自己做的也不够好,这里只能抛砖引玉,看看大家是否有更好的办法。
我们有这么几个主要的合法性检查场景:
- JSON数据格式
- 关键字段编码为HEX,长度不定
- TABLE内部字段类型
#### JSON数据格式
这里主要是`JSON DECODE`时,可能存在Crash的问题。我们已经在[json解析的异常捕获](#)一章中详细说明了问题本身,以及解决方法。这里就不再重复。
#### 关键字段编码为HEX,长度不定
todo list:
- 到公司补充一下,需要公共模块代码
#### TABLE内部字段类型
todo list:
- 到公司补充一下,需要公共模块代码
API的设计
最后更新于:2022-04-01 02:18:20
# API的设计
OpenResty,最擅长的应用场景之一就是API Server。如果我们只有简单的几个API出口、入口,那么我们可以相对随意简单一些。
> 举例几个简单API接口输出:
~~~
server {
listen 80;
server_name localhost;
location /app/set {
content_by_lua "ngx.say('set data')";
}
location /app/get {
content_by_lua "ngx.say('get data')";
}
location /app/del {
content_by_lua "ngx.say('del data')";
}
}
~~~
当你的API Server接口服务比较多,那么上面的方法显然不适合我们(太啰嗦)。这里推荐一下REST风格。
#### 什么是REST
从资源的角度来观察整个网络,分布在各处的资源由URI确定,而客户端的应用通过URI来获取资源的表示方式。获得这些表徵致使这些应用程序转变了其状态。随着不断获取资源的表示方式,客户端应用不断地在转变着其状态,所谓表述性状态转移(Representational State Transfer)。
这一观点不是凭空臆造的,而是通过观察当前Web互联网的运作方式而抽象出来的。Roy Fielding 认为,
~~~
设计良好的网络应用表现为一系列的网页,这些网页可以看作的虚拟的状态机,用户选择这些链接
导致下一网页传输到用户端展现给使用的人,而这正代表了状态的转变。
~~~
> REST是设计风格而不是标准。
REST通常基于使用HTTP,URI,和XML以及HTML这些现有的广泛流行的协议和标准。
- 资源是由URI来指定。
- 对资源的操作包括获取、创建、修改和删除资源,这些操作正好对应HTTP协议提供的GET、POST、PUT和DELETE方法。
- 通过操作资源的表现形式来操作资源。
- 资源的表现形式则是XML或者HTML,取决于读者是机器还是人,是消费web服务的客户软件还是web浏览器。当然也可以是任何其他的格式。
> REST的要求
- 客户端和服务器结构
- 连接协议具有无状态性
- 能够利用Cache机制增进性能
- 层次化的系统
#### REST使用举例
按照REST的风格引导,我们有关数据的API Server就可以变成这样。
~~~
server {
listen 80;
server_name localhost;
location /app/task01 {
content_by_lua "ngx.say(ngx.req.get_method() .. ' task01')";
}
location /app/task02 {
content_by_lua "ngx.say(ngx.req.get_method() .. ' task02')";
}
location /app/task03 {
content_by_lua "ngx.say(ngx.req.get_method() .. ' task03')";
}
}
~~~
对于`/app/task01`接口,这时候我们可以用下面的方法,完成对应的方法调用。
~~~
# curl -X GET http://127.0.0.1/app/task01
# curl -X PUT http://127.0.0.1/app/task01
# curl -X DELETE http://127.0.0.1/app/task01
~~~
#### 还有办法压缩不?
上一个章节,如果task类型非常多,那么后面这个配置依然会随着业务调整而调整。其实每个程序员都有一定的洁癖,是否可以以后直接写业务,而不用每次都修改主配置,万一改错了,服务就起不来了。
引用一下HttpLuaModule官方示例代码。
~~~
# use nginx var in code path
# WARNING: contents in nginx var must be carefully filtered,
# otherwise there'll be great security risk!
location ~ ^/app/([-_a-zA-Z0-9/]+) {
set $path $1;
content_by_lua_file /path/to/lua/app/root/$path.lua;
}
~~~
这下世界宁静了,每天写Lua代码的同学,再也不用去每次修改Nginx主配置了。有新业务,直接开工。顺路还强制了入口文件命名规则。对于后期检查维护更容易。
#### REST风格的缺点
需要一定的学习成本,如果你的接口是暴露给运维、售后、测试等不同团队,那么他们经常不去确定当时的`method`。当他们查看、模拟的时候,具有一定学习难度。
REST 推崇使用 HTTP 返回码来区分返回结果, 但最大的问题在于 HTTP 的错误返回码 (4xx 系列为主) 不够多,而且订得很随意。比如用 API 创建一个用户,那么错误可能有:
- 调用格式错误(一般返回 400,405)
- 授权错误(一般返回 403)
- "运行期"错误
- 用户名冲突
- 用户名不合法
- email 冲突
- email 不合法
web服务
最后更新于:2022-04-01 02:18:18
# web服务
灰度发布
最后更新于:2022-04-01 02:18:16
# 灰度发布
我们做的还不够好,先占个坑。
欢迎贡献章节。
持续集成
最后更新于:2022-04-01 02:18:13
# 持续集成
我们做的还不够好,先占个坑。
欢迎贡献章节。
性能测试
最后更新于:2022-04-01 02:18:11
# 性能测试
性能测试应该有两个方向:
- 单接口压力测试
- 生产环境模拟用户操作高压力测试
生产环境模拟测试,目前我们都是交给公司的QA团队专门完成的。这块我只能粗略列举一下:
- 获取1000用户以上生产用户的访问日志(统计学要求1000是最小集合)
- 计算指定时间内(例如10分钟),所有接口的触发频率
- 使用测试工具(loadrunner, jmeter等)模拟用户请求接口
- 适当放大压力,就可以模拟2000、5000等用户数的情况
#### ab 压测
单接口压力测试,我们都是由研发团队自己完成的。传统一点的方法,我们可以使用ab(apache bench)这样的工具。
~~~
#ab -n10 -c2 http://haosou.com/
-- output:
...
Complete requests: 10
Failed requests: 0
Non-2xx responses: 10
Total transferred: 3620 bytes
HTML transferred: 1780 bytes
Requests per second: 22.00 [#/sec] (mean)
Time per request: 90.923 [ms] (mean)
Time per request: 45.461 [ms] (mean, across all concurrent requests)
Transfer rate: 7.78 [Kbytes/sec] received
...
~~~
大家可以看到ab的使用超级简单,简单的有点弱了。在上面的例子中,我们发起了10个请求,每个请求都是一样的,如果每个请求有差异,ab就无能为力。
#### wrk 压测
单接口压力测试,为了满足每个请求或部分请求有差异,我们试用过很多不同的工具。最后找到了这个和我们距离最近、表现优异的测试工具[wrk](https://github.com/wg/wrk),这里我们重点介绍一下。
wrk如果要完成和ab一样的压力测试,区别不大,只是命令行参数略有调整。下面给大家举例每个请求都有差异的例子,供大家参考。
> scripts/counter.lua
~~~
-- example dynamic request script which demonstrates changing
-- the request path and a header for each request
-------------------------------------------------------------
-- NOTE: each wrk thread has an independent Lua scripting
-- context and thus there will be one counter per thread
counter = 0
request = function()
path = "/" .. counter
wrk.headers["X-Counter"] = counter
counter = counter + 1
return wrk.format(nil, path)
end
~~~
> shell执行
~~~
# ./wrk -c10 -d1 -s scripts/counter.lua http://baidu.com
Running 1s test @ http://baidu.com
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 20.44ms 3.74ms 34.87ms 77.48%
Req/Sec 226.05 42.13 270.00 70.00%
453 requests in 1.01s, 200.17KB read
Socket errors: connect 0, read 9, write 0, timeout 0
Requests/sec: 449.85
Transfer/sec: 198.78KB
~~~
> WireShark抓包印证一下
~~~
GET /228 HTTP/1.1
Host: baidu.com
X-Counter: 228
...(应答包 省略)
GET /232 HTTP/1.1
Host: baidu.com
X-Counter: 232
...(应答包 省略)
~~~
wrk是个非常成功的作品,它的实现更是从多个开源作品中挖掘牛X东西融入自身,如果你每天还在用C/C++,那么wrk的成功,对你应该有绝对的借鉴意义,多抬头,多看牛X代码,我们绝对可以创造奇迹。
引用[wrk](https://github.com/wg/wrk)官方结尾:
~~~
wrk contains code from a number of open source projects including the 'ae'
event loop from redis, the nginx/joyent/node.js 'http-parser', and Mike
Pall's LuaJIT.
~~~
API测试
最后更新于:2022-04-01 02:18:09
# API测试
API(Application Programming Interface)测试的自动化是软件测试最基本的一种类型。从本质上来说,API测试是用来验证组成软件的那些单个方法的正确性,而不是测试整个系统本身。API测试也称为单元测试(Unit Testing)、模块测试(Module Testing)、组件测试(Component Testing)以及元件测试(Element Testing)。从技术上来说,这些术语是有很大的差别的,但是在日常应用中,你可以认为它们大致相同的意思。它们背后的思想就是,必须确定系统中每个单独的模块工作正常,否则,这个系统作为一个整体不可能是正确的。毫无疑问,API测试对于任何重要的软件系统来说都是必不可少的。
我们对API测试的定位是服务对外输出的API接口测试,属于黑盒、偏重业务的测试步骤。
看过上一章内容的朋友还记得[lua-resty-test](https://github.com/membphis/lua-resty-test),我们的API测试同样是需要它来完成。get_client_tasks是终端用来获取当前可执行任务清单的API,我们用它当做例子给大家做个介绍。
> nginx conf:
~~~
location ~* /api/([\w_]+?)\.json {
content_by_lua_file lua/$1.lua;
}
location ~* /unit_test/([\w_]+?)\.json {
lua_check_client_abort on;
content_by_lua_file test_case_lua/unit/$1.lua;
}
~~~
> API测试代码:
~~~
-- unit test for /api/get_client_tasks.json
local tb = require "resty.iresty_test"
local json = require("cjson")
local test = tb.new({unit_name="get_client_tasks"})
function tb:init( )
self.mid = string.rep('0',32)
end
function tb:test_0000()
-- 正常请求
local res = ngx.location.capture(
'/api/get_client_tasks.json?mid='..self.mid,
{ method = ngx.HTTP_POST, body=[[{"type":[1600,1700]}]] }
)
if 200 ~= res.status then
error("failed code:" .. res.status)
end
end
function tb:test_0001()
-- 缺少body
local res = ngx.location.capture(
'/api/get_client_tasks.json?mid='..self.mid,
{ method = ngx.HTTP_POST }
)
if 400 ~= res.status then
error("failed code:" .. res.status)
end
end
function tb:test_0002()
-- 错误的json内容
local res = ngx.location.capture(
'/api/get_client_tasks.json?mid='..self.mid,
{ method = ngx.HTTP_POST, body=[[{"type":"[1600,1700]}]] }
)
if 400 ~= res.status then
error("failed code:" .. res.status)
end
end
function tb:test_0003()
-- 错误的json格式
local res = ngx.location.capture(
'/api/get_client_tasks.json?mid='..self.mid,
{ method = ngx.HTTP_POST, body=[[{"type":"[1600,1700]"}]] }
)
if 400 ~= res.status then
error("failed code:" .. res.status)
end
end
test:run()
~~~
nginx output:
~~~
0.000 [get_client_tasks] unit test start
0.001 \_[test_0000] PASS
0.001 \_[test_0001] PASS
0.001 \_[test_0002] PASS
0.001 \_[test_0003] PASS
0.001 [get_client_tasks] unit test complete
~~~
使用capture来模拟请求,其实是不靠谱的。如果我们要完全100%模拟客户请求,这时候就要使用第三方cosocket库,例如[lua-resty-http](https://github.com/pintsized/lua-resty-http),这样我们才可以完全指定http参数。
单元测试
最后更新于:2022-04-01 02:18:06
# 单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
单元测试的书写、验证,互联网公司几乎都是研发自己完成的,我们要保证代码出手时可交付、符合预期。如果连自己的预期都没达到,后面所有的工作,都将是额外无用功。
Lua中我们没有找到比较好的测试库,参考了Golang、Python等语言的单元测试书写方法以及调用规则,我们编写了[lua-resty-test](https://github.com/membphis/lua-resty-test)测试库,这里给自己的库推广一下,希望这东东也是你们的真爱。
> nginx示例配置
~~~
#you do not need the following line if you are using
#the ngx_openresty bundle:
lua_package_path "/path/to/lua-resty-redis/lib/?.lua;;";
server {
location /test {
content_by_lua_file test_case_lua/unit/test_example.lua;
}
}
~~~
> test_case_lua/unit/test_example.lua:
~~~
local tb = require "resty.iresty_test"
local test = tb.new({unit_name="bench_example"})
function tb:init( )
self:log("init complete")
end
function tb:test_00001( )
error("invalid input")
end
function tb:atest_00002()
self:log("never be called")
end
function tb:test_00003( )
self:log("ok")
end
-- units test
test:run()
-- bench test(total_count, micro_count, parallels)
test:bench_run(100000, 25, 20)
~~~
- init里面我们可以完成一些基础、公共变量的初始化,例如特定的url等
- test_*****函数中添加我们的单元测试代码
- 搞定测试代码,它即是单元测试,也是成压力测试
> 输出日志:
~~~
TIME Name Log
0.000 [bench_example] unit test start
0.000 [bench_example] init complete
0.000 \_[test_00001] fail ...de/nginx/test_case_lua/unit/test_example.lua:9: invalid input
0.000 \_[test_00003] ↓ ok
0.000 \_[test_00003] PASS
0.000 [bench_example] unit test complete
0.000 [bench_example] !!!BENCH TEST START!!
0.484 [bench_example] succ count: 100001 QPS: 206613.65
0.484 [bench_example] fail count: 100001 QPS: 206613.65
0.484 [bench_example] loop count: 100000 QPS: 206611.58
0.484 [bench_example] !!!BENCH TEST ALL DONE!!!
~~~
埋个伏笔:在压力测试例子中,测试到的QPS大约21万的,这是我本机一台Mac Mini压测的结果。构架好,姿势正确,我们可以很轻松做出好产品。
后面会详细说一下用这个工具进行压力测试的独到魅力,做出一个NB的网络处理应用,这个测试库应该是你的利器。
测试
最后更新于:2022-04-01 02:18:04
# 测试