最近入职做了一个小任务,实现一个小系统并且在有限的硬件条件下达到规定的QPS。

环境架构

压测工具: wrk

后端架构: Nginx -> Gunicorn -> Golang -> MySQL

硬件环境: 4核 8G

这个架构涵盖了Nginx快慢连接,微服务网络调用,还有数据库连接,每一步都可能是瓶颈,每一次优化都可能导致其他的模块抢占不到资源从而变成负优化。

瓶颈分析

QPS由系统的并发数和单个请求延迟决定,系统能同时处理的请求越多,单个请求延迟越小,QPS越高。

所以在系统部署后,第一步应该找到系统能承受的并发数。给wrk一个较低的连接数开始测试,然后逐步增加连接数,直到QPS不再上涨。通常这时候请求的平均延迟也呈上升趋势,因为系统处理不过来,出现了连接的堆积。

得到最大并发数以后,优化有两个方向,一个是优化系统的并发能力,通常可以通过一些配置进行优化,上限取决于硬件条件。另一个则是优化系统的延迟,这一步可以通过代码进行优化。

因为系统是环环相扣的,找出哪个系统的哪个部分拖累了整个系统也是很关键的一步。常用的就是控制变量法,让系统只保留Nginx测出QPS并记录,然后再启用Gunicorn,再测试...以此类推。
除了记录QPS,也应当把CPU占用率记录下来,后面开启的服务越多,一定会抢占前面服务的资源。例如单独压测Nginx跑出来10000QPS,CPU占用100%,加上Gunicorn后,QPS只有3000,NginxCPU占用率50%。一看损失了7000QPS,但是Nginx只吃到了一半的资源,理论上只能达到5000QPS,所以在NginxGunicorn之间损失了2000QPS,这2000是有优化空间的。

性能优化

在硬件环境固定的条件下做性能优化,目的就是榨干你的设备,让CPU不要空转,线程阻塞及时切换,又不能产生太多的上下文切换损耗。

Nginx

worker_processes 4;
worker_cpu_affinity 0001 0010 0100 1000;
worker_rlimit_nofile 655350;
worker_priority -20;

events {
	use epoll;
	worker_connections 4048;
	multi_accept on;
}

...

upstream gunicorn {
	server 127.0.0.1:8000;
	keepalive 300;
}

server {
	listen 80;

	server_name _;

	location / {
		proxy_pass http://gunicorn;
		proxy_http_version 1.1;
		proxy_set_header Connection "";
	}
	location /status {
		stub_status on;
	}
}
  • worker_processes为工作进程数(非线程),等于CPU核心数最佳。
  • worker_cpu_affinity分配CPU,保证每个CPU都有一个worker进程。
  • worker_rlimit_nofile一般小于等于系统的最大打开文件描述符数。
  • worker_priorityNginx能拿到更多的CPU。
  • events里开启使用epoll作为网络IO,一般情况下这个都是并发能力最强的。
  • proxy_http_version 1.1;proxy_set_header Connection "";可以开启Nginx到下游应用的长连接,复用连接也能减少QPS损耗。

Nginx能做的优化基本只有这些了。经过测试,Nginx抢占的资源并不多,处理还十分迅速,大多数情况都是吸收了大量的连接但是堆积起来,等待下游的处理。

Gunicorn

gunicorn python_server.wsgi:application \
	-b 0.0.0.0:8000 \
	--workers=9 \
	--backlog=2048 \
	--keep-alive=2 \
	--worker-connections=4048 \
	--worker-class='gevent'

应用层面的优化其实十分有限,其他应用如Tomcat,可优化的配置项也很少,只能逐步尝试优化。如果应用层是多线程模型,应当控制连接的数量,过多的连接反而会因为线程的频繁切换导致更多无意义的开销。

Socket

基本上所有的RPC协议都是建立在Socket连接之上,这里就涉及到对网络IO的优化了,可以从代码上,协议上进行优化,如果优化难度过大,最基本的也得加一个连接池。
对于应用来说,遇到IO阻塞会切换线程,执行其他任务,CPU是不会空转的,但对于调用方用户来说,请求的总时长是不会变的。还记得上文说到的,QPS由系统的并发数和单个请求延迟决定,CPU已经跑满了,那就只能从延迟下手。

MySQL

[mysqld]

back_log=500
max_connections=1024
key_buffer_size=512M
innodb_buffer_pool_size=2024M
query_cache_size=512M
thread_cache_size=64

这是一段比较极端的MySQL配置,我分配了很多的缓存,因为我的这个项目内存占用率不高,这也是一种思路,空间换时间。
对于绝大部分应用,数据库都是瓶颈,而大多数应用又都是读数据远远大于写数据,所以解决数据库瓶颈的最佳方法就是加缓存。除了数据库自带的缓存,其他缓存如RedisMemcache都是有效的手段,做好数据一致性的工作就好。