Skip to content

Hang-Zhou-Tim/Live

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

直播平台

服务

服务整体流程

live stream businesss process diagram

用户会先进入到前端的直播间列表页面,但此时他必须先登录才能访问其他服务,登录后,首先获取直播间列表,然后进入到直播间内,在直播间内,用户可以与直播进行互动比如群发消息,送礼物,PK,抢红包雨,或者购买秒杀商品和充值虚拟货币。

用户服务

用户首先需要点击发送SMS消息,然后在手机获得登录码后,输入登录码,验证码如果成功,就可以登陆,否则拒绝登录。

注意: 由于注册第三方SMS发送平台需要企业认证,我无法引入第三方API,所以当用户点击获取验证码时,就直接返回1000作为验证码即可。

直播服务

主播可以开启直播间,关闭直播间。

用户可以在列表里找到所有正在进行的直播间,并选择进入一个直播间。

消息服务

用户和主播可以在直播间发送消息,所有其他在直播间的人都能看到消息。

充值服务

用户可以点击余额,进入充值弹窗,用户之后就可以选择要充值的虚拟币,进行充值。

注意: 由于注册第三方支付平台需要企业认证,我无法引入第三方API,所以当用户点击购买时,就直接返回支付成功。

礼物服务

用户可以点击送礼,如果余额充足,则全部在直播间内的用户都能看见此礼物的特效。

直播PK服务

主播可以开启PK直播,其他用户可以选择PK类型直播间,找到所有正在进行的PK直播间。

用户可以进入PK直播间。

用户可以连线PK直播间,同时只有一个人可以连线。如果连线成功,则PK开始,显示PK进度条。

其余用户可以给PK直播间的两位主播送礼,进度条随着礼物的频率而变动,最终当进度条到达一侧时,会宣布有一位主播胜出。

红包雨服务

主播可以向管理员申请在其直播间内开启红包雨功能。

主播可以点击预备红包雨按钮,预热红包雨事件。

之后,后台完成准备工作后,主播即可开启红包雨功能。

直播间其他所有用户可以参与红包雨,在时间范围内,通过点击红包获取虚拟币。

直播带货服务

主播可以向管理员申请要推销的货物商品,管理员登录此商品。

在主播开启直播间时,要售出的商品会在直播间商品栏中显示。

直播间内其他用户就可以秒杀这些商品,然后点击准备预下单按钮。

若预下单的商品数量充足,则显示预下单成功,后台会生成一个预下单订单,用户需要在规定时间内,完成订单,否则回滚货物数量。

若预下单的商品数量不足,则返回预下单失败。

部署地址

Live Stream Rooms

登录时,使用13141150122,验证码1000,可以有开启红包雨和直播带货的特权哦。

技术栈

项目整体使用基于Spring Boot + Spring Cloud Alibaba搭建的分布式微服务架构。各个微服务负责一个单一业务,它们使用Dubbo进行远程调用,或者通过RocketMQ对微服务间的依赖关系进行解耦。各个微服务使用Redis作为数据缓存软件,对读写进行提速,对于需要持久化的数据,会存放在MySQL中。为了保证高并发环境下数据IO速度对请求的影响,我使用了Sharding JDBC对数据库做了分表操作,这样每个表的数据量会变小,索引的层数和IO时间也会有效平摊。(其实分库跟分表一样简单,不过我没钱部署多个库了。)由于直播业务场景通常需要实时通讯,比如群发消息,展示直播间内礼物动画,我使用Netty框架开发了基于websocket协议作为通讯基础的服务器,保证消息的实时发送。

为什么选择Spring Cloud Alibaba作为项目框架?

主要是阿里的产品经过双11的高并发业务场景的考量,而且他提供的中间件如 Nacos(服务发现与配置管理)、RocketMQ(消息队列)、Dubbo(RPC 框架)等,性能和可靠性都非常高。

Spring Cloud Alibaba 提供的这些中间件可以做到引入时无缝对接,减少了引入其他技术栈的学习成本和集成复杂度。

我个人经过学习也更熟悉基于Spring Cloud Alibaba的开发。

为何要用Nacos来做分布式配置和注册中心?

Nacos默认采用 AP(Availability and Partition tolerance)模型,通过 Raft 协议在多节点间达成一致性。适用于对可用性要求高的场景,性能上比基于 CP 模型的Consul和Zookeeper配置中心要快。而且我个人比较熟悉Nacos下对微服务的开发。

为何要用Dubbo? http vs RPC的区别?

