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.1.1 系统整体架构与时序联动

下图把整场答题活动从 T-30s 到 T+12s 的端到端链路在架构视角下展开:用户终端通过 CDN 接入,与 Go 答题服务建立 WebSocket 长连接;题目与倒计时信号经 Redis Pub/Sub 在答题服务集群中同步广播;用户答案投递到 Kafka,由判分引擎批量消费;判分结果写入 Redis 排行榜,触发 WebSocket 广播 + 积分服务落库 + IM 通知主播。整体形成"推流 → 答题 → 判分 → 积分 → 通知"的闭环。

flowchart TB subgraph USR["用户终端 (App / Web)"] U1["500万 用户"] U2["WebSocket 长连接"] end subgraph EDGE["接入层"] CDN["CDN
静态资源 / 题目包"] NGX["Nginx
会话保持 / WSS 终结"] end subgraph CONN["连接层 (Go)"] QS["答题服务集群
QuizServer"] WSP["WebSocket Pool
连接池"] end subgraph MQ["消息中间件"] RP["Redis Pub/Sub
题目 / 倒计时广播"] KAF["Kafka
答案洪峰削峰"] end subgraph JUDGE["判分层"] SC["ScorerConsumer
批量判分引擎"] LUA["Redis Lua
原子判分 + 防重放"] end subgraph DATA["数据层"] RS["Redis Cluster
会话 / 判分结果"] DB[("MySQL
答题记录 / 积分")] MQS["排行榜 SortedSet"] end subgraph NOTIF["通知 / 推送"] IM["IM 服务
站内信 / 主播弹幕"] PUSH["Push 通道
交易结果推送"] end U1 --> U2 --> NGX CDN -->|题目静态包| NGX NGX --> WSP --> QS QS <-.注册 / 心跳.-> RS QS -->|Publish 题目 + 倒计时| RP RP -.订阅广播.-> QS QS -->|题目推送| WSP WSP -->|题目 / 倒计时| U2 U2 -->|10秒内作答| WSP WSP -->|WriteMessages| KAF KAF -->|批量 FetchMessage| SC SC -->|Eval Lua| LUA LUA -->|INCR 正确数| RS SC -->|ZADD 排行榜| MQS MQS -->|Top N 结果| SC SC -->|批量写结果| DB SC -->|Publish 判分完成| RP RP -.触发.-> QS QS -->|WebSocket 广播结果| WSP WSP -->|结果通知| U2 DB -->|积分变更事件| IM DB -->|积分变更事件| PUSH classDef user fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef edge fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef conn fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef mq fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef judge fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef data fill:#1a1a2e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef notif fill:#16213e,stroke:#00d4ff,stroke-width:2px,color:#ffffff class U1,U2 user class CDN,NGX edge class QS,WSP conn class RP,KAF mq class SC,LUA judge class RS,DB,MQS data class IM,PUSH notif

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)做快速预判,减少完全匹配的次数。

📊 答案洪峰链路与判分并发视图

下图描绘了 5000 万/秒答案写入的处理链路:500 万 WebSocket 连接把答案投递到 Kafka 集群(按用户 ID 分区),判分消费者组通过 100+ 分区并行拉取,每个消费者在内存中批量判分后用 Lua 脚本原子写入 Redis。判分完成的 QID 通过 Redis Pub/Sub 触发 WebSocket 广播,最终结果经排行榜 ZSET 取 Top N 并发奖。

