최적 성능을 위한 SQLite on Rails의 방법과 이유
(fractaledmind.github.io)- Rails에서 SQLite를 기본값 그대로 운영 환경에 쓰기에는 아직 부족하지만, 몇 가지 설정을 더하면 고성능·회복력 있는 애플리케이션을 만들 수 있음
- 동시 쓰기 부하가 걸리면 SQLite의 전역 쓰기 락 때문에
SQLITE_BUSY가 발생하며, Rails의 일반적인 쓰기 트랜잭션 흐름은 기본 deferred 모드와 잘 맞지 않음 sqlite3-ruby1.6.9부터database.yml에서 기본 트랜잭션 모드를 IMMEDIATE로 바꿀 수 있어, 쓰기 락 획득 실패를 더 안전하게 대기·재시도하도록 만들 수 있음busy_timeout은 대기 중에도 Ruby GVL을 잡아 Puma 병렬성을 해치므로,busy_handler에서sleep으로 GVL을 놓고 1ms 간격 재시도를 쓰면 긴 꼬리 지연시간이 줄어듦- WAL 모드, IMMEDIATE 트랜잭션, GVL을 해제하는 busy handler, Rails 7.1의 SQLite 기본 설정, 선택적 읽기/쓰기 커넥션 풀 분리가 SQLite on Rails 성능 개선의 핵심 축임
기본 Rails + SQLite 설정에서 드러나는 한계
- Rails 애플리케이션을 SQLite 기반으로 성능 좋고 회복력 있게 운영하려면 현재 기본 설정만으로는 부족함
- 목표는 Rails 8에서 SQLite의 기본 경험을 운영 환경 준비 완료 상태로 만드는 것임
- 데모 애플리케이션은 Lorem News임
- Hacker News 형태의 기본 클론으로 사용자, 게시글, 댓글을 포함함
- 콘텐츠는 Lorem Ipsum으로 구성됨
- 부하 테스트에는 oha load testing CLI와 앱에 내장된 benchmarking routes를 사용함
post#create엔드포인트에 5초 동안 순차 요청을 보내면 RPS는 안정적이고 모든 요청이 성공함- 같은 엔드포인트에 4개 동시 요청을 5초 동안 보내면 일부 요청이 500 응답을 반환함
SQLITE_BUSY와 IMMEDIATE 트랜잭션
- 로그에서 먼저 확인되는 문제는
SQLITE_BUSY예외임 - SQLite는 동시에 하나의 쓰기 작업만 허용하기 위해 데이터베이스에 쓰기 락을 사용함
- 한 번에 하나의 커넥션만 쓰기 락을 가질 수 있음
- 다른 커넥션이 락을 보유한 상태에서 새 커넥션이 쓰기 락을 얻으려 하면
SQLITE_BUSY가 발생함
- Rails 앱이 더 큰 동시 부하를 받으면
SQLITE_BUSY로 실패하는 요청 비율도 증가함 - SQLite의 기본 트랜잭션 모드는 deferred이며, 실제 쓰기 작업이 발생할 때까지 락을 획득하지 않음
- 단일 커넥션 환경이나 읽기 전용 트랜잭션이 많은 환경에서는 성능상 유리함
- 운영 Rails 앱은 여러 스레드에서 여러 커넥션을 사용하고, Rails는 쓰기 쿼리를 트랜잭션으로 감싸므로 이 기본값과 충돌함
- 트랜잭션 중간에서 쓰기 락 획득에 실패하면 SQLite는 직렬화 가능 격리를 깨지 않기 위해 해당 쿼리를 안전하게 재시도할 수 없어 즉시 예외를 던짐
IMMEDIATE트랜잭션은 트랜잭션 시작 시점부터 쓰기 락 획득을 시도함- SQLite가 쓰기 쿼리를 큐에 넣고 나중에 다시 락 획득을 시도할 수 있음
- Rails의 쓰기 트랜잭션 패턴에는 deferred보다 이 방식이 더 적합함
sqlite3-rubygem은 1.6.9부터 기본 트랜잭션 모드 설정을 지원함- Rails는
database.yml의 최상위 키를sqlite3-ruby데이터베이스 초기화에 전달함 database.yml에서 Rails의 SQLite 트랜잭션을 IMMEDIATE 모드로 실행하도록 설정할 수 있음
- Rails는
- 이 변경 후 단순 부하 테스트에서는 동시 부하에서도 거의 500 오류 없이 처리되지만, 16개 동시 요청에서는 다시 일부 오류가 나타남
busy_timeout, GVL, custom busy_handler
- 다음 병목은 동시 요청 수가 Puma 워커 수에 접근하거나 이를 넘을 때 p99 지연시간이 급격히 증가하는 현상임
- 실제 요청 처리 시간은 Puma 워커 수의 3배 동시 부하에서도 안정적이지만, 약 5초 걸리는 요청이 생기기 시작하면
SQLITE_BUSY500 응답도 함께 발생함 - 이 5초는
database.yml의 timeout 설정과 일치하며, SQLite의busy_timeout설정으로 매핑됨 busy_timeout은 즉시BUSY예외를 던지는 대신 지정된 밀리초만큼 기다리며 쓰기 락 재획득을 시도함- SQLite는 일종의 지수 백오프 방식으로 락 재획득을 시도함
- timeout 안에 락을 얻지 못할 때만
BUSY예외를 던짐 - 웹 애플리케이션은 여러 커넥션을 열어도 쓰기 순서를 직접 조정하지 않고 SQLite에 맡길 수 있음
- 병목은 Ruby 프로세스 안에 SQLite가 임베드되어 있고,
sqlite3-ruby가 SQLite C 코드를 호출할 때 Ruby GVL을 해제하지 않는 데서 생김- 한 Puma 워커가 데이터베이스 쿼리 반환을 기다리는 동안 GVL을 계속 잡고 있음
- 다른 Puma 워커가 동시에 쓰기 쿼리를 SQLite에 보내는 것조차 어려워짐
- SQLite의 순차 쓰기 특성이 Rails 요청 처리까지 더 선형적으로 만들어 처리량을 크게 낮춤
- SQLite는
busy_timeout보다 낮은 수준의busy_handler훅을 제공함busy_timeout은 SQLite가 제공하는 특정busy_handler구현임sqlite3-ruby는sqlite3_busy_handlerC 함수에 대한 바인딩을 제공하므로, 쿼리가 큐에 들어갈 때 호출되는 Ruby 콜백을 만들 수 있음
- Ruby 콜백에서
Kernel.sleep을 사용하면 쿼리가 쓰기 락 재시도를 기다리는 동안 GVL을 해제할 수 있음 - 이 방식은 동시 부하에서 p99 지연시간을 크게 개선하지만, p99.99 지연시간에서는 동시 부하가 커질수록 가장 느린 요청이 더 느려지는 현상이 남아 있음
지수 백오프의 긴 꼬리와 1ms 재시도
- Ruby로 재구현한 SQLite
busy_timeout로직은 오래 기다린 쿼리일수록 더 불리해지는 구조임 - 초기 재시도에서는 대기 시간이 작지만, 콜백 호출 횟수가 늘수록 대기 시간이 커짐
- 첫 번째 대기에서는 1ms를 기다림
- 열 번째 호출에서는 50ms를 기다림
- 12번째 이후에는 100ms씩 기다림
- 누적 대기 시간이 timeout인 5000ms를 넘으면 예외가 발생함
- 꾸준히 새 쓰기 쿼리가 들어오면 새 쿼리는 짧은 대기 시간으로 더 자주 락 획득을 재시도할 수 있음
- 이미 세 번 대기한 오래된 쿼리가 10ms를 기다리는 동안 새 쿼리는 1ms, 2ms, 5ms 대기를 거치며 세 번 재시도할 수 있음
- 이 증가형 백오프는 오래된 쿼리가 쓰기 락을 얻지 못하고 timeout될 가능성을 높임
- 해결책은 쿼리의 나이와 관계없이 모든 쿼리가 같은 빈도로 재시도하게 만드는 것임
sqlite3-rubymain 브랜치에는 이 변경이 반영되어 있음- 해당 기능은 글 작성 시점 기준 tagged release에는 아직 포함되지 않음
- Ruby 콜백은
sleep으로 대기 중 GVL을 해제함 - 항상 1ms만 sleep함
- 이 변경 후 벤치마크에서 p99.99 지연시간 곡선이 평탄화됨
- 동시성이 Puma 워커 수의 절반을 넘을 때 점프는 남아 있음
- 그 이후 긴 꼬리 지연시간은 약 0.5초 수준에서 평탄해짐
WAL 모드와 읽기/쓰기 커넥션 풀 분리
- SQLite on Rails 성능을 위해 필요한 네 가지 조건은 IMMEDIATE 트랜잭션, GVL을 해제하는 busy handler, 적절한 SQLite 설정, WAL 모드임
- write-ahead log는 SQLite가 여러 동시 읽기 작업을 처리할 수 있게 함
- 기본 rollback journal mode는 읽기든 쓰기든 한 번에 하나의 쿼리만 허용함
- WAL 모드는 여러 reader를 동시에 허용하지만 writer는 한 번에 하나만 허용함
- Rails 7.1부터 Rails는 SQLite 데이터베이스에 더 나은 기본 설정을 적용함
- 이 설정들은 SQLite가 웹 애플리케이션 맥락에서 잘 동작하는 데 중요함
- 세부 설정과 이유는 별도 블로그 글에서 다룸
- 선택적 다섯 번째 성능 레버는 읽기 전용 풀과 쓰기 전용 풀을 분리하는 것임
- SQLite WAL 모드는 여러 읽기 커넥션과 하나의 쓰기 커넥션을 지원함
- Active Record 커넥션 풀이 쓰기 커넥션으로 포화되면 읽기 작업이 불필요하게 막힐 수 있음
- Rails의 multiple databases 지원을 활용해 reader와 writer 설정을 같은 SQLite 데이터베이스로 향하게 만들 수 있음
- 실제로는 별도 데이터베이스가 아니라 같은 단일 데이터베이스를 가리킴
- 결과적으로 서로 분리된 커넥션 풀과 커넥션 설정을 만들 수 있음
- reader 커넥션 풀은 읽기 전용 커넥션으로 구성하고, writer 커넥션 풀은 하나의 커넥션만 갖게 함
- Active Record 모델은 역할에 따라 적절한 커넥션 풀에 연결되도록 설정함
- Rails의 automatic role switching을 사용해 모든 웹 요청의 기본 커넥션을 reader 풀로 만들고, 데이터베이스 쓰기가 필요할 때만 writer 풀로 전환함
- 같은 데이터베이스를 쓰므로
read your own writes보장을 위한delay는 필요하지 않음 - ActiveRecord 어댑터의
transaction메서드를 패치해 트랜잭션이 writer 데이터베이스에 연결되게 함
- 같은 데이터베이스를 쓰므로
- 이 “deferred requests”와 격리된 커넥션 풀 조합은 comment create 엔드포인트 테스트에서 단순 RPS 기준 성능 개선을 보임
gem으로 패키징된 개선 사항
- 모든 개선을 Rails 앱에서 직접 구현할 필요는 없음
activerecord-enhancedsqlite3-adapter를 설치하면 관련 개선을 적용할 수 있음- 격리된 커넥션 풀 기능은 더 새로운 실험적 기능이므로 설정을 추가해 opt-in해야 함
- 이 접근은 SQLite를 Rails 운영 환경에서 빠르고 유연하게 쓰기 위한 도구, 기법, 기본값을 묶어 제공함
- Rails는 SQLite와 함께 작업하기 좋은 웹 애플리케이션 프레임워크이며, 관련 도구와 gem 생태계가 성장하고 있음
댓글과 토론
Hacker News 의견들
-
SQLite + Rails를 쓰려는 사람이라면 Oldmoe(X/GitHub)의 Litestack 프로젝트를 꼭 볼 만함
Litestack은 Ruby와 Ruby on Rails 앱에 SQLite의 내장성을 활용해 SQL 데이터베이스, 빠른 캐시, 작업 큐, 메시지 브로커, 전문 검색 엔진, 지표 플랫폼을 한 패키지로 제공하는 Ruby gem임
지금 프로젝트에서 쓰고 있는데 매우 만족스럽고, 링크는 https://github.com/oldmoe/litestack- Rails 8은 기본적으로 데이터베이스를 캐시, 큐, WebSocket 브로드캐스팅에 사용하게 될 예정임: https://fly.io/ruby-dispatch/the-plan-for-rails-8/
-
이 정도로 자세한 글을 쓰는 데 며칠이나 걸렸을지 상상이 안 감
Rails를 넘어 SQLite 웹 애플리케이션 확장을 고민하는 사람에게 유용한 글임 -
SQLite로 작업하는 사람은 사용 언어나 프레임워크와 무관하게 이 글을 읽어야 함
몇 년 전에 이런 내용을 대부분 혼자 알아내야 했던 입장이라, 정리해줘서 고마움 -
FOSS 분석 시스템을 만들고 있는데 설치가 쉬워야 해서, 이벤트 데이터를 메인 앱 데이터와 분리된 별도 SQLite 데이터베이스로 보내고 싶음
보통 정도로 바쁜 웹사이트도 초당 1000개 이상 이벤트가 생길 수 있어 확장이 걱정됨
서버 메모리에 이벤트를 모았다가 1초마다 한 번씩 묶음 쓰기를 하는 방식이 SQLite의 다수 쓰기 한계를 피하는 합리적인 방법인지, 더 나은 아이디어가 있는지 궁금함- 꽤 합리적이고 좋은 아이디어라고 봄. 여러 시스템에서 비슷한 방식을 구현해봤고, 일반적으로 묶음 처리는 항목당 오버헤드를 줄여줌
벤치마크로도 쉽게 증명할 수 있고, 묶은 항목 전체를 단일 트랜잭션에 넣을 수도 있음
묶음 처리라면 사실상 배치를 가져와 실제 쓰기를 수행하는 스레드 하나가 생기므로, SQLite의 동시 쓰기 1개 제한과도 잘 맞음
다만 복잡도가 조금 올라감. 단일 묶음 쓰기가 1초 안에 끝나지 않으면 어떻게 할지, 메모리에 저장하는 큐 크기를 무제한으로 둘지, 무제한이면 서버 과부하 시 OOM으로 죽지 않는다고 확신할 수 있는지, 제한을 둔다면 항목 유실을 감수할지, 버린다면 어떤 항목을 버릴지, 큐 한도는 얼마로 할지 정해야 함
이런 질문들은 큐가 필요한 거의 모든 시스템에서 반드시 나오며, 지금뿐 아니라 앞으로의 상황에도 도움이 됨 - SQLite가 쓰기 자체를 못하는 건 아님. 다만 한 번에 쓰기 트랜잭션 하나만 지원함
SQLite의 트랜잭션 동시성 성능을 신뢰하기 어렵다면 특정 스레드나 프로세스에서 모든 쓰기를 직렬화하면 됨 - 여기서는 잘 동작했고 SQLite에 대한 생각이 바뀌었음. Hipp도 함께 작업했는데, SQLite는 엄청난 성능을 낼 수 있음: https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
- 분석 데이터는 보통 쓰기 중심이라 ClickHouse를 추천함
ClickHouse의 async-insert[0] 기능을 쓰면 애플리케이션 쪽에서 이벤트 묶음 처리를 걱정하지 않아도 됨
내장형 솔루션을 찾는다면 ClickHouse 기반의 chDB를 쓸 수 있음
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli... - 묶음 쓰기는 아마 좋은 생각이지만, SQLite로 이런 걸 할 때 가장 좋은 방법은 WAL을 쓰고 지정된 단일 작성자 하나를 두는 것임
읽기 작업자는 원하는 만큼 둘 수 있고, 작성자는 큐 같은 것에서 입력을 받으면 됨
이렇게만 하면 성능이 정말 놀라운 경우가 많음
- 꽤 합리적이고 좋은 아이디어라고 봄. 여러 시스템에서 비슷한 방식을 구현해봤고, 일반적으로 묶음 처리는 항목당 오버헤드를 줄여줌
-
SQLite를 프로덕션 백엔드 데이터베이스로 쓰려는 흐름은 아직 잘 이해가 안 됨
SQLite는 휴대폰 주소록 같은 작고 내장 가능한 클라이언트 측 애플리케이션 데이터베이스로는 훌륭하지만, 개발자들 자신도 그 범위를 넘어서 확장되는 걸 꾸준히 거부해왔음
예를 들어 날짜/시간이나 UUID 같은 유용한 네이티브 타입을 추가하지 않음. 코드와 내장 객체 크기가 커지기 때문이고, 결과적으로 “모든 것이 문자열”인 상태에 갇힘
참조 무결성은 켤 수 있지만 제약 조건 옵션도 매우 제한적임
왜 맞지 않고 제대로 지원되지도 않는 역할에 계속 억지로 끼워 넣으려는지 모르겠음- 먼저 Richard Hipp가 SQLite가 “원래 의도된” 역할에 대해 같은 생각일지는 모르겠음
이유는 단순함. 읽기 중심 접근 패턴에서는 SQLite가 엄청나게 빠르고, 충분히 빨라서 데이터베이스 접근 코드를 단순화할 수 있으며, 예컨대 N+1 쿼리도 실제로 문제가 안 되는 경우가 많음
또한 SQLite는 N계층 아키텍처에서 한 계층을 제거하고, 그만큼 고장 날 수 있는 요소도 줄임. 직접 Postgres나 MySQL을 운영해봤다면 실제로 많은 일이 잘못될 수 있다는 걸 알 것임
모든 앱, 심지어 다수의 앱에 완벽한 선택은 아니지만, 지금의 흐름은 SQLite가 “작은 내장형 클라이언트 앱 데이터베이스”에만 적합하다는 명백히 틀린 생각에 대한 균형 조정에 가까움 - 결국 “API 서버”가 사실은 처음부터 DBMS였다는 걸 깨닫게 됨
그렇게 보고 나면, 같은 데이터를 다루는 DBMS 옆에 또 다른 DBMS가 있는 구조가 꽤 우스워짐
그래서 클라이언트가 Postgres에 직접 연결하게 하거나, Postgres를 빼고 자체 DBMS에 더 집중하는 방향으로 나뉨
2번을 택하면 SQLite는 그 위에 만들기 편한 엔진임. 완벽하진 않아도 지금 가진 도구임
이런 깨달음이 대규모로 퍼진 건 비교적 최근이라, 무엇이 되고 안 되는지 알아보는 실험이 많이 진행 중임
컴퓨팅의 자연스러운 순환이고, 오래된 것이 다시 새로워지는 셈임
Postgres는 MySQL, MSSQL, Oracle 또는 다른 DBMS로 바꿔 읽어도 됨 - UUID는 문자열이나 BLOB으로, 날짜는 문자열 또는 정수/실수 타임스탬프로 쓰면 되지 않나?
이런 단순화는 SQLite 개발자나 저사양 하드웨어뿐 아니라 앱 개발자에게도 여러모로 이익을 줌
문서가 단순해지고 학습 곡선이 짧아지며, 버그 표면과 바이너리 크기도 줄어듦
요즘 소프트웨어에는 모든 것에 비대함과 복잡성을 더하려는 경향이 있는데, SQLite 같은 몇몇 프로젝트가 거기에 맞서고 있어서 매우 좋음
- 먼저 Richard Hipp가 SQLite가 “원래 의도된” 역할에 대해 같은 생각일지는 모르겠음
-
훌륭한 글이고, Django에도 비슷한 자료가 있는지 궁금함
ArchiveBox는 Django를 통해 SQLite를 쓰는데, 글에서 Rails에 대해 설명한 것과 정확히 같은 문제를 꽤 자주 겪었음
앱의 다른 경로로 모든 쓰기를 직렬화하지 않아도 되는 SQLite 계층 솔루션이 있으면 좋겠음 -
sqlite3-rubygem은 설계상 SQLite 호출 중 GVL을 해제하지 않는데, 링크된 이슈 댓글 https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss...을 보면 락을 다시 획득하는 비용이 크다는 의심은 있었지만 검증은 안 한 것처럼 보임
이 모든 우회 작업을 생각하면 좀 미심쩍음
Python 확장 문화라면 반대로 설계됐을 것 같은데, 실제로 거기서는 어떻게 하는지 궁금함
추가로 링크된 이슈에는 “extralite gem은 블로킹 중 GVL을 해제하는 대체 SQLite 클라이언트이며, 동시성 관련 내용은 https://github.com/digital-fabric/extralite?tab=readme-ov-fi...에 있다. 일반적으로 이 gem보다 훨씬 빠르고 동시성 문제도 없다”는 댓글도 있음- 더 자세한 논의는 https://github.com/sparklemotion/sqlite3-ruby/pull/528와 https://github.com/digital-fabric/extralite/pull/46에서 볼 수 있음
SQLite 가상 머신의 모든step마다 단순히 GVL을 해제하면 단일 스레드 성능이 크게 나빠진다는 점이 검증됐음
단일 스레드와 다중 스레드 성능 사이의 중간 지점을 찾는 건 까다로움
Rails에서는 연결 풀 때문에 다중 스레드임을 알고 있지만, 저수준 gem은 단일 스레드 환경에서 쓰이는 다른 라이브러리와 도구에서도 많이 사용됨
- 더 자세한 논의는 https://github.com/sparklemotion/sqlite3-ruby/pull/528와 https://github.com/digital-fabric/extralite/pull/46에서 볼 수 있음
-
개인 장난감 웹서비스에 유지해두는 몇 가지 조정값은 다음과 같음
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = 1000000000;
PRAGMA foreign_keys = true;
PRAGMA temp_store = memory;
그리고 BEGIN IMMEDIATE 트랜잭션을 사용함
https://kerkour.com/sqlite-for-serverscache_size와mmap_size에 대해서는 어떻게 보는지 궁금함
-
SQLite도 Rails도 좋아하지만, 이건 프로덕션 환경에서 MS Access를 쓰는 것과 비슷해 보임
- 아주 다르진 않지만, 읽기 위주 시나리오에서는 SQLite가 Jet(MS Access)보다 훨씬 성능이 좋음
게다가 Access 사용이 더 많던 수십 년 전보다 지금은 컴퓨터와 디스크 속도가 훨씬 좋아졌음
읽기 위주라면 SQLite는 물론 Jet도 초당 수만 요청까지 꽤 쉽게 도달할 수 있을 것임
대부분의 애플리케이션은 동시 사용자 수가 수십만 명에 이르지 않으므로 SQLite가 잘 맞을 수 있음
SQLite는 거의 모든 플랫폼과 언어에 클라이언트가 있다는 점도 강점임
아카이브, 백업, 이식성도 SQLite에 잘 맞는 활용처임. 특정 기간에 한정된 데이터 입력이 있는 프로젝트에서 박스별 SQLite 사용을 강하게 밀었고, 지금도 그게 더 나았을 거라고 느낌
복잡한 스키마와 사용자 정의 내보내기/아카이브 기능을 만드는 대신 파일 하나를 복사해 아카이브나 백업으로 삼을 수 있고, 시간이 지나며 생기는 스키마 변경도 그렇게 깊게 고려하지 않아도 됐을 것임
상황에 따라 다르지만, 많은 문제에 꽤 괜찮은 해결책임. 대부분의 애플리케이션에서 더 확장성 높은 NoSQL 옵션보다 PostgreSQL이나 다른 관계형 DBMS가 더 나은 선택인 경우가 많은 것과 비슷함
과도하게 설계하는 경향이 있었고, 이제는 컴퓨팅과 입출력 성능이 그런 노력을 점점 덜 정당화하는 수준에 접근하고 있음 - 웹 앱용 MS Access 같은 것이 있으면 좋겠음. 여러 웹사이트 빌더 제품을 써봤지만 Access만큼 직관적인 건 없었음
- 왜 그런지 궁금함. 특히 읽기 중심 애플리케이션이라면 어떤 확장 한계를 걱정하는 건가?
- Pieter Levels에게는 이 방식이 통함. 물론 사용자 수가 특정 임계값을 넘으면 문제가 생길 것임
- 아주 다르진 않지만, 읽기 위주 시나리오에서는 SQLite가 Jet(MS Access)보다 훨씬 성능이 좋음
-
아주 유익하고 잘 쓴 글임
기본busy_timeout메서드가 오래된 쿼리를 벌주는 지수 지연을 왜 갖는지 궁금함
이게 기본값으로 합리적인 이유가 뭘까?