
天机学堂总结
1.天机学堂总结
1.1项目简介
天机学堂是一个基于微服务架构的生产级在线教育项目,核心用户不是K12群体,而是面向成年人的非学历职业技能培训平台。
项目包含了在线教育中核心的学习辅助系统、考试系统,电商类项目的促销优惠系统等等。更能学习到微服务开发中的各种热点问题,以及不同场景对应的解决方案。
1.2系统架构
天机学堂目前是一个B2C类型的教育网站,因此分为两个端:
后台管理端
用户端(PC网站)
整体架构如下:
1.3技术架构
1.4核心业务流程
天机学堂分为两部分:
学生端:其核心业务主体就是学员,所有业务围绕着学员的展开
管理端:其核心业务主体包括老师、管理员、其他员工,核心业务围绕着老师展开
1.4.1老师核心业务
例如,老师的核心业务流程有:
虽然流程并不复杂,但其中包含的业务繁多,例如:
课程分类管理:课程分类的增删改查
媒资管理:媒资的增删改查、媒资审核
题目管理:试题的增删改查、试题批阅、审核
课程管理:课程增删改查、课程上下架、课程审核、发布等等
1.4.2学员核心业务
学员的核心业务就是买课、学习,基本流程如下:
2.面试
项目名:天机学堂
项目说明:
天机学堂是一个在线的非学历职业技能培训平台,核心业务是以售卖各种技能培训的在线课程,并提供丰富的学习辅助功能、交互功能,以提升用户学习时的氛围感和学习的积极性。
技术栈:
SpringBoot、SpringCloud、Mybatis、MySQL、Redis、Redisson、Caffeine、RabbitMQ、XXL-JOB、腾讯云VOD(视频点播)、Nginx等
工作职责:
负责部分业务的需求分析、数据库表设计,以及通用工具的封装设计。例如开发了基于Redisson的分布式锁组件,零代码侵入,基于注解实现加锁、锁类型切换、加锁策略切换、SPEL的动态锁名、限流等功能,大大提高了开发效率。
参与设计和开发了学习计划和学习进度统计功能。设计了一套高性能的视频播放进度记录系统,在不增加数据库压力的情况下,使视频播放进度回放精度达到秒级。
对评价系统中的点赞功能进行了系统重构:重新设计了点赞数据结构,解除了与业务的耦合,成为了一个通用的点赞系统。基于Redis实现点赞记录和点赞数缓存,同时基于定时任务做数据持久化,大大提高了点赞系统的并发能力,减轻了数据库压力。
参与了积分排行榜功能的设计和开发。对用户的各种学习和交互行为做积分统计,例如签到积分、学习积分、问答积分等;并且按月累计积分形成赛季排行榜。做到了当前赛季排行榜的实时更新、历史赛季排行榜的分表持久保存。
参与了优惠券系统的设计与开发。优惠券系统支持基于兑换码兑换优惠券,我设计了一套可以支持20亿量级的并且可以脱离数据库做高效校验的兑换码生成算法。同时也对领券功能进行了并发安全、并发性能的优化设计。另外还设计实现了优惠券叠加方案推荐算法,基于用户购物车中商品推荐最佳的优惠券叠加方案。
基于AOP和注解使用策略模式开发了Redisson分布式锁组件,实现零代码侵入和多种锁功能,提高开发效率。
开发学习计划和进度统计功能,使用Redis和DelayQueue实现异步处理和和合并写请求实现高精度的视频进度记录,大幅数据库压力。
实现通用点赞系统,设计通用的点赞数据结构与业务解耦,使用Redis、MQ和定时任务优化,提高并发能力,减轻数据库压力。
设计并实现积分排行榜,结合Redis优化、MQ异步处理及动态分表技术保障排行榜实时性与历史排行榜定时持久化。
优惠券系统的设计与开发,支持基于兑换码兑换优惠券,生成支持超大量级高效校验的兑换码算法,优化领券功能并发性能和安全问题,实现优惠券叠加方案推荐算法。
工作职责部分选择其中的2~3个填即可,不要全写。
2.可迁移的技术方案
天机学堂中包含的技术和解决方案有:
基于自定义注解和Redisson的分布式锁工具
XXL-JOB分布式任务调度工具
Caffeine本地缓存工具
支持可靠消息、延迟消息的RabbitMQ工具
延迟队列DelayQueue
基于CompletableFuture和CountDownLatch的并发任务处理方案
高并发高精度的视频进度记录和回放解决方案
学习计划和学习进度统计的学习监督方案
通用的问答(评论)功能实现方案
通用、高性能的点赞系统解决方案
高性能、低存储成本的签到解决方案
实时性强、通用性好的积分排行榜、历史排行榜解决方案
支持大数据量、高性能校验的优惠券兑换码算法
基于LUA脚本的高性能、并发安全的优惠券领取解决方案(秒杀解决方案)
优惠券叠加的智能推荐算法(MapReduce的思想)
基于Redis合并写请求并基于定时任务异步持久化的并发优化方案
基于Redis和MQ的异步写优化方案
基于腾讯VOD的视频加密、视频点播、视频审核、视频雪碧图功能(已实现未讲解)
包含支付宝支付、微信支付的多平台支付系统(已实现未讲解)
订单退款拆单处理方案(已实现未讲解)
3.可能碰到的面试题:
问题0
问题1:分布式锁
通用分布式锁组件 基于注解和aop
@MyLock注解(name,waitTime,leaseTIme,timeUnit,lockType,lockStrategy)
注意切面类重写顺序order spring事务的aop等级最低
枚举锁类型 可重入/公平锁/读锁/写锁
锁工厂 根据锁类型创建不同类型锁 EnumMap Map<MyLockType, Function<String, RLock>>
策略模式 失败策略枚举
快速失败 快速结束 无线重试 重试超时后结束 重试超时后失败
定义抽象tryLock(RLock lock, MyLock prop)方法 枚举项中重写trylock方法 根据参数列表不同获取不同失败策略的锁 返回获取锁成功与否
SPEL表达式解析用户id
使用地方:优惠券领取(手动/兑换码) 用户确认下单生成支付单幂等 支付回调幂等校验 退款幂等性校验
面试官:能详细聊聊你的分布式锁设计吗,你是如何实现锁类型切换、锁策略切换基于限流的?
好的。
首先我的分布式锁是基于自定义注解结合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:视频进度统计
KEY | HashKey | HashValue |
lessonId | sectionId:1 |
|
sectionId:2 |
|
学习计划(每周学几小节) 进度统计
视频||考试=>小节完成==>学习记录表、课程表
redis 哈希结构保存记录 减少key数量,减少创建销毁成本
key:lessonid
hashkey:sectionid
json value:finished moment sectionid
DelayQueue延迟队列 延迟任务DelayTask<D> implements Delayed 两个属性 数据 和 延迟时间 重写 compareto方法和getdelay
将DelayTask添加到DelayQueue 然后用take方法获取任务 使用线程池异步执行
面试官:你在开发中参与了哪些功能开发让你觉得比较有挑战性?
答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:
首先续播时间误差要控制在30秒以内。
而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放
要达成这个目的,使用传统的手段显然是不行的。
首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。
其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。
面试官:能不能介绍一下你说的视频播放进度统计功能,你是如何保证进度回放的高精度的?
好的。
视频的播放进度记录分为登录用户和未登录用户两种情况,对于未登录用户,其视频播放进度只能是在客户端保存,有前端同时来实现。我重点说说登录用户的视频播放进度统计功能。
首先,视频播放进度的记录要考虑的用户异常退出的场景,因此我们不能依赖视频停止播放、浏览器退出等前端事件来提交播放进度,而是应该由前端定期的提交播放进度,类似一种心跳机制。
同时,由于要实现视频播放进度的秒级精度,因此这种心跳必然要维持一个较高的频率,频率越高则回放的时间精度越高。不过还要考虑到服务器压力问题,因此频率也不能太高,例如我们项目中设置的是15秒一次心跳。服务端在接收到前端提交的播放进度信息后,将其持久化保存即可。
当然,这些播放进度不能直接写入数据库,因为在用户访问的高峰期,对数据库来说压力还是太大了。所以我们采用的策略是将播放进度信息先写入缓存当中,再异步的持久化到数据库中。这是很常用的一种并发优化思路:先写缓存,再异步持久化到数据库。而异步持久化通常会采用定时任务来实现,但在这里却存在一些问题:第一,我们要保证播放进度的高精度,定时任务如果频率较低,则精度不足。定时任务频率较高则会增加数据库压力。第二,这里的播放进度信息有一些特殊性,因为播放进度不需要每一次的都记录到数据库,而是只需记录用户离开某个视频时最后一次提交的播放进度即可。如果定时任务频繁执行,用户离开视频前持久化到数据库的操作属于无效操作,增加了数据库负担。
综上,用户每次提交的视频播放进度先缓存到Redis,但是却不应该立刻持久化到数据库。而是应该在用户离开视频时最后一次提交播放进度再持久化到数据库。但问题是该如何判断用户是否是最后一次提交呢?
这里有两种方案:
方案一,基于时间做判断。只要用户依然在播放视频,那么Redis中的进度就会每隔15秒变化一次。因此我们只需要在缓存播放进度的同时,记录本次更新的时间戳。定时任务每隔20秒执行一次,但不是立刻更新数据库。而是要先判断Redis缓存中的时间戳与当前时间相差是否超过15秒,超过了则说明用户已经停止提交播放进度,说明当前是最后一次提交,我们写入数据库。否则放弃本次任务即可。
方案二,基于进度做判断。只要用户依然在播放视频,那么Redis中的进度就会每隔15秒变化一次。因此只要Redis中数据不变了,就说明用户停止播放视频了。因此,前端提交播放进度的时候,我们除了要缓存播放进度到Redis以外,还需要提交一个20秒后执行的延迟任务,任务中记录本次播放进度。将来任务执行的时候与缓存中的进度做比较,如果发生了变化,则证明用户依然在播放视频,放弃本次任务。如果没有变化,则证明用户停止播放视频了,则持久化到数据库。
考虑到方案一除了定时任务以外,需要一个额外的任务队列来记录发生变更的视频信息,增加了实现复杂度。所以最终我采用了第二种方案。
问题4:点赞系统
通用、独立、安全、并发
点赞数变更==MQ通知==>业务方
根据业务类型区分routingkey:QA
改进:
查询记录是否存在、新增记录、统计数量、更新数量 4次
合并写请求 先缓存到redis 再定期更新业务方数量
set结构保存记录 业务id为key userid为值
Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值
zset保存点赞次数(业务类型作key,业务id作member,次数作score) 相当于一个待持久化的队列
面试官:能讲讲你的点赞系统设计吗?
答:
首先在设计之初我们分析了一下点赞业务可能需要的一些要求。
例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。
再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。
所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离,隔离的手段就是在数据库表中设置了业务类型字段,目前是一张表中记录,将来我们如果数据量过大,还可以考虑基于业务类型对数据库做分表。
从具体实现上来说,为了减少数据库压力,我们会利用Redis来保存点赞记录、点赞数量信息,并且基于Redis的持久化机制来保证数据安全。然后利用定时任务定期的将点赞数量同步给业务方,持久化到数据库中。
面试官追问:能详细说说你的Redis数据结构吗?
答:
KEY(bizId)
VALUE(userId)
bizId:1
userId:1
userId:2
userId:3
首先保存点赞记录,使用了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干什么用的?
KEY(bizType)
Member(bizId)
Score(likedTimes)
likes:qa
bizId:1001
10
bizId:1002
5
likes:note
bizId:2001
9
bizId:2002
21
答:严格来说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中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。
问题5:积分排行榜
积分、签到、实时榜单、历史赛季榜单
bitmap位图保存签到记录(布隆过滤器也基于bitmap)
SETBIT key offset value
BITFIELD key GET encoding offset
示例 BITFIELD bm GET u3 0 查询从第1天到第3天的签到记录
与1做与运算,得到本身。让签到记录与1做与运算,得到最后一个bit位
把数字右移一位,最后一位到了小数点右侧,由于我们保留整数,最后一位自然就被丢弃了
MQ异步保存积分 要判断是超出今日上限
SortedSet储存用户积分形成榜单 太多可以分治划桶
滚动分页ZREVRANGEBYSCORE
历史赛季分表 MP动态表名插件
每月初XXL-JOB自动创建并导入数据 分页查询 分片广播
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
// 3.2.查询数据
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
int pageNo = index + 1; // 起始页,就是分片序号+1
int pageSize = 10;
while (true) {
List<PointsBoard> boardList = pointsBoardService.queryCurrentBoardList(key, pageNo, pageSize);
if (CollUtils.isEmpty(boardList)) {
break;
}
// 4.持久化到数据库
// 4.1.把排名信息写入id
boardList.forEach(b -> {
b.setId(b.getRank().longValue());
b.setRank(null);
});
// 4.2.持久化
pointsBoardService.saveBatch(boardList);
// 5.翻页,跳过N个页,N就是分片数量
pageNo+=total;
}
TableInfoContext.remove();
}
XXL-JOB执行器 调度器 任务 日志
java8新特性 Optional
面试官:你项目中使用过Redis的那些数据结构啊?
答:很多,比如String、Hash、Set、SortedSet、BitMap等
面试官追问:能不能具体说说使用的场景?
答:比如很多的缓存,我们就使用了String结构来存储。还有点赞功能,我们用了Set结构和SortedSet结构。签到功能,我们用了BitMap结构。
就拿签到来说吧。因为签到数据量非常大嘛,而BitMap则是用bit位来表示签到数据,31bit位就能表示1个月的签到记录,非常节省空间,而且查询效率也比较高。
面试官追问:你使用Redis保存签到记录,那如果Redis宕机怎么办?
答:对于Redis的高可用数据安全问题,有很多种方案。
比如:我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。
或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。
当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。
总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看公司的要求来选择。
面试官:你在项目中负责积分排行榜功能,说说看你们排行榜怎么设计实现的?
答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。
因为我们的产品设计是每个月为一个赛季,月初清零积分记录,这样学员就有持续的动力去学习。这就有了赛季的概念,因此也就有了当前赛季榜单和历史榜单的区分,其实现思路也不一样。
首先说当前赛季榜单,我们采用了Redis的SortedSet来实现。member是用户id,score就是当月积分总值。每当用户产生积分行为的时候,获取积分时,就会更新score值。这样Redis就会自动形成榜单了。非常方便且高效。
然后再说历史榜单,历史榜单肯定是保存到数据库了。不过由于数据过多,所以需要对数据做水平拆分,我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:
拆分数据时比较自然,无需做额外处理
查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题
因此我们就不需要用到分库分表的插件了,直接在业务层利用MybatisPlus就可以实现动态表名,动态插入了。简单高效。
我们会利用一个定时任务在每月初生成上赛季的榜单表,然后再用一个定时任务读取Redis中的上赛季榜单数据,持久化到数据库中。最后再有一个定时任务清理Redis中的历史数据。
这里要说明一下,这里三个任务是有关联的,之所以让任务分开定义,是为了避免任务耦合。这样在部分任务失败时,可以单独重试,无需所有任务从头重试。
当然,最终我们肯定要确保这三个任务的执行顺序,一定是依次执行的。
面试官追问:你们使用Redis的SortedSet来保存榜单数据,如果用户量非常多怎么办?
首先Redis的SortedSet底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,利用SortedSet也没什么问题,性能上也能得到保证。在我们的项目用户量下,完全足够。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶。
然后为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。
而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值范围比他高的桶的用户数量即可。依然非常简单、高效。
面试官追问:你们使用历史榜单采用的定时任务框架是哪个?处理数百万的榜单数据时任务是如何分片的?你们是如何确保多个任务依次执行的呢?
答:我们采用的是XXL-JOB框架。
XXL-JOB自带任务分片广播机制,每一个任务执行器都能通过API得到自己的分片编号、总分片数量。在做榜单数据批处理时,我们是按照分页查询的方式:
每个执行器的读取的起始页都是自己的分片编号+1,例如第一个执行器,其起始页就是1,第二个执行器,其起始页就是2,以此类推
然后不是逐页查询,而是有一个页的跨度,跨度值就是分片总数量。例如分了3片,那么跨度就是3
此时,第一个分片处理的数据就是第1、4、7、10、13等几页数据,第二个分片处理的就是第2、5、8、11、14等页的数据,第三个分片处理的就是第3、6、9、12、15等页的数据。
这样就能确保所有数据都会被处理,而且每一个执行器都执行的是不同的数据了。
最后,要确保多个任务的执行顺序,可以利用XXL-JOB中的子任务功能。比如有任务A、B、C,要按照字母顺序依次执行,我们就可以将C设置为B的子任务,再将B设置为A的子任务。然后给A设置一个触发器。
这样,当A触发时,就会依次执行这三个任务了。
面试官:你们是如何保证积分排行榜的实时性的?
答:
面试官追问:那如果数据量很大,所有排行榜数据都放入Redis的一个key,是不是太大了?
答:
面试官追问:那历史排行榜数据量越来越多,全都放入一张表吗?如何处理海量数据存储和查询的高效性?
答:由于数据过多,所以需要对数据做水平拆分,我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:
拆分数据时比较自然,无需做额外处理
查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题
因此我们就不需要用到分库分表的插件了,直接在业务层利用MybatisPlus就可以实现动态表名,动态插入了。简单高效。
面试官追问:那为什么你们没有选择基于开源框架,例如ShardingJDBC来实现分表呢?
答:
问题6:优惠券系统
优惠券的优惠策略设计
优惠券的兑换码算法
优惠券领取的并发安全问题及解决方案
优惠券叠加方案的智能推荐
多商品、多券叠加时的优惠金额计算
多商品订单退款的拆单和退券问题
优惠券管理:
优惠券管理 审核 过期/暂停 限定使用范围
立刻/定时发放 领取期限 使用期限(天数/时间段)
手动领取/指定发放(兑换码) 数量/限领
1:满减,2:每满减,3:折扣,4:无门槛
线程池异步生成兑换码
兑换码算法:24大写字母+8数字 5位二进制表示
自增id=>50位二进制(14校验码+4新鲜值+32自增id)=>十位兑换码
基于BitMap的重兑校验
参考jwt生成兑换码:
密钥+自增id==加密==>签名 ,签名+自增id==Base32转码==>50位兑换码
加密:
payload(4新鲜值(优惠券id)+32序列号)==16组加权码拿一加权求和==>14校验码
payload异或大质数(校验码后四位取16组之一)混淆结果
拼接:14校验码+4新鲜值+32自增id==>50位二进制=BASE32=>转码===>10位密文
解密:
BASE32解密
取高14位==>校验码
取低36位==>payload
payload^校验码后四位取密钥(16组大质数)==>原始payload
原始payload+加权码(优惠券id取)==加强求和==>原始校验码
判断校验码是否相同
优惠券领取:
查询 是否已领/超领 领完
直接领取:
校验优惠券是否存在,不存在无法领取
校验优惠券的发放时间,是不是正在发放中
校验优惠券剩余库存是否充足
校验优惠券的每人限领数量
兑换码领取:
兑换码格式是否正确
兑换码是否已经被兑换过 bitmap
更新库存采用乐观锁issue_num < total_num issue_num++
新增用户券也有线程安全问题 不能用乐观锁(新增)
由于这是针对同用户爆刷的,为了不影响其他用户的效率(乐观锁就够了),所以将查询统计每人已领数量、更新已发数量、新增用户券抽出一个方法,再方法上加锁
使用synchronized(userId.toString.intern())锁同一个用户
Long.toString底层使用new String()所以值一样也是不同对象,而 intern()方法可以从字符串常量池中获取
此时事务边界问题 锁在事务提交之前释放 脏数据再导致并发安全问题
将事务从主方法转移到抽出的方法 方法改为public
此时事务失效 原因非事务方法调用事务方法
spring基于动态代理实现事务管理,非事务方法没走代理对象,调用加了事务的方法调用的是原始对象的方法,所以事务失效
AspectJ @EnableAspectJAutoProxy(exposeProxy= true)注解暴露代理对象
AopContext.currentProxy();获取代理对象再调用
redisson优化:
set lock thread1 NX EX 10
锁误删 阻塞超时释放 使用线程标识
超时释放 判断后准备删除时阻塞
通用分布式锁组件 基于注解和aop
@MyLock注解(name,waitTime,leaseTIme,timeUnit,lockType,lockStrategy)
注意切面类重写顺序order spring事务的aop等级最低
枚举锁类型 可重入/公平锁/读锁/写锁
锁工厂 根据锁类型创建不同类型锁 EnumMap Map<MyLockType, Function<String, RLock>>
策略模式 失败策略枚举
快速失败 快速结束 无线重试 重试超时后结束 重试超时后失败
定义抽象tryLock(RLock lock, MyLock prop)方法 枚举项中重写trylock方法 根据参数列表不同获取不同失败策略的锁 返回获取锁成功与否
SPEL表达式解析用户id
Redis加MQ优化:
MQ异步写优化
Redis缓存优化 hash
发放优惠券时同时初始化redis优惠券缓存,暂停发放时将缓存删除,并同步数据到数据库
由于redis速度快,所以直接把redisson锁加到领券方法上,以优惠券id为锁名
基于lua脚本优化:
优惠券的使用:
优惠券智能推荐 最大优惠叠加券 单券
用券相同时,优惠金额最高的方案
优惠金额相同时,用券最少的方案
策略模式 优惠券规则抽象为一个接口Discount canuse calculateDiscount getRule
四个具体实现
定义策略工厂 map封装
MyLockFactory内部持有了一个Map,key是锁类型枚举,值是创建锁对象的Function。注意这里不是存锁对象,因为锁对象必须是多例的,不同业务用不同锁对象;同一个业务用相同锁对象。
MyLockFactory内部的Map采用了
EnumMap
。只有当Key是枚举类型时可以使用EnumMap
,其底层不是hash表,而是简单的数组。由于枚举项数量固定,因此这个数组长度就等于枚举项个数,然后按照枚举项序号作为角标依次存入数组。这样就能根据枚举项序号作为角标快速定位到数组中的数据。
定义接口
查询用户的优惠券 未使用
初步筛选 跟据总金额是否达到每张券的门槛筛选
细筛 限定范围是否包含可用课程 可用课程总价是否达到门槛
是否限定范围 stream流筛选可用课程
计算总价判断优惠券是否可用
封装到Map<Coupon, List<OrderCourseDTO>>
全排列solutions 回溯算法工具类 最后添加单券
计算优惠明细 (根据每种组合计算叠加优惠)
券叠加算法 商品优惠明细 同一个商品第二次用券基于第一次优惠之后
1)判断优惠券限定范围,找出范围内的课程
2)计算课程总价 (商品价格-该商品的折扣明细)
3)判断券是否可用
4)计算优惠金额 各可用商品累加后用券
5)计算优惠明细
Map<Long, Integer> detailMap
商品优惠明细=优惠总价*总价比例
防止精度丢失最后一个商品优惠金额=总-其它
基于CompleteableFuture做并行计算
CompletableFuture<U> supplyAsync(Supplier<U> supplier,Executor executor)
CountDownLatch 等待全部结束
Collections.synchronizedList线程安全
筛选最优解
用券相同时,优惠金额最高的方案
优惠金额相同时,用券最少的方案
两个map moreDiscount 金额为键 lessCoupon ids字符串为键
遍历solutions
每个solution ids排序转字符串作key
先比较lessCoupon 再比较moreDiscount
最后求交集得最优解 再排序
面试官:你们优惠券支持兑换码的方式是吧,哪兑换码是如何生成的呢?
(请设计一个优惠券兑换码生成方案,可以支持20亿以上的唯一兑换码,兑换码长度不超过10,只能包含字母数字,并且要保证生成和校验算法的高效)
答:
首先要考虑兑换码的验证的高效性,最佳的方案肯定是用自增序列号。因为自增序列号可以借助于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条件不成立,自然也会失败。
这样做借鉴了乐观锁的思想,在线程安全的情况下,保证了并发性能,同时也解决了乐观锁失败率较高的问题,一举多得。
面试官:你做的优惠券功能如何解决券超发的问题?
答:券超发问题常见的有两种场景:
券库存不足导致超发
发券时超过了每个用户限领数量
这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。
首先,针对库存不足导致的超发问题,也就是典型的库存超卖问题,我们可以通过乐观锁来解决。也就是在库存扣减的SQL语句中添加对于库存余量的判断。当然这里不必要求必须与查询到的库存一致,因为这样可能导致库存扣减失败率太高。而是判断库存是否大于0即可,这样既保证了安全,也提高了库存扣减的成功率。
其次,对于用户限领数量超出的问题,我们无法采用乐观锁。因为要判断是否超发,需要先查询用户已领取数量,然后判断有没有超过限领数量,没有超过才会新增一条领取记录。这就导致后续的新增操作会影响超发的判断,只能利用悲观锁将查询已领数量、判断超发、新增领取记录几个操作封装为原子操作。这样才能保证线程的安全。
面试官:那你这里聊到悲观锁,是用什么来实现的呢?
由于在我们项目中,优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下JVM锁失效问题,我们采用了基于Redisson的分布式锁。
(此处面试官可能会追问怎么实现的呢?如果没有追问就自己往下说,不要停)
不过Redisson分布式锁的加锁和释放锁逻辑对业务侵入比较多,因此我就对其做了二次封装(强调是自己做的),利用自定义注解,AOP,以及SPEL表达式实现了基于注解的分布式锁。(面试官可能会问SPEL用来做什么,没问的话就自己说)
我在封装的时候用了工厂模式来选择不同的锁类型,利用了策略模式来选择锁失败重试策略,利用SPEL表达式来实现动态锁名称。
(面试官可能追问锁失败重试的具体策略,没有就自己往下说)
因为获取锁可能会失败嘛,失败后可以重试,也可以不重试。如果重试结束可以直接报错,也可以快速结束。综合来说可能包含5种不同失败重试策略。例如:失败后直接结束、失败后直接抛异常、失败后重试一段时间然后结束、失败后重试一段时间然后抛异常、失败后一直重试。
(面试官如果追问Redisson原理,可以参考黑马的Redis视频中对于Redisson的讲解)
注意,这个回答也可以用作这个面试题:你在项目中用过什么设计模式啊?要学会举一反三。
面试官追问:加了锁以后性能肯定会有下降,如果无法满足当前业务的并发需求,你有什么改进的思路?
答:解决性能问题的办法有很多,针对领券问题,我们可以采用MQ来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。
具体来说,我们可以将优惠券的关键信息缓存到Redis中,用户请求进入后先读取Redis缓存,做好优惠券库存、领取数量的校验,如果校验不通过直接返回失败结果。如果校验通过则通过MQ发送消息,异步去写数据库,然后告诉用户领取成功即可。
当然,前面说的这种办法也存在一个问题,就是可能需要多次与Redis交互。因此还有一种思路就是利用Redis的LUA脚本来编写校验逻辑来代替java编写的校验逻辑。这样就只需要向Redis发一次请求即可完成校验。
面试官:能不能聊一聊你们如何实现优惠券的叠加推荐的?
答:我们的优惠规则是基于策略模式来定义的。在初期做调研的时候也考虑过规则引擎,不过考虑到我们的优惠规则并不复杂,而且规则引擎太重,增加了学习和维护成本,最终选择了基于策略模式来自定义规则。
使用优惠券的订单可能包含多个商品,如果出现部分商品退款的情况,你们如何处理退款金额?优惠券是如何处理的?
答:这里处理的方案有很多种,可以选择退券或不退券。不过基于产品的需求,我们采用的是不退券的方案。
具体来说,就是在一开始下单时,就会根据优惠券本身的使用范围,筛选出订单中可以参与优惠的商品,然后计算出每一个被优惠的商品具体的优惠金额分成,以及对应的实付金额。
而在退款的时候,如果用户选择只退部分商品,我们就可以根据每个商品的实付金额来退款,实现订单拆分退款。同时也满足退款不退券的原则。
当然,如果订单未支付,直接取消或者超时关闭,是可以退还优惠券的。
问题7:支付
支付流程:
用户购物车点下单=>
生成预订单id===>
订单确认页下单====>
校验保存“未支付”订单(唯一id幂等)====>
用户选择支付渠道====>
后端创建支付单,请求返回预支付地址,将业务订单号(bizOrderNo)和支付订单号(payOrderNo)写入支付订单(用bizOrderNo支付订单幂等校验,判断已支付/已关闭/支付渠道)====>
发送延迟任务消息到MQ,在支付超时范围内梯度间隔查询支付状态(超时/已支付)====>
用户扫描支付并收到微信响应结果====>
后台回调接口受到微信通知====>
校验信息并更新支付订单状态(幂等校验: 重复通知(已支付/关闭)、金额、更新订单乐观锁幂等处理(未支付\待提交))====>
MQ消息通知业务方更新业务订单状态====>
通知学习服务添加课程到课表
定时任务检测订单状态:
2分钟一次 分片广播
分页查询待支付订单
校验并处理订单
异常/超时=>关闭
向微信查询支付状态
查询失败/正在支付/支付状态是否变更==>不处理 后面定时任务还会继续处理
支付成功/失败==>更新订单状态
如果支付成功还需发送MQ消息通知业务更新信息
问题8:权限校验
双token三认证 access_token,refresh_token(一次使用,用完即删)
act失效时用rft向后端更新token,校验jti,重新生成rft和act,删除旧的redis中的jti
登录==>获取token()
jwt 存userId roleId rememberMe 签名算法
gateway AccountAuthFilter
登录拦截路径校验 ===解析token=== 校验权限获取用户信息 ===更新请求头放入userinfo=== 校验权限(根据role)
问题9:通用问题
多线程:
- 批量异步线程池发送MQ通知,定时任务课程下架
- 禁用课程分类批量下架课程
- 异步生成兑换码
- 计算优惠券优惠方案
反射:
幂等校验:
幂等性检查、错误处理、并发处理
解决:唯一id,分布式锁,乐观锁
- 视频上传腾讯VOD平台回调结果处理
- 课程上下架幂等处理(校验状态字段,是否已上架,)
- MQ消息重复消费()
- 支付回调通知幂等校验(是否已处理,重复通知;更新订单支付状态,乐观锁判断;同时**添加分布式锁**)
- 退款平台回调通知幂等校验(同时**添加分布式锁**)
面试官:你在项目中有用过线程池吗?
答:很多地方,比如我在实现优惠券的兑换码生成的时候。
当我们在发放优惠券的时候,会判断优惠券的领取方式,我们有基于页面手动领取,基于兑换码兑换领取等多种方式。
如果发现是兑换码领取,则会在发放的同时,生成兑换码。但由于兑换码数量比较多,如果在发放优惠券的同时生成兑换码,业务耗时会比较久。
因此,我们会采用线程池异步生成兑换码的方式。
面试官可能会追问:那你的线程池参数是怎么设置的?
答:线程池的常见参数包括:核心线程、最大线程、队列、线程名称、拒绝策略等。
这里核心线程数我们配置的是2,最大线程数是CPU核数。之所以这么配置是因为发放优惠券并不是高频业务,这里基于线程池做异步处理仅仅是为了减少业务耗时,提高用户体验。所以线程数无需特别高。
队列的大小设置的是200,而拒绝策略采用的是交给调用线程处理的方式。
由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。
面试官:你在项目开发中有没有遇到过什么疑难问题,最后是怎么解决的?
答:我想一下啊,问题肯定是碰到过的。
比如在开发优惠券功能的时候,优惠券有一个发放数量的限制,也就是库存。还有一个用户限量数量的限制,这个是设置优惠券的时候管理员配置的。
因此我们在用户领取优惠券的时候必须做库存校验、限领数量的校验。由于库存和领取数量都需要先查询统计,再做判断。因此在多线程时可能会发生并发安全问题。
其中库存校验其实是更新数据库中的已经发放的数量,因此可以直接基于乐观锁来解决安全问题。但领取数量不行,因为要临时统计当前用户已经领取了多少券,然后才能做判断。只能是采用悲观锁的方案。但是这样会影响性能。
所以为了提高性能,我们必须减少锁的范围。我们就把统计已经领取数量、判断、新增用户领券记录的这部分代码加锁,而且锁的对象是用户id。这样锁的范围就非常小了,业务的并发能力就有一定的提升。
想法是很好的,但是在实际测试的时候,我们发现尽管加了锁,但是还会出现用户超领的现象。比如限领2张,用户可能会领取3张、4张,甚至更多。也就是说并发安全问题并没有解决。
锁本身经过测试,肯定是没有问题的,所以一开始这个问题确实觉得挺诡异的。后来调试的时候发现,偶然发现,有的时候,当一个线程完成了领取记录的保存,另一个线程在统计领券数量时,依然统计不到这条记录。
这个时候猜测应该是数据库的事务隔离导致的,因为我们领取的整个业务外面加了事务,而加锁的是其中的限领数量校验的部分。因此业务结束时,会先释放锁,然后等整个业务结束,才会提交事务。这就导致在某些情况下,一个线程新增了领券记录,释放了锁;而另一个线程获取锁时,前一个线程事务尚未提交,因此读取不到未提交的领券记录。
为了解决这个问题,我们将事务的范围缩小,保证了事务先提交,再释放锁,最终线程安全问题不再发生了。
面试官:你有没有过高并发性能优化的经验?
答:
面试官:你有没有自己设计过一些组件或者工具?
答:
面试官:Spring事务失效的情况碰到过吗?或者知不知道哪些情况会导致事务失效?
答:Spring事务失效的原因有很多,比如说:
事务方法不是public的
非事务方法调用事务方法
事务方法的异常被捕获了
事务方法抛出异常类型不对
事务传播行为使用错误
Bean没有被Spring管理
等等。。
在我们项目中确实有碰到过,我想一想啊。
我记得是在优惠券业务中,一开始我们的优惠券只有一种领取方式,就是发放后展示在页面,让用户手动领取。领取的过程中有各种校验。那时候没碰到什么问题,项目也都正常运行。
后来产品提出了新的需求,要加一个兑换码兑换优惠券的功能。这个功能开发完以后就发现有时候会出现优惠券发放数量跟实际数量对不上的情况,就是实际发放的券总是比设定的要少。一开始一直找不到原因。
后来发现是某些情况下,在领取失败的时候,扣减的优惠券库存没有回滚导致的,也就是事务没有生效。仔细排查后发现,原来是在实现兑换码兑换优惠券的时候,由于很多业务逻辑跟手动领取优惠券很像,所以就把其中的一些数据库操作抽取为一个公共方法,然后在两个业务中都调用。因为所有数据库操作都在这个共享的方法中嘛,所以就把事务注解放到了抽取的方法上。当时没有注意,这恰好就是在非事务方法中调用了事务方法,导致了事务失效。
你在项目中有没有使用到设计模式?
答:当然用到过,比如在优惠券功能中就使用了策略模式来定义优惠规则。还有我实现的基于注解的通用分布式锁组件,也使用到了策略模式、工厂模式
你在项目中有没有使用到线程池或者并发编程?
答:当然,项目中很多地方都有用到。比如在实现优惠券的推荐算法时,我们采用的是排列组合多种优惠方案,然后分别计算,最终筛选出最优解的思路。
由于需要计算的优惠方案可能较多,为了提高计算效率,我们利用了CompletableFuture来实现多方案的并行计算。并且由于要筛选最优解,那就需要等待所有方案都计算完毕,再来筛选。因此就使用了CountdownLatch来做多线程的并行控制。