从线上故障到架构演进:我在Coze平台的技术探索与实践总结

邓强
2025-06-25 10:40
阅读 541

开篇:为什么要做一次这样的技术复盘?

开篇:为什么要做一次这样的技术复盘?

我是Coze团队的一名开发者,加入这个项目的时候,它还是一个处于高速迭代期的内部孵化产品。简单介绍一下背景:Coze是面向AI Agent开发者的低代码平台,允许用户通过图形化界面快速搭建、调试和部署自己的AI Agent应用。我们的目标是让没有太多编程基础的人也能轻松上手,实现“拖拖拽拽”就能构建智能体的功能体验。

在参与 Coze 平台多个版本迭代的过程中,我们遇到了不少挑战,其中一些问题甚至直接导致了生产环境的故障。这篇文章想从我亲身经历的一次典型事故讲起,聊聊我们是怎么一步步识别问题、进行技术选型、优化架构,并最终落地改进方案的过程。希望这篇实践性的内容能给同样做后台服务、低代码平台或者 AI 工具类产品开发的朋友带来启发。


问题描述:上线后持续报错,系统响应变得越来越慢

问题描述:上线后持续报错,系统响应变得越来越慢

时间回到去年 Q3 的一次版本发布之后,我们的日志监控平台上开始频繁出现超时报警,同时页面加载变慢,某些用户操作会卡顿十几秒甚至触发浏览器自动断开连接。一开始我们认为是临时性流量高峰引起的资源瓶颈,于是尝试扩容容器实例,但效果甚微。

进一步分析日志后发现,主要瓶颈集中在 执行引擎模块数据库层。具体表现为:

  • 某些任务链较长的流程节点执行时经常卡在某一环节
  • 数据库中存在大量未完成的记录(status=running)
  • 日志显示任务调度器频繁重试某个节点,导致整个工作流陷入死锁状态
  • 后端接口平均响应时间从原来的 <200ms 上升到接近 2s

而当时我们使用的核心技术栈是 Node.js + Koa + MongoDB,任务引擎基于自定义的工作流驱动机制,采用单线程阻塞式处理节点逻辑。

更糟糕的是,由于平台支持多租户模式,不同用户的数据相互隔离,当个别用户的复杂任务拖慢整体性能时,也会间接影响其他正常用户的访问体验。

这个问题不能等,必须马上解决。


解决方案:重新设计任务调度模型,引入异步事件驱动架构

解决方案:重新设计任务调度模型,引入异步事件驱动架构

面对如此明显的系统瓶颈,我们意识到不能再沿用之前那种简单粗暴的单线程模型。经过几次团队讨论,我们决定从以下几个方面入手进行重构:

1. 改造任务调度模型:从同步阻塞到异步消息队列驱动

之前我们的任务节点是一个接一个顺序执行的,如果中间某一步骤出错或卡住,后续流程就会全部被阻塞,造成用户体验极差。

为此,我们引入了一个轻量级的 消息队列系统(RabbitMQ) 来将任务的每个节点解耦开来,改为事件驱动的方式。

这样做的好处有几个:

  • 每个节点可以独立消费,互不干扰;
  • 节点失败后可以通过 MQ 重试,避免整个任务失败;
  • 能够利用水平扩展能力,按需增加 worker 数量来提升吞吐率;
  • 更容易实现任务进度的可观测性。

2. 数据结构优化:引入 Redis 缓存控制任务状态

原本所有的任务状态都存储在 MongoDB 中,每次读取都要经过 DB 层,压力很大。我们在关键路径上加了一层 Redis,用于缓存任务当前状态和上下文数据。

这样不仅减少了数据库查询次数,也使得任务状态变更更加实时可控。例如:

// 示例:缓存任务状态到 Redis
const updateTaskState = async (taskId, state) => {
  await redis.setex(`task:${taskId}:state`, 60 * 60, state); // 缓存1小时
}

3. 架构分层升级:引入 Worker Pool 管理任务执行单元

