간만에 django랑 여타 패키지들 최신 버전으로 프로젝트를 하는데, 예전에는 없던 현상이

 
박영록

간만에 django랑 여타 패키지들 최신 버전으로 프로젝트를 하는데, 예전에는 없던 현상이 있더군요. uwsgi에 멀티 프로세스로 돌릴 때, 폼 서밋해서 데이터 insert하고 바로 select해서 보면 방금 서밋한 모델이 select가 안되는 겁니다. 꼭 한 번 더 페이지 리프레시를 해줘야 데이터를 제대로 읽어오더군요. 로컬 개발 환경에서는 잘 되는 너무 기본적인 동작이 서버에서 안되니까 너무 황당해서 좀 파 봤는데요. 여러 가지 요인들이 얽혀서 발생했더군요.

우선, django의 autocommit 설정이 좀 괴이하더군요. 전 그냥 mysql 연결하면 mysql의 autocommit을 세팅해주는 줄 알았는데, 그게 아니라 autocommit을 에뮬레이션한다고 하더군요. 뭔 소린가 했더니 실제로는 set autocommit=0으로 커넥션을 시작하고, 매 쿼리마다 commit을 날리는 거였습니다. 뭥미? 싶었는데, 그것도 이유가 있더군요. PEP-249에서 커넥션 연결할 때 autocommit은 false로 한다는 항목이 있더군요. 그것 때문에 MySQLdb에서 디폴트를 그렇게 설정해놨는데, 이걸 connection 객체 생성할 때는 바꿀 수조차 없게 해놨습니다. 일단 커넥션 연결하고 나서 바꿀 수 있게 해두었죠.

뭐, 여기까지는 그럴 수 있다 싶었습니다. autocommit 에뮬레이션이 잘 동작하기만 한다면 말이죠. 근데 그게 문제가 있었습니다. insert를 하고 commit을 때린 세션에서는 정상적으로 데이터가 읽히지만, 그 전에 열어놓은 다른 세션에서는 여전히 이전 데이터를 읽는 겁니다. 그러니까 예를 들어서, Post라는 모델을 create했으면 그 세션에서는 Post.objects.get(id=inserted_id)로 하면 가져오지만, 다른 세션에서는 못 가져오는 거죠.

처음에는 commit이 제대로 안된 줄 알았는데, 쿼리 로그를 보니 commit은 매 쿼리마다 착실히 날려주고 있었습니다. 알고보니 MySQLdb 커넥션이 열릴 때 무조건 autocommit false로 열리는데, autocommit false를 하면 그 이후부터는 다 트랜잭션으로 인식하기 때문에 commit을 날리기 전까지는 세션을 연결한 시점의 데이터만 select할 수 있습니다. 그러니까 세션 1과 2가 다 autocommit false로 열려 있는데, 세션 1에서 insert하고 commit을 해도, 세션 2에서는 commit하기 전까지는 세션 1에서 insert한 데이터를 못 읽는다는 겁니다. 근데, 문제는 세션 2에서는 insert를 하지 않고 select만 한다면 commit은 안 날아갑니다. 그럼 세션 1에서 무슨 작업을 하든 세션 2는 영영 최신 데이터를 읽을 수 없는 거죠.

근데 또 그러면 리프레시 여러 번 해도 안되기도 해야 되는데, 그건 아니고 꼭 한 번만 리프레시하면 다음 번엔 데이터가 나오더군요. 이건 또 왜 그런지 봤더니, select에 실패했을 때 django가 rollback을 날립니다;; 그래서 세션 2가 다시 동기화되서 최신 데이터를 읽어올 수 있게 되죠.

이렇게 된 데는 mysql의 transaction_isolation이라는 옵션도 영향을 미쳤습니다. 이게 권장 기본값이 REPEATABLE-READ인데, 한 세션에서는 반복적으로 데이터를 읽을 때 새로 읽지 않고 같은 값을 읽습니다. commit이 없는 한 다른 세션에서 데이터가 늘어나든 줄어들든 상관 없이 같은 쿼리 결과가 나오는 거죠. lock 타임을 줄이기 위한 것 같습니다. 그래서 이 옵션을 READ-COMMITTED로 바꾸면 이 문제가 해결이 됩니다. 세션이 어떻게 되든 select할 때 최신 데이터를 가져오거든요. 물론 이렇게 하면 성능이 떨어집니다.

