平台消息推送(go)

实现方案

在 Go 后端开发中实现消息推送,需要根据实时性要求、客户端类型(Web / 移动端)、用户规模等场景选择合适的技术方案。以下是常见实现方式及关键细节:

核心技术方案对比

首先明确不同场景的技术选型,常见方案如下:

方案 实时性 适用场景 优势 劣势
短轮询(Polling) 非实时场景(如公告更新) 实现简单,兼容性好 无效请求多,服务器压力大
长轮询(Long Poll) 近实时场景(如订单状态通知) 减少请求次数,比短轮询高效 连接持有时间长,服务器资源占用较高
WebSocket 实时交互(如聊天、实时数据展示) 全双工通信,低延迟,少带宽消耗 需要维护长连接,兼容性依赖客户端
SSE(Server-Sent Events) 服务器单向推送(如实时日志) 基于 HTTP,实现简单,适合单向场景 仅服务器→客户端,不支持双向
第三方推送服务 移动端推送(iOS/Android) 无需维护长连接,支持离线推送

具体实现

Go 的goroutine和channel天然适合处理高并发连接,以下是关键方案的实现细节:

1.短轮询(最简单方案)

客户端定期(如每 30 秒)通过 HTTP 请求拉取消息,服务器返回最新数据。

后端实现

go 复制代码
// 消息存储(实际可用Redis/MySQL)
var messages = map[string][]string{ // userID -> 消息列表
    "user123": {"您有新订单", "账户余额更新"},
}

// 短轮询接口
func pollHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("user_id")
    if userID == "" {
        http.Error(w, "user_id required", http.StatusBadRequest)
        return
    }
    // 返回用户未读消息
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "messages": messages[userID],
        "timestamp": time.Now().Unix(),
    })
    // 标记为已读(实际需根据业务处理)
    messages[userID] = []string{}
}

func main() {
    http.HandleFunc("/poll", pollHandler)
    http.ListenAndServe(":8080", nil)
}

客户端:用 JS 定时调用/poll?user_id=xxx即可。

2. 长轮询(优化短轮询)

客户端发起请求后,服务器若没有新消息则hold 住连接(直到有消息或超时),客户端收到响应后立即发起新请求。

Go 后端实现:利用channel等待消息,结合context控制超时:

go 复制代码
// 消息通知通道(userID -> 消息通道)
var userChannels = make(map[string]chan string)
var mu sync.Mutex // 保护map并发安全

// 长轮询接口
func longPollHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("user_id")
    if userID == "" {
        http.Error(w, "user_id required", http.StatusBadRequest)
        return
    }

    // 创建用户的消息通道(若不存在)
    mu.Lock()
    if _, ok := userChannels[userID]; !ok {
        userChannels[userID] = make(chan string, 10) // 缓冲避免阻塞
    }
    ch := userChannels[userID]
    mu.Unlock()

    // 设置超时(如30秒)
    ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
    defer cancel()

    // 等待消息或超时
    select {
    case msg := <-ch:
        // 有新消息,返回
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"message": msg})
    case <-ctx.Done():
        // 超时,返回空(客户端会重新请求)
        w.WriteHeader(http.StatusNoContent)
    }
}

// 发送消息的函数(如订单系统调用)
func sendMessage(userID, msg string) {
    mu.Lock()
    defer mu.Unlock()
    if ch, ok := userChannels[userID]; ok {
        select {
        case ch <- msg: // 发送消息到通道
        default: // 通道满了,可存储到数据库(离线消息)
            saveOfflineMessage(userID, msg)
        }
    } else {
        // 用户未连接,存离线消息
        saveOfflineMessage(userID, msg)
    }
}

func main() {
    http.HandleFunc("/long-poll", longPollHandler)
    http.ListenAndServe(":8080", nil)
}

3.移动端推送(依赖第三方服务)

针对 iOS/Android,需集成 APNs(iOS)、FCM(Android)或国内的极光推送、个推等,Go 通过 HTTP/HTTPS 调用其 API。

调用极光推送 API

go 复制代码
func pushToMobile(userID, title, content string) error {
    // 1. 构造请求参数(参考极光文档)
    reqBody := map[string]interface{}{
        "platform": "all", // iOS+Android
        "audience": map[string]interface{}{
            "alias": []string{userID}, // 用userID作为别名
        },
        "notification": map[string]interface{}{
            "alert": content,
            "ios": map[string]interface{}{
                "title": title,
            },
            "android": map[string]interface{}{
                "title": title,
            },
        },
    }
    jsonBody, _ := json.Marshal(reqBody)

    // 2. 发送POST请求到极光API
    client := &http.Client{}
    req, err := http.NewRequest("POST", "https://api.jpush.cn/v3/push", bytes.NewBuffer(jsonBody))
    if err != nil {
        return err
    }
    // 极光认证:AppKey:MasterSecret 转Base64
    auth := base64.StdEncoding.EncodeToString([]byte("your_appkey:your_mastersecret"))
    req.Header.Set("Authorization", "Basic "+auth)
    req.Header.Set("Content-Type", "application/json")

    // 3. 处理响应
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("push failed: %s", string(body))
    }
    return nil
}

