背景痛点传统客服系统为何在高并发下“掉链子”最近在做一个客服平台的重构项目深刻体会到老系统在面对流量高峰时的无力感。传统的基于Servlet阻塞IO的架构当并发用户上来后HTTP连接池很快被耗尽新用户连排队的机会都没有就直接看到“服务器繁忙”。这就像节假日的高速收费站只有几个窗口开着后面堵成了长龙。数据库更是重灾区。用户排队状态、坐席分配记录都往几张核心表里写高并发下的锁竞争让数据库响应时间从毫秒级飙升到秒级甚至出现死锁。人工坐席的分配也相当原始基本就是简单的轮询或者随机分配经常出现有的坐席忙得不可开交有的却在“摸鱼”的情况。智能回复的体验就更糟了。基于规则的回复引擎只能处理非常固定的句式用户稍微换个说法就识别不了。更头疼的是上下文断裂问题——用户多轮对话中机器人经常“失忆”同一个问题反复问用户体验极差。这些痛点逼着我们思考如何构建一个既能扛住高并发又能智能调度还能提供连贯对话体验的客服系统。技术选型为高并发客服平台挑选合适的“武器库”技术选型就像配装备得根据实际战场情况来。我们对比了几套方案最终确定了现在的技术栈。消息队列Redis Streams vs Kafka刚开始考虑用Kafka毕竟是大数据场景下的标配。但仔细评估后发现客服消息的实时性要求更高而且消息量虽然大但单条消息体积小持久化压力没那么大。Redis Streams支持消费者组、消息ACK、阻塞读取完全能满足我们的需求更重要的是它和Spring Data Redis集成起来更简单运维成本也低。最终我们选择了Redis Streams作为核心的消息通道。并发模型ThreadPoolExecutor vs Virtual ThreadJDK21的Virtual Thread虚拟线程确实很诱人理论上可以轻松支持百万级并发。但我们考虑到生产环境还在用JDK17而且团队对虚拟线程的调试、监控经验还不够。传统的ThreadPoolExecutor配合响应式编程已经能很好地处理我们的并发场景。我们选择了Spring WebFlux作为Web层用少量的线程处理大量连接再配合弹性线程池处理计算密集型任务。回复策略Rule-Based vs 大语言模型完全基于规则的回复系统扩展性太差维护成本高。而直接调用GPT等大模型虽然聪明但成本高、响应慢。我们走了折中路线先用本地化的轻量NLP模型比如基于BERT微调的意图识别处理常见问题当模型置信度低于阈值时自动转人工同时把对话上下文完整地交给人工坐席。这样既保证了常见问题的快速回复又解决了复杂问题需要人工介入的需求。核心实现从架构到代码的实战拆解1. 非阻塞请求接入Spring WebFlux让连接池不再成为瓶颈我们抛弃了传统的Spring MVC全面拥抱Spring WebFlux。它的核心优势是用少量的Event Loop线程处理大量网络连接特别适合IO密集型的客服消息收发场景。RestController RequestMapping(/api/customer-service) RequiredArgsConstructor public class CustomerServiceController { private final MessageDispatcher messageDispatcher; PostMapping(/message) public MonoApiResponseVoid sendMessage(RequestBody CustomerMessage message) { return messageDispatcher.dispatch(message) .map(ticketId - ApiResponse.success(消息已接收排队中, ticketId)) .onErrorResume(e - Mono.just(ApiResponse.error(e.getMessage()))); } GetMapping(/queue/position/{ticketId}) public MonoApiResponseInteger getQueuePosition(PathVariable String ticketId) { return queueService.getPosition(ticketId) .map(ApiResponse::success) .defaultIfEmpty(ApiResponse.error(排队号不存在)); } }2. 智能排队基于Redis ZSET的动态优先级队列排队不是简单的先进先出我们设计了带优先级的动态排队系统。VIP客户、等待时间过长的客户会被优先处理。Service Slf4j RequiredArgsConstructor public class PriorityQueueServiceImpl implements QueueService { private final RedisTemplateString, String redisTemplate; private static final String QUEUE_KEY cs:queue:waiting; private static final String TICKET_PREFIX TICKET:; Override public MonoString enqueue(CustomerMessage message) { String ticketId TICKET_PREFIX UUID.randomUUID(); double score System.currentTimeMillis(); // VIP客户优先级提高 if (message.isVip()) { score - 1000 * 60 * 60; // VIP提前1小时 } return Mono.fromRunnable(() - redisTemplate.opsForZSet().add(QUEUE_KEY, ticketId, score) ).thenReturn(ticketId); } Override public MonoString dequeue() { // 超时自动清理移除30分钟前的排队记录 redisTemplate.opsForZSet().removeRangeByScore(QUEUE_KEY, 0, System.currentTimeMillis() - 30 * 60 * 1000); // 获取优先级最高的待处理消息 SetString tickets redisTemplate.opsForZSet().range(QUEUE_KEY, 0, 0); if (tickets null || tickets.isEmpty()) { return Mono.empty(); } String ticketId tickets.iterator().next(); return Mono.fromRunnable(() - redisTemplate.opsForZSet().remove(QUEUE_KEY, ticketId) ).thenReturn(ticketId); } }3. 坐席分配加权轮询算法实现负载均衡坐席分配不是简单的轮询要考虑坐席的技能等级、当前负载、历史接单量等因素。Service public class AgentDispatcher { private final AtomicInteger counter new AtomicInteger(0); private final ListAgent agents; private final int[] weights; private final int maxWeight; public AgentDispatcher(ListAgent agents) { this.agents agents; this.weights agents.stream() .mapToInt(Agent::getWeight) .toArray(); this.maxWeight Arrays.stream(weights).max().orElse(1); } public OptionalAgent dispatch() { if (agents.isEmpty()) { return Optional.empty(); } while (true) { int current counter.getAndUpdate(i - (i 1) % (agents.size() * maxWeight)); int agentIndex current % agents.size(); int weightSlot current / agents.size(); if (weightSlot weights[agentIndex]) { Agent agent agents.get(agentIndex); if (agent.isAvailable()) { return Optional.of(agent); } } } } }4. 智能回复与人工接管基于置信度的无缝切换智能回复不是全自动的当模型不确定时要及时转人工。Service Slf4j public class ReplyEngine { private final NlpModel nlpModel; private final AgentService agentService; private static final double CONFIDENCE_THRESHOLD 0.8; public MonoReplyResult process(MessageContext context) { return Mono.fromCallable(() - nlpModel.predict(context.getMessage())) .flatMap(prediction - { if (prediction.getConfidence() CONFIDENCE_THRESHOLD) { // 置信度高直接返回智能回复 return Mono.just(ReplyResult.aiReply(prediction.getAnswer())); } else { // 置信度低转人工 log.info(低置信度回复转人工处理。消息: {}, 置信度: {}, context.getMessage(), prediction.getConfidence()); return transferToHuman(context); } }); } private MonoReplyResult transferToHuman(MessageContext context) { return agentService.assignAgent(context) .flatMap(agent - { // 保存对话上下文供人工坐席参考 context.saveConversationHistory(); return Mono.just(ReplyResult.transferToHuman(agent, context.getSessionId())); }) .switchIfEmpty(Mono.just(ReplyResult.waiting(等待人工坐席接入中...))); } }生产级考量从能用到好用的关键设计1. 压测报告5000并发下的表现我们用JMeter模拟了5000并发用户持续发消息的场景持续压测10分钟。结果让人满意平均响应时间(RT)核心消息接收接口平均RT 85msP95在200ms以内错误率全程错误率低于0.1%主要是网络波动导致的超时系统资源CPU使用率稳定在65%左右内存无显著增长Redis负载QPS峰值达到12000但CPU使用率仅40%关键配置我们为Redis配置了持久化AOF everysec并设置了合适的内存淘汰策略。2. 消息可靠性本地事件表定时补偿分布式环境下消息丢失是必须考虑的问题。我们设计了双重保障机制。Component Slf4j public class MessageReliabilityManager { Transactional(rollbackFor Exception.class) public void saveWithEventTable(Message message) { // 1. 保存到业务表 messageRepository.save(message); // 2. 同时保存到本地事件表 EventRecord event EventRecord.builder() .eventId(UUID.randomUUID().toString()) .eventType(MESSAGE_RECEIVED) .payload(JsonUtils.toJson(message)) .status(EventStatus.PENDING) .createdAt(LocalDateTime.now()) .build(); eventRepository.save(event); } Scheduled(fixedDelay 30000) // 每30秒执行一次 public void compensateFailedEvents() { ListEventRecord failedEvents eventRepository .findByStatusAndCreatedAtBefore( EventStatus.FAILED, LocalDateTime.now().minusMinutes(5) ); failedEvents.forEach(event - { try { retryEvent(event); event.setStatus(EventStatus.PROCESSED); } catch (Exception e) { log.error(事件重试失败: {}, event.getEventId(), e); event.setRetryCount(event.getRetryCount() 1); if (event.getRetryCount() 3) { event.setStatus(EventStatus.ABANDONED); alertService.sendAlert(事件多次重试失败: event.getEventId()); } } eventRepository.save(event); }); } }3. 安全防护限流与敏感词过滤客服系统直接面向用户安全防护必须到位。Configuration public class SecurityConfig { Bean public FilterRegistrationBeanRateLimitFilter rateLimitFilter() { FilterRegistrationBeanRateLimitFilter registration new FilterRegistrationBean(); registration.setFilter(new RateLimitFilter()); registration.addUrlPatterns(/api/customer-service/*); registration.setOrder(1); return registration; } Bean public Bucket4jBucket bucket() { Refill refill Refill.intervally(100, Duration.ofMinutes(1)); Bandwidth limit Bandwidth.classic(100, refill); return Bucket4j.builder().addLimit(limit).build(); } } Component public class SensitiveWordFilter { private final TrieNode root new TrieNode(); public SensitiveWordFilter() { // 初始化敏感词库 ListString sensitiveWords loadSensitiveWords(); sensitiveWords.forEach(this::addWord); } public String filter(String text) { if (StringUtils.isBlank(text)) { return text; } StringBuilder result new StringBuilder(); TrieNode node root; int start 0; int position 0; while (position text.length()) { char c text.charAt(position); node node.getChild(c); if (node null) { result.append(text.charAt(start)); position start 1; start position; node root; } else if (node.isEnd()) { // 发现敏感词替换为* result.append(*.repeat(position - start 1)); position; start position; node root; } else { position; } } result.append(text.substring(start)); return result.toString(); } private static class TrieNode { private final MapCharacter, TrieNode children new HashMap(); private boolean end; public TrieNode getChild(char c) { return children.get(c); } public boolean isEnd() { return end; } } }避坑指南我们踩过的坑希望你能绕过1. Redis大Key问题与分片策略初期我们把所有排队数据放在一个ZSET中当排队人数超过10万时这个ZSET变成了大Key每次操作都很慢。解决方案按业务分片按用户ID哈希分片到不同的Queue Key按时间分片每小时一个队列自动过期清理控制单个集合大小超过5000条就自动创建新分片public String getShardedQueueKey(String userId) { int shard Math.abs(userId.hashCode()) % 16; return String.format(cs:queue:waiting:%d, shard); }2. 坐席状态同步的CAP权衡坐席的在线状态、忙碌状态需要在多个服务实例间同步。我们尝试了三种方案Redis Pub/Sub实时性好但网络分区时可能丢失状态数据库状态表一致性最好但性能有瓶颈最终一致性方案状态先更新到本地缓存再异步同步到Redis最终选择了方案3虽然状态同步有秒级延迟但对业务影响不大且系统更健壮。3. 智能模型冷启动的降级方案NLP模型刚上线时由于训练数据不足识别准确率很低。我们设计了分级降级策略Level 1置信度0.8使用模型回复Level 2置信度0.5-0.8使用模型回复人工审核Level 3置信度0.5直接转人工Level 4模型服务异常降级到规则引擎同时我们建立了反馈循环人工处理的对话会自动打标用于模型迭代训练。延伸思考CQRS模式与监控体系随着系统复杂度增加读写流量的差异越来越明显。查询排队位置、坐席状态等读操作QPS是写操作的10倍以上。我们正在考虑引入CQRS模式写模型处理消息接收、坐席分配等写操作保证强一致性读模型从Redis缓存或只读副本查询提供最终一致性视图同步机制通过CDCChange Data Capture同步写模型到读模型监控方面我们建立了完整的可观测性体系MetricsPrometheus收集QPS、RT、错误率等指标TracingSkyWalking实现全链路追踪LoggingELK集中日志分析写在最后这个客服平台项目从零到一再到支撑每天百万级消息处理中间踩了不少坑也积累了很多经验。技术选型没有银弹关键是要适合业务场景。SpringBoot生态给了我们快速搭建的能力但真正让系统稳定运行的是对细节的打磨和对生产环境的敬畏。目前系统已经稳定运行半年支持了多次营销活动的高并发冲击。我们开源了核心模块的代码和详细的部署文档希望能帮助到有类似需求的团队。项目地址在GitHub上包含了完整的Docker部署脚本和监控配置。做技术就像搭积木不仅要选对积木块还要知道怎么把它们稳固地组合在一起。这个客服平台项目让我对高并发系统设计有了更深的理解也让我明白好的系统不是设计出来的而是在不断迭代中演化出来的。