Database Read Replicas với Laravel: Tối Ưu Query Performance
·
12 min read
Giới Thiệu
Khi ứng dụng phát triển, database thường trở thành bottleneck đầu tiên. Read Replicas là giải pháp phân tải read queries sang các database copies, giúp primary database tập trung xử lý write operations.
Tại Sao Cần Read Replicas?
Without Replicas:
┌─────────────────┐
│ Primary DB │ ← ALL reads + writes
│ (Overload) │
└─────────────────┘
With Replicas:
┌─────────────────┐
│ Primary DB │ ← writes only
└────────┬────────┘
│ replication
┌────┴────┬────────┐
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐
│Replica│ │Replica│ │Replica│ ← reads distributed
│ 1 │ │ 2 │ │ 3 │
└───────┘ └───────┘ └───────┘
Lợi Ích
- Tăng Read Throughput: Phân tải reads sang nhiều replicas
- Giảm Load Primary: Primary chỉ xử lý writes
- High Availability: Replica có thể promote thành primary
- Geographic Distribution: Replicas gần users hơn
- Analytics Isolation: Chạy heavy reports trên replica
Cấu Hình MySQL Replication
AWS RDS Setup
# terraform/rds.tf
resource "aws_db_instance" "primary" {
identifier = "laravel-primary"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.r6g.large"
allocated_storage = 100
storage_type = "gp3"
db_name = "laravel"
username = "admin"
password = var.db_password
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
multi_az = true
storage_encrypted = true
parameter_group_name = aws_db_parameter_group.mysql.name
tags = {
Name = "laravel-primary"
}
}
resource "aws_db_instance" "replica_1" {
identifier = "laravel-replica-1"
replicate_source_db = aws_db_instance.primary.identifier
instance_class = "db.r6g.large"
vpc_security_group_ids = [aws_security_group.db.id]
storage_encrypted = true
skip_final_snapshot = true
tags = {
Name = "laravel-replica-1"
}
}
resource "aws_db_instance" "replica_2" {
identifier = "laravel-replica-2"
replicate_source_db = aws_db_instance.primary.identifier
instance_class = "db.r6g.large"
vpc_security_group_ids = [aws_security_group.db.id]
storage_encrypted = true
skip_final_snapshot = true
tags = {
Name = "laravel-replica-2"
}
}
resource "aws_db_parameter_group" "mysql" {
family = "mysql8.0"
name = "laravel-mysql"
parameter {
name = "slow_query_log"
value = "1"
}
parameter {
name = "long_query_time"
value = "1"
}
parameter {
name = "innodb_buffer_pool_size"
value = "{DBInstanceClassMemory*3/4}"
}
}
Laravel Configuration
// config/database.php
'mysql' => [
'read' => [
'host' => [
env('DB_READ_HOST_1', 'replica-1.example.com'),
env('DB_READ_HOST_2', 'replica-2.example.com'),
],
],
'write' => [
'host' => env('DB_WRITE_HOST', 'primary.example.com'),
],
'sticky' => true,
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
PDO::ATTR_PERSISTENT => false,
]) : [],
],
Environment Variables
# Primary (Write)
DB_WRITE_HOST=laravel-primary.xxxxx.us-east-1.rds.amazonaws.com
# Replicas (Read)
DB_READ_HOST_1=laravel-replica-1.xxxxx.us-east-1.rds.amazonaws.com
DB_READ_HOST_2=laravel-replica-2.xxxxx.us-east-1.rds.amazonaws.com
DB_DATABASE=laravel
DB_USERNAME=admin
DB_PASSWORD=your-secure-password
PostgreSQL Read Replicas
Configuration
// config/database.php
'pgsql' => [
'read' => [
'host' => [
env('DB_READ_HOST_1', 'replica-1.example.com'),
env('DB_READ_HOST_2', 'replica-2.example.com'),
],
'port' => env('DB_READ_PORT', '5432'),
],
'write' => [
'host' => env('DB_WRITE_HOST', 'primary.example.com'),
'port' => env('DB_WRITE_PORT', '5432'),
],
'sticky' => true,
'driver' => 'pgsql',
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'postgres'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'require',
],
PgBouncer for Connection Pooling
; /etc/pgbouncer/pgbouncer.ini
[databases]
; Primary for writes
laravel_write = host=primary.example.com port=5432 dbname=laravel
; Replicas for reads (load balanced)
laravel_read = host=replica-1.example.com,replica-2.example.com port=5432 dbname=laravel load_balance_hosts=round-robin
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; Connection pooling settings
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 50
min_pool_size = 10
reserve_pool_size = 25
reserve_pool_timeout = 5
; Timeouts
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15
server_login_retry = 15
query_timeout = 300
Hiểu Về Sticky Sessions
Vấn Đề Replication Lag
// Không có sticky sessions - có thể gặp vấn đề
$user = User::create(['name' => 'John']); // Write to primary
// Ngay lập tức query replica - data có thể chưa sync
$user = User::find($user->id); // Read from replica - might be NULL!
Sticky Sessions Giải Quyết
// config/database.php
'sticky' => true, // Enable sticky sessions
// Với sticky = true
$user = User::create(['name' => 'John']); // Write to primary
// Laravel automatically uses primary for subsequent reads in this request
$user = User::find($user->id); // Read from PRIMARY (safe!)
Cách Sticky Sessions Hoạt Động
// Trong Laravel DatabaseManager
public function select($query, $bindings = [], $useReadPdo = true)
{
// Nếu sticky enabled và đã có write trong request này
if ($this->recordsModified && $this->getConfig('sticky')) {
$useReadPdo = false; // Force use write connection
}
return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
$pdo = $useReadPdo ? $this->getReadPdo() : $this->getPdo();
// ...
});
}
Query Routing Strategies
1. Automatic Routing (Default)
// Laravel tự động route queries
User::all(); // → Read replica
User::create([...]); // → Write primary
User::find(1); // → Read replica (hoặc primary nếu sticky)
2. Force Write Connection
// Khi cần đọc data mới nhất
$user = User::onWriteConnection()->find($userId);
// Hoặc trong query builder
$users = DB::connection('mysql')
->useWritePdo()
->table('users')
->where('active', true)
->get();
3. Explicit Connection Selection
// app/Services/ReportService.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
class ReportService
{
public function generateHeavyReport(): array
{
// Force read từ replica cho heavy queries
return DB::connection('mysql')
->useReadPdo()
->table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->selectRaw('
DATE(orders.created_at) as date,
COUNT(DISTINCT orders.id) as order_count,
SUM(order_items.quantity * order_items.price) as revenue
')
->where('orders.created_at', '>=', now()->subDays(30))
->groupBy('date')
->get()
->toArray();
}
}
4. Custom Read/Write Logic
// app/Database/SmartConnection.php
namespace App\Database;
use Illuminate\Database\MySqlConnection;
class SmartConnection extends MySqlConnection
{
protected array $writeOperations = [
'insert', 'update', 'delete', 'truncate',
'create', 'alter', 'drop', 'lock', 'unlock'
];
public function select($query, $bindings = [], $useReadPdo = true)
{
// Force write cho specific queries
if ($this->shouldForceWrite($query)) {
$useReadPdo = false;
}
return parent::select($query, $bindings, $useReadPdo);
}
protected function shouldForceWrite(string $query): bool
{
// Force write connection cho SELECT ... FOR UPDATE
if (str_contains(strtoupper($query), 'FOR UPDATE')) {
return true;
}
// Force write cho queries có hint
if (str_contains($query, '/*force_primary*/')) {
return true;
}
return false;
}
}
Handling Replication Lag
1. Monitoring Lag
// app/Services/ReplicationMonitor.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
class ReplicationMonitor
{
public function checkLag(): array
{
$results = [];
// Check MySQL replication lag
$replicas = config('database.connections.mysql.read.host');
foreach ($replicas as $index => $host) {
try {
$lag = DB::connection('mysql_replica_' . $index)
->selectOne("SHOW SLAVE STATUS");
$results[$host] = [
'seconds_behind_master' => $lag->Seconds_Behind_Master ?? null,
'slave_io_running' => $lag->Slave_IO_Running ?? 'Unknown',
'slave_sql_running' => $lag->Slave_SQL_Running ?? 'Unknown',
];
} catch (\Exception $e) {
$results[$host] = [
'error' => $e->getMessage(),
];
}
}
return $results;
}
public function isLagAcceptable(int $maxLagSeconds = 5): bool
{
$lag = $this->checkLag();
foreach ($lag as $host => $status) {
if (isset($status['seconds_behind_master'])
&& $status['seconds_behind_master'] > $maxLagSeconds) {
return false;
}
}
return true;
}
}
2. Lag-Aware Queries
// app/Traits/LagAwareQueries.php
namespace App\Traits;
use Illuminate\Support\Facades\Cache;
trait LagAwareQueries
{
protected function queryWithLagCheck(callable $query, int $maxLagSeconds = 2)
{
$lag = Cache::remember('db_replication_lag', 10, function () {
return app(ReplicationMonitor::class)->checkLag();
});
// Nếu lag quá cao, force primary
foreach ($lag as $status) {
if (($status['seconds_behind_master'] ?? 0) > $maxLagSeconds) {
return $this->onWriteConnection()->tap($query);
}
}
return $query();
}
}
// Usage
class UserRepository
{
use LagAwareQueries;
public function findCriticalUser(int $id)
{
return $this->queryWithLagCheck(function () use ($id) {
return User::find($id);
}, maxLagSeconds: 1);
}
}
3. Write-then-Read Pattern
// app/Services/OrderService.php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function createOrder(array $data): Order
{
return DB::transaction(function () use ($data) {
// Create order
$order = Order::create($data);
// Load relationships ngay trong transaction
// Đảm bảo đọc từ primary
$order->load(['items', 'customer', 'shipping']);
return $order;
});
}
public function updateAndFetch(int $orderId, array $data): Order
{
DB::transaction(function () use ($orderId, $data) {
Order::where('id', $orderId)->update($data);
});
// Force read từ primary sau update
return Order::onWriteConnection()
->with(['items', 'customer'])
->findOrFail($orderId);
}
}
Failover Handling
1. Health Check
// app/Services/DatabaseHealthCheck.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DatabaseHealthCheck
{
public function checkConnections(): array
{
$status = [];
// Check primary
$status['primary'] = $this->checkConnection('mysql', 'write');
// Check replicas
$replicas = config('database.connections.mysql.read.host');
foreach ($replicas as $index => $host) {
$status["replica_{$index}"] = $this->checkConnection('mysql', 'read', $index);
}
return $status;
}
private function checkConnection(string $connection, string $type, ?int $index = null): array
{
try {
$start = microtime(true);
$pdo = $type === 'write'
? DB::connection($connection)->getPdo()
: DB::connection($connection)->getReadPdo();
$pdo->query('SELECT 1');
return [
'status' => 'healthy',
'latency_ms' => round((microtime(true) - $start) * 1000, 2),
];
} catch (\Exception $e) {
Log::error("Database connection failed", [
'connection' => $connection,
'type' => $type,
'index' => $index,
'error' => $e->getMessage(),
]);
return [
'status' => 'unhealthy',
'error' => $e->getMessage(),
];
}
}
}
2. Automatic Failover với ProxySQL
; /etc/proxysql.cnf
datadir="/var/lib/proxysql"
admin_variables=
{
admin_credentials="admin:admin"
mysql_ifaces="0.0.0.0:6032"
}
mysql_variables=
{
threads=4
max_connections=2048
default_query_delay=0
default_query_timeout=36000000
have_compress=true
poll_timeout=2000
interfaces="0.0.0.0:6033"
default_schema="information_schema"
stacksize=1048576
server_version="8.0.35"
connect_timeout_server=3000
monitor_username="monitor"
monitor_password="monitor_password"
monitor_history=600000
monitor_connect_interval=60000
monitor_ping_interval=10000
monitor_read_only_interval=1500
monitor_read_only_timeout=500
ping_interval_server_msec=120000
ping_timeout_server=500
commands_stats=true
sessions_sort=true
connect_retries_on_failure=10
}
mysql_servers =
(
{
address = "primary.example.com"
port = 3306
hostgroup = 0
weight = 1000
max_connections = 100
},
{
address = "replica-1.example.com"
port = 3306
hostgroup = 1
weight = 500
max_connections = 100
},
{
address = "replica-2.example.com"
port = 3306
hostgroup = 1
weight = 500
max_connections = 100
}
)
mysql_query_rules =
(
{
rule_id = 1
active = 1
match_pattern = "^SELECT .* FOR UPDATE"
destination_hostgroup = 0
apply = 1
},
{
rule_id = 2
active = 1
match_pattern = "^SELECT"
destination_hostgroup = 1
apply = 1
},
{
rule_id = 3
active = 1
match_pattern = ".*"
destination_hostgroup = 0
apply = 1
}
)
3. Circuit Breaker Pattern
// app/Database/CircuitBreaker.php
namespace App\Database;
use Illuminate\Support\Facades\Cache;
class CircuitBreaker
{
private const STATE_CLOSED = 'closed';
private const STATE_OPEN = 'open';
private const STATE_HALF_OPEN = 'half_open';
public function __construct(
private string $service,
private int $failureThreshold = 5,
private int $recoveryTime = 30
) {}
public function execute(callable $operation)
{
$state = $this->getState();
if ($state === self::STATE_OPEN) {
if ($this->shouldAttemptRecovery()) {
$this->setState(self::STATE_HALF_OPEN);
} else {
throw new CircuitBreakerOpenException("Circuit breaker is open for {$this->service}");
}
}
try {
$result = $operation();
$this->recordSuccess();
return $result;
} catch (\Exception $e) {
$this->recordFailure();
throw $e;
}
}
private function getState(): string
{
return Cache::get("circuit_breaker:{$this->service}:state", self::STATE_CLOSED);
}
private function setState(string $state): void
{
Cache::put("circuit_breaker:{$this->service}:state", $state, 3600);
}
private function recordSuccess(): void
{
$state = $this->getState();
if ($state === self::STATE_HALF_OPEN) {
$this->setState(self::STATE_CLOSED);
Cache::forget("circuit_breaker:{$this->service}:failures");
}
}
private function recordFailure(): void
{
$failures = Cache::increment("circuit_breaker:{$this->service}:failures");
if ($failures >= $this->failureThreshold) {
$this->setState(self::STATE_OPEN);
Cache::put("circuit_breaker:{$this->service}:opened_at", now()->timestamp, 3600);
}
}
private function shouldAttemptRecovery(): bool
{
$openedAt = Cache::get("circuit_breaker:{$this->service}:opened_at", 0);
return (now()->timestamp - $openedAt) >= $this->recoveryTime;
}
}
Best Practices
1. Connection Pooling
// config/database.php
'mysql' => [
// ... other config
'options' => [
PDO::ATTR_PERSISTENT => true, // Persistent connections
PDO::ATTR_TIMEOUT => 5,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
],
],
2. Query Hints
// app/Models/User.php
class User extends Model
{
public function scopeForcePrimary($query)
{
return $query->useWritePdo();
}
public function scopeForceReplica($query)
{
return $query->useReadPdo();
}
}
// Usage
$user = User::forcePrimary()->find($id);
$users = User::forceReplica()->active()->get();
3. Monitoring Queries
// app/Providers/AppServiceProvider.php
public function boot(): void
{
DB::listen(function ($query) {
$connection = $query->connectionName;
$time = $query->time;
// Log slow queries
if ($time > 1000) {
Log::warning('Slow query detected', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $time,
'connection' => $connection,
]);
}
// Track metrics
if (app()->bound('prometheus')) {
app('prometheus')->histogram(
'database_query_duration_seconds',
$time / 1000,
['connection' => $connection]
);
}
});
}
Kết Luận
Read Replicas giúp:
- Scale read capacity mà không ảnh hưởng writes
- Improve performance bằng cách phân tải queries
- Increase availability với failover options
- Isolate workloads (analytics vs production)
Checklist Implementation
- Cấu hình read/write connections
- Enable sticky sessions
- Setup monitoring replication lag
- Implement health checks
- Configure connection pooling
- Test failover scenarios
- Setup alerting for lag > threshold