最近在做一个智能客服机器人项目,上线后没多久就遇到了大麻烦。平时访问量不大时,系统运行得挺平稳,但一到促销活动或者突发事件,用户咨询量瞬间暴涨,系统就开始“罢工”——页面卡顿、响应超时,甚至整个服务直接挂掉。这让我深刻体会到,对于客服这种直接面向用户、对实时性要求极高的系统,高并发处理能力不是“锦上添花”,而是“生死攸关”。

传统的单体架构把所有功能(用户管理、对话引擎、知识库查询、会话记录)都打包在一个应用里。这种架构在开发初期确实简单快捷,但随着业务增长和流量激增,它的弊端暴露无遗:任何一个模块的瓶颈(比如复杂的意图识别计算)都会拖累整个系统;扩容只能整体扩容,资源浪费严重;一个非核心功能故障可能导致整个服务不可用,也就是常说的“服务雪崩”。

系统架构演进

为了解决这些问题,我们决定对系统进行彻底的架构升级,核心目标就是支撑每秒5000+的稳定请求。经过一番调研和选型,我们最终敲定了基于微服务架构的解决方案。

技术选型:为什么是Spring Cloud Alibaba?

在微服务框架的选择上,我们主要对比了Spring Cloud和Apache Dubbo。Dubbo在RPC性能上确实有优势,通信效率高,但它的生态相对封闭,服务治理功能需要自己整合。而Spring Cloud生态更为完整,提供了服务发现、配置中心、网关、熔断等一整套“全家桶”,社区活跃,学习成本相对较低。

我们最终选择了Spring Cloud Alibaba,主要是看中了它“开箱即用”和“生产级强化”的特点。它基于Spring Cloud标准,无缝集成,同时提供了Nacos(服务发现与配置)、Sentinel(流量控制)、RocketMQ(消息队列)等阿里内部经过海量流量验证的组件。特别是Nacos,同时支持服务注册发现和配置管理,比Eureka+Config的组合更轻量、更易用,大大简化了我们的运维部署。对于需要快速落地、稳定优先的项目来说,这套组合拳非常合适。

核心实现:拆解高并发三驾马车

确定了技术栈,接下来就是具体的架构设计和实现。我们的核心思路是:解耦、异步、缓存、限流

1. 请求异步化:用RocketMQ削峰填谷

客服请求的典型特点是瞬时高峰。用户发送一条消息后,系统需要经过意图识别、知识库检索、答案生成等多个步骤才能回复,这个过程可能耗时几百毫秒。如果所有请求都同步处理,大量线程会被阻塞等待,迅速耗尽资源。

我们的解决方案是引入RocketMQ,将同步调用改为异步消息驱动。用户请求到达网关后,立即被封装成消息发送到RocketMQ,并快速返回用户“消息已接收,正在处理”。后端有专门的“对话处理”服务集群作为消费者,从消息队列中拉取任务进行处理,处理完成后,再将结果通过WebSocket或消息推送的方式返回给前端。

这样做的好处是,前端请求的响应时间变得极短(仅消息入队时间),用户体验好。后端服务可以按照自己的处理能力匀速消费,避免了流量洪峰的冲击,实现了“削峰填谷”。即使后端处理暂时变慢,消息也会堆积在队列中,不会导致前端服务崩溃。

2. 状态外部化:基于Redis的分布式会话管理

在微服务架构下,用户的会话状态(如当前对话上下文、历史记录)不能再存在单个应用的内存中,因为请求可能被路由到任何一个服务实例。我们将所有会话状态存储到Redis中,实现分布式会话管理。

我们为每个会话创建一个唯一的sessionId作为Redis的key,将对话上下文、用户信息等序列化成JSON存储为value,并设置合理的TTL(生存时间)。这样,任何一个服务实例都能通过sessionId获取到完整的会话状态,实现了服务的无状态化,为水平扩容打下了基础。

3. 稳定性保障:用Sentinel实现熔断与降级

