项目《天机学堂》

简介: 天机学堂是一个非学历职业技能在线培训平台,核心业务为售卖课程并提供学习辅助与交互功能。技术栈涵盖SpringBoot、Redis、RabbitMQ等。本人负责需求分析、数据库设计及通用工具封装,如基于Redisson实现分布式锁组件,支持注解式加锁、锁类型切换与限流;并参与开发高性能视频进度记录系统,通过缓存+异步持久化方案实现秒级精度回放,有效降低数据库压力。

1.天机学堂项目说明

项目说明

天机学堂是一个在线的非学历职业技能培训平台,核心业务是以售卖各种技能培训的在线课程,并提供丰富的学习辅助功能、交互功能,以提升用户学习时的氛围感和学习的积极性。

技术栈

SpringBoot、SpringCloud、Mybatis、MySQL、Redis、Redisson、Caffeine、RabbitMQ、XXL-JOB、腾讯云VOD(视频点播)、Nginx等

工作职责

  • 负责部分业务的需求分析、数据库表设计,以及通用工具的封装设计。例如开发了基于Redisson的分布式锁组件,零代码侵入,基于注解实现加锁、锁类型切换、加锁策略切换、SPEL的动态锁名、限流等功能,大大提高了开发效率。
  • 参与设计和开发了学习计划和学习进度统计功能。设计了一套高性能的视频播放进度记录系统,在不增加数据库压力的情况下,使视频播放进度回放精度达到秒级
  • 对评价系统中的点赞功能进行了系统重构:重新设计了点赞数据结构,解除了与业务的耦合,成为了一个通用的点赞系统。基于Redis实现点赞记录和点赞数缓存,同时基于定时任务做数据持久化,大大提高了点赞系统的并发能力,减轻了数据库压力。
  • 参与了积分排行榜功能的设计和开发。对用户的各种学习和交互行为做积分统计,例如签到积分、学习积分、问答积分等;并且按月累计积分形成赛季排行榜。做到了当前赛季排行榜的实时更新、历史赛季排行榜的分表持久保存。
  • 参与了优惠券系统的设计与开发。优惠券系统支持基于兑换码兑换优惠券,我设计了一套可以支持20亿量级的并且可以脱离数据库做高效校验的兑换码生成算法。同时也对领券功能进行了并发安全、并发性能的优化设计。另外还设计实现了优惠券叠加方案推荐算法,基于用户购物车中商品推荐最佳的优惠券叠加方案。

工作职责部分选择其中的2~3个填即可,不要全写。

2.可迁移的技术方案

天机学堂中包含的技术和解决方案有:

  • 基于自定义注解和Redisson的分布式锁工具
  • XXL-JOB分布式任务调度工具
  • Caffeine本地缓存工具
  • 支持可靠消息、延迟消息的RabbitMQ工具
  • 延迟队列DelayQueue
  • 基于CompletableFuture和CountDownLatch的并发任务处理方案
  • 高并发高精度的视频进度记录和回放解决方案
  • 学习计划和学习进度统计的学习监督方案
  • 通用的问答(评论)功能实现方案
  • 通用、高性能的点赞系统解决方案
  • 高性能、低存储成本的签到解决方案
  • 实时性强、通用性好的积分排行榜、历史排行榜解决方案
  • 支持大数据量、高性能校验的优惠券兑换码算法
  • 基于LUA脚本的高性能、并发安全的优惠券领取解决方案(秒杀解决方案)
  • 优惠券叠加的智能推荐算法(MapReduce的思想)
  • 基于Redis合并写请求并基于定时任务异步持久化的并发优化方案
  • 基于Redis和MQ的异步写优化方案
  • 基于腾讯VOD的视频加密、视频点播、视频审核、视频雪碧图功能(已实现未讲解)
  • 包含支付宝支付、微信支付的多平台支付系统(已实现未讲解)
  • 订单退款拆单处理方案(已实现未讲解)

3.可能碰到的面试题:

问题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的集合,而SISMEMBER只能一次判断一个业务的点赞状态,要判断多个业务的点赞状态,就必须多次调用SISMEMBER命令,与Redis多次交互,这显然是不合适的。(此处略停顿,等待面试官追问,面试官可能会问“那你们怎么解决的”。如果没追问,自己接着说),所以呢我们就采用了Pipeline管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。

