從0到1,Python Web開發的進擊之路

轉載自知乎專欄

從0到1,Python Web開發的進擊之路

本文將以個人(開發)的角度,講述如何從零開始,編寫、搭建和部署一個基於Python的Web應用程序。

從最簡單的出發點來剖析,一個web應用後端要完成的工作抽象出來無非就是3點:

  1. 接收和解析請求。
  2. 處理業務邏輯。
  3. 生產和返回響應。

對於初學者來說,我們關心的只需這些步驟就夠了。要檢驗這三個步驟,最簡單的方法是先寫出一個hello world。

request->"hello world"->response

python有許多流行的web框架,我們該如何選擇呢?試着考慮三個因素:

  • 易用:該框架是面對初學者友好的,而且具有健全的文檔,靈活開發部署。例如flask,bottle。
  • 效率:該框架適合快速開發,擁有豐富的輪子,注重開發效率。例如django。
  • 性能:該框架能承載更大的請求壓力,提高吞吐量。例如falcon,tornado,aiohttp,sanic。

根據場景使用合適的框架能少走許多彎路,當然,你還能自己寫一個框架,這個下面再說。

對於缺乏經驗的人來說,易用性無疑是排在第一位的,推薦用flask作爲python web入門的第一個框架,另外也推薦django。

首先用virtualenv創建python的應用環境,爲什麼用virtualenv呢,virtualenv能創建一個純淨獨立的python環境,避免污染全局環境。(順便安利kennethreitz大神的pipenv

mkdir todo
cd todo
virtualenv venv
source venv/bin/activate
pip install flask
touch server.py

代碼未寫,規範先行。在寫代碼之前要定義好一套良好代碼規範,例如PEP8。這樣才能使得你的代碼變的更加可控。

心中默唸The Zen of Python:

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

下面用flask來編寫第一個程序:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/index')
def index():
    return jsonify(msg='hello world')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

在命令行輸入python server.py

python server.py
* Running on http://0.0.0.0:8000/ (Press CTRL+C to quit)

打開瀏覽器,訪問http://127.0.0.1:8000/index,如無意外,會看到下面的響應。

{
  "msg": "hello world"
}

一個最簡單的web程序就完成了!讓我們看下過程中都發生了什麼:

  1. 客戶端(瀏覽器)根據輸入的地址http://127.0.0.1:8000/index找到協議(http),主機(127.0.0.1),端口(8000)和路徑(/index),與服務器(application server)建立三次握手,併發送一個http請求。

  2. 服務器(application server)把請求報文封裝成請求對象,根據路由(router)找到/index這個路徑所對應的視圖函數,調用這個視圖函數。

  3. 視圖函數生成一個http響應,返回一個json數據給客戶端。

    HTTP/1.0 200 OK
    Content-Type: application/json
    Content-Length: 27
    Server: Werkzeug/0.11.15 Python/3.5.2
    Date: Thu, 26 Jan 2017 05:14:36 GMT
    

當我們輸入python server.py時,會建立一個服務器(也叫應用程序服務器,即application server)來監聽請求,並把請求轉給flask來處理。那麼這個服務器是如何跟python程序打交道的呢?答案就是WSGI(Web Server Gateway Interface)接口,它是server端(服務器)與application端(應用程序)之間的一套約定俗成的規範,使我們只要編寫一個統一的接口,就能應用到不同的wsgi server上。用圖表示它們的關係,就是下面這樣的:


只要application端(flask)和server端(flask內建的server)都遵循wsgi這個規範,那麼他們就能夠協同工作了,關於WSGI規範,可參閱Python官方的PEP 333裏的說明。

目前爲止,應用是下面這個樣子的:


一切都很簡單,現在我們要做一個Todo應用,提供添加todo,修改todo狀態和刪除todo的接口。

先不考慮數據庫,可以迅速地寫出下面的代碼:

from flask import Flask, jsonify, request, abort, Response
from time import time
from uuid import uuid4
import json

app = Flask(__name__)

class Todo(object):
    def __init__(self, content):
        self.id = str(uuid4())
        self.content = content #todo內容
        self.created_at = time() #創建時間
        self.is_finished = False #是否完成
        self.finished_at = None #完成時間

    def finish(self):
        self.is_finished = True
        self.finished_at = time()

    def json(self):
        return {
            'id': self.id,
            'content': self.content,
            'created_at': self.created_at,
            'is_finished': self.is_finished,
            'finished_at': self.finished_at
        }

todos = {}
get_todo = lambda tid: todos.get(tid, False)

@app.route('/todo')
def index():
    return jsonify(data=[todo.json() for todo in todos.values()])

@app.route('/todo', methods=['POST'])
def add():
    content = request.form.get('content', None)
    if not content:
        abort(400)
    todo = Todo(content)
    todos[todo.id] = todo
    return Response() #200

@app.route('/todo/<tid>/finish', methods=['PUT'])
def finish(tid):
    todo = get_todo(tid)
    if todo:
        todo.finish()
        todos[todo.id] = todo
        return Response()
    abort(404)

@app.route('/todo/<tid>', methods=['DELETE'])
def delete(tid):
    todo = get_todo(tid)
    if todo:
        todos.pop(tid)
        return Response()
    abort(404)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

這個程序基本實現了需要的接口,現在測試一下功能。

  • 添加一個todo
http -f POST http://127.0.0.1:8000/todo content=好好學習
HTTP/1.0 200 OK
Content-Length: 0
Content-Type: text/html; charset=utf-8
Date: Thu, 26 Jan 2017 06:45:37 GMT
Server: Werkzeug/0.11.15 Python/3.5.2
  • 查看todo列表
http http://127.0.0.1:8000/todo
HTTP/1.0 200 OK
Content-Length: 203
Content-Type: application/json
Date: Thu, 26 Jan 2017 06:46:16 GMT
Server: Werkzeug/0.11.15 Python/3.5.2

{
    "data": [
        "{\"created_at\": 1485413137.305699, \"id\": \"6f2b28c4-1e83-45b2-8b86-20e28e21cd40\", \"is_finished\": false, \"finished_at\": null, \"content\": \"\\u597d\\u597d\\u5b66\\u4e60\"}"
    ]
}
  • 修改todo狀態
http -f PUT http://127.0.0.1:8000/todo/6f2b28c4-1e83-45b2-8b86-20e28e21cd40/finish
HTTP/1.0 200 OK
Content-Length: 0
Content-Type: text/html; charset=utf-8
Date: Thu, 26 Jan 2017 06:47:18 GMT
Server: Werkzeug/0.11.15 Python/3.5.2

http http://127.0.0.1:8000/todo
HTTP/1.0 200 OK
Content-Length: 215
Content-Type: application/json
Date: Thu, 26 Jan 2017 06:47:22 GMT
Server: Werkzeug/0.11.15 Python/3.5.2

{
    "data": [
        "{\"created_at\": 1485413137.305699, \"id\": \"6f2b28c4-1e83-45b2-8b86-20e28e21cd40\", \"is_finished\": true, \"finished_at\": 1485413238.650981, \"content\": \"\\u597d\\u597d\\u5b66\\u4e60\"}"
    ]
}
  • 刪除todo
http -f DELETE http://127.0.0.1:8000/todo/6f2b28c4-1e83-45b2-8b86-20e28e21cd40
HTTP/1.0 200 OK
Content-Length: 0
Content-Type: text/html; charset=utf-8
Date: Thu, 26 Jan 2017 06:48:20 GMT
Server: Werkzeug/0.11.15 Python/3.5.2

http http://127.0.0.1:8000/todo
HTTP/1.0 200 OK
Content-Length: 17
Content-Type: application/json
Date: Thu, 26 Jan 2017 06:48:22 GMT
Server: Werkzeug/0.11.15 Python/3.5.2

{
    "data": []
}

但是這個的程序的數據都保存在內存裏,只要服務一停止所有的數據就沒辦法保存下來了,因此,我們還需要一個數據庫用於持久化數據。

那麼,應該選擇什麼數據庫呢?

  • 傳統的rdbms,例如mysql,postgresql等,他們具有很高的穩定性和不俗的性能,結構化查詢,支持事務,由ACID來保持數據的完整性。
  • nosql,例如mongodb,cassandra等,他們具有非結構化特性,易於橫向擴展,實現數據的自動分片,擁有靈活的存儲結構和強悍的讀寫性能。

這裏使用mongodb作例子,使用mongodb改造後的代碼是這樣的:

from flask import Flask, jsonify, request, abort, Response
from time import time
from bson.objectid import ObjectId
from bson.json_util import dumps
import pymongo

app = Flask(__name__)

mongo = pymongo.MongoClient('127.0.0.1', 27017)
db = mongo.todo

class Todo(object):
    @classmethod
    def create_doc(cls, content):
        return {
            'content': content,
            'created_at': time(),
            'is_finished': False,
            'finished_at': None
        }

@app.route('/todo')
def index():
    todos = db.todos.find({})
    return dumps(todos)

@app.route('/todo', methods=['POST'])
def add():
    content = request.form.get('content', None)
    if not content:
        abort(400)
    db.todos.insert(Todo.create_doc(content))
    return Response() #200

@app.route('/todo/<tid>/finish', methods=['PUT'])
def finish(tid):
    result = db.todos.update_one(
        {'_id': ObjectId(tid)},
        {
            '$set': {
                'is_finished': True,
                'finished_at': time()
            }
        }    
    )
    if result.matched_count == 0:
        abort(404)
    return Response()

@app.route('/todo/<tid>', methods=['DELETE'])
def delete(tid):
    result = db.todos.delete_one(
        {'_id': ObjectId(tid)}  
    )
    if result.matched_count == 0:
        abort(404)
    return Response()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

這樣一來,應用的數據便能持久化到本地了。現在,整個應用看起來是下面這樣的:


現在往mongodb插入1萬條數據。

import requests

for i in range(10000):
    requests.post('http://127.0.0.1:8000/todo', {'content': str(i)})

獲取todo的接口目前是有問題的,因爲它一次性把數據庫的所有記錄都返回了,當數據記錄增長到一萬條的時候,這個接口的請求就會變的非常慢,需要500ms後才能發出響應。現在對它進行如下的改造:

@app.route('/todo')
def index():
    start = request.args.get('start', '')
    start = int(start) if start.isdigit() else 0
    todos = db.todos.find().sort([('created_at', -1)]).limit(10).skip(start)
    return dumps(todos)

每次只取十條記錄,按創建日期排序,先取最新的,用分頁的方式獲取以往記錄。改造後的接口現在只需50ms便能返回響應。

現在對這個接口進行性能測試:

wrk -c 100 -t 12 -d 5s http://127.0.0.1:8000/todo
Running 5s test @ http://127.0.0.1:8000/todo
  12 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.22s   618.29ms   1.90s    48.12%
    Req/Sec    14.64     10.68    40.00     57.94%
  220 requests in 5.09s, 338.38KB read
  Socket errors: connect 0, read 0, write 0, timeout 87
Requests/sec:     43.20
Transfer/sec:     66.45KB

rps只有43。我們繼續進行改進,通過觀察我們發現我們查詢todo時需要通過created_at這個字段進行排序再過濾,這樣以來每次查詢都要先對10000條記錄進行排序,效率自然變的很低,對於這個場景,可以對created_at這個字段做索引:

db.todos.ensureIndex({'created_at': -1})

通過explain我們輕易地看出mongo使用了索引做掃描

> db.todos.find().sort({'created_at': -1}).limit(10).explain()
/* 1 */
{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "todo.todos",
        "indexFilterSet" : false,
        "parsedQuery" : {},
        "winningPlan" : {
            "stage" : "LIMIT",
            "limitAmount" : 10,
            "inputStage" : {
                "stage" : "FETCH",
                "inputStage" : {
                    "stage" : "IXSCAN",
                    "keyPattern" : {
                        "created_at" : -1.0
                    },
                    "indexName" : "created_at_-1",
                    "isMultiKey" : false,
                    "multiKeyPaths" : {
                        "created_at" : []
                    },
                    "isUnique" : false,
                    "isSparse" : false,
                    "isPartial" : false,
                    "indexVersion" : 2,
                    "direction" : "forward",
                    "indexBounds" : {
                        "created_at" : [ 
                            "[MaxKey, MinKey]"
                        ]
                    }
                }
            }
        },
        "rejectedPlans" : []
    },
    "serverInfo" : {
        "host" : "841bf506b6ec",
        "port" : 27017,
        "version" : "3.4.1",
        "gitVersion" : "5e103c4f5583e2566a45d740225dc250baacfbd7"
    },
    "ok" : 1.0
}