关键问题处理

  1. 连接管理:
  • 用map+互斥锁维护用户与连接的映射(如 WebSocket 的ConnectionManager)。
  • 处理断线重连:客户端需实现重连逻辑,服务器检测到连接关闭后清理映射。
  1. 离线消息:
  • 当用户未在线时,消息需暂存(用 Redis/MySQL),用户上线后同步推送。
  1. 分布式扩展:
  • 当服务器集群部署时,单个服务器无法感知其他节点的连接,需用中间件同步消息:
  • 用 Redis 的 Pub/Sub:每个服务器节点订阅消息频道,收到消息后检查本地是否有目标用户连接,有则推送。
  • 示例:消息产生时发布到user:{userID}频道,所有节点订阅并处理。
  1. 安全性:
  • WebSocket 握手时验证用户身份(如 Token),避免非法连接。
  • 敏感消息需加密传输(如 HTTPS/WSS)。
  1. 性能优化:
  • 限制单用户最大连接数(避免恶意连接)。
  • 用缓冲 channel 减少阻塞(如长轮询的消息通道)。

websocket

在 Go 后端实现实时消息推送给用户,核心是解决 "服务器主动向客户端发送消息" 的问题。目前主流方案有WebSocket(全双工,适合高频实时场景)、Server-Sent Events (SSE)(服务器单向推送,适合简单场景)、长轮询(兼容性好,但实时性稍弱)。其中 WebSocket 因双向通信、低延迟的特性,是实时消息场景的首选。

核心方案:WebSocket + 连接管理 + 集群支持

以下是基于 WebSocket 的完整实现思路,包含单服务和集群场景的处理。

  1. 技术选型
  • 通信协议:WebSocket(基于gorilla/websocket库,比标准库更易用)。
  • 连接管理:用map维护 "用户 ID - 连接" 映射(需保证并发安全)。
  • 集群支持:结合 Redis Pub/Sub 实现跨服务节点的消息广播。
  • 消息格式:JSON(便于前后端解析)。
  1. 单服务器场景实现
    单服务器场景下,只需在本地维护用户连接,直接推送消息即可。

定义核心结构

go 复制代码
import (
    "sync"
    "github.com/gorilla/websocket"
)

// 消息结构(前后端约定)
type Message struct {
    Type    string `json:"type"` // 消息类型(如"chat"、"notice")
    Content string `json:"content"`
    ToUser  string `json:"to_user"` // 目标用户ID
    FromUser string `json:"from_user"` // 发送者ID
    Time    int64  `json:"time"` // 时间戳
}

// 连接管理器:维护用户与WebSocket连接的映射(支持多设备连接)
type ConnManager struct {
    mu      sync.RWMutex               // 并发安全锁
    clients map[string][]*websocket.Conn // key: 用户ID,value: 该用户的所有连接(多设备)
}

// 全局连接管理器实例
var manager = &ConnManager{
    clients: make(map[string][]*websocket.Conn),
}

连接管理器方法(增删查)

go 复制代码
// 添加用户连接
func (m *ConnManager) Add(userID string, conn *websocket.Conn) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.clients[userID] = append(m.clients[userID], conn)
}

// 移除用户连接(连接关闭时调用)
func (m *ConnManager) Remove(userID string, conn *websocket.Conn) {
    m.mu.Lock()
    defer m.mu.Unlock()
    if conns, ok := m.clients[userID]; ok {
        // 遍历删除目标连接
        for i, c := range conns {
            if c == conn {
                m.clients[userID] = append(conns[:i], conns[i+1:]...)
                break
            }
        }
        // 若用户无连接,删除key
        if len(m.clients[userID]) == 0 {
            delete(m.clients, userID)
        }
    }
}

// 获取用户的所有连接
func (m *ConnManager) Get(userID string) []*websocket.Conn {
    m.mu.RLock()
    defer m.mu.RUnlock()
    return m.clients[userID] // 若用户无连接,返回空切片
}

WebSocket 连接处理(握手、认证、消息循环)

客户端需先通过 WebSocket 握手建立连接,服务器需验证用户身份(如 Token),并维护连接。

go 复制代码
import (
    "net/http"
    "time"
)

// WebSocket升级器(配置握手参数)
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    // 允许跨域(生产环境需限制Origin)
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

