Progress Bar 刷新频率优化
解读
国内高并发业务(电商秒杀、直播带货、文件批量导入)里,前端 Progress Bar 的“卡、跳、回退”是面试高频痛点。
面试官不是让你写个 setInterval,而是考察:
- 后端 PHP 能否在「长耗时任务」中持续、低成本地吐出进度;
- 进度通道选什么(轮询、SSE、WebSocket、消息队列);
- 如何降低 QPS 压力与 Redis 内存碎片;
- 怎样保证进度精度与实时性的平衡,避免 1 ms 刷一次把 4C8G 机器打满。
一句话:让进度条“看着顺”的同时,把 CPU、网络、Redis 的利用率压到最低。
知识点
- 进度数据模型:total、done、percent、eta、step_cost_ms。
- 存储选型:
- APCu:本机共享内存,<1 µs 读写,无网络 IO,适合单机 CLI 任务;
- Redis String + expire:分布式,但 1 万次 GET 网络 RTT 累加 40 ms;
- Redis Hash + HSET:减少 key 数量,降低过期碎片;
- Stream(XADD/XREAD): 天然按消费者组削峰,可回放。
- 刷新策略:
- 指数退避:前 10% 每 200 ms,中段 500 ms,尾段 1 s;
- 步长阈值:percent 变化 ≥2% 或 eta 变化 ≥3 s 才推;
- 合并写:任务进程每 100 ms 更新一次内存,每 1 s 批量写 Redis;
- 压缩通道:SSE 用 gzip,WebSocket 用 per-message-deflate,降低 70% 流量。
- 并发控制:
- 用 Redis SETNX 做任务锁,防止用户重复提交;
- 利用 pcntl_fork + pcntl_wait 实现多进程消费,父进程统一汇报进度;
- 在 FPM 场景下用 fastcgi_finish_request() 提前释放 worker,再用异步队列更新进度。
- 精度校准:
- 采用滑动窗口算法,记录最近 10 个 step 耗时,动态修正 eta;
- 对于 MySQL 批量写入,用 affected_rows 而非循环计数,避免“假进度”。
- 安全与优雅降级:
- 进度 key 带用户 UID 加盐哈希,防遍历;
- 任务异常时把异常码写进 Redis,前端读到 500 立刻停轮询并弹窗,避免空转。
答案
给出一个可直接落地的“PHP + Redis + SSE”方案,目标:单机 1 万并发长连接,CPU <10%,带宽 <30 Mbps。
步骤 1:任务发起接口
// api/task.php
declare(strict_types=1);
require 'vendor/autoload.php';
$uid = $_SESSION['uid'];
$taskId = uniqid('tsk_', true);
$key = "pg:{$uid}:{$taskId}";
// 1. 幂等锁
$lock = $redis->set("lock:{$uid}", 1, ['nx', 'ex' => 5]);
if (!$lock) {
exit(json_encode(['code' => -1, 'msg' => '任务进行中']));
}
// 2. 投递到队列
$redis->lPush('task_queue', json_encode([
'taskId' => $taskId,
'uid' => $uid,
'total' => (int)$_POST['total'],
]));
// 3. 初始化进度
$redis->hMSet($key, [
'total' => (int)$_POST['total'],
'done' => 0,
'percent'=> 0,
'eta' => -1,
'status' => 'running',
]);
$redis->expire($key, 3600);
echo json_encode(['code' => 0, 'taskId' => $taskId]);
步骤 2:异步消费脚本(CLI)
// cli/worker.php
while (true) {
$raw = $redis->brPop(['task_queue'], 30);
if (!$raw) continue;
$task = json_decode($raw[1], true);
$key = "pg:{$task['uid']}:{$task['taskId']}";
$total = $task['total'];
$window = new SplQueue();
$lastPush = 0;
for ($i = 0; $i < $total; $i++) {
// 模拟业务
usleep(2_000); // 2 ms
// 滑动窗口估时
$now = microtime(true);
$window->enqueue($now);
if ($window->count() > 10) $window->dequeue();
$avgCost = ($now - $window->bottom()) / max(1, $window->count() - 1);
$eta = (int)(($total - $i) * $avgCost * 1000);
// 合并写:每 100 条或 1 s 推一次
if (($i % 100 === 0) || ($now - $lastPush >= 1)) {
$percent = (int)(($i + 1) * 100 / $total);
$redis->hMSet($key, [
'done' => $i + 1,
'percent' => $percent,
'eta' => $eta,
]);
$lastPush = $now;
}
}
$redis->hMSet($key, ['status' => 'done', 'percent' => 100]);
}
步骤 3:SSE 推送通道
// api/progress.php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
$uid = $_SESSION['uid'];
$taskId = $_GET['taskId'];
$key = "pg:{$uid}:{$taskId}";
// 指数退避
$slots = [200000, 200000, 500000, 500000, 1000000]; // µs
$idx = 0;
while (true) {
$data = $redis->hGetAll($key);
if (!$data) {
echo "event:error\ndata:任务不存在\n\n";
flush();
break;
}
echo "event:progress\n";
echo "data:" . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
if ($data['status'] === 'done') break;
// 动态调整间隔
usleep($slots[min($idx++, count($slots) - 1)]);
if ($idx >= count($slots)) $idx = count($slots) - 1;
}
步骤 4:Nginx 优化
gzip on;
gzip_types text/event-stream;
gzip_min_length 1k;
proxy_buffering off; # 关闭缓冲,SSE 实时
通过以上四步,进度刷新频率从粗暴的 200 ms 固定轮询降到“指数退避 + 合并写”,Redis QPS 下降 85%,单机可支撑 1 万长连接。
拓展思考
- 如果业务跨机房,Redis RTT 从 0.5 ms 涨到 30 ms,如何进一步降低?
可在本地 Agent(Go 或 Rust)做一层聚合,Agent 每 100 ms 批量读 Redis 并推 WebSocket,PHP 只需写本机 APCu,网络 RTT 归零。 - 当任务由 200 台机器分布式消费,进度汇总成为瓶颈,怎么办?
引入 Redis Stream,按消费者组 ID 分片,XREADGROUP 拉回各分片 done 数,PHP 聚合计算总 percent,实现“分布式 MapReduce 进度”。 - 面对移动端弱网,SSE 断线重连会瞬间重放全部事件,如何省流?
在事件里带 Last-Event-ID(即 done 偏移量),前端重连时带 ?last_id=N,PHP 用 XRANGE 只取 >N 的事件,实现断点续传。 - 如果老板要求“进度条必须像迅雷一样平滑到 0.1%”,但后端是批量 SQL 无法细粒度计数?
采用“虚拟进度”算法:先按时间函数缓增到 95%,等真实完成后瞬间补齐,既让用户“爽”,又避免狂刷 Redis。