1. 这个系统到底要解决什么问题:双框架评分的起点
先说一下我为什么要折腾这么一套东西。事情起因是我们学校要搞一场面向全院学生的健美操和舞蹈比赛,参赛队伍录好视频提交上来,评委要对着视频逐项打分。一开始用Excel表格统计,几十个评委、几百个参赛视频,光是收集评分表、核对分数、去掉最高最低分就让人头大。后面还涉及不同评委在不同时间打分、视频下载下来反复拖动、分数汇总容易抄错——这些琐碎问题堆积起来,就逼着我去做一个真正能用的在线视频评分系统。
这个系统的核心需求其实很集中:第一,评委能在线观看参赛视频;第二,评分维度要灵活配置,比如健美操有动作完成度、艺术表现力、整体编排,舞蹈可能有技术技巧、情感表达、音乐契合度;第三,系统要自动汇总分数,去掉最高最低分,按权重算出总得分;第四,要有完整的权限控制,普通评委不能看到别人的打分,管理员能查看和导出所有结果。
技术选型上,我最终决定同时用ThinkPHP和Laravel各实现一套。这听起来有点“重复造轮子”,但实际做下来收获非常大。ThinkPHP在国内中小项目里用得特别多,上手快、文档全是中文、部署简单,很多高校和中小企业的系统都是它做的;Laravel则是国际主流框架,生态完善、设计理念现代,适合做更复杂的业务逻辑。同一套业务需求,用两种框架分别落地,既能对比出框架设计哲学的差异,也能为后续维护者提供选择参考——毕竟不同团队的技术栈偏好不一样。
文章标题里那个“_o4o1y”是项目代号,不用太在意。重点是这套系统的完整设计思路和双框架实现方案,我会从数据库设计、核心评分逻辑、视频处理、权限防作弊、性能优化这几个维度展开,把我实际踩过的坑和验证过的方案都写出来。
2. 数据库设计与核心实体关系:先把表结构定明白
2.1 六个核心数据表的职责划分
不管用ThinkPHP还是Laravel,底层都是MySQL,数据库设计是共通的,也是整个系统的地基。我第一版设计走了弯路,把评分规则硬编码在代码里,后面改规则简直要命。后来重构成了下面这套结构:
users(用户表):存储管理员、评委、选手三类账号,用role字段区分,密码用哈希存储。videos(参赛视频表):存储视频基本信息,包括所属参赛队、项目类型(健美操/体操/舞蹈)、视频URL、上传时间、状态。scoring_criteria(评分维度表):每个比赛项目可以配置多个评分维度,比如健美操有“动作完成度(30分)”“艺术表现力(30分)”“编排创意(20分)”“团队整齐度(20分)”。scores(评分表):核心业务表,记录每个评委对每个视频在各维度上的打分。teams(参赛队伍表):参赛队伍或选手信息。competitions(比赛场次表):一场比赛包含哪些项目、哪些参赛队伍、哪些评委。
这里面最关键的是scores表,它的设计直接决定统计逻辑的复杂度。我采用的方案是每个维度一条记录,用criterion_id关联scoring_criteria表。
2.2 为什么评分表要采用一维度一记录的存储模式
很多人习惯把多个维度写成一个JSON字段存进去,或者建十几个score_1、score_2这种冗余列。这两种做法我都试过,前者查询方便但统计极难聚合,后者改需求就是噩梦。最终选择了一维度一记录的模式:
CREATE TABLE `scores` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `video_id` bigint unsigned NOT NULL COMMENT '参赛视频ID', `judge_id` bigint unsigned NOT NULL COMMENT '评委用户ID', `criterion_id` bigint unsigned NOT NULL COMMENT '评分维度ID', `score` decimal(5,2) NOT NULL COMMENT '该维度得分', `remark` varchar(500) DEFAULT NULL COMMENT '评委备注', `created_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_video_judge_criterion` (`video_id`,`judge_id`,`criterion_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个细节值得注意:uk_video_judge_criterion这个联合唯一索引,保证了同一个评委对同一个视频的同一个维度只能打一次分。这个约束在应用层面还要再写一道校验,但数据库层面的兜底实际上帮了大忙——我测试时就碰到过并发情况下评委双击提交,如果没有这个唯一索引,就会出现重复评分,最终统计结果全乱。
视频表要单独说一句,video_url字段我存的是相对路径而不是完整URL。这样做的原因有两个:一是换域名或迁移服务器时不用改数据库,二是配合本地存储策略更灵活。后面如果接阿里云OSS或腾讯云COS,只需要在访问层做一层拼接。
2.3 评分权重在代码层还是数据库层:我的最终选择
关于评分权重,比如“动作完成度占30%”,我一开始把权重字段放进了scoring_criteria表里,每个维度有个weight字段。但用着用着发现问题:不同的评委可能有不同的打分偏好,有些比赛还会临时调整权重口径,每次改都要动数据库。
最终我把权重配置从数据库表里抽了出来,放到了配置文件里。ThinkPHP放在config/score.php,Laravel放在config/score.php,用数组维护权重映射。这样做的好处是改权重只需要改配置、清缓存、刷新页面,不需要迁移数据库。但缺点也很明显:如果系统要支持多场比赛同时进行且权重不同,配置文件方案就不够用了,需要回到数据库方案。
如果你只是做一个学校内部比赛,配置文件的方案完全够用,而且灵活度高。如果要做SaaS化多租户系统,那就老老实实把权重放数据库,并且每个比赛场次存一份快照——千万不要直接引用当前的权重配置,因为比赛结束三周后你改了权重,历史成绩就会跟着变,这是很隐蔽的Bug。
3. 双框架各自实现评分核心:从ThinkPHP到Laravel的代码迁移实战
3.1 ThinkPHP版本:用门面模式写出来的简洁控制器
ThinkPHP 8的写法比较直接,控制器里调用模型,模型里用查询构造器,整体链路短。
首先看评分提交的控制器方法:
<?php declare(strict_types=1); namespace app\controller; use think\Request; use think\facade\Db; use think\exception\ValidateException; class ScoreController { public function submit(Request $request) { $data = $request->post(); // 基础校验 if (!isset($data['video_id']) || !isset($data['judge_id']) || !isset($data['items'])) { return json(['code' => 1, 'msg' => '参数不完整']); } $videoId = (int)$data['video_id']; $judgeId = (int)$data['judge_id']; $items = $data['items']; // 格式: [['criterion_id' => 1, 'score' => 25], ...] // 校验该评委是否有权对该视频评分 $auth = $this->checkJudgeAccess($judgeId, $videoId); if (!$auth) { return json(['code' => 403, 'msg' => '您无权为该视频评分']); } // 事务提交评分 Db::startTrans(); try { foreach ($items as $item) { $criterionId = (int)$item['criterion_id']; $score = round((float)$item['score'], 2); // 检查分数是否超出维度上限 $criterion = Db::name('scoring_criteria')->find($criterionId); if (!$criterion || $score > $criterion['max_score'] || $score < 0) { throw new \Exception('评分超出范围'); } // 利用唯一索引 upsert 写入 Db::name('scores')->upsert([ 'video_id' => $videoId, 'judge_id' => $judgeId, 'criterion_id' => $criterionId, 'score' => $score, 'remark' => $item['remark'] ?? '', 'updated_at' => date('Y-m-d H:i:s') ], ['video_id', 'judge_id', 'criterion_id']); } Db::commit(); return json(['code' => 0, 'msg' => '评分成功']); } catch (\Exception $e) { Db::rollback(); return json(['code' => 1, 'msg' => '评分失败:' . $e->getMessage()]); } } }upsert这个方法是ThinkPHP 8新增的,底层是MySQL的ON DUPLICATE KEY UPDATE,配合之前那张表的联合唯一索引,完美解决了重复提交的问题。如果你用的ThinkPHP版本低于8,没有upsert,可以用Db::name('scores')->replace()或先where查询再决定update还是insert。
评委权限校验checkJudgeAccess的逻辑是这样的:首先判断users.role是否为judge,然后到这个比赛场次的评委配置表里查是否包含当前评委,最后再判断该视频是否属于这个比赛场次且状态为published。三层校验缺一不可,否则会出现评委跨场次打分或者比赛结束后还能打分的情况。
3.2 Laravel版本:表单验证、Eloquent关联和事件系统的组合
Laravel的实现思路比ThinkPHP更加“框架化”,每个环节都有专门的组件,代码虽然多,但职责非常清晰。
首先是表单验证:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class ScoreRequest extends FormRequest { public function authorize() { // 这里可以用 Policy 做更细粒度的权限控制 return $this->user()->role === 'judge'; } public function rules() { return [ 'video_id' => 'required|exists:videos,id', 'items' => 'required|array|min:1', 'items.*.criterion_id' => 'required|exists:scoring_criteria,id', 'items.*.score' => 'required|numeric|min:0|max:100', ]; } }Laravel的验证规则里,items.*.criterion_id这种“星号”批量验证语法非常方便,不用自己循环判断每个子项。max:100这里有个问题:不同维度的满分可能不同,有的维度满分30分,有的满分20分,统一的max:100并不能覆盖这个场景。所以我在控制器里又加了一道自定义验证,根据数据库里的max_score动态校验。
然后是Eloquent的关联设计:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Score extends Model { protected $fillable = [ 'video_id', 'judge_id', 'criterion_id', 'score', 'remark' ]; public function video(): BelongsTo { return $this->belongsTo(Video::class); } public function judge(): BelongsTo { return $this->belongsTo(User::class, 'judge_id'); } public function criterion(): BelongsTo { return $this->belongsTo(ScoringCriterion::class); } }在Laravel里,我利用了updateOrCreate方法来实现ThinkPHP中upsert的同等效果:
public function submit(ScoreRequest $request) { $validated = $request->validated(); DB::transaction(function () use ($validated, $request) { foreach ($validated['items'] as $item) { Score::updateOrCreate( [ 'video_id' => $validated['video_id'], 'judge_id' => $request->user()->id, 'criterion_id' => $item['criterion_id'], ], [ 'score' => round($item['score'], 2), 'remark' => $item['remark'] ?? null, ] ); } // 触发评分完成事件,用于通知管理员或自动生成统计 event(new ScoreSubmitted($request->user(), $validated['video_id'])); }); return response()->json(['code' => 0, 'msg' => '评分成功']); }这里用到了DB::transaction包裹整段逻辑,Laravel的事务闭包写法比ThinkPHP的startTrans/commit/rollback风格更加简洁,也更容易避免忘记commit导致的死锁问题。事件系统是Laravel的一个亮点,ScoreSubmitted事件可以挂监听器——比如记录日志、发送通知、刷新缓存统计等。ThinkPHP也有事件机制,但我个人觉得Laravel的事件体系更顺手。
3.3 两套实现的核心差异点对照
| 维度 | ThinkPHP实现 | Laravel实现 |
|---|---|---|
| 写入方式 | Db::name()->upsert() | updateOrCreate() |
| 事务控制 | 手动startTrans/commit | 闭包自动事务 |
| 验证机制 | 手动数组校验 | FormRequest自动化 |
| 权限处理 | 控制器内方法调用 | Policy/中间件 |
| 扩展能力 | 依赖注入需手动绑定 | 服务容器自动解析 |
并不是说Laravel一定更好。ThinkPHP胜在简单直接,学习成本低,PHP水平一般的同学也能快速改功能。Laravel的优雅建立在学习曲线上,如果你不熟悉门面、服务容器、依赖注入这些概念,一上来会有点懵。但从团队协作和长期维护的角度看,Laravel的规范性和代码可读性确实更好。
4. 全媒体资源接入:视频上传、转码和播放的实操细节
4.1 为什么不能直接让评委下载视频打分
第一次做这套系统时,我就偷懒直接让评委下载视频观看。结果第一天就炸了:几百个评委同时下载同一个大视频,服务器带宽被打满,前台选手上传视频也卡到超时。而且评委下载后在哪看、怎么看,完全不可控,根本没办法保证评分的公平性。
后来我改成在线播放方案,核心就是用<video>标签直接播放MP4文件。但这里有个前置条件是MP4的编码格式必须是H.264,否则浏览器直接黑屏或无声。很多选手用手机拍的视频是HEVC编码,在Chrome和Firefox上根本播不了,必须转码。
4.2 FFmpeg转码与处理队列的完整链路
我最终选择的方案是:上传原始视频 + 服务端FFmpeg转码为H.264/AAC编码的MP4 + 输出多个清晰度版本。
转码命令的核心参数:
ffmpeg -i input.mp4 \ -c:v libx264 -preset medium -crf 23 \ -c:a aac -b:a 128k \ -vf "scale=-2:720" \ -movflags +faststart \ output_720p.mp4逐个参数解释一下:-c:v libx264指定视频用H.264编码;-crf 23是质量参数,数值越小质量越高文件越大,23是体积和画质的平衡点;-vf "scale=-2:720"把视频高度缩放到720像素,-2用于保持宽高比的同时保证宽度是偶数(某些播放器对奇数宽度兼容性差);-movflags +faststart把MP4文件的元数据移动到文件头部,这样浏览器可以边下载边播放,不需要等待整个文件下载完成。
实际部署时,我没有让PHP进程直接调用FFmpeg,因为一个转码任务可能要跑几十秒甚至几分钟,HTTP请求早就超时了。我用的是Redis队列:PHP上传视频后把转码任务推到队列,后台Worker进程消费队列并执行FFmpeg,转码完成后再把结果状态更新回数据库。
ThinkPHP这边我用的是think-queue,Laravel那边直接用自带的Queue系统。业务逻辑一致但代码结构差别不小,这里用Laravel的写法举例:
<?php namespace App\Jobs; use App\Models\Video; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Bus\Queueable; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use FFMpeg\FFMpeg; class TranscodeVideo implements ShouldQueue { use Queueable; public $timeout = 600; // 10分钟超时 protected Video $video; public function __construct(Video $video) { $this->video = $video; } public function handle(): void { $inputPath = Storage::disk('local')->path($this->video->original_path); $outputPath = Storage::disk('local')->path('transcoded/' . $this->video->id . '_720p.mp4'); $ffmpeg = FFMpeg::create(); $ffmpeg->open($inputPath) ->filters() ->resize(new \FFMpeg\Coordinate\Dimension(1280, 720)) ->synchronize(); $ffmpeg->save(new \FFMpeg\Format\Video\X264('aac'), $outputPath); // 更新视频状态 $this->video->transcoded_path = 'transcoded/' . $this->video->id . '_720p.mp4'; $this->video->status = 'ready'; $this->video->save(); // 清理原始文件 Storage::disk('local')->delete($this->video->original_path); } }这里的PHP-FFMpeg库是PHP操作FFmpeg的封装,实际上底层还是调用命令行工具。比直接exec()的好处是API更友好、错误处理更规范。
4.3 视频播放时的一个大坑:防盗链和跨域
视频转码完成后,播放页面直接用HTML5播放器加载。但我在测试时发现一个诡异的问题:在本地环境播放正常,部署到服务器上后,评委反馈视频加载不出来,浏览器控制台报了一堆CORS错误。
排查了半天,原因是对视频文件的请求跨域了。我的视频存储在static.example.com上,页面在www.example.com上,这时候需要在静态资源服务器上配置跨域头:
location ~* \.(mp4|webm)$ { add_header Access-Control-Allow-Origin *; add_header Cache-Control "public, max-age=86400"; }配置完之后,播放就正常了。第二个坑是360浏览器和某些国产浏览器的兼容性,普通HTML5播放器在旧内核下会出现只有声音没画面或者直接白屏的问题。稳妥的做法是在播放器层面做兼容,用video.js或DPlayer这类成熟播放器组件替代原生<video>标签。
5. 评委端与统计端:分数汇总、榜单实时排行和结果导出
5.1 单维度平均分计算与去最高最低分策略
评分统计是整个系统最有技术含量的环节。一个选手的视频有30个评委打分,每个评委又给多个维度打分。我们需要计算每个维度的平均分、总分、名次。但如果不做任何处理,一个评委故意打0分或者100分,就会严重拉低或拉高选手的最终成绩,这是评分系统里很常见的公平性问题。
常用做法是“去掉最高分和最低分再取平均”。但这里的细节在于:是对每个维度都分开去最高最低,还是对每个评委的总分去最高最低?这两种算法结果是不一样的。
我选择的方案是每个维度独立去极值后再加总。因为评委可能某个维度打得很高、另一个维度打得很低,如果按总分抠掉某个评委,丢失的信息更多。每个维度独立去除极值虽然计算量大一点,但对每一个维度的合理评价保留得更完整。
核心统计SQL如下:
SELECT criterion_id, ROUND((SUM(score) - MAX(score) - MIN(score)) / (COUNT(score) - 2), 2) AS avg_score FROM scores WHERE video_id = ? GROUP BY criterion_id HAVING COUNT(score) >= 3;这里有个前提:如果评分人数少于3人,就去不了极值,直接取普通平均。HAVING COUNT(score) >= 3这个条件就是干这个的。如果评委人数不够,系统应该在配置上提醒管理员补充评委,否则统计结果会失真。
算完单个维度的平均分后,再乘上配置文件里的权重系数,加总得到最终得分:
public function calculateFinalScore($videoId) { $criteriaScores = DB::table('scores') ->selectRaw('criterion_id, ROUND((SUM(score) - MAX(score) - MIN(score)) / (COUNT(score) - 2), 2) as avg_score') ->where('video_id', $videoId) ->groupBy('criterion_id') ->havingRaw('COUNT(score) >= 3') ->get(); $weights = config('score.weights'); $finalScore = 0.0; foreach ($criteriaScores as $criteriaScore) { $weight = $weights[$criteriaScore->criterion_id] ?? 0; $finalScore += $criteriaScore->avg_score * $weight; } return round($finalScore, 2); }这里$weights数组的键是criterion_id,值是该维度的权重小数,比如0.3、0.3、0.2、0.2。所有权重加起来必须等于1,否则最终分数会有偏差。我加过一道启动检查,在系统初始化时自动校验权重之和是否为1,误差超过0.001就报警。这么做的原因是:有一次改配置文件时手滑把某个权重改成了0.25,直到比赛结束才被发现,结果所有排名全错了,那是惨痛的教训。
5.2 实时排行榜:Redis缓存分数而不是直接查库
比赛过程中,现场大屏需要实时展示各队伍排名。数据不需要精确到小数点的实时,但要求秒级更新。如果每秒钟都执行一次上面的聚合SQL,数据库压力会很大。我的方案是:评分提交后,把受影响的video_id塞入一个待更新集合,由定时任务每10秒批量计算这些视频的最新分数,并写入Redis的有序集合(ZSET)。
Laravel端用Redis的ZADD命令,key是contest:leaderboard,member是video_id,score是最终得分。读取排行榜只需要一条命令:
$leaderboard = Redis::zrevrange('contest:leaderboard', 0, 9, 'WITHSCORES');这样排行接口的响应时间在2毫秒以内,压力测试下1000并发毫无压力。ThinkPHP端我用了think\facade\Cache,但Redis的ZSET操作在ThinkPHP里的封装没有Laravel那么方便,需要调用底层的handler处理,也不复杂。
统计延迟10秒的问题在于:评委刚提交完分数,立刻看排行榜可能发现名次没变,会有“是不是没提交成功”的误会。我在前端页面加了“数据约10秒更新”的提示,实测下来评委都能接受。如果要求实时性更强,可以把定时任务的间隔缩短到2秒,代价是数据库压力稍高一些。
5.3 导出Excel成绩单的实现与编码坑
比赛结束后,管理员要导出所有队伍的成绩单。PHP导Excel最常用的方案是PhpSpreadsheet(Laravel可以用maatwebsite/excel包,底层也是它)。
最坑的地方是中文文件名。直接输出中文文件名会乱码,必须做URL编码转换或转成UTF-8的BOM格式:
// 错误写法,中文文件名会乱码 return response()->download($filePath, '成绩单.xlsx'); // 正确写法 return response()->download($filePath, rawurlencode('成绩单.xlsx') . '.xlsx');rawurlencode处理后的中文文件名,在浏览器里能正确显示为“成绩单.xlsx”,下载到本地也正常。另一个坑是Excel单元格里如果填入数字格式的号码,会被科学计数法显示,比如队伍编号“10001”变成了“10001.0”。解决办法是把这类字段拆分为字符串类型再写入:
$sheet->setCellValueExplicit('A1', '10001', \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);6. 权限体系与防作弊机制:哪些设计是我反复调整过的
6.1 三种角色和细粒度权限控制
这个系统的角色分管理员、评委、选手(或参赛队)三类。管理员拥有全部权限,包括配置比赛、设置评分维度、管理评委、导出成绩。评委只能看到分配给自己的评分任务,能评分但不能看到别人的评分结果。选手只能看自己的视频和最终分数,不能看其他选手的分数。
ThinkPHP这边我用了中间件做角色判断,Laravel用了官方的Policy机制。两种框架都能实现,但Policy机制更优雅:在app/Policies/ScorePolicy.php里定义view、create、update等方法,然后在控制器里调用$this->authorize(),可读性比Tp的中间件好很多。
6.2 防重复评分和防分数篡改的实战策略
数据库的唯一索引是第一道防线,应用层校验是第二道防线,第三道防线是日志审计。我在scores表上创建了联合唯一索引,应用层提交时也会先查询是否已评分,但并发情况下查询结果可能不准确,所以真正兜底的是数据库索引。每次评分写入后,我还会往score_logs表里记录一条审计日志,包含评委ID、视频ID、操作时间和完整的评分数据。万一出现争议,翻日志就能定位到是谁在什么时候打的分。
防分数篡改的另一个关键点是防止评委提交超额分数。比如“动作完成度”上限30分,评委直接传35分,系统要弹回错误。这个校验我在提交接口里做了,但更保险的做法是前端下拉框直接限定可选分数范围,后端再校验一次——前端是为了用户体验,后端才是安全底线。绝对不要相信前端传来的任何数据。
6.3 一个隐藏的安全漏洞:任意ID遍历
第一版上线后,我一个做安全测试的朋友提醒我:系统存在IDOR漏洞。什么意思呢?比赛还没结束时,选手直接访问/video/1/score这个URL,就可能看到其他选手的临时评分情况,因为很多接口通过URL参数直接定位了资源,却没有验证当前登录用户是否为该视频的评委。
修复方案是在所有查询视频和评分数据的接口中,强制校验当前用户与目标资源的关联关系:
// Laravel 中的资源授权 public function showScore(Video $video) { $this->authorize('viewScore', $video); // Policy 中校验当前用户是否为该场次评委 return response()->json($video->scores); }虽然题目只是学校比赛,但系统如果被学生发现了这个漏洞,很容易造成严重的公平性质疑。这一点必须重视。
7. 性能优化:大量评委同时评分时如何保持流畅
7.1 Nginx+PHP-FPM的常规配置调优
PHP应用在高并发下首选Nginx+PHP-FPM架构。我的配置思路是:
- PHP-FPM的
pm.max_children设为服务器内存除以单个PHP进程平均内存的商。比如8GB内存,单个PHP进程约30MB,max_children设为80比较稳妥。 - Nginx开启Gzip压缩,对JSON响应体收益最高,评分接口的JSON响应可以从120KB压到20KB,速度快6倍。
- 静态资源(JS、CSS、图片)设置长缓存,
expires 7d,但注意版本号更新时要改文件名,否则浏览器不刷新。
7.2 数据库索引优化与SQL慢查询排查
评分系统的数据库高频查询集中在scores表上。除了之前说的联合唯一索引,我还加了一个video_id + criterion_id的联合索引,用来加速成绩汇总统计。videos表的状态字段status也建了索引,因为列表页经常按状态过滤。
如果遇到慢查询,可以先开启MySQL慢查询日志:
slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1然后配合EXPLAIN分析SQL执行计划。我遇到过最典型的性能问题:在scores表数据量达到10万级别后,按video_id统计成绩的SQL执行时间从20毫秒涨到了800毫秒。EXPLAIN发现执行计划里出现了Using temporary; Using filesort,原因是没有一个复合索引能同时覆盖video_id的过滤和GROUP BY criterion_id的排序。解决方案就是新建了video_id + criterion_id复合索引,执行时间直接降到30毫秒。
7.3 写多读少的场景:MySQL还是Redis
评分系统是典型的“写多读少”:评委提交评分频繁,但排行榜刷新是周期性的。如果所有读写都打到MySQL,高峰期容易扛不住。我的实际方案是“写MySQL,读Redis”:每次评分提交直接写MySQL,同时异步把新分数同步到Redis ZSET,排行榜查询完全走Redis。这样MySQL只承担写入和持久化,读压力分流到Redis。
如果不用Redis,还有一种简化方案:把排行榜做成一张单独的rankings表,定期用定时任务汇总写入。比如每分钟更新一次排行榜,查询时直接SELECT * FROM rankings ORDER BY final_score DESC,大部分情况下也能满足需求,前提是比赛规模不大且实时性要求没那么高。
8. ThinkPHP与Laravel双框架落地过程中的血泪经验
这部分我想集中聊几个两种框架实现同一业务时出现的差异坑,以及我在实际对接前端页面过程中积累的一些经验。
8.1 数据库迁移与数据结构同步
双框架共用同一个数据库时,最大的问题是数据结构变更如何同步。ThinkPHP没有内置迁移工具,我都是手动改SQL;Laravel有强大的php artisan migrate。为了保持一致,我最终以Laravel的迁移文件为“准”,每写一个迁移文件,再在ThinkPHP的database目录下放一份等价的.sql文件,开发时两边同步执行。这个习惯帮我避免过至少三次因为字段不同步导致的线上接口报错。
8.2 表单验证风格差异与统一异常返回
前端对接时最头疼的是两个框架返回的验证错误格式不一致。ThinkPHP默认返回json错误信息里包含message字段,Laravel验证错误默认是422响应,结构是errors对象包含字段数组。我在两个框架的外层都套了一个统一的响应包装器:
{ "code": 0, "msg": "success", "data": {} }错误时:
{ "code": 422, "msg": "评分不能超过最大值30分", "data": {} }实际开发过程中,统一响应格式能让前端开发同学的对接效率提升很多,否则两边各写一套解析逻辑非常容易出问题。
8.3 环境配置与部署流程的差异
ThinkPHP的配置全部放在config目录下的PHP文件中,部署时直接修改这些文件即可。Laravel使用.env文件,部署时通过设置环境变量覆盖配置,更推荐用php artisan config:cache把配置缓存起来加速。
但部署时有一个容易踩的坑:Laravel在config:cache之后,如果再修改.env文件,不会立即生效。很多新手改完.env发现配置没变,就是因为忘了清缓存:
php artisan config:clearThinkPHP没这个问题,改配置即改即生效,但也意味着每次请求都要加载配置,性能上略逊于Laravel的配置缓存。
8.4 我做过的那些后悔操作和最终留下的建议
- 后悔没有提前做备份机制。有一次线上调试评分权重,改错了配置文件,导致已提交的所有分数都被错误权重重新算了一遍。后来加了每日自动备份和定时任务做分数快照,才彻底安心。
- 后悔没有一开始就用队列处理转码。第一版在HTTP请求里同步执行FFmpeg,直接拖垮了PHP-FPM进程,评委反馈页面转圈一分钟。改成队列后瞬间流畅了。
- 后悔没有在开发环境就开启慢查询日志。很多性能问题在生产环境才暴露,排查起来成本翻倍。我后来在本地开发环境就开启了MySQL慢查询日志,SQL效率问题在开发阶段就能发现。
如果你也要做类似的系统,我建议:先用ThinkPHP快速上线MVP版本,跑通流程后再考虑是否要用Laravel重构;不管选哪个框架,数据库设计、权限防作弊、队列处理这三点一定要提前想清楚,它们决定了系统的上限。
我个人在实际使用中的最大体会是:框架只是工具,能解决问题、方便维护就是好技术。双框架并行开发表面上看工作量翻倍,但实际上让我对PHP生态的理解提升了一个档次。如果你也在纠结选型,不妨像我一样,用同一个业务需求做一次双框架对比开发,收获会远超预期。