帶你進入資料庫連線池

VincentZ發表於2019-03-04
[原文連結](https://mp.weixin.qq.com/s/7wT_mw4uC0GuhhsJJIV0Pg)複製程式碼

概述

連線池的作用就是為了提高效能,將已經建立好的連線儲存在池中,當有請求來時,直接使用已經建立好的連線對Server端進行訪問。這樣省略了建立連線和銷燬連線的過程(TCP連線建立時的三次握手和銷燬時的四次握手),從而在效能上得到了提高。
連線池設計的基本原理是這樣的:
(1)建立連線池物件(服務啟動)。
(2)按照事先指定的引數建立初始數量的連線(即:空閒連線數)。
(3)對於一個訪問請求,直接從連線池中得到一個連線。如果連線池物件中沒有空閒的連線,且連線數沒有達到最大(即:最大活躍連線數),建立一個新的連線;如果達到最大,則設定一定的超時時間,來獲取連線。
(4)運用連線訪問服務。
(5)訪問服務完成,釋放連線(此時的釋放連線,並非真正關閉,而是將其放入空閒佇列中。如實際空閒連線數大於初始空閒連線數則釋放連線)。
(6)釋放連線池物件(服務停止、維護期間,釋放連線池物件,並釋放所有連線)。

說的通俗點,可以把連線池理解為一個一個的管道,在管道空閒時,便可以取出使用;同時,也可以鋪設新的管道(當然不能超過最大連線數的限制)。使用完之後,管道就變為空閒了。

通常比較常用的連線池是資料庫連線池,HTTP Client連線池,我也自己編寫過連線池,如Thrift連線池及插入Rabbitmq佇列的連線池。

下面分析三個典型的連線池的設計。

資料庫連線池

首先剖析一下資料庫連線池的設計與實現的原理。DBUtils 屬於資料庫連線池實現模組,用於連線DB-API 2模組,對資料庫連線執行緒化,使可以安全和高效的訪問資料庫的模組。本文主要分析一下PooledDB的流程。

DBUtils.PooledDB使用DB-API 2模組實現了一個強硬的、執行緒安全的、有快取的、可複用的資料庫連線。

如下圖展示了使用PooledDB時的工作流程:

本文主要考慮dedicated connections,即專用資料庫連線,在初始化時連線池時,就需要指定mincached、maxcached以及maxconnections等引數,分別表示連線池的最小連線數、連線池的最大連線數以及系統可用的最大連線數,同時,blocking參數列徵了當獲取不到連線的時候是阻塞等待獲取連線還是返回異常:

if not blocking:
    def wait():
        raise TooManyConnections
    self._condition.wait = wait複製程式碼

在連線池初始化時,就會建立mincached個連線,程式碼如下:

# Establish an initial number of idle database connections:
idle = [self.dedicated_connection() for i in range(mincached)]
while idle:
    idle.pop().close()複製程式碼

裡面有close方法,看一下連線close方法的實現:

def close(self):
    """Close the pooled dedicated connection."""
    # Instead of actually closing the connection,
    # return it to the pool for future reuse.
    if self._con:
        self._pool.cache(self._con)
        self._con = None複製程式碼

主要是實現了cache方法,看一下具體程式碼:

def cache(self, con):
    """Put a dedicated connection back into the idle cache."""
    self._condition.acquire()
    try:
        if not self._maxcached or len(self._idle_cache) < self._maxcached:
            con._reset(force=self._reset) # rollback possible transaction
            # the idle cache is not full, so put it there
            self._idle_cache.append(con) # append it to the idle cache
        else: # if the idle cache is already full,
            con.close() # then close the connection
        self._connections -= 1
        self._condition.notify()
    finally:
        self._condition.release()複製程式碼

由上述程式碼可見,close並不是把連線關閉,而是在連線池的數目小於maxcached的時候,將連線放回連線池,而大於此值時,關閉該連線。同時可以注意到,在放回連線池之前,需要將事務進行回滾,避免在使用連線池的時候有存活的事務沒有提交。這可以保證進入連線池的連線都是可用的。

而獲取連線的過程正如之前討論的,先從連線池中獲取連線,如果獲取連線失敗,則新建立連線:

# try to get a dedicated connection
    self._condition.acquire()
    try:
        while (self._maxconnections
                and self._connections >= self._maxconnections):
            self._condition.wait()
        # connection limit not reached, get a dedicated connection
        try: # first try to get it from the idle cache
            con = self._idle_cache.pop(0)
        except IndexError: # else get a fresh connection
            con = self.steady_connection()
        else:
            con._ping_check() # check connection
        con = PooledDedicatedDBConnection(self, con)
        self._connections += 1
    finally:
        self._condition.release()複製程式碼

關閉連線正如剛剛建立mincached個連線後關閉連線的流程,在連線池的數目小於maxcached的時候,將連線放回連線池,而大於此值時,關閉該連線。

RabbitMQ佇列插入訊息連線池

非同步訊息傳遞是高併發系統常用的一種技術手段。而這其中就少不了訊息佇列。頻繁的向訊息佇列裡面插入訊息,建立連線釋放連線會是比較大的開銷。所以,可以使用連線池來提高系統效能。

連線池的設計實現如下:

在獲取連線的時候,先從佇列裡面獲取連線,如果獲取不到,則新建立一個連線,如果不能新建立連線,則根據超時時間,阻塞等待從佇列裡面獲取連結。如果沒成功,則做最後的嘗試,重新建立連線。程式碼實現如下:

def get_connection_pipe(self):
        """
        獲取連線
        :return:
        """
        try:
            connection_pipe = self._queue.get(False)
        except Queue.Empty:
            try:
                connection_pipe = self.get_new_connection_pipe()
            except GetConnectionException:
                timeout = self.timeout
                try:
                    connection_pipe = self._queue.get(timeout=timeout)
                except Queue.Empty:
                    try:
                        connection_pipe = self.get_new_connection_pipe()
                    except GetConnectionException:
                        logging.error("Too much connections, Get Connection Timeout!")
        if (time.time() - connection_pipe.use_time) > self.disable_time:
            self.close(connection_pipe)
            return self.get_connection_pipe()
        return connection_pipe複製程式碼

一個RabbitMQ插入訊息佇列的完整連線池設計如下:

# coding:utf-8
import logging
import threading
import Queue
from kombu import Connection
import time

class InsertQueue():
    def __init__(self, host=None, port=None, virtual_host=None, heartbeat_interval=3, name=None, password=None,
                 logger=None, maxIdle=10, maxActive=50, timeout=30, disable_time=20):
        """
        :param str host: Hostname or IP Address to connect to
        :param int port: TCP port to connect to
        :param str virtual_host: RabbitMQ virtual host to use
        :param int heartbeat_interval:  How often to send heartbeats
        :param str name: auth credentials name
        :param str password: auth credentials password
        """
        self.logger = logging if logger is None else logger
        self.host = host
        self.port = port
        self.virtual_host = virtual_host
        self.heartbeat_interval = heartbeat_interval
        self.name = name
        self.password = password
        self.mutex = threading.RLock()
        self.maxIdle = maxIdle
        self.maxActive = maxActive
        self.available = self.maxActive
        self.timeout = timeout
        self._queue = Queue.Queue(maxsize=self.maxIdle)
        self.disable_time = disable_time

    def get_new_connection_pipe(self):
        """
        產生新的佇列連線
        :return:
        """

        with self.mutex:
            if self.available <= 0:
                raise GetConnectionException
            self.available -= 1
        try:

            conn = Connection(hostname=self.host,
                              port=self.port,
                              virtual_host=self.virtual_host,
                              heartbeat=self.heartbeat_interval,
                              userid=self.name,
                              password=self.password)
            producer = conn.Producer()

            return ConnectionPipe(conn, producer)
        except:
            with self.mutex:
                self.available += 1
            raise GetConnectionException

    def get_connection_pipe(self):
        """
        獲取連線
        :return:
        """
        try:
            connection_pipe = self._queue.get(False)
        except Queue.Empty:
            try:
                connection_pipe = self.get_new_connection_pipe()
            except GetConnectionException:
                timeout = self.timeout
                try:
                    connection_pipe = self._queue.get(timeout=timeout)
                except Queue.Empty:
                    try:
                        connection_pipe = self.get_new_connection_pipe()
                    except GetConnectionException:
                        logging.error("Too much connections, Get Connection Timeout!")
        if (time.time() - connection_pipe.use_time) > self.disable_time:
            self.close(connection_pipe)
            return self.get_connection_pipe()
        return connection_pipe

    def close(self, connection_pipe):
        """
        close the connection and the correlative channel
        :param connection_pipe:
        :return:
        """
        with self.mutex:
            self.available += 1
            connection_pipe.close()
        return

    def insert_message(self, exchange=None, body=None, routing_key='', mandatory=True):
        """
        insert message to queue
        :param str exchange: exchange name
        :param str body: message
        :param str routing_key: routing key
        :param bool mandatory: is confirm: True means confirm, False means not confirm
        :return:
        """

        put_into_queue_flag = True
        insert_result = False
        connection_pipe = None
        try:

            connection_pipe = self.get_connection_pipe()
            producer = connection_pipe.channel
            use_time = time.time()
            producer.publish(exchange=exchange,
                                             body=body,
                                             delivery_mode=2,
                                             routing_key=routing_key,
                                             mandatory=mandatory
                                             )
            insert_result = True

        except Exception:
            insert_result = False
            put_into_queue_flag = False
        finally:

            if put_into_queue_flag is True:
                try:
                    connection_pipe.use_time = use_time
                    self._queue.put_nowait(connection_pipe)
                except Queue.Full:
                    self.close(connection_pipe)
            else:
                if connection_pipe is not None:
                    self.close(connection_pipe)

        return insert_result

class ConnectionPipe(object):
    """
    connection和channel物件的封裝
    """

    def __init__(self, connection, channel):
        self.connection = connection
        self.channel = channel
        self.use_time = time.time()

    def close(self):
        try:
            self.connection.close()
        except Exception as ex:
            pass

class GetConnectionException():
    """
    獲取連線異常
    """
    pass複製程式碼

Thrift連線池

Thrift是什麼呢?簡而言之,Thrift定義一個簡單的檔案,包含資料型別和服務介面,以作為輸入檔案,編譯器生成程式碼用來方便地生成RPC客戶端和伺服器通訊的方式。實際上就是一種遠端呼叫的方式,因為協議棧為TCP層,所以相對於HTTP層效率會更高。

Thrift連線池的設計同資料庫連線池類似,流程圖如下:

思路依舊是,在獲取連線時,先從連線池中獲取連線,若池中無連線,則判斷是否可以新建連線,若不能新建連線,則阻塞等待連線。

在從池中獲取不到佇列的時候的處理方式,本設計處理方式為:當獲取不到連線時,將這部分請求放入一個等待佇列,等待獲取連線;而當關閉連線放回連線池時,優先判斷這個佇列是否有等待獲取連線的請求,若有,則優先分配給這些請求。

獲取不到連線時處理程式碼如下,將請求放入一個佇列進行阻塞等待獲取連線:

async_result = AsyncResult()
self.no_client_queue.appendleft(async_result)
client = async_result.get()  # blocking複製程式碼

而當有連線釋放需要放回連線池時,需要優先考慮這部分請求,程式碼如下:

def put_back_connections(self, client):
    """
    執行緒安全
    將連線放回連線池,邏輯如下:
    1、如果有請求尚未獲取到連線,請求優先
    2、如果連線池中的連線的數目小於maxIdle,則將該連線放回連線池
    3、關閉連線
    :param client:
    :return:
    """
    with self.lock:
        if self.no_client_queue.__len__() > 0:
            task = self.no_client_queue.pop()
            task.set(client)
        elif self.connections.__len__() < self.maxIdle:
            self.connections.add(client)
        else:
            client.close()
            self.pool_size -= 1複製程式碼

最後,基於thrift連線池,介紹一個簡單的服務化框架的實現。

服務化框架分為兩部分:RPC、註冊中心。
1、RPC:遠端呼叫,遠端呼叫的傳輸協議有很多種,可以走http、Webservice、TCP等。Thrift也是世界上主流的RPC框架。其重點在於安全、快速、最好能跨語言。
2、註冊中心:用於存放,服務的IP地址和埠資訊等。比較好的存放服務資訊的方案有:Zookeeper、Redis等。其重點在於避免單點問題,並且好維護。

通常的架構圖為:

通過Thrift連線池作為客戶端,而Zookeeper作為註冊中心,設計服務框架。具體就是服務端在啟動服務的時候到Zookeeper進行註冊,而客戶端在啟動的時候通過Zookeeper發現服務端的IP和埠,通過Thrift連線池輪詢建立連線訪問服務端的服務。

具體設計的程式碼如下,程式碼有點長,細細研讀一定有所收穫的:

# coding: utf-8

import threading
from collections import deque
import logging
import socket
import time
from kazoo.client import KazooClient
from thriftpy.protocol import TBinaryProtocolFactory
from thriftpy.transport import (
    TBufferedTransportFactory,
    TSocket,
)
from gevent.event import AsyncResult
from gevent import Timeout

from error import CTECThriftClientError
from thriftpy.thrift import TClient
from thriftpy.transport import TTransportException

class ClientPool:
    def __init__(self, service, server_hosts=None, zk_path=None, zk_hosts=None, logger=None, max_renew_times=3, maxActive=20,
                 maxIdle=10, get_connection_timeout=30, socket_timeout=30, disable_time=3):
        """
        :param service: Thrift的Service名稱
        :param server_hosts: 服務提供者地址,陣列型別,['ip:port','ip:port']
        :param zk_path: 服務提供者在zookeeper中的路徑
        :param zk_hosts: zookeeper的host地址,多個請用逗號隔開
        :param max_renew_times: 最大重連次數
        :param maxActive: 最大連線數
        :param maxIdle: 最大空閒連線數
        :param get_connection_timeout:獲取連線的超時時間
        :param socket_timeout: 讀取資料的超時時間
        :param disable_time: 連線失效時間
        """
        # 負載均衡佇列
        self.load_balance_queue = deque()
        self.service = service
        self.lock = threading.RLock()
        self.max_renew_times = max_renew_times
        self.maxActive = maxActive
        self.maxIdle = maxIdle
        self.connections = set()
        self.pool_size = 0
        self.get_connection_timeout = get_connection_timeout
        self.no_client_queue = deque()
        self.socket_timeout = socket_timeout
        self.disable_time = disable_time
        self.logger = logging if logger is None else logger

        if zk_hosts:
            self.kazoo_client = KazooClient(hosts=zk_hosts)
            self.kazoo_client.start()
            self.zk_path = zk_path
            self.zk_hosts = zk_hosts
            # 定義Watcher
            self.kazoo_client.ChildrenWatch(path=self.zk_path,
                                            func=self.watcher)
            # 重新整理連線池中的連線物件
            self.__refresh_thrift_connections(self.kazoo_client.get_children(self.zk_path))
        elif server_hosts:
            self.server_hosts = server_hosts
            # 複製新的IP地址到負載均衡佇列中
            self.load_balance_queue.extendleft(self.server_hosts)
        else:
            raise CTECThriftClientError('沒有指定伺服器獲取方式!')

    def get_new_client(self):
        """
        輪詢在每個ip:port的連線池中獲取連線(執行緒安全)
        從當前佇列右側取出ip:port資訊,獲取client
        將連線池物件放回到當前佇列的左側
        請求或連線超時時間,預設30秒
        :return:
        """
        with self.lock:
            if self.pool_size < self.maxActive:
                try:
                    ip = self.load_balance_queue.pop()
                except IndexError:
                    raise CTECThriftClientError('沒有可用的服務提供者列表!')
                if ip:
                    self.load_balance_queue.appendleft(ip)
                    # 建立新的thrift client
                    t_socket = TSocket(ip.split(':')[0], int(ip.split(':')[1]),
                                       socket_timeout=1000 * self.socket_timeout)
                    proto_factory = TBinaryProtocolFactory()
                    trans_factory = TBufferedTransportFactory()
                    transport = trans_factory.get_transport(t_socket)
                    protocol = proto_factory.get_protocol(transport)
                    transport.open()
                    client = TClient(self.service, protocol)
                    self.pool_size += 1
                return client
            else:
                return None

    def close(self):
        """
        關閉所有連線池和zk客戶端
        :return:
        """
        if getattr(self, 'kazoo_client', None):
            self.kazoo_client.stop()

    def watcher(self, children):
        """
        zk的watcher方法,負責檢測zk的變化,重新整理當前雙端佇列中的連線池
        :param children: 子節點,即服務提供方的列表
        :return:
        """
        self.__refresh_thrift_connections(children)

    def __refresh_thrift_connections(self, children):
        """
        重新整理服務提供者在當前佇列中的連線池資訊(執行緒安全),主要用於zk重新整理
        :param children:
        :return:
        """
        with self.lock:
            # 清空負載均衡佇列
            self.load_balance_queue.clear()
            # 清空連線池
            self.connections.clear()
            # 複製新的IP地址到負載均衡佇列中
            self.load_balance_queue.extendleft(children)

    def __getattr__(self, name):
        """
        函式呼叫,最大重試次數為max_renew_times
        :param name:
        :return:
        """

        def method(*args, **kwds):

            # 從連線池獲取連線
            client = self.get_client_from_pool()

            # 連線池中無連線
            if client is None:
                # 設定獲取連線的超時時間
                time_out = Timeout(self.get_connection_timeout)
                time_out.start()
                try:
                    async_result = AsyncResult()
                    self.no_client_queue.appendleft(async_result)
                    client = async_result.get()  # blocking
                except:
                    with self.lock:
                        if client is None:
                            self.no_client_queue.remove(async_result)
                            self.logger.error("Get Connection Timeout!")
                finally:
                    time_out.cancel()

            if client is not None:

                for i in xrange(self.max_renew_times):

                    try:
                        put_back_flag = True
                        client.last_use_time = time.time()
                        fun = getattr(client, name, None)
                        return fun(*args, **kwds)
                    except socket.timeout:
                        self.logger.error("Socket Timeout!")
                        # 關閉連線,不關閉會導致亂序
                        put_back_flag = False
                        self.close_one_client(client)
                        break

                    except TTransportException, e:
                        put_back_flag = False

                        if e.type == TTransportException.END_OF_FILE:
                            self.logger.warning("Socket Connection Reset Error,%s", e)
                            with self.lock:
                                client.close()
                                self.pool_size -= 1
                                client = self.get_new_client()
                        else:
                            self.logger.error("Socket Error,%s", e)
                            self.close_one_client(client)
                            break

                    except socket.error, e:
                        put_back_flag = False
                        if e.errno == socket.errno.ECONNABORTED:
                            self.logger.warning("Socket Connection aborted Error,%s", e)
                            with self.lock:
                                client.close()
                                self.pool_size -= 1
                                client = self.get_new_client()
                        else:
                            self.logger.error("Socket Error, %s", e)
                            self.close_one_client(client)
                            break

                    except Exception as e:
                        put_back_flag = False

                        self.logger.error("Thrift Error, %s", e)
                        self.close_one_client(client)
                        break

                    finally:
                        # 將連線放回連線池
                        if put_back_flag is True:
                            self.put_back_connections(client)
            return None

        return method

    def close_one_client(self, client):
        """
        執行緒安全
        關閉連線
        :param client:
        :return:
        """
        with self.lock:
            client.close()
            self.pool_size -= 1

    def put_back_connections(self, client):
        """
        執行緒安全
        將連線放回連線池,邏輯如下:
        1、如果有請求尚未獲取到連線,請求優先
        2、如果連線池中的連線的數目小於maxIdle,則將該連線放回連線池
        3、關閉連線
        :param client:
        :return:
        """
        with self.lock:
            if self.no_client_queue.__len__() > 0:
                task = self.no_client_queue.pop()
                task.set(client)
            elif self.connections.__len__() < self.maxIdle:
                self.connections.add(client)
            else:
                client.close()
                self.pool_size -= 1

    def get_client_from_pool(self):
        """
        執行緒安全
        從連線池中獲取連線,若連線池中有連線,直接取出,否則,
        新建一個連線,若一直無法獲取連線,則返回None
        :return:
        """
        client = self.get_one_client_from_pool()

        if client is not None and (time.time() - client.last_use_time) < self.disable_time:
            return client
        else:
            if client is not None:
                self.close_one_client(client)

        client = self.get_new_client()
        if client is not None:
            return client

        return None

    def get_one_client_from_pool(self):
        """
        執行緒安全
        從連線池中獲取一個連線,若取不到連線,則返回None
        :return:
        """
        with self.lock:
            if self.connections:
                try:
                    return self.connections.pop()
                except KeyError:
                    return None
            return None複製程式碼

相關文章