// WebSocket处理器:处理客户端连接请求
func WebSocketHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 从请求中获取用户身份(如Token解析)
    token := r.URL.Query().Get("token")
    userID, err := parseToken(token) // 自定义Token解析逻辑
    if err != nil {
        http.Error(w, "未授权", http.StatusUnauthorized)
        return
    }

    // 2. 升级HTTP连接为WebSocket
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        http.Error(w, "升级连接失败", http.StatusInternalServerError)
        return
    }
    defer conn.Close()

    // 3. 将连接添加到管理器
    manager.Add(userID, conn)
    defer manager.Remove(userID, conn) // 连接关闭时移除

    // 4. 维持连接(读取客户端消息,或仅心跳检测)
    for {
        // 读取客户端消息(若不需要双向通信,可忽略)
        _, _, err := conn.ReadMessage()
        if err != nil {
            // 客户端断开连接(如刷新页面、网络中断)
            break
        }
    }
}

// 解析Token获取用户ID(示例)
func parseToken(token string) (string, error) {
    // 实际场景:用jwt等库解析token,验证有效性后返回userID
    return "user_123", nil // 模拟返回用户ID
}

消息推送逻辑

当有消息需要推送时,通过用户 ID 从管理器获取连接,逐个发送消息。

go 复制代码
// 向指定用户推送消息
func PushToUser(userID string, msg Message) error {
    conns := manager.Get(userID)
    if len(conns) == 0 {
        return nil // 无连接,无需推送(可记录离线消息)
    }

    // 遍历用户的所有连接,发送消息
    for _, conn := range conns {
        // 设置写超时(防止阻塞)
        if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
            continue
        }
        // 发送JSON格式消息
        if err := conn.WriteJSON(msg); err != nil {
            // 发送失败(如连接已失效),移除该连接
            manager.Remove(userID, conn)
            continue
        }
    }
    return nil
}

启动服务

go 复制代码
func main() {
    // 注册WebSocket路由
    http.HandleFunc("/ws", WebSocketHandler)
    // 启动HTTP服务(生产环境需用HTTPS,WebSocket需对应wss协议)
    http.ListenAndServe(":8080", nil)
}

集群场景扩展(多服务器)

当后端是多节点集群时,单节点的连接管理器无法感知其他节点的用户连接,需通过Redis Pub/Sub实现跨节点消息同步。

原理:每个服务器节点订阅 Redis 的 "消息频道",当某节点需要推送消息时,先将消息发布到 Redis 频道,所有节点收到消息后,检查本地是否有目标用户的连接,有则推送。

初始化 Redis Pub/Sub

go 复制代码
import (
    "context"
    "github.com/go-redis/redis/v8"
)

var redisClient = redis.NewClient(&redis.Options{
    Addr: "localhost:6379", // Redis地址
})
var ctx = context.Background()
const msgChannel = "user_messages" // Redis消息频道名

每个节点启动 Redis 订阅协程

go 复制代码
// 启动Redis订阅,接收跨节点消息并推送
func StartRedisSubscriber() {
    pubSub := redisClient.Subscribe(ctx, msgChannel)
    defer pubSub.Close()

    // 循环接收消息
    for {
        msg, err := pubSub.ReceiveMessage(ctx)
        if err != nil {
            // 处理错误(如重连)
            continue
        }
        // 解析消息为Message结构
        var m Message
        if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
            continue
        }
        // 推送消息到本地用户(若有连接)
        PushToUser(m.ToUser, m)
    }
}

修改消息推送逻辑(发布到 Redis)

go 复制代码
// 集群模式下的消息推送:先发布到Redis,再由各节点本地推送
func ClusterPushToUser(userID string, msg Message) error {
    // 1. 发布消息到Redis频道(供其他节点接收)
    msgBytes, err := json.Marshal(msg)
    if err != nil {
        return err
    }
    if err := redisClient.Publish(ctx, msgChannel, msgBytes).Err(); err != nil {
        return err
    }
    // 2. 本地节点也需推送(避免自己订阅自己的消息延迟)
    return PushToUser(userID, msg)
}

附加功能(生产环境必备)

  1. 心跳检测:防止连接假死(服务器定期发送ping,客户端回复pong,超时未回复则关闭连接)
go 复制代码
// 在WebSocket连接循环中添加心跳检测
conn.SetPongHandler(func(string) error {
    conn.SetReadDeadline(time.Now().Add(30 * time.Second)) // 延长读超时
    return nil
})
// 定时发送ping
go func() {
    ticker := time.NewTicker(15 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
            return // 发送失败,退出心跳
        }
    }
}()
  1. 离线消息存储:若用户无在线连接,将消息存入数据库(如 MySQL、MongoDB),用户上线后同步。
  2. 连接限流:限制单用户最大连接数(如 3 个设备),防止恶意创建连接。
  3. 权限校验:推送前验证发送者是否有权限向目标用户发送消息(如好友关系)

总结

推荐优先使用WebSocket + Redis Pub/Sub方案,既满足实时性需求,又支持集群扩展。核心是通过连接管理器维护用户连接,结合消息发布订阅实现跨节点同步,同时完善心跳、认证、离线存储等辅助功能。

