Spring AI 企业级收官:事件总线 + Redis Pub/Sub + 断线补拉,客服终于敢上线

0 阅读16分钟

Spring AI 企业级收官:事件总线 + Redis Pub/Sub + 断线补拉,客服终于敢上线

作者:鱼宵 | Spring AI 实战精通营 · 第 13 篇

上一课我们做了可观测和透明化,traceId 串起全链路、SSE 命名事件推给前端。自己跑着挺爽,直到架构师 review 时一句话给我干沉默了:"你这个事件管道是自研的内存注册表?生产采样率降到 0.1 的时候 traceId 可能为空,事件不就静默丢了?多实例部署的时候,A 实例产生的事件怎么到得了连在 B 实例上的浏览器?"

我回去翻了一遍代码,还真是四个硬伤:注册表 key 依赖 traceId(采样低了就丢)、单实例内存态(多实例跨不过去)、事件没有统一序号(断线没法补拉)、SSE 断线即丢(没有 Last-Event-ID 重放)。这一课就是把这套自研管道全部推倒换成企业级方案,顺便把限流、熔断、指标、缓存、鉴权全补上。代码全在仓库 lesson-13/ 目录里,clone 下来跑一遍双实例跨实例推送的实测,你会明白"事件驱动解耦"这四个字到底值在哪。

一、核心原理:从自研注册表到企业级事件流

先看对比表,一眼明白这课换了什么:

能力L12 自研做法本课企业级做法
事件解耦traceId 内存注册表Spring 事件总线(ApplicationEventPublisher + @Async)
横向扩展无Redis Pub/Sub(每会话一个 channel)
断线重连无Redis List 存最近 100 条 + Last-Event-ID 补拉
指标无Micrometer,/actuator/metrics 实测
限流/重试/熔断无Resilience4j(按 sessionId 动态限流)
知识问答ES 向量库 RAGFAQ 归一化缓存命中(零模型成本)
会话安全无Redis token 鉴权

1. AgentEvent:统一事件协议

不管里面装的是思考步骤还是工具调用还是吐字,事件格式都一样:type(枚举)+ id(每会话单调递增,断线补拉靠它)+ version + sessionId + traceId + payload。

类比快递面单。不管里面装文件还是冰箱,面单格式都一样。前端拿到面单就知道该渲染成"思考行"还是"打字机文字"。

关键区别:事件只是数据,不含"往哪推"的逻辑。业务层只负责 new 一个事件 publish,至于最后发 Redis channel 还是 Kafka,业务层完全不关心——这就是事件总线解耦。

2. Spring 事件总线:不靠注册表的解耦

ApplicationEventPublisher 是 Spring 容器自带的"内部邮局"。业务层 publishEvent(事件),所有标了 @EventListener 的方法自动收到;加 @Async 就换线程投递。

为什么替代了 L12 的 traceId 注册表?因为事件里直接带着 sessionId,工具方法也通过 Spring AI 的 ToolContext 拿到 sessionId,根本不需要反查"当前请求是谁"。

3. Redis Pub/Sub:横向扩展的关键

事件序列化成 JSON 后 convertAndSend("sai-l13:events:{sessionId}", json) 发到每会话独立的频道。哪个实例挂着这个会话的 SSE,就订阅这个频道,实时收到。

类比:每个会话一个微信群。A 群的消息只有加了 A 群的手机能收到,不管消息是从北京还是上海发出来的。

事件不绑定在产生它的那个实例的内存里——10005 实例处理请求产生事件,浏览器 SSE 连在 10003 上照样收到。L12 的内存注册表永远做不到这一点。

4. Last-Event-ID 断线补拉

SSE 协议规定:服务端每帧可以带 id: 字段;浏览器断线重连时自动带上 Last-Event-ID 请求头。我们把每会话最近 100 条事件存进 Redis List,重连时把 id 之后漏掉的事件补发,再切到实时订阅。就像追剧缓存——断网重连后从上次看到的集数接着放。

5. Resilience4j:限流 / 重试 / 熔断

  • 限流:按 sessionId 动态建限流器(每会话 10 QPS)——注解式 @RateLimiter 是静态的,全会话共用一个,一个用户刷爆全店卡死。
  • 重试:模型调用失败自动再试(最多 3 次,间隔 500ms)。
  • 熔断:失败率超 50% 就"拉闸"10 秒,快速失败不拖垮系统。

