Progress Bar 刷新频率优化

解读

国内高并发业务(电商秒杀、直播带货、文件批量导入)里,前端 Progress Bar 的“卡、跳、回退”是面试高频痛点。
面试官不是让你写个 setInterval,而是考察:

  1. 后端 PHP 能否在「长耗时任务」中持续、低成本地吐出进度;
  2. 进度通道选什么(轮询、SSE、WebSocket、消息队列);
  3. 如何降低 QPS 压力与 Redis 内存碎片;
  4. 怎样保证进度精度与实时性的平衡,避免 1 ms 刷一次把 4C8G 机器打满。
    一句话:让进度条“看着顺”的同时,把 CPU、网络、Redis 的利用率压到最低。

知识点

  1. 进度数据模型:total、done、percent、eta、step_cost_ms。
  2. 存储选型:
    • APCu:本机共享内存,<1 µs 读写,无网络 IO,适合单机 CLI 任务;
    • Redis String + expire:分布式,但 1 万次 GET 网络 RTT 累加 40 ms;
    • Redis Hash + HSET:减少 key 数量,降低过期碎片;
    • Stream(XADD/XREAD): 天然按消费者组削峰,可回放。
  3. 刷新策略:
    • 指数退避:前 10% 每 200 ms,中段 500 ms,尾段 1 s;
    • 步长阈值:percent 变化 ≥2% 或 eta 变化 ≥3 s 才推;
    • 合并写:任务进程每 100 ms 更新一次内存,每 1 s 批量写 Redis;
    • 压缩通道:SSE 用 gzip,WebSocket 用 per-message-deflate,降低 70% 流量。
  4. 并发控制:
    • 用 Redis SETNX 做任务锁,防止用户重复提交;
    • 利用 pcntl_fork + pcntl_wait 实现多进程消费,父进程统一汇报进度;
    • 在 FPM 场景下用 fastcgi_finish_request() 提前释放 worker,再用异步队列更新进度。
  5. 精度校准:
    • 采用滑动窗口算法,记录最近 10 个 step 耗时,动态修正 eta;
    • 对于 MySQL 批量写入,用 affected_rows 而非循环计数,避免“假进度”。
  6. 安全与优雅降级:
    • 进度 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 万长连接。

拓展思考

  1. 如果业务跨机房,Redis RTT 从 0.5 ms 涨到 30 ms,如何进一步降低?
    可在本地 Agent(Go 或 Rust)做一层聚合,Agent 每 100 ms 批量读 Redis 并推 WebSocket,PHP 只需写本机 APCu,网络 RTT 归零。
  2. 当任务由 200 台机器分布式消费,进度汇总成为瓶颈,怎么办?
    引入 Redis Stream,按消费者组 ID 分片,XREADGROUP 拉回各分片 done 数,PHP 聚合计算总 percent,实现“分布式 MapReduce 进度”。
  3. 面对移动端弱网,SSE 断线重连会瞬间重放全部事件,如何省流?
    在事件里带 Last-Event-ID(即 done 偏移量),前端重连时带 ?last_id=N,PHP 用 XRANGE 只取 >N 的事件,实现断点续传。
  4. 如果老板要求“进度条必须像迅雷一样平滑到 0.1%”,但后端是批量 SQL 无法细粒度计数?
    采用“虚拟进度”算法:先按时间函数缓增到 95%,等真实完成后瞬间补齐,既让用户“爽”,又避免狂刷 Redis。