登录
基于session登录
短信验证码登录
配置登录拦截器
向 Spring MVC 框架中添加拦截器,LoginInterceptor
是一个自定义的拦截器,用于拦截用户的登录请求。
-
excludePathPatterns
这一句是设置拦截器需要放行的请求路径列表。 -
"/user/code", "/user/login", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**"
: 这是放行请求的路径列表。这些路径表示用户在访问这些请求时,不会被拦截器拦截。
封装UserDTO,返回给前端的Entity
数据使用BeanUtil
工具类转成DTO
@Data
是一个注解,通常与 Lombok 库一起使用,它可以自动生成类的 getter、setter、toString()
、equals()
和 hashCode()
方法,从而简化了 Java 类的编写。DTO 类用于在不同的层之间传输数据。
Session集群共享问题
什么是Session集群共享问题
当一个网站或应用部署在多个服务器上时,用户的会话数据(比如登录状态、购物车内容等)需要在这些服务器之间共享,以保证用户在不同服务器上的操作是一致的。然而,如果不采取特殊的措施,这些服务器之间并不会自动共享会话数据,而是会在各自的服务器上保存各自的会话数据。这就导致了会话数据的不一致性,造成了用户体验的问题,比如用户在一个服务器上登录了,但是在另一个服务器上却看不到登录状态。
如何解决Session集群共享问题?
方案一:Session拷贝(不推荐),Tomcat提供了Session拷贝功能,通过配置Tomcat可以实现Session的拷贝,但是这会增加服务器的额外内存开销,同时会带来数据一致性问题。
Tomcat 是一个流行的开源的 Java 服务器,可以将 Java Web 应用程序部署到服务器上并提供服务。使用 Tomcat 的会话复制功能,也就是在集群环境下,将一个节点(服务器)的会话数据复制到其他节点上,从而实现会话共享。Tomcat 提供了基于组播(Multicast)或 TCP 的会话复制机制,通过复制会话数据,所有节点都可以获取到相同的会话信息,实现了会话共享的效果。
方案二:Redis缓存(推荐),Redis缓存具有Session存储一样的特点,基于内存、存储结构可以是key-value结构、数据共享。
Redis缓存相较于传统Session存储的优点:
高性能和可伸缩性:Redis 是基于内存的缓存系统,读写速度非常快。由于数据存储在内存中,而不是硬盘上,因此可以实现毫秒级的响应时间,适用于高并发、低延迟的场景。此外,Redis 还支持集群和分片技术,可以实现水平扩展,处理大规模的并发请求。
可靠性和持久性:Redis 提供了持久化机制,可以将内存中的数据定期或异步地写入磁盘,以保证数据的持久性。这样即使发生服务器崩溃或重启,会话数据也可以被恢复。
丰富的数据结构:Redis 不仅仅是一个键值存储数据库,它还支持多种数据结构,如字符串、列表、哈希、集合和有序集合等。这些数据结构的灵活性使得可以更方便地存储和操作复杂的会话数据。
分布式缓存功能:Redis 作为一个高效的缓存解决方案,可以用于缓存会话数据,减轻后端服务器的负载。与传统的 Session 存储方式相比,使用 Redis 缓存会话数据可以大幅提高系统的性能和可扩展性。
可用性和可部署性:Redis 是一个强大而成熟的开源工具,有丰富的社区支持和活跃的开发者社区。它可以轻松地与各种编程语言和框架集成,并且可以在多个操作系统上运行。
Hash 结构与 String 结构类型的比较:
- String 数据结构是以 JSON 字符串的形式保存,更加直观,操作也更加简单,但是 JSON 结构会有很多非必须的内存开销,比如双引号、大括号,内存占用比 Hash 更高
- Hash 数据结构是以 Hash 表的形式保存,可以对单个字段进行CRUD,更加灵活
Redis替代Session需要考虑的问题:
- 选择合适的数据结构,了解 Hash 比 String 的区别
- 选择合适的key,为key设置一个业务前缀,方便区分和分组,为key拼接一个UUID,避免key冲突防止数据覆盖
- 选择合适的存储粒度,对于验证码这类数据,一般设置TTL为3min即可,防止大量缓存数据的堆积,而对于用户信息这类数据可以稍微设置长一点,比如30min,防止频繁对Redis进行IO操作
配置登录拦截器
单独配置一个拦截器用户刷新Redis中的token:在基于Session实现短信验证码登录时,我们只配置了一个拦截器,这里需要另外再配置一个拦截器专门用户刷新存入Redis中的 token,因为我们现在改用Redis了,为了防止用户在操作网站时突然由于Redis中的 token 过期,导致直接退出网站,严重影响用户体验。那为什么不把刷新的操作放到一个拦截器中呢,因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求,对于哪些不需要登录校验的请求是不会走拦截器的,刷新操作显然是要针对所有请求比较合理,所以单独创建一个拦截器拦截一切请求,刷新Redis中的Key
登录拦截器:
刷新token的拦截器:
将自定义的拦截器添加到SpringMVC的拦截器表中:
店铺数据查询
根据 id 查询商铺缓存
对于店铺的详细数据,这种数据变化比较大,店家可能会随时修改店铺的相关信息(比如宣传语,店铺名等),所以对于这类变动较为频繁的数据,我们是直接存入Redis中,并且设置合适的有效期(后面还会进行优化,确保Redis和MySQL的数据一致性,以及解决缓存常见的三大问题)
查询店铺类型
对于店铺类型数据,一般变动会比较小,所以这里我们直接将店铺类型的数据持久化存储到Redis中
数据一致性问题
常见的缓存更新策略:
主动更新:手动编码实现缓存更新,在修改数据库的同时更新缓存。双写方案(人工编码方式,缓存调用者在更新完数据库后再去更新缓存),读写穿透方案(将读取和写入操作首先在缓存中执行,然后再传播到数据存储),写回方案(调用者只操作缓存,其他线程去异步处理数据库,实现最终一致)。
删除缓存模式:更新数据时更新数据库并删除缓存,查询时更新缓存,无效写操作较少
先操作数据库:先更新数据库,再删缓存
保证缓存与数据库的操作的原子性:对于单体系统1,将缓存与数据库操作放在同一个事务中;对于分布式系统2,利用TCC(Try-Confirm-Cancel)等分布式事务方案
- 对于低一致性需求,可以使用内存淘汰机制。例如店铺类型数据的查询缓存
- 对于高一致性需求,可以采用主动更新策略,并以超时剔除作为兜底方案。例如店铺详情数据查询的缓存
常见解决缓存穿透的解决方案:缓存空对象 布隆过滤
- 缓存雪崩的常见解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略,比如快速失败机制,让请求尽可能打不到数据库上
- 给业务添加多级缓存
缓存击穿的常见解决方案:互斥锁 逻辑过期
基于互斥锁解决缓存击穿
优惠券秒杀
自增ID存在的问题
当用户抢购时,就会生成订单并保存到tb_voucher_order
这张表中,而订单表如果使用数据库自增ID就存在一些安全性问题
在MySQL中,表最多可以存储的记录数取决于多个因素,包括数据库版本、操作系统和硬件配置等。下面是一些常见的限制:
- 行数限制:在MySQL 5.7及之前的版本中,InnoDB和XtraDB存储引擎的行数限制为最大约为64亿行。而在MySQL 8.0及以后的版本中,它们的行数限制可达到理论上的最大值,大约是1844万亿行。
- 数据库文件大小限制:每个InnoDB表的存储大小受到所使用文件系统的限制。对于InnoDB表,默认情况下,数据库文件的大小限制取决于操作系统和文件系统,通常在几TB或更高。但是,这也可能受到特定的操作系统和文件系统的限制。
- 硬件资源限制:实际上,表的记录数还受到可用硬件资源,如磁盘空间、内存和处理能力的限制。当数据库文件较大时,磁盘空间变得关键,而在执行查询时,内存和处理能力可影响读写性能。
分布式ID(也可以叫全局唯一ID)
- UUID
- Redis自增
- 数据库自增
- snowflake算法(雪花算法)
这里我们使用自定义的方式实现:时间戳+序列号+数据库自增
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息,比如时间戳、UUID、业务关键词
优惠券秒杀接口实现
打开数据库然后添加秒杀券
单体下一人多单超卖问题
常见解决方案:
- 悲观锁,认为线程安全问题一定会发生,因此操作数据库之前都需要先获取锁,确保线程串行执行。常见的悲观锁有:synchronized、lock
- 乐观锁,认为线程安全问题不一定发生,因此不加锁,只会在更新数据库的时候去判断有没有其它线程对数据进行修改,如果没有修改则认为是安全的,直接更新数据库中的数据即可,如果修改了则说明不安全,直接抛异常或者等待重试。常见的实现方式有:版本号法、CAS操作、乐观锁算法
悲观锁和乐观锁的比较
- 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
- 悲观锁比乐观锁的冲突处理能力低:悲观锁在冲突发生时直接阻塞其他线程,乐观锁则是在提交阶段检查冲突并进行重试。
- 悲观锁比乐观锁的并发度低:悲观锁存在锁粒度较大的问题,可能会限制并发性能;而乐观锁可以实现较高的并发度。
- 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。
CAS:
- 比较(Compare):将内存地址V中的值与预期值A进行比较。
- 判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
- 交换(Swap):使用新的值B来更新内存地址V中的值。
CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。当CAS操作失败时,需要不断地进行重试,会占用CPU资源。如果重试次数过多或者线程争用激烈,可能会引起性能问题。如果多个线程同时对同一内存地址进行CAS操作,只有一个线程的CAS操作会成功,其他线程需要重试或放弃操作。
乐观锁解决一人多单超卖问题
版本号法
CAS
悲观锁解决超卖问题
锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低
- 锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)
- 我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题
- Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。
集群下的一人一单超卖问题
-
搭建集群并实现负载均衡,在IDEA中启动两个SpringBoot程序,一个端口号是8081,另一个端口是8082
- 准备两个接口,用于模拟集群下的多用户重复下单
- 使用接口发送请求
- 结论:有两个JVM,所以
synchronized
会失效!
分布式锁
- 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式锁的特点:
- 多线程可见。
- 互斥。分布式锁必须能够确保在任何时刻只有一个节点能够获得锁,其他节点需要等待。
- 高可用。分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,仍然能够正常工作。(容错性)当持有锁的节点发生故障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。
- 高性能。分布式锁需要具备良好的性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。
- 安全性。(可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。
分布式锁的常见实现方式:
- 基于关系数据库:可以利用数据库的事务特性和唯一索引来实现分布式锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。
- 基于缓存(如Redis):使用分布式缓存服务(如Redis)提供的原子操作来实现分布式锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。
- 基于ZooKeeper:ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。
Redisson:Redssion是一个十分成熟的Redis框架,功能也很多,比如:分布式锁和同步器、分布式对象、分布式集合、分布式服务,各种Redis实现分布式的解决方案。
Redisson分布式锁原理:
- 如何解决可重入问题:利用hash结构记录线程id和重入次数。
- 如何解决可重试问题:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。
- 如何解决超时续约问题:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间。
- 如何解决主从一致性问题:利用Redisson的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂
秒杀优化
最开始我们的遇到自增ID问题,我们通过实现分布式ID解决了问题;后面我们在单体系统下遇到了一人多单超卖问题,我们通过乐观锁解决了;我们对业务进行了变更,将一人多单变成了一人一单,结果在高并发场景下同一用户发送相同请求仍然出现了超卖问题,我们通过悲观锁解决了;由于用户量的激增,我们将单体系统升级成了集群,结果由于锁只能在一个JVM中可见导致又出现了,在高并发场景下同一用户发送下单请求出现超卖问题,我们通过实现分布式锁成功解决集群下的超卖问题;由于我们最开始实现的分布式锁比较简单,会出现超时释放导致超卖问题,我们通过给锁添加线程标识成功解决了;但是释放锁时,判断锁是否是当前线程 和 删除锁两个操作不是原子性的,可能导致超卖问题,我们通过将两个操作封装到一个Lua脚本成功解决了;为了解决锁的不可重入性,我们通过将锁以hash结构的形式存储,每次释放锁都value-1,获取锁value+1,从而实现锁的可重入性,并且将释放锁和获取锁的操作封装到Lua脚本中以确保原子性。最最后,我们发现可以直接使用现有比较成熟的方案Redisson来解决上诉出现的所有问题🤣,什么不可重试、不可重入、超市释放、原子性等问题Redisson都提供相对应的解决方法
异步秒杀优化
- 同步(Synchronous)是指程序按照顺序依次执行,每一步操作完成后再进行下一步。在同步模式下,当一个任务开始执行时,程序会一直等待该任务完成后才会继续执行下一个任务。
- 异步(Asynchronous)是指程序在执行任务时,不需要等待当前任务完成,而是在任务执行的同时继续执行其他任务。在异步模式下,任务的执行顺序是不确定的,程序通过回调、事件通知等方式来获取任务执行的结果。
显然异步的性能是要高于同步的,但是会牺牲掉一定的数据一致性,所以也不是无脑用异步,要根据具体业务进行分析,这里的下单是可以使用异步的,因为下单操作比较耗时,后端操作步骤多,可以进行拆分