类比奶茶店:限流 = 每人每次限购;重试 = 机器坏了自动重做一杯;熔断 = 排队人太多且连续出错时先关门修机器,免得所有人干等。

二、动手:双实例跑起来

环境:Windows + JDK 17 + Maven 3.9+。Redis 容器 redis-l13(端口 6391)和 Zipkin(9412)已在跑。

第 1 步:编译 + 启动主实例(10003)。

cd spring-ai-journey\lesson-13
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
java -jar target\lesson-13-1.0.0.jar --server.port=10003

第 2 步:创建会话 + 提问。

# 先创建会话拿 sessionId + token
Invoke-RestMethod http://localhost:10003/chat/session

# 再用返回的 sessionId/token 提问
Invoke-RestMethod "http://localhost:10003/chat?sessionId=你的sid&message=报销流程是什么?&token=你的token"

第 3 步(横向扩展实测):另开终端起第二实例。

java -jar target\lesson-13-1.0.0.jar --server.port=10005

三、关键代码:三段核心,逐行拆解

第一段:AgentEvent.java——统一事件协议。

package com.springai.lesson13.event;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
 * 统一事件协议:一次问答里所有内部动作的统一记录类。
 * 事件只是数据,不含"往哪推"的逻辑——这是和 L12 StepRecorder 的本质区别。
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AgentEvent {

    public enum Type {
        THINKING, TOOL, TOKEN, CITATIONS, DONE, ERROR, RATE_LIMITED
    }

    private Long id;                        // 每会话单调递增(Redis INCR 分配)
    private final String version = "1.0";   // 协议版本
    private final Type type;
    private final long ts;
    private final String sessionId;         // 决定发到哪个 channel
    private final String requestId;
    private final String traceId;
    private final Object payload;

    public AgentEvent(Type type, String sessionId, String requestId, String traceId, Object payload) {
        this.type = type;
        this.sessionId = sessionId;
        this.requestId = requestId;
        this.traceId = traceId;
        this.payload = payload;
        this.ts = System.currentTimeMillis();
    }

    public static AgentEvent of(Type type, String sessionId, String requestId, String traceId, Object payload) {
        return new AgentEvent(type, sessionId, requestId, traceId, payload);
    }

    // getter / setter 略(id 由网关分配后回填)
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getSessionId() { return sessionId; }
    public Type getType() { return type; }
    public Object getPayload() { return payload; }

    public String typeLower() {  // 转小写直接当 SSE event 名
        return type.name().toLowerCase(java.util.Locale.ROOT);
    }
}

第二段:RedisEventGateway.java——事件网关(核心)。

package com.springai.lesson13.event;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

/**
 * 事件网关:把 Spring 事件总线收到的 AgentEvent,异步投递到 Redis。
 * ① INCR 分配每会话单调序号(跨实例也不重号)
 * ② 落事件日志(List 存最近 100 条)——断线补拉的"缓存"
 * ③ 发到每会话独立 channel——订阅者实时收到(不管哪个实例产生的)
 */
@Component
public class RedisEventGateway {

    public static final String PREFIX = "sai-l13:";
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final MeterRegistry registry;

    public RedisEventGateway(StringRedisTemplate redis, ObjectMapper mapper, MeterRegistry registry) {
        this.redis = redis;
        this.mapper = mapper;
        this.registry = registry;
    }

    @Async("eventGatewayExecutor")   // 独立单线程池:publish 不阻塞业务,且事件严格按序
    @EventListener                   // Spring 总线收到 AgentEvent 就调这里
    public void on(AgentEvent event) {
        try {
            // ① INCR 分配序号(原子操作,跨实例不重号)
            Long id = redis.opsForValue().increment(seqKey(event.getSessionId()));
            event.setId(id);
            String json = mapper.writeValueAsString(event);
            // ② 落事件日志(List 队尾追加,裁掉 100 条之前的)
            redis.opsForList().rightPush(logKey(event.getSessionId()), json);
            redis.opsForList().trim(logKey(event.getSessionId()), -100, -1);
            // ③ 发到该会话 channel:订阅此 channel 的 SSE 实时收到
            redis.convertAndSend(channelKey(event.getSessionId()), json);
            // ④ 顺手记指标
            if (event.getType() == AgentEvent.Type.TOKEN && event.getPayload() instanceof String s) {
                registry.counter("sai.tokens.chars").increment(s.length());
            }
        } catch (Exception e) {
            // 网关故障不能拖挂业务:只打日志
        }
    }