Dubbo 是基于 Netty 的高性能 RPC 框架,具有高吞吐量和低延迟的特点。它在处理大规模、高并发的微服务调用时,表现出色,适合对性能要求高的场景。而像Feign这种基于 HTTP 协的框架,性能表现通常不如 Dubbo 高效。

区别1: 服务发现

首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现

HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口

RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS

可以看出服务发现这一块,两者是有些区别,但不太能分高低。

区别2: 底层连接形式

以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。

RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。

区别3: 传输时序列化协议

对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON序列化 结构体数据。Header 里的字段信息实在过于冗余,其实如果我们约定好头部的第几位是 Content-Type,就不需要每次都真的把 Content-Type 这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。

而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。

Gateway的用处是什么?

Gateway系统的入口点,处理来自客户端的所有请求,并将请求路由到适当的后端服务。具体用途有身份验证、监控、缓存、请求管理、静态响应处理等功能。

什么是CORS?

为了防止服务器的资源被第三方平台(比如不同域名的前端页面)利用,服务器端需要设置谁能进入到该服务器中。

什么是CSRF攻击?怎么防止?

CSRF攻击(跨站请求伪造)是一种挟制用户在当前已登录的Web应用程序上执行非本意操作的攻击方法。攻击者通过伪装请求,让用户在不知情的情况下执行恶意操作。比如,攻击者可以盗用登陆信息,以你的身份模拟发送各种请求对服务器来说这个请求是完全合法的,但是却完成了攻击者所期望的一个操作。

防止方法是在set cookie时,添加Samesite=None, 开启Secure属性, 并且升级HTTP为HTTPS。

为何用RocketMQ?

我主要是因为以下三点:

  1. 异步解耦: 上游业务不需要搞懂下游义务的实现,更改下游业务代码不影响上游业务的代码。
  2. 延迟消息: 可以针对业务搞一些操作,比如秒杀服务下,用户预下单时,就可以在下单前发送延迟消息,如果规定时间内订单没有支付,就可以回滚库存。
  3. 削峰: 如果某个时间点,请求数巨高,那我可以通过消息的形式,存放这些请求,保证下游服务的并发量不受影响。

它的性能如何?

每秒TPS为7万,主要是基于三点:

它对磁盘的数据存取是使用MMAP的方式实现的,也就是把用户缓冲区和内核缓冲区间做了共享(没错,减少了一次CPU拷贝时间)。

它底层对每个topic都会有一个存放一个consumequeue文件, consumequeue中的数据是顺序存放的,配合上操作系统的PageCache预读取机制,使得对consumequeue文件的读取几乎接近于内存读取,即使在有消息堆积情况下也不会影响性能。

它也支持RAID10的特性(我总是把10和01搞混。),先去对写入的数据进行镜像,保证可靠性,再去对数据做条带,保证并行数,理论上,做条带的磁盘数量多N倍,对磁盘读写速度就能提升N倍。

为何用Redis?

Redis 是基于内存的数据存储,读写速度非常快。一般情况下,读操作可以达到亚毫秒级的延迟,写操作也非常迅速。一般适用于读多写少的场景(诶嘿,我很多场景都是这样,比如礼物或虚拟货币的信息,甚至连改写的操作都没有)。

有时候我也想做些事务操作,比如对库存的更新,得先减少库存,看它是否已经小于0,如果是,就必须回滚减少。那么我可以通过LUA脚本来做。毕竟Redis底层Reactor模型内,工作线程只有一个,毕竟Redis作者大佬本人就说,瓶颈在于网络IO,既然工作线程只有一个,那么LUA脚本就可以被判定为事务内有效啦。

它和本地缓存区别是什么?

Hashmap或者Caffine本地缓存虽好,但是毕竟用的是本机内存,需要考虑服务器端的内存用量哦。其他理由我想了想,就下面这几个:

  1. Redis 作为集中式缓存,所有数据都集中存储在 Redis 中,因此在多个应用实例之间可以保持数据的一致性。

  2. Redis 支持持久化,可以在服务重启或故障后恢复数据。

  3. Redis 提供了丰富的缓存失效策略(如 LRU、LFU 等)和灵活的过期时间设置,可以自动清理过期数据。

缓存的过期时间如何设置?怎么防止缓存雪崩的?怎么防止缓存击穿的?怎么防止缓存穿透的?

缓存雪崩是指,大量用户在某一个时间段访问多个Redis中的商品,发现没有该数据,就会把请求打到MySQL上,导致服务崩溃。常见场景比如阿里双11,第二天0点要更新新的一批秒杀货物了,导致将近0点前的请求,都会因为Redis更新打到MySQL上。普遍解决方案是,在每个商品的过期时间上,增加个随机数,避免商品同时失效。

