PHP和Python部署方式与性能对比

Published On July 12, 2017

category server


PHP和Python是两种最主要的web编程语言,它们都很古老(PHP:1995,python:1991)。PHP为web而诞生,技术成熟稳定,但这位曾经的web之王的市场正在被python,nodejs,ruby等一点点蚕食。Python则比较万能,社区百花齐放,web开发是其一项重要的功能。虽然都是服务端脚本语言,但他们在运行方式上有很大的差别。本文介绍PHP和python web最流行的部署方式并对比它们的性能。

PHP

PHP脚本需要解释器解释执行,SAPI(Server API)是调用php解释器的统一接口,无论是在CLI执行php脚本还是服务器端运行php脚本,都是通过SAPI启动php解释器。服务端执行php最流行的方式是通过nginx+php-fpm的模式,php-fpm是php自带的用c写的一个fast-cgi的实现。对于php-fpm,它的生命周期是这样的:

while(accept(request)) {
    // ...
    php_execute_script(script)
    // ...
}
作为一种fast-cgi,php-fpm包括master进程和子进程,子进程负责处理请求,每个子进程是常驻内存的,也就是php的解释器只需要启动一次,同时也让mysql持久连接成为可能。

php解释器的核心就是zend引擎,启动zend引擎前后需要做大量的初始化和收尾工作,在上面略过了。php_execute_script函数会调用zend_compile_file将php脚本编译成opcode,然后调用zend_execute解释执行,这也是为什么php web支持热升级,即更新php脚本后立即生效。但是,每个请求都需要编译一遍php脚本的效率低,于是有了opcache。

nginx作为HTTP服务器,解析HTTP请求,转换成fcgi协议(通过环境变量传递请求的参数),并通过fcgi-pass转发给php-fpm,php-fpm默认监听端口9000。

部署

php-fpm

通过包管理器(比如yum,apt-get)安装php后,php-fpm就可以直接用了。 在我系统(arch linux)上,php-fpm的配置文件位于/etc/php/php-fpm.conf,该文件中包含一些全局配置,比如error_logs设置错误日志的位置。

php-fpm支持多个进程池,每个进程池由一个master进程管理,不同进程池监听不同的端口,并且可以分别控制具体的行为。

/etc/php/php-fpm.d/www.conf是www进程池的配置,以下是一些比较关键的配置项。

listen = 127.0.0.1:9000
listen.backlog = 511

pm = dynamic
pm.max_children = 5
listen.backlog表示完成三次握手后等待accept的最大连接数(针对linux系统),代表能同时连接的用户数。 pm是fpm的关键,它控制子进程数,dynamic顾命思议就是根据当前请求压力动态调整,可以通过pm.max_children设置上限,这个值代表能同时处理的请求数,建议设为cpu核数。除了dynamic,还有static和ondemand。除此之外,还有很多选项可以精确控制子进程的行为。

php-fpm的功能很强大,设计上与nginx类似:

  1. 多进程,单线程模式
  2. 使用事件驱动接收请求,能根据当前平台自动在epoll,poll,select中选择最佳的机制
  3. 高度自定义access log

除此之外还包括其他一些优点:

  1. 显示进程池的实时状态(支持json)
  2. 输出慢日志
  3. 动态控制子进程数

说完了配置,该说怎么运行。直接在命令行执行php-fpm即可启动。如果安装包同时包含了php-fpm的init脚本,则可以通过/etc/init.d/php-fpm start启动,或者使用service包装器(centos):service php-fpm start

注意:无论是修改php的配置(php.ini)还是php-fpm自身的配置,都需要重启php-fpm。

nginx

最简单的方式同样是通过包管理器安装。

nginx的配置文件位于/etc/nginx/nginx.conf。 在server块里配置:

location / {
    root html;
    index  index.html index.htm;
}