現在再做一輪性能測試,有了索引之後就大大降低了排序的成本,rps提高到了298。

wrk -c 100 -t 12 -d 5s http://127.0.0.1:8000/todo
Running 5s test @ http://127.0.0.1:8000/todo
  12 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   310.32ms   47.51ms 357.47ms   94.57%
    Req/Sec    26.88     14.11    80.00     76.64%
  1511 requests in 5.06s, 2.27MB read
Requests/sec:    298.34
Transfer/sec:    458.87KB

再把重心放到app server上,目前我們使用flask內建的wsgi server,這個server由於是單進程單線程模型的,所以性能很差,一個請求不處理完的話服務器就會阻塞住其他請求,我們需要對這個server做替換。關於python web的app server選擇,目前主流採用的有:

  • gunicorn
  • uWSGI

我們看gunicorn文檔可以得知,gunicorn是一個python編寫的高效的WSGI HTTP服務器,gunicorn使用pre-fork模型(一個master進程管理多個child子進程),使用gunicorn的方法十分簡單:

gunicorn --workers=9 server:app --bind 127.0.0.1:8000

根據文檔說明使用(2 * cpu核心數量)+1個worker,還要傳入一個兼容wsgi app的start up方法,通過Flask的源碼可以看到,Flask這個類實現了下面這個接口:

    def __call__(self, environ, start_response):
        """Shortcut for :attr:`wsgi_app`."""
        return self.wsgi_app(environ, start_response)

