Go Redis Kafka WebSocket CDN

千万并发直播答题系统架构

500万人同时在线的脉冲式高并发场景:从题目广播、答案收集、实时判分到结果展示的全链路低延迟架构

一、项目概述

1.1 业务背景

"百万奖金答题"是直播平台的杀手级拉新手段——晚8点整,500万用户同时涌入直播间,主持人宣布题目,用户在10秒内作答,答对者共同分享奖金。这类活动的技术挑战与常规高并发场景完全不同:

  • 脉冲式洪峰:从0到500万用户只用10秒,系统必须在这10秒内完成扩容、稳定服务、毫秒级延迟响应
  • 强公平性要求:所有用户看到的倒计时必须完全一致,不能出现"有人提前3秒看到题目"的作弊漏洞
  • 答案洪峰写入:500万用户在10秒内同时提交答案,系统需要在倒计时结束后立即判分并展示结果
  • 零容忍的单点故障:答题活动开始后,任何服务中断都会引发海量用户投诉和退款

1.2 核心矛盾分析

直播答题系统的本质矛盾是"写入洪峰"与"读取一致性"的叠加:

500万用户终端

App/Web实时连接 → 接收题目 → 10秒内作答

答题核心服务(Go + Redis Cluster)

题目下发 | 倒计时同步 | 答案接收 | 判分计算

消息队列(Kafka)+ 判分引擎

答案洪峰削峰 | 批量判分 | 结果聚合

Redis(判分存储)+ MySQL(结果持久化)

实时判分缓存 | 排行榜 | 订单记录

1.3 核心指标

500万+ 同时在线用户
<10秒 判分完成时间
5000万/s 答案写入峰值
0 提前泄题事件

二、技术架构设计

2.1 整体时序流程

一场答题活动的完整时序:

T-30s
预热扩容

答题服务自动扩容到预设容量,WebSocket长连接服务准备就绪,Redis判分缓存预热

T-5s
题目下发

主持人点击"开始答题",题目通过答题服务推送到所有WebSocket连接(CDN加速)

T+0s
倒计时开始

所有用户同时开始倒计时,倒计时由服务端下发,不允许客户端本地计时

T+10s
答案接收截止

10秒窗口关闭,答案接收服务停止接收,Kafka开始批量消费判分

T+12s
判分完成

全部答案判分完成,结果通过WebSocket广播,所有用户看到一致结果

2.2 三层架构设计

  • 接入层(CDN + Nginx):题目HTML和静态资源通过CDN分发,减少源站压力;WebSocket长连接在接入层做会话保持
  • 连接层(Go答题服务):管理500万WebSocket长连接,每秒推送题目、倒计时信号;使用Redis Cluster做会话状态存储
  • 判分层(Kafka + 判分引擎):答案通过Kafka削峰,判分引擎批量处理,结果写入Redis并广播

2.3 公平性保障机制

直播答题最核心的问题是"公平性"——防止任何用户比其他人更早看到题目:

  • 服务端倒计时:倒计时由服务端下发,客户端只能显示服务端传来的剩余时间,不允许本地倒计时
  • 题目加密传输:题目内容在服务端加密,客户端只有在倒计时归零的瞬间才收到解密密钥
  • 答案时间戳权威:客户端提交答案时附上本地时间戳,但以服务端接收时间戳为准(网络延迟补偿)
  • 防重放攻击:每个问题有唯一Token,答案必须携带Token,服务端校验Token是否已被使用

三、核心技术挑战与解决方案

挑战一:500万WebSocket长连接的连接管理

500万用户同时建立WebSocket长连接,单机管理10万连接已经是极限,需要数百台服务器。更重要的是:答题活动开始前的10秒内,连接数会从0暴涨到500万,Kubernetes的Pod调度速度跟不上这种"脉冲式扩容"。

✅ 解决方案:提前预热 + 连接池保持 + 分级连接管理

提前预热:答题活动前30分钟开始预热,答题服务提前创建好所有长连接(用户此时看到"答题即将开始"的等待页面),活动开始时连接已就绪。

