问题背景与解决方案

问题场景

在实现Excel数据导入功能时,遇到一个典型的生产者-消费者场景:

  1. 主流程:Excel文件解析 → 数据校验 → 数据库事务写入
  2. 附加流程:将成功数据推送给第三方系统

当第三方接口响应缓慢时(实测平均耗时8-12秒),导致整体接口响应时间超出前端等待阈值,造成以下问题:

  • 前端显示系统错误(HTTP 500)
  • 实际业务数据已完整入库
  • 用户体验与数据一致性存在割裂

解决方案演进

  1. 同步方案:直接顺序执行(已存在问题)
  2. 队列方案:Redis队列 + 独立消费者进程(最优解但需额外部署)
  3. 折中方案:PHP进程控制 + Redis临时队列(本文实现方案)

最终采用方案3,在保证系统轻量化的前提下实现异步处理,技术组合:

  • Redis List结构作为临时存储
  • PHP pcntl扩展进行进程控制
  • ThinkPHP6命令行组件实现消费逻辑

技术实现详解

1. Redis队列封装(生产者端)

class RedisQueue extends RedisBase
{
    /**
     * 安全写入队列(支持复杂数据结构)
     * @param string $name 队列名称
     * @param mixed $value 支持字符串/数组/对象
     * @return int 队列长度
     */
    public function rPush(string $name, $value)
    {
        $value = is_scalar($value) ? $value : json_encode($value);
        return $this->handle->rPush($this->getCacheKey($name), $value);
    }

    /**
     * 安全读取队列(自动反序列化)
     * @param string $name 队列名称
     * @return mixed 原始数据类型
     */
    public function lPop(string $name)
    {
        $value = $this->handle->lPop($this->getCacheKey($name));
        return json_decode($value, true) ?? $value;
    }
}

设计要点

  • 自动序列化/反序列化处理
  • 兼容标量值与复杂数据结构
  • 继承RedisBase实现连接池管理

2. 异步触发机制

private function send_import_sku_to_yjt($data)
{
    $redis = new RedisQueue();
    $queueName = 'yjt_sku_import';
    
    // 数据分批入队(避免大消息体)
    foreach (array_chunk($data, 100) as $batch) {
        $redis->rPush($queueName, $batch);
    }

    // 构建异步命令
    $rootPath = root_path();
    $command = sprintf(
        'php74 %sthink jiayi sendSkuToYjt -r %s -p %s &> /dev/null &',
        $rootPath,
        escapeshellarg($queueName),
        escapeshellarg(json_encode(['operator' => $this->getCurrentUser()]))
    );

    // 非阻塞执行
    pclose(popen($command, 'r')); 
}

关键技术点

  • popen():创建并行进程,非阻塞执行
  • escapeshellarg():防止命令注入攻击
  • 后台运行符&:脱离当前进程控制
  • 输出重定向:&> /dev/null丢弃日志

3. 命令行消费者(守护进程)

class Jiayi extends Command
{
    public function sendSkuToYjt(Input $input)
    {
        $queueName = $input->getOption('redis_name');
        $context = json_decode($input->getOption('params'), true);

        $redis = new RedisQueue();
        $retryCount = 0;

        while ($batch = $redis->lPop($queueName)) {
            try {
                $this->processBatch($batch, $context);
                $retryCount = 0; // 重置重试计数器
            } catch (Exception $e) {
                if ($retryCount++ < 3) {
                    $redis->rPush($queueName, $batch); // 重新入队
                    sleep(pow(2, $retryCount)); // 指数退避
                } else {
                    $this->logError($e, $batch);
                }
            }
        }
    }

    private function processBatch($batch, $context)
    {
        $yjtData = YJTSku::convertYJTSkuData($batch);
        $yjtData['yjt_token'] = YJTUtil::getToken($batch[0]['company_id']);
        
        AbsYJT::sendYJT(
            YJTUrlKey::ADD_SKU,
            $yjtData,
            $context['operator']
        );
    }
}

消费者特性

  • 失败重试机制(3次指数退避)
  • 上下文传递(操作人信息)
  • 异常处理与日志记录
  • 批量处理支持

系统架构原理

image.png

方案优势分析

  1. 响应速度优化

    • 主流程耗时从12s+降至200ms内
    • 前端立即获得成功反馈
  2. 系统可靠性

    • Redis持久化保证数据不丢失
    • 重试机制应对第三方系统不稳定
    • 进程隔离避免主流程崩溃
  3. 资源利用率

    • 按需创建消费者进程
    • 无常驻进程占用资源
    • 可平滑过渡到专业队列系统
  4. 可观测性

    • Redis队列长度监控
    • 失败记录与告警机制
    • 操作日志审计追踪

生产环境建议

  1. 安全增强

    // 增加队列名前缀隔离
    private function getCacheKey($name)
    {
        return config('app.env').':queue:'.$name;
    }
    
    // 命令执行增加权限校验
    if (!in_array(get_current_user(), ['www-data', 'nginx'])) {
        exit('Permission denied');
    }
  2. 性能调优

    • 调整PHP-FPM的max_children配置
    • 设置Redis内存淘汰策略为volatile-lru
    • 监控队列堆积告警(通过Redis的LLEN命令)
  3. 高可用方案

    • 部署多个消费者实例
    • 使用Supervisor进程管理
    • 设置Redis哨兵模式

本方案在保证系统轻量化的前提下,有效解决了同步接口的超时问题。后续若业务量增长,可通过以下步骤平滑升级:

  1. 引入RabbitMQ/Kafka专业消息队列
  2. 部署独立的消费者集群
  3. 增加流量控制与熔断机制

该模式适用于中小型系统的异步任务处理,特别是临时性、低频次的业务场景,能有效平衡开发成本与系统性能。


白穹雨
31 声望1 粉丝

热爱技术,热爱生活。学过Java,现在从事PHP。