Redis学习之秒杀业务
如下图:
注意,秒杀库存信息和商品信息最好是独立的两张表,不要放在一起影响性能。
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 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();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//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);
return Result.ok(orderId);
}
订单超卖问题
出现的原因:有多个人同时下单,在库存扣减前大家查到的库存都大于0,所以都 触发了扣库存操作。
解决方法
1)悲观锁:假定每次并发都会冲突,所以干脆给操作整体加锁,将并发改为同步执行。 可以通过 synchronized 关键字实现。 优点是实现简单,缺点是严重影响性能(大家可以同时抢购)。
2)乐观锁:假定并发不一定会冲突,所以不加锁,而是通过判断数据是否在查出来之后被其他线程修改过,来决定是否允许操作。
乐观锁主要有版本号法和 CAS 两种实现方式。
版本号法:
给数据增加一个版本号字段,每次修改操作版本号 + 1,就可以通过版本号来判断数据是否有被修改。
CAS 是对乐观锁的简化,即直接用一个每次都会查询和更新的字段来代替版本号,比如库存 stock 字段:
优点:性能好
缺点:存在成功率低的问题(很多人查到的版本号是一样的,结果只能有一个人操作成功),可以使用分段锁来改进。比如将 100 个库存分为 10 份,大家分别抢这 10 份。
对于以上这种场景,其实不用判断 stock 是否变化,可以直接判断 stock > 0,从而保障成功率。
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
一人一单
优惠券或者秒杀活动的目的是为了吸引新用户,因此不能让一个用户把所有东西都抢走了。所以需要额外判断用户当前下单数是否 > 0。
这一步操作在多线程场景下依然会出现问题:新用户第一次进来同时抢 10 次,结果判断下单数都是 0,然后就都抢成功了。
所以还是需要加锁,因为订单是新创建的数据,所以无法使用乐观锁,使用悲观锁实现。
单机实现
单机部署后端服务器时,可以使用 Java 自带的 Synchronized 关键字作为悲观锁。 要注意以下几个细节问题:
1)synchronized 锁的范围不能太大,不能锁住整个对象,会严重影响性能。因为是一人一单,所以可以每个用户一把独立的锁。 注意,锁住对象时要用 toString.intern,保证同 id 的用户始终是同一个对象:
synchonized(userId.toString().intern()) {
xxx
}
2)synchonized 必须在使用 @Transactional 注解的方法外层使用,因为 @Transactional 是使用动态代理,在方法执行结束后才提交事务。如果把 synchronized 写在事务方法内,提交事务前锁已经释放,但此时数据还未更新,其他线程依然能获取锁并顺利执行。 3)调用事务方法时不能用 this 对象,因为 @Transactional 注解实际上是调用 Spring 生成的代理对象的方法,如果调用 this 对象的方法会无法使用事务功能,所以要获取代理对象并调用。
初步代码:增加一人一单逻辑
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 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.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//6,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//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);
return Result.ok(orderId);
}
存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
注意:在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
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", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
,但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为: intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
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", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:
在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度
但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
分布式实现
Synchronized 关键字只对单个 JVM 有效,多机部署时还是可能会同时有多个不同 JVM 的线程访问已加锁的方法。
如下图:
因此,我们不能把锁存储到单个服务器上,而是应该使用一个集中的存储来管理锁,所有进程都能读到它。 这就需要分布式锁。