面试官追问:那你ZSET干什么用的?

答:严格来说ZSET并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。
因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。
每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。
并且ZSET的zopmin指令可以在获取数据同时删除数据,避免高并发下的脏写问题

面试官追问(可能会,没追问就自己说):那为什么一定要用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:积分排行榜

面试官:你项目中使用过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触发时,就会依次执行这三个任务了。

问题6:优惠券系统

面试官:你们优惠券支持兑换码的方式是吧,那兑换码是如何生成的呢?(请设计一个优惠券兑换码生成方案,可以支持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验证兑换状态,是否兑换过即可。
整个验证过程完全不用访问数据库,效率非常高。

面试官:你在项目中哪些地方用到过线程池?

答:很多地方,比如我在实现优惠券的兑换码生成的时候。
当我们在发放优惠券的时候,会判断优惠券的领取方式,我们有基于页面手动领取,基于兑换码兑换领取等多种方式。
如果发现是兑换码领取,则会在发放的同时,生成兑换码。但由于兑换码数量比较多,如果在发放优惠券的同时生成兑换码,业务耗时会比较久。
因此,我们会采用线程池异步生成兑换码的方式。

面试官可能会追问:那你的线程池参数是怎么设置的?

答:线程池的常见参数包括:核心线程、最大线程、队列、线程名称、拒绝策略等。
这里核心线程数我们配置的是2,最大线程数是CPU核数。之所以这么配置是因为发放优惠券并不是高频业务,这里基于线程池做异步处理仅仅是为了减少业务耗时,提高用户体验。所以线程数无需特别高。
队列的大小设置的是200,而拒绝策略采用的是交给调用线程处理的方式。
由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。

面试官:如何解决优惠券的超发问题?

答:超发、超卖问题往往是由于多线程的并发访问导致的。所以解决这个问题的手段就是加锁。可以采用悲观锁,也可以采用乐观锁。
如果并发量不是特别高,就使用悲观锁就可以了。不过性能会受到一定的影响。
如果并发相对较高,对性能有要求,那就可以选择使用乐观锁。
当然,乐观锁也有自己的问题,就是多线程竞争时,失败率比较高的问题。并行访问的N个线程只会有一个线程成功,其它都会失败。
所以,针对这个问题,再结合库存问题的特殊性,我们不一定要是有版本号或者CAS机制实现乐观锁。而是改进为在where条件中加上一个对库存的判断即可。
比如,在where条件中除了优惠券id以外,加上库存必须大于购买数量的条件。这样如果库存不足,where条件不成立,自然也会失败。
这样做借鉴了乐观锁的思想,在线程安全的情况下,保证了并发性能,同时也解决了乐观锁失败率较高的问题,一举多得。

面试官:Spring事务失效的情况碰到过吗?或者知不知道哪些情况会导致事务失效?

答:Spring事务失效的原因有很多,比如说:
- 事务方法不是public的
- 非事务方法调用事务方法
- 事务方法的异常被捕获了
- 事务方法抛出异常类型不对
- 事务传播行为使用错误
- Bean没有被Spring管理
等等。。
在我们项目中确实有碰到过,我想一想啊。
我记得是在优惠券业务中,一开始我们的优惠券只有一种领取方式,就是发放后展示在页面,让用户手动领取。领取的过程中有各种校验。那时候没碰到什么问题,项目也都正常运行。
后来产品提出了新的需求,要加一个兑换码兑换优惠券的功能。这个功能开发完以后就发现有时候会出现优惠券发放数量跟实际数量对不上的情况,就是实际发放的券总是比设定的要少。一开始一直找不到原因。
后来发现是某些情况下,在领取失败的时候,扣减的优惠券库存没有回滚导致的,也就是事务没有生效。自习排查后发现,原来是在实现兑换码兑换优惠券的时候,由于很多业务逻辑跟手动领取优惠券很像,所以就把其中的一些数据库操作抽取为一个公共方法,然后在两个业务中都调用。因为所有数据库操作都在这个共享的方法中嘛,所以就把事务注解放到了抽取的方法上。当时没有注意,这恰好就是在非事务方法中调用了事务方法,导致了事务失效。

面试官:在开发中碰到过什么疑难问题,最后是怎么解决的?

答:我想一下啊,问题肯定是碰到过的。
比如在开发优惠券功能的时候,优惠券有一个发放数量的限制,也就是库存。还有一个用户限量数量的限制,这个是设置优惠券的时候管理员配置的。
因此我们在用户领取优惠券的时候必须做库存校验、限领数量的校验。由于库存和领取数量都需要先查询统计,再做判断。因此在多线程时可能会发生并发安全问题。
其中库存校验其实是更新数据库中的已经发放的数量,因此可以直接基于乐观锁来解决安全问题。但领取数量不行,因为要临时统计当前用户已经领取了多少券,然后才能做判断。只能是采用悲观锁的方案。但是这样会影响性能。
所以为了提高性能,我们必须减少锁的范围。我们就把统计已经领取数量、判断、新增用户领券记录的这部分代码加锁,而且锁的对象是用户id。这样锁的范围就非常小了,业务的并发能力就有一定的提升。
想法是很好的,但是在实际测试的时候,我们发现尽管加了锁,但是还会出现用户超领的现象。比如限领2张,用户可能会领取3张、4张,甚至更多。也就是说并发安全问题并没有解决。
锁本身经过测试,肯定是没有问题的,所以一开始这个问题确实觉得挺诡异的。后来调试的时候发现,偶然发现,有的时候,当一个线程完成了领取记录的保存,另一个线程在统计领券数量时,依然统计不到这条记录。
这个时候猜测应该是数据库的事务隔离导致的,因为我们领取的整个业务外面加了事务,而加锁的是其中的限领数量校验的部分。因此业务结束时,会先释放锁,然后等整个业务结束,才会提交事务。这就导致在某些情况下,一个线程新增了领券记录,释放了锁;而另一个线程获取锁时,前一个线程事务尚未提交,因此读取不到未提交的领券记录。
为了解决这个问题,我们将事务的范围缩小,保证了事务先提交,再释放锁,最终线程安全问题不再发生了。


超发问题

面试官:你做的优惠券功能如何解决券超发的问题?

答:券超发问题常见的有两种场景:
● 券库存不足导致超发
● 发券时超过了每个用户限领数量
这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。
首先,针对库存不足导致的超发问题,也就是典型的库存超卖问题,我们可以通过乐观锁来解决。也就是在库存扣减的SQL语句中添加对于库存余量的判断。当然这里不必要求必须与查询到的库存一致,因为这样可能导致库存扣减失败率太高。而是判断库存是否大于0即可,这样既保证了安全,也提高了库存扣减的成功率。
其次,对于用户限领数量超出的问题,我们无法采用乐观锁。因为要判断是否超发,需要先查询用户已领取数量,然后判断有没有超过限领数量,没有超过才会新增一条领取记录。这就导致后续的新增操作会影响超发的判断,只能利用悲观锁将查询已领数量、判断超发、新增领取记录几个操作封装为原子操作。这样才能保证线程的安全。

问题7:锁实现的问题

面试官:那你这里聊到悲观锁,是用什么来实现的呢?

由于在我们项目中,优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下JVM锁失效问题,我们采用了基于Redisson的分布式锁。

(此处面试官可能会追问怎么实现的呢?如果没有追问就自己往下说,不要停)

不过Redisson分布式锁的加锁和释放锁逻辑对业务侵入比较多,因此就对其做了二次封装(强调是自己做的),利用自定义注解AOP,以及SPEL表达式实现了基于注解的分布式锁。(面试官可能会问SPEL用来做什么,没问的话就自己说)

我在封装的时候用了工厂模式来选择不同的锁类型,利用了策略模式来选择锁失败重试策略,利用SPEL表达式来实现动态锁名称。

(面试官可能追问锁失败重试的具体策略,没有就自己往下说)

因为获取锁可能会失败嘛,失败后可以重试,也可以不重试。如果重试结束可以直接报错,也可以快速结束。综合来说可能包含5种不同失败重试策略。例如:失败后直接结束、失败后直接抛异常、失败后重试一段时间然后结束、失败后重试一段时间然后抛异常、失败后一直重试。

(面试官如果追问Redisson原理,可以参考黑马的Redis视频中对于Redisson的讲解)

注意,这个回答也可以用作这个面试题:你在项目中用过什么设计模式啊?要学会举一反三。

问题8:性能问题

面试官:加锁以后性能会比较差,有什么好的办法吗?

答:解决性能问题的办法有很多,针对领券问题,我们可以采用MQ来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。

具体来说,我们可以将优惠券的关键信息缓存到Redis中,用户请求进入后先读取Redis缓存,做好优惠券库存、领取数量的校验,如果校验不通过直接返回失败结果。如果校验通过则通过MQ发送消息,异步去写数据库,然后告诉用户领取成功即可。

当然,前面说的这种办法也存在一个问题,就是可能需要多次与Redis交互。因此还有一种思路就是利用Redis的LUA脚本来编写校验逻辑来代替java编写的校验逻辑。这样就只需要向Redis发一次请求即可完成校验。

【优惠券模块】

嗯,关于优惠券这一块我先大概说一下他的整个业务流程吧

首先是我们的后台管理人员可以在后台的管理系统去新增优惠券。我们的优惠券分为三种类型:一种是满减优惠券、一种是折扣优惠券、还有一种是无门槛优惠券。

像我们的满减和折扣优惠券在发放之后,用户是可以在首页直接点击领取的。但是无门槛优惠券呢,则是通过生成兑换码,然后由我们的陪诊师私下去分发给用户的。其实这个也很好理解,就是说如果我们用户直接能在首页领取到无门槛优惠券的话,用户可能只会认为是你标注的价格虚高,并不能很好的起到一个优惠券的促销效果。而通过陪诊师私下发放给用户的话呢,其实能更好的达到一个拉拢客户、增加用户粘性的效果。

用户领取完优惠券,在预下单的界面就会自动推荐一个最优的优惠券组合方案给到我们的用户。关于这一块是这样子的,我们刚刚不是提到说我们的优惠券一共有三种类型嘛,像满减和折扣优惠券它是互斥的,就是说这两种优惠券用户在一次下单中最多能使用一张,但是在这个基础上是可以去叠加一张无门槛优惠券的。相当于我们这个推荐方案就是在用户的有效满减和折扣优惠券中,选择一张满足优惠门槛的最大金额的优惠券,加上一张优惠金额最大的无门槛优惠券。

如果说用户在下单之后发生了退款行为的话,优惠券也是会进行退还的,但是如果在退款时优惠券已经到达了过期时间,优惠券就会直接更新为已过期状态,无法继续使用的。

关于优惠券模块的大概一个业务流程就是这样子

【难点描述】

在优惠券这个模块呢,我觉得主要涉及到有两个难点,一个就是兑换码的生成算法,还有一个是我们的一个高并发场景下的一个超买或者超领的问题。不知道面试官您对哪个更感兴趣一些?

【--- 兑换码生成算法】

因为兑换码是需要用户去手动录入的嘛,所以当时产品经理给到我的需求就是要可读性好并且不能太长。所以我们最终采用一个字符长度为10,由24个大写字母+8个数字组成的方案,其中是排除了容易产生混淆的OI和01。

因为32刚好是2的5次方,所以我们选用了Base32作为我们的字符编码方案,每5个bit位对应一个字符。所以兑换码的总长度就是5*10,50个bit位。

除了可读性以外,兑换码最重要的特征就是需要具有唯一性,因为我们又是分布式微服务架构,所以兑换码应该是全局唯一的。像我们比较常用的生成全局唯一id的算法有UUID、雪花算法、和redis自增id。但是由于UUID和雪花算法他的一个二进制长度远远超出了我们的需求长度,所以最终是选择使用redis的自增id作为生成兑换码的方式。

不过自增id具有明显的规律性,肯定是不能直接转换为兑换码的,要不然会存在一个爆刷风险。在这里我们参考了JWT的工作原理,JWT它是对载荷进行加盐加密,然后通过签名去验证token。我们这边是使用数组作为密钥,对自增id进行加权加密,然后将得到的签名与自增id进行拼接,再使用base32编码得到最终的兑换码。

在进行兑换码验证的时候,只需要用同一组密钥再次进行加密,然后验证得到的签名是否一致就可以了。由于我们的密钥用户是不可知的,这就有效地避免了爆刷的风险。

关于兑换码的生成算法大概就是这么一个逻辑。

【--- 高并发超领超卖】

在用户领取优惠券的功能中,其实是存在两个可能发生线程安全的问题。一个是优惠券库存超卖问题,另一个是用户超领问题,因为我们在新增优惠券的时候是会设置用户的限领数量的。

其实优惠券库存超卖问题还比较好解决,我们这里是基于乐观锁的思想去解决这个问题的。因为在优惠券的数据库表中,设置有总发放数量和已领取数量两个字段。只需要在更新已领取数量时通过where条件对这两个字段进行比较判断,如果说已领取数量大于等于总发放数量的话,就表示当前优惠券已没有可领取的数量了,这样就解决了优惠券超卖的问题。

除了库存超卖问题,还有一个用户超领问题。这个就相对复杂一些,因为用户领取优惠券是一个新增操作,不能使用乐观锁去解决这个问题,又因为我们是分布式微服务架构,所以最后是使用了redission分布式锁去解决这个问题的,锁对象就是领取优惠券用户的userId。

但是在这个地方还是踩了比较多的一个坑的,主要就是一个事务失效和锁失效的问题。因为一开始所有代码都是放在同一个方法中的,又因为涉及到了多个表格的操作,所以方法是添加了@transactional注解进行事务管理。但是后面我使用Jmeter工具进行压测,发现还是会出现单人超领的问题。后面我就通过查阅资料,发现了这其实是由于在事务内部去使用锁导致的一个锁失效问题。因为MySQL它的一个默认的隔离级别是可重复读,所以当事务开启后,它对其他事务的修改是不可见的。这就导致了线程获取锁后,查询到的还是原来的数据,所以依旧会重复执行新增操作,这就导致了单人超领的问题。后面我是将需要进行事务管理的代码抽取成一个独立的方法,在内层方法上进行事务管理,对外层方法中调用内层方法的代码进行加锁,解决了这个问题。

但其实除了这个问题,还有一个事务失效的问题,当时在自测阶段我是没有留意到的,是后面提测的时候测试那边发现的。其实这个问题还挺好解决,主要就是一个非事务方法调用本类的事务方法导致的事务失效的问题,网上也有比较详细的解决方案,就是可以通过AspectJ去暴露代理对象,通过代理对象去调用内层方法。

当时这个需求真的做得我人都晕了,好不容易解决了一个超领超卖的问题,结果这又失效那又失效的,总之感觉哪哪不得劲。但是也感觉收获满满吧,对超卖问题的解决,对事务失效这一块的理解又更深了一些。

相关文章
|
13天前
|
数据采集 人工智能 安全
|
8天前
|
编解码 人工智能 自然语言处理
⚽阿里云百炼通义万相 2.6 视频生成玩法手册
通义万相Wan 2.6是全球首个支持角色扮演的AI视频生成模型,可基于参考视频形象与音色生成多角色合拍、多镜头叙事的15秒长视频,实现声画同步、智能分镜,适用于影视创作、营销展示等场景。
652 4
|
8天前
|
机器学习/深度学习 人工智能 前端开发
构建AI智能体:七十、小树成林,聚沙成塔:随机森林与大模型的协同进化
随机森林是一种基于决策树的集成学习算法,通过构建多棵决策树并结合它们的预测结果来提高准确性和稳定性。其核心思想包括两个随机性:Bootstrap采样(每棵树使用不同的训练子集)和特征随机选择(每棵树分裂时只考虑部分特征)。这种方法能有效处理大规模高维数据,避免过拟合,并评估特征重要性。随机森林的超参数如树的数量、最大深度等可通过网格搜索优化。该算法兼具强大预测能力和工程化优势,是机器学习中的常用基础模型。
350 164
|
7天前
|
机器学习/深度学习 自然语言处理 机器人
阿里云百炼大模型赋能|打造企业级电话智能体与智能呼叫中心完整方案
畅信达基于阿里云百炼大模型推出MVB2000V5智能呼叫中心方案,融合LLM与MRCP+WebSocket技术,实现语音识别率超95%、低延迟交互。通过电话智能体与座席助手协同,自动化处理80%咨询,降本增效显著,适配金融、电商、医疗等多行业场景。
359 155