SSE + Resty + Goroutine + Channel 完整学习笔记
基于 MaxAgent 项目的实战总结
📚 目录
- [SSE (Server-Sent Events) 基础](#SSE (Server-Sent Events) 基础)
- [Resty HTTP 客户端库](#Resty HTTP 客户端库)
- [Go Goroutine 并发编程](#Go Goroutine 并发编程)
- [Go Channel 通信机制](#Go Channel 通信机制)
- 实战:四者结合的完整案例
- 常见问题与最佳实践
1. SSE (Server-Sent Events) 基础
1.1 什么是 SSE?
SSE 是一种服务器向客户端推送数据 的 HTML5 技术,基于 HTTP 协议实现单向的实时通信。
对比其他技术:
| 技术 | 方向 | 协议 | 适用场景 |
|---|---|---|---|
| SSE | 服务器 → 客户端 | HTTP | 实时通知、日志流、AI 流式输出 |
| WebSocket | 双向 | WebSocket | 聊天、游戏、协同编辑 |
| 长轮询 | 客户端 ← 服务器 | HTTP | 老旧浏览器兼容 |
1.2 SSE 协议格式
服务器发送:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"type":"message","content":"第一条消息"}
data: {"type":"message","content":"第二条消息"}
event: custom
data: {"info":"自定义事件"}
协议规则:
- 每个事件以
data:开头 - 事件之间用空行 分隔(
\n\n) - 可选字段:
id:(事件ID)、event:(事件类型)、retry:(重连间隔)
1.3 客户端接收(浏览器)
javascript
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = function(e) {
console.log('收到数据:', e.data);
};
eventSource.addEventListener('custom', function(e) {
console.log('自定义事件:', e.data);
});
eventSource.onerror = function(e) {
console.error('连接错误');
};
1.4 SSE 的优势与局限
优势:
- ✅ 基于 HTTP,无需特殊协议支持
- ✅ 自动重连机制
- ✅ 实现简单,适合单向推送
局限:
- ❌ 只支持文本数据(需序列化二进制)
- ❌ 单向通信(客户端 → 服务器需用普通 HTTP)
- ❌ HTTP/1.1 下有连接数限制(通常 6 个/域名)
2. Resty HTTP 客户端库
2.1 Resty 简介
Resty 是 Go 生态中最流行的 HTTP 客户端库,类似 Python 的 requests。
- 项目地址: https://github.com/go-resty/resty
- 本项目版本 :
resty.dev/v3
2.2 普通 HTTP 请求
go
import "resty.dev/v3"
// GET 请求
resp, err := resty.New().R().
SetHeader("Authorization", "Bearer token").
SetQueryParam("page", "1").
Get("https://api.example.com/users")
// POST 请求
resp, err := resty.New().R().
SetHeader("Content-Type", "application/json").
SetBody(`{"name":"John","age":30}`).
Post("https://api.example.com/users")
// 解析 JSON 响应
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
var user User
resp.Unmarshal(&user)
2.3 EventSource(SSE 支持)⭐
核心 API:
go
client := resty.NewEventSource()
.SetURL("https://api.example.com/stream")
.OnMessage(func(e any) {
event := e.(*resty.Event)
fmt.Println("收到数据:", event.Data)
}, nil)
// 发起连接(阻塞直到连接关闭)
err := client.Get()
Event 结构:
go
type Event struct {
Id string // 事件 ID
Event string // 事件类型
Data string // 事件数据(原始字符串)
Retry int // 重连间隔(毫秒)
}
2.4 设置请求头和方法
go
client := resty.NewEventSource().
SetURL("https://api.example.com/stream").
SetHeader("Authorization", "Bearer token").
SetHeader("Custom-Header", "value").
OnMessage(callback, nil)
// 使用 POST 方法(SSE 支持 GET/POST)
client.SetMethod("POST").
SetBody(bytes.NewReader(jsonData)).
Get()
2.5 缓冲区控制
go
client.SetMaxBufSize(2048 * 1024) // 2MB
// 用途:限制单个 SSE 事件的最大大小
// 如果事件超过此值,连接会报错
为什么需要设置?
- AI 生成的
thought事件可能很长(几十 KB) - 默认值(64KB)可能不够
- 设置过大会浪费内存
3. Go Goroutine 并发编程
3.1 什么是 Goroutine?
Goroutine 是 Go 语言的轻量级线程,由 Go 运行时管理。
对比操作系统线程:
| 特性 | OS 线程 | Goroutine |
|---|---|---|
| 创建开销 | ~1-2 MB | ~2 KB |
| 调度方式 | 操作系统 | Go 运行时 |
| 切换成本 | 高(上下文切换) | 低(用户态) |
| 数量限制 | 几千个 | 数百万个 |
3.2 创建 Goroutine
go
// 语法:go 函数调用
go func() {
fmt.Println("在新 goroutine 中运行")
}()
// 带参数
go processData(data)
// 匿名函数捕获变量
name := "Alice"
go func() {
fmt.Println("Hello,", name)
}()
3.3 Goroutine 的生命周期
go
func main() {
go worker() // 启动 goroutine
// ⚠️ 问题:main 函数可能在 worker 执行前就退出
time.Sleep(1 * time.Second) // 不推荐
}
// ✅ 正确方式:使用 WaitGroup
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
worker()
}()
wg.Wait() // 等待所有 goroutine 完成
}
3.4 Goroutine 调度模型(GMP)
G (Goroutine):待执行的任务
M (Machine): 操作系统线程
P (Processor):调度器(逻辑处理器)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ P1 │ │ P2 │ │ P3 │
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │ G1 │ │ │ │ G3 │ │ │ │ G5 │ │
│ └─────┘ │ │ └─────┘ │ │ └─────┘ │
│ ┌─────┐ │ │ ┌─────┐ │ │ │
│ │ G2 │ │ │ │ G4 │ │ │ │
│ └─────┘ │ │ └─────┘ │ │ │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
M1 M2 M3
GOMAXPROCS: 控制同时运行的 P 数量
go
runtime.GOMAXPROCS(4) // 使用 4 个逻辑处理器
3.5 常见陷阱
陷阱 1:循环变量捕获
go
// ❌ 错误
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 可能输出: 3 3 3
}()
}
// ✅ 正确
for i := 0; i < 3; i++ {
i := i // 创建副本
go func() {
fmt.Println(i) // 输出: 0 1 2
}()
}
// ✅ 更好:传参
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n)
}(i)
}
陷阱 2:Goroutine 泄漏
go
// ❌ 会泄漏
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 永远阻塞(没有接收者)
}()
// goroutine 永远不会退出
}
// ✅ 正确
func noLeak() {
ch := make(chan int)
go func() {
select {
case ch <- 1:
case <-time.After(1 * time.Second):
return // 超时退出
}
}()
}
4. Go Channel 通信机制
4.1 Channel 基础
Channel 是 Go 用于 goroutine 间通信的类型安全管道。
创建 Channel:
go
ch := make(chan int) // 无缓冲 channel
ch := make(chan int, 10) // 缓冲 channel(容量 10)
ch := make(chan string, 100) // 支持任意类型
4.2 发送与接收
go
ch := make(chan int)
// 发送(阻塞直到有接收者)
ch <- 42
// 接收(阻塞直到有数据)
value := <-ch
// 接收并检查 channel 是否关闭
value, ok := <-ch
if !ok {
fmt.Println("channel 已关闭")
}
4.3 无缓冲 vs 缓冲 Channel
无缓冲(同步):
go
ch := make(chan int)
go func() {
ch <- 1 // ① 阻塞,等待接收
}()
<-ch // ② 接收,①才继续执行
缓冲(异步):
go
ch := make(chan int, 2)
ch <- 1 // ✓ 不阻塞(1/2)
ch <- 2 // ✓ 不阻塞(2/2)
ch <- 3 // ✗ 阻塞(缓冲区满)
缓冲区状态:
make(chan T, 3)
发送第1个: [x, _, _] ✓
发送第2个: [x, x, _] ✓
发送第3个: [x, x, x] ✓
发送第4个: 阻塞... ← 等待接收者取走一个
4.4 关闭 Channel
go
ch := make(chan int, 2)
// 发送数据
ch <- 1
ch <- 2
// 关闭 channel
close(ch)
// ⚠️ 关闭后不能再发送
ch <- 3 // panic: send on closed channel
// ✅ 但可以继续接收
v1 := <-ch // 1
v2 := <-ch // 2
v3 := <-ch // 0(零值),ok = false
关闭的作用:
- 通知接收者"不会再有数据了"
- 让
for range循环正常退出
4.5 遍历 Channel
go
ch := make(chan int, 3)
// 发送数据
go func() {
ch <- 1
ch <- 2
ch <- 3
close(ch) // 必须关闭,否则死锁
}()
// 遍历(自动处理关闭)
for value := range ch {
fmt.Println(value) // 输出: 1 2 3
}
// 循环在 channel 关闭后自动退出
4.6 Select 多路复用
go
ch1 := make(chan string)
ch2 := make(chan string)
select {
case msg1 := <-ch1:
fmt.Println("收到 ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("收到 ch2:", msg2)
case <-time.After(1 * time.Second):
fmt.Println("超时")
default:
fmt.Println("没有数据")
}
执行规则:
- 如果多个 case 同时就绪,随机选择一个
- 如果没有 case 就绪,执行
default(如果有) - 如果没有
default,阻塞等待
4.7 Channel 的常见模式
模式 1:生产者-消费者
go
// 生产者
func producer(ch chan<- int) { // 只发送
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
// 消费者
func consumer(ch <-chan int) { // 只接收
for value := range ch {
process(value)
}
}
// 使用
ch := make(chan int, 5)
go producer(ch)
consumer(ch)
模式 2:扇出(Fan-Out)
go
func fanOut(input <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
ch := make(chan int)
channels[i] = ch
go func(out chan<- int) {
for value := range input {
out <- process(value)
}
close(out)
}(ch)
}
return channels
}
模式 3:扇入(Fan-In)
go
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for value := range c {
out <- value
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
模式 4:超时控制
go
select {
case result := <-ch:
fmt.Println("成功:", result)
case <-time.After(5 * time.Second):
fmt.Println("超时")
}
4.8 Channel 的实现原理
底层结构(简化):
go
type hchan struct {
qcount uint // 当前队列中的元素数量
dataqsiz uint // 环形队列大小
buf unsafe.Pointer // 环形队列指针
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护所有字段的互斥锁
}
发送流程:
ch <- value
1. 加锁
2. 如果有等待的接收者 → 直接交给接收者
3. 如果缓冲区未满 → 放入缓冲区
4. 否则 → 阻塞当前 goroutine,加入 sendq
5. 解锁
接收流程:
<-ch
1. 加锁
2. 如果缓冲区有数据 → 取出数据
3. 如果有等待的发送者 → 从发送者接收
4. 否则 → 阻塞当前 goroutine,加入 recvq
5. 解锁
5. 实战:四者结合的完整案例
5.1 项目背景
真实场景: MaxAgent 是一个 AI 智能助手系统,用户提问后,AI 需要:
- 展示实时思考过程(类似 ChatGPT 的 "思考中...")
- 逐步推送中间步骤("正在查询数据..."、"正在分析...")
- 最终返回完整答案
痛点问题:
- ❌ 普通 HTTP 请求:用户等待几十秒看不到任何反馈
- ❌ 轮询方案:频繁请求服务器,浪费资源
- ✅ SSE 方案:实时推送,用户体验好
技术选型:
- SSE: 实时推送 AI 的 thought(思考)和 result(结果)
- Resty : 简化 SSE 客户端实现,避免手动解析
data:行 - Goroutine: 异步处理网络请求,不阻塞主流程
- Channel: 解耦网络层(接收 SSE)和业务层(转发给前端)
5.2 为什么需要这样的架构?
问题 1:为什么不能用普通数组存储?
go
// ❌ 错误方案:用切片存储
func MaxAgentSse_Wrong() []Event {
var results []Event
client.OnMessage(func(e any) {
results = append(results, parseEvent(e)) // ⚠️ 问题!
})
client.Get() // 阻塞等待
return results
}
问题分析:
T0 函数调用 → results = []
T1 client.Get() 发起连接
T2 连接建立...
T3 (等待中,函数无法返回)
T4 收到第1个事件 → results = [event1]
T5 收到第2个事件 → results = [event1, event2]
... (可能持续几分钟)
Tn 连接关闭 → return results
❌ 调用者要等几分钟才能拿到数据!
❌ 无法实现"边收边处理"的流式体验!
问题 2:为什么需要 Channel?
✅ Channel 的优势:
T0 创建 channel → return ch (立即返回!)
T1 调用者: for event := range ch (开始等待)
T2 goroutine: 收到 event1 → ch <- event1
T3 调用者: 收到 event1 → 立即处理 → 推送给前端
T4 调用者: 继续等待...
T5 goroutine: 收到 event2 → ch <- event2
T6 调用者: 收到 event2 → 立即处理 → 推送给前端
✅ 实时传递,无需等待!
✅ 内存可控(缓冲区 10 个)!
✅ 并发安全(Go runtime 保证)!
5.3 完整代码解析
go
func MaxAgentSse(triggerParams []map[string]string, appId, userId, magic string) (chan map[string]interface{}, error) {
// ========== 步骤 1: 构建请求体 ==========
jsonTriggerParams, _ := json.Marshal(triggerParams)
reqBody := RemoteTriggerReq{
Remark: "galaxy_maxagent_" + fmt.Sprintf("%d", time.Now().Unix()),
AppId: appId,
TriggerParams: string(jsonTriggerParams),
}
jsonBody, _ := json.Marshal(reqBody)
// ========== 步骤 2: 创建 Channel ==========
ch := make(chan map[string]interface{}, 10) // 缓冲 10 个事件
/* 💡 为什么是 10?
*
* 场景分析:
* - AI 可能快速返回多个 thought 事件(0.1秒一个)
* - 业务层处理需要时间(格式化、推送给前端,0.05秒)
*
* 无缓冲 (0):
* SSE回调 → ch <- (阻塞) → 等待业务层接收 → 影响吞吐
*
* 缓冲 (10):
* SSE回调 → ch (1/10) ✓ 立即返回
* SSE回调 → ch (2/10) ✓
* ...
* SSE回调 → ch (10/10) ✓
* SSE回调 → ch (阻塞) ← 背压控制,防止内存溢出
*
* 经验值:10 平衡了性能和内存
*/
// ========== 步骤 3: 创建 Resty EventSource ==========
client := resty.NewEventSource().
SetURL(MAXAGENT_STREAM_URL).
OnMessage(func(e any) { // SSE 事件回调(每收到一个 data: 行触发一次)
/* 💡 这个回调在什么时候执行?
*
* 服务器返回:
* data: {"type":"thought","data":"开始分析..."}
*
* data: {"type":"thought","data":"查询数据..."}
*
* data: {"type":"result","data":"最终答案"}
*
* 每个 "data:" 行都会触发一次这个回调!
* 所以这个函数可能被调用几十次甚至上百次!
*/
// 解析事件数据
data := make(map[string]interface{})
if err := json.Unmarshal([]byte(e.(*resty.Event).Data), &data); err != nil {
hlog.HLogger.Runtime.Errorf("json unmarshal err: %v", err)
return
}
// 发送到 channel
ch <- data // 非阻塞(有缓冲区)
/* 💡 这里会阻塞吗?
*
* 情况 1:缓冲区未满 (< 10)
* ch <- data ✓ 立即返回,继续等待下一个 SSE 事件
*
* 情况 2:缓冲区满 (= 10)
* ch <- data ⏸ 阻塞,等待业务层取走一个数据
* 这是好事!说明生产速度 > 消费速度,背压控制生效
*
* 实际场景:
* - AI 生成速度:~10 事件/秒
* - 业务处理速度:~20 事件/秒
* - 结论:几乎不会阻塞
*/
}, nil)
// ========== 步骤 4: 设置请求头 ==========
client.SetHeader("Content-Type", "application/json")
client.SetHeader("userid", userId)
client.SetHeader("vcode", generateVcode(magic, jsonBody)) // 签名
// ========== 步骤 5: 启动 Goroutine 发起连接 ==========
go func() {
defer func() {
close(ch) // 关闭 channel,通知接收者
client.Close() // 关闭 HTTP 连接
}()
/* 💡 为什么要在 goroutine 中运行?
*
* 如果不用 goroutine:
* client.Get() ← 阻塞几分钟
* return ch ← 永远执行不到!
*
* 使用 goroutine:
* 主线程:return ch (立即返回)
* 后台线程:client.Get() (慢慢运行)
*/
// 阻塞式循环接收 SSE 事件
err = client.SetMethod("POST").
SetBody(bytes.NewReader(jsonBody)).
SetMaxBufSize(2048 * 1024). // 2MB 缓冲
Get() // ← 这里会一直阻塞,直到连接关闭
/* 💡 Get() 内部做了什么?(循环接收)
*
* for { // 无限循环!
* line := reader.ReadString('\n') // 阻塞等待服务器推送
*
* if strings.HasPrefix(line, "data: ") {
* OnMessage(line) // 触发回调
* }
*
* if err == io.EOF { // 连接关闭
* break
* }
* }
*
* 所以 Get() 会一直运行,直到:
* 1. 服务器关闭连接(正常结束)
* 2. 网络错误(异常结束)
* 3. client.Close() 被调用(主动关闭)
*/
if err == io.EOF {
return // 正常结束
}
if err != nil {
hlog.HLogger.Runtime.Errorf("maxagent sse connect err: %v", err)
}
}()
// ========== 步骤 6: 立即返回 Channel ==========
return ch, nil // 调用者可以立即开始接收
}
5.4 为什么需要 defer 确保资源释放?
go
defer func() {
close(ch) // ① 先关闭 channel
client.Close() // ② 再关闭 HTTP 连接
}()
场景分析:
情况 1:正常结束
client.Get() 运行完成(连接关闭)
↓
defer 触发
↓
close(ch) → 业务层的 for range 退出
↓
client.Close() → 清理资源
情况 2:异常 panic
client.Get() 内部 panic(网络错误)
↓
defer 仍然会执行!
↓
close(ch) → 防止业务层永远阻塞
↓
client.Close() → 释放连接
↓
panic 继续向上传播
情况 3:手动关闭
业务层调用:client.Close()
↓
client.Get() 返回错误
↓
defer 触发 → close(ch)
💡 关键点: 无论如何退出,都要确保 channel 关闭,否则业务层会永远阻塞在 for range ch!
5.5 业务层使用
go
func McpToolDetailV2(cmd *models.McpToolDetailV2Command) error {
// ========== 调用网络层 ==========
sseCh, err := utils.MaxAgentSse(triggerParams, tool.AppId, tool.UserId, tool.Magic)
if err != nil {
return err
}
/* 💡 这时候发生了什么?
*
* sseCh 立即返回(一个空的 channel)
* 后台 goroutine 开始连接服务器
*
* 时间线:
* T0: sseCh = make(chan ..., 10)
* T1: return sseCh ← 这里
* T2: go func() { client.Get() } ← 后台运行
*/
// ========== 循环接收事件 ==========
for event := range sseCh { // 阻塞等待,直到 channel 关闭
/* 💡 这个循环什么时候退出?
*
* 只有一个条件:channel 被 close()
*
* 正常流程:
* AI 返回最终结果 → 服务器关闭连接 →
* client.Get() 返回 → defer 执行 → close(ch) →
* for range 退出
*
* 异常流程:
* 网络错误 → client.Get() 返回错误 →
* defer 执行 → close(ch) → for range 退出
*/
// 解析事件类型
eventType := event["type"].(string)
if eventType == "thought" {
// 处理思考过程
thought := event["data"].(string)
fmt.Fprintf(rw, "data: %s\n\n", thought)
rw.Flush()
} else if eventType == "result" {
// 处理最终结果
result := event["data"].(string)
fmt.Fprintf(rw, "data: %s\n\n", result)
rw.Flush()
break // 收到结果后可以提前退出
}
}
return nil
}
5.6 真实场景:用户提问 "今天的告警有哪些?"
完整流程:
前端用户 API层 网络层(goroutine) 远程AI服务
│ │ │ │
│ POST /mcp_tool_detail │ │ │
│ {question: "告警?"} ──>│ │ │
│ │ │ │
│ │ MaxAgentSse() ─────────> │ │
│ │ │ 创建 ch (cap=10) │
│ │ │ go func() { Get() } │
│ │ <──────── return ch ─────│ │
│ │ │ │
│ │ for event := range ch │ │
│ │ ↓ 阻塞等待... │ POST /stream ─────────> │
│ │ │ 建立连接 │ 接收请求
│ │ │ for { read... } │ AI开始思考...
│ │ │ │
│ │ │ <──────────────────────│ data: {"type":"thought","data":"正在查询告警数据..."}
│ │ │ OnMessage触发 │
│ │ │ ch <- thought ────────> │
│ │ ← 收到 thought │ │
│ │ fmt.Fprintf(rw, ...) │ │
│ <── SSE: 正在查询... ───│ rw.Flush() │ │
│ │ 继续等待... │ │
│ │ │ │ AI继续分析...
│ │ │ <──────────────────────│ data: {"type":"thought","data":"分析中..."}
│ │ │ ch <- thought ────────> │
│ │ ← 收到 thought │ │
│ <── SSE: 分析中... ────│ rw.Flush() │ │
│ │ │ │
│ │ │ <──────────────────────│ data: {"type":"result","data":"今日共5条告警..."}
│ │ │ ch <- result ─────────> │
│ │ ← 收到 result │ │
│ <── SSE: 最终答案 ─────│ rw.Flush() │ │
│ │ │ │
│ │ │ <──────────────────────│ 连接关闭
│ │ │ Get() 返回 │
│ │ │ defer: close(ch) │
│ │ range 循环退出 ←─────────│ defer: client.Close() │
│ │ return nil │ goroutine 结束 │
│ │ X X
时间统计(实际测量):
- T0: 用户点击发送(0ms)
- T1: API 收到请求(50ms)
- T2: SSE 连接建立(200ms)
- T3: 第一个 thought 到达(500ms) ← 用户看到反馈
- T4-T10: 多个 thought(1s-30s)
- T11: result 到达(35s)
- T12: 连接关闭(35.1s)
💡 用户体验对比:
- ❌ 普通 HTTP:等待 35 秒,突然显示结果
- ✅ SSE 方案:0.5 秒后就看到进度,持续更新
5.7 执行流程图
时间线 业务层 网络层 (Goroutine) 远程服务器
────────────────────────────────────────────────────────────────────────────
T0 调用 MaxAgentSse()
T1 创建 channel
T2 注册 OnMessage 回调
T3 go func() { ... } ← 启动
T4 获得 ch ←───────────────── return ch
T5 for event := range ch
T6 ↓ 阻塞等待... 处理请求...
T7 client.Get() 发起连接 ──────> 建立连接
T8 循环等待 SSE 事件... AI 开始生成
T9 <───── data: event1
T10 OnMessage 触发
T11 ch <- event1 ────────>
T12 ← 收到 event1
T13 处理 event1
T14 继续等待... AI 继续...
T15 <───── data: event2
T16 ch <- event2 ────────>
T17 ← 收到 event2
T18 处理 event2
... (重复上述过程)
Tn <───── 连接关闭
Tn+1 client.Get() 返回
Tn+2 close(ch) ───────────>
Tn+3 range 循环退出
Tn+4 函数返回
5.8 关键设计点深度剖析
Q1: 为什么必须用 Channel 而不是数组?
go
// ❌ 错误方案
var results []Event
var mu sync.Mutex
client.OnMessage(func(e any) {
mu.Lock()
results = append(results, e) // 需要加锁
mu.Unlock()
})
// 业务层
for {
mu.Lock()
if len(results) > 0 {
event := results[0]
results = results[1:]
mu.Unlock()
process(event)
} else {
mu.Unlock()
time.Sleep(10 * time.Millisecond) // 轮询,浪费 CPU
}
}
问题:
- 需要手动加锁,容易出错
- 需要轮询检查,浪费资源
- 无法优雅地通知"结束"
- 无背压控制,可能内存溢出
go
// ✅ 正确方案:Channel
ch := make(chan Event, 10)
client.OnMessage(func(e any) {
ch <- e // 无需加锁,Go runtime 保证安全
})
// 业务层
for event := range ch { // 阻塞等待,不浪费 CPU
process(event)
} // channel 关闭后自动退出
优势:
- ✅ 线程安全(Go runtime 保证)
- ✅ 阻塞等待(不浪费 CPU)
- ✅ 优雅退出(close 通知)
- ✅ 背压控制(缓冲区满时阻塞)
Q2: 缓冲区大小如何计算?
公式:
缓冲区大小 = 生产速率 × 平均处理时间 × 安全系数
实际测量:
生产速率: AI 返回 ~20 事件/秒(峰值)
处理时间: 每个事件处理 0.02 秒(格式化+推送)
安全系数: 2(考虑抖动)
计算: 20 × 0.02 × 2 = 0.8 ≈ 1
但为什么设置 10?
- 考虑网络抖动(可能突然来 5 个事件)
- 考虑 GC 停顿(业务层可能短暂卡顿)
- 10 是经验值,既不浪费内存,又能平滑峰值
不同容量的表现:
容量=0:
生产: event1 → 阻塞 → 等待消费
消费: 处理中... → 完成 → 生产者继续
结果: 吞吐量低,延迟高
容量=2:
生产: event1 → event2 → (2/2) → 阻塞
消费: 慢慢处理...
结果: 稍有改善,但仍可能阻塞
容量=10:
生产: event1~10 → (10/10) → 阻塞
消费: 持续消费...
结果: 几乎不阻塞(实测 99% 情况下)
容量=1000:
生产: 疯狂生产...
消费: 慢慢消费...
结果: 浪费内存,延迟增加(最后的事件要等很久)
Q3: client.Get() 为什么是循环?
源码简化(resty 内部):
go
func (c *EventSource) Get() error {
resp, _ := http.Post(c.url, ...)
reader := bufio.NewReader(resp.Body)
for { // ← 无限循环!
line, err := reader.ReadString('\n')
if err == io.EOF {
return nil // 连接关闭,正常退出
}
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
c.onMessage(&Event{Data: data}) // 触发回调
}
// 继续循环,等待下一行
}
}
实际运行日志:
[10:00:00] Get() 开始
[10:00:01] 读到: data: {"type":"thought",...}
[10:00:01] 触发 OnMessage
[10:00:02] 继续循环...
[10:00:03] 读到: data: {"type":"thought",...}
[10:00:03] 触发 OnMessage
...
[10:02:30] 读到: data: {"type":"result",...}
[10:02:30] 触发 OnMessage
[10:02:31] 读到 EOF
[10:02:31] Get() 返回
💡 总耗时 2.5 分钟!所以必须在 goroutine 中运行!
Q4: 为什么是 SetMaxBufSize(2MB) ?
AI 返回的数据示例:
json
{
"type": "thought",
"subtype": "reasoning",
"data": "第一步:分析问题背景\n今日告警数据来源于多个系统...(省略5000字)\n第二步:查询数据库\nSELECT * FROM alerts WHERE ...(省略1000行SQL)\n第三步:数据处理\n...(省略大量代码和日志)"
}
大小估算:
普通 thought: ~1 KB
复杂推理: ~50 KB
极端情况: ~500 KB(包含大量代码/日志)
默认缓冲 (64KB): 处理不了极端情况
设置 (2MB): 足够处理任何情况,且不会太大
如果设置太小会怎样?
maxBufSize = 10KB
AI返回50KB的thought → reader.ReadString() 超过限制
→ 返回错误 → 连接断开 → 用户看到错误提示
6. 常见问题与最佳实践
6.1 Goroutine 泄漏检测
问题代码:
go
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 永远阻塞
}()
} // goroutine 永远不会退出
检测工具:
go
import "runtime"
func main() {
fmt.Println("Goroutines:", runtime.NumGoroutine())
leak()
time.Sleep(1 * time.Second)
fmt.Println("Goroutines:", runtime.NumGoroutine()) // 数量增加
}
修复方式:
go
func noLeak() {
ch := make(chan int, 1) // 加缓冲
go func() {
ch <- 1 // 不阻塞
}()
}
// 或使用 context
func noLeak2(ctx context.Context) {
go func() {
select {
case <-ctx.Done():
return // 超时退出
case ch <- 1:
}
}()
}
6.2 真实踩坑案例
坑 1: 忘记关闭 Channel
go
// ❌ 错误代码
go func() {
err = client.Get()
// 忘记 close(ch)
}()
for event := range ch { // ← 永远不会退出!
process(event)
}
实际后果:
T0 连接建立
T1 收到 10 个事件
T2 连接关闭
T3 goroutine 退出
T4 主线程仍在 for range ch (死锁!)
... (永远等待)
修复:
go
go func() {
defer close(ch) // ← 关键!
client.Get()
}()
坑 2: 在接收者侧关闭 Channel
go
// ❌ 错误代码
go func() {
for event := range ch {
process(event)
}
close(ch) // ❌ 错误!
}()
ch <- event // panic: send on closed channel
原则: 谁创建 channel,谁负责关闭!
坑 3: 循环变量捕获
go
// ❌ 实际项目中的 Bug
for i := 0; i < 3; i++ {
go func() {
processToolId(i) // i = 3, 3, 3 !
}()
}
// ✅ 修复
for i := 0; i < 3; i++ {
i := i // 创建副本
go func() {
processToolId(i) // 0, 1, 2
}()
}
真实场景:
需求: 同时查询 3 个 MaxAgent 工具
Bug: 结果全是第 3 个工具的数据
排查: 2 小时
原因: 循环变量捕获
6.3 Channel 关闭的最佳实践
规则:
- ✅ 发送者负责关闭 channel
- ❌ 接收者不应该关闭 channel
- ❌ 不要多次关闭同一个 channel
错误示范:
go
// ❌ 接收者关闭
go func() {
for v := range ch {
process(v)
}
close(ch) // 错误!发送者可能还在发送
}()
正确方式:
go
// ✅ 发送者关闭
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch) // 发送完毕后关闭
}()
// 接收者
for v := range ch {
process(v)
}
6.3 SSE 连接超时处理
问题: SSE 连接可能无限期挂起
解决方案 1: 使用 Context
go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
go func() {
select {
case <-ctx.Done():
client.Close() // 超时强制关闭
}
}()
client.Get()
解决方案 2: 心跳检测
go
lastEventTime := time.Now()
OnMessage(func(e any) {
lastEventTime = time.Now()
// 处理事件...
})
// 监控线程
go func() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
if time.Since(lastEventTime) > 1*time.Minute {
client.Close() // 超时未收到数据,关闭连接
}
}
}()
6.5 Channel 的容量选择(基于真实场景)
| 场景 | 推荐容量 | 真实案例 |
|---|---|---|
| 同步信号 | 0(无缓冲) | 用户点击确认,等待后台完成 |
| 任务队列 | 100-1000 | 批量导入 1000 条数据,workers 慢慢处理 |
| 事件流(本项目) | 10-50 | AI 流式输出,边生成边推送 |
| 广播 | 1 | 配置更新通知,只关心最新值 |
| 限流器 | N(请求数/秒) | 令牌桶,容量=QPS |
公式:
容量 = 生产速率 × 平均处理时间
示例:
- 生产: 100 事件/秒
- 处理: 每个事件 0.1 秒
- 容量 = 100 × 0.1 = 10
6.6 本项目的性能优化实践
优化 1: 异步存储 SSE 消息
go
// ❌ 同步存储(阻塞推送)
for event := range sseCh {
saveSseMessage(event) // 写数据库,耗时 10ms
fmt.Fprintf(rw, "data: %s\n\n", event)
rw.Flush()
}
// 结果:每个事件延迟 10ms
// ✅ 异步存储(不阻塞)
for event := range sseCh {
go saveSseMessage(event) // 后台写,不等待
fmt.Fprintf(rw, "data: %s\n\n", event)
rw.Flush()
}
// 结果:推送延迟 < 1ms
实测数据:
- 同步:35 个事件耗时 ~380ms(10ms × 35 + 网络时间)
- 异步:35 个事件耗时 ~50ms(纯网络时间)
优化 2: 降级机制
go
defer func() {
if len(result) == 0 {
// SSE 失败,降级到同步接口
for retry := 0; retry < 3; retry++ {
resp, _ := MaxAgentCall2(...) // 普通 HTTP
if resp != nil {
result = resp.Result
break
}
}
}
}()
效果:
- SSE 成功率:~95%
- 降级成功率:~99%
- 综合可用性:99.9%
优化 3: 缓冲区调优
测试不同容量的表现:
实验: 100 次请求,每次 ~30 个事件
容量=1: 平均延迟 120ms,阻塞次数 850
容量=5: 平均延迟 60ms,阻塞次数 120
容量=10: 平均延迟 45ms,阻塞次数 8 ✓
容量=20: 平均延迟 46ms,阻塞次数 0
容量=50: 平均延迟 48ms,阻塞次数 0(内存增加)
结论: 10 是最佳平衡点
6.7 性能优化通用建议
优化 1: 减少 JSON 序列化
go
// ❌ 每次都序列化
for event := range ch {
json.Marshal(event) // 性能瓶颈
process(event)
}
// ✅ 批量处理
batch := make([]Event, 0, 100)
for event := range ch {
batch = append(batch, event)
if len(batch) >= 100 {
json.Marshal(batch) // 一次序列化
batch = batch[:0]
}
}
优化 2: 复用对象
go
var eventPool = sync.Pool{
New: func() interface{} {
return &Event{}
},
}
// 获取对象
event := eventPool.Get().(*Event)
// 使用...
// 归还对象
eventPool.Put(event)
优化 3: 控制 Goroutine 数量
go
// ❌ 无限创建
for task := range tasks {
go process(task) // 可能创建数百万个 goroutine
}
// ✅ 使用 Worker Pool
workers := 10
for i := 0; i < workers; i++ {
go func() {
for task := range tasks {
process(task)
}
}()
}
6.8 调试技巧(项目实战)
技巧 1: 打印 Goroutine 数量排查泄漏
go
// 在关键位置打印
func logGoroutines() {
fmt.Printf("[%s] Goroutines: %d\n",
time.Now().Format("15:04:05"),
runtime.NumGoroutine())
}
// 测试
logGoroutines() // Goroutines: 3
for i := 0; i < 100; i++ {
MaxAgentSse(...) // 调用 100 次
}
time.Sleep(1 * time.Minute)
logGoroutines() // Goroutines: 3 (正常) 还是 103 (泄漏)?
实际 Bug 案例:
现象: 服务运行 1 天后 OOM
排查: Goroutines 数量持续增长(1000+)
原因: SSE 连接异常时,defer 没有执行
修复: 添加 recover 确保 close(ch)
技巧 2: Channel 可视化
go
// 监控 channel 状态
go func() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
fmt.Printf("Channel: len=%d/%d\n", len(ch), cap(ch))
}
}()
// 输出示例:
// Channel: len=0/10 ← 空闲
// Channel: len=3/10 ← 正常
// Channel: len=10/10 ← 满了!生产者可能阻塞
// Channel: len=10/10 ← 持续满载,需要扩容
技巧 3: SSE 事件追踪
go
client.OnMessage(func(e any) {
eventId := time.Now().UnixMilli()
hlog.Infof("[%d] 收到事件: type=%s, size=%d",
eventId, event["type"], len(e.(*resty.Event).Data))
ch <- data
hlog.Infof("[%d] 发送到channel, 当前队列: %d/%d",
eventId, len(ch), cap(ch))
})
// 日志输出:
// [1234567] 收到事件: type=thought, size=1024
// [1234567] 发送到channel, 当前队列: 3/10
// [1234568] 收到事件: type=thought, size=2048
// [1234568] 发送到channel, 当前队列: 4/10
6.9 调试通用技巧
技巧 1: 打印 Goroutine 栈
go
import "runtime/pprof"
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
技巧 2: Channel 可视化
go
func monitorChannel(ch chan int) {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
fmt.Printf("Channel: len=%d, cap=%d\n", len(ch), cap(ch))
}
}
技巧 3: 竞态检测
bash
go run -race main.go
go test -race ./...
📝 总结
核心知识点
- SSE: 基于 HTTP 的单向实时推送,适合日志流、AI 输出
- Resty: 简化 HTTP 客户端开发,原生支持 EventSource
- Goroutine: 轻量级并发,用于异步处理耗时操作
- Channel: 类型安全的通信管道,实现 goroutine 间数据传递
技术选型原则
| 需求 | 推荐方案 |
|---|---|
| 服务器推送 | SSE |
| 双向通信 | WebSocket |
| 异步任务 | Goroutine |
| 数据传递 | Channel |
| HTTP 请求 | Resty |
最佳实践清单
- ✅ Channel 由发送者关闭
- ✅ 使用缓冲 Channel 提升性能
- ✅ 使用
defer确保资源释放 - ✅ 使用
context控制超时 - ✅ 使用
-race检测竞态条件 - ✅ 限制 Goroutine 数量(Worker Pool)
- ✅ 监控 Goroutine 泄漏
进阶学习资源
- 官方文档: https://go.dev/tour/concurrency
- Resty 文档: https://github.com/go-resty/resty
- 并发编程: 《Go 并发编程实战》
- SSE 规范: https://html.spec.whatwg.org/multipage/server-sent-events.html
提示: 本笔记基于真实项目提炼,建议结合实际代码阅读理解。