
1. 从零搭建直播高并发环境我踩过的坑和最终方案直播这玩意儿看起来就是推个流、拉个流的事但真要把并发量拉起来后端要处理的东西远比想象中复杂。我是一名写了三年多业务代码的后端开发之前一直做的是传统的增删改查系统顶多加点定时任务和消息队列。去年年底公司接了一个直播互动项目要求支撑单场十万级用户同时在线互动包括弹幕、点赞、礼物、在线人数统计这些功能。当时我第一反应是这不就是个聊天室吗后来真正上手才发现直播场景下的高并发和普通业务系统完全不是一个量级的问题。这篇文章记录的是我从零开始搭建一套直播高并发环境的完整过程。涉及技术选型、架构设计、缓存策略、消息推送方案、压测验证等环节适合有一定后端基础但没接触过直播场景的同学参考。我会尽量把每个决策背后的原因讲清楚包括我踩过的坑和最后怎么解决的。文章里涉及的具体参数和配置都是实测过的你可以直接拿去用但要注意根据自己的业务量级做调整。先说结论直播高并发环境的核心矛盾在于海量连接的管理和瞬时消息的扇出。十万人在线意味着你的服务端要同时维持十万个长连接任何一条弹幕消息都可能需要推送给十万个客户端。这个量级下传统的请求-响应模型直接崩掉必须换一套思路来做。2. 整体架构设计与技术选型思路2.1 为什么不能用传统的HTTP短轮询刚开始我想偷懒用HTTP短轮询来做弹幕和在线人数更新。客户端每隔两秒发一次请求问服务端有没有新消息。这个方案在几百人在线的时候跑得挺欢但压测到五千人的时候QPS直接飙到两千五数据库连接池瞬间打满CPU跑到百分之九十以上。问题很明显大部分请求都是无效的因为两秒内可能根本没有新消息但每个请求都要经过完整的HTTP握手、路由、鉴权、查库流程资源全浪费在空转上了。短轮询的另一个致命问题是实时性差。弹幕这种东西延迟超过一秒用户就能感知到两秒的轮询间隔意味着最坏情况下弹幕要等两秒才显示体验非常割裂。所以短轮询方案在直播场景下基本不可行除非你的在线人数很少比如内部培训直播那种几十个人的场景。2.2 长连接方案对比WebSocket vs SSE vs 私有TCP协议长连接是直播场景的标配但具体用哪种长连接技术需要根据业务特点来选。我对比了三种主流方案方案协议双向通信浏览器兼容性实现复杂度适用场景WebSocketTCP支持现代浏览器全支持中等弹幕、礼物、点赞等双向互动SSEHTTP仅服务端推送除IE外基本支持低公告、在线人数等单向推送私有TCPTCP支持需要客户端SDK高对延迟极度敏感的场景最后我选了WebSocket作为主要通信方式。原因有几个第一弹幕和礼物需要双向通信客户端要发消息服务端也要推消息WebSocket天然支持第二WebSocket的浏览器兼容性已经足够好不需要额外装插件第三生态成熟Java这边有Netty、Spring WebSocket等成熟框架踩坑成本低。SSE虽然实现简单但只支持服务端单向推送客户端发消息还得走HTTP等于要维护两套通道反而更麻烦。私有TCP协议性能最好但需要自己处理粘包拆包、心跳保活、重连等一系列问题对于我这种第一次做直播的人来说学习成本太高而且调试困难。2.3 消息中间件的选择Redis Pub/Sub还是Kafka长连接管理解决了下一个问题是消息怎么在多个服务实例之间分发。假设我部署了十台WebSocket服务节点用户A连在节点1上用户B连在节点2上用户A发了一条弹幕这条弹幕需要推送给所有节点上的用户。这就需要一个消息中间件来做广播。我一开始用的是Redis的Pub/Sub功能因为它足够轻量部署简单而且延迟极低。实测下来单条消息从发布到所有订阅者收到延迟在毫秒级别。但Redis Pub/Sub有个致命问题不持久化。如果某个服务节点在消息发布的那一刻正好重启或者网络抖动这条消息就丢了那个节点上的用户就看不到这条弹幕。对于弹幕来说偶尔丢一两条可能用户感知不到但对于礼物这种涉及金钱的消息丢一条就是事故。后来我换成了Kafka。Kafka的好处是消息持久化每个消费者组可以独立消费即使某个节点挂了重启后还能从上次的offset继续消费不会丢消息。而且Kafka的吞吐量远高于Redis Pub/Sub十万级并发的消息扇出完全扛得住。缺点是部署和维护成本高一些需要至少三个broker做高可用。但对于直播这种对可靠性要求高的场景这个成本是值得的。注意如果你做的是小规模直播在线人数几千人以内Redis Pub/Sub完全够用不用上Kafka。技术选型要看业务量级不要为了用而用。2.4 缓存层设计Redis在直播场景中的核心作用直播场景下Redis的作用不仅仅是缓存它承担了多个关键角色在线用户状态存储每个用户的房间号、连接节点、最后心跳时间等信息存在Redis Hash里方便快速查询和更新。弹幕频率限制用Redis的计数器做用户发言频率限制防止刷屏。比如每个用户每秒最多发三条弹幕用INCR命令配合EXPIRE实现。礼物库存扣减礼物这种涉及金钱的操作用Redis的原子操作做预扣减然后再异步落库避免直接打数据库。排行榜实时更新用Redis的Sorted Set做礼物排行榜按礼物价值排序实时更新性能极好。热点数据缓存直播间基本信息、主播信息等热点数据缓存在Redis里减少数据库压力。我用的Redis版本是7.0部署模式是Cluster模式六个节点三主三从。为什么用Cluster而不是主从哨兵因为直播场景下数据量和并发量都大单主节点的写入能力有限Cluster可以水平扩展。但Cluster有个坑不支持多key操作跨slot比如MGET、MSET这些命令如果key不在同一个slot会报错。解决办法是用hash tag把相关的key用{}包起来强制落到同一个slot。比如{room:1001}:users和{room:1001}:messages就会落到同一个slot。3. 核心模块的实操细节与避坑指南3.1 WebSocket连接管理心跳、重连与连接数控制WebSocket连接不是建立后就一劳永逸的中间可能因为网络抖动、服务端重启、客户端切后台等原因断开。我在这块踩了不少坑最后总结了一套比较稳的方案。心跳机制服务端每隔30秒给客户端发一个ping帧客户端收到后回一个pong帧。如果服务端连续三次没收到pong就判定连接已断开主动关闭并清理资源。为什么是30秒因为大部分移动网络的NAT超时时间是60秒到120秒30秒的心跳可以保证连接不被中间设备回收。心跳间隔太短会浪费带宽和电量太长则连接容易被断开。客户端重连客户端检测到连接断开后不能立即重连否则服务端可能还没清理完旧连接导致连接数虚高。我的做法是客户端采用指数退避策略重连第一次等1秒第二次等2秒第三次等4秒最多等30秒。同时服务端在关闭连接时会立即从Redis里删除该用户的在线状态避免脏数据。连接数控制单台服务节点能维持多少WebSocket连接这个取决于你的内存和文件描述符限制。我实测下来一台4核8G的云服务器调整了系统参数后可以稳定维持五万个连接。关键参数有三个# 修改文件描述符限制 ulimit -n 1000000 # 修改系统级最大连接数 echo net.core.somaxconn 65535 /etc/sysctl.conf echo net.ipv4.tcp_max_syn_backlog 65535 /etc/sysctl.conf # 修改TIME_WAIT状态的回收速度 echo net.ipv4.tcp_tw_reuse 1 /etc/sysctl.conf echo net.ipv4.tcp_fin_timeout 30 /etc/sysctl.conf提示ulimit的修改只对当前会话有效要永久生效需要改/etc/security/limits.conf。另外如果用的是容器环境还需要在容器启动参数里加--ulimit nofile1000000:1000000。3.2 弹幕消息的扇出优化从广播风暴到分级推送弹幕是直播场景下消息量最大的部分。假设十万人同时在线平均每人每秒发一条弹幕那就是十万条消息每秒。如果每条消息都广播给所有人总推送量是十万乘以十万也就是一百亿次推送每秒这显然不可能实现。实际上弹幕不需要推送给所有人。我的优化策略是分级推送第一级房间内广播。弹幕只推送给同一个房间的用户不同房间之间隔离。这样十万人的平台如果有100个房间每个房间平均1000人单房间的推送量就降到了1000条每秒。第二级采样推送。当房间人数超过一定阈值比如5000人弹幕不再全量推送而是按比例采样。比如每10条弹幕只推送1条给所有用户但发送者自己能看到自己发的弹幕。这样既保证了弹幕的活跃感又大幅降低了推送量。第三级分级推送。根据用户等级或者付费状态高等级用户发的弹幕全量推送普通用户的弹幕采样推送。这样既保证了核心用户的体验又控制了总量。具体实现上我在Kafka里按房间号分区每个房间的弹幕消息落到同一个partition然后由对应的消费者组消费并推送。采样逻辑在消费者端做用一个简单的计数器每收到10条消息才推送1条。// 弹幕采样推送的简化逻辑 public void onMessage(DanmakuMessage message) { // 发送者自己始终能看到自己的弹幕 if (message.getUserId().equals(currentUserId)) { sendToUser(message); return; } // 其他用户按采样率推送 if (sampleCounter.incrementAndGet() % SAMPLE_RATE 0) { broadcastToRoom(message); } }3.3 礼物系统的并发扣减Redis原子操作与异步落库礼物是直播场景下最敏感的部分涉及金钱绝对不能出错。我设计的原则是Redis做预扣减数据库做最终一致性。用户送礼物时先调用Redis的DECRBY命令扣减礼物库存如果返回值小于0说明库存不足直接返回失败。如果扣减成功再把送礼记录写入Kafka由异步消费者落库。这样做的原因是数据库的写入性能有限如果每个礼物都同步写库高并发下数据库直接被打爆。但这里有个问题如果Redis扣减成功了但Kafka消息发送失败或者异步消费者处理失败就会导致Redis和数据库不一致。我的解决办法是加一个对账机制每隔五分钟扫描Redis里所有预扣减但未落库的记录和数据库里的实际库存做对比发现不一致就补偿。// 礼物扣减的Redis Lua脚本保证原子性 String script local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return -2 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1; // 执行脚本 Long result redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(gift:stock: giftId), String.valueOf(count) );注意Lua脚本里的KEYS和ARGV一定要分清KEYS是Redis的keyARGV是参数。另外Lua脚本执行时间不能太长否则会阻塞其他请求。我实测下来这个脚本的执行时间在0.1毫秒以内完全没问题。3.4 在线人数统计的实时性与准确性平衡在线人数是直播间的核心指标但精确统计十万人同时在线的成本很高。我的方案是近似统计定时校准。具体做法是用户连接时在Redis里对应的房间在线集合里加一个成员用HyperLogLog做基数统计。HyperLogLog的好处是内存占用极小统计一亿个元素只需要12KB内存误差在0.81%以内。对于在线人数这种指标0.81%的误差完全可以接受。但HyperLogLog有个问题它不支持删除元素。用户断开连接时没法从HyperLogLog里移除。所以我的做法是HyperLogLog只做实时展示每五分钟用精确统计校准一次。精确统计就是遍历Redis里的在线用户集合统计实际数量。这个操作虽然慢但五分钟才做一次对系统压力不大。// 用户连接时 redisTemplate.opsForHyperLogLog().add(room:online: roomId, userId); // 获取在线人数近似值 Long onlineCount redisTemplate.opsForHyperLogLog().size(room:online: roomId); // 定时校准每五分钟 SetString allUsers redisTemplate.opsForSet().members(room:users: roomId); long exactCount allUsers.size(); redisTemplate.opsForValue().set(room:online:exact: roomId, exactCount);4. 压测验证与性能调优实录4.1 压测环境搭建与JMeter脚本设计压测是验证高并发环境是否达标的关键环节。我用的工具是JMeter配合WebSocket插件做长连接压测。压测环境是一台独立的云服务器配置和线上环境一致避免因为环境差异导致压测结果不准。JMeter脚本的设计要点线程组配置模拟一万个并发用户Ramp-up时间设置为60秒意思是每秒启动约167个用户避免瞬间冲击。WebSocket连接每个线程建立一个WebSocket连接连接成功后发送一条加入房间的消息。心跳维持每30秒发送一次ping帧保持连接活跃。消息发送每个线程每秒发送一条弹幕消息模拟真实用户行为。响应断言检查服务端返回的消息是否包含预期的字段确保消息推送正常。压测脚本里有个坑JMeter的WebSocket插件默认不支持自动重连如果连接断了线程就挂了。解决办法是在脚本里加一个While循环检测连接状态断了就重连。4.2 压测结果分析与瓶颈定位第一次压测的结果很不理想。一万并发用户持续压测五分钟结果如下指标数值是否达标平均响应时间230ms否最大响应时间5.2s否错误率3.7%否CPU使用率92%偏高内存使用率78%正常网络带宽450Mbps接近上限问题很明显响应时间太长错误率太高。我通过排查发现了几个瓶颈瓶颈一GC频繁。JVM的堆内存设置太小导致频繁Full GC。默认的堆内存是物理内存的四分之一8G的机器只有2G堆内存根本不够用。调整后# JVM启动参数优化 -Xms6g -Xmx6g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize16m瓶颈二Redis连接池不够。默认的连接池最大连接数是8高并发下大量请求在等待连接。调整后# Redis连接池配置 spring: redis: lettuce: pool: max-active: 200 max-idle: 50 min-idle: 20 max-wait: 1000ms瓶颈三Kafka生产者配置不合理。默认的批量发送大小是16KB太小了导致网络请求频繁。调整后# Kafka生产者配置 batch.size65536 linger.ms10 compression.typelz4 acks14.3 调优后的压测数据与稳定性验证经过三轮调优最终的压测结果如下指标数值是否达标平均响应时间45ms是最大响应时间320ms是错误率0.02%是CPU使用率65%正常内存使用率72%正常网络带宽280Mbps正常一万并发用户持续压测三十分钟系统稳定运行没有出现连接断开或者消息丢失的情况。后来我又把并发量加到三万系统依然扛得住只是响应时间略有上升平均在80ms左右。提示压测的时候一定要监控服务端的各项指标包括CPU、内存、网络、磁盘IO、GC次数等。推荐用PrometheusGrafana做监控可以实时看到各项指标的变化趋势方便定位瓶颈。4.4 常见问题速查表在实际操作中我遇到了不少问题这里整理成速查表方便你快速排查问题现象可能原因解决方法连接频繁断开心跳间隔太长缩短心跳间隔到30秒以内消息延迟高Kafka分区数不够增加分区数提高并行度Redis超时连接池太小增大连接池max-activeGC频繁堆内存太小增大-Xmx换G1GC消息丢失Kafka acks0改为acks1或all在线人数不准HyperLogLog误差定时精确校准礼物扣减超卖Redis和DB不一致加对账补偿机制弹幕刷屏没有频率限制Redis计数器限流5. 部署与运维的实战经验5.1 容器化部署与Kubernetes编排服务写完之后部署也是个大问题。我一开始是手动部署把jar包传到服务器上用nohup启动。这种方式在服务少的时候还行但直播环境需要动态扩缩容手动部署根本跟不上。后来我换成了DockerKubernetes。Dockerfile的编写要点FROM openjdk:17-jdk-slim WORKDIR /app COPY target/live-server.jar app.jar EXPOSE 8080 9090 ENTRYPOINT [java, -Xms4g, -Xmx4g, -XX:UseG1GC, -jar, app.jar]Kubernetes的Deployment配置要点apiVersion: apps/v1 kind: Deployment metadata: name: live-server spec: replicas: 6 selector: matchLabels: app: live-server template: metadata: labels: app: live-server spec: containers: - name: live-server image: live-server:latest ports: - containerPort: 8080 resources: requests: memory: 4Gi cpu: 2 limits: memory: 6Gi cpu: 4 readinessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 30 periodSeconds: 10注意WebSocket服务在Kubernetes里需要配置Session Affinity否则同一个用户的请求可能被转发到不同的Pod上导致连接状态不一致。我用的方案是在Service里配置sessionAffinity: ClientIP这样同一个IP的请求会落到同一个Pod上。5.2 监控告警体系的搭建直播服务最怕的就是半夜出问题没人知道。我搭了一套监控告警体系核心组件是PrometheusGrafanaAlertmanager。监控指标分几个层面系统层CPU、内存、磁盘、网络、文件描述符数量。JVM层堆内存使用、GC次数和耗时、线程数。应用层WebSocket连接数、消息发送量、消息延迟、错误率。中间件层Redis连接数、命中率、Kafka消费延迟。告警规则我设置了几个关键阈值WebSocket连接数超过单节点上限的80%时告警提醒扩容。消息延迟超过500ms时告警排查Kafka或网络问题。错误率超过1%时告警排查代码或依赖问题。Redis内存使用超过80%时告警防止OOM。5.3 灰度发布与回滚策略直播服务不能随便停服更新所以灰度发布是必须的。我的做法是新版本先部署一个Pod不接入流量。通过Kubernetes的Service把1%的流量切到新Pod。观察十分钟如果没有异常逐步增加流量比例。如果发现异常立即把流量切回旧版本然后排查问题。回滚策略也很简单保留上一个版本的Docker镜像回滚时直接把Deployment的镜像版本改回去Kubernetes会自动滚动更新。6. 个人实操体会与后续扩展方向这套环境从零搭到现在稳定运行前后花了大概两个月时间。中间踩的坑比我预想的多得多尤其是WebSocket连接管理和Kafka消息可靠性这两块反复调了好几轮才达到预期效果。如果让我重新做一遍我会在前期花更多时间做压测而不是等功能全写完再压。因为很多性能问题在代码层面是看不出来的只有压测才能暴露。另外监控一定要提前搭好不要等出问题了才想起来加监控那时候排查问题全靠猜效率极低。后续我打算在这套环境上继续扩展几个方向一是接入直播录制功能把直播流录制下来存到对象存储方便回放二是做多机房部署通过DNS调度把用户分配到最近的机房降低延迟三是引入AI审核对弹幕内容做实时过滤减少人工审核成本。这些等后面实践了再写笔记分享。最后分享一个小技巧WebSocket服务在压测的时候客户端的性能往往先于服务端成为瓶颈。我用JMeter压测时单台压测机最多只能模拟一万个连接再多就报内存不足了。解决办法是用多台压测机做分布式压测JMeter支持Master-Slave模式一台Master控制多台Slave可以轻松模拟十万级并发。