各位大拿,各位在代码海洋里扑腾的码农兄弟们,大家好!
今天咱们不聊那些虚头巴脑的设计模式,也不搞什么架构选型的宏观辩论。咱们聊点扎心的——内存。
特别是当你的 PHP 代码跑在容器里,还要去干那种“海量采集任务”的时候。这就像是你让一个只有两条腿的小蚂蚁,背着整个超市去爬喜马拉雅山。结果是什么?物理宕机。
那种感觉,就像你半夜两点正准备下班,结果物理机“砰”的一声蓝屏,不仅没下班,连服务器都下线了。运维大哥背着那袋沉重的土豆(物理机)在机房里跑,手里的保温杯都摔瘪了。
别慌,今天我们就来聊聊,怎么在 PHP 容器化的环境里,给内存这块顽石做个“手术”,既能干完活,又不会把物理机送走。
第一部分:PHP 的内存账本,到底是谁在花钱?
很多同学觉得,PHP 不就是脚本吗?跑完就死,哪来的内存问题?错!大错特错!
PHP 的内存模型虽然不像 C++ 那么底层裸奔,但它也有自己的“挥霍无度”之处。尤其是在容器化环境下,你以为你只占用了 128MB,其实系统在背后默默帮你记了一笔巨款。
1. 基础开销:PHP-FPM 和守护进程的脂肪
哪怕你写了一个空的 echo "Hello World";,PHP 也会启动一个进程。
- 进程栈:每个进程启动都需要分配栈空间。
- PHP-FPM:如果你用 PHP-FPM,那进程是常驻内存的。一旦你改了配置文件,PHP-FPM 会自动 reload,这时候可能会产生僵尸进程(虽然现在有主进程管理,但资源初始化也是要花钱的)。
- CLI 模式:虽然脚本跑完就释放,但在采集任务中,我们通常会用 Supervisor 之类的工具来常驻监控。这时候,它就是一个“饿死鬼”,内存只增不减。
2. OpCache:为了快,付出了内存的代价
这是 PHP 的老大哥了。为了不让每次请求都重新解析 PHP 文件,OpCache 把编译后的 OpCode 缓存到了内存里。
- 陷阱:如果你的代码量很大,OpCache 占用的内存是非常恐怖的。如果再加上
.php文件本身的占用,内存会迅速飙升。 - 代码测试:咱们来个简单的测试,看看一个空文件的内存占用:
<?php
// memory_hog.php
// 定义一个常量,防止 OpCache 因为简单常量优化而节省内存
define('CONSTANT_HUGE_DATA', str_repeat('A', 1024 * 1024));
// 启动 OpCache,观察内存
echo "Memory start: " . memory_get_usage() . "n";
// 做点无用功,把变量塞满
$bigArray = [];
for ($i = 0; $i < 10000; $i++) {
$bigArray[] = str_repeat('B', 1024); // 每个 1KB,一共 10MB
}
echo "Memory after loop: " . memory_get_usage() . "n";
echo "Peak memory: " . memory_get_peak_usage() . "n";
运行结果通常会显示,哪怕是空文件加上常量,也会占用几 MB 的内存。这在容器里可能不算什么,但在几百个容器并发时,这就是几十 GB 的流量。
第二部分:容器里的“囚徒困境”
你可能会说:“既然内存不够,那我直接调大 memory_limit 不就行了?”
兄弟,你这就好比让一个人吃自助餐,告诉他“你能吃多少吃多少,不够再点”。如果你没设上限,结果就是他撑死在餐厅里,而不是回家。
在容器化环境中,你有两道墙需要面对:PHP 的墙和操作系统的墙。
1. PHP 的墙:memory_limit
这是 PHP 内核里的变量。默认通常是 128M 或 256M。你可以通过 ini_set 或 php.ini 调大它。
2. 容器的墙:Docker 的 memory
这是 Docker 的资源限制。比如你运行命令是:
docker run -m 512m php:cli ...
这意味着 Docker 守护进程告诉操作系统:“这个容器最多只能用 512MB 物理内存。”
3. 死亡三角区
这里有个非常经典的坑:
PHP memory_limit < 容器 memory < 实际物理内存需求
如果你的 PHP 脚本试图分配 1GB 内存,而你的容器限制是 512MB。
- PHP 报错
Fatal error: Allowed memory size of ... exhausted,脚本直接挂掉。 - 如果你的脚本没有正确处理错误,或者使用了内存泄漏(后面会说),脚本可能没报错,但内存爆了。
- 最惨的是:如果脚本写得很烂,它疯狂申请内存直到系统发怒,触发 Linux 的 OOM Killer。这时候,谁内存用得最多,谁就先死。可能不是你的脚本,而是你的 Nginx,甚至是你的数据库!
第三部分:实战调优——如何给 PHP 减肥?
我们现在的目标很明确:在不炸毁物理机的前提下,让 PHP 尽可能多干活。
策略一:物理限制与逻辑限制的“双重保险”
不要相信 PHP 的 memory_limit。要相信 Docker 的 memory 限制。
原则:容器的内存限制应该大于 PHP 的 memory_limit,并且要预留出 PHP-FPM/CLI 进程本身的开销。
错误示范:
ini_set('memory_limit', '512M'); // PHP 认为我有 512M
// docker run -m 512m ... // 实际上我只能用 512M
// 结果:PHP 初始化分配了内存,但执行到一半,容器被 OOM 杀死。
正确姿势:
// 1. PHP 内部设置一个合理的上限,防止脚本本身写崩了
ini_set('memory_limit', '256M');
// 2. Docker 运行时设置比 PHP 大一点的安全余量
// docker run -m 512m --memory-swap 1g ...
代码示例:如何获取当前内存状态并报警
class MemoryMonitor {
public static function check() {
$limit = ini_get('memory_limit');
$current = memory_get_usage(true); // true 获取 peak usage
// 将 M 转换为字节
$limitBytes = self::sizeToBytes($limit);
$usagePercent = ($current / $limitBytes) * 100;
if ($usagePercent > 90) {
error_log("WARNING: Memory usage is critical: {$usagePercent}%");
// 触发邮件或钉钉告警
}
}
private static function sizeToBytes($val) {
if (is_numeric($val)) return $val;
$val = trim($val);
$last = strtolower($val[strlen($val)-1]);
$val = (int) $val;
switch ($last) {
case 'g': $val *= 1024;
case 'm': $val *= 1024;
case 'k': $val *= 1024;
}
return $val;
}
}
// 在循环中定期检查
while ($data = getNextData()) {
process($data);
if (rand(0, 100) === 0) { // 每 100 次检查一次,避免影响性能
MemoryMonitor::check();
}
}
策略二:流式处理——告别“全量加载”
这是解决 OOM 的核武器。很多同学写采集脚本,喜欢先读数据库,全量加载到数组里,然后循环处理。
// ❌ 这种写法是内存杀手
$allData = $db->query("SELECT * FROM huge_table")->fetchAll(); // 假设 1000 万条
foreach ($allData as $row) {
// 处理逻辑
}
如果 $allData 是 2GB,你的脚本还没跑,内存就爆了。
✅ 这种写法才是正道:流式查询
// ✅ 拉链式处理,内存恒定
$stmt = $db->query("SELECT * FROM huge_table");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
process($row);
// 重要:手动释放上一行引用,虽然 PDO 会自动处理,但在逻辑层面要清楚
unset($row);
}
同样,对于 HTTP 请求,如果是抓取大文件,千万千万不要先 file_get_contents 再存数组,然后 file_put_contents。
正确姿势:管道流
$fp = fopen('remote_url', 'r');
$local = fopen('local_file', 'w+');
while (!feof($fp)) {
$chunk = fread($fp, 8192); // 每次只读 8KB
fwrite($local, $chunk);
unset($chunk); // 立即释放当前块
}
fclose($fp);
fclose($local);
记住,内存不是用来存储的,是用来暂存的。暂存的时间越短,你占用的内存就越少。
第四部分:采集任务中的“内存陷阱”与“杀虫剂”
海量采集任务不仅仅是处理数据,还有网络请求和对象创建。
1. HTTP 客户端:Guzzle 的脂肪
如果你用 Guzzle 抓数据,默认它会缓存响应头和内容。如果处理不好,它会吃掉大量内存。
代码示例:
$client = new Client();
$response = $client->request('GET', 'https://api.big-site.com/data');
// ❌ 危险:把整个响应体塞进内存变量
$body = $response->getBody()->getContents();
$data = json_decode($body, true);
// ✅ 安全:直接流转,不存储
$response->getBody()->rewind();
while (!$response->getBody()->eof()) {
$chunk = $response->getBody()->read(1024);
// 这里你可以直接处理 chunk,或者写入文件,而不是存储在 $chunk 变量里太久
}
2. 对象的生命周期
在 OOP 编程中,有时候我们为了方便,会定义一些全局变量或类属性。
陷阱:
class Spider {
public $logger;
public $config;
public $db;
public function run() {
while ($task = $this->getNextTask()) {
// 如果这里没写析构逻辑,或者引用没断开,内存会涨
$this->processTask($task);
}
}
}
解药:对象复用
如果你使用了依赖注入(DI),尽量复用 $logger 和 $db 连接对象。不要在循环里 new DB(),那简直是灾难。连接对象是重头,对象里的句柄(Handle)才是轻的。
3. 垃圾回收(GC)的脾气
PHP 的引用计数机制让 GC 很高效,但也容易产生循环引用。
场景:一个对象 A 引用了对象 B,对象 B 又引用了对象 A。如果不手动 unset,或者无法打破循环,这部分内存在脚本结束前永远不会被释放。
手动干预:
// 在处理完一个复杂对象后
$obj = generateComplexObject();
// ... 操作 ...
unset($obj); // 强制断开引用
// 有时候,GC 不够给力,需要手动召唤
gc_collect_cycles();
虽然在 CLI 长时间运行的脚本中频繁调用 gc_collect_cycles 可能会有性能损耗,但在内存紧张关头,这是最后的手段。
第五部分:容器编排与进程管理——不仅要有壮汉,还要有管家
光调 PHP 的参数是不够的。如果容器崩了,或者进程假死,我们需要“监控”和“重启”。
1. Supervisor:不会死的 PHP 进程
Supervisor 是管理进程的神器。它能监控你的采集脚本,如果脚本崩了,或者内存过高,它会自动重启它。
配置示例:
[program:php_spider]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/spider.php
autostart=true
autorestart=true
startsecs=10
# 关键配置:如果进程占用内存超过 512MB,就杀掉重启
priority=999
mem_limit=512M
有了这个,哪怕你的脚本写了个死循环 while(true) 且占满了内存,Supervisor 也能把它踢掉,释放空间给新进程。虽然这浪费了一些计算资源,但换来了系统的稳定性。
2. Docker 的 Limit 机制(进阶)
在 Kubernetes 或 Docker Swarm 中,我们可以设置更细致的内存策略。
# docker-compose.yml 示例
version: '3'
services:
spider:
image: php:cli-alpine
deploy:
resources:
limits:
memory: 1024M # 物理限制
reservations:
memory: 512M # 预留
environment:
- MEMORY_LIMIT=512M # PHP 配置
这里 1024M 是容器能用的物理上限,512M 是 PHP 内部限制。永远不要让 PHP 的 memory_limit 超过容器的物理限制。
3. 信号处理:优雅地关机
当 Docker 发送 SIGTERM 信号停止容器时,PHP 进程会立即收到信号。默认情况下,它会立刻挂掉,刚采集到一半的数据就丢了。
我们需要捕获这个信号,告诉脚本:“别急,把数据存好,然后再关”。
代码示例:
// signal_handler.php
$shutdown = false;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$shutdown) {
echo "nReceived SIGTERM, saving state and exiting...n";
$shutdown = true;
saveCurrentProgress(); // 自定义函数,保存进度
});
pcntl_signal(SIGINT, function() use (&$shutdown) {
echo "nReceived SIGINT, exiting...n";
$shutdown = true;
});
while (!$shutdown && hasMoreData()) {
$data = fetchNextData();
process($data);
if ($shutdown) break; // 在处理循环里检查退出标志
}
第六部分:实战案例重构
假设我们有一个采集任务,抓取电商网站的商品信息。
重构前的代码(满地坑):
<?php
// bad_spider.php
require 'vendor/autoload.php';
use GuzzleHttpClient;
$client = new Client();
$db = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
// 1. 全量读取 URL 列表
$urls = file('url_list.txt');
echo "Loaded " . count($urls) . " URLs.n";
foreach ($urls as $index => $url) {
if ($index % 1000 === 0) {
echo "Processing {$index}...n";
// 忘记 unset
}
// 2. 获取页面,直接转成字符串
$response = $client->get(trim($url));
$content = $response->getBody()->getContents();
// 3. 解析并入库
$data = parseContent($content); // 这函数内部肯定创建了很多对象
if ($data) {
$stmt = $db->prepare("INSERT INTO products (...) VALUES (...)");
$stmt->execute($data);
}
// 4. 没有任何垃圾回收和状态保存
}
echo "Done.n";
后果预测:
url_list.txt有 100 万行,内存瞬间爆表。parseContent解析 HTML 可能会生成临时数组。- 如果中途挂了,什么都没存。
重构后的代码(钢铁侠战衣):
<?php
// good_spider.php
require 'vendor/autoload.php';
use GuzzleHttpClient;
use GuzzleHttpHandlerStreamingHandler;
use GuzzleHttpPsr7;
// 配置
$memoryLimit = '512M'; // PHP 内部限制
$containerLimit = '1G'; // 容器物理限制
$batchSize = 50; // 每批处理多少条
// --- 第一步:信号处理与优雅退出 ---
$finished = false;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$finished) {
echo "nStopping gracefully...n";
$finished = true;
});
// --- 第二步:流式读取 URL ---
$fp = fopen('url_list.txt', 'r');
$batch = [];
while ($url = fgets($fp)) {
$url = trim($url);
if (empty($url)) continue;
// 准备数据
$data = fetchProduct($url); // 假设这是封装好的流式抓取方法
if ($data) {
$batch[] = $data;
}
// 每处理一批,检查一次内存
if (count($batch) >= $batchSize) {
saveBatch($batch);
$batch = []; // 清空数组,释放内存
echo "Saved batch. Current usage: " . memory_get_usage() . "n";
// 触发一次 GC
gc_collect_cycles();
}
// 检查退出信号
if ($finished) {
if (!empty($batch)) saveBatch($batch);
break;
}
}
// 保存剩余的
if (!empty($batch)) saveBatch($batch);
echo "Job finished.n";
// 辅助方法:流式抓取,不加载全文
function fetchProduct($url) {
global $client;
// 使用流式处理 Response Body
$stream = Psr7stream_for(fopen('php://temp', 'r+'));
try {
$response = $client->get($url, [
'sink' => $stream // 让 Guzzle 把内容流式写入内存中的临时流
]);
$stream->rewind();
$content = $stream->getContents();
// 解析逻辑...
return parseData($content);
} catch (Exception $e) {
error_log("Error fetching {$url}: " . $e->getMessage());
return null;
}
}
// 辅助方法:数据库分批保存
function saveBatch($batch) {
global $db;
if (empty($batch)) return;
$sql = "INSERT INTO products (title, price) VALUES ";
$placeholders = [];
foreach ($batch as $item) {
$placeholders[] = "(?, ?)";
}
$sql .= implode(',', $placeholders);
$stmt = $db->prepare($sql);
$values = [];
foreach ($batch as $item) {
$values[] = $item['title'];
$values[] = $item['price'];
}
$stmt->execute($values);
}
第七部分:监控与排障——你知道你的脚本在做什么吗?
光防备是不够的,你得知道你的防线有没有被突破。没有监控的运维是盲人摸象。
1. 实时监控 Docker 内存
不要等机器挂了再去查。实时看:
docker stats --no-stream <container_id>
如果 MEM USAGE 一路飙升接近 100%,说明你的脚本或者 GC 没做好。
2. PHP 内置监控函数
$mem = memory_get_usage(true);
echo "Current: " . round($mem / 1024 / 1024, 2) . " MBn";
把这段日志打到文件或者监控系统里。
3. XHProf / Tideways
虽然 XHProf 有内存开销,但在调试复杂的内存泄漏时,它能告诉你哪段代码是“内存黑洞”。
总结一下(不许眨眼,这很重要)
我们要解决 PHP 海量采集导致 OOM 的问题,核心在于“限制”与“释放”。
- 双重限制:永远设置 Docker 容器的物理内存限制,并确保它严格大于 PHP 的
memory_limit。不要给容器无限内存。 - 流式处理:不要把所有数据一次性加载到内存数组中。使用流式 HTTP 请求、流式数据库查询、流式文件读写。
- 对象复用:不要在循环里 new 大对象,特别是数据库连接、HTTP 客户端和复杂的解析器。
- 信号监听:监听 SIGTERM,实现优雅退出,保证数据不丢失。
- 监控闭环:配合 Supervisor 监控进程状态,配合
memory_get_usage监控内存水位。
最后,记住一句话:内存是服务器最昂贵的资源,别让你的代码像个无底洞一样吞噬它。 搞定这些,你的物理机就能稳如老狗,你的 OOM Killer 就能去喝西北风了。
好了,今天的讲座就到这里。如果有同学觉得刚才讲的代码里还有漏洞,欢迎课后单独切磋,别在群里问,群里太吵,容易被打死。散会!