그래서, 이 문제를 완전하게 해결하는 방법은 진짜 트랜잭션이 필요할 때가 아니면 autocommit=1로 두는 겁니다. 근데 안타깝게도 django의 DATABASES 설정에는 그렇게 할 수 있는 옵션이 없습니다. 방법이 하나 있긴 한데, OPTIONS에 init_command에 SET AUTOCOMMIT=1을 넣으면 되는데, 그래도 MySQLdb 구현상 그 다음에 다시 SET AUTOCOMMIT=0이 실행되서 문제가 일부분 해결되지만 완전히 해결되진 않습니다.

그래서 찾다찾다 마지막으로 찾아낸 해결책은 django의 signal을 이용하는 것입니다. signal에서 connection_created에 SET AUTOCOMMIT=1을 날리도록 해두니까 MySQLdb 구현체에서 실행하는 SET AUTOCOMMIT=0 다음에 실행이 되서 제대로 동작하더군요. 그래서 일단 문제는 해결했습니다.

여기서 비하인드 스토리를 하나 더 끄집어내자면, 그동안 계속 django+mysql 조합을 써오면서 이 문제를 못 알아차렸었는데, 이번에 알아차리게 된 건 이유가 있습니다. 이전에는 주로 아파치의 mod-wsgi를 썼었는데, 아파치는 보통 process 숫자가 넉넉하면 같은 클라이언트가 요청할 때는 같은 process를 계속 할당합니다. 꼭 그런 건 아닌데 대체로 그렇더군요. 그래서 production 환경을 다 세팅해놓고 테스트를 해도 이 문제가 잘 발견되지 않습니다. 그런데 이번에는 nginx + uwsgi로 세팅을 했는데, uwsgi는 100%의 확률로 다른 process로 안내하더군요. 그래서 이 문제를 알게 된 거죠. 아마도 이전에 mod-wsgi에서도 실사용자들은 간혹 이런 문제를 겪었을 것이라고 추정됩니다.

아뭏든 이렇게 추적해본 결과로 PEP-249, django의 autocommit 에뮬레이션, MySQL의 transaction_isolation 기본값, uwsgi 멀티프로세스가 결합되면 이 문제는 이론상 100% 발생하는 문제입니다. 권장하는 기본값들로 다 설정했는데 문제가 발생한다면 뭔가 설계가 잘못된 것이죠.

저는 django의 설계 잘못이라고 봅니다. 자기 문서에도 autocommit true를 권장한다고 해놓고 DB에서 지원하는 autocommit을 쓸 수 없는 건 말이 안되는 것 같습니다. 이건 MySQLdb 구현체도 PEP-249를 너무 엄격하게 적용해놓은 문제도 있구요. 적어도 connection 객체 생성할 때 파라미터로 autocommit true를 줄 수 있는 방법 정도는 제시해야 맞을 듯. mysql의 transaction_isolation 값을 바꿔서 문제를 해결하는 것은 성능을 떨어뜨리기 때문에 좋은 방법은 아니라고 생각됩니다.

