GQ Video

请你先简单介绍一下这个GQ Video项目?

GQ Video 是我之前做的一个在线视频互动社区项目,主要面向 B 端创作者和 C 端观众。简单说,它不是单纯的视频点播网站,它是**“视频内容 + 实时互动 + 数据统计”一体化的平台**,功能上包括:视频上传与转码、弹幕评论、互动计数(点赞、收藏、投币)、消息通知以及搜索和推荐等。

从架构上,我们是基于 Spring Boot + Spring Cloud Alibaba 微服务来搭的,前后端分离,后端拆成了视频服务、互动服务、资源服务、网关、Admin 等多个微服务,注册发现用 Nacos,统一入口用 Gateway,缓存用 Redis,搜索用 Elasticsearch,存储用 Mysql + Minio,异步解耦用 RocketMQ,分布式事务用 Seata

我个人主要负责的是后端整体架构设计和核心业务链路,包括:

  • 微服务的拆分和技术选型,网关路由和注册中心的搭建;
  • Redis 业务组件(登录 Token、验证码、在线人数、播放量统计等);
  • ES 搜索与排序、RocketMQ 播放埋点与转码链路;
  • 以及大文件分片上传、FFmpeg 转码、对象存储上传这些跟视频强相关的能力。

整体来看,这是一个强调高并发读、多服务协同、强互动体验的视频社区项目。

你提到你把后端微服务拆成了视频服务、互动服务、资源服务、网关、Admin 这5个微服务模块,你能告诉你当时是怎么想的吗?为什么这么设计呢?

先说整体思路:我不是一上来就按技术栈拆,而是按**业务边界 + 非功能需求(扩展性、吞吐、存储类型)**来划的。最终形成的是 4 个业务域服务 + 1 个统一入口的网关。


1)视频服务:核心业务域,单独拿出来

  • 视频的投稿、审核、推荐、分 P 管理、基础统计(播放、收藏、投币这些计数)都放在这里,它是整个系统的数据中心之一。
  • 原因很简单:
    • 一方面视频这块业务规则复杂、表多、事务多(涉及用户、硬币、稿件、多表更新),需要一个比较“重”的服务来兜;
    • 另一方面很多其它服务(互动、资源)都要依赖视频基础信息,如果混在一起,后面扩展会很难拆。

2)互动服务:高频操作,解耦出来减压

  • 评论、弹幕、用户消息、点赞/收藏行为记录、在线人数这类“高频轻量操作”全部放在互动服务里。
  • 这么拆有几个考虑:
    • 互动读写非常频繁,如果和视频主表强耦合在一个服务里,很容易拖慢核心视频接口;
    • 互动的扩展模式也不太一样,后期要做限流、风控、黑名单、反垃圾等,会在这个域里持续演进,把它单独拆出来更灵活;
    • 通过内部接口和消息,把互动和视频的统计关联起来,比如删除视频的时候让互动服务清评论、清弹幕等。

3)资源服务:和“文件/存储/转码”强相关,单独一个域

  • 所有跟文件打交道的东西:大文件分片上传、合并、转码、切片、对象存储(MinIO)、本地文件清理等,都由资源服务负责。
  • 这么做的原因是:
    • 这块会涉及 FFmpeg、磁盘 IO、网络带宽这些比较“重”的资源占用,我不想让业务服务直接承担这一块的复杂性;
    • 未来如果要换存储方案、加 CDN、引入新的转码规格,只要改资源服务,不会影响视频、互动那边的业务接口;
    • 对外暴露的也是统一的文件上传/下载/删除接口,其他服务通过内部调用来使用。

4)Admin(后台管理):权限和场景完全不同,单独服务

  • 后台有一套自己的登录、角色权限、审核发布、运营配置等需求,和 C 端的访问模式完全不一样。
  • 我把 Admin 单独拆出来:
    • 可以用独立的账号体系和权限模型,不影响 C 端用户登录;
    • 它调用内部接口去操作视频、互动、资源,不直接暴露核心表结构;
    • 部署上也可以跟前台分开,比如只开放给内网或特定 IP 段。

5)网关:统一入口 + 路由 + 安全

  • 最外层用网关做唯一入口,对外就是一个域名,内部再把请求分发到视频、互动、资源、Admin 对应的服务。
  • 网关这层我还会做几件事:
    • 路由转发、路径统一、参数/头部的一些统一处理;
    • 基础的鉴权和灰度,比如拦截内部接口、做简单的版本路由;
    • 给后面限流、监控、埋点留好切入点。

6)总结成一两句话给面试官

“当时拆服务,我是按业务边界来的:视频是核心业务域,互动是高频轻量域,资源是 IO/转码强相关域,Admin 是后台管理域,再加一个统一网关做入口。
这样拆的好处是:核心数据和高频操作解耦、重 IO 能单独横向扩、后台和前台权限隔离、整体可扩展性和运维都更清晰,后面要做新功能或者扩容时,只动对应的那一块就行了。”

你们的服务是怎么做日志收集的

  • 我当前项目的的做法很简单:利用Logback,既打控制台,也打本地文件。每个服务把运行日志按固定格式写到本机的 logs 目录,按“日期+大小”自动切分,保留一段时间。出问题就直接看这些日志,定位很快。
  • 需要集中查看时,也不用改代码,只要在服务器上加个日志收集器Logstash、,把这些文件统一送到一个搜索平台(比如 ES),再用一个网页(比如 Kibana)来搜、画图、做告警就行。(也就是ELK)
  • 一句话流程:服务写文件 → 收集器把文件送到搜索库 → 网页上一搜就能看到所有服务的日志。

这样做的好处是:实现简单、出问题好排查;要升级成“像 ELK 那样的集中日志系统”也很容易,基本是平台侧加收集与展示,不用动业务代码。

项目中你是否使用过哪些 Java 并发工具类?

面试口述版(30-60 秒)

  • 我在 GQ Video 里主要用的是线程池配合队列做并发处理。比如我们用 ExecutorService 跑后台任务:一类线程消费 Redis 队列的“播放上报”和“转码任务”,把高峰请求削到后台,避免接口阻塞;还有删除视频这种重操作,我放到线程池里异步清理 ES、对象存储和本地文件,显著降低前端等待时间。
  • 传统的 JUC 工具像 ConcurrentHashMap、AtomicInteger、Semaphore、CountDownLatch、CyclicBarrier、BlockingQueue 在当前代码里没有直接大规模使用,但我熟悉它们的场景,业务上也有现成可落地的点。

追问时可展开(逐条举例)

  • 如果要引入 JUC,我会这样用:
    • ConcurrentHashMap:做热门视频的本地缓存与并发加载合并,避免同一时间多线程打 DB。
    • AtomicInteger/LongAdder:本地热点计数(在线人数、播放数滑窗),定时批量回写 Redis/DB。
    • Semaphore:限制“同时转码/同时上传/外部接口调用”并发度,保护 CPU/IO 和下游。
    • CountDownLatch:首页聚合多个区块数据并行拉取,主线程等待全部完成统一返回。
    • CyclicBarrier:多清晰度转码全部完成后再一次性发布“可播放”状态。
    • BlockingQueue:作为 Redis 或 MQ 不可用时的本地降级队列,保证生产-消费不断流。
  • 选择原则:共享可变状态优先用 ConcurrentHashMap/Atomic*;资源并发控制用 Semaphore;一次性并行聚合用 CountDownLatch/CompletableFuture;阶段同步用 CyclicBarrier;生产-消费优先消息中间件,退而求其次用 BlockingQueue+线程池。

一句话总结:现在我用“线程池+队列”把关键链路做了异步化和削峰,真要上更细粒度并发控制,我能把 JUC 工具按场景补进去,既能保证吞吐,也能控制稳定性。

你具体讲讲播放事件统计、转码任务这两个任务,没上MQ你是怎么实现的,用了MQ你又是怎么实现的?

面试口语版回答(MQ 改造播放统计 & 转码任务)


一、没上 MQ 之前

