Memory Allocator for MongoDB

Sunguck Lee
당근 테크 블로그
12 min readApr 17, 2022

DBMS 서버에서 메모리 관리는 매우 중요한 부분중 하나에요. DBMS 서버의 메모리 관리는 서버의 안정적인 운영뿐만 아니라 성능과도 직결되는 부분이 기 때문이죠.

이번 이야기에서는 MongoDB의 아래 2가지 궁금증을 풀어 보려고 해요.

  • mongos의 과다한 메모리 사용량의 원인
  • TCMalloc보다는 JEMalloc이 나은 선택인 경우

여기에서 부터는 문장의 간결함을 위해서 높임말은 생략할게요.

MySQL 서버는 리눅스 서버가 지금처럼 안정적이지 않던 시절부터 개발되고 사용되면서, MySQL 서버는 예전부터 운영 체제가 지원하는 느린 메모리 관리보다는 자체적으로 MEM_ROOT라고 불리는 Arena 기반의 Memory-Allocator(메모리 할당자) 코드를 사용해 왔다. 이는 지금도 마찬가지이며, 여전히 Memory-Allocator는 메모리 할당과 해제에 있어서 안정성과 성능 향상에 큰 역할을 하고 있다. 물론 MySQL 서버의 모든 코드가 MEM_ROOT를 사용하는 것은 아니다. 대표적으로 Stored Routine(Stored Function과 Stored Procedure)과 UDF 등은 각자의 Memory Allocator 또는 운영 체제의 기본 Memory Allocator를 사용하고 있다. 그렇다 하더라도 핵심적인 기능은 MEM_ROOT를 사용하기 때문에, MySQL 서버에서는 의도하지 않게 메모리 사용량이 급증한다거나 해제되지 않는 문제는 흔치 않다.

오늘의 이야기 주제는 MongoDB 서버에서의 메모리 할당과 해제에 대한 이야기이므로, 이제 MongoDB 서버로 돌아가 보자. MongoDB 서버는 사실 그다지 오래되지 않은 DBMS 서버이며, 처음부터 C++로 개발되었다. 메모리 할당과 해제를 직접 처리하는 C 언어와는 달리, C++에서는 (일반적으로) Heap Memory의 할당과 해제가 매우 많이 발생한다. 즉 별도의 Memory Allocator를 사용하지 않으면 리눅스 운영 체제의 기본 Memory Allocator인 PTMalloc2를 사용하게 된다. MongoDB에서도 PTMalloc2보다는 다른 더 나은 Memory Allocator를 인지하고 있었으며, 이를 위해서 MongoDB는 TCMalloc을 코드 수준에서 내장하고 있다.

리눅스 운영 체제에서 MySQL 이나 MongoDB 서버를 운영해본 경험이 있다면, Memory Allocator에 대한 논쟁과 토론들을 많이 보았을 것이다. 일반적으로 리눅스의 기본 Memory Allocator인 PTMalloc2보다는 TCMalloc이나 JEMalloc이 선호되고 있다는 블로그들을 자주 접했을 것이다. 그런데 TCMalloc과 JEMalloc 둘 중에서 어떤 것을 선택해야 할지에 대한 고민에 대한 내용은 그다지 많지 않다. 물론 MongoDB에서도 TCMalloc 대신 JEMalloc을 도입하는 부분에 대해서 검토를 했었던 것으로 보인다. 하지만 MongoDB에서는 JEMalloc 도입이 그다지 큰 이익이 없을 것으로 판단한 것으로 보인다.

