Estrategia de Fallback de Base de Datos Solo Lectura en Laravel

- Andrés Cruz - EN In english

Video thumbnail

En el diseño de arquitectura de software, el término fallback hace referencia a un mecanismo de respaldo o ruta alternativa que se activa automáticamente cuando el sistema principal experimenta un fallo, garantizando así la continuidad del servicio.

En esta sección explicaremos cómo implementar un sistema de fallback enfocado exclusivamente en operaciones de lectura, manteniendo un réplica o espejo de los datos principales. Esta estrategia resulta sumamente útil en aplicaciones web donde el tráfico es masivamente de consulta (como blogs o plataformas educativas), donde el grueso de la interacción consiste en leer publicaciones, listados o detalles de cursos.

Contexto y Limitaciones de Infraestructura

En entornos de alojamiento compartido o servidores con recursos limitados, las bases de datos de producción (como MySQL o PostgreSQL) suelen imponer un límite estricto de conexiones simultáneas (por ejemplo, un máximo de 20 conexiones concurrentes). Si se supera este umbral, el servidor rechaza las conexiones adicionales lanzando un error del sistema (como el código de error 2002: Operación no permitida).

Para mitigar este cuello de botella sin incurrir inmediatamente en costos de infraestructura más elevados, podemos implementar un mecanismo de fallback utilizando SQLite. Dado que SQLite gestiona los datos directamente en un archivo local sin pasar por un socket de red o servicio de base de datos remoto, se eliminan los límites de concurrencia por conexión del servidor principal.

Configuración de la Base de Datos SQLite

El primer paso consiste en configurar la conexión secundaria en el archivo config/database.php de Laravel, deshabilitando la revisión de claves foráneas para facilitar la sincronización masiva de datos:

config/database.php

'sqlite_fallback' => [
    'driver' => 'sqlite',
    'database' => database_path('fallback.sqlite'),
    'prefix' => '',
    'foreign_key_constraints' => false, // Evita conflictos de llaves foráneas en el fallback
],

Para inicializar el archivo y crear el esquema de tablas, se ejecutan las migraciones especificando la conexión alternativa (recuerda cambiar en el .env a sqlite -DB_CONNECTION=sqlite- ANTES de ejecutar este comando para aplicar las migraciones en la BD de respaldo):

$ php artisan migrate:fresh --database=sqlite_fallback

Sincronización de Datos para la Réplica

Puesto que el objetivo es respaldar únicamente las tablas críticas de consulta (como categorías, publicaciones o lecciones), se implementa un proceso de sincronización que limpia la base de datos local e inserta los datos actualizados desde la base de datos principal en bloques (chunks) para optimizar la memoria:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
***
public array $availableTables = [
    'categories' => 'Categorías',
    'posts' => 'Publicaciones (Posts)',
    ***
];

$syncedCount = 0;
$totalRecords = 0;
foreach ($this->selectedTables as $table) {
    if (Schema::connection('mysql')->hasTable($table)) {
        // Extraer desde MariaDB/MySQL
        $data = DB::connection('mysql')->table($table)->get()->map(fn($item) => (array) $item)->toArray();

        // Limpiar e insertar en SQLite
        DB::connection('sqlite_fallback')->table($table)->truncate();

        if (!empty($data)) {
            // Insertar en bloques de 500 registros para optimizar memoria en SQLite
            foreach (array_chunk($data, 500) as $chunk) {
                DB::connection('sqlite_fallback')->table($table)->insert($chunk);
            }
            $totalRecords += count($data);
        }

        $syncedCount++;
    }
}

Detección Automática de Fallos mediante un Trait en Eloquent

Para lograr que este cambio de conexión sea completamente transparente para controladores y componentes de la interfaz (como Livewire o Inertia), aprovechamos la naturaleza lazy (perezosa) de las conexiones en Eloquent sobrescribiendo el método getConnection() a través de un Trait reestructurado:

app/Traits/HasSqliteFallback.php

<?php

namespace App\Traits;

use Illuminate\Support\Facades\DB;
use PDOException;
use PDO;

trait HasSqliteFallback
{
    /**
     * Sobrescribe el método de obtención de conexión de Eloquent para este modelo.
     */
    public function getConnection()
    {
        try {
            // probar a fallar, fuerza una excepción
            throw new PDOException("SQLSTATE[HY000] [1040] Too many connections", 1040);

            $connection = parent::getConnection();

            // Forzamos un ping o prueba ligera del socket PDO
            $connection->getPdo();

            return $connection;

        } catch (PDOException $e) {
            // Si el socket falló, dio "Too many connections" (1040) o error de conexión
            if (in_array($e->getCode(), [1040, 2002]) || str_contains($e->getMessage(), '2002') || str_contains($e->getMessage(), 'Too many connections')) {
                \Log::warning("MySQL fuera de servicio. Conmutando modelo [" . static::class . "] a SQLite Fallback.");
                // 1. Registramos los polyfills en la conexión de SQLite antes de devolverla
                //$this->registerMysqlSqlitePolyfills('sqlite_fallback');

                // 2. Devolvemos la conexión de fallback
                return DB::connection('sqlite_fallback');
            }

            throw $e;
        }
    }
}

Es importante del uso de:

$connection->getPdo();

Ya que, hacemos una conexión ANTES de la consulta para ver si podemos conectarnos y si no es posible conectar da el error justamente en el trait, con el cual podemos MANEJAR la consulta y mandarla a la base de datos de tipo lectura.

