https://blog.51cto.com/u_16099300/10379143
分布式锁,redis相关学习资料:
https://edu.51cto.com/video/4196.html
前言
本章节主要实现限时、限量优惠券秒杀功能,并利用分布式锁解决《超卖问题》、《一人一单问题》。
一.优惠券下单基本功能实现
1.功能介绍及流程图
2.代码实现
登录后复制
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdworker redisIdworker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher==null){
return Result.fail("优惠券不存在!");
}
// 2.判断秒杀是否开始,开始时间在当前时间之后
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束,结束时间在当前时间之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已结束!");
}
// 4.判断库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdworker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
一.超卖问题
1.问题分析
线程1,执行完,查询,查到库存为1,判断库存大于0,然后进行扣减库存操作,在这之间,线程2,线程3,也都进行了查询,查询到的库存也都是1,判断库存也都大于0,都进行了扣减库存操作,导致库存只有1个,卖出了3次。
2.解决方案(悲观锁、乐观锁)
悲观锁: 添加同步锁,让线程串行执行
优点: 简单粗暴
缺点: 性能一般
乐观锁: 不加锁,在更新时判断是否有其它线程在修改
优点: 性能好
缺点: 存在成功率低的问题
悲观锁是通过加锁的方式,让原本并发执行的变成串行执行,保证了线程安全,但是大大降低了执行效率,悲观锁实现较为简单,本文主要研究乐观锁的实现方式。
2.1 乐观锁实现方式-版本号法
版本号法: 数据库冗余一个版本字段,每次查询库存的时候,就将这个版本字段也查询出来,在更新的时候,版本号加一,条件加上版本号等于查询到的版本,如果版本被根据了,数据库的update语句就会执行失败。
如上图,线程1,已查询到的库存为1,版本号为1,同时线程2也查询到库存为1,版本号为1,线程1在更新的时候,将版本加1 ,version = version + 1,同时更新条件加上 and version =1,更新完成,version 值变成2,线程2更新操作的时候,同样会将版本加1,version = version + 1,更新条件加上and version =1,但是此时的version已经被线程1更新为2,导致线程2的更新操作会失败,保证了线程安全。
2.2 乐观锁实现方式-CSA法
CSA法: 用需要修改的数据本身来判断数据是否已修改,利用库存本身的数据,来代替了版本,如上图线程1查到的库存是1,更新库存时,update语句加上and stock =1,执行成功,库存变成0,线程2,查到库存是1,同样更新库存时,update语句加上and stock =1,但此时数据已经被线程1改为0,导致线程2的更新操作会失败,保证了线程安全。
2.2.1 代码实现
只需要对更新数据库的语句进行修改
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock=stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock())//where id=? and stock=?
.update();
该方法解决了线程安全问题,但是带来了新的问题,失败率将会大大提升,如库存为100,100个线程并发执行,同时查到了库存为100,更新时,99个线程都会失败,只有一个会成功,按照正常的业务流程,100个库存,100个线程并发执行,应该都会成功,下面对扣减库存的逻辑进一步优化,解决失败率高的问题。
执行update语句时不用and stock =查到的值,只需要将条件改为 and stock >0,就解决了失败率高的问题。
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock=stock -1
.eq("voucher_id", voucherId).gt("stock",voucher.getStock())//where id=? and stock > 0
.update();
二.一人一单
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
1.单机应用下通过synchronized解决一人一单
引入依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
启动类开启暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true) //开启暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
具体实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdworker redisIdworker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
// 2.判断秒杀是否开始,开始时间在当前时间之后
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束,结束时间在当前时间之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.1一人一单
Long userId = UserHolder.getUser().getId();
// 同一个用户加锁,不同用户加不同的锁,toString()底层每次都new了一个新的对象,
// 会造成同一个用户加的是不同的锁
// intern()方法是去常量池找跟字符串值一样的地址,避免同一个用户加了不同的锁
synchronized (userId.toString().intern()) {
// 事务要生效,需要spring对当前类做了动态代理,拿到代理对象,用代理对象做了事务处理,如果用this调用方法,就是用的是
// 当前对象,造成实务无效,需要获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, voucher, userId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher voucher, Long userId) {
// 5.2 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucher).count();
// 5.3 判断订单是否存在
if (count > 0) {
return Result.fail("用户已购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock=stock -1
.eq("voucher_id", voucherId).gt("stock", voucher.getStock())//where id=? and stock > 0
.update();
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdworker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
}
2.分布式系统下通过Redis分布式锁解决一人一单
分布式系统每个服务会部署很多个实例,每个实例对一个一个独立的JVM,在每个实例内部能通过synchronized实现线程的互斥,但是实例和实例直接就无法试下线程互斥,只能通过分布式锁来解决。
2.1 代码实现
锁的接口类
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec
* @return
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
实现类
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @auther Kou
* @date 2022/7/11 22:33
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 1.获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 2.获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 3.success是Boolean包装类型,而方法的返回值是基本类型boolean,直接返回success会进行拆箱,
// 如果success是null,就会报空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 1.获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 2.获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 3.判断线程标识和锁标识是否一样
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
业务代码改造
@Resource
private StringRedisTemplate stringRedisTemplate;
public Result seckillVoucher01(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
// 2.判断秒杀是否开始,开始时间在当前时间之后
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束,结束时间在当前时间之前
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.1一人一单
Long userId = UserHolder.getUser().getId();
// 6.创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order" + userId, stringRedisTemplate);
// 7.获取锁
boolean isLock = lock.tryLock(1200);
// 8.判断是否获取锁成功
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, voucher, userId);
} finally {
// 9.释放锁
lock.unlock();
}
}