那时候我们还没引入消息队列,用的是「线程池 + Redis 列表」来异步处理任务。

  • 播放事件
    前端每次上报一次“播放事件”,后端不会直接在请求里做一堆写库操作,我这边直接把事件丢进 Redis 列表(LPUSH),后台有一个固定大小的线程池 while(true) 从列表里取(RPOP),
    取到VideoPlayInfoDto 之后, 做四件事:视频播放数 +1、记一条播放历史、按天在 Redis 里做播放统计、同步 ES 计数

  • 转码任务
    用户投稿上传完视频,我也是把转码任务信息塞进 Redis 列表,然后线程池轮询去拿

    取到任务以后从临时目录把分片拷贝到正式目录并合并用 FFmpeg 获取时长、转码、切片上传到 MinIO最后通过 Feign 调用视频服务更新数据库里的转码结果。

  • 这一版的问题其实也比较典型:

    • 所有重试、失败补偿、堆积监控都要自己写;

    • 线程池固定死了,高峰期压上来不太好扩;

    • 进程一挂或者有异常,只能靠日志排查,没有完整的轨迹。


二、上 MQ(RocketMQ)之后:发布订阅 + 条件开关

后面我们把这两条链路都迁到了 RocketMQ 上,并且用一个 mq.enabled 的配置做了灰度开关:

  • 当这个开关为false时:ExecuteQueueTask 这套 Redis + 线程池的方案生效;
  • 当这个开关为true时:MqPublisher(发送者) 和两个 Consumer(消费者)(PlayEventConsumer、TranscodeConsumer)生效,老的方案被 @ConditionalOnProperty 关掉。

接下来我仔细说说MQ的方案:

我设计了两个 Topic:

  • video-play(播放事件链路) 专门处理播放事件

  • video-transcode(转码任务链路) 处理转码任务

  • 播放事件这块
    发布端拿到播放上报后,不再往 Redis 队列里塞对象,直接把事件对象发到 MQ,不再阻塞主线程。
    消费端收到后,还是那4件事:加播放数、写播放历史、Redis 记录按天播放数、同步 ES 计数。

    • 差别在于这些现在都运行在 MQ 的消费线程里,主请求只负责“发消息”,很快就能返回,也就是解耦与异步。
  • 可靠性方面 RocketMQ 自带重试和死信队列,高峰期如果堆积,可以简单通过Consumer消费者组 来水平扩容。同时我还做了幂等处理(比如用 userId+videoId+fileIndex 做唯一键)。

  • 转码任务这块
    用户提交稿件或合并分片后,会发一条转码消息到 MQ。
    消费端收到后 调转码组件,也就是把之前 Redis 队列里做的那一整套拷贝、合并、FFmpeg 转码、MinIO 上传、更新数据库的流程都搬到了 MQ 消费端。

  • 可靠性方面RocketMQ 本身就支持失败重试和死信,如果某个视频持续转码失败,可以直接在控制台看到对应的消息和栈信息,比之前“线程里打个错误日志”好排查很多。


三、上 MQ 之后的变化

  • 对主链路的影响(异步/解耦):请求线程只负责“发消息”,谁来处理它都不管,发完就返回,也就是解耦与异步的一个思想,不卡在转码、统计这种重 IO 操作上

  • 对可靠性的提升RocketMQ 提供了重试、死信和堆积监控,出了问题有地方看,消息不会悄悄丢。

  • 对扩展性的提升现在想加新的统计维度或者新的转码流程,只需要新挂一个 Consumer 订阅同一个 Topic,不用改原来的业务接口。

  • 对运维的友好度(兜底机制):通过 mq.enabled 配置,可以在本地或故障场景下快速切回 Redis 方案,相当于内置了一套“降级兜底”的机制。


一句话总结

之前“线程池 + Redis 列表”能把事儿干完,但可靠性和可观测性都比较差;上了 RocketMQ 之后,我把播放统计和转码都改成了标准的发布订阅模型,用 MQ 自带的重试、死信、监控能力,既把主流程解耦了,也让这两条异步链路更稳定、更好扩展。

你为什么考虑的是RocketMQ这个消息队列而不是RabbitMQ或者Kafka

面试口语版回答示例

先说结论:我们这个项目的消息主要是业务型消息,比如播放事件、转码任务这类,要求的是“可靠投递 + 可重试 + 支持延时/顺序 + 运维别能太重”,在这几个点上,RocketMQ 比 RabbitMQ、Kafka 更贴合。

  • 和 RabbitMQ 比
    RabbitMQ 的路由模型确实很灵活,但整体吞吐会比 RocketMQ、Kafka 小一档,高峰期如果播放上报和转码任务一起压上来,压力会比较大;
    另外像延时消息、死信队列这类能力,RabbitMQ 需要依赖插件或自己组合交换机/队列去做而 RocketMQ 是原生就支持重试和死信队列的,我们消费端只要用 @RocketMQMessageListener 配一下消费组,就能直接享受这些能力,开发、排障都更简单;
    再加上我们本身用了 Spring Cloud Alibaba,一般也是跟 Nacos、Seata、RocketMQ 这一套生态一起用,集成成本更低。

  • 和 Kafka 比
    Kafka 更适合做日志、埋点、流式计算那种“海量吞吐 + 最终一致”的场景,它的消费模型是“至少一次”,做业务幂等要自己花不少功夫;
    而我们这类业务消息,更关心的是单条消息失败怎么重试、超过次数怎么办、要不要进死信队列、需要不要做延时重试,这些在 RocketMQ 里基本是开箱就有的,而在 Kafka 里很多要靠周边组件或者自研补;
    同时 Kafka 集群本身的运维成本也更高一些,对我们这种“中高并发的业务系统”来说,RocketMQ 在特性和复杂度之间更平衡

  • 结合到我们项目落地
    在 RocketMQ 上实现起来比较顺手,如果换成 RabbitMQ/Kafka,要么自己补很多机制,要么运维和开发成本都会上去。

一句话总结在这个以业务消息为主的场景下,RocketMQ 既能扛住播放/转码的量,又自带重试、死信、延时等业务友好的特性,和我们用的 Spring Cloud Alibaba 生态也比较契合,所以我更倾向选 RocketMQ,而不是 RabbitMQ 或 Kafka

我看到你的项目技术栈有Elasticsearch ,你说说你的Elasticsearch 做了哪些事,你为什么选择了Elasticsearch这个搜索引擎?你是怎么保证ES和Mysql的一致性的?

口语化答案

  • 在 GQ Video 里,Elasticsearch主要干三件事:

    1. 做“视频搜索”的索引库,存标题videoName、标签tags、封面、作者等元数据;
    2. 支持关键词高亮、相关性排序和多维排序(在相关性基础上叠加播放量/收藏量等热度字段);
    3. 实时更新计数类字段(播放/弹幕/收藏),用脚本自增并带 upsert,保证字段不存在也不报错。
  • 搜索怎么用的:

    • 查询走 multi_match 命中标题+标签,打开 highlighter,让命中的词用<span class='highlight'>包起来,前端直接渲染即可;
    • 排序先按相关性 _score,再按业务字段(比如播放量)做二次排序;
    • 分页、统计这些都在 ES 里完成,接口只负责把 videoId 批量回表补全必要信息。
  • 和 MySQL 的关系:

    • MySQL是主库,ES是“搜索副本”,我们走“最终一致”:新增/修改/删除异步同步到 ES;计数类通过消息/队列异步自增,失败有重试和兜底重刷。
  • 为什么选 Elasticsearch:

    • 全文检索体验好:倒排索引+相关性评分,模糊匹配、同义词、分词(IK)都很成熟;
    • 性能与扩展性强:大数据量下的查询/分页稳定,分片副本易于横向扩展;
    • 生态完善:高亮、聚合、脚本更新、排序、统计都开箱即用,开发成本低、可维护性好。

一句话:ES在项目里就是“高性能的搜索引擎 + 实时热度排序器”,把复杂的检索、高亮、排序交给它处理,MySQL只专注事务数据,二者异步同步,既快又稳。

面试口语版回答示例

如果你问我“你们的 Elasticsearch 在项目里干嘛、为什么用它、怎么保证跟 MySQL 一致”,我会这样说:


1)ES 在项目里具体做了什么?

在 GQ Video 里,其实我就是把 ES 当成一个专门给视频做搜索和热度排序的引擎,主要干了三件事:

第一是建一个“视频搜索库”

  • 服务启动的时候,我会先初始化好一个视频索引,把视频的基础信息都灌进去,比如 ID、标题、标签、封面、发布时间,还有一些统计类的字段。
  • 标题这块用了中文分词,标签这块按照逗号拆开成多个关键词,这样用户搜标题、搜标签都能比较准确地命中。

