可能碰到的面试题:
问题1:分布式锁
面试官:能详细聊聊你的分布式锁设计吗,你是如何实现锁类型切换、锁策略切换基于限流的?
好的。
首先我的分布式锁是基于自定义注解结合AOP来实现的。在自定义注解中可以指定锁名称、锁重试等待时长、锁超时释放时长等属性。当然最重要的,在注解中也支持锁类型属性、加锁策略属性。
我们先说锁类型切换,Redisson支持的分布式锁类型是固定的,比如普通的可重入锁Lock、公平锁FairLock、读锁、写锁等。因此我设计了一个枚举,与Redisson锁的类型一一对应,然后我还写了一个简单工厂,提供一个方法,可以便捷的根据枚举来创建锁对象。这样用户就可以在自定义注解中通过设置锁类型枚举来选择想要使用的锁类型。而我的AOP切面代码就可以根据用户设置的锁类型来创建对应锁对象了。
然后再说加锁策略切换,线程获取锁时如果成功没什么好说的,但如果失败则可以选择多种策略:例如获取锁失败不重试,直接结束;获取锁失败不重试直接抛异常;获取锁失败重试一段时间,依然失败则结束;获取锁失败重试一段时间,依然失败则抛异常;获取锁失败一直重试等。每种策略的代码逻辑不同,因此我就基于策略模式,先定义了加锁策略接口,然后提供了5种不同的策略实现,然后为各种策略定义了枚举。接下来就与锁类型切换类似了,在自定义注解中允许用户选择锁策略枚举,在AOP切面中根据用户选择的策略选择不同的策略实现类,尝试加锁。
至于限流功能,这里实现的就比较简单,就是在自定义注解中加了一个autoLock的标识,默认是true,在AOP切面中会在释放锁之前对这个autoLock做判断,如果为true才会执行unlock释放锁的动作,如果为false则不会执行;锁不释放就只能等待Redis自动释放,假如锁自动释放时长设置为1秒,那就类似于限流QPS为1
面试官追问:那你的设计中是否支持Redisson的连锁(MultiLock)机制呢?
这个锁我知道,它需要利用多个独立Redis节点来分别获取锁,主要解决的是Redis节点故障问题,提高分布式锁的可用性。但是性能损耗比较大,因此我们的设计中并没有支持MultiLock。
面试官追问:那你知道Redisson分布式锁原理吗?
分布式锁主要是满足多进程的互斥性,如果是简单分布式锁只需要利用redis的setnx即可实现。但是Redisson的分布式锁有更多高级特性,例如:可重入、自动续期、阻塞重试等,因此就没有选择使用setnx来实现。
Redisson底层是基于Redis的hash结构来记录获取锁的线程信息,结构是这样的:key是锁名称,hasKey是线程标示,hashValue是锁重入次数。这样就可以实现锁的可重入性。
然后Redisson的分布式锁允许自定义锁的超时自动释放时间,如果没有设置或者设置的值为-1,则自动释放时间为30秒,并且会开启一个WatchDog机制。WatchDog就是一个定时任务,每隔(leaseTime/3)秒就会执行一次,会重置锁的expire时间为30秒,从而实现所的自动续期
至于阻塞重试机制,则是基于Redis的发布订阅机制。如果设置了waitTime大于0,则获取锁失败的线程会订阅一个当前锁的频道,然后等待。获取锁成功的线程在执行完业务释放锁后会向频道内发送通知,收到通知的线程会再次尝试获取锁,重复这个过程直到获取锁成功或者重试时长超过waitTime
面试官追问:那基于Hash结构如此复杂的业务逻辑来实现,代码肯定不止一行,如何保证获取锁逻辑的原子性?
答:这个问题也很好解决,Redisson底层是基于LUA脚本实现的,在Redis中,LUA脚本中的多行代码逻辑执行是天然具备原子性的。
问题2:视频点播
面试官:你们是在线教育,那视频在线点播、视频加密等功能是如何实现的,你们是如何避免视频被盗播的?
视频上传、播放等功能并不是我负责的,不过我也有一定的了解。我们的视频处理都是基于腾讯云VOD的视频点播服务。其中有非常全面的视频任务流,只要配置好任务流的工作,例如视频加密、视频封面生成、视频雪碧图生成、视频内容审核等,在视频上传后这些工作都会自动完成,无需我们自己处理。
我们只需要实现视频上传即可。而且视频上传也无需在服务端上传,服务端之只是提供了一个授权签名,腾讯云提供了前端JS的SDK工具,前端拿到我们的授权签名以后,可以利用工具直接从客户端上传,不会增加服务端的压力。
另外腾讯云VOD还提供了一个视频播放器,播放视频时无需告知其视频地址,而是在服务端给一个授权码和文件id,剩下的视频就由视频播放器与腾讯云服务交互。整个视频数据传输的过程都是加密进行,视频内容也无法下载。
不过如果用户自己录制电脑屏幕那就没办法控制了。
问题3:视频进度统计
面试官:能不能介绍一下你说的视频播放进度统计功能,你是如何保证进度回放的高精度的?
好的。
视频的播放进度记录分为登录用户和未登录用户两种情况,对于未登录用户,其视频播放进度只能是在客户端保存,有前端同时来实现。
我重点说说登录用户的视频播放进度统计功能。
首先,视频播放进度的记录要考虑的用户异常退出的场景,因此我们不能依赖视频停止播放、浏览器退出等前端事件来提交播放进度,而是应该由前端定期的提交播放进度,类似一种心跳机制。
同时,由于要实现视频播放进度的秒级精度,因此这种心跳必然要维持一个较高的频率,频率越高则回放的时间精度越高。不过还要考虑到服务器压力问题,因此频率也不能太高,例如我们项目中设置的是15秒一次心跳。服务端在接收到前端提交的播放进度信息后,将其持久化保存即可。
当然,这些播放进度不能直接写入数据库,因为在用户访问的高峰期,对数据库来说压力还是太大了。所以我们采用的策略是将播放进度信息先写入缓存当中,再异步的持久化到数据库中。这是很常用的一种并发优化思路:先写缓存,再异步持久化到数据库。而异步持久化通常会采用定时任务来实现,但在这里却存在一些问题:第一,我们要保证播放进度的高精度,定时任务如果频率较低,则精度不足。定时任务频率较高则会增加数据库压力。第二,这里的播放进度信息有一些特殊性,因为播放进度不需要每一次的都记录到数据库,而是只需记录用户离开某个视频时最后一次提交的播放进度即可。如果定时任务频繁执行,用户离开视频前持久化到数据库的操作属于无效操作,增加了数据库负担。
综上,用户每次提交的视频播放进度先缓存到Redis,但是却不应该立刻持久化到数据库。而是应该在用户离开视频时最后一次提交播放进度再持久化到数据库。但问题是该如何判断用户是否是最后一次提交呢?
这里有两种方案:
方案一,基于时间做判断。只要用户依然在播放视频,那么Redis中的进度就会每隔15秒变化一次。因此我们只需要在缓存播放进度的同时,记录本次更新的时间戳。定时任务每隔20秒执行一次,但不是立刻更新数据库。而是要先判断Redis缓存中的时间戳与当前时间相差是否超过15秒,超过了则说明用户已经停止提交播放进度,说明当前是最后一次提交,我们写入数据库。否则放弃本次任务即可。
方案二,基于进度做判断。只要用户依然在播放视频,那么Redis中的进度就会每隔15秒变化一次。因此只要Redis中数据不变了,就说明用户停止播放视频了。因此,前端提交播放进度的时候,我们除了要缓存播放进度到Redis以外,还需要提交一个20秒后执行的延迟任务,任务中记录本次播放进度。将来任务执行的时候与缓存中的进度做比较,如果发生了变化,则证明用户依然在播放视频,放弃本次任务。如果没有变化,则证明用户停止播放视频了,则持久化到数据库。
考虑到方案一除了定时任务以外,需要一个额外的任务队列来记录发生变更的视频信息,增加了实现复杂度。所以最终我采用了第二种方案。
问题4:点赞系统
面试官:能讲讲你的点赞系统设计吗?
答:首先在设计之初我们分析了一下点赞业务可能需要的一些要求。
例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。
再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。
所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离。
从具体实现上来说,为了减少数据库压力,我们会利用Redis来保存点赞记录、点赞数量信息。然后利用定时任务定期的将点赞数量同步给业务方,持久化到数据库中。
注意事项:回答时要先说自己的思考过程,再说具体设计,彰显你的逻辑清晰。设计的时候先不说细节,只说大概,停顿一下,吸引面试官去追问细节。如果面试官不追问,停顿一下后,自己接着说下面的
面试官追问:能详细说说你的Redis数据结构吗?
答:我们使用了两种数据结构,set和zset
首先保存点赞记录,使用了set结构,key是业务类型+业务id,值是点赞过的用户id。当用户点赞时就SADD用户id进去,当用户取消点赞时就SREM删除用户id。当判断是否点赞时使用SISMEMBER即可。当要统计点赞数量时,只需要SCARD就行,而Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值,时间复杂度为O(1),性能非常好。
为什么不用用户id为key,业务id为值呢?如果用户量很大,可能出现BigKey?
您说的这个方案也是可以的,不过呢,考虑到我们的项目数据量并不会很大,我们不会有大V,因此点赞数量通常不会超过1000,因此不会出现BigKey。并且,由于我们采用了业务id为KEY,当我们要统计点赞数量时,可以直接使用SCARD来获取元素数量,无需额外保存,这是一个很大的优势。但如果是考虑到有大V的场景,有两种选择,一种还是应该选择您说的这种方案,另一种则是对用户id做hash分片,将大V的key拆分到多个KEY中,结构为 [bizType:bizId:userId高8位]
不过这里存在一个问题,就是页面需要判断当前用户有没有对某些业务点赞。这个时候会传来多个业务id的集合,而SISMEMBER只能一次判断一个业务的点赞状态,要判断多个业务的点赞状态,就必须多次调用SISMEMBER命令,与Redis多次交互,这显然是不合适的。(此处略停顿,等待面试官追问,面试官可能会问“那你们怎么解决的”。如果没追问,自己接着说),所以呢我们就采用了Pipeline管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。
面试官追问(可能会):那你ZSET干什么用的?
答:严格来说ZSET并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。
因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。
每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。
面试官追问(可能会,没追问就自己说):那为什么一定要用ZSET结构,把更新过的业务扔到一个List中不行吗?
答:扔到List结构中虽然也能实现,但是存在一些问题:
首先,假设定时任务每隔2分钟执行一次,一个业务如果在2分钟内多次被点赞,那就会多次向List中添加同一个业务及对应的点赞总数,数据库也要持久化多次。这显然是多余的,因为只有最后一次才是有效的。而使用ZSET则因为member的唯一性,多次添加会覆盖旧的点赞数量,最终也只会持久化一次。
(面试官可能说:“那就改为SET结构,SET中只放业务id,业务方收到MQ通知后再次查询不就行了。”如果没问就自己往下说)
当然要解决这个问题,也可以用SET结构代替List,然后当业务被点赞时,只存业务id到SET并通知业务方。业务方接收到MQ通知后,根据id再次查询点赞总数从而避免多次更新的问题。但是这种做法会导致多次网络通信,增加系统网络负担。而ZSET则可以同时保存业务id及最新点赞数量,避免多次网络查询。
不过,并不是说ZSET方案就是完全没问题的,毕竟ZSET底层是哈希结构+跳表,对内存会有额外的占用。但是考虑到我们的定时任务每次会查询并删除ZSET数据,ZSET中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。
注意:加黑的地方一定要说,彰显你对Redis底层数据结构和算法有深入了解。
问题5:积分排行榜
面试官:你们是如何保证积分排行榜的实时性的?
答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。
因为我们的产品设计是每个月为一个赛季,月初清零积分记录,这样学员就有持续的动力去学习。这就有了赛季的概念,因此也就有了当前赛季榜单和历史榜单的区分,其实现思路也不一样。
首先说当前赛季榜单,我们采用了Redis的SortedSet来实现。member是用户id,score就是当月积分总值。每当用户产生积分行为的时候,获取积分时,就会更新score值。这样Redis就会自动形成榜单了。非常方便且高效。
然后再说历史榜单,历史榜单肯定是保存到数据库了。不过由于数据过多,所以需要对数据做水平拆分,我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:
拆分数据时比较自然,无需做额外处理
查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题
因此我们就不需要用到分库分表的插件了,直接在业务层利用MybatisPlus就可以实现动态表名,动态插入了。简单高效。
我们会利用一个定时任务在每月初生成上赛季的榜单表,然后再用一个定时任务读取Redis中的上赛季榜单数据,持久化到数据库中。最后再有一个定时任务清理Redis中的历史数据。
这里要说明一下,这里三个任务是有关联的,之所以让任务分开定义,是为了避免任务耦合。这样在部分任务失败时,可以单独重试,无需所有任务从头重试。
当然,最终我们肯定要确保这三个任务的执行顺序,一定是依次执行的
面试官追问:那如果数据量很大,所有排行榜数据都放入Redis的一个key,是不是太大了?
答:
首先Redis的SortedSet底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,利用SortedSet也没什么问题,性能上也能得到保证。在我们的项目用户量下,完全足够。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶。
然后为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。
而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值范围比他高的桶的用户数量即可。依然非常简单、高效。
面试官追问:那历史排行榜数据量越来越多,全都放入一张表吗?如何处理海量数据存储和查询的高效性?
答:
面试官追问:那为什么你们没有选择基于开源框架,例如ShardingJDBC来实现分表呢?
答:
问题6:优惠券系统
面试官:能不能讲一讲你说的优惠券兑换算法?
答:
首先要考虑兑换码的验证的高效性,最佳的方案肯定是用自增序列号。因为自增序列号可以借助于BitMap验证兑换状态,完全不用查询数据库,效率非常高。
要满足20亿的兑换码需求,只需要31个bit位就够了,也就是在Integer的取值范围内,非常节省空间。我们就按32位来算,支持42亿数据规模。
不过,仅仅使用自增序列还不够,因为容易被人爆刷。所以还需要设计一个加密验签算法。算法有很多,比如可以使用按位加权方案。
32位的自增序列,可以每4位一组,转为10进制,这样就有8个数字。提前准备一个长度为8的加权数组,作为秘钥。对自增序列的8个数字按位加权求和,得到的结果作为签名。
当然,考虑到秘钥的安全性,我们也可以准备多组加权数组,比如准备16组。然后生成兑换码时随机生成一个4位的新鲜值,取值范围刚好是0~15,新鲜值是几,我们就取第几组加权数组作为秘钥。然后把新鲜值、自增序列拼接后按位加权求和,得到签名。
最后把签名值的后14位、新鲜值(4位)、自增序列(32位)拼接,得到一个50位二进制数,然后与一个较大的质数做异或运算加以混淆,再基于Base32或Base64转码,即可的对兑换码。
如果是基于Base32转码,得到的兑换码恰好10位,符合要求。
需要注意的是,用来做异或的大质数、加权数组都属于秘钥,千万不能泄露。如有必要,也可以定期更换。
当我们要验签的时候,首先将结果 利用Base32转码为数字。然后与大质数异或得到原始数值。
接着取高14位,得到签名;取后36位得到新鲜值与自增序列的拼接结果。取中4位得到新鲜值。
根据新鲜值找到对应的秘钥(加权数组),然后再次对后36位加权求和,得到签名。与高14位的签名比较是否一致,如果不一致证明兑换码被篡改过,属于无效兑换码。如果一致,证明是有效兑换码。
接着,取出低32位,得到兑换码的自增序列号。利用BitMap验证兑换状态,是否兑换过即可。
整个验证过程完全不用访问数据库,效率非常高。
面试官:你是如何避免优惠券的超发的(你是如何避免商品库存超卖的?)?
答:超发与商品库存超卖类似,解决方案也基本一致,...
不过券超发或特殊商品还有一个限领或限买数量的问题,也就是说某张券每人限领一张,或者某个商品每人限买一个。这里就不能采用乐观锁机制了,...
超发、超卖问题往往是由于多线程的并发访问导致的。所以解决这个问题的手段就是加锁。可以采用悲观锁,也可以采用乐观锁。
如果并发量不是特别高,就使用悲观锁就可以了。不过性能会受到一定的影响。
如果并发相对较高,对性能有要求,那就可以选择使用乐观锁。
当然,乐观锁也有自己的问题,就是多线程竞争时,失败率比较高的问题。并行访问的N个线程只会有一个线程成功,其它都会失败。
所以,针对这个问题,再结合库存问题的特殊性,我们不一定要是有版本号或者CAS机制实现乐观锁。而是改进为在where条件中加上一个对库存的判断即可。
比如,在where条件中除了优惠券id以外,加上库存必须大于购买数量的条件。这样如果库存不足,where条件不成立,自然也会失败。
这样做借鉴了乐观锁的思想,在线程安全的情况下,保证了并发性能,同时也解决了乐观锁失败率较高的问题,一举多得。
面试官追问:加了锁以后性能肯定会有下降,如果无法满足当前业务的并发需求,你有什么改进的思路?
答:提高并发的手段有很多,例如:...
面试官:能不能聊一聊你们如何实现优惠券的叠加推荐的?
答:
问题7:通用问题
面试官:你在项目中有用过线程池吗?
答:
面试官:你在项目开发中有没有遇到过什么疑难问题,最后是怎么解决的?
答:
面试官:你有没有过高并发性能优化的经验?
答:
面试官:你有没有自己设计过一些组件或者工具?
答:
面试问题的答案都在前面的笔记里面,回头一个一个过