1P by GN⁺ | ★ favorite | 댓글 1개
  • Rails에서 SQLite를 기본값 그대로 운영 환경에 쓰기에는 아직 부족하지만, 몇 가지 설정을 더하면 고성능·회복력 있는 애플리케이션을 만들 수 있음
  • 동시 쓰기 부하가 걸리면 SQLite의 전역 쓰기 락 때문에 SQLITE_BUSY가 발생하며, Rails의 일반적인 쓰기 트랜잭션 흐름은 기본 deferred 모드와 잘 맞지 않음
  • sqlite3-ruby 1.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-ruby gem은 1.6.9부터 기본 트랜잭션 모드 설정을 지원함
    • Rails는 database.yml의 최상위 키를 sqlite3-ruby 데이터베이스 초기화에 전달함
    • database.yml에서 Rails의 SQLite 트랜잭션을 IMMEDIATE 모드로 실행하도록 설정할 수 있음
  • 이 변경 후 단순 부하 테스트에서는 동시 부하에서도 거의 500 오류 없이 처리되지만, 16개 동시 요청에서는 다시 일부 오류가 나타남

busy_timeout, GVL, custom busy_handler

  • 다음 병목은 동시 요청 수가 Puma 워커 수에 접근하거나 이를 넘을 때 p99 지연시간이 급격히 증가하는 현상임
  • 실제 요청 처리 시간은 Puma 워커 수의 3배 동시 부하에서도 안정적이지만, 약 5초 걸리는 요청이 생기기 시작하면 SQLITE_BUSY 500 응답도 함께 발생함
  • 이 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-rubysqlite3_busy_handler C 함수에 대한 바인딩을 제공하므로, 쿼리가 큐에 들어갈 때 호출되는 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-ruby main 브랜치에는 이 변경이 반영되어 있음
    • 해당 기능은 글 작성 시점 기준 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를 넘어 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 같은 몇몇 프로젝트가 거기에 맞서고 있어서 매우 좋음
  • 훌륭한 글이고, Django에도 비슷한 자료가 있는지 궁금함
    ArchiveBox는 Django를 통해 SQLite를 쓰는데, 글에서 Rails에 대해 설명한 것과 정확히 같은 문제를 꽤 자주 겪었음
    앱의 다른 경로로 모든 쓰기를 직렬화하지 않아도 되는 SQLite 계층 솔루션이 있으면 좋겠음

  • sqlite3-ruby gem은 설계상 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/528https://github.com/digital-fabric/extralite/pull/46에서 볼 수 있음
      SQLite 가상 머신의 모든 step마다 단순히 GVL을 해제하면 단일 스레드 성능이 크게 나빠진다는 점이 검증됐음
      단일 스레드와 다중 스레드 성능 사이의 중간 지점을 찾는 건 까다로움
      Rails에서는 연결 풀 때문에 다중 스레드임을 알고 있지만, 저수준 gem은 단일 스레드 환경에서 쓰이는 다른 라이브러리와 도구에서도 많이 사용됨
  • 개인 장난감 웹서비스에 유지해두는 몇 가지 조정값은 다음과 같음
    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-servers

    • cache_sizemmap_size에 대해서는 어떻게 보는지 궁금함
  • SQLite도 Rails도 좋아하지만, 이건 프로덕션 환경에서 MS Access를 쓰는 것과 비슷해 보임

    • 아주 다르진 않지만, 읽기 위주 시나리오에서는 SQLite가 Jet(MS Access)보다 훨씬 성능이 좋음
      게다가 Access 사용이 더 많던 수십 년 전보다 지금은 컴퓨터와 디스크 속도가 훨씬 좋아졌음
      읽기 위주라면 SQLite는 물론 Jet도 초당 수만 요청까지 꽤 쉽게 도달할 수 있을 것임
      대부분의 애플리케이션은 동시 사용자 수가 수십만 명에 이르지 않으므로 SQLite가 잘 맞을 수 있음
      SQLite는 거의 모든 플랫폼과 언어에 클라이언트가 있다는 점도 강점임
      아카이브, 백업, 이식성도 SQLite에 잘 맞는 활용처임. 특정 기간에 한정된 데이터 입력이 있는 프로젝트에서 박스별 SQLite 사용을 강하게 밀었고, 지금도 그게 더 나았을 거라고 느낌
      복잡한 스키마와 사용자 정의 내보내기/아카이브 기능을 만드는 대신 파일 하나를 복사해 아카이브나 백업으로 삼을 수 있고, 시간이 지나며 생기는 스키마 변경도 그렇게 깊게 고려하지 않아도 됐을 것임
      상황에 따라 다르지만, 많은 문제에 꽤 괜찮은 해결책임. 대부분의 애플리케이션에서 더 확장성 높은 NoSQL 옵션보다 PostgreSQL이나 다른 관계형 DBMS가 더 나은 선택인 경우가 많은 것과 비슷함
      과도하게 설계하는 경향이 있었고, 이제는 컴퓨팅과 입출력 성능이 그런 노력을 점점 덜 정당화하는 수준에 접근하고 있음
    • 웹 앱용 MS Access 같은 것이 있으면 좋겠음. 여러 웹사이트 빌더 제품을 써봤지만 Access만큼 직관적인 건 없었음
    • 왜 그런지 궁금함. 특히 읽기 중심 애플리케이션이라면 어떤 확장 한계를 걱정하는 건가?
    • Pieter Levels에게는 이 방식이 통함. 물론 사용자 수가 특정 임계값을 넘으면 문제가 생길 것임
  • 아주 유익하고 잘 쓴 글임
    기본 busy_timeout 메서드가 오래된 쿼리를 벌주는 지수 지연을 왜 갖는지 궁금함
    이게 기본값으로 합리적인 이유가 뭘까?