Python 中的 MySQL 數據庫鏈接池

從 Java 到 Python

本文爲我和同事的共同研究成果javascript

當跨語言的時候,有些東西在一門語言中很常見,但到了另外一門語言中可能會不多見。html

例如 C# 中,常常會關注拆箱裝箱,但到了 Java 中卻發現,根本沒人關注這個。java

後來才知道,原來是由於 Java 中沒有真泛型,就算放到泛型集合中,同樣會裝箱。既然不可避免,那也就沒人去關注這塊的性能影響了。python

而 C# 中要是寫出這樣的代碼,那你明天不用來上班了。mysql

一樣的場景發生在了學習 Python 的過程當中。程序員

什麼?數據庫鏈接居然沒有鏈接池!?ajax

徹底不可理解啊,Java 中不用鏈接池對性能影響挺大的。sql

Python 程序員是由於 Python 原本就慢,而後就自暴自棄了嗎?數據庫

忽然想到一個笑話安全

問:爲何 Python 程序員不多談論內存泄漏?

答:由於 Python 重啓很快。

? 說多了都是淚,我以前排查 Java 內存泄漏的問題,超高併發的程序跑了1-2個月後就崩潰。我排查了很久,Java GC 參數也研究了不少,最後仍是經過控制變量法找到了緣由。

若是在 Python 中,多簡單的事啊,寫一個定時重啓腳本,解決…

問題來源

那本文的問題怎麼來的呢?正是我給公司的代碼加上鍊接池後產生的。一加鏈接池,就會有必定概率出現;一去掉鏈接池,就不會有。

db = get_connection()
try:
    cursor = db.cursor()
    if not cursor.execute("SELECT id FROM user WHERE name = %s", ['david']) > 0:
        return None
    return cursor.fetchone()[0]
finally:
    db.close()

一段很簡單的代碼,基本上整個項目中全部的數據庫查詢都是這麼寫的,原本沒任何問題。

但當我給底層加上鍊接池後,問題來了。

這邊後報出這樣一個異常:'NoneType' object has no attribute '__getitem__'

意思就是說cursor.fetchone() 取出來的結果是None

可是,代碼在調用以前命名已經檢查過affected rows了,根據文檔cursor.execute()返回的就是affected rows

文檔也是這麼寫的:Returns long integer rows affected, if any

解決問題第一步:網上找答案

什麼測試驅動開發,敏捷開發,我以爲都不對,一句話形容咱們那應該是:基於 Google 的 Bug 驅動開發。?

惋惜網上無任何結果,去 stackoverflow 上問也沒人知道。

感受又來到了一片無人區……

目前惟一能確認的就是和鏈接池相關了。

大體分析下應該是和鏈接複用有關,代碼沒寫好?底層鏈接池併發處理的代碼有 Bug?

先抓個詳細的異常看看吧。

解決問題第二步:分析異常日誌

咱們項目用了 Sentry,一個異常跟蹤系統。能夠把報錯時的調用堆棧和臨時變量都記錄下來。

第一個有用的信息是,咱們居然發現cursor.execute()的返回結果在 Sentry 上記錄的是18446744073709552000

這是一個很是詭異的數字,由於它接近2^64-1 (18446744073709551615),並且還比它大了一點。

網上也找不到太多相關資料,和這個數字相關的都是 Javascript 相關的問題。

由於 Javascript 中是沒法表示 2^64-1 的,相關討論:傳送門

簡單的一句話解釋就是:這個數字超過了 Javascript Integer 的最大範圍,因此底層用 Float 來表示了,因此致使丟失了精度。

但咱們的程序沒用 Javascript。到了這邊,咱們的第一反應必定是,要麼 MySQL 出了 Bug。要麼 MySQL-Python 出了 Bug。

解決問題第三步:一層層看源碼分析

先看 MySQL-Python 源碼,cursor.execute()內部調用了affected_rows()方法獲得了這個數字,而affected_rows()這個方法內部使用 C 實現了。

MySQL-Python 的 C 部分源碼很簡單,沒什麼邏輯:

return PyLong_FromUnsignedLongLong(mysql_affected_rows(&(self->connection)));

看樣子也沒什麼特別的,這裏就兩個地方可能有問題,PyLong_FromUnsignedLongLong()mysql_affected_rows()

先本身嘗試寫了一段代碼,調用PyLong_FromUnsignedLongLong()函數,發現不管如何都不會出現18446744073709552000這個數字。

而後看 MySQL 源碼,mysql_affected_rows() 返回類型是my_ulonglong,源碼中實際上是這麼定義的:

typedef unsigned long long my_ulonglong;

也就是說,在 C 代碼中,這個數字最大就是2^64-1 (18446744073709551615),不可能返回18446744073709552000的。

而後在mysql_affected_rows()官方文檔中又發現了一些有用的信息:

An integer greater than zero indicates the number of rows affected or retrieved. Zero indicates that no records were updated for an UPDATE statement, no rows matched the WHERE clause in the query or that no query has yet been executed. -1 indicates that the query returned an error or that, for a SELECT query, mysql_affected_rows() was called prior to calling mysql_store_result().

Because mysql_affected_rows() returns an unsigned value, you can check for -1 by comparing the return value to (my_ulonglong)-1 (or to (my_ulonglong)~0, which is equivalent).

好了,遇到第一個坑了,爲何 MySQL 官方文檔說這裏可能有-1,而 MySQL-Python 的文檔中卻沒說?並且返回類型是無符號的,-1就變成18446744073709551615了。

那麼若是我用if cursor.execute() > 0這種方式來判斷命中行數時,明明出錯了,我卻會獲得True的結果了。

很明顯 MySQL-Python 寫的是有問題的,同事聯繫了 MySQL-Python 的做者,做者認可了這裏的問題,把代碼修復了,下一個版本會修復。

神奇的數字

可是,看源碼發現的東西仍是沒解決咱們的問題,爲何咱們的到的數字是18446744073709552000,而不是18446744073709551615

整個調用鏈咱們都檢查過了,不可能出現這個數字。

而後一個週末,我在快睡醒的時候忽然想到了一個問題,這個數字是否是在 Python 報錯的時候,仍是18446744073709551615,而到了 Sentry 中,就變成了18446744073709552000

由於 Sentry Web 界面用的是 ajax,而 Javascript 中轉換這個數字的時候就會出錯。

最後一驗證,果真是 Sentry 的問題,Javascript 真的到處是坑。

好了,到了這一步,等 MySQL-Python 做者修復完後,咱們的代碼也就不會報錯了。問題解決?

可是,MySQL 官方卻沒有說爲何這裏會出現-1,並且爲何去掉了鏈接池就不會報錯?

就算咱們的代碼不報錯了,但若是這裏的返回數字不符合咱們預期或者說不可控的話,會致使更多隱形的數據上的問題。

Root Cause

目前爲止,依然沒找到 Root Cause。

別動,看好了,我要用壓測大法了!既然這個問題是在高併發使用鏈接池時出現的,那就壓測看看能不能重現吧。

用了一樣的代碼,10個進程,沒有 sleep。沒想到不須要一分鐘,這個問題就會馬上重現。

並且每次重現時,都會有一些 MySQL 底層的警告,說出現了錯誤的調用順序。

這時,我試了一下加了一行代碼:

db = get_connection()
cursor = None
try:
    cursor = db.cursor()
    if not cursor.execute("SELECT id FROM user WHERE name = %s", ['david']) > 0:
        return None
    return cursor.fetchone()[0]
finally:
    if cursor: # new code
        cursor.close() # new code
    db.close()

加完後就再也沒看到任何錯誤了。

嗯,這裏咱們的代碼寫的是不到位,我後來仔細看了官方教程,是有主動關閉cursor的代碼的。(偷偷告訴大家,這裏都是 CTO 之前寫的 ?)

粗略看了下cursor.close()的代碼,裏面其實就是在把未讀完的數據讀完:while self.nextset(): pass

那這裏出問題的緣由也就好理解了,高併發狀況下複用鏈接池,若是上一次請求因爲某些緣由沒有讀完全部數據,後面直接複用這個鏈接的時候,就會出現問題了。

而後,我又奇怪了,鏈接池框架在關閉鏈接的時候不該該作清理工做嗎?

Java JDBC 源碼也看過很多了,Connection關閉的時候會清理StatementStatement關閉的時候會清理ResultSet。由於單個鏈接只會在單線程中操做,是線程安全的,因此實現這樣的自動清理是很是簡單的。

之前寫 Java 中間件的時候,就老是把用戶當?,要儘可能考慮各類狀況避免內存泄漏。咱們默認都是認爲用戶是歷來不會去調用close方法的。因此經常會千方百計幫用戶去自動處理。

解決問題

最後要來解決問題了,代碼量很大,全部調用都改一遍其實也不難,由於這裏都是有規律的,正則啊腳本啊什麼的齊上陣,老是能解決的。

可是,其實也能夠像 JDBC 那樣搞自動關閉。

class AutoCloseCursorConnection(object):
    cursor = None
    conn = None

    def __init__(self, conn):
        self.conn = conn

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

    def cursor(self, *args, **kwargs):
        self.cursor = self.conn.cursor(*args, **kwargs)
        return self.cursor

    def close(self):
        if self.cursor:
            self.cursor.close()
        self.conn.close()

每次建立的鏈接包一下,就解決問題了。

源地址:http://www.dozer.cc/2016/07/mysql-connection-pool-in-python.html

相關文章
相關標籤/搜索