缓存击穿是指,某个热点数据的缓存失效时,瞬间有大量请求同时访问数据库查找该数据,导致数据库压力剧增,甚至可能导致系统崩溃。 数据库压力过大的根源是,因为同一时刻太多的请求访问了数据库。如果我加锁双检,同一时刻只有一个请求才能访问同一数据,然后更新Redis, 其他请求获得锁后再次查找Redis,获得数据,这有何难?

缓存穿透是指,用户总是查询Redis不存在,数据库也不存在的数据。最有可能的场景是黑客做DOS攻击。我对一些高并发接口做了空值缓存,就是说对于任何对不存在于Redis和数据库的数据的请求,我都会根据其查询id,缓存一个空值的对象。下次再来查找此对象,就直接根据id返回空值。不过业界做的更多的是使用布隆过滤器:

布隆过滤器底层使用bit数组存储数据,该数组中的元素默认值是0。布隆过滤器第一次初始化的时候,会把数据库中所有已存在的key,经过一些列的hash算法(比如:三次hash算法)计算,每个key都会计算出多个位置,然后把这些位置上的元素值设置成1。之后,有用户key请求过来的时候,再用相同的hash算法计算位置:

  • 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。这时允许继续往后面操作。
  • 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回。

为何使用MySQL?

个人比较熟悉它,用了五年数据库了。

数据库和缓存之间的一致性问题怎么解决的?

我的项目里没有那种更新操作很多的场景。

但如果遇到了这种问题,我做法是,当读Redis时,没读到数据,就访问Mysql的数据,把其更新到Redis。任何数据更新时,都先更新数据库再删除Redis。但考虑到我的数据库是根据主从架构部署的,就是用户读到的数据实际是从库的数据,就会导致用户可能会读到从库还未更新好的数据,并把脏数据写回到Redis中,导致数据不一致性问题。

我的做法是预算主库同步数据到从库的更新时间,然后在RocketMQ发送2秒的延迟消息,2秒后对Redis进行再次删除。这样可以保证数据最终一致。

延迟双删 vs Binlog删除, 区别在哪?

延时双删: 延迟到主从同步完成,进行二次删除。实时性比较好,因为redis第一时间就被删了,但是一致性弱,需要等双删才能保证最终一致性。

订阅BinLog: 把删除redis的任务交给第三方订阅系统,一旦查看到binlog删除,就提交Redis删除操作。实时性不好,因为redis是等从库删了,Redis才被删,导致这期间读到的是脏数据。但一致性强,一旦收到Bin log更新就删除Redis,保证删除一次就一定删除成功。

为何用ShardingJDBC?

什么是读写分离(主从架构)?

使用 ShardingJDBC 的主要原因是它提供了分库分表、读写分离功能,这些功能能够帮助开发者在面对大规模数据和高并发请求时,提高数据库的性能、可扩展性和可用性。

怎么实现分库分表的呢?

水平分库: 将同一张表的数据按某种规则(如根据用户ID、订单ID等)分散存储到多个数据库实例中。主要用于处理单表数据量巨大、需要高并发读写的场景,通过将同一个表的数据拆分到不同的数据库中,来实现横向扩展和负载均衡。

垂直分库: 将数据库中的不同表或不同业务模块的数据分布到多个数据库中,每个数据库负责不同的业务模块。 更适用于业务模块分离明显的系统,通过将不同的业务模块分布到不同的数据库中,来实现更好的业务隔离、灵活优化以及独立扩展。

水平分表: 将同一张表的数据按某种规则(如ID、时间等)拆分为多个子表,每个子表的结构相同,但存储的数据不同。所有子表在逻辑上仍然表示同一张大表。适合处理单表数据量巨大、查询性能下降的问题,通过将数据分布在多个表中,降低单表压力,提升查询效率。

垂直分表: 将一张表中字段较多的数据,按字段维度进行拆分,分成多张子表。每张子表包含不同的字段集合,共同构成原始表的完整数据。适合处理字段众多、访问频率差异大的表,通过将字段按功能或访问频率拆分,减少表的宽度,优化存储和访问效率。

为何用Websocket?

WebSocket 提供了一种更加高效、实时的通信方式,尤其适用于直播业务场景。

Websocket vs TCP性能如何?

TCP 提供了底层的高效传输能力,但需要开发者自行定义应用层协议。WebSocket 是对 TCP 的高层封装,更加适合 Web 应用的通信需求,但在极端性能要求的场景下,TCP 可能会稍微占优。

为何用Netty做即时通信系统?

Netty 基于 Java NIO,支持异步非阻塞 I/O,这意味着它可以在不阻塞线程的情况下处理大量并发连接。对于即时通信系统而言,这种能力能够有效地提高系统的并发处理能力,减少响应延迟。

