天机学堂面试题

目录

问题1:分布式锁

问题2:视频点播

问题3:视频进度统计

问题4:点赞系统

面试官追问:能详细说说你的Redis数据结构吗?

面试官追问:那你们Redis中具体使用了哪种数据结构呢?

问题5:积分排行榜

问题6:优惠券系统

4.1.超发问题

4.2.锁实现的问题

4.3.性能问题

4.1.你们的优惠券规则是如何编码实现的?

4.2.你在项目中有没有使用到设计模式?

4.3.你在项目中有没有使用到线程池或者并发编程?

4.4.那你能不能聊一聊CountdownLatch的基本原理?

4.5.使用优惠券的订单可能包含多个商品,如果出现部分商品退款的情况,你们如何处理退款金额?优惠券是如何处理的?

问题7:通用问题


问题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管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。

面试官追问:那你们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的那些数据结构啊?

答:很多,比如String、Hash、Set、SortedSet、BitMap等

面试官追问:能不能具体说说使用的场景?

答:比如很多的缓存,我们就使用了String结构来存储。还有点赞功能,我们用了Set结构和SortedSet结构。签到功能,我们用了BitMap结构。

就拿签到来说吧。因为签到数据量非常大嘛,而BitMap则是用bit位来表示签到数据,31bit位就能表示1个月的签到记录,非常节省空间,而且查询效率也比较高。

面试官追问:你使用Redis保存签到记录,那如果Redis宕机怎么办

答:对于Redis的高可用数据安全问题,有很多种方案。

比如:我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。

或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。

当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。

总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看公司的要求来选择。

问题5:积分排行榜

面试官:你在项目中负责积分排行榜功能,说说看你们排行榜怎么设计实现的?

答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。

因为我们的产品设计是每个月为一个赛季,月初清零积分记录,这样学员就有持续的动力去学习。这就有了赛季的概念,因此也就有了当前赛季榜单和历史榜单的区分,其实现思路也不一样。

首先说当前赛季榜单,我们采用了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 ZSet(有序集合) 来维护当前赛季的积分排行榜,保证了实时性。

具体实现:

每当用户获得积分时(如签到、学习等),在 addPointsRecord 方法中,除了将积分记录写入 MySQL,还会同步调用 Redis 的 ZSet.incrementScore() 原子操作,立即累加用户在 ZSet 中的分数:

java 复制代码
  redisTemplate.boundZSetOps(key).incrementScore(userId.toString(), points);
  

ZSet 底层是**跳表(SkipList)**结构,插入和更新的时间复杂度为 O(log N),查询排名的时间复杂度也是 O(log N),天然支持排序。

查询排名时通过 reverseRank() 获取倒序排名,通过 reverseRangeWithScores() 分页获取排行榜列表,都是实时从 Redis 计算的,不存在延迟。

面试官追问:那如果数据量很大,所有排行榜数据都放入Redis的一个key,是不是太大了?

答: 我们没有把所有数据放入一个 key,而是做了两个维度的拆分:

按赛季(月份)拆分 key:Redis key 的格式为 boards:{yyyyMM},例如 boards:202608 表示2026年8月的排行榜。每个月一个独立的 key,数据量被有效分散。

只存 Top 100:数据库表 points_board 中的 rank 字段注释写明"名次,只记录赛季前100"。前端展示也只需要前100名的数据(PointsBoardVO 中 boardList 只展示前100名),所以单个 key 中实际存储的成员数量是可控的。

定期清理:通过定时任务 clearPointsBoardFromRedis 在每月初将上个月的 Redis key 通过 unlink 异步删除,避免内存无限增长。

面试官追问:那历史排行榜数据量越来越多,全都放入一张表吗?如何处理海量数据存储和查询的高效性?

答:

答: 我们采用了按赛季水平分表的策略:

分表规则:历史排行榜数据按赛季 ID 拆分为独立表,表名格式为 points_board_{seasonId},例如 points_board_1、points_board_2。

数据迁移:通过 XXL-JOB 定时任务(PointsBoardPersistentHandler),每月1号凌晨将上个月 Redis 中的排行榜数据持久化到对应的历史分表中。定时任务还利用了分片广播机制,让多台服务器并行处理,不重复不遗漏。

