ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

直播高并发后端实战:WebSocket+Redis+Nginx架构搭建全笔记

直播高并发后端实战:WebSocket+Redis+Nginx架构搭建全笔记 直播这个场景后端要面对的压力和普通 CRUD 项目完全是两个物种。你可能会说不就是个聊天室加推流吗但真到了开播瞬间上万人在线、弹幕刷屏、礼物飘屏、连麦状态同步一起涌过来的时候再回头看那些增删改查接口真的就是小儿科。我最初开始碰直播高并发后端也是从接手一套互动系统开始一边被线上告警追着跑一边把架构一点点拆明白。这篇文章就是我从一个后端小白的角度手搓一套直播高并发环境的完整笔记从整体设计、技术选型到具体搭建、压测调参再到踩坑复盘全部展开。适合刚入行后端、想搞明白高并发到底高在哪里的朋友也适合前端同学想在前后端分离项目里补一补后端视角看完之后至少知道直播系统里每一条弹幕是怎么流转的。1. 直播高并发后端先拆清这三个压力点1.1 直播互动不是看视频是消息洪峰很多人第一次接触直播第一反应是“视频流难”毕竟推流、拉流、转码、CDN 一整套链路听起来就复杂。但如果你只关注后端互动系统真正的难点根本不是视频流而是消息洪峰。视频流走的是 CDN 和媒体服务后端更多时候负责的是信令和互动消息例如弹幕、礼物、点赞、关注、上麦下麦、在线人数变化。看视频是单向的观看者只管从 CDN 拉流后端不参与每个画面帧的转发。但弹幕就不一样了它是多对多的实时消息任意用户发一条弹幕直播间里所有其他人要基本同时看到。一万人在线一个用户发弹幕剩下九千九百九十九个人都要收到这条数据。如果每秒有几百人同时发弹幕那后端要处理的写入量和推送量就会瞬间变成几十万甚至上百万次转发。这不是普通 web 应用的请求模型普通 web 是“客户端请求一次、服务端响应一次、连接就结束了”直播互动是“连接长时间保持、服务端主动往客户端推数据”。所以你在设计系统的时候如果还抱着“一个接口一个 Controller、查询一次数据库然后返回 JSON”的思路绝对会被压垮。把直播互动抽象成三块就好理解了连接管理、消息流转、状态维护。连接管理负责维护大量在线用户的网络连接消息流转负责把弹幕、礼物这些消息从发送者传到所有接收者状态维护负责在线人数、礼物榜、房间热度这些一直在变的数据。把这三个问题拆开整个系统就清晰了。1.2 后端小白的第一个高并发项目为什么选直播场景我有个挺直观的感受后端新手学高并发最容易陷入“背八股”的怪圈。JUC 的并发工具、线程池参数、并发三要素背得滚瓜烂熟但一碰到真实的线上并发场景还是会懵。原因很简单没有把并发知识挂载到一个完整业务链路上。直播这个场景天生就适合拿来练手高并发因为它的业务规则简单但流量模型很复杂。为什么说业务规则简单弹幕就是“发消息、收消息”礼物就是“记录赠送、更新榜单、推送特效”在线人数就是“连接进来加一、连接断开减一”。没有像电商那种状态扭转、库存扣减、支付回调之类的复杂流程。正因为它简单你可以把大部分精力放在性能和稳定性上而不是纠缠业务逻辑。但流量模型又很复杂包含长连接、高吞吐写入、热 key 读取、实时推送、水平扩容、负载均衡等等。这些恰好是高并发后端最重要的基本功。而且直播场景不用花一分钱就能模拟你本地起一个 Spring Boot 服务再用脚本模拟几千个 WebSocket 客户端就能真真切切感受到连接数上涨之后的内存变化、GC 压力和线程池占用。这种“眼见为实”的学习效率比看十篇理论文章都高。1.3 一套直播高并发环境由哪几块组成如果你把它画成一个简化的架构图从上到下大概是这样的接入层用 Nginx 做反向代理和负载均衡客户端统一连到 Nginx再由它转发给后端服务。这一层还要处理 WebSocket 的升级协议。应用层Spring Boot 服务集群这里跑真正的业务逻辑比如用户认证、弹幕发送、礼物赠送以及维持 WebSocket 连接。状态层Redis用来存在线用户列表、礼物榜、房间维度缓存以及通过发布订阅模式做跨节点的消息广播。数据层MySQL存用户信息、礼物记录、弹幕回放等结构化数据。但要注意热路径上的高并发读写尽量避免直接打 MySQL。这套组合是我比较推荐新手第一次搭直播后端时使用的。没有引入太复杂的消息队列没有上微服务全家桶也没有搞 Service Mesh原因很简单一个新项目如果一开始就铺开十几个组件还没开始写业务先被运维和部署绊倒了。直播高并发环境的本质是让连接管理和消息推送能撑住量而这些用 Spring Boot、WebSocket、Redis、Nginx 四个核心件就能完整跑通。后面如果消息量真的大到 Redis Pub/Sub 扛不住再考虑引入 Kafka、RocketMQ 等专业消息队列也不迟。2. 新手选型指南Spring Boot、WebSocket、Redis、Nginx 怎么配合2.1 主体框架选 Spring Boot 3 Java 17主体框架我选了 Spring Boot 3 Java 17原因很朴素生态成熟资料多出了问题搜起来快。做直播高并发环境这种项目最怕用的是“看起来很炫但没人用过”的框架。Spring Boot 的自动配置和 starter 机制能让一个没有任何历史包袱的新工程在几分钟内跑起来而且它对 WebSocket、Redis、Web 开发的支持都非常完整所有这些在前后端分离项目里也都是常识操作。Java 17 是当前比较稳的长期支持版本虚拟线程在 Java 21 才真正好用但 Spring Boot 3 对 Java 17 的支持已经很成熟。选 Java 17 还有一个好处它的垃圾回收器默认 G1在多数延迟敏感场景下表现很稳尤其是直播弹幕这种大量短生命周期对象的场景。虽然很多人说 Java 写网络服务太重但实际上用 Netty 和虚拟线程之前Spring Boot Tomcat 的异步处理能力足够支撑中小型直播互动场景。你真正要担心的不是语言而是有没有把连接和线程模型搞清楚。2.2 实时通道用 WebSocket 还是 Netty别纠结我在做选型的时候在 WebSocket 和 Netty 之间纠结过一阵子。诚然Netty 是目前 Java 网络编程的底层王者Spring WebFlux 底层的 Reactor Netty、RPC 框架里的通信层都基于它。用 Netty 手写一个直播推送网关性能和可定制性都会更强但代码量和对网络编程的理解门槛也更高。对一个后端小白第一次搭直播高并发环境来说直接用 Spring 自带的 WebSocket 抽象反而更容易把逻辑讲清楚。这里给一个很实际的选型建议如果你是想快速跑通业务验证直播互动方案是否可行直接选 Spring Boot 的 WebSocket它底层也是 Tomcat 或 Netty 实现起步快、代码简洁如果你们公司已经有大流量的业务或者你明确知道要支撑百万级长连接那再去深入研究 Netty 手写网关也不迟。我搭这套环境的时候用的是 Spring WebSocket压测到单机五六千连接没什么大问题对新手来说已经是一个很好的起步量级。对比项Spring Boot WebSocketNetty 自研上手难度低注解加配置即可高需要理解 Pipeline、EventLoop扩展性中等适合中小规模高适合大规模定制调试成本低社区资料多偏高适合阶段从 0 到 1、MVP 验证从 1 到 100、大规模优化在技术选型上最忌讳的是“为了炫技而选复杂方案”直播高并发不只是连接管理一件事你的主要精力应该放在业务链路和数据一致性上。2.3 Redis 在直播场景里的状态设计Redis 在我这套环境里承担了三类工作缓存、计数、消息转发。缓存是最直观的直播间的基础信息、热门礼物列表、用户基本信息都可以放在 Redis 里避免每个请求都去查数据库。计数则是直播场景最核心的数据操作比如在线人数、点赞总数、送礼总值这些数据特点就是高频写、低频读或者高频写、也要实时读用 MySQL 行锁根本顶不住。在线人数推荐用 Redis 的 Set 或 ZSet 来维护直接SADD live:room:{roomId} {userId}用户下线时SREM需要查在线人数时SCARD一下就出来了。之所以不用简单的 INCR/DECR是因为直播间可能进进出出用计数方式容易出现重复计数、少计等脏数据而 Set 天然幂等同一个用户重复加入只算一次。礼物榜用 ZSet 是最自然的ZINCRBY live:gift:rank:{roomId} {score} {userId}每次有人送礼就把分值累加进去。直播间的排行榜需要实时更新ZSet 的 Skip List 结构能保证排序性能就算一个房间有数万人取 Top 50 的耗时也在毫秒级。还有一类消息转发场景我采用的是 Redis 发布订阅Pub/Sub这部分我会在消息链路里详细说。Redis 做状态层最大的好处是单线程模型天然避免并发竞争坏处是你必须设计好 key 的过期策略避免“永不失效的脏 key”把内存撑爆。2.4 Nginx 接入层与负载均衡的配置思路高并发环境不可能只靠单台后端硬扛水平扩容是必经之路而扩容之后必须有负载均衡把流量分散开。Nginx 在这里承担两个职责普通 HTTP API 的反向代理以及 WebSocket 的接入转发。对于普通 HTTP API只需要配置proxy_pass到后端集群地址即可。但 WebSocket 比普通 HTTP 多一个握手升级过程客户端发的是包含Upgrade: websocket头部的 HTTP 请求所以 Nginx 转发时必须保留这些头部同时设置一个足够长的proxy_read_timeout不然连接没有任何消息传输时会被 Nginx 掐断。有人可能会问WebSocket 是长连接会不会因为 Nginx 轮询导致同一个用户的消息被转发到不同后端放心Nginx 的负载均衡只在建立连接时生效连接建立之后就一直绑定在某一台后端节点上。只要后端节点能通过 Redis Pub/Sub 收到其他节点的消息用户的消息就不会丢这也是我坚持用 Redis 做节点间消息同步的原因。3. 最核心的链路实现弹幕、礼物、在线人数与鉴权3.1 弹幕和礼物的推送链路从接口到 Redis Pub/Sub弹幕和礼物这类互动消息我采用的方案是“HTTP 发送 WebSocket 推送”。听上去有点绕但实际用起来很舒服客户端往一个普通的 REST 接口 POST 一条弹幕内容后端先校验用户权限、过滤敏感词、落库存档然后通过 Redis Pub/Sub 把这条消息发布到对应直播间的 channel 上所有订阅了这个 channel 的后端节点收到消息后再推送给连接在自己节点上的客户端。这样做的好处是发送动作可以走普通 HTTP 接口方便做拦截、校验、限流而接收动作走 WebSocket 长连接保证实时性。有人会觉得那为什么不直接把弹幕内容也通过 WebSocket 发到后端省一次 HTTP 请求从技术上讲完全可行但把发送入口做成 HTTP 接口对客户端更友好也更容易做统一的参数校验和审计日志。在直播间里发弹幕本身就不是超高频率的动作真正超高频率的是广播推送所以把耗时的校验逻辑放在 HTTP 请求里把广播放在 WebSocket 里是更合理的分工。Redis Pub/Sub 的用法也很简单。发布端调用convertAndSend(live:room:1001, payload)订阅端监听live:room:*这个 pattern收到消息后解析出房间号再查看本地节点的 session 池中是否维护了这个房间的连接有就批量发送。这里有个坑Redis Pub/Sub 的消息不会持久化发布时如果没有订阅者在线消息会直接丢失。对弹幕这种“发出去就完事”的场景可以容忍但如果将来要做离线消息、补拉历史弹幕就需要换用 Stream 或者 Kafka。3.2 在线人数统计别碰数据库Redis 够用了在线人数是直播场景最典型的“状态型数据”几乎每秒钟都在变化。开播瞬间大量用户涌入人数从几千快速涨到几万如果每个进来的人都去数据库 UPDATE 一个人数计数器数据库非常容易变成瓶颈。而且这种数据的一致性要求没那么高它不是金额不需要强一致允许有几秒的偏差但必须实时展示在直播间的角标上。我用的方案是双写WebSocket 连接建立时SADD到房间在线集合同时把 userId 与房间号的映射关系写入本地内存连接断开时SREM从集合移除。查询在线人数就是一个SCARD key很快一秒内可以执行几万次。这里要配合心跳机制因为网络异常断开时服务端可能感知不到连接已经失效所以客户端每 30 秒发送一个心跳包服务端超过 60 秒没有收到心跳就主动断开这个连接清理 Redis 和本地 session。这套机制在直播高并发环境下非常实用能避免大量“僵尸连接”把在线人数和系统资源都拖垮。3.3 前后端分离下WebSocket 连接怎么鉴权前后端分离的项目用户信息通常通过 Token 传递。HTTP 接口在 Header 里放一个Authorization: Bearer xxx很自然但 WebSocket 握手时不能自定义 Header这是浏览器原生 WebSocket API 的限制。所以实际操作中常用的做法是在连接 URL 上带参数取比如ws://xxx/live/ws?roomId1001tokenxxx。在手写握手鉴权时要注意不要直接把 token 放到日志里不然用户的登录凭证就泄露了。Spring WebSocket 提供了HandshakeInterceptor在握手前拦截请求并校验 token校验通过才把 userId 放到 attributes 中传给 WebSocketHandler。我之前看到很多新手写法是到了 WebSocketHandler 里再解析 token、再查用户信息其实通过拦截器在握手阶段就完成鉴权和使用者是更优雅的方式还能避免无效连接占着资源。还有一点直播间权限校验也很重要。有些直播间是付费场或者密码房必须在握手时校验用户是否拥有进入权限而不能只验证 token 有效。我把这层逻辑也放在HandshakeInterceptor里校验失败直接返回 401客户端会收到一个握手失败的响应不会建立 WebSocket 连接。4. 从 0 到 1 搭建实录工程初始化、WebSocket、Nginx、压测4.1 环境准备JDK、Maven、Redis、Docker先把基础环境准备好。我本地用的是 macOS但这套东西在 Linux 服务器上更接近生产环境所以后期我全程在 CentOS 7 虚拟机里跑。需要准备的东西如下JDK 17配置好JAVA_HOMEMaven 3.8Redis 6.2单机版足够演示Docker 和 Docker Compose用来快速启动 MySQL、Redis减少本地环境污染Nginx 1.20一个方便压测的 WebSocket 客户端我用了 Python 的websockets库比 GUI 工具更灵活新手容易忽略的是系统的文件描述符限制。Linux 默认单个进程能打开的 fd 数量是 1024换成 WebSocket 连接就是最多同时在线 1024 人这个数量级对直播来说肯定不够。需要修改ulimit -n或者更稳妥的做法是在/etc/security/limits.conf里写入* soft nofile 102400和* hard nofile 102400。我一开始没改这个参数压测到一千个连接直接就报Too many open files排查了半天才意识到是这个底层限制。4.2 初始化 Spring Boot 工程与核心配置我用 Spring Initializr 生成的工程依赖只勾了Spring Web、WebSocket、Data Redis Reactive、Validation。主流 Java 后端项目基本都是这套组合再加上 MySQL 驱动和 MyBatis-Plus就覆盖了直播业务需要的几乎所有基础能力。核心的application.yml配置大概是这样的server: port: 8080 tomcat: max-threads: 200 accept-count: 1000 max-connections: 10000 spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms datasource: url: jdbc:mysql://127.0.0.1:3306/live?useUnicodetruecharacterEncodingutf8 username: root password: root123 hikari: maximum-pool-size: 20Tomcat 参数这里只说一个重点max-threads是处理请求的线程数不要盲目调到几千线程数过高会导致频繁上下文切换性能反而下降。200 左右在不开启虚拟线程的情况下是比较合理的起点后续压测时再根据 CPU 利用率进行调整。accept-count是请求队列长度给多一点能缓冲瞬时峰值流量不至于一来高峰就直接拒绝请求。4.3 接入 WebSocket 处理器与 Redis 订阅转发WebSocket 配置类核心代码大概长这样Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(liveWebSocketHandler(), /live/ws) .addInterceptors(new LiveAuthInterceptor()) .setAllowedOrigins(*); } Bean public WebSocketHandler liveWebSocketHandler() { return new LiveWebSocketHandler(); } }LiveWebSocketHandler继承TextWebSocketHandler维护了一个并发安全的 session 池。因为同一个直播间可能有很多连接所以我会用两层 Map外层是ConcurrentHashMapWebSocketSession, String之类的映射更准确一点用ConcurrentHashMapString, SetWebSocketSession按直播间维度管理连接。每次有消息发往某个直播间只需要遍历这个直播间对应的 session 集合逐个发送即可。Redis 订阅部分我实现了MessageListener接口订阅 pattern 是live:room:*。收到 Redis 消息后解析出房间号从本地 session 池取出对应连接批量推送。之所以要 Redis 中转是因为用户可能连接在 A 节点但发送弹幕的 HTTP 请求被 Nginx 转发到了 B 节点。B 节点把消息发布到 RedisA 节点订阅后推给自己的客户端这样不管客户端连在哪个节点消息都不会断。一个小优化是发布消息时带上消息序号和时间戳这样客户端做去重和排序就有依据了。4.4 Nginx 负载均衡配置实战Nginx 的配置我单独列一段因为 WebSocket 转发比普通 HTTP 多几个关键参数upstream live_backend { server 192.168.1.10:8080 max_fails3 fail_timeout30s; server 192.168.1.11:8080 max_fails3 fail_timeout30s; keepalive 64; } server { listen 80; server_name live.example.com; location /live/ws { proxy_pass http://live_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } location /api/ { proxy_pass http://live_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }有两点需要特别注意。第一proxy_http_version必须设成 1.1不然长连接无法生效。第二WebSocket 的proxy_read_timeout不能太短否则客户端长时间不讲话连接就被 Nginx 给断了。我一开始图省事只设置了默认的 60 秒结果每隔一分钟连接就断一次客户端疯狂重连把后端连接池都打满了。后来改成 3600 秒这个坑才算填上。4.5 压测验证连接数、吞吐量与参数调整压测是整个搭建过程里最有成就感的环节也是暴露问题的关键环节。我用了一段 Python 脚本模拟多个客户端同时连接 WebSocket然后定时发弹幕观察服务端进程的 CPU、内存和文件连接数。import asyncio import websockets async def mock_client(i): uri fws://127.0.0.1:8080/live/ws?roomId1001tokentest{i} async with websockets.connect(uri) as ws: for j in range(20): await ws.send(fmessage from user {i}) await asyncio.sleep(1) async def main(): tasks [mock_client(i) for i in range(3000)] await asyncio.gather(*tasks) asyncio.run(main())用ulimit -n 102400放开文件描述符限制之后单机跑 3000 个 WebSocket 连接是比较轻松的。当连接数到 5000 时能观察到 Tomcat 的连接线程占用明显上升所以我用了 Spring WebSocket 的异步发送模式避免大量网络阻塞卡住业务线程。另一个压测结论是单条弹幕广播给 5000 人会有几十毫秒的延迟这个延迟主要花在遍历 session 和网络发送上属于可接受范围。如果想继续优化可以引入用户分片和批量发送但那就属于进阶话题了。5. 直播后端上线前把这些问题都排查一遍5.1 连接数打满服务直接拒绝握手怎么办我第一次压测到四千多连接时新连接握手开始大量失败日志里出现Connection refused和Too many open files。排查时先看系统 fd 限制发现虽然设置了ulimit -n 102400但 Spring Boot 进程是用 systemd 启动的systemd 服务文件里默认的LimitNOFILE还是 1024。这个问题非常典型你在 shell 里调了 ulimit 只管当前 shell 和它的子进程换到 systemd 启动、docker 启动又是另一套配置。解决方法是同时检查三层操作系统层、systemd 服务文件层、JVM 进程层。systemd 服务文件里加LimitNOFILE102400然后 daemon-reload。Docker 部署时则要在 docker run 参数里加--ulimit nofile102400:102400。把这些都处理好连接数才真正能上去。5.2 内存飙升与 GC 频繁长连接场景下最容易被忽视的是内存里堆积了大量 WebSocketSession 和相关业务对象。前期我发现在线人数只有五千但堆内存占用已经超过了 1GGC 也变得很频繁。排查之后发现有一个地方写得很粗糙每当用户进入直播间我就往 session 的 attributes 里塞了一个很大的用户信息对象里面有头像 URL、历史记录、订阅关系等一堆用不上的字段。一个两个无所谓五千个连接叠加起来就很可观。解决思路是 attributes 里只放必要信息比如 userId、roomId、nickname。其他信息按需再查 Redis。另一个大招是开启 Tomcat 的连接器回收同时给 JVM 设置合理的堆内存上下限java -Xms512m -Xmx1024m -jar live-backend.jar对于这种场景不要给 JVM 分配超过物理内存的堆否则系统会用大量 swapGC 时间会变得很难看。我在 4G 内存的虚拟机上用-Xms512m -Xmx1g跑 3000 连接基本稳得住。5.3 Redis 热点 key 引发的连锁故障直播场景里最容易出问题的 Redis 用法是所有人都集中读写同一个 key。比如超级头部主播开播几万人同时看同一个直播间的在线人数、点赞数、礼物榜这个房间的所有 key 都变成了热点 key。哪怕 Redis 单机 QPS 能达到十万但热点 key 所在的 Redis 实例 CPU 一旦被打满整个系统的消息转发也会跟着卡顿。我采用的缓解方案是降级加本地缓存。在线人数允许几秒的延迟所以本地缓存十秒更新一次而不是每次请求都穿透到 Redis。礼物榜这种实时性要求高的数据则把它拆分到多个 key 里比如按用户 ID 哈希分成 10 个分片查询时把 10 个分片的结果聚合起来。这种分片策略会增加代码复杂度但对于直播高并发环境这是躲不掉的手段。热点 key 问题在流量小时感受不到一旦上了真实直播瞬间就能把你打懵。5.4 跨域、Token、部署层面的典型坑前后端分离项目里跨域和后端鉴权是绕不开的两个话题。WebSocket 的setAllowedOrigins(*)确实能解决跨域连接问题但生产环境里最好把允许来源固定成自己的前端域名否则任何网站都能往你的 WebSocket 服务灌消息风险极高。Token 方面要在 WebSocket 握手阶段校验不能等服务端和客户端已经建立了长连接再慢慢校验否则恶意用户可以通过不携带 token 的方式占用大量连接资源。部署方面因为我最终要用 Docker 部署发现容器里的时间默认是 UTC日志里排查问题会非常别扭。解决办法是在 Dockerfile 里设置时区同时在 Nginx 层的 access log 用nginx -V确认好日志格式。这些看起来是小事但线上排查问题时时间和日志对不上会耽误很长时间。最后再分享一个我踩过最值的坑永远不要在长连接消息里打印完整消息内容。弹幕业务文本可能很短但万一哪个用户发了一段超长字符你的日志系统可能直接被打满。我后来在打印日志时只保留前 64 个字符配合消息长度和用户 ID 就够定位问题了。这个习惯一直保留到现在每次做实时消息系统我都会提醒自己先想清楚日志和监控该怎么设计再开始写业务代码。
返回列表