    public static String seqKey(String sid)   { return PREFIX + "seq:" + sid; }
    public static String logKey(String sid)   { return PREFIX + "eventlog:" + sid; }
    public static String channelKey(String sid) { return PREFIX + "events:" + sid; }
}

为什么 @Async?publish 是业务线程调的,同步做 Redis IO 会把几百个 token 事件的往返串进业务链路、拖慢首字延迟。异步线程池里做,业务线程 publish 完立刻往下走。

第三段:CustomerService.java——动态限流 + 重试熔断。

package com.springai.lesson13.service;

import com.springai.lesson13.event.AgentEventPublisher;
import com.springai.lesson13.support.FaqCacheService;
import com.springai.lesson13.tool.CustomerTools;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;
import java.util.Map;

@Service
public class CustomerService {

    private final ChatClient chatClient;
    private final CustomerTools tools;
    private final FaqCacheService faqCache;
    private final AgentEventPublisher events;
    private final RateLimiterRegistry rateLimiterRegistry;

    // 构造注入略

    /** 动态限流:按 sessionId 取/建限流器(每会话 10 QPS,互不影响) */
    public boolean tryAcquire(String sessionId) {
        RateLimiter limiter = rateLimiterRegistry.rateLimiter(sessionId);
        return limiter.acquirePermission();
    }

    /** FAQ 缓存查询(未命中返回 null) */
    public String lookupFaq(String message) {
        return faqCache.lookup(message);
    }

    /** 阻塞式模型调用:注解式 Retry + CircuitBreaker 完整包裹 */
    @Retry(name = "model")
    @CircuitBreaker(name = "model", fallbackMethod = "modelFallback")
    public String chatBlocking(String sessionId, String requestId, String traceId, String message) {
        return chatClient.prompt()
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                .tools(tools)
                .toolContext(Map.of("sessionId", sessionId, "requestId", requestId, "traceId", traceId))
                .user(message)
                .call()
                .content();
    }

    /** 兜底话术:熔断/重试都失败时返回 */
    @SuppressWarnings("unused")
    private String modelFallback(String sessionId, String requestId, String traceId, String message, Exception e) {
        return "抱歉,智能服务暂时繁忙,已为您转接人工客服入口:400-800-8888。";
    }
}

面试点:注解式 @RateLimiter(name="x") 里的 name 是静态字符串,所有会话共用一个;按 sessionId 用 RateLimiterRegistry.rateLimiter(sessionId) 动态取/建,才是"每个会话独立限流"。

四、实测输出:跨实例推送铁证

以下是本机真实运行(DeepSeek,主实例 10003,第二实例 10005,Redis 6391)。

验收① 创建会话 + 401 鉴权:

PS> Invoke-RestMethod http://localhost:10003/chat/session
sessionId : s-e4f35025
token     : 521baa1ed98c41a1bb4e9c77ecab2e3c
expireSeconds : 1800

PS> Invoke-RestMethod "http://localhost:10003/chat?sessionId=s-e4f35025&message=test"
远程服务器返回错误: (401) 未经授权。

不带 token 直接 401——鉴权四步(发凭据→带凭据→校验→过期)跑通。

验收② FAQ 缓存命中(零模型成本):

{"answer":"报销走 OA 系统「费用报销」模块:粘贴发票 → 填写事由和金额 → 部门主管审批 → 财务复核 → 打款到工资卡……","source":"faq-cache","traceId":"6ac51634b31c05111ad1fdb045b5998f"}

对比:非 FAQ 问题走模型(source=model),模型自主调了订单工具。

验收③ SSE 命名事件流(id 严格递增):

id:1
event:thinking
data:收到问题,先查 FAQ 缓存…

id:3
event:tool
data:{"tool":"queryStock","args":"productCode=DOC-ENT","result":"星辰云文档企业版:定制席位余量仅剩 3","costMs":0}

