项目介绍
这个电影院项目不同于常见的基于会员限制用户观看范围的在线影院项目,主要就是按需购买片源来进行观看,用户就不会因高额的会员费而劝退。
项目的主要实现就是:微服务的五大主键,数据库使用mysql,redis,中间件使用rabbitmq,xxl-job,工具使用skywalk和jenkins。
项目的主要功能就是 片源的观看及购买及一些辅助用户的功能包括:签到积分,热门电源排行,优惠劵模块。简单的来说,用户登录并按规则下单->用户观看片源的整个流程。
用户登录模块
登录模块主要就是基于springSecurity实现的,在登录后,会提供JWT生成对应的token。
我给您说一下JWT的实现流程吧,其主要分为三个部分,header,body(存放userId),签名。使用header + body基于base64生成签名。最终请求都会去携带该token。(token中不要存放非常重重要的信息,因为安全性不高)
在我们的微服务项目中,请求会先进入网关,网关会配置一个全局拦截器,去解析token,并将userId继续让请求携带到微服务中。
服务中涉及到远程调用,为了保证服务间能够正常的获取userId,我们是使用threadLocal进行存储的。同时给定义一个公共的全局拦截器,在每次进入服务的时候,先判断threadLocal中是否有数据,如果没有就会到请求中获取,最终放行。
片源观看模块
1.视频的播放方式和上传
使用腾讯的VOD来实现的,其有非常全面的视频任务流,我们只需要对任务流进行配置即可使用,包括视频的加密,视频封面图的生成,视频雪碧图的生成,视频的审核。
我们只需要实现视频的上传即可,我们无需视频文件上传到服务端,服务只需要生成对应的授权签名,使用VOD提供的JS的SDK并携带授权起名,提供文件上传工具即可实现在客户端的视频上传。
VOD也提供了视频播放器,无需提供视频的播放地址,而是提供授权签名和文件id给VOD,就可以实现视频的播放,在这个视频的传输过程都是加密的,可以做到防止视频被盗。
实现方式就前端的操作,引入SDK,生成签名,视频文件上传(返回file类型的数据)。
2.提交观看位置(合并写请求,减少99%的DB操作)
这里的业务需求,就是需要实现用户续看的功能,保证用户切换其他设备的时候也可以续看,并且误差要在30秒之内。
旧方案:在刚开始的时候,就是想直接去操作数据库,前端每隔15秒就提交一次观看位置的请求,因为视频的播放为用户主要的操作,所以就会存在高并发的场景,大量的访问DB最终会压垮数据库。(修改观看记录表)
新方案:合并写请求,我们会发现在这么多DB操作只有最后一次的观看位置有用,使用redis的hash结构,大key存储片源表的id(其关联了片源id和userId),小key存储集的id(一个片源如果是电影的话就1节,反之存在多节),value就存储观看位置mount。在每次请求进来后会修改hash结构中的数据,并且使用mq的死信队列发送一个20秒延迟消息(面试官有问的话,就稍微解释一下),消息中主要就是mount,监听器任务就是判断消息中的mount和redis中的mount是否相同,如果相同就说明20内没有继续观看,此时修改数据库中观看位置。反之则不进行操作。
片源话题及回复模块
在数据库的设计上主要就是两张表,分别是 话题表和回复表。
话题表 主要字段就是标题,内容,回复数,而在回复表中主要即使目标回复id和点赞数。
在展示话题的回复时,通过递归的获取对应的回复集合。
在里面比较难的就是点赞的实现,我给您介绍一下点赞回复/话题模块吧。
点赞回复/话题模块
旧方案:基于点赞记录表做点赞的记录并且每次进行点赞or取消点赞的时候也小修改回复的总点赞数。缺点很明显,恶意的大量取消和点赞的请求出现的时候就会出现大量的DB操作,最终压垮数据库。
新方案:点赞记录不再存储到数据库中,使用redis的set结构进行存储,key存储业务id,value存储点赞的userId集合,因为set的结构其会校验重复点赞,使用zset结构存储各个回复的点赞量。key存储目标业务的类型,member存储业务id,scope存储点赞量。使用xxl-job每隔20秒就会调用popmin从zset中取三十条数据批量的修改点赞总数。(通过业务的类型进行不同表的修改操作)
但是并不是说旧方案是不好的,点赞操作本来就是一个低频的操作,使用数据库进行直接操作其实也是没有问题的,而且速度上反而更快,所以只能说有利有弊。
问:在在set结构中为什么不使用userId作为key呢,如果使用userId作为key的话,用户量庞大,会不会出现BigKey的情况呢?
可以使用userId作为key,但是呢,这里使用业务id作为key的话在获取业务对应的点赞量时比较方便,通过scard方法就可获取中点赞数,因为在set结构中头部会存储value的总数量,其时间复杂度为O(1)。在我们的项目中没有大V,所以我们默认总点赞数不会超过1000,所以就没有考虑BigKey的情况。
问:在点赞量的存储上为什么不使用redis的list结构呢?
如果是使用list结构的话,那在每次修改点赞状态的时候都会就新的点赞数存储到list中,但是在list中只有最后一次点赞量是有用的,增加了不少的DB操作,所以使用redis的zset结构配合xxl-job进行批量修改操作。
签到积分模块
通过签到获取积分,积分可用于抵扣金额,我们规定1积分抵0.01元。连续的签到可以格外获得积分。我们这里规定连续签到7天后开始每天2积分,14后每天3积分,在月初连续签到会重置。
在设计的时候,先是想到mysql进行存储签到的记录,但是呢,随着用户体量的增大和时间的关系,就会浪费大量的存储空间。所以想到使用redis的BitMap(位图,String类型)结构进行存储,每个月对应一个bitMap,因为其使用的是二进制进行存储的,在签到的判断上与1做异或判断就可以获取今天的签到情况。(主要就是通过redisTemplate实现的)
在连续签到遍历的做 ^1(与1异或) 并 >>>1(数值右移一位)操作,最终就可以获取类型签到的天数,最终添加对应数额的积分。
积分主要就是一张表,用于存储积分的情况,在后续使用积分抵扣就会操作该数据库。
问:你的项目中使用过redis的什么结构呢?
1.set结构:在物流项目中,主要就解决幂等性的问题,在消费运单中防止运单被多次消费。
在电影院项目中用来存储每个业务对应点赞用户的集合。
2.list结构:在物流项目中,作为用来存储运单的队列。key就是当前网点和下一网点的拼接,value就是对应起始和下一网点的运单集合。
3.hash结构:主要就是redisson的实现方式,大key存储的就是要上锁的业务id,小key就是线程id,value就是锁重入的次数。在电影院项目中,用来存储用户在集里的观看位置,解决合并写请求的问题。
4.bitmap结构:在电影院项目中,使用bitmap解决签到的问题,降低存储内存。
问:你将点赞的用户信息和签到的信息全部存储到redis中,不怕数据丢失吗?
点赞的数据和签到的数据不是非常重要的数据,所以没有存储到mysql中。
为了防止redis出问题,主要的解决方法就要:
1.手动设置redis的持久化类型就比如:AOF,RDB。
2.为redis搭建主从模式(一般是一种三从),包括使用哨兵模式,防止redis宕机。
3.如果对数据的完整性和正确性要求比较高的话,还是需要使用mysql进行存储。就比如那种银行项目等等,所以还是要根据具体的业务场景进行选择。
热门片源榜模块
热门排行榜主要就是给用户更多的观影发现,其分为:单月热门榜和历史热门榜。
主要就是使用redis的set结构记录每个片源的观看次数,并且使用使用zset结构记录每种片源的总观看数,类似点赞模块的设计模型。不同的就是在每月出会重置set和zset。
zset提供了ZREVRANGE,ZRANK可以很方便的获取排序后的数据,也就是对应的排名。key就是当月,member就是片源id,scope就是热度(我们会对该排序进行分页)
在历史热门榜的设计上,我们不考虑将所有的历史排行榜存到一张表中,这样后续在出现上效率会比较低。我们使用水平分表,这里没有使用jdbc-share插件,在分表上,每个月都单独的做一张排行表,在后续的查询上也不会有跨表的行为,使用年月作为表的后缀。
主要是通过三个定时任务在每个月月初的时候执行,分别是创建热门榜表,插入数据,清除zset缓存。
在创建表上主要就是通过mysql的insert标签及mybtisplus设置动态表名。(主要就是从写dynamicTableName来实现的)
这三个任务是必须按照顺序执行,分成多个任务的主要原因就是解耦合。在某个任务失败的时候无需从头执行,但是前提是要保证任务的执行顺序不能变。
问:热门榜使用zset结构,如果在电影数量庞大的时候,该怎么办呢?
zset底层使用跳表和哈希表实现的,所以在排序序列上效率是很高的,即使十几万乃至百万的电影数量,在效率上还是很高的。
如果真的需要进行优化的话,可以使用 分治和桶排序的思想来实现。将不同热度范围的片源放到不同的桶中,就比如 0~100,101~200...
在后续计算排名的时候,就是大于该分为的所有桶中数量的总和 + 当前桶的排名即可。
问:在执行定时任务的时候,使用什么技术实现的,在处理百万的排名数据上是怎么设计的呢?你是怎么控制任务的执行顺序呢?
定时任务上使用xxl-job来实现的,在处理排名数据存储到DB的时候使用 分片广播策略来实现的。
主要就是通过页码取模的方式来让对应的节点执行定时任务。(可以从xxljobhelper获取获取总节点数和当前节点的索引数)
xxl-job支持设置子任务,所以在保证任务的顺序上按照执行的顺序设置子任务id即可。
代金卷模块
兑换码的生成
我们的代金卷主要分为两种类型,手动领取及兑换码领取。优惠劵发放的时候兑换码优惠劵会多一步生成兑换码的操作。
兑换码的生成是自己手动实现的,我们需要生成十位的字符串,这些的字符分为 1~9,A~Z排除相同形状的字符总共是32位置字符。
为了提高安全性,想到使用自增id作为序列号,提高redis的Bitmap判断兑换码是否被兑换,为了防止序列号被爆刷,我们需要使用加密算法。
将序列号转换为32位的二进制并且每4为转为十进制,就可以得到8位数,按位加权,得到签名。
还会准备4位的新鲜值,用于随机的从16个权数组中取一个权数,最后将 签名的后16位+新鲜值+32位置的序列号,每5位转为字符,就可以得到兑换码。后续校验就是反向操作。
问:在项目中有使用过线程池吗?(可以在介绍兑换码的时候引导出来)
由于生成的兑换码较大,如果使用主线程取执行的话就太耗时了,在兑换码优惠劵发放的时候,会使用线程池创建异步的线程取执行兑换码的生成。主要就是提高效率。
线程池的创建上,主要的参数就是:核心线程数,总线程数,阻塞队列,救急线程数等等。在项目中的核心线程数设置为2,兑换码的生成本来就是一个低频的操作,所以没有设置很多,如果阻塞队列都装不下的时候,就会创建救急线程池去执行任务。
代金卷的领取(超卖问题)
1.超领问题的解决
代金卷的领取无非就是在高并发的场景下会有一个超领的问题,也就是常说的超卖问题。 这里我们没有使用传统的基于版本号的乐观锁,因为版本号锁每次只能有一个线程领取成功,在代金卷充足的时候,效率很低下。我们的先想到的解决方案就是修改sql语句中的条件语句设置为 已发放数量 < 中数量。主要就可以实时的获取数据库中的信息。
2.同用户并发领卷超领
在代金卷中,都是设置用户的限领量,同用户并发领卷的时候,领卷的数量大于限量。这个过程会去查询领取的数量再操作数据库,该方法没有加锁,导致没有原子性。(方法抽取成独立的方法)
是所以使用sychronized对userId进行加锁,再写该方法的时候遇到了上锁失败和锁粒度不够的问题。
上锁失败:因为userId是Long类型不能直接使用==进行匹配(底层是缓存数组的问题),我们想到使用toString,但是能toString也是new一个String,最终就使用 userId.toString.intern方法。(获取常量池中的数据)
锁粒度不够:先开启事务在上锁的话超领的问题还是在,所以需要上锁在开启事务。
问:只要sychronized吗,如果服务搭建集群的话,怎么办?
因为sychronized是基于一个jvm的吗,所以搭建集群的话,还是会导致锁失效的问题。
我们需要使用分布式锁,项目中我们使用的是redisson实现的分布式锁,在项目中我们主要使用就是可重入锁(lock)。但是呢,项目后期优化可能不局限于使用该锁,所以我就实现了动态获取锁及不同的上锁策略。(需要配置redis的基本数据)
实现方式:自定义注解 + aop + 工厂模式 + 策略模式 + SPEL。
1.自定义注解的属性主要就是锁的名字,锁的类型(对应枚举),策略(对应枚举)。使用@interface创建。
2.aop主要就是使用环绕通知,切点就是自定义注解。(使用切面.proceed方法来控制方法执行的顺序)
3.工厂模式主要就是类中的enumMap属性,key就是枚举类型,value就是redisson获取对应类型锁的函数。通过提供锁枚举获取对应的锁。
4.策略模式主要就是类中会有一个策略接口,不同的策略内部类都会实现该接口。在里面的策略就包括 快速失败,无限重试,超时后失败等等。
5.SPEL就是动态的做锁名字的拼接。
问:那你对redisson的底层实现有了解过吗?
redisson实现锁的功能主要就是:锁的重入性,阻塞重试机制,ttl重置机制。
锁的重入性:主要就是使用redis的hash结构实现的,大key存储锁的名字,小key存储线程的id,value存储锁重入的次数。
阻塞重试机制:主要通过redis的发布与订阅模式实现的,获取锁失败的线程会去订阅解锁频道,当获取锁的成功的线程完成解锁操作的时候,就会在该频道发送通知,失败的线程就会去重新获取锁,循环此过程直到获取锁成功或者超过等待时间。
ttl重置机制:底层主要就是通过watch dag来实现的,watch dag就是一个定时任务,每过10s就会判断线程是否解锁,如果没有就重置锁的ttl为30s。
问:那redisson是如何保证数据的原子性呢?
redisson的底层主要就是通过Lua基本实现的,Lua基本代码具有天然的原子性。
代金卷智能推荐
代金卷的智能推荐的步骤:查询数据库,初筛,细筛,排列组合,选择最优方案。
1.初筛:计算出购物车中所有片源的总金额和查询出该用户所有代金卷,将没达到门槛的优惠劵注解过滤掉。
2.细筛:此时要考虑代金卷的使用范围,片源有不同的分类(就比如:国内,国外等等)我们主要通过遍历查询查询出可以使用优惠劵的片源。已map进行存储。
3.排列组合:就是对map的keyset进行排列组合。(这里参考的算法就是leetcode的全排序算法)
4.通过遍历组合计算出对应的抵扣价格,及按百分比技术每个片源的抵扣金,将最优解存放在返回给前端。还会返回前端一个按抵扣金排序好的组合列表,通过给用户不同的代金卷组合方式。需要特殊处理的就是:在金额相同时,优先选择用卷最少的。
如果用户选择积分抵扣就会总金额就会再减去 积分数 * 0.01的金额。
当然在这个过程中我们也采用到策略模式,因为有不同的折扣类型,通过不同的折扣类型创建不同的策略类,在这些策略类中实现了判断门槛和计算折扣金额的方法。(在支付成功后会在redis中使用hash结构存储折扣的详情,大key就是订单id,小key就是片源id,value就是折扣的价格)
支付模块
此项目的支付方式主要就是:支付宝。
我们会创建一张支付模板表,属性主要就是:appId,公钥,私钥,商户id,支付标签等等。(后续可能会有不同的支付方式)
通过工厂模式 + 自定义注解实现,通过设置支付标签,获取对应的支付类。
因为项目支付在网页中,是所以使用的是扫码支付,在调用支付FaceToFace().preCreate()就会生成支付链接,通过前端的Qrcode工具生成支付二维码。通过设置异步回调通知地址setNotifyUrl方法用来判断支付是否成功。
问:点击支付按钮的时候,是怎么防止多次支付的呢?
前端在进入下单界面时就会先使用雪花算法生成对应的orderId作为唯一标识。在后续进行生成订单支付的时候对orderId进行判断,就可以避免多次支付。
问:退款你是怎么实现的呢?退款后代金卷是怎么处理的呢?
项目中是支持部分片源退款的操作,如果是部分片源退款的话,我们就会从redis的hash结构中获取用户对该片源的实付金额进行退款,因为其他的片源还是进行了优惠,所以代金卷不会退回。
如果如果是全部退款(订单中回存储片源的id集合用来判断是否是全额退款),代金卷则会退给用户。