也就是說我們只需把flask實例的名字傳給gunicorn就ok了:

gunicorn --workers=9 server:app --bind 127.0.0.1:8000
[2017-01-27 11:20:01 +0800] [5855] [INFO] Starting gunicorn 19.6.0
[2017-01-27 11:20:01 +0800] [5855] [INFO] Listening at: http://127.0.0.1:8000 (5855)
[2017-01-27 11:20:01 +0800] [5855] [INFO] Using worker: sync
[2017-01-27 11:20:01 +0800] [5889] [INFO] Booting worker with pid: 5889
[2017-01-27 11:20:01 +0800] [5890] [INFO] Booting worker with pid: 5890
[2017-01-27 11:20:01 +0800] [5891] [INFO] Booting worker with pid: 5891
[2017-01-27 11:20:01 +0800] [5892] [INFO] Booting worker with pid: 5892
[2017-01-27 11:20:02 +0800] [5893] [INFO] Booting worker with pid: 5893
[2017-01-27 11:20:02 +0800] [5894] [INFO] Booting worker with pid: 5894
[2017-01-27 11:20:02 +0800] [5895] [INFO] Booting worker with pid: 5895
[2017-01-27 11:20:02 +0800] [5896] [INFO] Booting worker with pid: 5896
[2017-01-27 11:20:02 +0800] [5897] [INFO] Booting worker with pid: 5897