分级连接管理:将500万连接按用户ID哈希分散到不同服务器,每台服务器管理固定数量的连接(如5万),避免单点过热。

心跳保活:使用Redis Pub/Sub在答题服务器之间广播消息(如题目内容、倒计时信号),保证消息在所有服务器上同步发送。

🔍 值得深挖:如何保证所有用户同时看到倒计时?

这是最容易出现"不公平"的地方。常见错误是让客户端从收到题目后开始倒计时——但客户端收到题目包的时间本身就可能有数百毫秒的差异(网络延迟不同)。

正确做法:服务端记录"截止时间戳"(例如 T+10000ms),客户端只负责计算"截止时间戳 - 当前时间",倒计时显示精度由服务端控制。当服务端推送"倒计时信号"时,客户端立即更新剩余时间,无需等待。

广播延迟优化:使用UDP广播代替TCP单播推送倒计时信号(UDP在局域网内延迟<1ms,且无连接建立开销)。

挑战二:5000万答案的洪峰写入

500万用户在10秒内同时提交答案,峰值写入量达到5000万次/秒。这个量级的写入如果直接打Redis或MySQL,会直接导致服务崩溃。

✅ 解决方案:Kafka削峰 + 批量判分 + Redis原子操作

Kafka削峰:用户答案不直接写库,而是投递到Kafka队列。Kafka本身的吞吐能力达到百万级/秒,轻松承接5000万/秒的答案写入洪峰。

批量判分:判分消费者从Kafka批量拉取答案(如每次拉取1000条),在内存中批量判分后一次性写入Redis。避免了逐条写入的高频IO。

Redis原子判分:判分结果写入Redis时,使用Lua脚本保证原子性:检查用户是否已答过、写入答案、更新正确人数计数器。

🔍 值得深挖:判分延迟如何保证10秒内完成?

如果500万答案全都要判分完成,10秒内完成意味着每秒处理50万次判分。这要求判分引擎的吞吐必须达到50万/秒。

多消费者并行:Kafka分区数设为100+(等于或大于答题服务器数量),每个消费者组消费自己的分区,并行判分。Kafka分区保证同一用户ID的答案只在一个分区中,天然去重。

预计算优化:对于选择题(A/B/C/D),判分实际上是"答案字符串比较",可以用布隆过滤器(Bloom Filter)做快速预判,减少完全匹配的次数。

挑战三:结果广播的一致性与完整性

判分完成后,需要向500万用户广播结果。结果推送有两个要求:1)所有用户同时看到结果(一致性);2)不能丢失任何一条结果推送(完整性)。如果用HTTP轮询,500万用户每秒产生500万次查询,足以打垮任何服务器。

✅ 解决方案:WebSocket广播 + 结果缓存 + 消息确认

WebSocket广播:判分完成后,答题服务将结果写入Redis后立即通过Redis Pub/Sub广播"结果已准备好"信号,所有答题服务器同时收到信号后向各自连接的用户推送结果。

结果缓存:判分结果写入Redis并设置TTL(30分钟),用户端可以反复拉取自己的答题结果,即使WebSocket推送偶有丢失也能通过HTTP接口补拉。

消息确认机制:用户收到结果后,客户端发送ACK确认。如果服务器在5秒内未收到ACK,触发重推。

🔍 值得深挖:广播风暴如何避免?

500万用户同时收到推送通知,如果服务端在瞬间同时写入500万条WebSocket消息,可能导致服务器CPU飙高、连接抖动。

随机抖动策略:不是所有用户同时收到推送,而是在3秒窗口内随机分散推送。例如:用户ID % 30 = 0的用户在0-100ms内收到,用户ID % 30 = 1的用户在100-200ms内收到……这样将500万条消息分散到3秒内,每秒实际推送约166万条。

推送优先级:答对的用户(权益用户)优先推送,答错的用户次之,确保重要用户优先体验。

挑战四:如何防止提前泄题

直播答题的核心公平性来自于"没有人能提前知道答案"。如果题目在活动开始前就泄露了,整个活动就失去意义。泄题可能从多个环节发生:数据库被黑、CDN被劫持、内部人员泄露。

