一、背景
在做一个合伙人分销系统时,有一个很常见的功能:推广海报下载------用户点一下"下载推广海报"按钮,页面会生成一张带专属二维码、带推荐人手机号水印的海报图,方便用户长按保存转发。
上线后收到反馈:手机打开这个页面,会先卡顿一段时间,然后图片才"唰"地一下出现。本文记录一下这次排查和优化的完整过程,思路对做类似"动态合成图片/海报"功能的同学应该有参考价值。
二、排查:先搞清楚图是怎么"画"出来的
拿到问题第一反应是:是不是前端用了 canvas / html2canvas 之类的方案,主线程被阻塞了?于是先撸了一遍代码,结论出乎意料:
这张海报完全是服务端用 PHP 的 GD 库实时合成的,跟前端 canvas 没有任何关系。
流程大致是这样:
用户点击"下载推广海报"
│
▼
window.location.href 整页跳转到 download_partner_qrcode.php
│
▼
返回一个"壳子"页面,里面塞了一个 <img src="download_partner_qrcode.php?download=1">
│
▼
浏览器请求这个 img src,真正的合成逻辑在这次请求里同步执行:
1. imagecreatefromjpeg() 解码 1535×2628 的 JPG 海报模板
2. file_get_contents() 同步请求第三方二维码生成服务
3. imagecopyresampled() 把二维码贴到模板上
4. imagettftext() 逐字绘制"推荐人:xxx"文字(还要做仿斜体效果)
5. imagejpeg() 用 quality=95 重新编码整张图,流式输出
问题一下就清晰了,卡顿的锅主要在这三个地方:
1. 完全没有缓存,每次访问都重新生成一遍
响应头里赫然写着:
php
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
也就是说,哪怕同一个用户的推广码、手机号从来没变过,每刷新一次页面,服务器都要老老实实把上面 5 个步骤重新跑一遍。CPU 图像处理开销 + 网络请求开销,全部白白浪费。
2. 同步依赖第三方接口,还给了 30 秒超时
php
$context = stream_context_create([
'http' => ['timeout' => 30, ...]
]);
$qrcodeData = @file_get_contents($qrcodeUrl, false, $context);
二维码是实时调用第三方接口 http://barcode.tczss.com/qrcode.php 生成的,这是一个同步阻塞调用,卡在这里整个响应就出不来。一旦第三方服务慢一点、抖一下,用户能等足足 30 秒------这就是"卡顿"最直接的元凶。而且没有任何本地兜底方案。
3. 前端没有任何加载态
整页跳转 + <img> 裸标签,图片没加载出来之前用户看到的就是一片空白,跟"卡死了"没什么区别,体验雪上加霜。
三、优化方案
问题定位清楚之后,方案其实很直接,没有伤筋动骨地重写架构,三板斧:
1. 加一层磁盘缓存(收益最大的一步)
推广码、手机号这些内容基本是稳定不变的,完全没必要每次都重新合成。加一个基于文件的缓存层:
php
$cacheDir = __DIR__ . '/../cache/posters/';
$cacheKey = md5('partner_' . $agentId . '_' . $agent['promo_code'] . '_' . $maskedMobile);
$cacheFile = $cacheDir . $cacheKey . '.jpg';
// 命中缓存,直接秒回,跳过整个 GD 合成流程
if (is_file($cacheFile)) {
header('Content-Type: image/jpeg');
header('Cache-Control: public, max-age=86400');
readfile($cacheFile);
exit;
}
// ... 未命中才走原来的 GD 合成逻辑 ...
// 合成完成后写入缓存,同时输出给浏览器
imagejpeg($template, $cacheFile, 95);
imagedestroy($template);
readfile($cacheFile);
缓存 key 用 agent_id + promo_code + 脱敏手机号 做 md5,既保证不同合伙人互不冲突,又保证一旦手机号这类内容变化会自动生成新文件、不会用错缓存。
效果:第一次访问该走的流程一步不少,但从第二次开始直接读文件秒回,GD 解码、第三方网络请求、逐字画图全部省掉。
2. 把超时时间从 30 秒砍到 6 秒
php
'timeout' => 6, // 原来是 30
正常情况下二维码接口秒级响应,30 秒的超时设置只是让"最坏情况"变得无比漫长。缩短超时之后,即使第三方服务真出问题,用户等待的上限也可控很多------而且加了缓存之后,这个慢请求本来就只会在首次生成时触发一次,影响面进一步收窄。
3. 前端补一个骨架屏加载态
用最原生的方式,不引入任何 JS 库:
html
<div class="image-preview-wrapper">
<div class="image-placeholder" id="imagePlaceholder">
<div class="image-loading-text">海报生成中...</div>
</div>
<img src="download_partner_qrcode.php?download=1" alt="合伙人推广码"
onload="this.classList.add('loaded'); document.getElementById('imagePlaceholder').style.display='none';"
onerror="document.getElementById('imagePlaceholder').innerHTML = '<div class=\'image-loading-text\'>生成失败,请返回重试</div>';">
</div>
配合一个带流光动画的 CSS 骨架屏(padding-top 按图片真实宽高比撑开容器,避免布局跳动),图片加载完自动淡入显示,失败也有明确的文案提示,而不是一个裸露的浏览器默认破图标。
css
.image-placeholder {
width: 100%;
padding-top: 171.2%; /* 按模板图 1535:2628 的宽高比撑开占位 */
background: #f2f2f2;
position: relative;
overflow: hidden;
}
.image-placeholder::after {
content: '';
position: absolute;
top: 0; left: -60%; width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
animation: placeholder-shine 1.4s infinite;
}
@keyframes placeholder-shine {
0% { left: -60%; }
100% { left: 120%; }
}
四、优化效果
| 优化前 | 优化后 | |
|---|---|---|
| 首次访问 | 完整走一遍 GD 合成 + 第三方网络请求 | 一样(缓存冷启动无法避免) |
| 二次及以后访问 | 依然完整重跑一遍 | 直接读本地缓存文件,跳过全部图像处理和网络请求 |
| 第三方接口异常 | 最长可能卡 30 秒 | 最长卡 6 秒,且只影响首次生成 |
| 加载体验 | 白屏 → 突然出图 | 骨架屏动画 → 平滑出图 / 明确的失败提示 |
五、小结
这次优化没有引入任何新的技术栈或复杂架构,核心思路就是**"能不算就不算,实在要等也让用户知道在等什么"**:
- 缓存永远是性价比最高的优化手段------尤其是这种输入基本不变、每次却都重新计算的场景,加一层文件缓存往往比优化算法本身收益大得多;
- 同步阻塞的第三方依赖一定要设置合理超时,别让"最坏情况"没有上限;
- 哪怕后端优化还没生效,前端一个恰当的加载态也能显著改善用户的主观体感------用户能接受"等待",但接受不了"卡死"。
如果你的项目里也有类似"动态生成图片/海报/报表"的功能,不妨先问自己一句:这个结果,是不是每次都在重复计算同一个答案?