动态表名:查询历史赛季时,通过 MyBatis-Plus 的 DynamicTableNameInnerInterceptor 拦截器 + ThreadLocal 实现动态表名替换,直接路由到对应的分表查询,避免了全表扫描。

当前赛季 vs 历史赛季的查询路径分离:

当前赛季 → 直接查 Redis ZSet(毫秒级响应)

历史赛季 → 查对应的 MySQL 分表(数据量小,查询快)

面试官追问:那为什么你们没有选择基于开源框架,例如ShardingJDBC来实现分表呢?

答: 主要基于以下几点考虑:

分表规则简单且确定:我们的分表维度只有一个------赛季 ID,路由规则非常明确(points_board_{seasonId}),不涉及复杂的多维度分片策略(如按用户 ID 取模 + 按时间范围分片等)。ShardingJDBC 的优势在于处理复杂分片规则,对我们来说属于过度设计。

轻量级实现更合适:我们利用 MyBatis-Plus 自带的 DynamicTableNameInnerInterceptor 拦截器,配合 ThreadLocal 在查询前设置目标表名,只需几行代码就能实现动态表名路由。整个方案侵入性小、易于理解和维护。

避免引入额外依赖和复杂度:ShardingJDBC 作为一个完整的分库分表框架,会引入额外的配置复杂度、SQL 解析开销以及与现有框架(如 MyBatis-Plus)的兼容性问题。对于只有几张分表的简单场景,维护成本大于收益。

业务场景不同:ShardingJDBC 主要解决的是单表数据量过大导致的读写性能瓶颈,而我们的分表更多是出于数据归档和生命周期管理的目的------当前赛季数据在 Redis 中热存储,历史赛季按月归档到独立表中,查询频率低,数据量可控。

总结来说:技术方案选型要匹配业务复杂度,杀鸡不用牛刀。

问题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,而拒绝策略采用的是交给调用线程处理的方式。

由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。

面试官:能不能讲一讲你说的优惠券兑换算法?

第一层:先讲整体流程(30秒概括)

"我们的兑换码方案大致分三块:

第一是兑换码的生成------后台管理员创建优惠券活动时,系统会异步批量生成兑换码,每个兑换码本质上是一个自增序列号经过编码加密后得到的10位字符串。

第二是用户兑换时的校验------用户输入兑换码后,我们先做解码校验它的合法性,然后通过 Redis Lua 脚本原子性地判断这个码有没有被用过、库存够不够、用户有没有超领取上限,校验通过就标记为已使用。

第三是异步落库------Lua 脚本通过后,发一条 MQ 消息,消费者在事务里完成扣库存、写用户券记录、更新兑换码状态这些数据库操作。

这样做的好处是,前置校验全在 Redis 里完成,不直接打到数据库,吞吐量很高;后续的持久化通过 MQ 异步解耦,响应也很快。"

第二层:面试官追问"兑换码是怎么生成的"

"兑换码的生成算法是我们自己设计的,核心思路是把一个递增序列号编码成一个不可伪造的短字符串。

序列号是通过 Redis 的 INCRBY 生成的,比如一张优惠券要发1000张,我就一次性 INCRBY 1000,拿到这批序列号的起始值,然后本地循环编码就行,不需要每次都请求 Redis。

编码过程大概是这样的:

先用优惠券 ID 的低4位算一个新鲜值,相当于给每张优惠券打个标记

把新鲜值和序列号拼接成一个36位的载荷

对载荷做加权哈希------就是把载荷每4位一组,乘以一个预置的质数表里的权值,累加后取模,得到一个14位的校验码

再用校验码的低4位去选一个异或密钥,对载荷做异或混淆------这样别人就算拿到了兑换码也没法反推出原始序列号

最后把校验码和混淆后的载荷拼成一个50位的二进制数,用 Base32 编码转成10位字符串

解码的时候反过来就行------先 Base32 解码,再逆异或还原载荷,重新算一遍校验码对比,一致就说明是合法兑换码。"