NIO为什么快?

在 NIO 中,读写操作是非阻塞的,调用完read,write方法,线程可以继续执行其他任务,不会因为 I/O 操作而阻塞。 而通过 Epoll 实现多路复用,一个线程可以管理多个通道,从而在高并发的网络应用中,大幅减少线程开销。这对于服务器端的应用(如 Web 服务器、聊天室服务器)非常有用。

Reactor模式是什么?

Reactor模式下,Boss Group 负责监听客户端连接,接收新连接并将其分配给 Worker GroupWorker Group 处理具体的 I/O 事件(如读写),执行数据的读写操作。这个分工使得系统能够高效处理高并发连接,利用多工作线程充分发挥多核 CPU 的性能,提升整体吞吐量和响应速度。

零拷贝是什么?

零拷贝减少在数据传输过程中 CPU 的拷贝操作,从而提高性能,特别是在网络通信和文件传输等高 I/O 场景中。

具体有sendfile, mmap。Netty的channel buffer默认用的就是基于mmap。

粘包半包问题?

粘包:多个数据包被粘连在一起,接收方一次性读取了多个包的数据,导致无法区分数据的边界。

半包:发送的数据包过大,接收方一次只接收到了数据包的一部分,导致数据不完整。

解决该类问题:

1. 基于固定长度的解码器

  • FixedLengthFrameDecoder:这个解码器按固定的长度将收到的数据拆分成一个个独立的消息帧。

2. 基于分隔符的解码器

  • DelimiterBasedFrameDecoder:这个解码器通过分隔符来标记消息的结束。

3. 基于长度字段的解码器

  • LengthFieldBasedFrameDecoder:这是 Netty 最通用的解码器,通过在消息头部携带一个长度字段来指示消息的总长度。
new LengthFieldBasedFrameDecoder(
    1024,  // 最大帧长度
    0,     // 长度字段的偏移量
    4,     // 长度字段的字节数
    0,     // 长度调整值
    4      // 去除长度字段的字节数
);

4. LineBasedFrameDecoder

  • 这个解码器通过检测换行符(\n\r\n)来分割消息。

实现机制:

  • 消息拆分:在 Netty 中,通过解码器,将接收到的 ByteBuf 数据拆分成一条条完整的消息。如果一个 ByteBuf 包含了多个消息,解码器会分割它们;如果 ByteBuf 只包含部分消息,解码器会等待接收到更多数据后再拼凑成完整消息。
  • 处理半包:如果当前读取的数据不足以组成一个完整的消息,Netty 会暂时保存这部分数据,直到有新的数据到达并能够组成完整消息后,再进行处理。

为何用Docker来部署?直接打成Jar包不好吗?

传统的 JAR 包部署依赖于目标服务器的环境(如操作系统、JDK 版本、依赖库等)。不同环境下可能会出现兼容性问题,导致怨天尤人的 "在我机器上可以运行" 的问题(^_^)。Docker 容器包含了应用所需的所有依赖、配置和环境变量,可以确保在开发、测试和生产环境中一致地运行,避免了环境差异导致的问题。就拿我部署的时候来说,我电脑Windows上生成docker镜像文件,可以直接通过打包+远程复制+简单的docker load的命令导出到Linux服务器上。

直接部署 JAR 包时,应用依赖的库版本、系统配置可能会与服务器上其他应用产生冲突。Docker 容器独立运行,隔离了各个应用的依赖和配置,可以同时运行多个版本的同一软件,而不会互相干扰。

传统的 JAR 部署需要针对不同的操作系统和硬件环境进行特定配置,降低了应用的可移植性。Docker 容器打包了应用和运行环境,可以在任何支持 Docker 的平台上运行,无需针对不同环境进行修改,大大提高了应用的可移植性。

架构

项目整体架构是怎么样的?

live stream skeleton architecture.drawio

Frontend 发送请求时,会经过API Gateway 层, 如果通过它的断言,就会进入到API层,那里会对微服务的调用做业务上的整合处理,并且应用普遍操作比如消息日志。Controller使用Dubbo对各个微服务进行通信。这些微服务通常会先选择处理在Redis里数据,之后再去异步(比如用线程池/RocketMQ) 同步数据到Mysql中,而不是直接跟数据库交互(因为数据库处理速度比Redis慢10倍,会导致用户交互体验差)。

为什么要有Gateway层?

主要用于做负载均衡,鉴权,处理CORS等问题。

为什么要有API层?

