Concurrencia en SQLite y por qué debería importarte
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
SQLite es un motor de base de datos potente, pero debido a su diseño tiene limitaciones que no deben pasarse por alto.
Jellyfin ha utilizado una base de datos basada en SQLite para almacenar la mayoría de sus datos durante años, pero también ha encontrado problemas en muchos sistemas. En esta publicación, explicaré cómo abordamos estas limitaciones y cómo los desarrolladores que usan SQLite pueden aplicar las mismas soluciones.
Esta será una publicación técnica dirigida a desarrolladores y cualquier persona que quiera aprender sobre concurrencia.
Además, la implementación de bloqueo para SQLite en Jellyfin debería ser bastante fácil de adaptar en otra aplicación de EF Core si enfrentas el mismo problema.
- JPVenson
La premisa
SQLite es un motor de base de datos basado en archivos que se ejecuta dentro de tu aplicación y te permite almacenar datos en una estructura relacional. En general, proporciona a tu aplicación los medios para almacenar datos estructurados como un único archivo sin depender de otra aplicación externa. Naturalmente, esto también tiene un costo. Si tu aplicación gestiona completamente este archivo, debes asumir que tu aplicación es la única propietaria y que nadie más lo manipulará mientras escribes datos.
Por lo tanto, una aplicación que quiera usar SQLite como base de datos debe ser la única que acceda a ella. Establecido este hecho, surge una consideración importante: si solo debe realizarse una operación de escritura en un archivo a la vez, esta regla también debe aplicarse a las operaciones dentro de la misma aplicación.
El modo W-A-L
SQLite tiene una característica que intenta sortear esta limitación: el Write-Ahead-Log (WAL). El WAL es un archivo separado que actúa como diario de operaciones que deben aplicarse al archivo SQLite. Esto permite que múltiples escrituras paralelas ocurran y se encolen en el WAL. Cuando otra parte de la aplicación quiere leer datos, lee desde la base de datos real, luego escanea el WAL en busca de modificaciones y las aplica sobre la marcha. Esta no es una solución infalible; aún hay escenarios donde el WAL no previene conflictos de bloqueo.
Transacciones en SQLite
Una transacción debe garantizar dos cosas: Las modificaciones realizadas dentro de una transacción pueden revertirse, ya sea cuando algo sale mal o cuando la aplicación lo decide. Opcionalmente, una transacción también puede bloquear a otros lectores para que no lean datos modificados dentro de ella. Aquí es donde se pone interesante y llegamos a la verdadera razón de esta publicación. Por algún motivo, en algunos sistemas que ejecutan Jellyfin, cuando ocurre una transacción, el motor SQLite reporta que la base de datos está bloqueada y, en lugar de esperar a que se resuelva la transacción, el motor se niega a esperar y simplemente falla. Este parece ser un problema no poco común y existen muchos reportes al respecto.
El factor que agrava este problema es que no ocurre de manera confiable. Hasta ahora, solo un miembro del equipo puede reproducirlo (algo) consistentemente, lo que lo convierte en un error aún más molesto. Según los reportes, este problema ocurre en todos los sistemas operativos, velocidades de disco y con o sin virtualización. Así que no hemos identificado ningún factor decisivo que contribuya a la probabilidad de que ocurra.
El factor Jellyfin
Establecida la teoría general sobre el comportamiento de SQLite, también debemos examinar los detalles de cómo Jellyfin usa SQLite. Durante operaciones normales en una configuración recomendada (almacenamiento no en red y preferiblemente SSD), es inusual que surjan problemas. Sin embargo, la forma en que Jellyfin utiliza la base de datos SQLite hasta la versión 10.11 es muy subóptima. En versiones anteriores a la 10.11, Jellyfin tenía un error en su límite de tareas paralelas que resultaba en una planificación excesiva exponencial de operaciones de escaneo de biblioteca. Esto saturaba el motor de base de datos con miles de solicitudes de escritura paralelas que SQLite simplemente no puede manejar. Si bien la mayoría de las implementaciones de SQLite tienen comportamientos de reintento, también tienen tiempos de espera y controles para evitar esperas infinitas. Si estresamos suficiente el motor, simplemente falla con un error. Esto, sumado a transacciones muy prolongadas y francamente no optimizadas, podría saturar la base de datos con solicitudes y causar fallos.
La solución
Dado que hemos migrado la base de código a EF Core propiamente dicho, contamos con las herramientas para abordar esto realmente, ya que EF Core nos proporciona un nivel de abstracción estructurado. EF Core permite interceptar cada ejecución de comandos o transacciones mediante la creación de Interceptores. Con un interceptor, finalmente podemos implementar la idea directa de evitar escrituras paralelas en la base de datos de forma transparente para el llamador. La idea general es tener múltiples estrategias de bloqueo. Dado que todos los niveles de sincronización inevitablemente tendrán un costo en el rendimiento, solo queremos aplicarlos cuando sea estrictamente necesario. Por ello, decidí implementar tres estrategias de bloqueo:
-
Sin bloqueo
-
Bloqueo optimista
-
Bloqueo pesimista
Por defecto, el comportamiento sin bloqueo hace exactamente lo que su nombre indica: nada. Esta es la opción predeterminada porque mi investigación muestra que para el 99% de los casos este no es un problema, y cada interacción en este nivel ralentizaría toda la aplicación.
Tanto el bloqueo optimista como el pesimista utilizan dos interceptores: uno para transacciones y otro para comandos, además de sobrescribir SaveChanges en JellyfinDbContext.
Comportamiento de bloqueo optimista
El bloqueo optimista asume que la operación en cuestión tendrá éxito y solo maneja los problemas posteriormente. En esencia, se resume en "intentar, reintentar y reintentar..." un número determinado de veces hasta que tengamos éxito o fallemos definitivamente. Esto aún deja abierta la posibilidad de no poder realizar una escritura, pero la sobrecarga introducida es mucho menor que en el bloqueo pesimista.
La idea detrás de su funcionamiento es simple: cada vez que dos operaciones intenten escribir en la base de datos, una siempre ganará. La otra fallará, esperará un tiempo y luego reintentará varias veces.
Jellyfin utiliza la biblioteca Polly para implementar el comportamiento de reintento y solo reintentará operaciones que detecte bloqueadas debido a este problema específico.
Comportamiento de bloqueo pesimista
El bloqueo pesimista siempre adquiere un bloqueo cuando debe realizarse una escritura en SQLite. Esencialmente, cada vez que se inicia una transacción o se realiza una operación de escritura en la base de datos mediante EF Core, Jellyfin esperará hasta que finalicen todas las demás operaciones de lectura y luego bloqueará cualquier otra operación (ya sea de lectura o escritura) hasta que se complete la escritura en curso. Esto significa que Jellyfin solo podrá realizar una única escritura en la base de datos, incluso cuando técnicamente no sería necesario.
En teoría, una aplicación no debería tener problemas para leer de la tabla "Alice" mientras escribe en la tabla "Bob". Sin embargo, para eliminar todas las posibles fuentes de bloqueo por concurrencia, Jellyfin solo permitirá una única escritura en su base de datos en este modo. Si bien esto garantizará la operación más estable, también será indudablemente la más lenta.
Jellyfin utiliza un ReaderWriterLockSlim para bloquear las operaciones, lo que significa que permitimos un número ilimitado de lecturas concurrentes mientras solo se permite una escritura en la base de datos.
Futuro comportamiento de bloqueo inteligente
En el futuro, podríamos considerar combinar ambos modos para obtener lo mejor de ambos mundos.
El resultado
Las pruebas iniciales mostraron que ambos modos tuvieron gran éxito en manejar el problema subyacente. Aunque aún no estamos seguros de por qué esto ocurre solo en algunos sistemas mientras otros funcionan, al menos ahora tenemos una opción para usuarios que previamente no podían usar Jellyfin.
Durante mi investigación sobre este tema, encontré numerosos reportes en internet que enfrentaban el mismo error, pero nadie pudo proporcionar una explicación concluyente sobre lo que realmente ocurre. Ha habido propuestas similares para manejarlo, pero no existía una solución "lista para implementar" que cubriera todos los casos diferentes, o solo código que requería modificaciones masivas en cada consulta de EF Core. La implementación de Jellyfin de los comportamientos de bloqueo debería ser una solución de copiar-pegar para cualquiera que enfrente los mismos problemas, ya que utiliza interceptores y el llamador no tiene conocimiento del comportamiento real del bloqueo.
¡Mucha suerte!
- JPVenson