서론이 길었는데 이제 MongoDB 서버에서 TCMalloc이 어떤 문제로 연결될 수 있는지를 간단히 살펴보자. MongoDB는 다양한 형태의 운용이 가능한데, 그 중에서 Sharded Cluster 배포인 경우 모든 요청들은 mongos(MongoDB Proxy)를 거쳐야 한다. MongoDB 서버는 메모리 설정을 위한 파라미터들이 제공되지만 mongos는 이런 설정 자체가 없으며, 전통적으로 많은 사용자들이 mongos는 많은 메모리를 사용하지 않으며 일반적인 경우 100~200MB 정도로 할당해도 충분하다고 알고 있다. 그래서 mongos는 별도의 서버를 할당하지 않고 MongoDB 서버와 같은 서버에 실행하기도 한다. 실제 MongoDB의 관리형 서비스인 Atlas MongoDB에서도 mongod(MongoDB 서버)와 mongos를 동일 인스턴스에 배포해서 서비스를 제공하고 있기도 하다.

mongos를 통해서 작은 크기의 데이터를 주고 받는 경우에는 mongos의 메모리 사용량은 크게 올라가지 않는다. 이런 경우 우리가 알고 있는 것처럼100 ~ 200MB 수준의 메모리만 사용하게 된다. 그런데 mongos를 통해서 아주 큰 데이터를 읽어오는 경우, mongos는 일시적으로 많은 데이터를 버퍼링해야 하며 순간적으로 메모리 사용량이 증가할 수 있다. 그렇다고 mongos가 모든 필요한 데이터를 MongoDB 서버로부터 가져와서 버퍼링을 한다는 의미는 아니다. mongos는 적절한 페이징 사이즈만큼의 도큐먼트를 가져와서 클라이언트가 가져갈 때까지 버퍼링을 하기 때문에 1~2개의 클라이언트가 대량의 데이터를 읽어 간다고 해서 심각한 메모리 사용을 유발하지는 않는다.

그런데 만약 대용량 테이블을 파티션해서 30개 정도의 쓰레드가 동시에 읽어간다면 어떻게 될까? 당연히 1개의 쓰레드가 읽을 때보다 30배나 더 많은 메모리를 한번에 소진하게 될 것이다. 아래 차트는 N개의 쓰레드를 이용해서 20건을 읽는 쿼리와 테이블의 모든 레코드를 가져가는 쿼리를 실행할 때, mongos의 메모리 사용량 관계를 보여주고 있다. 쓰레드가 80개를 넘어서면서 메모리 사용량이 떨어지는 것은, 메모리 할당과 해제 오버헤드로 인해서 처리 성능이 떨어졌기 때문이다.

mongos 메모리 사용량 차이(20건 쿼리 vs 풀 스캔 쿼리)

이렇게 많은 쓰레드로 동시에 대량 데이터를 읽는 경우가 있을까라고 생각할 수 있지만, MongoDB 서버의 데이터를 BigQuery나 다른 분석 DBMS로 복사할 때에는 (의도했던 아니던) 흔하게 발생하는 상황이기도 하다. 요즘처럼 이런 데이터 복사 작업을 오픈 소스 소프트웨어를 활용하는 경우, 내부적인 작동 방식에 대한 이해없이 빠르게 데이터를 가져가기 위해서 동시 작업 쓰레드를 60개씩 설정하는 경우도 흔히 발생한다. 이렇게 동시에 많은 쓰레드로 작업을 하게 되면, mongos 서버는 매우 많은 메모리를 한번에 소진하게 되는데, MongoDB 테이블의 도큐먼트 하나 하나의 크기가 큰 경우에는 더 심각하고 빠른 메모리 소진을 유발하게 된다. 때로는 mongos를 위해서 최대 2GB 정도의 여유를 두고 설치한 서버에서 MongoDB 서버보다 mongos가 더 많은 메모리를 사용하고 있을 수도 있고, 결국 메모리 부족으로 MongoDB 서버나 mongos 프로세스는 강제 종료될 것이다.

mongos가 메모리를 대량으로 점유해버리는 경우를 살펴보았다. 이제 이런 경우 TCMalloc과 JEMalloc이 어떻게 반응하는지를 살펴보자. 아래 차트는 20개 정도의 쓰레드로 대량 쿼리를 실행할 때, mongos의 Memory Allocator가 TCMalloc인 경우와 JEMalloc인 경우의 메모리 사용량 변화를 보여주고 있다.