API跟前端打交道,主要整合各个微服务的结果完成业务操作,它会做一些换算或转换业务对象的操作(DTO->VO),实现业务上的需求。好处是可以减少前端的调用次数,对服务的结果做统一处理,坏处是API层可能会调用各个下游服务,并发量就很大。

为何不使用HTTP而是dubbo呢?

主要是因为序列化速度比http快,长连接不需要每次都进行三次握手,底层使用Netty实现对不同socket channel的多路复用,适用于数据传输量不大的场景。

为何用RocketMQ隔离上下游业务?

解除两个微服务间的强依赖,实现异步操作。比如我们想要快速响应用户请求,把必要但是耗时的调用放到未来的某时去进行调用。或者,对于一时间内并发量较高的场景,可以对耗时微服务进行削峰,防止一时间内并发量过高,导致线程资源不够,服务被拒绝的问题。

用户中台服务是怎么实现的?请说说具体流程?

Live-Stream-User-Module-RPC-Architecture.drawio

  1. 用户发起码: 首先用户需要输入手机号并请求SMS码,后端获取此次请求前需要通过gateway层鉴权。之后该请求会来到User Controller层,这里专门管理用户模块的Dubbo远程服务调用。最后,Controller层会远程调用SMS消息模块,它会生成4位code,调用第三方SMS消息服务发送消息,然后把phone和code作为键值对记录到Redis中。

    我通过实现了一个自定义Gateway Filter,它会根据我在配置文件里写好的白名单,查看谁能绕过鉴权。这里只要是关于登录的请求就都可以绕过鉴权。

  2. 用户生成码: 用户根据获取到的SMS码,发送登录请求,这次请求同样会绕过Gateway层鉴权,然后Controller层会远程调用SMS消息服务,根据Redis的记录判断SMS是否一致。如果一致,Controller层会远程调用用户模块,获取用户信息。没有获取到用户任何信息的话就会创建新用户id并登记用户信息到Mysql中。这里创建ID的工作是根据我自制的分布式ID生成器来做的。

系统是怎么支持海量用户的呢?

首先,用户登录模块访问量很大,所以出于性能考虑,把登陆码,登录的用户常用信息,比如名称,手机号,放到Redis中缓存

其次,用户模块可以进行分布式部署,增加并行性。

然后,我使用线程池对请求第三方平台发送SMS信息做了异步处理,保证SMS发送请求的并发量。

最后,在数据库层面,我使用shardingJDBC做了分表:

水平分表: 一共100个表。这样,访问数据库操作都会进行了Hash取模,每个表的数据量变小增加查询效率。避免IO争抢并减少锁表的几率。

垂直分表: 把用户重要的,常被查询的热数据放到一个表中。提升表的效率,不用访问一些字符量大的字段。

自研分布式ID生成器是怎么做的?

可以使用号段的方式来获取自增 ID,号段可以理解成批量获取。比如从数据库获取 ID 时,就可以批量获取多个 ID 并缓存在本地,提升效率。

比如每次从数据库获取 ID 时,就获取一个号段,如 (1,1000],这个范围表示1000个 ID,业务应用在请求提供 ID 时,只需要在本地从1开始自增并返回,而不需要每次都取请求数据库,一直到当前号段达到阈值了,才去数据库重新获取下一号段。

