本文目录导读:

在PHP项目中做赛季累计数据对比,是一个非常常见且核心的业务需求(比如体育赛事、游戏排位、销售周期对比等),这类需求的核心难点不在于PHP本身,而在于SQL的编写逻辑和数据架构的设计。
以下从数据设计思路、核心SQL写法、PHP 业务层实现以及前端展示建议四个维度为你提供一套完整的落地方案。
数据设计思路(决定对比的难易度)
要对比“赛季累计”,通常有两种表结构:
- 流水型(明细表):每场比赛/每笔交易一条记录。
- 结构:
id, player_id, season_id, match_date, points, rebounds(篮板), assists(助攻)
- 结构:
- 聚合型(汇总表):每天/每周同步一次该运动员的赛季总数据。
- 结构:
id, player_id, season_id, stats_date, total_points, total_rebounds, total_assists
- 结构:
强烈建议:使用流水表 + 按赛季切片查询,或者流水表 + 实时更新聚合表。
为什么? 因为累计数据是“死的”,对比是“动态的”,如果你已经把累计数据直接存在表里,一旦需要“截取某个时间点的累计数据”就会非常麻烦。
核心 SQL 写法(对比的关键)
假设我们有两个赛季:2023赛季(上赛季)和 2024赛季(本赛季)。 我们需要对比 [2024-05-01] 这个时间点,本赛季的前10场,对上赛季前10场的累计对比。
场景1:直接对比整赛季至今的累计
-- 统计当前赛季截止到某个日期的数据
SELECT
player_id,
SUM(points) AS current_season_points,
COUNT(*) AS current_games_played
FROM game_logs
WHERE season_id = '2024'
AND match_date <= '2024-12-25'
GROUP BY player_id;
-- 统计上赛季相同数量的场次或同期数据
SELECT
player_id,
SUM(points) AS last_season_points,
COUNT(*) AS last_games_played
FROM game_logs
WHERE season_id = '2023'
AND match_date <= DATE_SUB('2024-12-25', INTERVAL 1 YEAR) -- 对比上一年同期
GROUP BY player_id;
场景2:对比“前N场”的赛季累计(更公平的对比)
这个是最专业的需求(詹姆斯本赛季前20场得分 vs 上赛季前20场得分”),这需要用窗口函数或关联子查询生成“场次序号”。
-- 适用于 MySQL 8.0+ / PostgreSQL (使用窗口函数)
WITH ranked_games AS (
SELECT
player_id,
season_id,
match_date,
points,
ROW_NUMBER() OVER (PARTITION BY player_id, season_id ORDER BY match_date ASC) AS game_sequence
FROM game_logs
WHERE season_id IN ('2023', '2024')
)
SELECT
cur.player_id,
cur.season_total AS current_season_first_10_games,
last.season_total AS last_season_first_10_games,
(cur.season_total - last.season_total) AS difference
FROM
(SELECT player_id, SUM(points) AS season_total
FROM ranked_games
WHERE season_id = '2024' AND game_sequence <= 10
GROUP BY player_id) AS cur
LEFT JOIN
(SELECT player_id, SUM(points) AS season_total
FROM ranked_games
WHERE season_id = '2023' AND game_sequence <= 10
GROUP BY player_id) AS last
ON cur.player_id = last.player_id;
PHP 业务层实现(Laravel / ThinkPHP 示例)
在PHP中,我们通常不会写复杂的原生SQL(除非必须),更推荐的方式是分两次查询,在PHP内存中做合成,这对索引优化更友好。
以下以 Laravel 为例展示代码逻辑:
<?php
namespace App\Services;
use App\Models\GameLog;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class SeasonComparisonService
{
/**
* 获取某球员的赛季累计对比数据
*
* @param int $playerId
* @param string $currentSeasonId // '2024'
* @param string $previousSeasonId // '2023'
* @param int $limitCount // 对比前几场,10
* @return array
*/
public function comparePlayerStats(int $playerId, string $currentSeasonId, string $previousSeasonId, int $limitCount = 10): array
{
// 1. 获取本赛季前 N 场的累计
$currentStats = $this->getCumulativeStats($playerId, $currentSeasonId, $limitCount);
// 2. 获取上赛季前 N 场的累计
$previousStats = $this->getCumulativeStats($playerId, $previousSeasonId, $limitCount);
// 3. 在内存中做差值和百分比计算
$pointsDiff = $currentStats['total_points'] - $previousStats['total_points'];
$pointsGrowth = $previousStats['total_points'] > 0
? round(($pointsDiff / $previousStats['total_points']) * 100, 2)
: 0;
return [
'player_id' => $playerId,
'current_season' => [
'id' => $currentSeasonId,
'games' => $currentStats['games_played'],
'points' => $currentStats['total_points'],
'avg_points' => $currentStats['avg_points'],
],
'previous_season' => [
'id' => $previousSeasonId,
'games' => $previousStats['games_played'],
'points' => $previousStats['total_points'],
'avg_points' => $previousStats['avg_points'],
],
'comparison' => [
'points_diff' => $pointsDiff,
'points_growth_percent' => $pointsGrowth,
'status' => $pointsDiff > 0 ? 'up' : ($pointsDiff < 0 ? 'down' : 'same'),
]
];
}
/**
* 获取特定赛季前N场的聚合指标
*
* @param int $playerId
* @param string $seasonId
* @param int $limit
* @return array
*/
private function getCumulativeStats(int $playerId, string $seasonId, int $limit): array
{
// 子查询:按时间排序,取前N场的ID
$subQuery = GameLog::query()
->select('id')
->where('player_id', $playerId)
->where('season_id', $seasonId)
->orderBy('match_date', 'asc')
->orderBy('id', 'asc') // 防止同一天比赛顺序错乱
->limit($limit);
// 主查询:聚合这N场比赛的数据
$stats = GameLog::query()
->select(
'player_id',
DB::raw('COUNT(*) as games_played'),
DB::raw('SUM(points) as total_points'),
DB::raw('AVG(points) as avg_points'),
DB::raw('SUM(rebounds) as total_rebounds'),
DB::raw('SUM(assists) as total_assists')
)
->whereIn('id', $subQuery) // 只用ID匹配,性能快
->groupBy('player_id')
->first();
return [
'games_played' => $stats->games_played ?? 0,
'total_points' => (int) ($stats->total_points ?? 0),
'avg_points' => round($stats->avg_points ?? 0, 1),
'total_rebounds' => (int) ($stats->total_rebounds ?? 0),
'total_assists' => (int) ($stats->total_assists ?? 0),
];
}
}
性能优化与避坑指南
-
索引必须要有:
- 针对流水表
game_logs,建联合索引(player_id, season_id, match_date),这是最重要的索引,能保证上述查询走索引,避免全表扫描。
- 针对流水表
-
避免笛卡尔积:
- 如果在SQL里直接
JOIN ON a.season_id != b.season_id会导致数据爆炸,尽量在PHP层用Key-Value数组去匹配。
- 如果在SQL里直接
-
关于数据量巨大:
- 如果数据量极大(千万级),上面第一种使用
ROW_NUMBER()窗口函数的方式会非常慢,因为它要对全赛季数据排序。 - 高并发场景建议:使用
游戏事件表+ 定时任务(如每天凌晨)将每个球员的前N场累计数据预计算好存到stats_daily_cumulative表中,对比时只查该表,瞬间返回。
- 如果数据量极大(千万级),上面第一种使用
-
时区问题:
- 对比“同期”数据时,注意服务器的时区设置,如果上赛季是 2023-10-01 开始,本赛季是 2024-10-01开始,直接减一年可能有问题(闰年或日期偏)。强烈建议以“赛季开始的第N天”或“比赛场次”作为基准,而不是自然年日期。
前端展示建议(如果做图表)
返回给前端的 $result 结构已经算好了差值,前端推荐使用 Chart.js 或 ECharts 做双柱状图对比。
- X轴:
场次 (1, 2, 3 ... N)。 - Y轴:
累计得分。 - 两组折线:本赛季(红色) vs 上赛季(灰色)。
这样的话,前端逻辑会非常简单:
// 假设 PHP 返回 foreach 每场的数据
const chartData = {
labels: ['1场', '2场', '3场', ...],
datasets: [
{ label: '2024赛季', data: [25, 48, 70, ...], borderColor: 'red' },
{ label: '2023赛季', data: [20, 45, 66, ...], borderColor: 'gray' }
]
};
关键点在于:如果PHP只是做API接口,千万不要在循环里面查询数据库(N+1问题),使用上面推荐的 whereIn 或者直接分两次查询(一次查本赛季,一次查上赛季),然后在内存中 foreach 匹配计算,这是最稳妥且无性能瓶颈的方案。