微服务之间调用链路变长,依赖增多,一个慢调用或故障可能层层传递,引发雪崩。我们使用Sentinel来保障系统的韧性。

  • 熔断:当“知识库查询服务”的调用异常比例超过阈值(如50%),Sentinel会熔断对该服务的调用。在接下来的熔断时间窗口内,所有对此服务的调用都会快速失败,直接执行降级逻辑(例如,返回一个默认的提示或从本地缓存中获取简单答案),避免线程被长时间占用。
  • 流控:我们对核心的“消息接收”接口设置了QPS限流。当每秒请求数超过预设值(根据压测结果设定),超出的请求会被立即拒绝,并返回友好提示,如“当前咨询人数过多,请稍后再试”,保护后端服务不被压垮。
  • 系统自适应保护:Sentinel还能监控系统的整体指标,如Load、CPU使用率、平均RT等,在系统濒临崩溃时,主动拒绝一部分请求,确保系统能存活下来。

代码示例:Spring Boot集成RocketMQ

理论说完了,来看看关键代码是怎么写的。这里展示一个简化的消息生产者和消费者示例,包含了重试和死信队列的处理。

首先,在pom.xml中引入依赖:

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.2.3</version>
</dependency>

1. 生产者:接收用户消息并发送到MQ

import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;

@Service
public class ChatMessageProducer {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    // 定义消息主题和标签
    private static final String TOPIC = "CHAT_REQUEST_TOPIC";
    private static final String TAG = "USER_QUERY";

    /**
     * 发送用户聊天消息到RocketMQ
     * @param sessionId 会话ID
     * @param userMessage 用户消息内容
     */
    public void sendUserMessage(String sessionId, String userMessage) {
        // 构建消息体,通常是一个DTO对象
        ChatMessageDTO messageDTO = new ChatMessageDTO(sessionId, userMessage, System.currentTimeMillis());
        
        // 发送同步消息,确保消息成功进入Broker。实际高并发场景可考虑发送异步消息。
        // key设置为sessionId,便于后续追踪和顺序性保证(同一会话的消息发到同一个队列)
        rocketMQTemplate.syncSend(
            TOPIC + ":" + TAG, // 完整目的地:Topic:Tag
            MessageBuilder.withPayload(messageDTO).setHeader("KEYS", sessionId).build()
        );
        
        // 实际项目中,这里会立即返回给前端,告知消息已接收
        // 真正的回复由消费者处理完后,通过WebSocket推送
    }
}

2. 消费者:处理消息并生成回复

import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
// 监听指定的Topic和Consumer Group
@RocketMQMessageListener(topic = "CHAT_REQUEST_TOPIC", 
                         consumerGroup = "CHAT_PROCESSOR_GROUP",
                         selectorExpression = "USER_QUERY", // 消费指定Tag的消息
                         consumeThreadMax = 20) // 调整并发消费线程数
public class ChatMessageConsumer implements RocketMQListener<ChatMessageDTO> {

    @Autowired
    private DialogueService dialogueService; // 业务处理服务
    @Autowired
    private ResultPushService resultPushService; // 结果推送服务

    @Override
    public void onMessage(ChatMessageDTO message) {
        String sessionId = message.getSessionId();
        log.info("收到会话[{}]的消息: {}", sessionId, message.getContent());
        
        try {
            // 1. 核心业务处理:意图识别、知识库查询、回复生成
            String reply = dialogueService.processUserQuery(sessionId, message.getContent());
            
            // 2. 将处理结果推送给前端用户(通过WebSocket或长轮询)
            resultPushService.pushReply(sessionId, reply);
            
        } catch (Exception e) {
            log.error("处理会话[{}]的消息时发生异常: {}", sessionId, message.getContent(), e);
            // 此处抛出异常,RocketMQ会根据重试策略进行消息重试
            throw new RuntimeException("消息处理失败,触发重试", e);
        }
    }
}

关于重试和死信队列: RocketMQ消费者默认在消费失败后会重试16次,每次重试间隔逐渐变长。如果16次后仍然失败,这条消息会被投递到一个特殊的“死信队列”(%DLQ%ConsumerGroupName)。我们需要有另一个后台任务监控死信队列,进行人工干预或持久化失败记录,确保消息不丢失,这是实现最终一致性的重要一环。

性能优化与压测数据