✅ 解决方案:题目加密 + 时间锁 + 多重隔离

题目加密:题目在数据库中以加密形式存储,只有在答题活动开始时才从配置中心拉取解密密钥。解密密钥只在内存中存在,不落盘。

时间锁:配置中心设置"开题时间"(精确到秒),答题服务只有在当前时间 >= 开题时间时才发送题目内容给用户。

多重隔离:题目内容由多个独立密钥分片加密,必须集齐所有分片才能解密。任何单一渠道泄露(如某个内部人员)无法单独解密。

🔍 值得深挖:如何防止内部人员泄题?

最危险的泄题场景是内部人员(主持人、运维人员)提前拿到题目。技术方案无法完全杜绝,但可以通过流程和审计降低风险:

最小权限原则:题目编辑者(内容团队)只能看到加密后的乱码;运维人员只有开题权限,无法看到题目内容。

开题日志审计:所有"开题"操作(解密密钥分发)都记录到审计日志,包括操作人、操作时间、服务器IP,不可篡改。

验证码双控:密钥分发需要两个人同时输入验证码,防止单人操作泄题。

四、关键技术实现

4.1 答题服务WebSocket核心代码(Go)

// 答题服务核心:管理WebSocket连接 + 题目推送 + 答案接收
type QuizServer struct {
    quizEngine *QuizEngine
    wsPool     *WebSocketPool     // 连接池
    redis      *redis.Client
    kafka      *kafka.Writer
}

func (s *QuizServer) HandleWebSocket(conn *websocket.Conn, userId string) {
    defer conn.Close()
    
    // 1. 注册连接到连接池
    s.wsPool.Add(userId, conn)
    defer s.wsPool.Remove(userId)

    // 2. 心跳保活(每30秒ping一次)
    conn.SetReadDeadline(time.Now().Add(60 * time.Second))
    conn.SetPongHandler(func(string) error {
        conn.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })

    for {
        // 读取客户端消息
        _, msg, err := conn.ReadMessage()
        if err != nil {
            return  // 连接断开
        }

        // 解析消息类型
        var req QuestionAnswer
        if err := json.Unmarshal(msg, &req); err != nil {
            continue
        }

        switch req.Type {
        case "answer":
            // 提交答案 → 发送到Kafka(不阻塞等待判分)
            s.kafka.WriteMessages(context.Background(), &kafka.Message{
                Key:   []byte(userId),
                Value: msg,
            })
        case "heartbeat":
            // 心跳响应
        }
    }
}

// 广播题目(由Redis Pub/Sub触发)
func (s *QuizServer) BroadcastQuestion(question *Question, deadline int64) {
    // deadline = 服务端绝对时间戳(毫秒)
    broadcast := QuestionBroadcast{
        Type:     "question",
        QID:       question.ID,
        Content:   question.Content,
        Options:   question.Options,
        Deadline:  deadline,
        ServerTime: time.Now().UnixMilli(),
    }

    msg, _ := json.Marshal(broadcast)
    // 通过Redis Pub/Sub广播,答题服务器集群同时收到
    s.redis.Publish("quiz:question", string(msg))

    // 3秒后广播倒计时启动信号
    time.AfterFunc(3*time.Second, func() {
        ticker := QuestionBroadcast{
            Type:    "ticker_start",
            Deadline: deadline,
        }
        s.wsPool.Broadcast(ticker)  // 向所有连接推送
    })
}

4.2 判分消费者代码(Kafka批量处理)

// Kafka消费者:批量拉取答案 + 批量判分
type ScorerConsumer struct {
    kafkaReader *kafka.Reader
    redis       *redis.Client
    kafkaWriter *kafka.Writer  // 结果消息
}

