ARTICLE DETAIL

资讯详情

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

基于SpringBoot的直播管理系统实战:从业务建模到数据一致性设计

基于SpringBoot的直播管理系统实战:从业务建模到数据一致性设计 接到“忘忧传媒直播管理系统”这个需求的时候对方一开始的描述很简单——“搞个后台管理能看到主播、直播数据就行”。但真的坐下来梳理业务才发现这类传媒公司的管理后台远不止一张列表的事主播签约状态、直播间场次生命周期、礼物打赏流水、收益分成结算、内容审核记录这些环节如果不在系统层面串起来运营每天要用三个Excel才能把账对上。这篇文章就把我基于SpringBoot做这套直播管理系统的全过程整理出来包括业务建模、技术选型、核心模块实现以及权限、定时任务、文件上传这些管理后台高频坑位的处理方案适合正在做类似管理系统项目、或者准备用SpringBoot做毕设/实训项目的同学参考。1. 想清楚业务再动手忘忧传媒这类公司到底在管什么1.1 管理系统的本质是打通“签约-开播-结算”这条链直播传媒公司的核心业务表面看是“主播开播、观众打赏”但站在公司管理者的角度每天要回答的问题是这个主播签了什么合同这个月播了多少场有效直播时长够不够礼物收益多少公司抽成多少主播该拿多少哪些内容违规被警告过这些问题单独看都不难难的是它们之间存在强关联。主播签的是A档分成合同那么他名下的所有场次收益就都要按A档规则计算某场直播因为违规被强制下播那这场直播的有效时长和礼物收益要不要计入结算这些规则如果靠线下表格维护任何一个环节的信息不同步月底结算就会出现争议。所以在设计系统时我不建议一上来就埋头建表而是先画清楚业务链路。忘忧传媒这类公司的核心链路就三条主播管理链主播入驻 - 资质审核 - 签约合同 - 培训/排期 - 开播 - 数据统计 - 收益结算直播管理链创建场次 - 启动推流 - 直播中监控 - 关播 - 生成回放 - 审核归档财务结算链礼物打赏 - 流水记录 - 平台抽成 - 公会/公司分成 - 主播分成 - 打款这三条链相互交织系统里的每一张表几乎都能在这三条链上找到位置。把链路图画清楚后再建表字段的冗余和关联关系就自然有依据了不会出现“这俩表要不要关联”的纠结。1.2 从角色出发梳理数据模型别一上来就建表系统里的角色大致有超级管理员、运营人员、财务人员、审核人员、主播部分功能开放、普通用户观看端。每一类角色关心的数据不同对应的权限边界也不同。运营要看直播场次和时长数据财务要看流水和结算单审核要看违规记录和证据截图主播只关心自己的收益明细和提现记录。从这些角色出发我梳理出的核心数据模型大致如下数据域核心表关键字段说明主播域anchorid, name, id_card, phone, contract_status, commission_rule_id合同状态和分成规则分开存方便后续合同变更直播域live_roomid, anchor_id, room_name, live_status, start_time, end_timelive_status 贯穿整个场次生命周期直播域live_recordid, room_id, start_time, end_time, valid_duration, replay_url每场直播的明细记录关播时生成礼物域giftid, name, price, coin_price, status礼物定价表下架用 status 控制而非删除流水域gift_logid, user_id, anchor_id, room_id, gift_id, amount, biz_nobiz_no 用于幂等防止重复扣款结算域settlement_batchid, batch_no, period_start, period_end, status结算批次一次结算一个批次结算域settlement_detailid, batch_id, anchor_id, gross_amount, company_share, anchor_share, tax, status每个主播一条明细内容域audit_recordid, target_type, target_id, audit_operator, audit_result, evidence_url覆盖资质审核、回放审核、违规审核这张表不是最终版但它的价值在于让团队成员对“系统到底要存什么”达成共识。我见过很多项目在建表阶段就陷入细节争吵本质原因是没先从角色和链路层面达成一致。2. SpringBoot与周边选型这套技术栈的合理性拆解2.1 自动装配逻辑对项目搭建效率的影响SpringBoot在这个项目里带来的最大收益是省掉了大量配置工作。以前用Spring MVC搭一个Web项目要写web.xml、配置DispatcherServlet、配置数据源、配事务管理器一套下来小半天就没了。SpringBoot用自动装配机制把“约定大于配置”落到了实处。它的底层逻辑其实不神秘spring-boot-autoconfigure包里的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件列举了所有自动配置类启动时Spring Boot会按条件注解逐个判断该加载哪套配置。比如引入了spring-boot-starter-data-redis自动配置类RedisAutoConfiguration发现Classpath里有RedisTemplate、Jedis等类就自动帮我们创建连接工厂和模板Bean如果classpath里没有对应的类条件不满足配置就静默跳过。理解这套机制对后续排错很重要。我遇到过一次诡异问题明明配了HikariCP连接池参数但连接池没生效后来发现是pom里引入了一个老版本的第三方starter它内部的自动配置抢先注册了DataSource相关Bean条件注解ConditionalOnMissingBean判断已有Bean就不再覆盖我们的参数自然就没起到作用。排查这种问题核心是看懂spring-configuration-metadata和条件注解的生效顺序。在版本选择上这个项目用的是Spring Boot 2.7.x而不是最新的3.x原因是3.x要求JDK 17并且javax命名空间迁移到了jakarta如果团队开发机还停留在JDK 8或者要集成的一些老SDK还没有完成迁移会平白增加工作量。如果你是新项目且JDK版本没限制直接上3.x问题不大但务必确认所有依赖都已兼容。2.2 MyBatis与表设计业务查询的灵活性和通用设计ORM选择上我用了MyBatis-Plus而不是JPA/Hibernate。原因在于直播管理系统的查询有一个显著特征条件维度多、组合变量大。运营的后台列表经常要按时间范围、主播名称、直播状态、礼物类型、是否违规等多个条件自由组合筛选而且报表类查询经常需要跨表聚合。Spring Data JPA在做标准CRUD时很省事但碰到复杂的动态查询要么写Query注解声明JPQL要么用Specification拼条件可读性和维护性都不理想。MyBatis的动态SQL在表达这类复杂条件时很顺手 加 的组合可以灵活拼装SQL结果直接映射到VO。用MyBatis-Plus而不是原生MyBatis主要是看中几个实用能力内置分页插件PaginationInnerInterceptor免去手写分页方言、LambdaQueryWrapper用起来比手拼String条件更安全、逻辑删除注解TableLogic直接处理“礼物下架/主播删除”这类场景。表设计方面有几点心得金额字段统一用decimal(10,2)避免float/double的精度问题。涉及分账的金额精度错了就是事故。状态字段不要用魔法值散落在代码里用枚举类统一管理数据库存intJava侧用枚举。比如直播状态live_status0待开播、1直播中、2已关播、3流中断、4已下架。每张业务表都保留create_time、update_time、deleted这三个字段前两个由MyBatis-Plus自动填充deleted用逻辑删除兜底避免手误删数据后无法恢复。唯一业务编号不要依赖自增id暴露给前端比如订单号、流水号、批次号用独立的biz_no字段格式可以包含日期随机数机器位。3. 核心业务的实现路径直播场次、打赏流水与分成结算3.1 直播间状态流转start、living、end、replay直播场次是这套系统里状态最复杂的一个实体。一场直播从运营排期创建到主播开播再到关播生成回放中间还有可能因为违规被强制下播、因为推流异常中断状态转换路径非常多。我设计的状态机如下待开播运营创建场次后进入的状态此时推流地址和拉流地址已经预先分配好。直播中主播推流成功后服务端收到直播平台的回调更新状态为直播中。已关播主播正常结束直播服务端统计直播时长、最高在线人数等数据生成live_record。已中断推流中途异常断开超过设定阈值时间未重连系统自动标记为中断需要运营人工确认是补时还是结束。已下架直播内容审核不通过或涉及违规强制结束并下架回放。这个状态机的核心约束有两个第一只有“待开播”状态允许更新为“直播中”防止主播在非排期时间私自开播第二只有“直播中”才能转“已关播”并且关播操作必须是幂等的也就是说即使用户连点了两次关播按钮也不能生成两条live_record。实现上我用了状态字段加一个状态转换校验方法每次变更都走同一个入口public void changeStatus(LiveRoom room, LiveStatus targetStatus) { LiveStatus current LiveStatus.of(room.getLiveStatus()); if (!current.canTransferTo(targetStatus)) { throw new BusinessException(非法的直播状态变更: current - targetStatus); } room.setLiveStatus(targetStatus.getCode()); }这种写法看着简单但能有效拦截非法状态跳转。曾经出现过一次线上事故网络抖动导致关播回调被重复投递结果生成两条直播记录后来就是在关播回调接口里增加了状态流转校验重复回调到“已关播”状态时直接返回成功但不重复处理问题才解决。3.2 打赏扣款链路余额、流水与防重复礼物打赏是整个系统里并发压力最大的接口。一个热门直播间晚高峰时观众送礼的请求QPS能到几百甚至上千而且每个请求都涉及余额扣减和数据落库。打赏链路的正确顺序是校验礼物状态和用户余额 - 扣减用户余额 - 写礼物流水 - 累加主播收益 - 通知主播端。这里最关键的是扣减余额和写流水必须保证原子性任何一个环节失败都要整体回滚。最开始我图省事直接用update语句扣减余额UPDATE user_wallet SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount}这条SQL自带了余额不够就不更新的保护返回值是影响行数等于0就说明余额不足可以省去一次查询。但这里有个隐患如果先扣了余额后写流水失败事务回滚会把余额扣减一并回滚这个没问题问题在于高并发下如果客户端重复点击“送礼物”按钮同一个用户可能同时发起多个扣款请求都走过了这个update语句每个都扣款成功但用户实际只想送一个礼物。防重复的方案是用唯一业务编号biz_no建唯一索引。客户端在发起打赏请求时生成一个UUID作为本次打赏的业务号后端gift_log表对(room_id, user_id, biz_no)建唯一索引。当重复请求到达时第二笔插入因为唯一索引冲突直接报DuplicateKeyException在异常处理里捕获后直接返回“重复请求请勿重复操作”。这个方案成本低、效果好比用Redis分布式锁去判断“同一个用户是否正在打赏”要简单得多而且分布式锁只能防并发防不了历史重复投递。主播收益的累加我放在同一事务里通过UPDATE语句直接加值避免先查后改的竞态问题。只要事务边界正确、唯一索引兜底这套链路在常规直播间的并发量级下是足够的。3.3 结算引擎固定比例与阶梯规则并存结算模块是财务最关心的部分也是业务规则变化最频繁的部分。忘忧传媒的抽成规则不是一刀切有的头部主播签的是固定比例比如公司拿20%、主播拿80%有的新主播按阶梯走月流水不足5万时公司拿40%超过5万的部分公司只拿30%还有少数合作方存在三方分账平台、公会、主播各按比例分。这类规则如果直接写在业务代码里每次改规则都要改代码重新发版财务等不起。我做了一个简单的结算引擎包含两个核心概念结算规则和结算项。结算规则表存规则头字段包括规则类型固定比例/阶梯比例、生效时间、失效时间、状态。规则明细表存具体的分成比例字段。结算引擎的输入是一个主播在一个结算周期内的礼物流水聚合结果输出是公司收入、主播收入、公会收入、税额等一组明细项。阶梯规则的计算示例周期内主播流水: 60000元 阶梯区间: 0 ~ 50000: 主播分成 60% 50000 ~ 100000: 主播分成 70% 计算: 50000 * 0.6 (60000 - 50000) * 0.7 30000 7000 37000这个引擎的扩展点在于把“结算规则”和“分成计算动作”解耦新增一种规则类型时只需要新增一个实现了RuleCalculator接口的类然后通过简单工厂或策略模式注册进去不需要改动核心结算流程符合开闭原则。实际项目中这一点非常关键因为结算规则几乎每个月都会因为商务谈判结果而变化。4. 管理后台三大高频故障点权限、定时任务、文件资源4.1 Spring Security RBAC新版写法与权限模型管理后台的权限控制几乎是必考项Spring Security是主流方案但新版本和网上大量老教程的写法差异很大这也是很多刚接触的人最头疼的地方。Spring Boot 2.7以及Spring Security 5.x中继承WebSecurityConfigurerAdapter还是主流写法但到了Spring Security 5.7之后这种写法已经被标记为过时Spring Boot 3.0以后直接移除了WebSecurityConfigurerAdapter改用SecurityFilterChain的Bean注册方式。Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/live/public/**).permitAll() .requestMatchers(/api/settlement/**).hasRole(FINANCE) .requestMatchers(/api/audit/**).hasAnyRole(AUDIT, ADMIN) .anyRequest().authenticated() ) .formLogin(form - form.loginProcessingUrl(/api/auth/login).permitAll()) .csrf(csrf - csrf.disable()); return http.build(); }数据模型用的是经典RBAC五张表sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu。菜单表里每一行对应一个前端路由或一个后端接口按钮角色和菜单多对多关联。登录后后端根据用户ID查出角色再查出菜单权限生成一个权限标识集合放进JWT或Session里。接口鉴权除了Security的authorizeHttpRequests按路径控制精确到按钮级别时我用了PreAuthorize注解比如“结算审批”按钮就标注hasPermission(settlement:approve)。这里要提醒一个很容易被忽略的点路径匹配用Spring Security的requestMatchers时注意ANT风格路径的匹配规则和controller里的RequestMapping路径要完全对齐否则会出现前端页面显示正常但接口一直403的情况。我们线上就出过一次Controller的路径是/api/audit/listSecurity里配的是/api/audit/**看起来没问题但后来有个版本把Controller路径改成了/api/audit-record/list忘了同步Security配置审核列表全部403排查过程非常曲折。4.2 Scheduled的正确用法线程池与分布式锁后台的定时任务主要有四类每5分钟检查一次直播推流状态超过阈值未连麦自动标记中断每天凌晨汇总前一日礼物流水生成运营日报每周一执行结算批次生成定期清理超过保留期限的历史回放文件。Spring Boot自带的Scheduled用起来确实方便几行注解就搞定但实际跑起来有几个坑第一默认的定时任务是单线程执行的。Spring Boot的调度默认使用单线程的ScheduledExecutorService如果任务A执行耗时长任务B到了执行时间也只能排队等A跑完。在直播推流状态检查这类需要每5分钟准点执行的任务上一旦前面有日报汇总这种耗时任务状态检查就会被延迟。解决方法是自定义TaskScheduler配置线程池大小。Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(scheduled-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); return scheduler; }第二多实例部署时同一个定时任务会被重复执行。上线初期系统是单节点部署没这个问题后来做高可用扩展成两台实例结果每周一的结算批次生成了两遍财务那边出现了两批一模一样的结算单。这是因为两台JVM各自跑了一份Scheduled任务。解决方案是引入分布式任务锁我用的是ShedLock它是基于数据库或Redis实现的轻量级调度锁注解上配置锁的最大持有时间确保同一时刻只有一个实例真正执行任务。Scheduled(cron 0 0 3 * * MON) SchedulerLock(name settlement_batch_generate, lockAtMostFor PT30M, lockAtLeastFor PT1M) public void generateSettlementBatch() { // 结算批次生成逻辑 }SchedulerLock的lockAtMostFor参数要格外小心它表示锁的最长持有时间如果设置过短任务还没跑完锁就过期了另一台实例会再次触发设置过长又可能因为实例宕机导致锁长期不释放。我一般按照任务正常运行耗时的3到5倍来设置。4.3 文件上传下载与本地资源映射的完整配置直播管理系统涉及的文件类型很多主播身份证照片、合同扫描件、直播间封面图、违规审核截图、回放视频等。小文件直接走应用上传接口没问题回放视频这种动辄几个GB的大文件我放在后面单独说。小文件上传的三处配置要一起改漏一处就出问题。第一处是Spring Boot自身的multipart配置spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB第二处是如果用了Nginx做反向代理nginx的client_max_body_size默认只有1MB不改的话上传超过1MB的文件直接413。第三处是如果应用部署在Docker容器里磁盘挂载路径和容器内路径要映射正确。上传后的文件访问我一开始直接用项目下的static目录存后来发现两个问题一是代码重新部署时新构建的jar包会覆盖掉static目录之前上传的文件全部丢失二是本地开发环境和生产环境的文件路径不一致代码里硬编码没有通用性。后来改成统一把文件存到外部磁盘目录通过自定义资源映射暴露访问路径Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath env.getProperty(file.upload-dir); registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }这里有一个值得注意的细节addResourceLocations的file:路径必须以/结尾否则映射不生效。另外如果是Windows环境路径是D:/upload/Linux环境是/var/data/upload/这些差异建议通过application.yml里的配置项隔离不要写在代码里。大文件上传我换了一种方案前端把文件切片每片5MB-10MB逐片上传后端每片落盘后记录进度全部传完后调用合并接口服务端按顺序合并分片并做MD5校验。这样即使某一片上传失败只需要重传那一片不需要整个文件重来。断点续传和并发分片上传的细节很多如果只是内部管理系统前端用现成的vue-simple-uploader组件后端配合一个简单的合并接口是性价比最高的方案。5. 直播高峰期的数据一致性从一次“礼物扣重复”事故说起5.1 唯一索引与幂等键防住重复请求有一次线上事故让我印象很深某头部主播开播周年庆粉丝集中刷礼物运营反馈有个别用户被重复扣款。查下来发现是网关层的重试机制导致同一个打赏请求被分发到了两台应用实例。这个问题的本质不是并发竞争而是请求的重复提交。分布式事务锁在这种场景下其实不好使因为两笔请求的key完全相同前一个事务还没提交后一个请求基于快照读到的是未扣款的状态然后两个事务都去扣款造成超扣。所以最终靠的还是一张带唯一索引的流水表。打赏流水表设计时就把biz_no字段设为唯一索引这个biz_no由客户端在发起请求时生成一次完整的用户操作对应一个唯一流水号服务端通过捕获DuplicateKeyException来识别重复请求try { giftLogMapper.insert(giftLog); } catch (DuplicateKeyException e) { // 重复请求直接返回成功 return Result.success(重复操作已忽略); }这种处理的巧妙之处在于不管重复请求来自网关重试、前端双击、还是消息队列重复消费只要biz_no相同数据库就能挡住。相比在Redis中setnx一个幂等key并设置失效时间数据库唯一索引没有过期时间的困扰对历史流水来说更可靠。除了打赏场景直播开播回调、关播回调、结算确认这些接口也都做了同样的幂等处理。这是直播类系统的一个基本素养外部回调可能重复用户请求可能重试系统必须天然具备幂等能力。5.2 热点key与最终一致性Redis和MySQL的协作直播间的在线人数、榜单排行、热门礼物列表这些热数据如果每次都查MySQL数据库根本扛不住晚高峰的查询量。我用Redis来做缓存层但缓存也不是一存了之。在线人数这个数据的写入频率极高直播间内每几秒钟就有一个用户上下线事件。我最初的方案是人数变更时直接写Redis的String key然后定时任务每分钟把快照写回MySQL。但在高热度直播间这个key会被高并发更新成为典型的Redis热点key。Redis本身单线程处理命令单个key的写操作会被串行化虽然压力不大但下游其他操作可能因为这个key的操作频繁而产生性能影响。后来我改成按直播间维度拆分成多个子key比如room:viewers:1001:0、room:viewers:1001:1、room:viewers:1001:2每个子key只记录一部分用户的在线状态读取时用mget汇总。这样单个key的写压力降低也避免了大key问题。业务数据的一致性是另一个关键点。用户送礼后MySQL里用户余额已经扣减但主播近7日收益在Redis里是缓存的这里就存在缓存与数据库短暂的窗口期。我的方案是核心账目数据余额、累计收益以MySQL为准Redis里的榜单、七日内收益这类非强一致数据允许秒级延迟先更新数据库然后删除Redis缓存等下次查询时再回填最新值。这也就是常见的Cache-Aside模式实际使用中比先更新缓存再更新数据库的可靠性高。6. 上线部署与长期维护配置、Docker与日志监控6.1 分环境配置和敏感信息加密项目从开发到测试再到生产至少要面对三套环境。我在application.yml基础上拆成application-dev.yml、application-test.yml、application-prod.yml公共配置放在主配置里环境差异配置放在各自的profile文件里。启动时通过--spring.profiles.activeprod指定环境。这里要特别强调敏感信息处理数据库密码、Redis密码、短信平台密钥这类信息直接以明文写在配置文件里是非常危险的做法。哪怕代码仓库是私有的一旦账号泄露整个数据库等于裸奔。我的做法是用Jasypt的Spring Boot Starter对配置项做加密jasypt: encryptor: password: ${JASYPT_PASSWORD} spring: datasource: url: jdbc:mysql://localhost:3306/wangyou_db username: root password: ENC(7QxY3nYbKp9Ls8u5TmVhgt4sDdZxcASdGwBQ)Jasypt的主密码JASYPT_PASSWORD在环境变量中注入这样即使配置文件泄露没有主密码也无法还原数据库密码。启动时可以先用Jasypt提供的工具类生成密文再把密文替换到配置里。还有一个细节Jasypt支持自定义算法默认的算法在某些环境下会提示“No suitable driver”通常是因为没有引入对应的JCE库配置一下jasypt.encryptor.algorithmPBEWithMD5AndDES或升级到最新版本就好了。6.2 Docker化部署与线上日志排查正式环境我用Docker Compose编排了三个容器MySQL、Redis、应用服务。应用服务的Dockerfile用多阶段构建第一阶段用maven镜像编译打包第二阶段用jre镜像运行这样最终镜像体积小部署速度快。FROM maven:3.8.6-openjdk-8 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:8-jre-alpine COPY --frombuilder /app/target/wangyou-live-manager.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar]日志这块我用logback-spring.xml配置了按天滚动的文件日志error级别的日志单独输出保留30天。线上排查问题时最痛苦的是没有请求关联ID一次请求经过网关、应用、MySQL、Redis日志散落在各处只能靠时间戳和用户ID手动串。后来我在Filter里给每个请求生成一个traceId放到MDC里并在日志pattern里输出这个traceIdpattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n/pattern这样整条请求链路的日志可以通过traceId一次性grep出来。虽然没接完整的链路追踪系统但对付管理后台这种规模的服务已经足够了。数据库备份方面每天凌晨用mysqldump全量备份测试环境和生产环境的业务库binlog按天保留。建议每个季度做一次恢复演练我见过太多团队只备份不恢复真出了问题才发现备份文件是坏的这种成本远比恢复演练高得多。这套系统上线后给我最大的一个职业习惯改变是业务规则远比技术架构复杂。直播管理系统从表面看就是CRUD但把分成规则、状态流转、幂等控制这些业务细节真正理清楚开发工作反而比一开始预期顺利。尤其是结算规则第一版上线后第二周就变了因为运营部门对部分主播实施了一个新的保底政策幸好结算引擎做了策略化设计改一条规则配置就完成了没有改一行代码。如果你的系统也是面向传媒或直播行业务必把这类规则变动的扩展性提前考虑进去。
返回列表