flowchart LR subgraph IN["接入侧 (500万用户)"] UC["用户终端
10秒内提交"] WS["WebSocket Pool
答题服务"] end subgraph KF["Kafka 集群"] P1[("Partition 1
user_id % 100 = 0")] P2[("Partition 2")] PN[("... 100+ Partitions
同 user 仅落 1 个分区")] end subgraph CG["判分消费者组"] C1["Consumer 1
批量 1000 条/批"] C2["Consumer 2"] CN["Consumer N
并行判分"] end subgraph RD["Redis Cluster"] ANS[("quiz:answer:{qid}:{uid}
SETEX 3600")] CRT[("quiz:correct:{qid}
INCR 正确数")] RANK[("leaderboard:{qid}
ZSET 排行榜")] BF["Bloom Filter
A/B/C/D 预判"] end subgraph OUT["结果广播"] PUB["Redis Pub/Sub
scored:{qid}"] BC2["答题服务集群
WebSocket 广播"] TOP["Top N 取奖
积分服务落库"] end UC -->|答案 JSON| WS WS -->|WriteMessages Key=user_id| P1 & P2 & PN P1 --> C1 P2 --> C2 PN --> CN C1 & C2 & CN -.预判.-> BF BF -.是答案.-> C1 & C2 & CN C1 & C2 & CN -->|Eval Lua
原子判分| ANS C1 & C2 & CN -->|INCR| CRT C1 & C2 & CN -->|ZADD 分数| RANK C1 & C2 & CN -->|Publish 判分完成| PUB PUB -.订阅.-> BC2 BC2 -->|500万推送| WS RANK -->|ZREVRANGE Top N| TOP TOP -->|入账| BC2 classDef user fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef kafka fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef consumer fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef redis fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef bc fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff class UC,WS user class P1,P2,PN kafka class C1,C2,CN consumer class ANS,CRT,RANK,BF redis class PUB,BC2,TOP bc

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

判分完成后,需要向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.2.1 判分与积分服务数据流

下图把判分消费者代码映射到端到端数据流:用户在 WebSocket 上提交答案(携带 QID、UID、答案、时间戳),由 QuizServer 写入 Kafka 对应分区;ScorerConsumer 批量拉取 1000 条/次,调用 Redis Lua 完成"判分 + 防重放 + 正确数 +1"四步原子操作;结果分两路:一路 ZADD 排行榜 + 持久化 MySQL,另一路通过 Redis Pub/Sub 触发答题服务 WebSocket 广播;同时按题目配置的奖金规则,积分服务订阅积分变更事件,发放奖金到用户钱包。

flowchart TB subgraph S1["Step 1: 答案投递"] A1["WebSocket 客户端
submit_answer {qid,uid,ans}"] A2["QuizServer
WriteMessages Key=user_id"] end subgraph S2["Step 2: Kafka 队列"] KP[("Kafka Topic: quiz-answers
100+ partitions
同 user 仅落 1 个分区")] end subgraph S3["Step 3: 批量判分"] SC1["FetchMessages batch=1000"] SC2["Lua 脚本原子判分
1. EXISTS 防重
2. SETEX 答案
3. INCR 正确数"] end subgraph S4["Step 4: 结果分发"] RR["Redis
结果缓存 (TTL 30min)"] ZS["ZSET 排行榜"] MY[("MySQL
持久化判分结果")] PS["Redis Pub/Sub
scored:{qid}"] end subgraph S5["Step 5: 广播 + 积分"] BQ["答题服务集群
订阅 scored 事件"] WS2["WebSocket 广播结果"] PT["积分服务
订阅积分变更"] WAL[("用户钱包
MySQL")] IM2["IM 通知
中奖用户 / 主播弹幕"] end A1 -->|JSON| A2 -->|Producer Record| KP KP -->|Consumer Group| SC1 --> SC2 SC2 -->|SETEX| RR SC2 -->|INCR| ZS SC2 -->|batch insert| MY SC2 -->|PUBLISH scored| PS PS -.订阅.-> BQ --> WS2 --> A1 ZS -->|ZREVRANGE Top N| PT PT -->|UPDATE balance| WAL PT -->|中奖事件| IM2 classDef s1 fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef s2 fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef s3 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff classDef s4 fill:#16213e,stroke:#7b61ff,stroke-width:2px,color:#ffffff classDef s5 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px,color:#ffffff class A1,A2 s1 class KP s2 class SC1,SC2 s3 class RR,ZS,MY,PS s4 class BQ,WS2,PT,WAL,IM2 s5

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是毫秒级完成,后端慢慢消费才是正确姿势。