Memory Usage (TCMalloc vs JEMalloc)

위 차트를 보면, mongos를 통해서 대량 쿼리를 실행하는 동안(GetMore 호출이 실행되는 도중) JEMalloc은 대략 1.3GB정도를 사용하고 있는 반면 TCMalloc은 2.4GB정도를 사용하고 있다. 그리고 대량 쿼리를 강제 종료하면 JEMalloc은 메모리 사용량이 2.2GB까지 솟구치는 것을 현상을 볼 수 있다. (이 과정이 중요한 것은 아니지만) 이는 TCMalloc의 메모리 할당과 해제 성능이 JEMalloc보다 낮아서 클라이언트가 mongos로부터 쿼리 결과를 빠르게 가져가지 못해서 mongos가 더 많은 데이터를 버퍼링해야 했기 때문에 메모리 사용량이 처음부터 2.4GB까지 솟구친 것이며, JEMalloc의 경우는 TCMalloc보다는 빠르게 처리했기 때문에 mongos가 버퍼링해야 할 데이터들이 많지 않았기 때문이다. (이는 차트상 TCMalloc의 GetMore 요청의 처리 수가 JEMalloc 대비 기복이 심한 것을 보면 이해할 수 있다.)

이 차트에서 더 주의깊게 살펴봐야 하는 지점은, 타임라인의 마지막 부분에서 JEMalloc의 메모리 사용량이 뚝 떨어지는 부분이다. 반면 TCMalloc은 메모리 사용량이 그 상태로 유지되는 것을 확인할 수 있다. 여기에서는 타임라인이 잘려있지만, 테스트후 대기했던 하루 정도까지 TCMalloc의 메모리 사용량은 2.4GB 수준에서 줄어들지 않았다. 그러면 375초 지점에서는 무슨 일이 있었던 것일까 ? 이는 mongos 서버의 Cursor Timeout(cursorTimeoutMillis) 파라미터가 300초(기본 값)로 설정되어 있기 때문이다. 쿼리를 강제 종료한 75초 지점부터 300초가 지나면서, 쿼리에 사용되었던 Cursor가 타임아웃으로 무효화되면서 Cursor가 가지고 있던 데이터 버퍼링용 메모리 공간이 불필요해진 것이다.

JEMalloc은 Cursor가 타임아웃되고 삭제되면서 반환된 메모리 공간을 리눅스 서버(운영 체제)로 반환했지만, TCMalloc은 반환된 메모리를 리눅스 서버로 반환하지 않고 자기 자신의 로컬 캐시 공간에 그대로 유지하기 때문에 이런 차이를 보이는 것이다. TCMalloc과 같이 리눅스 서버로부터 한번 할당받은 메모리 공간을 자체적으로 캐시하게 되면, 이후 유입되는 쿼리들의 처리를 위해서 (리눅스 서버로부터 다시 할당받지 않아도 되기 때문에) 더 빠르게 메모리 할당과 해제 작업을 처리할 수 있지만, JEMalloc은 그 반대로 느리게 작동하게 될 것이다.

용도에 따라서 TCMalloc과 JEMalloc은 장점과 단점이 명확하다. 대략적으로 테스트 케이스에서 보인 쿼리들의 요건들을 고려해서 적절한 Memory Allocator를 선택하면 될 것으로 보인다.

  • 대용량 쿼리가 매우 빈번하게 실행되거나
  • mongos를 위해서 충분한 메모리를 가지고 있다면

예를 들어서, 위 2개 조건에 부합된다면 TCMalloc을, 그렇지 않다면 JEMalloc이 더 나은 선택일 것이다. 대용량 쿼리가 빈번하지 않은 경우 TCMalloc 처럼 불필요하게 사용되지도 않을 메모리를 Memory Allocator가 가지고 있으면, 다른 프로세스가 메모리를 사용하지 못하는 낭비가 발생하게 될 것이다.