第三层:面试官追问"并发怎么保证安全的"

"兑换的并发安全主要靠三层保障:

第一层是 Redis Lua 脚本。判断兑换码是否已使用、库存是否充足、用户是否超限,这些操作全部在一个 Lua 脚本里原子完成,不会有并发问题。我们用了一个 Bitmap 来记录兑换码的使用状态,SETBIT 操作本身就是原子的。

第二层是 MQ 串行化消费。Lua 脚本通过后不是直接操作数据库,而是发一条消息到 RabbitMQ,消费者拿到消息后在 @Transactional 事务里做数据库操作。这样数据库的压力是可控的,而且消费失败还能重试。

第三层是数据库的乐观兜底。更新库存用的是 incrIssueNumById,本质是 SQL 层面的 SET issue_num = issue_num + 1 WHERE issue_num < total_num,即使前面有漏网之鱼,数据库也能兜住超发。"

第四层:面试官可能追问的加分点

"为什么不用 UUID 或者数据库自增 ID 做兑换码?"

"UUID 太长,用户输入不友好,而且无序;数据库自增 ID 太容易被猜测,别人拿到一个兑换码就能推算出其他的。我们的方案最终只有10位字符,用户好输入,而且经过异或混淆后,从外部完全看不出规律。"

"如果 Redis 挂了怎么办?"

"兑换码的生成是本地计算,不依赖 Redis 实时请求(序列号是一次性批量拿的)。兑换时的校验确实依赖 Redis,如果 Redis 不可用,可以直接降级到数据库校验------查兑换码表的状态字段,只是性能会下降,但功能不受影响。"

"加权码表和异或密钥表是怎么来的?"

"是项目启动时预置在代码里的静态常量数组,16组质数表对应新鲜值的16种取值,异或密钥表也是16个大质数。这些不需要动态生成,硬编码在工具类里就行。"

总结口诀(面试前默念)

生成:Redis 拿序列号 → 拼载荷 → 算校验码 → 异或混淆 → Base32 编码 兑换:解码校验 → Lua 原子判断 → MQ 异步 → 事务落库 安全:Lua 原子校验 + MQ 串行化 + DB 乐观兜底

面试官:你是如何避免优惠券的超发的(你是如何避免商品库存超卖的?)?

答:超发、超卖问题往往是由于多线程的并发访问导致的。所以解决这个问题的手段就是加锁。可以采用悲观锁,也可以采用乐观锁。

如果并发量不是特别高,就使用悲观锁就可以了。不过性能会受到一定的影响。

如果并发相对较高,对性能有要求,那就可以选择使用乐观锁。

当然,乐观锁也有自己的问题,就是多线程竞争时,失败率比较高的问题。并行访问的N个线程只会有一个线程成功,其它都会失败。

所以,针对这个问题,再结合库存问题的特殊性,我们不一定要是有版本号或者CAS机制实现乐观锁。而是改进为在where条件中加上一个对库存的判断即可。

比如,在where条件中除了优惠券id以外,加上库存必须大于购买数量的条件。这样如果库存不足,where条件不成立,自然也会失败。

这样做借鉴了乐观锁的思想,在线程安全的情况下,保证了并发性能,同时也解决了乐观锁失败率较高的问题,一举多得。

面试官追问:加了锁以后性能肯定会有下降,如果无法满足当前业务的并发需求,你有什么改进的思路?

问题1:加了锁以后性能下降,无法满足并发需求,有什么改进思路?

提高并发的手段有很多,我们项目中也确实做了多轮优化迭代,大致可以分几个层面来讲:

第一层:减小锁粒度

最开始我们用 synchronized 锁整个方法,粒度太粗了。后来改成只锁关键的"查-增-改"操作块,而且锁的维度是按用户 ID 加锁------不同用户之间不会互相阻塞,只有同一用户的高并发请求才会串行化。

第二层:从 JVM 锁升级为分布式锁

synchronized 只能保证单节点的线程安全,多实例部署下就不行了。后来我们引入了 Redisson 分布式锁,通过 Redis 的 SETNX + 看门狗机制实现可重入、自动续期的分布式锁,既保证了多节点的安全,又避免了死锁。

