各位朋友们,晚上好,晚上好!
欢迎来到今天的讲座,我是你们的老朋友,一名在 PHP 深海里潜水摸鱼二十年的资深极客。今天我们不讲那些“如何优雅地输出 Hello World”的入门课,也不讲“如何用 foreach 遍历数组”的废话。今天我们要聊的是一些硬核的、带血肉的、甚至有点“狼狈”的话题。
“当 Windows 物理机房的网线被猫咬断的那一刻,你的 PHP 调度器到底还能不能活下去?”
想象一下这个场景:你正在写代码,手指飞舞,代码行云流水,突然,你的老板或者运维总监冲进房间,一脸惊恐地喊道:“网络断了!防火墙挂了!数据库连不上了!我们还在运行的后台任务怎么办?”
这时候,如果你的调度器只是简单地把任务列表扔给 crontab 或者 Laravel 的 Schedule 类,那它们大概率正趴在桌子上睡觉呢——它们依赖的是外部的时间触发器。一旦网络中断,外部的时间同步可能失效,或者远程的任务状态根本无法写入。
我们要解决的,是 PHP 在“绝境”中的自给自足。
一、 破除迷信:为什么传统调度器在断网时会尿裤子?
在 Windows 物理机房里,大家习惯用任务计划程序或者 Linux 的 Crontab 来调度 PHP 脚本。但请记住一句话:只要还依赖外部的时间驱动,你就是在裸奔。
当网络断开时:
- NTP 同步失效:时间可能漂移,或者依赖网络服务的时钟同步机制失效。
- 数据库连接断开:大多数调度器在执行任务前会去数据库查一下任务列表,任务执行完还得更新状态。断网 = 数据库只读。任务跑了?没记录。任务没跑?系统不知道。
- 远程状态丢失:如果你有分布式的任务调度系统(比如 Gearman 服务器、Redis 分布式锁),一旦服务端断网,客户端任务依然在跑,但调度中心已经瞎了,它不知道任务执行了。
所以,我们的核心目标是:将调度器从“网络依赖型”转变为“本地状态驱动型”。
二、 架构设计:单例守护进程
在 Windows 上,我们要解决 PHP 脚本的执行问题。普通的 CGI 模式(php.exe script.php)是跑一阵就死。我们需要一个守护进程。
什么是守护进程?就是一个一直在后台跑的脚本,像一个不知疲倦的打工人,直到天荒地老(或者服务器关机)。
这个进程的核心循环逻辑很简单:
- 检查本地数据库(SQLite 是最佳拍档)。
- 算一算:现在几点了?下一个任务什么时候跑?
- 如果到了时间,就把任务抓出来,扔进 PHP 解释器执行。
- 执行完,记录结果,更新数据库,然后睡觉(
sleep),等待下一次唤醒。
三、 武器库选择:SQLite 是永远的备胎,也是最靠谱的备胎
不要再用 MySQL 了,兄弟们。断网的时候,MySQL 连接池里的连接大概率是“死锁”的。我们需要一个零配置、无需服务、文件级存储的数据库。
SQLite 是一个进程内的数据库,它的文件就躺在硬盘上。断网了?没关系,只要硬盘没坏,SQLite 就能接着写。它自带行级锁,并发控制做得相当不错,对于这种短周期的任务调度器来说,性能完全溢出。
四、 核心实战:代码示例
下面,我们把代码搬上来。为了方便演示,我们假设一个最恶劣的场景:Windows 物理机,断网,PHP CLI 环境。
我们需要一个 Database 类来处理 SQLite 的增删改查,还有一个 Scheduler 类来处理核心逻辑。
1. 数据库表结构设计
首先,我们需要一个表来存任务。不仅要存任务干啥(command),还要存它什么时候跑(cron_expression),以及最关键的:它的当前状态。
<?php
// db_schema.php
// 这段代码通常在程序启动时执行一次,或者放在 SQL 文件里手动跑
$dbFile = __DIR__ . '/scheduler.db';
$db = new SQLite3($dbFile);
// 创建任务表
$db->exec("CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
command TEXT NOT NULL,
expression TEXT NOT NULL, -- 比如 '*/5 * * * *'
status TEXT DEFAULT 'pending', -- pending, running, success, failed
last_run_at DATETIME,
next_run_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 创建执行日志表(可选,方便断网后排查)
$db->exec("CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER,
output TEXT,
status TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(task_id) REFERENCES tasks(id)
)");
echo "数据库初始化完成n";
2. 任务管理类
我们需要一个类来管理任务的状态。断网最大的风险是:任务执行了一半,数据库还没来得及保存,PHP 进程被杀死了怎么办?
解决方案是:状态锁。
如果一个任务正在跑(status=’running’),我们要确保下一次轮询时,不会重复触发它,直到它跑完更新状态。或者,更严谨的做法是:如果任务状态是 running 超过了一个阈值(比如 2 小时),强制将其重置为 pending,防止死锁。
<?php
class TaskManager {
private $db;
public function __construct($dbFile) {
// 即使断网,我们也要保持连接。SQLite 在断网后虽然不能写远程数据库,但本地文件操作是毫秒级的。
$this->db = new SQLite3($dbFile);
// 开启外键约束
$this->db->exec('PRAGMA foreign_keys = ON;');
}
/**
* 获取所有需要执行的任务
* 核心逻辑:查询所有状态为 pending,且当前时间 >= next_run_at 的任务
*/
public function getPendingTasks() {
$now = date('Y-m-d H:i:s');
$sql = "SELECT * FROM tasks WHERE status = 'pending' AND next_run_at <= '$now'";
$result = $this->db->query($sql);
$tasks = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$tasks[] = $row;
}
return $tasks;
}
/**
* 更新任务状态
* 这里我们用事务来保证数据一致性。断网时网络虽然断了,但内存中的事务还没提交,我们可以提交它。
*/
public function updateTaskStatus($taskId, $status, $nextRunTime = null) {
$now = date('Y-m-d H:i:s');
// 如果 nextRunTime 是 null,我们需要根据 cron 表达式自动计算下一次执行时间
if (!$nextRunTime) {
$nextRunTime = $this->calculateNextRun($taskId, $status);
}
// 使用事务,防止断网导致数据写入一半(虽然 SQLite 是单进程写,但逻辑上要严谨)
$this->db->exec('BEGIN IMMEDIATE TRANSACTION');
try {
$sql = "UPDATE tasks SET status = '$status', last_run_at = '$now', next_run_at = '$nextRunTime' WHERE id = $taskId";
$this->db->exec($sql);
// 记录日志
$logSql = "INSERT INTO logs (task_id, status) VALUES ($taskId, '$status')";
$this->db->exec($logSql);
$this->db->exec('COMMIT');
} catch (Exception $e) {
$this->db->exec('ROLLBACK');
throw $e;
}
}
/**
* 极其简化的 Cron 解析器
* 生产环境建议使用开源库,比如 'dragonmantank/cron-expression'
* 这里为了演示,我们手写一个假的时间计算
*/
private function calculateNextRun($taskId, $status) {
// 模拟:假设当前任务每 60 秒跑一次,断网期间我们假设时间正常流逝(服务器内部时钟是准的)
// 在极端断网情况下,服务器时钟可能不准,但这是唯一能做的方法。
$next = new DateTime('+1 minute');
return $next->format('Y-m-d H:i:s');
}
public function __destruct() {
$this->db->close();
}
}
3. 调度器守护进程主循环
这是最核心的部分。我们要写一个 while(true) 循环。
注意几个细节:
- Sleep 时间:不要每秒都查数据库,太浪费 CPU。查一次睡 10 秒。如果任务要每秒跑,那就要调整策略。
- 优雅关闭:Windows 上的 PHP 进程如果不小心被杀,可能会丢失最后的状态。我们需要注册
register_shutdown_function。 - 错误处理:如果执行任务报错了,不要让整个守护进程挂掉。
<?php
// scheduler.php
require_once 'TaskManager.php';
// 初始化数据库
// 注意:如果断网很久,数据库文件可能会被锁定。SQLite 有一个 busy_timeout 属性。
$dbFile = __DIR__ . '/scheduler.db';
$db = new SQLite3($dbFile);
$db->busyTimeout(5000); // 等待 5 秒获取锁,避免断网瞬间锁死
$db->close();
$tm = new TaskManager($dbFile);
// 注册关闭函数:当进程被 kill 时,尝试把任务状态改为 failed,防止下次重启以为没跑
register_shutdown_function(function() use ($tm) {
$error = error_get_last();
if ($error && ($error['type'] === E_ERROR || $error['type'] === E_PARSE)) {
echo "严重错误发生,守护进程即将终止。尝试保存状态...n";
// 这里可以写逻辑去查询哪些任务是 running 状态,然后重置为 pending
// 由于 register_shutdown_function 执行时 $tm 可能已经被析构,需要重新连接数据库
// 略过具体实现,这是一个保护性措施
}
});
echo "调度器已启动,正在监听网络变化(并没有,它根本不在乎网络!)...n";
while (true) {
// 1. 获取待办任务
$tasks = $tm->getPendingTasks();
foreach ($tasks as $task) {
echo "[{$task['name']}] 检测到触发条件,开始执行...n";
// 2. 锁定状态,防止并发执行
// 虽然我们查出来是 pending,但在多进程环境下,可能有两个进程同时查到。
// 所以执行前,必须把状态改为 running。
// 使用 SQL UPDATE WHERE ... LIMIT 1 来保证只有一个进程能抢到这个任务
$tm->acquireLock($task['id']);
// 3. 执行任务
$exitCode = null;
$output = null;
// 这里执行你的 PHP 代码
// exec() 在断网环境下依然有效,只要不涉及网络请求
$cmd = "php -r "echo 'Executing: " . addslashes($task['command']) . "\n'; exit(0);"";
// 实际使用中,你可能需要 shell_exec 或者 proc_open
// 这里为了简单直接执行
$output = shell_exec($cmd);
// 4. 根据结果更新状态
if ($exitCode === 0) {
$tm->updateTaskStatus($task['id'], 'success');
echo "[{$task['name']}] 执行成功。n";
} else {
$tm->updateTaskStatus($task['id'], 'failed');
echo "[{$task['name']}] 执行失败。n";
}
}
// 5. 休息一下,等待下次检查
// 即使断网,CPU 也要休息。不要让调度器成为 CPU 占用的罪魁祸首。
sleep(10);
}
五、 防御性编程:断网后的状态恢复
讲到这里,你以为万事大吉了?错。断网最可怕的不是“任务没跑”,而是“任务跑了但没保存”。
假设你的调度器跑了 10 个任务,正准备写入数据库,突然断网了。这时候,PHP 进程崩溃了。数据库里什么都没变,任务依然处于 pending 状态。
当网络恢复,调度器重启。它看到这 10 个任务还是 pending,于是它们又跑了一遍。
这就是并发竞态条件。
解决方案:幂等性设计
这是分布式系统的圣经,也是本地持久化的圣经。
在代码中,所有的任务函数必须是幂等的。即:运行一次和运行两次,结果是一样的。
例如:
- ❌ 错误的任务:
INSERT INTO users (name) VALUES ('Tom')。断网重启后,Tom 变成了两条。 - ✅ 正确的任务:
INSERT IGNORE INTO users (name) VALUES ('Tom')。或者UPDATE users SET name='Tom' WHERE id=1。
在你的 SQL 调度命令中,一定要包含 ON DUPLICATE KEY UPDATE 语句。
六、 Windows 环境下的特殊挑战
在 Windows 物理机上,还有一个坑。Windows 的任务计划程序有时候非常“智能”,它可能会在断电恢复或者网络重置后自动重启你的 PHP 脚本。
如果你启动了两个 PHP 进程,它们会同时读取数据库,同时抢到任务,同时执行。
解决方案:进程锁
除了在 SQL 层面做 UPDATE ... WHERE status='pending' LIMIT 1,我们还需要在文件系统层面加锁。
在代码执行任务之前,创建一个 lock 文件:
fopen("locks/task_{$id}.lock", "w")。
执行完后,fclose 删除该文件。
如果断网期间,这个 lock 文件没有被删除(比如进程挂了),下次重启时,检查文件是否存在。如果存在,说明上次卡住了,直接跳过这个任务。
// 在 acquireLock 方法中增加文件锁逻辑
public function acquireLock($taskId) {
$lockFile = __DIR__ . "/locks/task_{$taskId}.lock";
// 检查文件是否存在(断网后的残留)
if (file_exists($lockFile)) {
// 读取文件中的时间戳
$mtime = filemtime($lockFile);
// 如果锁文件存在超过 1 小时,视为死锁,清除它
if (time() - $mtime > 3600) {
unlink($lockFile);
} else {
// 锁仍然有效,放弃执行
echo "任务 {$taskId} 被锁定(可能是上次执行异常中断),跳过。n";
return false;
}
}
$fp = fopen($lockFile, 'w');
if (flock($fp, LOCK_EX | LOCK_NB)) {
return $fp; // 返回句柄供后续释放
} else {
fclose($fp);
echo "任务 {$taskId} 被其他进程锁定,放弃执行。n";
return false;
}
}
public function releaseLock($fp) {
if ($fp) {
flock($fp, LOCK_UN);
fclose($fp);
unlink(__DIR__ . "/locks/task_{$taskId}.lock"); // 执行完删除锁
}
}
七、 终极形态:事件驱动的本地队列
如果你的 PHP 脚本需要执行耗时操作(比如发送几十封邮件,或者调用一个很慢的内部 API),且这些操作必须在断网期间完成,那么单纯的 sleep(10) 轮询调度器就不够用了。因为如果任务只运行了 5 秒,调度器在第 6 秒又把它抓起来了。
这时候,我们需要一个基于文件的事件队列。
- 任务触发:当系统检测到某个事件(比如用户注册成功,或者文件上传完成),将任务写入一个 JSON 文件(
queue.json)。 - 守护进程读取
queue.json,发现新任务,将其从队列中删除(出队)。 - 执行任务。
- 如果执行失败,将任务重新写回
queue.json(重试机制)。
这种模式下,任务执行时长不再影响调度器的轮询间隔。即使断网,只要队列文件还在,任务就会在守护进程下次读取时被执行。
// 简单的本地队列示例
class LocalQueue {
private $file = 'queue.json';
public function push($task) {
$tasks = $this->getTasks();
$tasks[] = $task;
file_put_contents($this->file, json_encode($tasks));
}
public function pop() {
$tasks = $this->getTasks();
if (empty($tasks)) return null;
// FIFO
$task = array_shift($tasks);
file_put_contents($this->file, json_encode($tasks));
return $task;
}
private function getTasks() {
if (!file_exists($this->file)) return [];
return json_decode(file_get_contents($this->file), true);
}
}
八、 总结与思考
各位,今天我们聊了很多。我们抛弃了对网络连接的幻想,转而拥抱了本地文件系统和 SQLite。
当 Windows 物理机房断网时:
- 不要依赖
crontab:那是服务器硬件层面的定时器,跟 PHP 无关。 - 不要依赖外部数据库:那是网络的心脏,断了就凉。
- 拥抱 SQLite:它是你断网时的避难所。
- 实现状态持久化:让每一个动作都有记录,每一次执行都有锁,防止断网重启后的重复执行。
- 确保任务幂等:这是分布式系统的底线,也是断网生存的底线。
这不仅仅是关于 PHP 的技术,更是关于鲁棒性的哲学。作为一个程序员,我们不仅要写出能跑的代码,更要写出在黑暗中也能闪闪发光、在网络断开时依然坚挺的代码。
所以,下次当老板把网线拔掉的时候,你可以淡定地喝一口咖啡,看着你的调度器在本地数据库里欢快地舞动,心想:“看,这就是技术的力量,哪怕没有互联网,我依然能掌控一切。”
(当然,最好还是让老板别拔网线,因为如果真断网了,你的服务器可能也没法备份到异地了。)
好了,今天的讲座就到这里,我是你们的 PHP 专家,祝大家代码无 Bug,断网依然稳!