本文目录导读:

在 PHP 中实现延迟消息的取消,主要有以下几种方案,我会从简单到复杂逐一介绍:
Redis + 延迟队列(推荐方案)
原理
使用 Redis 的有序集合(ZSet),通过 score 存储执行时间戳,通过一个独立进程扫描到期任务。
<?php
class DelayedQueue {
private $redis;
private $queueKey = 'delayed:queue';
private $cancelKey = 'delayed:cancel';
public function __construct($redis) {
$this->redis = $redis;
}
/**
* 添加延迟任务
*/
public function add(string $taskId, array $data, int $delaySeconds): bool {
$executeTime = time() + $delaySeconds;
$task = json_encode([
'id' => $taskId,
'data' => $data,
'create_time' => time()
]);
// 将任务添加到 ZSet,score 为执行时间戳
return $this->redis->zAdd($this->queueKey, $executeTime, $task);
}
/**
* 取消延迟任务
*/
public function cancel(string $taskId): bool {
// 将所有该 taskId 的任务标记为取消
$cancelData = json_encode([
'id' => $taskId,
'cancel_time' => time()
]);
return $this->redis->hSet($this->cancelKey, $taskId, $cancelData);
}
/**
* 检查任务是否被取消(消费者处理时调用)
*/
public function isCancelled(string $taskId): bool {
return $this->redis->hExists($this->cancelKey, $taskId);
}
/**
* 获取到期任务(消费者轮询)
*/
public function pollReadyTasks(): array {
$now = time();
$tasks = $this->redis->zRangeByScore(
$this->queueKey,
'-inf',
$now,
['limit' => [0, 100]]
);
$readyTasks = [];
foreach ($tasks as $taskJson) {
$task = json_decode($taskJson, true);
// 检查是否被取消
if (!$this->isCancelled($task['id'])) {
$readyTasks[] = $task;
// 从队列中移除
$this->redis->zRem($this->queueKey, $taskJson);
} else {
// 被取消的任务直接移除
$this->redis->zRem($this->queueKey, $taskJson);
$this->redis->hDel($this->cancelKey, $task['id']);
}
}
return $readyTasks;
}
/**
* 清理已过期的取消标记
*/
public function cleanupCancelMarkers(int $maxAge = 86400): int {
$count = 0;
$cancelTasks = $this->redis->hGetAll($this->cancelKey);
foreach ($cancelTasks as $taskId => $cancelData) {
$data = json_decode($cancelData, true);
if (time() - $data['cancel_time'] > $maxAge) {
$this->redis->hDel($this->cancelKey, $taskId);
$count++;
}
}
return $count;
}
}
消费者处理
<?php
// consumer.php - 独立进程运行
while (true) {
$queue = new DelayedQueue($redis);
// 获取到期的任务
$readyTasks = $queue->pollReadyTasks();
foreach ($readyTasks as $task) {
try {
// 执行任务
processTask($task);
} catch (Exception $e) {
// 记录错误日志
error_log("Task {$task['id']} failed: " . $e->getMessage());
}
}
// 定期清理取消标记
if (time() % 3600 == 0) {
$queue->cleanupCancelMarkers();
}
// 每100毫秒轮询一次
usleep(100000);
}
Redis + 流(Stream)方案
Redis 5.0+ 的 Stream 数据结构,支持消息确认和待处理列表。
<?php
class DelayedStreamQueue {
private $redis;
private $streamKey = 'task:stream';
private $groupName = 'task:group';
public function __construct($redis) {
$this->redis = $redis;
}
/**
* 添加延迟任务
*/
public function add(string $taskId, array $data, int $delaySeconds): string {
$executeTime = time() + $delaySeconds;
$messageId = $this->redis->xAdd($this->streamKey, '*', [
'task_id' => $taskId,
'data' => json_encode($data),
'execute_time' => $executeTime,
'status' => 'pending'
]);
return $messageId;
}
/**
* 取消任务 - 标记为已取消
*/
public function cancel(string $taskId): bool {
// 使用 Lua 脚本来原子性更新所有匹配的任务
$luaScript = <<<LUA
local stream = KEYS[1]
local taskId = ARGV[1]
local cancelTime = ARGV[2]
-- 获取流中的信息
local entries = redis.call('XRANGE', stream, '-', '+')
local updated = 0
for _, entry in ipairs(entries) do
local msgId = entry[1]
local fields = entry[2]
local task_id = fields[2] -- 这里需要根据实际结构调整
if task_id == taskId then
-- 更新状态为已取消
redis.call('XACK', stream, KEYS[2], msgId)
redis.call('XDEL', stream, msgId)
updated = updated + 1
end
end
return updated
LUA;
return $this->redis->eval($luaScript, 2, $this->streamKey, $this->groupName, $taskId, time());
}
}
RabbitMQ 延迟消息方案
使用 TTL + 死信队列
<?php
class RabbitMQDelayedQueue {
private $connection;
private $channel;
public function __construct($connection) {
$this->connection = $connection;
$this->channel = $connection->channel();
$this->setup();
}
private function setup() {
// 声明主队列(延迟队列)
$this->channel->queue_declare(
'delay_queue', // 队列名
false, // passive
true, // durable
false, // exclusive
false, // auto_delete
false, // no_wait
[
'x-dead-letter-exchange' => ['exchange', 'task.exchange'],
'x-dead-letter-routing-key' => ['routing_key', 'task.process'],
'x-message-ttl' => ['I', 5000] // 5秒延迟
]
);
// 声明处理队列
$this->channel->queue_declare(
'process_queue',
false,
true,
false,
false
);
// 声明交换机
$this->channel->exchange_declare('task.exchange', 'direct', false, true, false);
// 绑定
$this->channel->queue_bind('process_queue', 'task.exchange', 'task.process');
}
/**
* 发布延迟消息
*/
public function addTask(string $taskId, array $data, int $delayMs) {
$this->channel->queue_declare(
'delay_queue_' . $taskId, // 每个任务一个队列来动态设置 TTL
false,
true,
false,
false,
false,
[
'x-dead-letter-exchange' => ['exchange', 'task.exchange'],
'x-dead-letter-routing-key' => ['routing_key', 'task.process'],
'x-message-ttl' => ['I', $delayMs]
]
);
$message = new AMQPMessage(json_encode([
'task_id' => $taskId,
'data' => $data
]));
$this->channel->basic_publish(
$message,
'',
'delay_queue_' . $taskId
);
}
/**
* 取消延迟任务
*/
public function cancelTask(string $taskId) {
// 直接删除对应的延迟队列
$this->channel->queue_delete('delay_queue_' . $taskId);
}
}
数据库定时扫描方案
适用于中小型项目,使用数据库存储任务状态。
<?php
class DatabaseDelayQueue {
private $pdo;
/**
* 添加延迟任务
*/
public function add(string $taskId, array $data, int $delaySeconds): bool {
$stmt = $this->pdo->prepare(
'INSERT INTO delayed_tasks (task_id, data, execute_at, status, created_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?, NOW())'
);
return $stmt->execute([
$taskId,
json_encode($data, JSON_UNESCAPED_UNICODE),
$delaySeconds,
'pending'
]);
}
/**
* 取消任务
*/
public function cancel(string $taskId): bool {
$stmt = $this->pdo->prepare(
'UPDATE delayed_tasks
SET status = ?, cancelled_at = NOW()
WHERE task_id = ? AND status = ?
AND execute_at > NOW()'
);
return $stmt->execute(['cancelled', $taskId, 'pending']);
}
/**
* 获取并执行到期任务
*/
public function processDueTasks(): array {
$now = new DateTime();
$stmt = $this->pdo->prepare(
'SELECT task_id, data
FROM delayed_tasks
WHERE execute_at <= ? AND status = ?
FOR UPDATE SKIP LOCKED'
);
$stmt->execute([$now->format('Y-m-d H:i:s'), 'pending']);
$tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 更新任务状态为执行中
foreach ($tasks as $task) {
$this->pdo->prepare(
'UPDATE delayed_tasks SET status = ? WHERE task_id = ?'
)->execute(['processing', $task['task_id']]);
}
return $tasks;
}
/**
* 完成任务
*/
public function complete(string $taskId): bool {
$stmt = $this->pdo->prepare(
'UPDATE delayed_tasks
SET status = ?, completed_at = NOW()
WHERE task_id = ?'
);
return $stmt->execute(['completed', $taskId]);
}
}
其他高级方案
使用 Redis RStream 扩展
// 使用 RedStream 或 RedisStreams 库
$stream = new RedisStreams\Stream($redis, 'tasks');
// 添加带延迟的任务
$stream->add([
'task_id' => $taskId,
'data' => json_encode($data),
'execute_at' => time() + $delaySeconds
], $maxLen);
// 取消任务
$stream->query(
RedisStreams\Query::greaterThan($lastId)
);
使用 PECL 插件
// pecl 安装 redis 扩展后 $queue = new \RedisQueue\DelayedQueue($redis); $queue->addJob($taskId, $data, $delaySeconds); $queue->cancelJob($taskId);
选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis ZSet | 实现简单、性能高、支持高效取消 | 需要额外消费者进程 | 多数场景,推荐 |
| Redis Stream | 持久化、支持消费组、可追溯 | 实现复杂度较高 | 需要消息确认机制 |
| RabbitMQ | 专业 MQ、可靠性高、支持 TTL | 部署复杂、资源占用大 | 企业级应用 |
| 数据库轮询 | 简单直观、事务支持好 |