PHP 稳定性实战:当 Windows 物理机房发生断网时,PHP 调度器如何实现状态的自动持久化?

各位朋友们,晚上好,晚上好!

欢迎来到今天的讲座,我是你们的老朋友,一名在 PHP 深海里潜水摸鱼二十年的资深极客。今天我们不讲那些“如何优雅地输出 Hello World”的入门课,也不讲“如何用 foreach 遍历数组”的废话。今天我们要聊的是一些硬核的、带血肉的、甚至有点“狼狈”的话题。

“当 Windows 物理机房的网线被猫咬断的那一刻,你的 PHP 调度器到底还能不能活下去?”

想象一下这个场景:你正在写代码,手指飞舞,代码行云流水,突然,你的老板或者运维总监冲进房间,一脸惊恐地喊道:“网络断了!防火墙挂了!数据库连不上了!我们还在运行的后台任务怎么办?”

这时候,如果你的调度器只是简单地把任务列表扔给 crontab 或者 Laravel 的 Schedule 类,那它们大概率正趴在桌子上睡觉呢——它们依赖的是外部的时间触发器。一旦网络中断,外部的时间同步可能失效,或者远程的任务状态根本无法写入。

我们要解决的,是 PHP 在“绝境”中的自给自足

一、 破除迷信:为什么传统调度器在断网时会尿裤子?

在 Windows 物理机房里,大家习惯用任务计划程序或者 Linux 的 Crontab 来调度 PHP 脚本。但请记住一句话:只要还依赖外部的时间驱动,你就是在裸奔。

当网络断开时:

  1. NTP 同步失效:时间可能漂移,或者依赖网络服务的时钟同步机制失效。
  2. 数据库连接断开:大多数调度器在执行任务前会去数据库查一下任务列表,任务执行完还得更新状态。断网 = 数据库只读。任务跑了?没记录。任务没跑?系统不知道。
  3. 远程状态丢失:如果你有分布式的任务调度系统(比如 Gearman 服务器、Redis 分布式锁),一旦服务端断网,客户端任务依然在跑,但调度中心已经瞎了,它不知道任务执行了。

所以,我们的核心目标是:将调度器从“网络依赖型”转变为“本地状态驱动型”。

二、 架构设计:单例守护进程

在 Windows 上,我们要解决 PHP 脚本的执行问题。普通的 CGI 模式(php.exe script.php)是跑一阵就死。我们需要一个守护进程

什么是守护进程?就是一个一直在后台跑的脚本,像一个不知疲倦的打工人,直到天荒地老(或者服务器关机)。

这个进程的核心循环逻辑很简单:

  1. 检查本地数据库(SQLite 是最佳拍档)。
  2. 算一算:现在几点了?下一个任务什么时候跑?
  3. 如果到了时间,就把任务抓出来,扔进 PHP 解释器执行。
  4. 执行完,记录结果,更新数据库,然后睡觉(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) 循环。

注意几个细节:

  1. Sleep 时间:不要每秒都查数据库,太浪费 CPU。查一次睡 10 秒。如果任务要每秒跑,那就要调整策略。
  2. 优雅关闭:Windows 上的 PHP 进程如果不小心被杀,可能会丢失最后的状态。我们需要注册 register_shutdown_function
  3. 错误处理:如果执行任务报错了,不要让整个守护进程挂掉。
<?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 秒又把它抓起来了。

这时候,我们需要一个基于文件的事件队列

  1. 任务触发:当系统检测到某个事件(比如用户注册成功,或者文件上传完成),将任务写入一个 JSON 文件(queue.json)。
  2. 守护进程读取 queue.json,发现新任务,将其从队列中删除(出队)。
  3. 执行任务。
  4. 如果执行失败,将任务重新写回 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 物理机房断网时:

  1. 不要依赖 crontab:那是服务器硬件层面的定时器,跟 PHP 无关。
  2. 不要依赖外部数据库:那是网络的心脏,断了就凉。
  3. 拥抱 SQLite:它是你断网时的避难所。
  4. 实现状态持久化:让每一个动作都有记录,每一次执行都有锁,防止断网重启后的重复执行。
  5. 确保任务幂等:这是分布式系统的底线,也是断网生存的底线。

这不仅仅是关于 PHP 的技术,更是关于鲁棒性的哲学。作为一个程序员,我们不仅要写出能跑的代码,更要写出在黑暗中也能闪闪发光、在网络断开时依然坚挺的代码。

所以,下次当老板把网线拔掉的时候,你可以淡定地喝一口咖啡,看着你的调度器在本地数据库里欢快地舞动,心想:“看,这就是技术的力量,哪怕没有互联网,我依然能掌控一切。”

(当然,最好还是让老板别拔网线,因为如果真断网了,你的服务器可能也没法备份到异地了。)

好了,今天的讲座就到这里,我是你们的 PHP 专家,祝大家代码无 Bug,断网依然稳!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注