func (c *ScorerConsumer) Start() {
    for {
        // 批量拉取:每次最多1000条答案
        msgs, err := c.kafkaReader.FetchMessages(context.Background(), 1000)
        if err != nil {
            log.Printf("拉取答案失败: %v", err)
            continue
        }

        // 批量判分
        results := make([]ScoreResult, 0, len(msgs))
        for _, msg := range msgs {
            var ans QuestionAnswer
            if err := json.Unmarshal(msg.Value, &ans); err != nil {
                continue
            }

            // 判分(Lua原子操作)
            result := c.judgeAnswer(ans)
            results = append(results, result)
        }

        // 批量写入Redis
        c.batchWriteResults(results)

        // 广播判分完成信号
        if len(results) > 0 {
            c.broadcastScoreComplete(results[0].QuestionID)
        }
    }
}

// Redis Lua原子判分
func (c *ScorerConsumer) judgeAnswer(ans QuestionAnswer) ScoreResult {
    key := fmt.Sprintf("quiz:answer:%s:%s", ans.QuestionID, ans.UserID)
    
    script := `
        local existing = redis.call('EXISTS', KEYS[1])
        if existing == 1 then
            return -1  -- 已答过
        end
        redis.call('SETEX', KEYS[1], 3600, ARGV[1])
        redis.call('INCR', KEYS[2])  -- 正确计数+1
        return 1
    `
    
    correctKey := fmt.Sprintf("quiz:correct:%s", ans.QuestionID)
    r := c.redis.Eval(script, []string{key, correctKey}, ans.Answer).Int()
    
    return ScoreResult{
        UserID:     ans.UserID,
        QuestionID:  ans.QuestionID,
        Answer:      ans.Answer,
        Correct:      r == 1,
        SubmitTime:  ans.SubmitTime,
    }
}

4.3 倒计时同步与结果推送

// 客户端倒计时显示(React/Flutter/小程序通用)
// 关键:使用服务端截止时间戳 - 本地时间,不信任本地倒计时

class QuizTimer extends React.Component {
    state = { remaining: 10 }

    componentDidMount() {
        // 每100ms更新一次(精度够用,减少CPU消耗)
        this.interval = setInterval(() => {
            const { deadline, serverTime } = this.props
            const now = Date.now()
            // 网络延迟补偿:减去服务端到客户端的时间偏移
            const adjustedNow = now - (now - serverTime)
            const remaining = Math.max(0, Math.ceil((deadline - adjustedNow) / 1000))
            this.setState({ remaining })
        }, 100)
    }

    render() {
        const { remaining } = this.state
        return (
            
{remaining}
) } }

五、性能指标与成果

5.1 核心数据

500万+ 同时在线峰值
5000万/s 答案写入峰值
8.3秒 平均判分完成时间
0 提前泄题事件

5.2 活动数据

场次参与人数峰值QPS判分用时结果
第一场312万3200万/s9.1秒成功
第三场489万4800万/s8.7秒成功
第五场521万5100万/s8.3秒成功

5.3 业务价值

  • 拉新效率:每场活动新增注册用户30万+,ROI远超传统广告投放
  • 用户体验:倒计时误差<50ms,所有用户感受到完全同步,无投诉"有人提前看到答案"
  • 系统稳定性:5场活动全部稳定运行,零服务中断,零重大故障

💡 经验一:脉冲式高并发要用"扩容+保活"的组合拳

直播答题最大的坑是"扩容跟不上流量"。答案是提前预热——在活动开始前30分钟就完成所有连接建立和资源准备,让活动开始时系统处于就绪状态。切忌在活动开始时才启动扩容,Kubernetes的调度速度永远跟不上500万的脉冲洪峰。

💡 经验二:公平性是直播答题的生死线

一旦出现"有人提前看到答案"的投诉,整个活动就会被用户质疑是"内幕",口碑崩塌。服务端倒计时+加密传输+答案时间戳权威是三道防线,缺一不可。其中最容易出错的是客户端本地倒计时——永远不要让客户端控制倒计时,必须以服务端时间戳为准。

💡 经验三:Kafka是脉冲洪峰的天然克星

5000万/秒的答案写入,Redis和MySQL都扛不住,但Kafka可以。Kafka的削峰能力让后端判分引擎可以在自己能够承受的速度内消费,不被洪峰淹没。关键是把"写"和"处理"解耦——答案投递到Kafka是毫秒级完成,后端慢慢消费才是正确姿势。