PDO es la interfaz implementada en PHP para la conexión y gestión de base de datos.

Uso en los Modelos Eloquent

Una vez definido el Trait, simplemente se incluye en aquellos modelos que representen tablas de solo lectura o de alta consulta:

namespace App\Models;

use App\Traits\HasSqliteFallback;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasSqliteFallback;

    // Lógica del modelo...
}

Con esta implementación, ante cualquier saturación en la base de datos MySQL, Eloquent interceptará el fallo en el método getConnection() y conmutará dinámicamente hacia la réplica local SQLite sin interrumpir la experiencia del usuario ni requerir modificaciones en la capa de controladores o vistas.

Consideraciones

  • Alineación de funciones SQL: Es importante asegurarse de utilizar consultas compatibles con ambos motores, evitando métodos o funciones propias de MySQL que no estén presentes en SQLite.
  • Uso restringido a lectura: Esta arquitectura no está diseñada para sincronización bidireccional inmediata. Si se requiere persistir escrituras durante una falla, se debe incorporar una tabla de transacciones diferidas o un sistema de colas.

Estrategia de Delegación de Modelos a Bases de Datos Secundarias

Video thumbnail

En el apartado anterior abordamos la estrategia de fallback para una base de datos de solo lectura, orientada a gestionar fallos de conexión en el servidor principal cuando se supera el límite de concurrencia. Bajo ese esquema, al detectarse una excepción, la aplicación conmuta dinámicamente hacia una base de datos espejo con las tablas críticas del sistema (como publicaciones, categorías, tutoriales y libros).

A partir de esa arquitectura surge un enfoque complementario: la delegación explícita de modelos hacia bases de datos secundarias de forma permanente.

Casos de Uso para la Delegación de Conexiones

Existen diversas razones técnicas para desacoplar modelos específicos de la base de datos principal:

  • Optimización de carga: Reducir la sobrecarga de I/O en la base de datos primaria moviendo tablas de alta lectura o escritura a un servidor dedicado.
  • Soporte para estructuras especializadas: Utilizar motores específicos (como PostgreSQL con la extensión pgvector) para búsquedas vectoriales y aplicaciones con Inteligencia Artificial.
  • Sistemas de búsqueda y rendimiento: Aprovechar tecnologías más eficientes en ciertos modelos, como búsquedas de texto completo o implementación de mecanismos tipo RAG (Retrieval-Augmented Generation).

Implementación mediante un Trait en Eloquent

A diferencia del mecanismo de fallback (que solo actúa en caso de error), esta técnica fuerza a que ciertos modelos utilicen siempre una conexión alternativa predefinida.

Para lograr esto, se crea un Trait que sobrescribe el método interno getConnectionName() de Eloquent:

<?php

namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;

trait DelegatesDatabaseQueries
{
    /**
     * Define si la delegación de consultas está activa globalmente para este modelo.
     */
    protected static bool $isDelegationActive = true;

    /**
     * Sobreescribe la conexión por defecto del modelo si la delegación está activa.
     */
    public function getConnectionName()
    {
        if (static::$isDelegationActive && property_exists($this, 'delegatedConnection') && !empty($this->delegatedConnection)) {
            return $this->delegatedConnection;
        }

        return parent::getConnectionName();
    }

    /**
     * Sobreescribe el nombre de la tabla si se especifica una tabla delegada alternativa.
     */
    public function getTable()
    {
        if (static::$isDelegationActive && property_exists($this, 'delegatedTable') && !empty($this->delegatedTable)) {
            return $this->delegatedTable;
        }

        return parent::getTable();
    }

    /**
     * Scope para forzar la ejecución en la conexión/tabla delegada para una sola consulta.
     */
    public function scopeOnDelegated(Builder $query): Builder
    {
        $connection = property_exists($this, 'delegatedConnection') ? $this->delegatedConnection : null;
        $table = property_exists($this, 'delegatedTable') ? $this->delegatedTable : null;

        if ($connection) {
            $query->from($table ? "{$table}" : $this->getTable());
            $query->getModel()->setConnection($connection);
        }

        return $query;
    }

    /**
     * Desactiva temporalmente la delegación y ejecuta la consulta en la BD principal.
     */
    public static function withoutDelegation(callable $callback)
    {
        static::$isDelegationActive = false;

        try {
            return $callback();
        } finally {
            static::$isDelegationActive = true;
        }
    }
}

Uso en el Modelo Eloquent

Para delegar un modelo a la base de datos secundaria, se incluye el Trait y se define la propiedad $connection (junto con $table si el nombre difiere en la base de datos de destino):

<?php

namespace App\Models;

use App\Traits\DelegatesDatabaseQueries;
use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    use DelegatesDatabaseQueries;

    /**
     * Conexión hacia la cual se delegarán las consultas de este modelo.
     */
    protected ?string $delegatedConnection = 'sqlite_fallback';

    /**
     * (Opcional) Nombre de la tabla alternativa si difiere en la conexión delegada.
     */
    protected ?string $delegatedTable = 'books';
}

Con esta implementación, cada vez que se ejecute una consulta mediante Eloquent sobre el modelo VectorDocument, el framework resolverá automáticamente la conexión delegada sin afectar el flujo del resto de la aplicación ni requerir cambios en los controladores.

Aprende a implementar una estrategia de fallback de base de datos en Laravel usando SQLite. Evita errores de saturación y garantiza alta disponibilidad en solo lectura.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.