这个项目还是要好好打磨,因为网上资料多,比较透明,而且也看了好长时间了,还有待发掘的点。这里就快速再重新梳理一下项目,然后引出其中需要熟悉的八股
一.概述
该项目集商户发布优惠、用户打卡探店等功能于一体,采用多级存储架构,包括本地缓存、Redis和MySQL,实现了用户登录、下单购物、优惠券秒杀、笔记发布和点赞的功能。
- 登录模块:使用Redis实现黑名单,解决Session共享问题,通过双重拦截器校验和刷新token,实现会话保持。
- 缓存模块:采用双写模式保持缓存一致性,通过布隆过滤器解决缓存穿透问题,互斥锁方案解决缓存击穿问题,
- 优惠券模块:
- 点赞关注模块:
二.登录模块
登录模块主要涉及两个内容:用户登录和保存登录状态。
用户登录很好理解,主要就是校验用户的账号密码;
由于HTTP是无状态协议,这意味着每次请求都是独立的,要想维持会话功能,就需要在服务器额外实现一套机制来实现会话功能。
1.验证码登录
验证码登录分为两个步骤:发送验证码和登录校验。
1.发送验证码:手机号校验通过后,服务器调用相关接口(例如由腾讯提供的验证码接口)发送并获取验证码,然后将验证码存放到redis中,同时设置过期时间,用于校验时比对。
redis中验证码的存储:string数据类型,key为code+手机号,value为验证码
2.首次登录校验:用户登录向后端发送手机号和验证码,后台取出redis中的验证码进行比对,如果不通过则会拦截,通过校验后需要向数据库中查询该用户是否存在,如果不存在则创建新用户,并保存到数据库。无论用户是否存在,都会将用户信息保存到redis中并设置有效期,最后返回token。
将用户信息保存到redis中的具体做法为:使用UUID生成一个随机字符串作为token(令牌),用户信息(如userid:123,username:Tom)则转化为字符串,将这两个信息以key-value的形式存储到redis中。
2.会话保持
会话保持即保存登录状态,同一个客户端发送多条请求时,服务器能够识别出这些请求来自同一用户。
通过用户的首次登录,后台已经返回了token,在后续的请求中,客户端会把token携带在HTTP头部的authorization
字段中。
这里使用双重拦截器校验和刷新。
第一个拦截器拦截所有访问路径,如果请求头部中authorization
字段携带了合法token,则刷新redis中该token的过期时间,并保存用户信息到Threadlocal
中。其他情况全部放行由第二个拦截器处理。此拦截器的作用为,如果用户长时间访问不需要登陆权限路径,也能会话保持。而且对于已经登录的用户,可能他长时间停留在不需要登录的页面,但如果突然点到需要登录的请求,发现请求过期了!也会有不好的体验,这个也是给已经登录的用户token续期的手段!!
第二个拦截器对需要登陆权限的路径生效,如果此时Threadlocal已经保存了用户信息则直接放行,否则进行拦截。此拦截器的作用是防止未登录用户访问需要登陆权限路径的内容。也就是拦截一切需要登录的相关请求
3.黑名单机制
为了防止有人恶意登录,大量发送无效验证码,那么就会可能给服务器带来压力,同时增加公司的开销
因此需要对请求中的手机号获取验证码进行次数限制。在我们的日常使用中,一般一分钟只能获取一次验证码,这里的实现思路很简单,可以称为使用锁的思想。规则是:限制十分钟内最多发送三次验证码,超过三次则拉入黑名单,24小时后从黑名单中移除。 具体流程如下:
- 检查号码是否在黑名单里面,在的话就禁止请求
- 查看请求频率(也就是次数)如果要超过三次也禁止请求,并拉入黑名单
- 十分钟后,计数的键自动过期,24h后,黑名单的键自动过期
这里需要两个key存储,均用String类型,key为 登录请求频率 + 手机号,值为 请求频率,设置十分钟过期,如果十分钟内有新的请求过来,重新设置过期时间为十分钟;key为 登录黑名单 + 手机号,值为手机号,设置24h过期
4.总结
登录模块主要分为三个部分,主要包括登录功能、会话保持、黑名单功能。
直接吟唱前面的部分,重点突出①redis存储token,基于token获取用户信息,这样做是为了保证用户信息不直接在网络中传输,保护隐私;②实现会话保持使用双重拦截器;③黑名单功能指的是将频繁获取验证码的手机号一段时间内不能再获取验证码,黑名单使用Redis实现
5.自测
拦截器和过滤器的区别是什么?为什么要用拦截器不使用过滤器?如果同时配置了过滤器和拦截器,哪个先执行,哪个后执行?
过滤器工作在 Servlet 容器层面,处理所有进入 Servlet 容器的请求。过滤器在更底层工作,它会处理更多的请求(包括静态资源、JSP、WebSocket 请求等)。过滤器适用于需要在整个应用范围内进行预处理和后处理的场景,如安全检查、日志记录、编码设置等。
拦截器工作在 Spring MVC 框架层面,主要处理控制器(Controller)的方法调用。拦截器在过滤器之后执行。拦截器只处理与 Spring MVC 控制器相关的请求,在处理范围上更小。拦截器适用于只需要在处理 Spring MVC 控制器请求时进行预处理和后处理的场景,如业务逻辑处理、数据预处理、视图处理等。
为什么要使用双重拦截器,只用一个拦截器不行吗?这两个拦截器的作用分别是什么?
第一个拦截器中拦截所有的路径,获取请求头中的token,使用token查询redis中存储的用户信息,将这个信息保存到threadlocal中,同时刷新token的有效期(更新redis中的过期时间),然后放行。如果这个请求中不包含token则直接放行,交由第二个拦截器处理。
第二个拦截器只拦截部分需要登录权限的路径,比如查询购物车、查询历史订单这种路径,拦截到相应的请求后会查询threadlocal中是否存在用户信息,如果不存在用户信息则直接拦截并返回401未授权状态码,如果用户信息存在于threadlocal中,则说明第一个拦截器已经进行了身份校验并拿到了合法的用户信息,则直接放行。
对于未登录的用户,只有访问登录路径和不需要登录授权的路径(比如浏览商品信息、浏览商户信息)才能够经过以上两个拦截器,否则均会被拦截并返回401。
使用两个拦截器的原因:这两个拦截器分别拦截不同的路径,第一个拦截器主要用于刷新token的有效期,第二个拦截器主要用于对授权路径进行鉴权。如果只使用一个拦截器,那么必须规定相同的拦截路径,如果拦截所有路径则会导致部分业务如浏览商品页面也需要登录才可以浏览,如果只拦截部分路径则会导致用户一直浏览商品信息,而token过期的情况。
什么是threadlocal?什么情况需要用到threadlocal?把用户信息存到Threadlocal中会有什么问题?你怎么解决这个问题?
当一个共享变量是共享的,但是需要每个线程互不影响,相互隔离,就可以使用ThreadLocal:跨层传递信息,隔离线程存储一些线程不安全的工具对象(SimpleDataFormat),Spring中的事务管理器
ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。可以将ThreadLocal理解为对外暴露的,用于操作ThreadLocalMap的工具类,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
如果在线程池中使用ThreadLocal会造成内存泄漏。因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象。线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法清除Entry对象
三、商户信息缓存
1.缓存更新策略
为了维护商户信息在Redis中和数据库中的数据一致性,这里采取主动更新 + 超时剔除兜底
主动更新采取旁路缓存模式——先更新数据库,再删除缓存的模式。
2.缓存穿透
产生原因:要查询的数据在缓存和数据库中都没有
对应场景:可能会有攻击者恶意构造大量不存在的商铺id进行查询。
解决方案:布隆过滤器,缓存null值。这里选择布隆过滤器。缓存null值可能会占用很多的内存
布隆过滤器的原理其实非常简单,就是bitmap + 多重hash,主要优势就是利用非常小的空间就可以实现在大规模数据下快速判断某一对象是否存在,缺点是存在误判的可能,但不会漏判,也就是存在的对象一定会判断为存在,而不存在的对象会有较低的概率为误判为存在,且不支持对象的删除,因为会增加误判的概率。
具体实现:
Redis 实现布隆过滤器的底层就是通过 bitmap 这种数据结构,至于如何实现,这里就不重复造轮子了,介绍业界比较好用的一个客户端工具——Redisson。Redisson 是用于在 Java 程序中操作 Redis 的库,利用Redisson 我们可以在程序中轻松地使用 Redis。
下面我们就通过 Redisson 来构造布隆过滤器。
1 | /** |
通过redisson创建布隆过滤器,然后初始化预期插入数量和错误比率即可。
redisson中的BloomFilter有2个核心方法:
- bloomFilter.add(orderId) 向布隆过滤器中添加id
- bloomFilter.contains(orderId) 判断id是否存在
查询流程如下:
而添加商铺信息的执行流程则是:
在添加商铺信息到数据库的同时,也要添加到布隆过滤器
3.缓存击穿
产生原因:热点key突然失效,此时如果大量请求突然过来,会全部打到数据库上。失效原因一般是过期了
对应场景:热门商铺正在搞活动,然后这时热点key突然失效,可能就会导致商铺信息页面加载不出来
TODO
解决方案:永不过期(逻辑过期),互斥锁更新。这里采用互斥锁更新,因为商铺信息可能变化比较快,如果采用逻辑过期内存占用大不说,而且数据一致性难以保持。虽然采用互斥锁在高并发情况下可能会有点问题,但后期可以补一个限流(TODO)
具体实现流程:
4.缓存雪崩
产生原因:大量缓存同时失效,或者Redis宕机
解决方案:
针对大量缓存同时失效:
- 给缓存设置随机TTL,避免同时过期
- 数据永不过期+更新机制。对高频访问的热点数据设置为永不过期,并通过后台任务或消息队列监听数据库变化,异步更新缓存。
- 限流与降级。在缓存失效或服务不可用时,通过限流(如令牌桶、漏桶算法)限制请求量,或降级返回默认数据(如静态页面、历史数据),减少数据库压力。
- 预加载(缓存预热)。系统启动时或定期将热点数据预加载到缓存中,避免运行时集中失效。 xxl-job。
针对Redis宕机:
- Redis集群。使用Redis集群、主从复制或哨兵模式,确保缓存服务宕机时能快速切换到备用节点,避免整体失效。
- 多级缓存。引入多级缓存(如本地缓存 + 分布式缓存)。当分布式缓存(如Redis)失效时,先尝试从本地缓存(如Caffeine、Guava)读取,降低对数据库的直接冲击。
TODO
这里可以优化的点很多。比如限流与降级+预加载+多级缓存。这些是可以尝试实现的点
5.总结
主要是对缓存的使用中可能出现的问题。数据一致性+缓存三件套
6.自测
- 为什么要用redis做一层缓存,相比直接查mysql有什么优势?
- 如何保证redis和mysql的数据一致性?延迟双删
- 如何保证缓存与数据库的操作的同时成功或失败?事务相关的八股,很多内容
- 缓存穿透、缓存击穿、缓存雪崩,什么是缓存xx?如何解决这个问题?这几个解决方案各自的优势和缺点分别是什么?
- 缓存三兄弟的其他问法:①现在有一个场景,假如有一个key即将过期了,但是此时有100万个请求访问存入这个key的数据,这种情况该怎么办(击穿)?②现在有一个场景,假如有大量的key同时过期,但是这些key的访问频率很高,一瞬间会给数据库造成过大压力,该怎么办(雪崩)?③现在有一个场景,有大量的请求进来,访问一个并不存在的key,且这个数据也不存在于数据库中,该怎么办(穿透)?
四、优惠券模块
业务梳理
1.分布式ID:随着时间增长,订单ID可能会超出表限制,如果增加了服务器机器,可能需要分库分表,此时再用自增ID就无法满足生成的主键是唯一的了。所以需要分布式ID!
2.优惠券分为普通优惠券和秒杀优惠券,普通优惠券无购买限制,但受库存数量限制;秒杀优惠券在普通优惠券的限制基础上还有时间限制和购买限制,一个人只能在秒杀时间段内购买一单,同时如果库存里面没有了,也购买失败。
梳理一下这里的数据表:
- 商品信息表:主要包含商品id,店铺id,商品名,商品描述,价格,类型(普通、限购、秒杀),状态(上下架)
- 普通商品库存表:主要包含商品id,库存
- 秒杀商品库存表:主要包含商品id,库存,限购数量,秒杀开始和结束时间
- 订单表:主要包含订单ID,商品id,购买用户
1.分布式ID的实现
时间戳+序列号的方式,或者说UUID
这个分布式ID写了一个封装了一个工具类,是用于订单ID,而不是优惠券ID!!!!
2.乐观锁解决普通优惠券超卖
产生问题:并发环境下,因为查询操作和下单操作不是原子操作,可能就会产生数据不一致,进而导致超卖。比如还剩下最后一个库存,此时AB线程来了查到都允许购买,他俩就都下单,这就是一份库存结果卖出去了两份。
解决方案:基于乐观锁的版本号改进方案。乐观锁思想是假设每一次读取数据时都不会有冲突,在实际的业务场景中,普通类商品确实如此,很少会有冲突,符合乐观锁的预设。但这里将查询到的库存代替版本号,并且在下单时,不必强制令此时的库存是否=查询到的库存才允许下单,只要此时的库存>0就允许下单,因为如果是强制等于,可能造成明明有库存,但会下单失败的情形。
3.分布式锁解决秒杀优惠券一人一单问题
使用的现成的Redisson,因为它不仅可以
互斥
高性能
自动过期
而且还有可重入锁,自动续期这俩功能
使用过程:
引入依赖——>配置Redisson客户端——>使用锁
1.引入依赖
1 | <dependency> |
2.配置Redisson客户端
1 |
|
3.修改一下使用锁的地方,其它的业务代码都不需要该
1 | // 3、创建订单(使用分布式锁) |
4.MQ异步下单秒杀优惠券
这里有几个文件,配置了
- VoucherOrderServiceImpl:处理用户请求,协调流程。
- seckill.lua:原子性检查库存和下单记录。
- MQSender 和 MQReceiver:通过消息队列异步处理订单。
- RabbitMQTopicConfig:配置消息路由。
整体执行流程
- 用户发起秒杀请求:用户调用
VoucherOrderServiceImpl.seckillVocher(voucherId)
,传入优惠券 ID - 执行 Lua 脚本 :
VoucherOrderServiceImpl
调用seckill.lua
,传入voucherId
和userId
,检查库存和下单记录:- 如果返回 1(库存不足)或 2(已下单),直接返回失败。
- 如果返回 0,进入下一步。
- 创建订单并发送消息:创建
VoucherOrder
对象,生成订单 ID,通过MQSender
将订单信息发送到seckillExchange
(路由键seckill.lua.message
)。 - 消息路由:
RabbitMQTopicConfig
配置的绑定确保消息从seckillExchange
路由到seckillQueue
。 - 接收并处理消息:
MQReceiver
从seckillQueue
接收消息,解析为VoucherOrder
,检查用户下单情况,扣减数据库库存,保存订单。
困惑点补充:
1.Lua脚本怎么检查库存和下单记录的
因为Redis保存了库存数量,以及用set集合了下单记录,所以可以很快查找到
五.点赞关注模块
1.点赞
使用Zset的原因
- 点赞这种高频变化的数据,如果我们使用MySQL是十分不理智的,因为MySQL慢、并且并发请求MySQL会影响其它重要业务,容易影响整个系统的性能,继而降低了用户体验。
- 不重复
Zset,key为业务名+UserID,value为点赞数
1.判断是否点赞可以用ZSet的ZSCORE
方法判断用户是否存在
1 | /** |
2.SortedList可以使用ZRANGE
方法实现范围查询
key为用户,value为点赞次数
1 | /** |
2.关注/共同关注
Set记录关注博主,对于共同关注,可以用两个Set求交集
1 | /** |