location ~ \.php$ {
   root           html;
   fastcgi_pass   127.0.0.1:9000;
   fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
   include        fastcgi_params;
}
~ \.php$表示后缀为.php的请求都会路由到这个location,root即文档根目录,一般也是php文件所在的根目录,如果是相对路径,则表示相对于nginx的prefix。prefix默认值是nginx的安装目录,也可以在启动nginx的时候使用-p选项设置。 fastcgi_param设置了SCRIPT_FILENAME变量,它的值就是php脚本的绝对路径。比如,root目录位于/home/www/scripts/php/,访问127.0.0.1/a/b.php,则SCRIPT_FILENAME的值为/home/www/scripts/php/a/b.php。 fastcgi_params中通过fastcgi_param指令设置了fcgi协议规定的其他变量,比如REQUEST_METHOD。nginx通过环境变量的方式将这些参数传递给php-fpm。

在文档根目录下创建一个php文件:

$ cat html/index.php
<?php
header('Content-Length: 13');
echo "hello world!\n";

通过curl测试一下:

$ curl -v localhost/index.php
> GET /index.php HTTP/1.1
> Host: localhost
> User-Agent: curl/7.54.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.11.8
< Date: Tue, 11 Jul 2017 15:13:17 GMT
< Content-Type: text/html; charset=UTF-8
< Content-Length: 13
< Connection: keep-alive
< X-Powered-By: PHP/7.1.6
<
hello world!
当访问root目录下的静态文件(比如html/js/css),则直接由nginx服务,不经过php。

整个流程如下: php web

opcache

opcache是php内置的一个扩展,它在共享内中缓存预编译后的字节码,避免每个请求都加载并解析php脚本,从而提升php执行的性能。

配置

在php.ini中搜索opcache,取消zend_extension=opcache.so前面的注释开启opcache模块。 opcache包含很多项配置,下面是官网建议的配置:

opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.enable_cli=1
这些配置项的含义如下:

  • memory_consumption:使用128m的共享内存作为缓存空间
  • interned_strings_buffer:字符串占用的内存
  • max_accelerated_files:最多缓存4000个文件
  • revalidate_freq:每隔60s检查一次文件的修改时间,判断是否被修改,如果有更新,则缓存已失效,需要重新编译php文件
  • fast_shutdown:每次请求结束后不主动释放内存,由Zend Engine去释放
  • enable_cli:以cli方式运行的php脚本是否开启opcache

测试

先确认配置是否正确,在html目录下新建一个php文件:

$ cat html/info.php
<?php
echo phpinfo();
在浏览器访问127.0.0.1/info.php phpinfo

以下是使用ab(apache benchmark)测试的结果。

开启opcache之前

$ ./ab -n 100000 -c 10 -k http://127.0.0.1/index.php
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:        nginx/1.11.8
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /index.php
Document Length:        13 bytes

