参考博文:黑马点评项目学习笔记(15w字详解,堪称史上最详细,欢迎收藏)-CSDN博客
【黑马点评】------相关文章汇总(包括实现,优化,测试和面经总结)_黑马点评面经-CSDN博客
一、项目梳理
1、黑马点评导入
此处由于我的mysql的版本问题,将tb_seckill_voucher表中改成下列。
`begin_time` timestamp NULL DEFAULT NULL COMMENT '生效时间', `end_time` timestamp NULL DEFAULT NULL COMMENT '失效时间',

这里我用的原本是jdk22,但报错太多,直接装jdk8就行,省去调试的问题,要不然能调试红温。
然后修改Redissionconfig类里redis的ip,以及配置文件里的信息(数据库密码等信息)
并启动redis,redis版本要在5.0以上
同时修改VoucherOrderServiceImpl,将下列代码注释掉
@PostConstruct private void init() { // SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); //后期需要去掉注释 }
然后启动即可运行(最开始我坚持用jdk22,但跑不通,改成1.8之后就能运行了,建议各位直接用1.8)
运行前端项目
找到nginx相关前端代码文件目录,进入cmd,
start nginx.exe
然后即可去浏览器访问localhost:8080即可
停止nginx用命令
nginx -s quit
优化说明:对于点评项目我做了以下优化,不过这个项目还是太烂大街了就不继续优化了,主要是练手
1、分布式ID使用新的方式优化
2、消息队列采用kafka替代了redis来实现
启动 kafka-server-start.bat ..\..\config\kraft\server.properties (管理员权限)
2、短信登录
1、主要涉及到UserServiceImpl
关键方法发送验证码和登录。
首先是发送验证码,判断手机号是否合法,合法再生成验证码保存到redis,这样做的目的是为了后续登录校验验证码是否正确。
然后是登录,同样先判断手机号是否合法,然后获取验证码,若为空或错误则失败。若成功则获取用户,不存在直接创建新用户,然后将user对象转为dto对象,并生成登录令牌,根据token将对象存储在redis里面,然后返回。
2、优化
问题:在分布式集群环境 中,Web 应用程序的会话是保存在单个服务器上的,当请求不经过该服务器时,会话信息无法被访问。
服务器之间无法实现会话状态的共享。比如在当前这个服务器上用户已经完成了登录,但是在另一个服务器的Session中没有用户信息,无法调用显示没有登录的服务器上的服务
所以这是上述过程使用redis的原因
(存在redis的常量专门用一个类来存放。)增加两个登录拦截器 ,一个是检查是否有token的 ,有的话从redis取token获取用户信息并刷新,这样如果用户频繁访问便会长时间不失效,注意这里无论结果如何都会放行,因为有些页面访问说不需要登录的。
另一个专门判断是否登录,不登录直接不放行(判断依据是能否从threadlocal里获取从redis里面拿的用户信息)。这里会用threadlocal保存用户信息(从redis里拿的),这样该请求后续操作只要想要用户信息直接threadlocal取,更加迅速。(注意不是所有页面都会经过这个拦截器!!)
注意结束后释放threadlocal里的信息。
两个拦截器添加到mvc拦截器表中
3、店铺查询
1、主要方法querybyId、queryTypeList
根据id查询店铺
先去redis查询店铺,如果命中了直接返回。没命中则去数据库里面查,如果数据库里面不存在,则失败,若查到则写入redis并返回。
根据店铺类型查询店铺
跟id查询过程差不多,不一样的是redis里type进行持久化存储,因为更改概率很小,而店铺则设置过期时间。
2、优化------缓存主动更新
采用缓存主动更新 来解决数据一致性问题,选择使用cacheAside模式(写数据库+删缓存) 来减少线程安全问题发生的概率,采用过期淘汰策略+内存淘汰机制(redis自带) 作为兜底方案,同时将缓存和数据库的操作放到同一个事务来保障操作的原子性
updateById
去数据库查是否有该店铺id,若有则更新数据库,然后删除redis缓存。
标@Transactional注解,保证缓存与数据库操作的原子性
另一种主动更新策略:先在缓存里改,把更新操作存储到队列里,然后将多个更新操作发送到数据库进行更改,减少了频繁通信的修改操作,但是一致性较弱,所以不太推荐。
可以用延迟双删优化高并发下的失败问题:如果数据库更新失败?直接返回就行,此时没有不一致问题。
缓存更新失败?失败就失败,第一次成功删第二次失败删概率很低,如果发生了我们可以接受,等后续的缓存更新就好了。如果要引入中间件或消息队列是不好的,因为如果中间件或消息队列失败了或者挂了怎么办,这样问题就陷入死循环,所以有一点非常低概率的问题是没问题的。
如果并发量达到亿级,可以再采用版本号法优化,避免第二次删缓存后的脏数据写入(这个概率已经极低了,基本不会发生,所以一般通常也可以忽略),绝不会出现脏数据覆盖旧数据的问题
3、优化-缓存三件套
缓存穿透解决:
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,好像redis不存在一样。
被动解决方案:布隆过滤器、缓存空对象
主动解决方案:增强id复杂度、加强权限校验、热点参数限流
这里使用缓存空对象方式:
若redis存在则返回,若为空字符串返回不存在,然后就只有null一种可能了。为null的话去数据库查是否存在该店铺,存在则存储在redis里,不存在则缓存空字符串。
缓存雪崩解决:缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
- 不同的Key的TTL添加随机值
- 利用Redis集群、或多级缓存提高服务的可用性
- 给缓存业务添加降级限流策略,比如快速失败机制,让请求尽可能打不到数据库上
这个项目不好搞这种场景,所以不考虑该风险实际实现(可以加一个随机值的ttl,不过太占内存了,性能不好)
缓存击穿解决:也叫热点Key问题,就是一个被高并发访问 并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案:互斥锁/逻辑过期(其实也有锁),我这里用互斥锁
互斥锁过程:
先去缓存中获取店铺id,存在直接返回,不存在则尝试获取锁(setnx),获取失败则休眠一段时间重试(这里重试是重新获取店铺缓存,获取锁成功就去数据库里面查,如果存在就添加到缓存,不存在则缓存空对象,最后释放锁返回店铺。
实际上这里涉及到锁就有bug,如果锁超时释放了怎么办?那就会被另一个线程获取锁,所以需要优化
3、优惠券秒杀
优惠券秒杀这个模块很复杂,是逐步优化的过程,所以我们按照优化思路去分析
3.1 分布式ID取代自增ID
如果是自增ID,若是自增太明显容易被人伪造ID,而且表数据太大的话进行分库分表又无法操作,所以采用分布式ID。具体有多种实现,如UUID,雪花算法,Redis自增,自定义。
这里我们采用自定义分布式ID
使用高32位时间戳+29位自增id+3位随机数实现
我们专门写一个Redisworker工具类来实现,Redis分配一个基于当前时期的key,然后对value自增长,再将其与时间戳结合使用。
java
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
count = count &( (1L << 29) - 1);
long downUuid= RandomUtil.randomInt(7);
// 3.拼接并返回
return (timestamp << COUNT_BITS) | (count<<3)|downUuid;
//32位能用30年,每日序列号可达几亿,低三位uuid增加随机性。
}
3.2 基于分布式ID实现秒杀
首先,系统根据传入的秒杀券ID查询对应的秒杀券信息。然后进行三重合法性校验:判断秒杀开始时间是否已到、结束时间是否未过、以及库存是否充足。如果任何一项校验不通过,则直接返回相应的错误提示。
当秒杀券通过所有校验后,系统会执行库存扣减操作 ,使用数据库的原子性更新语句将库存数量减一。如果扣减失败则抛出异常。库存扣减成功后,系统会生成一个分布式唯一订单ID**,创建订单对象并将订单保存到数据库中**。
如果订单创建成功,系统返回生成的订单ID;如果任何步骤出现异常,整个操作会通过Spring事务管理进行回滚,确保数据一致性。这个流程保证了在高并发场景下,秒杀券的抢购能够安全、有序地进行,避免了超卖等问题。
缺点:高并发下有安全问题,比如超卖,因为在进行扣减操作时先去查库存,然后直接改库存-1了,如果多个线程同时完成查库存这一步但实际库存不够这么多则会出现优惠券数量为负数。
3.3 解决超卖问题
这里我们采用锁来解决,可以使用悲观锁或乐观锁。而这里我们采用CAS的乐观锁来实现,因为它能在保证一定性能的同时保证线程安全,比悲观锁好一些。
更改方法:
秒杀券减1的时候多加一条判断,只有当前库存大于0才可以执行
3.4 解决一人一单问题
如果要实现一人一单,最基础的办法就是在减库存的时候加一个校验,从订单表中查是否为第一单。不过这里同样有并发安全问题,与之前类似,多个线程同时发现没有则都进行订单创建。
解决:使用悲观锁,因为乐观锁需要判断数据是否修改,这里只是查是否存在,所以用悲观锁好。
具体:使用synchronized代码块 加锁,并且使用代理对象调用方法,保证事务不失效
3.5 服务器集群下超卖问题
多个服务器synchronized锁就失效了,需要考虑使用分布式锁
这里采用redis的分布式锁来实现,set key value ex time nx
写一个SimpleRedisLock工具类,提供上锁和释放锁方法
修改代码,初始化工具类对象再尝试调方法获取锁
3.6 Redis分布式锁的超卖问题
如果线程A在释放锁之前被阻塞,导致锁超时释放,那么会造成线程B获取锁,而线程A突然运行又会把B的锁释放掉,最终导致超卖。
解决:使用Lua脚本实现原子性,使用的话调用execute函数来实现,脚本初始化用new DefaultRedisScript<>(),再setLocation设置路径获取lua脚本即可生成对象
3.7 redisson优化分布式锁
因为Redis分布式锁是不可重入锁,而且锁失败没有重试机制,超时释放存在安全隐患,可能业务没执行完就释放,而且有主从一致性问题。
解决:使用redisson
引入依赖,使用配置类配置,然后调用方法即可Redisson分布式锁原理:
如何解决可重入问题:利用hash结构记录线程id和重入次数。
如何解决可重试问题:利用信号量和发布订阅实现等待、唤醒,获取锁失败的重试机制。
如何解决超时续约问题:leasetime为-1时,利用watchDog(默认30秒),每隔一段时间(releaseTime / 3),重置超时时间,直到释放锁的时候才取消更新任务。
如何解决主从一致性问题:利用Redisson的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功(这里没用redis集群,不考虑)
缺陷:运维成本高、实现复杂
3.8 秒杀优化
将一部分的工作交给Redis,并且不能直接去调用Redis,而是通过开启一个独立的子线程去异步执行,从而大大提高效率
库存判断:string类型存优惠券数量
一人一单:set集合存userId
使用lua脚本保证上述操作的原子性
再使用消息队列存储消息,异步线程开启消费
kafka替代stream流实现消息队列
修改过程见文章结尾,实际上这里还用stream也行,毕竟是练手项目,体会一下过程就行
4、达人探店
发布探店笔记
saveBlog(@RequestBody Blog blog)从UserHolder里获取用户id,存到blog然后保存即可
查看探店笔记
queryBlogById(Long id) :根据blogid查,先获取blog再装入用户信息返回
queryHotBlog(Integer current)查看热点笔记,根据点赞数分页查询倒序排列,每个笔记再保存用户信息最后返回。
点赞功能
主要判断用户是否点过赞?
使用redis的set集合存某个笔记的点赞id。如果点过赞了就取消点赞,数据库-1;没点赞就点赞,数据库+1.redis进行用户id的加入与删除
不过还需要修改前面的查询代码,判断当前用户是否点赞并返回。
点赞排行榜
展示点赞前5名用户(按点赞时间排序)
把点赞的set集合改成zset。所以改造前面的代码。
从redis里获取前5的id然后去数据库里查信息并返回用户列表
实际上点赞和关注确实功能层级上不一样,关注是需要表来存的,但是点赞就算了,这个存的意义不大只要保证能判断某个视频该用户点没点赞就行。但如果要实现点赞列表,那实际上还是要在数据库里建一张表存userid和视频id,毕竟抖音是实现了个人的点赞视频的,所以redis的zset方案并不一定唯一,根据需求吧,也可以在数据库建表存。
5、好友关注
关注和取关
是否关注:直接去数据库查,关注id与用户id是否存在这条数据
关注与取关:
根据传来的关注id和是否关注信息判断,关注了就删数据库,没关注就增加一条记录
共同关注
利用redis的set集合的求交集来实现:
所以改造前两个代码,把用户id与对应的关注列表放进Redis里,取关就移除
这里的redis是永久存放实际上不是很好,空间利用率不高,所以可以设置过期时间。代码逻辑变成去redis查,没有就去数据库查
关注推送(feed流)
采用推模式:(如果用户量很大的话就使用推拉模式实现)
拉模式:也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
推模式:也叫做写扩散。
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
saveBlog时进行推送:查询当前用户所用粉丝,然后遍历粉丝列表,向每个粉丝的Zset集合里推送博客id和时间戳。分页查询:关键参数(上一个最小score\offset,也就是最小score有几个)
先获取当前用户然后去redis里面查blog列表,然后存储笔记id列表,并计算最小score与偏移量,然后拿id去数据库查blog的详细信息,由于in查询无序所以用一个orderby,并封装每个blog的用户和点赞信息(因为表中没有)。最后封装笔记列表、下一次查询的最大时间戳、下一次查询的偏移量返回。
6、附近商户:
先执行测试的loadShopData初始化geo。
根据店铺类型查,判断是否传了坐标,没传就直接去数据库查所有的;传了就使用GEO进行查询,把最近的店铺数据再到数据库查出来,然后存入距离返回
7、签到(前端未实现)
sign:签到,使用bitmap记录存入redis里
signCount:签到统计,从redis里取并查询当前月有几天签到
8、UV统计(没有真正实现)
保存用户数据并统计数量
二、所有接口梳理
blogcontroller
***saveBlog:**保存探店笔记,直接根据用户信息存到数据库,并查询粉丝id,对粉丝进行笔记的推送(每一个的zset里添加)
*likeBlog:点赞或取消点赞博客,底层使用zset存储博客对应的点赞用户id,如果没有就点赞,有就取消赞,修改数据库并改redis
queryMyBlog:查询自己的博客,直接去数据库查,分页查询
*queryHotBlog:查询热点笔记,根据点赞数去数据库查,然后补充该笔记的用户信息(用数据库查),再判断当前用户是否点赞存进去(redis看,没有就无)。
queryBlogById:根据博客id查询笔记,跟热点笔记流程类似,查笔记填充用户数据和当前用户是否点赞
*queryBlogLikes:根据博客id获取点赞排行榜前5 ,去redis查点赞的前5名id,再从数据库获取详细的信息返回(按点赞时间排序)
queryBlogByUserId:查询某个用户的博客,直接去数据库查就行
*queryBlogOfFollow:关注者博客feed流查询,利用
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]查询出对应的博客id,然后去数据库查并填充用户和点赞信息,最后封装返回。
followcontroller
*follow:进行关注或取关,如果要关注就更新数据库并存到redis,取关就删redis和数据库。(redis使用set集合)
isFollow:是否关注该用户,直接去数据库查是否有关注
followCommons:共同关注,求集合的交集,不为空就去数据库里面查信息
shopcontroller
*queryShopById:根据id查询店铺,利用了互斥锁解决缓存击穿,空对象解决缓存穿透问题
saveShop:直接保存数据库
updateShop:更新店铺信息,先更新数据库再删缓存
*queryShopByType:根据店铺类型查,判断是否传了坐标,没传就直接去数据库查所有的;传了就使用GEO进行查询,把最近的店铺数据再到数据库查出来,然后存入距离返回(这里geo数据需要提前预热)
queryShopByName:直接去数据库模糊查询
shoptypecontroller
queryTypeList:看redis有没有,没有去数据库查,查完了存进缓存(通常这里长时间不变化,可以不设置过期时间或过期时间很长)
usercontroller
*sendcode:发送验证码,校验并存到redis
*login:校验并检查redis,生成token并将用户信息保存到redis里面
logout:未完成
me:获取当前用户,查threadlocal
info:获取用户详情,查数据库
queryUserById:去数据库查用户
sign:签到,使用bitmap记录存入redis里 (前端未实现)
signCount:签到统计,从redis里取并查询当前月有几天签到 (前端未实现)
vouchercontroller
*addseckillvocher:秒杀券先把详细信息保存到数据库,再把简要信息存到秒杀表用于扣减库存,最后再redis存储用于秒杀
addVoucher:普通优惠券直接保存(默认无限张,实际这个功能也不算完善)
queryOrderOfShop:将秒杀表与优惠券表连接查询返回
voucherordercontroller
*seckillvoucher:秒杀业务关键,逻辑、压测、优化常考点!!
使用lua脚本实现库存扣减,成功后发送订单消息给消息队列(kafka)
消息队列异步生成订单存到数据库。
未完成或未讲解部分:upload\blogcomments
三、面试题:
介绍一下你的这个项目,最大的难点是什么,怎么解决的?
我参与的 "XXXXXX系统" 是一个基于 SpringBoot 的前后端分离项目,主要围绕本地生活服务场景,实现了短信验证码登录、店铺查找、优惠券秒杀、到店点评的业务流程。
其中最大的难点我认为就是优惠券秒杀,这里遇到过超卖问题和一人一单问题,超卖问题是我在进行测试时发现100张优惠券生成了120个左右的订单,这里明显是有问题,后来我进行排查发现原来是库存判断和扣减的非原子性操作,从而导致超卖。发现问题后我便开始解决它,我将库存判断和扣减合并为一条sql语句,就是当扣减库存时判断库存是否大于0,这里其实是类似cas的思想。不过到这里问题依然没解决,我又用单个用户多次下单测试,发现一个用户居然能有5张优惠券,但我这的需求是只能是一人一单,所以我又利用synchronized锁来实现一人一单,锁对象获取的是常量池里的用户Id,保证只能一人一单,最终解决了该问题。最后我又用jmeter进行测试,在qps达到1000的请求下,做到订单的正确生成,并且吞吐量达到了300+,完全解决了该问题。
为什么考虑用redis,我只用mysql行不行?
因为redis比mysql更快,某些条件下能达到十万qps每秒,但mysql一般只有几百上千的qps,所以我这里采用了redis加快了访问。当然我觉得更关键的是业务需求吧,这个项目有些地方是读多写少的场景,比如店铺查询,而且除此之外redis还有更多的数据结构方便我们使用,比mysql实用性更强。
登录校验怎么做的?
登录校验主要是用户根据验证码、手机号登录,涉及的功能有两个,一个是发送验证码,一个是登录。发送验证码的时候会进行手机号校验,再将手机号-验证码存储到redis中再返回验证码。然后就是登录,先看看手机号验证码合不合法,然后从redis取出验证码进行校验,
如果都通过就生成一个token,将token-用户信息存到redis中返回token(存在请求头的Autherization
(关键redis存手机号-验证码,token-用户信息)
登录这部分redis怎么存的?过期策略怎么设置的?
这里在发送验证码部分我是使用手机号-验证码存在redis里,过期时间设置为2分钟,因为通常验证码有效时间都不长,而登录的话是用token-用户信息存储的,过期时间设置为60分钟,通常用户访问的话会进行一个比较长期的操作,所以我们把时间设置长一点。
了解jwt吗?为什么不用它?
jwt也是一种登录校验的凭证,服务器将用户信息存储在jwt里并进行加密,后续用户访问只需要在请求里携带jwt就行了,jwt也有优点,那就是不用在服务器端维护数据,可以节省内存开销,不过它不能实现状态的立即失效,只能等令牌过期。如果想实现服务端对令牌有效期的控制那么依然需要利用redis,使用jwt令牌时去查redis看看有没有被拉黑,而这又跟session效率差不多了,不过session更简单一些所以我这里使用了session,不过如果使用jwt依然能进行登录检验,我觉得两种方案实际上也都可以
为什么用redis存,不直接用session?
session的话是基于会话的,会将信息保存在会话中,只能存在单体架构,如果是分布式架构的话就会出问题,用户在一个服务器登录了另一个没登录。而用redis就解决了这个问题,将用户相关的信息存到redis里,哪台服务器都能获取,不会有问题。
如果用户使用了一段时间,token过期了怎么办?
这里关于登录我还加了两个拦截器,一个拦截器是负责刷新token的,另一个是判断登录状态的。
刷新token的拦截器会将redis里的token-用户信息刷新,并获取用户信息保存到threadlocal里,不过这里无论是什么结果都放行,因为有的页面不需要登录也可以看,所以无需拦截。
第二个拦截器就是拦截未登录用户的,对于匹配的页面进行拦截,threadlocal里没有该用户的话直接拦截。
什么是cacheAside?你怎么实现的功能?(你的缓存与数据库一致性怎么做的)
cacheAside是旁路缓存策略,缓存服务不直接与数据库交互,而是由应用程序来负责协调两者。而在实际应用中我是用cacheaside先操作数据库再删缓存。
这里面其实有一些细节的考虑,读操作都差不多,查不到都要到数据库查并重建缓存。但是写操作有些不同
首先就是删除缓存还是更新缓存,如果是更新缓存,那么如果同时出现一个key的频繁更新可能会导致无效写操作较多,而且更新比删除要慢,所以不推荐,而如果是删除缓存,则更新请求都会打到数据库,对redis影响不大,依然能维持缓存的高效性。
然后就是操作数据库和缓存的顺序,如果先删缓存的话,这时有读操作来的话会读取到旧数据并写入缓存,一致性不好;而后删缓存的话会把这期间写的旧数据删掉,能够保证最终一致性。
除了cacheaside你还了解其他方案吗?为什么选用cacheaside?
还有其他方案,比如读写穿透,我们每次更新时同时更新数据库和缓存,这可以有强一致性但是性能比较差。
还有异步写回,先写缓存再将写操作批量同步到数据库,这样的话会有较低的一致性,不太推荐。
除此之外还有延迟双删策略,它在cacheaside基础上多删了一次缓存,因为如果a线程读缓存时发现没有去数据库读,而b线程去写缓存先去数据库修改然后回来删(当然这时没东西删),这时之前的读操作将读到的旧数据存到redis里,这就造成了不一致性。不过通常这种情况发生的概率极低,因为读是比写快的,所以几乎不发生读旧数据在删缓存后再写回
缓存击穿、雪崩、穿透怎么解决的?
虽然我实现了cacheAside,并采用TTL过期+内存淘汰机制作为兜底方案,但这仍然有一些问题,(主要就是针对读操作)。
首先就是缓存穿透,缓存穿透是指访问的数据在redis和数据库都没找到,导致请求都直接打到数据库上好像缓存不存在一样。针对缓存穿透我们可以用缓存空对象或者布隆过滤器的方式解决,这里我采用缓存空对象的方式实现,如果数据库发现没有数据则缓存里建一个空对象,加上ttl,如果后面有了可以再去数据库取,后续请求直接从缓存里获取这个空对象。
然后就是缓存雪崩,缓存雪崩是指缓存里大量的key同时过期,导致请求都直接打到数据库里,可能会直接把库打崩。不过针对缓存雪崩我们也有一些常见的解决方式,一种是采用redis集群或者采用多级缓存的方案,还要就是可以ttl设置一个随机值。这里的话我采用的就是ttl设置随机值(不过这只是实验,因为是学习项目,主要是理解概念,设置随机值的意义不大,所以后续删掉了)
最后就是缓存击穿,缓存击穿是指许多请求同时访问一个热点key,而这个热点key又突然过期了,无数请求瞬间打到数据库上会给库打崩掉。针对这个问题也有一些常见的解决措施,那就是互斥锁和逻辑过期,互斥锁的话实现较为简单,用时间来换空间,而逻辑过期的话会比较占用内存,是用空间换时间(?用锁还快吗),实际上逻辑过期也用到了锁,所以变复杂了一些。所以我最终采用互斥锁的方式解决。具体过程是这样的:
先去缓存中获取店铺id,存在直接返回,不存在则尝试获取锁,获取失败则休眠一段时间重试,获取锁成功就去数据库里面查,如果存在就添加到缓存,不存在则缓存空对象,最后释放锁返回店铺。
你这个互斥锁怎么实现的?有没有遇到问题?
这里我是用setnx实现的互斥锁,但是我来测试的时候发现如果超时了锁会自动释放从而影响后续获取锁的逻辑,不过这个概率是极低的,而且对于店铺查询这个操作大多时候是幂等的,所以就算多了几个线程来重建也不影响整体业务逻辑。
不过如果实在要解决这个问题的话可以使用redission来实现。
这个店铺查询为什么用redis?redis怎么存的?
我们说redis主要提升查询性能,对于店铺信息这种读多写少的数据非常合适。
在使用redis前我们去数据库查店铺信息这个过程平均要200ms,而引入redis后只有50ms左右,能大幅提升响应效率。
而redis只需要存店铺id-信息就行了。
优惠券秒杀怎么做的,怎么用的锁?
秒杀一开始是先完成一个简单的实现后续再考虑逐步优化的问题。
针对单体架构,一开始的业务是这样的,先判断秒杀券是否合法(时间在有效期内,是否有剩余),然后操作数据库直接减库存,操作成功则创建订单存入数据库里。
不过这种情况下问题比较大,会有超卖问题,因为在多线程环境下会有安全问题,所以这里我使用了乐观锁解决超卖问题,具体实现就是在sql查询里多加一个判断,当库存大于0的时候才去减库存。
不过解决超卖后还有问题,那就是一人一单,前面没有加一人一单的判断,但如果直接加一个去订单表查询是否有该用户的依然有并发安全问题,因为可能多个线程同时发现没有该用户然后下订单,所以这里我采用了悲观锁解决。创建订单前加synchronized锁(锁的是用户Id的Long对象),这样就解决了。
分布式Id怎么实现的?
这里是使用自定义ID实现的,如果是自增ID,若是自增太明显容易被人伪造ID,而且表数据太大的话进行分库分表又无法操作,所以采用分布式ID(具体有多种实现,如UUID,雪花算法,Redis自增,自定义。)
这里我们采用自定义分布式ID
使用高32位时间戳+29位自增id+3位随机数实现
我们专门写一个Redisworker工具类来实现,Redis分配一个基于当前时期的key,然后对value自增长用来实现29位自增id,再将其与时间戳结合使用。最后加一个随机数能更好的防止被人伪造
超卖问题怎么解决的?
使用了乐观锁解决超卖问题,具体实现就是在sql查询里多加一个判断,当库存大于0的时候才去减库存。这里如果使用悲观锁的话性能不好,而乐观锁的话实现简单并且能保证一定的性能
怎么保证每人只下一单?
这里我采用了悲观锁解决,如果直接加一个去订单表查询是否有该用户的依然有并发安全问题,因为可能多个线程同时发现没有该用户然后下订单。具体的方案是创建订单前加synchronized锁(锁的是用户Id的Long对象),这样就解决了。
不过这里仍然有个小缺陷,就是一人一单的事务管理交给了spring去执行,但是在释放锁和事务提交这短暂的时间内仍可能出现脏读,不过这个概率极低所以一般不会发生。
分布式锁怎么实现的,具体怎么做的说说?
分布式锁其实还是针对秒杀问题去设计的,因为在多个服务器上的sychronized锁不一样,所以之前的解决方案只适用于单体架构,所以需要优化成适合分布式架构的方案。
这里我采用了Redis分布式锁实现,因为它能实现原子性,set [key] [value] ex [time] nx一条命令即可上锁,而且性能也很高。
不过redis分布式锁直接加的话会有问题,假如线程a在获取锁后被阻塞导致锁超时释放,那么,线程b又获取锁后,a又继续进行把b的锁释放了,这回导致一连串的连锁反应,显然是不好的,所以我们要加一个线程id的判断再释放。
但是线程id的判断和释放锁无法保证原子性也会出现问题,所以lua脚本编写释放锁的逻辑从而保证释放锁的原子性
(这里最后我们采用了Redission分布式锁,因为它的上锁解锁都是原子性的,提供了超时释放和重试机制)
redission分布式锁怎么实现的。
Redisson分布式锁原理:
如何解决可重入问题:利用hash结构记录线程id和重入次数。
如何解决可重试问题:利用信号量和发布订阅实现等待、唤醒,获取锁失败的重试机制。
如何解决超时续约问题:leasetime为-1时,利用watchDog(默认30秒),每隔一段时间(releaseTime / 3,默认10秒),重置超时时间,直到释放锁的时候才取消更新任务。
怎么实现的异步秒杀?秒杀的redis都存了什么?
这里是使用的消息队列来实现的,通过使用消费者组实现,这里是使用单线程不断读取消息的方式,如果有消息就进行读取,然后发送ack确认消息被消费。
整个的这个秒杀主要有三处使用redis
首先是优惠券库存存储,这里是为了快速减库存用的,使用string类型
然后是用户是否购买优惠券判断,使用set集合判断用户id是否存在
最后就是消息队列,用来让消费者消费消息然后生成订单存到数据库并扣减库存。
秒杀的lua脚本怎么写的?
首先传入订单、优惠券、用户id,然后判断库存是否充足、该用户是否下过单,如果没下过则扣减库存,将下单用户保存到集合里,最后将消息发送到消息队列。
秒杀接口测了没有?性能提升多少?
在没有使用消息队列前,qps1000的情况下压测后吞吐量是在300多,而使用了消息队列后吞吐量达到了400多,提升了30%左右
发表评论(笔记怎么做的)
- 8.2.5 探店点赞
- 对于其他用户发的博客可以进行点赞,开始逻辑为 直接数据库+1
- 问题:用户可以无限点赞,应当为点赞/取消且点赞按钮高亮显示
- 解决:增加isLike属性,标志是否被当前用户点赞;使用redis set集合判断是否点赞过,key为Blog,value为userId
- 问题:检查Redis后到执行数据库更新之间存在时间窗口,可能导致多个请求同时通过检查,造成重复更新。
- 点赞排行榜,显示出最早点赞的几名用户(如TOP5),有唯一性又具备有序性的 zset集合 以时间戳为score存入用户信息 8.2.6 好友关注
- 一张tb_follow表标示关注信息,根据前端传来的id直接更新数据库保存关系信息
- 增加共同关注功能需要求交集,利用set集合,存储用户的关注列表,userId作为key,然后用set集合的api求共同关注的用户id,关注则先存入数据库再更新redis,取关则先删除数据库再删除redis
- 发动态时提醒粉丝,使用 Feed流 完成推送功能,采用TimeLine模式
- 拉模式:查看时拉去所有Blog然后排序展示
- 推模式:博主发送Blog时将信息推送给所有粉丝,就是我们在保存完探店笔记后,获得到当前博主的粉丝,然后把数据推送到粉丝的 redis 中去
- 推拉结合:推送给活跃用户,普通用户拉取
- 问题:因为数据是不断变化的,传统分页会导致展示重复的数据
- 解决:Feed流的滚动分页 8.2.7 附近商户
- redis使用GEO结构存储商家的经纬度以及店铺id,以店铺的typeId为key,将对应的商铺信息存入value 8.2.8 用户签到
- 直接用数据表记录数据量太大
- 解决:使用bitmap中的0,1来记录用户的签到情况
- 吧年和月作为bitMap的key,每次签到把当天对应bitmap位置上的下标从0变为1
- 获取某月的bitmap数据(十进制展示),与1进行与运算看看此位是否为1,从而进行签到统计(签到总数、连续签到)
附录:
kafka实现消息队列(windows版本)
这里我用的是windows版本的kafka,如果用linux需要更改一下命令
无需ZooKeeper,在Windows系统中以Kraft模式安装部署Kafka_wx5af57984ed42e的技术博客_51CTO博客
1、导入依赖
XML
<!-- Spring Kafka 核心依赖 -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- 消息序列化/反序列化(用 Jackson 处理对象与 JSON 转换) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
2、修改配置文件
XML
spring:
kafka:
# Kafka 集群地址(单机为 localhost:9092,集群用逗号分隔)
bootstrap-servers: localhost:9092
# 生产者配置(秒杀服务发送订单消息到 Kafka)
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer # 对象转 JSON
acks: 1 # 消息确认机制(1 表示 leader 接收成功即返回)
retries: 3 # 发送失败重试次数
batch-size: 16384 # 批量发送大小(优化性能)
buffer-memory: 33554432 # 发送缓冲区大小
# 消费者配置(秒杀订单处理服务从 Kafka 消费消息)
consumer:
group-id: seckill-order-group # 消费组 ID(同一组内消息负载均衡,不同组重复消费)
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer # JSON 转对象
auto-offset-reset: earliest # 无偏移量时,从最早消息开始消费
enable-auto-commit: false # 关闭自动提交偏移量(手动提交保证消息不丢失)
max-poll-records: 1 # 每次拉取 1 条消息(秒杀订单需串行处理,避免并发问题)
# JsonDeserializer 配置:信任 VoucherOrder 类(避免反序列化权限问题)
properties:
spring.json.value.default.type: com.hmdp.entity.VoucherOrder
spring.json.trusted.packages: com.hmdp.entity # 替换为 VoucherOrder 实际包名
spring.json.use.type.headers: true # 启用类型头
# 监听容器配置(控制消费者线程、重试机制)
listener:
concurrency: 1 # 消费者线程数(秒杀订单串行处理,设为 1)
ack-mode: MANUAL_IMMEDIATE # 手动立即提交偏移量
retry:
enabled: true # 开启消费重试
max-attempts: 3 # 最大重试次数(重试 3 次失败后放入死信队列)
initial-interval: 1000 # 首次重试间隔 1 秒
multiplier: 2 # 重试间隔倍数(每次重试间隔翻倍:1s → 2s → 4s)
recoverer: org.springframework.kafka.listener.DeadLetterPublishingRecoverer # 死信转发器
3、修改源码
修改seckillvoucher方法
java
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本,发送消息到队列中
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3. 构造订单对象(逻辑不变)
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 4. 发送订单消息到 Kafka
kafkaTemplate.send(SECKILL_ORDER_TOPIC, userId.toString(), voucherOrder);
// 3.返回订单id
return Result.ok(orderId);
}
增加系统常量
java
public static final String SECKILL_ORDER_TOPIC = "seckill-order-topic"; // 秒杀订单主题
public static final String SECKILL_ORDER_DLQ_TOPIC = "seckill-order-dlq-topic"; // 死信队列(处理最终失败的订单)
注入kafka模板
java
@Resource
private KafkaTemplate<String, VoucherOrder> kafkaTemplate; // Kafka 模板(发送消息)
单独新建一个类实现异步处理消息
java
@Slf4j
@Component
public class SeckillOrderConsumer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private VoucherOrderServiceImpl seckillOrderService; // 包含 createVoucherOrder 方法
/**
* 监听秒杀订单主题,处理订单创建
* 失败重试 3 次后,自动转发到死信队列
*/
@KafkaListener(
topics = SECKILL_ORDER_TOPIC, // 监听的主题
groupId = "seckill-order-group" // 消费组(与配置文件一致)
)
public void handleSeckillOrder(ConsumerRecord<String, VoucherOrder> record, Acknowledgment ack) {
try {
// 1. 获取 Kafka 消息中的订单对象
VoucherOrder voucherOrder = record.value();
log.info("接收秒杀订单消息成功");
// 2. 创建订单(原 createVoucherOrder 逻辑不变,需保证幂等性)
seckillOrderService.createVoucherOrder(voucherOrder);
// 3. 手动提交偏移量(确认消息处理成功,避免重复消费)
ack.acknowledge();
} catch (Exception e) {
log.error("处理秒杀订单失败,订单ID:{}", record.value().getId(), e);
// 无需手动处理重试:Spring Kafka 会自动根据配置重试(3次)
// 重试失败后,消息会被转发到死信队列
throw new RuntimeException("订单处理失败,触发重试", e); // 抛出异常触发重试机制
}
}
/**
* 监听死信队列(处理最终失败的订单)
* 可在这里做补偿逻辑:通知用户、恢复库存等
*/
@KafkaListener(
topics = SECKILL_ORDER_DLQ_TOPIC,
groupId = "seckill-order-dlq-group"
)
public void handleDlqOrder(ConsumerRecord<String, VoucherOrder> record) {
VoucherOrder voucherOrder = record.value();
log.error("死信队列接收失败订单:{},进行补偿处理", voucherOrder);
// 补偿逻辑示例:
// 1. 恢复 Redis 中的库存(需保证幂等性)
stringRedisTemplate.opsForValue().increment("seckill:stock:" + voucherOrder.getVoucherId());
// 2. 通知用户订单创建失败(如短信、推送)
// notifyUser(voucherOrder.getUserId(), "秒杀订单创建失败,请重试");
log.info( "----------秒杀订单创建失败,请重试---------------");
}
}
修改秒杀脚本
Lua
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
--local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
--redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
--redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
重试失败死信消息发送配置
java
@Configuration
public class KafkaConfig {
@Resource
private KafkaTemplate<String, VoucherOrder> kafkaTemplate;
// 2. 适配 2.5.x 版本:两参数构造 + setMessageConverter 补全配置
@Bean
public DeadLetterPublishingRecoverer deadLetterPublishingRecoverer() {
// 死信主题解析规则:固定主题 + 原分区一致(便于追溯)
BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition> destinationResolver = (consumerRecord, exception) -> {
return new TopicPartition(SECKILL_ORDER_DLQ_TOPIC, consumerRecord.partition());
};
// 核心:仅用两参数构造(2.5.x 版本 100% 支持,无任何额外方法调用)
return new DeadLetterPublishingRecoverer(kafkaTemplate, destinationResolver);
}
// 3. 手动注册 RecordMessageConverter Bean(解决依赖缺失问题)
@Bean
public RecordMessageConverter recordMessageConverter() {
// 适配 JSON 消息转换(与 JsonSerializer/JsonDeserializer 配套)
return new StringJsonMessageConverter();
}
创建kafka主题
Lua
# 创建秒杀订单主题(1 分区,1 副本,适合串行处理)
kafka-topics.bat --bootstrap-server localhost:9092 --create --topic seckill-order-topic --partitions 1 --replication-factor 1
# 创建死信队列主题
kafka-topics.bat --bootstrap-server localhost:9092 --create --topic seckill-order-dlq-topic --partitions 1 --replication-factor 1