MidТеория6 min

Сессии и загрузки

Конфигурация сессий: хранилища, сборщик мусора, время жизни. Загрузка файлов: лимиты, временные директории, обработка ошибок

Сессии и загрузка файлов

Хранилище сессий (session.save_handler)

По умолчанию PHP хранит данные сессий в файлах. Для production-приложений с несколькими серверами нужно централизованное хранилище.

Файловое хранилище (по умолчанию)

; Default: files stored on disk
session.save_handler = files

; Directory for session files
; Default: /tmp (INSECURE on shared hosting!)
session.save_path = /var/lib/php/sessions

; File permissions for session files (octal)
; 600 = owner read/write only
session.save_path = "600;/var/lib/php/sessions"
<?php

// Session files are named: sess_<session_id>
// Example: /var/lib/php/sessions/sess_abc123def456

// Check current handler
echo ini_get('session.save_handler'); // "files"
echo ini_get('session.save_path');    // "/var/lib/php/sessions"

// Session file content is serialized data:
// username|s:5:"admin";role|s:4:"user";last_active|i:1708617600;

Проблема файлов: На высоконагруженных серверах тысячи session-файлов в одной директории замедляют файловую систему. Также невозможно масштабировать на несколько серверов.

Redis-хранилище

; Redis -- recommended for production
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=secret&database=1"

; With Redis Sentinel
session.save_path = "tcp://sentinel1:26379?auth=secret&sentinel=mymaster,tcp://sentinel2:26379?auth=secret&sentinel=mymaster"

; With TLS
session.save_path = "tls://redis.example.com:6380?auth=secret"

Memcached-хранилище

; Memcached
session.save_handler = memcached
session.save_path = "127.0.0.1:11211"

; Multiple servers
session.save_path = "server1:11211,server2:11211"

Сравнение хранилищ

Хранилище Скорость Масштабирование Персистентность Когда использовать
files Средняя Один сервер Да Разработка, малые проекты
Redis Быстрая Кластер Настраивается Production (рекомендуется)
Memcached Быстрая Кластер Нет Когда потеря сессий допустима
Database Медленная Да Да Когда нужен аудит сессий

Сборщик мусора сессий (Garbage Collection)

PHP периодически удаляет устаревшие файлы сессий. Частота определяется вероятностной формулой.

Конфигурация GC

; Session lifetime in seconds
; After this time inactive session data becomes eligible for GC
session.gc_maxlifetime = 1440    ; 24 minutes (default)

; GC probability = gc_probability / gc_divisor
; Default: 1/100 = 1% chance on each request
session.gc_probability = 1
session.gc_divisor = 100

; For high-traffic sites: reduce probability
session.gc_probability = 1
session.gc_divisor = 1000       ; 0.1% chance

; Disable GC entirely (use external cron job instead)
session.gc_probability = 0

Вероятность запуска GC

<?php

// GC runs with probability = gc_probability / gc_divisor
// Default: 1/100 = 1% of requests trigger GC

// On Debian/Ubuntu: GC is DISABLED by default!
// gc_probability = 0
// Session cleanup is done via cron: /etc/cron.d/php
// This is intentional -- prevents random request slowdowns

// Check if GC is enabled
$prob = (int) ini_get('session.gc_probability');
$div = (int) ini_get('session.gc_divisor');

if ($prob === 0) {
    echo "GC disabled -- check cron jobs\n";
} else {
    echo "GC probability: " . ($prob / $div * 100) . "%\n";
}

Подвох Debian/Ubuntu: На этих системах session.gc_probability = 0 по умолчанию. Сборка мусора вообще не запускается через PHP! Вместо этого работает системный cron-скрипт. Если вы используете нестандартный session.save_path, старые сессии НЕ будут удаляться.

Правильная очистка через cron

# Cron job for session cleanup (better than PHP GC)
# Runs every 30 minutes, deletes sessions older than 24 minutes
*/30 * * * * find /var/lib/php/sessions -name 'sess_*' -mmin +24 -delete

# For Redis: sessions expire automatically via TTL
# No cron needed!

Время жизни сессии

; gc_maxlifetime: when INACTIVE session data is eligible for deletion
session.gc_maxlifetime = 1440    ; 24 minutes

; cookie_lifetime: when browser deletes the session cookie
; 0 = session cookie (deleted when browser closes)
session.cookie_lifetime = 0

; IMPORTANT: these are DIFFERENT things!
; gc_maxlifetime: server-side data expiration
; cookie_lifetime: client-side cookie expiration
<?php

// Common mistake: setting only one
// If gc_maxlifetime = 3600 (1 hour)
// but cookie_lifetime = 0 (session cookie)
// Then: user closes browser -> cookie gone -> session data stays 1 hour on server

// If gc_maxlifetime = 1440 (24 min)
// but cookie_lifetime = 86400 (1 day)
// Then: cookie valid but server data deleted after 24 min -> empty session!

// Best practice: gc_maxlifetime >= cookie_lifetime
// Or cookie_lifetime = 0 (session cookies)

// Programmatic session management
session_start([
    'gc_maxlifetime' => 3600,
    'cookie_lifetime' => 3600,
]);

Сериализация данных сессии

; Serialization handler for session data
; php: default PHP serialization (key|serialized_value)
; php_serialize: standard serialize() format (PHP 5.5.4+)
; php_binary: binary format

session.serialize_handler = php_serialize
<?php

// "php" format (default, legacy):
// name|s:5:"admin";age|i:25;
// WARNING: pipes (|) in keys cause issues!

// "php_serialize" format (recommended):
// a:2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}
// Uses standard serialize() -- no character restrictions

