phase 8
This commit is contained in:
@@ -250,7 +250,7 @@ DELETE /api/jobs/{id}/save - Unsave job
|
||||
## Phase 8: Performance Optimization
|
||||
|
||||
### 8.1 Caching Strategy
|
||||
- Cache job listings (Redis/Memcached)
|
||||
- Cache job listings (Redis)
|
||||
- Cache search results (5-15 minutes)
|
||||
- Cache categories and locations
|
||||
- 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;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class JobRepository
|
||||
{
|
||||
public function __construct(
|
||||
private CacheService $cacheService
|
||||
) {}
|
||||
/**
|
||||
* Get all active jobs with pagination
|
||||
*/
|
||||
@@ -124,6 +128,9 @@ class JobRepository
|
||||
*/
|
||||
public function getFeaturedJobs(int $limit = 10): Collection
|
||||
{
|
||||
$cacheKey = $this->cacheService->featuredJobsKey($limit);
|
||||
|
||||
return $this->cacheService->rememberWithTags(['jobs'], $cacheKey, CacheService::CACHE_FEATURED_JOBS, function () use ($limit) {
|
||||
// First, build a subquery with window function
|
||||
$subQuery = \DB::table('jobs')
|
||||
->selectRaw('jobs.*, ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn')
|
||||
@@ -142,6 +149,7 @@ class JobRepository
|
||||
->orderBy('posted_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +157,9 @@ class JobRepository
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$cacheKey = $this->cacheService->statisticsKey();
|
||||
|
||||
return $this->cacheService->remember($cacheKey, CacheService::CACHE_STATISTICS, function () {
|
||||
$total = Job::count();
|
||||
$active = Job::active()->count();
|
||||
$remote = Job::active()->remote()->count();
|
||||
@@ -170,6 +181,7 @@ class JobRepository
|
||||
'recently_added' => $recentlyAdded,
|
||||
'top_categories' => $topCategories,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class JobSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private CacheService $cacheService
|
||||
) {}
|
||||
/**
|
||||
* Search jobs with advanced filters
|
||||
*/
|
||||
@@ -188,22 +191,25 @@ class JobSearchService
|
||||
*/
|
||||
public function getFilterOptions(): array
|
||||
{
|
||||
$cacheKey = $this->cacheService->filterOptionsKey();
|
||||
|
||||
return $this->cacheService->remember($cacheKey, CacheService::CACHE_FILTER_OPTIONS, function () {
|
||||
// Get actual distinct values from database for job types and experience levels
|
||||
$jobTypes = Job::select('job_type')
|
||||
->whereNotNull('job_type')
|
||||
->distinct()
|
||||
->pluck('job_type')
|
||||
->toArray();
|
||||
|
||||
$experienceLevels = Job::select('experience_level')
|
||||
->whereNotNull('experience_level')
|
||||
->distinct()
|
||||
->pluck('experience_level')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'job_types' => [
|
||||
'full-time' => 'Full-time',
|
||||
'part-time' => 'Part-time',
|
||||
'contract' => 'Contract',
|
||||
'temporary' => 'Temporary',
|
||||
'internship' => 'Internship',
|
||||
'freelance' => 'Freelance',
|
||||
],
|
||||
'experience_levels' => [
|
||||
'entry' => 'Entry Level',
|
||||
'mid' => 'Mid Level',
|
||||
'senior' => 'Senior Level',
|
||||
'lead' => 'Lead',
|
||||
'executive' => 'Executive',
|
||||
],
|
||||
'job_types' => $jobTypes,
|
||||
'experience_levels' => $experienceLevels,
|
||||
'posted_within' => [
|
||||
1 => 'Last 24 hours',
|
||||
7 => 'Last 7 days',
|
||||
@@ -219,5 +225,6 @@ class JobSearchService
|
||||
'company' => 'Company A-Z',
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user