You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

AWS ElastiCache Redis集群模式下Pub/Sub重复消息问题排查

Redis集群下Pub/Sub重复消息问题解决(go-redis/v9)

问题背景

我在聊天应用中使用github.com/redis/go-redis/v9 v9.3.1实现Redis操作,通过Redis Pub/Sub完成用户输入状态提示和消息投递功能:每台服务器启动一个专属订阅协程监听指定频道,将频道消息推送给所有WebSocket连接的客户端。目前遇到的问题是终端用户/测试人员会收到大量重复消息。

排查后定位到诱因:Redis集群模式下,Pub/Sub消息会被复制到所有从节点,而当前客户端连接了集群中的所有节点,导致同一条消息被多次接收。

相关代码如下:

var redisClient = redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{"c1:6379",
        "c2:6379",
        "c3:6379"},

    ReadOnly:       false,
    RouteRandomly:  false,
    RouteByLatency: false,
    PoolSize:       10,
    MinIdleConns:   10,

    DialTimeout:  5 * time.Second,
    ReadTimeout:  3 * time.Second,
    WriteTimeout: 3 * time.Second,
    PoolTimeout:  4 * time.Second,

    //IdleCheckFrequency: 60 * time.Second,
    //IdleTimeout:        5 * time.Minute,
    //MaxConnAge:         0 * time.Second,

    MaxRetries:      10,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,

    TLSConfig: &tls.Config{
        InsecureSkipVerify: true,
    },
})

func InitializeStatusMessageSubscriber(statusTopic string, cm *ConnectionManager) {
    subscriber := redisClient.Subscribe(ctx, statusTopic)
    log.Printf("Subscribing to status subscriber in go-routine %s", statusTopic)
    var socketEventTypingStatus SocketEventTypingMessage

    channel := subscriber.Channel()

    for msg := range channel {
        if marshalError := json.Unmarshal([]byte(msg.Payload), &socketEventTypingStatus); marshalError != nil {
            log.Println("Error unmarshalling message to SocketEventTypingStatus message", marshalError)
            return
        }
        messageId := socketEventTypingStatus.Id
        expired := CheckIfKeyIsExpired(messageId)
        if expired {
            continue
        }

        id := socketEventTypingStatus.ChatId

        users := GetAllUsersForAGivenChat(id)

        for _, user := range users {
            cm.WriteMessageToUserSocket(user, msg.Payload)
        }
    }
}

注:InitializeStatusMessageSubscriber仅初始化一次


核心原因

Redis集群的Pub/Sub机制中,主节点收到的消息会同步到该分片的所有从节点。而默认的集群客户端订阅逻辑会在集群的每个节点上建立订阅连接,导致同一条消息从主节点和多个从节点被重复接收,最终推送给客户端时出现重复。


解决方法

1. 单独创建订阅专用的集群客户端,强制路由到主节点

创建一个专门用于订阅的客户端,通过自定义路由策略,让订阅请求只发送到集群中的一个主节点(集群内主节点会自动同步Pub/Sub消息到其他主节点,无需订阅多个)。

修改客户端初始化代码:

import "errors"

// 自定义路由函数:让订阅请求只发往任意一个主节点
func subscribeRoute(cmd redis.Cmder, topology *redis.ClusterTopology) ([]string, error) {
    // 筛选所有主节点地址
    var masters []string
    for _, node := range topology.Nodes {
        if node.IsMaster() {
            masters = append(masters, node.Addr)
        }
    }
    // 返回第一个主节点地址即可
    if len(masters) > 0 {
        return []string{masters[0]}, nil
    }
    return nil, errors.New("no master nodes available in cluster")
}

// 初始化订阅专用客户端
var redisSubClient = redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{"c1:6379", "c2:6379", "c3:6379"},
    Route: subscribeRoute, // 应用自定义路由策略
    ReadOnly: false,
    RouteRandomly: false,
    RouteByLatency: false,
    PoolSize:       10,
    MinIdleConns:   10,
    // 其他超时、重试、TLS配置保持和原客户端一致
    DialTimeout:  5 * time.Second,
    ReadTimeout:  3 * time.Second,
    WriteTimeout: 3 * time.Second,
    PoolTimeout:  4 * time.Second,
    MaxRetries:      10,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,
    TLSConfig: &tls.Config{
        InsecureSkipVerify: true,
    },
})

2. 修改订阅逻辑使用专用客户端

将原来的redisClient.Subscribe替换为redisSubClient.Subscribe:

func InitializeStatusMessageSubscriber(statusTopic string, cm *ConnectionManager) {
    // 使用订阅专用客户端
    subscriber := redisSubClient.Subscribe(ctx, statusTopic)
    log.Printf("Subscribing to status subscriber in go-routine %s", statusTopic)
    var socketEventTypingStatus SocketEventTypingMessage

    channel := subscriber.Channel()

    for msg := range channel {
        if marshalError := json.Unmarshal([]byte(msg.Payload), &socketEventTypingStatus); marshalError != nil {
            log.Println("Error unmarshalling message to SocketEventTypingStatus message", marshalError)
            return
        }
        messageId := socketEventTypingStatus.Id
        expired := CheckIfKeyIsExpired(messageId)
        if expired {
            continue
        }

        id := socketEventTypingStatus.ChatId

        users := GetAllUsersForAGivenChat(id)

        for _, user := range users {
            cm.WriteMessageToUserSocket(user, msg.Payload)
        }
    }
}

3. 额外的幂等保障(可选但推荐)

即使解决了订阅重复的问题,也建议保留或完善现有的CheckIfKeyIsExpired(messageId)逻辑,通过消息ID做幂等处理。比如用Redis缓存已处理的消息ID,设置合理的过期时间(比如和输入状态的有效期一致),彻底避免任何场景下的重复消息推送。


内容的提问来源于stack exchange,提问作者MrWayne

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 20:08:12