场景:商品更新,消息推送

在 Go 后端实现 "商品更新时向收藏用户推送消息" 的功能,核心是打通 "商品更新事件" 到 "用户消息推送" 的链路,需要结合业务存储(收藏关系)、事件触发、用户筛选和消息推送能力。以下是具体实现方案:

一、整体流程设计

整个功能的核心链路可拆解为 4 步:

  1. 存储收藏关系:记录 "用户 - 商品" 的收藏映射(谁收藏了哪个商品)。
  2. 捕获商品更新事件:当商品信息(价格、库存、描述等)更新时,触发 "商品更新事件"。
  3. 筛选目标用户:根据商品 ID,查询所有收藏该商品的用户 ID 列表。
  4. 批量推送消息:向这些用户推送 "商品更新" 通知(基于之前的 WebSocket 实时推送能力)。

二、具体实现步骤

  1. 数据模型设计(存储收藏关系)
    需要一张表记录用户对商品的收藏关系,这里以 MySQL 为例:
go 复制代码
-- 用户收藏表
CREATE TABLE user_collections (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id VARCHAR(32) NOT NULL COMMENT '用户ID',
    product_id VARCHAR(32) NOT NULL COMMENT '商品ID',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
    UNIQUE KEY uk_user_product (user_id, product_id) -- 避免重复收藏
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Go 中定义对应的数据结构和数据库操作(使用gorm示例):

go 复制代码
import (
    "gorm.io/gorm"
    "time"
)

// UserCollection 收藏关系模型
type UserCollection struct {
    ID         int64     `gorm:"primaryKey" json:"id"`
    UserID     string    `gorm:"index:idx_product;size:32" json:"user_id"` // 索引:按商品查用户
    ProductID  string    `gorm:"size:32" json:"product_id"`
    CreatedAt  time.Time `json:"created_at"`
}

// 数据库操作:根据商品ID查询所有收藏用户
func GetCollectionUsers(db *gorm.DB, productID string) ([]string, error) {
    var userIDs []string
    err := db.Model(&UserCollection{}).
        Where("product_id = ?", productID).
        Pluck("user_id", &userIDs). // 只查询user_id字段
        Error
    return userIDs, err
}
  1. 捕获商品更新事件(事件驱动设计)
    当商品信息更新时,需要触发一个 "商品更新事件",避免业务逻辑耦合。可以用本地事件总线(单服务)或消息队列(集群 / 高可用)实现。

方案 1:单服务用本地事件总线 (简单场景)

用 Go 的channel实现轻量事件总线:

go 复制代码
// 事件定义
type ProductUpdateEvent struct {
    ProductID  string      `json:"product_id"` // 商品ID
    Product    interface{} `json:"product"`    // 更新后的商品信息(如名称、价格等)
    UpdateTime time.Time   `json:"update_time"`
}

// 事件总线(全局单例)
type EventBus struct {
    productUpdateCh chan ProductUpdateEvent // 商品更新事件通道
}

var globalEventBus = &EventBus{
    productUpdateCh: make(chan ProductUpdateEvent, 1000), // 缓冲通道,避免阻塞
}

// 发布商品更新事件
func PublishProductUpdateEvent(event ProductUpdateEvent) {
    select {
    case globalEventBus.productUpdateCh <- event:
    default:
        // 通道满时的降级处理(如日志记录,避免事件丢失)
        log.Printf("event bus full, drop product update event: %s", event.ProductID)
    }
}

在商品更新接口中发布事件

go 复制代码
// 商品更新接口示例
func UpdateProductHandler(db *gorm.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // 1. 解析请求,获取商品ID和更新内容
        var req struct {
            ProductID string  `json:"product_id"`
            Price     float64 `json:"price"` // 示例:更新价格
            // 其他字段...
        }
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "invalid request", http.StatusBadRequest)
            return
        }

        // 2. 更新商品数据库
        product := Product{ID: req.ProductID, Price: req.Price} // 假设Product是商品模型
        if err := db.Model(&product).Updates(product).Error; err != nil {
            http.Error(w, "update product failed", http.StatusInternalServerError)
            return
        }

        // 3. 发布商品更新事件
        PublishProductUpdateEvent(ProductUpdateEvent{
            ProductID:  req.ProductID,
            Product:    product, // 可只传关键更新字段(如Price)
            UpdateTime: time.Now(),
        })

        w.WriteHeader(http.StatusOK)
    }
}

方案 2:集群场景用消息队列 (如 RabbitMQ/Kafka)

如果是分布式系统,用消息队列(如 RabbitMQ)确保事件跨服务传递:

