Java弹幕视频网站实战:从WebSocket到视频转码的完整架构
做弹幕视频网站,很多人第一反应是“这不就是B站吗”,往细了想才会意识到真正的难点在哪里。视频上传、转码、切片、播放、弹幕收发、实时推送、用户互动,这一整条链路拆开来看,每一个环节都不算简单,但组合在一起才是完整的弹幕视频网站。Java在这个领域其实很成熟,Spring Boot做后端、Redis扛缓存、RabbitMQ削峰解耦、WebSocket推弹幕,整套方案有大量生产级实践可以参考。
这篇文章就围绕我自己做的一个Java弹幕视频网站项目,把从技术选型、架构设计、核心表结构、弹幕前后端实现、视频上传转码,到上线部署和踩坑记录全部捋一遍。内容会比较长,适合准备做Java毕业设计、想系统了解WebSocket应用、或者后端想转全栈的同学参考。已经写过类似项目的也可以看看弹幕模块、文件处理这几个地方的设计思路,能避开不少我之前踩过的坑。
1. 内容整体设计与思路拆解
在做这个项目之前,我先想清楚了一个问题:弹幕视频网站的核心价值到底是什么?不是视频本身,而是弹幕营造的“一起看”的氛围。视频点播能力一套成熟的云服务就能解决,但弹幕系统必须自己实现,这才是整个项目的灵魂,也是技术上最有挑战性的部分。
1.1 核心需求解析
从需求层面拆解,一个完整的弹幕视频网站应该包含这些功能:
- 用户模块:注册、登录、个人信息管理。弹幕要跟用户关联,至少要能区分发送者。
- 视频模块:视频上传、转码、存储、播放。这里需要做文件格式校验、大小限制、分片上传。
- 弹幕模块:弹幕发送、存储、实时广播、按时间轴加载。这是核心中的核心。
- 互动模块:点赞、收藏、投币、评论。这些功能帮助提升用户粘性,也把业务从纯工具型产品推向社区型产品。
- 管理后台:视频审核、用户管理、数据统计。哪怕不做复杂运维,也至少要有基础的运营视角。
从用户视角看,整个流程是这样的:用户上传视频到服务器,服务器对视频做转码生成不同清晰度的版本,其他用户进入播放页,从后端读取指定视频的弹幕列表,播放视频时弹幕在画面上飞过,观众随时可以发送自己的弹幕,弹幕会实时出现在所有人屏幕上。
1.2 方案选型背后的思考
技术栈我选的比较主流:Spring Boot 2.7 + MyBatis Plus + MySQL + Redis + RabbitMQ + WebSocket + FFmpeg + MinIO。每一项选择都有实际考量:
- Spring Boot :生态成熟,社区活跃度极高,遇到问题能搜到大量解决方案。对毕业设计或中小型项目来说,开发效率和稳定性都足够。
- MyBatis Plus :单表CRUD基本不用写SQL,分页查询、逻辑删除、自动填充这些功能开箱即用,能省下大量写Mapper的时间。
- MySQL :存储用户、视频元数据、弹幕记录等结构化数据。弹幕虽然时效性强,但历史弹幕必须落库,否则刷新页面就没了。
- Redis :热点视频的元信息、弹幕列表缓存、分布式Session、点赞数计数器。弹幕读取走Redis,写入走MySQL,能有效扛住高并发读。
- RabbitMQ :视频上传后转码是耗时操作,如果不做异步处理,用户要等很久才能得到反馈。用MQ解耦后,上传接口收到文件立刻返回成功,转码任务异步执行。
- WebSocket :弹幕实时推送的最优解。HTTP轮询延迟高、浪费资源,WebSocket是长连接,服务端可以主动推送,延迟能控制在毫秒级。
- FFmpeg :视频转码和切片的事实标准。它能处理几乎所有格式的视频,生成HLS协议需要的ts切片和m3u8索引文件。
- MinIO :对象存储服务,用来存放视频源文件和转码后的切片。兼容S3协议,部署简单,社区版免费商用。
选型时我最纠结的是弹幕实时推送方案。当时有三个候选:短轮询、SSE、WebSocket。短轮询最简单,前端每隔几秒请求一次服务端,但延迟高且请求密度大会打穿服务器。SSE是服务端单向推送,能解决延迟问题,但弹幕是双向交互的,用户要发弹幕、服务端要通知大家,SSE做不到。最终选了WebSocket,虽然有连接管理和心跳保活的复杂度,但它支持全双工通信,天然契合弹幕场景。
提示:如果你只是快速验证想法,弹幕系统可以用Redis的发布订阅模式替代消息队列。但生产级项目建议把消息中间件引入进来,这不仅是技术需要,项目中体现消息队列的使用也是一种架构设计能力的展示。
2. 核心细节解析与实操要点
确定方案之后,我开始做细化的设计。这个阶段最重要的两件事:一是把数据库表结构设计好,二是把弹幕消息的通信协议定清楚。这两块决定了下游开发的走向,一旦后期返工,改动成本会非常高。
2.1 数据库表结构设计
数据库一共设计了7张核心表,我挑几个关键表说一下设计思路。
用户表:
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(64) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(128) NOT NULL COMMENT '密码(加密存储)',
nickname VARCHAR(64) NOT NULL COMMENT '昵称',
avatar VARCHAR(512) COMMENT '头像URL',
role TINYINT DEFAULT 1 COMMENT '角色:0管理员,1普通用户',
status TINYINT DEFAULT 1 COMMENT '状态:0禁用,1正常',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '用户表';
密码字段用的VARCHAR(128),因为存的是BCrypt加密后的结果,这个长度刚好能放下。用户名加了唯一索引,防止重复注册。角色字段预留了管理员和普通用户的区分,后面做权限控制不会被动。
视频表:
CREATE TABLE video (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '视频ID',
user_id BIGINT NOT NULL COMMENT '发布者ID',
title VARCHAR(128) NOT NULL COMMENT '视频标题',
description TEXT COMMENT '视频描述',
cover_url VARCHAR(512) COMMENT '封面图URL',
video_url VARCHAR(512) COMMENT '播放地址',
duration INT COMMENT '视频时长(秒)',
status TINYINT DEFAULT 0 COMMENT '状态:0转码中,1已发布,2转码失败,3下架',
play_count BIGINT DEFAULT 0 COMMENT '播放量',
danmaku_count INT DEFAULT 0 COMMENT '弹幕数',
like_count INT DEFAULT 0 COMMENT '点赞数',
favorite_count INT DEFAULT 0 COMMENT '收藏数',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '视频表';
视频表里的status字段特别关键,上传完的视频不是立刻能播的,要经过转码流程,所以默认是0,转码成功才改成1。播放量、弹幕数这些字段加了统计值冗余,虽然不符合严格的第三范式,但能避免每次都要count一次,性能收益明显。
弹幕表:
CREATE TABLE danmaku (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '弹幕ID',
video_id BIGINT NOT NULL COMMENT '视频ID',
user_id BIGINT COMMENT '发送者ID',
content VARCHAR(255) NOT NULL COMMENT '弹幕内容',
time_point FLOAT NOT NULL COMMENT '弹幕出现时间点(秒)',
color VARCHAR(16) DEFAULT '#FFFFFF' COMMENT '弹幕颜色',
type TINYINT DEFAULT 1 COMMENT '弹幕类型:1滚动,2顶部,3底部',
status TINYINT DEFAULT 1 COMMENT '状态:0屏蔽,1正常',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) COMMENT '弹幕表';
time_point字段存的是这条弹幕出现在视频的哪个时间点,精确到秒级就够用。type字段区分滚动弹幕、顶部弹幕和底部弹幕,顶部弹幕适合做字幕,底部弹幕适合做歌词,这是弹幕视频网站的标配功能。弹幕表和视频表是多对一的关系,所以video_id建了普通索引,查询时直接走索引扫描。
2.2 弹幕消息通信协议设计
WebSocket的通信协议需要前后端共同约定,否则两边各写各的,联调时会有各种字段对不上的问题。我设计的是一个JSON格式的协议,包含消息类型和数据体两个维度:
{
"action": "DANMAKU_SEND",
"data": {
"videoId": 123,
"content": "前方高能预警",
"timePoint": 15.5,
"color": "#FF0000",
"type": 1
}
}
消息类型action字段一共定了五种:
- JOIN:用户进入视频房间,客户端通知服务端加入该视频的弹幕频道。
- LEAVE:用户退出视频页面,客户端通知服务端离开频道。
- DANMAKU_SEND:用户发送新弹幕,服务端收到后校验并广播给所有人。
- DANMAKU_LIST:请求历史弹幕列表。
- PONG:心跳响应,保活机制用。
这里最需要想清楚的是DANMAKU_SEND的处理链路。服务端收到这条消息后不能直接广播,必须先做几件事:从Redis缓存中自增视频的弹幕计数,把弹幕内容异步写入MySQL,把弹幕消息以JSON格式推送给WebSocketChannelGroup中所有在线用户,最后再给发送者回一条ACK确认消息。发送者确认自己弹幕发送成功的反馈机制很重要,否则用户点了发送按钮没有反应,会以为网络出了问题。
2.3 视频文件处理方案
视频上传和转码是整个项目中容易低估复杂度的部分,我第一次做的时候只想到了上传文件这一个环节,实际操作后才发现后续还有一堆事情。
上传方面,我采用分片上传方案。前端把文件切成5MB一片,逐个上传,后端每收到一个分片就写入MinIO的临时目录,同时用Redis记录分片状态。全部传完后前端发起合并请求,后端校验分片完整性后合并成完整文件。这个方案的好处是:断点续传、单个文件大小没有硬性上限、上传失败只需要重传失败的分片而不是整个文件。
转码方面,视频文件合并完成后,后端发送一条异步任务到RabbitMQ,任务内容是视频编号和源文件路径。转码消费者从队列拿到任务后,调用FFmpeg执行转码:
ffmpeg -i input.mp4 -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k -hls_time 10 -hls_list_size 0 -hls_segment_filename output_%03d.ts output.m3u8
这条命令把源视频转成H.264编码的视频流和AAC编码的音频流,按10秒一个切片生成ts文件,并生成m3u8播放列表。播放器拿到m3u8地址后,自动按需加载切片,实现边下边播。转码完成后,消费者更新视频表的status字段为已发布,把m3u8地址写入video_url字段。
注意:FFmpeg转码非常消耗CPU资源,一个20分钟的视频在性能一般的服务器上可能要转5到10分钟。所以我用RabbitMQ做异步队列后还特意加了一个线程池配置来控制并发转码数,默认设为2,否则同时上传三四个大视频,服务器CPU直接被打满,网站其他功能都会卡死。
3. 实操过程与核心环节实现
设计阶段完成之后,我进入具体编码环节。说实话,写代码本身不算最难,难的是把WebSocket的并发问题处理好、把转码的异步链路调通、把播放页的性能优化到位。这一部分我尽量把关键代码和背后的思考讲清楚。
3.1 WebSocket弹幕服务端实现
WebSocket在Spring Boot里的实现方式比较统一,核心类是TextWebSocketHandler。我定义了DanmakuWebSocketHandler,这个类统一负责连接建立、消息接收、异常处理和连接关闭四个生命周期事件。
@Component
public class DanmakuWebSocketHandler extends TextWebSocketHandler {
private static final Map<Long, WebSocketSession> ONLINE_USERS = new ConcurrentHashMap<>();
private static final Map<Long, Set<WebSocketSession>> VIDEO_ROOMS = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Long userId = (Long) session.getAttributes().get("userId");
ONLINE_USERS.put(userId, session);
log.info("用户 {} 建立WebSocket连接", userId);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JSONObject payload = JSONObject.parseObject(message.getPayload());
String action = payload.getString("action");
JSONObject data = payload.getJSONObject("data");
switch (action) {
case "JOIN":
joinVideoRoom(session, data.getLong("videoId"));
break;
case "LEAVE":
leaveVideoRoom(session, data.getLong("videoId"));
break;
case "DANMAKU_SEND":
handleDanmakuSend(session, data);
break;
case "PONG":
session.getAttributes().put("lastHeartbeat", System.currentTimeMillis());
break;
default:
break;
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
Long userId = (Long) session.getAttributes().get("userId");
ONLINE_USERS.remove(userId);
VIDEO_ROOMS.forEach((videoId, sessions) -> sessions.remove(session));
log.info("用户 {} 断开连接", userId);
}
}
VIDEO_ROOMS这个Map维护的是“视频ID到WebSocket会话集合”的映射关系。当一个用户进入某个视频的播放页,客户端发送JOIN消息,服务端把这个用户的WebSocketSession放进对应视频的会话集合中。当该视频有新弹幕时,遍历这个集合,向每个session推消息,这就是往PUSH协议的雏形。
并发安全问题需要注意,多个线程可能同时操作同一个视频的会话集合,所以用了ConcurrentHashMap存储集合外层、CopyOnWriteArraySet存储每个视频下的会话集合,写操作线程安全,遍历时的性能损耗可以接受。
WebSocketEndpoint配置中拦截握手请求,做登录校验:
@Component
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(danmakuWebSocketHandler, "/ws/danmaku")
.addInterceptors(new HandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpSession session = servletRequest.getServletRequest().getSession();
Long userId = (Long) session.getAttribute("userId");
if (userId == null) {
return false;
}
attributes.put("userId", userId);
}
return true;
}
})
.setAllowedOrigins("*");
}
}
握手阶段从HttpSession中取出userId放进WebSocketSession的attribute里,后续在Handler中直接拿。这个设计保证了只有登录用户才能建立弹幕连接。
3.2 前端弹幕渲染与播放器对接
前端我用的是html5 video标签配合原生canvas绘制弹幕。没有直接选择DPlayer这类现成播放器,主要是想更深入了解弹幕渲染的底层逻辑。当然如果你项目时间紧迫,直接用DPlayer加弹幕插件会省很多时间。
弹幕渲染的核心逻辑分两块:弹幕数据的获取和弹幕动画的绘制。
历史弹幕获取:视频页面加载完成后,前端会先通过HTTP接口拉一次历史弹幕列表,接口路径设计为 /api/danmaku/history?videoId=1,后端从Redis缓存中读取该视频的弹幕列表返回。这样用户进入播放页后中间半小时的弹幕也能直接看到,不用等待实时推送。
实时弹幕渲染:WebSocket连接建立后,服务端推送新弹幕给客户端。前端拿到弹幕数据后,不是直接往canvas上画,先要经过一个轨道分配算法。每条弹幕会分配到水平方向的某一条轨道上,轨道的选择要避免和已有弹幕重叠:
function allocateTrack(pool, duration, screenWidth, danmakuSpeed) {
const trackCount = pool.length;
for (let i = 0; i < trackCount; i++) {
const track = pool[i];
const lastDanmaku = track[track.length - 1];
if (!lastDanmaku || isAvailable(lastDanmaku, duration, screenWidth, danmakuSpeed)) {
return i;
}
}
return -1; // 所有轨道都满,丢弃或延迟发射
}
这里的轨道池是预先计算好的,比如视频高度1080px,每条轨道高度30px,那么可用轨道大约是20条。isAvailable函数判断前一条弹幕是否已经离开当前轨道区域,如果还没离开,就尝试下一条轨道。如果所有轨道都满了,这条弹幕会被放入等待队列,等下一帧再检查。
canvas绘制弹幕用的是requestAnimationFrame循环,每帧清空画布、根据弹幕的速度和经过时间计算当前位置、逐条绘制文字。速度的计算公式是:
const speed = (screenWidth + danmakuWidth) / duration;
duration一般是8到10秒,代表弹幕从屏幕右侧进入、到完全消失在屏幕左侧的总时长。屏幕越宽、弹幕文字越长,速度就越快,保证在固定时间内走完整个屏幕。
3.3 视频上传与异步转码链路
视频上传的前端用Web Uploader组件实现分片上传,后端专门写了一个UploadController处理分片上传请求。核心方法如下:
@PostMapping("/upload/chunk")
public Result uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("identifier") String identifier,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("totalChunks") Integer totalChunks) {
String chunkDir = uploadDir + "/" + identifier;
File dir = new File(chunkDir);
if (!dir.exists()) {
dir.mkdirs();
}
File targetFile = new File(chunkDir + "/" + chunkNumber);
file.transferTo(targetFile);
redisTemplate.opsForValue().increment(CHUNK_UPLOAD_COUNT_KEY + identifier);
return Result.success();
}
每个分片以chunkNumber作为文件名,存储在临时目录。每次上传分片后,Redis中的计数器加一。当前端上传完所有分片后,调用合并接口,后端检查Redis中的分片数量是否等于totalChunks,相等则按顺序合并分片成完整文件:
@PostMapping("/upload/merge")
public Result mergeChunks(@RequestParam("identifier") String identifier,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("filename") String filename) {
// 检查分片是否齐全
Integer uploadedCount = (Integer) redisTemplate.opsForValue().get(CHUNK_UPLOAD_COUNT_KEY + identifier);
if (uploadedCount == null || uploadedCount != totalChunks) {
return Result.error("分片不完整,请重新上传");
}
// 合并分片
File finalFile = new File(uploadDir + "/" + UUID.randomUUID() + "_" + filename);
FileOutputStream outputStream = new FileOutputStream(finalFile);
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(chunkDir + "/" + i);
FileInputStream inputStream = new FileInputStream(chunkFile);
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
inputStream.close();
chunkFile.delete();
}
outputStream.close();
// 上传到MinIO并发送转码任务
String objectName = minioService.uploadFile(finalFile);
videoService.processVideo(objectName);
return Result.success();
}
合并完成后,文件会被上传到MinIO,然后调用processVideo方法,这个方法内部发送一条RabbitMQ消息,随后立刻返回。用户提交视频后跳转到一个“转码中”的页面,每隔几秒轮询一次视频状态接口,直到状态变为已发布或者转码失败。
3.4 视频播放页性能优化
视频播放页是用户停留时间最长的页面,性能直接影响用户体验。我这里做了几个优化措施:
首屏加载优化。视频页面的静态资源用了CDN加速,js、css、图片都通过CDN分发。播放器用了video.js的HLS插件,它的加载策略是边下边播,用户不需要等整个视频下载完,只需要等前几个ts切片加载完成就可以开始播放。
数据缓存策略。视频的元信息(标题、封面、播放量、弹幕数)在读取时先查Redis,缓存key设计为 video:detail:{id},过期时间设为30分钟。播放量字段不是直接更新MySQL,而是先更新Redis中的计数,然后每5分钟把增量同步到MySQL。弹幕的历史列表也是缓存到Redis,key为 danmaku:list:{videoId},设置24小时过期。
数据库查询优化。视频首页的视频列表用MyBatis Plus的分页查询,配合覆盖索引优化排序性能。弹幕历史查询在time_point字段建了索引,这个索引很关键,否则弹幕数据量上来后,每次按时间点查询会全表扫描,随着数据增长性能会急剧退化。
4. 常见问题与排查技巧实录
这个项目从开发到上线部署,踩过不少坑,很多问题都属于“不看不知道,看了才知道是这种原因”的典型。我整理了几个影响最严重的问题,以及对应的排查思路和解决方式,对号入座就能省下不少Debug的时间。
4.1 问题一:Linux服务器上WebSocket连接建立后马上断开
这个问题是在部署到Linux服务器后出现的。本地开发环境一切正常,代码拉上去部署后,前端总是报WebSocket连接失败。检查WebSocket连接的日志,发现握手请求到达了Spring容器,但紧接着就断开了。
排查思路:先看服务端日志,日志显示连接建立但很快被关闭。再查Nginx配置,发现没有配置WebSocket升级所需的Header。默认的proxy_pass配置不会传递Upgrade和Connection头,而WebSocket协议升级依赖这两个Header。解决方法是在Nginx的location块中显式加上:
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
这里还有一个隐藏问题:WebSocket连接默认不活跃时会被代理层关闭,所以Nginx的proxy_read_timeout要设置得足够长,我设置成3600秒。后端还需要实现心跳机制,客户端每30秒发送一次PONG消息,服务端更新心跳时间,超过60秒没收到心跳就主动断开连接。这样能把异常关闭的死连接及时清掉,释放资源。
4.2 问题二:视频转码后播放黑屏
这个问题的现象是视频状态变成了已发布,播放器也加载出了画面,但画面全黑,只有声音或者干脆什么都没有。
排查思路:先用VLC播放器直接打开转码生成的m3u8文件,发现能正常播放,说明FFmpeg转码本身没问题。接着看网页控制台的网络请求,发现浏览器在请求ts切片文件时,部分切片请求返回404。这时才意识到问题出在文件路径上。
FFmpeg生成的ts切片和m3u8索引文件里的路径是相对路径,如果播放器页面通过HTTP请求这些文件,而静态资源映射配置不正确,找不到切片文件就会黑屏。我的解决方法是把HLS输出目录映射为Spring Boot的静态资源目录:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/hls/**")
.addResourceLocations("file:/data/video/hls/");
}
}
这样播放器请求 /hls/video123/output.m3u8 就能被正确转发到 /data/video/hls/video123/ 目录。如果你的视频文件是放在MinIO里,操作方式会稍有不同——需要保证m3u8里引用的切片文件也是可访问的URL,可以把整个HLS输出目录同步到MinIO的同一个对象前缀下。
4.3 问题三:弹幕高峰期消息积压导致服务响应变慢
弹幕量不大时WebSocket推消息很快,但高峰期同时在线观看人数增多后,用户发弹幕有明显卡顿,服务端CPU和内存占用也持续走高。
排查思路:先看RabbitMQ的监控面板,发现队列中消息堆积数量持续上涨,说明生产速度大于消费速度。再看消费者代码,发现问题出在弹幕发送的处理链路里——每次收到弹幕消息,消费者都要做三件事:往MySQL写数据、往Redis更新计数、往WebSocket推送给在线用户。其中MySQL写入是IO操作,在高并发下成了瓶颈。
优化方案是把写MySQL这一步做异步化处理。弹幕发送请求进来后,先把弹幕消息发到RabbitMQ,消费者从队列拿到消息后直接推WebSocket给在线用户(这个操作走内存,毫秒级),同时把弹幕数据批量累积到一个队列里,每100条或者每2秒批量执行一次INSERT语句写入MySQL。Redis的计数更新也改成INCRBY批量操作。
这样改造后,单条弹幕的RT从平均50ms降到了5ms以内,高峰期消息积压也没有再出现。批量写的另一个好处是减少数据库连接频繁开关的开销,对DB的负载更友好。
4.4 问题四:多线程环境下的弹幕计数错乱
弹幕计数在并发场景下出过错——用户发送弹幕后,视频的弹幕数显示不准确,有时候差几十条。
排查思路:初步怀疑是并发更新导致计数覆盖。原来的实现是:先SELECT当前弹幕数,+1,再UPDATE回写。多线程下A、B同时SELECT到同一个值,A先UPDATE成100+1=101,B再UPDATE成100+1=101,结果两次操作实际变成只加了1。解决方法是改成原子操作:
UPDATE video SET danmaku_count = danmaku_count + 1 WHERE id = ?
这样数据库行锁会保证并发场景下计数正确。更推荐的做法是像前面说的,高频计数走Redis的INCR命令,Redis是单线程模型,INCR天然是原子的,不会出现并发覆盖问题,然后定期把Redis计数器同步到MySQL做持久化。
4.5 常见问题速查表
| 问题现象 | 排查方向 | 解决方法 |
|---|---|---|
| WebSocket连接频繁断开 | 检查Nginx是否配置Upgrade头 | 配置proxy_set_header Upgrade和Connection "upgrade" |
| 视频播放黑屏 | 检查ts切片请求是否404 | 配置静态资源映射或将hls文件上云存储 |
| 弹幕消息延迟高 | 检查队列积压和数据库IO | 批量异步写库,Redis缓存弹幕列表 |
| 弹幕计数不准确 | 检查是否用SELECT+UPDATE方式更新计数 | 改用Redis INCR或SQL原子自增 |
| 上传大文件超时 | 检查网关或Nginx的body大小限制 | 设置client_max_body_size并配置分片上传 |
| 转码CPU飙升 | 检查并发转码任务数 | 限制FFmpeg并发数,使用MQ调度 |
| 用户发弹幕没反应 | 检查WebSocket连接是否断连 | 实现心跳保活,断开自动重连 |
| 历史弹幕加载慢 | 检查是否命中Redis缓存 | 弹幕列表缓存到Redis,设置合理过期时间 |
最后再分享一点实际运维体会
整个项目从设计到上线,最深的体会是:弹幕视频网站的核心难点不在于某个单独的技术点,而在于把视频处理和实时通信这两条技术链路无缝地整合在同一个项目里。视频和弹幕是两条独立但互相依赖的链路——视频处理链路重IO、重计算、适合异步化;弹幕链路重实时、重并发、适合内存和缓存优先。做技术选型时如果只盯着某一个环节,越做到后面越会发现其他环节的设计影响到自己。
第二个体会是关于搜索观察到的经验:我注意到不止一次有人把这个项目当成面试项目来准备。弹幕视频网站确实很适合作为面试项目来讲,它比常规的CRUD管理系统有更深的技术延展性。面试官问你WebSocket实现原理时,你可以顺着项目里的实战细节讲清楚如何管理连接、如何处理广播消息、如何应对断连恢复;面试官问你消息队列应用场景时,你可以讲视频转码异步化解耦的实际案例;面试官问Redis应用场景时,弹幕缓存、计数器、分布式Session都是现成的例子。建议你把整个链路的每一个环节都吃透,比如弹幕推送为什么用WebSocket而不是轮询、转码为什么要做异步、消息队列在这里扮演什么角色,而不是只记住代码能跑起来。
如果后续要给这个项目加进阶能力,我个人建议从两个方向入手:一是加入推荐算法,根据用户的观看历史和点赞行为做视频推荐,这个方向涉及大数据和机器学习,是很好的加分项;二是做多端适配,目前只做了Web端,移动端H5和Android App是天然的扩展场景,移动端对视频流和WebSocket连接稳定性的要求会更高,能继续深挖不少技术细节。无论继续往哪个方向走,这个项目的技术栈和代码结构都留有足够的扩展空间。
写到最后提醒一句:这个项目如果你想自己复现,不要贪大求全。先跑通一条最简单的链路——用户上传一个视频,转码后能播放,弹幕能发能收——再逐步加评论、点赞、关注这些外围功能。核心链路稳定,后面的功能都是锦上添花,不会动摇项目的根基。
更多推荐
所有评论(0)