Django中實現數據庫連接池(基於二)

數據庫長連接

長連接是指程序之間的連接在建立之後,就一直打開,被後續程序重用。使用長連接的初衷是減少連接的開銷。
先看看官方文檔是怎麼講Django長連接的。翻譯得可能不太得體,原文參見Django databases

Django長連接

長連接(Persistent connections)是爲了避免在每個請求中都重新建立數據庫連接的開銷。在Django中,數據庫連接由CONN_MAX_AGE控制,這個參數定義了每個連接的最長壽命。可以爲每個DB單獨設置CONN_MAX_AGE

CONN_MAX_AGE的默認值是0,在每個請求結束時,將關閉數據庫連接。要啓用長連接,請將CONN_MAX_AGE設置爲正數秒。對於不限時的長連接,請將其設置爲None

連接管理

Django在首次進行數據庫查詢時會建立與數據庫的連接。它保持此連接打開,並在後續請求中重用它。連接一旦超過CONN_MAX_AGE定義的最大壽命或不再可用,Django就會關閉連接。

具體來講,沒有連接分兩種情況:1、這是第一個連接,2、或者先前的連接已關閉。如果Django沒有連接,它會在需要時自動建立與數據庫的連接。

在每個請求開始時,Django會關閉那些達到其最大壽命的連接。如果你的數據庫在一段時間後會關閉空閒連接,則應將CONN_MAX_AGE設置爲較小的值,以便Django不會試圖使用已由DB終止的連接。(這個問題可能只會影響流量非常低的網站。)

在每個請求結束時,Django會在連接達到最大壽命或者處於不可恢復的錯誤狀態時關閉連接。如果在處理請求時發生任何數據庫錯誤,Django會檢查連接是否仍然有效,如果沒有則關閉它。因此,數據庫錯誤最多隻影響一個請求;如果DB連接變得不可用,則下一個請求將獲取新連接。

注意

由於每個線程都維護自己的連接,因此你的數據庫必須至少支持與工作線程一樣多的併發連接。

有時,大多數views都不會訪問數據庫,例如,因爲它是外部系統的數據庫,或者歸功於緩存。在這種情況下,應該將CONN_MAX_AGE設置爲較小的值甚至0,因爲維護不太可能重用的連接沒有意義。這將有助於將與數據庫的併發連接數量保持在較小的值。

開發模式的Server爲它處理的每個請求創建一個新線程,無視長連接的作用。在開發過程中不需要啓用長連接。

當Django建立與數據庫的連接時,它會根據所使用的後端設置恰當的參數。如果啓用長連接,則不會再對每個請求重複設置。如果修改連接的隔離級別或時區等參數,則應在每個請求結束時恢復Django的默認值,在每個請求開始時強制使用適當的值,或者禁用長連接。

數據庫連接池

爲啥Django不支持連接池

我們看看Google Group裏各路大神的討論吧。

  • 第三方工具已經提供了,更專注做得更好。Django並不需要做全棧。
  • 用從pool裏取連接代替新建連接,向pool歸還連接代替關閉連接,然後在worker在整個請求期間都持有連接並不是真正的連接池。這需要跟worker數一樣多的數據庫連接,除了能在各個worker循環使用外,基本跟長連接是等效的。長連接也有自己的優點,消除了新建連接的開銷,避免的池化的複雜性,適用於不需要手動管理事務的中小型站點。
  • 首先要操心的不是數據庫,AWS之類的雲計算已經很牛了,按需擴容,多關注下緩存吧。
  • MySQL的連接非常輕量和高效,大量的Web應用都沒有使用連接池。
  • ……

連接池的優點

數據庫連接池(Connection pooling)的核心思想是連接複用,通過建立一個數據庫連接池以及一套連接使用、分配和管理策略,使得該連接池中的連接可以得到高效、安全的複用。主要有以下優勢:

  1. 減少資源開銷:減少連接的創建,避免了數據庫連接初始化和釋放過程的時間和資源開銷,加快系統的響應速度
  2. 統一連接管理:預先設定超時時間、連接數量,避免數據庫連接操作中可能出現的資源泄露,增強系統的穩定性

其實,我們在連接池裏建立的連接生存週期也可以比較長,這樣能充分利用長連接的優點減少連接的開銷;同時,連接池又可以幫助我們更方便管理DB連接,減少後端服務到MySQL的連接數。

爲何要在Django中用連接池

說了這麼多,有兩點是確定的:

  1. Django原生支持長連接,但不支持連接池
  2. 連接池還是有很多優點的,也有很多成熟的三方庫支持

