2026/9/18 4:14:56

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

仿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层会按照VideoService、VideoDanmuService、VideoCommentService这样拆开而不是把视频所有东西堆到一个类里。2.2 Maven依赖选型的细节核心依赖我用的是当前Java后端最主流的一套组合下面是pom.xml里的关键依赖!-- SpringBoot 2.7.x稳定且生态兼容性好 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.14/version /parent dependencies !-- web层 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus简化CRUD操作 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Redis用于缓存和热点数据 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- JWT用于登录鉴权 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- MinIO Java SDK对象存储 -- dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.2/version /dependency !-- Lombok减少样板代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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. 根据账号查询用户支持邮箱或手机号登录 LambdaQueryWrapperUserInfo 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:5173Vite默认端口后端接口跑在localhost:8888如果不处理跨域浏览器直接拦截请求。光加CrossOrigin注解还不够——拦截器里要放行OPTIONS预检请求否则浏览器会在正式请求之前发一个OPTIONS请求那时拦截器还没到Controller就被拦截了报一堆莫名其妙的错。4.2 视频上传分片上传与断点续传的实践B站视频动辄几百MB直接一次POST上传很不现实网络抖动一次就失败失败就得重来。我采用的是分片上传方案这也是当前视频类项目的主流做法。整体流程分三步前端切分将大文件切成固定大小的分片比如每片5MB逐片上传。后端接收接收分片时将数据暂存到磁盘上的临时目录记录已上传的分片序号。合并完成所有分片传完后前端调用一个合并接口后端将所有分片文件合并成一个完整视频文件再进行转码。核心代码如下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切片播放器可以通过索引文件边下边播。我用的是FFmpegSpringBoot里通过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做三级缓存一级缓存热门视频列表按分类Redis里存序列化后的JSON过期时间2分钟。二级缓存视频详情数据Redis存储key设计为video:detail:{videoId}过期时间30分钟。三级缓存播放量、弹幕数等计数类数据Redis直接做原子自增。public ListVideoVO 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. 缓存未命中查数据库 ListVideoVO 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 groupIdcom.github.xiaoymin/groupId artifactIdknife4j-openapi2-spring-boot-starter/artifactId version4.4.0/version /dependency配置之后在Controller类上补上Api和ApiOperation注解启动应用后访问/doc.html就能看到所有接口的说明和在线调试入口。前端同事拿到这个地址可以不依赖后端启动环境独立开发。Api(tags 视频接口) RestController RequestMapping(/api/video) public class VideoController { ApiOperation(获取视频详情) GetMapping(/detail/{videoId}) public ResultVOVideoDetailVO getVideoDetail(PathVariable Long videoId) { return ResultVO.success(videoService.getVideoDetail(videoId)); } }6.2 统一响应体与全局异常处理避免接口返回一堆杂乱JSON全部接口统一返回格式是我做任何项目都会坚持的规范。easylive里我定义了一个ResultVO类public class ResultVOT { private Integer code; // 状态码200成功500业务异常 private String message; // 提示信息 private T data; // 业务数据 }再加上全局异常处理器把业务异常、参数校验异常、未知异常分别处理保证任何情况下返回给前端的JSON结构都是一致的RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResultVOVoid handleBusinessException(BusinessException e) { return ResultVO.fail(e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVOVoid handleValidException(MethodArgumentNotValidException e) { String message e.getBindingResult().getFieldError().getDefaultMessage(); return ResultVO.fail(message); } ExceptionHandler(Exception.class) public ResultVOVoid 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写得不顺手而是索引设计有问题。把这两个指标盯住服务器的稳定性会提升一个台阶。