go 复制代码
// 初始化RabbitMQ生产者(发布事件)
func InitRabbitMQProducer() (*amqp.Channel, error) {
    conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
    if err != nil {
        return nil, err
    }
    ch, err := conn.Channel()
    if err != nil {
        return nil, err
    }
    // 声明商品更新事件队列
    _, err = ch.QueueDeclare(
        "product_update_events", // 队列名
        true,                    // 持久化
        false,                   // 不自动删除
        false,                   // 不排他
        false,                   // 不阻塞
        nil,
    )
    return ch, err
}

// 发布事件到RabbitMQ
func PublishProductUpdateEventMQ(ch *amqp.Channel, event ProductUpdateEvent) error {
    eventBytes, err := json.Marshal(event)
    if err != nil {
        return err
    }
    return ch.Publish(
        "",                      // 交换机(默认)
        "product_update_events", // 队列名
        false,
        false,
        amqp.Publishing{
            ContentType:  "application/json",
            Body:         eventBytes,
            DeliveryMode: amqp.Persistent, // 持久化消息
        },
    )
}
  1. 监听事件并筛选目标用户
    启动一个协程监听商品更新事件,收到事件后查询收藏该商品的用户:
go 复制代码
// 启动事件处理器(单服务场景)
func StartProductUpdateHandler(db *gorm.DB) {
    go func() {
        for event := range globalEventBus.productUpdateCh {
            // 1. 查询收藏该商品的所有用户
            userIDs, err := GetCollectionUsers(db, event.ProductID)
            if err != nil {
                log.Printf("get collection users failed: %v", err)
                continue
            }
            if len(userIDs) == 0 {
                continue // 无收藏用户,无需推送
            }

            // 2. 生成推送消息
            product, ok := event.Product.(Product)
            if !ok {
                log.Printf("invalid product data: %v", event.Product)
                continue
            }
            msg := Message{
                Type:    "product_update", // 消息类型:商品更新
                Content: fmt.Sprintf("您收藏的商品「%s」价格已更新为%.2f元", product.Name, product.Price),
                ToUser:  "", // 批量推送,后续逐个设置
                FromUser: "system", // 系统发送
                Time:     event.UpdateTime.Unix(),
            }

            // 3. 向每个用户推送消息
            for _, userID := range userIDs {
                msg.ToUser = userID
                if err := ClusterPushToUser(userID, msg); err != nil { // 复用之前的集群推送函数
                    log.Printf("push to user %s failed: %v", userID, err)
                }
            }
        }
    }()
}

// 集群场景:从RabbitMQ消费事件(类似逻辑)
func StartProductUpdateMQConsumer(db *gorm.DB, ch *amqp.Channel) {
    msgs, err := ch.Consume(
        "product_update_events", // 队列名
        "",                      // 消费者标签
        true,                    // 自动确认
        false,                   // 不排他
        false,                   // 不阻塞
        false,                   // 无参数
        nil,
    )
    if err != nil {
        log.Fatalf("failed to register consumer: %v", err)
    }

    go func() {
        for d := range msgs {
            var event ProductUpdateEvent
            if err := json.Unmarshal(d.Body, &event); err != nil {
                log.Printf("unmarshal event failed: %v", err)
                continue
            }
            // 后续逻辑同上:查询用户→推送消息
        }
    }()
}
  1. 处理离线用户(消息持久化)
    如果用户当前不在线(无 WebSocket 连接),需要将消息存入数据库,待用户上线后同步:
go 复制代码
// 离线消息表
type OfflineMessage struct {
    ID        int64     `gorm:"primaryKey" json:"id"`
    UserID    string    `gorm:"index" json:"user_id"` // 按用户查询
    Message   string    `json:"message"` // 消息内容(JSON字符串)
    Read      bool      `json:"read"`    // 是否已读
    CreatedAt time.Time `json:"created_at"`
}

// 推送消息时,若用户离线则存储离线消息
func PushToUserWithOffline(userID string, msg Message, db *gorm.DB) error {
    conns := manager.Get(userID)
    if len(conns) > 0 {
        // 在线,直接推送
        return PushToUser(userID, msg)
    }

    // 离线,存储到数据库
    msgBytes, err := json.Marshal(msg)
    if err != nil {
        return err
    }
    return db.Create(&OfflineMessage{
        UserID:    userID,
        Message:   string(msgBytes),
        Read:      false,
        CreatedAt: time.Unix(msg.Time, 0),
    }).Error
}

用户上线时(WebSocket 连接建立后),同步离线消息:

go 复制代码
// WebSocket连接建立后,同步离线消息
func syncOfflineMessages(userID string, conn *websocket.Conn, db *gorm.DB) {
    var offlineMsgs []OfflineMessage
    if err := db.Where("user_id = ? AND read = ?", userID, false).
        Order("created_at ASC").
        Find(&offlineMsgs).Error; err != nil {
        log.Printf("sync offline messages failed: %v", err)
        return
    }

    // 推送离线消息
    for _, msg := range offlineMsgs {
        var m Message
        if err := json.Unmarshal([]byte(msg.Message), &m); err != nil {
            continue
        }
        if err := conn.WriteJSON(m); err != nil {
            return
        }
        // 标记为已读
        db.Model(&OfflineMessage{}).Where("id = ?", msg.ID).Update("read", true)
    }
}

