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.
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.
Esta receta recorre la configuración de read replicas, el split de lectura/escritura, el monitoreo de replication lag y el 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, cuando necesitas ejecutar reportes analíticos sin ralentizar escrituras de producción, cuando quieres localidad geográfica de lecturas colocando réplicas cerca de usuarios, o cuando tu workload es intensivo en lectura (más del 80% lecturas) con un volumen de escritura moderado.
- Si la primaria está saturada por lecturas, ajusta primero las queries con Optimización de Queries en PostgreSQL.
- Para análisis que no deberían impactar escrituras de producción, las réplicas mantienen el reporting lejos de la primaria.
- Para localidad geográfica de lecturas, coloca réplicas cerca de los usuarios.
- Cuando el workload es mayormente lecturas con escrituras moderadas, las réplicas suelen ser la mejora más rápida.
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. La replicación física copia bloques de WAL (Write-Ahead Log) directamente; es rápida pero replica toda la base de datos. La replicación lógica replica cambios a nivel de fila; es selectiva pero tiene más overhead.
Replication lag es el tiempo entre una escritura en la primaria y que ese mismo cambio aparezca en la réplica. La latencia de red, la carga de la réplica y las transacciones grandes lo aumentan. Tu aplicación debe manejar lecturas stale o enrutar queries críticas de consistencia de vuelta a la primaria.
Variantes
Cada stack aproxima las read replicas de forma ligeramente distinta. La tabla compara los tipos de replicación, el monitoreo de lag y las opciones de enrutado de lecturas.
| 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 |
Elige PostgreSQL o MySQL si quieres control total, servicios administrados en la nube si prefieres simplicidad operacional, o CockroachDB si buscas réplicas multi-activas automáticas. Yo he usado streaming replication de PostgreSQL en producción durante años y ha sido rock solid una vez que configuras bien el monitoreo.
Cuándo No Usar
- Workloads intensivos en escritura: Si tu workload es más de 50% escrituras, las réplicas no ayudan. Cada escritura va a la primaria de todos modos, y las réplicas añaden overhead sin aliviar nada. Lo aprendí por las malas en un servicio de logging que era 70% escrituras; añadir réplicas solo aumentó la carga de la primaria.
- Requisitos de consistencia fuerte: Si tu app necesita consistencia lectura-después-de-escritura en cada query, las réplicas introducen riesgo de lecturas stale. Usa la primaria para todo o cambia a CockroachDB/Yugabyte para consistencia fuerte distribuida.
- Datasets pequeños: Si tu base de datos cabe en RAM y las queries son rápidas, escalar verticalmente (más CPU, más RAM) es más simple y barato que añadir réplicas.
- Restricciones de presupuesto: Cada réplica duplica aproximadamente el costo de la base de datos. En AWS RDS, una réplica db.r6g.large cuesta lo mismo que la primaria. Si el presupuesto es ajustado, ajusta queries y añade índices primero.
- Complejidad operacional: Las réplicas añaden procedimientos de failover, dashboards de monitoreo y lógica de enrutado de conexiones. Si tu equipo es pequeño, el overhead operacional podría no valer la pena.
Mejores Prácticas
Yo siempre monitoreo el replication lag y alerto cuando excede el umbral de mi caso de uso (1 a 5 segundos para lecturas de usuarios, hasta 60 segundos para analíticos). Enruta lecturas sensibles al tiempo a la primaria. Cuando un usuario actualiza su perfil, lo leo de vuelta desde la primaria para evitar datos stale. Usa connection pooling por réplica en lugar de abrir conexiones directas; PgBouncer o ProxySQL funcionan bien. Consulta Connection Pooling para una configuración de ejemplo. Yo distribuyo réplicas entre zonas de disponibilidad para que un fallo de zona no elimine mis lecturas. Prueba procedimientos de failover regularmente. Las réplicas pueden promoverse a primaria durante outages, así que asegúrate de que el proceso realmente funcione. Consulta Retry Logic para patrones de resiliencia.
Errores Comunes
- Asumir que las réplicas son instantáneamente consistentes: Me ha picado esto. Siempre considera el replication lag en escenarios de lectura-después-de-escritura. La réplica puede estar 200ms atrás y tu usuario ve datos stale.
- Enviar escrituras a réplicas: Las réplicas son solo lectura; las escrituras fallarán o serán silenciosamente ignoradas. Una vez vi a un dev junior rutear una migración a una réplica y se preguntaba por qué los cambios de schema no persistían.
- Ignorar el monitoreo de replication lag: Los usuarios ven datos stale sin que nadie lo sepa. Configura alertas de lag antes de necesitarlas, no después.
- Sobre-replicar: Cada réplica añade carga a la primaria. He visto equipos levantar 10 réplicas y preguntarse por qué la primaria está CPU-bound. 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. Yo corro drills de failover mensuales y me han salvado durante outages reales.
Ajustes
Ajusta el número de réplicas con pg_stat_replication
Si el replication lag excede consistentemente 5 segundos, puedes tener demasiadas
réplicas o hardware insuficiente. Consulta pg_stat_replication para ver el lag
por réplica y decidir.
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 te da escalado de lectura y 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
Habilita hot_standby_feedback en las réplicas
Esto evita que el master vacuume filas que las réplicas aún están leyendo:
-- En replica postgresql.conf
hot_standby_feedback = on
Retén suficiente WAL en el master
Ajusta max_wal_senders y wal_keep_size para que la primaria conserve suficiente
WAL para sus réplicas:
-- postgresql.conf
max_wal_senders = 10
wal_keep_size = 1024 -- MB
Enruta queries read-heavy a réplicas
Revisa pg_stat_statements para encontrar las queries de lectura más frecuentes y
envíalas 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;
Puntos Clave
- Empieza con una réplica y escala basado en monitoreo. Yo nunca he necesitado más de 5 réplicas incluso para apps de alto tráfico, y la mayoría de workloads están bien con 2-3.
- Monitorea el replication lag desde el día uno. Yo uso
pg_stat_replicationen PostgreSQL ySHOW REPLICA STATUSen MySQL. Sin monitoreo, estás volando a ciegas. - Enruta queries de lectura-después-de-escritura a la primaria. Este es el bug más común que veo en producción: un usuario actualiza datos, los lee de vuelta, y ve el valor antiguo porque la lectura fue a una réplica que no se había sincronizado.
- Usa connection pooling (PgBouncer, ProxySQL) por réplica. Las conexiones directas agotan el max_connections de la réplica rápidamente bajo carga.
- Practica drills de failover mensualmente. Cuando la primaria falla a las 3am, quieres memoria muscular, no una wiki page que nunca testeaste.
Ver También
- Documentación de streaming replication en PostgreSQL - documentación oficial para configurar streaming replication.
- Documentación de replicación en MySQL - guía oficial de replicación de MySQL cubriendo binlog, async y semi-sync.
- Documentación de PgBouncer - configuración de connection pooling para PostgreSQL con soporte de réplicas.
- Documentación de ProxySQL - split de lectura/escritura y query routing para MySQL.
- AWS RDS Read Replicas - read replicas administradas en AWS RDS.
- Arquitectura de CockroachDB - réplicas multi-activas con consenso Raft.
- Database Deadlocks y Retries - manejo de accesos concurrentes en setups replicados.
- Connection Pooling - configuración de pools para conexiones primaria y réplica.
Preguntas frecuentes
¿Cuánto replication lag es aceptable?
Yo apunto a menos de 100ms para lecturas de usuarios. Para análisis, desde unos segundos hasta varios minutos suele estar bien. Para invalidación de caché, mantenlo por debajo de un segundo. Ajusta el umbral de alertas al requisito más estricto de tu caso de uso.
¿Puedo escribir en una read replica?
No con read replicas estándar. Necesitarías replicación multi-master como Galera, CockroachDB o Yugabyte. Las read replicas estándar rechazan escrituras.
¿Necesito un proxy a nivel de aplicación para split de lecturas?
No siempre. Drivers como libpq para PostgreSQL y Connector/J para MySQL aceptan más de un host, y muchos ORMs pueden enrutar lecturas a una réplica. Para reglas complejas, añade ProxySQL, PgBouncer o AWS RDS Proxy.
¿Por qué mi lectura tras una escritura sigue devolviendo el valor antiguo?
Replication lag. La réplica aún no se había sincronizado con la primaria. Si un usuario escribe datos y los lee inmediatamente, envía esa lectura a la primaria o espera a que el lag de la réplica baje de tu umbral.
¿Cuántas réplicas debería ejecutar?
Yo empiezo con una o dos. La mayoría de workloads obtienen un buen retorno con una relación de 1:3 a 1:5 entre primaria y réplicas. Añade más solo si el monitoreo muestra que las réplicas actuales no dan abasto.
PgBouncer 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
Recursos Relacionados
Manejar deadlocks y reintentos en bases de datos
Detecta, previene y recupera deadlocks de base de datos con lógica de reintento automático, ordenamiento consistente de locks y niveles de aislamiento adecuados.
DocPlantilla de Runbook de Migración de Base de Datos
Plantilla de runbook de migración de base de datos para ejecutar cambios de esquema de forma segura con procedimientos de rollback, pasos de verificación y planes de comunicación.
GuideTeorema CAP y Trade-offs de Bases de Datos
Guía práctica del teorema CAP: consistencia, disponibilidad y tolerancia a particiones. Aprende a elegir los trade-offs correctos para tu aplicación.
RecipeBloqueo optimista en bases de datos
Implementa bloqueo optimista con versionado para evitar actualizaciones perdidas. Ejemplos en SQL, Node.js, Java/JPA, MongoDB, DynamoDB y ETags HTTP.
RecipeCrear y usar vistas y vistas materializadas
Cómo crear y usar vistas de base de datos y vistas materializadas para simplificar consultas y mejorar el rendimiento de lectura.
RecipeImplementar event sourcing en una base de datos relacional
Implementa event sourcing en una base de datos relacional. Almacena eventos inmutables, proyecta read models y usa snapshots con PostgreSQL, MySQL y SQL Server.