news 2026/9/18 4:14:54

仿B站项目实战:单体架构SpringBoot后端开发全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
仿B站项目实战:单体架构SpringBoot后端开发全解析

1. 为什么easylive用单体版而不是微服务:先把架构决策想清楚

先说个很多人容易犯的毛病——拿到“仿B站”这种需求,第一反应是上微服务。拆个用户服务、视频服务、评论服务、弹幕服务,再搞个网关,听起来很唬人,但说实话,对于个人项目或者中小团队来说,这就是给自己挖坑。

我当时做easylive这个项目时,第一步不是写代码,而是先想明白一个问题:这个项目真正要解决的问题是什么?

答案是:把B站最核心的内容闭环跑通——用户注册登录、视频上传、视频转码播放、弹幕评论互动。这套链路里,业务复杂度并没有高到必须用微服务来拆分,反而用单体架构能更快地完成全链路交付。

1.1 单体版的核心优势在哪里

单体架构最大的好处是开发效率极高。一个SpringBoot应用,内部按业务模块分包,不需要考虑服务间通信、分布式事务、服务注册发现这些问题。调试的时候直接本地起一个服务,断点打到任意地方,链路清晰可见。部署也简单,一个jar包扔到服务器上就能跑。

而微服务带来的拆分、治理、监控成本,在这样一个以学习或快速交付为目标的项目里,属于典型的“为了架构而架构”。技术选型永远要服务于项目目标,不是越复杂越好。

1.2 单体版什么时候会成为瓶颈

我也不是无脑推崇单体。这个方案能扛住的规模,大概在几千到几万日活这个量级。如果你设想的是几十万用户同时在线,那单体确实顶不住——但不是架构顶不住,而是数据库、带宽、转码资源这些基础设施先顶不住。

真到了那一天,单体也不是只能推倒重来。阿里巴巴很多核心业务早期都是单体架构,是通过“拆应用”的方式逐步演进到分布式的。单体架构的模块边界如果画得清晰,后续拆分成本是可控的。所以在动手之前,我建议你把业务模块的边界在package层面就划分清楚,这是为将来留的退路。

2. 骨架搭建:SpringBoot工程初始化与依赖选型

明确了单体路线后,第二步就是搭工程骨架。这一步看似简单,但其实决定了后面写代码的舒服程度。

2.1 工程结构怎么分层

easylive的后端工程我采用的是经典的四层结构,但包名是按照业务模块来组织的,而不是按技术层次来组织:

com.easylive ├── controller # 接口层,只做参数接收和结果返回 ├── service # 业务层,处理核心业务逻辑 ├── mapper # 数据访问层,MyBatis-Plus的Mapper接口 ├── entity # 数据库实体类 ├── dto # 接收前端参数的传输对象 ├── vo # 返回给前端的视图对象 ├── config # 配置类 ├── utils # 工具类 ├── exception # 自定义异常 └── interceptor # 拦截器(登录校验等)

controller、service、mapper这种分层是必须的,但具体到业务模块时,比如视频相关的逻辑,我在service层会按照VideoServiceVideoDanmuServiceVideoCommentService这样拆开,而不是把视频所有东西堆到一个类里。

2.2 Maven依赖选型的细节

核心依赖我用的是当前Java后端最主流的一套组合,下面是pom.xml里的关键依赖:

<!-- SpringBoot 2.7.x,稳定且生态兼容性好 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.14</version> </parent> <dependencies> <!-- web层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus,简化CRUD操作 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Redis,用于缓存和热点数据 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JWT,用于登录鉴权 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MinIO Java SDK,对象存储 --> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.2</version> </dependency> <!-- Lombok,减少样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里有个细节值得注意:视频转码后产生的切片文件,我没有直接塞进MySQL,也没有放到应用所在磁盘就完事,而是接入了MinIO对象存储。这样做的原因是,B站这种形态的视频项目,文件存储和业务数据库必须分离,否则本地磁盘一满,整个应用就危险了。MinIO兼容S3协议,后续就算要迁移到阿里云OSS或者腾讯云COS,代码改动很小。

2.3 配置文件的组织方式

配置这块我拆成了三个文件,符合日常开发习惯:

  • application.yml:公共配置
  • application-dev.yml:本地开发环境
  • application-prod.yml:生产环境

application.yml里的核心内容是这样的:

spring: profiles: active: dev servlet: multipart: max-file-size: 100MB max-request-size: 1024MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

两个点需要特别说明:

multipart上传大小限制。B站视频动辄几百MB甚至几个GB,如果这里不把max-file-size调大,上传接口会直接报错。但也不要以为调大就完事了——后面章节会单独讲分片上传的方案,那个才是真正解决大文件上传的手段。

MyBatis-Plus的逻辑删除配置。B站这种产品,用户删视频、删评论是高频操作,但物理删除会让数据统计和反作弊变得很困难。所以我用逻辑删除字段deleted,默认值0表示未删除,删除操作只是把该字段置为1。所有查询MyBatis-Plus会自动追加WHERE deleted = 0条件,对业务代码完全透明。

3. 拆解仿B站核心业务:视频、用户与互动的表设计

骨架搭好了,接下来是整个项目最关键的一步——数据库表设计。B站这个产品看着简单,实际上业务表之间关系非常复杂。我做easylive时把表拆成了四组:用户体系、视频内容、互动行为、运营支撑

3.1 用户与登录相关表

用户表是整个平台的基础,我的设计如下:

CREATE TABLE `user_info` ( `user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', `nick_name` varchar(50) NOT NULL COMMENT '昵称', `email` varchar(100) DEFAULT NULL COMMENT '邮箱(登录账号)', `phone` varchar(20) DEFAULT NULL COMMENT '手机号(登录账号)', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `birthday` date DEFAULT NULL COMMENT '生日', `sex` tinyint DEFAULT 0 COMMENT '性别 0未知 1男 2女', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`user_id`), UNIQUE KEY `uk_email` (`email`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

用户表有几个关键点:邮箱和手机号都做了唯一索引,这是登录凭证的天然约束;密码必须走BCrypt加密,明文存储是绝对红线;deleted字段配合前面MyBatis-Plus的逻辑删除配置,查数据永远自动带过滤条件。

3.2 视频信息表:状态机设计是重点

视频相关表是整个项目中最核心的部分,我拆了四张表:

  • video_info:视频基本信息
  • video_file:视频分P(分P是B站特色,一个视频多个片段)
  • video_category:视频分类(B站的一级分区和二级分区)
  • video_play_history:播放历史

video_info表的核心字段如下:

CREATE TABLE `video_info` ( `video_id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT 'UP主ID', `video_name` varchar(100) NOT NULL COMMENT '视频标题', `video_desc` text COMMENT '视频简介', `category_id` int NOT NULL COMMENT '分类ID', `cover` varchar(255) DEFAULT NULL COMMENT '封面图地址', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0转码中 1已发布 2下架 3审核失败', `play_count` bigint NOT NULL DEFAULT 0 COMMENT '播放量', `danmu_count` bigint NOT NULL DEFAULT 0 COMMENT '弹幕数', `like_count` bigint NOT NULL DEFAULT 0 COMMENT '点赞数', `publish_time` datetime DEFAULT NULL COMMENT '发布时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint NOT NULL DEFAULT 0, PRIMARY KEY (`video_id`), KEY `idx_user_id` (`user_id`), KEY `idx_category_status_publish` (`category_id`, `status`, `publish_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频信息表';

status字段是整个表设计的灵魂。B站的视频发布不是用户点上传就立刻可见,而是要经过一个状态流转:转码中 -> 已发布。如果转码失败或者内容违规,也可能是下架审核失败。查询列表时只展示status = 1的数据,这个状态机逻辑不复杂,但如果一开始不规划好,后面加审核流程时会非常痛苦。

注意那个复合索引(category_id, status, publish_time),这是视频列表页最核心的查询路径:按分类过滤、只看已发布、再按发布时间排序。没有这个索引,数据量一上来,首页接口就会变成一个慢查询。

3.3 弹幕与评论表:B站互动的核心

弹幕表设计时要考虑的是读写路径。B站的弹幕是按视频+时间点来查询的,所以索引要覆盖这两个维度:

CREATE TABLE `video_danmu` ( `danmu_id` bigint NOT NULL AUTO_INCREMENT, `video_id` bigint NOT NULL COMMENT '视频ID', `user_id` bigint DEFAULT NULL COMMENT '发送用户ID', `content` varchar(500) NOT NULL COMMENT '弹幕内容', `time_sec` int NOT NULL COMMENT '弹幕出现的秒数', `color` varchar(10) DEFAULT NULL COMMENT '弹幕颜色', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`danmu_id`), KEY `idx_video_time` (`video_id`, `time_sec`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='弹幕表';

弹幕查询只有一个场景:打开某个视频,拉到某个时间点附近的弹幕。所以(video_id, time_sec)这个联合索引就是为这个高频查询定制的。

评论表类似,但比弹幕多一个层级关系——B站的评论有楼中楼。我的方案是增加一个parent_id字段,为0表示一级评论,非0表示回复某条评论。这样不需要额外设计复杂的表结构,就能支撑两级评论展示。

4. 后端核心链路实现:鉴权、上传、转码与弹幕推送

表结构玩明白了,接下来真正动手写后端逻辑。这一章我把easylive最核心的四条链路完整走一遍,每条链路都踩过坑,讲出来供参考。

4.1 JWT登录鉴权:不是写个拦截器那么简单

用户登录这块,我用的方案是JWT + 拦截器。登录接口校验用户密码通过后,签发一个token返回给前端:

@Override public TokenVO login(String account, String password) { // 1. 根据账号查询用户(支持邮箱或手机号登录) LambdaQueryWrapper<UserInfo> query = Wrappers.lambdaQuery(); query.eq(UserInfo::getEmail, account).or().eq(UserInfo::getPhone, account); UserInfo user = userInfoMapper.selectOne(query); // 2. 用户不存在或密码不正确 if (user == null) { throw new BusinessException("账号或密码错误"); } if (!BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException("账号或密码错误"); } // 3. 生成JWT,有效期7天 String token = JWT.create() .withAudience(user.getUserId().toString()) .withExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .sign(Algorithm.HMAC256(jwtSecret)); // 4. 记录登录日志,返回结果 return new TokenVO(token, user); }

真正麻烦的是拦截器设计。我实现了一个LoginInterceptor,统一处理两件事:token解析验证用户信息透传

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } // 从请求头中获取token String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { throw new BusinessException("未登录或登录已过期"); } try { // 解析token,获取用户ID DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256(jwtSecret)) .build().verify(token.replace("Bearer ", "")); Long userId = Long.valueOf(decodedJWT.getAudience().get(0)); // 将用户信息放入请求上下文,方便后续接口使用 request.setAttribute("userId", userId); return true; } catch (Exception e) { throw new BusinessException("Token无效或已过期"); } }

这里有个容易踩的坑:前后端分离项目的跨域配置。前端页面跑在localhost:5173(Vite默认端口),后端接口跑在localhost:8888,如果不处理跨域,浏览器直接拦截请求。光加@CrossOrigin注解还不够——拦截器里要放行OPTIONS预检请求,否则浏览器会在正式请求之前发一个OPTIONS请求,那时拦截器还没到Controller就被拦截了,报一堆莫名其妙的错。

4.2 视频上传:分片上传与断点续传的实践

B站视频动辄几百MB,直接一次POST上传很不现实:网络抖动一次就失败,失败就得重来。我采用的是分片上传方案,这也是当前视频类项目的主流做法。

整体流程分三步:

  1. 前端切分:将大文件切成固定大小的分片(比如每片5MB),逐片上传。
  2. 后端接收:接收分片时,将数据暂存到磁盘上的临时目录,记录已上传的分片序号。
  3. 合并完成:所有分片传完后,前端调用一个"合并"接口,后端将所有分片文件合并成一个完整视频文件,再进行转码。

核心代码如下:

@PostMapping("/uploadChunk") public ResultVO uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("videoId") Long videoId, @RequestParam("chunkIndex") Integer chunkIndex) { // 保存分片到临时目录 String tempDir = fileStorageConfig.getTempPath() + videoId + "/"; File dir = new File(tempDir); if (!dir.exists()) { dir.mkdirs(); } File tempFile = new File(tempDir + chunkIndex + ".part"); file.transferTo(tempFile); return ResultVO.success(); } @PostMapping("/mergeChunks") public ResultVO mergeChunks(@RequestParam("videoId") Long videoId, @RequestParam("fileName") String fileName, @RequestParam("totalChunks") Integer totalChunks) { // 检查所有分片是否齐全 String tempDir = fileStorageConfig.getTempPath() + videoId + "/"; // 按顺序合并分片 // 合并完成后,更新video_info状态为待转码 return ResultVO.success(); }

断点续传的实现思路是:前端在上传前先调一个接口查询“哪些分片已上传”,然后只上传缺失的分片。这个机制实现不难,但对用户体验提升巨大。实测中,一个200MB的视频在普通带宽下,分片上传比整体上传的成功率高出非常多。

4.3 视频转码:FFmpeg命令的封装与异步处理

视频上传到服务器后,必须转码——原因很简单:原始视频格式五花八门,浏览器没法全部直接播放。B站采用的是HLS切片方案,也就是把视频转成.m3u8索引文件加一堆.ts切片,播放器可以通过索引文件边下边播。

我用的是FFmpeg,SpringBoot里通过@Async注解把转码任务异步化,避免阻塞主线程:

@Async("videoTaskExecutor") public void transcodeToHls(Long videoFileId, String inputPath) { String outputDir = fileStorageConfig.getHlsPath() + videoId + "/"; File dir = new File(outputDir); if (!dir.exists()) { dir.mkdirs(); } // 转码为HLS:生成m3u8索引和ts切片 // -hls_time 10: 每10秒切割成一个ts文件 // -hls_list_size 0: 保留全部切片索引,而不是默认的最后5个 String command = String.format( "ffmpeg -i %s -c:v libx264 -c:a aac -hls_time 10 -hls_list_size 0 -f hls %s/index.m3u8", inputPath, outputDir ); // 执行命令... }

这里最重要的参数就是-hls_time 10——让每个TS切片时长为10秒。切片太长拖慢播放启动,太短会产生大量文件、增加磁盘压力。10秒是B站这类主流视频平台的常用经验值。

转码是CPU密集型操作,异步线程池必须单独配置。我在@Configuration类里定义了一个专门的线程池,核心线程数根据服务器CPU核数来定:

@Bean("videoTaskExecutor") public Executor videoTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2); executor.setQueueCapacity(100); executor.setThreadNamePrefix("video-transcode-"); return executor; }

注意千万不要用默认的SimpleAsyncTaskExecutor,那玩意儿每个任务都新建线程,根本没有复用,高并发下分分钟把服务器打爆。

4.4 弹幕系统的推送选择

弹幕是B站的灵魂,也是技术上有点挑战的部分。我最初考虑过WebSocket实时推送,但这会大幅增加后端复杂度——需要维护长连接、处理心跳、做消息广播。最后我选择了HTTP轮询方案

  • 前端播放视频时,通过定时器每3秒拉取一次弹幕接口。
  • 后端按照视频ID和上次拉取的时间戳返回新增弹幕。
  • 用户发送弹幕时,直接POST到后端接口入库。

这个方案的实时性虽然不如WebSocket(有最多3秒延迟),但对单体项目来说完全够用,而且实现简单、稳定可靠。如果你后续想升级到WebSocket,可以把弹幕发送和拉取逻辑抽象成接口,替换起来也不难。

弹幕接口实现时要注意一个问题:一次拉取的数据量要限制。热门视频可能弹幕数量巨大,一次性全量返回,响应体几十MB,播放器直接卡死。我的方案是只返回当前时间点的前后几秒弹幕,配合索引(video_id, time_sec),查询效率极高。

5. 单体版最容易踩的性能坑:热点数据缓存与静态资源分离

单体版看起来结构简单,但这不意味着可以随便写。我在开发后期压测时发现,性能瓶颈往往不在代码逻辑,而在数据库查询文件存储这两个环节。

5.1 列表页的缓存设计:别让首页每次都查库

视频首页的推荐列表是访问量最大的接口。如果每次请求都去MySQL里查数据,并发一高数据库连接池直接被打满。我的方案是用Redis做三级缓存:

  1. 一级缓存:热门视频列表(按分类),Redis里存序列化后的JSON,过期时间2分钟。
  2. 二级缓存:视频详情数据,Redis存储,key设计为video:detail:{videoId},过期时间30分钟。
  3. 三级缓存:播放量、弹幕数等计数类数据,Redis直接做原子自增。
public List<VideoVO> loadVideoList(Integer categoryId, Integer pageNo) { String cacheKey = String.format("video:list:%s:%s", categoryId, pageNo); // 1. 先查缓存 String cachedJson = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(cachedJson)) { return JSON.parseArray(cachedJson, VideoVO.class); } // 2. 缓存未命中,查数据库 List<VideoVO> list = videoMapper.selectVideoList(categoryId, pageNo); // 3. 回填缓存,设置过期时间 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 2, TimeUnit.MINUTES); return list; }

这里有一个非常重要的细节:缓存一定要设置过期时间。很多初学者只set不设置有效期,导致热点数据永不失效,用户都看到旧数据了还不知道问题在哪。另外,列表页的缓存Key要带上分页参数,否则翻页会串数据。

5.2 播放量计数:先写Redis再异步刷库

B站视频页的播放量是实时变化的数字,这种高频写操作绝不能直接打MySQL。我的方案是:

  • 每次播放请求,只对Redis中的计数器做increment操作。
  • 后台定时任务每5分钟把Redis中的播放量批量同步到MySQL。
  • 用户看到的播放量从Redis读取,保证实时性。
public void recordPlayCount(Long videoId) { String key = "video:playcount:" + videoId; redisTemplate.opsForValue().increment(key); }

这个方案能把MySQL的写压力降好几个数量级。B站的播放量数据一致性没那么敏感,偶尔丢几条,或者统计延迟几分钟,用户根本感知不到。

5.3 静态资源必须和应用程序分离

开发早期我图省事,直接用SpringBoot映射本地目录来存放视频文件:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**") .addResourceHandler("file:" + fileStorageConfig.getPath() + "/"); } }

这个方案本地跑没问题,但生产环境隐患很大:视频文件占用大量磁盘,如果和应用jar包放在同一块磁盘,磁盘写满会导致应用崩溃。后来我把视频文件、封面图片统一迁移到了MinIO,应用只管业务逻辑,文件归对象存储管。

迁移后播放地址变成了MinIO的外链,需要单独配置桶的访问权限。这是正确做法,生产环境视频播放地址应该走CDN加速,而对象存储天然适配CDN回源。

5.4 数据库连接池与慢查询优化

HikariCP是SpringBoot默认的连接池,但它默认配置比较保守。我在生产环境做了这些调整:

spring: datasource: type: com.zaxxer.hikari.HikariDataSource hikari: minimum-idle: 10 maximum-pool-size: 30 connection-timeout: 30000 idle-timeout: 600000

这些参数不是拍脑袋定的:maximum-pool-size设30是基于机器配置(8核16G),连接数是线程数的3-4倍左右比较合适;connection-timeout设30秒,给慢查询留了足够时间,但也不至于无限等待。

慢查询优化是另一种手段。我开启MySQL慢查询日志后,发现最常出问题的是视频列表的ORDER BY publish_time DESC LIMIT语句,数据量大之后排序特别慢。后来通过复合索引和缓存双管齐下,页面响应时间从800多毫秒降到了50毫秒以内,效果非常明显。

6. 单体版交付前必须做的事:接口文档、异常统一与部署脚本

很多人在开发阶段很兴奋,一到部署就头大、就拖延。实际上收尾工作做好了,整个项目才算真正完成。这里分享easylive交付时的三个必备动作。

6.1 接口文档:用Knife4j替代手写文档

前后端分离开发,接口文档必须自动化。我用的是Knife4j,基于OpenAPI规范,加一个依赖就能自动生成接口文档页面:

<dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi2-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency>

配置之后,在Controller类上补上@Api@ApiOperation注解,启动应用后访问/doc.html就能看到所有接口的说明和在线调试入口。前端同事拿到这个地址,可以不依赖后端启动环境独立开发。

@Api(tags = "视频接口") @RestController @RequestMapping("/api/video") public class VideoController { @ApiOperation("获取视频详情") @GetMapping("/detail/{videoId}") public ResultVO<VideoDetailVO> getVideoDetail(@PathVariable Long videoId) { return ResultVO.success(videoService.getVideoDetail(videoId)); } }

6.2 统一响应体与全局异常处理:避免接口返回一堆杂乱JSON

全部接口统一返回格式,是我做任何项目都会坚持的规范。easylive里我定义了一个ResultVO类:

public class ResultVO<T> { private Integer code; // 状态码:200成功,500业务异常 private String message; // 提示信息 private T data; // 业务数据 }

再加上全局异常处理器,把业务异常、参数校验异常、未知异常分别处理,保证任何情况下返回给前端的JSON结构都是一致的:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResultVO<Void> handleBusinessException(BusinessException e) { return ResultVO.fail(e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResultVO<Void> handleValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldError().getDefaultMessage(); return ResultVO.fail(message); } @ExceptionHandler(Exception.class) public ResultVO<Void> handleException(Exception e) { log.error("系统异常", e); return ResultVO.fail("系统异常,请稍后再试"); } }

这个设计看起来简单,但实际开发中能省掉大量前端联调的争论。前端不需要针对每个接口单独处理异常格式,统一拦截code,非200一律弹错误提示就行。

6.3 部署脚本:用Docker Compose一键拉起全部依赖

单体应用虽然只要跑一个jar,但依赖MySQL、Redis、MinIO这三个基础设施。我写了一个docker-compose.yml,部署时不需要手动安装配置这些服务:

version: '3' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: easylive123 MYSQL_DATABASE: easylive ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7.0 ports: - "6379:6379" minio: image: minio/minio:latest command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: easylive MINIO_ROOT_PASSWORD: easylive123 ports: - "9000:9000" - "9001:9001" volumes: - ./minio-data:/data app: build: . depends_on: - mysql - redis - minio ports: - "8888:8888" environment: SPRING_PROFILES_ACTIVE: prod

用Docker Compose的好处是,不管换什么服务器,只要装了Docker,一条命令全起来了,环境差异问题彻底解决。

6.4 上线后的一点点个人体会

easylive的这个单体版后端从设计到上线,整体花了两周时间。期间最大的感悟是:技术水平不是靠用了多少中间件衡量的,而是靠把核心链路做到稳定可靠。很多人一上来就撸微服务全家桶,结果视频上传都没跑通,还到处找Bug。我反而觉得,把单体打磨好,把用户、视频、互动这条主链路上的每个细节都做扎实,比强行堆技术栈有价值得多。

最后分享一个实战小技巧:上线后观察MySQL慢查询日志和Redis的命中率。如果Redis命中率长期超过90%,说明缓存策略是健康的;如果某个接口经常走慢查询,那大概率不是SQL写得不顺手,而是索引设计有问题。把这两个指标盯住,服务器的稳定性会提升一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 4:13:32

海光DCU接入Kubernetes全攻略:从Device Plugin到vDCU虚拟化与DeepSeek部署

上个月我们接到一个内部任务&#xff1a;把新到的一批海光 DCU 服务器接入现有 Kubernetes 集群&#xff0c;再通过 CubeStudio 把这些算力以整卡、共享以及两种 vDCU 虚拟化模式开放给算法团队&#xff0c;最后还要在集群里直接拉起 DeepSeek 推理服务。整套做下来&#xff0c…

作者头像 李华
网站建设 2026/9/18 4:12:52

国产操作系统落地实践:从选型到部署避坑指南

国产操作系统这几个字&#xff0c;在IT圈已经喊了很多年&#xff0c;但直到最近两年我才真正觉得它到了“能干活”的阶段。身边不少朋友一听到这个词&#xff0c;第一反应是一个抽象的概念&#xff0c;等打开虚拟机装上统信UOS或者银河麒麟&#xff0c;才发现它其实是一套基于L…

作者头像 李华
网站建设 2026/9/18 4:12:31

TB67S531FTG+STM32F723ZE工业级步进电机控制方案实战解析

1. 为什么选 TB67S531FTG 搭配 STM32F723ZE先说结论&#xff1a;这套组合是工业级步进控制里“性价比”和“可靠性”平衡得相当好的方案。TB67S531FTG 是东芝近年力推的两相双极步进驱动芯片&#xff0c;内部集成了 PWM 斩波恒流控制、电流检测放大器和多种衰减模式&#xff1b…

作者头像 李华
网站建设 2026/9/18 4:11:25

STM32固件烧录实测:SWD比UART快6.8倍,选型与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 4:11:03

Agent-Reach:打造智能体触达层,让Agent真正够得着业务系统

做AI应用落地有一段时间了&#xff0c;我越来越觉得——大部分号称智能的Agent&#xff0c;其实只是"嘴上智能"。你问它什么它都能答&#xff0c;但真要让它去查个订单、改个配置、调个接口&#xff0c;它就卡住了。问题往往不在大模型本身&#xff0c;而在Agent根本…

作者头像 李华