Запрос MySQL для вычислений на основе записей о начале и окончании билетов
У меня есть база данных MySQL с записями о билетах, которые включают номер билета, тип события (начало или окончание), местоположение и дату. Для каждого номера билета существуют две записи, представляющие начальное и конечное местоположение.
Мне необходимо:
- Искать все записи за определенную дату
- Для каждой записи “Окончание” находить связанную запись “Начало” с тем же номером билета
- Выполнять вычисления на основе информации о начале и окончании
Также я сомневаюсь, является ли моя структура базы данных оптимальной, поскольку в настоящее время у меня есть отдельные строки для каждого события начала и окончания билета. Я рассматриваю возможность использования одной записи, которая обновляется данными об окончании при их получении.
Я реализую это решение на PHP.
Расчет на основе записей о начале и окончании билетов в MySQL
Для расчета на основе записей о начале и окончании билетов в MySQL необходимо объединить записи о билетах сами с собой, чтобы сопоставить события начала и окончания для одного и того же номера билета. Оптимальный подход использует самосоединение (self-join) с условным сопоставлением для пары записей о начале и окончании.
Содержание
- Базовое решение запроса
- Рекомендации по проектированию базы данных
- Продвинутые техники запросов
- Реализация на PHP
- Оптимизация производительности
- Лучшие практики
Базовое решение запроса
Наиболее прямой подход использует самосоединение (self-join) для сопоставления записей о начале и окончании:
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' - обе записи относятся к указанной дате
Рекомендации по проектированию базы данных
Ваше текущее проектирование с отдельными строками для событий начала и окончания имеет как преимущества, так и недостатки:
Текущее проектирование (отдельные строки):
- Плюсы:
- Историческое отслеживание всех событий
- Легкий аудит полного жизненного цикла билета
- Простота добавления новых типов событий
- Минусы:
- Требует соединений для базовых расчетов
- Более сложные запросы
- Возможность появления “осиротевших” записей (начато без окончания)
Альтернативное проектирование (Единая обновляемая запись):
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() для более сложного сопоставления
Для сценариев, где требуется более сложная логика сопоставления:
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
Обработка отсутствующих записей
Для обработки случаев, когда записи о начале или окончании могут отсутствовать:
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
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();
}
?>
Оптимизация производительности
Стратегия индексации
Для оптимальной производительности убедитесь, что у вас есть правильные индексы:
-- Создание индексов для наиболее распространенных шаблонов запросов
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);
Техники оптимизации запросов
-
Используйте EXPLAIN для анализа планов выполнения запросов:
sqlEXPLAIN SELECT stop.ticket_number FROM tickets stop INNER JOIN tickets start ON stop.ticket_number = start.ticket_number WHERE stop.date = '2024-01-15'; -
Ограничивайте результаты при работе с большими наборами данных:
sql-- Добавьте LIMIT для тестирования, затем удалите для продакшена SELECT ... LIMIT 1000; -
Рассмотрите материализованные представления для часто запрашиваемых отчетов:
sqlCREATE TABLE daily_ticket_stats ( report_date DATE PRIMARY KEY, total_tickets INT, total_hours DECIMAL(10,2), generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
Лучшие практики
-
Проверка данных: Обеспечьте целостность данных на уровне приложения
php// Проверка, что дата начала предшествует дате окончания if ($startDate >= $stopDate) { throw new InvalidArgumentException("Дата окончания должна быть после даты начала"); } -
Управление транзакциями: Используйте транзакции для критических операций
php$pdo->beginTransaction(); try { // Вставка обеих записей о начале и окончании $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); throw $e; } -
Обработка ошибок: Реализуйте комплексную обработку ошибок
phpprivate function validateTicketData($data) { $required = ['ticket_number', 'event_type', 'location', 'date']; foreach ($required as $field) { if (empty($data[$field])) { throw new InvalidArgumentException("Отсутствует обязательное поле: {$field}"); } } } -
Кэширование: Кэшируйте частые запросы для снижения нагрузки на базу данных
php// Пример использования файлового кэширования $cacheFile = "cache/daily_report_{$date}.json"; if (file_exists($cacheFile) && time() - filemtime($cacheFile) < 3600) { return json_decode(file_get_contents($cacheFile), true); } -
Мониторинг: Настройте мониторинг производительности запросов и качества данных
sql-- Мониторинг времени выполнения запросов SELECT * FROM performance_schema.events_statements_summary_by_digest WHERE digest_text LIKE '%calculateDailyDurations%' ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
Ваше текущее проектирование базы данных с отдельными строками для событий начала и окончания на самом деле довольно распространено и обеспечивает хорошую возможность аудита. Влияние соединений на производительность обычно незначительно, если вы не работаете с миллионами записей. В большинстве случаев предоставленное решение будет работать эффективно с правильной индексацией.
Источники
- MySQL Join Made Easy For Beginners
- MySQL JOIN - GeeksforGeeks
- MySQL JOIN types - INNER JOIN, SELF JOIN, CROSS JOIN, FULL OUTER
- MySQL 8.0 Reference Manual - SELECT Statement
- Database Administrators - How to avoid deadlocks when generating consecutive ticket numbers
- Stack Overflow - Making a MySQL query return/stop executing after finding the first match
Заключение
Чтобы эффективно рассчитывать на основе записей о начале и окончании билетов в MySQL:
- Используйте самосоединения для сопоставления записей о начале и окончании по номеру билета и типам событий
- Реализуйте правильную индексацию по столбцам ticket_number, event_type и date для оптимальной производительности
- Учитывайте ваше проектирование базы данных - отдельные строки обеспечивают возможность аудита, в то время как единые записи предлагают простоту
- Используйте классы PHP для инкапсуляции бизнес-логики и улучшения поддерживаемости кода
- Обрабатывайте крайние случаи, такие как отсутствующие записи и проверку данных
- Мониторьте производительность и оптимизируйте запросы по мере роста вашего набора данных
Предоставленное решение балансирует между производительностью и целостностью данных и обеспечивает прочную основу для вашей системы управления билетами. Начните с базового подхода к запросам и постепенно внедряйте более продвинутые функции по мере развития ваших требований.