第二是做搜索、高亮和多维排序

  • 用户在前台搜的时候,我会同时在标题和标签里做全文检索,再把命中的关键字高亮出来,前端拿到结果直接展示就行。
  • 排序上不是简单按时间排,而是先按相关度排一轮,再叠加播放量、收藏量、发布时间这些业务维度做二次排序,最后再做分页,把结果列表和分页信息一并返回。

为了展示更完整,我还会根据搜索结果里涉及的作者 ID,批量回数据库把作者昵称补上,这样一条结果就能把视频和作者信息都带齐。

第三是把各种统计数字实时同步到 ES
像播放次数、弹幕数、收藏数这些热度指标,在业务侧有变动时就顺手更新到 ES。

这块我封了一层通用的“计数自增”逻辑,加减多少都可以,内部会自动处理好“字段原来不存在”“默认为 0”这类情况,避免更新时报错。
不管是走消息队列,还是走本地线程去消费播放事件,处理完数据库之后都会调一次这个更新方法所以 ES 里的数据基本能跟着业务实时往前跑,搜索结果才能按最新的热度去排。


2)为什么选择 Elasticsearch?

这个项目的搜索需求,用 MySQL 的 LIKE 基本顶不住,所以我一开始就选了 ES,主要几个考虑:

  • 全文检索体验好ES 自带倒排索引 + 相关性打分,再配 IK 分词,能很好地解决标题模糊搜索、标签搜索这些需求,高亮也是开箱即用
  • 多维排序和聚合方便:我们既要按相关性排,又要按播放量、收藏量、发布时间这些业务字段二次排序,在 ES 里就是加几个 sort 的事,比在 MySQL 里各种 order by + limit 要灵活很多;
  • 扩展性和性能:视频量上去之后,单机 MySQL 做复杂搜索容易拖垮主库而 ES 天然支持分片、副本,后面要扩容可以直接水平扩展

一句话:MySQL 更适合作事务存储,ES 更适合复杂搜索和热度排序,两者各司其职。


3)怎么保证 ES 和 MySQL 的一致性?

我们整体思路是:MySQL 是主,ES 是搜索副本,采用“最终一致性 + 事件驱动”的模式,大概分三类情况:

  • 新增/审核通过时写入 ES
    视频在 MySQL 里插入成功后,会通过服务层调用 EsSearchComponent.saveDoc(videoInfo)

    • 如果文档不存在就新建,把 MySQL 里的视频实体 VideoInfo 映射成 VideoInfoEsDto 存进索引;
    • 后续视频信息有变动(比如标题、标签),就走 updateDoc(videoInfo),只更新非空字段。
      这部分是和业务写库同一条调用链上的,属于“写完主库后,马上异步同步 ES”。
  • 删除视频时删除 ES 文档
    VideoInfoServiceImpl.deleteVideo 里,删除 MySQL 主表之后,会直接调用 esSearchComponent.delDoc(videoId) 把对应文档删掉,同时还会异步清理分 P、弹幕、评论、封面和切片目录,确保 ES 里不会再搜出这条视频。

  • 计数量(播放/收藏/弹幕)的一致性
    播放事件这条链路,是通过 RocketMQ 或 Redis 队列来驱动的:

    • 同一条 VideoPlayEvent 消息,消费端先更新 MySQL 里的计数(videoInfoMapper.updateCountInfo),然后再调 esSearchComponent.updateDocCount 更新 ES;
    • 如果 ES 更新失败,会打出 error 日志,方便后面通过运维或者管理端接口(比如 VideoInfoApi.updateDocCount 或全量重建索引)做补偿;
    • 这类统计对用户来说允许极短时间的“最终一致”,所以我们更关注“不丢消息 + 可重放/可修复”,而不是强一致。

整体可以这样总结给面试官:

“我们把 MySQL 当成权威数据源,ES 当成只读的搜索副本。写入流程里会同步更新 ES,计数这类高频字段通过播放事件驱动去异步自增。出问题时有日志和接口可以重刷,保证的是‘最终一致 + 不丢数据’,而不是强事务级的一致性。”

更口语化的一致性回答(弱化类名/字段名)

我们整体思路是:MySQL 是主,ES 是搜索副本,采用“最终一致性 + 事件驱动”的模式,大概分三类情况:

第一种是新增和审核通过

  • 一条视频先正常落到数据库里,写库成功之后,我会在同一条业务链路里,再顺手把这条视频的关键信息同步一份到 ES。
  • 如果 ES 里还没有这条,就新建一条;有则更新。

第二种是删除视频

  • 删视频时也是先删数据库里的那条记录,然后紧接着把搜索引擎里对应的那条文档也删掉。
  • 同时还会在后台异步把这个视频相关的分 P、弹幕、评论、封面图、切片目录这些都清理掉,确保无论是数据库还是搜索结果里,都不会再出现这条已经删掉的视频。

第三种是各种计数:播放、收藏、弹幕这些

  • 这类高频更新的数字,我们是通过“播放事件”来驱动的:同一条事件消息,消费的时候先把数据库里的计数 +1,再把搜索引擎里的热度字段也跟着 +1。
  • 如果更新 ES 的那一步出错了,我这边会把异常打日志,后面可以通过后台接口或者运维脚本去做补偿,比如单条修正或者按天全量重刷。
  • 对用户来说,这种统计允许有一点点延迟,也就是最终一致,所以我们更关注“消息别丢、出了问题能补得回来”。

最后我一般会用一句话总结给面试官:

“我们把 MySQL 当权威数据源,ES 当搜索副本。新增、修改、删除都会顺带把 ES 更新一份,播放这类高频统计通过事件异步去驱动。偶尔有短暂不一致没关系,关键是消息不丢、有日志、有补偿手段,整体做到最终一致。”

Nacos相关

可能被问到的方向 & 回答思路(结合你现在的项目)

下面这些都是“你那句 Nacos 注册发现与配置切换”很容易引出来的问题,你可以按点准备答案。


  • 1)你项目里具体怎么用 Nacos 做服务注册发现的?
    • 所有微服务启动后会自动把自己注册到 Nacos,服务名就是 spring.application.name(比如视频、互动、资源、Admin、网关)。
    • 彼此调用时通过服务名访问而不是写死 IP/端口,比如网关路由 lb://视频服务、内部 Feign 调用 service-id
  • 2)配置中心这块,你在项目里是怎么用 Nacos 的?
    • 把每个服务的环境配置放在 Nacos,数据库、Redis、RocketMQ、MinIO、mq.enabled、日志级别、上传大小等。
    • 命名规则一般是“应用名 + 环境”,比如 ${spring.application.name}-dev.yml,让不同环境通过 namespace 或 profile 隔离。
    • 服务启动时从 Nacos 拉取配置,修改后可以在不重启的情况下动态生效(提一下 @RefreshScope 或 Spring Cloud Alibaba 的自动刷新机制即可)。
  • 3)“配置切换”你实际做过哪些场景?(这是高频追问)
    • MQ 开关mq.enabled 放在 Nacos,线上可以动态控制是走 RocketMQ 还是回退到本地线程 + Redis 队列
    • 存储切换资源服务里通过配置调整是走本地磁盘还是 MinIO,对象存储的 endpoint、密钥等也在 Nacos 里统一管理;
    • 运营参数:动态调整上传大小限制、日志级别、统计任务等,而不需要重新发布服务。
  • 4)为什么选 Nacos,而不是 Eureka / Consul / 自己用 Git 做配置?
    • 和 Spring Cloud Alibaba 生态贴合,注册中心 + 配置中心一体,接入成本低;
    • 自带配置中心实时推送功能更全
  • 5)你们是怎么利用 Nacos 做“高可用/动态扩展”的?
    • 多实例注册:同一个服务可以起多份实例,全部挂到 Nacos 底下网关和其它服务通过服务名 + 负载均衡自动分发请求,实现水平扩展
    • 配置层面:通过 Nacos 调整连接池大小、线程池参数、MQ 开关等,在线做性能调优或限流降级
    • 生产可以做 Nacos 集群 + 本地缓存,单点故障时客户端不会瞬间失效
  • 6)如果 Nacos 挂了或者配置发错了,你们怎么兜底?
    • 客户端会有本地缓存,短时间内 Nacos 不可用不会立刻影响服务
    • 关键配置可以在本地也留一份默认值,启动时如果拉不到远程配置,先用默认值起服务

