結構設計
基礎架構為flask+gunicorn+負載均衡,負載均衡分為阿里云硬體負載均衡服務和軟負載nginx,gunicorn使用supervisor進行管理,
使用nginx軟體負載結構圖

使用阿里云硬體負載均衡服務結構圖

因為flask app需要在記憶體中保存ip樹以及國家、省份、城市相關的字典,因此占用記憶體較高,gunicorn的1個worker需要占用300M記憶體,nginx的4個worker記憶體占用較小(不到100M),因此占用1.3G的記憶體(即需要一個2G記憶體的服務器),當gunicorn任意一個節點掛斷或者升級時,另外一個節點仍然在使用,不會影響整體服務
ip資料庫
IP庫(也叫IP地址資料庫),是由專業技術人員經過長時間通過多種技術手段收集而來的,并且長期有專業人員進行更新、維護、補充,
ip資料庫決議查詢代碼
基于二叉查找樹實作
import struct
from socket import inet_aton, inet_ntoa
import os
import sys
sys.setrecursionlimit(1000000)
_unpack_V = lambda b: struct.unpack("<L", b)
_unpack_N = lambda b: struct.unpack(">L", b)
_unpack_C = lambda b: struct.unpack("B", b)
class IpTree:
def __init__(self):
self.ip_dict = {}
self.country_codes = {}
self.china_province_codes = {}
self.china_city_codes = {}
def load_country_codes(self, file_name):
try:
path = os.path.abspath(file_name)
with open(path, "rb") as f:
for line in f.readlines():
data = https://www.cnblogs.com/CHLL55/p/line.split('\t')
self.country_codes[data[0]] = data[1]
# print self.country_codes
except Exception as ex:
print "cannot open file %s: %s" % (file, ex)
print ex.message
exit(0)
def load_china_province_codes(self, file_name):
try:
path = os.path.abspath(file_name)
with open(path, "rb") as f:
for line in f.readlines():
data = https://www.cnblogs.com/CHLL55/p/line.split('\t')
provinces = data[2].split('\r')
self.china_province_codes[provinces[0]] = data[0]
# print self.china_province_codes
except Exception as ex:
print "cannot open file %s: %s" % (file, ex)
print ex.message
exit(0)
def load_china_city_codes(self, file_name):
try:
path = os.path.abspath(file_name)
with open(path, "rb") as f:
for line in f.readlines():
data = https://www.cnblogs.com/CHLL55/p/line.split('\t')
cities = data[3].split('\r')
self.china_city_codes[cities[0]] = data[0]
except Exception as ex:
print "cannot open file %s: %s" % (file, ex)
print ex.message
exit(0)
def loadfile(self, file_name):
try:
ipdot0 = 254
path = os.path.abspath(file_name)
with open(path, "rb") as f:
local_binary0 = f.read()
local_offset, = _unpack_N(local_binary0[:4])
local_binary = local_binary0[4:local_offset]
# 256 nodes
while ipdot0 >= 0:
middle_ip = None
middle_content = None
lis = []
# offset
begin_offset = ipdot0 * 4
end_offset = (ipdot0 + 1) * 4
# index
start_index, = _unpack_V(local_binary[begin_offset:begin_offset + 4])
start_index = start_index * 8 + 1024
end_index, = _unpack_V(local_binary[end_offset:end_offset + 4])
end_index = end_index * 8 + 1024
while start_index < end_index:
content_offset, = _unpack_V(local_binary[start_index + 4: start_index + 7] +
chr(0).encode('utf-8'))
content_length, = _unpack_C(local_binary[start_index + 7])
content_offset = local_offset + content_offset - 1024
content = local_binary0[content_offset:content_offset + content_length]
if middle_content != content and middle_content is not None:
contents = middle_content.split('\t')
lis.append((middle_ip, (contents[0], self.lookup_country_code(contents[0]),
contents[1], self.lookup_china_province_code(contents[1]),
contents[2], self.lookup_china_city_code(contents[2]),
contents[3], contents[4])))
middle_content, = content,
middle_ip = inet_ntoa(local_binary[start_index:start_index + 4])
start_index += 8
self.ip_dict[ipdot0] = self.generate_tree(lis)
ipdot0 -= 1
except Exception as ex:
print "cannot open file %s: %s" % (file, ex)
print ex.message
exit(0)
def lookup_country(self, country_code):
try:
for item_country, item_country_code in self.country_codes.items():
if country_code == item_country_code:
return item_country, item_country_code
return 'None', 'None'
except KeyError:
return 'None', 'None'
def lookup_country_code(self, country):
try:
return self.country_codes[country]
except KeyError:
return 'None'
def lookup_china_province(self, province_code):
try:
for item_province, item_province_code, in self.china_province_codes.items():
if province_code == item_province_code:
return item_province, item_province_code
return 'None', 'None'
except KeyError:
return 'None', 'None'
def lookup_china_province_code(self, province):
try:
return self.china_province_codes[province.encode('utf-8')]
except KeyError:
return 'None'
def lookup_china_city(self, city_code):
try:
for item_city, item_city_code in self.china_city_codes.items():
if city_code == item_city_code:
return item_city, item_city_code
return 'None', 'None'
except KeyError:
return 'None', 'None'
def lookup_china_city_code(self, city):
try:
return self.china_city_codes[city]
except KeyError:
return 'None'
def lookup(self, ip):
ipdot = ip.split('.')
ipdot0 = int(ipdot[0])
if ipdot0 < 0 or ipdot0 > 255 or len(ipdot) != 4:
return None
try:
d = self.ip_dict[int(ipdot[0])]
except KeyError:
return None
if d is not None:
return self.lookup1(inet_aton(ip), d)
else:
return None
def lookup1(self, net_ip, (net_ip1, content, lefts, rights)):
if net_ip < net_ip1:
if lefts is None:
return content
else:
return self.lookup1(net_ip, lefts)
elif net_ip > net_ip1:
if rights is None:
return content
else:
return self.lookup1(net_ip, rights)
else:
return content
def generate_tree(self, ip_list):
length = len(ip_list)
if length > 1:
lefts = ip_list[:length / 2]
rights = ip_list[length / 2:]
(ip, content) = lefts[length / 2 - 1]
return inet_aton(ip), content, self.generate_tree(lefts), self.generate_tree(rights)
elif length == 1:
(ip, content) = ip_list[0]
return inet_aton(ip), content, None, None
else:
return
if __name__ == "__main__":
import sys
reload(sys)
sys.setdefaultencoding('utf-8')
ip_tree = IpTree()
ip_tree.load_country_codes("doc/country_list.txt")
ip_tree.load_china_province_codes("doc/china_province_code.txt")
ip_tree.load_china_city_codes("doc/china_city_code.txt")
ip_tree.loadfile("doc/mydata4vipday2.dat")
print ip_tree.lookup('123.12.23.45')
http請求
提供ip查詢服務的GET請求和POST請求
@ip_app.route('/api/ip_query', methods=['POST'])
def ip_query():
try:
ip = request.json['ip']
except KeyError as e:
raise InvalidUsage('bad request: no key ip in your request json body. {}'.format(e), status_code=400)
if not is_ip(ip):
raise InvalidUsage('{} is not a ip'.format(ip), status_code=400)
try:
res = ip_tree.lookup(ip)
except Exception as e:
raise InvalidUsage('internal error: {}'.format(e), status_code=500)
if res is not None:
return jsonify(res)
else:
raise InvalidUsage('no ip info in ip db for ip: {}'.format(ip), status_code=501)
@ip_app.route('/api/ip_query', methods=['GET'])
def ip_query_get():
try:
ip = request.values.get('ip')
except ValueError as e:
raise InvalidUsage('bad request: no param ip in your request. {}'.format(e), status_code=400)
if not is_ip(ip):
raise InvalidUsage('{} is not a ip'.format(ip), status_code=400)
try:
res = ip_tree.lookup(ip)
except Exception as e:
raise InvalidUsage('internal error: {}'.format(e), status_code=500)
if res is not None:
return jsonify(res)
else:
raise InvalidUsage('no ip info in ip db for ip: {}'.format(ip), status_code=501)
POST請求需要在請求體中包含類似下面的json欄位
{
"ip": "165.118.213.9"
}
GET請求的形式如:http://127.0.0.1:5000/api/ip_query?ip=165.118.213.9
服務部署
安裝依賴庫
依賴的庫requirements.txt如下:
certifi==2017.7.27.1
chardet==3.0.4
click==6.7
Flask==0.12.2
gevent==1.1.1
greenlet==0.4.12
gunicorn==19.7.1
idna==2.5
itsdangerous==0.24
Jinja2==2.9.6
locustio==0.7.5
MarkupSafe==1.0
meld3==1.0.2
msgpack-python==0.4.8
requests==2.18.3
supervisor==3.3.3
urllib3==1.22
Werkzeug==0.12.2
安裝方法:pip install -r requirements.txt
配置supervisor
vim /etc/supervisor/conf.d/ip_query_http_service.conf,內容如下
[program:ip_query_http_service]
directory = /root/qk_python/ip_query
command = gunicorn -w10 -b0.0.0.0:8080 ip_query_app:ip_app --worker-class gevent
autostart = true
startsecs = 5
autorestart = true
startretries = 3
user = root
stdout_logfile=/root/qk_python/ip_query/log/gunicorn.log
stderr_logfile=/root/qk_python/ip_query/log/gunicorn.err
內容添加完成之后,需要創建stdout_logfile和stderr_logfile這兩個目錄,否則supervisor啟動會報錯,然后更新supervisor啟動ip_query_http_service行程,
# 啟動supervisor
supervisord -c /etc/supervisor/supervisord.conf
# 更新supervisor服務
supervisorctl update
關于supervisor的常用操作參見最后面的參考資料,
安裝nginx
如果是軟負載的形式需要安裝nginx,編譯安裝nginx的方法參見最后面的參考資料,
配置nginx
vim /usr/local/nginx/nginx.conf,修改組態檔內容如下:
#user nobody;
#nginx行程數,建議設定為等于CPU總核心數,
worker_processes 4;
#error_log logs/error.log;
#error_log logs/error.log notice;
#全域錯誤日志定義型別,[ debug | info | notice | warn | error | crit ]
error_log logs/error.log info;
#行程檔案
pid logs/nginx.pid;
#一個nginx行程打開的最多檔案描述符數目,理論值應該是最多打開檔案數(系統的值ulimit -n)與nginx行程數相除,但是nginx分配請求并不均勻,所以建議與ulimit -n的值保持一致,
worker_rlimit_nofile 65535;
events {
#參考事件模型 linux 下使用epoll
use epoll;
#單個行程最大連接數(最大連接數=連接數*行程數)
worker_connections 65535;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
sendfile on;
#keepalive_timeout 0;
keepalive_timeout 65;
tcp_nopush on; #防止網路阻塞
tcp_nodelay on; #防止網路阻塞
#gzip on;
server {
#這里配置銜接服務提供的代理埠.
listen 9000;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
# root html;
# index index.html index.htm;
proxy_pass http://127.0.0.1:8000;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
#后端的Web服務器可以通過X-Forwarded-For獲取用戶真實IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
client_max_body_size 10m; #允許客戶端請求的最大單檔案位元組數
client_body_buffer_size 128k; #緩沖區代理緩沖用戶端請求的最大位元組數,
proxy_buffer_size 4k; #設定代理服務器(nginx)保存用戶頭資訊的緩沖區大小
proxy_temp_file_write_size 64k; #設定快取檔案夾大小,大于這個值,將從upstream服務器傳
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
壓力測驗
做壓力測驗,選擇正確的工具是前提,以下工具中,jmeter運行在windows機器較多,其他工具建議都運行在*nix機器上,
壓力測驗工具選擇
| 工具名稱 | 優缺點 | 建議 |
|---|---|---|
| ApacheBench(ab) | 命令使用簡單,效率高,統計資訊完善,施壓機器記憶體壓力小 | 推薦 |
| locust | python撰寫,效率低,受限于GIL,需要撰寫python測驗腳本 | 不推薦 |
| wrk | 命令使用簡單,效率高,統計資訊精煉,坑少,少報錯 | 最推薦 |
| jmeter | 基于java,Apache開源,圖形化界面,操作簡便 | 推薦 |
| webbench | 使用簡單,但是不支持POST請求 | 一般 |
| tsung | erlang撰寫,配置模板較多,較復雜 | 不推薦 |
上述六種工具全部親身使用過,下面選擇ab、wrk、jmeter三種工具簡單說明安裝使用方法,其他工具的使用方法如有需要,自行google
ab
安裝
apt-get install apache2-utils
常見options
| option | 含義 |
|---|---|
| -r | 當接收到socket錯誤的時候ab不退出 |
| -t | 發送請求的最長時間 |
| -c | 并發數,一次構造的請求數量 |
| -n | 發送的請求數量 |
| -p | postfile,指定包含post資料的檔案 |
| -T | content-type,指定post和put發送請求時請求體的型別 |
使用
測驗GET請求
ab -r -t 120 -c 5000 http://127.0.0.1:8080/api/ip_query?ip=165.118.213.9
測驗POST請求
ab -r -t 120 -c 5000 -p /tmp/post_data.txt -T 'application/json' http://127.0.0.1:8080/api/ip_query
其中/tmp/post_data.txt檔案的內容為待發送的-T指定格式的資料,在此處為json格式
{"ip": "125.118.213.9"}
wrk
http://www.restran.net/2016/09/27/wrk-http-benchmark/
安裝
apt-get install libssl-dev
git clone https://github.com/wg/wrk.git
cd wrk
make
cp wrk /usr/sbin
常見options
| option | 含義 |
|---|---|
| -c | 打開的連接數,即并發數 |
| -d | 壓力測驗時間:發送請求的最長時間 |
| -t | 施壓機器使用的執行緒數量 |
| -s | 指定要加載的lua腳本 |
| --latency | 列印延遲統計資訊 |
使用
測驗GET請求
wrk -t10 -c5000 -d120s --latency http://127.0.0.1:8080/api/ip_query?ip=165.118.213.9
測驗POST請求
wrk -t50 -c5000 -d120s --latency -s /tmp/wrk_post.lua http://127.0.0.1:8080
其中/tmp/wrk_post.lua檔案的內容為待加載的lua腳本,指定post的path,header,body
request = function()
path = "/api/ip_query"
wrk.headers["Content-Type"] = "application/json"
wrk.body = "{\"ip\":\"125.118.213.9\"}"
return wrk.format("POST", path)
end
jmeter
安裝
安裝jmeter前需要先安裝jdk1.8,然后在Apache官網可以下載jmeter,點此下載
使用

以上圖片來自一個測驗大牛,非常詳細,完整的xmind檔案下載見:jmeter-張蓓.xmind
jmeter的入門級使用也可以參考最后面的參考資料部分:使用Apache Jmeter進行并發壓力測驗
壓力測驗結果分析
wrk GET請求壓測結果
root@ubuntu:/tmp# wrk -t10 -c5000 -d60s --latency http://127.0.0.1:8080/api/ip_query?ip=165.118.213.9
Running 1m test @ http://127.0.0.1:8080/api/ip_query?ip=165.118.213.9
10 threads and 5000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 897.19ms 322.83ms 1.99s 70.52%
Req/Sec 318.80 206.03 2.14k 68.84%
Latency Distribution
50% 915.29ms
75% 1.11s
90% 1.29s
99% 1.57s
187029 requests in 1.00m, 51.01MB read
Socket errors: connect 0, read 0, write 0, timeout 38
Requests/sec: 3113.27
Transfer/sec: 869.53KB
ab GET請求壓測結果
root@ubuntu:/tmp# ab -r -t 60 -c 5000 http://127.0.0.1:8080/api/ip_query?ip=165.118.213.9
This is ApacheBench, Version 2.3 <$Revision: 1796539 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, https://www.apache.org/
Benchmarking 127.0.0.1 (be patient)
Completed 5000 requests
Completed 10000 requests
Completed 15000 requests
Completed 20000 requests
Completed 25000 requests
Completed 30000 requests
Completed 35000 requests
Completed 40000 requests
Completed 45000 requests
Completed 50000 requests
Finished 50000 requests
Server Software: gunicorn/19.7.1
Server Hostname: 127.0.0.1
Server Port: 8080
Document Path: /api/ip_query?ip=165.118.213.9
Document Length: 128 bytes
Concurrency Level: 5000
Time taken for tests: 19.617 seconds
Complete requests: 50000
Failed requests: 2
(Connect: 0, Receive: 0, Length: 1, Exceptions: 1)
Total transferred: 14050000 bytes
HTML transferred: 6400000 bytes
Requests per second: 2548.85 [#/sec] (mean)
Time per request: 1961.668 [ms] (mean)
Time per request: 0.392 [ms] (mean, across all concurrent requests)
Transfer rate: 699.44 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 597 1671.8 4 15500
Processing: 4 224 201.4 173 3013
Waiting: 4 223 200.1 172 2873
Total: 7 821 1694.4 236 15914
Percentage of the requests served within a certain time (ms)
50% 236
66% 383
75% 1049
80% 1155
90% 1476
95% 3295
98% 7347
99% 7551
100% 15914 (longest request)
jmeter GET請求壓測結果

結果分析
以上三個工具的壓測結果大體相同,RPS(Requests per second)大致在3000左右,此時機器配置為4核4G記憶體,并且gunicorn開了10個worker,記憶體占用3.2G,單臺機器只有3000并發,對于此配置的機器來說,需要進一步分析原因,后續再弄一臺機器,負載均衡后能達到5000以上才能滿足使用要求,
壓力測驗注意事項
檔案打開數
壓力測驗時對施壓機器的檔案打開數一般有要求,遠不止1024個open files,需要增加linux系統的檔案打開數,增加方法:
# 檔案打開數
ulimit -a
# 修改檔案打開數
ulimit -n 500000
SYN洪水攻擊保護
linux系統中有一個引數:/etc/sysctl.conf組態檔中的net.ipv4.tcp_syncookies欄位,這個欄位值默認為1,表示系統會檢測SYN洪水攻擊,并開啟保護,因此壓測時,如果發送大量重復性資料的請求,受壓機器SYN佇列溢位之后啟用SYN cookie,導致會有大量請求超時失敗,阿里云的負載均衡是有SYN洪水攻擊檢測和DDos攻擊檢測功能的,因此在做壓力測驗時需要注意兩點:
- 測驗時適當關閉負載均衡機器的 net.ipv4.tcp_syncookies 欄位
- 造資料時應該盡量避免大量重復性資料,以免被識別為攻擊,
gunicorn簡介及調優
關于gunicorn的選擇可以參考測驗報告:Python WSGI Server 性能分析
在選定gunicorn作為WSGI server之后,需要根據機器選擇相應的worker數量以及每個worker的worker-class,
worker數量選擇
每一個worker都是作為一個單獨的子行程來運行,都持有一份獨立的記憶體資料,每增加或減少一個worker,系統記憶體明顯的成倍數的改變,最初單臺機器gunicorn開啟3個worker,系統只支持1000RPS的并發,當把worker擴展為9個之后,系統支持3000RPS的并發,因此在記憶體足夠的時候,可以適當增加worker數量,
worker-class選擇
可以參考尾部的參考資料中的gunicorn常用settings和Gunicorn 幾種 Worker class 性能測驗比較這兩篇文章,
將gunicorn啟動時的worker-class從默認的sync改成gevent之后,系統RPS直接翻倍,
| worker-class | worker數量 | ab測驗的RPS |
|---|---|---|
| sync | 3 | 573.90 |
| gevent | 3 | 1011.84 |
gevent依賴:gevent >= 0.13,因此需要先使用pip安裝,對應的gunicorn啟動flask應用的命令需要修改為:
gunicorn -w10 -b0.0.0.0:8080 ip_query_app:ip_app --worker-class gevent
改進點
改進ip資料庫準確性
損失效率換取準確性:使用單一ip資料庫會存在一些ip無法查詢出結果的情況,并且國外ip一般只能精確到國家,可以平衡幾家ip資料庫的準確度和覆寫率,當無法查詢出準確的地址資訊時去查詢另外幾個ip資料庫,
提高單臺機器并發量
從發起請求,到WSGI服務器處理,到應用介面,到ip查詢每個程序都需要單獨分析每秒可執行量,進而分析系統瓶頸,從根本上提高單機并發量,
參考資料
- 全球 IPv4 地址歸屬地資料庫(IPIP.NET 版)
- 使用flask開發RESTful架構的api服務器端(5)–部署flask應用到nginx
- python web 部署:nginx + gunicorn + supervisor + flask 部署筆記
- flowsnow-nginx編譯安裝
- supervisor推薦教程-使用 supervisor 管理行程
- 維基-二叉查找樹
- 簡書-wrk壓力測驗post介面
- 使用Apache Jmeter進行并發壓力測驗
- gunicorn常用settings
- Gunicorn 幾種 Worker class 性能測驗比較
記得幫我點贊哦!
精心整理了計算機各個方向的從入門、進階、實戰的視頻課程和電子書,按照目錄合理分類,總能找到你需要的學習資料,還在等什么?快去關注下載吧!!!

念念不忘,必有回響,小伙伴們幫我點個贊吧,非常感謝,
我是職場亮哥,YY高級軟體工程師、四年作業經驗,拒絕咸魚爭當龍頭的斜杠程式員,
聽我說,進步多,程式人生一把梭
如果有幸能幫到你,請幫我點個【贊】,給個關注,如果能順帶評論給個鼓勵,將不勝感激,
職場亮哥文章串列:更多文章

本人所有文章、回答都與著作權保護平臺有合作,著作權歸職場亮哥所有,未經授權,轉載必究!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/210509.html
標籤:Python
上一篇:初入Django框架
下一篇:django 專案啟動
