1 puntos por GN⁺ 2024-09-13 | 1 comentarios | Compartir por WhatsApp
  • 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-ruby 1.6.9 se puede cambiar el modo de transacción predeterminado a IMMEDIATE en database.yml, para esperar y reintentar de forma más segura cuando falle la adquisición del bloqueo de escritura
  • busy_timeout mantiene tomado el GVL de Ruby incluso mientras espera, lo que perjudica el paralelismo de Puma; si se usa sleep dentro de busy_handler para 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#create durante 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 IMMEDIATE intentan 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-ruby soporta configurar el modo de transacción predeterminado desde la versión 1.6.9
    • Rails pasa las claves de nivel superior de database.yml a la inicialización de base de datos de sqlite3-ruby
    • Así se puede configurar en database.yml que las transacciones SQLite de Rails se ejecuten en modo IMMEDIATE
  • 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 ajuste busy_timeout de SQLite
  • busy_timeout intenta volver a adquirir el bloqueo de escritura durante la cantidad de milisegundos especificada en lugar de lanzar de inmediato una excepción BUSY
    • SQLite intenta recuperar el bloqueo con una especie de backoff exponencial
    • Solo lanza la excepción BUSY si 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-ruby no 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_handler de más bajo nivel que busy_timeout
    • busy_timeout es una implementación específica de busy_handler provista por SQLite
    • sqlite3-ruby ofrece un binding a la función C sqlite3_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_timeout de 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 sleep de 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 delay para garantizar read your own writes
    • Se parchea el método transaction del adaptador ActiveRecord para que la transacción se conecte a la base de datos writer
  • 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-adapter se 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

 
GN⁺ 2024-09-13
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.

    • Me parece una idea bastante razonable y buena. Implementé enfoques similares en varios sistemas y, en general, el procesamiento por lotes reduce el overhead por elemento.
      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.
    • No es que SQLite no pueda escribir. Simplemente soporta una sola transacción de escritura a la vez.
      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.
    • Aquí funcionó bien y cambió mi forma de ver SQLite. También trabajamos con Hipp, y SQLite puede alcanzar un rendimiento enorme: https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
    • Para datos de analítica, que suelen estar orientados a escrituras, recomiendo ClickHouse.
      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...
    • La escritura por lotes probablemente sea una buena idea, pero la mejor forma de hacer algo así con SQLite es usar WAL y tener un único escritor designado.
      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

    • Para empezar, no sé si Richard Hipp pensaría lo mismo sobre el rol para el que SQLite “fue originalmente concebido”
      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”
    • Al final uno se da cuenta de que el “servidor API” en realidad fue un DBMS desde el principio
      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
    • ¿No basta con usar UUID como cadenas o BLOB, y fechas como cadenas o timestamps enteros/reales?
      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 verificado
    Con 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 puede ver una discusión más detallada en https://github.com/sparklemotion/sqlite3-ruby/pull/528 y https://github.com/digital-fabric/extralite/pull/46
      Se verificó que simplemente liberar el GVL en cada step de la máquina virtual de SQLite empeora mucho el rendimiento de un solo hilo
      Encontrar 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

    • Me pregunto qué opinan sobre cache_size y mmap_size
  • Me gustan tanto SQLite como Rails, pero esto se parece a usar MS Access en un entorno de producción

    • No es exactamente lo mismo, pero en escenarios principalmente de lectura, SQLite rinde mucho mejor que Jet (MS Access)
      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
    • Me gustaría que existiera algo como MS Access para apps web. He probado varios productos para crear sitios web, pero ninguno fue tan intuitivo como Access
    • Me pregunto por qué será. Especialmente si se trata de una aplicación centrada en lectura, ¿qué límites de escalabilidad preocupan?
    • A Pieter Levels este enfoque le funciona. Claro que, si la cantidad de usuarios supera cierto umbral, surgirán problemas
  • Es un artículo muy útil y bien escrito
    Me pregunto por qué el método busy_timeout predeterminado tiene un retraso exponencial que castiga las consultas antiguas
    ¿Cuál será la razón por la que esto tiene sentido como valor predeterminado?