id:4
event:token
data:星辰

...(token 帧省略,id 严格递增)...

id:39
event:done
data:{"traceId":"6ac5172a82a1665c11ce1cae21caf0a6","source":"model"}

验收④ 跨实例推送铁证(本课最值钱的实测):

  • 实例 A:10003 上挂着 SSE(纯订阅,不跑业务);
  • 实例 B:10005 上对同一 sessionId 发起流式提问。

10003 的 SSE 收到的帧(全部由 10005 产生):

id:1
event:thinking
data:收到问题,先查 FAQ 缓存…

id:3
event:tool
data:{"tool":"queryStock","args":"productCode=DOC-ENT","result":"星辰云文档企业版:定制席位余量仅剩 3"}

id:4
event:token
data:星辰

...(token 帧省略)...

id:29
event:done
data:{"traceId":"6ac517bb3ce26fd2c7eef370a2e791eb","source":"model"}

10005 实例的日志里这次请求的 traceId 也是 6ac517bb...——事件经 Redis channel 跨实例到了 10003 的浏览器。这就是"事件网关横向扩展"的铁证,L12 的内存注册表永远做不到。

验收⑤ 指标 /actuator/metrics:

PS> Invoke-RestMethod http://localhost:10003/actuator/metrics/sai.cache.hit
{"name":"sai.cache.hit","measurements":[{"statistic":"COUNT","value":2.0}]}

PS> Invoke-RestMethod http://localhost:10003/actuator/metrics/sai.first.token.latency
{"name":"sai.first.token.latency","baseUnit":"seconds",
 "measurements":[{"statistic":"COUNT","value":1.0},{"statistic":"TOTAL_TIME","value":1.773},{"statistic":"MAX","value":1.773}]}

首 token 延迟 1.77 秒——这就是"模型开口有多快",生产监控的黄金指标。

验收⑥ 限流触发:

同一会话 1 秒内连发 15 次(限制 10 QPS):
成功=10  被限流429=5
{"name":"sai.ratelimit.rejected","measurements":[{"statistic":"COUNT","value":5.0}]}

五、挑战题:改参数,看看会怎样

  1. ⭐ 改成多线程:把 eventGatewayExecutor 从单线程改成 4 线程,重新抓 SSE,观察 token id 乱序现象(比如 id 4,5,7,6,10,9)——再想想"按 sessionId 分区"怎么用 ConcurrentHashMap<sessionId, 单线程队列> 实现。答案在 AsyncConfig.java 的线程池配置。
  2. ⭐⭐ FAQ 缓存回写:模型回答后,把"用户问题→标准答案"写进 Redis(TTL 1 天),观察 sai.cache.hit 指标变化。答案在 FaqCacheService.java 的 lookup 方法。
  3. ⭐⭐ 调限流阈值:把限流从 10 QPS 改成 20 QPS,同一会话 1 秒连发 15 次,看是不是全过了。答案在 application.yml 的 Resilience4j 配置段。

六、生产环境进阶:四个踩坑

1. 事件网关多线程导致 token 乱序。 起初 eventGatewayExecutor 配了 4~8 线程,抓包看到 id 4,5,7,6,10,9 乱序,回答拼成"订单2200(客户李四…"。改成单线程池后严格按序。生产要并行就按 sessionId 分区固定 worker。

2. SSE 订阅建立前的事件丢失。 刚 start 订阅线程就 publish thinking,第一帧落到 channel 上但订阅者还没挂好 → 丢失。加了 200ms 等待订阅注册完成。

3. new String(bytes) 中文乱码。 Redis 里存的是 UTF-8 字节,Windows 默认 GBK 解码直接乱码。必须显式 new String(bytes, StandardCharsets.UTF_8)。

4. EventSource 不能自定义请求头。 浏览器原生 EventSource 只支持 GET、不能设 Header,所以 token 同时支持 query 参数传递(教学取舍)。

七、面试回答模板

面试官:大模型应用的"用户可见事件流"怎么做才生产可用?为什么不能像 demo 那样用内存注册表?

