原文鏈接:MySQL-server-has-gone-away-in-django-ThreadPoolExecutor
MySQL server has gone away報錯
最近碰到MySQL server has gone away
的報錯,報錯出現的現象是:
- 生產環境周末到周一上午會出現一些間斷的報錯,晚些恢復正常
- 測試環境每天上午會出現間斷報錯,晚些恢復正常
- 出錯的場景都是通過
ThreadPoolExecutor
執行的異步任務中執行mysql
查詢操作,其他查詢操作正常 - 只有一部分異步任務會出現報錯,一部分執行正常
項目基本情況:基于python3.5+django1.8
,數據庫mysql
,生產和測試環境都是通過nginx+uwsgi
部署
分析原因
谷歌了一下MySQL server has gone away
問題可能的原因:
-
MySQL
服務宕了 - 連接超時
- 進程在
server
端被主動kill
-
SQL
語句太長
再結合項目實際情況逐條分析:
- 首先第一條,“
MySQL
服務宕了”,查看mysql
的報錯日志,看有沒有重啟信息,同時查看mysql
的運行時長。
mysql> show global status like 'uptime';
+---------------+----------+
| Variable_name | Value |
+---------------+----------+
| Uptime | 13881221 |
+---------------+----------+
1 row in set (0.00 sec)
報錯日志中并沒有服務重啟的信息,同時uptime
值很大,表示已經運行很長時間。因此第一條原因可以排除
- 第三條,“進程在
server
端被主動kill
”,當有長時間的慢查詢時執行kill
導致。查一下慢查詢數量。
mysql> show global status like 'com_kill';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Com_kill | 12 |
+---------------+-------+
1 row in set (0.00 sec)
Com_kill
居然有這么多-_-||,但也不確定出錯的查詢語句是否是慢查詢。找了一下報錯代碼的查詢語句,屬于索引查詢,而且查詢時間不超過100ms,因此,這一條也可以排除。
第四條,
SQL
語句太長導致。直接找到報錯的查詢語句,屬于普通查詢長度,所以這一條應該也可以排除。那么第二條,連接超時,當某個連接很長時間沒有新的請求了,達到了
mysql
服務器的超時時間,被強行關閉,再次使用該連接時,其實已經失效,就會出現"MySQL server has gone away
"的報錯了。首先看一下數據庫連接的最大超時時間設置多大。
mysql> show global variables like 'wait_timeout';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout | 86400 |
+---------------+-------+
1 row in set (0.00 sec)
目前設置的最大超時時間是24小時,也就是在這24小時內有數據庫連接超時,超時連接后面又被用到,導致報錯。
之前在django數據庫連接中分析了django
的數據庫連接是基于線程(thread.local
)創建的全局變量,即線程本地變量,下面簡稱為線程變量。
- 對于常規請求,要獲取數據庫連接時,會首先查看當前線程變量中是否有可用連接,沒有就創建并保存到線程變量中。每個
request
在開始和結束之前,會檢查當前線程的數據庫連接是否可用,并關閉不可用連接。這樣就保證了每次請求獲取到的一定是可用的數據庫連接。 - 對于基于
ThreadPoolExecutor
的異步任務,線程池是在項目啟動時創建的,當其中的線程被調起執行異步任務時,首次查詢數據庫時創建數據庫連接,后續會一直保存在該異步線程中,該線程也會一直保存在線程池中。
由于沒有像常規請求一樣的在開始和結束之前檢查數據庫連接是否可用的機制,線程池中的線程保存的數據庫連接也許是不可用的,就導致下次被調起執行數據庫操作時出現“MySQL server has gone away
”的報錯。
再結合出錯現象分析一下:
- 每到周末,生產環境活躍的用戶數量減少,尤其是涉及到異步任務的業務場景觸發減少,所以到周末線程池中的線程保存的部分數據庫連接閑置超過24小時,周一上班后用戶活躍增加,失效連接被調起,自然就會報錯了。
- 線程池中的每個線程被調起的時機和次數都不同,某些線程最近剛被調起過,某些線程長時間沒被調起,所以數據庫連接失效,因此會出現部分異步任務報錯的現象。
- 出現報錯后一段時間自己恢復正常,而
ThreadPoolExecutor
本身也沒有斷掉異常連接或者殺掉線程的機制,但是查看業務日志,發現恢復正常后執行異步任務的線程和之前報錯的線程不同,也就是基于某種機制,之前的線程被kill
了。
看一下ThreadPoolExecutor
中創建線程的邏輯:
def _adjust_thread_count(self):
# When the executor gets lost, the weakref callback will wake up
# the worker threads.
def weakref_cb(_, q=self._work_queue):
q.put(None)
# TODO(bquinlan): Should avoid creating new threads if there are more
# idle threads than items in the work queue.
if len(self._threads) < self._max_workers:
t = threading.Thread(target=_worker,
args=(weakref.ref(self, weakref_cb),
self._work_queue))
# 線程被設為守護線程
t.daemon = True
t.start()
self._threads.add(t)
_threads_queues[t] = self._work_queue
線程池中創建的線程屬于守護線程,當主線程退出,子線程也會跟著退出。而子線程是在調用submit
方法提交異步任務時,若線程池中實際線程數量小于指定數量,便會創建。因此主線程是請求線程。
在用uWSGI
部署的django
項目中,請求線程是由uWSGI
分配的。uWSGI
會根據配置文件中的process
, threads
參數決定開多少工作進程和子線程,同時還有max-requests
參數,表示為每個工作進程設置的請求數上限。
當該工作進程請求數達到這個值,就會被回收重用(重啟),其子線程也會重啟。所以上面的報錯現象中,其實是工作進程重啟了,請求子線程也會重建,導致線程池中的守護線程也會被kill
了,報錯就停止了。
總結一下原因:
- 由于
django
的數據庫連接是保存到線程本地變量中的,通過ThreadPoolExecutor
創建的線程會保存各自的數據庫連接。當連接被保存的時間超過mysql連接的最大超時時間,連接失效,但不會被線程釋放。之后再調起線程執行涉及到數據庫操作的異步任務時,會用到失效的數據庫連接,導致報錯。 - 又由于
uWSGI
的工作進程達到max-requests
數量而重啟,導致請求線程重啟,線程池中的線程是根據請求線程創建的守護線程,因此會被kill
,所以后面自己恢復正常。
解決方案
要解決這個問題,最直接的辦法是在線程池的所有異步任務中,在執行數據庫操作之前,檢查數據庫連接是否可用,然后關掉不可用連接。
from threading import local
def close_old_connections():
# 獲取當前線程本地變量
connections = local()
# 根據數據庫別名獲取數據庫連接
if hasattr(connections, 'default'):
conn = getattr(connections, 'default')
# 檢查連接可用性,并關閉不可用連接
conn.close_if_unusable_or_obsolete()
或者改寫一下django
獲取和保存數據庫連接的機制,可以創建一個全局的數據庫連接池,不管是常規請求還是異步任務,都從連接池獲取數據庫連接,由連接池保證數據庫連接的數量和可用性。
參考閱讀
MySQL server has gone away報錯原因分析
django數據庫連接
WSGI & uwsgi