实时性是社交媒体体验的关键差异化因素。用户发布一条短文,期待关注者能即刻感知;收到一条回复,期待立即获得通知。本文将深入微型博客实时推送系统的技术实现,对比 WebSocket、Server-Sent Events 和长轮询三种方案的适用场景,并分别实现消息通知送达、时间线增量更新和在线状态查询三大核心实时功能。
一、实时推送方案对比
1.1 三种主流方案
| 维度 | 长轮询 (Long Polling) | Server-Sent Events (SSE) | WebSocket |
|---|---|---|---|
| 通信方向 | 客户端 → 服务端 | 服务端 → 客户端 | 全双工 |
| 连接数 | 每次请求新建 | 单个 HTTP 连接 | 单个 TCP 连接 |
| 协议开销 | 高(HTTP 头重复) | 中(HTTP/1.1 或 HTTP/2) | 低(帧头极小) |
| 浏览器支持 | 全平台 | 现代浏览器(IE 不支持) | 现代浏览器 |
| 适用场景 | 兼容旧客户端 | 单向推送(通知、日志) | 双向实时通信 |
| 复杂度 | 低 | 低 | 中 |
微型博客的实时需求可以按方向拆解:服务端 → 客户端 的通知推送(有新消息、时间线更新)适合 SSE;客户端 → 服务端 的输入(正在输入、心跳)和 双向 的即时通讯(私信聊天)需要 WebSocket。
1.2 方案选型策略
通知系统(单向推送) → SSE
↓
时间线增量更新 → SSE(或 HTTP/2 Push)
↓
在线状态 + 私信聊天 → WebSocket
↓
客户端心跳 + 输入提示 → WebSocket
在典型实现中,SSE 负责主要的推送负载,WebSocket 负责需要双向交互的场景。两者可以共存,SSE 连接甚至可以降级到长轮询以兼容旧环境。
二、SSE 通知系统实现
2.1 Go SSE 服务端
package sse
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Event 定义 SSE 事件结构
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Data []byte `json:"data"`
}
// Client 表示一个 SSE 连接
type Client struct {
UserID int64
Chan chan Event
}
// Hub 管理所有 SSE 连接
type Hub struct {
clients map[int64]*Client // user_id -> client
register chan *Client
unregister chan *Client
broadcast chan Event
}
func NewHub() *Hub {
return &Hub{
clients: make(map[int64]*Client),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan Event, 100),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client.UserID] = client
case client := <-h.unregister:
if _, ok := h.clients[client.UserID]; ok {
delete(h.clients, client.UserID)
close(client.Chan)
}
case event := <-h.broadcast:
// 定向推送或广播
for _, client := range h.clients {
select {
case client.Chan <- event:
default:
// 客户端缓冲区满,丢弃事件
}
}
}
}
}
func (h *Hub) SendToUser(userID int64, event Event) {
if client, ok := h.clients[userID]; ok {
select {
case client.Chan <- event:
default:
}
}
}
func (h *Hub) SendToUsers(userIDs []int64, event Event) {
for _, uid := range userIDs {
h.SendToUser(uid, event)
}
}
// Handler SSE 连接处理器
func (h *Hub) Handler() gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetInt64("user_id")
if userID == 0 {
c.AbortWithStatus(401)
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
client := &Client{
UserID: userID,
Chan: make(chan Event, 10),
}
h.register <- client
defer func() { h.unregister <- client }()
// 发送初始连接确认
c.SSEvent("connected", fmt.Sprintf(`{"user_id":%d,"time":"%s"}`, userID, time.Now().Format(time.RFC3339)))
// 设置刷新器防止连接超时
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case event, ok := <-client.Chan:
if !ok {
return
}
c.SSEvent(event.Type, string(event.Data))
case <-ticker.C:
// 发送心跳注释保持连接
c.Writer.Write([]byte(":heartbeat\n\n"))
c.Writer.Flush()
case <-c.Request.Context().Done():
return
}
}
}
}
2.2 事件类型定义
package domain
import (
"encoding/json"
"time"
)
type NotificationType string
const (
NotificationNewPost NotificationType = "new_post"
NotificationNewReply NotificationType = "new_reply"
NotificationNewLike NotificationType = "new_like"
NotificationNewFollow NotificationType = "new_follow"
NotificationNewRepost NotificationType = "new_repost"
NotificationMention NotificationType = "mention"
NotificationSystem NotificationType = "system"
)
type Notification struct {
ID string `json:"id"`
Type NotificationType `json:"type"`
UserID int64 `json:"user_id"` // 接收者
ActorID int64 `json:"actor_id"` // 触发者
ActorName string `json:"actor_name"`
PostID *int64 `json:"post_id,omitempty"`
Content string `json:"content,omitempty"`
Read bool `json:"read"`
CreatedAt time.Time `json:"created_at"`
}
func (n *Notification) ToEvent() sse.Event {
data, _ := json.Marshal(n)
return sse.Event{
ID: n.ID,
Type: string(n.Type),
Data: data,
}
}
2.3 业务层集成
package service
import (
"context"
"fmt"
"time"
"miniblog/internal/domain"
"miniblog/internal/repository"
"miniblog/pkg/sse"
)
type NotificationService struct {
repo *repository.NotificationRepository
hub *sse.Hub
}
func (s *NotificationService) CreateNotification(ctx context.Context, notif *domain.Notification) error {
// 1. 持久化到数据库
if err := s.repo.Create(ctx, notif); err != nil {
return fmt.Errorf("persist notification: %w", err)
}
// 2. 通过 SSE 推送(如果用户在线)
s.hub.SendToUser(notif.UserID, notif.ToEvent())
// 3. 更新用户未读计数(Redis)
if err := s.repo.IncrUnreadCount(ctx, notif.UserID); err != nil {
// 非致命,记录日志
fmt.Printf("incr unread count failed: %v\n", err)
}
return nil
}
// 当用户 A 点赞用户 B 的帖子时触发
func (s *NotificationService) OnPostLiked(ctx context.Context, post *domain.Post, likerID int64) error {
if post.UserID == likerID {
return nil // 自点赞不通知
}
liker, err := s.userRepo.GetByID(ctx, likerID)
if err != nil {
return err
}
notif := &domain.Notification{
ID: generateID(),
Type: domain.NotificationNewLike,
UserID: post.UserID,
ActorID: likerID,
ActorName: liker.DisplayName,
PostID: &post.ID,
Read: false,
CreatedAt: time.Now().UTC(),
}
return s.CreateNotification(ctx, notif)
}
三、WebSocket 在线状态与私信
3.1 WebSocket 管理器
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // 生产环境应校验 Origin
},
}
type Message struct {
Type string `json:"type"` // message | typing | presence | heartbeat
From int64 `json:"from"`
To int64 `json:"to,omitempty"`
Payload json.RawMessage `json:"payload"`
}
type Connection struct {
UserID int64
Conn *websocket.Conn
Hub *WHub
Send chan []byte
}
func (c *Connection) readPump() {
defer func() {
c.Hub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(512 * 1024) // 512KB
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("websocket error: %v", err)
}
break
}
var msg Message
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
msg.From = c.UserID
c.Hub.handleMessage(msg)
}
}
func (c *Connection) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
c.Conn.WriteMessage(websocket.TextMessage, message)
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
type WHub struct {
connections map[int64]*Connection
mu sync.RWMutex
register chan *Connection
unregister chan *Connection
broadcast chan Message
}
func NewWHub() *WHub {
return &WHub{
connections: make(map[int64]*Connection),
register: make(chan *Connection),
unregister: make(chan *Connection),
broadcast: make(chan Message, 100),
}
}
func (h *WHub) Run() {
for {
select {
case conn := <-h.register:
h.mu.Lock()
h.connections[conn.UserID] = conn
h.mu.Unlock()
h.broadcastPresence(conn.UserID, "online")
case conn := <-h.unregister:
h.mu.Lock()
if _, ok := h.connections[conn.UserID]; ok {
delete(h.connections, conn.UserID)
close(conn.Send)
}
h.mu.Unlock()
h.broadcastPresence(conn.UserID, "offline")
case msg := <-h.broadcast:
h.handleMessage(msg)
}
}
}
func (h *WHub) handleMessage(msg Message) {
switch msg.Type {
case "message":
// 私信转发
h.sendToUser(msg.To, msg)
case "typing":
// 输入状态转发
h.sendToUser(msg.To, msg)
case "heartbeat":
// 心跳响应
h.sendToUser(msg.From, Message{Type: "heartbeat_ack", From: 0})
}
}
func (h *WHub) sendToUser(userID int64, msg Message) {
h.mu.RLock()
conn, ok := h.connections[userID]
h.mu.RUnlock()
if !ok {
// 用户离线,存入离线队列
return
}
data, _ := json.Marshal(msg)
select {
case conn.Send <- data:
default:
// 发送缓冲区满,关闭连接
close(conn.Send)
h.unregister <- conn
}
}
func (h *WHub) broadcastPresence(userID int64, status string) {
// 获取该用户的关注者
// 向在线的关注者广播状态变更
followerIDs := h.getFollowerIDs(userID)
msg := Message{
Type: "presence",
From: userID,
Payload: mustMarshal(map[string]interface{}{
"user_id": userID,
"status": status,
"time": time.Now().Unix(),
}),
}
for _, fid := range followerIDs {
if fid != userID {
h.sendToUser(fid, msg)
}
}
}
func mustMarshal(v interface{}) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
3.2 在线状态系统
package service
type PresenceService struct {
wsHub *ws.WHub
redis *redis.Client
}
func (s *PresenceService) UpdateStatus(userID int64, status string) {
// 更新 Redis 中的在线状态
key := fmt.Sprintf("presence:%d", userID)
if status == "online" {
s.redis.SetEx(context.Background(), key, "online", 2*time.Minute)
} else {
s.redis.Del(context.Background(), key)
}
}
func (s *PresenceService) GetOnlineStatus(userIDs []int64) map[int64]bool {
// 批量查询在线状态
keys := make([]string, len(userIDs))
for i, id := range userIDs {
keys[i] = fmt.Sprintf("presence:%d", id)
}
results := s.redis.MGet(context.Background(), keys...).Val()
status := make(map[int64]bool)
for i, r := range results {
status[userIDs[i]] = r != nil
}
return status
}
四、时间线增量更新
传统方案中客户端每次下拉刷新都需要重新获取整页时间线。更优的方案是服务端推送增量更新,客户端仅请求变更部分。
// 增量更新消息格式
type TimelineDelta struct {
Type string `json:"type"` // insert | update | delete
Position int `json:"position"` // 插入位置(0 = 顶部)
Post *Post `json:"post,omitempty"`
PostID int64 `json:"post_id,omitempty"`
}
// 当有新帖发布时,推送到关注者
func (s *FeedService) PushTimelineDelta(followerIDs []int64, post *domain.Post) {
delta := TimelineDelta{
Type: "insert",
Position: 0,
Post: post,
}
data, _ := json.Marshal(delta)
event := sse.Event{
Type: "timeline_delta",
Data: data,
}
// 向在线用户推送
for _, uid := range followerIDs {
s.sseHub.SendToUser(uid, event)
}
}
五、性能考量
5.1 连接管理
| 指标 | 建议值 | 说明 |
|---|---|---|
| SSE 心跳间隔 | 30 秒 | 防止 NAT/防火墙断开空闲连接 |
| WebSocket PING | 30 秒 | Gorilla 默认心跳机制 |
| 单用户最大连接 | 3 个 | 多设备同时登录 |
| 服务端缓冲区 | 10 条/连接 | 防止慢客户端拖垮系统 |
| 连接超时 | 2 分钟无心跳 | 清理僵尸连接 |
5.2 扩容策略
实时推送服务是有状态服务(每个连接绑定到特定服务器),水平扩容需要特殊处理:
- 粘性会话 (Sticky Session):负载均衡器将同一用户始终路由到同一服务器
- 共享状态:使用 Redis Pub/Sub 在各服务器间转发消息
- 无状态重构:将所有实时逻辑下沉到独立的 Gateway 服务
// Redis Pub/Sub 跨实例消息转发
func (h *Hub) subscribeRedis() {
pubsub := redis.Subscribe(context.Background(), "notifications")
defer pubsub.Close()
for msg := range pubsub.Channel() {
var event sse.Event
json.Unmarshal([]byte(msg.Payload), &event)
// 广播到本实例的所有客户端
for _, client := range h.clients {
select {
case client.Chan <- event:
default:
}
}
}
}
六、总结
微型博客的实时推送系统需要根据业务场景选择合适的底层技术:SSE 轻量高效,适合单向通知和增量更新;WebSocket 功能完备,适合双向通信和在线状态。两者可以共存,SSE 承载主要的推送流量,WebSocket 处理高交互场景。
生产环境部署时,必须考虑连接管理的边界条件:缓冲区溢出、僵尸连接清理、多实例消息同步。通过 Redis Pub/Sub 和合理的超时配置,可以将有状态的推送服务扩展到支撑百万级并发连接的规模。实时推送不是锦上添花,而是现代社交媒体体验的基础设施。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。