架构改造完成后,我们使用JMeter进行了全面的压力测试。模拟用户从发送消息到收到回复的完整流程。

  • 压测场景:逐步增加并发用户数,持续发送聊天请求。

  • 关键优化点

    1. 连接池优化:调大了数据库、Redis连接池的最大连接数,避免连接不够用。
    2. JVM调优:根据GC日志调整了堆内存大小和垃圾回收器(如使用G1)。
    3. 缓存预热:在服务启动时,将高频知识库内容加载到Redis。
    4. MQ消费并行度:根据消费者机器的CPU核心数,合理设置consumeThreadMax
  • 压测结果(在8核16G * 4台服务器集群下)

    • QPS(每秒查询率):系统稳定处理能力达到 5200 QPS,满足设计目标。
    • 平均响应时间(P95):从网关接收请求到消息成功入队,平均响应时间 < 10ms。用户端感知的“答案返回时间”取决于业务处理耗时,但因其为异步,前端体验流畅。
    • 错误率:在持续高压下,错误率(超时或失败)< 0.1%
    • 系统资源:CPU和内存使用率保持平稳,未出现剧烈抖动。

性能压测监控

避坑指南:那些年我们踩过的坑

在实际开发和上线过程中,我们遇到了不少典型问题,这里分享三个最重要的避坑点。

1. 消息幂等性:同一条消息不能处理两次

网络抖动或消费者重启可能导致RocketMQ重复投递同一条消息。如果处理逻辑不是幂等的,比如重复扣款、重复生成工单,就会造成严重问题。

我们的解决方案

  • 在消费消息时,利用Redis实现一个简易的“已处理标记”。以消息ID业务唯一标识(如sessionId+内容MD5)作为key,设置一个短暂的过期时间(如5分钟)。
  • 在处理前,先检查这个key是否存在。如果存在,说明正在处理或已处理过,直接丢弃消息。
  • 处理成功后,再设置这个key。这里要注意检查(check)和设置(set)的原子性,可以使用Redis的SETNX命令。

2. 分布式锁:控制并发访问共享资源

在“更新会话上下文”或“抢单式客服分配”场景下,需要防止多个线程或服务实例同时修改同一份数据。

正确使用姿势

  • 优先考虑使用Redis的SET resource_name random_value NX PX 30000命令实现分布式锁。NX表示只有key不存在时才设置,PX设置过期时间,random_value(通常用UUID)用于安全释放锁。
  • 获取锁后,业务处理完成,必须使用Lua脚本对比random_value来释放锁,确保不会释放其他客户端的锁。
  • 锁的粒度要细,过期时间要合理设置(比业务处理时间稍长),避免死锁和锁长期占用。

3. 冷启动预热:避免流量“打死”刚启动的服务

在流量高峰期扩容,或服务发布重启后,新实例如果直接承接线上流量,很容易因为JVM未完成JIT编译、缓存为空、数据库连接池未建立等问题,导致请求大量超时甚至实例崩溃。

我们的预热策略

  • 服务层面:结合K8s的readinessProbe(就绪探针),服务启动后,先完成内部组件初始化(如加载缓存、建立连接池),再对外宣告就绪。
  • 流量层面:在网关或负载均衡器配置权重,新启动的实例初始权重很低,随时间逐渐增加至正常水平。
  • 缓存层面:在启动脚本中,调用一个内部接口,主动加载核心缓存数据。

总结与展望

通过这套基于Spring Cloud Alibaba微服务架构的改造,我们的智能客服机器人系统成功扛住了高并发挑战,实现了高性能和高可用。核心经验总结起来就是:异步解耦是应对流量高峰的利器,缓存是提升性能的捷径,而熔断限流则是系统稳定的最后防线

当然,系统优化永无止境。目前我们的意图识别模块还是基于传统的规则和机器学习模型,在复杂、口语化的用户问法面前,有时仍显得力不从心。这引出了一个非常值得探索的开放性问题:如何将当下火热的大语言模型(LLM)与我们现有的高并发架构相结合,来大幅提升意图识别的准确率和泛化能力?

直接调用大模型API,其响应延迟和成本可能无法满足高并发实时场景。一个可能的思路是采用“混合模式”:高频、简单的问题仍由现有快速引擎处理;对于现有引擎置信度低的复杂问题,再异步调用大模型进行深度理解。同时,可以考虑用大模型生成的优质对话数据,反过来优化我们的小模型。这里面涉及到模型部署、流量调度、成本控制等一系列新的架构挑战,也是我们团队下一步重点研究的方向。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