1. 优惠卷秒杀
1.1 使用乐观锁解决优惠卷超卖问题
- 乐观锁的原理就是cas,在数据库中添加版本字段,每次更新数据时都会先检查是否是预期值,只有达到预期值时才会成功更新。
- 使用乐观锁带来的问题:优惠卷不会出售完的问题。
- 因为在进行cas的过程中,很可能其他线程已经修改了优惠卷的数量,这时就会抢购失败,导致优惠卷过剩,所以我们在设置预期值时并不是在原有的基础上-1,而是只要大于0就可以抢购成功。
1.2 每个用户只能抢购一张优惠卷
- 一开始是在整个方法上加上synchronized,避免同一用户可以抢购到多张优惠卷。
- 因为在方法上使用synchronized,其锁对象为this,这样每个抢购优惠卷的线程都会变成串行,性能太差了,而我们需要当用户ID相同时才需要串行,因此使用用户的ID作为对象锁。
- 因为我们需要的是用户ID的值一样,而不是对象,所以需要调用toString方法把Long对象转换成String对象,而toString方法每次的调用都是生成不同的字符串,达不到我们想要的效果,所以我们需要调用intern方法,来保证每次的请求的值一样。
intern方法:将一个string对象放入到字符串池中,如果字符串池中已经有相同的string值就返回字符串池中的对象,没有就将当前字符串放入到字符串池中,然后再返回
1.3 事务的提交与所释放时机的不一致问题
- 因为当减少优惠卷的业务逻辑是放在synchronized代码块中的,当代码块中的代码执行完毕后就会释放锁;而事务的提交是整个方法执行完才提交(使用@Transactional注解),所以当一个锁释放后,另一个线程进入到方法中,这时可能事务还没有提交。
- 解决办法:在进入整个方法前就加锁,锁对象为用户ID。给整个方法加时引起的事务失效。
- 事务失效:当前类下使用一个没有事务的方法去调用一个有事务的方法时。事务失效
- 原因:Spring事务基于Spring AOP,Spring AOP底层用的动态代理,并不会经过代理。
- 解决方案:通过AopContext.currentProxy()拿到当前代理对象。(使用此方法还需要引入aspectjweaver包,并且在启动上添加@EnableAspectJAutoProxy暴露代理对象)
**两种动态代理: **
- 基于接口代理(JDK代理)
- 基于接口代理,凡是类的方法非public修饰,或者用了static关键字修饰,那这些方法都不能被Spring AOP增强。
- 基于CGLib代理(子类代理)
- 基于子类代理,凡是类的方法使用了private、static、final修饰,那这些方法都不能被Spring AOP增强。
1.4 redis分布式锁解决分布式下的超卖问题
- 原因:多个服务器,就有多个jvm,每个服务器都有自己的监视器,不能达到互斥的效果。
- 解决办法: 使用Redis分布式锁.
1.4.1 实现只有同一用户才会串行
- 在给锁设置名字时加上用户的ID(redis中的key就是锁名)。
1.4.2 为什么获取锁就直接返回失败
- 因为获取锁失败的情况下就只有用户多次抢购,而这种情况是不被允许的。
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
1.4.3 解决Redis分布式锁误删的情况
- 产生的原因:一个线程得到锁后,此业务进入了堵塞,这时锁的过期时间到了,被释放掉了,线程二就可以获得锁,这时线程一完成了业务,就会把线程二的锁给删除了。
- 解决方案:给每个服务器都生成上一个UUID的标识+线程的ID,存入到value值中,当去释放锁的时候就需要先判断当前线程的标识和锁中存储的value值是否一致,只有一致时才能释放锁。
- 使用UUID而不使用线程的原生id是因为线程的id都是从1开始自增的,在分布式的情况下特别容易重复。
- 使用UUID而不使用线程的原生id是因为线程的id都是从1开始自增的,在分布式的情况下特别容易重复。
1.4.3 分布式锁的原子性问题引起的误删
- 问题:在判断锁标识是否是自己和释放锁是两个操作,并不是原子性的,线程一在判断锁标识后陷入堵塞后锁因为超时等待就释放掉了锁、(如jvm中垃圾回收的stop the world),这时线程二来拿到了锁,线程一这时再去释放锁因为已经经过锁标识判断成功了就又会删除掉线程二的锁。
- 解决办法:使用lua脚本来保证其原子性
- 为什么不使用事务来保证原子性?:
- redis的事务并没有完全的原子性。
1.4.4 优化Redis秒杀
- 我们在redis中缓存好优惠库信息和每次抢购优惠卷成功的用户id,用户id用list存储起来。
- 这样关于判断优惠卷库存是否充足和重复下单就可以不用到达数据库。
- 每次抢购成功后redis的库存需要自减,当秒杀活动结束后再写回到数据库。
- 将判断优惠卷库存是否充足,重复下单和减少缓存中的库存都加入到lua脚本中。lua脚本的返回值,1代表库存不足,2代表重复下单,0代表下单成功。
- 下单成功后将优惠卷id,用户id和订单id存入到阻塞队列中。
- 返回订单id给前台。
2. 全局ID的设计
2.1 全局ID生成器
解决自增ID的劣势:
- ID呈现规律性,比如今天得到的订单ID比昨天大70,就知道商城昨天的销售量。
- 避免多表下时,ID的唯一性。
为什么不使用UUID来实现全局ID
- 因为UUID没有自增性,在数据库构建索引时性能有所下降。
2.2 Redi实现全局唯一ID
使用Redis的原因:因为的自增是原子性的,不使用AtomicInteger的原因在于分布式的情况下,每个服务器中的AtomicInteger都不同。 ID的类型为long类型=8字节=64位
- 全局唯一ID(64位) = 时间戳(前32位) + 序列号(后32位)
- 时间戳(Long):当前时间-初始时间=相差多少秒
- 序列号(Long):通过Redis的自增来得到的
2.3 因为ID的类型为long而不是字符串,唯一ID如何拼接时间戳和序列号的?
让时间戳左移32位,这时时间戳的后32位全为0,再与序列号进行或运算,就可以完成拼接。
2.4 redis中key值的设计
key值=业务名称+当天时间 为什么要加上当月时间?
- 因为当一个业务一直运行时,value会一直增加,迟早会到达上限。
3. 使用session实现用户登录
每个浏览器在访问服务器时会自动创建session,并且在cookie中加入sessionId,这样每次访问服务器的时候就会找到对应的session。
3.1 单点服务器下的session用户登陆
- 用户登陆后保存在session中
- 使用拦截器让每个请求在进入controller层前通过sessionId找到session中的user信息
- 保存到ThreaLocal中
- 为什么不直接使用session.getAttribute("user"),而使用ThreaLocal?
- 登陆不一定用session实现,后期可能会用Redis代替session
- 在业务层获取session需要调用servlet相关的API,或者把session传递到service层。用起来不太方便。而且session毕竟是web层的东西,尽量不要进业务层。
3.2 使用Redis替代session解决分布式情况下的session不能共享的情况
session共享问题:请求切换到不同的Tomcat服务器时不能共享session。而且服务器之间复制session也会有一定的延迟。
使用Redis替代session需要考虑以下三个问题:
- 选择合适的数据结构。
- 使用String比较直观,里面储存json格式。
- 使用Hash结构,每个字段都是独立储存,可以针对单个字段进行修改,而String需要覆盖整个字符串。并且内存占用更少,因为json格式中,我们每条用户信息json中都需要保存字段名称。
- 选择合适的key。
- 一个要考虑到唯一性,如果使用sessionId,因为使用不同的浏览器访问时,sessionId是不同的。
- 考虑到客户端提取时的校验,一定要方便携带。
- 保存session不使用手机号作为key,而使用token,是因为key值是保存在客户端的,直接使用手机有泄露的风险。
- 选择合适的储存粒度。
- 在redis中只保存一些页面需要的信息,节省空间。 步骤:
- 保存验证码到Redis,使用手机号作为key值,因为验证码的key不需要保存到客户端,不用担心泄露。
- 登陆用户,先确认验证吗是否正确,正确后通过手机号寻找用户,然后使用UUID生成token,返回给客户端,并且使用token作为key,使用Hash结构储存。
- 在拦截器中进行校验,如果请求头带有toekn,且可以通过toekn在redis中找到用户信息,就把用户信息保存到ThreadLocal中。
- 在拦截器中刷新token的有效期,只要客户端与服务器一直进行交互,就会一直续费。
4. Redis实现分布式锁
实现分布式的三种方式:
业务流程如下:
需要注意如下几点:
- Boolean.TRUE.equals(success):在自动拆箱的适合要注意避免null。
- 要给锁加上过期时间避免死锁。
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
4.1 Redis的分布式锁优化
基于setnx实现分布式锁存在在下面的问题:
- 不可重入:同一个线程无法多次获取同一把锁。
- 不可重试:获取锁只尝试一次就返回false,没有重试机制。
- 超时释放:锁超时释放虽然可以避免死锁,当如果业务执行耗时较长,也会导致锁的释放,存在安全隐患。