第三层:用 Lua 脚本替代锁(最终方案)

其实不管是 JVM 锁还是分布式锁,本质上都是悲观锁,请求需要排队等待,吞吐量有上限。我们最终的方案是用 Redis Lua 脚本替代锁。

比如领取优惠券的场景,我们把"判断库存 → 判断用户领取上限 → 扣减库存 → 记录用户领取数"这一系列操作全部写在一个 Lua 脚本里,Redis 单线程执行 Lua 脚本天然就是原子的,不需要加锁,也不存在并发问题。

这样做的好处是:

没有锁竞争,不用排队等锁

一次网络请求就完成了所有校验和操作,减少了网络往返

性能比加锁方案提升了数倍

第四层:MQ 异步削峰

即使 Lua 脚本很快,后续的数据库持久化操作(写用户券记录、更新优惠券发放数量等)还是会拖慢响应。所以我们把整个流程拆成两段:

同步段:Lua 脚本在 Redis 中完成校验和预扣减,毫秒级返回

异步段:通过 RabbitMQ 发送消息,消费者在事务中慢慢写数据库

这样用户感知到的响应时间就是 Lua 脚本的执行时间,数据库的压力也被 MQ 削峰了。

第五层:如果还不够,还可以考虑

数据库层面:用乐观锁(UPDATE ... WHERE issue_num < total_num)替代悲观锁,减少锁等待

缓存预热:把优惠券信息提前加载到 Redis Hash 中,避免每次查数据库

限流降级:在网关层对领券接口做限流,保护后端服务不被打垮

面试官:能不能聊一聊你们如何实现优惠券的叠加推荐的?

问题2:你们如何实现优惠券的叠加推荐?

我们的优惠券叠加推荐是在用户预下单时触发的,核心目标是:从用户持有的所有可用优惠券中,找出最优的券组合方案,让用户的优惠力度最大。

整体流程分为六步:

第一步:查询用户可用券

用户进入预下单页面时,订单服务通过 Feign 调用促销服务,查询该用户所有未使用且在有效期内的优惠券。

第二步:初筛------按门槛过滤

先计算订单总价,然后用策略模式根据优惠券类型(满减、折扣、每满减、无门槛)分别调用对应的 canUse 方法,过滤掉门槛不满足的券。

比如订单总价100元,满200的券就直接被过滤掉了。

第三步:细筛------确定每张券的可用课程

有些优惠券是限定范围的(比如只适用于某个课程分类),有些是通用的。我们会遍历每张券,查出它的适用范围,确定它能用于订单中的哪些课程。

最终得到一个 Map<Coupon, List<Course>> 的映射关系------每张券对应它能优惠的课程列表。

第四步:全排列 + 多线程并行计算

这是核心部分。由于优惠券的使用顺序不同,最终优惠金额可能不同(比如先用满减券再用折扣券,和反过来用,结果不一样),所以我们需要枚举所有可能的排列组合。

具体做法是用回溯算法(PermuteUtil)对筛选后的优惠券做全排列,同时把每张单券也加入方案集合。

假设有3张券,全排列就有 6 种组合 + 3 种单券 = 9 种方案。

由于方案数量可能较多,我们用了 CompletableFuture + 自定义线程池 并行计算每种方案的优惠明细:

每个方案独立计算:按排列顺序依次使用优惠券,每张券在剩余可优惠课程上计算折扣

用 CountDownLatch 等待所有方案计算完成,设置了 2 秒超时兜底

第五步:计算每种方案的优惠明细

对于每种方案,按顺序遍历优惠券:

取出该券可用的课程,计算扣除前面券已优惠部分后的剩余总价

判断剩余总价是否满足该券的门槛

满足则计算优惠金额,并按比例分摊到每门课程的优惠明细中(最后一门课程兜底,确保总额精确)

更新优惠明细映射,供下一张券使用

第六步:选出最优方案

最优方案的评判规则是:

优惠金额优先------相同券组合下,选优惠最高的排列顺序

用券数量其次------优惠金额相同时,选用券最少的方案(减少浪费)