一句话:事件总线解耦 + Redis Pub/Sub 网关 + Last-Event-ID 补拉。展开:内存注册表在生产采样率低时 traceId 可能为空、事件静默丢失;单实例内存态多实例部署跨不过去。本课用 Spring 事件总线(业务 publish、异步网关投递)、Redis Pub/Sub 每会话一个 channel(双实例实测跨实例推送)、Redis List 存最近 100 条配合 Last-Event-ID 断线补拉。(指向本课第一、三节 / lesson-13 的 RedisEventGateway)

追问:按"每个会话"动态限流怎么实现?注解式为什么不够?

注解式 @RateLimiter(name="x") 的 name 是静态字符串,所有会话共用一个限流器,一个用户刷爆全店卡死。按 sessionId 用 RateLimiterRegistry.rateLimiter(sessionId) 动态取/建:第一次来新建、之后复用。生产再加 LRU 清理长期不用的限流器防内存膨胀。(指向本课第三节 CustomerService)

追问:相似问题直接缓存答案怎么做?为什么 FAQ 场景不用向量库?

用户问题先归一化(去空白标点、全角转半角、英文小写),再查 Redis:"年假怎么休?"和" 年假怎么休 "归一化后是同一串字符,直接命中标准答案,不调模型、零成本。确定性问答用精确匹配毫秒级、零模型成本;模糊语义才上向量库。(指向本课第四节验收②)

追问:首 token 延迟怎么统计?为什么是黄金指标?

在流式响应的 doOnNext 里,第一次收到 token 时记录时间差:registry.timer("sai.first.token.latency").record(System.nanoTime() - streamStart)。用户感知的"卡不卡"就是它——首字 1.77 秒和 5 秒体验天差地别。(指向本课第四节验收⑤)

八、总结表

坑现象解法
traceId 注册表采样低丢事件生产采样 0.1 时事件静默消失事件总线 + 事件自带 sessionId,不靠 traceId 反查
单实例内存注册表多实例部署事件跨不过去Redis Pub/Sub 每会话 channel,双实例实测推送
SSE 断线即丢事件用户刷新页面就从头来Redis List 存 100 条 + Last-Event-ID 补拉
事件网关多线程乱序token id 4,5,7,6,9,10单线程池保证顺序;生产按 sessionId 分区
订阅前事件丢失第一帧 thinking 没了等订阅注册完成 200ms 再 publish
new String(bytes) 乱码中文变"鏀跺埌"显式 StandardCharsets.UTF_8 解码
注解式限流全局共用一个用户刷爆全店RateLimiterRegistry 按 sessionId 动态建
EventSource 不能设 Headertoken 传不进去token 同时支持 query 参数(教学取舍)

九、关于这个系列

本文是「Java 后端实战精通营」系列第 13 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-13/(AgentEvent 统一协议 + RedisEventGateway 事件网关 + CustomerService 动态限流/重试/熔断 + SseEmitter 断线补拉)

主线 10 篇 + 生产增强 3 篇。系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官
11Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
12Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户
13Spring AI 企业级收官:事件总线 + Redis Pub/Sub + 断线补拉,客服终于敢上线

系列完结语:从第 1 篇"配好 yml 就能聊"的 Hello World,到第 10 篇攒出一个完整企业智能客服,再到第 11~13 篇把数据落中间件、全链路 traceId、事件总线 + Redis Pub/Sub + 限流熔断——13 课走下来,你手里已经有了一个能上线的智能客服工程。接下来就是把这套东西拿去自己的真实项目里跑一遍、改一改。生产化这条路没有终点:把 Pub/Sub 升级成 Redis Stream 或 Kafka、接 Prometheus + Grafana 画看板、用 Spring Security + JWT 替换教学 token——每一步都是新的面试故事。

跑完有任何报错,把终端输出发评论区,一起排查。


标签建议:SpringAI、事件驱动、Resilience4j 摘要建议(≤256 字):L12 的自研 traceId 内存注册表有四个生产硬伤:采样低丢事件、多实例跨不过去、断线不补拉、无限流。本课推倒重做:AgentEvent 统一协议 + Spring 事件总线 + Redis Pub/Sub 事件网关 + Last-Event-ID 断线补拉 + Resilience4j 动态限流/重试/熔断 + Micrometer 指标。双实例实测跨实例推送铁证,首 token 延迟 1.77s 可观测。源码在 gitee lesson-13 可 clone 直接跑。