개요
django는 python에서 Web Application Server를 만들기 위해 사용되는 프레임 워크입니다. Web Application Server를 만들 때는 자연스레 데이터 베이스의 니즈가 생기게 되고 DataBase와의 연결 설정을 필요로 하게 됩니다. 그런데 django 관점에서는 DataBase는 외부에 존재하는 대상이기 때문에 연결을 요청하고 데이터를 송수신하는 과정에서 다양한 문제가 발생할 수 있으며 이 문제들을 잘 제어하여 Exception을 처리하게 됩니다.
이번엔 마주한 문제는 django에서 ‘MySQL Server has gone away’라는 Exception입니다. django에서 daemon을 만들어 띄워서 동작시키던 도중 일어났던 Exception이었습니다. 이 Exception을 해결하는 방법을 조사하던 도중에 MySQL의 환경변수 설정에 따라 Django가 어떻게 동작하는지가 궁금해졌습니다.
connection과 관련하여 빈번하게 마주할 수 있는 문제 3가지 정도를 실험해 보고 각각의 상황에 어떤 Exception을 일으키는지 정리해 봅니다.
테스트 환경
테스트하기 전 제가 테스트하는 데 사용했던 환경은 다음과 같습니다.
- Django == 3.2.10
- pymysql == 1.0.3
- MySQL == 5.7 (docker)
주의할 점은 pymysql을 사용한다는 점인데 위와 같은 환경을 설정한 이유는 최초에 MySQL Server Has gone Away를 마주했던 환경과 최대한 비슷한 구성을 잡으려고 하기 때문입니다.
즉, 사용 중인 django가 mysqlclient라는 라이브러리를 통해 MySQL을 사용한다면 Exception의 메시지가 다를 수 있음을 감안해야 합니다.
3가지 문제?
주관적인 견해이지만 구글링을 통해 조사한 자료를 축약했을 때 connection과 관련하여 발생할 수 있는 문제는 다음 3가지를 꼽았습니다.
1. MySQL과의 연결에 오류가 발생한 경우
2. 패킷 전송에서 문제가 발생한 경우
3. MySQL의 허용 가능한 최대 connection 넘긴 경우
위의 3가지 문제를 마주할 경우 MySQL에서 다음과 같은 변수와 관련 있음을 알 수 있었습니다.
1. MySQL의 wait_timeout이 django에 어떤 영향을 주나
2. MySQL의 max_allowed_packet이 django에 어떤 영향을 주나
3. MySQL의 max_connection이 django에 어떤 영향을 주나
1. wait_timeout
MySQL의 wait timout이란 idle 상태인 connection을 끊을 때까지 데이터베이스가 대기하는 시간을 뜻합니다. default 설정은 28800(8시간) 초로 매우 높은 값이네요. 이를 확인하는 SQL은 다음과 같습니다.
show variables where variable_name='wait_timeout';
# Result
Variable_name|Value|
-------------+-----+
wait_timeout |28800|
MySQL에서 설정한 wait_timeout이 넘어서 새로운 SQL을 요청한 경우는 어떤 Exception이 발생하는지 확인해보고자 합니다. 이 상황을 재연해 보기 위해 django view에서 다음과 같은 코드를 사용했습니다.
class MySQLWaitTimoutExceed(View):
def get(self, request):
conn = connections.create_connection('default')
cursor = conn.cursor()
cursor.execute("SELECT 1;")
cursor.execute("SET SESSION wait_timeout=1;")
time.sleep(2)
cursor.execute("SELECT 1;")
return JsonResponse(data={})
위 코드는 connection을 새로 만들어 cursor를 획득하고 간단한 SQL을 실행한 다음 MySQL의 wait_timeout을 1초로 만들고 Django에서 2초를 대기한 다음 다시 SQL을 실행합니다. 이 코드의 결과 exception은 다음과 같습니다
Internal Server Error: /api/
Traceback (most recent call last):
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 82, in _execute
return self.cursor.execute(sql)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/django/db/backends/mysql/base.py", line 73, in execute
return self.cursor.execute(query, args)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/cursors.py", line 158, in execute
result = self._query(query)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/cursors.py", line 325, in _query
conn.query(q)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 549, in query
self._affected_rows = self._read_query_result(unbuffered=unbuffered)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 779, in _read_query_result
result.read()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 1157, in read
first_packet = self.connection._read_packet()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 696, in _read_packet
packet_header = self._read_bytes(4)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 752, in _read_bytes
raise err.OperationalError(
pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query')
MySQL에서 설정한 wait_timeout이 넘어선 경우 다시 요청을 보내는 경우 pymysql은 ‘Lost Connection to MySQL server during query’ Exception을 발생합니다. wait_timeout과 관련하여 구글에서 조사하면 ‘MySQL server has gone away’라는 Exception이 발생한다고 했지만 결과가 달랐습니다.
2. max_allowed_packet
max_allowed_packet이란 환경변수는 서버로 질의하거나 받게 되는 패킷의 최대 길이를 나타내는 MySQL의 환경변수입니다. default로 설정된 크기는 약 4MB 정도네요.
show variables where variable_name = 'max_allowed_packet';
# OutPut
Variable_name |Value |
------------------+-------+
max_allowed_packet|4194304|
이제 설정된 길이를 초과해서 보내는 경우 Django에서는 어떤 Exception이 발생하는지 확인해 봅시다. 이 테스트는 비교적 가정하기 쉬운데 다음과 같이 약 4MB를 초과하는 sql문을 실행하면 됩니다.
sql = 4 * ("SELECT 1;" * 1024 * 1024)
cursor.execute(sql)
이 코드의 결과는 다음과 같은 Exception이 발생합니다.
Internal Server Error: /api/
Traceback (most recent call last):
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/connections.py", line 760, in _write_bytes
self._sock.sendall(data)
BrokenPipeError: [Errno 32] Broken pipe
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 82, in _execute
return self.cursor.execute(sql)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/django/db/backends/mysql/base.py", line 73, in execute
return self.cursor.execute(query, args)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/cursors.py", line 158, in execute
result = self._query(query)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/cursors.py", line 325, in _query
conn.query(q)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/connections.py", line 548, in query
self._execute_command(COMMAND.COM_QUERY, sql)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/connections.py", line 818, in _execute_command
self._write_bytes(packet)
File "/Users/jako/private/git-repo/django-sandbox/venv/lib/python3.8/site-packages/pymysql/connections.py", line 763, in _write_bytes
raise err.OperationalError(
pymysql.err.OperationalError: (2006, "MySQL server has gone away (BrokenPipeError(32, 'Broken pipe'))")
max_allowed_packet을 초과한 데이터를 보내는 경우 ‘MySQL server has gone away’라는 Exception을 발생시키나 봅니다.
3. max_connection
MySQL의 ‘max_connection’ 시스템 변수는 MySQL에서 동시 연결 가능한 클라이언트 수입니다. default로 151이 잡혀있네요.
show variables like '%max_connection%';
# OutPut
Variable_name |Value|
---------------+-----+
max_connections|151 |
max_connections가 151이 넘어가는 경우의 Exception을 확인하기 위해 간단히 다음과 같은 코드를 작성했습니다. 코드가 많이 축약되었지만 django view에서 실행한 코드입니다.
def task(self):
conn = connections.create_connection('default')
cursor = conn.cursor()
cursor.execute("SELECT SLEEP(2);")
def get(self, request):
import threading
for _ in range(150):
t = threading.Thread(target=self.task, name="thread-1")
t.start()
thread를 이용하여 http 요청이 발생한 경우 150번의 thread logic을 실행합니다. 동시 연결 가능한 클라이언트 수가 151이니 두 번 정도 http 요청을 하게 되면 다음과 같은 Exception이 발생합니다.
connection = Database.connect(**conn_params)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 352, in __init__
self.connect()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 635, in connect
self._get_server_information()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 1056, in _get_server_information
packet = self._read_packet()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/connections.py", line 729, in _read_packet
packet.raise_for_error()
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/protocol.py", line 221, in raise_for_error
err.raise_mysql_exception(self._data)
File "/Users/jako/private/git-repo/django-base-configure/venv/lib/python3.8/site-packages/pymysql/err.py", line 143, in raise_mysql_exception
raise errorclass(errno, errval)
django.db.utils.OperationalError: (1040, 'ny connections')
Summary
각 상황에 대해 정리해 보자면 MySQL의 시스템 변수 별로 다음과 같은 Exception이 일어나는 것을 알 수 있습니다.
- wait_timeout → (2013, 'Lost connection to MySQL server during query')
- max_allowed_packet → (2006, "MySQL server has gone away (BrokenPipeError(32, 'Broken pipe'))")
- max_connection → (1040, 'ny connections')
각 옵션에 대해 덧붙이자면..
- wait_timeout의 경우 조사하던 과정에서는 MySQL server has gone away가 발생한다는 글을 많이 접했는데 결과가 다른 건지 아니면 가정한 코드가 다른 건지 아직 갈피가 안 잡히긴 하네요
- max_allowed_packet의 경우는 4MB라면 default가 꽤 많이 잡혀있다고 생각했는데 이와 관련돼서 구글에 올라온 글들을 보니 이 정도 설정도 환경에 따라 조절할 필요가 있어 보입니다.
- max_connection의 경우는 connection을 무작정 많이 잡는 것도 그렇다고 적게 잡는다고 이상할 것 같습니다. 최적의 connection 수를 찾아내는 튜닝 방법에 대해 더 조사를 해봐야겠네요.
해당 내용을 조사하면서 각 옵션을 어떻게 튜닝하면 좋을지에 대해도 알아보면 좋을 것 같다는 생각이 듭니다. 이는 MySQL 튜닝과 관련된 주제이므로 MySQL을 튜닝하는 방법도 잘 정리해 둬야겠습니다.
'Frame Work > Django' 카테고리의 다른 글
[Django] show_urls 흉내내기 (0) | 2023.04.09 |
---|---|
Django project를 src layout으로 구성하기 (0) | 2023.01.30 |
[Django] auth_group 다루기 (0) | 2022.12.18 |
uWSGI Socket + Nginx + Docker (0) | 2022.11.10 |
uWSGI를 알아보자 (0) | 2022.11.05 |