用户会先进入到前端的直播间列表页面,但此时他必须先登录才能访问其他服务,登录后,首先获取直播间列表,然后进入到直播间内,在直播间内,用户可以与直播进行互动比如群发消息,送礼物,PK,抢红包雨,或者购买秒杀商品和充值虚拟货币。
用户首先需要点击发送SMS消息,然后在手机获得登录码后,输入登录码,验证码如果成功,就可以登陆,否则拒绝登录。
注意: 由于注册第三方SMS发送平台需要企业认证,我无法引入第三方API,所以当用户点击获取验证码时,就直接返回1000作为验证码即可。
主播可以开启直播间,关闭直播间。
用户可以在列表里找到所有正在进行的直播间,并选择进入一个直播间。
用户和主播可以在直播间发送消息,所有其他在直播间的人都能看到消息。
用户可以点击余额,进入充值弹窗,用户之后就可以选择要充值的虚拟币,进行充值。
注意: 由于注册第三方支付平台需要企业认证,我无法引入第三方API,所以当用户点击购买时,就直接返回支付成功。
用户可以点击送礼,如果余额充足,则全部在直播间内的用户都能看见此礼物的特效。
主播可以开启PK直播,其他用户可以选择PK类型直播间,找到所有正在进行的PK直播间。
用户可以进入PK直播间。
用户可以连线PK直播间,同时只有一个人可以连线。如果连线成功,则PK开始,显示PK进度条。
其余用户可以给PK直播间的两位主播送礼,进度条随着礼物的频率而变动,最终当进度条到达一侧时,会宣布有一位主播胜出。
主播可以向管理员申请在其直播间内开启红包雨功能。
主播可以点击预备红包雨按钮,预热红包雨事件。
之后,后台完成准备工作后,主播即可开启红包雨功能。
直播间其他所有用户可以参与红包雨,在时间范围内,通过点击红包获取虚拟币。
主播可以向管理员申请要推销的货物商品,管理员登录此商品。
在主播开启直播间时,要售出的商品会在直播间商品栏中显示。
直播间内其他用户就可以秒杀这些商品,然后点击准备预下单按钮。
若预下单的商品数量充足,则显示预下单成功,后台会生成一个预下单订单,用户需要在规定时间内,完成订单,否则回滚货物数量。
若预下单的商品数量不足,则返回预下单失败。
登录时,使用13141150122,验证码1000,可以有开启红包雨和直播带货的特权哦。
项目整体使用基于Spring Boot + Spring Cloud Alibaba搭建的分布式微服务架构。各个微服务负责一个单一业务,它们使用Dubbo进行远程调用,或者通过RocketMQ对微服务间的依赖关系进行解耦。各个微服务使用Redis作为数据缓存软件,对读写进行提速,对于需要持久化的数据,会存放在MySQL中。为了保证高并发环境下数据IO速度对请求的影响,我使用了Sharding JDBC对数据库做了分表操作,这样每个表的数据量会变小,索引的层数和IO时间也会有效平摊。(其实分库跟分表一样简单,不过我没钱部署多个库了。)由于直播业务场景通常需要实时通讯,比如群发消息,展示直播间内礼物动画,我使用Netty框架开发了基于websocket协议作为通讯基础的服务器,保证消息的实时发送。
主要是阿里的产品经过双11的高并发业务场景的考量,而且他提供的中间件如 Nacos(服务发现与配置管理)、RocketMQ(消息队列)、Dubbo(RPC 框架)等,性能和可靠性都非常高。
Spring Cloud Alibaba 提供的这些中间件可以做到引入时无缝对接,减少了引入其他技术栈的学习成本和集成复杂度。
我个人经过学习也更熟悉基于Spring Cloud Alibaba的开发。
Nacos默认采用 AP(Availability and Partition tolerance)模型,通过 Raft 协议在多节点间达成一致性。适用于对可用性要求高的场景,性能上比基于 CP 模型的Consul和Zookeeper配置中心要快。而且我个人比较熟悉Nacos下对微服务的开发。
Dubbo 是基于 Netty 的高性能 RPC 框架,具有高吞吐量和低延迟的特点。它在处理大规模、高并发的微服务调用时,表现出色,适合对性能要求高的场景。而像Feign这种基于 HTTP 协的框架,性能表现通常不如 Dubbo 高效。
首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。
在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。
而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS。
可以看出服务发现这一块,两者是有些区别,但不太能分高低。
以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。
而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。
对于主流的 HTTP1.1,虽然它现在叫超文本协议,支持音频视频,但 HTTP 设计 初是用于做网页文本展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 JSON 来 序列化 结构体数据。Header 里的字段信息实在过于冗余,其实如果我们约定好头部的第几位是 Content-Type
,就不需要每次都真的把 Content-Type
这个字段都传过来,类似的情况其实在 Body 的 JSON 结构里也特别明显。
而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。
Gateway系统的入口点,处理来自客户端的所有请求,并将请求路由到适当的后端服务。具体用途有身份验证、监控、缓存、请求管理、静态响应处理等功能。
为了防止服务器的资源被第三方平台(比如不同域名的前端页面)利用,服务器端需要设置谁能进入到该服务器中。
CSRF攻击(跨站请求伪造)是一种挟制用户在当前已登录的Web应用程序上执行非本意操作的攻击方法。攻击者通过伪装请求,让用户在不知情的情况下执行恶意操作。比如,攻击者可以盗用登陆信息,以你的身份模拟发送各种请求对服务器来说这个请求是完全合法的,但是却完成了攻击者所期望的一个操作。
防止方法是在set cookie时,添加Samesite=None, 开启Secure属性, 并且升级HTTP为HTTPS。
我主要是因为以下三点:
- 异步解耦: 上游业务不需要搞懂下游义务的实现,更改下游业务代码不影响上游业务的代码。
- 延迟消息: 可以针对业务搞一些操作,比如秒杀服务下,用户预下单时,就可以在下单前发送延迟消息,如果规定时间内订单没有支付,就可以回滚库存。
- 削峰: 如果某个时间点,请求数巨高,那我可以通过消息的形式,存放这些请求,保证下游服务的并发量不受影响。
每秒TPS为7万,主要是基于三点:
它对磁盘的数据存取是使用MMAP的方式实现的,也就是把用户缓冲区和内核缓冲区间做了共享(没错,减少了一次CPU拷贝时间)。
它底层对每个topic都会有一个存放一个consumequeue文件, consumequeue中的数据是顺序存放的,配合上操作系统的PageCache预读取机制,使得对consumequeue文件的读取几乎接近于内存读取,即使在有消息堆积情况下也不会影响性能。
它也支持RAID10的特性(我总是把10和01搞混。),先去对写入的数据进行镜像,保证可靠性,再去对数据做条带,保证并行数,理论上,做条带的磁盘数量多N倍,对磁盘读写速度就能提升N倍。
Redis 是基于内存的数据存储,读写速度非常快。一般情况下,读操作可以达到亚毫秒级的延迟,写操作也非常迅速。一般适用于读多写少的场景(诶嘿,我很多场景都是这样,比如礼物或虚拟货币的信息,甚至连改写的操作都没有)。
有时候我也想做些事务操作,比如对库存的更新,得先减少库存,看它是否已经小于0,如果是,就必须回滚减少。那么我可以通过LUA脚本来做。毕竟Redis底层Reactor模型内,工作线程只有一个,毕竟Redis作者大佬本人就说,瓶颈在于网络IO,既然工作线程只有一个,那么LUA脚本就可以被判定为事务内有效啦。
Hashmap或者Caffine本地缓存虽好,但是毕竟用的是本机内存,需要考虑服务器端的内存用量哦。其他理由我想了想,就下面这几个:
-
Redis 作为集中式缓存,所有数据都集中存储在 Redis 中,因此在多个应用实例之间可以保持数据的一致性。
-
Redis 支持持久化,可以在服务重启或故障后恢复数据。
-
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在数据库中不存在。这时可以拒绝该请求,而直接返回。
个人比较熟悉它,用了五年数据库了。
我的项目里没有那种更新操作很多的场景。
但如果遇到了这种问题,我做法是,当读Redis时,没读到数据,就访问Mysql的数据,把其更新到Redis。任何数据更新时,都先更新数据库再删除Redis。但考虑到我的数据库是根据主从架构部署的,就是用户读到的数据实际是从库的数据,就会导致用户可能会读到从库还未更新好的数据,并把脏数据写回到Redis中,导致数据不一致性问题。
我的做法是预算主库同步数据到从库的更新时间,然后在RocketMQ发送2秒的延迟消息,2秒后对Redis进行再次删除。这样可以保证数据最终一致。
延时双删: 延迟到主从同步完成,进行二次删除。实时性比较好,因为redis第一时间就被删了,但是一致性弱,需要等双删才能保证最终一致性。
订阅BinLog: 把删除redis的任务交给第三方订阅系统,一旦查看到binlog删除,就提交Redis删除操作。实时性不好,因为redis是等从库删了,Redis才被删,导致这期间读到的是脏数据。但一致性强,一旦收到Bin log更新就删除Redis,保证删除一次就一定删除成功。
使用 ShardingJDBC 的主要原因是它提供了分库分表、读写分离功能,这些功能能够帮助开发者在面对大规模数据和高并发请求时,提高数据库的性能、可扩展性和可用性。
水平分库: 将同一张表的数据按某种规则(如根据用户ID、订单ID等)分散存储到多个数据库实例中。主要用于处理单表数据量巨大、需要高并发读写的场景,通过将同一个表的数据拆分到不同的数据库中,来实现横向扩展和负载均衡。
垂直分库: 将数据库中的不同表或不同业务模块的数据分布到多个数据库中,每个数据库负责不同的业务模块。 更适用于业务模块分离明显的系统,通过将不同的业务模块分布到不同的数据库中,来实现更好的业务隔离、灵活优化以及独立扩展。
水平分表: 将同一张表的数据按某种规则(如ID、时间等)拆分为多个子表,每个子表的结构相同,但存储的数据不同。所有子表在逻辑上仍然表示同一张大表。适合处理单表数据量巨大、查询性能下降的问题,通过将数据分布在多个表中,降低单表压力,提升查询效率。
垂直分表: 将一张表中字段较多的数据,按字段维度进行拆分,分成多张子表。每张子表包含不同的字段集合,共同构成原始表的完整数据。适合处理字段众多、访问频率差异大的表,通过将字段按功能或访问频率拆分,减少表的宽度,优化存储和访问效率。
WebSocket 提供了一种更加高效、实时的通信方式,尤其适用于直播业务场景。
TCP 提供了底层的高效传输能力,但需要开发者自行定义应用层协议。WebSocket 是对 TCP 的高层封装,更加适合 Web 应用的通信需求,但在极端性能要求的场景下,TCP 可能会稍微占优。
Netty 基于 Java NIO,支持异步非阻塞 I/O,这意味着它可以在不阻塞线程的情况下处理大量并发连接。对于即时通信系统而言,这种能力能够有效地提高系统的并发处理能力,减少响应延迟。
在 NIO 中,读写操作是非阻塞的,调用完read,write方法,线程可以继续执行其他任务,不会因为 I/O 操作而阻塞。 而通过 Epoll 实现多路复用,一个线程可以管理多个通道,从而在高并发的网络应用中,大幅减少线程开销。这对于服务器端的应用(如 Web 服务器、聊天室服务器)非常有用。
Reactor模式下,Boss Group 负责监听客户端连接,接收新连接并将其分配给 Worker Group。Worker 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 会暂时保存这部分数据,直到有新的数据到达并能够组成完整消息后,再进行处理。
传统的 JAR 包部署依赖于目标服务器的环境(如操作系统、JDK 版本、依赖库等)。不同环境下可能会出现兼容性问题,导致怨天尤人的 "在我机器上可以运行" 的问题(^_^)。Docker 容器包含了应用所需的所有依赖、配置和环境变量,可以确保在开发、测试和生产环境中一致地运行,避免了环境差异导致的问题。就拿我部署的时候来说,我电脑Windows上生成docker镜像文件,可以直接通过打包+远程复制+简单的docker load的命令导出到Linux服务器上。
直接部署 JAR 包时,应用依赖的库版本、系统配置可能会与服务器上其他应用产生冲突。Docker 容器独立运行,隔离了各个应用的依赖和配置,可以同时运行多个版本的同一软件,而不会互相干扰。
传统的 JAR 部署需要针对不同的操作系统和硬件环境进行特定配置,降低了应用的可移植性。Docker 容器打包了应用和运行环境,可以在任何支持 Docker 的平台上运行,无需针对不同环境进行修改,大大提高了应用的可移植性。
Frontend 发送请求时,会经过API Gateway 层, 如果通过它的断言,就会进入到API层,那里会对微服务的调用做业务上的整合处理,并且应用普遍操作比如消息日志。Controller使用Dubbo对各个微服务进行通信。这些微服务通常会先选择处理在Redis里数据,之后再去异步(比如用线程池/RocketMQ) 同步数据到Mysql中,而不是直接跟数据库交互(因为数据库处理速度比Redis慢10倍,会导致用户交互体验差)。
主要用于做负载均衡,鉴权,处理CORS等问题。
API跟前端打交道,主要整合各个微服务的结果完成业务操作,它会做一些换算或转换业务对象的操作(DTO->VO),实现业务上的需求。好处是可以减少前端的调用次数,对服务的结果做统一处理,坏处是API层可能会调用各个下游服务,并发量就很大。
主要是因为序列化速度比http快,长连接不需要每次都进行三次握手,底层使用Netty实现对不同socket channel的多路复用,适用于数据传输量不大的场景。
解除两个微服务间的强依赖,实现异步操作。比如我们想要快速响应用户请求,把必要但是耗时的调用放到未来的某时去进行调用。或者,对于一时间内并发量较高的场景,可以对耗时微服务进行削峰,防止一时间内并发量过高,导致线程资源不够,服务被拒绝的问题。
-
用户发起码: 首先用户需要输入手机号并请求SMS码,后端获取此次请求前需要通过gateway层鉴权。之后该请求会来到User Controller层,这里专门管理用户模块的Dubbo远程服务调用。最后,Controller层会远程调用SMS消息模块,它会生成4位code,调用第三方SMS消息服务发送消息,然后把phone和code作为键值对记录到Redis中。
我通过实现了一个自定义Gateway Filter,它会根据我在配置文件里写好的白名单,查看谁能绕过鉴权。这里只要是关于登录的请求就都可以绕过鉴权。
-
用户生成码: 用户根据获取到的SMS码,发送登录请求,这次请求同样会绕过Gateway层鉴权,然后Controller层会远程调用SMS消息服务,根据Redis的记录判断SMS是否一致。如果一致,Controller层会远程调用用户模块,获取用户信息。没有获取到用户任何信息的话就会创建新用户id并登记用户信息到Mysql中。这里创建ID的工作是根据我自制的分布式ID生成器来做的。
首先,用户登录模块访问量很大,所以出于性能考虑,把登陆码,登录的用户常用信息,比如名称,手机号,放到Redis中缓存。
其次,用户模块可以进行分布式部署,增加并行性。
然后,我使用线程池对请求第三方平台发送SMS信息做了异步处理,保证SMS发送请求的并发量。
最后,在数据库层面,我使用shardingJDBC做了分表:
水平分表: 一共100个表。这样,访问数据库操作都会进行了Hash取模,每个表的数据量变小增加查询效率。避免IO争抢并减少锁表的几率。
垂直分表: 把用户重要的,常被查询的热数据放到一个表中。提升表的效率,不用访问一些字符量大的字段。
可以使用号段的方式来获取自增 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 优点:
- 去中心化,便于分布式系统使用
- 基本信息可以直接放在token中。 username,nickname,role
- 功能权限较少的话,可以直接放在token中。用bit位表示用户所具有的功能权限
缺点:
- 服务端不能主动让token失效
利用中心化的Redis存储UUID Token 优点:
- 服务端可以主动让token失效
缺点:
- 依赖内存或redis存储。
- 分布式系统的话,需要redis查询/接口调用增加系统复杂性。
-
直播间列表的更新: 对于获取所有直播间列表的服务,我选择利用Redis缓存直播间列表,而且我进一步做了优化,因为直播间开启和关闭操作比较频繁,为了防止Redis短时间内进行多次写操作,降低性能以及数据双写不一致性,我使用了一个scheduler调度器,每一秒把所有数据库里数据导出,之后放到Redis里。
-
用户获取直播间列表: 用户在直播间列表页面或持续下拉时,会请求后端获取所有房间的列表。后端收到请求后,首先Gateway对用户进行鉴权,然后通过API层远程调用直播服务的方法,对管理用户列表的Redis缓存进行LRange分页查询,获取出来该页里所有的room的信息,返回到前端。
-
用户进入直播间: 用户在直播间列表页面点击进入一个直播间时,会请求后端获取所有房间的信息,比如用户的头像,昵称,主播的头像,等等。后端收到请求后,首先Gateway对用户进行鉴权,然后通过API层远程调用直播服务的方法,查询直播间的设置信息,再调用用户模块,查询用户和主播的信息,用于前端显示。
-
主播开启直播间: 用户开启直播间时,会请求后端。后端收到请求后,首先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服务器:
-
在用户进入直播间后,会自动请求后端获取IM服务器的地址。注意为了防止没有鉴权的用户肆意建立IM服务器的websocket握手操作,首先会让用户经历Gateway层的鉴权,然后通过API层远程调用IM服务,生成IM token, 并且通过dubbo的discovery client方法获取IM服务器列表,随机获取一个服务器地址。
-
前端用户对获取到的服务器地址进行连接,并且携带IM token, IM服务器在建立连接前,会调用IM服务,识别令牌对应的用户,如果识别成功,就与用户建立Websocket的升级操作,并且在Redis上记录下来,用户ID和其连接到服务器的IP,以及在socket channel的attribute属性下, 记录user id和其对应的channel context。
为什么需要这两个记录呢?
未来在发送消息到指定用户服务器时,需要先获取用户连接到的IM服务器的地址,在消息到达到服务器后,有又需要找到用户在此服务器上建立的socket channel,以此socket channel完成二次发送。
-
用户建立完连接后,会每五秒发送心跳包,记录用户在线情况,保证Redis记录不过期。
2. 用户群发消息:
用户连接IM服务后,进行直播发送操作,首先会把消息发送给RocketMQ中,进行解耦。转发消息服务会监听并接受这条转发消息,调用直播服务,找到所有直播间内,用户的Id。之后,我引入了一个路由层,专门做消息转发的操作,它会找到每个用户连接的im服务器的ip地址,然后把消息,批量把消息给到各个IM服务器,IM服务器再根据每个用户id获取到对应的socket channel context, 完成消息的推送。
为什么要我要设计有路由层呢?
业务操作完成后,想要对直播间内用户进行通知操作时,如果通过Dubbo直接广播给所有IM服务器该消息,然后IM服务器判断直播间用户列表里id,根据id再进行转发,这样会导致性能问题,最好的办法是再抽象出一个路由层,路由层去获取直播间内所有用户连接对应的IM服务器,然后过滤IM服务器进行转发,可以有效减少服务器广播的性能问题。
3. 消息的推送细节: 为了保证每个消息推送的可靠性,我做了重试机制。
用户IM服务器在通知前端时,先把这此通知记录和重试次数到Redis中,然后再通过发送MQ2秒的延时消息用于确认消息是否发送成功。两秒内,如果用户收到消息,会发送ACK包,这样Redis内的消息记录就会被删除。两秒后,IM服务器获取延时消息时,查看消息是否还存在于Redis里,如果还在,就进行重新发送该消息,重试上限为一次。
前端用户选择购买一定金额的虚拟货币时,会发送给后端请求。之后通过Gateway进行鉴权,来到API层,API层首先通过货币服务获取出该货币金额信息,在数据库中记录订单信息。之后模拟出第三方支付平台支付成功后回调操作,调用用户交易模块的回调服务,增加用户货币的余额,交易的记录,以及更新订单的支付完成状态到数据库中。这些数据库操作都是通过异步线程池完成的(这样用户不需要一直等待数据库IO操作时间)。
前端用户在前端选择一个礼物,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用礼物服务查看礼物信息,如果礼物存在,就把礼物消息发送给RocketMQ,礼物服务会监听该类信息。礼物服务之后会扣除用户余额,增加交易订单。如果扣除失败,它会把失败消息发送给IM服务的路由层,路由层根据送礼用户信息通知送礼失败信息。否则,它会把送礼通知给到Router层,以此对直播间内所有在线用户进行送礼通知转发。之后,所有连接到该房间用户的前端页面会根据IM服务器发送的消息,显示sagv礼物动画。
PK业务主要分为两个流程: 用户连接PK房间,以及用户开始PK。
- 用户连接PK房间: 用户在前端请求连接PK房间,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用直播服务,直播服务会根据房间ID检查房间类型,之后尝试在Redis中通过setnx记录即将在该room下PK的用户id, 如果Redis记录成功则返回连接成功,否则记录连接失败。
- 用户发送礼物给PK用户: 用户选择送礼对象,并且请求发送礼物。首先请求会经过Gateway鉴权,通过后API层会调用礼物服务查看礼物信息,如果该礼物存在,就把礼物消息发送给RocketMQ,礼物服务会监听该类信息。礼物服务之后会扣除用户余额,增加交易订单。如果扣除失败,它会把失败消息发送给IM服务的路由层,路由层根据送礼用户信息通知送礼失败信息。否则,它会先利用LUA脚本,原子性的更新PK进度条。之后把送礼通知和PK进度条信息发送给到Router层,以此对直播间内所有在线用户进行送礼通知转发。之后,所有连接到该房间用户的前端页面会根据IM服务器发送的消息,显示sagv礼物动画。
最初的进度条为50:50, 发送200元的礼物会增加一方20的进度,当某一方进度为100时,礼物服务会通过路由层发送给全体用户某一方PK成功的信息。
红包雨业务主要有三个业务流程: 主播准备红包雨,开始红包雨,以及用户抢红包。
- 主播准备红包雨: 主播在前端请求准备红包雨,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用礼物模块查询主播的红包雨配置,如果主播有权开启红包雨,则后端会根据配置,生成红包雨金额列表,放到Redis中,并把数据库中红包雨记录的状态改为已准备好。否则,返回主播无权开启红包雨事件。
- 主播开启红包雨: 主播在前端请求开启红包雨,并发送请求给后端。首先请求会经过Gateway鉴权,通过后API层会调用红包雨服务开始红包雨事件,红包雨服务会首先检查红包雨是否已经准备完毕。然后它把通知红包雨事件的消息给到IM服务器的路由层,通过路由层转发消息给房间内所有用户。通知完成后,数据库里的红包雨记录的状态会被更改为已完成。注意: 在群发通知前,会先在Redis记录下来此次通知,防止多次通知。
- 用户抢红包: 用户通过IM服务器收到红包雨消息后,浏览器会开始红包雨动画,用户点击红包时,就发送抢红包请求给后端。后端会先通过Gateway对用户进行鉴权,之后API层会调用红包雨管理服务,其会从Redis中获取一个金额,在返回给前端用户前,如果获取成功,会发送RocketMQ异步消息,通知用户余额服务增加用户余额。之后,返回把红包金额给前端用户。
主播带货业务分为购物车子业务和商品秒杀子业务。
这里Live-Gift-Provider模块里包含了主播带货的业务,我没有开新的模块,因为现在已经有10个微服务了,并部署在三台服务器上了,我不想再开新的微服务占内存,主要为了省钱。
购物车子业务:
-
用户获取商品单: 用户请求获取主播推销的商品单,后端会通过Gateway对用户进行鉴权,然后API层会询问商品服务用户带货的商品单,商品服务从Redis中获取名单,如果没有,则会去MySQL中寻找,返回给前端每个商品的基本信息。
-
用户添加商品到购物车: 用户请求添加商品单,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务,根据用户和房间id添加商品到Redis中。
添加的数据结构为Hash。key为用户id和房间id的组合,value为商品的ID和商品购买数量。
-
用户查看购物车信息: 用户请求查看购物车信息,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务根据用户和房间信息查看Redis的购物车信息,返回购物车信息。
商品秒杀子业务:
- 准备商品库存: 在主播开启直播间时,会自动发送准备商品以及整理库存的请求,后端通过Gateway对用户进行鉴权,然后API层会调用直播服务,在主播开播时,发送Rocketmq通知商品库存服务从Mysql获取主播带货商品库存并添加到Redis中。
- 用户预支付购物车商品: 当用户在购物车内选好商品后,就会请求预支付商品,后端通过Gateway对用户进行鉴权,然后API层会调用商品服务在缓存中查看用户购物车,根据购物车里每个商品数量,减少各个商品的库存。如果过程中单个商品扣减失败,则取消对该商品的购买,其余扣减库存成功的商品则会被记录到预订单中,通过商品订单服务存到MySQL中。之后,商品服务会对RocketMQ发送延迟消息,延时时长为预订单时长,如果用户在此期间购买商品则MySQL中订单的状态为完成,商品回滚服务收到延时消息后,不需要对每个商品进行回滚,否则回滚商品数量。
虽然库存量全部存在Redis里,但还是需要与数据库同步当前库存。我为了减少数据库IO操作,每15秒进行数据库更新操作。
- 用户预支付购物车商品: 当用户在购物车内完成预支付商品后,请求支付,后端通过Gateway对用户进行鉴权,然后API层会调用购物车服务,计算购物车内总共商品,之后API层调用用户余额服务按购物车商品价格扣减用户余额,然后调用商品订单服务,更新MySQL中订单的状态为完成。
为了防止用户但时间内多次点击预支付,我在API层引入限流注解@LoadLimiter, 它会根据指定的时间范围和用户访问次数拒绝访问。具体做法为利用Spring的Interceptor检测并增强有此注解的方法,Interceptor指定任何用户的请求到达方法前,都会通过Redis的Lua脚本,根据用户id查看访问次数,如果没有任何记录,则添加新的记录,设置过期时间为指定时间范围。否则查看用户当前访问次数是否超过指定访问次数,如果是,则返回失败,否则对访问次数+1。
-
消息转发/群发到用户对应的服务器里: 全局变量用户和对应的socket channel ctx。 router如何转发信息给用户进行长连接的服务器(Dubbo cluster/invoker)。
-
满足高并发度: netty的reactor模式,mq解耦并给下游服务处理。
-
根据业务类型,通过rocketmq异步调用下游服务,并告知对应用户执行结果。
高并发场景下带货+送礼+红包雨保证不出现超卖现象。
如何满足在大量用户数据的平台下进行实时存储,如何保证性能。