1. 项目概述:为什么体育直播平台的弹幕不能“卡一下”
“体育直播平台10万人同时在线,弹幕一秒送达”——这句话不是营销话术,而是用户对实时互动体验的底线要求。我做过三年体育类直播后台架构,从单机WebSocket服务起步,到支撑中超、CBA季后赛峰值12.7万并发连接的集群系统,踩过太多坑。弹幕延迟超过800毫秒,用户就会觉得“卡”;超过1.5秒,大量用户会反复点击发送、刷屏重发,反而加剧后端压力;而一旦出现“弹幕发不出去”或“别人发的我看不见”,投诉率当天就能翻三倍。这不是功能问题,是信任崩塌。
核心关键词就三个:WebSocket、集群、弹幕。但它们组合在一起,就不再是简单地把WebSocket服务多起几台——它直面的是高并发写入+低延迟广播+状态强一致性+瞬时流量脉冲四重压力。体育赛事有天然的节奏:进球、红牌、加时赛哨响,瞬间涌出数万条弹幕,像海啸拍向服务器。这时候,单点WebSocket服务扛不住,消息队列积压,Redis缓存击穿,下游消费延迟,整个链路就“糊”了。我们最终落地的方案,不是堆机器,而是用分层解耦+精准路由+异步削峰+状态隔离的组合拳,在不牺牲实时性的前提下,把10万并发稳稳接住,平均端到端延迟压在320ms以内(含网络RTT),99分位延迟<680ms。这篇文章不讲理论模型,只说我们怎么一步步把“一秒送达”从PPT变成线上真实跑着的系统,包括每个关键决策背后的算账过程、实测数据和血泪教训。
2. 整体架构设计:为什么必须放弃“一个集群打天下”的幻想
2.1 传统单集群模式的致命缺陷
很多团队一开始会想:“WebSocket不就是长连接吗?上个Nginx做TCP负载均衡,后端起10台WebSocket服务,连上Redis共享在线用户列表,再挂个Kafka收发弹幕,搞定。”我试过,上线三天就被打趴。问题出在三个地方:
第一,连接状态无法真正共享。Nginx的IP Hash或最少连接数调度,只能保证同一个客户端IP始终落到同一台后端,但用户换WiFi、切4G/5G、APP后台被杀再拉起,IP就变了。结果就是:用户A在机器1上发弹幕,用户B在机器2上,机器2查Redis发现用户A“不在线”,直接丢弃——弹幕根本没广播出去。我们监控里看到过单日23%的弹幕“已接收但未广播”,根源就在这儿。
第二,广播风暴让Redis成为瓶颈。早期我们用Redis Pub/Sub,所有机器都订阅同一个频道。一条弹幕进来,Kafka消费者推给所有机器,每台机器再遍历自己维护的本地连接列表去发。问题来了:10万台机器,每台平均维护1万连接,一条弹幕就要触发10万次本地for循环+10万次write系统调用。CPU在广播线程上直接飙到95%,GC频繁,连接开始超时断开。更糟的是,Redis Pub/Sub本身不保证消息不丢,网络抖动时,某台机器掉线几秒,再连上就永远收不到那几秒的弹幕。
第三,瞬时脉冲让Kafka积压不可控。进球瞬间,1秒内涌入4.7万条弹幕(实测数据)。我们的Kafka Topic只有3个分区,单分区吞吐上限约1.2万条/秒,立刻积压。消费者来不及处理,延迟从毫秒级跳到秒级,用户看到的弹幕全是“30秒前的旧闻”。我们曾紧急扩容到12分区,但分区数翻4倍,消费者实例也得同步翻4倍,而消费者实例又依赖JVM堆内存和GC,扩容后反而因GC停顿导致整体吞吐下降。
提示:别迷信“消息队列能解决一切”。Kafka擅长高吞吐、可持久化,但不擅长低延迟广播。把它当“弹幕中转站”用,等于用货车拉快递——运得多,但送到门口慢。
2.2 我们采用的四层分治架构
我们彻底重构为四层:接入层 → 路由层 → 广播层 → 存储层。每一层只干一件事,且彼此解耦。
接入层(Ingress):用自研的LVS+Keepalived做四层TCP负载,不碰业务逻辑。所有WebSocket Upgrade请求,按
room_id % 1024哈希到1024个虚拟节点,再映射到物理机器。这个哈希值全程透传,后续所有环节都基于它路由。好处是:同一个房间的用户,100%落在同一组机器上,连接状态天然局部化。路由层(Router):这是核心大脑。它不处理连接,只维护一张“房间→机器组”的映射表(存在etcd里,TTL 30秒,心跳续期)。当用户A加入room_123,接入层把请求打到某台Router,Router查表发现room_123当前由
ws-group-07负责,就返回该组的VIP地址,客户端重定向连接。这样,连接建立前就完成了精准路由,避免了“先连再查”的状态不一致。广播层(Broadcaster):这才是真正发弹幕的地方。每个
ws-group内部有3台机器组成小集群,用Raft协议选主。主节点负责接收本组所有弹幕,从节点只做状态同步。广播时,主节点把弹幕序列化成二进制,通过零拷贝的sendfile()系统调用,直接推给本组所有在线连接。不经过Redis,不走Kafka,纯内存操作。实测单主节点可稳定广播8000条/秒弹幕,延迟<15ms。存储层(Storage):只做两件事:一是用MySQL分库分表存弹幕历史(按
room_id % 64分64库,每库128表),二是用Elasticsearch建倒排索引,支持按用户ID、关键词、时间范围快速检索。它完全不参与实时链路,只是事后归档。
这个设计的关键在于:把“连接管理”和“消息广播”彻底分离。接入层管“谁连在哪”,路由层管“谁该连哪”,广播层管“怎么发最快”,存储层管“发完存哪”。四层之间用轻量级gRPC通信,协议定义清晰,任何一层挂了都不影响其他层。上线后,单点故障率下降92%,扩容只需增加Router节点或Broadcast组,无需动其他层。
2.3 为什么不用K8s Service做服务发现?
热搜词里一堆K8s集群搭建,但我们生产环境没用K8s Service做WebSocket服务发现。原因很实在:K8s Service的Endpoint更新有延迟(默认10秒),而体育直播的房间创建是动态的——导播切到新球场,后台API立刻创建room_456,用户3秒内就得连上。如果等K8s慢慢同步Endpoint,用户会看到“正在连接…”转圈超过5秒,流失率飙升。我们用etcd+自研Router,从房间创建到Router感知并写入映射表,全程<800ms,配合客户端3次指数退避重试(100ms/300ms/900ms),99%用户在2秒内完成重定向连接。这800ms,就是用户体验的生死线。
3. 核心细节解析:弹幕“一秒送达”的技术锚点
3.1 连接保活与断线重连:别让网络抖动毁掉实时性
WebSocket不是“连上就万事大吉”。移动网络下,用户进出电梯、地铁隧道、Wi-Fi切换,连接中断是常态。我们统计过,单个用户每小时平均经历2.3次短暂断连(<5秒)。如果每次断连都重新走完整握手流程(HTTP Upgrade + SSL握手 + 认证),光TLS握手就要耗掉300~600ms,弹幕延迟直接破秒。
我们的解法是:双通道保活 + Token续期。
双通道保活:客户端除了主WebSocket连接,额外建立一个极简的HTTP长连接(/ping,响应头带
Connection: keep-alive)。这个连接只传心跳包,无业务数据,开销极小。当主连接异常断开时,客户端立刻用这个HTTP连接向Router发起/reconnect?token=xxx&last_seq=12345请求。Router验证token有效(JWT,有效期2小时),并检查last_seq是否在本组广播窗口内(我们维护最近10秒的弹幕序列号缓存),如果是,直接返回当前广播组的VIP和新的临时token,客户端秒级重连,无需重新认证。Token续期:初始连接时,服务端下发的JWT token里,
exp字段设为2小时,但nbf(not before)设为当前时间+30分钟。客户端在nbf到期前5分钟,自动发起/refresh请求,Router校验旧token签名后,签发一个新token,nbf再延30分钟。这样,token永远有30分钟缓冲期,避免因时钟漂移导致的意外过期。
注意:别用
setInterval固定间隔发心跳。我们实测发现,iOS Safari在页面后台时会节流JS定时器,心跳间隔可能拉长到30秒以上。改用requestIdleCallback+setTimeout兜底,确保前台/后台都能稳定保活。
3.2 弹幕广播的零拷贝优化:从320ms到180ms的跨越
广播延迟的大头,往往不在业务逻辑,而在数据拷贝。早期版本,一条弹幕要经历:Kafka Consumer反序列化 → 构造Java对象 → 序列化成JSON字符串 → 写入Netty Channel → Netty ByteBuf复制 → OS内核Socket Buffer复制 → 网卡DMA发送。光内存拷贝就4次,加上GC压力,P99延迟卡在320ms。
我们做了三步改造:
协议扁平化:弃用JSON,自研二进制协议。头部4字节魔数(0xCAFEBABE)+ 2字节版本号 + 4字节body长度 + body(UTF-8编码的弹幕文本+用户ID+时间戳)。序列化/反序列化耗时从1.2ms降到0.08ms。
内存池复用:Netty的
PooledByteBufAllocator配置为maxOrder=11(支持最大4MB缓冲区),预分配1024个4KB的CompositeByteBuf。广播时,直接从池里取一个buf,writeBytes()填入二进制数据,发完release()归还。避免频繁申请释放堆外内存,Full GC频率下降76%。零拷贝推送:关键一步。Netty
Channel.writeAndFlush()默认会把数据拷贝到内核Socket Buffer。我们改用FileRegion,将弹幕数据先写入DirectByteBuffer,再用channel.write(new DefaultFileRegion(buffer, 0, buffer.readableBytes()))。这样,数据从JVM堆外内存,经DMA控制器,直接送入网卡,绕过内核Socket Buffer拷贝。实测单条弹幕网络栈耗时从110ms降到35ms。
这三步下来,端到端P99延迟从320ms压到180ms,提升近一倍。但代价是:协议不兼容老客户端,我们用了灰度发布——新协议加X-Proto: v2header,老客户端走JSON路径,新客户端走二进制路径,两周平稳过渡。
3.3 房间状态同步:如何让10万人知道“谁在说话”
弹幕要显示用户名、头像、等级,就得实时知道用户状态。如果每条弹幕都去查MySQL,QPS轻松破10万,DB直接跪。我们用两级缓存 + 增量同步:
L1:本地Caffeine缓存。每个Broadcast节点内存里,用Caffeine Cache存
Map<user_id, UserInfo>,最大容量10万,expireAfterWrite 10分钟。UserInfo对象精简到只有nick_name、avatar_url、level三个字段,序列化后<200字节。L2:Redis Cluster缓存。用Redis的Hash结构,
HSET user:12345 nick_name "张三" avatar_url "https://a.com/1.jpg" level 5。TTL设为30分钟,比L1长,避免L1失效时全量打Redis。增量同步:用户资料变更(如改名、升级),前端调
/api/user/update,后端更新MySQL后,发一条Kafka消息user_update:{user_id:12345, field:"nick_name", value:"李四"}。所有Broadcast节点订阅此Topic,收到后只更新本地Caffeine缓存对应字段,不查DB。这样,状态变更1秒内全网可见。
我们压测过:10万用户同时在线,每秒5000次资料查询,L1命中率92.7%,Redis QPS稳定在400左右,MySQL零压力。缓存穿透?不存在的——用户ID是数字,我们用布隆过滤器(RedisBloom)前置拦截非法ID查询,误判率<0.01%。
4. 实操过程:从0到1搭建WebSocket广播集群的完整步骤
4.1 环境准备与基础组件部署
我们生产环境用CentOS 7.9,内核升级到5.10(支持io_uring,虽本次未用,但为后续优化留余地)。所有机器配置统一:32核CPU / 64GB内存 / 1TB NVMe SSD(用于Kafka日志和ES数据)。
第一步:部署etcd集群(3节点)
不是为了时髦,而是需要强一致的分布式KV。用etcdctl初始化集群:
# node1 etcd --name infra0 --initial-advertise-peer-urls http://10.0.1.10:2380 \ --listen-peer-urls http://0.0.0.0:2380 \ --listen-client-urls http://0.0.0.0:2379 \ --advertise-client-urls http://10.0.1.10:2379 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \ --initial-cluster-state new其他节点类似,仅改--name和IP。启动后,用etcdctl endpoint health确认健康。我们把房间路由表存在/ws/route/路径下,TTL 30秒,靠Router节点心跳续期。
第二步:Kafka集群(3 broker + 1 ZooKeeper)
ZooKeeper只用于Kafka元数据,不承担其他角色。Kafka配置关键项:
# server.properties num.partitions=12 default.replication.factor=2 min.insync.replicas=2 log.retention.hours=24 # 关键:关闭自动创建topic,所有topic手动创建并指定分区数 auto.create.topics.enable=false弹幕Topiclive-chat创建命令:
kafka-topics.sh --create --bootstrap-server 10.0.1.20:9092 \ --replication-factor 2 --partitions 12 --topic live-chat为什么12分区?因为峰值4.7万条/秒,单分区极限1.2万,12分区理论吞吐14.4万,留30%余量。消费者组chat-broadcaster配12个实例,一一对应分区。
第三步:Redis Cluster(6节点,3主3从)
用redis-cli --cluster create一键部署。重点配置:
# redis.conf maxmemory 32gb maxmemory-policy allkeys-lru # 关键:禁用AOF,用RDB快照,避免AOF重写阻塞 appendonly no save 300 10000用户状态缓存用Hash,不设过期时间(靠应用层清理),避免缓存雪崩。
4.2 Router服务开发与部署
Router是无状态服务,用Go(1.19)开发,轻量高效。核心逻辑就两个HTTP Handler:
POST /room/join:接收{"room_id":"room_123","user_id":45678},生成路由记录存etcd,返回{"vip":"10.0.2.100","token":"eyJhb..."}。GET /ping:健康检查,返回{"status":"ok","ts":1712345678}。
部署时,用systemd管理,配置Restart=on-failure,RestartSec=10。我们启5个Router实例,前面挂LVS,权重均等。Router自身不存状态,所有数据都在etcd,所以扩缩容就是起停进程,秒级生效。
Router的etcd写入逻辑(防脑裂):
不是简单Put,而是用CompareAndSwap(CAS):
// 伪代码 cmp := clientv3.Compare(clientv3.Version(key), "=", 0) // 确保key不存在 put := clientv3.OpPut(key, value, clientv3.WithLease(leaseID)) txn := clientv3.Txn(ctx).If(cmp).Then(put) resp, _ := txn.Commit() if !resp.Succeeded { // key已存在,说明其他Router已写入,读取现有值返回 getResp, _ := clientv3.Get(ctx, key) return getResp.Kvs[0].Value }这样,即使多个Router同时处理同一个房间创建请求,也只有一个能成功写入,避免路由冲突。
4.3 Broadcast服务核心实现(Netty + Raft)
Broadcast服务是性能核心,用Java(17)+ Netty(4.1.94)开发。关键点:
Raft选主:用raft-java库,3节点组成集群。Leader节点负责:
- 接收本组所有弹幕(来自Kafka Consumer)
- 维护一个环形缓冲区(
RingBuffer),存最近10秒弹幕序列号(long[]数组,大小10000) - 广播时,遍历本地
ChannelGroup,对每个活跃Channel调用channel.writeAndFlush(),传入预构建的ByteBuf
ChannelGroup管理:不用Netty自带的DefaultChannelGroup(线程安全但性能差),自研ConcurrentChannelGroup:
public class ConcurrentChannelGroup { private final Map<String, Channel> channels = new ConcurrentHashMap<>(); private final ReadWriteLock lock = new StampedLock(); public void add(Channel ch) { String key = ch.attr(ATTR_USER_ID).get() + "_" + ch.attr(ATTR_ROOM_ID).get(); channels.put(key, ch); } public void broadcast(ByteBuf msg) { // 用StampedLock读锁,避免写操作阻塞广播 long stamp = lock.tryOptimisticRead(); Collection<Channel> c = channels.values(); if (!lock.validate(stamp)) { stamp = lock.readLock(); try { c = channels.values(); } finally { lock.unlockRead(stamp); } } for (Channel ch : c) { if (ch.isActive()) ch.writeAndFlush(msg.retain()); // retain避免重复引用 } } }retain()是关键,确保msg在所有Channel写完前不被释放。
部署:每个Broadcast组3台机器,配置相同。用Ansible批量部署,启动脚本里加-XX:+UseZGC -Xmx32g,ZGC停顿时间稳定在10ms内。我们监控ChannelGroup.size(),当单组连接数>1.2万时,Router自动把新用户导向下一组,实现动态负载均衡。
4.4 客户端SDK集成要点(Vue3 + TypeScript)
前端不是甩手掌柜。我们提供@ws/live-sdknpm包,核心是LiveSocket类:
class LiveSocket { private socket: WebSocket | null = null; private reconnectTimer: NodeJS.Timeout | null = null; private lastSeq: number = 0; connect(roomId: string, userId: number) { // 第一步:向Router获取VIP fetch(`/router/join?room_id=${roomId}&user_id=${userId}`) .then(res => res.json()) .then(data => { this.socket = new WebSocket(`wss://${data.vip}/ws?token=${data.token}`); this.setupEventListeners(); }); } private setupEventListeners() { this.socket?.addEventListener('message', (e) => { const msg = JSON.parse(e.data); if (msg.type === 'chat') { this.lastSeq = msg.seq; // 记录最新序列号 this.emit('chat', msg); } }); this.socket?.addEventListener('close', (e) => { if (e.code === 1006) { // 网络断开 this.attemptReconnect(); // 触发重连 } }); } private attemptReconnect() { // 指数退避:100ms, 300ms, 900ms const delays = [100, 300, 900]; const delay = delays[Math.min(this.retryCount, 2)]; this.reconnectTimer = setTimeout(() => { fetch(`/router/reconnect?token=${this.token}&last_seq=${this.lastSeq}`) .then(res => res.json()) .then(data => { this.socket = new WebSocket(`wss://${data.vip}/ws?token=${data.token}`); this.setupEventListeners(); }); }, delay); } }关键经验:WebSocket在iOS Safari中,页面进入后台时,连接会被系统静默关闭,且onclose事件可能不触发。我们加了document.visibilityState监听,页面切后台时主动socket.close(),切前台时自动重连,避免残留无效连接占用服务端资源。
5. 常见问题与排查技巧实录:那些线上凌晨三点的救火时刻
5.1 典型问题速查表
| 问题现象 | 可能原因 | 快速定位命令 | 解决方案 |
|---|---|---|---|
| 用户连接后收不到弹幕 | Router路由表未更新,或Broadcast组未正确订阅Kafka Topic | etcdctl get /ws/route/room_123kafka-consumer-groups.sh --bootstrap-server x.x.x.x:9092 --group chat-broadcaster --describe | 检查Router日志是否写入etcd成功;确认Consumer Group的CURRENT-OFFSET与LOG-END-OFFSET差值<100 |
| 弹幕延迟突增到2秒以上 | Kafka积压,或Broadcast节点GC停顿 | kafka-run-class.sh kafka.tools.GetOffsetShell --bootstrap-server x.x.x.x:9092 --topic live-chat --time -1jstat -gc <pid> 1s | 扩容Consumer实例;若GC频繁,检查-Xmx是否过大,ZGC建议-Xmx32g,避免大堆内存导致回收慢 |
大量连接1006错误断开 | Nginx或LVS连接超时,或客户端网络不稳定 | ss -s查看TIME-WAIT连接数tcpdump -i any port 443 -w ws.pcap抓包分析 | LVS设置net.ipv4.ip_vs.conn_reuse_mode=0;Nginx加proxy_read_timeout 300;客户端加心跳保活 |
| 新用户加入房间,看不到历史弹幕 | Redis缓存未命中,且MySQL查询慢 | redis-cli -h x.x.x.x hgetall user:12345slowlog get 5 | 检查布隆过滤器是否生效;优化MySQL用户表索引,user_id必须是主键 |
5.2 一次真实的线上故障复盘:进球瞬间的“弹幕消失”
时间:某场中超比赛第89分钟,主队进球。
现象:监控告警,broadcast_group_07的channel_active_count从1.1万骤降至300,大量用户反馈“弹幕发不出,也看不到别人的”。
排查过程:
- 先看Kafka:
live-chatTopic积压达23万条,Consumer Lag飙升。 - 登录
broadcast_group_07的Leader节点,jstat -gc显示Full GC每2秒一次,G1OldGen使用率99%。 jmap -histo <pid>发现io.netty.buffer.PooledUnsafeDirectByteBuf对象占堆内存78%,但DirectMemory使用正常,说明是Netty内存池泄漏。
根因:我们发现,有个新上线的“弹幕表情包”功能,后端返回的二进制协议里,表情URL字段未做长度校验。恶意用户构造了10MB的超长URL,Broadcast节点解析时,ByteBuf分配了10MB内存,但后续处理失败,release()没被调用,内存池一直不回收。100个这样的弹幕,就把32GB Direct Memory吃光,触发ZGC Full GC。
修复:
- 协议层加字段长度校验:
if (urlLength > 2048) throw new ProtocolException("URL too long"); - 内存池加监控:
PooledByteBufAllocator的metric()方法暴露numActiveAllocations,当>10万时告警。 - 紧急回滚表情包功能,22分钟恢复。
实操心得:永远不要相信上游数据。WebSocket协议解析的第一行,必须是严格的长度校验和魔数校验。我们后来在Router层加了WAF规则,对
/ws路径的所有Upgrade请求,做二进制头校验,非法请求直接400拒绝,不进后端。
5.3 性能压测的黄金参数与避坑指南
我们用k6做全链路压测,脚本模拟真实用户行为:
import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ { duration: '30s', target: 1000 }, // ramp up { duration: '5m', target: 10000 }, // steady state { duration: '30s', target: 50000 }, // spike ], thresholds: { http_req_duration: ['p(95)<300'], // HTTP请求95%<300ms checks: ['rate>0.99'] // 断言成功率>99% } }; export default function () { // 1. 获取Router VIP const res1 = http.post('https://router/api/join', JSON.stringify({ room_id: `room_${__VU}`, user_id: __VU * 1000 + __ITER })); // 2. 建立WebSocket(k6不原生支持WS,用websockets库) const ws = new WebSocket(`wss://${res1.json().vip}/ws?token=${res1.json().token}`); // 3. 发送弹幕(每5秒1条) ws.on('open', () => { setInterval(() => { ws.send(JSON.stringify({type:'chat', text:'GOAL!', user_id:__VU})); }, 5000); }); }压测避坑:
- 别用单机压测:k6单机最多撑1万并发,要用
k6 cloud或分布式部署。我们用3台k6压测机,每台起1.5万VU,总4.5万,逼近真实场景。 - 监控必须全链路:除了k6的
http_req_duration,还要在Router、Broadcast、Kafka各节点装Prometheus + Grafana,看etcd_request_duration_seconds、netty_channel_active_count、kafka_consumer_lag。单看k6指标,可能掩盖后端积压。 - 网络带宽是瓶颈:4.5万并发,每秒弹幕2000条,每条弹幕平均200字节,下行带宽需
2000*200*8/1024≈3.1Mbps,看似不大。但实际是10万连接每秒都在收,带宽是100000*200*8/1024≈156Mbps。我们最初忘了配网卡多队列,irqbalance没开,所有中断集中在一个CPU核,该核100%,其他核空闲。加ethtool -L eth0 combined 32并重启irqbalance,问题解决。
5.4 成本与扩展性平衡:10万人在线,到底要多少机器?
很多人问:“10万并发,要买多少云服务器?”答案不是固定数字,而是看你的SLA。我们按生产环境算过一笔细账:
| 组件 | 数量 | 配置 | 年成本(参考) | 关键作用 |
|---|---|---|---|---|
| Router | 5台 | 4核8G | ¥12,000 | 无状态,纯路由,可水平扩展 |
| Broadcast组 | 4组 × 3台 = 12台 | 32核64G | ¥288,000 | 核心广播,每组撑2.5万连接 |
| Kafka Broker | 3台 | 16核32G | ¥72,000 | 弹幕缓冲,峰值吞吐保障 |
| etcd | 3台 | 4核8G | ¥18,000 | 路由元数据,强一致 |
| Redis Cluster | 6台 | 8核16G | ¥108,000 | 用户状态缓存,降低DB压力 |
| 总计 | 30台 | — | ¥498,000 | — |
注意:这30台是峰值保障配置。日常流量只有峰值的30%,我们用K8s HPA(基于CPU和channel_active_count指标)自动缩容Broadcast组到2组(6台),Router缩到3台,年成本可降40%。但体育赛事前2小时,必须手动扩到满配,因为HPA扩容需要3~5分钟,而进球可能发生在下一秒。
最后分享一个小技巧:我们把Broadcast组的机器名按ws-group-01-a、ws-group-01-b、ws-group-01-c命名,-a是Leader候选,-b、-c是Follower。Raft选举时,优先选-a,这样Leader位置固定,运维排查时,一眼就知道主节点在哪台,不用每次curl http://x.x.x.x:8080/raft/status去查。
我在实际压测中发现,当单Broadcast组连接数超过1.3万时,ChannelGroup.broadcast()的延迟开始非线性增长,从15ms跳到40ms。所以我们的硬性红线是1.2万/组,超过就触发Router自动分流。这个数字,是我们在32核机器上,用wrk反复测试/ws/broadcast接口得出的,不是拍脑袋。技术没有银弹,只有一次次实测出来的边界。