// 在WebSocketHandler中添加同步逻辑
func WebSocketHandler(w http.ResponseWriter, r *http.Request, db *gorm.DB) {
    // ... 之前的认证、升级连接逻辑 ...

    // 同步离线消息
    syncOfflineMessages(userID, conn, db)

    // ... 后续的连接维护逻辑 ...
}

三、优化点(生产环境必备)

  • 批量查询优化:如果商品收藏用户过多(如 10 万 +),GetCollectionUsers需分页查询(LIMIT + OFFSET),避免一次性加载大量数据导致内存溢出。
  • 推送频率限制:若商品短时间内频繁更新(如 1 分钟内改 3 次价格),可合并消息(只推最后一次更新),避免骚扰用户。可通过 Redis 记录商品最近推送时间,过滤高频更新:
go 复制代码
// 检查是否需要推送(10分钟内只推一次)
func needPush(productID string) bool {
    key := fmt.Sprintf("product_push_limit:%s", productID)
    // 若key不存在,设置10分钟过期;若存在,返回false
    exists, _ := redisClient.SetNX(ctx, key, 1, 10*time.Minute).Result()
    return exists
}
  • 消息模板化:将消息内容定义为模板(如"您收藏的商品「{{.Name}}」已更新:{{.Changes}}"),通过模板引擎(如text/template)动态生成,便于维护。
  • 监控与告警:统计消息推送成功率、离线消息堆积量,通过 Prometheus+Grafana 监控,异常时告警(如推送失败率超过 5%)。

总结

整个功能的核心是 "事件触发→用户筛选→消息推送" 的闭环:

• 用数据库存储用户 - 商品收藏关系,确保能快速找到目标用户;

• 用事件总线 / 消息队列解耦商品更新和消息推送逻辑,提高可维护性;

• 结合 WebSocket 实时推送 + 离线消息存储,保证用户无论在线与否都能收到通知;

• 通过分页、频率限制等优化,适配高并发场景。

按这个思路实现,即可稳定支持 "商品更新时向收藏用户推送消息" 的需求。

用户登录、离线状态推送

要实现 "商品更新时,在线用户立即提醒,离线用户登录后提醒" 的功能,核心是精准判断用户状态并区分处理逻辑:在线状态依赖实时连接推送,离线状态依赖消息持久化 + 登录时同步。以下是具体实现方案,包含状态判断、消息处理、登录同步的完整链路。

一、核心逻辑拆解

  1. 用户状态定义:
    ◦ 在线:用户有活跃的 WebSocket 连接(连接管理器中存在该用户的有效连接)。
    ◦ 离线:用户无活跃 WebSocket 连接(连接管理器中无该用户的连接)。
  2. 处理流程:商品更新 → 筛选收藏用户 → 逐个判断用户状态 → 在线则实时推送 → 离线则存储离线消息 → 用户登录时同步离线消息。

二、具体实现步骤

  1. 完善用户状态判断(基于连接管理器)
    复用之前的ConnManager,通过检查用户是否有活跃连接判断状态:
go 复制代码
// 判断用户是否在线(有活跃WebSocket连接)
func (m *ConnManager) IsOnline(userID string) bool {
    m.mu.RLock()
    defer m.mu.RUnlock()
    conns, ok := m.clients[userID]
    // 存在连接且至少有一个有效连接(实际可更严格:检查连接是否存活)
    return ok && len(conns) > 0
}

注:严格来说,需判断连接是否 "存活"(如最近 30 秒内有心跳交互),可在ConnManager中为连接添加最后活跃时间,定期清理死连接。

  1. 商品更新事件处理(区分在线 / 离线用户)
    当商品更新事件触发后,对每个收藏用户执行 "在线推送 / 离线存储" 逻辑:
go 复制代码
// 处理商品更新事件的核心函数
func handleProductUpdateEvent(event ProductUpdateEvent, db *gorm.DB) {
    // 1. 筛选收藏该商品的用户(分页查询,避免大量用户导致内存溢出)
    userIDs, err := getCollectionUsersWithPage(db, event.ProductID, 0, 100) // 分页:页号0,每页100条
    if err != nil {
        log.Printf("查询收藏用户失败: %v", err)
        return
    }

    // 2. 生成推送消息(统一格式)
    msg := buildProductUpdateMsg(event) // 构建消息内容(见下方)

    // 3. 逐个处理用户
    for _, userID := range userIDs {
        // 3.1 检查用户是否在线
        if manager.IsOnline(userID) {
            // 在线:实时推送
            if err := pushOnlineMessage(userID, msg); err != nil {
                log.Printf("向用户[%s]实时推送失败: %v", userID, err)
            }
        } else {
            // 离线:存储离线消息
            if err := saveOfflineMessage(userID, msg, db); err != nil {
                log.Printf("存储用户[%s]离线消息失败: %v", userID, err)
            }
        }
    }

    // 4. 处理下一页(若有更多用户)
    // (逻辑:循环分页查询,直到获取所有用户)
}