CREATE TABLE `sequence_id_generator` (
  `id` int(10) NOT NULL, '业务id'
  `current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
  `step` int(10) NOT NULL COMMENT '号段的长度',
  `version` int(20) NOT NULL COMMENT '版本号',
  `biz_type`    int(20) NOT NULL COMMENT '业务类型',
   PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为:current_max_id ~ current_max_id+step

version 字段主要用于解决并发问题(乐观锁),biz_type 主要用于表示业务类型。

每次获取时, 先申请并更新当前id区间:

UPDATE `sequence_id_generator` SET `current_max_id` = 0 + 100, `version` = `version` + 1 WHERE version = cur_version AND `biz_type` = 101;
SELECT`current_max_id`,`step`,`version`FROM`sequence_id_generator`where`biz_type`=101;

乐观锁是如何体现的?

通过version字段来进行更新,如果失败,则重复循环三次。

如何计算号段长度值?

我的系统预设的是号段只剩25%阈值就会异步的去申请拿下一个号段。阈值越高,拿的频率越高,阈值越低,拿的越低。

假设拿号段所用的时间为2s(通常select->update总共也就100ms), 而一秒的并发量qps为100。

那么,拿号段的时间内必须保证至少有预留200个id号。

又因为25%阈值内需要200id号,所以号段长度值设为800。

数据库号段模式的优缺点:

  • 优点:ID 有序递增、存储消耗空间小
  • 缺点:存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )

jwt token vs uuid,哪种方案更好?

去中心化的JWT Token 优点:

  1. 去中心化,便于分布式系统使用
  2. 基本信息可以直接放在token中。 username,nickname,role
  3. 功能权限较少的话,可以直接放在token中。用bit位表示用户所具有的功能权限

缺点:

  1. 服务端不能主动让token失效

利用中心化的Redis存储UUID Token 优点:

  1. 服务端可以主动让token失效

缺点:

  1. 依赖内存或redis存储。
  2. 分布式系统的话,需要redis查询/接口调用增加系统复杂性。

直播服务是怎么实现的?请说说具体流程?

Live Stream Room Module.drawio

  1. 直播间列表的更新: 对于获取所有直播间列表的服务,我选择利用Redis缓存直播间列表,而且我进一步做了优化,因为直播间开启和关闭操作比较频繁,为了防止Redis短时间内进行多次写操作,降低性能以及数据双写不一致性,我使用了一个scheduler调度器,每一秒把所有数据库里数据导出,之后放到Redis里。

  2. 用户获取直播间列表: 用户在直播间列表页面或持续下拉时,会请求后端获取所有房间的列表。后端收到请求后,首先Gateway对用户进行鉴权,然后通过API层远程调用直播服务的方法,对管理用户列表的Redis缓存进行LRange分页查询,获取出来该页里所有的room的信息,返回到前端。

  3. 用户进入直播间: 用户在直播间列表页面点击进入一个直播间时,会请求后端获取所有房间的信息,比如用户的头像,昵称,主播的头像,等等。后端收到请求后,首先Gateway对用户进行鉴权,然后通过API层远程调用直播服务的方法,查询直播间的设置信息,再调用用户模块,查询用户和主播的信息,用于前端显示。

  4. 主播开启直播间: 用户开启直播间时,会请求后端。后端收到请求后,首先Gateway对用户进行鉴权,然后通过API层远程调用直播服务的方法,获取该用户的信息,然后通过记录直播间的数据到数据库和Redis中,返回前端直播间id,前端将会自动进入直播间。

消息服务是怎么实现的?请说说具体流程?

它是由两步组成的, 首先用户进入直播间,会连接IM服务器,然后再进行消息推送。

我先简单介绍下IM服务器的用途: 我的IM服务器是基于Netty开发,利用websocket协议进行通讯的,主要用于传递消息。 它也是一个Dubbo服务提供者,用于获取并识别业务信息,根据对应业务利用websocket发送对应消息。

为什么IM服务器需要作为Dubbo服务器提供发送服务呢?

我想要完成业务上的解耦

想象下,如果我想完成直播间转发,我直接把业务操作放到IM服务器上完成:

​ 先调用直播间服务找到直播间内所有用户id,然后再挨个找连接到的对应im服务器ip,发送消息给其他im服务器。这显然会带来很大的性能消耗,而且业务上任何的代码改动,都会导致IM服务器的代码改动。

这不是我想要的。

我的做法是,应该有一个消息转发服务,在用户想要转发消息的时候,把转发消息发送给IM服务器,IM服务器不需要处理具体的业务,它把该消息发送到RocketMQ上,等待下游业务完成业务操作,利用Dubbo服务器告知所有IM服务器进行转发。在这个过程中,IM服务器只需要专注于获取业务消息,以及发送被业务处理完的消息,不需要在乎业务消息本身。

1. 用户连接IM服务器:

live stream connect im core server architecture.drawio

  1. 在用户进入直播间后,会自动请求后端获取IM服务器的地址。注意为了防止没有鉴权的用户肆意建立IM服务器的websocket握手操作,首先会让用户经历Gateway层的鉴权,然后通过API层远程调用IM服务,生成IM token, 并且通过dubbo的discovery client方法获取IM服务器列表,随机获取一个服务器地址。

  2. 前端用户对获取到的服务器地址进行连接,并且携带IM token, IM服务器在建立连接前,会调用IM服务,识别令牌对应的用户,如果识别成功,就与用户建立Websocket的升级操作,并且在Redis上记录下来,用户ID和其连接到服务器的IP,以及在socket channel的attribute属性下, 记录user id和其对应的channel context。

    为什么需要这两个记录呢?

    未来在发送消息到指定用户服务器时,需要先获取用户连接到的IM服务器的地址,在消息到达到服务器后,有又需要找到用户在此服务器上建立的socket channel,以此socket channel完成二次发送。

  3. 用户建立完连接后,会每五秒发送心跳包,记录用户在线情况,保证Redis记录不过期。

2. 用户群发消息:

live stream im core server broadcasting architecture.drawio

​ 用户连接IM服务后,进行直播发送操作,首先会把消息发送给RocketMQ中,进行解耦。转发消息服务会监听并接受这条转发消息,调用直播服务,找到所有直播间内,用户的Id。之后,我引入了一个路由层,专门做消息转发的操作,它会找到每个用户连接的im服务器的ip地址,然后把消息,批量把消息给到各个IM服务器,IM服务器再根据每个用户id获取到对应的socket channel context, 完成消息的推送。

为什么要我要设计有路由层呢?

业务操作完成后,想要对直播间内用户进行通知操作时,如果通过Dubbo直接广播给所有IM服务器该消息,然后IM服务器判断直播间用户列表里id,根据id再进行转发,这样会导致性能问题,最好的办法是再抽象出一个路由层,路由层去获取直播间内所有用户连接对应的IM服务器,然后过滤IM服务器进行转发,可以有效减少服务器广播的性能问题。

3. 消息的推送细节: 为了保证每个消息推送的可靠性,我做了重试机制。

live stream im core server architecture retry mechanism.drawio

​ 用户IM服务器在通知前端时,先把这此通知记录和重试次数到Redis中,然后再通过发送MQ2秒的延时消息用于确认消息是否发送成功。两秒内,如果用户收到消息,会发送ACK包,这样Redis内的消息记录就会被删除。两秒后,IM服务器获取延时消息时,查看消息是否还存在于Redis里,如果还在,就进行重新发送该消息,重试上限为一次。

支付服务是怎么实现的?请说说具体流程?

Live Stream Buy Currency.drawio

前端用户选择购买一定金额的虚拟货币时,会发送给后端请求。之后通过Gateway进行鉴权,来到API层,API层首先通过货币服务获取出该货币金额信息,在数据库中记录订单信息。之后模拟出第三方支付平台支付成功后回调操作,调用用户交易模块的回调服务,增加用户货币的余额,交易的记录,以及更新订单的支付完成状态到数据库中。这些数据库操作都是通过异步线程池完成的(这样用户不需要一直等待数据库IO操作时间)。

礼物服务是怎么实现的?请说说具体流程?

Live Stream Send Gift.drawio

​ 前端用户在前端选择一个礼物,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用礼物服务查看礼物信息,如果礼物存在,就把礼物消息发送给RocketMQ,礼物服务会监听该类信息。礼物服务之后会扣除用户余额,增加交易订单。如果扣除失败,它会把失败消息发送给IM服务的路由层,路由层根据送礼用户信息通知送礼失败信息。否则,它会把送礼通知给到Router层,以此对直播间内所有在线用户进行送礼通知转发。之后,所有连接到该房间用户的前端页面会根据IM服务器发送的消息,显示sagv礼物动画。

直播PK服务是怎么实现的?请说说具体流程?

Live Stream PK Connection, Gift, and Progress Update.drawio

PK业务主要分为两个流程: 用户连接PK房间,以及用户开始PK。

  1. 用户连接PK房间: 用户在前端请求连接PK房间,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用直播服务,直播服务会根据房间ID检查房间类型,之后尝试在Redis中通过setnx记录即将在该room下PK的用户id, 如果Redis记录成功则返回连接成功,否则记录连接失败。
  2. 用户发送礼物给PK用户: 用户选择送礼对象,并且请求发送礼物。首先请求会经过Gateway鉴权,通过后API层会调用礼物服务查看礼物信息,如果该礼物存在,就把礼物消息发送给RocketMQ,礼物服务会监听该类信息。礼物服务之后会扣除用户余额,增加交易订单。如果扣除失败,它会把失败消息发送给IM服务的路由层,路由层根据送礼用户信息通知送礼失败信息。否则,它会先利用LUA脚本,原子性的更新PK进度条。之后把送礼通知和PK进度条信息发送给到Router层,以此对直播间内所有在线用户进行送礼通知转发。之后,所有连接到该房间用户的前端页面会根据IM服务器发送的消息,显示sagv礼物动画。

最初的进度条为50:50, 发送200元的礼物会增加一方20的进度,当某一方进度为100时,礼物服务会通过路由层发送给全体用户某一方PK成功的信息。

红包雨服务是怎么实现的?请说说具体流程?

live stream red packet rain service.drawio

红包雨业务主要有三个业务流程: 主播准备红包雨,开始红包雨,以及用户抢红包。

  1. 主播准备红包雨: 主播在前端请求准备红包雨,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用礼物模块查询主播的红包雨配置,如果主播有权开启红包雨,则后端会根据配置,生成红包雨金额列表,放到Redis中,并把数据库中红包雨记录的状态改为已准备好。否则,返回主播无权开启红包雨事件。
  2. 主播开启红包雨: 主播在前端请求开启红包雨,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用红包雨服务开始红包雨事件,红包雨服务会首先检查红包雨是否已经准备完毕。然后它把通知红包雨事件的消息给到IM服务器的路由层,通过路由层转发消息给房间内所有用户。通知完成后,数据库里的红包雨记录的状态会被更改为已完成。注意: 在群发通知前,会先在Redis记录下来此次通知,防止多次通知。
  3. 用户抢红包: 用户通过IM服务器收到红包雨消息后,浏览器会开始红包雨动画,用户点击红包时,就发送抢红包请求给后端。后端会先通过Gateway对用户进行鉴权,之后API层会调用红包雨管理服务,其会从Redis中获取一个金额,在返回给前端用户前,如果获取成功,会发送RocketMQ异步消息,通知用户余额服务增加用户余额。之后,返回把红包金额给前端用户。

主播带货服务是怎么实现的? 请说说具体流程?

主播带货业务分为购物车子业务和商品秒杀子业务。

这里Live-Gift-Provider模块里包含了主播带货的业务,我没有开新的模块,因为现在已经有10个微服务了,并部署在三台服务器上了,我不想再开新的微服务占内存,主要为了省钱。

live stream sku payment service.drawio

购物车子业务:

  1. 用户获取商品单: 用户请求获取主播推销的商品单,后端会通过Gateway对用户进行鉴权,然后API层会询问商品服务用户带货的商品单,商品服务从Redis中获取名单,如果没有,则会去MySQL中寻找,返回给前端每个商品的基本信息。

  2. 用户添加商品到购物车: 用户请求添加商品单,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务,根据用户和房间id添加商品到Redis中。

    添加的数据结构为Hash。key为用户id和房间id的组合,value为商品的ID和商品购买数量。

  3. 用户查看购物车信息: 用户请求查看购物车信息,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务根据用户和房间信息查看Redis的购物车信息,返回购物车信息。

商品秒杀子业务:

  1. 准备商品库存: 在主播开启直播间时,会自动发送准备商品以及整理库存的请求,后端通过Gateway对用户进行鉴权,然后API层会调用直播服务,在主播开播时,发送Rocketmq通知商品库存服务从Mysql获取主播带货商品库存并添加到Redis中。
  2. 用户预支付购物车商品: 当用户在购物车内选好商品后,就会请求预支付商品,后端通过Gateway对用户进行鉴权,然后API层会调用商品服务在缓存中查看用户购物车,根据购物车里每个商品数量,减少各个商品的库存。如果过程中单个商品扣减失败,则取消对该商品的购买,其余扣减库存成功的商品则会被记录到预订单中,通过商品订单服务存到MySQL中。之后,商品服务会对RocketMQ发送延迟消息,延时时长为预订单时长,如果用户在此期间购买商品则MySQL中订单的状态为完成,商品回滚服务收到延时消息后,不需要对每个商品进行回滚,否则回滚商品数量。

虽然库存量全部存在Redis里,但还是需要与数据库同步当前库存。我为了减少数据库IO操作,每15秒进行数据库更新操作。

  1. 用户预支付购物车商品: 当用户在购物车内完成预支付商品后,请求支付,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务,计算购物车内总共商品,之后API层调用用户余额服务按购物车商品价格扣减用户余额,然后调用商品订单服务,更新MySQL中订单的状态为完成。

限流怎么做的?

为了防止用户但时间内多次点击预支付,我在API层引入限流注解@LoadLimiter, 它会根据指定的时间范围和用户访问次数拒绝访问。具体做法为利用Spring的Interceptor检测并增强有此注解的方法,Interceptor指定任何用户的请求到达方法前,都会通过Redis的Lua脚本,根据用户id查看访问次数,如果没有任何记录,则添加新的记录,设置过期时间为指定时间范围。否则查看用户当前访问次数是否超过指定访问次数,如果是,则返回失败,否则对访问次数+1。

挑战

自研IM的挑战

  1. 消息转发/群发到用户对应的服务器里: 全局变量用户和对应的socket channel ctx。 router如何转发信息给用户进行长连接的服务器(Dubbo cluster/invoker)。

  2. 满足高并发度: netty的reactor模式,mq解耦并给下游服务处理。

  3. 根据业务类型,通过rocketmq异步调用下游服务,并告知对应用户执行结果。

高并发场景

高并发场景下带货+送礼+红包雨保证不出现超卖现象。

海量数据的用户中台

如何满足在大量用户数据的平台下进行实时存储,如何保证性能。