可以看到gunicorn啓動了9個進程(其中1個父進程)監聽請求。使用了多進程的模型看起來是下面這樣的:


繼續進行性能測試,可以看到吞吐量又有了很大的提升:

wrk -c 100 -t 12 -d 5s http://127.0.0.1:8000/todo
Running 5s test @ http://127.0.0.1:8000/todo
  12 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   109.30ms   16.10ms 251.01ms   90.31%
    Req/Sec    72.47     10.48   100.00     78.89%
  4373 requests in 5.07s, 6.59MB read
Requests/sec:    863.35
Transfer/sec:      1.30MB

那麼gunicorn還能再優化嗎,答案是肯定的。回到之前我們發現了這一行:

[2017-01-27 11:20:01 +0800] [5855] [INFO] Using worker: sync

也就是說,gunicorn worker使用的是sync(同步)模式來處理請求,那麼它支持async(異步)模式嗎,再看gunicorn的文檔有下面一段說明:

Async Workers
The asynchronous workers available are based on Greenlets (via Eventlet and Gevent). Greenlets are an implementation of cooperative multi-threading for Python. In general, an application should be able to make use of these worker classes with no changes.

gunicorn支持基於greenlet的異步的worker,它使得worker能夠協作式地工作。當worker阻塞在外部調用的IO操作時,gunicorn會聰明地把執行調度給其他worker,掛起當前的worker,直至IO操作完成後,被掛起的worker又會重新加入到調度隊列中,這樣gunicorn便有能力處理大量的併發請求了。

