This commit is contained in:
Doar Mihai
2026-04-03 20:55:51 +03:00
parent a8a48f3f6f
commit 31e666ef33
7 changed files with 586 additions and 68 deletions
+1 -1
View File
@@ -250,7 +250,7 @@ DELETE /api/jobs/{id}/save - Unsave job
## Phase 8: Performance Optimization ## Phase 8: Performance Optimization
### 8.1 Caching Strategy ### 8.1 Caching Strategy
- Cache job listings (Redis/Memcached) - Cache job listings (Redis)
- Cache search results (5-15 minutes) - Cache search results (5-15 minutes)
- Cache categories and locations - Cache categories and locations
- Cache statistics/counters - Cache statistics/counters
@@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands;
use App\Services\QueryOptimizationService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('db:optimize')]
#[Description('Optimize database tables and analyze statistics')]
class OptimizeDatabaseCommand extends Command
{
public function __construct(
private QueryOptimizationService $optimizationService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('Starting database optimization...');
$tables = [
'jobs',
'job_categories',
'job_sources',
'job_locations',
'api_logs',
'job_fetch_logs',
'analytics_events',
];
foreach ($tables as $table) {
$this->info("Optimizing table: {$table}");
try {
// Optimize table (defragment and rebuild indexes)
$this->optimizationService->optimizeTable($table);
$this->line(" ✓ Optimized");
// Analyze table statistics
$this->optimizationService->analyzeTable($table);
$this->line(" ✓ Analyzed");
// Get table stats
$stats = $this->optimizationService->getTableStats($table);
if ($stats) {
$this->line(" Size: {$stats['size_mb']} MB (Data: {$stats['data_size_mb']} MB, Indexes: {$stats['index_size_mb']} MB)");
$this->line(" Rows: " . number_format($stats['table_rows']));
}
$this->newLine();
} catch (\Exception $e) {
$this->error(" ✗ Failed: " . $e->getMessage());
}
}
$this->info('Database optimization completed!');
return self::SUCCESS;
}
}
+48 -36
View File
@@ -3,11 +3,15 @@
namespace App\Repositories; namespace App\Repositories;
use App\Models\Job; use App\Models\Job;
use App\Services\CacheService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class JobRepository class JobRepository
{ {
public function __construct(
private CacheService $cacheService
) {}
/** /**
* Get all active jobs with pagination * Get all active jobs with pagination
*/ */
@@ -124,24 +128,28 @@ class JobRepository
*/ */
public function getFeaturedJobs(int $limit = 10): Collection public function getFeaturedJobs(int $limit = 10): Collection
{ {
// First, build a subquery with window function $cacheKey = $this->cacheService->featuredJobsKey($limit);
$subQuery = \DB::table('jobs')
->selectRaw('jobs.*, ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn')
->whereNull('deleted_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
// Then query from that subquery filtering by rn return $this->cacheService->rememberWithTags(['jobs'], $cacheKey, CacheService::CACHE_FEATURED_JOBS, function () use ($limit) {
// Use withoutGlobalScopes to prevent SoftDeletes from adding WHERE clause // First, build a subquery with window function
return Job::withoutGlobalScopes() $subQuery = \DB::table('jobs')
->with(['source', 'category', 'jobLocation']) ->selectRaw('jobs.*, ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn')
->fromSub($subQuery, 'ranked_jobs') ->whereNull('deleted_at')
->whereRaw('ranked_jobs.rn <= 2') ->where(function ($query) {
->orderBy('posted_at', 'desc') $query->whereNull('expires_at')
->limit($limit) ->orWhere('expires_at', '>', now());
->get(); });
// Then query from that subquery filtering by rn
// Use withoutGlobalScopes to prevent SoftDeletes from adding WHERE clause
return Job::withoutGlobalScopes()
->with(['source', 'category', 'jobLocation'])
->fromSub($subQuery, 'ranked_jobs')
->whereRaw('ranked_jobs.rn <= 2')
->orderBy('posted_at', 'desc')
->limit($limit)
->get();
});
} }
/** /**
@@ -149,27 +157,31 @@ class JobRepository
*/ */
public function getStatistics(): array public function getStatistics(): array
{ {
$total = Job::count(); $cacheKey = $this->cacheService->statisticsKey();
$active = Job::active()->count();
$remote = Job::active()->remote()->count();
$recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count();
$topCategories = Job::active() return $this->cacheService->remember($cacheKey, CacheService::CACHE_STATISTICS, function () {
->select('job_category_id', \DB::raw('COUNT(*) as count')) $total = Job::count();
->whereNotNull('job_category_id') $active = Job::active()->count();
->groupBy('job_category_id') $remote = Job::active()->remote()->count();
->orderBy('count', 'desc') $recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count();
->limit(5)
->with('category')
->get();
return [ $topCategories = Job::active()
'total_jobs' => $total, ->select('job_category_id', \DB::raw('COUNT(*) as count'))
'active_jobs' => $active, ->whereNotNull('job_category_id')
'remote_jobs' => $remote, ->groupBy('job_category_id')
'recently_added' => $recentlyAdded, ->orderBy('count', 'desc')
'top_categories' => $topCategories, ->limit(5)
]; ->with('category')
->get();
return [
'total_jobs' => $total,
'active_jobs' => $active,
'remote_jobs' => $remote,
'recently_added' => $recentlyAdded,
'top_categories' => $topCategories,
];
});
} }
/** /**
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class CacheService
{
// Cache durations (in seconds)
const CACHE_JOB_LISTINGS = 300; // 5 minutes
const CACHE_SEARCH_RESULTS = 600; // 10 minutes
const CACHE_CATEGORIES = 3600; // 1 hour
const CACHE_STATISTICS = 1800; // 30 minutes
const CACHE_FILTER_OPTIONS = 3600; // 1 hour
const CACHE_FEATURED_JOBS = 900; // 15 minutes
/**
* Generate cache key for job listings
*/
public function jobListingsKey(array $filters = [], int $page = 1, int $perPage = 20): string
{
ksort($filters);
return 'jobs:list:' . md5(serialize($filters) . ":{$page}:{$perPage}");
}
/**
* Generate cache key for search results
*/
public function searchResultsKey(array $filters = [], int $page = 1): string
{
ksort($filters);
return 'jobs:search:' . md5(serialize($filters) . ":{$page}");
}
/**
* Generate cache key for job details
*/
public function jobDetailsKey(int $jobId): string
{
return "job:details:{$jobId}";
}
/**
* Generate cache key for categories
*/
public function categoriesKey(): string
{
return 'categories:all';
}
/**
* Generate cache key for category jobs
*/
public function categoryJobsKey(int $categoryId, int $page = 1): string
{
return "category:{$categoryId}:jobs:{$page}";
}
/**
* Generate cache key for statistics
*/
public function statisticsKey(): string
{
return 'stats:dashboard';
}
/**
* Generate cache key for filter options
*/
public function filterOptionsKey(): string
{
return 'filters:options';
}
/**
* Generate cache key for featured jobs
*/
public function featuredJobsKey(int $limit = 10): string
{
return "jobs:featured:{$limit}";
}
/**
* Generate cache key for recent jobs
*/
public function recentJobsKey(int $days = 7, int $limit = 6): string
{
return "jobs:recent:{$days}:{$limit}";
}
/**
* Clear job-related caches
*/
public function clearJobCaches(): void
{
Cache::tags(['jobs'])->flush();
}
/**
* Clear category caches
*/
public function clearCategoryCaches(): void
{
Cache::tags(['categories'])->flush();
}
/**
* Clear statistics caches
*/
public function clearStatisticsCaches(): void
{
Cache::forget($this->statisticsKey());
}
/**
* Clear all application caches
*/
public function clearAllCaches(): void
{
Cache::flush();
}
/**
* Remember with tags (for better cache management)
*/
public function rememberWithTags(array $tags, string $key, int $ttl, callable $callback)
{
return Cache::tags($tags)->remember($key, $ttl, $callback);
}
/**
* Get cached value or store new one
*/
public function remember(string $key, int $ttl, callable $callback)
{
return Cache::remember($key, $ttl, $callback);
}
/**
* Get from cache
*/
public function get(string $key, $default = null)
{
return Cache::get($key, $default);
}
/**
* Put value in cache
*/
public function put(string $key, $value, int $ttl): bool
{
return Cache::put($key, $value, $ttl);
}
/**
* Forget cache key
*/
public function forget(string $key): bool
{
return Cache::forget($key);
}
/**
* Increment counter (useful for statistics)
*/
public function increment(string $key, int $value = 1): int
{
return Cache::increment($key, $value);
}
/**
* Decrement counter
*/
public function decrement(string $key, int $value = 1): int
{
return Cache::decrement($key, $value);
}
}
+38 -31
View File
@@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Builder;
class JobSearchService class JobSearchService
{ {
public function __construct(
private CacheService $cacheService
) {}
/** /**
* Search jobs with advanced filters * Search jobs with advanced filters
*/ */
@@ -188,36 +191,40 @@ class JobSearchService
*/ */
public function getFilterOptions(): array public function getFilterOptions(): array
{ {
return [ $cacheKey = $this->cacheService->filterOptionsKey();
'job_types' => [
'full-time' => 'Full-time', return $this->cacheService->remember($cacheKey, CacheService::CACHE_FILTER_OPTIONS, function () {
'part-time' => 'Part-time', // Get actual distinct values from database for job types and experience levels
'contract' => 'Contract', $jobTypes = Job::select('job_type')
'temporary' => 'Temporary', ->whereNotNull('job_type')
'internship' => 'Internship', ->distinct()
'freelance' => 'Freelance', ->pluck('job_type')
], ->toArray();
'experience_levels' => [
'entry' => 'Entry Level', $experienceLevels = Job::select('experience_level')
'mid' => 'Mid Level', ->whereNotNull('experience_level')
'senior' => 'Senior Level', ->distinct()
'lead' => 'Lead', ->pluck('experience_level')
'executive' => 'Executive', ->toArray();
],
'posted_within' => [ return [
1 => 'Last 24 hours', 'job_types' => $jobTypes,
7 => 'Last 7 days', 'experience_levels' => $experienceLevels,
30 => 'Last 30 days', 'posted_within' => [
90 => 'Last 3 months', 1 => 'Last 24 hours',
], 7 => 'Last 7 days',
'sort_options' => [ 30 => 'Last 30 days',
'date' => 'Most Recent', 90 => 'Last 3 months',
'date_asc' => 'Oldest First', ],
'salary_desc' => 'Highest Salary', 'sort_options' => [
'salary_asc' => 'Lowest Salary', 'date' => 'Most Recent',
'title' => 'Title A-Z', 'date_asc' => 'Oldest First',
'company' => 'Company A-Z', 'salary_desc' => 'Highest Salary',
], 'salary_asc' => 'Lowest Salary',
]; 'title' => 'Title A-Z',
'company' => 'Company A-Z',
],
];
});
} }
} }
+195
View File
@@ -0,0 +1,195 @@
<?php
namespace App\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class QueryOptimizationService
{
/**
* Add eager loading constraints to prevent N+1 queries
*/
public function eagerLoadJobRelations(Builder $query): Builder
{
return $query->with([
'source:id,name',
'category:id,name,slug,icon',
'jobLocation:id,city,state,country',
]);
}
/**
* Optimize job listing query
*/
public function optimizeJobListingQuery(Builder $query): Builder
{
return $query
->select([
'id',
'title',
'company',
'location',
'job_type',
'experience_level',
'salary_min',
'salary_max',
'salary_currency',
'remote_allowed',
'posted_at',
'job_source_id',
'job_category_id',
'job_location_id',
'created_at',
])
->with([
'source:id,name',
'category:id,name,slug,icon',
'jobLocation:id,city,state,country',
]);
}
/**
* Analyze slow queries
*/
public function analyzeSlowQueries(int $threshold = 1000): array
{
// Enable query logging
DB::enableQueryLog();
// Get queries that took longer than threshold (in milliseconds)
$queries = DB::getQueryLog();
$slowQueries = array_filter($queries, function ($query) use ($threshold) {
return ($query['time'] ?? 0) > $threshold;
});
return array_map(function ($query) {
return [
'query' => $query['query'],
'bindings' => $query['bindings'],
'time' => $query['time'] . 'ms',
];
}, $slowQueries);
}
/**
* Get query explain plan
*/
public function explainQuery(string $sql, array $bindings = []): array
{
$explained = DB::select('EXPLAIN ' . $sql, $bindings);
return array_map(function ($row) {
return (array) $row;
}, $explained);
}
/**
* Optimize table (defragment and rebuild indexes)
*/
public function optimizeTable(string $table): void
{
DB::statement("OPTIMIZE TABLE {$table}");
}
/**
* Analyze table statistics for query optimizer
*/
public function analyzeTable(string $table): void
{
DB::statement("ANALYZE TABLE {$table}");
}
/**
* Get table size and index statistics
*/
public function getTableStats(string $table): array
{
$stats = DB::select("
SELECT
table_name,
ROUND(((data_length + index_length) / 1024 / 1024), 2) AS size_mb,
table_rows,
ROUND((data_length / 1024 / 1024), 2) AS data_size_mb,
ROUND((index_length / 1024 / 1024), 2) AS index_size_mb
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
AND table_name = ?
", [$table]);
return $stats ? (array) $stats[0] : [];
}
/**
* Get index usage statistics
*/
public function getIndexStats(string $table): array
{
$stats = DB::select("
SELECT
index_name,
column_name,
seq_in_index,
cardinality,
index_type
FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = ?
ORDER BY index_name, seq_in_index
", [$table]);
return array_map(function ($row) {
return (array) $row;
}, $stats);
}
/**
* Check for missing indexes based on slow queries
*/
public function suggestIndexes(string $table): array
{
$suggestions = [];
// Get columns that are frequently used in WHERE clauses without indexes
$unindexedColumns = DB::select("
SELECT DISTINCT column_name
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name NOT IN (
SELECT column_name
FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = ?
)
", [$table, $table]);
foreach ($unindexedColumns as $column) {
$suggestions[] = [
'type' => 'missing_index',
'table' => $table,
'column' => $column->column_name,
'suggestion' => "Consider adding an index on {$column->column_name}",
];
}
return $suggestions;
}
/**
* Chunk large queries to reduce memory usage
*/
public function chunkQuery(Builder $query, int $chunkSize, callable $callback): void
{
$query->chunk($chunkSize, $callback);
}
/**
* Use cursor for memory-efficient iteration
*/
public function cursorQuery(Builder $query): \Illuminate\Support\LazyCollection
{
return $query->cursor();
}
}
@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('jobs', function (Blueprint $table) {
// Single column indexes for common filters
$table->index('posted_at', 'idx_jobs_posted_at');
$table->index('job_type', 'idx_jobs_type');
$table->index('experience_level', 'idx_jobs_experience');
$table->index('remote_allowed', 'idx_jobs_remote');
$table->index('created_at', 'idx_jobs_created_at');
$table->index('expires_at', 'idx_jobs_expires_at');
// Composite indexes for common query patterns
$table->index(['job_category_id', 'posted_at'], 'idx_jobs_category_posted');
$table->index(['job_type', 'posted_at'], 'idx_jobs_type_posted');
$table->index(['remote_allowed', 'posted_at'], 'idx_jobs_remote_posted');
$table->index(['job_source_id', 'posted_at'], 'idx_jobs_source_posted');
$table->index(['expires_at', 'deleted_at'], 'idx_jobs_expires_deleted');
// Full-text index for search (if not already exists)
DB::statement('ALTER TABLE jobs ADD FULLTEXT idx_jobs_search (title, company, description)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('jobs', function (Blueprint $table) {
// Drop single column indexes
$table->dropIndex('idx_jobs_posted_at');
$table->dropIndex('idx_jobs_type');
$table->dropIndex('idx_jobs_experience');
$table->dropIndex('idx_jobs_remote');
$table->dropIndex('idx_jobs_created_at');
$table->dropIndex('idx_jobs_expires_at');
// Drop composite indexes
$table->dropIndex('idx_jobs_category_posted');
$table->dropIndex('idx_jobs_type_posted');
$table->dropIndex('idx_jobs_remote_posted');
$table->dropIndex('idx_jobs_source_posted');
$table->dropIndex('idx_jobs_expires_deleted');
// Drop full-text index
DB::statement('ALTER TABLE jobs DROP INDEX idx_jobs_search');
});
}
};