具体实现是用两个 Map 分别记录这两个维度的最优解,最后取交集,就是最终的最优方案集合,按优惠金额倒序排列返回给前端展示。

面试加分点(面试官追问时补充)

"为什么用全排列而不是组合?"

因为优惠券的使用顺序会影响最终优惠金额。比如一张"满200减50"和一张"打8折",先用满减再用折扣,与先用折扣再用满减,最终价格是不同的。所以必须考虑排列顺序。

"如果优惠券数量很多,全排列会不会爆炸?"

确实,n 张券的全排列是 n!,增长很快。但实际业务中,用户一次订单的课程数量有限,可用券也有限(一般不超过5~6张),所以排列数是可控的。而且我们在初筛和细筛阶段已经过滤掉了大量不可用的券,进一步减少了排列规模。如果券真的很多,可以考虑剪枝------比如提前跳过明显不优的排列,或者限制最大搜索方案数。

"优惠金额分摊到课程明细时为什么最后一个课程兜底?"

因为优惠金额是按课程价格占比分配的,分配过程中整除会产生精度损失。如果每门课都按比例算,最后可能出现分摊总额和实际优惠金额对不上的情况。所以把最后一门课程作为"兜底项",用总优惠金额减去前面已分配的金额,保证总额精确

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

答:Spring事务失效的原因有很多,比如说:

  • 事务方法不是public的

  • 非事务方法调用事务方法

  • 事务方法的异常被捕获了

  • 事务方法抛出异常类型不对

  • 事务传播行为使用错误

  • Bean没有被Spring管理

等等。。

在我们项目中确实有碰到过,我想一想啊。

我记得是在优惠券业务中,一开始我们的优惠券只有一种领取方式,就是发放后展示在页面,让用户手动领取。领取的过程中有各种校验。那时候没碰到什么问题,项目也都正常运行。

后来产品提出了新的需求,要加一个兑换码兑换优惠券的功能。这个功能开发完以后就发现有时候会出现优惠券发放数量跟实际数量对不上的情况,就是实际发放的券总是比设定的要少。一开始一直找不到原因。

后来发现是某些情况下,在领取失败的时候,扣减的优惠券库存没有回滚导致的,也就是事务没有生效。仔细排查后发现,原来是在实现兑换码兑换优惠券的时候,由于很多业务逻辑跟手动领取优惠券很像,所以就把其中的一些数据库操作抽取为一个公共方法,然后在两个业务中都调用。因为所有数据库操作都在这个共享的方法中嘛,所以就把事务注解放到了抽取的方法上。当时没有注意,这恰好就是在非事务方法中调用了事务方法,导致了事务失效。

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

答:我想一下啊,问题肯定是碰到过的。

比如在开发优惠券功能的时候,优惠券有一个发放数量的限制,也就是库存。还有一个用户限量数量的限制,这个是设置优惠券的时候管理员配置的。

因此我们在用户领取优惠券的时候必须做库存校验、限领数量的校验。由于库存和领取数量都需要先查询统计,再做判断。因此在多线程时可能会发生并发安全问题。

其中库存校验其实是更新数据库中的已经发放的数量,因此可以直接基于乐观锁来解决安全问题。但领取数量不行,因为要临时统计当前用户已经领取了多少券,然后才能做判断。只能是采用悲观锁的方案。但是这样会影响性能。

所以为了提高性能,我们必须减少锁的范围。我们就把统计已经领取数量、判断、新增用户领券记录的这部分代码加锁,而且锁的对象是用户id。这样锁的范围就非常小了,业务的并发能力就有一定的提升。

想法是很好的,但是在实际测试的时候,我们发现尽管加了锁,但是还会出现用户超领的现象。比如限领2张,用户可能会领取3张、4张,甚至更多。也就是说并发安全问题并没有解决。

锁本身经过测试,肯定是没有问题的,所以一开始这个问题确实觉得挺诡异的。后来调试的时候发现,偶然发现,有的时候,当一个线程完成了领取记录的保存,另一个线程在统计领券数量时,依然统计不到这条记录。