Concurrency Level:      10
Time taken for tests:   15.784 seconds
Complete requests:      100000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    99006
Total transferred:      19995030 bytes
HTML transferred:       1300000 bytes
Requests per second:    6335.61 [#/sec] (mean)
Time per request:       1.578 [ms] (mean)
Time per request:       0.158 [ms] (mean, across all concurrent requests)
Transfer rate:          1237.12 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       1
Processing:     0    2   0.5      2      17
Waiting:        0    2   0.5      2      17
Total:          0    2   0.5      2      17

Percentage of the requests served within a certain time (ms)
  50%      2
  66%      2
  75%      2
  80%      2
  90%      2
  95%      2
  98%      3
  99%      3
 100%     17 (longest request)

开启opcache之后

$ ./ab -n 100000 -c 10 -k http://127.0.0.1/index.php
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:        nginx/1.11.8
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /index.php
Document Length:        13 bytes

Concurrency Level:      10
Time taken for tests:   13.050 seconds
Complete requests:      100000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    99004
Total transferred:      19995020 bytes
HTML transferred:       1300000 bytes
Requests per second:    7662.56 [#/sec] (mean)
Time per request:       1.305 [ms] (mean)
Time per request:       0.131 [ms] (mean, across all concurrent requests)
Transfer rate:          1496.22 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       3
Processing:     0    1   0.5      1      23
Waiting:        0    1   0.5      1      23
Total:          0    1   0.5      1      23

Percentage of the requests served within a certain time (ms)
  50%      1
  66%      1
  75%      1
  80%      1
  90%      2
  95%      2
  98%      2
  99%      3
 100%     23 (longest request)
QPS提升了1300#/s,同时可以看到phpinfo页面显示的Cache hits由0变成了99993。 由此可见,即使一个只有两行的php脚本,opcache也能带来明显的性能提升(1300/6300x100%=20%)。

persistent connection

mysql持久连接是指一个请求结束后,不断开与mysql的连接,当下一个请求企图连接mysql的时候,如果已经有一个相同的连接存在则直接返回,这样就可以避免每个请求都建立一次连接的开销。这种特性具体是由mysql驱动支持的,但是要求php进程常驻内存,即fcgi是前提。

另外需要明确的是,php-fpm的不同子进程内的持久连接是独立的,如果php-fpm有100个子进程,则mysql需要维持100个连接。可以通过以下sql查看mysql打开的连接:

show status where `variable_name` = 'Threads_connected';

如果与mysql建立连接的开销比较大,比如mysql与web server不在同一台服务器,mysql并发数很高等,那么连接复用带来的性能提升很明显。 否则,持久连接的弊端可能会胜过它的优势。有以下几个原因:

  1. 相对oracle,mysql建立连接的速度很快
  2. 一个连接相当于一个session,它是由状态的,连接复用也就意味着这些状态被继承了,比如变量,临时表,事务
  3. 前一个请求如果没有释放锁,导致后面的请求死锁

mysqli扩展针对这些情况采取了一种弥补的措施,每次复用连接之前,执行mysql_change_user()函数清理状态,这也带来了一定的开销。 正因如此,社区甚至php官方文档并不推荐使用mysql持久连接

测试

实践出真知,亲自测试一下就知道了。 下面使用mysqli模块连接mysql,查询的sql语句很简单:select 1+1

非持久连接html/mysql1.php
<?php
$mysqli = new mysqli("127.0.0.1", "root", "", "test");
if ($mysqli->connect_errno) {
    echo "Failed to connect to MySQL: (" . $mysqli->connect_errno . ") " . $mysqli->connect_error;
}

if ($result=$mysqli->query("select 1+1")) {
    print_r($result);
    $result->close();
} else {
    echo "Qeury failed: (" . $mysqli->errno . ") " . $mysqli->error;
}
$mysqli->close();
持久连接html/mysql2.php

只需要在上面代码的基础上在connect方法的host参数前面添加p:即可。close函数并不会真正关闭连接。

<?php                                      
$mysqli = new mysqli("p:127.0.0.1", "root", "", "test");                              
// ...

在每次压测前后先记录一下mysql累计的连接次数:

show status where `variable_name` = 'Connections';

$ ./ab -n 10000 -c 10 -k http://127.0.0.1/mysql1.php
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests


Server Software:        nginx/1.11.8
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /mysql1.php
Document Length:        69 bytes

Concurrency Level:      10
Time taken for tests:   4.431 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    0
Total transferred:      2310000 bytes
HTML transferred:       690000 bytes
Requests per second:    2256.59 [#/sec] (mean)
Time per request:       4.431 [ms] (mean)
Time per request:       0.443 [ms] (mean, across all concurrent requests)
Transfer rate:          509.05 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       4
Processing:     2    4   1.7      4      21
Waiting:        1    4   1.7      4      21
Total:          2    4   1.7      4      21

Percentage of the requests served within a certain time (ms)
  50%      4
  66%      5
  75%      5
  80%      5
  90%      6
  95%      7
  98%      9
  99%     12
 100%     21 (longest request)
$ ./ab -n 10000 -c 10 -k http://127.0.0.1/mysql2.php
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests


Server Software:        nginx/1.11.8
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /mysql2.php
Document Length:        69 bytes

Concurrency Level:      10
Time taken for tests:   2.774 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    0
Total transferred:      2310000 bytes
HTML transferred:       690000 bytes
Requests per second:    3605.44 [#/sec] (mean)
Time per request:       2.774 [ms] (mean)
Time per request:       0.277 [ms] (mean, across all concurrent requests)
Transfer rate:          813.34 [Kbytes/sec] received


Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       2
Processing:     1    3   1.6      2      20
Waiting:        0    3   1.6      2      20
Total:          1    3   1.6      2      20

Percentage of the requests served within a certain time (ms)
  50%      2
  66%      3
  75%      3
  80%      3
  90%      4
  95%      5
  98%      8
  99%     11
 100%     20 (longest request)

第一次压测后连接数增加了1w,第二次压测后只增加了3,说明第二次确实使用了长连接。

从ab压测的结果看,QPS增加了1300+#/s,开启持久连接对性能提升了(1350/2256x100%=)60%。

注意:

  1. 生产环境的查询会比上面的简单查询复杂得多,建立连接的开销所占的比例要下降很多,实际性能的提升没有这里的明显。
  2. 上面两次压测均均开启了opcache

python

与php官方钦定了php-fpm不同的是,python的服务器就多到不知怎么选了,纯python写的就有gunicorn,gevent,cherypy。 下面以最受欢迎的gunicorn为例介绍python web的部署。

python web application一般都是通过wsgi(Web Server Gateway Interface)协议与server交互的。

wsgi

wsgi是python里针对web应用的一项标准,它将web app与http server解耦,因此只要符合wsgi协议,任何wsgi app和wsgi server都可以搭配使用。任何主流的python web框架都遵循wsgi协议,他们扮演wsgi app脚手架的角色。

一个wsgi app只需要实现一个函数(WSGI callable),比如: wsgi_callable.py

def simple_app(environ, start_response):
    """Simplest possible application object"""
    status = '200 OK'
    response_headers = [('Content-type', 'text/plain'), ('Content-Length', '13')]
    start_response(status, response_headers)
    return ['Hello world!\n']
environ是一个dict,里包含了请求的各种参数,比如environ['REQUEST_METHOD']表示请求的方法。

wsgi server负责解析http请求,并调用上述函数

部署

gunicorn

目前最受欢迎的wsgi http server是gunicorn,使用gunicorn运行python web很简单。

例如下面的命令会启动2个worker进程,监听本地的8000端口,并在后台运行。

gunicorn -w 2 -b 127.0.0.1:8000 -D wsgi_callable:simple_app

使用2个worker是因为我有两个cpu核心,在高负荷下能把两个cpu跑满,官方建议不要超过核心数的2倍+1。

$ grep processor /proc/cpuinfo 
processor       : 0
processor       : 1

ps -ef|grep gunicorn能看到三个进程,除了2个worker进程外,还包括master进程,负责监听端口和管理worker进程。

gunicorn本身也是一个python程序,所以它也是通过python解释器启动的,只不过它是以daemon的方式运行。 与php-fpm相同,worker进程同样通过prefork的方式创建,并且常驻内存。每一个请求,worker进程都会调用wsgi_callable模块的simple_app函数进行处理。由此可见,python脚本是在启动的时候加载到内存,不需要每次请求都解析一遍。所以,python web无法热升级,更新python脚本后,需要重启gunicorn才会生效,当然也就不需要缓存字节码。另一方面,可以直接在python层保持长链接,或者缓存任何python的变量。

使用curl发送一个请求:

curl -v localhost:8000
> GET / HTTP/1.1                           
> Host: localhost:8000                     
> User-Agent: curl/7.54.1                  
> Accept: */*                              
>                                          
< HTTP/1.1 200 OK                          
< Server: gunicorn/19.7.1                  
< Date: Tue, 11 Jul 2017 04:40:07 GMT      
< Connection: close                        
< Content-type: text/plain                 
< Content-Length: 13                       
<                                          
Hello world!

使用nginx代理

这一步是可选的,对于只提供api接口,不返回静态文件的服务端程序,当然就没有必要使用nginx做代理了。另一方面,nginx速度极快,即使用了对性能也不会有明显削减。

nginx在这里的作用仅仅是转发http请求,不进行解析,以下是一个最简单的配置(只截取了server块中关键的信息):

listen       80;
location / {
    proxy_pass         http://127.0.0.1:8000;
    proxy_set_header   Host                 $host;
    proxy_set_header   X-Real-IP            $remote_addr;
}

proxy_pass表示将所有匹配到这个location的请求转发到http://127.0.0.1:8000,比如访问http://127.0.0.1/hello/yxr,将被转发到http://127.0.0.1:8000/hello/yxr。 很多时候,还需要重写一些头,比如HOST和REMOTE_ADDR。Host是用户访问的主机名(或ip),REMOTE_ADDR是用户的ip。后面两条proxy_set_header指令,使得wsgi app可以通过HTTP_HOST和HTTP_X_REAL_IP两个环境变量获取真实的HOST和REMOTE_ADDR,否则这两个值始终分别是gunicorn和nginx监听的ip。

再次使用curl发送一个请求,

$ curl -v localhost                                   
> GET / HTTP/1.1                           
> Host: localhost                          
> User-Agent: curl/7.54.1                  
> Accept: */*                              
>                                          
< HTTP/1.1 200 OK                          
< Server: nginx/1.11.8                     
< Date: Tue, 11 Jul 2017 19:55:15 GMT      
< Content-Type: text/plain                 
< Content-Length: 13                       
< Connection: keep-alive                   
<                                          
Hello world!   
注意server响应头的变化。

整个流程如下: python web

性能测试

为了与php作对比,同样使用ab对gunicorn进行了压测。

./ab -n 100000 -c 10 -k http://127.0.0.1:8000/                                
This is ApacheBench, Version 2.3 <$Revision: 655654 $>                                
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/              
Licensed to The Apache Software Foundation, http://www.apache.org/                    

Benchmarking 127.0.0.1 (be patient)        
Completed 10000 requests                   
Completed 20000 requests                   
Completed 30000 requests                   
Completed 40000 requests                                                              
Completed 50000 requests                   
Completed 60000 requests                   
Completed 70000 requests                   
Completed 80000 requests                   
Completed 90000 requests                   
Completed 100000 requests                  
Finished 100000 requests                   


Server Software:        gunicorn/19.7.1    
Server Hostname:        127.0.0.1          
Server Port:            8000               

Document Path:          /                  
Document Length:        13 bytes           

Concurrency Level:      10                 
Time taken for tests:   21.009 seconds     
Complete requests:      100000             
Failed requests:        0
Write errors:           0
Keep-Alive requests:    0
Total transferred:      15900000 bytes
HTML transferred:       1300000 bytes
Requests per second:    4759.96 [#/sec] (mean)
Time per request:       2.101 [ms] (mean)
Time per request:       0.210 [ms] (mean, across all concurrent requests)             
Transfer rate:          739.09 [Kbytes/sec] received                                  

Connection Times (ms)                      
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       1
Processing:     0    2   0.7      2      25
Waiting:        0    2   0.7      2      25
Total:          1    2   0.7      2      25

Percentage of the requests served within a certain time (ms)                          
  50%      2                               
  66%      2                               
  75%      3                               
  80%      3                               
  90%      3                               
  95%      3                               
  98%      3                               
  99%      4                               
 100%     25 (longest request) 

php与python web的性能对比

上面分别对基于nginx+php-fpm的php web和基于gunicorn的python web进行了压测,它们都没有使用任何web框架,而是使用裸的PHP或python开发的简单的hello world,从而避免不同web框架对性能产生影响。

虽然不够科学,但数据还是显示了gunicorn比php-fpm要逊色不少,这同时也是目前大部分python web面临的现状——性能普遍很差。这种差并非在于python比php速度慢,也就是说并非是因为python执行1+1比php慢导致的,很大一部分原因是所选用的http服务器的性能差,比如gunicorn使用python对http协议进行解析,而php-fpm则是由nginx(c语言)完成解析。至于现在主流的异步web框架tornado,性能则更差,只不过他擅长处理大量的HTTP长连接以及以异步的方式访问后端的其他服务。有些人可能不太明白,为什么拿web框架来比较呢?异步web框架需要异步server支持,所以tornado本身也就是一个server,它与gunicorn所处的地位是一样的。 反观PHP web,php-fpm的性能比绝大部分的python wsgi server要好;另外,韩天峰声称他的异步web服务器swoole性能与nginx相当。

当然,python web开发者也无需悲观。python社区已经有bjoern这种现象级的存在(10w QPS),这个以后肯定会成为主流。另外,asyncio使用基于libuv(nodejs所用的事件库)的事件循环uvloop加上纯c写的http-parser(nodejs用的http解析器),性能已经超过了nodejs,与go相当。只不过,基于asyncio只支持python3,周边设施还不完善。

reference


qq email facebook github
© 2018 - Xurui Yan. All rights reserved
Built using pelican