Cómo y por qué usar SQLite en Rails para obtener un rendimiento óptimo
(fractaledmind.github.io)- Aunque SQLite todavía se queda corto para usarse en producción con Rails tal como viene por defecto, con algunos ajustes adicionales se pueden crear aplicaciones de alto rendimiento y resilientes
- Cuando hay carga de escritura concurrente, el bloqueo global de escritura de SQLite provoca
SQLITE_BUSY, y el flujo típico de transacciones de escritura en Rails no encaja bien con el modo deferred predeterminado - Desde
sqlite3-ruby1.6.9 se puede cambiar el modo de transacción predeterminado a IMMEDIATE endatabase.yml, para esperar y reintentar de forma más segura cuando falle la adquisición del bloqueo de escritura busy_timeoutmantiene tomado el GVL de Ruby incluso mientras espera, lo que perjudica el paralelismo de Puma; si se usasleepdentro debusy_handlerpara soltar el GVL y se reintenta cada 1 ms, se reduce la latencia de cola larga- El modo WAL, las transacciones IMMEDIATE, un busy handler que libera el GVL, la configuración predeterminada de SQLite en Rails 7.1 y la separación opcional de pools de conexión de lectura/escritura son los ejes clave para mejorar el rendimiento de SQLite on Rails
Límites que aparecen con la configuración básica de Rails + SQLite
- Para operar una aplicación Rails basada en SQLite con buen rendimiento y resiliencia, la configuración predeterminada actual no es suficiente
- El objetivo es que la experiencia predeterminada de SQLite en Rails 8 esté lista para producción
- La aplicación de demostración es Lorem News
- Es un clon básico al estilo Hacker News con usuarios, publicaciones y comentarios
- El contenido está compuesto por Lorem Ipsum
- Para las pruebas de carga se usan oha load testing CLI y las benchmarking routes integradas en la app
- Si se envían solicitudes secuenciales al endpoint
post#createdurante 5 segundos, el RPS se mantiene estable y todas las solicitudes tienen éxito - Si se envían 4 solicitudes concurrentes al mismo endpoint durante 5 segundos, algunas solicitudes devuelven respuestas 500
SQLITE_BUSY y las transacciones IMMEDIATE
- El primer problema que aparece en los logs es la excepción
SQLITE_BUSY - SQLite usa un bloqueo de escritura sobre la base de datos para permitir solo una operación de escritura a la vez
- Solo una conexión puede tener el bloqueo de escritura al mismo tiempo
- Si otra conexión ya tiene el bloqueo y una nueva intenta obtenerlo, ocurre
SQLITE_BUSY
- A medida que una app Rails recibe más carga concurrente, también aumenta la proporción de solicitudes que fallan con
SQLITE_BUSY - El modo de transacción predeterminado de SQLite es deferred, y no adquiere el bloqueo hasta que ocurre una escritura real
- Eso es ventajoso en rendimiento en entornos de una sola conexión o con muchas transacciones de solo lectura
- Pero una app Rails en producción usa varias conexiones desde varios hilos, y Rails envuelve las consultas de escritura en transacciones, así que ese valor predeterminado choca con ese patrón
- Si falla la adquisición del bloqueo de escritura a mitad de la transacción, SQLite no puede reintentar esa consulta de forma segura sin romper el aislamiento serializable, así que lanza la excepción de inmediato
- Las transacciones
IMMEDIATEintentan adquirir el bloqueo de escritura desde el inicio de la transacción- SQLite puede poner la consulta de escritura en cola e intentar adquirir el bloqueo más tarde
- Este enfoque encaja mejor con el patrón de transacciones de escritura de Rails que deferred
- El gem
sqlite3-rubysoporta configurar el modo de transacción predeterminado desde la versión 1.6.9- Rails pasa las claves de nivel superior de
database.ymla la inicialización de base de datos desqlite3-ruby - Así se puede configurar en
database.ymlque las transacciones SQLite de Rails se ejecuten en modo IMMEDIATE
- Rails pasa las claves de nivel superior de
- Después de este cambio, en pruebas simples de carga casi no hay errores 500 incluso con carga concurrente, pero con 16 solicitudes concurrentes vuelven a aparecer algunos errores
busy_timeout, GVL y busy_handler personalizado
- El siguiente cuello de botella aparece cuando el número de solicitudes concurrentes se acerca o supera el número de workers de Puma: la latencia p99 sube de forma abrupta
- El tiempo real de procesamiento de las solicitudes se mantiene estable incluso con una carga concurrente de 3 veces el número de workers de Puma, pero cuando empiezan a aparecer solicitudes que tardan unos 5 segundos, también aparecen respuestas 500 por
SQLITE_BUSY - Esos 5 segundos coinciden con la configuración de timeout en
database.yml, que se mapea al ajustebusy_timeoutde SQLite busy_timeoutintenta volver a adquirir el bloqueo de escritura durante la cantidad de milisegundos especificada en lugar de lanzar de inmediato una excepciónBUSY- SQLite intenta recuperar el bloqueo con una especie de backoff exponencial
- Solo lanza la excepción
BUSYsi no logra obtener el bloqueo dentro del timeout - Así, una aplicación web puede abrir varias conexiones y dejar que SQLite ordene las escrituras sin tener que coordinarlo por su cuenta
- El cuello de botella existe porque SQLite está embebido dentro del proceso Ruby, y
sqlite3-rubyno libera el GVL de Ruby al llamar al código C de SQLite- Mientras un worker de Puma espera el retorno de una consulta a la base de datos, sigue reteniendo el GVL
- Eso hace difícil incluso que otros workers de Puma envíen consultas de escritura a SQLite al mismo tiempo
- La naturaleza secuencial de las escrituras en SQLite vuelve también más lineal el procesamiento de solicitudes en Rails y reduce mucho el throughput
- SQLite ofrece un hook
busy_handlerde más bajo nivel quebusy_timeoutbusy_timeoutes una implementación específica debusy_handlerprovista por SQLitesqlite3-rubyofrece un binding a la función Csqlite3_busy_handler, así que se puede crear un callback en Ruby que se invoque cuando una consulta entra en cola
- Si en ese callback Ruby se usa
Kernel.sleep, se puede liberar el GVL mientras la consulta espera para reintentar el bloqueo de escritura - Este enfoque mejora mucho la latencia p99 bajo carga concurrente, pero en la latencia p99.99 todavía queda el problema de que las solicitudes más lentas se vuelven aún más lentas conforme aumenta la concurrencia
La cola larga del backoff exponencial y los reintentos cada 1 ms
- La lógica de
busy_timeoutde SQLite reimplementada en Ruby tiene una estructura que perjudica más a las consultas que llevan más tiempo esperando - En los primeros reintentos el tiempo de espera es pequeño, pero aumenta conforme crece el número de invocaciones del callback
- En la primera espera, espera 1 ms
- En la décima llamada, espera 50 ms
- A partir de la llamada 12, espera 100 ms cada vez
- Si el tiempo de espera acumulado supera el timeout de 5000 ms, se lanza una excepción
- Si siguen entrando nuevas consultas de escritura, las consultas nuevas pueden reintentar adquirir el bloqueo con más frecuencia gracias a sus esperas más cortas
- Mientras una consulta antigua que ya esperó tres veces aguarda 10 ms, una consulta nueva puede pasar por esperas de 1 ms, 2 ms y 5 ms y reintentar tres veces
- Este backoff creciente aumenta la probabilidad de que las consultas más antiguas no logren obtener el bloqueo de escritura y terminen por agotar el timeout
- La solución es hacer que todas las consultas reintenten con la misma frecuencia sin importar su antigüedad
- Este cambio ya está reflejado en la rama principal de
sqlite3-ruby- Al momento de escribir el artículo, esa función todavía no está incluida en una release etiquetada
- El callback Ruby libera el GVL mientras espera usando
sleep - Siempre hace
sleepde solo 1 ms
- Después de este cambio, en los benchmarks la curva de latencia p99.99 se aplana
- Sigue habiendo un salto cuando la concurrencia supera la mitad del número de workers de Puma
- Después de eso, la latencia de cola larga se estabiliza alrededor de 0.5 segundos
Modo WAL y separación de pools de conexión de lectura/escritura
- Las cuatro condiciones necesarias para el rendimiento de SQLite on Rails son transacciones IMMEDIATE, un busy handler que libera el GVL, una configuración adecuada de SQLite y el modo WAL
- El write-ahead log permite que SQLite maneje varias lecturas concurrentes
- El modo rollback journal predeterminado permite solo una consulta a la vez, ya sea de lectura o escritura
- El modo WAL permite varios readers al mismo tiempo, pero sigue permitiendo solo un writer a la vez
- Desde Rails 7.1, Rails aplica mejores configuraciones predeterminadas para bases de datos SQLite
- Estas configuraciones son importantes para que SQLite funcione bien en el contexto de una aplicación web
- Los detalles y el porqué se tratan en otra entrada de blog
- Una quinta palanca opcional de rendimiento es separar un pool de solo lectura y un pool de solo escritura
- El modo WAL de SQLite soporta varias conexiones de lectura y una sola conexión de escritura
- Si el pool de conexiones de Active Record se satura con conexiones de escritura, las lecturas pueden bloquearse innecesariamente
- Se puede aprovechar el soporte de múltiples bases de datos de Rails para hacer que las configuraciones de reader y writer apunten a la misma base de datos SQLite
- En realidad no son bases de datos separadas, sino la misma base de datos única
- El resultado es que se pueden crear pools de conexión y configuraciones de conexión separados entre sí
- El pool de conexiones reader se compone de conexiones de solo lectura y el pool writer tiene una sola conexión
- Los modelos de Active Record se configuran para conectarse al pool de conexión adecuado según su rol
- Con el automatic role switching de Rails, la conexión predeterminada de todas las solicitudes web puede ser el pool reader, y cambiar al pool writer solo cuando haga falta escribir en la base de datos
- Como se usa la misma base de datos, no hace falta
delaypara garantizarread your own writes - Se parchea el método
transactiondel adaptador ActiveRecord para que la transacción se conecte a la base de datos writer
- Como se usa la misma base de datos, no hace falta
- Esta combinación de “deferred requests” y pools de conexión aislados muestra una mejora de rendimiento en términos de RPS simple en la prueba del endpoint de creación de comentarios
Mejoras empaquetadas como gem
- No hace falta implementar todas estas mejoras directamente dentro de la app Rails
- Instalando
activerecord-enhancedsqlite3-adapterse pueden aplicar las mejoras relacionadas - La función de pools de conexión aislados es más nueva y experimental, así que requiere configuración adicional para activarla de forma opt-in
- Este enfoque ofrece en paquete las herramientas, técnicas y valores predeterminados para usar SQLite de forma rápida y flexible en producción con Rails
- Rails es un framework de aplicaciones web muy adecuado para trabajar con SQLite, y el ecosistema de herramientas y gems relacionadas sigue creciendo
1 comentarios
Opiniones de Hacker News
Si estás pensando en usar SQLite + Rails, vale mucho la pena mirar el proyecto Litestack de Oldmoe (X/GitHub).
Litestack es una gem de Ruby que aprovecha la naturaleza embebida de SQLite en apps de Ruby y Ruby on Rails para ofrecer, en un solo paquete, una base de datos SQL, caché rápido, cola de trabajos, broker de mensajes, motor de búsqueda de texto completo y plataforma de métricas.
Lo estoy usando en un proyecto actual y estoy muy satisfecho; el enlace es https://github.com/oldmoe/litestack
No puedo imaginar cuántos días habrá tomado escribir un artículo tan detallado.
Es un texto útil para cualquiera que esté pensando en escalar aplicaciones web con SQLite, más allá de Rails.
Cualquiera que trabaje con SQLite debería leer este artículo, sin importar el lenguaje o framework que use.
Hace unos años tuve que descubrir por mi cuenta la mayor parte de esto, así que se agradece que lo hayan organizado.
Estoy creando un sistema de analítica FOSS y necesito que sea fácil de instalar, así que quiero enviar los datos de eventos a una base de datos SQLite separada, independiente de los datos principales de la app.
Incluso un sitio web medianamente ocupado puede generar más de 1000 eventos por segundo, así que me preocupa el escalado.
Me pregunto si acumular eventos en la memoria del servidor y hacer una escritura por lotes una vez por segundo sería una forma razonable de evitar la limitación de múltiples escrituras de SQLite, o si hay mejores ideas.
También se puede demostrar fácilmente con benchmarks, y se puede meter todo el lote de elementos en una sola transacción.
Con procesamiento por lotes, en la práctica terminas con un único hilo que toma el batch y realiza la escritura real, así que encaja bien con la limitación de una sola escritura concurrente de SQLite.
Eso sí, aumenta un poco la complejidad. Hay que decidir qué hacer si una sola escritura por lotes no termina en menos de 1 segundo, si la cola almacenada en memoria tendrá tamaño ilimitado, si siendo ilimitada puedes estar seguro de que el servidor no morirá por OOM cuando esté sobrecargado, y si le pones límite, si aceptarás perder elementos; si los descartas, cuáles descartarás y cuál será el límite de la cola.
Estas preguntas aparecen inevitablemente en casi cualquier sistema que necesita una cola, y serán útiles no solo ahora sino también para situaciones futuras.
Si no confías en el rendimiento de concurrencia de transacciones de SQLite, puedes serializar todas las escrituras en un hilo o proceso específico.
Si usas la función async-insert[0] de ClickHouse, no tienes que preocuparte por el procesamiento por lotes de eventos del lado de la aplicación.
Si buscas una solución embebida, puedes usar chDB, que está basado en ClickHouse.
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
Puedes tener tantos workers de lectura como quieras, y el escritor puede recibir entradas desde algo como una cola.
Con solo eso, el rendimiento muchas veces resulta realmente sorprendente.
Todavía no entiendo bien la tendencia de usar SQLite como base de datos backend en producción
SQLite es excelente como base de datos pequeña, embebible y del lado del cliente, como la libreta de contactos de un teléfono, pero sus propios desarrolladores han rechazado constantemente que se expanda más allá de ese alcance
Por ejemplo, no agregan tipos nativos útiles como fecha/hora o UUID. Es porque eso aumentaría el tamaño del código y de los objetos embebidos y, como resultado, queda atrapado en un estado donde “todo es una cadena”
La integridad referencial se puede activar, pero las opciones de restricciones también son muy limitadas
No entiendo por qué siguen intentando forzarlo en un rol para el que no encaja y que ni siquiera está bien soportado
La razón es simple. En patrones de acceso centrados en lectura, SQLite es extremadamente rápido, lo bastante rápido como para simplificar el código de acceso a la base de datos, y por ejemplo muchas veces las consultas N+1 en la práctica no son un problema
Además, SQLite elimina una capa en una arquitectura de N niveles, y con eso también reduce los componentes que pueden fallar. Si alguna vez operaste Postgres o MySQL directamente, sabrás que en la práctica pueden salir mal muchas cosas
No es la opción perfecta para todas las apps, ni siquiera para muchas, pero la tendencia actual se parece más a un reajuste de equilibrio frente a la idea claramente equivocada de que SQLite solo es adecuado como “pequeña base de datos embebida para apps cliente”
Una vez que lo ves así, resulta bastante ridículo tener otro DBMS junto a un DBMS que maneja los mismos datos
Por eso los caminos se dividen entre hacer que el cliente se conecte directamente a Postgres, o quitar Postgres y enfocarse más en el DBMS propio
Si eliges la segunda opción, SQLite es un motor cómodo sobre el cual construir. No es perfecto, pero es la herramienta que tenemos ahora
Esta toma de conciencia se difundió a gran escala hace relativamente poco, así que hay muchos experimentos en marcha para ver qué funciona y qué no
Es un ciclo natural de la computación: lo viejo vuelve a ser nuevo
Postgres puede leerse también como MySQL, MSSQL, Oracle u otro DBMS
Este tipo de simplificación beneficia de varias maneras no solo a los desarrolladores de SQLite o al hardware de bajos recursos, sino también a los desarrolladores de apps
La documentación se simplifica, la curva de aprendizaje se acorta, y también se reduce la superficie de bugs y el tamaño del binario
En el software actual hay una tendencia a agregar hinchazón y complejidad a todo, y es muy bueno que algunos proyectos como SQLite se opongan a eso
Excelente artículo, y me pregunto si hay material parecido para Django
ArchiveBox usa SQLite a través de Django, y he tenido con bastante frecuencia exactamente los mismos problemas que el artículo describe para Rails
Sería bueno tener una solución a nivel de SQLite que no obligue a serializar todas las escrituras por otros caminos de la app
La gem
sqlite3-ruby, por diseño, no libera el GVL durante las llamadas a SQLite; viendo el comentario del issue enlazado https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss..., parece que se sospechaba que el costo de volver a adquirir el lock era alto, pero no se había verificadoCon todos estos rodeos, me genera cierta duda
En la cultura de extensiones de Python creo que se habría diseñado al revés; me pregunto cómo lo hacen realmente ahí
Además, en el issue enlazado también hay un comentario que dice: “la gem extralite es un cliente SQLite alternativo que libera el GVL mientras bloquea, y el contenido sobre concurrencia está en https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... En general es mucho más rápida que esta gem y no tiene problemas de concurrencia”
Se verificó que simplemente liberar el GVL en cada
stepde la máquina virtual de SQLite empeora mucho el rendimiento de un solo hiloEncontrar un punto intermedio entre rendimiento monohilo y multihilo es complicado
En Rails se sabe que es multihilo por el pool de conexiones, pero una gem de bajo nivel también se usa mucho en otras bibliotecas y herramientas que corren en entornos monohilo
Algunos ajustes que mantengo en mi servicio web personal de juguete son estos
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Y uso transacciones BEGIN IMMEDIATE
https://kerkour.com/sqlite-for-servers
cache_sizeymmap_sizeMe gustan tanto SQLite como Rails, pero esto se parece a usar MS Access en un entorno de producción
Además, hoy las computadoras y los discos son mucho más rápidos que hace décadas, cuando Access se usaba más
Si es principalmente de lectura, tanto SQLite como incluso Jet podrían llegar con bastante facilidad a decenas de miles de solicitudes por segundo
La mayoría de las aplicaciones no llegan a tener cientos de miles de usuarios concurrentes, así que SQLite puede encajar bien
Otra ventaja de SQLite es que tiene clientes para casi todas las plataformas y lenguajes
Archivado, respaldos y portabilidad también son casos de uso que le quedan bien a SQLite. En un proyecto con ingreso de datos limitado a un período específico, insistí mucho en usar SQLite por cada caja, y todavía siento que habría sido mejor
En vez de crear un esquema complejo y funciones personalizadas de exportación/archivo, se podía copiar un solo archivo para usarlo como archivo o respaldo, y tampoco habría sido necesario preocuparse tan a fondo por los cambios de esquema con el tiempo
Depende de la situación, pero es una solución bastante buena para muchos problemas. Es parecido a cómo, en la mayoría de las aplicaciones, PostgreSQL u otro DBMS relacional suele ser una mejor opción que alternativas NoSQL más escalables
Había una tendencia a sobrediseñar, y ahora el rendimiento de cómputo y de entrada/salida se está acercando a un nivel en el que justifica cada vez menos ese esfuerzo
Es un artículo muy útil y bien escrito
Me pregunto por qué el método
busy_timeoutpredeterminado tiene un retraso exponencial que castiga las consultas antiguas¿Cuál será la razón por la que esto tiene sentido como valor predeterminado?