这个时候猜测应该是数据库的事务隔离导致的,因为我们领取的整个业务外面加了事务,而加锁的是其中的限领数量校验的部分。因此业务结束时,会先释放锁,然后等整个业务结束,才会提交事务。这就导致在某些情况下,一个线程新增了领券记录,释放了锁;而另一个线程获取锁时,前一个线程事务尚未提交,因此读取不到未提交的领券记录。

为了解决这个问题,我们将事务的范围缩小,保证了事务先提交,再释放锁,最终线程安全问题不再发生了。

4.1.超发问题

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

答:券超发问题常见的有两种场景:

  • 券库存不足导致超发

  • 发券时超过了每个用户限领数量

这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。

首先,针对库存不足导致的超发问题,也就是典型的库存超卖问题,我们可以通过乐观锁来解决。也就是在库存扣减的SQL语句中添加对于库存余量的判断。当然这里不必要求必须与查询到的库存一致,因为这样可能导致库存扣减失败率太高。而是判断库存是否大于0即可,这样既保证了安全,也提高了库存扣减的成功率。

其次,对于用户限领数量超出的问题,我们无法采用乐观锁。因为要判断是否超发,需要先查询用户已领取数量,然后判断有没有超过限领数量,没有超过才会新增一条领取记录。这就导致后续的新增操作会影响超发的判断,只能利用悲观锁将查询已领数量、判断超发、新增领取记录几个操作封装为原子操作。这样才能保证线程的安全。

4.2.锁实现的问题

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

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

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

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

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

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

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

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

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

4.3.性能问题

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

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

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

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

4.1.你们的优惠券规则是如何编码实现的?

答:我们的优惠规则是基于策略模式来定义的。在初期做调研的时候也考虑过规则引擎,不过考虑到我们的优惠规则并不复杂,而且规则引擎太重,增加了学习和维护成本,最终选择了基于策略模式来自定义规则。

4.2.你在项目中有没有使用到设计模式?

答:当然用到过,比如在优惠券功能中就使用了策略模式来定义优惠规则。还有我实现的基于注解的通用分布式锁组件,也使用到了策略模式、工厂模式

4.3.你在项目中有没有使用到线程池或者并发编程?

答:当然,项目中很多地方都有用到。比如在实现优惠券的推荐算法时,我们采用的是排列组合多种优惠方案,然后分别计算,最终筛选出最优解的思路。

由于需要计算的优惠方案可能较多,为了提高计算效率,我们利用了CompletableFuture来实现多方案的并行计算。并且由于要筛选最优解,那就需要等待所有方案都计算完毕,再来筛选。因此就使用了CountdownLatch来做多线程的并行控制。

4.4.那你能不能聊一聊CountdownLatch的基本原理?

略,参考面试宝典

4.5.使用优惠券的订单可能包含多个商品,如果出现部分商品退款的情况,你们如何处理退款金额?优惠券是如何处理的?

答:这里处理的方案有很多种,可以选择退券或不退券。不过基于产品的需求,我们采用的是不退券的方案。

具体来说,就是在一开始下单时,就会根据优惠券本身的使用范围,筛选出订单中可以参与优惠的商品,然后计算出每一个被优惠的商品具体的优惠金额分成,以及对应的实付金额。

而在退款的时候,如果用户选择只退部分商品,我们就可以根据每个商品的实付金额来退款,实现订单拆分退款。同时也满足退款不退券的原则。

当然,如果订单未支付,直接取消或者超时关闭,是可以退还优惠券的。

问题7:通用问题

面试官:你在项目中有用过 线程池 吗?

用过的,我们项目中多个场景都用到了线程池,而且根据不同业务场景做了差异化配置。

场景一:优惠券方案并行计算

在预下单时,需要计算用户持有优惠券的最优组合方案。由于优惠券的使用顺序不同,最终优惠金额可能不同,所以需要对筛选后的优惠券做全排列,然后逐个计算每种方案的优惠金额。

排列方案可能比较多,串行计算太慢,所以我们用了一个核心线程数为12的自定义线程池,配合 CompletableFuture 并行计算所有方案,用 CountDownLatch 等待结果,设置了2秒超时兜底。这样原本可能需要几百毫秒的计算,压缩到了几十毫秒。

