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

  1. Session Management: Session phải shared giữa các servers
  2. File Storage: Uploaded files cần accessible từ mọi server
  3. Cache Consistency: Cache phải synchronized
  4. Queue Processing: Jobs cần distributed đều
  5. 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

Tài Liệu Tham Khảo

Bình luận