Django服務,一般情況下每個線程都維護自己的連接,有多少線程就會就有多少連接;如果採用分佈式部署,線程數較多,則會建立較多的連接。不僅非常消耗資源,還可能出現MySQL連接數不夠用的情況。

舉個例子:5個子服務 * 50個容器 * 10個進程 * 20個線程 = 50000個連接(Mysql5.5,Mysql5.6,Mysql5.7:默認的最大連接數都是151,理論上限爲:100000;實際幹到2W就很不容易了)。

假設我們能讓20個線程,複用一個size=10的連接池,這樣就能減少一半的數據庫連接。這對支持服務水平擴展,降低數據庫負載是非常有幫助的。那麼我們接下來看看,如何在Django中用上DB連接池。

Django連接池方案

SQLAlchemy Patch

SQLAlchemy有一個成熟的連接池實現,支持Django使用連接池,首先考慮的就是SQLAlchemy

Github上的輪子:

從源碼看,patch主要做了三件事情:

  1. 創建和返回新建的SQLAlchemy pool
  2. connection pool取connect
  3. hack掉Django自己的connect方法

所以,patch主要在是connect()方法上做了文章,實際使用的依然是Django的ORM,而不是SQLAlchemy的ORM。知道了原理,那我們可以對照着自己造個輪子,動手擼一個簡化版本的連接池patch吧。

自己造輪子

新建一個db_pool_patch.py

# -*- coding: utf-8 -*-

from django.conf import settings
from sqlalchemy.pool import manage

POOL_PESSIMISTIC_MODE = getattr(settings, "DJ_ORM_POOL_PESSIMISTIC", False)
POOL_SETTINGS = getattr(settings, 'DJ_ORM_POOL_OPTIONS', {})
POOL_SETTINGS.setdefault("recycle", 3600)


def is_iterable(value):
    """Check if value is iterable."""
    try:
        _ = iter(value)
        return True
    except TypeError:
        return False


class HashableDict(dict):
    def __hash__(self):
        items = [(n, tuple(v)) for n, v in self.items() if is_iterable(v)]
        return hash(tuple(items))


class ManagerProxy(object):
    def __init__(self, manager):
        self.manager = manager

    def __getattr__(self, key):
        return getattr(self.manager, key)

    def connect(self, *args, **kwargs):
        if 'conv' in kwargs:
            kwargs['conv'] = HashableDict(kwargs['conv'])

        if 'ssl' in kwargs:
            kwargs['ssl'] = HashableDict(kwargs['ssl'])

        return self.manager.connect(*args, **kwargs)


def patch_mysql():
    from django.db.backends.mysql import base as mysql_base

    if not hasattr(mysql_base, "_Database"):
        mysql_base._Database = mysql_base.Database
        manager = manage(mysql_base._Database, **POOL_SETTINGS)
        mysql_base.Database = ManagerProxy(manager)


def patch_sqlite3():
    from django.db.backends.sqlite3 import base as sqlite3_base

    if not hasattr(sqlite3_base, "_Database"):
        sqlite3_base._Database = sqlite3_base.Database
        sqlite3_base.Database = manage(sqlite3_base._Database, **POOL_SETTINGS)


def install_patch():
    patch_mysql()
    patch_sqlite3()

這裏,我們用不到60行代碼,就實現了對mysqlsqlite3的patch。

爲了方便跟蹤connection pool的工作情況,還可以建一個db_pool_listen.py添加一些監聽函數並打印log。

# -*- coding: utf-8 -*-

from django.conf import settings
from sqlalchemy import event, exc
from sqlalchemy.pool import Pool

from log import logger


@event.listens_for(Pool, "checkout")
def _on_checkout(dbapi_connection, connection_record, connection_proxy):
    logger.debug("connection retrieved from pool")

    if settings.POOL_PESSIMISTIC_MODE:
        cursor = dbapi_connection.cursor()
        try:
            cursor.execute("SELECT 1")
        except:
            # raise DisconnectionError - pool will try
            # connecting again up to three times before raising.
            raise exc.DisconnectionError()
        finally:
            cursor.close()


@event.listens_for(Pool, "checkin")
def _on_checkin(*args, **kwargs):
    logger.debug("connection returned to pool")


@event.listens_for(Pool, "connect")
def _on_connect(*args, **kwargs):
    logger.debug("connection created")

settings.py中配置一下pool的參數:

DJ_ORM_POOL_OPTIONS = {
    "pool_size": 20,
    "max_overflow": 0,
    "recycle": 3600,  # the default value
}
POOL_PESSIMISTIC_MODE = True

在Django項目的wsgi.py中安裝我們新建的patch:

import db_pool_patch
db_pool_patch.install_patch()

if settings.DEBUG:
    import db_pool_listen

application = get_wsgi_application()

測試結果及改進

然後訪問接口http://127.0.0.1:8000/book/get?book_id=1,就可以觀察到下面的log啦。

2019-08-29 22:17:10,140 db_pool_patch - db_pool_listen.py - DEBUG - connection retrieved from pool
(0.000) SELECT "book_tab"."book_id", "book_tab"."title", "book_tab"."author" FROM "book_tab" WHERE "book_tab"."book_id" = 1; args=(1,)
2019-08-29 22:17:10,141 db_pool_patch - views.py - INFO - some shit code begin to run ...
2019-08-29 22:17:15,144 db_pool_patch - views.py - INFO - hmm, shit code finished, it took 5 seconds
[29/Aug/2019 22:17:15] "GET /book/get?book_id=1 HTTP/1.1" 200 62
2019-08-29 22:17:15,146 db_pool_patch - db_pool_listen.py - DEBUG - connection returned to pool

nice, 我們的連接池已經可以正常工作了。但是從log裏可以看出,我們的patch有個問題,在整個請求處理期間一直持有連接,容易導致pool裏的連接不夠用;實際上一查完數據,就不需要繼續持有DB連接了。

def test_request(request):
    obj = TestModel.objects.get(id=1)
    do_something_that_takes_1000_seconds(obj)  # 這時DB連接依然被佔用,但實際已經不需要了

如前文提到的那樣:

用從pool裏取連接代替新建連接,向pool歸還連接代替關閉連接,然後在worker在整個請求期間都持有連接並算不上真正的連接池。這需要跟worker數一樣多的數據庫連接,除了能在各個worker循環使用外,基本跟長連接是等效的。

那麼,我們怎麼解決這個問題呢?在訪問完數據庫後,馬上歸還連接,而不是在請求完成時再歸還。因爲我們使用的依然是Django ORM,在請求期間持有連接本質上是Django ORM的行爲。所以可以patch一下Django ORM,執行完數據庫操作後,及時釋放連接。

from django.db.models.sql import compiler
from django.db.models.sql.constants import MULTI


def install_django_orm_patch():
    execute_sql = compiler.SQLCompiler.execute_sql

    # Django 1.11
    def patched_execute_sql(self, result_type=MULTI, chunked_fetch=False):
        result = execute_sql(self, result_type, chunked_fetch)
        if not self.connection.in_atomic_block:
            self.connection.close()  # return connection to pool by db_pool_patch
        return result

    compiler.SQLCompiler.execute_sql = patched_execute_sql

    insert_execute_sql = compiler.SQLInsertCompiler.execute_sql

    def patched_insert_execute_sql(self, return_id=False):
        result = insert_execute_sql(self, return_id)
        if not self.connection.in_atomic_block:
            self.connection.close()  # return connection to pool by db_pool_patch
        return result

    compiler.SQLInsertCompiler.execute_sql = patched_insert_execute_sql

再次訪問接口,打印的log如下:

2019-08-29 22:13:22,373 db_pool_patch - db_pool_listen.py - DEBUG - connection retrieved from pool
(0.000) SELECT "book_tab"."book_id", "book_tab"."title", "book_tab"."author" FROM "book_tab" WHERE "book_tab"."book_id" = 1; args=(1,)
2019-08-29 22:13:22,374 db_pool_patch - db_pool_listen.py - DEBUG - connection returned to pool
2019-08-29 22:13:22,374 db_pool_patch - views.py - INFO - some shit code begin to run ...
2019-08-29 22:13:27,378 db_pool_patch - views.py - INFO - hmm, shit code finished, it took 5 seconds
[29/Aug/2019 22:13:27] "GET /book/get?book_id=1 HTTP/1.1" 200 62

不錯,可以看出,DB連接在shit code開始運行前,就已經歸還給連接池了;這樣就降低了單個連接的持有時間,能顯著提高連接的使用率和pool的性能。

小結

本文主要介紹了Django長連接,比較了長連接和連接池,實現了基於SQLAlchemy Patch的Django數據庫連接池方案,並做了一定的改進。

 

本文轉自:https://lockshell.com/2019/08/28/django-db-connection-pool/

非常感謝原作者!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章