在构建后台管理系统或运维平台时,“实时日志查看”是一个非常高频的需求。传统的做法往往是前端通过 setInterval 每隔几秒轮询一次接口,这种方式不仅时效性差,而且对服务器造成了大量无效请求。

WebSockets 虽然是全双工通信的标准解决方案,但对于“服务器只管发,前端只管收”的日志场景来说,显得过于“重”了(需要处理心跳、握手协议、双向数据帧等)。

今天我们来探讨一种更轻量、更优雅的方案:Server-Sent Events (SSE),并结合 Go 的并发特性,实现一个生产级可用的日志推送服务。

为什么选择 SSE (Server-Sent Events)?

SSE 是基于 HTTP 协议的一种单向通信机制。相比 WebSockets,它有以下显著优势:

  1. 轻量级:直接使用 HTTP 协议,无须额外的握手协议升级。
  2. 断线重连:浏览器原生支持 EventSource API,内置了自动重连机制。
  3. 开发简单:服务端只需设置 Content-Type: text/event-stream,前端零依赖。
  4. 防火墙友好:完全复用标准的 HTTP 端口(80/443)。

核心架构设计:The Broker Pattern

为了解耦“日志生成者”和“日志消费者(前端客户端)”,我们采用 Broker(代理人)模式

下面通过一个时序图来展示整个数据流转的过程:

  sequenceDiagram
    participant Generator as Log Generator
    participant Broker as Broker (Go Channel)
    participant Client as Web Client (Browser)

    Note over Generator, Broker: 1. 日志生产阶段
    loop Every 0.5-1.5s
        Generator->>Broker: Send Log (Broadcast Chan)
    end

    Note over Broker, Client: 2. 广播处理阶段
    par Broadcast Loop
        Broker->>Broker: Read from Broadcast Chan
        Broker->>Broker: Save to History
        loop For Each Client
            Broker-->>Client: Try Send (Non-blocking)
            alt Channel Full
                Broker--xClient: Drop Message (Protect Server)
            else Channel Open
                Broker->>Client: Send to Client Chan
            end
        end
    end

    Note over Client, Broker: 3. SSE 推送阶段
    Client->>Broker: HTTP GET /events
    Broker-->>Client: Headers (text/event-stream)
    Broker-->>Client: Push History Logs
    loop Keep-Alive
        Broker->>Client: Push Real-time Log (data: {...})
        Client->>Client: Parse JSON & Render DOM
    end

1. 数据结构设计

我们定义核心的 Broker 结构体:

1
2
3
4
5
6
type Broker struct {
    clients   map[chan Log]struct{} // Set of all clients
    mutex     sync.RWMutex          // Protect clients map
    broadcast chan Log              // Inbound log channel
    history   []Log                 // Log cache for replay
}

这里有一个细节:clients 的 key 是 chan Log。这意味着每个连接的客户端都有一个专属的 Go Channel,这为我们后续实现精细化控制奠定了基础。

2. 避免“慢消费者”阻塞系统

在其它的即时通讯实现中,最容易踩的坑就是:一个卡顿的客户端导致整个广播过程阻塞

如果直接通过 clientChan <- msg 发送,一旦某个客户端网络断开或者接收缓冲满了,整个 Broker 的广播循环就会卡死在这里,导致所有人都收不到日志。

解决方案是利用 Go 的 select + default 特性,逻辑如下:

  graph TB
    Start([新日志消息到达]) --> Iterate[遍历所有客户端]
    
    Iterate --> CheckChannel{Channel 是否已满?}
    
    CheckChannel -->|否<br/>有空间| SendSuccess[✓ 发送消息成功<br/>clientChan ← msg]
    CheckChannel -->|是<br/>缓冲区满| DropMsg[✗ 丢弃消息<br/>default 分支]
    
    SendSuccess --> HasMore{还有其他客户端?}
    DropMsg --> HasMore
    
    HasMore -->|是| Iterate
    HasMore -->|否| End([广播完成])
    
    style Start fill:#e1f5ff,stroke:#0288d1,stroke-width:2px
    style End fill:#e1f5ff,stroke:#0288d1,stroke-width:2px
    style SendSuccess fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style DropMsg fill:#ffcdd2,stroke:#d32f2f,stroke-width:2px
    style CheckChannel fill:#fff9c4,stroke:#f57c00,stroke-width:2px
    style Iterate fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px

核心代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 广播逻辑
for clientChan := range b.clients {
    select {
    case clientChan <- msg:
        // 发送成功
    default:
        // 关键逻辑!
        // 如果 clientChan 满了(客户端由于网络原因读不过来),
        // 我们选择直接跳过(Drop)这条消息,而不是阻塞整个系统。
    }
}

这种非阻塞式广播的设计,保证了服务的高可用性。


核心代码解析

启动 HTTP 服务

在 Gin 中实现 SSE 非常简单。重点在于 HTTP Header 的设置和 Response 的 Flush。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (b *Broker) ServeHTTP(c *gin.Context) {
    // 1. 设置 SSE 必备的 Headers
    c.Writer.Header().Set("Content-Type", "text/event-stream")
    c.Writer.Header().Set("Cache-Control", "no-cache")
    c.Writer.Header().Set("Connection", "keep-alive")

    // ... (省略部分代码)

    // 6. 实时推送循环
    for {
        select {
        case msg := <-clientChan:
            // 格式化为 SSE 要求的 data: ...\n\n 格式
            c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", msg.String())))
            c.Writer.Flush() // 必须 Flush,否则数据会积压在缓冲区
        case <-c.Request.Context().Done():
            return
        }
    }
}

JSON 序列化的优化

在实战中,我们发现频繁的 json.Marshal 可能会成为 CPU 热点。我们可以给 Log 结构体预定义一个 

1
2
3
4
5
func (l Log) String() string {
    // 简单场景也可用 strings.Builder 手动拼接,性能更极致
    bts, _ := json.Marshal(l)
    return string(bts)
}

前端实现(真的只有几行)

得益于 EventSource,前端代码极其简洁:

1
2
3
4
5
6
const evtSource = new EventSource("/events");

evtSource.onmessage = function(event) {
    const log = JSON.parse(event.data);
    appendLog(log);
};