GateWay相关

可以被问到的几个典型方向(含答题思路)

下面这些都是围绕你那句「Gateway + Redis 分布式鉴权 / 会话共享 / 统一异常」最容易被问到的问题方向,你可以按点提前准备:


  • 1)整体鉴权/会话流程:登录之后,一次请求从网关到后端完整怎么走?
    按顺序讲:
    • 用户登录成功 → 后端生成 token,写到 Redis 里(携带用户信息和过期时间),并下发给前端(Cookie)。
    • 前端之后所有请求都带这个 token,到达网关时:
      • 对管理端 /admin/** 路由走 AdminFilter,优先放行登录相关路径,其它路径会从Cookie 里取管理员 token,没带就直接在网关抛“未登录”的业务异常。
      • 所有请求先过全局 GatewayGlobalRequestFilter,把带内部前缀的 URL 统一拦截掉,防止外部直接打内部接口。
    • 通过网关后,到各个微服务里再用 拦截器从 Header 取 token,到 Redis 里查用户信息做二次校验,实现真正的“分布式鉴权 + 会话共享”。

  • 2)为什么要在网关层做一层鉴权,微服务里又做一层?各自负责什么?
    • 网关层:做的是**“入口级”的粗粒度控制**,比如:非登录接口必须带管理员 token、屏蔽内部接口、简单日志等,一旦不满足条件就直接在网关返回统一错误,减少无效请求打到后端
    • 各微服务内:做的是**“业务级”的细粒度控制**,比如判断用户是否登录、角色/权限校验、是否有操作当前资源的权限等。
    • 这样设计的好处是:公共规则集中在网关,业务规则保留在各服务,既不重复写一堆 if,又不会把所有业务细节都塞到网关里

  • 3)你说的“Redis 实现分布式会话共享”具体指什么?多实例下是怎么共享的?
    • 所有登录态(无论前台用户还是后台管理员)的 token 和用户信息都统一存在 Redis,设置统一过期时间和续期策略
    • 无论请求被网关转发到哪一台实例、哪个微服务,只要拿到 token,去 Redis 查到同一份用户信息即可,所以 session 不再绑 JVM,而是跨实例共享。

  • 4)网关 + Nacos + Redis 之间是怎么配合的?
    • 服务注册发现走 Nacos:Gateway 只认“服务名 + 路由规则”,不关心具体 IP/端口,扩/缩容时实例变化由 Nacos 同步。
    • 鉴权和会话共享走 Redis:无论请求哪里落,鉴权时都是按统一 key 从 Redis 里查用户信息。
    • 配置(比如哪些路径需要 AdminFilter、日志级别、token 头名等)可以放到 Nacos 的 easylive-cloud-gateway-dev.yml,动态生效。

  • 5)统一网关层异常处理怎么做的?和各服务自己的异常处理有什么区别?
    • 网关这边实现了一个全局的 WebExceptionHandler所有经过 Gateway 的异常(包含过滤器里抛的业务异常、路由不到下游的 404/503 等)都会在这里兜底。
    • 在这个 Handler 里,把异常统一转换成一个标准的响应结构:固定字段(code、msg、status),404 / 503 / 业务异常 / 未知异常分别映射成约定的错误码和提示。
    • 各微服务内部也有自己的全局异常处理,但那是用户已经“穿过网关”之后的事;网关这一层更多是帮前端屏蔽“下游服务不可用 / 路由错误 / 未登录”等入口级问题

  • 6)你是怎么隔离“内部接口”和对外接口的?为什么要在网关做?
    • 所有给内部调用的接口(比如服务间 RPC)统一加了一个内部前缀,正常情况下只应该在内网被调用。
    • GatewayGlobalRequestFilter 会先判断 URL 是否包含这个前缀,如果是就直接在网关返回 404 或业务错误码,外部用户永远访问不到内部 API
    • 这么做的好处是:即便某个服务的内部控制写漏了,只要走网关的流量,都会被这层“白名单/黑名单式”的路径规则挡住

  • 7)如果 Redis 挂了或者延迟很高,你们的鉴权/会话会有什么影响,有没有兜底方案?
    • 短时间 Redis 不可用时,系统返回“系统繁忙,请稍后重试”

Seata相关

一、面试官可能问的方向(带思路)

  • 1)你为什么要引入 Seata,单用 @Transactional 不够吗?

    • 场景:评论发布时既要写评论库,又要更新视频服务里的评论数,这两个库在不同微服务里,传统 @Transactional 只能管住当前服务的本地库
    • 之所以需要引出 Seata:是为了让“评论写入 + 远程更新计数 + 其它跨服务操作”要么都成功,要么都回滚,所以用全局事务把多服务的本地事务包在一起
  • 2)你是在哪些具体业务里用了 @GlobalTransactional

    • 评论发布:插入评论 → 远程调用视频服务增加评论数,如果任何一步抛异常,全局回滚;

    • 级联删除:删除视频时,视频服务删主表和资源,互动服务删评论/弹幕等,中途有一步失败就全部撤销;

    • 复杂互动计数:比如同时更新用户互动统计、视频统计等多张表且跨服务时。

    • 跨服务、多库、必须保证强一致”的特点。

  • 3)给我讲讲 @GlobalTransactional 的工作原理,大概怎么运作?

    • 先说概念:
      • 标注在入口方法上时,Seata 会为这次请求创建一个全局事务 ID
      • 参与的每个微服务在自己的本地事务里都会带上这个 ID,向 Seata 注册成为“分支事务”;
    • 然后说流程:
      • 所有本地事务都成功提交后,Seata 的全局事务协调器才会把全局事务标记为成功;
      • 只要有一个分支失败,协调器就会让已经成功的分支全部回滚,保证整体一致。
  • 4)使用 Seata 之后,你是怎么写代码的?入口在哪?

    • 入口:一般放在“业务链路的最上游服务”的方法上,比如评论发布的接口实现。
    • 写法:把原本的 @Transactional(rollbackFor = Exception.class) 换成 @GlobalTransactional(rollbackFor = Exception.class),里面照常调用本地 Mapper、远程 Feign 接口。
    • 要点:
      • 入口方法本身不要再开新的本地事务嵌套;
      • 远程调用的下游服务自己的本地操作依然用 @Transactional 保证单服务内的一致性。
  • 5)你是怎么区分“该不该用 Seata”的?有没有滥用的风险?

    • 适合用的:
      • 跨多个服务/多个库,且必须强一致的场景,比如资金、积分、评论数这类对用户强感知的核心数据;
  • 6)Seata 带来的问题/成本你有考虑过吗?

    • 性能开销:所有参与服务都要跟协调器交互,全局事务越多,对 TPS 的影响越大。
    • 网络依赖:协调器或网络抖动时,容易放大成一条链路上的超时或回滚。
    • 运维复杂度:要多维护一个 Seata 集群和对应的事务表。
      • 对高频简单操作(例如纯统计)我会优先用 MQ + 本地事务,而不是动不动就上全局事务。
  • 7)Seata 出现部分失败或网络问题时,你们怎么排查?

    • 排查:
      • 查 Seata 的全局事务表,看哪些全局事务在重试 / 回滚中
      • 结合业务日志定位是哪个分支服务报错

代码生成器

可以被问到的典型方向(结合你这套代码生成器)


  • 1)你这个代码生成器是解决什么痛点?为什么要自己做?
    • 老实说:项目里每加一张表,都要手写 PO、Query、Mapper、XML、Service、Controller、分页、统一返回这些重复代码,很费时间也容易写错。
    • 生成器做的事:从数据库表结构(字段、类型、注释、索引)出发,一键生成从实体、查询对象、Mapper/XML 到 Service、Controller 的“标准骨架”,包括分页、统一响应、全局异常等。
    • 自己做而不是直接用别人的(比如 MyBatis-Plus 生成器),主要是因为该项目有一套固定规范:BaseMapper、BaseParam/SimplePage、ResponseVO、PageSize 等通用抽象,现成工具难以完全贴合,所以干脆做了一个“贴合本项目规范”的生成器

  • 2)它具体都能生成哪些东西?从哪一步到哪一步是自动化的?

    • 根据 user_info 这种表,自动生成:
      • PO 实体(UserInfo)——字段、注释、时间格式注解等;
      • Query(UserInfoQuery)——精确/模糊字段、时间区间、分页和排序;
      • Mapper 接口 + XML——通用 CRUD、批量、按业务键查询、UPSERT、动态条件;
      • Service/ServiceImpl——分页编排、批量空集短路、危险操作前置校验;
      • Controller——分页查询、新增/批量/按业务键增删改查,并统一用 ResponseVO 返回。

    表一建好,点一下生成,就有一条从 Controller 到 DAO 的“最小闭环”,只剩下少量业务逻辑需要手写


  • 3)你生成器的核心设计思路是什么?(比如模板、抽象等)
    • 大体是“元数据 + 模板”:
      • 从数据库拿到表字段、注释、主键、唯一索引这些元数据
      • 把通用能力抽象成 Base 类/通用枚举BaseMapperBaseParam/SimplePageResponseVO/PaginationResultVOPageSizeBusinessException 等;
      • 再用模板按照统一规范拼出各层代码。
    • 这样新表出来之后,只需要在模板层改一次,就能让所有新生成的代码同步更新风格和规范。

  • 4)它相比“手写 + 复制粘贴”或者 MyBatis-Plus 这类生成器,有什么亮点?
    • 相比通用生成器,你可以说:它不仅是“生成代码”,更是“生成符合团队规范、带安全检查的骨架”

  • 5)生成后的代码如何二次开发?会不会不方便维护?
    • 生成的只是“基础骨架”,比如 UserInfoController 里的基础 CRUD、分页、Query 条件等;
    • 真正的业务逻辑(比如注册校验、密码加密、积分发放)都是在 ServiceImpl 或额外的方法里手写,生成器不会反复覆盖;

  • 7)有没有遇到什么坑?
    • 顺带说一句:生成器本身也要当成一个“产品”来维护,版本升级、模板兼容都是后面慢慢打磨出来的。

你做项目遇到了什么难点?你是怎么解决的?

是的,我做这个项目时遇到过几个问题

1.验证码被覆盖的一个问题

一开始我们做验证码很简单:

  • 后端生成一张算术验证码图片,结果直接丢到 Redis 里,用的是固定的 key,比如就叫 checkCode。

  • 单人用的时候没问题,但一到高并发,就会出现这样的情况:A 用户刚拿到验证码,B 用户一刷新页面,Redis 里的值就被 B 覆盖了。

  • 结果就是 A 输入的其实是旧验证码,后端拿到的是真实值已经变成 B 的那一份,导致校验老是不通过。

2. 我的改造思路

我后来把这个问题拆成两块:“验证码内容”和“验证码引用”分开存

大概是这样做的:

  1. 用户点开注册/登录时,我生成一张算术验证码图片,把图片转成 base64 返回给前端;
  2. 同时我在后端随机生成一个 唯一的 key可以理解成验证码 ID,用 UUID 就行
  3. Redis 里不再用一个公共 key而是用:前缀 + 验证码ID 去保存真实验证码值,并设置 10 分钟过期
  4. 前端这时候拿到的是两样东西:
    • 一张图片(base64)用来展示给用户看
    • 一个验证码 ID(checkCodeKey),用户提交表单时把这个 ID 和自己输入的结果一起带回来;
  5. 校验的时候,后端根据验证码 ID 从 Redis 里取真实值,对一下是否一致;
  6. 不管对没对上,校验完立刻删掉这个 key,做到“一次一用,不能复用”。

这样一改,每个请求都有自己的验证码 key,互相之间完全隔离,自然也就不存在“别人刷新把我的验证码顶掉”的问题了。


2:大文件分片上传 + 转码耗时长,接口容易超时

问题:
视频是分片上传的,用户网络环境又不稳定。最开始如果在上传接口里直接合并分片、调用 FFmpeg 转码、生成切片,会出现:

  • 接口执行时间特别长,浏览器经常超时;
  • 一旦中途失败,很难知道哪一步出了问题,也没法优雅地重试。

我的做法:

  • 上传阶段

    • 每个上传任务在后端生成一个 uploadId,把分片个数、文件名、临时路径等信息存在 Redis 里;
    • 分片只负责往临时目录写文件块,并更新 Redis 里的进度,整个过程都非常轻量。
  • 转码阶段异步化

    • 当所有分片传完后,不再在请求里做转码,而是往队列里塞一个“转码任务”(Redis 队列 or RocketMQ);
    • 资源服务里有专门的消费任务/消费者,后台慢慢干:
      • 从临时目录拷贝 + 合并分片;
      • 调 FFmpeg 生成完整视频、切 HLS 切片、算时长;
      • 成功之后再回调视频服务更新数据库状态。

效果:

  • 用户请求只负责“上传分片 + 提交任务”,响应时间从几十秒降到几百毫秒;
  • 转码失败时日志清晰可查,而且是后台任务,可以单独重试或重新投递,不影响用户主流程。

案例 3:评论发布和评论数不一致(跨服务事务)

问题:
评论发布这一条链路会同时操作评论服务的数据库视频服务的评论计数

  • 评论表插入一条记录;
  • 远程调用视频服务,把该视频的评论数 +1。

如果中间任何一步失败,就会产生“列表里有评论,但计数没加上”或者反过来的情况。

我的做法:

  • 对这类强一致的链路,引入了 Seata,把入口方法从 @Transactional 改成 @GlobalTransactional
  • 评论服务这边插入评论作为一个本地分支事务;
  • 远程调用视频服务更新评论数,那边自己的本地事务也加入同一个全局事务;
  • 只要有任何一步抛异常,Seata 会通知所有已经成功的分支一起回滚,保证“要么都成功,要么都失败”。

效果:

  • 解决了“评论内容和计数对不上”的问题;
  • 也让这类跨服务的操作写法尽量贴近单体事务,对业务开发来说心智负担小很多。

4.:播放统计从“线程池 + Redis 列表”迁到 RocketMQ

问题:
最开始播放统计是用“Redis 队列 + 后台线程 while(true) 消费”的方式做的:

  • 高峰期时,线程池容易打满,队列一堆积就很难监控;
  • 失败重试、死信都要自己写,服务一挂掉,现场很难排查。

我的做法:

  • 把播放埋点、转码任务这些都迁到 RocketMQ:
    • 入口服务只负责发消息,把播放事件/转码任务丢到对应 Topic;
    • 消费端专门处理:更新播放数、写播放历史、更新 ES 热度字段、触发转码等。
  • 支持按配置开关:mq.enabled=true 走 MQ,关掉时自动退回到 Redis 队列方案,方便灰度和回滚。

效果:

  • 主接口明显变快,只做“发消息”;
  • 消息的堆积量、重试、死信都能在 MQ 控制台看到,运维和排障成本比之前的线程池高循环好太多。

简单讲讲这个项目的注册功能?

口语化讲注册流程(面试版)

这个项目的注册流程其实不复杂,可以分三步说:校验、建用户、落库。

  • 第一步是前端+验证码这块
    用户在前端填邮箱、昵称、密码,先看一张算术验证码图片。
    我这边会把验证码对应的一个 checkCodeKey 一起给前端,等用户点“注册”的时候,把用户输入的验证码 + 这个 key 一起带回来。

  • 第二步是后端校验
    后端拿到请求后,先用 checkCodeKey 去 Redis 查真实验证码,校验完不管对不对都直接删掉这个 key,保证验证码一次一用。
    验证码通过之后,再去库里查一遍邮箱和昵称,有重复就抛业务异常,前端直接提示“邮箱已注册/昵称已占用”。

  • 第三步是创建用户并落库
    校验都通过后,我会生成一个 10 位的用户 ID,把密码做一次 MD5(只存加密后的值),同时给一套默认属性:状态、性别、主题、注册时间,还有初始硬币(当前和累计各 10)。
    最后调用 Mapper 插入数据库,成功后只返回一个“注册成功”的结果,前端会跳回登录页而不是直接给用户登录。

  • 顺带会提一下做的防护点

    • 并发重复注册这块,一方面代码查重,另一方面数据库上有邮箱、昵称唯一索引兜底;
    • 验证码是每次生成一个独立 key 放 Redis,设置过期时间和一次性校验,不会出现别的用户刷新把你的验证码覆盖掉;
    • 安全上会对同 IP / 同邮箱做注册频控,密码永远只存加密后的摘要,不存明文。

简单讲讲这个项目的登录以及自动登录、退出你是怎么实现的?

口语化讲登录 / 自动登录 / 退出(面试版)

1)登录是怎么做的?

  • 前端这边就是把邮箱、密码、验证码一起丢给后端,同时带上验证码的 checkCodeKey
  • 后端先用这个 key 去 Redis 里把真实验证码拿出来,对完之后不管成不成功都直接删掉,验证码只用一次。
  • 接着校验账号:
    • 查一下这个邮箱有没有用户;
    • 把输入的密码做 MD5 后跟数据库里的摘要比一比;
    • 再看一下账号状态是不是正常;
    • 顺便更新一下最后登录时间、登录 IP。
  • 都通过以后,我会生成一个 UUID 当作 token,把「用户信息 + 过期时间(比如 7 天)」塞到 Redis 里,key 大概就是 token:{token} 这种。
  • 最后把这个 token 写到 Cookie 里返回给前端(有效期也是 7 天),如果之前已经有旧的 token,就顺手把 Redis 里旧的登录态删掉,避免一个账号在同端出现多个 token。

2)自动登录是怎么做的?

  • 自动登录其实就是拿着 token 来要用户信息
    • 前端每次刷新页面或打开站点,会把 Cookie 里的 token 带上,调用一个“自动登录”接口;
    • 后端拿 token 去 Redis 里找,如果能找到用户信息,说明登录态还有效,直接返回给前端;
    • 如果发现这个 token 马上要过期了(比如剩不到一天),我会顺便帮它续一下命:把 Redis 里的过期时间往后推一推,再重新写一遍 Cookie,这样用户几乎感受不到过期。

3)退出登录怎么做?

  • 退出就很简单了:
    • 从 Cookie 里把 token 读出来;
    • 把 Redis 中这个 token 对应的登录态删掉;
    • 同时把浏览器里的 Cookie 设置成立刻过期,相当于把本地的“钥匙”也扔掉。

4)一两句话总结

整个登录体系可以总结成一句话:

用 Redis 存登录态,用 Cookie 带 token —— 登录的时候写入,自动登录的时候续期,退出的时候一起清理;验证码同样用 Redis + 独立 key 做成一次一用,避免多人并发时互相覆盖。

简单讲讲这个项目的分类功能是怎么实现的,一级分类和二级分类是如何实现的?

我这块是用一张分类表 + 父子关系,把一级、二级都放在一起管理的。

数据结构

  • 表里有 category_id、p_category_id、编码、名称、图标、背景图、排序号这些字段。
  • 约定很简单:父级 ID 为 0 的就是一级分类,父级 ID 指向某个一级分类的,就是它下面的二级分类。

新增/修改

删除

  • 支持“级联删除”:传入一个 categoryId,同时把该分类以及它的直接子分类一并删掉(SQL 用 category_id = ? OR p_category_id = ?)。

排序

  • 提供一个批量排序接口,前端把拖拽后的 ID 顺序传上来(如 "3,5,4"),后端按顺序重写同父级下的 sort 值。

查询与树形

  • 查询时按 sort asc 取出线性列表;如果入参标记 convert2Tree=true,后端把线性数据按 p_category_id 组装成树(给每个节点挂 children),前端直接渲染成“一级-二级”的层级结构。

缓存

  • 分类变更后会刷新 Redis 缓存,把“排好序的树形列表”整体存到 category:list:;客户端读取时优先走缓存,未命中再回源并回填缓存。

一句话:用 p_category_id 建父子关系(0 表示一级),唯一校验+排序号维护新增/修改,删除支持带子类,查询可直接返回树形并缓存到 Redis,前端据此渲染一级/二级分类。

口语版:怎么实现分类、一级二级怎么区分

我这块是用一张分类表 + 父子关系,把一级、二级都放在一起管理的。

先说数据结构
表里有 category_idp_category_id、编码、名称、图标、背景图、排序号这些字段。
约定很简单:父级 ID 为 0 的就是一级分类,父级 ID 指向某个一级分类的,就是它下面的二级分类

新增 / 修改怎么做

  • 新增或修改前,会先按“分类id”做一次唯一校验,避免同一个id被重复使用。
  • 新增的时候,会在同一个父级下面查一遍当前最大的排序号,然后把新分类的排序号设成 max + 1,这样前端拖拽完再调一次“批量排序”接口就能重排;修改的话就按主键更新就行。

删除怎么做
支持简单的级联:传进来一个分类 ID,我这边会把这个分类以及它名下的一层子分类一起删掉,SQL 上就是 category_id = ? OR p_category_id = ? 这种写法。

查询和树形结构
查的时候先按 sort 升序把所有分类拉成一个扁平列表。
如果入参要求返回“树形”,后端就会先挑出所有一级分类,再把对应的二级挂到它的 children 下面,前端拿到就是一个现成的“一级 → 二级”的树。

缓存这块
分类其实不怎么变,所以我会把排好序的树形结果直接丢到 Redis 里,比如一个统一的 key。
读的时候优先走缓存,没命中再查数据库并回填;有新增、修改、删除的时候顺手把这份缓存刷新掉。

你要我一句话概括的话,就是:

p_category_id 做父子关系(0 表示一级),配合排序号和级联删除,把新增/修改/排序都集中在一张表里处理,查询时组装成树形并缓存到 Redis,前端就可以直接渲染一级、二级分类了。

简单讲讲这个项目的文件上传功能是怎么实现的

  • 预上传建档

    • 前端先调“预上传”,把文件名和分片数发过来。
    • 服务端生成一个 uploadId,顺带在磁盘建临时目录,在 Redis 里记录会话状态(分片总数、当前进度、临时路径、TTL),返回 uploadId。
  • 分片上传

    • 前端按顺序带着 uploadId + chunkIndex + 分片文件逐片上传。
    • 后端校验:会话是否存在、是否跳片、大小是否超限;然后把分片落到“临时目录/chunkIndex”这个文件里。
    • 每传一片就把进度与累计字节数写回 Redis,并续期,支持断点续传与重传当前片(覆盖写)。
  • 合并与后续处理

    • 所有分片到齐后(前端或服务端触发“合并”),把临时分片顺序拼成完整视频文件。
    • 触发后续流程:转码/切片/上传对象存储(或本地),回写数据库状态与文件路径;这一步我们支持走 MQ(RocketMQ)异步处理,峰值更稳,也可开关回退到线程池+Redis 队列。
  • 安全与体验

    • 上传限额、单文件大小、分片并发数都可配置;路径做了合法性校验。
    • 返回的是相对路径,前端用统一的“读文件”接口访问;封面图可选生成 200px 缩略图加速展示。

一句话:用“预上传建档(Redis 会话)+ 顺序分片上传(断点续传)+ 合并 + 异步转码/入库”的流程,既稳又能抗大文件与高并发。

口语版:这个项目的文件上传是怎么做的

我这块是按“预上传建档 → 分片上传 → 合并 → 异步转码”这一整条流水线来设计的。

  • 第一步:预上传建档
    用户刚选完文件,先调一个“预上传”接口,把文件名、分片数这些基本信息丢给后端。
    后端做三件事:
    1)生成一个唯一的 uploadId
    2)在磁盘上建一个对应的临时目录;
    3)在 Redis 里存一份“上传会话”,里面有分片总数、已上传到第几片、临时路径、过期时间等。
    然后把 uploadId 返回给前端,后续所有分片都围绕这个 uploadId 走。
  • 第二步:分片上传(支持断点续传)
    前端拿着 uploadId,按分片一片一片地传,每片都会带上当前的 chunkIndex 和分片内容。
    后端收到之后,会先检查:
    • 这个 uploadId 对应的会话在不在(有没有过期);
    • 分片顺序对不对,有没有乱序/跳片;
    • 尺寸有没有超限。
      校验通过,就把这一片写临时目录里。
      每传完一片,会把当前进度和累计大小更新回 Redis,并顺手续一下期,这样用户断网或者页面刷新了,再重新连上还能从上次的进度往下传
  • 第三步:合并 + 后续处理
    当所有分片都传完之后,后端会按顺序把这些小文件拼成一个完整的视频文件。
    接着就进入:
    • 做一次转码 + 切片(FFmpeg);
    • 上传到对象存储minio中(或者保存在本地);
    • 更新数据库里这条视频的状态和真URL路径。
    • 这几步我没放在上传接口里堵着,而是丢到后台异步去跑:
    • 正常是走 RocketMQ,把“转码任务”发到 MQ 里,由资源服务的消费者慢慢处理;
    • 如果 MQ 关掉了,还可以退回到“线程池 + Redis 队列”的模式,相当于内置了一套兜底降级方案。

一句话总结

我是用“预上传建档(Redis 维护会话)+ 分片断点续传 + 服务端合并 + 异步转码入库”这一条链路来做文件上传的,既能扛大文件、高并发,又不会把接口堵死。

面试官问:简单讲讲这个项目的审核功能是怎么实现的

口语版:这个项目的审核是怎么做的

  • 1)审核入口
    审核接口,传三个参数:视频 ID、审核结果(通过还是驳回)、驳回原因。

  • 2)关键校验?

    • 第一步我会校验状态,只允许把“待审核”的视频改成“通过”或者“驳回”,其他状态一律不让动。
    • 第二步用了一点类似乐观锁的思路:更新的时候带上条件 where video_id=? and status=待审核
      这样两个人同时点审核,只有第一个人能更新成功,第二个人发现状态已经不是待审核了,就认为审核已经被别人处理过了,避免多次覆盖。
  • 3)如果审核通过,要做哪些事?

    • 先把投稿表里的最新数据“拷贝”到线上正式表
    • 再把这次投稿对应的文件清单也同步过去:先删掉线上旧的文件记录,再把新的整批插进去;
    • 清理这次审核过程中积累的“待删除文件队列”避免临时或历史文件遗留在磁盘/对象存储里
    • 最后再做一些后置动作,比如写入 ES 索引、刷新缓存、给作者发一条站内消息告诉他“视频已审核通过”。
  • 4)如果审核驳回呢?

    • 只在投稿表里把状态改成“驳回”,顺便把驳回原因写进去
    • 不会动线上正式表,也不会把文件清单同步上线,相当于这次投稿没通过审核。
  • 5)事务和幂等怎么保证?

    • 整个审核方法是包在一个事务里的,过程中只要有一步失败,前面做的拷贝/同步都会一起回滚,保证状态一致;
    • 再加上前面说的“where status=待审核”这个条件,其实就实现了只审核一次的效果,多点几次也是同一条记录,不会出现反复覆盖。

一句话总结一下

审核就是一次“带乐观锁的状态流转”:只允许从待审核流转到通过或驳回;通过就把投稿数据和文件清单同步到线上并做后续处理,驳回只更新投稿状态和原因,全流程用事务和条件更新来保证只被审核一次、数据是干净一致的。

简单讲讲这个项目的弹幕功能是怎么实现的

口语版:这个项目的弹幕是怎么做的

我这块是用一张表 + 简单接口,把“发弹幕、拉弹幕、统计和开关”串在一起的。

弹幕怎么存?
后端有一张专门的弹幕表,大概就存这些信息:

  • 哪个视频、哪一 P(videoId + fileId);

  • 谁发的(userId);

  • 内容、颜色、模式(滚动 / 顶部 / 底部);

  • 最重要的一个字段:time(在第几秒出现);

  • 以及发送时间。

  • 视频表里还维护了一个“弹幕数量”的计数字段,用来做统计和排序。

用户发弹幕的流程

  • 前端在看视频的时候,带着参数去调发送弹幕接口。

  • 后端先从登录态里拿当前用户 ID,再做几层校验:

  • 文字长度有没有超限制(比如不超过 200 字);

  • 视频是否存在、这个视频有没有被 UP 主关闭弹幕。

  • 校验都通过之后,就往弹幕表里插一条记录,同时把对应视频的弹幕数加 1。

弹幕是怎么展示出来的?
播放器在加载某一 P 的时候,会调一个“拉弹幕”的接口。
后端就按这个分 P 去查,通常按弹幕的自增 ID 或时间顺序排好,一次性返回给前端。
每条记录里都有一个“出现在第几秒”的时间戳,前端就可以根据当前播放进度,把对应时间点的弹幕渲染出来;
如果这个视频被设置成“关闭弹幕”,那接口这边直接返回空列表就行了。

开关和计数这块怎么管?

  • 视频信息里有一个互动配置字段,里面会带“是否关闭弹幕”的开关;
  • 弹幕数量这块就是每成功发一条,就把视频表里的弹幕计数 +1,后面做排序或统计的时候可以直接用。

补一句整体设计
整体就是:登录校验在切面里统一做,弹幕表只负责存内容和时间点,视频表负责开关和计数,前端根据时间戳来决定什么时候把弹幕画到屏幕上,整体实现比较轻量,也方便后续扩展弹幕样式。

简单讲讲这个项目的点赞收藏投币功能是怎么实现的

口语版:点赞 / 收藏 / 投币是怎么做的

1)先说表这块

  • 我有一张专门的用户行为表,记录“谁对哪条视频做了什么”:视频 ID、评论 ID(有些行为跟评论有关)、行为类型(点赞 / 收藏 / 投币)、用户 ID 等。
  • 这些字段上做了一个联合唯一索引,相当于“同一个人对同一个目标,同一种行为只能有一条记录”,天然防重复
  • 视频表里再单独维护几个计数字段,比如点赞数、收藏数、投币数,用来展示和排序

2)点赞 / 收藏:开关型的逻辑

  • 点赞、收藏都是“开关”思路:
    • 先查一下这条行为在表里存不存在;
    • 已经存在,就说明用户点过赞 / 收藏了,这次再点就是取消:
    • 不存在,就是第一次点:插入一条记录,把计数 +1。
  • 整个操作包在事务里,防止数据不一致的情况

3)投币:累加型的逻辑

  • 是一种“扣我加你,再记一笔账”的模式:
    • 先判断一下自己不能给自己投币;
    • 再判断同一个用户对同一视频是不是已经投过,以及当前余额>=投币数
    • 校验通过后,从当前用户的硬币余额里扣除,同时给 UP 主的硬币加上;
    • 行为表里插入一条“投币”的记录;
    • 视频表里的投币数再 +N。
  • 整个流程同样放在一个事务里,要么全部成功,要么都不生效。

4)展示和站内消息这块

  • 当我们在查视频详情的时候,我会顺带把“当前用户对这个视频做过哪些行为”一起返回,比如是否已经点赞、是否已收藏、有没有投币过。
    前端拿到后就可以把按钮置蓝、显示“已投币”等状态。
  • 在点赞/评论/收藏审核等接口方法上加了一个 @RecordUserMessage 注解,切面会在业务执行成功后,自动帮你写一条站内消息,比如“你的视频被点赞了 / 被收藏了”,业务代码不用到处手动发通知。

5)一句话总结

点赞 / 收藏用“存在删、无则增”的开关模式,投币用“扣我加你 + 行为落表”的累加模式;
联合唯一索引保证一人一行为的幂等,视频计数统一通过一个更新方法维护,再配合 AOP 自动发站内消息,整个过程放在事务里保证一致性。

简单讲讲这个项目的评论功能是怎么实现的?父级子级评论怎么实现的?

口语版:评论功能 & 父子评论怎么做的

1)评论是怎么存的?

  • 后端就一张 video_comment 表,关键信息有:
    • comment_id:这条评论自己的 ID;
    • p_comment_id:父评论 ID,0 表示主评论,大于 0 表示某个主评论下面的子评论
    • video_iduser_idreply_user_id(如果是回复别人,会记被回复的人是谁);
    • content、点赞数、踩数、是否置顶、发送时间等。
  • 视频表里还维护一个总评论数,用来做展示和排序。

2)发评论的流程(顺便说父子关系怎么定)

  • 前端调发评论的接口,带 视频id、内容,如果是“回复某条评论”,会多带一个replyCommentId

  • 后端校验:视频是否存在、UP 有没有关闭评论区。

  • 如果没带 replyCommentId,那就是主评论

    入库后把视频的评论数 +1

  • 如果带了 replyCommentId,那就是回复

    • 先查被回复那条评论;
    • 如果对方本身是主评,就把当前这条的 p_comment_id 设成对方的 comment_id
    • 如果对方是子评,就沿用它的 p_comment_id,也就是所有回复都挂在同一个“楼层”下;
    • 同时把 reply_user_id 记成被回复的那个人,后面做“@谁谁谁”的展示和站内消息会用到。

一句话总结

我是用 p_comment_id 来做父子关系:0 表示主评,>0 表示挂在某个主评下面的子评;
发评论时看有没有 replyCommentId 来决定 p_comment_idreply_user_id,查评论时先主评再一次性把子评论挂在 children 上返回,配合点赞/踩、删除、置顶等能力,就能比较完整地支撑“楼中楼”评论体系。

在线人数怎么实现的?

  • 我用“心跳+过期监听”的思路做的,核心都在 Redis。
  • 客户端每隔几秒上报一次心跳:调用后端 /video/reportVideoPlayOnline?fileId&deviceId
  • 服务器做两件事:
    • 首次心跳:给这个用户写一个在线标记 key,比如 video:play:online:user:{fileId}:{deviceId},TTL≈8s;同时把在线总数 key video:play:online:count:{fileId} 自增1(并给它TTL≈10s)。返回当前在线数。
    • 非首次心跳:只给这两个 key 续期,不再自增,避免一个人被计多次。
  • 下线/断网的扣减:
    • 开启 Redis key 过期事件(Ex),监听到“用户在线标记”过期时,根据 key 解析出 fileId,对 video:play:online:count:{fileId} 做自减1。
  • 这样就实现了“首上+1、持续续期、不重复计数、心跳断了自动-1”,进程宕机也能靠过期回收,计数基本准。

口语版:在线人数是怎么做的

这个在线人数我用的是“心跳 + Redis 过期”的思路,核心都是靠 Redis 在撑。

  • 第一步:客户端定时发心跳
    播放器在播放某一 P 时,每隔几秒会带着发一次心跳,相当于告诉服务端“我还在看”。

  • 第二步:服务端怎么记在线用户
    收到心跳后,我分两种情况处理:

    • 第一次心跳(用户刚进入视频)
      • 在 Redis 里写一个用户在线标记,TTL 设成 8 秒;
      • 同时把这个视频的在线总数 自增 1,并给它设一个稍长一点的 TTL(比如 10 秒);
      • 然后把当前在线数返回给前端。
    • 后续心跳 的就不会再加 ,这样一个用户不会被算成多个人
  • 第三步:人走了怎么减 1?
    我在 Redis 这边打开了 key 过期事件监听:

    • 当某个Redis里的 在线标记 过期时,说明这个设备一段时间没心跳了,就认为下线了;
    • 同时把这个视频的在线总数 做一次自减 1。

这样一套下来,其实就是:

首次上报时在线人数 +1,之后只续命不重复计数;心跳断了靠 Redis 过期自动 -1。
就算服务重启,Redis 里 key 也会自己过期回收,在线人数整体是比较准确、而且实现也比较轻量的。

24小时热门显示是怎么实现的?

  • 我这块没做复杂的离线计算,走的是“在线统计 + SQL 过滤排序”。
  • 每次播放都会把 video_info.play_count +1,同时更新 last_play_time=now()(在 updateCountInfo 里做的)。
  • 接口 /video/loadHotVideoList 查询最近 24 小时:SQL 条件 last_play_time >= now() - interval 24 hour,再按 play_count desc 排序,分页返回,并联表补上作者头像/昵称做展示。
  • 这样能实时反映最近一天的热度;如果要换窗口(如 48 小时/7 天)只改查询参数即可。配合 last_play_timeplay_count 索引,查询很快。

口语版:24 小时热门是怎么做的

这块我没上特别重的离线统计,就是用在线累加 + 一条 SQL 过滤排序解决的。

先把数据埋好点
每次视频被播放,我都会做两件事:
1)把这条视频的播放数 +1;
2)顺便更新一下它的 最近一次播放时间

查 24 小时热门的时候
热门接口 /video/loadHotVideoList 的查询条件其实很直接:

  • 先筛一遍:last_play_time >= now() - interval 24 hour只看最近 24 小时有过播放的;
  • 再按 播放量正序排序,取前几页数据;
  • 联表把作者头像、昵称这些信息一起补上,方便前端直接展示。

好处和扩展性

  • 这样做的好处是:实时性很好,只要有人一播放,立刻会影响到 24 小时热门;
  • 要换时间窗口(比如 48 小时、7 天)也很简单,只改一下 where 条件即可。
  • 我们还可以在再给 最近一次播放时间 和 播放量 建个合适的索引,这个查询在数据量上来之后也能跑得很快。

你的项目中哪里用到了AOP?

在 GQ Video 里,我主要把 AOP 用在两块典型的横切逻辑上:登录校验用户行为转站内消息

  • 第一块是登录校验的全局拦截
    我在公共模块里定义了一个注解 @GlobalInterceptor,然后写了一个切面 GlobalOperationAspect,用 @Before 去拦截。
    控制器方法只要加上 @GlobalInterceptor(checkLogin = true),真正执行业务代码之前,切面会先从 HttpServletRequest 里拿到 token,再去 Redis 查 TokenUserInfoDto,如果 token 为空或者查不到,就直接抛 BusinessException,前端拿到统一的错误码。
    这样好处是比较明显的:接口里不用一遍遍去写“判断有没有登录”的 if,后面要调整登录策略(比如 token 时长、踢下线逻辑)只改切面这一处,就能全局生效。

  • 第二块是把用户行为自动转换成站内消息
    这类逻辑我用的是另外一个注解 @RecordUserMessage,对应的切面是 UserMessageOperationAspect,用 @Around 去包一层。
    像用户收藏、评论、回复、审核通过/驳回这种操作,在方法上打个 @RecordUserMessage(...) 就行了:
    切面里会先执行原方法,拿到 ResponseVO,只有在正常返回、不抛异常的情况下,才去解析方法参数(比如 videoIdactionTypereplyCommentIdcontent),结合注解里配的默认 MessageTypeEnum 和实际的 actionType,最后调用 UserMessageService.saveUserMessage 落一条站内消息。当前操作者是谁,是从请求头 token → Redis 里拿 TokenUserInfoDto 算出来的。
    这样业务代码只关心“这次是评论还是收藏”,不需要自己去管“给谁发一条什么类型的站内消息”,通知逻辑集中在切面里,既统一又方便扩展。

    https://www.legendkiller.xyz/wp-content/uploads/2025/08/image-1110.png

  • 为什么选 AOP 而不是简单用过滤器/拦截器?
    登录这块我们在网关也有粗粒度的校验,但像“哪些方法必须登录、成功后要不要额外记一条消息”这种,是方法级的业务规则,需要读到注解和参数,AOP 在这层会更合适;
    类似“操作审计、性能埋点”这种以后要加的能力,也可以沿用这一套模式,再加注解和切面就行,对现有业务代码侵入很小。

历史播放记录怎么实现的?

口语版:历史播放记录是怎么做的

我这块历史记录是绑在“播放事件”上的,用户一看视频,后台就自动记一笔,不需要前端额外调接口。

  • 1)数据怎么存?
    有一张播放历史表,大概就这几个字段:用户 ID、视频 ID、分 P 索引(fileIndex)、最后观看时间
    同一个用户对同一个视频,只保留一条记录,每次再看就更新这条的分 P 和时间。

  • 2)什么时候写历史?

    前端上报播放后,先发一条 video-play 消息到 RocketMQ,消费端 PlayEventConsumer 收到后:

    • 给视频播放数 +1;
    • 如果带了 userId,就调 saveHistory 记历史。

    saveHistory 做的事很简单:

    • 组一条 VideoPlayHistory(用户、视频、分 P、当前时间);
    • 调用 Mapper 的 insertOrUpdate,有就更新,没有就插入,这样同一用户多次播放同一视频不会刷出一堆重复记录
  • 3)历史列表怎么查?

    还有一个单独的查历史接口 loadHistory

    • 先拿当前登录的用户 ID;
    • 按 最后更新时间倒序 排序,再带上页码做分页;
    • Mapper 查询的时候会 联表视频表 把封面和标题一起查出来,这样前端拿到的每条历史记录里,就能看到“什么时候看的哪一 P”,也有“封面和名字”
  • 4)清空和删除单条怎么做?

    • 按当前用户 ID cleanHistory 会把这人的所有历史记录删掉;
    • 指定一个视频 ID,delHistory 只删当前用户在这个视频上的那条历史。

一句话总结

历史播放是依托“播放事件”自动写入的:每次播放就 insertOrUpdate 一条“用户 + 视频 + 分 P + 最后观看时间”的记录,查的时候按时间倒序分页、顺带带上封面和标题,并提供清空和按视频删除的接口,前端就能很方便地做“最近观看”列表。