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 向量库 RAG | FAQ 归一化缓存命中(零模型成本) |
| 会话安全 | 无 | 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}]}
五、挑战题:改参数,看看会怎样
- ⭐ 改成多线程:把 eventGatewayExecutor 从单线程改成 4 线程,重新抓 SSE,观察 token id 乱序现象(比如 id 4,5,7,6,10,9)——再想想"按 sessionId 分区"怎么用
ConcurrentHashMap<sessionId, 单线程队列>实现。答案在AsyncConfig.java的线程池配置。 - ⭐⭐ FAQ 缓存回写:模型回答后,把"用户问题→标准答案"写进 Redis(TTL 1 天),观察
sai.cache.hit指标变化。答案在FaqCacheService.java的 lookup 方法。 - ⭐⭐ 调限流阈值:把限流从 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 不能设 Header | token 传不进去 | token 同时支持 query 参数(教学取舍) |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 13 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
lesson-13/(AgentEvent 统一协议 + RedisEventGateway 事件网关 + CustomerService 动态限流/重试/熔断 + SseEmitter 断线补拉)
主线 10 篇 + 生产增强 3 篇。系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
| 11 | Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢 |
| 12 | Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户 |
| 13 | Spring 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 直接跑。