gunicorn有兩個不錯的async worker:

  • meinheld
  • gevent

meinheld是一個基於picoev的異步WSGI Web服務器,它可以很輕鬆地集成到gunicorn中,處理wsgi請求。

gunicorn --workers=9 --worker-class="meinheld.gmeinheld.MeinheldWorker" server:app --bind 127.0.0.1:8000
[2017-01-27 11:47:01 +0800] [7497] [INFO] Starting gunicorn 19.6.0
[2017-01-27 11:47:01 +0800] [7497] [INFO] Listening at: http://127.0.0.1:8000 (7497)
[2017-01-27 11:47:01 +0800] [7497] [INFO] Using worker: meinheld.gmeinheld.MeinheldWorker
[2017-01-27 11:47:01 +0800] [7531] [INFO] Booting worker with pid: 7531
[2017-01-27 11:47:01 +0800] [7532] [INFO] Booting worker with pid: 7532
[2017-01-27 11:47:01 +0800] [7533] [INFO] Booting worker with pid: 7533
[2017-01-27 11:47:01 +0800] [7534] [INFO] Booting worker with pid: 7534
[2017-01-27 11:47:01 +0800] [7535] [INFO] Booting worker with pid: 7535
[2017-01-27 11:47:01 +0800] [7536] [INFO] Booting worker with pid: 7536
[2017-01-27 11:47:01 +0800] [7537] [INFO] Booting worker with pid: 7537
[2017-01-27 11:47:01 +0800] [7538] [INFO] Booting worker with pid: 7538
[2017-01-27 11:47:01 +0800] [7539] [INFO] Booting worker with pid: 7539

可以看到現在使用的是meinheld.gmeinheld.MeinheldWorker這個worker。再進行性能測試看看:

wrk -c 100 -t 12 -d 5s http://127.0.0.1:8000/todo
Running 5s test @ http://127.0.0.1:8000/todo
  12 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    84.53ms   39.90ms 354.42ms   72.11%
    Req/Sec    94.52     20.84   150.00     70.28%
  5684 requests in 5.04s, 8.59MB read
Requests/sec:   1128.72
Transfer/sec:      1.71MB

果然提升了不少。

現在有了app server,那需要nginx之類的web server嗎?看看nginx反向代理能帶給我們什麼好處:

  • 負載均衡,把請求平均地分到上游的app server進程。
  • 靜態文件處理,靜態文件的訪問交給nginx來處理,降低了app server的壓力。
  • 接收完客戶端所有的TCP包,再一次交給上游的應用來處理,防止app server被慢請求干擾。
  • 訪問控制和路由重寫。
  • 強大的ngx_lua模塊。
  • Proxy cache。
  • Gzip,SSL...

爲了以後的擴展性,帶上一個nginx是有必要的,但如果你的應用沒大的需求,那麼可加可不加。

想讓nginx反向代理gunicorn,只需對nginx的配置文件加入幾行配置,讓nginx通過proxy_pass打到gunicorn監聽的端口上就可以了:

      server {
          listen 8888;

          location / {
               proxy_pass http://127.0.0.1:8000;
               proxy_redirect     off;
               proxy_set_header Host $host;
               proxy_set_header X-Real-IP $remote_addr;
               proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          }
      }

現在應用的結構是這樣的:


但僅僅是這樣還是不足以應對高併發下的請求的,洪水般的請求勢必是對數據庫的一個重大考驗,把請求數提升到1000,出現了大量了timeout:

wrk -c 1000 -t 12 -d 5s http://127.0.0.1:8888/todo
Running 5s test @ http://127.0.0.1:8888/todo
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   239.50ms  235.76ms   1.93s    91.13%
    Req/Sec    83.07     76.77   434.00     76.78%
  4548 requests in 5.10s, 6.52MB read
  Socket errors: connect 0, read 297, write 0, timeout 36
  Non-2xx or 3xx responses: 289
