Horizontal Scaling Laravel: Hướng Dẫn Mở Rộng Ứng Dụng Lên Hàng Triệu Users
·
10 min read
Giới Thiệu
Khi ứng dụng Laravel của bạn phát triển, một server đơn lẻ sẽ không còn đủ khả năng xử lý. Horizontal scaling (scale out) là chiến lược thêm nhiều servers để phân tải, thay vì nâng cấp hardware của server hiện tại (vertical scaling).
Vertical vs Horizontal Scaling
Vertical Scaling (Scale Up):
┌─────────────────┐
│ Bigger Server │
│ More CPU/RAM │
│ Single Point │
│ of Failure │
└─────────────────┘
Horizontal Scaling (Scale Out):
┌────────┐ ┌────────┐ ┌────────┐
│Server 1│ │Server 2│ │Server 3│
└────────┘ └────────┘ └────────┘
│ │ │
└──────────┼──────────┘
Load Balancer
Thách Thức Khi Horizontal Scaling
- Session Management: Session phải shared giữa các servers
- File Storage: Uploaded files cần accessible từ mọi server
- Cache Consistency: Cache phải synchronized
- Queue Processing: Jobs cần distributed đều
- Database Connections: Connection pooling và replication
Kiến Trúc Tổng Quan
┌─────────────────┐
│ CloudFlare │
│ (CDN + WAF) │
└────────┬────────┘
│
┌────────▼────────┐
│ Load Balancer │
│ (AWS ALB) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ App Server │ │ App Server │ │ App Server │
│ (Laravel) │ │ (Laravel) │ │ (Laravel) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Redis │ │ MySQL │ │ S3 │
│ (Session) │ │ (Primary) │ │ (Files) │
│ (Cache) │ │ + Replicas │ │ │
│ (Queue) │ │ │ │ │
└───────────────┘ └───────────────┘ └───────────────┘
1. Load Balancing
AWS Application Load Balancer
# terraform/alb.tf
resource "aws_lb" "main" {
name = "laravel-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
enable_deletion_protection = true
tags = {
Environment = "production"
}
}
resource "aws_lb_target_group" "app" {
name = "laravel-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = "/health"
port = "traffic-port"
protocol = "HTTP"
timeout = 5
unhealthy_threshold = 2
}
stickiness {
type = "lb_cookie"
cookie_duration = 86400
enabled = false # Disabled vì dùng Redis sessions
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.main.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Health Check Endpoint
// routes/web.php
Route::get('/health', function () {
try {
// Check database
DB::connection()->getPdo();
// Check Redis
Cache::store('redis')->put('health_check', true, 10);
// Check queue
Queue::size('default');
return response()->json([
'status' => 'healthy',
'timestamp' => now()->toIso8601String(),
'server' => gethostname(),
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'unhealthy',
'error' => $e->getMessage(),
], 503);
}
});
Nginx Configuration
# /etc/nginx/conf.d/laravel.conf
upstream laravel_backend {
least_conn; # Load balancing algorithm
server app1.internal:9000 weight=5;
server app2.internal:9000 weight=5;
server app3.internal:9000 weight=5;
keepalive 32;
}
server {
listen 80;
server_name example.com;
root /var/www/html/public;
index index.php;
# Real IP from load balancer
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass laravel_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Timeout settings
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
}
2. Session Management
Redis Session Driver
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => env('SESSION_CONNECTION', 'session'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
'encrypt' => true,
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
'path' => '/',
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
];
Redis Configuration
// config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', 'laravel:'),
],
// Dedicated connection for sessions
'session' => [
'host' => env('REDIS_SESSION_HOST', '127.0.0.1'),
'password' => env('REDIS_SESSION_PASSWORD'),
'port' => env('REDIS_SESSION_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '1'),
],
// Dedicated connection for cache
'cache' => [
'host' => env('REDIS_CACHE_HOST', '127.0.0.1'),
'password' => env('REDIS_CACHE_PASSWORD'),
'port' => env('REDIS_CACHE_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '2'),
],
// Dedicated connection for queues
'queue' => [
'host' => env('REDIS_QUEUE_HOST', '127.0.0.1'),
'password' => env('REDIS_QUEUE_PASSWORD'),
'port' => env('REDIS_QUEUE_PORT', '6379'),
'database' => env('REDIS_QUEUE_DB', '3'),
],
],
Redis Cluster for High Availability
// config/database.php - Redis Cluster
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'clusters' => [
'default' => [
[
'host' => env('REDIS_HOST_1', '10.0.1.10'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
[
'host' => env('REDIS_HOST_2', '10.0.1.11'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
[
'host' => env('REDIS_HOST_3', '10.0.1.12'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
],
],
],
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
],
],
3. Distributed File Storage
AWS S3 Configuration
// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
'cdn' => env('AWS_CLOUDFRONT_URL'),
],
],
File Upload Service
// app/Services/FileUploadService.php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileUploadService
{
public function upload(
UploadedFile $file,
string $directory = 'uploads',
?string $visibility = 'public'
): array {
$filename = $this->generateFilename($file);
$path = "{$directory}/{$filename}";
Storage::disk('s3')->put($path, $file->getContent(), [
'visibility' => $visibility,
'ContentType' => $file->getMimeType(),
'CacheControl' => 'max-age=31536000',
]);
return [
'path' => $path,
'url' => $this->getUrl($path),
'cdn_url' => $this->getCdnUrl($path),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
];
}
public function delete(string $path): bool
{
return Storage::disk('s3')->delete($path);
}
private function generateFilename(UploadedFile $file): string
{
$extension = $file->getClientOriginalExtension();
$hash = Str::random(32);
$date = now()->format('Y/m/d');
return "{$date}/{$hash}.{$extension}";
}
private function getUrl(string $path): string
{
return Storage::disk('s3')->url($path);
}
private function getCdnUrl(string $path): string
{
$cdnUrl = config('filesystems.disks.s3.cdn');
return $cdnUrl ? "{$cdnUrl}/{$path}" : $this->getUrl($path);
}
}
Temporary URLs for Private Files
// Tạo URL có thời hạn cho private files
$url = Storage::disk('s3')->temporaryUrl(
'private/document.pdf',
now()->addMinutes(30),
[
'ResponseContentDisposition' => 'attachment; filename="document.pdf"',
]
);
4. Distributed Caching
Cache Tagging for Invalidation
// app/Services/ProductCacheService.php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductCacheService
{
private const TTL = 3600;
public function get(int $id): ?Product
{
return Cache::tags(['products', "product:{$id}"])
->remember("product:{$id}", self::TTL, function () use ($id) {
return Product::with(['category', 'variants'])->find($id);
});
}
public function getByCategory(int $categoryId): array
{
return Cache::tags(['products', "category:{$categoryId}"])
->remember("category:{$categoryId}:products", self::TTL, function () use ($categoryId) {
return Product::where('category_id', $categoryId)
->active()
->get()
->toArray();
});
}
public function invalidateProduct(int $id): void
{
Cache::tags(["product:{$id}"])->flush();
}
public function invalidateCategory(int $categoryId): void
{
Cache::tags(["category:{$categoryId}"])->flush();
}
public function invalidateAll(): void
{
Cache::tags(['products'])->flush();
}
}
Distributed Locks
use Illuminate\Support\Facades\Cache;
// Atomic lock để tránh race conditions
$lock = Cache::lock('processing:order:123', 10);
if ($lock->get()) {
try {
$this->processOrder($order);
} finally {
$lock->release();
}
} else {
throw new OrderBeingProcessedException();
}
// Block until lock available
$lock = Cache::lock('processing:report', 60);
$lock->block(30, function () {
$this->generateReport();
});
5. Queue Workers Scaling
Supervisor Configuration
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-default.log
stopwaitsecs=3600
[program:laravel-worker-high]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=high --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-high.log
[program:laravel-worker-low]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --queue=low --sleep=5 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/laravel/worker-low.log
Laravel Horizon Configuration
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 10,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 300,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 20,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 300,
],
'supervisor-low' => [
'connection' => 'redis',
'queue' => ['low'],
'balance' => 'auto',
'autoScalingStrategy' => 'size',
'maxProcesses' => 5,
'maxTime' => 3600,
'memory' => 256,
'tries' => 3,
'timeout' => 600,
],
],
],
];
6. Database Scaling
Read/Write Splitting
// config/database.php
'mysql' => [
'read' => [
'host' => [
env('DB_READ_HOST_1', '10.0.1.20'),
env('DB_READ_HOST_2', '10.0.1.21'),
env('DB_READ_HOST_3', '10.0.1.22'),
],
],
'write' => [
'host' => env('DB_WRITE_HOST', '10.0.1.10'),
],
'sticky' => true, // Use write connection for reads after write
'driver' => 'mysql',
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
],
Force Write Connection
// Force read từ write connection khi cần data mới nhất
$user = User::onWriteConnection()->find($id);
// Hoặc trong transaction
DB::transaction(function () {
$order = Order::create([...]);
$order->items()->createMany([...]);
});
7. Auto Scaling
AWS Auto Scaling Group
resource "aws_autoscaling_group" "app" {
name = "laravel-asg"
vpc_zone_identifier = aws_subnet.private[*].id
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
min_size = 2
max_size = 10
desired_capacity = 3
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 75
}
}
}
resource "aws_autoscaling_policy" "scale_up" {
name = "scale-up"
scaling_adjustment = 2
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = aws_autoscaling_group.app.name
}
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
alarm_name = "high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = "120"
statistic = "Average"
threshold = "70"
alarm_actions = [aws_autoscaling_policy.scale_up.arn]
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.app.name
}
}
8. Monitoring với Laravel Pulse
// config/pulse.php
return [
'enabled' => env('PULSE_ENABLED', true),
'recorders' => [
Recorders\CacheInteractions::class => [
'enabled' => true,
'sample_rate' => 0.1,
],
Recorders\Exceptions::class => [
'enabled' => true,
],
Recorders\Queues::class => [
'enabled' => true,
],
Recorders\SlowQueries::class => [
'enabled' => true,
'threshold' => 100, // ms
],
Recorders\SlowRequests::class => [
'enabled' => true,
'threshold' => 1000, // ms
],
Recorders\Servers::class => [
'enabled' => true,
],
],
];
Kết Luận
Checklist Trước Khi Scale
- Sessions sử dụng Redis/Database
- Files được upload lên S3
- Cache sử dụng Redis
- Queues sử dụng Redis với Horizon
- Health check endpoint hoạt động
- Environment variables từ SSM/Secrets Manager
- Logging tập trung (CloudWatch/ELK)
- Monitoring và alerting đã setup