НейроАгент

Запрос MySQL: Расчет записей билетов с началом и окончанием

Узнайте, как рассчитывать длительность между записями билетов с началом и окончанием в MySQL. Полное руководство с запросами самосоединения, реализацией на PHP и советами по оптимизации производительности.

Вопрос

Запрос MySQL для вычислений на основе записей о начале и окончании билетов

У меня есть база данных MySQL с записями о билетах, которые включают номер билета, тип события (начало или окончание), местоположение и дату. Для каждого номера билета существуют две записи, представляющие начальное и конечное местоположение.

Мне необходимо:

  • Искать все записи за определенную дату
  • Для каждой записи “Окончание” находить связанную запись “Начало” с тем же номером билета
  • Выполнять вычисления на основе информации о начале и окончании

Также я сомневаюсь, является ли моя структура базы данных оптимальной, поскольку в настоящее время у меня есть отдельные строки для каждого события начала и окончания билета. Я рассматриваю возможность использования одной записи, которая обновляется данными об окончании при их получении.

Я реализую это решение на PHP.

НейроАгент

Расчет на основе записей о начале и окончании билетов в MySQL

Для расчета на основе записей о начале и окончании билетов в MySQL необходимо объединить записи о билетах сами с собой, чтобы сопоставить события начала и окончания для одного и того же номера билета. Оптимальный подход использует самосоединение (self-join) с условным сопоставлением для пары записей о начале и окончании.

Содержание

Базовое решение запроса

Наиболее прямой подход использует самосоединение (self-join) для сопоставления записей о начале и окончании:

sql
SELECT 
    stop.ticket_number,
    stop.date AS stop_date,
    stop.location AS stop_location,
    start.date AS start_date,
    start.location AS start_location,
    TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
    TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
FROM 
    tickets stop
INNER JOIN 
    tickets start ON stop.ticket_number = start.ticket_number 
                  AND stop.event_type = 'Stop'
                  AND start.event_type = 'Start'
WHERE 
    stop.date = '2024-01-15'
    AND start.date = '2024-01-15'

Этот запрос объединяет таблицу tickets самой с собой, сопоставляя записи, где:

  • номера билетов совпадают
  • одна запись имеет event_type = 'Stop', а другая - event_type = 'Start'
  • обе записи относятся к указанной дате

Рекомендации по проектированию базы данных

Ваше текущее проектирование с отдельными строками для событий начала и окончания имеет как преимущества, так и недостатки:

Текущее проектирование (отдельные строки):

  • Плюсы:
    • Историческое отслеживание всех событий
    • Легкий аудит полного жизненного цикла билета
    • Простота добавления новых типов событий
  • Минусы:
    • Требует соединений для базовых расчетов
    • Более сложные запросы
    • Возможность появления “осиротевших” записей (начато без окончания)

Альтернативное проектирование (Единая обновляемая запись):