场景二:MQ 异步发送消息

我们封装了一个 RabbitMqHelper 工具类,内部维护了一个线程池(核心10、最大15),用于异步发送 MQ 消息,不阻塞主线程。比如领券成功后写用户券记录、课程完结后通知等场景,都是通过这个线程池异步发消息。

场景三:登录记录异步写入

用户登录时需要记录登录日志(IP、时间等),这个操作不影响主流程,所以用了一个核心20、最大40的线程池异步写入数据库。拒绝策略用的是 DiscardPolicy(丢弃策略),因为登录记录丢了也没关系,不能影响登录主流程。

场景四:学习记录延迟持久化

用户看视频时,每隔一段时间会上报播放进度。如果每次都写数据库,高并发下压力太大。所以我们用了 Redis 缓存 + DelayQueue 延迟队列 + 线程池的组合方案:

播放进度先写 Redis

同时向 DelayQueue 提交一个延迟任务

延迟到期后,由线程池中的线程对比 Redis 中的进度是否还在变化

如果没变化(说明用户离开了),才持久化到数据库

关于线程池参数配置的思路:

CPU 密集型任务(如方案计算):核心线程数接近 CPU 核心数

IO 密集型任务(如数据库写入、MQ 发送):核心线程数设置为 CPU 核心数的 2~4 倍

非关键任务(如登录日志):用丢弃策略,避免阻塞主流程

关键任务(如 MQ 发送):用 CallerRunsPolicy,保证消息不丢

面试官:你在项目开发中有没有遇到过什么疑难问题,最后是怎么解决的?

有,我印象比较深的是优惠券领取的并发安全问题。

问题现象:

在压测领券接口时,发现同一个用户在并发请求下,领取到的优惠券数量超过了限领数量。比如用户限领1张,但并发下领到了2~3张。

排查过程:

首先定位到问题出在 checkAndCreateUserCoupon 方法中,"查询已领取数量 → 判断是否超限 → 保存领取记录"这三步操作不是原子的。两个请求同时查到"已领取0张",都通过了校验,就导致了超领。

解决过程(迭代了三版):

第一版:synchronized 本地锁 用 synchronized (user.toString().intern()) 按用户 ID 加锁,保证同一用户的请求串行化。但问题是:项目是多实例部署的,JVM 锁只能保证单节点的线程安全,多节点下还是会出问题。

第二版:Redisson 分布式锁 改用 Redisson 的 RLock,以用户 ID 为维度加分布式锁。解决了多节点的问题,但引入了新问题------锁的粒度、超时时间、事务的先后顺序都需要仔细调整。如果先开事务再获取锁,事务提交时锁可能已经释放了;如果锁超时了但事务还没提交,也会出现并发问题。

第三版(最终方案):Redis Lua 脚本 把"判断库存 → 判断用户领取上限 → 扣减库存 → 记录用户领取数"这一系列操作全部写在一个 Lua 脚本中,利用 Redis 单线程执行 Lua 的原子性来替代锁。同时后续的数据库持久化通过 MQ 异步完成。这样既没有锁竞争,也不存在并发问题,性能也是最好的。

收获:

这次经历让我深刻理解了并发编程中**"先判断后执行"**模式的陷阱,也学会了根据业务场景选择合适的并发控制手段------能用原子操作就不用锁,能用乐观方案就不用悲观方案。

面试官:你有没有过高并发性能优化的经验?

有的,我们项目中有几个典型的高并发优化场景。

场景一:学习记录上报优化

问题:用户看视频时每隔几秒上报一次播放进度,如果每次都写数据库,高峰期数据库扛不住。

优化:

播放进度先写 Redis 缓存(毫秒级响应)

通过 DelayQueue 延迟队列检测用户是否离开

只有用户离开后才做一次数据库持久化

从"每几秒写一次DB"优化为"每个视频只写一次DB",数据库写入量下降了90%以上

场景二:优惠券领取的异步化

问题:领券接口同步执行"校验 → 写记录 → 扣库存",响应慢、吞吐低。

