写在前面
上周有个朋友深夜给我打电话,声音里全是焦虑:"我们的订单系统又出事了------用户下单后支付成功,但订单状态没更新,客服电话被打爆了!"
我一问,原来他们所有订单处理逻辑都写在接口的同步流程里:高并发下一超时,订单就丢了。
这不是个例。我见过太多 PHP 项目在业务增长期被"同步处理"这把钝刀割得遍体鳞伤。今天这篇文章,不讲概念堆砌,只讲为什么你的订单会丢、怎么用队列真正解决,以及那些年我踩过的坑。
一、为什么你的订单总丢?------ 同步处理的三大原罪
1.1 超时:接口等不起,业务伤不起
php
// 很多项目的订单处理是这样的
public function createOrder($orderData)
{
// 1. 插入订单
$orderId = $this->orderModel->create($orderData);
// 2. 库存扣减
$this->inventoryService->deduct($orderId);
// 3. 发送通知
$this->notifyService->send($orderId);
// 4. 记录日志
$this->logService->record($orderId);
return $orderId;
}
这段代码看起来没问题,但如果第 3 步"发送通知"是个 HTTP 调用且耗时 3 秒,请问:
- 用户的感受是什么?接口等了 3 秒才返回。
- 如果第 4 步又花了 2 秒呢?5 秒,用户早就关页面了。
- 如果_notifyService_ 在第 3 步抛了异常呢?整个事务回滚,订单都没了。
更极端一点:用户支付成功 → 回调接口触发 → 库存服务超时 → 订单回滚 → 用户付了钱但没订单。投诉、客诉、退款,一个不少。
1.2 并发:并发一来,Race Condition 伺候
php
// 库存扣减的原始写法
$stock = $this->redis->get("goods:{$goodsId}:stock");
if ($stock > 0) {
$this->redis->decr("goods:{$goodsId}:stock"); // 库存超卖!
$this->orderModel->create($orderData);
}
在高并发下,get → 判断 → decr 这三步不是原子的,两个请求同时读到 stock = 1,都通过判断,都扣了,结果库存变成 -1,超卖两个。
你以为这是库存专属的问题?支付回调、优惠券发放、积分扣除......所有涉及"读-改-写"的场景,并发都是定时炸弹。
1.3 崩溃:进程挂了,任务也没了
PHP-FPM 工作模式:请求来 → 启动进程 → 处理 → 返回 → 进程销毁。
如果处理到一半,PHP-FPM 进程被 kill 了(OOM Kill、超时强制终止),正在执行的任务直接蒸發。用户看到接口返回了 200,但后端业务根本没执行完。
典型的"表面成功,实际失败"。
二、队列核心概念:三分钟搞懂
在说实现方案之前,先把几个核心概念捋清楚,这些概念是所有队列方案的基石。
2.1 生产者(Producer):任务的制造者
php
// 生产者:接收用户请求,往队列里扔一个任务
$queue->push("order_process", [
"order_id" => 10086,
"user_id" => 9527,
"amount" => 299.00,
"created_at" => time()
]);
生产者的职责就一个:把任务安全地送进队列,然后快速返回。它不应该关心任务什么时候被处理、谁来处理。
2.2 消费者(Consumer):任务的执行者
php
// 消费者:从队列里取任务,执行它
while (true) {
$job = $queue->pop("order_process"); // 阻塞式获取
$this->processOrder($job["order_id"]);
$queue->ack($job["id"]); // 确认处理完成
}
消费者的职责:从队列取任务、执行任务、确认完成。如果执行失败,要能重试或者放到死信队列。
2.3 消息持久化:不让消息死于内存
这是最容易忽略的一点。
如果没有持久化:队列服务重启(比如 Redis 宕机重启),队列里的消息全部丢失------相当于你往 ATM 里存了钱,但银行说"服务器重启了,存款记录没了"。
正确的做法:
- Redis :开启 AOF 持久化(
appendfsync everysec以上) - RabbitMQ:消息落盘 + 队列持久化
- 数据库:消息存储在表里(最原始但最保险)
三、PHP 队列实现方案对比
3.1 方案一:文件队列(低配版,玩具级)
把任务写到文件里,消费者定时扫描文件。
php
// 生产者:写文件
file_put_contents("/tmp/queue.txt", json_encode($job) . "\n", FILE_APPEND);
// 消费者:读文件
$lines = file("/tmp/queue.txt");
foreach ($lines as $line) {
$job = json_decode($line, true);
$this->process($job);
// 处理完删除行?不好意思,文件不支持原子删除......
}
优点:零依赖,代码简单。
致命缺点:
- 无并发支持(文件锁性能差)
- 无持久化保证(服务器重启文件可能损坏)
- 无重试机制
- 无监控
- 生产环境用这个,迟早出事。
3.2 方案二:Redis 队列(主流,简单易用)
Redis 的 List 结构天生就是队列:LPUSH 入队,BRPOP 出队。配合 RPOPLPUSH(或 BRPOPLPUSH)还能实现安全的队列转移(从原队列取出的同时备份到处理队列,防止消息丢失)。
php
// Redis 队列核心操作
// 入队
$redis->lpush("queue:order", json_encode($job));
// 出队(阻塞,最常用)
$job = $redis->brpop("queue:order", 10); // 阻塞10秒
优点:部署简单(大多数 PHP 项目本来就用 Redis),性能极高,持久化可配置,支持原子操作。
缺点:不支持多消费者共享同一个队列(需要自己做广播),不支持消息确认(需手动实现),cluster 模式下部分命令有坑。
适用场景:中小型项目,单个应用内的异步任务处理。
3.3 方案三:RabbitMQ / Redis Stream(企业级)
| 特性 | Redis 队列 | RabbitMQ | Redis Stream |
|---|---|---|---|
| 消息持久化 | AOF/RDB | 磁盘 | AOF |
| 消息确认 | 手动实现 | ACK 原生支持 | XACK 原生支持 |
| 多消费者 | 需 BRPOPLPUSH | Exchange 路由原生支持 | Consumer Group 原生支持 |
| 延迟消息 | 需 Sorted Set | 延迟插件 | 延迟消息 |
| 集群 | Redis Cluster | 镜像集群 | Redis Cluster |
| 运维成本 | 低 | 高 | 中 |
| 适用规模 | 中小 | 大 | 中大 |
RabbitMQ:功能最全,支持各种路由规则、死信队列、优先级队列。但配置复杂,运维成本高。
Redis Stream (推荐):Redis 5.0 引入,弥补了 List 的不足,支持 Consumer Group(多消费者分片消费)、消息持久化、ACK 确认。我目前项目中的首选。
四、Redis 队列实战代码
下面给出一个基于原生 PHP + Redis 的完整队列实现,包含生产者和消费者两部分。建议收藏,直接抄。
4.1 生产者(投递任务)
php
<?php
/**
* 订单队列 - 生产者
* 负责将订单任务投递到 Redis 队列
*/
class OrderProducer
{
private Redis $redis;
private string $queueName = "queue:order";
public function __construct()
{
$this->redis = new Redis();
$this->redis->connect("127.0.0.1", 6379);
$this->redis->auth("your_password"); // 生产环境务必设置密码
}
/**
* 投递订单处理任务
* @param array $orderData 订单数据
* @return bool 是否投递成功
*/
public function dispatch(array $orderData): bool
{
$job = [
"id" => uniqid("order_", true), // 唯一任务ID,用于去重
"data" => $orderData,
"attempts" => 0, // 初始重试次数
"created_at" => date("Y-m-d H:i:s"),
];
try {
// LPUSH 入队,JSON 序列化存储
$result = $this->redis->lpush(
$this->queueName,
json_encode($job, JSON_UNESCAPED_UNICODE)
);
if ($result === false) {
// 记录投递失败日志
$this->log("ERROR", "订单投递失败: " . json_encode($orderData, JSON_UNESCAPED_UNICODE));
return false;
}
$this->log("INFO", "订单已投递队列: {$job['id']}");
return true;
} catch (RedisException $e) {
$this->log("ERROR", "Redis异常: " . $e->getMessage());
return false;
}
}
/**
* 投递延迟订单处理任务(使用 Redis Sorted Set 实现延迟队列)
* @param array $orderData 订单数据
* @param int $delaySeconds 延迟秒数
* @return bool
*/
public function dispatchDelay(array $orderData, int $delaySeconds = 30): bool
{
$job = [
"id" => uniqid("order_", true), "data" => $orderData,
"attempts" => 0, "created_at" => date("Y-m-d H:i:s"),
];
$delayQueueName = "queue:order:delay";
$executeTime = time() + $delaySeconds; // 延迟到指定时间才执行
try {
// ZADD 的正确调用方式:zadd(key, score, member)
$result = $this->redis->zadd(
$delayQueueName, $executeTime,
json_encode($job, JSON_UNESCAPED_UNICODE)
);
if ($result === false) {
$this->log("ERROR", "延迟订单投递失败: " . json_encode($orderData, JSON_UNESCAPED_UNICODE));
return false;
}
$this->log("INFO", "延迟订单已投递: {$job['id']}, 将在 {$delaySeconds} 秒后执行");
return true;
} catch (RedisException $e) {
$this->log("ERROR", "延迟投递失败: " . $e->getMessage());
return false;
}
}
private function log(string $level, string $message): void
{
$timestamp = date("Y-m-d H:i:s");
echo "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
}
}
// ============ 使用示例 ============
$producer = new OrderProducer();
// 模拟用户下单接口中调用
$orderData = [
"order_id" => 10086,
"user_id" => 9527,
"goods" => [
["id" => 1, "name" => "iPhone 16", "num" => 1, "price" => 8999.00],
["id" => 2, "name" => "AirPods Pro", "num" => 1, "price" => 1999.00],
],
"total_amount" => 10998.00,
"pay_method" => "wechat",
];
$producer->dispatch($orderData);
// 立即返回,用户无需等待库存、通知、日志等操作
4.2 消费者(处理任务)
php
<?php
/**
* 订单队列 - 消费者
* 持续从 Redis 队列获取任务并执行
* 建议使用 nohup 或 supervisor 在后台运行
*/
class OrderConsumer
{
private Redis $redis;
private string $queueName = "queue:order";
private string $processingQueueName = "queue:order:processing"; // 处理中队列(安全备份)
private int $maxAttempts = 3; // 最大重试次数
private int $popTimeout = 10; // BRPOPLPUSH 阻塞时间(秒)
public function __construct()
{
$this->redis = new Redis();
$this->redis->connect("127.0.0.1", 6379);
$this->redis->auth("your_password");
}
/**
* 启动消费者(阻塞循环)
*/
public function run(): void
{
$this->log("INFO", "订单消费者已启动,监听队列: {$this->queueName}");
while (true) {
try {
// BRPOPLPUSH:从 order 队列取任务,同时放到 processing 队列做备份
// 这样即使进程崩溃,任务也不会丢失(从 processing 队列恢复)
$jobJson = $this->redis->brpoplpush(
$this->queueName,
$this->processingQueueName,
$this->popTimeout
);
if ($jobJson === null) {
// 超时,继续等待
continue;
}
$job = json_decode($jobJson, true);
if ($job === null) {
$this->log("WARNING", "任务JSON解析失败: " . $jobJson);
$this->redis->lrem($this->processingQueueName, 1, $jobJson);
continue;
}
$this->processJob($job);
} catch (RedisException $e) {
$this->log("ERROR", "Redis异常: " . $e->getMessage());
sleep(5); // 异常时短暂休眠,避免疯狂重试
} catch (Exception $e) {
$this->log("ERROR", "处理异常: " . $e->getMessage());
sleep(1);
}
// 处理延迟队列中的到期任务(可以放到定时任务中)
$this->processDelayQueue();
}
}
/**
* 处理单个任务
*/
private function processJob(array $job): void
{
$jobId = $job["id"] ?? "unknown";
$orderData = $job["data"] ?? [];
$attempts = ($job["attempts"] ?? 0) + 1;
$this->log("INFO", "开始处理任务: {$jobId} (第{$attempts}次尝试)");
try {
// ========== 这里是具体业务逻辑 ==========
// 1. 校验订单合法性
$this->validateOrder($orderData);
// 2. 库存扣减(使用 Lua 脚本保证原子性)
$this->deductInventory($orderData);
// 3. 创建订单记录
$orderId = $this->createOrderRecord($orderData);
// 4. 发送通知
$this->sendNotification($orderId, $orderData);
// 5. 记录操作日志
$this->logOrder($orderId, $orderData);
// ========== 业务逻辑结束 ==========
// 成功:从 processing 队列中移除(确认完成)
$this->redis->lrem($this->processingQueueName, 1, json_encode($job, JSON_UNESCAPED_UNICODE));
$this->log("INFO", "任务处理成功: {$jobId}");
} catch (Exception $e) {
$this->handleFailure($job, $attempts, $e);
}
}
/**
* 处理失败逻辑:重试 or 死信
*/
private function handleFailure(array $job, int $attempts, Exception $e): void
{
$jobId = $job["id"] ?? "unknown";
$this->log("WARNING", "任务处理失败: {$jobId}, 错误: {$e->getMessage()}");
if ($attempts < $this->maxAttempts) {
// 还没到最大重试次数,需要指数退避避免无限重试循环
$backoff = (int) pow(2, $attempts - 1); // 1s, 2s, 4s...
$this->log("INFO", "任务将在 {$backoff} 秒后重试 ({$attempts}/{$this->maxAttempts}): {$jobId}");
sleep($backoff);
// 更新重试计数和最后重试时间
$job["attempts"] = $attempts;
$job["last_retry_at"] = date("Y-m-d H:i:s");
// 从处理队列移除后重新放入主队列
$this->redis->lrem($this->processingQueueName, 1, json_encode($job, JSON_UNESCAPED_UNICODE));
$this->redis->lpush($this->queueName, json_encode($job, JSON_UNESCAPED_UNICODE));
} else {
// 超过最大重试次数,进入死信队列
$this->redis->lrem($this->processingQueueName, 1, json_encode($job, JSON_UNESCAPED_UNICODE));
$this->redis->lpush("queue:order:dead", json_encode([
"job" => $job,
"failed_at" => date("Y-m-d H:i:s"),
"reason" => $e->getMessage(),
"attempts" => $attempts,
], JSON_UNESCAPED_UNICODE));
$this->log("ERROR", "任务进入死信队列: {$jobId}");
}
}
/**
* 处理延迟队列中已到期的任务
* 使用 Sorted Set 实现延迟队列,score 存储到期时间戳
*/
private function processDelayQueue(): void
{
$delayQueueName = "queue:order:delay";
$now = time();
// 获取所有到期任务(score <= 当前时间),每次最多取10个
$jobs = $this->redis->zrangebyscore($delayQueueName, 0, $now, ["limit" => [0, 10]]);
foreach ($jobs as $jobJson) {
// 先从延迟队列中移除(原子操作,防止重复消费)
$removed = $this->redis->zrem($delayQueueName, $jobJson);
if ($removed > 0) {
// 成功移除后再投放到主队列
$this->redis->lpush($this->queueName, $jobJson);
}
}
}
// ========== 业务方法(示例)==========
private function validateOrder(array $orderData): void
{
if (empty($orderData["order_id"]) || empty($orderData["user_id"])) {
throw new InvalidArgumentException("订单数据不完整");
}
}
private function deductInventory(array $orderData): void
{
// 使用 Lua 脚本保证库存扣减的原子性,防止超卖
// Lua脚本返回值意义:
// 大于0:扣减成功(返回剩余库存)
// 0:库存不足
// -1:key不存在(商品不存在)
$luaScript = <<<'LUA'
local stock = redis.call('GET', KEYS[1])
if stock == false then
return -1 -- 库存 key 不存在
end
stock = tonumber(stock)
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
local remaining = redis.call('DECRBY', KEYS[1], ARGV[1])
return remaining -- 返回剩余库存
LUA;
foreach ($orderData["goods"] ?? [] as $goods) {
$goodsId = $goods["id"];
$num = $goods["num"];
$key = "goods:{$goodsId}:stock";
$remaining = $this->redis->eval($luaScript, [$key], 1, $num);
// PHP判断逻辑:大于0才是成功
if ($remaining === false || $remaining < 0) {
throw new RuntimeException("商品 {$goodsId} 库存扣减失败");
}
if ((int)$remaining === 0) {
throw new RuntimeException("商品 {$goodsId} 库存不足");
}
// 只有 remaining > 0 才是成功
}
}
private function createOrderRecord(array $orderData): int
{
// 实际项目中应该是数据库插入
// 这里用日志模拟
$this->log("INFO", "订单记录已创建: order_id={$orderData['order_id']}");
return $orderData["order_id"];
}
private function sendNotification(int $orderId, array $orderData): void
{
// 实际项目中是调用短信/推送服务
// 这里用 sleep 模拟耗时操作
$this->log("INFO", "通知已发送: order_id={$orderId}");
}
private function logOrder(int $orderId, array $orderData): void
{
$this->log("INFO", "操作日志已记录: order_id={$orderId}");
}
private function log(string $level, string $message): void
{
$timestamp = date("Y-m-d H:i:s");
echo "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
}
}
// ============ 启动消费者 ============
// 推荐使用 supervisor 管理进程
// 命令行运行:php order_consumer.php
// 守护进程运行:nohup php order_consumer.php > /var/log/order_consumer.log 2>&1 &
$consumer = new OrderConsumer();
$consumer->run();
4.3 ThinkPHP6 队列用法(补充)
如果你使用 ThinkPHP6,可以使用官方 topthink/think-queue 组件,封装度更高:
php
<?php
// TP6 生产者
use think\facade\Queue;
public function createOrder()
{
$orderData = [
"order_id" => 10086,
"user_id" => 9527,
"total_amount" => 10998.00,
];
// 1. 立即执行
Queue::push(\app\job\OrderJob::class, $orderData, "order");
// 2. 延迟 30 秒执行(订单超时取消场景)
Queue::later(30, \app\job\OrderJob::class, $orderData, "order");
return "订单已提交";
}
php
<?php
// TP6 消费者 Job 类
namespace app\job;
use think\queue\Job;
class OrderJob
{
public function fire(Job $job, array $data): void
{
$orderId = $data["order_id"] ?? 0;
try {
// 处理订单逻辑
$this->processOrder($data);
// 删除任务
$job->delete();
} catch (\Exception $e) {
// 失败后最多重试3次
if ($job->attempts() > 3) {
$job->delete(); // 放弃
} else {
$job->release(10); // 10秒后重试
}
}
}
private function processOrder(array $data): void
{
// 业务逻辑
}
}
bash
# 命令行启动消费者(TP6)
php think queue:work --queue order --tries 3
五、常见坑及解决方案
坑一:消息丢失------没 ACK,挂了就没了
问题:消费者处理任务时进程崩溃,任务没处理完,但已经从队列里删了。
解决:
- 使用 BRPOPLPUSH (或 BRPOP)将任务转移到处理中队列,处理完成后再手动删除。
- 开启 Redis AOF 持久化 :
appendfsync everysec或always。 - 重要任务用数据库做消息表 (最保险),状态机:
待处理 → 处理中 → 已完成/失败。
坑二:消息重复消费------幂等没做,同一个订单扣了两次钱
问题:消费者处理超时,触发了重试,同一个任务被执行了两遍。
解决 :幂等设计,每个任务带唯一 ID,处理前先查重:
php
private function processJob(array $job): void
{
$jobId = $job["id"];
// 用 Redis SETNX 做分布式锁,防止并发处理
$lockKey = "lock:job:{$jobId}";
$lock = $this->redis->set($lockKey, 1, ["NX", "EX" => 60]);
if (!$lock) {
$this->log("WARNING", "任务正在被其他进程处理: {$jobId}");
return; // 幂等:重复任务直接跳过
}
try {
// 检查是否已处理过(用 Redis 或数据库记录)
$processedKey = "processed:job:{$jobId}";
if ($this->redis->exists($processedKey)) {
$this->log("INFO", "任务已处理过,跳过: {$jobId}");
return;
}
// 执行业务逻辑......
$this->doRealWork($job["data"]);
// 标记已处理(设置过期时间,防止内存泄漏)
$this->redis->setex($processedKey, 86400 * 7, 1); // 7天后自动清除
} finally {
$this->redis->del($lockKey);
}
}
坑三:队列积压------消费者太少,任务越堆越多
问题:高峰期任务大量涌入,消费者处理不过来,队列越来越长,用户感受到明显延迟。
解决:
- 增加消费者数量 :部署多个消费者实例(使用
supervisor或 K8s HPA 自动扩缩容)。 - 分区(Stream Consumer Group):多个消费者分工处理不同消息。
- 监控告警:设置队列长度阈值,超过阈值触发报警。
- 限流保护:在入口处做流量控制,避免瞬时洪峰。
php
// 监控脚本示例
$queueLength = $redis->llen("queue:order");
if ($queueLength > 10000) {
// 触发告警(接入钉钉/飞书/Sentry)
$this->sendAlert("队列积压警告!当前长度: {$queueLength}");
}
坑四:顺序问题------先下单后发货,结果先发了货
问题 :Redis List 是 FIFO 队列,但多消费者并发处理时,先入队的消息不一定先处理完。
解决:
- 单消费者:严格的 FIFO,但吞吐量受限。
- 按订单 ID 哈希分区:相同订单的消息始终路由到同一个消费者。
- 依赖关系设计 :发货依赖下单完成,用延迟队列 或状态机控制顺序。
php
// 按订单ID哈希确保同一订单消息到同一消费者
$consumerGroup = "order_group_" . (crc32($orderId) % $consumerCount);
六、生产环境 Checklist
上线队列功能前,逐项检查:
- Redis 开启持久化 (AOF + RDB 双保险,
appendfsync everysec) - Redis 设置密码,禁止公网访问**
- 消费者进程使用 Supervisor 管理,配置自动重启
- 消息 ACK 机制已实现(BRPOPLPUSH 方案或手动 ACK)
- 幂等去重已实现(防重复消费)
- 死信队列已建立(失败任务有归宿)
- 队列长度监控告警(超过阈值报警)
- 消费者处理耗时监控(Prometheus + Grafana)
- 重试次数限制(避免无限重试)
- 延迟队列方案已设计(超时取消、延时发货等场景)
- 消费者支持水平扩展(多个实例部署)
- 环境配置文件分离(生产密码不通用于代码仓库)
总结
回到开头那个问题:订单总丢,怎么办?
答案不是"多加点日志",也不是"换个更强的服务器"。
答案是在架构层面做异步化:用队列把"用户下单"和"业务处理"解耦。
- 同步处理:用户等得起,业务等不起。
- 异步队列:用户秒响应,业务慢慢来。
队列不是什么高大上的技术,但它解决的是最实在的问题:让你的系统在高峰期不崩溃、在故障后不丢数据、在扩容时不多花冤枉钱。
当然,队列不是银弹。它引入了复杂性:需要监控、运维、重试机制、幂等设计。但这些都是值得付出的代价------因为相比用户丢单、赔偿纠纷,队列的运维成本低太多了。
行动项清单
- 今晚:review 现有代码,找出所有同步处理的高风险点(库存、支付回调、通知)
- 本周:引入 Redis,为核心流程配置异步队列(参考文中的生产者/消费者代码)
- 本周:实现消息 ACK + 幂等去重机制(参考"坑二"解决方案)
- 两周内:配置 Supervisor 管理消费者进程,设置队列长度监控告警
- 上线前:完成 Checklist 逐项检查,确认 Redis 持久化和安全配置
相关阅读推荐:
💡 有问题?评论区见! 关注作者,第一时间获取 PHP 工程化实战系列文章。