후아.. 근데 이걸 django forum에도 올려야 하는데 영어를 어쩐다?

  • 박영록

    django developer mailinglist에 보니 이미 올해 3월에 논쟁이 있었네요. 초반에는 많은 유저들의 열렬한 찬성이 있었는데, 반대가 몇몇 등장하면서 혼전이 된 상태인 듯. 쓰레드가 너무 길어서 일단 내일 다음에 다시 살펴보기로;;

    Hyungyong Kim

    PostgreSQL 쓰기 시작한 이후로 MySQL 안쓰게 되더라고요. 이런일이…

    Han Cold Kim

    와 대단하십니다.. 저도 이런 분석력을 갖고싶은데…! 좋은 글 잘봤습니다… 전부 이해하진 못했지만… 😦

    Choe Cheng-Dae

    음.. 그럼 DB 세팅을 롤백해야겠군요…

    Jamie J Seol

    아ㅠㅠㅠ 어쩐지…

    Kwon-Han Bae

    1.5에 트랜잭션 관련해서 격렬하게 바뀌고 있습니다.
    결론은 1.6은 되어야 나올듯..

    박영록

    그 사이 업데이트가 있어서 좀더 적어봅니다. 일단 위에 쓴 방법으로 문제를 해결했다고 생각했는데, 이번엔 다른 문제가 있었습니다. 가끔씩 DB 오류가 발생하는 것입니다. 오류 메세지는 1305, savepoint does not exists였습니다. mysql에서 savepoint rollback을 하려는데 이전에 저장해둔 savepoint가 없다는 것이죠. 그런데 이건 불규칙적으로 일어나는데다가 리프레시를 하면 금방 괜찮아져서 한동안 내버려 두다가, 오늘은 100% 재연되는 케이스를 발견해서 추적에 들어갔습니다.

    발생한 케이스는 OneToOneField를 get_or_create로 만드는 것이었습니다. 코드를 잘못 작성한 부분이 있었습니다. 원래는 get_or_create로 만들 때 key가 되는 항목만 인자로 넣고 나머지 필드는 리턴된 객체에서 넣어줘야 하는데, 귀찮아서 그냥 모든 필드를 인자로 넘겼더니 one to one의 대상 객체가 이미 있는데 나머지 필드가 일치하지 않으면 get도 안되고 createe도 안되서 항상 실패하는 코드가 된 것이죠. 덕분에 savepoint 에러를 무한 재현하면서 문제를 추적할 수 있었습니다.

    추적해본 결과, django와 mysql 양쪽 다 문제가 있었습니다. get_or_create의 트랜잭션 처리를 보면, 일반적인 commit/rollback이 아니라 savepoint를 사용합니다. 먼저 savepoint를 저장하고 쿼리를 수행하다 예외가 발생하면 savepoint를 rollback하는 것이죠. 그런데, 여기서 mysql 세션은 autocommit=1로 열려 있는 상태인데, autocommit이 1이면 mysql에서 savepoint 명령이 무시됩니다. 하지만 오류를 내진 않고 조용히 넘어가버리죠. 그러니까 django는 savepoint가 저장되었는지 아닌지 모릅니다. 그러니까 태연하게 savepoint를 저장했던 key값으로 rollback을 시도하는데, 이 때 저장된 savepoint가 없으므로 오류를 내는 것이죠.

    그러니까 mysql은 autocommit 상태에서 savepoint 처리에 일관성이 없는 셈입니다. autocommit에서 savepoint를 저장 안되게 할 꺼면 그 시점에 오류를 내든가, 혹은 그냥 패스시켰으면 rollback 때도 패스시켜야 되는데 이도저도 아닌 설정이라 이런 상황이 발생한 것이죠. 정합성을 중시하는 RDBMS에선 참 어이 없는 구현인 것 같습니다.

    django의 실수도 물론 있습니다. 분명히 transaction을 시도하는 상황인데 autocommit을 0으로 바꾸는 게 아니라, 그냥 기본값이 0이니까 0일꺼라고 가정하고 쿼리를 수행합니다. 아마도 autocommit 시뮬레이션이라는 괴이한 방식을 채택하다보니 begin transaction을 쓰지 않고 savepoint를 쓰면서 이런 식으로 코딩하게 된 것 같은데, 별로 pythonic하지 않죠. autocommit=0임을 가정하고 있다는 것을 좀더 드러내거나, 혹은 autocommit을 변경할 수 있는 방법을 제시해야 좀더 pythonic한 구현이 될 것입니다.

    그래서, set autocommit=1은 django에서 savepoint를 활용한 코드와 충돌이 나서 쓸 수 없는 옵션이 되어버렸습니다. 그래서 다른 대안을 찾아야 했죠. 찾다가 전에 시도했던 방법, settings.py의 DATABASES OPTIONS에 init_command로 set autocommit=1을 넣는 방법을 떠올렸습니다. 전에는 이게 불완전한 해결책이라고 생각했는데, 좀더 따져보니 충분히 해결책이 되는 것 같습니다. 어차피 REPEATABLE-READ에서 문제가 되는 건 커넥션에서 select만 일어날 때 commit이 일어나지 않아서 최신 데이터를 못 가져오는 것인데, 처음에 연결할 때 set autocommit=1을 한 번 해주면 이 때 데이터가 동기화되기 때문에 다시 set autocommit=0이 되더라도 최신 데이터를 읽는다는 소기의 목적은 달성이 됩니다. django의 autocommit 에뮬레이션과도 잘 맞아들어가구요. 그래서, 시그널 리시버에서는 다시 autocommit을 빼고 OPTIONS로 집어넣었더니 모든 게 다 잘 동작하더군요.

    고로, 이전에 제가 썼던 권고사항, django와 mysql을 사용할 때 시그널을 잡아서 autocommit을 켜라는 권고를 바꿉니다. DATABASES의 OPTIONS에 init_command로 집어넣는 것이 더 추천할 만한 방법인 듯.

    django ORM이 Rails의 ActiveRecord처럼 달콤한 기능들을 제공하진 않지만 기본적인 일들을 잘 해내고 있다고 생각했는데, 좀 신뢰가 떨어지는군요.

    Kenial Lee

    읽다보니 어째 PostgreSQL을 써 볼까… 하는 생각이 드네요(…) 장고의 MySQL 드라이버에서는 커넥션 풀링을 기본적으로 지원하지 않아서 또 뭔가 만져줘야 한다는 이야기에 다른 db를 쓰는 것도 생각중이었는데;

    좋은 글 감사합니다

    Thomas Hyunsik Kim

    @박영록 제가 잘 이해했는지 몰라서 여쭙니다 🙂 MySQL DB 디폴트가 autocommit=0인 상태에서, DB 커넥션 시작할때 autocommit=1으로 한번 바꿔주면, 곧바로 다시 MySQLdb가 autocommit=0으로 바꾸더라도 그 커넥션은 autocommit=1 인것처럼 트랜잭션으로 인식이 안되서 최신 데이터를 읽을 수 있는건가요??

    박영록

    아뇨, 첨에 연결할 때 1로 하면 그 시점에 이미 최신 데이터를 읽어올 준비가 됩니다. 그럼 다시 0으로 되더라도 아까 1로 한 시점의 최신 데이터까지는 읽을 수 있죠. 물론 이 경우에도 0으로 한 이후에 다른 세션에서 발생한 커밋 내용을 읽어오진 못합니다. 하지만 그건 별 상관 없는 게 django는 mysql 커넥션을 풀링하지 않고 그 때 그 때 연결하기 때문에 문제가 안됩니다. 한 request를 처리하는 동안 다른 커밋을 읽어올 필요는 없거든요.

    이전의 문제는 mysql의 세션이 한 번도 commit되는 일 없이 계속 재사용되는 경우에 생기는 거라서 세션에 커넥션 붙을 때마다 동기화만 되어도 문제는 해결되는 것이죠.

    KyungHoon Kim

    동일 문제 발생으로 Chinseok Lee 님께서 소개해 주셔서 이 글을 봤는데… mysql을 버려야 하지 않겠는가가 가장 먼저 떠오르네요 ㅠㅠ 귀한 내용 감사합니다!

    Kenial Lee

    .
    – 요약: django에서 mysql DB를 사용할 경우, insert 이후 최신 데이터가 select 되지 않는 문제 해결 방법

    1. mysql의 transaction_isolation 옵션 기본값인 REPEATABLE-READ를 READ-COMMITTED로 변경한다: 읽기 작업에도 transaction lock이 걸리므로 성능이 하락한다. 성능이 critical issue가 아니라면 사용할 수 있는 방법.

    2. settings.py의 DATABASES OPTIONS에 init_command로 set autocommit=1을 지정한다: 약간의 오버헤드가 있으나 현재 가장 쓸만한 방법.

    3. django의 connection_created signal에 SET AUTOCOMMIT=1 명령을 줌: django가 autocommit=0이라고 가정하여 savepoint 처리를 하기 때문에 에러가 발생할 수 있음. 사용하지 말 것!

    박영록@ 요약을 원하는 분들이 있을 것 같아서 적어 보았습니다. 세줄요약은 … 안 되네요;;;

    KyungHoon Kim

    이수겸 요약 감사합니다!

    Sung-jin Brian Hong

    REPEATABLE-READ가 성능이 더 하락합니다. READ-COMMITED가 성능이 더 좋습니다.

    Suyeol Jeon

    우와 대박 감사합니다

    Han Cold Kim

    갑자기 끌올됐네요 ㅋㅋ

    Han Cold Kim

    #mysql

    Joongi Kim

    혹시 이거 예전에 아라 게시판에 있었던 사용자 세션 섞이는 문제랑도 관련이 있으…려나요? 홍성진, 변규홍, 이준성 호출! =3=3

Advertisements