优化:

前置校验全部在 Redis 中通过 Lua 脚本完成(毫秒级)

后续持久化通过 MQ 异步完成

接口响应时间从几百毫秒降到几十毫秒,吞吐量提升数倍

场景三:积分排行榜的读写分离

问题:积分排行榜需要实时展示,如果每次都从数据库查,性能差。

优化:

当前赛季排行榜用 Redis ZSet 存储,查询排名 O(log N),实时更新 O(log N)

历史赛季数据按月分表,通过定时任务从 Redis 迁移到 MySQL

前端展示只取 Top 100,数据量可控

场景四:优惠券方案的多线程并行计算

问题:预下单时需要枚举优惠券的所有排列组合,逐个计算优惠金额,方案多时串行计算慢。

优化:用 CompletableFuture + 自定义线程池并行计算所有方案,配合 CountDownLatch 设置2秒超时,把计算时间从几百毫秒压缩到几十毫秒。

面试官:你有没有自己设计过一些组件或者工具?

有的,我在项目中设计或参与设计了几个比较实用的组件。

  1. 分布式锁注解 @MyLock

项目中封装了一个自定义注解 @MyLock,配合 AOP 切面实现声明式分布式锁。使用时只需要在方法上加一个注解:

java 复制代码
 @MyLock(name = "lock:coupon:uid:#{user}", myLockType = RE_ENTRANT_LOCK)

支持 SpEL 表达式动态解析锁名称(比如从方法参数中取用户 ID)、多种锁类型(可重入锁、公平锁、读写锁)、多种锁策略(失败重试、超时放弃等)。

后来这个组件被抽到了 tj-common 公共模块中,其他服务也能直接用。

  1. 兑换码生成算法工具 CodeUtil

设计了一套兑换码编码方案:递增序列号 → 拼接新鲜值 → 加权校验码 → 异或混淆 → Base32 编码。最终生成10位字符串,不可伪造、不可逆推,同时支持解码校验。

  1. 延迟任务处理器 LearningRecordDelayTaskHandler

封装了一个基于 JDK DelayQueue 的延迟任务组件,用于学习记录的异步持久化。核心设计:

自定义 DelayTask 实现 Delayed 接口,支持泛型数据携带

通过 @PostConstruct 启动后台监听线程

通过 @PreDestroy 优雅关闭

用线程池提交到期任务的处理逻辑

这个组件把"高频写"优化为"离开时才写",大大降低了数据库压力。

  1. MQ 消息工具 RabbitMqHelper

封装了 RabbitMQ 的发送逻辑,支持:

同步/异步发送

延迟消息发送(通过 DelayedMessageProcessor 设置 x-delay 头)

自动绑定消息 ID(用于消息追踪和去重)

内置线程池实现异步发送,不阻塞业务线程

这些都是先在一个业务模块中实现验证,然后抽取到公共模块供所有服务复用的,体现了从业务中来,到框架中去的设计思路。

相关推荐
Web极客码1 小时前
用 Python 搭建工具调用 Agent 的调试过程
java·服务器·前端
我命由我123451 小时前
Android 控件 - CardView(快速实现圆角)
android·java·java-ee·kotlin·android studio·android-studio·android runtime
2601_963869951 小时前
【计算机毕业设计】基于Java的潮牌购物网站系统的设计与实现
java·开发语言·课程设计
203号居民1 小时前
LeetCode hot 100 —560. 和为 K 的子数组
java·算法·leetcode
浪客川2 小时前
使用 IDEA 生成API文档
java·ide·intellij-idea
会编程的土豆2 小时前
从「锁」到「时间」:后端里几个容易混的概念
java·开发语言·数据库·goland
上下求索,莫负韶华2 小时前
笔记-数据库事务和java事务和切面
java·数据库·笔记
@小匠3 小时前
Spring Boot Nacos绑定 Map 时中文 key 导致启动失败:一次从复现到源码的排查实录
java·开发语言·前端
Knight_AL3 小时前
Lombok @Builder 踩坑:build() 前后对象类型不一样
android·java·开发语言