// 构建商品更新消息
func buildProductUpdateMsg(event ProductUpdateEvent) Message {
    product := event.Product.(Product) // 假设Product包含Name、Price等字段
    return Message{
        Type:    "product_update",
        Content: fmt.Sprintf("您收藏的商品「%s」已更新,最新价格:%.2f元", product.Name, product.Price),
        FromUser: "system",
        Time:     event.UpdateTime.Unix(),
    }
}
  1. 在线用户:实时推送(复用 WebSocket)
    直接使用之前的PushToUser函数,向用户的所有活跃连接推送消息:
go 复制代码
// 向在线用户推送消息
func pushOnlineMessage(userID string, msg Message) error {
    msg.ToUser = userID // 填充目标用户ID
    return PushToUser(userID, msg) // 复用WebSocket推送逻辑
}
  1. 离线用户:存储离线消息(数据库持久化)
    设计离线消息表,存储未送达的消息,待用户登录时同步:
    (1)离线消息表设计(MySQL)
go 复制代码
CREATE TABLE offline_messages (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id VARCHAR(32) NOT NULL COMMENT '目标用户ID',
    message JSON NOT NULL COMMENT '消息内容(JSON格式)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    is_read TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读(0-未读,1-已读)',
    INDEX idx_user_created (user_id, created_at) -- 按用户+时间查询
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

(2)Go 模型与存储逻辑

go 复制代码
// OfflineMessage 离线消息模型
type OfflineMessage struct {
    ID        int64     `gorm:"primaryKey" json:"id"`
    UserID    string    `gorm:"index:idx_user_created" json:"user_id"`
    Message   string    `gorm:"type:json" json:"message"` // 存储Message的JSON字符串
    CreatedAt time.Time `gorm:"index:idx_user_created" json:"created_at"`
    IsRead    bool      `json:"is_read"`
}

// 存储离线消息
func saveOfflineMessage(userID string, msg Message, db *gorm.DB) error {
    // 将消息序列化为JSON
    msgBytes, err := json.Marshal(msg)
    if err != nil {
        return err
    }
    // 存入数据库
    return db.Create(&OfflineMessage{
        UserID:    userID,
        Message:   string(msgBytes),
        CreatedAt: time.Unix(msg.Time, 0),
        IsRead:    false,
    }).Error
}
  1. 用户登录时:同步离线消息
    当用户建立 WebSocket 连接(登录状态确认)后,立即查询并推送其未读的离线消息:
    (1)WebSocket 连接建立时触发同步
go 复制代码
// WebSocket处理器(增加离线消息同步)
func WebSocketHandler(w http.ResponseWriter, r *http.Request, db *gorm.DB) {
    // 1. 认证用户(解析Token获取userID)
    userID, err := authenticateUser(r) // 自定义认证逻辑
    if err != nil {
        http.Error(w, "未授权", http.StatusUnauthorized)
        return
    }

    // 2. 升级为WebSocket连接
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        http.Error(w, "连接升级失败", http.StatusInternalServerError)
        return
    }
    defer conn.Close()

    // 3. 添加连接到管理器(标记用户为在线)
    manager.Add(userID, conn)
    defer manager.Remove(userID, conn)

    // 4. 同步离线消息(核心:用户登录后立即推送未读消息)
    if err := syncOfflineMessages(userID, conn, db); err != nil {
        log.Printf("用户[%s]离线消息同步失败: %v", userID, err)
    }

    // 5. 维持连接(心跳+消息循环)
    maintainWebSocketConn(conn, userID)
}

(2)离线消息同步逻辑

go 复制代码
// 同步用户未读的离线消息
func syncOfflineMessages(userID string, conn *websocket.Conn, db *gorm.DB) error {
    // 1. 查询未读离线消息(按时间升序,确保顺序正确)
    var offlineMsgs []OfflineMessage
    err := db.Where("user_id = ? AND is_read = ?", userID, false).
        Order("created_at ASC").
        Find(&offlineMsgs).Error
    if err != nil {
        return fmt.Errorf("查询离线消息失败: %w", err)
    }
    if len(offlineMsgs) == 0 {
        return nil // 无未读消息
    }

    // 2. 逐个推送到客户端
    for _, om := range offlineMsgs {
        // 反序列化消息
        var msg Message
        if err := json.Unmarshal([]byte(om.Message), &msg); err != nil {
            log.Printf("解析离线消息失败: %v", err)
            continue
        }
        // 推送消息
        if err := conn.WriteJSON(msg); err != nil {
            return fmt.Errorf("推送离线消息失败: %w", err)
        }
        // 标记为已读
        if err := db.Model(&OfflineMessage{}).
            Where("id = ?", om.ID).
            Update("is_read", true).Error; err != nil {
            log.Printf("标记消息已读失败: %v", err)
        }
    }
    return nil
}