// IMPORTANT: changing handler with existing sessions
// will corrupt all active sessions! Plan migration.

Загрузка файлов

Основные директивы

; Enable file uploads
file_uploads = On

; Maximum size of a SINGLE uploaded file
upload_max_filesize = 2M         ; Default (very small!)

; Maximum size of entire POST body (files + form data)
post_max_size = 8M               ; Must be >= upload_max_filesize

; Maximum number of files in single request
max_file_uploads = 20            ; Default

; Temporary directory for uploaded files
upload_tmp_dir = /tmp            ; Default: system temp dir

Иерархия лимитов

memory_limit >= post_max_size >= upload_max_filesize

Example for 100MB file uploads:
  upload_max_filesize = 100M
  post_max_size = 110M          ; Extra space for form data
  memory_limit = 256M           ; Larger than post_max_size
  max_execution_time = 300      ; Time for upload processing
  max_input_time = 300          ; Time for receiving upload

Критическое правило: Если post_max_size < upload_max_filesize, при загрузке файла между этими размерами $_FILES и $_POST будут полностью пустыми без какой-либо ошибки.

Уровни доступа файловых директив

<?php

// IMPORTANT: access levels differ!
// file_uploads     -- PHP_INI_SYSTEM (only php.ini)
// upload_max_filesize -- PHP_INI_PERDIR (.user.ini, php.ini)
// post_max_size    -- PHP_INI_PERDIR (.user.ini, php.ini)
// max_file_uploads -- PHP_INI_SYSTEM (only php.ini)

// NONE of these can be changed via ini_set()!
ini_set('upload_max_filesize', '100M'); // Returns false!
ini_set('post_max_size', '100M');       // Returns false!

Обработка ошибок загрузки

<?php

// Always check upload error code first
$errorMessages = [
    UPLOAD_ERR_OK         => 'Upload successful',
    UPLOAD_ERR_INI_SIZE   => 'File exceeds upload_max_filesize (' . ini_get('upload_max_filesize') . ')',
    UPLOAD_ERR_FORM_SIZE  => 'File exceeds MAX_FILE_SIZE in HTML form',
    UPLOAD_ERR_PARTIAL    => 'File was only partially uploaded',
    UPLOAD_ERR_NO_FILE    => 'No file was uploaded',
    UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary directory',
    UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
    UPLOAD_ERR_EXTENSION  => 'A PHP extension stopped the upload',
];

function handleUpload(array $file): string
{
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException(
            $errorMessages[$file['error']] ?? 'Unknown upload error'
        );
    }

    // CRITICAL: Always verify with is_uploaded_file()
    if (!is_uploaded_file($file['tmp_name'])) {
        throw new RuntimeException('Security violation: not an uploaded file');
    }

    // Move to permanent location
    $destination = '/var/www/app/uploads/' . bin2hex(random_bytes(16)) . '.dat';
    if (!move_uploaded_file($file['tmp_name'], $destination)) {
        throw new RuntimeException('Failed to move uploaded file');
    }

    return $destination;
}

Проверка конфигурации загрузок

<?php

function checkUploadConfig(int $requiredSizeMB): array
{
    $issues = [];

    $uploadMax = convertToBytes(ini_get('upload_max_filesize'));
    $postMax = convertToBytes(ini_get('post_max_size'));
    $memoryLimit = ini_get('memory_limit');
    $memoryBytes = $memoryLimit === '-1' ? PHP_INT_MAX : convertToBytes($memoryLimit);
    $required = $requiredSizeMB * 1024 * 1024;

    if ($uploadMax < $required) {
        $issues[] = "upload_max_filesize too small: " . ini_get('upload_max_filesize');
    }
    if ($postMax < $uploadMax) {
        $issues[] = "post_max_size < upload_max_filesize (broken!)";
    }
    if ($memoryBytes < $postMax) {
        $issues[] = "memory_limit < post_max_size";
    }
    if (!ini_get('file_uploads')) {
        $issues[] = "file_uploads is disabled!";
    }

    return $issues;
}

function convertToBytes(string $value): int
{
    $unit = strtoupper(substr(trim($value), -1));
    $bytes = (int) $value;

    return match ($unit) {
        'G' => $bytes * 1024 * 1024 * 1024,
        'M' => $bytes * 1024 * 1024,
        'K' => $bytes * 1024,
        default => $bytes,
    };
}

Настройка для разных сценариев

API-сервер (без загрузок)

file_uploads = Off
upload_max_filesize = 0
post_max_size = 2M
max_file_uploads = 0
session.save_handler = redis
session.save_path = "tcp://redis:6379"
session.gc_maxlifetime = 900

Файловый сервис (большие загрузки)

file_uploads = On
upload_max_filesize = 500M
post_max_size = 550M
memory_limit = 1G
max_file_uploads = 50
max_execution_time = 600
max_input_time = 600
session.save_handler = redis
session.save_path = "tcp://redis:6379"

CMS / админ-панель

file_uploads = On
upload_max_filesize = 50M
post_max_size = 55M
memory_limit = 256M
max_file_uploads = 20
session.save_handler = files
session.save_path = /var/lib/php/sessions
session.gc_maxlifetime = 3600
session.cookie_lifetime = 0

Проверь себя

Можно ли изменить `upload_max_filesize` через `ini_set()`?

Почему на Debian/Ubuntu сессии не очищаются сборщиком мусора PHP?

Чем `session.gc_maxlifetime` отличается от `session.cookie_lifetime`?

Какой `session.serialize_handler` рекомендуется использовать?

Что произойдёт, если `post_max_size = 5M`, а пользователь отправит POST-запрос размером 10M?