참고로, MongoDB configruation으로 제어할 수 있는 TCMalloc의 tcmallocReleaseRate 파라미터는 리눅스 서버로의 메모리 반환에 아무런 영향을 미치지 못했으며, tcmalloc.heap_limit_mb 파라미터가 지원되는 TCMalloc 2.9.1 버전을 MongoDB 서버에 패치해도 특별히 개선 효과를 보이진 않았다.

일반적으로 MongoDB 서버는 온라인 트랜잭션 처리 용도(OLTP)로 사용되는데, 이런 OLTP 처리용 DBMS 서버에서 이런 대용량 쿼리는 빈번하게 실행되지 않으며, mongos의 과도한 메모리 사용은 OLTP 쿼리 처리의 성능에 상당한 영향을 미치게 된다. 그래서 개인적으로 생각해볼 때, MongoDB 서버의 모든 프로그램은 TCMalloc보다는 JEMalloc으로 전환되어야 하지 않을까 생각한다. 하지만 이미 MongoDB에서는 JEMalloc을 검토했었고, TCMalloc을 그대로 유지하기로 결정한 듯 보인다. 만약 MongoDB를 직접 설치해서 사용하면서 메모리 공간이 넉넉하지 않은 사용자라면, MongoDB 서버의 Memory Allocator를 JEMalloc으로 전환해 보는 것도 좋은 시도가 아닐까 생각된다.

앞에서 설명했던 것처럼, MongoDB 서버는 이미 코드 수준에서 TCMalloc Memory Allocator를 사용하도록 구현되어 있다. 그래서 MongoDB 서버가 JEMalloc을 사용하도록 변경하기 위해서는, MongoDB 서버가 System Memory Allocator를 사용하도록 새로 빌드 후 LD_PRELOAD 설정을 이용해서 JEMalloc을 사용하도록 변경해야 한다.

MongoDB Rebuild (Percona MongoDB 예시)

$ python3 buildscripts/scons.py \
MONGO_VERSION=4.4.6-8-psmdb-system-allocator \
CC=/opt/rh/devtoolset-8/root/usr/bin/gcc \
CXX=/opt/rh/devtoolset-8/root/usr/bin/g++ \
AR=/opt/rh/devtoolset-8/root/usr/bin/ar \
CPPPATH="${AWS_LIBS}/include" \
LIBPATH="${AWS_LIBS}/lib64" \
--jlink=2 \
--install-mode=legacy \
--disable-warnings-as-errors \
--ssl \
--opt=on \
--use-sasl-client \
--wiredtiger \
--audit \
--inmemory \
--hotbackup \
--allocator=system \
mongod mongos

mongos의 systemctl 스크립트

## /lib/systemd/system/mongos.service[Unit]
Description=MongoDB Proxy (mongos)
After=time-sync.target network.target
[Service]
Type=forking
User=mongod
Group=mongod
PermissionsStartOnly=true
LimitFSIZE=infinity
LimitCPU=infinity
LimitAS=infinity
LimitNOFILE=64000
LimitNPROC=64000
ExecStart=/usr/bin/env bash -c "MALLOC_CONF='dirty_decay_ms:100,muzzy_decay_ms:2000' LD_PRELOAD=/usr/lib64/libjemalloc.so.2 /usr/bin/mongos -f /etc/mongos.conf > /var/log/mongo/mongos.stdout 2> /var/log/mongo/mongos.stderr"
PIDFile=/var/run/mongo/mongos.pid
[Install]
WantedBy=multi-user.target

mongos의 메모리 이야기를 재미있게 읽으셨다면, 그리고 더 많은 것들을 경험하고 싶으시다면, Real MySQL 오픈 챗에 참여 또는 당근 마켓 개발자와 DBA로 지원해주세요.

--

--