Streaming 模式背压处理

解读

国内高并发业务(直播、电商大促、IoT 上报)普遍采用“流式接口”降低内存峰值,但 PHP-FPM 的“请求-响应”生命周期短、进程池固定,一旦上游生产速率 > 下游消费速率,数据会在内存、网卡缓冲区、Redis 队列等环节堆积,最终触发 502/504 或 OOM。面试官问“背压”不是考 Kafka API,而是看候选人能否在 PHP 语境里给出“感知堆积→反向施压→保障可用”的完整闭环,兼顾 FPM 与 CLI 双场景。

知识点

  1. 背压(Back-Pressure)本质:让“上游感知下游容量”,通过阻塞、降速、丢弃、扩容四种手段把系统拉回稳态。
  2. PHP 流封装层:stream_set_chunk_size / stream_set_read_buffer 可控制单次内核拷贝量;配合 feof 逐块消费,避免一次性读入大文件。
  3. 非阻塞与 select:stream_socket_client 加 STREAM_CLIENT_ASYNC_CONNECT,再用 stream_select 实现事件驱动,单进程可管理上千慢连接,天然背压——socket 发送缓冲区满时 select 返回不可写,上游自动降速。
  4. 协程方案:Swoole 4.x 的 Coroutine\Channel 容量固定,push 超时即背压;Swoole 的 Hook 能把 PHP 原生 stream 自动替换成协程调度,FPM 代码几乎零改造即可跑在协程调度器里。
  5. 队列分级:本地内存 RingBuffer(SplQueue)→Redis List→Kafka,每一级都设置 max-length 或 ttl,超限时丢弃老数据并写日志,实现“部分有损但系统不崩”。
  6. 指标与观测:php-fpm.status 的 listen queue、Swoole 的 coroutine_stats、Prometheus 的 gauge_coroutine_num,当队列长度 > 阈值 80% 触发告警,自动扩容 Pod 或降级业务。
  7. 反向施压策略:
    • 阻塞式:channel->pop() 无数据时挂起协程,CPU 0 消耗;
    • 令牌桶:使用 Swoole\Timer 每 10ms 放 50 个令牌,拿不到令牌的请求直接返回 429;
    • 批量刷盘:MySQL 插入用 INSERT DELAYED 或批量 REPLACE,当 binlog 延迟 >1s 时把 batchSize 减半。

答案

“在 PHP 里做背压,我会按运行模式拆两条链路:
一、FPM 场景:

  1. 设置 client_body_buffer_size 8k,让 Nginx 在接收上传流时先缓冲到磁盘,避免 PHP 进程被大文件撑爆;
  2. 在 PHP 端用 fopen('php://input', 'rb') 按 64 KB 块读取,读完一块立刻 fwrite 到临时文件,并用 pcntl_alarm(5) 设置读超时,防止慢连接占用;
  3. 把临时文件路径投递到 Redis 的 LPush,列表长度由 LLEN 实时监控,超过 5000 就返回 503 并带 Retry-After,反向施压客户端;
  4. 后台 CLI 消费者用 popen('php consumer.php') 多进程消费,进程池大小 = CPU*2,当 Redis 长度持续 >80% 时通过 Kubernetes HPA 水平扩容 Consumer Pod。

二、长连接场景(Swoole 协程):

  1. 建立 Coroutine\Server,每连接自动创建 Coroutine\Channel(100) 做消息队列;
  2. 生产者 go(function() use (chan) { while (true) { chan->push(data,0.1);if(data, 0.1); if (chan->isFull()) { $fd->send('busy'); break; } } });
  3. 消费者 go(function() use (chan) { while (true) { task = chan>pop();batchInsert(chan->pop(); batchInsert(task); } });
  4. 通过 channel->length() 暴露到 /metrics,Prometheus 采集后配置告警:当 length >90 持续 30s,自动触发 Swoole\Process::kill($masterPid, SIGUSR1) 做平滑重启,释放内存泄漏。

这样既能利用 PHP 原生流控,又能借助协程/队列把背压传递到最前端,保证大促 20k QPS 下内存峰值 <512 MB,P99 延迟稳定在 120 ms 以内。”

拓展思考

  1. 如果业务要求“零丢数据”,可把 Redis 队列换成 Kafka,并启用 enable.idempotence=true;PHP 生产者用 longlang/php-kafka 的 produceAsync,当 broker 返回 RECORD_TOO_LARGE 或 TIMEOUT 时主动降速,实现端到端背压。
  2. 在 Serverless 场景(B 云函数 SCF),冷启动耗时 300 ms,不适合常驻协程,可把流式数据直接写对象存储 COS,通过 COS 触发器拉起函数消费;函数内存 512 MB,单并发处理 50 MB 文件,利用云厂商的“并发实例上限”天然做背压,代码里无需再维护队列。
  3. 未来 PHP 8.4 引入的 Fiber(协程原语)与 Revolt 事件循环组合,可脱离 Swoole 做纯用户态调度,届时背压模型可抽象成 Fiber\Suspension + SplQueue,实现“零扩展依赖”的流控框架,值得提前预研。