Requests/sec:    892.04
Transfer/sec:      1.28MB

阻止洪峯的方法有:

  • 限流(水桶算法)
  • 分流(負載均衡)
  • 緩存
  • 訪問控制

等等..這裏重點說緩存,緩存系統是每個web應用程序重要的一個模塊,緩存的作用是把熱點數據放入內存中,降低對數據庫的壓力。

下面用redis來對第一頁的數據進行緩存:

rds = redis.StrictRedis('127.0.0.1', 6379)

@app.route('/todo')
def index():
    start = request.args.get('start', '')
    start = int(start) if start.isdigit() else 0
    data = rds.get('todos')
    if data and start == 0:
        return data
    todos = db.todos.find().sort([('created_at', -1)]).limit(10).skip(start)
    data = dumps(todos)
    rds.set('todos', data, 3600)
    return data

只有在第一次請求時接觸到數據庫,其餘請求都會從緩存中讀取,瞬間就提高了應用的rps。
wrk -c 1000 -t 12 -d 5s http://127.0.0.1:8888/todo
Running 5s test @ http://127.0.0.1:8888/todo
  12 threads and 1000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    68.33ms   95.27ms   1.34s    93.69%
    Req/Sec   277.32    258.20     1.60k    77.33%
  15255 requests in 5.10s, 22.77MB read
  Socket errors: connect 0, read 382, write 0, timeout 0
  Non-2xx or 3xx responses: 207
Requests/sec:   2992.79
Transfer/sec:      4.47MB

上面的這個示例只展示了基礎的緩存方式,並沒有針對多用戶的情況處理,在涉及到狀態條件的影響下,應該使用更加複雜的緩存策略。

現在再來考慮使用緩存不當會造成幾個問題,設置緩存的時間是3600秒,當3600秒過後緩存失效,而新緩存又沒完成的中間時間內,如果有大量請求到來,就會蜂擁去查詢數據庫,這種現象稱爲緩存雪崩,針對這個情況,可以對數據庫請求這個動作進行加鎖,只允許第一個請求訪問數據庫,更新緩存後其他的請求都會訪問緩存,第二種方法是做二級緩存,拷貝緩存比一級緩存設置更長的過期時間。還有緩存穿透緩存一致性等問題雖然這裏沒有體現,但也是緩存設計中值得思考的幾個點。

下面是加入緩存後的系統結構:


目前爲止還不能說完善,如果中間某個進程掛掉了,那麼整個系統的穩定性就會土崩瓦解。爲此,要在中間加入一個進程管理工具:supervisor來監控和重啓應用進程。

首先要建立supervisor的配置文件:supervisord.conf

[program:gunicorn]
command=gunicorn --workers=9 --worker-class="meinheld.gmeinheld.MeinheldWorker" server:app --bind 127.0.0.1:8000
autostart=true
autorestart=true
stdout_logfile=access.log
stderr_logfile=error.log

然後啓動supervisord作爲後臺進程。

supervisord -c supervisord.conf

雖然緩存可以有效地幫我們減輕數據庫的壓力,但如果系統遇到大量併發的耗時任務時,進程也會阻塞在任務的處理上,影響了其他普通請求的正常響應,嚴重時,系統很可能會出現假死現象,爲了針對對耗時任務的處理,我們的應用還需要引入一個外部作業的處理系統,當程序接收到耗時任務的請求時,交給任務的工作進程池來處理,然後再通過異步回調或消息通知等方式來獲得處理結果。

應用程序與任務進程的通信通常藉助消息隊列的方式來進行通信,簡單來說,應用程序會把任務信息序列化爲一條消息(message)放入(push)與特定任務進程之間的信道里(channel),消息的中間件(broker)負責把消息持久化到存儲系統,此時任務進程再通過輪詢的方式獲取消息,處理任務,再把結果存儲和返回。

顯然,此時我們需要一個負責分發消息和與隊列打交道的調度器和一個存儲消息的中間件。

Celery是基於Python的一個分佈式的消息隊列調度系統,我們把Celery作爲消息調度器,Redis作爲消息存儲器,那麼應用看起來應該是這樣的。