sql
CREATE TABLE tickets (
    ticket_number INT PRIMARY KEY,
    start_location VARCHAR(100),
    start_date DATETIME,
    stop_location VARCHAR(100),
    stop_date DATETIME,
    duration_hours INT,
    status ENUM('active', 'completed', 'cancelled'),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Когда выбирать какое проектирование:

  • Используйте отдельные строки, если вам нужны полные журналы аудита
  • Используйте единые записи, если критична производительность, а журналы аудита могут обрабатываться отдельно
  • Рассмотрите гибридный подход с обоими вариантами проектирования

Продвинутые техники запросов

Использование ROW_NUMBER() для более сложного сопоставления

Для сценариев, где требуется более сложная логика сопоставления:

sql
WITH numbered_tickets AS (
    SELECT 
        ticket_number,
        location,
        date,
        event_type,
        CASE 
            WHEN event_type = 'Start' THEN 1
            WHEN event_type = 'Stop' THEN 2
        END AS event_order,
        ROW_NUMBER() OVER (PARTITION BY ticket_number ORDER BY date, event_type) AS rn
    FROM tickets
    WHERE date = '2024-01-15'
)
SELECT 
    t1.ticket_number,
    t1.location AS start_location,
    t1.date AS start_date,
    t2.location AS stop_location,
    t2.date AS stop_date,
    TIMESTAMPDIFF(HOUR, t1.date, t2.date) AS duration_hours
FROM numbered_tickets t1
JOIN numbered_tickets t2 ON t1.ticket_number = t2.ticket_number 
                       AND t1.event_order = 1 
                       AND t2.event_order = 2
                       AND t1.rn = t2.rn - 1

Обработка отсутствующих записей

Для обработки случаев, когда записи о начале или окончании могут отсутствовать:

sql
SELECT 
    COALESCE(stop.ticket_number, start.ticket_number) AS ticket_number,
    stop.location AS stop_location,
    stop.date AS stop_date,
    start.location AS start_location,
    start.date AS start_date,
    CASE 
        WHEN stop.ticket_number IS NOT NULL AND start.ticket_number IS NOT NULL 
        THEN TIMESTAMPDIFF(HOUR, start.date, stop.date)
        ELSE NULL
    END AS duration_hours
FROM 
    (SELECT * FROM tickets WHERE event_type = 'Stop' AND date = '2024-01-15') stop
FULL OUTER JOIN 
    (SELECT * FROM tickets WHERE event_type = 'Start' AND date = '2024-01-15') start
    ON stop.ticket_number = start.ticket_number

Реализация на PHP

Вот полная реализация решения на PHP:

php
<?php
class TicketCalculator {
    private $pdo;
    
    public function __construct($pdo) {
        $this->pdo = $pdo;
    }
    
    /**
     * Расчет длительности билетов для конкретной даты
     */
    public function calculateDailyDurations($date) {
        $sql = "SELECT 
                    stop.ticket_number,
                    stop.date AS stop_date,
                    stop.location AS stop_location,
                    start.date AS start_date,
                    start.location AS start_location,
                    TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
                    TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
                FROM tickets stop
                INNER JOIN tickets start 
                    ON stop.ticket_number = start.ticket_number 
                    AND stop.event_type = 'Stop'
                    AND start.event_type = 'Start'
                WHERE 
                    stop.date = :date
                    AND start.date = :date";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['date' => $date]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
    
    /**
     * Генерация ежедневного отчета со статистикой
     */
    public function generateDailyReport($date) {
        $tickets = $this->calculateDailyDurations($date);
        
        $totalTickets = count($tickets);
        $totalHours = array_sum(array_column($tickets, 'duration_hours'));
        $averageDuration = $totalTickets > 0 ? $totalHours / $totalTickets : 0;
        
        return [
            'date' => $date,
            'total_tickets' => $totalTickets,
            'total_hours' => $totalHours,
            'average_duration_hours' => round($averageDuration, 2),
            'tickets' => $tickets
        ];
    }
    
    /**
     * Проверка отсутствующих записей о начале или окончании
     */
    public function findIncompleteTickets($date) {
        // Поиск билетов с окончанием, но без начала
        $sql = "SELECT DISTINCT t1.ticket_number, 'missing_start' AS issue
                FROM tickets t1
                LEFT JOIN tickets t2 ON t1.ticket_number = t2.ticket_number 
                                   AND t1.event_type = 'Stop' 
                                   AND t2.event_type = 'Start'
                WHERE t1.event_type = 'Stop' 
                AND t1.date = :date
                AND t2.ticket_number IS NULL
                
                UNION
                
                SELECT DISTINCT t2.ticket_number, 'missing_stop' AS issue
                FROM tickets t2
                LEFT JOIN tickets t1 ON t2.ticket_number = t1.ticket_number 
                                   AND t2.event_type = 'Start' 
                                   AND t1.event_type = 'Stop'
                WHERE t2.event_type = 'Start' 
                AND t2.date = :date
                AND t1.ticket_number IS NULL";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['date' => $date]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

// Пример использования
try {
    $pdo = new PDO('mysql:host=localhost;dbname=tickets_db', 'username', 'password');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    
    $calculator = new TicketCalculator($pdo);
    
    // Генерация отчета за 15 января 2024 года
    $report = $calculator->generateDailyReport('2024-01-15');
    echo "Ежедневный отчет за {$report['date']}:";
    echo "Всего билетов: {$report['total_tickets']}";
    echo "Всего часов: {$report['total_hours']}";
    echo "Средняя длительность: {$report['average_duration_hours']} часов";
    
    // Проверка неполных билетов
    $incomplete = $calculator->findIncompleteTickets('2024-01-15');
    if (!empty($incomplete)) {
        echo "Обнаружены неполные билеты:";
        print_r($incomplete);
    }
    
} catch (PDOException $e) {
    echo "Ошибка базы данных: " . $e->getMessage();
}
?>

Оптимизация производительности

Стратегия индексации

Для оптимальной производительности убедитесь, что у вас есть правильные индексы:

sql
-- Создание индексов для наиболее распространенных шаблонов запросов
CREATE INDEX idx_ticket_number ON tickets(ticket_number);
CREATE INDEX idx_event_type ON tickets(event_type);
CREATE INDEX idx_date ON tickets(date);
CREATE INDEX idx_ticket_event_date ON tickets(ticket_number, event_type, date);

-- Составной индекс для наиболее распространенного запроса
CREATE INDEX idx_composite_query ON tickets(date, event_type, ticket_number);

Техники оптимизации запросов

  1. Используйте EXPLAIN для анализа планов выполнения запросов:

    sql
    EXPLAIN SELECT stop.ticket_number FROM tickets stop
    INNER JOIN tickets start ON stop.ticket_number = start.ticket_number
    WHERE stop.date = '2024-01-15';
    
  2. Ограничивайте результаты при работе с большими наборами данных:

    sql
    -- Добавьте LIMIT для тестирования, затем удалите для продакшена
    SELECT ... LIMIT 1000;
    
  3. Рассмотрите материализованные представления для часто запрашиваемых отчетов:

    sql
    CREATE TABLE daily_ticket_stats (
        report_date DATE PRIMARY KEY,
        total_tickets INT,
        total_hours DECIMAL(10,2),
        generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    

Лучшие практики

  1. Проверка данных: Обеспечьте целостность данных на уровне приложения

    php
    // Проверка, что дата начала предшествует дате окончания
    if ($startDate >= $stopDate) {
        throw new InvalidArgumentException("Дата окончания должна быть после даты начала");
    }
    
  2. Управление транзакциями: Используйте транзакции для критических операций

    php
    $pdo->beginTransaction();
    try {
        // Вставка обеих записей о начале и окончании
        $pdo->commit();
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
    
  3. Обработка ошибок: Реализуйте комплексную обработку ошибок

    php
    private function validateTicketData($data) {
        $required = ['ticket_number', 'event_type', 'location', 'date'];
        foreach ($required as $field) {
            if (empty($data[$field])) {
                throw new InvalidArgumentException("Отсутствует обязательное поле: {$field}");
            }
        }
    }
    
  4. Кэширование: Кэшируйте частые запросы для снижения нагрузки на базу данных

    php
    // Пример использования файлового кэширования
    $cacheFile = "cache/daily_report_{$date}.json";
    if (file_exists($cacheFile) && time() - filemtime($cacheFile) < 3600) {
        return json_decode(file_get_contents($cacheFile), true);
    }
    
  5. Мониторинг: Настройте мониторинг производительности запросов и качества данных

    sql
    -- Мониторинг времени выполнения запросов
    SELECT * FROM performance_schema.events_statements_summary_by_digest
    WHERE digest_text LIKE '%calculateDailyDurations%'
    ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
    

Ваше текущее проектирование базы данных с отдельными строками для событий начала и окончания на самом деле довольно распространено и обеспечивает хорошую возможность аудита. Влияние соединений на производительность обычно незначительно, если вы не работаете с миллионами записей. В большинстве случаев предоставленное решение будет работать эффективно с правильной индексацией.

Источники

  1. MySQL Join Made Easy For Beginners
  2. MySQL JOIN - GeeksforGeeks
  3. MySQL JOIN types - INNER JOIN, SELF JOIN, CROSS JOIN, FULL OUTER
  4. MySQL 8.0 Reference Manual - SELECT Statement
  5. Database Administrators - How to avoid deadlocks when generating consecutive ticket numbers
  6. Stack Overflow - Making a MySQL query return/stop executing after finding the first match

Заключение

Чтобы эффективно рассчитывать на основе записей о начале и окончании билетов в MySQL:

  1. Используйте самосоединения для сопоставления записей о начале и окончании по номеру билета и типам событий
  2. Реализуйте правильную индексацию по столбцам ticket_number, event_type и date для оптимальной производительности
  3. Учитывайте ваше проектирование базы данных - отдельные строки обеспечивают возможность аудита, в то время как единые записи предлагают простоту
  4. Используйте классы PHP для инкапсуляции бизнес-логики и улучшения поддерживаемости кода
  5. Обрабатывайте крайние случаи, такие как отсутствующие записи и проверку данных
  6. Мониторьте производительность и оптимизируйте запросы по мере роста вашего набора данных

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