千万并发直播答题系统架构
500万人同时在线的脉冲式高并发场景:从题目广播、答案收集、实时判分到结果展示的全链路低延迟架构
一、项目概述
1.1 业务背景
"百万奖金答题"是直播平台的杀手级拉新手段——晚8点整,500万用户同时涌入直播间,主持人宣布题目,用户在10秒内作答,答对者共同分享奖金。这类活动的技术挑战与常规高并发场景完全不同:
- 脉冲式洪峰:从0到500万用户只用10秒,系统必须在这10秒内完成扩容、稳定服务、毫秒级延迟响应
- 强公平性要求:所有用户看到的倒计时必须完全一致,不能出现"有人提前3秒看到题目"的作弊漏洞
- 答案洪峰写入:500万用户在10秒内同时提交答案,系统需要在倒计时结束后立即判分并展示结果
- 零容忍的单点故障:答题活动开始后,任何服务中断都会引发海量用户投诉和退款
1.2 核心矛盾分析
直播答题系统的本质矛盾是"写入洪峰"与"读取一致性"的叠加:
App/Web实时连接 → 接收题目 → 10秒内作答
题目下发 | 倒计时同步 | 答案接收 | 判分计算
答案洪峰削峰 | 批量判分 | 结果聚合
实时判分缓存 | 排行榜 | 订单记录
1.3 核心指标
二、技术架构设计
2.1 整体时序流程
一场答题活动的完整时序:
答题服务自动扩容到预设容量,WebSocket长连接服务准备就绪,Redis判分缓存预热
主持人点击"开始答题",题目通过答题服务推送到所有WebSocket连接(CDN加速)
所有用户同时开始倒计时,倒计时由服务端下发,不允许客户端本地计时
10秒窗口关闭,答案接收服务停止接收,Kafka开始批量消费判分
全部答案判分完成,结果通过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 核心数据
5.2 活动数据
| 场次 | 参与人数 | 峰值QPS | 判分用时 | 结果 |
|---|---|---|---|---|
| 第一场 | 312万 | 3200万/s | 9.1秒 | 成功 |
| 第三场 | 489万 | 4800万/s | 8.7秒 | 成功 |
| 第五场 | 521万 | 5100万/s | 8.3秒 | 成功 |
5.3 业务价值
- 拉新效率:每场活动新增注册用户30万+,ROI远超传统广告投放
- 用户体验:倒计时误差<50ms,所有用户感受到完全同步,无投诉"有人提前看到答案"
- 系统稳定性:5场活动全部稳定运行,零服务中断,零重大故障
💡 经验一:脉冲式高并发要用"扩容+保活"的组合拳
直播答题最大的坑是"扩容跟不上流量"。答案是提前预热——在活动开始前30分钟就完成所有连接建立和资源准备,让活动开始时系统处于就绪状态。切忌在活动开始时才启动扩容,Kubernetes的调度速度永远跟不上500万的脉冲洪峰。
💡 经验二:公平性是直播答题的生死线
一旦出现"有人提前看到答案"的投诉,整个活动就会被用户质疑是"内幕",口碑崩塌。服务端倒计时+加密传输+答案时间戳权威是三道防线,缺一不可。其中最容易出错的是客户端本地倒计时——永远不要让客户端控制倒计时,必须以服务端时间戳为准。
💡 经验三:Kafka是脉冲洪峰的天然克星
5000万/秒的答案写入,Redis和MySQL都扛不住,但Kafka可以。Kafka的削峰能力让后端判分引擎可以在自己能够承受的速度内消费,不被洪峰淹没。关键是把"写"和"处理"解耦——答案投递到Kafka是毫秒级完成,后端慢慢消费才是正确姿势。