一般來說,這個結構已經滿足大多數的小規模應用了,剩下做的就是代碼和組件配置的調優了。

然後還有一個很重要的點就是:測試

雖然很多人不喜歡寫測試(我也不喜歡),但良好的測試對調試和排錯是有很大幫助的。這裏指的測試不僅僅是單元測試,關於測試可以從幾個方面入手:

  • 壓力測試
    • wrk(請求)
    • htop(cpu和內存佔用)
    • dstat(硬盤讀寫)
    • tcpdump(網絡包)
    • iostat(io讀寫)
    • netstat(網絡連接)
  • 代碼測試
    • unittest(單元測試)
    • selenium(瀏覽器測試)
    • mock/stub
  • 黑盒測試
  • 功能測試
  • ...

還有另一個沒提到的點就是:安全,主要注意幾個點,其他奇形怪狀的坑根據實際情況作相應的調整:

  • SQL注入
  • XSS攻擊
  • CSRF攻擊
  • 重要信息加密
  • HTTPS
  • 防火牆
  • 訪問控制

面對系統日益複雜和增長的依賴,有什麼好的方法來保持系統的高可用和穩定一致性呢?docker是應用隔離和自動化構建的最佳選擇。docker提供了一個抽象層,虛擬化了操作系統環境,用容器技術爲應用的部署提供了沙盒環境,使應用之間能靈活地組合部署。

將每個組件獨立爲一個docker容器:

docker ps -a
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                     PORTS                                                NAMES
cdca11112543        nginx               "nginx -g 'daemon off"   2 days ago          Exited (128) 2 days ago                                                         nginx
83119f92104a        cassandra           "/docker-entrypoint.s"   2 days ago          Exited (0) 2 days ago                                                           cassandra
841bf506b6ec        mongo               "/entrypoint.sh mongo"   2 days ago          Exited (1) 2 minutes ago   0.0.0.0:27017->27017/tcp, 0.0.0.0:28017->28017/tcp   mongo
b110a4530c4a        python:latest       "python3"                2 days ago          Exited (0) 46 hours ago                                                         python
b522b2a8313b        phpfpm              "docker-php-entrypoin"   4 days ago          Exited (0) 4 days ago                                                           php-fpm
f8630d4b48d7        spotify/kafka       "supervisord -n"         2 weeks ago         Exited (0) 6 days ago                                                           kafka

關於docker的用法,可以瞭解官方文檔。

當業務逐漸快速增長時,原有的架構很可能已經不能支撐大流量帶來的訪問壓力了。

這時候就可以使用進一步的方法來優化應用:

  • 優化查詢,用數據庫工具做explain,記錄慢查詢日誌。
  • 讀寫分離,主節點負責接收寫,複製的從節點負責讀,並保持與主節點同步數據。
  • 頁面緩存,如果你的應用是面向頁面的,可以對頁面和數據片進行緩存。
  • 做冗餘表,把一些小表合併成大表以減少對數據庫的查詢次數。
  • 編寫C擴展,把性能痛點交給C來處理。
  • 提高機器配置,這也許是最簡單粗暴地提升性能的方式了...
  • PyPy

不過即使再怎麼優化,單機所能承載的壓力畢竟是有限的,這時候就要引入更多的服務器,做LVS負載均衡,提供更大的負載能力。但多機器的優化帶來了許多額外的問題,比如,機器之間怎麼共享狀態和數據,怎麼通信,怎麼保持一致性,所有的這些都迫使着原有的結構要更進一步,進化爲一個分佈式系統,使各個組件之間連接爲一個互聯網絡。

這時候你需要解決的問題就更多了:

  • 集羣的部署搭建
  • 單點問題
  • 分佈式鎖
  • 數據一致性
  • 數據可用性
  • 分區容忍性
  • 數據備份
  • 數據分片
  • 數據熱啓動
  • 數據丟失
  • 事務

分佈式的應用的結構是下面這樣的:



展開來說還有很多,服務架構,自動化運維,自動化部署,版本控制、前端,接口設計等,不過我認爲到這裏,作爲後端的基本職責就算是完成了。除了基本功,幫你走得更遠的是內功:操作系統、數據結構、計算機網絡、設計模式、數據庫這些能力能幫助你設計出更加完好的程序。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章