三、关键细节与优化

  1. 连接存活检测(避免误判在线状态)
    用户可能存在 "假在线" 状态(如网络中断但连接未关闭),需通过心跳机制清理死连接:
go 复制代码
// 维持WebSocket连接(包含心跳检测)
func maintainWebSocketConn(conn *websocket.Conn, userID string) {
    // 设置Pong回调(客户端回复心跳)
    conn.SetPongHandler(func(string) error {
        // 延长读超时(30秒内收到Pong视为存活)
        return conn.SetReadDeadline(time.Now().Add(30 * time.Second))
    })

    // 定时发送Ping(每15秒一次)
    pingTicker := time.NewTicker(15 * time.Second)
    defer pingTicker.Stop()

    // 读消息循环(同时检测连接是否存活)
    for {
        select {
        case <-pingTicker.C:
            // 发送Ping,若失败则关闭连接
            if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
                log.Printf("用户[%s]心跳发送失败,关闭连接: %v", userID, err)
                return
            }
        default:
            // 读取客户端消息(无消息时阻塞,超时后返回错误)
            _, _, err := conn.ReadMessage()
            if err != nil {
                log.Printf("用户[%s]连接断开: %v", userID, err)
                return
            }
        }
    }
}
  1. 批量处理与性能优化
    • 分页查询收藏用户:若商品收藏用户过多(如 10 万 +),单次查询会导致内存暴涨,需分页处理(LIMIT + OFFSET或游标分页)。
    • 批量插入离线消息:当离线用户数量大时,用gorm的CreateInBatches批量插入,减少数据库交互次数:
go 复制代码
// 批量存储离线消息
func batchSaveOfflineMessages(userIDs []string, msg Message, db *gorm.DB) error {
    msgBytes, _ := json.Marshal(msg)
    var offlineMsgs []OfflineMessage
    for _, userID := range userIDs {
        offlineMsgs = append(offlineMsgs, OfflineMessage{
            UserID:    userID,
            Message:   string(msgBytes),
            CreatedAt: time.Unix(msg.Time, 0),
        })
    }
    return db.CreateInBatches(offlineMsgs, 100).Error // 每100条批量插入
}
  1. 消息幂等性(避免重复推送)
    • 为每条消息生成唯一 ID(如msg.ID = uuid.New().String()),推送 / 存储时检查该 ID 是否已处理,避免因事件重试导致重复。
    • 离线消息同步时,即使重复推送,客户端也可根据msg.ID去重。
  2. 离线消息清理(避免存储膨胀)
    • 定期清理过期消息(如 30 天前的已读消息),可用定时任务(cron)执行:
go 复制代码
// 清理30天前的已读离线消息
func cleanExpiredOfflineMessages(db *gorm.DB) error {
    expiredTime := time.Now().Add(-30 * 24 * time.Hour)
    return db.Where("is_read = ? AND created_at < ?", true, expiredTime).
        Delete(&OfflineMessage{}).Error
}

四、总结

整个流程通过 "连接管理器判断在线状态""实时推送 + 离线存储""登录同步" 三个核心环节,实现了商品更新消息的精准触达:

• 在线用户依赖 WebSocket 实时推送,确保时效性;

• 离线用户通过数据库存储消息,登录时自动同步,确保不丢失;

• 配合心跳检测、分页处理、幂等性设计,可支撑高并发场景。

按此方案实现,既能满足用户体验(实时提醒),又能保证消息可靠性(离线不丢失)。

相关推荐
q***31891 小时前
深入解析Spring Boot中的@ConfigurationProperties注解
java·spring boot·后端
IT_陈寒2 小时前
JavaScript 性能优化实战:我从 V8 源码中学到的 7 个关键技巧
前端·人工智能·后端
superlls2 小时前
(Mysql)Mysql八股大杂烩
数据库·sql
張萠飛2 小时前
Phoenix+Hbase和Doris两个方案如何选择,能不能拿Doris完全替代Phoenix+Hbase?有什么难点?
大数据·数据库·hbase
风象南2 小时前
从擦除到恢复:JSON 库是如何“还原” Java 泛型信息的
后端
Victor3562 小时前
Redis(121)Redis的数据恢复如何进行?
后端
bagadesu2 小时前
IDEA + Spring Boot 的三种热加载方案
java·后端
Victor3562 小时前
Redis(120)Redis的常见错误如何处理?
后端
q***71853 小时前
【玩转全栈】----Django基本配置和介绍
数据库·django·sqlite