Configurar read replicas de base de datos para escalado
Escala workloads intensivos en lectura con read replicas de base de datos, monitoreo de replication lag y split de lectura/escritura entre instancias primarias y réplicas.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Configurar read replicas de base de datos para escalado
Visión General
Las read replicas son copias de tu base de datos principal que manejan tráfico solo de lectura, aliviando la instancia principal. Son la estrategia de escalado más común para workloads intensivos en lectura — dashboards analíticos, búsquedas y lecturas de API pueden dirigirse a réplicas mientras las escrituras van a la primaria.
A continuacion se cubre la configuración de read replicas, implementación de split de lectura/escritura, monitoreo de replication lag y manejo de lecturas stale en PostgreSQL, MySQL y bases de datos administradas en la nube.
Cuándo Usar
Usa este recurso cuando:
- La CPU o I/O de tu base de datos principal está saturada por queries de lectura. Consulta Query Optimization para ajustar lecturas.
- Necesitas ejecutar reportes analíticos sin impactar escrituras de producción. Consulta Logging para observabilidad.
- Quieres localidad geográfica de lecturas colocando réplicas cerca de usuarios. Consulta Caching para rendimiento en edge.
- Tu workload es intensivo en lectura (>80% lecturas) y el volumen de escritura es moderado
Solución
Python (SQLAlchemy con split de lectura/escritura)
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
import random
# Primaria para escrituras, réplicas para lecturas
primary_engine = create_engine("postgresql://user:pass@primary:5432/app")
replica_engines = [
create_engine("postgresql://user:pass@replica1:5432/app"),
create_engine("postgresql://user:pass@replica2:5432/app"),
]
class RoutingSession:
def __init__(self):
self._write_session = sessionmaker(bind=primary_engine)()
self._replica = random.choice(replica_engines)
self._read_session = sessionmaker(bind=self._replica)()
def execute_write(self, query, params=None):
return self._write_session.execute(text(query), params or {})
def execute_read(self, query, params=None):
return self._read_session.execute(text(query), params or {})
def commit(self):
self._write_session.commit()
# Uso
session = RoutingSession()
users = session.execute_read("SELECT * FROM users WHERE active = true")
session.execute_write("UPDATE users SET last_login = NOW() WHERE id = :id", {"id": 1})
session.commit()
JavaScript (Prisma con réplicas)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL, // primaria
},
},
});
// Extensión de Prisma para read replicas (feature preview)
const prismaWithReplicas = prisma.$extends({
query: {
$allModels: {
async findUnique({ model, operation, args, query }) {
// Ruta lecturas a réplica
return query(args);
},
},
},
});
// Para queries raw divididos
async function executeRead(sql) {
// Conectar a pool de réplica
const replicaPool = new Pool({ connectionString: process.env.REPLICA_URL });
return replicaPool.query(sql);
}
async function executeWrite(sql, params) {
return prisma.$executeRawUnsafe(sql, ...params);
}
Java (Spring Boot con AbstractRoutingDataSource)
@Configuration
public class DataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
AbstractRoutingDataSource routing = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "replica" : "primary";
}
};
Map<Object, Object> targets = new HashMap<>();
targets.put("primary", primary);
targets.put("replica", replica);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(primary);
return routing;
}
}
@Service
public class UserService {
@Transactional(readOnly = true)
public List<User> findAll() {
// Enrutado automáticamente a réplica
return userRepository.findAll();
}
@Transactional
public User save(User user) {
// Enrutado a primaria
return userRepository.save(user);
}
}
Explicación
Las read replicas usan replicación streaming (física) o lógica:
- Replicación física: Copia bloques de WAL (Write-Ahead Log) directamente. Rápida pero replica todo.
- Replicación lógica: Replica cambios a nivel de fila. Selectiva pero con más overhead.
Replication lag es el retraso entre una escritura en la primaria y su aparición en la réplica. Las causas incluyen latencia de red, carga de la réplica y transacciones grandes. Las aplicaciones deben manejar lecturas stale o enrutar queries críticas de consistencia a la primaria.
Variantes
| Base de datos | Tipo de replicación | Monitoreo de lag | Enrutado de lecturas |
|---|---|---|---|
| PostgreSQL | Streaming / Lógica | pg_stat_replication | PgBouncer, proxy custom |
| MySQL | Binlog (async/semi-sync) | SHOW SLAVE STATUS | ProxySQL, MaxScale |
| Cloud RDS | Streaming administrado | CloudWatch/Cloud Monitoring | RDS Proxy, custom |
| CockroachDB | Multi-active (Raft) | Built-in | Automático |
Lo que funciona
- Monitorea el replication lag: Alerta cuando el lag excede 1–5 segundos dependiendo del caso de uso
- Enruta lecturas sensibles al tiempo a la primaria: Lecturas de perfil de usuario después de editar deben ir a la primaria
- Usa connection pooling por réplica: No crees conexiones directamente; usa PgBouncer o ProxySQL. Consulta Connection Pooling para configuración.
- Distribuye réplicas entre zonas de disponibilidad: Protege contra fallos de zona
- Prueba procedimientos de failover: Las réplicas pueden promoverse a primaria durante outages. Consulta Retry Logic para patrones de resiliencia.
Errores Comunes
- Asumir que las réplicas son instantáneamente consistentes: Siempre considera el replication lag en escenarios de lectura-después-de-escritura
- Enviar escrituras a réplicas: Las réplicas son solo lectura; las escrituras fallarán o serán silenciosamente ignoradas
- Ignorar el monitoreo de replication lag: Los usuarios ven datos stale sin que nadie lo sepa
- Sobre-replicar: Cada réplica añade carga a la primaria; encuentra la proporción correcta (usualmente 1:3 a 1:5)
- Sin plan de failover: Cuando la primaria falla, promueve una réplica rápidamente — practica esto regularmente
Preguntas Frecuentes
P: ¿Cuánto replication lag es aceptable? R: Para lecturas orientadas a usuarios: <100ms. Para análisis: segundos a minutos. Para invalidación de caché: <1s. Monitorea y alerta basado en tu caso de uso.
P: ¿Puedo escribir en una read replica? R: Solo si usas replicación multi-master (Galera, CockroachDB, Yugabyte). Las read replicas estándar rechazan escrituras. Intentar escribir lanzará errores.
P: ¿Necesito un proxy a nivel de aplicación para split de lecturas? R: No siempre. Algunos drivers (libpq de PostgreSQL, Connector/J de MySQL) soportan múltiples hosts. ORMs como Prisma e Hibernate también proveen enrutado de réplicas. Para escenarios complejos, usa ProxySQL, PgBouncer o AWS RDS Proxy.
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
PgBouncer para Connection Pooling con Réplicas
# pgbouncer.ini
[databases]
master = host=master.db.internal port=5432 dbname=app
replica1 = host=replica1.db.internal port=5432 dbname=app
replica2 = host=replica2.db.internal port=5432 dbname=app
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
import psycopg2
# Escribir al master vía PgBouncer
write_conn = psycopg2.connect("postgresql://user:pass@pgbouncer:6432/master")
# Leer de réplica vía PgBouncer
read_conn = psycopg2.connect("postgresql://user:pass@pgbouncer:6432/replica1")
# Enrutado a nivel aplicación
def get_connection(is_write=False):
if is_write:
return psycopg2.connect("postgresql://user:pass@pgbouncer:6432/master")
# Round-robin entre réplicas
import random
replica = random.choice(['replica1', 'replica2'])
return psycopg2.connect(f"postgresql://user:pass@pgbouncer:6432/{replica}")
ProxySQL para Split Read/Write en MySQL
-- Configurar ProxySQL con servidores backend
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES
(0, 'master.db.internal', 3306), -- hostgroup 0: escrituras
(1, 'replica1.db.internal', 3306), -- hostgroup 1: lecturas
(1, 'replica2.db.internal', 3306); -- hostgroup 1: lecturas
-- Reglas de enrutado: SELECT va a réplicas, todo lo demás al master
INSERT INTO mysql_query_rules(rule_id, active, match_digest, destination_hostgroup, apply)
VALUES
(1, 1, '^SELECT.*FOR UPDATE', 0, 1), -- Locking reads al master
(2, 1, '^SELECT', 1, 1); -- Reads normales a réplicas
LOAD MYSQL SERVERS TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;
Configuración de AWS RDS Proxy
# AWS CloudFormation snippet para RDS Proxy con split read/write
Resources:
ReadWriteProxy:
Type: AWS::RDS::DBProxy
Properties:
DBProxyName: app-proxy
EngineFamily: POSTGRESQL
RoleArn: !GetAtt ProxyRole.Arn
Auth:
- AuthScheme: SECRETS
SecretArn: !Ref DBSecretArn
TargetGroupName: default
Targets:
- RdsInstanceId: !Ref MasterInstance
- RdsInstanceId: !Ref ReplicaInstance1
ConnectionPoolConfiguration:
MaxConnectionsPercent: 80
IdleClientTimeout: 1800
Driver SQL de Go con Split Read/Write
package db
import (
"database/sql"
"math/rand"
"time"
_ "github.com/lib/pq"
)
type DBRouter struct {
master *sql.DB
replicas []*sql.DB
rng *rand.Rand
}
func NewDBRouter(masterURL string, replicaURLs []string) (*DBRouter, error) {
master, err := sql.Open("postgres", masterURL)
if err != nil {
return nil, err
}
master.SetMaxOpenConns(20)
replicas := make([]*sql.DB, len(replicaURLs))
for i, url := range replicaURLs {
replica, err := sql.Open("postgres", url)
if err != nil {
return nil, err
}
replica.SetMaxOpenConns(10)
replicas[i] = replica
}
return &DBRouter{
master: master,
replicas: replicas,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}, nil
}
func (r *DBRouter) Read() *sql.DB {
if len(r.replicas) == 0 {
return r.master
}
return r.replicas[r.rng.Intn(len(r.replicas))]
}
func (r *DBRouter) Write() *sql.DB {
return r.master
}
// Uso
func (r *DBRouter) GetUser(id int) (*User, error) {
var user User
err := r.Read().QueryRow(
"SELECT id, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Email)
return &user, err
}
func (r *DBRouter) CreateUser(email string) error {
_, err := r.Write().Exec(
"INSERT INTO users (email) VALUES ($1)", email,
)
return err
}
Django Database Routers
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'app',
'HOST': 'master.db.internal',
'PORT': '5432',
},
'replica': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'app',
'HOST': 'replica1.db.internal',
'PORT': '5432',
},
}
# routers.py
class ReadReplicaRouter:
def db_for_read(self, model, **hints):
return 'replica'
def db_for_write(self, model, **hints):
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
return db == 'default'
DATABASE_ROUTERS = ['myapp.routers.ReadReplicaRouter']
# Uso: reads van automáticamente a réplica, writes al master
users = User.objects.filter(role='admin') # Va a réplica
user = User.objects.create(email='alice@example.com') # Va al master
Consultas de Monitoreo de Replication Lag
-- PostgreSQL: verificar replication lag
SELECT
client_addr,
state,
sent_lsn,
replay_lsn,
EXTRACT(EPOCH FROM (now() - replay_lag)) AS lag_seconds
FROM pg_stat_replication;
-- Verificar estado de WAL receiver en réplica
SELECT status, receive_start_lsn, written_lsn, flushed_lsn
FROM pg_stat_wal_receiver;
-- Monitorear lag de slot (si usas replication slots)
SELECT slot_name, restart_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots;
-- MySQL: verificar lag de réplica
SHOW REPLICA STATUS\G
-- Campos clave a monitorear:
-- Seconds_Behind_Master: debería ser < 5
-- Replica_IO_Running: Yes
-- Replica_SQL_Running: Yes
-- Monitorear via performance schema
SELECT
channel_name,
service_state,
last_error_number,
last_error_message
FROM performance_schema.replication_connection_status;
Manejo de Replication Lag en Código de Aplicación
import time
import psycopg2
def read_after_write(conn_master, conn_replica, query, params, max_wait=2.0):
"""Leer de réplica con fallback al master si el lag es demasiado alto."""
# Verificar replication lag
with conn_master.cursor() as cur:
cur.execute("""
SELECT EXTRACT(EPOCH FROM (now() - replay_lag))::float
FROM pg_stat_replication LIMIT 1
""")
lag = cur.fetchone()[0] or 0
if lag > max_wait:
# La réplica está muy atrás: leer del master
with conn_master.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
# Leer de réplica
try:
with conn_replica.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
except Exception:
# Fallback al master si hay error en réplica
with conn_master.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
Mejores Prácticas Adicionales
- Usa connection pooling con réplicas. Cada conexión de réplica consume memoria. Usa PgBouncer o un pooler para multiplexar conexiones:
from sqlalchemy import create_engine
master = create_engine("postgresql://user:pass@master:5432/app", pool_size=10)
replica = create_engine("postgresql://user:pass@replica:5432/app", pool_size=20)
- Enruta
SELECT ... FOR UPDATEal master. Los reads con lock deben ir al master, no a réplicas:
def get_connection(query_type='read', is_locking=False):
if query_type == 'write' or is_locking:
return master_conn
return replica_conn
- Monitorea la salud de réplicas y elimina réplicas no saludables. Una réplica muerta causa fallos de lectura. Implementa health checks:
def get_healthy_replica(replicas):
for replica in replicas:
try:
with replica.cursor() as cur:
cur.execute("SELECT 1")
return replica
except Exception:
continue
return master # Fallback al master si todas las réplicas están caídas
- Usa consistencia causal para read-after-write. Después de un write, lee del master por un breve período para asegurar que la réplica se haya actualizado:
import time
class CausalConsistencyManager:
def __init__(self, master, replica):
self.master = master
self.replica = replica
self.last_write_time = 0
def write(self, query, params):
with self.master.cursor() as cur:
cur.execute(query, params)
self.master.commit()
self.last_write_time = time.time()
def read(self, query, params):
# Leer del master si está dentro de 2 segundos del último write
if time.time() - self.last_write_time < 2.0:
with self.master.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
with self.replica.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
- Etiqueta conexiones de réplica en monitoreo. Distingue el tráfico de réplica del tráfico de master en logs y métricas:
import logging
logger = logging.getLogger('db_router')
def read_from_replica(query, params):
logger.debug("REPLICA_READ", extra={"query": query[:100]})
# ...
Errores Comunes Adicionales
-
Leer de una réplica inmediatamente después de escribir al master. La réplica puede no haber recibido el write todavía. Usa consistencia causal o read-from-master-after-write por una ventana breve.
-
No manejar fallos de réplica gracefulmente. Si una réplica cae, los reads deberían caer al master, no fallar:
try:
result = replica_conn.execute(query)
except ConnectionError:
result = master_conn.execute(query) # Fallback
- Ejecutar queries analíticos largos en réplicas sin límites de recursos. Un query analítico pesado puede consumir todos los recursos de la réplica y causar lag. Usa
statement_timeouten conexiones de réplica:
SET statement_timeout = '30s';
-
No crear los mismos índices en réplicas. La replicación lógica puede no sincronizar índices. Asegúrate de que las réplicas tengan los mismos índices que el master para rendimiento de lectura.
-
Usar replicación síncrona para escalado de lectura. La replicación síncrona espera confirmación de la réplica, añadiendo latencia a cada write. Usa replicación asíncrona para escalado de lectura; usa síncrona solo para HA failover.
FAQ Adicional
¿Cuántas réplicas debería tener?
Empieza con una réplica. Añade más cuando:
- El QPS de lectura excede lo que una réplica puede manejar
- Necesitas distribución geográfica (réplicas más cerca de los usuarios)
- Quieres ejecutar queries analíticos sin afectar la primaria
La mayoría de aplicaciones necesitan 1-3 réplicas. Más de 5 usualmente indica que necesitas una estrategia de escalado diferente (sharding, caching, o una base de datos analítica dedicada).
¿Puedo escribir a una réplica?
No. Las réplicas en replicación streaming estándar de PostgreSQL/MySQL son de solo lectura. Escribir a una réplica causa que la replicación se rompa. Usa replicación multi-master (Bucardo, replicación lógica de PostgreSQL con resolución de conflictos) si necesitas writes en múltiples nodos.
¿Cuál es la diferencia entre replicación física y lógica?
Replicación física copia cambios a nivel de bloque. La réplica es una copia byte por byte exacta de la primaria. Es rápida y simple pero requiere la misma versión de PostgreSQL y arquitectura.
Replicación lógica copia cambios lógicos (INSERT, UPDATE, DELETE) vía publicación/suscripción. Soporta diferentes versiones de PostgreSQL, replicación selectiva de tablas y upgrades cross-version. Es más lenta que la replicación física y no replica cambios de schema.
Tips de Rendimiento
- Usa
pg_stat_replicationpara ajustar el número de réplicas. Si el replication lag excede consistentemente 5 segundos, puedes tener demasiadas réplicas o hardware insuficiente:
SELECT application_name, client_addr,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes,
EXTRACT(EPOCH FROM replay_lag) AS lag_seconds
FROM pg_stat_replication;
- Coloca réplicas en diferentes zonas de disponibilidad. Esto proporciona tanto escalado de lectura como recuperación ante desastres:
# AWS RDS: crear read replicas en diferentes AZs
ReadReplica1:
Type: AWS::RDS::DBInstance
Properties:
SourceDBInstanceIdentifier: !Ref MasterInstance
AvailabilityZone: us-east-1b
DBInstanceClass: db.r6g.large
ReadReplica2:
Type: AWS::RDS::DBInstance
Properties:
SourceDBInstanceIdentifier: !Ref MasterInstance
AvailabilityZone: us-east-1c
DBInstanceClass: db.r6g.large
- Usa
hot_standby_feedback = onen réplicas. Esto previene que el master vacuume filas que las réplicas aún están leyendo:
-- En replica postgresql.conf
hot_standby_feedback = on
- Ajusta
max_wal_sendersywal_keep_sizeen el master. Asegura suficiente WAL retenido para las réplicas:
-- postgresql.conf
max_wal_senders = 10
wal_keep_size = 1024 -- MB
- Usa
pg_stat_statementspara identificar queries read-heavy. Enruta los queries de lectura más frecuentes a réplicas:
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query LIKE 'SELECT%'
ORDER BY calls DESC
LIMIT 20; Recursos Relacionados
Handle Database Deadlocks and Retries
Detect, prevent, and recover from database deadlocks with automatic retry logic, isolation levels, and query ordering strategies.
RecipeFull-Text Search
How to implement full-text search with Elasticsearch, Meilisearch, and PostgreSQL.
RecipeSQL Joins
Practical examples of INNER, LEFT, RIGHT, and FULL OUTER JOINs with real-world query patterns.
DocDatabase Migration Runbook Template
A database migration runbook template for executing schema changes safely with rollback procedures, verification steps, and communication plans.
GuideCAP Theorem and Database Trade-offs
A practical guide to the CAP theorem: consistency, availability, and partition tolerance. Learn how to choose the right trade-offs for your application.
RecipeImplement Optimistic Locking with Versioning
How to implement optimistic locking with versioning to prevent lost updates in concurrent database access
RecipeSet Up Full-Text Search Indexes
Configure full-text search indexes in PostgreSQL to query large text columns with ranking, stemming, and highlighting.