为了提高可扩展性和并发能力,我们将原先运行在主进程中的一整套任务执行逻辑抽离出来,作为一组可独立部署的 Worker 进程池,并通过 REST API 或 RPC 方式进行通信。

Worker 进程通过监听 MQ 中的消息来获取待执行的任务节点,每个节点独立执行完成后,再通知下一流程。

这种方式的好处包括:

  • 主进程不再承担实际业务逻辑计算
  • Worker 可以根据负载情况动态扩缩容
  • 容错机制更容易设计和维护

4. 技术选型上的几点权衡

在做这些改造时,我们也考虑过其它替代方案,比如是否选用 Kubernetes Job/Queue 机制,或者完全改用 Serverless 模式(如 AWS Lambda)来处理任务节点。但我们最终选择了 RabbitMQ + 自建 Worker 的组合,主要原因如下:

  • RabbitMQ 部署简单、成熟稳定,运维成本低
  • 我们已经有一套完整的 Node.js 微服务生态,迁移门槛较低
  • 对于长周期任务(>5min),Lambda 的冷启动和超时限制成为障碍

代码实践:核心组件的代码片段示例

代码实践:核心组件的代码片段示例

为了让大家对上述架构设计有个更直观的理解,这里贴几个核心模块的代码片段。

📌 任务入队列逻辑(伪代码)

class TaskQueueService {
  async enqueue(task) {
    const message = JSON.stringify({
      taskId: task._id,
      flowId: task.flowId,
      nodeId: task.currentNodeId
    });
    
    channel.sendToQueue('task_queue', Buffer.from(message));
  }
}

📌 Worker 监听任务队列并执行节点(伪代码)

function startWorker() {
  channel.consume('task_queue', async (msg) => {
    if (!msg) return;

    const payload = JSON.parse(msg.content.toString());
    console.log(`[x] Received ${payload.taskId}`);

    try {
      await executeNode(payload);
      channel.ack(msg); // 成功处理后确认消息
    } catch (err) {
      console.error(`[!] Failed to process ${payload.taskId}`, err);
      channel.nack(msg); // 失败后重新放回队列
    }
  });
}

async function executeNode({ taskId, flowId, nodeId }) {
  const node = await getNode(flowId, nodeId);

  switch(node.type) {
    case 'api_call':
      const result = await callExternalApi(node.config);
      await saveResultToDB(taskId, result);
      break;
    case 'condition':
      const conditionResult = evaluateCondition(node.logic, context);
      await proceedNext(conditionResult ? node.onTrue : node.onFalse);
      break;
    default:
      throw new Error(`Unknown node type: ${node.type}`);
  }

  await updateTaskProgress(taskId);
}

📌 Redis 控制任务状态(部分逻辑)

async function isTaskRunning(taskId) {
  const cachedState = await redis.get(`task:${taskId}:state`);
  return cachedState === 'running';
}

async function setTaskRunning(taskId) {
  await redis.setex(`task:${taskId}:state`, 60 * 60, 'running');
}

踩坑经验:那些没写进文档的小细节

虽然最终这次改造取得了不错的效果,但在过程中也踩了不少坑,以下是我们积累的一些真实经验和教训。

⚠️ 坑一:消息重复消费问题

一开始我们没考虑到 MQ 消息可能会因为网络抖动等原因被重复发送。结果出现了某些任务被重复执行的问题,进而导致数据库中出现重复记录。

解决方案: 引入幂等键(message_id),结合 Redis 缓存记录已处理过的消息 ID:

if (await redis.exists(`processed:${msgId}`)) {
  return channel.ack(msg); // 跳过已处理的消息
}
await redis.setex(`processed:${msgId}`, 60 * 60, 1);

⚠️ 坑二:Worker 死循环导致资源耗尽

我们在测试环境中模拟高并发任务时,Worker 经常出现长时间占用 CPU 不释放的情况。后来发现是因为有些节点的执行函数没有正确退出(比如异步回调未返回、死循环未 break 等)。

