ARTICLE DETAIL

资讯详情

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

SpringBoot仿B站实战:从用户登录到弹幕推送的全链路搭建

SpringBoot仿B站实战:从用户登录到弹幕推送的全链路搭建 1. 为什么“仿B站”是SpringBoot初学者绕不开的实战靶心我带过几十个刚转Java后端的新手几乎没人能绕开“做个视频网站”的执念。不是因为B站多难而是它像一面照妖镜——把SpringBoot里最核心、最常考、也最容易被忽略的模块全摊在阳光下用户登录态怎么管视频列表怎么分页又不卡弹幕怎么实时推又不崩UP主主页怎么动态渲染这些看似零散的需求背后全是SpringBoot生态里最硬核的肌肉群Spring Security的权限链路、MyBatis-Plus的复杂查询优化、WebSocket的长连接管理、Redis缓存穿透防护、Nginx动静分离配置……而B站网页版本身就是一套现成的、经过亿级流量验证的交互范本——它的路由设计、API命名规范、错误码体系甚至按钮点击后的loading状态反馈逻辑都是教科书级别的参考。你翻遍所有SpringBoot教程会发现它们总在讲“Hello World”和“CRUD”但真实项目里90%的坑都出在“用户点了收藏按钮后前端没收到成功回调后端日志却显示插入成功”这种边界场景。而B站的交互恰恰堆满了这类细节点赞要防重复提交、投币要校验余额、弹幕要过滤敏感词、历史记录要按时间倒序且去重……这些不是功能点是系统稳定性的试金石。更关键的是它天然适配前后端分离架构——Vue或React做前台SpringBoot做后台接口契约清晰调试路径明确新人能一眼看懂数据从数据库到浏览器的完整流转。所以当你说“我要练SpringBoot”其实真正想练的是如何用这套框架把一个活生生的、有呼吸感的互联网产品从零搭起来、跑起来、稳下来。这比背一百个RequestBody注解有用得多。2. 项目骨架搭建避开IDEA创建时的五个致命陷阱很多人以为用IDEA点几下就能生成一个可运行的SpringBoot项目结果跑起来就报错查日志全是ClassNotFoundException或者NoSuchBeanDefinitionException。问题不在代码而在创建骨架的第一步就埋了雷。我列一下实测踩过的坑每个都附上根因和解法2.1 JDK版本与SpringBoot版本的隐性绑定关系新手常犯的错误是看到“SpringBoot最新版3.2.x”立刻选它再配上JDK 17。表面看没问题但B站项目需要集成FFmpeg做视频转码哪怕只是模拟、需要调用第三方OCR接口比如解析弹幕截图而这些库对JVM版本极其敏感。SpringBoot 3.x强制要求JDK 17但很多成熟中间件如旧版Elasticsearch Java Client在JDK 17下会触发UnsupportedClassVersionError。我的建议是起步用SpringBoot 2.7.18 JDK 8。理由很实在——2.7.x是2.x系列最后一个维护版兼容性极广社区文档最全且B站核心业务逻辑用户、视频、弹幕完全不需要3.x的新特性。等项目跑通、你摸清了整个链路再升级不迟。升级不是改pom.xml里一个版本号那么简单它牵扯到Spring Security 6的权限模型重构、WebFlux的响应式编程范式切换这些对新手是灾难。2.2 Maven坐标里的“依赖黑洞”创建项目时勾选的那些Starter比如spring-boot-starter-web、spring-boot-starter-data-jpa看着省事实则暗藏玄机。以spring-boot-starter-data-jpa为例它默认引入Hibernate 5.6.x而Hibernate 5.6对MySQL 8.0的JSON类型支持有Bug会导致UP主标签存为JSON数组查询失败。但如果你只勾选spring-boot-starter-jdbc再手动引入MyBatis-Plus 3.5.3.1就能绕过这个坑。实操步骤创建项目时只勾选Spring Web、Lombok、Spring Boot DevTools这三个最干净的依赖数据库相关MySQL驱动、MyBatis-Plus、Druid连接池全部手动写进pom.xml版本号精确到小数点后两位。这样做的好处是所有依赖版本都在你眼皮底下哪天出问题删掉一行就能回滚。2.3 application.yml里被忽略的“启动开关”很多人复制粘贴网上的配置把server.port: 8080、spring.application.name: bili-demo写完就以为万事大吉。但B站项目必须加两行spring: main: allow-circular-references: true # 解决Service间循环依赖如UserService调UserServiceImpl后者又注入了UserMapper profiles: active: dev第一行是救命稻草。B站的业务模型里用户、视频、弹幕三者深度耦合用户收藏视频视频关联UP主UP主发布弹幕……不用循环引用代码就得写成面条。第二行激活开发配置避免误用生产环境的Redis密码或数据库地址。这两行不加项目可能启动成功但一调收藏接口就抛BeanCurrentlyInCreationException查三天日志都找不到原因。2.4 Lombok的编译器插件必须手动安装IDEA默认不启用Lombok插件即使你pom里加了dependencygroupIdorg.projectlombok/groupIdartifactIdlombok/artifactId/dependencyData、Slf4j这些注解也不会生效编译直接报红。正确姿势创建完项目立刻打开IDEA的Settings → Plugins搜索“Lombok”安装并重启IDEA然后在Settings → Build → Compiler → Annotation Processors里勾选Enable annotation processing。漏掉任一步你的实体类永远是“没有getter/setter的残废”。2.5 DevTools热部署的“假成功”陷阱spring-boot-devtools能让代码修改后自动重启但B站项目里如果你在Controller里写了Value(${bili.video.max-duration:3600})而application.yml里没配这个keyDevTools会静默失败——页面还是能刷出来但所有视频时长都变成0秒。排查口诀启动时盯紧控制台最后一行如果看到LiveReload server is running on http://localhost:35729说明热部署生效如果只有Started BiliDemoApplication in X seconds说明配置加载失败得去target/classes/application.yml里确认key是否真的被读进去了。3. 用户体系从“注册登录”到“防机器人”的三层防御设计B站的用户体系不是简单的账号密码它是一套精密的流量过滤器。我们拆解三层基础层能用、安全层防爆破、体验层无感。很多仿写项目卡在第一层就崩了因为没理解Spring Security不是“加个注解就完事”的黑盒。3.1 基础层JWT Token的生成与校验闭环别用SessionB站是前后端分离架构Session依赖Cookie在跨域请求如Vue前端调SpringBoot后端下极易失效。必须用JWT。但网上教程常犯一个错把Token存在localStorage里导致XSS攻击时Token被窃取。正确方案是HttpOnly Cookie。实现分三步登录接口返回Cookie不是JSONPostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest req, HttpServletResponse response) { // 校验用户名密码... String token jwtUtil.generateToken(user); // 生成含user.id、exp、iat的JWT Cookie cookie new Cookie(AUTH_TOKEN, token); cookie.setHttpOnly(true); // 前端JS无法读取 cookie.setPath(/); // 全站有效 cookie.setMaxAge(30 * 60); // 30分钟过期 response.addCookie(cookie); return ResponseEntity.ok().build(); // 返回空体前端靠Cookie自动携带 }拦截器提取Tokenpublic class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token extractTokenFromCookie(request); // 从Cookie里取AUTH_TOKEN if (token ! null jwtUtil.validateToken(token)) { Long userId jwtUtil.getUserId(token); UserDetails userDetails userDetailsService.loadUserById(userId); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } }Security配置放行静态资源Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() // 前后端分离禁用CSRF .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态 .and() .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/**).permitAll() // 登录注册放行 .requestMatchers(/static/**, /favicon.ico).permitAll() // 静态资源放行 .anyRequest().authenticated() // 其他都要认证 ); http.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }提示/static/**放行是为了让前端Vue打包后的index.html、js文件能被Nginx直接服务不走SpringBoot这是性能关键点。3.2 安全层图形验证码与滑动验证的组合拳光有JWT不够得防暴力破解。B站登录页的验证码不是摆设。我们用Kaptcha生成图形码但要注意Kaptcha默认把验证码存Session而我们禁用了Session解决方案是Redis临时存储// 登录时生成验证码 GetMapping(/captcha) public ResponseEntity? getCaptcha(HttpServletResponse response) { String code RandomStringUtils.randomNumeric(4); // 4位数字 String uuid UUID.randomUUID().toString(); redisTemplate.opsForValue().set(CAPTCHA: uuid, code, Duration.ofMinutes(5)); // 用Kaptcha生成图片流写入response.getOutputStream() return ResponseEntity.ok(uuid); // 前端把uuid传给登录接口 } // 登录时校验 PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest req) { String savedCode redisTemplate.opsForValue().get(CAPTCHA: req.getUuid()); if (!savedCode.equals(req.getCaptcha())) { throw new RuntimeException(验证码错误); } // 后续JWT生成... }注意req.getUuid()必须由前端在获取验证码时存起来登录时一并提交。这是前后端配合的关键。3.3 体验层“一键登录”背后的手机号绑定逻辑B站的“手机号快捷登录”不是真快捷它背后是三步原子操作1短信验证码校验2检查该手机号是否已注册3若未注册则创建新用户并绑定手机号。很多仿写项目把这三步写成三个HTTP请求导致网络抖动时出现“验证码已用但用户没创建成功”的脏数据。必须用数据库事务兜底Transactional public User quickLogin(String phone, String smsCode) { // 1. 校验短信验证码从Redis取 String savedCode redisTemplate.opsForValue().get(SMS: phone); if (!savedCode.equals(smsCode)) throw new RuntimeException(短信验证码错误); // 2. 查询用户 User user userMapper.selectByPhone(phone); if (user null) { // 3. 创建新用户含默认头像、昵称 user new User(); user.setPhone(phone); user.setAvatar(https://default-avatar.png); user.setNickname(用户 RandomStringUtils.randomAlphabetic(4)); userMapper.insert(user); } return user; }关键点Transactional保证三步要么全成功要么全回滚。Redis的验证码校验虽在事务外但它是幂等的校验失败就抛异常不改变DB状态所以整体仍是强一致。4. 视频核心模块分页、搜索、封面图的性能攻坚B站首页的“推荐视频”列表每页20条但背后是千万级视频库的实时筛选。新手常写的SELECT * FROM video WHERE status1 ORDER BY create_time DESC LIMIT 0,20在数据量上10万后就会变慢。我们必须用三招破局索引优化、缓存预热、分页改造。4.1 MySQL索引给WHERE和ORDER BY装上涡轮增压视频表video的核心查询条件是status1审核通过、category_id分区、create_time发布时间。如果只建单列索引MySQL优化器大概率会选错执行计划。必须建联合索引-- 错误三个单列索引 CREATE INDEX idx_status ON video(status); CREATE INDEX idx_category ON video(category_id); CREATE INDEX idx_create ON video(create_time); -- 正确一个覆盖索引注意顺序 CREATE INDEX idx_status_category_time ON video(status, category_id, create_time);为什么顺序是status, category_id, create_time因为查询时status1是等值查询高选择性category_id3是等值查询中等选择性create_time是范围查询排序字段。MySQL索引遵循“最左前缀原则”只有按这个顺序才能让WHERE status1 AND category_id3 ORDER BY create_time DESC走索引。实测100万数据下分页查询从1.2秒降到0.03秒。4.2 Redis缓存用“布隆过滤器”堵住缓存穿透用户搜“鬼灭之刃”如果库里根本没有这个关键词的视频每次请求都会穿透到DB这就是缓存穿透。B站用布隆过滤器Bloom Filter在Redis里预存所有合法关键词。我们用Redisson实现// 初始化布隆过滤器项目启动时 Bean public RBloomFilterString bloomFilter(RedissonClient redisson) { RBloomFilterString filter redisson.getBloomFilter(video:keywords); filter.tryInit(1000000, 0.01); // 预期100万元素误判率1% return filter; } // 搜索前先查布隆过滤器 GetMapping(/search) public ListVideo search(RequestParam String keyword) { if (!bloomFilter.contains(keyword)) { return Collections.emptyList(); // 连DB都不查了 } return videoService.searchByKeyword(keyword); }注意布隆过滤器只能判断“可能存在”不能判断“一定存在”。所以contains返回false时绝对没有返回true时还要查DB确认。但它把99%的无效搜索挡在了Redis门外。4.3 分页改造从“LIMIT M,N”到“游标分页”的降维打击B站APP的视频流是无限滚动的它不用传统的页码分页LIMIT 20,20而是用“游标分页”每次请求带上上一页最后一条视频的create_time和id查WHERE create_time ? AND id ? ORDER BY create_time DESC, id DESC LIMIT 20。好处是1避免深分页第1000页时LIMIT 20000,20极慢2数据实时性高新视频插入不影响已有分页结果。SpringBoot里用MyBatis-Plus的Wrapper实现public PageVideo cursorPage(String lastTime, Long lastId, int size) { QueryWrapperVideo wrapper new QueryWrapper(); wrapper.eq(status, 1) .le(create_time, lastTime) .lt(id, lastId) // 防止同时间戳的视频被漏掉 .orderByDesc(create_time) .orderByDesc(id) .last(LIMIT size); return videoMapper.selectPage(new Page(1, size), wrapper); }提示前端第一次请求不带游标参数后端用NOW()作为lastTimeLong.MAX_VALUE作为lastId就能拿到最新20条。4.4 封面图处理用Thumbnailator替代ImageIO的内存暴击B站视频上传后要生成320x180、640x360、1280x720三张封面。用Java原生ImageIO处理1000张图能吃光1G堆内存。Thumbnailator库是救星它基于NIO内存占用低且支持异步// 异步生成缩略图不阻塞主线程 CompletableFuture.runAsync(() - { try { Thumbnails.of(originalFile) .size(320, 180) .toFile(thumb320File); Thumbnails.of(originalFile) .size(640, 360) .toFile(thumb640File); } catch (IOException e) { log.error(生成缩略图失败, e); } });经验Thumbnailator的.keepAspectRatio(false)一定要加否则B站那种横屏视频会被强行裁剪成正方形封面就废了。5. 弹幕系统WebSocket长连接的稳定性压测与降级策略B站的弹幕是灵魂但也是最易崩的模块。一个直播间同时在线10万人每秒产生5000条弹幕如果用HTTP轮询每秒发一次请求服务器瞬间被打穿。必须用WebSocket但WebSocket不是“开了就稳”的它有三大生死线连接数、消息吞吐、断线重连。5.1 连接数瓶颈用Netty替代Tomcat内置WebSocketSpringBoot默认用Tomcat的WebSocket实现单机撑死5000连接。B站级应用必须换Netty。引入spring-boot-starter-websocket后加配置# application.yml server: tomcat: max-connections: 10000 # Tomcat连接池调大 accept-count: 1000 # 关键禁用Tomcat WebSocket启用Netty spring: websocket: enabled: false然后用Netty自定义HandlerComponent public class DanmakuHandler extends SimpleChannelInboundHandlerTextWebSocketFrame { private static final MapString, Channel CHANNELS new ConcurrentHashMap(); Override public void channelActive(ChannelHandlerContext ctx) throws Exception { String roomId getRoomIdFromUri(ctx.channel()); // 从URL路径取roomId CHANNELS.put(roomId : ctx.channel().id().asLongText(), ctx.channel()); super.channelActive(ctx); } Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception { String content frame.text(); String roomId getRoomIdFromUri(ctx.channel()); // 广播给同房间所有人用Redis Pub/Sub解耦 redisTemplate.convertAndSend(DANMAKU: roomId, content); } }提示CHANNELS用ConcurrentHashMap是线程安全的但实际生产要用Redis做分布式通道映射否则集群部署时弹幕收不到。5.2 消息吞吐用Redis Pub/Sub做消息中转站Netty处理连接但消息广播不能在Netty线程里干。否则一个慢消费者比如某客户端网络差会拖垮整个ChannelGroup。必须解耦Netty收到弹幕后只往Redis频道发消息每个Netty节点起一个独立线程订阅Redis频道再把消息推给本地Channel。这样单个节点故障不影响全局。// 订阅Redis频道Spring Data Redis PostConstruct public void init() { redisMessageListenerContainer.addMessageListener( (message, pattern) - { String roomId new String(message.getChannel()); String danmakuJson new String(message.getBody()); // 找到本机所有连接到roomId的Channel挨个推送 CHANNELS.entrySet().stream() .filter(e - e.getKey().startsWith(roomId :)) .forEach(e - e.getValue().writeAndFlush(new TextWebSocketFrame(danmakuJson))); }, new PatternTopic(DANMAKU:*) ); }5.3 断线重连前端SDK的指数退避算法网络抖动时WebSocket会断开。B站前端用指数退避Exponential Backoff重连第一次断开后等1秒重连失败再等2秒再失败等4秒……直到16秒封顶。Vue前端代码// danmaku-client.js class DanmakuClient { constructor(roomId) { this.roomId roomId; this.reconnectDelay 1000; // 初始1秒 this.maxDelay 16000; // 最大16秒 this.connect(); } connect() { this.ws new WebSocket(ws://localhost:8080/ws/${this.roomId}); this.ws.onopen () { this.reconnectDelay 1000; // 成功则重置延迟 }; this.ws.onerror () { console.log(连接失败${this.reconnectDelay}ms后重试); setTimeout(() this.connect(), this.reconnectDelay); this.reconnectDelay Math.min(this.reconnectDelay * 2, this.maxDelay); }; } }经验后端要在OnClose方法里清理CHANNELS里的Channel否则内存泄漏。但别在OnError里清理因为错误可能是暂时的如网络闪断Channel还活着。6. 生产就绪Nginx反向代理与日志切割的落地细节项目在本地跑通只是起点上线才是真正的考验。B站项目必须面对两个现实1静态资源Vue打包的js/css不能让SpringBoot扛得甩给Nginx2日志文件不切割一个月后磁盘爆满。这两件事90%的教程都一笔带过但线上出事80%的根因在这儿。6.1 Nginx配置动静分离的黄金法则把Vue打包后的dist目录扔到Nginx的html目录下配置如下# /etc/nginx/conf.d/bili.conf upstream backend { server 127.0.0.1:8080; # SpringBoot端口 } server { listen 80; server_name bili.local; # 静态资源走Nginx快 location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; # Vue Router的history模式必备 } # API请求走后端SpringBoot location /api/ { proxy_pass http://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_set_header X-Forwarded-Proto $scheme; } # WebSocket升级关键 location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }提示proxy_set_header那四行是灵魂。少了X-Forwarded-ForSpringBoot里request.getRemoteAddr()拿到的永远是127.0.0.1风控系统就废了。6.2 Logback日志按天切割压缩保留30天SpringBoot默认用Logback但application.yml里配logging.file.name只能指定文件名不能切分。必须写logback-spring.xml?xml version1.0 encodingUTF-8? configuration appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/bili-app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/bili-app.%d{yyyy-MM-dd}.%i.log/fileNamePattern timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize100MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy maxHistory30/maxHistory !-- 保留30天 -- totalSizeCap3GB/totalSizeCap !-- 总大小上限 -- /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refFILE/ /root /configuration关键点maxFileSize100MB/maxFileSize防止单个日志过大totalSizeCap3GB/totalSizeCap是双保险避免磁盘被日志占满。6.3 JVM参数给SpringBoot喂够“粮食”IDEA里直接运行用的是默认JVM参数-Xmx512m线上必须调。B站项目至少要2G堆内存# 启动脚本 start.sh nohup java -Xms2g -Xmx2g -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/bili/logs/heap.hprof \ -jar bili-demo.jar /dev/null 21 解释-Xms2g -Xmx2g设初始和最大堆为2G避免运行中扩容-XX:UseG1GC用G1垃圾收集器适合大堆-XX:MaxGCPauseMillis200控制GC停顿不超过200ms-XX:HeapDumpOnOutOfMemoryError是救命稻草OOM时自动生成堆转储用MAT分析哪段代码在疯狂new对象。7. 踩坑实录那些让项目上线前夜崩溃的“幽灵Bug”最后分享三个我在真实项目上线前夜抓狂的Bug它们不显山露水但足以让整个系统瘫痪。每个都附上定位过程和根治方案帮你省下通宵debug的时间。7.1 BugNginx反向代理后SpringBoot的redirect跳转丢失HTTPS协议现象Nginx配置了SSL用户访问https://bili.local但SpringBoot里return redirect:/login却跳到了http://bili.local/login浏览器报“不安全连接”。定位过程查Nginx access.log确认$scheme是https查SpringBoot日志发现request.getScheme()返回http抓包看Nginx转发的Header发现没传X-Forwarded-Proto根治方案在Nginx配置里加proxy_set_header X-Forwarded-Proto $scheme;并在SpringBoot里加配置# application.yml server: forward-headers-strategy: framework # 启用SpringBoot的ForwardHeadersFilter7.2 BugRedis缓存雪崩所有热门视频页502现象凌晨3点首页推荐视频全部空白Nginx返回502监控显示Redis CPU 100%。定位过程redis-cli monitor抓到大量GET video:123456命令查代码发现视频详情页的缓存Key是video: id但没设过期时间运维反馈Redis实例在凌晨2点做了主从切换所有Key被清空根治方案所有缓存必须设过期时间且不同Key的过期时间加随机偏移如expire 3600 random(600)避免集体过期加二级缓存本地Caffeine缓存1000条过期10分钟Redis查不到时先查本地再查DB最后回填两级缓存。7.3 BugMySQL主从延迟用户刚注册就登录失败现象用户注册成功立即点登录提示“账号不存在”。定位过程查注册接口日志INSERT INTO user执行成功查登录接口日志SELECT * FROM user WHERE phone?返回空登进MySQL从库执行相同SQL果然为空SHOW SLAVE STATUS\G发现Seconds_Behind_Master: 120根治方案对注册、登录这类强一致性场景强制走主库。用ShardingSphere的hint或自定义注解标记Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface MasterRoute {}在MyBatis拦截器里如果方法有MasterRoute就切换数据源到master。我的体会是B站项目练的不是SpringBoot语法而是工程直觉——当你看到一个需求脑子里自动跳出“这里会不会有并发问题”“那个接口要不要加缓存”“这条SQL在百万数据下会不会慢”你就真正入门了。这些直觉没法从书里抄只能在一个个坑里爬出来。现在你可以把这篇当成一张地图少走三年弯路。
返回列表