尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

后端小白手搓直播高并发环境:从Nginx到WebSocket的搭建实战笔记

后端小白手搓直播高并发环境:从Nginx到WebSocket的搭建实战笔记 手搓一套直播高并发环境一个后端小白从 0 到 1 的搭建笔记说出来不怕大家笑话我干了几年后端日常就是 CRUD、写接口、调 bug一直觉得自己跟“高并发”这三个字没啥缘分。直到上个月公司临时要搞一场线上直播活动需要在极短时间内搭一套能扛住突发流量的直播环境而这个活居然落到了我这个“后端小白”头上。当时脑子里全是问号直播高并发到底高在哪推流、拉流、聊天室、接口这几个东西一起涌进来我该先处理哪个更别提还有前后端联调、token 鉴权、跨域这些问题叠在一起。但活总得干。我用了一周时间从零开始把整套环境手搓了出来上线当天扛住了峰值在线人数破万的压力没有翻车。这中间踩了无数坑也把很多以前只在面试题里见过的概念真正落地了一遍。这篇文章就是我那几天完整的技术笔记既写给和我一样的后端开发者看也写给想搞明白直播系统背后原理的前端同学希望能帮大家少走点弯路。1. 先搞清楚直播高并发到底在并发什么1.1 直播场景里真正的并发瓶颈在哪很多人一听“直播高并发”第一反应就是视频流很大、带宽要够感觉自己一个后端开发根本插不上手。但真正拆开看一场直播活动的流量压力根本不是均匀分布的而是同时打在四条完全不同的链路上推流链路主播端把画面推到服务器这个方向是单向写压力相对固定瓶颈主要在带宽和转码能力。拉流链路观众端从服务器拉取视频流这是纯读流量量级远超推流通常需要 CDN 和边缘节点来扛。IM 聊天链路直播间里的弹幕、点赞、礼物消息这个方向是双向的而且是突发性最强、对后端压力最大的部分。观众突然涌入时消息量会瞬间暴涨。业务接口链路进房、关注、下单、抽奖、用户信息查询等 HTTP 接口调用这部分才是我们后端的传统主场也是高并发改造的重头戏。我一开始犯了个典型错误把精力全放在了推流拉流上想着去调媒体服务器参数。结果联调时才发现真正把我打垮的是聊天室消息和进房接口同时刷过来数据库连接瞬间被打满。所以如果你也是后端接手直播项目时第一件事不是研究视频流而是先把 IM 和业务接口这两条链路的并发模型想清楚。1.2 我这套技术栈是怎么选的技术选型这个事最忌讳的就是听到一个热门组件就往上塞。我当时手头的情况是团队主力语言是 Java现有的业务系统基于 Spring Boot前端是 Vue项目要在一周内上线不允许引入太多需要专门运维的新东西。所以最终选型非常务实网关层Nginx承担静态资源分发、HTTP 接口反向代理、WebSocket 反向代理以及最基础的四层负载均衡。后端服务Spring Boot 3.x业务接口和 IM 服务都用它写避免拆成多个语言多套维护。IM 实现Spring WebSocket STOMP 协议这套组合天然支持广播、群组、点对点消息正好符合直播间弹幕的推送场景。缓存与热点数据Redis用来扛进房人数、用户在线状态、热点配置等信息减少数据库压力。异步与削峰RabbitMQ处理礼物、点赞这类高频率低业务强度的消息批量落库。数据库MySQL只存最终的结果数据比如弹幕历史记录、礼物流水不参与实时链路。这套组合的好处是完全落地在 Spring 生态内不需要额外引入 Netty、Kafka 那套复杂体系对从零开始的后端小白足够友好同时留出了后续替换和扩展的余地。1.3 并发目标的量化估算动手之前一定要先做量化否则你根本不知道该往哪个方向优化。我当时做了一个简单的计算假设一场直播预计峰值在线人数 1 万人观众进房时会同时触发一个进房 HTTP 请求和一个加入聊天室的 WebSocket 连接。按 5 分钟内 80% 的人集中涌入来估算进房接口的瞬时 QPS 大约是 10000 × 0.8 ÷ 300秒也就是每秒至少 27 个请求看起来不大。但别忽略聊天室消息每个观众平均每 3 秒发一条弹幕那每秒就是 3000 多条消息推送再算上系统广播和礼物特效IM 链路每秒钟要处理的写入与推送量轻松过万。这个数字一算出来方向就清晰了真正需要优先保障的是 IM 链路的吞吐能力而不是业务接口。后文所有架构设计都是围绕这个目标来做的。2. 从零手搓网关、业务接口与鉴权的落地2.1 接入层 Nginx 的配置实践整个环境的入口是 Nginx它管着 HTTP 接口、WebSocket 连接和静态资源三件事。对于后端小白Nginx 配置看起来像天书但核心逻辑其实就三条定义上游服务、转发请求、处理协议升级。先看 HTTP 接口的反向代理配置我以 8080 端口上的 Spring Boot 服务为上游把 80 端口的请求转发过去upstream backend_api { server 127.0.0.1:8080 weight5 max_fails3 fail_timeout10s; keepalive 64; } server { listen 80; server_name live.example.com; location /api/ { proxy_pass http://backend_api; 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_set_header X-Forwarded-Proto $scheme; } }这里有几个细节值得展开。keepalive 64是让 Nginx 与后端服务之间保持 64 个长连接避免每次请求都重新握手这个参数在压测时对性能影响很明显加和不加差了将近一倍。proxy_set_header X-Forwarded-For是为了让后端拿到真实客户端 IP后面做频控和风控都会用到。对于小白来说最容易漏掉的是proxy_http_version 1.1这行没有它的话 keepalive 根本不生效因为默认的 HTTP/1.0 协议不支持连接复用。2.2 业务接口的 token 鉴权与跨域处理前端是单独的域名后端接口是另一个域名这就要同时解决跨域和鉴权两个问题。我在热词里看到很多人搜“vue前后端分离请求token处理”这确实是每个前后端分离项目都绕不开的坎。我的做法是采用拦截器统一处理 token。前端登录后拿到一个 JWT 类型的 token存储后每次请求在 Header 里带上Authorization后端拦截器负责解析和校验。先看 Spring Boot 侧的跨域配置直接实现WebMvcConfigurer接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这段配置里最关键的坑是allowedOriginPatterns和allowCredentials(true)必须搭配使用。如果写成allowedOrigins(*)再开 credentials浏览器会直接拦截请求报错 CORS 配置非法这个细节坑了我整整一个小时。原因是允许携带凭证时浏览器不允许使用通配符指定来源必须明确指定或者使用 pattern 模式。再来看 token 鉴权的拦截器Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { String userId JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这里一个容易忽略的问题预检请求OPTIONS必须直接放行否则浏览器预检失败后连实际请求都不会发出。这个问题在前端联调时非常常见很多人排查后端逻辑半天结果发现是预检被拦截了。2.3 注册拦截器并排除公开接口拦截器写好后还要注册进去并且要把登录接口、直播预览等不需要鉴权的公开接口排除掉Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/login, /api/live/preview, /api/live/stream-url ); } }注册拦截器的过程中我发现一个看似不起眼但很重要的点excludePathPatterns里的路径必须与addPathPatterns的匹配规则保持一致否则怎么配都不生效。比如你拦截的是/api/**排除时写/api/login/末尾多一个斜杠匹配不上登录接口照样被拦。跨域加鉴权这一套搞定后前后端联调时基本就能畅通无阻。实测下来这个环节最耗时的不是写代码而是两个端的人同时排查时都认为对方没问题结果发现是配置的小细节导致两边都以为问题出在别处。3. 直播 IM 聊天室WebSocket 与高并发消息分发3.1 为什么选 WebSocket 而不是轮询直播间的弹幕、点赞、礼物消息如果用传统的 HTTP 轮询去做前端每隔一两秒请求一次接口在线 1 万人时每秒就是好几千个 HTTP 请求其中绝大多数是无效请求纯纯浪费资源。WebSocket 是长连接建立一次连接后服务端可以主动把消息推给客户端消息实时性高而且不需要反复握手资源开销小得多。我在热词里看到有人搜“spring boot 好用的websocket 后端框架”这里说下我的理解和实践。Spring Boot 自带的 WebSocket 模块基于标准 JSR-356 协议加上 STOMP 子协议支持后就自带广播、群组、点对点三种消息模式正好覆盖直播间的所有场景。不需要额外引入第三方框架对从零开始的人来说是最平滑的路径。如果将来并发量再高一个量级可以考虑改用 Netty 自研 IM 网关或者接网易云信、腾讯 IM 这类云服务但那是后话。起步阶段Spring WebSocket 完全够用。3.2 单机 IM 的消息分发模型设计直播间聊天室的设计思路本质上是一个“房间 主题”模型。每个直播间是一个独立的群组观众进入直播间后订阅这个房间对应的主题主播和管理员的系统消息走全局广播。先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后配置 WebSocket 端点和消息代理Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic, /queue); config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws) .setAllowedOriginPatterns(*) .withSockJS(); } }这套配置的意思是服务端可以向/topic/room/{roomId}推送消息这个主题下所有订阅的客户端都能收到客户端往/app/chat/send发送消息会被路由到后端的MessageMapping方法处理。直播间消息发送的核心逻辑大概是这样MessageMapping(/chat/send) SendTo(/topic/room/{roomId}) public ChatMessage sendMessage(DestinationVariable String roomId, ChatMessage message) { message.setTimestamp(System.currentTimeMillis()); return message; }这条链路看起来简单但实测下来有一个后知后觉的坑SendTo注解里的路径如果带有变量必须加上DestinationVariable才能在返回路径里动态替换。没有这个注解的话所有消息都会走到字面意义的/topic/room/{roomId}主题里前端订阅的却是实际房间号的路径消息就永远发不到客户端。这个细节排查了我整整一个下午最后通过抓包看 WebSocket 帧才定位到。3.3 Nginx 代理 WebSocket 的关键配置WebSocket 和普通 HTTP 有一个本质区别连接建立后需要从 HTTP 协议升级到 WebSocket 协议。Nginx 默认不会处理这个升级过程所以必须显式配置location /ws { proxy_pass http://backend_ws; 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; }这里有两处是必须的。第一Upgrade和Connection upgrade这两个请求头缺一不可少了任何一个WebSocket 握手直接失败前端控制台会报WebSocket connection failed。第二proxy_read_timeout和proxy_send_timeout必须调大默认 60 秒一旦超过时间没有数据交互Nginx 就会主动断开连接。观众可能一分钟不发弹幕但还在看直播服务端偶尔又有心跳消息这些都会因为超时被误断。3.4 在线人数统计与 Redis 缓存策略直播间顶部显示的“XXX人正在看”是后端高频请求之一。最笨的方案是每次请求都去查数据库计在线人数高峰期每秒几百次查询就把数据库打崩了。我的做法是维护一个 Redis 计数器观众进房时自增离房时自减前端用轮询或定时拉取的方式获取人数public void userEnterRoom(String roomId, String userId) { String key live:online: roomId; Boolean first redisTemplate.opsForSet().add(key, userId); if (Boolean.TRUE.equals(first)) { redisTemplate.opsForValue().increment(live:online:count: roomId); } } public void userLeaveRoom(String roomId, String userId) { String key live:online: roomId; Long removed redisTemplate.opsForSet().remove(key, userId); if (removed ! null removed 0) { redisTemplate.opsForValue().decrement(live:online:count: roomId); } }这里用 Set 而不是直接用计数器有个很实际的原因用户刷新页面时可能先触发离开事件再触发进房事件如果直接加减数值一次刷新会导致重复统计。用 Set 做了幂等去重同一个用户重复进房不会导致计数增加这就稳了。4. 直播流分发从拉流到低延迟接入4.1 CDN 场景下后端该做什么视频流的拉流分发理论上是由 CDN 和媒体服务器完成的后端并不直接参与视频数据的转发。但后端的职责是生成带鉴权的拉流地址。我当时对接的是云厂商的 CDN 直播服务业务后端需要做的事是向媒体服务申请一个推流地址给主播用同时生成若干条不同清晰度的拉流地址并且在签名 URL 里附加过期时间防止地址泄露后被随意盗播。一个典型的带鉴权拉流地址看起来像这样https://live.example.com/live/{streamName}.m3u8?auth_key{timestamp}-{rand}-{uid}-{hash}这个地址是后端接口动态生成的观众每次进房时通过 HTTP 接口获取。生成签名的核心逻辑是使用 MD5 或 HMAC 对关键参数进行签名保证 URL 不可篡改。对于后端来说这一层的难点不在算法而在于要处理好时间戳的容错播放端和服务器的时间差要在允许范围内否则会误杀正常用户。4.2 直播间延迟优化的一些实践现在直播平台的趋势是向“无延迟直播”走传统的 HLS 协议延迟在 3 到 10 秒左右观众看到弹幕和画面不同步体验很糟糕。HTTP-FLV 可以把延迟压到 2 到 3 秒WebRTC 则能到 500 毫秒以内。我实际搭建环境时用了两种协议组合手机端和电脑端主流播放器用 HTTP-FLV 拉流需要强互动的场景用 WebRTC。后端在这一层需要适配的核心是同一个流需要同时输出多种协议格式而不是所有客户端都用同一条链路。另外要注意的是“多清晰度”方案。一条原始流推上来后媒体服务器可以转出 720P、1080P 等多个清晰度客户端根据网络情况自动切换。这个功能的实现虽然主要在媒体服务那边但后端接口需要能够告诉前端有哪些清晰度可选如何获取各清晰度对应的地址。这也是前后端联调时容易忽略的一个信息字段。4.3 推流环节的常见故障与应对推流是最容易出问题的一环。主播端使用 OBS 推流时最常遇到的错误是认证失败或者流名称冲突。我自己的排查经验是推流地址的鉴权参数是否过期。很多第三方推流工具会缓存推流地址过一段时间再推时签名 URL 已经失效。流名称是否唯一。如果两个主播不小心使用了相同的流名称后者会把前者的直播顶掉现象就是直播突然中断。如果主播端使用 OBS 连接手机摄像头手机和电脑需要在同一局域网环境下或者通过 OBS 的远程推流插件来配置。这里涉及网络穿透问题一旦跨网段就容易连不上。后端在这个过程中虽然不直接处理视频帧但推流状态的上报、心跳检测、异常断流后的自动重推这些逻辑是后端接口需要参与的。我在环境里加了一个定时任务定期检查各直播流的推流心跳发现超过 30 秒没有心跳的流自动标记为断流并通知前端展示对应的直播状态。5. Java 后端并发细节与性能调优5.1 线程池参数怎么调才算合理直播场景的接口有一个特点瞬时流量集中持续时间短。比如开播后的前几分钟是用户涌入的高峰后面会慢慢稳定下来。这种流量模型下线程池参数的设置比平时更重要。我在 Spring Boot 里自定义了一个线程池用来处理消息落库、礼物流水记录这类可异步化的任务Bean(asyncExecutor) public Executor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(2000); executor.setThreadNamePrefix(async-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这些参数不是拍脑袋定的。核心线程数 8参考的是服务器 CPU 核心数乘以 2 的经验值最大线程数 32留出余量应对瞬时峰值队列容量 2000 是为了缓冲防止任务直接打满线程拒绝策略用CallerRunsPolicy线程池满了后任务由调用线程自己执行宁可慢一点也不能丢消息。这个线程池最大的价值在于把“接收请求的主线程”和“处理消息的异步线程”分开。消息进来后主线程立刻返回前端感知到的响应延迟就很低。异步线程在后台慢慢处理哪怕偶发拥堵也不会阻塞用户操作。5.2 Redis 在热点数据里的扛压作用直播间的热门商品、公告、主播信息这类数据每次请求都查数据库是不现实的Redis 缓存的作用就在这。我用了一个非常简单的“先写后读”模型开播时把主播信息、直播标题、公告、商品列表序列化后写入 Redis过期时间设为开播时长加 10 分钟。前端每次拉这个信息时只查缓存数据库只在初始化和直播结束时各访问一次。实际压测中有一个数据让我印象很深不加缓存时一个进房接口由数据库查询、动态配置读取等 5 次 SQL 组成TPS 大概在 300 左右加了 Redis 缓存后同样的接口只查一次缓存TPS 直接跳到 3000 以上翻了 10 倍。5.3 用消息队列削峰礼物消息的批量落库直播间刷礼物的场景是一个典型的削峰需求。观众疯狂刷礼物时如果每一条都立即写数据库数据库连接会迅速耗尽。我的方案是前端把礼物消息发到后端后端先返回“感谢 XX 赠送的礼物”给主播端做特效展示同时把这条礼物流水丢进 RabbitMQ由消费者批量写入数据库。Redis 中维护一个礼物排行榜实时数据从 Redis 读最终流水数据从 MySQL 查。这个方案的核心权衡是“实时性和持久性分离”。观众和主播需要的是实时特效这部分数据在 Redis 里就能满足后台需要的是准确的礼物流水记录这部分通过消息队列慢慢落库不跟实时链路抢资源。5.4 数据库连接池的调优心得直播场景下数据库连接池是最容易先崩溃的地方。我一开始用的默认配置是hikari的maximum-pool-size10结果进房接口和消息落库同时并发时连接池瞬间被打到满新的请求全部排队等待接口响应直接超时。我把参数调到了这样spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000关于连接池大小有个经典公式理论上核心数 × 2 有效存储数就是最佳值但实际操作中必须根据压测结果来调整。我的经验是连接池不是越大越好太大反而会增加数据库服务器的连接切换开销。关键是保证高峰期连接池不会被占满同时每个连接的使用时间要尽量短这又回到了前文的异步化和缓存设计上。6. 常见问题与排查技巧实录6.1 高频问题速查表整个搭建过程中我踩过很多坑也记录了身边同事遇到的高频问题。下面这个速查表包含了我认为最值得收藏的排查要点问题现象可能原因排查方法解决方案前端 WebSocket 一直重连Nginx 没配 Upgrade 头抓包看握手是否 101添加proxy_set_header Upgrade $http_upgrade连上后收不到消息DestinationVariable未加导致主题路径错误看服务端日志的 sendTo 路径方法参数加上DestinationVariable接口报 CORS 跨域错误配置allowedOrigins(*)且开了allowCredentials看浏览器控制台报错详情改用allowedOriginPatterns(*)登录接口被 401拦截器排除路径不匹配打印拦截器日志看是否被拦截对齐addPathPatterns和excludePathPatterns规则在线人数翻倍统计刷新页面导致进退房事件重复观察 Redis 在线 Set 成员用 Redis Set 做去重后再计数弹幕偶尔延迟几秒消息代理线程池太小看日志是否有线程排队增大broker线程池或改用外部消息代理数据库连接池耗尽同步操作过多导致线程长时间占用连接查看active连接数和慢 SQL走异步 Redis MQ 削峰6.2 排查问题的定位方法后端小白在排查问题时最容易犯的错是凭感觉到处试。我这次最大的经验教训是遇到问题第一件事不是改代码而是抓包和看日志。排查 WebSocket 问题用浏览器开发者工具里的 Network 面板找到ws请求看握手状态码和消息帧的发送接收情况。后端侧开启 WebSocket 日志logging: level: org.springframework.web.socket: DEBUG org.springframework.messaging: DEBUG打开日志后客户端发送的消息、服务端分发的路径、异常的回调都会打在控制台里。很多消息推不过去的问题看一眼日志里 sendTo 的路径比对一下前端订阅的路径基本就能定位。排查 HTTP 层面的问题看 Nginx 的access.log和error.log是最快的。access log 里的请求状态码如果是 499表示客户端已经断开如果是 502表示后端服务不可用如果是 504表示后端响应超时。每个状态码背后对应的问题方向完全不同养成先看 Nginx 日志的习惯能省掉很多无意义的排查时间。6.3 压测踩坑与容量评估上线前我做了压测工具用的是 Apache JMeter先对 HTTP 接口做常规的并发测试再对 WebSocket 做连接持续测试。压测中暴露的问题比真实上线时更多。第一次压测300 个并发用户同时往聊天室发消息后端日志直接报 OOM排查下来发现是 WebSocket 的会话对象持有用户信息占用大量内存加上消息处理线程持有缓冲区导致内存飙升。解决方案是给消息处理设置超时并为每个房间的消息队列设置最大长度超出后丢弃非关键消息。容量评估这块我学到一个很直观的经验通过压测得到的“单机极限 QPS”再打五折才是生产环境可以承诺的指标。原因很简单压测环境网络简单、数据量小真实环境里有各种超时重试、网络抖动和异常场景预留安全水位能避免上线即翻车。7. 复盘这套环境还能怎么扩展搭建完这套系统后我最大的感受是直播高并发不是一个点上的问题而是一条完整链路每一层的协作结果。如果后续需要扩容有几个方向是明确可行的。第一个方向是 IM 层的水平扩展。当前单机 Spring WebSocket 能支撑的在线连接数有限一般几千路连接后内存和线程开销就开始明显上升。后续可以引入 Redis 的 Pub/Sub 来解决多实例间的消息广播问题让不同实例之间能够互通房间消息。再往后走可以用 Netty 替代 Spring WebSocket做更高性能的连接管理。第二个方向是业务接口的垂直拆分。随着直播类型变多比如带货直播、游戏直播、无人直播业务逻辑会越来越复杂。可以把用户服务、直播间服务、消息服务拆成独立微服务各自独立部署互不干扰。第三个方向是无人直播等自动化场景。这种方法本质上是通过预制视频流代替人工推流需要后端开发一个调度服务按计划自动启动推流任务、切换直播源、更改直播间状态。这个玩法现在很流行对后端来说也是很好的锻炼因为里面涉及任务调度、状态机管理、异常重试这些核心能力。第四个方向是引入对象存储来处理直播回放。直播结束后自动把直播流切片上传生成回放视频便于用户随时观看。这一层涉及到云存储的对接、转码、索引和播放鉴权又是一个完整的链路。最后再分享一个我自己实践过程中的小技巧。如果你也是第一次接触这类项目建议把每一条 Nginx 配置、每一个线程池参数、每一个 Redis Key 的设计想法都写进笔记里。过几天再看你会发现当初觉得“理所当然的写法”其实有很多是自己误打误撞试出来的写下来才能真正理解它为什么有效以及什么时候可能会失效。这套笔记不光是一次项目记录更是一个后端从“只会增删改查”走向“能扛流量、能调性能”的成长见证。
返回列表