解决方案: 为每个 Worker 设置最大执行时间和内存限制,在入口处添加守护进程检查逻辑:

node --max-old-space-size=512 worker.js

并在代码中加入健康检查:

setTimeout(() => {
  console.warn("Task execution timeout, exiting...");
  process.exit(1);
}, 60000);

⚠️ 坑三:日志输出过多影响性能

最初我们把每条任务执行的日志都打印到控制台,结果在高并发场景下 Log 输出变成了新的瓶颈。

解决方案: 将日志写入本地文件,并配置定期上传至日志中心(ELK)。同时对日志级别做了分级控制:

const winston = require('winston');
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'worker.log' })
  ]
});

效果总结:性能显著提升,稳定性大幅提升

这次架构调整上线一个月后,我们观察到了明显的变化:

指标 改造前 改造后
单任务平均执行时间 8s 2.5s
接口 P95 响应时间 2.1s 320ms
系统可用率 98.3% 99.7%
任务失败率 6% 0.7%
扩展能力(Worker 数) 固定 3 个 可动态扩展至 30+

更重要的是,平台的整体稳定性有了质的飞跃。我们再也没有收到因任务积压、执行阻塞引发的用户投诉;同时随着系统的弹性增强,我们在后续版本中还能更快地对接外部服务、接入更多的节点类型。


经验分享:几点建议送给正在走这条路的你

这段从事故修复到架构演进的经历让我深刻体会到,真正的技术成长不是看文档、写 Demo,而是真刀真枪地解决问题。

以下是我想分享给你的一些实战建议,无论你是做平台产品、工具类系统,还是 AI 应用方向:

✅ 1. 不要一开始就追求完美架构

很多人喜欢上来就谈“分布式”、“微服务”,但我们最开始就是一个简单的 Node.js 应用。只有当你真正遇到瓶颈、出现故障、有足够增长压力时,才值得去大规模重构。

✅ 2. 选择合适的技术栈比追逐热门更重要

虽然现在大家都在用 Kafka、Flink、Docker + K8s,但如果你的团队规模小、运维能力有限,不妨先用 RabbitMQ、Redis、PM2 这样的轻量级方案,反而更高效。

✅ 3. 写日志比写代码还重要

特别是在任务型系统里,每一笔日志都是排查问题的救命稻草。建议在设计之初就把日志规范定好,比如:

  • 每个任务要有唯一标识符(traceId)
  • 每个节点操作记录操作人、开始时间、结束时间
  • 错误信息带堆栈信息,并分类标签(error/warning/info)

✅ 4. 异常边界要明确界定

任务系统中,哪些错误需要立即终止?哪些可以重试?哪些应该跳过?这些问题一定要在初期做好设计,否则后期很容易陷入无限兜底的状态。

✅ 5. 永远预留退路,灰度上线是刚需

我们这次改造并没有一上来就全量切换新架构,而是做了双跑机制 —— 新老引擎共存,只对部分用户开放新功能。一旦有问题,可以快速回切。


结语:技术的终点始终是人的价值

回想这次改造过程,除了技术层面的收获,我也在心态上成长了很多。从一开始看到报警就紧张焦虑,到现在面对突发问题能迅速定位、冷静分析,这种转变来自于一次次的实战演练,也来自我们团队的互相配合与信任。

技术本质上是为了解决问题,而问题背后往往隐藏着更多人的需求。作为一名开发者,不仅要理解底层原理,更要站在用户的角度思考:他们到底想要什么?怎样才能让他们感受到“顺畅”和“高效”?

我相信,只要我们坚持不断地探索和优化,总能做出让用户说“这东西真香”的产品。


如果你也在搭建低代码平台、任务调度系统或是 AI 工具类产品,欢迎留言交流经验,也可以私信我一起探讨架构设计。愿我们都能在技术这条路上越走越稳,越走越远。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