产品结构图
Nginx实现代理
问:我们在本机的host文件中配置了域名映射,都是同一个服务器。我们只需要输入对应的域名就可以到对应的界面,这是怎么实现的?
答:主要就是通过Nginx反向代理来实现的,Nginx会先通过域名去匹配对应的端口(主要就是通过配置监听端口),再通过配置的服务器地址找到服务器,最终访问服务器对应的端口。
一个server就是一个虚拟主机。并且每个虚拟主机都是在监听80端口(在没有输入端口的时候就默认就是走80端口)。通过server_name也就是域名做匹配,最终进行代理。
项目编写时出现的小问题
1.在对Long类型的数据进行比较的时候,如果数值在-128~127时,则可以使用==判断是否相同。在源码中使用-128~127的缓存数组,在该区间的Long数据都是相同的,而不在该区间时则是通过new来创建的,不能使用==来判断是否相等。(所以在判断Long数值的相同时使用equal)
2.返回类型后存储类型为枚举的时候的存储方式。
@JsonValue //在使用枚举类型的数据进行返回时,我们可能只是需要枚举中的某个属性,此时在枚举中被@JsonVlaue修饰的属性就会作为返回类型(用于返回前端时,需要配合jackJson使用)
@EnumValue //当枚举类型的数据存储数据库的时候,此时在枚举中被@EnumValue修饰的属性就会作为存储类型(用于数据库的存储,需要配合mybatis使用)
- @JsonValue:在使用枚举类型的数据进行返回时,我们可能只是需要枚举中的某个属性,此时在枚举中被@JsonVlaue修饰的属性就会作为返回类型(用于返回前端时,需要配合jackJson使用)
- @EnumValue://当枚举类型的数据存储数据库的时候,此时在枚举中被@EnumValue修饰的属性就会作为存储类型(用于数据库的存储,需要配合mybatis使用)
3.在个人课表中的幂等性的处理方式:通过对用户id和课程的id做联合索引,并且将该索引设置为唯一索引,这样就可以保证在做课程加入的时候,一个用户只能有一个对应的课程,最终解决幂等性问题。
4.在项目中,每次进入个人空间及需要userId的地方是如何获取userId的?
在项目中我们主要是通过token来进行校验的,我们会做一个全局拦截器去解析该token(通过JWT),最终获取到userId,为了能够传递下去(不局限于当前方法),我们可以将userId存储到ThreadLocal中,最终后续的方法都可以获取到UserId。
视频观看模块(合并写请求)
问:在你开发中有没有比较有挑战性的模块呢?
答:有一个辅助功能我印象比较深刻,视频的播放位置的处理,我们的规则就是续播位置的误差需要控制在30秒内,并且切换设备也需要有这个续播功能,本质上就是观看的位置储存到服务端数据库的一系列操作,因为播放视频为用户的主要操作,所以需要考虑高并发的优化。.....那我给您介绍一下这个优化方案。.....
提交信息记录的优化方案(高并发优化方案)
未优化前:
提交的记录分为两种类型:考试和视频。
考试的话,我们就直接新增记录即可并将修改课表的已学章节数 + 1(因为规则就是只能考试一次)。
视频的话,前端每个15秒就会提交一次,主要就是记录当前的观看位置,去不断地修改记录的观看位置,看完的规则就是大于50%,我们通过前端传来的观看位置和视频总时间进行判断,最终修改课表数据中的已学章节数 + 1(只有第一次看完会修改课表数据)。
我们需要判断是否存在记录,以及每次提交都需要修改记录的观看位置,涉及到大量的数据库操作,接口会就一直处于高并发的场景。
优化后:
高并发的优化方案,可以从集群数量,服务的熔断,限流,及减少DB操作进行优化。
使用redis缓存+rabbitMq来进行优化(缓存 + 异步任务+线程池)。
使用redis的hash结构来存储临时观看位置,大key储存lessonId,小key储存节的id,value储存amount及看完标识。
在判断是否存在记录的时候,先去缓存中查询是否存在,然后再去DB中查询并添加缓存。(用缓存查询解决判断)
为了防止多次的操作数据库,修改观看位置时,先将储存到redis中的观看位置进行修改,使用延迟队列发送延迟20秒的消息(数据主要就是观看的位置),在每次监听到消息的时候。判断消息的观看位置和缓存中的是否相同(lessonId + 节id来确定缓存中观看位置数据),如果相同(说明用户当前20秒内没有观看)则去更新数据库并清除缓存。如果不相同(说明用户还在观看)则不进行操作。
在后续执行操作db的时候,为了提高速度,我们还使用了线程池(使用submit方法进行提交任务),在监听方法中监听到任务后使用多线程执行任务,我们会创建一个静态的线程池,实现多线程操作数据库,加快db的操作,减少io的消耗时间。因为涉及到的io操作比较多,所以将核心线程数设为2N+1(N为电脑的核数)
问题回答及评论模块
表的设计: 总共两张表,分别是问题表和 回答记录表。
问题表:只要就是存储问题的基本信息, 标题,内容, 回答的数量。用户id,课程id,外键就是回答的id。
回答表:存储回答和评论的基本信息,内容,是否点赞,回复问题的id, 回复回答的id,用户id。其中 通过判断问题id和回答id来判断是 回答还是评论。
在展示界面的设计就是,先展示问题的对应课程的 问题列表。点击对应问题的回答信息就会展示回答列表,点击对应的回答的讨论就会展示讨论的信息。通过上级问题id,上级的回答id来确定返回的讨论和回答数据。(一些具体的数据需要使用feign接口查询,就比如用户的信息)
点赞模块(使用set结构的redis进行优化 ,并且使用xxl-job执行定时任务)
问:介绍一下点赞模块吧?
点赞的业务包括:对问答的点赞,对笔记的点赞,所以我们直接将点赞设置一个独立的服务。
我们考虑到此模块可能会存在高并发的场景,所以在原先涉及大量的DB操作旧方案上通过redis的数据结构和定时任务来优化。(面试官感兴趣就介绍优化方案)
表的设计:点赞记录表。
点赞记录表: uerId, 讨论或回答id,点赞目标类型(回答或笔记),点赞时间。
新的点赞方案
优化前的方案就是:直接保存点赞记录到db + MQ修改对应回答或讨论的点赞数。
优化后:
将点赞的记录用set结构存储到redis中,key存储目标id(就是回答或笔记的id),value存储用户的id,在每次提交点赞或取消点赞的时候对set新增或删除的操作,并且取统计目标的点赞数,通过scard方法就可以获取点赞数。(在做点赞记录是否存在判断的时候使用isMember方法进行判断)
将点赞的数量存储到zset结构中,key存储目标类型(回答或笔记),member存储目标id,score存储点赞的数量。
通过xxl-job定时任务每隔20秒,发送消息到mq,实现到zset结构中取30条数据,使用popmin方法,获取目标id和点赞数量,做批量的修改操作。
减少了大量的DB操作。
因为点赞的模块请求量本来就比较小,就没有考虑对xxl-job搭建集群。(选答)
题外话:在改优化完成之后,我就在想,像这种小体量的请求,直接走数据库其实会快很多(因为DB操作也没有那么多),但是为了考虑高并发场景,所以还是进行优化比较好。只能说,有利有弊吧。(可以跟面试官陈述,这样比较真实)
问:redis中具体使用什么数据结构?
将点赞的记录用set结构存储到redis中,key存储目标id(就是回答或笔记的id),value存储用户的id。
将点赞的数量存储到zset结构中,key存储目标类型(回答或笔记),member存储目标id,score存储点赞的数量。
问:在set结构中,为什么不使用userid作为key,使用目标id作为value,如果用户量庞大的话,会不会出现BigKey的问题?
这个方案也是可以的,因为此模块不会涉及到大V的点赞操作,因此就认为点赞的个数为1000以内,就不会有BigKey的问题。并且我们需要统计每个目标的点赞数量,当目标id作为key的时候正好可以使用scard方法来获取个数比较方便。而Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值,时间复杂度为O(1)。
问:那你使用zset的作用是什么呢?
我们使用set结构存储点赞的记录,为配合xxl-job的使用,我们需要使用zset来记录点赞数发生变化的目标id和点赞数量,这样才能确定需要修改的目标id和点赞数。
问:那为什么不使用List结构呢,而是使用zset结构?
如果我们使用List结构,那么在每次统计目标id的点赞个数的时候就会存储因此改目标的点赞个数到list中,其实我们只需要最后一次的点赞数,但是使用list进行批量修改的话,就可能会多很多DB操作。但zset一定最好吗?我是不怎么认为,毕竟zset的底层实现为:哈希结构 + 跳表,需要格外的空间占用。
签到及积分模块
在就是选型的时候,我们就考虑使用mysql来存储签到的记录,当是签到涉及每个人每天的签到情况,这样表的数据就会十分的庞大,为了解决这个问题,我们想到使用redis的bitMap来解决此问题,因为存储是二进制的文件,并且每一个月才会有一条数据,比较节省空间。在该模块中比较特别的就是查询连续签到的天数,主要就是通过 &(与运输) + >>>(右移)来查询的。
问:你的项目中用过redis中的什么结构呢?
用得还是比较多的,就比如:String, Hash,set,zset,list,bitMap等等。
1.Hash: 在做分布式锁(物流项目中的支付模块,防止多次支付),临时存储视频的观看位置(解决高db操作的问题)。
2.set :保证即可的幂等性,防止重复消费(物流项目中,运单防止被车辆多次消费),作为缓存,存储点赞记录(点赞模块,减少db操作)
3.zset:保存对应业务的点赞个数,配合xxl-job发送消息到mq进行批量的修改操作。(点赞模块,减少多余的db操作)
4.list:在等等转运单,将两地网点的id拼接作为key,value就是存储对应位置的运单id,通过定时任务让车辆转载运单,并通过set解决幂等性的问题。(物流模块,车辆转载流程)
5.bitMap:保存当月的签到记录,通过二进制文件进行存储。(签到模块,减少db的大小)
问:你使用redis保存签到记录的话,如果redis宕机了怎么办呢?
主要就是要保证redis的高可用性。
1.我们可以给redis设置持久化机制,包括使用 AOF,RDB。
2.搭建redis的集群,设置哨兵等等。
3.对于数据的安全性和正确性要求较高的数据,还是需要使用传统的数据库进行存储。
总的来说,redis的bitmap存储签到的信息的安全性和内存的占用的情况都是可以接受的,但是具体的技术选型还是需要根据要求取选择。
积分模块
项目中规定,写问题回答的时候可以 + 3积分,编写笔记 可以 + 4分,每天都是最高20积分。这些积分可用于做排行榜。在增加不同的类型的积分的时候,通过mq的router key来添加不同的类型积分。
排行榜模块
问:说说你积分排行榜功能的设计和实现吧?
主要就是通过用户的每个月的积分做排行,在月初的时候会清零。将每个月作为一个赛季,我们的功能主要就是本月的排名和历史赛季排名。
本月的排名会通过redis的zset结构进行存储,key存储年月,member存储userId,scope存储积分值,可以通过ZREVRANGE,ZRANK方法获得排序和排名后的数据。因为底层是基于跳表的结构实现的,所以效率很高。
历史排名的话,就是要存储到数据库中,都是如果将每个赛季的数据都存到一张表里的话,数据就会很庞大,为了解决这个问题我们可以使用水平分表,这里的分表主要就是基于每个赛季建立独立的表,使用赛季id作为后缀,生成独立的表,这里我们没有使用share-jdbc,而是通过mybaits,做创建表的操作。在我们每次查询赛季的时候就不会有跨表的行为。在数据的插入和查询的时候使用mybtis-plus的插件设置动态表名,来实现数据的插入和获取。(实现步骤就是:DynamicTableNameInnterInterceptor,在map中创建tableNameHandler类,重写
dynamicTableName方法,将就表名拼接成新名的表名,最终动态修改表名)
通过xxl-job执行三个任务,创建对应赛季表,插入本赛季的排名数据,删除zset中的缓存记录。
这三个任务是相互关联的,分成三个任务的的目的就是减少任务的耦合度,在某个任务失败的时候,单独重新执行即可。无需重新执行全部任务,当然前提就是要保证任务执行的顺序不能变。(如果不按顺序,你可能先删除缓再做插入操作,这样就会出现插入空数据,这明显不合理)
public interface TableNameHandler {
/**
* 生成动态表名
*
* @param sql 当前执行 SQL
* @param tableName 表名
* @return String
*/
String dynamicTableName(String sql, String tableName);
}
问:排行榜使用zset(sortedSet)进行储存,那用户数据量非常多的时候,该怎么办呢?
sortSet的底层使用的是跳表的结构,排序效率是很高的,我们项目的用户体量在十万左右,即使是百万级的用户量,性能依旧是非常好的。
如果用户量真的非常大的话,我们的优化方案就是:使用分治和桶排序的思想,将用户按积分的范围分成多个桶。(就比如: 0~100,100~200 ......),在后续获取排名的时候也是很方便的,当前桶中用户的排名 + 大于该桶对应范围的其他桶数据个数的总和 就是该用户的排名,这样效率也是很高的。
问:在执行定时任务的时候,你们是使用什么框架来实现的呢?处理百万排名数据的时候是怎么解决的呢?然后保证多任务的顺序性呢?
1.在我们生成历史排名的时候就会顺序执行三个任务,使用xxl-job框架来实现的。
2.在处理海量任务的时候就会使用xxl-job的分片广播策略,因为排名的数据会做分页,我们可以使用api获取xxl-job的索引和总数,通过对页码取模的方式来确定执行任务的xxl-job节点。最终对应任务会被执行。
(在物流项目的运单消费中,也是通过xxl-job的分片广播来执行消费任务的,通过运单id取模。为了提高用户的体验,防止同用户的运单装配到不同的车上【用户就需要多次不同时间收快递】,要添加分布式锁)
3.为了保证任务的顺序性,可以使用子任务来保证顺序。a(子任务:b)->b(子任务:c)->c。(在任务管理中设置即可)
优惠劵模块(使用异步实现优惠劵领取)
优惠劵兑换码模块
问:你们优惠劵有兑换码的方式兑换是吧,那你聊聊实现的流程吧?
为了提高安全性,我们最优的值就是使用自增序列号,通过reids的bitmap就可以判断改兑换码是否兑换过,直接使用自增序列号会有爆刷的风险,因此我们还需要使用一些加密的算法。(1~9 A~Z,不要0,o,i,l)
我们会将序列号转为32位并且每四位转化为10进制,这样就可以获得8为的数值,对数值进行按位加权。我们准备16组的权数组,此就结果就是签名。
随机生成4位新鲜值,用于随机的取一个权数组。签名的后14位 + 4位新鲜值 + 32位的自增序列号,就过程了50位的数据,按5位计算字符,最终生成兑换码(基于Base32生成10位的兑换码)。
兑换码的校验就是该生成的相反操作。
整个过程没有涉及db操作,所以在效率上非常高。
使用incrBy来记录自增的最大位置。
问:在你的项目中有使用过线程池吗?
在优惠券模块使用了线程池,在我们对优惠卷发放的时候,如果优惠卷是兑换码兑换的时候,不仅需要修改优惠卷的状态,也要生成对应数量的兑换码,因为兑换码的数量较多,如果在发放的时候生成兑换码的话,会很耗时,因此使用线程池使用线程异步的生成兑换码,在效率上就得到了大大的提升。
问:你的线程池参数是怎么设置的呢?
线程池设置的参数主要就是:核心线程数,最大线程数,救急线程数,救急线程的时间单位,阻塞线程,拒绝策略。
因为优惠卷发放并不是高频的场景,所以在核心线程数就设置为2,这里使用线程池主要就是异步的执行兑换卷的生成,减少整个接口的耗时。将阻塞队列容量设置为200,当核心线程和阻塞队列都无法应对任务的时候就会使用救急线程去执行。
优惠劵领取模块
问:在你的优惠劵模块中,你是如何解决优惠劵超领的问题呢?(超卖问题)
超卖的的主要原因就是多线程并发访问导致的,事务的数据未提交,导致其他的事务也做了新增的操作最终导致超卖的问题。
主要的解决方案就是:使用悲观锁或者乐观锁。
悲观锁的效率比较低,就没有考虑了,mysql中的乐观锁主要也是通过版本号字段的匹配来解决超卖的问题。这也会导致失败率较高,在发放优惠劵的时候没有到达最后一张时也只会有一个线程执行成功,效率上比较低。
所以,我们在解决超卖是直接在sql的条件中加 当前发放的数量要小于总数量条件来解决并发的问题。大大提高了成功率
问:那你在这模块中,开发过程中有遇到什么难题吗?
当我们使用jmert去使用同一个用户并发的抢一张卷时,优惠劵的领取数量超过了限制的数量,在整个代码实现过程中没有保证原子性。(在这个方法中主要就是修改优惠劵的发放数,创建用户卷,修改兑换码的状态,因为这个普通方法是抽取出来的,直接加事务,并使用锁)
因为此方法的事务是独立的,在相同的userId获取优惠劵的时候呢,此方法都执行完了(此事务完成),不受外部方法的事务回滚的影响。最终导致超过限制。(不同的userId会去做限制的判断,内部事务会直接回滚)
开始的时候使用synchronzied代码可以,通过userId来上锁,保证相同用户每次只能由一个线程执行,但是呢,userId时Long类型的,当数据不在-128~127时每次都是新对象,又想到用toString方法,都是其底层还是创建一个新的对象,所以我们需要使用常量池中的数据来做校验也就是调用.toString.intern方法。
但是经过测试还是存在相同的问题,经过分析,发现如果是先开启事务在获取的锁的话,在上一个事务db操作完解锁但是未提交,此时下一个事务获取锁进行db操作,最终提交,出现了两次的db操作。这就导致问题还存在的原因。所以解决方案就是:缩小事务的范围,先获取锁在开启事务,这样就避免了问题。
问:只使用syncheronzied来加锁吗?实现悲观锁有没有其他的方式呢?
在开始的时候,确实使用synchronized来实现锁的,但是其是在一个jvm中实现的,在微服务项目中肯定是不够用的。因为优惠劵会搭建集群(集群下的jvm是独立的,会导致锁失效),我们需要考虑微服务下锁失效的问题。在项目中我们使用redisson来实现分布式锁。最开始的时候我们使用可以可重入锁也就是lock方法,但是呢这个方法是死的。后续需要使用其他锁的时候我们还需要修改代码。
解决方案:自定义注解 + aop + 工厂模式 + 策略模式 + SPEL 来实现的。(通过添加自定义注解来实现上锁,事务的优先级最低,所以会先上锁,在开启事务)
1.自定义注解主要就是存储用户的配置。包括锁的名字,获取锁的等待时间,过期时间,锁的类型,失败策略。
2.aop:主要就是通过自定义注解作为切点,使用环绕通知来上锁。(通过切点.proceed方法来控制执行的顺序)
3.工厂模式:在工厂类中map属性(EnumMap,底层是简单的数组),key存的就是锁类型的枚举,value存储的是方法。在工厂创建的时候就会对map进行插入操作。在工厂方法中通过锁类型的枚举从map获取并执行方法,最终返回对应类型的锁。
4.策略模式:在枚举类中会有一个上锁的接口,并且在枚举中会有多个类实现该方法,每个类都是一种策略。通过自定义注解中设置的策略执行对应的上锁方法。
5. 通过SPEL来动态的设置上锁的名字,主要就是根据userId来设置名字,SPEL是找的现成的代码,所以不是很了解。(名字的格式:lock:coupon:#{userId})
最终保证动态的控制分布式锁的类型及失败策略。
在物流项目中使用的分布式锁也是基于redisson实现的,锁的底层实现就是基于hash结构来实现的,大key也就是锁的名字,在物流项目中使用名字就是交易单id+订单id的拼接,小key就是线程id,value就是锁重入的次数。而本项目中的大key使用的就是userId。(只是做了格式化)
问:有了解过事务失效的场景吗?有遇到过吗?你是怎么解决的呢?
事务失效的场景
1.在是事务方法中捕获到异常未主动的抛出。
2.事务方法为非public方法。
3.事务的传播性行为错误。
4.事务设置为的回滚异常类不匹配。
5.非事务方法调用事务方法。
在我编写优惠劵模块的时候,就出现非事务方法调用事务方法导致索引失效。
因为在代码开中有重复的数据库操作,所以我们封装了一个通用的方法,主要就是领卷后的添加记录,修改优惠劵发放数量的操作,但是修改优惠劵老是不生效,后来就发现是事务失效了。
解决方法:
导入aop依赖 -> 开启aop注解(开启暴露代理)->获取代理类调用对应的事务方法。
优惠劵智能推荐模块
问:说说优惠劵智能推荐的流程吧?
主要的流程就是对查询到的用户进行进行初筛->细筛->优惠劵排列组合->计算每种组合的优惠金额->选择最优优惠。
1.先查询该用户的所有优惠劵集合。
2.初筛:先不考虑优惠劵的使用范围,将门槛金额大于课程价格和的优惠劵直接过滤掉。
3.细筛:每个优惠劵可能会限定范围,查询出每个优惠劵能使用的课程集合,最终使用Map进行收集。(Map<coupon, List<Loong>>),此时的key集合就是可以使用的优惠劵集合。
其实这里的初筛和细筛可以合并在一起,主要是为了增加可读性。
4.排列组合:生成所有的可用优惠劵的搭配方案。这个就是leetcode的全排序算法(这里要注意的就是还要添加只使用一张优惠劵的情况)
5.计算每种组合的优惠金额:主要就是循环优惠劵组合,判断当前的金额是否复合优惠劵的最低门槛,并按优惠劵的金额计算折扣价格,按照课程价在总价格中的比例计算各个课程的折扣价,在下次计算中金额前需要扣除当前的折扣价。
6.选择最优优惠:主要就是要考虑多个组合的折扣价格相同情况,此时就优先考虑使用优惠劵最少的组合。如果还存在优惠劵数量使用相同的组合的话,直接将这多种组合都返回给前端,将选择权交给用户。(返回的类型就DTO的list,DTO中的属性:折扣金额,优惠劵的id集合,每次商品的折扣金额使用map结构)
问:你项目中有没有使用过设计模式?
在优惠劵的折扣计算的时候就使用了策略模式,动态的获取不同的折扣类型的策略类,这些折扣类主要用于判断是否达到门槛和计算折扣金额。
在使用分布式锁的时候,为了能够动态的获取锁的类型,及不同的上锁策略,就使用工厂模式 + 策略模式。(工厂模式主要就是动态的获取锁的类型, 策略模式主要就是动态的获取对应的上锁策略)这里就可以引导模式官到到抢卷的模块,说说使用分布式锁的原因....
问:你们优惠劵规则是怎么实现的?
在优惠劵的折扣计算的时候就使用了策略模式,动态的获取不同的折扣类型的策略类,这些折扣类主要用于判断是否达到门槛和计算折扣金额。
问:在项目中有使用过线程池吗?
在生成优惠劵兑换码的模块中使用了线程池。引导面试官到发放优惠劵的模块中.....
问:如果出现部分商品退款的情况,退款金额和优惠劵是怎么处理的呢?
在退款金额上呢,如果用户选择了部分的商品进行退款的话,我们就根据每个商品实际付款金额进行退款(主要就是将每个商品的实付金额存储到redis中并将ttl设置为15天,通过该结构进行退款,使用hash结构,大key存储订单id,小key存储课程id,value存储实付金额),主要也满足我们不退回优惠劵的规则。